mujoco-react 7.0.1 → 8.1.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": "7.0.1",
3
+ "version": "8.1.0",
4
4
  "description": "Composable React Three Fiber building blocks for MuJoCo WASM simulations",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -17,15 +17,30 @@ import type { TrajectoryPlayerProps } from '../types';
17
17
  export function TrajectoryPlayer({
18
18
  trajectory,
19
19
  fps = 30,
20
+ speed = 1.0,
20
21
  loop = false,
21
22
  playing = false,
23
+ mode = 'kinematic',
22
24
  onFrame,
25
+ onComplete,
26
+ onStateChange,
23
27
  }: TrajectoryPlayerProps) {
24
- const player = useTrajectoryPlayer(trajectory, { fps, loop });
28
+ const player = useTrajectoryPlayer(trajectory, {
29
+ fps,
30
+ speed,
31
+ loop,
32
+ mode,
33
+ onComplete,
34
+ onStateChange,
35
+ });
25
36
  const onFrameRef = useRef(onFrame);
26
37
  onFrameRef.current = onFrame;
27
38
  const lastReportedFrameRef = useRef(-1);
28
39
 
40
+ useEffect(() => {
41
+ player.setSpeed(speed);
42
+ }, [speed, player]);
43
+
29
44
  useEffect(() => {
30
45
  if (playing) {
31
46
  player.play();
@@ -34,7 +49,6 @@ export function TrajectoryPlayer({
34
49
  }
35
50
  }, [playing, player]);
36
51
 
37
- // Use useFrame instead of setInterval to sync with the render loop
38
52
  useFrame(() => {
39
53
  if (!onFrameRef.current) return;
40
54
  const currentFrame = player.frame;
@@ -9,13 +9,13 @@ import { useEffect, useRef } from 'react';
9
9
  import * as THREE from 'three';
10
10
  import { useMujocoContext, useAfterPhysicsStep } from '../core/MujocoSimProvider';
11
11
  import { findBodyByName } from '../core/SceneLoader';
12
- import type { BodyStateResult } from '../types';
12
+ import type { Bodies, BodyStateResult } from '../types';
13
13
 
14
14
  /**
15
15
  * Track a MuJoCo body's world position, quaternion, and velocities.
16
16
  * All values are ref-based — updated every physics frame without re-renders.
17
17
  */
18
- export function useBodyState(name: string): BodyStateResult {
18
+ export function useBodyState(name: Bodies): BodyStateResult {
19
19
  const { mjModelRef, status } = useMujocoContext();
20
20
  const bodyIdRef = useRef(-1);
21
21
  const position = useRef(new THREE.Vector3());
@@ -10,7 +10,7 @@ import { useCallback, useEffect, useRef } from 'react';
10
10
  import { useMujocoContext, useAfterPhysicsStep } from '../core/MujocoSimProvider';
11
11
  import { findBodyByName, getName } from '../core/SceneLoader';
12
12
  import { getContact } from '../types';
13
- import type { ContactInfo, MujocoModel } from '../types';
13
+ import type { Bodies, ContactInfo, MujocoModel } from '../types';
14
14
 
15
15
  // Cache geom names per model to avoid cross-model id collisions.
16
16
  const geomNameCacheByModel = new WeakMap<MujocoModel, Map<number, string>>();
@@ -36,7 +36,7 @@ function getGeomNameCached(model: MujocoModel, geomId: number): string {
36
36
  * Reads `data.ncon` first to avoid allocating for zero contacts.
37
37
  */
38
38
  export function useContacts(
39
- bodyName?: string,
39
+ bodyName?: Bodies,
40
40
  callback?: (contacts: ContactInfo[]) => void,
41
41
  ): React.RefObject<ContactInfo[]> {
42
42
  const { mjModelRef, status } = useMujocoContext();
@@ -108,7 +108,7 @@ export function useContacts(
108
108
  * onEnter/onExit callbacks on transitions.
109
109
  */
110
110
  export function useContactEvents(
111
- bodyName: string,
111
+ bodyName: Bodies,
112
112
  handlers: {
113
113
  onEnter?: (info: ContactInfo) => void;
114
114
  onExit?: (info: ContactInfo) => void;
@@ -2,39 +2,52 @@
2
2
  * @license
3
3
  * SPDX-License-Identifier: Apache-2.0
4
4
  *
5
- * useCtrl — clean read/write access to a named actuator's ctrl value (spec 3.1)
5
+ * useCtrl — handle-based read/write access to a named actuator's ctrl value (spec 3.1)
6
6
  */
7
7
 
8
- import { useCallback, useEffect, useRef } from 'react';
8
+ import { useEffect, useRef, useMemo } from 'react';
9
9
  import { useMujocoContext } from '../core/MujocoSimProvider';
10
10
  import { findActuatorByName } from '../core/SceneLoader';
11
+ import type { Actuators, CtrlHandle } from '../types';
11
12
 
12
13
  /**
13
14
  * Access a single actuator's control value by name.
14
15
  *
15
- * Returns [currentValue, setValue]:
16
- * - `currentValue` is a ref updated every frame (no re-renders).
17
- * - `setValue` writes directly to `data.ctrl[actuatorId]`.
16
+ * Returns a `CtrlHandle` with `read()` and `write()` methods that
17
+ * operate directly on `data.ctrl` without causing React re-renders.
18
18
  */
19
- export function useCtrl(name: string): [React.RefObject<number>, (value: number) => void] {
19
+ export function useCtrl(name: Actuators): CtrlHandle {
20
20
  const { mjModelRef, mjDataRef, status } = useMujocoContext();
21
21
  const actuatorIdRef = useRef(-1);
22
- const valueRef = useRef(0);
22
+ const rangeRef = useRef<[number, number]>([0, 0]);
23
23
 
24
24
  useEffect(() => {
25
25
  const model = mjModelRef.current;
26
26
  if (!model || status !== 'ready') return;
27
- actuatorIdRef.current = findActuatorByName(model, name);
27
+ const id = findActuatorByName(model, name);
28
+ actuatorIdRef.current = id;
29
+ if (id >= 0) {
30
+ rangeRef.current = [
31
+ model.actuator_ctrlrange[id * 2],
32
+ model.actuator_ctrlrange[id * 2 + 1],
33
+ ];
34
+ }
28
35
  }, [name, status, mjModelRef]);
29
36
 
30
- // Read current value each frame (via afterStep would be ideal but
31
- // useCtrl is primarily for writing; reading can use the ref)
32
- const setValue = useCallback((value: number) => {
33
- const data = mjDataRef.current;
34
- if (!data || actuatorIdRef.current < 0) return;
35
- data.ctrl[actuatorIdRef.current] = value;
36
- valueRef.current = value;
37
- }, [mjDataRef]);
38
-
39
- return [valueRef, setValue];
37
+ return useMemo<CtrlHandle>(() => ({
38
+ read() {
39
+ const data = mjDataRef.current;
40
+ if (!data || actuatorIdRef.current < 0) return 0;
41
+ return data.ctrl[actuatorIdRef.current];
42
+ },
43
+ write(value: number) {
44
+ const data = mjDataRef.current;
45
+ if (!data || actuatorIdRef.current < 0) return;
46
+ data.ctrl[actuatorIdRef.current] = value;
47
+ },
48
+ name,
49
+ get range(): [number, number] {
50
+ return rangeRef.current;
51
+ },
52
+ }), [name, mjDataRef]);
40
53
  }
@@ -8,7 +8,7 @@
8
8
  import { useEffect, useRef } from 'react';
9
9
  import { useMujocoContext, useAfterPhysicsStep } from '../core/MujocoSimProvider';
10
10
  import { getName } from '../core/SceneLoader';
11
- import type { JointStateResult } from '../types';
11
+ import type { Joints, JointStateResult } from '../types';
12
12
 
13
13
  /**
14
14
  * Track a MuJoCo joint's position and velocity by name.
@@ -18,7 +18,7 @@ import type { JointStateResult } from '../types';
18
18
  * For ball joints, position is quat (4), velocity is angular vel (3).
19
19
  * For free joints, position is pos+quat (7), velocity is lin+ang vel (6).
20
20
  */
21
- export function useJointState(name: string): JointStateResult {
21
+ export function useJointState(name: Joints): JointStateResult {
22
22
  const { mjModelRef, mjDataRef, status } = useMujocoContext();
23
23
  const jointIdRef = useRef(-1);
24
24
  const qposAdrRef = useRef(0);
@@ -8,13 +8,14 @@
8
8
  import { useEffect, useRef, useMemo } from 'react';
9
9
  import { useMujocoContext, useAfterPhysicsStep } from '../core/MujocoSimProvider';
10
10
  import { getName } from '../core/SceneLoader';
11
- import type { SensorInfo, SensorResult } from '../types';
11
+ import type { Sensors, SensorHandle, SensorInfo } from '../types';
12
12
 
13
13
  /**
14
- * Access a single MuJoCo sensor by name. Returns a ref-based value
15
- * updated every physics frame without causing React re-renders.
14
+ * Access a single MuJoCo sensor by name. Returns a `SensorHandle` with
15
+ * `read()`, `dim`, and `name`. The backing array is updated every physics
16
+ * frame without causing React re-renders.
16
17
  */
17
- export function useSensor(name: string): SensorResult {
18
+ export function useSensor(name: Sensors): SensorHandle {
18
19
  const { mjModelRef, mjDataRef, status } = useMujocoContext();
19
20
  const sensorIdRef = useRef(-1);
20
21
  const sensorAdrRef = useRef(0);
@@ -47,7 +48,15 @@ export function useSensor(name: string): SensorResult {
47
48
  }
48
49
  });
49
50
 
50
- return { value: valueRef, size: sensorDimRef.current };
51
+ return useMemo<SensorHandle>(() => ({
52
+ read() {
53
+ return valueRef.current;
54
+ },
55
+ get dim() {
56
+ return sensorDimRef.current;
57
+ },
58
+ name,
59
+ }), [name]);
51
60
  }
52
61
 
53
62
  /**
@@ -8,7 +8,7 @@ import { useFrame } from '@react-three/fiber';
8
8
  import * as THREE from 'three';
9
9
  import { useMujocoContext } from '../core/MujocoSimProvider';
10
10
  import { findSiteByName } from '../core/SceneLoader';
11
- import type { SitePositionResult } from '../types';
11
+ import type { Sites, SitePositionResult } from '../types';
12
12
 
13
13
  // Preallocated temp for rotation matrix extraction
14
14
  const _mat4 = new THREE.Matrix4();
@@ -17,7 +17,7 @@ const _mat4 = new THREE.Matrix4();
17
17
  * Returns reactive refs for a MuJoCo site's world position and orientation.
18
18
  * Refs are updated every frame without triggering React re-renders.
19
19
  */
20
- export function useSitePosition(siteName: string): SitePositionResult {
20
+ export function useSitePosition(siteName: Sites): SitePositionResult {
21
21
  const { mjModelRef, mjDataRef, status } = useMujocoContext();
22
22
  const siteIdRef = useRef(-1);
23
23
  const positionRef = useRef(new THREE.Vector3());
@@ -7,65 +7,153 @@
7
7
 
8
8
  import { useCallback, useRef } from 'react';
9
9
  import { useFrame } from '@react-three/fiber';
10
- import { useMujocoContext } from '../core/MujocoSimProvider';
10
+ import { useMujocoContext, useBeforePhysicsStep } from '../core/MujocoSimProvider';
11
+ import type { PlaybackState, TrajectoryFrame, TrajectoryInput } from '../types';
11
12
 
12
- interface TrajectoryPlayerOptions {
13
+ export interface TrajectoryPlayerOptions {
13
14
  fps?: number;
15
+ speed?: number;
14
16
  loop?: boolean;
17
+ mode?: 'kinematic' | 'physics';
18
+ onComplete?: () => void;
19
+ onStateChange?: (state: PlaybackState) => void;
20
+ }
21
+
22
+ /** Check if input is TrajectoryFrame[] (vs number[][]) */
23
+ function isTrajectoryFrames(input: TrajectoryInput): input is TrajectoryFrame[] {
24
+ return input.length > 0 && typeof (input[0] as TrajectoryFrame).time === 'number'
25
+ && 'qpos' in (input[0] as TrajectoryFrame);
26
+ }
27
+
28
+ /** Extract qpos as plain number array from a frame */
29
+ function getQpos(input: TrajectoryInput, idx: number): ArrayLike<number> | null {
30
+ const item = input[idx];
31
+ if (!item) return null;
32
+ if (Array.isArray(item)) return item;
33
+ return (item as TrajectoryFrame).qpos;
34
+ }
35
+
36
+ /** Extract ctrl values from a TrajectoryFrame, if available */
37
+ function getCtrl(input: TrajectoryInput, idx: number): ArrayLike<number> | null {
38
+ const item = input[idx];
39
+ if (!item || Array.isArray(item)) return null;
40
+ return (item as TrajectoryFrame).ctrl ?? null;
15
41
  }
16
42
 
17
43
  /**
18
- * Play back a sequence of qpos frames, overriding simulation state.
44
+ * Play back a trajectory, overriding simulation state.
19
45
  *
20
- * When playing, the simulation is effectively paused and qpos is set
21
- * from the trajectory each render frame at the specified FPS.
46
+ * Accepts either `TrajectoryFrame[]` (from useTrajectoryRecorder) or
47
+ * `number[][]` (raw qpos arrays).
48
+ *
49
+ * In `kinematic` mode (default), the simulation is paused and qpos is
50
+ * set directly each frame with mj_forward for rendering.
51
+ *
52
+ * In `physics` mode, the simulation keeps running and ctrl values from
53
+ * the trajectory are applied each physics step via useBeforePhysicsStep.
22
54
  */
23
55
  export function useTrajectoryPlayer(
24
- trajectory: number[][],
56
+ trajectory: TrajectoryInput,
25
57
  options: TrajectoryPlayerOptions = {},
26
58
  ) {
27
59
  const { mjModelRef, mjDataRef, mujocoRef, pausedRef } = useMujocoContext();
28
- const fps = options.fps ?? 30;
29
- const loop = options.loop ?? false;
30
60
 
31
- const playingRef = useRef(false);
61
+ const optionsRef = useRef(options);
62
+ optionsRef.current = options;
63
+
64
+ const stateRef = useRef<PlaybackState>('idle');
32
65
  const frameRef = useRef(0);
33
66
  const lastFrameTimeRef = useRef(0);
67
+ const speedRef = useRef(options.speed ?? 1.0);
68
+ const wasPausedRef = useRef(false);
69
+
70
+ // Stable ref to trajectory to avoid stale closures in useBeforePhysicsStep
71
+ const trajectoryRef = useRef(trajectory);
72
+ trajectoryRef.current = trajectory;
73
+
74
+ const setState = useCallback((next: PlaybackState) => {
75
+ if (stateRef.current === next) return;
76
+ stateRef.current = next;
77
+ optionsRef.current.onStateChange?.(next);
78
+ }, []);
34
79
 
35
80
  const play = useCallback(() => {
36
- playingRef.current = true;
37
- pausedRef.current = true; // Pause sim during playback
81
+ const traj = trajectoryRef.current;
82
+ if (traj.length === 0) return;
83
+
84
+ const mode = optionsRef.current.mode ?? 'kinematic';
85
+
86
+ if (stateRef.current === 'completed') {
87
+ frameRef.current = 0;
88
+ }
89
+
90
+ if (mode === 'kinematic') {
91
+ wasPausedRef.current = pausedRef.current;
92
+ pausedRef.current = true;
93
+ }
94
+
38
95
  lastFrameTimeRef.current = performance.now();
39
- }, [pausedRef]);
96
+ setState('playing');
97
+ }, [pausedRef, setState]);
40
98
 
41
99
  const pause = useCallback(() => {
42
- playingRef.current = false;
43
- }, []);
100
+ if (stateRef.current !== 'playing') return;
101
+ setState('paused');
102
+ }, [setState]);
44
103
 
45
104
  const seek = useCallback((frameIdx: number) => {
46
- frameRef.current = Math.max(0, Math.min(frameIdx, trajectory.length - 1));
105
+ const traj = trajectoryRef.current;
106
+ if (traj.length === 0) return;
107
+
108
+ frameRef.current = Math.max(0, Math.min(frameIdx, traj.length - 1));
109
+
47
110
  const model = mjModelRef.current;
48
111
  const data = mjDataRef.current;
49
- if (!model || !data || !trajectory[frameRef.current]) return;
50
- const qpos = trajectory[frameRef.current];
112
+ if (!model || !data) return;
113
+
114
+ const qpos = getQpos(traj, frameRef.current);
115
+ if (!qpos) return;
116
+
51
117
  for (let i = 0; i < Math.min(qpos.length, model.nq); i++) {
52
118
  data.qpos[i] = qpos[i];
53
119
  }
54
120
  mujocoRef.current.mj_forward(model, data);
55
- }, [trajectory, mjModelRef, mjDataRef, mujocoRef]);
121
+ }, [mjModelRef, mjDataRef, mujocoRef]);
56
122
 
57
123
  const reset = useCallback(() => {
124
+ const mode = optionsRef.current.mode ?? 'kinematic';
125
+ if (mode === 'kinematic' && stateRef.current !== 'idle') {
126
+ pausedRef.current = wasPausedRef.current;
127
+ }
58
128
  frameRef.current = 0;
59
- playingRef.current = false;
60
- pausedRef.current = false;
61
- }, [pausedRef]);
129
+ setState('idle');
130
+ }, [pausedRef, setState]);
131
+
132
+ const setSpeed = useCallback((s: number) => {
133
+ speedRef.current = s;
134
+ }, []);
62
135
 
136
+ const complete = useCallback(() => {
137
+ const mode = optionsRef.current.mode ?? 'kinematic';
138
+ if (mode === 'kinematic') {
139
+ pausedRef.current = wasPausedRef.current;
140
+ }
141
+ setState('completed');
142
+ optionsRef.current.onComplete?.();
143
+ }, [pausedRef, setState]);
144
+
145
+ // --- Kinematic mode: drive qpos directly from useFrame ---
63
146
  useFrame(() => {
64
- if (!playingRef.current || trajectory.length === 0) return;
147
+ if (stateRef.current !== 'playing') return;
148
+ if ((optionsRef.current.mode ?? 'kinematic') !== 'kinematic') return;
149
+
150
+ const traj = trajectoryRef.current;
151
+ if (traj.length === 0) return;
65
152
 
66
153
  const now = performance.now();
154
+ const fps = optionsRef.current.fps ?? 30;
155
+ const frameInterval = 1000 / (fps * speedRef.current);
67
156
  const elapsed = now - lastFrameTimeRef.current;
68
- const frameInterval = 1000 / fps;
69
157
 
70
158
  if (elapsed < frameInterval) return;
71
159
  lastFrameTimeRef.current = now;
@@ -74,7 +162,7 @@ export function useTrajectoryPlayer(
74
162
  const data = mjDataRef.current;
75
163
  if (!model || !data) return;
76
164
 
77
- const qpos = trajectory[frameRef.current];
165
+ const qpos = getQpos(traj, frameRef.current);
78
166
  if (!qpos) return;
79
167
 
80
168
  for (let i = 0; i < Math.min(qpos.length, model.nq); i++) {
@@ -83,12 +171,44 @@ export function useTrajectoryPlayer(
83
171
  mujocoRef.current.mj_forward(model, data);
84
172
 
85
173
  frameRef.current++;
86
- if (frameRef.current >= trajectory.length) {
87
- if (loop) {
174
+ if (frameRef.current >= traj.length) {
175
+ if (optionsRef.current.loop) {
176
+ frameRef.current = 0;
177
+ } else {
178
+ complete();
179
+ }
180
+ }
181
+ });
182
+
183
+ // --- Physics mode: set ctrl values each physics step ---
184
+ useBeforePhysicsStep((model, data) => {
185
+ if (stateRef.current !== 'playing') return;
186
+ if ((optionsRef.current.mode ?? 'kinematic') !== 'physics') return;
187
+
188
+ const traj = trajectoryRef.current;
189
+ if (traj.length === 0) return;
190
+
191
+ // Advance frame based on sim time vs trajectory time
192
+ const fps = optionsRef.current.fps ?? 30;
193
+ const targetFrame = Math.floor(data.time * fps * speedRef.current);
194
+ frameRef.current = Math.min(targetFrame, traj.length - 1);
195
+
196
+ // Apply ctrl from trajectory
197
+ const ctrl = getCtrl(traj, frameRef.current);
198
+ if (ctrl) {
199
+ for (let i = 0; i < Math.min(ctrl.length, model.nu); i++) {
200
+ data.ctrl[i] = ctrl[i];
201
+ }
202
+ }
203
+
204
+ // Check completion
205
+ if (frameRef.current >= traj.length - 1) {
206
+ if (optionsRef.current.loop) {
207
+ // Reset sim time to restart
208
+ data.time = 0;
88
209
  frameRef.current = 0;
89
210
  } else {
90
- playingRef.current = false;
91
- pausedRef.current = false;
211
+ complete();
92
212
  }
93
213
  }
94
214
  });
@@ -98,8 +218,11 @@ export function useTrajectoryPlayer(
98
218
  pause,
99
219
  seek,
100
220
  reset,
221
+ setSpeed,
222
+ get state() { return stateRef.current; },
101
223
  get frame() { return frameRef.current; },
102
- get playing() { return playingRef.current; },
224
+ get playing() { return stateRef.current === 'playing'; },
103
225
  get totalFrames() { return trajectory.length; },
226
+ get progress() { return trajectory.length > 1 ? frameRef.current / (trajectory.length - 1) : 0; },
104
227
  };
105
228
  }
package/src/index.ts CHANGED
@@ -95,6 +95,8 @@ export type {
95
95
  // Trajectory
96
96
  TrajectoryFrame,
97
97
  TrajectoryData,
98
+ TrajectoryInput,
99
+ PlaybackState,
98
100
  // Keyboard teleop
99
101
  KeyBinding,
100
102
  KeyboardTeleopConfig,
@@ -115,8 +117,19 @@ export type {
115
117
  // Hook return types
116
118
  SitePositionResult,
117
119
  SensorResult,
120
+ CtrlHandle,
121
+ SensorHandle,
118
122
  BodyStateResult,
119
123
  JointStateResult,
124
+ // Register (type-safe named resources)
125
+ Register,
126
+ Actuators,
127
+ Sensors,
128
+ Bodies,
129
+ Joints,
130
+ Sites,
131
+ Geoms,
132
+ Keyframes,
120
133
  } from './types';
121
134
 
122
135
  // Re-export MuJoCo types for convenience