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
|
@@ -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
|
+
}
|