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.
@@ -0,0 +1,206 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ *
5
+ * useFrameCapture — still-frame capture for canvas-backed MuJoCo/R3F scenes.
6
+ */
7
+
8
+ import { useCallback, useState } from 'react';
9
+ import type React from 'react';
10
+
11
+ export type FrameCaptureStatus = 'idle' | 'capturing' | 'captured' | 'error';
12
+
13
+ export type FrameCaptureTarget =
14
+ | HTMLCanvasElement
15
+ | HTMLElement
16
+ | null
17
+ | undefined;
18
+
19
+ export type FrameCaptureTargetRef =
20
+ React.RefObject<HTMLCanvasElement | HTMLElement | null>;
21
+
22
+ export interface FrameCaptureOptions {
23
+ target?: FrameCaptureTarget | FrameCaptureTargetRef;
24
+ type?: string;
25
+ quality?: number;
26
+ waitForAnimationFrame?: boolean;
27
+ }
28
+
29
+ export interface FrameCaptureResult {
30
+ canvas: HTMLCanvasElement;
31
+ dataUrl: string;
32
+ type: string;
33
+ }
34
+
35
+ export interface FrameCaptureBlobResult {
36
+ canvas: HTMLCanvasElement;
37
+ blob: Blob;
38
+ type: string;
39
+ }
40
+
41
+ export interface FrameCaptureAPI {
42
+ status: FrameCaptureStatus;
43
+ error: Error | null;
44
+ isCapturing: boolean;
45
+ capture: (options?: FrameCaptureOptions) => Promise<FrameCaptureResult>;
46
+ captureBlob: (
47
+ options?: FrameCaptureOptions
48
+ ) => Promise<FrameCaptureBlobResult>;
49
+ reset: () => void;
50
+ }
51
+
52
+ function isTargetRef(
53
+ target: FrameCaptureOptions['target']
54
+ ): target is FrameCaptureTargetRef {
55
+ return Boolean(target && typeof target === 'object' && 'current' in target);
56
+ }
57
+
58
+ function resolveCanvasTarget(
59
+ target: FrameCaptureOptions['target']
60
+ ): HTMLCanvasElement {
61
+ const resolvedTarget = isTargetRef(target) ? target.current : target;
62
+
63
+ if (!resolvedTarget) {
64
+ throw new Error('No frame capture target is available.');
65
+ }
66
+
67
+ if (resolvedTarget instanceof HTMLCanvasElement) {
68
+ return resolvedTarget;
69
+ }
70
+
71
+ const canvas = resolvedTarget.querySelector('canvas');
72
+ if (!canvas) {
73
+ throw new Error('Frame capture target does not contain a canvas.');
74
+ }
75
+ return canvas;
76
+ }
77
+
78
+ function waitForNextAnimationFrame() {
79
+ return new Promise<void>((resolve) => {
80
+ requestAnimationFrame(() => resolve());
81
+ });
82
+ }
83
+
84
+ /**
85
+ * Capture the current canvas frame as a data URL.
86
+ *
87
+ * For WebGL scenes, create the renderer with `preserveDrawingBuffer: true`
88
+ * when you need deterministic captures after the frame has presented.
89
+ */
90
+ export async function captureFrame(
91
+ options: FrameCaptureOptions
92
+ ): Promise<FrameCaptureResult> {
93
+ const type = options.type ?? 'image/png';
94
+ const canvas = resolveCanvasTarget(options.target);
95
+
96
+ if (options.waitForAnimationFrame ?? true) {
97
+ await waitForNextAnimationFrame();
98
+ }
99
+
100
+ return {
101
+ canvas,
102
+ dataUrl: canvas.toDataURL(type, options.quality),
103
+ type,
104
+ };
105
+ }
106
+
107
+ /**
108
+ * Capture the current canvas frame as a Blob.
109
+ */
110
+ export async function captureFrameBlob(
111
+ options: FrameCaptureOptions
112
+ ): Promise<FrameCaptureBlobResult> {
113
+ const type = options.type ?? 'image/png';
114
+ const canvas = resolveCanvasTarget(options.target);
115
+
116
+ if (options.waitForAnimationFrame ?? true) {
117
+ await waitForNextAnimationFrame();
118
+ }
119
+
120
+ const blob = await new Promise<Blob>((resolve, reject) => {
121
+ canvas.toBlob(
122
+ (nextBlob) => {
123
+ if (nextBlob) {
124
+ resolve(nextBlob);
125
+ } else {
126
+ reject(new Error('Canvas frame capture did not produce a Blob.'));
127
+ }
128
+ },
129
+ type,
130
+ options.quality
131
+ );
132
+ });
133
+
134
+ return { canvas, blob, type };
135
+ }
136
+
137
+ /**
138
+ * React state wrapper around `captureFrame` and `captureFrameBlob`.
139
+ */
140
+ export function useFrameCapture(
141
+ defaultOptions: FrameCaptureOptions = {}
142
+ ): FrameCaptureAPI {
143
+ const [status, setStatus] = useState<FrameCaptureStatus>('idle');
144
+ const [error, setError] = useState<Error | null>(null);
145
+
146
+ const reset = useCallback(() => {
147
+ setStatus('idle');
148
+ setError(null);
149
+ }, []);
150
+
151
+ const capture = useCallback(
152
+ async (options: FrameCaptureOptions = {}) => {
153
+ setStatus('capturing');
154
+ setError(null);
155
+
156
+ try {
157
+ const result = await captureFrame({ ...defaultOptions, ...options });
158
+ setStatus('captured');
159
+ return result;
160
+ } catch (nextError) {
161
+ const error =
162
+ nextError instanceof Error
163
+ ? nextError
164
+ : new Error('Unable to capture the current canvas frame.');
165
+ setError(error);
166
+ setStatus('error');
167
+ throw error;
168
+ }
169
+ },
170
+ [defaultOptions]
171
+ );
172
+
173
+ const captureBlob = useCallback(
174
+ async (options: FrameCaptureOptions = {}) => {
175
+ setStatus('capturing');
176
+ setError(null);
177
+
178
+ try {
179
+ const result = await captureFrameBlob({
180
+ ...defaultOptions,
181
+ ...options,
182
+ });
183
+ setStatus('captured');
184
+ return result;
185
+ } catch (nextError) {
186
+ const error =
187
+ nextError instanceof Error
188
+ ? nextError
189
+ : new Error('Unable to capture the current canvas frame.');
190
+ setError(error);
191
+ setStatus('error');
192
+ throw error;
193
+ }
194
+ },
195
+ [defaultOptions]
196
+ );
197
+
198
+ return {
199
+ status,
200
+ error,
201
+ isCapturing: status === 'capturing',
202
+ capture,
203
+ captureBlob,
204
+ reset,
205
+ };
206
+ }
@@ -6,7 +6,7 @@
6
6
  */
