feat(web-platform): release V0.7 地图模块
This commit is contained in:
@@ -26,6 +26,7 @@ function fixture(): ProjectManifest {
|
||||
},
|
||||
],
|
||||
entries: [{ path: 'model.xml', format: 'mjcf', label: 'model' }],
|
||||
maps: [],
|
||||
selectedEntry: 'model.xml',
|
||||
totalBytes: xml.byteLength + png.byteLength,
|
||||
};
|
||||
|
||||
@@ -35,15 +35,39 @@ describe('project importer', () => {
|
||||
expect(entries).toHaveLength(3);
|
||||
expect(choosePreferredEntry(entries)).toBe('model.xml');
|
||||
});
|
||||
it('解压 ZIP 并保留二进制数据', async () => {
|
||||
it('异步解压 ZIP、保留二进制数据并报告阶段进度', async () => {
|
||||
const zipped = zipSync({
|
||||
'robot/model.urdf': encode('<robot name="r"/>'),
|
||||
'robot/mesh.obj': encode('v 0 0 0'),
|
||||
});
|
||||
const file = new File([zipped], 'robot.zip', { type: 'application/zip' });
|
||||
const result = await importBrowserFiles([file]);
|
||||
const phases: string[] = [];
|
||||
const result = await importBrowserFiles([file], undefined, (progress) =>
|
||||
phases.push(`${progress.phase}:${progress.completed}`),
|
||||
);
|
||||
expect(result.files.map((f) => f.path)).toContain('robot/mesh.obj');
|
||||
expect(result.selectedEntry).toBe('robot/model.urdf');
|
||||
expect(phases).toContain('extracting:0');
|
||||
expect(phases.at(-1)).toBe('indexing:1');
|
||||
});
|
||||
it('发现工程地图且不会将 map.json 当作模型入口', async () => {
|
||||
const mapJson = JSON.stringify({
|
||||
schemaVersion: 1,
|
||||
id: 'room',
|
||||
name: '房间',
|
||||
coordinateSystem: { units: 'm', up: 'Z', forward: '+X' },
|
||||
physics: { source: 'world.xml' },
|
||||
spawnPoints: [],
|
||||
});
|
||||
const result = await importBrowserFiles([
|
||||
new File(['<mujoco><worldbody/></mujoco>'], 'model.xml'),
|
||||
new File([mapJson], 'map.json'),
|
||||
new File(['<mujoco><worldbody/></mujoco>'], 'world.xml'),
|
||||
]);
|
||||
expect(result.maps).toEqual([
|
||||
expect.objectContaining({ descriptorPath: 'map.json', id: 'room', name: '房间' }),
|
||||
]);
|
||||
expect(result.entries.map((entry) => entry.path)).toEqual(['model.xml']);
|
||||
});
|
||||
it('拒绝 ZIP 路径穿越', async () => {
|
||||
const zipped = zipSync({ '../model.xml': encode('<mujoco/>') });
|
||||
@@ -56,8 +80,9 @@ describe('project importer', () => {
|
||||
const b = new File(['<mujoco/>'], 'model.xml');
|
||||
await expect(importBrowserFiles([a, b])).rejects.toThrow('同名路径');
|
||||
});
|
||||
it('拒绝超过限制的文件', async () => {
|
||||
const file = new File(['<mujoco/>'], 'model.xml');
|
||||
it('读取文件内容前根据元数据拒绝超过限制的输入', async () => {
|
||||
const file = new File(['<mujoco/>'], 'model.xml'),
|
||||
read = vi.spyOn(file, 'arrayBuffer');
|
||||
await expect(
|
||||
importBrowserFiles([file], {
|
||||
maxFiles: 1,
|
||||
@@ -66,8 +91,20 @@ describe('project importer', () => {
|
||||
maxZipBytes: 2,
|
||||
}),
|
||||
).rejects.toThrow('单文件超过限制');
|
||||
expect(read).not.toHaveBeenCalled();
|
||||
});
|
||||
it('规范化 MuJoCo 不接受的重复 material 和 ROS package URI', () => {
|
||||
it('按完成文件数报告普通工程读取进度', async () => {
|
||||
const updates: Array<{ phase: string; completed: number; total: number }> = [];
|
||||
const result = await importBrowserFiles(
|
||||
[new File(['<mujoco/>'], 'model.xml'), new File(['v 0 0 0'], 'mesh.obj')],
|
||||
undefined,
|
||||
({ phase, completed, total }) => updates.push({ phase, completed, total }),
|
||||
);
|
||||
expect(result.files).toHaveLength(2);
|
||||
expect(updates).toContainEqual({ phase: 'reading', completed: 2, total: 2 });
|
||||
expect(updates.at(-1)).toEqual({ phase: 'indexing', completed: 1, total: 1 });
|
||||
});
|
||||
it('规范化 MuJoCo 不接受的重复 material 和 ROS package URI', async () => {
|
||||
const urdf = projectFile(
|
||||
'go2w_description/urdf/robot.urdf',
|
||||
'<robot><link name="base"><visual><geometry><mesh filename="package://go2w_description/meshes/base.obj"/></geometry><material name="a"/><material name="b"/></visual></link></robot>',
|
||||
@@ -84,10 +121,11 @@ describe('project importer', () => {
|
||||
name: 'go2w',
|
||||
files: [urdf, mesh],
|
||||
entries: [{ path: urdf.path, format: 'urdf' as const, label: 'robot' }],
|
||||
maps: [],
|
||||
selectedEntry: urdf.path,
|
||||
totalBytes: urdf.size + 1,
|
||||
};
|
||||
const prepared = prepareProjectForMujoco(manifest, urdf.path);
|
||||
const prepared = await prepareProjectForMujoco(manifest, urdf.path);
|
||||
const text = new TextDecoder().decode(prepared.manifest.files[0].data);
|
||||
expect(text.match(/<material/g) ?? []).toHaveLength(1);
|
||||
expect(text).toContain('filename="../meshes/base.obj"');
|
||||
@@ -95,7 +133,7 @@ describe('project importer', () => {
|
||||
expect(text).toContain('fusestatic="false"');
|
||||
expect(prepared.warnings).toHaveLength(2);
|
||||
});
|
||||
it('将 DAE mesh 转换为 MuJoCo 可读取的 OBJ,并复用于 visual/collision', () => {
|
||||
it('将 DAE mesh 转换为 MuJoCo 可读取的 OBJ,并复用于 visual/collision', async () => {
|
||||
const urdf = projectFile(
|
||||
'robot/robot.urdf',
|
||||
'<robot><link name="base"><visual><geometry><mesh filename="meshes/triangle.dae"/></geometry></visual><collision><geometry><mesh filename="meshes/triangle.dae"/></geometry></collision></link></robot>',
|
||||
@@ -106,10 +144,11 @@ describe('project importer', () => {
|
||||
name: 'dae',
|
||||
files: [urdf, dae],
|
||||
entries: [{ path: urdf.path, format: 'urdf' as const, label: 'robot' }],
|
||||
maps: [],
|
||||
selectedEntry: urdf.path,
|
||||
totalBytes: urdf.size + dae.size,
|
||||
};
|
||||
const prepared = prepareProjectForMujoco(manifest, urdf.path);
|
||||
const prepared = await prepareProjectForMujoco(manifest, urdf.path);
|
||||
const text = new TextDecoder().decode(
|
||||
prepared.manifest.files.find((file) => file.path === urdf.path)!.data,
|
||||
);
|
||||
@@ -121,7 +160,7 @@ describe('project importer', () => {
|
||||
expect(new TextDecoder().decode(obj!.data)).toMatch(/^f\s/m);
|
||||
expect(prepared.warnings.join(' ')).toContain('1 个 DAE 文件转换为 OBJ');
|
||||
});
|
||||
it('DAE 缺失或转换失败时安全降级', () => {
|
||||
it('DAE 缺失或转换失败时安全降级', async () => {
|
||||
const urdf = projectFile(
|
||||
'robot.urdf',
|
||||
'<robot><link name="base"><visual><geometry><mesh filename="visual.dae"/></geometry></visual><collision><geometry><mesh filename="collision.dae"/></geometry></collision></link></robot>',
|
||||
@@ -131,10 +170,11 @@ describe('project importer', () => {
|
||||
name: 'dae',
|
||||
files: [urdf],
|
||||
entries: [{ path: urdf.path, format: 'urdf' as const, label: 'robot' }],
|
||||
maps: [],
|
||||
selectedEntry: urdf.path,
|
||||
totalBytes: urdf.size,
|
||||
};
|
||||
const prepared = prepareProjectForMujoco(manifest, urdf.path);
|
||||
const prepared = await prepareProjectForMujoco(manifest, urdf.path);
|
||||
const text = new TextDecoder().decode(prepared.manifest.files[0].data);
|
||||
expect(text).not.toContain('<visual>');
|
||||
expect(text).toContain('<collision>');
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { unzipSync } from 'fflate';
|
||||
import {
|
||||
DEFAULT_IMPORT_LIMITS,
|
||||
type ImportLimits,
|
||||
@@ -6,10 +5,19 @@ import {
|
||||
type ProjectFile,
|
||||
type ProjectManifest,
|
||||
} from './types';
|
||||
import { convertDaeToObj } from './daeConverter';
|
||||
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,
|
||||
@@ -121,10 +129,10 @@ function generatedObjPath(daePath: string, occupied: Set<string>): string {
|
||||
}
|
||||
|
||||
/** Normalizes common ROS URDF constructs that MuJoCo's stricter parser rejects. */
|
||||
export function prepareProjectForMujoco(
|
||||
export async function prepareProjectForMujoco(
|
||||
manifest: ProjectManifest,
|
||||
entryPath: string,
|
||||
): PreparedProject {
|
||||
): 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);
|
||||
@@ -183,16 +191,31 @@ export function prepareProjectForMujoco(
|
||||
|
||||
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 Array.from(document.querySelectorAll('mesh[filename]'))) {
|
||||
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);
|
||||
@@ -264,7 +287,17 @@ export function choosePreferredEntry(entries: ModelEntry[]): string | undefined
|
||||
}
|
||||
|
||||
function manifest(name: string, files: ProjectFile[]): ProjectManifest {
|
||||
const entries = discoverEntries(files);
|
||||
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 {
|
||||
@@ -272,6 +305,7 @@ function manifest(name: string, files: ProjectFile[]): ProjectManifest {
|
||||
name,
|
||||
files,
|
||||
entries,
|
||||
maps,
|
||||
selectedEntry: choosePreferredEntry(entries),
|
||||
totalBytes: files.reduce((n, f) => n + f.size, 0),
|
||||
};
|
||||
@@ -280,37 +314,53 @@ function manifest(name: string, files: ProjectFile[]): ProjectManifest {
|
||||
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;
|
||||
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;
|
||||
},
|
||||
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(
|
||||
@@ -327,23 +377,53 @@ export async function importBrowserFiles(
|
||||
mimeType: '',
|
||||
}));
|
||||
enforceLimits(files, limits);
|
||||
onProgress?.({ phase: 'indexing', completed: 1, total: 1 });
|
||||
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;
|
||||
|
||||
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());
|
||||
return {
|
||||
path: normalizeProjectPath(relative),
|
||||
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);
|
||||
}
|
||||
|
||||
|
||||
@@ -14,11 +14,23 @@ export interface ModelEntry {
|
||||
label: string;
|
||||
}
|
||||
|
||||
export interface MapEntry {
|
||||
descriptorPath: string;
|
||||
schemaVersion: 1 | 2;
|
||||
id: string;
|
||||
name: string;
|
||||
physicsPath?: string;
|
||||
visualPath?: string;
|
||||
authoringPath?: string;
|
||||
spawnPoints: Array<{ id: string; name: string }>;
|
||||
}
|
||||
|
||||
export interface ProjectManifest {
|
||||
id: string;
|
||||
name: string;
|
||||
files: ProjectFile[];
|
||||
entries: ModelEntry[];
|
||||
maps: MapEntry[];
|
||||
selectedEntry?: string;
|
||||
totalBytes: number;
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ it('按相对路径挂载并逆序清理 MEMFS', () => {
|
||||
id: 'safe',
|
||||
name: 'x',
|
||||
entries: [],
|
||||
maps: [],
|
||||
files: [{ path: 'a/b/model.xml', data, size: 1, source: 'file', mimeType: '' }],
|
||||
totalBytes: 1,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user