chassis-sim 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- chassis_sim/__init__.py +12 -0
- chassis_sim/bridges/__init__.py +34 -0
- chassis_sim/bridges/adapters.py +200 -0
- chassis_sim/bridges/base.py +217 -0
- chassis_sim/bridges/raycast.py +124 -0
- chassis_sim/bridges/simple_bridge.py +1041 -0
- chassis_sim/chassis/__init__.py +20 -0
- chassis_sim/chassis/ackermann.py +131 -0
- chassis_sim/chassis/base.py +153 -0
- chassis_sim/chassis/diff_drive.py +66 -0
- chassis_sim/chassis/mecanum.py +69 -0
- chassis_sim/chassis/registry.py +81 -0
- chassis_sim/config/__init__.py +15 -0
- chassis_sim/config/curriculum.yaml +248 -0
- chassis_sim/config/env.yaml +49 -0
- chassis_sim/config/reward.yaml +274 -0
- chassis_sim/controllers/__init__.py +13 -0
- chassis_sim/controllers/_geometry.py +181 -0
- chassis_sim/controllers/base.py +253 -0
- chassis_sim/controllers/dwa.py +625 -0
- chassis_sim/controllers/pure_pursuit.py +156 -0
- chassis_sim/envs/__init__.py +51 -0
- chassis_sim/envs/chassis_env.py +1993 -0
- chassis_sim/envs/config.py +779 -0
- chassis_sim/envs/dynamics.py +203 -0
- chassis_sim/envs/observation.py +584 -0
- chassis_sim/envs/path_extractor.py +261 -0
- chassis_sim/envs/path_utils.py +160 -0
- chassis_sim/envs/privileged.py +215 -0
- chassis_sim/envs/reward.py +738 -0
- chassis_sim/envs/safety_gate.py +142 -0
- chassis_sim/eval/__init__.py +773 -0
- chassis_sim/gym_registration.py +51 -0
- chassis_sim/obs/__init__.py +40 -0
- chassis_sim/obs/spec.py +385 -0
- chassis_sim/planning/__init__.py +102 -0
- chassis_sim/planning/hybrid_astar.py +855 -0
- chassis_sim/planning/map_generator.py +1788 -0
- chassis_sim/planning/rs_curve.py +715 -0
- chassis_sim/py.typed +0 -0
- chassis_sim/render/__init__.py +66 -0
- chassis_sim/render/animation.py +130 -0
- chassis_sim/render/figure.py +1082 -0
- chassis_sim/render/recorder.py +232 -0
- chassis_sim/render/scene.py +330 -0
- chassis_sim/scenarios/__init__.py +114 -0
- chassis_sim/scenarios/data/circle.yaml +40 -0
- chassis_sim/scenarios/data/corridor.yaml +28 -0
- chassis_sim/scenarios/data/dense_static.yaml +88 -0
- chassis_sim/scenarios/data/dynamic_crossing.yaml +27 -0
- chassis_sim/scenarios/data/foldback_reverse.yaml +18 -0
- chassis_sim/scenarios/data/maze_grid.yaml +22 -0
- chassis_sim/scenarios/data/obstacles_dense.yaml +17 -0
- chassis_sim/scenarios/data/obstacles_dense_reverse.yaml +17 -0
- chassis_sim/scenarios/data/obstacles_medium.yaml +17 -0
- chassis_sim/scenarios/data/obstacles_sparse.yaml +18 -0
- chassis_sim/scenarios/data/straight.yaml +15 -0
- chassis_sim/scenarios/loader.py +585 -0
- chassis_sim/scenarios/reset_options.py +349 -0
- chassis_sim/scenarios/scenario.py +231 -0
- chassis_sim/sensors/__init__.py +26 -0
- chassis_sim/sensors/specs.py +167 -0
- chassis_sim/sensors/suite.py +393 -0
- chassis_sim-0.2.0.dist-info/METADATA +1236 -0
- chassis_sim-0.2.0.dist-info/RECORD +68 -0
- chassis_sim-0.2.0.dist-info/WHEEL +5 -0
- chassis_sim-0.2.0.dist-info/licenses/LICENSE +21 -0
- chassis_sim-0.2.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,1041 @@
|
|
|
1
|
+
"""SimpleBridge:纯 numpy 的传感器桥,几何与运动学由注入的 `Chassis` 决定。
|
|
2
|
+
|
|
3
|
+
移植自 `/home/zhao/drl-sim/ros_bridge/numpy_bridge.py`(678 行),**逐行照搬**,
|
|
4
|
+
不重写任何公式。旧桥行号 ↔ 内容:
|
|
5
|
+
|
|
6
|
+
============= =========================================================
|
|
7
|
+
旧桥位置 内容
|
|
8
|
+
============= =========================================================
|
|
9
|
+
:30-132 射线求交(圆解析解 / 矩形局部系 slab test)→ raycast.py
|
|
10
|
+
:286-305 障碍物解析(circle / rectangle → numpy 数组)
|
|
11
|
+
:317-357 起始位姿 + 终点鼻向(B5)
|
|
12
|
+
:369-408 步进 + 碰撞 / 到达判定
|
|
13
|
+
:412-513 LiDAR(180 线 × 每侧 π 视场)
|
|
14
|
+
:515-525 里程计(先返回后累积漂移)
|
|
15
|
+
:527-549 get_path / get_path_global
|
|
16
|
+
:582-678 碰撞(4 轴 SAT + 按朝向的边界 AABB)
|
|
17
|
+
============= =========================================================
|
|
18
|
+
|
|
19
|
+
与旧桥**有意**不同的三处(都是"注入底盘"带来的,不是放宽阈值):
|
|
20
|
+
|
|
21
|
+
1. 运动学:每步调用 `chassis.step(action)` 拿 `ChassisCommand`,再交给唯一的积分核
|
|
22
|
+
`Chassis.integrate`。旧桥里"用上一帧转角算 ω"的滞后是
|
|
23
|
+
`AckermannChassis.step` 的职责,本桥**不**重复实现(否则滞后会被算两次)。
|
|
24
|
+
2. 几何:车体半长/半宽来自 `chassis.footprint()`,LiDAR 安装位姿来自
|
|
25
|
+
`chassis.lidar_mounts()`,不再读 env.yaml。
|
|
26
|
+
3. 世界边界 / 量程 / 两个 margin 提升为构造参数;默认值与旧桥字面量逐位相同
|
|
27
|
+
(`[0, 10, 0, 10]` / `10.0` / `0.03` / `0.05`),因此默认构造下与旧桥
|
|
28
|
+
位级一致。
|
|
29
|
+
|
|
30
|
+
域随机化:`domain_rand=True` 时 LiDAR 噪声与里程计漂移吃的是**全局** `np.random`
|
|
31
|
+
(主仓已知行为,此处照抄)。所以 `reset(seed=...)` 管不到它 —— 严格复现需同时固定
|
|
32
|
+
`np.random` 与 torch 的随机种子。
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
from __future__ import annotations
|
|
36
|
+
|
|
37
|
+
from collections.abc import Sequence
|
|
38
|
+
from typing import Any
|
|
39
|
+
|
|
40
|
+
import numpy as np
|
|
41
|
+
|
|
42
|
+
from chassis_sim.chassis import Chassis
|
|
43
|
+
from chassis_sim.sensors import Imu, Lidar, SensorSuite, WheelOdom
|
|
44
|
+
|
|
45
|
+
from .base import SensorProvider
|
|
46
|
+
from .raycast import _ray_circles_intersection, _ray_rects_intersection
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _linear_phase(t: float, period: float, pingpong: bool) -> float:
|
|
50
|
+
"""linear 动态障碍在时刻 `t` 的运动相位(秒)。
|
|
51
|
+
|
|
52
|
+
语义与 `scenarios.scenario.LinearDynamicObstacle` 的 docstring 逐字对应::
|
|
53
|
+
|
|
54
|
+
period == 0 ⇒ t
|
|
55
|
+
period > 0, 锯齿 ⇒ t mod period
|
|
56
|
+
period > 0, 折返 ⇒ period/2 - |(t mod period) - period/2|
|
|
57
|
+
"""
|
|
58
|
+
if period <= 0.0:
|
|
59
|
+
return t
|
|
60
|
+
u = t % period
|
|
61
|
+
if not pingpong:
|
|
62
|
+
return u
|
|
63
|
+
half = 0.5 * period
|
|
64
|
+
return half - abs(u - half)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class SimpleBridge(SensorProvider):
|
|
68
|
+
"""纯 numpy 的 `SensorProvider`:向量化射线投射 + 底盘无关的运动学。
|
|
69
|
+
|
|
70
|
+
参数
|
|
71
|
+
----------
|
|
72
|
+
chassis:
|
|
73
|
+
决定几何(`footprint`)、LiDAR 安装(`lidar_mounts`)与运动学
|
|
74
|
+
(`step` + `integrate`)的底盘实例。
|
|
75
|
+
dt:
|
|
76
|
+
积分步长(秒),默认 0.1(= 主仓 config/env.yaml 的 world.step_time)。
|
|
77
|
+
world_bounds:
|
|
78
|
+
`(xmin, xmax, ymin, ymax)`,默认 `(0, 10, 0, 10)`,与主仓 world.width /
|
|
79
|
+
height = 10 推导出的四面边界墙一致。
|
|
80
|
+
max_range:
|
|
81
|
+
LiDAR 最大量程(米),默认 10.0(= 主仓 env.yaml sensors.range_max)。
|
|
82
|
+
domain_rand:
|
|
83
|
+
是否启用 LiDAR 加性高斯噪声 + 里程计随机游走漂移。**用全局 np.random**,
|
|
84
|
+
不吃 `reset(seed=...)`;严格复现需同时固定 np.random 与 torch 的种子。
|
|
85
|
+
lidar_noise_std:
|
|
86
|
+
LiDAR 高斯噪声标准差(米)。
|
|
87
|
+
odom_drift_xy / odom_drift_theta:
|
|
88
|
+
每步 xy / theta 漂移比例系数(乘 dt 后作为高斯标准差)。
|
|
89
|
+
goal_dist_threshold:
|
|
90
|
+
判到达的距离阈值(米),与 `check_goal_reached` 的默认 threshold 同值,
|
|
91
|
+
二者相等时直接返回 `step()` 缓存的布尔值(旧桥 :557-558 的行为)。
|
|
92
|
+
goal_end_wp_count / goal_end_wp_margin:
|
|
93
|
+
"最近航点必须落在末尾窗口内"的两个整数旋钮,防止路径折返时路过 goal
|
|
94
|
+
提前判到达。
|
|
95
|
+
collision_margin:
|
|
96
|
+
碰撞安全裕度(米),对应旧桥 :588 的 `margin = 0.03`。
|
|
97
|
+
boundary_margin:
|
|
98
|
+
世界边界内缩量(米),对应旧桥 :669 的 `margin = 0.05`。
|
|
99
|
+
dynamic_obstacles:
|
|
100
|
+
动态障碍规格列表(`chassis_sim.scenarios.DynamicObstacle.to_env_dict()` 的
|
|
101
|
+
输出格式);默认为空 = 逐位不变。**纯运动学、零随机**:第 k 步后的模拟时间
|
|
102
|
+
恒为 `t = k * dt`,位置是 t 的解析函数(不累加浮点),因此同一 reset 序列
|
|
103
|
+
必然给出逐位相同的轨迹。运行中换一组用 `set_dynamic_obstacles()`。
|
|
104
|
+
sensors:
|
|
105
|
+
传感器 spec 列表(`chassis_sim.sensors.Lidar` / `Imu` / `WheelOdom` 任意
|
|
106
|
+
混合、任意数量与位姿);`None` 或空列表 = 完全关闭(不构造对象、不消耗
|
|
107
|
+
随机数、info 零新键)。几何由本桥注入(`_raycast_body_ray`),sensors 包
|
|
108
|
+
自己不碰世界;每支传感器一条按 `(sensor_seed, name)` 派生的独立随机流。
|
|
109
|
+
sensor_seed:
|
|
110
|
+
传感器随机流种子。与 `domain_rand` 用的全局 np.random **无关**:传感器
|
|
111
|
+
绝不消耗全局序列,因此开关它们不会平移 domain_rand 的噪声。
|
|
112
|
+
"""
|
|
113
|
+
|
|
114
|
+
# ── 扫描几何常量:get_scan() 的元数据全部由它们 + max_range 推导 ──────
|
|
115
|
+
N_LIDAR_RAYS: int = 180 # 单侧 FOV 内的射线数(LiDAR 缓冲区按它预分配)
|
|
116
|
+
LIDAR_FOV: float = np.pi # 单侧视场角(弧度):N_LIDAR_RAYS 条射线铺满它
|
|
117
|
+
RANGE_MIN: float = 0.05 # 最小量程(米):更近的命中被截到该值(旧桥 :390-392)
|
|
118
|
+
|
|
119
|
+
def __init__(
|
|
120
|
+
self,
|
|
121
|
+
chassis: Chassis,
|
|
122
|
+
*,
|
|
123
|
+
dt: float = 0.1,
|
|
124
|
+
world_bounds: tuple[float, float, float, float] = (0.0, 10.0, 0.0, 10.0),
|
|
125
|
+
max_range: float = 10.0,
|
|
126
|
+
domain_rand: bool = False,
|
|
127
|
+
lidar_noise_std: float = 0.03,
|
|
128
|
+
odom_drift_xy: float = 0.01,
|
|
129
|
+
odom_drift_theta: float = 0.005,
|
|
130
|
+
goal_dist_threshold: float = 0.3,
|
|
131
|
+
goal_end_wp_count: int = 3,
|
|
132
|
+
goal_end_wp_margin: int = 2,
|
|
133
|
+
collision_margin: float = 0.03,
|
|
134
|
+
boundary_margin: float = 0.05,
|
|
135
|
+
dynamic_obstacles: list | None = None,
|
|
136
|
+
sensors: Sequence[Lidar | Imu | WheelOdom] | None = None,
|
|
137
|
+
sensor_seed: int = 0,
|
|
138
|
+
) -> None:
|
|
139
|
+
self.chassis = chassis
|
|
140
|
+
self.dt = float(dt)
|
|
141
|
+
self.max_range = float(max_range)
|
|
142
|
+
self.collision_margin = float(collision_margin)
|
|
143
|
+
self.boundary_margin = float(boundary_margin)
|
|
144
|
+
|
|
145
|
+
self._domain_rand = bool(domain_rand)
|
|
146
|
+
self._lidar_noise_std = float(lidar_noise_std)
|
|
147
|
+
self._odom_drift_xy = float(odom_drift_xy)
|
|
148
|
+
self._odom_drift_theta = float(odom_drift_theta)
|
|
149
|
+
self._goal_dist_threshold = float(goal_dist_threshold)
|
|
150
|
+
self._goal_end_wp_count = int(goal_end_wp_count)
|
|
151
|
+
self._goal_end_wp_margin = int(goal_end_wp_margin)
|
|
152
|
+
|
|
153
|
+
# ── 世界边界(旧桥由 env.yaml world.bounds / width,height 推导)──────
|
|
154
|
+
self._world_xmin = float(world_bounds[0])
|
|
155
|
+
self._world_xmax = float(world_bounds[1])
|
|
156
|
+
self._world_ymin = float(world_bounds[2])
|
|
157
|
+
self._world_ymax = float(world_bounds[3])
|
|
158
|
+
|
|
159
|
+
# ── 车体几何(旧桥从 env.yaml robot.shape 读,这里从底盘取)─────────
|
|
160
|
+
_length, _width = chassis.footprint()
|
|
161
|
+
self._robot_half_l = float(_length) / 2.0
|
|
162
|
+
self._robot_half_w = float(_width) / 2.0
|
|
163
|
+
# LiDAR 安装位姿:(x, y, 朝向偏移),前 = (0.145, 0, 0)、后 = (-0.155, 0, π)
|
|
164
|
+
self._front_lidar, self._rear_lidar = chassis.lidar_mounts()
|
|
165
|
+
|
|
166
|
+
# ── 机器人状态 ────────────────────────────────────────────
|
|
167
|
+
self._x = 0.0
|
|
168
|
+
self._y = 0.0
|
|
169
|
+
self._theta = 0.0
|
|
170
|
+
self._v = 0.0 # 当前前进速度(米/秒)
|
|
171
|
+
self._vy = 0.0 # 当前横向速度(米/秒;仅麦轮非零,IMU 的 ay 用它做差分项)
|
|
172
|
+
self._omega = 0.0 # 当前角速度(弧度/秒)
|
|
173
|
+
self._collision = False
|
|
174
|
+
self._goal_reached = False
|
|
175
|
+
|
|
176
|
+
# ── 路径 ───────────────────────────────────────────────────
|
|
177
|
+
self._path_waypoints_global: np.ndarray = np.zeros((0, 3))
|
|
178
|
+
self._goal_heading = 0.0
|
|
179
|
+
|
|
180
|
+
# ── 里程计漂移累积量 ────────────────────────────
|
|
181
|
+
self._odom_drift_x = 0.0
|
|
182
|
+
self._odom_drift_y = 0.0
|
|
183
|
+
self._odom_drift_t = 0.0
|
|
184
|
+
|
|
185
|
+
# ── 障碍物(用于快速 broadcasting 的 numpy 数组)─────────
|
|
186
|
+
self._circle_centers: np.ndarray = np.empty((0, 2))
|
|
187
|
+
self._circle_radii: np.ndarray = np.empty((0,))
|
|
188
|
+
self._rect_centers: np.ndarray = np.empty((0, 2))
|
|
189
|
+
self._rect_half_dims: np.ndarray = np.empty((0, 2))
|
|
190
|
+
self._rect_thetas: np.ndarray = np.empty((0,))
|
|
191
|
+
|
|
192
|
+
# ── 预分配的 LiDAR 缓冲区(避免每次调用时的 malloc)────
|
|
193
|
+
n_rays = int(self.N_LIDAR_RAYS)
|
|
194
|
+
self._lidar_origins = np.empty((n_rays, 2), dtype=np.float64)
|
|
195
|
+
self._lidar_directions = np.empty((n_rays, 2), dtype=np.float64)
|
|
196
|
+
self._lidar_distances = np.empty(n_rays, dtype=np.float64)
|
|
197
|
+
self._lidar_angles = np.empty(n_rays, dtype=np.float64)
|
|
198
|
+
|
|
199
|
+
# ── 动态障碍(解析运动;空列表 ⇒ 零额外计算,逐位不变)────────
|
|
200
|
+
self._steps = 0 # 已推进的步数;模拟时间 = _steps * dt(乘法,不累加浮点)
|
|
201
|
+
self._dyn_specs: list[dict[str, Any]] = []
|
|
202
|
+
self._dyn_centers: np.ndarray = np.empty((0, 2))
|
|
203
|
+
self._dyn_radii: np.ndarray = np.empty((0,))
|
|
204
|
+
self.set_dynamic_obstacles(dynamic_obstacles)
|
|
205
|
+
|
|
206
|
+
# ── 可选传感器(默认全关:不构造对象、不消耗随机数、info 零新键)──────
|
|
207
|
+
# 几何注入:传感器包只给车体系 (原点, 方位角, 量程),世界系变换与求交留在桥上。
|
|
208
|
+
self._sensor_seed = int(sensor_seed)
|
|
209
|
+
self._sensors: SensorSuite | None = None
|
|
210
|
+
if sensors is not None and len(sensors) > 0:
|
|
211
|
+
self._sensors = SensorSuite(
|
|
212
|
+
sensors,
|
|
213
|
+
seed=self._sensor_seed,
|
|
214
|
+
raycast=self._raycast_body_ray,
|
|
215
|
+
default_track_width=float(_width),
|
|
216
|
+
)
|
|
217
|
+
self._sensor_reading: dict[str, Any] | None = None
|
|
218
|
+
|
|
219
|
+
# ── 预分配的世界系角点缓冲 ────────────────────
|
|
220
|
+
# 注意:局部角点**不**在此缓存。旧桥 :259-261 缓存过一份不含 margin 的
|
|
221
|
+
# 局部角点,但 :598-599 又按 hl/hw(含 margin)重建 —— 缓存那份是死代码。
|
|
222
|
+
# 本实现只保留世界系缓冲,局部角点每帧按 margin 重建。
|
|
223
|
+
self._collision_corners_world = np.empty((4, 2), dtype=np.float64)
|
|
224
|
+
|
|
225
|
+
# ═════════════════════════════════════════════════════════════════
|
|
226
|
+
# SensorProvider 接口
|
|
227
|
+
# ═════════════════════════════════════════════════════════════════
|
|
228
|
+
|
|
229
|
+
@property
|
|
230
|
+
def goal_heading(self) -> float:
|
|
231
|
+
"""终点处的**车头朝向(鼻向)** ∈ [-π, π](B5:不是运动方向)。
|
|
232
|
+
|
|
233
|
+
消费方是 env 的 goal_sig(sin/cos(robot_theta − goal_heading))与终端奖励的
|
|
234
|
+
朝向对齐项;语义为"车到达终点时应让车头对齐路径终点的鼻向"——倒车到达时
|
|
235
|
+
车头朝向恰好等于路径末点鼻向,因此该项在末段倒车的地图上不再反向。
|
|
236
|
+
"""
|
|
237
|
+
return self._goal_heading
|
|
238
|
+
|
|
239
|
+
def reset(
|
|
240
|
+
self,
|
|
241
|
+
obstacles: list | None = None,
|
|
242
|
+
path_waypoints: np.ndarray | None = None,
|
|
243
|
+
initial_heading: float | None = None,
|
|
244
|
+
) -> None:
|
|
245
|
+
# ── 将障碍物解析为带类型的 numpy 数组 ────────────────
|
|
246
|
+
circles_c, circles_r = [], []
|
|
247
|
+
rects_c, rects_hd, rects_t = [], [], []
|
|
248
|
+
if obstacles:
|
|
249
|
+
for obs in obstacles:
|
|
250
|
+
shape = obs["shape"]
|
|
251
|
+
state = obs["state"]
|
|
252
|
+
cx, cy, ct = float(state[0]), float(state[1]), float(state[2])
|
|
253
|
+
if shape["name"] == "circle":
|
|
254
|
+
circles_c.append([cx, cy])
|
|
255
|
+
circles_r.append(float(shape["radius"]))
|
|
256
|
+
elif shape["name"] == "rectangle":
|
|
257
|
+
rects_c.append([cx, cy])
|
|
258
|
+
rects_hd.append(
|
|
259
|
+
[float(shape["length"]) / 2, float(shape["width"]) / 2]
|
|
260
|
+
)
|
|
261
|
+
rects_t.append(ct)
|
|
262
|
+
self._circle_centers = np.array(circles_c, dtype=np.float64).reshape(-1, 2)
|
|
263
|
+
self._circle_radii = np.array(circles_r, dtype=np.float64)
|
|
264
|
+
self._rect_centers = np.array(rects_c, dtype=np.float64).reshape(-1, 2)
|
|
265
|
+
self._rect_half_dims = np.array(rects_hd, dtype=np.float64).reshape(-1, 2)
|
|
266
|
+
self._rect_thetas = np.array(rects_t, dtype=np.float64)
|
|
267
|
+
|
|
268
|
+
# ── 动态障碍相位复位(t = 0 ⇒ 场景 seed 决定的初态;无随机时即起点)──
|
|
269
|
+
self._steps = 0
|
|
270
|
+
self._refresh_dynamic()
|
|
271
|
+
|
|
272
|
+
# ── 根据路径初始化机器人 ─────────────────────────────
|
|
273
|
+
self._collision = False
|
|
274
|
+
self._goal_reached = False
|
|
275
|
+
self._odom_drift_x = 0.0
|
|
276
|
+
self._odom_drift_y = 0.0
|
|
277
|
+
self._odom_drift_t = 0.0
|
|
278
|
+
self._v = 0.0
|
|
279
|
+
self._vy = 0.0
|
|
280
|
+
self._omega = 0.0
|
|
281
|
+
# 执行器状态清零:旧桥在此处 self._steer = 0.0(:315),对应本仓底盘里的
|
|
282
|
+
# 转向滞后状态。不清零则新回合第一步沿用上一回合末的转角。
|
|
283
|
+
self.chassis.reset_state()
|
|
284
|
+
# 传感器的延迟缓冲与 IMU 速度历史同样不能跨回合残留
|
|
285
|
+
self._sensor_reading = None
|
|
286
|
+
if self._sensors is not None:
|
|
287
|
+
self._sensors.reset()
|
|
288
|
+
|
|
289
|
+
if path_waypoints is not None and len(path_waypoints) > 0:
|
|
290
|
+
self._path_waypoints_global = path_waypoints.astype(np.float64)
|
|
291
|
+
start_pt = path_waypoints[0]
|
|
292
|
+
self._x = float(start_pt[0])
|
|
293
|
+
self._y = float(start_pt[1])
|
|
294
|
+
|
|
295
|
+
if initial_heading is not None:
|
|
296
|
+
self._theta = float(initial_heading)
|
|
297
|
+
elif len(path_waypoints) >= 2:
|
|
298
|
+
dx = path_waypoints[1][0] - start_pt[0]
|
|
299
|
+
dy = path_waypoints[1][1] - start_pt[1]
|
|
300
|
+
self._theta = float(np.arctan2(dy, dx))
|
|
301
|
+
else:
|
|
302
|
+
self._theta = 0.0
|
|
303
|
+
|
|
304
|
+
# ── 终点朝向 = 路径末点自带的**鼻向**(B5 修复,2026-10-05)──
|
|
305
|
+
# 路径第 2 列是车头朝向(RS 车辆积分所得),不是运动方向;倒车段上
|
|
306
|
+
# 二者相差 π。旧实现取最后两点 XY 差(atan2(dy, dx) = 运动方向),
|
|
307
|
+
# 于是末段倒车的地图上:
|
|
308
|
+
# 1. env 用它算 goal_sig(sin/cos(robot_theta - goal_heading))→ 目标
|
|
309
|
+
# 朝向被指反;
|
|
310
|
+
# 2. 车"正确地倒车到达"时 reward 的 10×cos(goal_heading_error) 给 −10。
|
|
311
|
+
# 与地图生成侧的 _compute_goal_heading 保持同一语义,两处必须一致,
|
|
312
|
+
# 否则"地图给的终点朝向"与"bridge 报的终点朝向"打架。
|
|
313
|
+
# 注意:本 bridge 的 check_goal_reached() 只判距离 + 末段航点窗口,
|
|
314
|
+
# 不含朝向容差;朝向容差判定只在 ROS2 桥使用。
|
|
315
|
+
if len(path_waypoints) >= 2 and path_waypoints.shape[1] >= 3:
|
|
316
|
+
self._goal_heading = float(
|
|
317
|
+
(float(path_waypoints[-1][2]) + np.pi) % (2 * np.pi) - np.pi
|
|
318
|
+
)
|
|
319
|
+
elif len(path_waypoints) >= 2:
|
|
320
|
+
# 2 列路径无鼻向信息 → 退回运动方向(保持旧行为)
|
|
321
|
+
dx = path_waypoints[-1][0] - path_waypoints[-2][0]
|
|
322
|
+
dy = path_waypoints[-1][1] - path_waypoints[-2][1]
|
|
323
|
+
self._goal_heading = float(np.arctan2(dy, dx))
|
|
324
|
+
else:
|
|
325
|
+
self._goal_heading = 0.0
|
|
326
|
+
else:
|
|
327
|
+
# 回退方案:从当前朝向向前生成直线路径(10 米)
|
|
328
|
+
num_pts = 20
|
|
329
|
+
t = np.linspace(0, 10.0, num_pts)
|
|
330
|
+
path_x = self._x + t * np.cos(self._theta)
|
|
331
|
+
path_y = self._y + t * np.sin(self._theta)
|
|
332
|
+
self._path_waypoints_global = np.column_stack(
|
|
333
|
+
[
|
|
334
|
+
path_x,
|
|
335
|
+
path_y,
|
|
336
|
+
np.full(num_pts, self._theta),
|
|
337
|
+
]
|
|
338
|
+
)
|
|
339
|
+
self._goal_heading = float(self._theta)
|
|
340
|
+
|
|
341
|
+
# ── 首帧传感器读数(S3b)──
|
|
342
|
+
# 必须排在位姿 / 朝向 / 时间都复位之后:射线求交用的就是 _x/_y/_theta,
|
|
343
|
+
# 提前调用会拿**上一回合**的位姿算。v/vy/omega 已在上方清零,所以这一帧
|
|
344
|
+
# 是"静止于起点"的真实读数,reset 返回的观测里 lidar 段不再谎报量程外。
|
|
345
|
+
self._update_sensors()
|
|
346
|
+
|
|
347
|
+
# ── 动态障碍 ────────────────────────────────────────────────────
|
|
348
|
+
|
|
349
|
+
@property
|
|
350
|
+
def sim_time(self) -> float:
|
|
351
|
+
"""自上次 `reset()` 起的模拟时间(秒)= 步数 × dt。"""
|
|
352
|
+
return self._steps * self.dt
|
|
353
|
+
|
|
354
|
+
def set_dynamic_obstacles(self, specs: list | None = None) -> None:
|
|
355
|
+
"""换一组动态障碍(`None` / `[]` = 清空)。**不**改变模拟时间。
|
|
356
|
+
|
|
357
|
+
`reset()` 会把时间归零,因此"相位复位到初态"由 `reset()` 负责;本方法
|
|
358
|
+
只替换规格,位置按**当前**时间重新求值(运行中换障碍即在下一步生效)。
|
|
359
|
+
"""
|
|
360
|
+
parsed: list[dict[str, Any]] = []
|
|
361
|
+
for index, raw in enumerate(specs or ()):
|
|
362
|
+
kind = raw.get("type")
|
|
363
|
+
radius = float(raw.get("radius", 0.0))
|
|
364
|
+
if radius <= 0.0:
|
|
365
|
+
raise ValueError(
|
|
366
|
+
f"dynamic_obstacles[{index}].radius 必须 > 0,实际是 {radius!r}"
|
|
367
|
+
)
|
|
368
|
+
if kind == "linear":
|
|
369
|
+
parsed.append(
|
|
370
|
+
{
|
|
371
|
+
"type": "linear",
|
|
372
|
+
"start": tuple(float(v) for v in raw["start"]),
|
|
373
|
+
"velocity": tuple(float(v) for v in raw["velocity"]),
|
|
374
|
+
"radius": radius,
|
|
375
|
+
"period": float(raw.get("period", 0.0)),
|
|
376
|
+
"pingpong": bool(raw.get("pingpong", False)),
|
|
377
|
+
}
|
|
378
|
+
)
|
|
379
|
+
elif kind == "circular":
|
|
380
|
+
parsed.append(
|
|
381
|
+
{
|
|
382
|
+
"type": "circular",
|
|
383
|
+
"center": tuple(float(v) for v in raw["center"]),
|
|
384
|
+
"radius_orbit": float(raw["radius_orbit"]),
|
|
385
|
+
"omega": float(raw["omega"]),
|
|
386
|
+
"phase": float(raw.get("phase", 0.0)),
|
|
387
|
+
"radius": radius,
|
|
388
|
+
}
|
|
389
|
+
)
|
|
390
|
+
else:
|
|
391
|
+
raise ValueError(
|
|
392
|
+
f"dynamic_obstacles[{index}].type 未知: {kind!r}"
|
|
393
|
+
"(支持: linear, circular)"
|
|
394
|
+
)
|
|
395
|
+
self._dyn_specs = parsed
|
|
396
|
+
self._dyn_centers = np.zeros((len(parsed), 2), dtype=np.float64)
|
|
397
|
+
self._dyn_radii = np.array([p["radius"] for p in parsed], dtype=np.float64)
|
|
398
|
+
self._refresh_dynamic()
|
|
399
|
+
|
|
400
|
+
def _refresh_dynamic(self) -> None:
|
|
401
|
+
"""按当前 `t = _steps * dt` 重算动态障碍圆心(解析式,零随机)。"""
|
|
402
|
+
if not self._dyn_specs:
|
|
403
|
+
return
|
|
404
|
+
t = self._steps * self.dt
|
|
405
|
+
for index, spec in enumerate(self._dyn_specs):
|
|
406
|
+
if spec["type"] == "linear":
|
|
407
|
+
sx, sy = spec["start"]
|
|
408
|
+
vx, vy = spec["velocity"]
|
|
409
|
+
phase = _linear_phase(t, spec["period"], spec["pingpong"])
|
|
410
|
+
self._dyn_centers[index, 0] = sx + vx * phase
|
|
411
|
+
self._dyn_centers[index, 1] = sy + vy * phase
|
|
412
|
+
else:
|
|
413
|
+
cx, cy = spec["center"]
|
|
414
|
+
phi = spec["phase"] + spec["omega"] * t
|
|
415
|
+
self._dyn_centers[index, 0] = cx + spec["radius_orbit"] * np.cos(phi)
|
|
416
|
+
self._dyn_centers[index, 1] = cy + spec["radius_orbit"] * np.sin(phi)
|
|
417
|
+
|
|
418
|
+
def dynamic_positions(self) -> np.ndarray:
|
|
419
|
+
"""`(D, 3)` 的 `[x, y, radius]` 快照(当前时刻;无动态障碍时 `(0, 3)`)。"""
|
|
420
|
+
if not self._dyn_specs:
|
|
421
|
+
return np.empty((0, 3))
|
|
422
|
+
return np.column_stack([self._dyn_centers, self._dyn_radii])
|
|
423
|
+
|
|
424
|
+
def step(self, action: np.ndarray) -> None:
|
|
425
|
+
"""把仿真推进一个时间步(三步顺序固定,不能变)。
|
|
426
|
+
|
|
427
|
+
1. `cmd = chassis.step(action)` —— 执行器状态推进(阿克曼的转向滞后一拍
|
|
428
|
+
在底盘里发生;默认底盘等价于 `command()`);
|
|
429
|
+
2. `chassis.integrate(...)` —— 唯一的位姿积分核;
|
|
430
|
+
3. `θ` 归一化到 [-π, π] —— 与旧桥 :390 逐字同式。
|
|
431
|
+
"""
|
|
432
|
+
cmd = self.chassis.step(action)
|
|
433
|
+
self._v = float(cmd.v)
|
|
434
|
+
self._omega = float(cmd.omega)
|
|
435
|
+
self._x, self._y, self._theta = self.chassis.integrate(
|
|
436
|
+
self._x, self._y, self._theta, cmd, self.dt
|
|
437
|
+
)
|
|
438
|
+
self._theta = (self._theta + np.pi) % (2 * np.pi) - np.pi
|
|
439
|
+
self._vy = float(cmd.vy)
|
|
440
|
+
|
|
441
|
+
# 动态障碍先推进到与本步位姿**同一时刻**(t = _steps * dt),再判碰撞,
|
|
442
|
+
# 否则第 k 步会用上一时刻的障碍位置(错一拍)。
|
|
443
|
+
self._steps += 1
|
|
444
|
+
self._refresh_dynamic()
|
|
445
|
+
|
|
446
|
+
# 碰撞 / 目标检测
|
|
447
|
+
self._collision = self._check_collision()
|
|
448
|
+
|
|
449
|
+
goal_pt = (
|
|
450
|
+
self._path_waypoints_global[-1, :2]
|
|
451
|
+
if len(self._path_waypoints_global) > 0
|
|
452
|
+
else None
|
|
453
|
+
)
|
|
454
|
+
if goal_pt is not None:
|
|
455
|
+
dist = np.hypot(self._x - goal_pt[0], self._y - goal_pt[1])
|
|
456
|
+
# 最近路径点必须在末尾 N 个以内,防止路径折返时路过 goal 提前触发
|
|
457
|
+
wp_dists = np.hypot(
|
|
458
|
+
self._path_waypoints_global[:, 0] - self._x,
|
|
459
|
+
self._path_waypoints_global[:, 1] - self._y,
|
|
460
|
+
)
|
|
461
|
+
nearest_idx = int(np.argmin(wp_dists))
|
|
462
|
+
n_waypoints = len(self._path_waypoints_global)
|
|
463
|
+
end_margin = min(self._goal_end_wp_count, max(n_waypoints - 1, 1))
|
|
464
|
+
near_end = (
|
|
465
|
+
nearest_idx >= n_waypoints - end_margin - self._goal_end_wp_margin
|
|
466
|
+
)
|
|
467
|
+
# 显式 bool():SensorProvider 声明 -> bool,旧桥此处会漏出 np.bool_
|
|
468
|
+
self._goal_reached = bool(dist <= self._goal_dist_threshold and near_end)
|
|
469
|
+
else:
|
|
470
|
+
self._goal_reached = False
|
|
471
|
+
|
|
472
|
+
# 传感器在本步位姿/速度确定之后推进(t = _steps * dt,与 sim_time 同源)
|
|
473
|
+
self._update_sensors()
|
|
474
|
+
|
|
475
|
+
# ── 传感器 ────────────────────────────────────────────────────────
|
|
476
|
+
|
|
477
|
+
def get_lidar_front(self) -> np.ndarray:
|
|
478
|
+
return self._simulate_lidar(front=True)
|
|
479
|
+
|
|
480
|
+
def get_lidar_rear(self) -> np.ndarray:
|
|
481
|
+
return self._simulate_lidar(front=False)
|
|
482
|
+
|
|
483
|
+
def get_scan(self, side: str = "front") -> dict[str, Any]:
|
|
484
|
+
"""返回带 ROS sensor_msgs/LaserScan 元数据的单侧扫描字典。
|
|
485
|
+
|
|
486
|
+
键序与字段见 base.SCAN_FIELDS。数值全部由本类的扫描几何常量推导,
|
|
487
|
+
**没有**手工填的角度:
|
|
488
|
+
|
|
489
|
+
* angle_min = 安装朝向偏移 - pi/2(前雷达 -pi/2、后雷达 +pi/2);
|
|
490
|
+
* angle_max = angle_min + (N_LIDAR_RAYS - 1) * angle_increment;
|
|
491
|
+
* angle_increment = (angle_max - angle_min) / (N_LIDAR_RAYS - 1)
|
|
492
|
+
= LIDAR_FOV / (N_LIDAR_RAYS - 1)——与 _simulate_lidar 里
|
|
493
|
+
np.linspace(start, start + LIDAR_FOV, N_LIDAR_RAYS, endpoint=True) 的
|
|
494
|
+
步长是**同一个式子**,因此第 i 条射线的方位角恒等于
|
|
495
|
+
angle_min + i * angle_increment(约定已写进 SensorProvider.get_scan);
|
|
496
|
+
* range_min = RANGE_MIN(= 射线距离下限)、range_max = max_range;
|
|
497
|
+
* time_increment = scan_time = 0.0 —— 本桥一次调用用**同一帧位姿**发射全部
|
|
498
|
+
射线(瞬时快照,不建模扫掠时间),故单条射线耗时与整帧耗时精确为 0,
|
|
499
|
+
不是估算出的硬件参数。
|
|
500
|
+
|
|
501
|
+
angle_min / angle_max **不**归一化到 [-pi, pi](因此后雷达是
|
|
502
|
+
[pi/2, 3pi/2]):一旦折叠,angle_max - angle_min 就不再等于 (n-1) *
|
|
503
|
+
angle_increment,消费方按 angle_min + i * angle_increment 还原方位角会错。
|
|
504
|
+
需要车体系 [-pi, pi] 方位角时请自行对每根射线归一化。
|
|
505
|
+
|
|
506
|
+
Args:
|
|
507
|
+
side: "front" 或 "rear"。
|
|
508
|
+
|
|
509
|
+
Returns:
|
|
510
|
+
dict[str, Any]:ranges 为 float32 一维数组(长度 N_LIDAR_RAYS)。
|
|
511
|
+
**无返回用 +inf**:底层 _simulate_lidar 把距离 clip 到
|
|
512
|
+
[RANGE_MIN, max_range],饱和读数与"恰好命中 max_range"已不可区分,
|
|
513
|
+
因此 >= max_range 的读数在 scan 里统一报 +inf,不拿 range_max 冒充
|
|
514
|
+
有效测距;有限读数与 get_lidar_front() / get_lidar_rear() 逐元素
|
|
515
|
+
(bit 级)相等,且两个原始方法本身的返回值不变。
|
|
516
|
+
intensities 恒为 None(不建模回波强度);frame_id 为
|
|
517
|
+
"laser_front" / "laser_rear"。
|
|
518
|
+
|
|
519
|
+
Raises:
|
|
520
|
+
ValueError: side 不是 "front" / "rear"(消息里列出合法值)。
|
|
521
|
+
"""
|
|
522
|
+
if side == "front":
|
|
523
|
+
angle_off = float(self._front_lidar[2])
|
|
524
|
+
ranges = self.get_lidar_front()
|
|
525
|
+
elif side == "rear":
|
|
526
|
+
angle_off = float(self._rear_lidar[2])
|
|
527
|
+
ranges = self.get_lidar_rear()
|
|
528
|
+
else:
|
|
529
|
+
raise ValueError(
|
|
530
|
+
f"side 必须是 'front' 或 'rear',收到 {side!r}(合法值: 'front', 'rear')"
|
|
531
|
+
)
|
|
532
|
+
|
|
533
|
+
# 无返回语义转换:clip 之后的饱和读数(>= max_range)无法与真实命中区分,
|
|
534
|
+
# 统一报 +inf(ROS 惯例);原始饱和值仍由 get_lidar_*() 原样返回,行为不变。
|
|
535
|
+
ranges = np.where(ranges >= self.max_range, np.inf, ranges).astype(np.float32)
|
|
536
|
+
|
|
537
|
+
n_rays = int(self.N_LIDAR_RAYS)
|
|
538
|
+
angle_min = angle_off - np.pi / 2.0
|
|
539
|
+
angle_increment = float(self.LIDAR_FOV) / (n_rays - 1)
|
|
540
|
+
return {
|
|
541
|
+
"angle_min": float(angle_min),
|
|
542
|
+
"angle_max": float(angle_min + (n_rays - 1) * angle_increment),
|
|
543
|
+
"angle_increment": float(angle_increment),
|
|
544
|
+
"time_increment": 0.0,
|
|
545
|
+
"scan_time": 0.0,
|
|
546
|
+
"range_min": float(self.RANGE_MIN),
|
|
547
|
+
"range_max": float(self.max_range),
|
|
548
|
+
"ranges": ranges,
|
|
549
|
+
"intensities": None,
|
|
550
|
+
"frame_id": f"laser_{side}",
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
def _simulate_lidar(self, *, front: bool) -> np.ndarray:
|
|
554
|
+
"""默认单侧雷达:180 线 × π 视场,行为与加入参数化传感器前逐位相同。
|
|
555
|
+
|
|
556
|
+
实际几何全部在 `_cast_rays` 里;这里只是把本桥的历史常量按原值传下去
|
|
557
|
+
(N_LIDAR_RAYS / LIDAR_FOV / max_range / RANGE_MIN / domain_rand),因此默认
|
|
558
|
+
调用路径的算术与缓冲区复用方式都没有变化。
|
|
559
|
+
"""
|
|
560
|
+
return self._cast_rays(
|
|
561
|
+
front=front,
|
|
562
|
+
n_rays=int(self.N_LIDAR_RAYS),
|
|
563
|
+
fov=float(self.LIDAR_FOV),
|
|
564
|
+
max_range=self.max_range,
|
|
565
|
+
range_min=self.RANGE_MIN,
|
|
566
|
+
domain_rand=self._domain_rand,
|
|
567
|
+
)
|
|
568
|
+
|
|
569
|
+
def _ensure_lidar_buffers(self, n_rays: int) -> None:
|
|
570
|
+
"""按射线数准备预分配缓冲区(默认 180 时**不**重分配)。"""
|
|
571
|
+
if self._lidar_distances.shape[0] == n_rays:
|
|
572
|
+
return
|
|
573
|
+
self._lidar_origins = np.empty((n_rays, 2), dtype=np.float64)
|
|
574
|
+
self._lidar_directions = np.empty((n_rays, 2), dtype=np.float64)
|
|
575
|
+
self._lidar_distances = np.empty(n_rays, dtype=np.float64)
|
|
576
|
+
self._lidar_angles = np.empty(n_rays, dtype=np.float64)
|
|
577
|
+
|
|
578
|
+
def _cast_rays(
|
|
579
|
+
self,
|
|
580
|
+
*,
|
|
581
|
+
front: bool,
|
|
582
|
+
n_rays: int,
|
|
583
|
+
fov: float,
|
|
584
|
+
max_range: float,
|
|
585
|
+
range_min: float,
|
|
586
|
+
domain_rand: bool,
|
|
587
|
+
mount: tuple[float, float] | None = None,
|
|
588
|
+
angles: np.ndarray | None = None,
|
|
589
|
+
) -> np.ndarray:
|
|
590
|
+
"""从前方或后方 LiDAR 安装位发射 `n_rays` 条射线。
|
|
591
|
+
|
|
592
|
+
几何与默认雷达共用同一份算术(圆 / 矩形 / 动态圆 / 四面墙求交 + clip),
|
|
593
|
+
参数化只体现在射线数与视场角:
|
|
594
|
+
|
|
595
|
+
angles = linspace(θ + mount_yaw - fov/2, + fov, n_rays, endpoint=True)
|
|
596
|
+
|
|
597
|
+
因此第 i 条射线相对车头的方位角 = (mount_yaw - fov/2) + i·fov/(n_rays-1):
|
|
598
|
+
视场以安装朝向为中心(fov = π 时与历史的 -π/2 起点逐位相同)。
|
|
599
|
+
|
|
600
|
+
Args:
|
|
601
|
+
front: True 用前安装位,False 用后安装位。
|
|
602
|
+
n_rays: 射线数(>= 2)。
|
|
603
|
+
fov: 视场角(弧度)。
|
|
604
|
+
max_range: 最大量程(米)。
|
|
605
|
+
range_min: 最小量程(米)。
|
|
606
|
+
domain_rand: True 时按 `lidar_noise_std` 叠加**全局** np.random 噪声
|
|
607
|
+
(历史行为);参数化传感器传 False —— 它有自己的独立 Generator。
|
|
608
|
+
mount: 车体系射线原点 (x, y);给定时忽略 front 所指的安装位(安装朝向
|
|
609
|
+
已经烘进 angles),供传感器包注入的单射线调用使用。
|
|
610
|
+
angles: 世界系射线方位角(长度必须等于 n_rays);给定时替代
|
|
611
|
+
`linspace(θ + mount_yaw - fov/2, +fov)` 的均分。两条新参同时缺省
|
|
612
|
+
时本函数的算术与缓冲区用法与加入参数化传感器前逐位相同。
|
|
613
|
+
|
|
614
|
+
Returns:
|
|
615
|
+
(n_rays,) float32 距离,已 clip 到 [range_min, max_range]。
|
|
616
|
+
"""
|
|
617
|
+
self._ensure_lidar_buffers(n_rays)
|
|
618
|
+
|
|
619
|
+
# 传感器在世界坐标系中的位姿
|
|
620
|
+
if mount is None:
|
|
621
|
+
off_x, off_y, angle_off = self._front_lidar if front else self._rear_lidar
|
|
622
|
+
else:
|
|
623
|
+
# 车体系原点直给:世界系变换照旧,朝向由 angles 自己带
|
|
624
|
+
off_x, off_y, angle_off = float(mount[0]), float(mount[1]), 0.0
|
|
625
|
+
|
|
626
|
+
cos_t = np.cos(self._theta)
|
|
627
|
+
sin_t = np.sin(self._theta)
|
|
628
|
+
sx = self._x + off_x * cos_t - off_y * sin_t
|
|
629
|
+
sy = self._y + off_x * sin_t + off_y * cos_t
|
|
630
|
+
|
|
631
|
+
# 射线方向——复用预分配的缓冲区
|
|
632
|
+
if angles is None:
|
|
633
|
+
start_angle = self._theta + angle_off - 0.5 * fov
|
|
634
|
+
self._lidar_angles[:] = np.linspace(
|
|
635
|
+
start_angle, start_angle + fov, n_rays, endpoint=True
|
|
636
|
+
)
|
|
637
|
+
else:
|
|
638
|
+
self._lidar_angles[:] = np.asarray(angles, dtype=np.float64)
|
|
639
|
+
np.cos(self._lidar_angles, out=self._lidar_directions[:, 0])
|
|
640
|
+
np.sin(self._lidar_angles, out=self._lidar_directions[:, 1])
|
|
641
|
+
self._lidar_origins[:, 0] = sx
|
|
642
|
+
self._lidar_origins[:, 1] = sy
|
|
643
|
+
|
|
644
|
+
# 对所有障碍物进行射线求交
|
|
645
|
+
self._lidar_distances.fill(max_range)
|
|
646
|
+
|
|
647
|
+
if len(self._circle_radii) > 0:
|
|
648
|
+
d_c = _ray_circles_intersection(
|
|
649
|
+
self._lidar_origins,
|
|
650
|
+
self._lidar_directions,
|
|
651
|
+
self._circle_centers,
|
|
652
|
+
self._circle_radii,
|
|
653
|
+
max_range,
|
|
654
|
+
)
|
|
655
|
+
np.minimum(self._lidar_distances, d_c, out=self._lidar_distances)
|
|
656
|
+
|
|
657
|
+
# 动态障碍(当作圆参与同一套射线求交;空则跳过 ⇒ 默认路径逐位不变)
|
|
658
|
+
if len(self._dyn_radii) > 0:
|
|
659
|
+
d_dyn = _ray_circles_intersection(
|
|
660
|
+
self._lidar_origins,
|
|
661
|
+
self._lidar_directions,
|
|
662
|
+
self._dyn_centers,
|
|
663
|
+
self._dyn_radii,
|
|
664
|
+
max_range,
|
|
665
|
+
)
|
|
666
|
+
np.minimum(self._lidar_distances, d_dyn, out=self._lidar_distances)
|
|
667
|
+
|
|
668
|
+
if len(self._rect_thetas) > 0:
|
|
669
|
+
d_r = _ray_rects_intersection(
|
|
670
|
+
self._lidar_origins,
|
|
671
|
+
self._lidar_directions,
|
|
672
|
+
self._rect_centers,
|
|
673
|
+
self._rect_half_dims,
|
|
674
|
+
self._rect_thetas,
|
|
675
|
+
max_range,
|
|
676
|
+
)
|
|
677
|
+
np.minimum(self._lidar_distances, d_r, out=self._lidar_distances)
|
|
678
|
+
|
|
679
|
+
# 墙壁边界
|
|
680
|
+
directions_y = self._lidar_directions[:, 1]
|
|
681
|
+
directions_x = self._lidar_directions[:, 0]
|
|
682
|
+
|
|
683
|
+
# 上墙
|
|
684
|
+
mask = directions_y > 1e-10
|
|
685
|
+
if np.any(mask):
|
|
686
|
+
d_top = (self._world_ymax - sy) / directions_y[mask]
|
|
687
|
+
better = (d_top > 0) & (d_top < self._lidar_distances[mask])
|
|
688
|
+
self._lidar_distances[mask] = np.where(
|
|
689
|
+
better, d_top, self._lidar_distances[mask]
|
|
690
|
+
)
|
|
691
|
+
|
|
692
|
+
# 下墙
|
|
693
|
+
mask = directions_y < -1e-10
|
|
694
|
+
if np.any(mask):
|
|
695
|
+
d_bot = (self._world_ymin - sy) / directions_y[mask]
|
|
696
|
+
better = (d_bot > 0) & (d_bot < self._lidar_distances[mask])
|
|
697
|
+
self._lidar_distances[mask] = np.where(
|
|
698
|
+
better, d_bot, self._lidar_distances[mask]
|
|
699
|
+
)
|
|
700
|
+
|
|
701
|
+
# 右墙
|
|
702
|
+
mask = directions_x > 1e-10
|
|
703
|
+
if np.any(mask):
|
|
704
|
+
d_right = (self._world_xmax - sx) / directions_x[mask]
|
|
705
|
+
better = (d_right > 0) & (d_right < self._lidar_distances[mask])
|
|
706
|
+
self._lidar_distances[mask] = np.where(
|
|
707
|
+
better, d_right, self._lidar_distances[mask]
|
|
708
|
+
)
|
|
709
|
+
|
|
710
|
+
# 左墙
|
|
711
|
+
mask = directions_x < -1e-10
|
|
712
|
+
if np.any(mask):
|
|
713
|
+
d_left = (self._world_xmin - sx) / directions_x[mask]
|
|
714
|
+
better = (d_left > 0) & (d_left < self._lidar_distances[mask])
|
|
715
|
+
self._lidar_distances[mask] = np.where(
|
|
716
|
+
better, d_left, self._lidar_distances[mask]
|
|
717
|
+
)
|
|
718
|
+
|
|
719
|
+
# 地板(最小量程)
|
|
720
|
+
np.clip(
|
|
721
|
+
self._lidar_distances,
|
|
722
|
+
range_min,
|
|
723
|
+
max_range,
|
|
724
|
+
out=self._lidar_distances,
|
|
725
|
+
)
|
|
726
|
+
|
|
727
|
+
# Domain randomisation(全局 np.random,历史行为;参数化传感器不走这里)
|
|
728
|
+
if domain_rand:
|
|
729
|
+
noise = np.random.normal(0.0, self._lidar_noise_std, size=n_rays).astype(
|
|
730
|
+
np.float64
|
|
731
|
+
)
|
|
732
|
+
self._lidar_distances += noise
|
|
733
|
+
np.clip(
|
|
734
|
+
self._lidar_distances,
|
|
735
|
+
range_min,
|
|
736
|
+
max_range,
|
|
737
|
+
out=self._lidar_distances,
|
|
738
|
+
)
|
|
739
|
+
|
|
740
|
+
return self._lidar_distances.copy().astype(np.float32)
|
|
741
|
+
|
|
742
|
+
def _raycast_body_ray(
|
|
743
|
+
self, origin_xy: tuple[float, float], angle_rad: float, max_range: float
|
|
744
|
+
) -> float:
|
|
745
|
+
"""车体系单射线求交——注入给 `chassis_sim.sensors.SensorSuite` 的 `raycast`。
|
|
746
|
+
|
|
747
|
+
与 `_cast_rays` 共用同一份算术(圆 / 矩形 / 动态圆 / 四面墙求交 + clip):
|
|
748
|
+
几何只有一份实现,参数化传感器与 `get_lidar_front()` 不会各自漂移。区别只在
|
|
749
|
+
把"一次 n_rays 条均分射线"换成"一条指定方位角、指定原点的射线",因此本方法
|
|
750
|
+
每次都从头车变换出发(theta / x / y 取自当前步状态)。
|
|
751
|
+
|
|
752
|
+
Args:
|
|
753
|
+
origin_xy: 车体系射线原点 (x, y),单位米。
|
|
754
|
+
angle_rad: 车体系方位角(车头 x 轴起、逆时针为正),单位弧度。
|
|
755
|
+
max_range: 本次求交的最大量程(米)。
|
|
756
|
+
|
|
757
|
+
Returns:
|
|
758
|
+
float:命中距离(米),已 clip 到 [RANGE_MIN, max_range];无命中时返回
|
|
759
|
+
max_range(饱和),与 `get_lidar_front()` 的饱和语义一致。
|
|
760
|
+
"""
|
|
761
|
+
angles = np.array([self._theta + float(angle_rad)], dtype=np.float64)
|
|
762
|
+
distances = self._cast_rays(
|
|
763
|
+
front=True,
|
|
764
|
+
n_rays=1,
|
|
765
|
+
fov=0.0,
|
|
766
|
+
max_range=float(max_range),
|
|
767
|
+
range_min=self.RANGE_MIN,
|
|
768
|
+
domain_rand=False,
|
|
769
|
+
mount=(float(origin_xy[0]), float(origin_xy[1])),
|
|
770
|
+
angles=angles,
|
|
771
|
+
)
|
|
772
|
+
return float(distances[0])
|
|
773
|
+
|
|
774
|
+
def get_sensor_reading(self) -> dict[str, Any] | None:
|
|
775
|
+
"""当前步的传感器读数(未启用任何传感器时为 None)。
|
|
776
|
+
|
|
777
|
+
结构是 `dict[传感器名, 读数]`,键序 = 构造时传入的 spec 列表顺序,只包含
|
|
778
|
+
**已装配**的传感器(没给 spec 就没有键):
|
|
779
|
+
|
|
780
|
+
* LiDAR: {"scan": (n_rays,) float32 或 None, "scan_meta": {...}}
|
|
781
|
+
scan 为 None = 延迟预热期 / 本步丢帧(见 scan_meta 的
|
|
782
|
+
valid / dropped);scan_meta 含 name / mount / 角度几何 /
|
|
783
|
+
range_min / range_max / latency_steps / t / frame_t。
|
|
784
|
+
* IMU: {"ax", "ay", "yaw_rate", "yaw", "t"}
|
|
785
|
+
* 轮速计: {"v_left", "v_right", "v", "t"}
|
|
786
|
+
|
|
787
|
+
同名传感器不可能共存(随机流与读数都按 name 索引,构造时即报错)。
|
|
788
|
+
消费方只有 env 的 info 组装:本桥从不把读数放进 obs,obs 维度保持不变。
|
|
789
|
+
返回浅拷贝,调用方改字典不会污染桥的内部状态(内层读数与数组仍是引用)。
|
|
790
|
+
"""
|
|
791
|
+
return None if self._sensor_reading is None else dict(self._sensor_reading)
|
|
792
|
+
|
|
793
|
+
def _update_sensors(self) -> None:
|
|
794
|
+
"""推进可选传感器;未启用时是零开销空操作(不消耗任何随机数)。"""
|
|
795
|
+
if self._sensors is None:
|
|
796
|
+
return
|
|
797
|
+
# LiDAR 的每条射线通过构造时注入的 _raycast_body_ray 回调求交;那里一律
|
|
798
|
+
# domain_rand=False —— 传感器的噪声来自自己的 Generator,绝不消耗全局
|
|
799
|
+
# np.random(否则会平移 domain_rand 的 LiDAR/里程计序列)。
|
|
800
|
+
self._sensor_reading = self._sensors.update(
|
|
801
|
+
v=self._v,
|
|
802
|
+
vy=self._vy,
|
|
803
|
+
omega=self._omega,
|
|
804
|
+
theta=self._theta,
|
|
805
|
+
t=self.sim_time,
|
|
806
|
+
)
|
|
807
|
+
|
|
808
|
+
def get_odometry(self) -> np.ndarray:
|
|
809
|
+
# 注意: 里程计漂移在返回后累积 (与 ir-sim 桥的内部漂移时序差 1 步)。
|
|
810
|
+
# 初始化时漂移为 0,训练中无实际影响;对拍精度测试可能出现微小偏差。
|
|
811
|
+
# 顺序即契约:先产出含当前漂移的值,再把本步漂移累加进状态。
|
|
812
|
+
x = self._x + self._odom_drift_x
|
|
813
|
+
y = self._y + self._odom_drift_y
|
|
814
|
+
t = self._theta + self._odom_drift_t
|
|
815
|
+
if self._domain_rand:
|
|
816
|
+
self._odom_drift_x += np.random.normal(0.0, self._odom_drift_xy * self.dt)
|
|
817
|
+
self._odom_drift_y += np.random.normal(0.0, self._odom_drift_xy * self.dt)
|
|
818
|
+
self._odom_drift_t += np.random.normal(
|
|
819
|
+
0.0, self._odom_drift_theta * self.dt
|
|
820
|
+
)
|
|
821
|
+
return np.array([x, y, t, self._v, self._omega], dtype=np.float32)
|
|
822
|
+
|
|
823
|
+
# ── 只读快照访问器(渲染层用;不改变任何既有行为)────────────────
|
|
824
|
+
|
|
825
|
+
def pose(self) -> tuple[float, float, float]:
|
|
826
|
+
"""当前真实位姿 (x, y, θ):世界系,单位米 / 弧度(不含里程计漂移)。"""
|
|
827
|
+
return (float(self._x), float(self._y), float(self._theta))
|
|
828
|
+
|
|
829
|
+
def obstacle_shapes(self) -> list[dict[str, Any]]:
|
|
830
|
+
"""静态障碍物快照,格式与 `reset(obstacles=...)` 的输入一致。
|
|
831
|
+
|
|
832
|
+
返回**新建**的 list / dict,调用方改动不会污染桥内部状态;动态障碍物
|
|
833
|
+
不在其中(它们随时间变化,需走 `dynamic_positions()`)。
|
|
834
|
+
"""
|
|
835
|
+
shapes: list[dict[str, Any]] = []
|
|
836
|
+
for (cx, cy), radius in zip(self._circle_centers, self._circle_radii):
|
|
837
|
+
shapes.append(
|
|
838
|
+
{
|
|
839
|
+
"shape": {"name": "circle", "radius": float(radius)},
|
|
840
|
+
"state": [float(cx), float(cy), 0.0],
|
|
841
|
+
}
|
|
842
|
+
)
|
|
843
|
+
for (cx, cy), (half_l, half_w), theta in zip(
|
|
844
|
+
self._rect_centers, self._rect_half_dims, self._rect_thetas
|
|
845
|
+
):
|
|
846
|
+
shapes.append(
|
|
847
|
+
{
|
|
848
|
+
"shape": {
|
|
849
|
+
"name": "rectangle",
|
|
850
|
+
"length": float(2.0 * half_l),
|
|
851
|
+
"width": float(2.0 * half_w),
|
|
852
|
+
},
|
|
853
|
+
"state": [float(cx), float(cy), float(theta)],
|
|
854
|
+
}
|
|
855
|
+
)
|
|
856
|
+
return shapes
|
|
857
|
+
|
|
858
|
+
def get_path(self) -> np.ndarray:
|
|
859
|
+
if len(self._path_waypoints_global) == 0:
|
|
860
|
+
return np.zeros((0, 3), dtype=np.float32)
|
|
861
|
+
|
|
862
|
+
# 向量化的车体坐标系变换
|
|
863
|
+
dx = self._path_waypoints_global[:, 0] - self._x
|
|
864
|
+
dy = self._path_waypoints_global[:, 1] - self._y
|
|
865
|
+
cos_t = np.cos(-self._theta)
|
|
866
|
+
sin_t = np.sin(-self._theta)
|
|
867
|
+
rx = dx * cos_t - dy * sin_t
|
|
868
|
+
ry = dx * sin_t + dy * cos_t
|
|
869
|
+
|
|
870
|
+
if self._path_waypoints_global.shape[1] >= 3:
|
|
871
|
+
rtheta = self._path_waypoints_global[:, 2] - self._theta
|
|
872
|
+
rtheta = (rtheta + np.pi) % (2 * np.pi) - np.pi
|
|
873
|
+
return np.column_stack([rx, ry, rtheta]).astype(np.float32)
|
|
874
|
+
return np.column_stack([rx, ry]).astype(np.float32)
|
|
875
|
+
|
|
876
|
+
def get_path_global(self) -> np.ndarray:
|
|
877
|
+
"""返回世界系下全部路径航点,形状 (N, 3) [x, y, θ]。"""
|
|
878
|
+
if len(self._path_waypoints_global) == 0:
|
|
879
|
+
return np.zeros((0, 3), dtype=np.float32)
|
|
880
|
+
return self._path_waypoints_global.astype(np.float32).copy()
|
|
881
|
+
|
|
882
|
+
def check_collision(self) -> bool:
|
|
883
|
+
return self._collision
|
|
884
|
+
|
|
885
|
+
def check_goal_reached(self, threshold: float = 0.3) -> bool:
|
|
886
|
+
# _goal_reached 在 step() 中用 _goal_dist_threshold 计算。
|
|
887
|
+
# 若调用者传入不同 threshold,用新阈值重新判断距离部分。
|
|
888
|
+
if threshold == self._goal_dist_threshold:
|
|
889
|
+
return bool(self._goal_reached)
|
|
890
|
+
if not self._goal_reached and threshold <= self._goal_dist_threshold:
|
|
891
|
+
return False # 更严格阈值,原本就未达成,无需复算
|
|
892
|
+
goal_pt = (
|
|
893
|
+
self._path_waypoints_global[-1, :2]
|
|
894
|
+
if len(self._path_waypoints_global) > 0
|
|
895
|
+
else None
|
|
896
|
+
)
|
|
897
|
+
if goal_pt is not None:
|
|
898
|
+
dist = np.hypot(self._x - goal_pt[0], self._y - goal_pt[1])
|
|
899
|
+
# 最近路径点必须在末尾 N 个以内,防止路径折返时路过 goal 提前触发
|
|
900
|
+
wp_dists = np.hypot(
|
|
901
|
+
self._path_waypoints_global[:, 0] - self._x,
|
|
902
|
+
self._path_waypoints_global[:, 1] - self._y,
|
|
903
|
+
)
|
|
904
|
+
nearest_idx = int(np.argmin(wp_dists))
|
|
905
|
+
n_waypoints = len(self._path_waypoints_global)
|
|
906
|
+
end_margin = min(self._goal_end_wp_count, max(n_waypoints - 1, 1))
|
|
907
|
+
near_end = (
|
|
908
|
+
nearest_idx >= n_waypoints - end_margin - self._goal_end_wp_margin
|
|
909
|
+
)
|
|
910
|
+
return bool(dist < threshold and near_end)
|
|
911
|
+
return bool(self._goal_reached)
|
|
912
|
+
|
|
913
|
+
def close(self) -> None:
|
|
914
|
+
pass # 无需清理资源
|
|
915
|
+
|
|
916
|
+
# ═════════════════════════════════════════════════════════════════
|
|
917
|
+
# 内部方法
|
|
918
|
+
# ═════════════════════════════════════════════════════════════════
|
|
919
|
+
|
|
920
|
+
def _circles_overlap_robot(
|
|
921
|
+
self,
|
|
922
|
+
centers: np.ndarray,
|
|
923
|
+
radii: np.ndarray,
|
|
924
|
+
hl: float,
|
|
925
|
+
hw: float,
|
|
926
|
+
cos_t: float,
|
|
927
|
+
sin_t: float,
|
|
928
|
+
cw: np.ndarray,
|
|
929
|
+
) -> bool:
|
|
930
|
+
"""圆(静态/动态共用)与机器人矩形的重叠判定。
|
|
931
|
+
|
|
932
|
+
两条独立的检查相或:(a) 机器人四个角点落在圆内(`r + margin`);
|
|
933
|
+
(b) 圆心落在机器人矩形内(`hl/hw` 已含 margin,故半径**不**再加 margin,
|
|
934
|
+
避免 margin 双重计数)。静态圆与动态圆走同一份算数,只是喂进来的数组不同。
|
|
935
|
+
"""
|
|
936
|
+
margin = self.collision_margin
|
|
937
|
+
diff = cw[:, None, :] - centers[None, :, :] # (4, C, 2)
|
|
938
|
+
r_eff = radii + margin # (C,)
|
|
939
|
+
if np.any(np.sum(diff * diff, axis=-1) < r_eff[None, :] ** 2):
|
|
940
|
+
return True
|
|
941
|
+
local_c = centers - np.array([self._x, self._y]) # (C, 2)
|
|
942
|
+
lx_c = cos_t * local_c[:, 0] + sin_t * local_c[:, 1]
|
|
943
|
+
ly_c = -sin_t * local_c[:, 0] + cos_t * local_c[:, 1]
|
|
944
|
+
return bool(np.any((np.abs(lx_c) < hl + radii) & (np.abs(ly_c) < hw + radii)))
|
|
945
|
+
|
|
946
|
+
def _check_collision(self) -> bool:
|
|
947
|
+
"""机器人体与任何障碍物重叠则返回 True。
|
|
948
|
+
|
|
949
|
+
在障碍物维度上完全向量化——没有 Python for 循环。
|
|
950
|
+
"""
|
|
951
|
+
# 碰撞检测安全裕度:防止雷达噪声导致轻微触碰即判碰撞
|
|
952
|
+
margin = self.collision_margin
|
|
953
|
+
hl = self._robot_half_l + margin
|
|
954
|
+
hw = self._robot_half_w + margin
|
|
955
|
+
cos_t = np.cos(self._theta)
|
|
956
|
+
sin_t = np.sin(self._theta)
|
|
957
|
+
|
|
958
|
+
# ── 机器人角点在世界坐标系中的位置(含安全裕度)───────
|
|
959
|
+
# Bug fix: 旧桥缓存的局部角点不含 margin,直接用则 margin 形同虚设——
|
|
960
|
+
# 机器人角点判定仍是紧贴车身的物理极限,"幽灵车角"。此处用 hl/hw(已加
|
|
961
|
+
# margin)重建局部角点,确保四个角正确外扩。
|
|
962
|
+
cl_m = np.array(
|
|
963
|
+
[[hl, hw], [hl, -hw], [-hl, hw], [-hl, -hw]], dtype=np.float64
|
|
964
|
+
) # (4, 2) with margin
|
|
965
|
+
self._collision_corners_world[:, 0] = (
|
|
966
|
+
self._x + cos_t * cl_m[:, 0] - sin_t * cl_m[:, 1]
|
|
967
|
+
)
|
|
968
|
+
self._collision_corners_world[:, 1] = (
|
|
969
|
+
self._y + sin_t * cl_m[:, 0] + cos_t * cl_m[:, 1]
|
|
970
|
+
)
|
|
971
|
+
cw = self._collision_corners_world # (4, 2)
|
|
972
|
+
|
|
973
|
+
# ── 机器人 vs 圆形(静态 + 动态共用同一判定)──────────────
|
|
974
|
+
if len(self._circle_radii) > 0 and self._circles_overlap_robot(
|
|
975
|
+
self._circle_centers, self._circle_radii, hl, hw, cos_t, sin_t, cw
|
|
976
|
+
):
|
|
977
|
+
return True
|
|
978
|
+
if len(self._dyn_radii) > 0 and self._circles_overlap_robot(
|
|
979
|
+
self._dyn_centers, self._dyn_radii, hl, hw, cos_t, sin_t, cw
|
|
980
|
+
):
|
|
981
|
+
return True
|
|
982
|
+
|
|
983
|
+
# ── SAT: OBB(机器人) vs OBB(矩形障碍物) ──────────────────
|
|
984
|
+
# 旧代码仅检查"机器人角点是否在矩形内部"——单向检查。小障碍物
|
|
985
|
+
# (10cm 锥桶 AABB) 穿过车身中心时四个角全在盒外,幽灵穿模。
|
|
986
|
+
# SAT 在 4 根轴上检验投影重叠:任一根轴分离 → 无碰撞。
|
|
987
|
+
if len(self._rect_thetas) > 0:
|
|
988
|
+
h_rect = self._rect_half_dims + margin # (M, 2)
|
|
989
|
+
cos_rt = np.cos(self._rect_thetas) # (M,)
|
|
990
|
+
sin_rt = np.sin(self._rect_thetas) # (M,)
|
|
991
|
+
# 中心差: rect - robot
|
|
992
|
+
dx_c = self._rect_centers[:, 0] - self._x # (M,)
|
|
993
|
+
dy_c = self._rect_centers[:, 1] - self._y # (M,)
|
|
994
|
+
|
|
995
|
+
# 轴 1: 机器人局部 X = (cos_t, sin_t)
|
|
996
|
+
d1 = np.abs(cos_t * dx_c + sin_t * dy_c)
|
|
997
|
+
r_h1 = np.abs(h_rect[:, 0] * (cos_t * cos_rt + sin_t * sin_rt)) + np.abs(
|
|
998
|
+
h_rect[:, 1] * (-cos_t * sin_rt + sin_t * cos_rt)
|
|
999
|
+
)
|
|
1000
|
+
sep1 = d1 > hl + r_h1
|
|
1001
|
+
|
|
1002
|
+
# 轴 2: 机器人局部 Y = (-sin_t, cos_t)
|
|
1003
|
+
d2 = np.abs(-sin_t * dx_c + cos_t * dy_c)
|
|
1004
|
+
r_h2 = np.abs(h_rect[:, 0] * (-sin_t * cos_rt + cos_t * sin_rt)) + np.abs(
|
|
1005
|
+
h_rect[:, 1] * (sin_t * sin_rt + cos_t * cos_rt)
|
|
1006
|
+
)
|
|
1007
|
+
sep2 = d2 > hw + r_h2
|
|
1008
|
+
|
|
1009
|
+
# 轴 3: 矩形的局部 X = (cos_rt, sin_rt)
|
|
1010
|
+
d3 = np.abs(cos_rt * dx_c + sin_rt * dy_c)
|
|
1011
|
+
robot_h3 = np.abs(hl * (cos_rt * cos_t + sin_rt * sin_t)) + np.abs(
|
|
1012
|
+
hw * (-cos_rt * sin_t + sin_rt * cos_t)
|
|
1013
|
+
)
|
|
1014
|
+
sep3 = d3 > robot_h3 + h_rect[:, 0]
|
|
1015
|
+
|
|
1016
|
+
# 轴 4: 矩形的局部 Y = (-sin_rt, cos_rt)
|
|
1017
|
+
d4 = np.abs(-sin_rt * dx_c + cos_rt * dy_c)
|
|
1018
|
+
robot_h4 = np.abs(hl * (-sin_rt * cos_t + cos_rt * sin_t)) + np.abs(
|
|
1019
|
+
hw * (sin_rt * sin_t + cos_rt * cos_t)
|
|
1020
|
+
)
|
|
1021
|
+
sep4 = d4 > robot_h4 + h_rect[:, 1]
|
|
1022
|
+
|
|
1023
|
+
# 四轴全重叠 → 碰撞(无轴分离)
|
|
1024
|
+
if np.any(~(sep1 | sep2 | sep3 | sep4)):
|
|
1025
|
+
return True
|
|
1026
|
+
|
|
1027
|
+
# ── 世界边界:按朝向的轴对齐包围盒(P2-16)──────────────
|
|
1028
|
+
# 旧实现用 hl 判 x、hw 判 y,与 θ 无关:θ=90° 时车身沿 y 的半宽其实
|
|
1029
|
+
# 是 hl=0.22(含 margin)却按 hw=0.13 判 ⇒ 车尾可侵入墙 0.09 m 而不判
|
|
1030
|
+
# 碰撞。改用旋转后的 AABB 半宽/半高(|cosθ|·hl+|sinθ|·hw 等)。
|
|
1031
|
+
# ⚠ 地图生成侧的 _check_path_footprint 是同一份逻辑("照抄"关系,参考
|
|
1032
|
+
# 路径复验用),**两处必须同改**,否则复验会放行 env 判碰撞的位姿。
|
|
1033
|
+
margin = self.boundary_margin
|
|
1034
|
+
ext_x = abs(cos_t) * hl + abs(sin_t) * hw
|
|
1035
|
+
ext_y = abs(sin_t) * hl + abs(cos_t) * hw
|
|
1036
|
+
return bool(
|
|
1037
|
+
self._x - ext_x < self._world_xmin + margin
|
|
1038
|
+
or self._x + ext_x > self._world_xmax - margin
|
|
1039
|
+
or self._y - ext_y < self._world_ymin + margin
|
|
1040
|
+
or self._y + ext_y > self._world_ymax - margin
|
|
1041
|
+
)
|