Merge pull request 'Cen' (#2) from Cen into main
build / setup (compute matrix) (push) Has been cancelled
build / macos-15-arm64-studio (push) Has been cancelled
build / ubuntu-24.04-clang-18-studio (push) Has been cancelled
build / ubuntu-24.04-gcc-14-studio (push) Has been cancelled
build / windows-2025-ninja-studio (push) Has been cancelled
build / ubuntu-24.04-clang-18-wasm (push) Has been cancelled
build / ubuntu-24.04-clang-18-mjx (push) Has been cancelled
build / ${{ matrix.label }} (push) Has been cancelled

Reviewed-on: #2
This commit was merged in pull request #2.
This commit is contained in:
2026-08-21 16:55:40 +08:00
72 changed files with 672 additions and 85 deletions
+10
View File
@@ -11,6 +11,7 @@
"dependencies": {
"@mujoco/mujoco": "^3.11.0",
"fflate": "^0.8.3",
"lucide-react": "^0.555.0",
"react": "^19.2.8",
"react-dom": "^19.2.8",
"zustand": "^5.0.15"
@@ -3764,6 +3765,15 @@
"integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==",
"dev": true
},
"node_modules/lucide-react": {
"version": "0.555.0",
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.555.0.tgz",
"integrity": "sha512-D8FvHUGbxWBRQM90NZeIyhAvkFfsh3u9ekrMvJ30Z6gnpBHS6HC6ldLg7tL45hwiIz/u66eKDtdA23gwwGsAHA==",
"license": "ISC",
"peerDependencies": {
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/lz-string": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz",
+1
View File
@@ -58,6 +58,7 @@
"dependencies": {
"@mujoco/mujoco": "^3.11.0",
"fflate": "^0.8.3",
"lucide-react": "^0.555.0",
"react": "^19.2.8",
"react-dom": "^19.2.8",
"zustand": "^5.0.15"
+55 -3
View File
@@ -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>
@@ -36,9 +38,33 @@ test('显示中文平台骨架并加载单文件模型', async ({page}) => {
page.on('requestfailed', (request) => console.log(`[browser:requestfailed] ${request.url()} ${request.failure()?.errorText}`));
page.on('response', (response) => { if (response.status() >= 400 || response.url().endsWith('.wasm')) console.log(`[browser:response] ${response.status()} ${response.url()} ${response.headers()['content-type'] ?? ''}`); });
await page.goto('/');
await page.setViewportSize({width:1024,height:768});
const toolBox=await page.getByRole('toolbar',{name:'视口交互模式'}).boundingBox(),playBox=await page.getByRole('button',{name:'▶ 播放'}).boundingBox();
expect(toolBox&&playBox&&toolBox.x+toolBox.width<=playBox.x).toBeTruthy();
await page.getByRole('button',{name:'更多工作台操作'}).click();await expect(page.getByRole('menuitem',{name:'工作台设置'})).toBeVisible();await page.keyboard.press('Escape');
await page.setViewportSize({width:1440,height:900});
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:'切换到白天主题'})).toBeVisible();
await page.getByRole('button',{name:'布局设置'}).click();await expect(page.getByRole('dialog',{name:'布局设置'})).toBeVisible();await page.keyboard.press('Escape');
await page.getByRole('button',{name:'工作台设置'}).click();await expect(page.getByRole('dialog',{name:'工作台设置'})).toBeVisible();await page.keyboard.press('Escape');
await page.keyboard.press('Control+k');
await expect(page.getByRole('dialog',{name:'命令面板'})).toBeVisible();
await page.getByLabel('搜索命令').fill('复位相机');
await expect(page.getByRole('option',{name:/复位相机/})).toBeVisible();
await page.keyboard.press('Escape');
await page.getByRole('button',{name:'进入全屏'}).click();
await expect(page.getByRole('button',{name:'退出全屏'})).toBeVisible();
await page.keyboard.press('Control+k');
await expect(page.getByRole('dialog',{name:'命令面板'})).toBeVisible();
await page.keyboard.press('Escape');
await page.getByRole('button',{name:'退出全屏'}).click();
await page.getByRole('button',{name:'切换到白天主题'}).click();
await expect(page.locator('#root > div')).toHaveClass(/theme-light/);
await expect(page.getByRole('button',{name:'切换到黑夜主题'})).toBeVisible();
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',
@@ -47,8 +73,29 @@ test('显示中文平台骨架并加载单文件模型', async ({page}) => {
});
await expect(page.getByText('WASM 已加载')).toBeVisible({timeout: 30_000});
await page.getByRole('button',{name:'通知中心'}).click();await expect(page.getByRole('dialog',{name:'通知中心'})).toContainText('模型加载完成');await page.getByText('事件日志').click();await expect(page.getByRole('dialog',{name:'诊断与事件日志'})).toBeVisible();await page.keyboard.press('Escape');
await page.getByRole('button',{name:/FPS .*物理/}).click();
await expect(page.getByRole('dialog',{name:'性能详情'})).toBeVisible();
await page.keyboard.press('Escape');
await page.getByRole('tab',{name:'控制'}).click();
await page.getByRole('button',{name:'Actuator'}).click();
await expect(page.getByText('motor')).toBeVisible();
await expect(page.getByText('slide')).toBeVisible();
await expect(page.getByRole('tabpanel',{name:'控制'}).getByText('slide',{exact:true})).toBeVisible();
await page.getByRole('tab',{name:'模型结构'}).click();
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}) => {
@@ -72,9 +119,14 @@ test('加载引用 OBJ 的 URDF 工程', async ({page}) => {
]);
await expect(page.getByText('WASM 已加载')).toBeVisible({timeout: 30_000});
await expect(page.getByText('2 个文件')).toBeVisible();
await page.getByRole('button',{name:'URDF 处理方式'}).click();
await expect(page.getByLabel('URDF 处理方式')).toHaveValue('mjcf');
await expect(page.getByLabel('URDF 基座类型')).toHaveValue('floating');
await expect(page.getByText(/URDF 已转换为 MJCF(浮动基座),并整体平移/)).toBeVisible();
await page.getByRole('button',{name:'通知中心'}).click();
const notifications=page.getByRole('dialog',{name:'通知中心'});
await expect(notifications).toContainText('URDF 兼容处理');
await expect(notifications).toContainText(/URDF 已转换为 MJCF(浮动基座),并整体平移/);
await page.keyboard.press('Escape');
await expect(page.getByLabel('显示碰撞几何')).not.toBeChecked();
});
@@ -89,7 +141,7 @@ test('中等规模模型持续步进并可重复加载', async ({page}) => {
await expect(page.locator('footer')).not.toContainText('时间 0.000 s');
// 播放过程中重置必须同时暂停底层会话,之后仍可正常播放和暂停。
await page.getByRole('button',{name:'重置'}).click();
await page.getByRole('button',{name:'重置',exact:true}).click();
await expect(page.getByRole('button',{name:'▶ 播放'})).toBeVisible();
await expect(page.locator('footer')).toContainText('时间 0.000 s');
await page.getByRole('button',{name:'▶ 播放'}).click();
+62 -23
View File
@@ -1,43 +1,82 @@
/* Zustand 的 action 引用稳定;初始化 viewer 与导入回调有意只创建一次。 */
/* eslint-disable react-hooks/exhaustive-deps */
import {useCallback,useEffect,useRef,useState,type ChangeEvent,type DragEvent} from 'react';
import {Camera,ChevronLeft,ChevronRight,CircleHelp,Crosshair,Hand,Maximize,MousePointer2,PanelsTopLeft,Pause,Play,RotateCcw,Settings as SettingsIcon,SunMoon} from 'lucide-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 {MuJoCoViewer,type InteractionMode,type ViewerTheme} from '../viewer/MuJoCoViewer';
import {useAppStore,type AppDiagnostic} from '../stores/useAppStore';
import {WorkbenchHeader} from './components/WorkbenchHeader';
import {ViewerToolDock} from './components/ViewerToolDock';
import {ProjectSidebar,ModelControlsSidebar} from './components/SidebarPanel';
import {WorkspaceOverlays,type ImportProgress} from './components/WorkspaceOverlays';
import {EntrySelectionDialog} from './components/EntrySelectionDialog';
import {ErrorRecoveryPanel} from './components/ErrorRecoveryPanel';
import {StatusBar} from './components/StatusBar';
import {ViewportHUD} from './components/ViewportHUD';
import {ShortcutHelpDialog} from './components/ShortcutHelpDialog';
import {CommandPalette,type WorkbenchCommand} from './components/CommandPalette';
import {NotificationCenter,ToastViewport,type WorkbenchNotification} from './components/NotificationCenter';
import {SettingsDialog} from './components/SettingsDialog';
import {dispatchLayoutWidths,LayoutSettingsDialog,type LayoutPreset} from './components/LayoutSettingsDialog';
import {ConfirmDialog,IconButton} from '../components/ui';
import {DiagnosticsDrawer} from './components/DiagnosticsDrawer';
import {ToolbarOverflowMenu} from './components/ToolbarOverflowMenu';
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);
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 state=useAppStore();
const manifest=useRef<ProjectManifest|null>(null),notificationId=useRef(0),loadInFlight=useRef(false),importInFlight=useRef(false),adapter=useRef(new MainThreadPhysicsAdapter()),root=useRef<HTMLDivElement>(null),viewerHost=useRef<HTMLDivElement>(null),viewer=useRef<MuJoCoViewer|null>(null);
const [forceScale,setForceScale]=useState(50),[leftOpen,setLeftOpen]=useState(true),[rightOpen,setRightOpen]=useState(true),[helpOpen,setHelpOpen]=useState(false),[commandOpen,setCommandOpen]=useState(false),[removeConfirmOpen,setRemoveConfirmOpen]=useState(false),[fullscreen,setFullscreen]=useState(false),[settingsOpen,setSettingsOpen]=useState(false),[layoutOpen,setLayoutOpen]=useState(false),[diagnosticsOpen,setDiagnosticsOpen]=useState(false),[importProgress,setImportProgress]=useState<ImportProgress>(),[notifications,setNotifications]=useState<WorkbenchNotification[]>([]),[toast,setToast]=useState<WorkbenchNotification>();
const [urdfMode,setUrdfMode]=useState<UrdfLoadMode>('mjcf'),urdfModeRef=useRef<UrdfLoadMode>('mjcf');
const [baseMode,setBaseMode]=useState<UrdfBaseMode>('floating'),baseModeRef=useRef<UrdfBaseMode>('floating');
const [showCollision,setShowCollision]=useState(false),[theme,setTheme]=useState<ViewerTheme>(initialTheme),[jointAdvanced,setJointAdvanced]=useState(false),[ignoreJointLimits,setIgnoreJointLimits]=useState(false),[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]);
useEffect(()=>{viewer.current?.setTheme(theme);document.documentElement.style.colorScheme=theme;try{localStorage.setItem('mujoco-platform-theme',theme);}catch{/* 当前会话仍可切换 */}},[theme]);
useEffect(()=>{const change=()=>setFullscreen(document.fullscreenElement===root.current);document.addEventListener('fullscreenchange',change);return()=>document.removeEventListener('fullscreenchange',change);},[]);
const loadEntry=useCallback(async(path:string,requestedMode?:UrdfLoadMode)=>{if(!manifest.current||loadInFlight.current)return;loadInFlight.current=true;setIgnoreJointLimits(false);state.setEntry(path);state.setLoading(true);setImportProgress({label:'初始化 WASM 与编译模型',value:.65});state.setDiagnostic(undefined);viewer.current?.attach(null);state.setSnapshot(undefined);state.setSelection(null);try{const snapshot=await adapter.current.load(manifest.current,path,requestedMode??urdfModeRef.current,baseModeRef.current);setImportProgress({label:'创建视口场景',value:.92});adapter.current.setSpeed(useAppStore.getState().speed);state.setSnapshot(snapshot);state.setPaused(true);viewer.current?.attach(adapter.current.session);const notice:WorkbenchNotification={id:++notificationId.current,title:snapshot.warnings.length?'URDF 兼容处理':'模型加载完成',detail:snapshot.warnings.length?snapshot.warnings.join('\n'):path,tone:snapshot.warnings.length?'warning':'success',at:Date.now()};setNotifications(items=>[notice,...items].slice(0,20));setToast(notice);}catch(error){state.setDiagnostic(diagnostic('模型编译',error,path));const notice:WorkbenchNotification={id:++notificationId.current,title:'模型编译失败',detail:error instanceof Error?error.message:String(error),tone:'danger',at:Date.now()};setNotifications(items=>[notice,...items].slice(0,20));setToast(notice);}finally{loadInFlight.current=false;setImportProgress(undefined);state.setLoading(false);}},[]);
const ingest=useCallback(async(files:File[],lockOwned=false)=>{if(importInFlight.current&&!lockOwned)return;importInFlight.current=true;state.setLoading(true);setImportProgress({label:'读取工程文件',value:.12});try{const next=await importBrowserFiles(files);setImportProgress({label:'处理模型资源与入口',value:.38});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));const notice:WorkbenchNotification={id:++notificationId.current,title:'工程导入失败',detail:error instanceof Error?error.message:String(error),tone:'danger',at:Date.now()};setNotifications(items=>[notice,...items].slice(0,20));setToast(notice);}finally{importInFlight.current=false;setImportProgress(undefined);state.setLoading(false);}},[loadEntry]);
const removeProject=()=>{if(state.projectName)setRemoveConfirmOpen(true);};
const confirmRemoveProject=()=>{viewer.current?.attach(null);adapter.current.dispose();manifest.current=null;state.clearProject();setRemoveConfirmOpen(false);};
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 drop=(event:DragEvent)=>{event.preventDefault();if(state.loading||importInFlight.current)return;importInFlight.current=true;state.setLoading(true);setImportProgress({label:'读取拖放文件',value:.05});void (async()=>{try{const files=await filesFromDrop(event.dataTransfer.items,event.dataTransfer.files);await ingest(files,true);}catch(error){importInFlight.current=false;setImportProgress(undefined);state.setLoading(false);state.setDiagnostic(diagnostic('导入',error));}})();};
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>
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);};
const setActuator=(id:number,value:number)=>{adapter.current.setActuator(id,value);state.setSnapshot(adapter.current.snapshot()??undefined);};
const setJoint=(id:number,value:number)=>{adapter.current.setJointPosition(id,value);state.setPaused(true);state.setSnapshot(adapter.current.snapshot()??undefined);};
const toggleFullscreen=()=>{if(document.fullscreenElement)void document.exitFullscreen().catch(()=>{});else if(root.current)void root.current.requestFullscreen().catch(()=>{});};
const applyLayoutPreset=(preset:LayoutPreset)=>{if(preset==='viewport'){setLeftOpen(false);setRightOpen(false);dispatchLayoutWidths(288,288);}else if(preset==='project'){setLeftOpen(true);setRightOpen(false);dispatchLayoutWidths(384,288);}else if(preset==='control'){setLeftOpen(false);setRightOpen(true);dispatchLayoutWidths(288,384);}else{setLeftOpen(true);setRightOpen(true);dispatchLayoutWidths(288,288);}};
useEffect(()=>{const key=(event:KeyboardEvent)=>{if(document.activeElement instanceof HTMLElement&&document.activeElement.closest('[role="dialog"]'))return;if((event.ctrlKey||event.metaKey)&&event.key.toLocaleLowerCase()==='k'){event.preventDefault();setCommandOpen(true);return;}if((event.target as HTMLElement).matches('input,select,button'))return;if(event.code==='Space'){event.preventDefault();togglePause();}if(event.key==='r')reset();if(event.key==='1')mode('select');if(event.key==='2')mode('joint');if(event.key==='3')mode('force');};window.addEventListener('keydown',key);return()=>window.removeEventListener('keydown',key);});
const selectedFormat=state.entries.find(entry=>entry.path===state.selectedEntry)?.format;
const commands:WorkbenchCommand[]=[
{id:'play',label:state.paused?'播放仿真':'暂停仿真',group:'仿真',icon:state.paused?<Play className="h-4 w-4"/>:<Pause className="h-4 w-4"/>,shortcut:'Space',disabled:!state.snapshot,run:togglePause},
{id:'reset',label:'重置仿真',group:'仿真',icon:<RotateCcw className="h-4 w-4"/>,shortcut:'R',disabled:!state.snapshot,run:reset},
{id:'select',label:'切换到选择模式',group:'视口',icon:<MousePointer2 className="h-4 w-4"/>,shortcut:'1',run:()=>mode('select')},
{id:'joint',label:'切换到关节拖动',group:'视口',icon:<Hand className="h-4 w-4"/>,shortcut:'2',run:()=>mode('joint')},
{id:'force',label:'切换到外力施加',group:'视口',icon:<Crosshair className="h-4 w-4"/>,shortcut:'3',run:()=>mode('force')},
{id:'camera',label:'复位相机',group:'视口',icon:<Camera className="h-4 w-4"/>,run:()=>viewer.current?.resetCamera()},
{id:'left',label:leftOpen?'隐藏工程面板':'显示工程面板',group:'布局',icon:leftOpen?<ChevronLeft className="h-4 w-4"/>:<ChevronRight className="h-4 w-4"/>,run:()=>setLeftOpen(value=>!value)},
{id:'right',label:rightOpen?'隐藏属性面板':'显示属性面板',group:'布局',icon:rightOpen?<ChevronRight className="h-4 w-4"/>:<ChevronLeft className="h-4 w-4"/>,run:()=>setRightOpen(value=>!value)},
{id:'theme',label:theme==='dark'?'切换到白天主题':'切换到黑夜主题',group:'外观',icon:<SunMoon className="h-4 w-4"/>,run:()=>setTheme(value=>value==='dark'?'light':'dark')},
{id:'fullscreen',label:fullscreen?'退出全屏':'进入全屏',group:'布局',icon:<Maximize className="h-4 w-4"/>,run:toggleFullscreen},
{id:'help',label:'查看快捷键帮助',group:'帮助',icon:<CircleHelp className="h-4 w-4"/>,run:()=>setHelpOpen(true)},
];
return <div ref={root} className={`${theme==='light'?'theme-light':'theme-dark'} flex h-screen min-w-[1024px] flex-col overflow-hidden bg-app text-text-primary`} onDragOver={event=>event.preventDefault()} onDrop={drop}>
<WorkbenchHeader paused={state.paused} ready={Boolean(state.snapshot)} speed={state.speed} theme={theme} loading={state.loading} leftOpen={leftOpen} rightOpen={rightOpen} fullscreen={fullscreen} onFiles={changeFiles} onFolder={changeFiles} onTogglePause={togglePause} onStep={singleStep} onReset={reset} onSpeed={changeSpeed} onToggleLeft={()=>setLeftOpen(value=>!value)} onToggleRight={()=>setRightOpen(value=>!value)} onToggleTheme={()=>setTheme(value=>value==='dark'?'light':'dark')} onHelp={()=>setHelpOpen(true)} endActions={<><NotificationCenter items={notifications} onDismiss={id=>setNotifications(items=>items.filter(item=>item.id!==id))} onClear={()=>setNotifications([])} onOpenLog={()=>setDiagnosticsOpen(true)}/><span className="hidden items-center gap-0.5 xl:flex"><IconButton tooltip="布局设置" aria-label="布局设置" onClick={()=>setLayoutOpen(true)}><PanelsTopLeft className="h-4 w-4"/></IconButton><IconButton tooltip="工作台设置" aria-label="工作台设置" onClick={()=>setSettingsOpen(true)}><SettingsIcon className="h-4 w-4"/></IconButton></span></>} compactMenu={<ToolbarOverflowMenu fullscreen={fullscreen} onCommands={()=>setCommandOpen(true)} onLayout={()=>setLayoutOpen(true)} onSettings={()=>setSettingsOpen(true)} onFullscreen={toggleFullscreen} onHelp={()=>setHelpOpen(true)} onTheme={()=>setTheme(value=>value==='dark'?'light':'dark')}/>} onCommands={()=>setCommandOpen(true)} onToggleFullscreen={toggleFullscreen} center={<ViewerToolDock mode={state.mode} onModeChange={mode} onResetCamera={()=>viewer.current?.resetCamera()}/>}/>
<div className="flex min-h-0 flex-1"><ProjectSidebar visible={leftOpen} projectName={state.projectName} files={state.files} entries={state.entries} selectedEntry={state.selectedEntry} snapshot={state.snapshot} loading={state.loading} onRemove={removeProject} onSelectEntry={path=>void loadEntry(path)} onJointHover={jointId=>viewer.current?.highlightJoint(jointId)}/><main className="relative min-w-0 flex-1"><div ref={viewerHost} className="absolute inset-0"/><ViewportHUD paused={state.paused} mode={state.mode} selection={state.selection} ready={Boolean(state.snapshot)}/><WorkspaceOverlays loading={state.loading} hasSnapshot={Boolean(state.snapshot)} progress={importProgress}/><ToastViewport item={toast} onDismiss={()=>setToast(undefined)}/>{state.entries.length>1&&!state.selectedEntry&&<EntrySelectionDialog entries={state.entries} onSelect={loadEntry}/>} {state.diagnostic&&<ErrorRecoveryPanel key={state.diagnostic.at} value={state.diagnostic} onClose={()=>state.setDiagnostic(undefined)} onRetry={state.diagnostic.category==='模型编译'&&state.diagnostic.path?()=>void loadEntry(state.diagnostic!.path!):undefined} onOpenProject={()=>{setLeftOpen(true);state.setDiagnostic(undefined);}}/>}</main><ModelControlsSidebar visible={rightOpen} snapshot={state.snapshot} selection={state.selection} selectedFormat={selectedFormat} loading={state.loading} urdfMode={urdfMode} baseMode={baseMode} showCollision={showCollision} ignoreJointLimits={ignoreJointLimits} jointAdvanced={jointAdvanced} angleUnit={angleUnit} forceScale={forceScale} onUrdfMode={changeUrdfMode} onBaseMode={changeBaseMode} onShowCollision={setShowCollision} onResetJoints={resetJoints} onToggleJointLimits={toggleJointLimits} onToggleAdvanced={()=>setJointAdvanced(value=>!value)} onToggleAngleUnit={()=>setAngleUnit(value=>value==='rad'?'deg':'rad')} onActuator={setActuator} onJoint={setJoint} onForceScale={setForceScale}/></div>
<ShortcutHelpDialog open={helpOpen} onClose={()=>setHelpOpen(false)}/><DiagnosticsDrawer open={diagnosticsOpen} items={notifications} onClose={()=>setDiagnosticsOpen(false)} onClear={()=>setNotifications([])}/><SettingsDialog open={settingsOpen} onClose={()=>setSettingsOpen(false)} theme={theme} angleUnit={angleUnit} showCollision={showCollision} jointAdvanced={jointAdvanced} forceScale={forceScale} onTheme={setTheme} onAngleUnit={setAngleUnit} onShowCollision={setShowCollision} onJointAdvanced={setJointAdvanced} onForceScale={setForceScale}/><LayoutSettingsDialog open={layoutOpen} onClose={()=>setLayoutOpen(false)} leftOpen={leftOpen} rightOpen={rightOpen} onLeftOpen={setLeftOpen} onRightOpen={setRightOpen} onPreset={applyLayoutPreset} onReset={()=>applyLayoutPreset('default')}/><CommandPalette open={commandOpen} onClose={()=>setCommandOpen(false)} commands={commands}/><ConfirmDialog open={removeConfirmOpen} title="移除当前工程" confirmLabel="移除工程" danger onConfirm={confirmRemoveProject} onClose={()=>setRemoveConfirmOpen(false)}><p className="text-sm text-text-secondary"><strong className="text-text-primary">{state.projectName}</strong></p><p className="mt-2 text-xs text-text-tertiary"></p></ConfirmDialog><StatusBar time={state.snapshot?.time} fps={state.fps} stepMs={state.stepMs} memoryMb={state.memoryMb} loaded={Boolean(state.snapshot)} overBudget={state.overBudget}/>
</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/XMLURDF 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>}
+3 -2
View File
@@ -1,2 +1,3 @@
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;}}
import {Component,type ErrorInfo,type ReactNode} from 'react';
import {Button} from '../components/ui';
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-app text-text-primary"><section className="max-w-xl rounded-xl border border-danger-border bg-panel p-6 shadow-xl"><h1 className="text-xl font-semibold"></h1><pre className="mt-3 whitespace-pre-wrap text-sm text-danger">{this.state.error.message}</pre><Button variant="danger" className="mt-4" onClick={()=>location.reload()}></Button></section></main>:this.props.children;}}
@@ -0,0 +1,12 @@
import {useEffect,useId,useMemo,useRef,useState,type ReactNode} from 'react';
import {Search} from 'lucide-react';
import {Dialog,EmptySearchState,Kbd} from '../../components/ui';
export interface WorkbenchCommand{id:string;label:string;group:string;icon?:ReactNode;shortcut?:string;disabled?:boolean;run:()=>void;}
export function CommandPalette({open,onClose,commands}:{open:boolean;onClose:()=>void;commands:WorkbenchCommand[]}){
const [query,setQuery]=useState(''),[active,setActive]=useState(0),input=useRef<HTMLInputElement>(null),listId=useId();
const filtered=useMemo(()=>{const needle=query.trim().toLocaleLowerCase();return commands.filter(command=>!needle||`${command.label} ${command.group}`.toLocaleLowerCase().includes(needle));},[commands,query]);
const enabled=filtered.flatMap((command,index)=>command.disabled?[]:[index]),highlighted=filtered[active]&&!filtered[active].disabled?active:(enabled[0]??-1);
useEffect(()=>{if(open)requestAnimationFrame(()=>input.current?.focus());},[open]);
const close=()=>{setQuery('');setActive(0);onClose();},execute=(command?:WorkbenchCommand)=>{if(!command||command.disabled)return;command.run();close();};
return <Dialog open={open} onClose={close} title="命令面板" className="max-w-xl"><div className="relative -m-4 mb-2 border-b border-border"><Search className="pointer-events-none absolute left-4 top-1/2 h-4 w-4 -translate-y-1/2 text-text-tertiary"/><input ref={input} role="combobox" aria-label="搜索命令" aria-autocomplete="list" aria-expanded="true" aria-controls={listId} aria-activedescendant={highlighted>=0?`${listId}-${filtered[highlighted].id}`:undefined} value={query} onChange={event=>{setQuery(event.target.value);setActive(0);}} onKeyDown={event=>{if(!enabled.length)return;const current=Math.max(0,enabled.indexOf(highlighted));if(event.key==='ArrowDown'){event.preventDefault();setActive(enabled[(current+1)%enabled.length]);}else if(event.key==='ArrowUp'){event.preventDefault();setActive(enabled[(current-1+enabled.length)%enabled.length]);}else if(event.key==='Enter'){event.preventDefault();execute(filtered[highlighted]);}}} placeholder="输入命令名称…" className="h-11 w-full bg-input pl-11 pr-4 text-sm text-text-primary placeholder:text-text-tertiary focus:outline-none"/></div><div id={listId} role="listbox" aria-label="可用命令" className="max-h-80 space-y-1 overflow-auto pt-1">{filtered.length?filtered.map((command,index)=><button key={command.id} id={`${listId}-${command.id}`} type="button" role="option" aria-selected={index===highlighted} disabled={command.disabled} onMouseEnter={()=>{if(!command.disabled)setActive(index);}} onClick={()=>execute(command)} className={`flex w-full items-center gap-3 rounded-md px-3 py-2 text-left text-xs outline-none ${index===highlighted?'bg-accent-soft text-accent':'text-text-secondary hover:bg-element-hover'} disabled:opacity-40`}><span className="flex h-5 w-5 items-center justify-center">{command.icon}</span><span className="min-w-0 flex-1"><span className="block truncate font-medium">{command.label}</span><span className="block text-[10px] text-text-tertiary">{command.group}</span></span>{command.shortcut&&<Kbd>{command.shortcut}</Kbd>}</button>):<EmptySearchState label="没有匹配的命令"/>}</div></Dialog>;
}
@@ -0,0 +1,5 @@
import {useState} from 'react';
import {ChevronDown,TriangleAlert,X} from 'lucide-react';
import type {AppDiagnostic} from '../../stores/useAppStore';
import {IconButton} from '../../components/ui';
export function DiagnosticNotice({value,onClose}:{value:AppDiagnostic;onClose:()=>void}){const [expanded,setExpanded]=useState(false);return <section role="alert" className="absolute bottom-4 left-1/2 z-30 w-[min(42rem,calc(100%-2rem))] -translate-x-1/2 overflow-hidden rounded-xl border border-danger-border bg-panel shadow-2xl"><div className="flex items-start gap-3 p-3"><span className="mt-0.5 grid h-7 w-7 shrink-0 place-items-center rounded-full bg-danger-soft text-danger"><TriangleAlert className="h-4 w-4"/></span><div className="min-w-0 flex-1"><h2 className="text-sm font-semibold text-text-primary">{value.summary}</h2>{value.path&&<p className="mt-0.5 truncate text-xs text-text-tertiary" title={value.path}>{value.path}</p>}<button type="button" aria-expanded={expanded} className="mt-1 flex items-center gap-1 text-xs text-danger hover:underline" onClick={()=>setExpanded(v=>!v)}><ChevronDown className={`h-3 w-3 ${expanded?'rotate-180':''}`}/></button></div><IconButton aria-label="关闭错误" tooltip="关闭" onClick={onClose}><X className="h-4 w-4"/></IconButton></div>{expanded&&<pre className="max-h-36 overflow-auto border-t border-danger-border bg-danger-soft p-3 text-xs text-danger">{value.detail}</pre>}</section>;}
@@ -0,0 +1,6 @@
import {useState} from 'react';
import {CheckCircle2,Info,TriangleAlert,XCircle} from 'lucide-react';
import {Button,CopyButton,Dialog,Tabs} from '../../components/ui';
import type {WorkbenchNotification} from './NotificationCenter';
type Filter='all'|'warning'|'danger';
export function DiagnosticsDrawer({open,items,onClose,onClear}:{open:boolean;items:WorkbenchNotification[];onClose:()=>void;onClear:()=>void}){const [filter,setFilter]=useState<Filter>('all');const content=(value:Filter)=>{const filtered=items.filter(item=>value==='all'||item.tone===value);return <div className="space-y-2">{filtered.length?filtered.map(item=>{const Icon=item.tone==='danger'?XCircle:item.tone==='warning'?TriangleAlert:item.tone==='success'?CheckCircle2:Info;return <article key={item.id} className="rounded-lg border border-border bg-surface p-3"><div className="flex items-start gap-2"><Icon className={`mt-0.5 h-4 w-4 ${item.tone==='danger'?'text-danger':item.tone==='warning'?'text-warning':'text-success'}`}/><div className="min-w-0 flex-1"><h3 className="text-xs font-semibold">{item.title}</h3><time className="text-[10px] text-text-tertiary">{new Date(item.at).toLocaleString('zh-CN')}</time>{item.detail&&<pre className="mt-2 whitespace-pre-wrap text-[10px] leading-4 text-text-secondary">{item.detail}</pre>}</div>{item.detail&&<CopyButton value={`${item.title}\n${item.detail}`} label="复制事件详情"/>}</div></article>}):<p className="p-8 text-center text-xs text-text-tertiary"></p>}</div>;};return <Dialog open={open} onClose={onClose} title="诊断与事件日志" className="max-w-2xl" footer={<div className="flex justify-end"><Button variant="danger" disabled={!items.length} onClick={onClear}></Button></div>}><Tabs label="事件筛选" value={filter} onValueChange={setFilter} keepMounted={false} items={[{value:'all',label:`全部 ${items.length}`,content:content('all')},{value:'warning',label:`警告 ${items.filter(item=>item.tone==='warning').length}`,content:content('warning')},{value:'danger',label:`错误 ${items.filter(item=>item.tone==='danger').length}`,content:content('danger')}]}/></Dialog>;}
@@ -0,0 +1,4 @@
import {render,screen} from '@testing-library/react';
import {EntrySelectionDialog} from './EntrySelectionDialog';
describe('EntrySelectionDialog',()=>{it('父组件重渲染时不抢走入口按钮焦点,且不暴露无效关闭动作',()=>{const entries=[{path:'a.xml',label:'模型 A'},{path:'b.xml',label:'模型 B'}],select=vi.fn();const {rerender}=render(<EntrySelectionDialog entries={entries} onSelect={select}/>);const entry=screen.getByRole('button',{name:'模型 A'});entry.focus();rerender(<EntrySelectionDialog entries={[...entries]} onSelect={select}/>);expect(entry).toHaveFocus();expect(screen.queryByRole('button',{name:'关闭'})).not.toBeInTheDocument();});});
@@ -0,0 +1,4 @@
import {FileCode2} from 'lucide-react';
import {Button,Dialog} from '../../components/ui';
const noop=()=>{};
export function EntrySelectionDialog({entries,onSelect}:{entries:{path:string;label:string}[];onSelect:(path:string)=>void}){return <Dialog open={entries.length>0} onClose={noop} closable={false} title="选择模型入口"><p className="mb-4 text-sm text-text-secondary"></p><div className="space-y-2">{entries.map(entry=><Button key={entry.path} className="w-full justify-start overflow-hidden" onClick={()=>onSelect(entry.path)} icon={<FileCode2 className="h-4 w-4"/>}><span className="truncate">{entry.label}</span></Button>)}</div></Dialog>;}
@@ -0,0 +1,5 @@
import {useState} from 'react';
import {ChevronDown,FolderTree,RefreshCw,TriangleAlert,X} from 'lucide-react';
import type {AppDiagnostic} from '../../stores/useAppStore';
import {Button,CopyButton,IconButton} from '../../components/ui';
export function ErrorRecoveryPanel({value,onClose,onRetry,onOpenProject}:{value:AppDiagnostic;onClose:()=>void;onRetry?:()=>void;onOpenProject:()=>void}){const [expanded,setExpanded]=useState(false);return <section role="alert" className="absolute bottom-4 left-1/2 z-30 w-[min(42rem,calc(100%-2rem))] -translate-x-1/2 overflow-hidden rounded-xl border border-danger-border bg-panel shadow-2xl"><div className="flex items-start gap-3 p-3"><span className="mt-0.5 grid h-7 w-7 shrink-0 place-items-center rounded-full bg-danger-soft text-danger"><TriangleAlert className="h-4 w-4"/></span><div className="min-w-0 flex-1"><h2 className="text-sm font-semibold">{value.summary}</h2>{value.path&&<p className="truncate text-xs text-text-tertiary">{value.path}</p>}<div className="mt-2 flex flex-wrap gap-2">{onRetry&&<Button variant="danger" onClick={onRetry} icon={<RefreshCw className="h-3.5 w-3.5"/>}></Button>}<Button onClick={onOpenProject} icon={<FolderTree className="h-3.5 w-3.5"/>}></Button><CopyButton value={`${value.summary}\n${value.path??''}\n${value.detail}`} label="复制错误详情"/></div><button type="button" aria-expanded={expanded} className="mt-2 flex items-center gap-1 text-xs text-danger" onClick={()=>setExpanded(v=>!v)}><ChevronDown className={`h-3 w-3 ${expanded?'rotate-180':''}`}/></button></div><IconButton aria-label="关闭错误" tooltip="关闭" onClick={onClose}><X className="h-4 w-4"/></IconButton></div>{expanded&&<pre className="max-h-36 overflow-auto border-t border-danger-border bg-danger-soft p-3 text-xs text-danger">{value.detail}</pre>}</section>;}
@@ -0,0 +1,5 @@
import {fireEvent,render,screen} from '@testing-library/react';
import {DiagnosticNotice} from './DiagnosticNotice';
import {WorkspaceOverlays} from './WorkspaceOverlays';
import {StatusBar} from './StatusBar';
describe('工作台反馈组件',()=>{it('诊断详情可展开并关闭',()=>{const close=vi.fn();render(<DiagnosticNotice value={{category:'模型编译',summary:'模型编译失败',detail:'bad xml',path:'robot.xml',at:1}} onClose={close}/>);expect(screen.queryByText('bad xml')).not.toBeInTheDocument();fireEvent.click(screen.getByRole('button',{name:'技术详情'}));expect(screen.getByText('bad xml')).toBeVisible();fireEvent.click(screen.getByRole('button',{name:'关闭错误'}));expect(close).toHaveBeenCalledTimes(1);});it('加载态与空态互斥',()=>{const {rerender}=render(<WorkspaceOverlays loading={false} hasSnapshot={false}/>);expect(screen.getByText('拖放模型工程到此处')).toBeVisible();rerender(<WorkspaceOverlays loading hasSnapshot={false}/>);expect(screen.queryByText('拖放模型工程到此处')).not.toBeInTheDocument();expect(screen.getByRole('status')).toBeVisible();});it('展示格式化状态数据',()=>{render(<StatusBar time={1.25} fps={60} stepMs={0.5} memoryMb={10} loaded overBudget={false}/>);expect(screen.getByText(/时间 1.250 s/)).toBeVisible();expect(screen.getByText(/WASM 已加载/)).toBeVisible();});});
@@ -0,0 +1,12 @@
import {fireEvent,render,screen,within} from '@testing-library/react';
import {DiagnosticsDrawer} from './DiagnosticsDrawer';
import {ErrorRecoveryPanel} from './ErrorRecoveryPanel';
import {ToolbarOverflowMenu} from './ToolbarOverflowMenu';
import {WorkspaceOverlays} from './WorkspaceOverlays';
const event={id:1,title:'编译失败',detail:'bad xml',tone:'danger' as const,at:0};
describe('第五批工作台组件',()=>{
it('事件日志支持分类和清空',()=>{const clear=vi.fn();render(<DiagnosticsDrawer open items={[event]} onClose={()=>{}} onClear={clear}/>);expect(within(screen.getByRole('tabpanel',{name:/全部/})).getByText('bad xml')).toBeVisible();expect(screen.getAllByText('bad xml')).toHaveLength(1);fireEvent.click(screen.getByRole('button',{name:'清空事件'}));expect(clear).toHaveBeenCalled();});
it('错误恢复面板透传重试与工程树动作',()=>{const retry=vi.fn(),project=vi.fn();render(<ErrorRecoveryPanel value={{category:'模型编译',summary:'失败',detail:'bad',path:'a.xml',at:1}} onClose={()=>{}} onRetry={retry} onOpenProject={project}/>);fireEvent.click(screen.getByRole('button',{name:'重试当前入口'}));fireEvent.click(screen.getByRole('button',{name:'返回工程树'}));expect(retry).toHaveBeenCalled();expect(project).toHaveBeenCalled();});
it('导入叠层显示阶段进度',()=>{render(<div className="relative"><WorkspaceOverlays loading hasSnapshot={false} progress={{label:'处理模型资源',value:.4}}/></div>);expect(screen.getByRole('progressbar',{name:'处理模型资源'})).toHaveAttribute('aria-valuenow','40');});
it('工具栏更多菜单提供窄桌面动作',()=>{const settings=vi.fn();render(<ToolbarOverflowMenu fullscreen={false} onCommands={()=>{}} onLayout={()=>{}} onSettings={settings} onFullscreen={()=>{}} onHelp={()=>{}} onTheme={()=>{}}/>);fireEvent.click(screen.getByRole('button',{name:'更多工作台操作'}));fireEvent.click(screen.getByRole('menuitem',{name:'工作台设置'}));expect(settings).toHaveBeenCalled();});
});
@@ -0,0 +1,13 @@
import {act,fireEvent,render,screen} from '@testing-library/react';
import {NotificationCenter,ToastViewport,type WorkbenchNotification} from './NotificationCenter';
import {ProjectBreadcrumb} from './ProjectBreadcrumb';
import {SettingsDialog} from './SettingsDialog';
import {LayoutSettingsDialog} from './LayoutSettingsDialog';
const item:WorkbenchNotification={id:1,title:'模型加载完成',detail:'完成',tone:'success',at:0};
describe('第四批工作台组件',()=>{
it('通知中心展示、移除并清空消息',()=>{const dismiss=vi.fn(),clear=vi.fn();render(<NotificationCenter items={[item]} onDismiss={dismiss} onClear={clear}/>);fireEvent.click(screen.getByRole('button',{name:'通知中心'}));expect(screen.getByRole('dialog',{name:'通知中心'})).toHaveTextContent('模型加载完成');fireEvent.click(screen.getByRole('button',{name:'移除通知:模型加载完成'}));expect(dismiss).toHaveBeenCalledWith(1);fireEvent.click(screen.getByText('清空'));expect(clear).toHaveBeenCalled();});
it('Toast 自动关闭',()=>{vi.useFakeTimers();const close=vi.fn();render(<ToastViewport item={item} onDismiss={close}/>);act(()=>vi.advanceTimersByTime(4000));expect(close).toHaveBeenCalledWith(1);vi.useRealTimers();});
it('工程面包屑可切换多入口',()=>{const select=vi.fn();render(<ProjectBreadcrumb projectName="robot" selectedEntry="models/a.xml" entries={[{path:'models/a.xml',label:'A',format:'mjcf'},{path:'models/b.xml',label:'B',format:'mjcf'}]} onSelect={select}/>);fireEvent.click(screen.getByRole('button',{name:'切换模型入口'}));fireEvent.click(screen.getByRole('option',{name:/B/}));expect(select).toHaveBeenCalledWith('models/b.xml');});
it('模型加载期间禁用入口切换',()=>{render(<ProjectBreadcrumb projectName="robot" loading entries={[{path:'a.xml',label:'A',format:'mjcf'},{path:'b.xml',label:'B',format:'mjcf'}]} selectedEntry="a.xml" onSelect={()=>{}}/>);expect(screen.getByRole('button',{name:'切换模型入口'})).toBeDisabled();});
it('设置和布局弹窗透传现有设置动作',()=>{const theme=vi.fn(),preset=vi.fn();render(<><SettingsDialog open onClose={()=>{}} theme="dark" angleUnit="rad" showCollision={false} jointAdvanced={false} forceScale={50} onTheme={theme} onAngleUnit={()=>{}} onShowCollision={()=>{}} onJointAdvanced={()=>{}} onForceScale={()=>{}}/><LayoutSettingsDialog open={false} onClose={()=>{}} leftOpen rightOpen onLeftOpen={()=>{}} onRightOpen={()=>{}} onPreset={preset} onReset={()=>{}}/></>);fireEvent.change(screen.getByLabelText('设置主题'),{target:{value:'light'}});expect(theme).toHaveBeenCalledWith('light');});
});
@@ -0,0 +1,7 @@
import {Columns3,Focus,PanelLeft,PanelRight,RotateCcw} from 'lucide-react';
import {Button,Dialog} from '../../components/ui';
export type LayoutPreset='default'|'viewport'|'project'|'control';
const presets=[{value:'default' as const,label:'默认布局',detail:'左右面板均衡显示',icon:Columns3},{value:'viewport' as const,label:'宽视口',detail:'隐藏两侧面板',icon:Focus},{value:'project' as const,label:'工程浏览',detail:'加宽工程面板',icon:PanelLeft},{value:'control' as const,label:'控制调试',detail:'加宽控制面板',icon:PanelRight}];
export function LayoutSettingsDialog({open,onClose,leftOpen,rightOpen,onLeftOpen,onRightOpen,onPreset,onReset}:{open:boolean;onClose:()=>void;leftOpen:boolean;rightOpen:boolean;onLeftOpen:(value:boolean)=>void;onRightOpen:(value:boolean)=>void;onPreset:(preset:LayoutPreset)=>void;onReset:()=>void}){return <Dialog open={open} onClose={onClose} title="布局设置"><div className="flex gap-2"><Button variant={leftOpen?'primary':'secondary'} aria-pressed={leftOpen} onClick={()=>onLeftOpen(!leftOpen)} icon={<PanelLeft className="h-3.5 w-3.5"/>}></Button><Button variant={rightOpen?'primary':'secondary'} aria-pressed={rightOpen} onClick={()=>onRightOpen(!rightOpen)} icon={<PanelRight className="h-3.5 w-3.5"/>}></Button></div><h3 className="mb-2 mt-4 text-xs font-semibold"></h3><div className="grid grid-cols-2 gap-2">{presets.map(item=><button key={item.value} onClick={()=>onPreset(item.value)} className="flex gap-2 rounded-lg border border-border bg-surface p-3 text-left hover:border-accent hover:bg-accent-soft focus-visible:ring-2 focus-visible:ring-accent/30"><item.icon className="h-4 w-4 shrink-0 text-accent"/><span><span className="block text-xs font-medium">{item.label}</span><span className="mt-0.5 block text-[10px] text-text-tertiary">{item.detail}</span></span></button>)}</div><Button className="mt-4 w-full" onClick={onReset} icon={<RotateCcw className="h-3.5 w-3.5"/>}></Button></Dialog>;}
// eslint-disable-next-line react-refresh/only-export-components
export function dispatchLayoutWidths(left:number,right:number){window.dispatchEvent(new CustomEvent('mujoco-layout-widths',{detail:{left,right}}));}
@@ -0,0 +1,7 @@
import {useEffect,useRef} from 'react';
import {Bell,CheckCircle2,Info,Trash2,TriangleAlert,XCircle} from 'lucide-react';
import {Badge,IconButton,Popover} from '../../components/ui';
export interface WorkbenchNotification{id:number;title:string;detail?:string;tone:'success'|'warning'|'danger'|'info';at:number;}
const icons={success:CheckCircle2,warning:TriangleAlert,danger:XCircle,info:Info};
export function NotificationCenter({items,onDismiss,onClear,onOpenLog}:{items:WorkbenchNotification[];onDismiss:(id:number)=>void;onClear:()=>void;onOpenLog?:()=>void}){return <Popover label="通知中心" trigger={({open,toggle})=><IconButton tooltip="通知中心" aria-label="通知中心" aria-expanded={open} onClick={toggle}><Bell className="h-4 w-4"/>{items.length>0&&<span className="absolute right-0 top-0 h-1.5 w-1.5 rounded-full bg-warning"/>}</IconButton>}>{({close})=><div className="w-80 overflow-hidden rounded-lg border border-border bg-surface-elevated shadow-xl"><header className="flex h-9 items-center justify-between border-b border-border px-3"><h2 className="text-xs font-semibold"></h2><div className="flex gap-2">{onOpenLog&&<button className="text-[10px] text-accent" onClick={()=>{close();onOpenLog();}}></button>}{items.length>0&&<button className="flex items-center gap-1 text-[10px] text-text-tertiary hover:text-danger" onClick={onClear}><Trash2 className="h-3 w-3"/></button>}</div></header><div className="panel-scroll max-h-80 overflow-auto">{items.length?items.map(item=>{const Icon=icons[item.tone];return <article key={item.id} className="flex gap-2 border-b border-border px-3 py-2.5 last:border-0"><Icon className={`mt-0.5 h-4 w-4 shrink-0 ${item.tone==='success'?'text-success':item.tone==='warning'?'text-warning':item.tone==='danger'?'text-danger':'text-accent'}`}/><div className="min-w-0 flex-1"><div className="flex items-center gap-2"><h3 className="truncate text-xs font-medium">{item.title}</h3><Badge>{new Date(item.at).toLocaleTimeString('zh-CN',{hour:'2-digit',minute:'2-digit'})}</Badge></div>{item.detail&&<p className="mt-1 line-clamp-3 text-[10px] leading-4 text-text-tertiary">{item.detail}</p>}</div><IconButton aria-label={`移除通知:${item.title}`} tooltip="移除" onClick={()=>onDismiss(item.id)}><XCircle className="h-3.5 w-3.5"/></IconButton></article>}):<p className="p-6 text-center text-xs text-text-tertiary"></p>}</div></div>}</Popover>;}
export function ToastViewport({item,onDismiss}:{item?:WorkbenchNotification;onDismiss:(id:number)=>void}){const dismissRef=useRef(onDismiss);useEffect(()=>{dismissRef.current=onDismiss;},[onDismiss]);useEffect(()=>{if(!item)return;const timer=window.setTimeout(()=>dismissRef.current(item.id),4000);return()=>window.clearTimeout(timer);},[item]);if(!item)return null;const Icon=icons[item.tone];return <div role="status" className="pointer-events-auto absolute right-4 top-4 z-30 flex w-80 gap-2 rounded-lg border border-border bg-surface-elevated p-3 shadow-xl"><Icon className="h-4 w-4 shrink-0 text-accent"/><div className="min-w-0 flex-1"><p className="text-xs font-medium">{item.title}</p>{item.detail&&<p className="mt-1 line-clamp-2 text-[10px] text-text-tertiary">{item.detail}</p>}</div></div>;}
@@ -0,0 +1,3 @@
import {Activity,ChevronUp,Cpu,MemoryStick,TriangleAlert} from 'lucide-react';
import {Badge,Popover,PropertyRow,Separator} from '../../components/ui';
export function PerformancePopover({fps,stepMs,memoryMb,overBudget}:{fps:number;stepMs:number;memoryMb?:number;overBudget:boolean}){return <Popover label="性能详情" placement="top-left" trigger={({open,toggle})=><button type="button" aria-haspopup="dialog" aria-expanded={open} onClick={toggle} className="flex h-6 items-center gap-3 rounded px-1.5 hover:bg-element-hover focus-visible:ring-2 focus-visible:ring-accent/30"><span className="flex items-center gap-1.5"><Activity className="h-3 w-3"/>FPS {fps.toFixed(0)}</span><span className="flex items-center gap-1.5"><Cpu className="h-3 w-3"/> {stepMs.toFixed(2)} ms</span><ChevronUp className={`h-3 w-3 transition-transform ${open?'rotate-180':''}`}/></button>}>{()=> <div className="w-72 rounded-lg border border-border bg-surface-elevated p-3 text-xs text-text-secondary shadow-xl"><div className="mb-2 flex items-center justify-between"><h2 className="font-semibold text-text-primary"></h2><Badge tone={overBudget?'warning':'success'}>{overBudget?'预算超限':'运行正常'}</Badge></div><PropertyRow label="渲染帧率" value={`${fps.toFixed(0)} FPS`}/><PropertyRow label="物理步进" value={`${stepMs.toFixed(2)} ms`}/><PropertyRow label="浏览器内存" value={memoryMb===undefined?'不可用':`${memoryMb.toFixed(1)} MiB`}/><Separator className="my-2"/>{overBudget?<p className="flex gap-2 text-warning"><TriangleAlert className="mt-0.5 h-3.5 w-3.5 shrink-0"/>线</p>:<p className="flex gap-2 text-text-tertiary"><MemoryStick className="mt-0.5 h-3.5 w-3.5 shrink-0"/></p>}</div>}</Popover>;}
@@ -0,0 +1,4 @@
import {ChevronRight,FolderRoot} from 'lucide-react';
import type {ModelEntry} from '../../project/types';
import {SearchableCombobox} from '../../components/ui';
export function ProjectBreadcrumb({projectName,entries,selectedEntry,loading=false,onSelect}:{projectName:string;entries:ModelEntry[];selectedEntry?:string;loading?:boolean;onSelect:(path:string)=>void}){const parts=selectedEntry?.split('/').filter(Boolean)??[];return <div className="border-b border-border bg-surface px-3 py-2"><div aria-label="当前工程路径" className="flex min-w-0 items-center gap-1 text-[10px] text-text-tertiary"><FolderRoot className="h-3 w-3 shrink-0 text-accent"/><span className="truncate">{projectName}</span>{parts.map((part,index)=><span key={`${part}-${index}`} className="contents"><ChevronRight className="h-3 w-3 shrink-0"/><span className={`truncate ${index===parts.length-1?'text-text-primary':''}`}>{part}</span></span>)}</div>{entries.length>1&&<div className="mt-2"><SearchableCombobox label="切换模型入口" disabled={loading} value={selectedEntry} onChange={onSelect} options={entries.map(entry=>({value:entry.path,label:entry.label,description:entry.path}))}/></div>}</div>;}
@@ -0,0 +1,10 @@
import {fireEvent,render,screen} from '@testing-library/react';
import {ShortcutHelpDialog} from './ShortcutHelpDialog';
import {TreeSearchField} from './TreeSearchField';
import {ViewportHUD} from './ViewportHUD';
describe('第二批工作台组件',()=>{
it('搜索框发送内容并可清除',()=>{const change=vi.fn();const {rerender}=render(<TreeSearchField value="" onChange={change}/>);fireEvent.change(screen.getByRole('searchbox'),{target:{value:'arm'}});expect(change).toHaveBeenCalledWith('arm');rerender(<TreeSearchField value="arm" resultCount={2} onChange={change}/>);expect(screen.getByRole('status')).toHaveTextContent('找到 2 个匹配项');fireEvent.click(screen.getByRole('button',{name:'清除搜索'}));expect(change).toHaveBeenLastCalledWith('');});
it('快捷键帮助展示说明并支持 Escape',()=>{const close=vi.fn();render(<ShortcutHelpDialog open onClose={close}/>);expect(screen.getByRole('dialog',{name:'快捷键与视口操作'})).toBeVisible();expect(screen.getByText('播放 / 暂停')).toBeVisible();fireEvent.keyDown(document,{key:'Escape'});expect(close).toHaveBeenCalledTimes(1);});
it('视口 HUD 复用仿真与选择状态',()=>{render(<ViewportHUD ready paused={false} mode="joint" selection={{bodyId:2,bodyName:'arm',geomId:3,geomType:1,position:[0,0,0]}}/>);expect(screen.getByLabelText('视口状态')).toHaveTextContent('仿真中');expect(screen.getByLabelText('视口状态')).toHaveTextContent('关节拖动');expect(screen.getByLabelText('视口状态')).toHaveTextContent('arm');});
});
@@ -0,0 +1,3 @@
import {Dialog,PropertyRow,Select} from '../../components/ui';
export function SettingsDialog({open,onClose,theme,angleUnit,showCollision,jointAdvanced,forceScale,onTheme,onAngleUnit,onShowCollision,onJointAdvanced,onForceScale}:{open:boolean;onClose:()=>void;theme:'light'|'dark';angleUnit:'rad'|'deg';showCollision:boolean;jointAdvanced:boolean;forceScale:number;onTheme:(value:'light'|'dark')=>void;onAngleUnit:(value:'rad'|'deg')=>void;onShowCollision:(value:boolean)=>void;onJointAdvanced:(value:boolean)=>void;onForceScale:(value:number)=>void}){return <Dialog open={open} onClose={onClose} title="工作台设置"><div className="space-y-4"><section><h3 className="mb-2 text-xs font-semibold"></h3><PropertyRow label="主题" value={<Select aria-label="设置主题" value={theme} onChange={event=>onTheme(event.target.value as 'light'|'dark')}><option value="dark"></option><option value="light"></option></Select>}/></section><section><h3 className="mb-2 text-xs font-semibold"></h3><PropertyRow label="角度单位" value={<Select aria-label="设置角度单位" value={angleUnit} onChange={event=>onAngleUnit(event.target.value as 'rad'|'deg')}><option value="rad"></option><option value="deg"></option></Select>}/><Check label="显示碰撞几何" checked={showCollision} onChange={onShowCollision}/><Check label="关节高级信息" checked={jointAdvanced} onChange={onJointAdvanced}/><label className="mt-3 block text-xs text-text-tertiary"><span className="mb-1 flex justify-between"><span></span><output>{forceScale.toFixed(0)} N</output></span><input aria-label="设置外力强度" type="range" min={5} max={200} value={forceScale} onChange={event=>onForceScale(Number(event.target.value))} className="control-slider"/></label></section></div></Dialog>;}
function Check({label,checked,onChange}:{label:string;checked:boolean;onChange:(value:boolean)=>void}){return <label className="mt-2 flex items-center justify-between text-xs text-text-tertiary"><span>{label}</span><input type="checkbox" aria-label={label} checked={checked} onChange={event=>onChange(event.target.checked)} className="accent-accent focus-visible:ring-2 focus-visible:ring-accent/30"/></label>;}
@@ -0,0 +1,3 @@
import {Dialog,Kbd,Separator} from '../../components/ui';
const shortcuts=[['Space','播放 / 暂停'],['R','重置仿真'],['1','选择模式'],['2','关节拖动'],['3','外力施加']];
export function ShortcutHelpDialog({open,onClose}:{open:boolean;onClose:()=>void}){return <Dialog open={open} onClose={onClose} title="快捷键与视口操作"><section><h3 className="mb-2 text-xs font-semibold text-text-primary"></h3><dl className="space-y-2">{shortcuts.map(([key,label])=><div key={key} className="flex items-center justify-between text-xs"><dt className="text-text-secondary">{label}</dt><dd><Kbd>{key}</Kbd></dd></div>)}</dl></section><Separator className="my-4"/><section><h3 className="mb-2 text-xs font-semibold text-text-primary"></h3><ul className="space-y-1.5 text-xs text-text-secondary"><li></li><li></li><li></li><li></li></ul></section></Dialog>;}
@@ -0,0 +1,34 @@
import {useState,type ReactNode} from 'react';
import {Box,FolderTree,Info,Settings2,SlidersHorizontal} from 'lucide-react';
import type {ModelEntry} from '../../project/types';
import {countProjectSearchResults,ProjectTree,type ProjectTreeFile} from '../../project/ProjectTree';
import {countModelStructureSearchResults,ModelStructureTree} from '../../project/ModelStructureTree';
import type {SimulationSnapshot} from '../../simulation/SimulationSession';
import type {UrdfBaseMode,UrdfLoadMode} from '../../simulation/PhysicsAdapter';
import type {ViewerSelection} from '../../viewer/MuJoCoViewer';
import {Badge,Button,CollapsibleSection,CopyButton,PropertyRow,ResizablePanel,Select,Tabs} from '../../components/ui';
import {TreeSearchField} from './TreeSearchField';
import {ProjectBreadcrumb} from './ProjectBreadcrumb';
export function SidebarPanel({title,side,children,visible=true}:{title:string;side:'left'|'right';children:ReactNode;visible?:boolean}){return <ResizablePanel side={side} storageKey={`mujoco-${side}-sidebar-width`} visible={visible}><aside className={`flex h-full w-full min-w-0 flex-col overflow-hidden bg-panel ${side==='left'?'border-r':'border-l'} border-border`}><h2 className="flex h-10 shrink-0 items-center gap-2 border-b border-border bg-panel px-3 text-sm font-semibold text-text-primary"><Settings2 aria-hidden="true" className="h-4 w-4 text-accent"/>{title}</h2>{children}</aside></ResizablePanel>;}
export function ProjectSidebar({projectName,files,entries,selectedEntry,snapshot,loading,visible=true,onRemove,onSelectEntry,onJointHover}:{projectName?:string;files:ProjectTreeFile[];entries:ModelEntry[];selectedEntry?:string;snapshot?:SimulationSnapshot;loading:boolean;visible?:boolean;onRemove:()=>void;onSelectEntry:(path:string)=>void;onJointHover:(jointId:number|null)=>void}){const [tab,setTab]=useState<'project'|'structure'>('project'),[fileQuery,setFileQuery]=useState(''),[structureQuery,setStructureQuery]=useState('');const fileMatches=countProjectSearchResults(files,fileQuery),structureMatches=snapshot?countModelStructureSearchResults(snapshot.bodies,snapshot.joints,structureQuery):0;return <SidebarPanel title="工程资源" side="left" visible={visible}>{projectName?<><div className="flex shrink-0 items-center gap-2 border-b border-border px-3 py-2.5"><div className="min-w-0 flex-1"><div className="truncate text-sm font-medium text-accent" title={projectName}>{projectName}</div><div className="mt-0.5 text-[10px] text-text-tertiary">{files.length} </div></div><Button variant="danger" onClick={onRemove} disabled={loading}></Button></div><ProjectBreadcrumb projectName={projectName} entries={entries} selectedEntry={selectedEntry} loading={loading} onSelect={onSelectEntry}/><Tabs label="工程侧栏" value={tab} onValueChange={setTab} items={[{value:'project',label:'工程',icon:<FolderTree className="h-3.5 w-3.5"/>,content:<><TreeSearchField value={fileQuery} onChange={setFileQuery} resultCount={fileMatches} label="搜索工程文件" placeholder="搜索文件或目录…"/><div className="px-2 pb-3"><ProjectTree key={projectName} files={files} entries={entries} selectedEntry={selectedEntry} query={fileQuery}/></div></>},{value:'structure',label:'模型结构',icon:<Box className="h-3.5 w-3.5"/>,disabled:!snapshot,content:snapshot?<><TreeSearchField value={structureQuery} onChange={setStructureQuery} resultCount={structureMatches} label="搜索模型结构" placeholder="搜索 Body 或关节…"/><div className="px-2 pb-3"><ModelStructureTree bodies={snapshot.bodies} joints={snapshot.joints} onJointHover={onJointHover} query={structureQuery}/></div></>:<p className="p-4 text-center text-xs text-text-tertiary"></p>}]}/></>:<div className="p-4 text-center text-sm text-text-tertiary"></div>}</SidebarPanel>;}
interface ModelControlsProps{
snapshot?:SimulationSnapshot;selection:ViewerSelection|null;selectedFormat?:ModelEntry['format'];loading:boolean;visible?:boolean;
urdfMode:UrdfLoadMode;baseMode:UrdfBaseMode;showCollision:boolean;ignoreJointLimits:boolean;jointAdvanced:boolean;angleUnit:'rad'|'deg';forceScale:number;
onUrdfMode:(value:UrdfLoadMode)=>void;onBaseMode:(value:UrdfBaseMode)=>void;onShowCollision:(value:boolean)=>void;
onResetJoints:()=>void;onToggleJointLimits:()=>void;onToggleAdvanced:()=>void;onToggleAngleUnit:()=>void;
onActuator:(id:number,value:number)=>void;onJoint:(id:number,value:number)=>void;onForceScale:(value:number)=>void;
}
export function ModelControlsSidebar(props:ModelControlsProps){const [tab,setTab]=useState<'properties'|'controls'>('properties'),s=props.snapshot;if(!s)return <SidebarPanel title="模型与控制" side="right" visible={props.visible}><div className="p-4 text-sm text-text-tertiary"></div></SidebarPanel>;
const properties=<><CollapsibleSection title="模型信息" defaultOpen badge={<Badge>{s.model.nbody} Body</Badge>}><div><PropertyRow label="Body" value={s.model.nbody}/><PropertyRow label="Joint" value={s.model.njnt}/><PropertyRow label="Geom" value={s.model.ngeom}/><PropertyRow label="Actuator" value={s.model.nu}/><PropertyRow label="qpos / qvel" value={`${s.model.nq} / ${s.model.nv}`}/></div></CollapsibleSection>
{props.selectedFormat==='urdf'&&<CollapsibleSection title="URDF 处理方式" defaultOpen={false}><Select aria-label="URDF 处理方式" className="w-full" value={props.urdfMode} disabled={props.loading} onChange={event=>props.onUrdfMode(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-text-secondary"><span className="mb-1 block"></span><Select aria-label="URDF 基座类型" className="w-full" value={props.baseMode} disabled={props.loading||props.urdfMode==='native'} onChange={event=>props.onBaseMode(event.target.value as UrdfBaseMode)}><option value="floating">Free Joint</option><option value="fixed"></option></Select></label><p className="mt-2 text-xs text-text-tertiary">MJCF visual mesh z=0</p><Check label="显示碰撞几何" checked={props.showCollision} onChange={props.onShowCollision}/></CollapsibleSection>}
<CollapsibleSection title="当前选择" defaultOpen>{props.selection?<div className="text-xs"><PropertyRow label="Body" value={props.selection.bodyName} action={<CopyButton value={props.selection.bodyName} label="复制 Body 名称"/>}/><PropertyRow label="标识" value={`${props.selection.bodyId} / ${props.selection.geomId} / ${props.selection.geomType}`} action={<CopyButton value={`body ${props.selection.bodyId}, geom ${props.selection.geomId}, type ${props.selection.geomType}`} label="复制标识"/>}/><PropertyRow label="位置" value={props.selection.position.map(value=>value.toFixed(3)).join(', ')} action={<CopyButton value={props.selection.position.join(', ')} label="复制位置"/>}/></div>:<p className="flex items-center gap-2 text-xs text-text-tertiary"><Info className="h-3.5 w-3.5"/></p>}</CollapsibleSection></>;
const controls=<><CollapsibleSection title="Actuator" defaultOpen={false} badge={<Badge>{s.actuators.length}</Badge>}>{s.actuators.length?s.actuators.map(actuator=><ControlSlider key={actuator.id} label={actuator.name} value={actuator.value} min={actuator.min} max={actuator.max} onChange={value=>props.onActuator(actuator.id,value)}/>):<p className="text-xs text-text-tertiary"> actuator</p>}</CollapsibleSection>
<CollapsibleSection title="关节" defaultOpen badge={<Badge>{s.joints.length}</Badge>}><div className="mb-4 grid grid-cols-2 gap-2"><Button onClick={props.onResetJoints}></Button><Button variant={props.ignoreJointLimits?'primary':'secondary'} aria-pressed={props.ignoreJointLimits} onClick={props.onToggleJointLimits}></Button><Button variant={props.jointAdvanced?'primary':'secondary'} aria-pressed={props.jointAdvanced} onClick={props.onToggleAdvanced}></Button><Button variant={props.angleUnit==='deg'?'primary':'secondary'} aria-pressed={props.angleUnit==='deg'} onClick={props.onToggleAngleUnit}>{props.angleUnit==='rad'?'rad 弧度制':'° 角度制'}</Button></div>{s.joints.map(joint=>{const scale=joint.type===3&&props.angleUnit==='deg'?180/Math.PI:1,unit=joint.type===3?(props.angleUnit==='deg'?'°':' rad'):joint.type===2?' m':'';return <ControlSlider key={joint.id} label={`${joint.name}${joint.editable?'':'(只读)'}`} value={joint.value*scale} min={joint.min*scale} max={joint.max*scale} unit={unit} advanced={props.jointAdvanced} limited={joint.limited} limitsIgnored={joint.limitsIgnored} limitMin={joint.limitMin*scale} limitMax={joint.limitMax*scale} disabled={!joint.editable} onChange={value=>props.onJoint(joint.id,value/scale)}/>;})}</CollapsibleSection>
<CollapsibleSection title="外力强度" defaultOpen={false}><ControlSlider label={`${props.forceScale.toFixed(0)} N/屏幕单位`} value={props.forceScale} min={5} max={200} onChange={props.onForceScale}/><p className="text-xs text-text-tertiary"></p></CollapsibleSection></>;
return <SidebarPanel title="模型与控制" side="right" visible={props.visible}><Tabs label="模型控制侧栏" value={tab} onValueChange={setTab} items={[{value:'properties',label:'属性',icon:<Info className="h-3.5 w-3.5"/>,content:properties},{value:'controls',label:'控制',icon:<SlidersHorizontal className="h-3.5 w-3.5"/>,content:controls}]}/></SidebarPanel>;
}
function Check({label,checked,onChange}:{label:string;checked:boolean;onChange:(value:boolean)=>void}){return <label className="mt-3 flex items-center gap-2 text-xs text-text-secondary"><input type="checkbox" className="rounded accent-accent focus-visible:ring-2 focus-visible:ring-accent/40 focus-visible:ring-offset-1 focus-visible:ring-offset-panel" checked={checked} onChange={event=>onChange(event.target.checked)}/>{label}</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:(value: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 gap-2"><span className="truncate text-text-secondary">{label}</span><output className="technical-value text-text-primary">{format(sane)}</output></span><input className="control-slider rounded focus-visible:ring-2 focus-visible:ring-accent/40 focus-visible:ring-offset-2 focus-visible:ring-offset-panel" type="range" disabled={disabled} value={Math.min(max,Math.max(min,sane))} min={min} max={max} step={(max-min)/500||.001} onChange={event=>onChange(Number(event.target.value))}/>{advanced&&<span className="mt-1 flex justify-between text-[10px] text-text-tertiary"><span> {limited?format(limitMin):'无限制'}</span>{limitsIgnored&&limited&&<span className="text-warning"></span>}<span> {limited?format(limitMax):'无限制'}</span></span>}</label>;}
@@ -0,0 +1,7 @@
import type {ReactNode} from 'react';
import {Box,Clock3,MemoryStick,TriangleAlert} from 'lucide-react';
import {Kbd} from '../../components/ui';
import {PerformancePopover} from './PerformancePopover';
export interface StatusBarProps{time?:number;fps:number;stepMs:number;memoryMb?:number;loaded:boolean;overBudget:boolean;}
function Item({icon:Icon,children}:{icon:typeof Clock3;children:ReactNode}){return <span className="flex items-center gap-1.5"><Icon aria-hidden="true" className="h-3 w-3 text-text-tertiary"/>{children}</span>;}
export function StatusBar({time,fps,stepMs,memoryMb,loaded,overBudget}:StatusBarProps){return <footer className="technical-value relative z-30 flex h-7 shrink-0 items-center gap-5 border-t border-border bg-panel px-3 text-[11px] text-text-tertiary"><Item icon={Clock3}> {time?.toFixed(3)??'—'} s</Item><PerformancePopover fps={fps} stepMs={stepMs} memoryMb={memoryMb} overBudget={overBudget}/><Item icon={MemoryStick}> {memoryMb===undefined?'—':`${memoryMb.toFixed(1)} MiB`}</Item><Item icon={Box}>WASM {loaded?'已加载':'未加载'}</Item>{overBudget&&<span className="flex items-center gap-1 text-warning"><TriangleAlert className="h-3 w-3"/>线</span>}<span className="ml-auto hidden items-center gap-1.5 xl:flex"><Kbd>Space</Kbd> / · <Kbd>R</Kbd> · <Kbd>1/2/3</Kbd> </span></footer>;}
@@ -0,0 +1,8 @@
import {fireEvent,render,screen} from '@testing-library/react';
import {CommandPalette,type WorkbenchCommand} from './CommandPalette';
import {PerformancePopover} from './PerformancePopover';
describe('第三批工作台组件',()=>{
it('命令面板可搜索并执行现有命令',()=>{const run=vi.fn(),close=vi.fn(),commands:WorkbenchCommand[]=[{id:'reset',label:'重置仿真',group:'仿真',run},{id:'theme',label:'切换主题',group:'外观',run:vi.fn()}];render(<CommandPalette open onClose={close} commands={commands}/>);const input=screen.getByLabelText('搜索命令');fireEvent.change(input,{target:{value:'重置'}});expect(screen.queryByText('切换主题')).not.toBeInTheDocument();fireEvent.keyDown(input,{key:'Enter'});expect(run).toHaveBeenCalledTimes(1);expect(close).toHaveBeenCalledTimes(1);expect(input).toHaveAttribute('aria-controls');expect(input).toHaveAttribute('aria-activedescendant');});
it('状态栏性能入口展示已有指标',()=>{render(<PerformancePopover fps={59.6} stepMs={1.25} memoryMb={42.5} overBudget={false}/>);fireEvent.click(screen.getByRole('button'));expect(screen.getByRole('dialog',{name:'性能详情'})).toHaveTextContent('60 FPS');expect(screen.getByRole('dialog',{name:'性能详情'})).toHaveTextContent('42.5 MiB');fireEvent.keyDown(document,{key:'k',ctrlKey:true});expect(screen.queryByRole('dialog',{name:'性能详情'})).not.toBeInTheDocument();});
});
@@ -0,0 +1,3 @@
import {CircleHelp,Expand,LayoutDashboard,Maximize,Search,Settings,SunMoon} from 'lucide-react';
import {DropdownMenu} from '../../components/ui';
export function ToolbarOverflowMenu({fullscreen,onCommands,onLayout,onSettings,onFullscreen,onHelp,onTheme}:{fullscreen:boolean;onCommands:()=>void;onLayout:()=>void;onSettings:()=>void;onFullscreen:()=>void;onHelp:()=>void;onTheme:()=>void}){return <DropdownMenu label="更多工作台操作" className="xl:hidden" items={[{id:'commands',label:'命令面板',icon:<Search className="h-4 w-4"/>,onSelect:onCommands},{id:'layout',label:'布局设置',icon:<LayoutDashboard className="h-4 w-4"/>,onSelect:onLayout},{id:'settings',label:'工作台设置',icon:<Settings className="h-4 w-4"/>,onSelect:onSettings},{id:'fullscreen',label:fullscreen?'退出全屏':'进入全屏',icon:fullscreen?<Expand className="h-4 w-4"/>:<Maximize className="h-4 w-4"/>,onSelect:onFullscreen},{id:'help',label:'快捷键帮助',icon:<CircleHelp className="h-4 w-4"/>,onSelect:onHelp},{id:'theme',label:'切换主题',icon:<SunMoon className="h-4 w-4"/>,onSelect:onTheme}]}/>;}
@@ -0,0 +1,3 @@
import {Search,X} from 'lucide-react';
import {IconButton} from '../../components/ui';
export function TreeSearchField({value,onChange,resultCount,placeholder='搜索…',label='搜索树'}:{value:string;onChange:(value:string)=>void;resultCount?:number;placeholder?:string;label?:string}){return <div className="m-2"><div className="relative"><Search aria-hidden="true" className="pointer-events-none absolute left-2 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-text-tertiary"/><input type="search" aria-label={label} value={value} onChange={event=>onChange(event.target.value)} placeholder={placeholder} className="h-8 w-full rounded-md border border-border bg-input pl-7 pr-8 text-xs text-text-primary placeholder:text-text-tertiary focus-visible:ring-2 focus-visible:ring-accent/35"/>{value&&<span className="absolute right-0.5 top-0.5"><IconButton aria-label="清除搜索" tooltip="清除搜索" onClick={()=>onChange('')}><X className="h-3.5 w-3.5"/></IconButton></span>}</div>{value&&resultCount!==undefined&&<p role="status" className="px-1 pt-1.5 text-[10px] text-text-tertiary"> {resultCount} </p>}</div>;}
@@ -0,0 +1,5 @@
import {Crosshair,Hand,MousePointer2,RotateCcw} from 'lucide-react';
import type {InteractionMode} from '../../viewer/MuJoCoViewer';
import {IconButton,ToolbarToggleGroup,type ToolbarItem} from '../../components/ui';
const tools:ToolbarItem<InteractionMode>[]=[{value:'select',label:'选择',icon:MousePointer2},{value:'joint',label:'关节拖动',icon:Hand},{value:'force',label:'外力施加',icon:Crosshair}];
export function ViewerToolDock({mode,onModeChange,onResetCamera}:{mode:InteractionMode;onModeChange:(mode:InteractionMode)=>void;onResetCamera:()=>void}){return <div className="flex items-center gap-1"><ToolbarToggleGroup items={tools} value={mode} onChange={onModeChange} label="视口交互模式"/><IconButton tooltip="相机复位" aria-label="相机复位" onClick={onResetCamera}><RotateCcw className="h-3.5 w-3.5"/></IconButton></div>;}
@@ -0,0 +1,5 @@
import {CirclePause,CirclePlay,MousePointer2} from 'lucide-react';
import type {InteractionMode,ViewerSelection} from '../../viewer/MuJoCoViewer';
import {Badge} from '../../components/ui';
const labels:Record<InteractionMode,string>={select:'选择',joint:'关节拖动',force:'外力施加'};
export function ViewportHUD({paused,mode,selection,ready}:{paused:boolean;mode:InteractionMode;selection:ViewerSelection|null;ready:boolean}){if(!ready)return null;return <div aria-label="视口状态" className="pointer-events-none absolute left-3 top-3 z-10 flex max-w-[70%] flex-wrap items-center gap-1.5"><Badge tone={paused?'neutral':'success'}>{paused?<CirclePause className="h-3 w-3"/>:<CirclePlay className="h-3 w-3"/>}{paused?'已暂停':'仿真中'}</Badge><Badge tone="accent"><MousePointer2 className="h-3 w-3"/>{labels[mode]}</Badge>{selection&&<Badge title={`body ${selection.bodyId} · geom ${selection.geomId}`}>{selection.bodyName}</Badge>}</div>;}
@@ -0,0 +1,4 @@
import {fireEvent,render,screen} from '@testing-library/react';
import {WorkbenchHeader} from './WorkbenchHeader';
const fn=()=>{};
describe('WorkbenchHeader',()=>{it('透传仿真动作且保留可访问名称',()=>{const pause=vi.fn(),step=vi.fn(),reset=vi.fn(),speed=vi.fn();render(<WorkbenchHeader paused ready speed={1} theme="dark" loading={false} leftOpen rightOpen fullscreen={false} center={<span></span>} onFiles={fn} onFolder={fn} onTogglePause={pause} onStep={step} onReset={reset} onSpeed={speed} onToggleLeft={fn} onToggleRight={fn} onToggleTheme={fn} onHelp={fn} onCommands={fn} onToggleFullscreen={fn}/>);fireEvent.click(screen.getByRole('button',{name:'▶ 播放'}));fireEvent.click(screen.getByRole('button',{name:'单步'}));fireEvent.click(screen.getByRole('button',{name:'重置'}));fireEvent.change(screen.getByLabelText('仿真速度'),{target:{value:'2'}});expect(pause).toHaveBeenCalledTimes(1);expect(step).toHaveBeenCalledTimes(1);expect(reset).toHaveBeenCalledTimes(1);expect(speed).toHaveBeenCalledWith(2);expect(screen.getByRole('button',{name:'切换到白天主题'})).toBeInTheDocument();expect(screen.getByRole('button',{name:'隐藏工程面板'})).toHaveAttribute('aria-expanded','true');expect(screen.getByRole('button',{name:'打开命令面板'})).toBeInTheDocument();expect(screen.getByRole('button',{name:'进入全屏'})).toBeInTheDocument();});});
@@ -0,0 +1,5 @@
import type {ChangeEvent,ReactNode} from 'react';
import {CircleHelp,Expand,FolderOpen,Minimize,PanelLeft,PanelRight,Pause,Play,RotateCcw,Search,StepForward,Sun,Moon,Upload} from 'lucide-react';
import {Button,IconButton,Select} from '../../components/ui';
const fileActionClass='inline-flex h-7 cursor-pointer items-center gap-1.5 rounded-md border border-border bg-surface px-2 text-xs font-medium text-text-primary transition-colors hover:bg-element-hover focus-within:ring-2 focus-within:ring-accent/30';
export function WorkbenchHeader({paused,ready,speed,theme,loading,leftOpen,rightOpen,fullscreen,center,endActions,compactMenu,onFiles,onFolder,onTogglePause,onStep,onReset,onSpeed,onToggleLeft,onToggleRight,onToggleTheme,onHelp,onCommands,onToggleFullscreen}:{paused:boolean;ready:boolean;speed:number;theme:'light'|'dark';loading:boolean;leftOpen:boolean;rightOpen:boolean;fullscreen:boolean;center:ReactNode;endActions?:ReactNode;compactMenu?:ReactNode;onFiles:(event:ChangeEvent<HTMLInputElement>)=>void;onFolder:(event:ChangeEvent<HTMLInputElement>)=>void;onTogglePause:()=>void;onStep:()=>void;onReset:()=>void;onSpeed:(value:number)=>void;onToggleLeft:()=>void;onToggleRight:()=>void;onToggleTheme:()=>void;onHelp:()=>void;onCommands:()=>void;onToggleFullscreen:()=>void}){return <header className="relative z-40 grid h-10 shrink-0 grid-cols-[minmax(0,1fr)_auto_minmax(max-content,1fr)] items-center gap-2 border-b border-border bg-panel px-2.5"><div className="flex min-w-0 items-center gap-1"><h1 className="mr-2 hidden truncate border-r border-border pr-3 text-sm font-semibold text-text-primary xl:block">MuJoCo Web 仿</h1><label aria-disabled={loading} className={`${fileActionClass} ${loading?'pointer-events-none opacity-40':''}`}><Upload className="h-3.5 w-3.5"/><input className="sr-only" type="file" disabled={loading} multiple accept=".xml,.urdf,.zip,.obj,.stl,.dae,.msh,.png,.jpg,.jpeg,.bmp,.tga,.hdr" onChange={onFiles}/></label><label aria-disabled={loading} className={`${fileActionClass} ${loading?'pointer-events-none opacity-40':''}`}><FolderOpen className="h-3.5 w-3.5"/><input className="sr-only" type="file" disabled={loading} multiple {...({webkitdirectory:'',directory:''} as object)} onChange={onFolder}/></label></div><div className="flex items-center justify-center">{center}</div><div className="flex min-w-0 items-center justify-end gap-0.5"><Button variant="ghost" onClick={onTogglePause} disabled={!ready} aria-label={paused?'▶ 播放':'⏸ 暂停'} icon={paused?<Play className="h-3.5 w-3.5"/>:<Pause className="h-3.5 w-3.5"/>}>{paused?'播放':'暂停'}</Button><IconButton tooltip="单步" aria-label="单步" onClick={onStep} disabled={!ready||!paused}><StepForward className="h-3.5 w-3.5"/></IconButton><IconButton tooltip="重置" aria-label="重置" onClick={onReset} disabled={!ready}><RotateCcw className="h-3.5 w-3.5"/></IconButton><Select aria-label="仿真速度" value={speed} disabled={loading} onChange={event=>onSpeed(Number(event.target.value))} className="w-[70px]"><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="mx-1 h-5 border-l border-border"/><IconButton tooltip={leftOpen?'隐藏工程面板':'显示工程面板'} aria-label={leftOpen?'隐藏工程面板':'显示工程面板'} aria-expanded={leftOpen} onClick={onToggleLeft}><PanelLeft className="h-4 w-4"/></IconButton><IconButton tooltip={rightOpen?'隐藏属性面板':'显示属性面板'} aria-label={rightOpen?'隐藏属性面板':'显示属性面板'} aria-expanded={rightOpen} onClick={onToggleRight}><PanelRight className="h-4 w-4"/></IconButton>{endActions}{compactMenu}<IconButton className="hidden xl:inline-flex" tooltip="命令面板(Ctrl+K" aria-label="打开命令面板" onClick={onCommands}><Search className="h-4 w-4"/></IconButton><IconButton className="hidden xl:inline-flex" tooltip={fullscreen?'退出全屏':'进入全屏'} aria-label={fullscreen?'退出全屏':'进入全屏'} onClick={onToggleFullscreen}>{fullscreen?<Minimize className="h-4 w-4"/>:<Expand className="h-4 w-4"/>}</IconButton><IconButton className="hidden xl:inline-flex" tooltip="快捷键帮助" aria-label="快捷键帮助" onClick={onHelp}><CircleHelp className="h-4 w-4"/></IconButton><IconButton className="hidden xl:inline-flex" tooltip={theme==='dark'?'切换到白天主题':'切换到黑夜主题'} aria-label={theme==='dark'?'切换到白天主题':'切换到黑夜主题'} onClick={onToggleTheme}>{theme==='dark'?<Sun className="h-4 w-4"/>:<Moon className="h-4 w-4"/>}</IconButton></div></header>;}
@@ -0,0 +1,5 @@
import {Box,LoaderCircle,UploadCloud} from 'lucide-react';
import {ProgressBar,Skeleton} from '../../components/ui';
export interface ImportProgress{label:string;value:number;}
export function EmptyWorkspace({compact=false}:{compact?:boolean}){return <div className={`rounded-xl border border-dashed border-border-strong bg-panel/90 text-center shadow-sm ${compact?'m-3 p-4':'p-7'}`}><span className="mx-auto mb-3 grid h-10 w-10 place-items-center rounded-xl bg-accent-soft text-accent">{compact?<Box className="h-5 w-5"/>:<UploadCloud className="h-5 w-5"/>}</span><p className="text-sm font-semibold text-text-primary"></p><p className="mt-1 text-xs text-text-tertiary"> MJCF/XMLURDF ZIP</p></div>;}
export function WorkspaceOverlays({loading,hasSnapshot,progress}:{loading:boolean;hasSnapshot:boolean;progress?:ImportProgress}){return <>{!hasSnapshot&&!loading&&<div className="pointer-events-none absolute inset-0 grid place-items-center"><EmptyWorkspace/></div>}{loading&&<div role="status" aria-live="polite" aria-label={progress?.label??'正在加载 MuJoCo 与模型'} className="absolute inset-0 z-20 grid place-items-center bg-app/75 backdrop-blur-sm"><div className="w-80 rounded-xl border border-border bg-panel px-5 py-4 text-sm font-medium text-text-primary shadow-xl"><div className="flex items-center gap-3"><LoaderCircle aria-hidden="true" className="h-5 w-5 animate-spin text-accent"/> MuJoCo </div>{progress?<div className="mt-4"><ProgressBar value={progress.value} label={progress.label}/></div>:<div className="mt-4 space-y-2"><Skeleton className="h-2.5 w-full"/><Skeleton className="h-2.5 w-4/5"/></div>}</div></div>}</>;}
@@ -0,0 +1,2 @@
import type {ReactNode} from 'react';
export function Badge({children,tone='neutral',className='',title}:{children:ReactNode;tone?:'neutral'|'accent'|'success'|'warning';className?:string;title?:string}){const toneClass={neutral:'border-border bg-surface text-text-secondary',accent:'border-success-border bg-accent-soft text-accent',success:'border-success-border bg-success-soft text-success',warning:'border-warning-border bg-warning-soft text-warning'}[tone];return <span title={title} className={`inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-[10px] font-medium ${toneClass} ${className}`}>{children}</span>;}
@@ -0,0 +1,18 @@
import type {ButtonHTMLAttributes,ReactNode} from 'react';
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement>{
variant?:'primary'|'secondary'|'ghost'|'danger';
size?:'sm'|'md'|'icon';
icon?:ReactNode;
}
export function Button({variant='secondary',size='sm',icon,className='',children,type='button',...props}:ButtonProps){
const variants={
primary:'border-transparent bg-accent text-white hover:bg-accent-hover',
secondary:'border-border bg-surface text-text-primary hover:bg-element-hover',
ghost:'border-transparent bg-transparent text-text-secondary hover:bg-element-hover hover:text-text-primary',
danger:'border-danger-border bg-danger-soft text-danger hover:bg-danger hover:text-white',
};
const sizes={sm:'h-7 gap-1.5 rounded-md px-2 text-xs',md:'h-8 gap-2 rounded-md px-3 text-sm',icon:'h-7 w-7 rounded-md p-0'};
return <button type={type} className={`inline-flex shrink-0 select-none items-center justify-center border font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/30 disabled:cursor-not-allowed disabled:opacity-40 ${variants[variant]} ${sizes[size]} ${className}`.trim()} {...props}>{icon&&<span aria-hidden="true" className="flex items-center">{icon}</span>}{children}</button>;
}
@@ -0,0 +1,3 @@
import {fireEvent,render,screen} from '@testing-library/react';
import {CollapsibleSection} from './CollapsibleSection';
describe('CollapsibleSection',()=>{it('遵循默认折叠状态并可展开',()=>{render(<CollapsibleSection title="低频设置" defaultOpen={false}><span></span></CollapsibleSection>);const trigger=screen.getByRole('button',{name:'低频设置'});expect(trigger).toHaveAttribute('aria-expanded','false');expect(screen.queryByText('内容')).not.toBeInTheDocument();fireEvent.click(trigger);expect(trigger).toHaveAttribute('aria-expanded','true');expect(screen.getByText('内容')).toBeVisible();});it('forceOpen 时保持内容可见',()=>{render(<CollapsibleSection title="警告" defaultOpen={false} forceOpen><span></span></CollapsibleSection>);expect(screen.getByText('错误详情')).toBeVisible();});});
@@ -0,0 +1,11 @@
import {useState,type ReactNode} from 'react';
import {ChevronRight} from 'lucide-react';
export function CollapsibleSection({title,children,defaultOpen=true,forceOpen=false,badge}:{title:string;children:ReactNode;defaultOpen?:boolean;forceOpen?:boolean;badge?:ReactNode}){
const [open,setOpen]=useState(defaultOpen);const expanded=forceOpen||open;
return <section className="border-b border-border">
<button type="button" aria-expanded={expanded} onClick={()=>setOpen(value=>!value)} className="flex h-9 w-full items-center gap-2 px-3 text-left text-xs font-semibold text-text-secondary transition-colors hover:bg-element-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-accent/30">
<ChevronRight aria-hidden="true" className={`h-3.5 w-3.5 transition-transform ${expanded?'rotate-90':''}`}/><span className="min-w-0 flex-1 truncate">{title}</span>{badge}
</button>
{expanded&&<div className="px-3 pb-3">{children}</div>}
</section>;
}
@@ -0,0 +1,4 @@
import type {ReactNode} from 'react';
import {Button} from './Button';
import {Dialog} from './Dialog';
export function ConfirmDialog({open,title,children,confirmLabel='确认',cancelLabel='取消',danger=false,onConfirm,onClose}:{open:boolean;title:string;children:ReactNode;confirmLabel?:string;cancelLabel?:string;danger?:boolean;onConfirm:()=>void;onClose:()=>void}){return <Dialog open={open} onClose={onClose} title={title} footer={<div className="flex justify-end gap-2"><Button onClick={onClose}>{cancelLabel}</Button><Button variant={danger?'danger':'primary'} onClick={onConfirm}>{confirmLabel}</Button></div>}>{children}</Dialog>;}
@@ -0,0 +1,4 @@
import {useEffect,useState} from 'react';
import {Check,Copy} from 'lucide-react';
import {IconButton} from './IconButton';
export function CopyButton({value,label='复制'}:{value:string;label?:string}){const [copied,setCopied]=useState(false);useEffect(()=>{if(!copied)return;const timer=window.setTimeout(()=>setCopied(false),1200);return()=>window.clearTimeout(timer);},[copied]);return <IconButton aria-label={copied?'已复制':label} tooltip={copied?'已复制':label} onClick={()=>void (async()=>{try{if(!navigator.clipboard?.writeText)return;await navigator.clipboard.writeText(value);setCopied(true);}catch{setCopied(false);}})()} className="h-5 w-5">{copied?<Check className="h-3 w-3 text-success"/>:<Copy className="h-3 w-3"/>}</IconButton>;}
@@ -0,0 +1,6 @@
import {fireEvent,render,screen} from '@testing-library/react';
import {useState} from 'react';
import {Dialog} from './Dialog';
function Fixture(){const [open,setOpen]=useState(false);return <><button onClick={()=>setOpen(true)}></button><Dialog open={open} onClose={()=>setOpen(false)} title="入口选择"><button></button><button></button></Dialog></>;}
describe('Dialog',()=>{it('支持 Escape 关闭并恢复触发器焦点',()=>{render(<Fixture/>);const trigger=screen.getByRole('button',{name:'打开'});trigger.focus();fireEvent.click(trigger);expect(screen.getByRole('dialog')).toBeVisible();fireEvent.keyDown(document,{key:'Escape'});expect(screen.queryByRole('dialog')).not.toBeInTheDocument();expect(trigger).toHaveFocus();});it('将 Tab 焦点限制在弹窗内',()=>{render(<Fixture/>);fireEvent.click(screen.getByRole('button',{name:'打开'}));const first=screen.getByRole('button',{name:'关闭'}),last=screen.getByRole('button',{name:'最后一个'});last.focus();fireEvent.keyDown(document,{key:'Tab'});expect(first).toHaveFocus();first.focus();fireEvent.keyDown(document,{key:'Tab',shiftKey:true});expect(last).toHaveFocus();});it('全屏时将 Portal 挂载到全屏元素内部',()=>{const host=document.createElement('div');document.body.append(host);Object.defineProperty(document,'fullscreenElement',{configurable:true,value:host});const {unmount}=render(<Dialog open onClose={()=>{}} title="全屏弹窗"></Dialog>);expect(host).toContainElement(screen.getByRole('dialog'));unmount();Object.defineProperty(document,'fullscreenElement',{configurable:true,value:null});host.remove();});});
@@ -0,0 +1,14 @@
import {useEffect,useId,useRef,type ReactNode} from 'react';
import {createPortal} from 'react-dom';
import {X} from 'lucide-react';
import {IconButton} from './IconButton';
const FOCUSABLE='button:not([disabled]),a[href],input:not([disabled]),select:not([disabled]),textarea:not([disabled]),[tabindex]:not([tabindex="-1"])';
export function Dialog({open,onClose,title,children,footer,className='',closable=true}:{open:boolean;onClose:()=>void;title:string;children:ReactNode;footer?:ReactNode;className?:string;closable?:boolean}){
const ref=useRef<HTMLDivElement>(null),previous=useRef<HTMLElement|null>(null),onCloseRef=useRef(onClose),titleId=useId();
useEffect(()=>{onCloseRef.current=onClose;},[onClose]);
useEffect(()=>{if(!open)return;previous.current=document.activeElement instanceof HTMLElement?document.activeElement:null;ref.current?.focus();const key=(event:KeyboardEvent)=>{if(event.key==='Escape'&&closable){event.preventDefault();onCloseRef.current();return;}if(event.key!=='Tab'||!ref.current)return;const items=Array.from(ref.current.querySelectorAll<HTMLElement>(FOCUSABLE));if(!items.length){event.preventDefault();ref.current.focus();return;}const first=items[0],last=items.at(-1)!;if(event.shiftKey&&document.activeElement===first){event.preventDefault();last.focus();}else if(!event.shiftKey&&document.activeElement===last){event.preventDefault();first.focus();}};document.addEventListener('keydown',key);return()=>{document.removeEventListener('keydown',key);if(previous.current&&document.contains(previous.current))previous.current.focus();};},[open,closable]);
if(!open)return null;
const backdrop=<div aria-hidden="true" className="absolute inset-0 bg-black/55 backdrop-blur-[1px]" onMouseDown={event=>{if(closable&&event.target===event.currentTarget)onCloseRef.current();}}/>;
return createPortal(<div className="fixed inset-0 z-[400] grid place-items-center p-6" role="presentation">{backdrop}<div ref={ref} tabIndex={-1} role="dialog" aria-modal="true" aria-labelledby={titleId} className={`relative flex max-h-[80vh] w-full max-w-lg flex-col overflow-hidden rounded-xl border border-border bg-panel shadow-2xl focus:outline-none ${className}`}><header className="flex h-11 shrink-0 items-center justify-between border-b border-border bg-surface px-4"><h2 id={titleId} className="truncate text-sm font-semibold text-text-primary">{title}</h2>{closable&&<IconButton aria-label="关闭" tooltip="关闭" onClick={()=>onCloseRef.current()}><X className="h-4 w-4"/></IconButton>}</header><div className="overflow-y-auto p-4">{children}</div>{footer&&<footer className="border-t border-border bg-surface px-4 py-3">{footer}</footer>}</div></div>,document.fullscreenElement??document.body);
}
@@ -0,0 +1,7 @@
import {useEffect,useRef,type ReactNode} from 'react';
import {MoreHorizontal} from 'lucide-react';
import {IconButton} from './IconButton';
import {Popover} from './Popover';
export interface DropdownMenuItem{id:string;label:string;icon?:ReactNode;disabled?:boolean;onSelect:()=>void;}
function MenuContent({items,close}:{items:DropdownMenuItem[];close:()=>void}){const refs=useRef<(HTMLButtonElement|null)[]>([]);useEffect(()=>{requestAnimationFrame(()=>refs.current.find(item=>item&&!item.disabled)?.focus());},[]);return <div role="menu" className="w-48 rounded-lg border border-border bg-surface-elevated p-1 shadow-xl" onKeyDown={event=>{const enabled=refs.current.filter((item):item is HTMLButtonElement=>Boolean(item&&!item.disabled)),index=enabled.indexOf(document.activeElement as HTMLButtonElement);if(event.key==='ArrowDown'){event.preventDefault();enabled[(index+1)%enabled.length]?.focus();}else if(event.key==='ArrowUp'){event.preventDefault();enabled[(index-1+enabled.length)%enabled.length]?.focus();}else if(event.key==='Home'){event.preventDefault();enabled[0]?.focus();}else if(event.key==='End'){event.preventDefault();enabled.at(-1)?.focus();}}}>{items.map((item,index)=><button key={item.id} ref={node=>{refs.current[index]=node;}} role="menuitem" disabled={item.disabled} onClick={()=>{close();item.onSelect();}} className="flex h-8 w-full items-center gap-2 rounded px-2 text-left text-xs text-text-secondary hover:bg-element-hover hover:text-text-primary focus:bg-element-hover focus:outline-none disabled:opacity-40"><span className="flex h-4 w-4 items-center justify-center">{item.icon}</span>{item.label}</button>)}</div>;}
export function DropdownMenu({items,label='更多操作',className=''}:{items:DropdownMenuItem[];label?:string;className?:string}){return <span className={className}><Popover label={label} trigger={({open,toggle})=><IconButton aria-label={label} aria-expanded={open} tooltip={label} onClick={toggle}><MoreHorizontal className="h-4 w-4"/></IconButton>}>{({close})=><MenuContent items={items} close={close}/>}</Popover></span>;}
@@ -0,0 +1,2 @@
import {SearchX} from 'lucide-react';
export function EmptySearchState({label='没有匹配结果'}:{label?:string}){return <div className="grid place-items-center gap-2 px-3 py-6 text-center text-xs text-text-tertiary"><SearchX className="h-5 w-5"/><span>{label}</span></div>;}
@@ -0,0 +1,7 @@
import {fireEvent,render,screen,waitFor} from '@testing-library/react';
import {DropdownMenu,LiveRegion,ProgressBar,SearchableCombobox} from './index';
describe('第五批基础 UI',()=>{
it('下拉菜单打开后聚焦菜单项并恢复触发器焦点',async()=>{const run=vi.fn();render(<DropdownMenu items={[{id:'a',label:'动作 A',onSelect:run}]}/>);const trigger=screen.getByRole('button',{name:'更多操作'});trigger.focus();fireEvent.click(trigger);const item=screen.getByRole('menuitem',{name:'动作 A'});await waitFor(()=>expect(item).toHaveFocus());fireEvent.click(item);expect(run).toHaveBeenCalled();expect(trigger).toHaveFocus();});
it('进度条和实时区域暴露状态',()=>{render(<><ProgressBar value={.42} label="编译模型"/><LiveRegion></LiveRegion></>);expect(screen.getByRole('progressbar')).toHaveAttribute('aria-valuenow','42');expect(screen.getByRole('status')).toHaveTextContent('正在编译模型');});
it('可搜索组合框筛选并选择入口',()=>{const change=vi.fn();render(<SearchableCombobox label="模型入口" value="a" onChange={change} options={[{value:'a',label:'模型 A',description:'a.xml'},{value:'b',label:'模型 B',description:'b.xml'}]}/>);fireEvent.click(screen.getByRole('button',{name:'模型入口'}));fireEvent.change(screen.getByRole('combobox',{name:'搜索模型入口'}),{target:{value:'B'}});const input=screen.getByRole('combobox',{name:'搜索模型入口'});expect(input).toHaveAttribute('aria-expanded','true');fireEvent.click(screen.getByRole('option',{name:/模型 B/}));expect(change).toHaveBeenCalledWith('b');});
});
@@ -0,0 +1,8 @@
import type {ButtonHTMLAttributes} from 'react';
import {Tooltip} from './Tooltip';
export interface IconButtonProps extends ButtonHTMLAttributes<HTMLButtonElement>{active?:boolean;tooltip?:string;}
export function IconButton({active=false,tooltip,className='',type='button',...props}:IconButtonProps){
const button=<button type={type} aria-pressed={active||undefined} className={`inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-md border transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/30 disabled:cursor-not-allowed disabled:opacity-40 ${active?'border-accent/40 bg-accent-soft text-accent':'border-transparent bg-transparent text-text-tertiary hover:bg-element-hover hover:text-text-primary'} ${className}`.trim()} {...props}/>;
return tooltip?<Tooltip content={tooltip}>{button}</Tooltip>:button;
}
@@ -0,0 +1,2 @@
import type {ReactNode} from 'react';
export function Kbd({children}:{children:ReactNode}){return <kbd className="inline-flex min-w-5 items-center justify-center rounded border border-border-strong bg-surface px-1.5 py-0.5 font-mono text-[10px] leading-4 text-text-secondary shadow-sm">{children}</kbd>;}
@@ -0,0 +1,2 @@
import type {ReactNode} from 'react';
export function LiveRegion({children,assertive=false}:{children:ReactNode;assertive?:boolean}){return <div className="sr-only" role={assertive?'alert':'status'} aria-live={assertive?'assertive':'polite'} aria-atomic="true">{children}</div>;}
@@ -0,0 +1,3 @@
/* eslint-disable react-hooks/refs -- refs are read only inside event callbacks passed to render props */
import {useCallback,useEffect,useRef,useState,type ReactNode} from 'react';
export function Popover({trigger,children,placement='bottom-right',label}:{trigger:(props:{open:boolean;toggle:()=>void})=>ReactNode;children:(props:{close:(restoreFocus?:boolean)=>void})=>ReactNode;placement?:'bottom-right'|'bottom-left'|'top-left';label:string}){const [open,setOpen]=useState(false),root=useRef<HTMLDivElement>(null),previous=useRef<HTMLElement|null>(null);const close=useCallback((restoreFocus=true)=>{setOpen(false);if(restoreFocus)previous.current?.focus();},[]);useEffect(()=>{if(!open)return;const pointer=(event:PointerEvent)=>{if(!root.current?.contains(event.target as Node))setOpen(false);},key=(event:KeyboardEvent)=>{if(event.key==='Escape'||((event.ctrlKey||event.metaKey)&&event.key.toLocaleLowerCase()==='k')){setOpen(false);previous.current?.focus();}};document.addEventListener('pointerdown',pointer);document.addEventListener('keydown',key);return()=>{document.removeEventListener('pointerdown',pointer);document.removeEventListener('keydown',key);};},[open]);const position=placement==='top-left'?'bottom-8 left-0':placement==='bottom-left'?'left-0 top-9':'right-0 top-9';return <div ref={root} className="relative">{trigger({open,toggle:()=>{if(!open)previous.current=document.activeElement instanceof HTMLElement?document.activeElement:null;setOpen(value=>!value);}})}{open&&<section role="dialog" aria-label={label} className={`absolute z-50 ${position}`}>{children({close})}</section>}</div>;}
@@ -0,0 +1 @@
export function ProgressBar({value,label}:{value:number;label:string}){const percent=Math.round(Math.min(1,Math.max(0,value))*100);return <div><div className="mb-1 flex justify-between text-[10px] text-text-tertiary"><span>{label}</span><span>{percent}%</span></div><div role="progressbar" aria-label={label} aria-valuemin={0} aria-valuemax={100} aria-valuenow={percent} className="h-1.5 overflow-hidden rounded-full bg-element-active"><div className="h-full rounded-full bg-accent transition-[width]" style={{width:`${percent}%`}}/></div></div>;}
@@ -0,0 +1,2 @@
import type {ReactNode} from 'react';
export function PropertyRow({label,value,action}:{label:string;value:ReactNode;action?:ReactNode}){return <div className="grid min-h-6 grid-cols-[minmax(0,1fr)_auto] items-center gap-2 text-xs"><span className="truncate text-text-tertiary">{label}</span><span className="flex min-w-0 items-center justify-end gap-1 text-right text-text-primary"><span className="technical-value truncate">{value}</span>{action}</span></div>;}
@@ -0,0 +1,8 @@
import {useEffect,useRef,useState,type PointerEvent as ReactPointerEvent,type ReactNode} from 'react';
const clamp=(value:number,min:number,max:number)=>Math.min(max,Math.max(min,value));
const panelMaxWidth=(minWidth:number)=>Math.max(minWidth,Math.min(576,window.innerWidth*.4));
function storedWidth(key:string,fallback:number,minWidth:number){try{const value=Number(localStorage.getItem(key));return clamp(Number.isFinite(value)&&value>0?value:fallback,minWidth,panelMaxWidth(minWidth));}catch{return clamp(fallback,minWidth,panelMaxWidth(minWidth));}}
export function ResizablePanel({side,storageKey,visible=true,defaultWidth=288,minWidth=224,children,className=''}:{side:'left'|'right';storageKey:string;visible?:boolean;defaultWidth?:number;minWidth?:number;children:ReactNode;className?:string}){const [width,setWidth]=useState(()=>storedWidth(storageKey,defaultWidth,minWidth)),cleanupRef=useRef<()=>void>(()=>{});const update=(next:number)=>{const value=clamp(next,minWidth,panelMaxWidth(minWidth));setWidth(value);try{localStorage.setItem(storageKey,String(value));}catch{/* 无持久化权限时仍可调整 */}};
useEffect(()=>{const persist=(next:number)=>{const value=clamp(next,minWidth,panelMaxWidth(minWidth));setWidth(value);try{localStorage.setItem(storageKey,String(value));}catch{/* 忽略 */}},resize=()=>setWidth(value=>clamp(value,minWidth,panelMaxWidth(minWidth))),layout=(event:Event)=>{const widths=(event as CustomEvent<{left:number;right:number}>).detail;persist(widths[side]);};window.addEventListener('resize',resize);window.addEventListener('mujoco-layout-widths',layout);return()=>{window.removeEventListener('resize',resize);window.removeEventListener('mujoco-layout-widths',layout);cleanupRef.current();};},[minWidth,side,storageKey]);
const start=(event:ReactPointerEvent<HTMLButtonElement>)=>{event.preventDefault();cleanupRef.current();const origin=event.clientX,startWidth=width,pointerId=event.pointerId;const move=(moveEvent:PointerEvent)=>{if(moveEvent.pointerId===pointerId)update(startWidth+(moveEvent.clientX-origin)*(side==='left'?1:-1));};const stop=(stopEvent:PointerEvent)=>{if(stopEvent.pointerId!==pointerId)return;cleanup();};const cleanup=()=>{window.removeEventListener('pointermove',move);window.removeEventListener('pointerup',stop);window.removeEventListener('pointercancel',stop);cleanupRef.current=()=>{};};cleanupRef.current=cleanup;window.addEventListener('pointermove',move);window.addEventListener('pointerup',stop);window.addEventListener('pointercancel',stop);};
return <div hidden={!visible} className={`relative shrink-0 ${className}`} style={{width}}>{children}<button type="button" role="separator" aria-label={side==='left'?'调整工程面板宽度':'调整属性面板宽度'} aria-orientation="vertical" aria-valuemin={minWidth} aria-valuemax={Math.round(panelMaxWidth(minWidth))} aria-valuenow={Math.round(width)} onPointerDown={start} onKeyDown={event=>{if(event.key==='Home')update(minWidth);else if(event.key==='End')update(panelMaxWidth(minWidth));else if(event.key==='ArrowLeft')update(width+(side==='left'?-16:16));else if(event.key==='ArrowRight')update(width+(side==='left'?16:-16));else return;event.preventDefault();}} className={`absolute inset-y-0 z-30 w-2 cursor-col-resize bg-transparent outline-none after:absolute after:inset-y-0 after:left-1/2 after:w-px after:-translate-x-1/2 after:bg-transparent hover:after:bg-accent focus-visible:after:w-0.5 focus-visible:after:bg-accent ${side==='left'?'-right-1':'-left-1'}`}/></div>;}
@@ -0,0 +1 @@
export function SearchHighlight({text,query}:{text:string;query:string}){const needle=query.trim().toLocaleLowerCase();if(!needle)return <>{text}</>;const parts:({text:string;match:boolean})[]=[];let start=0,index=text.toLocaleLowerCase().indexOf(needle);while(index>=0){if(index>start)parts.push({text:text.slice(start,index),match:false});parts.push({text:text.slice(index,index+needle.length),match:true});start=index+needle.length;index=text.toLocaleLowerCase().indexOf(needle,start);}if(start<text.length)parts.push({text:text.slice(start),match:false});return <>{parts.map((part,i)=>part.match?<mark key={i} className="rounded-sm bg-warning-soft px-0.5 text-warning">{part.text}</mark>:part.text)}</>;}
@@ -0,0 +1,5 @@
import {useId,useMemo,useState} from 'react';
import {Check,ChevronsUpDown,Search} from 'lucide-react';
import {Popover} from './Popover';
export interface ComboboxOption{value:string;label:string;description?:string;}
export function SearchableCombobox({options,value,onChange,label,disabled=false}:{options:ComboboxOption[];value?:string;onChange:(value:string)=>void;label:string;disabled?:boolean}){const [query,setQuery]=useState(''),[active,setActive]=useState(0),listId=useId(),selected=options.find(option=>option.value===value),filtered=useMemo(()=>{const needle=query.trim().toLocaleLowerCase();return options.filter(option=>!needle||`${option.label} ${option.description??''}`.toLocaleLowerCase().includes(needle));},[options,query]);return <Popover label={label} trigger={({open,toggle})=><button type="button" disabled={disabled} aria-label={label} aria-expanded={open} onClick={toggle} className="flex h-8 w-full items-center justify-between rounded-md border border-border bg-input px-2 text-xs text-text-primary focus-visible:ring-2 focus-visible:ring-accent/30 disabled:opacity-40"><span className="truncate">{selected?.label??'请选择'}</span><ChevronsUpDown className="h-3.5 w-3.5 text-text-tertiary"/></button>}>{({close})=><div className="w-72 rounded-lg border border-border bg-surface-elevated p-1 shadow-xl"><div className="relative"><Search className="absolute left-2 top-2 h-3.5 w-3.5 text-text-tertiary"/><input autoFocus role="combobox" aria-label={`搜索${label}`} aria-expanded="true" aria-autocomplete="list" aria-controls={listId} aria-activedescendant={filtered[active]?`${listId}-${active}`:undefined} value={query} onChange={event=>{setQuery(event.target.value);setActive(0);}} onKeyDown={event=>{if(!filtered.length)return;if(event.key==='ArrowDown'){event.preventDefault();setActive(index=>(index+1)%filtered.length);}else if(event.key==='ArrowUp'){event.preventDefault();setActive(index=>(index-1+filtered.length)%filtered.length);}else if(event.key==='Enter'){event.preventDefault();onChange(filtered[active].value);setQuery('');close();}else if(event.key==='Tab')close(false);}} className="h-8 w-full rounded border border-border bg-input pl-7 pr-2 text-xs"/></div><div id={listId} role="listbox" className="panel-scroll mt-1 max-h-64 overflow-auto">{filtered.map((option,index)=><button key={option.value} id={`${listId}-${index}`} role="option" tabIndex={-1} aria-selected={option.value===value} onMouseEnter={()=>setActive(index)} onClick={()=>{onChange(option.value);setQuery('');close();}} className={`flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-xs ${index===active?'bg-accent-soft':''}`}><Check className={`h-3.5 w-3.5 ${option.value===value?'text-accent':'opacity-0'}`}/><span className="min-w-0"><span className="block truncate">{option.label}</span>{option.description&&<span className="block truncate text-[10px] text-text-tertiary">{option.description}</span>}</span></button>)}</div></div>}</Popover>;}
@@ -0,0 +1,9 @@
import {useState} from 'react';
import {act,fireEvent,render,screen} from '@testing-library/react';
import {Badge,ResizablePanel,Separator,Skeleton,Tabs} from './index';
function TabHarness(){const [value,setValue]=useState<'a'|'b'>('a');return <Tabs label="示例" value={value} onValueChange={setValue} items={[{value:'a',label:'甲',content:<span></span>},{value:'b',label:'乙',content:<span></span>}]}/>;}
describe('第二批基础 UI',()=>{
it('Tabs 保留面板 DOM并支持方向键导航',()=>{render(<TabHarness/>);const first=screen.getByRole('tab',{name:'甲'});expect(first).toHaveAttribute('tabindex','0');fireEvent.keyDown(first,{key:'ArrowRight'});expect(screen.getByRole('tab',{name:'乙'})).toHaveAttribute('aria-selected','true');expect(screen.getByText('乙内容')).toBeVisible();expect(screen.getByText('甲内容').closest('[role="tabpanel"]')).toHaveAttribute('hidden');});
it('ResizablePanel 支持键盘调整、限制异常持久值并保存宽度',()=>{localStorage.setItem('test-width','9999');render(<ResizablePanel side="left" storageKey="test-width"><div></div></ResizablePanel>);const handle=screen.getByRole('separator',{name:'调整工程面板宽度'});expect(Number(handle.getAttribute('aria-valuenow'))).toBeLessThanOrEqual(Number(handle.getAttribute('aria-valuemax')));fireEvent.keyDown(handle,{key:'Home'});expect(handle).toHaveAttribute('aria-valuenow','224');expect(localStorage.getItem('test-width')).toBe('224');act(()=>window.dispatchEvent(new CustomEvent('mujoco-layout-widths',{detail:{left:320,right:360}})));expect(handle).toHaveAttribute('aria-valuenow','320');});
it('Badge、Separator 和 Skeleton 可渲染',()=>{render(<><Badge></Badge><Separator/><Skeleton className="h-2"/></>);expect(screen.getByText('状态')).toBeVisible();expect(screen.getByRole('separator')).toBeVisible();});
});
@@ -0,0 +1,2 @@
import type {SelectHTMLAttributes} from 'react';
export function Select({className='',...props}:SelectHTMLAttributes<HTMLSelectElement>){return <select className={`h-7 rounded-md border border-border bg-input px-2 text-xs text-text-primary transition-colors hover:border-border-strong focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/30 disabled:opacity-40 ${className}`.trim()} {...props}/>;}
@@ -0,0 +1 @@
export function Separator({orientation='horizontal',className=''}:{orientation?:'horizontal'|'vertical';className?:string}){return <span role="separator" aria-orientation={orientation} className={`${orientation==='horizontal'?'block h-px w-full':'inline-block h-full w-px'} shrink-0 bg-border ${className}`}/>;}
@@ -0,0 +1 @@
export function Skeleton({className=''}:{className?:string}){return <span aria-hidden="true" className={`block animate-pulse rounded bg-element-active ${className}`}/>;}
@@ -0,0 +1,3 @@
import type {KeyboardEvent,ReactNode} from 'react';
export interface TabItem<T extends string>{value:T;label:string;icon?:ReactNode;content:ReactNode;disabled?:boolean;}
export function Tabs<T extends string>({items,value,onValueChange,label,className='',keepMounted=true}:{items:TabItem<T>[];value:T;onValueChange:(value:T)=>void;label:string;className?:string;keepMounted?:boolean}){const active=items.find(item=>item.value===value)??items.find(item=>!item.disabled)??items[0];const navigate=(event:KeyboardEvent<HTMLButtonElement>)=>{if(!['ArrowLeft','ArrowRight','Home','End'].includes(event.key))return;const enabled=items.filter(item=>!item.disabled);if(!enabled.length)return;const current=enabled.findIndex(item=>item.value===active.value);const next=event.key==='Home'?0:event.key==='End'?enabled.length-1:event.key==='ArrowRight'?(current+1)%enabled.length:(current-1+enabled.length)%enabled.length;event.preventDefault();const item=enabled[next];onValueChange(item.value);requestAnimationFrame(()=>document.getElementById(`${label}-tab-${item.value}`)?.focus());};return <div className={`flex min-h-0 flex-1 flex-col ${className}`}><div role="tablist" aria-label={label} className="flex h-9 shrink-0 items-end gap-1 border-b border-border bg-surface px-2">{items.map(item=><button key={item.value} type="button" role="tab" id={`${label}-tab-${item.value}`} tabIndex={item.value===active.value?0:-1} aria-selected={item.value===active.value} aria-controls={`${label}-${item.value}`} disabled={item.disabled} onClick={()=>onValueChange(item.value)} onKeyDown={navigate} className={`relative flex h-8 items-center gap-1.5 px-2 text-xs font-medium focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-accent/40 ${item.value===active.value?'text-accent after:absolute after:inset-x-1 after:bottom-0 after:h-0.5 after:rounded-full after:bg-accent':'text-text-tertiary hover:text-text-primary'}`}>{item.icon}{item.label}</button>)}</div>{(keepMounted?items:[active]).map(item=><div key={item.value} id={`${label}-${item.value}`} role="tabpanel" aria-labelledby={`${label}-tab-${item.value}`} hidden={item.value!==active.value} className="panel-scroll min-h-0 flex-1 overflow-auto">{item.content}</div>)}</div>;}
@@ -0,0 +1,8 @@
import {fireEvent,render,screen,waitFor} from '@testing-library/react';
import {ConfirmDialog,CopyButton,Kbd,PropertyRow,SearchHighlight} from './index';
describe('第三批基础 UI',()=>{
it('确认弹窗区分取消和危险确认动作',()=>{const confirm=vi.fn(),close=vi.fn();render(<ConfirmDialog open title="移除工程" danger onConfirm={confirm} onClose={close}></ConfirmDialog>);fireEvent.click(screen.getByRole('button',{name:'确认'}));expect(confirm).toHaveBeenCalledTimes(1);fireEvent.click(screen.getByRole('button',{name:'取消'}));expect(close).toHaveBeenCalledTimes(1);});
it('属性行支持复制且搜索词可高亮',async()=>{const writeText=vi.fn().mockResolvedValue(undefined);Object.defineProperty(navigator,'clipboard',{configurable:true,value:{writeText}});render(<><PropertyRow label="Body" value="robot" action={<CopyButton value="robot"/>}/><SearchHighlight text="robot_arm" query="arm"/><Kbd>Ctrl+K</Kbd></>);fireEvent.click(screen.getByRole('button',{name:'复制'}));await waitFor(()=>expect(writeText).toHaveBeenCalledWith('robot'));expect(screen.getByText('arm').tagName).toBe('MARK');expect(screen.getByText('Ctrl+K')).toBeVisible();});
it('Clipboard API 不可用时复制按钮不会抛错',()=>{Object.defineProperty(navigator,'clipboard',{configurable:true,value:undefined});render(<CopyButton value="robot"/>);expect(()=>fireEvent.click(screen.getByRole('button',{name:'复制'}))).not.toThrow();});
});
@@ -0,0 +1,6 @@
import type {ComponentType} from 'react';
import {IconButton} from './IconButton';
export interface ToolbarItem<T extends string>{value:T;label:string;icon:ComponentType<{className?:string}>;}
export function ToolbarToggleGroup<T extends string>({items,value,onChange,label}:{items:readonly ToolbarItem<T>[];value:T;onChange:(value:T)=>void;label:string}){
return <div role="toolbar" aria-label={label} className="flex items-center gap-0.5 rounded-lg border border-border bg-surface/80 p-0.5 shadow-sm">{items.map(item=>{const Icon=item.icon;return <IconButton key={item.value} active={item.value===value} tooltip={item.label} aria-label={item.label} onClick={()=>onChange(item.value)}><Icon className="h-3.5 w-3.5"/></IconButton>;})}</div>;
}
@@ -0,0 +1,9 @@
import type {ReactElement,ReactNode} from 'react';
export function Tooltip({content,children,side='bottom'}:{content:ReactNode;children:ReactElement;side?:'top'|'bottom'}){
if(!content)return children;
return <span className="group/tooltip relative inline-flex">
{children}
<span role="tooltip" className={`pointer-events-none absolute left-1/2 z-[500] hidden w-max max-w-64 -translate-x-1/2 rounded-md border border-border bg-surface-elevated px-2 py-1 text-[10px] font-medium text-text-primary shadow-lg group-hover/tooltip:block group-focus-within/tooltip:block ${side==='top'?'bottom-full mb-1.5':'top-full mt-1.5'}`}>{content}</span>
</span>;
}
@@ -0,0 +1,3 @@
import {fireEvent,render,screen} from '@testing-library/react';
import {VirtualTreeViewport} from './VirtualTreeViewport';
describe('VirtualTreeViewport',()=>{it('只渲染可视窗口并在滚动后更新行',()=>{const items=Array.from({length:1000},(_,id)=>({id,label:`节点 ${id}`}));render(<VirtualTreeViewport label="大型树" items={items} height={100} rowHeight={20} overscan={1} getKey={item=>item.id} renderRow={item=><div>{item.label}</div>}/>);const tree=screen.getByRole('tree',{name:'大型树'});expect(screen.getByText('节点 0')).toBeVisible();expect(screen.queryByText('节点 500')).not.toBeInTheDocument();Object.defineProperty(tree,'scrollTop',{configurable:true,value:10000});fireEvent.scroll(tree);expect(screen.getByText('节点 500')).toBeVisible();fireEvent.keyDown(tree,{key:'ArrowDown'});expect(tree.getAttribute('aria-activedescendant')).toContain('501');});});
@@ -0,0 +1,2 @@
import {useMemo,useRef,useState,type KeyboardEvent,type ReactNode} from 'react';
export function VirtualTreeViewport<T>({items,rowHeight=26,height=520,overscan=6,getKey,getLevel=()=>1,isExpandable=()=>false,isExpanded=()=>false,onToggle,onActiveChange,renderRow,label}:{items:T[];rowHeight?:number;height?:number;overscan?:number;getKey:(item:T)=>string|number;getLevel?:(item:T)=>number;isExpandable?:(item:T)=>boolean;isExpanded?:(item:T)=>boolean;onToggle?:(item:T)=>void;onActiveChange?:(item:T)=>void;renderRow:(item:T,index:number)=>ReactNode;label:string}){const root=useRef<HTMLDivElement>(null),[scrollTop,setScrollTop]=useState(0),[active,setActive]=useState(0),range=useMemo(()=>{const start=Math.max(0,Math.floor(scrollTop/rowHeight)-overscan),count=Math.ceil(height/rowHeight)+overscan*2;return {start,end:Math.min(items.length,start+count)};},[height,items.length,overscan,rowHeight,scrollTop]),safeActive=Math.min(active,Math.max(0,items.length-1)),activeId=items.length?`${label}-${getKey(items[safeActive])}`:undefined;const activate=(index:number)=>{const next=Math.min(items.length-1,Math.max(0,index));setActive(next);const item=items[next];if(item)onActiveChange?.(item);const viewport=root.current;if(viewport){const top=next*rowHeight;if(top<viewport.scrollTop)viewport.scrollTop=top;else if(top+rowHeight>viewport.scrollTop+height)viewport.scrollTop=top+rowHeight-height;}};const key=(event:KeyboardEvent<HTMLDivElement>)=>{if(!items.length)return;const item=items[safeActive],level=getLevel(item);if(event.key==='ArrowDown')activate(safeActive+1);else if(event.key==='ArrowUp')activate(safeActive-1);else if(event.key==='Home')activate(0);else if(event.key==='End')activate(items.length-1);else if(event.key==='ArrowRight'&&isExpandable(item)&&!isExpanded(item))onToggle?.(item);else if(event.key==='ArrowLeft'&&isExpandable(item)&&isExpanded(item))onToggle?.(item);else if(event.key==='ArrowLeft'){for(let index=safeActive-1;index>=0;index--)if(getLevel(items[index])<level){activate(index);break;}}else if((event.key==='Enter'||event.key===' ')&&isExpandable(item))onToggle?.(item);else return;event.preventDefault();};return <div ref={root} role="tree" aria-label={label} aria-activedescendant={activeId} tabIndex={0} className="panel-scroll relative overflow-auto outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-accent/30" style={{height:Math.min(height,Math.max(rowHeight,items.length*rowHeight))}} onKeyDown={key} onScroll={event=>{const top=event.currentTarget.scrollTop,first=Math.floor(top/rowHeight),last=first+Math.ceil(height/rowHeight);setScrollTop(top);if(safeActive<first||safeActive>last){setActive(first);if(items[first])onActiveChange?.(items[first]);}}}><div style={{height:items.length*rowHeight,position:'relative'}}>{items.slice(range.start,range.end).map((item,offset)=>{const index=range.start+offset,expandable=isExpandable(item);return <div id={`${label}-${getKey(item)}`} role="treeitem" aria-level={getLevel(item)} aria-expanded={expandable?isExpanded(item):undefined} key={getKey(item)} onMouseDown={()=>activate(index)} className={index===safeActive?'bg-accent-soft/60':''} style={{position:'absolute',left:0,right:0,top:index*rowHeight,height:rowHeight}}>{renderRow(item,index)}</div>;})}</div></div>;}
@@ -0,0 +1,24 @@
export * from './Button';
export * from './IconButton';
export * from './Tooltip';
export * from './Select';
export * from './Dialog';
export * from './CollapsibleSection';
export * from './ToolbarToggleGroup';
export * from './Badge';
export * from './Separator';
export * from './Skeleton';
export * from './Tabs';
export * from './ResizablePanel';
export * from './Kbd';
export * from './PropertyRow';
export * from './CopyButton';
export * from './ConfirmDialog';
export * from './SearchHighlight';
export * from './EmptySearchState';
export * from './VirtualTreeViewport';
export * from './Popover';
export * from './DropdownMenu';
export * from './ProgressBar';
export * from './LiveRegion';
export * from './SearchableCombobox';
@@ -0,0 +1,24 @@
import {fireEvent,render,screen} from '@testing-library/react';
import {buildBodyTree,countModelStructureSearchResults,ModelStructureTree} from './ModelStructureTree';
import type {BodyInfo,JointInfo} from '../simulation/SimulationSession';
const bodies:BodyInfo[]=[{id:0,name:'world',parentId:0},{id:1,name:'base',parentId:0},{id:2,name:'arm',parentId:1}];
const joints:JointInfo[]=[{id:0,name:'arm_joint',type:3,value:0,min:-1,max:1,limitMin:-1,limitMax:1,limited:true,limitsIgnored:false,editable:true,bodyId:2,axis:[0,0,1]}];
describe('ModelStructureTree',()=>{
it('按 body 父子关系构建结构,并将关节放在所属 body 下',()=>{
const tree=buildBodyTree(bodies,joints);
expect(tree[0]).toMatchObject({id:1,name:'base'});
expect(tree[0].children[0]).toMatchObject({id:2,name:'arm'});
expect(tree[0].children[0].joints[0].name).toBe('arm_joint');
});
it('鼠标进入和离开关节时通知查看器高亮',()=>{
const hover=vi.fn();render(<ModelStructureTree bodies={bodies} joints={joints} onJointHover={hover}/>);expect(screen.getByRole('treeitem',{name:'base'})).toHaveAttribute('aria-expanded','true');const item=screen.getByRole('treeitem',{name:/arm_joint/});
fireEvent.mouseEnter(item);fireEvent.mouseLeave(item);expect(hover.mock.calls).toEqual([[0],[null]]);
});
it('按 Body 或关节名称过滤并保留祖先路径',()=>{
render(<ModelStructureTree bodies={bodies} joints={joints} query="arm_joint" onJointHover={()=>{}}/>);expect(screen.getByRole('treeitem',{name:'base'})).toBeVisible();expect(screen.getByRole('treeitem',{name:/arm_joint/})).toBeVisible();expect(countModelStructureSearchResults(bodies,joints,'arm_joint')).toBe(3);expect(countModelStructureSearchResults(bodies,joints,'world')).toBe(0);
});
});
@@ -0,0 +1,32 @@
import {useState} from 'react';
import {Box,Disc3} from 'lucide-react';
import type {BodyInfo,JointInfo} from '../simulation/SimulationSession';
import {EmptySearchState,SearchHighlight,VirtualTreeViewport} from '../components/ui';
interface BodyNode extends BodyInfo {children:BodyNode[];joints:JointInfo[];}
// 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,searching,query}:{node:BodyNode;depth:number;onJointHover:(jointId:number|null)=>void;searching:boolean;query:string}){
const hasChildren=node.joints.length>0||node.children.length>0;const [open,setOpen]=useState(depth<2),shownOpen=searching||open;
return <li role="none">{hasChildren?<details open={shownOpen} onToggle={event=>{if(!searching)setOpen(event.currentTarget.open);}}><summary role="treeitem" aria-expanded={shownOpen} tabIndex={0} onClick={event=>{if(searching)event.preventDefault();}} className="flex cursor-pointer select-none items-center gap-1.5 truncate rounded px-1.5 py-1 text-xs text-text-secondary hover:bg-element-hover focus-visible:ring-2 focus-visible:ring-accent/30"><Box aria-hidden="true" className="h-3.5 w-3.5 shrink-0 text-accent"/><span className="truncate"><SearchHighlight text={node.name} query={query}/></span></summary><ul role="group" className="ml-3 border-l border-border pl-1">{node.joints.map(joint=><li role="none" key={joint.id}><span role="treeitem" tabIndex={0} className="flex cursor-default items-center gap-1.5 truncate rounded px-1.5 py-1 text-xs text-warning hover:bg-warning-soft focus:bg-warning-soft focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/30" onMouseEnter={()=>onJointHover(joint.id)} onMouseLeave={()=>onJointHover(null)} onFocus={()=>onJointHover(joint.id)} onBlur={()=>onJointHover(null)} title={`关节:${joint.name}`}><Disc3 aria-hidden="true" className="h-3.5 w-3.5 shrink-0"/><SearchHighlight text={joint.name} query={query}/></span></li>)}{node.children.map(child=><BodyBranch key={child.id} node={child} depth={depth+1} onJointHover={onJointHover} searching={searching} query={query}/>)}</ul></details>:<div role="treeitem" tabIndex={0} className="flex items-center gap-1.5 truncate rounded px-1.5 py-1 text-xs text-text-secondary focus-visible:ring-2 focus-visible:ring-accent/30"><Box aria-hidden="true" className="h-3.5 w-3.5 shrink-0 text-accent"/><SearchHighlight text={node.name} query={query}/></div>}</li>;
}
function filterBodies(nodes:BodyNode[],query:string):BodyNode[]{if(!query)return nodes;return nodes.flatMap(node=>{if(node.name.toLocaleLowerCase().includes(query))return [node];const joints=node.joints.filter(joint=>joint.name.toLocaleLowerCase().includes(query)),children=filterBodies(node.children,query);return joints.length||children.length?[{...node,joints,children}]:[];});}
function countBodyNodes(nodes:BodyNode[]):number{return nodes.reduce((total,node)=>total+1+node.joints.length+countBodyNodes(node.children),0);}
// eslint-disable-next-line react-refresh/only-export-components
export function countModelStructureSearchResults(bodies:BodyInfo[],joints:JointInfo[],query:string):number{return countBodyNodes(filterBodies(buildBodyTree(bodies,joints),query.trim().toLocaleLowerCase()));}
type FlatBodyItem={kind:'body';body:BodyNode;depth:number}|{kind:'joint';joint:JointInfo;depth:number};
function flattenBodies(nodes:BodyNode[],expanded:Set<number>,searching:boolean,depth=0):FlatBodyItem[]{return nodes.flatMap(body=>[{kind:'body' as const,body,depth},...(searching||expanded.has(body.id)?[...body.joints.map(joint=>({kind:'joint' as const,joint,depth:depth+1})),...flattenBodies(body.children,expanded,searching,depth+1)]:[])]);}
function initiallyExpanded(nodes:BodyNode[],depth=0):number[]{return nodes.flatMap(body=>[...(depth<2?[body.id]:[]),...initiallyExpanded(body.children,depth+1)]);}
export function ModelStructureTree({bodies,joints,onJointHover,query=''}:{bodies:BodyInfo[];joints:JointInfo[];onJointHover:(jointId:number|null)=>void;query?:string}){
const normalized=query.trim().toLocaleLowerCase(),roots=filterBodies(buildBodyTree(bodies,joints),normalized),[virtualExpanded,setVirtualExpanded]=useState(()=>new Set(initiallyExpanded(buildBodyTree(bodies,joints))));
if(bodies.length+joints.length>500&&roots.length){const searching=Boolean(normalized),flat=flattenBodies(roots,virtualExpanded,searching),toggle=(item:FlatBodyItem)=>{if(item.kind!=='body'||searching)return;setVirtualExpanded(current=>{const next=new Set(current);if(next.has(item.body.id))next.delete(item.body.id);else next.add(item.body.id);return next;});};return <nav aria-label="模型结构树"><VirtualTreeViewport label="虚拟化模型结构树" items={flat} getKey={item=>item.kind==='body'?`b:${item.body.id}`:`j:${item.joint.id}`} getLevel={item=>item.depth+1} isExpandable={item=>item.kind==='body'&&(item.body.joints.length>0||item.body.children.length>0)} isExpanded={item=>item.kind==='body'&&(searching||virtualExpanded.has(item.body.id))} onToggle={toggle} onActiveChange={item=>onJointHover(item.kind==='joint'?item.joint.id:null)} renderRow={item=>item.kind==='body'?<div onDoubleClick={()=>toggle(item)} className="flex h-full items-center gap-1.5 px-1.5 text-xs text-text-secondary" style={{paddingLeft:item.depth*12+6}}><Box className="h-3.5 w-3.5 text-accent"/><SearchHighlight text={item.body.name} query={query}/></div>:<div className="flex h-full items-center gap-1.5 px-1.5 text-xs text-warning" style={{paddingLeft:item.depth*12+6}} onMouseEnter={()=>onJointHover(item.joint.id)} onMouseLeave={()=>onJointHover(null)}><Disc3 className="h-3.5 w-3.5"/><SearchHighlight text={item.joint.name} query={query}/></div>}/></nav>;}
return <nav aria-label="模型结构树">{roots.length?<ul role="tree">{roots.map(root=><BodyBranch key={root.id} node={root} depth={0} onJointHover={onJointHover} searching={Boolean(normalized)} query={query}/>)}</ul>:<EmptySearchState label="没有匹配的 Body 或关节"/>}</nav>;
}
@@ -1,5 +1,5 @@
import {render,screen,within} from '@testing-library/react';
import {buildProjectTree,ProjectTree} from './ProjectTree';
import {fireEvent,render,screen,within} from '@testing-library/react';
import {buildProjectTree,countProjectSearchResults,ProjectTree} from './ProjectTree';
const files=[
{path:'robot/meshes/arm.obj',size:2048},
@@ -20,12 +20,20 @@ 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();
});
it('搜索时只保留匹配文件及其目录路径',()=>{
render(<ProjectTree files={files} entries={[]} query="arm.obj"/>);
expect(screen.getByText('robot')).toBeVisible();expect(screen.getByText('meshes')).toBeVisible();expect(screen.getByText('arm.obj')).toBeVisible();expect(screen.queryByText('README.txt')).not.toBeInTheDocument();expect(countProjectSearchResults(files,'meshes')).toBe(3);expect(countProjectSearchResults(files,'robot/meshes')).toBe(0);
});
it('大型工程使用可键盘折叠的虚拟树',()=>{const large=Array.from({length:401},(_,index)=>({path:`assets/file-${index}.obj`,size:1}));render(<ProjectTree files={large} entries={[]}/>);const tree=screen.getByRole('tree',{name:'虚拟化工程文件树'});expect(tree).toHaveAttribute('aria-activedescendant',expect.stringContaining('assets'));fireEvent.keyDown(tree,{key:'ArrowLeft'});expect(screen.queryByText('file-0.obj')).not.toBeInTheDocument();});
});
+18 -14
View File
@@ -1,4 +1,7 @@
import {useState} from 'react';
import {Box,File,FileCode2,Folder,FolderOpen} from 'lucide-react';
import type {ModelEntry} from './types';
import {EmptySearchState,SearchHighlight,VirtualTreeViewport} from '../components/ui';
export interface ProjectTreeFile {path:string;size:number;}
@@ -52,21 +55,22 @@ 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;searching:boolean;query:string;}
function DirectoryNode({node,entryFormats,selectedEntry,expandedEntry,searching,query}:TreeNodeProps&{node:ProjectTreeNode}){const [open,setOpen]=useState(Boolean(expandedEntry?.startsWith(`${node.path}/`)));const shownOpen=searching||open,FolderIcon=shownOpen?FolderOpen:Folder;return <li><details open={shownOpen} onToggle={event=>{if(!searching)setOpen(event.currentTarget.open);}}><summary title={node.path} onClick={event=>{if(searching)event.preventDefault();}} className="flex cursor-pointer select-none items-center gap-1.5 truncate rounded px-1.5 py-1 text-xs text-text-secondary hover:bg-element-hover"><FolderIcon aria-hidden="true" className="h-3.5 w-3.5 shrink-0 text-accent"/><span className="truncate"><SearchHighlight text={node.name} query={query}/></span></summary><TreeNodes nodes={node.children??[]} entryFormats={entryFormats} selectedEntry={selectedEntry} expandedEntry={expandedEntry} searching={searching} query={query}/></details></li>;}
function TreeNodes({nodes,entryFormats,selectedEntry,expandedEntry,searching,query}:TreeNodeProps&{nodes:ProjectTreeNode[]}){
return <ul role="group" className="ml-3 border-l border-border pl-1">{nodes.map(node=>{if(node.kind==='directory')return <DirectoryNode key={`d:${node.path}`} node={node} entryFormats={entryFormats} selectedEntry={selectedEntry} expandedEntry={expandedEntry} searching={searching} query={query}/>;const EntryIcon=entryFormats.has(node.path)?FileCode2:node.path.endsWith('.obj')||node.path.endsWith('.stl')||node.path.endsWith('.dae')?Box:File;return <li key={`f:${node.path}`} title={node.path} className={`flex min-w-0 items-center gap-1.5 rounded px-1.5 py-1 text-xs ${selectedEntry===node.path?'bg-accent-soft text-accent':'text-text-secondary hover:bg-element-hover'}`}><EntryIcon aria-hidden="true" className="h-3.5 w-3.5 shrink-0"/><span className="min-w-0 flex-1 truncate"><SearchHighlight text={node.name} query={query}/></span>{entryFormats.has(node.path)&&<span className="shrink-0 text-[10px] uppercase text-accent">{entryFormats.get(node.path)}</span>}<span className="shrink-0 text-[10px] text-text-tertiary">{formatSize(node.size??0)}</span></li>;})}</ul>;
}
export function ProjectTree({files,entries,selectedEntry}:{files:ProjectTreeFile[];entries:ModelEntry[];selectedEntry?:string}){
const nodes=buildProjectTree(files);
function filterNodes(nodes:ProjectTreeNode[],query:string):ProjectTreeNode[]{if(!query)return nodes;return nodes.flatMap(node=>{if(node.name.toLocaleLowerCase().includes(query))return [node];if(node.kind==='file')return [];const children=filterNodes(node.children??[],query);return children.length?[{...node,children}]:[];});}
function countNodes(nodes:ProjectTreeNode[]):number{return nodes.reduce((total,node)=>total+1+(node.children?countNodes(node.children):0),0);}
// eslint-disable-next-line react-refresh/only-export-components
export function countProjectSearchResults(files:ProjectTreeFile[],query:string):number{return countNodes(filterNodes(buildProjectTree(files),query.trim().toLocaleLowerCase()));}
interface FlatProjectNode{node:ProjectTreeNode;depth:number;}
function flattenProjectNodes(nodes:ProjectTreeNode[],expanded:Set<string>,searching:boolean,depth=0):FlatProjectNode[]{return nodes.flatMap(node=>[{node,depth},...(node.kind==='directory'&&(searching||expanded.has(node.path))?flattenProjectNodes(node.children??[],expanded,searching,depth+1):[])]);}
export function ProjectTree({files,entries,selectedEntry,query=''}:{files:ProjectTreeFile[];entries:ModelEntry[];selectedEntry?:string;query?:string}){
const normalized=query.trim().toLocaleLowerCase(),nodes=filterNodes(buildProjectTree(files),normalized),[virtualExpanded,setVirtualExpanded]=useState(()=>new Set(buildProjectTree(files).filter(node=>node.kind==='directory').map(node=>node.path)));
const entryFormats=new Map(entries.map(entry=>[entry.path,entry.format]));
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;
if(files.length>400&&nodes.length){const searching=Boolean(normalized),flat=flattenProjectNodes(nodes,virtualExpanded,searching),toggle=(item:FlatProjectNode)=>{if(item.node.kind!=='directory'||searching)return;setVirtualExpanded(current=>{const next=new Set(current);if(next.has(item.node.path))next.delete(item.node.path);else next.add(item.node.path);return next;});};return <nav aria-label="工程文件树"><VirtualTreeViewport label="虚拟化工程文件树" items={flat} getKey={item=>item.node.path} getLevel={item=>item.depth+1} isExpandable={item=>item.node.kind==='directory'&&(item.node.children?.length??0)>0} isExpanded={item=>searching||virtualExpanded.has(item.node.path)} onToggle={toggle} renderRow={({node,depth})=>{const directory=node.kind==='directory',opened=searching||virtualExpanded.has(node.path),EntryIcon=directory?(opened?FolderOpen:Folder):entryFormats.has(node.path)?FileCode2:node.path.endsWith('.obj')||node.path.endsWith('.stl')||node.path.endsWith('.dae')?Box:File;return <div title={node.path} onDoubleClick={()=>toggle({node,depth})} className={`flex h-full items-center gap-1.5 rounded px-1.5 text-xs ${selectedEntry===node.path?'bg-accent-soft text-accent':'text-text-secondary hover:bg-element-hover'}`} style={{paddingLeft:depth*12+6}}><EntryIcon className="h-3.5 w-3.5 shrink-0"/><span className="min-w-0 flex-1 truncate"><SearchHighlight text={node.name} query={query}/></span>{!directory&&<span className="text-[10px] text-text-tertiary">{formatSize(node.size??0)}</span>}</div>;}}/></nav>;}
return <nav aria-label="工程文件树">{nodes.length?<TreeNodes key={expandedEntry??'collapsed'} nodes={nodes} entryFormats={entryFormats} selectedEntry={selectedEntry} expandedEntry={expandedEntry} searching={Boolean(normalized)} query={query}/>:<EmptySearchState label="没有匹配的文件"/>}</nav>;
}
@@ -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}};
+33 -2
View File
@@ -1,5 +1,36 @@
@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}}
:root{
--ui-bg:#eef2f7;--ui-panel:#fbfcfe;--ui-surface:#f7f9fc;--ui-surface-elevated:#fff;--ui-input:#fff;
--ui-hover:#e9eef5;--ui-active:#dfe7f1;--ui-border:#d9e1eb;--ui-border-strong:#b8c4d2;
--ui-text-primary:#122033;--ui-text-secondary:#3d4d61;--ui-text-tertiary:#718096;
--ui-accent:#16835f;--ui-accent-hover:#116b4d;--ui-accent-soft:#dff4ec;
--ui-danger:#c53b45;--ui-danger-soft:#fff0f1;--ui-danger-border:#f1b9bd;
--ui-warning:#a76612;--ui-warning-soft:#fff7e6;--ui-warning-border:#efd18f;
--ui-success:#16835f;--ui-success-soft:#e7f7f1;--ui-success-border:#a7ddca;
--ui-scrollbar:#a9b5c4;--ui-scrollbar-hover:#7f8ea1;color-scheme:light;
}
.theme-dark{
--ui-bg:#0e141d;--ui-panel:#171f2b;--ui-surface:#1d2735;--ui-surface-elevated:#263243;--ui-input:#121a25;
--ui-hover:#283548;--ui-active:#324258;--ui-border:#2c394b;--ui-border-strong:#43536a;
--ui-text-primary:#edf2f7;--ui-text-secondary:#c8d2df;--ui-text-tertiary:#8f9caf;
--ui-accent:#35c792;--ui-accent-hover:#2eae80;--ui-accent-soft:#163b33;
--ui-danger:#ff7a83;--ui-danger-soft:#401f26;--ui-danger-border:#71333c;
--ui-warning:#f3bd5c;--ui-warning-soft:#3d301b;--ui-warning-border:#685028;
--ui-success:#51d4a4;--ui-success-soft:#183b32;--ui-success-border:#285f50;
--ui-scrollbar:#46566c;--ui-scrollbar-hover:#61728a;color-scheme:dark;
}
@layer base{
html,body,#root{height:100%;margin:0}body{overflow:hidden;background:var(--ui-bg);color:var(--ui-text-primary);font-family:Inter,"Noto Sans SC",system-ui,sans-serif}button,input,select{font:inherit}
:where(button,input,select):focus-visible{outline:none}
}
@layer components{
.panel-scroll{scrollbar-color:var(--ui-scrollbar) transparent;scrollbar-width:thin}
.control-slider{@apply w-full accent-accent}
.technical-value{font-variant-numeric:tabular-nums}
}
::-webkit-scrollbar{height:6px;width:6px}::-webkit-scrollbar-track{background:transparent}::-webkit-scrollbar-thumb{background:var(--ui-scrollbar);border-radius:999px}::-webkit-scrollbar-thumb:hover{background:var(--ui-scrollbar-hover)}
.theme-light,.theme-dark,.theme-light *,.theme-dark *{transition-property:background-color,color,border-color,box-shadow,opacity;transition-duration:180ms;transition-timing-function:ease-out}
@media(prefers-reduced-motion:reduce){.theme-light,.theme-dark,.theme-light *,.theme-dark *{transition-duration:0ms}}
+16 -7
View File
@@ -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();}
}
+13 -1
View File
@@ -1 +1,13 @@
module.exports = {content: ['./web_platform/index.html','./web_platform/src/**/*.{ts,tsx}'], theme: {extend: {colors: {panel:'#162033',accent:'#22c55e'}}}, plugins: []};
module.exports={
content:['./web_platform/index.html','./web_platform/src/**/*.{ts,tsx}'],
theme:{extend:{colors:{
app:'var(--ui-bg)',panel:'var(--ui-panel)',surface:'var(--ui-surface)','surface-elevated':'var(--ui-surface-elevated)',
input:'var(--ui-input)','element-hover':'var(--ui-hover)','element-active':'var(--ui-active)',
border:'var(--ui-border)','border-strong':'var(--ui-border-strong)',
'text-primary':'var(--ui-text-primary)','text-secondary':'var(--ui-text-secondary)','text-tertiary':'var(--ui-text-tertiary)',
accent:'var(--ui-accent)','accent-hover':'var(--ui-accent-hover)','accent-soft':'var(--ui-accent-soft)',
danger:'var(--ui-danger)','danger-soft':'var(--ui-danger-soft)','danger-border':'var(--ui-danger-border)',
warning:'var(--ui-warning)','warning-soft':'var(--ui-warning-soft)','warning-border':'var(--ui-warning-border)',
success:'var(--ui-success)','success-soft':'var(--ui-success-soft)','success-border':'var(--ui-success-border)'
}}},plugins:[]
};