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,1201 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ import { useFrame, useThree } from '@react-three/fiber';
7
+ import {
8
+ createContext,
9
+ useCallback,
10
+ useContext,
11
+ useEffect,
12
+ useMemo,
13
+ useRef,
14
+ useState,
15
+ } from 'react';
16
+ import * as THREE from 'three';
17
+ import { MujocoData, MujocoModel, MujocoModule } from '../types';
18
+ import { GenericIK } from './GenericIK';
19
+ import {
20
+ ActuatorInfo,
21
+ BodyInfo,
22
+ ContactInfo,
23
+ GeomInfo,
24
+ IKSolveFn,
25
+ JointInfo,
26
+ ModelOptions,
27
+ MujocoSimAPI,
28
+ PhysicsStepCallback,
29
+ RayHit,
30
+ SceneConfig,
31
+ SensorInfo,
32
+ SiteInfo,
33
+ StateSnapshot,
34
+ } from '../types';
35
+ import {
36
+ loadScene,
37
+ findKeyframeByName,
38
+ findBodyByName,
39
+ findGeomByName,
40
+ findSensorByName,
41
+ findActuatorByName,
42
+ getName,
43
+ } from './SceneLoader';
44
+
45
+ // ---- Joint type names ----
46
+ const JOINT_TYPE_NAMES = ['free', 'ball', 'slide', 'hinge'];
47
+ // ---- Geom type names ----
48
+ const GEOM_TYPE_NAMES = ['plane', 'hfield', 'sphere', 'capsule', 'ellipsoid', 'cylinder', 'box', 'mesh'];
49
+ // ---- Sensor type names (subset — MuJoCo has many) ----
50
+ // Sensor type names matching mjtSensor enum in mujoco WASM (mujoco-js 0.0.7)
51
+ const SENSOR_TYPE_NAMES: Record<number, string> = {
52
+ 0: 'touch', 1: 'accelerometer', 2: 'velocimeter', 3: 'gyro',
53
+ 4: 'force', 5: 'torque', 6: 'magnetometer', 7: 'rangefinder',
54
+ 8: 'camprojection', 9: 'jointpos', 10: 'jointvel', 11: 'tendonpos',
55
+ 12: 'tendonvel', 13: 'actuatorpos', 14: 'actuatorvel', 15: 'actuatorfrc',
56
+ 16: 'jointactfrc', 17: 'tendonactfrc', 18: 'ballquat', 19: 'ballangvel',
57
+ 20: 'jointlimitpos', 21: 'jointlimitvel', 22: 'jointlimitfrc',
58
+ 23: 'tendonlimitpos', 24: 'tendonlimitvel', 25: 'tendonlimitfrc',
59
+ 26: 'framepos', 27: 'framequat', 28: 'framexaxis', 29: 'frameyaxis',
60
+ 30: 'framezaxis', 31: 'framelinvel', 32: 'frameangvel',
61
+ 33: 'framelinacc', 34: 'frameangacc', 35: 'subtreecom',
62
+ 36: 'subtreelinvel', 37: 'subtreeangmom', 38: 'insidesite',
63
+ 39: 'geomdist', 40: 'geomnormal', 41: 'geomfromto',
64
+ 42: 'contact', 43: 'e_potential', 44: 'e_kinetic',
65
+ 45: 'clock', 46: 'tactile', 47: 'plugin', 48: 'user',
66
+ };
67
+
68
+ // Preallocated force/torque temps for applyForce/applyTorque
69
+ const _applyForce = new Float64Array(3);
70
+ const _applyTorque = new Float64Array(3);
71
+ const _applyPoint = new Float64Array(3);
72
+ const _rayPnt = new Float64Array(3);
73
+ const _rayVec = new Float64Array(3);
74
+ const _rayGeomId = new Int32Array(1);
75
+
76
+ // ---- Internal context types ----
77
+
78
+ export interface MujocoSimContextValue {
79
+ api: MujocoSimAPI;
80
+ mjModelRef: React.RefObject<MujocoModel | null>;
81
+ mjDataRef: React.RefObject<MujocoData | null>;
82
+ mujocoRef: React.RefObject<MujocoModule>;
83
+ configRef: React.RefObject<SceneConfig>;
84
+ siteIdRef: React.RefObject<number>;
85
+ gripperIdRef: React.RefObject<number>;
86
+ ikEnabledRef: React.RefObject<boolean>;
87
+ ikCalculatingRef: React.RefObject<boolean>;
88
+ pausedRef: React.RefObject<boolean>;
89
+ speedRef: React.RefObject<number>;
90
+ substepsRef: React.RefObject<number>;
91
+ ikTargetRef: React.RefObject<THREE.Group>;
92
+ genericIkRef: React.RefObject<GenericIK>;
93
+ ikSolveFnRef: React.RefObject<IKSolveFn>;
94
+ firstIkEnableRef: React.RefObject<boolean>;
95
+ gizmoAnimRef: React.RefObject<{
96
+ active: boolean;
97
+ startPos: THREE.Vector3;
98
+ endPos: THREE.Vector3;
99
+ startRot: THREE.Quaternion;
100
+ endRot: THREE.Quaternion;
101
+ startTime: number;
102
+ duration: number;
103
+ }>;
104
+ cameraAnimRef: React.RefObject<{
105
+ active: boolean;
106
+ startPos: THREE.Vector3;
107
+ endPos: THREE.Vector3;
108
+ startRot: THREE.Quaternion;
109
+ endRot: THREE.Quaternion;
110
+ startTarget: THREE.Vector3;
111
+ endTarget: THREE.Vector3;
112
+ startTime: number;
113
+ duration: number;
114
+ resolve: (() => void) | null;
115
+ }>;
116
+ onSelectionRef: React.RefObject<
117
+ ((bodyId: number, name: string) => void) | undefined
118
+ >;
119
+ beforeStepCallbacks: React.RefObject<Set<PhysicsStepCallback>>;
120
+ afterStepCallbacks: React.RefObject<Set<PhysicsStepCallback>>;
121
+ status: 'loading' | 'ready' | 'error';
122
+ }
123
+
124
+ const MujocoSimContext = createContext<MujocoSimContextValue | null>(null);
125
+
126
+ export function useMujocoSim(): MujocoSimContextValue {
127
+ const ctx = useContext(MujocoSimContext);
128
+ if (!ctx)
129
+ throw new Error('useMujocoSim must be used inside <MujocoSimProvider>');
130
+ return ctx;
131
+ }
132
+
133
+ export function useBeforePhysicsStep(callback: PhysicsStepCallback) {
134
+ const { beforeStepCallbacks } = useMujocoSim();
135
+ const callbackRef = useRef(callback);
136
+ callbackRef.current = callback;
137
+
138
+ useEffect(() => {
139
+ const wrapped: PhysicsStepCallback = (model, data) => callbackRef.current(model, data);
140
+ beforeStepCallbacks.current.add(wrapped);
141
+ return () => { beforeStepCallbacks.current.delete(wrapped); };
142
+ }, [beforeStepCallbacks]);
143
+ }
144
+
145
+ export function useAfterPhysicsStep(callback: PhysicsStepCallback) {
146
+ const { afterStepCallbacks } = useMujocoSim();
147
+ const callbackRef = useRef(callback);
148
+ callbackRef.current = callback;
149
+
150
+ useEffect(() => {
151
+ const wrapped: PhysicsStepCallback = (model, data) => callbackRef.current(model, data);
152
+ afterStepCallbacks.current.add(wrapped);
153
+ return () => { afterStepCallbacks.current.delete(wrapped); };
154
+ }, [afterStepCallbacks]);
155
+ }
156
+
157
+ interface MujocoSimProviderProps {
158
+ mujoco: MujocoModule;
159
+ config: SceneConfig;
160
+ onReady?: (api: MujocoSimAPI) => void;
161
+ onError?: (error: Error) => void;
162
+ onStep?: (time: number) => void;
163
+ onSelection?: (bodyId: number, name: string) => void;
164
+ // Declarative physics config props (spec 1.1)
165
+ gravity?: [number, number, number];
166
+ timestep?: number;
167
+ substeps?: number;
168
+ paused?: boolean;
169
+ speed?: number;
170
+ interpolate?: boolean;
171
+ children: React.ReactNode;
172
+ }
173
+
174
+ export function MujocoSimProvider({
175
+ mujoco,
176
+ config,
177
+ onReady,
178
+ onError,
179
+ onStep,
180
+ onSelection,
181
+ gravity,
182
+ timestep,
183
+ substeps,
184
+ paused,
185
+ speed,
186
+ interpolate,
187
+ children,
188
+ }: MujocoSimProviderProps) {
189
+ const { gl, camera } = useThree();
190
+ const [status, setStatus] = useState<'loading' | 'ready' | 'error'>('loading');
191
+
192
+ // --- Refs ---
193
+ const mjModelRef = useRef<MujocoModel | null>(null);
194
+ const mjDataRef = useRef<MujocoData | null>(null);
195
+ const mujocoRef = useRef<MujocoModule>(mujoco);
196
+ const configRef = useRef<SceneConfig>(config);
197
+ const siteIdRef = useRef(-1);
198
+ const gripperIdRef = useRef(-1);
199
+ const ikEnabledRef = useRef(false);
200
+ const ikCalculatingRef = useRef(false);
201
+ const pausedRef = useRef(paused ?? false);
202
+ const speedRef = useRef(speed ?? 1);
203
+ const substepsRef = useRef(substeps ?? 1);
204
+ const interpolateRef = useRef(interpolate ?? false);
205
+ const firstIkEnableRef = useRef(true);
206
+ const stepsToRunRef = useRef(0); // for single-step mode (spec 1.2)
207
+
208
+ // Interpolation state (spec 11.1)
209
+ const prevXposRef = useRef<Float64Array | null>(null);
210
+ const prevXquatRef = useRef<Float64Array | null>(null);
211
+ const interpAlphaRef = useRef(0);
212
+
213
+ const onSelectionRef = useRef(onSelection);
214
+ onSelectionRef.current = onSelection;
215
+ const onStepRef = useRef(onStep);
216
+ onStepRef.current = onStep;
217
+
218
+ const beforeStepCallbacks = useRef(new Set<PhysicsStepCallback>());
219
+ const afterStepCallbacks = useRef(new Set<PhysicsStepCallback>());
220
+
221
+ configRef.current = config;
222
+
223
+ // Sync declarative props to refs
224
+ useEffect(() => { pausedRef.current = paused ?? false; }, [paused]);
225
+ useEffect(() => { speedRef.current = speed ?? 1; }, [speed]);
226
+ useEffect(() => { substepsRef.current = substeps ?? 1; }, [substeps]);
227
+ useEffect(() => { interpolateRef.current = interpolate ?? false; }, [interpolate]);
228
+
229
+ // Sync gravity prop (spec 1.1)
230
+ useEffect(() => {
231
+ if (!gravity) return;
232
+ const model = mjModelRef.current;
233
+ if (!model?.opt?.gravity) return;
234
+ model.opt.gravity[0] = gravity[0];
235
+ model.opt.gravity[1] = gravity[1];
236
+ model.opt.gravity[2] = gravity[2];
237
+ }, [gravity]);
238
+
239
+ // Sync timestep prop (spec 1.1)
240
+ useEffect(() => {
241
+ if (timestep === undefined) return;
242
+ const model = mjModelRef.current;
243
+ if (!model?.opt) return;
244
+ model.opt.timestep = timestep;
245
+ }, [timestep]);
246
+
247
+ const ikTargetRef = useRef<THREE.Group>(new THREE.Group());
248
+ const genericIkRef = useRef<GenericIK>(new GenericIK(mujoco));
249
+
250
+ const gizmoAnimRef = useRef({
251
+ active: false,
252
+ startPos: new THREE.Vector3(),
253
+ endPos: new THREE.Vector3(),
254
+ startRot: new THREE.Quaternion(),
255
+ endRot: new THREE.Quaternion(),
256
+ startTime: 0,
257
+ duration: 1000,
258
+ });
259
+
260
+ const cameraAnimRef = useRef({
261
+ active: false,
262
+ startPos: new THREE.Vector3(),
263
+ endPos: new THREE.Vector3(),
264
+ startRot: new THREE.Quaternion(),
265
+ endRot: new THREE.Quaternion(),
266
+ startTarget: new THREE.Vector3(),
267
+ endTarget: new THREE.Vector3(),
268
+ startTime: 0,
269
+ duration: 0,
270
+ resolve: null as (() => void) | null,
271
+ });
272
+
273
+ const orbitTargetRef = useRef(new THREE.Vector3(0, 0, 0));
274
+
275
+ // --- Helper: sync gizmo to actual MuJoCo site position ---
276
+ const syncGizmoToSite = useCallback((data: MujocoData, siteId: number, target: THREE.Group) => {
277
+ if (siteId === -1) return;
278
+ const sitePos = data.site_xpos.subarray(siteId * 3, siteId * 3 + 3);
279
+ const siteMat = data.site_xmat.subarray(siteId * 9, siteId * 9 + 9);
280
+ target.position.set(sitePos[0], sitePos[1], sitePos[2]);
281
+ const m = new THREE.Matrix4().set(
282
+ siteMat[0], siteMat[1], siteMat[2], 0,
283
+ siteMat[3], siteMat[4], siteMat[5], 0,
284
+ siteMat[6], siteMat[7], siteMat[8], 0,
285
+ 0, 0, 0, 1
286
+ );
287
+ target.quaternion.setFromRotationMatrix(m);
288
+ }, []);
289
+
290
+ // IK solve function
291
+ const ikSolveFn = useCallback(
292
+ (pos: THREE.Vector3, quat: THREE.Quaternion, currentQ: number[]): number[] | null => {
293
+ const model = mjModelRef.current;
294
+ const data = mjDataRef.current;
295
+ if (!model || !data || siteIdRef.current === -1) return null;
296
+ return genericIkRef.current.solve(
297
+ model, data, siteIdRef.current,
298
+ configRef.current.numArmJoints ?? 7,
299
+ pos, quat, currentQ
300
+ );
301
+ },
302
+ []
303
+ );
304
+ const ikSolveFnRef = useRef<IKSolveFn>(ikSolveFn);
305
+ ikSolveFnRef.current = ikSolveFn;
306
+
307
+ // --- Load scene on mount ---
308
+ useEffect(() => {
309
+ let disposed = false;
310
+
311
+ (async () => {
312
+ try {
313
+ const result = await loadScene(mujoco, config);
314
+ if (disposed) {
315
+ result.mjModel.delete();
316
+ result.mjData.delete();
317
+ return;
318
+ }
319
+
320
+ mjModelRef.current = result.mjModel;
321
+ mjDataRef.current = result.mjData;
322
+ siteIdRef.current = result.siteId;
323
+ gripperIdRef.current = result.gripperId;
324
+
325
+ // Apply declarative physics props after load
326
+ if (gravity && result.mjModel.opt?.gravity) {
327
+ result.mjModel.opt.gravity[0] = gravity[0];
328
+ result.mjModel.opt.gravity[1] = gravity[1];
329
+ result.mjModel.opt.gravity[2] = gravity[2];
330
+ }
331
+ if (timestep !== undefined && result.mjModel.opt) {
332
+ result.mjModel.opt.timestep = timestep;
333
+ }
334
+
335
+ if (ikTargetRef.current) {
336
+ syncGizmoToSite(result.mjData, result.siteId, ikTargetRef.current);
337
+ }
338
+
339
+ setStatus('ready');
340
+ } catch (e: unknown) {
341
+ if (!disposed) {
342
+ setStatus('error');
343
+ onError?.(e instanceof Error ? e : new Error(String(e)));
344
+ }
345
+ }
346
+ })();
347
+
348
+ return () => {
349
+ disposed = true;
350
+ mjModelRef.current?.delete();
351
+ mjDataRef.current?.delete();
352
+ mjModelRef.current = null;
353
+ mjDataRef.current = null;
354
+ try { mujoco.FS.unmount('/working'); } catch { /* ignore */ }
355
+ };
356
+ }, [mujoco, config]);
357
+
358
+ // Fire onReady when status changes to ready
359
+ useEffect(() => {
360
+ if (status === 'ready' && onReady) {
361
+ onReady(apiRef.current);
362
+ }
363
+ }, [status]);
364
+
365
+ // --- Physics step (priority -1) ---
366
+ useFrame((state) => {
367
+ const model = mjModelRef.current;
368
+ const data = mjDataRef.current;
369
+ if (!model || !data) return;
370
+
371
+ // Gizmo animation
372
+ const ga = gizmoAnimRef.current;
373
+ const target = ikTargetRef.current;
374
+ if (ga.active && target) {
375
+ const now = performance.now();
376
+ const elapsed = now - ga.startTime;
377
+ const t = Math.min(elapsed / ga.duration, 1.0);
378
+ const ease = 1 - Math.pow(1 - t, 3);
379
+ target.position.lerpVectors(ga.startPos, ga.endPos, ease);
380
+ target.quaternion.slerpQuaternions(ga.startRot, ga.endRot, ease);
381
+ if (t >= 1.0) ga.active = false;
382
+ }
383
+
384
+ // Camera animation
385
+ const ca = cameraAnimRef.current;
386
+ if (ca.active) {
387
+ const now = performance.now();
388
+ const progress = Math.min((now - ca.startTime) / ca.duration, 1.0);
389
+ const ease =
390
+ progress < 0.5
391
+ ? 4 * progress * progress * progress
392
+ : 1 - Math.pow(-2 * progress + 2, 3) / 2;
393
+ camera.position.lerpVectors(ca.startPos, ca.endPos, ease);
394
+ camera.quaternion.slerpQuaternions(ca.startRot, ca.endRot, ease);
395
+ orbitTargetRef.current.lerpVectors(ca.startTarget, ca.endTarget, ease);
396
+ const orbitControls = state.controls as { target?: THREE.Vector3 };
397
+ if (orbitControls?.target) {
398
+ orbitControls.target.copy(orbitTargetRef.current);
399
+ }
400
+ if (progress >= 1.0) {
401
+ ca.active = false;
402
+ camera.position.copy(ca.endPos);
403
+ camera.quaternion.copy(ca.endRot);
404
+ orbitTargetRef.current.copy(ca.endTarget);
405
+ ca.resolve?.();
406
+ ca.resolve = null;
407
+ }
408
+ }
409
+
410
+ // Check single-step mode (spec 1.2)
411
+ const shouldStep = !pausedRef.current || stepsToRunRef.current > 0;
412
+ if (!shouldStep) return;
413
+
414
+ // Zero generalized applied forces
415
+ for (let i = 0; i < model.nv; i++) {
416
+ data.qfrc_applied[i] = 0;
417
+ }
418
+
419
+ // Before-step callbacks
420
+ for (const cb of beforeStepCallbacks.current) {
421
+ cb(model, data);
422
+ }
423
+
424
+ // IK
425
+ if (ikEnabledRef.current && target) {
426
+ ikCalculatingRef.current = true;
427
+ const numArm = configRef.current.numArmJoints ?? 7;
428
+ const currentQ: number[] = [];
429
+ for (let i = 0; i < numArm; i++) currentQ.push(data.qpos[i]);
430
+ const solution = ikSolveFnRef.current(target.position, target.quaternion, currentQ);
431
+ if (solution) {
432
+ for (let i = 0; i < numArm; i++) data.ctrl[i] = solution[i];
433
+ }
434
+ } else {
435
+ ikCalculatingRef.current = false;
436
+ }
437
+
438
+ // Step physics with substeps (spec 1.1)
439
+ const numSubsteps = substepsRef.current;
440
+ if (stepsToRunRef.current > 0) {
441
+ // Single-step mode (spec 1.2)
442
+ for (let s = 0; s < stepsToRunRef.current; s++) {
443
+ mujoco.mj_step(model, data);
444
+ }
445
+ stepsToRunRef.current = 0;
446
+ } else {
447
+ const startSimTime = data.time;
448
+ const frameTime = (1.0 / 60.0) * speedRef.current;
449
+ while (data.time - startSimTime < frameTime) {
450
+ for (let s = 0; s < numSubsteps; s++) {
451
+ mujoco.mj_step(model, data);
452
+ }
453
+ }
454
+ }
455
+
456
+ // After-step callbacks
457
+ for (const cb of afterStepCallbacks.current) {
458
+ cb(model, data);
459
+ }
460
+
461
+ onStepRef.current?.(data.time);
462
+ }, -1);
463
+
464
+ // --- API Methods ---
465
+
466
+ const reset = useCallback(() => {
467
+ const model = mjModelRef.current;
468
+ const data = mjDataRef.current;
469
+ if (!model || !data) return;
470
+
471
+ gizmoAnimRef.current.active = false;
472
+ mujoco.mj_resetData(model, data);
473
+
474
+ const homeJoints = configRef.current.homeJoints;
475
+ if (homeJoints) {
476
+ for (let i = 0; i < homeJoints.length; i++) {
477
+ data.ctrl[i] = homeJoints[i];
478
+ if (model.actuator_trnid[2 * i + 1] === 1) {
479
+ const jointId = model.actuator_trnid[2 * i];
480
+ if (jointId >= 0 && jointId < model.njnt) {
481
+ const qposAdr = model.jnt_qposadr[jointId];
482
+ data.qpos[qposAdr] = homeJoints[i];
483
+ }
484
+ }
485
+ }
486
+ }
487
+
488
+ configRef.current.onReset?.(model, data);
489
+ mujoco.mj_forward(model, data);
490
+
491
+ if (ikTargetRef.current) {
492
+ syncGizmoToSite(data, siteIdRef.current, ikTargetRef.current);
493
+ }
494
+ firstIkEnableRef.current = true;
495
+ ikEnabledRef.current = false;
496
+ }, [mujoco, syncGizmoToSite]);
497
+
498
+ const setIkEnabled = useCallback((enabled: boolean) => {
499
+ ikEnabledRef.current = enabled;
500
+ const data = mjDataRef.current;
501
+ if (enabled && data && !gizmoAnimRef.current.active && ikTargetRef.current) {
502
+ syncGizmoToSite(data, siteIdRef.current, ikTargetRef.current);
503
+ firstIkEnableRef.current = false;
504
+ }
505
+ }, [syncGizmoToSite]);
506
+
507
+ const syncTargetToSite = useCallback(() => {
508
+ const data = mjDataRef.current;
509
+ const target = ikTargetRef.current;
510
+ if (data && target) syncGizmoToSite(data, siteIdRef.current, target);
511
+ }, [syncGizmoToSite]);
512
+
513
+ const solveIK = useCallback(
514
+ (pos: THREE.Vector3, quat: THREE.Quaternion, currentQ: number[]): number[] | null => {
515
+ return ikSolveFnRef.current(pos, quat, currentQ);
516
+ },
517
+ []
518
+ );
519
+
520
+ const moveTarget = useCallback(
521
+ (pos: THREE.Vector3, duration = 0) => {
522
+ if (!ikEnabledRef.current) setIkEnabled(true);
523
+ const target = ikTargetRef.current;
524
+ if (!target) return;
525
+
526
+ const targetPos = pos.clone();
527
+ const targetRot = new THREE.Quaternion().setFromEuler(new THREE.Euler(Math.PI, 0, 0));
528
+
529
+ if (duration > 0) {
530
+ const ga = gizmoAnimRef.current;
531
+ ga.active = true;
532
+ ga.startPos.copy(target.position);
533
+ ga.endPos.copy(targetPos);
534
+ ga.startRot.copy(target.quaternion);
535
+ ga.endRot.copy(targetRot);
536
+ ga.startTime = performance.now();
537
+ ga.duration = duration;
538
+ } else {
539
+ gizmoAnimRef.current.active = false;
540
+ target.position.copy(targetPos);
541
+ target.quaternion.copy(targetRot);
542
+ }
543
+ },
544
+ [setIkEnabled]
545
+ );
546
+
547
+ const setSpeed = useCallback((multiplier: number) => {
548
+ speedRef.current = multiplier;
549
+ }, []);
550
+
551
+ const togglePause = useCallback((): boolean => {
552
+ pausedRef.current = !pausedRef.current;
553
+ return pausedRef.current;
554
+ }, []);
555
+
556
+ // spec 1.1: declarative pause
557
+ const setPaused = useCallback((p: boolean) => {
558
+ pausedRef.current = p;
559
+ }, []);
560
+
561
+ // spec 1.2: single-step mode
562
+ const step = useCallback((n = 1) => {
563
+ stepsToRunRef.current = n;
564
+ }, []);
565
+
566
+ // spec 1.3: simulation time access
567
+ const getTime = useCallback((): number => {
568
+ return mjDataRef.current?.time ?? 0;
569
+ }, []);
570
+
571
+ const getTimestep = useCallback((): number => {
572
+ return mjModelRef.current?.opt?.timestep ?? 0.002;
573
+ }, []);
574
+
575
+ // spec 4.1: state snapshot save/restore
576
+ const saveState = useCallback((): StateSnapshot => {
577
+ const data = mjDataRef.current;
578
+ if (!data) return { time: 0, qpos: new Float64Array(0), qvel: new Float64Array(0), ctrl: new Float64Array(0), act: new Float64Array(0), qfrc_applied: new Float64Array(0) };
579
+ return {
580
+ time: data.time,
581
+ qpos: new Float64Array(data.qpos),
582
+ qvel: new Float64Array(data.qvel),
583
+ ctrl: new Float64Array(data.ctrl),
584
+ act: new Float64Array(data.act),
585
+ qfrc_applied: new Float64Array(data.qfrc_applied),
586
+ };
587
+ }, []);
588
+
589
+ const restoreState = useCallback((snapshot: StateSnapshot) => {
590
+ const model = mjModelRef.current;
591
+ const data = mjDataRef.current;
592
+ if (!model || !data) return;
593
+ data.time = snapshot.time;
594
+ data.qpos.set(snapshot.qpos);
595
+ data.qvel.set(snapshot.qvel);
596
+ data.ctrl.set(snapshot.ctrl);
597
+ if (snapshot.act.length > 0) data.act.set(snapshot.act);
598
+ data.qfrc_applied.set(snapshot.qfrc_applied);
599
+ mujoco.mj_forward(model, data);
600
+ }, [mujoco]);
601
+
602
+ // spec 4.3: qpos/qvel direct set/get
603
+ const setQpos = useCallback((values: Float64Array | number[]) => {
604
+ const model = mjModelRef.current;
605
+ const data = mjDataRef.current;
606
+ if (!model || !data) return;
607
+ const arr = values instanceof Float64Array ? values : new Float64Array(values);
608
+ data.qpos.set(arr.subarray(0, Math.min(arr.length, model.nq)));
609
+ mujoco.mj_forward(model, data);
610
+ }, [mujoco]);
611
+
612
+ const setQvel = useCallback((values: Float64Array | number[]) => {
613
+ const data = mjDataRef.current;
614
+ if (!data) return;
615
+ const arr = values instanceof Float64Array ? values : new Float64Array(values);
616
+ data.qvel.set(arr.subarray(0, Math.min(arr.length, mjModelRef.current?.nv ?? 0)));
617
+ }, []);
618
+
619
+ const getQpos = useCallback((): Float64Array => {
620
+ return mjDataRef.current ? new Float64Array(mjDataRef.current.qpos) : new Float64Array(0);
621
+ }, []);
622
+
623
+ const getQvel = useCallback((): Float64Array => {
624
+ return mjDataRef.current ? new Float64Array(mjDataRef.current.qvel) : new Float64Array(0);
625
+ }, []);
626
+
627
+ // spec 3.1: ctrl set/get
628
+ const setCtrl = useCallback((nameOrValues: string | Record<string, number>, value?: number) => {
629
+ const model = mjModelRef.current;
630
+ const data = mjDataRef.current;
631
+ if (!model || !data) return;
632
+
633
+ if (typeof nameOrValues === 'string') {
634
+ // Single actuator by name
635
+ const id = findActuatorByName(model, nameOrValues);
636
+ if (id >= 0 && value !== undefined) data.ctrl[id] = value;
637
+ } else {
638
+ // Batch: { name: value, ... }
639
+ for (const [name, val] of Object.entries(nameOrValues)) {
640
+ const id = findActuatorByName(model, name);
641
+ if (id >= 0) data.ctrl[id] = val;
642
+ }
643
+ }
644
+ }, []);
645
+
646
+ const getCtrl = useCallback((): Float64Array => {
647
+ return mjDataRef.current ? new Float64Array(mjDataRef.current.ctrl) : new Float64Array(0);
648
+ }, []);
649
+
650
+ // spec 8.1: force/torque API
651
+ const applyForce = useCallback((bodyName: string, force: THREE.Vector3, point?: THREE.Vector3) => {
652
+ const model = mjModelRef.current;
653
+ const data = mjDataRef.current;
654
+ if (!model || !data) return;
655
+ const bodyId = findBodyByName(model, bodyName);
656
+ if (bodyId < 0) return;
657
+
658
+ _applyForce[0] = force.x; _applyForce[1] = force.y; _applyForce[2] = force.z;
659
+ _applyTorque[0] = 0; _applyTorque[1] = 0; _applyTorque[2] = 0;
660
+ if (point) {
661
+ _applyPoint[0] = point.x; _applyPoint[1] = point.y; _applyPoint[2] = point.z;
662
+ } else {
663
+ const i3 = bodyId * 3;
664
+ _applyPoint[0] = data.xpos[i3]; _applyPoint[1] = data.xpos[i3 + 1]; _applyPoint[2] = data.xpos[i3 + 2];
665
+ }
666
+ mujoco.mj_applyFT(model, data, _applyForce, _applyTorque, _applyPoint, bodyId, data.qfrc_applied);
667
+ }, [mujoco]);
668
+
669
+ const applyTorqueApi = useCallback((bodyName: string, torque: THREE.Vector3) => {
670
+ const model = mjModelRef.current;
671
+ const data = mjDataRef.current;
672
+ if (!model || !data) return;
673
+ const bodyId = findBodyByName(model, bodyName);
674
+ if (bodyId < 0) return;
675
+
676
+ _applyForce[0] = 0; _applyForce[1] = 0; _applyForce[2] = 0;
677
+ _applyTorque[0] = torque.x; _applyTorque[1] = torque.y; _applyTorque[2] = torque.z;
678
+ const i3 = bodyId * 3;
679
+ _applyPoint[0] = data.xpos[i3]; _applyPoint[1] = data.xpos[i3 + 1]; _applyPoint[2] = data.xpos[i3 + 2];
680
+ mujoco.mj_applyFT(model, data, _applyForce, _applyTorque, _applyPoint, bodyId, data.qfrc_applied);
681
+ }, [mujoco]);
682
+
683
+ const setExternalForce = useCallback((bodyName: string, force: THREE.Vector3, torque: THREE.Vector3) => {
684
+ const model = mjModelRef.current;
685
+ const data = mjDataRef.current;
686
+ if (!model || !data) return;
687
+ const bodyId = findBodyByName(model, bodyName);
688
+ if (bodyId < 0) return;
689
+ const i6 = bodyId * 6;
690
+ data.xfrc_applied[i6] = torque.x; data.xfrc_applied[i6 + 1] = torque.y; data.xfrc_applied[i6 + 2] = torque.z;
691
+ data.xfrc_applied[i6 + 3] = force.x; data.xfrc_applied[i6 + 4] = force.y; data.xfrc_applied[i6 + 5] = force.z;
692
+ }, []);
693
+
694
+ const applyGeneralizedForce = useCallback((values: Float64Array | number[]) => {
695
+ const data = mjDataRef.current;
696
+ if (!data) return;
697
+ const nv = mjModelRef.current?.nv ?? 0;
698
+ for (let i = 0; i < Math.min(values.length, nv); i++) {
699
+ data.qfrc_applied[i] += values[i];
700
+ }
701
+ }, []);
702
+
703
+ // spec 2.1: sensor data
704
+ const getSensorData = useCallback((name: string): Float64Array | null => {
705
+ const model = mjModelRef.current;
706
+ const data = mjDataRef.current;
707
+ if (!model || !data) return null;
708
+ const id = findSensorByName(model, name);
709
+ if (id < 0) return null;
710
+ const adr = model.sensor_adr[id];
711
+ const dim = model.sensor_dim[id];
712
+ return new Float64Array(data.sensordata.subarray(adr, adr + dim));
713
+ }, []);
714
+
715
+ // spec 2.4: contacts
716
+ const getContacts = useCallback((): ContactInfo[] => {
717
+ const model = mjModelRef.current;
718
+ const data = mjDataRef.current;
719
+ if (!model || !data) return [];
720
+ const contacts: ContactInfo[] = [];
721
+ const ncon = data.ncon;
722
+ for (let i = 0; i < ncon; i++) {
723
+ try {
724
+ const c = (data.contact as { get(i: number): { geom1: number; geom2: number; pos: Float64Array; dist: number } }).get(i);
725
+ contacts.push({
726
+ geom1: c.geom1,
727
+ geom1Name: getName(model, model.name_geomadr[c.geom1]),
728
+ geom2: c.geom2,
729
+ geom2Name: getName(model, model.name_geomadr[c.geom2]),
730
+ pos: [c.pos[0], c.pos[1], c.pos[2]],
731
+ depth: c.dist,
732
+ });
733
+ } catch {
734
+ break; // WASM contact access can fail
735
+ }
736
+ }
737
+ return contacts;
738
+ }, []);
739
+
740
+ // spec 5.1: model introspection
741
+ const getBodies = useCallback((): BodyInfo[] => {
742
+ const model = mjModelRef.current;
743
+ if (!model) return [];
744
+ const result: BodyInfo[] = [];
745
+ for (let i = 0; i < model.nbody; i++) {
746
+ result.push({
747
+ id: i,
748
+ name: getName(model, model.name_bodyadr[i]),
749
+ mass: model.body_mass[i],
750
+ parentId: model.body_parentid[i],
751
+ });
752
+ }
753
+ return result;
754
+ }, []);
755
+
756
+ const getJoints = useCallback((): JointInfo[] => {
757
+ const model = mjModelRef.current;
758
+ if (!model) return [];
759
+ const result: JointInfo[] = [];
760
+ for (let i = 0; i < model.njnt; i++) {
761
+ const type = model.jnt_type[i];
762
+ const limited = model.jnt_limited ? model.jnt_limited[i] !== 0 : false;
763
+ result.push({
764
+ id: i,
765
+ name: getName(model, model.name_jntadr[i]),
766
+ type,
767
+ typeName: JOINT_TYPE_NAMES[type] ?? `unknown(${type})`,
768
+ range: [model.jnt_range[2 * i], model.jnt_range[2 * i + 1]],
769
+ limited,
770
+ bodyId: model.jnt_bodyid[i],
771
+ qposAdr: model.jnt_qposadr[i],
772
+ dofAdr: model.jnt_dofadr[i],
773
+ });
774
+ }
775
+ return result;
776
+ }, []);
777
+
778
+ const getGeoms = useCallback((): GeomInfo[] => {
779
+ const model = mjModelRef.current;
780
+ if (!model) return [];
781
+ const result: GeomInfo[] = [];
782
+ for (let i = 0; i < model.ngeom; i++) {
783
+ const type = model.geom_type[i];
784
+ result.push({
785
+ id: i,
786
+ name: getName(model, model.name_geomadr[i]),
787
+ type,
788
+ typeName: GEOM_TYPE_NAMES[type] ?? `unknown(${type})`,
789
+ size: [model.geom_size[3 * i], model.geom_size[3 * i + 1], model.geom_size[3 * i + 2]],
790
+ bodyId: model.geom_bodyid[i],
791
+ });
792
+ }
793
+ return result;
794
+ }, []);
795
+
796
+ const getSites = useCallback((): SiteInfo[] => {
797
+ const model = mjModelRef.current;
798
+ if (!model) return [];
799
+ const result: SiteInfo[] = [];
800
+ for (let i = 0; i < model.nsite; i++) {
801
+ result.push({
802
+ id: i,
803
+ name: getName(model, model.name_siteadr[i]),
804
+ bodyId: model.site_bodyid ? model.site_bodyid[i] : -1,
805
+ });
806
+ }
807
+ return result;
808
+ }, []);
809
+
810
+ const getActuatorsApi = useCallback((): ActuatorInfo[] => {
811
+ const model = mjModelRef.current;
812
+ if (!model) return [];
813
+ const result: ActuatorInfo[] = [];
814
+ for (let i = 0; i < model.nu; i++) {
815
+ const hasRange = model.actuator_ctrlrange[2 * i] < model.actuator_ctrlrange[2 * i + 1];
816
+ result.push({
817
+ id: i,
818
+ name: getName(model, model.name_actuatoradr[i]),
819
+ range: hasRange
820
+ ? [model.actuator_ctrlrange[2 * i], model.actuator_ctrlrange[2 * i + 1]]
821
+ : [-Infinity, Infinity],
822
+ });
823
+ }
824
+ return result;
825
+ }, []);
826
+
827
+ const getSensors = useCallback((): SensorInfo[] => {
828
+ const model = mjModelRef.current;
829
+ if (!model) return [];
830
+ const result: SensorInfo[] = [];
831
+ for (let i = 0; i < model.nsensor; i++) {
832
+ const type = model.sensor_type[i];
833
+ result.push({
834
+ id: i,
835
+ name: getName(model, model.name_sensoradr[i]),
836
+ type,
837
+ typeName: SENSOR_TYPE_NAMES[type] ?? `unknown(${type})`,
838
+ dim: model.sensor_dim[i],
839
+ adr: model.sensor_adr[i],
840
+ });
841
+ }
842
+ return result;
843
+ }, []);
844
+
845
+ // spec 5.3: model options
846
+ const getModelOption = useCallback((): ModelOptions => {
847
+ const model = mjModelRef.current;
848
+ if (!model?.opt) return { timestep: 0.002, gravity: [0, 0, -9.81], integrator: 0 };
849
+ return {
850
+ timestep: model.opt.timestep,
851
+ gravity: [model.opt.gravity[0], model.opt.gravity[1], model.opt.gravity[2]],
852
+ integrator: model.opt.integrator,
853
+ };
854
+ }, []);
855
+
856
+ const setGravity = useCallback((g: [number, number, number]) => {
857
+ const model = mjModelRef.current;
858
+ if (!model?.opt?.gravity) return;
859
+ model.opt.gravity[0] = g[0];
860
+ model.opt.gravity[1] = g[1];
861
+ model.opt.gravity[2] = g[2];
862
+ }, []);
863
+
864
+ const setTimestepApi = useCallback((dt: number) => {
865
+ const model = mjModelRef.current;
866
+ if (!model?.opt) return;
867
+ model.opt.timestep = dt;
868
+ }, []);
869
+
870
+ // spec 7.1: physics raycast
871
+ const raycast = useCallback((origin: THREE.Vector3, direction: THREE.Vector3, maxDist = 100): RayHit | null => {
872
+ const model = mjModelRef.current;
873
+ const data = mjDataRef.current;
874
+ if (!model || !data) return null;
875
+
876
+ _rayPnt[0] = origin.x; _rayPnt[1] = origin.y; _rayPnt[2] = origin.z;
877
+ const dir = direction.clone().normalize();
878
+ _rayVec[0] = dir.x; _rayVec[1] = dir.y; _rayVec[2] = dir.z;
879
+ _rayGeomId[0] = -1;
880
+
881
+ try {
882
+ const dist = mujoco.mj_ray(model, data, _rayPnt, _rayVec, null, 1, -1, _rayGeomId);
883
+ if (dist < 0 || dist > maxDist) return null;
884
+ const geomId = _rayGeomId[0];
885
+ const bodyId = geomId >= 0 ? model.geom_bodyid[geomId] : -1;
886
+ return {
887
+ point: new THREE.Vector3(
888
+ origin.x + dir.x * dist,
889
+ origin.y + dir.y * dist,
890
+ origin.z + dir.z * dist,
891
+ ),
892
+ bodyId,
893
+ geomId,
894
+ distance: dist,
895
+ };
896
+ } catch {
897
+ return null; // mj_ray may not be available in all WASM builds
898
+ }
899
+ }, [mujoco]);
900
+
901
+ // spec 4.2: keyframe improvements
902
+ const applyKeyframe = useCallback((nameOrIndex: string | number) => {
903
+ const model = mjModelRef.current;
904
+ const data = mjDataRef.current;
905
+ if (!model || !data) return;
906
+
907
+ let keyId: number;
908
+ if (typeof nameOrIndex === 'number') {
909
+ keyId = nameOrIndex;
910
+ } else {
911
+ keyId = findKeyframeByName(model, nameOrIndex);
912
+ }
913
+ if (keyId < 0 || keyId >= model.nkey) {
914
+ console.warn(`applyKeyframe: keyframe "${nameOrIndex}" not found`);
915
+ return;
916
+ }
917
+
918
+ const nq = model.nq;
919
+ const nu = model.nu;
920
+ const qposOffset = keyId * nq;
921
+ for (let i = 0; i < nq; i++) data.qpos[i] = model.key_qpos[qposOffset + i];
922
+ const ctrlOffset = keyId * nu;
923
+ for (let i = 0; i < nu; i++) data.ctrl[i] = model.key_ctrl[ctrlOffset + i];
924
+
925
+ // Also restore qvel if available (spec 4.2)
926
+ if (model.key_qvel) {
927
+ const qvelOffset = keyId * model.nv;
928
+ for (let i = 0; i < model.nv; i++) data.qvel[i] = model.key_qvel[qvelOffset + i];
929
+ }
930
+
931
+ mujoco.mj_forward(model, data);
932
+
933
+ if (ikTargetRef.current) {
934
+ syncGizmoToSite(data, siteIdRef.current, ikTargetRef.current);
935
+ }
936
+ }, [mujoco, syncGizmoToSite]);
937
+
938
+ const getKeyframeNames = useCallback((): string[] => {
939
+ const model = mjModelRef.current;
940
+ if (!model) return [];
941
+ const names: string[] = [];
942
+ for (let i = 0; i < model.nkey; i++) {
943
+ names.push(getName(model, model.name_keyadr[i]));
944
+ }
945
+ return names;
946
+ }, []);
947
+
948
+ const getKeyframeCount = useCallback((): number => {
949
+ return mjModelRef.current?.nkey ?? 0;
950
+ }, []);
951
+
952
+ // spec 9.1: runtime model swap
953
+ const loadSceneApi = useCallback(async (newConfig: SceneConfig): Promise<void> => {
954
+ try {
955
+ // Clean up current model
956
+ mjModelRef.current?.delete();
957
+ mjDataRef.current?.delete();
958
+ mjModelRef.current = null;
959
+ mjDataRef.current = null;
960
+ setStatus('loading');
961
+
962
+ const result = await loadScene(mujoco, newConfig);
963
+ mjModelRef.current = result.mjModel;
964
+ mjDataRef.current = result.mjData;
965
+ siteIdRef.current = result.siteId;
966
+ gripperIdRef.current = result.gripperId;
967
+ configRef.current = newConfig;
968
+
969
+ if (ikTargetRef.current) {
970
+ syncGizmoToSite(result.mjData, result.siteId, ikTargetRef.current);
971
+ }
972
+ setStatus('ready');
973
+ } catch (e) {
974
+ setStatus('error');
975
+ throw e;
976
+ }
977
+ }, [mujoco, syncGizmoToSite]);
978
+
979
+ const getGizmoStats = useCallback((): { pos: THREE.Vector3; rot: THREE.Euler } | null => {
980
+ const target = ikTargetRef.current;
981
+ if (!ikCalculatingRef.current || !target) return null;
982
+ return {
983
+ pos: target.position.clone(),
984
+ rot: new THREE.Euler().setFromQuaternion(target.quaternion),
985
+ };
986
+ }, []);
987
+
988
+ const getCanvasSnapshot = useCallback(
989
+ (width?: number, height?: number, mimeType = 'image/jpeg'): string => {
990
+ if (width && height) {
991
+ const tempCanvas = document.createElement('canvas');
992
+ tempCanvas.width = width;
993
+ tempCanvas.height = height;
994
+ const ctx = tempCanvas.getContext('2d');
995
+ if (ctx) {
996
+ ctx.drawImage(gl.domElement, 0, 0, width, height);
997
+ return tempCanvas.toDataURL(mimeType, mimeType === 'image/jpeg' ? 0.8 : undefined);
998
+ }
999
+ }
1000
+ return gl.domElement.toDataURL(mimeType, mimeType === 'image/jpeg' ? 0.8 : undefined);
1001
+ },
1002
+ [gl]
1003
+ );
1004
+
1005
+ const project2DTo3D = useCallback(
1006
+ (x: number, y: number, cameraPos: THREE.Vector3, lookAt: THREE.Vector3): { point: THREE.Vector3; bodyId: number; geomId: number } | null => {
1007
+ const virtCam = (camera as THREE.PerspectiveCamera).clone();
1008
+ virtCam.position.copy(cameraPos);
1009
+ virtCam.lookAt(lookAt);
1010
+ virtCam.updateMatrixWorld();
1011
+ virtCam.updateProjectionMatrix();
1012
+ const ndc = new THREE.Vector2(x * 2 - 1, -(y * 2 - 1));
1013
+ const raycaster = new THREE.Raycaster();
1014
+ raycaster.setFromCamera(ndc, virtCam);
1015
+ const objects: THREE.Object3D[] = [];
1016
+ const scene = (camera as THREE.PerspectiveCamera).parent;
1017
+ if (scene) {
1018
+ scene.traverse((c) => {
1019
+ if ((c as THREE.Mesh).isMesh) objects.push(c);
1020
+ });
1021
+ }
1022
+ const hits = raycaster.intersectObjects(objects);
1023
+ if (hits.length > 0) {
1024
+ const hitObj = hits[0].object;
1025
+ // Find geomId from the hit object's userData
1026
+ const geomId = hitObj.userData.geomID !== undefined ? hitObj.userData.geomID : -1;
1027
+ // Walk up to find bodyId
1028
+ let obj = hitObj;
1029
+ while (obj && obj.userData.bodyID === undefined && obj.parent) {
1030
+ obj = obj.parent;
1031
+ }
1032
+ const bodyId = obj && obj.userData.bodyID !== undefined ? obj.userData.bodyID : -1;
1033
+ return { point: hits[0].point, bodyId, geomId };
1034
+ }
1035
+ return null;
1036
+ },
1037
+ [camera, gl]
1038
+ );
1039
+
1040
+ // --- Domain randomization (spec 10.3) ---
1041
+
1042
+ const setBodyMass = useCallback((name: string, mass: number): void => {
1043
+ const model = mjModelRef.current;
1044
+ if (!model) return;
1045
+ const id = findBodyByName(model, name);
1046
+ if (id < 0) return;
1047
+ model.body_mass[id] = mass;
1048
+ }, []);
1049
+
1050
+ const setGeomFriction = useCallback((name: string, friction: [number, number, number]): void => {
1051
+ const model = mjModelRef.current;
1052
+ if (!model) return;
1053
+ const id = findGeomByName(model, name);
1054
+ if (id < 0) return;
1055
+ model.geom_friction[id * 3] = friction[0];
1056
+ model.geom_friction[id * 3 + 1] = friction[1];
1057
+ model.geom_friction[id * 3 + 2] = friction[2];
1058
+ }, []);
1059
+
1060
+ const setGeomSize = useCallback((name: string, size: [number, number, number]): void => {
1061
+ const model = mjModelRef.current;
1062
+ if (!model) return;
1063
+ const id = findGeomByName(model, name);
1064
+ if (id < 0) return;
1065
+ model.geom_size[id * 3] = size[0];
1066
+ model.geom_size[id * 3 + 1] = size[1];
1067
+ model.geom_size[id * 3 + 2] = size[2];
1068
+ }, []);
1069
+
1070
+ const getCameraState = useCallback((): { position: THREE.Vector3; target: THREE.Vector3 } => {
1071
+ return { position: camera.position.clone(), target: orbitTargetRef.current.clone() };
1072
+ }, [camera]);
1073
+
1074
+ const moveCameraTo = useCallback(
1075
+ (position: THREE.Vector3, target: THREE.Vector3, durationMs: number): Promise<void> => {
1076
+ return new Promise((resolve) => {
1077
+ const ca = cameraAnimRef.current;
1078
+ ca.active = true;
1079
+ ca.startTime = performance.now();
1080
+ ca.duration = durationMs;
1081
+ ca.startPos.copy(camera.position);
1082
+ ca.startRot.copy(camera.quaternion);
1083
+ ca.startTarget.copy(orbitTargetRef.current);
1084
+ ca.endPos.copy(position);
1085
+ ca.endTarget.copy(target);
1086
+ const dummyCam = (camera as THREE.PerspectiveCamera).clone();
1087
+ dummyCam.position.copy(position);
1088
+ dummyCam.lookAt(target);
1089
+ ca.endRot.copy(dummyCam.quaternion);
1090
+ ca.resolve = resolve;
1091
+ setTimeout(resolve, durationMs + 100);
1092
+ });
1093
+ },
1094
+ [camera]
1095
+ );
1096
+
1097
+ // --- Assemble API ---
1098
+ const api = useMemo<MujocoSimAPI>(
1099
+ () => ({
1100
+ get status() { return status; },
1101
+ config,
1102
+ reset,
1103
+ setSpeed,
1104
+ togglePause,
1105
+ setPaused,
1106
+ step,
1107
+ getTime,
1108
+ getTimestep,
1109
+ applyKeyframe,
1110
+ saveState,
1111
+ restoreState,
1112
+ setQpos,
1113
+ setQvel,
1114
+ getQpos,
1115
+ getQvel,
1116
+ setCtrl,
1117
+ getCtrl,
1118
+ applyForce,
1119
+ applyTorque: applyTorqueApi,
1120
+ setExternalForce,
1121
+ applyGeneralizedForce,
1122
+ getSensorData,
1123
+ getContacts,
1124
+ getBodies,
1125
+ getJoints,
1126
+ getGeoms,
1127
+ getSites,
1128
+ getActuators: getActuatorsApi,
1129
+ getSensors,
1130
+ getModelOption,
1131
+ setGravity,
1132
+ setTimestep: setTimestepApi,
1133
+ raycast,
1134
+ getKeyframeNames,
1135
+ getKeyframeCount,
1136
+ loadScene: loadSceneApi,
1137
+ setIkEnabled,
1138
+ moveTarget,
1139
+ syncTargetToSite,
1140
+ solveIK,
1141
+ getGizmoStats,
1142
+ getCanvasSnapshot,
1143
+ project2DTo3D,
1144
+ getCameraState,
1145
+ moveCameraTo,
1146
+ setBodyMass,
1147
+ setGeomFriction,
1148
+ setGeomSize,
1149
+ mjModelRef,
1150
+ mjDataRef,
1151
+ }),
1152
+ [
1153
+ status, config, reset, setSpeed, togglePause, setPaused, step,
1154
+ getTime, getTimestep, applyKeyframe, saveState, restoreState,
1155
+ setQpos, setQvel, getQpos, getQvel, setCtrl, getCtrl,
1156
+ applyForce, applyTorqueApi, setExternalForce, applyGeneralizedForce,
1157
+ getSensorData, getContacts, getBodies, getJoints, getGeoms, getSites,
1158
+ getActuatorsApi, getSensors, getModelOption, setGravity, setTimestepApi,
1159
+ raycast, getKeyframeNames, getKeyframeCount, loadSceneApi,
1160
+ setIkEnabled, moveTarget, syncTargetToSite, solveIK, getGizmoStats,
1161
+ getCanvasSnapshot, project2DTo3D, getCameraState, moveCameraTo,
1162
+ setBodyMass, setGeomFriction, setGeomSize,
1163
+ ]
1164
+ );
1165
+ const apiRef = useRef(api);
1166
+ apiRef.current = api;
1167
+
1168
+ const contextValue = useMemo<MujocoSimContextValue>(
1169
+ () => ({
1170
+ api,
1171
+ mjModelRef,
1172
+ mjDataRef,
1173
+ mujocoRef,
1174
+ configRef,
1175
+ siteIdRef,
1176
+ gripperIdRef,
1177
+ ikEnabledRef,
1178
+ ikCalculatingRef,
1179
+ pausedRef,
1180
+ speedRef,
1181
+ substepsRef,
1182
+ ikTargetRef,
1183
+ genericIkRef,
1184
+ ikSolveFnRef,
1185
+ firstIkEnableRef,
1186
+ gizmoAnimRef,
1187
+ cameraAnimRef,
1188
+ onSelectionRef,
1189
+ beforeStepCallbacks,
1190
+ afterStepCallbacks,
1191
+ status,
1192
+ }),
1193
+ [api, status]
1194
+ );
1195
+
1196
+ return (
1197
+ <MujocoSimContext.Provider value={contextValue}>
1198
+ {children}
1199
+ </MujocoSimContext.Provider>
1200
+ );
1201
+ }