mujoco-react 0.2.0 → 0.3.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.
@@ -8,6 +8,7 @@ import { useFrame, useThree } from '@react-three/fiber';
8
8
  import { useEffect, useRef } from 'react';
9
9
  import * as THREE from 'three';
10
10
  import { useMujocoSim } from '../core/MujocoSimProvider';
11
+ import { useIk } from '../core/IkContext';
11
12
  import { findSiteByName } from '../core/SceneLoader';
12
13
  import type { IkGizmoProps } from '../types';
13
14
 
@@ -20,25 +21,18 @@ const _scale = new THREE.Vector3(1, 1, 1);
20
21
  /**
21
22
  * IkGizmo — drei PivotControls that tracks a MuJoCo site.
22
23
  *
24
+ * Must be rendered inside an `<IkController>`.
25
+ *
23
26
  * Props:
24
- * - `siteName` — MuJoCo site to track. Defaults to `SceneConfig.tcpSiteName`.
27
+ * - `siteName` — MuJoCo site to track. Defaults to the IkController's configured site.
25
28
  * - `scale` — Gizmo handle scale. Default: 0.18.
26
29
  * - `onDrag` — Custom drag callback `(pos, quat) => void`.
27
- * When omitted, dragging enables IK and writes to the provider's IK target.
30
+ * When omitted, dragging enables IK and writes to the IK target.
28
31
  * When provided, the consumer handles what happens during drag.
29
- *
30
- * Multiple gizmos can be rendered — each tracks its own site.
31
- * Zero gizmos is fine — programmatic IK control works via the provider API.
32
- *
33
- * Uses a tiny invisible mesh as child instead of axesHelper — PivotControls
34
- * computes an anchor offset from children's bounding box, and axesHelper's
35
- * (0→0.15) bounds would shift the handles away from the TCP origin.
36
32
  */
