mujoco-react 0.2.0 → 1.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.
@@ -11,20 +11,27 @@
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
+ import type { ThreeElements } from '@react-three/fiber';
16
17
  import * as THREE from 'three';
17
18
  import { useMujocoSim } from '../core/MujocoSimProvider';
18
19
 
19
20
  const DEFAULT_TENDON_COLOR = new THREE.Color(0.3, 0.3, 0.8);
20
21
  const DEFAULT_TENDON_WIDTH = 0.002;
21
22
 
22
- export function TendonRenderer() {
23
+ // Preallocated temp vector to avoid per-frame allocations
24
+ const _tmpVec = new THREE.Vector3();
25
+
26
+ export function TendonRenderer(props: Omit<ThreeElements['group'], 'ref'>) {
23
27
  const { mjModelRef, mjDataRef, status } = useMujocoSim();
24
28
  const groupRef = useRef<THREE.Group>(null);
25
29
  const meshesRef = useRef<THREE.Mesh[]>([]);
30
+ const curvesRef = useRef<THREE.CatmullRomCurve3[]>([]);
31
+ const materialRef = useRef<THREE.MeshStandardMaterial | null>(null);
26
32
 
27
- useFrame(() => {
33
+ // Build tendon meshes once when model loads
34
+ useEffect(() => {
28
35
  const model = mjModelRef.current;
29
36
  const data = mjDataRef.current;
30
37
  const group = groupRef.current;
@@ -33,52 +40,110 @@ export function TendonRenderer() {
33
40
  const ntendon = model.ntendon ?? 0;
34
41
  if (ntendon === 0) return;
35
42
 
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();
43
+ // Shared material for all tendons
44
+ const material = new THREE.MeshStandardMaterial({
45
+ color: DEFAULT_TENDON_COLOR,
46
+ roughness: 0.6,
47
+ metalness: 0.1,
48
+ });
49
+ materialRef.current = material;
50
+
51
+ const meshes: THREE.Mesh[] = [];
52
+ const curves: THREE.CatmullRomCurve3[] = [];
53
+
54
+ for (let t = 0; t < ntendon; t++) {
55
+ const wrapNum = model.ten_wrapnum[t];
56
+ if (wrapNum < 2) {
57
+ meshes.push(null!);
58
+ curves.push(null!);
59
+ continue;
60
+ }
61
+
62
+ // Initial dummy points — will be overwritten in useFrame
63
+ const points = Array.from({ length: wrapNum }, () => new THREE.Vector3());
64
+ const curve = new THREE.CatmullRomCurve3(points, false);
65
+ const segments = Math.max(wrapNum * 2, 4);
66
+ const geometry = new THREE.TubeGeometry(curve, segments, DEFAULT_TENDON_WIDTH, 6, false);
67
+ const mesh = new THREE.Mesh(geometry, material);
68
+ mesh.frustumCulled = false;
69
+ group.add(mesh);
70
+ meshes.push(mesh);
71
+ curves.push(curve);
41
72
  }
42
- meshesRef.current = [];
73
+
74
+ meshesRef.current = meshes;
75
+ curvesRef.current = curves;
76
+
77
+ return () => {
78
+ for (const mesh of meshes) {
79
+ if (!mesh) continue;
80
+ group.remove(mesh);
81
+ mesh.geometry.dispose();
82
+ }
83
+ material.dispose();
84
+ meshesRef.current = [];
85
+ curvesRef.current = [];
86
+ materialRef.current = null;
87
+ };
88
+ }, [status, mjModelRef, mjDataRef]);
89
+
90
+ // Update curve control points and rebuild geometry each frame
91
+ useFrame(() => {
92
+ const model = mjModelRef.current;
93
+ const data = mjDataRef.current;
94
+ if (!model || !data) return;
95
+
96
+ const ntendon = model.ntendon ?? 0;
97
+ const meshes = meshesRef.current;
98
+ const curves = curvesRef.current;
43
99
 
44
100
  for (let t = 0; t < ntendon; t++) {
101
+ const mesh = meshes[t];
102
+ const curve = curves[t];
103
+ if (!mesh || !curve) continue;
104
+
45
105
  const wrapAdr = model.ten_wrapadr[t];
46
106
  const wrapNum = model.ten_wrapnum[t];
47
- if (wrapNum < 2) continue;
48
107
 
49
- // Get wrap path points from data
50
- const points: THREE.Vector3[] = [];
108
+ // Update existing control points in-place
109
+ let validCount = 0;
51
110
  for (let w = 0; w < wrapNum; w++) {
52
111
  const idx = (wrapAdr + w) * 3;
53
112
  if (data.wrap_xpos && idx + 2 < data.wrap_xpos.length) {
54
113
  const x = data.wrap_xpos[idx];
55
114
  const y = data.wrap_xpos[idx + 1];
56
115
  const z = data.wrap_xpos[idx + 2];
57
- // Skip zero points (uninitialized wrap points)
58
116
  if (x !== 0 || y !== 0 || z !== 0) {
59
- points.push(new THREE.Vector3(x, y, z));
117
+ if (validCount < curve.points.length) {
118
+ curve.points[validCount].set(x, y, z);
119
+ }
120
+ validCount++;
60
121
  }
61
122
  }
62
123
  }
63
124
 
64
- if (points.length < 2) continue;
125
+ if (validCount < 2) {
126
+ mesh.visible = false;
127
+ continue;
128
+ }
65
129
 
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
- );
130
+ // Trim or pad points array to match valid count
131
+ if (curve.points.length !== validCount) {
132
+ curve.points.length = validCount;
133
+ while (curve.points.length < validCount) {
134
+ curve.points.push(new THREE.Vector3());
135
+ }
136
+ }
70
137
 
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);
138
+ // Rebuild geometry from updated curve
139
+ mesh.geometry.dispose();
140
+ mesh.geometry = new THREE.TubeGeometry(
141
+ curve, Math.max(validCount * 2, 4), DEFAULT_TENDON_WIDTH, 6, false
142
+ );
143
+ mesh.visible = true;
79
144
  }
80
145
  });
81
146
 
82
147
  if (status !== 'ready') return null;
83
- return <group ref={groupRef} />;
148
+ return <group {...props} ref={groupRef} />;
84
149
  }
@@ -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
+ }
@@ -24,15 +24,12 @@ export const MujocoCanvas = forwardRef<MujocoSimAPI, MujocoCanvasProps>(
24
24
  onError,
25
25
  onStep,
26
26
  onSelection,
27
- // Declarative physics config (spec 1.1)
27
+ // Declarative physics config
28
28
  gravity,
29
29
  timestep,
30
30
  substeps,
31
31
  paused,
32
32
  speed,
33
- interpolate,
34
- gravityCompensation,
35
- mjcfLights,
36
33
  children,
37
34
  ...canvasProps
38
35
  },
@@ -65,7 +62,6 @@ export const MujocoCanvas = forwardRef<MujocoSimAPI, MujocoCanvasProps>(
65
62
  substeps={substeps}
66
63
  paused={paused}
67
64
  speed={speed}
68
- interpolate={interpolate}
69
65
  >
70
66
  {children}
71
67
  </MujocoSimProvider>
@@ -0,0 +1,79 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ import { forwardRef, useEffect } from 'react';
7
+ import { useMujoco } from './MujocoProvider';
8
+ import { MujocoSimProvider } from './MujocoSimProvider';
9
+ import type { MujocoSimAPI, SceneConfig } from '../types';
10
+
11
+ export interface MujocoPhysicsProps {
12
+ /** Scene/robot configuration. */
13
+ config: SceneConfig;
14
+ /** Fires when model is loaded and API is ready. */
15
+ onReady?: (api: MujocoSimAPI) => void;
16
+ /** Fires on scene load failure. */
17
+ onError?: (error: Error) => void;
18
+ /** Called each physics step. */
19
+ onStep?: (time: number) => void;
20
+ /** Called on body double-click selection. */
21
+ onSelection?: (bodyId: number, name: string) => void;
22
+ /** Override model gravity. */
23
+ gravity?: [number, number, number];
24
+ /** Override model.opt.timestep. */
25
+ timestep?: number;
26
+ /** mj_step calls per frame. */
27
+ substeps?: number;
28
+ /** Declarative pause. */
29
+ paused?: boolean;
30
+ /** Simulation speed multiplier. */
31
+ speed?: number;
32
+ children: React.ReactNode;
33
+ }
34
+
35
+ /**
36
+ * MujocoPhysics — physics provider for use inside a user-owned R3F Canvas.
37
+ *
38
+ * This is the R3F-idiomatic alternative to MujocoCanvas. Instead of wrapping
39
+ * the Canvas, place this inside your own <Canvas>:
40
+ *
41
+ * ```tsx
42
+ * <MujocoProvider>
43
+ * <Canvas shadows camera={...}>
44
+ * <MujocoPhysics config={config} paused={paused}>
45
+ * <SceneRenderer />
46
+ * <OrbitControls />
47
+ * </MujocoPhysics>
48
+ * </Canvas>
49
+ * </MujocoProvider>
50
+ * ```
51
+ *
52
+ * Forward ref exposes MujocoSimAPI.
53
+ */
54
+ export const MujocoPhysics = forwardRef<MujocoSimAPI, MujocoPhysicsProps>(
55
+ function MujocoPhysics({ onError, children, ...props }, ref) {
56
+ const { mujoco, status: wasmStatus, error: wasmError } = useMujoco();
57
+
58
+ useEffect(() => {
59
+ if (wasmStatus === 'error' && onError) {
60
+ onError(new Error(wasmError ?? 'WASM load failed'));
61
+ }
62
+ }, [wasmStatus, wasmError, onError]);
63
+
64
+ if (wasmStatus === 'error' || wasmStatus === 'loading' || !mujoco) {
65
+ return null;
66
+ }
67
+
68
+ return (
69
+ <MujocoSimProvider
70
+ mujoco={mujoco}
71
+ apiRef={ref}
72
+ onError={onError}
73
+ {...props}
74
+ >
75
+ {children}
76
+ </MujocoSimProvider>
77
+ );
78
+ }
79
+ );
@@ -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