mujoco-react 6.0.1 → 7.0.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": "6.0.1",
3
+ "version": "7.0.0",
4
4
  "description": "Composable React Three Fiber building blocks for MuJoCo WASM simulations",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -0,0 +1,102 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ import { useFrame } from '@react-three/fiber';
7
+ import { useEffect, useLayoutEffect, useRef } from 'react';
8
+ import * as THREE from 'three';
9
+ import { useMujocoContext } from '../core/MujocoSimProvider';
10
+ import { findBodyByName } from '../core/SceneLoader';
11
+ import type { BodyProps, SceneObject } from '../types';
12
+
13
+ /**
14
+ * Declarative physics body component. Registers a body definition in the
15
+ * provider-level registry so it gets injected into the MJCF XML at load time.
16
+ *
17
+ * Bodies present at initial mount cause zero extra reloads (useLayoutEffect
18
+ * runs before the provider's loadScene useEffect). Bodies added/removed after
19
+ * the initial load trigger a debounced scene reload.
20
+ */
21
+ export function Body({
22
+ name,
23
+ type,
24
+ size,
25
+ position = [0, 0, 0],
26
+ rgba = [0.5, 0.5, 0.5, 1],
27
+ mass,
28
+ freejoint,
29
+ friction,
30
+ solref,
31
+ solimp,
32
+ condim,
33
+ children,
34
+ }: BodyProps) {
35
+ const { bodyRegistryRef, hiddenBodiesRef, requestBodyReload, mjDataRef, mjModelRef, status } =
36
+ useMujocoContext();
37
+ const bodyIdRef = useRef(-1);
38
+ const groupRef = useRef<THREE.Group>(null);
39
+ const initialLoadRef = useRef(true);
40
+ const hasChildren = children != null;
41
+
42
+ // Register in body registry BEFORE the provider's loadScene useEffect fires.
43
+ useLayoutEffect(() => {
44
+ const definition: SceneObject = {
45
+ name,
46
+ type,
47
+ size,
48
+ position,
49
+ rgba,
50
+ mass,
51
+ freejoint,
52
+ friction,
53
+ solref,
54
+ solimp,
55
+ condim,
56
+ };
57
+ bodyRegistryRef.current.set(name, { definition, hasCustomChildren: hasChildren });
58
+ if (hasChildren) {
59
+ hiddenBodiesRef.current.add(name);
60
+ }
61
+
62
+ return () => {
63
+ bodyRegistryRef.current.delete(name);
64
+ hiddenBodiesRef.current.delete(name);
65
+ if (!initialLoadRef.current) {
66
+ requestBodyReload();
67
+ }
68
+ };
69
+ }, [name, type, size, position, rgba, mass, freejoint, friction, solref, solimp, condim, hasChildren, bodyRegistryRef, hiddenBodiesRef, requestBodyReload]);
70
+
71
+ // Resolve body ID once the scene is ready
72
+ useEffect(() => {
73
+ if (status !== 'ready') return;
74
+ const model = mjModelRef.current;
75
+ if (!model) return;
76
+ bodyIdRef.current = findBodyByName(model, name);
77
+ initialLoadRef.current = false;
78
+ }, [status, name, mjModelRef]);
79
+
80
+ // Sync group transform to body pose each frame (only when children are provided)
81
+ useFrame(() => {
82
+ if (!hasChildren) return;
83
+ const data = mjDataRef.current;
84
+ const id = bodyIdRef.current;
85
+ const group = groupRef.current;
86
+ if (!data || id < 0 || !group) return;
87
+
88
+ const i3 = id * 3;
89
+ const i4 = id * 4;
90
+ group.position.set(data.xpos[i3], data.xpos[i3 + 1], data.xpos[i3 + 2]);
91
+ group.quaternion.set(
92
+ data.xquat[i4 + 1],
93
+ data.xquat[i4 + 2],
94
+ data.xquat[i4 + 3],
95
+ data.xquat[i4],
96
+ );
97
+ });
98
+
99
+ if (!hasChildren) return null;
100
+
101
+ return <group ref={groupRef}>{children}</group>;
102
+ }
@@ -12,7 +12,7 @@ import { useRef } from 'react';
12
12
  import { useFrame } from '@react-three/fiber';