37
33
  export function IkGizmo({ siteName, scale = 0.18, onDrag }: IkGizmoProps) {
38
- const {
39
- ikTargetRef, mjModelRef, mjDataRef, siteIdRef,
40
- api, ikEnabledRef, status,
41
- } = useMujocoSim();
34
+ const { mjModelRef, mjDataRef, status } = useMujocoSim();
35
+ const { ikTargetRef, siteIdRef, ikEnabledRef, setIkEnabled } = useIk();
42
36
 
43
37
  const wrapperRef = useRef<THREE.Group>(null);
44
38
  const pivotRef = useRef<THREE.Group>(null);
@@ -46,25 +40,23 @@ export function IkGizmo({ siteName, scale = 0.18, onDrag }: IkGizmoProps) {
46
40
  const localSiteIdRef = useRef(-1);
47
41
  const { controls } = useThree();
48
42
 
49
- // Resolve the site ID from siteName (or fall back to provider's tcpSiteName)
43
+ // Resolve the site ID from siteName (only when an explicit siteName override is given)
50
44
  useEffect(() => {
51
45
  const model = mjModelRef.current;
52
- if (!model || status !== 'ready') {
46
+ if (!model || status !== 'ready' || !siteName) {
53
47
  localSiteIdRef.current = -1;
54
48
  return;
55
49
  }
56
- if (siteName) {
57
- localSiteIdRef.current = findSiteByName(model, siteName);
58
- } else {
59
- // Default: use the provider's siteIdRef (from SceneConfig.tcpSiteName)
60
- localSiteIdRef.current = siteIdRef.current;
61
- }
62
- }, [siteName, status, mjModelRef, siteIdRef]);
50
+ localSiteIdRef.current = findSiteByName(model, siteName);
51
+ }, [siteName, status, mjModelRef]);
63
52
 
64
53
  // Every frame: sync the visual wrapper to the tracked site (when not dragging)
65
54
  useFrame(() => {
66
55
  const data = mjDataRef.current;
67
- const sid = localSiteIdRef.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
+ const sid = siteName ? localSiteIdRef.current : siteIdRef.current;
68
60
  if (!data || sid < 0 || !wrapperRef.current) return;
69
61
 
70
62
  if (!draggingRef.current) {
@@ -107,7 +99,7 @@ export function IkGizmo({ siteName, scale = 0.18, onDrag }: IkGizmoProps) {
107
99
  draggingRef.current = true;
108
100
  if (!onDrag) {
109
101
  // Default: enable IK so the robot follows
110
- if (!ikEnabledRef.current) api.setIkEnabled(true);
102
+ if (!ikEnabledRef.current) setIkEnabled(true);
111
103
  }
112
104
  if (controls) (controls as unknown as { enabled: boolean }).enabled = false;
113
105
  }}
@@ -126,7 +118,7 @@ export function IkGizmo({ siteName, scale = 0.18, onDrag }: IkGizmoProps) {
126
118
  // Custom: consumer handles the drag
127
119
  onDrag(_pos.clone(), _quat.clone());
128
120
  } else {
129
- // Default: write to provider's IK target
121
+ // Default: write to IK target
130
122
  const target = ikTargetRef.current;
131
123
  if (target) {
132
124
  target.position.copy(_pos);
@@ -12,120 +12,10 @@
12
12
  * Note: light_directional does NOT exist in WASM — use light_type instead.
13
13
  */
14
14
 
15
- import { useEffect, useRef } from 'react';
16
- import * as THREE from 'three';
17
- import { useThree } from '@react-three/fiber';
18
- import { useMujocoSim } from '../core/MujocoSimProvider';
15
+ import { useSceneLights } from '../hooks/useSceneLights';
19
16
  import type { SceneLightsProps } from '../types';
20
17
 
21
18
  export function SceneLights({ intensity = 1.0 }: SceneLightsProps) {
22
- const { mjModelRef, status } = useMujocoSim();
23
- const { scene } = useThree();
24
- const lightsRef = useRef<THREE.Light[]>([]);
25
- const targetsRef = useRef<THREE.Object3D[]>([]);
26
-
27
- useEffect(() => {
28
- const model = mjModelRef.current;
29
- if (!model || status !== 'ready') return;
30
-
31
- // Clean up previous lights
32
- for (const light of lightsRef.current) {
33
- scene.remove(light);
34
- light.dispose();
35
- }
36
- for (const t of targetsRef.current) scene.remove(t);
37
- lightsRef.current = [];
38
- targetsRef.current = [];
39
-
40
- const nlight = model.nlight ?? 0;
41
- if (nlight === 0) return;
42
-
43
- for (let i = 0; i < nlight; i++) {
44
- // Check if light is active
45
- const active = model.light_active ? model.light_active[i] : 1;
46
- if (!active) continue;
47
-
48
- // light_type: 0 = directional, 1 = spot (no light_directional in WASM)
49
- const lightType = model.light_type ? model.light_type[i] : 0;
50
- const isDirectional = lightType === 0;
51
- const castShadow = model.light_castshadow ? model.light_castshadow[i] !== 0 : false;
52
-
53
- // Read intensity from model if available, otherwise use prop
54
- const mjIntensity = model.light_intensity ? model.light_intensity[i] : 1.0;
55
- const finalIntensity = intensity * mjIntensity;
56
-
57
- // Read diffuse color
58
- const dr = model.light_diffuse ? model.light_diffuse[3 * i] : 1;
59
- const dg = model.light_diffuse ? model.light_diffuse[3 * i + 1] : 1;
60
- const db = model.light_diffuse ? model.light_diffuse[3 * i + 2] : 1;
61
- const color = new THREE.Color(dr, dg, db);
62
-
63
- // Read position and direction
64
- const px = model.light_pos[3 * i];
65
- const py = model.light_pos[3 * i + 1];
66
- const pz = model.light_pos[3 * i + 2];
67
- const dx = model.light_dir[3 * i];
68
- const dy = model.light_dir[3 * i + 1];
69
- const dz = model.light_dir[3 * i + 2];
70
-
71
- if (isDirectional) {
72
- const light = new THREE.DirectionalLight(color, finalIntensity);
73
- light.position.set(px, py, pz);
74
- light.target.position.set(px + dx, py + dy, pz + dz);
75
- light.castShadow = castShadow;
76
- if (castShadow) {
77
- light.shadow.mapSize.width = 1024;
78
- light.shadow.mapSize.height = 1024;
79
- light.shadow.camera.near = 0.1;
80
- light.shadow.camera.far = 50;
81
- const d = 5;
82
- light.shadow.camera.left = -d;
83
- light.shadow.camera.right = d;
84
- light.shadow.camera.top = d;
85
- light.shadow.camera.bottom = -d;
86
- }
87
- scene.add(light);
88
- scene.add(light.target);
89
- lightsRef.current.push(light);
90
- targetsRef.current.push(light.target);
91
- } else {
92
- // Spot light
93
- const cutoff = model.light_cutoff ? model.light_cutoff[i] : 45;
94
- const exponent = model.light_exponent ? model.light_exponent[i] : 10;
95
- const angle = (cutoff * Math.PI) / 180;
96
- const light = new THREE.SpotLight(color, finalIntensity, 0, angle, exponent / 128);
97
- light.position.set(px, py, pz);
98
- light.target.position.set(px + dx, py + dy, pz + dz);
99
- light.castShadow = castShadow;
100
-
101
- if (model.light_attenuation) {
102
- const att1 = model.light_attenuation[3 * i + 1]; // linear
103
- const att2 = model.light_attenuation[3 * i + 2]; // quadratic
104
- light.decay = att2 > 0 ? 2 : (att1 > 0 ? 1 : 0);
105
- light.distance = att1 > 0 ? 1 / att1 : 0;
106
- }
107
-
108
- if (castShadow) {
109
- light.shadow.mapSize.width = 512;
110
- light.shadow.mapSize.height = 512;
111
- }
112
- scene.add(light);
113
- scene.add(light.target);
114
- lightsRef.current.push(light);
115
- targetsRef.current.push(light.target);
116
- }
117
- }
118
-
119
- return () => {
120
- for (const light of lightsRef.current) {
121
- scene.remove(light);
122
- light.dispose();
123
- }
124
- for (const t of targetsRef.current) scene.remove(t);
125
- lightsRef.current = [];
126
- targetsRef.current = [];
127
- };
128
- }, [status, mjModelRef, scene, intensity]);
129
-
19
+ useSceneLights(intensity);
130
20
  return null;
131
21
  }
@@ -22,12 +22,13 @@ export function SceneRenderer() {
22
22
  const prevModelRef = useRef<MujocoModel | null>(null);
23
23
 
24
24
  const geomBuilder = useMemo(() => {
25
+ if (status !== 'ready') return null;
25
26
  return new GeomBuilder(mujocoRef.current);
26
- }, [mujocoRef.current]);
27
+ }, [status, mujocoRef]);
27
28
 
28
29
  // Build body groups when model loads
29
30
  useEffect(() => {
30
- if (status !== 'ready') return;
31
+ if (status !== 'ready' || !geomBuilder) return;
31
32
  const model = mjModelRef.current;
32
33
  const group = groupRef.current;
33
34
  if (!model || !group) return;
@@ -91,11 +92,12 @@ export function SceneRenderer() {
91
92
  while (obj && obj.userData.bodyID === undefined && obj.parent) {
92
93
  obj = obj.parent;
93
94
  }
94
- if (obj && obj.userData.bodyID !== undefined && obj.userData.bodyID > 0) {
95
+ const bodyID = obj?.userData.bodyID;
96
+ if (typeof bodyID === 'number' && bodyID > 0) {
95
97
  const model = mjModelRef.current;
96
- if (model && onSelectionRef.current) {
97
- const name = getName(model, model.name_bodyadr[obj.userData.bodyID]);
98
- onSelectionRef.current(obj.userData.bodyID, name);
98
+ if (model && bodyID < model.nbody && onSelectionRef.current) {
99
+ const name = getName(model, model.name_bodyadr[bodyID]);
100
+ onSelectionRef.current(bodyID, name);
99
101
  }
100
102
  }
101
103
  }}
@@ -5,9 +5,7 @@
5
5
  * SelectionHighlight — highlight a selected body with emissive color (spec 6.5)
6
6
  */
7
7
 
8
- import { useEffect, useRef } from 'react';
9
- import { useThree } from '@react-three/fiber';
10
- import * as THREE from 'three';
8
+ import { useSelectionHighlight } from '../hooks/useSelectionHighlight';
11
9
  import type { SelectionHighlightProps } from '../types';
12
10
 
13
11
  /**
@@ -19,51 +17,6 @@ export function SelectionHighlight({
19
17
  color = '#ff4444',
20
18
  emissiveIntensity = 0.3,
21
19
  }: SelectionHighlightProps) {
22
- const { scene } = useThree();
23
- const prevMeshesRef = useRef<{ mesh: THREE.Mesh; originalEmissive: THREE.Color; originalIntensity: number }[]>([]);
24
-
25
- useEffect(() => {
26
- // Restore previous highlights
27
- for (const entry of prevMeshesRef.current) {
28
- const mat = entry.mesh.material as THREE.MeshStandardMaterial;
29
- if (mat.emissive) {
30
- mat.emissive.copy(entry.originalEmissive);
31
- mat.emissiveIntensity = entry.originalIntensity;
32
- }
33
- }
34
- prevMeshesRef.current = [];
35
-
36
- if (bodyId === null || bodyId < 0) return;
37
-
38
- // Find all meshes belonging to this body
39
- const highlightColor = new THREE.Color(color);
40
- scene.traverse((obj) => {
41
- if (obj.userData.bodyID === bodyId && (obj as THREE.Mesh).isMesh) {
42
- const mesh = obj as THREE.Mesh;
43
- const mat = mesh.material as THREE.MeshStandardMaterial;
44
- if (mat.emissive) {
45
- prevMeshesRef.current.push({
46
- mesh,
47
- originalEmissive: mat.emissive.clone(),
48
- originalIntensity: mat.emissiveIntensity ?? 0,
49
- });
50
- mat.emissive.copy(highlightColor);
51
- mat.emissiveIntensity = emissiveIntensity;
52
- }
53
- }
54
- });
55
-
56
- return () => {
57
- for (const entry of prevMeshesRef.current) {
58
- const mat = entry.mesh.material as THREE.MeshStandardMaterial;
59
- if (mat.emissive) {
60
- mat.emissive.copy(entry.originalEmissive);
61
- mat.emissiveIntensity = entry.originalIntensity;
62
- }
63
- }
64
- prevMeshesRef.current = [];
65
- };
66
- }, [bodyId, color, emissiveIntensity, scene]);
67
-
20
+ useSelectionHighlight(bodyId, { color, emissiveIntensity });
68
21
  return null;
69
22
  }
@@ -11,7 +11,7 @@
11
11
  * Tendons use a default color and width.
12
12
  */
13
13
 
14
- import { useRef } from 'react';
14
+ import { useEffect, useRef } from 'react';
15
15
  import { useFrame } from '@react-three/fiber';
16
16
  import * as THREE from 'three';
17
17
  import { useMujocoSim } from '../core/MujocoSimProvider';
@@ -19,12 +19,18 @@ import { useMujocoSim } from '../core/MujocoSimProvider';
19
19
  const DEFAULT_TENDON_COLOR = new THREE.Color(0.3, 0.3, 0.8);
20
20
  const DEFAULT_TENDON_WIDTH = 0.002;
21
21
 
22
+ // Preallocated temp vector to avoid per-frame allocations
23
+ const _tmpVec = new THREE.Vector3();
24
+
22
25
  export function TendonRenderer() {
23
26
  const { mjModelRef, mjDataRef, status } = useMujocoSim();
24
27
  const groupRef = useRef<THREE.Group>(null);
25
28
  const meshesRef = useRef<THREE.Mesh[]>([]);
29
+ const curvesRef = useRef<THREE.CatmullRomCurve3[]>([]);
30
+ const materialRef = useRef<THREE.MeshStandardMaterial | null>(null);
26
31
 
27
- useFrame(() => {
32
+ // Build tendon meshes once when model loads
33
+ useEffect(() => {
28
34
  const model = mjModelRef.current;
29
35
  const data = mjDataRef.current;
30
36
  const group = groupRef.current;
@@ -33,49 +39,107 @@ export function TendonRenderer() {
33
39
  const ntendon = model.ntendon ?? 0;
34
40
  if (ntendon === 0) return;
35
41
 
36
- // Clean up old meshes
37
- for (const mesh of meshesRef.current) {
38
- group.remove(mesh);
39
- mesh.geometry.dispose();
40
- (mesh.material as THREE.Material).dispose();
42
+ // Shared material for all tendons
43
+ const material = new THREE.MeshStandardMaterial({
44
+ color: DEFAULT_TENDON_COLOR,
45
+ roughness: 0.6,
46
+ metalness: 0.1,
47
+ });
48
+ materialRef.current = material;
49
+
50
+ const meshes: THREE.Mesh[] = [];
51
+ const curves: THREE.CatmullRomCurve3[] = [];
52
+
53
+ for (let t = 0; t < ntendon; t++) {
54
+ const wrapNum = model.ten_wrapnum[t];
55
+ if (wrapNum < 2) {
56
+ meshes.push(null!);
57
+ curves.push(null!);
58
+ continue;
59
+ }
60
+
61
+ // Initial dummy points — will be overwritten in useFrame
62
+ const points = Array.from({ length: wrapNum }, () => new THREE.Vector3());
63
+ const curve = new THREE.CatmullRomCurve3(points, false);
64
+ const segments = Math.max(wrapNum * 2, 4);
65
+ const geometry = new THREE.TubeGeometry(curve, segments, DEFAULT_TENDON_WIDTH, 6, false);
66
+ const mesh = new THREE.Mesh(geometry, material);
67
+ mesh.frustumCulled = false;
68
+ group.add(mesh);
69
+ meshes.push(mesh);
70
+ curves.push(curve);
41
71
  }
42
- meshesRef.current = [];
72
+
73
+ meshesRef.current = meshes;
74
+ curvesRef.current = curves;
75
+
76
+ return () => {
77
+ for (const mesh of meshes) {
78
+ if (!mesh) continue;
79
+ group.remove(mesh);
80
+ mesh.geometry.dispose();
81
+ }
82
+ material.dispose();
83
+ meshesRef.current = [];
84
+ curvesRef.current = [];
85
+ materialRef.current = null;
86
+ };
87
+ }, [status, mjModelRef, mjDataRef]);
88
+
89
+ // Update curve control points and rebuild geometry each frame
90
+ useFrame(() => {
91
+ const model = mjModelRef.current;
92
+ const data = mjDataRef.current;
93
+ if (!model || !data) return;
94
+
95
+ const ntendon = model.ntendon ?? 0;
96
+ const meshes = meshesRef.current;
97
+ const curves = curvesRef.current;
43
98
 
44
99
  for (let t = 0; t < ntendon; t++) {
100
+ const mesh = meshes[t];
101
+ const curve = curves[t];
102
+ if (!mesh || !curve) continue;
103
+
45
104
  const wrapAdr = model.ten_wrapadr[t];
46
105
  const wrapNum = model.ten_wrapnum[t];
47
- if (wrapNum < 2) continue;
48
106
 
49
- // Get wrap path points from data
50
- const points: THREE.Vector3[] = [];
107
+ // Update existing control points in-place
108
+ let validCount = 0;
51
109
  for (let w = 0; w < wrapNum; w++) {
52
110
  const idx = (wrapAdr + w) * 3;
53
111
  if (data.wrap_xpos && idx + 2 < data.wrap_xpos.length) {
54
112
  const x = data.wrap_xpos[idx];
55
113
  const y = data.wrap_xpos[idx + 1];
56
114
  const z = data.wrap_xpos[idx + 2];
57
- // Skip zero points (uninitialized wrap points)
58
115
  if (x !== 0 || y !== 0 || z !== 0) {
59
- points.push(new THREE.Vector3(x, y, z));
116
+ if (validCount < curve.points.length) {
117
+ curve.points[validCount].set(x, y, z);
118
+ }
119
+ validCount++;
60
120
  }
61
121
  }
62
122
  }
63
123
 
64
- if (points.length < 2) continue;
124
+ if (validCount < 2) {
125
+ mesh.visible = false;
126
+ continue;
127
+ }
65
128
 
66
- const curve = new THREE.CatmullRomCurve3(points, false);
67
- const geometry = new THREE.TubeGeometry(
68
- curve, Math.max(points.length * 2, 4), DEFAULT_TENDON_WIDTH, 6, false
69
- );
129
+ // Trim or pad points array to match valid count
130
+ if (curve.points.length !== validCount) {
131
+ curve.points.length = validCount;
132
+ while (curve.points.length < validCount) {
133
+ curve.points.push(new THREE.Vector3());
134
+ }
135
+ }
70
136
 
71
- const material = new THREE.MeshStandardMaterial({
72
- color: DEFAULT_TENDON_COLOR,
73
- roughness: 0.6,
74
- metalness: 0.1,
75
- });
76
- const mesh = new THREE.Mesh(geometry, material);
77
- group.add(mesh);
78
- meshesRef.current.push(mesh);
137
+ // Rebuild geometry from updated curve
138
+ mesh.geometry.dispose();
139
+ mesh.geometry = new THREE.TubeGeometry(
140
+ curve, Math.max(validCount * 2, 4), DEFAULT_TENDON_WIDTH, 6, false
141
+ );
142
+ mesh.visible = true;
79
143
  }
80
144
  });
81
145
 
@@ -5,7 +5,8 @@
5
5
  * TrajectoryPlayer — component form of trajectory playback (spec 13.2)
6
6
  */
7
7
 
8
- import { useEffect } from 'react';
8
+ import { useEffect, useRef } from 'react';
9
+ import { useFrame } from '@react-three/fiber';
9
10
  import { useTrajectoryPlayer } from '../hooks/useTrajectoryPlayer';
10
11
  import type { TrajectoryPlayerProps } from '../types';
11
12
 
@@ -21,6 +22,9 @@ export function TrajectoryPlayer({
21
22
  onFrame,
22
23
  }: TrajectoryPlayerProps) {
23
24
  const player = useTrajectoryPlayer(trajectory, { fps, loop });
25
+ const onFrameRef = useRef(onFrame);
26
+ onFrameRef.current = onFrame;
27
+ const lastReportedFrameRef = useRef(-1);
24
28
 
25
29
  useEffect(() => {
26
30
  if (playing) {
@@ -28,17 +32,17 @@ export function TrajectoryPlayer({
28
32
  } else {
29
33
  player.pause();
30
34
  }
31
- }, [playing]);
35
+ }, [playing, player]);
32
36
 
33
- useEffect(() => {
34
- if (onFrame) {
35
- // Poll frame changes (lightweight, no extra useFrame needed)
36
- const interval = setInterval(() => {
37
- if (player.playing) onFrame(player.frame);
38
- }, 1000 / fps);
39
- return () => clearInterval(interval);
37
+ // Use useFrame instead of setInterval to sync with the render loop
38
+ useFrame(() => {
39
+ if (!onFrameRef.current) return;
40
+ const currentFrame = player.frame;
41
+ if (currentFrame !== lastReportedFrameRef.current && player.playing) {
42
+ lastReportedFrameRef.current = currentFrame;
43
+ onFrameRef.current(currentFrame);
40
44
  }
41
- }, [onFrame, fps]);
45
+ });
42
46
 
43
47
  return null;
44
48
  }
@@ -0,0 +1,40 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ *
5
+ * IkContext — React context for the IK controller plugin.
6
+ */
7
+
8
+ import { createContext, useContext } from 'react';
9
+ import * as THREE from 'three';
10
+
11
+ export interface IkContextValue {
12
+ ikEnabledRef: React.RefObject<boolean>;
13
+ ikCalculatingRef: React.RefObject<boolean>;
14
+ ikTargetRef: React.RefObject<THREE.Group>;
15
+ siteIdRef: React.RefObject<number>;
16
+ setIkEnabled(enabled: boolean): void;
17
+ moveTarget(pos: THREE.Vector3, duration?: number): void;
18
+ syncTargetToSite(): void;
19
+ solveIK(pos: THREE.Vector3, quat: THREE.Quaternion, currentQ: number[]): number[] | null;
20
+ getGizmoStats(): { pos: THREE.Vector3; rot: THREE.Euler } | null;
21
+ }
22
+
23
+ export const IkContext = createContext<IkContextValue | null>(null);
24
+
25
+ /**
26
+ * Access the IK controller context.
27
+ *
28
+ * - `useIk()` — throws if no `<IkController>` ancestor (use inside `<IkController>`)
29
+ * - `useIk({ optional: true })` — returns `null` if no ancestor (use in components
30
+ * that optionally interact with IK, e.g. keyboard controllers that disable IK)
31
+ */
32
+ export function useIk(): IkContextValue;
33
+ export function useIk(options: { optional: true }): IkContextValue | null;
34
+ export function useIk(options?: { optional?: boolean }): IkContextValue | null {
35
+ const ctx = useContext(IkContext);
36
+ if (!ctx && !options?.optional) {
37
+ throw new Error('useIk() must be used inside an <IkController>');
38
+ }
39
+ return ctx;
40
+ }
@@ -22,6 +22,8 @@ export function useMujoco(): MujocoContextValue {
22
22
 
23
23
  interface MujocoProviderProps {
24
24
  wasmUrl?: string;
25
+ /** Timeout in ms for WASM module load. Default: 30000. */
26
+ timeout?: number;
25
27
  children: React.ReactNode;
26
28
  onError?: (error: Error) => void;
27
29
  }
@@ -30,7 +32,7 @@ interface MujocoProviderProps {
30
32
  * MujocoProvider — WASM / module lifecycle.
31
33
  * Loads the MuJoCo WASM module on mount and provides it to children via context.
32
34
  */
33
- export function MujocoProvider({ wasmUrl, children, onError }: MujocoProviderProps) {
35
+ export function MujocoProvider({ wasmUrl, timeout = 30000, children, onError }: MujocoProviderProps) {
34
36
  const [status, setStatus] = useState<'loading' | 'ready' | 'error'>('loading');
35
37
  const [error, setError] = useState<string | null>(null);
36
38
  const moduleRef = useRef<MujocoModule | null>(null);
@@ -39,7 +41,7 @@ export function MujocoProvider({ wasmUrl, children, onError }: MujocoProviderPro
39
41
  useEffect(() => {
40
42
  isMounted.current = true;
41
43
 
42
- loadMujoco({
44
+ const wasmPromise = loadMujoco({
43
45
  ...(wasmUrl ? { locateFile: (path: string) => path.endsWith('.wasm') ? wasmUrl : path } : {}),
44
46
  printErr: (text: string) => {
45
47
  if (text.includes('Aborted') && isMounted.current) {
@@ -47,7 +49,13 @@ export function MujocoProvider({ wasmUrl, children, onError }: MujocoProviderPro
47
49
  setStatus('error');
48
50
  }
49
51
  },
50
- })
52
+ });
53
+
54
+ const timeoutPromise = new Promise<never>((_, reject) =>
55
+ setTimeout(() => reject(new Error(`WASM module load timed out after ${timeout}ms`)), timeout)
56
+ );
57
+
58
+ Promise.race([wasmPromise, timeoutPromise])
51
59
  .then((inst: unknown) => {
52
60
  if (isMounted.current) {
53
61
  moduleRef.current = inst as MujocoModule;
@@ -66,7 +74,7 @@ export function MujocoProvider({ wasmUrl, children, onError }: MujocoProviderPro
66
74
  return () => {
67
75
  isMounted.current = false;
68
76
  };
69
- }, [wasmUrl]);
77
+ }, [wasmUrl, timeout]);
70
78
 
71
79
  return (
72
80
  <MujocoContext.Provider