append spawn objects

This commit is contained in:
Даниил Грабарь
2026-09-18 12:37:28 +10:00
parent 9dc1073f42
commit b1b8cedef1
26 changed files with 124049 additions and 77 deletions
+20
View File
@@ -22,6 +22,26 @@ digital_twin:
rotation: "0 0 1 0" rotation: "0 0 1 0"
controller_timer: "50" controller_timer: "50"
objects:
enabled: true
count: 1 # точное общее количество; модели могут повторяться
models:
# - path: pkg://iiwa_description/objects/hammer/hammer1.obj
# rotation_deg: [0, 90, 0] # X, Y, Z в градусах, относительно робота
# - path: pkg://iiwa_description/objects/hammer/hammer2.obj
# rotation_deg: [90, 0, 0]
- path: pkg://iiwa_description/objects/hammer/hammer3.obj
rotation_deg: [90, 0, 0]
# - path: pkg://iiwa_description/objects/hammer/hammer4.obj
# rotation_deg: [1.57, 0, 0]
# - path: pkg://iiwa_description/objects/hammer/hammer5.obj
# rotation_deg: [1.57, 0, 0]
area: # метры в системе основания робота, до падения
x: [0.3, 0.7]
y: [-0.3, 0.3]
z: [0.1, 0.1] # высота нижней точки модели
cameras: cameras:
- pkg://iiwa_config/config/cameras/d455_top.yaml - pkg://iiwa_config/config/cameras/d455_top.yaml
@@ -39,3 +39,96 @@ Set `digital_twin.tcp_gizmo.enabled: false` and restart to disable this mode. Th
- Motion still obeys configured joint and TCP limits; simulation does not reproduce all physical robot protections. - Motion still obeys configured joint and TCP limits; simulation does not reproduce all physical robot protections.
- Physics is approximate, including inertia, friction, and elasticity. - Physics is approximate, including inertia, friction, and elasticity.
- FRI is not used; communication goes through the Webots ROS 2 driver. - FRI is not used; communication goes through the Webots ROS 2 driver.
## Spawning textured objects
Set `digital_twin.webots.objects` in `cobot-setting.yaml`:
```yaml
objects:
enabled: true
count: 5
models:
- path: pkg://iiwa_description/objects/hammer/hammer1.obj
rotation_deg: [0, 0, 0]
- path: pkg://iiwa_description/objects/hammer/hammer2.obj
rotation_deg: [0, 0, 0]
area:
x: [0.3, 0.7]
y: [-0.3, 0.3]
z: [0.1, 0.1]
```
Webots spawns exactly `count` instances once at startup, independently choosing
from `models` with replacement. This list specifies allowed models, not individual
quantities. An absent section, `enabled: false`, or `count: 0` disables spawning.
Paths support `pkg://`, absolute paths and paths relative to the settings file.
Currently only Wavefront OBJ is supported.
Bounds are in metres in the robot base frame. The spawner uses the same
`webots.transform` and `webots.rotation` (axis and angle in radians) as the robot:
`p_world = t_robot + R_robot * p_local`. They constrain initial spawn positions;
physics may move the objects afterwards. Moving the robot with the mouse after
startup does not relocate existing objects.
The spawn anchor is the bounding-box XY centre and bottom Z. Exported vertex
offsets are compensated without modifying the original files, UVs, scale
(1 unit = 1 metre); orientation is configured per model. Bounds constrain the anchor, not the entire
object. Random placements may overlap; choose a sufficiently large area.
`CadShape` loads OBJ materials through `mtllib` and textures through MTL. Preserve
relative asset paths: for example, `hammer1.mtl` references
`hammer1_basecolor.png`. The complete `objects` directory is installed with
`iiwa_description`. Missing models, materials or textures fail before the first
spawn request. Physics uses an approximate bounding Box and a mass of 0.5 kg,
not calibrated hammer inertia or grasp geometry. Objects are not automatically
added to the MoveIt Planning Scene.
The `object_spawner` node calls `/Ros2Supervisor/spawn_node_from_string`
(`webots_ros2_msgs/srv/SpawnNodeFromString`) once per object. A rejected request
aborts remaining work. Timeouts are not retried to avoid duplicates. The node
exits after success and only runs in simulation.
### Simulation smoke test
1. Build with `colcon build --base-paths src --packages-select iiwa_description iiwa_utils iiwa_bringup iiwa_config --symlink-install`.
2. Run `source install/setup.bash`, then `cobot run local`, selecting Webots.
3. Inspect the five nodes `spawned_object_0000` through `spawned_object_0004`, their
textures and the `All 5 objects spawned` log.
4. Change `transform` and `rotation`, restart and confirm the spawn area translates
and rotates with the robot base. Pause before objects fall to inspect initial positions.
5. Check `count: 0`, operation without cameras and rejection of missing models.
Reference: [Webots CadShape](https://cyberbotics.com/doc/reference/cadshape).
### Per-model rotation
Each `models` entry accepts `path` and `rotation_deg: [X, Y, Z]`.
Angles are fixed degrees: apply X, then Y, then Z about the robot base axes,
`R_model = Rz * Ry * Rx` and `R_world = R_robot * R_model`. For example:
```yaml
models:
- path: pkg://iiwa_description/objects/hammer/hammer1.obj
rotation_deg: [0, 0, 0]
- path: pkg://iiwa_description/objects/hammer/hammer2.obj
rotation_deg: [90, 0, 0]
- path: pkg://iiwa_description/objects/hammer/hammer3.obj
rotation_deg: [0, 0, 45]
```
The supplied OBJ hammers have their long axis along Z. `[0, 0, 0]` gives an
initial vertical orientation, `[90, 0, 0]` lays the hammer sideways and
`[0, 0, 45]` turns it around the vertical axis. Both configuration files now
explicitly set `[0, 0, 0]` for each model; adjust each independently.
Legacy path-only entries remain supported and imply zero rotation.
Both the visible model and its collision Box rotate. Height is recalculated:
`area.z` locates the bottom of the rotated collision bounds in the robot frame;
X/Y locate their centre. Model rotation does not rotate the spawn area.
Changes take effect on the next launch.
This sets the initial orientation, not a constraint. Gravity and contact can
still topple the hammer. Pause simulation at spawn to inspect it. Smoke-test
two models with different angles, check the Box stays above `area.z`, and check
that changing `webots.rotation` preserves each model's relative orientation.
@@ -39,3 +39,101 @@
- Действуют настроенные ограничения суставов и TCP; симуляция не воспроизводит все защиты физического робота - Действуют настроенные ограничения суставов и TCP; симуляция не воспроизводит все защиты физического робота
- Физика не идеальна — инерция, трение и упругость приближённые - Физика не идеальна — инерция, трение и упругость приближённые
- FRI-протокол не задействован — связь идёт через Webots-драйвер ROS 2 - FRI-протокол не задействован — связь идёт через Webots-драйвер ROS 2
## Спавн объектов с текстурами
В `cobot-setting.yaml` (секция `digital_twin.webots`) задайте:
```yaml
objects:
enabled: true
count: 5
models:
- path: pkg://iiwa_description/objects/hammer/hammer1.obj
rotation_deg: [0, 0, 0]
- path: pkg://iiwa_description/objects/hammer/hammer2.obj
rotation_deg: [0, 0, 0]
area:
x: [0.3, 0.7]
y: [-0.3, 0.3]
z: [0.1, 0.1]
```
При запуске Webots создаётся ровно `count` объектов: каждая модель независимо
выбирается из `models`, повторения разрешены. `enabled: false`, `count: 0` или
отсутствие секции отключает спавн. Список — разрешённые модели, а не количество
экземпляров каждой модели. Пути могут быть `pkg://`, абсолютными или относительно
файла настроек. Пока поддерживается Wavefront OBJ.
Область задаётся в метрах в системе основания робота: применяется та же пара
`webots.transform` / `webots.rotation` (ось и угол в радианах), что и при спавне
робота. Формула: `p_world = t_robot + R_robot * p_local`. Это начальная область,
а не ограничитель движения: физика затем может перемещать объекты. Перемещение
робота мышью после запуска не переносит уже созданные предметы.
Точка спавна — центр габаритов по X/Y и нижняя точка по Z. Смещение вершин
в экспортированном OBJ компенсируется автоматически; исходные файлы, UV,
масштаб (1 единица = 1 метр) сохраняется; ориентация задаётся отдельно для каждой модели. Диапазон ограничивает
эту точку, а не весь габарит. Пересечения объектов при случайном размещении
не исключаются: выбирайте достаточно просторную область.
`CadShape` загружает материалы из `mtllib` и текстуры из MTL. Сохраняйте OBJ,
MTL и PNG в исходной структуре каталогов: например, `hammer1.mtl` ссылается на
`hammer1_basecolor.png`. Весь каталог `objects` устанавливается вместе с
`iiwa_description`. Отсутствующие модели, MTL и текстуры вызывают ошибку до
первого запроса спавна. Для физики используется упрощённый габаритный Box и
масса 0.5 кг; это не точная модель инерции или захвата молотка. В MoveIt Planning
Scene эти объекты автоматически не добавляются.
Нода `object_spawner` вызывает
`/Ros2Supervisor/spawn_node_from_string` (`webots_ros2_msgs/srv/SpawnNodeFromString`)
однократно для каждого предмета. Отказ сервиса прерывает оставшийся спавн;
тайм-аут не повторяется, чтобы не создавать дубликаты. Нода завершается после
успеха. На физическом роботе она не запускается.
### Проверка в симуляции
1. Пересоберите пакеты:
`colcon build --base-paths src --packages-select iiwa_description iiwa_utils iiwa_bringup iiwa_config --symlink-install`.
2. Выполните `source install/setup.bash`, затем `cobot run local` и выберите Webots.
3. Проверьте пять узлов `spawned_object_0000` … `spawned_object_0004` в дереве сцены,
текстуры молотков и сообщение `All 5 objects spawned`.
4. Измените `transform` и `rotation`, перезапустите: область должна переместиться
и повернуться вместе с основанием робота. Для проверки начальных координат
используйте паузу симулятора до падения объектов.
5. Проверьте `count: 0`, запуск без камер и ошибку при несуществующей модели.
Справочник: [Webots CadShape](https://cyberbotics.com/doc/reference/cadshape).
### Индивидуальный поворот модели
Каждая запись `models` может содержать `path` и `rotation_deg: [X, Y, Z]`.
Углы фиксированные, в градусах; сначала применяется поворот вокруг X, затем Y,
затем Z в системе основания робота: `R_model = Rz * Ry * Rx`,
`R_world = R_robot * R_model`. Например:
```yaml
models:
- path: pkg://iiwa_description/objects/hammer/hammer1.obj
rotation_deg: [0, 0, 0]
- path: pkg://iiwa_description/objects/hammer/hammer2.obj
rotation_deg: [90, 0, 0]
- path: pkg://iiwa_description/objects/hammer/hammer3.obj
rotation_deg: [0, 0, 45]
```
В предоставленных OBJ длинная ось молотка направлена по Z: `[0, 0, 0]`
задаёт вертикальную начальную ориентацию, `[90, 0, 0]` кладёт молоток набок,
а `[0, 0, 45]` поворачивает его вокруг вертикальной оси. В обоих конфигурационных
файлах для всех моделей явно указано `[0, 0, 0]`; изменяйте каждую независимо.
Записи только с путём остаются совместимыми и означают нулевой поворот.
Поворачиваются и видимая модель, и её коллизионный Box. После поворота
пересчитывается высота: `area.z` задаёт нижнюю точку повёрнутого коллизионного
габарита в системе робота, а X/Y — его центр. Поворот модели не поворачивает
саму область спавна. Изменения применяются при следующем запуске.
Это начальная ориентация, а не фиксация: после появления гравитация и контакты
могут опрокинуть молоток. Для проверки поставьте симуляцию на паузу при спавне.
Проверьте разные углы у двух моделей, отсутствие погружения Box ниже `area.z`
и сохранение локальных углов при изменении `webots.rotation`.
@@ -1,4 +1,5 @@
import json import json
from dataclasses import asdict
from launch_ros.actions import Node from launch_ros.actions import Node
from webots_ros2_driver.webots_controller import WebotsController from webots_ros2_driver.webots_controller import WebotsController
@@ -7,12 +8,23 @@ from iiwa_utils.camera_spawner import load_camera_config, build_ros_urdf # type
def make_simulation_nodes(settings) -> list: def make_simulation_nodes(settings) -> list:
"""Возвращает ноды камер для симуляции: [camera_spawner, *camera_controllers]. """Create the configured object spawner and camera nodes for Webots."""
nodes = []
Если камеры не заданы в настройках — возвращает пустой список. webots = settings.digital_twin.webots
""" if webots.objects.enabled and webots.objects.count:
nodes.append(Node(
package="iiwa_utils",
executable="object_spawner",
name="object_spawner",
output="screen",
parameters=[{
"objects_config": json.dumps(asdict(webots.objects)),
"robot_translation": webots.transform,
"robot_rotation": webots.rotation,
}],
))
if not settings.digital_twin.webots.cameras: if not settings.digital_twin.webots.cameras:
return [] return nodes
camera_spawner = Node( camera_spawner = Node(
package="iiwa_utils", package="iiwa_utils",
@@ -37,4 +49,4 @@ def make_simulation_nodes(settings) -> list:
respawn=True, respawn=True,
)) ))
return [camera_spawner, *camera_controllers] return [*nodes, camera_spawner, *camera_controllers]
+20
View File
@@ -22,6 +22,26 @@ digital_twin:
rotation: "0 0 1 0" rotation: "0 0 1 0"
controller_timer: "50" controller_timer: "50"
objects:
enabled: true
count: 5 # точное общее количество; модели могут повторяться
models:
- path: pkg://iiwa_description/objects/hammer/hammer1.obj
rotation_deg: [0, 0, 0] # X, Y, Z в градусах, относительно робота
- path: pkg://iiwa_description/objects/hammer/hammer2.obj
rotation_deg: [0, 0, 0] # X, Y, Z в градусах, относительно робота
- path: pkg://iiwa_description/objects/hammer/hammer3.obj
rotation_deg: [0, 0, 0] # X, Y, Z в градусах, относительно робота
- path: pkg://iiwa_description/objects/hammer/hammer4.obj
rotation_deg: [0, 0, 0] # X, Y, Z в градусах, относительно робота
- path: pkg://iiwa_description/objects/hammer/hammer5.obj
rotation_deg: [0, 0, 0] # X, Y, Z в градусах, относительно робота
area: # метры в системе основания робота, до падения
x: [0.3, 0.7]
y: [-0.3, 0.3]
z: [0.1, 0.1] # высота нижней точки модели
cameras: cameras:
- pkg://iiwa_config/config/cameras/d455_top.yaml - pkg://iiwa_config/config/cameras/d455_top.yaml
+1 -1
View File
@@ -9,7 +9,7 @@ endif()
find_package(ament_cmake REQUIRED) find_package(ament_cmake REQUIRED)
install( install(
DIRECTORY resource worlds urdf DIRECTORY resource worlds urdf objects
DESTINATION share/${PROJECT_NAME} DESTINATION share/${PROJECT_NAME}
) )
@@ -0,0 +1,42 @@
# Blender 5.2.1 LTS MTL File: 'five_hammers.blend'
# www.blender.org
newmtl 01_|_circular_ground_face
Ns 250.000000
Ka 1.000000 1.000000 1.000000
Ks 0.500000 0.500000 0.500000
Ke 0.000000 0.000000 0.000000
Ni 1.500000
d 1.000000
illum 2
map_Kd hammer1_basecolor.png
newmtl 01_|_exposed_cut_wood
Ns 250.000000
Ka 1.000000 1.000000 1.000000
Ks 0.500000 0.500000 0.500000
Ke 0.000000 0.000000 0.000000
Ni 1.500000
d 1.000000
illum 2
map_Kd hammer1_basecolor.png
newmtl 01_|_honey_hickory_longitudinal_grain
Ns 250.000000
Ka 1.000000 1.000000 1.000000
Ks 0.500000 0.500000 0.500000
Ke 0.000000 0.000000 0.000000
Ni 1.500000
d 1.000000
illum 2
map_Kd hammer1_basecolor.png
newmtl 01_|_satin_forged_steel
Ns 250.000000
Ka 1.000000 1.000000 1.000000
Ks 0.500000 0.500000 0.500000
Ke 0.000000 0.000000 0.000000
Ni 1.500000
d 1.000000
illum 2
map_Kd hammer1_basecolor.png
File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 MiB

