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/README.md +82 -1
- package/dist/chunk-SEWQULWO.js +400 -0
- package/dist/chunk-SEWQULWO.js.map +1 -0
- package/dist/index.d.ts +114 -744
- package/dist/index.js +329 -35
- package/dist/index.js.map +1 -1
- package/dist/spark.d.ts +53 -0
- package/dist/spark.js +235 -0
- package/dist/spark.js.map +1 -0
- package/dist/types-BmneHLBM.d.ts +871 -0
- package/dist/vite.d.ts +9 -0
- package/dist/vite.js +4 -0
- package/dist/vite.js.map +1 -1
- package/package.json +15 -2
- package/src/components/Body.tsx +3 -1
- package/src/components/VisualScenario.tsx +566 -0
- package/src/core/MujocoCanvas.tsx +8 -1
- package/src/core/SceneLoader.ts +182 -3
- package/src/hooks/useFrameCapture.ts +206 -0
- package/src/hooks/usePolicy.ts +12 -8
- package/src/hooks/useSceneLights.ts +49 -18
- package/src/index.ts +48 -0
- package/src/spark.tsx +336 -0
- package/src/types.ts +159 -3
- package/src/vite.ts +8 -0
package/src/core/SceneLoader.ts
CHANGED
|
@@ -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
|
|
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
|
+
}
|
package/src/hooks/usePolicy.ts
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import { useRef } from 'react';
|
|
9
|
-
import {
|
|
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
|
|
41
|
+
const observation = cfg.onObservation({ model, data });
|
|
42
|
+
const action = cfg.infer ? cfg.infer({ observation, model, data }) : observation;
|
|
41
43
|
|
|
42
|
-
// Apply action
|
|
43
|
-
cfg.onAction(
|
|
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
|
-
|
|
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
|
|
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 =
|
|
51
|
+
const active = lightActive ? lightActive[i] : 1;
|
|
39
52
|
if (!active) continue;
|
|
40
53
|
|
|
41
|
-
const lightType =
|
|
54
|
+
const lightType = lightTypeArray ? lightTypeArray[i] : 0;
|
|
42
55
|
const isDirectional = lightType === 0;
|
|
43
|
-
const castShadow =
|
|
56
|
+
const castShadow = lightCastShadow ? lightCastShadow[i] !== 0 : false;
|
|
44
57
|
|
|
45
|
-
const mjIntensity =
|
|
58
|
+
const mjIntensity = lightIntensity ? lightIntensity[i] : 1.0;
|
|
46
59
|
const finalIntensity = intensity * mjIntensity;
|
|
47
60
|
|
|
48
|
-
const dr =
|
|
49
|
-
const dg =
|
|
50
|
-
const db =
|
|
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 =
|
|
54
|
-
const py =
|
|
55
|
-
const pz =
|
|
56
|
-
const dx =
|
|
57
|
-
const dy =
|
|
58
|
-
const dz =
|
|
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 =
|
|
82
|
-
const exponent =
|
|
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 (
|
|
90
|
-
const att1 =
|
|
91
|
-
const att2 =
|
|
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
|