mujoco-react 8.5.0 → 8.7.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 +117 -24
- package/bin/mujoco-react-codegen.mjs +3 -0
- package/bin/mujoco-react.mjs +98 -0
- package/dist/index.d.ts +57 -3
- package/dist/index.js +453 -76
- package/dist/index.js.map +1 -1
- package/dist/vite.d.ts +48 -0
- package/dist/vite.js +201 -0
- package/dist/vite.js.map +1 -0
- package/package.json +13 -1
- package/src/components/InstancedGeomRenderer.tsx +158 -0
- package/src/components/SceneRenderer.tsx +51 -12
- package/src/core/MujocoCanvas.tsx +2 -0
- package/src/core/MujocoPhysics.tsx +2 -0
- package/src/core/MujocoSimProvider.tsx +138 -10
- package/src/core/SceneLoader.ts +190 -60
- package/src/index.ts +11 -0
- package/src/types.ts +46 -1
- package/src/vite.ts +281 -0
|
@@ -25,6 +25,8 @@ import {
|
|
|
25
25
|
ContactInfo,
|
|
26
26
|
GeomInfo,
|
|
27
27
|
JointInfo,
|
|
28
|
+
LoadFromFilesOptions,
|
|
29
|
+
LocalMujocoFile,
|
|
28
30
|
ModelOptions,
|
|
29
31
|
MujocoSimAPI,
|
|
30
32
|
PhysicsStepCallback,
|
|
@@ -34,9 +36,11 @@ import {
|
|
|
34
36
|
SensorInfo,
|
|
35
37
|
SiteInfo,
|
|
36
38
|
StateSnapshot,
|
|
39
|
+
XmlPatch,
|
|
37
40
|
} from '../types';
|
|
38
41
|
import {
|
|
39
42
|
loadScene,
|
|
43
|
+
createSceneConfigFromFiles,
|
|
40
44
|
findKeyframeByName,
|
|
41
45
|
findBodyByName,
|
|
42
46
|
findGeomByName,
|
|
@@ -110,6 +114,8 @@ export interface MujocoSimContextValue {
|
|
|
110
114
|
pausedRef: React.RefObject<boolean>;
|
|
111
115
|
speedRef: React.RefObject<number>;
|
|
112
116
|
substepsRef: React.RefObject<number>;
|
|
117
|
+
interpolateRef: React.RefObject<boolean>;
|
|
118
|
+
interpolationStateRef: React.RefObject<BodyInterpolationState>;
|
|
113
119
|
onSelectionRef: React.RefObject<
|
|
114
120
|
((bodyId: number, name: string) => void) | undefined
|
|
115
121
|
>;
|
|
@@ -123,6 +129,15 @@ export interface MujocoSimContextValue {
|
|
|
123
129
|
status: 'loading' | 'ready' | 'error';
|
|
124
130
|
}
|
|
125
131
|
|
|
132
|
+
export interface BodyInterpolationState {
|
|
133
|
+
alpha: number;
|
|
134
|
+
previousXpos: Float64Array;
|
|
135
|
+
previousXquat: Float64Array;
|
|
136
|
+
currentXpos: Float64Array;
|
|
137
|
+
currentXquat: Float64Array;
|
|
138
|
+
valid: boolean;
|
|
139
|
+
}
|
|
140
|
+
|
|
126
141
|
const MujocoSimContext = createContext<MujocoSimContextValue | null>(null);
|
|
127
142
|
|
|
128
143
|
export type UseMujocoResult =
|
|
@@ -214,6 +229,7 @@ interface MujocoSimProviderProps {
|
|
|
214
229
|
substeps?: number;
|
|
215
230
|
paused?: boolean;
|
|
216
231
|
speed?: number;
|
|
232
|
+
interpolate?: boolean;
|
|
217
233
|
children: React.ReactNode;
|
|
218
234
|
}
|
|
219
235
|
|
|
@@ -230,6 +246,7 @@ export function MujocoSimProvider({
|
|
|
230
246
|
substeps,
|
|
231
247
|
paused,
|
|
232
248
|
speed,
|
|
249
|
+
interpolate,
|
|
233
250
|
children,
|
|
234
251
|
}: MujocoSimProviderProps) {
|
|
235
252
|
const { gl, camera } = useThree();
|
|
@@ -243,6 +260,16 @@ export function MujocoSimProvider({
|
|
|
243
260
|
const pausedRef = useRef(paused ?? false);
|
|
244
261
|
const speedRef = useRef(speed ?? 1);
|
|
245
262
|
const substepsRef = useRef(substeps ?? 1);
|
|
263
|
+
const interpolateRef = useRef(interpolate ?? false);
|
|
264
|
+
const interpolationStateRef = useRef<BodyInterpolationState>({
|
|
265
|
+
alpha: 1,
|
|
266
|
+
previousXpos: new Float64Array(0),
|
|
267
|
+
previousXquat: new Float64Array(0),
|
|
268
|
+
currentXpos: new Float64Array(0),
|
|
269
|
+
currentXquat: new Float64Array(0),
|
|
270
|
+
valid: false,
|
|
271
|
+
});
|
|
272
|
+
const physicsAccumulatorRef = useRef(0);
|
|
246
273
|
const stepsToRunRef = useRef(0);
|
|
247
274
|
const loadGenRef = useRef(0);
|
|
248
275
|
|
|
@@ -259,12 +286,13 @@ export function MujocoSimProvider({
|
|
|
259
286
|
const hiddenBodiesRef = useRef(new Set<string>());
|
|
260
287
|
const bodyReloadTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
261
288
|
|
|
262
|
-
configRef.current = config;
|
|
289
|
+
useEffect(() => { configRef.current = config; }, [config]);
|
|
263
290
|
|
|
264
291
|
// Sync declarative props to refs
|
|
265
292
|
useEffect(() => { pausedRef.current = paused ?? false; }, [paused]);
|
|
266
293
|
useEffect(() => { speedRef.current = speed ?? 1; }, [speed]);
|
|
267
294
|
useEffect(() => { substepsRef.current = substeps ?? 1; }, [substeps]);
|
|
295
|
+
useEffect(() => { interpolateRef.current = interpolate ?? false; }, [interpolate]);
|
|
268
296
|
|
|
269
297
|
// Sync gravity prop
|
|
270
298
|
useEffect(() => {
|
|
@@ -312,6 +340,8 @@ export function MujocoSimProvider({
|
|
|
312
340
|
|
|
313
341
|
mjModelRef.current = result.mjModel;
|
|
314
342
|
mjDataRef.current = result.mjData;
|
|
343
|
+
physicsAccumulatorRef.current = 0;
|
|
344
|
+
interpolationStateRef.current.valid = false;
|
|
315
345
|
|
|
316
346
|
// Apply declarative physics props after load
|
|
317
347
|
if (gravity && result.mjModel.opt?.gravity) {
|
|
@@ -340,6 +370,8 @@ export function MujocoSimProvider({
|
|
|
340
370
|
mjDataRef.current?.delete();
|
|
341
371
|
mjModelRef.current = null;
|
|
342
372
|
mjDataRef.current = null;
|
|
373
|
+
physicsAccumulatorRef.current = 0;
|
|
374
|
+
interpolationStateRef.current.valid = false;
|
|
343
375
|
try { mujoco.FS.unmount('/working'); } catch { /* ignore */ }
|
|
344
376
|
};
|
|
345
377
|
}, [mujoco, config]);
|
|
@@ -380,21 +412,62 @@ export function MujocoSimProvider({
|
|
|
380
412
|
cb(model, data);
|
|
381
413
|
}
|
|
382
414
|
|
|
383
|
-
// Step physics with substeps
|
|
384
415
|
const numSubsteps = substepsRef.current;
|
|
385
|
-
if (
|
|
416
|
+
if (!interpolateRef.current) {
|
|
417
|
+
// Step physics with substeps
|
|
418
|
+
if (stepsToRunRef.current > 0) {
|
|
419
|
+
for (let s = 0; s < stepsToRunRef.current; s++) {
|
|
420
|
+
mujoco.mj_step(model, data);
|
|
421
|
+
}
|
|
422
|
+
stepsToRunRef.current = 0;
|
|
423
|
+
} else {
|
|
424
|
+
const startSimTime = data.time;
|
|
425
|
+
const clampedDelta = Math.min(delta, 1 / 15); // cap to avoid spiral of death
|
|
426
|
+
const frameTime = clampedDelta * speedRef.current;
|
|
427
|
+
while (data.time - startSimTime < frameTime) {
|
|
428
|
+
for (let s = 0; s < numSubsteps; s++) {
|
|
429
|
+
mujoco.mj_step(model, data);
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
} else if (stepsToRunRef.current > 0) {
|
|
434
|
+
ensureInterpolationBuffers(model);
|
|
435
|
+
copyBodyPose(data, interpolationStateRef.current.previousXpos, interpolationStateRef.current.previousXquat);
|
|
386
436
|
for (let s = 0; s < stepsToRunRef.current; s++) {
|
|
387
437
|
mujoco.mj_step(model, data);
|
|
388
438
|
}
|
|
439
|
+
copyBodyPose(data, interpolationStateRef.current.currentXpos, interpolationStateRef.current.currentXquat);
|
|
440
|
+
interpolationStateRef.current.alpha = 1;
|
|
441
|
+
interpolationStateRef.current.valid = true;
|
|
389
442
|
stepsToRunRef.current = 0;
|
|
390
443
|
} else {
|
|
391
|
-
|
|
444
|
+
ensureInterpolationBuffers(model);
|
|
392
445
|
const clampedDelta = Math.min(delta, 1 / 15); // cap to avoid spiral of death
|
|
393
|
-
|
|
394
|
-
|
|
446
|
+
physicsAccumulatorRef.current += clampedDelta * speedRef.current;
|
|
447
|
+
const stepDt = Math.max((model.opt?.timestep ?? 0.002) * Math.max(1, numSubsteps), 1e-6);
|
|
448
|
+
let stepped = false;
|
|
449
|
+
|
|
450
|
+
while (physicsAccumulatorRef.current >= stepDt) {
|
|
451
|
+
copyBodyPose(data, interpolationStateRef.current.previousXpos, interpolationStateRef.current.previousXquat);
|
|
395
452
|
for (let s = 0; s < numSubsteps; s++) {
|
|
396
453
|
mujoco.mj_step(model, data);
|
|
397
454
|
}
|
|
455
|
+
copyBodyPose(data, interpolationStateRef.current.currentXpos, interpolationStateRef.current.currentXquat);
|
|
456
|
+
physicsAccumulatorRef.current -= stepDt;
|
|
457
|
+
stepped = true;
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
if (!interpolationStateRef.current.valid) {
|
|
461
|
+
copyBodyPose(data, interpolationStateRef.current.previousXpos, interpolationStateRef.current.previousXquat);
|
|
462
|
+
copyBodyPose(data, interpolationStateRef.current.currentXpos, interpolationStateRef.current.currentXquat);
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
interpolationStateRef.current.alpha = Math.min(Math.max(physicsAccumulatorRef.current / stepDt, 0), 1);
|
|
466
|
+
interpolationStateRef.current.valid = true;
|
|
467
|
+
|
|
468
|
+
if (!stepped) {
|
|
469
|
+
onStepRef.current?.(data.time);
|
|
470
|
+
return;
|
|
398
471
|
}
|
|
399
472
|
}
|
|
400
473
|
|
|
@@ -406,6 +479,21 @@ export function MujocoSimProvider({
|
|
|
406
479
|
onStepRef.current?.(data.time);
|
|
407
480
|
}, -1);
|
|
408
481
|
|
|
482
|
+
function ensureInterpolationBuffers(model: MujocoModel) {
|
|
483
|
+
const state = interpolationStateRef.current;
|
|
484
|
+
const xposLength = model.nbody * 3;
|
|
485
|
+
const xquatLength = model.nbody * 4;
|
|
486
|
+
if (state.previousXpos.length !== xposLength) state.previousXpos = new Float64Array(xposLength);
|
|
487
|
+
if (state.currentXpos.length !== xposLength) state.currentXpos = new Float64Array(xposLength);
|
|
488
|
+
if (state.previousXquat.length !== xquatLength) state.previousXquat = new Float64Array(xquatLength);
|
|
489
|
+
if (state.currentXquat.length !== xquatLength) state.currentXquat = new Float64Array(xquatLength);
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
function copyBodyPose(data: MujocoData, xpos: Float64Array, xquat: Float64Array) {
|
|
493
|
+
xpos.set(data.xpos.subarray(0, xpos.length));
|
|
494
|
+
xquat.set(data.xquat.subarray(0, xquat.length));
|
|
495
|
+
}
|
|
496
|
+
|
|
409
497
|
// --- API Methods ---
|
|
410
498
|
|
|
411
499
|
const reset = useCallback(() => {
|
|
@@ -849,7 +937,7 @@ export function MujocoSimProvider({
|
|
|
849
937
|
mjDataRef.current = null;
|
|
850
938
|
setStatus('loading');
|
|
851
939
|
|
|
852
|
-
const result = await loadScene(mujoco, newConfig);
|
|
940
|
+
const result = await loadScene(mujoco, buildMergedConfig(newConfig));
|
|
853
941
|
|
|
854
942
|
if (gen !== loadGenRef.current) {
|
|
855
943
|
result.mjModel.delete();
|
|
@@ -859,6 +947,8 @@ export function MujocoSimProvider({
|
|
|
859
947
|
|
|
860
948
|
mjModelRef.current = result.mjModel;
|
|
861
949
|
mjDataRef.current = result.mjData;
|
|
950
|
+
physicsAccumulatorRef.current = 0;
|
|
951
|
+
interpolationStateRef.current.valid = false;
|
|
862
952
|
configRef.current = newConfig;
|
|
863
953
|
|
|
864
954
|
setStatus('ready');
|
|
@@ -874,10 +964,41 @@ export function MujocoSimProvider({
|
|
|
874
964
|
if (bodyReloadTimerRef.current) clearTimeout(bodyReloadTimerRef.current);
|
|
875
965
|
bodyReloadTimerRef.current = setTimeout(() => {
|
|
876
966
|
bodyReloadTimerRef.current = null;
|
|
877
|
-
loadSceneApi(
|
|
967
|
+
loadSceneApi(configRef.current);
|
|
878
968
|
}, 0);
|
|
879
969
|
}, [loadSceneApi]);
|
|
880
970
|
|
|
971
|
+
const loadFromFilesApi = useCallback(
|
|
972
|
+
async (files: FileList | readonly LocalMujocoFile[], options?: LoadFromFilesOptions): Promise<void> => {
|
|
973
|
+
await loadSceneApi(createSceneConfigFromFiles(files, options));
|
|
974
|
+
},
|
|
975
|
+
[loadSceneApi]
|
|
976
|
+
);
|
|
977
|
+
|
|
978
|
+
const addBodyApi = useCallback(async (body: SceneObject): Promise<void> => {
|
|
979
|
+
const current = configRef.current;
|
|
980
|
+
const sceneObjects = [
|
|
981
|
+
...(current.sceneObjects ?? []).filter((obj) => obj.name !== body.name),
|
|
982
|
+
body,
|
|
983
|
+
];
|
|
984
|
+
await loadSceneApi({ ...current, sceneObjects });
|
|
985
|
+
}, [loadSceneApi]);
|
|
986
|
+
|
|
987
|
+
const removeBodyApi = useCallback(async (name: string): Promise<void> => {
|
|
988
|
+
const current = configRef.current;
|
|
989
|
+
bodyRegistryRef.current.delete(name);
|
|
990
|
+
const sceneObjects = (current.sceneObjects ?? []).filter((obj) => obj.name !== name);
|
|
991
|
+
await loadSceneApi({ ...current, sceneObjects });
|
|
992
|
+
}, [loadSceneApi]);
|
|
993
|
+
|
|
994
|
+
const recompileApi = useCallback(async (patches: XmlPatch[] = []): Promise<void> => {
|
|
995
|
+
const current = configRef.current;
|
|
996
|
+
await loadSceneApi({
|
|
997
|
+
...current,
|
|
998
|
+
xmlPatches: patches.length ? [...(current.xmlPatches ?? []), ...patches] : current.xmlPatches,
|
|
999
|
+
});
|
|
1000
|
+
}, [loadSceneApi]);
|
|
1001
|
+
|
|
881
1002
|
const getCanvasSnapshot = useCallback(
|
|
882
1003
|
(width?: number, height?: number, mimeType = 'image/jpeg'): string => {
|
|
883
1004
|
if (width && height) {
|
|
@@ -961,7 +1082,7 @@ export function MujocoSimProvider({
|
|
|
961
1082
|
const api = useMemo<MujocoSimAPI>(
|
|
962
1083
|
() => ({
|
|
963
1084
|
get status() { return status; },
|
|
964
|
-
config,
|
|
1085
|
+
get config() { return configRef.current; },
|
|
965
1086
|
reset,
|
|
966
1087
|
setSpeed,
|
|
967
1088
|
togglePause,
|
|
@@ -1000,6 +1121,10 @@ export function MujocoSimProvider({
|
|
|
1000
1121
|
getKeyframeNames,
|
|
1001
1122
|
getKeyframeCount,
|
|
1002
1123
|
loadScene: loadSceneApi,
|
|
1124
|
+
loadFromFiles: loadFromFilesApi,
|
|
1125
|
+
addBody: addBodyApi,
|
|
1126
|
+
removeBody: removeBodyApi,
|
|
1127
|
+
recompile: recompileApi,
|
|
1003
1128
|
getCanvasSnapshot,
|
|
1004
1129
|
project2DTo3D,
|
|
1005
1130
|
setBodyMass,
|
|
@@ -1009,7 +1134,7 @@ export function MujocoSimProvider({
|
|
|
1009
1134
|
mjDataRef,
|
|
1010
1135
|
}),
|
|
1011
1136
|
[
|
|
1012
|
-
status,
|
|
1137
|
+
status, reset, setSpeed, togglePause, setPaused, step,
|
|
1013
1138
|
getTime, getTimestep, applyKeyframe, saveState, restoreState,
|
|
1014
1139
|
setQpos, setQvel, getQpos, getQvel, setCtrl, getCtrl,
|
|
1015
1140
|
getControlMapApi, getActuatedJointsApi, resolveControlGroupApi,
|
|
@@ -1017,6 +1142,7 @@ export function MujocoSimProvider({
|
|
|
1017
1142
|
getSensorData, getContacts, getBodies, getJoints, getGeoms, getSites,
|
|
1018
1143
|
getActuatorsApi, getSensors, getModelOption, setGravity, setTimestepApi,
|
|
1019
1144
|
raycast, getKeyframeNames, getKeyframeCount, loadSceneApi,
|
|
1145
|
+
loadFromFilesApi, addBodyApi, removeBodyApi, recompileApi,
|
|
1020
1146
|
getCanvasSnapshot, project2DTo3D,
|
|
1021
1147
|
setBodyMass, setGeomFriction, setGeomSize,
|
|
1022
1148
|
]
|
|
@@ -1034,6 +1160,8 @@ export function MujocoSimProvider({
|
|
|
1034
1160
|
pausedRef,
|
|
1035
1161
|
speedRef,
|
|
1036
1162
|
substepsRef,
|
|
1163
|
+
interpolateRef,
|
|
1164
|
+
interpolationStateRef,
|
|
1037
1165
|
onSelectionRef,
|
|
1038
1166
|
beforeStepCallbacks,
|
|
1039
1167
|
afterStepCallbacks,
|
package/src/core/SceneLoader.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
657
|
-
|
|
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
|
-
|
|
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
|
-
|
|
805
|
+
paths.push(fullPath);
|
|
677
806
|
});
|
|
807
|
+
return paths;
|
|
678
808
|
}
|
package/src/index.ts
CHANGED
|
@@ -44,6 +44,7 @@ export { SceneLights } from './components/SceneLights';
|
|
|
44
44
|
export { Debug } from './components/Debug';
|
|
45
45
|
export { TendonRenderer } from './components/TendonRenderer';
|
|
46
46
|
export { FlexRenderer } from './components/FlexRenderer';
|
|
47
|
+
export { InstancedGeomRenderer } from './components/InstancedGeomRenderer';
|
|
47
48
|
export { ContactListener } from './components/ContactListener';
|
|
48
49
|
export { TrajectoryPlayer } from './components/TrajectoryPlayer';
|
|
49
50
|
|
|
@@ -141,6 +142,16 @@ export type {
|
|
|
141
142
|
JointStateResult,
|
|
142
143
|
// Register (type-safe named resources)
|
|
143
144
|
Register,
|
|
145
|
+
RegisteredRobotMap,
|
|
146
|
+
RobotResource,
|
|
147
|
+
Robots,
|
|
148
|
+
RobotActuators,
|
|
149
|
+
RobotSensors,
|
|
150
|
+
RobotBodies,
|
|
151
|
+
RobotJoints,
|
|
152
|
+
RobotSites,
|
|
153
|
+
RobotGeoms,
|
|
154
|
+
RobotKeyframes,
|
|
144
155
|
Actuators,
|
|
145
156
|
Sensors,
|
|
146
157
|
Bodies,
|