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.
package/src/spark.tsx ADDED
@@ -0,0 +1,336 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ import { useThree } from '@react-three/fiber';
7
+ import {
8
+ useCallback,
9
+ useEffect,
10
+ useMemo,
11
+ useRef,
12
+ useState,
13
+ } from 'react';
14
+ import * as THREE from 'three';
15
+ import {
16
+ SplatEnvironment,
17
+ useSplatEnvironment,
18
+ } from './components/VisualScenario';
19
+ import type {
20
+ SplatEnvironmentProps,
21
+ } from './types';
22
+
23
+ type SparkModule = typeof import('@sparkjsdev/spark');
24
+ type SparkRendererInstance = InstanceType<SparkModule['SparkRenderer']>;
25
+ type SparkSplatMeshInstance = InstanceType<SparkModule['SplatMesh']>;
26
+ type SparkDisposable = {
27
+ dispose?: () => unknown;
28
+ };
29
+
30
+ export type SparkSplatStatus = 'idle' | 'loading' | 'ready' | 'error';
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
+
42
+ export interface SparkSplatEnvironmentProps extends SplatEnvironmentProps {
43
+ /** Enable Spark LoD handling for large splat assets. Default: true. */
44
+ lod?: boolean | 'quality';
45
+ /**
46
+ * Hide meshes whose names include floor, ground, or plane while the splat is
47
+ * active. This mirrors the common hybrid-rendering setup where MJCF keeps
48
+ * collision geometry but the splat owns the visual environment.
49
+ */
50
+ hideGroundMeshes?: boolean;
51
+ onStatusChange?: (status: SparkSplatStatus) => void;
52
+ onLoad?: (mesh: SparkSplatMeshInstance) => void;
53
+ onError?: (error: Error) => void;
54
+ }
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
+
125
+ /**
126
+ * Optional SparkJS-backed Gaussian splat renderer for React Three Fiber scenes.
127
+ *
128
+ * Import from `mujoco-react/spark` and install `@sparkjsdev/spark` in the app
129
+ * that uses it. The core `mujoco-react` entrypoint does not depend on Spark.
130
+ */
131
+ export function SparkSplatEnvironment({
132
+ environment,
133
+ scenario,
134
+ renderer = 'spark',
135
+ src,
136
+ format,
137
+ collisionProxy,
138
+ collisionProxyMetadata,
139
+ showPlaceholder,
140
+ children,
141
+ lod = true,
142
+ hideGroundMeshes = false,
143
+ onStatusChange,
144
+ onLoad,
145
+ onError,
146
+ ...groupProps
147
+ }: SparkSplatEnvironmentProps) {
148
+ const groupRef = useRef<THREE.Group>(null);
149
+ const sparkRef = useRef<SparkRendererInstance | null>(null);
150
+ const meshRef = useRef<SparkSplatMeshInstance | null>(null);
151
+ const hiddenMeshesRef = useRef<THREE.Mesh[]>([]);
152
+ const onStatusChangeRef = useRef(onStatusChange);
153
+ const onLoadRef = useRef(onLoad);
154
+ const onErrorRef = useRef(onError);
155
+ const [status, setStatus] = useState<SparkSplatStatus>('idle');
156
+ const { gl, invalidate } = useThree();
157
+ const metadata = useSplatEnvironment({
158
+ environment,
159
+ scenario,
160
+ renderer,
161
+ src,
162
+ format,
163
+ collisionProxy: collisionProxyMetadata,
164
+ });
165
+
166
+ useEffect(() => {
167
+ onStatusChangeRef.current = onStatusChange;
168
+ }, [onStatusChange]);
169
+
170
+ useEffect(() => {
171
+ onLoadRef.current = onLoad;
172
+ }, [onLoad]);
173
+
174
+ useEffect(() => {
175
+ onErrorRef.current = onError;
176
+ }, [onError]);
177
+
178
+ useEffect(() => {
179
+ let disposed = false;
180
+
181
+ function setLifecycleStatus(nextStatus: SparkSplatStatus) {
182
+ setStatus(nextStatus);
183
+ onStatusChangeRef.current?.(nextStatus);
184
+ }
185
+
186
+ function restoreHiddenMeshes() {
187
+ for (const mesh of hiddenMeshesRef.current) {
188
+ mesh.visible = true;
189
+ }
190
+ hiddenMeshesRef.current = [];
191
+ }
192
+
193
+ async function loadSplat() {
194
+ if (!metadata.src) {
195
+ setLifecycleStatus('idle');
196
+ return;
197
+ }
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
+
208
+ setLifecycleStatus('loading');
209
+
210
+ try {
211
+ const sparkModule = await import('@sparkjsdev/spark');
212
+ if (disposed || !groupRef.current) return;
213
+
214
+ const spark = new sparkModule.SparkRenderer({
215
+ renderer: gl,
216
+ onDirty: invalidate,
217
+ });
218
+ const mesh = new sparkModule.SplatMesh({
219
+ url: metadata.src,
220
+ lod,
221
+ });
222
+ mesh.name = 'GaussianSplatMesh';
223
+
224
+ groupRef.current.add(spark);
225
+ groupRef.current.add(mesh);
226
+ sparkRef.current = spark;
227
+ meshRef.current = mesh;
228
+
229
+ if (hideGroundMeshes && groupRef.current.parent) {
230
+ groupRef.current.parent.traverse((object) => {
231
+ if (
232
+ !(object instanceof THREE.Mesh) ||
233
+ object === (mesh as unknown as THREE.Object3D)
234
+ ) {
235
+ return;
236
+ }
237
+ const name = object.name.toLowerCase();
238
+ if (
239
+ name.includes('floor') ||
240
+ name.includes('ground') ||
241
+ name.includes('plane')
242
+ ) {
243
+ object.visible = false;
244
+ hiddenMeshesRef.current.push(object);
245
+ }
246
+ });
247
+ }
248
+
249
+ await mesh.initialized;
250
+ if (disposed) return;
251
+ setLifecycleStatus('ready');
252
+ onLoadRef.current?.(mesh);
253
+ invalidate();
254
+ } catch (error) {
255
+ const normalizedError =
256
+ error instanceof Error ? error : new Error(String(error));
257
+ setLifecycleStatus('error');
258
+ onErrorRef.current?.(normalizedError);
259
+ }
260
+ }
261
+
262
+ void loadSplat();
263
+
264
+ return () => {
265
+ disposed = true;
266
+ restoreHiddenMeshes();
267
+
268
+ if (meshRef.current) {
269
+ groupRef.current?.remove(meshRef.current);
270
+ safelyDisposeSparkResource(meshRef.current);
271
+ meshRef.current = null;
272
+ }
273
+
274
+ if (sparkRef.current) {
275
+ groupRef.current?.remove(sparkRef.current);
276
+ safelyDisposeSparkResource(sparkRef.current);
277
+ sparkRef.current = null;
278
+ }
279
+ };
280
+ }, [
281
+ gl,
282
+ hideGroundMeshes,
283
+ invalidate,
284
+ lod,
285
+ metadata.format,
286
+ metadata.src,
287
+ ]);
288
+
289
+ return (
290
+ <SplatEnvironment
291
+ {...groupProps}
292
+ environment={environment}
293
+ scenario={scenario}
294
+ renderer={renderer}
295
+ src={metadata.src}
296
+ format={metadata.format}
297
+ collisionProxyMetadata={metadata.collisionProxy}
298
+ collisionProxy={collisionProxy}
299
+ showPlaceholder={showPlaceholder ?? status !== 'ready'}
300
+ >
301
+ <group ref={groupRef} />
302
+ {children}
303
+ </SplatEnvironment>
304
+ );
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
@@ -5,7 +5,7 @@
5
5
 
6
6
  import type React from 'react';
7
7
  import type { ReactNode } from 'react';
8
- import type { CanvasProps } from '@react-three/fiber';
8
+ import type { CanvasProps, ThreeElements } from '@react-three/fiber';
9
9
  import * as THREE from 'three';
10
10
 
11
11
  // ---- Register (type-safe named resources) ----
@@ -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 ----
@@ -791,6 +821,128 @@ export interface SceneLightsProps {
791
821
  intensity?: number;
792
822
  }
793
823
 
824
+ // ---- Visual scenarios / 3DGS composition ----
825
+
826
+ export type ScenarioLightingPreset = 'studio' | 'warehouse' | 'low-light' | 'splat';
827
+ export type SplatFormat = 'spz' | 'ply' | 'splat';
828
+ export type SplatRendererKind = 'spark' | 'custom';
829
+ export type SplatCollisionPrimitive = 'plane' | 'box' | 'sphere' | 'capsule' | 'mesh';
830
+
831
+ export interface ScenarioCameraConfig {
832
+ jitter?: number;
833
+ exposure?: number;
834
+ noise?: number;
835
+ blur?: number;
836
+ }
837
+
838
+ export interface ScenarioMaterialConfig {
839
+ randomizeObjectColors?: boolean;
840
+ randomizeTableMaterial?: boolean;
841
+ roughness?: number;
842
+ metalness?: number;
843
+ }
844
+
845
+ export interface SplatAssetConfig {
846
+ src: string;
847
+ /** Common browser-friendly splat format. Renderer-specific loaders may accept more. */
848
+ format?: SplatFormat;
849
+ /** Optional renderer hint. The library does not import renderer-specific code. */
850
+ renderer?: SplatRendererKind;
851
+ }
852
+
853
+ export interface SplatScenarioConfig {
854
+ enabled: boolean;
855
+ /** Common browser-friendly splat format. Renderer-specific loaders may accept more. */
856
+ format?: SplatFormat;
857
+ src?: string;
858
+ requiresCollisionProxy?: boolean;
859
+ collisionProxy?: SplatCollisionProxyConfig | null;
860
+ }
861
+
862
+ export interface SplatCollisionProxyConfig {
863
+ /** MJCF/XML file or artifact path that provides physics collision for the visual splat. */
864
+ xmlPath?: string;
865
+ /** Human-readable status for authoring and validation flows. */
866
+ status?: 'missing' | 'planned' | 'generated' | 'validated';
867
+ /** Primitive proxy shapes expected in the MJCF collision proxy. */
868
+ primitives?: SplatCollisionPrimitive[];
869
+ /** Optional notes that should travel with scene variants and rollout metadata. */
870
+ notes?: string[];
871
+ }
872
+
873
+ export interface PairedSplatEnvironmentConfig {
874
+ id: string;
875
+ label: string;
876
+ description?: string;
877
+ /** Visual-only Gaussian splat asset. */
878
+ splat: SplatAssetConfig;
879
+ /** MJCF/XML contact geometry paired with the visual splat. */
880
+ collisionProxy: SplatCollisionProxyConfig & { xmlPath: string };
881
+ }
882
+
883
+ export interface SplatEnvironmentMetadataInput {
884
+ environment?: PairedSplatEnvironmentConfig;
885
+ scenario?: VisualScenarioConfig;
886
+ renderer?: SplatRendererKind;
887
+ src?: string;
888
+ format?: SplatFormat;
889
+ collisionProxy?: SplatCollisionProxyConfig;
890
+ }
891
+
892
+ export interface SplatEnvironmentMetadata {
893
+ src?: string;
894
+ format: SplatFormat;
895
+ collisionProxy?: SplatCollisionProxyConfig;
896
+ userData: Record<string, unknown>;
897
+ }
898
+
899
+ export type SplatSceneInput =
900
+ | PairedSplatEnvironmentConfig
901
+ | VisualScenarioConfig
902
+ | undefined
903
+ | null;
904
+
905
+ export interface VisualScenarioConfig {
906
+ id?: string;
907
+ label?: string;
908
+ seed?: number;
909
+ lighting?: ScenarioLightingPreset;
910
+ environment?: string;
911
+ camera?: ScenarioCameraConfig;
912
+ materials?: ScenarioMaterialConfig;
913
+ splat?: SplatScenarioConfig | null;
914
+ }
915
+
916
+ export interface ScenarioLightingProps {
917
+ preset?: ScenarioLightingPreset;
918
+ intensity?: number;
919
+ castShadow?: boolean;
920
+ }
921
+
922
+ export interface SplatEnvironmentProps extends Omit<ThreeElements['group'], 'ref'> {
923
+ environment?: PairedSplatEnvironmentConfig;
924
+ scenario?: VisualScenarioConfig;
925
+ renderer?: SplatRendererKind;
926
+ src?: string;
927
+ format?: SplatFormat;
928
+ collisionProxy?: ReactNode;
929
+ collisionProxyMetadata?: SplatCollisionProxyConfig;
930
+ showPlaceholder?: boolean;
931
+ }
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
+
794
946
  export type TrajectoryInput = TrajectoryFrame[] | number[][];
795
947
 
796
948
  export interface TrajectoryPlayerProps {
@@ -829,6 +981,8 @@ export interface BodyProps {
829
981
  solref?: string;
830
982
  solimp?: string;
831
983
  condim?: number;
984
+ /** MuJoCo geom group. Group 3 is conventionally used for collision-only helper geoms. */
985
+ group?: number;
832
986
  children?: ReactNode;
833
987
  }
834
988
 
@@ -926,6 +1080,8 @@ export interface MujocoSimAPI {
926
1080
 
927
1081
  export type MujocoCanvasProps = Omit<CanvasProps, 'onError'> & {
928
1082
  config: SceneConfig;
1083
+ /** R3F content rendered while the MuJoCo WASM module is still loading. */
1084
+ loadingFallback?: ReactNode;
929
1085
  onReady?: (api: MujocoSimAPI) => void;
930
1086
  onError?: (error: Error) => void;
931
1087
  onStep?: (time: number) => void;
package/src/vite.ts CHANGED
@@ -86,6 +86,14 @@ export function mujocoReact(options: MujocoReactPluginOptions) {
86
86
  return {
87
87
  name: 'mujoco-react',
88
88
  enforce: 'pre' as const,
89
+ config(userConfig: { build?: { chunkSizeWarningLimit?: number } }) {
90
+ // three + drei + MuJoCo WASM glue are inherently large; the large-chunk
91
+ // warning is expected, not a failure. Raise the limit so consumers don't
92
+ // see it. Vite merges plugin config on top of user config, so only set a
93
+ // default when the consumer hasn't specified their own limit.
94
+ if (userConfig.build?.chunkSizeWarningLimit !== undefined) return;
95
+ return { build: { chunkSizeWarningLimit: 4000 } };
96
+ },
89
97
  configResolved(config: ViteConfig) {
90
98
  root = config.root;
91
99
  generatedRegister = path.resolve(root, generatedRegister);