diff --git a/wasm/web_platform/e2e/app.spec.ts b/wasm/web_platform/e2e/app.spec.ts index 954796e8..d3e7bded 100644 --- a/wasm/web_platform/e2e/app.spec.ts +++ b/wasm/web_platform/e2e/app.spec.ts @@ -43,8 +43,8 @@ test('显示中文平台骨架并加载单文件模型', async ({page}) => { 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(); + const resetCameraBox=await page.getByRole('button',{name:'相机复位'}).boundingBox(),playBox=await page.getByRole('button',{name:'▶ 播放'}).boundingBox(); + expect(resetCameraBox&&playBox&&resetCameraBox.x+resetCameraBox.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(); @@ -77,6 +77,9 @@ test('显示中文平台骨架并加载单文件模型', async ({page}) => { }); await expect(page.getByText('WASM 已加载')).toBeVisible({timeout: 30_000}); + await page.getByRole('button',{name:'显示设置'}).click(); + const displayDialog=page.getByRole('dialog',{name:'视图显示设置'});await expect(displayDialog).toBeVisible();await expect(displayDialog.getByRole('switch')).toHaveCount(7); + const centerOfMassSwitch=displayDialog.getByRole('switch',{name:/^质心/});await centerOfMassSwitch.click();await expect(centerOfMassSwitch).toHaveAttribute('aria-checked','true');await page.keyboard.press('Escape');await expect(displayDialog).toBeHidden(); 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(); @@ -102,6 +105,28 @@ test('显示中文平台骨架并加载单文件模型', async ({page}) => { await expect(page.getByRole('button',{name:'▶ 播放'})).toBeVisible(); }); +test('窄视口默认保留完整视口并可按需打开侧栏',async({page})=>{ + await page.setViewportSize({width:800,height:700}); + await page.goto('/'); + await expect(page.getByRole('main')).toBeInViewport(); + await expect(page.getByRole('button',{name:'显示工程面板'})).toBeVisible(); + await expect(page.getByRole('button',{name:'显示属性面板'})).toBeVisible(); + await page.getByRole('button',{name:'显示属性面板'}).click(); + await expect(page.getByRole('complementary').filter({hasText:'导入模型后显示属性'})).toBeVisible(); +}); + +test('工作区布局与视口显示偏好在刷新后保留',async({page})=>{ + await page.setViewportSize({width:1440,height:900}); + await page.goto('/'); + await page.getByRole('button',{name:'隐藏工程面板'}).click(); + await page.getByRole('button',{name:'显示设置'}).click(); + await page.getByRole('switch',{name:/^坐标系/}).click(); + await page.reload(); + await expect(page.getByRole('button',{name:'显示工程面板'})).toBeVisible(); + await page.getByRole('button',{name:'显示设置'}).click(); + await expect(page.getByRole('switch',{name:/^坐标系/})).toHaveAttribute('aria-checked','true'); +}); + test('转换后的 MJCF 可编辑并重新载入', async ({page}) => { await page.goto('/'); await page.locator('input[type="file"]').first().setInputFiles({name:'model.xml',mimeType:'text/xml',buffer:Buffer.from(SIMPLE_MODEL)}); @@ -120,6 +145,24 @@ test('转换后的 MJCF 可编辑并重新载入', async ({page}) => { await expect(page.getByRole('button',{name:'导出 MJCF'})).toHaveCount(0); }); +test('关闭已修改的 MJCF 前要求确认',async({page})=>{ + await page.goto('/'); + await page.locator('input[type="file"]').first().setInputFiles({name:'model.xml',mimeType:'text/xml',buffer:Buffer.from(SIMPLE_MODEL)}); + await expect(page.getByText('WASM 已加载')).toBeVisible({timeout:30_000}); + await page.getByRole('button',{name:'源代码'}).click(); + const editorDialog=page.getByRole('dialog',{name:'转换后的 MJCF 编辑器'}); + await editorDialog.locator('.monaco-editor').click({position:{x:240,y:120}}); + await page.keyboard.press('Control+End');await page.keyboard.insertText('\n'); + await editorDialog.getByRole('button',{name:'关闭源代码编辑器'}).click(); + const confirm=page.getByRole('dialog',{name:'放弃未保存的修改?'}); + await expect(confirm).toBeVisible(); + await confirm.getByRole('button',{name:'继续编辑'}).click(); + await expect(editorDialog).toBeVisible(); + await editorDialog.getByRole('button',{name:'关闭源代码编辑器'}).click(); + await page.getByRole('dialog',{name:'放弃未保存的修改?'}).getByRole('button',{name:'放弃修改'}).click(); + await expect(editorDialog).toHaveCount(0); +}); + test('加载包含 include、OBJ、STL 与 PNG 的工程', async ({page}) => { await page.goto('/'); await page.locator('input[type="file"]').first().setInputFiles([ @@ -150,7 +193,7 @@ test('加载引用 OBJ 的 URDF 工程', async ({page}) => { await expect(page.getByLabel('URDF 基座类型')).toHaveValue('floating'); await page.getByRole('button',{name:'通知中心'}).click(); const notifications=page.getByRole('dialog',{name:'通知中心'}); - await expect(notifications).toContainText('URDF 兼容处理'); + await expect(notifications).toContainText(/模型已加载 · \d+ 项兼容调整/); await expect(notifications).toContainText(/URDF 已转换为 MJCF(浮动基座),并整体平移/); await page.keyboard.press('Escape'); await expect(page.getByLabel('显示碰撞几何')).not.toBeChecked(); diff --git a/wasm/web_platform/src/app/App.tsx b/wasm/web_platform/src/app/App.tsx index 68a620f8..9df99cce 100644 --- a/wasm/web_platform/src/app/App.tsx +++ b/wasm/web_platform/src/app/App.tsx @@ -1,6 +1,6 @@ /* Zustand 的 action 引用稳定;初始化 viewer 与导入回调有意只创建一次。 */ /* eslint-disable react-hooks/exhaustive-deps */ -import {useCallback,useEffect,useRef,useState,type ChangeEvent,type DragEvent} from 'react'; +import {lazy,Suspense,useCallback,useEffect,useRef,useState,type ChangeEvent,type DragEvent} from 'react'; import {Camera,ChevronLeft,ChevronRight,CircleHelp,Code2,Crosshair,Download,Hand,Maximize,MousePointer2,PanelsTopLeft,Pause,Play,RotateCcw,Settings as SettingsIcon,SunMoon} from 'lucide-react'; import {DEFAULT_IMPORT_LIMITS,type ProjectManifest} from '../project/types'; import {filesFromDrop,importBrowserFiles,normalizeProjectPath,ProjectImportError} from '../project/importer'; @@ -9,6 +9,7 @@ import type {ActuatorParameters} from '../simulation/SimulationSession'; import type {ControllerCommand,ControllerStatus} from '../controller/types'; import type {RLCommand,RLPolicyStatus} from '../rl/types'; import {MuJoCoViewer,type InteractionMode,type ViewerTheme} from '../viewer/MuJoCoViewer'; +import {DEFAULT_VIEWER_DISPLAY_OPTIONS,type ViewerDisplayOptions} from '../viewer/displayOptions'; import {useAppStore,type AppDiagnostic} from '../stores/useAppStore'; import {WorkbenchHeader} from './components/WorkbenchHeader'; import {ViewerToolDock} from './components/ViewerToolDock'; @@ -26,30 +27,36 @@ import {dispatchLayoutWidths,LayoutSettingsDialog,type LayoutPreset} from './com import {Button,ConfirmDialog,IconButton} from '../components/ui'; import {DiagnosticsDrawer} from './components/DiagnosticsDrawer'; import {ToolbarOverflowMenu} from './components/ToolbarOverflowMenu'; -import {SourceEditorDialog} from './components/SourceEditorDialog'; + import {UrdfImportOptionsDialog} from './components/UrdfImportOptionsDialog'; import {downloadBytes,exportedFileName,mergeCachedFiles,readCachedText,upsertCachedMjcf} from '../project/cachedFiles'; +const SourceEditorDialog=lazy(()=>import('./components/SourceEditorDialog').then(module=>({default:module.SourceEditorDialog}))); + 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()};} function initialTheme():ViewerTheme{try{return localStorage.getItem('mujoco-platform-theme')==='light'?'light':'dark';}catch{return'dark';}} +function initialSidebarVisibility():{left:boolean;right:boolean}{const width=typeof window==='undefined'?1280:window.innerWidth;if(width<900)return {left:false,right:false};try{const stored=JSON.parse(localStorage.getItem('mujoco-platform-layout')??'null') as {left?:unknown;right?:unknown}|null;if(stored&&typeof stored.left==='boolean'&&typeof stored.right==='boolean')return {left:stored.left,right:stored.right};}catch{/* 使用响应式默认布局 */}return width>=1280?{left:true,right:true}:{left:false,right:true};} +function initialDisplayOptions():ViewerDisplayOptions{try{const stored=JSON.parse(localStorage.getItem('mujoco-platform-display')??'null') as Partial|null;if(!stored)return {...DEFAULT_VIEWER_DISPLAY_OPTIONS};const next={...DEFAULT_VIEWER_DISPLAY_OPTIONS};for(const key of Object.keys(next) as (keyof ViewerDisplayOptions)[])if(typeof stored[key]==='boolean')next[key]=stored[key];return next;}catch{return {...DEFAULT_VIEWER_DISPLAY_OPTIONS};}} function convertedCachePath(entryPath:string):string{const slash=entryPath.lastIndexOf('/');return `${slash>=0?entryPath.slice(0,slash+1):''}.__converted_mjcf_cache__.xml`;} function urdfLinkNames(project:ProjectManifest|null,path:string|undefined):string[]{const file=path?project?.files.find(candidate=>candidate.path===path):undefined;if(!file)return[];const document=new DOMParser().parseFromString(new TextDecoder().decode(file.data),'application/xml');return Array.from(document.querySelectorAll('robot > link[name]')).map(link=>link.getAttribute('name')).filter((name):name is string=>Boolean(name));} export function App(){ const state=useAppStore(); const manifest=useRef(null),notificationId=useRef(0),loadInFlight=useRef(false),importInFlight=useRef(false),adapter=useRef(new MainThreadPhysicsAdapter()),root=useRef(null),viewerHost=useRef(null),viewer=useRef(null),urdfEnhancementsRef=useRef({addActuators:true,addSensors:true,sensorType:'camera'}); - const [forceScale,setForceScale]=useState(50),[leftOpen,setLeftOpen]=useState(true),[rightOpen,setRightOpen]=useState(true),[helpOpen,setHelpOpen]=useState(false),[commandOpen,setCommandOpen]=useState(false),[sourceOpen,setSourceOpen]=useState(false),[generatedMjcf,setGeneratedMjcf]=useState(),[generatedMjcfPath,setGeneratedMjcfPath]=useState(),[pendingUrdfPath,setPendingUrdfPath]=useState(),[pendingUrdfMounts,setPendingUrdfMounts]=useState([]),[removeConfirmOpen,setRemoveConfirmOpen]=useState(false),[fullscreen,setFullscreen]=useState(false),[settingsOpen,setSettingsOpen]=useState(false),[layoutOpen,setLayoutOpen]=useState(false),[diagnosticsOpen,setDiagnosticsOpen]=useState(false),[importProgress,setImportProgress]=useState(),[notifications,setNotifications]=useState([]),[toast,setToast]=useState(),[selectedControllerPath,setSelectedControllerPath]=useState(),[controllerStatus,setControllerStatus]=useState(),[selectedPolicyPath,setSelectedPolicyPath]=useState(),[policyStatus,setPolicyStatus]=useState(); + const [forceScale,setForceScale]=useState(50),[leftOpen,setLeftOpen]=useState(()=>initialSidebarVisibility().left),[rightOpen,setRightOpen]=useState(()=>initialSidebarVisibility().right),[helpOpen,setHelpOpen]=useState(false),[commandOpen,setCommandOpen]=useState(false),[sourceOpen,setSourceOpen]=useState(false),[generatedMjcf,setGeneratedMjcf]=useState(),[generatedMjcfPath,setGeneratedMjcfPath]=useState(),[pendingUrdfPath,setPendingUrdfPath]=useState(),[pendingUrdfMounts,setPendingUrdfMounts]=useState([]),[removeConfirmOpen,setRemoveConfirmOpen]=useState(false),[fullscreen,setFullscreen]=useState(false),[settingsOpen,setSettingsOpen]=useState(false),[layoutOpen,setLayoutOpen]=useState(false),[diagnosticsOpen,setDiagnosticsOpen]=useState(false),[importProgress,setImportProgress]=useState(),[notifications,setNotifications]=useState([]),[toast,setToast]=useState(),[selectedControllerPath,setSelectedControllerPath]=useState(),[controllerStatus,setControllerStatus]=useState(),[selectedPolicyPath,setSelectedPolicyPath]=useState(),[policyStatus,setPolicyStatus]=useState(); const [urdfMode,setUrdfMode]=useState('mjcf'),urdfModeRef=useRef('mjcf'); const [baseMode,setBaseMode]=useState('floating'),baseModeRef=useRef('floating'); - const [showCollision,setShowCollision]=useState(false),[showSensorCamera,setShowSensorCamera]=useState(true),[theme,setTheme]=useState(initialTheme),[jointAdvanced,setJointAdvanced]=useState(false),[ignoreJointLimits,setIgnoreJointLimits]=useState(false),[angleUnit,setAngleUnit]=useState<'rad'|'deg'>('rad'); + const [displayOptions,setDisplayOptions]=useState(initialDisplayOptions),[showSensorCamera,setShowSensorCamera]=useState(true),[theme,setTheme]=useState(initialTheme),[jointAdvanced,setJointAdvanced]=useState(false),[ignoreJointLimits,setIgnoreJointLimits]=useState(false),[angleUnit,setAngleUnit]=useState<'rad'|'deg'>('rad'); + const showCollision=displayOptions.showCollision,setShowCollision=(value:boolean)=>setDisplayOptions(options=>({...options,showCollision:value})); 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);setControllerStatus(snapshot.controller);setPolicyStatus(snapshot.rlPolicy);if(snapshot.controller?.error||snapshot.rlPolicy?.error){adapter.current.setPaused(true);state.setPaused(true);}}},onError:error=>state.setDiagnostic(diagnostic(error.message.includes('控制器')?'仿真':'渲染',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?.setDisplayOptions(displayOptions);try{localStorage.setItem('mujoco-platform-display',JSON.stringify(displayOptions));}catch{/* 当前会话仍可修改 */}},[displayOptions]); + useEffect(()=>{if(window.innerWidth<900)return;try{localStorage.setItem('mujoco-platform-layout',JSON.stringify({left:leftOpen,right:rightOpen}));}catch{/* 当前会话仍可修改 */}},[leftOpen,rightOpen]); useEffect(()=>{viewer.current?.setShowSensorCamera(showSensorCamera);},[showSensorCamera]); 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);setControllerStatus(undefined);setPolicyStatus(undefined);state.setEntry(path);state.setLoading(true);setImportProgress({label:'初始化 WASM 与编译模型',value:.65});state.setDiagnostic(undefined);setGeneratedMjcf(undefined);setGeneratedMjcfPath(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,urdfEnhancementsRef.current);const supportFiles=adapter.current.cachedSupportFiles();if(supportFiles.length&&manifest.current){manifest.current=mergeCachedFiles(manifest.current,supportFiles);state.setProject(manifest.current.name,manifest.current.files.map(file=>({path:file.path,size:file.size})),manifest.current.entries,path);}setImportProgress({label:'创建视口场景',value:.92});adapter.current.setSpeed(useAppStore.getState().speed);state.setSnapshot(snapshot);state.setPaused(true);viewer.current?.attach(adapter.current.session);try{setGeneratedMjcf(new TextDecoder().decode(adapter.current.exportMjcf()));setGeneratedMjcfPath(convertedCachePath(path));}catch(error){console.warn('[MuJoCo] 无法生成源码预览',error);}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 loadEntry=useCallback(async(path:string,requestedMode?:UrdfLoadMode)=>{if(!manifest.current||loadInFlight.current)return;loadInFlight.current=true;setIgnoreJointLimits(false);setControllerStatus(undefined);setPolicyStatus(undefined);state.setEntry(path);state.setLoading(true);setImportProgress({label:'初始化 WASM 与编译模型',value:.65});state.setDiagnostic(undefined);setGeneratedMjcf(undefined);setGeneratedMjcfPath(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,urdfEnhancementsRef.current);const supportFiles=adapter.current.cachedSupportFiles();if(supportFiles.length&&manifest.current){manifest.current=mergeCachedFiles(manifest.current,supportFiles);state.setProject(manifest.current.name,manifest.current.files.map(file=>({path:file.path,size:file.size})),manifest.current.entries,path);}setImportProgress({label:'创建视口场景',value:.92});adapter.current.setSpeed(useAppStore.getState().speed);state.setSnapshot(snapshot);state.setPaused(true);viewer.current?.attach(adapter.current.session);try{setGeneratedMjcf(new TextDecoder().decode(adapter.current.exportMjcf()));setGeneratedMjcfPath(convertedCachePath(path));}catch(error){console.warn('[MuJoCo] 无法生成源码预览',error);}const notice:WorkbenchNotification={id:++notificationId.current,title:snapshot.warnings.length?`模型已加载 · ${snapshot.warnings.length} 项兼容调整`:'模型加载完成',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 requestLoadEntry=useCallback(async(path:string)=>{const entry=manifest.current?.entries.find(candidate=>candidate.path===path);if(entry?.format==='urdf'&&urdfModeRef.current==='mjcf'){setPendingUrdfMounts(urdfLinkNames(manifest.current,path));setPendingUrdfPath(path);return;}await loadEntry(path);},[loadEntry]); const confirmUrdfOptions=(options:UrdfEnhancementOptions)=>{const path=pendingUrdfPath;if(!path)return;urdfEnhancementsRef.current=options;setPendingUrdfPath(undefined);setPendingUrdfMounts([]);void loadEntry(path);}; const skipUrdfOptions=()=>confirmUrdfOptions({addActuators:false,addSensors:false,sensorType:'camera'}); @@ -106,9 +113,9 @@ export function App(){ {id:'fullscreen',label:fullscreen?'退出全屏':'进入全屏',group:'布局',icon:,run:toggleFullscreen}, {id:'help',label:'查看快捷键帮助',group:'帮助',icon:,run:()=>setHelpOpen(true)}, ]; - return
event.preventDefault()} onDrop={drop}> - setSourceOpen(true)} 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={<>setNotifications(items=>items.filter(item=>item.id!==id))} onClear={()=>setNotifications([])} onOpenLog={()=>setDiagnosticsOpen(true)}/>setLayoutOpen(true)}>setSettingsOpen(true)}>} compactMenu={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={viewer.current?.resetCamera()}/>}/> -
viewer.current?.highlightJoint(jointId)}/>
setToast(undefined)}/>{Boolean(state.snapshot?.model.ncam)&&(showSensorCamera?
摄像头
:)}{state.entries.length>1&&!state.selectedEntry&&!pendingUrdfPath&&} {state.diagnostic&&state.setDiagnostic(undefined)} onRetry={state.diagnostic.category==='模型编译'&&state.diagnostic.path?()=>void loadEntry(state.diagnostic!.path!):undefined} onOpenProject={()=>{setLeftOpen(true);state.setDiagnostic(undefined);}}/>}
/\.py$/i.test(file.path)).map(file=>file.path)} selectedControllerPath={selectedControllerPath} controllerStatus={controllerStatus} policyPaths={state.files.filter(file=>/\.onnx$/i.test(file.path)).map(file=>file.path)} selectedPolicyPath={selectedPolicyPath} policyStatus={policyStatus} onUrdfMode={changeUrdfMode} onBaseMode={changeBaseMode} onShowCollision={setShowCollision} onResetJoints={resetJoints} onToggleJointLimits={toggleJointLimits} onToggleAdvanced={()=>setJointAdvanced(value=>!value)} onToggleAngleUnit={()=>setAngleUnit(value=>value==='rad'?'deg':'rad')} onActuator={setActuator} onActuatorParameters={setActuatorParameters} onJoint={setJoint} onForceScale={setForceScale} onSelectControllerPath={setSelectedControllerPath} onLoadControllerPath={loadControllerPath} onImportController={importController} onToggleController={toggleController} onControllerCommand={sendControllerCommand} onRemoveController={removeController} onSelectPolicyPath={setSelectedPolicyPath} onLoadPolicyPath={loadPolicyPath} onImportPolicy={importPolicy} onTogglePolicy={togglePolicy} onPolicyCommand={setPolicyCommand} onRemovePolicy={removePolicy}/>
- {pendingUrdfPath&&}{sourceOpen&&generatedMjcf&&generatedMjcfPath&&setSourceOpen(false)} onSave={saveCachedSource}/>}setHelpOpen(false)}/>setDiagnosticsOpen(false)} onClear={()=>setNotifications([])}/>setSettingsOpen(false)} theme={theme} angleUnit={angleUnit} showCollision={showCollision} jointAdvanced={jointAdvanced} forceScale={forceScale} onTheme={setTheme} onAngleUnit={setAngleUnit} onShowCollision={setShowCollision} onJointAdvanced={setJointAdvanced} onForceScale={setForceScale}/>setLayoutOpen(false)} leftOpen={leftOpen} rightOpen={rightOpen} onLeftOpen={setLeftOpen} onRightOpen={setRightOpen} onPreset={applyLayoutPreset} onReset={()=>applyLayoutPreset('default')}/>setCommandOpen(false)} commands={commands}/>setRemoveConfirmOpen(false)}>

