mujoco-react 8.10.0 → 9.0.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.
Files changed (40) hide show
  1. package/README.md +81 -44
  2. package/dist/chunk-33CV6HSV.js +400 -0
  3. package/dist/chunk-33CV6HSV.js.map +1 -0
  4. package/dist/index.d.ts +92 -24
  5. package/dist/index.js +338 -54
  6. package/dist/index.js.map +1 -1
  7. package/dist/spark.d.ts +24 -3
  8. package/dist/spark.js +91 -6
  9. package/dist/spark.js.map +1 -1
  10. package/dist/{types-FFW7ykBu.d.ts → types-izZlUweI.d.ts} +109 -16
  11. package/package.json +1 -1
  12. package/src/components/Body.tsx +3 -1
  13. package/src/components/DragInteraction.tsx +1 -1
  14. package/src/components/IkGizmo.tsx +2 -2
  15. package/src/components/SceneRenderer.tsx +1 -1
  16. package/src/components/TrajectoryPlayer.tsx +4 -1
  17. package/src/components/VisualScenario.tsx +343 -6
  18. package/src/core/MujocoCanvas.tsx +8 -1
  19. package/src/core/MujocoPhysics.tsx +10 -4
  20. package/src/core/MujocoSimProvider.tsx +15 -12
  21. package/src/core/SceneLoader.ts +182 -3
  22. package/src/core/createController.tsx +2 -2
  23. package/src/hooks/useBodyState.ts +1 -1
  24. package/src/hooks/useContacts.ts +1 -1
  25. package/src/hooks/useCtrlNoise.ts +1 -1
  26. package/src/hooks/useFrameCapture.ts +206 -0
  27. package/src/hooks/useGamepad.ts +1 -1
  28. package/src/hooks/useGravityCompensation.ts +1 -1
  29. package/src/hooks/useIkController.ts +22 -13
  30. package/src/hooks/useJointState.ts +1 -1
  31. package/src/hooks/useKeyboardTeleop.ts +1 -1
  32. package/src/hooks/usePolicy.ts +13 -9
  33. package/src/hooks/useSensor.ts +1 -1
  34. package/src/hooks/useTrajectoryPlayer.ts +4 -4
  35. package/src/hooks/useTrajectoryRecorder.ts +1 -1
  36. package/src/index.ts +35 -0
  37. package/src/spark.tsx +138 -4
  38. package/src/types.ts +128 -21
  39. package/dist/chunk-KGFRKPLS.js +0 -186
  40. 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 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
 
@@ -6,19 +6,25 @@
6
6
  import { forwardRef, useEffect } from 'react';
7
7
  import { useMujocoWasm } from './MujocoProvider';
8
8
  import { MujocoSimProvider } from './MujocoSimProvider';
9
- import type { MujocoSimAPI, SceneConfig } from '../types';
9
+ import type {
10
+ MujocoSimAPI,
11
+ ReadyCallbackInput,
12
+ SceneConfig,
13
+ SelectionCallbackInput,
14
+ StepCallbackInput,
15
+ } from '../types';
10
16
 
