Files
Mujoco_WASM/wasm/web_platform/src/project/importer.ts
T
chenlin 1eb05132f1
build / setup (compute matrix) (pull_request) Has been cancelled
build / ${{ matrix.label }} (pull_request) Has been cancelled
build / macos-15-arm64-studio (pull_request) Has been cancelled
build / ubuntu-24.04-clang-18-studio (pull_request) Has been cancelled
build / ubuntu-24.04-gcc-14-studio (pull_request) Has been cancelled
build / windows-2025-ninja-studio (pull_request) Has been cancelled
build / ubuntu-24.04-clang-18-wasm (pull_request) Has been cancelled
build / ubuntu-24.04-clang-18-mjx (pull_request) Has been cancelled
lint / pre-commit (pull_request) Has been cancelled
feat: add browser-based MuJoCo simulation platform
2026-08-20 15:12:08 +08:00

224 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import {unzipSync} from 'fflate';
import {DEFAULT_IMPORT_LIMITS, type ImportLimits, type ModelEntry, type ProjectFile, type ProjectManifest} from './types';
import {convertDaeToObj} from './daeConverter';
const decoder = new TextDecoder('utf-8', {fatal: false});
export class ProjectImportError extends Error {
constructor(message: string, readonly path?: string) { super(message); this.name = 'ProjectImportError'; }
}
export function normalizeProjectPath(input: string): string {
const path = input.replaceAll('\\', '/').replace(/^\.\//, '');
if (!path || path.startsWith('/') || path.includes('\0') || /^[A-Za-z]:/.test(path)) throw new ProjectImportError('不允许绝对路径或空路径', input);
const parts = path.split('/').filter((part) => part !== '' && part !== '.');
if (!parts.length || parts.some((part) => part === '..')) throw new ProjectImportError('路径包含越界片段', input);
return parts.join('/');
}
function checkEncryptedZip(data: Uint8Array): void {
for (let i = 0; i + 8 < data.length; i++) {
if (data[i] === 0x50 && data[i + 1] === 0x4b && (data[i + 2] === 0x03 || data[i + 2] === 0x01) && (data[i + 3] === 0x04 || data[i + 3] === 0x02)) {
const flags = data[i + 6] | (data[i + 7] << 8);
if ((flags & 1) !== 0) throw new ProjectImportError('不支持加密 ZIP');
}
}
}
function enforceLimits(files: ProjectFile[], limits: ImportLimits): void {
if (files.length > limits.maxFiles) throw new ProjectImportError(`文件数量超过限制(${limits.maxFiles}`);
let total = 0;
const seen = new Set<string>();
for (const file of files) {
if (seen.has(file.path)) throw new ProjectImportError('工程中存在同名路径', file.path);
seen.add(file.path);
if (file.size > limits.maxFileBytes) throw new ProjectImportError(`单文件超过限制(${limits.maxFileBytes} 字节)`, file.path);
total += file.size;
if (total > limits.maxTotalBytes) throw new ProjectImportError(`工程总大小超过限制(${limits.maxTotalBytes} 字节)`);
}
}
export function discoverEntries(files: ProjectFile[]): ModelEntry[] {
return files.flatMap((file): ModelEntry[] => {
if (!/\.(xml|urdf)$/i.test(file.path)) return [];
const head = decoder.decode(file.data.subarray(0, Math.min(file.data.length, 256 * 1024))).replace(/^\uFEFF/, '');
const format = /<robot(?:\s|\/?>)/i.test(head) ? 'urdf' : /<mujoco(?:\s|\/?>)/i.test(head) ? 'mjcf' : undefined;
return format ? [{path: file.path, format, label: `${file.path} (${format.toUpperCase()})`}] : [];
});
}
export interface PreparedProject {
manifest: ProjectManifest;
warnings: string[];
}
function relativeProjectPath(fromFile: string, toFile: string): string {
const from = fromFile.split('/').slice(0, -1);
const to = toFile.split('/');
while (from.length && to.length && from[0] === to[0]) { from.shift(); to.shift(); }
return `${'../'.repeat(from.length)}${to.join('/')}` || './';
}
function resolveProjectReference(fromFile:string,reference:string):string|undefined {
if(/^[a-z][a-z\d+.-]*:/i.test(reference))return;
let decoded:string;
try{decoded=decodeURIComponent(reference.split(/[?#]/,1)[0]);}catch{return;}
const parts=fromFile.split('/').slice(0,-1);
for(const part of decoded.replaceAll('\\','/').split('/')){
if(!part||part==='.')continue;
if(part==='..'){if(!parts.length)return;parts.pop();}
else parts.push(part);
}
return parts.join('/');
}
function generatedObjPath(daePath:string,occupied:Set<string>):string {
const base=daePath.replace(/\.dae$/i,'');
let candidate=`${base}.mujoco.obj`;
for(let index=2;occupied.has(candidate);index+=1)candidate=`${base}.mujoco-${index}.obj`;
occupied.add(candidate);
return candidate;
}
/** Normalizes common ROS URDF constructs that MuJoCo's stricter parser rejects. */
export function prepareProjectForMujoco(manifest: ProjectManifest, entryPath: string): PreparedProject {
const entry = manifest.entries.find((candidate) => candidate.path === entryPath);
if (entry?.format !== 'urdf') return {manifest, warnings: []};
const source = manifest.files.find((file) => file.path === entryPath);
if (!source) return {manifest, warnings: []};
const document = new DOMParser().parseFromString(decoder.decode(source.data), 'application/xml');
if (document.querySelector('parsererror')) return {manifest, warnings: []};
const warnings: string[] = [];
const robot=document.documentElement;
let mujoco=Array.from(robot.children).find(child=>child.tagName==='mujoco');
if(!mujoco){mujoco=document.createElement('mujoco');robot.prepend(mujoco);}
let compiler=Array.from(mujoco.children).find(child=>child.tagName==='compiler');
if(!compiler){compiler=document.createElement('compiler');mujoco.append(compiler);}
compiler.setAttribute('discardvisual','false');
compiler.setAttribute('fusestatic','false');
let removedMaterials = 0;
for (const visual of Array.from(document.querySelectorAll('visual'))) {
const materials = Array.from(visual.children).filter((child) => child.tagName === 'material');
for (const duplicate of materials.slice(1)) { duplicate.remove(); removedMaterials += 1; }
}
if (removedMaterials) warnings.push(`为兼容 MuJoCo,已移除 visual 中 ${removedMaterials} 个重复 material(保留第一个)`);
const paths = manifest.files.map((file) => file.path);
let rewrittenUris = 0;
const unresolved: string[] = [];
for (const element of Array.from(document.querySelectorAll('[filename]'))) {
const value = element.getAttribute('filename');
if (!value?.startsWith('package://')) continue;
const packagePath = normalizeProjectPath(value.slice('package://'.length));
const target = paths.find((path) => path === packagePath) ?? paths.find((path) => path.endsWith(`/${packagePath}`));
if (!target) { unresolved.push(value); continue; }
element.setAttribute('filename', relativeProjectPath(entryPath, target));
rewrittenUris += 1;
}
if (rewrittenUris) warnings.push(`已将 ${rewrittenUris} 个 package:// 资源地址改写为工程内相对路径`);
if (unresolved.length) warnings.push(`有 ${unresolved.length} 个 package:// 资源未在工程中找到`);
const occupied=new Set(manifest.files.map(file=>file.path));
const converted=new Map<string,ProjectFile>();
let convertedDaeReferences=0;
let removedDaeVisuals=0;
let daeCollisionFallbacks=0;
for(const mesh of Array.from(document.querySelectorAll('mesh[filename]'))){
const filename=mesh.getAttribute('filename');
if(!filename?.toLowerCase().split(/[?#]/)[0].endsWith('.dae'))continue;
const daePath=resolveProjectReference(entryPath,filename);
const daeFile=daePath?manifest.files.find(file=>file.path===daePath):undefined;
try{
if(!daeFile||!daePath)throw new Error('工程中找不到 DAE 文件');
let objFile=converted.get(daePath);
if(!objFile){
const data=convertDaeToObj(daeFile.data,daePath);
if(data.byteLength>DEFAULT_IMPORT_LIMITS.maxFileBytes)throw new Error('转换后的 OBJ 超过单文件大小限制');
objFile={path:generatedObjPath(daePath,occupied),data,size:data.byteLength,source:daeFile.source,mimeType:'text/plain'};
converted.set(daePath,objFile);
}
mesh.setAttribute('filename',relativeProjectPath(entryPath,objFile.path));
convertedDaeReferences+=1;
}catch(error){
console.warn(`[MuJoCo] DAE 转换失败:${filename}`,error);
const visual=mesh.closest('visual');
if(visual){visual.remove();removedDaeVisuals+=1;}
else if(mesh.closest('collision')){
const sphere=document.createElement('sphere');sphere.setAttribute('radius','0.05');mesh.replaceWith(sphere);daeCollisionFallbacks+=1;
}
}
}
if(convertedDaeReferences)warnings.push(`已将 ${converted.size} 个 DAE 文件转换为 OBJ,供 ${convertedDaeReferences} 个 visual/collision 使用`);
if(removedDaeVisuals)warnings.push(`${removedDaeVisuals} 个 DAE visual 转换失败,已移除并使用其他 collision 几何显示/仿真`);
if(daeCollisionFallbacks)warnings.push(`${daeCollisionFallbacks} 个 DAE collision 转换失败,已替换为半径 0.05 m 的占位球体;碰撞精度会降低`);
const xml=new TextEncoder().encode(new XMLSerializer().serializeToString(document));
const replacement:ProjectFile={...source,data:xml,size:xml.byteLength};
const generated=Array.from(converted.values());
const files=[...manifest.files.map(file=>file===source?replacement:file),...generated];
return {manifest:{...manifest,files,totalBytes:files.reduce((total,file)=>total+file.size,0)},warnings};
}
export function choosePreferredEntry(entries: ModelEntry[]): string | undefined {
if (entries.length === 1) return entries[0].path;
const rootPreferred = entries.find((e) => !e.path.includes('/') && /^(model|scene)\.xml$/i.test(e.path));
if (rootPreferred) return rootPreferred.path;
const urdfs = entries.filter((e) => e.format === 'urdf');
return urdfs.length === 1 ? urdfs[0].path : undefined;
}
function manifest(name: string, files: ProjectFile[]): ProjectManifest {
const entries = discoverEntries(files);
if (!entries.length) throw new ProjectImportError('未发现包含 <mujoco> 或 <robot> 根元素的 XML/URDF 入口');
return {id: `${Date.now().toString(36)}-${crypto.randomUUID().slice(0, 8)}`, name, files, entries, selectedEntry: choosePreferredEntry(entries), totalBytes: files.reduce((n, f) => n + f.size, 0)};
}
export async function importBrowserFiles(input: File[], limits: ImportLimits = DEFAULT_IMPORT_LIMITS): Promise<ProjectManifest> {
if (!input.length) throw new ProjectImportError('未选择文件');
if (input.length === 1 && /\.zip$/i.test(input[0].name)) {
if (input[0].size > limits.maxZipBytes) throw new ProjectImportError(`ZIP 超过限制(${limits.maxZipBytes} 字节)`);
const bytes = new Uint8Array(await input[0].arrayBuffer()); checkEncryptedZip(bytes);
let unpacked: Record<string, Uint8Array>;
try {
let fileCount = 0;
let expandedBytes = 0;
unpacked = unzipSync(bytes, {filter: (entry) => {
if (entry.name.endsWith('/')) return false;
normalizeProjectPath(entry.name);
fileCount += 1;
expandedBytes += entry.originalSize;
if (fileCount > limits.maxFiles) throw new ProjectImportError(`文件数量超过限制(${limits.maxFiles}`);
if (entry.originalSize > limits.maxFileBytes) throw new ProjectImportError(`单文件超过限制(${limits.maxFileBytes} 字节)`, entry.name);
if (expandedBytes > limits.maxTotalBytes) throw new ProjectImportError(`ZIP 解压后总大小超过限制(${limits.maxTotalBytes} 字节)`);
return true;
}});
} catch (error) {
if (error instanceof ProjectImportError) throw error;
throw new ProjectImportError(`ZIP 解压失败:${error instanceof Error ? error.message : String(error)}`);
}
const files = Object.entries(unpacked).filter(([path]) => !path.endsWith('/')).map(([path, data]): ProjectFile => ({path: normalizeProjectPath(path), data, size: data.byteLength, source: 'zip', mimeType: ''}));
enforceLimits(files, limits); return manifest(input[0].name.replace(/\.zip$/i, ''), files);
}
const files = await Promise.all(input.map(async (file): Promise<ProjectFile> => {
const relative = (file as File & {webkitRelativePath?: string}).webkitRelativePath || file.name;
const data = new Uint8Array(await file.arrayBuffer());
return {path: normalizeProjectPath(relative), data, size: data.byteLength, source: relative === file.name ? 'file' : 'directory', mimeType: file.type};
}));
enforceLimits(files, limits); return manifest(files[0].path.split('/')[0] || '工程', files);
}
interface LegacyEntry {isFile: boolean; isDirectory: boolean; name: string; file(cb: (file: File) => void, err: (e: DOMException) => void): void; createReader(): {readEntries(cb: (entries: LegacyEntry[]) => void, err: (e: DOMException) => void): void};}
async function readEntry(entry: LegacyEntry, prefix = ''): Promise<File[]> {
if (entry.isFile) return [await new Promise<File>((resolve, reject) => entry.file((file) => {Object.defineProperty(file, 'webkitRelativePath', {value: `${prefix}${file.name}`}); resolve(file);}, reject))];
const reader = entry.createReader(); const children: LegacyEntry[] = [];
for (;;) { const batch = await new Promise<LegacyEntry[]>((resolve, reject) => reader.readEntries(resolve, reject)); if (!batch.length) break; children.push(...batch); }
return (await Promise.all(children.map((child) => readEntry(child, `${prefix}${entry.name}/`)))).flat();
}
export async function filesFromDrop(items: DataTransferItemList, fallback: FileList): Promise<File[]> {
const entries = Array.from(items).map((item) => (item as unknown as {webkitGetAsEntry?: () => LegacyEntry | null}).webkitGetAsEntry?.() ?? null).filter((entry): entry is LegacyEntry => entry !== null);
return entries.length ? (await Promise.all(entries.map((entry) => readEntry(entry)))).flat() : Array.from(fallback);
}