feat(web-platform): release V0.1
This commit is contained in:
@@ -5,10 +5,12 @@ const fixture = (relative: string) => fileURLToPath(new URL(`../fixtures/${relat
|
||||
|
||||
const SIMPLE_MODEL = `
|
||||
<mujoco model="e2e">
|
||||
<compiler angle="radian"/>
|
||||
<worldbody>
|
||||
<light pos="0 0 3"/>
|
||||
<body name="box" pos="0 0 1">
|
||||
<joint name="slide" type="slide" axis="1 0 0" range="-1 1"/>
|
||||
<joint name="hinge" type="hinge" axis="0 1 0" range="-1.57079632679 1.57079632679"/>
|
||||
<geom name="box_geom" type="box" size=".2 .2 .2" mass="1"/>
|
||||
</body>
|
||||
</worldbody>
|
||||
@@ -39,6 +41,12 @@ test('显示中文平台骨架并加载单文件模型', async ({page}) => {
|
||||
await expect(page.getByRole('heading', {name: 'MuJoCo Web 仿真平台'})).toBeVisible();
|
||||
await expect(page.getByRole('main').getByText('拖放模型工程到此处')).toBeVisible();
|
||||
await expect(page.getByRole('img',{name:'XYZ 方向指示器'})).toBeVisible();
|
||||
await expect(page.getByRole('button',{name:'切换到白天主题'})).toHaveText('☀');
|
||||
await page.getByRole('button',{name:'切换到白天主题'}).click();
|
||||
await expect(page.locator('#root > div')).toHaveClass(/theme-light/);
|
||||
await expect(page.getByRole('button',{name:'切换到黑夜主题'})).toHaveText('☾');
|
||||
await page.getByRole('button',{name:'切换到黑夜主题'}).click();
|
||||
await expect(page.locator('#root > div')).toHaveClass(/theme-dark/);
|
||||
|
||||
await page.locator('input[type="file"]').first().setInputFiles({
|
||||
name: 'model.xml',
|
||||
@@ -48,7 +56,21 @@ test('显示中文平台骨架并加载单文件模型', async ({page}) => {
|
||||
|
||||
await expect(page.getByText('WASM 已加载')).toBeVisible({timeout: 30_000});
|
||||
await expect(page.getByText('motor')).toBeVisible();
|
||||
await expect(page.getByText('slide')).toBeVisible();
|
||||
await expect(page.getByText('slide').first()).toBeVisible();
|
||||
const structure=page.getByRole('navigation',{name:'模型结构树'});await expect(structure).toBeVisible();await structure.getByRole('treeitem',{name:/hinge/}).hover();
|
||||
await expect(page.getByRole('alert')).toHaveCount(0);
|
||||
await expect(page.getByRole('button',{name:'重置关节'})).toBeVisible();
|
||||
await page.getByRole('button',{name:'高级'}).click();
|
||||
await expect(page.getByText('下限 -1.571 rad')).toBeVisible();
|
||||
await expect(page.getByText('上限 1.571 rad')).toBeVisible();
|
||||
await page.getByRole('button',{name:'rad 弧度制'}).click();
|
||||
await expect(page.getByText('下限 -90.000°')).toBeVisible();
|
||||
await expect(page.getByText('上限 90.000°')).toBeVisible();
|
||||
await page.getByRole('button',{name:'忽略关节限位'}).click();
|
||||
await expect(page.getByRole('button',{name:'忽略关节限位'})).toHaveAttribute('aria-pressed','true');
|
||||
await expect(page.getByText('已忽略').first()).toBeVisible();
|
||||
await page.getByRole('button',{name:'重置关节'}).click();
|
||||
await expect(page.getByRole('button',{name:'▶ 播放'})).toBeVisible();
|
||||
});
|
||||
|
||||
test('加载包含 include、OBJ、STL 与 PNG 的工程', async ({page}) => {
|
||||
|
||||
@@ -4,18 +4,20 @@ import {useCallback,useEffect,useRef,useState,type ChangeEvent,type DragEvent} f
|
||||
import type {ProjectManifest} from '../project/types';
|
||||
import {filesFromDrop,importBrowserFiles,ProjectImportError} from '../project/importer';
|
||||
import {ProjectTree} from '../project/ProjectTree';
|
||||
import {ModelStructureTree} from '../project/ModelStructureTree';
|
||||
import {MainThreadPhysicsAdapter,type UrdfBaseMode,type UrdfLoadMode} from '../simulation/PhysicsAdapter';
|
||||
import {MuJoCoViewer,type InteractionMode} from '../viewer/MuJoCoViewer';
|
||||
import {MuJoCoViewer,type InteractionMode,type ViewerTheme} 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:'外力施加'};
|
||||
function initialTheme():ViewerTheme{try{return localStorage.getItem('mujoco-platform-theme')==='light'?'light':'dark';}catch{return'dark';}}
|
||||
|
||||
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);
|
||||
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);const [theme,setTheme]=useState<ViewerTheme>(initialTheme);const [jointAdvanced,setJointAdvanced]=useState(false);const [ignoreJointLimits,setIgnoreJointLimits]=useState(false);const [angleUnit,setAngleUnit]=useState<'rad'|'deg'>('rad');
|
||||
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);}},[]);
|
||||
useEffect(()=>{viewer.current?.setMode(state.mode);},[state.mode]); useEffect(()=>{if(viewer.current)viewer.current.forceScale=forceScale;},[forceScale]);useEffect(()=>{viewer.current?.setShowCollision(showCollision);},[showCollision]);useEffect(()=>{viewer.current?.setTheme(theme);document.documentElement.style.colorScheme=theme;try{localStorage.setItem('mujoco-platform-theme',theme);}catch{/* 浏览器禁用存储时仍可在当前会话切换 */}},[theme]);
|
||||
const loadEntry=useCallback(async(path:string,requestedMode?:UrdfLoadMode)=>{if(!manifest.current)return;setIgnoreJointLimits(false);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);};
|
||||
@@ -27,12 +29,14 @@ export function App(){
|
||||
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);};
|
||||
const resetJoints=()=>{adapter.current.resetJoints();state.setPaused(true);state.setSnapshot(adapter.current.snapshot()??undefined);};
|
||||
const toggleJointLimits=()=>{const next=!ignoreJointLimits;adapter.current.setIgnoreJointLimits(next);setIgnoreJointLimits(next);state.setSnapshot(adapter.current.snapshot()??undefined);};
|
||||
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>}
|
||||
return <div className={`${theme==='light'?'theme-light':'theme-dark'} 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><button className="icon-btn" aria-label={theme==='dark'?'切换到白天主题':'切换到黑夜主题'} title={theme==='dark'?'切换到白天主题':'切换到黑夜主题'} onClick={()=>setTheme(value=>value==='dark'?'light':'dark')}><span aria-hidden="true">{theme==='dark'?'☀':'☾'}</span></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>{state.snapshot&&<section className="border-t border-slate-700 px-2 pb-4"><h3 className="px-1 py-2 text-xs font-semibold uppercase tracking-wide text-slate-300">模型结构</h3><ModelStructureTree bodies={state.snapshot.bodies} joints={state.snapshot.joints} onJointHover={jointId=>viewer.current?.highlightJoint(jointId)}/></section>}</>:<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>
|
||||
{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><div className="mb-4 grid grid-cols-2 gap-2"><button className="btn" onClick={resetJoints}>重置关节</button><button className={`btn ${ignoreJointLimits?'border-amber-500 bg-amber-950/50':''}`} aria-pressed={ignoreJointLimits} onClick={toggleJointLimits}>忽略关节限位</button><button className={`btn ${jointAdvanced?'border-green-500 bg-green-900/50':''}`} aria-pressed={jointAdvanced} onClick={()=>setJointAdvanced(value=>!value)}>高级</button><button className={`btn ${angleUnit==='deg'?'border-green-500 bg-green-900/50':''}`} aria-pressed={angleUnit==='deg'} onClick={()=>setAngleUnit(value=>value==='rad'?'deg':'rad')}>{angleUnit==='rad'?'rad 弧度制':'° 角度制'}</button></div>{state.snapshot.joints.map(j=>{const scale=j.type===3&&angleUnit==='deg'?180/Math.PI:1,unit=j.type===3?(angleUnit==='deg'?'°':' rad'):j.type===2?' m':'';return <ControlSlider key={j.id} label={`${j.name}${j.editable?'':'(只读)'}`} value={j.value*scale} min={j.min*scale} max={j.max*scale} unit={unit} advanced={jointAdvanced} limited={j.limited} limitsIgnored={j.limitsIgnored} limitMin={j.limitMin*scale} limitMax={j.limitMax*scale} disabled={!j.editable} onChange={v=>{adapter.current.setJointPosition(j.id,v/scale);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>;
|
||||
}
|
||||
@@ -40,4 +44,4 @@ function PanelTitle({children}:{children:string}){return <h2 className="sticky t
|
||||
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>}
|
||||
function ControlSlider({label,value,min,max,onChange,disabled=false,unit='',advanced=false,limited=false,limitsIgnored=false,limitMin=0,limitMax=0}:{label:string;value:number;min:number;max:number;onChange:(v:number)=>void;disabled?:boolean;unit?:string;advanced?:boolean;limited?:boolean;limitsIgnored?:boolean;limitMin?:number;limitMax?:number}){const sane=Number.isFinite(value)?value:0,format=(number:number)=>`${number.toFixed(3)}${unit}`;return <label className="mb-3 block text-xs"><span className="mb-1 flex justify-between"><span className="truncate pr-2">{label}</span><output>{format(sane)}</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))}/>{advanced&&<span className="mt-1 flex justify-between text-[10px] text-slate-500"><span>下限 {limited?format(limitMin):'无限制'}</span>{limitsIgnored&&limited&&<span className="text-amber-400">已忽略</span>}<span>上限 {limited?format(limitMax):'无限制'}</span></span>}</label>}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import {fireEvent,render,screen} from '@testing-library/react';
|
||||
import {buildBodyTree,ModelStructureTree} from './ModelStructureTree';
|
||||
import type {BodyInfo,JointInfo} from '../simulation/SimulationSession';
|
||||
|
||||
const bodies:BodyInfo[]=[{id:0,name:'world',parentId:0},{id:1,name:'base',parentId:0},{id:2,name:'arm',parentId:1}];
|
||||
const joints:JointInfo[]=[{id:0,name:'arm_joint',type:3,value:0,min:-1,max:1,limitMin:-1,limitMax:1,limited:true,limitsIgnored:false,editable:true,bodyId:2,axis:[0,0,1]}];
|
||||
|
||||
describe('ModelStructureTree',()=>{
|
||||
it('按 body 父子关系构建结构,并将关节放在所属 body 下',()=>{
|
||||
const tree=buildBodyTree(bodies,joints);
|
||||
expect(tree[0]).toMatchObject({id:1,name:'base'});
|
||||
expect(tree[0].children[0]).toMatchObject({id:2,name:'arm'});
|
||||
expect(tree[0].children[0].joints[0].name).toBe('arm_joint');
|
||||
});
|
||||
|
||||
it('鼠标进入和离开关节时通知查看器高亮',()=>{
|
||||
const hover=vi.fn();render(<ModelStructureTree bodies={bodies} joints={joints} onJointHover={hover}/>);const item=screen.getByRole('treeitem',{name:/arm_joint/});
|
||||
fireEvent.mouseEnter(item);fireEvent.mouseLeave(item);expect(hover.mock.calls).toEqual([[0],[null]]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,22 @@
|
||||
import {useState} from 'react';
|
||||
import type {BodyInfo,JointInfo} from '../simulation/SimulationSession';
|
||||
|
||||
interface BodyNode extends BodyInfo {children:BodyNode[];joints:JointInfo[];}
|
||||
|
||||
// eslint-disable-next-line react-refresh/only-export-components
|
||||
export function buildBodyTree(bodies:BodyInfo[],joints:JointInfo[]):BodyNode[]{
|
||||
const nodes=new Map<number,BodyNode>();for(const body of bodies)if(body.id>0)nodes.set(body.id,{...body,children:[],joints:joints.filter(joint=>joint.bodyId===body.id)});
|
||||
const roots:BodyNode[]=[];
|
||||
for(const node of nodes.values()){const parent=nodes.get(node.parentId);if(parent)parent.children.push(node);else roots.push(node);}
|
||||
const sort=(items:BodyNode[])=>{items.sort((a,b)=>a.id-b.id);for(const item of items)sort(item.children);};sort(roots);return roots;
|
||||
}
|
||||
|
||||
function BodyBranch({node,depth,onJointHover}:{node:BodyNode;depth:number;onJointHover:(jointId:number|null)=>void}){
|
||||
const hasChildren=node.joints.length>0||node.children.length>0;const [open,setOpen]=useState(depth<2);
|
||||
return <li>{hasChildren?<details open={open} onToggle={event=>setOpen(event.currentTarget.open)}><summary className="cursor-pointer select-none truncate rounded px-1.5 py-1 text-xs text-slate-300 hover:bg-slate-800"><span aria-hidden="true" className="mr-1 text-sky-400">◆</span>{node.name}</summary><ul role="group" className="ml-3 border-l border-slate-800 pl-1">{node.joints.map(joint=><li key={joint.id}><span role="treeitem" tabIndex={0} className="block cursor-default truncate rounded px-1.5 py-1 text-xs text-amber-300 hover:bg-amber-950/40 hover:text-amber-200 focus:bg-amber-950/40 focus:outline-none" onMouseEnter={()=>onJointHover(joint.id)} onMouseLeave={()=>onJointHover(null)} onFocus={()=>onJointHover(joint.id)} onBlur={()=>onJointHover(null)} title={`关节:${joint.name}`}><span aria-hidden="true" className="mr-1">○</span>{joint.name}</span></li>)}{node.children.map(child=><BodyBranch key={child.id} node={child} depth={depth+1} onJointHover={onJointHover}/>)}</ul></details>:<div className="truncate rounded px-1.5 py-1 text-xs text-slate-300"><span aria-hidden="true" className="mr-1 text-sky-400">◆</span>{node.name}</div>}</li>;
|
||||
}
|
||||
|
||||
export function ModelStructureTree({bodies,joints,onJointHover}:{bodies:BodyInfo[];joints:JointInfo[];onJointHover:(jointId:number|null)=>void}){
|
||||
const roots=buildBodyTree(bodies,joints);
|
||||
return <nav aria-label="模型结构树"><ul role="tree">{roots.map(root=><BodyBranch key={root.id} node={root} depth={0} onJointHover={onJointHover}/>)}</ul></nav>;
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import {render,screen,within} from '@testing-library/react';
|
||||
import {fireEvent,render,screen,within} from '@testing-library/react';
|
||||
import {buildProjectTree,ProjectTree} from './ProjectTree';
|
||||
|
||||
const files=[
|
||||
@@ -20,12 +20,13 @@ describe('ProjectTree',()=>{
|
||||
});
|
||||
|
||||
it('以可折叠目录显示文件名,而不是平铺完整路径',()=>{
|
||||
render(<ProjectTree files={files} entries={[{path:'robot/model.xml',format:'mjcf',label:'model'}]} selectedEntry="robot/model.xml"/>);
|
||||
const tree=screen.getByRole('navigation',{name:'工程文件树'});
|
||||
expect(within(tree).getByText('robot')).toBeVisible();
|
||||
expect(within(tree).getByText('meshes')).toBeVisible();
|
||||
render(<ProjectTree files={files} entries={[{path:'robot/model.xml',format:'urdf',label:'model'}]} selectedEntry="robot/model.xml"/>);
|
||||
const tree=screen.getByRole('navigation',{name:'工程文件树'}),robot=within(tree).getByText('robot'),meshes=within(tree).getByText('meshes');
|
||||
expect(robot.closest('details')).toHaveAttribute('open');
|
||||
expect(meshes.closest('details')).not.toHaveAttribute('open');
|
||||
fireEvent.click(meshes);
|
||||
expect(within(tree).getByText('arm.obj')).toBeVisible();
|
||||
expect(within(tree).queryByText('robot/meshes/arm.obj')).not.toBeInTheDocument();
|
||||
expect(within(tree).getByText('mjcf')).toBeVisible();
|
||||
expect(within(tree).getByText('urdf')).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import {useState} from 'react';
|
||||
import type {ModelEntry} from './types';
|
||||
|
||||
export interface ProjectTreeFile {path:string;size:number;}
|
||||
@@ -52,21 +53,15 @@ function formatSize(bytes:number):string {
|
||||
return `${(bytes/(1024*1024)).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
function TreeNodes({nodes,entryFormats,selectedEntry}:{nodes:ProjectTreeNode[];entryFormats:Map<string,ModelEntry['format']>;selectedEntry?:string}){
|
||||
return <ul role="group" className="ml-3 border-l border-slate-800 pl-1">
|
||||
{nodes.map(node=>node.kind==='directory'?<li key={`d:${node.path}`}>
|
||||
<details open>
|
||||
<summary title={node.path} className="cursor-pointer select-none truncate rounded px-1.5 py-1 text-xs text-slate-300 hover:bg-slate-800"><span aria-hidden="true" className="mr-1">📁</span>{node.name}</summary>
|
||||
<TreeNodes nodes={node.children??[]} entryFormats={entryFormats} selectedEntry={selectedEntry}/>
|
||||
</details>
|
||||
</li>:<li key={`f:${node.path}`} title={node.path} className={`flex min-w-0 items-center gap-1 rounded px-1.5 py-1 text-xs ${selectedEntry===node.path?'bg-green-900/40 text-green-200':'text-slate-300 hover:bg-slate-800'}`}>
|
||||
<span aria-hidden="true">{entryFormats.has(node.path)?'◇':'▧'}</span><span className="min-w-0 flex-1 truncate">{node.name}</span>{entryFormats.has(node.path)&&<span className="shrink-0 text-[10px] uppercase text-green-400">{entryFormats.get(node.path)}</span>}<span className="shrink-0 text-[10px] text-slate-500">{formatSize(node.size??0)}</span>
|
||||
</li>)}
|
||||
</ul>;
|
||||
interface TreeNodeProps {entryFormats:Map<string,ModelEntry['format']>;selectedEntry?:string;expandedEntry?:string;}
|
||||
function DirectoryNode({node,entryFormats,selectedEntry,expandedEntry}:TreeNodeProps&{node:ProjectTreeNode}){const [open,setOpen]=useState(Boolean(expandedEntry?.startsWith(`${node.path}/`)));return <li><details open={open} onToggle={event=>setOpen(event.currentTarget.open)}><summary title={node.path} className="cursor-pointer select-none truncate rounded px-1.5 py-1 text-xs text-slate-300 hover:bg-slate-800"><span aria-hidden="true" className="mr-1">📁</span>{node.name}</summary><TreeNodes nodes={node.children??[]} entryFormats={entryFormats} selectedEntry={selectedEntry} expandedEntry={expandedEntry}/></details></li>;}
|
||||
function TreeNodes({nodes,entryFormats,selectedEntry,expandedEntry}:TreeNodeProps&{nodes:ProjectTreeNode[]}){
|
||||
return <ul role="group" className="ml-3 border-l border-slate-800 pl-1">{nodes.map(node=>node.kind==='directory'?<DirectoryNode key={`d:${node.path}`} node={node} entryFormats={entryFormats} selectedEntry={selectedEntry} expandedEntry={expandedEntry}/>:<li key={`f:${node.path}`} title={node.path} className={`flex min-w-0 items-center gap-1 rounded px-1.5 py-1 text-xs ${selectedEntry===node.path?'bg-green-900/40 text-green-200':'text-slate-300 hover:bg-slate-800'}`}><span aria-hidden="true">{entryFormats.has(node.path)?'◇':'▧'}</span><span className="min-w-0 flex-1 truncate">{node.name}</span>{entryFormats.has(node.path)&&<span className="shrink-0 text-[10px] uppercase text-green-400">{entryFormats.get(node.path)}</span>}<span className="shrink-0 text-[10px] text-slate-500">{formatSize(node.size??0)}</span></li>)}</ul>;
|
||||
}
|
||||
|
||||
export function ProjectTree({files,entries,selectedEntry}:{files:ProjectTreeFile[];entries:ModelEntry[];selectedEntry?:string}){
|
||||
const nodes=buildProjectTree(files);
|
||||
const entryFormats=new Map(entries.map(entry=>[entry.path,entry.format]));
|
||||
return <nav aria-label="工程文件树"><TreeNodes nodes={nodes} entryFormats={entryFormats} selectedEntry={selectedEntry}/></nav>;
|
||||
const expandedEntry=entries.some(entry=>entry.path===selectedEntry&&entry.format==='urdf')?selectedEntry:undefined;
|
||||
return <nav aria-label="工程文件树"><TreeNodes key={expandedEntry??'collapsed'} nodes={nodes} entryFormats={entryFormats} selectedEntry={selectedEntry} expandedEntry={expandedEntry}/></nav>;
|
||||
}
|
||||
|
||||
@@ -17,7 +17,9 @@ export interface PhysicsAdapter {
|
||||
reset(): void;
|
||||
singleStep(): void;
|
||||
setActuator(id: number, value: number): void;
|
||||
setJointPosition(id: number, value: number): boolean;
|
||||
setJointPosition(id:number,value:number):boolean;
|
||||
resetJoints():void;
|
||||
setIgnoreJointLimits(ignore:boolean):void;
|
||||
setExternalForce(bodyId: number, force: [number, number, number]): void;
|
||||
clearExternalForce(): void;
|
||||
dispose(): void;
|
||||
@@ -67,6 +69,8 @@ export class MainThreadPhysicsAdapter implements PhysicsAdapter {
|
||||
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;}
|
||||
resetJoints():void {this.session?.resetJoints();}
|
||||
setIgnoreJointLimits(ignore:boolean):void {this.session?.setIgnoreJointLimits(ignore);}
|
||||
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;}
|
||||
|
||||
@@ -2,8 +2,8 @@ import type {MainModule, MjData, MjModel, MjvPerturb, MjvScene} from '@mujoco/mu
|
||||
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 JointInfo {id:number;name:string;type:number;value:number;min:number;max:number;limitMin:number;limitMax:number;limited:boolean;limitsIgnored:boolean;editable:boolean;bodyId:number;axis:[number,number,number];}
|
||||
export interface BodyInfo {id:number;name:string;parentId:number;}
|
||||
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;}
|
||||
|
||||
@@ -19,7 +19,9 @@ export class SimulationSession {
|
||||
private lastNow?: number;
|
||||
private forceBody = -1;
|
||||
private force: [number, number, number] = [0, 0, 0];
|
||||
private disposed = false;
|
||||
private disposed=false;
|
||||
private ignoreJointLimits=false;
|
||||
private jointLimits:{limited:boolean;min:number;max:number;type:number}[]=[];
|
||||
|
||||
constructor(readonly module: MainModule, modelPath: string, readonly warnings: string[] = []) {
|
||||
let model: MjModel | undefined; let data: MjData | undefined; let perturb: MjvPerturb | undefined;
|
||||
@@ -28,8 +30,9 @@ export class SimulationSession {
|
||||
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);
|
||||
this.model=model;this.data=data;this.perturb=perturb;
|
||||
this.jointLimits=Array.from({length:model.njnt},(_,id)=>{const joint=model!.jnt(id);try{return {limited:Boolean(joint.limited),min:Number(joint.range[0]),max:Number(joint.range[1]),type:Number(joint.type)};}finally{joint.delete();}});
|
||||
module.mj_forward(model,data);
|
||||
} catch (error) { perturb?.delete(); data?.delete(); model?.delete(); throw error; }
|
||||
}
|
||||
|
||||
@@ -66,24 +69,15 @@ export class SimulationSession {
|
||||
}
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
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 original=this.jointLimits[id];const next=!this.ignoreJointLimits&&original.limited?Math.min(original.max,Math.max(original.min,value)):value;this.setPaused(true);this.data.qpos[Number(joint.qposadr)]=next;this.module.mj_forward(this.model,this.data);return true;}finally{joint.delete();}
|
||||
}
|
||||
|
||||
resetJoints():void {this.setPaused(true);for(let id=0;id<this.model.njnt;id+=1){const joint=this.model.jnt(id);try{const type=Number(joint.type);if(type!==2&&type!==3)continue;this.data.qpos[Number(joint.qposadr)]=Number(joint.qpos0);this.data.qvel[Number(joint.dofadr)]=0;}finally{joint.delete();}}this.module.mj_forward(this.model,this.data);}
|
||||
|
||||
setIgnoreJointLimits(ignore:boolean):void {this.ignoreJointLimits=ignore;for(let id=0;id<this.model.njnt;id+=1){const joint=this.model.jnt(id);try{const original=this.jointLimits[id];if(!original||!original.limited)continue;joint.limited=ignore?0:1;if(!ignore&&(original.type===2||original.type===3)){const address=Number(joint.qposadr);this.data.qpos[address]=Math.min(original.max,Math.max(original.min,Number(this.data.qpos[address])));}}finally{joint.delete();}}this.module.mj_forward(this.model,this.data);}
|
||||
|
||||
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);}
|
||||
@@ -157,15 +151,16 @@ export class SimulationSession {
|
||||
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])]};
|
||||
const original=this.jointLimits[id],type=Number(joint.type),limited=original.limited;let min=limited?original.min:(type===2?-1:-Math.PI),max=limited?original.max:(type===2?1:Math.PI);
|
||||
if(this.ignoreJointLimits){if(type===3){min=-2*Math.PI;max=2*Math.PI;}else if(type===2){const span=limited?Math.max(.25,original.max-original.min):1;min=limited?original.min-span:-1;max=limited?original.max+span:1;}}
|
||||
return {id,name:joint.name||`joint_${id}`,type,value:Number(this.data.qpos[Number(joint.qposadr)]),min,max,limitMin:original.min,limitMax:original.max,limited,limitsIgnored:this.ignoreJointLimits,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}`}; }
|
||||
try {return {id,name:body.name||`body_${id}`,parentId:Number(this.model.body_parentid[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}};
|
||||
|
||||
@@ -3,3 +3,25 @@
|
||||
@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}}
|
||||
|
||||
/* 主题切换使用柔和过渡,避免大面积背景瞬间翻转造成刺眼。 */
|
||||
.theme-light,.theme-dark,.theme-light *,.theme-dark *{transition-property:background-color,color,border-color,box-shadow,opacity;transition-duration:420ms;transition-timing-function:cubic-bezier(.22,1,.36,1)}
|
||||
@media (prefers-reduced-motion:reduce){.theme-light,.theme-dark,.theme-light *,.theme-dark *{transition-duration:0ms}}
|
||||
|
||||
/* 白天主题覆盖现有 Tailwind 深色工具类,保持诊断、警告和选中状态的语义色。 */
|
||||
.theme-light{background:#f1f5f9!important;color:#0f172a!important}
|
||||
.theme-light header,.theme-light footer,.theme-light .panel,.theme-light [class*="bg-slate-900"],.theme-light [class*="bg-slate-950"]{background-color:#f8fafc!important}
|
||||
.theme-light .section,.theme-light header,.theme-light footer,.theme-light aside,.theme-light [class*="border-slate-"]{border-color:#cbd5e1!important}
|
||||
.theme-light .btn,.theme-light .field{border-color:#94a3b8!important;background:#fff!important;color:#0f172a!important}
|
||||
.theme-light .btn:hover,.theme-light .icon-btn:hover{border-color:#64748b!important;background:#e2e8f0!important}
|
||||
.theme-light [class*="text-slate-"]{color:#475569!important}
|
||||
.theme-light .text-slate-100,.theme-light .text-slate-300{color:#1e293b!important}
|
||||
.theme-light .text-slate-500{color:#64748b!important}
|
||||
.theme-light .section h3{color:#334155!important}
|
||||
.theme-light .hint{color:#64748b!important}
|
||||
.theme-light [class*="hover:bg-slate-800"]:hover{background-color:#e2e8f0!important}
|
||||
.theme-light [class*="bg-green-900"]{background-color:rgba(220,252,231,.8)!important}
|
||||
.theme-light [class*="bg-red-950"]{background-color:rgba(254,226,226,.96)!important}
|
||||
.theme-light [class*="text-red-"]{color:#991b1b!important}
|
||||
.theme-light [class*="bg-amber-950"]{background-color:rgba(254,243,199,.7)!important}
|
||||
.theme-light [class*="text-amber-"]{color:#92400e!important}
|
||||
|
||||
@@ -6,6 +6,7 @@ import {meshIdFromSceneDataId} from '../simulation/geometry';
|
||||
import {OrientationGizmo} from './OrientationGizmo';
|
||||
|
||||
export type InteractionMode = 'select' | 'joint' | 'force';
|
||||
export type ViewerTheme='light'|'dark';
|
||||
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;}
|
||||
|
||||
@@ -27,14 +28,17 @@ export class MuJoCoViewer {
|
||||
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 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 highlightedJointId=-1;private highlightedBodyId=-1;private jointMarker:THREE.Mesh|null=null;
|
||||
private resizeObserver:ResizeObserver;
|
||||
private orientationGizmo:OrientationGizmo;
|
||||
private grid:THREE.GridHelper;
|
||||
private hemisphere:THREE.HemisphereLight;
|
||||
private themeTransition?:{started:number;fromBackground:THREE.Color;toBackground:THREE.Color;fromGround:THREE.Color;toGround:THREE.Color;};
|
||||
|
||||
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.scene.background=new THREE.Color(0x0b1220);this.hemisphere=new THREE.HemisphereLight(0xffffff,0x223344,1.3);this.scene.add(this.hemisphere); const light=new THREE.DirectionalLight(0xffffff,2); light.position.set(4,-3,7); light.castShadow=true; this.scene.add(light);this.grid=new THREE.GridHelper(20,40,0x3b82f6,0x253047).rotateX(Math.PI/2);this.scene.add(this.grid);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);
|
||||
@@ -43,13 +47,18 @@ export class MuJoCoViewer {
|
||||
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();}
|
||||
setTheme(theme:ViewerTheme):void {const light=theme==='light';const background=this.scene.background instanceof THREE.Color?this.scene.background:new THREE.Color(0x0b1220);this.scene.background=background;this.themeTransition={started:performance.now(),fromBackground:background.clone(),toBackground:new THREE.Color(light?0xf8fafc:0x0b1220),fromGround:this.hemisphere.groundColor.clone(),toGround:new THREE.Color(light?0xcbd5e1:0x223344)};const next=new THREE.GridHelper(20,40,light?0x64748b:0x3b82f6,light?0xcbd5e1:0x253047).rotateX(Math.PI/2);this.scene.remove(this.grid);this.grid.geometry.dispose();const materials=Array.isArray(this.grid.material)?this.grid.material:[this.grid.material];for(const material of materials)material.dispose();this.grid=next;this.scene.add(this.grid);this.orientationGizmo.setTheme(theme);}
|
||||
private updateThemeTransition(now:number):void {const transition=this.themeTransition;if(!transition)return;const progress=Math.min(1,Math.max(0,(now-transition.started)/420)),eased=1-Math.pow(1-progress,3);(this.scene.background as THREE.Color).copy(transition.fromBackground).lerp(transition.toBackground,eased);this.hemisphere.groundColor.copy(transition.fromGround).lerp(transition.toGround,eased);if(progress===1)this.themeTransition=undefined;}
|
||||
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);}
|
||||
highlightJoint(jointId:number|null):void {this.highlightedJointId=jointId??-1;this.highlightedBodyId=-1;if(this.session&&jointId!==null&&jointId>=0&&jointId<this.session.model.njnt){const joint=this.session.model.jnt(jointId);try{this.highlightedBodyId=Number(joint.bodyid);}finally{joint.delete();}if(!this.jointMarker){const radius=Math.max(.008,this.session.geometryBounds().extent*.012);this.jointMarker=new THREE.Mesh(new THREE.SphereGeometry(radius,18,12),new THREE.MeshBasicMaterial({color:0xfacc15,depthTest:false,transparent:true,opacity:.95}));this.jointMarker.renderOrder=100;this.scene.add(this.jointMarker);}this.jointMarker.visible=true;this.updateJointMarker();}else if(this.jointMarker)this.jointMarker.visible=false;for(const mesh of this.meshes)this.applyMeshHighlight(mesh);}
|
||||
private updateJointMarker():void {if(!this.session||!this.jointMarker||this.highlightedJointId<0)return;const offset=this.highlightedJointId*3;this.jointMarker.position.set(Number(this.session.data.xanchor[offset]),Number(this.session.data.xanchor[offset+1]),Number(this.session.data.xanchor[offset+2]));}
|
||||
private applyMeshHighlight(mesh:THREE.Mesh):void {const material=mesh.material as THREE.MeshStandardMaterial;if(Number(mesh.userData.bodyId)===this.highlightedBodyId){material.emissive.setHex(0x8a6d00);material.emissiveIntensity=.85;}else if(mesh===this.selected){material.emissive.setHex(0x14532d);material.emissiveIntensity=1;}else{material.emissive.setHex(0);material.emissiveIntensity=1;}}
|
||||
|
||||
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 animate=(now:number):void=>{try {const result=this.session?.advance(now)??{steps:0,stepMs:0,overBudget:false};this.updateThemeTransition(now); this.controls.update();this.orientationGizmo.update(this.camera); if(this.session){this.updateMuJoCoScene();this.updateJointMarker();} 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(',')}`;}
|
||||
@@ -57,17 +66,17 @@ export class MuJoCoViewer {
|
||||
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 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;this.applyMeshHighlight(mesh);}
|
||||
|
||||
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 select(mesh:THREE.Mesh):void {const previous=this.selected;this.selected=mesh;if(previous)this.applyMeshHighlight(previous);this.applyMeshHighlight(mesh);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();}
|
||||
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;this.highlightedJointId=-1;this.highlightedBodyId=-1;if(this.jointMarker){this.scene.remove(this.jointMarker);this.jointMarker.geometry.dispose();(this.jointMarker.material as THREE.Material).dispose();this.jointMarker=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.grid.geometry.dispose();const gridMaterials=Array.isArray(this.grid.material)?this.grid.material:[this.grid.material];for(const material of gridMaterials)material.dispose();this.renderer.dispose();this.renderer.domElement.remove();}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ export class OrientationGizmo {
|
||||
private readonly axes:AxisElements[]=[];
|
||||
private readonly inverseQuaternion=new THREE.Quaternion();
|
||||
private readonly projected=new THREE.Vector3();
|
||||
private readonly center:SVGCircleElement;
|
||||
|
||||
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 方向指示器');
|
||||
@@ -23,7 +24,7 @@ export class OrientationGizmo {
|
||||
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);
|
||||
this.center=svgElement('circle');this.center.setAttribute('cx','50');this.center.setAttribute('cy','50');this.center.setAttribute('r','3.5');this.center.setAttribute('fill','#cbd5e1');this.element.append(this.center);host.append(this.element);
|
||||
}
|
||||
|
||||
update(camera:THREE.Camera):void {
|
||||
@@ -36,5 +37,9 @@ export class OrientationGizmo {
|
||||
}
|
||||
}
|
||||
|
||||
setTheme(theme:'light'|'dark'):void {
|
||||
const light=theme==='light';this.element.style.background=light?'rgba(248,250,252,.82)':'rgba(15,23,42,.72)';this.element.style.borderColor=light?'rgba(100,116,139,.35)':'rgba(100,116,139,.45)';this.center.setAttribute('fill',light?'#475569':'#cbd5e1');
|
||||
}
|
||||
|
||||
dispose():void {this.element.remove();}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user