import { DEFAULT_IMPORT_LIMITS, type ImportLimits, type ModelEntry, type ProjectFile, type ProjectManifest, } from './types'; import { discoverMapEntries } from '../map/MapLoader'; const decoder = new TextDecoder('utf-8', { fatal: false }); export interface ProjectImportProgress { phase: 'reading' | 'extracting' | 'indexing'; completed: number; total: number; path?: string; } export type ProjectImportProgressCallback = (progress: ProjectImportProgress) => void; 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(); 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 = /)/i.test(head) ? 'urdf' : /)/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 { 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 async function prepareProjectForMujoco( manifest: ProjectManifest, entryPath: string, ): Promise { 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'); compiler.setAttribute('strippath', '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'); // Some CAD/URDF editors bundle a ROS resource but accidentally prepend // "meshes/" to its package URI. Only this observed spelling is repaired; // do not guess by basename or resolve URLs/files outside the imported project. if (!value || !/^(?:meshes\/)?package:\/\//.test(value)) continue; const packagePath = normalizeProjectPath(value.replace(/^(?:meshes\/)?package:\/\//, '')); let targets = paths.filter((path) => path === packagePath || path.endsWith(`/${packagePath}`)); if (!targets.length) { const slash = packagePath.indexOf('/'); const bundledPath = slash < 0 ? '' : packagePath.slice(slash + 1); if (bundledPath) { const local = resolveProjectReference(entryPath, bundledPath); targets = paths.includes(local ?? '') ? [local!] : paths.filter((path) => path === bundledPath || path.endsWith(`/${bundledPath}`)); } } if (targets.length > 1) throw new ProjectImportError(`package:// 资源路径不唯一,不能自动选择:${value}`, entryPath); if (!targets.length) { unresolved.push(value); continue; } element.setAttribute('filename', relativeProjectPath(entryPath, targets[0])); 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(); const daeMeshes = Array.from(document.querySelectorAll('mesh[filename]')).filter((mesh) => mesh.getAttribute('filename')?.toLowerCase().split(/[?#]/)[0].endsWith('.dae'), ); let convertDaeToObj: typeof import('./daeConverter').convertDaeToObj | undefined; let converterLoadError: unknown; if (daeMeshes.length) try { ({ convertDaeToObj } = await import('./daeConverter')); } catch (error) { converterLoadError = error; } let convertedDaeReferences = 0; let removedDaeVisuals = 0; let daeCollisionFallbacks = 0; for (const mesh of daeMeshes) { 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 文件'); if (!convertDaeToObj) throw new Error( `DAE 转换器加载失败:${converterLoadError instanceof Error ? converterLoadError.message : String(converterLoadError)}`, ); 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 { let maps; try { maps = discoverMapEntries(files); } catch (error) { throw new ProjectImportError( `地图描述无效:${error instanceof Error ? error.message : String(error)}`, files.find((file) => /(^|\/)map\.json$/i.test(file.path))?.path, ); } const physicsPaths = new Set(maps.map((map) => map.physicsPath).filter(Boolean)); const entries = discoverEntries(files).filter((entry) => !physicsPaths.has(entry.path)); if (!entries.length) throw new ProjectImportError('未发现包含 或 根元素的 XML/URDF 入口'); return { id: `${Date.now().toString(36)}-${crypto.randomUUID().slice(0, 8)}`, name, files, entries, maps, selectedEntry: choosePreferredEntry(entries), totalBytes: files.reduce((n, f) => n + f.size, 0), }; } export async function importBrowserFiles( input: File[], limits: ImportLimits = DEFAULT_IMPORT_LIMITS, onProgress?: ProjectImportProgressCallback, ): Promise { 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} 字节)`); onProgress?.({ phase: 'reading', completed: 0, total: 1, path: input[0].name }); const bytes = new Uint8Array(await input[0].arrayBuffer()); onProgress?.({ phase: 'reading', completed: 1, total: 1, path: input[0].name }); checkEncryptedZip(bytes); let unpacked: Record; try { let fileCount = 0; let expandedBytes = 0; onProgress?.({ phase: 'extracting', completed: 0, total: 1 }); const { unzip } = await import('fflate'); unpacked = await new Promise>((resolve, reject) => { try { unzip( 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; }, }, (error, data) => (error ? reject(error) : resolve(data)), ); } catch (error) { reject(error); } }); onProgress?.({ phase: 'extracting', completed: 1, total: 1 }); } 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); onProgress?.({ phase: 'indexing', completed: 1, total: 1 }); return manifest(input[0].name.replace(/\.zip$/i, ''), files); } if (input.length > limits.maxFiles) throw new ProjectImportError(`文件数量超过限制(${limits.maxFiles})`); const sources = input.map((file) => { const relative = (file as File & { webkitRelativePath?: string }).webkitRelativePath || file.name; return { file, relative, path: normalizeProjectPath(relative) }; }); let totalBytes = 0; const seen = new Set(); for (const { file, path } of sources) { if (seen.has(path)) throw new ProjectImportError('工程中存在同名路径', path); seen.add(path); if (file.size > limits.maxFileBytes) throw new ProjectImportError(`单文件超过限制(${limits.maxFileBytes} 字节)`, path); totalBytes += file.size; if (totalBytes > limits.maxTotalBytes) throw new ProjectImportError(`工程总大小超过限制(${limits.maxTotalBytes} 字节)`); } const files = new Array(sources.length); let cursor = 0; let completed = 0; onProgress?.({ phase: 'reading', completed, total: sources.length }); const readNext = async () => { for (;;) { const index = cursor++; if (index >= sources.length) return; const { file, relative, path } = sources[index]; const data = new Uint8Array(await file.arrayBuffer()); files[index] = { path, data, size: data.byteLength, source: relative === file.name ? 'file' : 'directory', mimeType: file.type, }; completed += 1; onProgress?.({ phase: 'reading', completed, total: sources.length, path }); } }; await Promise.all(Array.from({ length: Math.min(4, sources.length) }, () => readNext())); enforceLimits(files, limits); onProgress?.({ phase: 'indexing', completed: 1, total: 1 }); return manifest(files[0].path.split('/')[0] || '工程', files); } interface DroppedFileSystemHandle { kind: 'file' | 'directory'; name: string; getFile?: () => Promise; values?: () => AsyncIterableIterator; } async function readFileSystemHandle(handle: DroppedFileSystemHandle, prefix = ''): Promise { if (handle.kind === 'file' && handle.getFile) { const file = await handle.getFile(); Object.defineProperty(file, 'webkitRelativePath', { configurable: true, value: `${prefix}${file.name}`, }); return [file]; } if (handle.kind !== 'directory' || !handle.values) return []; const files: File[] = []; for await (const child of handle.values()) files.push(...(await readFileSystemHandle(child, `${prefix}${handle.name}/`))); return 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 { if (entry.isFile) return [ await new Promise((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((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 { const fileItems = Array.from(items).filter((item) => item.kind === 'file'); const handleRequests = fileItems .map((item) => { const getter = ( item as unknown as { getAsFileSystemHandle?: () => Promise; } ).getAsFileSystemHandle; return getter?.call(item); }) .filter((request): request is Promise => Boolean(request)); if (handleRequests.length > 0 && handleRequests.length === fileItems.length) { const settled = await Promise.allSettled(handleRequests); const handles = settled.flatMap((result) => result.status === 'fulfilled' && result.value ? [result.value] : [], ); if (handles.length === fileItems.length) return (await Promise.all(handles.map((handle) => readFileSystemHandle(handle)))).flat(); } const entries = fileItems .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); }