确定从当前会话中移除“{state.projectName}”吗?

该操作不会删除本地文件。

+ return
event.preventDefault()} onDrop={drop}> + setSourceOpen(true)} 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={<>setNotifications(items=>items.filter(item=>item.id!==id))} onClear={()=>setNotifications([])} onOpenLog={()=>setDiagnosticsOpen(true)}/>setLayoutOpen(true)}>setSettingsOpen(true)}>} compactMenu={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={viewer.current?.resetCamera()}/>}/> +
viewer.current?.highlightJoint(jointId)}/>
setToast(undefined)}/>{Boolean(state.snapshot?.model.ncam)&&(showSensorCamera?
摄像头
:)}{state.entries.length>1&&!state.selectedEntry&&!pendingUrdfPath&&} {state.diagnostic&&state.setDiagnostic(undefined)} onRetry={state.diagnostic.category==='模型编译'&&state.diagnostic.path?()=>void loadEntry(state.diagnostic!.path!):undefined} onOpenProject={()=>{setLeftOpen(true);state.setDiagnostic(undefined);}}/>}
/\.py$/i.test(file.path)).map(file=>file.path)} selectedControllerPath={selectedControllerPath} controllerStatus={controllerStatus} policyPaths={state.files.filter(file=>/\.onnx$/i.test(file.path)).map(file=>file.path)} selectedPolicyPath={selectedPolicyPath} policyStatus={policyStatus} onUrdfMode={changeUrdfMode} onBaseMode={changeBaseMode} onShowCollision={setShowCollision} onResetJoints={resetJoints} onToggleJointLimits={toggleJointLimits} onToggleAdvanced={()=>setJointAdvanced(value=>!value)} onToggleAngleUnit={()=>setAngleUnit(value=>value==='rad'?'deg':'rad')} onActuator={setActuator} onActuatorParameters={setActuatorParameters} onJoint={setJoint} onForceScale={setForceScale} onSelectControllerPath={setSelectedControllerPath} onLoadControllerPath={loadControllerPath} onImportController={importController} onToggleController={toggleController} onControllerCommand={sendControllerCommand} onRemoveController={removeController} onSelectPolicyPath={setSelectedPolicyPath} onLoadPolicyPath={loadPolicyPath} onImportPolicy={importPolicy} onTogglePolicy={togglePolicy} onPolicyCommand={setPolicyCommand} onRemovePolicy={removePolicy}/>
+ {pendingUrdfPath&&}{sourceOpen&&generatedMjcf&&generatedMjcfPath&&正在加载源码编辑器…
}>setSourceOpen(false)} onSave={saveCachedSource}/>}setHelpOpen(false)}/>setDiagnosticsOpen(false)} onClear={()=>setNotifications([])}/>setSettingsOpen(false)} theme={theme} angleUnit={angleUnit} showCollision={showCollision} jointAdvanced={jointAdvanced} forceScale={forceScale} onTheme={setTheme} onAngleUnit={setAngleUnit} onShowCollision={setShowCollision} onJointAdvanced={setJointAdvanced} onForceScale={setForceScale}/>setLayoutOpen(false)} leftOpen={leftOpen} rightOpen={rightOpen} onLeftOpen={setLeftOpen} onRightOpen={setRightOpen} onPreset={applyLayoutPreset} onReset={()=>applyLayoutPreset('default')}/>setCommandOpen(false)} commands={commands}/>setRemoveConfirmOpen(false)}>