@@ -0,0 +1,42 @@
# Blender 5.2.1 LTS MTL File: 'five_hammers.blend'
# www.blender.org
newmtl 02_|_black_oxide_forged_steel
Ns 250.000000
Ka 1.000000 1.000000 1.000000
Ks 0.500000 0.500000 0.500000
Ke 0.000000 0.000000 0.000000
Ni 1.500000
d 1.000000
illum 2
map_Kd hammer2_basecolor.png
newmtl 02_|_blue_glass_fibre_resin
Ns 250.000000
Ka 1.000000 1.000000 1.000000
Ks 0.500000 0.500000 0.500000
Ke 0.000000 0.000000 0.000000
Ni 1.500000
d 1.000000
illum 2
map_Kd hammer2_basecolor.png
newmtl 02_|_graphite_moulded_rubber
Ns 250.000000
Ka 1.000000 1.000000 1.000000
Ks 0.500000 0.500000 0.500000
Ke 0.000000 0.000000 0.000000
Ni 1.500000
d 1.000000
illum 2
map_Kd hammer2_basecolor.png
newmtl 02_|_machined_steel_face
Ns 250.000000
Ka 1.000000 1.000000 1.000000
Ks 0.500000 0.500000 0.500000
Ke 0.000000 0.000000 0.000000
Ni 1.500000
d 1.000000
illum 2
map_Kd hammer2_basecolor.png
File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 MiB

