feat(web-platform): release V0.5.2 前端优化
This commit is contained in:
@@ -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();
|
||||
|
||||
@@ -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<ViewerDisplayOptions>|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<ProjectManifest|null>(null),notificationId=useRef(0),loadInFlight=useRef(false),importInFlight=useRef(false),adapter=useRef(new MainThreadPhysicsAdapter()),root=useRef<HTMLDivElement>(null),viewerHost=useRef<HTMLDivElement>(null),viewer=useRef<MuJoCoViewer|null>(null),urdfEnhancementsRef=useRef<UrdfEnhancementOptions>({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<string>(),[generatedMjcfPath,setGeneratedMjcfPath]=useState<string>(),[pendingUrdfPath,setPendingUrdfPath]=useState<string>(),[pendingUrdfMounts,setPendingUrdfMounts]=useState<string[]>([]),[removeConfirmOpen,setRemoveConfirmOpen]=useState(false),[fullscreen,setFullscreen]=useState(false),[settingsOpen,setSettingsOpen]=useState(false),[layoutOpen,setLayoutOpen]=useState(false),[diagnosticsOpen,setDiagnosticsOpen]=useState(false),[importProgress,setImportProgress]=useState<ImportProgress>(),[notifications,setNotifications]=useState<WorkbenchNotification[]>([]),[toast,setToast]=useState<WorkbenchNotification>(),[selectedControllerPath,setSelectedControllerPath]=useState<string>(),[controllerStatus,setControllerStatus]=useState<ControllerStatus>(),[selectedPolicyPath,setSelectedPolicyPath]=useState<string>(),[policyStatus,setPolicyStatus]=useState<RLPolicyStatus>();
|
||||
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<string>(),[generatedMjcfPath,setGeneratedMjcfPath]=useState<string>(),[pendingUrdfPath,setPendingUrdfPath]=useState<string>(),[pendingUrdfMounts,setPendingUrdfMounts]=useState<string[]>([]),[removeConfirmOpen,setRemoveConfirmOpen]=useState(false),[fullscreen,setFullscreen]=useState(false),[settingsOpen,setSettingsOpen]=useState(false),[layoutOpen,setLayoutOpen]=useState(false),[diagnosticsOpen,setDiagnosticsOpen]=useState(false),[importProgress,setImportProgress]=useState<ImportProgress>(),[notifications,setNotifications]=useState<WorkbenchNotification[]>([]),[toast,setToast]=useState<WorkbenchNotification>(),[selectedControllerPath,setSelectedControllerPath]=useState<string>(),[controllerStatus,setControllerStatus]=useState<ControllerStatus>(),[selectedPolicyPath,setSelectedPolicyPath]=useState<string>(),[policyStatus,setPolicyStatus]=useState<RLPolicyStatus>();
|
||||
const [urdfMode,setUrdfMode]=useState<UrdfLoadMode>('mjcf'),urdfModeRef=useRef<UrdfLoadMode>('mjcf');
|
||||
const [baseMode,setBaseMode]=useState<UrdfBaseMode>('floating'),baseModeRef=useRef<UrdfBaseMode>('floating');
|
||||
const [showCollision,setShowCollision]=useState(false),[showSensorCamera,setShowSensorCamera]=useState(true),[theme,setTheme]=useState<ViewerTheme>(initialTheme),[jointAdvanced,setJointAdvanced]=useState(false),[ignoreJointLimits,setIgnoreJointLimits]=useState(false),[angleUnit,setAngleUnit]=useState<'rad'|'deg'>('rad');
|
||||
const [displayOptions,setDisplayOptions]=useState<ViewerDisplayOptions>(initialDisplayOptions),[showSensorCamera,setShowSensorCamera]=useState(true),[theme,setTheme]=useState<ViewerTheme>(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:<Maximize className="h-4 w-4"/>,run:toggleFullscreen},
|
||||
{id:'help',label:'查看快捷键帮助',group:'帮助',icon:<CircleHelp className="h-4 w-4"/>,run:()=>setHelpOpen(true)},
|
||||
];
|
||||
return <div ref={root} className={`${theme==='light'?'theme-light':'theme-dark'} flex h-screen min-w-[1024px] flex-col overflow-hidden bg-app text-text-primary`} onDragOver={event=>event.preventDefault()} onDrop={drop}>
|
||||
<WorkbenchHeader paused={state.paused} ready={Boolean(state.snapshot)} speed={state.speed} theme={theme} loading={state.loading} leftOpen={leftOpen} rightOpen={rightOpen} fullscreen={fullscreen} hasProject={Boolean(generatedMjcf)} onFiles={changeFiles} onFolder={changeFiles} onOpenSource={()=>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={<><NotificationCenter items={notifications} onDismiss={id=>setNotifications(items=>items.filter(item=>item.id!==id))} onClear={()=>setNotifications([])} onOpenLog={()=>setDiagnosticsOpen(true)}/><span className="hidden items-center gap-0.5 xl:flex"><IconButton tooltip="布局设置" aria-label="布局设置" onClick={()=>setLayoutOpen(true)}><PanelsTopLeft className="h-4 w-4"/></IconButton><IconButton tooltip="工作台设置" aria-label="工作台设置" onClick={()=>setSettingsOpen(true)}><SettingsIcon className="h-4 w-4"/></IconButton></span></>} compactMenu={<ToolbarOverflowMenu fullscreen={fullscreen} onCommands={()=>setCommandOpen(true)} onLayout={()=>setLayoutOpen(true)} onSettings={()=>setSettingsOpen(true)} onFullscreen={toggleFullscreen} onHelp={()=>setHelpOpen(true)} onTheme={()=>setTheme(value=>value==='dark'?'light':'dark')}/>} onCommands={()=>setCommandOpen(true)} onToggleFullscreen={toggleFullscreen} center={<ViewerToolDock mode={state.mode} onModeChange={mode} onResetCamera={()=>viewer.current?.resetCamera()}/>}/>
|
||||
<div className="flex min-h-0 flex-1"><ProjectSidebar visible={leftOpen} projectName={state.projectName} files={state.files} entries={state.entries} selectedEntry={state.selectedEntry} snapshot={state.snapshot} loading={state.loading} onRemove={removeProject} onSelectEntry={requestLoadEntry} onJointHover={jointId=>viewer.current?.highlightJoint(jointId)}/><main className="relative min-w-0 flex-1"><div ref={viewerHost} className="absolute inset-0"/><ViewportHUD paused={state.paused} mode={state.mode} selection={state.selection} ready={Boolean(state.snapshot)}/><WorkspaceOverlays loading={state.loading} hasSnapshot={Boolean(state.snapshot)} progress={importProgress}/><ToastViewport item={toast} onDismiss={()=>setToast(undefined)}/>{Boolean(state.snapshot?.model.ncam)&&(showSensorCamera?<div aria-label="摄像头画面" className="pointer-events-none absolute bottom-4 left-4 z-20 aspect-video w-[min(320px,32%)] min-w-[120px] overflow-hidden rounded-lg border border-border-strong shadow-2xl"><div className="pointer-events-auto absolute inset-x-0 top-0 flex h-7 items-center justify-between bg-black/65 px-2 text-[10px] font-medium text-white"><span className="flex items-center gap-1"><Camera className="h-3 w-3"/>摄像头</span><button type="button" className="rounded px-1.5 py-0.5 hover:bg-white/15 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/60" onClick={()=>setShowSensorCamera(false)}>隐藏画面</button></div></div>:<Button className="absolute bottom-4 left-4 z-20" icon={<Camera className="h-3.5 w-3.5"/>} onClick={()=>setShowSensorCamera(true)}>显示摄像头画面</Button>)}{state.entries.length>1&&!state.selectedEntry&&!pendingUrdfPath&&<EntrySelectionDialog entries={state.entries} onSelect={requestLoadEntry}/>} {state.diagnostic&&<ErrorRecoveryPanel key={state.diagnostic.at} value={state.diagnostic} onClose={()=>state.setDiagnostic(undefined)} onRetry={state.diagnostic.category==='模型编译'&&state.diagnostic.path?()=>void loadEntry(state.diagnostic!.path!):undefined} onOpenProject={()=>{setLeftOpen(true);state.setDiagnostic(undefined);}}/>}</main><ModelControlsSidebar visible={rightOpen} snapshot={state.snapshot} selection={state.selection} selectedFormat={selectedFormat} loading={state.loading} urdfMode={urdfMode} baseMode={baseMode} showCollision={showCollision} ignoreJointLimits={ignoreJointLimits} jointAdvanced={jointAdvanced} angleUnit={angleUnit} forceScale={forceScale} controllerPaths={state.files.filter(file=>/\.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}/></div>
|
||||
{pendingUrdfPath&&<UrdfImportOptionsDialog open path={pendingUrdfPath} mountBodies={pendingUrdfMounts} onConfirm={confirmUrdfOptions} onSkip={skipUrdfOptions}/>}{sourceOpen&&generatedMjcf&&generatedMjcfPath&&<SourceEditorDialog open code={generatedMjcf} filePath={generatedMjcfPath} theme={theme} onClose={()=>setSourceOpen(false)} onSave={saveCachedSource}/>}<ShortcutHelpDialog open={helpOpen} onClose={()=>setHelpOpen(false)}/><DiagnosticsDrawer open={diagnosticsOpen} items={notifications} onClose={()=>setDiagnosticsOpen(false)} onClear={()=>setNotifications([])}/><SettingsDialog open={settingsOpen} onClose={()=>setSettingsOpen(false)} theme={theme} angleUnit={angleUnit} showCollision={showCollision} jointAdvanced={jointAdvanced} forceScale={forceScale} onTheme={setTheme} onAngleUnit={setAngleUnit} onShowCollision={setShowCollision} onJointAdvanced={setJointAdvanced} onForceScale={setForceScale}/><LayoutSettingsDialog open={layoutOpen} onClose={()=>setLayoutOpen(false)} leftOpen={leftOpen} rightOpen={rightOpen} onLeftOpen={setLeftOpen} onRightOpen={setRightOpen} onPreset={applyLayoutPreset} onReset={()=>applyLayoutPreset('default')}/><CommandPalette open={commandOpen} onClose={()=>setCommandOpen(false)} commands={commands}/><ConfirmDialog open={removeConfirmOpen} title="移除当前工程" confirmLabel="移除工程" danger onConfirm={confirmRemoveProject} onClose={()=>setRemoveConfirmOpen(false)}><p className="text-sm text-text-secondary">确定从当前会话中移除“<strong className="text-text-primary">{state.projectName}</strong>”吗?</p><p className="mt-2 text-xs text-text-tertiary">该操作不会删除本地文件。</p></ConfirmDialog><StatusBar time={state.snapshot?.time} fps={state.fps} stepMs={state.stepMs} memoryMb={state.memoryMb} loaded={Boolean(state.snapshot)} overBudget={state.overBudget}/>
|
||||
return <div ref={root} className={`${theme==='light'?'theme-light':'theme-dark'} flex h-screen min-w-0 flex-col overflow-hidden bg-app text-text-primary`} onDragOver={event=>event.preventDefault()} onDrop={drop}>
|
||||
<WorkbenchHeader paused={state.paused} ready={Boolean(state.snapshot)} speed={state.speed} theme={theme} loading={state.loading} leftOpen={leftOpen} rightOpen={rightOpen} fullscreen={fullscreen} hasProject={Boolean(generatedMjcf)} onFiles={changeFiles} onFolder={changeFiles} onOpenSource={()=>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={<><NotificationCenter items={notifications} onDismiss={id=>setNotifications(items=>items.filter(item=>item.id!==id))} onClear={()=>setNotifications([])} onOpenLog={()=>setDiagnosticsOpen(true)}/><span className="hidden items-center gap-0.5 xl:flex"><IconButton tooltip="布局设置" aria-label="布局设置" onClick={()=>setLayoutOpen(true)}><PanelsTopLeft className="h-4 w-4"/></IconButton><IconButton tooltip="工作台设置" aria-label="工作台设置" onClick={()=>setSettingsOpen(true)}><SettingsIcon className="h-4 w-4"/></IconButton></span></>} compactMenu={<ToolbarOverflowMenu fullscreen={fullscreen} onCommands={()=>setCommandOpen(true)} onLayout={()=>setLayoutOpen(true)} onSettings={()=>setSettingsOpen(true)} onFullscreen={toggleFullscreen} onHelp={()=>setHelpOpen(true)} onTheme={()=>setTheme(value=>value==='dark'?'light':'dark')}/>} onCommands={()=>setCommandOpen(true)} onToggleFullscreen={toggleFullscreen} center={<ViewerToolDock mode={state.mode} display={displayOptions} onModeChange={mode} onDisplayChange={setDisplayOptions} onResetCamera={()=>viewer.current?.resetCamera()}/>}/>
|
||||
<div className="relative flex min-h-0 flex-1"><ProjectSidebar visible={leftOpen} projectName={state.projectName} files={state.files} entries={state.entries} selectedEntry={state.selectedEntry} snapshot={state.snapshot} loading={state.loading} onRemove={removeProject} onSelectEntry={requestLoadEntry} onJointHover={jointId=>viewer.current?.highlightJoint(jointId)}/><main className="relative min-w-0 flex-1"><div ref={viewerHost} className="absolute inset-0"/><ViewportHUD paused={state.paused} mode={state.mode} selection={state.selection} ready={Boolean(state.snapshot)}/><WorkspaceOverlays loading={state.loading} hasSnapshot={Boolean(state.snapshot)} progress={importProgress}/><ToastViewport item={toast} onDismiss={()=>setToast(undefined)}/>{Boolean(state.snapshot?.model.ncam)&&(showSensorCamera?<div aria-label="摄像头画面" className="pointer-events-none absolute bottom-4 left-4 z-20 aspect-[4/3] w-[min(320px,32%)] min-w-[120px] overflow-hidden rounded-lg border border-border-strong shadow-2xl"><div className="pointer-events-auto absolute inset-x-0 top-0 flex h-7 items-center justify-between bg-black/65 px-2 text-[10px] font-medium text-white"><span className="flex items-center gap-1"><Camera className="h-3 w-3"/>摄像头</span><button type="button" className="rounded px-1.5 py-0.5 hover:bg-white/15 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/60" onClick={()=>setShowSensorCamera(false)}>隐藏画面</button></div></div>:<Button className="absolute bottom-4 left-4 z-20" icon={<Camera className="h-3.5 w-3.5"/>} onClick={()=>setShowSensorCamera(true)}>显示摄像头画面</Button>)}{state.entries.length>1&&!state.selectedEntry&&!pendingUrdfPath&&<EntrySelectionDialog entries={state.entries} onSelect={requestLoadEntry}/>} {state.diagnostic&&<ErrorRecoveryPanel key={state.diagnostic.at} value={state.diagnostic} onClose={()=>state.setDiagnostic(undefined)} onRetry={state.diagnostic.category==='模型编译'&&state.diagnostic.path?()=>void loadEntry(state.diagnostic!.path!):undefined} onOpenProject={()=>{setLeftOpen(true);state.setDiagnostic(undefined);}}/>}</main><ModelControlsSidebar visible={rightOpen} snapshot={state.snapshot} selection={state.selection} selectedFormat={selectedFormat} loading={state.loading} urdfMode={urdfMode} baseMode={baseMode} showCollision={showCollision} ignoreJointLimits={ignoreJointLimits} jointAdvanced={jointAdvanced} angleUnit={angleUnit} forceScale={forceScale} controllerPaths={state.files.filter(file=>/\.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}/></div>
|
||||
{pendingUrdfPath&&<UrdfImportOptionsDialog open path={pendingUrdfPath} mountBodies={pendingUrdfMounts} onConfirm={confirmUrdfOptions} onSkip={skipUrdfOptions}/>}{sourceOpen&&generatedMjcf&&generatedMjcfPath&&<Suspense fallback={<div role="status" className="fixed inset-0 z-[390] grid place-items-center bg-app/60 text-sm text-text-secondary backdrop-blur-sm">正在加载源码编辑器…</div>}><SourceEditorDialog open code={generatedMjcf} filePath={generatedMjcfPath} theme={theme} onClose={()=>setSourceOpen(false)} onSave={saveCachedSource}/></Suspense>}<ShortcutHelpDialog open={helpOpen} onClose={()=>setHelpOpen(false)}/><DiagnosticsDrawer open={diagnosticsOpen} items={notifications} onClose={()=>setDiagnosticsOpen(false)} onClear={()=>setNotifications([])}/><SettingsDialog open={settingsOpen} onClose={()=>setSettingsOpen(false)} theme={theme} angleUnit={angleUnit} showCollision={showCollision} jointAdvanced={jointAdvanced} forceScale={forceScale} onTheme={setTheme} onAngleUnit={setAngleUnit} onShowCollision={setShowCollision} onJointAdvanced={setJointAdvanced} onForceScale={setForceScale}/><LayoutSettingsDialog open={layoutOpen} onClose={()=>setLayoutOpen(false)} leftOpen={leftOpen} rightOpen={rightOpen} onLeftOpen={setLeftOpen} onRightOpen={setRightOpen} onPreset={applyLayoutPreset} onReset={()=>applyLayoutPreset('default')}/><CommandPalette open={commandOpen} onClose={()=>setCommandOpen(false)} commands={commands}/><ConfirmDialog open={removeConfirmOpen} title="移除当前工程" confirmLabel="移除工程" danger onConfirm={confirmRemoveProject} onClose={()=>setRemoveConfirmOpen(false)}><p className="text-sm text-text-secondary">确定从当前会话中移除“<strong className="text-text-primary">{state.projectName}</strong>”吗?</p><p className="mt-2 text-xs text-text-tertiary">该操作不会删除本地文件。</p></ConfirmDialog><StatusBar time={state.snapshot?.time} fps={state.fps} stepMs={state.stepMs} memoryMb={state.memoryMb} loaded={Boolean(state.snapshot)} overBudget={state.overBudget}/>
|
||||
</div>;
|
||||
}
|
||||
|
||||
@@ -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(<TreeSearchField value="" onChange={change}/>);fireEvent.change(screen.getByRole('searchbox'),{target:{value:'arm'}});expect(change).toHaveBeenCalledWith('arm');rerender(<TreeSearchField value="arm" resultCount={2} onChange={change}/>);expect(screen.getByRole('status')).toHaveTextContent('找到 2 个匹配项');fireEvent.click(screen.getByRole('button',{name:'清除搜索'}));expect(change).toHaveBeenLastCalledWith('');});
|
||||
it('快捷键帮助展示说明并支持 Escape',()=>{const close=vi.fn();render(<ShortcutHelpDialog open onClose={close}/>);expect(screen.getByRole('dialog',{name:'快捷键与视口操作'})).toBeVisible();expect(screen.getByText('播放 / 暂停')).toBeVisible();fireEvent.keyDown(document,{key:'Escape'});expect(close).toHaveBeenCalledTimes(1);});
|
||||
it('视口 HUD 复用仿真与选择状态',()=>{render(<ViewportHUD ready paused={false} mode="joint" selection={{bodyId:2,bodyName:'arm',geomId:3,geomType:1,position:[0,0,0]}}/>);expect(screen.getByLabelText('视口状态')).toHaveTextContent('仿真中');expect(screen.getByLabelText('视口状态')).toHaveTextContent('关节拖动');expect(screen.getByLabelText('视口状态')).toHaveTextContent('arm');});
|
||||
it('视口 HUD 复用状态并给出当前模式的鼠标提示',()=>{render(<ViewportHUD ready paused={false} mode="joint" selection={{bodyId:2,bodyName:'arm',geomId:3,geomType:1,position:[0,0,0]}}/>);expect(screen.getByLabelText('视口状态')).toHaveTextContent('仿真中');expect(screen.getByLabelText('视口状态')).toHaveTextContent('关节拖动');expect(screen.getByLabelText('视口状态')).toHaveTextContent('arm');expect(screen.getByLabelText('视口操作提示')).toHaveTextContent('左键拖动关节');expect(screen.getByLabelText('视口操作提示')).toHaveTextContent('右键平移');});
|
||||
it('空工作区解释导入到仿真的三步流程',()=>{render(<EmptyWorkspace/>);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(<ViewerDisplayPopover value={{...DEFAULT_VIEWER_DISPLAY_OPTIONS}} onChange={change}/>);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});});
|
||||
});
|
||||
|
||||
@@ -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<void>}){
|
||||
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<HTMLElement>(null),previousFocus=useRef<HTMLElement|null>(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 <div className="fixed inset-0 z-[390] pointer-events-none" role="presentation"><section role="dialog" aria-modal="false" aria-label="转换后的 MJCF 编辑器" style={maximized?undefined:{left:position.x,top:position.y,width:900,height:650}} className={`source-editor-window pointer-events-auto fixed flex min-h-[360px] min-w-[520px] flex-col overflow-hidden border border-border-strong bg-panel shadow-2xl ${maximized?'inset-0 h-full w-full':'resize'}`}>
|
||||
<header className="flex h-11 shrink-0 cursor-move select-none items-center gap-3 border-b border-border bg-surface px-3" onPointerDown={pointerDown} onPointerMove={pointerMove} onPointerUp={()=>{drag.current=null;}} onDoubleClick={()=>setMaximized(value=>!value)}><Code2 className="h-4 w-4 shrink-0 text-accent"/><div className="min-w-0 flex-1"><div className="truncate font-mono text-xs font-semibold text-text-primary">转换后的 MJCF</div><div className="truncate font-mono text-[9px] text-text-tertiary" title={filePath}>{filePath}</div></div><span className="text-[10px] text-text-tertiary">{contentSize(code)}</span><span className="rounded bg-accent-soft px-1.5 py-0.5 text-[9px] font-semibold text-accent">缓存文件 · 可编辑</span>{dirty&&<span className="rounded bg-warning-soft px-1.5 py-0.5 text-[9px] font-semibold text-warning">已修改</span>}<Button variant="primary" icon={<Save className="h-3 w-3"/>} disabled={!dirty||saving||Boolean(problem)} onClick={()=>void save()}>{saving?'重新载入中…':'保存并重新载入'}</Button><Button variant="ghost" icon={<Download className="h-3.5 w-3.5"/>} onClick={download}>下载</Button><Button variant="ghost" icon={copied?<Check className="h-3.5 w-3.5"/>:<Copy className="h-3.5 w-3.5"/>} onClick={()=>void copy()}>{copied?'已复制':'复制'}</Button><IconButton tooltip={maximized?'还原':'最大化'} aria-label={maximized?'还原':'最大化'} onClick={()=>setMaximized(value=>!value)}>{maximized?<Minimize2 className="h-4 w-4"/>:<Maximize2 className="h-4 w-4"/>}</IconButton><IconButton tooltip="关闭" aria-label="关闭源代码编辑器" onClick={onClose}><X className="h-4 w-4"/></IconButton></header>
|
||||
return <><div className="fixed inset-0 z-[390] pointer-events-none" role="presentation"><section ref={dialog} tabIndex={-1} role="dialog" aria-modal="false" aria-label="转换后的 MJCF 编辑器" style={maximized?undefined:{left:position.x,top:position.y,width:900,height:650}} className={`source-editor-window pointer-events-auto fixed flex min-h-[360px] min-w-[520px] flex-col overflow-hidden border border-border-strong bg-panel shadow-2xl ${maximized?'inset-0 h-full w-full':'resize'}`}>
|
||||
<header className="flex h-11 shrink-0 cursor-move select-none items-center gap-3 border-b border-border bg-surface px-3" onPointerDown={pointerDown} onPointerMove={pointerMove} onPointerUp={()=>{drag.current=null;}} onDoubleClick={()=>setMaximized(value=>!value)}><Code2 className="h-4 w-4 shrink-0 text-accent"/><div className="min-w-0 flex-1"><div className="truncate font-mono text-xs font-semibold text-text-primary">转换后的 MJCF</div><div className="truncate font-mono text-[9px] text-text-tertiary" title={filePath}>{filePath}</div></div><span className="text-[10px] text-text-tertiary">{contentSize(code)}</span><span className="rounded bg-accent-soft px-1.5 py-0.5 text-[9px] font-semibold text-accent">缓存文件 · 可编辑</span>{dirty&&<span className="rounded bg-warning-soft px-1.5 py-0.5 text-[9px] font-semibold text-warning">已修改</span>}<Button variant="primary" icon={<Save className="h-3 w-3"/>} disabled={!dirty||saving||Boolean(problem)} onClick={()=>void save()}>{saving?'重新载入中…':'保存并重新载入'}</Button><Button variant="ghost" icon={<Download className="h-3.5 w-3.5"/>} onClick={download}>下载</Button><Button variant="ghost" icon={copied?<Check className="h-3.5 w-3.5"/>:<Copy className="h-3.5 w-3.5"/>} onClick={()=>void copy()}>{copied?'已复制':'复制'}</Button><IconButton tooltip={maximized?'还原':'最大化'} aria-label={maximized?'还原':'最大化'} onClick={()=>setMaximized(value=>!value)}>{maximized?<Minimize2 className="h-4 w-4"/>:<Maximize2 className="h-4 w-4"/>}</IconButton><IconButton tooltip="关闭" aria-label="关闭源代码编辑器" onClick={requestClose}><X className="h-4 w-4"/></IconButton></header>
|
||||
<div className="min-h-0 flex-1 bg-input"><Editor height="100%" language="xml" theme={theme==='light'?'light':'vs-dark'} value={code} onChange={value=>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'}}/></div>
|
||||
<footer className="flex h-7 shrink-0 items-center justify-between gap-3 border-t border-border bg-surface px-3 text-[10px]"><div className={problem?'truncate text-warning':'text-success'}>{problem?`XML 错误:${problem}`:'✓ XML 结构正常'}</div><div className="flex items-center gap-2 font-mono text-text-tertiary"><span>Ctrl+S 保存并重新载入</span><span>•</span><span>MJCF / XML</span></div></footer>
|
||||
</section></div>;
|
||||
</section></div><ConfirmDialog open={discardOpen} title="放弃未保存的修改?" confirmLabel="放弃修改" cancelLabel="继续编辑" danger onConfirm={onClose} onClose={()=>setDiscardOpen(false)}><p className="text-sm text-text-secondary">当前 MJCF 源码包含未保存的修改。关闭后,这些修改将无法恢复。</p></ConfirmDialog></>;
|
||||
}
|
||||
|
||||
@@ -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 <span className="flex items-center gap-1.5"><Icon aria-hidden="true" className="h-3 w-3 text-text-tertiary"/>{children}</span>;}
|
||||
export function StatusBar({time,fps,stepMs,memoryMb,loaded,overBudget}:StatusBarProps){return <footer className="technical-value relative z-30 flex h-7 shrink-0 items-center gap-5 border-t border-border bg-panel px-3 text-[11px] text-text-tertiary"><Item icon={Clock3}>时间 {time?.toFixed(3)??'—'} s</Item><PerformancePopover fps={fps} stepMs={stepMs} memoryMb={memoryMb} overBudget={overBudget}/><Item icon={MemoryStick}>内存 {memoryMb===undefined?'—':`${memoryMb.toFixed(1)} MiB`}</Item><Item icon={Box}>WASM {loaded?'已加载':'未加载'}</Item>{overBudget&&<span className="flex items-center gap-1 text-warning"><TriangleAlert className="h-3 w-3"/>主线程超出步进预算,已限制追帧</span>}<span className="ml-auto hidden items-center gap-1.5 xl:flex"><Kbd>Space</Kbd> 播放/暂停 · <Kbd>R</Kbd> 重置 · <Kbd>1/2/3</Kbd> 模式</span></footer>;}
|
||||
function Item({icon:Icon,children,className=''}:{icon:typeof Clock3;children:ReactNode;className?:string}){return <span className={`items-center gap-1.5 ${className||'flex'}`}><Icon aria-hidden="true" className="h-3 w-3 text-text-tertiary"/>{children}</span>;}
|
||||
export function StatusBar({time,fps,stepMs,memoryMb,loaded,overBudget}:StatusBarProps){return <footer className="technical-value relative z-30 flex h-7 shrink-0 items-center gap-3 overflow-hidden border-t border-border bg-panel px-3 text-[11px] text-text-tertiary lg:gap-5"><Item icon={Clock3}>时间 {time?.toFixed(3)??'—'} s</Item><PerformancePopover fps={fps} stepMs={stepMs} memoryMb={memoryMb} overBudget={overBudget}/><Item icon={MemoryStick} className="hidden items-center gap-1.5 md:flex">内存 {memoryMb===undefined?'—':`${memoryMb.toFixed(1)} MiB`}</Item><Item icon={Box} className="hidden items-center gap-1.5 sm:flex">WASM {loaded?'已加载':'未加载'}</Item>{overBudget&&<span className="hidden min-w-0 items-center gap-1 truncate text-warning lg:flex"><TriangleAlert className="h-3 w-3 shrink-0"/>主线程超出步进预算,已限制追帧</span>}<span className="ml-auto hidden items-center gap-1.5 xl:flex"><Kbd>Space</Kbd> 播放/暂停 · <Kbd>R</Kbd> 重置 · <Kbd>1/2/3</Kbd> 模式</span></footer>;}
|
||||
|
||||
@@ -0,0 +1,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 <div className="space-y-0.5">{items.map(item=>{const checked=value[item.key];return <button key={item.key} type="button" role="switch" aria-checked={checked} onClick={()=>onChange({...value,[item.key]:!checked})} className="group flex w-full items-center gap-2.5 rounded-md px-2 py-1.5 text-left hover:bg-element-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40"><span className={`h-2.5 w-2.5 shrink-0 rounded-sm ${item.color}`}/><span className="min-w-0 flex-1"><span className="block text-xs font-medium text-text-primary">{item.label}</span><span className="block truncate text-[10px] text-text-tertiary">{item.description}</span></span><span aria-hidden="true" className={`grid h-4 w-4 shrink-0 place-items-center rounded border ${checked?'border-accent bg-accent text-white':'border-border-strong bg-input'}`}>{checked&&<Check className="h-3 w-3"/>}</span></button>;})}</div>;
|
||||
}
|
||||
|
||||
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 <Popover placement="bottom-right" label="视图显示设置" trigger={({open,toggle})=><IconButton active={open||customized} tooltip="显示设置" aria-label="显示设置" aria-expanded={open} onClick={toggle}><Eye className="h-3.5 w-3.5"/></IconButton>}>
|
||||
{()=> <div className="w-72 rounded-xl border border-border bg-surface-elevated p-2 shadow-2xl"><div className="mb-1 flex items-center justify-between px-2 py-1"><div><h2 className="text-xs font-semibold text-text-primary">视图显示</h2><p className="text-[10px] text-text-tertiary">控制模型和结构辅助标记</p></div><IconButton tooltip="恢复默认显示" aria-label="恢复默认显示" disabled={!customized} onClick={()=>onChange({...DEFAULT_VIEWER_DISPLAY_OPTIONS})}><RotateCcw className="h-3.5 w-3.5"/></IconButton></div><div className="border-t border-border pt-1"><p className="px-2 pb-0.5 pt-1 text-[10px] font-semibold uppercase tracking-wide text-text-tertiary">几何</p><DisplayRows items={geometryItems} value={value} onChange={onChange}/><p className="mt-1 border-t border-border px-2 pb-0.5 pt-2 text-[10px] font-semibold uppercase tracking-wide text-text-tertiary">辅助标记</p><DisplayRows items={helperItems} value={value} onChange={onChange}/><p className="mt-1 border-t border-border px-2 pb-0.5 pt-2 text-[10px] font-semibold uppercase tracking-wide text-text-tertiary">场景</p><DisplayRows items={sceneItems} value={value} onChange={onChange}/></div></div>}
|
||||
</Popover>;
|
||||
}
|
||||
@@ -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<InteractionMode>[]=[{value:'select',label:'选择',icon:MousePointer2},{value:'joint',label:'关节拖动',icon:Hand},{value:'force',label:'外力施加',icon:Crosshair}];
|
||||
export function ViewerToolDock({mode,onModeChange,onResetCamera}:{mode:InteractionMode;onModeChange:(mode:InteractionMode)=>void;onResetCamera:()=>void}){return <div className="flex items-center gap-1"><ToolbarToggleGroup items={tools} value={mode} onChange={onModeChange} label="视口交互模式"/><IconButton tooltip="相机复位" aria-label="相机复位" onClick={onResetCamera}><RotateCcw className="h-3.5 w-3.5"/></IconButton></div>;}
|
||||
export function ViewerToolDock({mode,display,onModeChange,onDisplayChange,onResetCamera}:{mode:InteractionMode;display:ViewerDisplayOptions;onModeChange:(mode:InteractionMode)=>void;onDisplayChange:(next:ViewerDisplayOptions)=>void;onResetCamera:()=>void}){return <div className="flex items-center gap-1"><ToolbarToggleGroup items={tools} value={mode} onChange={onModeChange} label="视口交互模式"/><ViewerDisplayPopover value={display} onChange={onDisplayChange}/><IconButton tooltip="相机复位" aria-label="相机复位" onClick={onResetCamera}><RotateCcw className="h-3.5 w-3.5"/></IconButton></div>;}
|
||||
|
||||
@@ -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<InteractionMode,string>={select:'选择',joint:'关节拖动',force:'外力施加'};
|
||||
export function ViewportHUD({paused,mode,selection,ready}:{paused:boolean;mode:InteractionMode;selection:ViewerSelection|null;ready:boolean}){if(!ready)return null;return <div aria-label="视口状态" className="pointer-events-none absolute left-3 top-3 z-10 flex max-w-[70%] flex-wrap items-center gap-1.5"><Badge tone={paused?'neutral':'success'}>{paused?<CirclePause className="h-3 w-3"/>:<CirclePlay className="h-3 w-3"/>}{paused?'已暂停':'仿真中'}</Badge><Badge tone="accent"><MousePointer2 className="h-3 w-3"/>{labels[mode]}</Badge>{selection&&<Badge title={`body ${selection.bodyId} · geom ${selection.geomId}`}>{selection.bodyName}</Badge>}</div>;}
|
||||
const primaryGestures:Record<InteractionMode,string>={select:'左键旋转',joint:'左键拖动关节',force:'左键拖动施力'};
|
||||
export function ViewportHUD({paused,mode,selection,ready}:{paused:boolean;mode:InteractionMode;selection:ViewerSelection|null;ready:boolean}){if(!ready)return null;return <><div aria-label="视口状态" className="pointer-events-none absolute left-3 top-3 z-10 flex max-w-[70%] flex-wrap items-center gap-1.5"><Badge tone={paused?'neutral':'success'}>{paused?<CirclePause className="h-3 w-3"/>:<CirclePlay className="h-3 w-3"/>}{paused?'已暂停':'仿真中'}</Badge><Badge tone="accent"><MousePointer2 className="h-3 w-3"/>{labels[mode]}</Badge>{selection&&<Badge title={`body ${selection.bodyId} · geom ${selection.geomId}`}>{selection.bodyName}</Badge>}</div><div aria-label="视口操作提示" className="pointer-events-none absolute bottom-3 left-1/2 z-10 hidden -translate-x-1/2 items-center gap-2 whitespace-nowrap rounded-full border border-border-strong bg-panel px-3 py-1.5 text-[10px] text-text-secondary shadow-xl lg:flex"><Mouse aria-hidden="true" className="h-3 w-3 text-text-secondary"/><span>{primaryGestures[mode]}</span><span aria-hidden="true" className="text-border-strong">·</span><span>右键平移</span><span aria-hidden="true" className="text-border-strong">·</span><span>滚轮缩放</span>{mode!=='select'&&<><span aria-hidden="true" className="text-border-strong">·</span><Kbd>1</Kbd><span>旋转视角</span></>}</div></>;}
|
||||
|
||||
@@ -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<HTMLInputElement>)=>void;onFolder:(event:ChangeEvent<HTMLInputElement>)=>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 <header className="relative z-40 grid h-10 shrink-0 grid-cols-[minmax(0,1fr)_auto_minmax(max-content,1fr)] items-center gap-2 border-b border-border bg-panel px-2.5"><div className="flex min-w-0 items-center gap-1"><h1 className="mr-2 hidden truncate border-r border-border pr-3 text-sm font-semibold text-text-primary xl:block">MuJoCo Web 仿真平台</h1><label aria-disabled={loading} className={`${fileActionClass} ${loading?'pointer-events-none opacity-40':''}`}><Upload className="h-3.5 w-3.5"/>打开文件<input className="sr-only" type="file" disabled={loading} multiple accept=".xml,.urdf,.zip,.obj,.stl,.dae,.msh,.png,.jpg,.jpeg,.bmp,.tga,.hdr" onChange={onFiles}/></label><label aria-disabled={loading} className={`${fileActionClass} ${loading?'pointer-events-none opacity-40':''}`}><FolderOpen className="h-3.5 w-3.5"/>文件夹<input className="sr-only" type="file" disabled={loading} multiple {...({webkitdirectory:'',directory:''} as object)} onChange={onFolder}/></label><IconButton tooltip="查看和修改缓存源代码" aria-label="源代码" disabled={!hasProject||loading} onClick={onOpenSource}><Code2 className="h-4 w-4"/></IconButton></div><div className="flex items-center justify-center">{center}</div><div className="flex min-w-0 items-center justify-end gap-0.5"><Button variant="ghost" onClick={onTogglePause} disabled={!ready} aria-label={paused?'▶ 播放':'⏸ 暂停'} icon={paused?<Play className="h-3.5 w-3.5"/>:<Pause className="h-3.5 w-3.5"/>}>{paused?'播放':'暂停'}</Button><IconButton tooltip="单步" aria-label="单步" onClick={onStep} disabled={!ready||!paused}><StepForward className="h-3.5 w-3.5"/></IconButton><IconButton tooltip="重置" aria-label="重置" onClick={onReset} disabled={!ready}><RotateCcw className="h-3.5 w-3.5"/></IconButton><Select aria-label="仿真速度" value={speed} disabled={loading} onChange={event=>onSpeed(Number(event.target.value))} className="w-[70px]"><option value={.25}>0.25×</option><option value={.5}>0.5×</option><option value={1}>1×</option><option value={2}>2×</option><option value={4}>4×</option></Select><span className="mx-1 h-5 border-l border-border"/><IconButton tooltip={leftOpen?'隐藏工程面板':'显示工程面板'} aria-label={leftOpen?'隐藏工程面板':'显示工程面板'} aria-expanded={leftOpen} onClick={onToggleLeft}><PanelLeft className="h-4 w-4"/></IconButton><IconButton tooltip={rightOpen?'隐藏属性面板':'显示属性面板'} aria-label={rightOpen?'隐藏属性面板':'显示属性面板'} aria-expanded={rightOpen} onClick={onToggleRight}><PanelRight className="h-4 w-4"/></IconButton>{endActions}{compactMenu}<IconButton className="hidden xl:inline-flex" tooltip="命令面板(Ctrl+K)" aria-label="打开命令面板" onClick={onCommands}><Search className="h-4 w-4"/></IconButton><IconButton className="hidden xl:inline-flex" tooltip={fullscreen?'退出全屏':'进入全屏'} aria-label={fullscreen?'退出全屏':'进入全屏'} onClick={onToggleFullscreen}>{fullscreen?<Minimize className="h-4 w-4"/>:<Expand className="h-4 w-4"/>}</IconButton><IconButton className="hidden xl:inline-flex" tooltip="快捷键帮助" aria-label="快捷键帮助" onClick={onHelp}><CircleHelp className="h-4 w-4"/></IconButton><IconButton className="hidden xl:inline-flex" tooltip={theme==='dark'?'切换到白天主题':'切换到黑夜主题'} aria-label={theme==='dark'?'切换到白天主题':'切换到黑夜主题'} onClick={onToggleTheme}>{theme==='dark'?<Sun className="h-4 w-4"/>:<Moon className="h-4 w-4"/>}</IconButton></div></header>;}
|
||||
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<HTMLInputElement>)=>void;onFolder:(event:ChangeEvent<HTMLInputElement>)=>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 <header className="relative z-40 grid h-10 shrink-0 grid-cols-[minmax(0,1fr)_auto_minmax(max-content,1fr)] items-center gap-2 border-b border-border bg-panel px-2.5"><div className="flex min-w-0 items-center gap-1"><h1 className="mr-2 hidden truncate border-r border-border pr-3 text-sm font-semibold text-text-primary xl:block">MuJoCo Web 仿真平台</h1><label aria-disabled={loading} className={`${fileActionClass} ${loading?'pointer-events-none opacity-40':''}`}><Upload className="h-3.5 w-3.5"/><span className={fileActionLabelClass}>打开文件</span><input id="mujoco-project-files" aria-label="打开文件" className="sr-only" type="file" disabled={loading} multiple accept=".xml,.urdf,.zip,.obj,.stl,.dae,.msh,.png,.jpg,.jpeg,.bmp,.tga,.hdr" onChange={onFiles}/></label><label aria-disabled={loading} className={`${fileActionClass} ${loading?'pointer-events-none opacity-40':''}`}><FolderOpen className="h-3.5 w-3.5"/><span className={fileActionLabelClass}>文件夹</span><input id="mujoco-project-folder" aria-label="打开文件夹" className="sr-only" type="file" disabled={loading} multiple {...({webkitdirectory:'',directory:''} as object)} onChange={onFolder}/></label><IconButton tooltip="查看和修改缓存源代码" aria-label="源代码" disabled={!hasProject||loading} onClick={onOpenSource}><Code2 className="h-4 w-4"/></IconButton></div><div className="flex items-center justify-center">{center}</div><div className="flex min-w-0 items-center justify-end gap-0.5"><Button variant="ghost" onClick={onTogglePause} disabled={!ready} aria-label={paused?'▶ 播放':'⏸ 暂停'} icon={paused?<Play className="h-3.5 w-3.5"/>:<Pause className="h-3.5 w-3.5"/>}>{paused?'播放':'暂停'}</Button><IconButton tooltip="单步" aria-label="单步" onClick={onStep} disabled={!ready||!paused}><StepForward className="h-3.5 w-3.5"/></IconButton><IconButton tooltip="重置" aria-label="重置" onClick={onReset} disabled={!ready}><RotateCcw className="h-3.5 w-3.5"/></IconButton><Select aria-label="仿真速度" value={speed} disabled={loading} onChange={event=>onSpeed(Number(event.target.value))} className="w-[70px]"><option value={.25}>0.25×</option><option value={.5}>0.5×</option><option value={1}>1×</option><option value={2}>2×</option><option value={4}>4×</option></Select><span className="mx-1 h-5 border-l border-border"/><IconButton tooltip={leftOpen?'隐藏工程面板':'显示工程面板'} aria-label={leftOpen?'隐藏工程面板':'显示工程面板'} aria-expanded={leftOpen} onClick={onToggleLeft}><PanelLeft className="h-4 w-4"/></IconButton><IconButton tooltip={rightOpen?'隐藏属性面板':'显示属性面板'} aria-label={rightOpen?'隐藏属性面板':'显示属性面板'} aria-expanded={rightOpen} onClick={onToggleRight}><PanelRight className="h-4 w-4"/></IconButton>{endActions}{compactMenu}<IconButton className="hidden xl:inline-flex" tooltip="命令面板(Ctrl+K)" aria-label="打开命令面板" onClick={onCommands}><Search className="h-4 w-4"/></IconButton><IconButton className="hidden xl:inline-flex" tooltip={fullscreen?'退出全屏':'进入全屏'} aria-label={fullscreen?'退出全屏':'进入全屏'} onClick={onToggleFullscreen}>{fullscreen?<Minimize className="h-4 w-4"/>:<Expand className="h-4 w-4"/>}</IconButton><IconButton className="hidden xl:inline-flex" tooltip="快捷键帮助" aria-label="快捷键帮助" onClick={onHelp}><CircleHelp className="h-4 w-4"/></IconButton><IconButton className="hidden xl:inline-flex" tooltip={theme==='dark'?'切换到白天主题':'切换到黑夜主题'} aria-label={theme==='dark'?'切换到白天主题':'切换到黑夜主题'} onClick={onToggleTheme}>{theme==='dark'?<Sun className="h-4 w-4"/>:<Moon className="h-4 w-4"/>}</IconButton></div></header>;}
|
||||
|
||||
@@ -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 <div className={`rounded-xl border border-dashed border-border-strong bg-panel/90 text-center shadow-sm ${compact?'m-3 p-4':'p-7'}`}><span className="mx-auto mb-3 grid h-10 w-10 place-items-center rounded-xl bg-accent-soft text-accent">{compact?<Box className="h-5 w-5"/>:<UploadCloud className="h-5 w-5"/>}</span><p className="text-sm font-semibold text-text-primary">拖放模型工程到此处</p><p className="mt-1 text-xs text-text-tertiary">支持 MJCF/XML、URDF、文件夹和 ZIP</p></div>;}
|
||||
export function WorkspaceOverlays({loading,hasSnapshot,progress}:{loading:boolean;hasSnapshot:boolean;progress?:ImportProgress}){return <>{!hasSnapshot&&!loading&&<div className="pointer-events-none absolute inset-0 grid place-items-center"><EmptyWorkspace/></div>}{loading&&<div role="status" aria-live="polite" aria-label={progress?.label??'正在加载 MuJoCo 与模型'} className="absolute inset-0 z-20 grid place-items-center bg-app/75 backdrop-blur-sm"><div className="w-80 rounded-xl border border-border bg-panel px-5 py-4 text-sm font-medium text-text-primary shadow-xl"><div className="flex items-center gap-3"><LoaderCircle aria-hidden="true" className="h-5 w-5 animate-spin text-accent"/>正在加载 MuJoCo 与模型…</div>{progress?<div className="mt-4"><ProgressBar value={progress.value} label={progress.label}/></div>:<div className="mt-4 space-y-2"><Skeleton className="h-2.5 w-full"/><Skeleton className="h-2.5 w-4/5"/></div>}</div></div>}</>;}
|
||||
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 <div className="m-3 rounded-xl border border-dashed border-border-strong bg-panel/90 p-4 text-center shadow-sm"><span className="mx-auto mb-3 grid h-10 w-10 place-items-center rounded-xl bg-accent-soft text-accent"><Box className="h-5 w-5"/></span><p className="text-sm font-semibold text-text-primary">拖放模型工程到此处</p><p className="mt-1 text-xs text-text-tertiary">支持 MJCF/XML、URDF、文件夹和 ZIP</p></div>;return <section aria-label="导入模型工程" className="w-[min(560px,calc(100vw-32px))] rounded-2xl border border-border-strong bg-panel/90 p-6 text-center shadow-2xl backdrop-blur-md"><span className="mx-auto mb-3 grid h-11 w-11 place-items-center rounded-xl bg-accent-soft text-accent"><UploadCloud className="h-5 w-5"/></span><h2 className="text-base font-semibold text-text-primary">拖放模型工程到此处</h2><p className="mt-1 text-xs text-text-tertiary">支持 MJCF/XML、URDF、文件夹和 ZIP</p><div className="mt-4 flex items-center justify-center gap-2"><label htmlFor="mujoco-project-files" className="inline-flex h-8 cursor-pointer items-center gap-1.5 rounded-md bg-accent px-3 text-xs font-medium text-white transition-colors hover:bg-accent-hover focus-within:ring-2 focus-within:ring-accent/40"><Upload className="h-3.5 w-3.5"/>选择文件</label><label htmlFor="mujoco-project-folder" className="inline-flex h-8 cursor-pointer items-center gap-1.5 rounded-md border border-border bg-surface px-3 text-xs font-medium text-text-primary transition-colors hover:bg-element-hover focus-within:ring-2 focus-within:ring-accent/40"><FolderOpen className="h-3.5 w-3.5"/>选择文件夹</label></div><ol aria-label="仿真工作流程" className="mt-5 hidden grid-cols-3 gap-2 border-t border-border pt-4 sm:grid">{workflow.map((item,index)=><li key={item.label} className="rounded-lg bg-surface px-3 py-2.5 text-left"><div className="flex items-center gap-2"><span className="technical-value text-[10px] font-semibold text-accent">0{index+1}</span><item.icon aria-hidden="true" className="h-3.5 w-3.5 text-text-secondary"/><span className="text-xs font-medium text-text-primary">{item.label}</span></div><span className="mt-1 block text-[10px] leading-4 text-text-tertiary">{item.detail}</span></li>)}</ol><p className="mt-3 flex items-center justify-center gap-1.5 text-[10px] text-text-tertiary"><ShieldCheck aria-hidden="true" className="h-3 w-3 text-success"/>模型与资源仅在当前浏览器会话中处理</p></section>;}
|
||||
export function WorkspaceOverlays({loading,hasSnapshot,progress}:{loading:boolean;hasSnapshot:boolean;progress?:ImportProgress}){return <>{!hasSnapshot&&!loading&&<div className="pointer-events-none absolute inset-0 grid place-items-center p-4"><div className="pointer-events-auto"><EmptyWorkspace/></div></div>}{loading&&<div role="status" aria-live="polite" aria-label={progress?.label??'正在加载 MuJoCo 与模型'} className="absolute inset-0 z-20 grid place-items-center bg-app/75 backdrop-blur-sm"><div className="w-80 rounded-xl border border-border bg-panel px-5 py-4 text-sm font-medium text-text-primary shadow-xl"><div className="flex items-center gap-3"><LoaderCircle aria-hidden="true" className="h-5 w-5 animate-spin text-accent"/>正在加载 MuJoCo 与模型…</div>{progress?<div className="mt-4"><ProgressBar value={progress.value} label={progress.label}/></div>:<div className="mt-4 space-y-2"><Skeleton className="h-2.5 w-full"/><Skeleton className="h-2.5 w-4/5"/></div>}</div></div>}</>;}
|
||||
|
||||
@@ -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<HTMLButtonElement>)=>{event.preventDefault();cleanupRef.current();const origin=event.clientX,startWidth=width,pointerId=event.pointerId;const move=(moveEvent:PointerEvent)=>{if(moveEvent.pointerId===pointerId)update(startWidth+(moveEvent.clientX-origin)*(side==='left'?1:-1));};const stop=(stopEvent:PointerEvent)=>{if(stopEvent.pointerId!==pointerId)return;cleanup();};const cleanup=()=>{window.removeEventListener('pointermove',move);window.removeEventListener('pointerup',stop);window.removeEventListener('pointercancel',stop);cleanupRef.current=()=>{};};cleanupRef.current=cleanup;window.addEventListener('pointermove',move);window.addEventListener('pointerup',stop);window.addEventListener('pointercancel',stop);};
|
||||
return <div hidden={!visible} className={`relative shrink-0 ${className}`} style={{width}}>{children}<button type="button" role="separator" aria-label={side==='left'?'调整工程面板宽度':'调整属性面板宽度'} aria-orientation="vertical" aria-valuemin={minWidth} aria-valuemax={Math.round(panelMaxWidth(minWidth))} aria-valuenow={Math.round(width)} onPointerDown={start} onKeyDown={event=>{if(event.key==='Home')update(minWidth);else if(event.key==='End')update(panelMaxWidth(minWidth));else if(event.key==='ArrowLeft')update(width+(side==='left'?-16:16));else if(event.key==='ArrowRight')update(width+(side==='left'?16:-16));else return;event.preventDefault();}} className={`absolute inset-y-0 z-30 w-2 cursor-col-resize bg-transparent outline-none after:absolute after:inset-y-0 after:left-1/2 after:w-px after:-translate-x-1/2 after:bg-transparent hover:after:bg-accent focus-visible:after:w-0.5 focus-visible:after:bg-accent ${side==='left'?'-right-1':'-left-1'}`}/></div>;}
|
||||
return <div hidden={!visible} className={`relative shrink-0 max-lg:absolute max-lg:inset-y-0 max-lg:z-40 max-lg:shadow-2xl ${side==='left'?'max-lg:left-0':'max-lg:right-0'} ${className}`} style={{width}}>{children}<button type="button" role="separator" aria-label={side==='left'?'调整工程面板宽度':'调整属性面板宽度'} aria-orientation="vertical" aria-valuemin={minWidth} aria-valuemax={Math.round(panelMaxWidth(minWidth))} aria-valuenow={Math.round(width)} onPointerDown={start} onKeyDown={event=>{if(event.key==='Home')update(minWidth);else if(event.key==='End')update(panelMaxWidth(minWidth));else if(event.key==='ArrowLeft')update(width+(side==='left'?-16:16));else if(event.key==='ArrowRight')update(width+(side==='left'?16:-16));else return;event.preventDefault();}} className={`absolute inset-y-0 z-30 w-2 cursor-col-resize bg-transparent outline-none after:absolute after:inset-y-0 after:left-1/2 after:w-px after:-translate-x-1/2 after:bg-transparent hover:after:bg-accent focus-visible:after:w-0.5 focus-visible:after:bg-accent ${side==='left'?'-right-1':'-left-1'}`}/></div>;}
|
||||
|
||||
@@ -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});}
|
||||
}
|
||||
|
||||
@@ -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<RLPolicyStatus>{
|
||||
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();
|
||||
|
||||
@@ -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}}
|
||||
|
||||
@@ -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<string,THREE.BufferGeometry>(); private textures=new Map<number,THREE.DataTexture>();
|
||||
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<session.model.ncam;id+=1){const camera=session.model.cam(id);try{if(this.sensorCameraId<0||camera.name.startsWith('__platform_camera__'))this.sensorCameraId=id;if(camera.name.startsWith('__platform_camera__'))break;}finally{camera.delete();}}this.fitCamera(session);}
|
||||
setMode(mode:InteractionMode):void {this.mode=mode; this.controls.enabled=mode==='select'; this.stopDrag();}
|
||||
setShowCollision(value:boolean):void {this.showCollision=value;this.applyGeomVisibility();}
|
||||
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<session.model.ncam;id+=1){const camera=session.model.cam(id);try{if(this.sensorCameraId<0||camera.name.startsWith('__platform_camera__'))this.sensorCameraId=id;if(camera.name.startsWith('__platform_camera__'))break;}finally{camera.delete();}}this.fitCamera(session);this.visualizationHelpers.attach(session,this.modelExtent);}
|
||||
setMode(mode:InteractionMode):void {this.mode=mode;this.controls.enabled=true;this.controls.enableRotate=mode==='select';this.controls.enablePan=true;this.controls.enableZoom=true;this.stopDrag();}
|
||||
setDisplayOptions(options:ViewerDisplayOptions):void {this.displayOptions={...options};this.grid.visible=options.showGrid;this.visualizationHelpers.setOptions(options);this.applyGeomVisibility();}
|
||||
setShowCollision(value:boolean):void {this.setDisplayOptions({...this.displayOptions,showCollision:value});}
|
||||
setShowSensorCamera(value:boolean):void {this.showSensorCamera=value;}
|
||||
setTheme(theme:ViewerTheme):void {const light=theme==='light';const background=this.scene.background instanceof THREE.Color?this.scene.background:new THREE.Color(0x0b1220);this.scene.background=background;this.themeTransition={started:performance.now(),fromBackground:background.clone(),toBackground:new THREE.Color(light?0xf8fafc:0x0b1220),fromGround:this.hemisphere.groundColor.clone(),toGround:new THREE.Color(light?0xcbd5e1:0x223344)};const next=new THREE.GridHelper(20,40,light?0x64748b:0x3b82f6,light?0xcbd5e1:0x253047).rotateX(Math.PI/2);this.scene.remove(this.grid);this.grid.geometry.dispose();const materials=Array.isArray(this.grid.material)?this.grid.material:[this.grid.material];for(const material of materials)material.dispose();this.grid=next;this.scene.add(this.grid);this.orientationGizmo.setTheme(theme);}
|
||||
setTheme(theme:ViewerTheme):void {const light=theme==='light';const background=this.scene.background instanceof THREE.Color?this.scene.background:new THREE.Color(0x0b1220);this.scene.background=background;this.themeTransition={started:performance.now(),fromBackground:background.clone(),toBackground:new THREE.Color(light?0xf8fafc:0x0b1220),fromGround:this.hemisphere.groundColor.clone(),toGround:new THREE.Color(light?0xcbd5e1:0x223344)};const next=new THREE.GridHelper(20,40,light?0x64748b:0x3b82f6,light?0xcbd5e1:0x253047).rotateX(Math.PI/2);next.visible=this.displayOptions.showGrid;this.scene.remove(this.grid);this.grid.geometry.dispose();const materials=Array.isArray(this.grid.material)?this.grid.material:[this.grid.material];for(const material of materials)material.dispose();this.grid=next;this.scene.add(this.grid);this.orientationGizmo.setTheme(theme);}
|
||||
private updateThemeTransition(now:number):void {const transition=this.themeTransition;if(!transition)return;const progress=Math.min(1,Math.max(0,(now-transition.started)/420)),eased=1-Math.pow(1-progress,3);(this.scene.background as THREE.Color).copy(transition.fromBackground).lerp(transition.toBackground,eased);this.hemisphere.groundColor.copy(transition.fromGround).lerp(transition.toGround,eased);if(progress===1)this.themeTransition=undefined;}
|
||||
private applyGeomVisibility():void {if(!this.option||!this.session)return;let hasVisual=false;for(let index=0;index<this.session.model.ngeom;index+=1)if(Number(this.session.model.geom_group[index])===1){hasVisual=true;break;}this.option.geomgroup[0]=!hasVisual||this.showCollision?1:0;this.option.geomgroup[1]=1;this.option.geomgroup[5]=0;}
|
||||
private applyGeomVisibility():void {if(!this.option||!this.session)return;this.modelHasVisuals=false;for(let index=0;index<this.session.model.ngeom;index+=1)if(Number(this.session.model.geom_group[index])===1){this.modelHasVisuals=true;break;}this.option.geomgroup[0]=this.modelHasVisuals?(this.displayOptions.showCollision?1:0):(this.displayOptions.showVisual||this.displayOptions.showCollision?1:0);this.option.geomgroup[1]=this.displayOptions.showVisual?1:0;this.option.geomgroup[5]=0;}
|
||||
resetCamera():void {if(this.session)this.fitCamera(this.session);}
|
||||
highlightJoint(jointId:number|null):void {this.highlightedJointId=jointId??-1;this.highlightedBodyId=-1;if(this.session&&jointId!==null&&jointId>=0&&jointId<this.session.model.njnt){const joint=this.session.model.jnt(jointId);try{this.highlightedBodyId=Number(joint.bodyid);}finally{joint.delete();}if(!this.jointMarker){const radius=Math.max(.008,this.session.geometryBounds().extent*.012);this.jointMarker=new THREE.Mesh(new THREE.SphereGeometry(radius,18,12),new THREE.MeshBasicMaterial({color:0xfacc15,depthTest:false,transparent:true,opacity:.95}));this.jointMarker.renderOrder=100;this.scene.add(this.jointMarker);}this.jointMarker.visible=true;this.updateJointMarker();}else if(this.jointMarker)this.jointMarker.visible=false;for(const mesh of this.meshes)this.applyMeshHighlight(mesh);}
|
||||
highlightJoint(jointId:number|null):void {this.highlightedJointId=jointId??-1;this.highlightedBodyId=-1;if(this.session&&jointId!==null&&jointId>=0&&jointId<this.session.model.njnt){const joint=this.session.model.jnt(jointId);try{this.highlightedBodyId=Number(joint.bodyid);}finally{joint.delete();}if(!this.jointMarker){const radius=Math.max(.008,this.modelExtent*.012);this.jointMarker=new THREE.Mesh(new THREE.SphereGeometry(radius,18,12),new THREE.MeshBasicMaterial({color:0xfacc15,depthTest:false,transparent:true,opacity:.95}));this.jointMarker.renderOrder=100;this.scene.add(this.jointMarker);}this.jointMarker.visible=true;this.updateJointMarker();}else if(this.jointMarker)this.jointMarker.visible=false;for(const mesh of this.meshes)this.applyMeshHighlight(mesh);}
|
||||
private updateJointMarker():void {if(!this.session||!this.jointMarker||this.highlightedJointId<0)return;const offset=this.highlightedJointId*3;this.jointMarker.position.set(Number(this.session.data.xanchor[offset]),Number(this.session.data.xanchor[offset+1]),Number(this.session.data.xanchor[offset+2]));}
|
||||
private applyMeshHighlight(mesh:THREE.Mesh):void {const material=mesh.material as THREE.MeshStandardMaterial;if(Number(mesh.userData.bodyId)===this.highlightedBodyId){material.emissive.setHex(0x8a6d00);material.emissiveIntensity=.85;}else if(mesh===this.selected){material.emissive.setHex(0x14532d);material.emissiveIntensity=1;}else{material.emissive.setHex(0);material.emissiveIntensity=1;}}
|
||||
|
||||
private fitCamera(session:SimulationSession):void {const {extent,center}=session.geometryBounds();this.controls.target.set(center[0],center[1],center[2]);this.camera.position.set(center[0]+extent*1.5,center[1]-extent*1.5,center[2]+extent);this.camera.near=Math.max(.001,extent/1000);this.camera.far=Math.max(100,extent*100);this.camera.updateProjectionMatrix();this.controls.update();}
|
||||
private fitCamera(session:SimulationSession):void {const {extent,center}=session.geometryBounds();this.modelExtent=extent;this.controls.target.set(center[0],center[1],center[2]);this.camera.position.set(center[0]+extent*1.5,center[1]-extent*1.5,center[2]+extent);this.camera.near=Math.max(.001,extent/1000);this.camera.far=Math.max(100,extent*100);this.camera.updateProjectionMatrix();this.controls.update();}
|
||||
private resize():void {const w=Math.max(1,this.host.clientWidth),h=Math.max(1,this.host.clientHeight); this.renderer.setSize(w,h,false); this.camera.aspect=w/h; this.camera.updateProjectionMatrix();}
|
||||
|
||||
private animate=(now:number):void=>{try {const result=this.session?.advance(now)??{steps:0,stepMs:0,overBudget:false};this.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<count;i++){const geom=geoms.get(i);if(!geom)continue;try{let mesh=this.meshes[i];const key=this.geometryKey(geom);if(!mesh||mesh.userData.geometryKey!==key){if(mesh){this.scene.remove(mesh);this.disposeMesh(mesh);} mesh=this.createMesh(geom,key);this.meshes[i]=mesh;this.scene.add(mesh);} mesh.visible=true;this.updateMesh(mesh,geom);}finally{geom.delete();}} for(let i=count;i<this.meshes.length;i++)this.meshes[i].visible=false;}finally{geoms.delete();}}
|
||||
/** 模型 geom 的尺寸固定,以 objid 保持稳定;接触点等动态 geom 才包含尺寸。 */
|
||||
private geometryKey(g:MjvGeom):string {const m=this.session!.module,dataId=g.type===m.mjtGeom.mjGEOM_MESH.value?meshIdFromSceneDataId(g.dataid):g.dataid;if(g.objtype===m.mjtObj.mjOBJ_GEOM.value)return `model:${g.objid}:${g.type}:${dataId}`;return `dynamic:${g.type}:${dataId}:${Array.from(g.size).join(',')}`;}
|
||||
private primitive(g:MjvGeom):THREE.BufferGeometry {const m=this.session!.module,t=g.type,s=g.size;if(t===m.mjtGeom.mjGEOM_PLANE.value)return new THREE.PlaneGeometry(2*(s[0]||1e3),2*(s[1]||1e3));if(t===m.mjtGeom.mjGEOM_SPHERE.value)return new THREE.SphereGeometry(s[0],24,16);if(t===m.mjtGeom.mjGEOM_CAPSULE.value)return new CapsuleGeometry(s[0],2*s[2]);if(t===m.mjtGeom.mjGEOM_BOX.value)return new THREE.BoxGeometry(2*s[0],2*s[1],2*s[2]);if(t===m.mjtGeom.mjGEOM_CYLINDER.value){const x=new THREE.CylinderGeometry(s[0],s[0],2*s[2],24);x.rotateX(Math.PI/2);return x;}if(t===m.mjtGeom.mjGEOM_ELLIPSOID.value){const x=new THREE.SphereGeometry(1,24,16);x.scale(s[0],s[1],s[2]);return x;}if(t===m.mjtGeom.mjGEOM_MESH.value&&g.dataid>=0)return this.meshGeometry(meshIdFromSceneDataId(g.dataid));return new THREE.BufferGeometry();}
|
||||
private meshGeometry(id:number):THREE.BufferGeometry {const m=this.session!.model;const va=Number(m.mesh_vertadr[id]),vn=Number(m.mesh_vertnum[id]),fa=Number(m.mesh_faceadr[id]),fn=Number(m.mesh_facenum[id]);const positions=new Float32Array(vn*3);for(let i=0;i<positions.length;i++)positions[i]=m.mesh_vert[va*3+i];const indices=new Uint32Array(fn*3);for(let i=0;i<indices.length;i++)indices[i]=m.mesh_face[fa*3+i];const geometry=new THREE.BufferGeometry();geometry.setAttribute('position',new THREE.BufferAttribute(positions,3));geometry.setIndex(new THREE.BufferAttribute(indices,1));const na=Number(m.mesh_normaladr[id]),nn=Number(m.mesh_normalnum[id]);if(nn===vn){const normals=new Float32Array(nn*3);for(let i=0;i<normals.length;i++)normals[i]=m.mesh_normal[na*3+i];geometry.setAttribute('normal',new THREE.BufferAttribute(normals,3));}else geometry.computeVertexNormals();const ta=Number(m.mesh_texcoordadr[id]),tn=Number(m.mesh_texcoordnum[id]);if(tn===vn&&ta>=0){const uv=new Float32Array(tn*2);for(let i=0;i<uv.length;i++)uv[i]=m.mesh_texcoord[ta*2+i];geometry.setAttribute('uv',new THREE.BufferAttribute(uv,2));}geometry.computeBoundingSphere();return geometry;}
|
||||
private texture(id:number):THREE.DataTexture|undefined {if(id<0)return;let found=this.textures.get(id);if(found)return found;const m=this.session!.model,w=Number(m.tex_width[id]),h=Number(m.tex_height[id]),channels=Number(m.tex_nchannel[id]||3),adr=Number(m.tex_adr[id]);if(!w||!h)return;const data=new Uint8Array(w*h*channels);for(let i=0;i<data.length;i++)data[i]=m.tex_data[adr+i];found=new THREE.DataTexture(data,w,h,channels===4?THREE.RGBAFormat:THREE.RGBFormat);found.colorSpace=THREE.SRGBColorSpace;found.flipY=true;found.needsUpdate=true;this.textures.set(id,found);return found;}
|
||||
private texture(id:number):THREE.DataTexture|undefined {if(id<0)return;let found=this.textures.get(id);if(found)return found;const m=this.session!.model,w=Number(m.tex_width[id]),h=Number(m.tex_height[id]),channels=Number(m.tex_nchannel[id]||3),adr=Number(m.tex_adr[id]);if(!w||!h)return;const source=new Uint8Array(w*h*channels);for(let i=0;i<source.length;i++)source[i]=m.tex_data[adr+i];const data=texturePixelsToRgba(source,w*h,channels);found=new THREE.DataTexture(data,w,h,THREE.RGBAFormat);found.colorSpace=THREE.SRGBColorSpace;found.flipY=true;found.needsUpdate=true;this.textures.set(id,found);return found;}
|
||||
// 只缓存真正可复用的模型 mesh。动态接触几何会逐帧改变尺寸,必须由所属
|
||||
// THREE.Mesh 在替换时释放,否则 geometry key 会无限增长并最终耗尽标签页内存。
|
||||
private createMesh(g:MjvGeom,key:string):THREE.Mesh {const m=this.session!.module,isMesh=g.type===m.mjtGeom.mjGEOM_MESH.value&&g.dataid>=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();}
|
||||
}
|
||||
|
||||
@@ -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<number,THREE.ArrowHelper>();
|
||||
private comHelpers=new Map<number,THREE.Mesh>();
|
||||
private inertiaHelpers=new Map<number,THREE.Group>();
|
||||
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;bodyId<session.model.nbody;bodyId+=1){
|
||||
const axes=new THREE.AxesHelper(frameSize);axes.name=`body-frame-${bodyId}`;axes.renderOrder=80;this.frames.add(axes);this.frameHelpers[bodyId]=axes;
|
||||
if(bodyId===0||!(Number(session.model.body_mass[bodyId])>0))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;jointId<session.model.njnt;jointId+=1){
|
||||
const type=Number(session.model.jnt_type[jointId]);if(type!==2&&type!==3)continue;
|
||||
const arrow=new THREE.ArrowHelper(new THREE.Vector3(0,0,1),new THREE.Vector3(),jointLength,0xef4444,jointLength*.22,jointLength*.12);arrow.name=`joint-axis-${jointId}`;arrow.renderOrder=90;(arrow.line.material as THREE.LineBasicMaterial).depthTest=false;(arrow.cone.material as THREE.MeshBasicMaterial).depthTest=false;this.jointAxes.add(arrow);this.jointHelpers.set(jointId,arrow);
|
||||
}
|
||||
this.applyVisibility();
|
||||
this.update();
|
||||
}
|
||||
|
||||
setOptions(options:ViewerDisplayOptions):void {this.options={...options};this.applyVisibility();}
|
||||
|
||||
update():void {
|
||||
const session=this.session;if(!session)return;
|
||||
const {data}=session;
|
||||
if(this.frames.visible)for(let bodyId=0;bodyId<this.frameHelpers.length;bodyId+=1){const helper=this.frameHelpers[bodyId];if(helper)this.setWorldMatrix(helper,data.xmat,data.xpos,bodyId);}
|
||||
if(this.jointAxes.visible)for(const [jointId,arrow] of this.jointHelpers){const offset=jointId*3,axis=this.jointAxisVector.set(Number(data.xaxis[offset]),Number(data.xaxis[offset+1]),Number(data.xaxis[offset+2]));if(axis.lengthSq()>1e-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<number>,positions:ArrayLike<number>,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<THREE.BufferGeometry>(),materials=new Set<THREE.Material>();
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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 通道纹理');
|
||||
});
|
||||
});
|
||||
@@ -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<pixelCount*channels)throw new Error('纹理像素数据不完整');
|
||||
const rgba=new Uint8Array(pixelCount*4);
|
||||
for(let pixel=0;pixel<pixelCount;pixel+=1){
|
||||
const sourceOffset=pixel*channels,targetOffset=pixel*4,red=source[sourceOffset];
|
||||
rgba[targetOffset]=red;
|
||||
rgba[targetOffset+1]=channels===1||channels===2?red:source[sourceOffset+1];
|
||||
rgba[targetOffset+2]=channels===1||channels===2?red:source[sourceOffset+2];
|
||||
rgba[targetOffset+3]=channels===2?source[sourceOffset+1]:channels===4?source[sourceOffset+3]:255;
|
||||
}
|
||||
return rgba;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import {inertiaBoxDimensions} from './visualizationMath';
|
||||
|
||||
describe('visualizationMath',()=>{
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -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])];
|
||||
}
|
||||
Reference in New Issue
Block a user