13
13
  import type { ThreeElements } from '@react-three/fiber';
14
14
  import * as THREE from 'three';
15
- import { useMujoco } from '../core/MujocoSimProvider';
15
+ import { useMujocoContext } from '../core/MujocoSimProvider';
16
16
  import { getContact } from '../types';
17
17
 
18
18
  const _dummy = new THREE.Object3D();
@@ -35,7 +35,7 @@ export function ContactMarkers({
35
35
  visible = true,
36
36
  ...groupProps
37
37
  }: ContactMarkersProps & Omit<ThreeElements['group'], 'ref' | 'visible'> = {}) {
38
- const { mjDataRef, status } = useMujoco();
38
+ const { mjDataRef, status } = useMujocoContext();
39
39
  const meshRef = useRef<THREE.InstancedMesh>(null);
40
40
 
41
41
  useFrame(() => {
@@ -9,7 +9,7 @@ import { useEffect, useMemo, useRef } from 'react';
9
9
  import { useFrame, useThree } from '@react-three/fiber';
10
10
  import type { ThreeElements } from '@react-three/fiber';
11
11
  import * as THREE from 'three';
12
- import { useMujoco } from '../core/MujocoSimProvider';
12
+ import { useMujocoContext } from '../core/MujocoSimProvider';
13
13
  import { getName } from '../core/SceneLoader';
14
14
  import { getContact } from '../types';
15
15
  import type { DebugProps } from '../types';
@@ -43,7 +43,7 @@ export function Debug({
43
43
  showTendons = false,
44
44
  ...groupProps
45
45
  }: DebugProps & Omit<ThreeElements['group'], 'ref'>) {
46
- const { mjModelRef, mjDataRef, status } = useMujoco();
46
+ const { mjModelRef, mjDataRef, status } = useMujocoContext();
47
47
  const { scene } = useThree();
48
48
  const groupRef = useRef<THREE.Group>(null);
49
49
 
@@ -7,7 +7,7 @@ import { useFrame, useThree } from '@react-three/fiber';
7
7
  import type { ThreeElements } from '@react-three/fiber';
8
8
  import { useEffect, useRef } from 'react';
9
9
  import * as THREE from 'three';
10
- import { useMujoco, useBeforePhysicsStep } from '../core/MujocoSimProvider';
10
+ import { useMujocoContext, useBeforePhysicsStep } from '../core/MujocoSimProvider';
11
11
  import type { DragInteractionProps } from '../types';
12
12
 
13
13
  // Preallocated temps to avoid GC pressure
@@ -38,7 +38,7 @@ export function DragInteraction({
38
38
  showArrow = true,
39
39
  ...groupProps
40
40
  }: DragInteractionProps & Omit<ThreeElements['group'], 'ref'>) {
41
- const { mjDataRef, mujocoRef, mjModelRef, status } = useMujoco();
41
+ const { mjDataRef, mujocoRef, mjModelRef, status } = useMujocoContext();
42
42
  const { gl, camera, scene, controls } = useThree();
43
43
 
44
44
  const draggingRef = useRef(false);
@@ -9,14 +9,14 @@ import { useEffect, useRef } from 'react';
9
9
  import { useFrame } from '@react-three/fiber';
10
10
  import type { ThreeElements } from '@react-three/fiber';
11
11
  import * as THREE from 'three';
12
- import { useMujoco } from '../core/MujocoSimProvider';
12
+ import { useMujocoContext } from '../core/MujocoSimProvider';
13
13
 
14
14
  /**
15
15
  * Renders MuJoCo flex (deformable) bodies as dynamic meshes.
16
16
  * Vertices are updated every frame from flexvert_xpos.
17
17
  */
18
18
  export function FlexRenderer(props: Omit<ThreeElements['group'], 'ref'>) {
19
- const { mjModelRef, mjDataRef, status } = useMujoco();
19
+ const { mjModelRef, mjDataRef, status } = useMujocoContext();
20
20
  const groupRef = useRef<THREE.Group>(null);
21
21
  const meshesRef = useRef<THREE.Mesh[]>([]);
22
22
 
@@ -7,8 +7,7 @@ import { PivotControls } from '@react-three/drei';
7
7
  import { useFrame, useThree } from '@react-three/fiber';
8
8
  import { useEffect, useRef } from 'react';
9
9
  import * as THREE from 'three';
10
- import { useMujoco } from '../core/MujocoSimProvider';
11
- import { useIk } from '../core/IkContext';
10
+ import { useMujocoContext } from '../core/MujocoSimProvider';
12
11
  import { findSiteByName } from '../core/SceneLoader';
13
12
  import type { IkGizmoProps } from '../types';
14
13
 
@@ -21,18 +20,19 @@ const _scale = new THREE.Vector3(1, 1, 1);
21
20
  /**
22
21
  * IkGizmo — drei PivotControls that tracks a MuJoCo site.
23
22
  *
24
- * Must be rendered inside an `<IkController>`.
23
+ * Requires a `controller` from `useIkController()`.
25
24
  *
26
25
  * Props:
27
- * - `siteName` — MuJoCo site to track. Defaults to the IkController's configured site.
26
+ * - `controller` — IkContextValue from `useIkController()`.
27
+ * - `siteName` — MuJoCo site to track. Defaults to the controller's configured site.
28
28
  * - `scale` — Gizmo handle scale. Default: 0.18.
29
29
  * - `onDrag` — Custom drag callback `(pos, quat) => void`.
30
30
  * When omitted, dragging enables IK and writes to the IK target.
31
31
  * When provided, the consumer handles what happens during drag.
32
32
  */
33
- export function IkGizmo({ siteName, scale = 0.18, onDrag }: IkGizmoProps) {
34
- const { mjModelRef, mjDataRef, status } = useMujoco();
35
- const { ikTargetRef, siteIdRef, ikEnabledRef, setIkEnabled } = useIk();
33
+ export function IkGizmo({ controller, siteName, scale = 0.18, onDrag }: IkGizmoProps) {
34
+ const { mjModelRef, mjDataRef, status } = useMujocoContext();
35
+ const { ikTargetRef, siteIdRef, ikEnabledRef, setIkEnabled } = controller;
36
36
 
37
37
  const wrapperRef = useRef<THREE.Group>(null);
38
38
  const pivotRef = useRef<THREE.Group>(null);
@@ -53,9 +53,6 @@ export function IkGizmo({ siteName, scale = 0.18, onDrag }: IkGizmoProps) {
53
53
  // Every frame: sync the visual wrapper to the tracked site (when not dragging)
54
54
  useFrame(() => {
55
55
  const data = mjDataRef.current;
56
- // Read IkController's siteIdRef directly in useFrame — avoids useEffect timing
57
- // issues (React runs child effects before parent effects, so reading siteIdRef
58
- // in a useEffect would see -1 before IkController resolves it).
59
56
  const sid = siteName ? localSiteIdRef.current : siteIdRef.current;
60
57
  if (!data || sid < 0 || !wrapperRef.current) return;
61
58
 
@@ -67,7 +64,6 @@ export function IkGizmo({ siteName, scale = 0.18, onDrag }: IkGizmoProps) {
67
64
 
68
65
  // Position wrapper at the site
69
66
  wrapperRef.current.position.set(p[i3], p[i3 + 1], p[i3 + 2]);
70
- // MuJoCo site_xmat is row-major 3x3; THREE.Matrix4.set() is row-major
71
67
  _mat4.set(
72
68
  m[i9], m[i9 + 1], m[i9 + 2], 0,
73
69
  m[i9 + 3], m[i9 + 4], m[i9 + 5], 0,
@@ -10,14 +10,14 @@ import * as THREE from 'three';
10
10
  import { GeomBuilder } from '../rendering/GeomBuilder';
11
11
  import { MujocoModel } from '../types';
12
12
  import { getName } from '../core/SceneLoader';
13
- import { useMujoco } from '../core/MujocoSimProvider';
13
+ import { useMujocoContext } from '../core/MujocoSimProvider';
14
14
 
15
15
  /**
16
16
  * SceneRenderer — creates and syncs MuJoCo body meshes every frame.
17
17
  * Accepts standard R3F group props (position, rotation, scale, visible, etc.).
18
18
  */
19
19
  export function SceneRenderer(props: Omit<ThreeElements['group'], 'ref'>) {
20
- const { mjModelRef, mjDataRef, mujocoRef, onSelectionRef, status } = useMujoco();
20
+ const { mjModelRef, mjDataRef, mujocoRef, onSelectionRef, hiddenBodiesRef, status } = useMujocoContext();
21
21
  const groupRef = useRef<THREE.Group>(null);
22
22
  const bodyRefs = useRef<(THREE.Group | null)[]>([]);
23
23
  const prevModelRef = useRef<MujocoModel | null>(null);
@@ -48,11 +48,14 @@ export function SceneRenderer(props: Omit<ThreeElements['group'], 'ref'>) {
48
48
  for (let i = 0; i < model.nbody; i++) {
49
49
  const bodyGroup = new THREE.Group();
50
50
  bodyGroup.userData.bodyID = i;
51
+ const bodyName = getName(model, model.name_bodyadr[i]);
51
52
 
52
- for (let g = 0; g < model.ngeom; g++) {
53
- if (model.geom_bodyid[g] === i) {
54
- const mesh = geomBuilder.create(model, g);
55
- if (mesh) bodyGroup.add(mesh);
53
+ if (!hiddenBodiesRef.current.has(bodyName)) {
54
+ for (let g = 0; g < model.ngeom; g++) {
55
+ if (model.geom_bodyid[g] === i) {
56
+ const mesh = geomBuilder.create(model, g);
57
+ if (mesh) bodyGroup.add(mesh);
58
+ }
56
59
  }
57
60
  }
58
61
 
@@ -15,7 +15,7 @@ import { useEffect, useRef } from 'react';
15
15
  import { useFrame } from '@react-three/fiber';
16
16
  import type { ThreeElements } from '@react-three/fiber';
17
17
  import * as THREE from 'three';
18
- import { useMujoco } from '../core/MujocoSimProvider';
18
+ import { useMujocoContext } from '../core/MujocoSimProvider';
19
19
 
20
20
  const DEFAULT_TENDON_COLOR = new THREE.Color(0.3, 0.3, 0.8);
21
21
  const DEFAULT_TENDON_WIDTH = 0.002;
@@ -24,7 +24,7 @@ const DEFAULT_TENDON_WIDTH = 0.002;
24
24
  const _tmpVec = new THREE.Vector3();
25
25
 
26
26
  export function TendonRenderer(props: Omit<ThreeElements['group'], 'ref'>) {
27
- const { mjModelRef, mjDataRef, status } = useMujoco();
27
+ const { mjModelRef, mjDataRef, status } = useMujocoContext();
28
28
  const groupRef = useRef<THREE.Group>(null);
29
29
  const meshesRef = useRef<THREE.Mesh[]>([]);
30
30
  const curvesRef = useRef<THREE.CatmullRomCurve3[]>([]);
@@ -27,6 +27,7 @@ import {
27
27
  PhysicsStepCallback,
28
28
  RayHit,
29
29
  SceneConfig,
30
+ SceneObject,
30
31
  SensorInfo,
31
32
  SiteInfo,
32
33
  StateSnapshot,
@@ -91,20 +92,68 @@ export interface MujocoSimContextValue {
91
92
  beforeStepCallbacks: React.RefObject<Set<PhysicsStepCallback>>;
92
93
  afterStepCallbacks: React.RefObject<Set<PhysicsStepCallback>>;
93
94
  resetCallbacks: React.RefObject<Set<() => void>>;
95
+ errorRef: React.RefObject<string | null>;
96
+ bodyRegistryRef: React.RefObject<Map<string, { definition: SceneObject; hasCustomChildren: boolean }>>;
97
+ hiddenBodiesRef: React.RefObject<Set<string>>;
98
+ requestBodyReload: () => void;
94
99
  status: 'loading' | 'ready' | 'error';
95
100
  }
96
101
 
97
102
  const MujocoSimContext = createContext<MujocoSimContextValue | null>(null);
98
103
 
99
- export function useMujoco(): MujocoSimContextValue {
104
+ export type UseMujocoResult =
105
+ | { status: 'loading'; isPending: true; isReady: false; isError: false; error: null; api: null; mjModelRef: null; mjDataRef: null }
106
+ | { status: 'error'; isPending: false; isReady: false; isError: true; error: string; api: null; mjModelRef: null; mjDataRef: null }
107
+ | { status: 'ready'; isPending: false; isReady: true; isError: false; error: null;
108
+ api: MujocoSimAPI; mjModelRef: React.RefObject<MujocoModel | null>; mjDataRef: React.RefObject<MujocoData | null> };
109
+
110
+ export function useMujocoContext(): MujocoSimContextValue {
100
111
  const ctx = useContext(MujocoSimContext);
101
112
  if (!ctx)
102
113
  throw new Error('useMujoco must be used inside <MujocoSimProvider>');
103
114
  return ctx;
104
115
  }
105
116
 
117
+ export function useMujoco(): UseMujocoResult {
118
+ const ctx = useMujocoContext();
119
+ if (ctx.status === 'ready') {
120
+ return {
121
+ status: 'ready',
122
+ isPending: false,
123
+ isReady: true,
124
+ isError: false,
125
+ error: null,
126
+ api: ctx.api,
127
+ mjModelRef: ctx.mjModelRef,
128
+ mjDataRef: ctx.mjDataRef,
129
+ };
130
+ }
131
+ if (ctx.status === 'error') {
132
+ return {
133
+ status: 'error',
134
+ isPending: false,
135
+ isReady: false,
136
+ isError: true,
137
+ error: ctx.errorRef.current ?? 'Unknown error',
138
+ api: null,
139
+ mjModelRef: null,
140
+ mjDataRef: null,
141
+ };
142
+ }
143
+ return {
144
+ status: 'loading',
145
+ isPending: true,
146
+ isReady: false,
147
+ isError: false,
148
+ error: null,
149
+ api: null,
150
+ mjModelRef: null,
151
+ mjDataRef: null,
152
+ };
153
+ }
154
+
106
155
  export function useBeforePhysicsStep(callback: PhysicsStepCallback) {
107
- const { beforeStepCallbacks } = useMujoco();
156
+ const { beforeStepCallbacks } = useMujocoContext();
108
157
  const callbackRef = useRef(callback);
109
158
  callbackRef.current = callback;
110
159
 
@@ -116,7 +165,7 @@ export function useBeforePhysicsStep(callback: PhysicsStepCallback) {
116
165
  }
117
166
 
118
167
  export function useAfterPhysicsStep(callback: PhysicsStepCallback) {
119
- const { afterStepCallbacks } = useMujoco();
168
+ const { afterStepCallbacks } = useMujocoContext();
120
169
  const callbackRef = useRef(callback);
121
170
  callbackRef.current = callback;
122
171
 
@@ -181,6 +230,10 @@ export function MujocoSimProvider({
181
230
  const beforeStepCallbacks = useRef(new Set<PhysicsStepCallback>());
182
231
  const afterStepCallbacks = useRef(new Set<PhysicsStepCallback>());
183
232
  const resetCallbacks = useRef(new Set<() => void>());
233
+ const errorRef = useRef<string | null>(null);
234
+ const bodyRegistryRef = useRef(new Map<string, { definition: SceneObject; hasCustomChildren: boolean }>());
235
+ const hiddenBodiesRef = useRef(new Set<string>());
236
+ const bodyReloadTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
184
237
 
185
238
  configRef.current = config;
186
239
 
@@ -207,13 +260,26 @@ export function MujocoSimProvider({
207
260
  model.opt.timestep = timestep;
208
261
  }, [timestep]);
209
262
 
263
+ // --- Build merged config (base + body registry) ---
264
+ function buildMergedConfig(baseConfig: SceneConfig): SceneConfig {
265
+ if (bodyRegistryRef.current.size === 0) return baseConfig;
266
+ const registeredNames = new Set(bodyRegistryRef.current.keys());
267
+ const baseObjects = (baseConfig.sceneObjects ?? []).filter(o => !registeredNames.has(o.name));
268
+ const registeredBodies = Array.from(bodyRegistryRef.current.values()).map(e => e.definition);
269
+ hiddenBodiesRef.current.clear();
270
+ for (const [name, entry] of bodyRegistryRef.current) {
271
+ if (entry.hasCustomChildren) hiddenBodiesRef.current.add(name);
272
+ }
273
+ return { ...baseConfig, sceneObjects: [...baseObjects, ...registeredBodies] };
274
+ }
275
+
210
276
  // --- Load scene on mount ---
211
277
  useEffect(() => {
212
278
  let disposed = false;
213
279
 
214
280
  (async () => {
215
281
  try {
216
- const result = await loadScene(mujoco, config);
282
+ const result = await loadScene(mujoco, buildMergedConfig(config));
217
283
  if (disposed) {
218
284
  result.mjModel.delete();
219
285
  result.mjData.delete();
@@ -236,8 +302,10 @@ export function MujocoSimProvider({
236
302
  setStatus('ready');
237
303
  } catch (e: unknown) {
238
304
  if (!disposed) {
305
+ const err = e instanceof Error ? e : new Error(String(e));
306
+ errorRef.current = err.message;
239
307
  setStatus('error');
240
- onError?.(e instanceof Error ? e : new Error(String(e)));
308
+ onError?.(err);
241
309
  }
242
310
  }
243
311
  })();
@@ -755,11 +823,20 @@ export function MujocoSimProvider({
755
823
  setStatus('ready');
756
824
  } catch (e) {
757
825
  if (gen !== loadGenRef.current) return;
826
+ errorRef.current = e instanceof Error ? e.message : String(e);
758
827
  setStatus('error');
759
828
  throw e;
760
829
  }
761
830
  }, [mujoco]);
762
831
 
832
+ const requestBodyReload = useCallback(() => {
833
+ if (bodyReloadTimerRef.current) clearTimeout(bodyReloadTimerRef.current);
834
+ bodyReloadTimerRef.current = setTimeout(() => {
835
+ bodyReloadTimerRef.current = null;
836
+ loadSceneApi(buildMergedConfig(configRef.current));
837
+ }, 0);
838
+ }, [loadSceneApi]);
839
+
763
840
  const getCanvasSnapshot = useCallback(
764
841
  (width?: number, height?: number, mimeType = 'image/jpeg'): string => {
765
842
  if (width && height) {
@@ -916,9 +993,13 @@ export function MujocoSimProvider({
916
993
  beforeStepCallbacks,
917
994
  afterStepCallbacks,
918
995
  resetCallbacks,
996
+ errorRef,
997
+ bodyRegistryRef,
998
+ hiddenBodiesRef,
999
+ requestBodyReload,
919
1000
  status,
920
1001
  }),
921
- [api, status]
1002
+ [api, status, requestBodyReload]
922
1003
  );
923
1004
 
924
1005
  return (
@@ -1,8 +1,6 @@
1
1
  /**
2
2
  * @license
3
3
  * SPDX-License-Identifier: Apache-2.0
4
- *
5
- * createController — typed factory for BYOC (Bring Your Own Controller) plugins.
6
4
  */
7
5
 
8
6
  import { useMemo, useRef } from 'react';
@@ -89,3 +87,57 @@ export function createController<TConfig>(
89
87
 
90
88
  return Controller as ControllerComponent<TConfig>;
91
89
  }
90
+
91
+ /**
92
+ * Factory that produces a typed controller hook.
93
+ *
94
+ * Same config stabilization and default merging as `createController`,
95
+ * but returns a hook instead of a component. Pass `null` to disable.
96
+ *
97
+ * @example
98
+ * ```tsx
99
+ * const useMyController = createControllerHook<MyConfig, MyValue>(
100
+ * { name: 'useMyController', defaultConfig: { gain: 1.0 } },
101
+ * function useMyControllerImpl(config) {
102
+ * // config is MyConfig | null — hooks must be called unconditionally
103
+ * useBeforePhysicsStep((_model, data) => {
104
+ * if (!config) return;
105
+ * data.ctrl[0] = config.gain * Math.sin(data.time);
106
+ * });
107
+ * if (!config) return null;
108
+ * return { /* value *\/ };
109
+ * },
110
+ * );
111
+ *
112
+ * // Usage:
113
+ * const value = useMyController({ gain: 2.0 });
114
+ * const disabled = useMyController(null); // returns null
115
+ * ```
116
+ */
117
+ export function createControllerHook<TConfig, TValue>(
118
+ options: ControllerOptions<TConfig>,
119
+ useImpl: (config: TConfig | null) => TValue | null,
120
+ ): (config: TConfig | null) => TValue | null {
121
+ const useController = (config: TConfig | null): TValue | null => {
122
+ const configObj = config as Record<string, unknown> | null;
123
+ const stableRef = useRef(configObj);
124
+ if (configObj && stableRef.current) {
125
+ if (!shallowEqual(stableRef.current, configObj)) {
126
+ stableRef.current = configObj;
127
+ }
128
+ } else {
129
+ stableRef.current = configObj;
130
+ }
131
+
132
+ const mergedConfig = useMemo(
133
+ () => stableRef.current
134
+ ? ({ ...options.defaultConfig, ...stableRef.current } as TConfig)
135
+ : null,
136
+ [stableRef.current],
137
+ );
138
+
139
+ return useImpl(mergedConfig);
140
+ };
141
+
142
+ return useController;
143
+ }
@@ -4,7 +4,7 @@
4
4
  */
5
5
 
6
6
  import { useMemo } from 'react';
7
- import { useMujoco } from '../core/MujocoSimProvider';
7
+ import { useMujocoContext } from '../core/MujocoSimProvider';
8
8
  import { getName } from '../core/SceneLoader';
9
9
  import type { ActuatorInfo } from '../types';
10
10
 
@@ -13,7 +13,7 @@ import type { ActuatorInfo } from '../types';
13
13
  * Computed once when the model loads. Consumer reads/writes data.ctrl[id] directly.
14
14
  */
15
15
  export function useActuators(): ActuatorInfo[] {
16
- const { mjModelRef, status } = useMujoco();
16
+ const { mjModelRef, status } = useMujocoContext();
17
17
 
18
18
  return useMemo(() => {
19
19
  if (status !== 'ready') return [];
@@ -7,7 +7,7 @@
7
7
 
8
8
  import { useEffect, useRef } from 'react';
9
9
  import * as THREE from 'three';
10
- import { useMujoco, useAfterPhysicsStep } from '../core/MujocoSimProvider';
10
+ import { useMujocoContext, useAfterPhysicsStep } from '../core/MujocoSimProvider';
11
11
  import { findBodyByName } from '../core/SceneLoader';
12
12
  import type { BodyStateResult } from '../types';
13
13
 
@@ -16,7 +16,7 @@ import type { BodyStateResult } from '../types';
16
16
  * All values are ref-based — updated every physics frame without re-renders.
17
17
  */
18
18
  export function useBodyState(name: string): BodyStateResult {
19
- const { mjModelRef, status } = useMujoco();
19
+ const { mjModelRef, status } = useMujocoContext();
20
20
  const bodyIdRef = useRef(-1);
21
21
  const position = useRef(new THREE.Vector3());
22
22
  const quaternion = useRef(new THREE.Quaternion());
@@ -7,7 +7,7 @@
7
7
  */
8
8
 
9
9
  import { useCallback, useEffect, useRef } from 'react';
10
- import { useMujoco, useAfterPhysicsStep } from '../core/MujocoSimProvider';
10
+ import { useMujocoContext, useAfterPhysicsStep } from '../core/MujocoSimProvider';
11
11
  import { findBodyByName, getName } from '../core/SceneLoader';
12
12
  import { getContact } from '../types';
13
13
  import type { ContactInfo, MujocoModel } from '../types';
@@ -39,7 +39,7 @@ export function useContacts(
39
39
  bodyName?: string,
40
40
  callback?: (contacts: ContactInfo[]) => void,
41
41
  ): React.RefObject<ContactInfo[]> {
42
- const { mjModelRef, status } = useMujoco();
42
+ const { mjModelRef, status } = useMujocoContext();
43
43
  const contactsRef = useRef<ContactInfo[]>([]);
44
44
  const bodyIdRef = useRef(-1);
45
45
  const bodyResolvedRef = useRef(false);
@@ -6,7 +6,7 @@
6
6
  */
7
7
 
8
8
  import { useCallback, useEffect, useRef } from 'react';
9
- import { useMujoco } from '../core/MujocoSimProvider';
9
+ import { useMujocoContext } from '../core/MujocoSimProvider';
10
10
  import { findActuatorByName } from '../core/SceneLoader';
11
11
 
12
12
  /**
@@ -17,7 +17,7 @@ import { findActuatorByName } from '../core/SceneLoader';
17
17
  * - `setValue` writes directly to `data.ctrl[actuatorId]`.
18
18
  */
19
19
  export function useCtrl(name: string): [React.RefObject<number>, (value: number) => void] {
20
- const { mjModelRef, mjDataRef, status } = useMujoco();
20
+ const { mjModelRef, mjDataRef, status } = useMujocoContext();
21
21
  const actuatorIdRef = useRef(-1);
22
22
  const valueRef = useRef(0);
23
23
 
@@ -6,7 +6,7 @@
6
6
  */
7
7
 
8
8
  import { useRef } from 'react';
9
- import { useMujoco, useBeforePhysicsStep } from '../core/MujocoSimProvider';
9
+ import { useMujocoContext, useBeforePhysicsStep } from '../core/MujocoSimProvider';
10
10
 
11
11
  interface CtrlNoiseConfig {
12
12
  /** Exponential filter rate (0-1). Higher = faster noise changes. Default: 0.01. */
@@ -25,7 +25,7 @@ interface CtrlNoiseConfig {
25
25
  * data.ctrl[i] += noise[i]
26
26
  */
27
27
  export function useCtrlNoise(config: CtrlNoiseConfig = {}) {
28
- const { mjModelRef } = useMujoco();
28
+ const { mjModelRef } = useMujocoContext();
29
29
  const configRef = useRef(config);
30
30
  configRef.current = config;
31
31
  const noiseRef = useRef<Float64Array | null>(null);
@@ -6,7 +6,7 @@
6
6
  */
7
7
 
8
8
  import { useEffect, useRef } from 'react';
9
- import { useMujoco, useBeforePhysicsStep } from '../core/MujocoSimProvider';
9
+ import { useMujocoContext, useBeforePhysicsStep } from '../core/MujocoSimProvider';
10
10
  import { findActuatorByName } from '../core/SceneLoader';
11
11
 
12
12
  interface GamepadConfig {
@@ -29,7 +29,7 @@ interface GamepadConfig {
29
29
  * Buttons map their 0..1 pressed value to the actuator.
30
30
  */
31
31
  export function useGamepad(config: GamepadConfig) {
32
- const { mjModelRef, status } = useMujoco();
32
+ const { mjModelRef, status } = useMujocoContext();
33
33
  const configRef = useRef(config);
34
34
  configRef.current = config;
35
35