@@ -0,0 +1,42 @@
# Blender 5.2.1 LTS MTL File: 'five_hammers.blend'
# www.blender.org
newmtl 03_|_ash_end_grain
Ns 250.000000
Ka 1.000000 1.000000 1.000000
Ks 0.500000 0.500000 0.500000
Ke 0.000000 0.000000 0.000000
Ni 1.500000
d 1.000000
illum 2
map_Kd hammer3_basecolor.png
newmtl 03_|_blue_tempered_steel
Ns 250.000000
Ka 1.000000 1.000000 1.000000
Ks 0.500000 0.500000 0.500000
Ke 0.000000 0.000000 0.000000
Ni 1.500000
d 1.000000
illum 2
map_Kd hammer3_basecolor.png
newmtl 03_|_bright_milled_steel
Ns 250.000000
Ka 1.000000 1.000000 1.000000
Ks 0.500000 0.500000 0.500000
Ke 0.000000 0.000000 0.000000
Ni 1.500000
d 1.000000
illum 2
map_Kd hammer3_basecolor.png
newmtl 03_|_pale_ash_straight_grain
Ns 250.000000
Ka 1.000000 1.000000 1.000000
Ks 0.500000 0.500000 0.500000
Ke 0.000000 0.000000 0.000000
Ni 1.500000
d 1.000000
illum 2
map_Kd hammer3_basecolor.png
File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 MiB

