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.
@@ -477,8 +477,9 @@ function sceneObjectToXml(obj: SceneObject): string {
477
477
  const solref = obj.solref ? ` solref="${obj.solref}"` : '';
478
478
  const solimp = obj.solimp ? ` solimp="${obj.solimp}"` : '';
479
479
  const condim = obj.condim ? ` condim="${obj.condim}"` : '';
480
+ const group = obj.group !== undefined ? ` group="${obj.group}"` : '';
480
481
  // Always set contype/conaffinity=1 so objects collide regardless of model defaults
481
- return `<body name="${obj.name}" pos="${pos}">${joint}<geom type="${obj.type}" size="${size}" rgba="${rgba}" contype="1" conaffinity="1"${mass}${friction}${solref}${solimp}${condim}/></body>`;
482
+ return `<body name="${obj.name}" pos="${pos}">${joint}<geom type="${obj.type}" size="${size}" rgba="${rgba}" contype="1" conaffinity="1"${mass}${friction}${solref}${solimp}${condim}${group}/></body>`;
482
483
  }
483
484
 
484
485
  /** Create virtual directory structure for a file path. */
@@ -527,6 +528,22 @@ function localFilePath(file: LocalMujocoFile): string {
527
528
  return normalizeVfsPath(file.webkitRelativePath || file.name);
528
529
  }
529
530
 
