mujoco-react 9.3.0 → 9.5.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,350 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ import type { ThreeElements } from '@react-three/fiber';
7
+ import { useEffect, useMemo, useState } from 'react';
8
+ import type { SplatCollisionProxyConfig } from '../types';
9
+
10
+ export type SplatCollisionProxyPreviewVector3 = [number, number, number];
11
+
12
+ export interface SplatCollisionProxyGeomPreview {
13
+ id: string;
14
+ type: 'box' | 'plane' | 'sphere' | 'capsule' | 'mesh';
15
+ position: SplatCollisionProxyPreviewVector3;
16
+ size: number[];
17
+ }
18
+
19
+ export interface SplatCollisionProxyPreviewProps
20
+ extends Omit<ThreeElements['group'], 'ref'> {
21
+ collisionProxy?: SplatCollisionProxyConfig | null;
22
+ xmlText?: string;
23
+ fetchXml?: (xmlPath: string) => Promise<string>;
24
+ color?: string;
25
+ opacity?: number;
26
+ planeColor?: string;
27
+ planeOpacity?: number;
28
+ }
29
+
30
+ export type SplatCollisionProxyPreviewStatus =
31
+ | 'idle'
32
+ | 'loading'
33
+ | 'ready'
34
+ | 'error';
35
+
36
+ export interface UseSplatCollisionProxyGeomsOptions {
37
+ collisionProxy?: SplatCollisionProxyConfig | null;
38
+ xmlText?: string;
39
+ fetchXml?: (xmlPath: string) => Promise<string>;
40
+ enabled?: boolean;
41
+ }
42
+
43
+ export interface SplatCollisionProxyGeomsState {
44
+ geoms: SplatCollisionProxyGeomPreview[];
45
+ status: SplatCollisionProxyPreviewStatus;
46
+ error: Error | null;
47
+ xmlPath?: string;
48
+ }
49
+
50
+ export function SplatCollisionProxyPreview({
51
+ collisionProxy,
52
+ xmlText,
53
+ fetchXml = fetchSplatCollisionProxyXml,
54
+ color = '#60a5fa',
55
+ opacity = 0.12,
56
+ planeColor = '#94a3b8',
57
+ planeOpacity = 0.08,
58
+ children,
59
+ ...groupProps
60
+ }: SplatCollisionProxyPreviewProps) {
61
+ const { geoms } = useSplatCollisionProxyGeoms({
62
+ collisionProxy,
63
+ xmlText,
64
+ fetchXml,
65
+ });
66
+
67
+ if (geoms.length === 0 && !children) return null;
68
+
69
+ return (
70
+ <group
71
+ {...groupProps}
72
+ userData={{
73
+ kind: 'splat-collision-proxy-preview',
74
+ ...groupProps.userData,
75
+ }}
76
+ >
77
+ {geoms.map((geom) => (
78
+ <SplatCollisionProxyGeom
79
+ key={geom.id}
80
+ geom={geom}
81
+ color={color}
82
+ opacity={opacity}
83
+ planeColor={planeColor}
84
+ planeOpacity={planeOpacity}
85
+ />
86
+ ))}
87
+ {children}
88
+ </group>
89
+ );
90
+ }
91
+
92
+ export function useSplatCollisionProxyGeoms({
93
+ collisionProxy,
94
+ xmlText,
95
+ fetchXml = fetchSplatCollisionProxyXml,
96
+ enabled = true,
97
+ }: UseSplatCollisionProxyGeomsOptions): SplatCollisionProxyGeomsState {
98
+ const [loadedXmlText, setLoadedXmlText] = useState<string | null>(null);
99
+ const [status, setStatus] =
100
+ useState<SplatCollisionProxyPreviewStatus>('idle');
101
+ const [error, setError] = useState<Error | null>(null);
102
+ const xmlPath = collisionProxy?.xmlPath;
103
+
104
+ useEffect(() => {
105
+ let cancelled = false;
106
+
107
+ if (!enabled) {
108
+ setLoadedXmlText(null);
109
+ setStatus('idle');
110
+ setError(null);
111
+ return undefined;
112
+ }
113
+
114
+ if (xmlText) {
115
+ setLoadedXmlText(xmlText);
116
+ setStatus('ready');
117
+ setError(null);
118
+ return undefined;
119
+ }
120
+
121
+ if (!xmlPath || !canFetchSplatCollisionProxyXml(xmlPath)) {
122
+ setLoadedXmlText(null);
123
+ setStatus('idle');
124
+ setError(null);
125
+ return undefined;
126
+ }
127
+ const fetchPath = xmlPath;
128
+
129
+ async function loadProxyXml() {
130
+ setStatus('loading');
131
+ setError(null);
132
+
133
+ try {
134
+ const nextXmlText = await fetchXml(fetchPath);
135
+ if (!cancelled) {
136
+ setLoadedXmlText(nextXmlText);
137
+ setStatus('ready');
138
+ }
139
+ } catch (nextError) {
140
+ if (!cancelled) {
141
+ setLoadedXmlText(null);
142
+ setStatus('error');
143
+ setError(
144
+ nextError instanceof Error
145
+ ? nextError
146
+ : new Error('Unable to load collision proxy XML.')
147
+ );
148
+ }
149
+ }
150
+ }
151
+
152
+ void loadProxyXml();
153
+
154
+ return () => {
155
+ cancelled = true;
156
+ };
157
+ }, [enabled, fetchXml, xmlPath, xmlText]);
158
+
159
+ const geoms = useMemo(
160
+ () => (loadedXmlText ? parseSplatCollisionProxyGeoms(loadedXmlText) : []),
161
+ [loadedXmlText]
162
+ );
163
+
164
+ return useMemo(
165
+ () => ({
166
+ geoms,
167
+ status,
168
+ error,
169
+ xmlPath,
170
+ }),
171
+ [error, geoms, status, xmlPath]
172
+ );
173
+ }
174
+
175
+ export async function fetchSplatCollisionProxyXml(xmlPath: string) {
176
+ const response = await fetch(xmlPath);
177
+ if (!response.ok) {
178
+ throw new Error(`Unable to load collision proxy XML (${response.status}).`);
179
+ }
180
+ return response.text();
181
+ }
182
+
183
+ export function canFetchSplatCollisionProxyXml(xmlPath: string) {
184
+ return (
185
+ xmlPath.startsWith('/') ||
186
+ xmlPath.startsWith('http://') ||
187
+ xmlPath.startsWith('https://')
188
+ );
189
+ }
190
+
191
+ export function parseSplatCollisionProxyGeoms(
192
+ xmlText: string
193
+ ): SplatCollisionProxyGeomPreview[] {
194
+ const parser = typeof DOMParser === 'undefined' ? null : new DOMParser();
195
+ if (!parser) return [];
196
+
197
+ const document = parser.parseFromString(xmlText, 'application/xml');
198
+ if (document.querySelector('parsererror')) return [];
199
+
200
+ const bodyPositions = new Map<Element, SplatCollisionProxyPreviewVector3>();
201
+
202
+ for (const body of Array.from(document.querySelectorAll('body'))) {
203
+ const parentBody = body.parentElement?.closest('body');
204
+ const parentPosition: SplatCollisionProxyPreviewVector3 = parentBody
205
+ ? bodyPositions.get(parentBody) ?? [0, 0, 0]
206
+ : [0, 0, 0];
207
+ bodyPositions.set(
208
+ body,
209
+ addProxyVectors(parentPosition, parseProxyVector(body.getAttribute('pos')))
210
+ );
211
+ }
212
+
213
+ return Array.from(document.querySelectorAll('geom'))
214
+ .map((geom, index): SplatCollisionProxyGeomPreview | null => {
215
+ const type = getCollisionProxyGeomType(geom);
216
+ if (!type) return null;
217
+ const parentBody = geom.closest('body');
218
+ const bodyPosition: SplatCollisionProxyPreviewVector3 = parentBody
219
+ ? bodyPositions.get(parentBody) ?? [0, 0, 0]
220
+ : [0, 0, 0];
221
+ const position = addProxyVectors(
222
+ bodyPosition,
223
+ parseProxyVector(geom.getAttribute('pos'))
224
+ );
225
+ const size = parseNumberList(geom.getAttribute('size'));
226
+ return {
227
+ id: geom.getAttribute('name') ?? `${type}-${index}`,
228
+ type,
229
+ position,
230
+ size,
231
+ };
232
+ })
233
+ .filter((geom): geom is SplatCollisionProxyGeomPreview => Boolean(geom));
234
+ }
235
+
236
+ function SplatCollisionProxyGeom({
237
+ geom,
238
+ color,
239
+ opacity,
240
+ planeColor,
241
+ planeOpacity,
242
+ }: {
243
+ geom: SplatCollisionProxyGeomPreview;
244
+ color: string;
245
+ opacity: number;
246
+ planeColor: string;
247
+ planeOpacity: number;
248
+ }) {
249
+ if (geom.type === 'sphere') {
250
+ return (
251
+ <mesh position={geom.position}>
252
+ <sphereGeometry args={[geom.size[0] ?? 0.1, 16, 8]} />
253
+ <SplatCollisionProxyMaterial color={color} opacity={opacity} />
254
+ </mesh>
255
+ );
256
+ }
257
+
258
+ if (geom.type === 'plane') {
259
+ const width = geom.size[0] && geom.size[0] > 0 ? geom.size[0] * 2 : 4;
260
+ const height = geom.size[1] && geom.size[1] > 0 ? geom.size[1] * 2 : 4;
261
+ return (
262
+ <mesh position={geom.position}>
263
+ <boxGeometry args={[width, height, 0.02]} />
264
+ <SplatCollisionProxyMaterial color={planeColor} opacity={planeOpacity} />
265
+ </mesh>
266
+ );
267
+ }
268
+
269
+ const size = getCollisionProxyBoxSize(geom);
270
+ return (
271
+ <mesh position={geom.position}>
272
+ <boxGeometry args={size} />
273
+ <SplatCollisionProxyMaterial color={color} opacity={opacity} />
274
+ </mesh>
275
+ );
276
+ }
277
+
278
+ function SplatCollisionProxyMaterial({
279
+ color,
280
+ opacity,
281
+ }: {
282
+ color: string;
283
+ opacity: number;
284
+ }) {
285
+ return (
286
+ <meshBasicMaterial
287
+ color={color}
288
+ transparent
289
+ opacity={opacity}
290
+ wireframe
291
+ />
292
+ );
293
+ }
294
+
295
+ function getCollisionProxyGeomType(
296
+ geom: Element
297
+ ): SplatCollisionProxyGeomPreview['type'] | null {
298
+ const type = geom.getAttribute('type') ?? 'sphere';
299
+ if (
300
+ type === 'box' ||
301
+ type === 'plane' ||
302
+ type === 'sphere' ||
303
+ type === 'capsule' ||
304
+ type === 'mesh'
305
+ ) {
306
+ return type;
307
+ }
308
+ return null;
309
+ }
310
+
311
+ function getCollisionProxyBoxSize(
312
+ geom: SplatCollisionProxyGeomPreview
313
+ ): SplatCollisionProxyPreviewVector3 {
314
+ if (geom.type === 'capsule') {
315
+ const radius = geom.size[0] ?? 0.05;
316
+ const halfLength = geom.size[1] ?? radius;
317
+ return [radius * 2, radius * 2, Math.max(radius * 2, halfLength * 2)];
318
+ }
319
+
320
+ if (geom.type === 'mesh') return [0.2, 0.2, 0.2];
321
+
322
+ return [
323
+ (geom.size[0] ?? 0.1) * 2,
324
+ (geom.size[1] ?? geom.size[0] ?? 0.1) * 2,
325
+ (geom.size[2] ?? geom.size[0] ?? 0.1) * 2,
326
+ ];
327
+ }
328
+
329
+ function parseProxyVector(
330
+ value: string | null
331
+ ): SplatCollisionProxyPreviewVector3 {
332
+ const values = parseNumberList(value);
333
+ return [values[0] ?? 0, values[1] ?? 0, values[2] ?? 0];
334
+ }
335
+
336
+ function parseNumberList(value: string | null) {
337
+ if (!value) return [];
338
+ return value
339
+ .trim()
340
+ .split(/\s+/)
341
+ .map((part) => Number(part))
342
+ .filter((part) => Number.isFinite(part));
343
+ }
344
+
345
+ function addProxyVectors(
346
+ a: SplatCollisionProxyPreviewVector3,
347
+ b: SplatCollisionProxyPreviewVector3
348
+ ): SplatCollisionProxyPreviewVector3 {
349
+ return [a[0] + b[0], a[1] + b[1], a[2] + b[2]];
350
+ }
@@ -26,6 +26,8 @@ import type {
26
26
  ScenarioLightingProps,
27
27
  SplatEnvironmentProps,
28
28
  VisualScenarioConfig,
29
+ VisualScenarioExecutionContext,
30
+ VisualScenarioExecutionContextInput,
29
31
  VisualScenarioEffectsProps,
30
32
  } from '../types';
