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/README.md +77 -64
- package/dist/index.d.ts +27 -7
- package/dist/index.js +191 -76
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/components/TrajectoryPlayer.tsx +16 -2
- package/src/core/SceneLoader.ts +77 -51
- 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;
|
package/src/core/SceneLoader.ts
CHANGED
|
@@ -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
|
|
171
|
+
const xmlQueue: string[] = [config.sceneFile];
|
|
172
|
+
const assetFiles: string[] = [];
|
|
161
173
|
const parser = new DOMParser();
|
|
162
174
|
|
|
163
|
-
//
|
|
164
|
-
while (
|
|
165
|
-
const fname =
|
|
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
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
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
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
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
|
-
|
|
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
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
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
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
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
|
|
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 {
|