feat: add browser-based MuJoCo simulation platform
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
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
This commit is contained in:
@@ -0,0 +1,31 @@
|
||||
import {render,screen,within} from '@testing-library/react';
|
||||
import {buildProjectTree,ProjectTree} from './ProjectTree';
|
||||
|
||||
const files=[
|
||||
{path:'robot/meshes/arm.obj',size:2048},
|
||||
{path:'robot/model.xml',size:512},
|
||||
{path:'robot/textures/body.png',size:4096},
|
||||
{path:'README.txt',size:10},
|
||||
];
|
||||
|
||||
describe('ProjectTree',()=>{
|
||||
it('按路径构建多级目录,并将目录排在文件前面',()=>{
|
||||
const tree=buildProjectTree(files);
|
||||
expect(tree.map(node=>[node.kind,node.name])).toEqual([['directory','robot'],['file','README.txt']]);
|
||||
const robot=tree[0];
|
||||
expect(robot.children?.map(node=>[node.kind,node.name])).toEqual([
|
||||
['directory','meshes'],['directory','textures'],['file','model.xml'],
|
||||
]);
|
||||
expect(robot.children?.[0].children?.[0]).toMatchObject({kind:'file',name:'arm.obj',path:'robot/meshes/arm.obj'});
|
||||
});
|
||||
|
||||
it('以可折叠目录显示文件名,而不是平铺完整路径',()=>{
|
||||
render(<ProjectTree files={files} entries={[{path:'robot/model.xml',format:'mjcf',label:'model'}]} selectedEntry="robot/model.xml"/>);
|
||||
const tree=screen.getByRole('navigation',{name:'工程文件树'});
|
||||
expect(within(tree).getByText('robot')).toBeVisible();
|
||||
expect(within(tree).getByText('meshes')).toBeVisible();
|
||||
expect(within(tree).getByText('arm.obj')).toBeVisible();
|
||||
expect(within(tree).queryByText('robot/meshes/arm.obj')).not.toBeInTheDocument();
|
||||
expect(within(tree).getByText('mjcf')).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
import type {ModelEntry} from './types';
|
||||
|
||||
export interface ProjectTreeFile {path:string;size:number;}
|
||||
|
||||
export interface ProjectTreeNode {
|
||||
name:string;
|
||||
path:string;
|
||||
kind:'directory'|'file';
|
||||
size?:number;
|
||||
children?:ProjectTreeNode[];
|
||||
}
|
||||
|
||||
interface MutableDirectory {
|
||||
name:string;
|
||||
path:string;
|
||||
directories:Map<string,MutableDirectory>;
|
||||
files:ProjectTreeNode[];
|
||||
}
|
||||
|
||||
function compareNodes(a:ProjectTreeNode,b:ProjectTreeNode):number {
|
||||
if(a.kind!==b.kind)return a.kind==='directory'?-1:1;
|
||||
return a.name.localeCompare(b.name,'zh-CN',{numeric:true,sensitivity:'base'});
|
||||
}
|
||||
|
||||
/** 将规范化后的工程路径转换为“目录优先、名称排序”的资源树。 */
|
||||
// 同文件导出纯函数是为了让资源树的数据转换可独立测试。
|
||||
// eslint-disable-next-line react-refresh/only-export-components
|
||||
export function buildProjectTree(files:ProjectTreeFile[]):ProjectTreeNode[] {
|
||||
const root:MutableDirectory={name:'',path:'',directories:new Map(),files:[]};
|
||||
for(const file of files){
|
||||
const parts=file.path.split('/').filter(Boolean);
|
||||
if(!parts.length)continue;
|
||||
let parent=root;
|
||||
for(const part of parts.slice(0,-1)){
|
||||
const path=parent.path?`${parent.path}/${part}`:part;
|
||||
let directory=parent.directories.get(part);
|
||||
if(!directory){directory={name:part,path,directories:new Map(),files:[]};parent.directories.set(part,directory);}
|
||||
parent=directory;
|
||||
}
|
||||
parent.files.push({name:parts.at(-1)!,path:file.path,kind:'file',size:file.size});
|
||||
}
|
||||
const finish=(directory:MutableDirectory):ProjectTreeNode[]=>[
|
||||
...Array.from(directory.directories.values(),child=>({name:child.name,path:child.path,kind:'directory' as const,children:finish(child)})),
|
||||
...directory.files,
|
||||
].sort(compareNodes);
|
||||
return finish(root);
|
||||
}
|
||||
|
||||
function formatSize(bytes:number):string {
|
||||
if(bytes<1024)return `${bytes} B`;
|
||||
if(bytes<1024*1024)return `${(bytes/1024).toFixed(bytes<10*1024?1:0)} KB`;
|
||||
return `${(bytes/(1024*1024)).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
function TreeNodes({nodes,entryFormats,selectedEntry}:{nodes:ProjectTreeNode[];entryFormats:Map<string,ModelEntry['format']>;selectedEntry?:string}){
|
||||
return <ul role="group" className="ml-3 border-l border-slate-800 pl-1">
|
||||
{nodes.map(node=>node.kind==='directory'?<li key={`d:${node.path}`}>
|
||||
<details open>
|
||||
<summary title={node.path} className="cursor-pointer select-none truncate rounded px-1.5 py-1 text-xs text-slate-300 hover:bg-slate-800"><span aria-hidden="true" className="mr-1">📁</span>{node.name}</summary>
|
||||
<TreeNodes nodes={node.children??[]} entryFormats={entryFormats} selectedEntry={selectedEntry}/>
|
||||
</details>
|
||||
</li>:<li key={`f:${node.path}`} title={node.path} className={`flex min-w-0 items-center gap-1 rounded px-1.5 py-1 text-xs ${selectedEntry===node.path?'bg-green-900/40 text-green-200':'text-slate-300 hover:bg-slate-800'}`}>
|
||||
<span aria-hidden="true">{entryFormats.has(node.path)?'◇':'▧'}</span><span className="min-w-0 flex-1 truncate">{node.name}</span>{entryFormats.has(node.path)&&<span className="shrink-0 text-[10px] uppercase text-green-400">{entryFormats.get(node.path)}</span>}<span className="shrink-0 text-[10px] text-slate-500">{formatSize(node.size??0)}</span>
|
||||
</li>)}
|
||||
</ul>;
|
||||
}
|
||||
|
||||
export function ProjectTree({files,entries,selectedEntry}:{files:ProjectTreeFile[];entries:ModelEntry[];selectedEntry?:string}){
|
||||
const nodes=buildProjectTree(files);
|
||||
const entryFormats=new Map(entries.map(entry=>[entry.path,entry.format]));
|
||||
return <nav aria-label="工程文件树"><TreeNodes nodes={nodes} entryFormats={entryFormats} selectedEntry={selectedEntry}/></nav>;
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import {LoadingManager,type Material,type Mesh,type Texture} from 'three';
|
||||
import {OBJExporter} from 'three/addons/exporters/OBJExporter.js';
|
||||
import {ColladaLoader} from 'three/addons/loaders/ColladaLoader.js';
|
||||
|
||||
const TRANSPARENT_PIXEL='data:image/gif;base64,R0lGODlhAQABAAD/ACwAAAAAAQABAAACADs=';
|
||||
|
||||
/**
|
||||
* 将 Collada 几何转换为 MuJoCo WASM 可读取的 OBJ。
|
||||
* ColladaLoader 会先统一为 Y-up;额外旋转到 MuJoCo 使用的 Z-up,并烘焙节点变换与单位缩放。
|
||||
*/
|
||||
export function convertDaeToObj(data:Uint8Array,path:string):Uint8Array {
|
||||
const manager=new LoadingManager();
|
||||
// 转换只需要几何。拦截贴图 URL,避免为浏览器内存文件发起无效网络请求。
|
||||
manager.setURLModifier(()=>TRANSPARENT_PIXEL);
|
||||
const loader=new ColladaLoader(manager);
|
||||
const text=new TextDecoder('utf-8').decode(data);
|
||||
const xml=new DOMParser().parseFromString(text,'application/xml');
|
||||
if(xml.querySelector('parsererror'))throw new Error('Collada XML 格式无效');
|
||||
const upAxis=xml.getElementsByTagName('up_axis')[0]?.textContent?.trim().toUpperCase()??'Y_UP';
|
||||
// 禁用 ColladaLoader 自带的 Z-up → Y-up 旋转,改为直接统一到 MuJoCo 的 Z-up。
|
||||
if(upAxis==='Z_UP')xml.getElementsByTagName('up_axis')[0]!.textContent='Y_UP';
|
||||
const normalized=new XMLSerializer().serializeToString(xml);
|
||||
const result=loader.parse(normalized,path.slice(0,path.lastIndexOf('/')+1));
|
||||
if(!result?.scene)throw new Error('Collada 文件无法解析');
|
||||
const scene=result.scene;
|
||||
if(upAxis==='Y_UP')scene.rotation.x+=Math.PI/2;
|
||||
else if(upAxis==='X_UP')scene.rotation.y-=Math.PI/2;
|
||||
scene.updateMatrixWorld(true);
|
||||
let meshCount=0;
|
||||
scene.traverse(object=>{
|
||||
const mesh=object as Mesh;
|
||||
if(!mesh.isMesh)return;
|
||||
meshCount+=1;
|
||||
const materials=Array.isArray(mesh.material)?mesh.material:[mesh.material];
|
||||
for(const material of materials)if(material)material.name='';
|
||||
});
|
||||
if(!meshCount)throw new Error('Collada 文件不包含可转换的三角网格');
|
||||
try {
|
||||
const output=new OBJExporter().parse(scene);
|
||||
if(!/^v\s/m.test(output)||!/^f\s/m.test(output))throw new Error('Collada 文件未生成有效三角面');
|
||||
return new TextEncoder().encode(output);
|
||||
} finally {
|
||||
scene.traverse(object=>{
|
||||
const mesh=object as Mesh;
|
||||
if(!mesh.isMesh)return;
|
||||
mesh.geometry?.dispose();
|
||||
const materials:Material[]=Array.isArray(mesh.material)?mesh.material:[mesh.material];
|
||||
for(const material of materials){
|
||||
for(const value of Object.values(material))if(value&&typeof value==='object'&&(value as Texture).isTexture)(value as Texture).dispose();
|
||||
material.dispose();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import {zipSync} from 'fflate';
|
||||
import {choosePreferredEntry,discoverEntries,importBrowserFiles,normalizeProjectPath,prepareProjectForMujoco,ProjectImportError} from './importer';
|
||||
import type {ProjectFile} from './types';
|
||||
const encode=(s:string)=>new TextEncoder().encode(s);
|
||||
const projectFile=(path:string,text:string):ProjectFile=>({path,data:encode(text),size:encode(text).length,source:'file',mimeType:'text/xml'});
|
||||
const TRIANGLE_DAE=`<?xml version="1.0"?><COLLADA xmlns="http://www.collada.org/2005/11/COLLADASchema" version="1.4.1">
|
||||
<asset><unit meter="1"/><up_axis>Z_UP</up_axis></asset><library_effects><effect id="fx"><profile_COMMON><technique sid="common"><lambert><diffuse><color>1 1 1 1</color></diffuse></lambert></technique></profile_COMMON></effect></library_effects><library_materials><material id="mat"><instance_effect url="#fx"/></material></library_materials>
|
||||
<library_geometries><geometry id="triangle"><mesh><source id="positions"><float_array id="positions-array" count="9">0 0 0 1 0 0 0 1 0</float_array><technique_common><accessor source="#positions-array" count="3" stride="3"><param name="X" type="float"/><param name="Y" type="float"/><param name="Z" type="float"/></accessor></technique_common></source><vertices id="vertices"><input semantic="POSITION" source="#positions"/></vertices><triangles count="1" material="mat"><input semantic="VERTEX" source="#vertices" offset="0"/><p>0 1 2</p></triangles></mesh></geometry></library_geometries>
|
||||
<library_visual_scenes><visual_scene id="scene"><node id="node"><instance_geometry url="#triangle"><bind_material><technique_common><instance_material symbol="mat" target="#mat"/></technique_common></bind_material></instance_geometry></node></visual_scene></library_visual_scenes><scene><instance_visual_scene url="#scene"/></scene></COLLADA>`;
|
||||
describe('project importer',()=>{
|
||||
it('拒绝路径穿越与绝对路径',()=>{expect(()=>normalizeProjectPath('../model.xml')).toThrow(ProjectImportError);expect(()=>normalizeProjectPath('/model.xml')).toThrow(ProjectImportError);expect(normalizeProjectPath('robot\\mesh\\a.obj')).toBe('robot/mesh/a.obj');});
|
||||
it('识别 MJCF 与 URDF 并执行入口优先级',()=>{const entries=discoverEntries([projectFile('other.xml','<mujoco/>'),projectFile('model.xml','<mujoco/>'),projectFile('robot.urdf','<robot/>')]);expect(entries).toHaveLength(3);expect(choosePreferredEntry(entries)).toBe('model.xml');});
|
||||
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]);expect(result.files.map(f=>f.path)).toContain('robot/mesh.obj');expect(result.selectedEntry).toBe('robot/model.urdf');});
|
||||
it('拒绝 ZIP 路径穿越',async()=>{const zipped=zipSync({'../model.xml':encode('<mujoco/>')});await expect(importBrowserFiles([new File([zipped],'bad.zip')])).rejects.toThrow('路径包含越界片段');});
|
||||
it('拒绝同名路径',async()=>{const a=new File(['<mujoco/>'],'model.xml');const b=new File(['<mujoco/>'],'model.xml');await expect(importBrowserFiles([a,b])).rejects.toThrow('同名路径');});
|
||||
it('拒绝超过限制的文件',async()=>{const file=new File(['<mujoco/>'],'model.xml');await expect(importBrowserFiles([file],{maxFiles:1,maxFileBytes:2,maxTotalBytes:2,maxZipBytes:2})).rejects.toThrow('单文件超过限制');});
|
||||
it('规范化 MuJoCo 不接受的重复 material 和 ROS package URI',()=>{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>');const mesh:ProjectFile={path:'go2w_description/meshes/base.obj',data:new Uint8Array([1]),size:1,source:'directory',mimeType:''};const manifest={id:'go2w',name:'go2w',files:[urdf,mesh],entries:[{path:urdf.path,format:'urdf' as const,label:'robot'}],selectedEntry:urdf.path,totalBytes:urdf.size+1};const prepared=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"');expect(text).toContain('discardvisual="false"');expect(text).toContain('fusestatic="false"');expect(prepared.warnings).toHaveLength(2);});
|
||||
it('将 DAE mesh 转换为 MuJoCo 可读取的 OBJ,并复用于 visual/collision',()=>{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>');const dae=projectFile('robot/meshes/triangle.dae',TRIANGLE_DAE);const manifest={id:'dae',name:'dae',files:[urdf,dae],entries:[{path:urdf.path,format:'urdf' as const,label:'robot'}],selectedEntry:urdf.path,totalBytes:urdf.size+dae.size};const prepared=prepareProjectForMujoco(manifest,urdf.path);const text=new TextDecoder().decode(prepared.manifest.files.find(file=>file.path===urdf.path)!.data);expect(text).not.toContain('.dae');expect(text.match(/meshes\/triangle\.mujoco\.obj/g)).toHaveLength(2);const obj=prepared.manifest.files.find(file=>file.path==='robot/meshes/triangle.mujoco.obj');expect(new TextDecoder().decode(obj!.data)).toMatch(/^f\s/m);expect(prepared.warnings.join(' ')).toContain('1 个 DAE 文件转换为 OBJ');});
|
||||
it('DAE 缺失或转换失败时安全降级',()=>{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>');const manifest={id:'dae',name:'dae',files:[urdf],entries:[{path:urdf.path,format:'urdf' as const,label:'robot'}],selectedEntry:urdf.path,totalBytes:urdf.size};const prepared=prepareProjectForMujoco(manifest,urdf.path);const text=new TextDecoder().decode(prepared.manifest.files[0].data);expect(text).not.toContain('<visual>');expect(text).toContain('<collision>');expect(text).toContain('<sphere radius="0.05"');expect(prepared.warnings.join(' ')).toContain('DAE visual');expect(prepared.warnings.join(' ')).toContain('DAE collision');});
|
||||
it('在解压前依据 ZIP 元数据拒绝膨胀内容',async()=>{const zipped=zipSync({'model.xml':encode(`<mujoco>${' '.repeat(4096)}</mujoco>`)});const file=new File([zipped],'large.zip');await expect(importBrowserFiles([file],{maxFiles:2,maxFileBytes:128,maxTotalBytes:256,maxZipBytes:4096})).rejects.toThrow('单文件超过限制');});
|
||||
});
|
||||
@@ -0,0 +1,223 @@
|
||||
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);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
export type ModelFormat = 'mjcf' | 'urdf';
|
||||
|
||||
export interface ProjectFile {
|
||||
path: string;
|
||||
data: Uint8Array;
|
||||
size: number;
|
||||
source: 'file' | 'directory' | 'zip';
|
||||
mimeType: string;
|
||||
}
|
||||
|
||||
export interface ModelEntry {
|
||||
path: string;
|
||||
format: ModelFormat;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export interface ProjectManifest {
|
||||
id: string;
|
||||
name: string;
|
||||
files: ProjectFile[];
|
||||
entries: ModelEntry[];
|
||||
selectedEntry?: string;
|
||||
totalBytes: number;
|
||||
}
|
||||
|
||||
export interface ImportLimits {
|
||||
maxFiles: number;
|
||||
maxFileBytes: number;
|
||||
maxTotalBytes: number;
|
||||
maxZipBytes: number;
|
||||
}
|
||||
|
||||
export const DEFAULT_IMPORT_LIMITS: ImportLimits = {
|
||||
maxFiles: 2_000,
|
||||
maxFileBytes: 128 * 1024 * 1024,
|
||||
maxTotalBytes: 512 * 1024 * 1024,
|
||||
maxZipBytes: 128 * 1024 * 1024,
|
||||
};
|
||||
@@ -0,0 +1,22 @@
|
||||
import {groundConvertedMjcf} from './urdfToMjcf';
|
||||
|
||||
const encode=(value:string)=>new TextEncoder().encode(value);
|
||||
const decode=(value:Uint8Array)=>new TextDecoder().decode(value);
|
||||
|
||||
describe('groundConvertedMjcf',()=>{
|
||||
it('抬升所有根 body,并在 z=0 添加地面',()=>{
|
||||
const result=decode(groundConvertedMjcf(encode('<mujoco><worldbody><body name="robot" pos="1 2 0.1"><geom type="box" size="1 1 1"/></body></worldbody></mujoco>'),-0.4,'fixed'));
|
||||
const document=new DOMParser().parseFromString(result,'application/xml');
|
||||
expect(document.querySelector('body[name="robot"]')?.getAttribute('pos')).toBe('1 2 0.5');
|
||||
expect(document.querySelector('geom[name="__platform_ground__"]')).toMatchObject({tagName:'geom'});
|
||||
expect(document.querySelector('geom[name="__platform_ground__"]')?.getAttribute('group')).toBe('5');
|
||||
expect(document.querySelector('freejoint')).toBeNull();
|
||||
});
|
||||
|
||||
it('为浮动基座的每个世界根 body 添加 freejoint',()=>{
|
||||
const result=decode(groundConvertedMjcf(encode('<mujoco><worldbody><body name="robot"><geom type="sphere" size="1"/></body></worldbody></mujoco>'),-1,'floating'));
|
||||
const document=new DOMParser().parseFromString(result,'application/xml');
|
||||
expect(document.querySelector('body[name="robot"] > freejoint')?.getAttribute('name')).toBe('__platform_base_freejoint__');
|
||||
expect(document.querySelector('body[name="robot"]')?.getAttribute('pos')).toBe('0 0 1');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
const decoder=new TextDecoder('utf-8');
|
||||
const encoder=new TextEncoder();
|
||||
|
||||
function numbers(value:string|undefined,count:number):number[]{
|
||||
const parsed=(value??'').trim().split(/\s+/).filter(Boolean).map(Number);
|
||||
return Array.from({length:count},(_,index)=>Number.isFinite(parsed[index])?parsed[index]:0);
|
||||
}
|
||||
|
||||
export type UrdfBaseMode='floating'|'fixed';
|
||||
|
||||
/** 给 MuJoCo 从 URDF 导出的 MJCF 添加地面、设置基座类型,并整体抬升根 body。 */
|
||||
export function groundConvertedMjcf(data:Uint8Array,minimumZ:number,baseMode:UrdfBaseMode='fixed'):Uint8Array {
|
||||
const document=new DOMParser().parseFromString(decoder.decode(data),'application/xml');
|
||||
if(document.querySelector('parsererror'))throw new Error('MuJoCo 导出的 MJCF XML 无法解析');
|
||||
const worldbody=document.querySelector('mujoco > worldbody');
|
||||
if(!worldbody)throw new Error('MuJoCo 导出的 MJCF 缺少 worldbody');
|
||||
const lift=Number.isFinite(minimumZ)?-minimumZ:0;
|
||||
const rootBodies=Array.from(worldbody.children).filter(element=>element.tagName==='body');
|
||||
for(const [index,body] of rootBodies.entries()){
|
||||
const pos=numbers(body.getAttribute('pos')??undefined,3);pos[2]+=lift;body.setAttribute('pos',pos.join(' '));
|
||||
if(baseMode==='floating'&&!Array.from(body.children).some(element=>element.tagName==='freejoint'||element.tagName==='joint')){
|
||||
const freejoint=document.createElement('freejoint');freejoint.setAttribute('name',rootBodies.length===1?'__platform_base_freejoint__':`__platform_base_freejoint_${index}__`);body.prepend(freejoint);
|
||||
}
|
||||
}
|
||||
const floor=document.createElement('geom');
|
||||
floor.setAttribute('name','__platform_ground__');floor.setAttribute('type','plane');floor.setAttribute('size','1 1 0.1');floor.setAttribute('pos','0 0 0');floor.setAttribute('rgba','0.12 0.16 0.22 1');floor.setAttribute('group','5');floor.setAttribute('friction','1 0.005 0.0001');
|
||||
worldbody.prepend(floor);
|
||||
return encoder.encode(new XMLSerializer().serializeToString(document));
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
import {MemfsWorkspace} from './workspace';
|
||||
import type {MainModule} from '@mujoco/mujoco';
|
||||
import type {ProjectManifest} from './types';
|
||||
it('按相对路径挂载并逆序清理 MEMFS',()=>{const calls:string[]=[];const FS={mkdirTree:(p:string)=>calls.push(`mkdir:${p}`),writeFile:(p:string)=>calls.push(`write:${p}`),unlink:(p:string)=>calls.push(`unlink:${p}`),rmdir:(p:string)=>calls.push(`rmdir:${p}`)};const workspace=new MemfsWorkspace({FS} as unknown as MainModule,'safe');const data=new Uint8Array([1]);const manifest:ProjectManifest={id:'safe',name:'x',entries:[],files:[{path:'a/b/model.xml',data,size:1,source:'file',mimeType:''}],totalBytes:1};workspace.mount(manifest);workspace.dispose();expect(calls).toEqual(expect.arrayContaining(['/workspace/safe/a/b/model.xml'].map(p=>`write:${p}`)));expect(calls).toContain('rmdir:/workspace/safe/a/b');expect(calls).toContain('rmdir:/workspace/safe/a');expect(calls.indexOf('unlink:/workspace/safe/a/b/model.xml')).toBeLessThan(calls.indexOf('rmdir:/workspace/safe/a/b'));expect(calls.indexOf('rmdir:/workspace/safe/a/b')).toBeLessThan(calls.indexOf('rmdir:/workspace/safe/a'));});
|
||||
@@ -0,0 +1,60 @@
|
||||
import type {MainModule} from '@mujoco/mujoco';
|
||||
import type {ProjectManifest} from './types';
|
||||
|
||||
interface EmscriptenFS {
|
||||
mkdirTree(path: string): void;
|
||||
writeFile(path: string, data: Uint8Array): void;
|
||||
readFile(path:string,options:{encoding:'utf8'}):string;
|
||||
unlink(path: string): void;
|
||||
rmdir(path: string): void;
|
||||
}
|
||||
|
||||
type ModuleWithFS = MainModule & {FS: EmscriptenFS};
|
||||
|
||||
export class MemfsWorkspace {
|
||||
readonly root: string;
|
||||
private files: string[] = [];
|
||||
private directories: string[] = [];
|
||||
|
||||
constructor(private readonly module: MainModule, projectId: string) {
|
||||
const safeId = projectId.replace(/[^a-zA-Z0-9_-]/g, '_');
|
||||
this.root = `/workspace/${safeId}`;
|
||||
}
|
||||
|
||||
mount(manifest: ProjectManifest): void {
|
||||
const fs = (this.module as ModuleWithFS).FS;
|
||||
fs.mkdirTree(this.root); this.directories.push(this.root);
|
||||
for (const file of manifest.files) {
|
||||
const absolute = `${this.root}/${file.path}`;
|
||||
const directory = absolute.slice(0, absolute.lastIndexOf('/'));
|
||||
if (directory !== this.root) {
|
||||
const relativeDirectory = directory.slice(this.root.length + 1);
|
||||
let current = this.root;
|
||||
for (const segment of relativeDirectory.split('/')) {
|
||||
current = `${current}/${segment}`;
|
||||
if (!this.directories.includes(current)) {
|
||||
fs.mkdirTree(current);
|
||||
this.directories.push(current);
|
||||
}
|
||||
}
|
||||
}
|
||||
fs.writeFile(absolute, file.data); this.files.push(absolute);
|
||||
}
|
||||
}
|
||||
|
||||
path(relative: string): string { return `${this.root}/${relative}`; }
|
||||
|
||||
readText(relative:string):string{return (this.module as ModuleWithFS).FS.readFile(this.path(relative),{encoding:'utf8'});}
|
||||
|
||||
writeGenerated(relative:string,data:Uint8Array):void {
|
||||
const absolute=this.path(relative);(this.module as ModuleWithFS).FS.writeFile(absolute,data);
|
||||
if(!this.files.includes(absolute))this.files.push(absolute);
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
const fs = (this.module as ModuleWithFS).FS;
|
||||
for (const file of this.files.reverse()) { try { fs.unlink(file); } catch { /* best-effort after failed mount */ } }
|
||||
for (const dir of this.directories.sort((a, b) => b.length - a.length)) { try { fs.rmdir(dir); } catch { /* parent or shared root */ } }
|
||||
this.files = []; this.directories = [];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user