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.
Files changed (42) hide show
  1. package/LICENSE +177 -0
  2. package/README.md +510 -0
  3. package/dist/index.d.ts +1080 -0
  4. package/dist/index.js +3518 -0
  5. package/dist/index.js.map +1 -0
  6. package/package.json +64 -0
  7. package/src/components/ContactListener.tsx +26 -0
  8. package/src/components/ContactMarkers.tsx +81 -0
  9. package/src/components/Debug.tsx +227 -0
  10. package/src/components/DragInteraction.tsx +227 -0
  11. package/src/components/FlexRenderer.tsx +102 -0
  12. package/src/components/IkGizmo.tsx +146 -0
  13. package/src/components/SceneLights.tsx +131 -0
  14. package/src/components/SceneRenderer.tsx +104 -0
  15. package/src/components/SelectionHighlight.tsx +69 -0
  16. package/src/components/TendonRenderer.tsx +84 -0
  17. package/src/components/TrajectoryPlayer.tsx +44 -0
  18. package/src/core/GenericIK.ts +339 -0
  19. package/src/core/MujocoCanvas.tsx +72 -0
  20. package/src/core/MujocoProvider.tsx +78 -0
  21. package/src/core/MujocoSimProvider.tsx +1201 -0
  22. package/src/core/SceneLoader.ts +275 -0
  23. package/src/hooks/useActuators.ts +36 -0
  24. package/src/hooks/useBodyState.ts +56 -0
  25. package/src/hooks/useContacts.ts +125 -0
  26. package/src/hooks/useCtrl.ts +40 -0
  27. package/src/hooks/useCtrlNoise.ts +59 -0
  28. package/src/hooks/useGamepad.ts +77 -0
  29. package/src/hooks/useGravityCompensation.ts +22 -0
  30. package/src/hooks/useJointState.ts +64 -0
  31. package/src/hooks/useKeyboardTeleop.ts +97 -0
  32. package/src/hooks/usePolicy.ts +56 -0
  33. package/src/hooks/useSensor.ts +83 -0
  34. package/src/hooks/useSitePosition.ts +62 -0
  35. package/src/hooks/useTrajectoryPlayer.ts +105 -0
  36. package/src/hooks/useTrajectoryRecorder.ts +97 -0
  37. package/src/hooks/useVideoRecorder.ts +82 -0
  38. package/src/index.ts +108 -0
  39. package/src/rendering/CapsuleGeometry.ts +35 -0
  40. package/src/rendering/GeomBuilder.ts +140 -0
  41. package/src/rendering/Reflector.ts +225 -0
  42. 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
+ }