mujoco-react 0.1.0 → 0.3.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/README.md +209 -45
- package/dist/index.d.ts +180 -97
- package/dist/index.js +1148 -772
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/components/ContactMarkers.tsx +12 -19
- package/src/components/Debug.tsx +168 -33
- package/src/components/DragInteraction.tsx +1 -1
- package/src/components/IkController.tsx +262 -0
- package/src/components/IkGizmo.tsx +17 -25
- package/src/components/SceneLights.tsx +2 -112
- package/src/components/SceneRenderer.tsx +8 -6
- package/src/components/SelectionHighlight.tsx +2 -49
- package/src/components/TendonRenderer.tsx +90 -26
- package/src/components/TrajectoryPlayer.tsx +14 -10
- package/src/core/IkContext.tsx +40 -0
- package/src/core/MujocoCanvas.tsx +6 -3
- package/src/core/MujocoProvider.tsx +12 -4
- package/src/core/MujocoSimProvider.tsx +69 -334
- package/src/core/SceneLoader.ts +44 -11
- package/src/core/createController.tsx +91 -0
- package/src/hooks/useCameraAnimation.ts +102 -0
- package/src/hooks/useContacts.ts +52 -22
- package/src/hooks/useJointState.ts +18 -2
- package/src/hooks/useSceneLights.ts +117 -0
- package/src/hooks/useSelectionHighlight.ts +65 -0
- package/src/index.ts +16 -1
- package/src/types.ts +59 -22
package/package.json
CHANGED
|
@@ -12,15 +12,16 @@ import { useRef } from 'react';
|
|
|
12
12
|
import { useFrame } from '@react-three/fiber';
|
|
13
13
|
import * as THREE from 'three';
|
|
14
14
|
import { useMujocoSim } from '../core/MujocoSimProvider';
|
|
15
|
+
import { getContact } from '../types';
|
|
15
16
|
|
|
16
17
|
const _dummy = new THREE.Object3D();
|
|
17
18
|
|
|
18
19
|
interface ContactMarkersProps {
|
|
19
20
|
/** Maximum contacts to render. Default: 100. */
|
|
20
21
|
maxContacts?: number;
|
|
21
|
-
/** Sphere radius. Default: 0.
|
|
22
|
+
/** Sphere radius. Default: 0.008. */
|
|
22
23
|
radius?: number;
|
|
23
|
-
/** Color. Default: '#
|
|
24
|
+
/** Color. Default: '#22d3ee'. */
|
|
24
25
|
color?: string;
|
|
25
26
|
/** Show markers. Default: true. */
|
|
26
27
|
visible?: boolean;
|
|
@@ -28,8 +29,8 @@ interface ContactMarkersProps {
|
|
|
28
29
|
|
|
29
30
|
export function ContactMarkers({
|
|
30
31
|
maxContacts = 100,
|
|
31
|
-
radius = 0.
|
|
32
|
-
color = '#
|
|
32
|
+
radius = 0.008,
|
|
33
|
+
color = '#22d3ee',
|
|
33
34
|
visible = true,
|
|
34
35
|
}: ContactMarkersProps = {}) {
|
|
35
36
|
const { mjDataRef, status } = useMujocoSim();
|
|
@@ -47,18 +48,15 @@ export function ContactMarkers({
|
|
|
47
48
|
const count = Math.min(ncon, maxContacts);
|
|
48
49
|
|
|
49
50
|
for (let i = 0; i < count; i++) {
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
if (!c) break;
|
|
53
|
-
_dummy.position.set(c.pos[0], c.pos[1], c.pos[2]);
|
|
54
|
-
_dummy.updateMatrix();
|
|
55
|
-
mesh.setMatrixAt(i, _dummy.matrix);
|
|
56
|
-
} catch {
|
|
57
|
-
// Contact access failed — stop here
|
|
51
|
+
const c = getContact(data, i);
|
|
52
|
+
if (!c) {
|
|
58
53
|
mesh.count = i;
|
|
59
54
|
mesh.instanceMatrix.needsUpdate = true;
|
|
60
55
|
return;
|
|
61
56
|
}
|
|
57
|
+
_dummy.position.set(c.pos[0], c.pos[1], c.pos[2]);
|
|
58
|
+
_dummy.updateMatrix();
|
|
59
|
+
mesh.setMatrixAt(i, _dummy.matrix);
|
|
62
60
|
}
|
|
63
61
|
|
|
64
62
|
mesh.count = count;
|
|
@@ -68,14 +66,9 @@ export function ContactMarkers({
|
|
|
68
66
|
if (status !== 'ready') return null;
|
|
69
67
|
|
|
70
68
|
return (
|
|
71
|
-
<instancedMesh ref={meshRef} args={[undefined, undefined, maxContacts]}>
|
|
69
|
+
<instancedMesh ref={meshRef} args={[undefined, undefined, maxContacts]} frustumCulled={false} renderOrder={999}>
|
|
72
70
|
<sphereGeometry args={[radius, 8, 8]} />
|
|
73
|
-
<
|
|
74
|
-
color={color}
|
|
75
|
-
emissive={color}
|
|
76
|
-
emissiveIntensity={0.3}
|
|
77
|
-
roughness={0.5}
|
|
78
|
-
/>
|
|
71
|
+
<meshBasicMaterial color={color} depthTest={false} />
|
|
79
72
|
</instancedMesh>
|
|
80
73
|
);
|
|
81
74
|
}
|
package/src/components/Debug.tsx
CHANGED
|
@@ -10,6 +10,7 @@ import { useFrame, useThree } from '@react-three/fiber';
|
|
|
10
10
|
import * as THREE from 'three';
|
|
11
11
|
import { useMujocoSim } from '../core/MujocoSimProvider';
|
|
12
12
|
import { getName } from '../core/SceneLoader';
|
|
13
|
+
import { getContact } from '../types';
|
|
13
14
|
import type { DebugProps } from '../types';
|
|
14
15
|
|
|
15
16
|
const JOINT_COLORS: Record<number, number> = {
|
|
@@ -19,6 +20,14 @@ const JOINT_COLORS: Record<number, number> = {
|
|
|
19
20
|
3: 0xffff00, // hinge - yellow
|
|
20
21
|
};
|
|
21
22
|
|
|
23
|
+
// Preallocated temps to avoid per-frame GC pressure
|
|
24
|
+
const _v3a = new THREE.Vector3();
|
|
25
|
+
const _v3b = new THREE.Vector3();
|
|
26
|
+
const _quat = new THREE.Quaternion();
|
|
27
|
+
const _contactPos = new THREE.Vector3();
|
|
28
|
+
const _contactNormal = new THREE.Vector3();
|
|
29
|
+
const MAX_CONTACT_ARROWS = 50;
|
|
30
|
+
|
|
22
31
|
/**
|
|
23
32
|
* Declarative debug visualization component.
|
|
24
33
|
* Renders wireframe geoms, site markers, joint axes, contact forces, COM markers, etc.
|
|
@@ -78,27 +87,92 @@ export function Debug({
|
|
|
78
87
|
}
|
|
79
88
|
}
|
|
80
89
|
|
|
81
|
-
// Site markers
|
|
90
|
+
// Site markers — scale based on site_size if available, else use geom_size of parent body
|
|
82
91
|
if (showSites) {
|
|
92
|
+
const siteSize = (model as Record<string, unknown>).site_size as Float64Array | undefined;
|
|
83
93
|
for (let i = 0; i < model.nsite; i++) {
|
|
84
|
-
|
|
85
|
-
|
|
94
|
+
// Determine marker radius: use site_size[3*i] if available, else estimate from parent body's geoms
|
|
95
|
+
let radius = 0.008;
|
|
96
|
+
if (siteSize) {
|
|
97
|
+
radius = Math.max(siteSize[3 * i] * 0.5, 0.004);
|
|
98
|
+
} else {
|
|
99
|
+
// Estimate from parent body's geom sizes
|
|
100
|
+
const bodyId = model.site_bodyid[i];
|
|
101
|
+
let maxGeomSize = 0;
|
|
102
|
+
for (let g = 0; g < model.ngeom; g++) {
|
|
103
|
+
if (model.geom_bodyid[g] === bodyId) {
|
|
104
|
+
maxGeomSize = Math.max(maxGeomSize, model.geom_size[3 * g]);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
if (maxGeomSize > 0) radius = maxGeomSize * 0.15;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const geometry = new THREE.OctahedronGeometry(radius);
|
|
111
|
+
const mat = new THREE.MeshBasicMaterial({ color: 0xff00ff, depthTest: false });
|
|
86
112
|
const mesh = new THREE.Mesh(geometry, mat);
|
|
113
|
+
mesh.renderOrder = 999;
|
|
114
|
+
mesh.frustumCulled = false;
|
|
87
115
|
mesh.userData.siteId = i;
|
|
116
|
+
|
|
117
|
+
// Label
|
|
118
|
+
const canvas = document.createElement('canvas');
|
|
119
|
+
canvas.width = 256;
|
|
120
|
+
canvas.height = 64;
|
|
121
|
+
const ctx = canvas.getContext('2d')!;
|
|
122
|
+
ctx.fillStyle = '#ff00ff';
|
|
123
|
+
ctx.font = 'bold 36px monospace';
|
|
124
|
+
ctx.textAlign = 'center';
|
|
125
|
+
ctx.fillText(getName(model, model.name_siteadr[i]), 128, 42);
|
|
126
|
+
const tex = new THREE.CanvasTexture(canvas);
|
|
127
|
+
const spriteMat = new THREE.SpriteMaterial({ map: tex, depthTest: false, transparent: true });
|
|
128
|
+
const sprite = new THREE.Sprite(spriteMat);
|
|
129
|
+
const labelScale = radius * 15;
|
|
130
|
+
sprite.scale.set(labelScale, labelScale * 0.25, 1);
|
|
131
|
+
sprite.position.y = radius * 2;
|
|
132
|
+
sprite.renderOrder = 999;
|
|
133
|
+
mesh.add(sprite);
|
|
134
|
+
|
|
88
135
|
sites.push(mesh);
|
|
89
136
|
}
|
|
90
137
|
}
|
|
91
138
|
|
|
92
|
-
// Joint axes
|
|
139
|
+
// Joint axes — scale arrow length based on parent body's geom sizes
|
|
93
140
|
if (showJoints) {
|
|
141
|
+
// Safely check for jnt_pos/jnt_axis on the WASM model
|
|
142
|
+
const jntPos = (model as Record<string, unknown>).jnt_pos as Float64Array | undefined;
|
|
143
|
+
const jntAxis = (model as Record<string, unknown>).jnt_axis as Float64Array | undefined;
|
|
144
|
+
|
|
94
145
|
for (let i = 0; i < model.njnt; i++) {
|
|
95
146
|
const type = model.jnt_type[i];
|
|
96
147
|
const color = JOINT_COLORS[type] ?? 0xffffff;
|
|
148
|
+
|
|
149
|
+
// Scale based on parent body geom size
|
|
150
|
+
const bodyId = model.jnt_bodyid[i];
|
|
151
|
+
let maxGeomSize = 0;
|
|
152
|
+
for (let g = 0; g < model.ngeom; g++) {
|
|
153
|
+
if (model.geom_bodyid[g] === bodyId) {
|
|
154
|
+
maxGeomSize = Math.max(maxGeomSize, model.geom_size[3 * g]);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
const arrowLen = Math.max(maxGeomSize * 0.8, 0.05);
|
|
158
|
+
|
|
97
159
|
const arrow = new THREE.ArrowHelper(
|
|
98
160
|
new THREE.Vector3(0, 0, 1), new THREE.Vector3(),
|
|
99
|
-
|
|
161
|
+
arrowLen, color, arrowLen * 0.25, arrowLen * 0.12
|
|
100
162
|
);
|
|
163
|
+
// Render on top so arrows show through geometry
|
|
164
|
+
arrow.renderOrder = 999;
|
|
165
|
+
arrow.frustumCulled = false;
|
|
166
|
+
arrow.line.material = new THREE.LineBasicMaterial({ color, depthTest: false });
|
|
167
|
+
(arrow.cone.material as THREE.MeshBasicMaterial).depthTest = false;
|
|
168
|
+
arrow.line.renderOrder = 999;
|
|
169
|
+
arrow.line.frustumCulled = false;
|
|
170
|
+
arrow.cone.renderOrder = 999;
|
|
171
|
+
arrow.cone.frustumCulled = false;
|
|
101
172
|
arrow.userData.jointId = i;
|
|
173
|
+
arrow.userData.bodyId = bodyId;
|
|
174
|
+
arrow.userData.hasJntPos = !!jntPos;
|
|
175
|
+
arrow.userData.hasJntAxis = !!jntAxis;
|
|
102
176
|
joints.push(arrow);
|
|
103
177
|
}
|
|
104
178
|
}
|
|
@@ -144,6 +218,10 @@ export function Debug({
|
|
|
144
218
|
const data = mjDataRef.current;
|
|
145
219
|
if (!model || !data || !debugGeometry) return;
|
|
146
220
|
|
|
221
|
+
// Safely grab optional arrays once
|
|
222
|
+
const jntPos = (model as Record<string, unknown>).jnt_pos as Float64Array | undefined;
|
|
223
|
+
const jntAxis = (model as Record<string, unknown>).jnt_axis as Float64Array | undefined;
|
|
224
|
+
|
|
147
225
|
// Update geom wireframes
|
|
148
226
|
for (const mesh of debugGeometry.geoms) {
|
|
149
227
|
const bid = mesh.userData.bodyId;
|
|
@@ -157,8 +235,9 @@ export function Debug({
|
|
|
157
235
|
// Apply local geom offset
|
|
158
236
|
const gid = mesh.userData.geomId;
|
|
159
237
|
const gp = model.geom_pos;
|
|
160
|
-
|
|
161
|
-
.applyQuaternion(mesh.quaternion)
|
|
238
|
+
_v3a.set(gp[3 * gid], gp[3 * gid + 1], gp[3 * gid + 2])
|
|
239
|
+
.applyQuaternion(mesh.quaternion);
|
|
240
|
+
mesh.position.add(_v3a);
|
|
162
241
|
}
|
|
163
242
|
|
|
164
243
|
// Update site markers
|
|
@@ -171,6 +250,35 @@ export function Debug({
|
|
|
171
250
|
);
|
|
172
251
|
}
|
|
173
252
|
|
|
253
|
+
// Update joint axes
|
|
254
|
+
for (const obj of debugGeometry.joints) {
|
|
255
|
+
const arrow = obj as THREE.ArrowHelper;
|
|
256
|
+
const jid = arrow.userData.jointId;
|
|
257
|
+
const bid = arrow.userData.bodyId;
|
|
258
|
+
const i3 = bid * 3;
|
|
259
|
+
const i4 = bid * 4;
|
|
260
|
+
|
|
261
|
+
_quat.set(
|
|
262
|
+
data.xquat[i4 + 1], data.xquat[i4 + 2],
|
|
263
|
+
data.xquat[i4 + 3], data.xquat[i4]
|
|
264
|
+
);
|
|
265
|
+
|
|
266
|
+
// Position: body origin + local joint anchor (if available)
|
|
267
|
+
arrow.position.set(data.xpos[i3], data.xpos[i3 + 1], data.xpos[i3 + 2]);
|
|
268
|
+
if (jntPos) {
|
|
269
|
+
_v3a.set(jntPos[3 * jid], jntPos[3 * jid + 1], jntPos[3 * jid + 2])
|
|
270
|
+
.applyQuaternion(_quat);
|
|
271
|
+
arrow.position.add(_v3a);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// Orient along joint axis in world frame (if available)
|
|
275
|
+
if (jntAxis) {
|
|
276
|
+
_v3a.set(jntAxis[3 * jid], jntAxis[3 * jid + 1], jntAxis[3 * jid + 2])
|
|
277
|
+
.applyQuaternion(_quat).normalize();
|
|
278
|
+
arrow.setDirection(_v3a);
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
174
282
|
// Update COM markers
|
|
175
283
|
for (const mesh of debugGeometry.comMarkers) {
|
|
176
284
|
const bid = mesh.userData.bodyId;
|
|
@@ -179,41 +287,68 @@ export function Debug({
|
|
|
179
287
|
}
|
|
180
288
|
});
|
|
181
289
|
|
|
182
|
-
// Contact force vectors
|
|
290
|
+
// Contact force vectors — pre-created pool to avoid per-frame allocation
|
|
183
291
|
const contactGroupRef = useRef<THREE.Group>(null);
|
|
184
|
-
const
|
|
292
|
+
const contactPoolRef = useRef<THREE.ArrowHelper[]>([]);
|
|
293
|
+
const contactPoolInitRef = useRef(false);
|
|
185
294
|
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
const model = mjModelRef.current;
|
|
189
|
-
const data = mjDataRef.current;
|
|
295
|
+
// Initialize arrow pool once
|
|
296
|
+
useEffect(() => {
|
|
190
297
|
const group = contactGroupRef.current;
|
|
191
|
-
if (!
|
|
298
|
+
if (!group || contactPoolInitRef.current) return;
|
|
299
|
+
contactPoolInitRef.current = true;
|
|
192
300
|
|
|
193
|
-
|
|
194
|
-
for (
|
|
195
|
-
|
|
196
|
-
|
|
301
|
+
const pool: THREE.ArrowHelper[] = [];
|
|
302
|
+
for (let i = 0; i < MAX_CONTACT_ARROWS; i++) {
|
|
303
|
+
const arrow = new THREE.ArrowHelper(
|
|
304
|
+
new THREE.Vector3(0, 1, 0), new THREE.Vector3(), 0.1, 0xff4444, 0.03, 0.015
|
|
305
|
+
);
|
|
306
|
+
arrow.visible = false;
|
|
307
|
+
group.add(arrow);
|
|
308
|
+
pool.push(arrow);
|
|
197
309
|
}
|
|
198
|
-
|
|
310
|
+
contactPoolRef.current = pool;
|
|
311
|
+
|
|
312
|
+
return () => {
|
|
313
|
+
for (const arrow of pool) {
|
|
314
|
+
group.remove(arrow);
|
|
315
|
+
arrow.dispose();
|
|
316
|
+
}
|
|
317
|
+
contactPoolRef.current = [];
|
|
318
|
+
contactPoolInitRef.current = false;
|
|
319
|
+
};
|
|
320
|
+
}, [showContacts]);
|
|
321
|
+
|
|
322
|
+
useFrame(() => {
|
|
323
|
+
if (!showContacts) return;
|
|
324
|
+
const data = mjDataRef.current;
|
|
325
|
+
const pool = contactPoolRef.current;
|
|
326
|
+
if (!data || pool.length === 0) return;
|
|
199
327
|
|
|
200
328
|
const ncon = data.ncon;
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
329
|
+
let arrowIdx = 0;
|
|
330
|
+
|
|
331
|
+
for (let i = 0; i < Math.min(ncon, MAX_CONTACT_ARROWS); i++) {
|
|
332
|
+
const c = getContact(data, i);
|
|
333
|
+
if (!c) break;
|
|
334
|
+
_contactPos.set(c.pos[0], c.pos[1], c.pos[2]);
|
|
335
|
+
_contactNormal.set(c.frame[0], c.frame[1], c.frame[2]);
|
|
336
|
+
const force = Math.abs(c.dist) * 100;
|
|
337
|
+
const length = Math.min(force * 0.01, 0.1);
|
|
338
|
+
if (length > 0.001 && arrowIdx < pool.length) {
|
|
339
|
+
const arrow = pool[arrowIdx];
|
|
340
|
+
arrow.position.copy(_contactPos);
|
|
341
|
+
arrow.setDirection(_contactNormal);
|
|
342
|
+
arrow.setLength(length, length * 0.3, length * 0.15);
|
|
343
|
+
arrow.visible = true;
|
|
344
|
+
arrowIdx++;
|
|
215
345
|
}
|
|
216
346
|
}
|
|
347
|
+
|
|
348
|
+
// Hide unused arrows
|
|
349
|
+
for (let i = arrowIdx; i < pool.length; i++) {
|
|
350
|
+
pool[i].visible = false;
|
|
351
|
+
}
|
|
217
352
|
});
|
|
218
353
|
|
|
219
354
|
if (status !== 'ready') return null;
|
|
@@ -208,7 +208,7 @@ export function DragInteraction({
|
|
|
208
208
|
|
|
209
209
|
if (draggingRef.current && bodyIdRef.current > 0) {
|
|
210
210
|
arrow.visible = true;
|
|
211
|
-
const dir = mouseWorldRef.current
|
|
211
|
+
const dir = _bodyPos.copy(mouseWorldRef.current).sub(grabWorldRef.current);
|
|
212
212
|
const len = dir.length();
|
|
213
213
|
if (len > 0.001) {
|
|
214
214
|
dir.normalize();
|
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*
|
|
5
|
+
* IkController — composable IK controller plugin.
|
|
6
|
+
* Extracts all IK logic from MujocoSimProvider into an opt-in component.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { useCallback, useEffect, useMemo, useRef } from 'react';
|
|
10
|
+
import { useFrame } from '@react-three/fiber';
|
|
11
|
+
import * as THREE from 'three';
|
|
12
|
+
import { createController } from '../core/createController';
|
|
13
|
+
import { IkContext, type IkContextValue } from '../core/IkContext';
|
|
14
|
+
import { useMujocoSim, useBeforePhysicsStep } from '../core/MujocoSimProvider';
|
|
15
|
+
import { GenericIK } from '../core/GenericIK';
|
|
16
|
+
import { findSiteByName } from '../core/SceneLoader';
|
|
17
|
+
import type { IkConfig, IKSolveFn, MujocoData } from '../types';
|
|
18
|
+
|
|
19
|
+
// Preallocated temp for syncGizmoToSite
|
|
20
|
+
const _syncMat4 = new THREE.Matrix4();
|
|
21
|
+
|
|
22
|
+
function syncGizmoToSite(data: MujocoData, siteId: number, target: THREE.Group) {
|
|
23
|
+
if (siteId === -1) return;
|
|
24
|
+
const sitePos = data.site_xpos.subarray(siteId * 3, siteId * 3 + 3);
|
|
25
|
+
const siteMat = data.site_xmat.subarray(siteId * 9, siteId * 9 + 9);
|
|
26
|
+
target.position.set(sitePos[0], sitePos[1], sitePos[2]);
|
|
27
|
+
_syncMat4.set(
|
|
28
|
+
siteMat[0], siteMat[1], siteMat[2], 0,
|
|
29
|
+
siteMat[3], siteMat[4], siteMat[5], 0,
|
|
30
|
+
siteMat[6], siteMat[7], siteMat[8], 0,
|
|
31
|
+
0, 0, 0, 1,
|
|
32
|
+
);
|
|
33
|
+
target.quaternion.setFromRotationMatrix(_syncMat4);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function IkControllerImpl({
|
|
37
|
+
config,
|
|
38
|
+
children,
|
|
39
|
+
}: {
|
|
40
|
+
config: IkConfig;
|
|
41
|
+
children?: React.ReactNode;
|
|
42
|
+
}) {
|
|
43
|
+
const { mjModelRef, mjDataRef, mujocoRef, configRef, resetCallbacks, status } =
|
|
44
|
+
useMujocoSim();
|
|
45
|
+
|
|
46
|
+
// All IK state lives here, NOT in the provider
|
|
47
|
+
const ikEnabledRef = useRef(false);
|
|
48
|
+
const ikCalculatingRef = useRef(false);
|
|
49
|
+
const ikTargetRef = useRef<THREE.Group>(new THREE.Group());
|
|
50
|
+
const siteIdRef = useRef(-1);
|
|
51
|
+
const genericIkRef = useRef<GenericIK>(new GenericIK(mujocoRef.current));
|
|
52
|
+
const firstIkEnableRef = useRef(true);
|
|
53
|
+
|
|
54
|
+
const needsInitialSync = useRef(true);
|
|
55
|
+
|
|
56
|
+
const gizmoAnimRef = useRef({
|
|
57
|
+
active: false,
|
|
58
|
+
startPos: new THREE.Vector3(),
|
|
59
|
+
endPos: new THREE.Vector3(),
|
|
60
|
+
startRot: new THREE.Quaternion(),
|
|
61
|
+
endRot: new THREE.Quaternion(),
|
|
62
|
+
startTime: 0,
|
|
63
|
+
duration: 1000,
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
// Resolve site ID when model loads or config changes
|
|
67
|
+
useEffect(() => {
|
|
68
|
+
const model = mjModelRef.current;
|
|
69
|
+
if (!model || status !== 'ready') {
|
|
70
|
+
siteIdRef.current = -1;
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
siteIdRef.current = findSiteByName(model, config.siteName);
|
|
74
|
+
const data = mjDataRef.current;
|
|
75
|
+
if (data && ikTargetRef.current) {
|
|
76
|
+
syncGizmoToSite(data, siteIdRef.current, ikTargetRef.current);
|
|
77
|
+
}
|
|
78
|
+
}, [config.siteName, status, mjModelRef, mjDataRef]);
|
|
79
|
+
|
|
80
|
+
// IK solve function — use custom solver if provided, otherwise built-in GenericIK
|
|
81
|
+
const ikSolveFn = useCallback(
|
|
82
|
+
(pos: THREE.Vector3, quat: THREE.Quaternion, currentQ: number[]): number[] | null => {
|
|
83
|
+
if (config.ikSolveFn) return config.ikSolveFn(pos, quat, currentQ);
|
|
84
|
+
const model = mjModelRef.current;
|
|
85
|
+
const data = mjDataRef.current;
|
|
86
|
+
if (!model || !data || siteIdRef.current === -1) return null;
|
|
87
|
+
return genericIkRef.current.solve(
|
|
88
|
+
model,
|
|
89
|
+
data,
|
|
90
|
+
siteIdRef.current,
|
|
91
|
+
config.numJoints,
|
|
92
|
+
pos,
|
|
93
|
+
quat,
|
|
94
|
+
currentQ,
|
|
95
|
+
{
|
|
96
|
+
damping: config.damping,
|
|
97
|
+
maxIterations: config.maxIterations,
|
|
98
|
+
},
|
|
99
|
+
);
|
|
100
|
+
},
|
|
101
|
+
[config.ikSolveFn, config.numJoints, config.damping, config.maxIterations, mjModelRef, mjDataRef],
|
|
102
|
+
);
|
|
103
|
+
const ikSolveFnRef = useRef<IKSolveFn>(ikSolveFn);
|
|
104
|
+
ikSolveFnRef.current = ikSolveFn;
|
|
105
|
+
|
|
106
|
+
// Gizmo animation + one-time initial sync in useFrame
|
|
107
|
+
useFrame(() => {
|
|
108
|
+
// Ensure the gizmo is positioned at the site after the first physics step
|
|
109
|
+
if (needsInitialSync.current && siteIdRef.current !== -1) {
|
|
110
|
+
const data = mjDataRef.current;
|
|
111
|
+
if (data && ikTargetRef.current) {
|
|
112
|
+
syncGizmoToSite(data, siteIdRef.current, ikTargetRef.current);
|
|
113
|
+
needsInitialSync.current = false;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const ga = gizmoAnimRef.current;
|
|
118
|
+
const target = ikTargetRef.current;
|
|
119
|
+
if (!ga.active || !target) return;
|
|
120
|
+
|
|
121
|
+
const now = performance.now();
|
|
122
|
+
const elapsed = now - ga.startTime;
|
|
123
|
+
const t = Math.min(elapsed / ga.duration, 1.0);
|
|
124
|
+
const ease = 1 - Math.pow(1 - t, 3);
|
|
125
|
+
target.position.lerpVectors(ga.startPos, ga.endPos, ease);
|
|
126
|
+
target.quaternion.slerpQuaternions(ga.startRot, ga.endRot, ease);
|
|
127
|
+
if (t >= 1.0) ga.active = false;
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
// IK solve in physics loop
|
|
131
|
+
useBeforePhysicsStep((model, data) => {
|
|
132
|
+
if (!ikEnabledRef.current) {
|
|
133
|
+
ikCalculatingRef.current = false;
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
const target = ikTargetRef.current;
|
|
137
|
+
if (!target) return;
|
|
138
|
+
|
|
139
|
+
ikCalculatingRef.current = true;
|
|
140
|
+
const numJoints = config.numJoints;
|
|
141
|
+
const currentQ: number[] = [];
|
|
142
|
+
for (let i = 0; i < numJoints; i++) currentQ.push(data.qpos[i]);
|
|
143
|
+
const solution = ikSolveFnRef.current(target.position, target.quaternion, currentQ);
|
|
144
|
+
if (solution) {
|
|
145
|
+
for (let i = 0; i < numJoints; i++) data.ctrl[i] = solution[i];
|
|
146
|
+
}
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
// Reset callback — sync gizmo and reset IK state
|
|
150
|
+
useEffect(() => {
|
|
151
|
+
const cb = () => {
|
|
152
|
+
const data = mjDataRef.current;
|
|
153
|
+
if (data && ikTargetRef.current) {
|
|
154
|
+
syncGizmoToSite(data, siteIdRef.current, ikTargetRef.current);
|
|
155
|
+
}
|
|
156
|
+
gizmoAnimRef.current.active = false;
|
|
157
|
+
firstIkEnableRef.current = true;
|
|
158
|
+
ikEnabledRef.current = false;
|
|
159
|
+
needsInitialSync.current = true;
|
|
160
|
+
};
|
|
161
|
+
resetCallbacks.current.add(cb);
|
|
162
|
+
return () => {
|
|
163
|
+
resetCallbacks.current.delete(cb);
|
|
164
|
+
};
|
|
165
|
+
}, [resetCallbacks, mjDataRef]);
|
|
166
|
+
|
|
167
|
+
// --- API methods ---
|
|
168
|
+
|
|
169
|
+
const setIkEnabled = useCallback(
|
|
170
|
+
(enabled: boolean) => {
|
|
171
|
+
ikEnabledRef.current = enabled;
|
|
172
|
+
const data = mjDataRef.current;
|
|
173
|
+
if (enabled && data && !gizmoAnimRef.current.active && ikTargetRef.current) {
|
|
174
|
+
syncGizmoToSite(data, siteIdRef.current, ikTargetRef.current);
|
|
175
|
+
firstIkEnableRef.current = false;
|
|
176
|
+
}
|
|
177
|
+
},
|
|
178
|
+
[mjDataRef],
|
|
179
|
+
);
|
|
180
|
+
|
|
181
|
+
const syncTargetToSiteApi = useCallback(() => {
|
|
182
|
+
const data = mjDataRef.current;
|
|
183
|
+
const target = ikTargetRef.current;
|
|
184
|
+
if (data && target) syncGizmoToSite(data, siteIdRef.current, target);
|
|
185
|
+
}, [mjDataRef]);
|
|
186
|
+
|
|
187
|
+
const solveIK = useCallback(
|
|
188
|
+
(pos: THREE.Vector3, quat: THREE.Quaternion, currentQ: number[]): number[] | null => {
|
|
189
|
+
return ikSolveFnRef.current(pos, quat, currentQ);
|
|
190
|
+
},
|
|
191
|
+
[],
|
|
192
|
+
);
|
|
193
|
+
|
|
194
|
+
const moveTarget = useCallback(
|
|
195
|
+
(pos: THREE.Vector3, duration = 0) => {
|
|
196
|
+
if (!ikEnabledRef.current) setIkEnabled(true);
|
|
197
|
+
const target = ikTargetRef.current;
|
|
198
|
+
if (!target) return;
|
|
199
|
+
|
|
200
|
+
const targetPos = pos.clone();
|
|
201
|
+
const targetRot = new THREE.Quaternion().setFromEuler(
|
|
202
|
+
new THREE.Euler(Math.PI, 0, 0),
|
|
203
|
+
);
|
|
204
|
+
|
|
205
|
+
if (duration > 0) {
|
|
206
|
+
const ga = gizmoAnimRef.current;
|
|
207
|
+
ga.active = true;
|
|
208
|
+
ga.startPos.copy(target.position);
|
|
209
|
+
ga.endPos.copy(targetPos);
|
|
210
|
+
ga.startRot.copy(target.quaternion);
|
|
211
|
+
ga.endRot.copy(targetRot);
|
|
212
|
+
ga.startTime = performance.now();
|
|
213
|
+
ga.duration = duration;
|
|
214
|
+
} else {
|
|
215
|
+
gizmoAnimRef.current.active = false;
|
|
216
|
+
target.position.copy(targetPos);
|
|
217
|
+
target.quaternion.copy(targetRot);
|
|
218
|
+
}
|
|
219
|
+
},
|
|
220
|
+
[setIkEnabled],
|
|
221
|
+
);
|
|
222
|
+
|
|
223
|
+
const getGizmoStats = useCallback(
|
|
224
|
+
(): { pos: THREE.Vector3; rot: THREE.Euler } | null => {
|
|
225
|
+
const target = ikTargetRef.current;
|
|
226
|
+
if (!ikCalculatingRef.current || !target) return null;
|
|
227
|
+
return {
|
|
228
|
+
pos: target.position.clone(),
|
|
229
|
+
rot: new THREE.Euler().setFromQuaternion(target.quaternion),
|
|
230
|
+
};
|
|
231
|
+
},
|
|
232
|
+
[],
|
|
233
|
+
);
|
|
234
|
+
|
|
235
|
+
const contextValue = useMemo<IkContextValue>(
|
|
236
|
+
() => ({
|
|
237
|
+
ikEnabledRef,
|
|
238
|
+
ikCalculatingRef,
|
|
239
|
+
ikTargetRef,
|
|
240
|
+
siteIdRef,
|
|
241
|
+
setIkEnabled,
|
|
242
|
+
moveTarget,
|
|
243
|
+
syncTargetToSite: syncTargetToSiteApi,
|
|
244
|
+
solveIK,
|
|
245
|
+
getGizmoStats,
|
|
246
|
+
}),
|
|
247
|
+
[setIkEnabled, moveTarget, syncTargetToSiteApi, solveIK, getGizmoStats],
|
|
248
|
+
);
|
|
249
|
+
|
|
250
|
+
return <IkContext.Provider value={contextValue}>{children}</IkContext.Provider>;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
export const IkController = createController<IkConfig>(
|
|
254
|
+
{
|
|
255
|
+
name: 'IkController',
|
|
256
|
+
defaultConfig: {
|
|
257
|
+
damping: 0.01,
|
|
258
|
+
maxIterations: 50,
|
|
259
|
+
},
|
|
260
|
+
},
|
|
261
|
+
IkControllerImpl,
|
|
262
|
+
);
|