mujoco-react 0.1.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 +177 -0
- package/README.md +510 -0
- package/dist/index.d.ts +1080 -0
- package/dist/index.js +3518 -0
- package/dist/index.js.map +1 -0
- package/package.json +64 -0
- package/src/components/ContactListener.tsx +26 -0
- package/src/components/ContactMarkers.tsx +81 -0
- package/src/components/Debug.tsx +227 -0
- package/src/components/DragInteraction.tsx +227 -0
- package/src/components/FlexRenderer.tsx +102 -0
- package/src/components/IkGizmo.tsx +146 -0
- package/src/components/SceneLights.tsx +131 -0
- package/src/components/SceneRenderer.tsx +104 -0
- package/src/components/SelectionHighlight.tsx +69 -0
- package/src/components/TendonRenderer.tsx +84 -0
- package/src/components/TrajectoryPlayer.tsx +44 -0
- package/src/core/GenericIK.ts +339 -0
- package/src/core/MujocoCanvas.tsx +72 -0
- package/src/core/MujocoProvider.tsx +78 -0
- package/src/core/MujocoSimProvider.tsx +1201 -0
- package/src/core/SceneLoader.ts +275 -0
- package/src/hooks/useActuators.ts +36 -0
- package/src/hooks/useBodyState.ts +56 -0
- package/src/hooks/useContacts.ts +125 -0
- package/src/hooks/useCtrl.ts +40 -0
- package/src/hooks/useCtrlNoise.ts +59 -0
- package/src/hooks/useGamepad.ts +77 -0
- package/src/hooks/useGravityCompensation.ts +22 -0
- package/src/hooks/useJointState.ts +64 -0
- package/src/hooks/useKeyboardTeleop.ts +97 -0
- package/src/hooks/usePolicy.ts +56 -0
- package/src/hooks/useSensor.ts +83 -0
- package/src/hooks/useSitePosition.ts +62 -0
- package/src/hooks/useTrajectoryPlayer.ts +105 -0
- package/src/hooks/useTrajectoryRecorder.ts +97 -0
- package/src/hooks/useVideoRecorder.ts +82 -0
- package/src/index.ts +108 -0
- package/src/rendering/CapsuleGeometry.ts +35 -0
- package/src/rendering/GeomBuilder.ts +140 -0
- package/src/rendering/Reflector.ts +225 -0
- package/src/types.ts +619 -0
package/package.json
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "mujoco-react",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Composable React Three Fiber building blocks for MuJoCo WASM simulations",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./dist/index.js",
|
|
7
|
+
"module": "./dist/index.js",
|
|
8
|
+
"types": "./dist/index.d.ts",
|
|
9
|
+
"exports": {
|
|
10
|
+
".": {
|
|
11
|
+
"import": {
|
|
12
|
+
"types": "./dist/index.d.ts",
|
|
13
|
+
"default": "./dist/index.js"
|
|
14
|
+
}
|
|
15
|
+
},
|
|
16
|
+
"./package.json": "./package.json"
|
|
17
|
+
},
|
|
18
|
+
"files": [
|
|
19
|
+
"dist",
|
|
20
|
+
"src"
|
|
21
|
+
],
|
|
22
|
+
"sideEffects": false,
|
|
23
|
+
"keywords": [
|
|
24
|
+
"mujoco",
|
|
25
|
+
"react",
|
|
26
|
+
"three",
|
|
27
|
+
"r3f",
|
|
28
|
+
"react-three-fiber",
|
|
29
|
+
"physics",
|
|
30
|
+
"simulation",
|
|
31
|
+
"robotics",
|
|
32
|
+
"wasm"
|
|
33
|
+
],
|
|
34
|
+
"license": "Apache-2.0",
|
|
35
|
+
"repository": {
|
|
36
|
+
"type": "git",
|
|
37
|
+
"url": "https://github.com/noah/mujoco-react"
|
|
38
|
+
},
|
|
39
|
+
"scripts": {
|
|
40
|
+
"build": "tsup",
|
|
41
|
+
"dev": "tsup --watch",
|
|
42
|
+
"typecheck": "tsc --noEmit",
|
|
43
|
+
"prepublishOnly": "npm run build"
|
|
44
|
+
},
|
|
45
|
+
"peerDependencies": {
|
|
46
|
+
"@react-three/drei": ">=9",
|
|
47
|
+
"@react-three/fiber": ">=8",
|
|
48
|
+
"react": ">=18",
|
|
49
|
+
"three": ">=0.160.0"
|
|
50
|
+
},
|
|
51
|
+
"dependencies": {
|
|
52
|
+
"mujoco-js": "0.0.7"
|
|
53
|
+
},
|
|
54
|
+
"devDependencies": {
|
|
55
|
+
"@react-three/drei": "^10.7.7",
|
|
56
|
+
"@react-three/fiber": "^9.5.0",
|
|
57
|
+
"@types/react": "^19.0.0",
|
|
58
|
+
"@types/three": "^0.181.0",
|
|
59
|
+
"react": "^19.2.0",
|
|
60
|
+
"three": "^0.181.0",
|
|
61
|
+
"tsup": "^8.4.0",
|
|
62
|
+
"typescript": "~5.8.2"
|
|
63
|
+
}
|
|
64
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*
|
|
5
|
+
* ContactListener — component form of contact events (spec 2.5)
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { useContactEvents } from '../hooks/useContacts';
|
|
9
|
+
import type { ContactListenerProps } from '../types';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Component form of useContactEvents.
|
|
13
|
+
* Fires onContactEnter/onContactExit callbacks when contacts change.
|
|
14
|
+
*/
|
|
15
|
+
export function ContactListener({
|
|
16
|
+
body,
|
|
17
|
+
onContactEnter,
|
|
18
|
+
onContactExit,
|
|
19
|
+
}: ContactListenerProps) {
|
|
20
|
+
useContactEvents(body, {
|
|
21
|
+
onEnter: onContactEnter,
|
|
22
|
+
onExit: onContactExit,
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*
|
|
5
|
+
* ContactMarkers — instanced sphere visualization of MuJoCo contacts (spec 6.2)
|
|
6
|
+
*
|
|
7
|
+
* Fixed from original: reads data.ncon first, accesses contact via .get(i),
|
|
8
|
+
* limits to maxContacts to avoid WASM heap OOM.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { useRef } from 'react';
|
|
12
|
+
import { useFrame } from '@react-three/fiber';
|
|
13
|
+
import * as THREE from 'three';
|
|
14
|
+
import { useMujocoSim } from '../core/MujocoSimProvider';
|
|
15
|
+
|
|
16
|
+
const _dummy = new THREE.Object3D();
|
|
17
|
+
|
|
18
|
+
interface ContactMarkersProps {
|
|
19
|
+
/** Maximum contacts to render. Default: 100. */
|
|
20
|
+
maxContacts?: number;
|
|
21
|
+
/** Sphere radius. Default: 0.005. */
|
|
22
|
+
radius?: number;
|
|
23
|
+
/** Color. Default: '#4f46e5'. */
|
|
24
|
+
color?: string;
|
|
25
|
+
/** Show markers. Default: true. */
|
|
26
|
+
visible?: boolean;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function ContactMarkers({
|
|
30
|
+
maxContacts = 100,
|
|
31
|
+
radius = 0.005,
|
|
32
|
+
color = '#4f46e5',
|
|
33
|
+
visible = true,
|
|
34
|
+
}: ContactMarkersProps = {}) {
|
|
35
|
+
const { mjDataRef, status } = useMujocoSim();
|
|
36
|
+
const meshRef = useRef<THREE.InstancedMesh>(null);
|
|
37
|
+
|
|
38
|
+
useFrame(() => {
|
|
39
|
+
const mesh = meshRef.current;
|
|
40
|
+
const data = mjDataRef.current;
|
|
41
|
+
if (!mesh || !data || !visible) {
|
|
42
|
+
if (mesh) mesh.count = 0;
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const ncon = data.ncon;
|
|
47
|
+
const count = Math.min(ncon, maxContacts);
|
|
48
|
+
|
|
49
|
+
for (let i = 0; i < count; i++) {
|
|
50
|
+
try {
|
|
51
|
+
const c = (data.contact as { get(i: number): { pos: Float64Array } | undefined }).get(i);
|
|
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
|
|
58
|
+
mesh.count = i;
|
|
59
|
+
mesh.instanceMatrix.needsUpdate = true;
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
mesh.count = count;
|
|
65
|
+
mesh.instanceMatrix.needsUpdate = true;
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
if (status !== 'ready') return null;
|
|
69
|
+
|
|
70
|
+
return (
|
|
71
|
+
<instancedMesh ref={meshRef} args={[undefined, undefined, maxContacts]}>
|
|
72
|
+
<sphereGeometry args={[radius, 8, 8]} />
|
|
73
|
+
<meshStandardMaterial
|
|
74
|
+
color={color}
|
|
75
|
+
emissive={color}
|
|
76
|
+
emissiveIntensity={0.3}
|
|
77
|
+
roughness={0.5}
|
|
78
|
+
/>
|
|
79
|
+
</instancedMesh>
|
|
80
|
+
);
|
|
81
|
+
}
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*
|
|
5
|
+
* Debug — visualization overlay for MuJoCo scene elements (spec 6.1)
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { useEffect, useMemo, useRef } from 'react';
|
|
9
|
+
import { useFrame, useThree } from '@react-three/fiber';
|
|
10
|
+
import * as THREE from 'three';
|
|
11
|
+
import { useMujocoSim } from '../core/MujocoSimProvider';
|
|
12
|
+
import { getName } from '../core/SceneLoader';
|
|
13
|
+
import type { DebugProps } from '../types';
|
|
14
|
+
|
|
15
|
+
const JOINT_COLORS: Record<number, number> = {
|
|
16
|
+
0: 0xff0000, // free - red
|
|
17
|
+
1: 0x00ff00, // ball - green
|
|
18
|
+
2: 0x0000ff, // slide - blue
|
|
19
|
+
3: 0xffff00, // hinge - yellow
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Declarative debug visualization component.
|
|
24
|
+
* Renders wireframe geoms, site markers, joint axes, contact forces, COM markers, etc.
|
|
25
|
+
*/
|
|
26
|
+
export function Debug({
|
|
27
|
+
showGeoms = false,
|
|
28
|
+
showSites = false,
|
|
29
|
+
showJoints = false,
|
|
30
|
+
showContacts = false,
|
|
31
|
+
showCOM = false,
|
|
32
|
+
showInertia = false,
|
|
33
|
+
showTendons = false,
|
|
34
|
+
}: DebugProps) {
|
|
35
|
+
const { mjModelRef, mjDataRef, status } = useMujocoSim();
|
|
36
|
+
const { scene } = useThree();
|
|
37
|
+
const groupRef = useRef<THREE.Group>(null);
|
|
38
|
+
|
|
39
|
+
// Build static debug geometry when model loads
|
|
40
|
+
const debugGeometry = useMemo(() => {
|
|
41
|
+
const model = mjModelRef.current;
|
|
42
|
+
if (!model || status !== 'ready') return null;
|
|
43
|
+
|
|
44
|
+
const geoms: THREE.Object3D[] = [];
|
|
45
|
+
const sites: THREE.Object3D[] = [];
|
|
46
|
+
const joints: THREE.Object3D[] = [];
|
|
47
|
+
const comMarkers: THREE.Object3D[] = [];
|
|
48
|
+
|
|
49
|
+
// Wireframe geoms
|
|
50
|
+
if (showGeoms) {
|
|
51
|
+
for (let i = 0; i < model.ngeom; i++) {
|
|
52
|
+
const type = model.geom_type[i];
|
|
53
|
+
const s = model.geom_size;
|
|
54
|
+
let geometry: THREE.BufferGeometry | null = null;
|
|
55
|
+
|
|
56
|
+
switch (type) {
|
|
57
|
+
case 2: // sphere
|
|
58
|
+
geometry = new THREE.SphereGeometry(s[3 * i], 12, 8);
|
|
59
|
+
break;
|
|
60
|
+
case 3: // capsule
|
|
61
|
+
geometry = new THREE.CapsuleGeometry(s[3 * i], s[3 * i + 1] * 2, 6, 8);
|
|
62
|
+
break;
|
|
63
|
+
case 5: // cylinder
|
|
64
|
+
geometry = new THREE.CylinderGeometry(s[3 * i], s[3 * i], s[3 * i + 1] * 2, 12);
|
|
65
|
+
break;
|
|
66
|
+
case 6: // box
|
|
67
|
+
geometry = new THREE.BoxGeometry(s[3 * i] * 2, s[3 * i + 1] * 2, s[3 * i + 2] * 2);
|
|
68
|
+
break;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (geometry) {
|
|
72
|
+
const mat = new THREE.MeshBasicMaterial({ color: 0x00ff00, wireframe: true, transparent: true, opacity: 0.3 });
|
|
73
|
+
const mesh = new THREE.Mesh(geometry, mat);
|
|
74
|
+
mesh.userData.geomId = i;
|
|
75
|
+
mesh.userData.bodyId = model.geom_bodyid[i];
|
|
76
|
+
geoms.push(mesh);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Site markers
|
|
82
|
+
if (showSites) {
|
|
83
|
+
for (let i = 0; i < model.nsite; i++) {
|
|
84
|
+
const geometry = new THREE.OctahedronGeometry(0.01);
|
|
85
|
+
const mat = new THREE.MeshBasicMaterial({ color: 0xff00ff, transparent: true, opacity: 0.7 });
|
|
86
|
+
const mesh = new THREE.Mesh(geometry, mat);
|
|
87
|
+
mesh.userData.siteId = i;
|
|
88
|
+
sites.push(mesh);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Joint axes
|
|
93
|
+
if (showJoints) {
|
|
94
|
+
for (let i = 0; i < model.njnt; i++) {
|
|
95
|
+
const type = model.jnt_type[i];
|
|
96
|
+
const color = JOINT_COLORS[type] ?? 0xffffff;
|
|
97
|
+
const arrow = new THREE.ArrowHelper(
|
|
98
|
+
new THREE.Vector3(0, 0, 1), new THREE.Vector3(),
|
|
99
|
+
0.05, color, 0.01, 0.005
|
|
100
|
+
);
|
|
101
|
+
arrow.userData.jointId = i;
|
|
102
|
+
joints.push(arrow);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// COM markers
|
|
107
|
+
if (showCOM) {
|
|
108
|
+
for (let i = 1; i < model.nbody; i++) {
|
|
109
|
+
const geometry = new THREE.SphereGeometry(0.005, 6, 6);
|
|
110
|
+
const mat = new THREE.MeshBasicMaterial({ color: 0xff0000 });
|
|
111
|
+
const mesh = new THREE.Mesh(geometry, mat);
|
|
112
|
+
mesh.userData.bodyId = i;
|
|
113
|
+
comMarkers.push(mesh);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
return { geoms, sites, joints, comMarkers };
|
|
118
|
+
}, [status, mjModelRef, showGeoms, showSites, showJoints, showCOM]);
|
|
119
|
+
|
|
120
|
+
// Add/remove debug objects from scene
|
|
121
|
+
useEffect(() => {
|
|
122
|
+
const group = groupRef.current;
|
|
123
|
+
if (!group || !debugGeometry) return;
|
|
124
|
+
|
|
125
|
+
const allObjects = [
|
|
126
|
+
...debugGeometry.geoms,
|
|
127
|
+
...debugGeometry.sites,
|
|
128
|
+
...debugGeometry.joints,
|
|
129
|
+
...debugGeometry.comMarkers,
|
|
130
|
+
];
|
|
131
|
+
for (const obj of allObjects) group.add(obj);
|
|
132
|
+
|
|
133
|
+
return () => {
|
|
134
|
+
for (const obj of allObjects) {
|
|
135
|
+
group.remove(obj);
|
|
136
|
+
if ((obj as THREE.Mesh).geometry) (obj as THREE.Mesh).geometry.dispose();
|
|
137
|
+
}
|
|
138
|
+
};
|
|
139
|
+
}, [debugGeometry]);
|
|
140
|
+
|
|
141
|
+
// Update positions every frame
|
|
142
|
+
useFrame(() => {
|
|
143
|
+
const model = mjModelRef.current;
|
|
144
|
+
const data = mjDataRef.current;
|
|
145
|
+
if (!model || !data || !debugGeometry) return;
|
|
146
|
+
|
|
147
|
+
// Update geom wireframes
|
|
148
|
+
for (const mesh of debugGeometry.geoms) {
|
|
149
|
+
const bid = mesh.userData.bodyId;
|
|
150
|
+
const i3 = bid * 3;
|
|
151
|
+
const i4 = bid * 4;
|
|
152
|
+
mesh.position.set(data.xpos[i3], data.xpos[i3 + 1], data.xpos[i3 + 2]);
|
|
153
|
+
mesh.quaternion.set(
|
|
154
|
+
data.xquat[i4 + 1], data.xquat[i4 + 2],
|
|
155
|
+
data.xquat[i4 + 3], data.xquat[i4]
|
|
156
|
+
);
|
|
157
|
+
// Apply local geom offset
|
|
158
|
+
const gid = mesh.userData.geomId;
|
|
159
|
+
const gp = model.geom_pos;
|
|
160
|
+
mesh.position.add(new THREE.Vector3(gp[3 * gid], gp[3 * gid + 1], gp[3 * gid + 2])
|
|
161
|
+
.applyQuaternion(mesh.quaternion));
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Update site markers
|
|
165
|
+
for (const mesh of debugGeometry.sites) {
|
|
166
|
+
const sid = mesh.userData.siteId;
|
|
167
|
+
mesh.position.set(
|
|
168
|
+
data.site_xpos[3 * sid],
|
|
169
|
+
data.site_xpos[3 * sid + 1],
|
|
170
|
+
data.site_xpos[3 * sid + 2],
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Update COM markers
|
|
175
|
+
for (const mesh of debugGeometry.comMarkers) {
|
|
176
|
+
const bid = mesh.userData.bodyId;
|
|
177
|
+
const i3 = bid * 3;
|
|
178
|
+
mesh.position.set(data.xpos[i3], data.xpos[i3 + 1], data.xpos[i3 + 2]);
|
|
179
|
+
}
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
// Contact force vectors
|
|
183
|
+
const contactGroupRef = useRef<THREE.Group>(null);
|
|
184
|
+
const contactArrowsRef = useRef<THREE.ArrowHelper[]>([]);
|
|
185
|
+
|
|
186
|
+
useFrame(() => {
|
|
187
|
+
if (!showContacts) return;
|
|
188
|
+
const model = mjModelRef.current;
|
|
189
|
+
const data = mjDataRef.current;
|
|
190
|
+
const group = contactGroupRef.current;
|
|
191
|
+
if (!model || !data || !group) return;
|
|
192
|
+
|
|
193
|
+
// Remove old arrows
|
|
194
|
+
for (const arrow of contactArrowsRef.current) {
|
|
195
|
+
group.remove(arrow);
|
|
196
|
+
arrow.dispose();
|
|
197
|
+
}
|
|
198
|
+
contactArrowsRef.current = [];
|
|
199
|
+
|
|
200
|
+
const ncon = data.ncon;
|
|
201
|
+
for (let i = 0; i < Math.min(ncon, 50); i++) {
|
|
202
|
+
try {
|
|
203
|
+
const c = (data.contact as { get(i: number): { pos: Float64Array; frame: Float64Array; dist: number } }).get(i);
|
|
204
|
+
const pos = new THREE.Vector3(c.pos[0], c.pos[1], c.pos[2]);
|
|
205
|
+
const normal = new THREE.Vector3(c.frame[0], c.frame[1], c.frame[2]);
|
|
206
|
+
const force = Math.abs(c.dist) * 100;
|
|
207
|
+
const length = Math.min(force * 0.01, 0.1);
|
|
208
|
+
if (length > 0.001) {
|
|
209
|
+
const arrow = new THREE.ArrowHelper(normal, pos, length, 0xff4444, length * 0.3, length * 0.15);
|
|
210
|
+
group.add(arrow);
|
|
211
|
+
contactArrowsRef.current.push(arrow);
|
|
212
|
+
}
|
|
213
|
+
} catch {
|
|
214
|
+
break;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
if (status !== 'ready') return null;
|
|
220
|
+
|
|
221
|
+
return (
|
|
222
|
+
<>
|
|
223
|
+
<group ref={groupRef} />
|
|
224
|
+
{showContacts && <group ref={contactGroupRef} />}
|
|
225
|
+
</>
|
|
226
|
+
);
|
|
227
|
+
}
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { useFrame, useThree } from '@react-three/fiber';
|
|
7
|
+
import { useEffect, useRef } from 'react';
|
|
8
|
+
import * as THREE from 'three';
|
|
9
|
+
import { useMujocoSim, useBeforePhysicsStep } from '../core/MujocoSimProvider';
|
|
10
|
+
import type { DragInteractionProps } from '../types';
|
|
11
|
+
|
|
12
|
+
// Preallocated temps to avoid GC pressure
|
|
13
|
+
const _force = new Float64Array(3);
|
|
14
|
+
const _torque = new Float64Array(3); // always [0,0,0]
|
|
15
|
+
const _point = new Float64Array(3);
|
|
16
|
+
const _bodyPos = new THREE.Vector3();
|
|
17
|
+
const _bodyQuat = new THREE.Quaternion();
|
|
18
|
+
const _worldHit = new THREE.Vector3();
|
|
19
|
+
const _raycaster = new THREE.Raycaster();
|
|
20
|
+
const _mouse = new THREE.Vector2();
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* DragInteraction — Ctrl/Cmd+click-drag to apply spring forces to MuJoCo bodies.
|
|
24
|
+
*
|
|
25
|
+
* Raycasts against scene meshes to identify bodies, then applies a spring
|
|
26
|
+
* force pulling the grabbed point toward the cursor each physics frame.
|
|
27
|
+
* Requires Ctrl (or Cmd on macOS) to avoid conflicting with OrbitControls.
|
|
28
|
+
*
|
|
29
|
+
* - `stiffness` — Spring constant * body mass. Default: 250.
|
|
30
|
+
* - `showArrow` — Show arrow from grab point toward cursor. Default: true.
|
|
31
|
+
*
|
|
32
|
+
* Forces compose with useGravityCompensation — the provider zeros
|
|
33
|
+
* qfrc_applied each frame, then all consumers add to it.
|
|
34
|
+
*/
|
|
35
|
+
export function DragInteraction({
|
|
36
|
+
stiffness = 250,
|
|
37
|
+
showArrow = true,
|
|
38
|
+
}: DragInteractionProps) {
|
|
39
|
+
const { mjDataRef, mujocoRef, mjModelRef, status } = useMujocoSim();
|
|
40
|
+
const { gl, camera, scene, controls } = useThree();
|
|
41
|
+
|
|
42
|
+
const draggingRef = useRef(false);
|
|
43
|
+
const bodyIdRef = useRef(-1);
|
|
44
|
+
const grabDistanceRef = useRef(0);
|
|
45
|
+
const localHitRef = useRef(new THREE.Vector3());
|
|
46
|
+
const grabWorldRef = useRef(new THREE.Vector3());
|
|
47
|
+
const mouseWorldRef = useRef(new THREE.Vector3());
|
|
48
|
+
|
|
49
|
+
// Arrow helper for visual feedback (managed imperatively)
|
|
50
|
+
const arrowRef = useRef<THREE.ArrowHelper | null>(null);
|
|
51
|
+
const groupRef = useRef<THREE.Group>(null);
|
|
52
|
+
|
|
53
|
+
useEffect(() => {
|
|
54
|
+
if (!showArrow || !groupRef.current) return;
|
|
55
|
+
const arrow = new THREE.ArrowHelper(
|
|
56
|
+
new THREE.Vector3(0, 1, 0),
|
|
57
|
+
new THREE.Vector3(),
|
|
58
|
+
0.1,
|
|
59
|
+
0xff4444,
|
|
60
|
+
);
|
|
61
|
+
arrow.visible = false;
|
|
62
|
+
// Make arrow semi-transparent
|
|
63
|
+
(arrow.line.material as THREE.LineBasicMaterial).transparent = true;
|
|
64
|
+
(arrow.line.material as THREE.LineBasicMaterial).opacity = 0.6;
|
|
65
|
+
(arrow.cone.material as THREE.MeshBasicMaterial).transparent = true;
|
|
66
|
+
(arrow.cone.material as THREE.MeshBasicMaterial).opacity = 0.6;
|
|
67
|
+
groupRef.current.add(arrow);
|
|
68
|
+
arrowRef.current = arrow;
|
|
69
|
+
return () => {
|
|
70
|
+
if (groupRef.current) groupRef.current.remove(arrow);
|
|
71
|
+
arrow.dispose();
|
|
72
|
+
arrowRef.current = null;
|
|
73
|
+
};
|
|
74
|
+
}, [showArrow]);
|
|
75
|
+
|
|
76
|
+
// Pointer events on the canvas
|
|
77
|
+
useEffect(() => {
|
|
78
|
+
const canvas = gl.domElement;
|
|
79
|
+
|
|
80
|
+
const onPointerDown = (evt: PointerEvent) => {
|
|
81
|
+
if (evt.button !== 0) return; // left click only
|
|
82
|
+
if (!evt.ctrlKey && !evt.metaKey) return; // require Ctrl/Cmd+click
|
|
83
|
+
const rect = canvas.getBoundingClientRect();
|
|
84
|
+
_mouse.set(
|
|
85
|
+
((evt.clientX - rect.left) / rect.width) * 2 - 1,
|
|
86
|
+
-((evt.clientY - rect.top) / rect.height) * 2 + 1,
|
|
87
|
+
);
|
|
88
|
+
_raycaster.setFromCamera(_mouse, camera);
|
|
89
|
+
|
|
90
|
+
const hits = _raycaster.intersectObjects(scene.children, true);
|
|
91
|
+
for (const hit of hits) {
|
|
92
|
+
let obj: THREE.Object3D | null = hit.object;
|
|
93
|
+
while (obj && obj.userData.bodyID === undefined && obj.parent) {
|
|
94
|
+
obj = obj.parent;
|
|
95
|
+
}
|
|
96
|
+
const bid = obj?.userData.bodyID;
|
|
97
|
+
if (bid !== undefined && bid > 0) {
|
|
98
|
+
bodyIdRef.current = bid;
|
|
99
|
+
draggingRef.current = true;
|
|
100
|
+
grabDistanceRef.current = hit.distance;
|
|
101
|
+
|
|
102
|
+
// Store hit point in body-local coords
|
|
103
|
+
const data = mjDataRef.current;
|
|
104
|
+
if (data) {
|
|
105
|
+
const i3 = bid * 3;
|
|
106
|
+
const i4 = bid * 4;
|
|
107
|
+
_bodyPos.set(data.xpos[i3], data.xpos[i3 + 1], data.xpos[i3 + 2]);
|
|
108
|
+
// MuJoCo xquat is [w,x,y,z]; THREE wants (x,y,z,w)
|
|
109
|
+
_bodyQuat.set(
|
|
110
|
+
data.xquat[i4 + 1], data.xquat[i4 + 2],
|
|
111
|
+
data.xquat[i4 + 3], data.xquat[i4]
|
|
112
|
+
);
|
|
113
|
+
// World hit → body-local: inverse(bodyRot) * (hitWorld - bodyPos)
|
|
114
|
+
localHitRef.current.copy(hit.point).sub(_bodyPos);
|
|
115
|
+
localHitRef.current.applyQuaternion(_bodyQuat.clone().invert());
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
mouseWorldRef.current.copy(hit.point);
|
|
119
|
+
grabWorldRef.current.copy(hit.point);
|
|
120
|
+
|
|
121
|
+
// Disable orbit controls during drag
|
|
122
|
+
if (controls) (controls as unknown as { enabled: boolean }).enabled = false;
|
|
123
|
+
break;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
const onPointerMove = (evt: PointerEvent) => {
|
|
129
|
+
if (!draggingRef.current) return;
|
|
130
|
+
// Safety: if no buttons are pressed, the pointerup was missed
|
|
131
|
+
if (evt.buttons === 0) {
|
|
132
|
+
draggingRef.current = false;
|
|
133
|
+
bodyIdRef.current = -1;
|
|
134
|
+
if (controls) (controls as unknown as { enabled: boolean }).enabled = true;
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
const rect = canvas.getBoundingClientRect();
|
|
138
|
+
_mouse.set(
|
|
139
|
+
((evt.clientX - rect.left) / rect.width) * 2 - 1,
|
|
140
|
+
-((evt.clientY - rect.top) / rect.height) * 2 + 1,
|
|
141
|
+
);
|
|
142
|
+
_raycaster.setFromCamera(_mouse, camera);
|
|
143
|
+
// Project mouse ray to the same grab distance
|
|
144
|
+
mouseWorldRef.current.copy(_raycaster.ray.origin)
|
|
145
|
+
.addScaledVector(_raycaster.ray.direction, grabDistanceRef.current);
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
const onPointerUp = () => {
|
|
149
|
+
if (!draggingRef.current) return;
|
|
150
|
+
draggingRef.current = false;
|
|
151
|
+
bodyIdRef.current = -1;
|
|
152
|
+
if (controls) (controls as unknown as { enabled: boolean }).enabled = true;
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
canvas.addEventListener('pointerdown', onPointerDown);
|
|
156
|
+
canvas.addEventListener('pointermove', onPointerMove);
|
|
157
|
+
// Listen on window so we catch releases even if pointer leaves the canvas
|
|
158
|
+
window.addEventListener('pointerup', onPointerUp);
|
|
159
|
+
window.addEventListener('pointercancel', onPointerUp);
|
|
160
|
+
return () => {
|
|
161
|
+
canvas.removeEventListener('pointerdown', onPointerDown);
|
|
162
|
+
canvas.removeEventListener('pointermove', onPointerMove);
|
|
163
|
+
window.removeEventListener('pointerup', onPointerUp);
|
|
164
|
+
window.removeEventListener('pointercancel', onPointerUp);
|
|
165
|
+
};
|
|
166
|
+
}, [gl, camera, scene, controls, mjDataRef]);
|
|
167
|
+
|
|
168
|
+
// Apply spring force each physics frame
|
|
169
|
+
useBeforePhysicsStep((model, data) => {
|
|
170
|
+
if (!draggingRef.current || bodyIdRef.current <= 0) return;
|
|
171
|
+
|
|
172
|
+
const bid = bodyIdRef.current;
|
|
173
|
+
const mujoco = mujocoRef.current;
|
|
174
|
+
|
|
175
|
+
// Reconstruct grab point world position from body's current pose
|
|
176
|
+
const i3 = bid * 3;
|
|
177
|
+
const i4 = bid * 4;
|
|
178
|
+
_bodyPos.set(data.xpos[i3], data.xpos[i3 + 1], data.xpos[i3 + 2]);
|
|
179
|
+
_bodyQuat.set(
|
|
180
|
+
data.xquat[i4 + 1], data.xquat[i4 + 2],
|
|
181
|
+
data.xquat[i4 + 3], data.xquat[i4]
|
|
182
|
+
);
|
|
183
|
+
_worldHit.copy(localHitRef.current);
|
|
184
|
+
_worldHit.applyQuaternion(_bodyQuat);
|
|
185
|
+
_worldHit.add(_bodyPos);
|
|
186
|
+
grabWorldRef.current.copy(_worldHit);
|
|
187
|
+
|
|
188
|
+
// Compute spring force: F = (mouseWorld - grabWorld) * body_mass * stiffness
|
|
189
|
+
const mass = model.body_mass[bid];
|
|
190
|
+
const s = stiffness * mass;
|
|
191
|
+
_force[0] = (mouseWorldRef.current.x - _worldHit.x) * s;
|
|
192
|
+
_force[1] = (mouseWorldRef.current.y - _worldHit.y) * s;
|
|
193
|
+
_force[2] = (mouseWorldRef.current.z - _worldHit.z) * s;
|
|
194
|
+
|
|
195
|
+
_point[0] = _worldHit.x;
|
|
196
|
+
_point[1] = _worldHit.y;
|
|
197
|
+
_point[2] = _worldHit.z;
|
|
198
|
+
|
|
199
|
+
_torque[0] = 0; _torque[1] = 0; _torque[2] = 0;
|
|
200
|
+
|
|
201
|
+
mujoco.mj_applyFT(model, data, _force, _torque, _point, bid, data.qfrc_applied);
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
// Update arrow visual
|
|
205
|
+
useFrame(() => {
|
|
206
|
+
const arrow = arrowRef.current;
|
|
207
|
+
if (!arrow) return;
|
|
208
|
+
|
|
209
|
+
if (draggingRef.current && bodyIdRef.current > 0) {
|
|
210
|
+
arrow.visible = true;
|
|
211
|
+
const dir = mouseWorldRef.current.clone().sub(grabWorldRef.current);
|
|
212
|
+
const len = dir.length();
|
|
213
|
+
if (len > 0.001) {
|
|
214
|
+
dir.normalize();
|
|
215
|
+
arrow.position.copy(grabWorldRef.current);
|
|
216
|
+
arrow.setDirection(dir);
|
|
217
|
+
arrow.setLength(len, Math.min(len * 0.2, 0.05), Math.min(len * 0.1, 0.03));
|
|
218
|
+
}
|
|
219
|
+
} else {
|
|
220
|
+
arrow.visible = false;
|
|
221
|
+
}
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
if (status !== 'ready') return null;
|
|
225
|
+
|
|
226
|
+
return <group ref={groupRef} />;
|
|
227
|
+
}
|