mujoco-react 8.2.1 → 8.4.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mujoco-react",
3
- "version": "8.2.1",
3
+ "version": "8.4.0",
4
4
  "description": "Composable React Three Fiber building blocks for MuJoCo WASM simulations",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -34,7 +34,7 @@
34
34
  "license": "Apache-2.0",
35
35
  "repository": {
36
36
  "type": "git",
37
- "url": "https://github.com/noah-wardlow/mujoco-react"
37
+ "url": "git+https://github.com/noah-wardlow/mujoco-react.git"
38
38
  },
39
39
  "scripts": {
40
40
  "build": "tsup",
@@ -48,7 +48,7 @@
48
48
  "three": ">=0.160.0"
49
49
  },
50
50
  "dependencies": {
51
- "mujoco-js": "0.0.7"
51
+ "@mujoco/mujoco": "^3.9.0"
52
52
  },
53
53
  "devDependencies": {
54
54
  "@react-three/drei": "^10.7.7",
@@ -13,7 +13,7 @@ import { useFrame } from '@react-three/fiber';
13
13
  import type { ThreeElements } from '@react-three/fiber';
14
14
  import * as THREE from 'three';
15
15
  import { useMujocoContext } from '../core/MujocoSimProvider';
16
- import { getContact } from '../types';
16
+ import { getContact, withContacts } from '../types';
17
17
 
18
18
  const _dummy = new THREE.Object3D();
19
19
 
@@ -49,19 +49,21 @@ export function ContactMarkers({
49
49
  const ncon = data.ncon;
50
50
  const count = Math.min(ncon, maxContacts);
51
51
 
52
- for (let i = 0; i < count; i++) {
53
- const c = getContact(data, i);
54
- if (!c) {
55
- mesh.count = i;
56
- mesh.instanceMatrix.needsUpdate = true;
57
- return;
52
+ let resolvedCount = count;
53
+ withContacts(data, (contactArray) => {
54
+ for (let i = 0; i < count; i++) {
55
+ const c = getContact(contactArray, i);
56
+ if (!c) {
57
+ resolvedCount = i;
58
+ return;
59
+ }
60
+ _dummy.position.set(c.pos[0], c.pos[1], c.pos[2]);
61
+ _dummy.updateMatrix();
62
+ mesh.setMatrixAt(i, _dummy.matrix);
58
63
  }
59
- _dummy.position.set(c.pos[0], c.pos[1], c.pos[2]);
60
- _dummy.updateMatrix();
61
- mesh.setMatrixAt(i, _dummy.matrix);
62
- }
64
+ });
63
65
 
64
- mesh.count = count;
66
+ mesh.count = resolvedCount;
65
67
  mesh.instanceMatrix.needsUpdate = true;
66
68
  });
67
69
 
@@ -11,7 +11,7 @@ import type { ThreeElements } from '@react-three/fiber';
11
11
  import * as THREE from 'three';
12
12
  import { useMujocoContext } from '../core/MujocoSimProvider';
13
13
  import { getName } from '../core/SceneLoader';
14
- import { getContact } from '../types';
14
+ import { getContact, withContacts } from '../types';
15
15
  import type { DebugProps } from '../types';
16
16
 
17
17
  const JOINT_COLORS: Record<number, number> = {
@@ -330,22 +330,24 @@ export function Debug({
330
330
  const ncon = data.ncon;
331
331
  let arrowIdx = 0;
332
332
 
333
- for (let i = 0; i < Math.min(ncon, MAX_CONTACT_ARROWS); i++) {
334
- const c = getContact(data, i);
335
- if (!c) break;
336
- _contactPos.set(c.pos[0], c.pos[1], c.pos[2]);
337
- _contactNormal.set(c.frame[0], c.frame[1], c.frame[2]);
338
- const force = Math.abs(c.dist) * 100;
339
- const length = Math.min(force * 0.01, 0.1);
340
- if (length > 0.001 && arrowIdx < pool.length) {
341
- const arrow = pool[arrowIdx];
342
- arrow.position.copy(_contactPos);
343
- arrow.setDirection(_contactNormal);
344
- arrow.setLength(length, length * 0.3, length * 0.15);
345
- arrow.visible = true;
346
- arrowIdx++;
333
+ withContacts(data, (contactArray) => {
334
+ for (let i = 0; i < Math.min(ncon, MAX_CONTACT_ARROWS); i++) {
335
+ const c = getContact(contactArray, i);
336
+ if (!c) break;
337
+ _contactPos.set(c.pos[0], c.pos[1], c.pos[2]);
338
+ _contactNormal.set(c.frame[0], c.frame[1], c.frame[2]);
339
+ const force = Math.abs(c.dist) * 100;
340
+ const length = Math.min(force * 0.01, 0.1);
341
+ if (length > 0.001 && arrowIdx < pool.length) {
342
+ const arrow = pool[arrowIdx];
343
+ arrow.position.copy(_contactPos);
344
+ arrow.setDirection(_contactNormal);
345
+ arrow.setLength(length, length * 0.3, length * 0.15);
346
+ arrow.visible = true;
347
+ arrowIdx++;
348
+ }
347
349
  }
348
- }
350
+ });
349
351
 
350
352
  // Hide unused arrows
351
353
  for (let i = arrowIdx; i < pool.length; i++) {
@@ -39,7 +39,7 @@ export function FlexRenderer(props: Omit<ThreeElements['group'], 'ref'>) {
39
39
  const positions = new Float32Array(vertNum * 3);
40
40
  geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3));
41
41
 
42
- // Note: flex_faceadr/flex_facenum/flex_face are NOT available in mujoco-js WASM.
42
+ // Note: flex_faceadr/flex_facenum/flex_face may not be available in all MuJoCo WASM builds.
43
43
  // Without face data we render as a point cloud. If future WASM versions expose
44
44
  // face arrays, index-based triangle rendering can be added here.
45
45
 
@@ -7,7 +7,7 @@
7
7
  * WASM fields used: model.ntendon, model.ten_wrapadr, model.ten_wrapnum
8
8
  * data.wrap_xpos, data.ten_wrapadr (runtime)
9
9
  *
10
- * Note: ten_rgba and ten_width are NOT available in mujoco-js 0.0.7.
10
+ * Note: ten_rgba and ten_width may not be available in all MuJoCo WASM builds.
11
11
  * Tendons use a default color and width.
12
12
  */
13
13
 
@@ -41,10 +41,10 @@ export class GenericIK {
41
41
  * @param model MuJoCo model
42
42
  * @param data MuJoCo data (qpos will be temporarily modified, then restored)
43
43
  * @param siteId Index of the end-effector site to control
44
- * @param numJoints Number of arm joints (assumes qpos[0..numJoints-1])
44
+ * @param qposAdr qpos addresses for scalar joints in solve order
45
45
  * @param targetPos Target position in world frame
46
46
  * @param targetQuat Target orientation in world frame
47
- * @param currentQ Current joint angles (length = numJoints)
47
+ * @param currentQ Current joint angles matching qposAdr order
48
48
  * @param opts Optional solver parameters
49
49
  * @returns Joint angles array, or null if solver diverged
50
50
  */
@@ -52,14 +52,14 @@ export class GenericIK {
52
52
  model: MujocoModel,
53
53
  data: MujocoData,
54
54
  siteId: number,
55
- numJoints: number,
55
+ qposAdr: ArrayLike<number>,
56
56
  targetPos: THREE.Vector3,
57
57
  targetQuat: THREE.Quaternion,
58
- currentQ: number[],
58
+ currentQ: ArrayLike<number>,
59
59
  opts?: Partial<GenericIKOptions>
60
60
  ): number[] | null {
61
61
  const o = { ...DEFAULTS, ...opts };
62
- const n = numJoints;
62
+ const n = qposAdr.length;
63
63
 
64
64
  // Save full qpos so we can restore after solving
65
65
  const savedQpos = new Float64Array(data.qpos.length);
@@ -86,9 +86,11 @@ export class GenericIK {
86
86
  let bestQ: number[] | null = null;
87
87
  let bestErr = Infinity;
88
88
 
89
+ if (n === 0) return null;
90
+
89
91
  for (let iter = 0; iter < o.maxIterations; iter++) {
90
92
  // Set joints and run FK
91
- for (let i = 0; i < n; i++) data.qpos[i] = q[i];
93
+ for (let i = 0; i < n; i++) data.qpos[qposAdr[i]] = q[i];
92
94
  this.mujoco.mj_forward(model, data);
93
95
 
94
96
  // Read current site pose
@@ -130,8 +132,9 @@ export class GenericIK {
130
132
 
131
133
  // Compute Jacobian via finite differences
132
134
  for (let j = 0; j < n; j++) {
133
- const saved = data.qpos[j];
134
- data.qpos[j] = q[j] + o.epsilon;
135
+ const adr = qposAdr[j];
136
+ const saved = data.qpos[adr];
137
+ data.qpos[adr] = q[j] + o.epsilon;
135
138
  this.mujoco.mj_forward(model, data);
136
139
 
137
140
  for (let i = 0; i < 3; i++) pertSitePos[i] = sp[off3 + i];
@@ -150,11 +153,11 @@ export class GenericIK {
150
153
  J[5 * n + j] = (dRot[2] / o.epsilon) * o.rotWeight;
151
154
 
152
155
  // Restore joint
153
- data.qpos[j] = saved;
156
+ data.qpos[adr] = saved;
154
157
  }
155
158
 
156
159
  // Restore base FK state for next iteration
157
- for (let i = 0; i < n; i++) data.qpos[i] = q[i];
160
+ for (let i = 0; i < n; i++) data.qpos[qposAdr[i]] = q[i];
158
161
 
159
162
  // Damped least squares: Δq = Jᵀ (J Jᵀ + λI)⁻¹ error
160
163
  // 1. Compute JJᵀ (6×6)
@@ -3,9 +3,10 @@
3
3
  * SPDX-License-Identifier: Apache-2.0
4
4
  */
5
5
 
6
- import loadMujoco from 'mujoco-js';
6
+ import loadMujoco from '@mujoco/mujoco';
7
+ import defaultMujocoWasmUrl from '@mujoco/mujoco/mujoco.wasm?url';
7
8
  import { createContext, useContext, useEffect, useRef, useState } from 'react';
8
- import { MujocoModule, MujocoContextValue } from '../types';
9
+ import type { MujocoModule, MujocoContextValue } from '../types';
9
10
 
10
11
  const MujocoContext = createContext<MujocoContextValue>({
11
12
  mujoco: null,
@@ -20,19 +21,77 @@ export function useMujocoWasm(): MujocoContextValue {
20
21
  return useContext(MujocoContext);
21
22
  }
22
23
 
23
- interface MujocoProviderProps {
24
+ export type MujocoWasmVariant = 'single' | 'threaded' | 'auto';
25
+
26
+ export interface MujocoLoaderOptions {
27
+ locateFile?: (path: string) => string;
28
+ printErr?: (text: string) => void;
29
+ }
30
+
31
+ export type MujocoLoader = (options?: MujocoLoaderOptions) => Promise<unknown>;
32
+
33
+ export interface MujocoProviderProps {
24
34
  wasmUrl?: string;
35
+ /** Optional URL for the multi-threaded WASM asset. */
36
+ mtWasmUrl?: string;
37
+ /**
38
+ * Optional official multi-threaded loader, usually imported from
39
+ * `@mujoco/mujoco/mt`. It is supplied by the app so the default package path
40
+ * does not force every bundler to process the threaded Emscripten build.
41
+ */
42
+ threadedLoader?: MujocoLoader;
43
+ /**
44
+ * MuJoCo WASM build to load. `single` is the default and works everywhere.
45
+ * `threaded` requires `threadedLoader` and cross-origin isolation. `auto`
46
+ * uses threaded only when both conditions are satisfied.
47
+ */
48
+ wasmVariant?: MujocoWasmVariant;
25
49
  /** Timeout in ms for WASM module load. Default: 30000. */
26
50
  timeout?: number;
27
51
  children: React.ReactNode;
28
52
  onError?: (error: Error) => void;
29
53
  }
30
54
 
55
+ function canUseThreadedWasm(): boolean {
56
+ return typeof globalThis !== 'undefined' && globalThis.crossOriginIsolated === true;
57
+ }
58
+
59
+ function isMujocoModule(value: unknown): value is MujocoModule {
60
+ return typeof value === 'object'
61
+ && value !== null
62
+ && 'FS' in value
63
+ && 'MjModel' in value
64
+ && 'MjData' in value
65
+ && 'mj_step' in value;
66
+ }
67
+
68
+ function hasWasmUrl(value: string | undefined): value is string {
69
+ return typeof value === 'string' && value.length > 0;
70
+ }
71
+
72
+ function resolveWasmVariant(
73
+ variant: MujocoWasmVariant | undefined,
74
+ threadedLoader: MujocoLoader | undefined,
75
+ mtWasmUrl: string | undefined
76
+ ): 'single' | 'threaded' {
77
+ if (variant === 'threaded') return 'threaded';
78
+ if (variant === 'auto' && threadedLoader && mtWasmUrl && canUseThreadedWasm()) return 'threaded';
79
+ return 'single';
80
+ }
81
+
31
82
  /**
32
83
  * MujocoProvider — WASM / module lifecycle.
33
84
  * Loads the MuJoCo WASM module on mount and provides it to children via context.
34
85
  */
35
- export function MujocoProvider({ wasmUrl, timeout = 30000, children, onError }: MujocoProviderProps) {
86
+ export function MujocoProvider({
87
+ wasmUrl,
88
+ mtWasmUrl,
89
+ threadedLoader,
90
+ wasmVariant = 'single',
91
+ timeout = 30000,
92
+ children,
93
+ onError,
94
+ }: MujocoProviderProps) {
36
95
  const [status, setStatus] = useState<'loading' | 'ready' | 'error'>('loading');
37
96
  const [error, setError] = useState<string | null>(null);
38
97
  const moduleRef = useRef<MujocoModule | null>(null);
@@ -41,8 +100,31 @@ export function MujocoProvider({ wasmUrl, timeout = 30000, children, onError }:
41
100
  useEffect(() => {
42
101
  isMounted.current = true;
43
102
 
44
- const wasmPromise = loadMujoco({
45
- ...(wasmUrl ? { locateFile: (path: string) => path.endsWith('.wasm') ? wasmUrl : path } : {}),
103
+ const variant = resolveWasmVariant(wasmVariant, threadedLoader, mtWasmUrl);
104
+ if (variant === 'threaded' && !threadedLoader) {
105
+ const err = new Error('MujocoProvider wasmVariant="threaded" requires a threadedLoader from @mujoco/mujoco/mt');
106
+ setError(err.message);
107
+ setStatus('error');
108
+ onError?.(err);
109
+ return;
110
+ }
111
+ let selectedWasmUrl = wasmUrl ?? defaultMujocoWasmUrl;
112
+
113
+ if (variant === 'threaded') {
114
+ if (!hasWasmUrl(mtWasmUrl)) {
115
+ const err = new Error('MujocoProvider wasmVariant="threaded" requires mtWasmUrl from @mujoco/mujoco/mt/mujoco.wasm?url');
116
+ setError(err.message);
117
+ setStatus('error');
118
+ onError?.(err);
119
+ return;
120
+ }
121
+ selectedWasmUrl = mtWasmUrl;
122
+ }
123
+
124
+ const load: MujocoLoader = variant === 'threaded' && threadedLoader ? threadedLoader : loadMujoco;
125
+
126
+ const wasmPromise = load({
127
+ locateFile: (path: string) => path.endsWith('.wasm') ? selectedWasmUrl : path,
46
128
  printErr: (text: string) => {
47
129
  if (text.includes('Aborted') && isMounted.current) {
48
130
  setError('Simulation crashed. Reload page.');
@@ -58,7 +140,10 @@ export function MujocoProvider({ wasmUrl, timeout = 30000, children, onError }:
58
140
  Promise.race([wasmPromise, timeoutPromise])
59
141
  .then((inst: unknown) => {
60
142
  if (isMounted.current) {
61
- moduleRef.current = inst as MujocoModule;
143
+ if (!isMujocoModule(inst)) {
144
+ throw new Error('MuJoCo WASM module initialized with an unexpected shape');
145
+ }
146
+ moduleRef.current = inst;
62
147
  setStatus('ready');
63
148
  }
64
149
  })
@@ -74,7 +159,7 @@ export function MujocoProvider({ wasmUrl, timeout = 30000, children, onError }:
74
159
  return () => {
75
160
  isMounted.current = false;
76
161
  };
77
- }, [wasmUrl, timeout]);
162
+ }, [wasmUrl, mtWasmUrl, threadedLoader, wasmVariant, timeout, onError]);
78
163
 
79
164
  return (
80
165
  <MujocoContext.Provider
@@ -14,11 +14,14 @@ import {
14
14
  useState,
15
15
  } from 'react';
16
16
  import * as THREE from 'three';
17
- import { MujocoData, MujocoModel, MujocoModule, getContact } from '../types';
17
+ import { MujocoData, MujocoModel, MujocoModule, getContact, withContacts } from '../types';
18
18
  import { SceneRenderer } from '../components/SceneRenderer';
19
19
  import {
20
+ ActuatedJointInfo,
20
21
  ActuatorInfo,
21
22
  BodyInfo,
23
+ ControlGroupInfo,
24
+ ControlGroupSelector,
22
25
  ContactInfo,
23
26
  GeomInfo,
24
27
  JointInfo,
@@ -40,7 +43,10 @@ import {
40
43
  findSensorByName,
41
44
  findActuatorByName,
42
45
  getActuatedScalarQposAdr,
46
+ getActuatedJoints as getActuatedJointsFromModel,
47
+ getControlMap as getControlMapFromModel,
43
48
  getName,
49
+ resolveControlGroup as resolveControlGroupFromModel,
44
50
  } from './SceneLoader';
45
51
 
46
52
  // ---- Joint type names ----
@@ -65,6 +71,24 @@ const SENSOR_TYPE_NAMES: Record<number, string> = {
65
71
  45: 'clock', 46: 'tactile', 47: 'plugin', 48: 'user',
66
72
  };
67
73
 
74
+ const EMPTY_CONTROL_GROUP: ControlGroupInfo = {
75
+ joints: [],
76
+ actuators: [],
77
+ qposAdr: [],
78
+ dofAdr: [],
79
+ ctrlAdr: [],
80
+ readQpos: () => new Float64Array(0),
81
+ readCtrl: () => new Float64Array(0),
82
+ writeQpos: () => {},
83
+ writeCtrl: () => {},
84
+ };
85
+
86
+ function isMutableApiRef(
87
+ ref: React.ForwardedRef<MujocoSimAPI>
88
+ ): ref is React.MutableRefObject<MujocoSimAPI | null> {
89
+ return typeof ref === 'object' && ref !== null && 'current' in ref;
90
+ }
91
+
68
92
  // Preallocated force/torque temps for applyForce/applyTorque
69
93
  const _applyForce = new Float64Array(3);
70
94
  const _applyTorque = new Float64Array(3);
@@ -329,8 +353,8 @@ export function MujocoSimProvider({
329
353
  if (externalApiRef) {
330
354
  if (typeof externalApiRef === 'function') {
331
355
  externalApiRef(api);
332
- } else {
333
- (externalApiRef as React.MutableRefObject<MujocoSimAPI | null>).current = api;
356
+ } else if (isMutableApiRef(externalApiRef)) {
357
+ externalApiRef.current = api;
334
358
  }
335
359
  }
336
360
  }
@@ -576,18 +600,20 @@ export function MujocoSimProvider({
576
600
  if (!model || !data) return [];
577
601
  const contacts: ContactInfo[] = [];
578
602
  const ncon = data.ncon;
579
- for (let i = 0; i < ncon; i++) {
580
- const c = getContact(data, i);
581
- if (!c) break;
582
- contacts.push({
583
- geom1: c.geom1,
584
- geom1Name: getName(model, model.name_geomadr[c.geom1]),
585
- geom2: c.geom2,
586
- geom2Name: getName(model, model.name_geomadr[c.geom2]),
587
- pos: [c.pos[0], c.pos[1], c.pos[2]],
588
- depth: c.dist,
589
- });
590
- }
603
+ withContacts(data, (contactArray) => {
604
+ for (let i = 0; i < ncon; i++) {
605
+ const c = getContact(contactArray, i);
606
+ if (!c) break;
607
+ contacts.push({
608
+ geom1: c.geom1,
609
+ geom1Name: getName(model, model.name_geomadr[c.geom1]),
610
+ geom2: c.geom2,
611
+ geom2Name: getName(model, model.name_geomadr[c.geom2]),
612
+ pos: [c.pos[0], c.pos[1], c.pos[2]],
613
+ depth: c.dist,
614
+ });
615
+ }
616
+ });
591
617
  return contacts;
592
618
  }, []);
593
619
 
@@ -612,14 +638,14 @@ export function MujocoSimProvider({
612
638
  const result: JointInfo[] = [];
613
639
  for (let i = 0; i < model.njnt; i++) {
614
640
  const type = model.jnt_type[i];
615
- const limited = model.jnt_limited ? model.jnt_limited[i] !== 0 : false;
641
+ const range: [number, number] = [model.jnt_range[2 * i], model.jnt_range[2 * i + 1]];
616
642
  result.push({
617
643
  id: i,
618
644
  name: getName(model, model.name_jntadr[i]),
619
645
  type,
620
646
  typeName: JOINT_TYPE_NAMES[type] ?? `unknown(${type})`,
621
- range: [model.jnt_range[2 * i], model.jnt_range[2 * i + 1]],
622
- limited,
647
+ range,
648
+ limited: range[0] < range[1],
623
649
  bodyId: model.jnt_bodyid[i],
624
650
  qposAdr: model.jnt_qposadr[i],
625
651
  dofAdr: model.jnt_dofadr[i],
@@ -677,6 +703,21 @@ export function MujocoSimProvider({
677
703
  return result;
678
704
  }, []);
679
705
 
706
+ const getControlMapApi = useCallback((): ControlGroupInfo => {
707
+ const model = mjModelRef.current;
708
+ return model ? getControlMapFromModel(model) : EMPTY_CONTROL_GROUP;
709
+ }, []);
710
+
711
+ const getActuatedJointsApi = useCallback((): ActuatedJointInfo[] => {
712
+ const model = mjModelRef.current;
713
+ return model ? getActuatedJointsFromModel(model) : [];
714
+ }, []);
715
+
716
+ const resolveControlGroupApi = useCallback((selector: ControlGroupSelector): ControlGroupInfo | null => {
717
+ const model = mjModelRef.current;
718
+ return model ? resolveControlGroupFromModel(model, selector) : null;
719
+ }, []);
720
+
680
721
  const getSensors = useCallback((): SensorInfo[] => {
681
722
  const model = mjModelRef.current;
682
723
  if (!model) return [];
@@ -937,6 +978,9 @@ export function MujocoSimProvider({
937
978
  getQvel,
938
979
  setCtrl,
939
980
  getCtrl,
981
+ getControlMap: getControlMapApi,
982
+ getActuatedJoints: getActuatedJointsApi,
983
+ resolveControlGroup: resolveControlGroupApi,
940
984
  applyForce,
941
985
  applyTorque: applyTorqueApi,
942
986
  setExternalForce,
@@ -968,6 +1012,7 @@ export function MujocoSimProvider({
968
1012
  status, config, reset, setSpeed, togglePause, setPaused, step,
969
1013
  getTime, getTimestep, applyKeyframe, saveState, restoreState,
970
1014
  setQpos, setQvel, getQpos, getQvel, setCtrl, getCtrl,
1015
+ getControlMapApi, getActuatedJointsApi, resolveControlGroupApi,
971
1016
  applyForce, applyTorqueApi, setExternalForce, applyGeneralizedForce,
972
1017
  getSensorData, getContacts, getBodies, getJoints, getGeoms, getSites,
973
1018
  getActuatorsApi, getSensors, getModelOption, setGravity, setTimestepApi,