别人克隆后没有 install,source 会直接失败。把展开后的 install 放进仓库,符号链接和指向本机 build 的 egg-link 收成真实文件,Python 入口改走当前环境的解释器,这样不用先 colcon build 也能启动。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
arm_dev
2026-10-15 11:04:43 +08:00
parent 3c86441fb0
commit 926a7f9796
1492 changed files with 1302154 additions and 2 deletions
@@ -0,0 +1 @@
prepend-non-duplicate;AMENT_PREFIX_PATH;
@@ -0,0 +1,3 @@
# generated from colcon_powershell/shell/template/hook_prepend_value.ps1.em
colcon_prepend_unique_value AMENT_PREFIX_PATH "$env:COLCON_CURRENT_PREFIX"
@@ -0,0 +1,3 @@
# generated from colcon_core/shell/template/hook_prepend_value.sh.em
_colcon_prepend_unique_value AMENT_PREFIX_PATH "$COLCON_CURRENT_PREFIX"
@@ -0,0 +1 @@
prepend-non-duplicate;PYTHONPATH;lib/python3.11/site-packages
@@ -0,0 +1,3 @@
# generated from colcon_powershell/shell/template/hook_prepend_value.ps1.em
colcon_prepend_unique_value PYTHONPATH "$env:COLCON_CURRENT_PREFIX/lib/python3.11/site-packages"
@@ -0,0 +1,3 @@
# generated from colcon_core/shell/template/hook_prepend_value.sh.em
_colcon_prepend_unique_value PYTHONPATH "$COLCON_CURRENT_PREFIX/lib/python3.11/site-packages"
@@ -0,0 +1,781 @@
"""启动 Gazebo 仿真:世界 + 机器人 + 控制器。
用法(推荐走 scripts/run_sim_gz.sh,它负责先激活仿真环境):
ros2 launch arm_sim_gazebo gazebo_sim.launch.py
ros2 launch arm_sim_gazebo gazebo_sim.launch.py world:=/abs/other.world headless:=false
=============================================================================
启动顺序为什么是这样(每一环都踩过)
=============================================================================
1. robot_state_publisher 发布归一化后的 URDF 到 /robot_description
2. Gazebo(gz_sim) 加载世界
3. ros_gz_sim create 从 /robot_description spawn 机器人
4. controller spawner 拉起 joint_state_broadcaster + rizon_arm_controller
两处顺序是硬要求,颠倒了只表现为"静默不工作":
a) RSP 必须在 create 之前就绪
create 用 -topic robot_description 取 URDF。RSP 以 transient_local 发布
(latched),迟到的订阅者也能拿到,所以这里主要是容忍,不是硬依赖。
b) controller spawner 必须在 Gazebo 起来之后
controller_manager **跑在 Gazebo 进程里**(由 gz_ros2_control 的系统插件
拉起),不是独立的 ros2_control_node。Gazebo 没起来时 spawner 找不到
/controller_manager 服务,会一直重试直到 timeout。
c) 归一化后的 URDF 必须同时喂给 RSP 和 create
gz_ros2_control 是从 /robot_description 读 <ros2_control> 段落的
(SDF 里只存插件名和参数路径,接口声明不在 SDF —— 实测确认)。
两边不是同一份时,控制器注册的关节名与 Gazebo 里的对不上,
症状是 "Joint 'sim_joint1' not found in the robot model"。
=============================================================================
use_sim_time
=============================================================================
仿真里所有节点都要 use_sim_time=True,时间来自 /clock(gz → ROS 桥接)。
不设的话 TF 时间戳会用墙上时钟,与 Gazebo 的仿真时间不同源,
表现为 RViz 里模型抖动、tf2 报 "Lookup would require extrapolation"。
/clock 的桥接在下面 clock_bridge 里,故意排在最前面。
"""
from __future__ import annotations
import os
from pathlib import Path
from launch import LaunchDescription
from launch.actions import (
DeclareLaunchArgument,
IncludeLaunchDescription,
LogInfo,
OpaqueFunction,
TimerAction,
)
from launch.launch_description_sources import PythonLaunchDescriptionSource
from launch.substitutions import LaunchConfiguration
from launch_ros.actions import Node
from launch_ros.substitutions import FindPackageShare
# 启动延迟(秒)。都是为了避开"服务还没起来就去调"的竞态。
# 数值偏保守:多等几秒远好过让用户对着一个静默失败的界面调试。
SPAWN_DELAY = 4.0
CONTROLLER_DELAY = 8.0
CONTROLLER_NAMES = ("joint_state_broadcaster", "rizon_arm_controller", "gripper_controller")
def _prepare(context, *args, **kwargs):
"""展开 + 归一化描述,并组装所有动作。
放在 OpaqueFunction 里做,因为需要先算出 URDF 文本才能构造
robot_state_publisher 的参数 —— 而 launch 的 substitution 机制
没法在 Python 里"看到"中间结果。
"""
from arm_sim_gazebo.robot_description import (
SimDescriptionError,
check_base_plugins,
default_controllers,
default_world,
default_xacro,
check_tactile_collisions,
model_link_pose,
prepare_urdf,
repo_root,
world_name,
)
from arm_sim_gazebo.sim_site import SimSiteError, resolve_world
world = LaunchConfiguration("world").perform(context).strip()
load_gripper = LaunchConfiguration("load_gripper").perform(context).strip().lower()
with_sensors = LaunchConfiguration("with_sensors").perform(context).strip().lower()
with_scene = LaunchConfiguration("with_scene").perform(context).strip().lower()
scene_file = LaunchConfiguration("scene").perform(context).strip()
headless = LaunchConfiguration("headless").perform(context).strip().lower()
verbose = LaunchConfiguration("verbose").perform(context).strip().lower()
# 相机清单在下面的传感器分支里才读,但场景分支(成像几何自检)也要用它。
# 先置空再赋值,避免"因为分支没进而在别处 NameError"这种低级但难查的错误。
specs = []
try:
root = repo_root()
# 与 gazebo_sim_launcher 用同一套优先级,直接 `ros2 launch` 的人也不会
# 遇到"改了 site.yaml 没反应":--world > site.yaml 的 sim.world > 内置默认。
world_path, _world_origin = resolve_world(world, root, fallback=default_world(root))
world_path = str(world_path)
# 腕部 FT 的话题名先读出来,喂给 xacro —— 这个话题名有三处要对上
# (配置、URDF 里的 <topic>、应用层按 sn 推出的 /{sn}/ft_sensor_wrench),
# 让配置当唯一源头,xacro 不再自己写一份。
from arm_sim_gazebo.sim_wrist_ft import (
WristFtConfigError,
bridge_args as wrist_ft_bridge_args,
check_wrist_ft_sensor,
)
from arm_sim_gazebo.sim_wrist_ft import load_wrist_ft
try:
wft = load_wrist_ft(root=root)
except WristFtConfigError as exc:
raise RuntimeError(f"\n[gazebo_sim] 仿真腕部 FT 配置有问题:\n{exc}\n") from exc
# 抓取锁:仿真里唯一能保证"夹住的东西不会在机械臂连续运动时滑掉"的机制,
# 说明见 devices/sim/gazebo/config/grasp_lock.yaml 与 sim_grasp_lock.py。
from arm_sim_gazebo.sim_grasp_lock import (
DEFAULT_OBJECT_SDF_TMPL,
GraspLockError,
check_grasp_lock_sdf,
)
from arm_sim_gazebo.sim_grasp_lock import load_grasp_lock
try:
glock = load_grasp_lock(root=root)
except GraspLockError as exc:
raise RuntimeError(
f"\n[gazebo_sim] 仿真抓取锁配置有问题:\n{exc}\n"
f" (只想先跑起来的话,把 grasp_lock.yaml 的 enabled 改成 false)\n"
) from exc
# 静态核对:物体 SDF 里到底有没有那个插件、参数对不对得上。
# 差一处就**静默失效**(现象只是"规莫名掉了"),所以必须在这里说清楚。
glock_warnings: List[str] = []
if glock.enabled:
_obj_sdf = root / DEFAULT_OBJECT_SDF_TMPL.format(object=glock.object)
try:
_sdf_text = _obj_sdf.read_text(encoding="utf-8")
except OSError:
_sdf_text = ""
glock_warnings = check_grasp_lock_sdf(
_sdf_text, glock, sdf_label=str(_obj_sdf)
)
urdf, report = prepare_urdf(
xacro_path=default_xacro(root),
controllers_file=default_controllers(root),
load_gripper=load_gripper != "false",
with_sensors=with_sensors != "false",
wrist_ft_topic=wft.topic,
)
wname = world_name(world_path)
missing_base = check_base_plugins(Path(world_path))
except (SimDescriptionError, SimSiteError) as exc:
# 描述准备失败是最常见的上手障碍(环境没 source、路径不对)。
# 直接把可读原因打出来,而不是让它变成一个看不懂的 launch traceback。
raise RuntimeError(f"\n[gazebo_sim] 仿真描述准备失败:\n{exc}\n") from exc
actions = [
LogInfo(msg=f"[gazebo_sim] 仓库根: {root}"),
LogInfo(msg=f"[gazebo_sim] 世界: {world_path} (world 名: {wname})"),
LogInfo(msg=f"[gazebo_sim] URDF 归一化: {report.summary()}"),
LogInfo(msg=f"[gazebo_sim] {glock.describe()}"),
]
for _w in glock_warnings:
actions.append(LogInfo(msg=f"[gazebo_sim] ⚠ 抓取锁检查: {_w}"))
if missing_base:
actions.append(
LogInfo(
msg=(
"[gazebo_sim] ⚠ world 缺少基础系统: "
+ ", ".join(missing_base)
+ "。world 里只要声明了任何一个 <plugin>,Gazebo 就不再加载默认"
"系统列表。缺 UserCommands 会让 /world/<名>/create 服务不存在,"
"模型根本不会被创建,而 create 仍打印 'OK creation of entity' ——"
"最终表现是控制器永远拉不起来。修法见 assembly.world 里的注释。"
)
)
)
if not report.changed:
actions.append(
LogInfo(
msg="[gazebo_sim] 注意:归一化没有改动任何内容。若 Gazebo 仍报 link 不存在,"
"先确认 flexiv_description 的版本是否已自行修好这两个上游缺陷。"
)
)
# --- 1) 机器人描述 -------------------------------------------------------
# 显式传 URDF 字符串(而不是 Command(["xacro ..."])),因为要发的是
# **归一化后**的那一份。这是 RSP 与 create 必须一致的东西,见文件头 (c)。
actions.append(
Node(
package="robot_state_publisher",
executable="robot_state_publisher",
name="robot_state_publisher",
output="screen",
parameters=[
{"robot_description": urdf},
{"use_sim_time": True},
],
)
)
# --- 2) Gazebo -----------------------------------------------------------
gz_args = ["-r"] # 立刻开始推进仿真(不等 GUI 里的播放按钮)
if verbose == "true":
gz_args += ["-v", "4"] # 打开 Gazebo 自身日志,排查插件加载失败时必需
if headless == "true":
# -s = 只跑服务端,不开 GUI。无显示器环境(本机就是)必须用这个。
gz_args.append("-s")
gz_args.append(world_path)
actions.append(
IncludeLaunchDescription(
PythonLaunchDescriptionSource(
os.path.join(
FindPackageShare("ros_gz_sim").perform(context),
"launch",
"gz_sim.launch.py",
)
),
launch_arguments={"gz_args": " ".join(gz_args)}.items(),
)
)
# --- 3) /clock 桥接(sim time 的来源,排在最前)-------------------------
actions.append(
Node(
package="ros_gz_bridge",
executable="parameter_bridge",
name="clock_bridge",
arguments=["/clock@rosgraph_msgs/msg/Clock[gz.msgs.Clock"],
output="screen",
)
)
# --- 3b) 世界模型位姿回读:物体被撞走时唯一能指名报错的数据源 ------------
# 为什么值得一条独立桥接:`perception.source=truth` 是**纯几何**的,
# 它按 scene.yaml 的**名义位姿**算孔位,从不问 Gazebo 物体现在在哪。
# 工件被碰走(实测:掉进台面通槽落到地面)之后,"真值"开始撒谎,
# 而现象全都指向别处(S05 报"两孔间距 169mm"、S07 一路下压不接触)。
# 这一条把 gz 的"每个模型实际位姿"搬进 ROS,让 sim_truth_perception
# 能把它和 scene.yaml 对上、指名报"工件不在它该在的地方"。
# 具体参数(话题名/类型/漂移容差)在 scene.yaml 的 pose_info 段。
from arm_sim_gazebo.sim_scene import load_scene as _load_scene
try:
_spec = _load_scene(None, root)
except Exception as exc: # noqa: BLE001 —— 场景读不出来要说清是场景的问题
raise RuntimeError(f"\n[gazebo_sim] 场景配置有问题:\n{exc}\n") from exc
if _spec.pose_info is not None:
_pi = _spec.pose_info
actions.append(
Node(
package="ros_gz_bridge",
executable="parameter_bridge",
name="pose_info_bridge",
output="screen",
arguments=[f"{_pi.gz_topic}@{_pi.ros_type}[{_pi.gz_type}"],
# gz 的话题名固定是 /world/<world>/pose/info(命名空间由 gz 决定,
# 应用侧改不了),ros_topic 只是"到 ROS 这一侧叫什么"。
# ros_gz_bridge 没有改名能力,所以这里用 remap 把名字对到
# scene.yaml 声明的那一个 —— 两侧对不上的表现是
# "桥接器起来了但一个消息都没有",那是个假故障。
remappings=[(_pi.gz_topic, _pi.ros_topic)],
)
)
actions.append(
LogInfo(
msg=f"[gazebo_sim] 世界模型位姿回读: {_pi.gz_topic} → {_pi.ros_topic}"
)
)
# --- 4) 相机:桥接 → 适配器 → (世界相机)补 TF --------------------------
# 只在挂了传感器时才做。--no-sensors 时跳过是必要的:适配器会去订阅
# 永远不存在的 gz 话题,它**不会报错**,只是静默没输出 —— 而"相机没图"
# 正是最需要快速定位的问题,不该被一个假故障混淆。
if with_sensors != "false":
from arm_sim_gazebo.sim_cameras import (
DEFAULT_CAMERAS_REL,
CameraConfigError,
bridge_args,
check_camera_consistency,
load_cameras,
rpy_to_quat,
)
try:
specs = load_cameras(root=root)
except CameraConfigError as exc:
raise RuntimeError(f"\n[gazebo_sim] 仿真相机清单有问题:\n{exc}\n") from exc
# 一致性:内参由 cameras.yaml 的 horizontal_fov 与 width/height 算,
# 而**渲染**用的是 SDF 里的值。两者不一致时图像与内参对不上,
# 三维点位会整体错 —— 而单目预览完全看不出来(gz 自己发的
# camera_info 本来就是错的,所以这份内参是"唯一真相",更要保证它与渲染一致)。
# 图幅尤其要查:它决定 fx 的尺度,写错会让孔检测的像素门槛静默失效。
# 这里只告警不拦截:真有偏差时,仍应能起仿真去现场比对。
_cam_issues = check_camera_consistency(
specs,
world_file=Path(world_path),
sensors_xacro=Path(root)
/ "devices/sim/gazebo/urdf/arm.sim.sensors.xacro",
)
# ⚠ 这里用 LogInfo 而不是 LogWarn:Humble 的 launch.actions 里没有
# LogWarn(Iron 才加),照抄新版本会直接 ImportError 让整个 launch
# 加载失败 —— 一个"只是想说句话"的动作把仿真起不来,代价太大。
# 前缀里带 ⚠ 保证它在满屏日志里仍然显眼。
for _w in _cam_issues:
actions.append(LogInfo(msg=f"[gazebo_sim] ⚠ {_w}"))
# 4a) 世界相机的 TF 兜底
#
# Gazebo **不会**为 world 里的 <model> 产生 ROS TF —— TF 只来自
# robot_state_publisher(也就是只覆盖 URDF 里的东西)。
# D435 正属于这一类:它的物理归属是"世界"(固定支架),不在 URDF 里
# (早期挂在 URDF 的 world link 上时,被 sdformat 连整棵子树静默丢掉)。
# 少了这条 TF,应用 tf2 查询直接报 "frame does not exist" ——
# 而图像本身完全正常,很容易误判成相机坏了。
#
# 位姿从 world 文件**解析**,不在这里重复配置:唯一真相就是 world 文件。
# 另配一份等于制造"改了 world 忘了改这里",而症状是相机看到的东西
# 与它自称的位置对不上,极难查。
world_tf = []
for s in specs:
if not s.is_world_mounted:
continue
try:
(px, py, pz), (rr, rp, ry) = model_link_pose(
Path(world_path), s.world_model, s.world_link
)
except SimDescriptionError as exc:
raise RuntimeError(
f"\n[gazebo_sim] 世界相机 {s.ros_ns} 的 TF 补不出来:\n{exc}\n"
) from exc
qx, qy, qz, qw = rpy_to_quat((rr, rp, ry))
world_tf.append(
Node(
package="tf2_ros",
executable="static_transform_publisher",
name=f"tf_{s.ros_ns}",
output="screen",
arguments=[
"--x", f"{px}", "--y", f"{py}", "--z", f"{pz}",
"--qx", f"{qx}", "--qy", f"{qy}", "--qz", f"{qz}", "--qw", f"{qw}",
"--frame-id", "world",
"--child-frame-id", s.link_frame,
],
parameters=[{"use_sim_time": True}],
)
)
actions.append(
LogInfo(
msg=f"[gazebo_sim] 世界相机 {s.ros_ns}: world → {s.link_frame} "
f"@ ({px:.3f}, {py:.3f}, {pz:.3f}) 由 world 文件解析得到"
)
)
if world_tf:
# 延后一点再发:/clock 还没起来时发布静态 TF 可能被 tf2 判成
# "未来时间戳",而这一类问题只在很后面才以 extrapolation 报出来。
actions.append(TimerAction(period=2.0, actions=world_tf))
# 4b) 桥接:只做传输(gz 消息类型 → ROS 消息类型,话题名照抄)。
# 改名、坐标系、深度单位都交给 4c 的适配器 —— 桥接器改不了 frame_id,
# 而 frame_id 恰恰是最容易错又最不报错的那一项。
actions.append(
Node(
package="ros_gz_bridge",
executable="parameter_bridge",
name="camera_bridge",
output="screen",
arguments=bridge_args(specs),
)
)
# 4c) 适配器:盖光学系 frame_id + 深度 32FC1 米 → 16UC1 毫米 + 改成
# 应用期望的话题名。详见 sim_camera_adapter.py 顶部。
actions.append(
Node(
package="arm_sim_gazebo",
executable="sim_camera_adapter",
name="sim_camera_adapter",
output="screen",
parameters=[
{"cameras_file": str(Path(root) / DEFAULT_CAMERAS_REL)},
{"use_sim_time": True},
],
)
)
# 4d) 触觉:桥接 + 适配器 -------------------------------------------
# 链路与相机同构:gz 只负责"有数据",语义(轴向、基线、单位、接触超时)
# 全在适配器里做。见 sim_tactile.py / sim_tactile_adapter.py 顶部。
from arm_sim_gazebo.sim_tactile import (
DEFAULT_TACTILE_REL,
TactileConfigError,
)
from arm_sim_gazebo.sim_tactile import bridge_args as tactile_bridge_args
from arm_sim_gazebo.sim_tactile import load_tactile
try:
tac = load_tactile(root=root)
except TactileConfigError as exc:
raise RuntimeError(f"\n[gazebo_sim] 仿真触觉配置有问题:\n{exc}\n") from exc
# 描述级静态检查:接触传感器引用的 collision 名是 sdformat **自动生成**的,
# 换个 sdformat 版本就可能变,而变了之后传感器只是"永远没输出"、不报错。
# 这里把它和"传感器挂错关节""话题写在错误的层级""传感器被整个丢掉"
# 一起在启动时报出来。expected_topics 用配置里声明的话题做对照 ——
# 这是唯一能发现"传感器被静默丢弃"的办法。
for _w in check_tactile_collisions(
urdf,
expected_topics=[s.wrench_topic for s in tac.sides]
+ [s.contact_topic for s in tac.sides],
):
actions.append(LogInfo(msg=f"[gazebo_sim] ⚠ 触觉描述检查: {_w}"))
actions.append(
Node(
package="ros_gz_bridge",
executable="parameter_bridge",
name="tactile_bridge",
output="screen",
arguments=tactile_bridge_args(tac),
)
)
actions.append(
Node(
package="arm_sim_gazebo",
executable="sim_tactile_adapter",
name="sim_tactile_adapter",
output="screen",
parameters=[
{"tactile_file": str(Path(root) / DEFAULT_TACTILE_REL)},
{"use_sim_time": True},
],
)
)
# 4e) 腕部 FT:只需桥接,**不需要适配器** ------------------------------
# 与触觉不同:触觉要轴向映射/基线/单位换算/contact 折算,所以有适配器;
# 腕部 FT 的 gz 输出本来就是牛顿、坐标系就是 child(link7) 系,而
# ros_gz_bridge **原生支持** gz.msgs.Wrench → WrenchStamped(实测确认)。
# 于是仿真产出的就是真机同名的 /<sn>/ft_sensor_wrench(同名同类型同单位),
# 应用层一行分支都不用写。少一层节点就少一个"配错了不报错"的地方。
if with_sensors != "false":
for _w in check_wrist_ft_sensor(urdf, wft):
actions.append(LogInfo(msg=f"[gazebo_sim] ⚠ 腕部 FT 检查: {_w}"))
actions.append(
Node(
package="ros_gz_bridge",
executable="parameter_bridge",
name="wrist_ft_bridge",
output="screen",
arguments=wrist_ft_bridge_args(wft),
)
)
# --- 5) spawn 机器人 -----------------------------------------------------
# -world 必须给对:world 名写错时 Gazebo **不报错**,只是实体没被创建,
# 表现成"launch 一切正常但 RViz 里什么都没有"。
actions.append(
TimerAction(
period=SPAWN_DELAY,
actions=[
Node(
package="ros_gz_sim",
executable="create",
name="spawn_robot",
output="screen",
arguments=[
"-world", wname,
"-name", "rizon_sim",
"-topic", "robot_description",
],
)
],
)
)
# --- 5b) spawn 场景物体(工件 / 通止规)---------------------------------
# 场景物体**不进 world 文件**,而是在这里 spawn:
# world 放"固定不动"的东西(地面、台子、支架相机),
# 物料(工件、规)会按站点/工序换位置,写进 world 就等于每次换料都改 world,
# 而 world 里还有物理参数与插件,动它的风险面太大(见 assembly.world 的注释)。
#
# ⚠️ 必须**等机器人 spawn 完**再 spawn 场景(这里再延后 2s):
# 两者都走 /world/<名>/create 服务,同时打会争同一个服务,
# 重试窗口内失败的那个**不报错**(create 打印 OK 但实体没进),
# 表现成"工件时有时无",非常难查。
if with_scene != "false":
from arm_sim_gazebo.sim_cameras import intrinsics as _intrinsics
from arm_sim_gazebo.sim_scene import (
SceneError,
check_pixel_gates,
load_scene,
operating_flange_z,
operating_pixel_report,
spawn_commands,
validate_scene,
)
try:
scene = load_scene(
Path(scene_file) if scene_file else None, root=root
)
except SceneError as exc:
raise RuntimeError(f"\n[gazebo_sim] 场景配置有问题:\n{exc}\n") from exc
actions.append(LogInfo(msg=f"[gazebo_sim] 场景: {scene.source}"))
# 摆放校验:悬空 / 穿模 / 出界 / 互相重叠 —— 全是"看起来正常、跑起来不对"
# 的那一类。这里只告警不拦截:真有问题时仍需能起仿真去看现场。
for _p in validate_scene(scene, root):
actions.append(LogInfo(msg=f"[gazebo_sim] ⚠ 场景摆放检查: {_p}"))
scene_nodes = []
for args in spawn_commands(scene, root):
name = args[args.index("-name") + 1]
scene_nodes.append(
Node(
package="ros_gz_sim",
executable="create",
name=f"spawn_{name}",
output="screen",
arguments=["-world", wname] + args,
)
)
if scene_nodes:
actions.append(TimerAction(period=SPAWN_DELAY + 2.0, actions=scene_nodes))
# 成像几何自检:把"该被检测到的特征"在这台相机下换算成像素,
# 与门槛比一遍。这是**唯一**能在上真机之前发现"孔间距不够像素门槛"
# 的办法 —— 等真机上才发现要贵得多。
cam = next((s for s in specs if s.ros_ns == scene.operating.camera), None) if with_sensors != "false" else None
if cam is not None:
_fx = _intrinsics(cam, cam.width, cam.height)[0][0]
px = operating_pixel_report(scene, _fx, root)
if px:
actions.append(
LogInfo(
msg="[gazebo_sim] 孔检测几何自检(相机 {},到工件 {:.2f}m,fx={:.1f}px):\n".format(
scene.operating.camera, scene.operating.camera_to_part_m, _fx
)
+ "\n".join(f" {k} = {v:.1f}px" for k, v in sorted(px.items()))
)
)
for _p in check_pixel_gates(px, scene.gates_px):
actions.append(LogInfo(msg=f"[gazebo_sim] ⚠ 孔检测几何: {_p}"))
_fz = operating_flange_z(scene, root)
if _fz is not None:
actions.append(
LogInfo(
msg=f"[gazebo_sim] 二阶段在仿真里应悬停到法兰 z≈{_fz:.3f}m"
f"(真机 site.yaml 写的是 0.23 —— 两者不同是**已知**的,"
f"原因是两边「基座与台面的相对高度」不同;"
f"决定成像的是相机到工件的距离,见 scene.yaml 的 operating 段)"
)
)
else:
actions.append(
LogInfo(
msg="[gazebo_sim] 未做孔检测几何自检(缺相机清单,或 --no-sensors)"
)
)
# --- 6) 控制器 -----------------------------------------------------------
# 用 joint_trajectory_controller 而不是直接给关节发位置:应用层统一按
# rizon_arm_controller/follow_joint_trajectory 发轨迹,这个接口名与真机一致。
controllers = [
TimerAction(
period=CONTROLLER_DELAY + i * 2.0,
actions=[
Node(
package="controller_manager",
executable="spawner",
name=f"spawn_{name}",
output="screen",
arguments=[
name,
"--controller-manager", "/controller_manager",
# controller_manager 在 Gazebo 进程里,它是 sim time;
# spawner 也要用 sim time,否则两者对时不同源。
"--controller-manager-timeout", "120",
],
parameters=[{"use_sim_time": True}],
)
],
)
for i, name in enumerate(CONTROLLER_NAMES)
]
actions.extend(controllers)
# --- 6b) 仿真夹爪适配器 --------------------------------------------------
# 为什么必须有它:sdformat 把 URDF 的 <mimic> 整个丢掉,gz-sim 也不实现,
# 所以 6 个从动关节不会跟随主关节。适配器按耦合公式算出 7 个关节的目标值
# 一起下发,并对外提供与真机**同名同类型**的 GripperExecute 服务
#(<ns>/open|close|move|rotate + status),上层代码一行都不用改。
#
# 耦合表来自 report.stripped_mimics —— 也就是 urdf_normalize 删掉 <mimic>
# 时记下来的那几张表。让 launch 传给适配器,而不是让适配器自己再展开一次
# xacro:两次展开在参数不同时会静默不一致,而症状是"手指角度不对"。
if load_gripper != "false":
from arm_sim_gazebo.sim_gripper import format_mimic_param
mimics = [format_mimic_param(*t) for t in report.stripped_mimics]
if not mimics:
# 这里必须出声:拿不到耦合表说明夹爪没被加载(或上游改了耦合方式),
# 而适配器会因此拒绝启动 —— 早知道比晚知道好。
actions.append(
LogInfo(
msg="[gazebo_sim] ⚠ 归一化报告里没有 mimic 关系,"
"仿真夹爪适配器将无法启动(它会明确报错)。"
"检查 load_gripper 与 flexiv_description 的 grav 宏。"
)
)
actions.append(
TimerAction(
# 等 gripper_controller 起来再发命令:控制器还没 active 时
# 发出去的 /commands 会被丢弃(不报错),夹爪就一直停在初始位。
period=CONTROLLER_DELAY + len(CONTROLLER_NAMES) * 2.0 + 1.0,
actions=[
Node(
package="arm_sim_gazebo",
executable="sim_gripper_adapter",
name="sim_gripper_adapter",
output="screen",
parameters=[
{
"controllers_file": str(default_controllers(root)),
"controller": "gripper_controller",
"mimics": mimics,
"robot_description": urdf,
"namespace": "/gripper",
# 抓取锁(仿真专用,见 sim_grasp_lock.py):
# 夹住后把物体固定到手指上,避免"位置控制夹持 +
# 连续轨迹"导致的打滑掉件。
"grasp_lock_enabled": bool(glock.enabled),
"grasp_lock_object": str(glock.object),
"grasp_lock_attach_topic": str(glock.attach_topic),
"grasp_lock_detach_topic": str(glock.detach_topic),
"grasp_lock_timeout_ms": int(glock.publish_timeout_ms),
},
{"use_sim_time": True},
],
)
],
)
)
# --- 6c) 机械臂的"应用接口"对齐真机 ---------------------------------------
# 仿真里唯一的控制通路是 ros2_control 的 JTC(control_msgs)。但应用层在
# 真机上用的是另一层接口 —— 这里把它们**按真机的名字与类型**补齐,
# 于是通止规装配(以及 stage2/stage3)在仿真里能不改一行代码就跑:
#
# /follow_joint_trajectory arm_msgs/FollowJointTrajectory → JTC
# 真机由 real_trajectory_adapter 提供(名字带 real,但实现只做 action
# 桥接、与硬件无关;这里**复用同一个节点**而不是再写一份 ——
# 两份实现迟早会不一致)。
# /set_named_pose arm_msgs/SetNamedPose(命名位姿)→ 上面那个
# 真机由 arm_bringup/stack.launch.py 起,仿真起同一份,只是把
# joint_prefix 换成 sim_(关节名不同,点位数值是同一台机器人)。
# /home std_srvs/Trigger
# 真机由 flexiv_arm_driver 提供(= 切工具系 + 转 /set_named_pose)。
# 仿真没有 Flexiv 驱动,用 sim_home_service 补同一份契约(不切工具系)。
actions.append(
Node(
package="arm_control",
executable="real_trajectory_adapter",
name="real_trajectory_adapter",
output="screen",
parameters=[{"use_sim_time": True}],
)
)
actions.append(
Node(
package="arm_control",
executable="named_pose_server",
name="named_pose_server",
output="screen",
parameters=[
# 命名位姿用**包内默认那份**(src/arm_control/config/named_poses.yaml):
# 它只有 home/ready/inspect 这几个点,与"仿真里是哪台臂"无关;
# 真机走 site.yaml 的 robots[].paths.named_poses_file(那份带该臂的点位)。
str(root / "src" / "arm_control" / "config" / "named_poses.yaml"),
# 关节名:仿真里是 sim_joint1..7(前缀来自 urdf 的 arm_prefix,
# site.yaml 的 sim.joint_prefix 目前是"声明了但没接线",见那一段注释)。
{"joint_prefix": "sim_"},
{"use_sim_time": True},
],
)
)
actions.append(
Node(
package="arm_sim_gazebo",
executable="sim_home_service",
name="sim_home_service",
output="screen",
parameters=[{"use_sim_time": True}],
)
)
actions.append(
LogInfo(
msg=(
"[gazebo_sim] 启动中。验收命令:\n"
" ros2 topic echo /clock --once # 仿真时间在走\n"
" ros2 control list_controllers # 两个控制器 active\n"
" ros2 topic echo /joint_states --once # 7 个 sim_joint\n"
" ros2 run tf2_ros tf2_echo world sim_tcp # 末端位姿可查\n"
" ros2 topic echo /tactile/left/status --once # 触觉三力在发\n"
" ign model -m workpiece -p # 工件在台面上(核对摆放)\n"
" ign model -m gauge -p # 通止规立在台面上\n"
" python3 scripts/check_sim_cameras.py # ★ 相机的真正验收\n"
" python3 scripts/check_sim_scene.py # ★ 场景摆放 + 孔检测几何验收\n"
" 机械臂应在重力下**保持站住**。若它瘫下去或消失,说明描述有问题,"
"参考 arm_sim_gazebo/urdf_normalize.py 顶部列的两个上游缺陷。\n"
" ★ 别用 'ros2 topic hz' 或肉眼预览来验收相机 —— 那些只能证明'有图像'。\n"
" 仿真相机的错误几乎全是静默的(深度单位差 1000 倍、frame_id 盖成\n"
" 本体系、gz 发的内参是错的、位姿 rpy 写错导致相机背对目标),\n"
" 它们全都能出图、能查到 TF。必须用 check_sim_cameras.py 反投影\n"
" 到 world、检查是否命中地面/台面,才算通过。\n"
" ⚠ 深度话题是 16UC1 毫米(已从 gz 的 32FC1 米转换过)。\n"
" 内参也不是 gz 发的那份:gz 恒发 fx=277/cx=160/cy=120(它的默认\n"
" 60° 视场 + 半分辨率主点),适配器已按 cameras.yaml 的视场重算覆盖。\n"
" 所以改相机视场要同时改 cameras.yaml 和 SDF,启动时会自动比对告警。"
)
)
)
return actions
def generate_launch_description():
return LaunchDescription(
[
DeclareLaunchArgument(
"world",
default_value="",
description="世界文件绝对路径。留空=用仓库里默认的 assembly.world",
),
DeclareLaunchArgument(
"load_gripper",
default_value="true",
description="是否挂 GN01 夹爪。装配任务需要 true",
),
DeclareLaunchArgument(
"with_sensors",
default_value="true",
description="是否挂 D405 与触觉。关掉跑得更快,用于纯运动学验证",
),
DeclareLaunchArgument(
"with_scene",
default_value="true",
description="是否 spawn 场景物料(工件/通止规)。false=只用空台面",
),
DeclareLaunchArgument(
"scene",
default_value="",
description="场景配置文件路径。留空=用 devices/sim/gazebo/config/scene.yaml",
),
DeclareLaunchArgument(
"headless",
default_value="true",
description="true=只开 Gazebo 服务端(无显示器环境用);false=带 GUI",
),
DeclareLaunchArgument(
"verbose",
default_value="false",
description="true=打开 Gazebo 详细日志(排查插件加载失败时开)",
),
OpaqueFunction(function=_prepare),
]
)
@@ -0,0 +1,31 @@
# generated from colcon_bash/shell/template/package.bash.em
# This script extends the environment for this package.
# a bash script is able to determine its own path if necessary
if [ -z "$COLCON_CURRENT_PREFIX" ]; then
# the prefix is two levels up from the package specific share directory
_colcon_package_bash_COLCON_CURRENT_PREFIX="$(builtin cd "`dirname "${BASH_SOURCE[0]}"`/../.." > /dev/null && pwd)"
else
_colcon_package_bash_COLCON_CURRENT_PREFIX="$COLCON_CURRENT_PREFIX"
fi
# function to source another script with conditional trace output
# first argument: the path of the script
# additional arguments: arguments to the script
_colcon_package_bash_source_script() {
if [ -f "$1" ]; then
if [ -n "$COLCON_TRACE" ]; then
echo "# . \"$1\""
fi
. "$@"
else
echo "not found: \"$1\"" 1>&2
fi
}
# source sh script of this package
_colcon_package_bash_source_script "$_colcon_package_bash_COLCON_CURRENT_PREFIX/share/arm_sim_gazebo/package.sh"
unset _colcon_package_bash_source_script
unset _colcon_package_bash_COLCON_CURRENT_PREFIX
@@ -0,0 +1,6 @@
source;share/arm_sim_gazebo/hook/pythonpath.ps1
source;share/arm_sim_gazebo/hook/pythonpath.dsv
source;share/arm_sim_gazebo/hook/pythonpath.sh
source;share/arm_sim_gazebo/hook/ament_prefix_path.ps1
source;share/arm_sim_gazebo/hook/ament_prefix_path.dsv
source;share/arm_sim_gazebo/hook/ament_prefix_path.sh
@@ -0,0 +1,116 @@
# generated from colcon_powershell/shell/template/package.ps1.em
# function to append a value to a variable
# which uses colons as separators
# duplicates as well as leading separators are avoided
# first argument: the name of the result variable
# second argument: the value to be prepended
function colcon_append_unique_value {
param (
$_listname,
$_value
)
# get values from variable
if (Test-Path Env:$_listname) {
$_values=(Get-Item env:$_listname).Value
} else {
$_values=""
}
$_duplicate=""
# start with no values
$_all_values=""
# iterate over existing values in the variable
if ($_values) {
$_values.Split(":") | ForEach {
# not an empty string
if ($_) {
# not a duplicate of _value
if ($_ -eq $_value) {
$_duplicate="1"
}
if ($_all_values) {
$_all_values="${_all_values}:$_"
} else {
$_all_values="$_"
}
}
}
}
# append only non-duplicates
if (!$_duplicate) {
# avoid leading separator
if ($_all_values) {
$_all_values="${_all_values}:${_value}"
} else {
$_all_values="${_value}"
}
}
# export the updated variable
Set-Item env:\$_listname -Value "$_all_values"
}
# function to prepend a value to a variable
# which uses colons as separators
# duplicates as well as trailing separators are avoided
# first argument: the name of the result variable
# second argument: the value to be prepended
function colcon_prepend_unique_value {
param (
$_listname,
$_value
)
# get values from variable
if (Test-Path Env:$_listname) {
$_values=(Get-Item env:$_listname).Value
} else {
$_values=""
}
# start with the new value
$_all_values="$_value"
# iterate over existing values in the variable
if ($_values) {
$_values.Split(":") | ForEach {
# not an empty string
if ($_) {
# not a duplicate of _value
if ($_ -ne $_value) {
# keep non-duplicate values
$_all_values="${_all_values}:$_"
}
}
}
}
# export the updated variable
Set-Item env:\$_listname -Value "$_all_values"
}
# function to source another script with conditional trace output
# first argument: the path of the script
# additional arguments: arguments to the script
function colcon_package_source_powershell_script {
param (
$_colcon_package_source_powershell_script
)
# source script with conditional trace output
if (Test-Path $_colcon_package_source_powershell_script) {
if ($env:COLCON_TRACE) {
echo ". '$_colcon_package_source_powershell_script'"
}
. "$_colcon_package_source_powershell_script"
} else {
Write-Error "not found: '$_colcon_package_source_powershell_script'"
}
}
# a powershell script is able to determine its own path
# the prefix is two levels up from the package specific share directory
$env:COLCON_CURRENT_PREFIX=(Get-Item $PSCommandPath).Directory.Parent.Parent.FullName
colcon_package_source_powershell_script "$env:COLCON_CURRENT_PREFIX/share/arm_sim_gazebo/hook/pythonpath.ps1"
colcon_package_source_powershell_script "$env:COLCON_CURRENT_PREFIX/share/arm_sim_gazebo/hook/ament_prefix_path.ps1"
Remove-Item Env:\COLCON_CURRENT_PREFIX
@@ -0,0 +1,87 @@
# generated from colcon_core/shell/template/package.sh.em
# This script extends the environment for this package.
# function to prepend a value to a variable
# which uses colons as separators
# duplicates as well as trailing separators are avoided
# first argument: the name of the result variable
# second argument: the value to be prepended
_colcon_prepend_unique_value() {
# arguments
_listname="$1"
_value="$2"
# get values from variable
eval _values=\"\$$_listname\"
# backup the field separator
_colcon_prepend_unique_value_IFS=$IFS
IFS=":"
# start with the new value
_all_values="$_value"
# workaround SH_WORD_SPLIT not being set in zsh
if [ "$(command -v colcon_zsh_convert_to_array)" ]; then
colcon_zsh_convert_to_array _values
fi
# iterate over existing values in the variable
for _item in $_values; do
# ignore empty strings
if [ -z "$_item" ]; then
continue
fi
# ignore duplicates of _value
if [ "$_item" = "$_value" ]; then
continue
fi
# keep non-duplicate values
_all_values="$_all_values:$_item"
done
unset _item
# restore the field separator
IFS=$_colcon_prepend_unique_value_IFS
unset _colcon_prepend_unique_value_IFS
# export the updated variable
eval export $_listname=\"$_all_values\"
unset _all_values
unset _values
unset _value
unset _listname
}
# since a plain shell script can't determine its own path when being sourced
# either use the provided COLCON_CURRENT_PREFIX
# or fall back to the build time prefix (if it exists)
_colcon_package_sh_COLCON_CURRENT_PREFIX="/home/cc/ros2/install/arm_sim_gazebo"
if [ -z "$COLCON_CURRENT_PREFIX" ]; then
if [ ! -d "$_colcon_package_sh_COLCON_CURRENT_PREFIX" ]; then
echo "The build time path \"$_colcon_package_sh_COLCON_CURRENT_PREFIX\" doesn't exist. Either source a script for a different shell or set the environment variable \"COLCON_CURRENT_PREFIX\" explicitly." 1>&2
unset _colcon_package_sh_COLCON_CURRENT_PREFIX
return 1
fi
COLCON_CURRENT_PREFIX="$_colcon_package_sh_COLCON_CURRENT_PREFIX"
fi
unset _colcon_package_sh_COLCON_CURRENT_PREFIX
# function to source another script with conditional trace output
# first argument: the path of the script
# additional arguments: arguments to the script
_colcon_package_sh_source_script() {
if [ -f "$1" ]; then
if [ -n "$COLCON_TRACE" ]; then
echo "# . \"$1\""
fi
. "$@"
else
echo "not found: \"$1\"" 1>&2
fi
}
# source sh hooks
_colcon_package_sh_source_script "$COLCON_CURRENT_PREFIX/share/arm_sim_gazebo/hook/pythonpath.sh"
_colcon_package_sh_source_script "$COLCON_CURRENT_PREFIX/share/arm_sim_gazebo/hook/ament_prefix_path.sh"
unset _colcon_package_sh_source_script
unset COLCON_CURRENT_PREFIX
# do not unset _colcon_prepend_unique_value since it might be used by non-primary shell hooks
@@ -0,0 +1,53 @@
<?xml version="1.0"?>
<?xml-model href="http://download.ros.org/schema/package_format3.xsd" schematypens="http://www.w3.org/2001/XMLSchema"?>
<package format="3">
<name>arm_sim_gazebo</name>
<version>0.1.0</version>
<description>
Gazebo 仿真后端。兑现 devices/sim/gazebo/manifest.yaml 里声明的
package: arm_sim_gazebo / executable: gazebo_sim_launcher。
职责:
1. 把 devices/sim/gazebo/urdf 下的仿真描述展开成 URDF,
并归一化成 Gazebo 能接受的形态(见 urdf_normalize.py 的长注释)
2. 起 Gazebo、spawn 机械臂、拉控制器、拉 ros_gz 桥
3. 把应用层需要的接口对齐成与真机/mock 完全一致
(控制器名 rizon_arm_controller、服务与话题名不变),
这样应用层代码不需要知道后端是谁
与真机环境严格隔离:本包只在 ros_gz_sim 环境构建(scripts/build_sim.sh),
真机环境不装它。反过来它也不依赖任何真机专属包。
</description>
<maintainer email="dev@example.com">arm_dev</maintainer>
<license>Apache-2.0</license>
<exec_depend>rclpy</exec_depend>
<exec_depend>launch</exec_depend>
<exec_depend>launch_ros</exec_depend>
<exec_depend>ament_index_python</exec_depend>
<exec_depend>robot_state_publisher</exec_depend>
<exec_depend>xacro</exec_depend>
<!-- Gazebo 侧(仅仿真环境有) -->
<exec_depend>ros_gz_sim</exec_depend>
<exec_depend>ros_gz_bridge</exec_depend>
<!-- 触觉适配器订阅 ros_gz_interfaces/msg/Contacts(接触传感器的原始消息),
发布 arm_msgs/msg/TactileStatus。两者都是运行期硬依赖。 -->
<exec_depend>ros_gz_interfaces</exec_depend>
<exec_depend>arm_msgs</exec_depend>
<exec_depend>tf2_ros</exec_depend>
<exec_depend>gz_ros2_control</exec_depend>
<exec_depend>controller_manager</exec_depend>
<exec_depend>joint_state_broadcaster</exec_depend>
<exec_depend>joint_trajectory_controller</exec_depend>
<!-- 描述与参数 -->
<exec_depend>flexiv_description</exec_depend>
<exec_depend>arm_control</exec_depend>
<exec_depend>arm_application</exec_depend>
<test_depend>python3-pytest</test_depend>
<test_depend>python3-yaml</test_depend>
<export>
<build_type>ament_python</build_type>
</export>
</package>
@@ -0,0 +1,42 @@
# generated from colcon_zsh/shell/template/package.zsh.em
# This script extends the environment for this package.
# a zsh script is able to determine its own path if necessary
if [ -z "$COLCON_CURRENT_PREFIX" ]; then
# the prefix is two levels up from the package specific share directory
_colcon_package_zsh_COLCON_CURRENT_PREFIX="$(builtin cd -q "`dirname "${(%):-%N}"`/../.." > /dev/null && pwd)"
else
_colcon_package_zsh_COLCON_CURRENT_PREFIX="$COLCON_CURRENT_PREFIX"
fi
# function to source another script with conditional trace output
# first argument: the path of the script
# additional arguments: arguments to the script
_colcon_package_zsh_source_script() {
if [ -f "$1" ]; then
if [ -n "$COLCON_TRACE" ]; then
echo "# . \"$1\""
fi
. "$@"
else
echo "not found: \"$1\"" 1>&2
fi
}
# function to convert array-like strings into arrays
# to workaround SH_WORD_SPLIT not being set
colcon_zsh_convert_to_array() {
local _listname=$1
local _dollar="$"
local _split="{="
local _to_array="(\"$_dollar$_split$_listname}\")"
eval $_listname=$_to_array
}
# source sh script of this package
_colcon_package_zsh_source_script "$_colcon_package_zsh_COLCON_CURRENT_PREFIX/share/arm_sim_gazebo/package.sh"
unset convert_zsh_to_array
unset _colcon_package_zsh_source_script
unset _colcon_package_zsh_COLCON_CURRENT_PREFIX
@@ -0,0 +1 @@
ament_index_python:arm_application:arm_control:arm_msgs:controller_manager:flexiv_description:gz_ros2_control:joint_state_broadcaster:joint_trajectory_controller:launch:launch_ros:rclpy:robot_state_publisher:ros_gz_bridge:ros_gz_interfaces:ros_gz_sim:tf2_ros:xacro