mujoco-react 8.0.0 → 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": "8.0.0",
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;
@@ -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,
package/src/types.ts CHANGED
@@ -487,6 +487,8 @@ export interface TrajectoryData {
487
487
  fps: number;
488
488
  }
489
489
 
490
+ export type PlaybackState = 'idle' | 'playing' | 'paused' | 'completed';
491
+
490
492
  // ---- Keyboard Teleop (spec 12.1) ----
491
493
 
492
494
  export interface KeyBinding {
@@ -540,12 +542,18 @@ export interface SceneLightsProps {
540
542
  intensity?: number;
541
543
  }
542
544
 
545
+ export type TrajectoryInput = TrajectoryFrame[] | number[][];
546
+
543
547
  export interface TrajectoryPlayerProps {
544
- trajectory: number[][];
548
+ trajectory: TrajectoryInput;
545
549
  fps?: number;
550
+ speed?: number;
546
551
  loop?: boolean;
547
552
  playing?: boolean;
553
+ mode?: 'kinematic' | 'physics';
548
554
  onFrame?: (frameIdx: number) => void;
555
+ onComplete?: () => void;
556
+ onStateChange?: (state: PlaybackState) => void;
549
557
  }
550
558
 
551
559
  export interface SelectionHighlightProps {