@@ -0,0 +1,42 @@
# Blender 5.2.1 LTS MTL File: 'five_hammers.blend'
# www.blender.org
newmtl 04_|_dark_walnut_grain
Ns 250.000000
Ka 1.000000 1.000000 1.000000
Ks 0.500000 0.500000 0.500000
Ke 0.000000 0.000000 0.000000
Ni 1.500000
d 1.000000
illum 2
map_Kd hammer4_basecolor.png
newmtl 04_|_polished_ball_and_face
Ns 250.000000
Ka 1.000000 1.000000 1.000000
Ks 0.500000 0.500000 0.500000
Ke 0.000000 0.000000 0.000000
Ni 1.500000
d 1.000000
illum 2
map_Kd hammer4_basecolor.png
newmtl 04_|_walnut_end_grain
Ns 250.000000
Ka 1.000000 1.000000 1.000000
Ks 0.500000 0.500000 0.500000
Ke 0.000000 0.000000 0.000000
Ni 1.500000
d 1.000000
illum 2
map_Kd hammer4_basecolor.png
newmtl 04_|_warm_forged_steel
Ns 250.000000
Ka 1.000000 1.000000 1.000000
Ks 0.500000 0.500000 0.500000
Ke 0.000000 0.000000 0.000000
Ni 1.500000
d 1.000000
illum 2
map_Kd hammer4_basecolor.png
File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 MiB

@@ -0,0 +1,42 @@
# Blender 5.2.1 LTS MTL File: 'five_hammers.blend'
# www.blender.org
newmtl 05_|_charcoal_anti_vibration_rubber
Ns 250.000000
Ka 1.000000 1.000000 1.000000
Ks 0.500000 0.500000 0.500000
Ke 0.000000 0.000000 0.000000
Ni 1.500000
d 1.000000
illum 2
map_Kd hammer5_basecolor.png
newmtl 05_|_dark_textured_forged_coating
Ns 250.000000
Ka 1.000000 1.000000 1.000000
Ks 0.500000 0.500000 0.500000
Ke 0.000000 0.000000 0.000000
Ni 1.500000
d 1.000000
illum 2
map_Kd hammer5_basecolor.png
newmtl 05_|_machined_impact_faces
Ns 250.000000
Ka 1.000000 1.000000 1.000000
Ks 0.500000 0.500000 0.500000
Ke 0.000000 0.000000 0.000000
Ni 1.500000
d 1.000000
illum 2
map_Kd hammer5_basecolor.png
newmtl 05_|_orange_glass_fibre_resin
Ns 250.000000
Ka 1.000000 1.000000 1.000000
Ks 0.500000 0.500000 0.500000
Ke 0.000000 0.000000 0.000000
Ni 1.500000
d 1.000000
illum 2
map_Kd hammer5_basecolor.png
File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 MiB

