mujoco-react 8.4.2 → 8.6.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.
@@ -25,6 +25,8 @@ import {
25
25
  ContactInfo,
26
26
  GeomInfo,
27
27
  JointInfo,
28
+ LoadFromFilesOptions,
29
+ LocalMujocoFile,
28
30
  ModelOptions,
29
31
  MujocoSimAPI,
30
32
  PhysicsStepCallback,
@@ -34,9 +36,11 @@ import {
34
36
  SensorInfo,
35
37
  SiteInfo,
36
38
  StateSnapshot,
39
+ XmlPatch,
37
40
  } from '../types';
38
41
  import {
39
42
  loadScene,
43
+ createSceneConfigFromFiles,
40
44
  findKeyframeByName,
41
45
  findBodyByName,
42
46
  findGeomByName,
@@ -110,6 +114,8 @@ export interface MujocoSimContextValue {
110
114
  pausedRef: React.RefObject<boolean>;
111
115
  speedRef: React.RefObject<number>;
112
116
  substepsRef: React.RefObject<number>;
117
+ interpolateRef: React.RefObject<boolean>;
118
+ interpolationStateRef: React.RefObject<BodyInterpolationState>;
113
119
  onSelectionRef: React.RefObject<
114
120
  ((bodyId: number, name: string) => void) | undefined
115
121
  >;
@@ -123,6 +129,15 @@ export interface MujocoSimContextValue {
123
129
  status: 'loading' | 'ready' | 'error';
124
130
  }
125
131
 
132
+ export interface BodyInterpolationState {
133
+ alpha: number;
134
+ previousXpos: Float64Array;
135
+ previousXquat: Float64Array;
136
+ currentXpos: Float64Array;
137
+ currentXquat: Float64Array;
138
+ valid: boolean;
139
+ }
140
+
126
141
  const MujocoSimContext = createContext<MujocoSimContextValue | null>(null);
127
142
 
128
143
  export type UseMujocoResult =
@@ -214,6 +229,7 @@ interface MujocoSimProviderProps {
214
229
  substeps?: number;
215
230
  paused?: boolean;
216
231
  speed?: number;
232
+ interpolate?: boolean;
217
233
  children: React.ReactNode;
218
234
  }
219
235
 
@@ -230,6 +246,7 @@ export function MujocoSimProvider({
230
246
  substeps,
231
247
  paused,
232
248
  speed,
249
+ interpolate,
233
250
  children,
234
251
  }: MujocoSimProviderProps) {
235
252
  const { gl, camera } = useThree();
@@ -243,6 +260,16 @@ export function MujocoSimProvider({
243
260
  const pausedRef = useRef(paused ?? false);
244
261
  const speedRef = useRef(speed ?? 1);
245
262
  const substepsRef = useRef(substeps ?? 1);
263
+ const interpolateRef = useRef(interpolate ?? false);
264
+ const interpolationStateRef = useRef<BodyInterpolationState>({
265
+ alpha: 1,
266
+ previousXpos: new Float64Array(0),
267
+ previousXquat: new Float64Array(0),
268
+ currentXpos: new Float64Array(0),
269
+ currentXquat: new Float64Array(0),
270
+ valid: false,
271
+ });
272
+ const physicsAccumulatorRef = useRef(0);
246
273
  const stepsToRunRef = useRef(0);
247
274
  const loadGenRef = useRef(0);
248
275
 
@@ -259,12 +286,13 @@ export function MujocoSimProvider({
259
286
  const hiddenBodiesRef = useRef(new Set<string>());
260
287
  const bodyReloadTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
261
288
 
262
- configRef.current = config;
289
+ useEffect(() => { configRef.current = config; }, [config]);
263
290
 
264
291
  // Sync declarative props to refs
265
292
  useEffect(() => { pausedRef.current = paused ?? false; }, [paused]);
266
293
  useEffect(() => { speedRef.current = speed ?? 1; }, [speed]);
267
294
  useEffect(() => { substepsRef.current = substeps ?? 1; }, [substeps]);
295
+ useEffect(() => { interpolateRef.current = interpolate ?? false; }, [interpolate]);
268
296
 
269
297
  // Sync gravity prop
270
298
  useEffect(() => {
@@ -312,6 +340,8 @@ export function MujocoSimProvider({
312
340
 
313
341
  mjModelRef.current = result.mjModel;
314
342
  mjDataRef.current = result.mjData;
343
+ physicsAccumulatorRef.current = 0;
344
+ interpolationStateRef.current.valid = false;
315
345
 
316
346
  // Apply declarative physics props after load
317
347
  if (gravity && result.mjModel.opt?.gravity) {
@@ -340,6 +370,8 @@ export function MujocoSimProvider({
340
370
  mjDataRef.current?.delete();
341
371
  mjModelRef.current = null;
342
372
  mjDataRef.current = null;
373
+ physicsAccumulatorRef.current = 0;
374
+ interpolationStateRef.current.valid = false;
343
375
  try { mujoco.FS.unmount('/working'); } catch { /* ignore */ }
344
376
  };
345
377
  }, [mujoco, config]);
@@ -380,21 +412,62 @@ export function MujocoSimProvider({
380
412
  cb(model, data);
381
413
  }
382
414
 
383
- // Step physics with substeps
384
415
  const numSubsteps = substepsRef.current;
385
- if (stepsToRunRef.current > 0) {
416
+ if (!interpolateRef.current) {
417
+ // Step physics with substeps
418
+ if (stepsToRunRef.current > 0) {
419
+ for (let s = 0; s < stepsToRunRef.current; s++) {
420
+ mujoco.mj_step(model, data);
421
+ }
422
+ stepsToRunRef.current = 0;
423
+ } else {
424
+ const startSimTime = data.time;
425
+ const clampedDelta = Math.min(delta, 1 / 15); // cap to avoid spiral of death
426
+ const frameTime = clampedDelta * speedRef.current;
427
+ while (data.time - startSimTime < frameTime) {
428
+ for (let s = 0; s < numSubsteps; s++) {
429
+ mujoco.mj_step(model, data);
430
+ }
431
+ }
432
+ }
433
+ } else if (stepsToRunRef.current > 0) {
434
+ ensureInterpolationBuffers(model);
435
+ copyBodyPose(data, interpolationStateRef.current.previousXpos, interpolationStateRef.current.previousXquat);
386
436
  for (let s = 0; s < stepsToRunRef.current; s++) {
387
437
  mujoco.mj_step(model, data);
388
438
  }
439
+ copyBodyPose(data, interpolationStateRef.current.currentXpos, interpolationStateRef.current.currentXquat);
440
+ interpolationStateRef.current.alpha = 1;
441
+ interpolationStateRef.current.valid = true;
389
442
  stepsToRunRef.current = 0;
390
443
  } else {
391
- const startSimTime = data.time;
444
+ ensureInterpolationBuffers(model);
392
445
  const clampedDelta = Math.min(delta, 1 / 15); // cap to avoid spiral of death
393
- const frameTime = clampedDelta * speedRef.current;
394
- while (data.time - startSimTime < frameTime) {
446
+ physicsAccumulatorRef.current += clampedDelta * speedRef.current;
447
+ const stepDt = Math.max((model.opt?.timestep ?? 0.002) * Math.max(1, numSubsteps), 1e-6);
448
+ let stepped = false;
449
+
450
+ while (physicsAccumulatorRef.current >= stepDt) {
451
+ copyBodyPose(data, interpolationStateRef.current.previousXpos, interpolationStateRef.current.previousXquat);
395
452
  for (let s = 0; s < numSubsteps; s++) {
396
453
  mujoco.mj_step(model, data);
397
454
  }
455
+ copyBodyPose(data, interpolationStateRef.current.currentXpos, interpolationStateRef.current.currentXquat);
456
+ physicsAccumulatorRef.current -= stepDt;
457
+ stepped = true;
458
+ }
459
+
460
+ if (!interpolationStateRef.current.valid) {
461
+ copyBodyPose(data, interpolationStateRef.current.previousXpos, interpolationStateRef.current.previousXquat);
462
+ copyBodyPose(data, interpolationStateRef.current.currentXpos, interpolationStateRef.current.currentXquat);
463
+ }
464
+
465
+ interpolationStateRef.current.alpha = Math.min(Math.max(physicsAccumulatorRef.current / stepDt, 0), 1);
466
+ interpolationStateRef.current.valid = true;
467
+
468
+ if (!stepped) {
469
+ onStepRef.current?.(data.time);
470
+ return;
398
471
  }
399
472
  }
400
473
 
@@ -406,6 +479,21 @@ export function MujocoSimProvider({
406
479
  onStepRef.current?.(data.time);
407
480
  }, -1);
408
481
 
482
+ function ensureInterpolationBuffers(model: MujocoModel) {
483
+ const state = interpolationStateRef.current;
484
+ const xposLength = model.nbody * 3;
485
+ const xquatLength = model.nbody * 4;
486
+ if (state.previousXpos.length !== xposLength) state.previousXpos = new Float64Array(xposLength);
487
+ if (state.currentXpos.length !== xposLength) state.currentXpos = new Float64Array(xposLength);
488
+ if (state.previousXquat.length !== xquatLength) state.previousXquat = new Float64Array(xquatLength);
489
+ if (state.currentXquat.length !== xquatLength) state.currentXquat = new Float64Array(xquatLength);
490
+ }
491
+
492
+ function copyBodyPose(data: MujocoData, xpos: Float64Array, xquat: Float64Array) {
493
+ xpos.set(data.xpos.subarray(0, xpos.length));
494
+ xquat.set(data.xquat.subarray(0, xquat.length));
495
+ }
496
+
409
497
  // --- API Methods ---
410
498
 
411
499
  const reset = useCallback(() => {
@@ -849,7 +937,7 @@ export function MujocoSimProvider({
849
937
  mjDataRef.current = null;
850
938
  setStatus('loading');
851
939
 
852
- const result = await loadScene(mujoco, newConfig);
940
+ const result = await loadScene(mujoco, buildMergedConfig(newConfig));
853
941
 
854
942
  if (gen !== loadGenRef.current) {
855
943
  result.mjModel.delete();
@@ -859,6 +947,8 @@ export function MujocoSimProvider({
859
947
 
860
948
  mjModelRef.current = result.mjModel;
861
949
  mjDataRef.current = result.mjData;
950
+ physicsAccumulatorRef.current = 0;
951
+ interpolationStateRef.current.valid = false;
862
952
  configRef.current = newConfig;
863
953
 
864
954
  setStatus('ready');
@@ -874,10 +964,41 @@ export function MujocoSimProvider({
874
964
  if (bodyReloadTimerRef.current) clearTimeout(bodyReloadTimerRef.current);
875
965
  bodyReloadTimerRef.current = setTimeout(() => {
876
966
  bodyReloadTimerRef.current = null;
877
- loadSceneApi(buildMergedConfig(configRef.current));
967
+ loadSceneApi(configRef.current);
878
968
  }, 0);
879
969
  }, [loadSceneApi]);
880
970
 
971
+ const loadFromFilesApi = useCallback(
972
+ async (files: FileList | readonly LocalMujocoFile[], options?: LoadFromFilesOptions): Promise<void> => {
973
+ await loadSceneApi(createSceneConfigFromFiles(files, options));
974
+ },
975
+ [loadSceneApi]
976
+ );
977
+
978
+ const addBodyApi = useCallback(async (body: SceneObject): Promise<void> => {
979
+ const current = configRef.current;
980
+ const sceneObjects = [
981
+ ...(current.sceneObjects ?? []).filter((obj) => obj.name !== body.name),
982
+ body,
983
+ ];
984
+ await loadSceneApi({ ...current, sceneObjects });
985
+ }, [loadSceneApi]);
986
+
987
+ const removeBodyApi = useCallback(async (name: string): Promise<void> => {
988
+ const current = configRef.current;
989
+ bodyRegistryRef.current.delete(name);
990
+ const sceneObjects = (current.sceneObjects ?? []).filter((obj) => obj.name !== name);
991
+ await loadSceneApi({ ...current, sceneObjects });
992
+ }, [loadSceneApi]);
993
+
994
+ const recompileApi = useCallback(async (patches: XmlPatch[] = []): Promise<void> => {
995
+ const current = configRef.current;
996
+ await loadSceneApi({
997
+ ...current,
998
+ xmlPatches: patches.length ? [...(current.xmlPatches ?? []), ...patches] : current.xmlPatches,
999
+ });
1000
+ }, [loadSceneApi]);
1001
+
881
1002
  const getCanvasSnapshot = useCallback(
882
1003
  (width?: number, height?: number, mimeType = 'image/jpeg'): string => {
883
1004
  if (width && height) {
@@ -961,7 +1082,7 @@ export function MujocoSimProvider({
961
1082
  const api = useMemo<MujocoSimAPI>(
962
1083
  () => ({
963
1084
  get status() { return status; },
964
- config,
1085
+ get config() { return configRef.current; },
965
1086
  reset,
966
1087
  setSpeed,
967
1088
  togglePause,
@@ -1000,6 +1121,10 @@ export function MujocoSimProvider({
1000
1121
  getKeyframeNames,
1001
1122
  getKeyframeCount,
1002
1123
  loadScene: loadSceneApi,
1124
+ loadFromFiles: loadFromFilesApi,
1125
+ addBody: addBodyApi,
1126
+ removeBody: removeBodyApi,
1127
+ recompile: recompileApi,
1003
1128
  getCanvasSnapshot,
1004
1129
  project2DTo3D,
1005
1130
  setBodyMass,
@@ -1009,7 +1134,7 @@ export function MujocoSimProvider({
1009
1134
  mjDataRef,
1010
1135
  }),
1011
1136
  [
1012
- status, config, reset, setSpeed, togglePause, setPaused, step,
1137
+ status, reset, setSpeed, togglePause, setPaused, step,
1013
1138
  getTime, getTimestep, applyKeyframe, saveState, restoreState,
1014
1139
  setQpos, setQvel, getQpos, getQvel, setCtrl, getCtrl,
1015
1140
  getControlMapApi, getActuatedJointsApi, resolveControlGroupApi,
@@ -1017,6 +1142,7 @@ export function MujocoSimProvider({
1017
1142
  getSensorData, getContacts, getBodies, getJoints, getGeoms, getSites,
1018
1143
  getActuatorsApi, getSensors, getModelOption, setGravity, setTimestepApi,
1019
1144
  raycast, getKeyframeNames, getKeyframeCount, loadSceneApi,
1145
+ loadFromFilesApi, addBodyApi, removeBodyApi, recompileApi,
1020
1146
  getCanvasSnapshot, project2DTo3D,
1021
1147
  setBodyMass, setGeomFriction, setGeomSize,
1022
1148
  ]
@@ -1034,6 +1160,8 @@ export function MujocoSimProvider({
1034
1160
  pausedRef,
1035
1161
  speedRef,
1036
1162
  substepsRef,
1163
+ interpolateRef,
1164
+ interpolationStateRef,
1037
1165
  onSelectionRef,
1038
1166
  beforeStepCallbacks,
1039
1167
  afterStepCallbacks,
@@ -0,0 +1,120 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ import type {
7
+ Bodies,
8
+ MujocoData,
9
+ MujocoModel,
10
+ ObservationConfig,
11
+ ObservationLayoutItem,
12
+ ObservationResult,
13
+ } from '../types';
14
+ import { findBodyByName, findSensorByName, findSiteByName } from './SceneLoader';
15
+
16
+ function append(
17
+ values: number[],
18
+ layout: ObservationLayoutItem[],
19
+ name: string,
20
+ chunk: ArrayLike<number>
21
+ ) {
22
+ const start = values.length;
23
+ for (let i = 0; i < chunk.length; i++) values.push(chunk[i] ?? 0);
24
+ layout.push({ name, start, size: chunk.length });
25
+ }
26
+
27
+ function appendScalar(
28
+ values: number[],
29
+ layout: ObservationLayoutItem[],
30
+ name: string,
31
+ value: number
32
+ ) {
33
+ const start = values.length;
34
+ values.push(value);
35
+ layout.push({ name, start, size: 1 });
36
+ }
37
+
38
+ function namedBodyList(names: Bodies | readonly Bodies[] | undefined): readonly Bodies[] {
39
+ if (!names) return [];
40
+ return typeof names === 'string' ? [names] : names;
41
+ }
42
+
43
+ function normalizedGravity(model: MujocoModel): [number, number, number] {
44
+ const gx = model.opt.gravity[0] ?? 0;
45
+ const gy = model.opt.gravity[1] ?? 0;
46
+ const gz = model.opt.gravity[2] ?? -9.81;
47
+ const length = Math.hypot(gx, gy, gz);
48
+ if (length === 0) return [0, 0, 0];
49
+ return [gx / length, gy / length, gz / length];
50
+ }
51
+
52
+ function rotateWorldVectorToBody(data: MujocoData, bodyId: number, world: readonly [number, number, number]): [number, number, number] {
53
+ const adr = bodyId * 4;
54
+ const w = data.xquat[adr] ?? 1;
55
+ const x = -(data.xquat[adr + 1] ?? 0);
56
+ const y = -(data.xquat[adr + 2] ?? 0);
57
+ const z = -(data.xquat[adr + 3] ?? 0);
58
+ const vx = world[0];
59
+ const vy = world[1];
60
+ const vz = world[2];
61
+
62
+ const tx = 2 * (y * vz - z * vy);
63
+ const ty = 2 * (z * vx - x * vz);
64
+ const tz = 2 * (x * vy - y * vx);
65
+
66
+ return [
67
+ vx + w * tx + y * tz - z * ty,
68
+ vy + w * ty + z * tx - x * tz,
69
+ vz + w * tz + x * ty - y * tx,
70
+ ];
71
+ }
72
+
73
+ /**
74
+ * Build a flat observation vector plus a layout map from live MuJoCo state.
75
+ *
76
+ * Missing named resources are skipped. The returned layout is the source of
77
+ * truth for the vector produced from the current model.
78
+ */
79
+ export function buildObservation(
80
+ model: MujocoModel,
81
+ data: MujocoData,
82
+ config: ObservationConfig
83
+ ): ObservationResult {
84
+ const values: number[] = [];
85
+ const layout: ObservationLayoutItem[] = [];
86
+
87
+ if (config.time) appendScalar(values, layout, 'time', data.time);
88
+ if (config.qpos) append(values, layout, 'qpos', data.qpos);
89
+ if (config.qvel) append(values, layout, 'qvel', data.qvel);
90
+ if (config.ctrl) append(values, layout, 'ctrl', data.ctrl);
91
+ if (config.act) append(values, layout, 'act', data.act);
92
+ if (config.sensordata) append(values, layout, 'sensordata', data.sensordata);
93
+
94
+ for (const name of config.sensors ?? []) {
95
+ const sensorId = findSensorByName(model, name);
96
+ if (sensorId < 0) continue;
97
+ const start = model.sensor_adr[sensorId] ?? 0;
98
+ const dim = model.sensor_dim[sensorId] ?? 0;
99
+ append(values, layout, `sensor:${name}`, data.sensordata.subarray(start, start + dim));
100
+ }
101
+
102
+ for (const name of config.sites ?? []) {
103
+ const siteId = findSiteByName(model, name);
104
+ if (siteId < 0) continue;
105
+ const start = siteId * 3;
106
+ append(values, layout, `site:${name}:xpos`, data.site_xpos.subarray(start, start + 3));
107
+ }
108
+
109
+ const gravity = normalizedGravity(model);
110
+ for (const name of namedBodyList(config.projectedGravity)) {
111
+ const bodyId = findBodyByName(model, name);
112
+ if (bodyId < 0) continue;
113
+ append(values, layout, `projectedGravity:${name}`, rotateWorldVectorToBody(data, bodyId, gravity));
114
+ }
115
+
116
+ return {
117
+ values: config.output === 'float64' ? new Float64Array(values) : new Float32Array(values),
118
+ layout,
119
+ };
120
+ }