Files
workinf_Blender_Wasm/web/app/src/robot/RobotTool.tsx
mes123456 f3e21c6b98
Some checks failed
M6 deployable RC / quick (push) Has been cancelled
M6 deployable RC / chromium (push) Has been cancelled
M6 deployable RC / release (push) Has been cancelled
chore: checkpoint web Blender migration source, tools and docs
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>
2026-11-10 11:18:01 +08:00

111 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>;
}