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,811 @@
1
+ import { useEffect, useRef, useState } from "react";
2
+ import { useFrame, useThree } from "@react-three/fiber";
3
+ import * as THREE from "three";
4
+ import { POSE_BONES, normalizeBoneName, primeBindPose } from "./poses.js";
5
+ import { IK_TRACKS, MID_TRACKS } from "./ardy/ik.js";
6
+ import { POSER_LAYER } from "./dualview.jsx";
7
+
8
+ /** Same normalised-name match rule as poses.js: equal, or one is a suffix of
9
+ * the other, so `mixamorig:LeftArm`, `mixamorigLeftArm` and `LeftArm` all hit. */
10
+ function matchesBone(boneName, entry) {
11
+ const norm = normalizeBoneName(boneName);
12
+ const target = normalizeBoneName(entry.bone);
13
+ return norm === target || norm.endsWith(target) || target.endsWith(norm);
14
+ }
15
+
16
+ const HANDLE_RADIUS = 0.05;
17
+
18
+ /** Handle colour by joint group, mirroring the reference's arm/leg/torso/head
19
+ * colour coding. */
20
+ function handleColor(entry) {
21
+ const id = entry.id;
22
+ if (id === "hips" || id.startsWith("spine") || id === "chest") return "#ffd23d";
23
+ if (id === "neck" || id === "head") return "#b98cff";
24
+ if (id.includes("Arm") || id.includes("ForeArm") || id.includes("Hand") || id.includes("Shoulder")) return "#ff8a3d";
25
+ return "#4dd2ff";
26
+ }
27
+
28
+ /**
29
+ * Draggable joint spheres for pose mode. While `enabled`, renders one handle
30
+ * per POSE_BONES joint at the joint's world position. Dragging a handle swings
31
+ * that joint's limb toward the pointer: the pointer is projected onto the
32
+ * camera-facing plane through the joint and the bone is rotated so its
33
+ * bone->child direction follows the projected point (the rotation axis that
34
+ * best matches the drag direction in screen space). Each move calls
35
+ * `onChange()`.
36
+ */
37
+ export function PoseHandles({ root, enabled, onChange }) {
38
+ const { camera, gl, raycaster } = useThree();
39
+ const meshRefs = useRef([]);
40
+ const jointsRef = useRef(new Map());
41
+ const dragRef = useRef(null);
42
+ const dragHandlersRef = useRef(null);
43
+ const onChangeRef = useRef(onChange);
44
+ onChangeRef.current = onChange;
45
+ const tmp = useRef({
46
+ origin: new THREE.Vector3(),
47
+ limb: new THREE.Vector3(),
48
+ dir: new THREE.Vector3(),
49
+ quat: new THREE.Quaternion(),
50
+ ndc: new THREE.Vector2(),
51
+ target: new THREE.Vector3(),
52
+ qDelta: new THREE.Quaternion(),
53
+ qWorld: new THREE.Quaternion(),
54
+ qParent: new THREE.Quaternion(),
55
+ qNew: new THREE.Quaternion(),
56
+ }).current;
57
+ // Late safety net only. Character primes the bind at clone time, while the
58
+ // rig is untouched; by the time this mounts a pose has already been applied,
59
+ // so a snapshot taken here would call that pose "rest". primeBindPose is a
60
+ // no-op when the stamp exists, which is the normal path.
61
+ useEffect(() => {
62
+ primeBindPose(root);
63
+ }, [root]);
64
+
65
+ // Resolve every joint to its bone instances whenever the character or pose
66
+ // mode changes. Mixamo FBX exports nest an identity "skinned" copy under
67
+ // the control bone; the first match is the control bone and dragging it
68
+ // carries the whole subtree, so the nested copy must not be written again.
69
+ // Resolution happens in an effect but is consumed during render, so a
70
+ // version counter is what actually re-renders the handles into the scene.
71
+ const [resolved, setResolved] = useState(0);
72
+
73
+ useEffect(() => {
74
+ jointsRef.current.clear();
75
+ if (!root || !enabled) {
76
+ setResolved((n) => n + 1);
77
+ return;
78
+ }
79
+ for (const entry of POSE_BONES) {
80
+ const found = [];
81
+ root.traverse((object) => {
82
+ if (object.isBone && matchesBone(object.name, entry)) found.push(object);
83
+ });
84
+ if (found.length) jointsRef.current.set(entry.id, found);
85
+ }
86
+ setResolved((n) => n + 1);
87
+ }, [root, enabled]);
88
+
89
+ useEffect(() => {
90
+ if (!enabled) return undefined;
91
+ const onMove = (ev) => {
92
+ const d = dragRef.current;
93
+ if (!d) return;
94
+ const rect = gl.domElement.getBoundingClientRect();
95
+ tmp.ndc.set(
96
+ ((ev.clientX - rect.left) / rect.width) * 2 - 1,
97
+ -((ev.clientY - rect.top) / rect.height) * 2 + 1
98
+ );
99
+ raycaster.setFromCamera(tmp.ndc, camera);
100
+ if (!raycaster.ray.intersectPlane(d.plane, tmp.target)) return;
101
+ tmp.target.sub(d.origin);
102
+ if (tmp.target.lengthSq() < 1e-8) return;
103
+ tmp.target.normalize();
104
+ tmp.qDelta.setFromUnitVectors(d.limbDir, tmp.target);
105
+ for (const bone of d.writable) {
106
+ bone.parent.getWorldQuaternion(tmp.qParent).invert();
107
+ bone.getWorldQuaternion(tmp.qWorld);
108
+ tmp.qNew.multiplyQuaternions(tmp.qDelta, tmp.qWorld).premultiply(tmp.qParent);
109
+ bone.quaternion.copy(tmp.qNew);
110
+ bone.updateMatrixWorld(true);
111
+ }
112
+ onChangeRef.current?.();
113
+ };
114
+ const onUp = () => {
115
+ // Detach this drag's listeners but KEEP the handler pair — nulling
116
+ // it here bricks every subsequent drag (startDrag refuses to start
117
+ // without it), the same failure the IK handles had. removeEventListener
118
+ // is idempotent.
119
+ window.removeEventListener("pointermove", onMove);
120
+ window.removeEventListener("pointerup", onUp);
121
+ window.removeEventListener("pointercancel", onUp);
122
+ dragRef.current = null;
123
+ gl.domElement.style.cursor = "";
124
+ };
125
+ dragHandlersRef.current = { onMove, onUp };
126
+ return () => {
127
+ // Abort an in-flight drag when pose mode closes mid-drag.
128
+ if (dragHandlersRef.current) {
129
+ window.removeEventListener("pointermove", dragHandlersRef.current.onMove);
130
+ window.removeEventListener("pointerup", dragHandlersRef.current.onUp);
131
+ window.removeEventListener("pointercancel", dragHandlersRef.current.onUp);
132
+ dragHandlersRef.current = null;
133
+ }
134
+ dragRef.current = null;
135
+ gl.domElement.style.cursor = "";
136
+ };
137
+ }, [enabled, camera, gl, raycaster, tmp]);
138
+
139
+ useFrame(() => {
140
+ if (!enabled) return;
141
+ for (let i = 0; i < POSE_BONES.length; i++) {
142
+ const mesh = meshRefs.current[i];
143
+ if (!mesh) continue;
144
+ const instances = jointsRef.current.get(POSE_BONES[i].id);
145
+ if (instances?.length) mesh.position.copy(instances[0].getWorldPosition(tmp.origin));
146
+ }
147
+ });
148
+
149
+ if (!enabled || !root) return null;
150
+
151
+ const startDrag = (entry) => (ev) => {
152
+ ev.stopPropagation();
153
+ // Stop the fly-camera controls' element-level pointerdown from also
154
+ // firing: R3F's canvas listener was registered before theirs, so
155
+ // stopImmediatePropagation here keeps the camera still while posing.
156
+ if (ev.nativeEvent.stopImmediatePropagation) ev.nativeEvent.stopImmediatePropagation();
157
+ const instances = jointsRef.current.get(entry.id);
158
+ if (!instances?.length || !dragHandlersRef.current) return;
159
+ const bone = instances[0];
160
+ bone.getWorldPosition(tmp.origin);
161
+ let child = null;
162
+ for (const c of bone.children) {
163
+ if (c.isBone && normalizeBoneName(c.name) !== normalizeBoneName(bone.name)) {
164
+ child = c;
165
+ break;
166
+ }
167
+ }
168
+ if (child) child.getWorldPosition(tmp.limb).sub(tmp.origin);
169
+ else tmp.limb.set(0, 1, 0).applyQuaternion(bone.getWorldQuaternion(tmp.quat)); // Mixamo limbs run along +Y
170
+ if (tmp.limb.lengthSq() < 1e-8) return;
171
+ tmp.limb.normalize();
172
+ const writable = [];
173
+ for (const inst of instances) {
174
+ let nested = false;
175
+ for (let p = inst.parent; p; p = p.parent) {
176
+ if (writable.includes(p)) {
177
+ nested = true;
178
+ break;
179
+ }
180
+ }
181
+ if (!nested) writable.push(inst);
182
+ }
183
+ camera.getWorldDirection(tmp.dir);
184
+ dragRef.current = {
185
+ writable,
186
+ origin: tmp.origin.clone(),
187
+ limbDir: tmp.limb.clone(),
188
+ plane: new THREE.Plane().setFromNormalAndCoplanarPoint(tmp.dir.clone(), tmp.origin.clone()),
189
+ };
190
+ gl.domElement.style.cursor = "grabbing";
191
+ window.addEventListener("pointermove", dragHandlersRef.current.onMove);
192
+ window.addEventListener("pointerup", dragHandlersRef.current.onUp);
193
+ window.addEventListener("pointercancel", dragHandlersRef.current.onUp);
194
+ };
195
+
196
+ return (
197
+ <group key={resolved}>
198
+ {POSE_BONES.map((entry, i) =>
199
+ jointsRef.current.has(entry.id) ? (
200
+ <mesh
201
+ key={entry.id}
202
+ ref={(m) => {
203
+ meshRefs.current[i] = m;
204
+ }}
205
+ renderOrder={999}
206
+ onPointerDown={startDrag(entry)}
207
+ onPointerOver={() => (gl.domElement.style.cursor = "grab")}
208
+ onPointerOut={() => {
209
+ if (!dragRef.current) gl.domElement.style.cursor = "";
210
+ }}
211
+ >
212
+ <sphereGeometry args={[HANDLE_RADIUS, 16, 16]} />
213
+ <meshStandardMaterial
214
+ color="#000000"
215
+ emissive={handleColor(entry)}
216
+ emissiveIntensity={1.5}
217
+ toneMapped={false}
218
+ depthTest={false}
219
+ transparent
220
+ opacity={0.95}
221
+ />
222
+ </mesh>
223
+ ) : null
224
+ )}
225
+ </group>
226
+ );
227
+ }
228
+
229
+ /** IK handle colour by limb, matching the FK handle coding: arms orange,
230
+ * legs blue. */
231
+ function ikHandleColor(track) {
232
+ return track.kind === "arm" ? "#ff8a3d" : "#4dd2ff";
233
+ }
234
+
235
+ /** World-axis gizmo colours (Blender / Cascadeur convention). */
236
+ const AXIS_DEFS = [
237
+ { axis: "X", dir: new THREE.Vector3(1, 0, 0), color: "#ff5340" },
238
+ { axis: "Y", dir: new THREE.Vector3(0, 1, 0), color: "#54e05c" },
239
+ { axis: "Z", dir: new THREE.Vector3(0, 0, 1), color: "#3d8bff" },
240
+ ];
241
+ const GIZMO_LEN = 0.22;
242
+ const GIZMO_SHAFT_R = 0.012;
243
+ const GIZMO_TIP_R = 0.03;
244
+ const HANDLE_R = 0.055; // chain target spheres
245
+ const JOINT_R = 0.042; // mid-joint + FK swing spheres
246
+ const SWING_RING_R = 0.12; // effector rotation ring
247
+ const SWING_RING_TUBE = 0.012; // visible tube
248
+ const SWING_RING_PICK_TUBE = 0.045; // invisible grab tube
249
+ /** Unfocused handles fade back so the focused one reads clearly. */
250
+ const OPACITY_FOCUSED = 1;
251
+ const OPACITY_UNFOCUSED = 0.3;
252
+ /** FK swing drag speed: TransformControls uses 20/camDist, tuned for its
253
+ * gizmo rings; posing needs finer steps, so the same formula runs at 5 —
254
+ * a ~60 px drag reads as ~15°, a full-screen sweep as ~165°. */
255
+ const FK_ROTATE_SPEED = 5;
256
+
257
+ /**
258
+ * Draggable IK/FK handles, DCC-style (Unity grab-handle + Cascadeur
259
+ * manipulators). Three handle kinds:
260
+ *
261
+ * - chain targets (wrists/ankles): the handle is the TARGET, decoupled from
262
+ * the effector — it follows the pointer freely and the limb only reaches
263
+ * for it. Focused ones get the mini move-gizmo (X/Y/Z axis arrows + the
264
+ * centre sphere free-drag).
265
+ * - mid joints (elbows/knees): drag repositions the mid joint with both
266
+ * ends pinned; the handle snaps to the clamped elbow/knee position. Also
267
+ * position handles, so they get the gizmo when focused.
268
+ * - FK swing joints (shoulders, spine, chest, neck, head, hips): drag
269
+ * swings the part toward the pointer — rotation only, no gizmo.
270
+ *
271
+ * Unfocused handles render at low opacity so the focused one stands out.
272
+ * Every drag move calls `onSolve(kind, trackId, targetWorld)`; the drag end
273
+ * calls `onDragEnd(trackId)` so the caller can key it.
274
+ */
275
+ export function IkHandles({ chains, fkJoints, ikState, enabled, focus, onFocus, onSolve, onDragEnd }) {
276
+ const { camera, gl, raycaster } = useThree();
277
+ const handleRefs = useRef({}); // id -> mesh (all sphere handles)
278
+ const gizmoRef = useRef(null);
279
+ const ringRef = useRef(null); // swing ring around the focused effector
280
+ const dragRef = useRef(null);
281
+ const handlersRef = useRef(null);
282
+ const solveRef = useRef(onSolve);
283
+ const endRef = useRef(onDragEnd);
284
+ const focusRef = useRef(onFocus);
285
+ // The pointerdown handler filters picks through the CURRENT focus: while
286
+ // a joint is focused, only that joint (sphere + its gizmo arrows) is
287
+ // interactive — unfocused handles are inert, so they can never shadow a
288
+ // gizmo arrow (the "the yellow circles block the gizmo" failure).
289
+ const focusIdRef = useRef(focus);
290
+ solveRef.current = onSolve;
291
+ endRef.current = onDragEnd;
292
+ focusRef.current = onFocus;
293
+ focusIdRef.current = focus;
294
+ const tmp = useRef({
295
+ ndc: new THREE.Vector2(),
296
+ hit: new THREE.Vector3(),
297
+ delta: new THREE.Vector3(),
298
+ target: new THREE.Vector3(),
299
+ pos: new THREE.Vector3(),
300
+ }).current;
301
+
302
+ useEffect(() => {
303
+ if (!enabled) return undefined;
304
+ const onMove = (ev) => {
305
+ const d = dragRef.current;
306
+ if (!d) return;
307
+ if (d.kind === "empty") {
308
+ // past the click threshold it's a camera drag, not a focus click
309
+ if (Math.hypot(ev.clientX - d.downXY[0], ev.clientY - d.downXY[1]) > CLICK_PX) d.moved = true;
310
+ return;
311
+ }
312
+ const rect = gl.domElement.getBoundingClientRect();
313
+ tmp.ndc.set(
314
+ ((ev.clientX - rect.left) / rect.width) * 2 - 1,
315
+ -((ev.clientY - rect.top) / rect.height) * 2 + 1
316
+ );
317
+ raycaster.setFromCamera(tmp.ndc, camera);
318
+ if (!raycaster.ray.intersectPlane(d.plane, tmp.hit)) return;
319
+ if (d.kind === "fk" || d.kind === "swing" || (d.kind === "body" && !d.axisDir)) {
320
+ // Swing = trackball rotation (TransformControls rotate model):
321
+ // axis = offset × eye, angle = (offset · tangent) × speed/camDist,
322
+ // applied absolutely from the drag-start orientation (no compounding).
323
+ // The body centre sphere swings the body; its arrows translate.
324
+ tmp.delta.subVectors(tmp.hit, d.hitStart);
325
+ const axis = tmp.delta.clone().cross(d.eye);
326
+ if (axis.lengthSq() < 1e-12) return;
327
+ axis.normalize();
328
+ const tangent = axis.clone().cross(d.eye).normalize();
329
+ const angle = tmp.delta.dot(tangent) * (FK_ROTATE_SPEED / d.camDist);
330
+ if (Math.hypot(ev.clientX - d.downXY[0], ev.clientY - d.downXY[1]) > CLICK_PX) d.moved = true;
331
+ solveRef.current?.(d.kind, d.trackId, { axis, angle, startQuat: d.startQuat, startParentQuat: d.startParentQuat });
332
+ return;
333
+ }
334
+ if (d.kind === "body" && d.axisDir) {
335
+ // Body translate: absolute from the drag-start local position,
336
+ // so repeated moves never compound — the hips' local start plus
337
+ // the world delta along the dragged axis.
338
+ tmp.delta.subVectors(tmp.hit, d.hitStart);
339
+ const along = tmp.delta.dot(d.axisDir);
340
+ if (Math.hypot(ev.clientX - d.downXY[0], ev.clientY - d.downXY[1]) > CLICK_PX) d.moved = true;
341
+ solveRef.current?.(d.kind, d.trackId, { worldDelta: d.axisDir.clone().multiplyScalar(along), startLocalPos: d.startLocalPos });
342
+ return;
343
+ }
344
+ if (d.axisDir) {
345
+ // Axis drag: keep only the axis component of the pointer travel.
346
+ tmp.delta.subVectors(tmp.hit, d.hitStart);
347
+ const along = tmp.delta.dot(d.axisDir);
348
+ tmp.target.copy(d.targetStart).addScaledVector(d.axisDir, along);
349
+ } else {
350
+ // Free drag: camera-facing plane, pointer-exact via the grab offset.
351
+ tmp.target.copy(tmp.hit).add(d.offset);
352
+ }
353
+ if (Math.hypot(ev.clientX - d.downXY[0], ev.clientY - d.downXY[1]) > CLICK_PX) d.moved = true;
354
+ solveRef.current?.(d.kind, d.trackId, tmp.target);
355
+ };
356
+ const onUp = (ev) => {
357
+ // Detach this drag's listeners but KEEP the handler pair — nulling
358
+ // it here would brick every subsequent drag (beginDrag refuses to
359
+ // start without it), which is exactly the "second drag does
360
+ // nothing" failure. removeEventListener is idempotent.
361
+ window.removeEventListener("pointermove", onMove);
362
+ window.removeEventListener("pointerup", onUp);
363
+ window.removeEventListener("pointercancel", onUp);
364
+ const d = dragRef.current;
365
+ dragRef.current = null;
366
+ gl.domElement.style.cursor = "";
367
+ if (!d) return;
368
+ // Click (never dragged): the focused sphere toggles focus OFF; a
369
+ // not-yet-focused sphere just took focus (nothing to bake). An
370
+ // empty-canvas click releases focus too.
371
+ if (!d.moved) {
372
+ if (d.kind === "empty") {
373
+ if (focusIdRef.current) focusRef.current?.(null);
374
+ return;
375
+ }
376
+ if (d.wasFocused && !d.axisDir) {
377
+ focusRef.current?.(null); // toggle off
378
+ }
379
+ // axis-arrow clicks and fresh-sphere clicks: nothing to bake
380
+ return;
381
+ }
382
+ endRef.current?.(d.trackId);
383
+ };
384
+ handlersRef.current = { onMove, onUp };
385
+ return () => {
386
+ window.removeEventListener("pointermove", onMove);
387
+ window.removeEventListener("pointerup", onUp);
388
+ window.removeEventListener("pointercancel", onUp);
389
+ handlersRef.current = null;
390
+ dragRef.current = null;
391
+ gl.domElement.style.cursor = "";
392
+ };
393
+ }, [enabled, camera, gl, raycaster, tmp]);
394
+
395
+ // QA hook: live handle world positions for headless checks (harmless).
396
+ if (typeof window !== "undefined") {
397
+ window.__ikHandlePos = (id) => {
398
+ const m = handleRefs.current[id];
399
+ return m ? m.getWorldPosition(new THREE.Vector3()) : null;
400
+ };
401
+ // world → screen for a handle (or the focused ring's edge), so headless
402
+ // drags can land on the mesh pixel-exactly.
403
+ window.__ikProject = (world) => {
404
+ const v = world.clone ? world.clone() : new THREE.Vector3(world.x, world.y, world.z);
405
+ v.project(camera);
406
+ const rect = gl.domElement.getBoundingClientRect();
407
+ return { x: rect.left + ((v.x + 1) / 2) * rect.width, y: rect.top + ((1 - v.y) / 2) * rect.height };
408
+ };
409
+ // effector local quaternion per chain track — rotation-edit assertions.
410
+ window.__ikEffectorQuat = (id) => {
411
+ const bone = chains?.get(id)?.bones[2];
412
+ return bone ? { x: bone.quaternion.x, y: bone.quaternion.y, z: bone.quaternion.z, w: bone.quaternion.w } : null;
413
+ };
414
+ // is the swing ring mounted for this track (focused chain)?
415
+ window.__ikRingVisible = () => !!ringRef.current;
416
+ }
417
+
418
+ // Handles + gizmo follow the live state every frame. A chain handle RIDES
419
+ // its effector (the actual wrist/ankle) at all times — pose changes,
420
+ // character moves and scrubs can never strand it in stale world space
421
+ // (the "IK handle escapes" bug) — EXCEPT while that chain is being
422
+ // dragged, when it tracks the pointer target for 1:1 feedback past reach.
423
+ // On release it returns to the effector, i.e. exactly where the hand is.
424
+ useFrame(() => {
425
+ if (!enabled || !ikState) return;
426
+ const draggingChain = dragRef.current?.kind === "chain" ? dragRef.current.trackId : null;
427
+ for (const track of IK_TRACKS) {
428
+ const mesh = handleRefs.current[track.id];
429
+ const chain = chains?.get(track.id);
430
+ if (!mesh || !chain) continue;
431
+ if (draggingChain === track.id) {
432
+ const t = ikState.targets.get(track.id);
433
+ if (t) mesh.position.copy(t);
434
+ } else {
435
+ mesh.position.copy(chain.bones[2].getWorldPosition(tmp.pos));
436
+ }
437
+ }
438
+ for (const track of MID_TRACKS) {
439
+ const mesh = handleRefs.current[track.id];
440
+ const chain = chains?.get(track.chain);
441
+ if (mesh && chain) mesh.position.copy(chain.bones[1].getWorldPosition(tmp.pos));
442
+ }
443
+ for (const [id, joint] of fkJoints ?? []) {
444
+ const mesh = handleRefs.current[id];
445
+ if (mesh && joint) mesh.position.copy(joint.bone.getWorldPosition(tmp.pos));
446
+ }
447
+ if (gizmoRef.current && focus) {
448
+ const mid = MID_TRACKS.find((t) => t.id === focus);
449
+ const chainTrack = IK_TRACKS.find((t) => t.id === focus);
450
+ if (chainTrack) {
451
+ const chain = chains?.get(focus);
452
+ if (draggingChain === focus) {
453
+ const t = ikState.targets.get(focus);
454
+ if (t) gizmoRef.current.position.copy(t);
455
+ } else if (chain) {
456
+ gizmoRef.current.position.copy(chain.bones[2].getWorldPosition(tmp.pos));
457
+ }
458
+ } else if (mid) {
459
+ const chain = chains?.get(mid.chain);
460
+ if (chain) gizmoRef.current.position.copy(chain.bones[1].getWorldPosition(tmp.pos));
461
+ } else if (focus === "hips") {
462
+ // the body gizmo rides the hips bone (also while it translates)
463
+ const joint = fkJoints?.get("hips");
464
+ if (joint) gizmoRef.current.position.copy(joint.bone.getWorldPosition(tmp.pos));
465
+ }
466
+ }
467
+ // The swing ring rides the focused chain's effector and always faces
468
+ // the camera: a trackball ring, not a bone-aligned one — the drag math
469
+ // is trackball, so the affordance must match.
470
+ if (ringRef.current && focus) {
471
+ const chain = chains?.get(focus);
472
+ if (chain) {
473
+ ringRef.current.position.copy(chain.bones[2].getWorldPosition(tmp.pos));
474
+ ringRef.current.quaternion.copy(camera.getWorldQuaternion(new THREE.Quaternion()));
475
+ }
476
+ }
477
+ });
478
+
479
+ /* Hit-testing is OWNED, not delegated to R3F: R3F computes the pointer
480
+ * NDC from its own measured canvas size, which drifts a few percent from
481
+ * the live rect (window resizes, layout shifts) — big handle spheres
482
+ * still catch the skewed ray, but small gizmo cones miss it entirely,
483
+ * which is exactly the "the handle doesn't grab" failure. A manual
484
+ * pointerdown + raycast against the registered handle/gizmo meshes uses
485
+ * the CURRENT canvas rect, so every hit is pixel-exact. */
486
+ const pickRefs = useRef([]); // [{ mesh, track, axisDir, kind }]
487
+
488
+ /** Click/drag threshold (screen px): a press that never moves past this is
489
+ * a click (focus toggle), not a drag (no key baked). */
490
+ const CLICK_PX = 4;
491
+
492
+ const beginDrag = (kind, track, axisDir, ndc, downXY) => {
493
+ if (!handlersRef.current) return;
494
+ if (kind === "empty") {
495
+ // An empty-canvas press: no drag, just a click candidate that
496
+ // releases focus on pointerup (see onUp).
497
+ dragRef.current = { kind: "empty", trackId: "empty", downXY, moved: false };
498
+ window.addEventListener("pointermove", handlersRef.current.onMove);
499
+ window.addEventListener("pointerup", handlersRef.current.onUp);
500
+ window.addEventListener("pointercancel", handlersRef.current.onUp);
501
+ return;
502
+ }
503
+ let origin;
504
+ if (kind === "chain" || kind === "swing") {
505
+ // Every drag starts from the CURRENT effector — the handle rides the
506
+ // wrist between drags, so that is where the user grabs it.
507
+ origin = chains.get(track.id).bones[2].getWorldPosition(new THREE.Vector3());
508
+ } else if (kind === "mid") {
509
+ origin = chains.get(track.chain).bones[1].getWorldPosition(new THREE.Vector3());
510
+ } else {
511
+ origin = fkJoints.get(track.id).bone.getWorldPosition(new THREE.Vector3());
512
+ }
513
+ raycaster.setFromCamera(ndc, camera);
514
+ let plane;
515
+ if (axisDir) {
516
+ // TransformControls: plane normal = axis × (eye × axis) — the plane
517
+ // contains the axis and is as view-perpendicular as possible.
518
+ const eye = camera.getWorldDirection(new THREE.Vector3());
519
+ const normal = axisDir.clone().cross(eye).cross(axisDir).normalize();
520
+ plane = new THREE.Plane().setFromNormalAndCoplanarPoint(normal, origin);
521
+ } else {
522
+ plane = new THREE.Plane().setFromNormalAndCoplanarPoint(
523
+ camera.getWorldDirection(new THREE.Vector3()),
524
+ origin
525
+ );
526
+ }
527
+ const hit = new THREE.Vector3();
528
+ raycaster.ray.intersectPlane(plane, hit);
529
+ dragRef.current = {
530
+ kind,
531
+ trackId: track.id,
532
+ plane,
533
+ axisDir: axisDir || null,
534
+ targetStart: origin,
535
+ hitStart: hit.clone(),
536
+ offset: axisDir ? new THREE.Vector3() : origin.clone().sub(hit),
537
+ downXY,
538
+ moved: false,
539
+ // Whether THIS handle was already focused when the press began —
540
+ // a no-move click toggles focus OFF only then; a click on a fresh
541
+ // (unfocused) handle must keep the focus it just took.
542
+ wasFocused: focusIdRef.current === track.id,
543
+ };
544
+ if (kind === "fk" || kind === "swing" || kind === "body") {
545
+ // Swing/translate start state, frozen at drag start so moves stay
546
+ // absolute: the bone's orientation, its parent's world rotation,
547
+ // the bone→camera eye, the camera distance, and (for the body) the
548
+ // hips' local position. "swing" rotates a chain's end bone (the
549
+ // hand/foot); "fk"/"body" rotate an FK joint's bone.
550
+ const bone = kind === "swing" ? chains.get(track.id)?.bones[2] : fkJoints?.get(track.id)?.bone;
551
+ if (!bone) {
552
+ // A pick whose bone is gone (stale rig) must not half-start a drag.
553
+ dragRef.current = null;
554
+ return;
555
+ }
556
+ dragRef.current.startQuat = bone.quaternion.clone();
557
+ dragRef.current.startParentQuat = bone.parent.getWorldQuaternion(new THREE.Quaternion());
558
+ dragRef.current.eye = origin.clone().sub(camera.getWorldPosition(new THREE.Vector3())).negate().normalize();
559
+ dragRef.current.camDist = camera.getWorldPosition(new THREE.Vector3()).distanceTo(origin);
560
+ if (kind === "body") dragRef.current.startLocalPos = fkJoints.get(track.id).bone.position.clone();
561
+ }
562
+ focusRef.current?.(track.id);
563
+ gl.domElement.style.cursor = "grabbing";
564
+ window.addEventListener("pointermove", handlersRef.current.onMove);
565
+ window.addEventListener("pointerup", handlersRef.current.onUp);
566
+ window.addEventListener("pointercancel", handlersRef.current.onUp);
567
+ };
568
+
569
+ // Capture-phase pointerdown on the canvas: nearest registered handle /
570
+ // gizmo mesh under the pointer starts a drag. While a joint is FOCUSED,
571
+ // the pick list narrows to that joint only (sphere + its gizmo arrows),
572
+ // so unfocused handles are inert and can never shadow a gizmo arrow;
573
+ // with nothing focused every sphere is a focus+drag target. A press that
574
+ // hits nothing records an empty click (releases focus on pointerup).
575
+ useEffect(() => {
576
+ if (!enabled) return undefined;
577
+ const el = gl.domElement;
578
+ const onDown = (ev) => {
579
+ if (ev.button !== 0) return;
580
+ const downXY = [ev.clientX, ev.clientY];
581
+ const focused = focusIdRef.current;
582
+ let picks = pickRefs.current.filter((p) => p.mesh);
583
+ if (focused) picks = picks.filter((p) => p.track.id === focused);
584
+ else picks = picks.filter((p) => !p.axisDir); // spheres only
585
+ if (!picks.length) {
586
+ if (focused) beginDrag("empty", { id: "empty" }, null, null, downXY);
587
+ return;
588
+ }
589
+ const rect = el.getBoundingClientRect();
590
+ const ndc = new THREE.Vector2(
591
+ ((ev.clientX - rect.left) / rect.width) * 2 - 1,
592
+ -((ev.clientY - rect.top) / rect.height) * 2 + 1
593
+ );
594
+ raycaster.setFromCamera(ndc, camera);
595
+ const hits = raycaster.intersectObjects(picks.map((p) => p.mesh), false);
596
+ if (!hits.length) {
597
+ if (focused) beginDrag("empty", { id: "empty" }, null, null, downXY);
598
+ return;
599
+ }
600
+ const pick = picks.find((p) => p.mesh === hits[0].object);
601
+ if (!pick) return;
602
+ ev.stopImmediatePropagation();
603
+ ev.stopPropagation();
604
+ ev.preventDefault();
605
+ beginDrag(pick.kind, pick.track, pick.axisDir, ndc, downXY);
606
+ };
607
+ // The handles live on POSER_LAYER so they render only in the poser
608
+ // (IK working) view; the raycaster must see that layer to hit them.
609
+ raycaster.layers.enable(POSER_LAYER);
610
+ el.addEventListener("pointerdown", onDown, true);
611
+ return () => {
612
+ el.removeEventListener("pointerdown", onDown, true);
613
+ raycaster.layers.disable(POSER_LAYER);
614
+ };
615
+ });
616
+
617
+ if (!enabled || !chains || !ikState) return null;
618
+
619
+ const register = (track, kind, axisDir = null, part = null) => (m) => {
620
+ const idx = pickRefs.current.findIndex((p) => p.track === track && p.kind === kind && p.axisDir === axisDir && p.part === part);
621
+ if (idx >= 0) pickRefs.current[idx].mesh = m;
622
+ else pickRefs.current.push({ mesh: m, track, axisDir, kind, part });
623
+ };
624
+
625
+ const sphereFor = (track, kind, color, radius) => {
626
+ const focused = focus === track.id;
627
+ return (
628
+ <mesh
629
+ key={track.id}
630
+ ref={(m) => {
631
+ handleRefs.current[track.id] = m;
632
+ if (m) m.layers.set(POSER_LAYER);
633
+ register(track, kind)(m);
634
+ }}
635
+ renderOrder={999}
636
+ scale={focused ? 1.2 : 1}
637
+ onPointerOver={() => (gl.domElement.style.cursor = "grab")}
638
+ onPointerOut={() => {
639
+ if (!dragRef.current) gl.domElement.style.cursor = "";
640
+ }}
641
+ >
642
+ <sphereGeometry args={[radius, 16, 16]} />
643
+ <meshStandardMaterial
644
+ color="#000000"
645
+ emissive={color}
646
+ emissiveIntensity={focused ? 2.6 : 1.4}
647
+ toneMapped={false}
648
+ depthTest={false}
649
+ transparent
650
+ opacity={focused ? OPACITY_FOCUSED : OPACITY_UNFOCUSED}
651
+ />
652
+ </mesh>
653
+ );
654
+ };
655
+
656
+ // The gizmo appears on chain + mid (position) handles — and on the hips
657
+ // BODY handle: its arrows translate the body (Y = height, for crouching
658
+ // and lying poses) while its centre sphere keeps swinging the body.
659
+ const gizmoTrack =
660
+ IK_TRACKS.find((t) => t.id === focus) ||
661
+ MID_TRACKS.find((t) => t.id === focus) ||
662
+ (focus === "hips" ? (fkJoints?.get("hips")?.track ?? null) : null);
663
+ const gizmoKind = IK_TRACKS.find((t) => t.id === focus) ? "chain" : MID_TRACKS.find((t) => t.id === focus) ? "mid" : focus === "hips" ? "body" : null;
664
+ // The focused chain (hand/foot) also gets a rotation ring: arrows move
665
+ // the wrist/ankle, the ring turns the hand/foot itself.
666
+ const swingTrack = IK_TRACKS.find((t) => t.id === focus) ?? null;
667
+
668
+ return (
669
+ <group>
670
+ {IK_TRACKS.map((track) => (chains.has(track.id) ? sphereFor(track, "chain", ikHandleColor(track), HANDLE_R) : null))}
671
+ {MID_TRACKS.map((track) => (chains.has(track.chain) ? sphereFor(track, "mid", ikHandleColor({ kind: track.chain.includes("Hand") ? "arm" : "leg" }), JOINT_R) : null))}
672
+ {[...(fkJoints ?? [])].map(([id, joint]) => sphereFor(joint.track, id === "hips" ? "body" : "fk", joint.track.color, id === "hips" ? HANDLE_R : JOINT_R))}
673
+
674
+ {/* Mini move-gizmo on the focused position handle: world-axis arrows. */}
675
+ {gizmoTrack && (
676
+ <group ref={gizmoRef} renderOrder={999}>
677
+ {AXIS_DEFS.map(({ axis, dir, color }) => {
678
+ const quat = new THREE.Quaternion().setFromUnitVectors(new THREE.Vector3(0, 1, 0), dir);
679
+ return (
680
+ <group key={axis} quaternion={quat}>
681
+ <mesh
682
+ position={[0, GIZMO_LEN / 2, 0]}
683
+ ref={(m) => {
684
+ if (m) m.layers.set(POSER_LAYER);
685
+ register(gizmoTrack, gizmoKind, dir, "shaft")(m);
686
+ }}
687
+ onPointerOver={() => (gl.domElement.style.cursor = "grab")}
688
+ onPointerOut={() => {
689
+ if (!dragRef.current) gl.domElement.style.cursor = "";
690
+ }}
691
+ >
692
+ <cylinderGeometry args={[GIZMO_SHAFT_R, GIZMO_SHAFT_R, GIZMO_LEN, 8]} />
693
+ <meshStandardMaterial color="#000000" emissive={color} emissiveIntensity={2.2} toneMapped={false} depthTest={false} transparent opacity={0.9} />
694
+ </mesh>
695
+ <mesh
696
+ position={[0, GIZMO_LEN + 0.04, 0]}
697
+ ref={(m) => {
698
+ if (m) m.layers.set(POSER_LAYER);
699
+ register(gizmoTrack, gizmoKind, dir, "tip")(m);
700
+ }}
701
+ onPointerOver={() => (gl.domElement.style.cursor = "grab")}
702
+ onPointerOut={() => {
703
+ if (!dragRef.current) gl.domElement.style.cursor = "";
704
+ }}
705
+ >
706
+ <coneGeometry args={[GIZMO_TIP_R, 0.08, 12]} />
707
+ <meshStandardMaterial color="#000000" emissive={color} emissiveIntensity={2.4} toneMapped={false} depthTest={false} transparent opacity={0.95} />
708
+ </mesh>
709
+ </group>
710
+ );
711
+ })}
712
+ </group>
713
+ )}
714
+
715
+ {/* Trackball rotation ring on the focused hand/foot: turns the
716
+ effector itself while the position gizmo keeps moving it. A thin
717
+ visible torus plus a fat invisible one — a 3 px tube raycasts
718
+ terribly, so the pick target is generous. */}
719
+ {swingTrack && (
720
+ <group ref={(g) => { ringRef.current = g; }} renderOrder={999}>
721
+ <mesh
722
+ ref={(m) => {
723
+ if (m) m.layers.set(POSER_LAYER);
724
+ }}
725
+ onPointerOver={() => (gl.domElement.style.cursor = "grab")}
726
+ onPointerOut={() => {
727
+ if (!dragRef.current) gl.domElement.style.cursor = "";
728
+ }}
729
+ >
730
+ <torusGeometry args={[SWING_RING_R, SWING_RING_TUBE, 10, 48]} />
731
+ <meshStandardMaterial
732
+ color="#000000"
733
+ emissive={ikHandleColor(swingTrack)}
734
+ emissiveIntensity={2.2}
735
+ toneMapped={false}
736
+ depthTest={false}
737
+ transparent
738
+ opacity={0.9}
739
+ />
740
+ </mesh>
741
+ <mesh
742
+ ref={(m) => {
743
+ if (m) m.layers.set(POSER_LAYER);
744
+ register(swingTrack, "swing", null, "ring")(m);
745
+ }}
746
+ >
747
+ <torusGeometry args={[SWING_RING_R, SWING_RING_PICK_TUBE, 8, 32]} />
748
+ <meshBasicMaterial transparent opacity={0} depthWrite={false} />
749
+ </mesh>
750
+ </group>
751
+ )}
752
+ </group>
753
+ );
754
+ }
755
+
756
+ /**
757
+ * Pose Studio overlay panel. `poses` is the merged list (built-ins + custom)
758
+ * the caller owns; every pose has `{ id, label, bones }` and custom poses add
759
+ * `{ thumb, custom: true }`. The tile entry animation cascades with the same
760
+ * stagger as the reference.
761
+ */
762
+ export function PoseStudioPanel({ subject, poses, selectedId, onSelect, onApply, onReset, onSave, onDelete, onClose, closing }) {
763
+ return (
764
+ <div className={"pose-studio" + (closing ? " closing" : "")}>
765
+ <div className="studio-head">
766
+ <span>Pose Studio · Subject {subject}</span>
767
+ <button type="button" className="x" onClick={onClose}>
768
+
769
+ </button>
770
+ </div>
771
+ <div className="studio-actions">
772
+ <button type="button" className="btn primary full" onClick={onApply}>
773
+ Apply pose
774
+ </button>
775
+ <button type="button" className="btn ghost full" onClick={onReset}>
776
+ Reset pose
777
+ </button>
778
+ </div>
779
+ <div className="pose-grid">
780
+ {poses.map((pose, index) => (
781
+ <button
782
+ type="button"
783
+ key={pose.id}
784
+ className={"pose-tile" + (pose.id === selectedId ? " active" : "")}
785
+ style={{ animationDelay: `${0.04 + index * 0.028}s` }}
786
+ onClick={() => onSelect(pose.id)}
787
+ >
788
+ {pose.thumb ? <img src={pose.thumb} alt={pose.label} /> : <div className="tile-blank" />}
789
+ <span>{pose.label}</span>
790
+ {pose.custom && (
791
+ <em
792
+ className="del"
793
+ title="Delete"
794
+ onClick={(e) => {
795
+ e.stopPropagation();
796
+ onDelete(pose.id);
797
+ }}
798
+ >
799
+
800
+ </em>
801
+ )}
802
+ </button>
803
+ ))}
804
+ <button type="button" className="pose-tile add" title="Save the character's current pose" onClick={onSave}>
805
+ <span className="add-plus">+</span>
806
+ <span className="add-text">Save pose</span>
807
+ </button>
808
+ </div>
809
+ </div>
810
+ );
811
+ }