+196
View File
@@ -0,0 +1,196 @@
"""Validated object configuration and Webots scene generation (metres/radians)."""
import json
import math
import random
import shlex
from dataclasses import dataclass, field
from pathlib import Path
from scipy.spatial.transform import Rotation
@dataclass(frozen=True)
class ObjectModelCfg:
path: str
rotation_deg: tuple[float, float, float] = (0.0, 0.0, 0.0)
@dataclass(frozen=True)
class ObjectsCfg:
enabled: bool = False
count: int = 0
models: list[ObjectModelCfg] = field(default_factory=list)
area: dict[str, list[float]] = field(default_factory=dict)
def parse_objects(raw, resolve_path) -> ObjectsCfg:
if raw is None:
return ObjectsCfg()
if not isinstance(raw, dict):
raise ValueError('digital_twin.webots.objects must be a mapping')
enabled = raw.get('enabled', False)
count = raw.get('count', 0)
models = raw.get('models', [])
area = raw.get('area', {})
if type(enabled) is not bool:
raise ValueError('objects.enabled must be true or false')
if type(count) is not int or count < 0:
raise ValueError('objects.count must be an integer >= 0')
if not isinstance(models, list):
raise ValueError('objects.models must be a list of paths or model mappings')
if enabled and count and not models:
raise ValueError('objects.models must not be empty when count > 0')
if not isinstance(area, dict):
raise ValueError('objects.area must be a mapping')
parsed_area = {}
if area or (enabled and count):
for axis in 'xyz':
bounds = area.get(axis)
if (not isinstance(bounds, (list, tuple)) or len(bounds) != 2
or any(type(v) not in (int, float) or not math.isfinite(v) for v in bounds)
or bounds[0] > bounds[1]):
raise ValueError(f'objects.area.{axis} must be [min, max], finite metres, min <= max')
parsed_area[axis] = list(map(float, bounds))
resolved = []
for index, entry in enumerate(models):
if isinstance(entry, str):
entry = {'path': entry}
if not isinstance(entry, dict) or entry.keys() - {'path', 'rotation_deg'}:
raise ValueError(f'objects.models[{index}] requires path and optional rotation_deg')
path = entry.get('path')
angles = entry.get('rotation_deg', (0, 0, 0))
if not isinstance(path, str) or not path.strip():
raise ValueError(f'objects.models[{index}].path must be a nonempty path')
if (not isinstance(angles, (list, tuple)) or len(angles) != 3
or any(type(v) not in (int, float) or not math.isfinite(v) for v in angles)):
raise ValueError(f'objects.models[{index}].rotation_deg must be finite [X, Y, Z] degrees')
resolved.append(ObjectModelCfg(resolve_path(path), tuple(map(float, angles))))
if any(Path(model.path).suffix.lower() != '.obj' for model in resolved):
raise ValueError('objects.models currently supports Wavefront .obj files')
return ObjectsCfg(enabled, count, resolved, parsed_area)
def robot_pose(translation, rotation):
try:
position = tuple(map(float, translation.split()))
orient = tuple(map(float, rotation.split()))
except (ValueError, AttributeError) as exc:
raise ValueError('Robot transform/rotation must be numeric strings') from exc
if len(position) != 3 or len(orient) != 4 or not all(map(math.isfinite, position + orient)):
raise ValueError('Robot transform needs 3 and rotation 4 finite numbers')
length = math.sqrt(sum(v*v for v in orient[:3]))
if length == 0:
raise ValueError('Robot rotation axis must not be zero')
return position, tuple(v / length for v in orient[:3]) + (orient[3],)
def world_position(local, translation, rotation):
position, orient = robot_pose(translation, rotation)
axis, angle = orient[:3], orient[3]
c, s = math.cos(angle), math.sin(angle)
dot = sum(a*b for a, b in zip(axis, local))
cross = (axis[1]*local[2] - axis[2]*local[1],
axis[2]*local[0] - axis[0]*local[2],
axis[0]*local[1] - axis[1]*local[0])
return tuple(position[i] + local[i]*c + cross[i]*s + axis[i]*dot*(1-c) for i in range(3))
@dataclass(frozen=True)
class ObjAsset:
path: Path
offset: tuple
size: tuple
textures: tuple
def _require_file(path):
if not path.is_file():
raise ValueError(f'Object asset file does not exist: {path}')
def load_obj(path) -> ObjAsset:
"""Keep original OBJ/MTL/UV data; derive a centred, bottom-aligned origin."""
path = Path(path).resolve()
_require_file(path)
low, high = [math.inf]*3, [-math.inf]*3
materials, textures = [], []
for line in path.read_text(encoding='utf-8').splitlines():
parts = line.split()
if not parts:
continue
if parts[0] == 'v':
vertex = tuple(map(float, parts[1:4]))
if len(vertex) != 3 or not all(map(math.isfinite, vertex)):
raise ValueError(f'Invalid vertex in {path}')
low = [min(a, b) for a, b in zip(low, vertex)]
high = [max(a, b) for a, b in zip(high, vertex)]
elif parts[0] == 'mtllib':
materials.extend(path.parent / p for p in shlex.split(line)[1:])
size = tuple(b-a for a, b in zip(low, high))
if any(not math.isfinite(v) or v <= 0 for v in size):
raise ValueError(f'OBJ must have finite, nonzero 3D bounds: {path}')
for material in materials:
_require_file(material)
for line in material.read_text(encoding='utf-8').splitlines():
parts = shlex.split(line, comments=True)
if parts and (parts[0].lower().startswith('map_') or parts[0].lower() in ('bump', 'disp', 'decal', 'norm')):
# Wavefront map options precede the final texture filename.
texture = material.parent / parts[-1]
_require_file(texture)
textures.append(texture)
offset = (-(low[0]+high[0])/2, -(low[1]+high[1])/2, -low[2])
return ObjAsset(path, offset, size, tuple(textures))
def _vector(values):
return ' '.join(format(v, '.12g') for v in values)
def model_rotation(rotation_deg):
"""Fixed-axis XYZ degrees: Rz(z) @ Ry(y) @ Rx(x), relative to robot base."""
rotvec = Rotation.from_euler('xyz', rotation_deg, degrees=True).as_rotvec()
angle = math.sqrt(sum(v*v for v in rotvec))
if angle < 1e-12:
return (0.0, 0.0, 1.0, 0.0)
return tuple(float(v / angle) for v in rotvec) + (angle,)
def build_objects(config, translation, rotation, rng=None):
if not config.enabled or config.count == 0:
return []
_, orient = robot_pose(translation, rotation)
# Validate every allowed asset before any service request is sent.
assets = [(load_obj(model.path), model.rotation_deg) for model in config.models]
rng = rng or random.Random()
nodes = []
for index in range(config.count):
asset, angles = rng.choice(assets)
model_orient = model_rotation(angles)
matrix = Rotation.from_euler('xyz', angles, degrees=True).as_matrix()
# Lift the rotated collision box above the spawn anchor. Keep the
# original oriented box, rather than inflating it into a world AABB.
half_height = sum(abs(matrix[2, i]) * asset.size[i] / 2 for i in range(3))
center = (-asset.offset[0], -asset.offset[1],
-asset.offset[2] + asset.size[2] / 2)
rotated_center = matrix @ center
visual_offset = tuple(-rotated_center[i] + (half_height if i == 2 else 0)
for i in range(3))
local = tuple(rng.uniform(*config.area[axis]) for axis in 'xyz')
world = world_position(local, translation, rotation)
nodes.append(f'''Solid {{
name "spawned_object_{index:04d}"
translation {_vector(world)}
rotation {_vector(orient)}
children [ Pose {{
translation {_vector(visual_offset)}
rotation {_vector(model_orient)}
children [ CadShape {{ url [ {json.dumps(str(asset.path))} ] }} ]
}} ]
boundingObject Pose {{
translation 0 0 {half_height:.12g}
rotation {_vector(model_orient)}
children [ Box {{ size {_vector(asset.size)} }} ]
}}
physics Physics {{ density -1 mass 0.5 }}
}}''')
return nodes
+28 -66
View File
@@ -1,88 +1,50 @@
# /Ros2Supervisor/spawn_node_from_string / webots_ros2_msgs/srv/SpawnNodeFromString """Spawn the configured batch once through the Webots supervisor service."""
# /Ros2Supervisor/spawn_urdf_robot import json
from random import randint
import rclpy import rclpy
from rclpy.executors import ExternalShutdownException from rclpy.executors import ExternalShutdownException
from rclpy.node import Node from rclpy.node import Node
from rclpy.task import Future
from webots_ros2_msgs.srv import SpawnNodeFromString from webots_ros2_msgs.srv import SpawnNodeFromString
from iiwa_utils.object_scene import build_objects, parse_objects
class ObjectSpawner(Node):
def __init__(self):
super().__init__("object_spawner")
# параметры передаваемые def spawn_objects(node):
self.declare_parameter('objects_path', '') raw = json.loads(node.declare_parameter('objects_config', '{}').value)
translation = node.declare_parameter('robot_translation', '0 0 0').value
# переменные rotation = node.declare_parameter('robot_rotation', '0 0 1 0').value
self._object_count: int = randint(1, 5) config = parse_objects(raw, lambda path: path)
self._spawned_count: int = 0 objects = build_objects(config, translation, rotation)
self._call_in_progress: bool = False if not objects:
node.get_logger().info('Object spawning disabled or count is zero')
# Реализация
self.cli = self.create_client(
SpawnNodeFromString, "/Ros2Supervisor/spawn_node_from_string"
)
while not self.cli.wait_for_service(timeout_sec=10):
self.get_logger().warning(
"service /Ros2Supervisor/spawn_node_from_string not available, waiting again..."
)
self._timer = self.create_timer(0.1, self.timer_callback)
# TODO: переделать процесс спавна, должен сформировать все proto файлы, после чего заспавнить все объекты
def timer_callback(self):
# TODO: здесь должен получать объекты
if self._spawned_count >= self._object_count:
self.get_logger().info(f"All {self._object_count} objects spawned")
self._timer.cancel()
return return
client = node.create_client(SpawnNodeFromString, '/Ros2Supervisor/spawn_node_from_string')
if self._call_in_progress: if not client.wait_for_service(timeout_sec=60.0):
return raise RuntimeError('Webots supervisor unavailable; no objects spawned')
for index, data in enumerate(objects):
data = 'Solid { name "test_box2" translation 0 1 0.5 children [ Shape { appearance PBRAppearance { baseColor 0.901961 0.380392 0 } geometry Box { size 0.1 0.1 0.1 } } ] boundingObject Box { size 0.1 0.1 0.1 } physics Physics { } }' future = client.call_async(SpawnNodeFromString.Request(data=data))
data = self._create_group(children=[data]) rclpy.spin_until_future_complete(node, future, timeout_sec=30.0)
if not future.done():
req = SpawnNodeFromString.Request(data=data, check_fields=True) # A timeout does not prove rejection: retrying could create duplicates.
self.get_logger().info( raise RuntimeError(f'Object {index + 1}: spawn timed out; result unknown, no retry')
f"Calling spawn service #{self._spawned_count + 1}/{self._object_count}"
)
self._call_in_progress = True
future = self.cli.call_async(req)
future.add_done_callback(self._response_callback)
def _response_callback(self, future: Future):
try:
response = future.result() response = future.result()
self.get_logger().info(f"Spawn service response: {response}") if response is None or not response.success:
except Exception as e: raise RuntimeError(f'Object {index + 1} rejected; {index}/{len(objects)} spawned')
self.get_logger().error(f"Service call field: {e}") node.get_logger().info(f'All {len(objects)} objects spawned')
finally:
self._call_in_progress = False
self._spawned_count += 1
# Вспомогательные методы формирования кода для спавна объектов
def _create_group(self, children: list[str]) -> str:
return f"DEF OBJECTS Group {{children [{" ".join(children)}]}}"
def main(args=None): def main(args=None):
try:
rclpy.init(args=args) rclpy.init(args=args)
node = ObjectSpawner() node = Node('object_spawner')
rclpy.spin(node=node) try:
spawn_objects(node)
except (KeyboardInterrupt, ExternalShutdownException): except (KeyboardInterrupt, ExternalShutdownException):
pass pass
finally: finally:
node.destroy_node() node.destroy_node()
if rclpy.ok():
rclpy.shutdown() rclpy.shutdown()
if __name__ == "__main__": if __name__ == '__main__':
main() main()
+11 -1
View File
@@ -1,11 +1,13 @@
import math import math
import os import os
from dataclasses import dataclass, fields, is_dataclass from dataclasses import dataclass, field, fields, is_dataclass
from typing import Any, Dict, List, Optional, Type, TypeVar from typing import Any, Dict, List, Optional, Type, TypeVar
import yaml import yaml
from ament_index_python.packages import get_package_share_directory from ament_index_python.packages import get_package_share_directory
from iiwa_utils.object_scene import ObjectsCfg, load_obj, parse_objects, robot_pose
T = TypeVar("T") T = TypeVar("T")
@@ -26,6 +28,7 @@ class WebotsCfg:
rotation: str rotation: str
controller_timer: str controller_timer: str
cameras: List[str] cameras: List[str]
objects: ObjectsCfg = field(default_factory=ObjectsCfg)
@dataclass(frozen=True) @dataclass(frozen=True)
@@ -339,7 +342,14 @@ def build_settings(settings_path: str, check_files: bool = True) -> Settings:
rotation=str(require(webots_raw, "rotation")), rotation=str(require(webots_raw, "rotation")),
controller_timer=str(int(require(webots_raw, "controller_timer"))), controller_timer=str(int(require(webots_raw, "controller_timer"))),
cameras=cameras, cameras=cameras,
objects=parse_objects(webots_raw.get("objects"),
lambda p: resolve_path(p, settings_dir)),
) )
if webots.objects.enabled and webots.objects.count:
robot_pose(webots.transform, webots.rotation)
if check_files:
for model in webots.objects.models:
load_obj(model.path)
rviz = RvizCfg( rviz = RvizCfg(
config=resolve_path(str(require(rviz_raw, "config")), settings_dir) config=resolve_path(str(require(rviz_raw, "config")), settings_dir)
) )
@@ -0,0 +1,30 @@
"""Exercise our service requests with the installed Jazzy message schema."""
from types import SimpleNamespace
from unittest.mock import Mock
import pytest
from iiwa_utils import object_spawner
@pytest.mark.parametrize('success,completed', [(True, True), (False, True), (True, False)])
def test_service_schema_and_failure_stop(monkeypatch, success, completed):
node = Mock()
node.declare_parameter.side_effect = [SimpleNamespace(value='{}'),
SimpleNamespace(value='0 0 0'),
SimpleNamespace(value='0 0 1 0')]
monkeypatch.setattr(object_spawner, 'build_objects', lambda *args: ['Solid {}', 'Solid {}'])
monkeypatch.setattr(object_spawner.rclpy, 'spin_until_future_complete', lambda *a, **kw: None)
client = node.create_client.return_value
client.wait_for_service.return_value = True
future = client.call_async.return_value
future.done.return_value = completed
future.result.return_value = SimpleNamespace(success=success)
if success and completed:
object_spawner.spawn_objects(node)
assert client.call_async.call_count == 2
assert client.call_async.call_args.args[0].data == 'Solid {}'
else:
with pytest.raises(RuntimeError):
object_spawner.spawn_objects(node)
assert client.call_async.call_count == 1
+121
View File
@@ -0,0 +1,121 @@
"""Configuration, model placement and textured asset regressions."""
import math
import random
from pathlib import Path
import pytest
from iiwa_utils import object_scene as scene
@pytest.fixture
def model(tmp_path):
(tmp_path / 'texture.png').write_bytes(b'texture')
(tmp_path / 'hammer.mtl').write_text('newmtl wood\nmap_Kd texture.png\n')
path = tmp_path / 'hammer.obj'
path.write_text('mtllib hammer.mtl\nv 10 20 30\nv 12 24 36\nf 1 2 1\n')
return path
def config(model, **updates):
raw = dict(enabled=True, count=5, models=[str(model)],
area=dict(x=[1, 1], y=[2, 2], z=[3, 3]))
raw.update(updates)
return scene.parse_objects(raw, lambda p: p)
def test_default_disabled():
assert not scene.parse_objects(None, lambda p: p).enabled
@pytest.mark.parametrize('updates', [
{'count': -1}, {'count': True}, {'count': 1.5}, {'enabled': 'true'},
{'models': []}, {'models': 'hammer.obj'},
{'area': {'x': [2, 1], 'y': [0, 1], 'z': [0, 1]}},
{'area': {'x': [0, math.nan], 'y': [0, 1], 'z': [0, 1]}},
{'area': {'x': [False, 1], 'y': [0, 1], 'z': [0, 1]}},
])
def test_reject_bad_configuration(model, updates):
with pytest.raises(ValueError):
config(model, **updates)
def test_rotation_about_translated_robot():
assert scene.world_position((1, 2, 3), '10 20 30', f'0 0 2 {math.pi/2}') == pytest.approx((8, 21, 33))
assert scene.world_position((0, 1, 0), '0 0 0', f'1 0 0 {math.pi/2}') == pytest.approx((0, 0, 1))
@pytest.mark.parametrize('translation,rotation', [('0 0', '0 0 1 0'), ('0 nan 0', '0 0 1 0'), ('0 0 0', '0 0 0 1')])
def test_bad_robot_pose(translation, rotation):
with pytest.raises(ValueError):
scene.world_position((0, 0, 0), translation, rotation)
def test_exact_count_repeats_and_centered_geometry(model):
nodes = scene.build_objects(config(model), '10 20 30', '0 0 1 0', random.Random(1))
assert len(nodes) == 5
assert len(set(nodes)) == 5
for node in nodes:
assert 'translation 11 22 33' in node
assert 'CadShape' in node and str(model) in node
assert 'translation -11 -22 -30' in node # XY centered, bottom at local z=0
assert 'size 2 4 6' in node
assert scene.build_objects(config(model, count=0), '0 0 0', '0 0 1 0') == []
def test_missing_texture_fails_before_spawning(model):
(model.parent / 'texture.png').unlink()
with pytest.raises(ValueError, match='texture'):
scene.build_objects(config(model), '0 0 0', '0 0 1 0')
def test_repository_models_have_materials_and_textures():
root = Path(__file__).resolve().parents[2] / 'iiwa_description/objects/hammer'
models = sorted(root.glob('*.obj'))
assert len(models) == 5
for path in models:
asset = scene.load_obj(path)
assert all(size > 0 for size in asset.size)
assert asset.textures
def test_per_model_rotation_and_serialization(model):
from dataclasses import asdict
cfg = config(model, models=[str(model), {'path': str(model), 'rotation_deg': [90, 0, 180]}])
assert cfg.models[0].rotation_deg == (0.0, 0.0, 0.0)
assert cfg.models[1].rotation_deg == (90.0, 0.0, 180.0)
assert scene.parse_objects(asdict(cfg), lambda p: p) == cfg
@pytest.mark.parametrize('entry', [
{}, {'path': 12}, {'path': 'a.obj', 'rotation_deg': [0, 0]},
{'path': 'a.obj', 'rotation_deg': [True, 0, 0]},
{'path': 'a.obj', 'rotation_deg': [0, float('inf'), 0]},
{'path': 'a.obj', 'rotation_deg': '90 0 0'},
{'path': 'a.obj', 'rotaton_deg': [90, 0, 0]},
])
def test_invalid_model_orientation(model, entry):
with pytest.raises(ValueError):
config(model, models=[entry])
def test_quarter_turn_repositions_geometry_and_collision(model):
cfg = config(model, count=1, models=[{'path': str(model), 'rotation_deg': [90, 0, 0]}])
node = scene.build_objects(cfg, '10 20 30', f'0 0 1 {math.pi/2}')[0]
# Anchor (1,2,3) rotates about the ROBOT, not about the model's X axis.
assert 'translation 8 21 33' in node
# Raw centre (11,22,33) -> (11,-33,22); rotated half-height = 2.
assert 'translation -11 33 -20' in node
assert 'translation 0 0 2' in node
# Rendering AND the original 2x4x6 collision box receive the model rotation.
assert node.count('rotation 1 0 0 1.57079632679') == 2
assert 'size 2 4 6' in node
def test_rotation_order_xyz():
# Apply Rx(90), then Ry(0), then Rz(90): x -> y, y -> z, z -> x.
orient = scene.model_rotation([90, 0, 90])
encoded = ' '.join(map(str, orient))
assert scene.world_position((1, 0, 0), '0 0 0', encoded) == pytest.approx((0, 1, 0))
assert scene.world_position((0, 1, 0), '0 0 0', encoded) == pytest.approx((0, 0, 1))
assert scene.world_position((0, 0, 1), '0 0 0', encoded) == pytest.approx((1, 0, 0))