feat: 模型控制
This commit is contained in:
parent
30c7f3c3e0
commit
85bf6feccc
2
.env
2
.env
@ -1,3 +1,3 @@
|
||||
VITE_DEVICE_ID = 'agv_src2200'
|
||||
|
||||
VITE_SERVICE_PORT = 3000
|
||||
VITE_SERVICE_PORT = 5175
|
||||
@ -30,9 +30,9 @@ COPY server/ ./server/
|
||||
# 从构建阶段拷贝前端产物
|
||||
COPY --from=builder /app/dist ./dist
|
||||
|
||||
EXPOSE 3001
|
||||
EXPOSE 5175
|
||||
|
||||
ENV NODE_ENV=production
|
||||
ENV VITE_SERVICE_PORT=3000
|
||||
ENV VITE_SERVICE_PORT=5175
|
||||
|
||||
CMD ["node", "server/index.js"]
|
||||
@ -5,10 +5,10 @@ services:
|
||||
dockerfile: Dockerfile
|
||||
container_name: inspection-host-computer
|
||||
ports:
|
||||
- "3000:3000" # 映射端口
|
||||
- "5175:5175" # 映射端口
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- VITE_SERVICE_PORT=3000 # 传给 Express 的环境变量
|
||||
- VITE_SERVICE_PORT=5175 # 传给 Express 的环境变量
|
||||
restart: unless-stopped # 容器意外停止后自动重启
|
||||
# 如果需要挂载日志或配置,可以加 volumes
|
||||
# volumes:
|
||||
|
||||
@ -23,8 +23,11 @@
|
||||
"fast-glob": "^3.3.3",
|
||||
"pinia": "^3.0.4",
|
||||
"pinia-plugin-persistedstate": "^4.7.1",
|
||||
"three": "^0.181.2",
|
||||
"urdf-loader": "^0.12.6",
|
||||
"vue": "^3.5.38",
|
||||
"vue-router": "^5.1.0"
|
||||
"vue-router": "^5.1.0",
|
||||
"xacro-parser": "^0.3.11"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^6.0.7",
|
||||
|
||||
38
pnpm-lock.yaml
generated
38
pnpm-lock.yaml
generated
@ -41,12 +41,21 @@ importers:
|
||||
pinia-plugin-persistedstate:
|
||||
specifier: ^4.7.1
|
||||
version: 4.7.1(pinia@3.0.4(vue@3.5.38))
|
||||
three:
|
||||
specifier: ^0.181.2
|
||||
version: 0.181.2
|
||||
urdf-loader:
|
||||
specifier: ^0.12.6
|
||||
version: 0.12.7(three@0.181.2)
|
||||
vue:
|
||||
specifier: ^3.5.38
|
||||
version: 3.5.38
|
||||
vue-router:
|
||||
specifier: ^5.1.0
|
||||
version: 5.1.0(@vue/compiler-sfc@3.5.38)(pinia@3.0.4(vue@3.5.38))(vite@8.1.0(@types/node@26.0.0)(sass@1.101.0)(yaml@2.9.0))(vue@3.5.38)
|
||||
xacro-parser:
|
||||
specifier: ^0.3.11
|
||||
version: 0.3.11
|
||||
devDependencies:
|
||||
'@vitejs/plugin-vue':
|
||||
specifier: ^6.0.7
|
||||
@ -963,6 +972,10 @@ packages:
|
||||
resolution: {integrity: sha512-w/ozOKR9Obk3qoWeY/WDi6MFta9AoMR+zud60mdnbniMcBxRuFJyDt2LdX/14A1UABeqk+Uk+LDfUpvoGKppZA==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
expr-eval-fork@3.0.3:
|
||||
resolution: {integrity: sha512-BhC+hbc5lIVjygr840n5DEkW3MQq7H9o+mc1/N7Z5uIiCFVyESLL5DIE7LNq4CYUNxy+XjA+3jRrL/h0Kt2xcg==}
|
||||
engines: {node: '>=16.9.0'}
|
||||
|
||||
express@5.2.1:
|
||||
resolution: {integrity: sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==}
|
||||
engines: {node: '>= 18'}
|
||||
@ -2045,6 +2058,9 @@ packages:
|
||||
engines: {node: '>=14.0.0'}
|
||||
hasBin: true
|
||||
|
||||
three@0.181.2:
|
||||
resolution: {integrity: sha512-k/CjiZ80bYss6Qs7/ex1TBlPD11whT9oKfT8oTGiHa34W4JRd1NiH/Tr1DbHWQ2/vMUypxksLnF2CfmlmM5XFQ==}
|
||||
|
||||
tinyglobby@0.2.17:
|
||||
resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==}
|
||||
engines: {node: '>=12.0.0'}
|
||||
@ -2168,6 +2184,11 @@ packages:
|
||||
resolution: {integrity: sha512-PcA2tsuGSF9cnySLHTLSh2qrQiJ70mn+r+Glzxv2TWZblxsxCC52BDlZoPCsz7STd9pN7EZetkWZBAvk4cgZdQ==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
urdf-loader@0.12.7:
|
||||
resolution: {integrity: sha512-9RQDhWt4x9K6R3uHjv68NLwX+JDm3A2XjhksrMgq6xl9NJcwqV21+htHU0cbG619hnqH1d4mUMFuqHo22R8DMA==}
|
||||
peerDependencies:
|
||||
three: '>=0.152.0'
|
||||
|
||||
urix@0.1.0:
|
||||
resolution: {integrity: sha512-Am1ousAhSLBeB9cG/7k7r2R0zj50uDRlZHPGbazid5s9rlF1F/QKYObEKSIunSjIOkJZqwRRLpvewjEkM7pSqg==}
|
||||
deprecated: Please see https://github.com/lydell/urix#deprecated
|
||||
@ -2300,6 +2321,9 @@ packages:
|
||||
wrappy@1.0.2:
|
||||
resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==}
|
||||
|
||||
xacro-parser@0.3.11:
|
||||
resolution: {integrity: sha512-zyRzHmf6/BySLZQP77zp6iEtdryq1g7tWtfwj/28GOPyf9ijtPvouivOAeaA/yAu0/Bwh20DtA4x+BrYiV6qKQ==}
|
||||
|
||||
y18n@5.0.8:
|
||||
resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==}
|
||||
engines: {node: '>=10'}
|
||||
@ -3282,6 +3306,8 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
expr-eval-fork@3.0.3: {}
|
||||
|
||||
express@5.2.1:
|
||||
dependencies:
|
||||
accepts: 2.0.0
|
||||
@ -4487,6 +4513,8 @@ snapshots:
|
||||
picocolors: 1.1.1
|
||||
sax: 1.6.0
|
||||
|
||||
three@0.181.2: {}
|
||||
|
||||
tinyglobby@0.2.17:
|
||||
dependencies:
|
||||
fdir: 6.5.0(picomatch@4.0.4)
|
||||
@ -4660,6 +4688,10 @@ snapshots:
|
||||
has-value: 0.3.1
|
||||
isobject: 3.0.1
|
||||
|
||||
urdf-loader@0.12.7(three@0.181.2):
|
||||
dependencies:
|
||||
three: 0.181.2
|
||||
|
||||
urix@0.1.0: {}
|
||||
|
||||
use@3.1.1: {}
|
||||
@ -4799,6 +4831,12 @@ snapshots:
|
||||
|
||||
wrappy@1.0.2: {}
|
||||
|
||||
xacro-parser@0.3.11:
|
||||
dependencies:
|
||||
expr-eval-fork: 3.0.3
|
||||
yaml: 2.9.0
|
||||
yargs: 17.7.3
|
||||
|
||||
y18n@5.0.8: {}
|
||||
|
||||
yaml@2.9.0: {}
|
||||
|
||||
465
public/inspection/elfin10/elfin10.urdf.xacro
Normal file
465
public/inspection/elfin10/elfin10.urdf.xacro
Normal file
@ -0,0 +1,465 @@
|
||||
<?xml version="1.0"?>
|
||||
|
||||
<robot name="elfin10" xmlns:xacro="http://wiki.ros.org/xacro">
|
||||
|
||||
<xacro:arg name="use_real_hardware" default="true" />
|
||||
<xacro:arg name="use_fake_hardware" default="false" />
|
||||
|
||||
<!-- Load TRANSMISSION file: -->
|
||||
<xacro:include filename="file://$(find elfin_description)/elfin10_transmission.xacro" />
|
||||
<!-- Load GAZEBO/ROS2_CONTROL file: -->
|
||||
<xacro:include filename="file://$(find elfin_description)/elfin10_gazebo.xacro" />
|
||||
|
||||
<xacro:property name="PI" value="3.1415926535897931"/>
|
||||
|
||||
<xacro:property name="density" value="3.09"/>
|
||||
<!-- <xacro:macro name="elfin10"> -->
|
||||
|
||||
<!-- TRANSMISSION: -->
|
||||
<xacro:elfin10_transmission />
|
||||
<!-- GAZEBO - ROS2_CONTROL: -->
|
||||
<xacro:elfin10_gazebo
|
||||
use_real_hardware="$(arg use_real_hardware)"
|
||||
use_fake_hardware="$(arg use_fake_hardware)"/>
|
||||
|
||||
<link name="world"/>
|
||||
|
||||
<joint name="world_base" type="fixed">
|
||||
<origin
|
||||
xyz="0 0 0"
|
||||
rpy="0 0 -1.5708" />
|
||||
<parent
|
||||
link="world" />
|
||||
<child
|
||||
link="elfin_base_link" />
|
||||
</joint>
|
||||
|
||||
<link name="elfin_base_link"/>
|
||||
|
||||
<joint name="elfin_base_joint" type="fixed">
|
||||
<origin
|
||||
xyz="0 0 0"
|
||||
rpy="0 0 ${PI/2}" />
|
||||
<parent
|
||||
link="elfin_base_link" />
|
||||
<child
|
||||
link="elfin_base" />
|
||||
</joint>
|
||||
|
||||
<link
|
||||
name="elfin_base">
|
||||
<inertial>
|
||||
<origin
|
||||
xyz="-0.00226511349377665 4.35718066723588E-06 0.0325128562537015"
|
||||
rpy="0 0 0" />
|
||||
<mass
|
||||
value="1.66727740473724" />
|
||||
<inertia
|
||||
ixx="0.004269288474386"
|
||||
ixy="6.02518130623876E-08"
|
||||
ixz="-6.92087963593712E-05"
|
||||
iyy="0.00385585336301628"
|
||||
iyz="-5.51423740998761E-08"
|
||||
izz="0.0068204473327682" />
|
||||
</inertial>
|
||||
<visual>
|
||||
<origin
|
||||
xyz="0 0 0"
|
||||
rpy="0 0 0" />
|
||||
<geometry>
|
||||
<mesh
|
||||
filename="file://$(find elfin_description)/meshes/elfin10/elfin_base.STL" />
|
||||
</geometry>
|
||||
<material
|
||||
name="">
|
||||
<color
|
||||
rgba="0.752941176470588 0.752941176470588 0.752941176470588 1" />
|
||||
</material>
|
||||
</visual>
|
||||
<collision>
|
||||
<origin
|
||||
xyz="0 0 0"
|
||||
rpy="0 0 0" />
|
||||
<geometry>
|
||||
<mesh
|
||||
filename="file://$(find elfin_description)/meshes/elfin10/elfin_base.STL" />
|
||||
</geometry>
|
||||
</collision>
|
||||
</link>
|
||||
<link
|
||||
name="elfin_link1">
|
||||
<inertial>
|
||||
<origin
|
||||
xyz="7.61162863471406E-07 -0.00300107309756385 -0.0432743915921636"
|
||||
rpy="0 0 0" />
|
||||
<mass
|
||||
value="6.3589066662676" />
|
||||
<inertia
|
||||
ixx="0.0391771076676677"
|
||||
ixy="5.46819690041203E-08"
|
||||
ixz="4.18972540516346E-07"
|
||||
iyy="0.0381555138034643"
|
||||
iyz="0.00661206821297882"
|
||||
izz="0.0211340101406971" />
|
||||
</inertial>
|
||||
<visual>
|
||||
<origin
|
||||
xyz="0 0 0"
|
||||
rpy="0 0 0" />
|
||||
<geometry>
|
||||
<mesh
|
||||
filename="file://$(find elfin_description)/meshes/elfin10/elfin_link1.STL" />
|
||||
</geometry>
|
||||
<material
|
||||
name="">
|
||||
<color
|
||||
rgba="0.752941176470588 0.752941176470588 0.752941176470588 1" />
|
||||
</material>
|
||||
</visual>
|
||||
<collision>
|
||||
<origin
|
||||
xyz="0 0 0"
|
||||
rpy="0 0 0" />
|
||||
<geometry>
|
||||
<mesh
|
||||
filename="file://$(find elfin_description)/meshes/elfin10/elfin_link1.STL" />
|
||||
</geometry>
|
||||
</collision>
|
||||
</link>
|
||||
<joint
|
||||
name="elfin_joint1"
|
||||
type="revolute">
|
||||
<origin
|
||||
xyz="0 0 0.26"
|
||||
rpy="0 0 0" />
|
||||
<parent
|
||||
link="elfin_base" />
|
||||
<child
|
||||
link="elfin_link1" />
|
||||
<axis
|
||||
xyz="0 0 1" />
|
||||
<limit
|
||||
lower="-3.14"
|
||||
upper="3.14"
|
||||
effort="420"
|
||||
velocity="1.57" />
|
||||
</joint>
|
||||
<link
|
||||
name="elfin_link2">
|
||||
<inertial>
|
||||
<origin
|
||||
xyz="-1.29217317440473E-07 0.204406209458582 0.145205671136993"
|
||||
rpy="0 0 0" />
|
||||
<mass
|
||||
value="5.51072381215317" />
|
||||
<inertia
|
||||
ixx="0.140886684844275"
|
||||
ixy="-1.98882818733046E-05"
|
||||
ixz="1.28793862225363E-06"
|
||||
iyy="0.00904523320332792"
|
||||
iyz="0.00556623571013066"
|
||||
izz="0.143882970137024" />
|
||||
</inertial>
|
||||
<visual>
|
||||
<origin
|
||||
xyz="0 0 0"
|
||||
rpy="0 0 0" />
|
||||
<geometry>
|
||||
<mesh
|
||||
filename="file://$(find elfin_description)/meshes/elfin10/elfin_link2.STL" />
|
||||
</geometry>
|
||||
<material
|
||||
name="">
|
||||
<color
|
||||
rgba="0.752941176470588 0.752941176470588 0.752941176470588 1" />
|
||||
</material>
|
||||
</visual>
|
||||
<collision>
|
||||
<origin
|
||||
xyz="0 0 0"
|
||||
rpy="0 0 0" />
|
||||
<geometry>
|
||||
<mesh
|
||||
filename="file://$(find elfin_description)/meshes/elfin10/elfin_link2.STL" />
|
||||
</geometry>
|
||||
</collision>
|
||||
</link>
|
||||
<joint
|
||||
name="elfin_joint2"
|
||||
type="revolute">
|
||||
<origin
|
||||
xyz="0 0 0"
|
||||
rpy="1.5708 0 0" />
|
||||
<parent
|
||||
link="elfin_link1" />
|
||||
<child
|
||||
link="elfin_link2" />
|
||||
<axis
|
||||
xyz="0 0 1" />
|
||||
<limit
|
||||
lower="-2.35"
|
||||
upper="2.35"
|
||||
effort="420"
|
||||
velocity="1.57" />
|
||||
</joint>
|
||||
<link
|
||||
name="elfin_link3">
|
||||
<inertial>
|
||||
<origin
|
||||
xyz="0.0357493010718184 -2.74303992683623E-06 -0.00441107040289549"
|
||||
rpy="0 0 0" />
|
||||
<mass
|
||||
value="3.25128088148268" />
|
||||
<inertia
|
||||
ixx="0.00712273850158261"
|
||||
ixy="4.14573354600121E-07"
|
||||
ixz="-0.00240614658407673"
|
||||
iyy="0.0131139013038093"
|
||||
iyz="-1.53858261863385E-07"
|
||||
izz="0.0123861174875263" />
|
||||
</inertial>
|
||||
<visual>
|
||||
<origin
|
||||
xyz="0 0 0"
|
||||
rpy="0 0 0" />
|
||||
<geometry>
|
||||
<mesh
|
||||
filename="file://$(find elfin_description)/meshes/elfin10/elfin_link3.STL" />
|
||||
</geometry>
|
||||
<material
|
||||
name="">
|
||||
<color
|
||||
rgba="0.752941176470588 0.752941176470588 0.752941176470588 1" />
|
||||
</material>
|
||||
</visual>
|
||||
<collision>
|
||||
<origin
|
||||
xyz="0 0 0"
|
||||
rpy="0 0 0" />
|
||||
<geometry>
|
||||
<mesh
|
||||
filename="file://$(find elfin_description)/meshes/elfin10/elfin_link3.STL" />
|
||||
</geometry>
|
||||
</collision>
|
||||
</link>
|
||||
<joint
|
||||
name="elfin_joint3"
|
||||
type="revolute">
|
||||
<origin
|
||||
xyz="0 0.48 0"
|
||||
rpy="-3.1416 0 1.5708" />
|
||||
<parent
|
||||
link="elfin_link2" />
|
||||
<child
|
||||
link="elfin_link3" />
|
||||
<axis
|
||||
xyz="0 0 1" />
|
||||
<limit
|
||||
lower="-2.61"
|
||||
upper="2.61"
|
||||
effort="200"
|
||||
velocity="1.57" />
|
||||
</joint>
|
||||
<link
|
||||
name="elfin_link4">
|
||||
<inertial>
|
||||
<origin
|
||||
xyz="3.8972274666868E-06 -0.065883038527668 0.300149807420176"
|
||||
rpy="0 0 0" />
|
||||
<mass
|
||||
value="2.45076028364132" />
|
||||
<inertia
|
||||
ixx="0.0366043963749035"
|
||||
ixy="2.14481800816891E-07"
|
||||
ixz="1.74370308297691E-06"
|
||||
iyy="0.0331005868546169"
|
||||
iyz="0.00876542795872678"
|
||||
izz="0.00689303506560623" />
|
||||
</inertial>
|
||||
<visual>
|
||||
<origin
|
||||
xyz="0 0 0"
|
||||
rpy="0 0 0" />
|
||||
<geometry>
|
||||
<mesh
|
||||
filename="file://$(find elfin_description)/meshes/elfin10/elfin_link4.STL" />
|
||||
</geometry>
|
||||
<material
|
||||
name="">
|
||||
<color
|
||||
rgba="0.752941176470588 0.752941176470588 0.752941176470588 1" />
|
||||
</material>
|
||||
</visual>
|
||||
<collision>
|
||||
<origin
|
||||
xyz="0 0 0"
|
||||
rpy="0 0 0" />
|
||||
<geometry>
|
||||
<mesh
|
||||
filename="file://$(find elfin_description)/meshes/elfin10/elfin_link4.STL" />
|
||||
</geometry>
|
||||
</collision>
|
||||
</link>
|
||||
<joint
|
||||
name="elfin_joint4"
|
||||
type="revolute">
|
||||
<origin
|
||||
xyz="0 0 0"
|
||||
rpy="1.5708 0 1.5708" />
|
||||
<parent
|
||||
link="elfin_link3" />
|
||||
<child
|
||||
link="elfin_link4" />
|
||||
<axis
|
||||
xyz="0 0 1" />
|
||||
<limit
|
||||
lower="-3.14"
|
||||
upper="3.14"
|
||||
effort="200"
|
||||
velocity="1.57" />
|
||||
</joint>
|
||||
<link
|
||||
name="elfin_link5">
|
||||
<inertial>
|
||||
<origin
|
||||
xyz="5.37084413168812E-06 -0.0262437975997385 -0.00353971828905618"
|
||||
rpy="0 0 0" />
|
||||
<mass
|
||||
value="1.39234539037872" />
|
||||
<inertia
|
||||
ixx="0.0033625821599978"
|
||||
ixy="2.21090591006111E-07"
|
||||
ixz="-5.5969383984273E-07"
|
||||
iyy="0.00182229285631246"
|
||||
iyz="0.000639319386824375"
|
||||
izz="0.00292731816323284" />
|
||||
</inertial>
|
||||
<visual>
|
||||
<origin
|
||||
xyz="0 0 0"
|
||||
rpy="0 0 0" />
|
||||
<geometry>
|
||||
<mesh
|
||||
filename="file://$(find elfin_description)/meshes/elfin10/elfin_link5.STL" />
|
||||
</geometry>
|
||||
<material
|
||||
name="">
|
||||
<color
|
||||
rgba="0.752941176470588 0.752941176470588 0.752941176470588 1" />
|
||||
</material>
|
||||
</visual>
|
||||
<collision>
|
||||
<origin
|
||||
xyz="0 0 0"
|
||||
rpy="0 0 0" />
|
||||
<geometry>
|
||||
<mesh
|
||||
filename="file://$(find elfin_description)/meshes/elfin10/elfin_link5.STL" />
|
||||
</geometry>
|
||||
</collision>
|
||||
</link>
|
||||
<joint
|
||||
name="elfin_joint5"
|
||||
type="revolute">
|
||||
<origin
|
||||
xyz="0 0 0.52"
|
||||
rpy="-1.5708 0 0" />
|
||||
<parent
|
||||
link="elfin_link4" />
|
||||
<child
|
||||
link="elfin_link5" />
|
||||
<axis
|
||||
xyz="0 0 1" />
|
||||
<limit
|
||||
lower="-2.56"
|
||||
upper="2.56"
|
||||
effort="69"
|
||||
velocity="1.57" />
|
||||
</joint>
|
||||
<link
|
||||
name="elfin_link6">
|
||||
<inertial>
|
||||
<origin
|
||||
xyz="-8.45231478862452E-06 -0.000388392932234965 0.144636177508013"
|
||||
rpy="0 0 0" />
|
||||
<mass
|
||||
value="0.297803050744826" />
|
||||
<inertia
|
||||
ixx="0.000151919382929214"
|
||||
ixy="-1.8598001023495E-09"
|
||||
ixz="6.95653480101813E-08"
|
||||
iyy="0.000155217204136347"
|
||||
iyz="-6.63866631888914E-08"
|
||||
izz="0.00027009779924127" />
|
||||
</inertial>
|
||||
<visual>
|
||||
<origin
|
||||
xyz="0 0 0"
|
||||
rpy="0 0 0" />
|
||||
<geometry>
|
||||
<mesh
|
||||
filename="file://$(find elfin_description)/meshes/elfin10/elfin_link6.STL" />
|
||||
</geometry>
|
||||
<material
|
||||
name="">
|
||||
<color
|
||||
rgba="0.752941176470588 0.752941176470588 0.752941176470588 1" />
|
||||
</material>
|
||||
</visual>
|
||||
<collision>
|
||||
<origin
|
||||
xyz="0 0 0"
|
||||
rpy="0 0 0" />
|
||||
<geometry>
|
||||
<mesh
|
||||
filename="file://$(find elfin_description)/meshes/elfin10/elfin_link6.STL" />
|
||||
</geometry>
|
||||
</collision>
|
||||
</link>
|
||||
<joint
|
||||
name="elfin_joint6"
|
||||
type="revolute">
|
||||
<origin
|
||||
xyz="0 0 0"
|
||||
rpy="1.5708 0 0" />
|
||||
<parent
|
||||
link="elfin_link5" />
|
||||
<child
|
||||
link="elfin_link6" />
|
||||
<axis
|
||||
xyz="0 0 1" />
|
||||
<limit
|
||||
lower="-3.14"
|
||||
upper="3.14"
|
||||
effort="69"
|
||||
velocity="1.57" />
|
||||
</joint>
|
||||
<link
|
||||
name="elfin_end_link"/>
|
||||
<joint
|
||||
name="elfin_end_joint"
|
||||
type="fixed">
|
||||
<origin
|
||||
xyz="0 0.0 0.1735"
|
||||
rpy="0 0 0" />
|
||||
<parent
|
||||
link="elfin_link6" />
|
||||
<child
|
||||
link="elfin_end_link" />
|
||||
<axis
|
||||
xyz="0 0 0" />
|
||||
</joint>
|
||||
|
||||
<link name="elfin_dummy_gripper"/>
|
||||
|
||||
<joint name="elfin_dummy_joint" type="fixed">
|
||||
<origin
|
||||
xyz="0 0.109 0"
|
||||
rpy="${-PI/2} ${-PI/2} 0" />
|
||||
<parent
|
||||
link="elfin_link6" />
|
||||
<child
|
||||
link="elfin_dummy_gripper" />
|
||||
</joint>
|
||||
|
||||
</robot>
|
||||
134
public/inspection/elfin10/elfin10_gazebo.xacro
Normal file
134
public/inspection/elfin10/elfin10_gazebo.xacro
Normal file
@ -0,0 +1,134 @@
|
||||
<?xml version="1.0"?>
|
||||
|
||||
<robot xmlns:xacro="http://www.ros.org/wiki/xacro">
|
||||
|
||||
<xacro:macro name="elfin10_gazebo"
|
||||
params="use_fake_hardware:=false
|
||||
use_real_hardware:=true">
|
||||
|
||||
<gazebo>
|
||||
<plugin filename="libgazebo_ros2_control.so" name="gazebo_ros2_control">
|
||||
<robot_sim_type>gazebo_ros2_control/GazeboSystem</robot_sim_type>
|
||||
<parameters>$(find elfin10_ros2_gazebo)/config/elfin_arm_controller.yaml</parameters>
|
||||
</plugin>
|
||||
</gazebo>
|
||||
|
||||
<ros2_control name="GazeboSystem" type="system">
|
||||
|
||||
<hardware>
|
||||
<xacro:if value="${use_real_hardware}">
|
||||
<plugin>elfin_hardware_interface/ElfinHWInterface</plugin>
|
||||
</xacro:if>
|
||||
|
||||
<xacro:unless value="${use_real_hardware}">
|
||||
<xacro:if value="${use_fake_hardware}">
|
||||
<plugin>fake_components/GenericSystem</plugin>
|
||||
</xacro:if>
|
||||
|
||||
<xacro:unless value="${use_fake_hardware}">
|
||||
<plugin>gazebo_ros2_control/GazeboSystem</plugin>
|
||||
</xacro:unless>
|
||||
</xacro:unless>
|
||||
</hardware>
|
||||
|
||||
|
||||
<joint name="elfin_joint1">
|
||||
<command_interface name="position">
|
||||
<param name="min">-3.14</param>
|
||||
<param name="max">3.14</param>
|
||||
</command_interface>
|
||||
<command_interface name="velocity">
|
||||
<param name="min">-1.57</param>
|
||||
<param name="max">1.57</param>
|
||||
</command_interface>
|
||||
<state_interface name="position"/>
|
||||
<state_interface name="velocity"/>
|
||||
</joint>
|
||||
<joint name="elfin_joint2">
|
||||
<command_interface name="position">
|
||||
<param name="min">-2.35</param>
|
||||
<param name="max">2.35</param>
|
||||
</command_interface>
|
||||
<command_interface name="velocity">
|
||||
<param name="min">-1.57</param>
|
||||
<param name="max">1.57</param>
|
||||
</command_interface>
|
||||
<state_interface name="position"/>
|
||||
<state_interface name="velocity"/>
|
||||
</joint>
|
||||
<joint name="elfin_joint3">
|
||||
<command_interface name="position">
|
||||
<param name="min">-2.61</param>
|
||||
<param name="max">2.61</param>
|
||||
</command_interface>
|
||||
<command_interface name="velocity">
|
||||
<param name="min">-1.57</param>
|
||||
<param name="max">1.57</param>
|
||||
</command_interface>
|
||||
<state_interface name="position"/>
|
||||
<state_interface name="velocity"/>
|
||||
</joint>
|
||||
<joint name="elfin_joint4">
|
||||
<command_interface name="position">
|
||||
<param name="min">-3.14</param>
|
||||
<param name="max">3.14</param>
|
||||
</command_interface>
|
||||
<command_interface name="velocity">
|
||||
<param name="min">-1.57</param>
|
||||
<param name="max">1.57</param>
|
||||
</command_interface>
|
||||
<state_interface name="position"/>
|
||||
<state_interface name="velocity"/>
|
||||
</joint>
|
||||
<joint name="elfin_joint5">
|
||||
<command_interface name="position">
|
||||
<param name="min">-2.56</param>
|
||||
<param name="max">2.56</param>
|
||||
</command_interface>
|
||||
<command_interface name="velocity">
|
||||
<param name="min">-1.57</param>
|
||||
<param name="max">1.57</param>
|
||||
</command_interface>
|
||||
<state_interface name="position"/>
|
||||
<state_interface name="velocity"/>
|
||||
</joint>
|
||||
<joint name="elfin_joint6">
|
||||
<command_interface name="position">
|
||||
<param name="min">-3.14</param>
|
||||
<param name="max">3.14</param>
|
||||
</command_interface>
|
||||
<command_interface name="velocity">
|
||||
<param name="min">-1.57</param>
|
||||
<param name="max">1.57</param>
|
||||
</command_interface>
|
||||
<state_interface name="position"/>
|
||||
<state_interface name="velocity"/>
|
||||
</joint>
|
||||
|
||||
</ros2_control>
|
||||
|
||||
<gazebo reference="elfin_link2">
|
||||
<visual name='visual'>
|
||||
<material>
|
||||
<script>
|
||||
<uri>file://media/materials/scripts/gazebo.material</uri>
|
||||
<name>Gazebo/Grey</name>
|
||||
</script>
|
||||
</material>
|
||||
</visual>
|
||||
</gazebo>
|
||||
|
||||
<gazebo reference="elfin_link4">
|
||||
<visual name='visual'>
|
||||
<material>
|
||||
<script>
|
||||
<uri>file://media/materials/scripts/gazebo.material</uri>
|
||||
<name>Gazebo/Grey</name>
|
||||
</script>
|
||||
</material>
|
||||
</visual>
|
||||
</gazebo>
|
||||
|
||||
</xacro:macro>
|
||||
|
||||
</robot>
|
||||
69
public/inspection/elfin10/elfin10_transmission.xacro
Normal file
69
public/inspection/elfin10/elfin10_transmission.xacro
Normal file
@ -0,0 +1,69 @@
|
||||
<?xml version="1.0"?>
|
||||
|
||||
<robot xmlns:xacro="http://wiki.ros.org/xacro">
|
||||
|
||||
<xacro:macro name="elfin10_transmission" >
|
||||
|
||||
<transmission name="elfin_joint1_trans">
|
||||
<type>transmission_interface/SimpleTransmission</type>
|
||||
<joint name="elfin_joint1">
|
||||
<hardwareInterface>hardware_interface/PositionJointInterface</hardwareInterface>
|
||||
</joint>
|
||||
<actuator name="elfin_joint1_motor">
|
||||
<mechanicalReduction>1</mechanicalReduction>
|
||||
</actuator>
|
||||
</transmission>
|
||||
|
||||
<transmission name="elfin_joint2_trans">
|
||||
<type>transmission_interface/SimpleTransmission</type>
|
||||
<joint name="elfin_joint2">
|
||||
<hardwareInterface>hardware_interface/PositionJointInterface</hardwareInterface>
|
||||
</joint>
|
||||
<actuator name="elfin_joint2_motor">
|
||||
<mechanicalReduction>1</mechanicalReduction>
|
||||
</actuator>
|
||||
</transmission>
|
||||
|
||||
<transmission name="elfin_joint3_trans">
|
||||
<type>transmission_interface/SimpleTransmission</type>
|
||||
<joint name="elfin_joint3">
|
||||
<hardwareInterface>hardware_interface/PositionJointInterface</hardwareInterface>
|
||||
</joint>
|
||||
<actuator name="elfin_joint3_motor">
|
||||
<mechanicalReduction>1</mechanicalReduction>
|
||||
</actuator>
|
||||
</transmission>
|
||||
|
||||
<transmission name="elfin_joint4_trans">
|
||||
<type>transmission_interface/SimpleTransmission</type>
|
||||
<joint name="elfin_joint4">
|
||||
<hardwareInterface>hardware_interface/PositionJointInterface</hardwareInterface>
|
||||
</joint>
|
||||
<actuator name="elfin_joint4_motor">
|
||||
<mechanicalReduction>1</mechanicalReduction>
|
||||
</actuator>
|
||||
</transmission>
|
||||
|
||||
<transmission name="elfin_joint5_trans">
|
||||
<type>transmission_interface/SimpleTransmission</type>
|
||||
<joint name="elfin_joint5">
|
||||
<hardwareInterface>hardware_interface/PositionJointInterface</hardwareInterface>
|
||||
</joint>
|
||||
<actuator name="elfin_joint5_motor">
|
||||
<mechanicalReduction>1</mechanicalReduction>
|
||||
</actuator>
|
||||
</transmission>
|
||||
|
||||
<transmission name="elfin_joint6_trans">
|
||||
<type>transmission_interface/SimpleTransmission</type>
|
||||
<joint name="elfin_joint6">
|
||||
<hardwareInterface>hardware_interface/PositionJointInterface</hardwareInterface>
|
||||
</joint>
|
||||
<actuator name="elfin_joint6_motor">
|
||||
<mechanicalReduction>1</mechanicalReduction>
|
||||
</actuator>
|
||||
</transmission>
|
||||
|
||||
</xacro:macro>
|
||||
|
||||
</robot>
|
||||
BIN
public/inspection/elfin10/meshes/elfin10/elfin_base.STL
Normal file
BIN
public/inspection/elfin10/meshes/elfin10/elfin_base.STL
Normal file
Binary file not shown.
BIN
public/inspection/elfin10/meshes/elfin10/elfin_end_link.STL
Normal file
BIN
public/inspection/elfin10/meshes/elfin10/elfin_end_link.STL
Normal file
Binary file not shown.
BIN
public/inspection/elfin10/meshes/elfin10/elfin_link1.STL
Normal file
BIN
public/inspection/elfin10/meshes/elfin10/elfin_link1.STL
Normal file
Binary file not shown.
BIN
public/inspection/elfin10/meshes/elfin10/elfin_link2.STL
Normal file
BIN
public/inspection/elfin10/meshes/elfin10/elfin_link2.STL
Normal file
Binary file not shown.
BIN
public/inspection/elfin10/meshes/elfin10/elfin_link3.STL
Normal file
BIN
public/inspection/elfin10/meshes/elfin10/elfin_link3.STL
Normal file
Binary file not shown.
BIN
public/inspection/elfin10/meshes/elfin10/elfin_link4.STL
Normal file
BIN
public/inspection/elfin10/meshes/elfin10/elfin_link4.STL
Normal file
Binary file not shown.
BIN
public/inspection/elfin10/meshes/elfin10/elfin_link5.STL
Normal file
BIN
public/inspection/elfin10/meshes/elfin10/elfin_link5.STL
Normal file
Binary file not shown.
BIN
public/inspection/elfin10/meshes/elfin10/elfin_link6.STL
Normal file
BIN
public/inspection/elfin10/meshes/elfin10/elfin_link6.STL
Normal file
Binary file not shown.
461
src/adapters/URDFAdapter.js
Normal file
461
src/adapters/URDFAdapter.js
Normal file
@ -0,0 +1,461 @@
|
||||
/**
|
||||
* URDF Adapter
|
||||
* Converts urdf-loaders result to unified model
|
||||
*/
|
||||
import * as THREE from 'three';
|
||||
import { UnifiedRobotModel, Link, Joint, JointLimits, VisualGeometry, CollisionGeometry, InertialProperties } from '@/models/UnifiedRobotModel.js';
|
||||
|
||||
export class URDFAdapter {
|
||||
/**
|
||||
* Convert urdf-loaders robot object to unified model
|
||||
* @param {THREE.Group} robot - Robot object returned by urdf-loaders
|
||||
* @param {string} urdfXML - Original URDF XML content (optional, for extracting inertial data)
|
||||
* @returns {UnifiedRobotModel}
|
||||
*/
|
||||
static convert(robot, urdfXML = null) {
|
||||
const model = new UnifiedRobotModel();
|
||||
model.name = robot.name || 'robot';
|
||||
model.threeObject = robot;
|
||||
|
||||
// Mark model type as URDF
|
||||
if (!robot.userData) robot.userData = {};
|
||||
robot.userData.type = 'urdf';
|
||||
|
||||
if (!robot.links || !robot.joints) {
|
||||
console.warn('URDF model missing links or joints information');
|
||||
return model;
|
||||
}
|
||||
|
||||
// If XML provided, parse inertial data
|
||||
let inertialData = {};
|
||||
if (urdfXML) {
|
||||
inertialData = this.parseInertialFromXML(urdfXML);
|
||||
}
|
||||
|
||||
// Convert links
|
||||
Object.values(robot.links).forEach(urdfLink => {
|
||||
const link = this.convertLink(urdfLink);
|
||||
|
||||
// If urdf-loader didn't parse inertial, get from XML
|
||||
if (!link.inertial && inertialData[urdfLink.name]) {
|
||||
link.inertial = inertialData[urdfLink.name];
|
||||
}
|
||||
|
||||
model.addLink(link);
|
||||
});
|
||||
|
||||
// Convert joints
|
||||
Object.values(robot.joints).forEach(urdfJoint => {
|
||||
const joint = this.convertJoint(urdfJoint);
|
||||
model.addJoint(joint);
|
||||
});
|
||||
|
||||
// If XML provided, supplement effort and velocity from XML (urdf-loaders may not have parsed)
|
||||
if (urdfXML) {
|
||||
this.supplementJointLimitsFromXML(model, urdfXML);
|
||||
}
|
||||
|
||||
// Find root link (link that is not a child of any joint)
|
||||
const allChildren = new Set(
|
||||
Array.from(model.joints.values()).map(j => j.child).filter(c => c)
|
||||
);
|
||||
const rootLinks = Array.from(model.links.keys()).filter(
|
||||
name => !allChildren.has(name)
|
||||
);
|
||||
if (rootLinks.length > 0) {
|
||||
model.rootLink = rootLinks[0];
|
||||
}
|
||||
|
||||
// Enhance materials for better lighting (MuJoCo style)
|
||||
this.enhanceMaterials(robot);
|
||||
|
||||
return model;
|
||||
}
|
||||
|
||||
/**
|
||||
* Enhance materials for better lighting (MuJoCo style)
|
||||
* Applies consistent shininess and specular properties to all materials
|
||||
* Directly modifies materials in place to ensure changes persist
|
||||
*/
|
||||
static enhanceMaterials(robotObject) {
|
||||
robotObject.traverse((child) => {
|
||||
if (child.isMesh && child.material) {
|
||||
// Handle material arrays
|
||||
if (Array.isArray(child.material)) {
|
||||
child.material = child.material.map(mat => {
|
||||
return this.enhanceSingleMaterial(mat);
|
||||
});
|
||||
} else {
|
||||
child.material = this.enhanceSingleMaterial(child.material);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Enhance a single material with better lighting properties
|
||||
* Returns enhanced material (may be cloned or modified in place)
|
||||
* Saves original properties for lighting toggle
|
||||
*/
|
||||
static enhanceSingleMaterial(material) {
|
||||
if (material.isMeshPhongMaterial || material.isMeshStandardMaterial) {
|
||||
// Save original properties if not already saved (for lighting toggle)
|
||||
if (material.userData.originalShininess === undefined) {
|
||||
material.userData.originalShininess = material.shininess !== undefined ? material.shininess : 30;
|
||||
// IMPORTANT: Save original color to preserve URDF material colors
|
||||
if (!material.userData.originalColor && material.color) {
|
||||
material.userData.originalColor = material.color.clone();
|
||||
}
|
||||
// Save original specular - if material had no specular, save null
|
||||
if (!material.specular) {
|
||||
material.userData.originalSpecular = null;
|
||||
} else if (material.specular.isColor) {
|
||||
const spec = material.specular;
|
||||
if (spec.r < 0.1 && spec.g < 0.1 && spec.b < 0.1) {
|
||||
material.userData.originalSpecular = null; // Likely default
|
||||
} else {
|
||||
material.userData.originalSpecular = spec.clone();
|
||||
}
|
||||
} else if (typeof material.specular === 'number') {
|
||||
if (material.specular === 0x111111 || material.specular < 0x111111) {
|
||||
material.userData.originalSpecular = null;
|
||||
} else {
|
||||
material.userData.originalSpecular = new THREE.Color(material.specular);
|
||||
}
|
||||
} else {
|
||||
material.userData.originalSpecular = null;
|
||||
}
|
||||
}
|
||||
|
||||
// IMPORTANT: Preserve original color from URDF material definitions
|
||||
// Do not modify material.color - urdf-loaders already sets the correct color
|
||||
|
||||
// Apply enhanced lighting (default enabled)
|
||||
// Increase shininess for better highlights
|
||||
if (material.shininess === undefined || material.shininess < 50) {
|
||||
material.shininess = 50;
|
||||
}
|
||||
|
||||
// Enhance specular reflection - ensure it's a Color object with proper values
|
||||
if (!material.specular) {
|
||||
material.specular = new THREE.Color(0.3, 0.3, 0.3);
|
||||
} else if (material.specular.isColor) {
|
||||
// If it's already a Color object, update values
|
||||
if (material.specular.r < 0.2 || material.specular.g < 0.2 || material.specular.b < 0.2) {
|
||||
material.specular.setRGB(0.3, 0.3, 0.3);
|
||||
}
|
||||
} else if (typeof material.specular === 'number') {
|
||||
// Convert number to Color object
|
||||
if (material.specular < 0x333333) {
|
||||
material.specular = new THREE.Color(0.3, 0.3, 0.3);
|
||||
} else {
|
||||
// Convert hex to Color
|
||||
material.specular = new THREE.Color(material.specular);
|
||||
if (material.specular.r < 0.2) {
|
||||
material.specular.setRGB(0.3, 0.3, 0.3);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Mark material as needing update
|
||||
material.needsUpdate = true;
|
||||
return material;
|
||||
} else if (material.type === 'MeshBasicMaterial') {
|
||||
// Convert MeshBasicMaterial to MeshPhongMaterial
|
||||
const oldMaterial = material;
|
||||
const envMap = typeof window !== 'undefined' && window.app?.sceneManager?.environmentManager?.getEnvironmentMap();
|
||||
const newMaterial = new THREE.MeshPhongMaterial({
|
||||
color: oldMaterial.color,
|
||||
map: oldMaterial.map,
|
||||
transparent: oldMaterial.transparent,
|
||||
opacity: oldMaterial.opacity,
|
||||
side: oldMaterial.side,
|
||||
shininess: 50,
|
||||
specular: new THREE.Color(0.3, 0.3, 0.3),
|
||||
envMap: envMap || null,
|
||||
reflectivity: envMap ? 0.3 : 0
|
||||
});
|
||||
// Save original properties for lighting toggle
|
||||
newMaterial.userData.originalShininess = 30;
|
||||
newMaterial.userData.originalSpecular = null; // MeshBasicMaterial had no specular
|
||||
return newMaterial;
|
||||
}
|
||||
|
||||
return material;
|
||||
}
|
||||
|
||||
static convertLink(urdfLink) {
|
||||
const link = new Link(urdfLink.name);
|
||||
link.threeObject = urdfLink;
|
||||
|
||||
// Convert inertial properties
|
||||
if (urdfLink.inertial) {
|
||||
link.inertial = this.convertInertial(urdfLink.inertial);
|
||||
}
|
||||
|
||||
// Note: urdf-loaders has already converted visual and collision to Three.js objects
|
||||
// We mainly extract metadata, actual meshes are in threeObject
|
||||
|
||||
return link;
|
||||
}
|
||||
|
||||
static convertJoint(urdfJoint) {
|
||||
// URDF joint object has jointType property (not type)
|
||||
const jointType = urdfJoint.jointType || urdfJoint.type || 'fixed';
|
||||
const joint = new Joint(urdfJoint.name, jointType);
|
||||
|
||||
// Extract parent name (urdf-loader returns Three.js object reference)
|
||||
joint.parent = urdfJoint.parent?.name || null;
|
||||
|
||||
// child may not be in urdfJoint.child, but in Three.js children array
|
||||
// In Three.js scene graph, Joint is parent node, Child Link is Joint's child node
|
||||
if (urdfJoint.child && urdfJoint.child.name) {
|
||||
joint.child = urdfJoint.child.name;
|
||||
} else if (urdfJoint.children && urdfJoint.children.length > 0) {
|
||||
// Find Link object from children array
|
||||
const childLink = urdfJoint.children.find(child =>
|
||||
child.isURDFLink || child.type === 'URDFLink'
|
||||
);
|
||||
if (childLink) {
|
||||
joint.child = childLink.name;
|
||||
}
|
||||
}
|
||||
|
||||
joint.threeObject = urdfJoint;
|
||||
|
||||
// Convert origin
|
||||
if (urdfJoint.origin) {
|
||||
joint.origin = {
|
||||
xyz: urdfJoint.origin.xyz || [0, 0, 0],
|
||||
rpy: urdfJoint.origin.rpy || [0, 0, 0]
|
||||
};
|
||||
}
|
||||
|
||||
// Convert axis
|
||||
if (urdfJoint.axis) {
|
||||
joint.axis = { xyz: urdfJoint.axis.xyz || [0, 0, 1] };
|
||||
}
|
||||
|
||||
// Convert limits
|
||||
if (urdfJoint.limit) {
|
||||
joint.limits = this.convertLimits(urdfJoint.limit);
|
||||
}
|
||||
|
||||
// Get current value
|
||||
if (urdfJoint.angle !== undefined) {
|
||||
joint.currentValue = urdfJoint.angle;
|
||||
} else if (urdfJoint.jointValue !== undefined) {
|
||||
joint.currentValue = urdfJoint.jointValue;
|
||||
}
|
||||
|
||||
return joint;
|
||||
}
|
||||
|
||||
static convertLimits(limit) {
|
||||
const limits = new JointLimits();
|
||||
if (limit.lower !== undefined) limits.lower = limit.lower;
|
||||
if (limit.upper !== undefined) limits.upper = limit.upper;
|
||||
if (limit.effort !== undefined) limits.effort = limit.effort;
|
||||
if (limit.velocity !== undefined) limits.velocity = limit.velocity;
|
||||
return limits;
|
||||
}
|
||||
|
||||
/**
|
||||
* Supplement joint effort and velocity information from URDF XML
|
||||
* (because urdf-loaders may not have parsed these attributes)
|
||||
*/
|
||||
static supplementJointLimitsFromXML(model, urdfXML) {
|
||||
try {
|
||||
const parser = new DOMParser();
|
||||
const doc = parser.parseFromString(urdfXML, 'text/xml');
|
||||
|
||||
const jointElements = doc.querySelectorAll('joint');
|
||||
jointElements.forEach(jointEl => {
|
||||
const jointName = jointEl.getAttribute('name');
|
||||
const joint = model.joints.get(jointName);
|
||||
|
||||
if (joint) {
|
||||
const limitEl = jointEl.querySelector('limit');
|
||||
if (limitEl) {
|
||||
if (!joint.limits) {
|
||||
joint.limits = new JointLimits();
|
||||
}
|
||||
|
||||
// Read effort and velocity
|
||||
const effort = limitEl.getAttribute('effort');
|
||||
const velocity = limitEl.getAttribute('velocity');
|
||||
|
||||
if (effort !== null) {
|
||||
joint.limits.effort = parseFloat(effort);
|
||||
}
|
||||
if (velocity !== null) {
|
||||
joint.limits.velocity = parseFloat(velocity);
|
||||
}
|
||||
|
||||
// Also ensure lower and upper are correctly set
|
||||
const lower = limitEl.getAttribute('lower');
|
||||
const upper = limitEl.getAttribute('upper');
|
||||
if (lower !== null) {
|
||||
joint.limits.lower = parseFloat(lower);
|
||||
}
|
||||
if (upper !== null) {
|
||||
joint.limits.upper = parseFloat(upper);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to supplement joint limit information from XML:', error);
|
||||
}
|
||||
}
|
||||
|
||||
static convertInertial(inertial) {
|
||||
const props = new InertialProperties();
|
||||
if (inertial.mass !== undefined) props.mass = inertial.mass;
|
||||
if (inertial.origin) {
|
||||
props.origin = {
|
||||
xyz: inertial.origin.xyz || [0, 0, 0],
|
||||
rpy: inertial.origin.rpy || [0, 0, 0]
|
||||
};
|
||||
}
|
||||
if (inertial.ixx !== undefined) props.ixx = inertial.ixx;
|
||||
if (inertial.iyy !== undefined) props.iyy = inertial.iyy;
|
||||
if (inertial.izz !== undefined) props.izz = inertial.izz;
|
||||
if (inertial.ixy !== undefined) props.ixy = inertial.ixy;
|
||||
if (inertial.ixz !== undefined) props.ixz = inertial.ixz;
|
||||
if (inertial.iyz !== undefined) props.iyz = inertial.iyz;
|
||||
return props;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse inertial data from URDF XML
|
||||
* @param {string} xmlContent - URDF XML content
|
||||
* @returns {Object} Mapping from link names to inertial data
|
||||
*/
|
||||
static parseInertialFromXML(xmlContent) {
|
||||
const inertialData = {};
|
||||
|
||||
try {
|
||||
const parser = new DOMParser();
|
||||
const xmlDoc = parser.parseFromString(xmlContent, 'text/xml');
|
||||
|
||||
// Get all link elements
|
||||
const links = xmlDoc.getElementsByTagName('link');
|
||||
|
||||
for (let i = 0; i < links.length; i++) {
|
||||
const linkElement = links[i];
|
||||
const linkName = linkElement.getAttribute('name');
|
||||
|
||||
// Find inertial element
|
||||
const inertialElement = linkElement.getElementsByTagName('inertial')[0];
|
||||
if (!inertialElement) continue;
|
||||
|
||||
const inertial = new InertialProperties();
|
||||
|
||||
// Parse mass
|
||||
const massElement = inertialElement.getElementsByTagName('mass')[0];
|
||||
if (massElement) {
|
||||
inertial.mass = parseFloat(massElement.getAttribute('value'));
|
||||
}
|
||||
|
||||
// Parse origin
|
||||
const originElement = inertialElement.getElementsByTagName('origin')[0];
|
||||
if (originElement) {
|
||||
const xyz = originElement.getAttribute('xyz');
|
||||
const rpy = originElement.getAttribute('rpy');
|
||||
inertial.origin = {
|
||||
xyz: xyz ? xyz.split(' ').map(parseFloat) : [0, 0, 0],
|
||||
rpy: rpy ? rpy.split(' ').map(parseFloat) : [0, 0, 0]
|
||||
};
|
||||
} else {
|
||||
inertial.origin = { xyz: [0, 0, 0], rpy: [0, 0, 0] };
|
||||
}
|
||||
|
||||
// Parse inertia
|
||||
const inertiaElement = inertialElement.getElementsByTagName('inertia')[0];
|
||||
if (inertiaElement) {
|
||||
inertial.ixx = parseFloat(inertiaElement.getAttribute('ixx')) || 0;
|
||||
inertial.iyy = parseFloat(inertiaElement.getAttribute('iyy')) || 0;
|
||||
inertial.izz = parseFloat(inertiaElement.getAttribute('izz')) || 0;
|
||||
inertial.ixy = parseFloat(inertiaElement.getAttribute('ixy')) || 0;
|
||||
inertial.ixz = parseFloat(inertiaElement.getAttribute('ixz')) || 0;
|
||||
inertial.iyz = parseFloat(inertiaElement.getAttribute('iyz')) || 0;
|
||||
}
|
||||
|
||||
inertialData[linkName] = inertial;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to parse URDF XML inertial data:', error);
|
||||
}
|
||||
|
||||
return inertialData;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set joint angle (using urdf-loaders' setJointValue method)
|
||||
* Reference URDFClasses.js setJointValue implementation
|
||||
*/
|
||||
static setJointAngle(joint, angle, ignoreLimits = false) {
|
||||
joint.currentValue = angle;
|
||||
|
||||
// URDF format: use urdf-loader's setJointValue method
|
||||
if (joint.threeObject) {
|
||||
// If ignoring limits, temporarily modify URDF joint object's limit values
|
||||
let originalLimits = null;
|
||||
if (ignoreLimits && joint.threeObject.limit) {
|
||||
originalLimits = {
|
||||
lower: joint.threeObject.limit.lower,
|
||||
upper: joint.threeObject.limit.upper
|
||||
};
|
||||
joint.threeObject.limit.lower = -Math.PI * 2;
|
||||
joint.threeObject.limit.upper = Math.PI * 2;
|
||||
}
|
||||
|
||||
// Prefer setJointValue method (urdf-loader's standard method)
|
||||
if (typeof joint.threeObject.setJointValue === 'function') {
|
||||
joint.threeObject.setJointValue(angle);
|
||||
|
||||
// Restore original limits
|
||||
if (originalLimits && joint.threeObject.limit) {
|
||||
joint.threeObject.limit.lower = originalLimits.lower;
|
||||
joint.threeObject.limit.upper = originalLimits.upper;
|
||||
}
|
||||
return;
|
||||
} else if (typeof joint.threeObject.setAngle === 'function') {
|
||||
joint.threeObject.setAngle(angle);
|
||||
|
||||
// Restore original limits
|
||||
if (originalLimits && joint.threeObject.limit) {
|
||||
joint.threeObject.limit.lower = originalLimits.lower;
|
||||
joint.threeObject.limit.upper = originalLimits.upper;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Restore original limits (if none of the above methods executed)
|
||||
if (originalLimits && joint.threeObject.limit) {
|
||||
joint.threeObject.limit.lower = originalLimits.lower;
|
||||
joint.threeObject.limit.upper = originalLimits.upper;
|
||||
}
|
||||
|
||||
// If none available, manually set rotation
|
||||
// Note: For URDF, we usually shouldn't reach here, as urdf-loader should provide setJointValue
|
||||
console.warn(`Joint ${joint.name} has no setJointValue or setAngle method, attempting manual setup`);
|
||||
if (joint.type === 'revolute' || joint.type === 'continuous') {
|
||||
const axis = joint.axis ? new THREE.Vector3(...joint.axis.xyz).normalize() : new THREE.Vector3(0, 0, 1);
|
||||
// Note: joint.threeObject may not be a Three.js object, cannot directly set rotation
|
||||
if (joint.threeObject.rotation) {
|
||||
joint.threeObject.rotation.setFromAxisAngle(axis, angle);
|
||||
}
|
||||
} else if (joint.type === 'prismatic') {
|
||||
const axis = joint.axis ? new THREE.Vector3(...joint.axis.xyz).normalize() : new THREE.Vector3(1, 0, 0);
|
||||
if (joint.threeObject.position) {
|
||||
joint.threeObject.position.copy(axis.multiplyScalar(angle));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
console.warn(`Joint ${joint.name} has no threeObject`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
624
src/adapters/XacroAdapter.js
Normal file
624
src/adapters/XacroAdapter.js
Normal file
@ -0,0 +1,624 @@
|
||||
/**
|
||||
* Xacro Adapter
|
||||
*
|
||||
* Parses ROS Xacro files and converts them to URDF format using xacro-parser.
|
||||
*
|
||||
* Supported Features:
|
||||
* - Property definitions and substitutions (xacro:property)
|
||||
* - Macro definitions with parameters (xacro:macro)
|
||||
* - Conditional blocks (xacro:if, xacro:unless)
|
||||
* - File inclusions (xacro:include) with package:// and relative paths
|
||||
* - Python-style boolean constants (True/False)
|
||||
* - Arithmetic expressions and property evaluation
|
||||
*
|
||||
* Compatibility:
|
||||
* - ROS Jade and later (inOrder=true, requirePrefix=true, localProperties=true)
|
||||
* - Automatically injects True/False constants for compatibility with ROS xacro files
|
||||
*
|
||||
* Usage:
|
||||
* - Upload all related xacro files (main file and included files)
|
||||
* - The adapter automatically resolves file inclusions from the uploaded file map
|
||||
* - Mesh files are resolved using package:// paths or relative paths
|
||||
*/
|
||||
import { XacroParser } from 'xacro-parser';
|
||||
import { URDFAdapter } from './URDFAdapter.js';
|
||||
|
||||
export class XacroAdapter {
|
||||
/**
|
||||
* Parse xacro content and convert to unified model
|
||||
* @param {string} xacroContent - Xacro file content
|
||||
* @param {string} fileName - Xacro file name (for working path)
|
||||
* @param {Map} fileMap - File map (path -> File object)
|
||||
* @param {File} file - Original file object (optional)
|
||||
* @returns {Promise<UnifiedRobotModel>}
|
||||
*/
|
||||
static async parse(xacroContent, fileName, fileMap = null, file = null) {
|
||||
try {
|
||||
// Create xacro parser
|
||||
const parser = new XacroParser();
|
||||
|
||||
// Configure parser for ROS Jade and later (default settings)
|
||||
parser.inOrder = true;
|
||||
parser.requirePrefix = true;
|
||||
parser.localProperties = true;
|
||||
|
||||
// Set working path (directory where xacro file is located)
|
||||
const workingPath = fileName.includes('/')
|
||||
? fileName.substring(0, fileName.lastIndexOf('/') + 1)
|
||||
: '';
|
||||
parser.workingPath = workingPath;
|
||||
|
||||
// Inject Python-style boolean constants as xacro properties
|
||||
// Some xacro files use True/False (capitalized) in conditions
|
||||
xacroContent = this.injectBooleanConstants(xacroContent);
|
||||
|
||||
// Extract and set xacro arguments with their default values
|
||||
// Also add common ROS arguments that might be used without definition
|
||||
const xacroArgs = this.extractXacroArguments(xacroContent);
|
||||
|
||||
// Add common ROS arguments with sensible defaults if not already defined
|
||||
if (!xacroArgs.hasOwnProperty('DEBUG')) {
|
||||
xacroArgs.DEBUG = 'false';
|
||||
}
|
||||
if (!xacroArgs.hasOwnProperty('SELF_COLLIDE')) {
|
||||
xacroArgs.SELF_COLLIDE = 'false';
|
||||
}
|
||||
|
||||
parser.arguments = xacroArgs;
|
||||
|
||||
// If fileMap provided, setup custom file loader
|
||||
if (fileMap) {
|
||||
parser.getFileContents = async (path) => {
|
||||
return await this.loadFileFromMap(path, fileMap, workingPath);
|
||||
};
|
||||
}
|
||||
|
||||
// Parse xacro to URDF XML
|
||||
const urdfXML = await parser.parse(xacroContent);
|
||||
|
||||
|
||||
// Convert XMLDocument to string
|
||||
const serializer = new XMLSerializer();
|
||||
let urdfString = serializer.serializeToString(urdfXML);
|
||||
|
||||
// Clean up the XML string - remove empty xmlns attributes that might cause issues
|
||||
urdfString = urdfString.replace(/\sxmlns=""/g, '');
|
||||
|
||||
// Re-parse the URDF string with DOMParser to create a clean XMLDocument
|
||||
// This removes any xacro-specific nodes that urdf-loader might not handle
|
||||
const domParser = new DOMParser();
|
||||
const cleanUrdfXML = domParser.parseFromString(urdfString, 'text/xml');
|
||||
|
||||
// Check for parsing errors
|
||||
const parseError = cleanUrdfXML.querySelector('parsererror');
|
||||
if (parseError) {
|
||||
console.error('[XacroAdapter] XML parsing error:', parseError.textContent);
|
||||
throw new Error('Generated URDF has invalid XML: ' + parseError.textContent);
|
||||
}
|
||||
|
||||
// Remove problematic elements that urdf-loader might not handle well
|
||||
// Remove gazebo elements (plugins, sensors, etc.)
|
||||
const gazeboElements = cleanUrdfXML.querySelectorAll('gazebo');
|
||||
gazeboElements.forEach(el => el.parentNode?.removeChild(el));
|
||||
|
||||
// Remove transmission elements (these are for Gazebo/ROS control)
|
||||
const transmissionElements = cleanUrdfXML.querySelectorAll('transmission');
|
||||
transmissionElements.forEach(el => el.parentNode?.removeChild(el));
|
||||
|
||||
// Remove visual/collision elements with empty geometry tags
|
||||
this.removeEmptyGeometry(cleanUrdfXML);
|
||||
|
||||
// Clean up empty text nodes and comments that might cause issues
|
||||
this.cleanXMLNodes(cleanUrdfXML.documentElement);
|
||||
|
||||
// Convert the clean XMLDocument back to string for URDFLoader
|
||||
const finalUrdfString = serializer.serializeToString(cleanUrdfXML);
|
||||
|
||||
// Now use existing URDF loading infrastructure
|
||||
// Import URDFLoader dynamically
|
||||
const urdfModule = await import('urdf-loader');
|
||||
const URDFLoader = urdfModule.URDFLoader || urdfModule.default || urdfModule;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const loader = new URDFLoader();
|
||||
loader.parseCollision = true;
|
||||
|
||||
// Extract directory where URDF file is located
|
||||
const urdfDir = workingPath;
|
||||
|
||||
// If file map provided, setup resource loader (same as URDF loading)
|
||||
if (fileMap) {
|
||||
// Parse URDF content, find all used package names
|
||||
const packages = this.extractPackagesFromURDF(finalUrdfString);
|
||||
|
||||
// Build package map
|
||||
const packageMap = {};
|
||||
packages.forEach(pkg => {
|
||||
packageMap[pkg] = pkg;
|
||||
});
|
||||
packageMap[''] = '';
|
||||
|
||||
loader.packages = packageMap;
|
||||
|
||||
// Set URL Modifier (same as URDF loading)
|
||||
const urlModifier = (url) => {
|
||||
// Handle blob URLs
|
||||
if (url.startsWith('blob:')) {
|
||||
const blobMatch = url.match(/^blob:https?:\/\/[^\/]+\/(.+)$/);
|
||||
if (blobMatch && blobMatch[1]) {
|
||||
const fileName = blobMatch[1];
|
||||
if (/\.(jpg|jpeg|png|gif|bmp|tga|tiff|webp|dae|stl|obj|gltf|glb)$/i.test(fileName)) {
|
||||
url = fileName;
|
||||
} else {
|
||||
return url;
|
||||
}
|
||||
} else {
|
||||
return url;
|
||||
}
|
||||
}
|
||||
|
||||
const isTextureFile = /\.(jpg|jpeg|png|gif|bmp|tga|tiff|webp)$/i.test(url);
|
||||
const isMeshFile = /\.(dae|stl|obj|gltf|glb)$/i.test(url);
|
||||
|
||||
let meshPath = url;
|
||||
|
||||
// Remove http:// or https:// prefix
|
||||
if (meshPath.startsWith('http://') || meshPath.startsWith('https://')) {
|
||||
try {
|
||||
const urlObj = new URL(meshPath);
|
||||
meshPath = urlObj.pathname;
|
||||
if (meshPath.startsWith('/')) {
|
||||
meshPath = meshPath.substring(1);
|
||||
}
|
||||
} catch (e) {
|
||||
// Invalid URL, use as-is
|
||||
}
|
||||
}
|
||||
|
||||
// Remove package:// prefix
|
||||
if (meshPath.startsWith('package://')) {
|
||||
meshPath = meshPath.replace(/^package:\/\//, '');
|
||||
const parts = meshPath.split('/');
|
||||
if (parts.length > 1) {
|
||||
meshPath = parts.slice(1).join('/');
|
||||
}
|
||||
}
|
||||
|
||||
// Remove leading ./
|
||||
meshPath = meshPath.replace(/^\.\//, '');
|
||||
|
||||
// Handle relative paths
|
||||
let normalizedPath = meshPath;
|
||||
if (meshPath.includes('../')) {
|
||||
const parts = meshPath.split('/');
|
||||
const resolvedParts = [];
|
||||
for (const part of parts) {
|
||||
if (part === '..') {
|
||||
resolvedParts.pop();
|
||||
} else if (part !== '.' && part !== '') {
|
||||
resolvedParts.push(part);
|
||||
}
|
||||
}
|
||||
normalizedPath = resolvedParts.join('/');
|
||||
}
|
||||
|
||||
// Build full path
|
||||
const fullPath = urdfDir + normalizedPath;
|
||||
const altPath = urdfDir + meshPath;
|
||||
|
||||
// Find file in fileMap
|
||||
let matchedFile = fileMap.get(fullPath);
|
||||
|
||||
if (!matchedFile && altPath !== fullPath) {
|
||||
matchedFile = fileMap.get(altPath);
|
||||
}
|
||||
|
||||
if (!matchedFile) {
|
||||
matchedFile = fileMap.get(normalizedPath);
|
||||
}
|
||||
|
||||
if (!matchedFile) {
|
||||
matchedFile = fileMap.get(meshPath);
|
||||
}
|
||||
|
||||
if (!matchedFile) {
|
||||
const targetFileName = normalizedPath.split('/').pop() || meshPath.split('/').pop();
|
||||
for (const [key, file] of fileMap.entries()) {
|
||||
const keyFileName = key.split('/').pop();
|
||||
if (keyFileName === targetFileName) {
|
||||
matchedFile = file;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (matchedFile) {
|
||||
const bloburl = URL.createObjectURL(matchedFile);
|
||||
return bloburl;
|
||||
}
|
||||
|
||||
return url;
|
||||
};
|
||||
|
||||
loader.manager.setURLModifier(urlModifier);
|
||||
|
||||
// Custom loadMeshCb (same as URDF loading)
|
||||
const originalLoadMeshCb = loader.loadMeshCb || loader.defaultMeshLoader.bind(loader);
|
||||
loader.loadMeshCb = (path, manager, done) => {
|
||||
this.findFileInMapByPath(path, fileMap, urdfDir).then(file => {
|
||||
if (file) {
|
||||
const ext = (file.name || path).toLowerCase().split('.').pop();
|
||||
this.loadMeshFileAsync(file, ext, manager).then(meshObject => {
|
||||
if (meshObject) {
|
||||
done(meshObject, null);
|
||||
} else {
|
||||
done(null, new Error(`Failed to load mesh file: ${path}`));
|
||||
}
|
||||
}).catch(err => {
|
||||
console.error(`Failed to load mesh: ${path}`, err);
|
||||
done(null, err);
|
||||
});
|
||||
} else {
|
||||
let stlPath = path
|
||||
stlPath = stlPath.replace('file:///', 'inspection/elfin10')
|
||||
console.log(stlPath, manager, done)
|
||||
originalLoadMeshCb(path, manager, done);
|
||||
}
|
||||
}).catch(error => {
|
||||
console.error(`Failed to find file: ${path}`, error);
|
||||
originalLoadMeshCb(path, manager, done);
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
// Create temporary URL for URDF content
|
||||
const blob = new Blob([finalUrdfString], { type: 'text/xml' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
|
||||
loader.load(url, (robot) => {
|
||||
URL.revokeObjectURL(url);
|
||||
|
||||
// Convert to unified model using URDFAdapter
|
||||
try {
|
||||
const model = URDFAdapter.convert(robot, finalUrdfString);
|
||||
resolve(model);
|
||||
} catch (error) {
|
||||
console.error('[XacroAdapter] URDF conversion error:', error);
|
||||
reject(new Error('URDF conversion failed: ' + error.message));
|
||||
}
|
||||
}, undefined, (error) => {
|
||||
URL.revokeObjectURL(url);
|
||||
console.error('[XacroAdapter] URDF loading error:', error);
|
||||
reject(new Error('URDF loading failed: ' + (error.message || error)));
|
||||
});
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[XacroAdapter] Xacro parsing error:', error);
|
||||
throw new Error('Xacro parsing failed: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load file from fileMap for xacro includes
|
||||
* @param {string} path - File path
|
||||
* @param {Map} fileMap - File map
|
||||
* @param {string} workingPath - Working directory
|
||||
* @returns {Promise<string>}
|
||||
*/
|
||||
static async loadFileFromMap(path, fileMap, workingPath) {
|
||||
console.log('path', path)
|
||||
console.log('fileMap', fileMap)
|
||||
console.log('workingPath', workingPath)
|
||||
// Clean path - remove leading slash if present
|
||||
let cleanPath = path;
|
||||
// if (cleanPath.startsWith('/')) {
|
||||
// cleanPath = cleanPath.substring(1);
|
||||
// }
|
||||
|
||||
// // Remove leading ./
|
||||
// cleanPath = cleanPath.replace(/^\.\//, '');
|
||||
cleanPath = cleanPath.split('///')[1];
|
||||
console.log('cleanPath', cleanPath)
|
||||
// Try different path combinations
|
||||
const possiblePaths = [
|
||||
cleanPath,
|
||||
workingPath + cleanPath,
|
||||
path,
|
||||
workingPath + path.replace(/^\/+/, ''),
|
||||
// Also try without the first directory component
|
||||
cleanPath.includes('/') ? cleanPath.substring(cleanPath.indexOf('/') + 1) : cleanPath,
|
||||
];
|
||||
console.log('possiblePaths', possiblePaths)
|
||||
// Try each path
|
||||
for (const tryPath of possiblePaths) {
|
||||
console.log('tryPath', tryPath)
|
||||
const file = fileMap.get(tryPath);
|
||||
if (file) {
|
||||
console.log(12345)
|
||||
const content = await file.text();
|
||||
return content;
|
||||
}
|
||||
}
|
||||
|
||||
// Try filename only match
|
||||
const fileName = cleanPath.split('/').pop();
|
||||
for (const [key, file] of fileMap.entries()) {
|
||||
const keyFileName = key.split('/').pop();
|
||||
if (keyFileName === fileName) {
|
||||
const content = await file.text();
|
||||
return content;
|
||||
}
|
||||
}
|
||||
|
||||
console.error('[XacroAdapter] Cannot find included file:', path);
|
||||
throw new Error(`Cannot find included file: ${path}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove visual/collision elements with empty geometry tags
|
||||
* xacro-parser may not fully expand conditional geometry (xacro:if)
|
||||
* We'll just remove these empty visual elements rather than try to fix them
|
||||
* @param {XMLDocument} xmlDoc - XML document to clean
|
||||
*/
|
||||
static removeEmptyGeometry(xmlDoc) {
|
||||
// Find all geometry elements
|
||||
const geometryElements = xmlDoc.querySelectorAll('geometry');
|
||||
|
||||
geometryElements.forEach(geom => {
|
||||
// Check if geometry is empty (no child elements)
|
||||
if (!geom.children || geom.children.length === 0) {
|
||||
// Find the parent visual or collision element
|
||||
let parent = geom.parentNode;
|
||||
if (parent) {
|
||||
const grandParent = parent.parentNode;
|
||||
if (grandParent) {
|
||||
if (parent.nodeName === 'visual' || parent.nodeName === 'collision') {
|
||||
// Remove empty visual/collision elements
|
||||
grandParent.removeChild(parent);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively clean XML nodes - remove empty text nodes and comments
|
||||
* @param {Element} element - Element to clean
|
||||
*/
|
||||
static cleanXMLNodes(element) {
|
||||
if (!element || !element.childNodes) return;
|
||||
|
||||
const nodesToRemove = [];
|
||||
|
||||
// Collect nodes to remove
|
||||
for (let i = 0; i < element.childNodes.length; i++) {
|
||||
const node = element.childNodes[i];
|
||||
|
||||
// Remove comments
|
||||
if (node.nodeType === Node.COMMENT_NODE) {
|
||||
nodesToRemove.push(node);
|
||||
}
|
||||
// Remove empty or whitespace-only text nodes
|
||||
else if (node.nodeType === Node.TEXT_NODE) {
|
||||
if (!node.nodeValue || node.nodeValue.trim() === '') {
|
||||
nodesToRemove.push(node);
|
||||
}
|
||||
}
|
||||
// Recursively clean element nodes
|
||||
else if (node.nodeType === Node.ELEMENT_NODE) {
|
||||
this.cleanXMLNodes(node);
|
||||
}
|
||||
}
|
||||
|
||||
// Remove collected nodes
|
||||
nodesToRemove.forEach(node => {
|
||||
element.removeChild(node);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract package names from URDF content
|
||||
* @param {string} urdfContent - URDF content
|
||||
* @returns {Set<string>}
|
||||
*/
|
||||
static extractPackagesFromURDF(urdfContent) {
|
||||
const packages = new Set();
|
||||
const packageRegex = /package:\/\/([^\/]+)/g;
|
||||
let match;
|
||||
while ((match = packageRegex.exec(urdfContent)) !== null) {
|
||||
packages.add(match[1]);
|
||||
}
|
||||
return packages;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find file in fileMap by path
|
||||
* @param {string} path - File path
|
||||
* @param {Map} fileMap - File map
|
||||
* @param {string} urdfDir - URDF directory
|
||||
* @returns {Promise<File|null>}
|
||||
*/
|
||||
static async findFileInMapByPath(path, fileMap, urdfDir) {
|
||||
let meshPath = path;
|
||||
|
||||
// Remove blob: prefix
|
||||
meshPath = meshPath.replace(/^blob:[^\/]+\//, '');
|
||||
|
||||
// Remove package:// prefix
|
||||
if (meshPath.startsWith('package://')) {
|
||||
meshPath = meshPath.replace(/^package:\/\//, '');
|
||||
const parts = meshPath.split('/');
|
||||
if (parts.length > 1) {
|
||||
meshPath = parts.slice(1).join('/');
|
||||
}
|
||||
}
|
||||
|
||||
// Remove leading ./
|
||||
meshPath = meshPath.replace(/^\.\//, '');
|
||||
|
||||
// Build full path
|
||||
const fullPath = urdfDir + meshPath;
|
||||
|
||||
// Try full path
|
||||
let file = fileMap.get(fullPath);
|
||||
if (file) return file;
|
||||
|
||||
// Try path without directory
|
||||
file = fileMap.get(meshPath);
|
||||
if (file) return file;
|
||||
|
||||
// Try filename match
|
||||
const targetFileName = meshPath.split('/').pop();
|
||||
for (const [key, f] of fileMap.entries()) {
|
||||
const keyFileName = key.split('/').pop();
|
||||
if (keyFileName === targetFileName) {
|
||||
return f;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load mesh file asynchronously
|
||||
* @param {File} file - File object
|
||||
* @param {string} ext - File extension
|
||||
* @param {THREE.LoadingManager} manager - Loading manager
|
||||
* @returns {Promise<THREE.Object3D>}
|
||||
*/
|
||||
static async loadMeshFileAsync(file, ext, manager) {
|
||||
const THREE = await import('three');
|
||||
const blobUrl = URL.createObjectURL(file);
|
||||
|
||||
try {
|
||||
let meshObject = null;
|
||||
|
||||
switch (ext) {
|
||||
case 'stl': {
|
||||
const { STLLoader } = await import('three/examples/jsm/loaders/STLLoader.js');
|
||||
const stlLoader = new STLLoader(manager);
|
||||
const stlGeometry = await new Promise((resolve, reject) => {
|
||||
stlLoader.load(blobUrl, resolve, undefined, reject);
|
||||
});
|
||||
const stlMaterial = new THREE.MeshPhongMaterial();
|
||||
meshObject = new THREE.Mesh(stlGeometry, stlMaterial);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'dae': {
|
||||
const { ColladaLoader } = await import('three/examples/jsm/loaders/ColladaLoader.js');
|
||||
const colladaLoader = new ColladaLoader(manager);
|
||||
const colladaModel = await new Promise((resolve, reject) => {
|
||||
colladaLoader.load(blobUrl, resolve, undefined, reject);
|
||||
});
|
||||
meshObject = colladaModel.scene;
|
||||
|
||||
// Remove lights
|
||||
if (meshObject && meshObject.traverse) {
|
||||
const lightsToRemove = [];
|
||||
meshObject.traverse(child => {
|
||||
if (child.isLight) {
|
||||
lightsToRemove.push(child);
|
||||
}
|
||||
});
|
||||
lightsToRemove.forEach(light => {
|
||||
if (light.parent) {
|
||||
light.parent.remove(light);
|
||||
}
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'obj': {
|
||||
const { OBJLoader } = await import('three/examples/jsm/loaders/OBJLoader.js');
|
||||
const objLoader = new OBJLoader(manager);
|
||||
meshObject = await new Promise((resolve, reject) => {
|
||||
objLoader.load(blobUrl, resolve, undefined, reject);
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
case 'gltf':
|
||||
case 'glb': {
|
||||
const { GLTFLoader } = await import('three/examples/jsm/loaders/GLTFLoader.js');
|
||||
const gltfLoader = new GLTFLoader(manager);
|
||||
const gltfModel = await new Promise((resolve, reject) => {
|
||||
gltfLoader.load(blobUrl, resolve, undefined, reject);
|
||||
});
|
||||
meshObject = gltfModel.scene;
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
console.warn(`Unsupported file format: ${ext}`);
|
||||
URL.revokeObjectURL(blobUrl);
|
||||
return null;
|
||||
}
|
||||
|
||||
URL.revokeObjectURL(blobUrl);
|
||||
return meshObject;
|
||||
} catch (error) {
|
||||
URL.revokeObjectURL(blobUrl);
|
||||
console.error(`Failed to load mesh file: ${file.name}`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Inject Python-style boolean constants (True/False) into xacro content
|
||||
* Some xacro files use capitalized True/False in conditional expressions
|
||||
* The parameters are passed as strings ("True", "False") but compared to constants
|
||||
* We define the constants to match the string values for comparison to work
|
||||
* @param {string} xacroContent - Original xacro content
|
||||
* @returns {string} - Modified xacro content with boolean constants defined
|
||||
*/
|
||||
static injectBooleanConstants(xacroContent) {
|
||||
// Find the robot tag opening
|
||||
const robotTagMatch = xacroContent.match(/(<robot[^>]*>)/);
|
||||
if (!robotTagMatch) {
|
||||
return xacroContent;
|
||||
}
|
||||
|
||||
// Define True/False as string properties for comparison
|
||||
// When mirror_dae="True" is passed, it becomes the string "True"
|
||||
// We want ${mirror_dae == True} to work, so True must also be "True"
|
||||
const booleanProperties = `
|
||||
<!-- Injected by XacroAdapter: Python-style boolean constants -->
|
||||
<xacro:property name="True" value="True"/>
|
||||
<xacro:property name="False" value="False"/>
|
||||
`;
|
||||
|
||||
const insertPosition = robotTagMatch.index + robotTagMatch[0].length;
|
||||
return xacroContent.substring(0, insertPosition) +
|
||||
booleanProperties +
|
||||
xacroContent.substring(insertPosition);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract xacro arguments and their default values from xacro content
|
||||
* Xacro files can define arguments using <xacro:arg name="..." default="..."/>
|
||||
* These need to be provided to the parser via parser.arguments
|
||||
* @param {string} xacroContent - Xacro file content
|
||||
* @returns {Object} - Object with argument names as keys and default values
|
||||
*/
|
||||
static extractXacroArguments(xacroContent) {
|
||||
const args = {};
|
||||
|
||||
// Match <xacro:arg name="NAME" default="VALUE"/>
|
||||
// Also match <arg name="NAME" default="VALUE"/> (without xacro: prefix)
|
||||
const argPattern = /<(?:xacro:)?arg\s+name=["']([^"']+)["'](?:\s+default=["']([^"']*)["'])?/g;
|
||||
|
||||
let match;
|
||||
while ((match = argPattern.exec(xacroContent)) !== null) {
|
||||
const argName = match[1];
|
||||
const defaultValue = match[2] || 'false'; // Default to 'false' if no default specified
|
||||
args[argName] = defaultValue;
|
||||
}
|
||||
|
||||
return args;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
553
src/controllers/FileHandler.js
Normal file
553
src/controllers/FileHandler.js
Normal file
@ -0,0 +1,553 @@
|
||||
/**
|
||||
* FileHandler - File handling module
|
||||
* Handles file drag-drop, loading, type detection, etc.
|
||||
*/
|
||||
import * as THREE from 'three';
|
||||
import { ModelLoaderFactory } from '../loaders/ModelLoaderFactory.js';
|
||||
import { readFileContent, getFileFromEntry, getFileTypeFromExtension, getFileDisplayType } from '../utils/FileUtils.js';
|
||||
|
||||
export class FileHandler {
|
||||
constructor() {
|
||||
this.fileMap = new Map();
|
||||
this.availableModels = [];
|
||||
this.currentModelFile = null;
|
||||
this.onModelLoaded = null; // Callback function
|
||||
this.usdViewerManager = null; // USD viewer manager (lazy loaded)
|
||||
}
|
||||
|
||||
/**
|
||||
* Set USD viewer manager
|
||||
*/
|
||||
setUSDViewerManager(manager) {
|
||||
this.usdViewerManager = manager;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set USD viewer initializer callback (for lazy loading)
|
||||
*/
|
||||
setUSDViewerInitializer(initializer) {
|
||||
this.usdViewerInitializer = initializer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup file drag-drop
|
||||
*/
|
||||
setupFileDrop() {
|
||||
const body = document.body;
|
||||
|
||||
const preventDefaults = (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
};
|
||||
|
||||
['dragenter', 'dragover', 'dragleave', 'drop'].forEach(eventName => {
|
||||
body.addEventListener(eventName, preventDefaults, false);
|
||||
});
|
||||
|
||||
let dragCounter = 0;
|
||||
body.addEventListener('dragenter', (e) => {
|
||||
dragCounter++;
|
||||
const dropZone = document.getElementById('drop-zone');
|
||||
if (dropZone) dropZone.classList.add('drag-over');
|
||||
}, false);
|
||||
|
||||
body.addEventListener('dragleave', (e) => {
|
||||
dragCounter--;
|
||||
if (dragCounter === 0) {
|
||||
const dropZone = document.getElementById('drop-zone');
|
||||
if (dropZone) dropZone.classList.remove('drag-over');
|
||||
}
|
||||
}, false);
|
||||
|
||||
body.addEventListener('drop', (e) => {
|
||||
dragCounter = 0;
|
||||
const dropZone = document.getElementById('drop-zone');
|
||||
if (dropZone) dropZone.classList.remove('drag-over');
|
||||
this.handleDrop(e);
|
||||
}, false);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Handle file drop
|
||||
*/
|
||||
async handleDrop(e) {
|
||||
const items = e.dataTransfer.items;
|
||||
if (!items || items.length === 0) return;
|
||||
|
||||
this.fileMap.clear();
|
||||
|
||||
const entries = [];
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
const item = items[i];
|
||||
if (item.webkitGetAsEntry) {
|
||||
const entry = item.webkitGetAsEntry();
|
||||
if (entry) {
|
||||
entries.push(entry);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (entries.length > 0) {
|
||||
await this.processEntries(entries);
|
||||
} else {
|
||||
const files = e.dataTransfer.files;
|
||||
if (files.length > 0) {
|
||||
for (const file of files) {
|
||||
const path = file.webkitRelativePath || file.name;
|
||||
this.fileMap.set(path, file);
|
||||
// Only add file.name as a separate key if there's no webkitRelativePath
|
||||
// This prevents duplicate entries in the file tree
|
||||
if (!file.webkitRelativePath) {
|
||||
this.fileMap.set(file.name, file);
|
||||
}
|
||||
}
|
||||
|
||||
const loadableFiles = await this.findAllLoadableFiles(Array.from(files));
|
||||
if (loadableFiles.length > 0) {
|
||||
this.availableModels = loadableFiles;
|
||||
this.onFilesLoaded?.(loadableFiles);
|
||||
await this.loadFileOrMesh(loadableFiles[0]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process file system entries
|
||||
*/
|
||||
async processEntries(entries) {
|
||||
const files = [];
|
||||
|
||||
for (const entry of entries) {
|
||||
if (entry.isFile) {
|
||||
const file = await getFileFromEntry(entry);
|
||||
const path = entry.fullPath || entry.name;
|
||||
this.fileMap.set(path, file);
|
||||
files.push(file);
|
||||
} else if (entry.isDirectory) {
|
||||
const dirFiles = await this.readDirectory(entry);
|
||||
files.push(...dirFiles);
|
||||
}
|
||||
}
|
||||
|
||||
if (files.length === 0) return;
|
||||
|
||||
const loadableFiles = await this.findAllLoadableFiles(files);
|
||||
|
||||
if (loadableFiles.length === 0) {
|
||||
this.onFilesLoaded?.([]);
|
||||
return;
|
||||
}
|
||||
|
||||
this.availableModels = loadableFiles;
|
||||
this.onFilesLoaded?.(loadableFiles);
|
||||
|
||||
if (loadableFiles.length > 0) {
|
||||
await this.loadFileOrMesh(loadableFiles[0]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively read directory
|
||||
*/
|
||||
async readDirectory(dirEntry) {
|
||||
const files = [];
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = dirEntry.createReader();
|
||||
|
||||
const readEntries = () => {
|
||||
reader.readEntries(async (entries) => {
|
||||
if (entries.length === 0) {
|
||||
resolve(files);
|
||||
return;
|
||||
}
|
||||
|
||||
for (const entry of entries) {
|
||||
if (entry.isFile) {
|
||||
const file = await getFileFromEntry(entry);
|
||||
const path = entry.fullPath || entry.name;
|
||||
this.fileMap.set(path, file);
|
||||
files.push(file);
|
||||
} else if (entry.isDirectory) {
|
||||
const subFiles = await this.readDirectory(entry);
|
||||
files.push(...subFiles);
|
||||
}
|
||||
}
|
||||
|
||||
readEntries();
|
||||
}, reject);
|
||||
};
|
||||
|
||||
readEntries();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Find all loadable files
|
||||
*/
|
||||
async findAllLoadableFiles(files) {
|
||||
const supportedExtensions = {
|
||||
model: ['urdf', 'xacro', 'xml', 'usd', 'usda', 'usdc', 'usdz'],
|
||||
mesh: ['dae', 'stl', 'obj', 'collada']
|
||||
};
|
||||
const loadableFiles = [];
|
||||
|
||||
const checkPromises = files.map(async (file) => {
|
||||
const ext = file.name.toLowerCase().split('.').pop();
|
||||
|
||||
if (supportedExtensions.model.includes(ext)) {
|
||||
if (ext === 'xml') {
|
||||
try {
|
||||
const content = await readFileContent(file);
|
||||
const fileType = ModelLoaderFactory.detectFileType(file.name, content);
|
||||
|
||||
if (!fileType) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
file: file,
|
||||
name: file.name,
|
||||
type: fileType,
|
||||
path: file.webkitRelativePath || file.name,
|
||||
category: 'model'
|
||||
};
|
||||
} catch (error) {
|
||||
console.error(`Failed to read XML file: ${file.name}`, error);
|
||||
return null;
|
||||
}
|
||||
} else {
|
||||
const fileType = getFileTypeFromExtension(ext);
|
||||
return {
|
||||
file: file,
|
||||
name: file.name,
|
||||
type: fileType,
|
||||
path: file.webkitRelativePath || file.name,
|
||||
category: 'model'
|
||||
};
|
||||
}
|
||||
} else if (supportedExtensions.mesh.includes(ext)) {
|
||||
return {
|
||||
file: file,
|
||||
name: file.name,
|
||||
type: 'mesh',
|
||||
path: file.webkitRelativePath || file.name,
|
||||
category: 'mesh'
|
||||
};
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
const results = await Promise.all(checkPromises);
|
||||
|
||||
results.forEach(result => {
|
||||
if (result) {
|
||||
loadableFiles.push(result);
|
||||
}
|
||||
});
|
||||
|
||||
loadableFiles.sort((a, b) => {
|
||||
if (a.category !== b.category) {
|
||||
return a.category === 'model' ? -1 : 1;
|
||||
}
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
|
||||
return loadableFiles;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load file or mesh
|
||||
*/
|
||||
async loadFileOrMesh(fileInfo) {
|
||||
if (fileInfo.category === 'model') {
|
||||
await this.loadFile(fileInfo.file);
|
||||
} else if (fileInfo.category === 'mesh') {
|
||||
await this.loadMeshAsModel(fileInfo.file, fileInfo.name);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load model file
|
||||
*/
|
||||
async loadFile(file) {
|
||||
this.currentModelFile = file;
|
||||
|
||||
try {
|
||||
const fileName = file.name.toLowerCase();
|
||||
|
||||
// Get the full path from fileMap
|
||||
let fullPath = file.name;
|
||||
for (const [path, f] of this.fileMap.entries()) {
|
||||
if (f === file) {
|
||||
fullPath = path;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Handle USD format files (all USD formats use WASM)
|
||||
const isUSD = fileName.endsWith('.usd') || fileName.endsWith('.usda') ||
|
||||
fileName.endsWith('.usdc') || fileName.endsWith('.usdz');
|
||||
if (isUSD) {
|
||||
// Ensure USD viewer is initialized
|
||||
if (!this.usdViewerManager && this.usdViewerInitializer) {
|
||||
try {
|
||||
await this.usdViewerInitializer();
|
||||
} catch (error) {
|
||||
console.error('USD viewer initialization failed:', error);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (this.fileMap.size === 0) {
|
||||
this.fileMap.set(file.name, file);
|
||||
}
|
||||
|
||||
// USD files need File object passed
|
||||
const model = await ModelLoaderFactory.loadModel(
|
||||
'usd',
|
||||
null,
|
||||
fullPath,
|
||||
this.fileMap,
|
||||
file,
|
||||
{ usdViewerManager: this.usdViewerManager }
|
||||
);
|
||||
|
||||
this.onModelLoaded?.(model, file, false, null);
|
||||
document.getElementById('drop-zone')?.classList.remove('show');
|
||||
document.getElementById('drop-zone')?.classList.remove('drag-over');
|
||||
return;
|
||||
}
|
||||
|
||||
// For other files, read text content
|
||||
const content = await readFileContent(file);
|
||||
|
||||
// Detect if USDC binary format (based on content)
|
||||
if (this.isUSDCBinaryContent(content)) {
|
||||
console.error('Cannot load USDC binary format, please convert to USDZ or USDA');
|
||||
return;
|
||||
}
|
||||
|
||||
const fileType = ModelLoaderFactory.detectFileType(file.name, content);
|
||||
|
||||
if (!fileType) {
|
||||
console.error(`${window.i18n.t('unsupportedFormat')}: ${file.name}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.fileMap.size === 0) {
|
||||
this.fileMap.set(file.name, file);
|
||||
}
|
||||
|
||||
const model = await ModelLoaderFactory.loadModel(
|
||||
fileType,
|
||||
content,
|
||||
fullPath, // Use full path instead of just file.name
|
||||
this.fileMap,
|
||||
file,
|
||||
{ usdViewerManager: this.usdViewerManager }
|
||||
);
|
||||
|
||||
// Notify model loaded (pass null as snapshot, let main.js create it)
|
||||
this.onModelLoaded?.(model, file, false, null);
|
||||
|
||||
document.getElementById('drop-zone')?.classList.remove('show');
|
||||
document.getElementById('drop-zone')?.classList.remove('drag-over');
|
||||
|
||||
} catch (error) {
|
||||
console.error('Failed to load file:', error);
|
||||
|
||||
// If error message contains USDC related content
|
||||
if (error.message && (error.message.includes('USDC') || error.message.includes('binary format'))) {
|
||||
console.error('Cannot load USDC binary format, please convert to USDZ or USDA');
|
||||
} else {
|
||||
console.error(`${window.i18n.t('loadFailed')}: ${error.message}`);
|
||||
}
|
||||
|
||||
// Remove snapshot if exists
|
||||
const snapshot = document.getElementById('canvas-snapshot');
|
||||
if (snapshot?.parentNode) {
|
||||
snapshot.parentNode.removeChild(snapshot);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect if file content is USDC binary format
|
||||
*/
|
||||
isUSDCBinaryContent(content) {
|
||||
if (!content || typeof content !== 'string') {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Detect USDC binary format characteristics
|
||||
// 1. Starts with "PXR-USDC" (USDC file magic bytes)
|
||||
if (content.startsWith('PXR-USDC')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 2. Check first 100 bytes, if contains many null bytes, likely binary
|
||||
const firstBytes = content.substring(0, 100);
|
||||
const nullCount = (firstBytes.match(/\x00/g) || []).length;
|
||||
if (nullCount > 10) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 3. Check if valid ASCII USD file
|
||||
// Valid USDA files usually start with #usda or contain def keywords
|
||||
if (content.includes('#usda') ||
|
||||
(content.includes('def ') && content.includes('{'))) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 4. File starts with non-printable characters (excluding newline, carriage return, tab, space)
|
||||
const firstChar = content.charCodeAt(0);
|
||||
if (firstChar < 32 && firstChar !== 10 && firstChar !== 13 && firstChar !== 9 && firstChar !== 32) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create loading snapshot
|
||||
*/
|
||||
createLoadingSnapshot() {
|
||||
const canvas = document.getElementById('canvas');
|
||||
if (!canvas) return null;
|
||||
|
||||
try {
|
||||
const dataURL = canvas.toDataURL('image/png');
|
||||
const snapshot = document.createElement('div');
|
||||
snapshot.id = 'canvas-snapshot';
|
||||
snapshot.style.cssText = `
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-image: url(${dataURL});
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
background-color: var(--bg-primary);
|
||||
background-repeat: no-repeat;
|
||||
z-index: 2;
|
||||
pointer-events: none;
|
||||
`;
|
||||
|
||||
const canvasContainer = document.getElementById('canvas-container');
|
||||
if (canvasContainer) {
|
||||
canvasContainer.appendChild(snapshot);
|
||||
}
|
||||
|
||||
return snapshot;
|
||||
} catch (error) {
|
||||
console.error('Failed to create snapshot:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove loading snapshot
|
||||
*/
|
||||
removeLoadingSnapshot(snapshot) {
|
||||
if (!snapshot || !snapshot.parentNode) return;
|
||||
|
||||
snapshot.style.transition = 'opacity 0.3s ease';
|
||||
snapshot.style.opacity = '0';
|
||||
|
||||
setTimeout(() => {
|
||||
if (snapshot.parentNode) {
|
||||
snapshot.parentNode.removeChild(snapshot);
|
||||
}
|
||||
}, 300);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load single mesh file as model
|
||||
*/
|
||||
async loadMeshAsModel(file, fileName) {
|
||||
try {
|
||||
const meshObject = await ModelLoaderFactory.loadMeshFileDirect(file, fileName);
|
||||
|
||||
if (!meshObject) {
|
||||
throw new Error(window.i18n.t('cannotLoadMesh'));
|
||||
}
|
||||
|
||||
// Ensure mesh materials support lighting and shadows
|
||||
meshObject.traverse((child) => {
|
||||
if (child.isMesh) {
|
||||
if (child.material?.type === 'MeshBasicMaterial') {
|
||||
const oldMaterial = child.material;
|
||||
child.material = new THREE.MeshPhongMaterial({
|
||||
color: oldMaterial.color,
|
||||
map: oldMaterial.map,
|
||||
transparent: oldMaterial.transparent,
|
||||
opacity: oldMaterial.opacity,
|
||||
side: oldMaterial.side,
|
||||
shininess: 30
|
||||
});
|
||||
if (child.material.map) {
|
||||
child.material.map.colorSpace = THREE.SRGBColorSpace;
|
||||
}
|
||||
}
|
||||
child.castShadow = true;
|
||||
child.receiveShadow = true;
|
||||
}
|
||||
});
|
||||
|
||||
const simpleMeshModel = {
|
||||
name: fileName,
|
||||
rootLink: 'mesh_root',
|
||||
links: new Map([
|
||||
['mesh_root', {
|
||||
name: 'mesh_root',
|
||||
threeObject: meshObject,
|
||||
visuals: [{
|
||||
threeObject: meshObject,
|
||||
geometry: { mesh: fileName }
|
||||
}],
|
||||
inertial: null
|
||||
}]
|
||||
]),
|
||||
joints: new Map(),
|
||||
threeObject: meshObject
|
||||
};
|
||||
|
||||
this.currentModelFile = file;
|
||||
this.onModelLoaded?.(simpleMeshModel, file, true, null);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Failed to load mesh file:', error);
|
||||
|
||||
const snapshot = document.getElementById('canvas-snapshot');
|
||||
if (snapshot?.parentNode) {
|
||||
snapshot.parentNode.removeChild(snapshot);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get file map
|
||||
*/
|
||||
getFileMap() {
|
||||
return this.fileMap;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get available models list
|
||||
*/
|
||||
getAvailableModels() {
|
||||
return this.availableModels;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current model file
|
||||
*/
|
||||
getCurrentModelFile() {
|
||||
return this.currentModelFile;
|
||||
}
|
||||
}
|
||||
|
||||
181
src/models/UnifiedRobotModel.js
Normal file
181
src/models/UnifiedRobotModel.js
Normal file
@ -0,0 +1,181 @@
|
||||
/**
|
||||
* Unified robot model data interface
|
||||
* All formats (URDF, MJCF, USD) are converted to this unified format
|
||||
*/
|
||||
export class UnifiedRobotModel {
|
||||
constructor() {
|
||||
this.name = '';
|
||||
this.links = new Map(); // Map<name, Link>
|
||||
this.joints = new Map(); // Map<name, Joint>
|
||||
this.materials = new Map(); // Map<name, Material>
|
||||
this.constraints = new Map(); // Map<name, Constraint> - for parallel mechanism constraints
|
||||
this.rootLink = null; // Root link name
|
||||
this.threeObject = null; // Three.js object (if available)
|
||||
}
|
||||
|
||||
addLink(link) {
|
||||
this.links.set(link.name, link);
|
||||
}
|
||||
|
||||
addJoint(joint) {
|
||||
this.joints.set(joint.name, joint);
|
||||
}
|
||||
|
||||
addConstraint(constraint) {
|
||||
this.constraints.set(constraint.name, constraint);
|
||||
}
|
||||
|
||||
getLink(name) {
|
||||
return this.links.get(name);
|
||||
}
|
||||
|
||||
getJoint(name) {
|
||||
return this.joints.get(name);
|
||||
}
|
||||
|
||||
getConstraint(name) {
|
||||
return this.constraints.get(name);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Link interface
|
||||
*/
|
||||
export class Link {
|
||||
constructor(name) {
|
||||
this.name = name;
|
||||
this.visuals = []; // VisualGeometry[]
|
||||
this.collisions = []; // CollisionGeometry[]
|
||||
this.inertial = null; // InertialProperties
|
||||
this.threeObject = null; // Three.js object
|
||||
this.userData = {}; // User-defined data (for adapters to store additional information)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* VisualGeometry interface
|
||||
*/
|
||||
export class VisualGeometry {
|
||||
constructor() {
|
||||
this.name = '';
|
||||
this.origin = { xyz: [0, 0, 0], rpy: [0, 0, 0] };
|
||||
this.geometry = null; // GeometryType
|
||||
this.material = null; // Material
|
||||
this.threeObject = null; // Three.js Mesh
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* CollisionGeometry interface
|
||||
*/
|
||||
export class CollisionGeometry {
|
||||
constructor() {
|
||||
this.name = '';
|
||||
this.origin = { xyz: [0, 0, 0], rpy: [0, 0, 0] };
|
||||
this.geometry = null; // GeometryType
|
||||
this.threeObject = null; // Three.js Mesh
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GeometryType interface
|
||||
*/
|
||||
export class GeometryType {
|
||||
constructor(type) {
|
||||
this.type = type; // 'box' | 'sphere' | 'cylinder' | 'mesh'
|
||||
this.size = null; // Size parameters (varies by type)
|
||||
this.filename = null; // Mesh file path (if mesh type)
|
||||
}
|
||||
|
||||
clone() {
|
||||
const cloned = new GeometryType(this.type);
|
||||
cloned.size = this.size ? { ...this.size } : null;
|
||||
cloned.filename = this.filename;
|
||||
return cloned;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* InertialProperties interface
|
||||
*/
|
||||
export class InertialProperties {
|
||||
constructor() {
|
||||
this.mass = 0;
|
||||
this.origin = { xyz: [0, 0, 0], rpy: [0, 0, 0] };
|
||||
this.ixx = 0;
|
||||
this.iyy = 0;
|
||||
this.izz = 0;
|
||||
this.ixy = 0;
|
||||
this.ixz = 0;
|
||||
this.iyz = 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Joint interface
|
||||
*/
|
||||
export class Joint {
|
||||
constructor(name, type) {
|
||||
this.name = name;
|
||||
this.type = type; // 'revolute' | 'prismatic' | 'fixed' | 'continuous'
|
||||
this.parent = null; // Parent link name
|
||||
this.child = null; // Child link name
|
||||
this.origin = { xyz: [0, 0, 0], rpy: [0, 0, 0] };
|
||||
this.axis = { xyz: [0, 0, 1] }; // Default z-axis
|
||||
this.limits = null; // JointLimits
|
||||
this.currentValue = 0; // Current joint value
|
||||
this.threeObject = null; // Three.js object (if available)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* JointLimits interface
|
||||
*/
|
||||
export class JointLimits {
|
||||
constructor() {
|
||||
this.lower = -Math.PI;
|
||||
this.upper = Math.PI;
|
||||
this.effort = null;
|
||||
this.velocity = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Material interface
|
||||
*/
|
||||
export class Material {
|
||||
constructor(name) {
|
||||
this.name = name;
|
||||
this.color = { r: 0.8, g: 0.8, b: 0.8 };
|
||||
this.texture = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Constraint interface - for describing closed-chain constraints of parallel mechanisms
|
||||
* Supports MuJoCo equality constraint types
|
||||
*/
|
||||
export class Constraint {
|
||||
constructor(name, type) {
|
||||
this.name = name;
|
||||
this.type = type; // 'connect' | 'weld' | 'joint' | 'tendon' | 'distance'
|
||||
|
||||
// Constraint objects (may be body, geom, joint, etc. depending on type)
|
||||
this.body1 = null;
|
||||
this.body2 = null;
|
||||
this.anchor = null; // Connection point coordinates
|
||||
this.torquescale = null; // Torque scale
|
||||
|
||||
// Joint constraint specific properties
|
||||
this.joint1 = null;
|
||||
this.joint2 = null;
|
||||
this.polycoef = null; // Polynomial coefficients [a0, a1, a2, a3, a4]
|
||||
|
||||
// Visualization object
|
||||
this.threeObject = null; // Three.js object for displaying constraint
|
||||
|
||||
// Original data (for debugging)
|
||||
this.userData = {};
|
||||
}
|
||||
}
|
||||
|
||||
144
src/utils/FileUtils.js
Normal file
144
src/utils/FileUtils.js
Normal file
@ -0,0 +1,144 @@
|
||||
/**
|
||||
* File operation utility functions
|
||||
*/
|
||||
|
||||
/**
|
||||
* Read file content as text
|
||||
*/
|
||||
export function readFileContent(file) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => resolve(e.target.result);
|
||||
reader.onerror = reject;
|
||||
reader.readAsText(file);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get File object from file system entry
|
||||
*/
|
||||
export function getFileFromEntry(entry) {
|
||||
return new Promise((resolve, reject) => {
|
||||
entry.file(resolve, reject);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively read directory
|
||||
*/
|
||||
export async function readDirectory(dirEntry, fileMap) {
|
||||
const files = [];
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = dirEntry.createReader();
|
||||
|
||||
function readEntries() {
|
||||
reader.readEntries(async (entries) => {
|
||||
if (entries.length === 0) {
|
||||
resolve(files);
|
||||
return;
|
||||
}
|
||||
|
||||
for (const entry of entries) {
|
||||
if (entry.isFile) {
|
||||
const file = await getFileFromEntry(entry);
|
||||
const path = entry.fullPath || entry.name;
|
||||
fileMap.set(path, file);
|
||||
files.push(file);
|
||||
} else if (entry.isDirectory) {
|
||||
const subFiles = await readDirectory(entry, fileMap);
|
||||
files.push(...subFiles);
|
||||
}
|
||||
}
|
||||
|
||||
readEntries();
|
||||
}, reject);
|
||||
}
|
||||
|
||||
readEntries();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get file type from extension
|
||||
*/
|
||||
export function getFileTypeFromExtension(ext) {
|
||||
const typeMap = {
|
||||
'urdf': 'urdf',
|
||||
'xacro': 'xacro',
|
||||
'xml': 'mjcf',
|
||||
'usd': 'usd',
|
||||
'usda': 'usd',
|
||||
'usdc': 'usd',
|
||||
'usdz': 'usd'
|
||||
};
|
||||
return typeMap[ext] || 'unknown';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get file display type
|
||||
*/
|
||||
export function getFileDisplayType(ext, fileName) {
|
||||
const modelExts = ['urdf', 'xacro', 'xml', 'usd', 'usda', 'usdc', 'usdz'];
|
||||
const meshExts = ['dae', 'stl', 'obj', 'collada'];
|
||||
|
||||
if (modelExts.includes(ext)) {
|
||||
return 'model';
|
||||
} else if (meshExts.includes(ext)) {
|
||||
return 'mesh';
|
||||
}
|
||||
return 'file';
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize path
|
||||
*/
|
||||
export function normalizePath(path) {
|
||||
if (!path) return '';
|
||||
return path.replace(/\\/g, '/').replace(/^\/+/, '').replace(/\/+/g, '/');
|
||||
}
|
||||
|
||||
export const detectFileType = (fileName, content) => {
|
||||
const ext = fileName.toLowerCase().split('.').pop();
|
||||
|
||||
switch (ext) {
|
||||
case 'urdf':
|
||||
return 'urdf';
|
||||
case 'xacro':
|
||||
return 'xacro';
|
||||
case 'xml':
|
||||
if (content) {
|
||||
if (isValidRobotMJCF(content)) {
|
||||
return 'mjcf';
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
case 'usd':
|
||||
case 'usda':
|
||||
case 'usdc':
|
||||
case 'usdz':
|
||||
return 'usd';
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const isValidRobotMJCF = (content) => {
|
||||
if (!content || typeof content !== 'string') {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!content.includes('<mujoco')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const hasJoints = content.includes('<joint');
|
||||
const hasActuators = content.includes('<actuator');
|
||||
|
||||
if (hasJoints || hasActuators) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
@ -30,7 +30,7 @@
|
||||
</template>
|
||||
<script setup>
|
||||
import Chassis from './chassis/Chassis.vue';
|
||||
import MechanicalArm from './MechanicalArm.vue';
|
||||
import MechanicalArm from './arm/MechanicalArm.vue';
|
||||
|
||||
const currentTime = ref('')
|
||||
const weekText = ref('')
|
||||
@ -63,7 +63,7 @@ const animateBinary = () => {
|
||||
const activePage = ref('chassis')
|
||||
const handleBtn = (page) => {
|
||||
activePage.value = page
|
||||
emits("activePage", page);
|
||||
// emits("activePage", page);
|
||||
}
|
||||
|
||||
let clockTimer, binaryTimer;
|
||||
@ -147,7 +147,7 @@ onUnmounted(() => {
|
||||
gap: 12px ;
|
||||
|
||||
.nav-btn {
|
||||
background: rgba(0, 60, 140, 0.5);
|
||||
background: #003c8c80;
|
||||
border: 1px solid rgba(0, 194, 255, 0.35);
|
||||
color: #a0d8f0;
|
||||
padding: 6px 20px;
|
||||
@ -173,7 +173,7 @@ onUnmounted(() => {
|
||||
justify-content: center;
|
||||
font-family: 'Orbitron', monospace;
|
||||
font-size: 10px;
|
||||
color: rgba(0, 194, 255, 0.3);
|
||||
color: #00c2ff4d;
|
||||
letter-spacing: 2px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
|
||||
@ -1,385 +0,0 @@
|
||||
<template>
|
||||
<div class="robot-teach-container">
|
||||
<!-- 主体内容区 -->
|
||||
<div class="main-wrap">
|
||||
<!-- 左侧数据面板 -->
|
||||
<div class="left-panel">
|
||||
<!-- TCP 笛卡尔坐标 -->
|
||||
<div class="panel-card">
|
||||
<div class="row dual-row">
|
||||
<div class="coord-item">
|
||||
<span class="key">X</span>
|
||||
<el-input v-model.number="tcp.x" suffix="mm" />
|
||||
</div>
|
||||
<div class="coord-item">
|
||||
<span class="key">RX</span>
|
||||
<el-input v-model.number="tcp.rx" suffix="°" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="row dual-row">
|
||||
<div class="coord-item">
|
||||
<span class="key">Y</span>
|
||||
<el-input v-model.number="tcp.y" suffix="mm" />
|
||||
</div>
|
||||
<div class="coord-item">
|
||||
<span class="key">RY</span>
|
||||
<el-input v-model.number="tcp.ry" suffix="°" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="row dual-row">
|
||||
<div class="coord-item">
|
||||
<span class="key">Z</span>
|
||||
<el-input v-model.number="tcp.z" suffix="mm" />
|
||||
</div>
|
||||
<div class="coord-item">
|
||||
<span class="key">RZ</span>
|
||||
<el-input v-model.number="tcp.rz" suffix="°" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 关节 J1-J6 -->
|
||||
<div class="panel-card joint-card">
|
||||
<div class="joint-row" v-for="(item, idx) in jointList" :key="idx">
|
||||
<span class="joint-key">{{ item.name }}</span>
|
||||
<el-input v-model.number="item.val" />
|
||||
<div class="btn-group">
|
||||
<el-button size="small">-</el-button>
|
||||
<div class="range-tip">{{ item.range }}</div>
|
||||
<el-button size="small">+</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 速度滑块 -->
|
||||
<div class="speed-bar">
|
||||
<span class="label">速度</span>
|
||||
<el-slider v-model="speed" :min="1" :max="100" />
|
||||
<div class="speed-num">
|
||||
<el-button size="small">-</el-button>
|
||||
<span>{{ speed }} %</span>
|
||||
<el-button size="small">+</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 中间3D机械臂空白区域 -->
|
||||
<div class="center-view">
|
||||
<div class="robot-3d-box">
|
||||
<!-- 此处后续接入Three.js/URDF机械臂模型,当前留白 -->
|
||||
<div class="axis-line x-axis"></div>
|
||||
<div class="axis-line y-axis"></div>
|
||||
<div class="axis-line z-axis"></div>
|
||||
<div class="empty-tip">机械臂3D可视化区域</div>
|
||||
</div>
|
||||
<!-- 底部功能按钮 -->
|
||||
<div class="center-bottom-btns">
|
||||
<el-button type="success" size="large">零力示教</el-button>
|
||||
<el-button size="large">回到原点</el-button>
|
||||
<el-button size="large">Z轴对齐</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 右侧摇杆操作面板 -->
|
||||
<div class="right-panel">
|
||||
<!-- Z轴加减 -->
|
||||
<div class="z-ctrl">
|
||||
<el-button circle>+</el-button>
|
||||
<el-button circle>Z</el-button>
|
||||
<el-button circle>-</el-button>
|
||||
</div>
|
||||
<!-- XY方向摇杆 -->
|
||||
<div class="joystick">
|
||||
<div class="joy-top">+X</div>
|
||||
<div class="joy-row">
|
||||
<div class="joy-left">-Y</div>
|
||||
<div class="joy-center"></div>
|
||||
<div class="joy-right">+Y</div>
|
||||
</div>
|
||||
<div class="joy-bottom">-X</div>
|
||||
</div>
|
||||
<!-- RZ旋转 -->
|
||||
<div class="rz-ctrl">
|
||||
<el-button circle><-</el-button>
|
||||
<el-button circle>RZ</el-button>
|
||||
<el-button circle>>+</el-button>
|
||||
</div>
|
||||
<!-- RX RY摇杆 -->
|
||||
<div class="joystick">
|
||||
<div class="joy-top">+RX</div>
|
||||
<div class="joy-row">
|
||||
<div class="joy-left">-RY</div>
|
||||
<div class="joy-center"></div>
|
||||
<div class="joy-right">+RY</div>
|
||||
</div>
|
||||
<div class="joy-bottom">-RX</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
|
||||
// 顶部控制
|
||||
const coordMode = ref('TCP')
|
||||
const baseCoord = ref('Base')
|
||||
const useTool = ref(false)
|
||||
const stepMode = ref('continuous')
|
||||
|
||||
// TCP坐标数据
|
||||
const tcp = ref({
|
||||
x: 46.691,
|
||||
y: 385.321,
|
||||
z: 141.877,
|
||||
rx: -174.595,
|
||||
ry: 4.212,
|
||||
rz: -72.937
|
||||
})
|
||||
|
||||
// 6关节数据
|
||||
const jointList = ref([
|
||||
{ name: 'J1', val: 84.605, range: '-360°~360°' },
|
||||
{ name: 'J2', val: -7.826, range: '-135°~135°' },
|
||||
{ name: 'J3', val: 136.306, range: '-153°~153°' },
|
||||
{ name: 'J4', val: -6.773, range: '-360°~360°' },
|
||||
{ name: 'J5', val: 30.087, range: '-180°~180°' },
|
||||
{ name: 'J6', val: -16.567, range: '-360°~360°' },
|
||||
])
|
||||
|
||||
// 速度
|
||||
const speed = ref(56)
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
|
||||
.robot-teach-container {
|
||||
height: 100%;
|
||||
background: $bg-deep;
|
||||
color: $text;
|
||||
font-family: $font-b;
|
||||
overflow: hidden;
|
||||
user-select: none;
|
||||
|
||||
// 主体三栏布局
|
||||
.main-wrap {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: grid;
|
||||
grid-template-columns: 320px 1fr 320px;
|
||||
gap: 16px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
// 左侧面板
|
||||
.left-panel {
|
||||
padding: 10px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
overflow-y: auto;
|
||||
background: $bg-body;
|
||||
|
||||
.panel-card {
|
||||
margin-top: 20px;
|
||||
background: $bg-card;
|
||||
border-radius: 8px;
|
||||
padding: 14px;
|
||||
box-shadow: $shadow;
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
margin-bottom: 12px;
|
||||
gap: 10px;
|
||||
&.dual-row {
|
||||
.coord-item {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
.coord-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
.key {
|
||||
width: 32px;
|
||||
font-weight: bold;
|
||||
font-size: 16px;
|
||||
}
|
||||
:deep(.el-input__inner) {
|
||||
background: #2d323e;
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.joint-card {
|
||||
.joint-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 10px;
|
||||
gap: 8px;
|
||||
.joint-key {
|
||||
width: 32px;
|
||||
font-weight: bold;
|
||||
}
|
||||
:deep(.el-input) {
|
||||
width: 80px;
|
||||
}
|
||||
:deep(.el-input__inner) {
|
||||
background: #2d323e;
|
||||
border: 1px solid $border-color;
|
||||
color: #fff;
|
||||
}
|
||||
.btn-group {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
.range-tip {
|
||||
font-size: 12px;
|
||||
color: $text-gray;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.speed-bar {
|
||||
background: $bg-card;
|
||||
border-radius: 8px;
|
||||
padding: 14px;
|
||||
box-shadow: $shadow;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
.speed-num {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 中间3D区域
|
||||
.center-view {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
.robot-3d-box {
|
||||
flex: 1;
|
||||
background: $bg-card;
|
||||
border-radius: 8px;
|
||||
box-shadow: $shadow;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
.empty-tip {
|
||||
color: $text-gray;
|
||||
font-size: 14px;
|
||||
}
|
||||
// 坐标轴示意线
|
||||
.axis-line {
|
||||
position: absolute;
|
||||
&.x-axis {
|
||||
width: 200px;
|
||||
height: 2px;
|
||||
background: #f56c6c;
|
||||
left: 50%;
|
||||
top: 60%;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
&.y-axis {
|
||||
width: 200px;
|
||||
height: 2px;
|
||||
background: #67c23a;
|
||||
left: 50%;
|
||||
top: 60%;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
&.z-axis {
|
||||
width: 2px;
|
||||
height: 220px;
|
||||
background: #409eff;
|
||||
left: 50%;
|
||||
top: 10%;
|
||||
}
|
||||
}
|
||||
}
|
||||
.center-bottom-btns {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
// 右侧摇杆面板
|
||||
.right-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
align-items: center;
|
||||
|
||||
.z-ctrl, .rz-ctrl {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.joystick {
|
||||
width: 140px;
|
||||
height: 140px;
|
||||
background: $bg-card;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 0 0 1px $shadow;
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 10px;
|
||||
.joy-top, .joy-bottom {
|
||||
color: $primary;
|
||||
font-weight: bold;
|
||||
}
|
||||
.joy-row {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
.joy-left { color: #67c23a; }
|
||||
.joy-right { color: #f56c6c; }
|
||||
.joy-center {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border-radius: 50%;
|
||||
background: #333842;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.point-btns {
|
||||
margin-top: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// element-plus深色输入框覆盖
|
||||
:deep(.el-input__wrapper) {
|
||||
background-color: #2d323e;
|
||||
box-shadow: $shadow;
|
||||
.el-input__inner {
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
:deep(.el-slider__runway) {
|
||||
background: #333842;
|
||||
}
|
||||
</style>
|
||||
403
src/views/arm/MechanicalArm.vue
Normal file
403
src/views/arm/MechanicalArm.vue
Normal file
@ -0,0 +1,403 @@
|
||||
<template>
|
||||
<div class="robot-teach-container">
|
||||
<!-- 主体内容区 -->
|
||||
<div class="main-wrap">
|
||||
<!-- 左侧数据面板 -->
|
||||
<div class="left-panel">
|
||||
<!-- TCP 笛卡尔坐标 -->
|
||||
<div class="panel-card">
|
||||
<div class="row dual-row">
|
||||
<div class="coord-item">
|
||||
<span class="key">X</span>
|
||||
<el-input v-model.number="tcp.x" suffix="mm" />
|
||||
</div>
|
||||
<div class="coord-item">
|
||||
<span class="key">RX</span>
|
||||
<el-input v-model.number="tcp.rx" suffix="°" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="row dual-row">
|
||||
<div class="coord-item">
|
||||
<span class="key">Y</span>
|
||||
<el-input v-model.number="tcp.y" suffix="mm" />
|
||||
</div>
|
||||
<div class="coord-item">
|
||||
<span class="key">RY</span>
|
||||
<el-input v-model.number="tcp.ry" suffix="°" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="row dual-row">
|
||||
<div class="coord-item">
|
||||
<span class="key">Z</span>
|
||||
<el-input v-model.number="tcp.z" suffix="mm" />
|
||||
</div>
|
||||
<div class="coord-item">
|
||||
<span class="key">RZ</span>
|
||||
<el-input v-model.number="tcp.rz" suffix="°" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="joint-list">
|
||||
<div class="title">关节控制</div>
|
||||
<div v-for="joint in jointControls" :key="joint.name" class="joint-item">
|
||||
<label :for="joint.name">{{ joint.name }}</label>
|
||||
<el-slider v-model="joint.value" :min="joint.min" :max="joint.max" :step="0.01" @input="updateJoint(joint.name, joint.value)"/>
|
||||
<span class="angle">{{ (joint.value * 180 / Math.PI).toFixed(1) }}°</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="speed-container">
|
||||
<div class="label">速度:</div>
|
||||
<el-slider v-model="speed" :min="1" :max="100" />
|
||||
<div class="proportion">{{ speed }} %</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 中间3D机械臂空白区域 -->
|
||||
<div class="center-view">
|
||||
<UrdfView ref="urdfViewerRef" />
|
||||
<!-- 底部功能按钮 -->
|
||||
<!-- <div class="center-bottom-btns">
|
||||
<el-button type="success" size="large">零力示教</el-button>
|
||||
<el-button size="large">回到原点</el-button>
|
||||
<el-button size="large">Z轴对齐</el-button>
|
||||
</div> -->
|
||||
</div>
|
||||
|
||||
<!-- 右侧摇杆操作面板 -->
|
||||
<div class="right-panel">
|
||||
<div class="controller">
|
||||
<div class="title">末端控制</div>
|
||||
<div class="axis-group">
|
||||
<!-- X Axis -->
|
||||
<div class="axis-row x">
|
||||
<div class="axis-label x">X</div>
|
||||
<div class="axis-slider-wrap">
|
||||
<div class="axis-name">前后平移</div>
|
||||
<div>
|
||||
<el-button type="primary" circle>
|
||||
<el-icon size="24"><Remove /></el-icon>
|
||||
</el-button>
|
||||
<el-button type="primary" circle >
|
||||
<el-icon size="24"><CirclePlus /></el-icon>
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Y Axis -->
|
||||
<div class="axis-row y">
|
||||
<div class="axis-label y">Y</div>
|
||||
<div class="axis-slider-wrap">
|
||||
<div class="axis-name">左右平移</div>
|
||||
<div>
|
||||
<el-button type="primary" circle>
|
||||
<el-icon size="24"><Remove /></el-icon>
|
||||
</el-button>
|
||||
<el-button type="primary" circle >
|
||||
<el-icon size="24"><CirclePlus /></el-icon>
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Z Axis -->
|
||||
<div class="axis-row z">
|
||||
<div class="axis-label z">Z</div>
|
||||
<div class="axis-slider-wrap">
|
||||
<div class="axis-name">上下平移</div>
|
||||
<div>
|
||||
<el-button type="primary" circle>
|
||||
<el-icon size="24"><Remove /></el-icon>
|
||||
</el-button>
|
||||
<el-button type="primary" circle >
|
||||
<el-icon size="24"><CirclePlus /></el-icon>
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="controller">
|
||||
<div class="title">姿态控制</div>
|
||||
<div class="axis-group">
|
||||
<!-- X Axis -->
|
||||
<div class="axis-row x">
|
||||
<div class="axis-label x">X</div>
|
||||
<div class="axis-slider-wrap">
|
||||
<div class="axis-name">前后平移</div>
|
||||
<div>
|
||||
<el-button type="primary" circle>
|
||||
<el-icon size="24"><Remove /></el-icon>
|
||||
</el-button>
|
||||
<el-button type="primary" circle >
|
||||
<el-icon size="24"><CirclePlus /></el-icon>
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Y Axis -->
|
||||
<div class="axis-row y">
|
||||
<div class="axis-label y">Y</div>
|
||||
<div class="axis-slider-wrap">
|
||||
<div class="axis-name">左右平移</div>
|
||||
<div>
|
||||
<el-button type="primary" circle>
|
||||
<el-icon size="24"><Remove /></el-icon>
|
||||
</el-button>
|
||||
<el-button type="primary" circle >
|
||||
<el-icon size="24"><CirclePlus /></el-icon>
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Z Axis -->
|
||||
<div class="axis-row z">
|
||||
<div class="axis-label z">Z</div>
|
||||
<div class="axis-slider-wrap">
|
||||
<div class="axis-name">上下平移</div>
|
||||
<div>
|
||||
<el-button type="primary" circle>
|
||||
<el-icon size="24"><Remove /></el-icon>
|
||||
</el-button>
|
||||
<el-button type="primary" circle >
|
||||
<el-icon size="24"><CirclePlus /></el-icon>
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { nextTick, onMounted, ref } from 'vue'
|
||||
import UrdfView from './UrdfView.vue'
|
||||
import { Remove, CirclePlus } from '@element-plus/icons-vue'
|
||||
|
||||
const urdfViewerRef = ref()
|
||||
const jointControls = ref()
|
||||
|
||||
// TCP坐标数据
|
||||
const tcp = ref({
|
||||
x: 46.691,
|
||||
y: 385.321,
|
||||
z: 141.877,
|
||||
rx: -174.595,
|
||||
ry: 4.212,
|
||||
rz: -72.937
|
||||
})
|
||||
|
||||
// 速度
|
||||
const speed = ref(56)
|
||||
|
||||
const updateJoint = (jointName, angle) => {
|
||||
urdfViewerRef.value.updateJoint(jointName, angle)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
nextTick(() => {
|
||||
jointControls.value = urdfViewerRef.value.jointControls
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.robot-teach-container {
|
||||
height: 100%;
|
||||
background: $bg-deep;
|
||||
color: $text;
|
||||
font-family: $font-b;
|
||||
overflow: hidden;
|
||||
user-select: none;
|
||||
|
||||
// 主体三栏布局
|
||||
.main-wrap {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
|
||||
// 左侧面板
|
||||
.left-panel {
|
||||
width: 400px;
|
||||
padding: 20px;
|
||||
overflow-y: auto;
|
||||
// background: #030729;
|
||||
background: $bg-body;
|
||||
border-right: 1px solid rgba(0, 195, 255, 0.08);
|
||||
|
||||
.panel-card {
|
||||
padding: 12px;
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.joint-list {
|
||||
margin-top: 24px;
|
||||
// background: #161933;
|
||||
padding: 15px;
|
||||
border-radius: 8px;
|
||||
color: #ccc;
|
||||
|
||||
.title {
|
||||
font-size: 20px;
|
||||
font-weight: bold;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
|
||||
.joint-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 10px;
|
||||
padding: 10px;
|
||||
border: 1px solid rgba(0, 195, 255, 0.08);
|
||||
border-radius: 5px;
|
||||
background: #0a0b10;
|
||||
|
||||
label {
|
||||
width: 120px;
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
input[type=range] {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.angle {
|
||||
width: 50px;
|
||||
text-align: right;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.speed-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
|
||||
.label,
|
||||
.proportion {
|
||||
width: 100px;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 中间3D区域
|
||||
.center-view {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
// 右侧摇杆面板
|
||||
.right-panel {
|
||||
width: 400px;
|
||||
padding: 20px;
|
||||
// background: #030729;
|
||||
background: $bg-body;
|
||||
border-left: 1px solid rgba(0, 195, 255, 0.08);
|
||||
|
||||
.controller {
|
||||
margin-bottom: 60px;
|
||||
|
||||
.title {
|
||||
font-size: 20px;
|
||||
font-weight: bold;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.axis-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
|
||||
.axis-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 14px;
|
||||
background: #0a0b10;
|
||||
border: 1px solid #1c2a52;
|
||||
border-radius: 10px;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
border-color: #2a3f7a;
|
||||
background: #3b82f608;
|
||||
}
|
||||
|
||||
.axis-label {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 6px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.x {
|
||||
background: #f8717126;
|
||||
color: #f87171;
|
||||
border: 1px solid #f871714d;
|
||||
}
|
||||
|
||||
.y {
|
||||
background: #34d39926;
|
||||
color: #34d399;
|
||||
border: 1px solid #34d3994d;
|
||||
}
|
||||
|
||||
.z {
|
||||
background: #38bdf826;
|
||||
color: #38bdf8;
|
||||
border: 1px solid #38bdf84d;
|
||||
}
|
||||
|
||||
|
||||
.axis-slider-wrap {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 4px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// element-plus深色输入框覆盖
|
||||
:deep(.el-input__wrapper) {
|
||||
background-color: #2d323e;
|
||||
box-shadow: $shadow;
|
||||
|
||||
.el-input__inner {
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.el-slider__runway) {
|
||||
background: #333842;
|
||||
}
|
||||
</style>
|
||||
237
src/views/arm/UrdfView.vue
Normal file
237
src/views/arm/UrdfView.vue
Normal file
@ -0,0 +1,237 @@
|
||||
<template>
|
||||
<div class="elfin-viewer">
|
||||
<div ref="viewerContainer" class="viewer-container">
|
||||
<!-- Three.js 渲染 -->
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { 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 { XacroAdapter } from '@/adapters/XacroAdapter.js'
|
||||
|
||||
const viewerContainer = ref(null);
|
||||
|
||||
let scene, camera, renderer, controls;
|
||||
|
||||
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('#060a12');
|
||||
camera = new THREE.PerspectiveCamera(45, width / height, 0.1, 1000);
|
||||
camera.position.set(2, 2, 3);
|
||||
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(0x404060, 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 updateJoint = (jointName, angle) => {
|
||||
if (!robotInstance) return;
|
||||
|
||||
const joint = robotInstance.joints[jointName];
|
||||
|
||||
if (!joint) {
|
||||
console.warn(`未找到关节: ${jointName}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// ✅ 使用 setJointValue 触发变换矩阵更新
|
||||
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) {
|
||||
joint.value = 0;
|
||||
updateJoint(joint.name, 0);
|
||||
}
|
||||
};
|
||||
|
||||
const fileMap = new Map()
|
||||
const loadFiles = async () => {
|
||||
const fileNames = [
|
||||
'elfin10.urdf.xacro',
|
||||
'elfin10_gazebo.xacro',
|
||||
'elfin10_transmission.xacro',
|
||||
'meshes/elfin10/elfin_base.STL',
|
||||
'meshes/elfin10/elfin_end_link.STL',
|
||||
'meshes/elfin10/elfin_link1.STL',
|
||||
'meshes/elfin10/elfin_link2.STL',
|
||||
'meshes/elfin10/elfin_link3.STL',
|
||||
'meshes/elfin10/elfin_link4.STL',
|
||||
'meshes/elfin10/elfin_link5.STL',
|
||||
'meshes/elfin10/elfin_link6.STL'
|
||||
]
|
||||
const fetchPromises = fileNames.map(async (name) => {
|
||||
const url = `/inspection/elfin10/${name}`
|
||||
const response = await fetch(url)
|
||||
const blob = await response.blob()
|
||||
return new File([blob], name, { type: blob.type })
|
||||
})
|
||||
const fileList = await Promise.all(fetchPromises)
|
||||
fileList.forEach(item => {
|
||||
fileMap.set('elfin10/' + item.name, item);
|
||||
})
|
||||
}
|
||||
|
||||
const currentModel = ref()
|
||||
let robotInstance = null; // 保存 URDFRobot 对象
|
||||
|
||||
const loadRobot = async () => {
|
||||
const xacroUrl = `/inspection/elfin10/elfin10.urdf.xacro`;
|
||||
|
||||
const response = await fetch(xacroUrl);
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
const xacroContent = await response.text();
|
||||
const file = fileMap.get('elfin10/elfin10.urdf.xacro')
|
||||
const model = await XacroAdapter.parse(xacroContent, file.name, fileMap, file);
|
||||
|
||||
const robot = model.threeObject; // 这就是 URDFRobot
|
||||
|
||||
|
||||
// 将机器人添加到场景
|
||||
scene.add(robot); // ⚠️ 注意:是 robot.scene,不是 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;
|
||||
// 如果是连续关节,范围设为 -PI ~ PI
|
||||
if (joint._jointType === 'continuous') {
|
||||
lower = -Math.PI;
|
||||
upper = Math.PI;
|
||||
}
|
||||
// 初始角度一般为 0
|
||||
jointControls.push({
|
||||
name,
|
||||
min: lower,
|
||||
max: upper,
|
||||
value: 0, // 因为 joint.angle 初始为 0
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// ============ 动画循环 ============
|
||||
const animate = () => {
|
||||
animationId.value = requestAnimationFrame(animate);
|
||||
controls?.update();
|
||||
renderer?.render(scene, camera);
|
||||
};
|
||||
|
||||
// ============ 生命周期 ============
|
||||
onMounted(async () => {
|
||||
await nextTick();
|
||||
await loadFiles()
|
||||
initScene();
|
||||
await loadRobot();
|
||||
animate();
|
||||
window.addEventListener('resize', onResize);
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (animationId.value) cancelAnimationFrame(animationId.value);
|
||||
window.removeEventListener('resize', onResize);
|
||||
renderer?.dispose();
|
||||
controls?.dispose();
|
||||
});
|
||||
|
||||
const onResize = () => {
|
||||
const container = viewerContainer.value;
|
||||
if (!container || !camera || !renderer) return;
|
||||
const width = container.clientWidth;
|
||||
const height = container.clientHeight;
|
||||
camera.aspect = width / height;
|
||||
camera.updateProjectionMatrix();
|
||||
renderer.setSize(width, height);
|
||||
};
|
||||
|
||||
defineExpose({ updateJoint, resetJoints, jointControls });
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.elfin-viewer {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
background: #0d0d1a;
|
||||
|
||||
.viewer-container {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
canvas {
|
||||
display: block;
|
||||
width: 100% !important;
|
||||
height: 100% !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
</style>
|
||||
@ -216,7 +216,7 @@ onBeforeUnmount(() => { })
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
background: $bg-deep;
|
||||
background: #030729;
|
||||
color: $text;
|
||||
font-family: $font-b;
|
||||
overflow: hidden;
|
||||
@ -241,7 +241,7 @@ onBeforeUnmount(() => { })
|
||||
justify-content: space-between;
|
||||
height: 40px;
|
||||
padding: 0 16px;
|
||||
background: $bg-card;
|
||||
background: #030729;
|
||||
border-bottom: 1px solid $border;
|
||||
flex-shrink: 0;
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user