Accumulated uncommitted work: GHOST Web backend, WebGL/GPU web layer, web workflow engine and protocol, tooling, docs and task records. Large generated evidence, user-testing archives, build snapshots and imported test assets remain untracked. Co-Authored-By: Claude Code <noreply@anthropic.com>
111 lines
13 KiB
TypeScript
111 lines
13 KiB
TypeScript
import {useEffect,useRef,useState} from 'react';
|
||
import type {WebEngineClient} from '../engine-client/WebEngineClient';
|
||
import type {SceneSnapshotIR} from '../../../protocol/scene-ir';
|
||
import {sha256Hex} from '../../../protocol/render-assets';
|
||
import {exportGLB} from '../../../protocol/glb-export';
|
||
import {parseRobotURDF,resolveRobotFiles,type RobotModel} from './urdf';
|
||
import workflow from './robot-workflow.py?raw';
|
||
|
||
interface Result {schemaVersion:1;model:RobotModel;assembled:boolean;angles:Record<string,number>;checks:{name:string;status:string;stepRadians:number;maxWorldDelta:number;descendants:number}[];objects:string[];matrices:Record<string,{parent:string|null;local:number[];world:number[]}>}
|
||
type Applied=Awaited<ReturnType<WebEngineClient['applyServerBlendResult']>>;
|
||
interface Props {client:WebEngineClient|null;snapshot:SceneSnapshotIR|null;onApplied:(scene:Applied)=>void;onStart:()=>Promise<void>;onSave:()=>Promise<void>;onClose:()=>void}
|
||
const base='/robot-assets/abb-irb120/';
|
||
function download(name:string,bytes:BlobPart,type='application/json'){
|
||
const url=URL.createObjectURL(new Blob([bytes],{type})),link=document.createElement('a');link.href=url;link.download=name;link.click();setTimeout(()=>URL.revokeObjectURL(url),1000);
|
||
}
|
||
export function RobotTool({client,snapshot,onApplied,onStart,onSave,onClose}:Props){
|
||
const [model,setModel]=useState<RobotModel|null>(null),[files,setFiles]=useState<File[]>([]),[resolved,setResolved]=useState<{file:File;path:string}[]>([]);
|
||
const [result,setResult]=useState<Result|null>(null),[angles,setAngles]=useState<Record<string,number>>({}),[error,setError]=useState(''),[status,setStatus]=useState('等待输入');
|
||
const [busy,setBusy]=useState(false),[ready,setReady]=useState(false),[verified,setVerified]=useState(false),[exports,setExports]=useState<string[]>([]);
|
||
const currentHash=useRef(''),revision=useRef<number|null>(null),abort=useRef<AbortController|null>(null),mounted=useRef(true),operation=useRef(false),panelRef=useRef<HTMLElement>(null);
|
||
useEffect(()=>{mounted.current=true;const previous=document.activeElement as HTMLElement|null;panelRef.current?.querySelector<HTMLButtonElement>('button')?.focus();return()=>{mounted.current=false;abort.current?.abort();previous?.focus();};},[]);
|
||
useEffect(()=>{if(result&&snapshot?.revision!==revision.current){setVerified(false);setStatus('场景已变化,请读取项目机器人');}},[snapshot?.revision,result]);
|
||
const run=async(fn:()=>Promise<void>)=>{
|
||
if(operation.current)return;operation.current=true;setBusy(true);setError('');
|
||
try{await fn();}catch(e){if(mounted.current){setError(String(e instanceof Error?e.message:e));setStatus('操作失败');setVerified(false);setExports([]);setResult(old=>old?{...old,checks:[]}:null);}}
|
||
finally{operation.current=false;abort.current=null;if(mounted.current){setBusy(false);setTimeout(()=>panelRef.current?.querySelector<HTMLButtonElement>('button')?.focus(),0);}}
|
||
};
|
||
const invalidate=()=>{setReady(false);setResolved([]);setExports([]);};
|
||
const checkFiles=(next:RobotModel,input:File[])=>{
|
||
const bindings=resolveRobotFiles(next,input);setResolved(bindings);setReady(true);setStatus(`资源就绪 · ${next.links.length} links · ${bindings.length} STL · 6 轴`);return bindings;
|
||
};
|
||
const preset=()=>run(async()=>{
|
||
invalidate();setModel(null);setStatus('读取 ABB IRB120 样例');
|
||
const receipt=await fetch(base+'provenance.json').then(r=>{if(!r.ok)throw new Error('样例清单不可用');return r.json();});
|
||
const load=async(path:string)=>{
|
||
const response=await fetch(base+path);if(!response.ok)throw new Error('样例文件缺失: '+path);const data=await response.arrayBuffer();
|
||
const expected=receipt.files.find((item:{path:string})=>item.path===path);
|
||
if(!expected||await sha256Hex(data)!==expected.sha256)throw new Error('样例哈希不匹配: '+path);
|
||
return new File([data],path.split('/').at(-1)!);
|
||
};
|
||
const urdf=await load('abb_irb120.urdf'),next=parseRobotURDF(await urdf.text());
|
||
const paths=[...new Set(next.links.flatMap(l=>l.visuals.map(v=>v.path)))],loaded:File[]=[];
|
||
for(const path of paths)loaded.push(await load(path));
|
||
setModel(next);setFiles(loaded);checkFiles(next,loaded);setExports([]);
|
||
});
|
||
const assertCurrent=async()=>{if(!client||!currentHash.current||await sha256Hex(await client.saveBlend())!==currentHash.current)throw new Error('场景已变化,请先读取项目机器人');};
|
||
const execute=async(action:string,extra:Record<string,unknown>={})=>{
|
||
if(!client)throw new Error('引擎尚未就绪');
|
||
if(action!=='import'&&action!=='inspect')await assertCurrent();
|
||
const controller=new AbortController();abort.current=controller;setStatus({import:'正在导入零件',assemble:'正在建立 URDF 层级',check:'正在检查六轴',pose:'正在应用关节角度',inspect:'正在读取项目机器人'}[action]??action);setVerified(false);
|
||
const config={action,...extra},source='import json\nCONFIG=json.loads('+JSON.stringify(JSON.stringify(config))+')\n'+workflow;
|
||
if(new TextEncoder().encode(source).length>65536)throw new Error('URDF 元数据超出当前工具的 64 KiB 作业上限');
|
||
const response=await client.runNativeWorkflow({kind:'PYTHON',context:{mode:'OBJECT'},python:{source,textName:'RobotTool.py',console:false}},controller.signal);
|
||
if(!response.scene)throw new Error('机器人操作没有返回场景');
|
||
revision.current=response.scene.snapshot.revision;onApplied(response.scene);
|
||
currentHash.current=await sha256Hex(await client.saveBlend());
|
||
const line=response.job.output?.split('\n').find(line=>line.startsWith('WEB_ROBOT_RESULT='));if(!line)throw new Error('机器人结果缺失');
|
||
const value:Result=JSON.parse(line.slice('WEB_ROBOT_RESULT='.length));if(value.schemaVersion!==1||value.model.joints.filter(j=>j.type!=='fixed').length!==6)throw new Error('机器人结果无效');
|
||
if(!mounted.current)return;
|
||
setResult(value);setAngles(Object.fromEntries(Object.entries(value.angles).map(([id,n])=>[id,Number((n*180/Math.PI).toFixed(6))])));setExports([]);
|
||
const checked=action==='check'&&value.checks.length===6&&value.checks.every(c=>c.status==='PASS');setVerified(checked);
|
||
setStatus(checked?'六轴检查通过':action==='import'?'零件已导入':action==='assemble'?'层级已建立':action==='pose'?'姿态已应用':'已读取项目机器人');
|
||
};
|
||
const importParts=()=>run(async()=>{
|
||
if(!client||!model||!ready)throw new Error('请先检查资源');
|
||
const starter=!snapshot;
|
||
if(starter)await onStart();
|
||
const prefix='robot-tool/'+crypto.randomUUID();
|
||
await client.uploadNativeResources(resolved.map(item=>({...item,path:prefix+'/'+item.path})),true);
|
||
await execute('import',{model,resourcePrefix:prefix,clearStarter:starter});
|
||
});
|
||
const exportRobot=()=>run(async()=>{
|
||
if(!client||!result||!verified)throw new Error('请先通过六轴检查');await assertCurrent();
|
||
const scene=await client.snapshot(),ids=new Set(result.objects),nodes=scene.snapshot.nodes.filter(n=>ids.has(n.name));
|
||
if(nodes.length!==ids.size)throw new Error('机器人对象已变化');
|
||
const dataIds=new Set(nodes.map(n=>n.dataId)),materialIds=new Set(scene.snapshot.meshes.filter(m=>dataIds.has(m.id)).flatMap(m=>m.materialSlotIds??[]));
|
||
const subset:SceneSnapshotIR={...scene.snapshot,nodes,meshes:scene.snapshot.meshes.filter(m=>dataIds.has(m.id)),materials:scene.snapshot.materials.filter(m=>materialIds.has(m.id)),
|
||
images:[],lights:[],cameras:[],animations:[],armatures:[],nonMeshData:[],collections:scene.snapshot.collections.map(c=>({...c,objectIds:c.objectIds.filter(id=>nodes.some(n=>n.id===id))}))};
|
||
const output=exportGLB(subset,scene.geometryBuffers);
|
||
if(!output.glb||output.report.warnings.some(w=>w.severity==='error'))throw new Error(JSON.stringify(output.report.warnings));
|
||
const name=result.model.name+'_hierarchy.glb';download(name,output.glb,'model/gltf-binary');
|
||
setExports([name,`SHA-256 ${await sha256Hex(output.glb)}`,`${nodes.length} nodes · ${subset.meshes.length} meshes`]);setStatus('机器人 GLB 已导出');
|
||
});
|
||
const mapping=()=>{if(!result)return;download(result.model.name+'_joints.json',JSON.stringify({schemaVersion:1,robot:result.model.name,units:'metres/radians',basis:'GLB Y-up=(URDF x,z,-y)',anglesRadians:result.angles,
|
||
joints:result.model.joints.map(j=>({...j,gltfNode:j.child,axisGLTF:[j.axis[0],j.axis[2],-j.axis[1]]})),checks:result.checks},null,2));};
|
||
return <div className="robot-tool-backdrop" onKeyDown={event=>{event.stopPropagation();if(event.key==='Escape'&&!busy)onClose();if(event.key==='Tab'){
|
||
const controls=Array.from(panelRef.current?.querySelectorAll<HTMLElement>('button:not(:disabled),input:not(:disabled),[tabindex="0"]')??[]).filter(el=>el.getClientRects().length);
|
||
const first=controls[0],last=controls.at(-1);if(event.shiftKey&&document.activeElement===first){event.preventDefault();last?.focus();}else if(!event.shiftKey&&document.activeElement===last){event.preventDefault();first?.focus();}
|
||
}}}>
|
||
<section ref={panelRef} className="robot-tool" role="dialog" aria-modal="true" aria-label="六轴机器人工具">
|
||
<header><h2>六轴机器人</h2><button aria-label="关闭机器人工具" title="关闭" disabled={busy} onClick={onClose}>×</button></header>
|
||
<div className="robot-tool-body">
|
||
<section><h3>1 · 模型与资源</h3><div className="robot-tool-actions"><button disabled={busy} onClick={preset}>加载 ABB IRB120 样例</button><button disabled={busy||!snapshot} onClick={()=>void run(()=>execute('inspect'))}>读取项目机器人</button></div>
|
||
<label>URDF 文件<input aria-label="机器人 URDF 文件" type="file" accept=".urdf,.xml" disabled={busy} onChange={e=>{const file=e.target.files?.[0];if(file)void run(async()=>{invalidate();setModel(null);const next=parseRobotURDF(await file.text());setModel(next);setStatus('URDF 已读取');});}}/></label>
|
||
<label>STL 文件<input aria-label="机器人 STL 文件" type="file" multiple accept=".stl" disabled={busy} onChange={e=>{setFiles(Array.from(e.target.files??[]));invalidate();}}/></label>
|
||
<label>资源目录<input aria-label="机器人资源目录" type="file" multiple {...{webkitdirectory:''}} disabled={busy} onChange={e=>{setFiles(Array.from(e.target.files??[]));invalidate();}}/></label>
|
||
<div className="robot-tool-actions"><button disabled={busy||!model} onClick={()=>void run(async()=>{setReady(false);if(model)checkFiles(model,files);})}>检查资源</button><button disabled={busy||!ready||!client} onClick={importParts}>导入零件</button></div>
|
||
{model?<div className="robot-tool-summary">{model.name} · {model.links.length} links · {files.length} files</div>:null}
|
||
</section>
|
||
<section><h3>2 · 装配与检查</h3><div className="robot-tool-actions"><button disabled={busy||!result} onClick={()=>void run(()=>execute('assemble'))}>建立 URDF 层级</button><button disabled={busy||!result?.assembled} onClick={()=>void run(()=>execute('check'))}>检查六轴</button></div>
|
||
{result?<table aria-label="机器人关节表"><thead><tr><th>关节</th><th>父级 → 子级</th><th>轴</th><th>检查</th></tr></thead><tbody>{result.model.joints.map(j=><tr key={j.name}><td>{j.name}<small>{j.type}</small></td><td>{j.parent} → {j.child}</td><td>{j.type==='fixed'?'--':j.axis.join(', ')}</td><td>{result.checks.find(c=>c.name===j.name)?.status??'--'}</td></tr>)}</tbody></table>:null}
|
||
</section>
|
||
{result?.assembled?<section><h3>3 · 关节姿态</h3>{result.model.joints.filter(j=>j.type!=='fixed').map(j=><label className="robot-joint" key={j.name}><span>{j.name}</span><input aria-label={`${j.name} 滑块`} type="range" min={j.lower*180/Math.PI} max={j.upper*180/Math.PI} step="0.1" value={angles[j.name]??0} disabled={busy} onChange={e=>setAngles({...angles,[j.name]:Number(e.target.value)})}/><input aria-label={`${j.name} 角度`} type="number" min={j.lower*180/Math.PI} max={j.upper*180/Math.PI} step="0.1" value={angles[j.name]??0} disabled={busy} onChange={e=>setAngles({...angles,[j.name]:Number(e.target.value)})}/><span>°</span></label>)}
|
||
<div className="robot-tool-actions"><button disabled={busy} onClick={()=>void run(()=>execute('pose',{angles:Object.fromEntries(Object.entries(angles).map(([id,value])=>[id,value*Math.PI/180]))}))}>应用关节角度</button><button disabled={busy} onClick={()=>void run(()=>execute('pose',{angles:Object.fromEntries(Object.keys(angles).map(id=>[id,0]))}))}>恢复零位</button></div>
|
||
</section>:null}
|
||
<section><h3>4 · 保存与导出</h3><div className="robot-tool-actions"><button disabled={busy||!result} onClick={()=>void run(onSave)}>保存机器人项目</button><button disabled={busy||!verified} onClick={exportRobot}>导出机器人 GLB</button><button disabled={busy||!result} onClick={()=>void run(async()=>{await assertCurrent();mapping();})}>下载关节映射</button><button disabled={busy||!result} onClick={()=>result&&download(result.model.name+'.urdf',result.model.urdf,'application/xml')}>下载 URDF</button></div>{exports.map(value=><div className="robot-tool-export" key={value}>{value}</div>)}</section>
|
||
</div><footer><output role="status">{status}</output>{busy&&abort.current?<button onClick={()=>abort.current?.abort()}>取消操作</button>:null}{error?<div role="alert">{error}</div>:null}</footer>
|
||
</section>
|
||
</div>;
|
||
}
|