feat: 模型控制

This commit is contained in:
zhanghao 2026-06-29 16:19:37 +08:00
parent 30c7f3c3e0
commit 85bf6feccc
26 changed files with 3324 additions and 397 deletions

2
.env
View File

@ -1,3 +1,3 @@
VITE_DEVICE_ID = 'agv_src2200' VITE_DEVICE_ID = 'agv_src2200'
VITE_SERVICE_PORT = 3000 VITE_SERVICE_PORT = 5175

View File

@ -30,9 +30,9 @@ COPY server/ ./server/
# 从构建阶段拷贝前端产物 # 从构建阶段拷贝前端产物
COPY --from=builder /app/dist ./dist COPY --from=builder /app/dist ./dist
EXPOSE 3001 EXPOSE 5175
ENV NODE_ENV=production ENV NODE_ENV=production
ENV VITE_SERVICE_PORT=3000 ENV VITE_SERVICE_PORT=5175
CMD ["node", "server/index.js"] CMD ["node", "server/index.js"]

View File

@ -5,10 +5,10 @@ services:
dockerfile: Dockerfile dockerfile: Dockerfile
container_name: inspection-host-computer container_name: inspection-host-computer
ports: ports:
- "3000:3000" # 映射端口 - "5175:5175" # 映射端口
environment: environment:
- NODE_ENV=production - NODE_ENV=production
- VITE_SERVICE_PORT=3000 # 传给 Express 的环境变量 - VITE_SERVICE_PORT=5175 # 传给 Express 的环境变量
restart: unless-stopped # 容器意外停止后自动重启 restart: unless-stopped # 容器意外停止后自动重启
# 如果需要挂载日志或配置,可以加 volumes # 如果需要挂载日志或配置,可以加 volumes
# volumes: # volumes:

View File

@ -23,8 +23,11 @@
"fast-glob": "^3.3.3", "fast-glob": "^3.3.3",
"pinia": "^3.0.4", "pinia": "^3.0.4",
"pinia-plugin-persistedstate": "^4.7.1", "pinia-plugin-persistedstate": "^4.7.1",
"three": "^0.181.2",
"urdf-loader": "^0.12.6",
"vue": "^3.5.38", "vue": "^3.5.38",
"vue-router": "^5.1.0" "vue-router": "^5.1.0",
"xacro-parser": "^0.3.11"
}, },
"devDependencies": { "devDependencies": {
"@vitejs/plugin-vue": "^6.0.7", "@vitejs/plugin-vue": "^6.0.7",

38
pnpm-lock.yaml generated
View File

@ -41,12 +41,21 @@ importers:
pinia-plugin-persistedstate: pinia-plugin-persistedstate:
specifier: ^4.7.1 specifier: ^4.7.1
version: 4.7.1(pinia@3.0.4(vue@3.5.38)) 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: vue:
specifier: ^3.5.38 specifier: ^3.5.38
version: 3.5.38 version: 3.5.38
vue-router: vue-router:
specifier: ^5.1.0 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) 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: devDependencies:
'@vitejs/plugin-vue': '@vitejs/plugin-vue':
specifier: ^6.0.7 specifier: ^6.0.7
@ -963,6 +972,10 @@ packages:
resolution: {integrity: sha512-w/ozOKR9Obk3qoWeY/WDi6MFta9AoMR+zud60mdnbniMcBxRuFJyDt2LdX/14A1UABeqk+Uk+LDfUpvoGKppZA==} resolution: {integrity: sha512-w/ozOKR9Obk3qoWeY/WDi6MFta9AoMR+zud60mdnbniMcBxRuFJyDt2LdX/14A1UABeqk+Uk+LDfUpvoGKppZA==}
engines: {node: '>=0.10.0'} 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: express@5.2.1:
resolution: {integrity: sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==} resolution: {integrity: sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==}
engines: {node: '>= 18'} engines: {node: '>= 18'}
@ -2045,6 +2058,9 @@ packages:
engines: {node: '>=14.0.0'} engines: {node: '>=14.0.0'}
hasBin: true hasBin: true
three@0.181.2:
resolution: {integrity: sha512-k/CjiZ80bYss6Qs7/ex1TBlPD11whT9oKfT8oTGiHa34W4JRd1NiH/Tr1DbHWQ2/vMUypxksLnF2CfmlmM5XFQ==}
tinyglobby@0.2.17: tinyglobby@0.2.17:
resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==}
engines: {node: '>=12.0.0'} engines: {node: '>=12.0.0'}
@ -2168,6 +2184,11 @@ packages:
resolution: {integrity: sha512-PcA2tsuGSF9cnySLHTLSh2qrQiJ70mn+r+Glzxv2TWZblxsxCC52BDlZoPCsz7STd9pN7EZetkWZBAvk4cgZdQ==} resolution: {integrity: sha512-PcA2tsuGSF9cnySLHTLSh2qrQiJ70mn+r+Glzxv2TWZblxsxCC52BDlZoPCsz7STd9pN7EZetkWZBAvk4cgZdQ==}
engines: {node: '>=0.10.0'} 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: urix@0.1.0:
resolution: {integrity: sha512-Am1ousAhSLBeB9cG/7k7r2R0zj50uDRlZHPGbazid5s9rlF1F/QKYObEKSIunSjIOkJZqwRRLpvewjEkM7pSqg==} resolution: {integrity: sha512-Am1ousAhSLBeB9cG/7k7r2R0zj50uDRlZHPGbazid5s9rlF1F/QKYObEKSIunSjIOkJZqwRRLpvewjEkM7pSqg==}
deprecated: Please see https://github.com/lydell/urix#deprecated deprecated: Please see https://github.com/lydell/urix#deprecated
@ -2300,6 +2321,9 @@ packages:
wrappy@1.0.2: wrappy@1.0.2:
resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} 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: y18n@5.0.8:
resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==}
engines: {node: '>=10'} engines: {node: '>=10'}
@ -3282,6 +3306,8 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
expr-eval-fork@3.0.3: {}
express@5.2.1: express@5.2.1:
dependencies: dependencies:
accepts: 2.0.0 accepts: 2.0.0
@ -4487,6 +4513,8 @@ snapshots:
picocolors: 1.1.1 picocolors: 1.1.1
sax: 1.6.0 sax: 1.6.0
three@0.181.2: {}
tinyglobby@0.2.17: tinyglobby@0.2.17:
dependencies: dependencies:
fdir: 6.5.0(picomatch@4.0.4) fdir: 6.5.0(picomatch@4.0.4)
@ -4660,6 +4688,10 @@ snapshots:
has-value: 0.3.1 has-value: 0.3.1
isobject: 3.0.1 isobject: 3.0.1
urdf-loader@0.12.7(three@0.181.2):
dependencies:
three: 0.181.2
urix@0.1.0: {} urix@0.1.0: {}
use@3.1.1: {} use@3.1.1: {}
@ -4799,6 +4831,12 @@ snapshots:
wrappy@1.0.2: {} 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: {} y18n@5.0.8: {}
yaml@2.9.0: {} yaml@2.9.0: {}

