feat(web-platform): release V0.1
This commit is contained in:
@@ -0,0 +1,20 @@
|
||||
import {fireEvent,render,screen} from '@testing-library/react';
|
||||
import {buildBodyTree,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]}];
|
||||
|
||||
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}/>);const item=screen.getByRole('treeitem',{name:/arm_joint/});
|
||||
fireEvent.mouseEnter(item);fireEvent.mouseLeave(item);expect(hover.mock.calls).toEqual([[0],[null]]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,22 @@
|
||||
import {useState} from 'react';
|
||||
import type {BodyInfo,JointInfo} from '../simulation/SimulationSession';
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
function BodyBranch({node,depth,onJointHover}:{node:BodyNode;depth:number;onJointHover:(jointId:number|null)=>void}){
|
||||
const hasChildren=node.joints.length>0||node.children.length>0;const [open,setOpen]=useState(depth<2);
|
||||
return <li>{hasChildren?<details open={open} onToggle={event=>setOpen(event.currentTarget.open)}><summary className="cursor-pointer select-none truncate rounded px-1.5 py-1 text-xs text-slate-300 hover:bg-slate-800"><span aria-hidden="true" className="mr-1 text-sky-400">◆</span>{node.name}</summary><ul role="group" className="ml-3 border-l border-slate-800 pl-1">{node.joints.map(joint=><li key={joint.id}><span role="treeitem" tabIndex={0} className="block cursor-default truncate rounded px-1.5 py-1 text-xs text-amber-300 hover:bg-amber-950/40 hover:text-amber-200 focus:bg-amber-950/40 focus:outline-none" onMouseEnter={()=>onJointHover(joint.id)} onMouseLeave={()=>onJointHover(null)} onFocus={()=>onJointHover(joint.id)} onBlur={()=>onJointHover(null)} title={`关节:${joint.name}`}><span aria-hidden="true" className="mr-1">○</span>{joint.name}</span></li>)}{node.children.map(child=><BodyBranch key={child.id} node={child} depth={depth+1} onJointHover={onJointHover}/>)}</ul></details>:<div className="truncate rounded px-1.5 py-1 text-xs text-slate-300"><span aria-hidden="true" className="mr-1 text-sky-400">◆</span>{node.name}</div>}</li>;
|
||||
}
|
||||
|
||||
export function ModelStructureTree({bodies,joints,onJointHover}:{bodies:BodyInfo[];joints:JointInfo[];onJointHover:(jointId:number|null)=>void}){
|
||||
const roots=buildBodyTree(bodies,joints);
|
||||
return <nav aria-label="模型结构树"><ul role="tree">{roots.map(root=><BodyBranch key={root.id} node={root} depth={0} onJointHover={onJointHover}/>)}</ul></nav>;
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import {render,screen,within} from '@testing-library/react';
|
||||
import {fireEvent,render,screen,within} from '@testing-library/react';
|
||||
import {buildProjectTree,ProjectTree} from './ProjectTree';
|
||||
|
||||
const files=[
|
||||
@@ -20,12 +20,13 @@ describe('ProjectTree',()=>{
|
||||
});
|
||||
|
||||
it('以可折叠目录显示文件名,而不是平铺完整路径',()=>{
|
||||
render(<ProjectTree files={files} entries={[{path:'robot/model.xml',format:'mjcf',label:'model'}]} selectedEntry="robot/model.xml"/>);
|
||||
const tree=screen.getByRole('navigation',{name:'工程文件树'});
|
||||
expect(within(tree).getByText('robot')).toBeVisible();
|
||||
expect(within(tree).getByText('meshes')).toBeVisible();
|
||||
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);
|
||||
expect(within(tree).getByText('arm.obj')).toBeVisible();
|
||||
expect(within(tree).queryByText('robot/meshes/arm.obj')).not.toBeInTheDocument();
|
||||
expect(within(tree).getByText('mjcf')).toBeVisible();
|
||||
expect(within(tree).getByText('urdf')).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import {useState} from 'react';
|
||||
import type {ModelEntry} from './types';
|
||||
|
||||
export interface ProjectTreeFile {path:string;size:number;}
|
||||
@@ -52,21 +53,15 @@ function formatSize(bytes:number):string {
|
||||
return `${(bytes/(1024*1024)).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
function TreeNodes({nodes,entryFormats,selectedEntry}:{nodes:ProjectTreeNode[];entryFormats:Map<string,ModelEntry['format']>;selectedEntry?:string}){
|
||||
return <ul role="group" className="ml-3 border-l border-slate-800 pl-1">
|
||||
{nodes.map(node=>node.kind==='directory'?<li key={`d:${node.path}`}>
|
||||
<details open>
|
||||
<summary title={node.path} className="cursor-pointer select-none truncate rounded px-1.5 py-1 text-xs text-slate-300 hover:bg-slate-800"><span aria-hidden="true" className="mr-1">📁</span>{node.name}</summary>
|
||||
<TreeNodes nodes={node.children??[]} entryFormats={entryFormats} selectedEntry={selectedEntry}/>
|
||||
</details>
|
||||
</li>:<li key={`f:${node.path}`} title={node.path} className={`flex min-w-0 items-center gap-1 rounded px-1.5 py-1 text-xs ${selectedEntry===node.path?'bg-green-900/40 text-green-200':'text-slate-300 hover:bg-slate-800'}`}>
|
||||
<span aria-hidden="true">{entryFormats.has(node.path)?'◇':'▧'}</span><span className="min-w-0 flex-1 truncate">{node.name}</span>{entryFormats.has(node.path)&&<span className="shrink-0 text-[10px] uppercase text-green-400">{entryFormats.get(node.path)}</span>}<span className="shrink-0 text-[10px] text-slate-500">{formatSize(node.size??0)}</span>
|
||||
</li>)}
|
||||
</ul>;
|
||||
interface TreeNodeProps {entryFormats:Map<string,ModelEntry['format']>;selectedEntry?:string;expandedEntry?:string;}
|
||||
function DirectoryNode({node,entryFormats,selectedEntry,expandedEntry}:TreeNodeProps&{node:ProjectTreeNode}){const [open,setOpen]=useState(Boolean(expandedEntry?.startsWith(`${node.path}/`)));return <li><details open={open} onToggle={event=>setOpen(event.currentTarget.open)}><summary title={node.path} className="cursor-pointer select-none truncate rounded px-1.5 py-1 text-xs text-slate-300 hover:bg-slate-800"><span aria-hidden="true" className="mr-1">📁</span>{node.name}</summary><TreeNodes nodes={node.children??[]} entryFormats={entryFormats} selectedEntry={selectedEntry} expandedEntry={expandedEntry}/></details></li>;}
|
||||
function TreeNodes({nodes,entryFormats,selectedEntry,expandedEntry}:TreeNodeProps&{nodes:ProjectTreeNode[]}){
|
||||
return <ul role="group" className="ml-3 border-l border-slate-800 pl-1">{nodes.map(node=>node.kind==='directory'?<DirectoryNode key={`d:${node.path}`} node={node} entryFormats={entryFormats} selectedEntry={selectedEntry} expandedEntry={expandedEntry}/>:<li key={`f:${node.path}`} title={node.path} className={`flex min-w-0 items-center gap-1 rounded px-1.5 py-1 text-xs ${selectedEntry===node.path?'bg-green-900/40 text-green-200':'text-slate-300 hover:bg-slate-800'}`}><span aria-hidden="true">{entryFormats.has(node.path)?'◇':'▧'}</span><span className="min-w-0 flex-1 truncate">{node.name}</span>{entryFormats.has(node.path)&&<span className="shrink-0 text-[10px] uppercase text-green-400">{entryFormats.get(node.path)}</span>}<span className="shrink-0 text-[10px] text-slate-500">{formatSize(node.size??0)}</span></li>)}</ul>;
|
||||
}
|
||||
|
||||
export function ProjectTree({files,entries,selectedEntry}:{files:ProjectTreeFile[];entries:ModelEntry[];selectedEntry?:string}){
|
||||
const nodes=buildProjectTree(files);
|
||||
const entryFormats=new Map(entries.map(entry=>[entry.path,entry.format]));
|
||||
return <nav aria-label="工程文件树"><TreeNodes nodes={nodes} entryFormats={entryFormats} selectedEntry={selectedEntry}/></nav>;
|
||||
const expandedEntry=entries.some(entry=>entry.path===selectedEntry&&entry.format==='urdf')?selectedEntry:undefined;
|
||||
return <nav aria-label="工程文件树"><TreeNodes key={expandedEntry??'collapsed'} nodes={nodes} entryFormats={entryFormats} selectedEntry={selectedEntry} expandedEntry={expandedEntry}/></nav>;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user