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/README.md +77 -64
- package/dist/index.d.ts +27 -7
- package/dist/index.js +125 -31
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/components/TrajectoryPlayer.tsx +16 -2
- package/src/hooks/useTrajectoryPlayer.ts +152 -29
- package/src/index.ts +2 -0
- package/src/types.ts +9 -1
package/package.json
CHANGED
|
@@ -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, {
|
|
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
|
|
44
|
+
* Play back a trajectory, overriding simulation state.
|
|
19
45
|
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
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:
|
|
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
|
|
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
|
-
|
|
37
|
-
|
|
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
|
-
|
|
96
|
+
setState('playing');
|
|
97
|
+
}, [pausedRef, setState]);
|
|
40
98
|
|
|
41
99
|
const pause = useCallback(() => {
|
|
42
|
-
|
|
43
|
-
|
|
100
|
+
if (stateRef.current !== 'playing') return;
|
|
101
|
+
setState('paused');
|
|
102
|
+
}, [setState]);
|
|
44
103
|
|
|
45
104
|
const seek = useCallback((frameIdx: number) => {
|
|
46
|
-
|
|
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
|
|
50
|
-
|
|
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
|
-
}, [
|
|
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
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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 (
|
|
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 =
|
|
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 >=
|
|
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
|
-
|
|
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
|
|
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
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:
|
|
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 {
|