11
17
  export interface MujocoPhysicsProps {
12
18
  /** Scene/robot configuration. */
13
19
  config: SceneConfig;
14
20
  /** Fires when model is loaded and API is ready. */
15
- onReady?: (api: MujocoSimAPI) => void;
21
+ onReady?: (input: ReadyCallbackInput) => void;
16
22
  /** Fires on scene load failure. */
17
23
  onError?: (error: Error) => void;
18
24
  /** Called each physics step. */
19
- onStep?: (time: number) => void;
25
+ onStep?: (input: StepCallbackInput) => void;
20
26
  /** Called on body double-click selection. */
21
- onSelection?: (bodyId: number, name: string) => void;
27
+ onSelection?: (input: SelectionCallbackInput) => void;
22
28
  /** Override model gravity. */
23
29
  gravity?: [number, number, number];
24
30
  /** Override model.opt.timestep. */
@@ -31,11 +31,14 @@ import {
31
31
  MujocoSimAPI,
32
32
  PhysicsStepCallback,
33
33
  RayHit,
34
+ ReadyCallbackInput,
34
35
  SceneConfig,
35
36
  SceneObject,
37
+ SelectionCallbackInput,
36
38
  SensorInfo,
37
39
  SiteInfo,
38
40
  StateSnapshot,
41
+ StepCallbackInput,
39
42
  XmlPatch,
40
43
  } from '../types';
41
44
  import {
@@ -117,7 +120,7 @@ export interface MujocoSimContextValue {
117
120
  interpolateRef: React.RefObject<boolean>;
118
121
  interpolationStateRef: React.RefObject<BodyInterpolationState>;
119
122
  onSelectionRef: React.RefObject<
120
- ((bodyId: number, name: string) => void) | undefined
123
+ ((input: SelectionCallbackInput) => void) | undefined
121
124
  >;
122
125
  beforeStepCallbacks: React.RefObject<Set<PhysicsStepCallback>>;
123
126
  afterStepCallbacks: React.RefObject<Set<PhysicsStepCallback>>;
@@ -197,7 +200,7 @@ export function useBeforePhysicsStep(callback: PhysicsStepCallback) {
197
200
  callbackRef.current = callback;
198
201
 
199
202
  useEffect(() => {
200
- const wrapped: PhysicsStepCallback = (model, data) => callbackRef.current(model, data);
203
+ const wrapped: PhysicsStepCallback = (input) => callbackRef.current(input);
201
204
  beforeStepCallbacks.current.add(wrapped);
202
205
  return () => { beforeStepCallbacks.current.delete(wrapped); };
203
206
  }, [beforeStepCallbacks]);
@@ -209,7 +212,7 @@ export function useAfterPhysicsStep(callback: PhysicsStepCallback) {
209
212
  callbackRef.current = callback;
210
213
 
211
214
  useEffect(() => {
212
- const wrapped: PhysicsStepCallback = (model, data) => callbackRef.current(model, data);
215
+ const wrapped: PhysicsStepCallback = (input) => callbackRef.current(input);
213
216
  afterStepCallbacks.current.add(wrapped);
214
217
  return () => { afterStepCallbacks.current.delete(wrapped); };
215
218
  }, [afterStepCallbacks]);
@@ -219,10 +222,10 @@ interface MujocoSimProviderProps {
219
222
  mujoco: MujocoModule;
220
223
  config: SceneConfig;
221
224
  apiRef?: React.ForwardedRef<MujocoSimAPI>;
222
- onReady?: (api: MujocoSimAPI) => void;
225
+ onReady?: (input: ReadyCallbackInput) => void;
223
226
  onError?: (error: Error) => void;
224
- onStep?: (time: number) => void;
225
- onSelection?: (bodyId: number, name: string) => void;
227
+ onStep?: (input: StepCallbackInput) => void;
228
+ onSelection?: (input: SelectionCallbackInput) => void;
226
229
  // Declarative physics config props
227
230
  gravity?: [number, number, number];
228
231
  timestep?: number;
@@ -380,7 +383,7 @@ export function MujocoSimProvider({
380
383
  useEffect(() => {
381
384
  if (status === 'ready') {
382
385
  const api = apiRef.current;
383
- if (onReady) onReady(api);
386
+ if (onReady) onReady({ api });
384
387
  // Assign the forwarded ref
385
388
  if (externalApiRef) {
386
389
  if (typeof externalApiRef === 'function') {
@@ -409,7 +412,7 @@ export function MujocoSimProvider({
409
412
 
410
413
  // Before-step callbacks
411
414
  for (const cb of beforeStepCallbacks.current) {
412
- cb(model, data);
415
+ cb({ model, data });
413
416
  }
414
417
 
415
418
  const numSubsteps = substepsRef.current;
@@ -466,17 +469,17 @@ export function MujocoSimProvider({
466
469
  interpolationStateRef.current.valid = true;
467
470
 
468
471
  if (!stepped) {
469
- onStepRef.current?.(data.time);
472
+ onStepRef.current?.({ time: data.time, model, data });
470
473
  return;
471
474
  }
472
475
  }
473
476
 
474
477
  // After-step callbacks
475
478
  for (const cb of afterStepCallbacks.current) {
476
- cb(model, data);
479
+ cb({ model, data });
477
480
  }
478
481
 
479
- onStepRef.current?.(data.time);
482
+ onStepRef.current?.({ time: data.time, model, data });
480
483
  }, -1);
481
484
 
482
485
  function ensureInterpolationBuffers(model: MujocoModel) {
@@ -515,7 +518,7 @@ export function MujocoSimProvider({
515
518
  }
516
519
  }
517
520
 
518
- configRef.current.onReset?.(model, data);
521
+ configRef.current.onReset?.({ model, data });
519
522
  mujoco.mj_forward(model, data);
520
523
 
521
524
  // Notify composable plugins (e.g. IkController)