mujoco-react 8.10.0 → 8.11.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.
@@ -3,20 +3,26 @@
3
3
  * SPDX-License-Identifier: Apache-2.0
4
4
  */
5
5
 
6
+ import { useThree } from '@react-three/fiber';
6
7
  import type { ThreeElements } from '@react-three/fiber';
7
8
  import type { ReactNode } from 'react';
8
- import { useMemo } from 'react';
9
+ import { useEffect, useMemo } from 'react';
9
10
  import * as THREE from 'three';
10
11
  import type {
11
12
  PairedSplatEnvironmentConfig,
13
+ ScenarioMaterialConfig,
14
+ SceneConfig,
12
15
  SplatCollisionProxyConfig,
13
16
  SplatEnvironmentMetadata,
14
17
  SplatEnvironmentMetadataInput,
15
18
  SplatFormat,
19
+ SplatRendererKind,
20
+ SplatSceneInput,
16
21
  ScenarioLightingPreset,
17
22
  ScenarioLightingProps,
18
23
  SplatEnvironmentProps,
19
24
  VisualScenarioConfig,
25
+ VisualScenarioEffectsProps,
20
26
  } from '../types';
21
27
 
22
28
  const DEFAULT_BACKGROUND = '#181a1f';
@@ -104,6 +110,99 @@ export function getScenarioCameraPosition(
104
110
  ];
105
111
  }
106
112
 
113
+ export function VisualScenarioEffects(props: VisualScenarioEffectsProps) {
114
+ useVisualScenarioEffects(props);
115
+ return null;
116
+ }
117
+
118
+ export function useVisualScenarioEffects({
119
+ scenario,
120
+ enabled = true,
121
+ applyBackground = true,
122
+ applyFog = true,
123
+ applyRenderer = true,
124
+ applyMaterials = true,
125
+ background,
126
+ fogNear,
127
+ fogFar,
128
+ materialFilter,
129
+ }: VisualScenarioEffectsProps) {
130
+ const { gl, scene, invalidate } = useThree();
131
+
132
+ useEffect(() => {
133
+ if (!enabled || !scenario) {
134
+ return undefined;
135
+ }
136
+
137
+ const previousExposure = gl.toneMappingExposure;
138
+ const previousBackground = scene.background;
139
+ const previousFog = scene.fog;
140
+ const materialSnapshots = new Map<
141
+ THREE.Material,
142
+ {
143
+ color?: THREE.Color;
144
+ roughness?: number;
145
+ metalness?: number;
146
+ }
147
+ >();
148
+
149
+ if (applyRenderer) {
150
+ gl.toneMappingExposure = scenario.camera?.exposure ?? 1;
151
+ }
152
+
153
+ if (applyBackground) {
154
+ scene.background = new THREE.Color(
155
+ background ?? getScenarioBackground(scenario.lighting)
156
+ );
157
+ }
158
+
159
+ if (applyFog) {
160
+ scene.fog = createScenarioFog(scenario, background, fogNear, fogFar);
161
+ }
162
+
163
+ if (applyMaterials && scenario.materials) {
164
+ applyScenarioMaterials(scene, scenario, materialSnapshots, materialFilter);
165
+ }
166
+
167
+ invalidate();
168
+
169
+ return () => {
170
+ gl.toneMappingExposure = previousExposure;
171
+ scene.background = previousBackground;
172
+ scene.fog = previousFog;
173
+
174
+ for (const [material, snapshot] of materialSnapshots) {
175
+ const mutable = getMutableScenarioMaterial(material);
176
+ if (!mutable) continue;
177
+ if (snapshot.color) mutable.color.copy(snapshot.color);
178
+ if (typeof snapshot.roughness === 'number') {
179
+ mutable.roughness = snapshot.roughness;
180
+ }
181
+ if (typeof snapshot.metalness === 'number') {
182
+ mutable.metalness = snapshot.metalness;
183
+ }
184
+ mutable.needsUpdate = true;
185
+ }
186
+
187
+ invalidate();
188
+ };
189
+ }, [
190
+ applyBackground,
191
+ applyFog,
192
+ applyMaterials,
193
+ applyRenderer,
194
+ background,
195
+ enabled,
196
+ fogFar,
197
+ fogNear,
198
+ gl,
199
+ invalidate,
200
+ materialFilter,
201
+ scenario,
202
+ scene,
203
+ ]);
204
+ }
205
+
107
206
  /**
108
207
  * Renderer-agnostic Gaussian splat environment boundary.
109
208
  *
@@ -113,6 +212,8 @@ export function getScenarioCameraPosition(
113
212
  */
