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;checks:{name:string;status:string;stepRadians:number;maxWorldDelta:number;descendants:number}[];objects:string[];matrices:Record} type Applied=Awaited>; interface Props {client:WebEngineClient|null;snapshot:SceneSnapshotIR|null;onApplied:(scene:Applied)=>void;onStart:()=>Promise;onSave:()=>Promise;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(null),[files,setFiles]=useState([]),[resolved,setResolved]=useState<{file:File;path:string}[]>([]); const [result,setResult]=useState(null),[angles,setAngles]=useState>({}),[error,setError]=useState(''),[status,setStatus]=useState('等待输入'); const [busy,setBusy]=useState(false),[ready,setReady]=useState(false),[verified,setVerified]=useState(false),[exports,setExports]=useState([]); const currentHash=useRef(''),revision=useRef(null),abort=useRef(null),mounted=useRef(true),operation=useRef(false),panelRef=useRef(null); useEffect(()=>{mounted.current=true;const previous=document.activeElement as HTMLElement|null;panelRef.current?.querySelector('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)=>{ 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('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={})=>{ 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
{event.stopPropagation();if(event.key==='Escape'&&!busy)onClose();if(event.key==='Tab'){ const controls=Array.from(panelRef.current?.querySelectorAll('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();} }}}>

六轴机器人

1 · 模型与资源

{model?
{model.name} · {model.links.length} links · {files.length} files
:null}

2 · 装配与检查

{result?{result.model.joints.map(j=>)}
关节父级 → 子级轴检查
{j.name}{j.type}{j.parent} → {j.child}{j.type==='fixed'?'--':j.axis.join(', ')}{result.checks.find(c=>c.name===j.name)?.status??'--'}
:null}
{result?.assembled?

3 · 关节姿态

{result.model.joints.filter(j=>j.type!=='fixed').map(j=>)}
:null}

4 · 保存与导出

{exports.map(value=>
{value}
)}
; }