531
+ function dirname(path: string): string {
532
+ const normalized = normalizeVfsPath(path);
533
+ const idx = normalized.lastIndexOf('/');
534
+ return idx === -1 ? '' : normalized.slice(0, idx + 1);
535
+ }
536
+
537
+ function relativeVfsPath(fromDir: string, targetPath: string): string {
538
+ const from = normalizeVfsPath(fromDir).split('/').filter(Boolean);
539
+ const target = normalizeVfsPath(targetPath).split('/').filter(Boolean);
540
+ while (from.length && target.length && from[0] === target[0]) {
541
+ from.shift();
542
+ target.shift();
543
+ }
544
+ return [...from.map(() => '..'), ...target].join('/') || '.';
545
+ }
546
+
530
547
  function inferSceneFile(files: readonly LocalMujocoFile[], options?: LoadFromFilesOptions): string {
531
548
  if (options?.sceneFile) return normalizeVfsPath(options.sceneFile);
532
549
 
@@ -551,6 +568,7 @@ export function createSceneConfigFromFiles(
551
568
  src: '',
552
569
  sceneFile: inferSceneFile(fileArray, options),
553
570
  files: fileArray,
571
+ environmentFiles: options.environmentFiles?.map(normalizeVfsPath),
554
572
  homeJoints: options.homeJoints,
555
573
  xmlPatches: options.xmlPatches,
556
574
  sceneObjects: options.sceneObjects,
@@ -558,6 +576,137 @@ export function createSceneConfigFromFiles(
558
576
  };
559
577
  }
560
578
 
579
+ const ENVIRONMENT_MERGE_SECTIONS = [
580
+ 'asset',
581
+ 'worldbody',
582
+ 'contact',
583
+ 'equality',
584
+ 'tendon',
585
+ 'sensor',
586
+ 'keyframe',
587
+ 'custom',
588
+ 'extension',
589
+ ] as const;
590
+
591
+ function directChild(parent: Element, tagName: string): Element | null {
592
+ const lower = tagName.toLowerCase();
593
+ for (const child of Array.from(parent.children)) {
594
+ if (child.tagName.toLowerCase() === lower) return child;
595
+ }
596
+ return null;
597
+ }
598
+
599
+ function ensureTopLevelSection(doc: XMLDocument, tagName: string): Element {
600
+ const root = doc.documentElement;
601
+ const existing = directChild(root, tagName);
602
+ if (existing) return existing;
603
+
604
+ const section = doc.createElement(tagName);
605
+ if (tagName === 'asset') {
606
+ const worldbody = directChild(root, 'worldbody');
607
+ if (worldbody) root.insertBefore(section, worldbody);
608
+ else root.appendChild(section);
609
+ } else {
610
+ root.appendChild(section);
611
+ }
612
+ return section;
613
+ }
614
+
615
+ function readCompilerDirs(doc: XMLDocument) {
616
+ const compiler = directChild(doc.documentElement, 'compiler');
617
+ const assetDir = compiler?.getAttribute('assetdir') || '';
618
+ return {
619
+ meshDir: compiler?.getAttribute('meshdir') || assetDir,
620
+ textureDir: compiler?.getAttribute('texturedir') || assetDir,
621
+ };
622
+ }
623
+
624
+ function isExternalPath(path: string): boolean {
625
+ return /^[a-z]+:\/\//i.test(path) || path.startsWith('package://') || path.startsWith('/');
626
+ }
627
+
628
+ function fileReferencePrefix(el: Element, compilerDirs: ReturnType<typeof readCompilerDirs>): string {
629
+ const tag = el.tagName.toLowerCase();
630
+ if (tag === 'mesh') return compilerDirs.meshDir ? compilerDirs.meshDir + '/' : '';
631
+ if (tag === 'texture' || tag === 'hfield') return compilerDirs.textureDir ? compilerDirs.textureDir + '/' : '';
632
+ return '';
633
+ }
634
+
635
+ function rewriteFileReferencesForMerge(node: Element, sourceFile: string, targetFile: string, sourceDoc: XMLDocument) {
636
+ const sourceDir = dirname(sourceFile);
637
+ const targetDir = dirname(targetFile);
638
+ const compilerDirs = readCompilerDirs(sourceDoc);
639
+ node.querySelectorAll('[file], [filename]').forEach((el) => {
640
+ const attr = el.hasAttribute('file') ? 'file' : 'filename';
641
+ const value = el.getAttribute(attr);
642
+ if (!value || isExternalPath(value)) return;
643
+
644
+ const sourceRelativePath = normalizeVfsPath(fileReferencePrefix(el, compilerDirs) + value);
645
+ const resolvedPath = normalizeVfsPath(sourceDir + sourceRelativePath);
646
+ el.setAttribute(attr, relativeVfsPath(targetDir, resolvedPath));
647
+ });
648
+ }
649
+
650
+ function hasParseError(doc: XMLDocument): boolean {
651
+ return doc.getElementsByTagName('parsererror').length > 0;
652
+ }
653
+
654
+ function composeEnvironmentXml(
655
+ sceneXml: string,
656
+ config: SceneConfig,
657
+ parser: DOMParser,
658
+ environmentXmlByPath: Map<string, string>
659
+ ): string {
660
+ const environmentFiles = config.environmentFiles?.map(normalizeVfsPath) ?? [];
661
+ if (!environmentFiles.length) return sceneXml;
662
+
663
+ const sceneDoc = parser.parseFromString(sceneXml, 'text/xml');
664
+ if (hasParseError(sceneDoc)) {
665
+ console.warn(`Could not compose environments: failed to parse ${config.sceneFile}`);
666
+ return sceneXml;
667
+ }
668
+
669
+ for (const environmentFile of environmentFiles) {
670
+ const environmentXml = environmentXmlByPath.get(environmentFile);
671
+ if (!environmentXml) {
672
+ console.warn(`Environment XML not found: ${environmentFile}`);
673
+ continue;
674
+ }
675
+
676
+ const environmentDoc = parser.parseFromString(environmentXml, 'text/xml');
677
+ if (hasParseError(environmentDoc)) {
678
+ console.warn(`Skipping environment XML with parse errors: ${environmentFile}`);
679
+ continue;
680
+ }
681
+
682
+ for (const sectionName of ENVIRONMENT_MERGE_SECTIONS) {
683
+ const environmentSection = directChild(environmentDoc.documentElement, sectionName);
684
+ if (!environmentSection?.children.length) continue;
685
+
686
+ const targetSection = ensureTopLevelSection(sceneDoc, sectionName);
687
+ for (const child of Array.from(environmentSection.children)) {
688
+ const imported = sceneDoc.importNode(child, true) as Element;
689
+ rewriteFileReferencesForMerge(imported, environmentFile, config.sceneFile, environmentDoc);
690
+ targetSection.appendChild(imported);
691
+ }
692
+ }
693
+ }
694
+
695
+ return new XMLSerializer().serializeToString(sceneDoc);
696
+ }
697
+
698
+ function findTextByConfiguredPath(textByPath: Map<string, string>, configuredPath: string): string | undefined {
699
+ const normalized = normalizeVfsPath(configuredPath);
700
+ const direct = textByPath.get(normalized);
701
+ if (direct) return direct;
702
+
703
+ const suffix = '/' + normalized;
704
+ for (const [path, text] of textByPath) {
705
+ if (path.endsWith(suffix) || path === normalized.split('/').pop()) return text;
706
+ }
707
+ return undefined;
708
+ }
709
+
561
710
  function applyXmlPatches(text: string, fname: string, config: SceneConfig): string {
562
711
  let result = text;
563
712
  for (const patch of config.xmlPatches ?? []) {
@@ -627,10 +776,25 @@ async function loadSceneFromFiles(
627
776
  if (isModelTextFile(path)) {
628
777
  const text = applyXmlPatches(await file.text(), path, config);
629
778
  textByPath.set(path, text);
630
- mujoco.FS.writeFile(`/working/${path}`, text);
631
779
  } else {
632
780
  mujoco.FS.writeFile(`/working/${path}`, new Uint8Array(await file.arrayBuffer()));
781
+ written.add(path);
633
782
  }
783
+ }
784
+
785
+ const environmentXmlByPath = new Map<string, string>();
786
+ for (const environmentFile of config.environmentFiles?.map(normalizeVfsPath) ?? []) {
787
+ const environmentXml = findTextByConfiguredPath(textByPath, environmentFile);
788
+ if (environmentXml) environmentXmlByPath.set(environmentFile, environmentXml);
789
+ }
790
+
791
+ for (const [path, text] of textByPath) {
792
+ const composedText = path === config.sceneFile
793
+ ? composeEnvironmentXml(text, config, parser, environmentXmlByPath)
794
+ : text;
795
+ textByPath.set(path, composedText);
796
+ ensureDir(mujoco, path);
797
+ mujoco.FS.writeFile(`/working/${path}`, composedText);
634
798
  written.add(path);
635
799
  }
636
800
 
@@ -689,6 +853,18 @@ export async function loadScene(
689
853
 
690
854
  const baseUrl = config.src.endsWith('/') ? config.src : config.src + '/';
691
855
 
856
+ const environmentXmlByPath = new Map<string, string>();
857
+ const environmentFiles = config.environmentFiles?.map(normalizeVfsPath) ?? [];
858
+ for (const environmentFile of environmentFiles) {
859
+ onProgress?.(`Downloading ${environmentFile}...`);
860
+ const res = await fetch(baseUrl + environmentFile);
861
+ if (!res.ok) {
862
+ console.warn(`Failed to fetch environment XML ${environmentFile}: ${res.status} ${res.statusText}`);
863
+ continue;
864
+ }
865
+ environmentXmlByPath.set(environmentFile, applyXmlPatches(await res.text(), environmentFile, config));
866
+ }
867
+
692
868
  const downloaded = new Set<string>();
693
869
  const xmlQueue: string[] = [config.sceneFile];
694
870
  const assetFiles: string[] = [];
@@ -714,7 +890,10 @@ export async function loadScene(
714
890
  continue;
715
891
  }
716
892
 
717
- const text = applyXmlPatches(await res.text(), fname, config);
893
+ const patchedText = applyXmlPatches(await res.text(), fname, config);
894
+ const text = fname === config.sceneFile
895
+ ? composeEnvironmentXml(patchedText, config, parser, environmentXmlByPath)
896
+ : patchedText;
718
897
 
719
898
  ensureDir(mujoco, fname);
720
899
  mujoco.FS.writeFile(`/working/${fname}`, text);
@@ -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
  }
@@ -34,28 +34,41 @@ export function useSceneLights(intensity = 1.0) {
34
34
  const nlight = model.nlight ?? 0;
35
35
  if (nlight === 0) return;
36
36
 
37
+ const lightActive = getModelArray(model, 'light_active');
38
+ const lightTypeArray = getModelArray(model, 'light_type');
39
+ const lightCastShadow = getModelArray(model, 'light_castshadow');
40
+ const lightIntensity = getModelArray(model, 'light_intensity');
41
+ const lightDiffuse = getModelArray(model, 'light_diffuse');
42
+ const lightPos = getModelArray(model, 'light_pos');
43
+ const lightDir = getModelArray(model, 'light_dir');
44
+ const lightCutoff = getModelArray(model, 'light_cutoff');
45
+ const lightExponent = getModelArray(model, 'light_exponent');
46
+ const lightAttenuation = getModelArray(model, 'light_attenuation');
47
+
48
+ if (!lightPos || !lightDir) return;
49
+
37
50
  for (let i = 0; i < nlight; i++) {
38
- const active = model.light_active ? model.light_active[i] : 1;
51
+ const active = lightActive ? lightActive[i] : 1;
39
52
  if (!active) continue;
40
53
 
41
- const lightType = model.light_type ? model.light_type[i] : 0;
54
+ const lightType = lightTypeArray ? lightTypeArray[i] : 0;
42
55
  const isDirectional = lightType === 0;
43
- const castShadow = model.light_castshadow ? model.light_castshadow[i] !== 0 : false;
56
+ const castShadow = lightCastShadow ? lightCastShadow[i] !== 0 : false;
44
57
 
45
- const mjIntensity = model.light_intensity ? model.light_intensity[i] : 1.0;
58
+ const mjIntensity = lightIntensity ? lightIntensity[i] : 1.0;
46
59
  const finalIntensity = intensity * mjIntensity;
47
60
 
48
- const dr = model.light_diffuse ? model.light_diffuse[3 * i] : 1;
49
- const dg = model.light_diffuse ? model.light_diffuse[3 * i + 1] : 1;
50
- const db = model.light_diffuse ? model.light_diffuse[3 * i + 2] : 1;
61
+ const dr = lightDiffuse ? lightDiffuse[3 * i] : 1;
62
+ const dg = lightDiffuse ? lightDiffuse[3 * i + 1] : 1;
63
+ const db = lightDiffuse ? lightDiffuse[3 * i + 2] : 1;
51
64
  const color = new THREE.Color(dr, dg, db);
52
65
 
53
- const px = model.light_pos[3 * i];
54
- const py = model.light_pos[3 * i + 1];
55
- const pz = model.light_pos[3 * i + 2];
56
- const dx = model.light_dir[3 * i];
57
- const dy = model.light_dir[3 * i + 1];
58
- const dz = model.light_dir[3 * i + 2];
66
+ const px = lightPos[3 * i];
67
+ const py = lightPos[3 * i + 1];
68
+ const pz = lightPos[3 * i + 2];
69
+ const dx = lightDir[3 * i];
70
+ const dy = lightDir[3 * i + 1];
71
+ const dz = lightDir[3 * i + 2];
59
72
 
60
73
  if (isDirectional) {
61
74
  const light = new THREE.DirectionalLight(color, finalIntensity);
@@ -78,17 +91,17 @@ export function useSceneLights(intensity = 1.0) {
78
91
  lightsRef.current.push(light);
79
92
  targetsRef.current.push(light.target);
80
93
  } else {
81
- const cutoff = model.light_cutoff ? model.light_cutoff[i] : 45;
82
- const exponent = model.light_exponent ? model.light_exponent[i] : 10;
94
+ const cutoff = lightCutoff ? lightCutoff[i] : 45;
95
+ const exponent = lightExponent ? lightExponent[i] : 10;
83
96
  const angle = (cutoff * Math.PI) / 180;
84
97
  const light = new THREE.SpotLight(color, finalIntensity, 0, angle, exponent / 128);
85
98
  light.position.set(px, py, pz);
86
99
  light.target.position.set(px + dx, py + dy, pz + dz);
87
100
  light.castShadow = castShadow;
88
101
 
89
- if (model.light_attenuation) {
90
- const att1 = model.light_attenuation[3 * i + 1];
91
- const att2 = model.light_attenuation[3 * i + 2];
102
+ if (lightAttenuation) {
103
+ const att1 = lightAttenuation[3 * i + 1];
104
+ const att2 = lightAttenuation[3 * i + 2];
92
105
  light.decay = att2 > 0 ? 2 : (att1 > 0 ? 1 : 0);
93
106
  light.distance = att1 > 0 ? 1 / att1 : 0;
94
107
  }
@@ -115,3 +128,21 @@ export function useSceneLights(intensity = 1.0) {
115
128
  };
116
129
  }, [status, mjModelRef, scene, intensity]);
117
130
  }
131
+
132
+ function getModelArray(model: unknown, key: string): ArrayLike<number> | undefined {
133
+ try {
134
+ const value = (model as Record<string, unknown>)[key];
135
+ return isArrayLikeNumber(value) ? value : undefined;
136
+ } catch {
137
+ return undefined;
138
+ }
139
+ }
140
+
141
+ function isArrayLikeNumber(value: unknown): value is ArrayLike<number> {
142
+ return (
143
+ typeof value === 'object' &&
144
+ value !== null &&
145
+ 'length' in value &&
146
+ typeof (value as ArrayLike<number>).length === 'number'
147
+ );
148
+ }
package/src/index.ts CHANGED
@@ -41,6 +41,19 @@ export { IkGizmo } from './components/IkGizmo';
41
41
  export { ContactMarkers } from './components/ContactMarkers';
42
42
  export { DragInteraction } from './components/DragInteraction';
43
43
  export { SceneLights } from './components/SceneLights';
44
+ export {
45
+ ScenarioLighting,
46
+ SplatEnvironment,
47
+ VisualScenarioEffects,
48
+ createPairedSplatEnvironment,
49
+ createSparkSplatViewerUrl,
50
+ createSplatEnvironmentUserData,
51
+ getScenarioBackground,
52
+ getScenarioCameraPosition,
53
+ useSplatEnvironment,
54
+ useVisualScenarioEffects,
55
+ withSplatEnvironment,
56
+ } from './components/VisualScenario';
44
57
  export { Debug } from './components/Debug';
45
58
  export { TendonRenderer } from './components/TendonRenderer';
46
59
  export { FlexRenderer } from './components/FlexRenderer';
@@ -64,6 +77,20 @@ export { useTrajectoryPlayer } from './hooks/useTrajectoryPlayer';
64
77
  export { useTrajectoryRecorder } from './hooks/useTrajectoryRecorder';
65
78
  export { useGamepad } from './hooks/useGamepad';
66
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';
67
94
  export { useCtrlNoise } from './hooks/useCtrlNoise';
68
95
  export { useBodyMeshes } from './hooks/useBodyMeshes';
69
96
  export { useSelectionHighlight } from './hooks/useSelectionHighlight';
@@ -115,6 +142,10 @@ export type {
115
142
  KeyboardTeleopConfig,
116
143
  // Policy
117
144
  PolicyConfig,
145
+ PolicyVector,
146
+ PolicyObservationInput,
147
+ PolicyInferenceInput,
148
+ PolicyActionInput,
118
149
  // Observations
119
150
  ObservationConfig,
120
151
  ObservationHandle,
@@ -127,6 +158,23 @@ export type {
127
158
  DragInteractionProps,
128
159
  DebugProps,
129
160
  SceneLightsProps,
161
+ ScenarioLightingPreset,
162
+ SplatFormat,
163
+ SplatRendererKind,
164
+ SplatCollisionPrimitive,
165
+ ScenarioCameraConfig,
166
+ SplatAssetConfig,
167
+ SplatScenarioConfig,
168
+ SplatCollisionProxyConfig,
169
+ PairedSplatEnvironmentConfig,
170
+ SplatEnvironmentMetadataInput,
171
+ SplatEnvironmentMetadata,
172
+ SplatSceneInput,
173
+ VisualScenarioConfig,
174
+ ScenarioLightingProps,
175
+ ScenarioMaterialConfig,
176
+ SplatEnvironmentProps,
177
+ VisualScenarioEffectsProps,
130
178
  TrajectoryPlayerProps,
131
179
  ContactListenerProps,
132
180
  // API