mujoco-react 8.5.0 → 8.7.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,
@@ -10,6 +10,8 @@ import type {
10
10
  ControlGroupSelector,
11
11
  ControlJointInfo,
12
12
  JointInfo,
13
+ LoadFromFilesOptions,
14
+ LocalMujocoFile,
13
15
  MujocoData,
14
16
  MujocoModel,
15
17
  MujocoModule,
@@ -505,6 +507,170 @@ function loadModelFromPath(mujoco: MujocoModule, path: string): MujocoModel {
505
507
  throw new Error('MuJoCo WASM module does not expose an XML path loader');
506
508
  }
507
509
 
510
+ function isModelTextFile(fname: string): boolean {
511
+ const lower = fname.toLowerCase();
512
+ return lower.endsWith('.xml') || lower.endsWith('.urdf') || lower.endsWith('.mjcf');
513
+ }
514
+
515
+ function normalizeVfsPath(path: string): string {
516
+ const parts = path.replace(/\\/g, '/').split('/');
517
+ const norm: string[] = [];
518
+ for (const part of parts) {
519
+ if (!part || part === '.') continue;
520
+ if (part === '..') norm.pop();
521
+ else norm.push(part);
522
+ }
523
+ return norm.join('/');
524
+ }
525
+
526
+ function localFilePath(file: LocalMujocoFile): string {
527
+ return normalizeVfsPath(file.webkitRelativePath || file.name);
528
+ }
529
+
530
+ function inferSceneFile(files: readonly LocalMujocoFile[], options?: LoadFromFilesOptions): string {
531
+ if (options?.sceneFile) return normalizeVfsPath(options.sceneFile);
532
+
533
+ const paths = files.map(localFilePath);
534
+ const preferred = ['scene.xml', 'model.xml', 'robot.xml', 'scene.urdf', 'model.urdf', 'robot.urdf'];
535
+ for (const name of preferred) {
536
+ const match = paths.find((path) => path.endsWith(name));
537
+ if (match) return match;
538
+ }
539
+
540
+ const firstModel = paths.find(isModelTextFile);
541
+ if (!firstModel) throw new Error('No MJCF XML or URDF file found in FileList');
542
+ return firstModel;
543
+ }
544
+
545
+ export function createSceneConfigFromFiles(
546
+ files: FileList | readonly LocalMujocoFile[],
547
+ options: LoadFromFilesOptions = {}
548
+ ): SceneConfig {
549
+ const fileArray = Array.from(files) as LocalMujocoFile[];
550
+ return {
551
+ src: '',
552
+ sceneFile: inferSceneFile(fileArray, options),
553
+ files: fileArray,
554
+ homeJoints: options.homeJoints,
555
+ xmlPatches: options.xmlPatches,
556
+ sceneObjects: options.sceneObjects,
557
+ onReset: options.onReset,
558
+ };
559
+ }
560
+
561
+ function applyXmlPatches(text: string, fname: string, config: SceneConfig): string {
562
+ let result = text;
563
+ for (const patch of config.xmlPatches ?? []) {
564
+ if (fname.endsWith(patch.target) || fname === patch.target) {
565
+ if (patch.replace) {
566
+ const [from, to] = patch.replace;
567
+ if (result.includes(from)) {
568
+ result = result.replace(from, to);
569
+ } else {
570
+ const preview = from.length > 80 ? `${from.slice(0, 80)}...` : from;
571
+ console.warn(`XML patch replace pattern not found in ${fname}: "${preview}"`);
572
+ }
573
+ }
574
+ if (patch.inject && patch.injectAfter) {
575
+ const idx = result.indexOf(patch.injectAfter);
576
+ if (idx !== -1) {
577
+ const tagEnd = result.indexOf('>', idx + patch.injectAfter.length);
578
+ if (tagEnd !== -1) {
579
+ result = result.slice(0, tagEnd + 1) + patch.inject + result.slice(tagEnd + 1);
580
+ } else {
581
+ console.warn(`XML patch inject failed in ${fname}: could not find tag end after "${patch.injectAfter}"`);
582
+ }
583
+ } else {
584
+ const preview = patch.injectAfter.length > 80
585
+ ? `${patch.injectAfter.slice(0, 80)}...`
586
+ : patch.injectAfter;
587
+ console.warn(`XML patch inject anchor not found in ${fname}: "${preview}"`);
588
+ }
589
+ }
590
+ }
591
+ }
592
+
593
+ if (fname === config.sceneFile && config.sceneObjects?.length && result.includes('</worldbody>')) {
594
+ const xml = config.sceneObjects.map((obj) => sceneObjectToXml(obj)).join('');
595
+ result = result.replace('</worldbody>', xml + '</worldbody>');
596
+ }
597
+
598
+ return result;
599
+ }
600
+
601
+ async function loadSceneFromFiles(
602
+ mujoco: MujocoModule,
603
+ config: SceneConfig,
604
+ onProgress?: (msg: string) => void
605
+ ): Promise<LoadResult> {
606
+ const files = config.files ?? [];
607
+ if (!files.length) throw new Error('loadFromFiles requires at least one File');
608
+
609
+ try { mujoco.FS.unmount('/working'); } catch { /* ignore */ }
610
+ try { mujoco.FS.mkdir('/working'); } catch { /* ignore */ }
611
+
612
+ const parser = new DOMParser();
613
+ const byPath = new Map<string, LocalMujocoFile>();
614
+ const byBasename = new Map<string, LocalMujocoFile>();
615
+ const written = new Set<string>();
616
+ const textByPath = new Map<string, string>();
617
+
618
+ for (const file of files) {
619
+ const path = localFilePath(file);
620
+ byPath.set(path, file);
621
+ byBasename.set(path.split('/').pop() ?? path, file);
622
+ }
623
+
624
+ for (const [path, file] of byPath) {
625
+ onProgress?.(`Reading ${path}...`);
626
+ ensureDir(mujoco, path);
627
+ if (isModelTextFile(path)) {
628
+ const text = applyXmlPatches(await file.text(), path, config);
629
+ textByPath.set(path, text);
630
+ mujoco.FS.writeFile(`/working/${path}`, text);
631
+ } else {
632
+ mujoco.FS.writeFile(`/working/${path}`, new Uint8Array(await file.arrayBuffer()));
633
+ }
634
+ written.add(path);
635
+ }
636
+
637
+ for (const [path, text] of textByPath) {
638
+ const deps = collectDependencyPaths(text, path, parser);
639
+ for (const dep of deps) {
640
+ if (written.has(dep)) continue;
641
+ const file = byPath.get(dep) ?? byBasename.get(dep.split('/').pop() ?? dep);
642
+ if (!file) continue;
643
+ ensureDir(mujoco, dep);
644
+ if (isModelTextFile(dep)) {
645
+ mujoco.FS.writeFile(`/working/${dep}`, applyXmlPatches(await file.text(), dep, config));
646
+ } else {
647
+ mujoco.FS.writeFile(`/working/${dep}`, new Uint8Array(await file.arrayBuffer()));
648
+ }
649
+ written.add(dep);
650
+ }
651
+ }
652
+
653
+ onProgress?.('Loading model...');
654
+ const mjModel = loadModelFromPath(mujoco, `/working/${config.sceneFile}`);
655
+ const mjData = new mujoco.MjData(mjModel);
656
+ applyInitialPose(mjModel, mjData, config);
657
+ mujoco.mj_forward(mjModel, mjData);
658
+
659
+ return { mjModel, mjData };
660
+ }
661
+
662
+ function applyInitialPose(mjModel: MujocoModel, mjData: MujocoData, config: SceneConfig) {
663
+ if (!config.homeJoints) return;
664
+ const homeCount = Math.min(config.homeJoints.length, Math.max(mjModel.nu, mjModel.nq));
665
+ for (let i = 0; i < homeCount; i++) {
666
+ if (i < mjModel.nu) mjData.ctrl[i] = config.homeJoints[i];
667
+ if (i < mjModel.nq) {
668
+ const qposAdr = i < mjModel.nu ? getActuatedScalarQposAdr(mjModel, i) : -1;
669
+ mjData.qpos[qposAdr !== -1 ? qposAdr : i] = config.homeJoints[i];
670
+ }
671
+ }
672
+ }
673
+
508
674
  /**
509
675
  * Config-driven scene loader — replaces the old RobotLoader + patchSingleRobot approach.
510
676
  */
@@ -513,6 +679,10 @@ export async function loadScene(
513
679
  config: SceneConfig,
514
680
  onProgress?: (msg: string) => void
515
681
  ): Promise<LoadResult> {
682
+ if (config.files?.length) {
683
+ return loadSceneFromFiles(mujoco, config, onProgress);
684
+ }
685
+
516
686
  // 1. Clean up virtual filesystem
517
687
  try { mujoco.FS.unmount('/working'); } catch { /* ignore */ }
518
688
  try { mujoco.FS.mkdir('/working'); } catch { /* ignore */ }
@@ -530,7 +700,7 @@ export async function loadScene(
530
700
  if (downloaded.has(fname)) continue;
531
701
  downloaded.add(fname);
532
702
 
533
- if (!fname.endsWith('.xml')) {
703
+ if (!isModelTextFile(fname)) {
534
704
  // Non-XML discovered during XML scan — collect for parallel download
535
705
  assetFiles.push(fname);
536
706
  continue;
@@ -544,44 +714,7 @@ export async function loadScene(
544
714
  continue;
545
715
  }
546
716
 
547
- let text = await res.text();
548
-
549
- // 3. Apply XML patches from config
550
- for (const patch of config.xmlPatches ?? []) {
551
- if (fname.endsWith(patch.target) || fname === patch.target) {
552
- if (patch.replace) {
553
- const [from, to] = patch.replace;
554
- if (text.includes(from)) {
555
- text = text.replace(from, to);
556
- } else {
557
- const preview = from.length > 80 ? `${from.slice(0, 80)}...` : from;
558
- console.warn(`XML patch replace pattern not found in ${fname}: "${preview}"`);
559
- }
560
- }
561
- if (patch.inject && patch.injectAfter) {
562
- const idx = text.indexOf(patch.injectAfter);
563
- if (idx !== -1) {
564
- const tagEnd = text.indexOf('>', idx + patch.injectAfter.length);
565
- if (tagEnd !== -1) {
566
- text = text.slice(0, tagEnd + 1) + patch.inject + text.slice(tagEnd + 1);
567
- } else {
568
- console.warn(`XML patch inject failed in ${fname}: could not find tag end after "${patch.injectAfter}"`);
569
- }
570
- } else {
571
- const preview = patch.injectAfter.length > 80
572
- ? `${patch.injectAfter.slice(0, 80)}...`
573
- : patch.injectAfter;
574
- console.warn(`XML patch inject anchor not found in ${fname}: "${preview}"`);
575
- }
576
- }
577
- }
578
- }
579
-
580
- // 4. Inject scene objects into the scene file
581
- if (fname === config.sceneFile && config.sceneObjects?.length) {
582
- const xml = config.sceneObjects.map((obj) => sceneObjectToXml(obj)).join('');
583
- text = text.replace('</worldbody>', xml + '</worldbody>');
584
- }
717
+ const text = applyXmlPatches(await res.text(), fname, config);
585
718
 
586
719
  ensureDir(mujoco, fname);
587
720
  mujoco.FS.writeFile(`/working/${fname}`, text);
@@ -617,16 +750,7 @@ export async function loadScene(
617
750
 
618
751
  // 6. Set initial pose — set both ctrl and qpos so robot starts at home.
619
752
  // If homeJoints is not provided, keep raw MuJoCo defaults.
620
- if (config.homeJoints) {
621
- const homeCount = Math.min(config.homeJoints.length, mjModel.nu);
622
- for (let i = 0; i < homeCount; i++) {
623
- mjData.ctrl[i] = config.homeJoints[i];
624
- const qposAdr = getActuatedScalarQposAdr(mjModel, i);
625
- if (qposAdr !== -1) {
626
- mjData.qpos[qposAdr] = config.homeJoints[i];
627
- }
628
- }
629
- }
753
+ applyInitialPose(mjModel, mjData, config);
630
754
 
631
755
  mujoco.mj_forward(mjModel, mjData);
632
756
 
@@ -643,6 +767,16 @@ function scanDependencies(
643
767
  downloaded: Set<string>,
644
768
  queue: string[]
645
769
  ) {
770
+ for (const fullPath of collectDependencyPaths(xmlString, currentFile, parser)) {
771
+ if (!downloaded.has(fullPath)) queue.push(fullPath);
772
+ }
773
+ }
774
+
775
+ function collectDependencyPaths(
776
+ xmlString: string,
777
+ currentFile: string,
778
+ parser: DOMParser
779
+ ): string[] {
646
780
  const xmlDoc = parser.parseFromString(xmlString, 'text/xml');
647
781
 
648
782
  const compiler = xmlDoc.querySelector('compiler');
@@ -653,9 +787,11 @@ function scanDependencies(
653
787
  ? currentFile.substring(0, currentFile.lastIndexOf('/') + 1)
654
788
  : '';
655
789
 
656
- xmlDoc.querySelectorAll('[file]').forEach((el) => {
657
- const fileAttr = el.getAttribute('file');
790
+ const paths: string[] = [];
791
+ xmlDoc.querySelectorAll('[file], [filename]').forEach((el) => {
792
+ const fileAttr = el.getAttribute('file') ?? el.getAttribute('filename');
658
793
  if (!fileAttr) return;
794
+ if (/^[a-z]+:\/\//i.test(fileAttr) || fileAttr.startsWith('package://')) return;
659
795
 
660
796
  let prefix = '';
661
797
  if (el.tagName.toLowerCase() === 'mesh') {
@@ -664,15 +800,9 @@ function scanDependencies(
664
800
  prefix = textureDir ? textureDir + '/' : '';
665
801
  }
666
802
 
667
- let fullPath = (currentDir + prefix + fileAttr).replace(/\/\//g, '/');
668
- const parts = fullPath.split('/');
669
- const norm: string[] = [];
670
- for (const p of parts) {
671
- if (p === '..') norm.pop();
672
- else if (p !== '.') norm.push(p);
673
- }
674
- fullPath = norm.join('/');
803
+ const fullPath = normalizeVfsPath(currentDir + prefix + fileAttr);
675
804
 
676
- if (!downloaded.has(fullPath)) queue.push(fullPath);
805
+ paths.push(fullPath);
677
806
  });
807
+ return paths;
678
808
  }
package/src/index.ts CHANGED
@@ -44,6 +44,7 @@ export { SceneLights } from './components/SceneLights';
44
44
  export { Debug } from './components/Debug';
45
45
  export { TendonRenderer } from './components/TendonRenderer';
46
46
  export { FlexRenderer } from './components/FlexRenderer';
47
+ export { InstancedGeomRenderer } from './components/InstancedGeomRenderer';
47
48
  export { ContactListener } from './components/ContactListener';
48
49
  export { TrajectoryPlayer } from './components/TrajectoryPlayer';
49
50
 
@@ -141,6 +142,16 @@ export type {
141
142
  JointStateResult,
142
143
  // Register (type-safe named resources)
143
144
  Register,
145
+ RegisteredRobotMap,
146
+ RobotResource,
147
+ Robots,
148
+ RobotActuators,
149
+ RobotSensors,
150
+ RobotBodies,
151
+ RobotJoints,
152
+ RobotSites,
153
+ RobotGeoms,
154
+ RobotKeyframes,
144
155
  Actuators,
145
156
  Sensors,
146
157
  Bodies,