append spawn objects
This commit is contained in:
@@ -22,6 +22,26 @@ digital_twin:
|
||||
rotation: "0 0 1 0"
|
||||
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:
|
||||
- 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.
|
||||
- Physics is approximate, including inertia, friction, and elasticity.
|
||||
- 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; симуляция не воспроизводит все защиты физического робота
|
||||
- Физика не идеальна — инерция, трение и упругость приближённые
|
||||
- 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
|
||||
from dataclasses import asdict
|
||||
|
||||
from launch_ros.actions import Node
|
||||
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:
|
||||
"""Возвращает ноды камер для симуляции: [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:
|
||||
return []
|
||||
return nodes
|
||||
|
||||
camera_spawner = Node(
|
||||
package="iiwa_utils",
|
||||
@@ -37,4 +49,4 @@ def make_simulation_nodes(settings) -> list:
|
||||
respawn=True,
|
||||
))
|
||||
|
||||
return [camera_spawner, *camera_controllers]
|
||||
return [*nodes, camera_spawner, *camera_controllers]
|
||||
|
||||
@@ -22,6 +22,26 @@ digital_twin:
|
||||
rotation: "0 0 1 0"
|
||||
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:
|
||||
- pkg://iiwa_config/config/cameras/d455_top.yaml
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ endif()
|
||||
find_package(ament_cmake REQUIRED)
|
||||
|
||||
install(
|
||||
DIRECTORY resource worlds urdf
|
||||
DIRECTORY resource worlds urdf objects
|
||||
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 |
@@ -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
|
||||
@@ -1,88 +1,50 @@
|
||||
# /Ros2Supervisor/spawn_node_from_string / webots_ros2_msgs/srv/SpawnNodeFromString
|
||||
# /Ros2Supervisor/spawn_urdf_robot
|
||||
|
||||
|
||||
from random import randint
|
||||
"""Spawn the configured batch once through the Webots supervisor service."""
|
||||
import json
|
||||
|
||||
import rclpy
|
||||
from rclpy.executors import ExternalShutdownException
|
||||
from rclpy.node import Node
|
||||
from rclpy.task import Future
|
||||
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")
|
||||
|
||||
# параметры передаваемые
|
||||
self.declare_parameter('objects_path', '')
|
||||
|
||||
# переменные
|
||||
self._object_count: int = randint(1, 5)
|
||||
self._spawned_count: int = 0
|
||||
self._call_in_progress: bool = False
|
||||
|
||||
# Реализация
|
||||
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
|
||||
|
||||
if self._call_in_progress:
|
||||
return
|
||||
|
||||
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 { } }'
|
||||
data = self._create_group(children=[data])
|
||||
|
||||
req = SpawnNodeFromString.Request(data=data, check_fields=True)
|
||||
self.get_logger().info(
|
||||
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()
|
||||
self.get_logger().info(f"Spawn service response: {response}")
|
||||
except Exception as e:
|
||||
self.get_logger().error(f"Service call field: {e}")
|
||||
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 spawn_objects(node):
|
||||
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
|
||||
config = parse_objects(raw, lambda path: path)
|
||||
objects = build_objects(config, translation, rotation)
|
||||
if not objects:
|
||||
node.get_logger().info('Object spawning disabled or count is zero')
|
||||
return
|
||||
client = node.create_client(SpawnNodeFromString, '/Ros2Supervisor/spawn_node_from_string')
|
||||
if not client.wait_for_service(timeout_sec=60.0):
|
||||
raise RuntimeError('Webots supervisor unavailable; no objects spawned')
|
||||
for index, data in enumerate(objects):
|
||||
future = client.call_async(SpawnNodeFromString.Request(data=data))
|
||||
rclpy.spin_until_future_complete(node, future, timeout_sec=30.0)
|
||||
if not future.done():
|
||||
# A timeout does not prove rejection: retrying could create duplicates.
|
||||
raise RuntimeError(f'Object {index + 1}: spawn timed out; result unknown, no retry')
|
||||
response = future.result()
|
||||
if response is None or not response.success:
|
||||
raise RuntimeError(f'Object {index + 1} rejected; {index}/{len(objects)} spawned')
|
||||
node.get_logger().info(f'All {len(objects)} objects spawned')
|
||||
|
||||
|
||||
def main(args=None):
|
||||
rclpy.init(args=args)
|
||||
node = Node('object_spawner')
|
||||
try:
|
||||
rclpy.init(args=args)
|
||||
node = ObjectSpawner()
|
||||
rclpy.spin(node=node)
|
||||
spawn_objects(node)
|
||||
except (KeyboardInterrupt, ExternalShutdownException):
|
||||
pass
|
||||
finally:
|
||||
node.destroy_node()
|
||||
rclpy.shutdown()
|
||||
if rclpy.ok():
|
||||
rclpy.shutdown()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import math
|
||||
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
|
||||
|
||||
import yaml
|
||||
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")
|
||||
|
||||
|
||||
@@ -26,6 +28,7 @@ class WebotsCfg:
|
||||
rotation: str
|
||||
controller_timer: str
|
||||
cameras: List[str]
|
||||
objects: ObjectsCfg = field(default_factory=ObjectsCfg)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -339,7 +342,14 @@ def build_settings(settings_path: str, check_files: bool = True) -> Settings:
|
||||
rotation=str(require(webots_raw, "rotation")),
|
||||
controller_timer=str(int(require(webots_raw, "controller_timer"))),
|
||||
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(
|
||||
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
|
||||
@@ -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))
|
||||
Reference in New Issue
Block a user