确定从当前会话中移除“{state.projectName}”吗?

该操作不会删除本地文件。

; } diff --git a/wasm/web_platform/src/app/components/SecondBatchComponents.test.tsx b/wasm/web_platform/src/app/components/SecondBatchComponents.test.tsx index 5603276c..e1166102 100644 --- a/wasm/web_platform/src/app/components/SecondBatchComponents.test.tsx +++ b/wasm/web_platform/src/app/components/SecondBatchComponents.test.tsx @@ -2,9 +2,14 @@ import {fireEvent,render,screen} from '@testing-library/react'; import {ShortcutHelpDialog} from './ShortcutHelpDialog'; import {TreeSearchField} from './TreeSearchField'; import {ViewportHUD} from './ViewportHUD'; +import {EmptyWorkspace} from './WorkspaceOverlays'; +import {ViewerDisplayPopover} from './ViewerDisplayPopover'; +import {DEFAULT_VIEWER_DISPLAY_OPTIONS} from '../../viewer/displayOptions'; describe('第二批工作台组件',()=>{ it('搜索框发送内容并可清除',()=>{const change=vi.fn();const {rerender}=render();fireEvent.change(screen.getByRole('searchbox'),{target:{value:'arm'}});expect(change).toHaveBeenCalledWith('arm');rerender();expect(screen.getByRole('status')).toHaveTextContent('找到 2 个匹配项');fireEvent.click(screen.getByRole('button',{name:'清除搜索'}));expect(change).toHaveBeenLastCalledWith('');}); it('快捷键帮助展示说明并支持 Escape',()=>{const close=vi.fn();render();expect(screen.getByRole('dialog',{name:'快捷键与视口操作'})).toBeVisible();expect(screen.getByText('播放 / 暂停')).toBeVisible();fireEvent.keyDown(document,{key:'Escape'});expect(close).toHaveBeenCalledTimes(1);}); - it('视口 HUD 复用仿真与选择状态',()=>{render();expect(screen.getByLabelText('视口状态')).toHaveTextContent('仿真中');expect(screen.getByLabelText('视口状态')).toHaveTextContent('关节拖动');expect(screen.getByLabelText('视口状态')).toHaveTextContent('arm');}); + it('视口 HUD 复用状态并给出当前模式的鼠标提示',()=>{render();expect(screen.getByLabelText('视口状态')).toHaveTextContent('仿真中');expect(screen.getByLabelText('视口状态')).toHaveTextContent('关节拖动');expect(screen.getByLabelText('视口状态')).toHaveTextContent('arm');expect(screen.getByLabelText('视口操作提示')).toHaveTextContent('左键拖动关节');expect(screen.getByLabelText('视口操作提示')).toHaveTextContent('右键平移');}); + it('空工作区解释导入到仿真的三步流程',()=>{render();expect(screen.getByRole('region',{name:'导入模型工程'})).toBeVisible();expect(screen.getByRole('list',{name:'仿真工作流程'})).toHaveTextContent('导入');expect(screen.getByRole('list',{name:'仿真工作流程'})).toHaveTextContent('检查与配置');expect(screen.getByRole('list',{name:'仿真工作流程'})).toHaveTextContent('运行与调试');expect(screen.getByText('模型与资源仅在当前浏览器会话中处理')).toBeVisible();}); + it('显示浮窗切换碰撞体和结构辅助标记',()=>{const change=vi.fn();render();fireEvent.click(screen.getByRole('button',{name:'显示设置'}));expect(screen.getByRole('dialog',{name:'视图显示设置'})).toBeVisible();expect(screen.getAllByRole('switch')).toHaveLength(7);fireEvent.click(screen.getByRole('switch',{name:/碰撞体/}));expect(change).toHaveBeenCalledWith({...DEFAULT_VIEWER_DISPLAY_OPTIONS,showCollision:true});}); }); diff --git a/wasm/web_platform/src/app/components/SourceEditorDialog.tsx b/wasm/web_platform/src/app/components/SourceEditorDialog.tsx index 1b8f3d74..33ce5449 100644 --- a/wasm/web_platform/src/app/components/SourceEditorDialog.tsx +++ b/wasm/web_platform/src/app/components/SourceEditorDialog.tsx @@ -3,25 +3,27 @@ import Editor from '@monaco-editor/react'; import {useCallback,useEffect,useRef,useState,type PointerEvent as ReactPointerEvent} from 'react'; import {Check,Code2,Copy,Download,Maximize2,Minimize2,Save,X} from 'lucide-react'; import {downloadBytes} from '../../project/cachedFiles'; -import {Button,IconButton} from '../../components/ui'; +import {Button,ConfirmDialog,IconButton} from '../../components/ui'; function basename(path:string):string{return path.split('/').at(-1)??path;} function contentSize(content:string):string{const bytes=new Blob([content]).size;return bytes<1024?`${bytes} B`:`${(bytes/1024).toFixed(1)} KB`;} function xmlProblem(code:string):string|undefined{const document=new DOMParser().parseFromString(code,'application/xml'),error=document.querySelector('parsererror');return error?.textContent?.split('\n')[0]||undefined;} export function SourceEditorDialog({open,code:sourceCode,filePath,theme,onClose,onSave}:{open:boolean;code:string;filePath:string;theme:'light'|'dark';onClose:()=>void;onSave:(path:string,text:string)=>void|Promise}){ - const [code,setCode]=useState(sourceCode),[savedCode,setSavedCode]=useState(sourceCode),[saving,setSaving]=useState(false),[copied,setCopied]=useState(false),[maximized,setMaximized]=useState(false),[position,setPosition]=useState(()=>({x:Math.max(24,(window.innerWidth-900)/2),y:Math.max(52,(window.innerHeight-650)/2)})); - const drag=useRef<{x:number;y:number;left:number;top:number}|null>(null),dirty=code!==savedCode,problem=xmlProblem(code); + const [code,setCode]=useState(sourceCode),[savedCode,setSavedCode]=useState(sourceCode),[saving,setSaving]=useState(false),[copied,setCopied]=useState(false),[maximized,setMaximized]=useState(false),[discardOpen,setDiscardOpen]=useState(false),[position,setPosition]=useState(()=>({x:Math.max(24,(window.innerWidth-900)/2),y:Math.max(52,(window.innerHeight-650)/2)})); + const drag=useRef<{x:number;y:number;left:number;top:number}|null>(null),dialog=useRef(null),previousFocus=useRef(null),dirty=code!==savedCode,problem=xmlProblem(code); + const requestClose=useCallback(()=>{if(dirty)setDiscardOpen(true);else onClose();},[dirty,onClose]); const save=useCallback(async()=>{if(!dirty||problem)return;setSaving(true);try{await onSave(filePath,code);setSavedCode(code);}finally{setSaving(false);}},[code,dirty,filePath,onSave,problem]); - useEffect(()=>{const key=(event:KeyboardEvent)=>{if((event.ctrlKey||event.metaKey)&&event.key.toLowerCase()==='s'&&dirty&&!problem){event.preventDefault();void save();}};window.addEventListener('keydown',key);return()=>window.removeEventListener('keydown',key);},[dirty,problem,save]); + useEffect(()=>{if(!open)return;previousFocus.current=document.activeElement instanceof HTMLElement?document.activeElement:null;requestAnimationFrame(()=>dialog.current?.focus());return()=>{if(previousFocus.current&&document.contains(previousFocus.current))previousFocus.current.focus();};},[open]); + useEffect(()=>{const key=(event:KeyboardEvent)=>{if(discardOpen)return;if((event.ctrlKey||event.metaKey)&&event.key.toLowerCase()==='s'&&dirty&&!problem){event.preventDefault();void save();}else if(event.key==='Escape'){event.preventDefault();requestClose();}};window.addEventListener('keydown',key);return()=>window.removeEventListener('keydown',key);},[dirty,discardOpen,problem,requestClose,save]); const copy=async()=>{await navigator.clipboard.writeText(code);setCopied(true);window.setTimeout(()=>setCopied(false),1500);}; const download=()=>downloadBytes(new TextEncoder().encode(code),basename(filePath),'application/xml'); const pointerDown=(event:ReactPointerEvent)=>{if(maximized||event.button!==0||(event.target as HTMLElement).closest('button'))return;drag.current={x:event.clientX,y:event.clientY,left:position.x,top:position.y};event.currentTarget.setPointerCapture(event.pointerId);}; const pointerMove=(event:ReactPointerEvent)=>{if(!drag.current)return;setPosition({x:Math.min(window.innerWidth-120,Math.max(-780,drag.current.left+event.clientX-drag.current.x)),y:Math.min(window.innerHeight-48,Math.max(0,drag.current.top+event.clientY-drag.current.y))});}; if(!open)return null; - return
-
{drag.current=null;}} onDoubleClick={()=>setMaximized(value=>!value)}>
转换后的 MJCF
{filePath}
{contentSize(code)}缓存文件 · 可编辑{dirty&&已修改}setMaximized(value=>!value)}>{maximized?:}
+ return <>
+
{drag.current=null;}} onDoubleClick={()=>setMaximized(value=>!value)}>
转换后的 MJCF
{filePath}
{contentSize(code)}缓存文件 · 可编辑{dirty&&已修改}setMaximized(value=>!value)}>{maximized?:}
setCode(value??'')} options={{automaticLayout:true,minimap:{enabled:false},fontFamily:"'JetBrains Mono','Fira Code',ui-monospace,monospace",fontSize:13,fontLigatures:true,scrollBeyondLastLine:false,wordWrap:'off',stickyScroll:{enabled:false},tabSize:2,formatOnPaste:true,formatOnType:true,lineNumbersMinChars:4,padding:{top:12,bottom:14},renderLineHighlight:'all'}}/>
{problem?`XML 错误:${problem}`:'✓ XML 结构正常'}
Ctrl+S 保存并重新载入MJCF / XML
-
; +
setDiscardOpen(false)}>

