chore(web-platform): release V0.6.1 工程质量优化
This commit is contained in:
@@ -1,24 +1,64 @@
|
||||
import {fireEvent,render,screen} from '@testing-library/react';
|
||||
import {buildBodyTree,countModelStructureSearchResults,ModelStructureTree} from './ModelStructureTree';
|
||||
import type {BodyInfo,JointInfo} from '../simulation/SimulationSession';
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import {
|
||||
buildBodyTree,
|
||||
countModelStructureSearchResults,
|
||||
ModelStructureTree,
|
||||
} from './ModelStructureTree';
|
||||
import type { BodyInfo, JointInfo } from '../simulation/SimulationSession';
|
||||
|
||||
const bodies:BodyInfo[]=[{id:0,name:'world',parentId:0},{id:1,name:'base',parentId:0},{id:2,name:'arm',parentId:1}];
|
||||
const joints:JointInfo[]=[{id:0,name:'arm_joint',type:3,value:0,min:-1,max:1,limitMin:-1,limitMax:1,limited:true,limitsIgnored:false,editable:true,bodyId:2,axis:[0,0,1]}];
|
||||
const bodies: BodyInfo[] = [
|
||||
{ id: 0, name: 'world', parentId: 0 },
|
||||
{ id: 1, name: 'base', parentId: 0 },
|
||||
{ id: 2, name: 'arm', parentId: 1 },
|
||||
];
|
||||
const joints: JointInfo[] = [
|
||||
{
|
||||
id: 0,
|
||||
name: 'arm_joint',
|
||||
type: 3,
|
||||
value: 0,
|
||||
min: -1,
|
||||
max: 1,
|
||||
limitMin: -1,
|
||||
limitMax: 1,
|
||||
limited: true,
|
||||
limitsIgnored: false,
|
||||
editable: true,
|
||||
bodyId: 2,
|
||||
axis: [0, 0, 1],
|
||||
},
|
||||
];
|
||||
|
||||
describe('ModelStructureTree',()=>{
|
||||
it('按 body 父子关系构建结构,并将关节放在所属 body 下',()=>{
|
||||
const tree=buildBodyTree(bodies,joints);
|
||||
expect(tree[0]).toMatchObject({id:1,name:'base'});
|
||||
expect(tree[0].children[0]).toMatchObject({id:2,name:'arm'});
|
||||
describe('ModelStructureTree', () => {
|
||||
it('按 body 父子关系构建结构,并将关节放在所属 body 下', () => {
|
||||
const tree = buildBodyTree(bodies, joints);
|
||||
expect(tree[0]).toMatchObject({ id: 1, name: 'base' });
|
||||
expect(tree[0].children[0]).toMatchObject({ id: 2, name: 'arm' });
|
||||
expect(tree[0].children[0].joints[0].name).toBe('arm_joint');
|
||||
});
|
||||
|
||||
it('鼠标进入和离开关节时通知查看器高亮',()=>{
|
||||
const hover=vi.fn();render(<ModelStructureTree bodies={bodies} joints={joints} onJointHover={hover}/>);expect(screen.getByRole('treeitem',{name:'base'})).toHaveAttribute('aria-expanded','true');const item=screen.getByRole('treeitem',{name:/arm_joint/});
|
||||
fireEvent.mouseEnter(item);fireEvent.mouseLeave(item);expect(hover.mock.calls).toEqual([[0],[null]]);
|
||||
it('鼠标进入和离开关节时通知查看器高亮', () => {
|
||||
const hover = vi.fn();
|
||||
render(<ModelStructureTree bodies={bodies} joints={joints} onJointHover={hover} />);
|
||||
expect(screen.getByRole('treeitem', { name: 'base' })).toHaveAttribute('aria-expanded', 'true');
|
||||
const item = screen.getByRole('treeitem', { name: /arm_joint/ });
|
||||
fireEvent.mouseEnter(item);
|
||||
fireEvent.mouseLeave(item);
|
||||
expect(hover.mock.calls).toEqual([[0], [null]]);
|
||||
});
|
||||
|
||||
it('按 Body 或关节名称过滤并保留祖先路径',()=>{
|
||||
render(<ModelStructureTree bodies={bodies} joints={joints} query="arm_joint" onJointHover={()=>{}}/>);expect(screen.getByRole('treeitem',{name:'base'})).toBeVisible();expect(screen.getByRole('treeitem',{name:/arm_joint/})).toBeVisible();expect(countModelStructureSearchResults(bodies,joints,'arm_joint')).toBe(3);expect(countModelStructureSearchResults(bodies,joints,'world')).toBe(0);
|
||||
it('按 Body 或关节名称过滤并保留祖先路径', () => {
|
||||
render(
|
||||
<ModelStructureTree
|
||||
bodies={bodies}
|
||||
joints={joints}
|
||||
query="arm_joint"
|
||||
onJointHover={() => {}}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByRole('treeitem', { name: 'base' })).toBeVisible();
|
||||
expect(screen.getByRole('treeitem', { name: /arm_joint/ })).toBeVisible();
|
||||
expect(countModelStructureSearchResults(bodies, joints, 'arm_joint')).toBe(3);
|
||||
expect(countModelStructureSearchResults(bodies, joints, 'world')).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,32 +1,257 @@
|
||||
import {useState} from 'react';
|
||||
import {Box,Disc3} from 'lucide-react';
|
||||
import type {BodyInfo,JointInfo} from '../simulation/SimulationSession';
|
||||
import {EmptySearchState,SearchHighlight,VirtualTreeViewport} from '../components/ui';
|
||||
import { useState } from 'react';
|
||||
import { Box, Disc3 } from 'lucide-react';
|
||||
import type { BodyInfo, JointInfo } from '../simulation/SimulationSession';
|
||||
import { EmptySearchState, SearchHighlight, VirtualTreeViewport } from '../components/ui';
|
||||
|
||||
interface BodyNode extends BodyInfo {children:BodyNode[];joints:JointInfo[];}
|
||||
interface BodyNode extends BodyInfo {
|
||||
children: BodyNode[];
|
||||
joints: JointInfo[];
|
||||
}
|
||||
|
||||
// eslint-disable-next-line react-refresh/only-export-components
|
||||
export function buildBodyTree(bodies:BodyInfo[],joints:JointInfo[]):BodyNode[]{
|
||||
const nodes=new Map<number,BodyNode>();for(const body of bodies)if(body.id>0)nodes.set(body.id,{...body,children:[],joints:joints.filter(joint=>joint.bodyId===body.id)});
|
||||
const roots:BodyNode[]=[];
|
||||
for(const node of nodes.values()){const parent=nodes.get(node.parentId);if(parent)parent.children.push(node);else roots.push(node);}
|
||||
const sort=(items:BodyNode[])=>{items.sort((a,b)=>a.id-b.id);for(const item of items)sort(item.children);};sort(roots);return roots;
|
||||
export function buildBodyTree(bodies: BodyInfo[], joints: JointInfo[]): BodyNode[] {
|
||||
const nodes = new Map<number, BodyNode>();
|
||||
for (const body of bodies)
|
||||
if (body.id > 0)
|
||||
nodes.set(body.id, {
|
||||
...body,
|
||||
children: [],
|
||||
joints: joints.filter((joint) => joint.bodyId === body.id),
|
||||
});
|
||||
const roots: BodyNode[] = [];
|
||||
for (const node of nodes.values()) {
|
||||
const parent = nodes.get(node.parentId);
|
||||
if (parent) parent.children.push(node);
|
||||
else roots.push(node);
|
||||
}
|
||||
const sort = (items: BodyNode[]) => {
|
||||
items.sort((a, b) => a.id - b.id);
|
||||
for (const item of items) sort(item.children);
|
||||
};
|
||||
sort(roots);
|
||||
return roots;
|
||||
}
|
||||
|
||||
function BodyBranch({node,depth,onJointHover,searching,query}:{node:BodyNode;depth:number;onJointHover:(jointId:number|null)=>void;searching:boolean;query:string}){
|
||||
const hasChildren=node.joints.length>0||node.children.length>0;const [open,setOpen]=useState(depth<2),shownOpen=searching||open;
|
||||
return <li role="none">{hasChildren?<details open={shownOpen} onToggle={event=>{if(!searching)setOpen(event.currentTarget.open);}}><summary role="treeitem" aria-expanded={shownOpen} tabIndex={0} onClick={event=>{if(searching)event.preventDefault();}} className="flex cursor-pointer select-none items-center gap-1.5 truncate rounded px-1.5 py-1 text-xs text-text-secondary hover:bg-element-hover focus-visible:ring-2 focus-visible:ring-accent/30"><Box aria-hidden="true" className="h-3.5 w-3.5 shrink-0 text-accent"/><span className="truncate"><SearchHighlight text={node.name} query={query}/></span></summary><ul role="group" className="ml-3 border-l border-border pl-1">{node.joints.map(joint=><li role="none" key={joint.id}><span role="treeitem" tabIndex={0} className="flex cursor-default items-center gap-1.5 truncate rounded px-1.5 py-1 text-xs text-warning hover:bg-warning-soft focus:bg-warning-soft focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/30" onMouseEnter={()=>onJointHover(joint.id)} onMouseLeave={()=>onJointHover(null)} onFocus={()=>onJointHover(joint.id)} onBlur={()=>onJointHover(null)} title={`关节:${joint.name}`}><Disc3 aria-hidden="true" className="h-3.5 w-3.5 shrink-0"/><SearchHighlight text={joint.name} query={query}/></span></li>)}{node.children.map(child=><BodyBranch key={child.id} node={child} depth={depth+1} onJointHover={onJointHover} searching={searching} query={query}/>)}</ul></details>:<div role="treeitem" tabIndex={0} className="flex items-center gap-1.5 truncate rounded px-1.5 py-1 text-xs text-text-secondary focus-visible:ring-2 focus-visible:ring-accent/30"><Box aria-hidden="true" className="h-3.5 w-3.5 shrink-0 text-accent"/><SearchHighlight text={node.name} query={query}/></div>}</li>;
|
||||
function BodyBranch({
|
||||
node,
|
||||
depth,
|
||||
onJointHover,
|
||||
searching,
|
||||
query,
|
||||
}: {
|
||||
node: BodyNode;
|
||||
depth: number;
|
||||
onJointHover: (jointId: number | null) => void;
|
||||
searching: boolean;
|
||||
query: string;
|
||||
}) {
|
||||
const hasChildren = node.joints.length > 0 || node.children.length > 0;
|
||||
const [open, setOpen] = useState(depth < 2),
|
||||
shownOpen = searching || open;
|
||||
return (
|
||||
<li role="none">
|
||||
{hasChildren ? (
|
||||
<details
|
||||
open={shownOpen}
|
||||
onToggle={(event) => {
|
||||
if (!searching) setOpen(event.currentTarget.open);
|
||||
}}
|
||||
>
|
||||
<summary
|
||||
role="treeitem"
|
||||
aria-expanded={shownOpen}
|
||||
tabIndex={0}
|
||||
onClick={(event) => {
|
||||
if (searching) event.preventDefault();
|
||||
}}
|
||||
className="flex cursor-pointer select-none items-center gap-1.5 truncate rounded px-1.5 py-1 text-xs text-text-secondary hover:bg-element-hover focus-visible:ring-2 focus-visible:ring-accent/30"
|
||||
>
|
||||
<Box aria-hidden="true" className="h-3.5 w-3.5 shrink-0 text-accent" />
|
||||
<span className="truncate">
|
||||
<SearchHighlight text={node.name} query={query} />
|
||||
</span>
|
||||
</summary>
|
||||
<ul role="group" className="ml-3 border-l border-border pl-1">
|
||||
{node.joints.map((joint) => (
|
||||
<li role="none" key={joint.id}>
|
||||
<span
|
||||
role="treeitem"
|
||||
tabIndex={0}
|
||||
className="flex cursor-default items-center gap-1.5 truncate rounded px-1.5 py-1 text-xs text-warning hover:bg-warning-soft focus:bg-warning-soft focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/30"
|
||||
onMouseEnter={() => onJointHover(joint.id)}
|
||||
onMouseLeave={() => onJointHover(null)}
|
||||
onFocus={() => onJointHover(joint.id)}
|
||||
onBlur={() => onJointHover(null)}
|
||||
title={`关节:${joint.name}`}
|
||||
>
|
||||
<Disc3 aria-hidden="true" className="h-3.5 w-3.5 shrink-0" />
|
||||
<SearchHighlight text={joint.name} query={query} />
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
{node.children.map((child) => (
|
||||
<BodyBranch
|
||||
key={child.id}
|
||||
node={child}
|
||||
depth={depth + 1}
|
||||
onJointHover={onJointHover}
|
||||
searching={searching}
|
||||
query={query}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
</details>
|
||||
) : (
|
||||
<div
|
||||
role="treeitem"
|
||||
tabIndex={0}
|
||||
className="flex items-center gap-1.5 truncate rounded px-1.5 py-1 text-xs text-text-secondary focus-visible:ring-2 focus-visible:ring-accent/30"
|
||||
>
|
||||
<Box aria-hidden="true" className="h-3.5 w-3.5 shrink-0 text-accent" />
|
||||
<SearchHighlight text={node.name} query={query} />
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
function filterBodies(nodes:BodyNode[],query:string):BodyNode[]{if(!query)return nodes;return nodes.flatMap(node=>{if(node.name.toLocaleLowerCase().includes(query))return [node];const joints=node.joints.filter(joint=>joint.name.toLocaleLowerCase().includes(query)),children=filterBodies(node.children,query);return joints.length||children.length?[{...node,joints,children}]:[];});}
|
||||
function countBodyNodes(nodes:BodyNode[]):number{return nodes.reduce((total,node)=>total+1+node.joints.length+countBodyNodes(node.children),0);}
|
||||
function filterBodies(nodes: BodyNode[], query: string): BodyNode[] {
|
||||
if (!query) return nodes;
|
||||
return nodes.flatMap((node) => {
|
||||
if (node.name.toLocaleLowerCase().includes(query)) return [node];
|
||||
const joints = node.joints.filter((joint) => joint.name.toLocaleLowerCase().includes(query)),
|
||||
children = filterBodies(node.children, query);
|
||||
return joints.length || children.length ? [{ ...node, joints, children }] : [];
|
||||
});
|
||||
}
|
||||
function countBodyNodes(nodes: BodyNode[]): number {
|
||||
return nodes.reduce(
|
||||
(total, node) => total + 1 + node.joints.length + countBodyNodes(node.children),
|
||||
0,
|
||||
);
|
||||
}
|
||||
// eslint-disable-next-line react-refresh/only-export-components
|
||||
export function countModelStructureSearchResults(bodies:BodyInfo[],joints:JointInfo[],query:string):number{return countBodyNodes(filterBodies(buildBodyTree(bodies,joints),query.trim().toLocaleLowerCase()));}
|
||||
type FlatBodyItem={kind:'body';body:BodyNode;depth:number}|{kind:'joint';joint:JointInfo;depth:number};
|
||||
function flattenBodies(nodes:BodyNode[],expanded:Set<number>,searching:boolean,depth=0):FlatBodyItem[]{return nodes.flatMap(body=>[{kind:'body' as const,body,depth},...(searching||expanded.has(body.id)?[...body.joints.map(joint=>({kind:'joint' as const,joint,depth:depth+1})),...flattenBodies(body.children,expanded,searching,depth+1)]:[])]);}
|
||||
function initiallyExpanded(nodes:BodyNode[],depth=0):number[]{return nodes.flatMap(body=>[...(depth<2?[body.id]:[]),...initiallyExpanded(body.children,depth+1)]);}
|
||||
export function ModelStructureTree({bodies,joints,onJointHover,query=''}:{bodies:BodyInfo[];joints:JointInfo[];onJointHover:(jointId:number|null)=>void;query?:string}){
|
||||
const normalized=query.trim().toLocaleLowerCase(),roots=filterBodies(buildBodyTree(bodies,joints),normalized),[virtualExpanded,setVirtualExpanded]=useState(()=>new Set(initiallyExpanded(buildBodyTree(bodies,joints))));
|
||||
if(bodies.length+joints.length>500&&roots.length){const searching=Boolean(normalized),flat=flattenBodies(roots,virtualExpanded,searching),toggle=(item:FlatBodyItem)=>{if(item.kind!=='body'||searching)return;setVirtualExpanded(current=>{const next=new Set(current);if(next.has(item.body.id))next.delete(item.body.id);else next.add(item.body.id);return next;});};return <nav aria-label="模型结构树"><VirtualTreeViewport label="虚拟化模型结构树" items={flat} getKey={item=>item.kind==='body'?`b:${item.body.id}`:`j:${item.joint.id}`} getLevel={item=>item.depth+1} isExpandable={item=>item.kind==='body'&&(item.body.joints.length>0||item.body.children.length>0)} isExpanded={item=>item.kind==='body'&&(searching||virtualExpanded.has(item.body.id))} onToggle={toggle} onActiveChange={item=>onJointHover(item.kind==='joint'?item.joint.id:null)} renderRow={item=>item.kind==='body'?<div onDoubleClick={()=>toggle(item)} className="flex h-full items-center gap-1.5 px-1.5 text-xs text-text-secondary" style={{paddingLeft:item.depth*12+6}}><Box className="h-3.5 w-3.5 text-accent"/><SearchHighlight text={item.body.name} query={query}/></div>:<div className="flex h-full items-center gap-1.5 px-1.5 text-xs text-warning" style={{paddingLeft:item.depth*12+6}} onMouseEnter={()=>onJointHover(item.joint.id)} onMouseLeave={()=>onJointHover(null)}><Disc3 className="h-3.5 w-3.5"/><SearchHighlight text={item.joint.name} query={query}/></div>}/></nav>;}
|
||||
return <nav aria-label="模型结构树">{roots.length?<ul role="tree">{roots.map(root=><BodyBranch key={root.id} node={root} depth={0} onJointHover={onJointHover} searching={Boolean(normalized)} query={query}/>)}</ul>:<EmptySearchState label="没有匹配的 Body 或关节"/>}</nav>;
|
||||
export function countModelStructureSearchResults(
|
||||
bodies: BodyInfo[],
|
||||
joints: JointInfo[],
|
||||
query: string,
|
||||
): number {
|
||||
return countBodyNodes(
|
||||
filterBodies(buildBodyTree(bodies, joints), query.trim().toLocaleLowerCase()),
|
||||
);
|
||||
}
|
||||
type FlatBodyItem =
|
||||
| { kind: 'body'; body: BodyNode; depth: number }
|
||||
| { kind: 'joint'; joint: JointInfo; depth: number };
|
||||
function flattenBodies(
|
||||
nodes: BodyNode[],
|
||||
expanded: Set<number>,
|
||||
searching: boolean,
|
||||
depth = 0,
|
||||
): FlatBodyItem[] {
|
||||
return nodes.flatMap((body) => [
|
||||
{ kind: 'body' as const, body, depth },
|
||||
...(searching || expanded.has(body.id)
|
||||
? [
|
||||
...body.joints.map((joint) => ({ kind: 'joint' as const, joint, depth: depth + 1 })),
|
||||
...flattenBodies(body.children, expanded, searching, depth + 1),
|
||||
]
|
||||
: []),
|
||||
]);
|
||||
}
|
||||
function initiallyExpanded(nodes: BodyNode[], depth = 0): number[] {
|
||||
return nodes.flatMap((body) => [
|
||||
...(depth < 2 ? [body.id] : []),
|
||||
...initiallyExpanded(body.children, depth + 1),
|
||||
]);
|
||||
}
|
||||
export function ModelStructureTree({
|
||||
bodies,
|
||||
joints,
|
||||
onJointHover,
|
||||
query = '',
|
||||
}: {
|
||||
bodies: BodyInfo[];
|
||||
joints: JointInfo[];
|
||||
onJointHover: (jointId: number | null) => void;
|
||||
query?: string;
|
||||
}) {
|
||||
const normalized = query.trim().toLocaleLowerCase(),
|
||||
roots = filterBodies(buildBodyTree(bodies, joints), normalized),
|
||||
[virtualExpanded, setVirtualExpanded] = useState(
|
||||
() => new Set(initiallyExpanded(buildBodyTree(bodies, joints))),
|
||||
);
|
||||
if (bodies.length + joints.length > 500 && roots.length) {
|
||||
const searching = Boolean(normalized),
|
||||
flat = flattenBodies(roots, virtualExpanded, searching),
|
||||
toggle = (item: FlatBodyItem) => {
|
||||
if (item.kind !== 'body' || searching) return;
|
||||
setVirtualExpanded((current) => {
|
||||
const next = new Set(current);
|
||||
if (next.has(item.body.id)) next.delete(item.body.id);
|
||||
else next.add(item.body.id);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
return (
|
||||
<nav aria-label="模型结构树">
|
||||
<VirtualTreeViewport
|
||||
label="虚拟化模型结构树"
|
||||
items={flat}
|
||||
getKey={(item) => (item.kind === 'body' ? `b:${item.body.id}` : `j:${item.joint.id}`)}
|
||||
getLevel={(item) => item.depth + 1}
|
||||
isExpandable={(item) =>
|
||||
item.kind === 'body' && (item.body.joints.length > 0 || item.body.children.length > 0)
|
||||
}
|
||||
isExpanded={(item) =>
|
||||
item.kind === 'body' && (searching || virtualExpanded.has(item.body.id))
|
||||
}
|
||||
onToggle={toggle}
|
||||
onActiveChange={(item) => onJointHover(item.kind === 'joint' ? item.joint.id : null)}
|
||||
renderRow={(item) =>
|
||||
item.kind === 'body' ? (
|
||||
<div
|
||||
onDoubleClick={() => toggle(item)}
|
||||
className="flex h-full items-center gap-1.5 px-1.5 text-xs text-text-secondary"
|
||||
style={{ paddingLeft: item.depth * 12 + 6 }}
|
||||
>
|
||||
<Box className="h-3.5 w-3.5 text-accent" />
|
||||
<SearchHighlight text={item.body.name} query={query} />
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className="flex h-full items-center gap-1.5 px-1.5 text-xs text-warning"
|
||||
style={{ paddingLeft: item.depth * 12 + 6 }}
|
||||
onMouseEnter={() => onJointHover(item.joint.id)}
|
||||
onMouseLeave={() => onJointHover(null)}
|
||||
>
|
||||
<Disc3 className="h-3.5 w-3.5" />
|
||||
<SearchHighlight text={item.joint.name} query={query} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
/>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<nav aria-label="模型结构树">
|
||||
{roots.length ? (
|
||||
<ul role="tree">
|
||||
{roots.map((root) => (
|
||||
<BodyBranch
|
||||
key={root.id}
|
||||
node={root}
|
||||
depth={0}
|
||||
onJointHover={onJointHover}
|
||||
searching={Boolean(normalized)}
|
||||
query={query}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<EmptySearchState label="没有匹配的 Body 或关节" />
|
||||
)}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,27 +1,44 @@
|
||||
import {fireEvent,render,screen,within} from '@testing-library/react';
|
||||
import {buildProjectTree,countProjectSearchResults,ProjectTree} from './ProjectTree';
|
||||
import { fireEvent, render, screen, within } from '@testing-library/react';
|
||||
import { buildProjectTree, countProjectSearchResults, 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},
|
||||
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'],
|
||||
describe('ProjectTree', () => {
|
||||
it('按路径构建多级目录,并将目录排在文件前面', () => {
|
||||
const tree = buildProjectTree(files);
|
||||
expect(tree.map((node) => [node.kind, node.name])).toEqual([
|
||||
['directory', 'robot'],
|
||||
['file', 'README.txt'],
|
||||
]);
|
||||
expect(robot.children?.[0].children?.[0]).toMatchObject({kind:'file',name:'arm.obj',path:'robot/meshes/arm.obj'});
|
||||
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:'urdf',label:'model'}]} selectedEntry="robot/model.xml"/>);
|
||||
const tree=screen.getByRole('navigation',{name:'工程文件树'}),robot=within(tree).getByText('robot'),meshes=within(tree).getByText('meshes');
|
||||
it('以可折叠目录显示文件名,而不是平铺完整路径', () => {
|
||||
render(
|
||||
<ProjectTree
|
||||
files={files}
|
||||
entries={[{ path: 'robot/model.xml', format: 'urdf', label: 'model' }]}
|
||||
selectedEntry="robot/model.xml"
|
||||
/>,
|
||||
);
|
||||
const tree = screen.getByRole('navigation', { name: '工程文件树' }),
|
||||
robot = within(tree).getByText('robot'),
|
||||
meshes = within(tree).getByText('meshes');
|
||||
expect(robot.closest('details')).toHaveAttribute('open');
|
||||
expect(meshes.closest('details')).not.toHaveAttribute('open');
|
||||
fireEvent.click(meshes);
|
||||
@@ -30,10 +47,25 @@ describe('ProjectTree',()=>{
|
||||
expect(within(tree).getByText('urdf')).toBeVisible();
|
||||
});
|
||||
|
||||
it('搜索时只保留匹配文件及其目录路径',()=>{
|
||||
render(<ProjectTree files={files} entries={[]} query="arm.obj"/>);
|
||||
expect(screen.getByText('robot')).toBeVisible();expect(screen.getByText('meshes')).toBeVisible();expect(screen.getByText('arm.obj')).toBeVisible();expect(screen.queryByText('README.txt')).not.toBeInTheDocument();expect(countProjectSearchResults(files,'meshes')).toBe(3);expect(countProjectSearchResults(files,'robot/meshes')).toBe(0);
|
||||
it('搜索时只保留匹配文件及其目录路径', () => {
|
||||
render(<ProjectTree files={files} entries={[]} query="arm.obj" />);
|
||||
expect(screen.getByText('robot')).toBeVisible();
|
||||
expect(screen.getByText('meshes')).toBeVisible();
|
||||
expect(screen.getByText('arm.obj')).toBeVisible();
|
||||
expect(screen.queryByText('README.txt')).not.toBeInTheDocument();
|
||||
expect(countProjectSearchResults(files, 'meshes')).toBe(3);
|
||||
expect(countProjectSearchResults(files, 'robot/meshes')).toBe(0);
|
||||
});
|
||||
|
||||
it('大型工程使用可键盘折叠的虚拟树',()=>{const large=Array.from({length:401},(_,index)=>({path:`assets/file-${index}.obj`,size:1}));render(<ProjectTree files={large} entries={[]}/>);const tree=screen.getByRole('tree',{name:'虚拟化工程文件树'});expect(tree).toHaveAttribute('aria-activedescendant',expect.stringContaining('assets'));fireEvent.keyDown(tree,{key:'ArrowLeft'});expect(screen.queryByText('file-0.obj')).not.toBeInTheDocument();});
|
||||
it('大型工程使用可键盘折叠的虚拟树', () => {
|
||||
const large = Array.from({ length: 401 }, (_, index) => ({
|
||||
path: `assets/file-${index}.obj`,
|
||||
size: 1,
|
||||
}));
|
||||
render(<ProjectTree files={large} entries={[]} />);
|
||||
const tree = screen.getByRole('tree', { name: '虚拟化工程文件树' });
|
||||
expect(tree).toHaveAttribute('aria-activedescendant', expect.stringContaining('assets'));
|
||||
fireEvent.keyDown(tree, { key: 'ArrowLeft' });
|
||||
expect(screen.queryByText('file-0.obj')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,76 +1,314 @@
|
||||
import {useState} from 'react';
|
||||
import {Box,File,FileCode2,Folder,FolderOpen} from 'lucide-react';
|
||||
import type {ModelEntry} from './types';
|
||||
import {EmptySearchState,SearchHighlight,VirtualTreeViewport} from '../components/ui';
|
||||
import { useState } from 'react';
|
||||
import { Box, File, FileCode2, Folder, FolderOpen } from 'lucide-react';
|
||||
import type { ModelEntry } from './types';
|
||||
import { EmptySearchState, SearchHighlight, VirtualTreeViewport } from '../components/ui';
|
||||
|
||||
export interface ProjectTreeFile {path:string;size:number;}
|
||||
export interface ProjectTreeFile {
|
||||
path: string;
|
||||
size: number;
|
||||
}
|
||||
|
||||
export interface ProjectTreeNode {
|
||||
name:string;
|
||||
path:string;
|
||||
kind:'directory'|'file';
|
||||
size?:number;
|
||||
children?:ProjectTreeNode[];
|
||||
name: string;
|
||||
path: string;
|
||||
kind: 'directory' | 'file';
|
||||
size?: number;
|
||||
children?: ProjectTreeNode[];
|
||||
}
|
||||
|
||||
interface MutableDirectory {
|
||||
name:string;
|
||||
path:string;
|
||||
directories:Map<string,MutableDirectory>;
|
||||
files:ProjectTreeNode[];
|
||||
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'});
|
||||
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;
|
||||
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});
|
||||
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);
|
||||
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 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`;
|
||||
}
|
||||
|
||||
interface TreeNodeProps {entryFormats:Map<string,ModelEntry['format']>;selectedEntry?:string;expandedEntry?:string;searching:boolean;query:string;}
|
||||
function DirectoryNode({node,entryFormats,selectedEntry,expandedEntry,searching,query}:TreeNodeProps&{node:ProjectTreeNode}){const [open,setOpen]=useState(Boolean(expandedEntry?.startsWith(`${node.path}/`)));const shownOpen=searching||open,FolderIcon=shownOpen?FolderOpen:Folder;return <li><details open={shownOpen} onToggle={event=>{if(!searching)setOpen(event.currentTarget.open);}}><summary title={node.path} onClick={event=>{if(searching)event.preventDefault();}} className="flex cursor-pointer select-none items-center gap-1.5 truncate rounded px-1.5 py-1 text-xs text-text-secondary hover:bg-element-hover"><FolderIcon aria-hidden="true" className="h-3.5 w-3.5 shrink-0 text-accent"/><span className="truncate"><SearchHighlight text={node.name} query={query}/></span></summary><TreeNodes nodes={node.children??[]} entryFormats={entryFormats} selectedEntry={selectedEntry} expandedEntry={expandedEntry} searching={searching} query={query}/></details></li>;}
|
||||
function TreeNodes({nodes,entryFormats,selectedEntry,expandedEntry,searching,query}:TreeNodeProps&{nodes:ProjectTreeNode[]}){
|
||||
return <ul role="group" className="ml-3 border-l border-border pl-1">{nodes.map(node=>{if(node.kind==='directory')return <DirectoryNode key={`d:${node.path}`} node={node} entryFormats={entryFormats} selectedEntry={selectedEntry} expandedEntry={expandedEntry} searching={searching} query={query}/>;const EntryIcon=entryFormats.has(node.path)?FileCode2:node.path.endsWith('.obj')||node.path.endsWith('.stl')||node.path.endsWith('.dae')?Box:File;return <li key={`f:${node.path}`} title={node.path} className={`flex min-w-0 items-center gap-1.5 rounded px-1.5 py-1 text-xs ${selectedEntry===node.path?'bg-accent-soft text-accent':'text-text-secondary hover:bg-element-hover'}`}><EntryIcon aria-hidden="true" className="h-3.5 w-3.5 shrink-0"/><span className="min-w-0 flex-1 truncate"><SearchHighlight text={node.name} query={query}/></span>{entryFormats.has(node.path)&&<span className="shrink-0 text-[10px] uppercase text-accent">{entryFormats.get(node.path)}</span>}<span className="shrink-0 text-[10px] text-text-tertiary">{formatSize(node.size??0)}</span></li>;})}</ul>;
|
||||
interface TreeNodeProps {
|
||||
entryFormats: Map<string, ModelEntry['format']>;
|
||||
selectedEntry?: string;
|
||||
expandedEntry?: string;
|
||||
searching: boolean;
|
||||
query: string;
|
||||
}
|
||||
function DirectoryNode({
|
||||
node,
|
||||
entryFormats,
|
||||
selectedEntry,
|
||||
expandedEntry,
|
||||
searching,
|
||||
query,
|
||||
}: TreeNodeProps & { node: ProjectTreeNode }) {
|
||||
const [open, setOpen] = useState(Boolean(expandedEntry?.startsWith(`${node.path}/`)));
|
||||
const shownOpen = searching || open,
|
||||
FolderIcon = shownOpen ? FolderOpen : Folder;
|
||||
return (
|
||||
<li>
|
||||
<details
|
||||
open={shownOpen}
|
||||
onToggle={(event) => {
|
||||
if (!searching) setOpen(event.currentTarget.open);
|
||||
}}
|
||||
>
|
||||
<summary
|
||||
title={node.path}
|
||||
onClick={(event) => {
|
||||
if (searching) event.preventDefault();
|
||||
}}
|
||||
className="flex cursor-pointer select-none items-center gap-1.5 truncate rounded px-1.5 py-1 text-xs text-text-secondary hover:bg-element-hover"
|
||||
>
|
||||
<FolderIcon aria-hidden="true" className="h-3.5 w-3.5 shrink-0 text-accent" />
|
||||
<span className="truncate">
|
||||
<SearchHighlight text={node.name} query={query} />
|
||||
</span>
|
||||
</summary>
|
||||
<TreeNodes
|
||||
nodes={node.children ?? []}
|
||||
entryFormats={entryFormats}
|
||||
selectedEntry={selectedEntry}
|
||||
expandedEntry={expandedEntry}
|
||||
searching={searching}
|
||||
query={query}
|
||||
/>
|
||||
</details>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
function TreeNodes({
|
||||
nodes,
|
||||
entryFormats,
|
||||
selectedEntry,
|
||||
expandedEntry,
|
||||
searching,
|
||||
query,
|
||||
}: TreeNodeProps & { nodes: ProjectTreeNode[] }) {
|
||||
return (
|
||||
<ul role="group" className="ml-3 border-l border-border pl-1">
|
||||
{nodes.map((node) => {
|
||||
if (node.kind === 'directory')
|
||||
return (
|
||||
<DirectoryNode
|
||||
key={`d:${node.path}`}
|
||||
node={node}
|
||||
entryFormats={entryFormats}
|
||||
selectedEntry={selectedEntry}
|
||||
expandedEntry={expandedEntry}
|
||||
searching={searching}
|
||||
query={query}
|
||||
/>
|
||||
);
|
||||
const EntryIcon = entryFormats.has(node.path)
|
||||
? FileCode2
|
||||
: node.path.endsWith('.obj') || node.path.endsWith('.stl') || node.path.endsWith('.dae')
|
||||
? Box
|
||||
: File;
|
||||
return (
|
||||
<li
|
||||
key={`f:${node.path}`}
|
||||
title={node.path}
|
||||
className={`flex min-w-0 items-center gap-1.5 rounded px-1.5 py-1 text-xs ${selectedEntry === node.path ? 'bg-accent-soft text-accent' : 'text-text-secondary hover:bg-element-hover'}`}
|
||||
>
|
||||
<EntryIcon aria-hidden="true" className="h-3.5 w-3.5 shrink-0" />
|
||||
<span className="min-w-0 flex-1 truncate">
|
||||
<SearchHighlight text={node.name} query={query} />
|
||||
</span>
|
||||
{entryFormats.has(node.path) && (
|
||||
<span className="shrink-0 text-[10px] uppercase text-accent">
|
||||
{entryFormats.get(node.path)}
|
||||
</span>
|
||||
)}
|
||||
<span className="shrink-0 text-[10px] text-text-tertiary">
|
||||
{formatSize(node.size ?? 0)}
|
||||
</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
function filterNodes(nodes:ProjectTreeNode[],query:string):ProjectTreeNode[]{if(!query)return nodes;return nodes.flatMap(node=>{if(node.name.toLocaleLowerCase().includes(query))return [node];if(node.kind==='file')return [];const children=filterNodes(node.children??[],query);return children.length?[{...node,children}]:[];});}
|
||||
function countNodes(nodes:ProjectTreeNode[]):number{return nodes.reduce((total,node)=>total+1+(node.children?countNodes(node.children):0),0);}
|
||||
function filterNodes(nodes: ProjectTreeNode[], query: string): ProjectTreeNode[] {
|
||||
if (!query) return nodes;
|
||||
return nodes.flatMap((node) => {
|
||||
if (node.name.toLocaleLowerCase().includes(query)) return [node];
|
||||
if (node.kind === 'file') return [];
|
||||
const children = filterNodes(node.children ?? [], query);
|
||||
return children.length ? [{ ...node, children }] : [];
|
||||
});
|
||||
}
|
||||
function countNodes(nodes: ProjectTreeNode[]): number {
|
||||
return nodes.reduce(
|
||||
(total, node) => total + 1 + (node.children ? countNodes(node.children) : 0),
|
||||
0,
|
||||
);
|
||||
}
|
||||
// eslint-disable-next-line react-refresh/only-export-components
|
||||
export function countProjectSearchResults(files:ProjectTreeFile[],query:string):number{return countNodes(filterNodes(buildProjectTree(files),query.trim().toLocaleLowerCase()));}
|
||||
interface FlatProjectNode{node:ProjectTreeNode;depth:number;}
|
||||
function flattenProjectNodes(nodes:ProjectTreeNode[],expanded:Set<string>,searching:boolean,depth=0):FlatProjectNode[]{return nodes.flatMap(node=>[{node,depth},...(node.kind==='directory'&&(searching||expanded.has(node.path))?flattenProjectNodes(node.children??[],expanded,searching,depth+1):[])]);}
|
||||
export function ProjectTree({files,entries,selectedEntry,query=''}:{files:ProjectTreeFile[];entries:ModelEntry[];selectedEntry?:string;query?:string}){
|
||||
const normalized=query.trim().toLocaleLowerCase(),nodes=filterNodes(buildProjectTree(files),normalized),[virtualExpanded,setVirtualExpanded]=useState(()=>new Set(buildProjectTree(files).filter(node=>node.kind==='directory').map(node=>node.path)));
|
||||
const entryFormats=new Map(entries.map(entry=>[entry.path,entry.format]));
|
||||
const expandedEntry=entries.some(entry=>entry.path===selectedEntry&&entry.format==='urdf')?selectedEntry:undefined;
|
||||
if(files.length>400&&nodes.length){const searching=Boolean(normalized),flat=flattenProjectNodes(nodes,virtualExpanded,searching),toggle=(item:FlatProjectNode)=>{if(item.node.kind!=='directory'||searching)return;setVirtualExpanded(current=>{const next=new Set(current);if(next.has(item.node.path))next.delete(item.node.path);else next.add(item.node.path);return next;});};return <nav aria-label="工程文件树"><VirtualTreeViewport label="虚拟化工程文件树" items={flat} getKey={item=>item.node.path} getLevel={item=>item.depth+1} isExpandable={item=>item.node.kind==='directory'&&(item.node.children?.length??0)>0} isExpanded={item=>searching||virtualExpanded.has(item.node.path)} onToggle={toggle} renderRow={({node,depth})=>{const directory=node.kind==='directory',opened=searching||virtualExpanded.has(node.path),EntryIcon=directory?(opened?FolderOpen:Folder):entryFormats.has(node.path)?FileCode2:node.path.endsWith('.obj')||node.path.endsWith('.stl')||node.path.endsWith('.dae')?Box:File;return <div title={node.path} onDoubleClick={()=>toggle({node,depth})} className={`flex h-full items-center gap-1.5 rounded px-1.5 text-xs ${selectedEntry===node.path?'bg-accent-soft text-accent':'text-text-secondary hover:bg-element-hover'}`} style={{paddingLeft:depth*12+6}}><EntryIcon className="h-3.5 w-3.5 shrink-0"/><span className="min-w-0 flex-1 truncate"><SearchHighlight text={node.name} query={query}/></span>{!directory&&<span className="text-[10px] text-text-tertiary">{formatSize(node.size??0)}</span>}</div>;}}/></nav>;}
|
||||
return <nav aria-label="工程文件树">{nodes.length?<TreeNodes key={expandedEntry??'collapsed'} nodes={nodes} entryFormats={entryFormats} selectedEntry={selectedEntry} expandedEntry={expandedEntry} searching={Boolean(normalized)} query={query}/>:<EmptySearchState label="没有匹配的文件"/>}</nav>;
|
||||
export function countProjectSearchResults(files: ProjectTreeFile[], query: string): number {
|
||||
return countNodes(filterNodes(buildProjectTree(files), query.trim().toLocaleLowerCase()));
|
||||
}
|
||||
interface FlatProjectNode {
|
||||
node: ProjectTreeNode;
|
||||
depth: number;
|
||||
}
|
||||
function flattenProjectNodes(
|
||||
nodes: ProjectTreeNode[],
|
||||
expanded: Set<string>,
|
||||
searching: boolean,
|
||||
depth = 0,
|
||||
): FlatProjectNode[] {
|
||||
return nodes.flatMap((node) => [
|
||||
{ node, depth },
|
||||
...(node.kind === 'directory' && (searching || expanded.has(node.path))
|
||||
? flattenProjectNodes(node.children ?? [], expanded, searching, depth + 1)
|
||||
: []),
|
||||
]);
|
||||
}
|
||||
export function ProjectTree({
|
||||
files,
|
||||
entries,
|
||||
selectedEntry,
|
||||
query = '',
|
||||
}: {
|
||||
files: ProjectTreeFile[];
|
||||
entries: ModelEntry[];
|
||||
selectedEntry?: string;
|
||||
query?: string;
|
||||
}) {
|
||||
const normalized = query.trim().toLocaleLowerCase(),
|
||||
nodes = filterNodes(buildProjectTree(files), normalized),
|
||||
[virtualExpanded, setVirtualExpanded] = useState(
|
||||
() =>
|
||||
new Set(
|
||||
buildProjectTree(files)
|
||||
.filter((node) => node.kind === 'directory')
|
||||
.map((node) => node.path),
|
||||
),
|
||||
);
|
||||
const entryFormats = new Map(entries.map((entry) => [entry.path, entry.format]));
|
||||
const expandedEntry = entries.some(
|
||||
(entry) => entry.path === selectedEntry && entry.format === 'urdf',
|
||||
)
|
||||
? selectedEntry
|
||||
: undefined;
|
||||
if (files.length > 400 && nodes.length) {
|
||||
const searching = Boolean(normalized),
|
||||
flat = flattenProjectNodes(nodes, virtualExpanded, searching),
|
||||
toggle = (item: FlatProjectNode) => {
|
||||
if (item.node.kind !== 'directory' || searching) return;
|
||||
setVirtualExpanded((current) => {
|
||||
const next = new Set(current);
|
||||
if (next.has(item.node.path)) next.delete(item.node.path);
|
||||
else next.add(item.node.path);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
return (
|
||||
<nav aria-label="工程文件树">
|
||||
<VirtualTreeViewport
|
||||
label="虚拟化工程文件树"
|
||||
items={flat}
|
||||
getKey={(item) => item.node.path}
|
||||
getLevel={(item) => item.depth + 1}
|
||||
isExpandable={(item) =>
|
||||
item.node.kind === 'directory' && (item.node.children?.length ?? 0) > 0
|
||||
}
|
||||
isExpanded={(item) => searching || virtualExpanded.has(item.node.path)}
|
||||
onToggle={toggle}
|
||||
renderRow={({ node, depth }) => {
|
||||
const directory = node.kind === 'directory',
|
||||
opened = searching || virtualExpanded.has(node.path),
|
||||
EntryIcon = directory
|
||||
? opened
|
||||
? FolderOpen
|
||||
: Folder
|
||||
: entryFormats.has(node.path)
|
||||
? FileCode2
|
||||
: node.path.endsWith('.obj') ||
|
||||
node.path.endsWith('.stl') ||
|
||||
node.path.endsWith('.dae')
|
||||
? Box
|
||||
: File;
|
||||
return (
|
||||
<div
|
||||
title={node.path}
|
||||
onDoubleClick={() => toggle({ node, depth })}
|
||||
className={`flex h-full items-center gap-1.5 rounded px-1.5 text-xs ${selectedEntry === node.path ? 'bg-accent-soft text-accent' : 'text-text-secondary hover:bg-element-hover'}`}
|
||||
style={{ paddingLeft: depth * 12 + 6 }}
|
||||
>
|
||||
<EntryIcon className="h-3.5 w-3.5 shrink-0" />
|
||||
<span className="min-w-0 flex-1 truncate">
|
||||
<SearchHighlight text={node.name} query={query} />
|
||||
</span>
|
||||
{!directory && (
|
||||
<span className="text-[10px] text-text-tertiary">
|
||||
{formatSize(node.size ?? 0)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<nav aria-label="工程文件树">
|
||||
{nodes.length ? (
|
||||
<TreeNodes
|
||||
key={expandedEntry ?? 'collapsed'}
|
||||
nodes={nodes}
|
||||
entryFormats={entryFormats}
|
||||
selectedEntry={selectedEntry}
|
||||
expandedEntry={expandedEntry}
|
||||
searching={Boolean(normalized)}
|
||||
query={query}
|
||||
/>
|
||||
) : (
|
||||
<EmptySearchState label="没有匹配的文件" />
|
||||
)}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,13 +1,77 @@
|
||||
import {editableSourcePaths,exportedFileName,mergeCachedFiles,readCachedText,updateCachedText,upsertCachedMjcf} from './cachedFiles';
|
||||
import type {ProjectManifest} from './types';
|
||||
import {
|
||||
editableSourcePaths,
|
||||
exportedFileName,
|
||||
mergeCachedFiles,
|
||||
readCachedText,
|
||||
updateCachedText,
|
||||
upsertCachedMjcf,
|
||||
} from './cachedFiles';
|
||||
import type { ProjectManifest } from './types';
|
||||
|
||||
const encoder=new TextEncoder();
|
||||
function fixture():ProjectManifest{const xml=encoder.encode('<mujoco/>'),png=new Uint8Array([1,2]);return {id:'p',name:'测试 工程.zip',files:[{path:'model.xml',data:xml,size:xml.byteLength,source:'zip',mimeType:'text/xml'},{path:'texture.png',data:png,size:png.byteLength,source:'zip',mimeType:'image/png'}],entries:[{path:'model.xml',format:'mjcf',label:'model'}],selectedEntry:'model.xml',totalBytes:xml.byteLength+png.byteLength};}
|
||||
const encoder = new TextEncoder();
|
||||
function fixture(): ProjectManifest {
|
||||
const xml = encoder.encode('<mujoco/>'),
|
||||
png = new Uint8Array([1, 2]);
|
||||
return {
|
||||
id: 'p',
|
||||
name: '测试 工程.zip',
|
||||
files: [
|
||||
{ path: 'model.xml', data: xml, size: xml.byteLength, source: 'zip', mimeType: 'text/xml' },
|
||||
{
|
||||
path: 'texture.png',
|
||||
data: png,
|
||||
size: png.byteLength,
|
||||
source: 'zip',
|
||||
mimeType: 'image/png',
|
||||
},
|
||||
],
|
||||
entries: [{ path: 'model.xml', format: 'mjcf', label: 'model' }],
|
||||
selectedEntry: 'model.xml',
|
||||
totalBytes: xml.byteLength + png.byteLength,
|
||||
};
|
||||
}
|
||||
|
||||
describe('cached source files',()=>{
|
||||
it('只列出可编辑文本并读取缓存',()=>{const manifest=fixture();expect(editableSourcePaths(manifest)).toEqual(['model.xml']);expect(readCachedText(manifest,'model.xml')).toBe('<mujoco/>');expect(()=>readCachedText(manifest,'texture.png')).toThrow('二进制');});
|
||||
it('以不可变方式更新会话缓存和大小',()=>{const original=fixture(),updated=updateCachedText(original,'model.xml','<mujoco model="edited"/>');expect(readCachedText(updated,'model.xml')).toContain('edited');expect(readCachedText(original,'model.xml')).toBe('<mujoco/>');expect(updated.totalBytes).toBe(updated.files.reduce((sum,file)=>sum+file.size,0));});
|
||||
it('合并转换生成的支持资源',()=>{const original=fixture(),obj={path:'mesh.mujoco.obj',data:encoder.encode('v 0 0 0'),size:7,source:'file' as const,mimeType:'text/plain'},updated=mergeCachedFiles(original,[obj]);expect(updated.files.map(file=>file.path)).toContain('mesh.mujoco.obj');expect(original.files.map(file=>file.path)).not.toContain('mesh.mujoco.obj');});
|
||||
it('创建可重新载入的 MJCF 缓存文件和入口',()=>{const updated=upsertCachedMjcf(fixture(),'.__converted_mjcf_cache__.xml','<mujoco model="cached"/>');expect(readCachedText(updated,'.__converted_mjcf_cache__.xml')).toContain('cached');expect(updated.entries.at(-1)).toMatchObject({path:'.__converted_mjcf_cache__.xml',format:'mjcf'});});
|
||||
it('生成安全的导出文件名',()=>{expect(exportedFileName('测试 工程.zip','urdf')).toBe('测试_工程.urdf');expect(exportedFileName('robot.xml','xml')).toBe('robot.xml');});
|
||||
describe('cached source files', () => {
|
||||
it('只列出可编辑文本并读取缓存', () => {
|
||||
const manifest = fixture();
|
||||
expect(editableSourcePaths(manifest)).toEqual(['model.xml']);
|
||||
expect(readCachedText(manifest, 'model.xml')).toBe('<mujoco/>');
|
||||
expect(() => readCachedText(manifest, 'texture.png')).toThrow('二进制');
|
||||
});
|
||||
it('以不可变方式更新会话缓存和大小', () => {
|
||||
const original = fixture(),
|
||||
updated = updateCachedText(original, 'model.xml', '<mujoco model="edited"/>');
|
||||
expect(readCachedText(updated, 'model.xml')).toContain('edited');
|
||||
expect(readCachedText(original, 'model.xml')).toBe('<mujoco/>');
|
||||
expect(updated.totalBytes).toBe(updated.files.reduce((sum, file) => sum + file.size, 0));
|
||||
});
|
||||
it('合并转换生成的支持资源', () => {
|
||||
const original = fixture(),
|
||||
obj = {
|
||||
path: 'mesh.mujoco.obj',
|
||||
data: encoder.encode('v 0 0 0'),
|
||||
size: 7,
|
||||
source: 'file' as const,
|
||||
mimeType: 'text/plain',
|
||||
},
|
||||
updated = mergeCachedFiles(original, [obj]);
|
||||
expect(updated.files.map((file) => file.path)).toContain('mesh.mujoco.obj');
|
||||
expect(original.files.map((file) => file.path)).not.toContain('mesh.mujoco.obj');
|
||||
});
|
||||
it('创建可重新载入的 MJCF 缓存文件和入口', () => {
|
||||
const updated = upsertCachedMjcf(
|
||||
fixture(),
|
||||
'.__converted_mjcf_cache__.xml',
|
||||
'<mujoco model="cached"/>',
|
||||
);
|
||||
expect(readCachedText(updated, '.__converted_mjcf_cache__.xml')).toContain('cached');
|
||||
expect(updated.entries.at(-1)).toMatchObject({
|
||||
path: '.__converted_mjcf_cache__.xml',
|
||||
format: 'mjcf',
|
||||
});
|
||||
});
|
||||
it('生成安全的导出文件名', () => {
|
||||
expect(exportedFileName('测试 工程.zip', 'urdf')).toBe('测试_工程.urdf');
|
||||
expect(exportedFileName('robot.xml', 'xml')).toBe('robot.xml');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,57 +1,105 @@
|
||||
import type {ProjectManifest} from './types';
|
||||
import type { ProjectManifest } from './types';
|
||||
|
||||
const TEXT_EXTENSIONS=/\.(?:xml|urdf|txt|obj|mtl|csv|json|yaml|yml)$/i;
|
||||
const decoder=new TextDecoder('utf-8',{fatal:false});
|
||||
const encoder=new TextEncoder();
|
||||
const TEXT_EXTENSIONS = /\.(?:xml|urdf|txt|obj|mtl|csv|json|yaml|yml)$/i;
|
||||
const decoder = new TextDecoder('utf-8', { fatal: false });
|
||||
const encoder = new TextEncoder();
|
||||
|
||||
export function isEditableSource(path:string):boolean{return TEXT_EXTENSIONS.test(path);}
|
||||
|
||||
export function editableSourcePaths(manifest:ProjectManifest):string[]{
|
||||
return manifest.files.filter(file=>isEditableSource(file.path)).map(file=>file.path).sort((a,b)=>a.localeCompare(b));
|
||||
export function isEditableSource(path: string): boolean {
|
||||
return TEXT_EXTENSIONS.test(path);
|
||||
}
|
||||
|
||||
export function readCachedText(manifest:ProjectManifest,path:string):string{
|
||||
const file=manifest.files.find(candidate=>candidate.path===path);
|
||||
if(!file)throw new Error(`缓存中找不到文件:${path}`);
|
||||
if(!isEditableSource(path))throw new Error(`不支持编辑二进制文件:${path}`);
|
||||
export function editableSourcePaths(manifest: ProjectManifest): string[] {
|
||||
return manifest.files
|
||||
.filter((file) => isEditableSource(file.path))
|
||||
.map((file) => file.path)
|
||||
.sort((a, b) => a.localeCompare(b));
|
||||
}
|
||||
|
||||
export function readCachedText(manifest: ProjectManifest, path: string): string {
|
||||
const file = manifest.files.find((candidate) => candidate.path === path);
|
||||
if (!file) throw new Error(`缓存中找不到文件:${path}`);
|
||||
if (!isEditableSource(path)) throw new Error(`不支持编辑二进制文件:${path}`);
|
||||
return decoder.decode(file.data);
|
||||
}
|
||||
|
||||
/** 返回只更新浏览器会话内存的新工程清单,不接触用户本地文件系统。 */
|
||||
export function mergeCachedFiles(manifest:ProjectManifest,additional:ProjectManifest['files']):ProjectManifest{
|
||||
if(!additional.length)return manifest;
|
||||
const byPath=new Map(manifest.files.map(file=>[file.path,file]));
|
||||
for(const file of additional)byPath.set(file.path,file);
|
||||
const files=Array.from(byPath.values());
|
||||
return {...manifest,files,totalBytes:files.reduce((total,item)=>total+item.size,0)};
|
||||
export function mergeCachedFiles(
|
||||
manifest: ProjectManifest,
|
||||
additional: ProjectManifest['files'],
|
||||
): ProjectManifest {
|
||||
if (!additional.length) return manifest;
|
||||
const byPath = new Map(manifest.files.map((file) => [file.path, file]));
|
||||
for (const file of additional) byPath.set(file.path, file);
|
||||
const files = Array.from(byPath.values());
|
||||
return { ...manifest, files, totalBytes: files.reduce((total, item) => total + item.size, 0) };
|
||||
}
|
||||
|
||||
export function upsertCachedMjcf(manifest:ProjectManifest,path:string,text:string):ProjectManifest{
|
||||
const data=encoder.encode(text),index=manifest.files.findIndex(candidate=>candidate.path===path);
|
||||
const files=manifest.files.slice();
|
||||
const file={path,data,size:data.byteLength,source:'file' as const,mimeType:'application/xml'};
|
||||
if(index<0)files.push(file);else files[index]={...files[index],...file};
|
||||
const entries=manifest.entries.some(entry=>entry.path===path)?manifest.entries:[...manifest.entries,{path,format:'mjcf' as const,label:`${path} (MJCF 缓存)`}];
|
||||
return {...manifest,files,entries,totalBytes:files.reduce((total,item)=>total+item.size,0)};
|
||||
export function upsertCachedMjcf(
|
||||
manifest: ProjectManifest,
|
||||
path: string,
|
||||
text: string,
|
||||
): ProjectManifest {
|
||||
const data = encoder.encode(text),
|
||||
index = manifest.files.findIndex((candidate) => candidate.path === path);
|
||||
const files = manifest.files.slice();
|
||||
const file = {
|
||||
path,
|
||||
data,
|
||||
size: data.byteLength,
|
||||
source: 'file' as const,
|
||||
mimeType: 'application/xml',
|
||||
};
|
||||
if (index < 0) files.push(file);
|
||||
else files[index] = { ...files[index], ...file };
|
||||
const entries = manifest.entries.some((entry) => entry.path === path)
|
||||
? manifest.entries
|
||||
: [...manifest.entries, { path, format: 'mjcf' as const, label: `${path} (MJCF 缓存)` }];
|
||||
return {
|
||||
...manifest,
|
||||
files,
|
||||
entries,
|
||||
totalBytes: files.reduce((total, item) => total + item.size, 0),
|
||||
};
|
||||
}
|
||||
|
||||
export function updateCachedText(manifest:ProjectManifest,path:string,text:string):ProjectManifest{
|
||||
const index=manifest.files.findIndex(candidate=>candidate.path===path);
|
||||
if(index<0)throw new Error(`缓存中找不到文件:${path}`);
|
||||
if(!isEditableSource(path))throw new Error(`不支持编辑二进制文件:${path}`);
|
||||
const data=encoder.encode(text),files=manifest.files.slice();
|
||||
files[index]={...files[index],data,size:data.byteLength,mimeType:files[index].mimeType||'text/plain'};
|
||||
return {...manifest,files,totalBytes:files.reduce((total,file)=>total+file.size,0)};
|
||||
export function updateCachedText(
|
||||
manifest: ProjectManifest,
|
||||
path: string,
|
||||
text: string,
|
||||
): ProjectManifest {
|
||||
const index = manifest.files.findIndex((candidate) => candidate.path === path);
|
||||
if (index < 0) throw new Error(`缓存中找不到文件:${path}`);
|
||||
if (!isEditableSource(path)) throw new Error(`不支持编辑二进制文件:${path}`);
|
||||
const data = encoder.encode(text),
|
||||
files = manifest.files.slice();
|
||||
files[index] = {
|
||||
...files[index],
|
||||
data,
|
||||
size: data.byteLength,
|
||||
mimeType: files[index].mimeType || 'text/plain',
|
||||
};
|
||||
return { ...manifest, files, totalBytes: files.reduce((total, file) => total + file.size, 0) };
|
||||
}
|
||||
|
||||
export function downloadBytes(data:Uint8Array,fileName:string,mimeType='application/xml'):void{
|
||||
const blob=new Blob([data as BlobPart],{type:`${mimeType};charset=utf-8`});
|
||||
const url=URL.createObjectURL(blob),anchor=document.createElement('a');
|
||||
anchor.href=url;anchor.download=fileName;anchor.style.display='none';document.body.append(anchor);anchor.click();anchor.remove();
|
||||
setTimeout(()=>URL.revokeObjectURL(url),0);
|
||||
export function downloadBytes(
|
||||
data: Uint8Array,
|
||||
fileName: string,
|
||||
mimeType = 'application/xml',
|
||||
): void {
|
||||
const blob = new Blob([data as BlobPart], { type: `${mimeType};charset=utf-8` });
|
||||
const url = URL.createObjectURL(blob),
|
||||
anchor = document.createElement('a');
|
||||
anchor.href = url;
|
||||
anchor.download = fileName;
|
||||
anchor.style.display = 'none';
|
||||
document.body.append(anchor);
|
||||
anchor.click();
|
||||
anchor.remove();
|
||||
setTimeout(() => URL.revokeObjectURL(url), 0);
|
||||
}
|
||||
|
||||
export function exportedFileName(projectName:string,extension:'urdf'|'xml'):string{
|
||||
const stem=projectName.replace(/\.(?:zip|xml|urdf)$/i,'').replace(/[^\p{L}\p{N}._-]+/gu,'_')||'model';
|
||||
export function exportedFileName(projectName: string, extension: 'urdf' | 'xml'): string {
|
||||
const stem =
|
||||
projectName.replace(/\.(?:zip|xml|urdf)$/i, '').replace(/[^\p{L}\p{N}._-]+/gu, '_') || 'model';
|
||||
return `${stem}.${extension}`;
|
||||
}
|
||||
|
||||
@@ -1,52 +1,56 @@
|
||||
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';
|
||||
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=';
|
||||
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();
|
||||
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';
|
||||
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;
|
||||
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='';
|
||||
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 文件不包含可转换的三角网格');
|
||||
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 文件未生成有效三角面');
|
||||
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;
|
||||
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();
|
||||
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();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,21 +1,157 @@
|
||||
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">
|
||||
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('单文件超过限制');});
|
||||
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('单文件超过限制');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,24 +1,43 @@
|
||||
import {unzipSync} from 'fflate';
|
||||
import {DEFAULT_IMPORT_LIMITS, type ImportLimits, type ModelEntry, type ProjectFile, type ProjectManifest} from './types';
|
||||
import {convertDaeToObj} from './daeConverter';
|
||||
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});
|
||||
const decoder = new TextDecoder('utf-8', { fatal: false });
|
||||
|
||||
export class ProjectImportError extends Error {
|
||||
constructor(message: string, readonly path?: string) { super(message); this.name = 'ProjectImportError'; }
|
||||
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);
|
||||
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);
|
||||
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)) {
|
||||
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');
|
||||
}
|
||||
@@ -26,24 +45,35 @@ function checkEncryptedZip(data: Uint8Array): void {
|
||||
}
|
||||
|
||||
function enforceLimits(files: ProjectFile[], limits: ImportLimits): void {
|
||||
if (files.length > limits.maxFiles) throw new ProjectImportError(`文件数量超过限制(${limits.maxFiles})`);
|
||||
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);
|
||||
if (file.size > limits.maxFileBytes)
|
||||
throw new ProjectImportError(`单文件超过限制(${limits.maxFileBytes} 字节)`, file.path);
|
||||
total += file.size;
|
||||
if (total > limits.maxTotalBytes) throw new ProjectImportError(`工程总大小超过限制(${limits.maxTotalBytes} 字节)`);
|
||||
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()})`}] : [];
|
||||
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()})` }]
|
||||
: [];
|
||||
});
|
||||
}
|
||||
|
||||
@@ -55,55 +85,80 @@ export interface PreparedProject {
|
||||
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(); }
|
||||
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);
|
||||
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`;
|
||||
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 {
|
||||
export function prepareProjectForMujoco(
|
||||
manifest: ProjectManifest,
|
||||
entryPath: string,
|
||||
): PreparedProject {
|
||||
const entry = manifest.entries.find((candidate) => candidate.path === entryPath);
|
||||
if (entry?.format !== 'urdf') return {manifest, warnings: []};
|
||||
if (entry?.format !== 'urdf') return { manifest, warnings: [] };
|
||||
const source = manifest.files.find((file) => file.path === entryPath);
|
||||
if (!source) return {manifest, warnings: []};
|
||||
if (!source) return { manifest, warnings: [] };
|
||||
const document = new DOMParser().parseFromString(decoder.decode(source.data), 'application/xml');
|
||||
if (document.querySelector('parsererror')) return {manifest, warnings: []};
|
||||
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');
|
||||
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; }
|
||||
for (const duplicate of materials.slice(1)) {
|
||||
duplicate.remove();
|
||||
removedMaterials += 1;
|
||||
}
|
||||
}
|
||||
if (removedMaterials) warnings.push(`为兼容 MuJoCo,已移除 visual 中 ${removedMaterials} 个重复 material(保留第一个)`);
|
||||
if (removedMaterials)
|
||||
warnings.push(
|
||||
`为兼容 MuJoCo,已移除 visual 中 ${removedMaterials} 个重复 material(保留第一个)`,
|
||||
);
|
||||
|
||||
const paths = manifest.files.map((file) => file.path);
|
||||
let rewrittenUris = 0;
|
||||
@@ -112,58 +167,97 @@ export function prepareProjectForMujoco(manifest: ProjectManifest, entryPath: st
|
||||
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; }
|
||||
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 (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);
|
||||
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;
|
||||
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 的占位球体;碰撞精度会降低`);
|
||||
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};
|
||||
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));
|
||||
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;
|
||||
@@ -171,53 +265,133 @@ export function choosePreferredEntry(entries: ModelEntry[]): string | 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)};
|
||||
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> {
|
||||
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);
|
||||
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;
|
||||
}});
|
||||
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)}`);
|
||||
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 = 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);
|
||||
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};}
|
||||
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();
|
||||
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);
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -1,73 +1,165 @@
|
||||
import {enhanceConvertedMjcf,groundConvertedMjcf} from './urdfToMjcf';
|
||||
import { enhanceConvertedMjcf, groundConvertedMjcf } from './urdfToMjcf';
|
||||
|
||||
const encode=(value:string)=>new TextEncoder().encode(value);
|
||||
const decode=(value:Uint8Array)=>new TextDecoder().decode(value);
|
||||
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');
|
||||
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('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__');
|
||||
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');
|
||||
});
|
||||
});
|
||||
|
||||
describe('enhanceConvertedMjcf',()=>{
|
||||
it('为可驱动关节补充 motor,并跳过已有驱动器和 ball joint',()=>{
|
||||
const source='<mujoco><worldbody><body name="robot"><joint name="shoulder" type="hinge"/><body><joint name="slider" type="slide"/><joint name="ball" type="ball"/><joint name="parent_driven"/></body></body></worldbody><actuator><position name="existing" joint="shoulder"/><motor name="parent_motor" jointinparent="parent_driven"/></actuator></mujoco>';
|
||||
const result=enhanceConvertedMjcf(encode(source),{addActuators:true,addSensors:false,sensorType:'camera'});
|
||||
const document=new DOMParser().parseFromString(decode(result.data),'application/xml');
|
||||
describe('enhanceConvertedMjcf', () => {
|
||||
it('为可驱动关节补充 motor,并跳过已有驱动器和 ball joint', () => {
|
||||
const source =
|
||||
'<mujoco><worldbody><body name="robot"><joint name="shoulder" type="hinge"/><body><joint name="slider" type="slide"/><joint name="ball" type="ball"/><joint name="parent_driven"/></body></body></worldbody><actuator><position name="existing" joint="shoulder"/><motor name="parent_motor" jointinparent="parent_driven"/></actuator></mujoco>';
|
||||
const result = enhanceConvertedMjcf(encode(source), {
|
||||
addActuators: true,
|
||||
addSensors: false,
|
||||
sensorType: 'camera',
|
||||
});
|
||||
const document = new DOMParser().parseFromString(decode(result.data), 'application/xml');
|
||||
expect(result.actuatorCount).toBe(1);
|
||||
expect(document.querySelector('motor[joint="slider"]')?.getAttribute('ctrllimited')).toBe('false');
|
||||
expect(document.querySelector('motor[joint="slider"]')?.getAttribute('ctrllimited')).toBe(
|
||||
'false',
|
||||
);
|
||||
expect(document.querySelector('motor[joint="slider"]')?.hasAttribute('ctrlrange')).toBe(false);
|
||||
expect(document.querySelector('motor[joint="slider"]')?.getAttribute('name')).toBe('slider_motor');
|
||||
expect(document.querySelector('motor[joint="slider"]')?.getAttribute('forcerange')).toBe('-100 100');
|
||||
expect(document.querySelector('motor[joint="slider"]')?.getAttribute('name')).toBe(
|
||||
'slider_motor',
|
||||
);
|
||||
expect(document.querySelector('motor[joint="slider"]')?.getAttribute('forcerange')).toBe(
|
||||
'-100 100',
|
||||
);
|
||||
expect(document.querySelector('joint[name="slider"]')?.getAttribute('stiffness')).toBe('0');
|
||||
expect(document.querySelector('joint[name="slider"]')?.getAttribute('damping')).toBe('0');
|
||||
expect(document.querySelectorAll('[joint="shoulder"]')).toHaveLength(1);
|
||||
expect(document.querySelectorAll('[jointinparent="parent_driven"], [joint="parent_driven"]')).toHaveLength(1);
|
||||
expect(
|
||||
document.querySelectorAll('[jointinparent="parent_driven"], [joint="parent_driven"]'),
|
||||
).toHaveLength(1);
|
||||
expect(document.querySelector('[joint="ball"]')).toBeNull();
|
||||
});
|
||||
|
||||
it('识别 Go2-W 并补齐官方 MuJoCo 稳定性参数',()=>{
|
||||
const prefixes=['FL','FR','RL','RR'],parts=['hip','thigh','calf'];
|
||||
const joints=[...prefixes.flatMap(prefix=>parts.map(part=>`<joint name="${prefix}_${part}_joint"/>`)),...prefixes.map(prefix=>`<body name="${prefix}_foot"><joint name="${prefix}_foot_joint"/><geom group="0"/></body>`)].join('');
|
||||
const result=enhanceConvertedMjcf(encode(`<mujoco><worldbody><body name="base">${joints}</body></worldbody></mujoco>`),{addActuators:true,addSensors:false,sensorType:'camera'}),document=new DOMParser().parseFromString(decode(result.data),'application/xml');
|
||||
expect(result.unitreeGo2wTuned).toBe(true);expect(document.querySelector('option')?.getAttribute('cone')).toBe('elliptic');expect(document.querySelector('joint[name="FL_thigh_joint"]')?.getAttribute('armature')).toBe('0.01');expect(document.querySelector('joint[name="FL_thigh_joint"]')?.getAttribute('damping')).toBe('0.1');expect(document.querySelector('motor[joint="FL_calf_joint"]')?.getAttribute('forcerange')).toBe('-45.43 45.43');expect(document.querySelector('motor[joint="FL_foot_joint"]')?.getAttribute('forcerange')).toBe('-15 15');expect(document.querySelector('body[name="FL_foot"] geom')?.getAttribute('condim')).toBe('6');
|
||||
it('识别 Go2-W 并补齐官方 MuJoCo 稳定性参数', () => {
|
||||
const prefixes = ['FL', 'FR', 'RL', 'RR'],
|
||||
parts = ['hip', 'thigh', 'calf'];
|
||||
const joints = [
|
||||
...prefixes.flatMap((prefix) =>
|
||||
parts.map((part) => `<joint name="${prefix}_${part}_joint"/>`),
|
||||
),
|
||||
...prefixes.map(
|
||||
(prefix) =>
|
||||
`<body name="${prefix}_foot"><joint name="${prefix}_foot_joint"/><geom group="0"/></body>`,
|
||||
),
|
||||
].join('');
|
||||
const result = enhanceConvertedMjcf(
|
||||
encode(`<mujoco><worldbody><body name="base">${joints}</body></worldbody></mujoco>`),
|
||||
{ addActuators: true, addSensors: false, sensorType: 'camera' },
|
||||
),
|
||||
document = new DOMParser().parseFromString(decode(result.data), 'application/xml');
|
||||
expect(result.unitreeGo2wTuned).toBe(true);
|
||||
expect(document.querySelector('option')?.getAttribute('cone')).toBe('elliptic');
|
||||
expect(document.querySelector('joint[name="FL_thigh_joint"]')?.getAttribute('armature')).toBe(
|
||||
'0.01',
|
||||
);
|
||||
expect(document.querySelector('joint[name="FL_thigh_joint"]')?.getAttribute('damping')).toBe(
|
||||
'0.1',
|
||||
);
|
||||
expect(document.querySelector('motor[joint="FL_calf_joint"]')?.getAttribute('forcerange')).toBe(
|
||||
'-45.43 45.43',
|
||||
);
|
||||
expect(document.querySelector('motor[joint="FL_foot_joint"]')?.getAttribute('forcerange')).toBe(
|
||||
'-15 15',
|
||||
);
|
||||
expect(document.querySelector('body[name="FL_foot"] geom')?.getAttribute('condim')).toBe('6');
|
||||
});
|
||||
|
||||
it('将可调摄像头固连到指定机器人 body',()=>{
|
||||
const result=enhanceConvertedMjcf(encode('<mujoco><worldbody><body name="base"><body name="head"/></body></worldbody></mujoco>'),{addActuators:false,addSensors:true,sensorType:'camera',cameraMountBody:'head',cameraPosition:[.2,0,.1],cameraDirection:'+X'});
|
||||
const document=new DOMParser().parseFromString(decode(result.data),'application/xml');
|
||||
const camera=document.querySelector('body[name="head"] > camera');
|
||||
it('将可调摄像头固连到指定机器人 body', () => {
|
||||
const result = enhanceConvertedMjcf(
|
||||
encode(
|
||||
'<mujoco><worldbody><body name="base"><body name="head"/></body></worldbody></mujoco>',
|
||||
),
|
||||
{
|
||||
addActuators: false,
|
||||
addSensors: true,
|
||||
sensorType: 'camera',
|
||||
cameraMountBody: 'head',
|
||||
cameraPosition: [0.2, 0, 0.1],
|
||||
cameraDirection: '+X',
|
||||
},
|
||||
);
|
||||
const document = new DOMParser().parseFromString(decode(result.data), 'application/xml');
|
||||
const camera = document.querySelector('body[name="head"] > camera');
|
||||
expect(result.cameraAdded).toBe(true);
|
||||
expect(result.imuAdded).toBe(true);
|
||||
expect(document.querySelector('body[name="base"] > site[name="imu"]')).toMatchObject({tagName:'site'});
|
||||
expect(document.querySelector('sensor > gyro[name="imu_gyro"]')?.getAttribute('site')).toBe('imu');
|
||||
expect(document.querySelector('sensor > accelerometer[name="imu_acc"]')?.getAttribute('site')).toBe('imu');
|
||||
expect(document.querySelector('body[name="base"] > site[name="imu"]')).toMatchObject({
|
||||
tagName: 'site',
|
||||
});
|
||||
expect(document.querySelector('sensor > gyro[name="imu_gyro"]')?.getAttribute('site')).toBe(
|
||||
'imu',
|
||||
);
|
||||
expect(
|
||||
document.querySelector('sensor > accelerometer[name="imu_acc"]')?.getAttribute('site'),
|
||||
).toBe('imu');
|
||||
expect(camera?.getAttribute('mode')).toBe('fixed');
|
||||
expect(camera?.getAttribute('pos')).toBe('0.2 0 0.1');
|
||||
expect(camera?.getAttribute('xyaxes')).toBe('0 -1 0 0 0 1');
|
||||
expect(camera?.getAttribute('resolution')).toBe('640 480');
|
||||
const repeated=enhanceConvertedMjcf(result.data,{addActuators:false,addSensors:true,sensorType:'camera'});
|
||||
const repeatedDocument=new DOMParser().parseFromString(decode(repeated.data),'application/xml');
|
||||
const repeated = enhanceConvertedMjcf(result.data, {
|
||||
addActuators: false,
|
||||
addSensors: true,
|
||||
sensorType: 'camera',
|
||||
});
|
||||
const repeatedDocument = new DOMParser().parseFromString(
|
||||
decode(repeated.data),
|
||||
'application/xml',
|
||||
);
|
||||
expect(repeatedDocument.querySelectorAll('camera')).toHaveLength(1);
|
||||
expect(repeatedDocument.querySelectorAll('sensor > gyro')).toHaveLength(1);
|
||||
expect(repeatedDocument.querySelectorAll('sensor > accelerometer')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('关闭选项时不修改 actuator 和 camera',()=>{
|
||||
const result=enhanceConvertedMjcf(encode('<mujoco><worldbody><body name="robot"><joint name="joint"/></body></worldbody></mujoco>'),{addActuators:false,addSensors:false,sensorType:'camera'});
|
||||
const document=new DOMParser().parseFromString(decode(result.data),'application/xml');
|
||||
it('关闭选项时不修改 actuator 和 camera', () => {
|
||||
const result = enhanceConvertedMjcf(
|
||||
encode(
|
||||
'<mujoco><worldbody><body name="robot"><joint name="joint"/></body></worldbody></mujoco>',
|
||||
),
|
||||
{ addActuators: false, addSensors: false, sensorType: 'camera' },
|
||||
);
|
||||
const document = new DOMParser().parseFromString(decode(result.data), 'application/xml');
|
||||
expect(document.querySelector('actuator')).toBeNull();
|
||||
expect(document.querySelector('camera')).toBeNull();
|
||||
});
|
||||
|
||||
@@ -1,101 +1,275 @@
|
||||
const decoder=new TextDecoder('utf-8');
|
||||
const encoder=new TextEncoder();
|
||||
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);
|
||||
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';
|
||||
export type CameraDirection='+X'|'-X'|'+Y'|'-Y'|'+Z'|'-Z';
|
||||
export interface UrdfEnhancementOptions {addActuators:boolean;addSensors:boolean;sensorType:'camera';cameraMountBody?:string;cameraPosition?:[number,number,number];cameraDirection?:CameraDirection;}
|
||||
export interface UrdfEnhancementResult {data:Uint8Array;actuatorCount:number;cameraAdded:boolean;imuAdded:boolean;unitreeGo2wTuned:boolean;}
|
||||
|
||||
function uniqueName(document:Document,selector:string,base:string):string {
|
||||
const occupied=new Set(Array.from(document.querySelectorAll(selector)).map(element=>element.getAttribute('name')).filter(Boolean));
|
||||
if(!occupied.has(base))return base;
|
||||
for(let index=2;;index+=1)if(!occupied.has(`${base}_${index}`))return `${base}_${index}`;
|
||||
export type UrdfBaseMode = 'floating' | 'fixed';
|
||||
export type CameraDirection = '+X' | '-X' | '+Y' | '-Y' | '+Z' | '-Z';
|
||||
export interface UrdfEnhancementOptions {
|
||||
addActuators: boolean;
|
||||
addSensors: boolean;
|
||||
sensorType: 'camera';
|
||||
cameraMountBody?: string;
|
||||
cameraPosition?: [number, number, number];
|
||||
cameraDirection?: CameraDirection;
|
||||
}
|
||||
export interface UrdfEnhancementResult {
|
||||
data: Uint8Array;
|
||||
actuatorCount: number;
|
||||
cameraAdded: boolean;
|
||||
imuAdded: boolean;
|
||||
unitreeGo2wTuned: boolean;
|
||||
}
|
||||
|
||||
function tuneUnitreeGo2w(document:Document):boolean {
|
||||
const prefixes=['FL','FR','RL','RR'],legParts=['hip','thigh','calf'];
|
||||
const expected=[...prefixes.flatMap(prefix=>legParts.map(part=>`${prefix}_${part}_joint`)),...prefixes.map(prefix=>`${prefix}_foot_joint`)];
|
||||
const joints=new Map(Array.from(document.querySelectorAll('worldbody joint[name]')).map(joint=>[joint.getAttribute('name')!,joint]));
|
||||
if(!expected.every(name=>joints.has(name)))return false;
|
||||
const root=document.querySelector('mujoco');if(!root)return false;
|
||||
let option=Array.from(root.children).find(element=>element.tagName==='option');if(!option){option=document.createElement('option');root.prepend(option);}
|
||||
option.setAttribute('cone','elliptic');option.setAttribute('impratio','100');
|
||||
for(const name of expected){const joint=joints.get(name)!;joint.setAttribute('armature','0.01');joint.setAttribute('damping','0.1');joint.setAttribute('frictionloss','0.2');}
|
||||
for(const motor of Array.from(document.querySelectorAll('actuator > *[joint]'))){const jointName=motor.getAttribute('joint')??'';if(!expected.includes(jointName))continue;const limit=jointName.includes('_calf_')?45.43:jointName.endsWith('_foot_joint')?15:23.7;motor.setAttribute('forcelimited','true');motor.setAttribute('forcerange',`${-limit} ${limit}`);}
|
||||
for(const geom of Array.from(document.querySelectorAll('worldbody body geom'))){if(geom.getAttribute('contype')==='0'||geom.getAttribute('group')==='1')continue;geom.setAttribute('friction','0.4 0.005 0.0001');geom.setAttribute('margin','0.001');geom.setAttribute('condim','1');const body=geom.closest('body'),name=body?.getAttribute('name')??'';if(/_(?:foot|wheel)(?:_link)?$/i.test(name)){geom.setAttribute('friction','0.8 0.02 0.01');geom.setAttribute('condim','6');geom.setAttribute('priority','1');}}
|
||||
function uniqueName(document: Document, selector: string, base: string): string {
|
||||
const occupied = new Set(
|
||||
Array.from(document.querySelectorAll(selector))
|
||||
.map((element) => element.getAttribute('name'))
|
||||
.filter(Boolean),
|
||||
);
|
||||
if (!occupied.has(base)) return base;
|
||||
for (let index = 2; ; index += 1)
|
||||
if (!occupied.has(`${base}_${index}`)) return `${base}_${index}`;
|
||||
}
|
||||
|
||||
function tuneUnitreeGo2w(document: Document): boolean {
|
||||
const prefixes = ['FL', 'FR', 'RL', 'RR'],
|
||||
legParts = ['hip', 'thigh', 'calf'];
|
||||
const expected = [
|
||||
...prefixes.flatMap((prefix) => legParts.map((part) => `${prefix}_${part}_joint`)),
|
||||
...prefixes.map((prefix) => `${prefix}_foot_joint`),
|
||||
];
|
||||
const joints = new Map(
|
||||
Array.from(document.querySelectorAll('worldbody joint[name]')).map((joint) => [
|
||||
joint.getAttribute('name')!,
|
||||
joint,
|
||||
]),
|
||||
);
|
||||
if (!expected.every((name) => joints.has(name))) return false;
|
||||
const root = document.querySelector('mujoco');
|
||||
if (!root) return false;
|
||||
let option = Array.from(root.children).find((element) => element.tagName === 'option');
|
||||
if (!option) {
|
||||
option = document.createElement('option');
|
||||
root.prepend(option);
|
||||
}
|
||||
option.setAttribute('cone', 'elliptic');
|
||||
option.setAttribute('impratio', '100');
|
||||
for (const name of expected) {
|
||||
const joint = joints.get(name)!;
|
||||
joint.setAttribute('armature', '0.01');
|
||||
joint.setAttribute('damping', '0.1');
|
||||
joint.setAttribute('frictionloss', '0.2');
|
||||
}
|
||||
for (const motor of Array.from(document.querySelectorAll('actuator > *[joint]'))) {
|
||||
const jointName = motor.getAttribute('joint') ?? '';
|
||||
if (!expected.includes(jointName)) continue;
|
||||
const limit = jointName.includes('_calf_')
|
||||
? 45.43
|
||||
: jointName.endsWith('_foot_joint')
|
||||
? 15
|
||||
: 23.7;
|
||||
motor.setAttribute('forcelimited', 'true');
|
||||
motor.setAttribute('forcerange', `${-limit} ${limit}`);
|
||||
}
|
||||
for (const geom of Array.from(document.querySelectorAll('worldbody body geom'))) {
|
||||
if (geom.getAttribute('contype') === '0' || geom.getAttribute('group') === '1') continue;
|
||||
geom.setAttribute('friction', '0.4 0.005 0.0001');
|
||||
geom.setAttribute('margin', '0.001');
|
||||
geom.setAttribute('condim', '1');
|
||||
const body = geom.closest('body'),
|
||||
name = body?.getAttribute('name') ?? '';
|
||||
if (/_(?:foot|wheel)(?:_link)?$/i.test(name)) {
|
||||
geom.setAttribute('friction', '0.8 0.02 0.01');
|
||||
geom.setAttribute('condim', '6');
|
||||
geom.setAttribute('priority', '1');
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/** 为转换后的 MJCF 按需补充可直接控制的关节电机和一台自动取景相机。 */
|
||||
export function enhanceConvertedMjcf(data:Uint8Array,options:UrdfEnhancementOptions):UrdfEnhancementResult {
|
||||
const document=new DOMParser().parseFromString(decoder.decode(data),'application/xml');
|
||||
if(document.querySelector('parsererror'))throw new Error('MuJoCo 导出的 MJCF XML 无法解析');
|
||||
const root=document.querySelector('mujoco'),worldbody=document.querySelector('mujoco > worldbody');
|
||||
if(!root||!worldbody)throw new Error('MuJoCo 导出的 MJCF 缺少 worldbody');
|
||||
let actuatorCount=0;
|
||||
if(options.addActuators){
|
||||
let actuator=Array.from(root.children).find(element=>element.tagName==='actuator');
|
||||
if(!actuator){actuator=document.createElement('actuator');root.append(actuator);}
|
||||
const driven=new Set(Array.from(actuator.children).flatMap(element=>[element.getAttribute('joint'),element.getAttribute('jointinparent')]).filter(Boolean));
|
||||
for(const joint of Array.from(worldbody.querySelectorAll('joint'))){
|
||||
const type=joint.getAttribute('type')??'hinge';
|
||||
if(type!=='hinge'&&type!=='slide')continue;
|
||||
let jointName=joint.getAttribute('name');
|
||||
if(!jointName){jointName=uniqueName(document,'joint[name]','__platform_joint__');joint.setAttribute('name',jointName);}
|
||||
if(driven.has(jointName))continue;
|
||||
const motor=document.createElement('motor');
|
||||
motor.setAttribute('name',uniqueName(document,'actuator > *[name]',`${jointName}_motor`));
|
||||
motor.setAttribute('joint',jointName);motor.setAttribute('ctrllimited','false');motor.setAttribute('forcelimited','true');motor.setAttribute('forcerange','-100 100');
|
||||
if(!joint.hasAttribute('stiffness'))joint.setAttribute('stiffness','0');if(!joint.hasAttribute('damping'))joint.setAttribute('damping','0');
|
||||
actuator.append(motor);driven.add(jointName);actuatorCount+=1;
|
||||
export function enhanceConvertedMjcf(
|
||||
data: Uint8Array,
|
||||
options: UrdfEnhancementOptions,
|
||||
): UrdfEnhancementResult {
|
||||
const document = new DOMParser().parseFromString(decoder.decode(data), 'application/xml');
|
||||
if (document.querySelector('parsererror')) throw new Error('MuJoCo 导出的 MJCF XML 无法解析');
|
||||
const root = document.querySelector('mujoco'),
|
||||
worldbody = document.querySelector('mujoco > worldbody');
|
||||
if (!root || !worldbody) throw new Error('MuJoCo 导出的 MJCF 缺少 worldbody');
|
||||
let actuatorCount = 0;
|
||||
if (options.addActuators) {
|
||||
let actuator = Array.from(root.children).find((element) => element.tagName === 'actuator');
|
||||
if (!actuator) {
|
||||
actuator = document.createElement('actuator');
|
||||
root.append(actuator);
|
||||
}
|
||||
if(!actuator.children.length)actuator.remove();
|
||||
const driven = new Set(
|
||||
Array.from(actuator.children)
|
||||
.flatMap((element) => [
|
||||
element.getAttribute('joint'),
|
||||
element.getAttribute('jointinparent'),
|
||||
])
|
||||
.filter(Boolean),
|
||||
);
|
||||
for (const joint of Array.from(worldbody.querySelectorAll('joint'))) {
|
||||
const type = joint.getAttribute('type') ?? 'hinge';
|
||||
if (type !== 'hinge' && type !== 'slide') continue;
|
||||
let jointName = joint.getAttribute('name');
|
||||
if (!jointName) {
|
||||
jointName = uniqueName(document, 'joint[name]', '__platform_joint__');
|
||||
joint.setAttribute('name', jointName);
|
||||
}
|
||||
if (driven.has(jointName)) continue;
|
||||
const motor = document.createElement('motor');
|
||||
motor.setAttribute('name', uniqueName(document, 'actuator > *[name]', `${jointName}_motor`));
|
||||
motor.setAttribute('joint', jointName);
|
||||
motor.setAttribute('ctrllimited', 'false');
|
||||
motor.setAttribute('forcelimited', 'true');
|
||||
motor.setAttribute('forcerange', '-100 100');
|
||||
if (!joint.hasAttribute('stiffness')) joint.setAttribute('stiffness', '0');
|
||||
if (!joint.hasAttribute('damping')) joint.setAttribute('damping', '0');
|
||||
actuator.append(motor);
|
||||
driven.add(jointName);
|
||||
actuatorCount += 1;
|
||||
}
|
||||
if (!actuator.children.length) actuator.remove();
|
||||
}
|
||||
const unitreeGo2wTuned=tuneUnitreeGo2w(document);
|
||||
let imuAdded=false;
|
||||
if(options.addSensors&&!document.querySelector('sensor > gyro[name="imu_gyro"], sensor > gyro[name="__platform_imu_gyro__"]')){
|
||||
const bodies=Array.from(worldbody.querySelectorAll('body')),mount=bodies.find(body=>/^(?:base|base_link|trunk)$/i.test(body.getAttribute('name')??''))??Array.from(worldbody.children).find(element=>element.tagName==='body');
|
||||
if(mount){
|
||||
const site=document.createElement('site'),siteName=uniqueName(document,'site[name]','imu');site.setAttribute('name',siteName);site.setAttribute('pos','0 0 0');site.setAttribute('size','0.005');site.setAttribute('rgba','0.2 0.8 1 0.5');mount.append(site);
|
||||
let sensor=Array.from(root.children).find(element=>element.tagName==='sensor');if(!sensor){sensor=document.createElement('sensor');root.append(sensor);}
|
||||
const gyro=document.createElement('gyro');gyro.setAttribute('name',uniqueName(document,'sensor > *[name]','imu_gyro'));gyro.setAttribute('site',siteName);gyro.setAttribute('noise','0');sensor.append(gyro);
|
||||
const accelerometer=document.createElement('accelerometer');accelerometer.setAttribute('name',uniqueName(document,'sensor > *[name]','imu_acc'));accelerometer.setAttribute('site',siteName);accelerometer.setAttribute('noise','0');sensor.append(accelerometer);imuAdded=true;
|
||||
const unitreeGo2wTuned = tuneUnitreeGo2w(document);
|
||||
let imuAdded = false;
|
||||
if (
|
||||
options.addSensors &&
|
||||
!document.querySelector(
|
||||
'sensor > gyro[name="imu_gyro"], sensor > gyro[name="__platform_imu_gyro__"]',
|
||||
)
|
||||
) {
|
||||
const bodies = Array.from(worldbody.querySelectorAll('body')),
|
||||
mount =
|
||||
bodies.find((body) =>
|
||||
/^(?:base|base_link|trunk)$/i.test(body.getAttribute('name') ?? ''),
|
||||
) ?? Array.from(worldbody.children).find((element) => element.tagName === 'body');
|
||||
if (mount) {
|
||||
const site = document.createElement('site'),
|
||||
siteName = uniqueName(document, 'site[name]', 'imu');
|
||||
site.setAttribute('name', siteName);
|
||||
site.setAttribute('pos', '0 0 0');
|
||||
site.setAttribute('size', '0.005');
|
||||
site.setAttribute('rgba', '0.2 0.8 1 0.5');
|
||||
mount.append(site);
|
||||
let sensor = Array.from(root.children).find((element) => element.tagName === 'sensor');
|
||||
if (!sensor) {
|
||||
sensor = document.createElement('sensor');
|
||||
root.append(sensor);
|
||||
}
|
||||
const gyro = document.createElement('gyro');
|
||||
gyro.setAttribute('name', uniqueName(document, 'sensor > *[name]', 'imu_gyro'));
|
||||
gyro.setAttribute('site', siteName);
|
||||
gyro.setAttribute('noise', '0');
|
||||
sensor.append(gyro);
|
||||
const accelerometer = document.createElement('accelerometer');
|
||||
accelerometer.setAttribute('name', uniqueName(document, 'sensor > *[name]', 'imu_acc'));
|
||||
accelerometer.setAttribute('site', siteName);
|
||||
accelerometer.setAttribute('noise', '0');
|
||||
sensor.append(accelerometer);
|
||||
imuAdded = true;
|
||||
}
|
||||
}
|
||||
let cameraAdded=false;
|
||||
if(options.addSensors&&options.sensorType==='camera'&&!document.querySelector('camera[name^="__platform_camera__"]')){
|
||||
const bodies=Array.from(worldbody.querySelectorAll('body')),preferred=options.cameraMountBody?bodies.find(body=>body.getAttribute('name')===options.cameraMountBody):undefined;
|
||||
const mount=preferred??bodies.find(body=>/(head|camera|sensor|neck|头)/i.test(body.getAttribute('name')??''))??bodies.at(-1);
|
||||
if(mount){
|
||||
const directions:Record<CameraDirection,string>={'+X':'0 -1 0 0 0 1','-X':'0 1 0 0 0 1','+Y':'1 0 0 0 0 1','-Y':'-1 0 0 0 0 1','+Z':'0 1 0 1 0 0','-Z':'0 -1 0 1 0 0'},position=options.cameraPosition??[.1,0,.05],direction=options.cameraDirection??'+X';
|
||||
const camera=document.createElement('camera');camera.setAttribute('name',uniqueName(document,'camera[name]','__platform_camera__'));camera.setAttribute('mode','fixed');
|
||||
camera.setAttribute('pos',position.map(value=>Number.isFinite(value)?value:0).join(' '));camera.setAttribute('xyaxes',directions[direction]);camera.setAttribute('fovy','45');camera.setAttribute('resolution','640 480');mount.append(camera);cameraAdded=true;
|
||||
let cameraAdded = false;
|
||||
if (
|
||||
options.addSensors &&
|
||||
options.sensorType === 'camera' &&
|
||||
!document.querySelector('camera[name^="__platform_camera__"]')
|
||||
) {
|
||||
const bodies = Array.from(worldbody.querySelectorAll('body')),
|
||||
preferred = options.cameraMountBody
|
||||
? bodies.find((body) => body.getAttribute('name') === options.cameraMountBody)
|
||||
: undefined;
|
||||
const mount =
|
||||
preferred ??
|
||||
bodies.find((body) =>
|
||||
/(head|camera|sensor|neck|头)/i.test(body.getAttribute('name') ?? ''),
|
||||
) ??
|
||||
bodies.at(-1);
|
||||
if (mount) {
|
||||
const directions: Record<CameraDirection, string> = {
|
||||
'+X': '0 -1 0 0 0 1',
|
||||
'-X': '0 1 0 0 0 1',
|
||||
'+Y': '1 0 0 0 0 1',
|
||||
'-Y': '-1 0 0 0 0 1',
|
||||
'+Z': '0 1 0 1 0 0',
|
||||
'-Z': '0 -1 0 1 0 0',
|
||||
},
|
||||
position = options.cameraPosition ?? [0.1, 0, 0.05],
|
||||
direction = options.cameraDirection ?? '+X';
|
||||
const camera = document.createElement('camera');
|
||||
camera.setAttribute('name', uniqueName(document, 'camera[name]', '__platform_camera__'));
|
||||
camera.setAttribute('mode', 'fixed');
|
||||
camera.setAttribute(
|
||||
'pos',
|
||||
position.map((value) => (Number.isFinite(value) ? value : 0)).join(' '),
|
||||
);
|
||||
camera.setAttribute('xyaxes', directions[direction]);
|
||||
camera.setAttribute('fovy', '45');
|
||||
camera.setAttribute('resolution', '640 480');
|
||||
mount.append(camera);
|
||||
cameraAdded = true;
|
||||
}
|
||||
}
|
||||
return {data:encoder.encode(new XMLSerializer().serializeToString(document)),actuatorCount,cameraAdded,imuAdded,unitreeGo2wTuned};
|
||||
return {
|
||||
data: encoder.encode(new XMLSerializer().serializeToString(document)),
|
||||
actuatorCount,
|
||||
cameraAdded,
|
||||
imuAdded,
|
||||
unitreeGo2wTuned,
|
||||
};
|
||||
}
|
||||
|
||||
/** 给 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);
|
||||
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');
|
||||
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));
|
||||
}
|
||||
|
||||
@@ -1,4 +1,34 @@
|
||||
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'));});
|
||||
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'),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,29 +1,33 @@
|
||||
import type {MainModule} from '@mujoco/mujoco';
|
||||
import type {ProjectManifest} from './types';
|
||||
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;
|
||||
readFile(path: string, options: { encoding: 'utf8' }): string;
|
||||
unlink(path: string): void;
|
||||
rmdir(path: string): void;
|
||||
}
|
||||
|
||||
type ModuleWithFS = MainModule & {FS: EmscriptenFS};
|
||||
type ModuleWithFS = MainModule & { FS: EmscriptenFS };
|
||||
|
||||
export class MemfsWorkspace {
|
||||
readonly root: string;
|
||||
private files: string[] = [];
|
||||
private directories: string[] = [];
|
||||
|
||||
constructor(private readonly module: MainModule, projectId: 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);
|
||||
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('/'));
|
||||
@@ -38,23 +42,42 @@ export class MemfsWorkspace {
|
||||
}
|
||||
}
|
||||
}
|
||||
fs.writeFile(absolute, file.data); this.files.push(absolute);
|
||||
fs.writeFile(absolute, file.data);
|
||||
this.files.push(absolute);
|
||||
}
|
||||
}
|
||||
|
||||
path(relative: string): string { return `${this.root}/${relative}`; }
|
||||
path(relative: string): string {
|
||||
return `${this.root}/${relative}`;
|
||||
}
|
||||
|
||||
readText(relative:string):string{return (this.module as ModuleWithFS).FS.readFile(this.path(relative),{encoding:'utf8'});}
|
||||
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);
|
||||
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 = [];
|
||||
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