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 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 1.0.
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-js](https://www.npmjs.com/package/mujoco-js). Load any MuJoCo model, step physics, render bodies, and write controllers as React components.
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
  [![npm](https://img.shields.io/npm/v/mujoco-react)](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 React component that uses handle-based hooks for type-safe actuator and sensor access:
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 MyController() {
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
- Drop it into the tree:
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
- <MujocoCanvas config={config}>
112
- <MyController />
113
- </MujocoCanvas>
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 `createControllerHook` factory produces a typed hook with config stabilization and default merging. Pass `null` to disable:
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 { createControllerHook, useBeforePhysicsStep } from "mujoco-react";
132
+ import { useEffect, useRef } from "react";
133
+ import { useMujoco, useBeforePhysicsStep, useAfterPhysicsStep } from "mujoco-react";
120
134
 
121
- export const useMyController = createControllerHook<{ gain: number }, { value: number }>(
122
- { name: "useMyController", defaultConfig: { gain: 1.0 } },
123
- (config) => {
124
- useBeforePhysicsStep((_model, data) => {
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
- // const result = useMyController({ gain: 2.0 });
134
- // const disabled = useMyController(null); // returns null
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
- The `createController` factory is the component equivalent — same config stabilization, but returns a component that can render children:
179
+ For reusable controllers with typed config, default merging, and children, use the `createController` factory:
138
180
 
139
181
  ```tsx
140
- import { createController, useBeforePhysicsStep, Debug } from "mujoco-react";
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
- useBeforePhysicsStep((_model, data) => {
146
- data.ctrl[0] = config.gain * Math.sin(data.time);
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.loadFromXML("/path/to/scene.xml");
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-js 0.0.7)
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 the WASM contact array.
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(data: MujocoData, i: number): MujocoContact | undefined;
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
- loadFromXML: (path: string) => MujocoModel;
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-js';
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
- ...wasmUrl ? { locateFile: (path) => path.endsWith(".wasm") ? wasmUrl : path } : {},
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(data, i) {
64
+ function getContact(contacts, i) {
64
65
  try {
65
- return data.contact.get(i);
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.MjModel.loadFromXML(`/working/${config.sceneFile}`);
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
- for (let i = 0; i < ncon; i++) {
1084
- const c = getContact(data, i);
1085
- if (!c) break;
1086
- contacts.push({
1087
- geom1: c.geom1,
1088
- geom1Name: getName(model, model.name_geomadr[c.geom1]),
1089
- geom2: c.geom2,
1090
- geom2Name: getName(model, model.name_geomadr[c.geom2]),
1091
- pos: [c.pos[0], c.pos[1], c.pos[2]],
1092
- depth: c.dist
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
- for (let i = 0; i < count; i++) {
2259
- const c = getContact(data, i);
2260
- if (!c) {
2261
- mesh.count = i;
2262
- mesh.instanceMatrix.needsUpdate = true;
2263
- return;
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
- _dummy.position.set(c.pos[0], c.pos[1], c.pos[2]);
2266
- _dummy.updateMatrix();
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
- for (let i = 0; i < Math.min(ncon, MAX_CONTACT_ARROWS); i++) {
2811
- const c = getContact(data, i);
2812
- if (!c) break;
2813
- _contactPos.set(c.pos[0], c.pos[1], c.pos[2]);
2814
- _contactNormal.set(c.frame[0], c.frame[1], c.frame[2]);
2815
- const force = Math.abs(c.dist) * 100;
2816
- const length = Math.min(force * 0.01, 0.1);
2817
- if (length > 1e-3 && arrowIdx < pool.length) {
2818
- const arrow = pool[arrowIdx];
2819
- arrow.position.copy(_contactPos);
2820
- arrow.setDirection(_contactNormal);
2821
- arrow.setLength(length, length * 0.3, length * 0.15);
2822
- arrow.visible = true;
2823
- arrowIdx++;
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
- for (let i = 0; i < ncon; i++) {
3057
- const c = getContact(data, i);
3058
- if (!c) break;
3059
- if (filterBody >= 0) {
3060
- const b1 = model.geom_bodyid[c.geom1];
3061
- const b2 = model.geom_bodyid[c.geom2];
3062
- if (b1 !== filterBody && b2 !== filterBody) continue;
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
- contacts.push({
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 are NOT available in mujoco-js 0.0.7.
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
  /**