mujoco-react 8.2.0 → 8.3.3
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 +78 -31
- package/dist/index.d.ts +6 -3
- package/dist/index.js +86 -60
- package/dist/index.js.map +1 -1
- package/package.json +3 -3
- package/src/components/ContactMarkers.tsx +14 -12
- package/src/components/Debug.tsx +18 -16
- package/src/components/FlexRenderer.tsx +1 -1
- package/src/components/TendonRenderer.tsx +1 -1
- package/src/core/MujocoProvider.tsx +3 -2
- package/src/core/MujocoSimProvider.tsx +15 -13
- package/src/core/SceneLoader.ts +11 -1
- package/src/hooks/useContacts.ts +20 -18
- package/src/rendering/GeomBuilder.ts +1 -1
- package/src/types.ts +22 -4
- package/src/wasm-url.d.ts +4 -0
package/README.md
CHANGED
|
@@ -4,9 +4,9 @@
|
|
|
4
4
|
|
|
5
5
|
# mujoco-react
|
|
6
6
|
|
|
7
|
-
> **Beta** — This library is under active development. The API may change between minor versions until
|
|
7
|
+
> **Beta** — This library is under active development. The API may change between minor versions until 10.0.
|
|
8
8
|
|
|
9
|
-
Composable [React Three Fiber](https://docs.pmnd.rs/react-three-fiber) wrapper around [mujoco
|
|
9
|
+
Composable [React Three Fiber](https://docs.pmnd.rs/react-three-fiber) wrapper around the official [@mujoco/mujoco](https://www.npmjs.com/package/@mujoco/mujoco) WASM bindings. Load any MuJoCo model, step physics, render bodies, and write controllers as React components.
|
|
10
10
|
|
|
11
11
|
[](https://www.npmjs.com/package/mujoco-react)
|
|
12
12
|
|
|
@@ -87,64 +87,109 @@ function MyComponent() {
|
|
|
87
87
|
|
|
88
88
|
## Writing a Controller
|
|
89
89
|
|
|
90
|
-
A controller is a
|
|
90
|
+
A controller is a hook that reads sensors and writes actuators each physics step:
|
|
91
91
|
|
|
92
92
|
```tsx
|
|
93
93
|
import { useCtrl, useSensor, useBeforePhysicsStep } from "mujoco-react";
|
|
94
94
|
|
|
95
|
-
function
|
|
95
|
+
function useMyController(gain: number) {
|
|
96
96
|
const shoulder = useCtrl("shoulder");
|
|
97
97
|
const elbow = useCtrl("elbow");
|
|
98
98
|
const force = useSensor("force_sensor");
|
|
99
99
|
|
|
100
100
|
useBeforePhysicsStep(() => {
|
|
101
|
-
shoulder.write(Math.sin(Date.now() / 1000));
|
|
101
|
+
shoulder.write(gain * Math.sin(Date.now() / 1000));
|
|
102
102
|
elbow.write(force.read()[0] * -0.5);
|
|
103
103
|
});
|
|
104
|
-
return null;
|
|
105
104
|
}
|
|
106
105
|
```
|
|
107
106
|
|
|
108
|
-
|
|
107
|
+
### Applying Forces
|
|
108
|
+
|
|
109
|
+
Write directly to `xfrc_applied` in a physics callback for per-frame forces. The layout is `[torque_x, torque_y, torque_z, force_x, force_y, force_z]` per body:
|
|
109
110
|
|
|
110
111
|
```tsx
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
112
|
+
import { useBeforePhysicsStep } from "mujoco-react";
|
|
113
|
+
|
|
114
|
+
function useSpringForce(bodyId: number, target: [number, number, number], stiffness = 100) {
|
|
115
|
+
useBeforePhysicsStep((_model, data) => {
|
|
116
|
+
const i3 = bodyId * 3;
|
|
117
|
+
const i6 = bodyId * 6;
|
|
118
|
+
data.xfrc_applied[i6 + 3] = (target[0] - data.xpos[i3]) * stiffness;
|
|
119
|
+
data.xfrc_applied[i6 + 4] = (target[1] - data.xpos[i3 + 1]) * stiffness;
|
|
120
|
+
data.xfrc_applied[i6 + 5] = (target[2] - data.xpos[i3 + 2]) * stiffness;
|
|
121
|
+
});
|
|
122
|
+
}
|
|
114
123
|
```
|
|
115
124
|
|
|
116
|
-
The `
|
|
125
|
+
The `api.applyForce(bodyName, force)` convenience method also works for one-off interactions (e.g. button clicks) but does a name lookup each call.
|
|
126
|
+
|
|
127
|
+
### WebSocket Joint Control
|
|
128
|
+
|
|
129
|
+
Stream joint commands over a WebSocket and send simulation state back:
|
|
117
130
|
|
|
118
131
|
```tsx
|
|
119
|
-
import {
|
|
132
|
+
import { useEffect, useRef } from "react";
|
|
133
|
+
import { useMujoco, useBeforePhysicsStep, useAfterPhysicsStep } from "mujoco-react";
|
|
120
134
|
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
(
|
|
124
|
-
|
|
125
|
-
if (!config) return;
|
|
126
|
-
data.ctrl[0] = config.gain * Math.sin(data.time);
|
|
127
|
-
});
|
|
128
|
-
if (!config) return null;
|
|
129
|
-
return { value: config.gain };
|
|
130
|
-
},
|
|
131
|
-
);
|
|
135
|
+
function useWebSocketJoints(url: string) {
|
|
136
|
+
const { api } = useMujoco();
|
|
137
|
+
const wsRef = useRef<WebSocket | null>(null);
|
|
138
|
+
const latestJointsRef = useRef<number[] | null>(null);
|
|
132
139
|
|
|
133
|
-
|
|
134
|
-
|
|
140
|
+
useEffect(() => {
|
|
141
|
+
const ws = new WebSocket(url);
|
|
142
|
+
wsRef.current = ws;
|
|
143
|
+
|
|
144
|
+
ws.onmessage = (evt) => {
|
|
145
|
+
const msg = JSON.parse(evt.data);
|
|
146
|
+
if (msg.type === "joint_command") {
|
|
147
|
+
latestJointsRef.current = msg.qpos;
|
|
148
|
+
}
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
return () => ws.close();
|
|
152
|
+
}, [url]);
|
|
153
|
+
|
|
154
|
+
// Apply incoming joint positions each physics step
|
|
155
|
+
useBeforePhysicsStep((model, data) => {
|
|
156
|
+
const joints = latestJointsRef.current;
|
|
157
|
+
if (!joints) return;
|
|
158
|
+
for (let i = 0; i < Math.min(joints.length, model.nu); i++) {
|
|
159
|
+
data.ctrl[i] = joints[i];
|
|
160
|
+
}
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
// Send sensor feedback back after physics
|
|
164
|
+
useAfterPhysicsStep((model, data) => {
|
|
165
|
+
const ws = wsRef.current;
|
|
166
|
+
if (!ws || ws.readyState !== WebSocket.OPEN) return;
|
|
167
|
+
|
|
168
|
+
ws.send(JSON.stringify({
|
|
169
|
+
type: "feedback",
|
|
170
|
+
time: data.time,
|
|
171
|
+
qpos: Array.from(data.qpos),
|
|
172
|
+
qvel: Array.from(data.qvel),
|
|
173
|
+
sensordata: Array.from(data.sensordata),
|
|
174
|
+
}));
|
|
175
|
+
});
|
|
176
|
+
}
|
|
135
177
|
```
|
|
136
178
|
|
|
137
|
-
|
|
179
|
+
For reusable controllers with typed config, default merging, and children, use the `createController` factory:
|
|
138
180
|
|
|
139
181
|
```tsx
|
|
140
|
-
import { createController,
|
|
182
|
+
import { createController, useCtrl, useBeforePhysicsStep } from "mujoco-react";
|
|
141
183
|
|
|
142
184
|
export const MyController = createController<{ gain: number }>(
|
|
143
185
|
{ name: "MyController", defaultConfig: { gain: 1.0 } },
|
|
144
186
|
({ config, children }) => {
|
|
145
|
-
|
|
146
|
-
|
|
187
|
+
const shoulder = useCtrl("shoulder");
|
|
188
|
+
|
|
189
|
+
useBeforePhysicsStep(() => {
|
|
190
|
+
shoulder.write(config.gain * Math.sin(Date.now() / 1000));
|
|
147
191
|
});
|
|
192
|
+
|
|
148
193
|
return <>{children}</>;
|
|
149
194
|
},
|
|
150
195
|
);
|
|
@@ -154,6 +199,8 @@ export const MyController = createController<{ gain: number }>(
|
|
|
154
199
|
// </MyController>
|
|
155
200
|
```
|
|
156
201
|
|
|
202
|
+
A `createControllerHook` factory is also available for the hook equivalent — see the [Building Controllers](https://dadd.mintlify.app/guides/building-controllers) guide.
|
|
203
|
+
|
|
157
204
|
## Architecture
|
|
158
205
|
|
|
159
206
|
`<MujocoCanvas>` wraps R3F `<Canvas>` and forwards all Canvas props (`camera`, `shadows`, `gl`, etc.). For full control over the Canvas, use `<MujocoPhysics>` inside your own:
|
|
@@ -470,7 +517,7 @@ import { useMujocoWasm } from "mujoco-react";
|
|
|
470
517
|
const { mujoco, status } = useMujocoWasm();
|
|
471
518
|
|
|
472
519
|
if (mujoco) {
|
|
473
|
-
const model = mujoco.MjModel.
|
|
520
|
+
const model = mujoco.MjModel.from_xml_path("/path/to/scene.xml");
|
|
474
521
|
const data = new mujoco.MjData(model);
|
|
475
522
|
mujoco.mj_step(model, data);
|
|
476
523
|
console.log(data.qpos); // joint positions after one step
|
|
@@ -794,7 +841,7 @@ Features planned but not yet implemented:
|
|
|
794
841
|
| **Web Worker physics** | P2 | Run `mj_step` off main thread via SharedArrayBuffer |
|
|
795
842
|
| **Register codegen** | P2 | CLI to auto-generate `Register` type augmentation from MJCF XML |
|
|
796
843
|
|
|
797
|
-
### WASM Limitations (mujoco
|
|
844
|
+
### WASM Limitations (@mujoco/mujoco)
|
|
798
845
|
|
|
799
846
|
These MuJoCo features are not yet exposed in the WASM binding:
|
|
800
847
|
|
package/dist/index.d.ts
CHANGED
|
@@ -64,12 +64,13 @@ interface MujocoContact {
|
|
|
64
64
|
*/
|
|
65
65
|
interface MujocoContactArray {
|
|
66
66
|
get(i: number): MujocoContact | undefined;
|
|
67
|
+
delete?: () => void;
|
|
67
68
|
}
|
|
68
69
|
/**
|
|
69
|
-
* Read a single contact from
|
|
70
|
+
* Read a single contact from an already-acquired WASM contact array.
|
|
70
71
|
* Returns undefined if the access fails (WASM heap issue, bad index, etc.).
|
|
71
72
|
*/
|
|
72
|
-
declare function getContact(
|
|
73
|
+
declare function getContact(contacts: MujocoContactArray, i: number): MujocoContact | undefined;
|
|
73
74
|
/**
|
|
74
75
|
* Minimal interface for MuJoCo Model to avoid 'any'.
|
|
75
76
|
*/
|
|
@@ -217,7 +218,9 @@ interface MujocoData {
|
|
|
217
218
|
*/
|
|
218
219
|
interface MujocoModule {
|
|
219
220
|
MjModel: {
|
|
220
|
-
|
|
221
|
+
from_xml_path?: (path: string) => MujocoModel;
|
|
222
|
+
from_xml_string?: (xml: string, vfs?: unknown) => MujocoModel;
|
|
223
|
+
loadFromXML?: (path: string) => MujocoModel;
|
|
221
224
|
[key: string]: unknown;
|
|
222
225
|
};
|
|
223
226
|
MjData: new (model: MujocoModel) => MujocoData;
|
package/dist/index.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import loadMujoco from 'mujoco
|
|
1
|
+
import loadMujoco from '@mujoco/mujoco';
|
|
2
|
+
import defaultMujocoWasmUrl from '@mujoco/mujoco/mujoco.wasm?url';
|
|
2
3
|
import { createContext, forwardRef, useEffect, useContext, useState, useRef, useCallback, useMemo, useLayoutEffect } from 'react';
|
|
3
4
|
import { jsx, jsxs } from 'react/jsx-runtime';
|
|
4
5
|
import { Canvas, useThree, useFrame } from '@react-three/fiber';
|
|
@@ -22,7 +23,7 @@ function MujocoProvider({ wasmUrl, timeout = 3e4, children, onError }) {
|
|
|
22
23
|
useEffect(() => {
|
|
23
24
|
isMounted.current = true;
|
|
24
25
|
const wasmPromise = loadMujoco({
|
|
25
|
-
|
|
26
|
+
locateFile: (path) => path.endsWith(".wasm") ? wasmUrl ?? defaultMujocoWasmUrl : path,
|
|
26
27
|
printErr: (text) => {
|
|
27
28
|
if (text.includes("Aborted") && isMounted.current) {
|
|
28
29
|
setError("Simulation crashed. Reload page.");
|
|
@@ -60,13 +61,21 @@ function MujocoProvider({ wasmUrl, timeout = 3e4, children, onError }) {
|
|
|
60
61
|
}
|
|
61
62
|
|
|
62
63
|
// src/types.ts
|
|
63
|
-
function getContact(
|
|
64
|
+
function getContact(contacts, i) {
|
|
64
65
|
try {
|
|
65
|
-
return
|
|
66
|
+
return contacts.get(i);
|
|
66
67
|
} catch {
|
|
67
68
|
return void 0;
|
|
68
69
|
}
|
|
69
70
|
}
|
|
71
|
+
function withContacts(data, read) {
|
|
72
|
+
const contacts = data.contact;
|
|
73
|
+
try {
|
|
74
|
+
return read(contacts);
|
|
75
|
+
} finally {
|
|
76
|
+
contacts.delete?.();
|
|
77
|
+
}
|
|
78
|
+
}
|
|
70
79
|
var CapsuleGeometry = class extends THREE11.BufferGeometry {
|
|
71
80
|
parameters;
|
|
72
81
|
constructor(radius = 1, length = 1, capSegments = 4, radialSegments = 8) {
|
|
@@ -403,6 +412,15 @@ function ensureDir(mujoco, fname) {
|
|
|
403
412
|
}
|
|
404
413
|
}
|
|
405
414
|
}
|
|
415
|
+
function loadModelFromPath(mujoco, path) {
|
|
416
|
+
if (mujoco.MjModel.from_xml_path) {
|
|
417
|
+
return mujoco.MjModel.from_xml_path(path);
|
|
418
|
+
}
|
|
419
|
+
if (mujoco.MjModel.loadFromXML) {
|
|
420
|
+
return mujoco.MjModel.loadFromXML(path);
|
|
421
|
+
}
|
|
422
|
+
throw new Error("MuJoCo WASM module does not expose an XML path loader");
|
|
423
|
+
}
|
|
406
424
|
async function loadScene(mujoco, config, onProgress) {
|
|
407
425
|
try {
|
|
408
426
|
mujoco.FS.unmount("/working");
|
|
@@ -486,7 +504,7 @@ async function loadScene(mujoco, config, onProgress) {
|
|
|
486
504
|
}
|
|
487
505
|
}
|
|
488
506
|
onProgress?.("Loading model...");
|
|
489
|
-
const mjModel = mujoco
|
|
507
|
+
const mjModel = loadModelFromPath(mujoco, `/working/${config.sceneFile}`);
|
|
490
508
|
const mjData = new mujoco.MjData(mjModel);
|
|
491
509
|
if (config.homeJoints) {
|
|
492
510
|
const homeCount = Math.min(config.homeJoints.length, mjModel.nu);
|
|
@@ -1080,18 +1098,20 @@ function MujocoSimProvider({
|
|
|
1080
1098
|
if (!model || !data) return [];
|
|
1081
1099
|
const contacts = [];
|
|
1082
1100
|
const ncon = data.ncon;
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1101
|
+
withContacts(data, (contactArray) => {
|
|
1102
|
+
for (let i = 0; i < ncon; i++) {
|
|
1103
|
+
const c = getContact(contactArray, i);
|
|
1104
|
+
if (!c) break;
|
|
1105
|
+
contacts.push({
|
|
1106
|
+
geom1: c.geom1,
|
|
1107
|
+
geom1Name: getName(model, model.name_geomadr[c.geom1]),
|
|
1108
|
+
geom2: c.geom2,
|
|
1109
|
+
geom2Name: getName(model, model.name_geomadr[c.geom2]),
|
|
1110
|
+
pos: [c.pos[0], c.pos[1], c.pos[2]],
|
|
1111
|
+
depth: c.dist
|
|
1112
|
+
});
|
|
1113
|
+
}
|
|
1114
|
+
});
|
|
1095
1115
|
return contacts;
|
|
1096
1116
|
}, []);
|
|
1097
1117
|
const getBodies = useCallback(() => {
|
|
@@ -2255,18 +2275,20 @@ function ContactMarkers({
|
|
|
2255
2275
|
}
|
|
2256
2276
|
const ncon = data.ncon;
|
|
2257
2277
|
const count = Math.min(ncon, maxContacts);
|
|
2258
|
-
|
|
2259
|
-
|
|
2260
|
-
|
|
2261
|
-
|
|
2262
|
-
|
|
2263
|
-
|
|
2278
|
+
let resolvedCount = count;
|
|
2279
|
+
withContacts(data, (contactArray) => {
|
|
2280
|
+
for (let i = 0; i < count; i++) {
|
|
2281
|
+
const c = getContact(contactArray, i);
|
|
2282
|
+
if (!c) {
|
|
2283
|
+
resolvedCount = i;
|
|
2284
|
+
return;
|
|
2285
|
+
}
|
|
2286
|
+
_dummy.position.set(c.pos[0], c.pos[1], c.pos[2]);
|
|
2287
|
+
_dummy.updateMatrix();
|
|
2288
|
+
mesh.setMatrixAt(i, _dummy.matrix);
|
|
2264
2289
|
}
|
|
2265
|
-
|
|
2266
|
-
|
|
2267
|
-
mesh.setMatrixAt(i, _dummy.matrix);
|
|
2268
|
-
}
|
|
2269
|
-
mesh.count = count;
|
|
2290
|
+
});
|
|
2291
|
+
mesh.count = resolvedCount;
|
|
2270
2292
|
mesh.instanceMatrix.needsUpdate = true;
|
|
2271
2293
|
});
|
|
2272
2294
|
if (status !== "ready") return null;
|
|
@@ -2807,22 +2829,24 @@ function Debug({
|
|
|
2807
2829
|
if (!data || pool.length === 0) return;
|
|
2808
2830
|
const ncon = data.ncon;
|
|
2809
2831
|
let arrowIdx = 0;
|
|
2810
|
-
|
|
2811
|
-
|
|
2812
|
-
|
|
2813
|
-
|
|
2814
|
-
|
|
2815
|
-
|
|
2816
|
-
|
|
2817
|
-
|
|
2818
|
-
|
|
2819
|
-
|
|
2820
|
-
|
|
2821
|
-
|
|
2822
|
-
|
|
2823
|
-
|
|
2832
|
+
withContacts(data, (contactArray) => {
|
|
2833
|
+
for (let i = 0; i < Math.min(ncon, MAX_CONTACT_ARROWS); i++) {
|
|
2834
|
+
const c = getContact(contactArray, i);
|
|
2835
|
+
if (!c) break;
|
|
2836
|
+
_contactPos.set(c.pos[0], c.pos[1], c.pos[2]);
|
|
2837
|
+
_contactNormal.set(c.frame[0], c.frame[1], c.frame[2]);
|
|
2838
|
+
const force = Math.abs(c.dist) * 100;
|
|
2839
|
+
const length = Math.min(force * 0.01, 0.1);
|
|
2840
|
+
if (length > 1e-3 && arrowIdx < pool.length) {
|
|
2841
|
+
const arrow = pool[arrowIdx];
|
|
2842
|
+
arrow.position.copy(_contactPos);
|
|
2843
|
+
arrow.setDirection(_contactNormal);
|
|
2844
|
+
arrow.setLength(length, length * 0.3, length * 0.15);
|
|
2845
|
+
arrow.visible = true;
|
|
2846
|
+
arrowIdx++;
|
|
2847
|
+
}
|
|
2824
2848
|
}
|
|
2825
|
-
}
|
|
2849
|
+
});
|
|
2826
2850
|
for (let i = arrowIdx; i < pool.length; i++) {
|
|
2827
2851
|
pool[i].visible = false;
|
|
2828
2852
|
}
|
|
@@ -3053,23 +3077,25 @@ function useContacts(bodyName, callback) {
|
|
|
3053
3077
|
}
|
|
3054
3078
|
const contacts = [];
|
|
3055
3079
|
const filterBody = bodyIdRef.current;
|
|
3056
|
-
|
|
3057
|
-
|
|
3058
|
-
|
|
3059
|
-
|
|
3060
|
-
|
|
3061
|
-
|
|
3062
|
-
|
|
3080
|
+
withContacts(data, (contactArray) => {
|
|
3081
|
+
for (let i = 0; i < ncon; i++) {
|
|
3082
|
+
const c = getContact(contactArray, i);
|
|
3083
|
+
if (!c) break;
|
|
3084
|
+
if (filterBody >= 0) {
|
|
3085
|
+
const b1 = model.geom_bodyid[c.geom1];
|
|
3086
|
+
const b2 = model.geom_bodyid[c.geom2];
|
|
3087
|
+
if (b1 !== filterBody && b2 !== filterBody) continue;
|
|
3088
|
+
}
|
|
3089
|
+
contacts.push({
|
|
3090
|
+
geom1: c.geom1,
|
|
3091
|
+
geom1Name: getGeomNameCached(model, c.geom1),
|
|
3092
|
+
geom2: c.geom2,
|
|
3093
|
+
geom2Name: getGeomNameCached(model, c.geom2),
|
|
3094
|
+
pos: [c.pos[0], c.pos[1], c.pos[2]],
|
|
3095
|
+
depth: c.dist
|
|
3096
|
+
});
|
|
3063
3097
|
}
|
|
3064
|
-
|
|
3065
|
-
geom1: c.geom1,
|
|
3066
|
-
geom1Name: getGeomNameCached(model, c.geom1),
|
|
3067
|
-
geom2: c.geom2,
|
|
3068
|
-
geom2Name: getGeomNameCached(model, c.geom2),
|
|
3069
|
-
pos: [c.pos[0], c.pos[1], c.pos[2]],
|
|
3070
|
-
depth: c.dist
|
|
3071
|
-
});
|
|
3072
|
-
}
|
|
3098
|
+
});
|
|
3073
3099
|
contactsRef.current = contacts;
|
|
3074
3100
|
callbackRef.current?.(contacts);
|
|
3075
3101
|
});
|
|
@@ -4058,7 +4084,7 @@ function useCameraAnimation() {
|
|
|
4058
4084
|
* WASM fields used: model.ntendon, model.ten_wrapadr, model.ten_wrapnum
|
|
4059
4085
|
* data.wrap_xpos, data.ten_wrapadr (runtime)
|
|
4060
4086
|
*
|
|
4061
|
-
* Note: ten_rgba and ten_width
|
|
4087
|
+
* Note: ten_rgba and ten_width may not be available in all MuJoCo WASM builds.
|
|
4062
4088
|
* Tendons use a default color and width.
|
|
4063
4089
|
*/
|
|
4064
4090
|
/**
|