feat(web-platform): release V0.2
build / setup (compute matrix) (pull_request) Has been cancelled
build / macos-15-arm64-studio (pull_request) Has been cancelled
build / ubuntu-24.04-clang-18-studio (pull_request) Has been cancelled
build / ubuntu-24.04-gcc-14-studio (pull_request) Has been cancelled
build / windows-2025-ninja-studio (pull_request) Has been cancelled
build / ubuntu-24.04-clang-18-wasm (pull_request) Has been cancelled
build / ubuntu-24.04-clang-18-mjx (pull_request) Has been cancelled
lint / pre-commit (pull_request) Has been cancelled
build / ${{ matrix.label }} (pull_request) Has been cancelled
build / setup (compute matrix) (pull_request) Has been cancelled
build / macos-15-arm64-studio (pull_request) Has been cancelled
build / ubuntu-24.04-clang-18-studio (pull_request) Has been cancelled
build / ubuntu-24.04-gcc-14-studio (pull_request) Has been cancelled
build / windows-2025-ninja-studio (pull_request) Has been cancelled
build / ubuntu-24.04-clang-18-wasm (pull_request) Has been cancelled
build / ubuntu-24.04-clang-18-mjx (pull_request) Has been cancelled
lint / pre-commit (pull_request) Has been cancelled
build / ${{ matrix.label }} (pull_request) Has been cancelled
This commit is contained in:
@@ -1,47 +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 {ModelStructureTree} from '../project/ModelStructureTree';
|
||||
import {MainThreadPhysicsAdapter,type UrdfBaseMode,type UrdfLoadMode} from '../simulation/PhysicsAdapter';
|
||||
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);const [theme,setTheme]=useState<ViewerTheme>(initialTheme);const [jointAdvanced,setJointAdvanced]=useState(false);const [ignoreJointLimits,setIgnoreJointLimits]=useState(false);const [angleUnit,setAngleUnit]=useState<'rad'|'deg'>('rad');
|
||||
useEffect(()=>{if(!viewerHost.current)return;viewer.current=new MuJoCoViewer(viewerHost.current,{onSelection:state.setSelection,onFrame:(frame,fps,snapshot)=>{const memory=(performance as Performance&{memory?:{usedJSHeapSize:number}}).memory?.usedJSHeapSize;state.setMetrics(fps,frame.stepMs,memory===undefined?undefined:memory/1048576,frame.overBudget);if(snapshot)state.setSnapshot(snapshot);},onError:(error)=>state.setDiagnostic(diagnostic('渲染',error))});return()=>{viewer.current?.dispose();viewer.current=null;adapter.current.dispose();};},[]);
|
||||
useEffect(()=>{viewer.current?.setMode(state.mode);},[state.mode]); useEffect(()=>{if(viewer.current)viewer.current.forceScale=forceScale;},[forceScale]);useEffect(()=>{viewer.current?.setShowCollision(showCollision);},[showCollision]);useEffect(()=>{viewer.current?.setTheme(theme);document.documentElement.style.colorScheme=theme;try{localStorage.setItem('mujoco-platform-theme',theme);}catch{/* 浏览器禁用存储时仍可在当前会话切换 */}},[theme]);
|
||||
const loadEntry=useCallback(async(path:string,requestedMode?:UrdfLoadMode)=>{if(!manifest.current)return;setIgnoreJointLimits(false);state.setEntry(path);state.setLoading(true);state.setDiagnostic(undefined);viewer.current?.attach(null);try{const snapshot=await adapter.current.load(manifest.current,path,requestedMode??urdfModeRef.current,baseModeRef.current);state.setSnapshot(snapshot);state.setPaused(true);viewer.current?.attach(adapter.current.session);}catch(error){state.setDiagnostic(diagnostic('模型编译',error,path));}finally{state.setLoading(false);}},[]);
|
||||
const ingest=useCallback(async(files:File[])=>{state.setLoading(true);try{const next=await importBrowserFiles(files);manifest.current=next;state.setProject(next.name,next.files.map(({path,size})=>({path,size})),next.entries,next.selectedEntry);if(next.selectedEntry)await loadEntry(next.selectedEntry);}catch(error){state.setDiagnostic(diagnostic(error instanceof ProjectImportError&&/ZIP/.test(error.message)?'ZIP':'导入',error,error instanceof ProjectImportError?error.path:undefined));}finally{state.setLoading(false);}},[loadEntry]);
|
||||
const removeProject=()=>{if(!state.projectName||!window.confirm(`确定从当前会话中移除“${state.projectName}”吗?\n不会删除本地文件。`))return;viewer.current?.attach(null);adapter.current.dispose();manifest.current=null;state.clearProject();};
|
||||
const 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);};
|
||||
const mode=(value:InteractionMode)=>state.setMode(value);
|
||||
const resetJoints=()=>{adapter.current.resetJoints();state.setPaused(true);state.setSnapshot(adapter.current.snapshot()??undefined);};
|
||||
const toggleJointLimits=()=>{const next=!ignoreJointLimits;adapter.current.setIgnoreJointLimits(next);setIgnoreJointLimits(next);state.setSnapshot(adapter.current.snapshot()??undefined);};
|
||||
useEffect(()=>{const key=(e:KeyboardEvent)=>{if((e.target as HTMLElement).matches('input,select,button'))return;if(e.code==='Space'){e.preventDefault();togglePause();}if(e.key==='r')reset();if(e.key==='1')mode('select');if(e.key==='2')mode('joint');if(e.key==='3')mode('force');};window.addEventListener('keydown',key);return()=>window.removeEventListener('keydown',key);});
|
||||
return <div className={`${theme==='light'?'theme-light':'theme-dark'} flex h-screen min-w-[1024px] flex-col overflow-hidden bg-slate-950 text-slate-100`} onDragOver={e=>e.preventDefault()} onDrop={drop}>
|
||||
<header className="flex h-14 shrink-0 items-center gap-3 border-b border-slate-700 bg-slate-900 px-4"><h1 className="mr-3 text-base font-semibold">MuJoCo Web 仿真平台</h1><label className="btn cursor-pointer">打开文件/ZIP<input className="sr-only" type="file" multiple accept=".xml,.urdf,.zip,.obj,.stl,.dae,.msh,.png,.jpg,.jpeg,.bmp,.tga,.hdr" onChange={changeFiles}/></label><label className="btn cursor-pointer">打开文件夹<input className="sr-only" type="file" multiple {...({webkitdirectory:'',directory:''} as object)} onChange={changeFiles}/></label><span className="h-6 border-l border-slate-700"/><button className="btn" onClick={togglePause} disabled={!state.snapshot}>{state.paused?'▶ 播放':'⏸ 暂停'}</button><button className="btn" onClick={singleStep} disabled={!state.snapshot||!state.paused}>单步</button><button className="btn" onClick={reset} disabled={!state.snapshot}>重置</button><select aria-label="仿真速度" value={state.speed} onChange={e=>changeSpeed(Number(e.target.value))} className="field w-24"><option value={.25}>0.25×</option><option value={.5}>0.5×</option><option value={1}>1×</option><option value={2}>2×</option><option value={4}>4×</option></select><span className="h-6 border-l border-slate-700"/>{(Object.keys(modeLabels) as InteractionMode[]).map(value=><button key={value} className={`btn ${state.mode===value?'border-green-500 bg-green-900/50':''}`} onClick={()=>mode(value)}>{modeLabels[value]}</button>)}<button className="btn ml-auto" onClick={()=>viewer.current?.resetCamera()}>相机复位</button><button className="icon-btn" aria-label="切换工程面板" onClick={()=>setLeftOpen(v=>!v)}>☰</button><button className="icon-btn" aria-label="切换属性面板" onClick={()=>setRightOpen(v=>!v)}>⚙</button><button className="icon-btn" aria-label={theme==='dark'?'切换到白天主题':'切换到黑夜主题'} title={theme==='dark'?'切换到白天主题':'切换到黑夜主题'} onClick={()=>setTheme(value=>value==='dark'?'light':'dark')}><span aria-hidden="true">{theme==='dark'?'☀':'☾'}</span></button></header>
|
||||
<div className="flex min-h-0 flex-1">{leftOpen&&<aside className="panel w-72 min-w-56 max-w-[40vw] shrink-0 resize-x overflow-auto border-r"><PanelTitle>工程资源</PanelTitle>{state.projectName?<><div className="flex items-center gap-2 px-3 py-2"><div className="min-w-0 flex-1 truncate text-sm font-medium text-green-300" title={state.projectName}>{state.projectName}</div><button type="button" className="btn shrink-0 border-red-900 px-2 text-red-300 hover:border-red-700 hover:bg-red-950" onClick={removeProject} disabled={state.loading} aria-label="移除当前工程" title="从当前会话中移除,不会删除本地文件">移除</button></div><div className="px-3 pb-2 text-xs text-slate-400">{state.files.length} 个文件</div><div className="px-2 pb-3"><ProjectTree files={state.files} entries={state.entries} selectedEntry={state.selectedEntry}/></div>{state.snapshot&&<section className="border-t border-slate-700 px-2 pb-4"><h3 className="px-1 py-2 text-xs font-semibold uppercase tracking-wide text-slate-300">模型结构</h3><ModelStructureTree bodies={state.snapshot.bodies} joints={state.snapshot.joints} onJointHover={jointId=>viewer.current?.highlightJoint(jointId)}/></section>}</>:<EmptyImport/>}</aside>}
|
||||
<main className="relative min-w-0 flex-1"><div ref={viewerHost} className="absolute inset-0"/>{!state.snapshot&&!state.loading&&<div className="pointer-events-none absolute inset-0 grid place-items-center"><EmptyImport/></div>}{state.loading&&<div role="status" className="absolute inset-0 grid place-items-center bg-slate-950/70"><div className="rounded bg-slate-800 px-5 py-3">正在加载 MuJoCo 与模型…</div></div>}{state.entries.length>1&&!state.selectedEntry&&<EntryDialog entries={state.entries} onSelect={loadEntry}/>} {state.diagnostic&&<DiagnosticCard value={state.diagnostic} onClose={()=>state.setDiagnostic(undefined)}/>}</main>
|
||||
{rightOpen&&<aside className="panel w-80 min-w-64 max-w-[40vw] shrink-0 resize-x overflow-auto border-l"><PanelTitle>模型与控制</PanelTitle>{state.snapshot?<><section className="section"><h3>模型信息</h3><dl className="grid grid-cols-2 gap-1 text-xs"><dt>Body</dt><dd>{state.snapshot.model.nbody}</dd><dt>Joint</dt><dd>{state.snapshot.model.njnt}</dd><dt>Geom</dt><dd>{state.snapshot.model.ngeom}</dd><dt>Actuator</dt><dd>{state.snapshot.model.nu}</dd><dt>qpos / qvel</dt><dd>{state.snapshot.model.nq} / {state.snapshot.model.nv}</dd></dl></section>{state.entries.find(entry=>entry.path===state.selectedEntry)?.format==='urdf'&&<section className="section"><h3>URDF 处理方式</h3><select aria-label="URDF 处理方式" className="field w-full" value={urdfMode} disabled={state.loading} onChange={event=>changeUrdfMode(event.target.value as UrdfLoadMode)}><option value="mjcf">转换为 MJCF(推荐)</option><option value="native">MuJoCo 原生 URDF</option></select><label className="mt-3 block text-xs text-slate-300"><span className="mb-1 block">基座类型</span><select aria-label="URDF 基座类型" className="field w-full" value={baseMode} disabled={state.loading||urdfMode==='native'} onChange={event=>changeBaseMode(event.target.value as UrdfBaseMode)}><option value="floating">浮动基座(Free Joint)</option><option value="fixed">固定基座(连接世界)</option></select></label><p className="hint mt-2">MJCF 模式会保留 visual mesh、添加物理地面,并将模型最低点对齐到 z=0。浮动基座可受重力和外力运动;固定基座保持与世界固连。</p><label className="mt-3 flex items-center gap-2 text-xs"><input type="checkbox" className="accent-green-500" checked={showCollision} onChange={event=>setShowCollision(event.target.checked)}/>显示碰撞几何</label></section>}{state.snapshot.warnings.length>0&&<section className="section border-amber-700/60 bg-amber-950/20"><h3 className="text-amber-300">URDF 兼容处理</h3><ul className="list-disc space-y-1 pl-4 text-xs text-amber-200">{state.snapshot.warnings.map(message=><li key={message}>{message}</li>)}</ul></section>}<section className="section"><h3>当前选择</h3>{state.selection?<div className="text-xs"><p>{state.selection.bodyName}</p><p className="text-slate-400">body {state.selection.bodyId} · geom {state.selection.geomId} · type {state.selection.geomType}</p><p className="text-slate-400">位置 {state.selection.position.map(v=>v.toFixed(3)).join(', ')}</p></div>:<p className="hint">在视口中单击物体</p>}</section><section className="section"><h3>Actuator</h3>{state.snapshot.actuators.length?state.snapshot.actuators.map(a=><ControlSlider key={a.id} label={a.name} value={a.value} min={a.min} max={a.max} onChange={v=>{adapter.current.setActuator(a.id,v);state.setSnapshot(adapter.current.snapshot()??undefined);}}/>):<p className="hint">模型没有 actuator</p>}</section><section className="section"><h3>关节</h3><div className="mb-4 grid grid-cols-2 gap-2"><button className="btn" onClick={resetJoints}>重置关节</button><button className={`btn ${ignoreJointLimits?'border-amber-500 bg-amber-950/50':''}`} aria-pressed={ignoreJointLimits} onClick={toggleJointLimits}>忽略关节限位</button><button className={`btn ${jointAdvanced?'border-green-500 bg-green-900/50':''}`} aria-pressed={jointAdvanced} onClick={()=>setJointAdvanced(value=>!value)}>高级</button><button className={`btn ${angleUnit==='deg'?'border-green-500 bg-green-900/50':''}`} aria-pressed={angleUnit==='deg'} onClick={()=>setAngleUnit(value=>value==='rad'?'deg':'rad')}>{angleUnit==='rad'?'rad 弧度制':'° 角度制'}</button></div>{state.snapshot.joints.map(j=>{const scale=j.type===3&&angleUnit==='deg'?180/Math.PI:1,unit=j.type===3?(angleUnit==='deg'?'°':' rad'):j.type===2?' m':'';return <ControlSlider key={j.id} label={`${j.name}${j.editable?'':'(只读)'}`} value={j.value*scale} min={j.min*scale} max={j.max*scale} unit={unit} advanced={jointAdvanced} limited={j.limited} limitsIgnored={j.limitsIgnored} limitMin={j.limitMin*scale} limitMax={j.limitMax*scale} disabled={!j.editable} onChange={v=>{adapter.current.setJointPosition(j.id,v/scale);state.setPaused(true);state.setSnapshot(adapter.current.snapshot()??undefined);}}/>;})}</section><section className="section"><h3>外力强度</h3><ControlSlider label={`${forceScale.toFixed(0)} N/屏幕单位`} value={forceScale} min={5} max={200} onChange={setForceScale}/><p className="hint">选择“外力施加”,在动态物体上按住拖动,松开即清零。</p></section></>:<p className="p-4 text-sm text-slate-400">导入模型后显示属性</p>}</aside>}</div>
|
||||
<footer className="flex h-7 shrink-0 items-center gap-5 border-t border-slate-700 bg-slate-900 px-3 text-xs text-slate-400"><span>时间 {state.snapshot?.time.toFixed(3)??'—'} s</span><span>FPS {state.fps.toFixed(0)}</span><span>物理 {state.stepMs.toFixed(2)} ms</span><span>内存 {state.memoryMb===undefined?'—':`${state.memoryMb.toFixed(1)} MiB`}</span><span>WASM {state.snapshot?'已加载':'未加载'}</span>{state.overBudget&&<span className="text-amber-400">主线程超出步进预算,已限制追帧</span>}<span className="ml-auto">快捷键:Space 播放/暂停 · R 重置 · 1/2/3 模式</span></footer>
|
||||
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/XML、URDF、文件夹和 ZIP</p></div>}
|
||||
function EntryDialog({entries,onSelect}:{entries:{path:string;label:string}[];onSelect:(path:string)=>void}){return <div role="dialog" aria-modal="true" aria-label="选择模型入口" className="absolute inset-0 grid place-items-center bg-slate-950/80"><div className="w-[32rem] rounded border border-slate-600 bg-slate-900 p-5"><h2 className="font-semibold">选择模型入口</h2><p className="mt-1 text-sm text-slate-400">工程包含多个可加载模型,请选择一个。</p><div className="mt-4 space-y-2">{entries.map(e=><button className="btn block w-full overflow-hidden text-ellipsis text-left" key={e.path} onClick={()=>onSelect(e.path)}>{e.label}</button>)}</div></div></div>}
|
||||
function DiagnosticCard({value,onClose}:{value:AppDiagnostic;onClose:()=>void}){return <section role="alert" className="absolute bottom-4 left-4 right-4 max-h-48 overflow-auto rounded border border-red-700 bg-red-950/95 p-3 shadow-xl"><button aria-label="关闭错误" className="float-right" onClick={onClose}>×</button><h2 className="font-semibold text-red-200">{value.summary}</h2>{value.path&&<p className="mt-1 text-xs text-red-300">路径:{value.path}</p>}<pre className="mt-2 whitespace-pre-wrap text-xs text-red-100">{value.detail}</pre></section>}
|
||||
function ControlSlider({label,value,min,max,onChange,disabled=false,unit='',advanced=false,limited=false,limitsIgnored=false,limitMin=0,limitMax=0}:{label:string;value:number;min:number;max:number;onChange:(v:number)=>void;disabled?:boolean;unit?:string;advanced?:boolean;limited?:boolean;limitsIgnored?:boolean;limitMin?:number;limitMax?:number}){const sane=Number.isFinite(value)?value:0,format=(number:number)=>`${number.toFixed(3)}${unit}`;return <label className="mb-3 block text-xs"><span className="mb-1 flex justify-between"><span className="truncate pr-2">{label}</span><output>{format(sane)}</output></span><input className="w-full accent-green-500" type="range" disabled={disabled} value={Math.min(max,Math.max(min,sane))} min={min} max={max} step={(max-min)/500||.001} onChange={e=>onChange(Number(e.target.value))}/>{advanced&&<span className="mt-1 flex justify-between text-[10px] text-slate-500"><span>下限 {limited?format(limitMin):'无限制'}</span>{limitsIgnored&&limited&&<span className="text-amber-400">已忽略</span>}<span>上限 {limited?format(limitMax):'无限制'}</span></span>}</label>}
|
||||
|
||||
@@ -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/XML、URDF、文件夹和 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>}</>;}
|
||||
Reference in New Issue
Block a user