114
213
  export function SplatEnvironment({
115
214
  environment,
215
+ scenario,
216
+ renderer,
116
217
  src,
117
218
  format,
118
219
  collisionProxy,
@@ -123,6 +224,8 @@ export function SplatEnvironment({
123
224
  }: SplatEnvironmentProps) {
124
225
  const metadata = useSplatEnvironment({
125
226
  environment,
227
+ scenario,
228
+ renderer,
126
229
  src,
127
230
  format,
128
231
  collisionProxy: collisionProxyMetadata,
@@ -149,13 +252,31 @@ export function SplatEnvironment({
149
252
 
150
253
  export function useSplatEnvironment({
151
254
  environment,
255
+ scenario,
256
+ renderer,
152
257
  src,
153
258
  format,
154
259
  collisionProxy,
155
260
  }: SplatEnvironmentMetadataInput): SplatEnvironmentMetadata {
156
- const resolvedSrc = src ?? environment?.splat.src;
157
- const resolvedFormat = format ?? environment?.splat.format ?? 'spz';
158
- const resolvedCollisionProxy = collisionProxy ?? environment?.collisionProxy;
261
+ const scenarioEnvironment = useMemo(
262
+ () =>
263
+ environment ??
264
+ (scenario
265
+ ? createPairedSplatEnvironment(scenario, { renderer })
266
+ : undefined),
267
+ [environment, renderer, scenario]
268
+ );
269
+ const resolvedSrc = src ?? scenarioEnvironment?.splat.src ?? scenario?.splat?.src;
270
+ const resolvedFormat =
271
+ format ??
272
+ scenarioEnvironment?.splat.format ??
273
+ scenario?.splat?.format ??
274
+ 'spz';
275
+ const resolvedCollisionProxy =
276
+ collisionProxy ??
277
+ scenarioEnvironment?.collisionProxy ??
278
+ scenario?.splat?.collisionProxy ??
279
+ undefined;
159
280
 
160
281
  return useMemo(
161
282
  () => ({
@@ -163,16 +284,110 @@ export function useSplatEnvironment({
163
284
  format: resolvedFormat,
164
285
  collisionProxy: resolvedCollisionProxy,
165
286
  userData: createSplatEnvironmentUserData({
166
- environment,
287
+ environment: scenarioEnvironment,
167
288
  src: resolvedSrc,
168
289
  format: resolvedFormat,
169
290
  collisionProxy: resolvedCollisionProxy,
170
291
  }),
171
292
  }),
172
- [environment, resolvedSrc, resolvedFormat, resolvedCollisionProxy]
293
+ [scenarioEnvironment, resolvedSrc, resolvedFormat, resolvedCollisionProxy]
173
294
  );
174
295
  }
175
296
 
297
+ /**
298
+ * Convert a generic visual scenario splat block into a paired visual/physics
299
+ * environment config. Returns undefined until both the splat asset and MJCF
300
+ * collision proxy are present.
301
+ */
302
+ export function createPairedSplatEnvironment(
303
+ scenario: Pick<VisualScenarioConfig, 'id' | 'label' | 'environment' | 'splat'>,
304
+ options: {
305
+ id?: string;
306
+ label?: string;
307
+ description?: string;
308
+ renderer?: SplatRendererKind;
309
+ } = {}
310
+ ): PairedSplatEnvironmentConfig | undefined {
311
+ const splat = scenario.splat;
312
+ const collisionProxy = splat?.collisionProxy;
313
+
314
+ if (!splat?.enabled || !splat.src || !collisionProxy?.xmlPath) {
315
+ return undefined;
316
+ }
317
+
318
+ return {
319
+ id: options.id ?? scenario.id ?? 'splat-environment',
320
+ label: options.label ?? scenario.label ?? 'Gaussian splat environment',
321
+ description:
322
+ options.description ??
323
+ (scenario.environment
324
+ ? `Visual ${scenario.environment} splat paired with MJCF collision proxy.`
325
+ : undefined),
326
+ splat: {
327
+ src: splat.src,
328
+ format: splat.format ?? 'spz',
329
+ renderer: options.renderer,
330
+ },
331
+ collisionProxy: {
332
+ ...collisionProxy,
333
+ xmlPath: collisionProxy.xmlPath,
334
+ },
335
+ };
336
+ }
337
+
338
+ function isPairedSplatEnvironment(input: SplatSceneInput): input is PairedSplatEnvironmentConfig {
339
+ return !!input && 'collisionProxy' in input && 'splat' in input;
340
+ }
341
+
342
+ function sceneRelativePath(sceneConfig: SceneConfig, path: string): string {
343
+ const src = sceneConfig.src;
344
+ if (!src) return path;
345
+
346
+ const base = src.endsWith('/') ? src : src + '/';
347
+ if (path.startsWith(base)) return path.slice(base.length);
348
+ return path;
349
+ }
350
+
351
+ function uniquePaths(paths: readonly string[]): string[] {
352
+ const seen = new Set<string>();
353
+ const result: string[] = [];
354
+ for (const path of paths) {
355
+ if (seen.has(path)) continue;
356
+ seen.add(path);
357
+ result.push(path);
358
+ }
359
+ return result;
360
+ }
361
+
362
+ /**
363
+ * Compose a MuJoCo scene config with a paired splat collision proxy.
364
+ *
365
+ * This keeps the common hybrid setup declarative:
366
+ * robot XML remains `sceneFile`, the `.spz` remains a visual-only layer, and
367
+ * the paired MJCF collision proxy is added to `environmentFiles`.
368
+ */
369
+ export function withSplatEnvironment(
370
+ sceneConfig: SceneConfig,
371
+ input: SplatSceneInput,
372
+ options: { renderer?: SplatRendererKind } = {}
373
+ ): SceneConfig {
374
+ const environment = isPairedSplatEnvironment(input)
375
+ ? input
376
+ : input
377
+ ? createPairedSplatEnvironment(input, options)
378
+ : undefined;
379
+ const xmlPath = environment?.collisionProxy.xmlPath;
380
+ if (!xmlPath) return sceneConfig;
381
+
382
+ return {
383
+ ...sceneConfig,
384
+ environmentFiles: uniquePaths([
385
+ ...(sceneConfig.environmentFiles ?? []),
386
+ sceneRelativePath(sceneConfig, xmlPath),
387
+ ]),
388
+ };
389
+ }
390
+
176
391
  export function createSplatEnvironmentUserData({
177
392
  environment,
178
393
  src,
@@ -226,4 +441,126 @@ function SplatPlaceholder() {
226
441
  );
227
442
  }
228
443
 
444
+ function createScenarioFog(
445
+ scenario: VisualScenarioConfig,
446
+ background: THREE.ColorRepresentation | undefined,
447
+ fogNear: number | undefined,
448
+ fogFar: number | undefined
449
+ ) {
450
+ if (scenario.lighting === 'low-light') {
451
+ return new THREE.Fog(
452
+ background ?? getScenarioBackground(scenario.lighting),
453
+ fogNear ?? 2.5,
454
+ fogFar ?? 9
455
+ );
456
+ }
457
+
458
+ if (scenario.lighting === 'warehouse') {
459
+ return new THREE.Fog(
460
+ background ?? getScenarioBackground(scenario.lighting),
461
+ fogNear ?? 5,
462
+ fogFar ?? 16
463
+ );
464
+ }
465
+
466
+ return null;
467
+ }
468
+
469
+ function applyScenarioMaterials(
470
+ scene: THREE.Scene,
471
+ scenario: VisualScenarioConfig,
472
+ snapshots: Map<
473
+ THREE.Material,
474
+ {
475
+ color?: THREE.Color;
476
+ roughness?: number;
477
+ metalness?: number;
478
+ }
479
+ >,
480
+ materialFilter: VisualScenarioEffectsProps['materialFilter']
481
+ ) {
482
+ const materials = scenario.materials;
483
+ if (!materials) return;
484
+
485
+ scene.traverse((object) => {
486
+ if (!(object instanceof THREE.Mesh)) {
487
+ return;
488
+ }
489
+
490
+ for (const material of normalizeMaterials(object.material)) {
491
+ const mutable = getMutableScenarioMaterial(material);
492
+ if (!mutable) continue;
493
+ if (materialFilter && !materialFilter(object, material)) continue;
494
+
495
+ if (!snapshots.has(material)) {
496
+ snapshots.set(material, {
497
+ color: mutable.color.clone(),
498
+ roughness: mutable.roughness,
499
+ metalness: mutable.metalness,
500
+ });
501
+ }
502
+
503
+ applyScenarioMaterial(mutable, object, scenario, materials);
504
+ }
505
+ });
506
+ }
507
+
508
+ function applyScenarioMaterial(
509
+ material: THREE.MeshStandardMaterial | THREE.MeshPhysicalMaterial,
510
+ object: THREE.Object3D,
511
+ scenario: VisualScenarioConfig,
512
+ materials: ScenarioMaterialConfig
513
+ ) {
514
+ const seed = scenario.seed ?? 0;
515
+ const objectKey = `${scenario.id ?? 'scenario'}:${object.name}:${material.name}:${seed}`;
516
+ const variation = hashToUnitInterval(objectKey);
517
+
518
+ if (materials.randomizeObjectColors) {
519
+ material.color.setHSL(variation, 0.38, 0.42);
520
+ }
521
+
522
+ if (materials.randomizeTableMaterial) {
523
+ material.roughness = clamp01(
524
+ materials.roughness ?? 0.35 + variation * 0.45
525
+ );
526
+ material.metalness = clamp01(
527
+ materials.metalness ?? variation * 0.12
528
+ );
529
+ }
530
+
531
+ material.needsUpdate = true;
532
+ }
533
+
534
+ function normalizeMaterials(
535
+ material: THREE.Material | THREE.Material[]
536
+ ): THREE.Material[] {
537
+ return Array.isArray(material) ? material : [material];
538
+ }
539
+
540
+ function getMutableScenarioMaterial(
541
+ material: THREE.Material
542
+ ): THREE.MeshStandardMaterial | THREE.MeshPhysicalMaterial | null {
543
+ if (
544
+ material instanceof THREE.MeshStandardMaterial ||
545
+ material instanceof THREE.MeshPhysicalMaterial
546
+ ) {
547
+ return material;
548
+ }
549
+
550
+ return null;
551
+ }
552
+
553
+ function hashToUnitInterval(value: string) {
554
+ let hash = 2166136261;
555
+ for (let index = 0; index < value.length; index += 1) {
556
+ hash ^= value.charCodeAt(index);
557
+ hash = Math.imul(hash, 16777619);
558
+ }
559
+ return (hash >>> 0) / 4294967295;
560
+ }
561
+
562
+ function clamp01(value: number) {
563
+ return Math.max(0, Math.min(1, value));
564
+ }
565
+
229
566
  export type SplatCollisionProxy = ReactNode | ThreeElements['group'];
@@ -31,6 +31,7 @@ export const MujocoCanvas = forwardRef<MujocoSimAPI, MujocoCanvasProps>(
31
31
  paused,
32
32
  speed,
33
33
  interpolate,
34
+ loadingFallback,
34
35
  children,
35
36
  ...canvasProps
36
37
  },
@@ -44,7 +45,13 @@ export const MujocoCanvas = forwardRef<MujocoSimAPI, MujocoCanvasProps>(
44
45
  }
45
46
  }, [wasmStatus, wasmError, onError]);
46
47
 
47
- if (wasmStatus === 'error' || wasmStatus === 'loading' || !mujoco) {
48
+ if (wasmStatus === 'loading' || !mujoco) {
49
+ return loadingFallback ? (
50
+ <Canvas {...canvasProps}>{loadingFallback}</Canvas>
51
+ ) : null;
52
+ }
53
+
54
+ if (wasmStatus === 'error') {
48
55
  return null;
49
56
  }
50
57
 
@@ -477,8 +477,9 @@ function sceneObjectToXml(obj: SceneObject): string {
477
477
  const solref = obj.solref ? ` solref="${obj.solref}"` : '';
478
478
  const solimp = obj.solimp ? ` solimp="${obj.solimp}"` : '';
479
479
  const condim = obj.condim ? ` condim="${obj.condim}"` : '';
480
+ const group = obj.group !== undefined ? ` group="${obj.group}"` : '';
480
481
  // Always set contype/conaffinity=1 so objects collide regardless of model defaults
481
- return `<body name="${obj.name}" pos="${pos}">${joint}<geom type="${obj.type}" size="${size}" rgba="${rgba}" contype="1" conaffinity="1"${mass}${friction}${solref}${solimp}${condim}/></body>`;
482
+ return `<body name="${obj.name}" pos="${pos}">${joint}<geom type="${obj.type}" size="${size}" rgba="${rgba}" contype="1" conaffinity="1"${mass}${friction}${solref}${solimp}${condim}${group}/></body>`;
482
483
  }
483
484
 
484
485
  /** Create virtual directory structure for a file path. */
@@ -527,6 +528,22 @@ function localFilePath(file: LocalMujocoFile): string {
527
528
  return normalizeVfsPath(file.webkitRelativePath || file.name);
528
529
  }
529
530
 
531
+ function dirname(path: string): string {
532
+ const normalized = normalizeVfsPath(path);
533
+ const idx = normalized.lastIndexOf('/');
534
+ return idx === -1 ? '' : normalized.slice(0, idx + 1);
535
+ }
536
+
537
+ function relativeVfsPath(fromDir: string, targetPath: string): string {
538
+ const from = normalizeVfsPath(fromDir).split('/').filter(Boolean);
539
+ const target = normalizeVfsPath(targetPath).split('/').filter(Boolean);
540
+ while (from.length && target.length && from[0] === target[0]) {
541
+ from.shift();
542
+ target.shift();
543
+ }
544
+ return [...from.map(() => '..'), ...target].join('/') || '.';
545
+ }
546
+
530
547
  function inferSceneFile(files: readonly LocalMujocoFile[], options?: LoadFromFilesOptions): string {
531
548
  if (options?.sceneFile) return normalizeVfsPath(options.sceneFile);
532
549
 
@@ -551,6 +568,7 @@ export function createSceneConfigFromFiles(
551
568
  src: '',
552
569
  sceneFile: inferSceneFile(fileArray, options),
553
570
  files: fileArray,
571
+ environmentFiles: options.environmentFiles?.map(normalizeVfsPath),
554
572
  homeJoints: options.homeJoints,
555
573
  xmlPatches: options.xmlPatches,
556
574
  sceneObjects: options.sceneObjects,
@@ -558,6 +576,137 @@ export function createSceneConfigFromFiles(
558
576
  };
559
577
  }
560
578
 
579
+ const ENVIRONMENT_MERGE_SECTIONS = [
580
+ 'asset',
581
+ 'worldbody',
582
+ 'contact',
583
+ 'equality',
584
+ 'tendon',
585
+ 'sensor',
586
+ 'keyframe',
587
+ 'custom',
588
+ 'extension',
589
+ ] as const;
590
+
591
+ function directChild(parent: Element, tagName: string): Element | null {
592
+ const lower = tagName.toLowerCase();
593
+ for (const child of Array.from(parent.children)) {
594
+ if (child.tagName.toLowerCase() === lower) return child;
595
+ }
596
+ return null;
597
+ }
598
+
599
+ function ensureTopLevelSection(doc: XMLDocument, tagName: string): Element {
600
+ const root = doc.documentElement;
601
+ const existing = directChild(root, tagName);
602
+ if (existing) return existing;
603
+
604
+ const section = doc.createElement(tagName);
605
+ if (tagName === 'asset') {
606
+ const worldbody = directChild(root, 'worldbody');
607
+ if (worldbody) root.insertBefore(section, worldbody);
608
+ else root.appendChild(section);
609
+ } else {
610
+ root.appendChild(section);
611
+ }
612
+ return section;
613
+ }
614
+
615
+ function readCompilerDirs(doc: XMLDocument) {
616
+ const compiler = directChild(doc.documentElement, 'compiler');
617
+ const assetDir = compiler?.getAttribute('assetdir') || '';
618
+ return {
619
+ meshDir: compiler?.getAttribute('meshdir') || assetDir,
620
+ textureDir: compiler?.getAttribute('texturedir') || assetDir,
621
+ };
622
+ }
623
+
624
+ function isExternalPath(path: string): boolean {
625
+ return /^[a-z]+:\/\//i.test(path) || path.startsWith('package://') || path.startsWith('/');
626
+ }
627
+
628
+ function fileReferencePrefix(el: Element, compilerDirs: ReturnType<typeof readCompilerDirs>): string {
629
+ const tag = el.tagName.toLowerCase();
630
+ if (tag === 'mesh') return compilerDirs.meshDir ? compilerDirs.meshDir + '/' : '';
631
+ if (tag === 'texture' || tag === 'hfield') return compilerDirs.textureDir ? compilerDirs.textureDir + '/' : '';
632
+ return '';
633
+ }
634
+
635
+ function rewriteFileReferencesForMerge(node: Element, sourceFile: string, targetFile: string, sourceDoc: XMLDocument) {
636
+ const sourceDir = dirname(sourceFile);
637
+ const targetDir = dirname(targetFile);
638
+ const compilerDirs = readCompilerDirs(sourceDoc);
639
+ node.querySelectorAll('[file], [filename]').forEach((el) => {
640
+ const attr = el.hasAttribute('file') ? 'file' : 'filename';
641
+ const value = el.getAttribute(attr);
642
+ if (!value || isExternalPath(value)) return;
643
+
644
+ const sourceRelativePath = normalizeVfsPath(fileReferencePrefix(el, compilerDirs) + value);
645
+ const resolvedPath = normalizeVfsPath(sourceDir + sourceRelativePath);
646
+ el.setAttribute(attr, relativeVfsPath(targetDir, resolvedPath));
647
+ });
648
+ }
649
+
650
+ function hasParseError(doc: XMLDocument): boolean {
651
+ return doc.getElementsByTagName('parsererror').length > 0;
652
+ }
653
+
654
+ function composeEnvironmentXml(
655
+ sceneXml: string,
656
+ config: SceneConfig,
657
+ parser: DOMParser,
658
+ environmentXmlByPath: Map<string, string>
659
+ ): string {
660
+ const environmentFiles = config.environmentFiles?.map(normalizeVfsPath) ?? [];
661
+ if (!environmentFiles.length) return sceneXml;
662
+
663
+ const sceneDoc = parser.parseFromString(sceneXml, 'text/xml');
664
+ if (hasParseError(sceneDoc)) {
665
+ console.warn(`Could not compose environments: failed to parse ${config.sceneFile}`);
666
+ return sceneXml;
667
+ }
668
+
669
+ for (const environmentFile of environmentFiles) {
670
+ const environmentXml = environmentXmlByPath.get(environmentFile);
671
+ if (!environmentXml) {
672
+ console.warn(`Environment XML not found: ${environmentFile}`);
673
+ continue;
674
+ }
675
+
676
+ const environmentDoc = parser.parseFromString(environmentXml, 'text/xml');
677
+ if (hasParseError(environmentDoc)) {
678
+ console.warn(`Skipping environment XML with parse errors: ${environmentFile}`);
679
+ continue;
680
+ }
681
+
682
+ for (const sectionName of ENVIRONMENT_MERGE_SECTIONS) {
683
+ const environmentSection = directChild(environmentDoc.documentElement, sectionName);
684
+ if (!environmentSection?.children.length) continue;
685
+
686
+ const targetSection = ensureTopLevelSection(sceneDoc, sectionName);
687
+ for (const child of Array.from(environmentSection.children)) {
688
+ const imported = sceneDoc.importNode(child, true) as Element;
689
+ rewriteFileReferencesForMerge(imported, environmentFile, config.sceneFile, environmentDoc);
690
+ targetSection.appendChild(imported);
691
+ }
692
+ }
693
+ }
694
+
695
+ return new XMLSerializer().serializeToString(sceneDoc);
696
+ }
697
+
698
+ function findTextByConfiguredPath(textByPath: Map<string, string>, configuredPath: string): string | undefined {
699
+ const normalized = normalizeVfsPath(configuredPath);
700
+ const direct = textByPath.get(normalized);
701
+ if (direct) return direct;
702
+
703
+ const suffix = '/' + normalized;
704
+ for (const [path, text] of textByPath) {
705
+ if (path.endsWith(suffix) || path === normalized.split('/').pop()) return text;
706
+ }
707
+ return undefined;
708
+ }
709
+
561
710
  function applyXmlPatches(text: string, fname: string, config: SceneConfig): string {
562
711
  let result = text;
563
712
  for (const patch of config.xmlPatches ?? []) {
@@ -627,10 +776,25 @@ async function loadSceneFromFiles(
627
776
  if (isModelTextFile(path)) {
628
777
  const text = applyXmlPatches(await file.text(), path, config);
629
778
  textByPath.set(path, text);
630
- mujoco.FS.writeFile(`/working/${path}`, text);
631
779
  } else {
632
780
  mujoco.FS.writeFile(`/working/${path}`, new Uint8Array(await file.arrayBuffer()));
781
+ written.add(path);
633
782
  }
783
+ }
784
+
785
+ const environmentXmlByPath = new Map<string, string>();
786
+ for (const environmentFile of config.environmentFiles?.map(normalizeVfsPath) ?? []) {
787
+ const environmentXml = findTextByConfiguredPath(textByPath, environmentFile);
788
+ if (environmentXml) environmentXmlByPath.set(environmentFile, environmentXml);
789
+ }
790
+
791
+ for (const [path, text] of textByPath) {
792
+ const composedText = path === config.sceneFile
793
+ ? composeEnvironmentXml(text, config, parser, environmentXmlByPath)
794
+ : text;
795
+ textByPath.set(path, composedText);
796
+ ensureDir(mujoco, path);
797
+ mujoco.FS.writeFile(`/working/${path}`, composedText);
634
798
  written.add(path);
635
799
  }
636
800
 
@@ -689,6 +853,18 @@ export async function loadScene(
689
853
 
690
854
  const baseUrl = config.src.endsWith('/') ? config.src : config.src + '/';
691
855
 
856
+ const environmentXmlByPath = new Map<string, string>();
857
+ const environmentFiles = config.environmentFiles?.map(normalizeVfsPath) ?? [];
858
+ for (const environmentFile of environmentFiles) {
859
+ onProgress?.(`Downloading ${environmentFile}...`);
860
+ const res = await fetch(baseUrl + environmentFile);
861
+ if (!res.ok) {
862
+ console.warn(`Failed to fetch environment XML ${environmentFile}: ${res.status} ${res.statusText}`);
863
+ continue;
864
+ }
865
+ environmentXmlByPath.set(environmentFile, applyXmlPatches(await res.text(), environmentFile, config));
866
+ }
867
+
692
868
  const downloaded = new Set<string>();
693
869
  const xmlQueue: string[] = [config.sceneFile];
694
870
  const assetFiles: string[] = [];
@@ -714,7 +890,10 @@ export async function loadScene(
714
890
  continue;
715
891
  }
716
892
 
717
- const text = applyXmlPatches(await res.text(), fname, config);
893
+ const patchedText = applyXmlPatches(await res.text(), fname, config);
894
+ const text = fname === config.sceneFile
895
+ ? composeEnvironmentXml(patchedText, config, parser, environmentXmlByPath)
896
+ : patchedText;
718
897
 
719
898
  ensureDir(mujoco, fname);
720
899
  mujoco.FS.writeFile(`/working/${fname}`, text);