当前 MJCF 源码包含未保存的修改。关闭后,这些修改将无法恢复。

; } diff --git a/wasm/web_platform/src/app/components/StatusBar.tsx b/wasm/web_platform/src/app/components/StatusBar.tsx index 6ab8cfd4..f97658bb 100644 --- a/wasm/web_platform/src/app/components/StatusBar.tsx +++ b/wasm/web_platform/src/app/components/StatusBar.tsx @@ -3,5 +3,5 @@ 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 ;} -export function StatusBar({time,fps,stepMs,memoryMb,loaded,overBudget}:StatusBarProps){return
时间 {time?.toFixed(3)??'—'} s内存 {memoryMb===undefined?'—':`${memoryMb.toFixed(1)} MiB`}WASM {loaded?'已加载':'未加载'}{overBudget&&主线程超出步进预算,已限制追帧}Space 播放/暂停 · R 重置 · 1/2/3 模式
;} +function Item({icon:Icon,children,className=''}:{icon:typeof Clock3;children:ReactNode;className?:string}){return ;} +export function StatusBar({time,fps,stepMs,memoryMb,loaded,overBudget}:StatusBarProps){return
时间 {time?.toFixed(3)??'—'} s内存 {memoryMb===undefined?'—':`${memoryMb.toFixed(1)} MiB`}WASM {loaded?'已加载':'未加载'}{overBudget&&主线程超出步进预算,已限制追帧}Space 播放/暂停 · R 重置 · 1/2/3 模式
;} diff --git a/wasm/web_platform/src/app/components/ViewerDisplayPopover.tsx b/wasm/web_platform/src/app/components/ViewerDisplayPopover.tsx new file mode 100644 index 00000000..3a1a3345 --- /dev/null +++ b/wasm/web_platform/src/app/components/ViewerDisplayPopover.tsx @@ -0,0 +1,30 @@ +import {Check,Eye,RotateCcw} from 'lucide-react'; +import {IconButton,Popover} from '../../components/ui'; +import {DEFAULT_VIEWER_DISPLAY_OPTIONS,type ViewerDisplayOptions} from '../../viewer/displayOptions'; + +type DisplayKey=keyof ViewerDisplayOptions; +interface DisplayItem {key:DisplayKey;label:string;description:string;color:string;} +const geometryItems:DisplayItem[]=[ + {key:'showVisual',label:'视觉模型',description:'显示模型的外观几何与材质',color:'bg-slate-400'}, + {key:'showCollision',label:'碰撞体',description:'以青色半透明方式叠加碰撞几何',color:'bg-cyan-400'}, +]; +const helperItems:DisplayItem[]=[ + {key:'showFrames',label:'坐标系',description:'显示每个刚体的 RGB 坐标轴',color:'bg-red-400'}, + {key:'showJointAxes',label:'关节轴',description:'显示转动与滑动关节的正轴方向',color:'bg-red-500'}, + {key:'showCenterOfMass',label:'质心',description:'显示各刚体的质量中心',color:'bg-yellow-400'}, + {key:'showInertia',label:'惯量',description:'显示由主惯量计算的等效惯量盒',color:'bg-cyan-300'}, +]; +const sceneItems:DisplayItem[]=[ + {key:'showGrid',label:'地面网格',description:'显示世界坐标系的参考网格',color:'bg-blue-400'}, +]; + +function DisplayRows({items,value,onChange}:{items:DisplayItem[];value:ViewerDisplayOptions;onChange:(next:ViewerDisplayOptions)=>void}){ + return
{items.map(item=>{const checked=value[item.key];return ;})}
; +} + +export function ViewerDisplayPopover({value,onChange}:{value:ViewerDisplayOptions;onChange:(next:ViewerDisplayOptions)=>void}){ + const customized=Object.keys(DEFAULT_VIEWER_DISPLAY_OPTIONS).some(key=>value[key as DisplayKey]!==DEFAULT_VIEWER_DISPLAY_OPTIONS[key as DisplayKey]); + return }> + {()=>

视图显示

控制模型和结构辅助标记

onChange({...DEFAULT_VIEWER_DISPLAY_OPTIONS})}>

几何

辅助标记

