mujoco-react 8.0.0 → 8.1.1

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.1",
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;
@@ -137,6 +137,17 @@ function sceneObjectToXml(obj: SceneObject): string {
137
137
  return `<body name="${obj.name}" pos="${pos}">${joint}<geom type="${obj.type}" size="${size}" rgba="${rgba}" contype="1" conaffinity="1"${mass}${friction}${solref}${solimp}${condim}/></body>`;
138
138
  }
139
139
 
140
+ /** Create virtual directory structure for a file path. */
141
+ function ensureDir(mujoco: MujocoModule, fname: string) {
142
+ const dirParts = fname.split('/');
143
+ dirParts.pop();
144
+ let currentPath = '/working';
145
+ for (const part of dirParts) {
146
+ currentPath += '/' + part;
147
+ try { mujoco.FS.mkdir(currentPath); } catch { /* ignore */ }
148
+ }
149
+ }
150
+
140
151
  interface LoadResult {
141
152
  mjModel: MujocoModel;
142
153
  mjData: MujocoData;
@@ -157,15 +168,22 @@ export async function loadScene(
157
168
  const baseUrl = config.src.endsWith('/') ? config.src : config.src + '/';
158
169
 
159
170
  const downloaded = new Set<string>();
160
- const queue: string[] = [config.sceneFile];
171
+ const xmlQueue: string[] = [config.sceneFile];
172
+ const assetFiles: string[] = [];
161
173
  const parser = new DOMParser();
162
174
 
163
- // 2. Download all model files
164
- while (queue.length > 0) {
165
- const fname = queue.shift()!;
175
+ // 2a. Download XML files sequentially (to discover dependencies)
176
+ while (xmlQueue.length > 0) {
177
+ const fname = xmlQueue.shift()!;
166
178
  if (downloaded.has(fname)) continue;
167
179
  downloaded.add(fname);
168
180
 
181
+ if (!fname.endsWith('.xml')) {
182
+ // Non-XML discovered during XML scan — collect for parallel download
183
+ assetFiles.push(fname);
184
+ continue;
185
+ }
186
+
169
187
  onProgress?.(`Downloading ${fname}...`);
170
188
 
171
189
  const res = await fetch(baseUrl + fname);
@@ -174,61 +192,69 @@ export async function loadScene(
174
192
  continue;
175
193
  }
176
194
 
177
- // Create virtual directory structure
178
- const dirParts = fname.split('/');
179
- dirParts.pop();
180
- let currentPath = '/working';
181
- for (const part of dirParts) {
182
- currentPath += '/' + part;
183
- try { mujoco.FS.mkdir(currentPath); } catch { /* ignore */ }
184
- }
185
-
186
- if (fname.endsWith('.xml')) {
187
- let text = await res.text();
188
-
189
- // 3. Apply XML patches from config
190
- for (const patch of config.xmlPatches ?? []) {
191
- if (fname.endsWith(patch.target) || fname === patch.target) {
192
- if (patch.replace) {
193
- const [from, to] = patch.replace;
194
- if (text.includes(from)) {
195
- text = text.replace(from, to);
196
- } else {
197
- const preview = from.length > 80 ? `${from.slice(0, 80)}...` : from;
198
- console.warn(`XML patch replace pattern not found in ${fname}: "${preview}"`);
199
- }
195
+ let text = await res.text();
196
+
197
+ // 3. Apply XML patches from config
198
+ for (const patch of config.xmlPatches ?? []) {
199
+ if (fname.endsWith(patch.target) || fname === patch.target) {
200
+ if (patch.replace) {
201
+ const [from, to] = patch.replace;
202
+ if (text.includes(from)) {
203
+ text = text.replace(from, to);
204
+ } else {
205
+ const preview = from.length > 80 ? `${from.slice(0, 80)}...` : from;
206
+ console.warn(`XML patch replace pattern not found in ${fname}: "${preview}"`);
200
207
  }
201
- if (patch.inject && patch.injectAfter) {
202
- const idx = text.indexOf(patch.injectAfter);
203
- if (idx !== -1) {
204
- // Find the end of the opening tag (next '>') after the match
205
- const tagEnd = text.indexOf('>', idx + patch.injectAfter.length);
206
- if (tagEnd !== -1) {
207
- text = text.slice(0, tagEnd + 1) + patch.inject + text.slice(tagEnd + 1);
208
- } else {
209
- console.warn(`XML patch inject failed in ${fname}: could not find tag end after "${patch.injectAfter}"`);
210
- }
208
+ }
209
+ if (patch.inject && patch.injectAfter) {
210
+ const idx = text.indexOf(patch.injectAfter);
211
+ if (idx !== -1) {
212
+ const tagEnd = text.indexOf('>', idx + patch.injectAfter.length);
213
+ if (tagEnd !== -1) {
214
+ text = text.slice(0, tagEnd + 1) + patch.inject + text.slice(tagEnd + 1);
211
215
  } else {
212
- const preview = patch.injectAfter.length > 80
213
- ? `${patch.injectAfter.slice(0, 80)}...`
214
- : patch.injectAfter;
215
- console.warn(`XML patch inject anchor not found in ${fname}: "${preview}"`);
216
+ console.warn(`XML patch inject failed in ${fname}: could not find tag end after "${patch.injectAfter}"`);
216
217
  }
218
+ } else {
219
+ const preview = patch.injectAfter.length > 80
220
+ ? `${patch.injectAfter.slice(0, 80)}...`
221
+ : patch.injectAfter;
222
+ console.warn(`XML patch inject anchor not found in ${fname}: "${preview}"`);
217
223
  }
218
224
  }
219
225
  }
226
+ }
220
227
 
221
- // 4. Inject scene objects into the scene file
222
- if (fname === config.sceneFile && config.sceneObjects?.length) {
223
- const xml = config.sceneObjects.map((obj) => sceneObjectToXml(obj)).join('');
224
- text = text.replace('</worldbody>', xml + '</worldbody>');
225
- }
228
+ // 4. Inject scene objects into the scene file
229
+ if (fname === config.sceneFile && config.sceneObjects?.length) {
230
+ const xml = config.sceneObjects.map((obj) => sceneObjectToXml(obj)).join('');
231
+ text = text.replace('</worldbody>', xml + '</worldbody>');
232
+ }
226
233
 
227
- mujoco.FS.writeFile(`/working/${fname}`, text);
228
- scanDependencies(text, fname, parser, downloaded, queue);
229
- } else {
230
- const buffer = new Uint8Array(await res.arrayBuffer());
231
- mujoco.FS.writeFile(`/working/${fname}`, buffer);
234
+ ensureDir(mujoco, fname);
235
+ mujoco.FS.writeFile(`/working/${fname}`, text);
236
+ scanDependencies(text, fname, parser, downloaded, xmlQueue);
237
+ }
238
+
239
+ // 2b. Download all binary assets (meshes, textures) in parallel
240
+ if (assetFiles.length > 0) {
241
+ onProgress?.(`Downloading ${assetFiles.length} assets...`);
242
+
243
+ const results = await Promise.all(
244
+ assetFiles.map(async (fname) => {
245
+ const res = await fetch(baseUrl + fname);
246
+ if (!res.ok) {
247
+ console.warn(`Failed to fetch ${fname}: ${res.status} ${res.statusText}`);
248
+ return null;
249
+ }
250
+ return { fname, buffer: new Uint8Array(await res.arrayBuffer()) };
251
+ })
252
+ );
253
+
254
+ for (const result of results) {
255
+ if (!result) continue;
256
+ ensureDir(mujoco, result.fname);
257
+ mujoco.FS.writeFile(`/working/${result.fname}`, result.buffer);
232
258
  }
233
259
  }
234
260
 
@@ -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 {