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
package/src/types.ts
CHANGED
|
@@ -17,6 +17,13 @@ import * as THREE from 'three';
|
|
|
17
17
|
* ```ts
|
|
18
18
|
* declare module 'mujoco-react' {
|
|
19
19
|
* interface Register {
|
|
20
|
+
* robots: {
|
|
21
|
+
* panda: {
|
|
22
|
+
* actuators: 'joint1' | 'joint2' | 'gripper';
|
|
23
|
+
* sensors: 'force_sensor' | 'torque_sensor';
|
|
24
|
+
* bodies: 'link0' | 'link1' | 'hand';
|
|
25
|
+
* };
|
|
26
|
+
* };
|
|
20
27
|
* actuators: 'joint1' | 'joint2' | 'gripper';
|
|
21
28
|
* sensors: 'force_sensor' | 'torque_sensor';
|
|
22
29
|
* bodies: 'link0' | 'link1' | 'hand';
|
|
@@ -28,6 +35,26 @@ import * as THREE from 'three';
|
|
|
28
35
|
*/
|
|
29
36
|
export interface Register {}
|
|
30
37
|
|
|
38
|
+
export type RegisteredRobotMap = Register extends { robots: infer T extends Record<string, Record<string, string>> }
|
|
39
|
+
? T
|
|
40
|
+
: never;
|
|
41
|
+
export type Robots = [RegisteredRobotMap] extends [never] ? string : Extract<keyof RegisteredRobotMap, string>;
|
|
42
|
+
export type RobotResource<TRobot extends string, TKey extends string> =
|
|
43
|
+
[RegisteredRobotMap] extends [never]
|
|
44
|
+
? string
|
|
45
|
+
: TRobot extends keyof RegisteredRobotMap
|
|
46
|
+
? TKey extends keyof RegisteredRobotMap[TRobot]
|
|
47
|
+
? RegisteredRobotMap[TRobot][TKey]
|
|
48
|
+
: string
|
|
49
|
+
: never;
|
|
50
|
+
export type RobotActuators<TRobot extends string> = RobotResource<TRobot, 'actuators'>;
|
|
51
|
+
export type RobotSensors<TRobot extends string> = RobotResource<TRobot, 'sensors'>;
|
|
52
|
+
export type RobotBodies<TRobot extends string> = RobotResource<TRobot, 'bodies'>;
|
|
53
|
+
export type RobotJoints<TRobot extends string> = RobotResource<TRobot, 'joints'>;
|
|
54
|
+
export type RobotSites<TRobot extends string> = RobotResource<TRobot, 'sites'>;
|
|
55
|
+
export type RobotGeoms<TRobot extends string> = RobotResource<TRobot, 'geoms'>;
|
|
56
|
+
export type RobotKeyframes<TRobot extends string> = RobotResource<TRobot, 'keyframes'>;
|
|
57
|
+
|
|
31
58
|
export type Actuators = Register extends { actuators: infer T extends string } ? T : string;
|
|
32
59
|
export type Sensors = Register extends { sensors: infer T extends string } ? T : string;
|
|
33
60
|
export type Bodies = Register extends { bodies: infer T extends string } ? T : string;
|
|
@@ -331,11 +358,24 @@ export interface XmlPatch {
|
|
|
331
358
|
replace?: [string, string];
|
|
332
359
|
}
|
|
333
360
|
|
|
361
|
+
export type LocalMujocoFile = File;
|
|
362
|
+
|
|
363
|
+
export interface LoadFromFilesOptions {
|
|
364
|
+
/** Entry MJCF/URDF file. Inferred from scene.xml, model.xml, robot.xml, or the first XML/URDF file when omitted. */
|
|
365
|
+
sceneFile?: string;
|
|
366
|
+
homeJoints?: number[];
|
|
367
|
+
xmlPatches?: XmlPatch[];
|
|
368
|
+
sceneObjects?: SceneObject[];
|
|
369
|
+
onReset?: (model: MujocoModel, data: MujocoData) => void;
|
|
370
|
+
}
|
|
371
|
+
|
|
334
372
|
export interface SceneConfig {
|
|
335
373
|
/** Base URL for fetching model files. The loader fetches `src + sceneFile` and follows dependencies. */
|
|
336
374
|
src: string;
|
|
337
|
-
/** Entry MJCF XML file name, e.g. 'scene.xml'. */
|
|
375
|
+
/** Entry MJCF XML or URDF file name, e.g. 'scene.xml' or 'robot.urdf'. */
|
|
338
376
|
sceneFile: string;
|
|
377
|
+
/** Browser-selected files for local MJCF/URDF loading. Preserves webkitRelativePath when available. */
|
|
378
|
+
files?: readonly LocalMujocoFile[];
|
|
339
379
|
sceneObjects?: SceneObject[];
|
|
340
380
|
homeJoints?: number[];
|
|
341
381
|
xmlPatches?: XmlPatch[];
|
|
@@ -778,6 +818,10 @@ export interface MujocoSimAPI {
|
|
|
778
818
|
|
|
779
819
|
// Model loading (spec 9.1)
|
|
780
820
|
loadScene(newConfig: SceneConfig): Promise<void>;
|
|
821
|
+
loadFromFiles(files: FileList | readonly LocalMujocoFile[], options?: LoadFromFilesOptions): Promise<void>;
|
|
822
|
+
addBody(body: SceneObject): Promise<void>;
|
|
823
|
+
removeBody(name: Bodies): Promise<void>;
|
|
824
|
+
recompile(patches?: XmlPatch[]): Promise<void>;
|
|
781
825
|
|
|
782
826
|
// Canvas
|
|
783
827
|
getCanvasSnapshot(width?: number, height?: number, mimeType?: string): string;
|
|
@@ -812,6 +856,7 @@ export type MujocoCanvasProps = Omit<CanvasProps, 'onError'> & {
|
|
|
812
856
|
substeps?: number;
|
|
813
857
|
paused?: boolean;
|
|
814
858
|
speed?: number;
|
|
859
|
+
interpolate?: boolean;
|
|
815
860
|
};
|
|
816
861
|
|
|
817
862
|
// ---- Hook Return Types ----
|
package/src/vite.ts
ADDED
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
|
|
9
|
+
type ViteConfig = { root: string };
|
|
10
|
+
type ViteServer = {
|
|
11
|
+
watcher: {
|
|
12
|
+
add(paths: string | string[]): void;
|
|
13
|
+
on(event: 'add' | 'change' | 'unlink', listener: (file: string) => void): void;
|
|
14
|
+
};
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export interface MujocoReactPluginOptions {
|
|
18
|
+
/** Entry MJCF/URDF files to scan. Prefer a record for stable per-robot type names. */
|
|
19
|
+
models: ModelInput;
|
|
20
|
+
/** Generated declaration file. Defaults to `src/mujoco-register.gen.d.ts`. */
|
|
21
|
+
generatedRegister?: string;
|
|
22
|
+
/** Module name to augment. Defaults to `mujoco-react`. */
|
|
23
|
+
moduleName?: string;
|
|
24
|
+
/** Disable console output. */
|
|
25
|
+
disableLogging?: boolean;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface MujocoRegisterCodegenOptions {
|
|
29
|
+
models: ModelInput;
|
|
30
|
+
out: string;
|
|
31
|
+
moduleName?: string;
|
|
32
|
+
root?: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface MujocoRegisterCodegenResult {
|
|
36
|
+
out: string;
|
|
37
|
+
files: string[];
|
|
38
|
+
counts: Record<RegisterKey, number>;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
type RegisterKey = 'actuators' | 'sensors' | 'bodies' | 'joints' | 'sites' | 'geoms' | 'keyframes';
|
|
42
|
+
export type ModelInput = string | readonly string[] | Record<string, string>;
|
|
43
|
+
|
|
44
|
+
interface ModelEntry {
|
|
45
|
+
id: string;
|
|
46
|
+
file: string;
|
|
47
|
+
names: Record<RegisterKey, Set<string>>;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const REGISTER_KEYS: RegisterKey[] = ['actuators', 'sensors', 'bodies', 'joints', 'sites', 'geoms', 'keyframes'];
|
|
51
|
+
const MODEL_EXTENSIONS = new Set(['.xml', '.mjcf', '.urdf']);
|
|
52
|
+
|
|
53
|
+
function createEmptyNames(): Record<RegisterKey, Set<string>> {
|
|
54
|
+
return {
|
|
55
|
+
actuators: new Set(),
|
|
56
|
+
sensors: new Set(),
|
|
57
|
+
bodies: new Set(),
|
|
58
|
+
joints: new Set(),
|
|
59
|
+
sites: new Set(),
|
|
60
|
+
geoms: new Set(),
|
|
61
|
+
keyframes: new Set(),
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function mujocoReact(options: MujocoReactPluginOptions) {
|
|
66
|
+
const models = normalizeModels(options.models);
|
|
67
|
+
let root = process.cwd();
|
|
68
|
+
let generatedRegister = options.generatedRegister ?? 'src/mujoco-register.gen.d.ts';
|
|
69
|
+
let watchedFiles: string[] = [];
|
|
70
|
+
|
|
71
|
+
async function generate() {
|
|
72
|
+
const result = await generateMujocoRegister({
|
|
73
|
+
models: options.models,
|
|
74
|
+
out: generatedRegister,
|
|
75
|
+
moduleName: options.moduleName,
|
|
76
|
+
root,
|
|
77
|
+
});
|
|
78
|
+
watchedFiles = result.files;
|
|
79
|
+
if (!options.disableLogging) {
|
|
80
|
+
const total = Object.values(result.counts).reduce((sum, count) => sum + count, 0);
|
|
81
|
+
console.log(`[mujoco-react] generated ${path.relative(root, result.out)} (${total} names)`);
|
|
82
|
+
}
|
|
83
|
+
return result;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return {
|
|
87
|
+
name: 'mujoco-react',
|
|
88
|
+
enforce: 'pre' as const,
|
|
89
|
+
configResolved(config: ViteConfig) {
|
|
90
|
+
root = config.root;
|
|
91
|
+
generatedRegister = path.resolve(root, generatedRegister);
|
|
92
|
+
},
|
|
93
|
+
async buildStart(this: { addWatchFile?: (file: string) => void }) {
|
|
94
|
+
const result = await generate();
|
|
95
|
+
for (const file of result.files) this.addWatchFile?.(file);
|
|
96
|
+
},
|
|
97
|
+
configureServer(server: ViteServer) {
|
|
98
|
+
generate().then((result) => server.watcher.add(result.files)).catch((error: unknown) => {
|
|
99
|
+
console.error('[mujoco-react] register generation failed', error);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
server.watcher.on('add', regenerateIfModelFile);
|
|
103
|
+
server.watcher.on('change', regenerateIfModelFile);
|
|
104
|
+
server.watcher.on('unlink', regenerateIfModelFile);
|
|
105
|
+
|
|
106
|
+
function regenerateIfModelFile(file: string) {
|
|
107
|
+
if (!shouldRegenerate(file, watchedFiles, models, root)) return;
|
|
108
|
+
generate().then((result) => server.watcher.add(result.files)).catch((error: unknown) => {
|
|
109
|
+
console.error('[mujoco-react] register generation failed', error);
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
},
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export async function generateMujocoRegister(
|
|
117
|
+
options: MujocoRegisterCodegenOptions
|
|
118
|
+
): Promise<MujocoRegisterCodegenResult> {
|
|
119
|
+
const root = path.resolve(options.root ?? process.cwd());
|
|
120
|
+
const out = path.resolve(root, options.out);
|
|
121
|
+
const moduleName = options.moduleName ?? 'mujoco-react';
|
|
122
|
+
const models = normalizeModels(options.models);
|
|
123
|
+
const names = createEmptyNames();
|
|
124
|
+
const seen = new Set<string>();
|
|
125
|
+
|
|
126
|
+
for (const model of models) {
|
|
127
|
+
await scanModel(path.resolve(root, model.file), root, seen, model.names);
|
|
128
|
+
mergeNames(names, model.names);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
await mkdir(path.dirname(out), { recursive: true });
|
|
132
|
+
await writeFile(out, renderRegister(moduleName, names, models), 'utf8');
|
|
133
|
+
|
|
134
|
+
return {
|
|
135
|
+
out,
|
|
136
|
+
files: [...seen].sort((a, b) => a.localeCompare(b)),
|
|
137
|
+
counts: Object.fromEntries(REGISTER_KEYS.map((key) => [key, names[key].size])) as Record<RegisterKey, number>,
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
async function scanModel(
|
|
142
|
+
filePath: string,
|
|
143
|
+
root: string,
|
|
144
|
+
seen: Set<string>,
|
|
145
|
+
names: Record<RegisterKey, Set<string>>
|
|
146
|
+
) {
|
|
147
|
+
const normalized = path.normalize(filePath);
|
|
148
|
+
if (seen.has(normalized)) return;
|
|
149
|
+
seen.add(normalized);
|
|
150
|
+
|
|
151
|
+
const xml = await readFile(normalized, 'utf8');
|
|
152
|
+
collectSimpleTagNames(xml, 'body', names.bodies);
|
|
153
|
+
collectSimpleTagNames(xml, 'joint', names.joints);
|
|
154
|
+
collectSimpleTagNames(xml, 'site', names.sites);
|
|
155
|
+
collectSimpleTagNames(xml, 'geom', names.geoms);
|
|
156
|
+
collectSimpleTagNames(xml, 'key', names.keyframes);
|
|
157
|
+
collectSectionNames(xml, 'actuator', names.actuators);
|
|
158
|
+
collectSectionNames(xml, 'sensor', names.sensors);
|
|
159
|
+
|
|
160
|
+
for (const includePath of collectIncludePaths(xml)) {
|
|
161
|
+
const next = path.resolve(path.dirname(normalized), includePath);
|
|
162
|
+
if (next.startsWith(root)) await scanModel(next, root, seen, names);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function collectSimpleTagNames(xml: string, tag: string, target: Set<string>) {
|
|
167
|
+
const pattern = new RegExp(`<\\s*${tag}\\b([^>]*)>`, 'gi');
|
|
168
|
+
for (const match of xml.matchAll(pattern)) {
|
|
169
|
+
const name = readAttr(match[1], 'name');
|
|
170
|
+
if (name) target.add(name);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function collectSectionNames(xml: string, section: string, target: Set<string>) {
|
|
175
|
+
const sectionPattern = new RegExp(`<\\s*${section}\\b[^>]*>([\\s\\S]*?)<\\s*/\\s*${section}\\s*>`, 'gi');
|
|
176
|
+
for (const sectionMatch of xml.matchAll(sectionPattern)) {
|
|
177
|
+
const tagPattern = /<\s*[a-zA-Z0-9_:-]+\b([^>]*)>/g;
|
|
178
|
+
for (const tagMatch of sectionMatch[1].matchAll(tagPattern)) {
|
|
179
|
+
const name = readAttr(tagMatch[1], 'name');
|
|
180
|
+
if (name) target.add(name);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function collectIncludePaths(xml: string): string[] {
|
|
186
|
+
const result: string[] = [];
|
|
187
|
+
const pattern = /<\s*include\b([^>]*)>/gi;
|
|
188
|
+
for (const match of xml.matchAll(pattern)) {
|
|
189
|
+
const file = readAttr(match[1], 'file');
|
|
190
|
+
if (file && !file.includes('://') && !file.startsWith('/')) result.push(file);
|
|
191
|
+
}
|
|
192
|
+
return result;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function readAttr(attrs: string, attr: string): string | undefined {
|
|
196
|
+
const pattern = new RegExp(`\\b${attr}\\s*=\\s*(['"])(.*?)\\1`, 'i');
|
|
197
|
+
return attrs.match(pattern)?.[2];
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function renderRegister(
|
|
201
|
+
moduleName: string,
|
|
202
|
+
names: Record<RegisterKey, Set<string>>,
|
|
203
|
+
models: readonly ModelEntry[]
|
|
204
|
+
): string {
|
|
205
|
+
const fields = REGISTER_KEYS
|
|
206
|
+
.filter((key) => names[key].size > 0)
|
|
207
|
+
.map((key) => ` ${key}: ${renderUnion(names[key])};`);
|
|
208
|
+
const robots = models
|
|
209
|
+
.map((model) => ` ${quoteProperty(model.id)}: {\n${renderRobotFields(model.names)}\n };`);
|
|
210
|
+
|
|
211
|
+
return `// Auto-generated by mujoco-react. Do not edit.
|
|
212
|
+
// Regenerate by running Vite with the mujocoReact() plugin or \`mujoco-react codegen\`.
|
|
213
|
+
|
|
214
|
+
import 'mujoco-react';
|
|
215
|
+
|
|
216
|
+
declare module '${moduleName}' {
|
|
217
|
+
interface Register {
|
|
218
|
+
robots: {
|
|
219
|
+
${robots.join('\n')}
|
|
220
|
+
};
|
|
221
|
+
${fields.join('\n')}
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
`;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function renderRobotFields(names: Record<RegisterKey, Set<string>>): string {
|
|
228
|
+
return REGISTER_KEYS
|
|
229
|
+
.map((key) => ` ${key}: ${names[key].size > 0 ? renderUnion(names[key]) : 'never'};`)
|
|
230
|
+
.join('\n');
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function renderUnion(values: Set<string>): string {
|
|
234
|
+
return [...values].sort((a, b) => a.localeCompare(b)).map((value) => `'${escapeTs(value)}'`).join(' | ');
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function escapeTs(value: string): string {
|
|
238
|
+
return value.replace(/\\/g, '\\\\').replace(/'/g, "\\'");
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function quoteProperty(value: string): string {
|
|
242
|
+
return /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(value) ? value : `'${escapeTs(value)}'`;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function normalizeModels(input: ModelInput): ModelEntry[] {
|
|
246
|
+
if (typeof input === 'string') return [createModelEntry(deriveModelId(input), input)];
|
|
247
|
+
if (Array.isArray(input)) return input.map((file) => createModelEntry(deriveModelId(file), file));
|
|
248
|
+
return Object.entries(input).map(([id, file]) => createModelEntry(sanitizeModelId(id), file));
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function createModelEntry(id: string, file: string): ModelEntry {
|
|
252
|
+
return { id, file, names: createEmptyNames() };
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function deriveModelId(file: string): string {
|
|
256
|
+
const normalized = file.replace(/\\/g, '/');
|
|
257
|
+
const parts = normalized.split('/').filter(Boolean);
|
|
258
|
+
const filename = parts.at(-1) ?? 'model';
|
|
259
|
+
const parent = parts.length > 1 ? parts.at(-2) : undefined;
|
|
260
|
+
const base = filename.replace(/\.(xml|mjcf|urdf)$/i, '');
|
|
261
|
+
return sanitizeModelId(parent && ['scene', 'model', 'robot'].includes(base.toLowerCase()) ? parent : base);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function sanitizeModelId(value: string): string {
|
|
265
|
+
const sanitized = value.replace(/[^A-Za-z0-9_$]/g, '_').replace(/^[^A-Za-z_$]+/, '');
|
|
266
|
+
return sanitized || 'model';
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function mergeNames(target: Record<RegisterKey, Set<string>>, source: Record<RegisterKey, Set<string>>) {
|
|
270
|
+
for (const key of REGISTER_KEYS) {
|
|
271
|
+
for (const value of source[key]) target[key].add(value);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function shouldRegenerate(file: string, watchedFiles: string[], models: readonly ModelEntry[], root: string): boolean {
|
|
276
|
+
const absolute = path.resolve(file);
|
|
277
|
+
if (watchedFiles.includes(absolute)) return true;
|
|
278
|
+
if (!MODEL_EXTENSIONS.has(path.extname(absolute).toLowerCase())) return false;
|
|
279
|
+
const modelDirs = models.map((model) => path.dirname(path.resolve(root, model.file)));
|
|
280
|
+
return modelDirs.some((dir) => absolute.startsWith(dir));
|
|
281
|
+
}
|