chore(web-platform): release V0.6.1 工程质量优化
web-platform-ci / TypeScript, lint, unit, build (push) Has been cancelled
web-platform-ci / Playwright E2E (push) Has been cancelled

This commit is contained in:
2026-08-28 15:38:10 +08:00
parent f4b415c54f
commit 60d3a6d68c
135 changed files with 13552 additions and 2882 deletions
+1179 -115
View File
File diff suppressed because it is too large Load Diff
+28 -3
View File
@@ -1,3 +1,28 @@
import {Component,type ErrorInfo,type ReactNode} from 'react';
import {Button} from '../components/ui';
export class ErrorBoundary extends Component<{children:ReactNode},{error?:Error}>{state:{error?:Error}={};static getDerivedStateFromError(error:Error){return {error};}componentDidCatch(error:Error,info:ErrorInfo){console.error('React fatal error',error,info);}render(){return this.state.error?<main className="grid h-screen place-items-center bg-app text-text-primary"><section className="max-w-xl rounded-xl border border-danger-border bg-panel p-6 shadow-xl"><h1 className="text-xl font-semibold">界面发生致命错误</h1><pre className="mt-3 whitespace-pre-wrap text-sm text-danger">{this.state.error.message}</pre><Button variant="danger" className="mt-4" onClick={()=>location.reload()}>重新加载</Button></section></main>:this.props.children;}}
import { Component, type ErrorInfo, type ReactNode } from 'react';
import { Button } from '../components/ui';
export class ErrorBoundary extends Component<{ children: ReactNode }, { error?: Error }> {
state: { error?: Error } = {};
static getDerivedStateFromError(error: Error) {
return { error };
}
componentDidCatch(error: Error, info: ErrorInfo) {
console.error('React fatal error', error, info);
}
render() {
return this.state.error ? (
<main className="grid h-screen place-items-center bg-app text-text-primary">
<section className="max-w-xl rounded-xl border border-danger-border bg-panel p-6 shadow-xl">
<h1 className="text-xl font-semibold">界面发生致命错误</h1>
<pre className="mt-3 whitespace-pre-wrap text-sm text-danger">
{this.state.error.message}
</pre>
<Button variant="danger" className="mt-4" onClick={() => location.reload()}>
重新加载
</Button>
</section>
</main>
) : (
this.props.children
);
}
}
@@ -1,39 +1,96 @@
import {fireEvent,render,screen} from '@testing-library/react';
import {ActuatorControl} from './SidebarPanel';
import type {ActuatorInfo} from '../../simulation/SimulationSession';
import { fireEvent, render, screen } from '@testing-library/react';
import { ActuatorControl } from './SidebarPanel';
import type { ActuatorInfo } from '../../simulation/SimulationSession';
const actuator:ActuatorInfo={id:0,name:'shoulder_motor',value:.5,min:-1,max:1,limited:true,jointId:0,jointName:'shoulder',jointType:3,unit:'N·m',kind:'motor',controlCount:1,gear:2,gain:1,kp:0,kv:0,ctrlLimited:true,ctrlMin:-1,ctrlMax:1,forceLimited:true,forceMin:-20,forceMax:20};
const actuator: ActuatorInfo = {
id: 0,
name: 'shoulder_motor',
value: 0.5,
min: -1,
max: 1,
limited: true,
jointId: 0,
jointName: 'shoulder',
jointType: 3,
unit: 'N·m',
kind: 'motor',
controlCount: 1,
gear: 2,
gain: 1,
kp: 0,
kv: 0,
ctrlLimited: true,
ctrlMin: -1,
ctrlMax: 1,
forceLimited: true,
forceMin: -20,
forceMax: 20,
};
describe('ActuatorControl',()=>{
it('显示对应关节和常用力矩单位',()=>{
render(<ActuatorControl actuator={actuator} onControl={()=>{}} onParameters={()=>{}}/>);
describe('ActuatorControl', () => {
it('显示对应关节和常用力矩单位', () => {
render(<ActuatorControl actuator={actuator} onControl={() => {}} onParameters={() => {}} />);
expect(screen.getByText('shoulder_motor')).toBeVisible();
expect(screen.getByText('关节:shoulder')).toBeVisible();
expect(screen.getByText('1.000 N·m')).toBeVisible();
});
it('内部按 gear 换算输出,但参数面板只开放 kp、kv 等业务参数',()=>{
const onControl=vi.fn(),onParameters=vi.fn();
render(<ActuatorControl actuator={actuator} onControl={onControl} onParameters={onParameters}/>);
fireEvent.change(screen.getByRole('slider'),{target:{value:'2'}});
it('内部按 gear 换算输出,但参数面板只开放 kp、kv 等业务参数', () => {
const onControl = vi.fn(),
onParameters = vi.fn();
render(
<ActuatorControl actuator={actuator} onControl={onControl} onParameters={onParameters} />,
);
fireEvent.change(screen.getByRole('slider'), { target: { value: '2' } });
expect(onControl).toHaveBeenCalledWith(1);
fireEvent.click(screen.getByText('常用参数'));
expect(screen.queryByLabelText('传动比 gear')).not.toBeInTheDocument();
expect(screen.queryByLabelText('固定增益 gain')).not.toBeInTheDocument();
const kp=screen.getByLabelText(/kp(MJCF stiffness/);fireEvent.change(kp,{target:{value:'3'}});fireEvent.blur(kp);
expect(onParameters).toHaveBeenCalledWith(expect.objectContaining({kp:3,ctrlLimited:true,forceLimited:true}));
const kp = screen.getByLabelText(/kp(MJCF stiffness/);
fireEvent.change(kp, { target: { value: '3' } });
fireEvent.blur(kp);
expect(onParameters).toHaveBeenCalledWith(
expect.objectContaining({ kp: 3, ctrlLimited: true, forceLimited: true }),
);
});
it('position 伺服使用角度目标并开放 kp、kv',()=>{
const onParameters=vi.fn();
render(<ActuatorControl actuator={{...actuator,name:'shoulder_servo',kind:'position',unit:'°',value:Math.PI/2,min:-Math.PI,max:Math.PI,gear:1,kp:100,kv:10,gain:100}} onControl={()=>{}} onParameters={onParameters}/>);
expect(screen.getByText('90.000 °')).toBeVisible();fireEvent.click(screen.getByText('常用参数'));
const kp=screen.getByLabelText(/位置增益 kp/);fireEvent.change(kp,{target:{value:'150'}});fireEvent.blur(kp);
expect(onParameters).toHaveBeenCalledWith(expect.objectContaining({kp:150,kv:10}));
it('position 伺服使用角度目标并开放 kp、kv', () => {
const onParameters = vi.fn();
render(
<ActuatorControl
actuator={{
...actuator,
name: 'shoulder_servo',
kind: 'position',
unit: '°',
value: Math.PI / 2,
min: -Math.PI,
max: Math.PI,
gear: 1,
kp: 100,
kv: 10,
gain: 100,
}}
onControl={() => {}}
onParameters={onParameters}
/>,
);
expect(screen.getByText('90.000 °')).toBeVisible();
fireEvent.click(screen.getByText('常用参数'));
const kp = screen.getByLabelText(/位置增益 kp/);
fireEvent.change(kp, { target: { value: '150' } });
fireEvent.blur(kp);
expect(onParameters).toHaveBeenCalledWith(expect.objectContaining({ kp: 150, kv: 10 }));
});
it('非 motor 驱动器保持原始控制单位且不开放通用参数编辑',()=>{
render(<ActuatorControl actuator={{...actuator,name:'custom',kind:'other',unit:'',value:.25}} onControl={()=>{}} onParameters={()=>{}}/>);
it('非 motor 驱动器保持原始控制单位且不开放通用参数编辑', () => {
render(
<ActuatorControl
actuator={{ ...actuator, name: 'custom', kind: 'other', unit: '', value: 0.25 }}
onControl={() => {}}
onParameters={() => {}}
/>,
);
expect(screen.getByText('0.250')).toBeVisible();
expect(screen.queryByText('常用参数')).not.toBeInTheDocument();
expect(screen.getByText(/不是可直接编辑的 motor\/position/)).toBeVisible();
@@ -1,12 +1,120 @@
import {useEffect,useId,useMemo,useRef,useState,type ReactNode} from 'react';
import {Search} from 'lucide-react';
import {Dialog,EmptySearchState,Kbd} from '../../components/ui';
export interface WorkbenchCommand{id:string;label:string;group:string;icon?:ReactNode;shortcut?:string;disabled?:boolean;run:()=>void;}
export function CommandPalette({open,onClose,commands}:{open:boolean;onClose:()=>void;commands:WorkbenchCommand[]}){
const [query,setQuery]=useState(''),[active,setActive]=useState(0),input=useRef<HTMLInputElement>(null),listId=useId();
const filtered=useMemo(()=>{const needle=query.trim().toLocaleLowerCase();return commands.filter(command=>!needle||`${command.label} ${command.group}`.toLocaleLowerCase().includes(needle));},[commands,query]);
const enabled=filtered.flatMap((command,index)=>command.disabled?[]:[index]),highlighted=filtered[active]&&!filtered[active].disabled?active:(enabled[0]??-1);
useEffect(()=>{if(open)requestAnimationFrame(()=>input.current?.focus());},[open]);
const close=()=>{setQuery('');setActive(0);onClose();},execute=(command?:WorkbenchCommand)=>{if(!command||command.disabled)return;command.run();close();};
return <Dialog open={open} onClose={close} title="命令面板" className="max-w-xl"><div className="relative -m-4 mb-2 border-b border-border"><Search className="pointer-events-none absolute left-4 top-1/2 h-4 w-4 -translate-y-1/2 text-text-tertiary"/><input ref={input} role="combobox" aria-label="搜索命令" aria-autocomplete="list" aria-expanded="true" aria-controls={listId} aria-activedescendant={highlighted>=0?`${listId}-${filtered[highlighted].id}`:undefined} value={query} onChange={event=>{setQuery(event.target.value);setActive(0);}} onKeyDown={event=>{if(!enabled.length)return;const current=Math.max(0,enabled.indexOf(highlighted));if(event.key==='ArrowDown'){event.preventDefault();setActive(enabled[(current+1)%enabled.length]);}else if(event.key==='ArrowUp'){event.preventDefault();setActive(enabled[(current-1+enabled.length)%enabled.length]);}else if(event.key==='Enter'){event.preventDefault();execute(filtered[highlighted]);}}} placeholder="输入命令名称…" className="h-11 w-full bg-input pl-11 pr-4 text-sm text-text-primary placeholder:text-text-tertiary focus:outline-none"/></div><div id={listId} role="listbox" aria-label="可用命令" className="max-h-80 space-y-1 overflow-auto pt-1">{filtered.length?filtered.map((command,index)=><button key={command.id} id={`${listId}-${command.id}`} type="button" role="option" aria-selected={index===highlighted} disabled={command.disabled} onMouseEnter={()=>{if(!command.disabled)setActive(index);}} onClick={()=>execute(command)} className={`flex w-full items-center gap-3 rounded-md px-3 py-2 text-left text-xs outline-none ${index===highlighted?'bg-accent-soft text-accent':'text-text-secondary hover:bg-element-hover'} disabled:opacity-40`}><span className="flex h-5 w-5 items-center justify-center">{command.icon}</span><span className="min-w-0 flex-1"><span className="block truncate font-medium">{command.label}</span><span className="block text-[10px] text-text-tertiary">{command.group}</span></span>{command.shortcut&&<Kbd>{command.shortcut}</Kbd>}</button>):<EmptySearchState label="没有匹配的命令"/>}</div></Dialog>;
import { useEffect, useId, useMemo, useRef, useState, type ReactNode } from 'react';
import { Search } from 'lucide-react';
import { Dialog, EmptySearchState, Kbd } from '../../components/ui';
export interface WorkbenchCommand {
id: string;
label: string;
group: string;
icon?: ReactNode;
shortcut?: string;
disabled?: boolean;
run: () => void;
}
export function CommandPalette({
open,
onClose,
commands,
}: {
open: boolean;
onClose: () => void;
commands: WorkbenchCommand[];
}) {
const [query, setQuery] = useState(''),
[active, setActive] = useState(0),
input = useRef<HTMLInputElement>(null),
listId = useId();
const filtered = useMemo(() => {
const needle = query.trim().toLocaleLowerCase();
return commands.filter(
(command) =>
!needle || `${command.label} ${command.group}`.toLocaleLowerCase().includes(needle),
);
}, [commands, query]);
const enabled = filtered.flatMap((command, index) => (command.disabled ? [] : [index])),
highlighted = filtered[active] && !filtered[active].disabled ? active : (enabled[0] ?? -1);
useEffect(() => {
if (open) requestAnimationFrame(() => input.current?.focus());
}, [open]);
const close = () => {
setQuery('');
setActive(0);
onClose();
},
execute = (command?: WorkbenchCommand) => {
if (!command || command.disabled) return;
command.run();
close();
};
return (
<Dialog open={open} onClose={close} title="命令面板" className="max-w-xl">
<div className="relative -m-4 mb-2 border-b border-border">
<Search className="pointer-events-none absolute left-4 top-1/2 h-4 w-4 -translate-y-1/2 text-text-tertiary" />
<input
ref={input}
role="combobox"
aria-label="搜索命令"
aria-autocomplete="list"
aria-expanded="true"
aria-controls={listId}
aria-activedescendant={
highlighted >= 0 ? `${listId}-${filtered[highlighted].id}` : undefined
}
value={query}
onChange={(event) => {
setQuery(event.target.value);
setActive(0);
}}
onKeyDown={(event) => {
if (!enabled.length) return;
const current = Math.max(0, enabled.indexOf(highlighted));
if (event.key === 'ArrowDown') {
event.preventDefault();
setActive(enabled[(current + 1) % enabled.length]);
} else if (event.key === 'ArrowUp') {
event.preventDefault();
setActive(enabled[(current - 1 + enabled.length) % enabled.length]);
} else if (event.key === 'Enter') {
event.preventDefault();
execute(filtered[highlighted]);
}
}}
placeholder="输入命令名称…"
className="h-11 w-full bg-input pl-11 pr-4 text-sm text-text-primary placeholder:text-text-tertiary focus:outline-none"
/>
</div>
<div
id={listId}
role="listbox"
aria-label="可用命令"
className="max-h-80 space-y-1 overflow-auto pt-1"
>
{filtered.length ? (
filtered.map((command, index) => (
<button
key={command.id}
id={`${listId}-${command.id}`}
type="button"
role="option"
aria-selected={index === highlighted}
disabled={command.disabled}
onMouseEnter={() => {
if (!command.disabled) setActive(index);
}}
onClick={() => execute(command)}
className={`flex w-full items-center gap-3 rounded-md px-3 py-2 text-left text-xs outline-none ${index === highlighted ? 'bg-accent-soft text-accent' : 'text-text-secondary hover:bg-element-hover'} disabled:opacity-40`}
>
<span className="flex h-5 w-5 items-center justify-center">{command.icon}</span>
<span className="min-w-0 flex-1">
<span className="block truncate font-medium">{command.label}</span>
<span className="block text-[10px] text-text-tertiary">{command.group}</span>
</span>
{command.shortcut && <Kbd>{command.shortcut}</Kbd>}
</button>
))
) : (
<EmptySearchState label="没有匹配的命令" />
)}
</div>
</Dialog>
);
}
@@ -1,5 +1,50 @@
import {useState} from 'react';
import {ChevronDown,TriangleAlert,X} from 'lucide-react';
import type {AppDiagnostic} from '../../stores/useAppStore';
import {IconButton} from '../../components/ui';
export function DiagnosticNotice({value,onClose}:{value:AppDiagnostic;onClose:()=>void}){const [expanded,setExpanded]=useState(false);return <section role="alert" className="absolute bottom-4 left-1/2 z-30 w-[min(42rem,calc(100%-2rem))] -translate-x-1/2 overflow-hidden rounded-xl border border-danger-border bg-panel shadow-2xl"><div className="flex items-start gap-3 p-3"><span className="mt-0.5 grid h-7 w-7 shrink-0 place-items-center rounded-full bg-danger-soft text-danger"><TriangleAlert className="h-4 w-4"/></span><div className="min-w-0 flex-1"><h2 className="text-sm font-semibold text-text-primary">{value.summary}</h2>{value.path&&<p className="mt-0.5 truncate text-xs text-text-tertiary" title={value.path}>路径:{value.path}</p>}<button type="button" aria-expanded={expanded} className="mt-1 flex items-center gap-1 text-xs text-danger hover:underline" onClick={()=>setExpanded(v=>!v)}>技术详情<ChevronDown className={`h-3 w-3 ${expanded?'rotate-180':''}`}/></button></div><IconButton aria-label="关闭错误" tooltip="关闭" onClick={onClose}><X className="h-4 w-4"/></IconButton></div>{expanded&&<pre className="max-h-36 overflow-auto border-t border-danger-border bg-danger-soft p-3 text-xs text-danger">{value.detail}</pre>}</section>;}
import { useState } from 'react';
import { ChevronDown, TriangleAlert, X } from 'lucide-react';
import type { AppDiagnostic } from '../../stores/useAppStore';
import { IconButton } from '../../components/ui';
export function DiagnosticNotice({
value,
onClose,
}: {
value: AppDiagnostic;
onClose: () => void;
}) {
const [expanded, setExpanded] = useState(false);
return (
<section
role="alert"
className="absolute bottom-4 left-1/2 z-30 w-[min(42rem,calc(100%-2rem))] -translate-x-1/2 overflow-hidden rounded-xl border border-danger-border bg-panel shadow-2xl"
>
<div className="flex items-start gap-3 p-3">
<span className="mt-0.5 grid h-7 w-7 shrink-0 place-items-center rounded-full bg-danger-soft text-danger">
<TriangleAlert className="h-4 w-4" />
</span>
<div className="min-w-0 flex-1">
<h2 className="text-sm font-semibold text-text-primary">{value.summary}</h2>
{value.path && (
<p className="mt-0.5 truncate text-xs text-text-tertiary" title={value.path}>
路径:{value.path}
</p>
)}
<button
type="button"
aria-expanded={expanded}
className="mt-1 flex items-center gap-1 text-xs text-danger hover:underline"
onClick={() => setExpanded((v) => !v)}
>
技术详情
<ChevronDown className={`h-3 w-3 ${expanded ? 'rotate-180' : ''}`} />
</button>
</div>
<IconButton aria-label="关闭错误" tooltip="关闭" onClick={onClose}>
<X className="h-4 w-4" />
</IconButton>
</div>
{expanded && (
<pre className="max-h-36 overflow-auto border-t border-danger-border bg-danger-soft p-3 text-xs text-danger">
{value.detail}
</pre>
)}
</section>
);
}
@@ -1,6 +1,97 @@
import {useState} from 'react';
import {CheckCircle2,Info,TriangleAlert,XCircle} from 'lucide-react';
import {Button,CopyButton,Dialog,Tabs} from '../../components/ui';
import type {WorkbenchNotification} from './NotificationCenter';
type Filter='all'|'warning'|'danger';
export function DiagnosticsDrawer({open,items,onClose,onClear}:{open:boolean;items:WorkbenchNotification[];onClose:()=>void;onClear:()=>void}){const [filter,setFilter]=useState<Filter>('all');const content=(value:Filter)=>{const filtered=items.filter(item=>value==='all'||item.tone===value);return <div className="space-y-2">{filtered.length?filtered.map(item=>{const Icon=item.tone==='danger'?XCircle:item.tone==='warning'?TriangleAlert:item.tone==='success'?CheckCircle2:Info;return <article key={item.id} className="rounded-lg border border-border bg-surface p-3"><div className="flex items-start gap-2"><Icon className={`mt-0.5 h-4 w-4 ${item.tone==='danger'?'text-danger':item.tone==='warning'?'text-warning':'text-success'}`}/><div className="min-w-0 flex-1"><h3 className="text-xs font-semibold">{item.title}</h3><time className="text-[10px] text-text-tertiary">{new Date(item.at).toLocaleString('zh-CN')}</time>{item.detail&&<pre className="mt-2 whitespace-pre-wrap text-[10px] leading-4 text-text-secondary">{item.detail}</pre>}</div>{item.detail&&<CopyButton value={`${item.title}\n${item.detail}`} label="复制事件详情"/>}</div></article>}):<p className="p-8 text-center text-xs text-text-tertiary">没有符合条件的事件</p>}</div>;};return <Dialog open={open} onClose={onClose} title="诊断与事件日志" className="max-w-2xl" footer={<div className="flex justify-end"><Button variant="danger" disabled={!items.length} onClick={onClear}>清空事件</Button></div>}><Tabs label="事件筛选" value={filter} onValueChange={setFilter} keepMounted={false} items={[{value:'all',label:`全部 ${items.length}`,content:content('all')},{value:'warning',label:`警告 ${items.filter(item=>item.tone==='warning').length}`,content:content('warning')},{value:'danger',label:`错误 ${items.filter(item=>item.tone==='danger').length}`,content:content('danger')}]}/></Dialog>;}
import { useState } from 'react';
import { CheckCircle2, Info, TriangleAlert, XCircle } from 'lucide-react';
import { Button, CopyButton, Dialog, Tabs } from '../../components/ui';
import type { WorkbenchNotification } from './NotificationCenter';
type Filter = 'all' | 'warning' | 'danger';
export function DiagnosticsDrawer({
open,
items,
onClose,
onClear,
}: {
open: boolean;
items: WorkbenchNotification[];
onClose: () => void;
onClear: () => void;
}) {
const [filter, setFilter] = useState<Filter>('all');
const content = (value: Filter) => {
const filtered = items.filter((item) => value === 'all' || item.tone === value);
return (
<div className="space-y-2">
{filtered.length ? (
filtered.map((item) => {
const Icon =
item.tone === 'danger'
? XCircle
: item.tone === 'warning'
? TriangleAlert
: item.tone === 'success'
? CheckCircle2
: Info;
return (
<article key={item.id} className="rounded-lg border border-border bg-surface p-3">
<div className="flex items-start gap-2">
<Icon
className={`mt-0.5 h-4 w-4 ${item.tone === 'danger' ? 'text-danger' : item.tone === 'warning' ? 'text-warning' : 'text-success'}`}
/>
<div className="min-w-0 flex-1">
<h3 className="text-xs font-semibold">{item.title}</h3>
<time className="text-[10px] text-text-tertiary">
{new Date(item.at).toLocaleString('zh-CN')}
</time>
{item.detail && (
<pre className="mt-2 whitespace-pre-wrap text-[10px] leading-4 text-text-secondary">
{item.detail}
</pre>
)}
</div>
{item.detail && (
<CopyButton value={`${item.title}\n${item.detail}`} label="复制事件详情" />
)}
</div>
</article>
);
})
) : (
<p className="p-8 text-center text-xs text-text-tertiary">没有符合条件的事件</p>
)}
</div>
);
};
return (
<Dialog
open={open}
onClose={onClose}
title="诊断与事件日志"
className="max-w-2xl"
footer={
<div className="flex justify-end">
<Button variant="danger" disabled={!items.length} onClick={onClear}>
清空事件
</Button>
</div>
}
>
<Tabs
label="事件筛选"
value={filter}
onValueChange={setFilter}
keepMounted={false}
items={[
{ value: 'all', label: `全部 ${items.length}`, content: content('all') },
{
value: 'warning',
label: `警告 ${items.filter((item) => item.tone === 'warning').length}`,
content: content('warning'),
},
{
value: 'danger',
label: `错误 ${items.filter((item) => item.tone === 'danger').length}`,
content: content('danger'),
},
]}
/>
</Dialog>
);
}
@@ -1,4 +1,18 @@
import {render,screen} from '@testing-library/react';
import {EntrySelectionDialog} from './EntrySelectionDialog';
import { render, screen } from '@testing-library/react';
import { EntrySelectionDialog } from './EntrySelectionDialog';
describe('EntrySelectionDialog',()=>{it('父组件重渲染时不抢走入口按钮焦点,且不暴露无效关闭动作',()=>{const entries=[{path:'a.xml',label:'模型 A'},{path:'b.xml',label:'模型 B'}],select=vi.fn();const {rerender}=render(<EntrySelectionDialog entries={entries} onSelect={select}/>);const entry=screen.getByRole('button',{name:'模型 A'});entry.focus();rerender(<EntrySelectionDialog entries={[...entries]} onSelect={select}/>);expect(entry).toHaveFocus();expect(screen.queryByRole('button',{name:'关闭'})).not.toBeInTheDocument();});});
describe('EntrySelectionDialog', () => {
it('父组件重渲染时不抢走入口按钮焦点,且不暴露无效关闭动作', () => {
const entries = [
{ path: 'a.xml', label: '模型 A' },
{ path: 'b.xml', label: '模型 B' },
],
select = vi.fn();
const { rerender } = render(<EntrySelectionDialog entries={entries} onSelect={select} />);
const entry = screen.getByRole('button', { name: '模型 A' });
entry.focus();
rerender(<EntrySelectionDialog entries={[...entries]} onSelect={select} />);
expect(entry).toHaveFocus();
expect(screen.queryByRole('button', { name: '关闭' })).not.toBeInTheDocument();
});
});
@@ -1,4 +1,28 @@
import {FileCode2} from 'lucide-react';
import {Button,Dialog} from '../../components/ui';
const noop=()=>{};
export function EntrySelectionDialog({entries,onSelect}:{entries:{path:string;label:string}[];onSelect:(path:string)=>void}){return <Dialog open={entries.length>0} onClose={noop} closable={false} title="选择模型入口"><p className="mb-4 text-sm text-text-secondary">工程包含多个可加载模型,请选择一个。</p><div className="space-y-2">{entries.map(entry=><Button key={entry.path} className="w-full justify-start overflow-hidden" onClick={()=>onSelect(entry.path)} icon={<FileCode2 className="h-4 w-4"/>}><span className="truncate">{entry.label}</span></Button>)}</div></Dialog>;}
import { FileCode2 } from 'lucide-react';
import { Button, Dialog } from '../../components/ui';
const noop = () => {};
export function EntrySelectionDialog({
entries,
onSelect,
}: {
entries: { path: string; label: string }[];
onSelect: (path: string) => void;
}) {
return (
<Dialog open={entries.length > 0} onClose={noop} closable={false} title="选择模型入口">
<p className="mb-4 text-sm text-text-secondary">工程包含多个可加载模型,请选择一个。</p>
<div className="space-y-2">
{entries.map((entry) => (
<Button
key={entry.path}
className="w-full justify-start overflow-hidden"
onClick={() => onSelect(entry.path)}
icon={<FileCode2 className="h-4 w-4" />}
>
<span className="truncate">{entry.label}</span>
</Button>
))}
</div>
</Dialog>
);
}
@@ -1,5 +1,68 @@
import {useState} from 'react';
import {ChevronDown,FolderTree,RefreshCw,TriangleAlert,X} from 'lucide-react';
import type {AppDiagnostic} from '../../stores/useAppStore';
import {Button,CopyButton,IconButton} from '../../components/ui';
export function ErrorRecoveryPanel({value,onClose,onRetry,onOpenProject}:{value:AppDiagnostic;onClose:()=>void;onRetry?:()=>void;onOpenProject:()=>void}){const [expanded,setExpanded]=useState(false);return <section role="alert" className="absolute bottom-4 left-1/2 z-30 w-[min(42rem,calc(100%-2rem))] -translate-x-1/2 overflow-hidden rounded-xl border border-danger-border bg-panel shadow-2xl"><div className="flex items-start gap-3 p-3"><span className="mt-0.5 grid h-7 w-7 shrink-0 place-items-center rounded-full bg-danger-soft text-danger"><TriangleAlert className="h-4 w-4"/></span><div className="min-w-0 flex-1"><h2 className="text-sm font-semibold">{value.summary}</h2>{value.path&&<p className="truncate text-xs text-text-tertiary">路径:{value.path}</p>}<div className="mt-2 flex flex-wrap gap-2">{onRetry&&<Button variant="danger" onClick={onRetry} icon={<RefreshCw className="h-3.5 w-3.5"/>}>重试当前入口</Button>}<Button onClick={onOpenProject} icon={<FolderTree className="h-3.5 w-3.5"/>}>返回工程树</Button><CopyButton value={`${value.summary}\n${value.path??''}\n${value.detail}`} label="复制错误详情"/></div><button type="button" aria-expanded={expanded} className="mt-2 flex items-center gap-1 text-xs text-danger" onClick={()=>setExpanded(v=>!v)}>技术详情<ChevronDown className={`h-3 w-3 ${expanded?'rotate-180':''}`}/></button></div><IconButton aria-label="关闭错误" tooltip="关闭" onClick={onClose}><X className="h-4 w-4"/></IconButton></div>{expanded&&<pre className="max-h-36 overflow-auto border-t border-danger-border bg-danger-soft p-3 text-xs text-danger">{value.detail}</pre>}</section>;}
import { useState } from 'react';
import { ChevronDown, FolderTree, RefreshCw, TriangleAlert, X } from 'lucide-react';
import type { AppDiagnostic } from '../../stores/useAppStore';
import { Button, CopyButton, IconButton } from '../../components/ui';
export function ErrorRecoveryPanel({
value,
onClose,
onRetry,
onOpenProject,
}: {
value: AppDiagnostic;
onClose: () => void;
onRetry?: () => void;
onOpenProject: () => void;
}) {
const [expanded, setExpanded] = useState(false);
return (
<section
role="alert"
className="absolute bottom-4 left-1/2 z-30 w-[min(42rem,calc(100%-2rem))] -translate-x-1/2 overflow-hidden rounded-xl border border-danger-border bg-panel shadow-2xl"
>
<div className="flex items-start gap-3 p-3">
<span className="mt-0.5 grid h-7 w-7 shrink-0 place-items-center rounded-full bg-danger-soft text-danger">
<TriangleAlert className="h-4 w-4" />
</span>
<div className="min-w-0 flex-1">
<h2 className="text-sm font-semibold">{value.summary}</h2>
{value.path && <p className="truncate text-xs text-text-tertiary">路径:{value.path}</p>}
<div className="mt-2 flex flex-wrap gap-2">
{onRetry && (
<Button
variant="danger"
onClick={onRetry}
icon={<RefreshCw className="h-3.5 w-3.5" />}
>
重试当前入口
</Button>
)}
<Button onClick={onOpenProject} icon={<FolderTree className="h-3.5 w-3.5" />}>
返回工程树
</Button>
<CopyButton
value={`${value.summary}\n${value.path ?? ''}\n${value.detail}`}
label="复制错误详情"
/>
</div>
<button
type="button"
aria-expanded={expanded}
className="mt-2 flex items-center gap-1 text-xs text-danger"
onClick={() => setExpanded((v) => !v)}
>
技术详情
<ChevronDown className={`h-3 w-3 ${expanded ? 'rotate-180' : ''}`} />
</button>
</div>
<IconButton aria-label="关闭错误" tooltip="关闭" onClick={onClose}>
<X className="h-4 w-4" />
</IconButton>
</div>
{expanded && (
<pre className="max-h-36 overflow-auto border-t border-danger-border bg-danger-soft p-3 text-xs text-danger">
{value.detail}
</pre>
)}
</section>
);
}
@@ -1,5 +1,38 @@
import {fireEvent,render,screen} from '@testing-library/react';
import {DiagnosticNotice} from './DiagnosticNotice';
import {WorkspaceOverlays} from './WorkspaceOverlays';
import {StatusBar} from './StatusBar';
describe('工作台反馈组件',()=>{it('诊断详情可展开并关闭',()=>{const close=vi.fn();render(<DiagnosticNotice value={{category:'模型编译',summary:'模型编译失败',detail:'bad xml',path:'robot.xml',at:1}} onClose={close}/>);expect(screen.queryByText('bad xml')).not.toBeInTheDocument();fireEvent.click(screen.getByRole('button',{name:'技术详情'}));expect(screen.getByText('bad xml')).toBeVisible();fireEvent.click(screen.getByRole('button',{name:'关闭错误'}));expect(close).toHaveBeenCalledTimes(1);});it('加载态与空态互斥',()=>{const {rerender}=render(<WorkspaceOverlays loading={false} hasSnapshot={false}/>);expect(screen.getByText('拖放模型工程到此处')).toBeVisible();rerender(<WorkspaceOverlays loading hasSnapshot={false}/>);expect(screen.queryByText('拖放模型工程到此处')).not.toBeInTheDocument();expect(screen.getByRole('status')).toBeVisible();});it('展示格式化状态数据',()=>{render(<StatusBar time={1.25} fps={60} stepMs={0.5} memoryMb={10} loaded overBudget={false}/>);expect(screen.getByText(/时间 1.250 s/)).toBeVisible();expect(screen.getByText(/WASM 已加载/)).toBeVisible();});});
import { fireEvent, render, screen } from '@testing-library/react';
import { DiagnosticNotice } from './DiagnosticNotice';
import { WorkspaceOverlays } from './WorkspaceOverlays';
import { StatusBar } from './StatusBar';
describe('工作台反馈组件', () => {
it('诊断详情可展开并关闭', () => {
const close = vi.fn();
render(
<DiagnosticNotice
value={{
category: '模型编译',
summary: '模型编译失败',
detail: 'bad xml',
path: 'robot.xml',
at: 1,
}}
onClose={close}
/>,
);
expect(screen.queryByText('bad xml')).not.toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: '技术详情' }));
expect(screen.getByText('bad xml')).toBeVisible();
fireEvent.click(screen.getByRole('button', { name: '关闭错误' }));
expect(close).toHaveBeenCalledTimes(1);
});
it('加载态与空态互斥', () => {
const { rerender } = render(<WorkspaceOverlays loading={false} hasSnapshot={false} />);
expect(screen.getByText('拖放模型工程到此处')).toBeVisible();
rerender(<WorkspaceOverlays loading hasSnapshot={false} />);
expect(screen.queryByText('拖放模型工程到此处')).not.toBeInTheDocument();
expect(screen.getByRole('status')).toBeVisible();
});
it('展示格式化状态数据', () => {
render(<StatusBar time={1.25} fps={60} stepMs={0.5} memoryMb={10} loaded overBudget={false} />);
expect(screen.getByText(/时间 1.250 s/)).toBeVisible();
expect(screen.getByText(/WASM 已加载/)).toBeVisible();
});
});
@@ -1,12 +1,66 @@
import {fireEvent,render,screen,within} from '@testing-library/react';
import {DiagnosticsDrawer} from './DiagnosticsDrawer';
import {ErrorRecoveryPanel} from './ErrorRecoveryPanel';
import {ToolbarOverflowMenu} from './ToolbarOverflowMenu';
import {WorkspaceOverlays} from './WorkspaceOverlays';
const event={id:1,title:'编译失败',detail:'bad xml',tone:'danger' as const,at:0};
describe('第五批工作台组件',()=>{
it('事件日志支持分类和清空',()=>{const clear=vi.fn();render(<DiagnosticsDrawer open items={[event]} onClose={()=>{}} onClear={clear}/>);expect(within(screen.getByRole('tabpanel',{name:/全部/})).getByText('bad xml')).toBeVisible();expect(screen.getAllByText('bad xml')).toHaveLength(1);fireEvent.click(screen.getByRole('button',{name:'清空事件'}));expect(clear).toHaveBeenCalled();});
it('错误恢复面板透传重试与工程树动作',()=>{const retry=vi.fn(),project=vi.fn();render(<ErrorRecoveryPanel value={{category:'模型编译',summary:'失败',detail:'bad',path:'a.xml',at:1}} onClose={()=>{}} onRetry={retry} onOpenProject={project}/>);fireEvent.click(screen.getByRole('button',{name:'重试当前入口'}));fireEvent.click(screen.getByRole('button',{name:'返回工程树'}));expect(retry).toHaveBeenCalled();expect(project).toHaveBeenCalled();});
it('导入叠层显示阶段进度',()=>{render(<div className="relative"><WorkspaceOverlays loading hasSnapshot={false} progress={{label:'处理模型资源',value:.4}}/></div>);expect(screen.getByRole('progressbar',{name:'处理模型资源'})).toHaveAttribute('aria-valuenow','40');});
it('工具栏更多菜单提供窄桌面动作',()=>{const settings=vi.fn();render(<ToolbarOverflowMenu fullscreen={false} onCommands={()=>{}} onLayout={()=>{}} onSettings={settings} onFullscreen={()=>{}} onHelp={()=>{}} onTheme={()=>{}}/>);fireEvent.click(screen.getByRole('button',{name:'更多工作台操作'}));fireEvent.click(screen.getByRole('menuitem',{name:'工作台设置'}));expect(settings).toHaveBeenCalled();});
import { fireEvent, render, screen, within } from '@testing-library/react';
import { DiagnosticsDrawer } from './DiagnosticsDrawer';
import { ErrorRecoveryPanel } from './ErrorRecoveryPanel';
import { ToolbarOverflowMenu } from './ToolbarOverflowMenu';
import { WorkspaceOverlays } from './WorkspaceOverlays';
const event = { id: 1, title: '编译失败', detail: 'bad xml', tone: 'danger' as const, at: 0 };
describe('第五批工作台组件', () => {
it('事件日志支持分类和清空', () => {
const clear = vi.fn();
render(<DiagnosticsDrawer open items={[event]} onClose={() => {}} onClear={clear} />);
expect(
within(screen.getByRole('tabpanel', { name: /全部/ })).getByText('bad xml'),
).toBeVisible();
expect(screen.getAllByText('bad xml')).toHaveLength(1);
fireEvent.click(screen.getByRole('button', { name: '清空事件' }));
expect(clear).toHaveBeenCalled();
});
it('错误恢复面板透传重试与工程树动作', () => {
const retry = vi.fn(),
project = vi.fn();
render(
<ErrorRecoveryPanel
value={{ category: '模型编译', summary: '失败', detail: 'bad', path: 'a.xml', at: 1 }}
onClose={() => {}}
onRetry={retry}
onOpenProject={project}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '重试当前入口' }));
fireEvent.click(screen.getByRole('button', { name: '返回工程树' }));
expect(retry).toHaveBeenCalled();
expect(project).toHaveBeenCalled();
});
it('导入叠层显示阶段进度', () => {
render(
<div className="relative">
<WorkspaceOverlays
loading
hasSnapshot={false}
progress={{ label: '处理模型资源', value: 0.4 }}
/>
</div>,
);
expect(screen.getByRole('progressbar', { name: '处理模型资源' })).toHaveAttribute(
'aria-valuenow',
'40',
);
});
it('工具栏更多菜单提供窄桌面动作', () => {
const settings = vi.fn();
render(
<ToolbarOverflowMenu
fullscreen={false}
onCommands={() => {}}
onLayout={() => {}}
onSettings={settings}
onFullscreen={() => {}}
onHelp={() => {}}
onTheme={() => {}}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '更多工作台操作' }));
fireEvent.click(screen.getByRole('menuitem', { name: '工作台设置' }));
expect(settings).toHaveBeenCalled();
});
});
@@ -1,13 +1,103 @@
import {act,fireEvent,render,screen} from '@testing-library/react';
import {NotificationCenter,ToastViewport,type WorkbenchNotification} from './NotificationCenter';
import {ProjectBreadcrumb} from './ProjectBreadcrumb';
import {SettingsDialog} from './SettingsDialog';
import {LayoutSettingsDialog} from './LayoutSettingsDialog';
const item:WorkbenchNotification={id:1,title:'模型加载完成',detail:'完成',tone:'success',at:0};
describe('第四批工作台组件',()=>{
it('通知中心展示、移除并清空消息',()=>{const dismiss=vi.fn(),clear=vi.fn();render(<NotificationCenter items={[item]} onDismiss={dismiss} onClear={clear}/>);fireEvent.click(screen.getByRole('button',{name:'通知中心'}));expect(screen.getByRole('dialog',{name:'通知中心'})).toHaveTextContent('模型加载完成');fireEvent.click(screen.getByRole('button',{name:'移除通知:模型加载完成'}));expect(dismiss).toHaveBeenCalledWith(1);fireEvent.click(screen.getByText('清空'));expect(clear).toHaveBeenCalled();});
it('Toast 自动关闭',()=>{vi.useFakeTimers();const close=vi.fn();render(<ToastViewport item={item} onDismiss={close}/>);act(()=>vi.advanceTimersByTime(4000));expect(close).toHaveBeenCalledWith(1);vi.useRealTimers();});
it('工程面包屑可切换多入口',()=>{const select=vi.fn();render(<ProjectBreadcrumb projectName="robot" selectedEntry="models/a.xml" entries={[{path:'models/a.xml',label:'A',format:'mjcf'},{path:'models/b.xml',label:'B',format:'mjcf'}]} onSelect={select}/>);fireEvent.click(screen.getByRole('button',{name:'切换模型入口'}));fireEvent.click(screen.getByRole('option',{name:/B/}));expect(select).toHaveBeenCalledWith('models/b.xml');});
it('模型加载期间禁用入口切换',()=>{render(<ProjectBreadcrumb projectName="robot" loading entries={[{path:'a.xml',label:'A',format:'mjcf'},{path:'b.xml',label:'B',format:'mjcf'}]} selectedEntry="a.xml" onSelect={()=>{}}/>);expect(screen.getByRole('button',{name:'切换模型入口'})).toBeDisabled();});
it('设置和布局弹窗透传现有设置动作',()=>{const theme=vi.fn(),preset=vi.fn();render(<><SettingsDialog open onClose={()=>{}} theme="dark" angleUnit="rad" showCollision={false} jointAdvanced={false} forceScale={50} onTheme={theme} onAngleUnit={()=>{}} onShowCollision={()=>{}} onJointAdvanced={()=>{}} onForceScale={()=>{}}/><LayoutSettingsDialog open={false} onClose={()=>{}} leftOpen rightOpen onLeftOpen={()=>{}} onRightOpen={()=>{}} onPreset={preset} onReset={()=>{}}/></>);fireEvent.change(screen.getByLabelText('设置主题'),{target:{value:'light'}});expect(theme).toHaveBeenCalledWith('light');});
import { act, fireEvent, render, screen } from '@testing-library/react';
import {
NotificationCenter,
ToastViewport,
type WorkbenchNotification,
} from './NotificationCenter';
import { ProjectBreadcrumb } from './ProjectBreadcrumb';
import { SettingsDialog } from './SettingsDialog';
import { LayoutSettingsDialog } from './LayoutSettingsDialog';
const item: WorkbenchNotification = {
id: 1,
title: '模型加载完成',
detail: '完成',
tone: 'success',
at: 0,
};
describe('第四批工作台组件', () => {
it('通知中心展示、移除并清空消息', () => {
const dismiss = vi.fn(),
clear = vi.fn();
render(<NotificationCenter items={[item]} onDismiss={dismiss} onClear={clear} />);
fireEvent.click(screen.getByRole('button', { name: '通知中心' }));
expect(screen.getByRole('dialog', { name: '通知中心' })).toHaveTextContent('模型加载完成');
fireEvent.click(screen.getByRole('button', { name: '移除通知:模型加载完成' }));
expect(dismiss).toHaveBeenCalledWith(1);
fireEvent.click(screen.getByText('清空'));
expect(clear).toHaveBeenCalled();
});
it('Toast 自动关闭', () => {
vi.useFakeTimers();
const close = vi.fn();
render(<ToastViewport item={item} onDismiss={close} />);
act(() => vi.advanceTimersByTime(4000));
expect(close).toHaveBeenCalledWith(1);
vi.useRealTimers();
});
it('工程面包屑可切换多入口', () => {
const select = vi.fn();
render(
<ProjectBreadcrumb
projectName="robot"
selectedEntry="models/a.xml"
entries={[
{ path: 'models/a.xml', label: 'A', format: 'mjcf' },
{ path: 'models/b.xml', label: 'B', format: 'mjcf' },
]}
onSelect={select}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '切换模型入口' }));
fireEvent.click(screen.getByRole('option', { name: /B/ }));
expect(select).toHaveBeenCalledWith('models/b.xml');
});
it('模型加载期间禁用入口切换', () => {
render(
<ProjectBreadcrumb
projectName="robot"
loading
entries={[
{ path: 'a.xml', label: 'A', format: 'mjcf' },
{ path: 'b.xml', label: 'B', format: 'mjcf' },
]}
selectedEntry="a.xml"
onSelect={() => {}}
/>,
);
expect(screen.getByRole('button', { name: '切换模型入口' })).toBeDisabled();
});
it('设置和布局弹窗透传现有设置动作', () => {
const theme = vi.fn(),
preset = vi.fn();
render(
<>
<SettingsDialog
open
onClose={() => {}}
theme="dark"
angleUnit="rad"
showCollision={false}
jointAdvanced={false}
forceScale={50}
onTheme={theme}
onAngleUnit={() => {}}
onShowCollision={() => {}}
onJointAdvanced={() => {}}
onForceScale={() => {}}
/>
<LayoutSettingsDialog
open={false}
onClose={() => {}}
leftOpen
rightOpen
onLeftOpen={() => {}}
onRightOpen={() => {}}
onPreset={preset}
onReset={() => {}}
/>
</>,
);
fireEvent.change(screen.getByLabelText('设置主题'), { target: { value: 'light' } });
expect(theme).toHaveBeenCalledWith('light');
});
});
@@ -1,7 +1,78 @@
import {Columns3,Focus,PanelLeft,PanelRight,RotateCcw} from 'lucide-react';
import {Button,Dialog} from '../../components/ui';
export type LayoutPreset='default'|'viewport'|'project'|'control';
const presets=[{value:'default' as const,label:'默认布局',detail:'左右面板均衡显示',icon:Columns3},{value:'viewport' as const,label:'宽视口',detail:'隐藏两侧面板',icon:Focus},{value:'project' as const,label:'工程浏览',detail:'加宽工程面板',icon:PanelLeft},{value:'control' as const,label:'控制调试',detail:'加宽控制面板',icon:PanelRight}];
export function LayoutSettingsDialog({open,onClose,leftOpen,rightOpen,onLeftOpen,onRightOpen,onPreset,onReset}:{open:boolean;onClose:()=>void;leftOpen:boolean;rightOpen:boolean;onLeftOpen:(value:boolean)=>void;onRightOpen:(value:boolean)=>void;onPreset:(preset:LayoutPreset)=>void;onReset:()=>void}){return <Dialog open={open} onClose={onClose} title="布局设置"><div className="flex gap-2"><Button variant={leftOpen?'primary':'secondary'} aria-pressed={leftOpen} onClick={()=>onLeftOpen(!leftOpen)} icon={<PanelLeft className="h-3.5 w-3.5"/>}>工程面板</Button><Button variant={rightOpen?'primary':'secondary'} aria-pressed={rightOpen} onClick={()=>onRightOpen(!rightOpen)} icon={<PanelRight className="h-3.5 w-3.5"/>}>属性面板</Button></div><h3 className="mb-2 mt-4 text-xs font-semibold">布局预设</h3><div className="grid grid-cols-2 gap-2">{presets.map(item=><button key={item.value} onClick={()=>onPreset(item.value)} className="flex gap-2 rounded-lg border border-border bg-surface p-3 text-left hover:border-accent hover:bg-accent-soft focus-visible:ring-2 focus-visible:ring-accent/30"><item.icon className="h-4 w-4 shrink-0 text-accent"/><span><span className="block text-xs font-medium">{item.label}</span><span className="mt-0.5 block text-[10px] text-text-tertiary">{item.detail}</span></span></button>)}</div><Button className="mt-4 w-full" onClick={onReset} icon={<RotateCcw className="h-3.5 w-3.5"/>}>恢复默认布局与宽度</Button></Dialog>;}
import { Columns3, Focus, PanelLeft, PanelRight, RotateCcw } from 'lucide-react';
import { Button, Dialog } from '../../components/ui';
export type LayoutPreset = 'default' | 'viewport' | 'project' | 'control';
const presets = [
{ value: 'default' as const, label: '默认布局', detail: '左右面板均衡显示', icon: Columns3 },
{ value: 'viewport' as const, label: '宽视口', detail: '隐藏两侧面板', icon: Focus },
{ value: 'project' as const, label: '工程浏览', detail: '加宽工程面板', icon: PanelLeft },
{ value: 'control' as const, label: '控制调试', detail: '加宽控制面板', icon: PanelRight },
];
export function LayoutSettingsDialog({
open,
onClose,
leftOpen,
rightOpen,
onLeftOpen,
onRightOpen,
onPreset,
onReset,
}: {
open: boolean;
onClose: () => void;
leftOpen: boolean;
rightOpen: boolean;
onLeftOpen: (value: boolean) => void;
onRightOpen: (value: boolean) => void;
onPreset: (preset: LayoutPreset) => void;
onReset: () => void;
}) {
return (
<Dialog open={open} onClose={onClose} title="布局设置">
<div className="flex gap-2">
<Button
variant={leftOpen ? 'primary' : 'secondary'}
aria-pressed={leftOpen}
onClick={() => onLeftOpen(!leftOpen)}
icon={<PanelLeft className="h-3.5 w-3.5" />}
>
工程面板
</Button>
<Button
variant={rightOpen ? 'primary' : 'secondary'}
aria-pressed={rightOpen}
onClick={() => onRightOpen(!rightOpen)}
icon={<PanelRight className="h-3.5 w-3.5" />}
>
属性面板
</Button>
</div>
<h3 className="mb-2 mt-4 text-xs font-semibold">布局预设</h3>
<div className="grid grid-cols-2 gap-2">
{presets.map((item) => (
<button
key={item.value}
onClick={() => onPreset(item.value)}
className="flex gap-2 rounded-lg border border-border bg-surface p-3 text-left hover:border-accent hover:bg-accent-soft focus-visible:ring-2 focus-visible:ring-accent/30"
>
<item.icon className="h-4 w-4 shrink-0 text-accent" />
<span>
<span className="block text-xs font-medium">{item.label}</span>
<span className="mt-0.5 block text-[10px] text-text-tertiary">{item.detail}</span>
</span>
</button>
))}
</div>
<Button
className="mt-4 w-full"
onClick={onReset}
icon={<RotateCcw className="h-3.5 w-3.5" />}
>
恢复默认布局与宽度
</Button>
</Dialog>
);
}
// eslint-disable-next-line react-refresh/only-export-components
export function dispatchLayoutWidths(left:number,right:number){window.dispatchEvent(new CustomEvent('mujoco-layout-widths',{detail:{left,right}}));}
export function dispatchLayoutWidths(left: number, right: number) {
window.dispatchEvent(new CustomEvent('mujoco-layout-widths', { detail: { left, right } }));
}
@@ -1,25 +1,87 @@
import {fireEvent,render,screen,waitFor} from '@testing-library/react';
import {beforeEach,describe,expect,it,vi} from 'vitest';
import {LocalTrainingPanel} from './LocalTrainingPanel';
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { LocalTrainingPanel } from './LocalTrainingPanel';
beforeEach(()=>{localStorage.clear();vi.unstubAllGlobals();});
beforeEach(() => {
localStorage.clear();
sessionStorage.clear();
vi.unstubAllGlobals();
});
describe('LocalTrainingPanel',()=>{
it('连接本地服务并从图形界面发起训练请求',async()=>{
const health={version:'0.1.0',ready:true,trainerRoot:'/opt/unitree_rl_mjlab',python:'/env/bin/python',tasks:['Unitree-Go2-Flat']};
const job={id:'a'.repeat(32),state:'queued',taskId:'Unitree-Go2-Flat',createdAt:'2025-01-01T00:00:00Z',iteration:0,maxIterations:2000,progress:0,message:'等待启动',logs:[],artifactReady:false};
const fetchMock=vi.fn()
.mockResolvedValueOnce(new Response(JSON.stringify(health),{status:200,headers:{'Content-Type':'application/json'}}))
.mockResolvedValueOnce(new Response(JSON.stringify(job),{status:202,headers:{'Content-Type':'application/json'}}));
vi.stubGlobal('fetch',fetchMock);
render(<LocalTrainingPanel onPolicyReady={vi.fn()}/>);
fireEvent.click(screen.getByRole('button',{name:'连接'}));
describe('LocalTrainingPanel', () => {
it('连接本地服务并从图形界面发起训练请求', async () => {
const health = {
version: '0.1.0',
ready: true,
trainerRoot: '/opt/unitree_rl_mjlab',
python: '/env/bin/python',
tasks: ['Unitree-Go2-Flat'],
};
const job = {
id: 'a'.repeat(32),
state: 'queued',
taskId: 'Unitree-Go2-Flat',
createdAt: '2025-01-01T00:00:00Z',
iteration: 0,
maxIterations: 2000,
progress: 0,
message: '等待启动',
logs: [],
artifactReady: false,
};
const fetchMock = vi
.fn()
.mockResolvedValueOnce(
new Response(JSON.stringify(health), {
status: 200,
headers: { 'Content-Type': 'application/json' },
}),
)
.mockResolvedValueOnce(
new Response(JSON.stringify(job), {
status: 202,
headers: { 'Content-Type': 'application/json' },
}),
)
.mockResolvedValueOnce(
new Response(JSON.stringify(health), {
status: 200,
headers: { 'Content-Type': 'application/json' },
}),
)
.mockResolvedValueOnce(
new Response(JSON.stringify({ error: '训练任务不存在或服务已重启' }), {
status: 404,
headers: { 'Content-Type': 'application/json' },
}),
);
vi.stubGlobal('fetch', fetchMock);
render(<LocalTrainingPanel onPolicyReady={vi.fn()} />);
fireEvent.change(screen.getByLabelText('训练服务访问令牌'), {
target: { value: 'secret-token' },
});
fireEvent.click(screen.getByRole('button', { name: '连接' }));
expect(await screen.findByText('/opt/unitree_rl_mjlab')).toBeInTheDocument();
fireEvent.change(screen.getByLabelText('并行环境'),{target:{value:'32'}});
fireEvent.click(screen.getByRole('button',{name:'发起本地训练'}));
await waitFor(()=>expect(fetchMock).toHaveBeenCalledTimes(2));
const request=fetchMock.mock.calls[1][1] as RequestInit;
expect(JSON.parse(String(request.body))).toMatchObject({taskId:'Unitree-Go2-Flat',numEnvs:32,device:'gpu',gpuIds:[0],wandbMode:'offline'});
fireEvent.change(screen.getByLabelText('并行环境'), { target: { value: '32' } });
fireEvent.click(screen.getByRole('button', { name: '发起本地训练' }));
await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(2));
const request = fetchMock.mock.calls[1][1] as RequestInit;
expect(JSON.parse(String(request.body))).toMatchObject({
taskId: 'Unitree-Go2-Flat',
numEnvs: 32,
device: 'gpu',
gpuIds: [0],
wandbMode: 'offline',
});
expect(new Headers(request.headers).get('Authorization')).toBe('Bearer secret-token');
expect(await screen.findByText('排队中')).toBeInTheDocument();
const tokenInput = screen.getByLabelText('训练服务访问令牌');
expect(tokenInput).toBeEnabled();
fireEvent.change(tokenInput, { target: { value: 'new-secret-token' } });
fireEvent.click(screen.getByRole('button', { name: '连接' }));
await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(4));
expect(await screen.findByRole('button', { name: '发起本地训练' })).toBeInTheDocument();
expect(sessionStorage.getItem('mujoco-local-training-token')).toBe('new-secret-token');
});
});
@@ -1,77 +1,435 @@
import {useEffect,useState,type ReactNode} from 'react';
import {Download,Link,Play,Server,Square} from 'lucide-react';
import {Badge,Button,ProgressBar,PropertyRow,Select} from '../../components/ui';
import {LocalTrainingClient} from '../../training/LocalTrainingClient';
import type {TrainingDevice,TrainingJob,TrainingServerInfo,WandbMode} from '../../training/types';
import { useEffect, useState, type ReactNode } from 'react';
import { Download, Link, Play, Server, Square } from 'lucide-react';
import { Badge, Button, ProgressBar, PropertyRow, Select } from '../../components/ui';
import { LocalTrainingClient } from '../../training/LocalTrainingClient';
import type {
TrainingDevice,
TrainingJob,
TrainingServerInfo,
WandbMode,
} from '../../training/types';
const ENDPOINT_KEY='mujoco-local-training-endpoint',JOB_KEY='mujoco-local-training-job';
const DEFAULT_ENDPOINT='http://127.0.0.1:8765';
const ACTIVE_STATES=new Set(['queued','running']);
function stored(key:string,fallback=''):string{try{return localStorage.getItem(key)??fallback;}catch{return fallback;}}
function errorText(error:unknown):string{return error instanceof Error?error.message:String(error);}
function stateLabel(state:TrainingJob['state']):string{return {queued:'排队中',running:'训练中',succeeded:'已完成',failed:'失败',cancelled:'已取消'}[state];}
export function LocalTrainingPanel({onPolicyReady}:{onPolicyReady(file:File):void}){
const [endpoint,setEndpoint]=useState(()=>stored(ENDPOINT_KEY,DEFAULT_ENDPOINT));
const [server,setServer]=useState<TrainingServerInfo>();
const [job,setJob]=useState<TrainingJob>();
const [busy,setBusy]=useState(false),[error,setError]=useState<string>();
const [taskId,setTaskId]=useState('Unitree-Go2-Flat'),[numEnvs,setNumEnvs]=useState(4096),[maxIterations,setMaxIterations]=useState(2000),[seed,setSeed]=useState(42),[runName,setRunName]=useState('web'),[device,setDevice]=useState<TrainingDevice>('gpu'),[gpuIds,setGpuIds]=useState('0'),[wandbMode,setWandbMode]=useState<WandbMode>('offline');
const connect=async()=>{
setBusy(true);setError(undefined);
try{
const client=new LocalTrainingClient(endpoint),info=await client.health();
setServer(info);try{localStorage.setItem(ENDPOINT_KEY,client.endpoint);}catch{/* 当前会话仍可连接 */}
if(info.tasks.length&&!info.tasks.includes(taskId))setTaskId(info.tasks[0]);
const remembered=info.activeJobId??stored(JOB_KEY);
if(remembered){try{setJob(await client.job(remembered));}catch{try{localStorage.removeItem(JOB_KEY);}catch{/* ignore */}}}
if(!info.ready)setError(info.error??'训练服务尚未就绪');
}catch(value){setServer(undefined);setError(errorText(value));}
finally{setBusy(false);}
};
const jobId=job?.id,jobState=job?.state;
useEffect(()=>{
if(!jobId||!jobState||!ACTIVE_STATES.has(jobState))return;
let disposed=false;
const refresh=async()=>{try{const next=await new LocalTrainingClient(endpoint).job(jobId);if(!disposed)setJob(next);}catch(value){if(!disposed)setError(errorText(value));}};
const timer=window.setInterval(()=>void refresh(),1500);return()=>{disposed=true;window.clearInterval(timer);};
},[endpoint,jobId,jobState]);
const start=async()=>{
setBusy(true);setError(undefined);
try{
const ids=device==='gpu'?gpuIds.split(/[\s,]+/).filter(Boolean).map(Number):[];
if(ids.some(id=>!Number.isInteger(id)||id<0))throw new Error('GPU 编号必须是非负整数');
const next=await new LocalTrainingClient(endpoint).start({taskId,numEnvs,maxIterations,seed,runName,device,gpuIds:ids,wandbMode});
setJob(next);try{localStorage.setItem(JOB_KEY,next.id);}catch{/* ignore */}
}catch(value){setError(errorText(value));}finally{setBusy(false);}
};
const cancel=async()=>{if(!job)return;setBusy(true);setError(undefined);try{setJob(await new LocalTrainingClient(endpoint).cancel(job.id));}catch(value){setError(errorText(value));}finally{setBusy(false);}};
const importResult=async()=>{if(!job)return;setBusy(true);setError(undefined);try{onPolicyReady(await new LocalTrainingClient(endpoint).downloadPolicy(job.id));}catch(value){setError(errorText(value));}finally{setBusy(false);}};
const active=Boolean(job&&ACTIVE_STATES.has(job.state));
return <div>
<label className="block text-xs text-text-secondary"><span className="mb-1 block">本地训练服务</span><div className="flex gap-2"><input aria-label="本地训练服务地址" className="field h-7 min-w-0 flex-1 px-2 text-xs text-text-primary" value={endpoint} disabled={active} onChange={event=>setEndpoint(event.target.value)}/><Button icon={<Link className="h-3.5 w-3.5"/>} disabled={busy||active} onClick={()=>void connect()}>连接</Button></div></label>
<div className="mt-2 flex items-center justify-between rounded-md border border-border bg-surface px-2 py-1.5 text-[10px] text-text-tertiary"><span className="flex min-w-0 items-center gap-1.5 truncate"><Server className="h-3.5 w-3.5"/>{server?.trainerRoot??'请先启动本地训练服务'}</span><Badge tone={server?.ready?'success':'warning'}>{server?.ready?'可用':'离线'}</Badge></div>
{server?.ready&&!job&&<div className="mt-3 space-y-2">
<Field label="训练任务"><Select aria-label="训练任务" className="w-full" value={taskId} onChange={event=>setTaskId(event.target.value)}>{server.tasks.map(task=><option key={task} value={task}>{task}</option>)}</Select></Field>
<div className="grid grid-cols-2 gap-2"><NumberField label="并行环境" value={numEnvs} min={1} max={16384} onChange={setNumEnvs}/><NumberField label="训练迭代" value={maxIterations} min={1} max={1000000} onChange={setMaxIterations}/><NumberField label="随机种子" value={seed} min={0} max={2147483647} onChange={setSeed}/><Field label="运行名称"><input aria-label="运行名称" className="field h-7 w-full px-2 text-xs text-text-primary" value={runName} onChange={event=>setRunName(event.target.value)}/></Field></div>
<div className="grid grid-cols-2 gap-2"><Field label="计算设备"><Select aria-label="计算设备" className="w-full" value={device} onChange={event=>setDevice(event.target.value as TrainingDevice)}><option value="gpu">GPU</option><option value="cpu">CPU</option></Select></Field><Field label="GPU 编号"><input aria-label="GPU 编号" className="field h-7 w-full px-2 text-xs text-text-primary disabled:opacity-40" value={gpuIds} disabled={device==='cpu'} onChange={event=>setGpuIds(event.target.value)}/></Field></div>
<Field label="实验记录"><Select aria-label="W&B 模式" className="w-full" value={wandbMode} onChange={event=>setWandbMode(event.target.value as WandbMode)}><option value="offline">本地离线(默认,无需登录)</option><option value="disabled">完全禁用 W&amp;B</option><option value="online">在线 W&amp;B(需要 API Key)</option></Select></Field>
<Button variant="primary" className="w-full" icon={<Play className="h-3.5 w-3.5"/>} disabled={busy} onClick={()=>void start()}>发起本地训练</Button>
<p className="text-[10px] leading-4 text-text-tertiary">训练使用本地 mjlab 任务资产,不会把浏览器中的模型上传到网络。服务一次只运行一个训练任务。</p>
</div>}
{job&&<div className="mt-3 rounded-lg border border-border bg-surface p-2.5">
<div className="mb-2 flex items-center justify-between gap-2"><span className="truncate text-xs font-medium text-text-primary" title={job.id}>{job.taskId}</span><Badge tone={job.state==='succeeded'?'success':job.state==='failed'||job.state==='cancelled'?'warning':'accent'}>{stateLabel(job.state)}</Badge></div>
<ProgressBar value={job.progress} label="训练进度"/><div className="mt-2"><PropertyRow label="迭代" value={`${job.iteration} / ${job.maxIterations}`}/><PropertyRow label="状态" value={job.message}/></div>
{job.logs.length>0&&<details className="mt-2"><summary className="cursor-pointer text-[10px] text-text-secondary">最近日志</summary><pre className="mt-1 max-h-36 overflow-auto whitespace-pre-wrap break-all rounded bg-app p-2 text-[9px] leading-4 text-text-tertiary">{job.logs.slice(-40).join('\n')}</pre></details>}
<div className="mt-3 grid grid-cols-2 gap-2">{active?<Button variant="danger" className="col-span-2" icon={<Square className="h-3.5 w-3.5"/>} disabled={busy} onClick={()=>void cancel()}>停止训练</Button>:<><Button disabled={busy||!job.artifactReady} icon={<Download className="h-3.5 w-3.5"/>} onClick={()=>void importResult()}>导入策略</Button><Button onClick={()=>{setJob(undefined);try{localStorage.removeItem(JOB_KEY);}catch{/* ignore */}}}>新建任务</Button></>}</div>
</div>}
{error&&<p role="alert" className="mt-2 break-words rounded bg-danger/10 p-2 text-[10px] leading-4 text-danger">{error}</p>}
</div>;
const ENDPOINT_KEY = 'mujoco-local-training-endpoint',
JOB_KEY = 'mujoco-local-training-job',
TOKEN_KEY = 'mujoco-local-training-token';
const DEFAULT_ENDPOINT = 'http://127.0.0.1:8765';
const ACTIVE_STATES = new Set(['queued', 'running']);
function stored(key: string, fallback = ''): string {
try {
return localStorage.getItem(key) ?? fallback;
} catch {
return fallback;
}
}
function sessionStored(key: string): string {
try {
return sessionStorage.getItem(key) ?? '';
} catch {
return '';
}
}
function errorText(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
function stateLabel(state: TrainingJob['state']): string {
return {
queued: '排队中',
running: '训练中',
succeeded: '已完成',
failed: '失败',
cancelled: '已取消',
}[state];
}
function Field({label,children}:{label:string;children:ReactNode}){return <label className="block text-[10px] text-text-tertiary"><span className="mb-1 block">{label}</span>{children}</label>;}
function NumberField({label,value,min,max,onChange}:{label:string;value:number;min:number;max:number;onChange(value:number):void}){return <Field label={label}><input aria-label={label} type="number" className="field h-7 w-full px-2 text-xs text-text-primary" value={value} min={min} max={max} onChange={event=>onChange(Number(event.target.value))}/></Field>;}
export function LocalTrainingPanel({ onPolicyReady }: { onPolicyReady(file: File): void }) {
const [endpoint, setEndpoint] = useState(() => stored(ENDPOINT_KEY, DEFAULT_ENDPOINT));
const [token, setToken] = useState(() => sessionStored(TOKEN_KEY));
const [server, setServer] = useState<TrainingServerInfo>();
const [job, setJob] = useState<TrainingJob>();
const [busy, setBusy] = useState(false),
[error, setError] = useState<string>();
const [taskId, setTaskId] = useState('Unitree-Go2-Flat'),
[numEnvs, setNumEnvs] = useState(4096),
[maxIterations, setMaxIterations] = useState(2000),
[seed, setSeed] = useState(42),
[runName, setRunName] = useState('web'),
[device, setDevice] = useState<TrainingDevice>('gpu'),
[gpuIds, setGpuIds] = useState('0'),
[wandbMode, setWandbMode] = useState<WandbMode>('offline');
const connect = async () => {
setBusy(true);
setError(undefined);
try {
const client = new LocalTrainingClient(endpoint, token),
info = await client.health();
setServer(info);
try {
localStorage.setItem(ENDPOINT_KEY, client.endpoint);
sessionStorage.setItem(TOKEN_KEY, client.token);
} catch {
/* 当前会话仍可连接 */
}
if (info.tasks.length && !info.tasks.includes(taskId)) setTaskId(info.tasks[0]);
const remembered = info.activeJobId ?? stored(JOB_KEY);
if (remembered) {
try {
const recovered = await client.job(remembered);
setJob(recovered);
try {
localStorage.setItem(JOB_KEY, recovered.id);
} catch {
/* ignore */
}
} catch {
setJob(undefined);
try {
localStorage.removeItem(JOB_KEY);
} catch {
/* ignore */
}
}
} else {
setJob(undefined);
}
if (!info.ready) setError(info.error ?? '训练服务尚未就绪');
} catch (value) {
setServer(undefined);
setError(errorText(value));
} finally {
setBusy(false);
}
};
const jobId = job?.id,
jobState = job?.state;
useEffect(() => {
if (!jobId || !jobState || !ACTIVE_STATES.has(jobState)) return;
let disposed = false;
const refresh = async () => {
try {
const next = await new LocalTrainingClient(endpoint, token).job(jobId);
if (!disposed) setJob(next);
} catch (value) {
if (!disposed) setError(errorText(value));
}
};
const timer = window.setInterval(() => void refresh(), 1500);
return () => {
disposed = true;
window.clearInterval(timer);
};
}, [endpoint, jobId, jobState, token]);
const start = async () => {
setBusy(true);
setError(undefined);
try {
const ids =
device === 'gpu'
? gpuIds
.split(/[\s,]+/)
.filter(Boolean)
.map(Number)
: [];
if (ids.some((id) => !Number.isInteger(id) || id < 0))
throw new Error('GPU 编号必须是非负整数');
const next = await new LocalTrainingClient(endpoint, token).start({
taskId,
numEnvs,
maxIterations,
seed,
runName,
device,
gpuIds: ids,
wandbMode,
});
setJob(next);
try {
localStorage.setItem(JOB_KEY, next.id);
} catch {
/* ignore */
}
} catch (value) {
setError(errorText(value));
} finally {
setBusy(false);
}
};
const cancel = async () => {
if (!job) return;
setBusy(true);
setError(undefined);
try {
setJob(await new LocalTrainingClient(endpoint, token).cancel(job.id));
} catch (value) {
setError(errorText(value));
} finally {
setBusy(false);
}
};
const importResult = async () => {
if (!job) return;
setBusy(true);
setError(undefined);
try {
onPolicyReady(await new LocalTrainingClient(endpoint, token).downloadPolicy(job.id));
} catch (value) {
setError(errorText(value));
} finally {
setBusy(false);
}
};
const active = Boolean(job && ACTIVE_STATES.has(job.state));
return (
<div>
<label className="block text-xs text-text-secondary">
<span className="mb-1 block">本地训练服务</span>
<div className="flex gap-2">
<input
aria-label="本地训练服务地址"
className="field h-7 min-w-0 flex-1 px-2 text-xs text-text-primary"
value={endpoint}
onChange={(event) => setEndpoint(event.target.value)}
/>
<Button
icon={<Link className="h-3.5 w-3.5" />}
disabled={busy || !token.trim()}
onClick={() => void connect()}
>
连接
</Button>
</div>
</label>
<label className="mt-2 block text-[10px] text-text-tertiary">
<span className="mb-1 block">访问令牌(服务启动时显示)</span>
<input
aria-label="训练服务访问令牌"
type="password"
autoComplete="off"
className="field h-7 w-full px-2 text-xs text-text-primary"
value={token}
onChange={(event) => setToken(event.target.value)}
/>
</label>
<div className="mt-2 flex items-center justify-between rounded-md border border-border bg-surface px-2 py-1.5 text-[10px] text-text-tertiary">
<span className="flex min-w-0 items-center gap-1.5 truncate">
<Server className="h-3.5 w-3.5" />
{server?.trainerRoot ?? '请先启动本地训练服务'}
</span>
<Badge tone={server?.ready ? 'success' : 'warning'}>
{server?.ready ? '可用' : '离线'}
</Badge>
</div>
{server?.ready && !job && (
<div className="mt-3 space-y-2">
<Field label="训练任务">
<Select
aria-label="训练任务"
className="w-full"
value={taskId}
onChange={(event) => setTaskId(event.target.value)}
>
{server.tasks.map((task) => (
<option key={task} value={task}>
{task}
</option>
))}
</Select>
</Field>
<div className="grid grid-cols-2 gap-2">
<NumberField
label="并行环境"
value={numEnvs}
min={1}
max={16384}
onChange={setNumEnvs}
/>
<NumberField
label="训练迭代"
value={maxIterations}
min={1}
max={1000000}
onChange={setMaxIterations}
/>
<NumberField
label="随机种子"
value={seed}
min={0}
max={2147483647}
onChange={setSeed}
/>
<Field label="运行名称">
<input
aria-label="运行名称"
className="field h-7 w-full px-2 text-xs text-text-primary"
value={runName}
onChange={(event) => setRunName(event.target.value)}
/>
</Field>
</div>
<div className="grid grid-cols-2 gap-2">
<Field label="计算设备">
<Select
aria-label="计算设备"
className="w-full"
value={device}
onChange={(event) => setDevice(event.target.value as TrainingDevice)}
>
<option value="gpu">GPU</option>
<option value="cpu">CPU</option>
</Select>
</Field>
<Field label="GPU 编号">
<input
aria-label="GPU 编号"
className="field h-7 w-full px-2 text-xs text-text-primary disabled:opacity-40"
value={gpuIds}
disabled={device === 'cpu'}
onChange={(event) => setGpuIds(event.target.value)}
/>
</Field>
</div>
<Field label="实验记录">
<Select
aria-label="W&B 模式"
className="w-full"
value={wandbMode}
onChange={(event) => setWandbMode(event.target.value as WandbMode)}
>
<option value="offline">本地离线(默认,无需登录)</option>
<option value="disabled">完全禁用 W&amp;B</option>
<option value="online">在线 W&amp;B(需要 API Key)</option>
</Select>
</Field>
<Button
variant="primary"
className="w-full"
icon={<Play className="h-3.5 w-3.5" />}
disabled={busy}
onClick={() => void start()}
>
发起本地训练
</Button>
<p className="text-[10px] leading-4 text-text-tertiary">
训练使用本地 mjlab
任务资产,不会把浏览器中的模型上传到网络。服务一次只运行一个训练任务。
</p>
</div>
)}
{job && (
<div className="mt-3 rounded-lg border border-border bg-surface p-2.5">
<div className="mb-2 flex items-center justify-between gap-2">
<span className="truncate text-xs font-medium text-text-primary" title={job.id}>
{job.taskId}
</span>
<Badge
tone={
job.state === 'succeeded'
? 'success'
: job.state === 'failed' || job.state === 'cancelled'
? 'warning'
: 'accent'
}
>
{stateLabel(job.state)}
</Badge>
</div>
<ProgressBar value={job.progress} label="训练进度" />
<div className="mt-2">
<PropertyRow label="迭代" value={`${job.iteration} / ${job.maxIterations}`} />
<PropertyRow label="状态" value={job.message} />
</div>
{job.logs.length > 0 && (
<details className="mt-2">
<summary className="cursor-pointer text-[10px] text-text-secondary">最近日志</summary>
<pre className="mt-1 max-h-36 overflow-auto whitespace-pre-wrap break-all rounded bg-app p-2 text-[9px] leading-4 text-text-tertiary">
{job.logs.slice(-40).join('\n')}
</pre>
</details>
)}
<div className="mt-3 grid grid-cols-2 gap-2">
{active ? (
<Button
variant="danger"
className="col-span-2"
icon={<Square className="h-3.5 w-3.5" />}
disabled={busy}
onClick={() => void cancel()}
>
停止训练
</Button>
) : (
<>
<Button
disabled={busy || !job.artifactReady}
icon={<Download className="h-3.5 w-3.5" />}
onClick={() => void importResult()}
>
导入策略
</Button>
<Button
onClick={() => {
setJob(undefined);
try {
localStorage.removeItem(JOB_KEY);
} catch {
/* ignore */
}
}}
>
新建任务
</Button>
</>
)}
</div>
</div>
)}
{error && (
<p
role="alert"
className="mt-2 break-words rounded bg-danger/10 p-2 text-[10px] leading-4 text-danger"
>
{error}
</p>
)}
</div>
);
}
function Field({ label, children }: { label: string; children: ReactNode }) {
return (
<label className="block text-[10px] text-text-tertiary">
<span className="mb-1 block">{label}</span>
{children}
</label>
);
}
function NumberField({
label,
value,
min,
max,
onChange,
}: {
label: string;
value: number;
min: number;
max: number;
onChange(value: number): void;
}) {
return (
<Field label={label}>
<input
aria-label={label}
type="number"
className="field h-7 w-full px-2 text-xs text-text-primary"
value={value}
min={min}
max={max}
onChange={(event) => onChange(Number(event.target.value))}
/>
</Field>
);
}
@@ -1,7 +1,141 @@
import {useEffect,useRef} from 'react';
import {Bell,CheckCircle2,Info,Trash2,TriangleAlert,XCircle} from 'lucide-react';
import {Badge,IconButton,Popover} from '../../components/ui';
export interface WorkbenchNotification{id:number;title:string;detail?:string;tone:'success'|'warning'|'danger'|'info';at:number;}
const icons={success:CheckCircle2,warning:TriangleAlert,danger:XCircle,info:Info};
export function NotificationCenter({items,onDismiss,onClear,onOpenLog}:{items:WorkbenchNotification[];onDismiss:(id:number)=>void;onClear:()=>void;onOpenLog?:()=>void}){return <Popover label="通知中心" trigger={({open,toggle})=><IconButton tooltip="通知中心" aria-label="通知中心" aria-expanded={open} onClick={toggle}><Bell className="h-4 w-4"/>{items.length>0&&<span className="absolute right-0 top-0 h-1.5 w-1.5 rounded-full bg-warning"/>}</IconButton>}>{({close})=><div className="w-80 overflow-hidden rounded-lg border border-border bg-surface-elevated shadow-xl"><header className="flex h-9 items-center justify-between border-b border-border px-3"><h2 className="text-xs font-semibold">通知</h2><div className="flex gap-2">{onOpenLog&&<button className="text-[10px] text-accent" onClick={()=>{close();onOpenLog();}}>事件日志</button>}{items.length>0&&<button className="flex items-center gap-1 text-[10px] text-text-tertiary hover:text-danger" onClick={onClear}><Trash2 className="h-3 w-3"/>清空</button>}</div></header><div className="panel-scroll max-h-80 overflow-auto">{items.length?items.map(item=>{const Icon=icons[item.tone];return <article key={item.id} className="flex gap-2 border-b border-border px-3 py-2.5 last:border-0"><Icon className={`mt-0.5 h-4 w-4 shrink-0 ${item.tone==='success'?'text-success':item.tone==='warning'?'text-warning':item.tone==='danger'?'text-danger':'text-accent'}`}/><div className="min-w-0 flex-1"><div className="flex items-center gap-2"><h3 className="truncate text-xs font-medium">{item.title}</h3><Badge>{new Date(item.at).toLocaleTimeString('zh-CN',{hour:'2-digit',minute:'2-digit'})}</Badge></div>{item.detail&&<p className="mt-1 line-clamp-3 text-[10px] leading-4 text-text-tertiary">{item.detail}</p>}</div><IconButton aria-label={`移除通知:${item.title}`} tooltip="移除" onClick={()=>onDismiss(item.id)}><XCircle className="h-3.5 w-3.5"/></IconButton></article>}):<p className="p-6 text-center text-xs text-text-tertiary">当前没有通知</p>}</div></div>}</Popover>;}
export function ToastViewport({item,onDismiss}:{item?:WorkbenchNotification;onDismiss:(id:number)=>void}){const dismissRef=useRef(onDismiss);useEffect(()=>{dismissRef.current=onDismiss;},[onDismiss]);useEffect(()=>{if(!item)return;const timer=window.setTimeout(()=>dismissRef.current(item.id),4000);return()=>window.clearTimeout(timer);},[item]);if(!item)return null;const Icon=icons[item.tone];return <div role="status" className="pointer-events-auto absolute right-4 top-4 z-30 flex w-80 gap-2 rounded-lg border border-border bg-surface-elevated p-3 shadow-xl"><Icon className="h-4 w-4 shrink-0 text-accent"/><div className="min-w-0 flex-1"><p className="text-xs font-medium">{item.title}</p>{item.detail&&<p className="mt-1 line-clamp-2 text-[10px] text-text-tertiary">{item.detail}</p>}</div></div>;}
import { useEffect, useRef } from 'react';
import { Bell, CheckCircle2, Info, Trash2, TriangleAlert, XCircle } from 'lucide-react';
import { Badge, IconButton, Popover } from '../../components/ui';
export interface WorkbenchNotification {
id: number;
title: string;
detail?: string;
tone: 'success' | 'warning' | 'danger' | 'info';
at: number;
}
const icons = { success: CheckCircle2, warning: TriangleAlert, danger: XCircle, info: Info };
export function NotificationCenter({
items,
onDismiss,
onClear,
onOpenLog,
}: {
items: WorkbenchNotification[];
onDismiss: (id: number) => void;
onClear: () => void;
onOpenLog?: () => void;
}) {
return (
<Popover
label="通知中心"
trigger={({ open, toggle }) => (
<IconButton tooltip="通知中心" aria-label="通知中心" aria-expanded={open} onClick={toggle}>
<Bell className="h-4 w-4" />
{items.length > 0 && (
<span className="absolute right-0 top-0 h-1.5 w-1.5 rounded-full bg-warning" />
)}
</IconButton>
)}
>
{({ close }) => (
<div className="w-80 overflow-hidden rounded-lg border border-border bg-surface-elevated shadow-xl">
<header className="flex h-9 items-center justify-between border-b border-border px-3">
<h2 className="text-xs font-semibold">通知</h2>
<div className="flex gap-2">
{onOpenLog && (
<button
className="text-[10px] text-accent"
onClick={() => {
close();
onOpenLog();
}}
>
事件日志
</button>
)}
{items.length > 0 && (
<button
className="flex items-center gap-1 text-[10px] text-text-tertiary hover:text-danger"
onClick={onClear}
>
<Trash2 className="h-3 w-3" />
清空
</button>
)}
</div>
</header>
<div className="panel-scroll max-h-80 overflow-auto">
{items.length ? (
items.map((item) => {
const Icon = icons[item.tone];
return (
<article
key={item.id}
className="flex gap-2 border-b border-border px-3 py-2.5 last:border-0"
>
<Icon
className={`mt-0.5 h-4 w-4 shrink-0 ${item.tone === 'success' ? 'text-success' : item.tone === 'warning' ? 'text-warning' : item.tone === 'danger' ? 'text-danger' : 'text-accent'}`}
/>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<h3 className="truncate text-xs font-medium">{item.title}</h3>
<Badge>
{new Date(item.at).toLocaleTimeString('zh-CN', {
hour: '2-digit',
minute: '2-digit',
})}
</Badge>
</div>
{item.detail && (
<p className="mt-1 line-clamp-3 text-[10px] leading-4 text-text-tertiary">
{item.detail}
</p>
)}
</div>
<IconButton
aria-label={`移除通知:${item.title}`}
tooltip="移除"
onClick={() => onDismiss(item.id)}
>
<XCircle className="h-3.5 w-3.5" />
</IconButton>
</article>
);
})
) : (
<p className="p-6 text-center text-xs text-text-tertiary">当前没有通知</p>
)}
</div>
</div>
)}
</Popover>
);
}
export function ToastViewport({
item,
onDismiss,
}: {
item?: WorkbenchNotification;
onDismiss: (id: number) => void;
}) {
const dismissRef = useRef(onDismiss);
useEffect(() => {
dismissRef.current = onDismiss;
}, [onDismiss]);
useEffect(() => {
if (!item) return;
const timer = window.setTimeout(() => dismissRef.current(item.id), 4000);
return () => window.clearTimeout(timer);
}, [item]);
if (!item) return null;
const Icon = icons[item.tone];
return (
<div
role="status"
className="pointer-events-auto absolute right-4 top-4 z-30 flex w-80 gap-2 rounded-lg border border-border bg-surface-elevated p-3 shadow-xl"
>
<Icon className="h-4 w-4 shrink-0 text-accent" />
<div className="min-w-0 flex-1">
<p className="text-xs font-medium">{item.title}</p>
{item.detail && (
<p className="mt-1 line-clamp-2 text-[10px] text-text-tertiary">{item.detail}</p>
)}
</div>
</div>
);
}
@@ -1,3 +1,68 @@
import {Activity,ChevronUp,Cpu,MemoryStick,TriangleAlert} from 'lucide-react';
import {Badge,Popover,PropertyRow,Separator} from '../../components/ui';
export function PerformancePopover({fps,stepMs,memoryMb,overBudget}:{fps:number;stepMs:number;memoryMb?:number;overBudget:boolean}){return <Popover label="性能详情" placement="top-left" trigger={({open,toggle})=><button type="button" aria-haspopup="dialog" aria-expanded={open} onClick={toggle} className="flex h-6 items-center gap-3 rounded px-1.5 hover:bg-element-hover focus-visible:ring-2 focus-visible:ring-accent/30"><span className="flex items-center gap-1.5"><Activity className="h-3 w-3"/>FPS {fps.toFixed(0)}</span><span className="flex items-center gap-1.5"><Cpu className="h-3 w-3"/>物理 {stepMs.toFixed(2)} ms</span><ChevronUp className={`h-3 w-3 transition-transform ${open?'rotate-180':''}`}/></button>}>{()=> <div className="w-72 rounded-lg border border-border bg-surface-elevated p-3 text-xs text-text-secondary shadow-xl"><div className="mb-2 flex items-center justify-between"><h2 className="font-semibold text-text-primary">运行性能</h2><Badge tone={overBudget?'warning':'success'}>{overBudget?'预算超限':'运行正常'}</Badge></div><PropertyRow label="渲染帧率" value={`${fps.toFixed(0)} FPS`}/><PropertyRow label="物理步进" value={`${stepMs.toFixed(2)} ms`}/><PropertyRow label="浏览器内存" value={memoryMb===undefined?'不可用':`${memoryMb.toFixed(1)} MiB`}/><Separator className="my-2"/>{overBudget?<p className="flex gap-2 text-warning"><TriangleAlert className="mt-0.5 h-3.5 w-3.5 shrink-0"/>主线程超出步进预算,平台已限制追帧以保持交互响应。</p>:<p className="flex gap-2 text-text-tertiary"><MemoryStick className="mt-0.5 h-3.5 w-3.5 shrink-0"/>指标来自浏览器运行时,仅用于当前会话诊断。</p>}</div>}</Popover>;}
import { Activity, ChevronUp, Cpu, MemoryStick, TriangleAlert } from 'lucide-react';
import { Badge, Popover, PropertyRow, Separator } from '../../components/ui';
export function PerformancePopover({
fps,
stepMs,
memoryMb,
overBudget,
}: {
fps: number;
stepMs: number;
memoryMb?: number;
overBudget: boolean;
}) {
return (
<Popover
label="性能详情"
placement="top-left"
trigger={({ open, toggle }) => (
<button
type="button"
aria-haspopup="dialog"
aria-expanded={open}
onClick={toggle}
className="flex h-6 items-center gap-3 rounded px-1.5 hover:bg-element-hover focus-visible:ring-2 focus-visible:ring-accent/30"
>
<span className="flex items-center gap-1.5">
<Activity className="h-3 w-3" />
FPS {fps.toFixed(0)}
</span>
<span className="flex items-center gap-1.5">
<Cpu className="h-3 w-3" />
物理 {stepMs.toFixed(2)} ms
</span>
<ChevronUp className={`h-3 w-3 transition-transform ${open ? 'rotate-180' : ''}`} />
</button>
)}
>
{() => (
<div className="w-72 rounded-lg border border-border bg-surface-elevated p-3 text-xs text-text-secondary shadow-xl">
<div className="mb-2 flex items-center justify-between">
<h2 className="font-semibold text-text-primary">运行性能</h2>
<Badge tone={overBudget ? 'warning' : 'success'}>
{overBudget ? '预算超限' : '运行正常'}
</Badge>
</div>
<PropertyRow label="渲染帧率" value={`${fps.toFixed(0)} FPS`} />
<PropertyRow label="物理步进" value={`${stepMs.toFixed(2)} ms`} />
<PropertyRow
label="浏览器内存"
value={memoryMb === undefined ? '不可用' : `${memoryMb.toFixed(1)} MiB`}
/>
<Separator className="my-2" />
{overBudget ? (
<p className="flex gap-2 text-warning">
<TriangleAlert className="mt-0.5 h-3.5 w-3.5 shrink-0" />
主线程超出步进预算,平台已限制追帧以保持交互响应。
</p>
) : (
<p className="flex gap-2 text-text-tertiary">
<MemoryStick className="mt-0.5 h-3.5 w-3.5 shrink-0" />
指标来自浏览器运行时,仅用于当前会话诊断。
</p>
)}
</div>
)}
</Popover>
);
}
@@ -1,4 +1,52 @@
import {ChevronRight,FolderRoot} from 'lucide-react';
import type {ModelEntry} from '../../project/types';
import {SearchableCombobox} from '../../components/ui';
export function ProjectBreadcrumb({projectName,entries,selectedEntry,loading=false,onSelect}:{projectName:string;entries:ModelEntry[];selectedEntry?:string;loading?:boolean;onSelect:(path:string)=>void}){const parts=selectedEntry?.split('/').filter(Boolean)??[];return <div className="border-b border-border bg-surface px-3 py-2"><div aria-label="当前工程路径" className="flex min-w-0 items-center gap-1 text-[10px] text-text-tertiary"><FolderRoot className="h-3 w-3 shrink-0 text-accent"/><span className="truncate">{projectName}</span>{parts.map((part,index)=><span key={`${part}-${index}`} className="contents"><ChevronRight className="h-3 w-3 shrink-0"/><span className={`truncate ${index===parts.length-1?'text-text-primary':''}`}>{part}</span></span>)}</div>{entries.length>1&&<div className="mt-2"><SearchableCombobox label="切换模型入口" disabled={loading} value={selectedEntry} onChange={onSelect} options={entries.map(entry=>({value:entry.path,label:entry.label,description:entry.path}))}/></div>}</div>;}
import { ChevronRight, FolderRoot } from 'lucide-react';
import type { ModelEntry } from '../../project/types';
import { SearchableCombobox } from '../../components/ui';
export function ProjectBreadcrumb({
projectName,
entries,
selectedEntry,
loading = false,
onSelect,
}: {
projectName: string;
entries: ModelEntry[];
selectedEntry?: string;
loading?: boolean;
onSelect: (path: string) => void;
}) {
const parts = selectedEntry?.split('/').filter(Boolean) ?? [];
return (
<div className="border-b border-border bg-surface px-3 py-2">
<div
aria-label="当前工程路径"
className="flex min-w-0 items-center gap-1 text-[10px] text-text-tertiary"
>
<FolderRoot className="h-3 w-3 shrink-0 text-accent" />
<span className="truncate">{projectName}</span>
{parts.map((part, index) => (
<span key={`${part}-${index}`} className="contents">
<ChevronRight className="h-3 w-3 shrink-0" />
<span className={`truncate ${index === parts.length - 1 ? 'text-text-primary' : ''}`}>
{part}
</span>
</span>
))}
</div>
{entries.length > 1 && (
<div className="mt-2">
<SearchableCombobox
label="切换模型入口"
disabled={loading}
value={selectedEntry}
onChange={onSelect}
options={entries.map((entry) => ({
value: entry.path,
label: entry.label,
description: entry.path,
}))}
/>
</div>
)}
</div>
);
}
@@ -1,23 +1,69 @@
import {fireEvent,render,screen} from '@testing-library/react';
import {describe,expect,it,vi} from 'vitest';
import {PythonControllerPanel} from './PythonControllerPanel';
import { fireEvent, render, screen } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import { PythonControllerPanel } from './PythonControllerPanel';
const noop=()=>{};
const noop = () => {};
describe('PythonControllerPanel',()=>{
it('向支持 command 的已启用控制器发送基本移动指令',()=>{
const onCommand=vi.fn();
render(<PythonControllerPanel paths={[]} loading={false} status={{language:'python',path:'go2.py',name:'Go2',controlHz:200,loaded:true,enabled:true,acceptsCommands:true,activeCommand:'stop',lastStepMs:.1}} onSelectPath={noop} onLoadPath={noop} onImport={noop} onToggle={noop} onCommand={onCommand} onRemove={noop}/>);
fireEvent.click(screen.getByRole('button',{name:'前进'}));
fireEvent.click(screen.getByRole('button',{name:'左转'}));
fireEvent.click(screen.getByRole('button',{name:'起跳'}));
expect(onCommand.mock.calls).toEqual([['forward'],['turn_left'],['jump']]);
expect(screen.getByRole('button',{name:'移动停止'})).toHaveAttribute('aria-pressed','true');
describe('PythonControllerPanel', () => {
it('向支持 command 的已启用控制器发送基本移动指令', () => {
const onCommand = vi.fn();
render(
<PythonControllerPanel
paths={[]}
loading={false}
status={{
language: 'python',
path: 'go2.py',
name: 'Go2',
controlHz: 200,
loaded: true,
enabled: true,
acceptsCommands: true,
activeCommand: 'stop',
lastStepMs: 0.1,
}}
onSelectPath={noop}
onLoadPath={noop}
onImport={noop}
onToggle={noop}
onCommand={onCommand}
onRemove={noop}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '前进' }));
fireEvent.click(screen.getByRole('button', { name: '左转' }));
fireEvent.click(screen.getByRole('button', { name: '起跳' }));
expect(onCommand.mock.calls).toEqual([['forward'], ['turn_left'], ['jump']]);
expect(screen.getByRole('button', { name: '移动停止' })).toHaveAttribute(
'aria-pressed',
'true',
);
});
it('控制器未启用时禁用基本移动按钮',()=>{
render(<PythonControllerPanel paths={[]} loading={false} status={{language:'python',path:'go2.py',name:'Go2',controlHz:200,loaded:true,enabled:false,acceptsCommands:true,lastStepMs:0}} onSelectPath={noop} onLoadPath={noop} onImport={noop} onToggle={noop} onCommand={noop} onRemove={noop}/>);
expect(screen.getByRole('button',{name:'前进'})).toBeDisabled();
expect(screen.getByRole('button',{name:'起跳'})).toBeDisabled();
it('控制器未启用时禁用基本移动按钮', () => {
render(
<PythonControllerPanel
paths={[]}
loading={false}
status={{
language: 'python',
path: 'go2.py',
name: 'Go2',
controlHz: 200,
loaded: true,
enabled: false,
acceptsCommands: true,
lastStepMs: 0,
}}
onSelectPath={noop}
onLoadPath={noop}
onImport={noop}
onToggle={noop}
onCommand={noop}
onRemove={noop}
/>,
);
expect(screen.getByRole('button', { name: '前进' })).toBeDisabled();
expect(screen.getByRole('button', { name: '起跳' })).toBeDisabled();
});
});
@@ -1,37 +1,184 @@
import {useRef,type ChangeEvent} from 'react';
import {ArrowDown,ArrowLeft,ArrowRight,ArrowUp,FileUp,Octagon,Power,RotateCw,Trash2} from 'lucide-react';
import type {ControllerCommand,ControllerStatus} from '../../controller/types';
import {Badge,Button,PropertyRow,Select} from '../../components/ui';
import { useRef, type ChangeEvent } from 'react';
import {
ArrowDown,
ArrowLeft,
ArrowRight,
ArrowUp,
FileUp,
Octagon,
Power,
RotateCw,
Trash2,
} from 'lucide-react';
import type { ControllerCommand, ControllerStatus } from '../../controller/types';
import { Badge, Button, PropertyRow, Select } from '../../components/ui';
export interface PythonControllerPanelProps {
paths:string[];
selectedPath?:string;
status?:ControllerStatus;
loading:boolean;
onSelectPath(path:string):void;
onLoadPath(path:string):void;
onImport(file:File):void;
onToggle(enabled:boolean):void;
onCommand(command:ControllerCommand):void;
onRemove():void;
paths: string[];
selectedPath?: string;
status?: ControllerStatus;
loading: boolean;
onSelectPath(path: string): void;
onLoadPath(path: string): void;
onImport(file: File): void;
onToggle(enabled: boolean): void;
onCommand(command: ControllerCommand): void;
onRemove(): void;
}
export function PythonControllerPanel({paths,selectedPath,status,loading,onSelectPath,onLoadPath,onImport,onToggle,onCommand,onRemove}:PythonControllerPanelProps){
const input=useRef<HTMLInputElement>(null);
const importFile=(event:ChangeEvent<HTMLInputElement>)=>{const file=event.target.files?.[0];if(file)onImport(file);event.target.value='';};
return <div>
<input ref={input} className="hidden" type="file" accept=".py,text/x-python" onChange={importFile}/>
{paths.length>0&&<label className="mb-3 block text-xs text-text-secondary"><span className="mb-1 block">工程内脚本</span><Select aria-label="Python 控制脚本" className="w-full" value={selectedPath??''} disabled={loading} onChange={event=>onSelectPath(event.target.value)}><option value="">选择 .py 文件</option>{paths.map(path=><option key={path} value={path}>{path}</option>)}</Select></label>}
<div className="grid grid-cols-2 gap-2">
<Button icon={<FileUp className="h-3.5 w-3.5"/>} disabled={loading} onClick={()=>input.current?.click()}>导入 .py</Button>
<Button icon={<RotateCw className="h-3.5 w-3.5"/>} disabled={loading||!selectedPath} onClick={()=>selectedPath&&onLoadPath(selectedPath)}>加载脚本</Button>
export function PythonControllerPanel({
paths,
selectedPath,
status,
loading,
onSelectPath,
onLoadPath,
onImport,
onToggle,
onCommand,
onRemove,
}: PythonControllerPanelProps) {
const input = useRef<HTMLInputElement>(null);
const importFile = (event: ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (file) onImport(file);
event.target.value = '';
};
return (
<div>
<input
ref={input}
className="hidden"
type="file"
accept=".py,text/x-python"
onChange={importFile}
/>
{paths.length > 0 && (
<label className="mb-3 block text-xs text-text-secondary">
<span className="mb-1 block">工程内脚本</span>
<Select
aria-label="Python 控制脚本"
className="w-full"
value={selectedPath ?? ''}
disabled={loading}
onChange={(event) => onSelectPath(event.target.value)}
>
<option value="">选择 .py 文件</option>
{paths.map((path) => (
<option key={path} value={path}>
{path}
</option>
))}
</Select>
</label>
)}
<div className="grid grid-cols-2 gap-2">
<Button
icon={<FileUp className="h-3.5 w-3.5" />}
disabled={loading}
onClick={() => input.current?.click()}
>
导入 .py
</Button>
<Button
icon={<RotateCw className="h-3.5 w-3.5" />}
disabled={loading || !selectedPath}
onClick={() => selectedPath && onLoadPath(selectedPath)}
>
加载脚本
</Button>
</div>
{status ? (
<div className="mt-3 rounded-lg border border-border bg-surface p-2.5">
<div className="mb-2 flex items-center justify-between gap-2">
<span className="truncate text-xs font-medium text-text-primary" title={status.path}>
{status.name}
</span>
<Badge>{status.enabled ? '运行中' : '已停止'}</Badge>
</div>
<PropertyRow label="语言" value="Python / Pyodide" />
<PropertyRow label="控制频率" value={`${status.controlHz} Hz`} />
<PropertyRow label="上次耗时" value={`${status.lastStepMs.toFixed(3)} ms`} />
{status.error && (
<p
role="alert"
className="mt-2 break-words rounded bg-danger/10 p-2 text-[10px] leading-4 text-danger"
>
{status.error}
</p>
)}
{status.acceptsCommands && (
<div className="mt-3 border-t border-border pt-3">
<p className="mb-2 text-[10px] text-text-tertiary">基本移动指令</p>
<div className="grid grid-cols-3 gap-1.5">
<span />
<Button
aria-pressed={status.activeCommand === 'forward'}
disabled={!status.enabled}
icon={<ArrowUp className="h-3.5 w-3.5" />}
onClick={() => onCommand('forward')}
>
前进
</Button>
<span />
<Button
aria-pressed={status.activeCommand === 'turn_left'}
disabled={!status.enabled}
icon={<ArrowLeft className="h-3.5 w-3.5" />}
onClick={() => onCommand('turn_left')}
>
左转
</Button>
<Button
aria-label="移动停止"
aria-pressed={status.activeCommand === 'stop'}
disabled={!status.enabled}
icon={<Octagon className="h-3.5 w-3.5" />}
onClick={() => onCommand('stop')}
>
停止
</Button>
<Button
aria-pressed={status.activeCommand === 'turn_right'}
disabled={!status.enabled}
icon={<ArrowRight className="h-3.5 w-3.5" />}
onClick={() => onCommand('turn_right')}
>
右转
</Button>
<span />
<Button
aria-pressed={status.activeCommand === 'backward'}
disabled={!status.enabled}
icon={<ArrowDown className="h-3.5 w-3.5" />}
onClick={() => onCommand('backward')}
>
后退
</Button>
<Button disabled={!status.enabled} onClick={() => onCommand('jump')}>
起跳
</Button>
</div>
</div>
)}
<div className="mt-3 grid grid-cols-2 gap-2">
<Button
variant={status.enabled ? 'secondary' : 'primary'}
icon={<Power className="h-3.5 w-3.5" />}
onClick={() => onToggle(!status.enabled)}
>
{status.enabled ? '停止' : '启用'}
</Button>
<Button variant="danger" icon={<Trash2 className="h-3.5 w-3.5" />} onClick={onRemove}>
卸载
</Button>
</div>
</div>
) : (
<p className="mt-3 text-xs leading-5 text-text-tertiary">
加载可信的单文件 Python 控制器。脚本在每次 mj_step 前按仿真时间同步执行,默认 100 Hz。
</p>
)}
</div>
{status?<div className="mt-3 rounded-lg border border-border bg-surface p-2.5">
<div className="mb-2 flex items-center justify-between gap-2"><span className="truncate text-xs font-medium text-text-primary" title={status.path}>{status.name}</span><Badge>{status.enabled?'运行中':'已停止'}</Badge></div>
<PropertyRow label="语言" value="Python / Pyodide"/><PropertyRow label="控制频率" value={`${status.controlHz} Hz`}/><PropertyRow label="上次耗时" value={`${status.lastStepMs.toFixed(3)} ms`}/>
{status.error&&<p role="alert" className="mt-2 break-words rounded bg-danger/10 p-2 text-[10px] leading-4 text-danger">{status.error}</p>}
{status.acceptsCommands&&<div className="mt-3 border-t border-border pt-3"><p className="mb-2 text-[10px] text-text-tertiary">基本移动指令</p><div className="grid grid-cols-3 gap-1.5"><span/><Button aria-pressed={status.activeCommand==='forward'} disabled={!status.enabled} icon={<ArrowUp className="h-3.5 w-3.5"/>} onClick={()=>onCommand('forward')}>前进</Button><span/><Button aria-pressed={status.activeCommand==='turn_left'} disabled={!status.enabled} icon={<ArrowLeft className="h-3.5 w-3.5"/>} onClick={()=>onCommand('turn_left')}>左转</Button><Button aria-label="移动停止" aria-pressed={status.activeCommand==='stop'} disabled={!status.enabled} icon={<Octagon className="h-3.5 w-3.5"/>} onClick={()=>onCommand('stop')}>停止</Button><Button aria-pressed={status.activeCommand==='turn_right'} disabled={!status.enabled} icon={<ArrowRight className="h-3.5 w-3.5"/>} onClick={()=>onCommand('turn_right')}>右转</Button><span/><Button aria-pressed={status.activeCommand==='backward'} disabled={!status.enabled} icon={<ArrowDown className="h-3.5 w-3.5"/>} onClick={()=>onCommand('backward')}>后退</Button><Button disabled={!status.enabled} onClick={()=>onCommand('jump')}>起跳</Button></div></div>}
<div className="mt-3 grid grid-cols-2 gap-2"><Button variant={status.enabled?'secondary':'primary'} icon={<Power className="h-3.5 w-3.5"/>} onClick={()=>onToggle(!status.enabled)}>{status.enabled?'停止':'启用'}</Button><Button variant="danger" icon={<Trash2 className="h-3.5 w-3.5"/>} onClick={onRemove}>卸载</Button></div>
</div>:<p className="mt-3 text-xs leading-5 text-text-tertiary">加载可信的单文件 Python 控制器。脚本在每次 mj_step 前按仿真时间同步执行,默认 100 Hz。</p>}
</div>;
);
}
+184 -24
View File
@@ -1,30 +1,190 @@
import {useRef,type ChangeEvent} from 'react';
import {BrainCircuit,FileUp,Power,RotateCw,Trash2} from 'lucide-react';
import type {RLCommand,RLPolicyStatus} from '../../rl/types';
import {Badge,Button,PropertyRow,Select} from '../../components/ui';
import { useRef, type ChangeEvent } from 'react';
import { BrainCircuit, FileUp, Power, RotateCw, Trash2 } from 'lucide-react';
import type { RLCommand, RLPolicyStatus } from '../../rl/types';
import { Badge, Button, PropertyRow, Select } from '../../components/ui';
export interface RLPolicyPanelProps {
paths:string[];selectedPath?:string;status?:RLPolicyStatus;loading:boolean;
onSelectPath(path:string):void;onLoadPath(path:string):void;onImport(file:File):void;
onToggle(enabled:boolean):void;onCommand(command:RLCommand):void;onRemove():void;
paths: string[];
selectedPath?: string;
status?: RLPolicyStatus;
loading: boolean;
onSelectPath(path: string): void;
onLoadPath(path: string): void;
onImport(file: File): void;
onToggle(enabled: boolean): void;
onCommand(command: RLCommand): void;
onRemove(): void;
}
export function RLPolicyPanel({paths,selectedPath,status,loading,onSelectPath,onLoadPath,onImport,onToggle,onCommand,onRemove}:RLPolicyPanelProps){
const input=useRef<HTMLInputElement>(null);
const importFile=(event:ChangeEvent<HTMLInputElement>)=>{const file=event.target.files?.[0];if(file)onImport(file);event.target.value='';};
const command=status?.command??{linearX:0,linearY:0,angularZ:0};
return <div>
<input ref={input} className="hidden" type="file" accept=".onnx,application/octet-stream" onChange={importFile}/>
{paths.length>0&&<label className="mb-3 block text-xs text-text-secondary"><span className="mb-1 block">工程内策略</span><Select aria-label="ONNX 策略" className="w-full" value={selectedPath??''} disabled={loading} onChange={event=>onSelectPath(event.target.value)}><option value="">选择 .onnx 文件</option>{paths.map(path=><option key={path} value={path}>{path}</option>)}</Select></label>}
<div className="grid grid-cols-2 gap-2"><Button icon={<FileUp className="h-3.5 w-3.5"/>} disabled={loading} onClick={()=>input.current?.click()}>导入 ONNX</Button><Button icon={<RotateCw className="h-3.5 w-3.5"/>} disabled={loading||!selectedPath} onClick={()=>selectedPath&&onLoadPath(selectedPath)}>加载策略</Button></div>
{status?<div className="mt-3 rounded-lg border border-border bg-surface p-2.5">
<div className="mb-2 flex items-center justify-between gap-2"><span className="flex min-w-0 items-center gap-1.5 truncate text-xs font-medium text-text-primary" title={status.path}><BrainCircuit className="h-3.5 w-3.5 shrink-0 text-accent"/>{status.taskName}</span><Badge>{status.enabled?'推理中':'已停止'}</Badge></div>
<PropertyRow label="控制频率" value={`${status.controlHz} Hz`}/><PropertyRow label="观测 / 动作" value={`${status.observationSize} / ${status.actionSize}`}/><PropertyRow label="推理次数" value={status.inferenceCount}/><PropertyRow label="上次推理" value={`${status.lastInferenceMs.toFixed(2)} ms`}/>
<div className="mt-3 border-t border-border pt-3"><p className="mb-2 text-[10px] text-text-tertiary">速度指令(机身坐标系)</p><CommandInput label="前向 m/s" value={command.linearX} min={-0.5} max={1} onChange={linearX=>onCommand({...command,linearX})}/><CommandInput label="侧向 m/s" value={command.linearY} min={-0.5} max={0.5} onChange={linearY=>onCommand({...command,linearY})}/><CommandInput label="偏航 rad/s" value={command.angularZ} min={-1} max={1} onChange={angularZ=>onCommand({...command,angularZ})}/><Button className="mt-1 w-full" onClick={()=>onCommand({linearX:0,linearY:0,angularZ:0})}>停止移动</Button></div>
{status.error&&<p role="alert" className="mt-2 break-words rounded bg-danger/10 p-2 text-[10px] leading-4 text-danger">{status.error}</p>}
<div className="mt-3 grid grid-cols-2 gap-2"><Button variant={status.enabled?'secondary':'primary'} icon={<Power className="h-3.5 w-3.5"/>} disabled={Boolean(status.error)} onClick={()=>onToggle(!status.enabled)}>{status.enabled?'停止':'启用'}</Button><Button variant="danger" icon={<Trash2 className="h-3.5 w-3.5"/>} onClick={onRemove}>卸载</Button></div>
</div>:<p className="mt-3 text-xs leading-5 text-text-tertiary">加载 mjlab 导出的单输入、单动作输出 policy.onnx。首个内置任务使用 47 维 Go2 actor 观测和 12 维腿部关节位置动作;Go2-W 轮电机保持零力矩。</p>}
</div>;
export function RLPolicyPanel({
paths,
selectedPath,
status,
loading,
onSelectPath,
onLoadPath,
onImport,
onToggle,
onCommand,
onRemove,
}: RLPolicyPanelProps) {
const input = useRef<HTMLInputElement>(null);
const importFile = (event: ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (file) onImport(file);
event.target.value = '';
};
const command = status?.command ?? { linearX: 0, linearY: 0, angularZ: 0 };
return (
<div>
<input
ref={input}
className="hidden"
type="file"
accept=".onnx,application/octet-stream"
onChange={importFile}
/>
{paths.length > 0 && (
<label className="mb-3 block text-xs text-text-secondary">
<span className="mb-1 block">工程内策略</span>
<Select
aria-label="ONNX 策略"
className="w-full"
value={selectedPath ?? ''}
disabled={loading}
onChange={(event) => onSelectPath(event.target.value)}
>
<option value="">选择 .onnx 文件</option>
{paths.map((path) => (
<option key={path} value={path}>
{path}
</option>
))}
</Select>
</label>
)}
<div className="grid grid-cols-2 gap-2">
<Button
icon={<FileUp className="h-3.5 w-3.5" />}
disabled={loading}
onClick={() => input.current?.click()}
>
导入 ONNX
</Button>
<Button
icon={<RotateCw className="h-3.5 w-3.5" />}
disabled={loading || !selectedPath}
onClick={() => selectedPath && onLoadPath(selectedPath)}
>
加载策略
</Button>
</div>
{status ? (
<div className="mt-3 rounded-lg border border-border bg-surface p-2.5">
<div className="mb-2 flex items-center justify-between gap-2">
<span
className="flex min-w-0 items-center gap-1.5 truncate text-xs font-medium text-text-primary"
title={status.path}
>
<BrainCircuit className="h-3.5 w-3.5 shrink-0 text-accent" />
{status.taskName}
</span>
<Badge>{status.enabled ? '推理中' : '已停止'}</Badge>
</div>
<PropertyRow label="控制频率" value={`${status.controlHz} Hz`} />
<PropertyRow
label="观测 / 动作"
value={`${status.observationSize} / ${status.actionSize}`}
/>
<PropertyRow label="推理次数" value={status.inferenceCount} />
<PropertyRow label="上次推理" value={`${status.lastInferenceMs.toFixed(2)} ms`} />
<div className="mt-3 border-t border-border pt-3">
<p className="mb-2 text-[10px] text-text-tertiary">速度指令(机身坐标系)</p>
<CommandInput
label="前向 m/s"
value={command.linearX}
min={-0.5}
max={1}
onChange={(linearX) => onCommand({ ...command, linearX })}
/>
<CommandInput
label="侧向 m/s"
value={command.linearY}
min={-0.5}
max={0.5}
onChange={(linearY) => onCommand({ ...command, linearY })}
/>
<CommandInput
label="偏航 rad/s"
value={command.angularZ}
min={-1}
max={1}
onChange={(angularZ) => onCommand({ ...command, angularZ })}
/>
<Button
className="mt-1 w-full"
onClick={() => onCommand({ linearX: 0, linearY: 0, angularZ: 0 })}
>
停止移动
</Button>
</div>
{status.error && (
<p
role="alert"
className="mt-2 break-words rounded bg-danger/10 p-2 text-[10px] leading-4 text-danger"
>
{status.error}
</p>
)}
<div className="mt-3 grid grid-cols-2 gap-2">
<Button
variant={status.enabled ? 'secondary' : 'primary'}
icon={<Power className="h-3.5 w-3.5" />}
disabled={Boolean(status.error)}
onClick={() => onToggle(!status.enabled)}
>
{status.enabled ? '停止' : '启用'}
</Button>
<Button variant="danger" icon={<Trash2 className="h-3.5 w-3.5" />} onClick={onRemove}>
卸载
</Button>
</div>
</div>
) : (
<p className="mt-3 text-xs leading-5 text-text-tertiary">
加载 mjlab 导出的单输入、单动作输出 policy.onnx。首个内置任务使用 47 维 Go2 actor 观测和
12 维腿部关节位置动作;Go2-W 轮电机保持零力矩。
</p>
)}
</div>
);
}
function CommandInput({label,value,min,max,onChange}:{label:string;value:number;min:number;max:number;onChange(value:number):void}){return <label className="mb-2 grid grid-cols-[1fr_72px] items-center gap-2 text-[10px] text-text-tertiary"><span>{label}</span><input className="field h-7 w-full px-2 text-right text-xs text-text-primary" type="number" step="0.05" min={min} max={max} value={value} onChange={event=>onChange(Number(event.target.value))}/></label>;}
function CommandInput({
label,
value,
min,
max,
onChange,
}: {
label: string;
value: number;
min: number;
max: number;
onChange(value: number): void;
}) {
return (
<label className="mb-2 grid grid-cols-[1fr_72px] items-center gap-2 text-[10px] text-text-tertiary">
<span>{label}</span>
<input
className="field h-7 w-full px-2 text-right text-xs text-text-primary"
type="number"
step="0.05"
min={min}
max={max}
value={value}
onChange={(event) => onChange(Number(event.target.value))}
/>
</label>
);
}
@@ -1,15 +1,62 @@
import {fireEvent,render,screen} from '@testing-library/react';
import {ShortcutHelpDialog} from './ShortcutHelpDialog';
import {TreeSearchField} from './TreeSearchField';
import {ViewportHUD} from './ViewportHUD';
import {EmptyWorkspace} from './WorkspaceOverlays';
import {ViewerDisplayPopover} from './ViewerDisplayPopover';
import {DEFAULT_VIEWER_DISPLAY_OPTIONS} from '../../viewer/displayOptions';
import { fireEvent, render, screen } from '@testing-library/react';
import { ShortcutHelpDialog } from './ShortcutHelpDialog';
import { TreeSearchField } from './TreeSearchField';
import { ViewportHUD } from './ViewportHUD';
import { EmptyWorkspace } from './WorkspaceOverlays';
import { ViewerDisplayPopover } from './ViewerDisplayPopover';
import { DEFAULT_VIEWER_DISPLAY_OPTIONS } from '../../viewer/displayOptions';
describe('第二批工作台组件',()=>{
it('搜索框发送内容并可清除',()=>{const change=vi.fn();const {rerender}=render(<TreeSearchField value="" onChange={change}/>);fireEvent.change(screen.getByRole('searchbox'),{target:{value:'arm'}});expect(change).toHaveBeenCalledWith('arm');rerender(<TreeSearchField value="arm" resultCount={2} onChange={change}/>);expect(screen.getByRole('status')).toHaveTextContent('找到 2 个匹配项');fireEvent.click(screen.getByRole('button',{name:'清除搜索'}));expect(change).toHaveBeenLastCalledWith('');});
it('快捷键帮助展示说明并支持 Escape',()=>{const close=vi.fn();render(<ShortcutHelpDialog open onClose={close}/>);expect(screen.getByRole('dialog',{name:'快捷键与视口操作'})).toBeVisible();expect(screen.getByText('播放 / 暂停')).toBeVisible();fireEvent.keyDown(document,{key:'Escape'});expect(close).toHaveBeenCalledTimes(1);});
it('视口 HUD 复用状态并给出当前模式的鼠标提示',()=>{render(<ViewportHUD ready paused={false} mode="joint" selection={{bodyId:2,bodyName:'arm',geomId:3,geomType:1,position:[0,0,0]}}/>);expect(screen.getByLabelText('视口状态')).toHaveTextContent('仿真中');expect(screen.getByLabelText('视口状态')).toHaveTextContent('关节拖动');expect(screen.getByLabelText('视口状态')).toHaveTextContent('arm');expect(screen.getByLabelText('视口操作提示')).toHaveTextContent('左键拖动关节');expect(screen.getByLabelText('视口操作提示')).toHaveTextContent('右键平移');});
it('空工作区解释导入到仿真的三步流程',()=>{render(<EmptyWorkspace/>);expect(screen.getByRole('region',{name:'导入模型工程'})).toBeVisible();expect(screen.getByRole('list',{name:'仿真工作流程'})).toHaveTextContent('导入');expect(screen.getByRole('list',{name:'仿真工作流程'})).toHaveTextContent('检查与配置');expect(screen.getByRole('list',{name:'仿真工作流程'})).toHaveTextContent('运行与调试');expect(screen.getByText('模型与资源仅在当前浏览器会话中处理')).toBeVisible();});
it('显示浮窗切换碰撞体和结构辅助标记',()=>{const change=vi.fn();render(<ViewerDisplayPopover value={{...DEFAULT_VIEWER_DISPLAY_OPTIONS}} onChange={change}/>);fireEvent.click(screen.getByRole('button',{name:'显示设置'}));expect(screen.getByRole('dialog',{name:'视图显示设置'})).toBeVisible();expect(screen.getAllByRole('switch')).toHaveLength(7);fireEvent.click(screen.getByRole('switch',{name:/碰撞体/}));expect(change).toHaveBeenCalledWith({...DEFAULT_VIEWER_DISPLAY_OPTIONS,showCollision:true});});
describe('第二批工作台组件', () => {
it('搜索框发送内容并可清除', () => {
const change = vi.fn();
const { rerender } = render(<TreeSearchField value="" onChange={change} />);
fireEvent.change(screen.getByRole('searchbox'), { target: { value: 'arm' } });
expect(change).toHaveBeenCalledWith('arm');
rerender(<TreeSearchField value="arm" resultCount={2} onChange={change} />);
expect(screen.getByRole('status')).toHaveTextContent('找到 2 个匹配项');
fireEvent.click(screen.getByRole('button', { name: '清除搜索' }));
expect(change).toHaveBeenLastCalledWith('');
});
it('快捷键帮助展示说明并支持 Escape', () => {
const close = vi.fn();
render(<ShortcutHelpDialog open onClose={close} />);
expect(screen.getByRole('dialog', { name: '快捷键与视口操作' })).toBeVisible();
expect(screen.getByText('播放 / 暂停')).toBeVisible();
fireEvent.keyDown(document, { key: 'Escape' });
expect(close).toHaveBeenCalledTimes(1);
});
it('视口 HUD 复用状态并给出当前模式的鼠标提示', () => {
render(
<ViewportHUD
ready
paused={false}
mode="joint"
selection={{ bodyId: 2, bodyName: 'arm', geomId: 3, geomType: 1, position: [0, 0, 0] }}
/>,
);
expect(screen.getByLabelText('视口状态')).toHaveTextContent('仿真中');
expect(screen.getByLabelText('视口状态')).toHaveTextContent('关节拖动');
expect(screen.getByLabelText('视口状态')).toHaveTextContent('arm');
expect(screen.getByLabelText('视口操作提示')).toHaveTextContent('左键拖动关节');
expect(screen.getByLabelText('视口操作提示')).toHaveTextContent('右键平移');
});
it('空工作区解释导入到仿真的三步流程', () => {
render(<EmptyWorkspace />);
expect(screen.getByRole('region', { name: '导入模型工程' })).toBeVisible();
expect(screen.getByRole('list', { name: '仿真工作流程' })).toHaveTextContent('导入');
expect(screen.getByRole('list', { name: '仿真工作流程' })).toHaveTextContent('检查与配置');
expect(screen.getByRole('list', { name: '仿真工作流程' })).toHaveTextContent('运行与调试');
expect(screen.getByText('模型与资源仅在当前浏览器会话中处理')).toBeVisible();
});
it('显示浮窗切换碰撞体和结构辅助标记', () => {
const change = vi.fn();
render(
<ViewerDisplayPopover value={{ ...DEFAULT_VIEWER_DISPLAY_OPTIONS }} onChange={change} />,
);
fireEvent.click(screen.getByRole('button', { name: '显示设置' }));
expect(screen.getByRole('dialog', { name: '视图显示设置' })).toBeVisible();
expect(screen.getAllByRole('switch')).toHaveLength(7);
fireEvent.click(screen.getByRole('switch', { name: /碰撞体/ }));
expect(change).toHaveBeenCalledWith({ ...DEFAULT_VIEWER_DISPLAY_OPTIONS, showCollision: true });
});
});
@@ -1,3 +1,106 @@
import {Dialog,PropertyRow,Select} from '../../components/ui';
export function SettingsDialog({open,onClose,theme,angleUnit,showCollision,jointAdvanced,forceScale,onTheme,onAngleUnit,onShowCollision,onJointAdvanced,onForceScale}:{open:boolean;onClose:()=>void;theme:'light'|'dark';angleUnit:'rad'|'deg';showCollision:boolean;jointAdvanced:boolean;forceScale:number;onTheme:(value:'light'|'dark')=>void;onAngleUnit:(value:'rad'|'deg')=>void;onShowCollision:(value:boolean)=>void;onJointAdvanced:(value:boolean)=>void;onForceScale:(value:number)=>void}){return <Dialog open={open} onClose={onClose} title="工作台设置"><div className="space-y-4"><section><h3 className="mb-2 text-xs font-semibold">外观</h3><PropertyRow label="主题" value={<Select aria-label="设置主题" value={theme} onChange={event=>onTheme(event.target.value as 'light'|'dark')}><option value="dark">黑夜</option><option value="light">白天</option></Select>}/></section><section><h3 className="mb-2 text-xs font-semibold">模型与控制</h3><PropertyRow label="角度单位" value={<Select aria-label="设置角度单位" value={angleUnit} onChange={event=>onAngleUnit(event.target.value as 'rad'|'deg')}><option value="rad">弧度</option><option value="deg">角度</option></Select>}/><Check label="显示碰撞几何" checked={showCollision} onChange={onShowCollision}/><Check label="关节高级信息" checked={jointAdvanced} onChange={onJointAdvanced}/><label className="mt-3 block text-xs text-text-tertiary"><span className="mb-1 flex justify-between"><span>外力强度</span><output>{forceScale.toFixed(0)} N</output></span><input aria-label="设置外力强度" type="range" min={5} max={200} value={forceScale} onChange={event=>onForceScale(Number(event.target.value))} className="control-slider"/></label></section></div></Dialog>;}
function Check({label,checked,onChange}:{label:string;checked:boolean;onChange:(value:boolean)=>void}){return <label className="mt-2 flex items-center justify-between text-xs text-text-tertiary"><span>{label}</span><input type="checkbox" aria-label={label} checked={checked} onChange={event=>onChange(event.target.checked)} className="accent-accent focus-visible:ring-2 focus-visible:ring-accent/30"/></label>;}
import { Dialog, PropertyRow, Select } from '../../components/ui';
export function SettingsDialog({
open,
onClose,
theme,
angleUnit,
showCollision,
jointAdvanced,
forceScale,
onTheme,
onAngleUnit,
onShowCollision,
onJointAdvanced,
onForceScale,
}: {
open: boolean;
onClose: () => void;
theme: 'light' | 'dark';
angleUnit: 'rad' | 'deg';
showCollision: boolean;
jointAdvanced: boolean;
forceScale: number;
onTheme: (value: 'light' | 'dark') => void;
onAngleUnit: (value: 'rad' | 'deg') => void;
onShowCollision: (value: boolean) => void;
onJointAdvanced: (value: boolean) => void;
onForceScale: (value: number) => void;
}) {
return (
<Dialog open={open} onClose={onClose} title="工作台设置">
<div className="space-y-4">
<section>
<h3 className="mb-2 text-xs font-semibold">外观</h3>
<PropertyRow
label="主题"
value={
<Select
aria-label="设置主题"
value={theme}
onChange={(event) => onTheme(event.target.value as 'light' | 'dark')}
>
<option value="dark">黑夜</option>
<option value="light">白天</option>
</Select>
}
/>
</section>
<section>
<h3 className="mb-2 text-xs font-semibold">模型与控制</h3>
<PropertyRow
label="角度单位"
value={
<Select
aria-label="设置角度单位"
value={angleUnit}
onChange={(event) => onAngleUnit(event.target.value as 'rad' | 'deg')}
>
<option value="rad">弧度</option>
<option value="deg">角度</option>
</Select>
}
/>
<Check label="显示碰撞几何" checked={showCollision} onChange={onShowCollision} />
<Check label="关节高级信息" checked={jointAdvanced} onChange={onJointAdvanced} />
<label className="mt-3 block text-xs text-text-tertiary">
<span className="mb-1 flex justify-between">
<span>外力强度</span>
<output>{forceScale.toFixed(0)} N</output>
</span>
<input
aria-label="设置外力强度"
type="range"
min={5}
max={200}
value={forceScale}
onChange={(event) => onForceScale(Number(event.target.value))}
className="control-slider"
/>
</label>
</section>
</div>
</Dialog>
);
}
function Check({
label,
checked,
onChange,
}: {
label: string;
checked: boolean;
onChange: (value: boolean) => void;
}) {
return (
<label className="mt-2 flex items-center justify-between text-xs text-text-tertiary">
<span>{label}</span>
<input
type="checkbox"
aria-label={label}
checked={checked}
onChange={(event) => onChange(event.target.checked)}
className="accent-accent focus-visible:ring-2 focus-visible:ring-accent/30"
/>
</label>
);
}
@@ -1,3 +1,37 @@
import {Dialog,Kbd,Separator} from '../../components/ui';
const shortcuts=[['Space','播放 / 暂停'],['R','重置仿真'],['1','选择模式'],['2','关节拖动'],['3','外力施加']];
export function ShortcutHelpDialog({open,onClose}:{open:boolean;onClose:()=>void}){return <Dialog open={open} onClose={onClose} title="快捷键与视口操作"><section><h3 className="mb-2 text-xs font-semibold text-text-primary">键盘快捷键</h3><dl className="space-y-2">{shortcuts.map(([key,label])=><div key={key} className="flex items-center justify-between text-xs"><dt className="text-text-secondary">{label}</dt><dd><Kbd>{key}</Kbd></dd></div>)}</dl></section><Separator className="my-4"/><section><h3 className="mb-2 text-xs font-semibold text-text-primary">鼠标操作</h3><ul className="space-y-1.5 text-xs text-text-secondary"><li>左键拖动:旋转相机或执行当前交互工具</li><li>右键拖动:平移相机</li><li>滚轮:缩放视口</li><li>选择物体后可在右侧“属性”中查看信息</li></ul></section></Dialog>;}
import { Dialog, Kbd, Separator } from '../../components/ui';
const shortcuts = [
['Space', '播放 / 暂停'],
['R', '重置仿真'],
['1', '选择模式'],
['2', '关节拖动'],
['3', '外力施加'],
];
export function ShortcutHelpDialog({ open, onClose }: { open: boolean; onClose: () => void }) {
return (
<Dialog open={open} onClose={onClose} title="快捷键与视口操作">
<section>
<h3 className="mb-2 text-xs font-semibold text-text-primary">键盘快捷键</h3>
<dl className="space-y-2">
{shortcuts.map(([key, label]) => (
<div key={key} className="flex items-center justify-between text-xs">
<dt className="text-text-secondary">{label}</dt>
<dd>
<Kbd>{key}</Kbd>
</dd>
</div>
))}
</dl>
</section>
<Separator className="my-4" />
<section>
<h3 className="mb-2 text-xs font-semibold text-text-primary">鼠标操作</h3>
<ul className="space-y-1.5 text-xs text-text-secondary">
<li>左键拖动:旋转相机或执行当前交互工具</li>
<li>右键拖动:平移相机</li>
<li>滚轮:缩放视口</li>
<li>选择物体后可在右侧“属性”中查看信息</li>
</ul>
</section>
</Dialog>
);
}
+765 -63
View File
@@ -1,67 +1,769 @@
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 {ActuatorInfo,ActuatorParameters,SimulationSnapshot} from '../../simulation/SimulationSession';
import type {UrdfBaseMode,UrdfLoadMode} from '../../simulation/PhysicsAdapter';
import type {ViewerSelection} from '../../viewer/MuJoCoViewer';
import type {ControllerCommand,ControllerStatus} from '../../controller/types';
import type {RLCommand,RLPolicyStatus} from '../../rl/types';
import {Badge,Button,CollapsibleSection,CopyButton,PropertyRow,ResizablePanel,Select,Tabs} from '../../components/ui';
import {TreeSearchField} from './TreeSearchField';
import {ProjectBreadcrumb} from './ProjectBreadcrumb';
import {PythonControllerPanel} from './PythonControllerPanel';
import {RLPolicyPanel} from './RLPolicyPanel';
import {LocalTrainingPanel} from './LocalTrainingPanel';
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 {
ActuatorInfo,
ActuatorParameters,
SimulationSnapshot,
} from '../../simulation/SimulationSession';
import type { UrdfBaseMode, UrdfLoadMode } from '../../simulation/PhysicsAdapter';
import type { ViewerSelection } from '../../viewer/MuJoCoViewer';
import type { ControllerCommand, ControllerStatus } from '../../controller/types';
import type { RLCommand, RLPolicyStatus } from '../../rl/types';
import {
Badge,
Button,
CollapsibleSection,
CopyButton,
PropertyRow,
ResizablePanel,
Select,
Tabs,
} from '../../components/ui';
import { TreeSearchField } from './TreeSearchField';
import { ProjectBreadcrumb } from './ProjectBreadcrumb';
import { PythonControllerPanel } from './PythonControllerPanel';
import { RLPolicyPanel } from './RLPolicyPanel';
import { LocalTrainingPanel } from './LocalTrainingPanel';
export function SidebarPanel({title,side,children,visible=true}:{title:string;side:'left'|'right';children:ReactNode;visible?:boolean}){return <ResizablePanel side={side} storageKey={`mujoco-${side}-sidebar-width`} visible={visible}><aside className={`flex h-full w-full min-w-0 flex-col overflow-hidden bg-panel ${side==='left'?'border-r':'border-l'} border-border`}><h2 className="flex h-10 shrink-0 items-center gap-2 border-b border-border bg-panel px-3 text-sm font-semibold text-text-primary"><Settings2 aria-hidden="true" className="h-4 w-4 text-accent"/>{title}</h2>{children}</aside></ResizablePanel>;}
export function ProjectSidebar({projectName,files,entries,selectedEntry,snapshot,loading,visible=true,onRemove,onSelectEntry,onJointHover}:{projectName?:string;files:ProjectTreeFile[];entries:ModelEntry[];selectedEntry?:string;snapshot?:SimulationSnapshot;loading:boolean;visible?:boolean;onRemove:()=>void;onSelectEntry:(path:string)=>void;onJointHover:(jointId:number|null)=>void}){const [tab,setTab]=useState<'project'|'structure'>('project'),[fileQuery,setFileQuery]=useState(''),[structureQuery,setStructureQuery]=useState('');const fileMatches=countProjectSearchResults(files,fileQuery),structureMatches=snapshot?countModelStructureSearchResults(snapshot.bodies,snapshot.joints,structureQuery):0;return <SidebarPanel title="工程资源" side="left" visible={visible}>{projectName?<><div className="flex shrink-0 items-center gap-2 border-b border-border px-3 py-2.5"><div className="min-w-0 flex-1"><div className="truncate text-sm font-medium text-accent" title={projectName}>{projectName}</div><div className="mt-0.5 text-[10px] text-text-tertiary">{files.length} 个文件</div></div><Button variant="danger" onClick={onRemove} disabled={loading}>移除</Button></div><ProjectBreadcrumb projectName={projectName} entries={entries} selectedEntry={selectedEntry} loading={loading} onSelect={onSelectEntry}/><Tabs label="工程侧栏" value={tab} onValueChange={setTab} items={[{value:'project',label:'工程',icon:<FolderTree className="h-3.5 w-3.5"/>,content:<><TreeSearchField value={fileQuery} onChange={setFileQuery} resultCount={fileMatches} label="搜索工程文件" placeholder="搜索文件或目录…"/><div className="px-2 pb-3"><ProjectTree key={projectName} files={files} entries={entries} selectedEntry={selectedEntry} query={fileQuery}/></div></>},{value:'structure',label:'模型结构',icon:<Box className="h-3.5 w-3.5"/>,disabled:!snapshot,content:snapshot?<><TreeSearchField value={structureQuery} onChange={setStructureQuery} resultCount={structureMatches} label="搜索模型结构" placeholder="搜索 Body 或关节…"/><div className="px-2 pb-3"><ModelStructureTree bodies={snapshot.bodies} joints={snapshot.joints} onJointHover={onJointHover} query={structureQuery}/></div></>:<p className="p-4 text-center text-xs text-text-tertiary">加载模型后显示结构</p>}]}/></>:<div className="p-4 text-center text-sm text-text-tertiary">导入模型后显示工程资源</div>}</SidebarPanel>;}
interface ModelControlsProps{
snapshot?:SimulationSnapshot;selection:ViewerSelection|null;selectedFormat?:ModelEntry['format'];loading:boolean;visible?:boolean;
urdfMode:UrdfLoadMode;baseMode:UrdfBaseMode;showCollision:boolean;ignoreJointLimits:boolean;jointAdvanced:boolean;angleUnit:'rad'|'deg';forceScale:number;
controllerPaths:string[];selectedControllerPath?:string;controllerStatus?:ControllerStatus;
policyPaths:string[];selectedPolicyPath?:string;policyStatus?:RLPolicyStatus;
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;onActuatorParameters:(id:number,parameters:ActuatorParameters)=>void;onJoint:(id:number,value:number)=>void;onForceScale:(value:number)=>void;
onSelectControllerPath:(path:string)=>void;onLoadControllerPath:(path:string)=>void;onImportController:(file:File)=>void;onToggleController:(enabled:boolean)=>void;onControllerCommand:(command:ControllerCommand)=>void;onRemoveController:()=>void;
onSelectPolicyPath:(path:string)=>void;onLoadPolicyPath:(path:string)=>void;onImportPolicy:(file:File)=>void;onTogglePolicy:(enabled:boolean)=>void;onPolicyCommand:(command:RLCommand)=>void;onRemovePolicy:()=>void;
}
export function ModelControlsSidebar(props:ModelControlsProps){const [tab,setTab]=useState<'properties'|'controls'>('properties'),s=props.snapshot;if(!s)return <SidebarPanel title="模型与控制" side="right" visible={props.visible}><div className="p-4 text-sm text-text-tertiary">导入模型后显示属性</div></SidebarPanel>;
const properties=<><CollapsibleSection title="模型信息" defaultOpen badge={<Badge>{s.model.nbody} Body</Badge>}><div><PropertyRow label="Body" value={s.model.nbody}/><PropertyRow label="Joint" value={s.model.njnt}/><PropertyRow label="Geom" value={s.model.ngeom}/><PropertyRow label="Actuator" value={s.model.nactuator}/><PropertyRow label="qpos / qvel" value={`${s.model.nq} / ${s.model.nv}`}/></div></CollapsibleSection>
{props.selectedFormat==='urdf'&&<CollapsibleSection title="URDF 处理方式" defaultOpen={false}><Select aria-label="URDF 处理方式" className="w-full" value={props.urdfMode} disabled={props.loading} onChange={event=>props.onUrdfMode(event.target.value as UrdfLoadMode)}><option value="mjcf">转换为 MJCF(推荐)</option><option value="native">MuJoCo 原生 URDF</option></Select><label className="mt-3 block text-xs text-text-secondary"><span className="mb-1 block">基座类型</span><Select aria-label="URDF 基座类型" className="w-full" value={props.baseMode} disabled={props.loading||props.urdfMode==='native'} onChange={event=>props.onBaseMode(event.target.value as UrdfBaseMode)}><option value="floating">浮动基座(Free Joint)</option><option value="fixed">固定基座(连接世界)</option></Select></label><p className="mt-2 text-xs text-text-tertiary">MJCF 模式保留 visual mesh、添加物理地面,并将模型最低点对齐到 z=0。</p><Check label="显示碰撞几何" checked={props.showCollision} onChange={props.onShowCollision}/></CollapsibleSection>}
<CollapsibleSection title="当前选择" defaultOpen>{props.selection?<div className="text-xs"><PropertyRow label="Body" value={props.selection.bodyName} action={<CopyButton value={props.selection.bodyName} label="复制 Body 名称"/>}/><PropertyRow label="标识" value={`${props.selection.bodyId} / ${props.selection.geomId} / ${props.selection.geomType}`} action={<CopyButton value={`body ${props.selection.bodyId}, geom ${props.selection.geomId}, type ${props.selection.geomType}`} label="复制标识"/>}/><PropertyRow label="位置" value={props.selection.position.map(value=>value.toFixed(3)).join(', ')} action={<CopyButton value={props.selection.position.join(', ')} label="复制位置"/>}/></div>:<p className="flex items-center gap-2 text-xs text-text-tertiary"><Info className="h-3.5 w-3.5"/>在视口中单击物体</p>}</CollapsibleSection></>;
const controls=<><CollapsibleSection title="ONNX 强化学习策略" defaultOpen badge={s.rlPolicy?<Badge>{s.rlPolicy.enabled?'推理':'停止'}</Badge>:undefined}><RLPolicyPanel paths={props.policyPaths} selectedPath={props.selectedPolicyPath} status={props.policyStatus??s.rlPolicy} loading={props.loading} onSelectPath={props.onSelectPolicyPath} onLoadPath={props.onLoadPolicyPath} onImport={props.onImportPolicy} onToggle={props.onTogglePolicy} onCommand={props.onPolicyCommand} onRemove={props.onRemovePolicy}/></CollapsibleSection><CollapsibleSection title="本地强化学习训练" defaultOpen={false}><LocalTrainingPanel onPolicyReady={props.onImportPolicy}/></CollapsibleSection><CollapsibleSection title="Python 控制器" defaultOpen badge={s.controller?<Badge>{s.controller.enabled?'运行':'停止'}</Badge>:undefined}><PythonControllerPanel paths={props.controllerPaths} selectedPath={props.selectedControllerPath} status={props.controllerStatus??s.controller} loading={props.loading} onSelectPath={props.onSelectControllerPath} onLoadPath={props.onLoadControllerPath} onImport={props.onImportController} onToggle={props.onToggleController} onCommand={props.onControllerCommand} onRemove={props.onRemoveController}/></CollapsibleSection><CollapsibleSection title="Actuator" defaultOpen={false} badge={<Badge>{s.actuators.length}</Badge>}>{s.actuators.length?s.actuators.map(actuator=><ActuatorControl key={actuator.id} actuator={actuator} onControl={value=>props.onActuator(actuator.id,value)} onParameters={parameters=>props.onActuatorParameters(actuator.id,parameters)}/>):<p className="text-xs text-text-tertiary">模型没有驱动器</p>}</CollapsibleSection>
<CollapsibleSection title="关节" defaultOpen badge={<Badge>{s.joints.length}</Badge>}><div className="mb-4 grid grid-cols-2 gap-2"><Button onClick={props.onResetJoints}>重置关节</Button><Button variant={props.ignoreJointLimits?'primary':'secondary'} aria-pressed={props.ignoreJointLimits} onClick={props.onToggleJointLimits}>忽略关节限位</Button><Button variant={props.jointAdvanced?'primary':'secondary'} aria-pressed={props.jointAdvanced} onClick={props.onToggleAdvanced}>高级</Button><Button variant={props.angleUnit==='deg'?'primary':'secondary'} aria-pressed={props.angleUnit==='deg'} onClick={props.onToggleAngleUnit}>{props.angleUnit==='rad'?'rad 弧度制':'° 角度制'}</Button></div>{s.joints.map(joint=>{const scale=joint.type===3&&props.angleUnit==='deg'?180/Math.PI:1,unit=joint.type===3?(props.angleUnit==='deg'?'°':' rad'):joint.type===2?' m':'';return <ControlSlider key={joint.id} label={`${joint.name}${joint.editable?'':'(只读)'}`} value={joint.value*scale} min={joint.min*scale} max={joint.max*scale} unit={unit} advanced={props.jointAdvanced} limited={joint.limited} limitsIgnored={joint.limitsIgnored} limitMin={joint.limitMin*scale} limitMax={joint.limitMax*scale} disabled={!joint.editable} onChange={value=>props.onJoint(joint.id,value/scale)}/>;})}</CollapsibleSection>
<CollapsibleSection title="外力强度" defaultOpen={false}><ControlSlider label={`${props.forceScale.toFixed(0)} N/屏幕单位`} value={props.forceScale} min={5} max={200} onChange={props.onForceScale}/><p className="text-xs text-text-tertiary">选择“外力施加”,在动态物体上按住拖动,松开即清零。</p></CollapsibleSection></>;
return <SidebarPanel title="模型与控制" side="right" visible={props.visible}><Tabs label="模型控制侧栏" value={tab} onValueChange={setTab} items={[{value:'properties',label:'属性',icon:<Info className="h-3.5 w-3.5"/>,content:properties},{value:'controls',label:'控制',icon:<SlidersHorizontal className="h-3.5 w-3.5"/>,content:controls}]}/></SidebarPanel>;
export function SidebarPanel({
title,
side,
children,
visible = true,
}: {
title: string;
side: 'left' | 'right';
children: ReactNode;
visible?: boolean;
}) {
return (
<ResizablePanel side={side} storageKey={`mujoco-${side}-sidebar-width`} visible={visible}>
<aside
className={`flex h-full w-full min-w-0 flex-col overflow-hidden bg-panel ${side === 'left' ? 'border-r' : 'border-l'} border-border`}
>
<h2 className="flex h-10 shrink-0 items-center gap-2 border-b border-border bg-panel px-3 text-sm font-semibold text-text-primary">
<Settings2 aria-hidden="true" className="h-4 w-4 text-accent" />
{title}
</h2>
{children}
</aside>
</ResizablePanel>
);
}
export function ActuatorControl({actuator,onControl,onParameters}:{actuator:ActuatorInfo;onControl:(value:number)=>void;onParameters:(parameters:ActuatorParameters)=>void}){
const isMotor=actuator.kind==='motor',isPosition=actuator.kind==='position',editable=isMotor||isPosition,baseTargetScale=isPosition&&actuator.jointType===3?180/Math.PI:1,targetScale=isPosition&&Math.abs(actuator.gear)>1e-9?baseTargetScale/actuator.gear:baseTargetScale;
const clampForce=(value:number)=>actuator.forceLimited?Math.min(actuator.forceMax,Math.max(actuator.forceMin,value)):value,physicalScale=actuator.gear*actuator.gain;
const forceA=clampForce(actuator.min*actuator.gain)*actuator.gear,forceB=clampForce(actuator.max*actuator.gain)*actuator.gear;
const targetA=actuator.min*targetScale,targetB=actuator.max*targetScale,outputMin=isMotor?Math.min(forceA,forceB):Math.min(targetA,targetB),outputMax=isMotor?Math.max(forceA,forceB):Math.max(targetA,targetB);
const outputValue=isMotor?clampForce(actuator.value*actuator.gain)*actuator.gear:actuator.value*targetScale,outputDisabled=(isMotor&&Math.abs(physicalScale)<=1e-9)||(isPosition&&Math.abs(actuator.gear)<=1e-9);
const outputLabel=isMotor?(actuator.jointType===3?'输出力矩':'输出力'):isPosition?(actuator.jointType===3?'目标角度':'目标位置'):'控制输入';
const forceUnit=actuator.jointType===3?'N·m':actuator.jointType===2?'N':'',jointForceA=actuator.forceMin*actuator.gear,jointForceB=actuator.forceMax*actuator.gear,jointForceMin=Math.min(jointForceA,jointForceB),jointForceMax=Math.max(jointForceA,jointForceB);
const update=(patch:Partial<ActuatorParameters>)=>onParameters({...actuator,...patch}),controlLabel=isPosition?(actuator.jointType===3?'角度':'位置'):'控制',gearSquared=actuator.gear*actuator.gear;
return <div className="mb-3 rounded-lg border border-border bg-surface p-2.5">
<div className="mb-2 flex min-w-0 items-start justify-between gap-2"><div className="min-w-0"><div className="truncate text-xs font-medium text-text-primary" title={actuator.name}>{actuator.name}</div><div className="mt-0.5 truncate text-[10px] text-text-tertiary">{actuator.jointName?`关节:${actuator.jointName}`:'未关联标量关节'}</div></div><Badge>{actuator.unit||'u'}</Badge></div>
{actuator.controlCount===1?<ControlSlider label={outputLabel} value={outputValue} min={outputMin} max={outputMax} disabled={outputDisabled} unit={actuator.unit?` ${actuator.unit}`:''} onChange={value=>{if(outputDisabled)return;onControl(isMotor?value/physicalScale:value/targetScale);}}/>:<p className="mb-2 text-[10px] leading-4 text-text-tertiary">该驱动器包含 {actuator.controlCount} 个控制分量,请在 MJCF 源码或专用控制器中设置。</p>}
{actuator.controlCount===1&&!actuator.ctrlLimited&&<div className="mb-2"><ParameterInput label={`${controlLabel}输入(不限幅)`} value={actuator.value*targetScale} onCommit={value=>onControl(value/targetScale)}/></div>}
{editable?<details className="group border-t border-border pt-2"><summary className="cursor-pointer select-none text-xs font-medium text-text-secondary hover:text-text-primary">常用参数</summary>
<div className="mt-2 grid grid-cols-2 gap-2">{isPosition?<><ParameterInput label={`位置增益 kp(${forceUnit}/${actuator.jointType===3?'rad':'m'})`} value={actuator.kp*gearSquared} disabled={gearSquared<=1e-18} onCommit={kp=>update({kp:kp/gearSquared})}/><ParameterInput label={`速度增益 kv(${forceUnit}·s/${actuator.jointType===3?'rad':'m'})`} value={actuator.kv*gearSquared} disabled={gearSquared<=1e-18} onCommit={kv=>update({kv:kv/gearSquared})}/></>:<><ParameterInput label={`kp(MJCF stiffness,${forceUnit}/${actuator.jointType===3?'rad':'m'})`} value={actuator.kp} onCommit={kp=>update({kp})}/><ParameterInput label={`kv(MJCF damping,${forceUnit}·s/${actuator.jointType===3?'rad':'m'})`} value={actuator.kv} onCommit={kv=>update({kv})}/></>}</div>
<ParameterToggle label={`限制输出${actuator.jointType===3?'力矩':'力'}${forceUnit?`(${forceUnit})`:''}`} checked={actuator.forceLimited} onChange={forceLimited=>update({forceLimited})}/>
<div className="mt-2 grid grid-cols-2 gap-2"><ParameterInput label="输出下限" value={jointForceMin} disabled={!actuator.forceLimited||Math.abs(actuator.gear)<=1e-9} onCommit={value=>update(actuator.gear>=0?{forceMin:value/actuator.gear}:{forceMax:value/actuator.gear})}/><ParameterInput label="输出上限" value={jointForceMax} disabled={!actuator.forceLimited||Math.abs(actuator.gear)<=1e-9} onCommit={value=>update(actuator.gear>=0?{forceMax:value/actuator.gear}:{forceMin:value/actuator.gear})}/></div>
<p className="mt-2 text-[10px] leading-4 text-text-tertiary">{isPosition?'position 伺服使用 kp 跟踪目标位置,kv 提供速度阻尼。':'motor 保持力/力矩控制且控制输入不限幅。MJCF 的 motor 没有 kp/kv 属性;这里的 kp、kv 会分别保存为对应 joint 的 stiffness、damping。'} 参数修改会立即作用于当前模型,并可随 MJCF 导出。</p>
</details>:<p className="border-t border-border pt-2 text-[10px] leading-4 text-text-tertiary">该驱动器不是可直接编辑的 motor/position 类型,控制值按模型原始单位显示;请在 MJCF 源码中修改专用参数。</p>}
</div>;
export function ProjectSidebar({
projectName,
files,
entries,
selectedEntry,
snapshot,
loading,
visible = true,
onRemove,
onSelectEntry,
onJointHover,
}: {
projectName?: string;
files: ProjectTreeFile[];
entries: ModelEntry[];
selectedEntry?: string;
snapshot?: SimulationSnapshot;
loading: boolean;
visible?: boolean;
onRemove: () => void;
onSelectEntry: (path: string) => void;
onJointHover: (jointId: number | null) => void;
}) {
const [tab, setTab] = useState<'project' | 'structure'>('project'),
[fileQuery, setFileQuery] = useState(''),
[structureQuery, setStructureQuery] = useState('');
const fileMatches = countProjectSearchResults(files, fileQuery),
structureMatches = snapshot
? countModelStructureSearchResults(snapshot.bodies, snapshot.joints, structureQuery)
: 0;
return (
<SidebarPanel title="工程资源" side="left" visible={visible}>
{projectName ? (
<>
<div className="flex shrink-0 items-center gap-2 border-b border-border px-3 py-2.5">
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-medium text-accent" title={projectName}>
{projectName}
</div>
<div className="mt-0.5 text-[10px] text-text-tertiary">{files.length} 个文件</div>
</div>
<Button variant="danger" onClick={onRemove} disabled={loading}>
移除
</Button>
</div>
<ProjectBreadcrumb
projectName={projectName}
entries={entries}
selectedEntry={selectedEntry}
loading={loading}
onSelect={onSelectEntry}
/>
<Tabs
label="工程侧栏"
value={tab}
onValueChange={setTab}
items={[
{
value: 'project',
label: '工程',
icon: <FolderTree className="h-3.5 w-3.5" />,
content: (
<>
<TreeSearchField
value={fileQuery}
onChange={setFileQuery}
resultCount={fileMatches}
label="搜索工程文件"
placeholder="搜索文件或目录…"
/>
<div className="px-2 pb-3">
<ProjectTree
key={projectName}
files={files}
entries={entries}
selectedEntry={selectedEntry}
query={fileQuery}
/>
</div>
</>
),
},
{
value: 'structure',
label: '模型结构',
icon: <Box className="h-3.5 w-3.5" />,
disabled: !snapshot,
content: snapshot ? (
<>
<TreeSearchField
value={structureQuery}
onChange={setStructureQuery}
resultCount={structureMatches}
label="搜索模型结构"
placeholder="搜索 Body 或关节…"
/>
<div className="px-2 pb-3">
<ModelStructureTree
bodies={snapshot.bodies}
joints={snapshot.joints}
onJointHover={onJointHover}
query={structureQuery}
/>
</div>
</>
) : (
<p className="p-4 text-center text-xs text-text-tertiary">加载模型后显示结构</p>
),
},
]}
/>
</>
) : (
<div className="p-4 text-center text-sm text-text-tertiary">导入模型后显示工程资源</div>
)}
</SidebarPanel>
);
}
interface ModelControlsProps {
snapshot?: SimulationSnapshot;
selection: ViewerSelection | null;
selectedFormat?: ModelEntry['format'];
loading: boolean;
visible?: boolean;
urdfMode: UrdfLoadMode;
baseMode: UrdfBaseMode;
showCollision: boolean;
ignoreJointLimits: boolean;
jointAdvanced: boolean;
angleUnit: 'rad' | 'deg';
forceScale: number;
controllerPaths: string[];
selectedControllerPath?: string;
controllerStatus?: ControllerStatus;
policyPaths: string[];
selectedPolicyPath?: string;
policyStatus?: RLPolicyStatus;
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;
onActuatorParameters: (id: number, parameters: ActuatorParameters) => void;
onJoint: (id: number, value: number) => void;
onForceScale: (value: number) => void;
onSelectControllerPath: (path: string) => void;
onLoadControllerPath: (path: string) => void;
onImportController: (file: File) => void;
onToggleController: (enabled: boolean) => void;
onControllerCommand: (command: ControllerCommand) => void;
onRemoveController: () => void;
onSelectPolicyPath: (path: string) => void;
onLoadPolicyPath: (path: string) => void;
onImportPolicy: (file: File) => void;
onTogglePolicy: (enabled: boolean) => void;
onPolicyCommand: (command: RLCommand) => void;
onRemovePolicy: () => void;
}
export function ModelControlsSidebar(props: ModelControlsProps) {
const [tab, setTab] = useState<'properties' | 'controls'>('properties'),
s = props.snapshot;
if (!s)
return (
<SidebarPanel title="模型与控制" side="right" visible={props.visible}>
<div className="p-4 text-sm text-text-tertiary">导入模型后显示属性</div>
</SidebarPanel>
);
const properties = (
<>
<CollapsibleSection title="模型信息" defaultOpen badge={<Badge>{s.model.nbody} Body</Badge>}>
<div>
<PropertyRow label="Body" value={s.model.nbody} />
<PropertyRow label="Joint" value={s.model.njnt} />
<PropertyRow label="Geom" value={s.model.ngeom} />
<PropertyRow label="Actuator" value={s.model.nactuator} />
<PropertyRow label="qpos / qvel" value={`${s.model.nq} / ${s.model.nv}`} />
</div>
</CollapsibleSection>
{props.selectedFormat === 'urdf' && (
<CollapsibleSection title="URDF 处理方式" defaultOpen={false}>
<Select
aria-label="URDF 处理方式"
className="w-full"
value={props.urdfMode}
disabled={props.loading}
onChange={(event) => props.onUrdfMode(event.target.value as UrdfLoadMode)}
>
<option value="mjcf">转换为 MJCF(推荐)</option>
<option value="native">MuJoCo 原生 URDF</option>
</Select>
<label className="mt-3 block text-xs text-text-secondary">
<span className="mb-1 block">基座类型</span>
<Select
aria-label="URDF 基座类型"
className="w-full"
value={props.baseMode}
disabled={props.loading || props.urdfMode === 'native'}
onChange={(event) => props.onBaseMode(event.target.value as UrdfBaseMode)}
>
<option value="floating">浮动基座(Free Joint)</option>
<option value="fixed">固定基座(连接世界)</option>
</Select>
</label>
<p className="mt-2 text-xs text-text-tertiary">
MJCF 模式保留 visual mesh、添加物理地面,并将模型最低点对齐到 z=0。
</p>
<Check
label="显示碰撞几何"
checked={props.showCollision}
onChange={props.onShowCollision}
/>
</CollapsibleSection>
)}
<CollapsibleSection title="当前选择" defaultOpen>
{props.selection ? (
<div className="text-xs">
<PropertyRow
label="Body"
value={props.selection.bodyName}
action={<CopyButton value={props.selection.bodyName} label="复制 Body 名称" />}
/>
<PropertyRow
label="标识"
value={`${props.selection.bodyId} / ${props.selection.geomId} / ${props.selection.geomType}`}
action={
<CopyButton
value={`body ${props.selection.bodyId}, geom ${props.selection.geomId}, type ${props.selection.geomType}`}
label="复制标识"
/>
}
/>
<PropertyRow
label="位置"
value={props.selection.position.map((value) => value.toFixed(3)).join(', ')}
action={<CopyButton value={props.selection.position.join(', ')} label="复制位置" />}
/>
</div>
) : (
<p className="flex items-center gap-2 text-xs text-text-tertiary">
<Info className="h-3.5 w-3.5" />
在视口中单击物体
</p>
)}
</CollapsibleSection>
</>
);
const controls = (
<>
<CollapsibleSection
title="ONNX 强化学习策略"
defaultOpen
badge={s.rlPolicy ? <Badge>{s.rlPolicy.enabled ? '推理' : '停止'}</Badge> : undefined}
>
<RLPolicyPanel
paths={props.policyPaths}
selectedPath={props.selectedPolicyPath}
status={props.policyStatus ?? s.rlPolicy}
loading={props.loading}
onSelectPath={props.onSelectPolicyPath}
onLoadPath={props.onLoadPolicyPath}
onImport={props.onImportPolicy}
onToggle={props.onTogglePolicy}
onCommand={props.onPolicyCommand}
onRemove={props.onRemovePolicy}
/>
</CollapsibleSection>
<CollapsibleSection title="本地强化学习训练" defaultOpen={false}>
<LocalTrainingPanel onPolicyReady={props.onImportPolicy} />
</CollapsibleSection>
<CollapsibleSection
title="Python 控制器"
defaultOpen
badge={s.controller ? <Badge>{s.controller.enabled ? '运行' : '停止'}</Badge> : undefined}
>
<PythonControllerPanel
paths={props.controllerPaths}
selectedPath={props.selectedControllerPath}
status={props.controllerStatus ?? s.controller}
loading={props.loading}
onSelectPath={props.onSelectControllerPath}
onLoadPath={props.onLoadControllerPath}
onImport={props.onImportController}
onToggle={props.onToggleController}
onCommand={props.onControllerCommand}
onRemove={props.onRemoveController}
/>
</CollapsibleSection>
<CollapsibleSection
title="Actuator"
defaultOpen={false}
badge={<Badge>{s.actuators.length}</Badge>}
>
{s.actuators.length ? (
s.actuators.map((actuator) => (
<ActuatorControl
key={actuator.id}
actuator={actuator}
onControl={(value) => props.onActuator(actuator.id, value)}
onParameters={(parameters) => props.onActuatorParameters(actuator.id, parameters)}
/>
))
) : (
<p className="text-xs text-text-tertiary">模型没有驱动器</p>
)}
</CollapsibleSection>
<CollapsibleSection title="关节" defaultOpen badge={<Badge>{s.joints.length}</Badge>}>
<div className="mb-4 grid grid-cols-2 gap-2">
<Button onClick={props.onResetJoints}>重置关节</Button>
<Button
variant={props.ignoreJointLimits ? 'primary' : 'secondary'}
aria-pressed={props.ignoreJointLimits}
onClick={props.onToggleJointLimits}
>
忽略关节限位
</Button>
<Button
variant={props.jointAdvanced ? 'primary' : 'secondary'}
aria-pressed={props.jointAdvanced}
onClick={props.onToggleAdvanced}
>
高级
</Button>
<Button
variant={props.angleUnit === 'deg' ? 'primary' : 'secondary'}
aria-pressed={props.angleUnit === 'deg'}
onClick={props.onToggleAngleUnit}
>
{props.angleUnit === 'rad' ? 'rad 弧度制' : '° 角度制'}
</Button>
</div>
{s.joints.map((joint) => {
const scale = joint.type === 3 && props.angleUnit === 'deg' ? 180 / Math.PI : 1,
unit =
joint.type === 3
? props.angleUnit === 'deg'
? '°'
: ' rad'
: joint.type === 2
? ' m'
: '';
return (
<ControlSlider
key={joint.id}
label={`${joint.name}${joint.editable ? '' : '(只读)'}`}
value={joint.value * scale}
min={joint.min * scale}
max={joint.max * scale}
unit={unit}
advanced={props.jointAdvanced}
limited={joint.limited}
limitsIgnored={joint.limitsIgnored}
limitMin={joint.limitMin * scale}
limitMax={joint.limitMax * scale}
disabled={!joint.editable}
onChange={(value) => props.onJoint(joint.id, value / scale)}
/>
);
})}
</CollapsibleSection>
<CollapsibleSection title="外力强度" defaultOpen={false}>
<ControlSlider
label={`${props.forceScale.toFixed(0)} N/屏幕单位`}
value={props.forceScale}
min={5}
max={200}
onChange={props.onForceScale}
/>
<p className="text-xs text-text-tertiary">
选择“外力施加”,在动态物体上按住拖动,松开即清零。
</p>
</CollapsibleSection>
</>
);
return (
<SidebarPanel title="模型与控制" side="right" visible={props.visible}>
<Tabs
label="模型控制侧栏"
value={tab}
onValueChange={setTab}
items={[
{
value: 'properties',
label: '属性',
icon: <Info className="h-3.5 w-3.5" />,
content: properties,
},
{
value: 'controls',
label: '控制',
icon: <SlidersHorizontal className="h-3.5 w-3.5" />,
content: controls,
},
]}
/>
</SidebarPanel>
);
}
export function ActuatorControl({
actuator,
onControl,
onParameters,
}: {
actuator: ActuatorInfo;
onControl: (value: number) => void;
onParameters: (parameters: ActuatorParameters) => void;
}) {
const isMotor = actuator.kind === 'motor',
isPosition = actuator.kind === 'position',
editable = isMotor || isPosition,
baseTargetScale = isPosition && actuator.jointType === 3 ? 180 / Math.PI : 1,
targetScale =
isPosition && Math.abs(actuator.gear) > 1e-9
? baseTargetScale / actuator.gear
: baseTargetScale;
const clampForce = (value: number) =>
actuator.forceLimited
? Math.min(actuator.forceMax, Math.max(actuator.forceMin, value))
: value,
physicalScale = actuator.gear * actuator.gain;
const forceA = clampForce(actuator.min * actuator.gain) * actuator.gear,
forceB = clampForce(actuator.max * actuator.gain) * actuator.gear;
const targetA = actuator.min * targetScale,
targetB = actuator.max * targetScale,
outputMin = isMotor ? Math.min(forceA, forceB) : Math.min(targetA, targetB),
outputMax = isMotor ? Math.max(forceA, forceB) : Math.max(targetA, targetB);
const outputValue = isMotor
? clampForce(actuator.value * actuator.gain) * actuator.gear
: actuator.value * targetScale,
outputDisabled =
(isMotor && Math.abs(physicalScale) <= 1e-9) ||
(isPosition && Math.abs(actuator.gear) <= 1e-9);
const outputLabel = isMotor
? actuator.jointType === 3
? '输出力矩'
: '输出力'
: isPosition
? actuator.jointType === 3
? '目标角度'
: '目标位置'
: '控制输入';
const forceUnit = actuator.jointType === 3 ? 'N·m' : actuator.jointType === 2 ? 'N' : '',
jointForceA = actuator.forceMin * actuator.gear,
jointForceB = actuator.forceMax * actuator.gear,
jointForceMin = Math.min(jointForceA, jointForceB),
jointForceMax = Math.max(jointForceA, jointForceB);
const update = (patch: Partial<ActuatorParameters>) => onParameters({ ...actuator, ...patch }),
controlLabel = isPosition ? (actuator.jointType === 3 ? '角度' : '位置') : '控制',
gearSquared = actuator.gear * actuator.gear;
return (
<div className="mb-3 rounded-lg border border-border bg-surface p-2.5">
<div className="mb-2 flex min-w-0 items-start justify-between gap-2">
<div className="min-w-0">
<div className="truncate text-xs font-medium text-text-primary" title={actuator.name}>
{actuator.name}
</div>
<div className="mt-0.5 truncate text-[10px] text-text-tertiary">
{actuator.jointName ? `关节:${actuator.jointName}` : '未关联标量关节'}
</div>
</div>
<Badge>{actuator.unit || 'u'}</Badge>
</div>
{actuator.controlCount === 1 ? (
<ControlSlider
label={outputLabel}
value={outputValue}
min={outputMin}
max={outputMax}
disabled={outputDisabled}
unit={actuator.unit ? ` ${actuator.unit}` : ''}
onChange={(value) => {
if (outputDisabled) return;
onControl(isMotor ? value / physicalScale : value / targetScale);
}}
/>
) : (
<p className="mb-2 text-[10px] leading-4 text-text-tertiary">
该驱动器包含 {actuator.controlCount} 个控制分量,请在 MJCF 源码或专用控制器中设置。
</p>
)}
{actuator.controlCount === 1 && !actuator.ctrlLimited && (
<div className="mb-2">
<ParameterInput
label={`${controlLabel}输入(不限幅)`}
value={actuator.value * targetScale}
onCommit={(value) => onControl(value / targetScale)}
/>
</div>
)}
{editable ? (
<details className="group border-t border-border pt-2">
<summary className="cursor-pointer select-none text-xs font-medium text-text-secondary hover:text-text-primary">
常用参数
</summary>
<div className="mt-2 grid grid-cols-2 gap-2">
{isPosition ? (
<>
<ParameterInput
label={`位置增益 kp(${forceUnit}/${actuator.jointType === 3 ? 'rad' : 'm'})`}
value={actuator.kp * gearSquared}
disabled={gearSquared <= 1e-18}
onCommit={(kp) => update({ kp: kp / gearSquared })}
/>
<ParameterInput
label={`速度增益 kv(${forceUnit}·s/${actuator.jointType === 3 ? 'rad' : 'm'})`}
value={actuator.kv * gearSquared}
disabled={gearSquared <= 1e-18}
onCommit={(kv) => update({ kv: kv / gearSquared })}
/>
</>
) : (
<>
<ParameterInput
label={`kp(MJCF stiffness,${forceUnit}/${actuator.jointType === 3 ? 'rad' : 'm'})`}
value={actuator.kp}
onCommit={(kp) => update({ kp })}
/>
<ParameterInput
label={`kv(MJCF damping,${forceUnit}·s/${actuator.jointType === 3 ? 'rad' : 'm'})`}
value={actuator.kv}
onCommit={(kv) => update({ kv })}
/>
</>
)}
</div>
<ParameterToggle
label={`限制输出${actuator.jointType === 3 ? '力矩' : '力'}${forceUnit ? `(${forceUnit})` : ''}`}
checked={actuator.forceLimited}
onChange={(forceLimited) => update({ forceLimited })}
/>
<div className="mt-2 grid grid-cols-2 gap-2">
<ParameterInput
label="输出下限"
value={jointForceMin}
disabled={!actuator.forceLimited || Math.abs(actuator.gear) <= 1e-9}
onCommit={(value) =>
update(
actuator.gear >= 0
? { forceMin: value / actuator.gear }
: { forceMax: value / actuator.gear },
)
}
/>
<ParameterInput
label="输出上限"
value={jointForceMax}
disabled={!actuator.forceLimited || Math.abs(actuator.gear) <= 1e-9}
onCommit={(value) =>
update(
actuator.gear >= 0
? { forceMax: value / actuator.gear }
: { forceMin: value / actuator.gear },
)
}
/>
</div>
<p className="mt-2 text-[10px] leading-4 text-text-tertiary">
{isPosition
? 'position 伺服使用 kp 跟踪目标位置,kv 提供速度阻尼。'
: 'motor 保持力/力矩控制且控制输入不限幅。MJCF 的 motor 没有 kp/kv 属性;这里的 kp、kv 会分别保存为对应 joint 的 stiffness、damping。'}{' '}
参数修改会立即作用于当前模型,并可随 MJCF 导出。
</p>
</details>
) : (
<p className="border-t border-border pt-2 text-[10px] leading-4 text-text-tertiary">
该驱动器不是可直接编辑的 motor/position 类型,控制值按模型原始单位显示;请在 MJCF
源码中修改专用参数。
</p>
)}
</div>
);
}
function ParameterInput({
label,
value,
onCommit,
disabled = false,
}: {
label: string;
value: number;
onCommit: (value: number) => void;
disabled?: boolean;
}) {
return (
<label className="block text-[10px] text-text-tertiary">
<span className="mb-1 block truncate">{label}</span>
<input
key={value}
type="number"
step="any"
defaultValue={Number.isFinite(value) ? value : 0}
disabled={disabled}
className="field h-7 w-full px-2 text-xs text-text-primary disabled:opacity-40"
onBlur={(event) => {
const next = Number(event.currentTarget.value);
if (Number.isFinite(next) && next !== value) onCommit(next);
else event.currentTarget.value = String(value);
}}
onKeyDown={(event) => {
if (event.key === 'Enter') event.currentTarget.blur();
}}
/>
</label>
);
}
function ParameterToggle({
label,
checked,
onChange,
}: {
label: string;
checked: boolean;
onChange: (value: boolean) => void;
}) {
return (
<label className="mt-2 flex items-center gap-2 text-[11px] text-text-secondary">
<input
type="checkbox"
className="accent-accent"
checked={checked}
onChange={(event) => onChange(event.target.checked)}
/>
{label}
</label>
);
}
function Check({
label,
checked,
onChange,
}: {
label: string;
checked: boolean;
onChange: (value: boolean) => void;
}) {
return (
<label className="mt-3 flex items-center gap-2 text-xs text-text-secondary">
<input
type="checkbox"
className="rounded accent-accent focus-visible:ring-2 focus-visible:ring-accent/40 focus-visible:ring-offset-1 focus-visible:ring-offset-panel"
checked={checked}
onChange={(event) => onChange(event.target.checked)}
/>
{label}
</label>
);
}
function ControlSlider({
label,
value,
min,
max,
onChange,
disabled = false,
unit = '',
advanced = false,
limited = false,
limitsIgnored = false,
limitMin = 0,
limitMax = 0,
}: {
label: string;
value: number;
min: number;
max: number;
onChange: (value: number) => void;
disabled?: boolean;
unit?: string;
advanced?: boolean;
limited?: boolean;
limitsIgnored?: boolean;
limitMin?: number;
limitMax?: number;
}) {
const sane = Number.isFinite(value) ? value : 0,
format = (number: number) => `${number.toFixed(3)}${unit}`;
return (
<label className="mb-3 block text-xs">
<span className="mb-1 flex justify-between gap-2">
<span className="truncate text-text-secondary">{label}</span>
<output className="technical-value text-text-primary">{format(sane)}</output>
</span>
<input
className="control-slider rounded focus-visible:ring-2 focus-visible:ring-accent/40 focus-visible:ring-offset-2 focus-visible:ring-offset-panel"
type="range"
disabled={disabled}
value={Math.min(max, Math.max(min, sane))}
min={min}
max={max}
step={(max - min) / 500 || 0.001}
onChange={(event) => onChange(Number(event.target.value))}
/>
{advanced && (
<span className="mt-1 flex justify-between text-[10px] text-text-tertiary">
<span>下限 {limited ? format(limitMin) : '无限制'}</span>
{limitsIgnored && limited && <span className="text-warning">已忽略</span>}
<span>上限 {limited ? format(limitMax) : '无限制'}</span>
</span>
)}
</label>
);
}
function ParameterInput({label,value,onCommit,disabled=false}:{label:string;value:number;onCommit:(value:number)=>void;disabled?:boolean}){return <label className="block text-[10px] text-text-tertiary"><span className="mb-1 block truncate">{label}</span><input key={value} type="number" step="any" defaultValue={Number.isFinite(value)?value:0} disabled={disabled} className="field h-7 w-full px-2 text-xs text-text-primary disabled:opacity-40" onBlur={event=>{const next=Number(event.currentTarget.value);if(Number.isFinite(next)&&next!==value)onCommit(next);else event.currentTarget.value=String(value);}} onKeyDown={event=>{if(event.key==='Enter')event.currentTarget.blur();}}/></label>;}
function ParameterToggle({label,checked,onChange}:{label:string;checked:boolean;onChange:(value:boolean)=>void}){return <label className="mt-2 flex items-center gap-2 text-[11px] text-text-secondary"><input type="checkbox" className="accent-accent" checked={checked} onChange={event=>onChange(event.target.checked)}/>{label}</label>;}
function Check({label,checked,onChange}:{label:string;checked:boolean;onChange:(value:boolean)=>void}){return <label className="mt-3 flex items-center gap-2 text-xs text-text-secondary"><input type="checkbox" className="rounded accent-accent focus-visible:ring-2 focus-visible:ring-accent/40 focus-visible:ring-offset-1 focus-visible:ring-offset-panel" checked={checked} onChange={event=>onChange(event.target.checked)}/>{label}</label>;}
function ControlSlider({label,value,min,max,onChange,disabled=false,unit='',advanced=false,limited=false,limitsIgnored=false,limitMin=0,limitMax=0}:{label:string;value:number;min:number;max:number;onChange:(value:number)=>void;disabled?:boolean;unit?:string;advanced?:boolean;limited?:boolean;limitsIgnored?:boolean;limitMin?:number;limitMax?:number}){const sane=Number.isFinite(value)?value:0,format=(number:number)=>`${number.toFixed(3)}${unit}`;return <label className="mb-3 block text-xs"><span className="mb-1 flex justify-between gap-2"><span className="truncate text-text-secondary">{label}</span><output className="technical-value text-text-primary">{format(sane)}</output></span><input className="control-slider rounded focus-visible:ring-2 focus-visible:ring-accent/40 focus-visible:ring-offset-2 focus-visible:ring-offset-panel" type="range" disabled={disabled} value={Math.min(max,Math.max(min,sane))} min={min} max={max} step={(max-min)/500||.001} onChange={event=>onChange(Number(event.target.value))}/>{advanced&&<span className="mt-1 flex justify-between text-[10px] text-text-tertiary"><span>下限 {limited?format(limitMin):'无限制'}</span>{limitsIgnored&&limited&&<span className="text-warning">已忽略</span>}<span>上限 {limited?format(limitMax):'无限制'}</span></span>}</label>;}
@@ -1,29 +1,248 @@
import './monacoSetup';
import Editor from '@monaco-editor/react';
import {useCallback,useEffect,useRef,useState,type PointerEvent as ReactPointerEvent} from 'react';
import {Check,Code2,Copy,Download,Maximize2,Minimize2,Save,X} from 'lucide-react';
import {downloadBytes} from '../../project/cachedFiles';
import {Button,ConfirmDialog,IconButton} from '../../components/ui';
import {
useCallback,
useEffect,
useRef,
useState,
type PointerEvent as ReactPointerEvent,
} from 'react';
import { Check, Code2, Copy, Download, Maximize2, Minimize2, Save, X } from 'lucide-react';
import { downloadBytes } from '../../project/cachedFiles';
import { Button, ConfirmDialog, IconButton } from '../../components/ui';
function basename(path:string):string{return path.split('/').at(-1)??path;}
function contentSize(content:string):string{const bytes=new Blob([content]).size;return bytes<1024?`${bytes} B`:`${(bytes/1024).toFixed(1)} KB`;}
function xmlProblem(code:string):string|undefined{const document=new DOMParser().parseFromString(code,'application/xml'),error=document.querySelector('parsererror');return error?.textContent?.split('\n')[0]||undefined;}
export function SourceEditorDialog({open,code:sourceCode,filePath,theme,onClose,onSave}:{open:boolean;code:string;filePath:string;theme:'light'|'dark';onClose:()=>void;onSave:(path:string,text:string)=>void|Promise<void>}){
const [code,setCode]=useState(sourceCode),[savedCode,setSavedCode]=useState(sourceCode),[saving,setSaving]=useState(false),[copied,setCopied]=useState(false),[maximized,setMaximized]=useState(false),[discardOpen,setDiscardOpen]=useState(false),[position,setPosition]=useState(()=>({x:Math.max(24,(window.innerWidth-900)/2),y:Math.max(52,(window.innerHeight-650)/2)}));
const drag=useRef<{x:number;y:number;left:number;top:number}|null>(null),dialog=useRef<HTMLElement>(null),previousFocus=useRef<HTMLElement|null>(null),dirty=code!==savedCode,problem=xmlProblem(code);
const requestClose=useCallback(()=>{if(dirty)setDiscardOpen(true);else onClose();},[dirty,onClose]);
const save=useCallback(async()=>{if(!dirty||problem)return;setSaving(true);try{await onSave(filePath,code);setSavedCode(code);}finally{setSaving(false);}},[code,dirty,filePath,onSave,problem]);
useEffect(()=>{if(!open)return;previousFocus.current=document.activeElement instanceof HTMLElement?document.activeElement:null;requestAnimationFrame(()=>dialog.current?.focus());return()=>{if(previousFocus.current&&document.contains(previousFocus.current))previousFocus.current.focus();};},[open]);
useEffect(()=>{const key=(event:KeyboardEvent)=>{if(discardOpen)return;if((event.ctrlKey||event.metaKey)&&event.key.toLowerCase()==='s'&&dirty&&!problem){event.preventDefault();void save();}else if(event.key==='Escape'){event.preventDefault();requestClose();}};window.addEventListener('keydown',key);return()=>window.removeEventListener('keydown',key);},[dirty,discardOpen,problem,requestClose,save]);
const copy=async()=>{await navigator.clipboard.writeText(code);setCopied(true);window.setTimeout(()=>setCopied(false),1500);};
const download=()=>downloadBytes(new TextEncoder().encode(code),basename(filePath),'application/xml');
const pointerDown=(event:ReactPointerEvent)=>{if(maximized||event.button!==0||(event.target as HTMLElement).closest('button'))return;drag.current={x:event.clientX,y:event.clientY,left:position.x,top:position.y};event.currentTarget.setPointerCapture(event.pointerId);};
const pointerMove=(event:ReactPointerEvent)=>{if(!drag.current)return;setPosition({x:Math.min(window.innerWidth-120,Math.max(-780,drag.current.left+event.clientX-drag.current.x)),y:Math.min(window.innerHeight-48,Math.max(0,drag.current.top+event.clientY-drag.current.y))});};
if(!open)return null;
return <><div className="fixed inset-0 z-[390] pointer-events-none" role="presentation"><section ref={dialog} tabIndex={-1} role="dialog" aria-modal="false" aria-label="转换后的 MJCF 编辑器" style={maximized?undefined:{left:position.x,top:position.y,width:900,height:650}} className={`source-editor-window pointer-events-auto fixed flex min-h-[360px] min-w-[520px] flex-col overflow-hidden border border-border-strong bg-panel shadow-2xl ${maximized?'inset-0 h-full w-full':'resize'}`}>
<header className="flex h-11 shrink-0 cursor-move select-none items-center gap-3 border-b border-border bg-surface px-3" onPointerDown={pointerDown} onPointerMove={pointerMove} onPointerUp={()=>{drag.current=null;}} onDoubleClick={()=>setMaximized(value=>!value)}><Code2 className="h-4 w-4 shrink-0 text-accent"/><div className="min-w-0 flex-1"><div className="truncate font-mono text-xs font-semibold text-text-primary">转换后的 MJCF</div><div className="truncate font-mono text-[9px] text-text-tertiary" title={filePath}>{filePath}</div></div><span className="text-[10px] text-text-tertiary">{contentSize(code)}</span><span className="rounded bg-accent-soft px-1.5 py-0.5 text-[9px] font-semibold text-accent">缓存文件 · 可编辑</span>{dirty&&<span className="rounded bg-warning-soft px-1.5 py-0.5 text-[9px] font-semibold text-warning">已修改</span>}<Button variant="primary" icon={<Save className="h-3 w-3"/>} disabled={!dirty||saving||Boolean(problem)} onClick={()=>void save()}>{saving?'重新载入中…':'保存并重新载入'}</Button><Button variant="ghost" icon={<Download className="h-3.5 w-3.5"/>} onClick={download}>下载</Button><Button variant="ghost" icon={copied?<Check className="h-3.5 w-3.5"/>:<Copy className="h-3.5 w-3.5"/>} onClick={()=>void copy()}>{copied?'已复制':'复制'}</Button><IconButton tooltip={maximized?'还原':'最大化'} aria-label={maximized?'还原':'最大化'} onClick={()=>setMaximized(value=>!value)}>{maximized?<Minimize2 className="h-4 w-4"/>:<Maximize2 className="h-4 w-4"/>}</IconButton><IconButton tooltip="关闭" aria-label="关闭源代码编辑器" onClick={requestClose}><X className="h-4 w-4"/></IconButton></header>
<div className="min-h-0 flex-1 bg-input"><Editor height="100%" language="xml" theme={theme==='light'?'light':'vs-dark'} value={code} onChange={value=>setCode(value??'')} options={{automaticLayout:true,minimap:{enabled:false},fontFamily:"'JetBrains Mono','Fira Code',ui-monospace,monospace",fontSize:13,fontLigatures:true,scrollBeyondLastLine:false,wordWrap:'off',stickyScroll:{enabled:false},tabSize:2,formatOnPaste:true,formatOnType:true,lineNumbersMinChars:4,padding:{top:12,bottom:14},renderLineHighlight:'all'}}/></div>
<footer className="flex h-7 shrink-0 items-center justify-between gap-3 border-t border-border bg-surface px-3 text-[10px]"><div className={problem?'truncate text-warning':'text-success'}>{problem?`XML 错误:${problem}`:'✓ XML 结构正常'}</div><div className="flex items-center gap-2 font-mono text-text-tertiary"><span>Ctrl+S 保存并重新载入</span><span>•</span><span>MJCF / XML</span></div></footer>
</section></div><ConfirmDialog open={discardOpen} title="放弃未保存的修改?" confirmLabel="放弃修改" cancelLabel="继续编辑" danger onConfirm={onClose} onClose={()=>setDiscardOpen(false)}><p className="text-sm text-text-secondary">当前 MJCF 源码包含未保存的修改。关闭后,这些修改将无法恢复。</p></ConfirmDialog></>;
function basename(path: string): string {
return path.split('/').at(-1) ?? path;
}
function contentSize(content: string): string {
const bytes = new Blob([content]).size;
return bytes < 1024 ? `${bytes} B` : `${(bytes / 1024).toFixed(1)} KB`;
}
function xmlProblem(code: string): string | undefined {
const document = new DOMParser().parseFromString(code, 'application/xml'),
error = document.querySelector('parsererror');
return error?.textContent?.split('\n')[0] || undefined;
}
export function SourceEditorDialog({
open,
code: sourceCode,
filePath,
theme,
onClose,
onSave,
}: {
open: boolean;
code: string;
filePath: string;
theme: 'light' | 'dark';
onClose: () => void;
onSave: (path: string, text: string) => void | Promise<void>;
}) {
const [code, setCode] = useState(sourceCode),
[savedCode, setSavedCode] = useState(sourceCode),
[saving, setSaving] = useState(false),
[copied, setCopied] = useState(false),
[maximized, setMaximized] = useState(false),
[discardOpen, setDiscardOpen] = useState(false),
[position, setPosition] = useState(() => ({
x: Math.max(24, (window.innerWidth - 900) / 2),
y: Math.max(52, (window.innerHeight - 650) / 2),
}));
const drag = useRef<{ x: number; y: number; left: number; top: number } | null>(null),
dialog = useRef<HTMLElement>(null),
previousFocus = useRef<HTMLElement | null>(null),
dirty = code !== savedCode,
problem = xmlProblem(code);
const requestClose = useCallback(() => {
if (dirty) setDiscardOpen(true);
else onClose();
}, [dirty, onClose]);
const save = useCallback(async () => {
if (!dirty || problem) return;
setSaving(true);
try {
await onSave(filePath, code);
setSavedCode(code);
} finally {
setSaving(false);
}
}, [code, dirty, filePath, onSave, problem]);
useEffect(() => {
if (!open) return;
previousFocus.current =
document.activeElement instanceof HTMLElement ? document.activeElement : null;
requestAnimationFrame(() => dialog.current?.focus());
return () => {
if (previousFocus.current && document.contains(previousFocus.current))
previousFocus.current.focus();
};
}, [open]);
useEffect(() => {
const key = (event: KeyboardEvent) => {
if (discardOpen) return;
if (
(event.ctrlKey || event.metaKey) &&
event.key.toLowerCase() === 's' &&
dirty &&
!problem
) {
event.preventDefault();
void save();
} else if (event.key === 'Escape') {
event.preventDefault();
requestClose();
}
};
window.addEventListener('keydown', key);
return () => window.removeEventListener('keydown', key);
}, [dirty, discardOpen, problem, requestClose, save]);
const copy = async () => {
await navigator.clipboard.writeText(code);
setCopied(true);
window.setTimeout(() => setCopied(false), 1500);
};
const download = () =>
downloadBytes(new TextEncoder().encode(code), basename(filePath), 'application/xml');
const pointerDown = (event: ReactPointerEvent) => {
if (maximized || event.button !== 0 || (event.target as HTMLElement).closest('button')) return;
drag.current = { x: event.clientX, y: event.clientY, left: position.x, top: position.y };
event.currentTarget.setPointerCapture(event.pointerId);
};
const pointerMove = (event: ReactPointerEvent) => {
if (!drag.current) return;
setPosition({
x: Math.min(
window.innerWidth - 120,
Math.max(-780, drag.current.left + event.clientX - drag.current.x),
),
y: Math.min(
window.innerHeight - 48,
Math.max(0, drag.current.top + event.clientY - drag.current.y),
),
});
};
if (!open) return null;
return (
<>
<div className="fixed inset-0 z-[390] pointer-events-none" role="presentation">
<section
ref={dialog}
tabIndex={-1}
role="dialog"
aria-modal="false"
aria-label="转换后的 MJCF 编辑器"
style={
maximized ? undefined : { left: position.x, top: position.y, width: 900, height: 650 }
}
className={`source-editor-window pointer-events-auto fixed flex min-h-[360px] min-w-[520px] flex-col overflow-hidden border border-border-strong bg-panel shadow-2xl ${maximized ? 'inset-0 h-full w-full' : 'resize'}`}
>
<header
className="flex h-11 shrink-0 cursor-move select-none items-center gap-3 border-b border-border bg-surface px-3"
onPointerDown={pointerDown}
onPointerMove={pointerMove}
onPointerUp={() => {
drag.current = null;
}}
onDoubleClick={() => setMaximized((value) => !value)}
>
<Code2 className="h-4 w-4 shrink-0 text-accent" />
<div className="min-w-0 flex-1">
<div className="truncate font-mono text-xs font-semibold text-text-primary">
转换后的 MJCF
</div>
<div className="truncate font-mono text-[9px] text-text-tertiary" title={filePath}>
{filePath}
</div>
</div>
<span className="text-[10px] text-text-tertiary">{contentSize(code)}</span>
<span className="rounded bg-accent-soft px-1.5 py-0.5 text-[9px] font-semibold text-accent">
缓存文件 · 可编辑
</span>
{dirty && (
<span className="rounded bg-warning-soft px-1.5 py-0.5 text-[9px] font-semibold text-warning">
已修改
</span>
)}
<Button
variant="primary"
icon={<Save className="h-3 w-3" />}
disabled={!dirty || saving || Boolean(problem)}
onClick={() => void save()}
>
{saving ? '重新载入中…' : '保存并重新载入'}
</Button>
<Button variant="ghost" icon={<Download className="h-3.5 w-3.5" />} onClick={download}>
下载
</Button>
<Button
variant="ghost"
icon={copied ? <Check className="h-3.5 w-3.5" /> : <Copy className="h-3.5 w-3.5" />}
onClick={() => void copy()}
>
{copied ? '已复制' : '复制'}
</Button>
<IconButton
tooltip={maximized ? '还原' : '最大化'}
aria-label={maximized ? '还原' : '最大化'}
onClick={() => setMaximized((value) => !value)}
>
{maximized ? <Minimize2 className="h-4 w-4" /> : <Maximize2 className="h-4 w-4" />}
</IconButton>
<IconButton tooltip="关闭" aria-label="关闭源代码编辑器" onClick={requestClose}>
<X className="h-4 w-4" />
</IconButton>
</header>
<div className="min-h-0 flex-1 bg-input">
<Editor
height="100%"
language="xml"
theme={theme === 'light' ? 'light' : 'vs-dark'}
value={code}
onChange={(value) => setCode(value ?? '')}
options={{
automaticLayout: true,
minimap: { enabled: false },
fontFamily: "'JetBrains Mono','Fira Code',ui-monospace,monospace",
fontSize: 13,
fontLigatures: true,
scrollBeyondLastLine: false,
wordWrap: 'off',
stickyScroll: { enabled: false },
tabSize: 2,
formatOnPaste: true,
formatOnType: true,
lineNumbersMinChars: 4,
padding: { top: 12, bottom: 14 },
renderLineHighlight: 'all',
}}
/>
</div>
<footer className="flex h-7 shrink-0 items-center justify-between gap-3 border-t border-border bg-surface px-3 text-[10px]">
<div className={problem ? 'truncate text-warning' : 'text-success'}>
{problem ? `XML 错误:${problem}` : '✓ XML 结构正常'}
</div>
<div className="flex items-center gap-2 font-mono text-text-tertiary">
<span>Ctrl+S 保存并重新载入</span>
<span>•</span>
<span>MJCF / XML</span>
</div>
</footer>
</section>
</div>
<ConfirmDialog
open={discardOpen}
title="放弃未保存的修改?"
confirmLabel="放弃修改"
cancelLabel="继续编辑"
danger
onConfirm={onClose}
onClose={() => setDiscardOpen(false)}
>
<p className="text-sm text-text-secondary">
当前 MJCF 源码包含未保存的修改。关闭后,这些修改将无法恢复。
</p>
</ConfirmDialog>
</>
);
}
+51 -7
View File
@@ -1,7 +1,51 @@
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,className=''}:{icon:typeof Clock3;children:ReactNode;className?:string}){return <span className={`items-center gap-1.5 ${className||'flex'}`}><Icon aria-hidden="true" className="h-3 w-3 text-text-tertiary"/>{children}</span>;}
export function StatusBar({time,fps,stepMs,memoryMb,loaded,overBudget}:StatusBarProps){return <footer className="technical-value relative z-30 flex h-7 shrink-0 items-center gap-3 overflow-hidden border-t border-border bg-panel px-3 text-[11px] text-text-tertiary lg:gap-5"><Item icon={Clock3}>时间 {time?.toFixed(3)??'—'} s</Item><PerformancePopover fps={fps} stepMs={stepMs} memoryMb={memoryMb} overBudget={overBudget}/><Item icon={MemoryStick} className="hidden items-center gap-1.5 md:flex">内存 {memoryMb===undefined?'—':`${memoryMb.toFixed(1)} MiB`}</Item><Item icon={Box} className="hidden items-center gap-1.5 sm:flex">WASM {loaded?'已加载':'未加载'}</Item>{overBudget&&<span className="hidden min-w-0 items-center gap-1 truncate text-warning lg:flex"><TriangleAlert className="h-3 w-3 shrink-0"/>主线程超出步进预算,已限制追帧</span>}<span className="ml-auto hidden items-center gap-1.5 xl:flex"><Kbd>Space</Kbd> 播放/暂停 · <Kbd>R</Kbd> 重置 · <Kbd>1/2/3</Kbd> 模式</span></footer>;}
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,
className = '',
}: {
icon: typeof Clock3;
children: ReactNode;
className?: string;
}) {
return (
<span className={`items-center gap-1.5 ${className || 'flex'}`}>
<Icon aria-hidden="true" className="h-3 w-3 text-text-tertiary" />
{children}
</span>
);
}
export function StatusBar({ time, fps, stepMs, memoryMb, loaded, overBudget }: StatusBarProps) {
return (
<footer className="technical-value relative z-30 flex h-7 shrink-0 items-center gap-3 overflow-hidden border-t border-border bg-panel px-3 text-[11px] text-text-tertiary lg:gap-5">
<Item icon={Clock3}>时间 {time?.toFixed(3) ?? '—'} s</Item>
<PerformancePopover fps={fps} stepMs={stepMs} memoryMb={memoryMb} overBudget={overBudget} />
<Item icon={MemoryStick} className="hidden items-center gap-1.5 md:flex">
内存 {memoryMb === undefined ? '—' : `${memoryMb.toFixed(1)} MiB`}
</Item>
<Item icon={Box} className="hidden items-center gap-1.5 sm:flex">
WASM {loaded ? '已加载' : '未加载'}
</Item>
{overBudget && (
<span className="hidden min-w-0 items-center gap-1 truncate text-warning lg:flex">
<TriangleAlert className="h-3 w-3 shrink-0" />
主线程超出步进预算,已限制追帧
</span>
)}
<span className="ml-auto hidden items-center gap-1.5 xl:flex">
<Kbd>Space</Kbd> 播放/暂停 · <Kbd>R</Kbd> 重置 · <Kbd>1/2/3</Kbd> 模式
</span>
</footer>
);
}
@@ -1,8 +1,31 @@
import {fireEvent,render,screen} from '@testing-library/react';
import {CommandPalette,type WorkbenchCommand} from './CommandPalette';
import {PerformancePopover} from './PerformancePopover';
import { fireEvent, render, screen } from '@testing-library/react';
import { CommandPalette, type WorkbenchCommand } from './CommandPalette';
import { PerformancePopover } from './PerformancePopover';
describe('第三批工作台组件',()=>{
it('命令面板可搜索并执行现有命令',()=>{const run=vi.fn(),close=vi.fn(),commands:WorkbenchCommand[]=[{id:'reset',label:'重置仿真',group:'仿真',run},{id:'theme',label:'切换主题',group:'外观',run:vi.fn()}];render(<CommandPalette open onClose={close} commands={commands}/>);const input=screen.getByLabelText('搜索命令');fireEvent.change(input,{target:{value:'重置'}});expect(screen.queryByText('切换主题')).not.toBeInTheDocument();fireEvent.keyDown(input,{key:'Enter'});expect(run).toHaveBeenCalledTimes(1);expect(close).toHaveBeenCalledTimes(1);expect(input).toHaveAttribute('aria-controls');expect(input).toHaveAttribute('aria-activedescendant');});
it('状态栏性能入口展示已有指标',()=>{render(<PerformancePopover fps={59.6} stepMs={1.25} memoryMb={42.5} overBudget={false}/>);fireEvent.click(screen.getByRole('button'));expect(screen.getByRole('dialog',{name:'性能详情'})).toHaveTextContent('60 FPS');expect(screen.getByRole('dialog',{name:'性能详情'})).toHaveTextContent('42.5 MiB');fireEvent.keyDown(document,{key:'k',ctrlKey:true});expect(screen.queryByRole('dialog',{name:'性能详情'})).not.toBeInTheDocument();});
describe('第三批工作台组件', () => {
it('命令面板可搜索并执行现有命令', () => {
const run = vi.fn(),
close = vi.fn(),
commands: WorkbenchCommand[] = [
{ id: 'reset', label: '重置仿真', group: '仿真', run },
{ id: 'theme', label: '切换主题', group: '外观', run: vi.fn() },
];
render(<CommandPalette open onClose={close} commands={commands} />);
const input = screen.getByLabelText('搜索命令');
fireEvent.change(input, { target: { value: '重置' } });
expect(screen.queryByText('切换主题')).not.toBeInTheDocument();
fireEvent.keyDown(input, { key: 'Enter' });
expect(run).toHaveBeenCalledTimes(1);
expect(close).toHaveBeenCalledTimes(1);
expect(input).toHaveAttribute('aria-controls');
expect(input).toHaveAttribute('aria-activedescendant');
});
it('状态栏性能入口展示已有指标', () => {
render(<PerformancePopover fps={59.6} stepMs={1.25} memoryMb={42.5} overBudget={false} />);
fireEvent.click(screen.getByRole('button'));
expect(screen.getByRole('dialog', { name: '性能详情' })).toHaveTextContent('60 FPS');
expect(screen.getByRole('dialog', { name: '性能详情' })).toHaveTextContent('42.5 MiB');
fireEvent.keyDown(document, { key: 'k', ctrlKey: true });
expect(screen.queryByRole('dialog', { name: '性能详情' })).not.toBeInTheDocument();
});
});
@@ -1,3 +1,72 @@
import {CircleHelp,Expand,LayoutDashboard,Maximize,Search,Settings,SunMoon} from 'lucide-react';
import {DropdownMenu} from '../../components/ui';
export function ToolbarOverflowMenu({fullscreen,onCommands,onLayout,onSettings,onFullscreen,onHelp,onTheme}:{fullscreen:boolean;onCommands:()=>void;onLayout:()=>void;onSettings:()=>void;onFullscreen:()=>void;onHelp:()=>void;onTheme:()=>void}){return <DropdownMenu label="更多工作台操作" className="xl:hidden" items={[{id:'commands',label:'命令面板',icon:<Search className="h-4 w-4"/>,onSelect:onCommands},{id:'layout',label:'布局设置',icon:<LayoutDashboard className="h-4 w-4"/>,onSelect:onLayout},{id:'settings',label:'工作台设置',icon:<Settings className="h-4 w-4"/>,onSelect:onSettings},{id:'fullscreen',label:fullscreen?'退出全屏':'进入全屏',icon:fullscreen?<Expand className="h-4 w-4"/>:<Maximize className="h-4 w-4"/>,onSelect:onFullscreen},{id:'help',label:'快捷键帮助',icon:<CircleHelp className="h-4 w-4"/>,onSelect:onHelp},{id:'theme',label:'切换主题',icon:<SunMoon className="h-4 w-4"/>,onSelect:onTheme}]}/>;}
import {
CircleHelp,
Expand,
LayoutDashboard,
Maximize,
Search,
Settings,
SunMoon,
} from 'lucide-react';
import { DropdownMenu } from '../../components/ui';
export function ToolbarOverflowMenu({
fullscreen,
onCommands,
onLayout,
onSettings,
onFullscreen,
onHelp,
onTheme,
}: {
fullscreen: boolean;
onCommands: () => void;
onLayout: () => void;
onSettings: () => void;
onFullscreen: () => void;
onHelp: () => void;
onTheme: () => void;
}) {
return (
<DropdownMenu
label="更多工作台操作"
className="xl:hidden"
items={[
{
id: 'commands',
label: '命令面板',
icon: <Search className="h-4 w-4" />,
onSelect: onCommands,
},
{
id: 'layout',
label: '布局设置',
icon: <LayoutDashboard className="h-4 w-4" />,
onSelect: onLayout,
},
{
id: 'settings',
label: '工作台设置',
icon: <Settings className="h-4 w-4" />,
onSelect: onSettings,
},
{
id: 'fullscreen',
label: fullscreen ? '退出全屏' : '进入全屏',
icon: fullscreen ? <Expand className="h-4 w-4" /> : <Maximize className="h-4 w-4" />,
onSelect: onFullscreen,
},
{
id: 'help',
label: '快捷键帮助',
icon: <CircleHelp className="h-4 w-4" />,
onSelect: onHelp,
},
{
id: 'theme',
label: '切换主题',
icon: <SunMoon className="h-4 w-4" />,
onSelect: onTheme,
},
]}
/>
);
}
@@ -1,3 +1,46 @@
import {Search,X} from 'lucide-react';
import {IconButton} from '../../components/ui';
export function TreeSearchField({value,onChange,resultCount,placeholder='搜索…',label='搜索树'}:{value:string;onChange:(value:string)=>void;resultCount?:number;placeholder?:string;label?:string}){return <div className="m-2"><div className="relative"><Search aria-hidden="true" className="pointer-events-none absolute left-2 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-text-tertiary"/><input type="search" aria-label={label} value={value} onChange={event=>onChange(event.target.value)} placeholder={placeholder} className="h-8 w-full rounded-md border border-border bg-input pl-7 pr-8 text-xs text-text-primary placeholder:text-text-tertiary focus-visible:ring-2 focus-visible:ring-accent/35"/>{value&&<span className="absolute right-0.5 top-0.5"><IconButton aria-label="清除搜索" tooltip="清除搜索" onClick={()=>onChange('')}><X className="h-3.5 w-3.5"/></IconButton></span>}</div>{value&&resultCount!==undefined&&<p role="status" className="px-1 pt-1.5 text-[10px] text-text-tertiary">找到 {resultCount} 个匹配项</p>}</div>;}
import { Search, X } from 'lucide-react';
import { IconButton } from '../../components/ui';
export function TreeSearchField({
value,
onChange,
resultCount,
placeholder = '搜索…',
label = '搜索树',
}: {
value: string;
onChange: (value: string) => void;
resultCount?: number;
placeholder?: string;
label?: string;
}) {
return (
<div className="m-2">
<div className="relative">
<Search
aria-hidden="true"
className="pointer-events-none absolute left-2 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-text-tertiary"
/>
<input
type="search"
aria-label={label}
value={value}
onChange={(event) => onChange(event.target.value)}
placeholder={placeholder}
className="h-8 w-full rounded-md border border-border bg-input pl-7 pr-8 text-xs text-text-primary placeholder:text-text-tertiary focus-visible:ring-2 focus-visible:ring-accent/35"
/>
{value && (
<span className="absolute right-0.5 top-0.5">
<IconButton aria-label="清除搜索" tooltip="清除搜索" onClick={() => onChange('')}>
<X className="h-3.5 w-3.5" />
</IconButton>
</span>
)}
</div>
{value && resultCount !== undefined && (
<p role="status" className="px-1 pt-1.5 text-[10px] text-text-tertiary">
找到 {resultCount} 个匹配项
</p>
)}
</div>
);
}
@@ -1,31 +1,52 @@
import {fireEvent,render,screen} from '@testing-library/react';
import {UrdfImportOptionsDialog} from './UrdfImportOptionsDialog';
import { fireEvent, render, screen } from '@testing-library/react';
import { UrdfImportOptionsDialog } from './UrdfImportOptionsDialog';
describe('UrdfImportOptionsDialog',()=>{
it('默认选择关节驱动器和摄像头传感器',()=>{
const onConfirm=vi.fn();
render(<UrdfImportOptionsDialog open path="robot.urdf" mountBodies={['base','head_link']} onConfirm={onConfirm} onSkip={()=>{}}/>);
expect(screen.getByRole('checkbox',{name:/为关节添加驱动器/})).toBeChecked();
expect(screen.getByRole('checkbox',{name:/添加传感器/})).toBeChecked();
describe('UrdfImportOptionsDialog', () => {
it('默认选择关节驱动器和摄像头传感器', () => {
const onConfirm = vi.fn();
render(
<UrdfImportOptionsDialog
open
path="robot.urdf"
mountBodies={['base', 'head_link']}
onConfirm={onConfirm}
onSkip={() => {}}
/>,
);
expect(screen.getByRole('checkbox', { name: /为关节添加驱动器/ })).toBeChecked();
expect(screen.getByRole('checkbox', { name: /添加传感器/ })).toBeChecked();
expect(screen.getByLabelText('摄像头固连 Body')).toHaveValue('head_link');
fireEvent.change(screen.getByLabelText('摄像头位置 X'),{target:{value:'0.2'}});
fireEvent.click(screen.getByRole('button',{name:'转换并加载'}));
expect(onConfirm).toHaveBeenCalledWith(expect.objectContaining({addActuators:true,addSensors:true,sensorType:'camera',cameraMountBody:'head_link',cameraPosition:[.2,0,.05],cameraDirection:'+X'}));
fireEvent.change(screen.getByLabelText('摄像头位置 X'), { target: { value: '0.2' } });
fireEvent.click(screen.getByRole('button', { name: '转换并加载' }));
expect(onConfirm).toHaveBeenCalledWith(
expect.objectContaining({
addActuators: true,
addSensors: true,
sensorType: 'camera',
cameraMountBody: 'head_link',
cameraPosition: [0.2, 0, 0.05],
cameraDirection: '+X',
}),
);
});
it('允许分别关闭自动生成项',()=>{
const onConfirm=vi.fn();
render(<UrdfImportOptionsDialog open path="robot.urdf" onConfirm={onConfirm} onSkip={()=>{}}/>);
fireEvent.click(screen.getByRole('checkbox',{name:/为关节添加驱动器/}));
fireEvent.click(screen.getByRole('checkbox',{name:/添加传感器/}));
fireEvent.click(screen.getByRole('button',{name:'转换并加载'}));
expect(onConfirm).toHaveBeenCalledWith(expect.objectContaining({addActuators:false,addSensors:false,sensorType:'camera'}));
it('允许分别关闭自动生成项', () => {
const onConfirm = vi.fn();
render(
<UrdfImportOptionsDialog open path="robot.urdf" onConfirm={onConfirm} onSkip={() => {}} />,
);
fireEvent.click(screen.getByRole('checkbox', { name: /为关节添加驱动器/ }));
fireEvent.click(screen.getByRole('checkbox', { name: /添加传感器/ }));
fireEvent.click(screen.getByRole('button', { name: '转换并加载' }));
expect(onConfirm).toHaveBeenCalledWith(
expect.objectContaining({ addActuators: false, addSensors: false, sensorType: 'camera' }),
);
});
it('可以不添加组件并继续加载',()=>{
const onSkip=vi.fn();
render(<UrdfImportOptionsDialog open path="robot.urdf" onConfirm={()=>{}} onSkip={onSkip}/>);
fireEvent.click(screen.getByRole('button',{name:'不添加,直接加载'}));
it('可以不添加组件并继续加载', () => {
const onSkip = vi.fn();
render(<UrdfImportOptionsDialog open path="robot.urdf" onConfirm={() => {}} onSkip={onSkip} />);
fireEvent.click(screen.getByRole('button', { name: '不添加,直接加载' }));
expect(onSkip).toHaveBeenCalledOnce();
});
});
@@ -1,26 +1,178 @@
import {useState,type ReactNode} from 'react';
import {Camera,Settings2} from 'lucide-react';
import type {CameraDirection,UrdfEnhancementOptions} from '../../project/urdfToMjcf';
import {Button,Dialog,Select} from '../../components/ui';
import { useState, type ReactNode } from 'react';
import { Camera, Settings2 } from 'lucide-react';
import type { CameraDirection, UrdfEnhancementOptions } from '../../project/urdfToMjcf';
import { Button, Dialog, Select } from '../../components/ui';
function OptionCard({checked,onChange,icon,title,description,children}:{checked:boolean;onChange:(checked:boolean)=>void;icon:ReactNode;title:string;description:string;children?:ReactNode}){
return <label className={`flex cursor-pointer gap-3 rounded-lg border p-3 transition-colors ${checked?'border-accent/60 bg-accent/10':'border-border bg-surface hover:bg-element-hover'}`}>
<input className="mt-0.5 h-4 w-4 accent-accent" type="checkbox" checked={checked} onChange={event=>onChange(event.target.checked)}/>
<span className="mt-0.5 text-text-secondary" aria-hidden="true">{icon}</span>
<span className="min-w-0 flex-1"><span className="block text-sm font-medium text-text-primary">{title}</span><span className="mt-1 block text-xs leading-5 text-text-secondary">{description}</span>{children}</span>
</label>;
function OptionCard({
checked,
onChange,
icon,
title,
description,
children,
}: {
checked: boolean;
onChange: (checked: boolean) => void;
icon: ReactNode;
title: string;
description: string;
children?: ReactNode;
}) {
return (
<label
className={`flex cursor-pointer gap-3 rounded-lg border p-3 transition-colors ${checked ? 'border-accent/60 bg-accent/10' : 'border-border bg-surface hover:bg-element-hover'}`}
>
<input
className="mt-0.5 h-4 w-4 accent-accent"
type="checkbox"
checked={checked}
onChange={(event) => onChange(event.target.checked)}
/>
<span className="mt-0.5 text-text-secondary" aria-hidden="true">
{icon}
</span>
<span className="min-w-0 flex-1">
<span className="block text-sm font-medium text-text-primary">{title}</span>
<span className="mt-1 block text-xs leading-5 text-text-secondary">{description}</span>
{children}
</span>
</label>
);
}
export function UrdfImportOptionsDialog({open,path,mountBodies=[],onConfirm,onSkip}:{open:boolean;path?:string;mountBodies?:string[];onConfirm:(options:UrdfEnhancementOptions)=>void;onSkip:()=>void}){
const [options,setOptions]=useState<UrdfEnhancementOptions>(()=>({addActuators:true,addSensors:true,sensorType:'camera',cameraMountBody:mountBodies.find(name=>/(head|camera|sensor|neck|头)/i.test(name))??mountBodies.at(-1),cameraPosition:[.1,0,.05],cameraDirection:'+X'}));
const setPosition=(axis:number,value:number)=>setOptions(current=>{const position:[number,number,number]=[...(current.cameraPosition??[.1,0,.05])];position[axis]=Number.isFinite(value)?value:0;return {...current,cameraPosition:position};});
return <Dialog open={open} onClose={onSkip} closable={false} title="配置 URDF 仿真组件" className="max-w-xl" footer={<div className="flex justify-end gap-2"><Button onClick={onSkip}>不添加,直接加载</Button><Button variant="primary" onClick={()=>onConfirm(options)}>转换并加载</Button></div>}>
<p className="text-sm text-text-secondary">导入 <strong className="text-text-primary">{path}</strong> 后,是否自动补充以下仿真组件?稍后重新选择该 URDF 时仍会再次询问。</p>
<div className="mt-4 space-y-3">
<OptionCard checked={options.addActuators} onChange={addActuators=>setOptions(value=>({...value,addActuators}))} icon={<Settings2 className="h-4 w-4"/>} title="为关节添加驱动器" description="为每个 hinge/slide 关节生成控制输入不限幅的 motor 驱动器;hinge 使用 N·m、slide 使用 N。kp/kv 用于调整对应 MJCF 关节的刚度和阻尼,已有驱动器不会重复添加。"/>
<OptionCard checked={options.addSensors} onChange={addSensors=>setOptions(value=>({...value,addSensors}))} icon={<Camera className="h-4 w-4"/>} title="添加传感器" description="在浮动基座添加三轴陀螺仪和三轴加速度计(6轴 IMU),并添加一台 640×480 固定摄像头。"/>
{options.addSensors&&<div className="rounded-lg border border-border bg-surface p-3"><div className="mb-2 text-xs font-medium text-text-primary">摄像头安装参数</div><label className="block text-[11px] text-text-secondary"><span className="mb-1 block">固连 Body</span><Select aria-label="摄像头固连 Body" className="w-full" value={options.cameraMountBody??''} onChange={event=>setOptions(value=>({...value,cameraMountBody:event.target.value||undefined}))}>{mountBodies.length?mountBodies.map(name=><option key={name} value={name}>{name}</option>):<option value="">自动选择头部/末端 Body</option>}</Select></label><div className="mt-3 grid grid-cols-3 gap-2">{(['X','Y','Z'] as const).map((axis,index)=><label key={axis} className="text-[11px] text-text-secondary"><span className="mb-1 block">位置 {axis}(m)</span><input aria-label={`摄像头位置 ${axis}`} className="field h-8 w-full px-2 text-xs" type="number" step="0.01" value={(options.cameraPosition??[.1,0,.05])[index]} onChange={event=>setPosition(index,Number(event.target.value))}/></label>)}</div><label className="mt-3 block text-[11px] text-text-secondary"><span className="mb-1 block">镜头朝向(Body 局部轴)</span><Select aria-label="摄像头朝向" className="w-full" value={options.cameraDirection??'+X'} onChange={event=>setOptions(value=>({...value,cameraDirection:event.target.value as CameraDirection}))}>{(['+X','-X','+Y','-Y','+Z','-Z'] as CameraDirection[]).map(direction=><option key={direction}>{direction}</option>)}</Select></label><p className="mt-2 text-[10px] leading-4 text-text-tertiary">位置和朝向均相对于所选 Body;常见 ROS 头部摄像头使用 +X 朝前、+Z 朝上。</p></div>}
</div>
<p className="mt-4 text-xs text-text-tertiary">自动组件只写入浏览器内生成的 MJCF,不会修改本地 URDF 文件;使用“原生 URDF”加载模式时不会注入这些组件。</p>
</Dialog>;
export function UrdfImportOptionsDialog({
open,
path,
mountBodies = [],
onConfirm,
onSkip,
}: {
open: boolean;
path?: string;
mountBodies?: string[];
onConfirm: (options: UrdfEnhancementOptions) => void;
onSkip: () => void;
}) {
const [options, setOptions] = useState<UrdfEnhancementOptions>(() => ({
addActuators: true,
addSensors: true,
sensorType: 'camera',
cameraMountBody:
mountBodies.find((name) => /(head|camera|sensor|neck|头)/i.test(name)) ?? mountBodies.at(-1),
cameraPosition: [0.1, 0, 0.05],
cameraDirection: '+X',
}));
const setPosition = (axis: number, value: number) =>
setOptions((current) => {
const position: [number, number, number] = [...(current.cameraPosition ?? [0.1, 0, 0.05])];
position[axis] = Number.isFinite(value) ? value : 0;
return { ...current, cameraPosition: position };
});
return (
<Dialog
open={open}
onClose={onSkip}
closable={false}
title="配置 URDF 仿真组件"
className="max-w-xl"
footer={
<div className="flex justify-end gap-2">
<Button onClick={onSkip}>不添加,直接加载</Button>
<Button variant="primary" onClick={() => onConfirm(options)}>
转换并加载
</Button>
</div>
}
>
<p className="text-sm text-text-secondary">
导入 <strong className="text-text-primary">{path}</strong>{' '}
后,是否自动补充以下仿真组件?稍后重新选择该 URDF 时仍会再次询问。
</p>
<div className="mt-4 space-y-3">
<OptionCard
checked={options.addActuators}
onChange={(addActuators) => setOptions((value) => ({ ...value, addActuators }))}
icon={<Settings2 className="h-4 w-4" />}
title="为关节添加驱动器"
description="为每个 hinge/slide 关节生成控制输入不限幅的 motor 驱动器;hinge 使用 N·m、slide 使用 N。kp/kv 用于调整对应 MJCF 关节的刚度和阻尼,已有驱动器不会重复添加。"
/>
<OptionCard
checked={options.addSensors}
onChange={(addSensors) => setOptions((value) => ({ ...value, addSensors }))}
icon={<Camera className="h-4 w-4" />}
title="添加传感器"
description="在浮动基座添加三轴陀螺仪和三轴加速度计(6轴 IMU),并添加一台 640×480 固定摄像头。"
/>
{options.addSensors && (
<div className="rounded-lg border border-border bg-surface p-3">
<div className="mb-2 text-xs font-medium text-text-primary">摄像头安装参数</div>
<label className="block text-[11px] text-text-secondary">
<span className="mb-1 block">固连 Body</span>
<Select
aria-label="摄像头固连 Body"
className="w-full"
value={options.cameraMountBody ?? ''}
onChange={(event) =>
setOptions((value) => ({
...value,
cameraMountBody: event.target.value || undefined,
}))
}
>
{mountBodies.length ? (
mountBodies.map((name) => (
<option key={name} value={name}>
{name}
</option>
))
) : (
<option value="">自动选择头部/末端 Body</option>
)}
</Select>
</label>
<div className="mt-3 grid grid-cols-3 gap-2">
{(['X', 'Y', 'Z'] as const).map((axis, index) => (
<label key={axis} className="text-[11px] text-text-secondary">
<span className="mb-1 block">位置 {axis}(m)</span>
<input
aria-label={`摄像头位置 ${axis}`}
className="field h-8 w-full px-2 text-xs"
type="number"
step="0.01"
value={(options.cameraPosition ?? [0.1, 0, 0.05])[index]}
onChange={(event) => setPosition(index, Number(event.target.value))}
/>
</label>
))}
</div>
<label className="mt-3 block text-[11px] text-text-secondary">
<span className="mb-1 block">镜头朝向(Body 局部轴)</span>
<Select
aria-label="摄像头朝向"
className="w-full"
value={options.cameraDirection ?? '+X'}
onChange={(event) =>
setOptions((value) => ({
...value,
cameraDirection: event.target.value as CameraDirection,
}))
}
>
{(['+X', '-X', '+Y', '-Y', '+Z', '-Z'] as CameraDirection[]).map((direction) => (
<option key={direction}>{direction}</option>
))}
</Select>
</label>
<p className="mt-2 text-[10px] leading-4 text-text-tertiary">
位置和朝向均相对于所选 Body;常见 ROS 头部摄像头使用 +X 朝前、+Z 朝上。
</p>
</div>
)}
</div>
<p className="mt-4 text-xs text-text-tertiary">
自动组件只写入浏览器内生成的 MJCF,不会修改本地 URDF 文件;使用“原生
URDF”加载模式时不会注入这些组件。
</p>
</Dialog>
);
}
@@ -1,30 +1,166 @@
import {Check,Eye,RotateCcw} from 'lucide-react';
import {IconButton,Popover} from '../../components/ui';
import {DEFAULT_VIEWER_DISPLAY_OPTIONS,type ViewerDisplayOptions} from '../../viewer/displayOptions';
import { Check, Eye, RotateCcw } from 'lucide-react';
import { IconButton, Popover } from '../../components/ui';
import {
DEFAULT_VIEWER_DISPLAY_OPTIONS,
type ViewerDisplayOptions,
} from '../../viewer/displayOptions';
type DisplayKey=keyof ViewerDisplayOptions;
interface DisplayItem {key:DisplayKey;label:string;description:string;color:string;}
const geometryItems:DisplayItem[]=[
{key:'showVisual',label:'视觉模型',description:'显示模型的外观几何与材质',color:'bg-slate-400'},
{key:'showCollision',label:'碰撞体',description:'以青色半透明方式叠加碰撞几何',color:'bg-cyan-400'},
type DisplayKey = keyof ViewerDisplayOptions;
interface DisplayItem {
key: DisplayKey;
label: string;
description: string;
color: string;
}
const geometryItems: DisplayItem[] = [
{
key: 'showVisual',
label: '视觉模型',
description: '显示模型的外观几何与材质',
color: 'bg-slate-400',
},
{
key: 'showCollision',
label: '碰撞体',
description: '以青色半透明方式叠加碰撞几何',
color: 'bg-cyan-400',
},
];
const helperItems:DisplayItem[]=[
{key:'showFrames',label:'坐标系',description:'显示每个刚体的 RGB 坐标轴',color:'bg-red-400'},
{key:'showJointAxes',label:'关节轴',description:'显示转动与滑动关节的正轴方向',color:'bg-red-500'},
{key:'showCenterOfMass',label:'质心',description:'显示各刚体的质量中心',color:'bg-yellow-400'},
{key:'showInertia',label:'惯量',description:'显示由主惯量计算的等效惯量盒',color:'bg-cyan-300'},
const helperItems: DisplayItem[] = [
{
key: 'showFrames',
label: '坐标系',
description: '显示每个刚体的 RGB 坐标轴',
color: 'bg-red-400',
},
{
key: 'showJointAxes',
label: '关节轴',
description: '显示转动与滑动关节的正轴方向',
color: 'bg-red-500',
},
{
key: 'showCenterOfMass',
label: '质心',
description: '显示各刚体的质量中心',
color: 'bg-yellow-400',
},
{
key: 'showInertia',
label: '惯量',
description: '显示由主惯量计算的等效惯量盒',
color: 'bg-cyan-300',
},
];
const sceneItems:DisplayItem[]=[
{key:'showGrid',label:'地面网格',description:'显示世界坐标系的参考网格',color:'bg-blue-400'},
const sceneItems: DisplayItem[] = [
{
key: 'showGrid',
label: '地面网格',
description: '显示世界坐标系的参考网格',
color: 'bg-blue-400',
},
];
function DisplayRows({items,value,onChange}:{items:DisplayItem[];value:ViewerDisplayOptions;onChange:(next:ViewerDisplayOptions)=>void}){
return <div className="space-y-0.5">{items.map(item=>{const checked=value[item.key];return <button key={item.key} type="button" role="switch" aria-checked={checked} onClick={()=>onChange({...value,[item.key]:!checked})} className="group flex w-full items-center gap-2.5 rounded-md px-2 py-1.5 text-left hover:bg-element-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40"><span className={`h-2.5 w-2.5 shrink-0 rounded-sm ${item.color}`}/><span className="min-w-0 flex-1"><span className="block text-xs font-medium text-text-primary">{item.label}</span><span className="block truncate text-[10px] text-text-tertiary">{item.description}</span></span><span aria-hidden="true" className={`grid h-4 w-4 shrink-0 place-items-center rounded border ${checked?'border-accent bg-accent text-white':'border-border-strong bg-input'}`}>{checked&&<Check className="h-3 w-3"/>}</span></button>;})}</div>;
function DisplayRows({
items,
value,
onChange,
}: {
items: DisplayItem[];
value: ViewerDisplayOptions;
onChange: (next: ViewerDisplayOptions) => void;
}) {
return (
<div className="space-y-0.5">
{items.map((item) => {
const checked = value[item.key];
return (
<button
key={item.key}
type="button"
role="switch"
aria-checked={checked}
onClick={() => onChange({ ...value, [item.key]: !checked })}
className="group flex w-full items-center gap-2.5 rounded-md px-2 py-1.5 text-left hover:bg-element-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40"
>
<span className={`h-2.5 w-2.5 shrink-0 rounded-sm ${item.color}`} />
<span className="min-w-0 flex-1">
<span className="block text-xs font-medium text-text-primary">{item.label}</span>
<span className="block truncate text-[10px] text-text-tertiary">
{item.description}
</span>
</span>
<span
aria-hidden="true"
className={`grid h-4 w-4 shrink-0 place-items-center rounded border ${checked ? 'border-accent bg-accent text-white' : 'border-border-strong bg-input'}`}
>
{checked && <Check className="h-3 w-3" />}
</span>
</button>
);
})}
</div>
);
}
export function ViewerDisplayPopover({value,onChange}:{value:ViewerDisplayOptions;onChange:(next:ViewerDisplayOptions)=>void}){
const customized=Object.keys(DEFAULT_VIEWER_DISPLAY_OPTIONS).some(key=>value[key as DisplayKey]!==DEFAULT_VIEWER_DISPLAY_OPTIONS[key as DisplayKey]);
return <Popover placement="bottom-right" label="视图显示设置" trigger={({open,toggle})=><IconButton active={open||customized} tooltip="显示设置" aria-label="显示设置" aria-expanded={open} onClick={toggle}><Eye className="h-3.5 w-3.5"/></IconButton>}>
{()=> <div className="w-72 rounded-xl border border-border bg-surface-elevated p-2 shadow-2xl"><div className="mb-1 flex items-center justify-between px-2 py-1"><div><h2 className="text-xs font-semibold text-text-primary">视图显示</h2><p className="text-[10px] text-text-tertiary">控制模型和结构辅助标记</p></div><IconButton tooltip="恢复默认显示" aria-label="恢复默认显示" disabled={!customized} onClick={()=>onChange({...DEFAULT_VIEWER_DISPLAY_OPTIONS})}><RotateCcw className="h-3.5 w-3.5"/></IconButton></div><div className="border-t border-border pt-1"><p className="px-2 pb-0.5 pt-1 text-[10px] font-semibold uppercase tracking-wide text-text-tertiary">几何</p><DisplayRows items={geometryItems} value={value} onChange={onChange}/><p className="mt-1 border-t border-border px-2 pb-0.5 pt-2 text-[10px] font-semibold uppercase tracking-wide text-text-tertiary">辅助标记</p><DisplayRows items={helperItems} value={value} onChange={onChange}/><p className="mt-1 border-t border-border px-2 pb-0.5 pt-2 text-[10px] font-semibold uppercase tracking-wide text-text-tertiary">场景</p><DisplayRows items={sceneItems} value={value} onChange={onChange}/></div></div>}
</Popover>;
export function ViewerDisplayPopover({
value,
onChange,
}: {
value: ViewerDisplayOptions;
onChange: (next: ViewerDisplayOptions) => void;
}) {
const customized = Object.keys(DEFAULT_VIEWER_DISPLAY_OPTIONS).some(
(key) => value[key as DisplayKey] !== DEFAULT_VIEWER_DISPLAY_OPTIONS[key as DisplayKey],
);
return (
<Popover
placement="bottom-right"
label="视图显示设置"
trigger={({ open, toggle }) => (
<IconButton
active={open || customized}
tooltip="显示设置"
aria-label="显示设置"
aria-expanded={open}
onClick={toggle}
>
<Eye className="h-3.5 w-3.5" />
</IconButton>
)}
>
{() => (
<div className="w-72 rounded-xl border border-border bg-surface-elevated p-2 shadow-2xl">
<div className="mb-1 flex items-center justify-between px-2 py-1">
<div>
<h2 className="text-xs font-semibold text-text-primary">视图显示</h2>
<p className="text-[10px] text-text-tertiary">控制模型和结构辅助标记</p>
</div>
<IconButton
tooltip="恢复默认显示"
aria-label="恢复默认显示"
disabled={!customized}
onClick={() => onChange({ ...DEFAULT_VIEWER_DISPLAY_OPTIONS })}
>
<RotateCcw className="h-3.5 w-3.5" />
</IconButton>
</div>
<div className="border-t border-border pt-1">
<p className="px-2 pb-0.5 pt-1 text-[10px] font-semibold uppercase tracking-wide text-text-tertiary">
几何
</p>
<DisplayRows items={geometryItems} value={value} onChange={onChange} />
<p className="mt-1 border-t border-border px-2 pb-0.5 pt-2 text-[10px] font-semibold uppercase tracking-wide text-text-tertiary">
辅助标记
</p>
<DisplayRows items={helperItems} value={value} onChange={onChange} />
<p className="mt-1 border-t border-border px-2 pb-0.5 pt-2 text-[10px] font-semibold uppercase tracking-wide text-text-tertiary">
场景
</p>
<DisplayRows items={sceneItems} value={value} onChange={onChange} />
</div>
</div>
)}
</Popover>
);
}
@@ -1,7 +1,33 @@
import {Crosshair,Hand,MousePointer2,RotateCcw} from 'lucide-react';
import type {InteractionMode} from '../../viewer/MuJoCoViewer';
import type {ViewerDisplayOptions} from '../../viewer/displayOptions';
import {IconButton,ToolbarToggleGroup,type ToolbarItem} from '../../components/ui';
import {ViewerDisplayPopover} from './ViewerDisplayPopover';
const tools:ToolbarItem<InteractionMode>[]=[{value:'select',label:'选择',icon:MousePointer2},{value:'joint',label:'关节拖动',icon:Hand},{value:'force',label:'外力施加',icon:Crosshair}];
export function ViewerToolDock({mode,display,onModeChange,onDisplayChange,onResetCamera}:{mode:InteractionMode;display:ViewerDisplayOptions;onModeChange:(mode:InteractionMode)=>void;onDisplayChange:(next:ViewerDisplayOptions)=>void;onResetCamera:()=>void}){return <div className="flex items-center gap-1"><ToolbarToggleGroup items={tools} value={mode} onChange={onModeChange} label="视口交互模式"/><ViewerDisplayPopover value={display} onChange={onDisplayChange}/><IconButton tooltip="相机复位" aria-label="相机复位" onClick={onResetCamera}><RotateCcw className="h-3.5 w-3.5"/></IconButton></div>;}
import { Crosshair, Hand, MousePointer2, RotateCcw } from 'lucide-react';
import type { InteractionMode } from '../../viewer/MuJoCoViewer';
import type { ViewerDisplayOptions } from '../../viewer/displayOptions';
import { IconButton, ToolbarToggleGroup, type ToolbarItem } from '../../components/ui';
import { ViewerDisplayPopover } from './ViewerDisplayPopover';
const tools: ToolbarItem<InteractionMode>[] = [
{ value: 'select', label: '选择', icon: MousePointer2 },
{ value: 'joint', label: '关节拖动', icon: Hand },
{ value: 'force', label: '外力施加', icon: Crosshair },
];
export function ViewerToolDock({
mode,
display,
onModeChange,
onDisplayChange,
onResetCamera,
}: {
mode: InteractionMode;
display: ViewerDisplayOptions;
onModeChange: (mode: InteractionMode) => void;
onDisplayChange: (next: ViewerDisplayOptions) => void;
onResetCamera: () => void;
}) {
return (
<div className="flex items-center gap-1">
<ToolbarToggleGroup items={tools} value={mode} onChange={onModeChange} label="视口交互模式" />
<ViewerDisplayPopover value={display} onChange={onDisplayChange} />
<IconButton tooltip="相机复位" aria-label="相机复位" onClick={onResetCamera}>
<RotateCcw className="h-3.5 w-3.5" />
</IconButton>
</div>
);
}
@@ -1,6 +1,72 @@
import {CirclePause,CirclePlay,Mouse,MousePointer2} from 'lucide-react';
import type {InteractionMode,ViewerSelection} from '../../viewer/MuJoCoViewer';
import {Badge,Kbd} from '../../components/ui';
const labels:Record<InteractionMode,string>={select:'选择',joint:'关节拖动',force:'外力施加'};
const primaryGestures:Record<InteractionMode,string>={select:'左键旋转',joint:'左键拖动关节',force:'左键拖动施力'};
export function ViewportHUD({paused,mode,selection,ready}:{paused:boolean;mode:InteractionMode;selection:ViewerSelection|null;ready:boolean}){if(!ready)return null;return <><div aria-label="视口状态" className="pointer-events-none absolute left-3 top-3 z-10 flex max-w-[70%] flex-wrap items-center gap-1.5"><Badge tone={paused?'neutral':'success'}>{paused?<CirclePause className="h-3 w-3"/>:<CirclePlay className="h-3 w-3"/>}{paused?'已暂停':'仿真中'}</Badge><Badge tone="accent"><MousePointer2 className="h-3 w-3"/>{labels[mode]}</Badge>{selection&&<Badge title={`body ${selection.bodyId} · geom ${selection.geomId}`}>{selection.bodyName}</Badge>}</div><div aria-label="视口操作提示" className="pointer-events-none absolute bottom-3 left-1/2 z-10 hidden -translate-x-1/2 items-center gap-2 whitespace-nowrap rounded-full border border-border-strong bg-panel px-3 py-1.5 text-[10px] text-text-secondary shadow-xl lg:flex"><Mouse aria-hidden="true" className="h-3 w-3 text-text-secondary"/><span>{primaryGestures[mode]}</span><span aria-hidden="true" className="text-border-strong">·</span><span>右键平移</span><span aria-hidden="true" className="text-border-strong">·</span><span>滚轮缩放</span>{mode!=='select'&&<><span aria-hidden="true" className="text-border-strong">·</span><Kbd>1</Kbd><span>旋转视角</span></>}</div></>;}
import { CirclePause, CirclePlay, Mouse, MousePointer2 } from 'lucide-react';
import type { InteractionMode, ViewerSelection } from '../../viewer/MuJoCoViewer';
import { Badge, Kbd } from '../../components/ui';
const labels: Record<InteractionMode, string> = {
select: '选择',
joint: '关节拖动',
force: '外力施加',
};
const primaryGestures: Record<InteractionMode, string> = {
select: '左键旋转',
joint: '左键拖动关节',
force: '左键拖动施力',
};
export function ViewportHUD({
paused,
mode,
selection,
ready,
}: {
paused: boolean;
mode: InteractionMode;
selection: ViewerSelection | null;
ready: boolean;
}) {
if (!ready) return null;
return (
<>
<div
aria-label="视口状态"
className="pointer-events-none absolute left-3 top-3 z-10 flex max-w-[70%] flex-wrap items-center gap-1.5"
>
<Badge tone={paused ? 'neutral' : 'success'}>
{paused ? <CirclePause className="h-3 w-3" /> : <CirclePlay className="h-3 w-3" />}
{paused ? '已暂停' : '仿真中'}
</Badge>
<Badge tone="accent">
<MousePointer2 className="h-3 w-3" />
{labels[mode]}
</Badge>
{selection && (
<Badge title={`body ${selection.bodyId} · geom ${selection.geomId}`}>
{selection.bodyName}
</Badge>
)}
</div>
<div
aria-label="视口操作提示"
className="pointer-events-none absolute bottom-3 left-1/2 z-10 hidden -translate-x-1/2 items-center gap-2 whitespace-nowrap rounded-full border border-border-strong bg-panel px-3 py-1.5 text-[10px] text-text-secondary shadow-xl lg:flex"
>
<Mouse aria-hidden="true" className="h-3 w-3 text-text-secondary" />
<span>{primaryGestures[mode]}</span>
<span aria-hidden="true" className="text-border-strong">
·
</span>
<span>右键平移</span>
<span aria-hidden="true" className="text-border-strong">
·
</span>
<span>滚轮缩放</span>
{mode !== 'select' && (
<>
<span aria-hidden="true" className="text-border-strong">
·
</span>
<Kbd>1</Kbd>
<span>旋转视角</span>
</>
)}
</div>
</>
);
}
@@ -1,4 +1,51 @@
import {fireEvent,render,screen} from '@testing-library/react';
import {WorkbenchHeader} from './WorkbenchHeader';
const fn=()=>{};
describe('WorkbenchHeader',()=>{it('透传仿真动作且保留可访问名称',()=>{const pause=vi.fn(),step=vi.fn(),reset=vi.fn(),speed=vi.fn();render(<WorkbenchHeader paused ready speed={1} theme="dark" loading={false} leftOpen rightOpen fullscreen={false} center={<span>工具</span>} onFiles={fn} onFolder={fn} onTogglePause={pause} onStep={step} onReset={reset} onSpeed={speed} onToggleLeft={fn} onToggleRight={fn} onToggleTheme={fn} onHelp={fn} onCommands={fn} onToggleFullscreen={fn}/>);fireEvent.click(screen.getByRole('button',{name:'▶ 播放'}));fireEvent.click(screen.getByRole('button',{name:'单步'}));fireEvent.click(screen.getByRole('button',{name:'重置'}));fireEvent.change(screen.getByLabelText('仿真速度'),{target:{value:'2'}});expect(pause).toHaveBeenCalledTimes(1);expect(step).toHaveBeenCalledTimes(1);expect(reset).toHaveBeenCalledTimes(1);expect(speed).toHaveBeenCalledWith(2);expect(screen.getByRole('button',{name:'切换到白天主题'})).toBeInTheDocument();expect(screen.getByRole('button',{name:'隐藏工程面板'})).toHaveAttribute('aria-expanded','true');expect(screen.getByRole('button',{name:'打开命令面板'})).toBeInTheDocument();expect(screen.getByRole('button',{name:'进入全屏'})).toBeInTheDocument();});});
import { fireEvent, render, screen } from '@testing-library/react';
import { WorkbenchHeader } from './WorkbenchHeader';
const fn = () => {};
describe('WorkbenchHeader', () => {
it('透传仿真动作且保留可访问名称', () => {
const pause = vi.fn(),
step = vi.fn(),
reset = vi.fn(),
speed = vi.fn();
render(
<WorkbenchHeader
paused
ready
speed={1}
theme="dark"
loading={false}
leftOpen
rightOpen
fullscreen={false}
center={<span>工具</span>}
onFiles={fn}
onFolder={fn}
onTogglePause={pause}
onStep={step}
onReset={reset}
onSpeed={speed}
onToggleLeft={fn}
onToggleRight={fn}
onToggleTheme={fn}
onHelp={fn}
onCommands={fn}
onToggleFullscreen={fn}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '▶ 播放' }));
fireEvent.click(screen.getByRole('button', { name: '单步' }));
fireEvent.click(screen.getByRole('button', { name: '重置' }));
fireEvent.change(screen.getByLabelText('仿真速度'), { target: { value: '2' } });
expect(pause).toHaveBeenCalledTimes(1);
expect(step).toHaveBeenCalledTimes(1);
expect(reset).toHaveBeenCalledTimes(1);
expect(speed).toHaveBeenCalledWith(2);
expect(screen.getByRole('button', { name: '切换到白天主题' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: '隐藏工程面板' })).toHaveAttribute(
'aria-expanded',
'true',
);
expect(screen.getByRole('button', { name: '打开命令面板' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: '进入全屏' })).toBeInTheDocument();
});
});
@@ -1,6 +1,209 @@
import type {ChangeEvent,ReactNode} from 'react';
import {CircleHelp,Code2,Expand,FolderOpen,Minimize,PanelLeft,PanelRight,Pause,Play,RotateCcw,Search,StepForward,Sun,Moon,Upload} from 'lucide-react';
import {Button,IconButton,Select} from '../../components/ui';
const fileActionClass='inline-flex h-7 cursor-pointer items-center gap-1.5 rounded-md border border-border bg-surface px-2 text-xs font-medium text-text-primary transition-colors hover:bg-element-hover focus-within:ring-2 focus-within:ring-accent/30';
const fileActionLabelClass='hidden sm:inline';
export function WorkbenchHeader({paused,ready,speed,theme,loading,leftOpen,rightOpen,fullscreen,hasProject,center,endActions,compactMenu,onFiles,onFolder,onOpenSource,onTogglePause,onStep,onReset,onSpeed,onToggleLeft,onToggleRight,onToggleTheme,onHelp,onCommands,onToggleFullscreen}:{paused:boolean;ready:boolean;speed:number;theme:'light'|'dark';loading:boolean;leftOpen:boolean;rightOpen:boolean;fullscreen:boolean;hasProject?:boolean;center:ReactNode;endActions?:ReactNode;compactMenu?:ReactNode;onFiles:(event:ChangeEvent<HTMLInputElement>)=>void;onFolder:(event:ChangeEvent<HTMLInputElement>)=>void;onOpenSource?:()=>void;onTogglePause:()=>void;onStep:()=>void;onReset:()=>void;onSpeed:(value:number)=>void;onToggleLeft:()=>void;onToggleRight:()=>void;onToggleTheme:()=>void;onHelp:()=>void;onCommands:()=>void;onToggleFullscreen:()=>void}){return <header className="relative z-40 grid h-10 shrink-0 grid-cols-[minmax(0,1fr)_auto_minmax(max-content,1fr)] items-center gap-2 border-b border-border bg-panel px-2.5"><div className="flex min-w-0 items-center gap-1"><h1 className="mr-2 hidden truncate border-r border-border pr-3 text-sm font-semibold text-text-primary xl:block">MuJoCo Web 仿真平台</h1><label aria-disabled={loading} className={`${fileActionClass} ${loading?'pointer-events-none opacity-40':''}`}><Upload className="h-3.5 w-3.5"/><span className={fileActionLabelClass}>打开文件</span><input id="mujoco-project-files" aria-label="打开文件" className="sr-only" type="file" disabled={loading} multiple accept=".xml,.urdf,.zip,.obj,.stl,.dae,.msh,.png,.jpg,.jpeg,.bmp,.tga,.hdr" onChange={onFiles}/></label><label aria-disabled={loading} className={`${fileActionClass} ${loading?'pointer-events-none opacity-40':''}`}><FolderOpen className="h-3.5 w-3.5"/><span className={fileActionLabelClass}>文件夹</span><input id="mujoco-project-folder" aria-label="打开文件夹" className="sr-only" type="file" disabled={loading} multiple {...({webkitdirectory:'',directory:''} as object)} onChange={onFolder}/></label><IconButton tooltip="查看和修改缓存源代码" aria-label="源代码" disabled={!hasProject||loading} onClick={onOpenSource}><Code2 className="h-4 w-4"/></IconButton></div><div className="flex items-center justify-center">{center}</div><div className="flex min-w-0 items-center justify-end gap-0.5"><Button variant="ghost" onClick={onTogglePause} disabled={!ready} aria-label={paused?'▶ 播放':'⏸ 暂停'} icon={paused?<Play className="h-3.5 w-3.5"/>:<Pause className="h-3.5 w-3.5"/>}>{paused?'播放':'暂停'}</Button><IconButton tooltip="单步" aria-label="单步" onClick={onStep} disabled={!ready||!paused}><StepForward className="h-3.5 w-3.5"/></IconButton><IconButton tooltip="重置" aria-label="重置" onClick={onReset} disabled={!ready}><RotateCcw className="h-3.5 w-3.5"/></IconButton><Select aria-label="仿真速度" value={speed} disabled={loading} onChange={event=>onSpeed(Number(event.target.value))} className="w-[70px]"><option value={.25}>0.25×</option><option value={.5}>0.5×</option><option value={1}>1×</option><option value={2}>2×</option><option value={4}>4×</option></Select><span className="mx-1 h-5 border-l border-border"/><IconButton tooltip={leftOpen?'隐藏工程面板':'显示工程面板'} aria-label={leftOpen?'隐藏工程面板':'显示工程面板'} aria-expanded={leftOpen} onClick={onToggleLeft}><PanelLeft className="h-4 w-4"/></IconButton><IconButton tooltip={rightOpen?'隐藏属性面板':'显示属性面板'} aria-label={rightOpen?'隐藏属性面板':'显示属性面板'} aria-expanded={rightOpen} onClick={onToggleRight}><PanelRight className="h-4 w-4"/></IconButton>{endActions}{compactMenu}<IconButton className="hidden xl:inline-flex" tooltip="命令面板(Ctrl+K)" aria-label="打开命令面板" onClick={onCommands}><Search className="h-4 w-4"/></IconButton><IconButton className="hidden xl:inline-flex" tooltip={fullscreen?'退出全屏':'进入全屏'} aria-label={fullscreen?'退出全屏':'进入全屏'} onClick={onToggleFullscreen}>{fullscreen?<Minimize className="h-4 w-4"/>:<Expand className="h-4 w-4"/>}</IconButton><IconButton className="hidden xl:inline-flex" tooltip="快捷键帮助" aria-label="快捷键帮助" onClick={onHelp}><CircleHelp className="h-4 w-4"/></IconButton><IconButton className="hidden xl:inline-flex" tooltip={theme==='dark'?'切换到白天主题':'切换到黑夜主题'} aria-label={theme==='dark'?'切换到白天主题':'切换到黑夜主题'} onClick={onToggleTheme}>{theme==='dark'?<Sun className="h-4 w-4"/>:<Moon className="h-4 w-4"/>}</IconButton></div></header>;}
import type { ChangeEvent, ReactNode } from 'react';
import {
CircleHelp,
Code2,
Expand,
FolderOpen,
Minimize,
PanelLeft,
PanelRight,
Pause,
Play,
RotateCcw,
Search,
StepForward,
Sun,
Moon,
Upload,
} from 'lucide-react';
import { Button, IconButton, Select } from '../../components/ui';
const fileActionClass =
'inline-flex h-7 cursor-pointer items-center gap-1.5 rounded-md border border-border bg-surface px-2 text-xs font-medium text-text-primary transition-colors hover:bg-element-hover focus-within:ring-2 focus-within:ring-accent/30';
const fileActionLabelClass = 'hidden sm:inline';
export function WorkbenchHeader({
paused,
ready,
speed,
theme,
loading,
leftOpen,
rightOpen,
fullscreen,
hasProject,
center,
endActions,
compactMenu,
onFiles,
onFolder,
onOpenSource,
onTogglePause,
onStep,
onReset,
onSpeed,
onToggleLeft,
onToggleRight,
onToggleTheme,
onHelp,
onCommands,
onToggleFullscreen,
}: {
paused: boolean;
ready: boolean;
speed: number;
theme: 'light' | 'dark';
loading: boolean;
leftOpen: boolean;
rightOpen: boolean;
fullscreen: boolean;
hasProject?: boolean;
center: ReactNode;
endActions?: ReactNode;
compactMenu?: ReactNode;
onFiles: (event: ChangeEvent<HTMLInputElement>) => void;
onFolder: (event: ChangeEvent<HTMLInputElement>) => void;
onOpenSource?: () => void;
onTogglePause: () => void;
onStep: () => void;
onReset: () => void;
onSpeed: (value: number) => void;
onToggleLeft: () => void;
onToggleRight: () => void;
onToggleTheme: () => void;
onHelp: () => void;
onCommands: () => void;
onToggleFullscreen: () => void;
}) {
return (
<header className="relative z-40 grid h-10 shrink-0 grid-cols-[minmax(0,1fr)_auto_minmax(max-content,1fr)] items-center gap-2 border-b border-border bg-panel px-2.5">
<div className="flex min-w-0 items-center gap-1">
<h1 className="mr-2 hidden truncate border-r border-border pr-3 text-sm font-semibold text-text-primary xl:block">
MuJoCo Web 仿真平台
</h1>
<label
aria-disabled={loading}
className={`${fileActionClass} ${loading ? 'pointer-events-none opacity-40' : ''}`}
>
<Upload className="h-3.5 w-3.5" />
<span className={fileActionLabelClass}>打开文件</span>
<input
id="mujoco-project-files"
aria-label="打开文件"
className="sr-only"
type="file"
disabled={loading}
multiple
accept=".xml,.urdf,.zip,.obj,.stl,.dae,.msh,.png,.jpg,.jpeg,.bmp,.tga,.hdr"
onChange={onFiles}
/>
</label>
<label
aria-disabled={loading}
className={`${fileActionClass} ${loading ? 'pointer-events-none opacity-40' : ''}`}
>
<FolderOpen className="h-3.5 w-3.5" />
<span className={fileActionLabelClass}>文件夹</span>
<input
id="mujoco-project-folder"
aria-label="打开文件夹"
className="sr-only"
type="file"
disabled={loading}
multiple
{...({ webkitdirectory: '', directory: '' } as object)}
onChange={onFolder}
/>
</label>
<IconButton
tooltip="查看和修改缓存源代码"
aria-label="源代码"
disabled={!hasProject || loading}
onClick={onOpenSource}
>
<Code2 className="h-4 w-4" />
</IconButton>
</div>
<div className="flex items-center justify-center">{center}</div>
<div className="flex min-w-0 items-center justify-end gap-0.5">
<Button
variant="ghost"
onClick={onTogglePause}
disabled={!ready}
aria-label={paused ? '▶ 播放' : '⏸ 暂停'}
icon={paused ? <Play className="h-3.5 w-3.5" /> : <Pause className="h-3.5 w-3.5" />}
>
{paused ? '播放' : '暂停'}
</Button>
<IconButton tooltip="单步" aria-label="单步" onClick={onStep} disabled={!ready || !paused}>
<StepForward className="h-3.5 w-3.5" />
</IconButton>
<IconButton tooltip="重置" aria-label="重置" onClick={onReset} disabled={!ready}>
<RotateCcw className="h-3.5 w-3.5" />
</IconButton>
<Select
aria-label="仿真速度"
value={speed}
disabled={loading}
onChange={(event) => onSpeed(Number(event.target.value))}
className="w-[70px]"
>
<option value={0.25}>0.25×</option>
<option value={0.5}>0.5×</option>
<option value={1}>1×</option>
<option value={2}>2×</option>
<option value={4}>4×</option>
</Select>
<span className="mx-1 h-5 border-l border-border" />
<IconButton
tooltip={leftOpen ? '隐藏工程面板' : '显示工程面板'}
aria-label={leftOpen ? '隐藏工程面板' : '显示工程面板'}
aria-expanded={leftOpen}
onClick={onToggleLeft}
>
<PanelLeft className="h-4 w-4" />
</IconButton>
<IconButton
tooltip={rightOpen ? '隐藏属性面板' : '显示属性面板'}
aria-label={rightOpen ? '隐藏属性面板' : '显示属性面板'}
aria-expanded={rightOpen}
onClick={onToggleRight}
>
<PanelRight className="h-4 w-4" />
</IconButton>
{endActions}
{compactMenu}
<IconButton
className="hidden xl:inline-flex"
tooltip="命令面板(Ctrl+K)"
aria-label="打开命令面板"
onClick={onCommands}
>
<Search className="h-4 w-4" />
</IconButton>
<IconButton
className="hidden xl:inline-flex"
tooltip={fullscreen ? '退出全屏' : '进入全屏'}
aria-label={fullscreen ? '退出全屏' : '进入全屏'}
onClick={onToggleFullscreen}
>
{fullscreen ? <Minimize className="h-4 w-4" /> : <Expand className="h-4 w-4" />}
</IconButton>
<IconButton
className="hidden xl:inline-flex"
tooltip="快捷键帮助"
aria-label="快捷键帮助"
onClick={onHelp}
>
<CircleHelp className="h-4 w-4" />
</IconButton>
<IconButton
className="hidden xl:inline-flex"
tooltip={theme === 'dark' ? '切换到白天主题' : '切换到黑夜主题'}
aria-label={theme === 'dark' ? '切换到白天主题' : '切换到黑夜主题'}
onClick={onToggleTheme}
>
{theme === 'dark' ? <Sun className="h-4 w-4" /> : <Moon className="h-4 w-4" />}
</IconButton>
</div>
</header>
);
}
@@ -1,6 +1,129 @@
import {Box,FolderOpen,LoaderCircle,PlayCircle,Settings2,ShieldCheck,Upload,UploadCloud} from 'lucide-react';
import {ProgressBar,Skeleton} from '../../components/ui';
export interface ImportProgress{label:string;value:number;}
const workflow=[{label:'导入',detail:'URDF、MJCF 或工程包',icon:UploadCloud},{label:'检查与配置',detail:'结构、驱动器与传感器',icon:Settings2},{label:'运行与调试',detail:'控制、策略与物理状态',icon:PlayCircle}];
export function EmptyWorkspace({compact=false}:{compact?:boolean}){if(compact)return <div className="m-3 rounded-xl border border-dashed border-border-strong bg-panel/90 p-4 text-center shadow-sm"><span className="mx-auto mb-3 grid h-10 w-10 place-items-center rounded-xl bg-accent-soft text-accent"><Box className="h-5 w-5"/></span><p className="text-sm font-semibold text-text-primary">拖放模型工程到此处</p><p className="mt-1 text-xs text-text-tertiary">支持 MJCF/XML、URDF、文件夹和 ZIP</p></div>;return <section aria-label="导入模型工程" className="w-[min(560px,calc(100vw-32px))] rounded-2xl border border-border-strong bg-panel/90 p-6 text-center shadow-2xl backdrop-blur-md"><span className="mx-auto mb-3 grid h-11 w-11 place-items-center rounded-xl bg-accent-soft text-accent"><UploadCloud className="h-5 w-5"/></span><h2 className="text-base font-semibold text-text-primary">拖放模型工程到此处</h2><p className="mt-1 text-xs text-text-tertiary">支持 MJCF/XML、URDF、文件夹和 ZIP</p><div className="mt-4 flex items-center justify-center gap-2"><label htmlFor="mujoco-project-files" className="inline-flex h-8 cursor-pointer items-center gap-1.5 rounded-md bg-accent px-3 text-xs font-medium text-white transition-colors hover:bg-accent-hover focus-within:ring-2 focus-within:ring-accent/40"><Upload className="h-3.5 w-3.5"/>选择文件</label><label htmlFor="mujoco-project-folder" className="inline-flex h-8 cursor-pointer items-center gap-1.5 rounded-md border border-border bg-surface px-3 text-xs font-medium text-text-primary transition-colors hover:bg-element-hover focus-within:ring-2 focus-within:ring-accent/40"><FolderOpen className="h-3.5 w-3.5"/>选择文件夹</label></div><ol aria-label="仿真工作流程" className="mt-5 hidden grid-cols-3 gap-2 border-t border-border pt-4 sm:grid">{workflow.map((item,index)=><li key={item.label} className="rounded-lg bg-surface px-3 py-2.5 text-left"><div className="flex items-center gap-2"><span className="technical-value text-[10px] font-semibold text-accent">0{index+1}</span><item.icon aria-hidden="true" className="h-3.5 w-3.5 text-text-secondary"/><span className="text-xs font-medium text-text-primary">{item.label}</span></div><span className="mt-1 block text-[10px] leading-4 text-text-tertiary">{item.detail}</span></li>)}</ol><p className="mt-3 flex items-center justify-center gap-1.5 text-[10px] text-text-tertiary"><ShieldCheck aria-hidden="true" className="h-3 w-3 text-success"/>模型与资源仅在当前浏览器会话中处理</p></section>;}
export function WorkspaceOverlays({loading,hasSnapshot,progress}:{loading:boolean;hasSnapshot:boolean;progress?:ImportProgress}){return <>{!hasSnapshot&&!loading&&<div className="pointer-events-none absolute inset-0 grid place-items-center p-4"><div className="pointer-events-auto"><EmptyWorkspace/></div></div>}{loading&&<div role="status" aria-live="polite" aria-label={progress?.label??'正在加载 MuJoCo 与模型'} className="absolute inset-0 z-20 grid place-items-center bg-app/75 backdrop-blur-sm"><div className="w-80 rounded-xl border border-border bg-panel px-5 py-4 text-sm font-medium text-text-primary shadow-xl"><div className="flex items-center gap-3"><LoaderCircle aria-hidden="true" className="h-5 w-5 animate-spin text-accent"/>正在加载 MuJoCo 与模型…</div>{progress?<div className="mt-4"><ProgressBar value={progress.value} label={progress.label}/></div>:<div className="mt-4 space-y-2"><Skeleton className="h-2.5 w-full"/><Skeleton className="h-2.5 w-4/5"/></div>}</div></div>}</>;}
import {
Box,
FolderOpen,
LoaderCircle,
PlayCircle,
Settings2,
ShieldCheck,
Upload,
UploadCloud,
} from 'lucide-react';
import { ProgressBar, Skeleton } from '../../components/ui';
export interface ImportProgress {
label: string;
value: number;
}
const workflow = [
{ label: '导入', detail: 'URDF、MJCF 或工程包', icon: UploadCloud },
{ label: '检查与配置', detail: '结构、驱动器与传感器', icon: Settings2 },
{ label: '运行与调试', detail: '控制、策略与物理状态', icon: PlayCircle },
];
export function EmptyWorkspace({ compact = false }: { compact?: boolean }) {
if (compact)
return (
<div className="m-3 rounded-xl border border-dashed border-border-strong bg-panel/90 p-4 text-center shadow-sm">
<span className="mx-auto mb-3 grid h-10 w-10 place-items-center rounded-xl bg-accent-soft text-accent">
<Box className="h-5 w-5" />
</span>
<p className="text-sm font-semibold text-text-primary">拖放模型工程到此处</p>
<p className="mt-1 text-xs text-text-tertiary">支持 MJCF/XML、URDF、文件夹和 ZIP</p>
</div>
);
return (
<section
aria-label="导入模型工程"
className="w-[min(560px,calc(100vw-32px))] rounded-2xl border border-border-strong bg-panel/90 p-6 text-center shadow-2xl backdrop-blur-md"
>
<span className="mx-auto mb-3 grid h-11 w-11 place-items-center rounded-xl bg-accent-soft text-accent">
<UploadCloud className="h-5 w-5" />
</span>
<h2 className="text-base font-semibold text-text-primary">拖放模型工程到此处</h2>
<p className="mt-1 text-xs text-text-tertiary">支持 MJCF/XML、URDF、文件夹和 ZIP</p>
<div className="mt-4 flex items-center justify-center gap-2">
<label
htmlFor="mujoco-project-files"
className="inline-flex h-8 cursor-pointer items-center gap-1.5 rounded-md bg-accent px-3 text-xs font-medium text-white transition-colors hover:bg-accent-hover focus-within:ring-2 focus-within:ring-accent/40"
>
<Upload className="h-3.5 w-3.5" />
选择文件
</label>
<label
htmlFor="mujoco-project-folder"
className="inline-flex h-8 cursor-pointer items-center gap-1.5 rounded-md border border-border bg-surface px-3 text-xs font-medium text-text-primary transition-colors hover:bg-element-hover focus-within:ring-2 focus-within:ring-accent/40"
>
<FolderOpen className="h-3.5 w-3.5" />
选择文件夹
</label>
</div>
<ol
aria-label="仿真工作流程"
className="mt-5 hidden grid-cols-3 gap-2 border-t border-border pt-4 sm:grid"
>
{workflow.map((item, index) => (
<li key={item.label} className="rounded-lg bg-surface px-3 py-2.5 text-left">
<div className="flex items-center gap-2">
<span className="technical-value text-[10px] font-semibold text-accent">
0{index + 1}
</span>
<item.icon aria-hidden="true" className="h-3.5 w-3.5 text-text-secondary" />
<span className="text-xs font-medium text-text-primary">{item.label}</span>
</div>
<span className="mt-1 block text-[10px] leading-4 text-text-tertiary">
{item.detail}
</span>
</li>
))}
</ol>
<p className="mt-3 flex items-center justify-center gap-1.5 text-[10px] text-text-tertiary">
<ShieldCheck aria-hidden="true" className="h-3 w-3 text-success" />
模型与资源仅在当前浏览器会话中处理
</p>
</section>
);
}
export function WorkspaceOverlays({
loading,
hasSnapshot,
progress,
}: {
loading: boolean;
hasSnapshot: boolean;
progress?: ImportProgress;
}) {
return (
<>
{!hasSnapshot && !loading && (
<div className="pointer-events-none absolute inset-0 grid place-items-center p-4">
<div className="pointer-events-auto">
<EmptyWorkspace />
</div>
</div>
)}
{loading && (
<div
role="status"
aria-live="polite"
aria-label={progress?.label ?? '正在加载 MuJoCo 与模型'}
className="absolute inset-0 z-20 grid place-items-center bg-app/75 backdrop-blur-sm"
>
<div className="w-80 rounded-xl border border-border bg-panel px-5 py-4 text-sm font-medium text-text-primary shadow-xl">
<div className="flex items-center gap-3">
<LoaderCircle aria-hidden="true" className="h-5 w-5 animate-spin text-accent" />
正在加载 MuJoCo 与模型…
</div>
{progress ? (
<div className="mt-4">
<ProgressBar value={progress.value} label={progress.label} />
</div>
) : (
<div className="mt-4 space-y-2">
<Skeleton className="h-2.5 w-full" />
<Skeleton className="h-2.5 w-4/5" />
</div>
)}
</div>
</div>
)}
</>
);
}
@@ -1,8 +1,8 @@
import {loader} from '@monaco-editor/react';
import { loader } from '@monaco-editor/react';
import * as monaco from 'monaco-editor/editor/editor.api';
import 'monaco-editor/languages/definitions/xml/register';
import EditorWorker from 'monaco-editor/editor/editor.worker?worker';
type MonacoGlobal=typeof globalThis&{MonacoEnvironment?:{getWorker?:()=>Worker}};
(globalThis as MonacoGlobal).MonacoEnvironment={getWorker:()=>new EditorWorker()};
loader.config({monaco});
type MonacoGlobal = typeof globalThis & { MonacoEnvironment?: { getWorker?: () => Worker } };
(globalThis as MonacoGlobal).MonacoEnvironment = { getWorker: () => new EditorWorker() };
loader.config({ monaco });
+27 -2
View File
@@ -1,2 +1,27 @@
import type {ReactNode} from 'react';
export function Badge({children,tone='neutral',className='',title}:{children:ReactNode;tone?:'neutral'|'accent'|'success'|'warning';className?:string;title?:string}){const toneClass={neutral:'border-border bg-surface text-text-secondary',accent:'border-success-border bg-accent-soft text-accent',success:'border-success-border bg-success-soft text-success',warning:'border-warning-border bg-warning-soft text-warning'}[tone];return <span title={title} className={`inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-[10px] font-medium ${toneClass} ${className}`}>{children}</span>;}
import type { ReactNode } from 'react';
export function Badge({
children,
tone = 'neutral',
className = '',
title,
}: {
children: ReactNode;
tone?: 'neutral' | 'accent' | 'success' | 'warning';
className?: string;
title?: string;
}) {
const toneClass = {
neutral: 'border-border bg-surface text-text-secondary',
accent: 'border-success-border bg-accent-soft text-accent',
success: 'border-success-border bg-success-soft text-success',
warning: 'border-warning-border bg-warning-soft text-warning',
}[tone];
return (
<span
title={title}
className={`inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-[10px] font-medium ${toneClass} ${className}`}
>
{children}
</span>
);
}
+39 -13
View File
@@ -1,18 +1,44 @@
import type {ButtonHTMLAttributes,ReactNode} from 'react';
import type { ButtonHTMLAttributes, ReactNode } from 'react';
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement>{
variant?:'primary'|'secondary'|'ghost'|'danger';
size?:'sm'|'md'|'icon';
icon?:ReactNode;
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: 'primary' | 'secondary' | 'ghost' | 'danger';
size?: 'sm' | 'md' | 'icon';
icon?: ReactNode;
}
export function Button({variant='secondary',size='sm',icon,className='',children,type='button',...props}:ButtonProps){
const variants={
primary:'border-transparent bg-accent text-white hover:bg-accent-hover',
secondary:'border-border bg-surface text-text-primary hover:bg-element-hover',
ghost:'border-transparent bg-transparent text-text-secondary hover:bg-element-hover hover:text-text-primary',
danger:'border-danger-border bg-danger-soft text-danger hover:bg-danger hover:text-white',
export function Button({
variant = 'secondary',
size = 'sm',
icon,
className = '',
children,
type = 'button',
...props
}: ButtonProps) {
const variants = {
primary: 'border-transparent bg-accent text-white hover:bg-accent-hover',
secondary: 'border-border bg-surface text-text-primary hover:bg-element-hover',
ghost:
'border-transparent bg-transparent text-text-secondary hover:bg-element-hover hover:text-text-primary',
danger: 'border-danger-border bg-danger-soft text-danger hover:bg-danger hover:text-white',
};
const sizes={sm:'h-7 gap-1.5 rounded-md px-2 text-xs',md:'h-8 gap-2 rounded-md px-3 text-sm',icon:'h-7 w-7 rounded-md p-0'};
return <button type={type} className={`inline-flex shrink-0 select-none items-center justify-center border font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/30 disabled:cursor-not-allowed disabled:opacity-40 ${variants[variant]} ${sizes[size]} ${className}`.trim()} {...props}>{icon&&<span aria-hidden="true" className="flex items-center">{icon}</span>}{children}</button>;
const sizes = {
sm: 'h-7 gap-1.5 rounded-md px-2 text-xs',
md: 'h-8 gap-2 rounded-md px-3 text-sm',
icon: 'h-7 w-7 rounded-md p-0',
};
return (
<button
type={type}
className={`inline-flex shrink-0 select-none items-center justify-center border font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/30 disabled:cursor-not-allowed disabled:opacity-40 ${variants[variant]} ${sizes[size]} ${className}`.trim()}
{...props}
>
{icon && (
<span aria-hidden="true" className="flex items-center">
{icon}
</span>
)}
{children}
</button>
);
}
@@ -1,3 +1,25 @@
import {fireEvent,render,screen} from '@testing-library/react';
import {CollapsibleSection} from './CollapsibleSection';
describe('CollapsibleSection',()=>{it('遵循默认折叠状态并可展开',()=>{render(<CollapsibleSection title="低频设置" defaultOpen={false}><span>内容</span></CollapsibleSection>);const trigger=screen.getByRole('button',{name:'低频设置'});expect(trigger).toHaveAttribute('aria-expanded','false');expect(screen.queryByText('内容')).not.toBeInTheDocument();fireEvent.click(trigger);expect(trigger).toHaveAttribute('aria-expanded','true');expect(screen.getByText('内容')).toBeVisible();});it('forceOpen 时保持内容可见',()=>{render(<CollapsibleSection title="警告" defaultOpen={false} forceOpen><span>错误详情</span></CollapsibleSection>);expect(screen.getByText('错误详情')).toBeVisible();});});
import { fireEvent, render, screen } from '@testing-library/react';
import { CollapsibleSection } from './CollapsibleSection';
describe('CollapsibleSection', () => {
it('遵循默认折叠状态并可展开', () => {
render(
<CollapsibleSection title="低频设置" defaultOpen={false}>
<span>内容</span>
</CollapsibleSection>,
);
const trigger = screen.getByRole('button', { name: '低频设置' });
expect(trigger).toHaveAttribute('aria-expanded', 'false');
expect(screen.queryByText('内容')).not.toBeInTheDocument();
fireEvent.click(trigger);
expect(trigger).toHaveAttribute('aria-expanded', 'true');
expect(screen.getByText('内容')).toBeVisible();
});
it('forceOpen 时保持内容可见', () => {
render(
<CollapsibleSection title="警告" defaultOpen={false} forceOpen>
<span>错误详情</span>
</CollapsibleSection>,
);
expect(screen.getByText('错误详情')).toBeVisible();
});
});
@@ -1,11 +1,36 @@
import {useState,type ReactNode} from 'react';
import {ChevronRight} from 'lucide-react';
export function CollapsibleSection({title,children,defaultOpen=true,forceOpen=false,badge}:{title:string;children:ReactNode;defaultOpen?:boolean;forceOpen?:boolean;badge?:ReactNode}){
const [open,setOpen]=useState(defaultOpen);const expanded=forceOpen||open;
return <section className="border-b border-border">
<button type="button" aria-expanded={expanded} onClick={()=>setOpen(value=>!value)} className="flex h-9 w-full items-center gap-2 px-3 text-left text-xs font-semibold text-text-secondary transition-colors hover:bg-element-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-accent/30">
<ChevronRight aria-hidden="true" className={`h-3.5 w-3.5 transition-transform ${expanded?'rotate-90':''}`}/><span className="min-w-0 flex-1 truncate">{title}</span>{badge}
</button>
{expanded&&<div className="px-3 pb-3">{children}</div>}
</section>;
import { useState, type ReactNode } from 'react';
import { ChevronRight } from 'lucide-react';
export function CollapsibleSection({
title,
children,
defaultOpen = true,
forceOpen = false,
badge,
}: {
title: string;
children: ReactNode;
defaultOpen?: boolean;
forceOpen?: boolean;
badge?: ReactNode;
}) {
const [open, setOpen] = useState(defaultOpen);
const expanded = forceOpen || open;
return (
<section className="border-b border-border">
<button
type="button"
aria-expanded={expanded}
onClick={() => setOpen((value) => !value)}
className="flex h-9 w-full items-center gap-2 px-3 text-left text-xs font-semibold text-text-secondary transition-colors hover:bg-element-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-accent/30"
>
<ChevronRight
aria-hidden="true"
className={`h-3.5 w-3.5 transition-transform ${expanded ? 'rotate-90' : ''}`}
/>
<span className="min-w-0 flex-1 truncate">{title}</span>
{badge}
</button>
{expanded && <div className="px-3 pb-3">{children}</div>}
</section>
);
}
@@ -1,4 +1,40 @@
import type {ReactNode} from 'react';
import {Button} from './Button';
import {Dialog} from './Dialog';
export function ConfirmDialog({open,title,children,confirmLabel='确认',cancelLabel='取消',danger=false,onConfirm,onClose}:{open:boolean;title:string;children:ReactNode;confirmLabel?:string;cancelLabel?:string;danger?:boolean;onConfirm:()=>void;onClose:()=>void}){return <Dialog open={open} onClose={onClose} title={title} footer={<div className="flex justify-end gap-2"><Button onClick={onClose}>{cancelLabel}</Button><Button variant={danger?'danger':'primary'} onClick={onConfirm}>{confirmLabel}</Button></div>}>{children}</Dialog>;}
import type { ReactNode } from 'react';
import { Button } from './Button';
import { Dialog } from './Dialog';
export function ConfirmDialog({
open,
title,
children,
confirmLabel = '确认',
cancelLabel = '取消',
danger = false,
onConfirm,
onClose,
}: {
open: boolean;
title: string;
children: ReactNode;
confirmLabel?: string;
cancelLabel?: string;
danger?: boolean;
onConfirm: () => void;
onClose: () => void;
}) {
return (
<Dialog
open={open}
onClose={onClose}
title={title}
footer={
<div className="flex justify-end gap-2">
<Button onClick={onClose}>{cancelLabel}</Button>
<Button variant={danger ? 'danger' : 'primary'} onClick={onConfirm}>
{confirmLabel}
</Button>
</div>
}
>
{children}
</Dialog>
);
}
+31 -4
View File
@@ -1,4 +1,31 @@
import {useEffect,useState} from 'react';
import {Check,Copy} from 'lucide-react';
import {IconButton} from './IconButton';
export function CopyButton({value,label='复制'}:{value:string;label?:string}){const [copied,setCopied]=useState(false);useEffect(()=>{if(!copied)return;const timer=window.setTimeout(()=>setCopied(false),1200);return()=>window.clearTimeout(timer);},[copied]);return <IconButton aria-label={copied?'已复制':label} tooltip={copied?'已复制':label} onClick={()=>void (async()=>{try{if(!navigator.clipboard?.writeText)return;await navigator.clipboard.writeText(value);setCopied(true);}catch{setCopied(false);}})()} className="h-5 w-5">{copied?<Check className="h-3 w-3 text-success"/>:<Copy className="h-3 w-3"/>}</IconButton>;}
import { useEffect, useState } from 'react';
import { Check, Copy } from 'lucide-react';
import { IconButton } from './IconButton';
export function CopyButton({ value, label = '复制' }: { value: string; label?: string }) {
const [copied, setCopied] = useState(false);
useEffect(() => {
if (!copied) return;
const timer = window.setTimeout(() => setCopied(false), 1200);
return () => window.clearTimeout(timer);
}, [copied]);
return (
<IconButton
aria-label={copied ? '已复制' : label}
tooltip={copied ? '已复制' : label}
onClick={() =>
void (async () => {
try {
if (!navigator.clipboard?.writeText) return;
await navigator.clipboard.writeText(value);
setCopied(true);
} catch {
setCopied(false);
}
})()
}
className="h-5 w-5"
>
{copied ? <Check className="h-3 w-3 text-success" /> : <Copy className="h-3 w-3" />}
</IconButton>
);
}
+53 -5
View File
@@ -1,6 +1,54 @@
import {fireEvent,render,screen} from '@testing-library/react';
import {useState} from 'react';
import {Dialog} from './Dialog';
import { fireEvent, render, screen } from '@testing-library/react';
import { useState } from 'react';
import { Dialog } from './Dialog';
function Fixture(){const [open,setOpen]=useState(false);return <><button onClick={()=>setOpen(true)}>打开</button><Dialog open={open} onClose={()=>setOpen(false)} title="入口选择"><button>第一个</button><button>最后一个</button></Dialog></>;}
describe('Dialog',()=>{it('支持 Escape 关闭并恢复触发器焦点',()=>{render(<Fixture/>);const trigger=screen.getByRole('button',{name:'打开'});trigger.focus();fireEvent.click(trigger);expect(screen.getByRole('dialog')).toBeVisible();fireEvent.keyDown(document,{key:'Escape'});expect(screen.queryByRole('dialog')).not.toBeInTheDocument();expect(trigger).toHaveFocus();});it('将 Tab 焦点限制在弹窗内',()=>{render(<Fixture/>);fireEvent.click(screen.getByRole('button',{name:'打开'}));const first=screen.getByRole('button',{name:'关闭'}),last=screen.getByRole('button',{name:'最后一个'});last.focus();fireEvent.keyDown(document,{key:'Tab'});expect(first).toHaveFocus();first.focus();fireEvent.keyDown(document,{key:'Tab',shiftKey:true});expect(last).toHaveFocus();});it('全屏时将 Portal 挂载到全屏元素内部',()=>{const host=document.createElement('div');document.body.append(host);Object.defineProperty(document,'fullscreenElement',{configurable:true,value:host});const {unmount}=render(<Dialog open onClose={()=>{}} title="全屏弹窗">内容</Dialog>);expect(host).toContainElement(screen.getByRole('dialog'));unmount();Object.defineProperty(document,'fullscreenElement',{configurable:true,value:null});host.remove();});});
function Fixture() {
const [open, setOpen] = useState(false);
return (
<>
<button onClick={() => setOpen(true)}>打开</button>
<Dialog open={open} onClose={() => setOpen(false)} title="入口选择">
<button>第一个</button>
<button>最后一个</button>
</Dialog>
</>
);
}
describe('Dialog', () => {
it('支持 Escape 关闭并恢复触发器焦点', () => {
render(<Fixture />);
const trigger = screen.getByRole('button', { name: '打开' });
trigger.focus();
fireEvent.click(trigger);
expect(screen.getByRole('dialog')).toBeVisible();
fireEvent.keyDown(document, { key: 'Escape' });
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
expect(trigger).toHaveFocus();
});
it('将 Tab 焦点限制在弹窗内', () => {
render(<Fixture />);
fireEvent.click(screen.getByRole('button', { name: '打开' }));
const first = screen.getByRole('button', { name: '关闭' }),
last = screen.getByRole('button', { name: '最后一个' });
last.focus();
fireEvent.keyDown(document, { key: 'Tab' });
expect(first).toHaveFocus();
first.focus();
fireEvent.keyDown(document, { key: 'Tab', shiftKey: true });
expect(last).toHaveFocus();
});
it('全屏时将 Portal 挂载到全屏元素内部', () => {
const host = document.createElement('div');
document.body.append(host);
Object.defineProperty(document, 'fullscreenElement', { configurable: true, value: host });
const { unmount } = render(
<Dialog open onClose={() => {}} title="全屏弹窗">
内容
</Dialog>,
);
expect(host).toContainElement(screen.getByRole('dialog'));
unmount();
Object.defineProperty(document, 'fullscreenElement', { configurable: true, value: null });
host.remove();
});
});
+103 -12
View File
@@ -1,14 +1,105 @@
import {useEffect,useId,useRef,type ReactNode} from 'react';
import {createPortal} from 'react-dom';
import {X} from 'lucide-react';
import {IconButton} from './IconButton';
import { useEffect, useId, useRef, type ReactNode } from 'react';
import { createPortal } from 'react-dom';
import { X } from 'lucide-react';
import { IconButton } from './IconButton';
const FOCUSABLE='button:not([disabled]),a[href],input:not([disabled]),select:not([disabled]),textarea:not([disabled]),[tabindex]:not([tabindex="-1"])';
export function Dialog({open,onClose,title,children,footer,className='',closable=true}:{open:boolean;onClose:()=>void;title:string;children:ReactNode;footer?:ReactNode;className?:string;closable?:boolean}){
const ref=useRef<HTMLDivElement>(null),previous=useRef<HTMLElement|null>(null),onCloseRef=useRef(onClose),titleId=useId();
useEffect(()=>{onCloseRef.current=onClose;},[onClose]);
useEffect(()=>{if(!open)return;previous.current=document.activeElement instanceof HTMLElement?document.activeElement:null;ref.current?.focus();const key=(event:KeyboardEvent)=>{if(event.key==='Escape'&&closable){event.preventDefault();onCloseRef.current();return;}if(event.key!=='Tab'||!ref.current)return;const items=Array.from(ref.current.querySelectorAll<HTMLElement>(FOCUSABLE));if(!items.length){event.preventDefault();ref.current.focus();return;}const first=items[0],last=items.at(-1)!;if(event.shiftKey&&document.activeElement===first){event.preventDefault();last.focus();}else if(!event.shiftKey&&document.activeElement===last){event.preventDefault();first.focus();}};document.addEventListener('keydown',key);return()=>{document.removeEventListener('keydown',key);if(previous.current&&document.contains(previous.current))previous.current.focus();};},[open,closable]);
if(!open)return null;
const backdrop=<div aria-hidden="true" className="absolute inset-0 bg-black/55 backdrop-blur-[1px]" onMouseDown={event=>{if(closable&&event.target===event.currentTarget)onCloseRef.current();}}/>;
return createPortal(<div className="fixed inset-0 z-[400] grid place-items-center p-6" role="presentation">{backdrop}<div ref={ref} tabIndex={-1} role="dialog" aria-modal="true" aria-labelledby={titleId} className={`relative flex max-h-[80vh] w-full max-w-lg flex-col overflow-hidden rounded-xl border border-border bg-panel shadow-2xl focus:outline-none ${className}`}><header className="flex h-11 shrink-0 items-center justify-between border-b border-border bg-surface px-4"><h2 id={titleId} className="truncate text-sm font-semibold text-text-primary">{title}</h2>{closable&&<IconButton aria-label="关闭" tooltip="关闭" onClick={()=>onCloseRef.current()}><X className="h-4 w-4"/></IconButton>}</header><div className="overflow-y-auto p-4">{children}</div>{footer&&<footer className="border-t border-border bg-surface px-4 py-3">{footer}</footer>}</div></div>,document.fullscreenElement??document.body);
const FOCUSABLE =
'button:not([disabled]),a[href],input:not([disabled]),select:not([disabled]),textarea:not([disabled]),[tabindex]:not([tabindex="-1"])';
export function Dialog({
open,
onClose,
title,
children,
footer,
className = '',
closable = true,
}: {
open: boolean;
onClose: () => void;
title: string;
children: ReactNode;
footer?: ReactNode;
className?: string;
closable?: boolean;
}) {
const ref = useRef<HTMLDivElement>(null),
previous = useRef<HTMLElement | null>(null),
onCloseRef = useRef(onClose),
titleId = useId();
useEffect(() => {
onCloseRef.current = onClose;
}, [onClose]);
useEffect(() => {
if (!open) return;
previous.current =
document.activeElement instanceof HTMLElement ? document.activeElement : null;
ref.current?.focus();
const key = (event: KeyboardEvent) => {
if (event.key === 'Escape' && closable) {
event.preventDefault();
onCloseRef.current();
return;
}
if (event.key !== 'Tab' || !ref.current) return;
const items = Array.from(ref.current.querySelectorAll<HTMLElement>(FOCUSABLE));
if (!items.length) {
event.preventDefault();
ref.current.focus();
return;
}
const first = items[0],
last = items.at(-1)!;
if (event.shiftKey && document.activeElement === first) {
event.preventDefault();
last.focus();
} else if (!event.shiftKey && document.activeElement === last) {
event.preventDefault();
first.focus();
}
};
document.addEventListener('keydown', key);
return () => {
document.removeEventListener('keydown', key);
if (previous.current && document.contains(previous.current)) previous.current.focus();
};
}, [open, closable]);
if (!open) return null;
const backdrop = (
<div
aria-hidden="true"
className="absolute inset-0 bg-black/55 backdrop-blur-[1px]"
onMouseDown={(event) => {
if (closable && event.target === event.currentTarget) onCloseRef.current();
}}
/>
);
return createPortal(
<div className="fixed inset-0 z-[400] grid place-items-center p-6" role="presentation">
{backdrop}
<div
ref={ref}
tabIndex={-1}
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
className={`relative flex max-h-[80vh] w-full max-w-lg flex-col overflow-hidden rounded-xl border border-border bg-panel shadow-2xl focus:outline-none ${className}`}
>
<header className="flex h-11 shrink-0 items-center justify-between border-b border-border bg-surface px-4">
<h2 id={titleId} className="truncate text-sm font-semibold text-text-primary">
{title}
</h2>
{closable && (
<IconButton aria-label="关闭" tooltip="关闭" onClick={() => onCloseRef.current()}>
<X className="h-4 w-4" />
</IconButton>
)}
</header>
<div className="overflow-y-auto p-4">{children}</div>
{footer && (
<footer className="border-t border-border bg-surface px-4 py-3">{footer}</footer>
)}
</div>
</div>,
document.fullscreenElement ?? document.body,
);
}
@@ -1,7 +1,85 @@
import {useEffect,useRef,type ReactNode} from 'react';
import {MoreHorizontal} from 'lucide-react';
import {IconButton} from './IconButton';
import {Popover} from './Popover';
export interface DropdownMenuItem{id:string;label:string;icon?:ReactNode;disabled?:boolean;onSelect:()=>void;}
function MenuContent({items,close}:{items:DropdownMenuItem[];close:()=>void}){const refs=useRef<(HTMLButtonElement|null)[]>([]);useEffect(()=>{requestAnimationFrame(()=>refs.current.find(item=>item&&!item.disabled)?.focus());},[]);return <div role="menu" className="w-48 rounded-lg border border-border bg-surface-elevated p-1 shadow-xl" onKeyDown={event=>{const enabled=refs.current.filter((item):item is HTMLButtonElement=>Boolean(item&&!item.disabled)),index=enabled.indexOf(document.activeElement as HTMLButtonElement);if(event.key==='ArrowDown'){event.preventDefault();enabled[(index+1)%enabled.length]?.focus();}else if(event.key==='ArrowUp'){event.preventDefault();enabled[(index-1+enabled.length)%enabled.length]?.focus();}else if(event.key==='Home'){event.preventDefault();enabled[0]?.focus();}else if(event.key==='End'){event.preventDefault();enabled.at(-1)?.focus();}}}>{items.map((item,index)=><button key={item.id} ref={node=>{refs.current[index]=node;}} role="menuitem" disabled={item.disabled} onClick={()=>{close();item.onSelect();}} className="flex h-8 w-full items-center gap-2 rounded px-2 text-left text-xs text-text-secondary hover:bg-element-hover hover:text-text-primary focus:bg-element-hover focus:outline-none disabled:opacity-40"><span className="flex h-4 w-4 items-center justify-center">{item.icon}</span>{item.label}</button>)}</div>;}
export function DropdownMenu({items,label='更多操作',className=''}:{items:DropdownMenuItem[];label?:string;className?:string}){return <span className={className}><Popover label={label} trigger={({open,toggle})=><IconButton aria-label={label} aria-expanded={open} tooltip={label} onClick={toggle}><MoreHorizontal className="h-4 w-4"/></IconButton>}>{({close})=><MenuContent items={items} close={close}/>}</Popover></span>;}
import { useEffect, useRef, type ReactNode } from 'react';
import { MoreHorizontal } from 'lucide-react';
import { IconButton } from './IconButton';
import { Popover } from './Popover';
export interface DropdownMenuItem {
id: string;
label: string;
icon?: ReactNode;
disabled?: boolean;
onSelect: () => void;
}
function MenuContent({ items, close }: { items: DropdownMenuItem[]; close: () => void }) {
const refs = useRef<(HTMLButtonElement | null)[]>([]);
useEffect(() => {
requestAnimationFrame(() => refs.current.find((item) => item && !item.disabled)?.focus());
}, []);
return (
<div
role="menu"
className="w-48 rounded-lg border border-border bg-surface-elevated p-1 shadow-xl"
onKeyDown={(event) => {
const enabled = refs.current.filter((item): item is HTMLButtonElement =>
Boolean(item && !item.disabled),
),
index = enabled.indexOf(document.activeElement as HTMLButtonElement);
if (event.key === 'ArrowDown') {
event.preventDefault();
enabled[(index + 1) % enabled.length]?.focus();
} else if (event.key === 'ArrowUp') {
event.preventDefault();
enabled[(index - 1 + enabled.length) % enabled.length]?.focus();
} else if (event.key === 'Home') {
event.preventDefault();
enabled[0]?.focus();
} else if (event.key === 'End') {
event.preventDefault();
enabled.at(-1)?.focus();
}
}}
>
{items.map((item, index) => (
<button
key={item.id}
ref={(node) => {
refs.current[index] = node;
}}
role="menuitem"
disabled={item.disabled}
onClick={() => {
close();
item.onSelect();
}}
className="flex h-8 w-full items-center gap-2 rounded px-2 text-left text-xs text-text-secondary hover:bg-element-hover hover:text-text-primary focus:bg-element-hover focus:outline-none disabled:opacity-40"
>
<span className="flex h-4 w-4 items-center justify-center">{item.icon}</span>
{item.label}
</button>
))}
</div>
);
}
export function DropdownMenu({
items,
label = '更多操作',
className = '',
}: {
items: DropdownMenuItem[];
label?: string;
className?: string;
}) {
return (
<span className={className}>
<Popover
label={label}
trigger={({ open, toggle }) => (
<IconButton aria-label={label} aria-expanded={open} tooltip={label} onClick={toggle}>
<MoreHorizontal className="h-4 w-4" />
</IconButton>
)}
>
{({ close }) => <MenuContent items={items} close={close} />}
</Popover>
</span>
);
}
@@ -1,2 +1,9 @@
import {SearchX} from 'lucide-react';
export function EmptySearchState({label='没有匹配结果'}:{label?:string}){return <div className="grid place-items-center gap-2 px-3 py-6 text-center text-xs text-text-tertiary"><SearchX className="h-5 w-5"/><span>{label}</span></div>;}
import { SearchX } from 'lucide-react';
export function EmptySearchState({ label = '没有匹配结果' }: { label?: string }) {
return (
<div className="grid place-items-center gap-2 px-3 py-6 text-center text-xs text-text-tertiary">
<SearchX className="h-5 w-5" />
<span>{label}</span>
</div>
);
}
@@ -1,7 +1,48 @@
import {fireEvent,render,screen,waitFor} from '@testing-library/react';
import {DropdownMenu,LiveRegion,ProgressBar,SearchableCombobox} from './index';
describe('第五批基础 UI',()=>{
it('下拉菜单打开后聚焦菜单项并恢复触发器焦点',async()=>{const run=vi.fn();render(<DropdownMenu items={[{id:'a',label:'动作 A',onSelect:run}]}/>);const trigger=screen.getByRole('button',{name:'更多操作'});trigger.focus();fireEvent.click(trigger);const item=screen.getByRole('menuitem',{name:'动作 A'});await waitFor(()=>expect(item).toHaveFocus());fireEvent.click(item);expect(run).toHaveBeenCalled();expect(trigger).toHaveFocus();});
it('进度条和实时区域暴露状态',()=>{render(<><ProgressBar value={.42} label="编译模型"/><LiveRegion>正在编译模型</LiveRegion></>);expect(screen.getByRole('progressbar')).toHaveAttribute('aria-valuenow','42');expect(screen.getByRole('status')).toHaveTextContent('正在编译模型');});
it('可搜索组合框筛选并选择入口',()=>{const change=vi.fn();render(<SearchableCombobox label="模型入口" value="a" onChange={change} options={[{value:'a',label:'模型 A',description:'a.xml'},{value:'b',label:'模型 B',description:'b.xml'}]}/>);fireEvent.click(screen.getByRole('button',{name:'模型入口'}));fireEvent.change(screen.getByRole('combobox',{name:'搜索模型入口'}),{target:{value:'B'}});const input=screen.getByRole('combobox',{name:'搜索模型入口'});expect(input).toHaveAttribute('aria-expanded','true');fireEvent.click(screen.getByRole('option',{name:/模型 B/}));expect(change).toHaveBeenCalledWith('b');});
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { DropdownMenu, LiveRegion, ProgressBar, SearchableCombobox } from './index';
describe('第五批基础 UI', () => {
it('下拉菜单打开后聚焦菜单项并恢复触发器焦点', async () => {
const run = vi.fn();
render(<DropdownMenu items={[{ id: 'a', label: '动作 A', onSelect: run }]} />);
const trigger = screen.getByRole('button', { name: '更多操作' });
trigger.focus();
fireEvent.click(trigger);
const item = screen.getByRole('menuitem', { name: '动作 A' });
await waitFor(() => expect(item).toHaveFocus());
fireEvent.click(item);
expect(run).toHaveBeenCalled();
expect(trigger).toHaveFocus();
});
it('进度条和实时区域暴露状态', () => {
render(
<>
<ProgressBar value={0.42} label="编译模型" />
<LiveRegion>正在编译模型</LiveRegion>
</>,
);
expect(screen.getByRole('progressbar')).toHaveAttribute('aria-valuenow', '42');
expect(screen.getByRole('status')).toHaveTextContent('正在编译模型');
});
it('可搜索组合框筛选并选择入口', () => {
const change = vi.fn();
render(
<SearchableCombobox
label="模型入口"
value="a"
onChange={change}
options={[
{ value: 'a', label: '模型 A', description: 'a.xml' },
{ value: 'b', label: '模型 B', description: 'b.xml' },
]}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '模型入口' }));
fireEvent.change(screen.getByRole('combobox', { name: '搜索模型入口' }), {
target: { value: 'B' },
});
const input = screen.getByRole('combobox', { name: '搜索模型入口' });
expect(input).toHaveAttribute('aria-expanded', 'true');
fireEvent.click(screen.getByRole('option', { name: /模型 B/ }));
expect(change).toHaveBeenCalledWith('b');
});
});
+22 -6
View File
@@ -1,8 +1,24 @@
import type {ButtonHTMLAttributes} from 'react';
import {Tooltip} from './Tooltip';
import type { ButtonHTMLAttributes } from 'react';
import { Tooltip } from './Tooltip';
export interface IconButtonProps extends ButtonHTMLAttributes<HTMLButtonElement>{active?:boolean;tooltip?:string;}
export function IconButton({active=false,tooltip,className='',type='button',...props}:IconButtonProps){
const button=<button type={type} aria-pressed={active||undefined} className={`inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-md border transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/30 disabled:cursor-not-allowed disabled:opacity-40 ${active?'border-accent/40 bg-accent-soft text-accent':'border-transparent bg-transparent text-text-tertiary hover:bg-element-hover hover:text-text-primary'} ${className}`.trim()} {...props}/>;
return tooltip?<Tooltip content={tooltip}>{button}</Tooltip>:button;
export interface IconButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
active?: boolean;
tooltip?: string;
}
export function IconButton({
active = false,
tooltip,
className = '',
type = 'button',
...props
}: IconButtonProps) {
const button = (
<button
type={type}
aria-pressed={active || undefined}
className={`inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-md border transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/30 disabled:cursor-not-allowed disabled:opacity-40 ${active ? 'border-accent/40 bg-accent-soft text-accent' : 'border-transparent bg-transparent text-text-tertiary hover:bg-element-hover hover:text-text-primary'} ${className}`.trim()}
{...props}
/>
);
return tooltip ? <Tooltip content={tooltip}>{button}</Tooltip> : button;
}
+8 -2
View File
@@ -1,2 +1,8 @@
import type {ReactNode} from 'react';
export function Kbd({children}:{children:ReactNode}){return <kbd className="inline-flex min-w-5 items-center justify-center rounded border border-border-strong bg-surface px-1.5 py-0.5 font-mono text-[10px] leading-4 text-text-secondary shadow-sm">{children}</kbd>;}
import type { ReactNode } from 'react';
export function Kbd({ children }: { children: ReactNode }) {
return (
<kbd className="inline-flex min-w-5 items-center justify-center rounded border border-border-strong bg-surface px-1.5 py-0.5 font-mono text-[10px] leading-4 text-text-secondary shadow-sm">
{children}
</kbd>
);
}
+19 -2
View File
@@ -1,2 +1,19 @@
import type {ReactNode} from 'react';
export function LiveRegion({children,assertive=false}:{children:ReactNode;assertive?:boolean}){return <div className="sr-only" role={assertive?'alert':'status'} aria-live={assertive?'assertive':'polite'} aria-atomic="true">{children}</div>;}
import type { ReactNode } from 'react';
export function LiveRegion({
children,
assertive = false,
}: {
children: ReactNode;
assertive?: boolean;
}) {
return (
<div
className="sr-only"
role={assertive ? 'alert' : 'status'}
aria-live={assertive ? 'assertive' : 'polite'}
aria-atomic="true"
>
{children}
</div>
);
}
+65 -2
View File
@@ -1,3 +1,66 @@
/* eslint-disable react-hooks/refs -- refs are read only inside event callbacks passed to render props */
import {useCallback,useEffect,useRef,useState,type ReactNode} from 'react';
export function Popover({trigger,children,placement='bottom-right',label}:{trigger:(props:{open:boolean;toggle:()=>void})=>ReactNode;children:(props:{close:(restoreFocus?:boolean)=>void})=>ReactNode;placement?:'bottom-right'|'bottom-left'|'top-left';label:string}){const [open,setOpen]=useState(false),root=useRef<HTMLDivElement>(null),previous=useRef<HTMLElement|null>(null);const close=useCallback((restoreFocus=true)=>{setOpen(false);if(restoreFocus)previous.current?.focus();},[]);useEffect(()=>{if(!open)return;const pointer=(event:PointerEvent)=>{if(!root.current?.contains(event.target as Node))setOpen(false);},key=(event:KeyboardEvent)=>{if(event.key==='Escape'||((event.ctrlKey||event.metaKey)&&event.key.toLocaleLowerCase()==='k')){setOpen(false);previous.current?.focus();}};document.addEventListener('pointerdown',pointer);document.addEventListener('keydown',key);return()=>{document.removeEventListener('pointerdown',pointer);document.removeEventListener('keydown',key);};},[open]);const position=placement==='top-left'?'bottom-8 left-0':placement==='bottom-left'?'left-0 top-9':'right-0 top-9';return <div ref={root} className="relative">{trigger({open,toggle:()=>{if(!open)previous.current=document.activeElement instanceof HTMLElement?document.activeElement:null;setOpen(value=>!value);}})}{open&&<section role="dialog" aria-label={label} className={`absolute z-50 ${position}`}>{children({close})}</section>}</div>;}
import { useCallback, useEffect, useRef, useState, type ReactNode } from 'react';
export function Popover({
trigger,
children,
placement = 'bottom-right',
label,
}: {
trigger: (props: { open: boolean; toggle: () => void }) => ReactNode;
children: (props: { close: (restoreFocus?: boolean) => void }) => ReactNode;
placement?: 'bottom-right' | 'bottom-left' | 'top-left';
label: string;
}) {
const [open, setOpen] = useState(false),
root = useRef<HTMLDivElement>(null),
previous = useRef<HTMLElement | null>(null);
const close = useCallback((restoreFocus = true) => {
setOpen(false);
if (restoreFocus) previous.current?.focus();
}, []);
useEffect(() => {
if (!open) return;
const pointer = (event: PointerEvent) => {
if (!root.current?.contains(event.target as Node)) setOpen(false);
},
key = (event: KeyboardEvent) => {
if (
event.key === 'Escape' ||
((event.ctrlKey || event.metaKey) && event.key.toLocaleLowerCase() === 'k')
) {
setOpen(false);
previous.current?.focus();
}
};
document.addEventListener('pointerdown', pointer);
document.addEventListener('keydown', key);
return () => {
document.removeEventListener('pointerdown', pointer);
document.removeEventListener('keydown', key);
};
}, [open]);
const position =
placement === 'top-left'
? 'bottom-8 left-0'
: placement === 'bottom-left'
? 'left-0 top-9'
: 'right-0 top-9';
return (
<div ref={root} className="relative">
{trigger({
open,
toggle: () => {
if (!open)
previous.current =
document.activeElement instanceof HTMLElement ? document.activeElement : null;
setOpen((value) => !value);
},
})}
{open && (
<section role="dialog" aria-label={label} className={`absolute z-50 ${position}`}>
{children({ close })}
</section>
)}
</div>
);
}
+24 -1
View File
@@ -1 +1,24 @@
export function ProgressBar({value,label}:{value:number;label:string}){const percent=Math.round(Math.min(1,Math.max(0,value))*100);return <div><div className="mb-1 flex justify-between text-[10px] text-text-tertiary"><span>{label}</span><span>{percent}%</span></div><div role="progressbar" aria-label={label} aria-valuemin={0} aria-valuemax={100} aria-valuenow={percent} className="h-1.5 overflow-hidden rounded-full bg-element-active"><div className="h-full rounded-full bg-accent transition-[width]" style={{width:`${percent}%`}}/></div></div>;}
export function ProgressBar({ value, label }: { value: number; label: string }) {
const percent = Math.round(Math.min(1, Math.max(0, value)) * 100);
return (
<div>
<div className="mb-1 flex justify-between text-[10px] text-text-tertiary">
<span>{label}</span>
<span>{percent}%</span>
</div>
<div
role="progressbar"
aria-label={label}
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={percent}
className="h-1.5 overflow-hidden rounded-full bg-element-active"
>
<div
className="h-full rounded-full bg-accent transition-[width]"
style={{ width: `${percent}%` }}
/>
</div>
</div>
);
}
+20 -2
View File
@@ -1,2 +1,20 @@
import type {ReactNode} from 'react';
export function PropertyRow({label,value,action}:{label:string;value:ReactNode;action?:ReactNode}){return <div className="grid min-h-6 grid-cols-[minmax(0,1fr)_auto] items-center gap-2 text-xs"><span className="truncate text-text-tertiary">{label}</span><span className="flex min-w-0 items-center justify-end gap-1 text-right text-text-primary"><span className="technical-value truncate">{value}</span>{action}</span></div>;}
import type { ReactNode } from 'react';
export function PropertyRow({
label,
value,
action,
}: {
label: string;
value: ReactNode;
action?: ReactNode;
}) {
return (
<div className="grid min-h-6 grid-cols-[minmax(0,1fr)_auto] items-center gap-2 text-xs">
<span className="truncate text-text-tertiary">{label}</span>
<span className="flex min-w-0 items-center justify-end gap-1 text-right text-text-primary">
<span className="technical-value truncate">{value}</span>
{action}
</span>
</div>
);
}
@@ -1,8 +1,127 @@
import {useEffect,useRef,useState,type PointerEvent as ReactPointerEvent,type ReactNode} from 'react';
const clamp=(value:number,min:number,max:number)=>Math.min(max,Math.max(min,value));
const panelMaxWidth=(minWidth:number)=>Math.max(minWidth,Math.min(576,window.innerWidth*.4));
function storedWidth(key:string,fallback:number,minWidth:number){try{const value=Number(localStorage.getItem(key));return clamp(Number.isFinite(value)&&value>0?value:fallback,minWidth,panelMaxWidth(minWidth));}catch{return clamp(fallback,minWidth,panelMaxWidth(minWidth));}}
export function ResizablePanel({side,storageKey,visible=true,defaultWidth=288,minWidth=224,children,className=''}:{side:'left'|'right';storageKey:string;visible?:boolean;defaultWidth?:number;minWidth?:number;children:ReactNode;className?:string}){const [width,setWidth]=useState(()=>storedWidth(storageKey,defaultWidth,minWidth)),cleanupRef=useRef<()=>void>(()=>{});const update=(next:number)=>{const value=clamp(next,minWidth,panelMaxWidth(minWidth));setWidth(value);try{localStorage.setItem(storageKey,String(value));}catch{/* 无持久化权限时仍可调整 */}};
useEffect(()=>{const persist=(next:number)=>{const value=clamp(next,minWidth,panelMaxWidth(minWidth));setWidth(value);try{localStorage.setItem(storageKey,String(value));}catch{/* 忽略 */}},resize=()=>setWidth(value=>clamp(value,minWidth,panelMaxWidth(minWidth))),layout=(event:Event)=>{const widths=(event as CustomEvent<{left:number;right:number}>).detail;persist(widths[side]);};window.addEventListener('resize',resize);window.addEventListener('mujoco-layout-widths',layout);return()=>{window.removeEventListener('resize',resize);window.removeEventListener('mujoco-layout-widths',layout);cleanupRef.current();};},[minWidth,side,storageKey]);
const start=(event:ReactPointerEvent<HTMLButtonElement>)=>{event.preventDefault();cleanupRef.current();const origin=event.clientX,startWidth=width,pointerId=event.pointerId;const move=(moveEvent:PointerEvent)=>{if(moveEvent.pointerId===pointerId)update(startWidth+(moveEvent.clientX-origin)*(side==='left'?1:-1));};const stop=(stopEvent:PointerEvent)=>{if(stopEvent.pointerId!==pointerId)return;cleanup();};const cleanup=()=>{window.removeEventListener('pointermove',move);window.removeEventListener('pointerup',stop);window.removeEventListener('pointercancel',stop);cleanupRef.current=()=>{};};cleanupRef.current=cleanup;window.addEventListener('pointermove',move);window.addEventListener('pointerup',stop);window.addEventListener('pointercancel',stop);};
return <div hidden={!visible} className={`relative shrink-0 max-lg:absolute max-lg:inset-y-0 max-lg:z-40 max-lg:shadow-2xl ${side==='left'?'max-lg:left-0':'max-lg:right-0'} ${className}`} style={{width}}>{children}<button type="button" role="separator" aria-label={side==='left'?'调整工程面板宽度':'调整属性面板宽度'} aria-orientation="vertical" aria-valuemin={minWidth} aria-valuemax={Math.round(panelMaxWidth(minWidth))} aria-valuenow={Math.round(width)} onPointerDown={start} onKeyDown={event=>{if(event.key==='Home')update(minWidth);else if(event.key==='End')update(panelMaxWidth(minWidth));else if(event.key==='ArrowLeft')update(width+(side==='left'?-16:16));else if(event.key==='ArrowRight')update(width+(side==='left'?16:-16));else return;event.preventDefault();}} className={`absolute inset-y-0 z-30 w-2 cursor-col-resize bg-transparent outline-none after:absolute after:inset-y-0 after:left-1/2 after:w-px after:-translate-x-1/2 after:bg-transparent hover:after:bg-accent focus-visible:after:w-0.5 focus-visible:after:bg-accent ${side==='left'?'-right-1':'-left-1'}`}/></div>;}
import {
useEffect,
useRef,
useState,
type PointerEvent as ReactPointerEvent,
type ReactNode,
} from 'react';
const clamp = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value));
const panelMaxWidth = (minWidth: number) =>
Math.max(minWidth, Math.min(576, window.innerWidth * 0.4));
function storedWidth(key: string, fallback: number, minWidth: number) {
try {
const value = Number(localStorage.getItem(key));
return clamp(
Number.isFinite(value) && value > 0 ? value : fallback,
minWidth,
panelMaxWidth(minWidth),
);
} catch {
return clamp(fallback, minWidth, panelMaxWidth(minWidth));
}
}
export function ResizablePanel({
side,
storageKey,
visible = true,
defaultWidth = 288,
minWidth = 224,
children,
className = '',
}: {
side: 'left' | 'right';
storageKey: string;
visible?: boolean;
defaultWidth?: number;
minWidth?: number;
children: ReactNode;
className?: string;
}) {
const [width, setWidth] = useState(() => storedWidth(storageKey, defaultWidth, minWidth)),
cleanupRef = useRef<() => void>(() => {});
const update = (next: number) => {
const value = clamp(next, minWidth, panelMaxWidth(minWidth));
setWidth(value);
try {
localStorage.setItem(storageKey, String(value));
} catch {
/* 无持久化权限时仍可调整 */
}
};
useEffect(() => {
const persist = (next: number) => {
const value = clamp(next, minWidth, panelMaxWidth(minWidth));
setWidth(value);
try {
localStorage.setItem(storageKey, String(value));
} catch {
/* 忽略 */
}
},
resize = () => setWidth((value) => clamp(value, minWidth, panelMaxWidth(minWidth))),
layout = (event: Event) => {
const widths = (event as CustomEvent<{ left: number; right: number }>).detail;
persist(widths[side]);
};
window.addEventListener('resize', resize);
window.addEventListener('mujoco-layout-widths', layout);
return () => {
window.removeEventListener('resize', resize);
window.removeEventListener('mujoco-layout-widths', layout);
cleanupRef.current();
};
}, [minWidth, side, storageKey]);
const start = (event: ReactPointerEvent<HTMLButtonElement>) => {
event.preventDefault();
cleanupRef.current();
const origin = event.clientX,
startWidth = width,
pointerId = event.pointerId;
const move = (moveEvent: PointerEvent) => {
if (moveEvent.pointerId === pointerId)
update(startWidth + (moveEvent.clientX - origin) * (side === 'left' ? 1 : -1));
};
const stop = (stopEvent: PointerEvent) => {
if (stopEvent.pointerId !== pointerId) return;
cleanup();
};
const cleanup = () => {
window.removeEventListener('pointermove', move);
window.removeEventListener('pointerup', stop);
window.removeEventListener('pointercancel', stop);
cleanupRef.current = () => {};
};
cleanupRef.current = cleanup;
window.addEventListener('pointermove', move);
window.addEventListener('pointerup', stop);
window.addEventListener('pointercancel', stop);
};
return (
<div
hidden={!visible}
className={`relative shrink-0 max-lg:absolute max-lg:inset-y-0 max-lg:z-40 max-lg:shadow-2xl ${side === 'left' ? 'max-lg:left-0' : 'max-lg:right-0'} ${className}`}
style={{ width }}
>
{children}
<button
type="button"
role="separator"
aria-label={side === 'left' ? '调整工程面板宽度' : '调整属性面板宽度'}
aria-orientation="vertical"
aria-valuemin={minWidth}
aria-valuemax={Math.round(panelMaxWidth(minWidth))}
aria-valuenow={Math.round(width)}
onPointerDown={start}
onKeyDown={(event) => {
if (event.key === 'Home') update(minWidth);
else if (event.key === 'End') update(panelMaxWidth(minWidth));
else if (event.key === 'ArrowLeft') update(width + (side === 'left' ? -16 : 16));
else if (event.key === 'ArrowRight') update(width + (side === 'left' ? 16 : -16));
else return;
event.preventDefault();
}}
className={`absolute inset-y-0 z-30 w-2 cursor-col-resize bg-transparent outline-none after:absolute after:inset-y-0 after:left-1/2 after:w-px after:-translate-x-1/2 after:bg-transparent hover:after:bg-accent focus-visible:after:w-0.5 focus-visible:after:bg-accent ${side === 'left' ? '-right-1' : '-left-1'}`}
/>
</div>
);
}
@@ -1 +1,27 @@
export function SearchHighlight({text,query}:{text:string;query:string}){const needle=query.trim().toLocaleLowerCase();if(!needle)return <>{text}</>;const parts:({text:string;match:boolean})[]=[];let start=0,index=text.toLocaleLowerCase().indexOf(needle);while(index>=0){if(index>start)parts.push({text:text.slice(start,index),match:false});parts.push({text:text.slice(index,index+needle.length),match:true});start=index+needle.length;index=text.toLocaleLowerCase().indexOf(needle,start);}if(start<text.length)parts.push({text:text.slice(start),match:false});return <>{parts.map((part,i)=>part.match?<mark key={i} className="rounded-sm bg-warning-soft px-0.5 text-warning">{part.text}</mark>:part.text)}</>;}
export function SearchHighlight({ text, query }: { text: string; query: string }) {
const needle = query.trim().toLocaleLowerCase();
if (!needle) return <>{text}</>;
const parts: { text: string; match: boolean }[] = [];
let start = 0,
index = text.toLocaleLowerCase().indexOf(needle);
while (index >= 0) {
if (index > start) parts.push({ text: text.slice(start, index), match: false });
parts.push({ text: text.slice(index, index + needle.length), match: true });
start = index + needle.length;
index = text.toLocaleLowerCase().indexOf(needle, start);
}
if (start < text.length) parts.push({ text: text.slice(start), match: false });
return (
<>
{parts.map((part, i) =>
part.match ? (
<mark key={i} className="rounded-sm bg-warning-soft px-0.5 text-warning">
{part.text}
</mark>
) : (
part.text
),
)}
</>
);
}
@@ -1,5 +1,120 @@
import {useId,useMemo,useState} from 'react';
import {Check,ChevronsUpDown,Search} from 'lucide-react';
import {Popover} from './Popover';
export interface ComboboxOption{value:string;label:string;description?:string;}
export function SearchableCombobox({options,value,onChange,label,disabled=false}:{options:ComboboxOption[];value?:string;onChange:(value:string)=>void;label:string;disabled?:boolean}){const [query,setQuery]=useState(''),[active,setActive]=useState(0),listId=useId(),selected=options.find(option=>option.value===value),filtered=useMemo(()=>{const needle=query.trim().toLocaleLowerCase();return options.filter(option=>!needle||`${option.label} ${option.description??''}`.toLocaleLowerCase().includes(needle));},[options,query]);return <Popover label={label} trigger={({open,toggle})=><button type="button" disabled={disabled} aria-label={label} aria-expanded={open} onClick={toggle} className="flex h-8 w-full items-center justify-between rounded-md border border-border bg-input px-2 text-xs text-text-primary focus-visible:ring-2 focus-visible:ring-accent/30 disabled:opacity-40"><span className="truncate">{selected?.label??'请选择'}</span><ChevronsUpDown className="h-3.5 w-3.5 text-text-tertiary"/></button>}>{({close})=><div className="w-72 rounded-lg border border-border bg-surface-elevated p-1 shadow-xl"><div className="relative"><Search className="absolute left-2 top-2 h-3.5 w-3.5 text-text-tertiary"/><input autoFocus role="combobox" aria-label={`搜索${label}`} aria-expanded="true" aria-autocomplete="list" aria-controls={listId} aria-activedescendant={filtered[active]?`${listId}-${active}`:undefined} value={query} onChange={event=>{setQuery(event.target.value);setActive(0);}} onKeyDown={event=>{if(!filtered.length)return;if(event.key==='ArrowDown'){event.preventDefault();setActive(index=>(index+1)%filtered.length);}else if(event.key==='ArrowUp'){event.preventDefault();setActive(index=>(index-1+filtered.length)%filtered.length);}else if(event.key==='Enter'){event.preventDefault();onChange(filtered[active].value);setQuery('');close();}else if(event.key==='Tab')close(false);}} className="h-8 w-full rounded border border-border bg-input pl-7 pr-2 text-xs"/></div><div id={listId} role="listbox" className="panel-scroll mt-1 max-h-64 overflow-auto">{filtered.map((option,index)=><button key={option.value} id={`${listId}-${index}`} role="option" tabIndex={-1} aria-selected={option.value===value} onMouseEnter={()=>setActive(index)} onClick={()=>{onChange(option.value);setQuery('');close();}} className={`flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-xs ${index===active?'bg-accent-soft':''}`}><Check className={`h-3.5 w-3.5 ${option.value===value?'text-accent':'opacity-0'}`}/><span className="min-w-0"><span className="block truncate">{option.label}</span>{option.description&&<span className="block truncate text-[10px] text-text-tertiary">{option.description}</span>}</span></button>)}</div></div>}</Popover>;}
import { useId, useMemo, useState } from 'react';
import { Check, ChevronsUpDown, Search } from 'lucide-react';
import { Popover } from './Popover';
export interface ComboboxOption {
value: string;
label: string;
description?: string;
}
export function SearchableCombobox({
options,
value,
onChange,
label,
disabled = false,
}: {
options: ComboboxOption[];
value?: string;
onChange: (value: string) => void;
label: string;
disabled?: boolean;
}) {
const [query, setQuery] = useState(''),
[active, setActive] = useState(0),
listId = useId(),
selected = options.find((option) => option.value === value),
filtered = useMemo(() => {
const needle = query.trim().toLocaleLowerCase();
return options.filter(
(option) =>
!needle ||
`${option.label} ${option.description ?? ''}`.toLocaleLowerCase().includes(needle),
);
}, [options, query]);
return (
<Popover
label={label}
trigger={({ open, toggle }) => (
<button
type="button"
disabled={disabled}
aria-label={label}
aria-expanded={open}
onClick={toggle}
className="flex h-8 w-full items-center justify-between rounded-md border border-border bg-input px-2 text-xs text-text-primary focus-visible:ring-2 focus-visible:ring-accent/30 disabled:opacity-40"
>
<span className="truncate">{selected?.label ?? '请选择'}</span>
<ChevronsUpDown className="h-3.5 w-3.5 text-text-tertiary" />
</button>
)}
>
{({ close }) => (
<div className="w-72 rounded-lg border border-border bg-surface-elevated p-1 shadow-xl">
<div className="relative">
<Search className="absolute left-2 top-2 h-3.5 w-3.5 text-text-tertiary" />
<input
autoFocus
role="combobox"
aria-label={`搜索${label}`}
aria-expanded="true"
aria-autocomplete="list"
aria-controls={listId}
aria-activedescendant={filtered[active] ? `${listId}-${active}` : undefined}
value={query}
onChange={(event) => {
setQuery(event.target.value);
setActive(0);
}}
onKeyDown={(event) => {
if (!filtered.length) return;
if (event.key === 'ArrowDown') {
event.preventDefault();
setActive((index) => (index + 1) % filtered.length);
} else if (event.key === 'ArrowUp') {
event.preventDefault();
setActive((index) => (index - 1 + filtered.length) % filtered.length);
} else if (event.key === 'Enter') {
event.preventDefault();
onChange(filtered[active].value);
setQuery('');
close();
} else if (event.key === 'Tab') close(false);
}}
className="h-8 w-full rounded border border-border bg-input pl-7 pr-2 text-xs"
/>
</div>
<div id={listId} role="listbox" className="panel-scroll mt-1 max-h-64 overflow-auto">
{filtered.map((option, index) => (
<button
key={option.value}
id={`${listId}-${index}`}
role="option"
tabIndex={-1}
aria-selected={option.value === value}
onMouseEnter={() => setActive(index)}
onClick={() => {
onChange(option.value);
setQuery('');
close();
}}
className={`flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-xs ${index === active ? 'bg-accent-soft' : ''}`}
>
<Check
className={`h-3.5 w-3.5 ${option.value === value ? 'text-accent' : 'opacity-0'}`}
/>
<span className="min-w-0">
<span className="block truncate">{option.label}</span>
{option.description && (
<span className="block truncate text-[10px] text-text-tertiary">
{option.description}
</span>
)}
</span>
</button>
))}
</div>
</div>
)}
</Popover>
);
}
@@ -1,9 +1,60 @@
import {useState} from 'react';
import {act,fireEvent,render,screen} from '@testing-library/react';
import {Badge,ResizablePanel,Separator,Skeleton,Tabs} from './index';
function TabHarness(){const [value,setValue]=useState<'a'|'b'>('a');return <Tabs label="示例" value={value} onValueChange={setValue} items={[{value:'a',label:'甲',content:<span>甲内容</span>},{value:'b',label:'乙',content:<span>乙内容</span>}]}/>;}
describe('第二批基础 UI',()=>{
it('Tabs 保留面板 DOM并支持方向键导航',()=>{render(<TabHarness/>);const first=screen.getByRole('tab',{name:'甲'});expect(first).toHaveAttribute('tabindex','0');fireEvent.keyDown(first,{key:'ArrowRight'});expect(screen.getByRole('tab',{name:'乙'})).toHaveAttribute('aria-selected','true');expect(screen.getByText('乙内容')).toBeVisible();expect(screen.getByText('甲内容').closest('[role="tabpanel"]')).toHaveAttribute('hidden');});
it('ResizablePanel 支持键盘调整、限制异常持久值并保存宽度',()=>{localStorage.setItem('test-width','9999');render(<ResizablePanel side="left" storageKey="test-width"><div>面板</div></ResizablePanel>);const handle=screen.getByRole('separator',{name:'调整工程面板宽度'});expect(Number(handle.getAttribute('aria-valuenow'))).toBeLessThanOrEqual(Number(handle.getAttribute('aria-valuemax')));fireEvent.keyDown(handle,{key:'Home'});expect(handle).toHaveAttribute('aria-valuenow','224');expect(localStorage.getItem('test-width')).toBe('224');act(()=>window.dispatchEvent(new CustomEvent('mujoco-layout-widths',{detail:{left:320,right:360}})));expect(handle).toHaveAttribute('aria-valuenow','320');});
it('Badge、Separator 和 Skeleton 可渲染',()=>{render(<><Badge>状态</Badge><Separator/><Skeleton className="h-2"/></>);expect(screen.getByText('状态')).toBeVisible();expect(screen.getByRole('separator')).toBeVisible();});
import { useState } from 'react';
import { act, fireEvent, render, screen } from '@testing-library/react';
import { Badge, ResizablePanel, Separator, Skeleton, Tabs } from './index';
function TabHarness() {
const [value, setValue] = useState<'a' | 'b'>('a');
return (
<Tabs
label="示例"
value={value}
onValueChange={setValue}
items={[
{ value: 'a', label: '甲', content: <span>甲内容</span> },
{ value: 'b', label: '乙', content: <span>乙内容</span> },
]}
/>
);
}
describe('第二批基础 UI', () => {
it('Tabs 保留面板 DOM并支持方向键导航', () => {
render(<TabHarness />);
const first = screen.getByRole('tab', { name: '甲' });
expect(first).toHaveAttribute('tabindex', '0');
fireEvent.keyDown(first, { key: 'ArrowRight' });
expect(screen.getByRole('tab', { name: '乙' })).toHaveAttribute('aria-selected', 'true');
expect(screen.getByText('乙内容')).toBeVisible();
expect(screen.getByText('甲内容').closest('[role="tabpanel"]')).toHaveAttribute('hidden');
});
it('ResizablePanel 支持键盘调整、限制异常持久值并保存宽度', () => {
localStorage.setItem('test-width', '9999');
render(
<ResizablePanel side="left" storageKey="test-width">
<div>面板</div>
</ResizablePanel>,
);
const handle = screen.getByRole('separator', { name: '调整工程面板宽度' });
expect(Number(handle.getAttribute('aria-valuenow'))).toBeLessThanOrEqual(
Number(handle.getAttribute('aria-valuemax')),
);
fireEvent.keyDown(handle, { key: 'Home' });
expect(handle).toHaveAttribute('aria-valuenow', '224');
expect(localStorage.getItem('test-width')).toBe('224');
act(() =>
window.dispatchEvent(
new CustomEvent('mujoco-layout-widths', { detail: { left: 320, right: 360 } }),
),
);
expect(handle).toHaveAttribute('aria-valuenow', '320');
});
it('Badge、Separator 和 Skeleton 可渲染', () => {
render(
<>
<Badge>状态</Badge>
<Separator />
<Skeleton className="h-2" />
</>,
);
expect(screen.getByText('状态')).toBeVisible();
expect(screen.getByRole('separator')).toBeVisible();
});
});
+9 -2
View File
@@ -1,2 +1,9 @@
import type {SelectHTMLAttributes} from 'react';
export function Select({className='',...props}:SelectHTMLAttributes<HTMLSelectElement>){return <select className={`h-7 rounded-md border border-border bg-input px-2 text-xs text-text-primary transition-colors hover:border-border-strong focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/30 disabled:opacity-40 ${className}`.trim()} {...props}/>;}
import type { SelectHTMLAttributes } from 'react';
export function Select({ className = '', ...props }: SelectHTMLAttributes<HTMLSelectElement>) {
return (
<select
className={`h-7 rounded-md border border-border bg-input px-2 text-xs text-text-primary transition-colors hover:border-border-strong focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/30 disabled:opacity-40 ${className}`.trim()}
{...props}
/>
);
}
+15 -1
View File
@@ -1 +1,15 @@
export function Separator({orientation='horizontal',className=''}:{orientation?:'horizontal'|'vertical';className?:string}){return <span role="separator" aria-orientation={orientation} className={`${orientation==='horizontal'?'block h-px w-full':'inline-block h-full w-px'} shrink-0 bg-border ${className}`}/>;}
export function Separator({
orientation = 'horizontal',
className = '',
}: {
orientation?: 'horizontal' | 'vertical';
className?: string;
}) {
return (
<span
role="separator"
aria-orientation={orientation}
className={`${orientation === 'horizontal' ? 'block h-px w-full' : 'inline-block h-full w-px'} shrink-0 bg-border ${className}`}
/>
);
}
+8 -1
View File
@@ -1 +1,8 @@
export function Skeleton({className=''}:{className?:string}){return <span aria-hidden="true" className={`block animate-pulse rounded bg-element-active ${className}`}/>;}
export function Skeleton({ className = '' }: { className?: string }) {
return (
<span
aria-hidden="true"
className={`block animate-pulse rounded bg-element-active ${className}`}
/>
);
}
+84 -3
View File
@@ -1,3 +1,84 @@
import type {KeyboardEvent,ReactNode} from 'react';
export interface TabItem<T extends string>{value:T;label:string;icon?:ReactNode;content:ReactNode;disabled?:boolean;}
export function Tabs<T extends string>({items,value,onValueChange,label,className='',keepMounted=true}:{items:TabItem<T>[];value:T;onValueChange:(value:T)=>void;label:string;className?:string;keepMounted?:boolean}){const active=items.find(item=>item.value===value)??items.find(item=>!item.disabled)??items[0];const navigate=(event:KeyboardEvent<HTMLButtonElement>)=>{if(!['ArrowLeft','ArrowRight','Home','End'].includes(event.key))return;const enabled=items.filter(item=>!item.disabled);if(!enabled.length)return;const current=enabled.findIndex(item=>item.value===active.value);const next=event.key==='Home'?0:event.key==='End'?enabled.length-1:event.key==='ArrowRight'?(current+1)%enabled.length:(current-1+enabled.length)%enabled.length;event.preventDefault();const item=enabled[next];onValueChange(item.value);requestAnimationFrame(()=>document.getElementById(`${label}-tab-${item.value}`)?.focus());};return <div className={`flex min-h-0 flex-1 flex-col ${className}`}><div role="tablist" aria-label={label} className="flex h-9 shrink-0 items-end gap-1 border-b border-border bg-surface px-2">{items.map(item=><button key={item.value} type="button" role="tab" id={`${label}-tab-${item.value}`} tabIndex={item.value===active.value?0:-1} aria-selected={item.value===active.value} aria-controls={`${label}-${item.value}`} disabled={item.disabled} onClick={()=>onValueChange(item.value)} onKeyDown={navigate} className={`relative flex h-8 items-center gap-1.5 px-2 text-xs font-medium focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-accent/40 ${item.value===active.value?'text-accent after:absolute after:inset-x-1 after:bottom-0 after:h-0.5 after:rounded-full after:bg-accent':'text-text-tertiary hover:text-text-primary'}`}>{item.icon}{item.label}</button>)}</div>{(keepMounted?items:[active]).map(item=><div key={item.value} id={`${label}-${item.value}`} role="tabpanel" aria-labelledby={`${label}-tab-${item.value}`} hidden={item.value!==active.value} className="panel-scroll min-h-0 flex-1 overflow-auto">{item.content}</div>)}</div>;}
import type { KeyboardEvent, ReactNode } from 'react';
export interface TabItem<T extends string> {
value: T;
label: string;
icon?: ReactNode;
content: ReactNode;
disabled?: boolean;
}
export function Tabs<T extends string>({
items,
value,
onValueChange,
label,
className = '',
keepMounted = true,
}: {
items: TabItem<T>[];
value: T;
onValueChange: (value: T) => void;
label: string;
className?: string;
keepMounted?: boolean;
}) {
const active =
items.find((item) => item.value === value) ?? items.find((item) => !item.disabled) ?? items[0];
const navigate = (event: KeyboardEvent<HTMLButtonElement>) => {
if (!['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(event.key)) return;
const enabled = items.filter((item) => !item.disabled);
if (!enabled.length) return;
const current = enabled.findIndex((item) => item.value === active.value);
const next =
event.key === 'Home'
? 0
: event.key === 'End'
? enabled.length - 1
: event.key === 'ArrowRight'
? (current + 1) % enabled.length
: (current - 1 + enabled.length) % enabled.length;
event.preventDefault();
const item = enabled[next];
onValueChange(item.value);
requestAnimationFrame(() => document.getElementById(`${label}-tab-${item.value}`)?.focus());
};
return (
<div className={`flex min-h-0 flex-1 flex-col ${className}`}>
<div
role="tablist"
aria-label={label}
className="flex h-9 shrink-0 items-end gap-1 border-b border-border bg-surface px-2"
>
{items.map((item) => (
<button
key={item.value}
type="button"
role="tab"
id={`${label}-tab-${item.value}`}
tabIndex={item.value === active.value ? 0 : -1}
aria-selected={item.value === active.value}
aria-controls={`${label}-${item.value}`}
disabled={item.disabled}
onClick={() => onValueChange(item.value)}
onKeyDown={navigate}
className={`relative flex h-8 items-center gap-1.5 px-2 text-xs font-medium focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-accent/40 ${item.value === active.value ? 'text-accent after:absolute after:inset-x-1 after:bottom-0 after:h-0.5 after:rounded-full after:bg-accent' : 'text-text-tertiary hover:text-text-primary'}`}
>
{item.icon}
{item.label}
</button>
))}
</div>
{(keepMounted ? items : [active]).map((item) => (
<div
key={item.value}
id={`${label}-${item.value}`}
role="tabpanel"
aria-labelledby={`${label}-tab-${item.value}`}
hidden={item.value !== active.value}
className="panel-scroll min-h-0 flex-1 overflow-auto"
>
{item.content}
</div>
))}
</div>
);
}
@@ -1,8 +1,38 @@
import {fireEvent,render,screen,waitFor} from '@testing-library/react';
import {ConfirmDialog,CopyButton,Kbd,PropertyRow,SearchHighlight} from './index';
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { ConfirmDialog, CopyButton, Kbd, PropertyRow, SearchHighlight } from './index';
describe('第三批基础 UI',()=>{
it('确认弹窗区分取消和危险确认动作',()=>{const confirm=vi.fn(),close=vi.fn();render(<ConfirmDialog open title="移除工程" danger onConfirm={confirm} onClose={close}>确认内容</ConfirmDialog>);fireEvent.click(screen.getByRole('button',{name:'确认'}));expect(confirm).toHaveBeenCalledTimes(1);fireEvent.click(screen.getByRole('button',{name:'取消'}));expect(close).toHaveBeenCalledTimes(1);});
it('属性行支持复制且搜索词可高亮',async()=>{const writeText=vi.fn().mockResolvedValue(undefined);Object.defineProperty(navigator,'clipboard',{configurable:true,value:{writeText}});render(<><PropertyRow label="Body" value="robot" action={<CopyButton value="robot"/>}/><SearchHighlight text="robot_arm" query="arm"/><Kbd>Ctrl+K</Kbd></>);fireEvent.click(screen.getByRole('button',{name:'复制'}));await waitFor(()=>expect(writeText).toHaveBeenCalledWith('robot'));expect(screen.getByText('arm').tagName).toBe('MARK');expect(screen.getByText('Ctrl+K')).toBeVisible();});
it('Clipboard API 不可用时复制按钮不会抛错',()=>{Object.defineProperty(navigator,'clipboard',{configurable:true,value:undefined});render(<CopyButton value="robot"/>);expect(()=>fireEvent.click(screen.getByRole('button',{name:'复制'}))).not.toThrow();});
describe('第三批基础 UI', () => {
it('确认弹窗区分取消和危险确认动作', () => {
const confirm = vi.fn(),
close = vi.fn();
render(
<ConfirmDialog open title="移除工程" danger onConfirm={confirm} onClose={close}>
确认内容
</ConfirmDialog>,
);
fireEvent.click(screen.getByRole('button', { name: '确认' }));
expect(confirm).toHaveBeenCalledTimes(1);
fireEvent.click(screen.getByRole('button', { name: '取消' }));
expect(close).toHaveBeenCalledTimes(1);
});
it('属性行支持复制且搜索词可高亮', async () => {
const writeText = vi.fn().mockResolvedValue(undefined);
Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText } });
render(
<>
<PropertyRow label="Body" value="robot" action={<CopyButton value="robot" />} />
<SearchHighlight text="robot_arm" query="arm" />
<Kbd>Ctrl+K</Kbd>
</>,
);
fireEvent.click(screen.getByRole('button', { name: '复制' }));
await waitFor(() => expect(writeText).toHaveBeenCalledWith('robot'));
expect(screen.getByText('arm').tagName).toBe('MARK');
expect(screen.getByText('Ctrl+K')).toBeVisible();
});
it('Clipboard API 不可用时复制按钮不会抛错', () => {
Object.defineProperty(navigator, 'clipboard', { configurable: true, value: undefined });
render(<CopyButton value="robot" />);
expect(() => fireEvent.click(screen.getByRole('button', { name: '复制' }))).not.toThrow();
});
});
@@ -1,6 +1,41 @@
import type {ComponentType} from 'react';
import {IconButton} from './IconButton';
export interface ToolbarItem<T extends string>{value:T;label:string;icon:ComponentType<{className?:string}>;}
export function ToolbarToggleGroup<T extends string>({items,value,onChange,label}:{items:readonly ToolbarItem<T>[];value:T;onChange:(value:T)=>void;label:string}){
return <div role="toolbar" aria-label={label} className="flex items-center gap-0.5 rounded-lg border border-border bg-surface/80 p-0.5 shadow-sm">{items.map(item=>{const Icon=item.icon;return <IconButton key={item.value} active={item.value===value} tooltip={item.label} aria-label={item.label} onClick={()=>onChange(item.value)}><Icon className="h-3.5 w-3.5"/></IconButton>;})}</div>;
import type { ComponentType } from 'react';
import { IconButton } from './IconButton';
export interface ToolbarItem<T extends string> {
value: T;
label: string;
icon: ComponentType<{ className?: string }>;
}
export function ToolbarToggleGroup<T extends string>({
items,
value,
onChange,
label,
}: {
items: readonly ToolbarItem<T>[];
value: T;
onChange: (value: T) => void;
label: string;
}) {
return (
<div
role="toolbar"
aria-label={label}
className="flex items-center gap-0.5 rounded-lg border border-border bg-surface/80 p-0.5 shadow-sm"
>
{items.map((item) => {
const Icon = item.icon;
return (
<IconButton
key={item.value}
active={item.value === value}
tooltip={item.label}
aria-label={item.label}
onClick={() => onChange(item.value)}
>
<Icon className="h-3.5 w-3.5" />
</IconButton>
);
})}
</div>
);
}
+22 -7
View File
@@ -1,9 +1,24 @@
import type {ReactElement,ReactNode} from 'react';
import type { ReactElement, ReactNode } from 'react';
export function Tooltip({content,children,side='bottom'}:{content:ReactNode;children:ReactElement;side?:'top'|'bottom'}){
if(!content)return children;
return <span className="group/tooltip relative inline-flex">
{children}
<span role="tooltip" className={`pointer-events-none absolute left-1/2 z-[500] hidden w-max max-w-64 -translate-x-1/2 rounded-md border border-border bg-surface-elevated px-2 py-1 text-[10px] font-medium text-text-primary shadow-lg group-hover/tooltip:block group-focus-within/tooltip:block ${side==='top'?'bottom-full mb-1.5':'top-full mt-1.5'}`}>{content}</span>
</span>;
export function Tooltip({
content,
children,
side = 'bottom',
}: {
content: ReactNode;
children: ReactElement;
side?: 'top' | 'bottom';
}) {
if (!content) return children;
return (
<span className="group/tooltip relative inline-flex">
{children}
<span
role="tooltip"
className={`pointer-events-none absolute left-1/2 z-[500] hidden w-max max-w-64 -translate-x-1/2 rounded-md border border-border bg-surface-elevated px-2 py-1 text-[10px] font-medium text-text-primary shadow-lg group-hover/tooltip:block group-focus-within/tooltip:block ${side === 'top' ? 'bottom-full mb-1.5' : 'top-full mt-1.5'}`}
>
{content}
</span>
</span>
);
}
@@ -1,3 +1,26 @@
import {fireEvent,render,screen} from '@testing-library/react';
import {VirtualTreeViewport} from './VirtualTreeViewport';
describe('VirtualTreeViewport',()=>{it('只渲染可视窗口并在滚动后更新行',()=>{const items=Array.from({length:1000},(_,id)=>({id,label:`节点 ${id}`}));render(<VirtualTreeViewport label="大型树" items={items} height={100} rowHeight={20} overscan={1} getKey={item=>item.id} renderRow={item=><div>{item.label}</div>}/>);const tree=screen.getByRole('tree',{name:'大型树'});expect(screen.getByText('节点 0')).toBeVisible();expect(screen.queryByText('节点 500')).not.toBeInTheDocument();Object.defineProperty(tree,'scrollTop',{configurable:true,value:10000});fireEvent.scroll(tree);expect(screen.getByText('节点 500')).toBeVisible();fireEvent.keyDown(tree,{key:'ArrowDown'});expect(tree.getAttribute('aria-activedescendant')).toContain('501');});});
import { fireEvent, render, screen } from '@testing-library/react';
import { VirtualTreeViewport } from './VirtualTreeViewport';
describe('VirtualTreeViewport', () => {
it('只渲染可视窗口并在滚动后更新行', () => {
const items = Array.from({ length: 1000 }, (_, id) => ({ id, label: `节点 ${id}` }));
render(
<VirtualTreeViewport
label="大型树"
items={items}
height={100}
rowHeight={20}
overscan={1}
getKey={(item) => item.id}
renderRow={(item) => <div>{item.label}</div>}
/>,
);
const tree = screen.getByRole('tree', { name: '大型树' });
expect(screen.getByText('节点 0')).toBeVisible();
expect(screen.queryByText('节点 500')).not.toBeInTheDocument();
Object.defineProperty(tree, 'scrollTop', { configurable: true, value: 10000 });
fireEvent.scroll(tree);
expect(screen.getByText('节点 500')).toBeVisible();
fireEvent.keyDown(tree, { key: 'ArrowDown' });
expect(tree.getAttribute('aria-activedescendant')).toContain('501');
});
});
@@ -1,2 +1,122 @@
import {useMemo,useRef,useState,type KeyboardEvent,type ReactNode} from 'react';
export function VirtualTreeViewport<T>({items,rowHeight=26,height=520,overscan=6,getKey,getLevel=()=>1,isExpandable=()=>false,isExpanded=()=>false,onToggle,onActiveChange,renderRow,label}:{items:T[];rowHeight?:number;height?:number;overscan?:number;getKey:(item:T)=>string|number;getLevel?:(item:T)=>number;isExpandable?:(item:T)=>boolean;isExpanded?:(item:T)=>boolean;onToggle?:(item:T)=>void;onActiveChange?:(item:T)=>void;renderRow:(item:T,index:number)=>ReactNode;label:string}){const root=useRef<HTMLDivElement>(null),[scrollTop,setScrollTop]=useState(0),[active,setActive]=useState(0),range=useMemo(()=>{const start=Math.max(0,Math.floor(scrollTop/rowHeight)-overscan),count=Math.ceil(height/rowHeight)+overscan*2;return {start,end:Math.min(items.length,start+count)};},[height,items.length,overscan,rowHeight,scrollTop]),safeActive=Math.min(active,Math.max(0,items.length-1)),activeId=items.length?`${label}-${getKey(items[safeActive])}`:undefined;const activate=(index:number)=>{const next=Math.min(items.length-1,Math.max(0,index));setActive(next);const item=items[next];if(item)onActiveChange?.(item);const viewport=root.current;if(viewport){const top=next*rowHeight;if(top<viewport.scrollTop)viewport.scrollTop=top;else if(top+rowHeight>viewport.scrollTop+height)viewport.scrollTop=top+rowHeight-height;}};const key=(event:KeyboardEvent<HTMLDivElement>)=>{if(!items.length)return;const item=items[safeActive],level=getLevel(item);if(event.key==='ArrowDown')activate(safeActive+1);else if(event.key==='ArrowUp')activate(safeActive-1);else if(event.key==='Home')activate(0);else if(event.key==='End')activate(items.length-1);else if(event.key==='ArrowRight'&&isExpandable(item)&&!isExpanded(item))onToggle?.(item);else if(event.key==='ArrowLeft'&&isExpandable(item)&&isExpanded(item))onToggle?.(item);else if(event.key==='ArrowLeft'){for(let index=safeActive-1;index>=0;index--)if(getLevel(items[index])<level){activate(index);break;}}else if((event.key==='Enter'||event.key===' ')&&isExpandable(item))onToggle?.(item);else return;event.preventDefault();};return <div ref={root} role="tree" aria-label={label} aria-activedescendant={activeId} tabIndex={0} className="panel-scroll relative overflow-auto outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-accent/30" style={{height:Math.min(height,Math.max(rowHeight,items.length*rowHeight))}} onKeyDown={key} onScroll={event=>{const top=event.currentTarget.scrollTop,first=Math.floor(top/rowHeight),last=first+Math.ceil(height/rowHeight);setScrollTop(top);if(safeActive<first||safeActive>last){setActive(first);if(items[first])onActiveChange?.(items[first]);}}}><div style={{height:items.length*rowHeight,position:'relative'}}>{items.slice(range.start,range.end).map((item,offset)=>{const index=range.start+offset,expandable=isExpandable(item);return <div id={`${label}-${getKey(item)}`} role="treeitem" aria-level={getLevel(item)} aria-expanded={expandable?isExpanded(item):undefined} key={getKey(item)} onMouseDown={()=>activate(index)} className={index===safeActive?'bg-accent-soft/60':''} style={{position:'absolute',left:0,right:0,top:index*rowHeight,height:rowHeight}}>{renderRow(item,index)}</div>;})}</div></div>;}
import { useMemo, useRef, useState, type KeyboardEvent, type ReactNode } from 'react';
export function VirtualTreeViewport<T>({
items,
rowHeight = 26,
height = 520,
overscan = 6,
getKey,
getLevel = () => 1,
isExpandable = () => false,
isExpanded = () => false,
onToggle,
onActiveChange,
renderRow,
label,
}: {
items: T[];
rowHeight?: number;
height?: number;
overscan?: number;
getKey: (item: T) => string | number;
getLevel?: (item: T) => number;
isExpandable?: (item: T) => boolean;
isExpanded?: (item: T) => boolean;
onToggle?: (item: T) => void;
onActiveChange?: (item: T) => void;
renderRow: (item: T, index: number) => ReactNode;
label: string;
}) {
const root = useRef<HTMLDivElement>(null),
[scrollTop, setScrollTop] = useState(0),
[active, setActive] = useState(0),
range = useMemo(() => {
const start = Math.max(0, Math.floor(scrollTop / rowHeight) - overscan),
count = Math.ceil(height / rowHeight) + overscan * 2;
return { start, end: Math.min(items.length, start + count) };
}, [height, items.length, overscan, rowHeight, scrollTop]),
safeActive = Math.min(active, Math.max(0, items.length - 1)),
activeId = items.length ? `${label}-${getKey(items[safeActive])}` : undefined;
const activate = (index: number) => {
const next = Math.min(items.length - 1, Math.max(0, index));
setActive(next);
const item = items[next];
if (item) onActiveChange?.(item);
const viewport = root.current;
if (viewport) {
const top = next * rowHeight;
if (top < viewport.scrollTop) viewport.scrollTop = top;
else if (top + rowHeight > viewport.scrollTop + height)
viewport.scrollTop = top + rowHeight - height;
}
};
const key = (event: KeyboardEvent<HTMLDivElement>) => {
if (!items.length) return;
const item = items[safeActive],
level = getLevel(item);
if (event.key === 'ArrowDown') activate(safeActive + 1);
else if (event.key === 'ArrowUp') activate(safeActive - 1);
else if (event.key === 'Home') activate(0);
else if (event.key === 'End') activate(items.length - 1);
else if (event.key === 'ArrowRight' && isExpandable(item) && !isExpanded(item))
onToggle?.(item);
else if (event.key === 'ArrowLeft' && isExpandable(item) && isExpanded(item)) onToggle?.(item);
else if (event.key === 'ArrowLeft') {
for (let index = safeActive - 1; index >= 0; index--)
if (getLevel(items[index]) < level) {
activate(index);
break;
}
} else if ((event.key === 'Enter' || event.key === ' ') && isExpandable(item)) onToggle?.(item);
else return;
event.preventDefault();
};
return (
<div
ref={root}
role="tree"
aria-label={label}
aria-activedescendant={activeId}
tabIndex={0}
className="panel-scroll relative overflow-auto outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-accent/30"
style={{ height: Math.min(height, Math.max(rowHeight, items.length * rowHeight)) }}
onKeyDown={key}
onScroll={(event) => {
const top = event.currentTarget.scrollTop,
first = Math.floor(top / rowHeight),
last = first + Math.ceil(height / rowHeight);
setScrollTop(top);
if (safeActive < first || safeActive > last) {
setActive(first);
if (items[first]) onActiveChange?.(items[first]);
}
}}
>
<div style={{ height: items.length * rowHeight, position: 'relative' }}>
{items.slice(range.start, range.end).map((item, offset) => {
const index = range.start + offset,
expandable = isExpandable(item);
return (
<div
id={`${label}-${getKey(item)}`}
role="treeitem"
aria-level={getLevel(item)}
aria-expanded={expandable ? isExpanded(item) : undefined}
key={getKey(item)}
onMouseDown={() => activate(index)}
className={index === safeActive ? 'bg-accent-soft/60' : ''}
style={{
position: 'absolute',
left: 0,
right: 0,
top: index * rowHeight,
height: rowHeight,
}}
>
{renderRow(item, index)}
</div>
);
})}
</div>
</div>
);
}
@@ -1,135 +1,197 @@
import type {PyodideInterface} from 'pyodide';
import type {PyCallable,PyDict} from 'pyodide/ffi';
import type {ControllerBindings,ControllerCommand,ControllerStatus} from './types';
import type { PyodideInterface } from 'pyodide';
import type { PyCallable, PyDict } from 'pyodide/ffi';
import type { ControllerBindings, ControllerCommand, ControllerStatus } from './types';
const DEFAULT_CONTROL_HZ=100;
const MIN_CONTROL_HZ=1;
const MAX_CONTROL_HZ=500;
const DEFAULT_CONTROL_HZ = 100;
const MIN_CONTROL_HZ = 1;
const MAX_CONTROL_HZ = 500;
let pyodidePromise:Promise<PyodideInterface>|undefined;
let pyodidePromise: Promise<PyodideInterface> | undefined;
function pyodideIndexUrl():string {
return new URL('pyodide/',document.baseURI).href;
function pyodideIndexUrl(): string {
return new URL('pyodide/', document.baseURI).href;
}
export function getPythonRuntime():Promise<PyodideInterface> {
pyodidePromise??=import('pyodide').then(({loadPyodide})=>loadPyodide({indexURL:pyodideIndexUrl()}));
export function getPythonRuntime(): Promise<PyodideInterface> {
pyodidePromise ??= import('pyodide').then(({ loadPyodide }) =>
loadPyodide({ indexURL: pyodideIndexUrl() }),
);
return pyodidePromise;
}
function destroyProxy(value:unknown):void {
if(value&&typeof value==='object'&&'destroy' in value&&typeof (value as {destroy?:unknown}).destroy==='function'){
(value as {destroy():void}).destroy();
function destroyProxy(value: unknown): void {
if (
value &&
typeof value === 'object' &&
'destroy' in value &&
typeof (value as { destroy?: unknown }).destroy === 'function'
) {
(value as { destroy(): void }).destroy();
}
}
function errorMessage(error:unknown):string {
return error instanceof Error?error.message:String(error);
function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
/** 在主线程同步执行可信的单文件 Python 控制器,保证控制发生在 mj_step 之前。 */
export class PythonControllerRuntime {
private globals?:PyDict;
private initFunction?:PyCallable;
private stepFunction?:PyCallable;
private resetFunction?:PyCallable;
private commandFunction?:PyCallable;
private disposeFunction?:PyCallable;
private state?:unknown;
private nextControlTime=0;
private statusValue:ControllerStatus;
private globals?: PyDict;
private initFunction?: PyCallable;
private stepFunction?: PyCallable;
private resetFunction?: PyCallable;
private commandFunction?: PyCallable;
private disposeFunction?: PyCallable;
private state?: unknown;
private nextControlTime = 0;
private statusValue: ControllerStatus;
private constructor(private readonly bindings:ControllerBindings,path:string,name:string,controlHz:number){
this.statusValue={language:'python',path,name,controlHz,loaded:true,enabled:false,acceptsCommands:false,lastStepMs:0};
private constructor(
private readonly bindings: ControllerBindings,
path: string,
name: string,
controlHz: number,
) {
this.statusValue = {
language: 'python',
path,
name,
controlHz,
loaded: true,
enabled: false,
acceptsCommands: false,
lastStepMs: 0,
};
}
static async load(source:string,path:string,bindings:ControllerBindings):Promise<PythonControllerRuntime>{
const pyodide=await getPythonRuntime();
const globals=pyodide.runPython('dict()') as PyDict;
globals.set('__name__','__mujoco_controller__');
try{
await pyodide.runPythonAsync(source,{globals});
if(!globals.has('step'))throw new Error('Python 控制器必须定义 step(ctx, state)');
const rawHz=globals.has('CONTROL_HZ')?Number(globals.get('CONTROL_HZ')):DEFAULT_CONTROL_HZ;
const controlHz=Math.min(MAX_CONTROL_HZ,Math.max(MIN_CONTROL_HZ,Number.isFinite(rawHz)?rawHz:DEFAULT_CONTROL_HZ));
const name=globals.has('NAME')?String(globals.get('NAME')):path.split('/').at(-1)??path;
const runtime=new PythonControllerRuntime(bindings,path,name,controlHz);
runtime.globals=globals;
runtime.initFunction=globals.has('init')?globals.get('init') as PyCallable:undefined;
runtime.stepFunction=globals.get('step') as PyCallable;
runtime.resetFunction=globals.has('reset')?globals.get('reset') as PyCallable:undefined;
runtime.commandFunction=globals.has('command')?globals.get('command') as PyCallable:undefined;
runtime.statusValue.acceptsCommands=Boolean(runtime.commandFunction);
runtime.disposeFunction=globals.has('dispose')?globals.get('dispose') as PyCallable:undefined;
runtime.state=runtime.initFunction?.(bindings.model);
if(runtime.state instanceof Promise)throw new Error('控制器函数必须同步执行');
static async load(
source: string,
path: string,
bindings: ControllerBindings,
): Promise<PythonControllerRuntime> {
const pyodide = await getPythonRuntime();
const globals = pyodide.runPython('dict()') as PyDict;
globals.set('__name__', '__mujoco_controller__');
try {
await pyodide.runPythonAsync(source, { globals });
if (!globals.has('step')) throw new Error('Python 控制器必须定义 step(ctx, state)');
const rawHz = globals.has('CONTROL_HZ')
? Number(globals.get('CONTROL_HZ'))
: DEFAULT_CONTROL_HZ;
const controlHz = Math.min(
MAX_CONTROL_HZ,
Math.max(MIN_CONTROL_HZ, Number.isFinite(rawHz) ? rawHz : DEFAULT_CONTROL_HZ),
);
const name = globals.has('NAME')
? String(globals.get('NAME'))
: (path.split('/').at(-1) ?? path);
const runtime = new PythonControllerRuntime(bindings, path, name, controlHz);
runtime.globals = globals;
runtime.initFunction = globals.has('init') ? (globals.get('init') as PyCallable) : undefined;
runtime.stepFunction = globals.get('step') as PyCallable;
runtime.resetFunction = globals.has('reset')
? (globals.get('reset') as PyCallable)
: undefined;
runtime.commandFunction = globals.has('command')
? (globals.get('command') as PyCallable)
: undefined;
runtime.statusValue.acceptsCommands = Boolean(runtime.commandFunction);
runtime.disposeFunction = globals.has('dispose')
? (globals.get('dispose') as PyCallable)
: undefined;
runtime.state = runtime.initFunction?.(bindings.model);
if (runtime.state instanceof Promise) throw new Error('控制器函数必须同步执行');
return runtime;
}catch(error){
} catch (error) {
globals.destroy();
throw new Error(`Python 控制器加载失败(${path}):${errorMessage(error)}`,{cause:error});
throw new Error(`Python 控制器加载失败(${path}):${errorMessage(error)}`, { cause: error });
}
}
status():ControllerStatus{return {...this.statusValue};}
setEnabled(enabled:boolean,currentTime:number):void {
if(!this.statusValue.loaded)return;
this.statusValue.enabled=enabled;
this.statusValue.error=undefined;
this.nextControlTime=currentTime;
if(!enabled)this.statusValue.activeCommand=undefined;
status(): ControllerStatus {
return { ...this.statusValue };
}
command(command:ControllerCommand):void {
if(!this.statusValue.enabled)throw new Error('请先启用 Python 控制器');
if(!this.commandFunction)throw new Error('当前 Python 控制器未定义 command(name, state)');
try{
const result=this.commandFunction(command,this.state);
if(result instanceof Promise)throw new Error('command() 必须是同步函数');
setEnabled(enabled: boolean, currentTime: number): void {
if (!this.statusValue.loaded) return;
this.statusValue.enabled = enabled;
this.statusValue.error = undefined;
this.nextControlTime = currentTime;
if (!enabled) this.statusValue.activeCommand = undefined;
}
command(command: ControllerCommand): void {
if (!this.statusValue.enabled) throw new Error('请先启用 Python 控制器');
if (!this.commandFunction) throw new Error('当前 Python 控制器未定义 command(name, state)');
try {
const result = this.commandFunction(command, this.state);
if (result instanceof Promise) throw new Error('command() 必须是同步函数');
destroyProxy(result);
this.statusValue.activeCommand=command==='jump'?'stop':command;
this.statusValue.error=undefined;
}catch(error){
this.statusValue.error=errorMessage(error);
throw new Error(`Python 控制指令失败:${this.statusValue.error}`,{cause:error});
this.statusValue.activeCommand = command === 'jump' ? 'stop' : command;
this.statusValue.error = undefined;
} catch (error) {
this.statusValue.error = errorMessage(error);
throw new Error(`Python 控制指令失败:${this.statusValue.error}`, { cause: error });
}
}
stepIfDue(time:number):void {
if(!this.statusValue.enabled||!this.stepFunction||time+1e-9<this.nextControlTime)return;
const dt=1/this.statusValue.controlHz;
const started=performance.now();
try{
const result=this.stepFunction(this.bindings.createStepApi(time,dt),this.state);
if(result instanceof Promise)throw new Error('step() 必须是同步函数');
stepIfDue(time: number): void {
if (!this.statusValue.enabled || !this.stepFunction || time + 1e-9 < this.nextControlTime)
return;
const dt = 1 / this.statusValue.controlHz;
const started = performance.now();
try {
const result = this.stepFunction(this.bindings.createStepApi(time, dt), this.state);
if (result instanceof Promise) throw new Error('step() 必须是同步函数');
destroyProxy(result);
this.statusValue.lastStepMs=performance.now()-started;
this.nextControlTime=time+dt;
}catch(error){
this.statusValue.lastStepMs=performance.now()-started;
this.statusValue.enabled=false;
this.statusValue.error=errorMessage(error);
throw new Error(`Python 控制器运行失败:${this.statusValue.error}`,{cause:error});
this.statusValue.lastStepMs = performance.now() - started;
this.nextControlTime = time + dt;
} catch (error) {
this.statusValue.lastStepMs = performance.now() - started;
this.statusValue.enabled = false;
this.statusValue.error = errorMessage(error);
throw new Error(`Python 控制器运行失败:${this.statusValue.error}`, { cause: error });
}
}
reset(currentTime:number):void {
this.nextControlTime=currentTime;
this.statusValue.activeCommand=undefined;
if(!this.resetFunction)return;
try{const result=this.resetFunction(this.state);destroyProxy(result);}
catch(error){this.statusValue.enabled=false;this.statusValue.error=errorMessage(error);throw error;}
reset(currentTime: number): void {
this.nextControlTime = currentTime;
this.statusValue.activeCommand = undefined;
if (!this.resetFunction) return;
try {
const result = this.resetFunction(this.state);
destroyProxy(result);
} catch (error) {
this.statusValue.enabled = false;
this.statusValue.error = errorMessage(error);
throw error;
}
}
dispose():void {
if(!this.statusValue.loaded)return;
this.statusValue.loaded=false;
this.statusValue.enabled=false;
try{if(this.disposeFunction){const result=this.disposeFunction(this.state);destroyProxy(result);}}
finally{
destroyProxy(this.state);this.state=undefined;
this.initFunction?.destroy();this.stepFunction?.destroy();this.resetFunction?.destroy();this.commandFunction?.destroy();this.disposeFunction?.destroy();this.globals?.destroy();
this.initFunction=undefined;this.stepFunction=undefined;this.resetFunction=undefined;this.commandFunction=undefined;this.disposeFunction=undefined;this.globals=undefined;
dispose(): void {
if (!this.statusValue.loaded) return;
this.statusValue.loaded = false;
this.statusValue.enabled = false;
try {
if (this.disposeFunction) {
const result = this.disposeFunction(this.state);
destroyProxy(result);
}
} finally {
destroyProxy(this.state);
this.state = undefined;
this.initFunction?.destroy();
this.stepFunction?.destroy();
this.resetFunction?.destroy();
this.commandFunction?.destroy();
this.disposeFunction?.destroy();
this.globals?.destroy();
this.initFunction = undefined;
this.stepFunction = undefined;
this.resetFunction = undefined;
this.commandFunction = undefined;
this.disposeFunction = undefined;
this.globals = undefined;
}
}
}
+26 -25
View File
@@ -1,37 +1,38 @@
export type ControllerCommand='stop'|'forward'|'backward'|'turn_left'|'turn_right'|'jump';
export type ControllerCommand =
'stop' | 'forward' | 'backward' | 'turn_left' | 'turn_right' | 'jump';
export interface ControllerStatus {
language:'python';
path:string;
name:string;
controlHz:number;
loaded:boolean;
enabled:boolean;
acceptsCommands:boolean;
activeCommand?:ControllerCommand;
lastStepMs:number;
error?:string;
language: 'python';
path: string;
name: string;
controlHz: number;
loaded: boolean;
enabled: boolean;
acceptsCommands: boolean;
activeCommand?: ControllerCommand;
lastStepMs: number;
error?: string;
}
export interface ControllerModelApi {
joint(name:string):number;
actuator(name:string):number;
sensor(name:string):number;
body(name:string):number;
joint(name: string): number;
actuator(name: string): number;
sensor(name: string): number;
body(name: string): number;
}
export interface ControllerStepApi {
readonly time:number;
readonly dt:number;
qpos(jointId:number):number;
qvel(jointId:number):number;
sensor(sensorId:number):number[];
body_quat(bodyId:number):number[];
body_position(bodyId:number):number[];
set_control(actuatorId:number,value:number):void;
readonly time: number;
readonly dt: number;
qpos(jointId: number): number;
qvel(jointId: number): number;
sensor(sensorId: number): number[];
body_quat(bodyId: number): number[];
body_position(bodyId: number): number[];
set_control(actuatorId: number, value: number): void;
}
export interface ControllerBindings {
readonly model:ControllerModelApi;
createStepApi(time:number,dt:number):ControllerStepApi;
readonly model: ControllerModelApi;
createStepApi(time: number, dt: number): ControllerStepApi;
}
+15 -5
View File
@@ -1,6 +1,16 @@
import {StrictMode} from 'react';
import {createRoot} from 'react-dom/client';
import {App} from './app/App';
import {ErrorBoundary} from './app/ErrorBoundary';
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { App } from './app/App';
import { ErrorBoundary } from './app/ErrorBoundary';
import './styles.css';
if(location.protocol==='file:')document.body.innerHTML='<main style="font-family:sans-serif;padding:2rem"><h1>需要本地 HTTP 服务器</h1><p>请运行 npm run dev,不能直接通过 file:// 打开。</p></main>';else createRoot(document.getElementById('root')!).render(<StrictMode><ErrorBoundary><App/></ErrorBoundary></StrictMode>);
if (location.protocol === 'file:')
document.body.innerHTML =
'<main style="font-family:sans-serif;padding:2rem"><h1>需要本地 HTTP 服务器</h1><p>请运行 npm run dev,不能直接通过 file:// 打开。</p></main>';
else
createRoot(document.getElementById('root')!).render(
<StrictMode>
<ErrorBoundary>
<App />
</ErrorBoundary>
</StrictMode>,
);
@@ -1,24 +1,64 @@
import {fireEvent,render,screen} from '@testing-library/react';
import {buildBodyTree,countModelStructureSearchResults,ModelStructureTree} from './ModelStructureTree';
import type {BodyInfo,JointInfo} from '../simulation/SimulationSession';
import { fireEvent, render, screen } from '@testing-library/react';
import {
buildBodyTree,
countModelStructureSearchResults,
ModelStructureTree,
} from './ModelStructureTree';
import type { BodyInfo, JointInfo } from '../simulation/SimulationSession';
const bodies:BodyInfo[]=[{id:0,name:'world',parentId:0},{id:1,name:'base',parentId:0},{id:2,name:'arm',parentId:1}];
const joints:JointInfo[]=[{id:0,name:'arm_joint',type:3,value:0,min:-1,max:1,limitMin:-1,limitMax:1,limited:true,limitsIgnored:false,editable:true,bodyId:2,axis:[0,0,1]}];
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'});
describe('ModelStructureTree', () => {
it('按 body 父子关系构建结构,并将关节放在所属 body 下', () => {
const tree = buildBodyTree(bodies, joints);
expect(tree[0]).toMatchObject({ id: 1, name: 'base' });
expect(tree[0].children[0]).toMatchObject({ id: 2, name: 'arm' });
expect(tree[0].children[0].joints[0].name).toBe('arm_joint');
});
it('鼠标进入和离开关节时通知查看器高亮',()=>{
const hover=vi.fn();render(<ModelStructureTree bodies={bodies} joints={joints} onJointHover={hover}/>);expect(screen.getByRole('treeitem',{name:'base'})).toHaveAttribute('aria-expanded','true');const item=screen.getByRole('treeitem',{name:/arm_joint/});
fireEvent.mouseEnter(item);fireEvent.mouseLeave(item);expect(hover.mock.calls).toEqual([[0],[null]]);
it('鼠标进入和离开关节时通知查看器高亮', () => {
const hover = vi.fn();
render(<ModelStructureTree bodies={bodies} joints={joints} onJointHover={hover} />);
expect(screen.getByRole('treeitem', { name: 'base' })).toHaveAttribute('aria-expanded', 'true');
const item = screen.getByRole('treeitem', { name: /arm_joint/ });
fireEvent.mouseEnter(item);
fireEvent.mouseLeave(item);
expect(hover.mock.calls).toEqual([[0], [null]]);
});
it('按 Body 或关节名称过滤并保留祖先路径',()=>{
render(<ModelStructureTree bodies={bodies} joints={joints} query="arm_joint" onJointHover={()=>{}}/>);expect(screen.getByRole('treeitem',{name:'base'})).toBeVisible();expect(screen.getByRole('treeitem',{name:/arm_joint/})).toBeVisible();expect(countModelStructureSearchResults(bodies,joints,'arm_joint')).toBe(3);expect(countModelStructureSearchResults(bodies,joints,'world')).toBe(0);
it('按 Body 或关节名称过滤并保留祖先路径', () => {
render(
<ModelStructureTree
bodies={bodies}
joints={joints}
query="arm_joint"
onJointHover={() => {}}
/>,
);
expect(screen.getByRole('treeitem', { name: 'base' })).toBeVisible();
expect(screen.getByRole('treeitem', { name: /arm_joint/ })).toBeVisible();
expect(countModelStructureSearchResults(bodies, joints, 'arm_joint')).toBe(3);
expect(countModelStructureSearchResults(bodies, joints, 'world')).toBe(0);
});
});
+248 -23
View File
@@ -1,32 +1,257 @@
import {useState} from 'react';
import {Box,Disc3} from 'lucide-react';
import type {BodyInfo,JointInfo} from '../simulation/SimulationSession';
import {EmptySearchState,SearchHighlight,VirtualTreeViewport} from '../components/ui';
import { useState } from 'react';
import { Box, Disc3 } from 'lucide-react';
import type { BodyInfo, JointInfo } from '../simulation/SimulationSession';
import { EmptySearchState, SearchHighlight, VirtualTreeViewport } from '../components/ui';
interface BodyNode extends BodyInfo {children:BodyNode[];joints:JointInfo[];}
interface BodyNode extends BodyInfo {
children: BodyNode[];
joints: JointInfo[];
}
// eslint-disable-next-line react-refresh/only-export-components
export function buildBodyTree(bodies:BodyInfo[],joints:JointInfo[]):BodyNode[]{
const nodes=new Map<number,BodyNode>();for(const body of bodies)if(body.id>0)nodes.set(body.id,{...body,children:[],joints:joints.filter(joint=>joint.bodyId===body.id)});
const roots:BodyNode[]=[];
for(const node of nodes.values()){const parent=nodes.get(node.parentId);if(parent)parent.children.push(node);else roots.push(node);}
const sort=(items:BodyNode[])=>{items.sort((a,b)=>a.id-b.id);for(const item of items)sort(item.children);};sort(roots);return roots;
export function buildBodyTree(bodies: BodyInfo[], joints: JointInfo[]): BodyNode[] {
const nodes = new Map<number, BodyNode>();
for (const body of bodies)
if (body.id > 0)
nodes.set(body.id, {
...body,
children: [],
joints: joints.filter((joint) => joint.bodyId === body.id),
});
const roots: BodyNode[] = [];
for (const node of nodes.values()) {
const parent = nodes.get(node.parentId);
if (parent) parent.children.push(node);
else roots.push(node);
}
const sort = (items: BodyNode[]) => {
items.sort((a, b) => a.id - b.id);
for (const item of items) sort(item.children);
};
sort(roots);
return roots;
}
function BodyBranch({node,depth,onJointHover,searching,query}:{node:BodyNode;depth:number;onJointHover:(jointId:number|null)=>void;searching:boolean;query:string}){
const hasChildren=node.joints.length>0||node.children.length>0;const [open,setOpen]=useState(depth<2),shownOpen=searching||open;
return <li role="none">{hasChildren?<details open={shownOpen} onToggle={event=>{if(!searching)setOpen(event.currentTarget.open);}}><summary role="treeitem" aria-expanded={shownOpen} tabIndex={0} onClick={event=>{if(searching)event.preventDefault();}} className="flex cursor-pointer select-none items-center gap-1.5 truncate rounded px-1.5 py-1 text-xs text-text-secondary hover:bg-element-hover focus-visible:ring-2 focus-visible:ring-accent/30"><Box aria-hidden="true" className="h-3.5 w-3.5 shrink-0 text-accent"/><span className="truncate"><SearchHighlight text={node.name} query={query}/></span></summary><ul role="group" className="ml-3 border-l border-border pl-1">{node.joints.map(joint=><li role="none" key={joint.id}><span role="treeitem" tabIndex={0} className="flex cursor-default items-center gap-1.5 truncate rounded px-1.5 py-1 text-xs text-warning hover:bg-warning-soft focus:bg-warning-soft focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/30" onMouseEnter={()=>onJointHover(joint.id)} onMouseLeave={()=>onJointHover(null)} onFocus={()=>onJointHover(joint.id)} onBlur={()=>onJointHover(null)} title={`关节:${joint.name}`}><Disc3 aria-hidden="true" className="h-3.5 w-3.5 shrink-0"/><SearchHighlight text={joint.name} query={query}/></span></li>)}{node.children.map(child=><BodyBranch key={child.id} node={child} depth={depth+1} onJointHover={onJointHover} searching={searching} query={query}/>)}</ul></details>:<div role="treeitem" tabIndex={0} className="flex items-center gap-1.5 truncate rounded px-1.5 py-1 text-xs text-text-secondary focus-visible:ring-2 focus-visible:ring-accent/30"><Box aria-hidden="true" className="h-3.5 w-3.5 shrink-0 text-accent"/><SearchHighlight text={node.name} query={query}/></div>}</li>;
function BodyBranch({
node,
depth,
onJointHover,
searching,
query,
}: {
node: BodyNode;
depth: number;
onJointHover: (jointId: number | null) => void;
searching: boolean;
query: string;
}) {
const hasChildren = node.joints.length > 0 || node.children.length > 0;
const [open, setOpen] = useState(depth < 2),
shownOpen = searching || open;
return (
<li role="none">
{hasChildren ? (
<details
open={shownOpen}
onToggle={(event) => {
if (!searching) setOpen(event.currentTarget.open);
}}
>
<summary
role="treeitem"
aria-expanded={shownOpen}
tabIndex={0}
onClick={(event) => {
if (searching) event.preventDefault();
}}
className="flex cursor-pointer select-none items-center gap-1.5 truncate rounded px-1.5 py-1 text-xs text-text-secondary hover:bg-element-hover focus-visible:ring-2 focus-visible:ring-accent/30"
>
<Box aria-hidden="true" className="h-3.5 w-3.5 shrink-0 text-accent" />
<span className="truncate">
<SearchHighlight text={node.name} query={query} />
</span>
</summary>
<ul role="group" className="ml-3 border-l border-border pl-1">
{node.joints.map((joint) => (
<li role="none" key={joint.id}>
<span
role="treeitem"
tabIndex={0}
className="flex cursor-default items-center gap-1.5 truncate rounded px-1.5 py-1 text-xs text-warning hover:bg-warning-soft focus:bg-warning-soft focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/30"
onMouseEnter={() => onJointHover(joint.id)}
onMouseLeave={() => onJointHover(null)}
onFocus={() => onJointHover(joint.id)}
onBlur={() => onJointHover(null)}
title={`关节:${joint.name}`}
>
<Disc3 aria-hidden="true" className="h-3.5 w-3.5 shrink-0" />
<SearchHighlight text={joint.name} query={query} />
</span>
</li>
))}
{node.children.map((child) => (
<BodyBranch
key={child.id}
node={child}
depth={depth + 1}
onJointHover={onJointHover}
searching={searching}
query={query}
/>
))}
</ul>
</details>
) : (
<div
role="treeitem"
tabIndex={0}
className="flex items-center gap-1.5 truncate rounded px-1.5 py-1 text-xs text-text-secondary focus-visible:ring-2 focus-visible:ring-accent/30"
>
<Box aria-hidden="true" className="h-3.5 w-3.5 shrink-0 text-accent" />
<SearchHighlight text={node.name} query={query} />
</div>
)}
</li>
);
}
function filterBodies(nodes:BodyNode[],query:string):BodyNode[]{if(!query)return nodes;return nodes.flatMap(node=>{if(node.name.toLocaleLowerCase().includes(query))return [node];const joints=node.joints.filter(joint=>joint.name.toLocaleLowerCase().includes(query)),children=filterBodies(node.children,query);return joints.length||children.length?[{...node,joints,children}]:[];});}
function countBodyNodes(nodes:BodyNode[]):number{return nodes.reduce((total,node)=>total+1+node.joints.length+countBodyNodes(node.children),0);}
function filterBodies(nodes: BodyNode[], query: string): BodyNode[] {
if (!query) return nodes;
return nodes.flatMap((node) => {
if (node.name.toLocaleLowerCase().includes(query)) return [node];
const joints = node.joints.filter((joint) => joint.name.toLocaleLowerCase().includes(query)),
children = filterBodies(node.children, query);
return joints.length || children.length ? [{ ...node, joints, children }] : [];
});
}
function countBodyNodes(nodes: BodyNode[]): number {
return nodes.reduce(
(total, node) => total + 1 + node.joints.length + countBodyNodes(node.children),
0,
);
}
// eslint-disable-next-line react-refresh/only-export-components
export function countModelStructureSearchResults(bodies:BodyInfo[],joints:JointInfo[],query:string):number{return countBodyNodes(filterBodies(buildBodyTree(bodies,joints),query.trim().toLocaleLowerCase()));}
type FlatBodyItem={kind:'body';body:BodyNode;depth:number}|{kind:'joint';joint:JointInfo;depth:number};
function flattenBodies(nodes:BodyNode[],expanded:Set<number>,searching:boolean,depth=0):FlatBodyItem[]{return nodes.flatMap(body=>[{kind:'body' as const,body,depth},...(searching||expanded.has(body.id)?[...body.joints.map(joint=>({kind:'joint' as const,joint,depth:depth+1})),...flattenBodies(body.children,expanded,searching,depth+1)]:[])]);}
function initiallyExpanded(nodes:BodyNode[],depth=0):number[]{return nodes.flatMap(body=>[...(depth<2?[body.id]:[]),...initiallyExpanded(body.children,depth+1)]);}
export function ModelStructureTree({bodies,joints,onJointHover,query=''}:{bodies:BodyInfo[];joints:JointInfo[];onJointHover:(jointId:number|null)=>void;query?:string}){
const normalized=query.trim().toLocaleLowerCase(),roots=filterBodies(buildBodyTree(bodies,joints),normalized),[virtualExpanded,setVirtualExpanded]=useState(()=>new Set(initiallyExpanded(buildBodyTree(bodies,joints))));
if(bodies.length+joints.length>500&&roots.length){const searching=Boolean(normalized),flat=flattenBodies(roots,virtualExpanded,searching),toggle=(item:FlatBodyItem)=>{if(item.kind!=='body'||searching)return;setVirtualExpanded(current=>{const next=new Set(current);if(next.has(item.body.id))next.delete(item.body.id);else next.add(item.body.id);return next;});};return <nav aria-label="模型结构树"><VirtualTreeViewport label="虚拟化模型结构树" items={flat} getKey={item=>item.kind==='body'?`b:${item.body.id}`:`j:${item.joint.id}`} getLevel={item=>item.depth+1} isExpandable={item=>item.kind==='body'&&(item.body.joints.length>0||item.body.children.length>0)} isExpanded={item=>item.kind==='body'&&(searching||virtualExpanded.has(item.body.id))} onToggle={toggle} onActiveChange={item=>onJointHover(item.kind==='joint'?item.joint.id:null)} renderRow={item=>item.kind==='body'?<div onDoubleClick={()=>toggle(item)} className="flex h-full items-center gap-1.5 px-1.5 text-xs text-text-secondary" style={{paddingLeft:item.depth*12+6}}><Box className="h-3.5 w-3.5 text-accent"/><SearchHighlight text={item.body.name} query={query}/></div>:<div className="flex h-full items-center gap-1.5 px-1.5 text-xs text-warning" style={{paddingLeft:item.depth*12+6}} onMouseEnter={()=>onJointHover(item.joint.id)} onMouseLeave={()=>onJointHover(null)}><Disc3 className="h-3.5 w-3.5"/><SearchHighlight text={item.joint.name} query={query}/></div>}/></nav>;}
return <nav aria-label="模型结构树">{roots.length?<ul role="tree">{roots.map(root=><BodyBranch key={root.id} node={root} depth={0} onJointHover={onJointHover} searching={Boolean(normalized)} query={query}/>)}</ul>:<EmptySearchState label="没有匹配的 Body 或关节"/>}</nav>;
export function countModelStructureSearchResults(
bodies: BodyInfo[],
joints: JointInfo[],
query: string,
): number {
return countBodyNodes(
filterBodies(buildBodyTree(bodies, joints), query.trim().toLocaleLowerCase()),
);
}
type FlatBodyItem =
| { kind: 'body'; body: BodyNode; depth: number }
| { kind: 'joint'; joint: JointInfo; depth: number };
function flattenBodies(
nodes: BodyNode[],
expanded: Set<number>,
searching: boolean,
depth = 0,
): FlatBodyItem[] {
return nodes.flatMap((body) => [
{ kind: 'body' as const, body, depth },
...(searching || expanded.has(body.id)
? [
...body.joints.map((joint) => ({ kind: 'joint' as const, joint, depth: depth + 1 })),
...flattenBodies(body.children, expanded, searching, depth + 1),
]
: []),
]);
}
function initiallyExpanded(nodes: BodyNode[], depth = 0): number[] {
return nodes.flatMap((body) => [
...(depth < 2 ? [body.id] : []),
...initiallyExpanded(body.children, depth + 1),
]);
}
export function ModelStructureTree({
bodies,
joints,
onJointHover,
query = '',
}: {
bodies: BodyInfo[];
joints: JointInfo[];
onJointHover: (jointId: number | null) => void;
query?: string;
}) {
const normalized = query.trim().toLocaleLowerCase(),
roots = filterBodies(buildBodyTree(bodies, joints), normalized),
[virtualExpanded, setVirtualExpanded] = useState(
() => new Set(initiallyExpanded(buildBodyTree(bodies, joints))),
);
if (bodies.length + joints.length > 500 && roots.length) {
const searching = Boolean(normalized),
flat = flattenBodies(roots, virtualExpanded, searching),
toggle = (item: FlatBodyItem) => {
if (item.kind !== 'body' || searching) return;
setVirtualExpanded((current) => {
const next = new Set(current);
if (next.has(item.body.id)) next.delete(item.body.id);
else next.add(item.body.id);
return next;
});
};
return (
<nav aria-label="模型结构树">
<VirtualTreeViewport
label="虚拟化模型结构树"
items={flat}
getKey={(item) => (item.kind === 'body' ? `b:${item.body.id}` : `j:${item.joint.id}`)}
getLevel={(item) => item.depth + 1}
isExpandable={(item) =>
item.kind === 'body' && (item.body.joints.length > 0 || item.body.children.length > 0)
}
isExpanded={(item) =>
item.kind === 'body' && (searching || virtualExpanded.has(item.body.id))
}
onToggle={toggle}
onActiveChange={(item) => onJointHover(item.kind === 'joint' ? item.joint.id : null)}
renderRow={(item) =>
item.kind === 'body' ? (
<div
onDoubleClick={() => toggle(item)}
className="flex h-full items-center gap-1.5 px-1.5 text-xs text-text-secondary"
style={{ paddingLeft: item.depth * 12 + 6 }}
>
<Box className="h-3.5 w-3.5 text-accent" />
<SearchHighlight text={item.body.name} query={query} />
</div>
) : (
<div
className="flex h-full items-center gap-1.5 px-1.5 text-xs text-warning"
style={{ paddingLeft: item.depth * 12 + 6 }}
onMouseEnter={() => onJointHover(item.joint.id)}
onMouseLeave={() => onJointHover(null)}
>
<Disc3 className="h-3.5 w-3.5" />
<SearchHighlight text={item.joint.name} query={query} />
</div>
)
}
/>
</nav>
);
}
return (
<nav aria-label="模型结构树">
{roots.length ? (
<ul role="tree">
{roots.map((root) => (
<BodyBranch
key={root.id}
node={root}
depth={0}
onJointHover={onJointHover}
searching={Boolean(normalized)}
query={query}
/>
))}
</ul>
) : (
<EmptySearchState label="没有匹配的 Body 或关节" />
)}
</nav>
);
}
+54 -22
View File
@@ -1,27 +1,44 @@
import {fireEvent,render,screen,within} from '@testing-library/react';
import {buildProjectTree,countProjectSearchResults,ProjectTree} from './ProjectTree';
import { fireEvent, render, screen, within } from '@testing-library/react';
import { buildProjectTree, countProjectSearchResults, ProjectTree } from './ProjectTree';
const files=[
{path:'robot/meshes/arm.obj',size:2048},
{path:'robot/model.xml',size:512},
{path:'robot/textures/body.png',size:4096},
{path:'README.txt',size:10},
const files = [
{ path: 'robot/meshes/arm.obj', size: 2048 },
{ path: 'robot/model.xml', size: 512 },
{ path: 'robot/textures/body.png', size: 4096 },
{ path: 'README.txt', size: 10 },
];
describe('ProjectTree',()=>{
it('按路径构建多级目录,并将目录排在文件前面',()=>{
const tree=buildProjectTree(files);
expect(tree.map(node=>[node.kind,node.name])).toEqual([['directory','robot'],['file','README.txt']]);
const robot=tree[0];
expect(robot.children?.map(node=>[node.kind,node.name])).toEqual([
['directory','meshes'],['directory','textures'],['file','model.xml'],
describe('ProjectTree', () => {
it('按路径构建多级目录,并将目录排在文件前面', () => {
const tree = buildProjectTree(files);
expect(tree.map((node) => [node.kind, node.name])).toEqual([
['directory', 'robot'],
['file', 'README.txt'],
]);
expect(robot.children?.[0].children?.[0]).toMatchObject({kind:'file',name:'arm.obj',path:'robot/meshes/arm.obj'});
const robot = tree[0];
expect(robot.children?.map((node) => [node.kind, node.name])).toEqual([
['directory', 'meshes'],
['directory', 'textures'],
['file', 'model.xml'],
]);
expect(robot.children?.[0].children?.[0]).toMatchObject({
kind: 'file',
name: 'arm.obj',
path: 'robot/meshes/arm.obj',
});
});
it('以可折叠目录显示文件名,而不是平铺完整路径',()=>{
render(<ProjectTree files={files} entries={[{path:'robot/model.xml',format:'urdf',label:'model'}]} selectedEntry="robot/model.xml"/>);
const tree=screen.getByRole('navigation',{name:'工程文件树'}),robot=within(tree).getByText('robot'),meshes=within(tree).getByText('meshes');
it('以可折叠目录显示文件名,而不是平铺完整路径', () => {
render(
<ProjectTree
files={files}
entries={[{ path: 'robot/model.xml', format: 'urdf', label: 'model' }]}
selectedEntry="robot/model.xml"
/>,
);
const tree = screen.getByRole('navigation', { name: '工程文件树' }),
robot = within(tree).getByText('robot'),
meshes = within(tree).getByText('meshes');
expect(robot.closest('details')).toHaveAttribute('open');
expect(meshes.closest('details')).not.toHaveAttribute('open');
fireEvent.click(meshes);
@@ -30,10 +47,25 @@ describe('ProjectTree',()=>{
expect(within(tree).getByText('urdf')).toBeVisible();
});
it('搜索时只保留匹配文件及其目录路径',()=>{
render(<ProjectTree files={files} entries={[]} query="arm.obj"/>);
expect(screen.getByText('robot')).toBeVisible();expect(screen.getByText('meshes')).toBeVisible();expect(screen.getByText('arm.obj')).toBeVisible();expect(screen.queryByText('README.txt')).not.toBeInTheDocument();expect(countProjectSearchResults(files,'meshes')).toBe(3);expect(countProjectSearchResults(files,'robot/meshes')).toBe(0);
it('搜索时只保留匹配文件及其目录路径', () => {
render(<ProjectTree files={files} entries={[]} query="arm.obj" />);
expect(screen.getByText('robot')).toBeVisible();
expect(screen.getByText('meshes')).toBeVisible();
expect(screen.getByText('arm.obj')).toBeVisible();
expect(screen.queryByText('README.txt')).not.toBeInTheDocument();
expect(countProjectSearchResults(files, 'meshes')).toBe(3);
expect(countProjectSearchResults(files, 'robot/meshes')).toBe(0);
});
it('大型工程使用可键盘折叠的虚拟树',()=>{const large=Array.from({length:401},(_,index)=>({path:`assets/file-${index}.obj`,size:1}));render(<ProjectTree files={large} entries={[]}/>);const tree=screen.getByRole('tree',{name:'虚拟化工程文件树'});expect(tree).toHaveAttribute('aria-activedescendant',expect.stringContaining('assets'));fireEvent.keyDown(tree,{key:'ArrowLeft'});expect(screen.queryByText('file-0.obj')).not.toBeInTheDocument();});
it('大型工程使用可键盘折叠的虚拟树', () => {
const large = Array.from({ length: 401 }, (_, index) => ({
path: `assets/file-${index}.obj`,
size: 1,
}));
render(<ProjectTree files={large} entries={[]} />);
const tree = screen.getByRole('tree', { name: '虚拟化工程文件树' });
expect(tree).toHaveAttribute('aria-activedescendant', expect.stringContaining('assets'));
fireEvent.keyDown(tree, { key: 'ArrowLeft' });
expect(screen.queryByText('file-0.obj')).not.toBeInTheDocument();
});
});
+290 -52
View File
@@ -1,76 +1,314 @@
import {useState} from 'react';
import {Box,File,FileCode2,Folder,FolderOpen} from 'lucide-react';
import type {ModelEntry} from './types';
import {EmptySearchState,SearchHighlight,VirtualTreeViewport} from '../components/ui';
import { useState } from 'react';
import { Box, File, FileCode2, Folder, FolderOpen } from 'lucide-react';
import type { ModelEntry } from './types';
import { EmptySearchState, SearchHighlight, VirtualTreeViewport } from '../components/ui';
export interface ProjectTreeFile {path:string;size:number;}
export interface ProjectTreeFile {
path: string;
size: number;
}
export interface ProjectTreeNode {
name:string;
path:string;
kind:'directory'|'file';
size?:number;
children?:ProjectTreeNode[];
name: string;
path: string;
kind: 'directory' | 'file';
size?: number;
children?: ProjectTreeNode[];
}
interface MutableDirectory {
name:string;
path:string;
directories:Map<string,MutableDirectory>;
files:ProjectTreeNode[];
name: string;
path: string;
directories: Map<string, MutableDirectory>;
files: ProjectTreeNode[];
}
function compareNodes(a:ProjectTreeNode,b:ProjectTreeNode):number {
if(a.kind!==b.kind)return a.kind==='directory'?-1:1;
return a.name.localeCompare(b.name,'zh-CN',{numeric:true,sensitivity:'base'});
function compareNodes(a: ProjectTreeNode, b: ProjectTreeNode): number {
if (a.kind !== b.kind) return a.kind === 'directory' ? -1 : 1;
return a.name.localeCompare(b.name, 'zh-CN', { numeric: true, sensitivity: 'base' });
}
/** 将规范化后的工程路径转换为“目录优先、名称排序”的资源树。 */
// 同文件导出纯函数是为了让资源树的数据转换可独立测试。
// eslint-disable-next-line react-refresh/only-export-components
export function buildProjectTree(files:ProjectTreeFile[]):ProjectTreeNode[] {
const root:MutableDirectory={name:'',path:'',directories:new Map(),files:[]};
for(const file of files){
const parts=file.path.split('/').filter(Boolean);
if(!parts.length)continue;
let parent=root;
for(const part of parts.slice(0,-1)){
const path=parent.path?`${parent.path}/${part}`:part;
let directory=parent.directories.get(part);
if(!directory){directory={name:part,path,directories:new Map(),files:[]};parent.directories.set(part,directory);}
parent=directory;
export function buildProjectTree(files: ProjectTreeFile[]): ProjectTreeNode[] {
const root: MutableDirectory = { name: '', path: '', directories: new Map(), files: [] };
for (const file of files) {
const parts = file.path.split('/').filter(Boolean);
if (!parts.length) continue;
let parent = root;
for (const part of parts.slice(0, -1)) {
const path = parent.path ? `${parent.path}/${part}` : part;
let directory = parent.directories.get(part);
if (!directory) {
directory = { name: part, path, directories: new Map(), files: [] };
parent.directories.set(part, directory);
}
parent = directory;
}
parent.files.push({name:parts.at(-1)!,path:file.path,kind:'file',size:file.size});
parent.files.push({ name: parts.at(-1)!, path: file.path, kind: 'file', size: file.size });
}
const finish=(directory:MutableDirectory):ProjectTreeNode[]=>[
...Array.from(directory.directories.values(),child=>({name:child.name,path:child.path,kind:'directory' as const,children:finish(child)})),
...directory.files,
].sort(compareNodes);
const finish = (directory: MutableDirectory): ProjectTreeNode[] =>
[
...Array.from(directory.directories.values(), (child) => ({
name: child.name,
path: child.path,
kind: 'directory' as const,
children: finish(child),
})),
...directory.files,
].sort(compareNodes);
return finish(root);
}
function formatSize(bytes:number):string {
if(bytes<1024)return `${bytes} B`;
if(bytes<1024*1024)return `${(bytes/1024).toFixed(bytes<10*1024?1:0)} KB`;
return `${(bytes/(1024*1024)).toFixed(1)} MB`;
function formatSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(bytes < 10 * 1024 ? 1 : 0)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
interface TreeNodeProps {entryFormats:Map<string,ModelEntry['format']>;selectedEntry?:string;expandedEntry?:string;searching:boolean;query:string;}
function DirectoryNode({node,entryFormats,selectedEntry,expandedEntry,searching,query}:TreeNodeProps&{node:ProjectTreeNode}){const [open,setOpen]=useState(Boolean(expandedEntry?.startsWith(`${node.path}/`)));const shownOpen=searching||open,FolderIcon=shownOpen?FolderOpen:Folder;return <li><details open={shownOpen} onToggle={event=>{if(!searching)setOpen(event.currentTarget.open);}}><summary title={node.path} onClick={event=>{if(searching)event.preventDefault();}} className="flex cursor-pointer select-none items-center gap-1.5 truncate rounded px-1.5 py-1 text-xs text-text-secondary hover:bg-element-hover"><FolderIcon aria-hidden="true" className="h-3.5 w-3.5 shrink-0 text-accent"/><span className="truncate"><SearchHighlight text={node.name} query={query}/></span></summary><TreeNodes nodes={node.children??[]} entryFormats={entryFormats} selectedEntry={selectedEntry} expandedEntry={expandedEntry} searching={searching} query={query}/></details></li>;}
function TreeNodes({nodes,entryFormats,selectedEntry,expandedEntry,searching,query}:TreeNodeProps&{nodes:ProjectTreeNode[]}){
return <ul role="group" className="ml-3 border-l border-border pl-1">{nodes.map(node=>{if(node.kind==='directory')return <DirectoryNode key={`d:${node.path}`} node={node} entryFormats={entryFormats} selectedEntry={selectedEntry} expandedEntry={expandedEntry} searching={searching} query={query}/>;const EntryIcon=entryFormats.has(node.path)?FileCode2:node.path.endsWith('.obj')||node.path.endsWith('.stl')||node.path.endsWith('.dae')?Box:File;return <li key={`f:${node.path}`} title={node.path} className={`flex min-w-0 items-center gap-1.5 rounded px-1.5 py-1 text-xs ${selectedEntry===node.path?'bg-accent-soft text-accent':'text-text-secondary hover:bg-element-hover'}`}><EntryIcon aria-hidden="true" className="h-3.5 w-3.5 shrink-0"/><span className="min-w-0 flex-1 truncate"><SearchHighlight text={node.name} query={query}/></span>{entryFormats.has(node.path)&&<span className="shrink-0 text-[10px] uppercase text-accent">{entryFormats.get(node.path)}</span>}<span className="shrink-0 text-[10px] text-text-tertiary">{formatSize(node.size??0)}</span></li>;})}</ul>;
interface TreeNodeProps {
entryFormats: Map<string, ModelEntry['format']>;
selectedEntry?: string;
expandedEntry?: string;
searching: boolean;
query: string;
}
function DirectoryNode({
node,
entryFormats,
selectedEntry,
expandedEntry,
searching,
query,
}: TreeNodeProps & { node: ProjectTreeNode }) {
const [open, setOpen] = useState(Boolean(expandedEntry?.startsWith(`${node.path}/`)));
const shownOpen = searching || open,
FolderIcon = shownOpen ? FolderOpen : Folder;
return (
<li>
<details
open={shownOpen}
onToggle={(event) => {
if (!searching) setOpen(event.currentTarget.open);
}}
>
<summary
title={node.path}
onClick={(event) => {
if (searching) event.preventDefault();
}}
className="flex cursor-pointer select-none items-center gap-1.5 truncate rounded px-1.5 py-1 text-xs text-text-secondary hover:bg-element-hover"
>
<FolderIcon aria-hidden="true" className="h-3.5 w-3.5 shrink-0 text-accent" />
<span className="truncate">
<SearchHighlight text={node.name} query={query} />
</span>
</summary>
<TreeNodes
nodes={node.children ?? []}
entryFormats={entryFormats}
selectedEntry={selectedEntry}
expandedEntry={expandedEntry}
searching={searching}
query={query}
/>
</details>
</li>
);
}
function TreeNodes({
nodes,
entryFormats,
selectedEntry,
expandedEntry,
searching,
query,
}: TreeNodeProps & { nodes: ProjectTreeNode[] }) {
return (
<ul role="group" className="ml-3 border-l border-border pl-1">
{nodes.map((node) => {
if (node.kind === 'directory')
return (
<DirectoryNode
key={`d:${node.path}`}
node={node}
entryFormats={entryFormats}
selectedEntry={selectedEntry}
expandedEntry={expandedEntry}
searching={searching}
query={query}
/>
);
const EntryIcon = entryFormats.has(node.path)
? FileCode2
: node.path.endsWith('.obj') || node.path.endsWith('.stl') || node.path.endsWith('.dae')
? Box
: File;
return (
<li
key={`f:${node.path}`}
title={node.path}
className={`flex min-w-0 items-center gap-1.5 rounded px-1.5 py-1 text-xs ${selectedEntry === node.path ? 'bg-accent-soft text-accent' : 'text-text-secondary hover:bg-element-hover'}`}
>
<EntryIcon aria-hidden="true" className="h-3.5 w-3.5 shrink-0" />
<span className="min-w-0 flex-1 truncate">
<SearchHighlight text={node.name} query={query} />
</span>
{entryFormats.has(node.path) && (
<span className="shrink-0 text-[10px] uppercase text-accent">
{entryFormats.get(node.path)}
</span>
)}
<span className="shrink-0 text-[10px] text-text-tertiary">
{formatSize(node.size ?? 0)}
</span>
</li>
);
})}
</ul>
);
}
function filterNodes(nodes:ProjectTreeNode[],query:string):ProjectTreeNode[]{if(!query)return nodes;return nodes.flatMap(node=>{if(node.name.toLocaleLowerCase().includes(query))return [node];if(node.kind==='file')return [];const children=filterNodes(node.children??[],query);return children.length?[{...node,children}]:[];});}
function countNodes(nodes:ProjectTreeNode[]):number{return nodes.reduce((total,node)=>total+1+(node.children?countNodes(node.children):0),0);}
function filterNodes(nodes: ProjectTreeNode[], query: string): ProjectTreeNode[] {
if (!query) return nodes;
return nodes.flatMap((node) => {
if (node.name.toLocaleLowerCase().includes(query)) return [node];
if (node.kind === 'file') return [];
const children = filterNodes(node.children ?? [], query);
return children.length ? [{ ...node, children }] : [];
});
}
function countNodes(nodes: ProjectTreeNode[]): number {
return nodes.reduce(
(total, node) => total + 1 + (node.children ? countNodes(node.children) : 0),
0,
);
}
// eslint-disable-next-line react-refresh/only-export-components
export function countProjectSearchResults(files:ProjectTreeFile[],query:string):number{return countNodes(filterNodes(buildProjectTree(files),query.trim().toLocaleLowerCase()));}
interface FlatProjectNode{node:ProjectTreeNode;depth:number;}
function flattenProjectNodes(nodes:ProjectTreeNode[],expanded:Set<string>,searching:boolean,depth=0):FlatProjectNode[]{return nodes.flatMap(node=>[{node,depth},...(node.kind==='directory'&&(searching||expanded.has(node.path))?flattenProjectNodes(node.children??[],expanded,searching,depth+1):[])]);}
export function ProjectTree({files,entries,selectedEntry,query=''}:{files:ProjectTreeFile[];entries:ModelEntry[];selectedEntry?:string;query?:string}){
const normalized=query.trim().toLocaleLowerCase(),nodes=filterNodes(buildProjectTree(files),normalized),[virtualExpanded,setVirtualExpanded]=useState(()=>new Set(buildProjectTree(files).filter(node=>node.kind==='directory').map(node=>node.path)));
const entryFormats=new Map(entries.map(entry=>[entry.path,entry.format]));
const expandedEntry=entries.some(entry=>entry.path===selectedEntry&&entry.format==='urdf')?selectedEntry:undefined;
if(files.length>400&&nodes.length){const searching=Boolean(normalized),flat=flattenProjectNodes(nodes,virtualExpanded,searching),toggle=(item:FlatProjectNode)=>{if(item.node.kind!=='directory'||searching)return;setVirtualExpanded(current=>{const next=new Set(current);if(next.has(item.node.path))next.delete(item.node.path);else next.add(item.node.path);return next;});};return <nav aria-label="工程文件树"><VirtualTreeViewport label="虚拟化工程文件树" items={flat} getKey={item=>item.node.path} getLevel={item=>item.depth+1} isExpandable={item=>item.node.kind==='directory'&&(item.node.children?.length??0)>0} isExpanded={item=>searching||virtualExpanded.has(item.node.path)} onToggle={toggle} renderRow={({node,depth})=>{const directory=node.kind==='directory',opened=searching||virtualExpanded.has(node.path),EntryIcon=directory?(opened?FolderOpen:Folder):entryFormats.has(node.path)?FileCode2:node.path.endsWith('.obj')||node.path.endsWith('.stl')||node.path.endsWith('.dae')?Box:File;return <div title={node.path} onDoubleClick={()=>toggle({node,depth})} className={`flex h-full items-center gap-1.5 rounded px-1.5 text-xs ${selectedEntry===node.path?'bg-accent-soft text-accent':'text-text-secondary hover:bg-element-hover'}`} style={{paddingLeft:depth*12+6}}><EntryIcon className="h-3.5 w-3.5 shrink-0"/><span className="min-w-0 flex-1 truncate"><SearchHighlight text={node.name} query={query}/></span>{!directory&&<span className="text-[10px] text-text-tertiary">{formatSize(node.size??0)}</span>}</div>;}}/></nav>;}
return <nav aria-label="工程文件树">{nodes.length?<TreeNodes key={expandedEntry??'collapsed'} nodes={nodes} entryFormats={entryFormats} selectedEntry={selectedEntry} expandedEntry={expandedEntry} searching={Boolean(normalized)} query={query}/>:<EmptySearchState label="没有匹配的文件"/>}</nav>;
export function countProjectSearchResults(files: ProjectTreeFile[], query: string): number {
return countNodes(filterNodes(buildProjectTree(files), query.trim().toLocaleLowerCase()));
}
interface FlatProjectNode {
node: ProjectTreeNode;
depth: number;
}
function flattenProjectNodes(
nodes: ProjectTreeNode[],
expanded: Set<string>,
searching: boolean,
depth = 0,
): FlatProjectNode[] {
return nodes.flatMap((node) => [
{ node, depth },
...(node.kind === 'directory' && (searching || expanded.has(node.path))
? flattenProjectNodes(node.children ?? [], expanded, searching, depth + 1)
: []),
]);
}
export function ProjectTree({
files,
entries,
selectedEntry,
query = '',
}: {
files: ProjectTreeFile[];
entries: ModelEntry[];
selectedEntry?: string;
query?: string;
}) {
const normalized = query.trim().toLocaleLowerCase(),
nodes = filterNodes(buildProjectTree(files), normalized),
[virtualExpanded, setVirtualExpanded] = useState(
() =>
new Set(
buildProjectTree(files)
.filter((node) => node.kind === 'directory')
.map((node) => node.path),
),
);
const entryFormats = new Map(entries.map((entry) => [entry.path, entry.format]));
const expandedEntry = entries.some(
(entry) => entry.path === selectedEntry && entry.format === 'urdf',
)
? selectedEntry
: undefined;
if (files.length > 400 && nodes.length) {
const searching = Boolean(normalized),
flat = flattenProjectNodes(nodes, virtualExpanded, searching),
toggle = (item: FlatProjectNode) => {
if (item.node.kind !== 'directory' || searching) return;
setVirtualExpanded((current) => {
const next = new Set(current);
if (next.has(item.node.path)) next.delete(item.node.path);
else next.add(item.node.path);
return next;
});
};
return (
<nav aria-label="工程文件树">
<VirtualTreeViewport
label="虚拟化工程文件树"
items={flat}
getKey={(item) => item.node.path}
getLevel={(item) => item.depth + 1}
isExpandable={(item) =>
item.node.kind === 'directory' && (item.node.children?.length ?? 0) > 0
}
isExpanded={(item) => searching || virtualExpanded.has(item.node.path)}
onToggle={toggle}
renderRow={({ node, depth }) => {
const directory = node.kind === 'directory',
opened = searching || virtualExpanded.has(node.path),
EntryIcon = directory
? opened
? FolderOpen
: Folder
: entryFormats.has(node.path)
? FileCode2
: node.path.endsWith('.obj') ||
node.path.endsWith('.stl') ||
node.path.endsWith('.dae')
? Box
: File;
return (
<div
title={node.path}
onDoubleClick={() => toggle({ node, depth })}
className={`flex h-full items-center gap-1.5 rounded px-1.5 text-xs ${selectedEntry === node.path ? 'bg-accent-soft text-accent' : 'text-text-secondary hover:bg-element-hover'}`}
style={{ paddingLeft: depth * 12 + 6 }}
>
<EntryIcon className="h-3.5 w-3.5 shrink-0" />
<span className="min-w-0 flex-1 truncate">
<SearchHighlight text={node.name} query={query} />
</span>
{!directory && (
<span className="text-[10px] text-text-tertiary">
{formatSize(node.size ?? 0)}
</span>
)}
</div>
);
}}
/>
</nav>
);
}
return (
<nav aria-label="工程文件树">
{nodes.length ? (
<TreeNodes
key={expandedEntry ?? 'collapsed'}
nodes={nodes}
entryFormats={entryFormats}
selectedEntry={selectedEntry}
expandedEntry={expandedEntry}
searching={Boolean(normalized)}
query={query}
/>
) : (
<EmptySearchState label="没有匹配的文件" />
)}
</nav>
);
}
+74 -10
View File
@@ -1,13 +1,77 @@
import {editableSourcePaths,exportedFileName,mergeCachedFiles,readCachedText,updateCachedText,upsertCachedMjcf} from './cachedFiles';
import type {ProjectManifest} from './types';
import {
editableSourcePaths,
exportedFileName,
mergeCachedFiles,
readCachedText,
updateCachedText,
upsertCachedMjcf,
} from './cachedFiles';
import type { ProjectManifest } from './types';
const encoder=new TextEncoder();
function fixture():ProjectManifest{const xml=encoder.encode('<mujoco/>'),png=new Uint8Array([1,2]);return {id:'p',name:'测试 工程.zip',files:[{path:'model.xml',data:xml,size:xml.byteLength,source:'zip',mimeType:'text/xml'},{path:'texture.png',data:png,size:png.byteLength,source:'zip',mimeType:'image/png'}],entries:[{path:'model.xml',format:'mjcf',label:'model'}],selectedEntry:'model.xml',totalBytes:xml.byteLength+png.byteLength};}
const encoder = new TextEncoder();
function fixture(): ProjectManifest {
const xml = encoder.encode('<mujoco/>'),
png = new Uint8Array([1, 2]);
return {
id: 'p',
name: '测试 工程.zip',
files: [
{ path: 'model.xml', data: xml, size: xml.byteLength, source: 'zip', mimeType: 'text/xml' },
{
path: 'texture.png',
data: png,
size: png.byteLength,
source: 'zip',
mimeType: 'image/png',
},
],
entries: [{ path: 'model.xml', format: 'mjcf', label: 'model' }],
selectedEntry: 'model.xml',
totalBytes: xml.byteLength + png.byteLength,
};
}
describe('cached source files',()=>{
it('只列出可编辑文本并读取缓存',()=>{const manifest=fixture();expect(editableSourcePaths(manifest)).toEqual(['model.xml']);expect(readCachedText(manifest,'model.xml')).toBe('<mujoco/>');expect(()=>readCachedText(manifest,'texture.png')).toThrow('二进制');});
it('以不可变方式更新会话缓存和大小',()=>{const original=fixture(),updated=updateCachedText(original,'model.xml','<mujoco model="edited"/>');expect(readCachedText(updated,'model.xml')).toContain('edited');expect(readCachedText(original,'model.xml')).toBe('<mujoco/>');expect(updated.totalBytes).toBe(updated.files.reduce((sum,file)=>sum+file.size,0));});
it('合并转换生成的支持资源',()=>{const original=fixture(),obj={path:'mesh.mujoco.obj',data:encoder.encode('v 0 0 0'),size:7,source:'file' as const,mimeType:'text/plain'},updated=mergeCachedFiles(original,[obj]);expect(updated.files.map(file=>file.path)).toContain('mesh.mujoco.obj');expect(original.files.map(file=>file.path)).not.toContain('mesh.mujoco.obj');});
it('创建可重新载入的 MJCF 缓存文件和入口',()=>{const updated=upsertCachedMjcf(fixture(),'.__converted_mjcf_cache__.xml','<mujoco model="cached"/>');expect(readCachedText(updated,'.__converted_mjcf_cache__.xml')).toContain('cached');expect(updated.entries.at(-1)).toMatchObject({path:'.__converted_mjcf_cache__.xml',format:'mjcf'});});
it('生成安全的导出文件名',()=>{expect(exportedFileName('测试 工程.zip','urdf')).toBe('测试_工程.urdf');expect(exportedFileName('robot.xml','xml')).toBe('robot.xml');});
describe('cached source files', () => {
it('只列出可编辑文本并读取缓存', () => {
const manifest = fixture();
expect(editableSourcePaths(manifest)).toEqual(['model.xml']);
expect(readCachedText(manifest, 'model.xml')).toBe('<mujoco/>');
expect(() => readCachedText(manifest, 'texture.png')).toThrow('二进制');
});
it('以不可变方式更新会话缓存和大小', () => {
const original = fixture(),
updated = updateCachedText(original, 'model.xml', '<mujoco model="edited"/>');
expect(readCachedText(updated, 'model.xml')).toContain('edited');
expect(readCachedText(original, 'model.xml')).toBe('<mujoco/>');
expect(updated.totalBytes).toBe(updated.files.reduce((sum, file) => sum + file.size, 0));
});
it('合并转换生成的支持资源', () => {
const original = fixture(),
obj = {
path: 'mesh.mujoco.obj',
data: encoder.encode('v 0 0 0'),
size: 7,
source: 'file' as const,
mimeType: 'text/plain',
},
updated = mergeCachedFiles(original, [obj]);
expect(updated.files.map((file) => file.path)).toContain('mesh.mujoco.obj');
expect(original.files.map((file) => file.path)).not.toContain('mesh.mujoco.obj');
});
it('创建可重新载入的 MJCF 缓存文件和入口', () => {
const updated = upsertCachedMjcf(
fixture(),
'.__converted_mjcf_cache__.xml',
'<mujoco model="cached"/>',
);
expect(readCachedText(updated, '.__converted_mjcf_cache__.xml')).toContain('cached');
expect(updated.entries.at(-1)).toMatchObject({
path: '.__converted_mjcf_cache__.xml',
format: 'mjcf',
});
});
it('生成安全的导出文件名', () => {
expect(exportedFileName('测试 工程.zip', 'urdf')).toBe('测试_工程.urdf');
expect(exportedFileName('robot.xml', 'xml')).toBe('robot.xml');
});
});
+87 -39
View File
@@ -1,57 +1,105 @@
import type {ProjectManifest} from './types';
import type { ProjectManifest } from './types';
const TEXT_EXTENSIONS=/\.(?:xml|urdf|txt|obj|mtl|csv|json|yaml|yml)$/i;
const decoder=new TextDecoder('utf-8',{fatal:false});
const encoder=new TextEncoder();
const TEXT_EXTENSIONS = /\.(?:xml|urdf|txt|obj|mtl|csv|json|yaml|yml)$/i;
const decoder = new TextDecoder('utf-8', { fatal: false });
const encoder = new TextEncoder();
export function isEditableSource(path:string):boolean{return TEXT_EXTENSIONS.test(path);}
export function editableSourcePaths(manifest:ProjectManifest):string[]{
return manifest.files.filter(file=>isEditableSource(file.path)).map(file=>file.path).sort((a,b)=>a.localeCompare(b));
export function isEditableSource(path: string): boolean {
return TEXT_EXTENSIONS.test(path);
}
export function readCachedText(manifest:ProjectManifest,path:string):string{
const file=manifest.files.find(candidate=>candidate.path===path);
if(!file)throw new Error(`缓存中找不到文件:${path}`);
if(!isEditableSource(path))throw new Error(`不支持编辑二进制文件:${path}`);
export function editableSourcePaths(manifest: ProjectManifest): string[] {
return manifest.files
.filter((file) => isEditableSource(file.path))
.map((file) => file.path)
.sort((a, b) => a.localeCompare(b));
}
export function readCachedText(manifest: ProjectManifest, path: string): string {
const file = manifest.files.find((candidate) => candidate.path === path);
if (!file) throw new Error(`缓存中找不到文件:${path}`);
if (!isEditableSource(path)) throw new Error(`不支持编辑二进制文件:${path}`);
return decoder.decode(file.data);
}
/** 返回只更新浏览器会话内存的新工程清单,不接触用户本地文件系统。 */
export function mergeCachedFiles(manifest:ProjectManifest,additional:ProjectManifest['files']):ProjectManifest{
if(!additional.length)return manifest;
const byPath=new Map(manifest.files.map(file=>[file.path,file]));
for(const file of additional)byPath.set(file.path,file);
const files=Array.from(byPath.values());
return {...manifest,files,totalBytes:files.reduce((total,item)=>total+item.size,0)};
export function mergeCachedFiles(
manifest: ProjectManifest,
additional: ProjectManifest['files'],
): ProjectManifest {
if (!additional.length) return manifest;
const byPath = new Map(manifest.files.map((file) => [file.path, file]));
for (const file of additional) byPath.set(file.path, file);
const files = Array.from(byPath.values());
return { ...manifest, files, totalBytes: files.reduce((total, item) => total + item.size, 0) };
}
export function upsertCachedMjcf(manifest:ProjectManifest,path:string,text:string):ProjectManifest{
const data=encoder.encode(text),index=manifest.files.findIndex(candidate=>candidate.path===path);
const files=manifest.files.slice();
const file={path,data,size:data.byteLength,source:'file' as const,mimeType:'application/xml'};
if(index<0)files.push(file);else files[index]={...files[index],...file};
const entries=manifest.entries.some(entry=>entry.path===path)?manifest.entries:[...manifest.entries,{path,format:'mjcf' as const,label:`${path} (MJCF 缓存)`}];
return {...manifest,files,entries,totalBytes:files.reduce((total,item)=>total+item.size,0)};
export function upsertCachedMjcf(
manifest: ProjectManifest,
path: string,
text: string,
): ProjectManifest {
const data = encoder.encode(text),
index = manifest.files.findIndex((candidate) => candidate.path === path);
const files = manifest.files.slice();
const file = {
path,
data,
size: data.byteLength,
source: 'file' as const,
mimeType: 'application/xml',
};
if (index < 0) files.push(file);
else files[index] = { ...files[index], ...file };
const entries = manifest.entries.some((entry) => entry.path === path)
? manifest.entries
: [...manifest.entries, { path, format: 'mjcf' as const, label: `${path} (MJCF 缓存)` }];
return {
...manifest,
files,
entries,
totalBytes: files.reduce((total, item) => total + item.size, 0),
};
}
export function updateCachedText(manifest:ProjectManifest,path:string,text:string):ProjectManifest{
const index=manifest.files.findIndex(candidate=>candidate.path===path);
if(index<0)throw new Error(`缓存中找不到文件:${path}`);
if(!isEditableSource(path))throw new Error(`不支持编辑二进制文件:${path}`);
const data=encoder.encode(text),files=manifest.files.slice();
files[index]={...files[index],data,size:data.byteLength,mimeType:files[index].mimeType||'text/plain'};
return {...manifest,files,totalBytes:files.reduce((total,file)=>total+file.size,0)};
export function updateCachedText(
manifest: ProjectManifest,
path: string,
text: string,
): ProjectManifest {
const index = manifest.files.findIndex((candidate) => candidate.path === path);
if (index < 0) throw new Error(`缓存中找不到文件:${path}`);
if (!isEditableSource(path)) throw new Error(`不支持编辑二进制文件:${path}`);
const data = encoder.encode(text),
files = manifest.files.slice();
files[index] = {
...files[index],
data,
size: data.byteLength,
mimeType: files[index].mimeType || 'text/plain',
};
return { ...manifest, files, totalBytes: files.reduce((total, file) => total + file.size, 0) };
}
export function downloadBytes(data:Uint8Array,fileName:string,mimeType='application/xml'):void{
const blob=new Blob([data as BlobPart],{type:`${mimeType};charset=utf-8`});
const url=URL.createObjectURL(blob),anchor=document.createElement('a');
anchor.href=url;anchor.download=fileName;anchor.style.display='none';document.body.append(anchor);anchor.click();anchor.remove();
setTimeout(()=>URL.revokeObjectURL(url),0);
export function downloadBytes(
data: Uint8Array,
fileName: string,
mimeType = 'application/xml',
): void {
const blob = new Blob([data as BlobPart], { type: `${mimeType};charset=utf-8` });
const url = URL.createObjectURL(blob),
anchor = document.createElement('a');
anchor.href = url;
anchor.download = fileName;
anchor.style.display = 'none';
document.body.append(anchor);
anchor.click();
anchor.remove();
setTimeout(() => URL.revokeObjectURL(url), 0);
}
export function exportedFileName(projectName:string,extension:'urdf'|'xml'):string{
const stem=projectName.replace(/\.(?:zip|xml|urdf)$/i,'').replace(/[^\p{L}\p{N}._-]+/gu,'_')||'model';
export function exportedFileName(projectName: string, extension: 'urdf' | 'xml'): string {
const stem =
projectName.replace(/\.(?:zip|xml|urdf)$/i, '').replace(/[^\p{L}\p{N}._-]+/gu, '_') || 'model';
return `${stem}.${extension}`;
}
+39 -35
View File
@@ -1,52 +1,56 @@
import {LoadingManager,type Material,type Mesh,type Texture} from 'three';
import {OBJExporter} from 'three/addons/exporters/OBJExporter.js';
import {ColladaLoader} from 'three/addons/loaders/ColladaLoader.js';
import { LoadingManager, type Material, type Mesh, type Texture } from 'three';
import { OBJExporter } from 'three/addons/exporters/OBJExporter.js';
import { ColladaLoader } from 'three/addons/loaders/ColladaLoader.js';
const TRANSPARENT_PIXEL='data:image/gif;base64,R0lGODlhAQABAAD/ACwAAAAAAQABAAACADs=';
const TRANSPARENT_PIXEL = 'data:image/gif;base64,R0lGODlhAQABAAD/ACwAAAAAAQABAAACADs=';
/**
* 将 Collada 几何转换为 MuJoCo WASM 可读取的 OBJ。
* ColladaLoader 会先统一为 Y-up;额外旋转到 MuJoCo 使用的 Z-up,并烘焙节点变换与单位缩放。
*/
export function convertDaeToObj(data:Uint8Array,path:string):Uint8Array {
const manager=new LoadingManager();
export function convertDaeToObj(data: Uint8Array, path: string): Uint8Array {
const manager = new LoadingManager();
// 转换只需要几何。拦截贴图 URL,避免为浏览器内存文件发起无效网络请求。
manager.setURLModifier(()=>TRANSPARENT_PIXEL);
const loader=new ColladaLoader(manager);
const text=new TextDecoder('utf-8').decode(data);
const xml=new DOMParser().parseFromString(text,'application/xml');
if(xml.querySelector('parsererror'))throw new Error('Collada XML 格式无效');
const upAxis=xml.getElementsByTagName('up_axis')[0]?.textContent?.trim().toUpperCase()??'Y_UP';
manager.setURLModifier(() => TRANSPARENT_PIXEL);
const loader = new ColladaLoader(manager);
const text = new TextDecoder('utf-8').decode(data);
const xml = new DOMParser().parseFromString(text, 'application/xml');
if (xml.querySelector('parsererror')) throw new Error('Collada XML 格式无效');
const upAxis =
xml.getElementsByTagName('up_axis')[0]?.textContent?.trim().toUpperCase() ?? 'Y_UP';
// 禁用 ColladaLoader 自带的 Z-up → Y-up 旋转,改为直接统一到 MuJoCo 的 Z-up。
if(upAxis==='Z_UP')xml.getElementsByTagName('up_axis')[0]!.textContent='Y_UP';
const normalized=new XMLSerializer().serializeToString(xml);
const result=loader.parse(normalized,path.slice(0,path.lastIndexOf('/')+1));
if(!result?.scene)throw new Error('Collada 文件无法解析');
const scene=result.scene;
if(upAxis==='Y_UP')scene.rotation.x+=Math.PI/2;
else if(upAxis==='X_UP')scene.rotation.y-=Math.PI/2;
if (upAxis === 'Z_UP') xml.getElementsByTagName('up_axis')[0]!.textContent = 'Y_UP';
const normalized = new XMLSerializer().serializeToString(xml);
const result = loader.parse(normalized, path.slice(0, path.lastIndexOf('/') + 1));
if (!result?.scene) throw new Error('Collada 文件无法解析');
const scene = result.scene;
if (upAxis === 'Y_UP') scene.rotation.x += Math.PI / 2;
else if (upAxis === 'X_UP') scene.rotation.y -= Math.PI / 2;
scene.updateMatrixWorld(true);
let meshCount=0;
scene.traverse(object=>{
const mesh=object as Mesh;
if(!mesh.isMesh)return;
meshCount+=1;
const materials=Array.isArray(mesh.material)?mesh.material:[mesh.material];
for(const material of materials)if(material)material.name='';
let meshCount = 0;
scene.traverse((object) => {
const mesh = object as Mesh;
if (!mesh.isMesh) return;
meshCount += 1;
const materials = Array.isArray(mesh.material) ? mesh.material : [mesh.material];
for (const material of materials) if (material) material.name = '';
});
if(!meshCount)throw new Error('Collada 文件不包含可转换的三角网格');
if (!meshCount) throw new Error('Collada 文件不包含可转换的三角网格');
try {
const output=new OBJExporter().parse(scene);
if(!/^v\s/m.test(output)||!/^f\s/m.test(output))throw new Error('Collada 文件未生成有效三角面');
const output = new OBJExporter().parse(scene);
if (!/^v\s/m.test(output) || !/^f\s/m.test(output))
throw new Error('Collada 文件未生成有效三角面');
return new TextEncoder().encode(output);
} finally {
scene.traverse(object=>{
const mesh=object as Mesh;
if(!mesh.isMesh)return;
scene.traverse((object) => {
const mesh = object as Mesh;
if (!mesh.isMesh) return;
mesh.geometry?.dispose();
const materials:Material[]=Array.isArray(mesh.material)?mesh.material:[mesh.material];
for(const material of materials){
for(const value of Object.values(material))if(value&&typeof value==='object'&&(value as Texture).isTexture)(value as Texture).dispose();
const materials: Material[] = Array.isArray(mesh.material) ? mesh.material : [mesh.material];
for (const material of materials) {
for (const value of Object.values(material))
if (value && typeof value === 'object' && (value as Texture).isTexture)
(value as Texture).dispose();
material.dispose();
}
});
+153 -17
View File
@@ -1,21 +1,157 @@
import {zipSync} from 'fflate';
import {choosePreferredEntry,discoverEntries,importBrowserFiles,normalizeProjectPath,prepareProjectForMujoco,ProjectImportError} from './importer';
import type {ProjectFile} from './types';
const encode=(s:string)=>new TextEncoder().encode(s);
const projectFile=(path:string,text:string):ProjectFile=>({path,data:encode(text),size:encode(text).length,source:'file',mimeType:'text/xml'});
const TRIANGLE_DAE=`<?xml version="1.0"?><COLLADA xmlns="http://www.collada.org/2005/11/COLLADASchema" version="1.4.1">
import { zipSync } from 'fflate';
import {
choosePreferredEntry,
discoverEntries,
importBrowserFiles,
normalizeProjectPath,
prepareProjectForMujoco,
ProjectImportError,
} from './importer';
import type { ProjectFile } from './types';
const encode = (s: string) => new TextEncoder().encode(s);
const projectFile = (path: string, text: string): ProjectFile => ({
path,
data: encode(text),
size: encode(text).length,
source: 'file',
mimeType: 'text/xml',
});
const TRIANGLE_DAE = `<?xml version="1.0"?><COLLADA xmlns="http://www.collada.org/2005/11/COLLADASchema" version="1.4.1">
<asset><unit meter="1"/><up_axis>Z_UP</up_axis></asset><library_effects><effect id="fx"><profile_COMMON><technique sid="common"><lambert><diffuse><color>1 1 1 1</color></diffuse></lambert></technique></profile_COMMON></effect></library_effects><library_materials><material id="mat"><instance_effect url="#fx"/></material></library_materials>
<library_geometries><geometry id="triangle"><mesh><source id="positions"><float_array id="positions-array" count="9">0 0 0 1 0 0 0 1 0</float_array><technique_common><accessor source="#positions-array" count="3" stride="3"><param name="X" type="float"/><param name="Y" type="float"/><param name="Z" type="float"/></accessor></technique_common></source><vertices id="vertices"><input semantic="POSITION" source="#positions"/></vertices><triangles count="1" material="mat"><input semantic="VERTEX" source="#vertices" offset="0"/><p>0 1 2</p></triangles></mesh></geometry></library_geometries>
<library_visual_scenes><visual_scene id="scene"><node id="node"><instance_geometry url="#triangle"><bind_material><technique_common><instance_material symbol="mat" target="#mat"/></technique_common></bind_material></instance_geometry></node></visual_scene></library_visual_scenes><scene><instance_visual_scene url="#scene"/></scene></COLLADA>`;
describe('project importer',()=>{
it('拒绝路径穿越与绝对路径',()=>{expect(()=>normalizeProjectPath('../model.xml')).toThrow(ProjectImportError);expect(()=>normalizeProjectPath('/model.xml')).toThrow(ProjectImportError);expect(normalizeProjectPath('robot\\mesh\\a.obj')).toBe('robot/mesh/a.obj');});
it('识别 MJCF 与 URDF 并执行入口优先级',()=>{const entries=discoverEntries([projectFile('other.xml','<mujoco/>'),projectFile('model.xml','<mujoco/>'),projectFile('robot.urdf','<robot/>')]);expect(entries).toHaveLength(3);expect(choosePreferredEntry(entries)).toBe('model.xml');});
it('解压 ZIP 并保留二进制数据',async()=>{const zipped=zipSync({'robot/model.urdf':encode('<robot name="r"/>'),'robot/mesh.obj':encode('v 0 0 0')});const file=new File([zipped],'robot.zip',{type:'application/zip'});const result=await importBrowserFiles([file]);expect(result.files.map(f=>f.path)).toContain('robot/mesh.obj');expect(result.selectedEntry).toBe('robot/model.urdf');});
it('拒绝 ZIP 路径穿越',async()=>{const zipped=zipSync({'../model.xml':encode('<mujoco/>')});await expect(importBrowserFiles([new File([zipped],'bad.zip')])).rejects.toThrow('路径包含越界片段');});
it('拒绝同名路径',async()=>{const a=new File(['<mujoco/>'],'model.xml');const b=new File(['<mujoco/>'],'model.xml');await expect(importBrowserFiles([a,b])).rejects.toThrow('同名路径');});
it('拒绝超过限制的文件',async()=>{const file=new File(['<mujoco/>'],'model.xml');await expect(importBrowserFiles([file],{maxFiles:1,maxFileBytes:2,maxTotalBytes:2,maxZipBytes:2})).rejects.toThrow('单文件超过限制');});
it('规范化 MuJoCo 不接受的重复 material 和 ROS package URI',()=>{const urdf=projectFile('go2w_description/urdf/robot.urdf','<robot><link name="base"><visual><geometry><mesh filename="package://go2w_description/meshes/base.obj"/></geometry><material name="a"/><material name="b"/></visual></link></robot>');const mesh:ProjectFile={path:'go2w_description/meshes/base.obj',data:new Uint8Array([1]),size:1,source:'directory',mimeType:''};const manifest={id:'go2w',name:'go2w',files:[urdf,mesh],entries:[{path:urdf.path,format:'urdf' as const,label:'robot'}],selectedEntry:urdf.path,totalBytes:urdf.size+1};const prepared=prepareProjectForMujoco(manifest,urdf.path);const text=new TextDecoder().decode(prepared.manifest.files[0].data);expect((text.match(/<material/g)??[])).toHaveLength(1);expect(text).toContain('filename="../meshes/base.obj"');expect(text).toContain('discardvisual="false"');expect(text).toContain('fusestatic="false"');expect(prepared.warnings).toHaveLength(2);});
it('将 DAE mesh 转换为 MuJoCo 可读取的 OBJ,并复用于 visual/collision',()=>{const urdf=projectFile('robot/robot.urdf','<robot><link name="base"><visual><geometry><mesh filename="meshes/triangle.dae"/></geometry></visual><collision><geometry><mesh filename="meshes/triangle.dae"/></geometry></collision></link></robot>');const dae=projectFile('robot/meshes/triangle.dae',TRIANGLE_DAE);const manifest={id:'dae',name:'dae',files:[urdf,dae],entries:[{path:urdf.path,format:'urdf' as const,label:'robot'}],selectedEntry:urdf.path,totalBytes:urdf.size+dae.size};const prepared=prepareProjectForMujoco(manifest,urdf.path);const text=new TextDecoder().decode(prepared.manifest.files.find(file=>file.path===urdf.path)!.data);expect(text).not.toContain('.dae');expect(text.match(/meshes\/triangle\.mujoco\.obj/g)).toHaveLength(2);const obj=prepared.manifest.files.find(file=>file.path==='robot/meshes/triangle.mujoco.obj');expect(new TextDecoder().decode(obj!.data)).toMatch(/^f\s/m);expect(prepared.warnings.join(' ')).toContain('1 个 DAE 文件转换为 OBJ');});
it('DAE 缺失或转换失败时安全降级',()=>{const urdf=projectFile('robot.urdf','<robot><link name="base"><visual><geometry><mesh filename="visual.dae"/></geometry></visual><collision><geometry><mesh filename="collision.dae"/></geometry></collision></link></robot>');const manifest={id:'dae',name:'dae',files:[urdf],entries:[{path:urdf.path,format:'urdf' as const,label:'robot'}],selectedEntry:urdf.path,totalBytes:urdf.size};const prepared=prepareProjectForMujoco(manifest,urdf.path);const text=new TextDecoder().decode(prepared.manifest.files[0].data);expect(text).not.toContain('<visual>');expect(text).toContain('<collision>');expect(text).toContain('<sphere radius="0.05"');expect(prepared.warnings.join(' ')).toContain('DAE visual');expect(prepared.warnings.join(' ')).toContain('DAE collision');});
it('在解压前依据 ZIP 元数据拒绝膨胀内容',async()=>{const zipped=zipSync({'model.xml':encode(`<mujoco>${' '.repeat(4096)}</mujoco>`)});const file=new File([zipped],'large.zip');await expect(importBrowserFiles([file],{maxFiles:2,maxFileBytes:128,maxTotalBytes:256,maxZipBytes:4096})).rejects.toThrow('单文件超过限制');});
describe('project importer', () => {
it('拒绝路径穿越与绝对路径', () => {
expect(() => normalizeProjectPath('../model.xml')).toThrow(ProjectImportError);
expect(() => normalizeProjectPath('/model.xml')).toThrow(ProjectImportError);
expect(normalizeProjectPath('robot\\mesh\\a.obj')).toBe('robot/mesh/a.obj');
});
it('识别 MJCF 与 URDF 并执行入口优先级', () => {
const entries = discoverEntries([
projectFile('other.xml', '<mujoco/>'),
projectFile('model.xml', '<mujoco/>'),
projectFile('robot.urdf', '<robot/>'),
]);
expect(entries).toHaveLength(3);
expect(choosePreferredEntry(entries)).toBe('model.xml');
});
it('解压 ZIP 并保留二进制数据', async () => {
const zipped = zipSync({
'robot/model.urdf': encode('<robot name="r"/>'),
'robot/mesh.obj': encode('v 0 0 0'),
});
const file = new File([zipped], 'robot.zip', { type: 'application/zip' });
const result = await importBrowserFiles([file]);
expect(result.files.map((f) => f.path)).toContain('robot/mesh.obj');
expect(result.selectedEntry).toBe('robot/model.urdf');
});
it('拒绝 ZIP 路径穿越', async () => {
const zipped = zipSync({ '../model.xml': encode('<mujoco/>') });
await expect(importBrowserFiles([new File([zipped], 'bad.zip')])).rejects.toThrow(
'路径包含越界片段',
);
});
it('拒绝同名路径', async () => {
const a = new File(['<mujoco/>'], 'model.xml');
const b = new File(['<mujoco/>'], 'model.xml');
await expect(importBrowserFiles([a, b])).rejects.toThrow('同名路径');
});
it('拒绝超过限制的文件', async () => {
const file = new File(['<mujoco/>'], 'model.xml');
await expect(
importBrowserFiles([file], {
maxFiles: 1,
maxFileBytes: 2,
maxTotalBytes: 2,
maxZipBytes: 2,
}),
).rejects.toThrow('单文件超过限制');
});
it('规范化 MuJoCo 不接受的重复 material 和 ROS package URI', () => {
const urdf = projectFile(
'go2w_description/urdf/robot.urdf',
'<robot><link name="base"><visual><geometry><mesh filename="package://go2w_description/meshes/base.obj"/></geometry><material name="a"/><material name="b"/></visual></link></robot>',
);
const mesh: ProjectFile = {
path: 'go2w_description/meshes/base.obj',
data: new Uint8Array([1]),
size: 1,
source: 'directory',
mimeType: '',
};
const manifest = {
id: 'go2w',
name: 'go2w',
files: [urdf, mesh],
entries: [{ path: urdf.path, format: 'urdf' as const, label: 'robot' }],
selectedEntry: urdf.path,
totalBytes: urdf.size + 1,
};
const prepared = prepareProjectForMujoco(manifest, urdf.path);
const text = new TextDecoder().decode(prepared.manifest.files[0].data);
expect(text.match(/<material/g) ?? []).toHaveLength(1);
expect(text).toContain('filename="../meshes/base.obj"');
expect(text).toContain('discardvisual="false"');
expect(text).toContain('fusestatic="false"');
expect(prepared.warnings).toHaveLength(2);
});
it('将 DAE mesh 转换为 MuJoCo 可读取的 OBJ,并复用于 visual/collision', () => {
const urdf = projectFile(
'robot/robot.urdf',
'<robot><link name="base"><visual><geometry><mesh filename="meshes/triangle.dae"/></geometry></visual><collision><geometry><mesh filename="meshes/triangle.dae"/></geometry></collision></link></robot>',
);
const dae = projectFile('robot/meshes/triangle.dae', TRIANGLE_DAE);
const manifest = {
id: 'dae',
name: 'dae',
files: [urdf, dae],
entries: [{ path: urdf.path, format: 'urdf' as const, label: 'robot' }],
selectedEntry: urdf.path,
totalBytes: urdf.size + dae.size,
};
const prepared = prepareProjectForMujoco(manifest, urdf.path);
const text = new TextDecoder().decode(
prepared.manifest.files.find((file) => file.path === urdf.path)!.data,
);
expect(text).not.toContain('.dae');
expect(text.match(/meshes\/triangle\.mujoco\.obj/g)).toHaveLength(2);
const obj = prepared.manifest.files.find(
(file) => file.path === 'robot/meshes/triangle.mujoco.obj',
);
expect(new TextDecoder().decode(obj!.data)).toMatch(/^f\s/m);
expect(prepared.warnings.join(' ')).toContain('1 个 DAE 文件转换为 OBJ');
});
it('DAE 缺失或转换失败时安全降级', () => {
const urdf = projectFile(
'robot.urdf',
'<robot><link name="base"><visual><geometry><mesh filename="visual.dae"/></geometry></visual><collision><geometry><mesh filename="collision.dae"/></geometry></collision></link></robot>',
);
const manifest = {
id: 'dae',
name: 'dae',
files: [urdf],
entries: [{ path: urdf.path, format: 'urdf' as const, label: 'robot' }],
selectedEntry: urdf.path,
totalBytes: urdf.size,
};
const prepared = prepareProjectForMujoco(manifest, urdf.path);
const text = new TextDecoder().decode(prepared.manifest.files[0].data);
expect(text).not.toContain('<visual>');
expect(text).toContain('<collision>');
expect(text).toContain('<sphere radius="0.05"');
expect(prepared.warnings.join(' ')).toContain('DAE visual');
expect(prepared.warnings.join(' ')).toContain('DAE collision');
});
it('在解压前依据 ZIP 元数据拒绝膨胀内容', async () => {
const zipped = zipSync({ 'model.xml': encode(`<mujoco>${' '.repeat(4096)}</mujoco>`) });
const file = new File([zipped], 'large.zip');
await expect(
importBrowserFiles([file], {
maxFiles: 2,
maxFileBytes: 128,
maxTotalBytes: 256,
maxZipBytes: 4096,
}),
).rejects.toThrow('单文件超过限制');
});
});
+285 -111
View File
@@ -1,24 +1,43 @@
import {unzipSync} from 'fflate';
import {DEFAULT_IMPORT_LIMITS, type ImportLimits, type ModelEntry, type ProjectFile, type ProjectManifest} from './types';
import {convertDaeToObj} from './daeConverter';
import { unzipSync } from 'fflate';
import {
DEFAULT_IMPORT_LIMITS,
type ImportLimits,
type ModelEntry,
type ProjectFile,
type ProjectManifest,
} from './types';
import { convertDaeToObj } from './daeConverter';
const decoder = new TextDecoder('utf-8', {fatal: false});
const decoder = new TextDecoder('utf-8', { fatal: false });
export class ProjectImportError extends Error {
constructor(message: string, readonly path?: string) { super(message); this.name = 'ProjectImportError'; }
constructor(
message: string,
readonly path?: string,
) {
super(message);
this.name = 'ProjectImportError';
}
}
export function normalizeProjectPath(input: string): string {
const path = input.replaceAll('\\', '/').replace(/^\.\//, '');
if (!path || path.startsWith('/') || path.includes('\0') || /^[A-Za-z]:/.test(path)) throw new ProjectImportError('不允许绝对路径或空路径', input);
if (!path || path.startsWith('/') || path.includes('\0') || /^[A-Za-z]:/.test(path))
throw new ProjectImportError('不允许绝对路径或空路径', input);
const parts = path.split('/').filter((part) => part !== '' && part !== '.');
if (!parts.length || parts.some((part) => part === '..')) throw new ProjectImportError('路径包含越界片段', input);
if (!parts.length || parts.some((part) => part === '..'))
throw new ProjectImportError('路径包含越界片段', input);
return parts.join('/');
}
function checkEncryptedZip(data: Uint8Array): void {
for (let i = 0; i + 8 < data.length; i++) {
if (data[i] === 0x50 && data[i + 1] === 0x4b && (data[i + 2] === 0x03 || data[i + 2] === 0x01) && (data[i + 3] === 0x04 || data[i + 3] === 0x02)) {
if (
data[i] === 0x50 &&
data[i + 1] === 0x4b &&
(data[i + 2] === 0x03 || data[i + 2] === 0x01) &&
(data[i + 3] === 0x04 || data[i + 3] === 0x02)
) {
const flags = data[i + 6] | (data[i + 7] << 8);
if ((flags & 1) !== 0) throw new ProjectImportError('不支持加密 ZIP');
}
@@ -26,24 +45,35 @@ function checkEncryptedZip(data: Uint8Array): void {
}
function enforceLimits(files: ProjectFile[], limits: ImportLimits): void {
if (files.length > limits.maxFiles) throw new ProjectImportError(`文件数量超过限制(${limits.maxFiles})`);
if (files.length > limits.maxFiles)
throw new ProjectImportError(`文件数量超过限制(${limits.maxFiles})`);
let total = 0;
const seen = new Set<string>();
for (const file of files) {
if (seen.has(file.path)) throw new ProjectImportError('工程中存在同名路径', file.path);
seen.add(file.path);
if (file.size > limits.maxFileBytes) throw new ProjectImportError(`单文件超过限制(${limits.maxFileBytes} 字节)`, file.path);
if (file.size > limits.maxFileBytes)
throw new ProjectImportError(`单文件超过限制(${limits.maxFileBytes} 字节)`, file.path);
total += file.size;
if (total > limits.maxTotalBytes) throw new ProjectImportError(`工程总大小超过限制(${limits.maxTotalBytes} 字节)`);
if (total > limits.maxTotalBytes)
throw new ProjectImportError(`工程总大小超过限制(${limits.maxTotalBytes} 字节)`);
}
}
export function discoverEntries(files: ProjectFile[]): ModelEntry[] {
return files.flatMap((file): ModelEntry[] => {
if (!/\.(xml|urdf)$/i.test(file.path)) return [];
const head = decoder.decode(file.data.subarray(0, Math.min(file.data.length, 256 * 1024))).replace(/^\uFEFF/, '');
const format = /<robot(?:\s|\/?>)/i.test(head) ? 'urdf' : /<mujoco(?:\s|\/?>)/i.test(head) ? 'mjcf' : undefined;
return format ? [{path: file.path, format, label: `${file.path} (${format.toUpperCase()})`}] : [];
const head = decoder
.decode(file.data.subarray(0, Math.min(file.data.length, 256 * 1024)))
.replace(/^\uFEFF/, '');
const format = /<robot(?:\s|\/?>)/i.test(head)
? 'urdf'
: /<mujoco(?:\s|\/?>)/i.test(head)
? 'mjcf'
: undefined;
return format
? [{ path: file.path, format, label: `${file.path} (${format.toUpperCase()})` }]
: [];
});
}
@@ -55,55 +85,80 @@ export interface PreparedProject {
function relativeProjectPath(fromFile: string, toFile: string): string {
const from = fromFile.split('/').slice(0, -1);
const to = toFile.split('/');
while (from.length && to.length && from[0] === to[0]) { from.shift(); to.shift(); }
while (from.length && to.length && from[0] === to[0]) {
from.shift();
to.shift();
}
return `${'../'.repeat(from.length)}${to.join('/')}` || './';
}
function resolveProjectReference(fromFile:string,reference:string):string|undefined {
if(/^[a-z][a-z\d+.-]*:/i.test(reference))return;
let decoded:string;
try{decoded=decodeURIComponent(reference.split(/[?#]/,1)[0]);}catch{return;}
const parts=fromFile.split('/').slice(0,-1);
for(const part of decoded.replaceAll('\\','/').split('/')){
if(!part||part==='.')continue;
if(part==='..'){if(!parts.length)return;parts.pop();}
else parts.push(part);
function resolveProjectReference(fromFile: string, reference: string): string | undefined {
if (/^[a-z][a-z\d+.-]*:/i.test(reference)) return;
let decoded: string;
try {
decoded = decodeURIComponent(reference.split(/[?#]/, 1)[0]);
} catch {
return;
}
const parts = fromFile.split('/').slice(0, -1);
for (const part of decoded.replaceAll('\\', '/').split('/')) {
if (!part || part === '.') continue;
if (part === '..') {
if (!parts.length) return;
parts.pop();
} else parts.push(part);
}
return parts.join('/');
}
function generatedObjPath(daePath:string,occupied:Set<string>):string {
const base=daePath.replace(/\.dae$/i,'');
let candidate=`${base}.mujoco.obj`;
for(let index=2;occupied.has(candidate);index+=1)candidate=`${base}.mujoco-${index}.obj`;
function generatedObjPath(daePath: string, occupied: Set<string>): string {
const base = daePath.replace(/\.dae$/i, '');
let candidate = `${base}.mujoco.obj`;
for (let index = 2; occupied.has(candidate); index += 1)
candidate = `${base}.mujoco-${index}.obj`;
occupied.add(candidate);
return candidate;
}
/** Normalizes common ROS URDF constructs that MuJoCo's stricter parser rejects. */
export function prepareProjectForMujoco(manifest: ProjectManifest, entryPath: string): PreparedProject {
export function prepareProjectForMujoco(
manifest: ProjectManifest,
entryPath: string,
): PreparedProject {
const entry = manifest.entries.find((candidate) => candidate.path === entryPath);
if (entry?.format !== 'urdf') return {manifest, warnings: []};
if (entry?.format !== 'urdf') return { manifest, warnings: [] };
const source = manifest.files.find((file) => file.path === entryPath);
if (!source) return {manifest, warnings: []};
if (!source) return { manifest, warnings: [] };
const document = new DOMParser().parseFromString(decoder.decode(source.data), 'application/xml');
if (document.querySelector('parsererror')) return {manifest, warnings: []};
if (document.querySelector('parsererror')) return { manifest, warnings: [] };
const warnings: string[] = [];
const robot=document.documentElement;
let mujoco=Array.from(robot.children).find(child=>child.tagName==='mujoco');
if(!mujoco){mujoco=document.createElement('mujoco');robot.prepend(mujoco);}
let compiler=Array.from(mujoco.children).find(child=>child.tagName==='compiler');
if(!compiler){compiler=document.createElement('compiler');mujoco.append(compiler);}
compiler.setAttribute('discardvisual','false');
compiler.setAttribute('fusestatic','false');
const robot = document.documentElement;
let mujoco = Array.from(robot.children).find((child) => child.tagName === 'mujoco');
if (!mujoco) {
mujoco = document.createElement('mujoco');
robot.prepend(mujoco);
}
let compiler = Array.from(mujoco.children).find((child) => child.tagName === 'compiler');
if (!compiler) {
compiler = document.createElement('compiler');
mujoco.append(compiler);
}
compiler.setAttribute('discardvisual', 'false');
compiler.setAttribute('fusestatic', 'false');
let removedMaterials = 0;
for (const visual of Array.from(document.querySelectorAll('visual'))) {
const materials = Array.from(visual.children).filter((child) => child.tagName === 'material');
for (const duplicate of materials.slice(1)) { duplicate.remove(); removedMaterials += 1; }
for (const duplicate of materials.slice(1)) {
duplicate.remove();
removedMaterials += 1;
}
}
if (removedMaterials) warnings.push(`为兼容 MuJoCo,已移除 visual 中 ${removedMaterials} 个重复 material(保留第一个)`);
if (removedMaterials)
warnings.push(
`为兼容 MuJoCo,已移除 visual 中 ${removedMaterials} 个重复 material(保留第一个)`,
);
const paths = manifest.files.map((file) => file.path);
let rewrittenUris = 0;
@@ -112,58 +167,97 @@ export function prepareProjectForMujoco(manifest: ProjectManifest, entryPath: st
const value = element.getAttribute('filename');
if (!value?.startsWith('package://')) continue;
const packagePath = normalizeProjectPath(value.slice('package://'.length));
const target = paths.find((path) => path === packagePath) ?? paths.find((path) => path.endsWith(`/${packagePath}`));
if (!target) { unresolved.push(value); continue; }
const target =
paths.find((path) => path === packagePath) ??
paths.find((path) => path.endsWith(`/${packagePath}`));
if (!target) {
unresolved.push(value);
continue;
}
element.setAttribute('filename', relativeProjectPath(entryPath, target));
rewrittenUris += 1;
}
if (rewrittenUris) warnings.push(`已将 ${rewrittenUris} 个 package:// 资源地址改写为工程内相对路径`);
if (rewrittenUris)
warnings.push(`已将 ${rewrittenUris} 个 package:// 资源地址改写为工程内相对路径`);
if (unresolved.length) warnings.push(`有 ${unresolved.length} 个 package:// 资源未在工程中找到`);
const occupied=new Set(manifest.files.map(file=>file.path));
const converted=new Map<string,ProjectFile>();
let convertedDaeReferences=0;
let removedDaeVisuals=0;
let daeCollisionFallbacks=0;
for(const mesh of Array.from(document.querySelectorAll('mesh[filename]'))){
const filename=mesh.getAttribute('filename');
if(!filename?.toLowerCase().split(/[?#]/)[0].endsWith('.dae'))continue;
const daePath=resolveProjectReference(entryPath,filename);
const daeFile=daePath?manifest.files.find(file=>file.path===daePath):undefined;
try{
if(!daeFile||!daePath)throw new Error('工程中找不到 DAE 文件');
let objFile=converted.get(daePath);
if(!objFile){
const data=convertDaeToObj(daeFile.data,daePath);
if(data.byteLength>DEFAULT_IMPORT_LIMITS.maxFileBytes)throw new Error('转换后的 OBJ 超过单文件大小限制');
objFile={path:generatedObjPath(daePath,occupied),data,size:data.byteLength,source:daeFile.source,mimeType:'text/plain'};
converted.set(daePath,objFile);
const occupied = new Set(manifest.files.map((file) => file.path));
const converted = new Map<string, ProjectFile>();
let convertedDaeReferences = 0;
let removedDaeVisuals = 0;
let daeCollisionFallbacks = 0;
for (const mesh of Array.from(document.querySelectorAll('mesh[filename]'))) {
const filename = mesh.getAttribute('filename');
if (!filename?.toLowerCase().split(/[?#]/)[0].endsWith('.dae')) continue;
const daePath = resolveProjectReference(entryPath, filename);
const daeFile = daePath ? manifest.files.find((file) => file.path === daePath) : undefined;
try {
if (!daeFile || !daePath) throw new Error('工程中找不到 DAE 文件');
let objFile = converted.get(daePath);
if (!objFile) {
const data = convertDaeToObj(daeFile.data, daePath);
if (data.byteLength > DEFAULT_IMPORT_LIMITS.maxFileBytes)
throw new Error('转换后的 OBJ 超过单文件大小限制');
objFile = {
path: generatedObjPath(daePath, occupied),
data,
size: data.byteLength,
source: daeFile.source,
mimeType: 'text/plain',
};
converted.set(daePath, objFile);
}
mesh.setAttribute('filename',relativeProjectPath(entryPath,objFile.path));
convertedDaeReferences+=1;
}catch(error){
console.warn(`[MuJoCo] DAE 转换失败:${filename}`,error);
const visual=mesh.closest('visual');
if(visual){visual.remove();removedDaeVisuals+=1;}
else if(mesh.closest('collision')){
const sphere=document.createElement('sphere');sphere.setAttribute('radius','0.05');mesh.replaceWith(sphere);daeCollisionFallbacks+=1;
mesh.setAttribute('filename', relativeProjectPath(entryPath, objFile.path));
convertedDaeReferences += 1;
} catch (error) {
console.warn(`[MuJoCo] DAE 转换失败:${filename}`, error);
const visual = mesh.closest('visual');
if (visual) {
visual.remove();
removedDaeVisuals += 1;
} else if (mesh.closest('collision')) {
const sphere = document.createElement('sphere');
sphere.setAttribute('radius', '0.05');
mesh.replaceWith(sphere);
daeCollisionFallbacks += 1;
}
}
}
if(convertedDaeReferences)warnings.push(`已将 ${converted.size} 个 DAE 文件转换为 OBJ,供 ${convertedDaeReferences} 个 visual/collision 使用`);
if(removedDaeVisuals)warnings.push(`${removedDaeVisuals} 个 DAE visual 转换失败,已移除并使用其他 collision 几何显示/仿真`);
if(daeCollisionFallbacks)warnings.push(`${daeCollisionFallbacks} 个 DAE collision 转换失败,已替换为半径 0.05 m 的占位球体;碰撞精度会降低`);
if (convertedDaeReferences)
warnings.push(
`已将 ${converted.size} 个 DAE 文件转换为 OBJ,供 ${convertedDaeReferences} 个 visual/collision 使用`,
);
if (removedDaeVisuals)
warnings.push(
`${removedDaeVisuals} 个 DAE visual 转换失败,已移除并使用其他 collision 几何显示/仿真`,
);
if (daeCollisionFallbacks)
warnings.push(
`${daeCollisionFallbacks} 个 DAE collision 转换失败,已替换为半径 0.05 m 的占位球体;碰撞精度会降低`,
);
const xml=new TextEncoder().encode(new XMLSerializer().serializeToString(document));
const replacement:ProjectFile={...source,data:xml,size:xml.byteLength};
const generated=Array.from(converted.values());
const files=[...manifest.files.map(file=>file===source?replacement:file),...generated];
return {manifest:{...manifest,files,totalBytes:files.reduce((total,file)=>total+file.size,0)},warnings};
const xml = new TextEncoder().encode(new XMLSerializer().serializeToString(document));
const replacement: ProjectFile = { ...source, data: xml, size: xml.byteLength };
const generated = Array.from(converted.values());
const files = [
...manifest.files.map((file) => (file === source ? replacement : file)),
...generated,
];
return {
manifest: {
...manifest,
files,
totalBytes: files.reduce((total, file) => total + file.size, 0),
},
warnings,
};
}
export function choosePreferredEntry(entries: ModelEntry[]): string | undefined {
if (entries.length === 1) return entries[0].path;
const rootPreferred = entries.find((e) => !e.path.includes('/') && /^(model|scene)\.xml$/i.test(e.path));
const rootPreferred = entries.find(
(e) => !e.path.includes('/') && /^(model|scene)\.xml$/i.test(e.path),
);
if (rootPreferred) return rootPreferred.path;
const urdfs = entries.filter((e) => e.format === 'urdf');
return urdfs.length === 1 ? urdfs[0].path : undefined;
@@ -171,53 +265,133 @@ export function choosePreferredEntry(entries: ModelEntry[]): string | undefined
function manifest(name: string, files: ProjectFile[]): ProjectManifest {
const entries = discoverEntries(files);
if (!entries.length) throw new ProjectImportError('未发现包含 <mujoco> 或 <robot> 根元素的 XML/URDF 入口');
return {id: `${Date.now().toString(36)}-${crypto.randomUUID().slice(0, 8)}`, name, files, entries, selectedEntry: choosePreferredEntry(entries), totalBytes: files.reduce((n, f) => n + f.size, 0)};
if (!entries.length)
throw new ProjectImportError('未发现包含 <mujoco> 或 <robot> 根元素的 XML/URDF 入口');
return {
id: `${Date.now().toString(36)}-${crypto.randomUUID().slice(0, 8)}`,
name,
files,
entries,
selectedEntry: choosePreferredEntry(entries),
totalBytes: files.reduce((n, f) => n + f.size, 0),
};
}
export async function importBrowserFiles(input: File[], limits: ImportLimits = DEFAULT_IMPORT_LIMITS): Promise<ProjectManifest> {
export async function importBrowserFiles(
input: File[],
limits: ImportLimits = DEFAULT_IMPORT_LIMITS,
): Promise<ProjectManifest> {
if (!input.length) throw new ProjectImportError('未选择文件');
if (input.length === 1 && /\.zip$/i.test(input[0].name)) {
if (input[0].size > limits.maxZipBytes) throw new ProjectImportError(`ZIP 超过限制(${limits.maxZipBytes} 字节)`);
const bytes = new Uint8Array(await input[0].arrayBuffer()); checkEncryptedZip(bytes);
if (input[0].size > limits.maxZipBytes)
throw new ProjectImportError(`ZIP 超过限制(${limits.maxZipBytes} 字节)`);
const bytes = new Uint8Array(await input[0].arrayBuffer());
checkEncryptedZip(bytes);
let unpacked: Record<string, Uint8Array>;
try {
let fileCount = 0;
let expandedBytes = 0;
unpacked = unzipSync(bytes, {filter: (entry) => {
if (entry.name.endsWith('/')) return false;
normalizeProjectPath(entry.name);
fileCount += 1;
expandedBytes += entry.originalSize;
if (fileCount > limits.maxFiles) throw new ProjectImportError(`文件数量超过限制(${limits.maxFiles})`);
if (entry.originalSize > limits.maxFileBytes) throw new ProjectImportError(`单文件超过限制(${limits.maxFileBytes} 字节)`, entry.name);
if (expandedBytes > limits.maxTotalBytes) throw new ProjectImportError(`ZIP 解压后总大小超过限制(${limits.maxTotalBytes} 字节)`);
return true;
}});
unpacked = unzipSync(bytes, {
filter: (entry) => {
if (entry.name.endsWith('/')) return false;
normalizeProjectPath(entry.name);
fileCount += 1;
expandedBytes += entry.originalSize;
if (fileCount > limits.maxFiles)
throw new ProjectImportError(`文件数量超过限制(${limits.maxFiles})`);
if (entry.originalSize > limits.maxFileBytes)
throw new ProjectImportError(
`单文件超过限制(${limits.maxFileBytes} 字节)`,
entry.name,
);
if (expandedBytes > limits.maxTotalBytes)
throw new ProjectImportError(
`ZIP 解压后总大小超过限制(${limits.maxTotalBytes} 字节)`,
);
return true;
},
});
} catch (error) {
if (error instanceof ProjectImportError) throw error;
throw new ProjectImportError(`ZIP 解压失败:${error instanceof Error ? error.message : String(error)}`);
throw new ProjectImportError(
`ZIP 解压失败:${error instanceof Error ? error.message : String(error)}`,
);
}
const files = Object.entries(unpacked).filter(([path]) => !path.endsWith('/')).map(([path, data]): ProjectFile => ({path: normalizeProjectPath(path), data, size: data.byteLength, source: 'zip', mimeType: ''}));
enforceLimits(files, limits); return manifest(input[0].name.replace(/\.zip$/i, ''), files);
const files = Object.entries(unpacked)
.filter(([path]) => !path.endsWith('/'))
.map(([path, data]): ProjectFile => ({
path: normalizeProjectPath(path),
data,
size: data.byteLength,
source: 'zip',
mimeType: '',
}));
enforceLimits(files, limits);
return manifest(input[0].name.replace(/\.zip$/i, ''), files);
}
const files = await Promise.all(input.map(async (file): Promise<ProjectFile> => {
const relative = (file as File & {webkitRelativePath?: string}).webkitRelativePath || file.name;
const data = new Uint8Array(await file.arrayBuffer());
return {path: normalizeProjectPath(relative), data, size: data.byteLength, source: relative === file.name ? 'file' : 'directory', mimeType: file.type};
}));
enforceLimits(files, limits); return manifest(files[0].path.split('/')[0] || '工程', files);
const files = await Promise.all(
input.map(async (file): Promise<ProjectFile> => {
const relative =
(file as File & { webkitRelativePath?: string }).webkitRelativePath || file.name;
const data = new Uint8Array(await file.arrayBuffer());
return {
path: normalizeProjectPath(relative),
data,
size: data.byteLength,
source: relative === file.name ? 'file' : 'directory',
mimeType: file.type,
};
}),
);
enforceLimits(files, limits);
return manifest(files[0].path.split('/')[0] || '工程', files);
}
interface LegacyEntry {isFile: boolean; isDirectory: boolean; name: string; file(cb: (file: File) => void, err: (e: DOMException) => void): void; createReader(): {readEntries(cb: (entries: LegacyEntry[]) => void, err: (e: DOMException) => void): void};}
interface LegacyEntry {
isFile: boolean;
isDirectory: boolean;
name: string;
file(cb: (file: File) => void, err: (e: DOMException) => void): void;
createReader(): {
readEntries(cb: (entries: LegacyEntry[]) => void, err: (e: DOMException) => void): void;
};
}
async function readEntry(entry: LegacyEntry, prefix = ''): Promise<File[]> {
if (entry.isFile) return [await new Promise<File>((resolve, reject) => entry.file((file) => {Object.defineProperty(file, 'webkitRelativePath', {value: `${prefix}${file.name}`}); resolve(file);}, reject))];
const reader = entry.createReader(); const children: LegacyEntry[] = [];
for (;;) { const batch = await new Promise<LegacyEntry[]>((resolve, reject) => reader.readEntries(resolve, reject)); if (!batch.length) break; children.push(...batch); }
return (await Promise.all(children.map((child) => readEntry(child, `${prefix}${entry.name}/`)))).flat();
if (entry.isFile)
return [
await new Promise<File>((resolve, reject) =>
entry.file((file) => {
Object.defineProperty(file, 'webkitRelativePath', { value: `${prefix}${file.name}` });
resolve(file);
}, reject),
),
];
const reader = entry.createReader();
const children: LegacyEntry[] = [];
for (;;) {
const batch = await new Promise<LegacyEntry[]>((resolve, reject) =>
reader.readEntries(resolve, reject),
);
if (!batch.length) break;
children.push(...batch);
}
return (
await Promise.all(children.map((child) => readEntry(child, `${prefix}${entry.name}/`)))
).flat();
}
export async function filesFromDrop(items: DataTransferItemList, fallback: FileList): Promise<File[]> {
const entries = Array.from(items).map((item) => (item as unknown as {webkitGetAsEntry?: () => LegacyEntry | null}).webkitGetAsEntry?.() ?? null).filter((entry): entry is LegacyEntry => entry !== null);
return entries.length ? (await Promise.all(entries.map((entry) => readEntry(entry)))).flat() : Array.from(fallback);
export async function filesFromDrop(
items: DataTransferItemList,
fallback: FileList,
): Promise<File[]> {
const entries = Array.from(items)
.map(
(item) =>
(item as unknown as { webkitGetAsEntry?: () => LegacyEntry | null }).webkitGetAsEntry?.() ??
null,
)
.filter((entry): entry is LegacyEntry => entry !== null);
return entries.length
? (await Promise.all(entries.map((entry) => readEntry(entry)))).flat()
: Array.from(fallback);
}
+131 -39
View File
@@ -1,73 +1,165 @@
import {enhanceConvertedMjcf,groundConvertedMjcf} from './urdfToMjcf';
import { enhanceConvertedMjcf, groundConvertedMjcf } from './urdfToMjcf';
const encode=(value:string)=>new TextEncoder().encode(value);
const decode=(value:Uint8Array)=>new TextDecoder().decode(value);
const encode = (value: string) => new TextEncoder().encode(value);
const decode = (value: Uint8Array) => new TextDecoder().decode(value);
describe('groundConvertedMjcf',()=>{
it('抬升所有根 body,并在 z=0 添加地面',()=>{
const result=decode(groundConvertedMjcf(encode('<mujoco><worldbody><body name="robot" pos="1 2 0.1"><geom type="box" size="1 1 1"/></body></worldbody></mujoco>'),-0.4,'fixed'));
const document=new DOMParser().parseFromString(result,'application/xml');
describe('groundConvertedMjcf', () => {
it('抬升所有根 body,并在 z=0 添加地面', () => {
const result = decode(
groundConvertedMjcf(
encode(
'<mujoco><worldbody><body name="robot" pos="1 2 0.1"><geom type="box" size="1 1 1"/></body></worldbody></mujoco>',
),
-0.4,
'fixed',
),
);
const document = new DOMParser().parseFromString(result, 'application/xml');
expect(document.querySelector('body[name="robot"]')?.getAttribute('pos')).toBe('1 2 0.5');
expect(document.querySelector('geom[name="__platform_ground__"]')).toMatchObject({tagName:'geom'});
expect(document.querySelector('geom[name="__platform_ground__"]')?.getAttribute('group')).toBe('5');
expect(document.querySelector('geom[name="__platform_ground__"]')).toMatchObject({
tagName: 'geom',
});
expect(document.querySelector('geom[name="__platform_ground__"]')?.getAttribute('group')).toBe(
'5',
);
expect(document.querySelector('freejoint')).toBeNull();
});
it('为浮动基座的每个世界根 body 添加 freejoint',()=>{
const result=decode(groundConvertedMjcf(encode('<mujoco><worldbody><body name="robot"><geom type="sphere" size="1"/></body></worldbody></mujoco>'),-1,'floating'));
const document=new DOMParser().parseFromString(result,'application/xml');
expect(document.querySelector('body[name="robot"] > freejoint')?.getAttribute('name')).toBe('__platform_base_freejoint__');
it('为浮动基座的每个世界根 body 添加 freejoint', () => {
const result = decode(
groundConvertedMjcf(
encode(
'<mujoco><worldbody><body name="robot"><geom type="sphere" size="1"/></body></worldbody></mujoco>',
),
-1,
'floating',
),
);
const document = new DOMParser().parseFromString(result, 'application/xml');
expect(document.querySelector('body[name="robot"] > freejoint')?.getAttribute('name')).toBe(
'__platform_base_freejoint__',
);
expect(document.querySelector('body[name="robot"]')?.getAttribute('pos')).toBe('0 0 1');
});
});
describe('enhanceConvertedMjcf',()=>{
it('为可驱动关节补充 motor,并跳过已有驱动器和 ball joint',()=>{
const source='<mujoco><worldbody><body name="robot"><joint name="shoulder" type="hinge"/><body><joint name="slider" type="slide"/><joint name="ball" type="ball"/><joint name="parent_driven"/></body></body></worldbody><actuator><position name="existing" joint="shoulder"/><motor name="parent_motor" jointinparent="parent_driven"/></actuator></mujoco>';
const result=enhanceConvertedMjcf(encode(source),{addActuators:true,addSensors:false,sensorType:'camera'});
const document=new DOMParser().parseFromString(decode(result.data),'application/xml');
describe('enhanceConvertedMjcf', () => {
it('为可驱动关节补充 motor,并跳过已有驱动器和 ball joint', () => {
const source =
'<mujoco><worldbody><body name="robot"><joint name="shoulder" type="hinge"/><body><joint name="slider" type="slide"/><joint name="ball" type="ball"/><joint name="parent_driven"/></body></body></worldbody><actuator><position name="existing" joint="shoulder"/><motor name="parent_motor" jointinparent="parent_driven"/></actuator></mujoco>';
const result = enhanceConvertedMjcf(encode(source), {
addActuators: true,
addSensors: false,
sensorType: 'camera',
});
const document = new DOMParser().parseFromString(decode(result.data), 'application/xml');
expect(result.actuatorCount).toBe(1);
expect(document.querySelector('motor[joint="slider"]')?.getAttribute('ctrllimited')).toBe('false');
expect(document.querySelector('motor[joint="slider"]')?.getAttribute('ctrllimited')).toBe(
'false',
);
expect(document.querySelector('motor[joint="slider"]')?.hasAttribute('ctrlrange')).toBe(false);
expect(document.querySelector('motor[joint="slider"]')?.getAttribute('name')).toBe('slider_motor');
expect(document.querySelector('motor[joint="slider"]')?.getAttribute('forcerange')).toBe('-100 100');
expect(document.querySelector('motor[joint="slider"]')?.getAttribute('name')).toBe(
'slider_motor',
);
expect(document.querySelector('motor[joint="slider"]')?.getAttribute('forcerange')).toBe(
'-100 100',
);
expect(document.querySelector('joint[name="slider"]')?.getAttribute('stiffness')).toBe('0');
expect(document.querySelector('joint[name="slider"]')?.getAttribute('damping')).toBe('0');
expect(document.querySelectorAll('[joint="shoulder"]')).toHaveLength(1);
expect(document.querySelectorAll('[jointinparent="parent_driven"], [joint="parent_driven"]')).toHaveLength(1);
expect(
document.querySelectorAll('[jointinparent="parent_driven"], [joint="parent_driven"]'),
).toHaveLength(1);
expect(document.querySelector('[joint="ball"]')).toBeNull();
});
it('识别 Go2-W 并补齐官方 MuJoCo 稳定性参数',()=>{
const prefixes=['FL','FR','RL','RR'],parts=['hip','thigh','calf'];
const joints=[...prefixes.flatMap(prefix=>parts.map(part=>`<joint name="${prefix}_${part}_joint"/>`)),...prefixes.map(prefix=>`<body name="${prefix}_foot"><joint name="${prefix}_foot_joint"/><geom group="0"/></body>`)].join('');
const result=enhanceConvertedMjcf(encode(`<mujoco><worldbody><body name="base">${joints}</body></worldbody></mujoco>`),{addActuators:true,addSensors:false,sensorType:'camera'}),document=new DOMParser().parseFromString(decode(result.data),'application/xml');
expect(result.unitreeGo2wTuned).toBe(true);expect(document.querySelector('option')?.getAttribute('cone')).toBe('elliptic');expect(document.querySelector('joint[name="FL_thigh_joint"]')?.getAttribute('armature')).toBe('0.01');expect(document.querySelector('joint[name="FL_thigh_joint"]')?.getAttribute('damping')).toBe('0.1');expect(document.querySelector('motor[joint="FL_calf_joint"]')?.getAttribute('forcerange')).toBe('-45.43 45.43');expect(document.querySelector('motor[joint="FL_foot_joint"]')?.getAttribute('forcerange')).toBe('-15 15');expect(document.querySelector('body[name="FL_foot"] geom')?.getAttribute('condim')).toBe('6');
it('识别 Go2-W 并补齐官方 MuJoCo 稳定性参数', () => {
const prefixes = ['FL', 'FR', 'RL', 'RR'],
parts = ['hip', 'thigh', 'calf'];
const joints = [
...prefixes.flatMap((prefix) =>
parts.map((part) => `<joint name="${prefix}_${part}_joint"/>`),
),
...prefixes.map(
(prefix) =>
`<body name="${prefix}_foot"><joint name="${prefix}_foot_joint"/><geom group="0"/></body>`,
),
].join('');
const result = enhanceConvertedMjcf(
encode(`<mujoco><worldbody><body name="base">${joints}</body></worldbody></mujoco>`),
{ addActuators: true, addSensors: false, sensorType: 'camera' },
),
document = new DOMParser().parseFromString(decode(result.data), 'application/xml');
expect(result.unitreeGo2wTuned).toBe(true);
expect(document.querySelector('option')?.getAttribute('cone')).toBe('elliptic');
expect(document.querySelector('joint[name="FL_thigh_joint"]')?.getAttribute('armature')).toBe(
'0.01',
);
expect(document.querySelector('joint[name="FL_thigh_joint"]')?.getAttribute('damping')).toBe(
'0.1',
);
expect(document.querySelector('motor[joint="FL_calf_joint"]')?.getAttribute('forcerange')).toBe(
'-45.43 45.43',
);
expect(document.querySelector('motor[joint="FL_foot_joint"]')?.getAttribute('forcerange')).toBe(
'-15 15',
);
expect(document.querySelector('body[name="FL_foot"] geom')?.getAttribute('condim')).toBe('6');
});
it('将可调摄像头固连到指定机器人 body',()=>{
const result=enhanceConvertedMjcf(encode('<mujoco><worldbody><body name="base"><body name="head"/></body></worldbody></mujoco>'),{addActuators:false,addSensors:true,sensorType:'camera',cameraMountBody:'head',cameraPosition:[.2,0,.1],cameraDirection:'+X'});
const document=new DOMParser().parseFromString(decode(result.data),'application/xml');
const camera=document.querySelector('body[name="head"] > camera');
it('将可调摄像头固连到指定机器人 body', () => {
const result = enhanceConvertedMjcf(
encode(
'<mujoco><worldbody><body name="base"><body name="head"/></body></worldbody></mujoco>',
),
{
addActuators: false,
addSensors: true,
sensorType: 'camera',
cameraMountBody: 'head',
cameraPosition: [0.2, 0, 0.1],
cameraDirection: '+X',
},
);
const document = new DOMParser().parseFromString(decode(result.data), 'application/xml');
const camera = document.querySelector('body[name="head"] > camera');
expect(result.cameraAdded).toBe(true);
expect(result.imuAdded).toBe(true);
expect(document.querySelector('body[name="base"] > site[name="imu"]')).toMatchObject({tagName:'site'});
expect(document.querySelector('sensor > gyro[name="imu_gyro"]')?.getAttribute('site')).toBe('imu');
expect(document.querySelector('sensor > accelerometer[name="imu_acc"]')?.getAttribute('site')).toBe('imu');
expect(document.querySelector('body[name="base"] > site[name="imu"]')).toMatchObject({
tagName: 'site',
});
expect(document.querySelector('sensor > gyro[name="imu_gyro"]')?.getAttribute('site')).toBe(
'imu',
);
expect(
document.querySelector('sensor > accelerometer[name="imu_acc"]')?.getAttribute('site'),
).toBe('imu');
expect(camera?.getAttribute('mode')).toBe('fixed');
expect(camera?.getAttribute('pos')).toBe('0.2 0 0.1');
expect(camera?.getAttribute('xyaxes')).toBe('0 -1 0 0 0 1');
expect(camera?.getAttribute('resolution')).toBe('640 480');
const repeated=enhanceConvertedMjcf(result.data,{addActuators:false,addSensors:true,sensorType:'camera'});
const repeatedDocument=new DOMParser().parseFromString(decode(repeated.data),'application/xml');
const repeated = enhanceConvertedMjcf(result.data, {
addActuators: false,
addSensors: true,
sensorType: 'camera',
});
const repeatedDocument = new DOMParser().parseFromString(
decode(repeated.data),
'application/xml',
);
expect(repeatedDocument.querySelectorAll('camera')).toHaveLength(1);
expect(repeatedDocument.querySelectorAll('sensor > gyro')).toHaveLength(1);
expect(repeatedDocument.querySelectorAll('sensor > accelerometer')).toHaveLength(1);
});
it('关闭选项时不修改 actuator 和 camera',()=>{
const result=enhanceConvertedMjcf(encode('<mujoco><worldbody><body name="robot"><joint name="joint"/></body></worldbody></mujoco>'),{addActuators:false,addSensors:false,sensorType:'camera'});
const document=new DOMParser().parseFromString(decode(result.data),'application/xml');
it('关闭选项时不修改 actuator 和 camera', () => {
const result = enhanceConvertedMjcf(
encode(
'<mujoco><worldbody><body name="robot"><joint name="joint"/></body></worldbody></mujoco>',
),
{ addActuators: false, addSensors: false, sensorType: 'camera' },
);
const document = new DOMParser().parseFromString(decode(result.data), 'application/xml');
expect(document.querySelector('actuator')).toBeNull();
expect(document.querySelector('camera')).toBeNull();
});
+252 -78
View File
@@ -1,101 +1,275 @@
const decoder=new TextDecoder('utf-8');
const encoder=new TextEncoder();
const decoder = new TextDecoder('utf-8');
const encoder = new TextEncoder();
function numbers(value:string|undefined,count:number):number[]{
const parsed=(value??'').trim().split(/\s+/).filter(Boolean).map(Number);
return Array.from({length:count},(_,index)=>Number.isFinite(parsed[index])?parsed[index]:0);
function numbers(value: string | undefined, count: number): number[] {
const parsed = (value ?? '').trim().split(/\s+/).filter(Boolean).map(Number);
return Array.from({ length: count }, (_, index) =>
Number.isFinite(parsed[index]) ? parsed[index] : 0,
);
}
export type UrdfBaseMode='floating'|'fixed';
export type CameraDirection='+X'|'-X'|'+Y'|'-Y'|'+Z'|'-Z';
export interface UrdfEnhancementOptions {addActuators:boolean;addSensors:boolean;sensorType:'camera';cameraMountBody?:string;cameraPosition?:[number,number,number];cameraDirection?:CameraDirection;}
export interface UrdfEnhancementResult {data:Uint8Array;actuatorCount:number;cameraAdded:boolean;imuAdded:boolean;unitreeGo2wTuned:boolean;}
function uniqueName(document:Document,selector:string,base:string):string {
const occupied=new Set(Array.from(document.querySelectorAll(selector)).map(element=>element.getAttribute('name')).filter(Boolean));
if(!occupied.has(base))return base;
for(let index=2;;index+=1)if(!occupied.has(`${base}_${index}`))return `${base}_${index}`;
export type UrdfBaseMode = 'floating' | 'fixed';
export type CameraDirection = '+X' | '-X' | '+Y' | '-Y' | '+Z' | '-Z';
export interface UrdfEnhancementOptions {
addActuators: boolean;
addSensors: boolean;
sensorType: 'camera';
cameraMountBody?: string;
cameraPosition?: [number, number, number];
cameraDirection?: CameraDirection;
}
export interface UrdfEnhancementResult {
data: Uint8Array;
actuatorCount: number;
cameraAdded: boolean;
imuAdded: boolean;
unitreeGo2wTuned: boolean;
}
function tuneUnitreeGo2w(document:Document):boolean {
const prefixes=['FL','FR','RL','RR'],legParts=['hip','thigh','calf'];
const expected=[...prefixes.flatMap(prefix=>legParts.map(part=>`${prefix}_${part}_joint`)),...prefixes.map(prefix=>`${prefix}_foot_joint`)];
const joints=new Map(Array.from(document.querySelectorAll('worldbody joint[name]')).map(joint=>[joint.getAttribute('name')!,joint]));
if(!expected.every(name=>joints.has(name)))return false;
const root=document.querySelector('mujoco');if(!root)return false;
let option=Array.from(root.children).find(element=>element.tagName==='option');if(!option){option=document.createElement('option');root.prepend(option);}
option.setAttribute('cone','elliptic');option.setAttribute('impratio','100');
for(const name of expected){const joint=joints.get(name)!;joint.setAttribute('armature','0.01');joint.setAttribute('damping','0.1');joint.setAttribute('frictionloss','0.2');}
for(const motor of Array.from(document.querySelectorAll('actuator > *[joint]'))){const jointName=motor.getAttribute('joint')??'';if(!expected.includes(jointName))continue;const limit=jointName.includes('_calf_')?45.43:jointName.endsWith('_foot_joint')?15:23.7;motor.setAttribute('forcelimited','true');motor.setAttribute('forcerange',`${-limit} ${limit}`);}
for(const geom of Array.from(document.querySelectorAll('worldbody body geom'))){if(geom.getAttribute('contype')==='0'||geom.getAttribute('group')==='1')continue;geom.setAttribute('friction','0.4 0.005 0.0001');geom.setAttribute('margin','0.001');geom.setAttribute('condim','1');const body=geom.closest('body'),name=body?.getAttribute('name')??'';if(/_(?:foot|wheel)(?:_link)?$/i.test(name)){geom.setAttribute('friction','0.8 0.02 0.01');geom.setAttribute('condim','6');geom.setAttribute('priority','1');}}
function uniqueName(document: Document, selector: string, base: string): string {
const occupied = new Set(
Array.from(document.querySelectorAll(selector))
.map((element) => element.getAttribute('name'))
.filter(Boolean),
);
if (!occupied.has(base)) return base;
for (let index = 2; ; index += 1)
if (!occupied.has(`${base}_${index}`)) return `${base}_${index}`;
}
function tuneUnitreeGo2w(document: Document): boolean {
const prefixes = ['FL', 'FR', 'RL', 'RR'],
legParts = ['hip', 'thigh', 'calf'];
const expected = [
...prefixes.flatMap((prefix) => legParts.map((part) => `${prefix}_${part}_joint`)),
...prefixes.map((prefix) => `${prefix}_foot_joint`),
];
const joints = new Map(
Array.from(document.querySelectorAll('worldbody joint[name]')).map((joint) => [
joint.getAttribute('name')!,
joint,
]),
);
if (!expected.every((name) => joints.has(name))) return false;
const root = document.querySelector('mujoco');
if (!root) return false;
let option = Array.from(root.children).find((element) => element.tagName === 'option');
if (!option) {
option = document.createElement('option');
root.prepend(option);
}
option.setAttribute('cone', 'elliptic');
option.setAttribute('impratio', '100');
for (const name of expected) {
const joint = joints.get(name)!;
joint.setAttribute('armature', '0.01');
joint.setAttribute('damping', '0.1');
joint.setAttribute('frictionloss', '0.2');
}
for (const motor of Array.from(document.querySelectorAll('actuator > *[joint]'))) {
const jointName = motor.getAttribute('joint') ?? '';
if (!expected.includes(jointName)) continue;
const limit = jointName.includes('_calf_')
? 45.43
: jointName.endsWith('_foot_joint')
? 15
: 23.7;
motor.setAttribute('forcelimited', 'true');
motor.setAttribute('forcerange', `${-limit} ${limit}`);
}
for (const geom of Array.from(document.querySelectorAll('worldbody body geom'))) {
if (geom.getAttribute('contype') === '0' || geom.getAttribute('group') === '1') continue;
geom.setAttribute('friction', '0.4 0.005 0.0001');
geom.setAttribute('margin', '0.001');
geom.setAttribute('condim', '1');
const body = geom.closest('body'),
name = body?.getAttribute('name') ?? '';
if (/_(?:foot|wheel)(?:_link)?$/i.test(name)) {
geom.setAttribute('friction', '0.8 0.02 0.01');
geom.setAttribute('condim', '6');
geom.setAttribute('priority', '1');
}
}
return true;
}
/** 为转换后的 MJCF 按需补充可直接控制的关节电机和一台自动取景相机。 */
export function enhanceConvertedMjcf(data:Uint8Array,options:UrdfEnhancementOptions):UrdfEnhancementResult {
const document=new DOMParser().parseFromString(decoder.decode(data),'application/xml');
if(document.querySelector('parsererror'))throw new Error('MuJoCo 导出的 MJCF XML 无法解析');
const root=document.querySelector('mujoco'),worldbody=document.querySelector('mujoco > worldbody');
if(!root||!worldbody)throw new Error('MuJoCo 导出的 MJCF 缺少 worldbody');
let actuatorCount=0;
if(options.addActuators){
let actuator=Array.from(root.children).find(element=>element.tagName==='actuator');
if(!actuator){actuator=document.createElement('actuator');root.append(actuator);}
const driven=new Set(Array.from(actuator.children).flatMap(element=>[element.getAttribute('joint'),element.getAttribute('jointinparent')]).filter(Boolean));
for(const joint of Array.from(worldbody.querySelectorAll('joint'))){
const type=joint.getAttribute('type')??'hinge';
if(type!=='hinge'&&type!=='slide')continue;
let jointName=joint.getAttribute('name');
if(!jointName){jointName=uniqueName(document,'joint[name]','__platform_joint__');joint.setAttribute('name',jointName);}
if(driven.has(jointName))continue;
const motor=document.createElement('motor');
motor.setAttribute('name',uniqueName(document,'actuator > *[name]',`${jointName}_motor`));
motor.setAttribute('joint',jointName);motor.setAttribute('ctrllimited','false');motor.setAttribute('forcelimited','true');motor.setAttribute('forcerange','-100 100');
if(!joint.hasAttribute('stiffness'))joint.setAttribute('stiffness','0');if(!joint.hasAttribute('damping'))joint.setAttribute('damping','0');
actuator.append(motor);driven.add(jointName);actuatorCount+=1;
export function enhanceConvertedMjcf(
data: Uint8Array,
options: UrdfEnhancementOptions,
): UrdfEnhancementResult {
const document = new DOMParser().parseFromString(decoder.decode(data), 'application/xml');
if (document.querySelector('parsererror')) throw new Error('MuJoCo 导出的 MJCF XML 无法解析');
const root = document.querySelector('mujoco'),
worldbody = document.querySelector('mujoco > worldbody');
if (!root || !worldbody) throw new Error('MuJoCo 导出的 MJCF 缺少 worldbody');
let actuatorCount = 0;
if (options.addActuators) {
let actuator = Array.from(root.children).find((element) => element.tagName === 'actuator');
if (!actuator) {
actuator = document.createElement('actuator');
root.append(actuator);
}
if(!actuator.children.length)actuator.remove();
const driven = new Set(
Array.from(actuator.children)
.flatMap((element) => [
element.getAttribute('joint'),
element.getAttribute('jointinparent'),
])
.filter(Boolean),
);
for (const joint of Array.from(worldbody.querySelectorAll('joint'))) {
const type = joint.getAttribute('type') ?? 'hinge';
if (type !== 'hinge' && type !== 'slide') continue;
let jointName = joint.getAttribute('name');
if (!jointName) {
jointName = uniqueName(document, 'joint[name]', '__platform_joint__');
joint.setAttribute('name', jointName);
}
if (driven.has(jointName)) continue;
const motor = document.createElement('motor');
motor.setAttribute('name', uniqueName(document, 'actuator > *[name]', `${jointName}_motor`));
motor.setAttribute('joint', jointName);
motor.setAttribute('ctrllimited', 'false');
motor.setAttribute('forcelimited', 'true');
motor.setAttribute('forcerange', '-100 100');
if (!joint.hasAttribute('stiffness')) joint.setAttribute('stiffness', '0');
if (!joint.hasAttribute('damping')) joint.setAttribute('damping', '0');
actuator.append(motor);
driven.add(jointName);
actuatorCount += 1;
}
if (!actuator.children.length) actuator.remove();
}
const unitreeGo2wTuned=tuneUnitreeGo2w(document);
let imuAdded=false;
if(options.addSensors&&!document.querySelector('sensor > gyro[name="imu_gyro"], sensor > gyro[name="__platform_imu_gyro__"]')){
const bodies=Array.from(worldbody.querySelectorAll('body')),mount=bodies.find(body=>/^(?:base|base_link|trunk)$/i.test(body.getAttribute('name')??''))??Array.from(worldbody.children).find(element=>element.tagName==='body');
if(mount){
const site=document.createElement('site'),siteName=uniqueName(document,'site[name]','imu');site.setAttribute('name',siteName);site.setAttribute('pos','0 0 0');site.setAttribute('size','0.005');site.setAttribute('rgba','0.2 0.8 1 0.5');mount.append(site);
let sensor=Array.from(root.children).find(element=>element.tagName==='sensor');if(!sensor){sensor=document.createElement('sensor');root.append(sensor);}
const gyro=document.createElement('gyro');gyro.setAttribute('name',uniqueName(document,'sensor > *[name]','imu_gyro'));gyro.setAttribute('site',siteName);gyro.setAttribute('noise','0');sensor.append(gyro);
const accelerometer=document.createElement('accelerometer');accelerometer.setAttribute('name',uniqueName(document,'sensor > *[name]','imu_acc'));accelerometer.setAttribute('site',siteName);accelerometer.setAttribute('noise','0');sensor.append(accelerometer);imuAdded=true;
const unitreeGo2wTuned = tuneUnitreeGo2w(document);
let imuAdded = false;
if (
options.addSensors &&
!document.querySelector(
'sensor > gyro[name="imu_gyro"], sensor > gyro[name="__platform_imu_gyro__"]',
)
) {
const bodies = Array.from(worldbody.querySelectorAll('body')),
mount =
bodies.find((body) =>
/^(?:base|base_link|trunk)$/i.test(body.getAttribute('name') ?? ''),
) ?? Array.from(worldbody.children).find((element) => element.tagName === 'body');
if (mount) {
const site = document.createElement('site'),
siteName = uniqueName(document, 'site[name]', 'imu');
site.setAttribute('name', siteName);
site.setAttribute('pos', '0 0 0');
site.setAttribute('size', '0.005');
site.setAttribute('rgba', '0.2 0.8 1 0.5');
mount.append(site);
let sensor = Array.from(root.children).find((element) => element.tagName === 'sensor');
if (!sensor) {
sensor = document.createElement('sensor');
root.append(sensor);
}
const gyro = document.createElement('gyro');
gyro.setAttribute('name', uniqueName(document, 'sensor > *[name]', 'imu_gyro'));
gyro.setAttribute('site', siteName);
gyro.setAttribute('noise', '0');
sensor.append(gyro);
const accelerometer = document.createElement('accelerometer');
accelerometer.setAttribute('name', uniqueName(document, 'sensor > *[name]', 'imu_acc'));
accelerometer.setAttribute('site', siteName);
accelerometer.setAttribute('noise', '0');
sensor.append(accelerometer);
imuAdded = true;
}
}
let cameraAdded=false;
if(options.addSensors&&options.sensorType==='camera'&&!document.querySelector('camera[name^="__platform_camera__"]')){
const bodies=Array.from(worldbody.querySelectorAll('body')),preferred=options.cameraMountBody?bodies.find(body=>body.getAttribute('name')===options.cameraMountBody):undefined;
const mount=preferred??bodies.find(body=>/(head|camera|sensor|neck|头)/i.test(body.getAttribute('name')??''))??bodies.at(-1);
if(mount){
const directions:Record<CameraDirection,string>={'+X':'0 -1 0 0 0 1','-X':'0 1 0 0 0 1','+Y':'1 0 0 0 0 1','-Y':'-1 0 0 0 0 1','+Z':'0 1 0 1 0 0','-Z':'0 -1 0 1 0 0'},position=options.cameraPosition??[.1,0,.05],direction=options.cameraDirection??'+X';
const camera=document.createElement('camera');camera.setAttribute('name',uniqueName(document,'camera[name]','__platform_camera__'));camera.setAttribute('mode','fixed');
camera.setAttribute('pos',position.map(value=>Number.isFinite(value)?value:0).join(' '));camera.setAttribute('xyaxes',directions[direction]);camera.setAttribute('fovy','45');camera.setAttribute('resolution','640 480');mount.append(camera);cameraAdded=true;
let cameraAdded = false;
if (
options.addSensors &&
options.sensorType === 'camera' &&
!document.querySelector('camera[name^="__platform_camera__"]')
) {
const bodies = Array.from(worldbody.querySelectorAll('body')),
preferred = options.cameraMountBody
? bodies.find((body) => body.getAttribute('name') === options.cameraMountBody)
: undefined;
const mount =
preferred ??
bodies.find((body) =>
/(head|camera|sensor|neck|头)/i.test(body.getAttribute('name') ?? ''),
) ??
bodies.at(-1);
if (mount) {
const directions: Record<CameraDirection, string> = {
'+X': '0 -1 0 0 0 1',
'-X': '0 1 0 0 0 1',
'+Y': '1 0 0 0 0 1',
'-Y': '-1 0 0 0 0 1',
'+Z': '0 1 0 1 0 0',
'-Z': '0 -1 0 1 0 0',
},
position = options.cameraPosition ?? [0.1, 0, 0.05],
direction = options.cameraDirection ?? '+X';
const camera = document.createElement('camera');
camera.setAttribute('name', uniqueName(document, 'camera[name]', '__platform_camera__'));
camera.setAttribute('mode', 'fixed');
camera.setAttribute(
'pos',
position.map((value) => (Number.isFinite(value) ? value : 0)).join(' '),
);
camera.setAttribute('xyaxes', directions[direction]);
camera.setAttribute('fovy', '45');
camera.setAttribute('resolution', '640 480');
mount.append(camera);
cameraAdded = true;
}
}
return {data:encoder.encode(new XMLSerializer().serializeToString(document)),actuatorCount,cameraAdded,imuAdded,unitreeGo2wTuned};
return {
data: encoder.encode(new XMLSerializer().serializeToString(document)),
actuatorCount,
cameraAdded,
imuAdded,
unitreeGo2wTuned,
};
}
/** 给 MuJoCo 从 URDF 导出的 MJCF 添加地面、设置基座类型,并整体抬升根 body。 */
export function groundConvertedMjcf(data:Uint8Array,minimumZ:number,baseMode:UrdfBaseMode='fixed'):Uint8Array {
const document=new DOMParser().parseFromString(decoder.decode(data),'application/xml');
if(document.querySelector('parsererror'))throw new Error('MuJoCo 导出的 MJCF XML 无法解析');
const worldbody=document.querySelector('mujoco > worldbody');
if(!worldbody)throw new Error('MuJoCo 导出的 MJCF 缺少 worldbody');
const lift=Number.isFinite(minimumZ)?-minimumZ:0;
const rootBodies=Array.from(worldbody.children).filter(element=>element.tagName==='body');
for(const [index,body] of rootBodies.entries()){
const pos=numbers(body.getAttribute('pos')??undefined,3);pos[2]+=lift;body.setAttribute('pos',pos.join(' '));
if(baseMode==='floating'&&!Array.from(body.children).some(element=>element.tagName==='freejoint'||element.tagName==='joint')){
const freejoint=document.createElement('freejoint');freejoint.setAttribute('name',rootBodies.length===1?'__platform_base_freejoint__':`__platform_base_freejoint_${index}__`);body.prepend(freejoint);
export function groundConvertedMjcf(
data: Uint8Array,
minimumZ: number,
baseMode: UrdfBaseMode = 'fixed',
): Uint8Array {
const document = new DOMParser().parseFromString(decoder.decode(data), 'application/xml');
if (document.querySelector('parsererror')) throw new Error('MuJoCo 导出的 MJCF XML 无法解析');
const worldbody = document.querySelector('mujoco > worldbody');
if (!worldbody) throw new Error('MuJoCo 导出的 MJCF 缺少 worldbody');
const lift = Number.isFinite(minimumZ) ? -minimumZ : 0;
const rootBodies = Array.from(worldbody.children).filter((element) => element.tagName === 'body');
for (const [index, body] of rootBodies.entries()) {
const pos = numbers(body.getAttribute('pos') ?? undefined, 3);
pos[2] += lift;
body.setAttribute('pos', pos.join(' '));
if (
baseMode === 'floating' &&
!Array.from(body.children).some(
(element) => element.tagName === 'freejoint' || element.tagName === 'joint',
)
) {
const freejoint = document.createElement('freejoint');
freejoint.setAttribute(
'name',
rootBodies.length === 1
? '__platform_base_freejoint__'
: `__platform_base_freejoint_${index}__`,
);
body.prepend(freejoint);
}
}
const floor=document.createElement('geom');
floor.setAttribute('name','__platform_ground__');floor.setAttribute('type','plane');floor.setAttribute('size','1 1 0.1');floor.setAttribute('pos','0 0 0');floor.setAttribute('rgba','0.12 0.16 0.22 1');floor.setAttribute('group','5');floor.setAttribute('friction','1 0.005 0.0001');
const floor = document.createElement('geom');
floor.setAttribute('name', '__platform_ground__');
floor.setAttribute('type', 'plane');
floor.setAttribute('size', '1 1 0.1');
floor.setAttribute('pos', '0 0 0');
floor.setAttribute('rgba', '0.12 0.16 0.22 1');
floor.setAttribute('group', '5');
floor.setAttribute('friction', '1 0.005 0.0001');
worldbody.prepend(floor);
return encoder.encode(new XMLSerializer().serializeToString(document));
}
+34 -4
View File
@@ -1,4 +1,34 @@
import {MemfsWorkspace} from './workspace';
import type {MainModule} from '@mujoco/mujoco';
import type {ProjectManifest} from './types';
it('按相对路径挂载并逆序清理 MEMFS',()=>{const calls:string[]=[];const FS={mkdirTree:(p:string)=>calls.push(`mkdir:${p}`),writeFile:(p:string)=>calls.push(`write:${p}`),unlink:(p:string)=>calls.push(`unlink:${p}`),rmdir:(p:string)=>calls.push(`rmdir:${p}`)};const workspace=new MemfsWorkspace({FS} as unknown as MainModule,'safe');const data=new Uint8Array([1]);const manifest:ProjectManifest={id:'safe',name:'x',entries:[],files:[{path:'a/b/model.xml',data,size:1,source:'file',mimeType:''}],totalBytes:1};workspace.mount(manifest);workspace.dispose();expect(calls).toEqual(expect.arrayContaining(['/workspace/safe/a/b/model.xml'].map(p=>`write:${p}`)));expect(calls).toContain('rmdir:/workspace/safe/a/b');expect(calls).toContain('rmdir:/workspace/safe/a');expect(calls.indexOf('unlink:/workspace/safe/a/b/model.xml')).toBeLessThan(calls.indexOf('rmdir:/workspace/safe/a/b'));expect(calls.indexOf('rmdir:/workspace/safe/a/b')).toBeLessThan(calls.indexOf('rmdir:/workspace/safe/a'));});
import { MemfsWorkspace } from './workspace';
import type { MainModule } from '@mujoco/mujoco';
import type { ProjectManifest } from './types';
it('按相对路径挂载并逆序清理 MEMFS', () => {
const calls: string[] = [];
const FS = {
mkdirTree: (p: string) => calls.push(`mkdir:${p}`),
writeFile: (p: string) => calls.push(`write:${p}`),
unlink: (p: string) => calls.push(`unlink:${p}`),
rmdir: (p: string) => calls.push(`rmdir:${p}`),
};
const workspace = new MemfsWorkspace({ FS } as unknown as MainModule, 'safe');
const data = new Uint8Array([1]);
const manifest: ProjectManifest = {
id: 'safe',
name: 'x',
entries: [],
files: [{ path: 'a/b/model.xml', data, size: 1, source: 'file', mimeType: '' }],
totalBytes: 1,
};
workspace.mount(manifest);
workspace.dispose();
expect(calls).toEqual(
expect.arrayContaining(['/workspace/safe/a/b/model.xml'].map((p) => `write:${p}`)),
);
expect(calls).toContain('rmdir:/workspace/safe/a/b');
expect(calls).toContain('rmdir:/workspace/safe/a');
expect(calls.indexOf('unlink:/workspace/safe/a/b/model.xml')).toBeLessThan(
calls.indexOf('rmdir:/workspace/safe/a/b'),
);
expect(calls.indexOf('rmdir:/workspace/safe/a/b')).toBeLessThan(
calls.indexOf('rmdir:/workspace/safe/a'),
);
});
+38 -15
View File
@@ -1,29 +1,33 @@
import type {MainModule} from '@mujoco/mujoco';
import type {ProjectManifest} from './types';
import type { MainModule } from '@mujoco/mujoco';
import type { ProjectManifest } from './types';
interface EmscriptenFS {
mkdirTree(path: string): void;
writeFile(path: string, data: Uint8Array): void;
readFile(path:string,options:{encoding:'utf8'}):string;
readFile(path: string, options: { encoding: 'utf8' }): string;
unlink(path: string): void;
rmdir(path: string): void;
}
type ModuleWithFS = MainModule & {FS: EmscriptenFS};
type ModuleWithFS = MainModule & { FS: EmscriptenFS };
export class MemfsWorkspace {
readonly root: string;
private files: string[] = [];
private directories: string[] = [];
constructor(private readonly module: MainModule, projectId: string) {
constructor(
private readonly module: MainModule,
projectId: string,
) {
const safeId = projectId.replace(/[^a-zA-Z0-9_-]/g, '_');
this.root = `/workspace/${safeId}`;
}
mount(manifest: ProjectManifest): void {
const fs = (this.module as ModuleWithFS).FS;
fs.mkdirTree(this.root); this.directories.push(this.root);
fs.mkdirTree(this.root);
this.directories.push(this.root);
for (const file of manifest.files) {
const absolute = `${this.root}/${file.path}`;
const directory = absolute.slice(0, absolute.lastIndexOf('/'));
@@ -38,23 +42,42 @@ export class MemfsWorkspace {
}
}
}
fs.writeFile(absolute, file.data); this.files.push(absolute);
fs.writeFile(absolute, file.data);
this.files.push(absolute);
}
}
path(relative: string): string { return `${this.root}/${relative}`; }
path(relative: string): string {
return `${this.root}/${relative}`;
}
readText(relative:string):string{return (this.module as ModuleWithFS).FS.readFile(this.path(relative),{encoding:'utf8'});}
readText(relative: string): string {
return (this.module as ModuleWithFS).FS.readFile(this.path(relative), { encoding: 'utf8' });
}
writeGenerated(relative:string,data:Uint8Array):void {
const absolute=this.path(relative);(this.module as ModuleWithFS).FS.writeFile(absolute,data);
if(!this.files.includes(absolute))this.files.push(absolute);
writeGenerated(relative: string, data: Uint8Array): void {
const absolute = this.path(relative);
(this.module as ModuleWithFS).FS.writeFile(absolute, data);
if (!this.files.includes(absolute)) this.files.push(absolute);
}
dispose(): void {
const fs = (this.module as ModuleWithFS).FS;
for (const file of this.files.reverse()) { try { fs.unlink(file); } catch { /* best-effort after failed mount */ } }
for (const dir of this.directories.sort((a, b) => b.length - a.length)) { try { fs.rmdir(dir); } catch { /* parent or shared root */ } }
this.files = []; this.directories = [];
for (const file of this.files.reverse()) {
try {
fs.unlink(file);
} catch {
/* best-effort after failed mount */
}
}
for (const dir of this.directories.sort((a, b) => b.length - a.length)) {
try {
fs.rmdir(dir);
} catch {
/* parent or shared root */
}
}
this.files = [];
this.directories = [];
}
}
+232 -68
View File
@@ -1,79 +1,243 @@
import type {MjData,MjModel} from '@mujoco/mujoco';
import {buildGo2wObservation,GO2W_VELOCITY_TASK} from '../tasks/go2wVelocity';
import type {JointBinding,RLCommand} from '../types';
import type {PolicyRuntimeBindings} from './OnnxPolicyRuntime';
import type { MjData, MjModel } from '@mujoco/mujoco';
import { buildGo2wObservation, GO2W_VELOCITY_TASK } from '../tasks/go2wVelocity';
import type { JointBinding, RLCommand } from '../types';
import type { PolicyRuntimeBindings } from './OnnxPolicyRuntime';
interface BoundJoint extends JointBinding {positionActuator:boolean;controlScale:number;}
interface BoundJoint extends JointBinding {
positionActuator: boolean;
controlScale: number;
}
function rotateInverse(quaternion:readonly number[],vector:readonly number[]):[number,number,number]{
const [w,x,y,z]=quaternion,[vx,vy,vz]=vector;
const tx=2*(y*vz-z*vy),ty=2*(z*vx-x*vz),tz=2*(x*vy-y*vx);
return [vx-w*tx+(y*tz-z*ty),vy-w*ty+(z*tx-x*tz),vz-w*tz+(x*ty-y*tx)];
function rotateInverse(
quaternion: readonly number[],
vector: readonly number[],
): [number, number, number] {
const [w, x, y, z] = quaternion,
[vx, vy, vz] = vector;
const tx = 2 * (y * vz - z * vy),
ty = 2 * (z * vx - x * vz),
tz = 2 * (x * vy - y * vx);
return [
vx - w * tx + (y * tz - z * ty),
vy - w * ty + (z * tx - x * tz),
vz - w * tz + (x * ty - y * tx),
];
}
/** 将 mjlab Go2 velocity 的 47 维 actor 观测和 12 维关节位置动作映射到 MuJoCo。 */
export class Go2wPolicyBindings implements PolicyRuntimeBindings {
private readonly joints:BoundJoint[];
private readonly baseBodyId:number;
private readonly baseFreeJointId:number;
private readonly gyroSensorId?:number;
private readonly wheelActuatorIds:number[];
private readonly joints: BoundJoint[];
private readonly baseBodyId: number;
private readonly baseFreeJointId: number;
private readonly gyroSensorId?: number;
private readonly wheelActuatorIds: number[];
constructor(private readonly model:MjModel,private readonly data:MjData,private readonly setActuator:(id:number,value:number)=>void){
const jointIds=new Map<string,number>(),actuatorIds=new Map<string,number>(),sensorIds=new Map<string,number>(),bodyIds=new Map<string,number>();
for(let id=0;id<model.njnt;id+=1){const item=model.jnt(id);try{if(item.name)jointIds.set(item.name,id);}finally{item.delete();}}
for(let id=0;id<model.nactuator;id+=1){const item=model.actuator(id);try{if(item.name)actuatorIds.set(item.name,id);}finally{item.delete();}}
for(let id=0;id<model.nsensor;id+=1){const item=model.sensor(id);try{if(item.name)sensorIds.set(item.name,id);}finally{item.delete();}}
for(let id=0;id<model.nbody;id+=1){const item=model.body(id);try{if(item.name)bodyIds.set(item.name,id);}finally{item.delete();}}
this.baseBodyId=bodyIds.get('base_link')??bodyIds.get('base')??this.findFloatingBaseBody();
this.baseFreeJointId=this.findFreeJoint(this.baseBodyId);
const gyroCandidate=sensorIds.get('imu_gyro')??sensorIds.get('__platform_imu_gyro__');
this.gyroSensorId=gyroCandidate!==undefined&&this.isBaseAlignedGyro(gyroCandidate)?gyroCandidate:undefined;
this.joints=GO2W_VELOCITY_TASK.jointNames.map((name,index)=>{
const jointId=jointIds.get(name);if(jointId===undefined)throw new Error(`Go2-W 策略找不到关节:${name}`);
const short=name.replace(/_joint$/,'');
const actuatorId=actuatorIds.get(short)??actuatorIds.get(`${name}_motor`);
if(actuatorId===undefined)throw new Error(`Go2-W 策略找不到驱动器:${short} 或 ${name}_motor`);
const joint=model.jnt(jointId),actuator=model.actuator(actuatorId);
try{
const address=Number(model.actuator_ctrladr[actuatorId]??actuatorId),nextAddress=actuatorId+1<model.nactuator?Number(model.actuator_ctrladr[actuatorId+1]):model.nu;
if(nextAddress-address!==1||Number(actuator.trntype)!==0||Number(actuator.trnid[0])!==jointId)throw new Error(`驱动器 ${actuator.name||actuatorId} 不是关节 ${name} 的标量 joint transmission`);
if(Number(actuator.gaintype)!==0||Number(actuator.dyntype)!==0)throw new Error(`驱动器 ${actuator.name||actuatorId} 必须使用 fixed gain 和无激活动力学`);
const gear=Number(actuator.gear[0]),gain=Number(actuator.gainprm[0]),positionActuator=Number(actuator.biastype)===1&&Math.abs(Number(actuator.biasprm[1])+gain)<1e-5;
const motorActuator=Number(actuator.biastype)===0;
if(!positionActuator&&!motorActuator)throw new Error(`驱动器 ${actuator.name||actuatorId} 不是受支持的 motor/position 类型`);
if(positionActuator&&(Math.abs(gear-1)>1e-5||Math.abs(gain-GO2W_VELOCITY_TASK.stiffness[index])>1e-4||Math.abs(Number(actuator.biasprm[2])+GO2W_VELOCITY_TASK.damping[index])>1e-4))throw new Error(`position 驱动器 ${actuator.name||actuatorId} 的 gear/kp/kd 与 mjlab deploy 配置不一致`);
const controlScale=gear*gain;
if(!Number.isFinite(controlScale)||Math.abs(controlScale)<1e-9)throw new Error(`驱动器 ${actuator.name||actuatorId} 的 gear × gain 无效`);
return {name,jointId,qposAddress:Number(joint.qposadr),qvelAddress:Number(joint.dofadr),actuatorId,positionActuator,controlScale};
}finally{actuator.delete();joint.delete();}
});
this.wheelActuatorIds=['FL','FR','RL','RR'].flatMap(prefix=>{
const id=actuatorIds.get(`${prefix}_wheel`)??actuatorIds.get(`${prefix}_wheel_joint_motor`)??actuatorIds.get(`${prefix}_foot_joint_motor`);
return id===undefined?[]:[id];
});
}
observe(time:number,lastAction:Float32Array,command:RLCommand):Float32Array{
const quaternion=Array.from(this.data.xquat.subarray(this.baseBodyId*4,this.baseBodyId*4+4),Number);
const projectedGravity=rotateInverse(quaternion,[0,0,-1]);
let angularVelocity:[number,number,number];
if(this.gyroSensorId!==undefined){const address=Number(this.model.sensor_adr[this.gyroSensorId]);angularVelocity=[Number(this.data.sensordata[address]),Number(this.data.sensordata[address+1]),Number(this.data.sensordata[address+2])];}
else {const joint=this.model.jnt(this.baseFreeJointId);try{const address=Number(joint.dofadr)+3;angularVelocity=[Number(this.data.qvel[address]),Number(this.data.qvel[address+1]),Number(this.data.qvel[address+2])];}finally{joint.delete();}}
return buildGo2wObservation({angularVelocity,projectedGravity,command,time,jointPosition:this.joints.map(item=>Number(this.data.qpos[item.qposAddress])),jointVelocity:this.joints.map(item=>Number(this.data.qvel[item.qvelAddress])),lastAction:Array.from(lastAction)});
}
apply(action:Float32Array):void{
for(let index=0;index<this.joints.length;index+=1){
const item=this.joints[index],target=GO2W_VELOCITY_TASK.defaultJointPosition[index]+GO2W_VELOCITY_TASK.actionScale[index]*action[index];
const torque=GO2W_VELOCITY_TASK.stiffness[index]*(target-Number(this.data.qpos[item.qposAddress]))-GO2W_VELOCITY_TASK.damping[index]*Number(this.data.qvel[item.qvelAddress]);
this.setActuator(item.actuatorId,item.positionActuator?target:torque/item.controlScale);
constructor(
private readonly model: MjModel,
private readonly data: MjData,
private readonly setActuator: (id: number, value: number) => void,
) {
const jointIds = new Map<string, number>(),
actuatorIds = new Map<string, number>(),
sensorIds = new Map<string, number>(),
bodyIds = new Map<string, number>();
for (let id = 0; id < model.njnt; id += 1) {
const item = model.jnt(id);
try {
if (item.name) jointIds.set(item.name, id);
} finally {
item.delete();
}
}
for(const id of this.wheelActuatorIds)this.setActuator(id,0);
for (let id = 0; id < model.nactuator; id += 1) {
const item = model.actuator(id);
try {
if (item.name) actuatorIds.set(item.name, id);
} finally {
item.delete();
}
}
for (let id = 0; id < model.nsensor; id += 1) {
const item = model.sensor(id);
try {
if (item.name) sensorIds.set(item.name, id);
} finally {
item.delete();
}
}
for (let id = 0; id < model.nbody; id += 1) {
const item = model.body(id);
try {
if (item.name) bodyIds.set(item.name, id);
} finally {
item.delete();
}
}
this.baseBodyId =
bodyIds.get('base_link') ?? bodyIds.get('base') ?? this.findFloatingBaseBody();
this.baseFreeJointId = this.findFreeJoint(this.baseBodyId);
const gyroCandidate = sensorIds.get('imu_gyro') ?? sensorIds.get('__platform_imu_gyro__');
this.gyroSensorId =
gyroCandidate !== undefined && this.isBaseAlignedGyro(gyroCandidate)
? gyroCandidate
: undefined;
this.joints = GO2W_VELOCITY_TASK.jointNames.map((name, index) => {
const jointId = jointIds.get(name);
if (jointId === undefined) throw new Error(`Go2-W 策略找不到关节:${name}`);
const short = name.replace(/_joint$/, '');
const actuatorId = actuatorIds.get(short) ?? actuatorIds.get(`${name}_motor`);
if (actuatorId === undefined)
throw new Error(`Go2-W 策略找不到驱动器:${short} 或 ${name}_motor`);
const joint = model.jnt(jointId),
actuator = model.actuator(actuatorId);
try {
const address = Number(model.actuator_ctrladr[actuatorId] ?? actuatorId),
nextAddress =
actuatorId + 1 < model.nactuator
? Number(model.actuator_ctrladr[actuatorId + 1])
: model.nu;
if (
nextAddress - address !== 1 ||
Number(actuator.trntype) !== 0 ||
Number(actuator.trnid[0]) !== jointId
)
throw new Error(
`驱动器 ${actuator.name || actuatorId} 不是关节 ${name} 的标量 joint transmission`,
);
if (Number(actuator.gaintype) !== 0 || Number(actuator.dyntype) !== 0)
throw new Error(
`驱动器 ${actuator.name || actuatorId} 必须使用 fixed gain 和无激活动力学`,
);
const gear = Number(actuator.gear[0]),
gain = Number(actuator.gainprm[0]),
positionActuator =
Number(actuator.biastype) === 1 && Math.abs(Number(actuator.biasprm[1]) + gain) < 1e-5;
const motorActuator = Number(actuator.biastype) === 0;
if (!positionActuator && !motorActuator)
throw new Error(`驱动器 ${actuator.name || actuatorId} 不是受支持的 motor/position 类型`);
if (
positionActuator &&
(Math.abs(gear - 1) > 1e-5 ||
Math.abs(gain - GO2W_VELOCITY_TASK.stiffness[index]) > 1e-4 ||
Math.abs(Number(actuator.biasprm[2]) + GO2W_VELOCITY_TASK.damping[index]) > 1e-4)
)
throw new Error(
`position 驱动器 ${actuator.name || actuatorId} 的 gear/kp/kd 与 mjlab deploy 配置不一致`,
);
const controlScale = gear * gain;
if (!Number.isFinite(controlScale) || Math.abs(controlScale) < 1e-9)
throw new Error(`驱动器 ${actuator.name || actuatorId} 的 gear × gain 无效`);
return {
name,
jointId,
qposAddress: Number(joint.qposadr),
qvelAddress: Number(joint.dofadr),
actuatorId,
positionActuator,
controlScale,
};
} finally {
actuator.delete();
joint.delete();
}
});
this.wheelActuatorIds = ['FL', 'FR', 'RL', 'RR'].flatMap((prefix) => {
const id =
actuatorIds.get(`${prefix}_wheel`) ??
actuatorIds.get(`${prefix}_wheel_joint_motor`) ??
actuatorIds.get(`${prefix}_foot_joint_motor`);
return id === undefined ? [] : [id];
});
}
clear():void{for(const item of this.joints)this.setActuator(item.actuatorId,0);for(const id of this.wheelActuatorIds)this.setActuator(id,0);}
private isBaseAlignedGyro(sensorId:number):boolean{const siteId=Number(this.model.sensor_objid[sensorId]);if(Number(this.model.sensor_dim[sensorId])!==3||siteId<0||siteId>=this.model.nsite||Number(this.model.site_bodyid[siteId])!==this.baseBodyId)return false;const offset=siteId*4;return Math.abs(Number(this.model.site_quat[offset])-1)<1e-5&&Math.abs(Number(this.model.site_quat[offset+1]))<1e-5&&Math.abs(Number(this.model.site_quat[offset+2]))<1e-5&&Math.abs(Number(this.model.site_quat[offset+3]))<1e-5;}
private findFloatingBaseBody():number{for(let jointId=0;jointId<this.model.njnt;jointId+=1)if(Number(this.model.jnt_type[jointId])===0)return Number(this.model.jnt_bodyid[jointId]);throw new Error('Go2-W 策略需要浮动基座(free joint)');}
private findFreeJoint(bodyId:number):number{for(let jointId=0;jointId<this.model.njnt;jointId+=1)if(Number(this.model.jnt_type[jointId])===0&&Number(this.model.jnt_bodyid[jointId])===bodyId)return jointId;throw new Error('Go2-W 基座没有 free joint,请使用浮动基座模型');}
observe(time: number, lastAction: Float32Array, command: RLCommand): Float32Array {
const quaternion = Array.from(
this.data.xquat.subarray(this.baseBodyId * 4, this.baseBodyId * 4 + 4),
Number,
);
const projectedGravity = rotateInverse(quaternion, [0, 0, -1]);
let angularVelocity: [number, number, number];
if (this.gyroSensorId !== undefined) {
const address = Number(this.model.sensor_adr[this.gyroSensorId]);
angularVelocity = [
Number(this.data.sensordata[address]),
Number(this.data.sensordata[address + 1]),
Number(this.data.sensordata[address + 2]),
];
} else {
const joint = this.model.jnt(this.baseFreeJointId);
try {
const address = Number(joint.dofadr) + 3;
angularVelocity = [
Number(this.data.qvel[address]),
Number(this.data.qvel[address + 1]),
Number(this.data.qvel[address + 2]),
];
} finally {
joint.delete();
}
}
return buildGo2wObservation({
angularVelocity,
projectedGravity,
command,
time,
jointPosition: this.joints.map((item) => Number(this.data.qpos[item.qposAddress])),
jointVelocity: this.joints.map((item) => Number(this.data.qvel[item.qvelAddress])),
lastAction: Array.from(lastAction),
});
}
apply(action: Float32Array): void {
for (let index = 0; index < this.joints.length; index += 1) {
const item = this.joints[index],
target =
GO2W_VELOCITY_TASK.defaultJointPosition[index] +
GO2W_VELOCITY_TASK.actionScale[index] * action[index];
const torque =
GO2W_VELOCITY_TASK.stiffness[index] * (target - Number(this.data.qpos[item.qposAddress])) -
GO2W_VELOCITY_TASK.damping[index] * Number(this.data.qvel[item.qvelAddress]);
this.setActuator(
item.actuatorId,
item.positionActuator ? target : torque / item.controlScale,
);
}
for (const id of this.wheelActuatorIds) this.setActuator(id, 0);
}
clear(): void {
for (const item of this.joints) this.setActuator(item.actuatorId, 0);
for (const id of this.wheelActuatorIds) this.setActuator(id, 0);
}
private isBaseAlignedGyro(sensorId: number): boolean {
const siteId = Number(this.model.sensor_objid[sensorId]);
if (
Number(this.model.sensor_dim[sensorId]) !== 3 ||
siteId < 0 ||
siteId >= this.model.nsite ||
Number(this.model.site_bodyid[siteId]) !== this.baseBodyId
)
return false;
const offset = siteId * 4;
return (
Math.abs(Number(this.model.site_quat[offset]) - 1) < 1e-5 &&
Math.abs(Number(this.model.site_quat[offset + 1])) < 1e-5 &&
Math.abs(Number(this.model.site_quat[offset + 2])) < 1e-5 &&
Math.abs(Number(this.model.site_quat[offset + 3])) < 1e-5
);
}
private findFloatingBaseBody(): number {
for (let jointId = 0; jointId < this.model.njnt; jointId += 1)
if (Number(this.model.jnt_type[jointId]) === 0) return Number(this.model.jnt_bodyid[jointId]);
throw new Error('Go2-W 策略需要浮动基座(free joint)');
}
private findFreeJoint(bodyId: number): number {
for (let jointId = 0; jointId < this.model.njnt; jointId += 1)
if (
Number(this.model.jnt_type[jointId]) === 0 &&
Number(this.model.jnt_bodyid[jointId]) === bodyId
)
return jointId;
throw new Error('Go2-W 基座没有 free joint,请使用浮动基座模型');
}
}
+190 -62
View File
@@ -1,83 +1,211 @@
import * as ort from 'onnxruntime-web/wasm';
import {GO2W_VELOCITY_TASK,clampGo2wCommand} from '../tasks/go2wVelocity';
import type {RLCommand,RLPolicyStatus} from '../types';
import { GO2W_VELOCITY_TASK, clampGo2wCommand } from '../tasks/go2wVelocity';
import type { RLCommand, RLPolicyStatus } from '../types';
ort.env.wasm.numThreads=1;
ort.env.wasm.proxy=false;
ort.env.wasm.numThreads = 1;
ort.env.wasm.proxy = false;
export interface PolicyRuntimeBindings {
observe(time:number,lastAction:Float32Array,command:RLCommand):Float32Array;
apply(action:Float32Array):void;
clear():void;
observe(time: number, lastAction: Float32Array, command: RLCommand): Float32Array;
apply(action: Float32Array): void;
clear(): void;
}
function message(error:unknown):string{return error instanceof Error?error.message:String(error);}
function message(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
/**
* ONNX Runtime Web 的 run() 是异步 API。物理循环会在每个 mj_step 前持续施加最近一次
* 完成的动作,并按控制频率启动下一次推理,避免阻塞 MuJoCo 的同步步进循环。
*/
export class OnnxPolicyRuntime {
private enabled=false;
private disposed=false;
private inFlight=false;
private nextInferenceTime=0;
private action=new Float32Array(GO2W_VELOCITY_TASK.actionSize);
private commandValue:RLCommand={linearX:0,linearY:0,angularZ:0};
private inferenceCount=0;
private lastInferenceMs=0;
private error?:string;
private epoch=0;
private runPromise?:Promise<void>;
private enabled = false;
private disposed = false;
private inFlight = false;
private nextInferenceTime = 0;
private action = new Float32Array(GO2W_VELOCITY_TASK.actionSize);
private commandValue: RLCommand = { linearX: 0, linearY: 0, angularZ: 0 };
private inferenceCount = 0;
private lastInferenceMs = 0;
private error?: string;
private epoch = 0;
private runPromise?: Promise<void>;
private constructor(private readonly session:ort.InferenceSession,private readonly bindings:PolicyRuntimeBindings,private readonly path:string,private readonly inputName:string,private readonly outputName:string){}
private constructor(
private readonly session: ort.InferenceSession,
private readonly bindings: PolicyRuntimeBindings,
private readonly path: string,
private readonly inputName: string,
private readonly outputName: string,
) {}
static async load(model:Uint8Array,path:string,bindings:PolicyRuntimeBindings):Promise<OnnxPolicyRuntime>{
const session=await ort.InferenceSession.create(model.slice(),{executionProviders:['wasm'],graphOptimizationLevel:'all'});
try{
if(session.inputNames.length!==1)throw new Error(`当前仅支持单输入策略,模型包含 ${session.inputNames.length} 个输入`);
if(session.outputNames.length<1)throw new Error('ONNX 策略没有输出');
const input=session.inputMetadata[0],output=session.outputMetadata[0];
if(!input?.isTensor||input.type!=='float32')throw new Error('策略输入必须是 float32 Tensor');
if(!output?.isTensor||output.type!=='float32')throw new Error('策略输出必须是 float32 Tensor');
if(input.shape.length!==2||output.shape.length!==2)throw new Error(`策略输入/输出必须是二维 [batch, features],实际为 [${input.shape}] / [${output.shape}]`);
const inputBatch=input.shape[0],outputBatch=output.shape[0],fixedInput=input.shape[1],fixedOutput=output.shape[1];
if(typeof inputBatch==='number'&&inputBatch!==-1&&inputBatch!==1)throw new Error(`策略输入 batch 必须为 1 或动态维度,实际为 ${inputBatch}`);
if(typeof outputBatch==='number'&&outputBatch!==-1&&outputBatch!==1)throw new Error(`策略输出 batch 必须为 1 或动态维度,实际为 ${outputBatch}`);
if(typeof fixedInput==='number'&&fixedInput>0&&fixedInput!==GO2W_VELOCITY_TASK.observationSize)throw new Error(`策略观测维度不匹配:模型 ${fixedInput},任务 ${GO2W_VELOCITY_TASK.observationSize}`);
if(typeof fixedOutput==='number'&&fixedOutput>0&&fixedOutput!==GO2W_VELOCITY_TASK.actionSize)throw new Error(`策略动作维度不匹配:模型 ${fixedOutput},任务 ${GO2W_VELOCITY_TASK.actionSize}`);
return new OnnxPolicyRuntime(session,bindings,path,session.inputNames[0],session.outputNames[0]);
}catch(error){await session.release();throw error;}
static async load(
model: Uint8Array,
path: string,
bindings: PolicyRuntimeBindings,
): Promise<OnnxPolicyRuntime> {
const session = await ort.InferenceSession.create(model.slice(), {
executionProviders: ['wasm'],
graphOptimizationLevel: 'all',
});
try {
if (session.inputNames.length !== 1)
throw new Error(`当前仅支持单输入策略,模型包含 ${session.inputNames.length} 个输入`);
if (session.outputNames.length < 1) throw new Error('ONNX 策略没有输出');
const input = session.inputMetadata[0],
output = session.outputMetadata[0];
if (!input?.isTensor || input.type !== 'float32')
throw new Error('策略输入必须是 float32 Tensor');
if (!output?.isTensor || output.type !== 'float32')
throw new Error('策略输出必须是 float32 Tensor');
if (input.shape.length !== 2 || output.shape.length !== 2)
throw new Error(
`策略输入/输出必须是二维 [batch, features],实际为 [${input.shape}] / [${output.shape}]`,
);
const inputBatch = input.shape[0],
outputBatch = output.shape[0],
fixedInput = input.shape[1],
fixedOutput = output.shape[1];
if (typeof inputBatch === 'number' && inputBatch !== -1 && inputBatch !== 1)
throw new Error(`策略输入 batch 必须为 1 或动态维度,实际为 ${inputBatch}`);
if (typeof outputBatch === 'number' && outputBatch !== -1 && outputBatch !== 1)
throw new Error(`策略输出 batch 必须为 1 或动态维度,实际为 ${outputBatch}`);
if (
typeof fixedInput === 'number' &&
fixedInput > 0 &&
fixedInput !== GO2W_VELOCITY_TASK.observationSize
)
throw new Error(
`策略观测维度不匹配:模型 ${fixedInput},任务 ${GO2W_VELOCITY_TASK.observationSize}`,
);
if (
typeof fixedOutput === 'number' &&
fixedOutput > 0 &&
fixedOutput !== GO2W_VELOCITY_TASK.actionSize
)
throw new Error(
`策略动作维度不匹配:模型 ${fixedOutput},任务 ${GO2W_VELOCITY_TASK.actionSize}`,
);
return new OnnxPolicyRuntime(
session,
bindings,
path,
session.inputNames[0],
session.outputNames[0],
);
} catch (error) {
await session.release();
throw error;
}
}
status():RLPolicyStatus{return {taskId:GO2W_VELOCITY_TASK.id,taskName:GO2W_VELOCITY_TASK.name,path:this.path,loaded:!this.disposed,enabled:this.enabled,controlHz:GO2W_VELOCITY_TASK.controlHz,observationSize:GO2W_VELOCITY_TASK.observationSize,actionSize:GO2W_VELOCITY_TASK.actionSize,inputName:this.inputName,outputName:this.outputName,command:{...this.commandValue},inferenceCount:this.inferenceCount,lastInferenceMs:this.lastInferenceMs,error:this.error};}
setCommand(command:RLCommand):void{this.commandValue=clampGo2wCommand(command);}
setEnabled(enabled:boolean,time:number):void{if(this.disposed)return;this.epoch+=1;this.enabled=enabled;this.error=undefined;this.nextInferenceTime=time;if(!enabled){this.action.fill(0);this.bindings.clear();}}
reset(time:number):void{this.epoch+=1;this.action.fill(0);this.nextInferenceTime=time;this.error=undefined;this.bindings.clear();}
status(): RLPolicyStatus {
return {
taskId: GO2W_VELOCITY_TASK.id,
taskName: GO2W_VELOCITY_TASK.name,
path: this.path,
loaded: !this.disposed,
enabled: this.enabled,
controlHz: GO2W_VELOCITY_TASK.controlHz,
observationSize: GO2W_VELOCITY_TASK.observationSize,
actionSize: GO2W_VELOCITY_TASK.actionSize,
inputName: this.inputName,
outputName: this.outputName,
command: { ...this.commandValue },
inferenceCount: this.inferenceCount,
lastInferenceMs: this.lastInferenceMs,
error: this.error,
};
}
setCommand(command: RLCommand): void {
this.commandValue = clampGo2wCommand(command);
}
setEnabled(enabled: boolean, time: number): void {
if (this.disposed) return;
this.epoch += 1;
this.enabled = enabled;
this.error = undefined;
this.nextInferenceTime = time;
if (!enabled) {
this.action.fill(0);
this.bindings.clear();
}
}
reset(time: number): void {
this.epoch += 1;
this.action.fill(0);
this.nextInferenceTime = time;
this.error = undefined;
this.bindings.clear();
}
step(time:number):void{
if(!this.enabled||this.disposed)return;
step(time: number): void {
if (!this.enabled || this.disposed) return;
this.bindings.apply(this.action);
if(this.inFlight||time+1e-9<this.nextInferenceTime)return;
let observation:Float32Array;
try{observation=this.bindings.observe(time,this.action,this.commandValue);}
catch(error){this.fail(error);return;}
this.inFlight=true;
this.nextInferenceTime=time+1/GO2W_VELOCITY_TASK.controlHz;
const started=performance.now(),epoch=this.epoch;
const input=new ort.Tensor('float32',observation,[1,observation.length]);
this.runPromise=this.session.run({[this.inputName]:input}).then(outputs=>{
try{
const output=outputs[this.outputName];
if(!output||output.type!=='float32')throw new Error(`找不到 float32 输出:${this.outputName}`);
if(output.data.length!==GO2W_VELOCITY_TASK.actionSize)throw new Error(`策略动作维度错误:期望 ${GO2W_VELOCITY_TASK.actionSize},实际 ${output.data.length}`);
const next=Float32Array.from(output.data as Float32Array,Number);
for(const value of next)if(!Number.isFinite(value))throw new Error('策略输出包含非有限数');
if(!this.disposed&&this.enabled&&epoch===this.epoch){this.action=next;this.inferenceCount+=1;this.lastInferenceMs=performance.now()-started;}
}finally{for(const value of Object.values(outputs))value.dispose();}
}).catch(error=>{if(epoch===this.epoch)this.fail(error);}).finally(()=>{input.dispose();this.inFlight=false;this.runPromise=undefined;});
if (this.inFlight || time + 1e-9 < this.nextInferenceTime) return;
let observation: Float32Array;
try {
observation = this.bindings.observe(time, this.action, this.commandValue);
} catch (error) {
this.fail(error);
return;
}
this.inFlight = true;
this.nextInferenceTime = time + 1 / GO2W_VELOCITY_TASK.controlHz;
const started = performance.now(),
epoch = this.epoch;
const input = new ort.Tensor('float32', observation, [1, observation.length]);
this.runPromise = this.session
.run({ [this.inputName]: input })
.then((outputs) => {
try {
const output = outputs[this.outputName];
if (!output || output.type !== 'float32')
throw new Error(`找不到 float32 输出:${this.outputName}`);
if (output.data.length !== GO2W_VELOCITY_TASK.actionSize)
throw new Error(
`策略动作维度错误:期望 ${GO2W_VELOCITY_TASK.actionSize},实际 ${output.data.length}`,
);
const next = Float32Array.from(output.data as Float32Array, Number);
for (const value of next)
if (!Number.isFinite(value)) throw new Error('策略输出包含非有限数');
if (!this.disposed && this.enabled && epoch === this.epoch) {
this.action = next;
this.inferenceCount += 1;
this.lastInferenceMs = performance.now() - started;
}
} finally {
for (const value of Object.values(outputs)) value.dispose();
}
})
.catch((error) => {
if (epoch === this.epoch) this.fail(error);
})
.finally(() => {
input.dispose();
this.inFlight = false;
this.runPromise = undefined;
});
}
private fail(error:unknown):void{if(this.disposed)return;this.error=message(error);this.enabled=false;this.bindings.clear();}
dispose():void{if(this.disposed)return;this.disposed=true;this.enabled=false;this.epoch+=1;this.bindings.clear();const pending=this.runPromise??Promise.resolve();void pending.catch(()=>{}).finally(()=>this.session.release().catch(error=>console.warn('[ONNX] 释放推理会话失败',error)));}
private fail(error: unknown): void {
if (this.disposed) return;
this.error = message(error);
this.enabled = false;
this.bindings.clear();
}
dispose(): void {
if (this.disposed) return;
this.disposed = true;
this.enabled = false;
this.epoch += 1;
this.bindings.clear();
const pending = this.runPromise ?? Promise.resolve();
void pending
.catch(() => {})
.finally(() =>
this.session.release().catch((error) => console.warn('[ONNX] 释放推理会话失败', error)),
);
}
}
+47 -18
View File
@@ -1,28 +1,57 @@
import {describe,expect,it} from 'vitest';
import {buildGo2wObservation,clampGo2wCommand,go2wGaitPhase,GO2W_VELOCITY_TASK} from './go2wVelocity';
import { describe, expect, it } from 'vitest';
import {
buildGo2wObservation,
clampGo2wCommand,
go2wGaitPhase,
GO2W_VELOCITY_TASK,
} from './go2wVelocity';
describe('Go2-W velocity task',()=>{
it('按 mjlab deploy 顺序构造 47 维 actor 观测',()=>{
const jointPosition=GO2W_VELOCITY_TASK.defaultJointPosition.map(value=>value+0.1);
const observation=buildGo2wObservation({angularVelocity:[1,2,3],projectedGravity:[0,0,-1],command:{linearX:0.5,linearY:-0.25,angularZ:0.2},time:0,jointPosition,jointVelocity:Array(12).fill(0.3),lastAction:Array(12).fill(-0.4)});
describe('Go2-W velocity task', () => {
it('按 mjlab deploy 顺序构造 47 维 actor 观测', () => {
const jointPosition = GO2W_VELOCITY_TASK.defaultJointPosition.map((value) => value + 0.1);
const observation = buildGo2wObservation({
angularVelocity: [1, 2, 3],
projectedGravity: [0, 0, -1],
command: { linearX: 0.5, linearY: -0.25, angularZ: 0.2 },
time: 0,
jointPosition,
jointVelocity: Array(12).fill(0.3),
lastAction: Array(12).fill(-0.4),
});
expect(observation).toHaveLength(47);
[1,2,3,0,0,-1,0.5,-0.25,0.2,0,1].forEach((value,index)=>expect(observation[index]).toBeCloseTo(value));
for(const value of observation.slice(11,23))expect(value).toBeCloseTo(0.1);
for(const value of observation.slice(23,35))expect(value).toBeCloseTo(0.3);
for(const value of observation.slice(35,47))expect(value).toBeCloseTo(-0.4);
[1, 2, 3, 0, 0, -1, 0.5, -0.25, 0.2, 0, 1].forEach((value, index) =>
expect(observation[index]).toBeCloseTo(value),
);
for (const value of observation.slice(11, 23)) expect(value).toBeCloseTo(0.1);
for (const value of observation.slice(23, 35)) expect(value).toBeCloseTo(0.3);
for (const value of observation.slice(35, 47)) expect(value).toBeCloseTo(-0.4);
});
it('静止时关闭步态相位,并限制速度命令范围',()=>{
expect(go2wGaitPhase(0.15,{linearX:0,linearY:0,angularZ:0})).toEqual([0,0]);
const moving=go2wGaitPhase(0.15,{linearX:1,linearY:0,angularZ:0});
it('静止时关闭步态相位,并限制速度命令范围', () => {
expect(go2wGaitPhase(0.15, { linearX: 0, linearY: 0, angularZ: 0 })).toEqual([0, 0]);
const moving = go2wGaitPhase(0.15, { linearX: 1, linearY: 0, angularZ: 0 });
expect(moving[0]).toBeCloseTo(1);
expect(moving[1]).toBeCloseTo(0);
expect(clampGo2wCommand({linearX:4,linearY:-4,angularZ:3})).toEqual({linearX:1,linearY:-0.5,angularZ:1});
expect(clampGo2wCommand({ linearX: 4, linearY: -4, angularZ: 3 })).toEqual({
linearX: 1,
linearY: -0.5,
angularZ: 1,
});
});
it('拒绝维度错误或非有限观测',()=>{
const valid={angularVelocity:[0,0,0],projectedGravity:[0,0,-1],command:{linearX:0,linearY:0,angularZ:0},time:0,jointPosition:Array(12).fill(0),jointVelocity:Array(12).fill(0),lastAction:Array(12).fill(0)};
expect(()=>buildGo2wObservation({...valid,lastAction:[0]})).toThrow(/观测维度/);
expect(()=>buildGo2wObservation({...valid,angularVelocity:[Number.NaN,0,0]})).toThrow(/非有限数/);
it('拒绝维度错误或非有限观测', () => {
const valid = {
angularVelocity: [0, 0, 0],
projectedGravity: [0, 0, -1],
command: { linearX: 0, linearY: 0, angularZ: 0 },
time: 0,
jointPosition: Array(12).fill(0),
jointVelocity: Array(12).fill(0),
lastAction: Array(12).fill(0),
};
expect(() => buildGo2wObservation({ ...valid, lastAction: [0] })).toThrow(/观测维度/);
expect(() => buildGo2wObservation({ ...valid, angularVelocity: [Number.NaN, 0, 0] })).toThrow(
/非有限数/,
);
});
});
+68 -43
View File
@@ -1,57 +1,82 @@
import type {RLCommand} from '../types';
import type { RLCommand } from '../types';
export const GO2W_VELOCITY_TASK={
id:'unitree-go2w-velocity' as const,
name:'Unitree Go2-W 平衡/速度控制',
controlHz:50,
gaitPeriod:0.6,
observationSize:47,
actionSize:12,
commandLimits:{linearX:[-0.5,1] as const,linearY:[-0.5,0.5] as const,angularZ:[-1,1] as const},
jointNames:[
'FL_hip_joint','FL_thigh_joint','FL_calf_joint',
'FR_hip_joint','FR_thigh_joint','FR_calf_joint',
'RL_hip_joint','RL_thigh_joint','RL_calf_joint',
'RR_hip_joint','RR_thigh_joint','RR_calf_joint',
export const GO2W_VELOCITY_TASK = {
id: 'unitree-go2w-velocity' as const,
name: 'Unitree Go2-W 平衡/速度控制',
controlHz: 50,
gaitPeriod: 0.6,
observationSize: 47,
actionSize: 12,
commandLimits: {
linearX: [-0.5, 1] as const,
linearY: [-0.5, 0.5] as const,
angularZ: [-1, 1] as const,
},
jointNames: [
'FL_hip_joint',
'FL_thigh_joint',
'FL_calf_joint',
'FR_hip_joint',
'FR_thigh_joint',
'FR_calf_joint',
'RL_hip_joint',
'RL_thigh_joint',
'RL_calf_joint',
'RR_hip_joint',
'RR_thigh_joint',
'RR_calf_joint',
] as const,
defaultJointPosition:[-0.1,0.9,-1.8,0.1,0.9,-1.8,-0.1,0.9,-1.8,0.1,0.9,-1.8] as const,
actionScale:[0.25,0.25,0.25,0.25,0.25,0.25,0.25,0.25,0.25,0.25,0.25,0.25] as const,
stiffness:[20,20,40,20,20,40,20,20,40,20,20,40] as const,
damping:[1,1,2,1,1,2,1,1,2,1,1,2] as const,
defaultJointPosition: [-0.1, 0.9, -1.8, 0.1, 0.9, -1.8, -0.1, 0.9, -1.8, 0.1, 0.9, -1.8] as const,
actionScale: [0.25, 0.25, 0.25, 0.25, 0.25, 0.25, 0.25, 0.25, 0.25, 0.25, 0.25, 0.25] as const,
stiffness: [20, 20, 40, 20, 20, 40, 20, 20, 40, 20, 20, 40] as const,
damping: [1, 1, 2, 1, 1, 2, 1, 1, 2, 1, 1, 2] as const,
};
export function clampGo2wCommand(command:RLCommand):RLCommand {
const limits=GO2W_VELOCITY_TASK.commandLimits;
const clamp=(value:number,range:readonly[number,number])=>Math.min(range[1],Math.max(range[0],Number.isFinite(value)?value:0));
return {linearX:clamp(command.linearX,limits.linearX),linearY:clamp(command.linearY,limits.linearY),angularZ:clamp(command.angularZ,limits.angularZ)};
export function clampGo2wCommand(command: RLCommand): RLCommand {
const limits = GO2W_VELOCITY_TASK.commandLimits;
const clamp = (value: number, range: readonly [number, number]) =>
Math.min(range[1], Math.max(range[0], Number.isFinite(value) ? value : 0));
return {
linearX: clamp(command.linearX, limits.linearX),
linearY: clamp(command.linearY, limits.linearY),
angularZ: clamp(command.angularZ, limits.angularZ),
};
}
export function go2wGaitPhase(time:number,command:RLCommand):[number,number] {
if(Math.hypot(command.linearX,command.linearY,command.angularZ)<0.1)return [0,0];
const phase=((time/GO2W_VELOCITY_TASK.gaitPeriod)%1+1)%1;
return [Math.sin(phase*2*Math.PI),Math.cos(phase*2*Math.PI)];
export function go2wGaitPhase(time: number, command: RLCommand): [number, number] {
if (Math.hypot(command.linearX, command.linearY, command.angularZ) < 0.1) return [0, 0];
const phase = (((time / GO2W_VELOCITY_TASK.gaitPeriod) % 1) + 1) % 1;
return [Math.sin(phase * 2 * Math.PI), Math.cos(phase * 2 * Math.PI)];
}
export function buildGo2wObservation(values:{
angularVelocity:readonly number[];
projectedGravity:readonly number[];
command:RLCommand;
time:number;
jointPosition:readonly number[];
jointVelocity:readonly number[];
lastAction:readonly number[];
}):Float32Array {
const phase=go2wGaitPhase(values.time,values.command);
const observation=new Float32Array([
...values.angularVelocity.slice(0,3),
...values.projectedGravity.slice(0,3),
values.command.linearX,values.command.linearY,values.command.angularZ,
export function buildGo2wObservation(values: {
angularVelocity: readonly number[];
projectedGravity: readonly number[];
command: RLCommand;
time: number;
jointPosition: readonly number[];
jointVelocity: readonly number[];
lastAction: readonly number[];
}): Float32Array {
const phase = go2wGaitPhase(values.time, values.command);
const observation = new Float32Array([
...values.angularVelocity.slice(0, 3),
...values.projectedGravity.slice(0, 3),
values.command.linearX,
values.command.linearY,
values.command.angularZ,
...phase,
...values.jointPosition.map((value,index)=>value-GO2W_VELOCITY_TASK.defaultJointPosition[index]),
...values.jointPosition.map(
(value, index) => value - GO2W_VELOCITY_TASK.defaultJointPosition[index],
),
...values.jointVelocity,
...values.lastAction,
]);
if(observation.length!==GO2W_VELOCITY_TASK.observationSize)throw new Error(`Go2-W 观测维度错误:期望 ${GO2W_VELOCITY_TASK.observationSize},实际 ${observation.length}`);
for(const value of observation)if(!Number.isFinite(value))throw new Error('Go2-W 观测包含非有限数');
if (observation.length !== GO2W_VELOCITY_TASK.observationSize)
throw new Error(
`Go2-W 观测维度错误:期望 ${GO2W_VELOCITY_TASK.observationSize},实际 ${observation.length}`,
);
for (const value of observation)
if (!Number.isFinite(value)) throw new Error('Go2-W 观测包含非有限数');
return observation;
}
+22 -22
View File
@@ -1,30 +1,30 @@
export interface RLCommand {
linearX:number;
linearY:number;
angularZ:number;
linearX: number;
linearY: number;
angularZ: number;
}
export interface RLPolicyStatus {
taskId:'unitree-go2w-velocity';
taskName:string;
path:string;
loaded:boolean;
enabled:boolean;
controlHz:number;
observationSize:number;
actionSize:number;
inputName:string;
outputName:string;
command:RLCommand;
inferenceCount:number;
lastInferenceMs:number;
error?:string;
taskId: 'unitree-go2w-velocity';
taskName: string;
path: string;
loaded: boolean;
enabled: boolean;
controlHz: number;
observationSize: number;
actionSize: number;
inputName: string;
outputName: string;
command: RLCommand;
inferenceCount: number;
lastInferenceMs: number;
error?: string;
}
export interface JointBinding {
name:string;
jointId:number;
qposAddress:number;
qvelAddress:number;
actuatorId:number;
name: string;
jointId: number;
qposAddress: number;
qvelAddress: number;
actuatorId: number;
}
+236 -84
View File
@@ -1,17 +1,33 @@
import loadMujoco, {type MainModule} from '@mujoco/mujoco';
import type {ProjectFile,ProjectManifest} from '../project/types';
import {prepareProjectForMujoco} from '../project/importer';
import {enhanceConvertedMjcf,groundConvertedMjcf,type UrdfBaseMode,type UrdfEnhancementOptions} from '../project/urdfToMjcf';
import {MemfsWorkspace} from '../project/workspace';
import {SimulationSession,type ActuatorParameters,type FrameResult,type SimulationSnapshot} from './SimulationSession';
import type {ControllerCommand,ControllerStatus} from '../controller/types';
import type {RLCommand,RLPolicyStatus} from '../rl/types';
import loadMujoco, { type MainModule } from '@mujoco/mujoco';
import type { ProjectFile, ProjectManifest } from '../project/types';
import { prepareProjectForMujoco } from '../project/importer';
import {
enhanceConvertedMjcf,
groundConvertedMjcf,
type UrdfBaseMode,
type UrdfEnhancementOptions,
} from '../project/urdfToMjcf';
import { MemfsWorkspace } from '../project/workspace';
import {
SimulationSession,
type ActuatorParameters,
type FrameResult,
type SimulationSnapshot,
} from './SimulationSession';
import type { ControllerCommand, ControllerStatus } from '../controller/types';
import type { RLCommand, RLPolicyStatus } from '../rl/types';
export type UrdfLoadMode='mjcf'|'native';
export type {UrdfBaseMode,UrdfEnhancementOptions};
export type UrdfLoadMode = 'mjcf' | 'native';
export type { UrdfBaseMode, UrdfEnhancementOptions };
export interface PhysicsAdapter {
load(manifest:ProjectManifest,entryPath:string,urdfMode?:UrdfLoadMode,baseMode?:UrdfBaseMode,enhancements?:UrdfEnhancementOptions):Promise<SimulationSnapshot>;
load(
manifest: ProjectManifest,
entryPath: string,
urdfMode?: UrdfLoadMode,
baseMode?: UrdfBaseMode,
enhancements?: UrdfEnhancementOptions,
): Promise<SimulationSnapshot>;
advance(now: number): FrameResult;
snapshot(): SimulationSnapshot | null;
setPaused(paused: boolean): void;
@@ -19,21 +35,21 @@ export interface PhysicsAdapter {
reset(): void;
singleStep(): void;
setActuator(id: number, value: number): void;
setActuatorParameters(id:number,parameters:ActuatorParameters):boolean;
setJointPosition(id:number,value:number):boolean;
resetJoints():void;
setIgnoreJointLimits(ignore:boolean):void;
setActuatorParameters(id: number, parameters: ActuatorParameters): boolean;
setJointPosition(id: number, value: number): boolean;
resetJoints(): void;
setIgnoreJointLimits(ignore: boolean): void;
setExternalForce(bodyId: number, force: [number, number, number]): void;
clearExternalForce(): void;
loadPythonController(source:string,path:string):Promise<ControllerStatus>;
setControllerEnabled(enabled:boolean):void;
sendControllerCommand(command:ControllerCommand):void;
removeController():void;
loadRLPolicy(model:Uint8Array,path:string):Promise<RLPolicyStatus>;
setRLPolicyEnabled(enabled:boolean):void;
setRLCommand(command:RLCommand):void;
removeRLPolicy():void;
cachedSupportFiles():ProjectFile[];
loadPythonController(source: string, path: string): Promise<ControllerStatus>;
setControllerEnabled(enabled: boolean): void;
sendControllerCommand(command: ControllerCommand): void;
removeController(): void;
loadRLPolicy(model: Uint8Array, path: string): Promise<RLPolicyStatus>;
setRLPolicyEnabled(enabled: boolean): void;
setRLCommand(command: RLCommand): void;
removeRLPolicy(): void;
cachedSupportFiles(): ProjectFile[];
exportMjcf(): Uint8Array;
dispose(): void;
}
@@ -53,68 +69,204 @@ export function getMujocoModule(): Promise<MainModule> {
export class MainThreadPhysicsAdapter implements PhysicsAdapter {
session: SimulationSession | null = null;
workspace: MemfsWorkspace | null = null;
private supportFiles:ProjectFile[]=[];
private supportFiles: ProjectFile[] = [];
async load(manifest:ProjectManifest,entryPath:string,urdfMode:UrdfLoadMode='mjcf',baseMode:UrdfBaseMode='floating',enhancements:UrdfEnhancementOptions={addActuators:false,addSensors:false,sensorType:'camera'}):Promise<SimulationSnapshot> {
this.releaseCurrent();const module=await getMujocoModule();const workspace=new MemfsWorkspace(module,manifest.id);const prepared=prepareProjectForMujoco(manifest,entryPath);
this.supportFiles=prepared.manifest.files.filter(file=>!manifest.files.some(original=>original.path===file.path));
try{
console.info('[MuJoCo] 写入 MEMFS',prepared.manifest.files.length);workspace.mount(prepared.manifest);
const entry=prepared.manifest.entries.find(candidate=>candidate.path===entryPath);let modelPath=workspace.path(entryPath);const warnings=[...prepared.warnings];
if(entry?.format==='urdf'&&urdfMode==='mjcf'){
console.info('[MuJoCo] 编译 URDF 中间模型',entryPath);const intermediate=new SimulationSession(module,modelPath);
try{
const minimumZ=intermediate.minimumGeometryZ();const slash=entryPath.lastIndexOf('/');const directory=slash>=0?entryPath.slice(0,slash+1):'';const convertedPath=`${directory}.__mujoco_converted_${manifest.id.replace(/[^a-zA-Z0-9_-]/g,'_')}.xml`;
if(module.mj_saveLastXML(workspace.path(convertedPath),intermediate.model)===0)throw new Error('MuJoCo 无法导出中间 MJCF');
const grounded=groundConvertedMjcf(new TextEncoder().encode(workspace.readText(convertedPath)),minimumZ,baseMode);
const enhanced=enhanceConvertedMjcf(grounded,enhancements);
workspace.writeGenerated(convertedPath,enhanced.data);modelPath=workspace.path(convertedPath);
warnings.push(`URDF 已转换为 MJCF(${baseMode==='floating'?'浮动基座':'固定基座'}),并整体平移 ${(-minimumZ).toFixed(4)} m,使最低点接触 z=0 地面`);
if(enhanced.actuatorCount)warnings.push(`已为 ${enhanced.actuatorCount} 个 hinge/slide 关节生成 motor 驱动器(控制输入不限幅;hinge 输出单位 N·m,slide 输出单位 N)`);
if(enhanced.unitreeGo2wTuned)warnings.unshift('已识别 Unitree Go2-W,并补齐官方 MuJoCo 关节惯量/阻尼、力矩限幅和轮胎接触参数');
if(enhanced.imuAdded)warnings.unshift('已在浮动基座添加6轴 IMU:imu_gyro(三轴角速度)和 imu_acc(三轴加速度)');
if(enhanced.cameraAdded)warnings.push(`已将 640×480 摄像头固连到 ${enhancements.cameraMountBody||'自动选择的头部/末端 body'},局部位置 ${(enhancements.cameraPosition??[.1,0,.05]).join(' ')} m,朝向 ${enhancements.cameraDirection??'+X'}`);
}finally{intermediate.dispose();}
async load(
manifest: ProjectManifest,
entryPath: string,
urdfMode: UrdfLoadMode = 'mjcf',
baseMode: UrdfBaseMode = 'floating',
enhancements: UrdfEnhancementOptions = {
addActuators: false,
addSensors: false,
sensorType: 'camera',
},
): Promise<SimulationSnapshot> {
this.releaseCurrent();
const module = await getMujocoModule();
const workspace = new MemfsWorkspace(module, manifest.id);
const prepared = prepareProjectForMujoco(manifest, entryPath);
this.supportFiles = prepared.manifest.files.filter(
(file) => !manifest.files.some((original) => original.path === file.path),
);
try {
console.info('[MuJoCo] 写入 MEMFS', prepared.manifest.files.length);
workspace.mount(prepared.manifest);
const entry = prepared.manifest.entries.find((candidate) => candidate.path === entryPath);
let modelPath = workspace.path(entryPath);
const warnings = [...prepared.warnings];
if (entry?.format === 'urdf' && urdfMode === 'mjcf') {
console.info('[MuJoCo] 编译 URDF 中间模型', entryPath);
const intermediate = new SimulationSession(module, modelPath);
try {
const minimumZ = intermediate.minimumGeometryZ();
const slash = entryPath.lastIndexOf('/');
const directory = slash >= 0 ? entryPath.slice(0, slash + 1) : '';
const convertedPath = `${directory}.__mujoco_converted_${manifest.id.replace(/[^a-zA-Z0-9_-]/g, '_')}.xml`;
if (module.mj_saveLastXML(workspace.path(convertedPath), intermediate.model) === 0)
throw new Error('MuJoCo 无法导出中间 MJCF');
const grounded = groundConvertedMjcf(
new TextEncoder().encode(workspace.readText(convertedPath)),
minimumZ,
baseMode,
);
const enhanced = enhanceConvertedMjcf(grounded, enhancements);
workspace.writeGenerated(convertedPath, enhanced.data);
modelPath = workspace.path(convertedPath);
warnings.push(
`URDF 已转换为 MJCF(${baseMode === 'floating' ? '浮动基座' : '固定基座'}),并整体平移 ${(-minimumZ).toFixed(4)} m,使最低点接触 z=0 地面`,
);
if (enhanced.actuatorCount)
warnings.push(
`已为 ${enhanced.actuatorCount} 个 hinge/slide 关节生成 motor 驱动器(控制输入不限幅;hinge 输出单位 N·m,slide 输出单位 N)`,
);
if (enhanced.unitreeGo2wTuned)
warnings.unshift(
'已识别 Unitree Go2-W,并补齐官方 MuJoCo 关节惯量/阻尼、力矩限幅和轮胎接触参数',
);
if (enhanced.imuAdded)
warnings.unshift(
'已在浮动基座添加6轴 IMU:imu_gyro(三轴角速度)和 imu_acc(三轴加速度)',
);
if (enhanced.cameraAdded)
warnings.push(
`已将 640×480 摄像头固连到 ${enhancements.cameraMountBody || '自动选择的头部/末端 body'},局部位置 ${(enhancements.cameraPosition ?? [0.1, 0, 0.05]).join(' ')} m,朝向 ${enhancements.cameraDirection ?? '+X'}`,
);
} finally {
intermediate.dispose();
}
}
console.info('[MuJoCo] 编译模型',modelPath);const session=new SimulationSession(module,modelPath,warnings);
if(entry?.format==='urdf'&&urdfMode==='native'){const offset=session.alignLowestPointToGround();warnings.push(`原生 URDF 已整体平移 ${offset.toFixed(4)} m,使最低点位于 z=0`);}
if(warnings.length)console.info('[MuJoCo] URDF 兼容处理',warnings);
console.info('[MuJoCo] 模型编译完成');this.workspace=workspace;this.session=session;const snapshot=session.snapshot();console.info('[MuJoCo] 状态快照完成');return snapshot;
}catch(error){workspace.dispose();throw new Error(`模型编译失败(${entryPath}):${error instanceof Error?error.message:String(error)}`,{cause:error});}
console.info('[MuJoCo] 编译模型', modelPath);
const session = new SimulationSession(module, modelPath, warnings);
if (entry?.format === 'urdf' && urdfMode === 'native') {
const offset = session.alignLowestPointToGround();
warnings.push(`原生 URDF 已整体平移 ${offset.toFixed(4)} m,使最低点位于 z=0`);
}
if (warnings.length) console.info('[MuJoCo] URDF 兼容处理', warnings);
console.info('[MuJoCo] 模型编译完成');
this.workspace = workspace;
this.session = session;
const snapshot = session.snapshot();
console.info('[MuJoCo] 状态快照完成');
return snapshot;
} catch (error) {
workspace.dispose();
throw new Error(
`模型编译失败(${entryPath}):${error instanceof Error ? error.message : String(error)}`,
{ cause: error },
);
}
}
advance(now:number):FrameResult{return this.session?.advance(now)??{steps:0,stepMs:0,overBudget:false};}
snapshot():SimulationSnapshot|null{return this.session?.snapshot()??null;}
setPaused(value:boolean):void{this.session?.setPaused(value);}
setSpeed(value:number):void{this.session?.setSpeed(value);}
reset():void{this.session?.reset();}
singleStep():void{this.session?.singleStep();}
setActuator(id:number,value:number):void{this.session?.setActuator(id,value);}
setActuatorParameters(id:number,parameters:ActuatorParameters):boolean{return this.session?.setActuatorParameters(id,parameters)??false;}
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();}
async loadPythonController(source:string,path:string):Promise<ControllerStatus>{if(!this.session)throw new Error('请先加载模型');return this.session.loadPythonController(source,path);}
setControllerEnabled(enabled:boolean):void{this.session?.setControllerEnabled(enabled);}
sendControllerCommand(command:ControllerCommand):void{this.session?.sendControllerCommand(command);}
removeController():void{this.session?.removeController();}
async loadRLPolicy(model:Uint8Array,path:string):Promise<RLPolicyStatus>{if(!this.session)throw new Error('请先加载模型');return this.session.loadRLPolicy(model,path);}
setRLPolicyEnabled(enabled:boolean):void{this.session?.setRLPolicyEnabled(enabled);}
setRLCommand(command:RLCommand):void{this.session?.setRLCommand(command);}
removeRLPolicy():void{this.session?.removeRLPolicy();}
cachedSupportFiles():ProjectFile[]{return this.supportFiles.map(file=>({...file,data:file.data.slice()}));}
exportMjcf():Uint8Array{
if(!this.session||!this.workspace)throw new Error('尚未加载可导出的模型');
const relative='.__platform_export__.xml';
if(this.session.module.mj_saveLastXML(this.workspace.path(relative),this.session.model)===0)throw new Error('MuJoCo 无法生成 MJCF');
const source=this.workspace.readText(relative),document=new DOMParser().parseFromString(source,'application/xml'),actuatorSection=document.querySelector('mujoco > actuator');
if(document.querySelector('parsererror'))return new TextEncoder().encode(source);
const snapshot=this.session.snapshot();
if(actuatorSection){for(const info of snapshot.actuators){const element=Array.from(actuatorSection.children).find(candidate=>candidate.getAttribute('name')===info.name);if(!element)continue;element.setAttribute('ctrllimited',info.ctrlLimited?'true':'false');element.setAttribute('forcelimited',info.forceLimited?'true':'false');}}
for(const info of snapshot.actuators){if(info.kind!=='motor'||!info.jointName)continue;const joint=Array.from(document.querySelectorAll('worldbody joint[name]')).find(candidate=>candidate.getAttribute('name')===info.jointName);if(joint){joint.setAttribute('stiffness',String(info.kp));joint.setAttribute('damping',String(info.kv));}}
const output=new TextEncoder().encode(new XMLSerializer().serializeToString(document));this.workspace.writeGenerated(relative,output);return output;
advance(now: number): FrameResult {
return this.session?.advance(now) ?? { steps: 0, stepMs: 0, overBudget: false };
}
snapshot(): SimulationSnapshot | null {
return this.session?.snapshot() ?? null;
}
setPaused(value: boolean): void {
this.session?.setPaused(value);
}
setSpeed(value: number): void {
this.session?.setSpeed(value);
}
reset(): void {
this.session?.reset();
}
singleStep(): void {
this.session?.singleStep();
}
setActuator(id: number, value: number): void {
this.session?.setActuator(id, value);
}
setActuatorParameters(id: number, parameters: ActuatorParameters): boolean {
return this.session?.setActuatorParameters(id, parameters) ?? false;
}
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();
}
async loadPythonController(source: string, path: string): Promise<ControllerStatus> {
if (!this.session) throw new Error('请先加载模型');
return this.session.loadPythonController(source, path);
}
setControllerEnabled(enabled: boolean): void {
this.session?.setControllerEnabled(enabled);
}
sendControllerCommand(command: ControllerCommand): void {
this.session?.sendControllerCommand(command);
}
removeController(): void {
this.session?.removeController();
}
async loadRLPolicy(model: Uint8Array, path: string): Promise<RLPolicyStatus> {
if (!this.session) throw new Error('请先加载模型');
return this.session.loadRLPolicy(model, path);
}
setRLPolicyEnabled(enabled: boolean): void {
this.session?.setRLPolicyEnabled(enabled);
}
setRLCommand(command: RLCommand): void {
this.session?.setRLCommand(command);
}
removeRLPolicy(): void {
this.session?.removeRLPolicy();
}
cachedSupportFiles(): ProjectFile[] {
return this.supportFiles.map((file) => ({ ...file, data: file.data.slice() }));
}
exportMjcf(): Uint8Array {
if (!this.session || !this.workspace) throw new Error('尚未加载可导出的模型');
const relative = '.__platform_export__.xml';
if (this.session.module.mj_saveLastXML(this.workspace.path(relative), this.session.model) === 0)
throw new Error('MuJoCo 无法生成 MJCF');
const source = this.workspace.readText(relative),
document = new DOMParser().parseFromString(source, 'application/xml'),
actuatorSection = document.querySelector('mujoco > actuator');
if (document.querySelector('parsererror')) return new TextEncoder().encode(source);
const snapshot = this.session.snapshot();
if (actuatorSection) {
for (const info of snapshot.actuators) {
const element = Array.from(actuatorSection.children).find(
(candidate) => candidate.getAttribute('name') === info.name,
);
if (!element) continue;
element.setAttribute('ctrllimited', info.ctrlLimited ? 'true' : 'false');
element.setAttribute('forcelimited', info.forceLimited ? 'true' : 'false');
}
}
for (const info of snapshot.actuators) {
if (info.kind !== 'motor' || !info.jointName) continue;
const joint = Array.from(document.querySelectorAll('worldbody joint[name]')).find(
(candidate) => candidate.getAttribute('name') === info.jointName,
);
if (joint) {
joint.setAttribute('stiffness', String(info.kp));
joint.setAttribute('damping', String(info.kv));
}
}
const output = new TextEncoder().encode(new XMLSerializer().serializeToString(document));
this.workspace.writeGenerated(relative, output);
return output;
}
private releaseCurrent(): void {
this.session?.dispose();
this.session = null;
this.workspace?.dispose();
this.workspace = null;
this.supportFiles = [];
}
dispose(): void {
this.releaseCurrent();
}
private releaseCurrent():void{this.session?.dispose(); this.session=null; this.workspace?.dispose(); this.workspace=null;this.supportFiles=[];}
dispose():void{this.releaseCurrent();}
}
+752 -157
View File
@@ -1,17 +1,84 @@
import type {MainModule, MjData, MjModel, MjvPerturb, MjvScene} from '@mujoco/mujoco';
import {meshIdFromSceneDataId} from './geometry';
import {PythonControllerRuntime} from '../controller/PythonControllerRuntime';
import type {ControllerBindings,ControllerCommand,ControllerStatus} from '../controller/types';
import {Go2wPolicyBindings} from '../rl/runtime/Go2wPolicyBindings';
import type {OnnxPolicyRuntime} from '../rl/runtime/OnnxPolicyRuntime';
import type {RLCommand,RLPolicyStatus} from '../rl/types';
import type { MainModule, MjData, MjModel, MjvPerturb, MjvScene } from '@mujoco/mujoco';
import { meshIdFromSceneDataId } from './geometry';
import { PythonControllerRuntime } from '../controller/PythonControllerRuntime';
import type { ControllerBindings, ControllerCommand, ControllerStatus } from '../controller/types';
import { Go2wPolicyBindings } from '../rl/runtime/Go2wPolicyBindings';
import type { OnnxPolicyRuntime } from '../rl/runtime/OnnxPolicyRuntime';
import type { RLCommand, RLPolicyStatus } from '../rl/types';
export interface ActuatorParameters {gear:number;gain:number;kp:number;kv:number;ctrlLimited:boolean;ctrlMin:number;ctrlMax:number;forceLimited:boolean;forceMin:number;forceMax:number;}
export interface ActuatorInfo extends ActuatorParameters {id:number;name:string;value:number;min:number;max:number;limited:boolean;jointId?:number;jointName?:string;jointType?:number;unit:string;kind:'motor'|'position'|'velocity'|'other';controlCount:number;}
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[]; controller?:ControllerStatus; rlPolicy?:RLPolicyStatus; model:{nbody:number;njnt:number;ngeom:number;ncam:number;nactuator:number;nu:number;nq:number;nv:number};}
export interface FrameResult {steps: number; stepMs: number; overBudget: boolean;}
export interface ActuatorParameters {
gear: number;
gain: number;
kp: number;
kv: number;
ctrlLimited: boolean;
ctrlMin: number;
ctrlMax: number;
forceLimited: boolean;
forceMin: number;
forceMax: number;
}
export interface ActuatorInfo extends ActuatorParameters {
id: number;
name: string;
value: number;
min: number;
max: number;
limited: boolean;
jointId?: number;
jointName?: string;
jointType?: number;
unit: string;
kind: 'motor' | 'position' | 'velocity' | 'other';
controlCount: number;
}
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[];
controller?: ControllerStatus;
rlPolicy?: RLPolicyStatus;
model: {
nbody: number;
njnt: number;
ngeom: number;
ncam: number;
nactuator: number;
nu: number;
nq: number;
nv: number;
};
}
export interface FrameResult {
steps: number;
stepMs: number;
overBudget: boolean;
}
export class SimulationSession {
readonly model: MjModel;
@@ -25,103 +92,298 @@ export class SimulationSession {
private lastNow?: number;
private forceBody = -1;
private force: [number, number, number] = [0, 0, 0];
private disposed=false;
private ignoreJointLimits=false;
private jointLimits:{limited:boolean;min:number;max:number;type:number}[]=[];
private pythonController?:PythonControllerRuntime;
private controllerLoadGeneration=0;
private rlPolicy?:OnnxPolicyRuntime;
private rlPolicyLoadGeneration=0;
private disposed = false;
private ignoreJointLimits = false;
private jointLimits: { limited: boolean; min: number; max: number; type: number }[] = [];
private pythonController?: PythonControllerRuntime;
private controllerLoadGeneration = 0;
private rlPolicy?: OnnxPolicyRuntime;
private rlPolicyLoadGeneration = 0;
constructor(readonly module: MainModule, modelPath: string, readonly warnings: string[] = []) {
let model: MjModel | undefined; let data: MjData | undefined; let perturb: MjvPerturb | undefined;
constructor(
readonly module: MainModule,
modelPath: string,
readonly warnings: string[] = [],
) {
let model: MjModel | undefined;
let data: MjData | undefined;
let perturb: MjvPerturb | undefined;
try {
model = module.MjModel.mj_loadXML(modelPath) ?? undefined;
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;
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; }
perturb = new module.MjvPerturb();
module.mjv_defaultPerturb(perturb);
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;
}
}
setPaused(paused: boolean): void {this.paused = paused; this.accumulator = 0; this.lastNow = undefined;}
setSpeed(speed: number): void {this.speed = Math.min(4, Math.max(0.1, speed));}
reset(): void {this.setPaused(true);this.module.mj_resetData(this.model,this.data);this.module.mj_forward(this.model,this.data);this.clearExternalForce();this.data.ctrl.fill(0);this.pythonController?.reset(Number(this.data.time));this.rlPolicy?.reset(Number(this.data.time));}
singleStep(): void {this.runController();this.applyForce();this.module.mj_step(this.model,this.data);}
setPaused(paused: boolean): void {
this.paused = paused;
this.accumulator = 0;
this.lastNow = undefined;
}
setSpeed(speed: number): void {
this.speed = Math.min(4, Math.max(0.1, speed));
}
reset(): void {
this.setPaused(true);
this.module.mj_resetData(this.model, this.data);
this.module.mj_forward(this.model, this.data);
this.clearExternalForce();
this.data.ctrl.fill(0);
this.pythonController?.reset(Number(this.data.time));
this.rlPolicy?.reset(Number(this.data.time));
}
singleStep(): void {
this.runController();
this.applyForce();
this.module.mj_step(this.model, this.data);
}
advance(now: number): FrameResult {
if (this.lastNow === undefined) {this.lastNow = now; return {steps: 0, stepMs: 0, overBudget: false};}
const elapsed = Math.min(0.1, Math.max(0, (now - this.lastNow) / 1000)); this.lastNow = now;
if (this.paused) return {steps: 0, stepMs: 0, overBudget: false};
if (this.lastNow === undefined) {
this.lastNow = now;
return { steps: 0, stepMs: 0, overBudget: false };
}
const elapsed = Math.min(0.1, Math.max(0, (now - this.lastNow) / 1000));
this.lastNow = now;
if (this.paused) return { steps: 0, stepMs: 0, overBudget: false };
this.accumulator += elapsed * this.speed;
const dt = Number(this.model.opt.timestep) || 0.002; const started = performance.now(); let steps = 0;
while (this.accumulator >= dt && steps < this.maxCatchUpSteps && performance.now() - started < this.frameBudgetMs) {
this.runController();this.applyForce();this.module.mj_step(this.model,this.data);this.accumulator-=dt;steps++;
const dt = Number(this.model.opt.timestep) || 0.002;
const started = performance.now();
let steps = 0;
while (
this.accumulator >= dt &&
steps < this.maxCatchUpSteps &&
performance.now() - started < this.frameBudgetMs
) {
this.runController();
this.applyForce();
this.module.mj_step(this.model, this.data);
this.accumulator -= dt;
steps++;
}
const overBudget = this.accumulator >= dt;
if (steps >= this.maxCatchUpSteps) this.accumulator = Math.min(this.accumulator, dt);
return {steps, stepMs: performance.now() - started, overBudget};
return { steps, stepMs: performance.now() - started, overBudget };
}
async loadPythonController(source:string,path:string):Promise<ControllerStatus>{
const generation=++this.controllerLoadGeneration;
const runtime=await PythonControllerRuntime.load(source,path,this.controllerBindings());
if(this.disposed||generation!==this.controllerLoadGeneration){runtime.dispose();throw new Error('模型已切换,控制器加载已取消');}
this.pythonController?.dispose();this.pythonController=runtime;
async loadPythonController(source: string, path: string): Promise<ControllerStatus> {
const generation = ++this.controllerLoadGeneration;
const runtime = await PythonControllerRuntime.load(source, path, this.controllerBindings());
if (this.disposed || generation !== this.controllerLoadGeneration) {
runtime.dispose();
throw new Error('模型已切换,控制器加载已取消');
}
this.pythonController?.dispose();
this.pythonController = runtime;
return runtime.status();
}
setControllerEnabled(enabled:boolean):void {
if(enabled&&this.pythonController){this.data.ctrl.fill(0);this.rlPolicy?.setEnabled(false,Number(this.data.time));}
this.pythonController?.setEnabled(enabled,Number(this.data.time));
if(!enabled)this.data.ctrl.fill(0);
setControllerEnabled(enabled: boolean): void {
if (enabled && this.pythonController) {
this.data.ctrl.fill(0);
this.rlPolicy?.setEnabled(false, Number(this.data.time));
}
this.pythonController?.setEnabled(enabled, Number(this.data.time));
if (!enabled) this.data.ctrl.fill(0);
}
async loadRLPolicy(model:Uint8Array,path:string):Promise<RLPolicyStatus>{
const generation=++this.rlPolicyLoadGeneration;
const bindings=new Go2wPolicyBindings(this.model,this.data,(id,value)=>this.setActuator(id,value));
const {OnnxPolicyRuntime:Runtime}=await import('../rl/runtime/OnnxPolicyRuntime');
const runtime=await Runtime.load(model,path,bindings);
if(this.disposed||generation!==this.rlPolicyLoadGeneration){runtime.dispose();throw new Error('模型已切换,ONNX 策略加载已取消');}
this.data.ctrl.fill(0);this.rlPolicy?.dispose();this.rlPolicy=runtime;
async loadRLPolicy(model: Uint8Array, path: string): Promise<RLPolicyStatus> {
const generation = ++this.rlPolicyLoadGeneration;
const bindings = new Go2wPolicyBindings(this.model, this.data, (id, value) =>
this.setActuator(id, value),
);
const { OnnxPolicyRuntime: Runtime } = await import('../rl/runtime/OnnxPolicyRuntime');
const runtime = await Runtime.load(model, path, bindings);
if (this.disposed || generation !== this.rlPolicyLoadGeneration) {
runtime.dispose();
throw new Error('模型已切换,ONNX 策略加载已取消');
}
this.data.ctrl.fill(0);
this.rlPolicy?.dispose();
this.rlPolicy = runtime;
return runtime.status();
}
setRLPolicyEnabled(enabled:boolean):void {
if(enabled&&this.rlPolicy){this.data.ctrl.fill(0);this.pythonController?.setEnabled(false,Number(this.data.time));}
this.rlPolicy?.setEnabled(enabled,Number(this.data.time));
if(!enabled)this.data.ctrl.fill(0);
setRLPolicyEnabled(enabled: boolean): void {
if (enabled && this.rlPolicy) {
this.data.ctrl.fill(0);
this.pythonController?.setEnabled(false, Number(this.data.time));
}
this.rlPolicy?.setEnabled(enabled, Number(this.data.time));
if (!enabled) this.data.ctrl.fill(0);
}
setRLCommand(command:RLCommand):void {this.rlPolicy?.setCommand(command);}
removeRLPolicy():void {this.rlPolicyLoadGeneration+=1;this.rlPolicy?.dispose();this.rlPolicy=undefined;this.data.ctrl.fill(0);}
sendControllerCommand(command:ControllerCommand):void {this.pythonController?.command(command);}
removeController():void {this.controllerLoadGeneration+=1;this.pythonController?.dispose();this.pythonController=undefined;this.data.ctrl.fill(0);}
private runController():void {
try{this.pythonController?.stepIfDue(Number(this.data.time));this.rlPolicy?.step(Number(this.data.time));}
catch(error){this.setPaused(true);this.data.ctrl.fill(0);throw error;}
setRLCommand(command: RLCommand): void {
this.rlPolicy?.setCommand(command);
}
removeRLPolicy(): void {
this.rlPolicyLoadGeneration += 1;
this.rlPolicy?.dispose();
this.rlPolicy = undefined;
this.data.ctrl.fill(0);
}
private controllerBindings():ControllerBindings {
const joints=new Map<string,number>(),actuators=new Map<string,number>(),sensors=new Map<string,number>(),bodies=new Map<string,number>();
for(let id=0;id<this.model.njnt;id+=1){const item=this.model.jnt(id);try{if(item.name)joints.set(item.name,id);}finally{item.delete();}}
for(let id=0;id<this.model.nactuator;id+=1){const item=this.model.actuator(id);try{if(item.name)actuators.set(item.name,id);}finally{item.delete();}}
for(let id=0;id<this.model.nsensor;id+=1){const item=this.model.sensor(id);try{if(item.name)sensors.set(item.name,id);}finally{item.delete();}}
for(let id=0;id<this.model.nbody;id+=1){const item=this.model.body(id);try{if(item.name)bodies.set(item.name,id);}finally{item.delete();}}
const resolve=(items:Map<string,number>,kind:string,name:string)=>{const id=items.get(name);if(id===undefined)throw new Error(`模型中找不到${kind}:${name}`);return id;};
return {model:{joint:name=>resolve(joints,'关节',name),actuator:name=>resolve(actuators,'驱动器',name),sensor:name=>resolve(sensors,'传感器',name),body:name=>resolve(bodies,'Body',name)},createStepApi:(time,dt)=>({time,dt,qpos:(jointId)=>{const joint=this.model.jnt(jointId);try{const type=Number(joint.type);if(type!==2&&type!==3)throw new Error(`关节 ${jointId} 不是标量 hinge/slide 关节`);return Number(this.data.qpos[Number(joint.qposadr)]);}finally{joint.delete();}},qvel:(jointId)=>{const joint=this.model.jnt(jointId);try{return Number(this.data.qvel[Number(joint.dofadr)]);}finally{joint.delete();}},sensor:(sensorId)=>{if(sensorId<0||sensorId>=this.model.nsensor)throw new Error(`传感器 ID 无效:${sensorId}`);const adr=Number(this.model.sensor_adr[sensorId]),dim=Number(this.model.sensor_dim[sensorId]);return Array.from(this.data.sensordata.subarray(adr,adr+dim),Number);},body_quat:(bodyId)=>{if(bodyId<0||bodyId>=this.model.nbody)throw new Error(`Body ID 无效:${bodyId}`);const adr=bodyId*4;return [Number(this.data.xquat[adr]),Number(this.data.xquat[adr+1]),Number(this.data.xquat[adr+2]),Number(this.data.xquat[adr+3])];},body_position:(bodyId)=>{if(bodyId<0||bodyId>=this.model.nbody)throw new Error(`Body ID 无效:${bodyId}`);const adr=bodyId*3;return [Number(this.data.xpos[adr]),Number(this.data.xpos[adr+1]),Number(this.data.xpos[adr+2])];},set_control:(actuatorId,value)=>{if(!Number.isFinite(value))throw new Error(`控制输出不是有限数:${value}`);this.setActuator(actuatorId,value);}})};
sendControllerCommand(command: ControllerCommand): void {
this.pythonController?.command(command);
}
removeController(): void {
this.controllerLoadGeneration += 1;
this.pythonController?.dispose();
this.pythonController = undefined;
this.data.ctrl.fill(0);
}
private runController(): void {
try {
this.pythonController?.stepIfDue(Number(this.data.time));
this.rlPolicy?.step(Number(this.data.time));
} catch (error) {
this.setPaused(true);
this.data.ctrl.fill(0);
throw error;
}
}
private controllerBindings(): ControllerBindings {
const joints = new Map<string, number>(),
actuators = new Map<string, number>(),
sensors = new Map<string, number>(),
bodies = new Map<string, number>();
for (let id = 0; id < this.model.njnt; id += 1) {
const item = this.model.jnt(id);
try {
if (item.name) joints.set(item.name, id);
} finally {
item.delete();
}
}
for (let id = 0; id < this.model.nactuator; id += 1) {
const item = this.model.actuator(id);
try {
if (item.name) actuators.set(item.name, id);
} finally {
item.delete();
}
}
for (let id = 0; id < this.model.nsensor; id += 1) {
const item = this.model.sensor(id);
try {
if (item.name) sensors.set(item.name, id);
} finally {
item.delete();
}
}
for (let id = 0; id < this.model.nbody; id += 1) {
const item = this.model.body(id);
try {
if (item.name) bodies.set(item.name, id);
} finally {
item.delete();
}
}
const resolve = (items: Map<string, number>, kind: string, name: string) => {
const id = items.get(name);
if (id === undefined) throw new Error(`模型中找不到${kind}:${name}`);
return id;
};
return {
model: {
joint: (name) => resolve(joints, '关节', name),
actuator: (name) => resolve(actuators, '驱动器', name),
sensor: (name) => resolve(sensors, '传感器', name),
body: (name) => resolve(bodies, 'Body', name),
},
createStepApi: (time, dt) => ({
time,
dt,
qpos: (jointId) => {
const joint = this.model.jnt(jointId);
try {
const type = Number(joint.type);
if (type !== 2 && type !== 3)
throw new Error(`关节 ${jointId} 不是标量 hinge/slide 关节`);
return Number(this.data.qpos[Number(joint.qposadr)]);
} finally {
joint.delete();
}
},
qvel: (jointId) => {
const joint = this.model.jnt(jointId);
try {
return Number(this.data.qvel[Number(joint.dofadr)]);
} finally {
joint.delete();
}
},
sensor: (sensorId) => {
if (sensorId < 0 || sensorId >= this.model.nsensor)
throw new Error(`传感器 ID 无效:${sensorId}`);
const adr = Number(this.model.sensor_adr[sensorId]),
dim = Number(this.model.sensor_dim[sensorId]);
return Array.from(this.data.sensordata.subarray(adr, adr + dim), Number);
},
body_quat: (bodyId) => {
if (bodyId < 0 || bodyId >= this.model.nbody) throw new Error(`Body ID 无效:${bodyId}`);
const adr = bodyId * 4;
return [
Number(this.data.xquat[adr]),
Number(this.data.xquat[adr + 1]),
Number(this.data.xquat[adr + 2]),
Number(this.data.xquat[adr + 3]),
];
},
body_position: (bodyId) => {
if (bodyId < 0 || bodyId >= this.model.nbody) throw new Error(`Body ID 无效:${bodyId}`);
const adr = bodyId * 3;
return [
Number(this.data.xpos[adr]),
Number(this.data.xpos[adr + 1]),
Number(this.data.xpos[adr + 2]),
];
},
set_control: (actuatorId, value) => {
if (!Number.isFinite(value)) throw new Error(`控制输出不是有限数:${value}`);
this.setActuator(actuatorId, value);
},
}),
};
}
setActuator(id: number, value: number): void {
if (id < 0 || id >= this.model.nactuator) return;
const actuator = this.model.actuator(id);
try {
const address=Number(this.model.actuator_ctrladr[id]??id),nextAddress=id+1<this.model.nactuator?Number(this.model.actuator_ctrladr[id+1]):this.model.nu;if(nextAddress-address!==1)return;
const address = Number(this.model.actuator_ctrladr[id] ?? id),
nextAddress =
id + 1 < this.model.nactuator
? Number(this.model.actuator_ctrladr[id + 1])
: this.model.nu;
if (nextAddress - address !== 1) return;
const limited = Boolean(actuator.ctrllimited);
const min = limited ? Number(actuator.ctrlrange[0]) : Number.NEGATIVE_INFINITY;
const max = limited ? Number(actuator.ctrlrange[1]) : Number.POSITIVE_INFINITY;
@@ -131,125 +393,458 @@ export class SimulationSession {
}
}
setActuatorParameters(id:number,parameters:ActuatorParameters):boolean {
if(id<0||id>=this.model.nactuator)return false;
const finite=(value:number,fallback:number)=>Number.isFinite(value)?value:fallback;
const ordered=(a:number,b:number,fallbackA:number,fallbackB:number):[number,number]=>{const first=finite(a,fallbackA),second=finite(b,fallbackB),lower=Math.min(first,second),upper=Math.max(first,second);return upper-lower>=1e-9?[lower,upper]:[lower,lower+1e-6];};
const actuator=this.model.actuator(id);
try{
const address=Number(this.model.actuator_ctrladr[id]??id),nextAddress=id+1<this.model.nactuator?Number(this.model.actuator_ctrladr[id+1]):this.model.nu;
const scalarJoint=nextAddress-address===1&&(Number(actuator.trntype)===0||Number(actuator.trntype)===1)&&Number(actuator.trnid[0])>=0,plainDynamics=Number(actuator.gaintype)===0&&Number(actuator.dyntype)===0;
const motorLike=scalarJoint&&plainDynamics&&Number(actuator.biastype)===0,positionLike=scalarJoint&&plainDynamics&&Number(actuator.biastype)===1&&Math.abs(Number(actuator.biasprm[1])+Number(actuator.gainprm[0]))<1e-6;if(!motorLike&&!positionLike)return false;
const [ctrlMin,ctrlMax]=ordered(parameters.ctrlMin,parameters.ctrlMax,-1,1),[forceMin,forceMax]=ordered(parameters.forceMin,parameters.forceMax,-100,100);
actuator.gear[0]=finite(parameters.gear,1);
if(positionLike){const kp=Math.max(0,finite(parameters.kp,100)),kv=Math.max(0,finite(parameters.kv,10));actuator.gainprm[0]=kp;actuator.biasprm[1]=-kp;actuator.biasprm[2]=-kv;}else{actuator.gainprm[0]=finite(parameters.gain,1);const jointId=Number(actuator.trnid[0]);if(jointId>=0&&jointId<this.model.njnt){const joint=this.model.jnt(jointId);try{joint.stiffness=Math.max(0,finite(parameters.kp,0));joint.damping=Math.max(0,finite(parameters.kv,0));}finally{joint.delete();}}}
actuator.ctrllimited=parameters.ctrlLimited?1:0;actuator.ctrlrange[0]=ctrlMin;actuator.ctrlrange[1]=ctrlMax;
actuator.forcelimited=parameters.forceLimited?1:0;actuator.forcerange[0]=forceMin;actuator.forcerange[1]=forceMax;
if(parameters.ctrlLimited)this.data.ctrl[address]=Math.min(ctrlMax,Math.max(ctrlMin,Number(this.data.ctrl[address])));
this.module.mj_forward(this.model,this.data);return true;
}finally{actuator.delete();}
setActuatorParameters(id: number, parameters: ActuatorParameters): boolean {
if (id < 0 || id >= this.model.nactuator) return false;
const finite = (value: number, fallback: number) => (Number.isFinite(value) ? value : fallback);
const ordered = (
a: number,
b: number,
fallbackA: number,
fallbackB: number,
): [number, number] => {
const first = finite(a, fallbackA),
second = finite(b, fallbackB),
lower = Math.min(first, second),
upper = Math.max(first, second);
return upper - lower >= 1e-9 ? [lower, upper] : [lower, lower + 1e-6];
};
const actuator = this.model.actuator(id);
try {
const address = Number(this.model.actuator_ctrladr[id] ?? id),
nextAddress =
id + 1 < this.model.nactuator
? Number(this.model.actuator_ctrladr[id + 1])
: this.model.nu;
const scalarJoint =
nextAddress - address === 1 &&
(Number(actuator.trntype) === 0 || Number(actuator.trntype) === 1) &&
Number(actuator.trnid[0]) >= 0,
plainDynamics = Number(actuator.gaintype) === 0 && Number(actuator.dyntype) === 0;
const motorLike = scalarJoint && plainDynamics && Number(actuator.biastype) === 0,
positionLike =
scalarJoint &&
plainDynamics &&
Number(actuator.biastype) === 1 &&
Math.abs(Number(actuator.biasprm[1]) + Number(actuator.gainprm[0])) < 1e-6;
if (!motorLike && !positionLike) return false;
const [ctrlMin, ctrlMax] = ordered(parameters.ctrlMin, parameters.ctrlMax, -1, 1),
[forceMin, forceMax] = ordered(parameters.forceMin, parameters.forceMax, -100, 100);
actuator.gear[0] = finite(parameters.gear, 1);
if (positionLike) {
const kp = Math.max(0, finite(parameters.kp, 100)),
kv = Math.max(0, finite(parameters.kv, 10));
actuator.gainprm[0] = kp;
actuator.biasprm[1] = -kp;
actuator.biasprm[2] = -kv;
} else {
actuator.gainprm[0] = finite(parameters.gain, 1);
const jointId = Number(actuator.trnid[0]);
if (jointId >= 0 && jointId < this.model.njnt) {
const joint = this.model.jnt(jointId);
try {
joint.stiffness = Math.max(0, finite(parameters.kp, 0));
joint.damping = Math.max(0, finite(parameters.kv, 0));
} finally {
joint.delete();
}
}
}
actuator.ctrllimited = parameters.ctrlLimited ? 1 : 0;
actuator.ctrlrange[0] = ctrlMin;
actuator.ctrlrange[1] = ctrlMax;
actuator.forcelimited = parameters.forceLimited ? 1 : 0;
actuator.forcerange[0] = forceMin;
actuator.forcerange[1] = forceMax;
if (parameters.ctrlLimited)
this.data.ctrl[address] = Math.min(
ctrlMax,
Math.max(ctrlMin, Number(this.data.ctrl[address])),
);
this.module.mj_forward(this.model, this.data);
return true;
} finally {
actuator.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();}
setJointPosition(id: number, value: number): boolean {
if (id < 0 || id >= this.model.njnt) return false;
const joint = this.model.jnt(id);
try {
const type = Number(joint.type);
if (type !== 2 && type !== 3) return false;
const original = this.jointLimits[id];
const next =
!this.ignoreJointLimits && original.limited
? Math.min(original.max, Math.max(original.min, value))
: value;
this.setPaused(true);
this.data.qpos[Number(joint.qposadr)] = next;
this.module.mj_forward(this.model, this.data);
return true;
} finally {
joint.delete();
}
}
resetJoints():void {this.setPaused(true);for(let id=0;id<this.model.njnt;id+=1){const joint=this.model.jnt(id);try{const type=Number(joint.type);if(type!==2&&type!==3)continue;this.data.qpos[Number(joint.qposadr)]=Number(joint.qpos0);this.data.qvel[Number(joint.dofadr)]=0;}finally{joint.delete();}}this.module.mj_forward(this.model,this.data);}
resetJoints(): void {
this.setPaused(true);
for (let id = 0; id < this.model.njnt; id += 1) {
const joint = this.model.jnt(id);
try {
const type = Number(joint.type);
if (type !== 2 && type !== 3) continue;
this.data.qpos[Number(joint.qposadr)] = Number(joint.qpos0);
this.data.qvel[Number(joint.dofadr)] = 0;
} finally {
joint.delete();
}
}
this.module.mj_forward(this.model, this.data);
}
setIgnoreJointLimits(ignore:boolean):void {this.ignoreJointLimits=ignore;for(let id=0;id<this.model.njnt;id+=1){const joint=this.model.jnt(id);try{const original=this.jointLimits[id];if(!original||!original.limited)continue;joint.limited=ignore?0:1;if(!ignore&&(original.type===2||original.type===3)){const address=Number(joint.qposadr);this.data.qpos[address]=Math.min(original.max,Math.max(original.min,Number(this.data.qpos[address])));}}finally{joint.delete();}}this.module.mj_forward(this.model,this.data);}
setIgnoreJointLimits(ignore: boolean): void {
this.ignoreJointLimits = ignore;
for (let id = 0; id < this.model.njnt; id += 1) {
const joint = this.model.jnt(id);
try {
const original = this.jointLimits[id];
if (!original || !original.limited) continue;
joint.limited = ignore ? 0 : 1;
if (!ignore && (original.type === 2 || original.type === 3)) {
const address = Number(joint.qposadr);
this.data.qpos[address] = Math.min(
original.max,
Math.max(original.min, Number(this.data.qpos[address])),
);
}
} finally {
joint.delete();
}
}
this.module.mj_forward(this.model, this.data);
}
setExternalForce(bodyId: number, force: [number, number, number]): void {this.forceBody = bodyId > 0 && bodyId < this.model.nbody ? bodyId : -1; this.force = force;}
clearExternalForce(): void {this.forceBody = -1; this.force = [0, 0, 0]; this.data.xfrc_applied.fill(0); this.perturb.active = 0;}
initializePerturb(scene: MjvScene, bodyId: number): void {this.perturb.select = bodyId; this.module.mjv_initPerturb(this.model, this.data, scene, this.perturb);}
applyPerturbForce(): void {if (this.forceBody > 0) this.module.mjv_applyPerturbForce(this.model, this.data, this.perturb);}
setExternalForce(bodyId: number, force: [number, number, number]): void {
this.forceBody = bodyId > 0 && bodyId < this.model.nbody ? bodyId : -1;
this.force = force;
}
clearExternalForce(): void {
this.forceBody = -1;
this.force = [0, 0, 0];
this.data.xfrc_applied.fill(0);
this.perturb.active = 0;
}
initializePerturb(scene: MjvScene, bodyId: number): void {
this.perturb.select = bodyId;
this.module.mjv_initPerturb(this.model, this.data, scene, this.perturb);
}
applyPerturbForce(): void {
if (this.forceBody > 0) this.module.mjv_applyPerturbForce(this.model, this.data, this.perturb);
}
private applyForce(): void {
this.data.xfrc_applied.fill(0); if (this.forceBody < 1) return;
this.applyPerturbForce(); const offset = this.forceBody * 6;
this.data.xfrc_applied[offset] += this.force[0]; this.data.xfrc_applied[offset + 1] += this.force[1]; this.data.xfrc_applied[offset + 2] += this.force[2];
this.data.xfrc_applied.fill(0);
if (this.forceBody < 1) return;
this.applyPerturbForce();
const offset = this.forceBody * 6;
this.data.xfrc_applied[offset] += this.force[0];
this.data.xfrc_applied[offset + 1] += this.force[1];
this.data.xfrc_applied[offset + 2] += this.force[2];
}
/** 用有限几何的包围球估算视图中心与范围,忽略地面等无限平面。 */
geometryBounds():{center:[number,number,number];extent:number} {
const lower=[Number.POSITIVE_INFINITY,Number.POSITIVE_INFINITY,Number.POSITIVE_INFINITY];const upper=[Number.NEGATIVE_INFINITY,Number.NEGATIVE_INFINITY,Number.NEGATIVE_INFINITY];
for(let geom=0;geom<this.model.ngeom;geom+=1){if(Number(this.model.geom_type[geom])===this.module.mjtGeom.mjGEOM_PLANE.value)continue;const radius=Math.max(0,Number(this.model.geom_rbound[geom]));for(let axis=0;axis<3;axis+=1){const value=Number(this.data.geom_xpos[geom*3+axis]);lower[axis]=Math.min(lower[axis],value-radius);upper[axis]=Math.max(upper[axis],value+radius);}}
if(!lower.every(Number.isFinite)||!upper.every(Number.isFinite))return {center:[0,0,0],extent:2};
return {center:[(lower[0]+upper[0])/2,(lower[1]+upper[1])/2,(lower[2]+upper[2])/2],extent:Math.max(.5,upper[0]-lower[0],upper[1]-lower[1],upper[2]-lower[2])};
geometryBounds(): { center: [number, number, number]; extent: number } {
const lower = [Number.POSITIVE_INFINITY, Number.POSITIVE_INFINITY, Number.POSITIVE_INFINITY];
const upper = [Number.NEGATIVE_INFINITY, Number.NEGATIVE_INFINITY, Number.NEGATIVE_INFINITY];
for (let geom = 0; geom < this.model.ngeom; geom += 1) {
if (Number(this.model.geom_type[geom]) === this.module.mjtGeom.mjGEOM_PLANE.value) continue;
const radius = Math.max(0, Number(this.model.geom_rbound[geom]));
for (let axis = 0; axis < 3; axis += 1) {
const value = Number(this.data.geom_xpos[geom * 3 + axis]);
lower[axis] = Math.min(lower[axis], value - radius);
upper[axis] = Math.max(upper[axis], value + radius);
}
}
if (!lower.every(Number.isFinite) || !upper.every(Number.isFinite))
return { center: [0, 0, 0], extent: 2 };
return {
center: [(lower[0] + upper[0]) / 2, (lower[1] + upper[1]) / 2, (lower[2] + upper[2]) / 2],
extent: Math.max(0.5, upper[0] - lower[0], upper[1] - lower[1], upper[2] - lower[2]),
};
}
/** 返回当前姿态全部模型几何(不含无限平面)的最低世界坐标。 */
minimumGeometryZ():number {
const option=new this.module.MjvOption();const camera=new this.module.MjvCamera();const scene=new this.module.MjvScene(this.model,32768);
this.module.mjv_defaultOption(option);this.module.mjv_defaultCamera(camera);
let minimum=Number.POSITIVE_INFINITY;
try{
this.module.mjv_updateScene(this.model,this.data,option,this.perturb,camera,this.module.mjtCatBit.mjCAT_ALL.value,scene);
const geoms=scene.geoms;
try{for(let index=0;index<geoms.size();index+=1){const geom=geoms.get(index);if(!geom)continue;try{
if(geom.type===this.module.mjtGeom.mjGEOM_PLANE.value)continue;
const z=this.geomMinimumZ(geom);if(Number.isFinite(z))minimum=Math.min(minimum,z);
}finally{geom.delete();}}}finally{geoms.delete();}
}finally{scene.delete();camera.delete();option.delete();}
return Number.isFinite(minimum)?minimum:0;
minimumGeometryZ(): number {
const option = new this.module.MjvOption();
const camera = new this.module.MjvCamera();
const scene = new this.module.MjvScene(this.model, 32768);
this.module.mjv_defaultOption(option);
this.module.mjv_defaultCamera(camera);
let minimum = Number.POSITIVE_INFINITY;
try {
this.module.mjv_updateScene(
this.model,
this.data,
option,
this.perturb,
camera,
this.module.mjtCatBit.mjCAT_ALL.value,
scene,
);
const geoms = scene.geoms;
try {
for (let index = 0; index < geoms.size(); index += 1) {
const geom = geoms.get(index);
if (!geom) continue;
try {
if (geom.type === this.module.mjtGeom.mjGEOM_PLANE.value) continue;
const z = this.geomMinimumZ(geom);
if (Number.isFinite(z)) minimum = Math.min(minimum, z);
} finally {
geom.delete();
}
}
} finally {
geoms.delete();
}
} finally {
scene.delete();
camera.delete();
option.delete();
}
return Number.isFinite(minimum) ? minimum : 0;
}
/** 平移所有世界根 body,使当前姿态的最低点位于 z=0。 */
alignLowestPointToGround():number {
const offset=-this.minimumGeometryZ();
if(Math.abs(offset)<1e-9)return 0;
for(let body=1;body<this.model.nbody;body+=1)if(Number(this.model.body_parentid[body])===0)this.model.body_pos[body*3+2]+=offset;
this.module.mj_forward(this.model,this.data);
alignLowestPointToGround(): number {
const offset = -this.minimumGeometryZ();
if (Math.abs(offset) < 1e-9) return 0;
for (let body = 1; body < this.model.nbody; body += 1)
if (Number(this.model.body_parentid[body]) === 0) this.model.body_pos[body * 3 + 2] += offset;
this.module.mj_forward(this.model, this.data);
return offset;
}
private geomMinimumZ(geom:import('@mujoco/mujoco').MjvGeom):number {
const m=this.module,type=geom.type,s=geom.size,r0=geom.mat[6],r1=geom.mat[7],r2=geom.mat[8],center=geom.pos[2];
if(type===m.mjtGeom.mjGEOM_SPHERE.value)return center-s[0];
if(type===m.mjtGeom.mjGEOM_BOX.value)return center-(Math.abs(r0)*s[0]+Math.abs(r1)*s[1]+Math.abs(r2)*s[2]);
if(type===m.mjtGeom.mjGEOM_ELLIPSOID.value)return center-Math.hypot(r0*s[0],r1*s[1],r2*s[2]);
if(type===m.mjtGeom.mjGEOM_CYLINDER.value)return center-(Math.hypot(r0,r1)*s[0]+Math.abs(r2)*s[2]);
if(type===m.mjtGeom.mjGEOM_CAPSULE.value)return center-(s[0]+Math.abs(r2)*s[2]);
if(type===m.mjtGeom.mjGEOM_MESH.value&&geom.dataid>=0){
const id=meshIdFromSceneDataId(geom.dataid),first=Number(this.model.mesh_vertadr[id]),count=Number(this.model.mesh_vertnum[id]);let minimum=Number.POSITIVE_INFINITY;
for(let vertex=0;vertex<count;vertex+=1){const offset=(first+vertex)*3;minimum=Math.min(minimum,center+r0*this.model.mesh_vert[offset]+r1*this.model.mesh_vert[offset+1]+r2*this.model.mesh_vert[offset+2]);}
private geomMinimumZ(geom: import('@mujoco/mujoco').MjvGeom): number {
const m = this.module,
type = geom.type,
s = geom.size,
r0 = geom.mat[6],
r1 = geom.mat[7],
r2 = geom.mat[8],
center = geom.pos[2];
if (type === m.mjtGeom.mjGEOM_SPHERE.value) return center - s[0];
if (type === m.mjtGeom.mjGEOM_BOX.value)
return center - (Math.abs(r0) * s[0] + Math.abs(r1) * s[1] + Math.abs(r2) * s[2]);
if (type === m.mjtGeom.mjGEOM_ELLIPSOID.value)
return center - Math.hypot(r0 * s[0], r1 * s[1], r2 * s[2]);
if (type === m.mjtGeom.mjGEOM_CYLINDER.value)
return center - (Math.hypot(r0, r1) * s[0] + Math.abs(r2) * s[2]);
if (type === m.mjtGeom.mjGEOM_CAPSULE.value) return center - (s[0] + Math.abs(r2) * s[2]);
if (type === m.mjtGeom.mjGEOM_MESH.value && geom.dataid >= 0) {
const id = meshIdFromSceneDataId(geom.dataid),
first = Number(this.model.mesh_vertadr[id]),
count = Number(this.model.mesh_vertnum[id]);
let minimum = Number.POSITIVE_INFINITY;
for (let vertex = 0; vertex < count; vertex += 1) {
const offset = (first + vertex) * 3;
minimum = Math.min(
minimum,
center +
r0 * this.model.mesh_vert[offset] +
r1 * this.model.mesh_vert[offset + 1] +
r2 * this.model.mesh_vert[offset + 2],
);
}
return minimum;
}
const radius=geom.size[0]||0;return center-radius;
const radius = geom.size[0] || 0;
return center - radius;
}
snapshot(): SimulationSnapshot {
const actuators = Array.from({length: this.model.nactuator}, (_, id): ActuatorInfo => {
const actuators = Array.from({ length: this.model.nactuator }, (_, id): ActuatorInfo => {
const actuator = this.model.actuator(id);
try {
const limited = Boolean(actuator.ctrllimited);
const address=Number(this.model.actuator_ctrladr[id]??id),nextAddress=id+1<this.model.nactuator?Number(this.model.actuator_ctrladr[id+1]):this.model.nu,controlCount=Math.max(1,nextAddress-address);
const jointId=(Number(actuator.trntype)===0||Number(actuator.trntype)===1)&&Number(actuator.trnid[0])>=0?Number(actuator.trnid[0]):undefined;
let jointName:string|undefined,jointType:number|undefined,jointStiffness=0,jointDamping=0;if(jointId!==undefined&&jointId<this.model.njnt){const joint=this.model.jnt(jointId);try{jointName=joint.name||`joint_${jointId}`;jointType=Number(joint.type);jointStiffness=Number(joint.stiffness);jointDamping=Number(joint.damping);}finally{joint.delete();}}
const ctrlMin=Number(actuator.ctrlrange[0]),ctrlMax=Number(actuator.ctrlrange[1]),forceLimited=Boolean(actuator.forcelimited);
const scalarJoint=controlCount===1&&jointId!==undefined,plainDynamics=Number(actuator.gaintype)===0&&Number(actuator.dyntype)===0,gain=Number(actuator.gainprm[0]),bias1=Number(actuator.biasprm[1]),bias2=Number(actuator.biasprm[2]);
const kind:ActuatorInfo['kind']=scalarJoint&&plainDynamics&&Number(actuator.biastype)===0?'motor':scalarJoint&&plainDynamics&&Number(actuator.biastype)===1&&Math.abs(bias1+gain)<1e-6?'position':scalarJoint&&plainDynamics&&Number(actuator.biastype)===1&&Math.abs(bias1)<1e-9&&Math.abs(bias2+gain)<1e-6?'velocity':'other';
return {id,name:actuator.name||`actuator_${id}`,value:Number(this.data.ctrl[address]),min:limited?ctrlMin:-100,max:limited?ctrlMax:100,limited,jointId,jointName,jointType,unit:kind==='motor'?(jointType===3?'N·m':jointType===2?'N':''):kind==='position'?(jointType===3?'°':jointType===2?'m':''):'',kind,controlCount,gear:Number(actuator.gear[0]),gain,kp:kind==='position'?gain:kind==='motor'?jointStiffness:0,kv:kind==='position'?Math.max(0,-bias2):kind==='velocity'?gain:kind==='motor'?jointDamping:0,ctrlLimited:limited,ctrlMin,ctrlMax,forceLimited,forceMin:Number(actuator.forcerange[0]),forceMax:Number(actuator.forcerange[1])};
const address = Number(this.model.actuator_ctrladr[id] ?? id),
nextAddress =
id + 1 < this.model.nactuator
? Number(this.model.actuator_ctrladr[id + 1])
: this.model.nu,
controlCount = Math.max(1, nextAddress - address);
const jointId =
(Number(actuator.trntype) === 0 || Number(actuator.trntype) === 1) &&
Number(actuator.trnid[0]) >= 0
? Number(actuator.trnid[0])
: undefined;
let jointName: string | undefined,
jointType: number | undefined,
jointStiffness = 0,
jointDamping = 0;
if (jointId !== undefined && jointId < this.model.njnt) {
const joint = this.model.jnt(jointId);
try {
jointName = joint.name || `joint_${jointId}`;
jointType = Number(joint.type);
jointStiffness = Number(joint.stiffness);
jointDamping = Number(joint.damping);
} finally {
joint.delete();
}
}
const ctrlMin = Number(actuator.ctrlrange[0]),
ctrlMax = Number(actuator.ctrlrange[1]),
forceLimited = Boolean(actuator.forcelimited);
const scalarJoint = controlCount === 1 && jointId !== undefined,
plainDynamics = Number(actuator.gaintype) === 0 && Number(actuator.dyntype) === 0,
gain = Number(actuator.gainprm[0]),
bias1 = Number(actuator.biasprm[1]),
bias2 = Number(actuator.biasprm[2]);
const kind: ActuatorInfo['kind'] =
scalarJoint && plainDynamics && Number(actuator.biastype) === 0
? 'motor'
: scalarJoint &&
plainDynamics &&
Number(actuator.biastype) === 1 &&
Math.abs(bias1 + gain) < 1e-6
? 'position'
: scalarJoint &&
plainDynamics &&
Number(actuator.biastype) === 1 &&
Math.abs(bias1) < 1e-9 &&
Math.abs(bias2 + gain) < 1e-6
? 'velocity'
: 'other';
return {
id,
name: actuator.name || `actuator_${id}`,
value: Number(this.data.ctrl[address]),
min: limited ? ctrlMin : -100,
max: limited ? ctrlMax : 100,
limited,
jointId,
jointName,
jointType,
unit:
kind === 'motor'
? jointType === 3
? 'N·m'
: jointType === 2
? 'N'
: ''
: kind === 'position'
? jointType === 3
? '°'
: jointType === 2
? 'm'
: ''
: '',
kind,
controlCount,
gear: Number(actuator.gear[0]),
gain,
kp: kind === 'position' ? gain : kind === 'motor' ? jointStiffness : 0,
kv:
kind === 'position'
? Math.max(0, -bias2)
: kind === 'velocity'
? gain
: kind === 'motor'
? jointDamping
: 0,
ctrlLimited: limited,
ctrlMin,
ctrlMax,
forceLimited,
forceMin: Number(actuator.forcerange[0]),
forceMax: Number(actuator.forcerange[1]),
};
} finally {
actuator.delete();
}
});
const joints = Array.from({length: this.model.njnt}, (_, id): JointInfo => {
const joints = Array.from({ length: this.model.njnt }, (_, id): JointInfo => {
const joint = this.model.jnt(id);
try {
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])]};
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(0.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 bodies = Array.from({ length: this.model.nbody }, (_, id): BodyInfo => {
const body = this.model.body(id);
try {return {id,name:body.name||`body_${id}`,parentId:Number(this.model.body_parentid[id])};}
finally { body.delete(); }
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,controller:this.pythonController?.status(),rlPolicy:this.rlPolicy?.status(),model:{nbody:this.model.nbody,njnt:this.model.njnt,ngeom:this.model.ngeom,ncam:this.model.ncam,nactuator:this.model.nactuator,nu:this.model.nu,nq:this.model.nq,nv:this.model.nv}};
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,
controller: this.pythonController?.status(),
rlPolicy: this.rlPolicy?.status(),
model: {
nbody: this.model.nbody,
njnt: this.model.njnt,
ngeom: this.model.ngeom,
ncam: this.model.ncam,
nactuator: this.model.nactuator,
nu: this.model.nu,
nq: this.model.nq,
nv: this.model.nv,
},
};
}
dispose(): void {
if (this.disposed) return;
this.disposed = true;
this.removeController();
this.removeRLPolicy();
this.clearExternalForce();
this.perturb.delete();
this.data.delete();
this.model.delete();
}
dispose(): void {if(this.disposed)return;this.disposed=true;this.removeController();this.removeRLPolicy();this.clearExternalForce();this.perturb.delete();this.data.delete();this.model.delete();}
}
+3 -3
View File
@@ -1,7 +1,7 @@
import {meshIdFromSceneDataId} from './geometry';
import { meshIdFromSceneDataId } from './geometry';
describe('meshIdFromSceneDataId',()=>{
it('解析 mjvGeom 的完整 mesh/凸包编码',()=>{
describe('meshIdFromSceneDataId', () => {
it('解析 mjvGeom 的完整 mesh/凸包编码', () => {
expect(meshIdFromSceneDataId(0)).toBe(0);
expect(meshIdFromSceneDataId(1)).toBe(0);
expect(meshIdFromSceneDataId(2)).toBe(1);
+2 -2
View File
@@ -2,6 +2,6 @@
* mjvGeom.dataid 对 mesh 编码为 2 * meshId;最低位表示是否显示凸包。
* 它不能直接作为 mjModel.mesh_* 数组的索引。
*/
export function meshIdFromSceneDataId(dataId:number):number {
return dataId<0?-1:Math.floor(dataId/2);
export function meshIdFromSceneDataId(dataId: number): number {
return dataId < 0 ? -1 : Math.floor(dataId / 2);
}
+24 -19
View File
@@ -1,29 +1,34 @@
import {useAppStore} from './useAppStore';
import { useAppStore } from './useAppStore';
describe('useAppStore.clearProject',()=>{
afterEach(()=>useAppStore.getState().clearProject());
describe('useAppStore.clearProject', () => {
afterEach(() => useAppStore.getState().clearProject());
it('清空已导入工程及其运行状态',()=>{
const store=useAppStore.getState();
store.setProject('robot',[{path:'robot/model.xml',size:128}],[{path:'robot/model.xml',format:'mjcf',label:'model'}],'robot/model.xml');
it('清空已导入工程及其运行状态', () => {
const store = useAppStore.getState();
store.setProject(
'robot',
[{ path: 'robot/model.xml', size: 128 }],
[{ path: 'robot/model.xml', format: 'mjcf', label: 'model' }],
'robot/model.xml',
);
store.setLoading(true);
store.setDiagnostic({category:'导入',summary:'错误',detail:'detail',at:1});
store.setDiagnostic({ category: '导入', summary: '错误', detail: 'detail', at: 1 });
useAppStore.getState().clearProject();
expect(useAppStore.getState()).toMatchObject({
projectName:undefined,
files:[],
entries:[],
selectedEntry:undefined,
loading:false,
diagnostic:undefined,
snapshot:undefined,
selection:null,
paused:true,
fps:0,
stepMs:0,
overBudget:false,
projectName: undefined,
files: [],
entries: [],
selectedEntry: undefined,
loading: false,
diagnostic: undefined,
snapshot: undefined,
selection: null,
paused: true,
fps: 0,
stepMs: 0,
overBudget: false,
});
});
});
+89 -22
View File
@@ -1,25 +1,92 @@
import {create} from 'zustand';
import type {ModelEntry} from '../project/types';
import type {SimulationSnapshot} from '../simulation/SimulationSession';
import type {InteractionMode, ViewerSelection} from '../viewer/MuJoCoViewer';
import { create } from 'zustand';
import type { ModelEntry } from '../project/types';
import type { SimulationSnapshot } from '../simulation/SimulationSession';
import type { InteractionMode, ViewerSelection } from '../viewer/MuJoCoViewer';
export interface AppDiagnostic {category:'导入'|'ZIP'|'文件系统'|'模型编译'|'仿真'|'渲染';summary:string;detail:string;path?:string;at:number;}
interface FileMeta {path:string;size:number;}
interface AppState {
projectName?:string; files:FileMeta[]; entries:ModelEntry[]; selectedEntry?:string;
loading:boolean; diagnostic?:AppDiagnostic; snapshot?:SimulationSnapshot; selection:ViewerSelection|null;
paused:boolean; speed:number; mode:InteractionMode; fps:number; stepMs:number; memoryMb?:number; overBudget:boolean;
setProject(name:string,files:FileMeta[],entries:ModelEntry[],selectedEntry?:string):void;
clearProject():void;
setEntry(path:string):void; setLoading(value:boolean):void; setDiagnostic(value?:AppDiagnostic):void;
setSnapshot(value?:SimulationSnapshot):void; setSelection(value:ViewerSelection|null):void;
setPaused(value:boolean):void; setSpeed(value:number):void; setMode(value:InteractionMode):void;
setMetrics(fps:number,stepMs:number,memoryMb:number|undefined,overBudget:boolean):void;
export interface AppDiagnostic {
category: '导入' | 'ZIP' | '文件系统' | '模型编译' | '仿真' | '渲染';
summary: string;
detail: string;
path?: string;
at: number;
}
export const useAppStore=create<AppState>((set)=>({
files:[],entries:[],loading:false,selection:null,paused:true,speed:1,mode:'select',fps:0,stepMs:0,overBudget:false,
setProject:(projectName,files,entries,selectedEntry)=>set({projectName,files,entries,selectedEntry,snapshot:undefined,selection:null,diagnostic:undefined}),
clearProject:()=>set({projectName:undefined,files:[],entries:[],selectedEntry:undefined,loading:false,diagnostic:undefined,snapshot:undefined,selection:null,paused:true,fps:0,stepMs:0,memoryMb:undefined,overBudget:false}),
setEntry:(selectedEntry)=>set({selectedEntry}),setLoading:(loading)=>set({loading}),setDiagnostic:(diagnostic)=>set({diagnostic}),setSnapshot:(snapshot)=>set({snapshot}),setSelection:(selection)=>set({selection}),
setPaused:(paused)=>set({paused}),setSpeed:(speed)=>set({speed}),setMode:(mode)=>set({mode}),setMetrics:(fps,stepMs,memoryMb,overBudget)=>set((s)=>({fps:fps||s.fps,stepMs,memoryMb:memoryMb??s.memoryMb,overBudget}))
interface FileMeta {
path: string;
size: number;
}
interface AppState {
projectName?: string;
files: FileMeta[];
entries: ModelEntry[];
selectedEntry?: string;
loading: boolean;
diagnostic?: AppDiagnostic;
snapshot?: SimulationSnapshot;
selection: ViewerSelection | null;
paused: boolean;
speed: number;
mode: InteractionMode;
fps: number;
stepMs: number;
memoryMb?: number;
overBudget: boolean;
setProject(name: string, files: FileMeta[], entries: ModelEntry[], selectedEntry?: string): void;
clearProject(): void;
setEntry(path: string): void;
setLoading(value: boolean): void;
setDiagnostic(value?: AppDiagnostic): void;
setSnapshot(value?: SimulationSnapshot): void;
setSelection(value: ViewerSelection | null): void;
setPaused(value: boolean): void;
setSpeed(value: number): void;
setMode(value: InteractionMode): void;
setMetrics(fps: number, stepMs: number, memoryMb: number | undefined, overBudget: boolean): void;
}
export const useAppStore = create<AppState>((set) => ({
files: [],
entries: [],
loading: false,
selection: null,
paused: true,
speed: 1,
mode: 'select',
fps: 0,
stepMs: 0,
overBudget: false,
setProject: (projectName, files, entries, selectedEntry) =>
set({
projectName,
files,
entries,
selectedEntry,
snapshot: undefined,
selection: null,
diagnostic: undefined,
}),
clearProject: () =>
set({
projectName: undefined,
files: [],
entries: [],
selectedEntry: undefined,
loading: false,
diagnostic: undefined,
snapshot: undefined,
selection: null,
paused: true,
fps: 0,
stepMs: 0,
memoryMb: undefined,
overBudget: false,
}),
setEntry: (selectedEntry) => set({ selectedEntry }),
setLoading: (loading) => set({ loading }),
setDiagnostic: (diagnostic) => set({ diagnostic }),
setSnapshot: (snapshot) => set({ snapshot }),
setSelection: (selection) => set({ selection }),
setPaused: (paused) => set({ paused }),
setSpeed: (speed) => set({ speed }),
setMode: (mode) => set({ mode }),
setMetrics: (fps, stepMs, memoryMb, overBudget) =>
set((s) => ({ fps: fps || s.fps, stepMs, memoryMb: memoryMb ?? s.memoryMb, overBudget })),
}));
+122 -29
View File
@@ -2,36 +2,129 @@
@tailwind components;
@tailwind utilities;
:root{
--ui-bg:#eef2f7;--ui-panel:#fbfcfe;--ui-surface:#f7f9fc;--ui-surface-elevated:#fff;--ui-input:#fff;
--ui-hover:#e9eef5;--ui-active:#dfe7f1;--ui-border:#d9e1eb;--ui-border-strong:#b8c4d2;
--ui-text-primary:#122033;--ui-text-secondary:#3d4d61;--ui-text-tertiary:#5f6f82;
--ui-accent:#16835f;--ui-accent-hover:#116b4d;--ui-accent-soft:#dff4ec;
--ui-danger:#c53b45;--ui-danger-soft:#fff0f1;--ui-danger-border:#f1b9bd;
--ui-warning:#a76612;--ui-warning-soft:#fff7e6;--ui-warning-border:#efd18f;
--ui-success:#16835f;--ui-success-soft:#e7f7f1;--ui-success-border:#a7ddca;
--ui-scrollbar:#a9b5c4;--ui-scrollbar-hover:#7f8ea1;color-scheme:light;
:root {
--ui-bg: #eef2f7;
--ui-panel: #fbfcfe;
--ui-surface: #f7f9fc;
--ui-surface-elevated: #fff;
--ui-input: #fff;
--ui-hover: #e9eef5;
--ui-active: #dfe7f1;
--ui-border: #d9e1eb;
--ui-border-strong: #b8c4d2;
--ui-text-primary: #122033;
--ui-text-secondary: #3d4d61;
--ui-text-tertiary: #5f6f82;
--ui-accent: #16835f;
--ui-accent-hover: #116b4d;
--ui-accent-soft: #dff4ec;
--ui-danger: #c53b45;
--ui-danger-soft: #fff0f1;
--ui-danger-border: #f1b9bd;
--ui-warning: #a76612;
--ui-warning-soft: #fff7e6;
--ui-warning-border: #efd18f;
--ui-success: #16835f;
--ui-success-soft: #e7f7f1;
--ui-success-border: #a7ddca;
--ui-scrollbar: #a9b5c4;
--ui-scrollbar-hover: #7f8ea1;
color-scheme: light;
}
.theme-dark{
--ui-bg:#0e141d;--ui-panel:#171f2b;--ui-surface:#1d2735;--ui-surface-elevated:#263243;--ui-input:#121a25;
--ui-hover:#283548;--ui-active:#324258;--ui-border:#2c394b;--ui-border-strong:#43536a;
--ui-text-primary:#edf2f7;--ui-text-secondary:#c8d2df;--ui-text-tertiary:#8f9caf;
--ui-accent:#35c792;--ui-accent-hover:#2eae80;--ui-accent-soft:#163b33;
--ui-danger:#ff7a83;--ui-danger-soft:#401f26;--ui-danger-border:#71333c;
--ui-warning:#f3bd5c;--ui-warning-soft:#3d301b;--ui-warning-border:#685028;
--ui-success:#51d4a4;--ui-success-soft:#183b32;--ui-success-border:#285f50;
--ui-scrollbar:#46566c;--ui-scrollbar-hover:#61728a;color-scheme:dark;
.theme-dark {
--ui-bg: #0e141d;
--ui-panel: #171f2b;
--ui-surface: #1d2735;
--ui-surface-elevated: #263243;
--ui-input: #121a25;
--ui-hover: #283548;
--ui-active: #324258;
--ui-border: #2c394b;
--ui-border-strong: #43536a;
--ui-text-primary: #edf2f7;
--ui-text-secondary: #c8d2df;
--ui-text-tertiary: #8f9caf;
--ui-accent: #35c792;
--ui-accent-hover: #2eae80;
--ui-accent-soft: #163b33;
--ui-danger: #ff7a83;
--ui-danger-soft: #401f26;
--ui-danger-border: #71333c;
--ui-warning: #f3bd5c;
--ui-warning-soft: #3d301b;
--ui-warning-border: #685028;
--ui-success: #51d4a4;
--ui-success-soft: #183b32;
--ui-success-border: #285f50;
--ui-scrollbar: #46566c;
--ui-scrollbar-hover: #61728a;
color-scheme: dark;
}
@layer base{
html,body,#root{height:100%;margin:0}body{overflow:hidden;background:var(--ui-bg);color:var(--ui-text-primary);font-family:Inter,"Noto Sans SC",system-ui,sans-serif}button,input,select{font:inherit}
:where(button,input,select,textarea,[tabindex]):focus-visible{outline:2px solid var(--ui-accent);outline-offset:2px}
@layer base {
html,
body,
#root {
height: 100%;
margin: 0;
}
body {
overflow: hidden;
background: var(--ui-bg);
color: var(--ui-text-primary);
font-family: Inter, 'Noto Sans SC', system-ui, sans-serif;
}
button,
input,
select {
font: inherit;
}
:where(button, input, select, textarea, [tabindex]):focus-visible {
outline: 2px solid var(--ui-accent);
outline-offset: 2px;
}
}
@layer components{
.panel-scroll{scrollbar-color:var(--ui-scrollbar) transparent;scrollbar-width:thin}
.control-slider{@apply w-full accent-accent}
.field{@apply rounded-md border border-border bg-input transition-colors hover:border-border-strong focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent}
.technical-value{font-variant-numeric:tabular-nums}
@layer components {
.panel-scroll {
scrollbar-color: var(--ui-scrollbar) transparent;
scrollbar-width: thin;
}
.control-slider {
@apply w-full accent-accent;
}
.field {
@apply rounded-md border border-border bg-input transition-colors hover:border-border-strong focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent;
}
.technical-value {
font-variant-numeric: tabular-nums;
}
}
::-webkit-scrollbar {
height: 6px;
width: 6px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: var(--ui-scrollbar);
border-radius: 999px;
}
::-webkit-scrollbar-thumb:hover {
background: var(--ui-scrollbar-hover);
}
.theme-light,
.theme-dark {
transition:
background-color 180ms ease-out,
color 180ms ease-out;
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
scroll-behavior: auto !important;
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}
::-webkit-scrollbar{height:6px;width:6px}::-webkit-scrollbar-track{background:transparent}::-webkit-scrollbar-thumb{background:var(--ui-scrollbar);border-radius:999px}::-webkit-scrollbar-thumb:hover{background:var(--ui-scrollbar-hover)}
.theme-light,.theme-dark{transition:background-color 180ms ease-out,color 180ms ease-out}
@media(prefers-reduced-motion:reduce){*,*::before,*::after{scroll-behavior:auto!important;animation-duration:.01ms!important;animation-iteration-count:1!important;transition-duration:.01ms!important}}
@@ -1,23 +1,60 @@
import {afterEach,describe,expect,it,vi} from 'vitest';
import {LocalTrainingClient} from './LocalTrainingClient';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { LocalTrainingClient } from './LocalTrainingClient';
afterEach(()=>vi.unstubAllGlobals());
afterEach(() => vi.unstubAllGlobals());
describe('LocalTrainingClient',()=>{
it('规范化服务地址并提交受类型约束的 JSON 请求',async()=>{
const fetchMock=vi.fn().mockResolvedValue(new Response(JSON.stringify({id:'a'.repeat(32),state:'queued'}),{status:202,headers:{'Content-Type':'application/json'}}));
vi.stubGlobal('fetch',fetchMock);
const client=new LocalTrainingClient('http://127.0.0.1:8765/');
await client.start({taskId:'Unitree-Go2-Flat',numEnvs:16,maxIterations:2,seed:42,runName:'test',device:'cpu',gpuIds:[],wandbMode:'offline'});
expect(fetchMock).toHaveBeenCalledWith('http://127.0.0.1:8765/api/training/jobs',expect.objectContaining({method:'POST'}));
const options=fetchMock.mock.calls[0][1] as RequestInit;
expect(JSON.parse(String(options.body))).toMatchObject({taskId:'Unitree-Go2-Flat',numEnvs:16,device:'cpu'});
describe('LocalTrainingClient', () => {
it('规范化服务地址并提交受类型约束的 JSON 请求', async () => {
const fetchMock = vi.fn().mockResolvedValue(
new Response(JSON.stringify({ id: 'a'.repeat(32), state: 'queued' }), {
status: 202,
headers: { 'Content-Type': 'application/json' },
}),
);
vi.stubGlobal('fetch', fetchMock);
const client = new LocalTrainingClient('http://127.0.0.1:8765/', 'secret-token');
await client.start({
taskId: 'Unitree-Go2-Flat',
numEnvs: 16,
maxIterations: 2,
seed: 42,
runName: 'test',
device: 'cpu',
gpuIds: [],
wandbMode: 'offline',
});
expect(fetchMock).toHaveBeenCalledWith(
'http://127.0.0.1:8765/api/training/jobs',
expect.objectContaining({ method: 'POST' }),
);
const options = fetchMock.mock.calls[0][1] as RequestInit;
expect(JSON.parse(String(options.body))).toMatchObject({
taskId: 'Unitree-Go2-Flat',
numEnvs: 16,
device: 'cpu',
});
expect(new Headers(options.headers).get('Authorization')).toBe('Bearer secret-token');
});
it('显示服务端返回的中文错误',async()=>{
vi.stubGlobal('fetch',vi.fn().mockResolvedValue(new Response(JSON.stringify({error:'已有训练任务正在运行'}),{status:409,headers:{'Content-Type':'application/json'}})));
await expect(new LocalTrainingClient('http://localhost:8765').health()).rejects.toThrow('已有训练任务正在运行');
it('显示服务端返回的中文错误', async () => {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue(
new Response(JSON.stringify({ error: '已有训练任务正在运行' }), {
status: 409,
headers: { 'Content-Type': 'application/json' },
}),
),
);
await expect(
new LocalTrainingClient('http://localhost:8765', 'secret-token').health(),
).rejects.toThrow('已有训练任务正在运行');
});
it('拒绝非 HTTP 地址',()=>{expect(()=>new LocalTrainingClient('file:///tmp/socket')).toThrow('http 或 https');});
it('拒绝非 HTTP 地址和空访问令牌', () => {
expect(() => new LocalTrainingClient('file:///tmp/socket', 'secret-token')).toThrow(
'http 或 https',
);
expect(() => new LocalTrainingClient('http://localhost:8765', '')).toThrow('访问令牌');
});
});
@@ -1,36 +1,72 @@
import type {TrainingJob,TrainingRequest,TrainingServerInfo} from './types';
import type { TrainingJob, TrainingRequest, TrainingServerInfo } from './types';
function normalizeEndpoint(value:string):string{
const endpoint=value.trim().replace(/\/+$/,'');
let url:URL;
try{url=new URL(endpoint);}catch{throw new Error('训练服务地址无效');}
if(url.protocol!=='http:'&&url.protocol!=='https:')throw new Error('训练服务地址必须使用 http 或 https');
return url.toString().replace(/\/$/,'');
function normalizeEndpoint(value: string): string {
const endpoint = value.trim().replace(/\/+$/, '');
let url: URL;
try {
url = new URL(endpoint);
} catch {
throw new Error('训练服务地址无效');
}
if (url.protocol !== 'http:' && url.protocol !== 'https:')
throw new Error('训练服务地址必须使用 http 或 https');
return url.toString().replace(/\/$/, '');
}
async function responseError(response:Response):Promise<Error>{
try{const body=await response.json() as {error?:string};if(body.error)return new Error(body.error);}catch{/* 使用 HTTP 状态作为回退 */}
async function responseError(response: Response): Promise<Error> {
try {
const body = (await response.json()) as { error?: string };
if (body.error) return new Error(body.error);
} catch {
/* 使用 HTTP 状态作为回退 */
}
return new Error(`本地训练服务请求失败(HTTP ${response.status})`);
}
export class LocalTrainingClient {
readonly endpoint:string;
constructor(endpoint:string){this.endpoint=normalizeEndpoint(endpoint);}
readonly endpoint: string;
readonly token: string;
constructor(endpoint: string, token: string) {
this.endpoint = normalizeEndpoint(endpoint);
this.token = token.trim();
if (!this.token) throw new Error('请输入训练服务访问令牌');
}
private async json<T>(path:string,init?:RequestInit):Promise<T>{
const response=await fetch(`${this.endpoint}${path}`,init);
if(!response.ok)throw await responseError(response);
private requestInit(init?: RequestInit): RequestInit {
const headers = new Headers(init?.headers);
headers.set('Authorization', `Bearer ${this.token}`);
return { ...init, headers };
}
private async json<T>(path: string, init?: RequestInit): Promise<T> {
const response = await fetch(`${this.endpoint}${path}`, this.requestInit(init));
if (!response.ok) throw await responseError(response);
return response.json() as Promise<T>;
}
health():Promise<TrainingServerInfo>{return this.json('/api/training/health');}
start(request:TrainingRequest):Promise<TrainingJob>{return this.json('/api/training/jobs',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(request)});}
job(id:string):Promise<TrainingJob>{return this.json(`/api/training/jobs/${encodeURIComponent(id)}`);}
cancel(id:string):Promise<TrainingJob>{return this.json(`/api/training/jobs/${encodeURIComponent(id)}`,{method:'DELETE'});}
async downloadPolicy(id:string):Promise<File>{
const response=await fetch(`${this.endpoint}/api/training/jobs/${encodeURIComponent(id)}/artifacts/policy.onnx`);
if(!response.ok)throw await responseError(response);
const blob=await response.blob();
return new File([blob],`policy-${id.slice(0,8)}.onnx`,{type:'application/octet-stream'});
health(): Promise<TrainingServerInfo> {
return this.json('/api/training/health');
}
start(request: TrainingRequest): Promise<TrainingJob> {
return this.json('/api/training/jobs', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(request),
});
}
job(id: string): Promise<TrainingJob> {
return this.json(`/api/training/jobs/${encodeURIComponent(id)}`);
}
cancel(id: string): Promise<TrainingJob> {
return this.json(`/api/training/jobs/${encodeURIComponent(id)}`, { method: 'DELETE' });
}
async downloadPolicy(id: string): Promise<File> {
const response = await fetch(
`${this.endpoint}/api/training/jobs/${encodeURIComponent(id)}/artifacts/policy.onnx`,
this.requestInit(),
);
if (!response.ok) throw await responseError(response);
const blob = await response.blob();
return new File([blob], `policy-${id.slice(0, 8)}.onnx`, { type: 'application/octet-stream' });
}
}
+31 -31
View File
@@ -1,40 +1,40 @@
export type TrainingJobState='queued'|'running'|'succeeded'|'failed'|'cancelled';
export type TrainingDevice='cpu'|'gpu';
export type WandbMode='offline'|'online'|'disabled';
export type TrainingJobState = 'queued' | 'running' | 'succeeded' | 'failed' | 'cancelled';
export type TrainingDevice = 'cpu' | 'gpu';
export type WandbMode = 'offline' | 'online' | 'disabled';
export interface TrainingServerInfo {
version:string;
ready:boolean;
trainerRoot:string;
python:string;
tasks:string[];
activeJobId?:string;
error?:string;
version: string;
ready: boolean;
trainerRoot: string;
python: string;
tasks: string[];
activeJobId?: string;
error?: string;
}
export interface TrainingRequest {
taskId:string;
numEnvs:number;
maxIterations:number;
seed:number;
runName:string;
device:TrainingDevice;
gpuIds:number[];
wandbMode:WandbMode;
taskId: string;
numEnvs: number;
maxIterations: number;
seed: number;
runName: string;
device: TrainingDevice;
gpuIds: number[];
wandbMode: WandbMode;
}
export interface TrainingJob {
id:string;
state:TrainingJobState;
taskId:string;
createdAt:string;
startedAt?:string;
endedAt?:string;
iteration:number;
maxIterations:number;
progress:number;
message:string;
logs:string[];
artifactReady:boolean;
artifactName?:string;
id: string;
state: TrainingJobState;
taskId: string;
createdAt: string;
startedAt?: string;
endedAt?: string;
iteration: number;
maxIterations: number;
progress: number;
message: string;
logs: string[];
artifactReady: boolean;
artifactName?: string;
}

Some files were not shown because too many files have changed in this diff Show More