View 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>

View 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>

View 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>

461
src/adapters/URDFAdapter.js Normal file
View 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`);
}
}
}

View 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;
}
}

View 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;
}
}

View 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
View 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;
}

View File

@ -30,7 +30,7 @@
</template> </template>
<script setup> <script setup>
import Chassis from './chassis/Chassis.vue'; import Chassis from './chassis/Chassis.vue';
import MechanicalArm from './MechanicalArm.vue'; import MechanicalArm from './arm/MechanicalArm.vue';
const currentTime = ref('') const currentTime = ref('')
const weekText = ref('') const weekText = ref('')
@ -63,7 +63,7 @@ const animateBinary = () => {
const activePage = ref('chassis') const activePage = ref('chassis')
const handleBtn = (page) => { const handleBtn = (page) => {
activePage.value = page activePage.value = page
emits("activePage", page); // emits("activePage", page);
} }
let clockTimer, binaryTimer; let clockTimer, binaryTimer;
@ -147,7 +147,7 @@ onUnmounted(() => {
gap: 12px ; gap: 12px ;
.nav-btn { .nav-btn {
background: rgba(0, 60, 140, 0.5); background: #003c8c80;
border: 1px solid rgba(0, 194, 255, 0.35); border: 1px solid rgba(0, 194, 255, 0.35);
color: #a0d8f0; color: #a0d8f0;
padding: 6px 20px; padding: 6px 20px;
@ -173,7 +173,7 @@ onUnmounted(() => {
justify-content: center; justify-content: center;
font-family: 'Orbitron', monospace; font-family: 'Orbitron', monospace;
font-size: 10px; font-size: 10px;
color: rgba(0, 194, 255, 0.3); color: #00c2ff4d;
letter-spacing: 2px; letter-spacing: 2px;
white-space: nowrap; white-space: nowrap;
overflow: hidden; overflow: hidden;

View File

@ -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>&lt;-</el-button>
<el-button circle>RZ</el-button>
<el-button circle>&gt;+</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>

View 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
View 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>

View File

@ -216,7 +216,7 @@ onBeforeUnmount(() => { })
display: flex; display: flex;
flex-direction: column; flex-direction: column;
height: 100%; height: 100%;
background: $bg-deep; background: #030729;
color: $text; color: $text;
font-family: $font-b; font-family: $font-b;
overflow: hidden; overflow: hidden;
@ -241,7 +241,7 @@ onBeforeUnmount(() => { })
justify-content: space-between; justify-content: space-between;
height: 40px; height: 40px;
padding: 0 16px; padding: 0 16px;
background: $bg-card; background: #030729;
border-bottom: 1px solid $border; border-bottom: 1px solid $border;
flex-shrink: 0; flex-shrink: 0;