7
7
 
8
8
  import { useRef } from 'react';
9
- import { useMujocoContext, useBeforePhysicsStep } from '../core/MujocoSimProvider';
9
+ import { useBeforePhysicsStep } from '../core/MujocoSimProvider';
10
10
  import type { PolicyConfig } from '../types';
11
11
 
12
12
  /**
@@ -20,12 +20,13 @@ import type { PolicyConfig } from '../types';
20
20
  * @returns { step, isRunning } control handles
21
21
  */
22
22
  export function usePolicy(config: PolicyConfig) {
23
- const { mjModelRef } = useMujocoContext();
24
23
  const lastActionTimeRef = useRef(0);
24
+ const lastObservationRef = useRef<ReturnType<PolicyConfig['onObservation']> | null>(null);
25
25
  const lastActionRef = useRef<Float32Array | Float64Array | number[] | null>(null);
26
- const isRunningRef = useRef(true);
26
+ const isRunningRef = useRef(config.enabled ?? true);
27
27
  const configRef = useRef(config);
28
28
  configRef.current = config;
29
+ isRunningRef.current = config.enabled ?? isRunningRef.current;
29
30
 
30
31
  useBeforePhysicsStep((model, data) => {
31
32
  if (!isRunningRef.current) return;
@@ -37,13 +38,15 @@ export function usePolicy(config: PolicyConfig) {
37
38
  // Check if it's time for a new action
38
39
  if (data.time - lastActionTimeRef.current >= interval) {
39
40
  // Build observation
40
- const obs = cfg.onObservation(model, data);
41
+ const observation = cfg.onObservation({ model, data });
42
+ const action = cfg.infer ? cfg.infer({ observation, model, data }) : observation;
41
43
 
42
- // Apply action (consumer does inference inline or uses cached result)
43
- cfg.onAction(obs, model, data);
44
+ // Apply action. If `infer` is omitted, this preserves the legacy inline-controller path.
45
+ cfg.onAction({ action, observation, model, data });
44
46
 
45
47
  lastActionTimeRef.current = data.time;
46
- lastActionRef.current = obs;
48
+ lastObservationRef.current = observation;
49
+ lastActionRef.current = action;
47
50
  }
48
51
  });
49
52
 
@@ -51,6 +54,7 @@ export function usePolicy(config: PolicyConfig) {
51
54
  get isRunning() { return isRunningRef.current; },
52
55
  start: () => { isRunningRef.current = true; },
53
56
  stop: () => { isRunningRef.current = false; },
54
- get lastObservation() { return lastActionRef.current; },
57
+ get lastObservation() { return lastObservationRef.current; },
58
+ get lastAction() { return lastActionRef.current; },
55
59
  };
56
60
  }
package/src/index.ts CHANGED
@@ -44,11 +44,15 @@ export { SceneLights } from './components/SceneLights';
44
44
  export {
45
45
  ScenarioLighting,
46
46
  SplatEnvironment,
47
+ VisualScenarioEffects,
48
+ createPairedSplatEnvironment,
47
49
  createSparkSplatViewerUrl,
48
50
  createSplatEnvironmentUserData,
49
51
  getScenarioBackground,
50
52
  getScenarioCameraPosition,
51
53
  useSplatEnvironment,
54
+ useVisualScenarioEffects,
55
+ withSplatEnvironment,
52
56
  } from './components/VisualScenario';
53
57
  export { Debug } from './components/Debug';
54
58
  export { TendonRenderer } from './components/TendonRenderer';
@@ -73,6 +77,20 @@ export { useTrajectoryPlayer } from './hooks/useTrajectoryPlayer';
73
77
  export { useTrajectoryRecorder } from './hooks/useTrajectoryRecorder';
74
78
  export { useGamepad } from './hooks/useGamepad';
75
79
  export { useVideoRecorder } from './hooks/useVideoRecorder';
80
+ export {
81
+ captureFrame,
82
+ captureFrameBlob,
83
+ useFrameCapture,
84
+ } from './hooks/useFrameCapture';
85
+ export type {
86
+ FrameCaptureAPI,
87
+ FrameCaptureBlobResult,
88
+ FrameCaptureOptions,
89
+ FrameCaptureResult,
90
+ FrameCaptureStatus,
91
+ FrameCaptureTarget,
92
+ FrameCaptureTargetRef,
93
+ } from './hooks/useFrameCapture';
76
94
  export { useCtrlNoise } from './hooks/useCtrlNoise';
77
95
  export { useBodyMeshes } from './hooks/useBodyMeshes';
78
96
  export { useSelectionHighlight } from './hooks/useSelectionHighlight';
@@ -124,6 +142,10 @@ export type {
124
142
  KeyboardTeleopConfig,
125
143
  // Policy
126
144
  PolicyConfig,
145
+ PolicyVector,
146
+ PolicyObservationInput,
147
+ PolicyInferenceInput,
148
+ PolicyActionInput,
127
149
  // Observations
128
150
  ObservationConfig,
129
151
  ObservationHandle,
@@ -147,9 +169,12 @@ export type {
147
169
  PairedSplatEnvironmentConfig,
148
170
  SplatEnvironmentMetadataInput,
149
171
  SplatEnvironmentMetadata,
172
+ SplatSceneInput,
150
173
  VisualScenarioConfig,
151
174
  ScenarioLightingProps,
175
+ ScenarioMaterialConfig,
152
176
  SplatEnvironmentProps,
177
+ VisualScenarioEffectsProps,
153
178
  TrajectoryPlayerProps,
154
179
  ContactListenerProps,
155
180
  // API
package/src/spark.tsx CHANGED
@@ -4,7 +4,13 @@
4
4
  */
5
5
 
6
6
  import { useThree } from '@react-three/fiber';
7
- import { useEffect, useRef, useState } from 'react';
7
+ import {
8
+ useCallback,
9
+ useEffect,
10
+ useMemo,
11
+ useRef,
12
+ useState,
13
+ } from 'react';
8
14
  import * as THREE from 'three';
9
15
  import {
10
16
  SplatEnvironment,
@@ -17,9 +23,22 @@ import type {
17
23
  type SparkModule = typeof import('@sparkjsdev/spark');
18
24
  type SparkRendererInstance = InstanceType<SparkModule['SparkRenderer']>;
19
25
  type SparkSplatMeshInstance = InstanceType<SparkModule['SplatMesh']>;
26
+ type SparkDisposable = {
27
+ dispose?: () => unknown;
28
+ };
20
29
 
21
30
  export type SparkSplatStatus = 'idle' | 'loading' | 'ready' | 'error';
22
31
 
32
+ export interface SparkSplatLifecycle {
33
+ status: SparkSplatStatus;
34
+ error: Error | null;
35
+ isLoading: boolean;
36
+ isReady: boolean;
37
+ isError: boolean;
38
+ props: Pick<SparkSplatEnvironmentProps, 'onStatusChange' | 'onError'>;
39
+ reset: () => void;
40
+ }
41
+
23
42
  export interface SparkSplatEnvironmentProps extends SplatEnvironmentProps {
24
43
  /** Enable Spark LoD handling for large splat assets. Default: true. */
25
44
  lod?: boolean | 'quality';
@@ -34,6 +53,75 @@ export interface SparkSplatEnvironmentProps extends SplatEnvironmentProps {
34
53
  onError?: (error: Error) => void;
35
54
  }
36
55
 
56
+ /**
57
+ * Tracks Spark 3DGS loading state for UI that wraps `SparkSplatEnvironment`.
58
+ *
59
+ * Use the returned `props` with `<SparkSplatEnvironment {...lifecycle.props} />`
60
+ * to avoid repeating status/error state in app code.
61
+ */
62
+ export function useSparkSplatLifecycle({
63
+ enabled = true,
64
+ initialStatus,
65
+ onError,
66
+ onStatusChange,
67
+ }: {
68
+ enabled?: boolean;
69
+ initialStatus?: SparkSplatStatus;
70
+ onError?: (error: Error) => void;
71
+ onStatusChange?: (status: SparkSplatStatus) => void;
72
+ } = {}): SparkSplatLifecycle {
73
+ const [status, setStatus] = useState<SparkSplatStatus>(
74
+ initialStatus ?? (enabled ? 'loading' : 'idle')
75
+ );
76
+ const [error, setError] = useState<Error | null>(null);
77
+
78
+ useEffect(() => {
79
+ setStatus(enabled ? initialStatus ?? 'loading' : 'idle');
80
+ setError(null);
81
+ }, [enabled, initialStatus]);
82
+
83
+ const handleStatusChange = useCallback(
84
+ (nextStatus: SparkSplatStatus) => {
85
+ setStatus(nextStatus);
86
+ if (nextStatus !== 'error') {
87
+ setError(null);
88
+ }
89
+ onStatusChange?.(nextStatus);
90
+ },
91
+ [onStatusChange]
92
+ );
93
+
94
+ const handleError = useCallback(
95
+ (nextError: Error) => {
96
+ setError(nextError);
97
+ setStatus('error');
98
+ onError?.(nextError);
99
+ },
100
+ [onError]
101
+ );
102
+
103
+ const reset = useCallback(() => {
104
+ setStatus(enabled ? initialStatus ?? 'loading' : 'idle');
105
+ setError(null);
106
+ }, [enabled, initialStatus]);
107
+
108
+ return useMemo(
109
+ () => ({
110
+ status,
111
+ error,
112
+ isLoading: status === 'loading',
113
+ isReady: status === 'ready',
114
+ isError: status === 'error',
115
+ props: {
116
+ onStatusChange: handleStatusChange,
117
+ onError: handleError,
118
+ },
119
+ reset,
120
+ }),
121
+ [error, handleError, handleStatusChange, reset, status]
122
+ );
123
+ }
124
+
37
125
  /**
38
126
  * Optional SparkJS-backed Gaussian splat renderer for React Three Fiber scenes.
39
127
  *
@@ -42,6 +130,8 @@ export interface SparkSplatEnvironmentProps extends SplatEnvironmentProps {
42
130
  */
43
131
  export function SparkSplatEnvironment({
44
132
  environment,
133
+ scenario,
134
+ renderer = 'spark',
45
135
  src,
46
136
  format,
47
137
  collisionProxy,
@@ -66,6 +156,8 @@ export function SparkSplatEnvironment({
66
156
  const { gl, invalidate } = useThree();
67
157
  const metadata = useSplatEnvironment({
68
158
  environment,
159
+ scenario,
160
+ renderer,
69
161
  src,
70
162
  format,
71
163
  collisionProxy: collisionProxyMetadata,
@@ -99,11 +191,20 @@ export function SparkSplatEnvironment({
99
191
  }
100
192
 
101
193
  async function loadSplat() {
102
- if (!metadata.src || metadata.format !== 'spz') {
194
+ if (!metadata.src) {
103
195
  setLifecycleStatus('idle');
104
196
  return;
105
197
  }
106
198
 
199
+ if (metadata.format !== 'spz') {
200
+ const unsupportedFormatError = new Error(
201
+ `SparkSplatEnvironment only supports .spz assets; received "${metadata.format}".`
202
+ );
203
+ setLifecycleStatus('error');
204
+ onErrorRef.current?.(unsupportedFormatError);
205
+ return;
206
+ }
207
+
107
208
  setLifecycleStatus('loading');
108
209
 
109
210
  try {
@@ -166,13 +267,13 @@ export function SparkSplatEnvironment({
166
267
 
167
268
  if (meshRef.current) {
168
269
  groupRef.current?.remove(meshRef.current);
169
- meshRef.current.dispose?.();
270
+ safelyDisposeSparkResource(meshRef.current);
170
271
  meshRef.current = null;
171
272
  }
172
273
 
173
274
  if (sparkRef.current) {
174
275
  groupRef.current?.remove(sparkRef.current);
175
- sparkRef.current.dispose?.();
276
+ safelyDisposeSparkResource(sparkRef.current);
176
277
  sparkRef.current = null;
177
278
  }
178
279
  };
@@ -189,6 +290,8 @@ export function SparkSplatEnvironment({
189
290
  <SplatEnvironment
190
291
  {...groupProps}
191
292
  environment={environment}
293
+ scenario={scenario}
294
+ renderer={renderer}
192
295
  src={metadata.src}
193
296
  format={metadata.format}
194
297
  collisionProxyMetadata={metadata.collisionProxy}
@@ -200,3 +303,34 @@ export function SparkSplatEnvironment({
200
303
  </SplatEnvironment>
201
304
  );
202
305
  }
306
+
307
+ function safelyDisposeSparkResource(resource: SparkDisposable) {
308
+ try {
309
+ const result = resource.dispose?.();
310
+ if (isPromiseLike(result)) {
311
+ void Promise.resolve(result).catch(handleSparkDisposeError);
312
+ }
313
+ } catch (error) {
314
+ handleSparkDisposeError(error);
315
+ }
316
+ }
317
+
318
+ function isPromiseLike(value: unknown): value is PromiseLike<unknown> {
319
+ return (
320
+ typeof value === 'object' &&
321
+ value !== null &&
322
+ 'then' in value &&
323
+ typeof (value as { then?: unknown }).then === 'function'
324
+ );
325
+ }
326
+
327
+ function handleSparkDisposeError(error: unknown) {
328
+ if (
329
+ error instanceof Error &&
330
+ error.message.toLowerCase().includes('worker terminate')
331
+ ) {
332
+ return;
333
+ }
334
+
335
+ console.warn('[mujoco-react] Spark resource disposal failed.', error);
336
+ }
package/src/types.ts CHANGED
@@ -429,6 +429,8 @@ export interface SceneObject {
429
429
  solref?: string;
430
430
  solimp?: string;
431
431
  condim?: number;
432
+ /** MuJoCo geom group. Group 3 is conventionally used for collision-only helper geoms. */
433
+ group?: number;
432
434
  }
433
435
 
434
436
  export interface XmlPatch {
@@ -443,6 +445,8 @@ export type LocalMujocoFile = File;
443
445
  export interface LoadFromFilesOptions {
444
446
  /** Entry MJCF/URDF file. Inferred from scene.xml, model.xml, robot.xml, or the first XML/URDF file when omitted. */
445
447
  sceneFile?: string;
448
+ /** Additional MJCF environment XML files merged into the entry scene before MuJoCo compilation. */
449
+ environmentFiles?: string[];
446
450
  homeJoints?: number[];
447
451
  xmlPatches?: XmlPatch[];
448
452
  sceneObjects?: SceneObject[];
@@ -456,6 +460,14 @@ export interface SceneConfig {
456
460
  sceneFile: string;
457
461
  /** Browser-selected files for local MJCF/URDF loading. Preserves webkitRelativePath when available. */
458
462
  files?: readonly LocalMujocoFile[];
463
+ /**
464
+ * Additional MJCF environment XML files merged into the entry scene before compilation.
465
+ *
466
+ * Use this for static collision/physics layers such as a Gaussian-splat
467
+ * environment's proxy `scene.xml`; render the splat itself as a separate
468
+ * visual layer.
469
+ */
470
+ environmentFiles?: string[];
459
471
  sceneObjects?: SceneObject[];
460
472
  homeJoints?: number[];
461
473
  xmlPatches?: XmlPatch[];
@@ -709,10 +721,28 @@ export interface KeyboardTeleopConfig {
709
721
 
710
722
  // ---- Policy (spec 10.1) ----
711
723
 
724
+ export type PolicyVector = Float32Array | Float64Array | number[];
725
+
726
+ export interface PolicyObservationInput {
727
+ model: MujocoModel;
728
+ data: MujocoData;
729
+ }
730
+
731
+ export interface PolicyInferenceInput extends PolicyObservationInput {
732
+ observation: PolicyVector;
733
+ }
734
+
735
+ export interface PolicyActionInput extends PolicyInferenceInput {
736
+ action: PolicyVector;
737
+ }
738
+
712
739
  export interface PolicyConfig {
713
740
  frequency: number;
714
- onObservation: (model: MujocoModel, data: MujocoData) => Float32Array | Float64Array | number[];
715
- onAction: (action: Float32Array | Float64Array | number[], model: MujocoModel, data: MujocoData) => void;
741
+ enabled?: boolean;
742
+ onObservation: (input: PolicyObservationInput) => PolicyVector;
743
+ /** Run policy inference. Omit to pass observations directly to `onAction` for custom inline controllers. */
744
+ infer?: (input: PolicyInferenceInput) => PolicyVector;
745
+ onAction: (input: PolicyActionInput) => void;
716
746
  }
717
747
 
718
748
  // ---- Observation Builder ----
@@ -805,6 +835,13 @@ export interface ScenarioCameraConfig {
805
835
  blur?: number;
806
836
  }
807
837
 
838
+ export interface ScenarioMaterialConfig {
839
+ randomizeObjectColors?: boolean;
840
+ randomizeTableMaterial?: boolean;
841
+ roughness?: number;
842
+ metalness?: number;
843
+ }
844
+
808
845
  export interface SplatAssetConfig {
809
846
  src: string;
810
847
  /** Common browser-friendly splat format. Renderer-specific loaders may accept more. */
@@ -819,7 +856,7 @@ export interface SplatScenarioConfig {
819
856
  format?: SplatFormat;
820
857
  src?: string;
821
858
  requiresCollisionProxy?: boolean;
822
- collisionProxy?: SplatCollisionProxyConfig;
859
+ collisionProxy?: SplatCollisionProxyConfig | null;
823
860
  }
824
861
 
825
862
  export interface SplatCollisionProxyConfig {
@@ -845,6 +882,8 @@ export interface PairedSplatEnvironmentConfig {
845
882
 
846
883
  export interface SplatEnvironmentMetadataInput {
847
884
  environment?: PairedSplatEnvironmentConfig;
885
+ scenario?: VisualScenarioConfig;
886
+ renderer?: SplatRendererKind;
848
887
  src?: string;
849
888
  format?: SplatFormat;
850
889
  collisionProxy?: SplatCollisionProxyConfig;
@@ -857,6 +896,12 @@ export interface SplatEnvironmentMetadata {
857
896
  userData: Record<string, unknown>;
858
897
  }
859
898
 
899
+ export type SplatSceneInput =
900
+ | PairedSplatEnvironmentConfig
901
+ | VisualScenarioConfig
902
+ | undefined
903
+ | null;
904
+
860
905
  export interface VisualScenarioConfig {
861
906
  id?: string;
862
907
  label?: string;
@@ -864,7 +909,8 @@ export interface VisualScenarioConfig {
864
909
  lighting?: ScenarioLightingPreset;
865
910
  environment?: string;
866
911
  camera?: ScenarioCameraConfig;
867
- splat?: SplatScenarioConfig;
912
+ materials?: ScenarioMaterialConfig;
913
+ splat?: SplatScenarioConfig | null;
868
914
  }
869
915
 
870
916
  export interface ScenarioLightingProps {
@@ -875,6 +921,8 @@ export interface ScenarioLightingProps {
875
921
 
876
922
  export interface SplatEnvironmentProps extends Omit<ThreeElements['group'], 'ref'> {
877
923
  environment?: PairedSplatEnvironmentConfig;
924
+ scenario?: VisualScenarioConfig;
925
+ renderer?: SplatRendererKind;
878
926
  src?: string;
879
927
  format?: SplatFormat;
880
928
  collisionProxy?: ReactNode;
@@ -882,6 +930,19 @@ export interface SplatEnvironmentProps extends Omit<ThreeElements['group'], 'ref
882
930
  showPlaceholder?: boolean;
883
931
  }
884
932
 
933
+ export interface VisualScenarioEffectsProps {
934
+ scenario?: VisualScenarioConfig;
935
+ enabled?: boolean;
936
+ applyBackground?: boolean;
937
+ applyFog?: boolean;
938
+ applyRenderer?: boolean;
939
+ applyMaterials?: boolean;
940
+ background?: THREE.ColorRepresentation;
941
+ fogNear?: number;
942
+ fogFar?: number;
943
+ materialFilter?: (object: THREE.Object3D, material: THREE.Material) => boolean;
944
+ }
945
+
885
946
  export type TrajectoryInput = TrajectoryFrame[] | number[][];
886
947
 
887
948
  export interface TrajectoryPlayerProps {
@@ -920,6 +981,8 @@ export interface BodyProps {
920
981
  solref?: string;
921
982
  solimp?: string;
922
983
  condim?: number;
984
+ /** MuJoCo geom group. Group 3 is conventionally used for collision-only helper geoms. */
985
+ group?: number;
923
986
  children?: ReactNode;
924
987
  }
925
988
 
@@ -1017,6 +1080,8 @@ export interface MujocoSimAPI {
1017
1080
 
1018
1081
  export type MujocoCanvasProps = Omit<CanvasProps, 'onError'> & {
1019
1082
  config: SceneConfig;
1083
+ /** R3F content rendered while the MuJoCo WASM module is still loading. */
1084
+ loadingFallback?: ReactNode;
1020
1085
  onReady?: (api: MujocoSimAPI) => void;
1021
1086
  onError?: (error: Error) => void;
1022
1087
  onStep?: (time: number) => void;