mujoco-react 8.4.2 → 8.6.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.
@@ -10,6 +10,8 @@ import type {
10
10
  ControlGroupSelector,
11
11
  ControlJointInfo,
12
12
  JointInfo,
13
+ LoadFromFilesOptions,
14
+ LocalMujocoFile,
13
15
  MujocoData,
14
16
  MujocoModel,
15
17
  MujocoModule,
@@ -505,6 +507,170 @@ function loadModelFromPath(mujoco: MujocoModule, path: string): MujocoModel {
505
507
  throw new Error('MuJoCo WASM module does not expose an XML path loader');
506
508
  }
507
509
 
510
+ function isModelTextFile(fname: string): boolean {
511
+ const lower = fname.toLowerCase();
512
+ return lower.endsWith('.xml') || lower.endsWith('.urdf') || lower.endsWith('.mjcf');
513
+ }
514
+
515
+ function normalizeVfsPath(path: string): string {
516
+ const parts = path.replace(/\\/g, '/').split('/');
517
+ const norm: string[] = [];
518
+ for (const part of parts) {
519
+ if (!part || part === '.') continue;
520
+ if (part === '..') norm.pop();
521
+ else norm.push(part);
522
+ }
523
+ return norm.join('/');
524
+ }
525
+
526
+ function localFilePath(file: LocalMujocoFile): string {
527
+ return normalizeVfsPath(file.webkitRelativePath || file.name);
528
+ }
529
+
530
+ function inferSceneFile(files: readonly LocalMujocoFile[], options?: LoadFromFilesOptions): string {
531
+ if (options?.sceneFile) return normalizeVfsPath(options.sceneFile);
532
+
533
+ const paths = files.map(localFilePath);
534
+ const preferred = ['scene.xml', 'model.xml', 'robot.xml', 'scene.urdf', 'model.urdf', 'robot.urdf'];
535
+ for (const name of preferred) {
536
+ const match = paths.find((path) => path.endsWith(name));
537
+ if (match) return match;
538
+ }
539
+
540
+ const firstModel = paths.find(isModelTextFile);
541
+ if (!firstModel) throw new Error('No MJCF XML or URDF file found in FileList');
542
+ return firstModel;
543
+ }
544
+
545
+ export function createSceneConfigFromFiles(
546
+ files: FileList | readonly LocalMujocoFile[],
547
+ options: LoadFromFilesOptions = {}
548
+ ): SceneConfig {
549
+ const fileArray = Array.from(files) as LocalMujocoFile[];
550
+ return {
551
+ src: '',
552
+ sceneFile: inferSceneFile(fileArray, options),
553
+ files: fileArray,
554
+ homeJoints: options.homeJoints,
555
+ xmlPatches: options.xmlPatches,
556
+ sceneObjects: options.sceneObjects,
557
+ onReset: options.onReset,
558
+ };
559
+ }
560
+
561
+ function applyXmlPatches(text: string, fname: string, config: SceneConfig): string {
562
+ let result = text;
563
+ for (const patch of config.xmlPatches ?? []) {
564
+ if (fname.endsWith(patch.target) || fname === patch.target) {
565
+ if (patch.replace) {
566
+ const [from, to] = patch.replace;
567
+ if (result.includes(from)) {
568
+ result = result.replace(from, to);
569
+ } else {
570
+ const preview = from.length > 80 ? `${from.slice(0, 80)}...` : from;
571
+ console.warn(`XML patch replace pattern not found in ${fname}: "${preview}"`);
572
+ }
573
+ }
574
+ if (patch.inject && patch.injectAfter) {
575
+ const idx = result.indexOf(patch.injectAfter);
576
+ if (idx !== -1) {
577
+ const tagEnd = result.indexOf('>', idx + patch.injectAfter.length);
578
+ if (tagEnd !== -1) {
579
+ result = result.slice(0, tagEnd + 1) + patch.inject + result.slice(tagEnd + 1);
580
+ } else {
581
+ console.warn(`XML patch inject failed in ${fname}: could not find tag end after "${patch.injectAfter}"`);
582
+ }
583
+ } else {
584
+ const preview = patch.injectAfter.length > 80
585
+ ? `${patch.injectAfter.slice(0, 80)}...`
586
+ : patch.injectAfter;
587
+ console.warn(`XML patch inject anchor not found in ${fname}: "${preview}"`);
588
+ }
589
+ }
590
+ }
591
+ }
592
+
593
+ if (fname === config.sceneFile && config.sceneObjects?.length && result.includes('</worldbody>')) {
594
+ const xml = config.sceneObjects.map((obj) => sceneObjectToXml(obj)).join('');
595
+ result = result.replace('</worldbody>', xml + '</worldbody>');
596
+ }
597
+
598
+ return result;
599
+ }
600
+
601
+ async function loadSceneFromFiles(
602
+ mujoco: MujocoModule,
603
+ config: SceneConfig,
604
+ onProgress?: (msg: string) => void
605
+ ): Promise<LoadResult> {
606
+ const files = config.files ?? [];
607
+ if (!files.length) throw new Error('loadFromFiles requires at least one File');
608
+
609
+ try { mujoco.FS.unmount('/working'); } catch { /* ignore */ }
610
+ try { mujoco.FS.mkdir('/working'); } catch { /* ignore */ }
611
+
612
+ const parser = new DOMParser();
613
+ const byPath = new Map<string, LocalMujocoFile>();
614
+ const byBasename = new Map<string, LocalMujocoFile>();
615
+ const written = new Set<string>();
616
+ const textByPath = new Map<string, string>();
617
+
618
+ for (const file of files) {
619
+ const path = localFilePath(file);
620
+ byPath.set(path, file);
621
+ byBasename.set(path.split('/').pop() ?? path, file);
622
+ }
623
+
624
+ for (const [path, file] of byPath) {
625
+ onProgress?.(`Reading ${path}...`);
626
+ ensureDir(mujoco, path);
627
+ if (isModelTextFile(path)) {
628
+ const text = applyXmlPatches(await file.text(), path, config);
629
+ textByPath.set(path, text);
630
+ mujoco.FS.writeFile(`/working/${path}`, text);
631
+ } else {
632
+ mujoco.FS.writeFile(`/working/${path}`, new Uint8Array(await file.arrayBuffer()));
633
+ }
634
+ written.add(path);
635
+ }
636
+
637
+ for (const [path, text] of textByPath) {
638
+ const deps = collectDependencyPaths(text, path, parser);
639
+ for (const dep of deps) {
640
+ if (written.has(dep)) continue;
641
+ const file = byPath.get(dep) ?? byBasename.get(dep.split('/').pop() ?? dep);
642
+ if (!file) continue;
643
+ ensureDir(mujoco, dep);
644
+ if (isModelTextFile(dep)) {
645
+ mujoco.FS.writeFile(`/working/${dep}`, applyXmlPatches(await file.text(), dep, config));
646
+ } else {
647
+ mujoco.FS.writeFile(`/working/${dep}`, new Uint8Array(await file.arrayBuffer()));
648
+ }
649
+ written.add(dep);
650
+ }
651
+ }
652
+
653
+ onProgress?.('Loading model...');
654
+ const mjModel = loadModelFromPath(mujoco, `/working/${config.sceneFile}`);
655
+ const mjData = new mujoco.MjData(mjModel);
656
+ applyInitialPose(mjModel, mjData, config);
657
+ mujoco.mj_forward(mjModel, mjData);
658
+
659
+ return { mjModel, mjData };
660
+ }
661
+
662
+ function applyInitialPose(mjModel: MujocoModel, mjData: MujocoData, config: SceneConfig) {
663
+ if (!config.homeJoints) return;
664
+ const homeCount = Math.min(config.homeJoints.length, Math.max(mjModel.nu, mjModel.nq));
665
+ for (let i = 0; i < homeCount; i++) {
666
+ if (i < mjModel.nu) mjData.ctrl[i] = config.homeJoints[i];
667
+ if (i < mjModel.nq) {
668
+ const qposAdr = i < mjModel.nu ? getActuatedScalarQposAdr(mjModel, i) : -1;
669
+ mjData.qpos[qposAdr !== -1 ? qposAdr : i] = config.homeJoints[i];
670
+ }
671
+ }
672
+ }
673
+
508
674
  /**
509
675
  * Config-driven scene loader — replaces the old RobotLoader + patchSingleRobot approach.
510
676
  */
@@ -513,6 +679,10 @@ export async function loadScene(
513
679
  config: SceneConfig,
514
680
  onProgress?: (msg: string) => void
515
681
  ): Promise<LoadResult> {
682
+ if (config.files?.length) {
683
+ return loadSceneFromFiles(mujoco, config, onProgress);
684
+ }
685
+
516
686
  // 1. Clean up virtual filesystem
517
687
  try { mujoco.FS.unmount('/working'); } catch { /* ignore */ }
518
688
  try { mujoco.FS.mkdir('/working'); } catch { /* ignore */ }
@@ -530,7 +700,7 @@ export async function loadScene(
530
700
  if (downloaded.has(fname)) continue;
531
701
  downloaded.add(fname);
532
702
 
533
- if (!fname.endsWith('.xml')) {
703
+ if (!isModelTextFile(fname)) {
534
704
  // Non-XML discovered during XML scan — collect for parallel download
535
705
  assetFiles.push(fname);
536
706
  continue;
@@ -544,44 +714,7 @@ export async function loadScene(
544
714
  continue;
545
715
  }
546
716
 
547
- let text = await res.text();
548
-
549
- // 3. Apply XML patches from config
550
- for (const patch of config.xmlPatches ?? []) {
551
- if (fname.endsWith(patch.target) || fname === patch.target) {
552
- if (patch.replace) {
553
- const [from, to] = patch.replace;
554
- if (text.includes(from)) {
555
- text = text.replace(from, to);
556
- } else {
557
- const preview = from.length > 80 ? `${from.slice(0, 80)}...` : from;
558
- console.warn(`XML patch replace pattern not found in ${fname}: "${preview}"`);
559
- }
560
- }
561
- if (patch.inject && patch.injectAfter) {
562
- const idx = text.indexOf(patch.injectAfter);
563
- if (idx !== -1) {
564
- const tagEnd = text.indexOf('>', idx + patch.injectAfter.length);
565
- if (tagEnd !== -1) {
566
- text = text.slice(0, tagEnd + 1) + patch.inject + text.slice(tagEnd + 1);
567
- } else {
568
- console.warn(`XML patch inject failed in ${fname}: could not find tag end after "${patch.injectAfter}"`);
569
- }
570
- } else {
571
- const preview = patch.injectAfter.length > 80
572
- ? `${patch.injectAfter.slice(0, 80)}...`
573
- : patch.injectAfter;
574
- console.warn(`XML patch inject anchor not found in ${fname}: "${preview}"`);
575
- }
576
- }
577
- }
578
- }
579
-
580
- // 4. Inject scene objects into the scene file
581
- if (fname === config.sceneFile && config.sceneObjects?.length) {
582
- const xml = config.sceneObjects.map((obj) => sceneObjectToXml(obj)).join('');
583
- text = text.replace('</worldbody>', xml + '</worldbody>');
584
- }
717
+ const text = applyXmlPatches(await res.text(), fname, config);
585
718
 
586
719
  ensureDir(mujoco, fname);
587
720
  mujoco.FS.writeFile(`/working/${fname}`, text);
@@ -617,16 +750,7 @@ export async function loadScene(
617
750
 
618
751
  // 6. Set initial pose — set both ctrl and qpos so robot starts at home.
619
752
  // If homeJoints is not provided, keep raw MuJoCo defaults.
620
- if (config.homeJoints) {
621
- const homeCount = Math.min(config.homeJoints.length, mjModel.nu);
622
- for (let i = 0; i < homeCount; i++) {
623
- mjData.ctrl[i] = config.homeJoints[i];
624
- const qposAdr = getActuatedScalarQposAdr(mjModel, i);
625
- if (qposAdr !== -1) {
626
- mjData.qpos[qposAdr] = config.homeJoints[i];
627
- }
628
- }
629
- }
753
+ applyInitialPose(mjModel, mjData, config);
630
754
 
631
755
  mujoco.mj_forward(mjModel, mjData);
632
756
 
@@ -643,6 +767,16 @@ function scanDependencies(
643
767
  downloaded: Set<string>,
644
768
  queue: string[]
645
769
  ) {
770
+ for (const fullPath of collectDependencyPaths(xmlString, currentFile, parser)) {
771
+ if (!downloaded.has(fullPath)) queue.push(fullPath);
772
+ }
773
+ }
774
+
775
+ function collectDependencyPaths(
776
+ xmlString: string,
777
+ currentFile: string,
778
+ parser: DOMParser
779
+ ): string[] {
646
780
  const xmlDoc = parser.parseFromString(xmlString, 'text/xml');
647
781
 
648
782
  const compiler = xmlDoc.querySelector('compiler');
@@ -653,9 +787,11 @@ function scanDependencies(
653
787
  ? currentFile.substring(0, currentFile.lastIndexOf('/') + 1)
654
788
  : '';
655
789
 
656
- xmlDoc.querySelectorAll('[file]').forEach((el) => {
657
- const fileAttr = el.getAttribute('file');
790
+ const paths: string[] = [];
791
+ xmlDoc.querySelectorAll('[file], [filename]').forEach((el) => {
792
+ const fileAttr = el.getAttribute('file') ?? el.getAttribute('filename');
658
793
  if (!fileAttr) return;
794
+ if (/^[a-z]+:\/\//i.test(fileAttr) || fileAttr.startsWith('package://')) return;
659
795
 
660
796
  let prefix = '';
661
797
  if (el.tagName.toLowerCase() === 'mesh') {
@@ -664,15 +800,9 @@ function scanDependencies(
664
800
  prefix = textureDir ? textureDir + '/' : '';
665
801
  }
666
802
 
667
- let fullPath = (currentDir + prefix + fileAttr).replace(/\/\//g, '/');
668
- const parts = fullPath.split('/');
669
- const norm: string[] = [];
670
- for (const p of parts) {
671
- if (p === '..') norm.pop();
672
- else if (p !== '.') norm.push(p);
673
- }
674
- fullPath = norm.join('/');
803
+ const fullPath = normalizeVfsPath(currentDir + prefix + fileAttr);
675
804
 
676
- if (!downloaded.has(fullPath)) queue.push(fullPath);
805
+ paths.push(fullPath);
677
806
  });
807
+ return paths;
678
808
  }
@@ -0,0 +1,38 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ import { useMemo, useRef } from 'react';
7
+ import { useMujocoContext } from '../core/MujocoSimProvider';
8
+ import { buildObservation } from '../core/ObservationBuilder';
9
+ import type { ObservationConfig, ObservationHandle, ObservationResult } from '../types';
10
+
11
+ const EMPTY_OBSERVATION: ObservationResult = {
12
+ values: new Float32Array(0),
13
+ layout: [],
14
+ };
15
+
16
+ /**
17
+ * Live observation reader for policy loops and telemetry.
18
+ *
19
+ * The handle is stable; call `read()` inside callbacks to sample the latest
20
+ * MuJoCo model/data state without forcing React renders.
21
+ */
22
+ export function useObservation(config: ObservationConfig): ObservationHandle {
23
+ const { mjModelRef, mjDataRef } = useMujocoContext();
24
+ const configRef = useRef(config);
25
+ configRef.current = config;
26
+
27
+ return useMemo(() => ({
28
+ read() {
29
+ const model = mjModelRef.current;
30
+ const data = mjDataRef.current;
31
+ if (!model || !data) return EMPTY_OBSERVATION;
32
+ return buildObservation(model, data, configRef.current);
33
+ },
34
+ readValues() {
35
+ return this.read().values;
36
+ },
37
+ }), [mjDataRef, mjModelRef]);
38
+ }
package/src/index.ts CHANGED
@@ -26,6 +26,7 @@ export {
26
26
  resolveControlGroup,
27
27
  createContiguousControlGroup,
28
28
  } from './core/SceneLoader';
29
+ export { buildObservation } from './core/ObservationBuilder';
29
30
 
30
31
  // Controller factory
31
32
  export { createController, createControllerHook } from './core/createController';
@@ -43,6 +44,7 @@ export { SceneLights } from './components/SceneLights';
43
44
  export { Debug } from './components/Debug';
44
45
  export { TendonRenderer } from './components/TendonRenderer';
45
46
  export { FlexRenderer } from './components/FlexRenderer';
47
+ export { InstancedGeomRenderer } from './components/InstancedGeomRenderer';
46
48
  export { ContactListener } from './components/ContactListener';
47
49
  export { TrajectoryPlayer } from './components/TrajectoryPlayer';
48
50
 
@@ -57,6 +59,7 @@ export { useCtrl } from './hooks/useCtrl';
57
59
  export { useContacts, useContactEvents } from './hooks/useContacts';
58
60
  export { useKeyboardTeleop } from './hooks/useKeyboardTeleop';
59
61
  export { usePolicy } from './hooks/usePolicy';
62
+ export { useObservation } from './hooks/useObservation';
60
63
  export { useTrajectoryPlayer } from './hooks/useTrajectoryPlayer';
61
64
  export { useTrajectoryRecorder } from './hooks/useTrajectoryRecorder';
62
65
  export { useGamepad } from './hooks/useGamepad';
@@ -112,6 +115,12 @@ export type {
112
115
  KeyboardTeleopConfig,
113
116
  // Policy
114
117
  PolicyConfig,
118
+ // Observations
119
+ ObservationConfig,
120
+ ObservationHandle,
121
+ ObservationLayoutItem,
122
+ ObservationOutput,
123
+ ObservationResult,
115
124
  // Component props
116
125
  BodyProps,
117
126
  IkGizmoProps,
package/src/types.ts CHANGED
@@ -331,11 +331,24 @@ export interface XmlPatch {
331
331
  replace?: [string, string];
332
332
  }
333
333
 
334
+ export type LocalMujocoFile = File;
335
+
336
+ export interface LoadFromFilesOptions {
337
+ /** Entry MJCF/URDF file. Inferred from scene.xml, model.xml, robot.xml, or the first XML/URDF file when omitted. */
338
+ sceneFile?: string;
339
+ homeJoints?: number[];
340
+ xmlPatches?: XmlPatch[];
341
+ sceneObjects?: SceneObject[];
342
+ onReset?: (model: MujocoModel, data: MujocoData) => void;
343
+ }
344
+
334
345
  export interface SceneConfig {
335
346
  /** Base URL for fetching model files. The loader fetches `src + sceneFile` and follows dependencies. */
336
347
  src: string;
337
- /** Entry MJCF XML file name, e.g. 'scene.xml'. */
348
+ /** Entry MJCF XML or URDF file name, e.g. 'scene.xml' or 'robot.urdf'. */
338
349
  sceneFile: string;
350
+ /** Browser-selected files for local MJCF/URDF loading. Preserves webkitRelativePath when available. */
351
+ files?: readonly LocalMujocoFile[];
339
352
  sceneObjects?: SceneObject[];
340
353
  homeJoints?: number[];
341
354
  xmlPatches?: XmlPatch[];
@@ -595,6 +608,51 @@ export interface PolicyConfig {
595
608
  onAction: (action: Float32Array | Float64Array | number[], model: MujocoModel, data: MujocoData) => void;
596
609
  }
597
610
 
611
+ // ---- Observation Builder ----
612
+
613
+ export type ObservationOutput = 'float32' | 'float64';
614
+
615
+ export interface ObservationConfig {
616
+ /** Include scalar simulation time. */
617
+ time?: boolean;
618
+ /** Include all qpos values. */
619
+ qpos?: boolean;
620
+ /** Include all qvel values. */
621
+ qvel?: boolean;
622
+ /** Include all ctrl values. */
623
+ ctrl?: boolean;
624
+ /** Include all actuator activation values. */
625
+ act?: boolean;
626
+ /** Include all raw sensordata values. */
627
+ sensordata?: boolean;
628
+ /** Include named sensor values in the configured order. */
629
+ sensors?: readonly Sensors[];
630
+ /** Include named site world positions in the configured order. */
631
+ sites?: readonly Sites[];
632
+ /** Include world gravity projected into each named body's local frame. */
633
+ projectedGravity?: Bodies | readonly Bodies[];
634
+ /** Output array type. Defaults to Float32Array. */
635
+ output?: ObservationOutput;
636
+ }
637
+
638
+ export interface ObservationLayoutItem {
639
+ name: string;
640
+ start: number;
641
+ size: number;
642
+ }
643
+
644
+ export interface ObservationResult {
645
+ values: Float32Array | Float64Array;
646
+ layout: ObservationLayoutItem[];
647
+ }
648
+
649
+ export interface ObservationHandle {
650
+ /** Read a fresh observation from the current live MuJoCo model/data refs. */
651
+ read(): ObservationResult;
652
+ /** Read just the vector values for policy inference. */
653
+ readValues(): Float32Array | Float64Array;
654
+ }
655
+
598
656
  // ---- Debug Component (spec 6.1) ----
599
657
 
600
658
  export interface DebugProps {
@@ -733,6 +791,10 @@ export interface MujocoSimAPI {
733
791
 
734
792
  // Model loading (spec 9.1)
735
793
  loadScene(newConfig: SceneConfig): Promise<void>;
794
+ loadFromFiles(files: FileList | readonly LocalMujocoFile[], options?: LoadFromFilesOptions): Promise<void>;
795
+ addBody(body: SceneObject): Promise<void>;
796
+ removeBody(name: Bodies): Promise<void>;
797
+ recompile(patches?: XmlPatch[]): Promise<void>;
736
798
 
737
799
  // Canvas
738
800
  getCanvasSnapshot(width?: number, height?: number, mimeType?: string): string;
@@ -767,6 +829,7 @@ export type MujocoCanvasProps = Omit<CanvasProps, 'onError'> & {
767
829
  substeps?: number;
768
830
  paused?: boolean;
769
831
  speed?: number;
832
+ interpolate?: boolean;
770
833
  };
771
834
 
772
835
  // ---- Hook Return Types ----