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
@@ -0,0 +1,414 @@
1
+ /**
2
+ * Drive a CozyClay rig from a decoded ARDY motion (see npz.js) with
3
+ * POSITIONAL SKINNING — the same method the viser demo's Mixamo avatar uses
4
+ * (interactive_demo/mixamo_avatar.py compute_bone_transforms):
5
+ *
6
+ * boneWorldPos[j] = posed[f][j] + R[f][j] @ (mixamoBind[j] - ardyNeutral[j])
7
+ * boneWorldQuat[j] = R[f][j] @ bindQuat[j]
8
+ *
9
+ * where R[f][j] is the ARDY joint's GLOBAL rotation (FK over local_rot_mats)
10
+ * and ardyNeutral is CoreSkeleton27's neutral pose (cskel27-neutral.js,
11
+ * exported from the box). The Mixamo bind offset is measured against the
12
+ * ARDY neutral joint, so no assumption about the rig's local rotation axes
13
+ * or rest-frame orientation is ever made — the failure mode of the old
14
+ * local-rotation retarget (basis = Rb^T @ L @ Rb), which rotated limbs
15
+ * around slightly wrong axes and let the proportion gap drift the feet off
16
+ * the npz's own contact points.
17
+ *
18
+ * Two deliberate deviations from the viser avatar, both scene-related:
19
+ *
20
+ * 1. Uniform scale s. The avatar assets are metre-scale like ARDY; the
21
+ * CozyClay rigs keep their own proportions instead of deforming onto
22
+ * the ARDY skeleton's. s = rig leg height / ARDY neutral leg height
23
+ * (measured per rig from the bind pose), so posed_joints map into rig
24
+ * units with feet planting exactly when the npz puts them on the floor.
25
+ * 2. Anchoring. The viser demo plays every clip at its own origin;
26
+ * CozyClay anchors the clip's root to the blocking (waypoint /
27
+ * Subject 1 position). The whole clip is shifted so the anchor frame's
28
+ * root sits at the rig origin — the Character group stays static at the
29
+ * scene anchor during playback (rotation still comes from the group's
30
+ * `rot`, the clip-to-scene yaw), and the full root trajectory, vertical
31
+ * included, now lives in the bones.
32
+ *
33
+ * Joint->bone mapping follows the viser avatar (_MIXAMO_TO_CORE): Mixamo's
34
+ * three spine bones map onto core Spine1/Spine2/Spine3 (core Spine and the
35
+ * HandEnd leaves drive no bone — their motion folds into the mapped joints'
36
+ * global transforms, and HeadTop_End/Toe_End/finger bones simply follow
37
+ * their parents). Bones the map does not touch keep their bind transforms.
38
+ */
39
+
40
+ import * as THREE from "three";
41
+ import { CSKEL27_JOINTS } from "./cskel27.js";
42
+ import { CSKEL27_NEUTRAL } from "./cskel27-neutral.js";
43
+ import { globalRotations } from "./convert.js";
44
+ import { normalizeBoneName } from "../poses.js";
45
+
46
+ /** cskel27 joint -> Mixamo bone name, mirroring the viser avatar's
47
+ * _MIXAMO_TO_CORE (null = no bone is driven by this joint).
48
+ *
49
+ * Deliberate deviation: the wrist (Hand) and thumb root (HandThumb1) are
50
+ * NOT driven. The viser avatar collapses all hand skin weights rigidly, so
51
+ * ARDY's wrist orientation is invisible there; on the real finger rig it
52
+ * rotates the whole hand and presents the curled fingers from a different
53
+ * palm angle than the rest state (a constant convention offset, most
54
+ * visible as "fist on one side, flat hand on the other"). Riding the
55
+ * forearm at the bind-relative wrist keeps the hand's presentation exactly
56
+ * the natural relaxed hand at all times; the lost wrist flexion is a few
57
+ * degrees in these clips and reads worse than it helps. */
58
+ const SKINNING_MAP = CSKEL27_JOINTS.map((name) => {
59
+ switch (name) {
60
+ case "Spine":
61
+ case "RightHandEnd":
62
+ case "LeftHandEnd":
63
+ case "RightHand":
64
+ case "LeftHand":
65
+ case "RightHandThumb1":
66
+ case "LeftHandThumb1":
67
+ return null;
68
+ case "Spine1":
69
+ return "Spine";
70
+ case "Spine2":
71
+ return "Spine1";
72
+ case "Spine3":
73
+ return "Spine2";
74
+ default:
75
+ return name;
76
+ }
77
+ });
78
+
79
+ const ARDY_NEUTRAL_MIN_Y = -0.9544128; // toe depth under the hips-origin neutral pose
80
+
81
+ /* No finger forcing: ARDY's cskel27 stops at the wrist, and the fingers
82
+ * simply keep the rig's own bind pose at all times (the same presentation
83
+ * as the viser avatar, which has no finger articulation either). Nothing
84
+ * in this module writes finger bones. */
85
+
86
+ /* --- rig preparation ------------------------------------------------------ */
87
+
88
+ /** The project's bone-matching rule: normalised names equal, or one is a
89
+ * suffix of the other (`mixamorighips` vs bare `hips`). First depth-first
90
+ * match wins — Mixamo FBX nests an identity "skinned" copy of every bone
91
+ * under the control bone, and the nested copies are never written (same
92
+ * rule poses.js and export.js use). */
93
+ function findBone(rig, mixamoName) {
94
+ const target = normalizeBoneName(mixamoName);
95
+ let found = null;
96
+ rig.traverse((object) => {
97
+ if (found || !object.isBone) return;
98
+ const norm = normalizeBoneName(object.name);
99
+ if (norm === target || norm.endsWith(target)) found = object;
100
+ });
101
+ return found;
102
+ }
103
+
104
+ /** Bind-pose quaternions per bone, from the poseBind snapshot primed at clone
105
+ * time; a lazy fallback mirrors export.js's restOf() for rigs that never
106
+ * mounted Character. */
107
+ const bindFallback = new WeakMap();
108
+
109
+ function bindsOf(rig) {
110
+ const primed = rig.userData && rig.userData.poseBind;
111
+ if (primed) return primed;
112
+ let map = bindFallback.get(rig);
113
+ if (!map) {
114
+ map = new Map();
115
+ rig.traverse((object) => {
116
+ if (object.isBone) {
117
+ const q = object.quaternion;
118
+ map.set(object, { x: q.x, y: q.y, z: q.z, w: q.w });
119
+ }
120
+ });
121
+ bindFallback.set(rig, map);
122
+ }
123
+ return map;
124
+ }
125
+
126
+ /** Per-rig positional-skinning preparation, computed once from the bind
127
+ * pose and cached. */
128
+ const rigPreps = new WeakMap();
129
+
130
+ function prepOf(rig) {
131
+ let prep = rigPreps.get(rig);
132
+ if (prep) return prep;
133
+ const binds = bindsOf(rig);
134
+
135
+ // Rig-space bind transform of every node on the path to each mapped
136
+ // bone: bind local rotations (poseBind) composed with the current —
137
+ // never re-positioned outside this module — local translations. Computed
138
+ // BEFORE this module ever writes a bone transform, so positions are the
139
+ // FBX bind translations.
140
+ // Rig-space = the space BELOW the rig root: the root's own transform
141
+ // (Character's position/rotation and the 0.01 centimetre scale) is
142
+ // applied by the scene on top and must never leak into bind transforms —
143
+ // with a scaled root it would shrink every bind matrix and bone locals
144
+ // would be written in metres into a centimetre hierarchy.
145
+ const worldByNode = new Map();
146
+ const walk = (node, parentMat) => {
147
+ const b = node.isBone ? binds.get(node) : null;
148
+ const q = b
149
+ ? new THREE.Quaternion(b.x, b.y, b.z, b.w)
150
+ : node.quaternion;
151
+ const local = new THREE.Matrix4().compose(node.position, q, node.scale);
152
+ const world = parentMat.clone().multiply(local);
153
+ worldByNode.set(node, world);
154
+ for (const child of node.children) walk(child, world);
155
+ };
156
+ for (const child of rig.children) walk(child, new THREE.Matrix4());
157
+
158
+ // Mapped bones and their bind transforms, in cskel27 index order.
159
+ const bones = new Array(CSKEL27_JOINTS.length).fill(null);
160
+ const bindPos = new Array(CSKEL27_JOINTS.length).fill(null);
161
+ const bindQuat = new Array(CSKEL27_JOINTS.length).fill(null);
162
+ const bindScale = new Array(CSKEL27_JOINTS.length).fill(null);
163
+ const parentBindWorld = new Array(CSKEL27_JOINTS.length).fill(null);
164
+ for (let j = 0; j < CSKEL27_JOINTS.length; j += 1) {
165
+ const mixamoName = SKINNING_MAP[j];
166
+ if (!mixamoName) continue;
167
+ const bone = findBone(rig, mixamoName);
168
+ if (!bone) continue;
169
+ const world = worldByNode.get(bone);
170
+ bones[j] = bone;
171
+ bindPos[j] = new THREE.Vector3().setFromMatrixPosition(world);
172
+ bindQuat[j] = new THREE.Quaternion().setFromRotationMatrix(world);
173
+ bindScale[j] = bone.scale.clone();
174
+ parentBindWorld[j] = worldByNode.get(bone.parent) ?? new THREE.Matrix4();
175
+ }
176
+
177
+ // Uniform scale s: the rig's own leg height over ARDY's, so posed_joints
178
+ // map into rig units keeping the character's authored proportions (the
179
+ // viser avatar deforms onto the ARDY skeleton instead; both plant the
180
+ // feet exactly). Leg height = hips bind Y minus the lowest mapped bind
181
+ // Y, against ARDY's neutral toe depth of 0.9544128 m.
182
+ const hipsIndex = CSKEL27_JOINTS.indexOf("Hips");
183
+ const hipsY = bindPos[hipsIndex] ? bindPos[hipsIndex].y : 0;
184
+ let lowestY = hipsY;
185
+ for (let j = 0; j < bindPos.length; j += 1) {
186
+ if (bindPos[j] && bindPos[j].y < lowestY) lowestY = bindPos[j].y;
187
+ }
188
+ const legHeight = hipsY - lowestY;
189
+ const scale = legHeight > 1e-6 ? legHeight / -ARDY_NEUTRAL_MIN_Y : 1;
190
+
191
+ // Per-joint bind offset against the floor-shifted ARDY neutral pose
192
+ // (viser: bind_joints[:, 1] -= bind_joints[:, 1].min()).
193
+ const offsets = new Array(CSKEL27_JOINTS.length).fill(null);
194
+ for (let j = 0; j < CSKEL27_JOINTS.length; j += 1) {
195
+ if (!bones[j]) continue;
196
+ const neutral = CSKEL27_NEUTRAL[j];
197
+ offsets[j] = new THREE.Vector3(
198
+ bindPos[j].x - scale * neutral[0],
199
+ bindPos[j].y - scale * (neutral[1] - ARDY_NEUTRAL_MIN_Y),
200
+ bindPos[j].z - scale * neutral[2],
201
+ );
202
+ }
203
+
204
+ // Chain parents for local conversion: the cskel parent is NOT always the
205
+ // bone's parent (core Spine drives no bone, so the driven Spine bone's
206
+ // real parent is the driven Hips bone). For every mapped bone find the
207
+ // nearest MAPPED ancestor bone and the bind-space relative matrix from
208
+ // that ancestor to the bone's parent; bones without a mapped ancestor
209
+ // hang off a static node whose bind world stays valid all playback.
210
+ const boneIndex = new Map();
211
+ for (let j = 0; j < bones.length; j += 1) {
212
+ if (bones[j]) boneIndex.set(bones[j], j);
213
+ }
214
+ const chainParent = new Array(CSKEL27_JOINTS.length).fill(-1);
215
+ const chainRel = new Array(CSKEL27_JOINTS.length).fill(null);
216
+ for (let j = 0; j < bones.length; j += 1) {
217
+ const bone = bones[j];
218
+ if (!bone) continue;
219
+ for (let node = bone.parent; node && node !== rig; node = node.parent) {
220
+ if (node.isBone && boneIndex.has(node)) {
221
+ chainParent[j] = boneIndex.get(node);
222
+ chainRel[j] = new THREE.Matrix4()
223
+ .copy(worldByNode.get(node))
224
+ .invert()
225
+ .multiply(worldByNode.get(bone.parent));
226
+ break;
227
+ }
228
+ }
229
+ }
230
+
231
+ prep = { bones, bindQuat, bindScale, parentBindWorld, chainParent, chainRel, scale, offsets };
232
+ rigPreps.set(rig, prep);
233
+ return prep;
234
+ }
235
+
236
+ /** cskel27 index -> control Bone (null where no bone is driven), kept as a
237
+ * public introspection hook for tests and snapshot/restore. */
238
+ export function motionBones(rig) {
239
+ return prepOf(rig).bones;
240
+ }
241
+
242
+ /** QA introspection: expose the cached per-rig prep (scale, offsets, chain
243
+ * parents) so headless checks can compare app-side prep against node-side
244
+ * expectations. Not used by the app itself. */
245
+ /** Recover the current ARDY-space root from the live retargeted rig.
246
+ *
247
+ * Positional skinning writes each mapped bone as
248
+ * bone = scale * root + globalRotation * bindOffset
249
+ * in rig space. Inverting that equation captures generated root motion plus
250
+ * any IK hips translation, so pose constraints never borrow placement from an
251
+ * unrelated reference clip. */
252
+ export function captureArdyRoot(rig) {
253
+ const prep = prepOf(rig);
254
+ if (!prep) throw new Error("captureArdyRoot: rig is not prepared for ARDY playback");
255
+ const rootIndex = CSKEL27_JOINTS.indexOf("Hips");
256
+ const bone = prep.bones[rootIndex];
257
+ if (!bone) throw new Error("captureArdyRoot: mapped Hips bone is unavailable");
258
+
259
+ rig.updateMatrixWorld(true);
260
+ const rigInverse = new THREE.Matrix4().copy(rig.matrixWorld).invert();
261
+ const boneInRig = new THREE.Matrix4().multiplyMatrices(rigInverse, bone.matrixWorld);
262
+ const position = new THREE.Vector3();
263
+ const boneRotation = new THREE.Quaternion();
264
+ boneInRig.decompose(position, boneRotation, new THREE.Vector3());
265
+
266
+ const globalRotation = boneRotation.multiply(prep.bindQuat[rootIndex].clone().invert()).normalize();
267
+ const bindOffset = prep.offsets[rootIndex].clone().applyQuaternion(globalRotation);
268
+ return [
269
+ (position.x - bindOffset.x) / prep.scale,
270
+ (position.y - bindOffset.y) / prep.scale,
271
+ (position.z - bindOffset.z) / prep.scale,
272
+ ];
273
+ }
274
+
275
+ export function debugPrep(rig) {
276
+ const p = prepOf(rig);
277
+ return {
278
+ scale: p.scale,
279
+ offsets: p.offsets.map((o) => (o ? [o.x, o.y, o.z] : null)),
280
+ };
281
+ }
282
+
283
+ /* --- per-frame application -------------------------------------------------- */
284
+
285
+ const frameLocals = new Array(CSKEL27_JOINTS.length);
286
+ for (let j = 0; j < frameLocals.length; j += 1) {
287
+ frameLocals[j] = [
288
+ [0, 0, 0],
289
+ [0, 0, 0],
290
+ [0, 0, 0],
291
+ ];
292
+ }
293
+
294
+ const mGlobal = new THREE.Matrix3();
295
+ const mWorld = new THREE.Matrix4();
296
+ const mLocal = new THREE.Matrix4();
297
+ const mParentInv = new THREE.Matrix4();
298
+ const qGlobal = new THREE.Quaternion();
299
+ const qWorld = new THREE.Quaternion();
300
+ const vOffset = new THREE.Vector3();
301
+ const vWorld = new THREE.Vector3();
302
+ const vDecompPos = new THREE.Vector3();
303
+ const qDecomp = new THREE.Quaternion();
304
+ const vDecompScale = new THREE.Vector3();
305
+ const desiredWorld = new Array(CSKEL27_JOINTS.length).fill(null);
306
+
307
+ /**
308
+ * Apply one motion frame to the rig with positional skinning: every mapped
309
+ * bone's rig-space world transform is set to
310
+ *
311
+ * pos = s * (posed[f][j] - anchorRootXZ) + R[f][j] @ offset[j]
312
+ * quat = R[f][j] @ bindQuat[j]
313
+ *
314
+ * and then converted to bone-local against the (already updated) parent.
315
+ * `frame` is clamped into range. The clip-to-scene yaw is NOT applied here —
316
+ * the Character group's `rot` carries it, exactly like before.
317
+ */
318
+ export function applyMotionFrame(rig, motion, frame) {
319
+ if (!rig || !motion) return;
320
+ const f = Math.max(0, Math.min(Math.round(frame) || 0, motion.frames - 1));
321
+ const prep = prepOf(rig);
322
+ const joints = CSKEL27_JOINTS.length;
323
+ const base = f * joints * 9;
324
+
325
+ // Global (world) rotations of every cskel27 joint at this frame: FK over
326
+ // the npz local rotations down the parent chain.
327
+ for (let j = 0; j < joints; j += 1) {
328
+ const o = base + j * 9;
329
+ const L = frameLocals[j];
330
+ L[0][0] = motion.rotMats[o]; L[0][1] = motion.rotMats[o + 1]; L[0][2] = motion.rotMats[o + 2];
331
+ L[1][0] = motion.rotMats[o + 3]; L[1][1] = motion.rotMats[o + 4]; L[1][2] = motion.rotMats[o + 5];
332
+ L[2][0] = motion.rotMats[o + 6]; L[2][1] = motion.rotMats[o + 7]; L[2][2] = motion.rotMats[o + 8];
333
+ }
334
+ const globals = globalRotations(frameLocals);
335
+
336
+ // Anchor: the anchor frame's root stays at the rig origin (the Character
337
+ // group sits at the scene anchor); height stays floor-absolute.
338
+ const anchorFrame = Math.max(0, Math.min(motion.anchorFrame || 0, motion.frames - 1));
339
+ const anchorX = motion.posedJoints[(anchorFrame * joints) * 3];
340
+ const anchorZ = motion.posedJoints[(anchorFrame * joints) * 3 + 2];
341
+
342
+ for (let j = 0; j < joints; j += 1) {
343
+ const bone = prep.bones[j];
344
+ desiredWorld[j] = null;
345
+ if (!bone) continue;
346
+
347
+ const G = globals[j];
348
+ mGlobal.set(
349
+ G[0][0], G[0][1], G[0][2],
350
+ G[1][0], G[1][1], G[1][2],
351
+ G[2][0], G[2][1], G[2][2],
352
+ );
353
+ qGlobal.setFromRotationMatrix(mWorld.setFromMatrix3(mGlobal));
354
+
355
+ const po = (f * joints + j) * 3;
356
+ vOffset.copy(prep.offsets[j]).applyQuaternion(qGlobal);
357
+ vWorld.set(
358
+ prep.scale * (motion.posedJoints[po] - anchorX) + vOffset.x,
359
+ prep.scale * motion.posedJoints[po + 1] + vOffset.y,
360
+ prep.scale * (motion.posedJoints[po + 2] - anchorZ) + vOffset.z,
361
+ );
362
+ qWorld.copy(qGlobal).multiply(prep.bindQuat[j]);
363
+
364
+ // Convert the rig-space world transform to bone-local. The parent
365
+ // world is the nearest mapped ancestor's desired world (already
366
+ // written this frame — cskel index order is topological along every
367
+ // Mixamo chain) times the bind-relative chain matrix, or the static
368
+ // bind parent world when no mapped ancestor exists.
369
+ const chainParentJ = prep.chainParent[j];
370
+ if (chainParentJ >= 0) {
371
+ mParentInv.copy(desiredWorld[chainParentJ]).multiply(prep.chainRel[j]);
372
+ } else {
373
+ mParentInv.copy(prep.parentBindWorld[j]);
374
+ }
375
+
376
+ mWorld.compose(vWorld, qWorld, prep.bindScale[j]);
377
+ desiredWorld[j] = mWorld.clone();
378
+ mLocal.copy(mParentInv).invert().multiply(mWorld);
379
+ mLocal.decompose(vDecompPos, qDecomp, vDecompScale);
380
+ bone.position.copy(vDecompPos);
381
+ bone.quaternion.copy(qDecomp);
382
+ }
383
+ rig.updateMatrixWorld(true);
384
+ }
385
+
386
+ /* --- snapshot / restore ----------------------------------------------------- */
387
+
388
+ /**
389
+ * Capture the current transform of every bone playback touches — the
390
+ * skinning-mapped bones (position AND quaternion: applyMotionFrame drives
391
+ * both) — so clearing a motion restores the prior CozyClay pose exactly.
392
+ */
393
+ export function snapshotPlaybackBones(rig) {
394
+ const out = [];
395
+ for (const bone of motionBones(rig)) {
396
+ if (!bone) continue;
397
+ out.push([
398
+ bone,
399
+ bone.quaternion.x, bone.quaternion.y, bone.quaternion.z, bone.quaternion.w,
400
+ bone.position.x, bone.position.y, bone.position.z,
401
+ ]);
402
+ }
403
+ return out;
404
+ }
405
+
406
+ /** Restore transforms captured by snapshotPlaybackBones. */
407
+ export function restorePlaybackBones(rig, snapshot) {
408
+ if (!rig || !snapshot) return;
409
+ for (const entry of snapshot) {
410
+ entry[0].quaternion.set(entry[1], entry[2], entry[3], entry[4]);
411
+ if (entry.length > 5) entry[0].position.set(entry[5], entry[6], entry[7]);
412
+ }
413
+ rig.updateMatrixWorld(true);
414
+ }
@@ -0,0 +1,16 @@
1
+ /** Move one prompt clip on the fixed ARDY block grid without changing its
2
+ * duration. Overlaps are rejected because segment generation needs one prompt
3
+ * per frame range; gaps remain valid and inherit the main prompt. */
4
+ export function movePromptClipFrames(clips, id, rawStartFrame, blockFrames = 40) {
5
+ const target = clips.find((clip) => clip.id === id);
6
+ if (!target) return clips;
7
+ const duration = target.endFrame - target.startFrame;
8
+ const startFrame = Math.max(0, Math.round(rawStartFrame / blockFrames) * blockFrames);
9
+ const endFrame = startFrame + duration;
10
+ if (startFrame === target.startFrame) return clips;
11
+ const overlaps = clips.some((clip) =>
12
+ clip.id !== id && startFrame < clip.endFrame && endFrame > clip.startFrame
13
+ );
14
+ if (overlaps) return clips;
15
+ return clips.map((clip) => clip.id === id ? { ...clip, startFrame, endFrame } : clip);
16
+ }
@@ -0,0 +1,17 @@
1
+ /** Convert a pointer X into the frame represented by the rendered lane.
2
+ * `laneLeft` already includes horizontal scroll through getBoundingClientRect,
3
+ * while `displayFrameCount` carries zoom-out's expanded virtual range. */
4
+ export function frameFromClientX(clientX, laneLeft, laneWidth, displayFrameCount, frameCount) {
5
+ const width = Math.max(1, laneWidth);
6
+ const t = Math.max(0, Math.min(1, (clientX - laneLeft) / width));
7
+ const displayedFrame = Math.round(t * Math.max(0, displayFrameCount - 1));
8
+ return Math.max(0, Math.min(frameCount - 1, displayedFrame));
9
+ }
10
+
11
+ /** Convert drag delta into a raw prompt-block start frame. Geometry and frame
12
+ * count are captured at pointer-down so extending the timeline cannot feed
13
+ * back into the next pointermove. Scroll cancels out because this uses delta X. */
14
+ export function promptMoveStartFrame(startFrame, startClientX, clientX, laneWidth, displayFrameCount) {
15
+ const framesPerPixel = Math.max(0, displayFrameCount - 1) / Math.max(1, laneWidth);
16
+ return startFrame + (clientX - startClientX) * framesPerPixel;
17
+ }
@@ -0,0 +1,11 @@
1
+ const PROMPT_BLOCK_FRAMES = 40; // 2 s at ARDY's 20 fps horizon
2
+ const PROMPT_RESIZE_BLOCK_PX = 120; // one deliberate drag step = one prompt block
3
+
4
+ /** Resize from the immutable pointer-down frame. Keeping both the frame and
5
+ * pixel origin fixed prevents a growing timeline from feeding its new frame
6
+ * count back into the next pointermove and exploding exponentially. */
7
+ export function promptResizeFrame(startFrame, startClientX, clientX) {
8
+ const blocks = (clientX - startClientX) / PROMPT_RESIZE_BLOCK_PX;
9
+ const blockDelta = Math.sign(blocks) * Math.round(Math.abs(blocks));
10
+ return startFrame + blockDelta * PROMPT_BLOCK_FRAMES;
11
+ }