feat: add browser-based MuJoCo simulation platform
build / setup (compute matrix) (pull_request) Has been cancelled
build / ${{ matrix.label }} (pull_request) Has been cancelled
build / macos-15-arm64-studio (pull_request) Has been cancelled
build / ubuntu-24.04-clang-18-studio (pull_request) Has been cancelled
build / ubuntu-24.04-gcc-14-studio (pull_request) Has been cancelled
build / windows-2025-ninja-studio (pull_request) Has been cancelled
build / ubuntu-24.04-clang-18-wasm (pull_request) Has been cancelled
build / ubuntu-24.04-clang-18-mjx (pull_request) Has been cancelled
lint / pre-commit (pull_request) Has been cancelled
build / setup (compute matrix) (pull_request) Has been cancelled
build / ${{ matrix.label }} (pull_request) Has been cancelled
build / macos-15-arm64-studio (pull_request) Has been cancelled
build / ubuntu-24.04-clang-18-studio (pull_request) Has been cancelled
build / ubuntu-24.04-gcc-14-studio (pull_request) Has been cancelled
build / windows-2025-ninja-studio (pull_request) Has been cancelled
build / ubuntu-24.04-clang-18-wasm (pull_request) Has been cancelled
build / ubuntu-24.04-clang-18-mjx (pull_request) Has been cancelled
lint / pre-commit (pull_request) Has been cancelled
This commit is contained in:
@@ -0,0 +1,43 @@
|
||||
/* Zustand 的 action 引用稳定;初始化 viewer 与导入回调有意只创建一次。 */
|
||||
/* eslint-disable react-hooks/exhaustive-deps */
|
||||
import {useCallback,useEffect,useRef,useState,type ChangeEvent,type DragEvent} from 'react';
|
||||
import type {ProjectManifest} from '../project/types';
|
||||
import {filesFromDrop,importBrowserFiles,ProjectImportError} from '../project/importer';
|
||||
import {ProjectTree} from '../project/ProjectTree';
|
||||
import {MainThreadPhysicsAdapter,type UrdfBaseMode,type UrdfLoadMode} from '../simulation/PhysicsAdapter';
|
||||
import {MuJoCoViewer,type InteractionMode} from '../viewer/MuJoCoViewer';
|
||||
import {useAppStore,type AppDiagnostic} from '../stores/useAppStore';
|
||||
|
||||
function diagnostic(category:AppDiagnostic['category'],error:unknown,path?:string):AppDiagnostic{const detail=error instanceof Error?error.message:String(error);return {category,summary:`${category}失败`,detail,path,at:Date.now()};}
|
||||
const modeLabels:Record<InteractionMode,string>={select:'选择',joint:'关节拖动',force:'外力施加'};
|
||||
|
||||
export function App(){
|
||||
const state=useAppStore(); const manifest=useRef<ProjectManifest|null>(null); const adapter=useRef(new MainThreadPhysicsAdapter()); const viewerHost=useRef<HTMLDivElement>(null); const viewer=useRef<MuJoCoViewer|null>(null); const [forceScale,setForceScale]=useState(50); const [leftOpen,setLeftOpen]=useState(true);const [rightOpen,setRightOpen]=useState(true);const [urdfMode,setUrdfMode]=useState<UrdfLoadMode>('mjcf');const urdfModeRef=useRef<UrdfLoadMode>('mjcf');const [baseMode,setBaseMode]=useState<UrdfBaseMode>('floating');const baseModeRef=useRef<UrdfBaseMode>('floating');const [showCollision,setShowCollision]=useState(false);
|
||||
useEffect(()=>{if(!viewerHost.current)return;viewer.current=new MuJoCoViewer(viewerHost.current,{onSelection:state.setSelection,onFrame:(frame,fps,snapshot)=>{const memory=(performance as Performance&{memory?:{usedJSHeapSize:number}}).memory?.usedJSHeapSize;state.setMetrics(fps,frame.stepMs,memory===undefined?undefined:memory/1048576,frame.overBudget);if(snapshot)state.setSnapshot(snapshot);},onError:(error)=>state.setDiagnostic(diagnostic('渲染',error))});return()=>{viewer.current?.dispose();viewer.current=null;adapter.current.dispose();};},[]);
|
||||
useEffect(()=>{viewer.current?.setMode(state.mode);},[state.mode]); useEffect(()=>{if(viewer.current)viewer.current.forceScale=forceScale;},[forceScale]);useEffect(()=>{viewer.current?.setShowCollision(showCollision);},[showCollision]);
|
||||
const loadEntry=useCallback(async(path:string,requestedMode?:UrdfLoadMode)=>{if(!manifest.current)return;state.setEntry(path);state.setLoading(true);state.setDiagnostic(undefined);viewer.current?.attach(null);try{const snapshot=await adapter.current.load(manifest.current,path,requestedMode??urdfModeRef.current,baseModeRef.current);state.setSnapshot(snapshot);state.setPaused(true);viewer.current?.attach(adapter.current.session);}catch(error){state.setDiagnostic(diagnostic('模型编译',error,path));}finally{state.setLoading(false);}},[]);
|
||||
const ingest=useCallback(async(files:File[])=>{state.setLoading(true);try{const next=await importBrowserFiles(files);manifest.current=next;state.setProject(next.name,next.files.map(({path,size})=>({path,size})),next.entries,next.selectedEntry);if(next.selectedEntry)await loadEntry(next.selectedEntry);}catch(error){state.setDiagnostic(diagnostic(error instanceof ProjectImportError&&/ZIP/.test(error.message)?'ZIP':'导入',error,error instanceof ProjectImportError?error.path:undefined));}finally{state.setLoading(false);}},[loadEntry]);
|
||||
const removeProject=()=>{if(!state.projectName||!window.confirm(`确定从当前会话中移除“${state.projectName}”吗?\n不会删除本地文件。`))return;viewer.current?.attach(null);adapter.current.dispose();manifest.current=null;state.clearProject();};
|
||||
const changeUrdfMode=(value:UrdfLoadMode)=>{setUrdfMode(value);urdfModeRef.current=value;const entry=state.entries.find(candidate=>candidate.path===state.selectedEntry);if(entry?.format==='urdf')void loadEntry(entry.path,value);};
|
||||
const changeBaseMode=(value:UrdfBaseMode)=>{setBaseMode(value);baseModeRef.current=value;const entry=state.entries.find(candidate=>candidate.path===state.selectedEntry);if(entry?.format==='urdf'&&urdfModeRef.current==='mjcf')void loadEntry(entry.path,'mjcf');};
|
||||
const changeFiles=(event:ChangeEvent<HTMLInputElement>)=>{void ingest(Array.from(event.target.files??[]));event.target.value='';};
|
||||
const drop=(event:DragEvent)=>{event.preventDefault();void filesFromDrop(event.dataTransfer.items,event.dataTransfer.files).then(ingest).catch((e)=>state.setDiagnostic(diagnostic('导入',e)));};
|
||||
const togglePause=()=>{const value=!state.paused;state.setPaused(value);adapter.current.setPaused(value);};
|
||||
const reset=()=>{adapter.current.setPaused(true);adapter.current.reset();state.setSnapshot(adapter.current.snapshot()??undefined);state.setPaused(true);};
|
||||
const singleStep=()=>{adapter.current.singleStep();state.setSnapshot(adapter.current.snapshot()??undefined);};
|
||||
const changeSpeed=(value:number)=>{state.setSpeed(value);adapter.current.setSpeed(value);};
|
||||
const mode=(value:InteractionMode)=>{state.setMode(value);};
|
||||
useEffect(()=>{const key=(e:KeyboardEvent)=>{if((e.target as HTMLElement).matches('input,select,button'))return;if(e.code==='Space'){e.preventDefault();togglePause();}if(e.key==='r')reset();if(e.key==='1')mode('select');if(e.key==='2')mode('joint');if(e.key==='3')mode('force');};window.addEventListener('keydown',key);return()=>window.removeEventListener('keydown',key);});
|
||||
return <div className="flex h-screen min-w-[1024px] flex-col overflow-hidden bg-slate-950 text-slate-100" onDragOver={e=>e.preventDefault()} onDrop={drop}>
|
||||
<header className="flex h-14 shrink-0 items-center gap-3 border-b border-slate-700 bg-slate-900 px-4"><h1 className="mr-3 text-base font-semibold">MuJoCo Web 仿真平台</h1><label className="btn cursor-pointer">打开文件/ZIP<input className="sr-only" type="file" multiple accept=".xml,.urdf,.zip,.obj,.stl,.dae,.msh,.png,.jpg,.jpeg,.bmp,.tga,.hdr" onChange={changeFiles}/></label><label className="btn cursor-pointer">打开文件夹<input className="sr-only" type="file" multiple {...({webkitdirectory:'',directory:''} as object)} onChange={changeFiles}/></label><span className="h-6 border-l border-slate-700"/><button className="btn" onClick={togglePause} disabled={!state.snapshot}>{state.paused?'▶ 播放':'⏸ 暂停'}</button><button className="btn" onClick={singleStep} disabled={!state.snapshot||!state.paused}>单步</button><button className="btn" onClick={reset} disabled={!state.snapshot}>重置</button><select aria-label="仿真速度" value={state.speed} onChange={e=>changeSpeed(Number(e.target.value))} className="field w-24"><option value={.25}>0.25×</option><option value={.5}>0.5×</option><option value={1}>1×</option><option value={2}>2×</option><option value={4}>4×</option></select><span className="h-6 border-l border-slate-700"/>{(Object.keys(modeLabels) as InteractionMode[]).map(value=><button key={value} className={`btn ${state.mode===value?'border-green-500 bg-green-900/50':''}`} onClick={()=>mode(value)}>{modeLabels[value]}</button>)}<button className="btn ml-auto" onClick={()=>viewer.current?.resetCamera()}>相机复位</button><button className="icon-btn" aria-label="切换工程面板" onClick={()=>setLeftOpen(v=>!v)}>☰</button><button className="icon-btn" aria-label="切换属性面板" onClick={()=>setRightOpen(v=>!v)}>⚙</button></header>
|
||||
<div className="flex min-h-0 flex-1">{leftOpen&&<aside className="panel w-72 min-w-56 max-w-[40vw] shrink-0 resize-x overflow-auto border-r"><PanelTitle>工程资源</PanelTitle>{state.projectName?<><div className="flex items-center gap-2 px-3 py-2"><div className="min-w-0 flex-1 truncate text-sm font-medium text-green-300" title={state.projectName}>{state.projectName}</div><button type="button" className="btn shrink-0 border-red-900 px-2 text-red-300 hover:border-red-700 hover:bg-red-950" onClick={removeProject} disabled={state.loading} aria-label="移除当前工程" title="从当前会话中移除,不会删除本地文件">移除</button></div><div className="px-3 pb-2 text-xs text-slate-400">{state.files.length} 个文件</div><div className="px-2 pb-3"><ProjectTree files={state.files} entries={state.entries} selectedEntry={state.selectedEntry}/></div></>:<EmptyImport/>}</aside>}
|
||||
<main className="relative min-w-0 flex-1"><div ref={viewerHost} className="absolute inset-0"/>{!state.snapshot&&!state.loading&&<div className="pointer-events-none absolute inset-0 grid place-items-center"><EmptyImport/></div>}{state.loading&&<div role="status" className="absolute inset-0 grid place-items-center bg-slate-950/70"><div className="rounded bg-slate-800 px-5 py-3">正在加载 MuJoCo 与模型…</div></div>}{state.entries.length>1&&!state.selectedEntry&&<EntryDialog entries={state.entries} onSelect={loadEntry}/>} {state.diagnostic&&<DiagnosticCard value={state.diagnostic} onClose={()=>state.setDiagnostic(undefined)}/>}</main>
|
||||
{rightOpen&&<aside className="panel w-80 min-w-64 max-w-[40vw] shrink-0 resize-x overflow-auto border-l"><PanelTitle>模型与控制</PanelTitle>{state.snapshot?<><section className="section"><h3>模型信息</h3><dl className="grid grid-cols-2 gap-1 text-xs"><dt>Body</dt><dd>{state.snapshot.model.nbody}</dd><dt>Joint</dt><dd>{state.snapshot.model.njnt}</dd><dt>Geom</dt><dd>{state.snapshot.model.ngeom}</dd><dt>Actuator</dt><dd>{state.snapshot.model.nu}</dd><dt>qpos / qvel</dt><dd>{state.snapshot.model.nq} / {state.snapshot.model.nv}</dd></dl></section>{state.entries.find(entry=>entry.path===state.selectedEntry)?.format==='urdf'&&<section className="section"><h3>URDF 处理方式</h3><select aria-label="URDF 处理方式" className="field w-full" value={urdfMode} disabled={state.loading} onChange={event=>changeUrdfMode(event.target.value as UrdfLoadMode)}><option value="mjcf">转换为 MJCF(推荐)</option><option value="native">MuJoCo 原生 URDF</option></select><label className="mt-3 block text-xs text-slate-300"><span className="mb-1 block">基座类型</span><select aria-label="URDF 基座类型" className="field w-full" value={baseMode} disabled={state.loading||urdfMode==='native'} onChange={event=>changeBaseMode(event.target.value as UrdfBaseMode)}><option value="floating">浮动基座(Free Joint)</option><option value="fixed">固定基座(连接世界)</option></select></label><p className="hint mt-2">MJCF 模式会保留 visual mesh、添加物理地面,并将模型最低点对齐到 z=0。浮动基座可受重力和外力运动;固定基座保持与世界固连。</p><label className="mt-3 flex items-center gap-2 text-xs"><input type="checkbox" className="accent-green-500" checked={showCollision} onChange={event=>setShowCollision(event.target.checked)}/>显示碰撞几何</label></section>}{state.snapshot.warnings.length>0&&<section className="section border-amber-700/60 bg-amber-950/20"><h3 className="text-amber-300">URDF 兼容处理</h3><ul className="list-disc space-y-1 pl-4 text-xs text-amber-200">{state.snapshot.warnings.map(message=><li key={message}>{message}</li>)}</ul></section>}<section className="section"><h3>当前选择</h3>{state.selection?<div className="text-xs"><p>{state.selection.bodyName}</p><p className="text-slate-400">body {state.selection.bodyId} · geom {state.selection.geomId} · type {state.selection.geomType}</p><p className="text-slate-400">位置 {state.selection.position.map(v=>v.toFixed(3)).join(', ')}</p></div>:<p className="hint">在视口中单击物体</p>}</section><section className="section"><h3>Actuator</h3>{state.snapshot.actuators.length?state.snapshot.actuators.map(a=><ControlSlider key={a.id} label={a.name} value={a.value} min={a.min} max={a.max} onChange={v=>{adapter.current.setActuator(a.id,v);state.setSnapshot(adapter.current.snapshot()??undefined);}}/>):<p className="hint">模型没有 actuator</p>}</section><section className="section"><h3>关节</h3>{state.snapshot.joints.map(j=><ControlSlider key={j.id} label={`${j.name}${j.editable?'':'(只读)'}`} value={j.value} min={j.min} max={j.max} disabled={!j.editable} onChange={v=>{adapter.current.setJointPosition(j.id,v);state.setPaused(true);state.setSnapshot(adapter.current.snapshot()??undefined);}}/>)}</section><section className="section"><h3>外力强度</h3><ControlSlider label={`${forceScale.toFixed(0)} N/屏幕单位`} value={forceScale} min={5} max={200} onChange={setForceScale}/><p className="hint">选择“外力施加”,在动态物体上按住拖动,松开即清零。</p></section></>:<p className="p-4 text-sm text-slate-400">导入模型后显示属性</p>}</aside>}</div>
|
||||
<footer className="flex h-7 shrink-0 items-center gap-5 border-t border-slate-700 bg-slate-900 px-3 text-xs text-slate-400"><span>时间 {state.snapshot?.time.toFixed(3)??'—'} s</span><span>FPS {state.fps.toFixed(0)}</span><span>物理 {state.stepMs.toFixed(2)} ms</span><span>内存 {state.memoryMb===undefined?'—':`${state.memoryMb.toFixed(1)} MiB`}</span><span>WASM {state.snapshot?'已加载':'未加载'}</span>{state.overBudget&&<span className="text-amber-400">主线程超出步进预算,已限制追帧</span>}<span className="ml-auto">快捷键:Space 播放/暂停 · R 重置 · 1/2/3 模式</span></footer>
|
||||
</div>;
|
||||
}
|
||||
function PanelTitle({children}:{children:string}){return <h2 className="sticky top-0 border-b border-slate-700 bg-slate-900/95 px-3 py-2 text-sm font-semibold">{children}</h2>}
|
||||
function EmptyImport(){return <div className="rounded-lg border border-dashed border-slate-600 bg-slate-900/80 p-6 text-center"><p className="font-medium">拖放模型工程到此处</p><p className="mt-1 text-xs text-slate-400">支持 MJCF/XML、URDF、文件夹和 ZIP</p></div>}
|
||||
function EntryDialog({entries,onSelect}:{entries:{path:string;label:string}[];onSelect:(path:string)=>void}){return <div role="dialog" aria-modal="true" aria-label="选择模型入口" className="absolute inset-0 grid place-items-center bg-slate-950/80"><div className="w-[32rem] rounded border border-slate-600 bg-slate-900 p-5"><h2 className="font-semibold">选择模型入口</h2><p className="mt-1 text-sm text-slate-400">工程包含多个可加载模型,请选择一个。</p><div className="mt-4 space-y-2">{entries.map(e=><button className="btn block w-full overflow-hidden text-ellipsis text-left" key={e.path} onClick={()=>onSelect(e.path)}>{e.label}</button>)}</div></div></div>}
|
||||
function DiagnosticCard({value,onClose}:{value:AppDiagnostic;onClose:()=>void}){return <section role="alert" className="absolute bottom-4 left-4 right-4 max-h-48 overflow-auto rounded border border-red-700 bg-red-950/95 p-3 shadow-xl"><button aria-label="关闭错误" className="float-right" onClick={onClose}>×</button><h2 className="font-semibold text-red-200">{value.summary}</h2>{value.path&&<p className="mt-1 text-xs text-red-300">路径:{value.path}</p>}<pre className="mt-2 whitespace-pre-wrap text-xs text-red-100">{value.detail}</pre></section>}
|
||||
function ControlSlider({label,value,min,max,onChange,disabled=false}:{label:string;value:number;min:number;max:number;onChange:(v:number)=>void;disabled?:boolean}){const sane=Number.isFinite(value)?value:0;return <label className="mb-3 block text-xs"><span className="mb-1 flex justify-between"><span className="truncate pr-2">{label}</span><output>{sane.toFixed(3)}</output></span><input className="w-full accent-green-500" type="range" disabled={disabled} value={Math.min(max,Math.max(min,sane))} min={min} max={max} step={(max-min)/500||.001} onChange={e=>onChange(Number(e.target.value))}/></label>}
|
||||
@@ -0,0 +1,2 @@
|
||||
import {Component, type ErrorInfo, type ReactNode} from 'react';
|
||||
export class ErrorBoundary extends Component<{children:ReactNode},{error?:Error}>{state:{error?:Error}={};static getDerivedStateFromError(error:Error){return {error};}componentDidCatch(error:Error,info:ErrorInfo){console.error('React fatal error',error,info);}render(){return this.state.error?<main className="grid h-screen place-items-center bg-slate-950 text-slate-100"><section className="max-w-xl rounded border border-red-700 bg-red-950/50 p-6"><h1 className="text-xl font-semibold">界面发生致命错误</h1><pre className="mt-3 whitespace-pre-wrap text-sm text-red-200">{this.state.error.message}</pre><button className="btn mt-4" onClick={()=>location.reload()}>重新加载</button></section></main>:this.props.children;}}
|
||||
@@ -0,0 +1,6 @@
|
||||
import {StrictMode} from 'react';
|
||||
import {createRoot} from 'react-dom/client';
|
||||
import {App} from './app/App';
|
||||
import {ErrorBoundary} from './app/ErrorBoundary';
|
||||
import './styles.css';
|
||||
if(location.protocol==='file:')document.body.innerHTML='<main style="font-family:sans-serif;padding:2rem"><h1>需要本地 HTTP 服务器</h1><p>请运行 npm run dev:platform --prefix wasm,不能直接通过 file:// 打开。</p></main>';else createRoot(document.getElementById('root')!).render(<StrictMode><ErrorBoundary><App/></ErrorBoundary></StrictMode>);
|
||||
@@ -0,0 +1,31 @@
|
||||
import {render,screen,within} from '@testing-library/react';
|
||||
import {buildProjectTree,ProjectTree} from './ProjectTree';
|
||||
|
||||
const files=[
|
||||
{path:'robot/meshes/arm.obj',size:2048},
|
||||
{path:'robot/model.xml',size:512},
|
||||
{path:'robot/textures/body.png',size:4096},
|
||||
{path:'README.txt',size:10},
|
||||
];
|
||||
|
||||
describe('ProjectTree',()=>{
|
||||
it('按路径构建多级目录,并将目录排在文件前面',()=>{
|
||||
const tree=buildProjectTree(files);
|
||||
expect(tree.map(node=>[node.kind,node.name])).toEqual([['directory','robot'],['file','README.txt']]);
|
||||
const robot=tree[0];
|
||||
expect(robot.children?.map(node=>[node.kind,node.name])).toEqual([
|
||||
['directory','meshes'],['directory','textures'],['file','model.xml'],
|
||||
]);
|
||||
expect(robot.children?.[0].children?.[0]).toMatchObject({kind:'file',name:'arm.obj',path:'robot/meshes/arm.obj'});
|
||||
});
|
||||
|
||||
it('以可折叠目录显示文件名,而不是平铺完整路径',()=>{
|
||||
render(<ProjectTree files={files} entries={[{path:'robot/model.xml',format:'mjcf',label:'model'}]} selectedEntry="robot/model.xml"/>);
|
||||
const tree=screen.getByRole('navigation',{name:'工程文件树'});
|
||||
expect(within(tree).getByText('robot')).toBeVisible();
|
||||
expect(within(tree).getByText('meshes')).toBeVisible();
|
||||
expect(within(tree).getByText('arm.obj')).toBeVisible();
|
||||
expect(within(tree).queryByText('robot/meshes/arm.obj')).not.toBeInTheDocument();
|
||||
expect(within(tree).getByText('mjcf')).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
import type {ModelEntry} from './types';
|
||||
|
||||
export interface ProjectTreeFile {path:string;size:number;}
|
||||
|
||||
export interface ProjectTreeNode {
|
||||
name:string;
|
||||
path:string;
|
||||
kind:'directory'|'file';
|
||||
size?:number;
|
||||
children?:ProjectTreeNode[];
|
||||
}
|
||||
|
||||
interface MutableDirectory {
|
||||
name:string;
|
||||
path:string;
|
||||
directories:Map<string,MutableDirectory>;
|
||||
files:ProjectTreeNode[];
|
||||
}
|
||||
|
||||
function compareNodes(a:ProjectTreeNode,b:ProjectTreeNode):number {
|
||||
if(a.kind!==b.kind)return a.kind==='directory'?-1:1;
|
||||
return a.name.localeCompare(b.name,'zh-CN',{numeric:true,sensitivity:'base'});
|
||||
}
|
||||
|
||||
/** 将规范化后的工程路径转换为“目录优先、名称排序”的资源树。 */
|
||||
// 同文件导出纯函数是为了让资源树的数据转换可独立测试。
|
||||
// eslint-disable-next-line react-refresh/only-export-components
|
||||
export function buildProjectTree(files:ProjectTreeFile[]):ProjectTreeNode[] {
|
||||
const root:MutableDirectory={name:'',path:'',directories:new Map(),files:[]};
|
||||
for(const file of files){
|
||||
const parts=file.path.split('/').filter(Boolean);
|
||||
if(!parts.length)continue;
|
||||
let parent=root;
|
||||
for(const part of parts.slice(0,-1)){
|
||||
const path=parent.path?`${parent.path}/${part}`:part;
|
||||
let directory=parent.directories.get(part);
|
||||
if(!directory){directory={name:part,path,directories:new Map(),files:[]};parent.directories.set(part,directory);}
|
||||
parent=directory;
|
||||
}
|
||||
parent.files.push({name:parts.at(-1)!,path:file.path,kind:'file',size:file.size});
|
||||
}
|
||||
const finish=(directory:MutableDirectory):ProjectTreeNode[]=>[
|
||||
...Array.from(directory.directories.values(),child=>({name:child.name,path:child.path,kind:'directory' as const,children:finish(child)})),
|
||||
...directory.files,
|
||||
].sort(compareNodes);
|
||||
return finish(root);
|
||||
}
|
||||
|
||||
function formatSize(bytes:number):string {
|
||||
if(bytes<1024)return `${bytes} B`;
|
||||
if(bytes<1024*1024)return `${(bytes/1024).toFixed(bytes<10*1024?1:0)} KB`;
|
||||
return `${(bytes/(1024*1024)).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
function TreeNodes({nodes,entryFormats,selectedEntry}:{nodes:ProjectTreeNode[];entryFormats:Map<string,ModelEntry['format']>;selectedEntry?:string}){
|
||||
return <ul role="group" className="ml-3 border-l border-slate-800 pl-1">
|
||||
{nodes.map(node=>node.kind==='directory'?<li key={`d:${node.path}`}>
|
||||
<details open>
|
||||
<summary title={node.path} className="cursor-pointer select-none truncate rounded px-1.5 py-1 text-xs text-slate-300 hover:bg-slate-800"><span aria-hidden="true" className="mr-1">📁</span>{node.name}</summary>
|
||||
<TreeNodes nodes={node.children??[]} entryFormats={entryFormats} selectedEntry={selectedEntry}/>
|
||||
</details>
|
||||
</li>:<li key={`f:${node.path}`} title={node.path} className={`flex min-w-0 items-center gap-1 rounded px-1.5 py-1 text-xs ${selectedEntry===node.path?'bg-green-900/40 text-green-200':'text-slate-300 hover:bg-slate-800'}`}>
|
||||
<span aria-hidden="true">{entryFormats.has(node.path)?'◇':'▧'}</span><span className="min-w-0 flex-1 truncate">{node.name}</span>{entryFormats.has(node.path)&&<span className="shrink-0 text-[10px] uppercase text-green-400">{entryFormats.get(node.path)}</span>}<span className="shrink-0 text-[10px] text-slate-500">{formatSize(node.size??0)}</span>
|
||||
</li>)}
|
||||
</ul>;
|
||||
}
|
||||
|
||||
export function ProjectTree({files,entries,selectedEntry}:{files:ProjectTreeFile[];entries:ModelEntry[];selectedEntry?:string}){
|
||||
const nodes=buildProjectTree(files);
|
||||
const entryFormats=new Map(entries.map(entry=>[entry.path,entry.format]));
|
||||
return <nav aria-label="工程文件树"><TreeNodes nodes={nodes} entryFormats={entryFormats} selectedEntry={selectedEntry}/></nav>;
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import {LoadingManager,type Material,type Mesh,type Texture} from 'three';
|
||||
import {OBJExporter} from 'three/addons/exporters/OBJExporter.js';
|
||||
import {ColladaLoader} from 'three/addons/loaders/ColladaLoader.js';
|
||||
|
||||
const TRANSPARENT_PIXEL='data:image/gif;base64,R0lGODlhAQABAAD/ACwAAAAAAQABAAACADs=';
|
||||
|
||||
/**
|
||||
* 将 Collada 几何转换为 MuJoCo WASM 可读取的 OBJ。
|
||||
* ColladaLoader 会先统一为 Y-up;额外旋转到 MuJoCo 使用的 Z-up,并烘焙节点变换与单位缩放。
|
||||
*/
|
||||
export function convertDaeToObj(data:Uint8Array,path:string):Uint8Array {
|
||||
const manager=new LoadingManager();
|
||||
// 转换只需要几何。拦截贴图 URL,避免为浏览器内存文件发起无效网络请求。
|
||||
manager.setURLModifier(()=>TRANSPARENT_PIXEL);
|
||||
const loader=new ColladaLoader(manager);
|
||||
const text=new TextDecoder('utf-8').decode(data);
|
||||
const xml=new DOMParser().parseFromString(text,'application/xml');
|
||||
if(xml.querySelector('parsererror'))throw new Error('Collada XML 格式无效');
|
||||
const upAxis=xml.getElementsByTagName('up_axis')[0]?.textContent?.trim().toUpperCase()??'Y_UP';
|
||||
// 禁用 ColladaLoader 自带的 Z-up → Y-up 旋转,改为直接统一到 MuJoCo 的 Z-up。
|
||||
if(upAxis==='Z_UP')xml.getElementsByTagName('up_axis')[0]!.textContent='Y_UP';
|
||||
const normalized=new XMLSerializer().serializeToString(xml);
|
||||
const result=loader.parse(normalized,path.slice(0,path.lastIndexOf('/')+1));
|
||||
if(!result?.scene)throw new Error('Collada 文件无法解析');
|
||||
const scene=result.scene;
|
||||
if(upAxis==='Y_UP')scene.rotation.x+=Math.PI/2;
|
||||
else if(upAxis==='X_UP')scene.rotation.y-=Math.PI/2;
|
||||
scene.updateMatrixWorld(true);
|
||||
let meshCount=0;
|
||||
scene.traverse(object=>{
|
||||
const mesh=object as Mesh;
|
||||
if(!mesh.isMesh)return;
|
||||
meshCount+=1;
|
||||
const materials=Array.isArray(mesh.material)?mesh.material:[mesh.material];
|
||||
for(const material of materials)if(material)material.name='';
|
||||
});
|
||||
if(!meshCount)throw new Error('Collada 文件不包含可转换的三角网格');
|
||||
try {
|
||||
const output=new OBJExporter().parse(scene);
|
||||
if(!/^v\s/m.test(output)||!/^f\s/m.test(output))throw new Error('Collada 文件未生成有效三角面');
|
||||
return new TextEncoder().encode(output);
|
||||
} finally {
|
||||
scene.traverse(object=>{
|
||||
const mesh=object as Mesh;
|
||||
if(!mesh.isMesh)return;
|
||||
mesh.geometry?.dispose();
|
||||
const materials:Material[]=Array.isArray(mesh.material)?mesh.material:[mesh.material];
|
||||
for(const material of materials){
|
||||
for(const value of Object.values(material))if(value&&typeof value==='object'&&(value as Texture).isTexture)(value as Texture).dispose();
|
||||
material.dispose();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import {zipSync} from 'fflate';
|
||||
import {choosePreferredEntry,discoverEntries,importBrowserFiles,normalizeProjectPath,prepareProjectForMujoco,ProjectImportError} from './importer';
|
||||
import type {ProjectFile} from './types';
|
||||
const encode=(s:string)=>new TextEncoder().encode(s);
|
||||
const projectFile=(path:string,text:string):ProjectFile=>({path,data:encode(text),size:encode(text).length,source:'file',mimeType:'text/xml'});
|
||||
const TRIANGLE_DAE=`<?xml version="1.0"?><COLLADA xmlns="http://www.collada.org/2005/11/COLLADASchema" version="1.4.1">
|
||||
<asset><unit meter="1"/><up_axis>Z_UP</up_axis></asset><library_effects><effect id="fx"><profile_COMMON><technique sid="common"><lambert><diffuse><color>1 1 1 1</color></diffuse></lambert></technique></profile_COMMON></effect></library_effects><library_materials><material id="mat"><instance_effect url="#fx"/></material></library_materials>
|
||||
<library_geometries><geometry id="triangle"><mesh><source id="positions"><float_array id="positions-array" count="9">0 0 0 1 0 0 0 1 0</float_array><technique_common><accessor source="#positions-array" count="3" stride="3"><param name="X" type="float"/><param name="Y" type="float"/><param name="Z" type="float"/></accessor></technique_common></source><vertices id="vertices"><input semantic="POSITION" source="#positions"/></vertices><triangles count="1" material="mat"><input semantic="VERTEX" source="#vertices" offset="0"/><p>0 1 2</p></triangles></mesh></geometry></library_geometries>
|
||||
<library_visual_scenes><visual_scene id="scene"><node id="node"><instance_geometry url="#triangle"><bind_material><technique_common><instance_material symbol="mat" target="#mat"/></technique_common></bind_material></instance_geometry></node></visual_scene></library_visual_scenes><scene><instance_visual_scene url="#scene"/></scene></COLLADA>`;
|
||||
describe('project importer',()=>{
|
||||
it('拒绝路径穿越与绝对路径',()=>{expect(()=>normalizeProjectPath('../model.xml')).toThrow(ProjectImportError);expect(()=>normalizeProjectPath('/model.xml')).toThrow(ProjectImportError);expect(normalizeProjectPath('robot\\mesh\\a.obj')).toBe('robot/mesh/a.obj');});
|
||||
it('识别 MJCF 与 URDF 并执行入口优先级',()=>{const entries=discoverEntries([projectFile('other.xml','<mujoco/>'),projectFile('model.xml','<mujoco/>'),projectFile('robot.urdf','<robot/>')]);expect(entries).toHaveLength(3);expect(choosePreferredEntry(entries)).toBe('model.xml');});
|
||||
it('解压 ZIP 并保留二进制数据',async()=>{const zipped=zipSync({'robot/model.urdf':encode('<robot name="r"/>'),'robot/mesh.obj':encode('v 0 0 0')});const file=new File([zipped],'robot.zip',{type:'application/zip'});const result=await importBrowserFiles([file]);expect(result.files.map(f=>f.path)).toContain('robot/mesh.obj');expect(result.selectedEntry).toBe('robot/model.urdf');});
|
||||
it('拒绝 ZIP 路径穿越',async()=>{const zipped=zipSync({'../model.xml':encode('<mujoco/>')});await expect(importBrowserFiles([new File([zipped],'bad.zip')])).rejects.toThrow('路径包含越界片段');});
|
||||
it('拒绝同名路径',async()=>{const a=new File(['<mujoco/>'],'model.xml');const b=new File(['<mujoco/>'],'model.xml');await expect(importBrowserFiles([a,b])).rejects.toThrow('同名路径');});
|
||||
it('拒绝超过限制的文件',async()=>{const file=new File(['<mujoco/>'],'model.xml');await expect(importBrowserFiles([file],{maxFiles:1,maxFileBytes:2,maxTotalBytes:2,maxZipBytes:2})).rejects.toThrow('单文件超过限制');});
|
||||
it('规范化 MuJoCo 不接受的重复 material 和 ROS package URI',()=>{const urdf=projectFile('go2w_description/urdf/robot.urdf','<robot><link name="base"><visual><geometry><mesh filename="package://go2w_description/meshes/base.obj"/></geometry><material name="a"/><material name="b"/></visual></link></robot>');const mesh:ProjectFile={path:'go2w_description/meshes/base.obj',data:new Uint8Array([1]),size:1,source:'directory',mimeType:''};const manifest={id:'go2w',name:'go2w',files:[urdf,mesh],entries:[{path:urdf.path,format:'urdf' as const,label:'robot'}],selectedEntry:urdf.path,totalBytes:urdf.size+1};const prepared=prepareProjectForMujoco(manifest,urdf.path);const text=new TextDecoder().decode(prepared.manifest.files[0].data);expect((text.match(/<material/g)??[])).toHaveLength(1);expect(text).toContain('filename="../meshes/base.obj"');expect(text).toContain('discardvisual="false"');expect(text).toContain('fusestatic="false"');expect(prepared.warnings).toHaveLength(2);});
|
||||
it('将 DAE mesh 转换为 MuJoCo 可读取的 OBJ,并复用于 visual/collision',()=>{const urdf=projectFile('robot/robot.urdf','<robot><link name="base"><visual><geometry><mesh filename="meshes/triangle.dae"/></geometry></visual><collision><geometry><mesh filename="meshes/triangle.dae"/></geometry></collision></link></robot>');const dae=projectFile('robot/meshes/triangle.dae',TRIANGLE_DAE);const manifest={id:'dae',name:'dae',files:[urdf,dae],entries:[{path:urdf.path,format:'urdf' as const,label:'robot'}],selectedEntry:urdf.path,totalBytes:urdf.size+dae.size};const prepared=prepareProjectForMujoco(manifest,urdf.path);const text=new TextDecoder().decode(prepared.manifest.files.find(file=>file.path===urdf.path)!.data);expect(text).not.toContain('.dae');expect(text.match(/meshes\/triangle\.mujoco\.obj/g)).toHaveLength(2);const obj=prepared.manifest.files.find(file=>file.path==='robot/meshes/triangle.mujoco.obj');expect(new TextDecoder().decode(obj!.data)).toMatch(/^f\s/m);expect(prepared.warnings.join(' ')).toContain('1 个 DAE 文件转换为 OBJ');});
|
||||
it('DAE 缺失或转换失败时安全降级',()=>{const urdf=projectFile('robot.urdf','<robot><link name="base"><visual><geometry><mesh filename="visual.dae"/></geometry></visual><collision><geometry><mesh filename="collision.dae"/></geometry></collision></link></robot>');const manifest={id:'dae',name:'dae',files:[urdf],entries:[{path:urdf.path,format:'urdf' as const,label:'robot'}],selectedEntry:urdf.path,totalBytes:urdf.size};const prepared=prepareProjectForMujoco(manifest,urdf.path);const text=new TextDecoder().decode(prepared.manifest.files[0].data);expect(text).not.toContain('<visual>');expect(text).toContain('<collision>');expect(text).toContain('<sphere radius="0.05"');expect(prepared.warnings.join(' ')).toContain('DAE visual');expect(prepared.warnings.join(' ')).toContain('DAE collision');});
|
||||
it('在解压前依据 ZIP 元数据拒绝膨胀内容',async()=>{const zipped=zipSync({'model.xml':encode(`<mujoco>${' '.repeat(4096)}</mujoco>`)});const file=new File([zipped],'large.zip');await expect(importBrowserFiles([file],{maxFiles:2,maxFileBytes:128,maxTotalBytes:256,maxZipBytes:4096})).rejects.toThrow('单文件超过限制');});
|
||||
});
|
||||
@@ -0,0 +1,223 @@
|
||||
import {unzipSync} from 'fflate';
|
||||
import {DEFAULT_IMPORT_LIMITS, type ImportLimits, type ModelEntry, type ProjectFile, type ProjectManifest} from './types';
|
||||
import {convertDaeToObj} from './daeConverter';
|
||||
|
||||
const decoder = new TextDecoder('utf-8', {fatal: false});
|
||||
|
||||
export class ProjectImportError extends Error {
|
||||
constructor(message: string, readonly path?: string) { super(message); this.name = 'ProjectImportError'; }
|
||||
}
|
||||
|
||||
export function normalizeProjectPath(input: string): string {
|
||||
const path = input.replaceAll('\\', '/').replace(/^\.\//, '');
|
||||
if (!path || path.startsWith('/') || path.includes('\0') || /^[A-Za-z]:/.test(path)) throw new ProjectImportError('不允许绝对路径或空路径', input);
|
||||
const parts = path.split('/').filter((part) => part !== '' && part !== '.');
|
||||
if (!parts.length || parts.some((part) => part === '..')) throw new ProjectImportError('路径包含越界片段', input);
|
||||
return parts.join('/');
|
||||
}
|
||||
|
||||
function checkEncryptedZip(data: Uint8Array): void {
|
||||
for (let i = 0; i + 8 < data.length; i++) {
|
||||
if (data[i] === 0x50 && data[i + 1] === 0x4b && (data[i + 2] === 0x03 || data[i + 2] === 0x01) && (data[i + 3] === 0x04 || data[i + 3] === 0x02)) {
|
||||
const flags = data[i + 6] | (data[i + 7] << 8);
|
||||
if ((flags & 1) !== 0) throw new ProjectImportError('不支持加密 ZIP');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function enforceLimits(files: ProjectFile[], limits: ImportLimits): void {
|
||||
if (files.length > limits.maxFiles) throw new ProjectImportError(`文件数量超过限制(${limits.maxFiles})`);
|
||||
let total = 0;
|
||||
const seen = new Set<string>();
|
||||
for (const file of files) {
|
||||
if (seen.has(file.path)) throw new ProjectImportError('工程中存在同名路径', file.path);
|
||||
seen.add(file.path);
|
||||
if (file.size > limits.maxFileBytes) throw new ProjectImportError(`单文件超过限制(${limits.maxFileBytes} 字节)`, file.path);
|
||||
total += file.size;
|
||||
if (total > limits.maxTotalBytes) throw new ProjectImportError(`工程总大小超过限制(${limits.maxTotalBytes} 字节)`);
|
||||
}
|
||||
}
|
||||
|
||||
export function discoverEntries(files: ProjectFile[]): ModelEntry[] {
|
||||
return files.flatMap((file): ModelEntry[] => {
|
||||
if (!/\.(xml|urdf)$/i.test(file.path)) return [];
|
||||
const head = decoder.decode(file.data.subarray(0, Math.min(file.data.length, 256 * 1024))).replace(/^\uFEFF/, '');
|
||||
const format = /<robot(?:\s|\/?>)/i.test(head) ? 'urdf' : /<mujoco(?:\s|\/?>)/i.test(head) ? 'mjcf' : undefined;
|
||||
return format ? [{path: file.path, format, label: `${file.path} (${format.toUpperCase()})`}] : [];
|
||||
});
|
||||
}
|
||||
|
||||
export interface PreparedProject {
|
||||
manifest: ProjectManifest;
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
function relativeProjectPath(fromFile: string, toFile: string): string {
|
||||
const from = fromFile.split('/').slice(0, -1);
|
||||
const to = toFile.split('/');
|
||||
while (from.length && to.length && from[0] === to[0]) { from.shift(); to.shift(); }
|
||||
return `${'../'.repeat(from.length)}${to.join('/')}` || './';
|
||||
}
|
||||
|
||||
function resolveProjectReference(fromFile:string,reference:string):string|undefined {
|
||||
if(/^[a-z][a-z\d+.-]*:/i.test(reference))return;
|
||||
let decoded:string;
|
||||
try{decoded=decodeURIComponent(reference.split(/[?#]/,1)[0]);}catch{return;}
|
||||
const parts=fromFile.split('/').slice(0,-1);
|
||||
for(const part of decoded.replaceAll('\\','/').split('/')){
|
||||
if(!part||part==='.')continue;
|
||||
if(part==='..'){if(!parts.length)return;parts.pop();}
|
||||
else parts.push(part);
|
||||
}
|
||||
return parts.join('/');
|
||||
}
|
||||
|
||||
function generatedObjPath(daePath:string,occupied:Set<string>):string {
|
||||
const base=daePath.replace(/\.dae$/i,'');
|
||||
let candidate=`${base}.mujoco.obj`;
|
||||
for(let index=2;occupied.has(candidate);index+=1)candidate=`${base}.mujoco-${index}.obj`;
|
||||
occupied.add(candidate);
|
||||
return candidate;
|
||||
}
|
||||
|
||||
/** Normalizes common ROS URDF constructs that MuJoCo's stricter parser rejects. */
|
||||
export function prepareProjectForMujoco(manifest: ProjectManifest, entryPath: string): PreparedProject {
|
||||
const entry = manifest.entries.find((candidate) => candidate.path === entryPath);
|
||||
if (entry?.format !== 'urdf') return {manifest, warnings: []};
|
||||
const source = manifest.files.find((file) => file.path === entryPath);
|
||||
if (!source) return {manifest, warnings: []};
|
||||
const document = new DOMParser().parseFromString(decoder.decode(source.data), 'application/xml');
|
||||
if (document.querySelector('parsererror')) return {manifest, warnings: []};
|
||||
|
||||
const warnings: string[] = [];
|
||||
const robot=document.documentElement;
|
||||
let mujoco=Array.from(robot.children).find(child=>child.tagName==='mujoco');
|
||||
if(!mujoco){mujoco=document.createElement('mujoco');robot.prepend(mujoco);}
|
||||
let compiler=Array.from(mujoco.children).find(child=>child.tagName==='compiler');
|
||||
if(!compiler){compiler=document.createElement('compiler');mujoco.append(compiler);}
|
||||
compiler.setAttribute('discardvisual','false');
|
||||
compiler.setAttribute('fusestatic','false');
|
||||
|
||||
let removedMaterials = 0;
|
||||
for (const visual of Array.from(document.querySelectorAll('visual'))) {
|
||||
const materials = Array.from(visual.children).filter((child) => child.tagName === 'material');
|
||||
for (const duplicate of materials.slice(1)) { duplicate.remove(); removedMaterials += 1; }
|
||||
}
|
||||
if (removedMaterials) warnings.push(`为兼容 MuJoCo,已移除 visual 中 ${removedMaterials} 个重复 material(保留第一个)`);
|
||||
|
||||
const paths = manifest.files.map((file) => file.path);
|
||||
let rewrittenUris = 0;
|
||||
const unresolved: string[] = [];
|
||||
for (const element of Array.from(document.querySelectorAll('[filename]'))) {
|
||||
const value = element.getAttribute('filename');
|
||||
if (!value?.startsWith('package://')) continue;
|
||||
const packagePath = normalizeProjectPath(value.slice('package://'.length));
|
||||
const target = paths.find((path) => path === packagePath) ?? paths.find((path) => path.endsWith(`/${packagePath}`));
|
||||
if (!target) { unresolved.push(value); continue; }
|
||||
element.setAttribute('filename', relativeProjectPath(entryPath, target));
|
||||
rewrittenUris += 1;
|
||||
}
|
||||
if (rewrittenUris) warnings.push(`已将 ${rewrittenUris} 个 package:// 资源地址改写为工程内相对路径`);
|
||||
if (unresolved.length) warnings.push(`有 ${unresolved.length} 个 package:// 资源未在工程中找到`);
|
||||
|
||||
const occupied=new Set(manifest.files.map(file=>file.path));
|
||||
const converted=new Map<string,ProjectFile>();
|
||||
let convertedDaeReferences=0;
|
||||
let removedDaeVisuals=0;
|
||||
let daeCollisionFallbacks=0;
|
||||
for(const mesh of Array.from(document.querySelectorAll('mesh[filename]'))){
|
||||
const filename=mesh.getAttribute('filename');
|
||||
if(!filename?.toLowerCase().split(/[?#]/)[0].endsWith('.dae'))continue;
|
||||
const daePath=resolveProjectReference(entryPath,filename);
|
||||
const daeFile=daePath?manifest.files.find(file=>file.path===daePath):undefined;
|
||||
try{
|
||||
if(!daeFile||!daePath)throw new Error('工程中找不到 DAE 文件');
|
||||
let objFile=converted.get(daePath);
|
||||
if(!objFile){
|
||||
const data=convertDaeToObj(daeFile.data,daePath);
|
||||
if(data.byteLength>DEFAULT_IMPORT_LIMITS.maxFileBytes)throw new Error('转换后的 OBJ 超过单文件大小限制');
|
||||
objFile={path:generatedObjPath(daePath,occupied),data,size:data.byteLength,source:daeFile.source,mimeType:'text/plain'};
|
||||
converted.set(daePath,objFile);
|
||||
}
|
||||
mesh.setAttribute('filename',relativeProjectPath(entryPath,objFile.path));
|
||||
convertedDaeReferences+=1;
|
||||
}catch(error){
|
||||
console.warn(`[MuJoCo] DAE 转换失败:${filename}`,error);
|
||||
const visual=mesh.closest('visual');
|
||||
if(visual){visual.remove();removedDaeVisuals+=1;}
|
||||
else if(mesh.closest('collision')){
|
||||
const sphere=document.createElement('sphere');sphere.setAttribute('radius','0.05');mesh.replaceWith(sphere);daeCollisionFallbacks+=1;
|
||||
}
|
||||
}
|
||||
}
|
||||
if(convertedDaeReferences)warnings.push(`已将 ${converted.size} 个 DAE 文件转换为 OBJ,供 ${convertedDaeReferences} 个 visual/collision 使用`);
|
||||
if(removedDaeVisuals)warnings.push(`${removedDaeVisuals} 个 DAE visual 转换失败,已移除并使用其他 collision 几何显示/仿真`);
|
||||
if(daeCollisionFallbacks)warnings.push(`${daeCollisionFallbacks} 个 DAE collision 转换失败,已替换为半径 0.05 m 的占位球体;碰撞精度会降低`);
|
||||
|
||||
const xml=new TextEncoder().encode(new XMLSerializer().serializeToString(document));
|
||||
const replacement:ProjectFile={...source,data:xml,size:xml.byteLength};
|
||||
const generated=Array.from(converted.values());
|
||||
const files=[...manifest.files.map(file=>file===source?replacement:file),...generated];
|
||||
return {manifest:{...manifest,files,totalBytes:files.reduce((total,file)=>total+file.size,0)},warnings};
|
||||
}
|
||||
|
||||
export function choosePreferredEntry(entries: ModelEntry[]): string | undefined {
|
||||
if (entries.length === 1) return entries[0].path;
|
||||
const rootPreferred = entries.find((e) => !e.path.includes('/') && /^(model|scene)\.xml$/i.test(e.path));
|
||||
if (rootPreferred) return rootPreferred.path;
|
||||
const urdfs = entries.filter((e) => e.format === 'urdf');
|
||||
return urdfs.length === 1 ? urdfs[0].path : undefined;
|
||||
}
|
||||
|
||||
function manifest(name: string, files: ProjectFile[]): ProjectManifest {
|
||||
const entries = discoverEntries(files);
|
||||
if (!entries.length) throw new ProjectImportError('未发现包含 <mujoco> 或 <robot> 根元素的 XML/URDF 入口');
|
||||
return {id: `${Date.now().toString(36)}-${crypto.randomUUID().slice(0, 8)}`, name, files, entries, selectedEntry: choosePreferredEntry(entries), totalBytes: files.reduce((n, f) => n + f.size, 0)};
|
||||
}
|
||||
|
||||
export async function importBrowserFiles(input: File[], limits: ImportLimits = DEFAULT_IMPORT_LIMITS): Promise<ProjectManifest> {
|
||||
if (!input.length) throw new ProjectImportError('未选择文件');
|
||||
if (input.length === 1 && /\.zip$/i.test(input[0].name)) {
|
||||
if (input[0].size > limits.maxZipBytes) throw new ProjectImportError(`ZIP 超过限制(${limits.maxZipBytes} 字节)`);
|
||||
const bytes = new Uint8Array(await input[0].arrayBuffer()); checkEncryptedZip(bytes);
|
||||
let unpacked: Record<string, Uint8Array>;
|
||||
try {
|
||||
let fileCount = 0;
|
||||
let expandedBytes = 0;
|
||||
unpacked = unzipSync(bytes, {filter: (entry) => {
|
||||
if (entry.name.endsWith('/')) return false;
|
||||
normalizeProjectPath(entry.name);
|
||||
fileCount += 1;
|
||||
expandedBytes += entry.originalSize;
|
||||
if (fileCount > limits.maxFiles) throw new ProjectImportError(`文件数量超过限制(${limits.maxFiles})`);
|
||||
if (entry.originalSize > limits.maxFileBytes) throw new ProjectImportError(`单文件超过限制(${limits.maxFileBytes} 字节)`, entry.name);
|
||||
if (expandedBytes > limits.maxTotalBytes) throw new ProjectImportError(`ZIP 解压后总大小超过限制(${limits.maxTotalBytes} 字节)`);
|
||||
return true;
|
||||
}});
|
||||
} catch (error) {
|
||||
if (error instanceof ProjectImportError) throw error;
|
||||
throw new ProjectImportError(`ZIP 解压失败:${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
const files = Object.entries(unpacked).filter(([path]) => !path.endsWith('/')).map(([path, data]): ProjectFile => ({path: normalizeProjectPath(path), data, size: data.byteLength, source: 'zip', mimeType: ''}));
|
||||
enforceLimits(files, limits); return manifest(input[0].name.replace(/\.zip$/i, ''), files);
|
||||
}
|
||||
const files = await Promise.all(input.map(async (file): Promise<ProjectFile> => {
|
||||
const relative = (file as File & {webkitRelativePath?: string}).webkitRelativePath || file.name;
|
||||
const data = new Uint8Array(await file.arrayBuffer());
|
||||
return {path: normalizeProjectPath(relative), data, size: data.byteLength, source: relative === file.name ? 'file' : 'directory', mimeType: file.type};
|
||||
}));
|
||||
enforceLimits(files, limits); return manifest(files[0].path.split('/')[0] || '工程', files);
|
||||
}
|
||||
|
||||
interface LegacyEntry {isFile: boolean; isDirectory: boolean; name: string; file(cb: (file: File) => void, err: (e: DOMException) => void): void; createReader(): {readEntries(cb: (entries: LegacyEntry[]) => void, err: (e: DOMException) => void): void};}
|
||||
async function readEntry(entry: LegacyEntry, prefix = ''): Promise<File[]> {
|
||||
if (entry.isFile) return [await new Promise<File>((resolve, reject) => entry.file((file) => {Object.defineProperty(file, 'webkitRelativePath', {value: `${prefix}${file.name}`}); resolve(file);}, reject))];
|
||||
const reader = entry.createReader(); const children: LegacyEntry[] = [];
|
||||
for (;;) { const batch = await new Promise<LegacyEntry[]>((resolve, reject) => reader.readEntries(resolve, reject)); if (!batch.length) break; children.push(...batch); }
|
||||
return (await Promise.all(children.map((child) => readEntry(child, `${prefix}${entry.name}/`)))).flat();
|
||||
}
|
||||
|
||||
export async function filesFromDrop(items: DataTransferItemList, fallback: FileList): Promise<File[]> {
|
||||
const entries = Array.from(items).map((item) => (item as unknown as {webkitGetAsEntry?: () => LegacyEntry | null}).webkitGetAsEntry?.() ?? null).filter((entry): entry is LegacyEntry => entry !== null);
|
||||
return entries.length ? (await Promise.all(entries.map((entry) => readEntry(entry)))).flat() : Array.from(fallback);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
export type ModelFormat = 'mjcf' | 'urdf';
|
||||
|
||||
export interface ProjectFile {
|
||||
path: string;
|
||||
data: Uint8Array;
|
||||
size: number;
|
||||
source: 'file' | 'directory' | 'zip';
|
||||
mimeType: string;
|
||||
}
|
||||
|
||||
export interface ModelEntry {
|
||||
path: string;
|
||||
format: ModelFormat;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export interface ProjectManifest {
|
||||
id: string;
|
||||
name: string;
|
||||
files: ProjectFile[];
|
||||
entries: ModelEntry[];
|
||||
selectedEntry?: string;
|
||||
totalBytes: number;
|
||||
}
|
||||
|
||||
export interface ImportLimits {
|
||||
maxFiles: number;
|
||||
maxFileBytes: number;
|
||||
maxTotalBytes: number;
|
||||
maxZipBytes: number;
|
||||
}
|
||||
|
||||
export const DEFAULT_IMPORT_LIMITS: ImportLimits = {
|
||||
maxFiles: 2_000,
|
||||
maxFileBytes: 128 * 1024 * 1024,
|
||||
maxTotalBytes: 512 * 1024 * 1024,
|
||||
maxZipBytes: 128 * 1024 * 1024,
|
||||
};
|
||||
@@ -0,0 +1,22 @@
|
||||
import {groundConvertedMjcf} from './urdfToMjcf';
|
||||
|
||||
const encode=(value:string)=>new TextEncoder().encode(value);
|
||||
const decode=(value:Uint8Array)=>new TextDecoder().decode(value);
|
||||
|
||||
describe('groundConvertedMjcf',()=>{
|
||||
it('抬升所有根 body,并在 z=0 添加地面',()=>{
|
||||
const result=decode(groundConvertedMjcf(encode('<mujoco><worldbody><body name="robot" pos="1 2 0.1"><geom type="box" size="1 1 1"/></body></worldbody></mujoco>'),-0.4,'fixed'));
|
||||
const document=new DOMParser().parseFromString(result,'application/xml');
|
||||
expect(document.querySelector('body[name="robot"]')?.getAttribute('pos')).toBe('1 2 0.5');
|
||||
expect(document.querySelector('geom[name="__platform_ground__"]')).toMatchObject({tagName:'geom'});
|
||||
expect(document.querySelector('geom[name="__platform_ground__"]')?.getAttribute('group')).toBe('5');
|
||||
expect(document.querySelector('freejoint')).toBeNull();
|
||||
});
|
||||
|
||||
it('为浮动基座的每个世界根 body 添加 freejoint',()=>{
|
||||
const result=decode(groundConvertedMjcf(encode('<mujoco><worldbody><body name="robot"><geom type="sphere" size="1"/></body></worldbody></mujoco>'),-1,'floating'));
|
||||
const document=new DOMParser().parseFromString(result,'application/xml');
|
||||
expect(document.querySelector('body[name="robot"] > freejoint')?.getAttribute('name')).toBe('__platform_base_freejoint__');
|
||||
expect(document.querySelector('body[name="robot"]')?.getAttribute('pos')).toBe('0 0 1');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
const decoder=new TextDecoder('utf-8');
|
||||
const encoder=new TextEncoder();
|
||||
|
||||
function numbers(value:string|undefined,count:number):number[]{
|
||||
const parsed=(value??'').trim().split(/\s+/).filter(Boolean).map(Number);
|
||||
return Array.from({length:count},(_,index)=>Number.isFinite(parsed[index])?parsed[index]:0);
|
||||
}
|
||||
|
||||
export type UrdfBaseMode='floating'|'fixed';
|
||||
|
||||
/** 给 MuJoCo 从 URDF 导出的 MJCF 添加地面、设置基座类型,并整体抬升根 body。 */
|
||||
export function groundConvertedMjcf(data:Uint8Array,minimumZ:number,baseMode:UrdfBaseMode='fixed'):Uint8Array {
|
||||
const document=new DOMParser().parseFromString(decoder.decode(data),'application/xml');
|
||||
if(document.querySelector('parsererror'))throw new Error('MuJoCo 导出的 MJCF XML 无法解析');
|
||||
const worldbody=document.querySelector('mujoco > worldbody');
|
||||
if(!worldbody)throw new Error('MuJoCo 导出的 MJCF 缺少 worldbody');
|
||||
const lift=Number.isFinite(minimumZ)?-minimumZ:0;
|
||||
const rootBodies=Array.from(worldbody.children).filter(element=>element.tagName==='body');
|
||||
for(const [index,body] of rootBodies.entries()){
|
||||
const pos=numbers(body.getAttribute('pos')??undefined,3);pos[2]+=lift;body.setAttribute('pos',pos.join(' '));
|
||||
if(baseMode==='floating'&&!Array.from(body.children).some(element=>element.tagName==='freejoint'||element.tagName==='joint')){
|
||||
const freejoint=document.createElement('freejoint');freejoint.setAttribute('name',rootBodies.length===1?'__platform_base_freejoint__':`__platform_base_freejoint_${index}__`);body.prepend(freejoint);
|
||||
}
|
||||
}
|
||||
const floor=document.createElement('geom');
|
||||
floor.setAttribute('name','__platform_ground__');floor.setAttribute('type','plane');floor.setAttribute('size','1 1 0.1');floor.setAttribute('pos','0 0 0');floor.setAttribute('rgba','0.12 0.16 0.22 1');floor.setAttribute('group','5');floor.setAttribute('friction','1 0.005 0.0001');
|
||||
worldbody.prepend(floor);
|
||||
return encoder.encode(new XMLSerializer().serializeToString(document));
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
import {MemfsWorkspace} from './workspace';
|
||||
import type {MainModule} from '@mujoco/mujoco';
|
||||
import type {ProjectManifest} from './types';
|
||||
it('按相对路径挂载并逆序清理 MEMFS',()=>{const calls:string[]=[];const FS={mkdirTree:(p:string)=>calls.push(`mkdir:${p}`),writeFile:(p:string)=>calls.push(`write:${p}`),unlink:(p:string)=>calls.push(`unlink:${p}`),rmdir:(p:string)=>calls.push(`rmdir:${p}`)};const workspace=new MemfsWorkspace({FS} as unknown as MainModule,'safe');const data=new Uint8Array([1]);const manifest:ProjectManifest={id:'safe',name:'x',entries:[],files:[{path:'a/b/model.xml',data,size:1,source:'file',mimeType:''}],totalBytes:1};workspace.mount(manifest);workspace.dispose();expect(calls).toEqual(expect.arrayContaining(['/workspace/safe/a/b/model.xml'].map(p=>`write:${p}`)));expect(calls).toContain('rmdir:/workspace/safe/a/b');expect(calls).toContain('rmdir:/workspace/safe/a');expect(calls.indexOf('unlink:/workspace/safe/a/b/model.xml')).toBeLessThan(calls.indexOf('rmdir:/workspace/safe/a/b'));expect(calls.indexOf('rmdir:/workspace/safe/a/b')).toBeLessThan(calls.indexOf('rmdir:/workspace/safe/a'));});
|
||||
@@ -0,0 +1,60 @@
|
||||
import type {MainModule} from '@mujoco/mujoco';
|
||||
import type {ProjectManifest} from './types';
|
||||
|
||||
interface EmscriptenFS {
|
||||
mkdirTree(path: string): void;
|
||||
writeFile(path: string, data: Uint8Array): void;
|
||||
readFile(path:string,options:{encoding:'utf8'}):string;
|
||||
unlink(path: string): void;
|
||||
rmdir(path: string): void;
|
||||
}
|
||||
|
||||
type ModuleWithFS = MainModule & {FS: EmscriptenFS};
|
||||
|
||||
export class MemfsWorkspace {
|
||||
readonly root: string;
|
||||
private files: string[] = [];
|
||||
private directories: string[] = [];
|
||||
|
||||
constructor(private readonly module: MainModule, projectId: string) {
|
||||
const safeId = projectId.replace(/[^a-zA-Z0-9_-]/g, '_');
|
||||
this.root = `/workspace/${safeId}`;
|
||||
}
|
||||
|
||||
mount(manifest: ProjectManifest): void {
|
||||
const fs = (this.module as ModuleWithFS).FS;
|
||||
fs.mkdirTree(this.root); this.directories.push(this.root);
|
||||
for (const file of manifest.files) {
|
||||
const absolute = `${this.root}/${file.path}`;
|
||||
const directory = absolute.slice(0, absolute.lastIndexOf('/'));
|
||||
if (directory !== this.root) {
|
||||
const relativeDirectory = directory.slice(this.root.length + 1);
|
||||
let current = this.root;
|
||||
for (const segment of relativeDirectory.split('/')) {
|
||||
current = `${current}/${segment}`;
|
||||
if (!this.directories.includes(current)) {
|
||||
fs.mkdirTree(current);
|
||||
this.directories.push(current);
|
||||
}
|
||||
}
|
||||
}
|
||||
fs.writeFile(absolute, file.data); this.files.push(absolute);
|
||||
}
|
||||
}
|
||||
|
||||
path(relative: string): string { return `${this.root}/${relative}`; }
|
||||
|
||||
readText(relative:string):string{return (this.module as ModuleWithFS).FS.readFile(this.path(relative),{encoding:'utf8'});}
|
||||
|
||||
writeGenerated(relative:string,data:Uint8Array):void {
|
||||
const absolute=this.path(relative);(this.module as ModuleWithFS).FS.writeFile(absolute,data);
|
||||
if(!this.files.includes(absolute))this.files.push(absolute);
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
const fs = (this.module as ModuleWithFS).FS;
|
||||
for (const file of this.files.reverse()) { try { fs.unlink(file); } catch { /* best-effort after failed mount */ } }
|
||||
for (const dir of this.directories.sort((a, b) => b.length - a.length)) { try { fs.rmdir(dir); } catch { /* parent or shared root */ } }
|
||||
this.files = []; this.directories = [];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import loadMujoco, {type MainModule} from '@mujoco/mujoco';
|
||||
import type {ProjectManifest} from '../project/types';
|
||||
import {prepareProjectForMujoco} from '../project/importer';
|
||||
import {groundConvertedMjcf,type UrdfBaseMode} from '../project/urdfToMjcf';
|
||||
import {MemfsWorkspace} from '../project/workspace';
|
||||
import {SimulationSession, type FrameResult, type SimulationSnapshot} from './SimulationSession';
|
||||
|
||||
export type UrdfLoadMode='mjcf'|'native';
|
||||
export type {UrdfBaseMode};
|
||||
|
||||
export interface PhysicsAdapter {
|
||||
load(manifest:ProjectManifest,entryPath:string,urdfMode?:UrdfLoadMode,baseMode?:UrdfBaseMode):Promise<SimulationSnapshot>;
|
||||
advance(now: number): FrameResult;
|
||||
snapshot(): SimulationSnapshot | null;
|
||||
setPaused(paused: boolean): void;
|
||||
setSpeed(speed: number): void;
|
||||
reset(): void;
|
||||
singleStep(): void;
|
||||
setActuator(id: number, value: number): void;
|
||||
setJointPosition(id: number, value: number): boolean;
|
||||
setExternalForce(bodyId: number, force: [number, number, number]): void;
|
||||
clearExternalForce(): void;
|
||||
dispose(): void;
|
||||
}
|
||||
|
||||
let modulePromise: Promise<MainModule> | undefined;
|
||||
export function getMujocoModule(): Promise<MainModule> {
|
||||
if (!modulePromise) {
|
||||
console.info('[MuJoCo] 开始初始化单线程 WASM');
|
||||
modulePromise = loadMujoco().then((module) => {
|
||||
console.info('[MuJoCo] WASM 初始化完成');
|
||||
return module;
|
||||
});
|
||||
}
|
||||
return modulePromise;
|
||||
}
|
||||
|
||||
export class MainThreadPhysicsAdapter implements PhysicsAdapter {
|
||||
session: SimulationSession | null = null;
|
||||
workspace: MemfsWorkspace | null = null;
|
||||
|
||||
async load(manifest:ProjectManifest,entryPath:string,urdfMode:UrdfLoadMode='mjcf',baseMode:UrdfBaseMode='floating'):Promise<SimulationSnapshot> {
|
||||
this.releaseCurrent();const module=await getMujocoModule();const workspace=new MemfsWorkspace(module,manifest.id);const prepared=prepareProjectForMujoco(manifest,entryPath);
|
||||
try{
|
||||
console.info('[MuJoCo] 写入 MEMFS',prepared.manifest.files.length);workspace.mount(prepared.manifest);
|
||||
const entry=prepared.manifest.entries.find(candidate=>candidate.path===entryPath);let modelPath=workspace.path(entryPath);const warnings=[...prepared.warnings];
|
||||
if(entry?.format==='urdf'&&urdfMode==='mjcf'){
|
||||
console.info('[MuJoCo] 编译 URDF 中间模型',entryPath);const intermediate=new SimulationSession(module,modelPath);
|
||||
try{
|
||||
const minimumZ=intermediate.minimumGeometryZ();const slash=entryPath.lastIndexOf('/');const directory=slash>=0?entryPath.slice(0,slash+1):'';const convertedPath=`${directory}.__mujoco_converted_${manifest.id.replace(/[^a-zA-Z0-9_-]/g,'_')}.xml`;
|
||||
if(module.mj_saveLastXML(workspace.path(convertedPath),intermediate.model)===0)throw new Error('MuJoCo 无法导出中间 MJCF');
|
||||
workspace.writeGenerated(convertedPath,groundConvertedMjcf(new TextEncoder().encode(workspace.readText(convertedPath)),minimumZ,baseMode));modelPath=workspace.path(convertedPath);
|
||||
warnings.push(`URDF 已转换为 MJCF(${baseMode==='floating'?'浮动基座':'固定基座'}),并整体平移 ${(-minimumZ).toFixed(4)} m,使最低点接触 z=0 地面`);
|
||||
}finally{intermediate.dispose();}
|
||||
}
|
||||
console.info('[MuJoCo] 编译模型',modelPath);const session=new SimulationSession(module,modelPath,warnings);
|
||||
if(entry?.format==='urdf'&&urdfMode==='native'){const offset=session.alignLowestPointToGround();warnings.push(`原生 URDF 已整体平移 ${offset.toFixed(4)} m,使最低点位于 z=0`);}
|
||||
if(warnings.length)console.warn('[MuJoCo] URDF 兼容处理',warnings);
|
||||
console.info('[MuJoCo] 模型编译完成');this.workspace=workspace;this.session=session;const snapshot=session.snapshot();console.info('[MuJoCo] 状态快照完成');return snapshot;
|
||||
}catch(error){workspace.dispose();throw new Error(`模型编译失败(${entryPath}):${error instanceof Error?error.message:String(error)}`,{cause:error});}
|
||||
}
|
||||
advance(now:number):FrameResult{return this.session?.advance(now)??{steps:0,stepMs:0,overBudget:false};}
|
||||
snapshot():SimulationSnapshot|null{return this.session?.snapshot()??null;}
|
||||
setPaused(value:boolean):void{this.session?.setPaused(value);}
|
||||
setSpeed(value:number):void{this.session?.setSpeed(value);}
|
||||
reset():void{this.session?.reset();}
|
||||
singleStep():void{this.session?.singleStep();}
|
||||
setActuator(id:number,value:number):void{this.session?.setActuator(id,value);}
|
||||
setJointPosition(id:number,value:number):boolean{return this.session?.setJointPosition(id,value)??false;}
|
||||
setExternalForce(bodyId:number,force:[number,number,number]):void{this.session?.setExternalForce(bodyId,force);}
|
||||
clearExternalForce():void{this.session?.clearExternalForce();}
|
||||
private releaseCurrent():void{this.session?.dispose(); this.session=null; this.workspace?.dispose(); this.workspace=null;}
|
||||
dispose():void{this.releaseCurrent();}
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
import type {MainModule, MjData, MjModel, MjvPerturb, MjvScene} from '@mujoco/mujoco';
|
||||
import {meshIdFromSceneDataId} from './geometry';
|
||||
|
||||
export interface ActuatorInfo {id: number; name: string; value: number; min: number; max: number; limited: boolean;}
|
||||
export interface JointInfo {id: number; name: string; type: number; value: number; min: number; max: number; limited: boolean; editable: boolean; bodyId: number; axis: [number, number, number];}
|
||||
export interface BodyInfo {id: number; name: string;}
|
||||
export interface SimulationSnapshot {time: number; qpos: number[]; qvel: number[]; ctrl: number[]; actuators: ActuatorInfo[]; joints: JointInfo[]; bodies: BodyInfo[]; warnings: string[]; model: {nbody: number; njnt: number; ngeom: number; nu: number; nq: number; nv: number};}
|
||||
export interface FrameResult {steps: number; stepMs: number; overBudget: boolean;}
|
||||
|
||||
export class SimulationSession {
|
||||
readonly model: MjModel;
|
||||
readonly data: MjData;
|
||||
readonly perturb: MjvPerturb;
|
||||
paused = true;
|
||||
speed = 1;
|
||||
readonly frameBudgetMs = 8;
|
||||
readonly maxCatchUpSteps = 100;
|
||||
private accumulator = 0;
|
||||
private lastNow?: number;
|
||||
private forceBody = -1;
|
||||
private force: [number, number, number] = [0, 0, 0];
|
||||
private disposed = false;
|
||||
|
||||
constructor(readonly module: MainModule, modelPath: string, readonly warnings: string[] = []) {
|
||||
let model: MjModel | undefined; let data: MjData | undefined; let perturb: MjvPerturb | undefined;
|
||||
try {
|
||||
model = module.MjModel.mj_loadXML(modelPath) ?? undefined;
|
||||
if (!model) throw new Error(`MuJoCo 无法编译模型:${modelPath}`);
|
||||
data = new module.MjData(model);
|
||||
perturb = new module.MjvPerturb(); module.mjv_defaultPerturb(perturb);
|
||||
this.model = model; this.data = data; this.perturb = perturb;
|
||||
module.mj_forward(model, data);
|
||||
} catch (error) { perturb?.delete(); data?.delete(); model?.delete(); throw error; }
|
||||
}
|
||||
|
||||
setPaused(paused: boolean): void {this.paused = paused; this.accumulator = 0; this.lastNow = undefined;}
|
||||
setSpeed(speed: number): void {this.speed = Math.min(4, Math.max(0.1, speed));}
|
||||
reset(): void {this.setPaused(true);this.module.mj_resetData(this.model,this.data);this.module.mj_forward(this.model,this.data);this.clearExternalForce();}
|
||||
singleStep(): void {this.applyForce(); this.module.mj_step(this.model, this.data);}
|
||||
|
||||
advance(now: number): FrameResult {
|
||||
if (this.lastNow === undefined) {this.lastNow = now; return {steps: 0, stepMs: 0, overBudget: false};}
|
||||
const elapsed = Math.min(0.1, Math.max(0, (now - this.lastNow) / 1000)); this.lastNow = now;
|
||||
if (this.paused) return {steps: 0, stepMs: 0, overBudget: false};
|
||||
this.accumulator += elapsed * this.speed;
|
||||
const dt = Number(this.model.opt.timestep) || 0.002; const started = performance.now(); let steps = 0;
|
||||
while (this.accumulator >= dt && steps < this.maxCatchUpSteps && performance.now() - started < this.frameBudgetMs) {
|
||||
this.applyForce(); this.module.mj_step(this.model, this.data); this.accumulator -= dt; steps++;
|
||||
}
|
||||
const overBudget = this.accumulator >= dt;
|
||||
if (steps >= this.maxCatchUpSteps) this.accumulator = Math.min(this.accumulator, dt);
|
||||
return {steps, stepMs: performance.now() - started, overBudget};
|
||||
}
|
||||
|
||||
setActuator(id: number, value: number): void {
|
||||
if (id < 0 || id >= this.model.nu) return;
|
||||
const actuator = this.model.actuator(id);
|
||||
try {
|
||||
const limited = Boolean(actuator.ctrllimited);
|
||||
const min = limited ? Number(actuator.ctrlrange[0]) : -1;
|
||||
const max = limited ? Number(actuator.ctrlrange[1]) : 1;
|
||||
const address = Number(this.model.actuator_ctrladr[id] ?? id);
|
||||
this.data.ctrl[address] = Math.min(max, Math.max(min, value));
|
||||
} finally {
|
||||
actuator.delete();
|
||||
}
|
||||
}
|
||||
|
||||
setJointPosition(id: number, value: number): boolean {
|
||||
if (id < 0 || id >= this.model.njnt) return false;
|
||||
const joint = this.model.jnt(id);
|
||||
try {
|
||||
const type = Number(joint.type);
|
||||
if (type !== 2 && type !== 3) return false;
|
||||
const limited = Boolean(joint.limited);
|
||||
const min = limited ? Number(joint.range[0]) : -Math.PI;
|
||||
const max = limited ? Number(joint.range[1]) : Math.PI;
|
||||
this.setPaused(true);
|
||||
this.data.qpos[Number(joint.qposadr)] = Math.min(max, Math.max(min, value));
|
||||
this.module.mj_forward(this.model, this.data);
|
||||
return true;
|
||||
} finally {
|
||||
joint.delete();
|
||||
}
|
||||
}
|
||||
|
||||
setExternalForce(bodyId: number, force: [number, number, number]): void {this.forceBody = bodyId > 0 && bodyId < this.model.nbody ? bodyId : -1; this.force = force;}
|
||||
clearExternalForce(): void {this.forceBody = -1; this.force = [0, 0, 0]; this.data.xfrc_applied.fill(0); this.perturb.active = 0;}
|
||||
initializePerturb(scene: MjvScene, bodyId: number): void {this.perturb.select = bodyId; this.module.mjv_initPerturb(this.model, this.data, scene, this.perturb);}
|
||||
applyPerturbForce(): void {if (this.forceBody > 0) this.module.mjv_applyPerturbForce(this.model, this.data, this.perturb);}
|
||||
|
||||
private applyForce(): void {
|
||||
this.data.xfrc_applied.fill(0); if (this.forceBody < 1) return;
|
||||
this.applyPerturbForce(); const offset = this.forceBody * 6;
|
||||
this.data.xfrc_applied[offset] += this.force[0]; this.data.xfrc_applied[offset + 1] += this.force[1]; this.data.xfrc_applied[offset + 2] += this.force[2];
|
||||
}
|
||||
|
||||
/** 用有限几何的包围球估算视图中心与范围,忽略地面等无限平面。 */
|
||||
geometryBounds():{center:[number,number,number];extent:number} {
|
||||
const lower=[Number.POSITIVE_INFINITY,Number.POSITIVE_INFINITY,Number.POSITIVE_INFINITY];const upper=[Number.NEGATIVE_INFINITY,Number.NEGATIVE_INFINITY,Number.NEGATIVE_INFINITY];
|
||||
for(let geom=0;geom<this.model.ngeom;geom+=1){if(Number(this.model.geom_type[geom])===this.module.mjtGeom.mjGEOM_PLANE.value)continue;const radius=Math.max(0,Number(this.model.geom_rbound[geom]));for(let axis=0;axis<3;axis+=1){const value=Number(this.data.geom_xpos[geom*3+axis]);lower[axis]=Math.min(lower[axis],value-radius);upper[axis]=Math.max(upper[axis],value+radius);}}
|
||||
if(!lower.every(Number.isFinite)||!upper.every(Number.isFinite))return {center:[0,0,0],extent:2};
|
||||
return {center:[(lower[0]+upper[0])/2,(lower[1]+upper[1])/2,(lower[2]+upper[2])/2],extent:Math.max(.5,upper[0]-lower[0],upper[1]-lower[1],upper[2]-lower[2])};
|
||||
}
|
||||
|
||||
/** 返回当前姿态全部模型几何(不含无限平面)的最低世界坐标。 */
|
||||
minimumGeometryZ():number {
|
||||
const option=new this.module.MjvOption();const camera=new this.module.MjvCamera();const scene=new this.module.MjvScene(this.model,32768);
|
||||
this.module.mjv_defaultOption(option);this.module.mjv_defaultCamera(camera);
|
||||
let minimum=Number.POSITIVE_INFINITY;
|
||||
try{
|
||||
this.module.mjv_updateScene(this.model,this.data,option,this.perturb,camera,this.module.mjtCatBit.mjCAT_ALL.value,scene);
|
||||
const geoms=scene.geoms;
|
||||
try{for(let index=0;index<geoms.size();index+=1){const geom=geoms.get(index);if(!geom)continue;try{
|
||||
if(geom.type===this.module.mjtGeom.mjGEOM_PLANE.value)continue;
|
||||
const z=this.geomMinimumZ(geom);if(Number.isFinite(z))minimum=Math.min(minimum,z);
|
||||
}finally{geom.delete();}}}finally{geoms.delete();}
|
||||
}finally{scene.delete();camera.delete();option.delete();}
|
||||
return Number.isFinite(minimum)?minimum:0;
|
||||
}
|
||||
|
||||
/** 平移所有世界根 body,使当前姿态的最低点位于 z=0。 */
|
||||
alignLowestPointToGround():number {
|
||||
const offset=-this.minimumGeometryZ();
|
||||
if(Math.abs(offset)<1e-9)return 0;
|
||||
for(let body=1;body<this.model.nbody;body+=1)if(Number(this.model.body_parentid[body])===0)this.model.body_pos[body*3+2]+=offset;
|
||||
this.module.mj_forward(this.model,this.data);
|
||||
return offset;
|
||||
}
|
||||
|
||||
private geomMinimumZ(geom:import('@mujoco/mujoco').MjvGeom):number {
|
||||
const m=this.module,type=geom.type,s=geom.size,r0=geom.mat[6],r1=geom.mat[7],r2=geom.mat[8],center=geom.pos[2];
|
||||
if(type===m.mjtGeom.mjGEOM_SPHERE.value)return center-s[0];
|
||||
if(type===m.mjtGeom.mjGEOM_BOX.value)return center-(Math.abs(r0)*s[0]+Math.abs(r1)*s[1]+Math.abs(r2)*s[2]);
|
||||
if(type===m.mjtGeom.mjGEOM_ELLIPSOID.value)return center-Math.hypot(r0*s[0],r1*s[1],r2*s[2]);
|
||||
if(type===m.mjtGeom.mjGEOM_CYLINDER.value)return center-(Math.hypot(r0,r1)*s[0]+Math.abs(r2)*s[2]);
|
||||
if(type===m.mjtGeom.mjGEOM_CAPSULE.value)return center-(s[0]+Math.abs(r2)*s[2]);
|
||||
if(type===m.mjtGeom.mjGEOM_MESH.value&&geom.dataid>=0){
|
||||
const id=meshIdFromSceneDataId(geom.dataid),first=Number(this.model.mesh_vertadr[id]),count=Number(this.model.mesh_vertnum[id]);let minimum=Number.POSITIVE_INFINITY;
|
||||
for(let vertex=0;vertex<count;vertex+=1){const offset=(first+vertex)*3;minimum=Math.min(minimum,center+r0*this.model.mesh_vert[offset]+r1*this.model.mesh_vert[offset+1]+r2*this.model.mesh_vert[offset+2]);}
|
||||
return minimum;
|
||||
}
|
||||
const radius=geom.size[0]||0;return center-radius;
|
||||
}
|
||||
|
||||
snapshot(): SimulationSnapshot {
|
||||
const actuators = Array.from({length: this.model.nu}, (_, id): ActuatorInfo => {
|
||||
const actuator = this.model.actuator(id);
|
||||
try {
|
||||
const limited = Boolean(actuator.ctrllimited);
|
||||
const address = Number(this.model.actuator_ctrladr[id] ?? id);
|
||||
return {id,name:actuator.name || `actuator_${id}`,value:Number(this.data.ctrl[address]),min:limited?Number(actuator.ctrlrange[0]):-1,max:limited?Number(actuator.ctrlrange[1]):1,limited};
|
||||
} finally {
|
||||
actuator.delete();
|
||||
}
|
||||
});
|
||||
const joints = Array.from({length: this.model.njnt}, (_, id): JointInfo => {
|
||||
const joint = this.model.jnt(id);
|
||||
try {
|
||||
const type=Number(joint.type); const limited=Boolean(joint.limited);
|
||||
return {id,name:joint.name || `joint_${id}`,type,value:Number(this.data.qpos[Number(joint.qposadr)]),min:limited?Number(joint.range[0]):-Math.PI,max:limited?Number(joint.range[1]):Math.PI,limited,editable:type===2||type===3,bodyId:Number(joint.bodyid),axis:[Number(joint.axis[0]),Number(joint.axis[1]),Number(joint.axis[2])]};
|
||||
} finally {
|
||||
joint.delete();
|
||||
}
|
||||
});
|
||||
const bodies = Array.from({length: this.model.nbody}, (_,id): BodyInfo => {
|
||||
const body = this.model.body(id);
|
||||
try { return {id,name:body.name || `body_${id}`}; }
|
||||
finally { body.delete(); }
|
||||
});
|
||||
return {time:Number(this.data.time),qpos:Array.from(this.data.qpos),qvel:Array.from(this.data.qvel),ctrl:Array.from(this.data.ctrl),actuators,joints,bodies,warnings:this.warnings,model:{nbody:this.model.nbody,njnt:this.model.njnt,ngeom:this.model.ngeom,nu:this.model.nu,nq:this.model.nq,nv:this.model.nv}};
|
||||
}
|
||||
dispose(): void {if(this.disposed)return; this.disposed=true; this.clearExternalForce(); this.perturb.delete(); this.data.delete(); this.model.delete();}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import {meshIdFromSceneDataId} from './geometry';
|
||||
|
||||
describe('meshIdFromSceneDataId',()=>{
|
||||
it('解析 mjvGeom 的完整 mesh/凸包编码',()=>{
|
||||
expect(meshIdFromSceneDataId(0)).toBe(0);
|
||||
expect(meshIdFromSceneDataId(1)).toBe(0);
|
||||
expect(meshIdFromSceneDataId(2)).toBe(1);
|
||||
expect(meshIdFromSceneDataId(15)).toBe(7);
|
||||
expect(meshIdFromSceneDataId(-1)).toBe(-1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* mjvGeom.dataid 对 mesh 编码为 2 * meshId;最低位表示是否显示凸包。
|
||||
* 它不能直接作为 mjModel.mesh_* 数组的索引。
|
||||
*/
|
||||
export function meshIdFromSceneDataId(dataId:number):number {
|
||||
return dataId<0?-1:Math.floor(dataId/2);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import {useAppStore} from './useAppStore';
|
||||
|
||||
describe('useAppStore.clearProject',()=>{
|
||||
afterEach(()=>useAppStore.getState().clearProject());
|
||||
|
||||
it('清空已导入工程及其运行状态',()=>{
|
||||
const store=useAppStore.getState();
|
||||
store.setProject('robot',[{path:'robot/model.xml',size:128}],[{path:'robot/model.xml',format:'mjcf',label:'model'}],'robot/model.xml');
|
||||
store.setLoading(true);
|
||||
store.setDiagnostic({category:'导入',summary:'错误',detail:'detail',at:1});
|
||||
|
||||
useAppStore.getState().clearProject();
|
||||
|
||||
expect(useAppStore.getState()).toMatchObject({
|
||||
projectName:undefined,
|
||||
files:[],
|
||||
entries:[],
|
||||
selectedEntry:undefined,
|
||||
loading:false,
|
||||
diagnostic:undefined,
|
||||
snapshot:undefined,
|
||||
selection:null,
|
||||
paused:true,
|
||||
fps:0,
|
||||
stepMs:0,
|
||||
overBudget:false,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
import {create} from 'zustand';
|
||||
import type {ModelEntry} from '../project/types';
|
||||
import type {SimulationSnapshot} from '../simulation/SimulationSession';
|
||||
import type {InteractionMode, ViewerSelection} from '../viewer/MuJoCoViewer';
|
||||
|
||||
export interface AppDiagnostic {category:'导入'|'ZIP'|'文件系统'|'模型编译'|'仿真'|'渲染';summary:string;detail:string;path?:string;at:number;}
|
||||
interface FileMeta {path:string;size:number;}
|
||||
interface AppState {
|
||||
projectName?:string; files:FileMeta[]; entries:ModelEntry[]; selectedEntry?:string;
|
||||
loading:boolean; diagnostic?:AppDiagnostic; snapshot?:SimulationSnapshot; selection:ViewerSelection|null;
|
||||
paused:boolean; speed:number; mode:InteractionMode; fps:number; stepMs:number; memoryMb?:number; overBudget:boolean;
|
||||
setProject(name:string,files:FileMeta[],entries:ModelEntry[],selectedEntry?:string):void;
|
||||
clearProject():void;
|
||||
setEntry(path:string):void; setLoading(value:boolean):void; setDiagnostic(value?:AppDiagnostic):void;
|
||||
setSnapshot(value?:SimulationSnapshot):void; setSelection(value:ViewerSelection|null):void;
|
||||
setPaused(value:boolean):void; setSpeed(value:number):void; setMode(value:InteractionMode):void;
|
||||
setMetrics(fps:number,stepMs:number,memoryMb:number|undefined,overBudget:boolean):void;
|
||||
}
|
||||
export const useAppStore=create<AppState>((set)=>({
|
||||
files:[],entries:[],loading:false,selection:null,paused:true,speed:1,mode:'select',fps:0,stepMs:0,overBudget:false,
|
||||
setProject:(projectName,files,entries,selectedEntry)=>set({projectName,files,entries,selectedEntry,snapshot:undefined,selection:null,diagnostic:undefined}),
|
||||
clearProject:()=>set({projectName:undefined,files:[],entries:[],selectedEntry:undefined,loading:false,diagnostic:undefined,snapshot:undefined,selection:null,paused:true,fps:0,stepMs:0,memoryMb:undefined,overBudget:false}),
|
||||
setEntry:(selectedEntry)=>set({selectedEntry}),setLoading:(loading)=>set({loading}),setDiagnostic:(diagnostic)=>set({diagnostic}),setSnapshot:(snapshot)=>set({snapshot}),setSelection:(selection)=>set({selection}),
|
||||
setPaused:(paused)=>set({paused}),setSpeed:(speed)=>set({speed}),setMode:(mode)=>set({mode}),setMetrics:(fps,stepMs,memoryMb,overBudget)=>set((s)=>({fps:fps||s.fps,stepMs,memoryMb:memoryMb??s.memoryMb,overBudget}))
|
||||
}));
|
||||
@@ -0,0 +1,5 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
@layer base {html,body,#root{height:100%;margin:0}body{font-family:Inter,"Noto Sans SC",system-ui,sans-serif}button,input,select{font:inherit}}
|
||||
@layer components {.btn{@apply rounded border border-slate-600 bg-slate-800 px-2.5 py-1.5 text-xs text-slate-100 transition hover:border-slate-400 hover:bg-slate-700 disabled:cursor-not-allowed disabled:opacity-40}.icon-btn{@apply btn min-w-8 text-sm}.field{@apply rounded border border-slate-600 bg-slate-800 px-2 py-1 text-xs}.panel{@apply bg-panel}.section{@apply border-b border-slate-700 p-3}.section h3{@apply mb-3 text-xs font-semibold uppercase tracking-wide text-slate-300}.section dt{@apply text-slate-400}.section dd{@apply text-right}.hint{@apply text-xs text-slate-500}}
|
||||
@@ -0,0 +1 @@
|
||||
import '@testing-library/jest-dom/vitest';
|
||||
@@ -0,0 +1,73 @@
|
||||
import * as THREE from 'three';
|
||||
import {OrbitControls} from 'three/examples/jsm/controls/OrbitControls.js';
|
||||
import type {MjvGeom, MjvOption, MjvCamera, MjvScene} from '@mujoco/mujoco';
|
||||
import type {FrameResult, SimulationSession, SimulationSnapshot} from '../simulation/SimulationSession';
|
||||
import {meshIdFromSceneDataId} from '../simulation/geometry';
|
||||
import {OrientationGizmo} from './OrientationGizmo';
|
||||
|
||||
export type InteractionMode = 'select' | 'joint' | 'force';
|
||||
export interface ViewerSelection {bodyId: number; geomId: number; bodyName: string; geomType: number; position: [number, number, number];}
|
||||
interface ViewerCallbacks {onSelection(selection: ViewerSelection | null): void; onFrame(frame: FrameResult, fps: number, snapshot?: SimulationSnapshot): void; onError(error: Error): void;}
|
||||
|
||||
class CapsuleGeometry extends THREE.BufferGeometry {
|
||||
constructor(radius:number,length:number) {super(); const path=new THREE.Path(); path.absarc(0,-length/2,radius,Math.PI*1.5,0); path.absarc(0,length/2,radius,0,Math.PI*.5); const source=new THREE.LatheGeometry(path.getPoints(24),16); this.copy(source); source.dispose(); this.rotateX(Math.PI/2);}
|
||||
}
|
||||
|
||||
export class MuJoCoViewer {
|
||||
readonly scene = new THREE.Scene();
|
||||
readonly camera = new THREE.PerspectiveCamera(45,1,.01,2_000);
|
||||
readonly renderer: THREE.WebGLRenderer;
|
||||
readonly controls: OrbitControls;
|
||||
mode: InteractionMode = 'select';
|
||||
forceScale = 50;
|
||||
private showCollision=false;
|
||||
private session: SimulationSession | null = null;
|
||||
private option: MjvOption | null = null;
|
||||
private mjCamera: MjvCamera | null = null;
|
||||
private mjScene: MjvScene | null = null;
|
||||
private frame = 0; private lastFpsAt=performance.now(); private fpsFrames=0; private lastSnapshotAt=0;
|
||||
private meshes: THREE.Mesh[]=[]; private geometries=new Map<string,THREE.BufferGeometry>(); private textures=new Map<number,THREE.DataTexture>();
|
||||
private raycaster=new THREE.Raycaster(); private pointer=new THREE.Vector2(); private selected:THREE.Mesh|null=null; private dragStart:THREE.Vector2|null=null; private dragAxis=new THREE.Vector2(1,0); private dragJointId=-1; private dragJointValue=0; private arrow:THREE.ArrowHelper|null=null;
|
||||
private resizeObserver:ResizeObserver;
|
||||
private orientationGizmo:OrientationGizmo;
|
||||
|
||||
constructor(private readonly host:HTMLElement, private readonly callbacks:ViewerCallbacks) {
|
||||
this.renderer=new THREE.WebGLRenderer({antialias:true,alpha:false}); this.renderer.setPixelRatio(Math.min(devicePixelRatio,2)); this.renderer.shadowMap.enabled=true; this.renderer.outputColorSpace=THREE.SRGBColorSpace; host.append(this.renderer.domElement);
|
||||
this.camera.up.set(0,0,1); this.camera.position.set(3,-3,2); this.controls=new OrbitControls(this.camera,this.renderer.domElement); this.controls.enableDamping=true;
|
||||
this.scene.background=new THREE.Color(0x0b1220); this.scene.add(new THREE.HemisphereLight(0xffffff,0x223344,1.3)); const light=new THREE.DirectionalLight(0xffffff,2); light.position.set(4,-3,7); light.castShadow=true; this.scene.add(light); this.scene.add(new THREE.GridHelper(20,40,0x3b82f6,0x253047).rotateX(Math.PI/2));this.orientationGizmo=new OrientationGizmo(host);this.orientationGizmo.update(this.camera);
|
||||
this.resizeObserver=new ResizeObserver(()=>this.resize()); this.resizeObserver.observe(host); this.resize();
|
||||
this.renderer.domElement.addEventListener('pointerdown',this.onPointerDown); this.renderer.domElement.addEventListener('pointermove',this.onPointerMove); window.addEventListener('pointerup',this.onPointerUp);
|
||||
this.frame=requestAnimationFrame(this.animate);
|
||||
}
|
||||
|
||||
attach(session:SimulationSession|null):void {this.releaseModel(); this.session=session; if(!session)return; this.option=new session.module.MjvOption(); session.module.mjv_defaultOption(this.option);this.applyGeomVisibility(); this.mjCamera=new session.module.MjvCamera(); session.module.mjv_defaultCamera(this.mjCamera); this.mjScene=new session.module.MjvScene(session.model,32768); this.fitCamera(session);}
|
||||
setMode(mode:InteractionMode):void {this.mode=mode; this.controls.enabled=mode==='select'; this.stopDrag();}
|
||||
setShowCollision(value:boolean):void {this.showCollision=value;this.applyGeomVisibility();}
|
||||
private applyGeomVisibility():void {if(!this.option||!this.session)return;let hasVisual=false;for(let index=0;index<this.session.model.ngeom;index+=1)if(Number(this.session.model.geom_group[index])===1){hasVisual=true;break;}this.option.geomgroup[0]=!hasVisual||this.showCollision?1:0;this.option.geomgroup[1]=1;this.option.geomgroup[5]=0;}
|
||||
resetCamera():void {if(this.session)this.fitCamera(this.session);}
|
||||
|
||||
private fitCamera(session:SimulationSession):void {const {extent,center}=session.geometryBounds();this.controls.target.set(center[0],center[1],center[2]);this.camera.position.set(center[0]+extent*1.5,center[1]-extent*1.5,center[2]+extent);this.camera.near=Math.max(.001,extent/1000);this.camera.far=Math.max(100,extent*100);this.camera.updateProjectionMatrix();this.controls.update();}
|
||||
private resize():void {const w=Math.max(1,this.host.clientWidth),h=Math.max(1,this.host.clientHeight); this.renderer.setSize(w,h,false); this.camera.aspect=w/h; this.camera.updateProjectionMatrix();}
|
||||
|
||||
private animate=(now:number):void=>{try {const result=this.session?.advance(now)??{steps:0,stepMs:0,overBudget:false}; this.controls.update();this.orientationGizmo.update(this.camera); if(this.session)this.updateMuJoCoScene(); this.renderer.render(this.scene,this.camera); this.fpsFrames++; let fps=0;if(now-this.lastFpsAt>=500){fps=this.fpsFrames*1000/(now-this.lastFpsAt);this.fpsFrames=0;this.lastFpsAt=now;} const snapshot=this.session&&now-this.lastSnapshotAt>150?(this.lastSnapshotAt=now,this.session.snapshot()):undefined; this.callbacks.onFrame(result,fps,snapshot);}catch(error){this.callbacks.onError(error instanceof Error?error:new Error(String(error)));} this.frame=requestAnimationFrame(this.animate);};
|
||||
|
||||
private updateMuJoCoScene():void {const s=this.session!; s.module.mjv_updateScene(s.model,s.data,this.option!,s.perturb,this.mjCamera!,s.module.mjtCatBit.mjCAT_ALL.value,this.mjScene!); const geoms=this.mjScene!.geoms; try {for(let i=0;i<geoms.size();i++){const geom=geoms.get(i);if(!geom)continue;try{let mesh=this.meshes[i];const key=this.geometryKey(geom);if(!mesh||mesh.userData.geometryKey!==key){if(mesh){this.scene.remove(mesh);this.disposeMesh(mesh);} mesh=this.createMesh(geom,key);this.meshes[i]=mesh;this.scene.add(mesh);} mesh.visible=true;this.updateMesh(mesh,geom);}finally{geom.delete();}} for(let i=geoms.size();i<this.meshes.length;i++)this.meshes[i].visible=false;}finally{geoms.delete();}}
|
||||
private geometryKey(g:MjvGeom):string {const dataId=g.type===this.session!.module.mjtGeom.mjGEOM_MESH.value?meshIdFromSceneDataId(g.dataid):g.dataid;return `${g.type}:${dataId}:${Array.from(g.size).join(',')}`;}
|
||||
private primitive(g:MjvGeom):THREE.BufferGeometry {const m=this.session!.module,t=g.type,s=g.size;if(t===m.mjtGeom.mjGEOM_PLANE.value)return new THREE.PlaneGeometry(2*(s[0]||1e3),2*(s[1]||1e3));if(t===m.mjtGeom.mjGEOM_SPHERE.value)return new THREE.SphereGeometry(s[0],24,16);if(t===m.mjtGeom.mjGEOM_CAPSULE.value)return new CapsuleGeometry(s[0],2*s[2]);if(t===m.mjtGeom.mjGEOM_BOX.value)return new THREE.BoxGeometry(2*s[0],2*s[1],2*s[2]);if(t===m.mjtGeom.mjGEOM_CYLINDER.value){const x=new THREE.CylinderGeometry(s[0],s[0],2*s[2],24);x.rotateX(Math.PI/2);return x;}if(t===m.mjtGeom.mjGEOM_ELLIPSOID.value){const x=new THREE.SphereGeometry(1,24,16);x.scale(s[0],s[1],s[2]);return x;}if(t===m.mjtGeom.mjGEOM_MESH.value&&g.dataid>=0)return this.meshGeometry(meshIdFromSceneDataId(g.dataid));return new THREE.BufferGeometry();}
|
||||
private meshGeometry(id:number):THREE.BufferGeometry {const m=this.session!.model;const va=Number(m.mesh_vertadr[id]),vn=Number(m.mesh_vertnum[id]),fa=Number(m.mesh_faceadr[id]),fn=Number(m.mesh_facenum[id]);const positions=new Float32Array(vn*3);for(let i=0;i<positions.length;i++)positions[i]=m.mesh_vert[va*3+i];const indices=new Uint32Array(fn*3);for(let i=0;i<indices.length;i++)indices[i]=m.mesh_face[fa*3+i];const geometry=new THREE.BufferGeometry();geometry.setAttribute('position',new THREE.BufferAttribute(positions,3));geometry.setIndex(new THREE.BufferAttribute(indices,1));const na=Number(m.mesh_normaladr[id]),nn=Number(m.mesh_normalnum[id]);if(nn===vn){const normals=new Float32Array(nn*3);for(let i=0;i<normals.length;i++)normals[i]=m.mesh_normal[na*3+i];geometry.setAttribute('normal',new THREE.BufferAttribute(normals,3));}else geometry.computeVertexNormals();const ta=Number(m.mesh_texcoordadr[id]),tn=Number(m.mesh_texcoordnum[id]);if(tn===vn&&ta>=0){const uv=new Float32Array(tn*2);for(let i=0;i<uv.length;i++)uv[i]=m.mesh_texcoord[ta*2+i];geometry.setAttribute('uv',new THREE.BufferAttribute(uv,2));}geometry.computeBoundingSphere();return geometry;}
|
||||
private texture(id:number):THREE.DataTexture|undefined {if(id<0)return;let found=this.textures.get(id);if(found)return found;const m=this.session!.model,w=Number(m.tex_width[id]),h=Number(m.tex_height[id]),channels=Number(m.tex_nchannel[id]||3),adr=Number(m.tex_adr[id]);if(!w||!h)return;const data=new Uint8Array(w*h*channels);for(let i=0;i<data.length;i++)data[i]=m.tex_data[adr+i];found=new THREE.DataTexture(data,w,h,channels===4?THREE.RGBAFormat:THREE.RGBFormat);found.colorSpace=THREE.SRGBColorSpace;found.flipY=true;found.needsUpdate=true;this.textures.set(id,found);return found;}
|
||||
private createMesh(g:MjvGeom,key:string):THREE.Mesh {let geometry=this.geometries.get(key);if(!geometry){geometry=this.primitive(g);this.geometries.set(key,geometry);}const map=this.texture(g.texid);const material=new THREE.MeshStandardMaterial({color:new THREE.Color(g.rgba[0],g.rgba[1],g.rgba[2]),opacity:g.rgba[3],transparent:g.rgba[3]<1,...(map?{map}:{}),roughness:Math.max(.05,1-g.shininess),metalness:g.reflectance});const mesh=new THREE.Mesh(geometry,material);mesh.matrixAutoUpdate=false;mesh.castShadow=true;mesh.receiveShadow=true;mesh.userData.geometryKey=key;return mesh;}
|
||||
private updateMesh(mesh:THREE.Mesh,g:MjvGeom):void {const mat=mesh.material as THREE.MeshStandardMaterial;mat.color.setRGB(g.rgba[0],g.rgba[1],g.rgba[2]);mat.opacity=g.rgba[3];mat.transparent=g.rgba[3]<1;mesh.matrix.set(g.mat[0],g.mat[1],g.mat[2],g.pos[0],g.mat[3],g.mat[4],g.mat[5],g.pos[1],g.mat[6],g.mat[7],g.mat[8],g.pos[2],0,0,0,1);mesh.matrixWorldNeedsUpdate=true;const geomId=g.objtype===this.session!.module.mjtObj.mjOBJ_GEOM.value?g.objid:-1;const bodyId=geomId>=0?Number(this.session!.model.geom_bodyid[geomId]):-1;mesh.userData.geomId=geomId;mesh.userData.bodyId=bodyId;mesh.userData.geomType=g.type;}
|
||||
|
||||
private eventPointer(event:PointerEvent):void {const r=this.renderer.domElement.getBoundingClientRect();this.pointer.set((event.clientX-r.left)/r.width*2-1,-((event.clientY-r.top)/r.height)*2+1);}
|
||||
private onPointerDown=(event:PointerEvent):void=>{this.eventPointer(event);this.raycaster.setFromCamera(this.pointer,this.camera);const hit=this.raycaster.intersectObjects(this.meshes.filter(m=>m.visible),false)[0];if(!hit)return;const mesh=hit.object as THREE.Mesh;this.select(mesh);const bodyId=Number(mesh.userData.bodyId);if(this.mode==='joint'){const joint=this.session?.snapshot().joints.find(j=>j.bodyId===bodyId&&j.editable);if(joint){this.dragStart=this.pointer.clone();this.dragJointId=joint.id;this.dragJointValue=joint.value;const p=joint.bodyId*3,xm=joint.bodyId*9;const origin=new THREE.Vector3(this.session!.data.xpos[p],this.session!.data.xpos[p+1],this.session!.data.xpos[p+2]);const local=new THREE.Vector3(...joint.axis);const axis=new THREE.Vector3(this.session!.data.xmat[xm]*local.x+this.session!.data.xmat[xm+1]*local.y+this.session!.data.xmat[xm+2]*local.z,this.session!.data.xmat[xm+3]*local.x+this.session!.data.xmat[xm+4]*local.y+this.session!.data.xmat[xm+5]*local.z,this.session!.data.xmat[xm+6]*local.x+this.session!.data.xmat[xm+7]*local.y+this.session!.data.xmat[xm+8]*local.z);const a=origin.clone().project(this.camera),b=origin.clone().add(axis).project(this.camera);this.dragAxis.set(b.x-a.x,b.y-a.y);if(this.dragAxis.lengthSq()<1e-6)this.dragAxis.set(1,0);else this.dragAxis.normalize();}}else if(this.mode==='force'&&bodyId>0){this.dragStart=this.pointer.clone();this.session?.initializePerturb(this.mjScene!,bodyId);this.showArrow(hit.point);this.renderer.domElement.setPointerCapture(event.pointerId);}};
|
||||
private onPointerMove=(event:PointerEvent):void=>{if(!this.dragStart||!this.session)return;this.eventPointer(event);const dx=this.pointer.x-this.dragStart.x,dy=this.pointer.y-this.dragStart.y;if(this.mode==='joint'&&this.dragJointId>=0)this.session.setJointPosition(this.dragJointId,this.dragJointValue+(dx*this.dragAxis.x+dy*this.dragAxis.y)*Math.PI);else if(this.mode==='force'&&this.selected){const bodyId=Number(this.selected.userData.bodyId),scale=this.forceScale;const force:[number,number,number]=[dx*scale,0,-dy*scale];this.session.setExternalForce(bodyId,force);this.updateArrow(force);}};
|
||||
private onPointerUp=():void=>{this.stopDrag();};
|
||||
private stopDrag():void {this.dragStart=null;this.dragJointId=-1;this.session?.clearExternalForce();if(this.arrow){this.scene.remove(this.arrow);this.arrow.dispose();this.arrow=null;}}
|
||||
private select(mesh:THREE.Mesh):void {if(this.selected){const old=this.selected.material as THREE.MeshStandardMaterial;old.emissive.setHex(0);}this.selected=mesh;const material=mesh.material as THREE.MeshStandardMaterial;material.emissive.setHex(0x14532d);const bodyId=Number(mesh.userData.bodyId),geomId=Number(mesh.userData.geomId);const name=this.session?.snapshot().bodies.find(b=>b.id===bodyId)?.name??`body_${bodyId}`;const e=mesh.matrix.elements;this.callbacks.onSelection({bodyId,geomId,bodyName:name,geomType:Number(mesh.userData.geomType),position:[e[12],e[13],e[14]]});}
|
||||
private showArrow(origin:THREE.Vector3):void {this.arrow=new THREE.ArrowHelper(new THREE.Vector3(1,0,0),origin,0.01,0xf97316);this.scene.add(this.arrow);}
|
||||
private updateArrow(force:[number,number,number]):void {if(!this.arrow)return;const v=new THREE.Vector3(...force),length=v.length()/25;if(length>1e-6){this.arrow.setDirection(v.normalize());this.arrow.setLength(length,Math.min(.2,length*.2),Math.min(.1,length*.1));}}
|
||||
private disposeMesh(mesh:THREE.Mesh):void {(mesh.material as THREE.Material).dispose();}
|
||||
private releaseModel():void {this.stopDrag();for(const mesh of this.meshes){this.scene.remove(mesh);this.disposeMesh(mesh);}this.meshes=[];for(const g of this.geometries.values())g.dispose();this.geometries.clear();for(const t of this.textures.values())t.dispose();this.textures.clear();this.mjScene?.delete();this.mjCamera?.delete();this.option?.delete();this.mjScene=null;this.mjCamera=null;this.option=null;this.session=null;this.selected=null;}
|
||||
dispose():void {cancelAnimationFrame(this.frame);this.releaseModel();this.resizeObserver.disconnect();this.renderer.domElement.removeEventListener('pointerdown',this.onPointerDown);this.renderer.domElement.removeEventListener('pointermove',this.onPointerMove);window.removeEventListener('pointerup',this.onPointerUp);this.controls.dispose();this.orientationGizmo.dispose();this.renderer.dispose();this.renderer.domElement.remove();}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import * as THREE from 'three';
|
||||
|
||||
const SVG_NS='http://www.w3.org/2000/svg';
|
||||
interface AxisElements {axis:THREE.Vector3;line:SVGLineElement;negative:SVGCircleElement;positive:SVGCircleElement;label:SVGTextElement;}
|
||||
|
||||
function svgElement<K extends keyof SVGElementTagNameMap>(name:K):SVGElementTagNameMap[K]{return document.createElementNS(SVG_NS,name);}
|
||||
|
||||
/** 固定在视口右下角、随相机旋转的 XYZ 方向示意器。 */
|
||||
export class OrientationGizmo {
|
||||
readonly element:SVGSVGElement;
|
||||
private readonly axes:AxisElements[]=[];
|
||||
private readonly inverseQuaternion=new THREE.Quaternion();
|
||||
private readonly projected=new THREE.Vector3();
|
||||
|
||||
constructor(host:HTMLElement){
|
||||
this.element=svgElement('svg');this.element.setAttribute('viewBox','0 0 100 100');this.element.setAttribute('role','img');this.element.setAttribute('aria-label','XYZ 方向指示器');
|
||||
Object.assign(this.element.style,{position:'absolute',right:'14px',bottom:'14px',width:'92px',height:'92px',pointerEvents:'none',border:'1px solid rgba(100,116,139,.45)',borderRadius:'10px',background:'rgba(15,23,42,.72)',backdropFilter:'blur(3px)'});
|
||||
const definitions:[string,number,THREE.Vector3][]=[['X',0xef4444,new THREE.Vector3(1,0,0)],['Y',0x22c55e,new THREE.Vector3(0,1,0)],['Z',0x3b82f6,new THREE.Vector3(0,0,1)]];
|
||||
for(const [name,colorValue,axis] of definitions){
|
||||
const color=`#${colorValue.toString(16).padStart(6,'0')}`;const group=svgElement('g');const line=svgElement('line');line.setAttribute('stroke',color);line.setAttribute('stroke-width','2.5');line.setAttribute('stroke-linecap','round');
|
||||
const negative=svgElement('circle');negative.setAttribute('r','4');negative.setAttribute('fill',color);negative.setAttribute('fill-opacity','.7');
|
||||
const positive=svgElement('circle');positive.setAttribute('r','8');positive.setAttribute('fill',color);
|
||||
const label=svgElement('text');label.textContent=name;label.setAttribute('fill','#0f172a');label.setAttribute('font-size','10');label.setAttribute('font-weight','700');label.setAttribute('text-anchor','middle');label.setAttribute('dominant-baseline','central');
|
||||
group.append(line,negative,positive,label);this.element.append(group);this.axes.push({axis,line,negative,positive,label});
|
||||
}
|
||||
const center=svgElement('circle');center.setAttribute('cx','50');center.setAttribute('cy','50');center.setAttribute('r','3.5');center.setAttribute('fill','#cbd5e1');this.element.append(center);host.append(this.element);
|
||||
}
|
||||
|
||||
update(camera:THREE.Camera):void {
|
||||
this.inverseQuaternion.copy(camera.quaternion).invert();
|
||||
for(const item of this.axes){
|
||||
this.projected.copy(item.axis).applyQuaternion(this.inverseQuaternion);const x=50+this.projected.x*30,y=50-this.projected.y*30;const nx=50-this.projected.x*30,ny=50+this.projected.y*30;
|
||||
item.line.setAttribute('x1',String(nx));item.line.setAttribute('y1',String(ny));item.line.setAttribute('x2',String(x));item.line.setAttribute('y2',String(y));
|
||||
item.negative.setAttribute('cx',String(nx));item.negative.setAttribute('cy',String(ny));item.positive.setAttribute('cx',String(x));item.positive.setAttribute('cy',String(y));item.label.setAttribute('x',String(x));item.label.setAttribute('y',String(y+.5));
|
||||
const opacity=String(.65+.35*Math.max(0,this.projected.z));item.positive.setAttribute('fill-opacity',opacity);item.label.setAttribute('fill-opacity',opacity);
|
||||
}
|
||||
}
|
||||
|
||||
dispose():void {this.element.remove();}
|
||||
}
|
||||
Reference in New Issue
Block a user