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.
Files changed (42) hide show
  1. package/LICENSE +177 -0
  2. package/README.md +510 -0
  3. package/dist/index.d.ts +1080 -0
  4. package/dist/index.js +3518 -0
  5. package/dist/index.js.map +1 -0
  6. package/package.json +64 -0
  7. package/src/components/ContactListener.tsx +26 -0
  8. package/src/components/ContactMarkers.tsx +81 -0
  9. package/src/components/Debug.tsx +227 -0
  10. package/src/components/DragInteraction.tsx +227 -0
  11. package/src/components/FlexRenderer.tsx +102 -0
  12. package/src/components/IkGizmo.tsx +146 -0
  13. package/src/components/SceneLights.tsx +131 -0
  14. package/src/components/SceneRenderer.tsx +104 -0
  15. package/src/components/SelectionHighlight.tsx +69 -0
  16. package/src/components/TendonRenderer.tsx +84 -0
  17. package/src/components/TrajectoryPlayer.tsx +44 -0
  18. package/src/core/GenericIK.ts +339 -0
  19. package/src/core/MujocoCanvas.tsx +72 -0
  20. package/src/core/MujocoProvider.tsx +78 -0
  21. package/src/core/MujocoSimProvider.tsx +1201 -0
  22. package/src/core/SceneLoader.ts +275 -0
  23. package/src/hooks/useActuators.ts +36 -0
  24. package/src/hooks/useBodyState.ts +56 -0
  25. package/src/hooks/useContacts.ts +125 -0
  26. package/src/hooks/useCtrl.ts +40 -0
  27. package/src/hooks/useCtrlNoise.ts +59 -0
  28. package/src/hooks/useGamepad.ts +77 -0
  29. package/src/hooks/useGravityCompensation.ts +22 -0
  30. package/src/hooks/useJointState.ts +64 -0
  31. package/src/hooks/useKeyboardTeleop.ts +97 -0
  32. package/src/hooks/usePolicy.ts +56 -0
  33. package/src/hooks/useSensor.ts +83 -0
  34. package/src/hooks/useSitePosition.ts +62 -0
  35. package/src/hooks/useTrajectoryPlayer.ts +105 -0
  36. package/src/hooks/useTrajectoryRecorder.ts +97 -0
  37. package/src/hooks/useVideoRecorder.ts +82 -0
  38. package/src/index.ts +108 -0
  39. package/src/rendering/CapsuleGeometry.ts +35 -0
  40. package/src/rendering/GeomBuilder.ts +140 -0
  41. package/src/rendering/Reflector.ts +225 -0
  42. 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
+ }