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>
This commit is contained in:
110
web/app/src/robot/RobotTool.tsx
Normal file
110
web/app/src/robot/RobotTool.tsx
Normal file
@@ -0,0 +1,110 @@
|
||||
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>;
|
||||
}
|
||||
1
web/app/src/robot/raw.d.ts
vendored
Normal file
1
web/app/src/robot/raw.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
declare module '*.py?raw' {const source:string;export default source;}
|
||||
9
web/app/src/robot/robot-tool.css
Normal file
9
web/app/src/robot/robot-tool.css
Normal file
@@ -0,0 +1,9 @@
|
||||
.robot-tool-backdrop{position:fixed;inset:0;z-index:1200;background:#0003;display:flex;justify-content:flex-end;padding:36px 12px 16px;box-sizing:border-box}
|
||||
.robot-tool{width:580px;max-width:100%;display:flex;flex-direction:column;min-height:0;background:#252827;color:#e4e8e5;border:1px solid #737b76;box-shadow:0 8px 25px #0005}
|
||||
.robot-tool header{display:flex;align-items:center;justify-content:space-between;border-bottom:1px solid #555e58;padding:10px 14px}.robot-tool h2{font-size:17px;margin:0}.robot-tool header button{width:28px;height:28px;padding:0;font-size:20px}
|
||||
.robot-tool-body{overflow:auto;min-height:0;padding:0 14px}.robot-tool-body>section{padding:12px 0;border-bottom:1px solid #4e5550}.robot-tool h3{font-size:13px;margin:0 0 10px;color:#c6dace}.robot-tool label{display:flex;align-items:center;gap:8px;margin:7px 0;font-size:12px;flex-wrap:wrap}.robot-tool input{min-width:0;max-width:100%;color:inherit}.robot-tool input[type=file]{flex:1;min-width:150px;font-size:11px}
|
||||
.robot-tool button{background:#444d47;color:#edf2ee;border:1px solid #78867b;border-radius:3px;padding:6px 9px;cursor:pointer;font-size:12px}.robot-tool button:disabled{opacity:.45;cursor:default}.robot-tool button:hover:enabled{background:#54645a}.robot-tool button:focus-visible,.robot-tool input:focus-visible{outline:2px solid #bfcf79;outline-offset:2px}.robot-tool-actions{display:flex;gap:7px;flex-wrap:wrap;margin:7px 0}
|
||||
.robot-tool-summary{color:#bad5c4;font-size:11px;padding-top:6px}.robot-tool table{width:100%;border-collapse:collapse;font-size:11px;table-layout:fixed}.robot-tool th,.robot-tool td{text-align:left;padding:6px 3px;border-bottom:1px solid #414942;overflow-wrap:anywhere}.robot-tool th:nth-child(2){width:40%}.robot-tool small{display:block;color:#a4b1a8;font-size:10px}
|
||||
.robot-tool .robot-joint{display:grid;grid-template-columns:68px minmax(60px,1fr) 82px 12px;gap:8px}.robot-joint input[type=number]{width:100%;box-sizing:border-box;background:#1d211e;border:1px solid #657368;padding:4px}.robot-joint input[type=range]{width:100%;accent-color:#8fc4a8}
|
||||
.robot-tool footer{padding:10px 14px;border-top:1px solid #606960;font-size:12px}.robot-tool [role=alert]{color:#ffb7a4;margin-top:6px;overflow-wrap:anywhere}.robot-tool-export{font-size:10px;overflow-wrap:anywhere;margin:5px 0;color:#b6d3c1}
|
||||
@media(max-width:650px){.robot-tool-backdrop{padding:8px}.robot-tool{width:100%}.robot-tool-body{padding:0 10px}}
|
||||
118
web/app/src/robot/robot-workflow.py
Normal file
118
web/app/src/robot/robot-workflow.py
Normal file
@@ -0,0 +1,118 @@
|
||||
import bpy
|
||||
import json
|
||||
import math
|
||||
from pathlib import Path
|
||||
from mathutils import Matrix, Euler, Quaternion, Vector
|
||||
|
||||
MARKER='web_robot_tool_v1'
|
||||
action=CONFIG['action']
|
||||
roots=[obj for obj in bpy.data.objects if MARKER in obj]
|
||||
|
||||
def origin(value):
|
||||
return Matrix.Translation(value['xyz']) @ Euler(value['rpy'],'XYZ').to_matrix().to_4x4()
|
||||
|
||||
def flat(matrix):
|
||||
return [matrix[row][col] for col in range(4) for row in range(4)]
|
||||
|
||||
if action=='import':
|
||||
if roots:raise RuntimeError('项目已有机器人,请打开新项目或读取已有机器人')
|
||||
model=CONFIG['model']
|
||||
names=[model['root']]+[link['name'] for link in model['links']]+[v['name'] for link in model['links'] for v in link['visuals']]
|
||||
if len(names)!=len(set(names)) or any(name in bpy.data.objects for name in names):raise RuntimeError('机器人对象名称与现有场景冲突')
|
||||
resource_root=Path(bpy.data.filepath).parent/'resources'/CONFIG['resourcePrefix']
|
||||
for link in model['links']:
|
||||
for visual in link['visuals']:
|
||||
file=(resource_root/visual['path']).resolve()
|
||||
if not file.is_relative_to(resource_root.resolve()) or not file.is_file():raise RuntimeError('机器人网格资源缺失')
|
||||
if CONFIG.get('clearStarter'):
|
||||
for obj in list(bpy.data.objects):bpy.data.objects.remove(obj,do_unlink=True)
|
||||
collection=bpy.data.collections.new(model['name']);bpy.context.scene.collection.children.link(collection)
|
||||
root=bpy.data.objects.new(model['root'],None);collection.objects.link(root)
|
||||
root.empty_display_size=.04
|
||||
for link in model['links']:
|
||||
frame=bpy.data.objects.new(link['name'],None);collection.objects.link(frame)
|
||||
frame.parent=root;frame.matrix_parent_inverse=Matrix.Identity(4);frame.empty_display_size=.03
|
||||
for visual in link['visuals']:
|
||||
before=set(bpy.data.objects)
|
||||
bpy.ops.wm.stl_import(filepath=str(resource_root/visual['path']),global_scale=1,forward_axis='Y',up_axis='Z')
|
||||
imported=[obj for obj in bpy.data.objects if obj not in before]
|
||||
if len(imported)!=1:raise RuntimeError('STL 导入结果不是一个对象')
|
||||
part=imported[0];part.name=visual['name'];part.data.name=visual['name']+'_mesh'
|
||||
if not len(part.data.vertices) or not len(part.data.polygons):raise RuntimeError('STL 网格为空')
|
||||
for owner in list(part.users_collection):owner.objects.unlink(part)
|
||||
collection.objects.link(part);part.parent=frame;part.matrix_parent_inverse=Matrix.Identity(4)
|
||||
part.matrix_basis=origin(visual);part.scale=visual['scale']
|
||||
material=bpy.data.materials.new(model['name']+' '+visual['name'])
|
||||
material.diffuse_color=visual['color'];material.use_nodes=True
|
||||
bsdf=material.node_tree.nodes.get('Principled BSDF');bsdf.inputs['Base Color'].default_value=visual['color'];bsdf.inputs['Roughness'].default_value=.38
|
||||
part.data.materials.clear();part.data.materials.append(material)
|
||||
state={'schemaVersion':1,'model':model,'assembled':False,'angles':{j['name']:0 for j in model['joints'] if j['type']!='fixed'}}
|
||||
else:
|
||||
if len(roots)!=1:raise RuntimeError('项目必须包含一个由机器人工具创建的机器人')
|
||||
root=roots[0];state=json.loads(root[MARKER]);model=state['model']
|
||||
if state.get('schemaVersion')!=1:raise RuntimeError('机器人元数据版本不支持')
|
||||
|
||||
def pose(angles):
|
||||
for joint in model['joints']:
|
||||
child=bpy.data.objects.get(joint['child']);parent=bpy.data.objects.get(joint['parent'])
|
||||
if not child or not parent:raise RuntimeError('机器人 link 已删除')
|
||||
child.parent=parent;child.matrix_parent_inverse=Matrix.Identity(4)
|
||||
matrix=origin(joint)
|
||||
if joint['type']!='fixed':
|
||||
angle=angles[joint['name']]
|
||||
if not math.isfinite(angle) or not joint['lower']-1e-6<=angle<=joint['upper']+1e-6:raise RuntimeError('关节角度超过限位: '+joint['name'])
|
||||
matrix=matrix @ Quaternion(Vector(joint['axis']).normalized(),angle).to_matrix().to_4x4()
|
||||
child.matrix_basis=matrix
|
||||
bpy.context.view_layer.update()
|
||||
|
||||
if action=='assemble':
|
||||
pose(state['angles']);state['assembled']=True
|
||||
elif action=='pose':
|
||||
if not state['assembled']:raise RuntimeError('请先建立 URDF 层级')
|
||||
pose(CONFIG['angles']);state['angles']=CONFIG['angles']
|
||||
elif action not in ('import','inspect','check'):
|
||||
raise RuntimeError('未知机器人操作')
|
||||
|
||||
checks=[]
|
||||
if action=='check':
|
||||
if not state['assembled']:raise RuntimeError('请先建立 URDF 层级')
|
||||
expected_names={model['root']}|{link['name'] for link in model['links']}|{v['name'] for link in model['links'] for v in link['visuals']}
|
||||
if root.parent or {obj.name for obj in [root]+list(root.children_recursive)}!=expected_names:raise RuntimeError('机器人包含未知节点或外部父级')
|
||||
for joint in model['joints']:
|
||||
child=bpy.data.objects.get(joint['child'])
|
||||
if not child or not child.parent or child.parent.name!=joint['parent']:raise RuntimeError('父子关系不匹配: '+joint['child'])
|
||||
expected=origin(joint)
|
||||
if joint['type']!='fixed':expected=expected @ Quaternion(Vector(joint['axis']).normalized(),state['angles'][joint['name']]).to_matrix().to_4x4()
|
||||
if max(abs(child.matrix_local[r][c]-expected[r][c]) for r in range(4) for c in range(4))>1e-5:raise RuntimeError('关节原点或姿态已修改: '+joint['child'])
|
||||
for link in model['links']:
|
||||
frame=bpy.data.objects.get(link['name'])
|
||||
if not frame:raise RuntimeError('link 缺失')
|
||||
if link['name']==model['rootLink'] and frame.parent!=root:raise RuntimeError('机器人根层级已修改')
|
||||
for visual in link['visuals']:
|
||||
obj=bpy.data.objects.get(visual['name'])
|
||||
expected=origin(visual) @ Matrix.Diagonal((*visual['scale'],1))
|
||||
if not obj or obj.parent!=frame or obj.type!='MESH' or len(obj.data.polygons)==0:raise RuntimeError('可视网格缺失或层级无效')
|
||||
if max(abs(obj.matrix_local[r][c]-expected[r][c]) for r in range(4) for c in range(4))>1e-5:raise RuntimeError('可视网格原点已修改')
|
||||
for joint in model['joints']:
|
||||
if joint['type']=='fixed':continue
|
||||
child=bpy.data.objects[joint['child']];original=child.matrix_basis.copy()
|
||||
descendants=[child]+list(child.children_recursive);before={obj.name:obj.matrix_world.copy() for obj in descendants}
|
||||
current=state['angles'][joint['name']];step=min(.25,joint['upper']-current)
|
||||
if step<.001:step=max(-.25,joint['lower']-current)
|
||||
if abs(step)<1e-6:raise RuntimeError('关节可动范围过小')
|
||||
try:
|
||||
child.matrix_basis=origin(joint) @ Quaternion(Vector(joint['axis']).normalized(),current+step).to_matrix().to_4x4();bpy.context.view_layer.update()
|
||||
delta=max(abs(obj.matrix_world[r][c]-before[obj.name][r][c]) for obj in descendants for r in range(4) for c in range(4))
|
||||
if delta<1e-7:raise RuntimeError('关节未发生运动')
|
||||
checks.append({'name':joint['name'],'status':'PASS','stepRadians':step,'maxWorldDelta':delta,'descendants':len(descendants)-1})
|
||||
finally:child.matrix_basis=original;bpy.context.view_layer.update()
|
||||
|
||||
root[MARKER]=json.dumps(state,ensure_ascii=False)
|
||||
for obj in bpy.context.selected_objects:obj.select_set(False)
|
||||
root.select_set(True);bpy.context.view_layer.objects.active=root
|
||||
bpy.context.scene.unit_settings.system='METRIC';bpy.context.scene.unit_settings.scale_length=1
|
||||
bpy.context.view_layer.update()
|
||||
owned=[root]+list(root.children_recursive)
|
||||
report={**state,'checks':checks,'objects':[obj.name for obj in owned],
|
||||
'matrices':{obj.name:{'parent':obj.parent.name if obj.parent else None,'local':flat(obj.matrix_local),'world':flat(obj.matrix_world)} for obj in owned}}
|
||||
print('WEB_ROBOT_RESULT='+json.dumps(report,ensure_ascii=False))
|
||||
67
web/app/src/robot/urdf.ts
Normal file
67
web/app/src/robot/urdf.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
export type Vec3=[number,number,number];
|
||||
export interface RobotVisual {path:string;xyz:Vec3;rpy:Vec3;scale:Vec3;color:[number,number,number,number];name:string}
|
||||
export interface RobotJoint {name:string;type:'fixed'|'revolute'|'continuous';parent:string;child:string;xyz:Vec3;rpy:Vec3;axis:Vec3;lower:number;upper:number}
|
||||
export interface RobotModel {name:string;root:string;rootLink:string;urdf:string;links:{name:string;visuals:RobotVisual[]}[];joints:RobotJoint[]}
|
||||
const fail=(code:string):never=>{throw new Error(code);};
|
||||
const direct=(el:Element,tag:string)=>Array.from(el.children).filter(child=>child.tagName===tag);
|
||||
function vector(value:string|null,defaults:number[],size=3):number[]{
|
||||
const result=value===null?defaults:value.trim().split(/\s+/).map(Number);
|
||||
if(result.length!==size||result.some(n=>!Number.isFinite(n)||Math.abs(n)>1e6))fail('URDF 数值无效');return result;
|
||||
}
|
||||
function name(value:string|null):string{if(!value||!/^[A-Za-z_][A-Za-z0-9_.-]{0,47}$/.test(value))return fail('URDF 名称仅支持 1-48 位字母、数字、下划线、点或横线');return value;}
|
||||
export function resourcePath(value:string):string {
|
||||
const path=value.startsWith('package://')?value.slice(10):value.replace(/^\.\//,'');
|
||||
if(!path||path.startsWith('/')||path.includes('\\')||path.includes(':')||path.includes('\0')||path.split('/').some(s=>!s||s==='.'||s==='..'))fail('URDF 资源路径无效');
|
||||
return path;
|
||||
}
|
||||
export function parseRobotURDF(source:string):RobotModel {
|
||||
if(new TextEncoder().encode(source).length>256*1024||/<!DOCTYPE|<!ENTITY/i.test(source))fail('URDF 文件过大或包含外部实体');
|
||||
const doc=new DOMParser().parseFromString(source,'application/xml'),robot=doc.documentElement;
|
||||
if(doc.querySelector('parsererror')||robot.tagName!=='robot')fail('URDF XML 无效');
|
||||
if(Array.from(robot.getElementsByTagName('*')).some(e=>e.tagName.includes(':')))fail('请提供已展开的 URDF,暂不执行 Xacro');
|
||||
const origins=(el:Element)=>{const origin=direct(el,'origin')[0];return {xyz:vector(origin?.getAttribute('xyz')??null,[0,0,0]) as Vec3,rpy:vector(origin?.getAttribute('rpy')??null,[0,0,0]) as Vec3};};
|
||||
const globalMaterials=new Map(direct(robot,'material').map(el=>[el.getAttribute('name'),el]));
|
||||
const links=direct(robot,'link').map(link=>({name:name(link.getAttribute('name')),visuals:direct(link,'visual').map((visual,index)=>{
|
||||
const geometry=direct(visual,'geometry')[0],mesh=geometry&&direct(geometry,'mesh')[0];if(!mesh)fail('当前机器人工具仅支持 STL 可视网格');
|
||||
const path=resourcePath(mesh.getAttribute('filename')??'');if(!path.toLowerCase().endsWith('.stl'))fail('当前机器人工具仅支持 STL 可视网格');
|
||||
const scale=vector(mesh.getAttribute('scale'),[1,1,1]) as Vec3;if(scale.some(v=>v<=0))fail('URDF 网格缩放必须大于零');
|
||||
const material=direct(visual,'material')[0],resolved=material&&(direct(material,'color').length?material:globalMaterials.get(material.getAttribute('name')));
|
||||
const color=vector(resolved?direct(resolved,'color')[0]?.getAttribute('rgba')??null:null,[.7372549,.3490196,.1607843,1],4) as RobotVisual['color'];
|
||||
if(color.some(v=>v<0||v>1))fail('URDF 颜色必须在 0 到 1 之间');
|
||||
return {path,...origins(visual),scale,color,name:`${name(link.getAttribute('name'))}_visual${index?'_'+index:''}`};
|
||||
})}));
|
||||
if(!links.length||links.length>128)fail('URDF link 数量必须在 1 到 128 之间');
|
||||
const ids=new Set(links.map(l=>l.name));if(ids.size!==links.length)fail('URDF link 名称重复');
|
||||
const joints=direct(robot,'joint').map(joint=>{
|
||||
const type=joint.getAttribute('type');if(!['fixed','revolute','continuous'].includes(type??'')||direct(joint,'mimic').length)fail('仅支持 fixed/revolute/continuous,不支持 mimic');
|
||||
const parent=name(direct(joint,'parent')[0]?.getAttribute('link')??null),child=name(direct(joint,'child')[0]?.getAttribute('link')??null);
|
||||
if(!ids.has(parent)||!ids.has(child)||parent===child)fail('URDF parent/child 引用无效');
|
||||
const axis=vector(direct(joint,'axis')[0]?.getAttribute('xyz')??null,[1,0,0]) as Vec3;
|
||||
if(type!=='fixed'&&Math.hypot(...axis)<1e-8)fail('URDF 关节轴不能为零');
|
||||
const limit=direct(joint,'limit')[0];
|
||||
if(type==='revolute'&&(!limit?.hasAttribute('lower')||!limit.hasAttribute('upper')))fail('revolute 关节缺少限位');
|
||||
const lower=type==='revolute'?Number(limit.getAttribute('lower')):-Math.PI*2,upper=type==='revolute'?Number(limit.getAttribute('upper')):Math.PI*2;
|
||||
if(!Number.isFinite(lower)||!Number.isFinite(upper)||lower>=upper)fail('URDF 关节限位无效');
|
||||
return {name:name(joint.getAttribute('name')),type:type as RobotJoint['type'],parent,child,...origins(joint),axis,lower,upper};
|
||||
});
|
||||
if(joints.filter(j=>j.type!=='fixed').length!==6)fail('需要恰好 6 个转动关节');
|
||||
if(new Set(joints.map(j=>j.name)).size!==joints.length||new Set(joints.map(j=>j.child)).size!==joints.length)fail('URDF 关节重复或一个 link 有多个父级');
|
||||
const roots=links.filter(l=>!joints.some(j=>j.child===l.name));if(roots.length!==1||joints.length!==links.length-1)fail('URDF 必须为单根树结构');
|
||||
const seen=new Set<string>();const visit=(id:string)=>{if(seen.has(id))fail('URDF 层级存在环');seen.add(id);for(const j of joints.filter(j=>j.parent===id))visit(j.child);};visit(roots[0].name);
|
||||
if(seen.size!==links.length)fail('URDF 存在未连接或环状 link');
|
||||
const robotName=name(robot.getAttribute('name')),root='Robot_'+robotName;
|
||||
const all=[root,...links.map(l=>l.name),...links.flatMap(l=>l.visuals.map(v=>v.name))];if(new Set(all).size!==all.length)fail('URDF 生成的对象名称冲突');
|
||||
return {name:robotName,root,rootLink:roots[0].name,urdf:source,links,joints};
|
||||
}
|
||||
|
||||
export function resolveRobotFiles(model:RobotModel,files:File[]):{file:File;path:string}[]{
|
||||
const paths=[...new Set(model.links.flatMap(l=>l.visuals.map(v=>v.path)))];
|
||||
if(!paths.length)fail('URDF 没有可视网格');
|
||||
return paths.map(path=>{
|
||||
const exact=files.filter(file=>file.webkitRelativePath===path||file.webkitRelativePath.endsWith('/'+path));
|
||||
const candidates=exact.length?exact:files.filter(file=>file.name===path.split('/').at(-1));
|
||||
if(candidates.length!==1)fail(`${candidates.length?'资源重名,请选择目录':'缺少资源'}: ${path}`);
|
||||
if(candidates[0].size<84)fail('STL 文件为空或不完整: '+path);
|
||||
return {file:candidates[0],path};
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user