Files
Mujoco_WASM/web_platform/src/project/importer.ts
T
chenlin f3a8a38acd
web-platform-ci / Standalone decision service (no cloud credentials) (push) Has been cancelled
web-platform-ci / TypeScript, lint, unit, build (push) Has been cancelled
web-platform-ci / Playwright E2E (push) Has been cancelled
lekiwi-compatibility / cpu-compatibility (push) Has been cancelled
web-platform-ci / Standalone decision service (no cloud credentials) (pull_request) Has been cancelled
web-platform-ci / TypeScript, lint, unit, build (pull_request) Has been cancelled
web-platform-ci / Playwright E2E (pull_request) Has been cancelled
lekiwi-compatibility / cpu-compatibility (pull_request) Has been cancelled
feat: release v1.0.1 CADWorld 网站与 LeKiwi 智能抓放
集成同源 BYOK 会话隔离、精简模型设置、官方订阅入口和 HTTPS 发布运维;保留本地训练/调参与控制能力。同步 npm 版本及 CHANGELOG,记录公网真实 API 验收仍待用户凭据。
2026-09-24 09:57:41 +08:00

535 lines
20 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 {
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<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 async function prepareProjectForMujoco(
manifest: ProjectManifest,
entryPath: string,
): Promise<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');
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<string, ProjectFile>();
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('未发现包含 <mujoco> 或 <robot> 根元素的 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<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} 字节)`);
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<string, Uint8Array>;
try {
let fileCount = 0;
let expandedBytes = 0;
onProgress?.({ phase: 'extracting', completed: 0, total: 1 });
const { unzip } = await import('fflate');
unpacked = await new Promise<Record<string, Uint8Array>>((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<string>();
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<ProjectFile>(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<File>;
values?: () => AsyncIterableIterator<DroppedFileSystemHandle>;
}
async function readFileSystemHandle(handle: DroppedFileSystemHandle, prefix = ''): Promise<File[]> {
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<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 fileItems = Array.from(items).filter((item) => item.kind === 'file');
const handleRequests = fileItems
.map((item) => {
const getter = (
item as unknown as {
getAsFileSystemHandle?: () => Promise<DroppedFileSystemHandle | null>;
}
).getAsFileSystemHandle;
return getter?.call(item);
})
.filter((request): request is Promise<DroppedFileSystemHandle | null> => 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);
}