mujoco-react 8.9.2 → 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.
@@ -0,0 +1,566 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ import { useThree } from '@react-three/fiber';
7
+ import type { ThreeElements } from '@react-three/fiber';
8
+ import type { ReactNode } from 'react';
9
+ import { useEffect, useMemo } from 'react';
10
+ import * as THREE from 'three';
11
+ import type {
12
+ PairedSplatEnvironmentConfig,
13
+ ScenarioMaterialConfig,
14
+ SceneConfig,
15
+ SplatCollisionProxyConfig,
16
+ SplatEnvironmentMetadata,
17
+ SplatEnvironmentMetadataInput,
18
+ SplatFormat,
19
+ SplatRendererKind,
20
+ SplatSceneInput,
21
+ ScenarioLightingPreset,
22
+ ScenarioLightingProps,
23
+ SplatEnvironmentProps,
24
+ VisualScenarioConfig,
25
+ VisualScenarioEffectsProps,
26
+ } from '../types';
27
+
28
+ const DEFAULT_BACKGROUND = '#181a1f';
29
+
30
+ export function ScenarioLighting({
31
+ preset = 'studio',
32
+ castShadow = true,
33
+ intensity = 1,
34
+ }: ScenarioLightingProps) {
35
+ if (preset === 'warehouse') {
36
+ return (
37
+ <>
38
+ <ambientLight intensity={0.18 * intensity} />
39
+ <directionalLight
40
+ position={[3.5, -2, 5]}
41
+ intensity={2.2 * intensity}
42
+ castShadow={castShadow}
43
+ />
44
+ <directionalLight position={[-2, 1.5, 2.5]} intensity={0.25 * intensity} />
45
+ </>
46
+ );
47
+ }
48
+
49
+ if (preset === 'low-light') {
50
+ return (
51
+ <>
52
+ <ambientLight intensity={0.08 * intensity} />
53
+ <directionalLight
54
+ position={[2, -2, 3]}
55
+ intensity={0.75 * intensity}
56
+ castShadow={castShadow}
57
+ />
58
+ <pointLight position={[-0.5, -0.8, 1.3]} intensity={0.6 * intensity} />
59
+ </>
60
+ );
61
+ }
62
+
63
+ if (preset === 'splat') {
64
+ return (
65
+ <>
66
+ <ambientLight intensity={0.42 * intensity} />
67
+ <directionalLight
68
+ position={[1.8, -2.4, 3.5]}
69
+ intensity={1.2 * intensity}
70
+ castShadow={castShadow}
71
+ />
72
+ <pointLight position={[0.4, 0.2, 1.4]} intensity={0.35 * intensity} />
73
+ </>
74
+ );
75
+ }
76
+
77
+ return (
78
+ <>
79
+ <ambientLight intensity={0.35 * intensity} />
80
+ <directionalLight
81
+ position={[2.5, -3, 4]}
82
+ intensity={1.6 * intensity}
83
+ castShadow={castShadow}
84
+ />
85
+ </>
86
+ );
87
+ }
88
+
89
+ export function getScenarioBackground(
90
+ preset: ScenarioLightingPreset | undefined,
91
+ fallback = DEFAULT_BACKGROUND
92
+ ) {
93
+ if (preset === 'warehouse') return '#20242b';
94
+ if (preset === 'low-light') return '#0f1115';
95
+ if (preset === 'splat') return '#1b1f24';
96
+ return fallback;
97
+ }
98
+
99
+ export function getScenarioCameraPosition(
100
+ basePosition: readonly [number, number, number],
101
+ scenario?: Pick<VisualScenarioConfig, 'camera'>
102
+ ): [number, number, number] {
103
+ const [x, y, z] = basePosition;
104
+ const jitter = scenario?.camera?.jitter ?? 0;
105
+
106
+ return [
107
+ Number((x + jitter * 0.6).toFixed(3)),
108
+ Number((y - jitter * 0.4).toFixed(3)),
109
+ Number((z + jitter * 0.25).toFixed(3)),
110
+ ];
111
+ }
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
+
206
+ /**
207
+ * Renderer-agnostic Gaussian splat environment boundary.
208
+ *
209
+ * This component intentionally does not import a specific 3DGS renderer. Pass a
210
+ * Spark/GaussianSplats3D object as `children` once the app chooses a renderer,
211
+ * and pass MuJoCo/MJCF collision proxy visuals via `collisionProxy`.
212
+ */
213
+ export function SplatEnvironment({
214
+ environment,
215
+ scenario,
216
+ renderer,
217
+ src,
218
+ format,
219
+ collisionProxy,
220
+ collisionProxyMetadata,
221
+ children,
222
+ showPlaceholder = true,
223
+ ...groupProps
224
+ }: SplatEnvironmentProps) {
225
+ const metadata = useSplatEnvironment({
226
+ environment,
227
+ scenario,
228
+ renderer,
229
+ src,
230
+ format,
231
+ collisionProxy: collisionProxyMetadata,
232
+ });
233
+ const existingUserData =
234
+ typeof groupProps.userData === 'object' && groupProps.userData !== null
235
+ ? groupProps.userData
236
+ : {};
237
+
238
+ return (
239
+ <group
240
+ {...groupProps}
241
+ userData={{
242
+ ...existingUserData,
243
+ ...metadata.userData,
244
+ }}
245
+ >
246
+ {children}
247
+ {children || !showPlaceholder ? null : <SplatPlaceholder />}
248
+ {collisionProxy}
249
+ </group>
250
+ );
251
+ }
252
+
253
+ export function useSplatEnvironment({
254
+ environment,
255
+ scenario,
256
+ renderer,
257
+ src,
258
+ format,
259
+ collisionProxy,
260
+ }: SplatEnvironmentMetadataInput): SplatEnvironmentMetadata {
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;
280
+
281
+ return useMemo(
282
+ () => ({
283
+ src: resolvedSrc,
284
+ format: resolvedFormat,
285
+ collisionProxy: resolvedCollisionProxy,
286
+ userData: createSplatEnvironmentUserData({
287
+ environment: scenarioEnvironment,
288
+ src: resolvedSrc,
289
+ format: resolvedFormat,
290
+ collisionProxy: resolvedCollisionProxy,
291
+ }),
292
+ }),
293
+ [scenarioEnvironment, resolvedSrc, resolvedFormat, resolvedCollisionProxy]
294
+ );
295
+ }
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
+
391
+ export function createSplatEnvironmentUserData({
392
+ environment,
393
+ src,
394
+ format = 'spz',
395
+ collisionProxy,
396
+ }: {
397
+ environment?: PairedSplatEnvironmentConfig;
398
+ src?: string;
399
+ format?: SplatFormat;
400
+ collisionProxy?: SplatCollisionProxyConfig;
401
+ }) {
402
+ return {
403
+ role: 'splat-environment',
404
+ environmentId: environment?.id,
405
+ environmentLabel: environment?.label,
406
+ splatSrc: src,
407
+ splatFormat: format,
408
+ splatRenderer: environment?.splat.renderer,
409
+ collisionProxyStatus: collisionProxy?.status ?? 'missing',
410
+ collisionProxyXmlPath: collisionProxy?.xmlPath,
411
+ collisionProxyPrimitives: collisionProxy?.primitives ?? [],
412
+ };
413
+ }
414
+
415
+ export function createSparkSplatViewerUrl({
416
+ viewerUrl,
417
+ splatSrc,
418
+ }: {
419
+ viewerUrl: string;
420
+ splatSrc: string;
421
+ }) {
422
+ const url = new URL(viewerUrl, 'http://mujoco-react.local');
423
+ url.searchParams.set('splat', splatSrc);
424
+ return viewerUrl.startsWith('http') ? url.toString() : `${url.pathname}${url.search}`;
425
+ }
426
+
427
+ function SplatPlaceholder() {
428
+ return (
429
+ <group>
430
+ <mesh position={[0, 0, 1.2]}>
431
+ <boxGeometry args={[2.4, 2.4, 2.4]} />
432
+ <meshBasicMaterial
433
+ color="#8b8b8b"
434
+ transparent
435
+ opacity={0.06}
436
+ wireframe
437
+ side={THREE.DoubleSide}
438
+ />
439
+ </mesh>
440
+ </group>
441
+ );
442
+ }
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
+
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