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.
- package/README.md +65 -28
- package/dist/chunk-SEWQULWO.js +400 -0
- package/dist/chunk-SEWQULWO.js.map +1 -0
- package/dist/index.d.ts +82 -14
- package/dist/index.js +289 -17
- package/dist/index.js.map +1 -1
- package/dist/spark.d.ts +24 -3
- package/dist/spark.js +91 -6
- package/dist/spark.js.map +1 -1
- package/dist/{types-FFW7ykBu.d.ts → types-BmneHLBM.d.ts} +59 -5
- package/package.json +1 -1
- package/src/components/Body.tsx +3 -1
- package/src/components/VisualScenario.tsx +343 -6
- package/src/core/MujocoCanvas.tsx +8 -1
- package/src/core/SceneLoader.ts +182 -3
- package/src/hooks/useFrameCapture.ts +206 -0
- package/src/hooks/usePolicy.ts +12 -8
- package/src/index.ts +25 -0
- package/src/spark.tsx +138 -4
- package/src/types.ts +69 -4
- package/dist/chunk-KGFRKPLS.js +0 -186
- package/dist/chunk-KGFRKPLS.js.map +0 -1
|
@@ -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
|
|
157
|
-
|
|
158
|
-
|
|
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
|
-
[
|
|
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 === '
|
|
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
|
|
package/src/core/SceneLoader.ts
CHANGED
|
@@ -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
|
|
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);
|