31
33
 
@@ -114,6 +116,84 @@ export function getScenarioCameraPosition(
114
116
  ];
115
117
  }
116
118
 
119
+ export function useVisualScenarioExecutionContext({
120
+ scenario,
121
+ environment,
122
+ renderer,
123
+ variantId,
124
+ enabled,
125
+ }: VisualScenarioExecutionContextInput): VisualScenarioExecutionContext {
126
+ return useMemo(
127
+ () =>
128
+ createVisualScenarioExecutionContext({
129
+ scenario,
130
+ environment,
131
+ renderer,
132
+ variantId,
133
+ enabled,
134
+ }),
135
+ [enabled, environment, renderer, scenario, variantId]
136
+ );
137
+ }
138
+
139
+ export function createVisualScenarioExecutionContext({
140
+ scenario,
141
+ environment,
142
+ renderer,
143
+ variantId,
144
+ enabled = true,
145
+ }: VisualScenarioExecutionContextInput): VisualScenarioExecutionContext {
146
+ const pairedEnvironment =
147
+ environment ??
148
+ (scenario ? createPairedSplatEnvironment(scenario, { renderer }) : undefined);
149
+ const splat = scenario?.splat;
150
+ const collisionProxy =
151
+ pairedEnvironment?.collisionProxy ?? splat?.collisionProxy ?? undefined;
152
+ const readiness = getSplatEnvironmentReadiness({
153
+ environment: pairedEnvironment,
154
+ scenario,
155
+ renderer,
156
+ enabled,
157
+ });
158
+ const format =
159
+ pairedEnvironment?.splat.format ?? splat?.format ?? readiness.format ?? 'spz';
160
+
161
+ return {
162
+ scenarioId: scenario?.id ?? pairedEnvironment?.id ?? 'visual-scenario',
163
+ scenarioLabel:
164
+ scenario?.label ?? pairedEnvironment?.label ?? 'Visual scenario',
165
+ variantId,
166
+ seed: scenario?.seed ?? 0,
167
+ lighting: scenario?.lighting ?? 'studio',
168
+ environment: scenario?.environment,
169
+ camera: {
170
+ jitter: scenario?.camera?.jitter ?? 0,
171
+ exposure: scenario?.camera?.exposure ?? 1,
172
+ noise: scenario?.camera?.noise ?? 0,
173
+ blur: scenario?.camera?.blur ?? 0,
174
+ },
175
+ materials: {
176
+ randomizeObjectColors: Boolean(
177
+ scenario?.materials?.randomizeObjectColors
178
+ ),
179
+ randomizeTableMaterial: Boolean(
180
+ scenario?.materials?.randomizeTableMaterial
181
+ ),
182
+ roughness: scenario?.materials?.roughness,
183
+ metalness: scenario?.materials?.metalness,
184
+ },
185
+ splatEnabled: Boolean(splat?.enabled || pairedEnvironment),
186
+ splatSrc: pairedEnvironment?.splat.src ?? splat?.src,
187
+ splatFormat: format,
188
+ splatRenderer: renderer ?? pairedEnvironment?.splat.renderer,
189
+ collisionProxyXmlPath: collisionProxy?.xmlPath,
190
+ collisionProxyStatus: collisionProxy?.status,
191
+ collisionProxyPrimitives: collisionProxy?.primitives ?? [],
192
+ readiness,
193
+ transformSource: 'visualScenario.camera',
194
+ };
195
+ }
196
+
117
197
  export function VisualScenarioEffects(props: VisualScenarioEffectsProps) {
118
198
  useVisualScenarioEffects(props);
119
199
  return null;
@@ -341,43 +421,55 @@ export function useSplatSceneConfig({
341
421
  enabled = true,
342
422
  renderer,
343
423
  }: SplatSceneConfigInput): SplatSceneConfigState {
344
- const resolvedEnvironment = useMemo(
345
- () =>
346
- enabled
347
- ? environment ??
348
- (scenario
349
- ? createPairedSplatEnvironment(scenario, { renderer })
350
- : undefined)
351
- : undefined,
352
- [enabled, environment, renderer, scenario]
353
- );
354
- const readiness = useMemo(
424
+ return useMemo(
355
425
  () =>
356
- getSplatEnvironmentReadiness({
357
- environment: resolvedEnvironment,
426
+ createSplatSceneConfig({
427
+ sceneConfig,
358
428
  scenario,
359
- renderer,
429
+ environment,
360
430
  enabled,
431
+ renderer,
361
432
  }),
362
- [enabled, renderer, resolvedEnvironment, scenario]
363
- );
364
- const resolvedSceneConfig = useMemo(
365
- () =>
366
- resolvedEnvironment
367
- ? withSplatEnvironment(sceneConfig, resolvedEnvironment)
368
- : sceneConfig,
369
- [resolvedEnvironment, sceneConfig]
433
+ [enabled, environment, renderer, scenario, sceneConfig]
370
434
  );
435
+ }
371
436
 
372
- return useMemo(
373
- () => ({
374
- environment: resolvedEnvironment,
375
- sceneConfig: resolvedSceneConfig,
376
- enabled: enabled && readiness.status !== SplatEnvironmentReadinessStatus.Disabled,
377
- readiness,
378
- }),
379
- [enabled, readiness, resolvedEnvironment, resolvedSceneConfig]
380
- );
437
+ /**
438
+ * Resolve a visual scenario's paired splat environment without requiring React.
439
+ *
440
+ * Use this in codegen, import validators, backend handoff metadata, or app code
441
+ * that needs the same behavior as `useSplatSceneConfig` outside a component.
442
+ */
443
+ export function createSplatSceneConfig({
444
+ sceneConfig,
445
+ scenario,
446
+ environment,
447
+ enabled = true,
448
+ renderer,
449
+ }: SplatSceneConfigInput): SplatSceneConfigState {
450
+ const resolvedEnvironment = enabled
451
+ ? environment ??
452
+ (scenario
453
+ ? createPairedSplatEnvironment(scenario, { renderer })
454
+ : undefined)
455
+ : undefined;
456
+ const readiness = getSplatEnvironmentReadiness({
457
+ environment: resolvedEnvironment,
458
+ scenario,
459
+ renderer,
460
+ enabled,
461
+ });
462
+ const resolvedSceneConfig = resolvedEnvironment
463
+ ? withSplatEnvironment(sceneConfig, resolvedEnvironment, { renderer })
464
+ : sceneConfig;
465
+
466
+ return {
467
+ environment: resolvedEnvironment,
468
+ sceneConfig: resolvedSceneConfig,
469
+ enabled:
470
+ enabled && readiness.status !== SplatEnvironmentReadinessStatus.Disabled,
471
+ readiness,
472
+ };
381
473
  }
382
474
 
383
475
  export function getSplatEnvironmentReadiness({
@@ -468,9 +560,9 @@ export function getSplatEnvironmentReadiness({
468
560
  }
469
561
 
470
562
  /**
471
- * Convert a generic visual scenario splat block into a paired visual/physics
472
- * environment config. Returns undefined until both the splat asset and MJCF
473
- * collision proxy are present.
563
+ * Convert a generic visual scenario splat block into a composable splat
564
+ * environment config. Visual-only splats are valid; readiness reports whether
565
+ * a paired MJCF collision proxy is required before training/physics handoff.
474
566
  */
475
567
  export function createPairedSplatEnvironment(
476
568
  scenario: Pick<VisualScenarioConfig, 'id' | 'label' | 'environment' | 'splat'>,
@@ -484,7 +576,7 @@ export function createPairedSplatEnvironment(
484
576
  const splat = scenario.splat;
485
577
  const collisionProxy = splat?.collisionProxy;
486
578
 
487
- if (!splat?.enabled || !splat.src || !collisionProxy?.xmlPath) {
579
+ if (!splat?.enabled || !splat.src) {
488
580
  return undefined;
489
581
  }
490
582
 
@@ -501,15 +593,22 @@ export function createPairedSplatEnvironment(
501
593
  format: splat.format ?? 'spz',
502
594
  renderer: options.renderer,
503
595
  },
504
- collisionProxy: {
505
- ...collisionProxy,
506
- xmlPath: collisionProxy.xmlPath,
507
- },
596
+ collisionProxy: collisionProxy?.xmlPath
597
+ ? {
598
+ ...collisionProxy,
599
+ xmlPath: collisionProxy.xmlPath,
600
+ }
601
+ : undefined,
508
602
  };
509
603
  }
510
604
 
511
605
  function isPairedSplatEnvironment(input: SplatSceneInput): input is PairedSplatEnvironmentConfig {
512
- return !!input && 'collisionProxy' in input && 'splat' in input;
606
+ return (
607
+ !!input &&
608
+ 'splat' in input &&
609
+ !!input.splat &&
610
+ !('enabled' in input.splat)
611
+ );
513
612
  }
514
613
 
515
614
  function sceneRelativePath(sceneConfig: SceneConfig, path: string): string {
@@ -549,7 +648,7 @@ export function withSplatEnvironment(
549
648
  : input
550
649
  ? createPairedSplatEnvironment(input, options)
551
650
  : undefined;
552
- const xmlPath = environment?.collisionProxy.xmlPath;
651
+ const xmlPath = environment?.collisionProxy?.xmlPath;
553
652
  if (!xmlPath) return sceneConfig;
554
653
 
555
654
  return {
@@ -148,12 +148,12 @@ function vector3FromArray(values: ArrayLike<number>, offset: number): [number, n
148
148
  return [values[offset], values[offset + 1], values[offset + 2]];
149
149
  }
150
150
 
151
- function quaternionFromArray(values: ArrayLike<number>, offset: number): [number, number, number, number] {
151
+ function quaternionFromMujocoQuat(values: ArrayLike<number>, offset: number): [number, number, number, number] {
152
152
  return [
153
- values[offset],
154
153
  values[offset + 1],
155
154
  values[offset + 2],
156
155
  values[offset + 3],
156
+ values[offset],
157
157
  ];
158
158
  }
159
159
 
@@ -993,7 +993,7 @@ export function MujocoSimProvider({
993
993
  ? vector3FromArray(model.cam_pos, posOffset)
994
994
  : null,
995
995
  quaternion: model.cam_quat
996
- ? quaternionFromArray(model.cam_quat, quatOffset)
996
+ ? quaternionFromMujocoQuat(model.cam_quat, quatOffset)
997
997
  : null,
998
998
  });
999
999
  }
@@ -1024,7 +1024,7 @@ export function MujocoSimProvider({
1024
1024
  const quaternion = data.cam_xmat
1025
1025
  ? quaternionFromXmat(data.cam_xmat, cameraId * 9)
1026
1026
  : model.cam_quat
1027
- ? quaternionFromArray(model.cam_quat, cameraId * 4)
1027
+ ? quaternionFromMujocoQuat(model.cam_quat, cameraId * 4)
1028
1028
  : undefined;
1029
1029
 
1030
1030
  if (!position || !quaternion) {
@@ -1442,7 +1442,7 @@ export function MujocoSimProvider({
1442
1442
  const cameraFrames: Record<string, CameraFrameCaptureResult> = {};
1443
1443
  for (const { key, captureOptions, mountedSource, session } of captureSessions) {
1444
1444
  const resolvedCaptureOptions = resolveCameraCaptureOptions(captureOptions);
1445
- const cameraFrame = session.captureDataUrl({
1445
+ const cameraFrame = await session.captureDataUrlAsync({
1446
1446
  ...resolvedCaptureOptions,
1447
1447
  source: mountedSource ?? resolvedCaptureOptions.source,
1448
1448
  });
@@ -1471,7 +1471,7 @@ export function MujocoSimProvider({
1471
1471
 
1472
1472
  const frame = {
1473
1473
  frameIndex,
1474
- time: getTime(),
1474
+ time: data.time,
1475
1475
  cameras: cameraFrames,
1476
1476
  };
1477
1477
  if (retainFrames) {