mujoco-react 0.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/LICENSE +177 -0
- package/README.md +510 -0
- package/dist/index.d.ts +1080 -0
- package/dist/index.js +3518 -0
- package/dist/index.js.map +1 -0
- package/package.json +64 -0
- package/src/components/ContactListener.tsx +26 -0
- package/src/components/ContactMarkers.tsx +81 -0
- package/src/components/Debug.tsx +227 -0
- package/src/components/DragInteraction.tsx +227 -0
- package/src/components/FlexRenderer.tsx +102 -0
- package/src/components/IkGizmo.tsx +146 -0
- package/src/components/SceneLights.tsx +131 -0
- package/src/components/SceneRenderer.tsx +104 -0
- package/src/components/SelectionHighlight.tsx +69 -0
- package/src/components/TendonRenderer.tsx +84 -0
- package/src/components/TrajectoryPlayer.tsx +44 -0
- package/src/core/GenericIK.ts +339 -0
- package/src/core/MujocoCanvas.tsx +72 -0
- package/src/core/MujocoProvider.tsx +78 -0
- package/src/core/MujocoSimProvider.tsx +1201 -0
- package/src/core/SceneLoader.ts +275 -0
- package/src/hooks/useActuators.ts +36 -0
- package/src/hooks/useBodyState.ts +56 -0
- package/src/hooks/useContacts.ts +125 -0
- package/src/hooks/useCtrl.ts +40 -0
- package/src/hooks/useCtrlNoise.ts +59 -0
- package/src/hooks/useGamepad.ts +77 -0
- package/src/hooks/useGravityCompensation.ts +22 -0
- package/src/hooks/useJointState.ts +64 -0
- package/src/hooks/useKeyboardTeleop.ts +97 -0
- package/src/hooks/usePolicy.ts +56 -0
- package/src/hooks/useSensor.ts +83 -0
- package/src/hooks/useSitePosition.ts +62 -0
- package/src/hooks/useTrajectoryPlayer.ts +105 -0
- package/src/hooks/useTrajectoryRecorder.ts +97 -0
- package/src/hooks/useVideoRecorder.ts +82 -0
- package/src/index.ts +108 -0
- package/src/rendering/CapsuleGeometry.ts +35 -0
- package/src/rendering/GeomBuilder.ts +140 -0
- package/src/rendering/Reflector.ts +225 -0
- package/src/types.ts +619 -0
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*
|
|
5
|
+
* useJointState — per-joint position/velocity access (spec 2.3)
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { useEffect, useRef } from 'react';
|
|
9
|
+
import { useMujocoSim, useAfterPhysicsStep } from '../core/MujocoSimProvider';
|
|
10
|
+
import { getName } from '../core/SceneLoader';
|
|
11
|
+
import type { JointStateResult } from '../types';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Track a MuJoCo joint's position and velocity by name.
|
|
15
|
+
* Values are updated every physics frame via refs (no re-renders).
|
|
16
|
+
*
|
|
17
|
+
* For hinge/slide joints, position/velocity are scalar (stored as Float64Array of length 1).
|
|
18
|
+
* For ball joints, position is quat (4), velocity is angular vel (3).
|
|
19
|
+
* For free joints, position is pos+quat (7), velocity is lin+ang vel (6).
|
|
20
|
+
*/
|
|
21
|
+
export function useJointState(name: string): JointStateResult {
|
|
22
|
+
const { mjModelRef, mjDataRef, status } = useMujocoSim();
|
|
23
|
+
const jointIdRef = useRef(-1);
|
|
24
|
+
const qposAdrRef = useRef(0);
|
|
25
|
+
const dofAdrRef = useRef(0);
|
|
26
|
+
const qposDimRef = useRef(1);
|
|
27
|
+
const dofDimRef = useRef(1);
|
|
28
|
+
const positionRef = useRef<number | Float64Array>(0);
|
|
29
|
+
const velocityRef = useRef<number | Float64Array>(0);
|
|
30
|
+
|
|
31
|
+
useEffect(() => {
|
|
32
|
+
const model = mjModelRef.current;
|
|
33
|
+
if (!model || status !== 'ready') return;
|
|
34
|
+
for (let i = 0; i < model.njnt; i++) {
|
|
35
|
+
if (getName(model, model.name_jntadr[i]) === name) {
|
|
36
|
+
jointIdRef.current = i;
|
|
37
|
+
qposAdrRef.current = model.jnt_qposadr[i];
|
|
38
|
+
dofAdrRef.current = model.jnt_dofadr[i];
|
|
39
|
+
const type = model.jnt_type[i];
|
|
40
|
+
// Type 0=free (7 qpos, 6 dof), 1=ball (4 qpos, 3 dof), 2=slide (1,1), 3=hinge (1,1)
|
|
41
|
+
if (type === 0) { qposDimRef.current = 7; dofDimRef.current = 6; }
|
|
42
|
+
else if (type === 1) { qposDimRef.current = 4; dofDimRef.current = 3; }
|
|
43
|
+
else { qposDimRef.current = 1; dofDimRef.current = 1; }
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
jointIdRef.current = -1;
|
|
48
|
+
}, [name, status, mjModelRef]);
|
|
49
|
+
|
|
50
|
+
useAfterPhysicsStep((_model, data) => {
|
|
51
|
+
if (jointIdRef.current < 0) return;
|
|
52
|
+
const qa = qposAdrRef.current;
|
|
53
|
+
const da = dofAdrRef.current;
|
|
54
|
+
if (qposDimRef.current === 1) {
|
|
55
|
+
positionRef.current = data.qpos[qa];
|
|
56
|
+
velocityRef.current = data.qvel[da];
|
|
57
|
+
} else {
|
|
58
|
+
positionRef.current = new Float64Array(data.qpos.subarray(qa, qa + qposDimRef.current));
|
|
59
|
+
velocityRef.current = new Float64Array(data.qvel.subarray(da, da + dofDimRef.current));
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
return { position: positionRef, velocity: velocityRef };
|
|
64
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*
|
|
5
|
+
* useKeyboardTeleop — keyboard teleoperation hook (spec 12.1)
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { useEffect, useRef } from 'react';
|
|
9
|
+
import { useMujocoSim, useBeforePhysicsStep } from '../core/MujocoSimProvider';
|
|
10
|
+
import { findActuatorByName } from '../core/SceneLoader';
|
|
11
|
+
import type { KeyboardTeleopConfig } from '../types';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Map keyboard keys to actuator commands.
|
|
15
|
+
*
|
|
16
|
+
* Supports three binding modes:
|
|
17
|
+
* - `delta`: Add delta to actuator value while key is held
|
|
18
|
+
* - `toggle`: Toggle between two values on key press
|
|
19
|
+
* - `set`: Set actuator to a fixed value while key is held
|
|
20
|
+
*/
|
|
21
|
+
export function useKeyboardTeleop(config: KeyboardTeleopConfig) {
|
|
22
|
+
const { mjModelRef, mjDataRef, status } = useMujocoSim();
|
|
23
|
+
const pressedRef = useRef(new Set<string>());
|
|
24
|
+
const toggleStateRef = useRef(new Map<string, boolean>());
|
|
25
|
+
const enabledRef = useRef(config.enabled ?? true);
|
|
26
|
+
enabledRef.current = config.enabled ?? true;
|
|
27
|
+
|
|
28
|
+
// Resolve actuator IDs
|
|
29
|
+
const bindingsRef = useRef(config.bindings);
|
|
30
|
+
bindingsRef.current = config.bindings;
|
|
31
|
+
|
|
32
|
+
// Actuator ID cache
|
|
33
|
+
const actuatorCacheRef = useRef(new Map<string, number>());
|
|
34
|
+
useEffect(() => {
|
|
35
|
+
const model = mjModelRef.current;
|
|
36
|
+
if (!model || status !== 'ready') return;
|
|
37
|
+
const cache = new Map<string, number>();
|
|
38
|
+
for (const binding of Object.values(config.bindings)) {
|
|
39
|
+
if (!cache.has(binding.actuator)) {
|
|
40
|
+
cache.set(binding.actuator, findActuatorByName(model, binding.actuator));
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
actuatorCacheRef.current = cache;
|
|
44
|
+
}, [config.bindings, status, mjModelRef]);
|
|
45
|
+
|
|
46
|
+
// Key event listeners
|
|
47
|
+
useEffect(() => {
|
|
48
|
+
const onKeyDown = (e: KeyboardEvent) => {
|
|
49
|
+
if (!enabledRef.current) return;
|
|
50
|
+
const key = e.key.toLowerCase();
|
|
51
|
+
if (bindingsRef.current[key]) {
|
|
52
|
+
pressedRef.current.add(key);
|
|
53
|
+
// Handle toggle on keydown
|
|
54
|
+
const binding = bindingsRef.current[key];
|
|
55
|
+
if (binding.toggle) {
|
|
56
|
+
const current = toggleStateRef.current.get(key) ?? false;
|
|
57
|
+
toggleStateRef.current.set(key, !current);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
const onKeyUp = (e: KeyboardEvent) => {
|
|
62
|
+
pressedRef.current.delete(e.key.toLowerCase());
|
|
63
|
+
};
|
|
64
|
+
window.addEventListener('keydown', onKeyDown);
|
|
65
|
+
window.addEventListener('keyup', onKeyUp);
|
|
66
|
+
return () => {
|
|
67
|
+
window.removeEventListener('keydown', onKeyDown);
|
|
68
|
+
window.removeEventListener('keyup', onKeyUp);
|
|
69
|
+
};
|
|
70
|
+
}, []);
|
|
71
|
+
|
|
72
|
+
// Apply bindings each physics frame
|
|
73
|
+
useBeforePhysicsStep((_model, data) => {
|
|
74
|
+
if (!enabledRef.current) return;
|
|
75
|
+
const bindings = bindingsRef.current;
|
|
76
|
+
const cache = actuatorCacheRef.current;
|
|
77
|
+
|
|
78
|
+
for (const [key, binding] of Object.entries(bindings)) {
|
|
79
|
+
const actId = cache.get(binding.actuator);
|
|
80
|
+
if (actId === undefined || actId < 0) continue;
|
|
81
|
+
|
|
82
|
+
if (binding.toggle) {
|
|
83
|
+
// Toggle mode: set value based on toggle state
|
|
84
|
+
const state = toggleStateRef.current.get(key) ?? false;
|
|
85
|
+
data.ctrl[actId] = state ? binding.toggle[1] : binding.toggle[0];
|
|
86
|
+
} else if (pressedRef.current.has(key)) {
|
|
87
|
+
if (binding.delta !== undefined) {
|
|
88
|
+
// Delta mode: add delta while held
|
|
89
|
+
data.ctrl[actId] += binding.delta;
|
|
90
|
+
} else if (binding.set !== undefined) {
|
|
91
|
+
// Set mode: set fixed value while held
|
|
92
|
+
data.ctrl[actId] = binding.set;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
});
|
|
97
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*
|
|
5
|
+
* usePolicy — policy decimation loop hook (spec 10.1)
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { useRef } from 'react';
|
|
9
|
+
import { useMujocoSim, useBeforePhysicsStep } from '../core/MujocoSimProvider';
|
|
10
|
+
import type { PolicyConfig } from '../types';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Framework-agnostic policy execution hook.
|
|
14
|
+
*
|
|
15
|
+
* Manages a decimation loop: calls `onObservation` to build observations
|
|
16
|
+
* at the specified frequency, then calls `onAction` to apply the policy output.
|
|
17
|
+
* The actual inference (ONNX, TF.js, custom) is the consumer's responsibility.
|
|
18
|
+
*
|
|
19
|
+
* @param config Policy configuration
|
|
20
|
+
* @returns { step, isRunning } control handles
|
|
21
|
+
*/
|
|
22
|
+
export function usePolicy(config: PolicyConfig) {
|
|
23
|
+
const { mjModelRef } = useMujocoSim();
|
|
24
|
+
const lastActionTimeRef = useRef(0);
|
|
25
|
+
const lastActionRef = useRef<Float32Array | Float64Array | number[] | null>(null);
|
|
26
|
+
const isRunningRef = useRef(true);
|
|
27
|
+
const configRef = useRef(config);
|
|
28
|
+
configRef.current = config;
|
|
29
|
+
|
|
30
|
+
useBeforePhysicsStep((model, data) => {
|
|
31
|
+
if (!isRunningRef.current) return;
|
|
32
|
+
|
|
33
|
+
const cfg = configRef.current;
|
|
34
|
+
const dt = model.opt?.timestep ?? 0.002;
|
|
35
|
+
const interval = 1.0 / cfg.frequency;
|
|
36
|
+
|
|
37
|
+
// Check if it's time for a new action
|
|
38
|
+
if (data.time - lastActionTimeRef.current >= interval) {
|
|
39
|
+
// Build observation
|
|
40
|
+
const obs = cfg.onObservation(model, data);
|
|
41
|
+
|
|
42
|
+
// Apply action (consumer does inference inline or uses cached result)
|
|
43
|
+
cfg.onAction(obs, model, data);
|
|
44
|
+
|
|
45
|
+
lastActionTimeRef.current = data.time;
|
|
46
|
+
lastActionRef.current = obs;
|
|
47
|
+
}
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
return {
|
|
51
|
+
get isRunning() { return isRunningRef.current; },
|
|
52
|
+
start: () => { isRunningRef.current = true; },
|
|
53
|
+
stop: () => { isRunningRef.current = false; },
|
|
54
|
+
get lastObservation() { return lastActionRef.current; },
|
|
55
|
+
};
|
|
56
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*
|
|
5
|
+
* useSensor / useSensors — MuJoCo sensor access hooks (spec 2.1)
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { useEffect, useRef, useMemo } from 'react';
|
|
9
|
+
import { useMujocoSim, useAfterPhysicsStep } from '../core/MujocoSimProvider';
|
|
10
|
+
import { getName } from '../core/SceneLoader';
|
|
11
|
+
import type { SensorInfo, SensorResult } from '../types';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Access a single MuJoCo sensor by name. Returns a ref-based value
|
|
15
|
+
* updated every physics frame without causing React re-renders.
|
|
16
|
+
*/
|
|
17
|
+
export function useSensor(name: string): SensorResult {
|
|
18
|
+
const { mjModelRef, mjDataRef, status } = useMujocoSim();
|
|
19
|
+
const sensorIdRef = useRef(-1);
|
|
20
|
+
const sensorAdrRef = useRef(0);
|
|
21
|
+
const sensorDimRef = useRef(0);
|
|
22
|
+
const valueRef = useRef<Float64Array>(new Float64Array(0));
|
|
23
|
+
|
|
24
|
+
// Resolve sensor ID once model is ready
|
|
25
|
+
useEffect(() => {
|
|
26
|
+
const model = mjModelRef.current;
|
|
27
|
+
if (!model || status !== 'ready') return;
|
|
28
|
+
for (let i = 0; i < model.nsensor; i++) {
|
|
29
|
+
if (getName(model, model.name_sensoradr[i]) === name) {
|
|
30
|
+
sensorIdRef.current = i;
|
|
31
|
+
sensorAdrRef.current = model.sensor_adr[i];
|
|
32
|
+
sensorDimRef.current = model.sensor_dim[i];
|
|
33
|
+
valueRef.current = new Float64Array(model.sensor_dim[i]);
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
sensorIdRef.current = -1;
|
|
38
|
+
}, [name, status, mjModelRef]);
|
|
39
|
+
|
|
40
|
+
// Update every frame after physics step
|
|
41
|
+
useAfterPhysicsStep((_model, data) => {
|
|
42
|
+
if (sensorIdRef.current < 0) return;
|
|
43
|
+
const adr = sensorAdrRef.current;
|
|
44
|
+
const dim = sensorDimRef.current;
|
|
45
|
+
for (let i = 0; i < dim; i++) {
|
|
46
|
+
valueRef.current[i] = data.sensordata[adr + i];
|
|
47
|
+
}
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
return { value: valueRef, size: sensorDimRef.current };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Enumerate all sensors in the loaded MuJoCo model.
|
|
55
|
+
* Returns a stable array recomputed only when the model changes.
|
|
56
|
+
*/
|
|
57
|
+
export function useSensors(): SensorInfo[] {
|
|
58
|
+
const { mjModelRef, status } = useMujocoSim();
|
|
59
|
+
|
|
60
|
+
return useMemo(() => {
|
|
61
|
+
const model = mjModelRef.current;
|
|
62
|
+
if (!model || status !== 'ready') return [];
|
|
63
|
+
const SENSOR_TYPE_NAMES: Record<number, string> = {
|
|
64
|
+
0: 'touch', 1: 'accelerometer', 2: 'velocimeter', 3: 'gyro',
|
|
65
|
+
4: 'force', 5: 'torque', 6: 'magnetometer', 7: 'rangefinder',
|
|
66
|
+
8: 'jointpos', 9: 'jointvel', 10: 'tendonpos', 11: 'tendonvel',
|
|
67
|
+
12: 'actuatorpos', 13: 'actuatorvel', 14: 'actuatorfrc',
|
|
68
|
+
};
|
|
69
|
+
const result: SensorInfo[] = [];
|
|
70
|
+
for (let i = 0; i < model.nsensor; i++) {
|
|
71
|
+
const type = model.sensor_type[i];
|
|
72
|
+
result.push({
|
|
73
|
+
id: i,
|
|
74
|
+
name: getName(model, model.name_sensoradr[i]),
|
|
75
|
+
type,
|
|
76
|
+
typeName: SENSOR_TYPE_NAMES[type] ?? `unknown(${type})`,
|
|
77
|
+
dim: model.sensor_dim[i],
|
|
78
|
+
adr: model.sensor_adr[i],
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
return result;
|
|
82
|
+
}, [mjModelRef, status]);
|
|
83
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { useEffect, useRef } from 'react';
|
|
7
|
+
import { useFrame } from '@react-three/fiber';
|
|
8
|
+
import * as THREE from 'three';
|
|
9
|
+
import { useMujocoSim } from '../core/MujocoSimProvider';
|
|
10
|
+
import { findSiteByName } from '../core/SceneLoader';
|
|
11
|
+
import type { SitePositionResult } from '../types';
|
|
12
|
+
|
|
13
|
+
// Preallocated temp for rotation matrix extraction
|
|
14
|
+
const _mat4 = new THREE.Matrix4();
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Returns reactive refs for a MuJoCo site's world position and orientation.
|
|
18
|
+
* Refs are updated every frame without triggering React re-renders.
|
|
19
|
+
*/
|
|
20
|
+
export function useSitePosition(siteName: string): SitePositionResult {
|
|
21
|
+
const { mjModelRef, mjDataRef, status } = useMujocoSim();
|
|
22
|
+
const siteIdRef = useRef(-1);
|
|
23
|
+
const positionRef = useRef(new THREE.Vector3());
|
|
24
|
+
const quaternionRef = useRef(new THREE.Quaternion());
|
|
25
|
+
|
|
26
|
+
// Resolve site ID when model is ready
|
|
27
|
+
useEffect(() => {
|
|
28
|
+
const model = mjModelRef.current;
|
|
29
|
+
if (!model || status !== 'ready') {
|
|
30
|
+
siteIdRef.current = -1;
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
siteIdRef.current = findSiteByName(model, siteName);
|
|
34
|
+
}, [siteName, status, mjModelRef]);
|
|
35
|
+
|
|
36
|
+
// Update refs every frame
|
|
37
|
+
useFrame(() => {
|
|
38
|
+
const data = mjDataRef.current;
|
|
39
|
+
const sid = siteIdRef.current;
|
|
40
|
+
if (!data || sid < 0) return;
|
|
41
|
+
|
|
42
|
+
const i3 = sid * 3;
|
|
43
|
+
const i9 = sid * 9;
|
|
44
|
+
|
|
45
|
+
positionRef.current.set(
|
|
46
|
+
data.site_xpos[i3],
|
|
47
|
+
data.site_xpos[i3 + 1],
|
|
48
|
+
data.site_xpos[i3 + 2]
|
|
49
|
+
);
|
|
50
|
+
|
|
51
|
+
const m = data.site_xmat;
|
|
52
|
+
_mat4.set(
|
|
53
|
+
m[i9], m[i9 + 1], m[i9 + 2], 0,
|
|
54
|
+
m[i9 + 3], m[i9 + 4], m[i9 + 5], 0,
|
|
55
|
+
m[i9 + 6], m[i9 + 7], m[i9 + 8], 0,
|
|
56
|
+
0, 0, 0, 1,
|
|
57
|
+
);
|
|
58
|
+
quaternionRef.current.setFromRotationMatrix(_mat4);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
return { position: positionRef, quaternion: quaternionRef };
|
|
62
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*
|
|
5
|
+
* useTrajectoryPlayer — trajectory playback/scrubbing (spec 13.2)
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { useCallback, useRef } from 'react';
|
|
9
|
+
import { useFrame } from '@react-three/fiber';
|
|
10
|
+
import { useMujocoSim } from '../core/MujocoSimProvider';
|
|
11
|
+
|
|
12
|
+
interface TrajectoryPlayerOptions {
|
|
13
|
+
fps?: number;
|
|
14
|
+
loop?: boolean;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Play back a sequence of qpos frames, overriding simulation state.
|
|
19
|
+
*
|
|
20
|
+
* When playing, the simulation is effectively paused and qpos is set
|
|
21
|
+
* from the trajectory each render frame at the specified FPS.
|
|
22
|
+
*/
|
|
23
|
+
export function useTrajectoryPlayer(
|
|
24
|
+
trajectory: number[][],
|
|
25
|
+
options: TrajectoryPlayerOptions = {},
|
|
26
|
+
) {
|
|
27
|
+
const { mjModelRef, mjDataRef, mujocoRef, pausedRef } = useMujocoSim();
|
|
28
|
+
const fps = options.fps ?? 30;
|
|
29
|
+
const loop = options.loop ?? false;
|
|
30
|
+
|
|
31
|
+
const playingRef = useRef(false);
|
|
32
|
+
const frameRef = useRef(0);
|
|
33
|
+
const lastFrameTimeRef = useRef(0);
|
|
34
|
+
|
|
35
|
+
const play = useCallback(() => {
|
|
36
|
+
playingRef.current = true;
|
|
37
|
+
pausedRef.current = true; // Pause sim during playback
|
|
38
|
+
lastFrameTimeRef.current = performance.now();
|
|
39
|
+
}, [pausedRef]);
|
|
40
|
+
|
|
41
|
+
const pause = useCallback(() => {
|
|
42
|
+
playingRef.current = false;
|
|
43
|
+
}, []);
|
|
44
|
+
|
|
45
|
+
const seek = useCallback((frameIdx: number) => {
|
|
46
|
+
frameRef.current = Math.max(0, Math.min(frameIdx, trajectory.length - 1));
|
|
47
|
+
const model = mjModelRef.current;
|
|
48
|
+
const data = mjDataRef.current;
|
|
49
|
+
if (!model || !data || !trajectory[frameRef.current]) return;
|
|
50
|
+
const qpos = trajectory[frameRef.current];
|
|
51
|
+
for (let i = 0; i < Math.min(qpos.length, model.nq); i++) {
|
|
52
|
+
data.qpos[i] = qpos[i];
|
|
53
|
+
}
|
|
54
|
+
mujocoRef.current.mj_forward(model, data);
|
|
55
|
+
}, [trajectory, mjModelRef, mjDataRef, mujocoRef]);
|
|
56
|
+
|
|
57
|
+
const reset = useCallback(() => {
|
|
58
|
+
frameRef.current = 0;
|
|
59
|
+
playingRef.current = false;
|
|
60
|
+
pausedRef.current = false;
|
|
61
|
+
}, [pausedRef]);
|
|
62
|
+
|
|
63
|
+
useFrame(() => {
|
|
64
|
+
if (!playingRef.current || trajectory.length === 0) return;
|
|
65
|
+
|
|
66
|
+
const now = performance.now();
|
|
67
|
+
const elapsed = now - lastFrameTimeRef.current;
|
|
68
|
+
const frameInterval = 1000 / fps;
|
|
69
|
+
|
|
70
|
+
if (elapsed < frameInterval) return;
|
|
71
|
+
lastFrameTimeRef.current = now;
|
|
72
|
+
|
|
73
|
+
const model = mjModelRef.current;
|
|
74
|
+
const data = mjDataRef.current;
|
|
75
|
+
if (!model || !data) return;
|
|
76
|
+
|
|
77
|
+
const qpos = trajectory[frameRef.current];
|
|
78
|
+
if (!qpos) return;
|
|
79
|
+
|
|
80
|
+
for (let i = 0; i < Math.min(qpos.length, model.nq); i++) {
|
|
81
|
+
data.qpos[i] = qpos[i];
|
|
82
|
+
}
|
|
83
|
+
mujocoRef.current.mj_forward(model, data);
|
|
84
|
+
|
|
85
|
+
frameRef.current++;
|
|
86
|
+
if (frameRef.current >= trajectory.length) {
|
|
87
|
+
if (loop) {
|
|
88
|
+
frameRef.current = 0;
|
|
89
|
+
} else {
|
|
90
|
+
playingRef.current = false;
|
|
91
|
+
pausedRef.current = false;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
return {
|
|
97
|
+
play,
|
|
98
|
+
pause,
|
|
99
|
+
seek,
|
|
100
|
+
reset,
|
|
101
|
+
get frame() { return frameRef.current; },
|
|
102
|
+
get playing() { return playingRef.current; },
|
|
103
|
+
get totalFrames() { return trajectory.length; },
|
|
104
|
+
};
|
|
105
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*
|
|
5
|
+
* useTrajectoryRecorder — trajectory recording hook (spec 13.1)
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { useCallback, useRef } from 'react';
|
|
9
|
+
import { useMujocoSim, useAfterPhysicsStep } from '../core/MujocoSimProvider';
|
|
10
|
+
import type { TrajectoryFrame } from '../types';
|
|
11
|
+
|
|
12
|
+
interface RecorderOptions {
|
|
13
|
+
fields?: ('qpos' | 'qvel' | 'ctrl' | 'sensordata')[];
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Record simulation trajectories for analysis, replay, or training data.
|
|
18
|
+
*/
|
|
19
|
+
export function useTrajectoryRecorder(options: RecorderOptions = {}) {
|
|
20
|
+
const { mjModelRef } = useMujocoSim();
|
|
21
|
+
const recordingRef = useRef(false);
|
|
22
|
+
const framesRef = useRef<TrajectoryFrame[]>([]);
|
|
23
|
+
const fields = options.fields ?? ['qpos'];
|
|
24
|
+
|
|
25
|
+
useAfterPhysicsStep((_model, data) => {
|
|
26
|
+
if (!recordingRef.current) return;
|
|
27
|
+
|
|
28
|
+
const frame: TrajectoryFrame = {
|
|
29
|
+
time: data.time,
|
|
30
|
+
qpos: new Float64Array(data.qpos),
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
if (fields.includes('qvel')) frame.qvel = new Float64Array(data.qvel);
|
|
34
|
+
if (fields.includes('ctrl')) frame.ctrl = new Float64Array(data.ctrl);
|
|
35
|
+
if (fields.includes('sensordata') && data.sensordata) {
|
|
36
|
+
frame.sensordata = new Float64Array(data.sensordata);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
framesRef.current.push(frame);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
const start = useCallback(() => {
|
|
43
|
+
framesRef.current = [];
|
|
44
|
+
recordingRef.current = true;
|
|
45
|
+
}, []);
|
|
46
|
+
|
|
47
|
+
const stop = useCallback(() => {
|
|
48
|
+
recordingRef.current = false;
|
|
49
|
+
return framesRef.current;
|
|
50
|
+
}, []);
|
|
51
|
+
|
|
52
|
+
const downloadJSON = useCallback(() => {
|
|
53
|
+
const frames = framesRef.current;
|
|
54
|
+
const data = frames.map(f => ({
|
|
55
|
+
time: f.time,
|
|
56
|
+
qpos: Array.from(f.qpos),
|
|
57
|
+
...(f.qvel ? { qvel: Array.from(f.qvel) } : {}),
|
|
58
|
+
...(f.ctrl ? { ctrl: Array.from(f.ctrl) } : {}),
|
|
59
|
+
...(f.sensordata ? { sensordata: Array.from(f.sensordata) } : {}),
|
|
60
|
+
}));
|
|
61
|
+
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
|
|
62
|
+
const url = URL.createObjectURL(blob);
|
|
63
|
+
const a = document.createElement('a');
|
|
64
|
+
a.href = url;
|
|
65
|
+
a.download = 'trajectory.json';
|
|
66
|
+
a.click();
|
|
67
|
+
URL.revokeObjectURL(url);
|
|
68
|
+
}, []);
|
|
69
|
+
|
|
70
|
+
const downloadCSV = useCallback(() => {
|
|
71
|
+
const frames = framesRef.current;
|
|
72
|
+
if (frames.length === 0) return;
|
|
73
|
+
const nq = frames[0].qpos.length;
|
|
74
|
+
const headers = ['time', ...Array.from({ length: nq }, (_, i) => `qpos_${i}`)];
|
|
75
|
+
const rows = frames.map(f =>
|
|
76
|
+
[f.time, ...Array.from(f.qpos)].join(',')
|
|
77
|
+
);
|
|
78
|
+
const csv = [headers.join(','), ...rows].join('\n');
|
|
79
|
+
const blob = new Blob([csv], { type: 'text/csv' });
|
|
80
|
+
const url = URL.createObjectURL(blob);
|
|
81
|
+
const a = document.createElement('a');
|
|
82
|
+
a.href = url;
|
|
83
|
+
a.download = 'trajectory.csv';
|
|
84
|
+
a.click();
|
|
85
|
+
URL.revokeObjectURL(url);
|
|
86
|
+
}, []);
|
|
87
|
+
|
|
88
|
+
return {
|
|
89
|
+
start,
|
|
90
|
+
stop,
|
|
91
|
+
downloadJSON,
|
|
92
|
+
downloadCSV,
|
|
93
|
+
get recording() { return recordingRef.current; },
|
|
94
|
+
get frameCount() { return framesRef.current.length; },
|
|
95
|
+
get frames() { return framesRef.current; },
|
|
96
|
+
};
|
|
97
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*
|
|
5
|
+
* useVideoRecorder — canvas video recording hook (spec 13.3)
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { useCallback, useRef } from 'react';
|
|
9
|
+
import { useThree } from '@react-three/fiber';
|
|
10
|
+
|
|
11
|
+
interface VideoRecorderOptions {
|
|
12
|
+
fps?: number;
|
|
13
|
+
mimeType?: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Record the R3F canvas to a video file using MediaRecorder.
|
|
18
|
+
*/
|
|
19
|
+
export function useVideoRecorder(options: VideoRecorderOptions = {}) {
|
|
20
|
+
const { gl } = useThree();
|
|
21
|
+
const recorderRef = useRef<MediaRecorder | null>(null);
|
|
22
|
+
const chunksRef = useRef<Blob[]>([]);
|
|
23
|
+
const recordingRef = useRef(false);
|
|
24
|
+
|
|
25
|
+
const start = useCallback(() => {
|
|
26
|
+
const canvas = gl.domElement;
|
|
27
|
+
const fps = options.fps ?? 30;
|
|
28
|
+
const mimeType = options.mimeType ?? 'video/webm';
|
|
29
|
+
|
|
30
|
+
const stream = canvas.captureStream(fps);
|
|
31
|
+
const recorder = new MediaRecorder(stream, {
|
|
32
|
+
mimeType: MediaRecorder.isTypeSupported(mimeType) ? mimeType : 'video/webm',
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
chunksRef.current = [];
|
|
36
|
+
recorder.ondataavailable = (e) => {
|
|
37
|
+
if (e.data.size > 0) chunksRef.current.push(e.data);
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
recorder.start();
|
|
41
|
+
recorderRef.current = recorder;
|
|
42
|
+
recordingRef.current = true;
|
|
43
|
+
}, [gl, options.fps, options.mimeType]);
|
|
44
|
+
|
|
45
|
+
const stop = useCallback((): Promise<Blob> => {
|
|
46
|
+
return new Promise((resolve) => {
|
|
47
|
+
const recorder = recorderRef.current;
|
|
48
|
+
if (!recorder || recorder.state === 'inactive') {
|
|
49
|
+
resolve(new Blob([]));
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
recorder.onstop = () => {
|
|
54
|
+
const blob = new Blob(chunksRef.current, { type: recorder.mimeType });
|
|
55
|
+
chunksRef.current = [];
|
|
56
|
+
recordingRef.current = false;
|
|
57
|
+
recorderRef.current = null;
|
|
58
|
+
resolve(blob);
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
recorder.stop();
|
|
62
|
+
});
|
|
63
|
+
}, []);
|
|
64
|
+
|
|
65
|
+
const download = useCallback(async (filename = 'recording.webm') => {
|
|
66
|
+
const blob = await stop();
|
|
67
|
+
if (blob.size === 0) return;
|
|
68
|
+
const url = URL.createObjectURL(blob);
|
|
69
|
+
const a = document.createElement('a');
|
|
70
|
+
a.href = url;
|
|
71
|
+
a.download = filename;
|
|
72
|
+
a.click();
|
|
73
|
+
URL.revokeObjectURL(url);
|
|
74
|
+
}, [stop]);
|
|
75
|
+
|
|
76
|
+
return {
|
|
77
|
+
start,
|
|
78
|
+
stop,
|
|
79
|
+
download,
|
|
80
|
+
get recording() { return recordingRef.current; },
|
|
81
|
+
};
|
|
82
|
+
}
|