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/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
+ }