@ -3,501 +3,230 @@
< div ref = "viewerContainer" class = "viewer-container" >
<!-- Three . js 渲染 -- >
< / div >
< div v-if ="isLoading" class="loading-overlay" >
< div class = "spinner" > < / div >
< p > 正在加载机器人模型 ... < / p >
< p class = "loading-detail" > { { loadingMessage } } < / p >
< / div >
< div class = "control-panel" v-if ="!isLoading && jointControls.length > 0" >
< h4 > 关节控制 < / h4 >
< div class = "joint-list" >
< div v-for ="joint in jointControls" :key="joint.name" class="joint-item" >
< label :for ="joint.name" > { { joint . displayName } } < / label >
< input
type = "range"
: id = "joint.name"
: min = "joint.min"
: max = "joint.max"
: step = "0.01"
v - model . number = "joint.value"
@ input = "updateJoint(joint.name, joint.value)"
/ >
< span class = "angle" > { { ( joint . value * 180 / Math . PI ) . toFixed ( 1 ) } } ° < / span >
< / div >
< / div >
< button @click ="resetJoints" class = "reset-btn" > 重置关节 < / button >
< / div >
< / div >
< / template >
< script setup >
import { ref, onMounted , onBeforeUnmount , nextTick , reactive } from 'vue' ;
import { computed , ref , onMounted , onBeforeUnmount , nextTick , reactive } from 'vue' ;
import * as THREE from 'three' ;
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js' ;
import { STLLoader } from 'three/examples/jsm/loaders/STLLoader.js' ;
import URDFLoader from 'urdf-loader' ;
import { XacroAdapter } from '@/adapters/XacroAdapter.js'
import { URDFAdapter } from '@/adapters/URDFAdapter.js'
const props = defineProps ( {
basePath : { type : String , default : '/inspection/elfin10' } ,
modelColor : { type : String , default : '#cbcbcb' } ,
showControls : { type : Boolean , default : true }
} ) ;
modelPath : {
type : String ,
default : '/inspection/elfin10/elfin10.urdf.xacro'
} ,
filePath : {
type : String ,
default : ''
} ,
fileName : {
type : String ,
default : ''
}
} )
const viewerContainer = ref ( null ) ;
const isLoading = ref ( true ) ;
const loadingMessage = ref ( '初始化...' ) ;
let scene , camera , renderer , controls ;
let robotGroup = null ;
const jointMap = new Map ( ) ;
const animationId = ref ( null ) ;
const jointControls = reactive ( [ ] ) ;
/ / = = = = = = = = = = = = X A C R O 解 析 器 = = = = = = = = = = = =
class XacroParser {
constructor ( ) {
this . variables = new Map ( ) ;
this . macros = new Map ( ) ;
this . includes = [ ] ;
this . robotXml = null ;
}
parse ( xacroContent ) {
const parser = new DOMParser ( ) ;
const xmlDoc = parser . parseFromString ( xacroContent , 'text/xml' ) ;
const properties = xmlDoc . getElementsByTagName ( 'xacro:property' ) ;
for ( const prop of properties ) {
const name = prop . getAttribute ( 'name' ) ;
const value = prop . getAttribute ( 'value' ) ;
if ( name && value ) this . variables . set ( name , this . evaluateExpression ( value ) ) ;
}
const includes = xmlDoc . getElementsByTagName ( 'xacro:include' ) ;
for ( const inc of includes ) {
const filename = inc . getAttribute ( 'filename' ) ;
if ( filename ) this . includes . push ( filename ) ;
}
const macros = xmlDoc . getElementsByTagName ( 'xacro:macro' ) ;
for ( const macro of macros ) {
const name = macro . getAttribute ( 'name' ) ;
const params = macro . getAttribute ( 'params' ) || '' ;
if ( name ) {
this . macros . set ( name , {
params : params . split ( /\s+/ ) . filter ( p => p ) ,
content : macro . innerHTML
} ) ;
}
}
this . robotXml = this . processRobotXml ( xmlDoc ) ;
return this . robotXml ;
}
processRobotXml ( xmlDoc ) {
const doc = xmlDoc . cloneNode ( true ) ;
const allElements = Array . from ( doc . getElementsByTagName ( '*' ) ) ;
for ( const elem of allElements ) {
if ( elem . tagName . toLowerCase ( ) . startsWith ( 'xacro:' ) ) {
elem . parentNode ? . removeChild ( elem ) ;
}
}
const remaining = Array . from ( doc . getElementsByTagName ( '*' ) ) ;
for ( const elem of remaining ) {
for ( const attr of elem . attributes ) {
if ( attr . value ? . includes ( '${' ) ) attr . value = this . replaceVariables ( attr . value ) ;
}
if ( elem . textContent ? . includes ( '${' ) ) elem . textContent = this . replaceVariables ( elem . textContent ) ;
}
return doc ;
}
replaceVariables ( text ) {
return text . replace ( /\$\{([^}]+)\}/g , ( match , expr ) => this . evaluateExpression ( expr ) ) ;
}
evaluateExpression ( expr ) {
expr = expr . trim ( ) ;
if ( ! expr ) return '0' ;
try {
for ( const [ key , value ] of this . variables ) {
if ( key . length === 0 ) continue ;
const regex = new RegExp ( '\\b' + this . escapeRegex ( key ) + '\\b' , 'g' ) ;
expr = expr . replace ( regex , '(' + value + ')' ) ;
}
expr = expr . replace ( /\bPI\b/g , '(' + Math . PI + ')' ) ;
expr = expr . replace ( /\bpi\b/g , '(' + Math . PI + ')' ) ;
const sanitized = expr . replace ( /\s+/g , '' ) ;
if ( /^[\d+\-*/().eE]+$/ . test ( sanitized ) ) {
const result = Function ( '"use strict"; return (' + sanitized + ')' ) ( ) ;
if ( typeof result === 'number' && isFinite ( result ) ) return result . toString ( ) ;
}
console . warn ( '表达式求值失败,返回原值:' , expr ) ;
return expr ;
} catch ( e ) {
console . warn ( '表达式求值异常:' , expr , e ) ;
return expr ;
}
}
escapeRegex ( str ) {
return str . replace ( /[.*+?^${}()|[\]\\]/g , '\\$&' ) ;
}
getRobotXml ( ) { return this . robotXml ; }
getIncludes ( ) { return this . includes ; }
const normalizePublicPath = ( path ) => {
let normalized = path . trim ( ) . replace ( /\\/g , '/' )
normalized = normalized . replace ( /^[a-zA-Z]:.*?\/public\// , '/' )
normalized = normalized . replace ( /^\.?\/?public\// , '/' )
return normalized . startsWith ( '/' ) ? normalized : ` / ${ normalized } `
}
/ / = = = = = = = = = = = = 工 具 函 数 = = = = = = = = = = = =
const cleanMeshPath = ( path ) => {
if ( ! path ) return '' ;
path = path . replace ( /^file:\/\// , '' ) ;
path = path . replace ( /^package:\/\/[^/]+\// , '' ) ;
const findMatch = path . match ( /\$\(find\s+([^)]+)\)/ ) ;
if ( findMatch ) path = path . replace ( /\$\(find\s+[^)]+\)/ , '' ) ;
path = path . replace ( /\\/g , '/' ) . replace ( /^\/+/ , '' ) ;
return path ;
} ;
const modelUrl = computed ( ( ) => normalizePublicPath (
props . filePath && props . fileName
? ` ${ props . filePath . replace ( /[\\/]?$/ , '/' ) } ${ props . fileName } `
: props . modelPath
) )
const parseXYZ = ( str ) => {
if ( ! str ) return { x : 0 , y : 0 , z : 0 } ;
const [ x , y , z ] = str . trim ( ) . split ( /\s+/ ) . map ( Number ) ;
return { x : x || 0 , y : y || 0 , z : z || 0 } ;
} ;
const modelDirectory = computed ( ( ) => modelUrl . value . slice ( 0 , modelUrl . value . lastIndexOf ( '/' ) + 1 ) )
const parseRPY = ( str ) => {
if ( ! str ) return { roll : 0 , pitch : 0 , yaw : 0 } ;
const [ r , p , y ] = str . trim ( ) . split ( /\s+/ ) . map ( Number ) ;
return { roll : r || 0 , pitch : p || 0 , yaw : y || 0 } ;
} ;
const viewerContainer = ref ( null ) ;
/ / ★ 正 确 转 换 R P Y → 四 元 数 ( U R D F 固 定 轴 X Y Z = T h r e e . j s 局 部 轴 Z Y X )
const rpyToQuaternion = ( rpy ) => {
const euler = new THREE . Euler ( rpy . roll , rpy . pitch , rpy . yaw , 'ZYX' ) ;
return new THREE . Quaternion ( ) . setFromEuler ( euler ) ;
} ;
let scene , camera , renderer , controls ;
/ / = = = = = = = = = = = = S T L 加 载 = = = = = = = = = = = =
const loadSTLWithRetry = ( url , group , visualPos , visualQuat ) => {
return new Promise ( ( resolve ) => {
const loader = new STLLoader ( ) ;
const loadMesh = ( attemptUrl ) => {
loader . load (
attemptUrl ,
( geometry ) => {
geometry . computeVertexNormals ( ) ;
const material = new THREE . MeshStandardMaterial ( {
color : props . modelColor ,
roughness : 0.4 ,
metalness : 0.3 ,
} ) ;
const mesh = new THREE . Mesh ( geometry , material ) ;
mesh . position . set ( visualPos . x , visualPos . y , visualPos . z ) ;
mesh . quaternion . copy ( visualQuat ) ; / / 使 用 四 元 数
mesh . castShadow = true ;
mesh . receiveShadow = true ;
while ( group . children . length > 0 ) {
const child = group . children [ 0 ] ;
if ( child . isMesh ) {
child . geometry ? . dispose ( ) ;
child . material ? . dispose ( ) ;
}
group . remove ( child ) ;
}
group . add ( mesh ) ;
resolve ( mesh ) ;
} ,
undefined ,
( error ) => {
console . warn ( ` 加载STL失败: ${ attemptUrl } ` , error ) ;
if ( ! attemptUrl . startsWith ( '/' ) && ! attemptUrl . startsWith ( 'http' ) ) {
loadMesh ( '/' + attemptUrl ) ;
} else {
resolve ( null ) ;
}
}
) ;
} ;
loadMesh ( url ) ;
} ) ;
} ;
/ / = = = = = = = = = = = = U R D F 解 析 与 构 建 ( 严 格 修 复 ) = = = = = = = = = = = =
const parseAndBuildRobot = async ( urdfDoc ) => {
const links = urdfDoc . getElementsByTagName ( 'link' ) ;
const jointsRaw = urdfDoc . getElementsByTagName ( 'joint' ) ;
const joints = sortJointsTopologically ( jointsRaw , links ) ;
const linkGroupMap = new Map ( ) ;
const mainGroup = new THREE . Group ( ) ;
mainGroup . name = 'robot' ;
loadingMessage . value = '创建链接结构...' ;
for ( const link of links ) {
const name = link . getAttribute ( 'name' ) ;
const group = new THREE . Group ( ) ;
group . name = name ;
const visual = link . getElementsByTagName ( 'visual' ) [ 0 ] ;
if ( visual ) {
const origin = visual . getElementsByTagName ( 'origin' ) [ 0 ] ;
const geometry = visual . getElementsByTagName ( 'geometry' ) [ 0 ] ;
const meshTag = geometry ? . getElementsByTagName ( 'mesh' ) [ 0 ] ;
if ( meshTag ) {
let meshPath = cleanMeshPath ( meshTag . getAttribute ( 'filename' ) ) ;
let visualPos = { x : 0 , y : 0 , z : 0 } ;
let visualQuat = new THREE . Quaternion ( ) ;
if ( origin ) {
const rawXyz = parseXYZ ( origin . getAttribute ( 'xyz' ) || '0 0 0' ) ;
const rawRpy = parseRPY ( origin . getAttribute ( 'rpy' ) || '0 0 0' ) ;
visualPos = rawXyz ;
visualQuat = rpyToQuaternion ( rawRpy ) ; / / ★ 使 用 正 确 顺 序
}
group . userData = {
meshPath : ` ${ props . basePath } ${ meshPath } ` ,
visualPos ,
visualQuat
} ;
}
}
linkGroupMap . set ( name , group ) ;
mainGroup . add ( group ) ;
}
loadingMessage . value = '加载3D模型文件...' ;
const loadPromises = [ ] ;
for ( const [ name , group ] of linkGroupMap ) {
if ( group . userData . meshPath ) {
loadPromises . push (
loadSTLWithRetry ( group . userData . meshPath , group , group . userData . visualPos , group . userData . visualQuat )
) ;
}
}
await Promise . allSettled ( loadPromises ) ;
loadingMessage . value = '构建关节结构...' ;
const jointDataList = [ ] ;
for ( const joint of joints ) {
const type = joint . getAttribute ( 'type' ) ;
const name = joint . getAttribute ( 'name' ) ;
const parent = joint . getElementsByTagName ( 'parent' ) [ 0 ] ? . getAttribute ( 'link' ) ;
const child = joint . getElementsByTagName ( 'child' ) [ 0 ] ? . getAttribute ( 'link' ) ;
const origin = joint . getElementsByTagName ( 'origin' ) [ 0 ] ;
let xyz = { x : 0 , y : 0 , z : 0 } ;
let rpy = { roll : 0 , pitch : 0 , yaw : 0 } ;
if ( origin ) {
xyz = parseXYZ ( origin . getAttribute ( 'xyz' ) || '0 0 0' ) ;
rpy = parseRPY ( origin . getAttribute ( 'rpy' ) || '0 0 0' ) ;
}
const axis = joint . getElementsByTagName ( 'axis' ) [ 0 ] ;
const axisVec = parseXYZ ( axis ? . getAttribute ( 'xyz' ) || '0 0 1' ) ;
const limit = joint . getElementsByTagName ( 'limit' ) [ 0 ] ;
const min = limit ? parseFloat ( limit . getAttribute ( 'lower' ) ) : - Math . PI ;
const max = limit ? parseFloat ( limit . getAttribute ( 'upper' ) ) : Math . PI ;
jointDataList . push ( {
name , type , parent , child , xyz , rpy ,
axis : new THREE . Vector3 ( axisVec . x , axisVec . y , axisVec . z ) ,
min , max
} ) ;
}
/ / 构 建 层 级 ( E u l e r 顺 序 Z Y X )
for ( const jointData of jointDataList ) {
const { name , type , parent , child , xyz , rpy , axis , min , max } = jointData ;
const parentGroup = linkGroupMap . get ( parent ) ;
const childGroup = linkGroupMap . get ( child ) ;
if ( ! parentGroup || ! childGroup ) {
console . warn ( ` 关节 ${ name } 的父或子链接不存在 ` ) ;
continue ;
}
if ( mainGroup . children . includes ( childGroup ) ) mainGroup . remove ( childGroup ) ;
if ( parentGroup . children . includes ( childGroup ) ) parentGroup . remove ( childGroup ) ;
if ( type === 'revolute' || type === 'continuous' ) {
const varGroup = new THREE . Group ( ) ;
varGroup . name = name + '_var' ;
const originGroup = new THREE . Group ( ) ;
originGroup . name = name + '_origin' ;
originGroup . position . set ( xyz . x , xyz . y , xyz . z ) ;
/ / ★ 使 用 正 确 顺 序
const euler = new THREE . Euler ( rpy . roll , rpy . pitch , rpy . yaw , 'ZYX' ) ;
originGroup . quaternion . setFromEuler ( euler ) ;
originGroup . add ( childGroup ) ;
varGroup . add ( originGroup ) ;
jointMap . set ( name , {
name ,
group : varGroup ,
axis : axis . clone ( ) . normalize ( ) , / / 仍 为 Z - u p 轴 , 稍 后 在 l o a d R o b o t 中 统 一 变 换
min , max ,
value : 0 ,
displayName : name . replace ( /_/g , ' ' ) . replace ( 'elfin ' , '' )
} ) ;
parentGroup . add ( varGroup ) ;
} else {
/ / 固 定 关 节
childGroup . position . set ( xyz . x , xyz . y , xyz . z ) ;
const euler = new THREE . Euler ( rpy . roll , rpy . pitch , rpy . yaw , 'ZYX' ) ;
childGroup . quaternion . setFromEuler ( euler ) ;
parentGroup . add ( childGroup ) ;
}
}
return mainGroup ;
} ;
/ / = = = = = = = = = = = = 拓 扑 排 序 = = = = = = = = = = = =
const sortJointsTopologically = ( joints , links ) => {
const childToJoint = new Map ( ) ;
const allLinkNames = new Set ( ) ;
for ( const link of links ) allLinkNames . add ( link . getAttribute ( 'name' ) ) ;
const jointArray = Array . from ( joints ) ;
for ( const joint of jointArray ) {
const child = joint . getElementsByTagName ( 'child' ) [ 0 ] ? . getAttribute ( 'link' ) ;
if ( child ) childToJoint . set ( child , joint ) ;
}
const childLinks = new Set ( childToJoint . keys ( ) ) ;
const rootLinks = [ ... allLinkNames ] . filter ( name => ! childLinks . has ( name ) ) ;
const sorted = [ ] ;
const queue = [ ... rootLinks ] ;
const visited = new Set ( ) ;
while ( queue . length > 0 ) {
const linkName = queue . shift ( ) ;
for ( const joint of jointArray ) {
const parent = joint . getElementsByTagName ( 'parent' ) [ 0 ] ? . getAttribute ( 'link' ) ;
const child = joint . getElementsByTagName ( 'child' ) [ 0 ] ? . getAttribute ( 'link' ) ;
if ( parent === linkName && ! visited . has ( joint . getAttribute ( 'name' ) ) ) {
visited . add ( joint . getAttribute ( 'name' ) ) ;
sorted . push ( joint ) ;
if ( child ) queue . push ( child ) ;
}
}
}
for ( const joint of jointArray ) {
if ( ! visited . has ( joint . getAttribute ( 'name' ) ) ) sorted . push ( joint ) ;
}
return sorted ;
} ;
const animationId = ref ( null ) ;
const jointControls = reactive ( [ ] ) ;
/ / = = = = = = = = = = = = 场 景 初 始 化 = = = = = = = = = = = =
const initScene = ( ) => {
const container = viewerContainer . value ;
const width = container . clientWidth || 800 ;
const height = container . clientHeight || 600 ;
scene = new THREE . Scene ( ) ;
scene . background = new THREE . Color ( 0x1a1a2e ) ;
camera = new THREE . PerspectiveCamera ( 45 , width / height , 0.1 , 1000 ) ;
camera . position . set ( 2 , 2 , 3 ) ;
scene . background = new THREE . Color ( '#050a14' ) ;
camera = new THREE . PerspectiveCamera ( 35 , width / height , 0.1 , 1000 ) ;
camera . position . set ( 0 , 0 , 4 ) ;
renderer = new THREE . WebGLRenderer ( { antialias : true , alpha : true } ) ;
renderer . setSize ( width , height ) ;
renderer . setPixelRatio ( Math . min ( window . devicePixelRatio , 2 ) ) ;
renderer . shadowMap . enabled = true ;
renderer . shadowMap . type = THREE . PCFSoftShadowMap ;
renderer . toneMapping = THREE . ACESFilmicToneMapping ;
renderer . toneMappingExposure = 1.2 ;
container . appendChild ( renderer . domElement ) ;
controls = new OrbitControls ( camera , renderer . domElement ) ;
controls . enableDamping = true ;
controls . dampingFactor = 0.1 ;
controls . target . set ( 0 , 0.5 , 0 ) ;
controls . update ( ) ;
const ambient = new THREE . AmbientLight ( 0x 404060, 0.6 ) ;
const ambient = new THREE . AmbientLight ( 0xffffff , 0.6 )
scene . add ( ambient ) ;
const main = new THREE . DirectionalLight ( 0xffffff , 1.0 ) ;
main . position . set ( 5 , 10 , 7 ) ;
main . castShadow = true ;
scene . add ( main ) ;
const fill = new THREE . DirectionalLight ( 0x8888ff , 0.4 ) ;
fill . position . set ( - 5 , 0 , 5 ) ;
scene . add ( fill ) ;
const rim = new THREE . DirectionalLight ( 0xffffff , 0.3 ) ;
rim . position . set ( 0 , - 5 , 5 ) ;
scene . add ( rim ) ;
const grid = new THREE . GridHelper ( 3 , 20 , 0x444466 , 0x333355 ) ;
grid . position . y = - 0.5 ;
scene . add ( grid ) ;
const directionalLight = new THREE . DirectionalLight ( 0xffffff , 1.0 ) ;
directionalLight . position . set ( 5 , 10 , 7 ) ;
directionalLight . castShadow = true ;
scene . add ( directionalLight ) ;
} ;
/ / = = = = = = = = = = = = 关 节 控 制 ( 无 需 修 改 ) = = = = = = = = = = = =
const updateJoint = ( jointName , angle ) => {
const joint = jointMap . get ( jointName ) ;
if ( ! joint ) return ;
const clampedAngle = Math . max ( joint . min , Math . min ( joint . max , angle ) ) ;
joint . value = clampedAngle ;
if ( ! robotInstance ) return ;
joint . group . quaternion . identity ( ) ;
const axis = joint . axis . clone ( ) . normalize ( ) ;
joint . group . quaternion . setFromAxisAngle ( axis , clampedAngle ) ; / / 正 负 根 据 实 际 情 况 调 整
const joint = robotInstance . joints [ jointName ] ;
const control = jointControls . find ( j => j . name === jointName ) ;
if ( control ) control . value = clampedAngle ;
if ( ! joint ) {
console . warn ( ` 未找到关节: ${ jointName } ` ) ;
return ;
}
/ / ✅ 使 用 s e t J o i n t V a l u e 触 发 变 换 矩 阵 更 新
if ( joint . _jointType === 'revolute' || joint . _jointType === 'continuous' ) {
joint . setJointValue ( angle ) ;
} else if ( joint . _jointType === 'prismatic' ) {
joint . setJointValue ( angle ) ;
}
/ / 同 步 滑 块 显 示 值
const control = jointControls . find ( c => c . name === jointName ) ;
if ( control ) control . value = angle ;
} ;
const resetJoints = ( ) => {
for ( const joint of jointControls ) updateJoint ( joint . name , 0 ) ;
} ;
/ / = = = = = = = = = = = = 加 载 机 器 人 ( 轴 变 换 加 入 ) = = = = = = = = = = = =
const loadRobot = async ( ) => {
try {
isLoading . value = true ;
loadingMessage . value = '加载XACRO文件...' ;
const xacroUrl = ` ${ props . basePath } elfin10.urdf.xacro ` ;
const response = await fetch ( xacroUrl ) ;
if ( ! response . ok ) throw new Error ( ` HTTP ${ response . status } ` ) ;
const xacroContent = await response . text ( ) ;
loadingMessage . value = '解析XACRO...' ;
const parser = new XacroParser ( ) ;
const robotDoc = parser . parse ( xacroContent ) ;
loadingMessage . value = '构建机器人模型...' ;
robotGroup = await parseAndBuildRobot ( robotDoc ) ;
if ( robotGroup ) {
scene . add ( robotGroup ) ;
/ / ★ 全 局 旋 转 : Z - u p → Y - u p
robotGroup . rotation . x = - Math . PI / 2 ;
/ / ★ 关 键 : 将 所 有 关 节 轴 从 Z - u p 坐 标 系 变 换 到 Y - u p 坐 标 系
const globalQuat = new THREE . Quaternion ( ) . setFromEuler (
new THREE . Euler ( - Math . PI / 2 , 0 , 0 , 'XYZ' )
) ;
const globalQuat1 = new THREE . Quaternion ( ) . setFromEuler (
new THREE . Euler ( - Math . PI / 2 , 0 , 0 , 'ZXY' )
) ;
for ( const joint of jointMap . values ( ) ) {
if ( [ 'joint2' , 'joint6' ] . includes ( joint . displayName ) ) {
joint . axis . applyQuaternion ( globalQuat ) . normalize ( ) ;
}
if ( [ 'joint3' , 'joint4' , 'joint5' ] . includes ( joint . displayName ) ) {
joint . axis . applyQuaternion ( globalQuat1 ) . normalize ( ) ;
}
}
jointControls . length = 0 ;
for ( const [ name , joint ] of jointMap ) {
jointControls . push ( {
name ,
displayName : joint . displayName ,
value : 0 ,
min : joint . min ,
max : joint . max
} ) ;
}
fitCamera ( ) ;
}
isLoading . value = false ;
loadingMessage . value = '加载完成' ;
} catch ( error ) {
console . error ( '加载机器人失败:' , error ) ;
isLoading . value = false ;
loadingMessage . value = ` 加载失败: ${ error . message } ` ;
for ( const joint of jointControls ) {
joint . value = 0 ;
updateJoint ( joint . name , 0 ) ;
}
} ;
const fitCamera = ( ) => {
if ( ! robotGroup ) return ;
const box = new THREE . Box3 ( ) . setFromObject ( robotGroup ) ;
const center = box . getCenter ( new THREE . Vector3 ( ) ) ;
const size = box . getSize ( new THREE . Vector3 ( ) ) ;
const maxDim = Math . max ( size . x , size . y , size . z ) ;
if ( maxDim === 0 ) return ;
const distance = maxDim / ( 2 * Math . tan ( ( camera . fov * Math . PI ) / 360 ) ) ;
const direction = new THREE . Vector3 ( 1 , 1 , 1 ) . normalize ( ) ;
camera . position . copy ( center ) . add ( direction . multiplyScalar ( distance * 1.5 ) ) ;
controls . target . copy ( center ) ;
controls . update ( ) ;
const fileMap = new Map ( )
const resolveReferencedUrl = ( reference , parentUrl ) => {
const normalized = reference . replace ( /\\/g , '/' )
const rosPath = normalized . match ( /^(?:file:\/\/)?\$\(find\s+[^)]+\)\/(.+)$/ )
if ( rosPath ) return ` ${ modelDirectory . value } ${ rosPath [ 1 ] } `
const packagePath = normalized . match ( /^package:\/\/[^/]+\/(.+)$/ )
if ( packagePath ) return ` ${ modelDirectory . value } ${ packagePath [ 1 ] } `
if ( /^(?:https?:|blob:|data:)/i . test ( normalized ) ) return normalized
return new URL ( normalized , new URL ( parentUrl , window . location . origin ) ) . pathname
}
const loadFiles = async ( ) => {
fileMap . clear ( )
if ( ! modelUrl . value . toLowerCase ( ) . endsWith ( '.xacro' ) ) return
const pendingUrls = [ modelUrl . value ]
const visitedUrls = new Set ( )
while ( pendingUrls . length ) {
const url = pendingUrls . shift ( )
if ( visitedUrls . has ( url ) ) continue
visitedUrls . add ( url )
const response = await fetch ( url )
if ( ! response . ok ) throw new Error ( ` 加载模型依赖失败: ${ url } (HTTP ${ response . status } ) ` )
const blob = await response . blob ( )
const fileName = url . split ( '/' ) . pop ( )
const file = new File ( [ blob ] , fileName , { type : blob . type } )
fileMap . set ( url , file )
if ( ! /\.(?:xacro|urdf|xml)$/i . test ( fileName ) ) continue
const content = await blob . text ( )
const references = [ ... content . matchAll ( /\b(?:filename|url)=["']([^"']+)["']/gi ) ]
references . forEach ( ( [ , reference ] ) => {
const referencedUrl = resolveReferencedUrl ( reference , url )
if ( ! visitedUrls . has ( referencedUrl ) ) pendingUrls . push ( referencedUrl )
} )
}
}
const currentModel = ref ( )
let robotInstance = null ; / / 保 存 U R D F R o b o t 对 象
const parseUrdf = async ( urdfContent ) => {
const loader = new URDFLoader ( )
loader . parseCollision = true
const packageNames = [ ... urdfContent . matchAll ( /package:\/\/([^/]+)/g ) ] . map ( match => match [ 1 ] )
loader . packages = Object . fromEntries (
[ ... new Set ( packageNames ) ] . map ( packageName => [ packageName , ` ${ modelDirectory . value } ${ packageName } ` ] )
)
const robot = loader . parse ( urdfContent , modelDirectory . value )
return URDFAdapter . convert ( robot , urdfContent )
}
const loadRobot = async ( ) => {
const response = await fetch ( modelUrl . value ) ;
if ( ! response . ok ) throw new Error ( ` 加载模型失败: ${ modelUrl . value } (HTTP ${ response . status } ) ` ) ;
const content = await response . text ( ) ;
const fileName = modelUrl . value . split ( '/' ) . pop ( )
const lowerCaseFileName = fileName . toLowerCase ( )
let model
if ( lowerCaseFileName . endsWith ( '.xacro' ) ) {
const file = fileMap . get ( modelUrl . value ) || new File ( [ content ] , fileName , { type : 'text/xml' } )
model = await XacroAdapter . parse ( content , file . name , fileMap , file ) ;
} else if ( lowerCaseFileName . endsWith ( '.urdf' ) ) {
model = await parseUrdf ( content )
} else {
throw new Error ( ` 不支持的模型格式: ${ fileName } ,仅支持 .xacro 和 .urdf ` )
}
const robot = model . threeObject ;
/ / 将 机 器 人 添 加 到 场 景
scene . add ( robot ) ;
robot . rotation . x = - Math . PI / 2 ;
currentModel . value = model
robotInstance = robot ;
/ / 获 取 关 节 条 目 数 组
let jointEntries = [ ] ;
if ( robot . joints instanceof Map ) {
jointEntries = [ ... robot . joints . entries ( ) ] ;
} else if ( typeof robot . joints === 'object' && robot . joints !== null ) {
jointEntries = Object . entries ( robot . joints ) ;
} else if ( Array . isArray ( robot . joints ) ) {
jointEntries = robot . joints . map ( ( j , i ) => [ i , j ] ) ; / / 索 引 作 为 名 称
} else {
console . error ( '未知的 joints 结构:' , robot . joints ) ;
}
/ / 填 充 关 节 控 制 列 表
for ( const [ name , joint ] of jointEntries ) {
if ( joint . _jointType === 'fixed' ) continue ;
const limits = joint . limits || { } ;
let lower = limits . lower !== undefined ? limits . lower : - Math . PI ;
let upper = limits . upper !== undefined ? limits . upper : Math . PI ;
/ / 如 果 是 连 续 关 节 , 范 围 设 为 - P I ~ P I
if ( joint . _jointType === 'continuous' ) {
lower = - Math . PI ;
upper = Math . PI ;
}
/ / 初 始 角 度 一 般 为 0
jointControls . push ( {
name ,
min : lower ,
max : upper ,
value : 0 , / / 因 为 j o i n t . a n g l e 初 始 为 0
} ) ;
}
} ;
/ / = = = = = = = = = = = = 动 画 循 环 = = = = = = = = = = = =
@ -510,6 +239,7 @@ const animate = () => {
/ / = = = = = = = = = = = = 生 命 周 期 = = = = = = = = = = = =
onMounted ( async ( ) => {
await nextTick ( ) ;
await loadFiles ( )
initScene ( ) ;
await loadRobot ( ) ;
animate ( ) ;
@ -533,56 +263,30 @@ const onResize = () => {
renderer . setSize ( width , height ) ;
} ;
defineExpose ( { updateJoint , resetJoints , jointControls , jointMap } ) ;
defineExpose ( { updateJoint , resetJoints , jointControls } ) ;
< / script >
< style scoped>
< style lang= "scss" scoped>
. elfin - viewer {
width : 100 % ;
height : 100 % ;
position : relative ;
overflow : hidden ;
background : # 0 d0d1a ;
background : # 050 a14 ;
. viewer - container {
width : 100 % ;
height : 100 % ;
canvas {
display : block ;
width : 100 % ! important ;
height : 100 % ! important ;
}
}
}
. viewer - container {
width : 100 % ;
height : 100 % ;
}
. viewer - container canvas {
display : block ;
width : 100 % ! important ;
height : 100 % ! important ;
}
. control - panel {
position : absolute ;
top : 20 px ;
right : 20 px ;
background : rgba ( 20 , 20 , 40 , 0.85 ) ;
padding : 15 px ;
border - radius : 8 px ;
color : # ccc ;
max - height : 80 % ;
overflow - y : auto ;
}
. joint - item {
display : flex ;
align - items : center ;
gap : 8 px ;
margin - bottom : 10 px ;
}
. joint - item label {
width : 120 px ;
font - size : 0.9 em ;
}
. joint - item input [ type = range ] {
flex : 1 ;
}
. angle {
width : 50 px ;
text - align : right ;
}
. reset - btn {
margin - top : 10 px ;
width : 100 % ;
}
< / style >
< / style >