场景

} +
; +} diff --git a/wasm/web_platform/src/app/components/ViewerToolDock.tsx b/wasm/web_platform/src/app/components/ViewerToolDock.tsx index c137a104..d1e912dc 100644 --- a/wasm/web_platform/src/app/components/ViewerToolDock.tsx +++ b/wasm/web_platform/src/app/components/ViewerToolDock.tsx @@ -1,5 +1,7 @@ import {Crosshair,Hand,MousePointer2,RotateCcw} from 'lucide-react'; import type {InteractionMode} from '../../viewer/MuJoCoViewer'; +import type {ViewerDisplayOptions} from '../../viewer/displayOptions'; import {IconButton,ToolbarToggleGroup,type ToolbarItem} from '../../components/ui'; +import {ViewerDisplayPopover} from './ViewerDisplayPopover'; const tools:ToolbarItem[]=[{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
;} +export function ViewerToolDock({mode,display,onModeChange,onDisplayChange,onResetCamera}:{mode:InteractionMode;display:ViewerDisplayOptions;onModeChange:(mode:InteractionMode)=>void;onDisplayChange:(next:ViewerDisplayOptions)=>void;onResetCamera:()=>void}){return
;} diff --git a/wasm/web_platform/src/app/components/ViewportHUD.tsx b/wasm/web_platform/src/app/components/ViewportHUD.tsx index a00046cb..4a24b169 100644 --- a/wasm/web_platform/src/app/components/ViewportHUD.tsx +++ b/wasm/web_platform/src/app/components/ViewportHUD.tsx @@ -1,5 +1,6 @@ -import {CirclePause,CirclePlay,MousePointer2} from 'lucide-react'; +import {CirclePause,CirclePlay,Mouse,MousePointer2} from 'lucide-react'; import type {InteractionMode,ViewerSelection} from '../../viewer/MuJoCoViewer'; -import {Badge} from '../../components/ui'; +import {Badge,Kbd} from '../../components/ui'; const labels:Record={select:'选择',joint:'关节拖动',force:'外力施加'}; -export function ViewportHUD({paused,mode,selection,ready}:{paused:boolean;mode:InteractionMode;selection:ViewerSelection|null;ready:boolean}){if(!ready)return null;return
{paused?:}{paused?'已暂停':'仿真中'}{labels[mode]}{selection&&{selection.bodyName}}
;} +const primaryGestures:Record={select:'左键旋转',joint:'左键拖动关节',force:'左键拖动施力'}; +export function ViewportHUD({paused,mode,selection,ready}:{paused:boolean;mode:InteractionMode;selection:ViewerSelection|null;ready:boolean}){if(!ready)return null;return <>
{paused?:}{paused?'已暂停':'仿真中'}{labels[mode]}{selection&&{selection.bodyName}}
;} diff --git a/wasm/web_platform/src/app/components/WorkbenchHeader.tsx b/wasm/web_platform/src/app/components/WorkbenchHeader.tsx index 743739ec..2b56ad3d 100644 --- a/wasm/web_platform/src/app/components/WorkbenchHeader.tsx +++ b/wasm/web_platform/src/app/components/WorkbenchHeader.tsx @@ -2,4 +2,5 @@ import type {ChangeEvent,ReactNode} from 'react'; import {CircleHelp,Code2,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,hasProject,center,endActions,compactMenu,onFiles,onFolder,onOpenSource,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;hasProject?:boolean;center:ReactNode;endActions?:ReactNode;compactMenu?:ReactNode;onFiles:(event:ChangeEvent)=>void;onFolder:(event:ChangeEvent)=>void;onOpenSource?:()=>void;onTogglePause:()=>void;onStep:()=>void;onReset:()=>void;onSpeed:(value:number)=>void;onToggleLeft:()=>void;onToggleRight:()=>void;onToggleTheme:()=>void;onHelp:()=>void;onCommands:()=>void;onToggleFullscreen:()=>void}){return

MuJoCo Web 仿真平台

{center}
{endActions}{compactMenu}{fullscreen?:}{theme==='dark'?:}
;} +const fileActionLabelClass='hidden sm:inline'; +export function WorkbenchHeader({paused,ready,speed,theme,loading,leftOpen,rightOpen,fullscreen,hasProject,center,endActions,compactMenu,onFiles,onFolder,onOpenSource,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;hasProject?:boolean;center:ReactNode;endActions?:ReactNode;compactMenu?:ReactNode;onFiles:(event:ChangeEvent)=>void;onFolder:(event:ChangeEvent)=>void;onOpenSource?:()=>void;onTogglePause:()=>void;onStep:()=>void;onReset:()=>void;onSpeed:(value:number)=>void;onToggleLeft:()=>void;onToggleRight:()=>void;onToggleTheme:()=>void;onHelp:()=>void;onCommands:()=>void;onToggleFullscreen:()=>void}){return

MuJoCo Web 仿真平台

{center}
{endActions}{compactMenu}{fullscreen?:}{theme==='dark'?:}
;} diff --git a/wasm/web_platform/src/app/components/WorkspaceOverlays.tsx b/wasm/web_platform/src/app/components/WorkspaceOverlays.tsx index 1f4dd69c..7b4797f3 100644 --- a/wasm/web_platform/src/app/components/WorkspaceOverlays.tsx +++ b/wasm/web_platform/src/app/components/WorkspaceOverlays.tsx @@ -1,5 +1,6 @@ -import {Box,LoaderCircle,UploadCloud} from 'lucide-react'; +import {Box,FolderOpen,LoaderCircle,PlayCircle,Settings2,ShieldCheck,Upload,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
{compact?:}

拖放模型工程到此处

支持 MJCF/XML、URDF、文件夹和 ZIP

;} -export function WorkspaceOverlays({loading,hasSnapshot,progress}:{loading:boolean;hasSnapshot:boolean;progress?:ImportProgress}){return <>{!hasSnapshot&&!loading&&
}{loading&&
{progress?
:
}
};} +const workflow=[{label:'导入',detail:'URDF、MJCF 或工程包',icon:UploadCloud},{label:'检查与配置',detail:'结构、驱动器与传感器',icon:Settings2},{label:'运行与调试',detail:'控制、策略与物理状态',icon:PlayCircle}]; +export function EmptyWorkspace({compact=false}:{compact?:boolean}){if(compact)return

拖放模型工程到此处

支持 MJCF/XML、URDF、文件夹和 ZIP

;return

拖放模型工程到此处

支持 MJCF/XML、URDF、文件夹和 ZIP

    {workflow.map((item,index)=>
  1. 0{index+1}
    {item.detail}
  2. )}

;} +export function WorkspaceOverlays({loading,hasSnapshot,progress}:{loading:boolean;hasSnapshot:boolean;progress?:ImportProgress}){return <>{!hasSnapshot&&!loading&&
}{loading&&
{progress?
:
}
};} diff --git a/wasm/web_platform/src/components/ui/ResizablePanel.tsx b/wasm/web_platform/src/components/ui/ResizablePanel.tsx index 8d3043f0..49d0b6fb 100644 --- a/wasm/web_platform/src/components/ui/ResizablePanel.tsx +++ b/wasm/web_platform/src/components/ui/ResizablePanel.tsx @@ -5,4 +5,4 @@ function storedWidth(key:string,fallback:number,minWidth:number){try{const value 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)=>{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 ;} + return ;} diff --git a/wasm/web_platform/src/simulation/PhysicsAdapter.ts b/wasm/web_platform/src/simulation/PhysicsAdapter.ts index 8ab2a90f..7d6b24ba 100644 --- a/wasm/web_platform/src/simulation/PhysicsAdapter.ts +++ b/wasm/web_platform/src/simulation/PhysicsAdapter.ts @@ -78,7 +78,7 @@ export class MainThreadPhysicsAdapter implements PhysicsAdapter { } console.info('[MuJoCo] 编译模型',modelPath);const session=new SimulationSession(module,modelPath,warnings); if(entry?.format==='urdf'&&urdfMode==='native'){const offset=session.alignLowestPointToGround();warnings.push(`原生 URDF 已整体平移 ${offset.toFixed(4)} m,使最低点位于 z=0`);} - if(warnings.length)console.warn('[MuJoCo] URDF 兼容处理',warnings); + if(warnings.length)console.info('[MuJoCo] URDF 兼容处理',warnings); console.info('[MuJoCo] 模型编译完成');this.workspace=workspace;this.session=session;const snapshot=session.snapshot();console.info('[MuJoCo] 状态快照完成');return snapshot; }catch(error){workspace.dispose();throw new Error(`模型编译失败(${entryPath}):${error instanceof Error?error.message:String(error)}`,{cause:error});} } diff --git a/wasm/web_platform/src/simulation/SimulationSession.ts b/wasm/web_platform/src/simulation/SimulationSession.ts index 17923c07..829e3675 100644 --- a/wasm/web_platform/src/simulation/SimulationSession.ts +++ b/wasm/web_platform/src/simulation/SimulationSession.ts @@ -3,7 +3,7 @@ import {meshIdFromSceneDataId} from './geometry'; import {PythonControllerRuntime} from '../controller/PythonControllerRuntime'; import type {ControllerBindings,ControllerCommand,ControllerStatus} from '../controller/types'; import {Go2wPolicyBindings} from '../rl/runtime/Go2wPolicyBindings'; -import {OnnxPolicyRuntime} from '../rl/runtime/OnnxPolicyRuntime'; +import type {OnnxPolicyRuntime} from '../rl/runtime/OnnxPolicyRuntime'; import type {RLCommand,RLPolicyStatus} from '../rl/types'; export interface ActuatorParameters {gear:number;gain:number;kp:number;kv:number;ctrlLimited:boolean;ctrlMin:number;ctrlMax:number;forceLimited:boolean;forceMin:number;forceMax:number;} @@ -82,7 +82,8 @@ export class SimulationSession { async loadRLPolicy(model:Uint8Array,path:string):Promise{ const generation=++this.rlPolicyLoadGeneration; const bindings=new Go2wPolicyBindings(this.model,this.data,(id,value)=>this.setActuator(id,value)); - const runtime=await OnnxPolicyRuntime.load(model,path,bindings); + const {OnnxPolicyRuntime:Runtime}=await import('../rl/runtime/OnnxPolicyRuntime'); + const runtime=await Runtime.load(model,path,bindings); if(this.disposed||generation!==this.rlPolicyLoadGeneration){runtime.dispose();throw new Error('模型已切换,ONNX 策略加载已取消');} this.data.ctrl.fill(0);this.rlPolicy?.dispose();this.rlPolicy=runtime; return runtime.status(); diff --git a/wasm/web_platform/src/styles.css b/wasm/web_platform/src/styles.css index 74a1f1dc..5a8e8498 100644 --- a/wasm/web_platform/src/styles.css +++ b/wasm/web_platform/src/styles.css @@ -5,7 +5,7 @@ :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-text-primary:#122033;--ui-text-secondary:#3d4d61;--ui-text-tertiary:#5f6f82; --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; @@ -24,13 +24,14 @@ } @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} + :where(button,input,select,textarea,[tabindex]):focus-visible{outline:2px solid var(--ui-accent);outline-offset:2px} } @layer components{ .panel-scroll{scrollbar-color:var(--ui-scrollbar) transparent;scrollbar-width:thin} .control-slider{@apply w-full accent-accent} + .field{@apply rounded-md border border-border bg-input transition-colors hover:border-border-strong focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-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}} +.theme-light,.theme-dark{transition:background-color 180ms ease-out,color 180ms ease-out} +@media(prefers-reduced-motion:reduce){*,*::before,*::after{scroll-behavior:auto!important;animation-duration:.01ms!important;animation-iteration-count:1!important;transition-duration:.01ms!important}} diff --git a/wasm/web_platform/src/viewer/MuJoCoViewer.ts b/wasm/web_platform/src/viewer/MuJoCoViewer.ts index 10338c85..88424dd7 100644 --- a/wasm/web_platform/src/viewer/MuJoCoViewer.ts +++ b/wasm/web_platform/src/viewer/MuJoCoViewer.ts @@ -4,7 +4,10 @@ import type {MjvGeom, MjvOption, MjvCamera, MjvScene} from '@mujoco/mujoco'; import type {FrameResult, SimulationSession, SimulationSnapshot} from '../simulation/SimulationSession'; import {meshIdFromSceneDataId} from '../simulation/geometry'; import {OrientationGizmo} from './OrientationGizmo'; -import {cameraAlignedForce,closestRayAxisParameter,resolveHingeDragDelta,signedAngleAroundAxis} from './interactionMath'; +import {closestRayAxisParameter,forceFromScreenDrag,resolveHingeDragDelta,signedAngleAroundAxis} from './interactionMath'; +import {texturePixelsToRgba} from './texturePixels'; +import {DEFAULT_VIEWER_DISPLAY_OPTIONS,type ViewerDisplayOptions} from './displayOptions'; +import {ViewerVisualizationHelpers} from './ViewerVisualizationHelpers'; export type InteractionMode = 'select' | 'joint' | 'force'; export type ViewerTheme='light'|'dark'; @@ -18,14 +21,16 @@ class CapsuleGeometry extends THREE.BufferGeometry { export class MuJoCoViewer { readonly scene = new THREE.Scene(); readonly camera = new THREE.PerspectiveCamera(45,1,.01,2_000); - private readonly sensorCamera=new THREE.PerspectiveCamera(45,16/9,.01,2_000); + private readonly sensorCamera=new THREE.PerspectiveCamera(45,4/3,.01,2_000); readonly renderer: THREE.WebGLRenderer; readonly controls: OrbitControls; mode: InteractionMode = 'select'; forceScale = 50; - private showCollision=false; + private displayOptions:ViewerDisplayOptions={...DEFAULT_VIEWER_DISPLAY_OPTIONS}; + private modelHasVisuals=false; private showSensorCamera=true; private sensorCameraId=-1; + private modelExtent=2; private session: SimulationSession | null = null; private option: MjvOption | null = null; private mjCamera: MjvCamera | null = null; @@ -33,9 +38,10 @@ export class MuJoCoViewer { private frame = 0; private lastFpsAt=performance.now(); private fpsFrames=0; private lastSnapshotAt=0; private meshes: THREE.Mesh[]=[]; private geometries=new Map(); private textures=new Map(); private readonly sensorRotation=new THREE.Matrix4();private readonly renderSize=new THREE.Vector2(); - private raycaster=new THREE.Raycaster(); private pointer=new THREE.Vector2(); private selected:THREE.Mesh|null=null; private dragStart:THREE.Vector2|null=null; private dragJointId=-1; private dragJointType=-1;private dragJointValue=0;private dragHitDistance=0;private dragSlideParameter=0;private dragJointPivot=new THREE.Vector3();private dragJointAxisWorld=new THREE.Vector3();private dragJointStartWorld=new THREE.Vector3();private dragJointStartPlaneVector=new THREE.Vector3(); private arrow:THREE.ArrowHelper|null=null;private highlightedJointId=-1;private highlightedBodyId=-1;private jointMarker:THREE.Mesh|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 dragJointId=-1; private dragJointType=-1;private dragJointValue=0;private dragHitDistance=0;private dragSlideParameter=0;private dragJointPivot=new THREE.Vector3();private dragJointAxisWorld=new THREE.Vector3();private dragJointStartWorld=new THREE.Vector3();private dragJointStartPlaneVector=new THREE.Vector3();private dragForceOrigin=new THREE.Vector3();private dragForcePlane=new THREE.Plane(); 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 visualizationHelpers:ViewerVisualizationHelpers; private grid:THREE.GridHelper; private hemisphere:THREE.HemisphereLight; private themeTransition?:{started:number;fromBackground:THREE.Color;toBackground:THREE.Color;fromGround:THREE.Color;toGround:THREE.Color;}; @@ -43,52 +49,53 @@ export class MuJoCoViewer { 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.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.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.grid.visible=this.displayOptions.showGrid;this.scene.add(this.grid);this.visualizationHelpers=new ViewerVisualizationHelpers(this.scene);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);this.renderer.domElement.addEventListener('lostpointercapture',this.onPointerUp);window.addEventListener('pointerup',this.onPointerUp);window.addEventListener('pointercancel',this.onPointerUp);window.addEventListener('blur',this.onPointerUp); this.frame=requestAnimationFrame(this.animate); } - attach(session:SimulationSession|null):void {this.releaseModel(); this.session=session; if(!session)return; this.option=new session.module.MjvOption(); session.module.mjv_defaultOption(this.option);this.applyGeomVisibility(); this.mjCamera=new session.module.MjvCamera(); session.module.mjv_defaultCamera(this.mjCamera); this.mjScene=new session.module.MjvScene(session.model,32768);for(let id=0;id=0&&jointId=0&&jointId{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.setScissorTest(false);this.renderer.render(this.scene,this.camera);if(this.showSensorCamera&&this.updateSensorCamera())this.renderSensorCamera(); 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; /* 避免每个 RAF 都触发 Zustand/React 全树重渲染。 */ if(snapshot||fps>0)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.visualizationHelpers.update();} this.renderer.setScissorTest(false);this.renderer.render(this.scene,this.camera);if(this.showSensorCamera&&this.updateSensorCamera())this.renderSensorCamera(); 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; /* 避免每个 RAF 都触发 Zustand/React 全树重渲染。 */ if(snapshot||fps>0)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 updateSensorCamera():boolean {if(!this.session||this.sensorCameraId<0||this.sensorCameraId>=this.session.model.ncam)return false;const id=this.sensorCameraId,p=id*3,m=id*9,data=this.session.data,model=this.session.model;this.sensorCamera.position.set(Number(data.cam_xpos[p]),Number(data.cam_xpos[p+1]),Number(data.cam_xpos[p+2]));this.sensorRotation.set(Number(data.cam_xmat[m]),Number(data.cam_xmat[m+1]),Number(data.cam_xmat[m+2]),0,Number(data.cam_xmat[m+3]),Number(data.cam_xmat[m+4]),Number(data.cam_xmat[m+5]),0,Number(data.cam_xmat[m+6]),Number(data.cam_xmat[m+7]),Number(data.cam_xmat[m+8]),0,0,0,0,1);this.sensorCamera.quaternion.setFromRotationMatrix(this.sensorRotation);this.sensorCamera.fov=Number(model.cam_fovy[id])||45;const extent=this.session.geometryBounds().extent;this.sensorCamera.near=Math.max(.001,extent/1000);this.sensorCamera.far=Math.max(100,extent*100);this.sensorCamera.updateProjectionMatrix();return true;} - private renderSensorCamera():void {const size=this.renderer.getSize(this.renderSize),width=Math.max(120,Math.min(320,size.x*.32)),height=width*9/16,margin=16;this.sensorCamera.aspect=width/height;this.sensorCamera.updateProjectionMatrix();this.renderer.setViewport(margin,margin,width,height);this.renderer.setScissor(margin,margin,width,height);this.renderer.setScissorTest(true);this.renderer.render(this.scene,this.sensorCamera);this.renderer.setScissorTest(false);this.renderer.setViewport(0,0,size.x,size.y);} + private updateSensorCamera():boolean {if(!this.session||this.sensorCameraId<0||this.sensorCameraId>=this.session.model.ncam)return false;const id=this.sensorCameraId,p=id*3,m=id*9,data=this.session.data,model=this.session.model;this.sensorCamera.position.set(Number(data.cam_xpos[p]),Number(data.cam_xpos[p+1]),Number(data.cam_xpos[p+2]));this.sensorRotation.set(Number(data.cam_xmat[m]),Number(data.cam_xmat[m+1]),Number(data.cam_xmat[m+2]),0,Number(data.cam_xmat[m+3]),Number(data.cam_xmat[m+4]),Number(data.cam_xmat[m+5]),0,Number(data.cam_xmat[m+6]),Number(data.cam_xmat[m+7]),Number(data.cam_xmat[m+8]),0,0,0,0,1);this.sensorCamera.quaternion.setFromRotationMatrix(this.sensorRotation);this.sensorCamera.fov=Number(model.cam_fovy[id])||45;const extent=this.modelExtent;this.sensorCamera.near=Math.max(.001,extent/1000);this.sensorCamera.far=Math.max(100,extent*100);this.sensorCamera.updateProjectionMatrix();return true;} + private renderSensorCamera():void {const size=this.renderer.getSize(this.renderSize),width=Math.max(120,Math.min(320,size.x*.32)),height=width*3/4,margin=16;this.sensorCamera.aspect=width/height;this.sensorCamera.updateProjectionMatrix();this.renderer.setViewport(margin,margin,width,height);this.renderer.setScissor(margin,margin,width,height);this.renderer.setScissorTest(true);this.renderer.render(this.scene,this.sensorCamera);this.renderer.setScissorTest(false);this.renderer.setViewport(0,0,size.x,size.y);} 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 {const count=geoms.size();for(let i=0;i=0)return this.meshGeometry(meshIdFromSceneDataId(g.dataid));return new THREE.BufferGeometry();} private meshGeometry(id:number):THREE.BufferGeometry {const m=this.session!.model;const va=Number(m.mesh_vertadr[id]),vn=Number(m.mesh_vertnum[id]),fa=Number(m.mesh_faceadr[id]),fn=Number(m.mesh_facenum[id]);const positions=new Float32Array(vn*3);for(let i=0;i=0){const uv=new Float32Array(tn*2);for(let i=0;i=0,sharedKey=isMesh?`mesh:${meshIdFromSceneDataId(g.dataid)}`:undefined;let geometry=sharedKey?this.geometries.get(sharedKey):undefined;if(!geometry){geometry=this.primitive(g);if(sharedKey)this.geometries.set(sharedKey,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;mesh.userData.ownsGeometry=!sharedKey;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;this.applyMeshHighlight(mesh);} + private updateMesh(mesh:THREE.Mesh,g:MjvGeom):void {const mat=mesh.material as THREE.MeshStandardMaterial,geomId=g.objtype===this.session!.module.mjtObj.mjOBJ_GEOM.value?g.objid:-1,bodyId=geomId>=0?Number(this.session!.model.geom_bodyid[geomId]):-1,isCollision=this.modelHasVisuals&&geomId>=0&&Number(this.session!.model.geom_group[geomId])===0;if(isCollision){const worldCollision=bodyId===0;mat.color.setHex(worldCollision?0x64748b:0x22d3ee);mat.opacity=worldCollision ? .12 : .28;mat.transparent=true;mat.depthTest=worldCollision;mat.depthWrite=false;mat.roughness=.85;mat.metalness=0;mesh.renderOrder=worldCollision?0:60;}else{mat.color.setRGB(g.rgba[0],g.rgba[1],g.rgba[2]);mat.opacity=g.rgba[3];mat.transparent=g.rgba[3]<1;mat.depthTest=true;mat.depthWrite=true;mat.roughness=Math.max(.05,1-g.shininess);mat.metalness=g.reflectance;mesh.renderOrder=0;}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;mesh.userData.geomId=geomId;mesh.userData.bodyId=bodyId;mesh.userData.geomType=g.type;mesh.userData.isCollision=isCollision;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.dragJointType=joint.type;this.dragJointValue=joint.value;this.dragHitDistance=hit.distance;const offset=joint.id*3;this.dragJointPivot.set(Number(this.session!.data.xanchor[offset]),Number(this.session!.data.xanchor[offset+1]),Number(this.session!.data.xanchor[offset+2]));this.dragJointAxisWorld.set(Number(this.session!.data.xaxis[offset]),Number(this.session!.data.xaxis[offset+1]),Number(this.session!.data.xaxis[offset+2])).normalize();this.raycaster.ray.at(hit.distance,this.dragJointStartWorld);const plane=new THREE.Plane().setFromNormalAndCoplanarPoint(this.dragJointAxisWorld,this.dragJointPivot),projected=plane.projectPoint(this.dragJointStartWorld,new THREE.Vector3());this.dragJointStartPlaneVector.copy(projected).sub(this.dragJointPivot);this.dragSlideParameter=closestRayAxisParameter(this.raycaster.ray,this.dragJointPivot,this.dragJointAxisWorld);this.renderer.domElement.setPointerCapture(event.pointerId);}}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);this.raycaster.setFromCamera(this.pointer,this.camera);const delta=this.pointer.clone().sub(this.dragStart),rect=this.renderer.domElement.getBoundingClientRect(),aspect=rect.width/Math.max(1,rect.height),screenDelta=new THREE.Vector2(delta.x*aspect,delta.y);if(this.mode==='joint'&&this.dragJointId>=0){let value=this.dragJointValue,currentPlaneVector:THREE.Vector3|undefined,currentSlideParameter=Number.NaN;if(this.dragJointType===2){currentSlideParameter=closestRayAxisParameter(this.raycaster.ray,this.dragJointPivot,this.dragJointAxisWorld);if(Number.isFinite(currentSlideParameter)&&Number.isFinite(this.dragSlideParameter))value+=currentSlideParameter-this.dragSlideParameter;}else if(this.dragJointType===3){const currentWorld=this.raycaster.ray.at(this.dragHitDistance,new THREE.Vector3()),plane=new THREE.Plane().setFromNormalAndCoplanarPoint(this.dragJointAxisWorld,this.dragJointPivot);currentPlaneVector=plane.projectPoint(currentWorld,new THREE.Vector3()).sub(this.dragJointPivot);const worldDelta=signedAngleAroundAxis(this.dragJointStartPlaneVector,currentPlaneVector,this.dragJointAxisWorld),cameraForward=this.camera.getWorldDirection(new THREE.Vector3()),planeFacing=Math.abs(this.raycaster.ray.direction.dot(this.dragJointAxisWorld));const tangentWorld=cameraForward.clone().cross(this.dragJointAxisWorld).normalize(),a=this.dragJointPivot.clone().project(this.camera),b=this.dragJointPivot.clone().add(tangentWorld).project(this.camera),tangentScreen=new THREE.Vector2((b.x-a.x)*aspect,b.y-a.y);const tangentDelta=tangentScreen.lengthSq()>1e-10?screenDelta.dot(tangentScreen.normalize())*Math.PI:0;value+=resolveHingeDragDelta(worldDelta,tangentDelta,planeFacing);}if(this.session.setJointPosition(this.dragJointId,value)){this.dragJointValue=value;this.dragStart.copy(this.pointer);if(currentPlaneVector&¤tPlaneVector.lengthSq()>1e-12)this.dragJointStartPlaneVector.copy(currentPlaneVector);if(Number.isFinite(currentSlideParameter))this.dragSlideParameter=currentSlideParameter;}}else if(this.mode==='force'&&this.selected){const bodyId=Number(this.selected.userData.bodyId),right=new THREE.Vector3(1,0,0).applyQuaternion(this.camera.quaternion),vector=cameraAlignedForce(screenDelta.x,screenDelta.y,right,this.forceScale),force:[number,number,number]=[vector.x,vector.y,vector.z];this.session.setExternalForce(bodyId,force);this.updateArrow(force);}}; + private onPointerDown=(event:PointerEvent):void=>{if(event.button!==0)return;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.dragJointType=joint.type;this.dragJointValue=joint.value;this.dragHitDistance=hit.distance;const offset=joint.id*3;this.dragJointPivot.set(Number(this.session!.data.xanchor[offset]),Number(this.session!.data.xanchor[offset+1]),Number(this.session!.data.xanchor[offset+2]));this.dragJointAxisWorld.set(Number(this.session!.data.xaxis[offset]),Number(this.session!.data.xaxis[offset+1]),Number(this.session!.data.xaxis[offset+2])).normalize();this.raycaster.ray.at(hit.distance,this.dragJointStartWorld);const plane=new THREE.Plane().setFromNormalAndCoplanarPoint(this.dragJointAxisWorld,this.dragJointPivot),projected=plane.projectPoint(this.dragJointStartWorld,new THREE.Vector3());this.dragJointStartPlaneVector.copy(projected).sub(this.dragJointPivot);this.dragSlideParameter=closestRayAxisParameter(this.raycaster.ray,this.dragJointPivot,this.dragJointAxisWorld);this.renderer.domElement.setPointerCapture(event.pointerId);}}else if(this.mode==='force'&&bodyId>0){this.dragStart=this.pointer.clone();this.dragHitDistance=hit.distance;this.dragForceOrigin.copy(hit.point);this.dragForcePlane.setFromNormalAndCoplanarPoint(this.camera.getWorldDirection(new THREE.Vector3()),hit.point);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);this.raycaster.setFromCamera(this.pointer,this.camera);const delta=this.pointer.clone().sub(this.dragStart),rect=this.renderer.domElement.getBoundingClientRect(),aspect=rect.width/Math.max(1,rect.height),screenDelta=new THREE.Vector2(delta.x*aspect,delta.y);if(this.mode==='joint'&&this.dragJointId>=0){let value=this.dragJointValue,currentPlaneVector:THREE.Vector3|undefined,currentSlideParameter=Number.NaN;if(this.dragJointType===2){currentSlideParameter=closestRayAxisParameter(this.raycaster.ray,this.dragJointPivot,this.dragJointAxisWorld);if(Number.isFinite(currentSlideParameter)&&Number.isFinite(this.dragSlideParameter))value+=currentSlideParameter-this.dragSlideParameter;}else if(this.dragJointType===3){const currentWorld=this.raycaster.ray.at(this.dragHitDistance,new THREE.Vector3()),plane=new THREE.Plane().setFromNormalAndCoplanarPoint(this.dragJointAxisWorld,this.dragJointPivot);currentPlaneVector=plane.projectPoint(currentWorld,new THREE.Vector3()).sub(this.dragJointPivot);const worldDelta=signedAngleAroundAxis(this.dragJointStartPlaneVector,currentPlaneVector,this.dragJointAxisWorld),cameraForward=this.camera.getWorldDirection(new THREE.Vector3()),planeFacing=Math.abs(this.raycaster.ray.direction.dot(this.dragJointAxisWorld));const tangentWorld=cameraForward.clone().cross(this.dragJointAxisWorld).normalize(),a=this.dragJointPivot.clone().project(this.camera),b=this.dragJointPivot.clone().add(tangentWorld).project(this.camera),tangentScreen=new THREE.Vector2((b.x-a.x)*aspect,b.y-a.y);const tangentDelta=tangentScreen.lengthSq()>1e-10?screenDelta.dot(tangentScreen.normalize())*Math.PI:0;value+=resolveHingeDragDelta(worldDelta,tangentDelta,planeFacing);}if(this.session.setJointPosition(this.dragJointId,value)){this.dragJointValue=value;this.dragStart.copy(this.pointer);if(currentPlaneVector&¤tPlaneVector.lengthSq()>1e-12)this.dragJointStartPlaneVector.copy(currentPlaneVector);if(Number.isFinite(currentSlideParameter))this.dragSlideParameter=currentSlideParameter;}}else if(this.mode==='force'&&this.selected){const endpoint=this.raycaster.ray.intersectPlane(this.dragForcePlane,new THREE.Vector3());if(!endpoint)return;const dragVector=endpoint.sub(this.dragForceOrigin),vector=forceFromScreenDrag(dragVector,screenDelta.length(),this.forceScale),bodyId=Number(this.selected.userData.bodyId),force:[number,number,number]=[vector.x,vector.y,vector.z];this.session.setExternalForce(bodyId,force);this.updateArrow(dragVector);}}; private onPointerUp=():void=>{this.stopDrag();}; private stopDrag():void {this.dragStart=null;this.dragJointId=-1;this.dragJointType=-1;this.dragHitDistance=0;this.dragSlideParameter=0;this.session?.clearExternalForce();if(this.arrow){this.scene.remove(this.arrow);this.arrow.dispose();this.arrow=null;}} 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 showArrow(origin:THREE.Vector3):void {this.arrow=new THREE.ArrowHelper(new THREE.Vector3(1,0,0),origin,0.01,0xf97316);this.arrow.visible=false;this.scene.add(this.arrow);} + private updateArrow(dragVector:THREE.Vector3):void {if(!this.arrow)return;const length=dragVector.length();this.arrow.visible=length>1e-6;if(!this.arrow.visible)return;const maxHeadLength=Math.max(.015,this.modelExtent*.06),headLength=Math.min(maxHeadLength,length*.25),headWidth=Math.min(maxHeadLength*.55,length*.14);this.arrow.setDirection(dragVector.clone().normalize());this.arrow.setLength(length,headLength,headWidth);} private disposeMesh(mesh:THREE.Mesh):void {(mesh.material as THREE.Material).dispose();if(mesh.userData.ownsGeometry)mesh.geometry.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.sensorCameraId=-1;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);this.renderer.domElement.removeEventListener('lostpointercapture',this.onPointerUp);window.removeEventListener('pointerup',this.onPointerUp);window.removeEventListener('pointercancel',this.onPointerUp);window.removeEventListener('blur',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();} + 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.sensorCameraId=-1;this.modelExtent=2;this.modelHasVisuals=false;this.visualizationHelpers.attach(null,this.modelExtent);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);this.renderer.domElement.removeEventListener('lostpointercapture',this.onPointerUp);window.removeEventListener('pointerup',this.onPointerUp);window.removeEventListener('pointercancel',this.onPointerUp);window.removeEventListener('blur',this.onPointerUp);this.controls.dispose();this.orientationGizmo.dispose();this.visualizationHelpers.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();} } diff --git a/wasm/web_platform/src/viewer/ViewerVisualizationHelpers.ts b/wasm/web_platform/src/viewer/ViewerVisualizationHelpers.ts new file mode 100644 index 00000000..f8aa5c87 --- /dev/null +++ b/wasm/web_platform/src/viewer/ViewerVisualizationHelpers.ts @@ -0,0 +1,83 @@ +import * as THREE from 'three'; +import type {SimulationSession} from '../simulation/SimulationSession'; +import {DEFAULT_VIEWER_DISPLAY_OPTIONS,type ViewerDisplayOptions} from './displayOptions'; +import {inertiaBoxDimensions} from './visualizationMath'; + +/** 独立于 MuJoCo 场景几何的机器人结构辅助可视化。 */ +export class ViewerVisualizationHelpers { + private readonly root=new THREE.Group(); + private readonly frames=new THREE.Group(); + private readonly jointAxes=new THREE.Group(); + private readonly centersOfMass=new THREE.Group(); + private readonly inertiaBoxes=new THREE.Group(); + private session:SimulationSession|null=null; + private options:ViewerDisplayOptions={...DEFAULT_VIEWER_DISPLAY_OPTIONS}; + private frameHelpers:THREE.AxesHelper[]=[]; + private jointHelpers=new Map(); + private comHelpers=new Map(); + private inertiaHelpers=new Map(); + private readonly jointAxisVector=new THREE.Vector3(); + private readonly inertiaRotation=new THREE.Matrix4(); + + constructor(private readonly scene:THREE.Scene){ + this.root.name='viewer-visualization-helpers'; + this.frames.name='body-coordinate-frames'; + this.jointAxes.name='joint-axes'; + this.centersOfMass.name='centers-of-mass'; + this.inertiaBoxes.name='inertia-boxes'; + this.root.add(this.frames,this.jointAxes,this.centersOfMass,this.inertiaBoxes); + scene.add(this.root); + this.applyVisibility(); + } + + attach(session:SimulationSession|null,modelExtent:number):void { + this.clear(); + this.session=session; + if(!session)return; + const extent=Math.max(modelExtent,.01),frameSize=Math.max(.04,extent*.1),jointLength=Math.max(.06,extent*.14),comRadius=Math.max(.006,extent*.012); + for(let bodyId=0;bodyId0))continue; + const com=new THREE.Mesh(new THREE.SphereGeometry(comRadius,16,12),new THREE.MeshBasicMaterial({color:0xfacc15,depthTest:false,transparent:true,opacity:.95}));com.name=`center-of-mass-${bodyId}`;com.renderOrder=92;this.centersOfMass.add(com);this.comHelpers.set(bodyId,com); + const offset=bodyId*3,dimensions=inertiaBoxDimensions(Number(session.model.body_mass[bodyId]),[Number(session.model.body_inertia[offset]),Number(session.model.body_inertia[offset+1]),Number(session.model.body_inertia[offset+2])]); + if(dimensions){const minimum=Math.max(.004,extent*.004),maximum=Math.max(.05,extent*1.5),size=new THREE.Vector3(...dimensions).clampScalar(minimum,maximum),group=this.createInertiaBox(size);group.name=`inertia-box-${bodyId}`;this.inertiaBoxes.add(group);this.inertiaHelpers.set(bodyId,group);} + } + for(let jointId=0;jointId1e-12)arrow.setDirection(axis.normalize());arrow.position.set(Number(data.xanchor[offset]),Number(data.xanchor[offset+1]),Number(data.xanchor[offset+2]));} + if(this.centersOfMass.visible)for(const [bodyId,com] of this.comHelpers){const offset=bodyId*3;com.position.set(Number(data.xipos[offset]),Number(data.xipos[offset+1]),Number(data.xipos[offset+2]));} + if(this.inertiaBoxes.visible)for(const [bodyId,box] of this.inertiaHelpers){const positionOffset=bodyId*3,matrixOffset=bodyId*9,rotation=this.inertiaRotation.set(Number(data.ximat[matrixOffset]),Number(data.ximat[matrixOffset+1]),Number(data.ximat[matrixOffset+2]),0,Number(data.ximat[matrixOffset+3]),Number(data.ximat[matrixOffset+4]),Number(data.ximat[matrixOffset+5]),0,Number(data.ximat[matrixOffset+6]),Number(data.ximat[matrixOffset+7]),Number(data.ximat[matrixOffset+8]),0,0,0,0,1);box.position.set(Number(data.xipos[positionOffset]),Number(data.xipos[positionOffset+1]),Number(data.xipos[positionOffset+2]));box.quaternion.setFromRotationMatrix(rotation);} + } + + dispose():void {this.clear();this.scene.remove(this.root);} + + private createInertiaBox(size:THREE.Vector3):THREE.Group { + const group=new THREE.Group(),geometry=new THREE.BoxGeometry(size.x,size.y,size.z),fill=new THREE.MeshBasicMaterial({color:0x22d3ee,transparent:true,opacity:.18,depthWrite:false}),edgeMaterial=new THREE.LineBasicMaterial({color:0x22d3ee,transparent:true,opacity:.8,depthWrite:false}); + const mesh=new THREE.Mesh(geometry,fill),edges=new THREE.LineSegments(new THREE.EdgesGeometry(geometry),edgeMaterial);mesh.renderOrder=70;edges.renderOrder=71;group.add(mesh,edges);return group; + } + + private setWorldMatrix(object:THREE.Object3D,rotations:ArrayLike,positions:ArrayLike,id:number):void { + const m=id*9,p=id*3;object.matrixAutoUpdate=false;object.matrix.set(Number(rotations[m]),Number(rotations[m+1]),Number(rotations[m+2]),Number(positions[p]),Number(rotations[m+3]),Number(rotations[m+4]),Number(rotations[m+5]),Number(positions[p+1]),Number(rotations[m+6]),Number(rotations[m+7]),Number(rotations[m+8]),Number(positions[p+2]),0,0,0,1);object.matrixWorldNeedsUpdate=true; + } + + private applyVisibility():void {this.frames.visible=this.options.showFrames;this.jointAxes.visible=this.options.showJointAxes;this.centersOfMass.visible=this.options.showCenterOfMass;this.inertiaBoxes.visible=this.options.showInertia;} + + private clear():void { + const geometries=new Set(),materials=new Set(); + for(const group of [this.frames,this.jointAxes,this.centersOfMass,this.inertiaBoxes]){group.traverse(object=>{const candidate=object as THREE.Mesh|THREE.LineSegments;if(candidate.geometry)geometries.add(candidate.geometry);if(candidate.material){const values=Array.isArray(candidate.material)?candidate.material:[candidate.material];for(const material of values)materials.add(material);}});group.clear();} + for(const geometry of geometries)geometry.dispose();for(const material of materials)material.dispose(); + this.frameHelpers=[];this.jointHelpers.clear();this.comHelpers.clear();this.inertiaHelpers.clear();this.session=null; + } +} diff --git a/wasm/web_platform/src/viewer/displayOptions.ts b/wasm/web_platform/src/viewer/displayOptions.ts new file mode 100644 index 00000000..cf80edce --- /dev/null +++ b/wasm/web_platform/src/viewer/displayOptions.ts @@ -0,0 +1,19 @@ +export interface ViewerDisplayOptions { + showVisual:boolean; + showCollision:boolean; + showFrames:boolean; + showJointAxes:boolean; + showCenterOfMass:boolean; + showInertia:boolean; + showGrid:boolean; +} + +export const DEFAULT_VIEWER_DISPLAY_OPTIONS:ViewerDisplayOptions={ + showVisual:true, + showCollision:false, + showFrames:false, + showJointAxes:false, + showCenterOfMass:false, + showInertia:false, + showGrid:true, +}; diff --git a/wasm/web_platform/src/viewer/interactionMath.test.ts b/wasm/web_platform/src/viewer/interactionMath.test.ts index eaf7255b..6f599a93 100644 --- a/wasm/web_platform/src/viewer/interactionMath.test.ts +++ b/wasm/web_platform/src/viewer/interactionMath.test.ts @@ -1,5 +1,5 @@ import * as THREE from 'three'; -import {cameraAlignedForce,closestRayAxisParameter,resolveHingeDragDelta,signedAngleAroundAxis} from './interactionMath'; +import {closestRayAxisParameter,forceFromScreenDrag,resolveHingeDragDelta,signedAngleAroundAxis} from './interactionMath'; describe('interactionMath',()=>{ it('按右手定则计算绕关节轴的旋转方向',()=>{ @@ -20,8 +20,12 @@ describe('interactionMath',()=>{ expect(closestRayAxisParameter(new THREE.Ray(new THREE.Vector3(),new THREE.Vector3(1,0,0)),axisOrigin,axis)).toBeNaN(); }); - it('外力的右拖和上拖分别映射到相机右方与世界上方',()=>{ - expect(cameraAlignedForce(.5,.25,new THREE.Vector3(0,-1,.3),100).toArray()).toEqual([0,-50,25]); - expect(cameraAlignedForce(.5,0,new THREE.Vector3(-1,0,0),100).x).toBe(-50); + it('外力与屏幕拖动箭头同向,并由屏幕距离决定大小',()=>{ + const force=forceFromScreenDrag(new THREE.Vector3(3,4,0),.5,100); + expect(force.length()).toBeCloseTo(50); + expect(force.x/force.length()).toBeCloseTo(.6); + expect(force.y/force.length()).toBeCloseTo(.8); + expect(force.z).toBe(0); + expect(forceFromScreenDrag(new THREE.Vector3(),1,100).length()).toBe(0); }); }); diff --git a/wasm/web_platform/src/viewer/interactionMath.ts b/wasm/web_platform/src/viewer/interactionMath.ts index c13ad5ae..35333271 100644 --- a/wasm/web_platform/src/viewer/interactionMath.ts +++ b/wasm/web_platform/src/viewer/interactionMath.ts @@ -22,8 +22,8 @@ export function closestRayAxisParameter(ray:THREE.Ray,axisOrigin:THREE.Vector3,a const value=(axis.dot(offset)-dot*direction.dot(offset))/denominator;return Number.isFinite(value)?value:Number.NaN; } -/** MuJoCo MOVE_V 风格:水平跟随相机右方向,垂直始终对应世界 +Z。 */ -export function cameraAlignedForce(dx:number,dy:number,cameraRight:THREE.Vector3,scale:number):THREE.Vector3 { - const right=cameraRight.clone();right.z=0;if(right.lengthSq()<=1e-12)right.set(1,0,0);else right.normalize(); - return right.multiplyScalar(dx*scale).addScaledVector(new THREE.Vector3(0,0,1),dy*scale); +/** 将屏幕拖动映射为与可视箭头同向的外力;screenDistance 使用视口归一化距离。 */ +export function forceFromScreenDrag(dragVector:THREE.Vector3,screenDistance:number,scale:number):THREE.Vector3 { + if(dragVector.lengthSq()<=1e-12||!Number.isFinite(screenDistance)||!Number.isFinite(scale))return new THREE.Vector3(); + return dragVector.clone().normalize().multiplyScalar(Math.max(0,screenDistance)*Math.max(0,scale)); } diff --git a/wasm/web_platform/src/viewer/texturePixels.test.ts b/wasm/web_platform/src/viewer/texturePixels.test.ts new file mode 100644 index 00000000..dd2322e4 --- /dev/null +++ b/wasm/web_platform/src/viewer/texturePixels.test.ts @@ -0,0 +1,17 @@ +import {texturePixelsToRgba} from './texturePixels'; + +describe('texturePixelsToRgba',()=>{ + it('将 RGB 像素补齐为 WebGL 兼容的 RGBA',()=>{ + expect(Array.from(texturePixelsToRgba(new Uint8Array([1,2,3,4,5,6]),2,3))).toEqual([1,2,3,255,4,5,6,255]); + }); + + it('保留灰度透明度与 RGBA 数据',()=>{ + expect(Array.from(texturePixelsToRgba(new Uint8Array([12,34]),1,2))).toEqual([12,12,12,34]); + expect(Array.from(texturePixelsToRgba(new Uint8Array([1,2,3,4]),1,4))).toEqual([1,2,3,4]); + }); + + it('拒绝不完整数据和未知通道数',()=>{ + expect(()=>texturePixelsToRgba(new Uint8Array([1,2]),1,3)).toThrow('纹理像素数据不完整'); + expect(()=>texturePixelsToRgba(new Uint8Array(),0,5)).toThrow('不支持 5 通道纹理'); + }); +}); diff --git a/wasm/web_platform/src/viewer/texturePixels.ts b/wasm/web_platform/src/viewer/texturePixels.ts new file mode 100644 index 00000000..91219157 --- /dev/null +++ b/wasm/web_platform/src/viewer/texturePixels.ts @@ -0,0 +1,14 @@ +export function texturePixelsToRgba(source:Uint8Array,pixelCount:number,channels:number):Uint8Array{ + if(!Number.isInteger(pixelCount)||pixelCount<0)throw new Error('纹理像素数量无效'); + if(!Number.isInteger(channels)||channels<1||channels>4)throw new Error(`不支持 ${channels} 通道纹理`); + if(source.length{ + it('从主惯量恢复等效实心盒尺寸',()=>{ + const mass=12,width=2,height=4,depth=6; + const inertia:[number,number,number]=[ + mass*(height**2+depth**2)/12, + mass*(width**2+depth**2)/12, + mass*(width**2+height**2)/12, + ]; + const dimensions=inertiaBoxDimensions(mass,inertia); + expect(dimensions?.[0]).toBeCloseTo(width); + expect(dimensions?.[1]).toBeCloseTo(height); + expect(dimensions?.[2]).toBeCloseTo(depth); + }); + + it('拒绝无质量或不构成实体盒的惯量',()=>{ + expect(inertiaBoxDimensions(0,[1,1,1])).toBeNull(); + expect(inertiaBoxDimensions(1,[10,1,1])).toBeNull(); + }); +}); diff --git a/wasm/web_platform/src/viewer/visualizationMath.ts b/wasm/web_platform/src/viewer/visualizationMath.ts new file mode 100644 index 00000000..5cd97286 --- /dev/null +++ b/wasm/web_platform/src/viewer/visualizationMath.ts @@ -0,0 +1,16 @@ +export type PrincipalInertia=readonly [number,number,number]; +export type BoxDimensions=readonly [number,number,number]; + +/** 根据实体质量和主惯量,求具有相同惯量的等效实心盒尺寸。 */ +export function inertiaBoxDimensions(mass:number,inertia:PrincipalInertia):BoxDimensions|null { + const [ix,iy,iz]=inertia; + if(!(mass>0)||![ix,iy,iz].every(value=>Number.isFinite(value)&&value>=0))return null; + const factor=6/mass; + const squared:[number,number,number]=[ + factor*(iy+iz-ix), + factor*(ix+iz-iy), + factor*(ix+iy-iz), + ]; + if(squared.some(value=>value<=0||!Number.isFinite(value)))return null; + return [Math.sqrt(squared[0]),Math.sqrt(squared[1]),Math.sqrt(squared[2])]; +}