From e4849e1645b547188fb7f6a659239f9a3e3340f2 Mon Sep 17 00:00:00 2001 From: cen617-code <1057290604@qq.com> Date: Thu, 20 Aug 2026 16:36:19 +0800 Subject: [PATCH 1/2] feat(web-platform): release V0.1 --- wasm/web_platform/e2e/app.spec.ts | 24 ++++++++++- wasm/web_platform/src/app/App.tsx | 22 ++++++---- .../src/project/ModelStructureTree.test.tsx | 20 +++++++++ .../src/project/ModelStructureTree.tsx | 22 ++++++++++ .../src/project/ProjectTree.test.tsx | 13 +++--- wasm/web_platform/src/project/ProjectTree.tsx | 19 +++----- .../src/simulation/PhysicsAdapter.ts | 6 ++- .../src/simulation/SimulationSession.ts | 43 ++++++++----------- wasm/web_platform/src/styles.css | 22 ++++++++++ wasm/web_platform/src/viewer/MuJoCoViewer.ts | 23 +++++++--- .../src/viewer/OrientationGizmo.ts | 7 ++- 11 files changed, 160 insertions(+), 61 deletions(-) create mode 100644 wasm/web_platform/src/project/ModelStructureTree.test.tsx create mode 100644 wasm/web_platform/src/project/ModelStructureTree.tsx diff --git a/wasm/web_platform/e2e/app.spec.ts b/wasm/web_platform/e2e/app.spec.ts index 6bf6c8b3..4e454a01 100644 --- a/wasm/web_platform/e2e/app.spec.ts +++ b/wasm/web_platform/e2e/app.spec.ts @@ -5,10 +5,12 @@ const fixture = (relative: string) => fileURLToPath(new URL(`../fixtures/${relat const SIMPLE_MODEL = ` + + @@ -39,6 +41,12 @@ test('显示中文平台骨架并加载单文件模型', async ({page}) => { await expect(page.getByRole('heading', {name: 'MuJoCo Web 仿真平台'})).toBeVisible(); await expect(page.getByRole('main').getByText('拖放模型工程到此处')).toBeVisible(); await expect(page.getByRole('img',{name:'XYZ 方向指示器'})).toBeVisible(); + await expect(page.getByRole('button',{name:'切换到白天主题'})).toHaveText('☀'); + await page.getByRole('button',{name:'切换到白天主题'}).click(); + await expect(page.locator('#root > div')).toHaveClass(/theme-light/); + await expect(page.getByRole('button',{name:'切换到黑夜主题'})).toHaveText('☾'); + await page.getByRole('button',{name:'切换到黑夜主题'}).click(); + await expect(page.locator('#root > div')).toHaveClass(/theme-dark/); await page.locator('input[type="file"]').first().setInputFiles({ name: 'model.xml', @@ -48,7 +56,21 @@ test('显示中文平台骨架并加载单文件模型', async ({page}) => { await expect(page.getByText('WASM 已加载')).toBeVisible({timeout: 30_000}); await expect(page.getByText('motor')).toBeVisible(); - await expect(page.getByText('slide')).toBeVisible(); + await expect(page.getByText('slide').first()).toBeVisible(); + const structure=page.getByRole('navigation',{name:'模型结构树'});await expect(structure).toBeVisible();await structure.getByRole('treeitem',{name:/hinge/}).hover(); + await expect(page.getByRole('alert')).toHaveCount(0); + await expect(page.getByRole('button',{name:'重置关节'})).toBeVisible(); + await page.getByRole('button',{name:'高级'}).click(); + await expect(page.getByText('下限 -1.571 rad')).toBeVisible(); + await expect(page.getByText('上限 1.571 rad')).toBeVisible(); + await page.getByRole('button',{name:'rad 弧度制'}).click(); + await expect(page.getByText('下限 -90.000°')).toBeVisible(); + await expect(page.getByText('上限 90.000°')).toBeVisible(); + await page.getByRole('button',{name:'忽略关节限位'}).click(); + await expect(page.getByRole('button',{name:'忽略关节限位'})).toHaveAttribute('aria-pressed','true'); + await expect(page.getByText('已忽略').first()).toBeVisible(); + await page.getByRole('button',{name:'重置关节'}).click(); + await expect(page.getByRole('button',{name:'▶ 播放'})).toBeVisible(); }); test('加载包含 include、OBJ、STL 与 PNG 的工程', async ({page}) => { diff --git a/wasm/web_platform/src/app/App.tsx b/wasm/web_platform/src/app/App.tsx index 2b82bcfd..fbae18c4 100644 --- a/wasm/web_platform/src/app/App.tsx +++ b/wasm/web_platform/src/app/App.tsx @@ -4,18 +4,20 @@ import {useCallback,useEffect,useRef,useState,type ChangeEvent,type DragEvent} f import type {ProjectManifest} from '../project/types'; import {filesFromDrop,importBrowserFiles,ProjectImportError} from '../project/importer'; import {ProjectTree} from '../project/ProjectTree'; +import {ModelStructureTree} from '../project/ModelStructureTree'; import {MainThreadPhysicsAdapter,type UrdfBaseMode,type UrdfLoadMode} from '../simulation/PhysicsAdapter'; -import {MuJoCoViewer,type InteractionMode} from '../viewer/MuJoCoViewer'; +import {MuJoCoViewer,type InteractionMode,type ViewerTheme} from '../viewer/MuJoCoViewer'; import {useAppStore,type AppDiagnostic} from '../stores/useAppStore'; function diagnostic(category:AppDiagnostic['category'],error:unknown,path?:string):AppDiagnostic{const detail=error instanceof Error?error.message:String(error);return {category,summary:`${category}失败`,detail,path,at:Date.now()};} const modeLabels:Record={select:'选择',joint:'关节拖动',force:'外力施加'}; +function initialTheme():ViewerTheme{try{return localStorage.getItem('mujoco-platform-theme')==='light'?'light':'dark';}catch{return'dark';}} export function App(){ - const state=useAppStore(); const manifest=useRef(null); const adapter=useRef(new MainThreadPhysicsAdapter()); const viewerHost=useRef(null); const viewer=useRef(null); const [forceScale,setForceScale]=useState(50); const [leftOpen,setLeftOpen]=useState(true);const [rightOpen,setRightOpen]=useState(true);const [urdfMode,setUrdfMode]=useState('mjcf');const urdfModeRef=useRef('mjcf');const [baseMode,setBaseMode]=useState('floating');const baseModeRef=useRef('floating');const [showCollision,setShowCollision]=useState(false); + const state=useAppStore(); const manifest=useRef(null); const adapter=useRef(new MainThreadPhysicsAdapter()); const viewerHost=useRef(null); const viewer=useRef(null); const [forceScale,setForceScale]=useState(50); const [leftOpen,setLeftOpen]=useState(true);const [rightOpen,setRightOpen]=useState(true);const [urdfMode,setUrdfMode]=useState('mjcf');const urdfModeRef=useRef('mjcf');const [baseMode,setBaseMode]=useState('floating');const baseModeRef=useRef('floating');const [showCollision,setShowCollision]=useState(false);const [theme,setTheme]=useState(initialTheme);const [jointAdvanced,setJointAdvanced]=useState(false);const [ignoreJointLimits,setIgnoreJointLimits]=useState(false);const [angleUnit,setAngleUnit]=useState<'rad'|'deg'>('rad'); useEffect(()=>{if(!viewerHost.current)return;viewer.current=new MuJoCoViewer(viewerHost.current,{onSelection:state.setSelection,onFrame:(frame,fps,snapshot)=>{const memory=(performance as Performance&{memory?:{usedJSHeapSize:number}}).memory?.usedJSHeapSize;state.setMetrics(fps,frame.stepMs,memory===undefined?undefined:memory/1048576,frame.overBudget);if(snapshot)state.setSnapshot(snapshot);},onError:(error)=>state.setDiagnostic(diagnostic('渲染',error))});return()=>{viewer.current?.dispose();viewer.current=null;adapter.current.dispose();};},[]); - useEffect(()=>{viewer.current?.setMode(state.mode);},[state.mode]); useEffect(()=>{if(viewer.current)viewer.current.forceScale=forceScale;},[forceScale]);useEffect(()=>{viewer.current?.setShowCollision(showCollision);},[showCollision]); - const loadEntry=useCallback(async(path:string,requestedMode?:UrdfLoadMode)=>{if(!manifest.current)return;state.setEntry(path);state.setLoading(true);state.setDiagnostic(undefined);viewer.current?.attach(null);try{const snapshot=await adapter.current.load(manifest.current,path,requestedMode??urdfModeRef.current,baseModeRef.current);state.setSnapshot(snapshot);state.setPaused(true);viewer.current?.attach(adapter.current.session);}catch(error){state.setDiagnostic(diagnostic('模型编译',error,path));}finally{state.setLoading(false);}},[]); + useEffect(()=>{viewer.current?.setMode(state.mode);},[state.mode]); useEffect(()=>{if(viewer.current)viewer.current.forceScale=forceScale;},[forceScale]);useEffect(()=>{viewer.current?.setShowCollision(showCollision);},[showCollision]);useEffect(()=>{viewer.current?.setTheme(theme);document.documentElement.style.colorScheme=theme;try{localStorage.setItem('mujoco-platform-theme',theme);}catch{/* 浏览器禁用存储时仍可在当前会话切换 */}},[theme]); + const loadEntry=useCallback(async(path:string,requestedMode?:UrdfLoadMode)=>{if(!manifest.current)return;setIgnoreJointLimits(false);state.setEntry(path);state.setLoading(true);state.setDiagnostic(undefined);viewer.current?.attach(null);try{const snapshot=await adapter.current.load(manifest.current,path,requestedMode??urdfModeRef.current,baseModeRef.current);state.setSnapshot(snapshot);state.setPaused(true);viewer.current?.attach(adapter.current.session);}catch(error){state.setDiagnostic(diagnostic('模型编译',error,path));}finally{state.setLoading(false);}},[]); const ingest=useCallback(async(files:File[])=>{state.setLoading(true);try{const next=await importBrowserFiles(files);manifest.current=next;state.setProject(next.name,next.files.map(({path,size})=>({path,size})),next.entries,next.selectedEntry);if(next.selectedEntry)await loadEntry(next.selectedEntry);}catch(error){state.setDiagnostic(diagnostic(error instanceof ProjectImportError&&/ZIP/.test(error.message)?'ZIP':'导入',error,error instanceof ProjectImportError?error.path:undefined));}finally{state.setLoading(false);}},[loadEntry]); const removeProject=()=>{if(!state.projectName||!window.confirm(`确定从当前会话中移除“${state.projectName}”吗?\n不会删除本地文件。`))return;viewer.current?.attach(null);adapter.current.dispose();manifest.current=null;state.clearProject();}; const changeUrdfMode=(value:UrdfLoadMode)=>{setUrdfMode(value);urdfModeRef.current=value;const entry=state.entries.find(candidate=>candidate.path===state.selectedEntry);if(entry?.format==='urdf')void loadEntry(entry.path,value);}; @@ -27,12 +29,14 @@ export function App(){ const singleStep=()=>{adapter.current.singleStep();state.setSnapshot(adapter.current.snapshot()??undefined);}; const changeSpeed=(value:number)=>{state.setSpeed(value);adapter.current.setSpeed(value);}; const mode=(value:InteractionMode)=>{state.setMode(value);}; + const resetJoints=()=>{adapter.current.resetJoints();state.setPaused(true);state.setSnapshot(adapter.current.snapshot()??undefined);}; + const toggleJointLimits=()=>{const next=!ignoreJointLimits;adapter.current.setIgnoreJointLimits(next);setIgnoreJointLimits(next);state.setSnapshot(adapter.current.snapshot()??undefined);}; useEffect(()=>{const key=(e:KeyboardEvent)=>{if((e.target as HTMLElement).matches('input,select,button'))return;if(e.code==='Space'){e.preventDefault();togglePause();}if(e.key==='r')reset();if(e.key==='1')mode('select');if(e.key==='2')mode('joint');if(e.key==='3')mode('force');};window.addEventListener('keydown',key);return()=>window.removeEventListener('keydown',key);}); - return
e.preventDefault()} onDrop={drop}> -

MuJoCo Web 仿真平台

{(Object.keys(modeLabels) as InteractionMode[]).map(value=>)}
-
{leftOpen&&} + return
e.preventDefault()} onDrop={drop}> +

MuJoCo Web 仿真平台

{(Object.keys(modeLabels) as InteractionMode[]).map(value=>)}
+
{leftOpen&&}
{!state.snapshot&&!state.loading&&
}{state.loading&&
正在加载 MuJoCo 与模型…
}{state.entries.length>1&&!state.selectedEntry&&} {state.diagnostic&&state.setDiagnostic(undefined)}/>}
- {rightOpen&&}
+ {rightOpen&&}
时间 {state.snapshot?.time.toFixed(3)??'—'} sFPS {state.fps.toFixed(0)}物理 {state.stepMs.toFixed(2)} ms内存 {state.memoryMb===undefined?'—':`${state.memoryMb.toFixed(1)} MiB`}WASM {state.snapshot?'已加载':'未加载'}{state.overBudget&&主线程超出步进预算,已限制追帧}快捷键:Space 播放/暂停 · R 重置 · 1/2/3 模式
; } @@ -40,4 +44,4 @@ function PanelTitle({children}:{children:string}){return

拖放模型工程到此处

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

} function EntryDialog({entries,onSelect}:{entries:{path:string;label:string}[];onSelect:(path:string)=>void}){return

选择模型入口

工程包含多个可加载模型,请选择一个。

{entries.map(e=>)}
} function DiagnosticCard({value,onClose}:{value:AppDiagnostic;onClose:()=>void}){return

{value.summary}

{value.path&&

路径:{value.path}

}
{value.detail}
} -function ControlSlider({label,value,min,max,onChange,disabled=false}:{label:string;value:number;min:number;max:number;onChange:(v:number)=>void;disabled?:boolean}){const sane=Number.isFinite(value)?value:0;return } +function ControlSlider({label,value,min,max,onChange,disabled=false,unit='',advanced=false,limited=false,limitsIgnored=false,limitMin=0,limitMax=0}:{label:string;value:number;min:number;max:number;onChange:(v:number)=>void;disabled?:boolean;unit?:string;advanced?:boolean;limited?:boolean;limitsIgnored?:boolean;limitMin?:number;limitMax?:number}){const sane=Number.isFinite(value)?value:0,format=(number:number)=>`${number.toFixed(3)}${unit}`;return } diff --git a/wasm/web_platform/src/project/ModelStructureTree.test.tsx b/wasm/web_platform/src/project/ModelStructureTree.test.tsx new file mode 100644 index 00000000..3faaf512 --- /dev/null +++ b/wasm/web_platform/src/project/ModelStructureTree.test.tsx @@ -0,0 +1,20 @@ +import {fireEvent,render,screen} from '@testing-library/react'; +import {buildBodyTree,ModelStructureTree} from './ModelStructureTree'; +import type {BodyInfo,JointInfo} from '../simulation/SimulationSession'; + +const bodies:BodyInfo[]=[{id:0,name:'world',parentId:0},{id:1,name:'base',parentId:0},{id:2,name:'arm',parentId:1}]; +const joints:JointInfo[]=[{id:0,name:'arm_joint',type:3,value:0,min:-1,max:1,limitMin:-1,limitMax:1,limited:true,limitsIgnored:false,editable:true,bodyId:2,axis:[0,0,1]}]; + +describe('ModelStructureTree',()=>{ + it('按 body 父子关系构建结构,并将关节放在所属 body 下',()=>{ + const tree=buildBodyTree(bodies,joints); + expect(tree[0]).toMatchObject({id:1,name:'base'}); + expect(tree[0].children[0]).toMatchObject({id:2,name:'arm'}); + expect(tree[0].children[0].joints[0].name).toBe('arm_joint'); + }); + + it('鼠标进入和离开关节时通知查看器高亮',()=>{ + const hover=vi.fn();render();const item=screen.getByRole('treeitem',{name:/arm_joint/}); + fireEvent.mouseEnter(item);fireEvent.mouseLeave(item);expect(hover.mock.calls).toEqual([[0],[null]]); + }); +}); diff --git a/wasm/web_platform/src/project/ModelStructureTree.tsx b/wasm/web_platform/src/project/ModelStructureTree.tsx new file mode 100644 index 00000000..e78f2089 --- /dev/null +++ b/wasm/web_platform/src/project/ModelStructureTree.tsx @@ -0,0 +1,22 @@ +import {useState} from 'react'; +import type {BodyInfo,JointInfo} from '../simulation/SimulationSession'; + +interface BodyNode extends BodyInfo {children:BodyNode[];joints:JointInfo[];} + +// eslint-disable-next-line react-refresh/only-export-components +export function buildBodyTree(bodies:BodyInfo[],joints:JointInfo[]):BodyNode[]{ + const nodes=new Map();for(const body of bodies)if(body.id>0)nodes.set(body.id,{...body,children:[],joints:joints.filter(joint=>joint.bodyId===body.id)}); + const roots:BodyNode[]=[]; + for(const node of nodes.values()){const parent=nodes.get(node.parentId);if(parent)parent.children.push(node);else roots.push(node);} + const sort=(items:BodyNode[])=>{items.sort((a,b)=>a.id-b.id);for(const item of items)sort(item.children);};sort(roots);return roots; +} + +function BodyBranch({node,depth,onJointHover}:{node:BodyNode;depth:number;onJointHover:(jointId:number|null)=>void}){ + const hasChildren=node.joints.length>0||node.children.length>0;const [open,setOpen]=useState(depth<2); + return
  • {hasChildren?
    setOpen(event.currentTarget.open)}>{node.name}
      {node.joints.map(joint=>
    • onJointHover(joint.id)} onMouseLeave={()=>onJointHover(null)} onFocus={()=>onJointHover(joint.id)} onBlur={()=>onJointHover(null)} title={`关节:${joint.name}`}>{joint.name}
    • )}{node.children.map(child=>)}
    :
    {node.name}
    }
  • ; +} + +export function ModelStructureTree({bodies,joints,onJointHover}:{bodies:BodyInfo[];joints:JointInfo[];onJointHover:(jointId:number|null)=>void}){ + const roots=buildBodyTree(bodies,joints); + return ; +} diff --git a/wasm/web_platform/src/project/ProjectTree.test.tsx b/wasm/web_platform/src/project/ProjectTree.test.tsx index 2d339703..8a19af57 100644 --- a/wasm/web_platform/src/project/ProjectTree.test.tsx +++ b/wasm/web_platform/src/project/ProjectTree.test.tsx @@ -1,4 +1,4 @@ -import {render,screen,within} from '@testing-library/react'; +import {fireEvent,render,screen,within} from '@testing-library/react'; import {buildProjectTree,ProjectTree} from './ProjectTree'; const files=[ @@ -20,12 +20,13 @@ describe('ProjectTree',()=>{ }); it('以可折叠目录显示文件名,而不是平铺完整路径',()=>{ - render(); - const tree=screen.getByRole('navigation',{name:'工程文件树'}); - expect(within(tree).getByText('robot')).toBeVisible(); - expect(within(tree).getByText('meshes')).toBeVisible(); + render(); + const tree=screen.getByRole('navigation',{name:'工程文件树'}),robot=within(tree).getByText('robot'),meshes=within(tree).getByText('meshes'); + expect(robot.closest('details')).toHaveAttribute('open'); + expect(meshes.closest('details')).not.toHaveAttribute('open'); + fireEvent.click(meshes); expect(within(tree).getByText('arm.obj')).toBeVisible(); expect(within(tree).queryByText('robot/meshes/arm.obj')).not.toBeInTheDocument(); - expect(within(tree).getByText('mjcf')).toBeVisible(); + expect(within(tree).getByText('urdf')).toBeVisible(); }); }); diff --git a/wasm/web_platform/src/project/ProjectTree.tsx b/wasm/web_platform/src/project/ProjectTree.tsx index 8e01b45a..f5c7a74c 100644 --- a/wasm/web_platform/src/project/ProjectTree.tsx +++ b/wasm/web_platform/src/project/ProjectTree.tsx @@ -1,3 +1,4 @@ +import {useState} from 'react'; import type {ModelEntry} from './types'; export interface ProjectTreeFile {path:string;size:number;} @@ -52,21 +53,15 @@ function formatSize(bytes:number):string { return `${(bytes/(1024*1024)).toFixed(1)} MB`; } -function TreeNodes({nodes,entryFormats,selectedEntry}:{nodes:ProjectTreeNode[];entryFormats:Map;selectedEntry?:string}){ - return
      - {nodes.map(node=>node.kind==='directory'?
    • -
      - {node.name} - -
      -
    • :
    • - {node.name}{entryFormats.has(node.path)&&{entryFormats.get(node.path)}}{formatSize(node.size??0)} -
    • )} -
    ; +interface TreeNodeProps {entryFormats:Map;selectedEntry?:string;expandedEntry?:string;} +function DirectoryNode({node,entryFormats,selectedEntry,expandedEntry}:TreeNodeProps&{node:ProjectTreeNode}){const [open,setOpen]=useState(Boolean(expandedEntry?.startsWith(`${node.path}/`)));return
  • setOpen(event.currentTarget.open)}>{node.name}
  • ;} +function TreeNodes({nodes,entryFormats,selectedEntry,expandedEntry}:TreeNodeProps&{nodes:ProjectTreeNode[]}){ + return
      {nodes.map(node=>node.kind==='directory'?:
    • {node.name}{entryFormats.has(node.path)&&{entryFormats.get(node.path)}}{formatSize(node.size??0)}
    • )}
    ; } export function ProjectTree({files,entries,selectedEntry}:{files:ProjectTreeFile[];entries:ModelEntry[];selectedEntry?:string}){ const nodes=buildProjectTree(files); const entryFormats=new Map(entries.map(entry=>[entry.path,entry.format])); - return ; + const expandedEntry=entries.some(entry=>entry.path===selectedEntry&&entry.format==='urdf')?selectedEntry:undefined; + return ; } diff --git a/wasm/web_platform/src/simulation/PhysicsAdapter.ts b/wasm/web_platform/src/simulation/PhysicsAdapter.ts index a1ec4532..c70b3d41 100644 --- a/wasm/web_platform/src/simulation/PhysicsAdapter.ts +++ b/wasm/web_platform/src/simulation/PhysicsAdapter.ts @@ -17,7 +17,9 @@ export interface PhysicsAdapter { reset(): void; singleStep(): void; setActuator(id: number, value: number): void; - setJointPosition(id: number, value: number): boolean; + setJointPosition(id:number,value:number):boolean; + resetJoints():void; + setIgnoreJointLimits(ignore:boolean):void; setExternalForce(bodyId: number, force: [number, number, number]): void; clearExternalForce(): void; dispose(): void; @@ -67,6 +69,8 @@ export class MainThreadPhysicsAdapter implements PhysicsAdapter { singleStep():void{this.session?.singleStep();} setActuator(id:number,value:number):void{this.session?.setActuator(id,value);} setJointPosition(id:number,value:number):boolean{return this.session?.setJointPosition(id,value)??false;} + resetJoints():void {this.session?.resetJoints();} + setIgnoreJointLimits(ignore:boolean):void {this.session?.setIgnoreJointLimits(ignore);} setExternalForce(bodyId:number,force:[number,number,number]):void{this.session?.setExternalForce(bodyId,force);} clearExternalForce():void{this.session?.clearExternalForce();} private releaseCurrent():void{this.session?.dispose(); this.session=null; this.workspace?.dispose(); this.workspace=null;} diff --git a/wasm/web_platform/src/simulation/SimulationSession.ts b/wasm/web_platform/src/simulation/SimulationSession.ts index 374c20d3..22db18ae 100644 --- a/wasm/web_platform/src/simulation/SimulationSession.ts +++ b/wasm/web_platform/src/simulation/SimulationSession.ts @@ -2,8 +2,8 @@ import type {MainModule, MjData, MjModel, MjvPerturb, MjvScene} from '@mujoco/mu import {meshIdFromSceneDataId} from './geometry'; export interface ActuatorInfo {id: number; name: string; value: number; min: number; max: number; limited: boolean;} -export interface JointInfo {id: number; name: string; type: number; value: number; min: number; max: number; limited: boolean; editable: boolean; bodyId: number; axis: [number, number, number];} -export interface BodyInfo {id: number; name: string;} +export interface JointInfo {id:number;name:string;type:number;value:number;min:number;max:number;limitMin:number;limitMax:number;limited:boolean;limitsIgnored:boolean;editable:boolean;bodyId:number;axis:[number,number,number];} +export interface BodyInfo {id:number;name:string;parentId:number;} export interface SimulationSnapshot {time: number; qpos: number[]; qvel: number[]; ctrl: number[]; actuators: ActuatorInfo[]; joints: JointInfo[]; bodies: BodyInfo[]; warnings: string[]; model: {nbody: number; njnt: number; ngeom: number; nu: number; nq: number; nv: number};} export interface FrameResult {steps: number; stepMs: number; overBudget: boolean;} @@ -19,7 +19,9 @@ export class SimulationSession { private lastNow?: number; private forceBody = -1; private force: [number, number, number] = [0, 0, 0]; - private disposed = false; + private disposed=false; + private ignoreJointLimits=false; + private jointLimits:{limited:boolean;min:number;max:number;type:number}[]=[]; constructor(readonly module: MainModule, modelPath: string, readonly warnings: string[] = []) { let model: MjModel | undefined; let data: MjData | undefined; let perturb: MjvPerturb | undefined; @@ -28,8 +30,9 @@ export class SimulationSession { if (!model) throw new Error(`MuJoCo 无法编译模型:${modelPath}`); data = new module.MjData(model); perturb = new module.MjvPerturb(); module.mjv_defaultPerturb(perturb); - this.model = model; this.data = data; this.perturb = perturb; - module.mj_forward(model, data); + this.model=model;this.data=data;this.perturb=perturb; + this.jointLimits=Array.from({length:model.njnt},(_,id)=>{const joint=model!.jnt(id);try{return {limited:Boolean(joint.limited),min:Number(joint.range[0]),max:Number(joint.range[1]),type:Number(joint.type)};}finally{joint.delete();}}); + module.mj_forward(model,data); } catch (error) { perturb?.delete(); data?.delete(); model?.delete(); throw error; } } @@ -66,24 +69,15 @@ export class SimulationSession { } } - setJointPosition(id: number, value: number): boolean { - if (id < 0 || id >= this.model.njnt) return false; - const joint = this.model.jnt(id); - try { - const type = Number(joint.type); - if (type !== 2 && type !== 3) return false; - const limited = Boolean(joint.limited); - const min = limited ? Number(joint.range[0]) : -Math.PI; - const max = limited ? Number(joint.range[1]) : Math.PI; - this.setPaused(true); - this.data.qpos[Number(joint.qposadr)] = Math.min(max, Math.max(min, value)); - this.module.mj_forward(this.model, this.data); - return true; - } finally { - joint.delete(); - } + setJointPosition(id:number,value:number):boolean { + if(id<0||id>=this.model.njnt)return false;const joint=this.model.jnt(id); + try{const type=Number(joint.type);if(type!==2&&type!==3)return false;const original=this.jointLimits[id];const next=!this.ignoreJointLimits&&original.limited?Math.min(original.max,Math.max(original.min,value)):value;this.setPaused(true);this.data.qpos[Number(joint.qposadr)]=next;this.module.mj_forward(this.model,this.data);return true;}finally{joint.delete();} } + resetJoints():void {this.setPaused(true);for(let id=0;id 0 && bodyId < this.model.nbody ? bodyId : -1; this.force = force;} clearExternalForce(): void {this.forceBody = -1; this.force = [0, 0, 0]; this.data.xfrc_applied.fill(0); this.perturb.active = 0;} initializePerturb(scene: MjvScene, bodyId: number): void {this.perturb.select = bodyId; this.module.mjv_initPerturb(this.model, this.data, scene, this.perturb);} @@ -157,15 +151,16 @@ export class SimulationSession { const joints = Array.from({length: this.model.njnt}, (_, id): JointInfo => { const joint = this.model.jnt(id); try { - const type=Number(joint.type); const limited=Boolean(joint.limited); - return {id,name:joint.name || `joint_${id}`,type,value:Number(this.data.qpos[Number(joint.qposadr)]),min:limited?Number(joint.range[0]):-Math.PI,max:limited?Number(joint.range[1]):Math.PI,limited,editable:type===2||type===3,bodyId:Number(joint.bodyid),axis:[Number(joint.axis[0]),Number(joint.axis[1]),Number(joint.axis[2])]}; + const original=this.jointLimits[id],type=Number(joint.type),limited=original.limited;let min=limited?original.min:(type===2?-1:-Math.PI),max=limited?original.max:(type===2?1:Math.PI); + if(this.ignoreJointLimits){if(type===3){min=-2*Math.PI;max=2*Math.PI;}else if(type===2){const span=limited?Math.max(.25,original.max-original.min):1;min=limited?original.min-span:-1;max=limited?original.max+span:1;}} + return {id,name:joint.name||`joint_${id}`,type,value:Number(this.data.qpos[Number(joint.qposadr)]),min,max,limitMin:original.min,limitMax:original.max,limited,limitsIgnored:this.ignoreJointLimits,editable:type===2||type===3,bodyId:Number(joint.bodyid),axis:[Number(joint.axis[0]),Number(joint.axis[1]),Number(joint.axis[2])]}; } finally { joint.delete(); } }); const bodies = Array.from({length: this.model.nbody}, (_,id): BodyInfo => { const body = this.model.body(id); - try { return {id,name:body.name || `body_${id}`}; } + try {return {id,name:body.name||`body_${id}`,parentId:Number(this.model.body_parentid[id])};} finally { body.delete(); } }); return {time:Number(this.data.time),qpos:Array.from(this.data.qpos),qvel:Array.from(this.data.qvel),ctrl:Array.from(this.data.ctrl),actuators,joints,bodies,warnings:this.warnings,model:{nbody:this.model.nbody,njnt:this.model.njnt,ngeom:this.model.ngeom,nu:this.model.nu,nq:this.model.nq,nv:this.model.nv}}; diff --git a/wasm/web_platform/src/styles.css b/wasm/web_platform/src/styles.css index e8dce321..4bd9ccd3 100644 --- a/wasm/web_platform/src/styles.css +++ b/wasm/web_platform/src/styles.css @@ -3,3 +3,25 @@ @tailwind utilities; @layer base {html,body,#root{height:100%;margin:0}body{font-family:Inter,"Noto Sans SC",system-ui,sans-serif}button,input,select{font:inherit}} @layer components {.btn{@apply rounded border border-slate-600 bg-slate-800 px-2.5 py-1.5 text-xs text-slate-100 transition hover:border-slate-400 hover:bg-slate-700 disabled:cursor-not-allowed disabled:opacity-40}.icon-btn{@apply btn min-w-8 text-sm}.field{@apply rounded border border-slate-600 bg-slate-800 px-2 py-1 text-xs}.panel{@apply bg-panel}.section{@apply border-b border-slate-700 p-3}.section h3{@apply mb-3 text-xs font-semibold uppercase tracking-wide text-slate-300}.section dt{@apply text-slate-400}.section dd{@apply text-right}.hint{@apply text-xs text-slate-500}} + +/* 主题切换使用柔和过渡,避免大面积背景瞬间翻转造成刺眼。 */ +.theme-light,.theme-dark,.theme-light *,.theme-dark *{transition-property:background-color,color,border-color,box-shadow,opacity;transition-duration:420ms;transition-timing-function:cubic-bezier(.22,1,.36,1)} +@media (prefers-reduced-motion:reduce){.theme-light,.theme-dark,.theme-light *,.theme-dark *{transition-duration:0ms}} + +/* 白天主题覆盖现有 Tailwind 深色工具类,保持诊断、警告和选中状态的语义色。 */ +.theme-light{background:#f1f5f9!important;color:#0f172a!important} +.theme-light header,.theme-light footer,.theme-light .panel,.theme-light [class*="bg-slate-900"],.theme-light [class*="bg-slate-950"]{background-color:#f8fafc!important} +.theme-light .section,.theme-light header,.theme-light footer,.theme-light aside,.theme-light [class*="border-slate-"]{border-color:#cbd5e1!important} +.theme-light .btn,.theme-light .field{border-color:#94a3b8!important;background:#fff!important;color:#0f172a!important} +.theme-light .btn:hover,.theme-light .icon-btn:hover{border-color:#64748b!important;background:#e2e8f0!important} +.theme-light [class*="text-slate-"]{color:#475569!important} +.theme-light .text-slate-100,.theme-light .text-slate-300{color:#1e293b!important} +.theme-light .text-slate-500{color:#64748b!important} +.theme-light .section h3{color:#334155!important} +.theme-light .hint{color:#64748b!important} +.theme-light [class*="hover:bg-slate-800"]:hover{background-color:#e2e8f0!important} +.theme-light [class*="bg-green-900"]{background-color:rgba(220,252,231,.8)!important} +.theme-light [class*="bg-red-950"]{background-color:rgba(254,226,226,.96)!important} +.theme-light [class*="text-red-"]{color:#991b1b!important} +.theme-light [class*="bg-amber-950"]{background-color:rgba(254,243,199,.7)!important} +.theme-light [class*="text-amber-"]{color:#92400e!important} diff --git a/wasm/web_platform/src/viewer/MuJoCoViewer.ts b/wasm/web_platform/src/viewer/MuJoCoViewer.ts index 540b7a2c..dad37721 100644 --- a/wasm/web_platform/src/viewer/MuJoCoViewer.ts +++ b/wasm/web_platform/src/viewer/MuJoCoViewer.ts @@ -6,6 +6,7 @@ import {meshIdFromSceneDataId} from '../simulation/geometry'; import {OrientationGizmo} from './OrientationGizmo'; export type InteractionMode = 'select' | 'joint' | 'force'; +export type ViewerTheme='light'|'dark'; export interface ViewerSelection {bodyId: number; geomId: number; bodyName: string; geomType: number; position: [number, number, number];} interface ViewerCallbacks {onSelection(selection: ViewerSelection | null): void; onFrame(frame: FrameResult, fps: number, snapshot?: SimulationSnapshot): void; onError(error: Error): void;} @@ -27,14 +28,17 @@ export class MuJoCoViewer { private mjScene: MjvScene | null = null; private frame = 0; private lastFpsAt=performance.now(); private fpsFrames=0; private lastSnapshotAt=0; private meshes: THREE.Mesh[]=[]; private geometries=new Map(); private textures=new Map(); - private raycaster=new THREE.Raycaster(); private pointer=new THREE.Vector2(); private selected:THREE.Mesh|null=null; private dragStart:THREE.Vector2|null=null; private dragAxis=new THREE.Vector2(1,0); private dragJointId=-1; private dragJointValue=0; private arrow:THREE.ArrowHelper|null=null; + private raycaster=new THREE.Raycaster(); private pointer=new THREE.Vector2(); private selected:THREE.Mesh|null=null; private dragStart:THREE.Vector2|null=null; private dragAxis=new THREE.Vector2(1,0); private dragJointId=-1; private dragJointValue=0; private arrow:THREE.ArrowHelper|null=null;private highlightedJointId=-1;private highlightedBodyId=-1;private jointMarker:THREE.Mesh|null=null; private resizeObserver:ResizeObserver; private orientationGizmo:OrientationGizmo; + private grid:THREE.GridHelper; + private hemisphere:THREE.HemisphereLight; + private themeTransition?:{started:number;fromBackground:THREE.Color;toBackground:THREE.Color;fromGround:THREE.Color;toGround:THREE.Color;}; constructor(private readonly host:HTMLElement, private readonly callbacks:ViewerCallbacks) { this.renderer=new THREE.WebGLRenderer({antialias:true,alpha:false}); this.renderer.setPixelRatio(Math.min(devicePixelRatio,2)); this.renderer.shadowMap.enabled=true; this.renderer.outputColorSpace=THREE.SRGBColorSpace; host.append(this.renderer.domElement); this.camera.up.set(0,0,1); this.camera.position.set(3,-3,2); this.controls=new OrbitControls(this.camera,this.renderer.domElement); this.controls.enableDamping=true; - this.scene.background=new THREE.Color(0x0b1220); this.scene.add(new THREE.HemisphereLight(0xffffff,0x223344,1.3)); const light=new THREE.DirectionalLight(0xffffff,2); light.position.set(4,-3,7); light.castShadow=true; this.scene.add(light); this.scene.add(new THREE.GridHelper(20,40,0x3b82f6,0x253047).rotateX(Math.PI/2));this.orientationGizmo=new OrientationGizmo(host);this.orientationGizmo.update(this.camera); + this.scene.background=new THREE.Color(0x0b1220);this.hemisphere=new THREE.HemisphereLight(0xffffff,0x223344,1.3);this.scene.add(this.hemisphere); const light=new THREE.DirectionalLight(0xffffff,2); light.position.set(4,-3,7); light.castShadow=true; this.scene.add(light);this.grid=new THREE.GridHelper(20,40,0x3b82f6,0x253047).rotateX(Math.PI/2);this.scene.add(this.grid);this.orientationGizmo=new OrientationGizmo(host);this.orientationGizmo.update(this.camera); this.resizeObserver=new ResizeObserver(()=>this.resize()); this.resizeObserver.observe(host); this.resize(); this.renderer.domElement.addEventListener('pointerdown',this.onPointerDown); this.renderer.domElement.addEventListener('pointermove',this.onPointerMove); window.addEventListener('pointerup',this.onPointerUp); this.frame=requestAnimationFrame(this.animate); @@ -43,13 +47,18 @@ export class MuJoCoViewer { attach(session:SimulationSession|null):void {this.releaseModel(); this.session=session; if(!session)return; this.option=new session.module.MjvOption(); session.module.mjv_defaultOption(this.option);this.applyGeomVisibility(); this.mjCamera=new session.module.MjvCamera(); session.module.mjv_defaultCamera(this.mjCamera); this.mjScene=new session.module.MjvScene(session.model,32768); this.fitCamera(session);} setMode(mode:InteractionMode):void {this.mode=mode; this.controls.enabled=mode==='select'; this.stopDrag();} setShowCollision(value:boolean):void {this.showCollision=value;this.applyGeomVisibility();} + setTheme(theme:ViewerTheme):void {const light=theme==='light';const background=this.scene.background instanceof THREE.Color?this.scene.background:new THREE.Color(0x0b1220);this.scene.background=background;this.themeTransition={started:performance.now(),fromBackground:background.clone(),toBackground:new THREE.Color(light?0xf8fafc:0x0b1220),fromGround:this.hemisphere.groundColor.clone(),toGround:new THREE.Color(light?0xcbd5e1:0x223344)};const next=new THREE.GridHelper(20,40,light?0x64748b:0x3b82f6,light?0xcbd5e1:0x253047).rotateX(Math.PI/2);this.scene.remove(this.grid);this.grid.geometry.dispose();const materials=Array.isArray(this.grid.material)?this.grid.material:[this.grid.material];for(const material of materials)material.dispose();this.grid=next;this.scene.add(this.grid);this.orientationGizmo.setTheme(theme);} + private updateThemeTransition(now:number):void {const transition=this.themeTransition;if(!transition)return;const progress=Math.min(1,Math.max(0,(now-transition.started)/420)),eased=1-Math.pow(1-progress,3);(this.scene.background as THREE.Color).copy(transition.fromBackground).lerp(transition.toBackground,eased);this.hemisphere.groundColor.copy(transition.fromGround).lerp(transition.toGround,eased);if(progress===1)this.themeTransition=undefined;} private applyGeomVisibility():void {if(!this.option||!this.session)return;let hasVisual=false;for(let index=0;index=0&&jointId{try {const result=this.session?.advance(now)??{steps:0,stepMs:0,overBudget:false}; this.controls.update();this.orientationGizmo.update(this.camera); if(this.session)this.updateMuJoCoScene(); this.renderer.render(this.scene,this.camera); this.fpsFrames++; let fps=0;if(now-this.lastFpsAt>=500){fps=this.fpsFrames*1000/(now-this.lastFpsAt);this.fpsFrames=0;this.lastFpsAt=now;} const snapshot=this.session&&now-this.lastSnapshotAt>150?(this.lastSnapshotAt=now,this.session.snapshot()):undefined; this.callbacks.onFrame(result,fps,snapshot);}catch(error){this.callbacks.onError(error instanceof Error?error:new Error(String(error)));} this.frame=requestAnimationFrame(this.animate);}; + private animate=(now:number):void=>{try {const result=this.session?.advance(now)??{steps:0,stepMs:0,overBudget:false};this.updateThemeTransition(now); this.controls.update();this.orientationGizmo.update(this.camera); if(this.session){this.updateMuJoCoScene();this.updateJointMarker();} this.renderer.render(this.scene,this.camera); this.fpsFrames++; let fps=0;if(now-this.lastFpsAt>=500){fps=this.fpsFrames*1000/(now-this.lastFpsAt);this.fpsFrames=0;this.lastFpsAt=now;} const snapshot=this.session&&now-this.lastSnapshotAt>150?(this.lastSnapshotAt=now,this.session.snapshot()):undefined; this.callbacks.onFrame(result,fps,snapshot);}catch(error){this.callbacks.onError(error instanceof Error?error:new Error(String(error)));} this.frame=requestAnimationFrame(this.animate);}; private updateMuJoCoScene():void {const s=this.session!; s.module.mjv_updateScene(s.model,s.data,this.option!,s.perturb,this.mjCamera!,s.module.mjtCatBit.mjCAT_ALL.value,this.mjScene!); const geoms=this.mjScene!.geoms; try {for(let i=0;i=0){const uv=new Float32Array(tn*2);for(let i=0;i=0?Number(this.session!.model.geom_bodyid[geomId]):-1;mesh.userData.geomId=geomId;mesh.userData.bodyId=bodyId;mesh.userData.geomType=g.type;} + private updateMesh(mesh:THREE.Mesh,g:MjvGeom):void {const mat=mesh.material as THREE.MeshStandardMaterial;mat.color.setRGB(g.rgba[0],g.rgba[1],g.rgba[2]);mat.opacity=g.rgba[3];mat.transparent=g.rgba[3]<1;mesh.matrix.set(g.mat[0],g.mat[1],g.mat[2],g.pos[0],g.mat[3],g.mat[4],g.mat[5],g.pos[1],g.mat[6],g.mat[7],g.mat[8],g.pos[2],0,0,0,1);mesh.matrixWorldNeedsUpdate=true;const geomId=g.objtype===this.session!.module.mjtObj.mjOBJ_GEOM.value?g.objid:-1;const bodyId=geomId>=0?Number(this.session!.model.geom_bodyid[geomId]):-1;mesh.userData.geomId=geomId;mesh.userData.bodyId=bodyId;mesh.userData.geomType=g.type;this.applyMeshHighlight(mesh);} private eventPointer(event:PointerEvent):void {const r=this.renderer.domElement.getBoundingClientRect();this.pointer.set((event.clientX-r.left)/r.width*2-1,-((event.clientY-r.top)/r.height)*2+1);} private onPointerDown=(event:PointerEvent):void=>{this.eventPointer(event);this.raycaster.setFromCamera(this.pointer,this.camera);const hit=this.raycaster.intersectObjects(this.meshes.filter(m=>m.visible),false)[0];if(!hit)return;const mesh=hit.object as THREE.Mesh;this.select(mesh);const bodyId=Number(mesh.userData.bodyId);if(this.mode==='joint'){const joint=this.session?.snapshot().joints.find(j=>j.bodyId===bodyId&&j.editable);if(joint){this.dragStart=this.pointer.clone();this.dragJointId=joint.id;this.dragJointValue=joint.value;const p=joint.bodyId*3,xm=joint.bodyId*9;const origin=new THREE.Vector3(this.session!.data.xpos[p],this.session!.data.xpos[p+1],this.session!.data.xpos[p+2]);const local=new THREE.Vector3(...joint.axis);const axis=new THREE.Vector3(this.session!.data.xmat[xm]*local.x+this.session!.data.xmat[xm+1]*local.y+this.session!.data.xmat[xm+2]*local.z,this.session!.data.xmat[xm+3]*local.x+this.session!.data.xmat[xm+4]*local.y+this.session!.data.xmat[xm+5]*local.z,this.session!.data.xmat[xm+6]*local.x+this.session!.data.xmat[xm+7]*local.y+this.session!.data.xmat[xm+8]*local.z);const a=origin.clone().project(this.camera),b=origin.clone().add(axis).project(this.camera);this.dragAxis.set(b.x-a.x,b.y-a.y);if(this.dragAxis.lengthSq()<1e-6)this.dragAxis.set(1,0);else this.dragAxis.normalize();}}else if(this.mode==='force'&&bodyId>0){this.dragStart=this.pointer.clone();this.session?.initializePerturb(this.mjScene!,bodyId);this.showArrow(hit.point);this.renderer.domElement.setPointerCapture(event.pointerId);}}; private onPointerMove=(event:PointerEvent):void=>{if(!this.dragStart||!this.session)return;this.eventPointer(event);const dx=this.pointer.x-this.dragStart.x,dy=this.pointer.y-this.dragStart.y;if(this.mode==='joint'&&this.dragJointId>=0)this.session.setJointPosition(this.dragJointId,this.dragJointValue+(dx*this.dragAxis.x+dy*this.dragAxis.y)*Math.PI);else if(this.mode==='force'&&this.selected){const bodyId=Number(this.selected.userData.bodyId),scale=this.forceScale;const force:[number,number,number]=[dx*scale,0,-dy*scale];this.session.setExternalForce(bodyId,force);this.updateArrow(force);}}; private onPointerUp=():void=>{this.stopDrag();}; private stopDrag():void {this.dragStart=null;this.dragJointId=-1;this.session?.clearExternalForce();if(this.arrow){this.scene.remove(this.arrow);this.arrow.dispose();this.arrow=null;}} - private select(mesh:THREE.Mesh):void {if(this.selected){const old=this.selected.material as THREE.MeshStandardMaterial;old.emissive.setHex(0);}this.selected=mesh;const material=mesh.material as THREE.MeshStandardMaterial;material.emissive.setHex(0x14532d);const bodyId=Number(mesh.userData.bodyId),geomId=Number(mesh.userData.geomId);const name=this.session?.snapshot().bodies.find(b=>b.id===bodyId)?.name??`body_${bodyId}`;const e=mesh.matrix.elements;this.callbacks.onSelection({bodyId,geomId,bodyName:name,geomType:Number(mesh.userData.geomType),position:[e[12],e[13],e[14]]});} + private select(mesh:THREE.Mesh):void {const previous=this.selected;this.selected=mesh;if(previous)this.applyMeshHighlight(previous);this.applyMeshHighlight(mesh);const bodyId=Number(mesh.userData.bodyId),geomId=Number(mesh.userData.geomId);const name=this.session?.snapshot().bodies.find(b=>b.id===bodyId)?.name??`body_${bodyId}`;const e=mesh.matrix.elements;this.callbacks.onSelection({bodyId,geomId,bodyName:name,geomType:Number(mesh.userData.geomType),position:[e[12],e[13],e[14]]});} private showArrow(origin:THREE.Vector3):void {this.arrow=new THREE.ArrowHelper(new THREE.Vector3(1,0,0),origin,0.01,0xf97316);this.scene.add(this.arrow);} private updateArrow(force:[number,number,number]):void {if(!this.arrow)return;const v=new THREE.Vector3(...force),length=v.length()/25;if(length>1e-6){this.arrow.setDirection(v.normalize());this.arrow.setLength(length,Math.min(.2,length*.2),Math.min(.1,length*.1));}} private disposeMesh(mesh:THREE.Mesh):void {(mesh.material as THREE.Material).dispose();} - private releaseModel():void {this.stopDrag();for(const mesh of this.meshes){this.scene.remove(mesh);this.disposeMesh(mesh);}this.meshes=[];for(const g of this.geometries.values())g.dispose();this.geometries.clear();for(const t of this.textures.values())t.dispose();this.textures.clear();this.mjScene?.delete();this.mjCamera?.delete();this.option?.delete();this.mjScene=null;this.mjCamera=null;this.option=null;this.session=null;this.selected=null;} - dispose():void {cancelAnimationFrame(this.frame);this.releaseModel();this.resizeObserver.disconnect();this.renderer.domElement.removeEventListener('pointerdown',this.onPointerDown);this.renderer.domElement.removeEventListener('pointermove',this.onPointerMove);window.removeEventListener('pointerup',this.onPointerUp);this.controls.dispose();this.orientationGizmo.dispose();this.renderer.dispose();this.renderer.domElement.remove();} + private releaseModel():void {this.stopDrag();for(const mesh of this.meshes){this.scene.remove(mesh);this.disposeMesh(mesh);}this.meshes=[];for(const g of this.geometries.values())g.dispose();this.geometries.clear();for(const t of this.textures.values())t.dispose();this.textures.clear();this.mjScene?.delete();this.mjCamera?.delete();this.option?.delete();this.mjScene=null;this.mjCamera=null;this.option=null;this.session=null;this.selected=null;this.highlightedJointId=-1;this.highlightedBodyId=-1;if(this.jointMarker){this.scene.remove(this.jointMarker);this.jointMarker.geometry.dispose();(this.jointMarker.material as THREE.Material).dispose();this.jointMarker=null;}} + dispose():void {cancelAnimationFrame(this.frame);this.releaseModel();this.resizeObserver.disconnect();this.renderer.domElement.removeEventListener('pointerdown',this.onPointerDown);this.renderer.domElement.removeEventListener('pointermove',this.onPointerMove);window.removeEventListener('pointerup',this.onPointerUp);this.controls.dispose();this.orientationGizmo.dispose();this.grid.geometry.dispose();const gridMaterials=Array.isArray(this.grid.material)?this.grid.material:[this.grid.material];for(const material of gridMaterials)material.dispose();this.renderer.dispose();this.renderer.domElement.remove();} } diff --git a/wasm/web_platform/src/viewer/OrientationGizmo.ts b/wasm/web_platform/src/viewer/OrientationGizmo.ts index 8588ec4a..83dd89e2 100644 --- a/wasm/web_platform/src/viewer/OrientationGizmo.ts +++ b/wasm/web_platform/src/viewer/OrientationGizmo.ts @@ -11,6 +11,7 @@ export class OrientationGizmo { private readonly axes:AxisElements[]=[]; private readonly inverseQuaternion=new THREE.Quaternion(); private readonly projected=new THREE.Vector3(); + private readonly center:SVGCircleElement; constructor(host:HTMLElement){ this.element=svgElement('svg');this.element.setAttribute('viewBox','0 0 100 100');this.element.setAttribute('role','img');this.element.setAttribute('aria-label','XYZ 方向指示器'); @@ -23,7 +24,7 @@ export class OrientationGizmo { const label=svgElement('text');label.textContent=name;label.setAttribute('fill','#0f172a');label.setAttribute('font-size','10');label.setAttribute('font-weight','700');label.setAttribute('text-anchor','middle');label.setAttribute('dominant-baseline','central'); group.append(line,negative,positive,label);this.element.append(group);this.axes.push({axis,line,negative,positive,label}); } - const center=svgElement('circle');center.setAttribute('cx','50');center.setAttribute('cy','50');center.setAttribute('r','3.5');center.setAttribute('fill','#cbd5e1');this.element.append(center);host.append(this.element); + this.center=svgElement('circle');this.center.setAttribute('cx','50');this.center.setAttribute('cy','50');this.center.setAttribute('r','3.5');this.center.setAttribute('fill','#cbd5e1');this.element.append(this.center);host.append(this.element); } update(camera:THREE.Camera):void { @@ -36,5 +37,9 @@ export class OrientationGizmo { } } + setTheme(theme:'light'|'dark'):void { + const light=theme==='light';this.element.style.background=light?'rgba(248,250,252,.82)':'rgba(15,23,42,.72)';this.element.style.borderColor=light?'rgba(100,116,139,.35)':'rgba(100,116,139,.45)';this.center.setAttribute('fill',light?'#475569':'#cbd5e1'); + } + dispose():void {this.element.remove();} } -- 2.52.0 From aa62ec0c93cb55a26909f6984505ec10bac9a804 Mon Sep 17 00:00:00 2001 From: cen617-code <1057290604@qq.com> Date: Fri, 21 Aug 2026 16:52:22 +0800 Subject: [PATCH 2/2] feat(web-platform): release V0.2 --- wasm/package-lock.json | 10 +++ wasm/package.json | 1 + wasm/web_platform/e2e/app.spec.ts | 40 +++++++-- wasm/web_platform/src/app/App.tsx | 81 +++++++++++++------ wasm/web_platform/src/app/ErrorBoundary.tsx | 5 +- .../src/app/components/CommandPalette.tsx | 12 +++ .../src/app/components/DiagnosticNotice.tsx | 5 ++ .../src/app/components/DiagnosticsDrawer.tsx | 6 ++ .../components/EntrySelectionDialog.test.tsx | 4 + .../app/components/EntrySelectionDialog.tsx | 4 + .../src/app/components/ErrorRecoveryPanel.tsx | 5 ++ .../components/FeedbackComponents.test.tsx | 5 ++ .../components/FifthBatchComponents.test.tsx | 12 +++ .../components/FourthBatchComponents.test.tsx | 13 +++ .../app/components/LayoutSettingsDialog.tsx | 7 ++ .../src/app/components/NotificationCenter.tsx | 7 ++ .../src/app/components/PerformancePopover.tsx | 3 + .../src/app/components/ProjectBreadcrumb.tsx | 4 + .../components/SecondBatchComponents.test.tsx | 10 +++ .../src/app/components/SettingsDialog.tsx | 3 + .../src/app/components/ShortcutHelpDialog.tsx | 3 + .../src/app/components/SidebarPanel.tsx | 34 ++++++++ .../src/app/components/StatusBar.tsx | 7 ++ .../components/ThirdBatchComponents.test.tsx | 8 ++ .../app/components/ToolbarOverflowMenu.tsx | 3 + .../src/app/components/TreeSearchField.tsx | 3 + .../src/app/components/ViewerToolDock.tsx | 5 ++ .../src/app/components/ViewportHUD.tsx | 5 ++ .../app/components/WorkbenchHeader.test.tsx | 4 + .../src/app/components/WorkbenchHeader.tsx | 5 ++ .../src/app/components/WorkspaceOverlays.tsx | 5 ++ wasm/web_platform/src/components/ui/Badge.tsx | 2 + .../web_platform/src/components/ui/Button.tsx | 18 +++++ .../components/ui/CollapsibleSection.test.tsx | 3 + .../src/components/ui/CollapsibleSection.tsx | 11 +++ .../src/components/ui/ConfirmDialog.tsx | 4 + .../src/components/ui/CopyButton.tsx | 4 + .../src/components/ui/Dialog.test.tsx | 6 ++ .../web_platform/src/components/ui/Dialog.tsx | 14 ++++ .../src/components/ui/DropdownMenu.tsx | 7 ++ .../src/components/ui/EmptySearchState.tsx | 2 + .../src/components/ui/FifthBatchUi.test.tsx | 7 ++ .../src/components/ui/IconButton.tsx | 8 ++ wasm/web_platform/src/components/ui/Kbd.tsx | 2 + .../src/components/ui/LiveRegion.tsx | 2 + .../src/components/ui/Popover.tsx | 3 + .../src/components/ui/ProgressBar.tsx | 1 + .../src/components/ui/PropertyRow.tsx | 2 + .../src/components/ui/ResizablePanel.tsx | 8 ++ .../src/components/ui/SearchHighlight.tsx | 1 + .../src/components/ui/SearchableCombobox.tsx | 5 ++ .../src/components/ui/SecondBatchUi.test.tsx | 9 +++ .../web_platform/src/components/ui/Select.tsx | 2 + .../src/components/ui/Separator.tsx | 1 + .../src/components/ui/Skeleton.tsx | 1 + wasm/web_platform/src/components/ui/Tabs.tsx | 3 + .../src/components/ui/ThirdBatchUi.test.tsx | 8 ++ .../src/components/ui/ToolbarToggleGroup.tsx | 6 ++ .../src/components/ui/Tooltip.tsx | 9 +++ .../ui/VirtualTreeViewport.test.tsx | 3 + .../src/components/ui/VirtualTreeViewport.tsx | 2 + wasm/web_platform/src/components/ui/index.ts | 24 ++++++ .../src/project/ModelStructureTree.test.tsx | 8 +- .../src/project/ModelStructureTree.tsx | 22 +++-- .../src/project/ProjectTree.test.tsx | 9 ++- wasm/web_platform/src/project/ProjectTree.tsx | 23 ++++-- wasm/web_platform/src/styles.css | 55 +++++++------ wasm/web_platform/tailwind.config.cjs | 14 +++- 68 files changed, 558 insertions(+), 70 deletions(-) create mode 100644 wasm/web_platform/src/app/components/CommandPalette.tsx create mode 100644 wasm/web_platform/src/app/components/DiagnosticNotice.tsx create mode 100644 wasm/web_platform/src/app/components/DiagnosticsDrawer.tsx create mode 100644 wasm/web_platform/src/app/components/EntrySelectionDialog.test.tsx create mode 100644 wasm/web_platform/src/app/components/EntrySelectionDialog.tsx create mode 100644 wasm/web_platform/src/app/components/ErrorRecoveryPanel.tsx create mode 100644 wasm/web_platform/src/app/components/FeedbackComponents.test.tsx create mode 100644 wasm/web_platform/src/app/components/FifthBatchComponents.test.tsx create mode 100644 wasm/web_platform/src/app/components/FourthBatchComponents.test.tsx create mode 100644 wasm/web_platform/src/app/components/LayoutSettingsDialog.tsx create mode 100644 wasm/web_platform/src/app/components/NotificationCenter.tsx create mode 100644 wasm/web_platform/src/app/components/PerformancePopover.tsx create mode 100644 wasm/web_platform/src/app/components/ProjectBreadcrumb.tsx create mode 100644 wasm/web_platform/src/app/components/SecondBatchComponents.test.tsx create mode 100644 wasm/web_platform/src/app/components/SettingsDialog.tsx create mode 100644 wasm/web_platform/src/app/components/ShortcutHelpDialog.tsx create mode 100644 wasm/web_platform/src/app/components/SidebarPanel.tsx create mode 100644 wasm/web_platform/src/app/components/StatusBar.tsx create mode 100644 wasm/web_platform/src/app/components/ThirdBatchComponents.test.tsx create mode 100644 wasm/web_platform/src/app/components/ToolbarOverflowMenu.tsx create mode 100644 wasm/web_platform/src/app/components/TreeSearchField.tsx create mode 100644 wasm/web_platform/src/app/components/ViewerToolDock.tsx create mode 100644 wasm/web_platform/src/app/components/ViewportHUD.tsx create mode 100644 wasm/web_platform/src/app/components/WorkbenchHeader.test.tsx create mode 100644 wasm/web_platform/src/app/components/WorkbenchHeader.tsx create mode 100644 wasm/web_platform/src/app/components/WorkspaceOverlays.tsx create mode 100644 wasm/web_platform/src/components/ui/Badge.tsx create mode 100644 wasm/web_platform/src/components/ui/Button.tsx create mode 100644 wasm/web_platform/src/components/ui/CollapsibleSection.test.tsx create mode 100644 wasm/web_platform/src/components/ui/CollapsibleSection.tsx create mode 100644 wasm/web_platform/src/components/ui/ConfirmDialog.tsx create mode 100644 wasm/web_platform/src/components/ui/CopyButton.tsx create mode 100644 wasm/web_platform/src/components/ui/Dialog.test.tsx create mode 100644 wasm/web_platform/src/components/ui/Dialog.tsx create mode 100644 wasm/web_platform/src/components/ui/DropdownMenu.tsx create mode 100644 wasm/web_platform/src/components/ui/EmptySearchState.tsx create mode 100644 wasm/web_platform/src/components/ui/FifthBatchUi.test.tsx create mode 100644 wasm/web_platform/src/components/ui/IconButton.tsx create mode 100644 wasm/web_platform/src/components/ui/Kbd.tsx create mode 100644 wasm/web_platform/src/components/ui/LiveRegion.tsx create mode 100644 wasm/web_platform/src/components/ui/Popover.tsx create mode 100644 wasm/web_platform/src/components/ui/ProgressBar.tsx create mode 100644 wasm/web_platform/src/components/ui/PropertyRow.tsx create mode 100644 wasm/web_platform/src/components/ui/ResizablePanel.tsx create mode 100644 wasm/web_platform/src/components/ui/SearchHighlight.tsx create mode 100644 wasm/web_platform/src/components/ui/SearchableCombobox.tsx create mode 100644 wasm/web_platform/src/components/ui/SecondBatchUi.test.tsx create mode 100644 wasm/web_platform/src/components/ui/Select.tsx create mode 100644 wasm/web_platform/src/components/ui/Separator.tsx create mode 100644 wasm/web_platform/src/components/ui/Skeleton.tsx create mode 100644 wasm/web_platform/src/components/ui/Tabs.tsx create mode 100644 wasm/web_platform/src/components/ui/ThirdBatchUi.test.tsx create mode 100644 wasm/web_platform/src/components/ui/ToolbarToggleGroup.tsx create mode 100644 wasm/web_platform/src/components/ui/Tooltip.tsx create mode 100644 wasm/web_platform/src/components/ui/VirtualTreeViewport.test.tsx create mode 100644 wasm/web_platform/src/components/ui/VirtualTreeViewport.tsx create mode 100644 wasm/web_platform/src/components/ui/index.ts diff --git a/wasm/package-lock.json b/wasm/package-lock.json index d67e7b2a..7edd2e56 100644 --- a/wasm/package-lock.json +++ b/wasm/package-lock.json @@ -11,6 +11,7 @@ "dependencies": { "@mujoco/mujoco": "^3.11.0", "fflate": "^0.8.3", + "lucide-react": "^0.555.0", "react": "^19.2.8", "react-dom": "^19.2.8", "zustand": "^5.0.15" @@ -3764,6 +3765,15 @@ "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", "dev": true }, + "node_modules/lucide-react": { + "version": "0.555.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.555.0.tgz", + "integrity": "sha512-D8FvHUGbxWBRQM90NZeIyhAvkFfsh3u9ekrMvJ30Z6gnpBHS6HC6ldLg7tL45hwiIz/u66eKDtdA23gwwGsAHA==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/lz-string": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", diff --git a/wasm/package.json b/wasm/package.json index ebd08bf2..44f8981d 100644 --- a/wasm/package.json +++ b/wasm/package.json @@ -58,6 +58,7 @@ "dependencies": { "@mujoco/mujoco": "^3.11.0", "fflate": "^0.8.3", + "lucide-react": "^0.555.0", "react": "^19.2.8", "react-dom": "^19.2.8", "zustand": "^5.0.15" diff --git a/wasm/web_platform/e2e/app.spec.ts b/wasm/web_platform/e2e/app.spec.ts index 4e454a01..a38c7753 100644 --- a/wasm/web_platform/e2e/app.spec.ts +++ b/wasm/web_platform/e2e/app.spec.ts @@ -38,13 +38,31 @@ test('显示中文平台骨架并加载单文件模型', async ({page}) => { page.on('requestfailed', (request) => console.log(`[browser:requestfailed] ${request.url()} ${request.failure()?.errorText}`)); page.on('response', (response) => { if (response.status() >= 400 || response.url().endsWith('.wasm')) console.log(`[browser:response] ${response.status()} ${response.url()} ${response.headers()['content-type'] ?? ''}`); }); await page.goto('/'); + await page.setViewportSize({width:1024,height:768}); + const toolBox=await page.getByRole('toolbar',{name:'视口交互模式'}).boundingBox(),playBox=await page.getByRole('button',{name:'▶ 播放'}).boundingBox(); + expect(toolBox&&playBox&&toolBox.x+toolBox.width<=playBox.x).toBeTruthy(); + await page.getByRole('button',{name:'更多工作台操作'}).click();await expect(page.getByRole('menuitem',{name:'工作台设置'})).toBeVisible();await page.keyboard.press('Escape'); + await page.setViewportSize({width:1440,height:900}); await expect(page.getByRole('heading', {name: 'MuJoCo Web 仿真平台'})).toBeVisible(); await expect(page.getByRole('main').getByText('拖放模型工程到此处')).toBeVisible(); await expect(page.getByRole('img',{name:'XYZ 方向指示器'})).toBeVisible(); - await expect(page.getByRole('button',{name:'切换到白天主题'})).toHaveText('☀'); + await expect(page.getByRole('button',{name:'切换到白天主题'})).toBeVisible(); + await page.getByRole('button',{name:'布局设置'}).click();await expect(page.getByRole('dialog',{name:'布局设置'})).toBeVisible();await page.keyboard.press('Escape'); + await page.getByRole('button',{name:'工作台设置'}).click();await expect(page.getByRole('dialog',{name:'工作台设置'})).toBeVisible();await page.keyboard.press('Escape'); + await page.keyboard.press('Control+k'); + await expect(page.getByRole('dialog',{name:'命令面板'})).toBeVisible(); + await page.getByLabel('搜索命令').fill('复位相机'); + await expect(page.getByRole('option',{name:/复位相机/})).toBeVisible(); + await page.keyboard.press('Escape'); + await page.getByRole('button',{name:'进入全屏'}).click(); + await expect(page.getByRole('button',{name:'退出全屏'})).toBeVisible(); + await page.keyboard.press('Control+k'); + await expect(page.getByRole('dialog',{name:'命令面板'})).toBeVisible(); + await page.keyboard.press('Escape'); + await page.getByRole('button',{name:'退出全屏'}).click(); await page.getByRole('button',{name:'切换到白天主题'}).click(); await expect(page.locator('#root > div')).toHaveClass(/theme-light/); - await expect(page.getByRole('button',{name:'切换到黑夜主题'})).toHaveText('☾'); + await expect(page.getByRole('button',{name:'切换到黑夜主题'})).toBeVisible(); await page.getByRole('button',{name:'切换到黑夜主题'}).click(); await expect(page.locator('#root > div')).toHaveClass(/theme-dark/); @@ -55,8 +73,15 @@ test('显示中文平台骨架并加载单文件模型', async ({page}) => { }); await expect(page.getByText('WASM 已加载')).toBeVisible({timeout: 30_000}); + await page.getByRole('button',{name:'通知中心'}).click();await expect(page.getByRole('dialog',{name:'通知中心'})).toContainText('模型加载完成');await page.getByText('事件日志').click();await expect(page.getByRole('dialog',{name:'诊断与事件日志'})).toBeVisible();await page.keyboard.press('Escape'); + await page.getByRole('button',{name:/FPS .*物理/}).click(); + await expect(page.getByRole('dialog',{name:'性能详情'})).toBeVisible(); + await page.keyboard.press('Escape'); + await page.getByRole('tab',{name:'控制'}).click(); + await page.getByRole('button',{name:'Actuator'}).click(); await expect(page.getByText('motor')).toBeVisible(); - await expect(page.getByText('slide').first()).toBeVisible(); + await expect(page.getByRole('tabpanel',{name:'控制'}).getByText('slide',{exact:true})).toBeVisible(); + await page.getByRole('tab',{name:'模型结构'}).click(); const structure=page.getByRole('navigation',{name:'模型结构树'});await expect(structure).toBeVisible();await structure.getByRole('treeitem',{name:/hinge/}).hover(); await expect(page.getByRole('alert')).toHaveCount(0); await expect(page.getByRole('button',{name:'重置关节'})).toBeVisible(); @@ -94,9 +119,14 @@ test('加载引用 OBJ 的 URDF 工程', async ({page}) => { ]); await expect(page.getByText('WASM 已加载')).toBeVisible({timeout: 30_000}); await expect(page.getByText('2 个文件')).toBeVisible(); + await page.getByRole('button',{name:'URDF 处理方式'}).click(); await expect(page.getByLabel('URDF 处理方式')).toHaveValue('mjcf'); await expect(page.getByLabel('URDF 基座类型')).toHaveValue('floating'); - await expect(page.getByText(/URDF 已转换为 MJCF(浮动基座),并整体平移/)).toBeVisible(); + await page.getByRole('button',{name:'通知中心'}).click(); + const notifications=page.getByRole('dialog',{name:'通知中心'}); + await expect(notifications).toContainText('URDF 兼容处理'); + await expect(notifications).toContainText(/URDF 已转换为 MJCF(浮动基座),并整体平移/); + await page.keyboard.press('Escape'); await expect(page.getByLabel('显示碰撞几何')).not.toBeChecked(); }); @@ -111,7 +141,7 @@ test('中等规模模型持续步进并可重复加载', async ({page}) => { await expect(page.locator('footer')).not.toContainText('时间 0.000 s'); // 播放过程中重置必须同时暂停底层会话,之后仍可正常播放和暂停。 - await page.getByRole('button',{name:'重置'}).click(); + await page.getByRole('button',{name:'重置',exact:true}).click(); await expect(page.getByRole('button',{name:'▶ 播放'})).toBeVisible(); await expect(page.locator('footer')).toContainText('时间 0.000 s'); await page.getByRole('button',{name:'▶ 播放'}).click(); diff --git a/wasm/web_platform/src/app/App.tsx b/wasm/web_platform/src/app/App.tsx index fbae18c4..a4fc9475 100644 --- a/wasm/web_platform/src/app/App.tsx +++ b/wasm/web_platform/src/app/App.tsx @@ -1,47 +1,82 @@ /* Zustand 的 action 引用稳定;初始化 viewer 与导入回调有意只创建一次。 */ /* eslint-disable react-hooks/exhaustive-deps */ import {useCallback,useEffect,useRef,useState,type ChangeEvent,type DragEvent} from 'react'; +import {Camera,ChevronLeft,ChevronRight,CircleHelp,Crosshair,Hand,Maximize,MousePointer2,PanelsTopLeft,Pause,Play,RotateCcw,Settings as SettingsIcon,SunMoon} from 'lucide-react'; import type {ProjectManifest} from '../project/types'; import {filesFromDrop,importBrowserFiles,ProjectImportError} from '../project/importer'; -import {ProjectTree} from '../project/ProjectTree'; -import {ModelStructureTree} from '../project/ModelStructureTree'; import {MainThreadPhysicsAdapter,type UrdfBaseMode,type UrdfLoadMode} from '../simulation/PhysicsAdapter'; import {MuJoCoViewer,type InteractionMode,type ViewerTheme} from '../viewer/MuJoCoViewer'; import {useAppStore,type AppDiagnostic} from '../stores/useAppStore'; +import {WorkbenchHeader} from './components/WorkbenchHeader'; +import {ViewerToolDock} from './components/ViewerToolDock'; +import {ProjectSidebar,ModelControlsSidebar} from './components/SidebarPanel'; +import {WorkspaceOverlays,type ImportProgress} from './components/WorkspaceOverlays'; +import {EntrySelectionDialog} from './components/EntrySelectionDialog'; +import {ErrorRecoveryPanel} from './components/ErrorRecoveryPanel'; +import {StatusBar} from './components/StatusBar'; +import {ViewportHUD} from './components/ViewportHUD'; +import {ShortcutHelpDialog} from './components/ShortcutHelpDialog'; +import {CommandPalette,type WorkbenchCommand} from './components/CommandPalette'; +import {NotificationCenter,ToastViewport,type WorkbenchNotification} from './components/NotificationCenter'; +import {SettingsDialog} from './components/SettingsDialog'; +import {dispatchLayoutWidths,LayoutSettingsDialog,type LayoutPreset} from './components/LayoutSettingsDialog'; +import {ConfirmDialog,IconButton} from '../components/ui'; +import {DiagnosticsDrawer} from './components/DiagnosticsDrawer'; +import {ToolbarOverflowMenu} from './components/ToolbarOverflowMenu'; function diagnostic(category:AppDiagnostic['category'],error:unknown,path?:string):AppDiagnostic{const detail=error instanceof Error?error.message:String(error);return {category,summary:`${category}失败`,detail,path,at:Date.now()};} -const modeLabels:Record={select:'选择',joint:'关节拖动',force:'外力施加'}; function initialTheme():ViewerTheme{try{return localStorage.getItem('mujoco-platform-theme')==='light'?'light':'dark';}catch{return'dark';}} export function App(){ - const state=useAppStore(); const manifest=useRef(null); const adapter=useRef(new MainThreadPhysicsAdapter()); const viewerHost=useRef(null); const viewer=useRef(null); const [forceScale,setForceScale]=useState(50); const [leftOpen,setLeftOpen]=useState(true);const [rightOpen,setRightOpen]=useState(true);const [urdfMode,setUrdfMode]=useState('mjcf');const urdfModeRef=useRef('mjcf');const [baseMode,setBaseMode]=useState('floating');const baseModeRef=useRef('floating');const [showCollision,setShowCollision]=useState(false);const [theme,setTheme]=useState(initialTheme);const [jointAdvanced,setJointAdvanced]=useState(false);const [ignoreJointLimits,setIgnoreJointLimits]=useState(false);const [angleUnit,setAngleUnit]=useState<'rad'|'deg'>('rad'); - useEffect(()=>{if(!viewerHost.current)return;viewer.current=new MuJoCoViewer(viewerHost.current,{onSelection:state.setSelection,onFrame:(frame,fps,snapshot)=>{const memory=(performance as Performance&{memory?:{usedJSHeapSize:number}}).memory?.usedJSHeapSize;state.setMetrics(fps,frame.stepMs,memory===undefined?undefined:memory/1048576,frame.overBudget);if(snapshot)state.setSnapshot(snapshot);},onError:(error)=>state.setDiagnostic(diagnostic('渲染',error))});return()=>{viewer.current?.dispose();viewer.current=null;adapter.current.dispose();};},[]); - useEffect(()=>{viewer.current?.setMode(state.mode);},[state.mode]); useEffect(()=>{if(viewer.current)viewer.current.forceScale=forceScale;},[forceScale]);useEffect(()=>{viewer.current?.setShowCollision(showCollision);},[showCollision]);useEffect(()=>{viewer.current?.setTheme(theme);document.documentElement.style.colorScheme=theme;try{localStorage.setItem('mujoco-platform-theme',theme);}catch{/* 浏览器禁用存储时仍可在当前会话切换 */}},[theme]); - const loadEntry=useCallback(async(path:string,requestedMode?:UrdfLoadMode)=>{if(!manifest.current)return;setIgnoreJointLimits(false);state.setEntry(path);state.setLoading(true);state.setDiagnostic(undefined);viewer.current?.attach(null);try{const snapshot=await adapter.current.load(manifest.current,path,requestedMode??urdfModeRef.current,baseModeRef.current);state.setSnapshot(snapshot);state.setPaused(true);viewer.current?.attach(adapter.current.session);}catch(error){state.setDiagnostic(diagnostic('模型编译',error,path));}finally{state.setLoading(false);}},[]); - const ingest=useCallback(async(files:File[])=>{state.setLoading(true);try{const next=await importBrowserFiles(files);manifest.current=next;state.setProject(next.name,next.files.map(({path,size})=>({path,size})),next.entries,next.selectedEntry);if(next.selectedEntry)await loadEntry(next.selectedEntry);}catch(error){state.setDiagnostic(diagnostic(error instanceof ProjectImportError&&/ZIP/.test(error.message)?'ZIP':'导入',error,error instanceof ProjectImportError?error.path:undefined));}finally{state.setLoading(false);}},[loadEntry]); - const removeProject=()=>{if(!state.projectName||!window.confirm(`确定从当前会话中移除“${state.projectName}”吗?\n不会删除本地文件。`))return;viewer.current?.attach(null);adapter.current.dispose();manifest.current=null;state.clearProject();}; + const state=useAppStore(); + const manifest=useRef(null),notificationId=useRef(0),loadInFlight=useRef(false),importInFlight=useRef(false),adapter=useRef(new MainThreadPhysicsAdapter()),root=useRef(null),viewerHost=useRef(null),viewer=useRef(null); + const [forceScale,setForceScale]=useState(50),[leftOpen,setLeftOpen]=useState(true),[rightOpen,setRightOpen]=useState(true),[helpOpen,setHelpOpen]=useState(false),[commandOpen,setCommandOpen]=useState(false),[removeConfirmOpen,setRemoveConfirmOpen]=useState(false),[fullscreen,setFullscreen]=useState(false),[settingsOpen,setSettingsOpen]=useState(false),[layoutOpen,setLayoutOpen]=useState(false),[diagnosticsOpen,setDiagnosticsOpen]=useState(false),[importProgress,setImportProgress]=useState(),[notifications,setNotifications]=useState([]),[toast,setToast]=useState(); + const [urdfMode,setUrdfMode]=useState('mjcf'),urdfModeRef=useRef('mjcf'); + const [baseMode,setBaseMode]=useState('floating'),baseModeRef=useRef('floating'); + const [showCollision,setShowCollision]=useState(false),[theme,setTheme]=useState(initialTheme),[jointAdvanced,setJointAdvanced]=useState(false),[ignoreJointLimits,setIgnoreJointLimits]=useState(false),[angleUnit,setAngleUnit]=useState<'rad'|'deg'>('rad'); + useEffect(()=>{if(!viewerHost.current)return;viewer.current=new MuJoCoViewer(viewerHost.current,{onSelection:state.setSelection,onFrame:(frame,fps,snapshot)=>{const memory=(performance as Performance&{memory?:{usedJSHeapSize:number}}).memory?.usedJSHeapSize;state.setMetrics(fps,frame.stepMs,memory===undefined?undefined:memory/1048576,frame.overBudget);if(snapshot)state.setSnapshot(snapshot);},onError:error=>state.setDiagnostic(diagnostic('渲染',error))});return()=>{viewer.current?.dispose();viewer.current=null;adapter.current.dispose();};},[]); + useEffect(()=>{viewer.current?.setMode(state.mode);},[state.mode]); + useEffect(()=>{if(viewer.current)viewer.current.forceScale=forceScale;},[forceScale]); + useEffect(()=>{viewer.current?.setShowCollision(showCollision);},[showCollision]); + useEffect(()=>{viewer.current?.setTheme(theme);document.documentElement.style.colorScheme=theme;try{localStorage.setItem('mujoco-platform-theme',theme);}catch{/* 当前会话仍可切换 */}},[theme]); + useEffect(()=>{const change=()=>setFullscreen(document.fullscreenElement===root.current);document.addEventListener('fullscreenchange',change);return()=>document.removeEventListener('fullscreenchange',change);},[]); + const loadEntry=useCallback(async(path:string,requestedMode?:UrdfLoadMode)=>{if(!manifest.current||loadInFlight.current)return;loadInFlight.current=true;setIgnoreJointLimits(false);state.setEntry(path);state.setLoading(true);setImportProgress({label:'初始化 WASM 与编译模型',value:.65});state.setDiagnostic(undefined);viewer.current?.attach(null);state.setSnapshot(undefined);state.setSelection(null);try{const snapshot=await adapter.current.load(manifest.current,path,requestedMode??urdfModeRef.current,baseModeRef.current);setImportProgress({label:'创建视口场景',value:.92});adapter.current.setSpeed(useAppStore.getState().speed);state.setSnapshot(snapshot);state.setPaused(true);viewer.current?.attach(adapter.current.session);const notice:WorkbenchNotification={id:++notificationId.current,title:snapshot.warnings.length?'URDF 兼容处理':'模型加载完成',detail:snapshot.warnings.length?snapshot.warnings.join('\n'):path,tone:snapshot.warnings.length?'warning':'success',at:Date.now()};setNotifications(items=>[notice,...items].slice(0,20));setToast(notice);}catch(error){state.setDiagnostic(diagnostic('模型编译',error,path));const notice:WorkbenchNotification={id:++notificationId.current,title:'模型编译失败',detail:error instanceof Error?error.message:String(error),tone:'danger',at:Date.now()};setNotifications(items=>[notice,...items].slice(0,20));setToast(notice);}finally{loadInFlight.current=false;setImportProgress(undefined);state.setLoading(false);}},[]); + const ingest=useCallback(async(files:File[],lockOwned=false)=>{if(importInFlight.current&&!lockOwned)return;importInFlight.current=true;state.setLoading(true);setImportProgress({label:'读取工程文件',value:.12});try{const next=await importBrowserFiles(files);setImportProgress({label:'处理模型资源与入口',value:.38});manifest.current=next;state.setProject(next.name,next.files.map(({path,size})=>({path,size})),next.entries,next.selectedEntry);if(next.selectedEntry)await loadEntry(next.selectedEntry);}catch(error){state.setDiagnostic(diagnostic(error instanceof ProjectImportError&&/ZIP/.test(error.message)?'ZIP':'导入',error,error instanceof ProjectImportError?error.path:undefined));const notice:WorkbenchNotification={id:++notificationId.current,title:'工程导入失败',detail:error instanceof Error?error.message:String(error),tone:'danger',at:Date.now()};setNotifications(items=>[notice,...items].slice(0,20));setToast(notice);}finally{importInFlight.current=false;setImportProgress(undefined);state.setLoading(false);}},[loadEntry]); + const removeProject=()=>{if(state.projectName)setRemoveConfirmOpen(true);}; + const confirmRemoveProject=()=>{viewer.current?.attach(null);adapter.current.dispose();manifest.current=null;state.clearProject();setRemoveConfirmOpen(false);}; const changeUrdfMode=(value:UrdfLoadMode)=>{setUrdfMode(value);urdfModeRef.current=value;const entry=state.entries.find(candidate=>candidate.path===state.selectedEntry);if(entry?.format==='urdf')void loadEntry(entry.path,value);}; const changeBaseMode=(value:UrdfBaseMode)=>{setBaseMode(value);baseModeRef.current=value;const entry=state.entries.find(candidate=>candidate.path===state.selectedEntry);if(entry?.format==='urdf'&&urdfModeRef.current==='mjcf')void loadEntry(entry.path,'mjcf');}; const changeFiles=(event:ChangeEvent)=>{void ingest(Array.from(event.target.files??[]));event.target.value='';}; - const drop=(event:DragEvent)=>{event.preventDefault();void filesFromDrop(event.dataTransfer.items,event.dataTransfer.files).then(ingest).catch((e)=>state.setDiagnostic(diagnostic('导入',e)));}; + const drop=(event:DragEvent)=>{event.preventDefault();if(state.loading||importInFlight.current)return;importInFlight.current=true;state.setLoading(true);setImportProgress({label:'读取拖放文件',value:.05});void (async()=>{try{const files=await filesFromDrop(event.dataTransfer.items,event.dataTransfer.files);await ingest(files,true);}catch(error){importInFlight.current=false;setImportProgress(undefined);state.setLoading(false);state.setDiagnostic(diagnostic('导入',error));}})();}; const togglePause=()=>{const value=!state.paused;state.setPaused(value);adapter.current.setPaused(value);}; const reset=()=>{adapter.current.setPaused(true);adapter.current.reset();state.setSnapshot(adapter.current.snapshot()??undefined);state.setPaused(true);}; const singleStep=()=>{adapter.current.singleStep();state.setSnapshot(adapter.current.snapshot()??undefined);}; const changeSpeed=(value:number)=>{state.setSpeed(value);adapter.current.setSpeed(value);}; - const mode=(value:InteractionMode)=>{state.setMode(value);}; + const mode=(value:InteractionMode)=>state.setMode(value); const resetJoints=()=>{adapter.current.resetJoints();state.setPaused(true);state.setSnapshot(adapter.current.snapshot()??undefined);}; const toggleJointLimits=()=>{const next=!ignoreJointLimits;adapter.current.setIgnoreJointLimits(next);setIgnoreJointLimits(next);state.setSnapshot(adapter.current.snapshot()??undefined);}; - useEffect(()=>{const key=(e:KeyboardEvent)=>{if((e.target as HTMLElement).matches('input,select,button'))return;if(e.code==='Space'){e.preventDefault();togglePause();}if(e.key==='r')reset();if(e.key==='1')mode('select');if(e.key==='2')mode('joint');if(e.key==='3')mode('force');};window.addEventListener('keydown',key);return()=>window.removeEventListener('keydown',key);}); - return
    e.preventDefault()} onDrop={drop}> -

    MuJoCo Web 仿真平台

    {(Object.keys(modeLabels) as InteractionMode[]).map(value=>)}
    -
    {leftOpen&&} -
    {!state.snapshot&&!state.loading&&
    }{state.loading&&
    正在加载 MuJoCo 与模型…
    }{state.entries.length>1&&!state.selectedEntry&&} {state.diagnostic&&state.setDiagnostic(undefined)}/>}
    - {rightOpen&&}
    -
    时间 {state.snapshot?.time.toFixed(3)??'—'} sFPS {state.fps.toFixed(0)}物理 {state.stepMs.toFixed(2)} ms内存 {state.memoryMb===undefined?'—':`${state.memoryMb.toFixed(1)} MiB`}WASM {state.snapshot?'已加载':'未加载'}{state.overBudget&&主线程超出步进预算,已限制追帧}快捷键:Space 播放/暂停 · R 重置 · 1/2/3 模式
    + const setActuator=(id:number,value:number)=>{adapter.current.setActuator(id,value);state.setSnapshot(adapter.current.snapshot()??undefined);}; + const setJoint=(id:number,value:number)=>{adapter.current.setJointPosition(id,value);state.setPaused(true);state.setSnapshot(adapter.current.snapshot()??undefined);}; + const toggleFullscreen=()=>{if(document.fullscreenElement)void document.exitFullscreen().catch(()=>{});else if(root.current)void root.current.requestFullscreen().catch(()=>{});}; + const applyLayoutPreset=(preset:LayoutPreset)=>{if(preset==='viewport'){setLeftOpen(false);setRightOpen(false);dispatchLayoutWidths(288,288);}else if(preset==='project'){setLeftOpen(true);setRightOpen(false);dispatchLayoutWidths(384,288);}else if(preset==='control'){setLeftOpen(false);setRightOpen(true);dispatchLayoutWidths(288,384);}else{setLeftOpen(true);setRightOpen(true);dispatchLayoutWidths(288,288);}}; + useEffect(()=>{const key=(event:KeyboardEvent)=>{if(document.activeElement instanceof HTMLElement&&document.activeElement.closest('[role="dialog"]'))return;if((event.ctrlKey||event.metaKey)&&event.key.toLocaleLowerCase()==='k'){event.preventDefault();setCommandOpen(true);return;}if((event.target as HTMLElement).matches('input,select,button'))return;if(event.code==='Space'){event.preventDefault();togglePause();}if(event.key==='r')reset();if(event.key==='1')mode('select');if(event.key==='2')mode('joint');if(event.key==='3')mode('force');};window.addEventListener('keydown',key);return()=>window.removeEventListener('keydown',key);}); + const selectedFormat=state.entries.find(entry=>entry.path===state.selectedEntry)?.format; + const commands:WorkbenchCommand[]=[ + {id:'play',label:state.paused?'播放仿真':'暂停仿真',group:'仿真',icon:state.paused?:,shortcut:'Space',disabled:!state.snapshot,run:togglePause}, + {id:'reset',label:'重置仿真',group:'仿真',icon:,shortcut:'R',disabled:!state.snapshot,run:reset}, + {id:'select',label:'切换到选择模式',group:'视口',icon:,shortcut:'1',run:()=>mode('select')}, + {id:'joint',label:'切换到关节拖动',group:'视口',icon:,shortcut:'2',run:()=>mode('joint')}, + {id:'force',label:'切换到外力施加',group:'视口',icon:,shortcut:'3',run:()=>mode('force')}, + {id:'camera',label:'复位相机',group:'视口',icon:,run:()=>viewer.current?.resetCamera()}, + {id:'left',label:leftOpen?'隐藏工程面板':'显示工程面板',group:'布局',icon:leftOpen?:,run:()=>setLeftOpen(value=>!value)}, + {id:'right',label:rightOpen?'隐藏属性面板':'显示属性面板',group:'布局',icon:rightOpen?:,run:()=>setRightOpen(value=>!value)}, + {id:'theme',label:theme==='dark'?'切换到白天主题':'切换到黑夜主题',group:'外观',icon:,run:()=>setTheme(value=>value==='dark'?'light':'dark')}, + {id:'fullscreen',label:fullscreen?'退出全屏':'进入全屏',group:'布局',icon:,run:toggleFullscreen}, + {id:'help',label:'查看快捷键帮助',group:'帮助',icon:,run:()=>setHelpOpen(true)}, + ]; + return
    event.preventDefault()} onDrop={drop}> + setLeftOpen(value=>!value)} onToggleRight={()=>setRightOpen(value=>!value)} onToggleTheme={()=>setTheme(value=>value==='dark'?'light':'dark')} onHelp={()=>setHelpOpen(true)} endActions={<>setNotifications(items=>items.filter(item=>item.id!==id))} onClear={()=>setNotifications([])} onOpenLog={()=>setDiagnosticsOpen(true)}/>setLayoutOpen(true)}>setSettingsOpen(true)}>} compactMenu={setCommandOpen(true)} onLayout={()=>setLayoutOpen(true)} onSettings={()=>setSettingsOpen(true)} onFullscreen={toggleFullscreen} onHelp={()=>setHelpOpen(true)} onTheme={()=>setTheme(value=>value==='dark'?'light':'dark')}/>} onCommands={()=>setCommandOpen(true)} onToggleFullscreen={toggleFullscreen} center={viewer.current?.resetCamera()}/>}/> +
    void loadEntry(path)} onJointHover={jointId=>viewer.current?.highlightJoint(jointId)}/>
    setToast(undefined)}/>{state.entries.length>1&&!state.selectedEntry&&} {state.diagnostic&&state.setDiagnostic(undefined)} onRetry={state.diagnostic.category==='模型编译'&&state.diagnostic.path?()=>void loadEntry(state.diagnostic!.path!):undefined} onOpenProject={()=>{setLeftOpen(true);state.setDiagnostic(undefined);}}/>}
    setJointAdvanced(value=>!value)} onToggleAngleUnit={()=>setAngleUnit(value=>value==='rad'?'deg':'rad')} onActuator={setActuator} onJoint={setJoint} onForceScale={setForceScale}/>
    + setHelpOpen(false)}/>setDiagnosticsOpen(false)} onClear={()=>setNotifications([])}/>setSettingsOpen(false)} theme={theme} angleUnit={angleUnit} showCollision={showCollision} jointAdvanced={jointAdvanced} forceScale={forceScale} onTheme={setTheme} onAngleUnit={setAngleUnit} onShowCollision={setShowCollision} onJointAdvanced={setJointAdvanced} onForceScale={setForceScale}/>setLayoutOpen(false)} leftOpen={leftOpen} rightOpen={rightOpen} onLeftOpen={setLeftOpen} onRightOpen={setRightOpen} onPreset={applyLayoutPreset} onReset={()=>applyLayoutPreset('default')}/>setCommandOpen(false)} commands={commands}/>setRemoveConfirmOpen(false)}>

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

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

    ; } -function PanelTitle({children}:{children:string}){return

    {children}

    } -function EmptyImport(){return

    拖放模型工程到此处

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

    } -function EntryDialog({entries,onSelect}:{entries:{path:string;label:string}[];onSelect:(path:string)=>void}){return

    选择模型入口

    工程包含多个可加载模型,请选择一个。

    {entries.map(e=>)}
    } -function DiagnosticCard({value,onClose}:{value:AppDiagnostic;onClose:()=>void}){return

    {value.summary}

    {value.path&&

    路径:{value.path}

    }
    {value.detail}
    } -function ControlSlider({label,value,min,max,onChange,disabled=false,unit='',advanced=false,limited=false,limitsIgnored=false,limitMin=0,limitMax=0}:{label:string;value:number;min:number;max:number;onChange:(v:number)=>void;disabled?:boolean;unit?:string;advanced?:boolean;limited?:boolean;limitsIgnored?:boolean;limitMin?:number;limitMax?:number}){const sane=Number.isFinite(value)?value:0,format=(number:number)=>`${number.toFixed(3)}${unit}`;return } diff --git a/wasm/web_platform/src/app/ErrorBoundary.tsx b/wasm/web_platform/src/app/ErrorBoundary.tsx index 10987634..cba4e443 100644 --- a/wasm/web_platform/src/app/ErrorBoundary.tsx +++ b/wasm/web_platform/src/app/ErrorBoundary.tsx @@ -1,2 +1,3 @@ -import {Component, type ErrorInfo, type ReactNode} from 'react'; -export class ErrorBoundary extends Component<{children:ReactNode},{error?:Error}>{state:{error?:Error}={};static getDerivedStateFromError(error:Error){return {error};}componentDidCatch(error:Error,info:ErrorInfo){console.error('React fatal error',error,info);}render(){return this.state.error?

    界面发生致命错误

    {this.state.error.message}
    :this.props.children;}} +import {Component,type ErrorInfo,type ReactNode} from 'react'; +import {Button} from '../components/ui'; +export class ErrorBoundary extends Component<{children:ReactNode},{error?:Error}>{state:{error?:Error}={};static getDerivedStateFromError(error:Error){return {error};}componentDidCatch(error:Error,info:ErrorInfo){console.error('React fatal error',error,info);}render(){return this.state.error?

    界面发生致命错误

    {this.state.error.message}
    :this.props.children;}} diff --git a/wasm/web_platform/src/app/components/CommandPalette.tsx b/wasm/web_platform/src/app/components/CommandPalette.tsx new file mode 100644 index 00000000..6010ffde --- /dev/null +++ b/wasm/web_platform/src/app/components/CommandPalette.tsx @@ -0,0 +1,12 @@ +import {useEffect,useId,useMemo,useRef,useState,type ReactNode} from 'react'; +import {Search} from 'lucide-react'; +import {Dialog,EmptySearchState,Kbd} from '../../components/ui'; +export interface WorkbenchCommand{id:string;label:string;group:string;icon?:ReactNode;shortcut?:string;disabled?:boolean;run:()=>void;} +export function CommandPalette({open,onClose,commands}:{open:boolean;onClose:()=>void;commands:WorkbenchCommand[]}){ + const [query,setQuery]=useState(''),[active,setActive]=useState(0),input=useRef(null),listId=useId(); + const filtered=useMemo(()=>{const needle=query.trim().toLocaleLowerCase();return commands.filter(command=>!needle||`${command.label} ${command.group}`.toLocaleLowerCase().includes(needle));},[commands,query]); + const enabled=filtered.flatMap((command,index)=>command.disabled?[]:[index]),highlighted=filtered[active]&&!filtered[active].disabled?active:(enabled[0]??-1); + useEffect(()=>{if(open)requestAnimationFrame(()=>input.current?.focus());},[open]); + const close=()=>{setQuery('');setActive(0);onClose();},execute=(command?:WorkbenchCommand)=>{if(!command||command.disabled)return;command.run();close();}; + return
    =0?`${listId}-${filtered[highlighted].id}`:undefined} value={query} onChange={event=>{setQuery(event.target.value);setActive(0);}} onKeyDown={event=>{if(!enabled.length)return;const current=Math.max(0,enabled.indexOf(highlighted));if(event.key==='ArrowDown'){event.preventDefault();setActive(enabled[(current+1)%enabled.length]);}else if(event.key==='ArrowUp'){event.preventDefault();setActive(enabled[(current-1+enabled.length)%enabled.length]);}else if(event.key==='Enter'){event.preventDefault();execute(filtered[highlighted]);}}} placeholder="输入命令名称…" className="h-11 w-full bg-input pl-11 pr-4 text-sm text-text-primary placeholder:text-text-tertiary focus:outline-none"/>
    {filtered.length?filtered.map((command,index)=>):}
    ; +} diff --git a/wasm/web_platform/src/app/components/DiagnosticNotice.tsx b/wasm/web_platform/src/app/components/DiagnosticNotice.tsx new file mode 100644 index 00000000..868cac14 --- /dev/null +++ b/wasm/web_platform/src/app/components/DiagnosticNotice.tsx @@ -0,0 +1,5 @@ +import {useState} from 'react'; +import {ChevronDown,TriangleAlert,X} from 'lucide-react'; +import type {AppDiagnostic} from '../../stores/useAppStore'; +import {IconButton} from '../../components/ui'; +export function DiagnosticNotice({value,onClose}:{value:AppDiagnostic;onClose:()=>void}){const [expanded,setExpanded]=useState(false);return

    {value.summary}

    {value.path&&

    路径:{value.path}

    }
    {expanded&&
    {value.detail}
    }
    ;} diff --git a/wasm/web_platform/src/app/components/DiagnosticsDrawer.tsx b/wasm/web_platform/src/app/components/DiagnosticsDrawer.tsx new file mode 100644 index 00000000..ff65ffbd --- /dev/null +++ b/wasm/web_platform/src/app/components/DiagnosticsDrawer.tsx @@ -0,0 +1,6 @@ +import {useState} from 'react'; +import {CheckCircle2,Info,TriangleAlert,XCircle} from 'lucide-react'; +import {Button,CopyButton,Dialog,Tabs} from '../../components/ui'; +import type {WorkbenchNotification} from './NotificationCenter'; +type Filter='all'|'warning'|'danger'; +export function DiagnosticsDrawer({open,items,onClose,onClear}:{open:boolean;items:WorkbenchNotification[];onClose:()=>void;onClear:()=>void}){const [filter,setFilter]=useState('all');const content=(value:Filter)=>{const filtered=items.filter(item=>value==='all'||item.tone===value);return
    {filtered.length?filtered.map(item=>{const Icon=item.tone==='danger'?XCircle:item.tone==='warning'?TriangleAlert:item.tone==='success'?CheckCircle2:Info;return

    {item.title}

    {item.detail&&
    {item.detail}
    }
    {item.detail&&}
    }):

    没有符合条件的事件

    }
    ;};return
    }>item.tone==='warning').length}`,content:content('warning')},{value:'danger',label:`错误 ${items.filter(item=>item.tone==='danger').length}`,content:content('danger')}]}/>;} diff --git a/wasm/web_platform/src/app/components/EntrySelectionDialog.test.tsx b/wasm/web_platform/src/app/components/EntrySelectionDialog.test.tsx new file mode 100644 index 00000000..c44f65d5 --- /dev/null +++ b/wasm/web_platform/src/app/components/EntrySelectionDialog.test.tsx @@ -0,0 +1,4 @@ +import {render,screen} from '@testing-library/react'; +import {EntrySelectionDialog} from './EntrySelectionDialog'; + +describe('EntrySelectionDialog',()=>{it('父组件重渲染时不抢走入口按钮焦点,且不暴露无效关闭动作',()=>{const entries=[{path:'a.xml',label:'模型 A'},{path:'b.xml',label:'模型 B'}],select=vi.fn();const {rerender}=render();const entry=screen.getByRole('button',{name:'模型 A'});entry.focus();rerender();expect(entry).toHaveFocus();expect(screen.queryByRole('button',{name:'关闭'})).not.toBeInTheDocument();});}); diff --git a/wasm/web_platform/src/app/components/EntrySelectionDialog.tsx b/wasm/web_platform/src/app/components/EntrySelectionDialog.tsx new file mode 100644 index 00000000..af14449f --- /dev/null +++ b/wasm/web_platform/src/app/components/EntrySelectionDialog.tsx @@ -0,0 +1,4 @@ +import {FileCode2} from 'lucide-react'; +import {Button,Dialog} from '../../components/ui'; +const noop=()=>{}; +export function EntrySelectionDialog({entries,onSelect}:{entries:{path:string;label:string}[];onSelect:(path:string)=>void}){return 0} onClose={noop} closable={false} title="选择模型入口">

    工程包含多个可加载模型,请选择一个。

    {entries.map(entry=>)}
    ;} diff --git a/wasm/web_platform/src/app/components/ErrorRecoveryPanel.tsx b/wasm/web_platform/src/app/components/ErrorRecoveryPanel.tsx new file mode 100644 index 00000000..bc1c1376 --- /dev/null +++ b/wasm/web_platform/src/app/components/ErrorRecoveryPanel.tsx @@ -0,0 +1,5 @@ +import {useState} from 'react'; +import {ChevronDown,FolderTree,RefreshCw,TriangleAlert,X} from 'lucide-react'; +import type {AppDiagnostic} from '../../stores/useAppStore'; +import {Button,CopyButton,IconButton} from '../../components/ui'; +export function ErrorRecoveryPanel({value,onClose,onRetry,onOpenProject}:{value:AppDiagnostic;onClose:()=>void;onRetry?:()=>void;onOpenProject:()=>void}){const [expanded,setExpanded]=useState(false);return

    {value.summary}

    {value.path&&

    路径:{value.path}

    }
    {onRetry&&}
    {expanded&&
    {value.detail}
    }
    ;} diff --git a/wasm/web_platform/src/app/components/FeedbackComponents.test.tsx b/wasm/web_platform/src/app/components/FeedbackComponents.test.tsx new file mode 100644 index 00000000..01fcf066 --- /dev/null +++ b/wasm/web_platform/src/app/components/FeedbackComponents.test.tsx @@ -0,0 +1,5 @@ +import {fireEvent,render,screen} from '@testing-library/react'; +import {DiagnosticNotice} from './DiagnosticNotice'; +import {WorkspaceOverlays} from './WorkspaceOverlays'; +import {StatusBar} from './StatusBar'; +describe('工作台反馈组件',()=>{it('诊断详情可展开并关闭',()=>{const close=vi.fn();render();expect(screen.queryByText('bad xml')).not.toBeInTheDocument();fireEvent.click(screen.getByRole('button',{name:'技术详情'}));expect(screen.getByText('bad xml')).toBeVisible();fireEvent.click(screen.getByRole('button',{name:'关闭错误'}));expect(close).toHaveBeenCalledTimes(1);});it('加载态与空态互斥',()=>{const {rerender}=render();expect(screen.getByText('拖放模型工程到此处')).toBeVisible();rerender();expect(screen.queryByText('拖放模型工程到此处')).not.toBeInTheDocument();expect(screen.getByRole('status')).toBeVisible();});it('展示格式化状态数据',()=>{render();expect(screen.getByText(/时间 1.250 s/)).toBeVisible();expect(screen.getByText(/WASM 已加载/)).toBeVisible();});}); diff --git a/wasm/web_platform/src/app/components/FifthBatchComponents.test.tsx b/wasm/web_platform/src/app/components/FifthBatchComponents.test.tsx new file mode 100644 index 00000000..6583ee8c --- /dev/null +++ b/wasm/web_platform/src/app/components/FifthBatchComponents.test.tsx @@ -0,0 +1,12 @@ +import {fireEvent,render,screen,within} from '@testing-library/react'; +import {DiagnosticsDrawer} from './DiagnosticsDrawer'; +import {ErrorRecoveryPanel} from './ErrorRecoveryPanel'; +import {ToolbarOverflowMenu} from './ToolbarOverflowMenu'; +import {WorkspaceOverlays} from './WorkspaceOverlays'; +const event={id:1,title:'编译失败',detail:'bad xml',tone:'danger' as const,at:0}; +describe('第五批工作台组件',()=>{ + it('事件日志支持分类和清空',()=>{const clear=vi.fn();render({}} onClear={clear}/>);expect(within(screen.getByRole('tabpanel',{name:/全部/})).getByText('bad xml')).toBeVisible();expect(screen.getAllByText('bad xml')).toHaveLength(1);fireEvent.click(screen.getByRole('button',{name:'清空事件'}));expect(clear).toHaveBeenCalled();}); + it('错误恢复面板透传重试与工程树动作',()=>{const retry=vi.fn(),project=vi.fn();render({}} onRetry={retry} onOpenProject={project}/>);fireEvent.click(screen.getByRole('button',{name:'重试当前入口'}));fireEvent.click(screen.getByRole('button',{name:'返回工程树'}));expect(retry).toHaveBeenCalled();expect(project).toHaveBeenCalled();}); + it('导入叠层显示阶段进度',()=>{render(
    );expect(screen.getByRole('progressbar',{name:'处理模型资源'})).toHaveAttribute('aria-valuenow','40');}); + it('工具栏更多菜单提供窄桌面动作',()=>{const settings=vi.fn();render({}} onLayout={()=>{}} onSettings={settings} onFullscreen={()=>{}} onHelp={()=>{}} onTheme={()=>{}}/>);fireEvent.click(screen.getByRole('button',{name:'更多工作台操作'}));fireEvent.click(screen.getByRole('menuitem',{name:'工作台设置'}));expect(settings).toHaveBeenCalled();}); +}); diff --git a/wasm/web_platform/src/app/components/FourthBatchComponents.test.tsx b/wasm/web_platform/src/app/components/FourthBatchComponents.test.tsx new file mode 100644 index 00000000..373fe5d8 --- /dev/null +++ b/wasm/web_platform/src/app/components/FourthBatchComponents.test.tsx @@ -0,0 +1,13 @@ +import {act,fireEvent,render,screen} from '@testing-library/react'; +import {NotificationCenter,ToastViewport,type WorkbenchNotification} from './NotificationCenter'; +import {ProjectBreadcrumb} from './ProjectBreadcrumb'; +import {SettingsDialog} from './SettingsDialog'; +import {LayoutSettingsDialog} from './LayoutSettingsDialog'; +const item:WorkbenchNotification={id:1,title:'模型加载完成',detail:'完成',tone:'success',at:0}; +describe('第四批工作台组件',()=>{ + it('通知中心展示、移除并清空消息',()=>{const dismiss=vi.fn(),clear=vi.fn();render();fireEvent.click(screen.getByRole('button',{name:'通知中心'}));expect(screen.getByRole('dialog',{name:'通知中心'})).toHaveTextContent('模型加载完成');fireEvent.click(screen.getByRole('button',{name:'移除通知:模型加载完成'}));expect(dismiss).toHaveBeenCalledWith(1);fireEvent.click(screen.getByText('清空'));expect(clear).toHaveBeenCalled();}); + it('Toast 自动关闭',()=>{vi.useFakeTimers();const close=vi.fn();render();act(()=>vi.advanceTimersByTime(4000));expect(close).toHaveBeenCalledWith(1);vi.useRealTimers();}); + it('工程面包屑可切换多入口',()=>{const select=vi.fn();render();fireEvent.click(screen.getByRole('button',{name:'切换模型入口'}));fireEvent.click(screen.getByRole('option',{name:/B/}));expect(select).toHaveBeenCalledWith('models/b.xml');}); + it('模型加载期间禁用入口切换',()=>{render({}}/>);expect(screen.getByRole('button',{name:'切换模型入口'})).toBeDisabled();}); + it('设置和布局弹窗透传现有设置动作',()=>{const theme=vi.fn(),preset=vi.fn();render(<>{}} theme="dark" angleUnit="rad" showCollision={false} jointAdvanced={false} forceScale={50} onTheme={theme} onAngleUnit={()=>{}} onShowCollision={()=>{}} onJointAdvanced={()=>{}} onForceScale={()=>{}}/>{}} leftOpen rightOpen onLeftOpen={()=>{}} onRightOpen={()=>{}} onPreset={preset} onReset={()=>{}}/>);fireEvent.change(screen.getByLabelText('设置主题'),{target:{value:'light'}});expect(theme).toHaveBeenCalledWith('light');}); +}); diff --git a/wasm/web_platform/src/app/components/LayoutSettingsDialog.tsx b/wasm/web_platform/src/app/components/LayoutSettingsDialog.tsx new file mode 100644 index 00000000..470624a0 --- /dev/null +++ b/wasm/web_platform/src/app/components/LayoutSettingsDialog.tsx @@ -0,0 +1,7 @@ +import {Columns3,Focus,PanelLeft,PanelRight,RotateCcw} from 'lucide-react'; +import {Button,Dialog} from '../../components/ui'; +export type LayoutPreset='default'|'viewport'|'project'|'control'; +const presets=[{value:'default' as const,label:'默认布局',detail:'左右面板均衡显示',icon:Columns3},{value:'viewport' as const,label:'宽视口',detail:'隐藏两侧面板',icon:Focus},{value:'project' as const,label:'工程浏览',detail:'加宽工程面板',icon:PanelLeft},{value:'control' as const,label:'控制调试',detail:'加宽控制面板',icon:PanelRight}]; +export function LayoutSettingsDialog({open,onClose,leftOpen,rightOpen,onLeftOpen,onRightOpen,onPreset,onReset}:{open:boolean;onClose:()=>void;leftOpen:boolean;rightOpen:boolean;onLeftOpen:(value:boolean)=>void;onRightOpen:(value:boolean)=>void;onPreset:(preset:LayoutPreset)=>void;onReset:()=>void}){return

    布局预设

    {presets.map(item=>)}
    ;} +// eslint-disable-next-line react-refresh/only-export-components +export function dispatchLayoutWidths(left:number,right:number){window.dispatchEvent(new CustomEvent('mujoco-layout-widths',{detail:{left,right}}));} diff --git a/wasm/web_platform/src/app/components/NotificationCenter.tsx b/wasm/web_platform/src/app/components/NotificationCenter.tsx new file mode 100644 index 00000000..ffb38769 --- /dev/null +++ b/wasm/web_platform/src/app/components/NotificationCenter.tsx @@ -0,0 +1,7 @@ +import {useEffect,useRef} from 'react'; +import {Bell,CheckCircle2,Info,Trash2,TriangleAlert,XCircle} from 'lucide-react'; +import {Badge,IconButton,Popover} from '../../components/ui'; +export interface WorkbenchNotification{id:number;title:string;detail?:string;tone:'success'|'warning'|'danger'|'info';at:number;} +const icons={success:CheckCircle2,warning:TriangleAlert,danger:XCircle,info:Info}; +export function NotificationCenter({items,onDismiss,onClear,onOpenLog}:{items:WorkbenchNotification[];onDismiss:(id:number)=>void;onClear:()=>void;onOpenLog?:()=>void}){return {items.length>0&&}}>{({close})=>

    通知

    {onOpenLog&&}{items.length>0&&}
    {items.length?items.map(item=>{const Icon=icons[item.tone];return

    {item.title}

    {new Date(item.at).toLocaleTimeString('zh-CN',{hour:'2-digit',minute:'2-digit'})}
    {item.detail&&

    {item.detail}

    }
    onDismiss(item.id)}>
    }):

    当前没有通知

    }
    }
    ;} +export function ToastViewport({item,onDismiss}:{item?:WorkbenchNotification;onDismiss:(id:number)=>void}){const dismissRef=useRef(onDismiss);useEffect(()=>{dismissRef.current=onDismiss;},[onDismiss]);useEffect(()=>{if(!item)return;const timer=window.setTimeout(()=>dismissRef.current(item.id),4000);return()=>window.clearTimeout(timer);},[item]);if(!item)return null;const Icon=icons[item.tone];return

    {item.title}

    {item.detail&&

    {item.detail}

    }
    ;} diff --git a/wasm/web_platform/src/app/components/PerformancePopover.tsx b/wasm/web_platform/src/app/components/PerformancePopover.tsx new file mode 100644 index 00000000..f08f172c --- /dev/null +++ b/wasm/web_platform/src/app/components/PerformancePopover.tsx @@ -0,0 +1,3 @@ +import {Activity,ChevronUp,Cpu,MemoryStick,TriangleAlert} from 'lucide-react'; +import {Badge,Popover,PropertyRow,Separator} from '../../components/ui'; +export function PerformancePopover({fps,stepMs,memoryMb,overBudget}:{fps:number;stepMs:number;memoryMb?:number;overBudget:boolean}){return }>{()=>

    运行性能

    {overBudget?'预算超限':'运行正常'}
    {overBudget?

    主线程超出步进预算,平台已限制追帧以保持交互响应。

    :

    指标来自浏览器运行时,仅用于当前会话诊断。

    }
    }
    ;} diff --git a/wasm/web_platform/src/app/components/ProjectBreadcrumb.tsx b/wasm/web_platform/src/app/components/ProjectBreadcrumb.tsx new file mode 100644 index 00000000..605b1b5d --- /dev/null +++ b/wasm/web_platform/src/app/components/ProjectBreadcrumb.tsx @@ -0,0 +1,4 @@ +import {ChevronRight,FolderRoot} from 'lucide-react'; +import type {ModelEntry} from '../../project/types'; +import {SearchableCombobox} from '../../components/ui'; +export function ProjectBreadcrumb({projectName,entries,selectedEntry,loading=false,onSelect}:{projectName:string;entries:ModelEntry[];selectedEntry?:string;loading?:boolean;onSelect:(path:string)=>void}){const parts=selectedEntry?.split('/').filter(Boolean)??[];return
    {projectName}{parts.map((part,index)=>{part})}
    {entries.length>1&&
    ({value:entry.path,label:entry.label,description:entry.path}))}/>
    }
    ;} diff --git a/wasm/web_platform/src/app/components/SecondBatchComponents.test.tsx b/wasm/web_platform/src/app/components/SecondBatchComponents.test.tsx new file mode 100644 index 00000000..5603276c --- /dev/null +++ b/wasm/web_platform/src/app/components/SecondBatchComponents.test.tsx @@ -0,0 +1,10 @@ +import {fireEvent,render,screen} from '@testing-library/react'; +import {ShortcutHelpDialog} from './ShortcutHelpDialog'; +import {TreeSearchField} from './TreeSearchField'; +import {ViewportHUD} from './ViewportHUD'; + +describe('第二批工作台组件',()=>{ + it('搜索框发送内容并可清除',()=>{const change=vi.fn();const {rerender}=render();fireEvent.change(screen.getByRole('searchbox'),{target:{value:'arm'}});expect(change).toHaveBeenCalledWith('arm');rerender();expect(screen.getByRole('status')).toHaveTextContent('找到 2 个匹配项');fireEvent.click(screen.getByRole('button',{name:'清除搜索'}));expect(change).toHaveBeenLastCalledWith('');}); + it('快捷键帮助展示说明并支持 Escape',()=>{const close=vi.fn();render();expect(screen.getByRole('dialog',{name:'快捷键与视口操作'})).toBeVisible();expect(screen.getByText('播放 / 暂停')).toBeVisible();fireEvent.keyDown(document,{key:'Escape'});expect(close).toHaveBeenCalledTimes(1);}); + it('视口 HUD 复用仿真与选择状态',()=>{render();expect(screen.getByLabelText('视口状态')).toHaveTextContent('仿真中');expect(screen.getByLabelText('视口状态')).toHaveTextContent('关节拖动');expect(screen.getByLabelText('视口状态')).toHaveTextContent('arm');}); +}); diff --git a/wasm/web_platform/src/app/components/SettingsDialog.tsx b/wasm/web_platform/src/app/components/SettingsDialog.tsx new file mode 100644 index 00000000..9318d8e4 --- /dev/null +++ b/wasm/web_platform/src/app/components/SettingsDialog.tsx @@ -0,0 +1,3 @@ +import {Dialog,PropertyRow,Select} from '../../components/ui'; +export function SettingsDialog({open,onClose,theme,angleUnit,showCollision,jointAdvanced,forceScale,onTheme,onAngleUnit,onShowCollision,onJointAdvanced,onForceScale}:{open:boolean;onClose:()=>void;theme:'light'|'dark';angleUnit:'rad'|'deg';showCollision:boolean;jointAdvanced:boolean;forceScale:number;onTheme:(value:'light'|'dark')=>void;onAngleUnit:(value:'rad'|'deg')=>void;onShowCollision:(value:boolean)=>void;onJointAdvanced:(value:boolean)=>void;onForceScale:(value:number)=>void}){return

    外观

    onTheme(event.target.value as 'light'|'dark')}>}/>

    模型与控制

    onAngleUnit(event.target.value as 'rad'|'deg')}>}/>
    ;} +function Check({label,checked,onChange}:{label:string;checked:boolean;onChange:(value:boolean)=>void}){return ;} diff --git a/wasm/web_platform/src/app/components/ShortcutHelpDialog.tsx b/wasm/web_platform/src/app/components/ShortcutHelpDialog.tsx new file mode 100644 index 00000000..fdf5a0a9 --- /dev/null +++ b/wasm/web_platform/src/app/components/ShortcutHelpDialog.tsx @@ -0,0 +1,3 @@ +import {Dialog,Kbd,Separator} from '../../components/ui'; +const shortcuts=[['Space','播放 / 暂停'],['R','重置仿真'],['1','选择模式'],['2','关节拖动'],['3','外力施加']]; +export function ShortcutHelpDialog({open,onClose}:{open:boolean;onClose:()=>void}){return

    键盘快捷键

    {shortcuts.map(([key,label])=>
    {label}
    {key}
    )}

    鼠标操作

    • 左键拖动:旋转相机或执行当前交互工具
    • 右键拖动:平移相机
    • 滚轮:缩放视口
    • 选择物体后可在右侧“属性”中查看信息
    ;} diff --git a/wasm/web_platform/src/app/components/SidebarPanel.tsx b/wasm/web_platform/src/app/components/SidebarPanel.tsx new file mode 100644 index 00000000..66cc963e --- /dev/null +++ b/wasm/web_platform/src/app/components/SidebarPanel.tsx @@ -0,0 +1,34 @@ +import {useState,type ReactNode} from 'react'; +import {Box,FolderTree,Info,Settings2,SlidersHorizontal} from 'lucide-react'; +import type {ModelEntry} from '../../project/types'; +import {countProjectSearchResults,ProjectTree,type ProjectTreeFile} from '../../project/ProjectTree'; +import {countModelStructureSearchResults,ModelStructureTree} from '../../project/ModelStructureTree'; +import type {SimulationSnapshot} from '../../simulation/SimulationSession'; +import type {UrdfBaseMode,UrdfLoadMode} from '../../simulation/PhysicsAdapter'; +import type {ViewerSelection} from '../../viewer/MuJoCoViewer'; +import {Badge,Button,CollapsibleSection,CopyButton,PropertyRow,ResizablePanel,Select,Tabs} from '../../components/ui'; +import {TreeSearchField} from './TreeSearchField'; +import {ProjectBreadcrumb} from './ProjectBreadcrumb'; + +export function SidebarPanel({title,side,children,visible=true}:{title:string;side:'left'|'right';children:ReactNode;visible?:boolean}){return ;} + +export function ProjectSidebar({projectName,files,entries,selectedEntry,snapshot,loading,visible=true,onRemove,onSelectEntry,onJointHover}:{projectName?:string;files:ProjectTreeFile[];entries:ModelEntry[];selectedEntry?:string;snapshot?:SimulationSnapshot;loading:boolean;visible?:boolean;onRemove:()=>void;onSelectEntry:(path:string)=>void;onJointHover:(jointId:number|null)=>void}){const [tab,setTab]=useState<'project'|'structure'>('project'),[fileQuery,setFileQuery]=useState(''),[structureQuery,setStructureQuery]=useState('');const fileMatches=countProjectSearchResults(files,fileQuery),structureMatches=snapshot?countModelStructureSearchResults(snapshot.bodies,snapshot.joints,structureQuery):0;return {projectName?<>
    {projectName}
    {files.length} 个文件
    ,content:<>
    },{value:'structure',label:'模型结构',icon:,disabled:!snapshot,content:snapshot?<>
    :

    加载模型后显示结构

    }]}/>:
    导入模型后显示工程资源
    }
    ;} + +interface ModelControlsProps{ + snapshot?:SimulationSnapshot;selection:ViewerSelection|null;selectedFormat?:ModelEntry['format'];loading:boolean;visible?:boolean; + urdfMode:UrdfLoadMode;baseMode:UrdfBaseMode;showCollision:boolean;ignoreJointLimits:boolean;jointAdvanced:boolean;angleUnit:'rad'|'deg';forceScale:number; + onUrdfMode:(value:UrdfLoadMode)=>void;onBaseMode:(value:UrdfBaseMode)=>void;onShowCollision:(value:boolean)=>void; + onResetJoints:()=>void;onToggleJointLimits:()=>void;onToggleAdvanced:()=>void;onToggleAngleUnit:()=>void; + onActuator:(id:number,value:number)=>void;onJoint:(id:number,value:number)=>void;onForceScale:(value:number)=>void; +} +export function ModelControlsSidebar(props:ModelControlsProps){const [tab,setTab]=useState<'properties'|'controls'>('properties'),s=props.snapshot;if(!s)return
    导入模型后显示属性
    ; + const properties=<>{s.model.nbody} Body}>
    + {props.selectedFormat==='urdf'&&

    MJCF 模式保留 visual mesh、添加物理地面,并将模型最低点对齐到 z=0。

    } + {props.selection?
    }/>}/>value.toFixed(3)).join(', ')} action={}/>
    :

    在视口中单击物体

    }
    ; + const controls=<>{s.actuators.length}}>{s.actuators.length?s.actuators.map(actuator=>props.onActuator(actuator.id,value)}/>):

    模型没有 actuator

    }
    + {s.joints.length}}>
    {s.joints.map(joint=>{const scale=joint.type===3&&props.angleUnit==='deg'?180/Math.PI:1,unit=joint.type===3?(props.angleUnit==='deg'?'°':' rad'):joint.type===2?' m':'';return props.onJoint(joint.id,value/scale)}/>;})}
    +

    选择“外力施加”,在动态物体上按住拖动,松开即清零。

    ; + return ,content:properties},{value:'controls',label:'控制',icon:,content:controls}]}/>; +} +function Check({label,checked,onChange}:{label:string;checked:boolean;onChange:(value:boolean)=>void}){return ;} +function ControlSlider({label,value,min,max,onChange,disabled=false,unit='',advanced=false,limited=false,limitsIgnored=false,limitMin=0,limitMax=0}:{label:string;value:number;min:number;max:number;onChange:(value:number)=>void;disabled?:boolean;unit?:string;advanced?:boolean;limited?:boolean;limitsIgnored?:boolean;limitMin?:number;limitMax?:number}){const sane=Number.isFinite(value)?value:0,format=(number:number)=>`${number.toFixed(3)}${unit}`;return ;} diff --git a/wasm/web_platform/src/app/components/StatusBar.tsx b/wasm/web_platform/src/app/components/StatusBar.tsx new file mode 100644 index 00000000..6ab8cfd4 --- /dev/null +++ b/wasm/web_platform/src/app/components/StatusBar.tsx @@ -0,0 +1,7 @@ +import type {ReactNode} from 'react'; +import {Box,Clock3,MemoryStick,TriangleAlert} from 'lucide-react'; +import {Kbd} from '../../components/ui'; +import {PerformancePopover} from './PerformancePopover'; +export interface StatusBarProps{time?:number;fps:number;stepMs:number;memoryMb?:number;loaded:boolean;overBudget:boolean;} +function Item({icon:Icon,children}:{icon:typeof Clock3;children:ReactNode}){return ;} +export function StatusBar({time,fps,stepMs,memoryMb,loaded,overBudget}:StatusBarProps){return
    时间 {time?.toFixed(3)??'—'} s内存 {memoryMb===undefined?'—':`${memoryMb.toFixed(1)} MiB`}WASM {loaded?'已加载':'未加载'}{overBudget&&主线程超出步进预算,已限制追帧}Space 播放/暂停 · R 重置 · 1/2/3 模式
    ;} diff --git a/wasm/web_platform/src/app/components/ThirdBatchComponents.test.tsx b/wasm/web_platform/src/app/components/ThirdBatchComponents.test.tsx new file mode 100644 index 00000000..e98856f7 --- /dev/null +++ b/wasm/web_platform/src/app/components/ThirdBatchComponents.test.tsx @@ -0,0 +1,8 @@ +import {fireEvent,render,screen} from '@testing-library/react'; +import {CommandPalette,type WorkbenchCommand} from './CommandPalette'; +import {PerformancePopover} from './PerformancePopover'; + +describe('第三批工作台组件',()=>{ + it('命令面板可搜索并执行现有命令',()=>{const run=vi.fn(),close=vi.fn(),commands:WorkbenchCommand[]=[{id:'reset',label:'重置仿真',group:'仿真',run},{id:'theme',label:'切换主题',group:'外观',run:vi.fn()}];render();const input=screen.getByLabelText('搜索命令');fireEvent.change(input,{target:{value:'重置'}});expect(screen.queryByText('切换主题')).not.toBeInTheDocument();fireEvent.keyDown(input,{key:'Enter'});expect(run).toHaveBeenCalledTimes(1);expect(close).toHaveBeenCalledTimes(1);expect(input).toHaveAttribute('aria-controls');expect(input).toHaveAttribute('aria-activedescendant');}); + it('状态栏性能入口展示已有指标',()=>{render();fireEvent.click(screen.getByRole('button'));expect(screen.getByRole('dialog',{name:'性能详情'})).toHaveTextContent('60 FPS');expect(screen.getByRole('dialog',{name:'性能详情'})).toHaveTextContent('42.5 MiB');fireEvent.keyDown(document,{key:'k',ctrlKey:true});expect(screen.queryByRole('dialog',{name:'性能详情'})).not.toBeInTheDocument();}); +}); diff --git a/wasm/web_platform/src/app/components/ToolbarOverflowMenu.tsx b/wasm/web_platform/src/app/components/ToolbarOverflowMenu.tsx new file mode 100644 index 00000000..56752379 --- /dev/null +++ b/wasm/web_platform/src/app/components/ToolbarOverflowMenu.tsx @@ -0,0 +1,3 @@ +import {CircleHelp,Expand,LayoutDashboard,Maximize,Search,Settings,SunMoon} from 'lucide-react'; +import {DropdownMenu} from '../../components/ui'; +export function ToolbarOverflowMenu({fullscreen,onCommands,onLayout,onSettings,onFullscreen,onHelp,onTheme}:{fullscreen:boolean;onCommands:()=>void;onLayout:()=>void;onSettings:()=>void;onFullscreen:()=>void;onHelp:()=>void;onTheme:()=>void}){return ,onSelect:onCommands},{id:'layout',label:'布局设置',icon:,onSelect:onLayout},{id:'settings',label:'工作台设置',icon:,onSelect:onSettings},{id:'fullscreen',label:fullscreen?'退出全屏':'进入全屏',icon:fullscreen?:,onSelect:onFullscreen},{id:'help',label:'快捷键帮助',icon:,onSelect:onHelp},{id:'theme',label:'切换主题',icon:,onSelect:onTheme}]}/>;} diff --git a/wasm/web_platform/src/app/components/TreeSearchField.tsx b/wasm/web_platform/src/app/components/TreeSearchField.tsx new file mode 100644 index 00000000..cb983f15 --- /dev/null +++ b/wasm/web_platform/src/app/components/TreeSearchField.tsx @@ -0,0 +1,3 @@ +import {Search,X} from 'lucide-react'; +import {IconButton} from '../../components/ui'; +export function TreeSearchField({value,onChange,resultCount,placeholder='搜索…',label='搜索树'}:{value:string;onChange:(value:string)=>void;resultCount?:number;placeholder?:string;label?:string}){return
    {value&&resultCount!==undefined&&

    找到 {resultCount} 个匹配项

    }
    ;} diff --git a/wasm/web_platform/src/app/components/ViewerToolDock.tsx b/wasm/web_platform/src/app/components/ViewerToolDock.tsx new file mode 100644 index 00000000..c137a104 --- /dev/null +++ b/wasm/web_platform/src/app/components/ViewerToolDock.tsx @@ -0,0 +1,5 @@ +import {Crosshair,Hand,MousePointer2,RotateCcw} from 'lucide-react'; +import type {InteractionMode} from '../../viewer/MuJoCoViewer'; +import {IconButton,ToolbarToggleGroup,type ToolbarItem} from '../../components/ui'; +const tools:ToolbarItem[]=[{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
    ;} diff --git a/wasm/web_platform/src/app/components/ViewportHUD.tsx b/wasm/web_platform/src/app/components/ViewportHUD.tsx new file mode 100644 index 00000000..a00046cb --- /dev/null +++ b/wasm/web_platform/src/app/components/ViewportHUD.tsx @@ -0,0 +1,5 @@ +import {CirclePause,CirclePlay,MousePointer2} from 'lucide-react'; +import type {InteractionMode,ViewerSelection} from '../../viewer/MuJoCoViewer'; +import {Badge} from '../../components/ui'; +const labels:Record={select:'选择',joint:'关节拖动',force:'外力施加'}; +export function ViewportHUD({paused,mode,selection,ready}:{paused:boolean;mode:InteractionMode;selection:ViewerSelection|null;ready:boolean}){if(!ready)return null;return
    {paused?:}{paused?'已暂停':'仿真中'}{labels[mode]}{selection&&{selection.bodyName}}
    ;} diff --git a/wasm/web_platform/src/app/components/WorkbenchHeader.test.tsx b/wasm/web_platform/src/app/components/WorkbenchHeader.test.tsx new file mode 100644 index 00000000..4218df1a --- /dev/null +++ b/wasm/web_platform/src/app/components/WorkbenchHeader.test.tsx @@ -0,0 +1,4 @@ +import {fireEvent,render,screen} from '@testing-library/react'; +import {WorkbenchHeader} from './WorkbenchHeader'; +const fn=()=>{}; +describe('WorkbenchHeader',()=>{it('透传仿真动作且保留可访问名称',()=>{const pause=vi.fn(),step=vi.fn(),reset=vi.fn(),speed=vi.fn();render(工具} onFiles={fn} onFolder={fn} onTogglePause={pause} onStep={step} onReset={reset} onSpeed={speed} onToggleLeft={fn} onToggleRight={fn} onToggleTheme={fn} onHelp={fn} onCommands={fn} onToggleFullscreen={fn}/>);fireEvent.click(screen.getByRole('button',{name:'▶ 播放'}));fireEvent.click(screen.getByRole('button',{name:'单步'}));fireEvent.click(screen.getByRole('button',{name:'重置'}));fireEvent.change(screen.getByLabelText('仿真速度'),{target:{value:'2'}});expect(pause).toHaveBeenCalledTimes(1);expect(step).toHaveBeenCalledTimes(1);expect(reset).toHaveBeenCalledTimes(1);expect(speed).toHaveBeenCalledWith(2);expect(screen.getByRole('button',{name:'切换到白天主题'})).toBeInTheDocument();expect(screen.getByRole('button',{name:'隐藏工程面板'})).toHaveAttribute('aria-expanded','true');expect(screen.getByRole('button',{name:'打开命令面板'})).toBeInTheDocument();expect(screen.getByRole('button',{name:'进入全屏'})).toBeInTheDocument();});}); diff --git a/wasm/web_platform/src/app/components/WorkbenchHeader.tsx b/wasm/web_platform/src/app/components/WorkbenchHeader.tsx new file mode 100644 index 00000000..ee67086c --- /dev/null +++ b/wasm/web_platform/src/app/components/WorkbenchHeader.tsx @@ -0,0 +1,5 @@ +import type {ChangeEvent,ReactNode} from 'react'; +import {CircleHelp,Expand,FolderOpen,Minimize,PanelLeft,PanelRight,Pause,Play,RotateCcw,Search,StepForward,Sun,Moon,Upload} from 'lucide-react'; +import {Button,IconButton,Select} from '../../components/ui'; +const fileActionClass='inline-flex h-7 cursor-pointer items-center gap-1.5 rounded-md border border-border bg-surface px-2 text-xs font-medium text-text-primary transition-colors hover:bg-element-hover focus-within:ring-2 focus-within:ring-accent/30'; +export function WorkbenchHeader({paused,ready,speed,theme,loading,leftOpen,rightOpen,fullscreen,center,endActions,compactMenu,onFiles,onFolder,onTogglePause,onStep,onReset,onSpeed,onToggleLeft,onToggleRight,onToggleTheme,onHelp,onCommands,onToggleFullscreen}:{paused:boolean;ready:boolean;speed:number;theme:'light'|'dark';loading:boolean;leftOpen:boolean;rightOpen:boolean;fullscreen:boolean;center:ReactNode;endActions?:ReactNode;compactMenu?:ReactNode;onFiles:(event:ChangeEvent)=>void;onFolder:(event:ChangeEvent)=>void;onTogglePause:()=>void;onStep:()=>void;onReset:()=>void;onSpeed:(value:number)=>void;onToggleLeft:()=>void;onToggleRight:()=>void;onToggleTheme:()=>void;onHelp:()=>void;onCommands:()=>void;onToggleFullscreen:()=>void}){return

    MuJoCo Web 仿真平台

    {center}
    {endActions}{compactMenu}{fullscreen?:}{theme==='dark'?:}
    ;} diff --git a/wasm/web_platform/src/app/components/WorkspaceOverlays.tsx b/wasm/web_platform/src/app/components/WorkspaceOverlays.tsx new file mode 100644 index 00000000..1f4dd69c --- /dev/null +++ b/wasm/web_platform/src/app/components/WorkspaceOverlays.tsx @@ -0,0 +1,5 @@ +import {Box,LoaderCircle,UploadCloud} from 'lucide-react'; +import {ProgressBar,Skeleton} from '../../components/ui'; +export interface ImportProgress{label:string;value:number;} +export function EmptyWorkspace({compact=false}:{compact?:boolean}){return
    {compact?:}

    拖放模型工程到此处

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

    ;} +export function WorkspaceOverlays({loading,hasSnapshot,progress}:{loading:boolean;hasSnapshot:boolean;progress?:ImportProgress}){return <>{!hasSnapshot&&!loading&&
    }{loading&&
    {progress?
    :
    }
    };} diff --git a/wasm/web_platform/src/components/ui/Badge.tsx b/wasm/web_platform/src/components/ui/Badge.tsx new file mode 100644 index 00000000..422ba818 --- /dev/null +++ b/wasm/web_platform/src/components/ui/Badge.tsx @@ -0,0 +1,2 @@ +import type {ReactNode} from 'react'; +export function Badge({children,tone='neutral',className='',title}:{children:ReactNode;tone?:'neutral'|'accent'|'success'|'warning';className?:string;title?:string}){const toneClass={neutral:'border-border bg-surface text-text-secondary',accent:'border-success-border bg-accent-soft text-accent',success:'border-success-border bg-success-soft text-success',warning:'border-warning-border bg-warning-soft text-warning'}[tone];return {children};} diff --git a/wasm/web_platform/src/components/ui/Button.tsx b/wasm/web_platform/src/components/ui/Button.tsx new file mode 100644 index 00000000..3665c390 --- /dev/null +++ b/wasm/web_platform/src/components/ui/Button.tsx @@ -0,0 +1,18 @@ +import type {ButtonHTMLAttributes,ReactNode} from 'react'; + +export interface ButtonProps extends ButtonHTMLAttributes{ + variant?:'primary'|'secondary'|'ghost'|'danger'; + size?:'sm'|'md'|'icon'; + icon?:ReactNode; +} + +export function Button({variant='secondary',size='sm',icon,className='',children,type='button',...props}:ButtonProps){ + const variants={ + primary:'border-transparent bg-accent text-white hover:bg-accent-hover', + secondary:'border-border bg-surface text-text-primary hover:bg-element-hover', + ghost:'border-transparent bg-transparent text-text-secondary hover:bg-element-hover hover:text-text-primary', + danger:'border-danger-border bg-danger-soft text-danger hover:bg-danger hover:text-white', + }; + const sizes={sm:'h-7 gap-1.5 rounded-md px-2 text-xs',md:'h-8 gap-2 rounded-md px-3 text-sm',icon:'h-7 w-7 rounded-md p-0'}; + return ; +} diff --git a/wasm/web_platform/src/components/ui/CollapsibleSection.test.tsx b/wasm/web_platform/src/components/ui/CollapsibleSection.test.tsx new file mode 100644 index 00000000..24bcc192 --- /dev/null +++ b/wasm/web_platform/src/components/ui/CollapsibleSection.test.tsx @@ -0,0 +1,3 @@ +import {fireEvent,render,screen} from '@testing-library/react'; +import {CollapsibleSection} from './CollapsibleSection'; +describe('CollapsibleSection',()=>{it('遵循默认折叠状态并可展开',()=>{render(内容);const trigger=screen.getByRole('button',{name:'低频设置'});expect(trigger).toHaveAttribute('aria-expanded','false');expect(screen.queryByText('内容')).not.toBeInTheDocument();fireEvent.click(trigger);expect(trigger).toHaveAttribute('aria-expanded','true');expect(screen.getByText('内容')).toBeVisible();});it('forceOpen 时保持内容可见',()=>{render(错误详情);expect(screen.getByText('错误详情')).toBeVisible();});}); diff --git a/wasm/web_platform/src/components/ui/CollapsibleSection.tsx b/wasm/web_platform/src/components/ui/CollapsibleSection.tsx new file mode 100644 index 00000000..bfd54ddc --- /dev/null +++ b/wasm/web_platform/src/components/ui/CollapsibleSection.tsx @@ -0,0 +1,11 @@ +import {useState,type ReactNode} from 'react'; +import {ChevronRight} from 'lucide-react'; +export function CollapsibleSection({title,children,defaultOpen=true,forceOpen=false,badge}:{title:string;children:ReactNode;defaultOpen?:boolean;forceOpen?:boolean;badge?:ReactNode}){ + const [open,setOpen]=useState(defaultOpen);const expanded=forceOpen||open; + return
    + + {expanded&&
    {children}
    } +
    ; +} diff --git a/wasm/web_platform/src/components/ui/ConfirmDialog.tsx b/wasm/web_platform/src/components/ui/ConfirmDialog.tsx new file mode 100644 index 00000000..b2b7c7ab --- /dev/null +++ b/wasm/web_platform/src/components/ui/ConfirmDialog.tsx @@ -0,0 +1,4 @@ +import type {ReactNode} from 'react'; +import {Button} from './Button'; +import {Dialog} from './Dialog'; +export function ConfirmDialog({open,title,children,confirmLabel='确认',cancelLabel='取消',danger=false,onConfirm,onClose}:{open:boolean;title:string;children:ReactNode;confirmLabel?:string;cancelLabel?:string;danger?:boolean;onConfirm:()=>void;onClose:()=>void}){return }>{children};} diff --git a/wasm/web_platform/src/components/ui/CopyButton.tsx b/wasm/web_platform/src/components/ui/CopyButton.tsx new file mode 100644 index 00000000..111e05d8 --- /dev/null +++ b/wasm/web_platform/src/components/ui/CopyButton.tsx @@ -0,0 +1,4 @@ +import {useEffect,useState} from 'react'; +import {Check,Copy} from 'lucide-react'; +import {IconButton} from './IconButton'; +export function CopyButton({value,label='复制'}:{value:string;label?:string}){const [copied,setCopied]=useState(false);useEffect(()=>{if(!copied)return;const timer=window.setTimeout(()=>setCopied(false),1200);return()=>window.clearTimeout(timer);},[copied]);return void (async()=>{try{if(!navigator.clipboard?.writeText)return;await navigator.clipboard.writeText(value);setCopied(true);}catch{setCopied(false);}})()} className="h-5 w-5">{copied?:};} diff --git a/wasm/web_platform/src/components/ui/Dialog.test.tsx b/wasm/web_platform/src/components/ui/Dialog.test.tsx new file mode 100644 index 00000000..a1034d70 --- /dev/null +++ b/wasm/web_platform/src/components/ui/Dialog.test.tsx @@ -0,0 +1,6 @@ +import {fireEvent,render,screen} from '@testing-library/react'; +import {useState} from 'react'; +import {Dialog} from './Dialog'; + +function Fixture(){const [open,setOpen]=useState(false);return <>setOpen(false)} title="入口选择">;} +describe('Dialog',()=>{it('支持 Escape 关闭并恢复触发器焦点',()=>{render();const trigger=screen.getByRole('button',{name:'打开'});trigger.focus();fireEvent.click(trigger);expect(screen.getByRole('dialog')).toBeVisible();fireEvent.keyDown(document,{key:'Escape'});expect(screen.queryByRole('dialog')).not.toBeInTheDocument();expect(trigger).toHaveFocus();});it('将 Tab 焦点限制在弹窗内',()=>{render();fireEvent.click(screen.getByRole('button',{name:'打开'}));const first=screen.getByRole('button',{name:'关闭'}),last=screen.getByRole('button',{name:'最后一个'});last.focus();fireEvent.keyDown(document,{key:'Tab'});expect(first).toHaveFocus();first.focus();fireEvent.keyDown(document,{key:'Tab',shiftKey:true});expect(last).toHaveFocus();});it('全屏时将 Portal 挂载到全屏元素内部',()=>{const host=document.createElement('div');document.body.append(host);Object.defineProperty(document,'fullscreenElement',{configurable:true,value:host});const {unmount}=render({}} title="全屏弹窗">内容);expect(host).toContainElement(screen.getByRole('dialog'));unmount();Object.defineProperty(document,'fullscreenElement',{configurable:true,value:null});host.remove();});}); diff --git a/wasm/web_platform/src/components/ui/Dialog.tsx b/wasm/web_platform/src/components/ui/Dialog.tsx new file mode 100644 index 00000000..82c57871 --- /dev/null +++ b/wasm/web_platform/src/components/ui/Dialog.tsx @@ -0,0 +1,14 @@ +import {useEffect,useId,useRef,type ReactNode} from 'react'; +import {createPortal} from 'react-dom'; +import {X} from 'lucide-react'; +import {IconButton} from './IconButton'; + +const FOCUSABLE='button:not([disabled]),a[href],input:not([disabled]),select:not([disabled]),textarea:not([disabled]),[tabindex]:not([tabindex="-1"])'; +export function Dialog({open,onClose,title,children,footer,className='',closable=true}:{open:boolean;onClose:()=>void;title:string;children:ReactNode;footer?:ReactNode;className?:string;closable?:boolean}){ + const ref=useRef(null),previous=useRef(null),onCloseRef=useRef(onClose),titleId=useId(); + useEffect(()=>{onCloseRef.current=onClose;},[onClose]); + useEffect(()=>{if(!open)return;previous.current=document.activeElement instanceof HTMLElement?document.activeElement:null;ref.current?.focus();const key=(event:KeyboardEvent)=>{if(event.key==='Escape'&&closable){event.preventDefault();onCloseRef.current();return;}if(event.key!=='Tab'||!ref.current)return;const items=Array.from(ref.current.querySelectorAll(FOCUSABLE));if(!items.length){event.preventDefault();ref.current.focus();return;}const first=items[0],last=items.at(-1)!;if(event.shiftKey&&document.activeElement===first){event.preventDefault();last.focus();}else if(!event.shiftKey&&document.activeElement===last){event.preventDefault();first.focus();}};document.addEventListener('keydown',key);return()=>{document.removeEventListener('keydown',key);if(previous.current&&document.contains(previous.current))previous.current.focus();};},[open,closable]); + if(!open)return null; + const backdrop=;} diff --git a/wasm/web_platform/src/components/ui/SearchHighlight.tsx b/wasm/web_platform/src/components/ui/SearchHighlight.tsx new file mode 100644 index 00000000..2325b817 --- /dev/null +++ b/wasm/web_platform/src/components/ui/SearchHighlight.tsx @@ -0,0 +1 @@ +export function SearchHighlight({text,query}:{text:string;query:string}){const needle=query.trim().toLocaleLowerCase();if(!needle)return <>{text};const parts:({text:string;match:boolean})[]=[];let start=0,index=text.toLocaleLowerCase().indexOf(needle);while(index>=0){if(index>start)parts.push({text:text.slice(start,index),match:false});parts.push({text:text.slice(index,index+needle.length),match:true});start=index+needle.length;index=text.toLocaleLowerCase().indexOf(needle,start);}if(start{parts.map((part,i)=>part.match?{part.text}:part.text)};} diff --git a/wasm/web_platform/src/components/ui/SearchableCombobox.tsx b/wasm/web_platform/src/components/ui/SearchableCombobox.tsx new file mode 100644 index 00000000..3336421e --- /dev/null +++ b/wasm/web_platform/src/components/ui/SearchableCombobox.tsx @@ -0,0 +1,5 @@ +import {useId,useMemo,useState} from 'react'; +import {Check,ChevronsUpDown,Search} from 'lucide-react'; +import {Popover} from './Popover'; +export interface ComboboxOption{value:string;label:string;description?:string;} +export function SearchableCombobox({options,value,onChange,label,disabled=false}:{options:ComboboxOption[];value?:string;onChange:(value:string)=>void;label:string;disabled?:boolean}){const [query,setQuery]=useState(''),[active,setActive]=useState(0),listId=useId(),selected=options.find(option=>option.value===value),filtered=useMemo(()=>{const needle=query.trim().toLocaleLowerCase();return options.filter(option=>!needle||`${option.label} ${option.description??''}`.toLocaleLowerCase().includes(needle));},[options,query]);return }>{({close})=>
    {setQuery(event.target.value);setActive(0);}} onKeyDown={event=>{if(!filtered.length)return;if(event.key==='ArrowDown'){event.preventDefault();setActive(index=>(index+1)%filtered.length);}else if(event.key==='ArrowUp'){event.preventDefault();setActive(index=>(index-1+filtered.length)%filtered.length);}else if(event.key==='Enter'){event.preventDefault();onChange(filtered[active].value);setQuery('');close();}else if(event.key==='Tab')close(false);}} className="h-8 w-full rounded border border-border bg-input pl-7 pr-2 text-xs"/>
    {filtered.map((option,index)=>)}
    }
    ;} diff --git a/wasm/web_platform/src/components/ui/SecondBatchUi.test.tsx b/wasm/web_platform/src/components/ui/SecondBatchUi.test.tsx new file mode 100644 index 00000000..c673a5b6 --- /dev/null +++ b/wasm/web_platform/src/components/ui/SecondBatchUi.test.tsx @@ -0,0 +1,9 @@ +import {useState} from 'react'; +import {act,fireEvent,render,screen} from '@testing-library/react'; +import {Badge,ResizablePanel,Separator,Skeleton,Tabs} from './index'; +function TabHarness(){const [value,setValue]=useState<'a'|'b'>('a');return 甲内容},{value:'b',label:'乙',content:乙内容}]}/>;} +describe('第二批基础 UI',()=>{ + it('Tabs 保留面板 DOM并支持方向键导航',()=>{render();const first=screen.getByRole('tab',{name:'甲'});expect(first).toHaveAttribute('tabindex','0');fireEvent.keyDown(first,{key:'ArrowRight'});expect(screen.getByRole('tab',{name:'乙'})).toHaveAttribute('aria-selected','true');expect(screen.getByText('乙内容')).toBeVisible();expect(screen.getByText('甲内容').closest('[role="tabpanel"]')).toHaveAttribute('hidden');}); + it('ResizablePanel 支持键盘调整、限制异常持久值并保存宽度',()=>{localStorage.setItem('test-width','9999');render(
    面板
    );const handle=screen.getByRole('separator',{name:'调整工程面板宽度'});expect(Number(handle.getAttribute('aria-valuenow'))).toBeLessThanOrEqual(Number(handle.getAttribute('aria-valuemax')));fireEvent.keyDown(handle,{key:'Home'});expect(handle).toHaveAttribute('aria-valuenow','224');expect(localStorage.getItem('test-width')).toBe('224');act(()=>window.dispatchEvent(new CustomEvent('mujoco-layout-widths',{detail:{left:320,right:360}})));expect(handle).toHaveAttribute('aria-valuenow','320');}); + it('Badge、Separator 和 Skeleton 可渲染',()=>{render(<>状态);expect(screen.getByText('状态')).toBeVisible();expect(screen.getByRole('separator')).toBeVisible();}); +}); diff --git a/wasm/web_platform/src/components/ui/Select.tsx b/wasm/web_platform/src/components/ui/Select.tsx new file mode 100644 index 00000000..b3a58dee --- /dev/null +++ b/wasm/web_platform/src/components/ui/Select.tsx @@ -0,0 +1,2 @@ +import type {SelectHTMLAttributes} from 'react'; +export function Select({className='',...props}:SelectHTMLAttributes){return