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
@@ -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]
+20
View File
@@ -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
+1 -1
View File
@@ -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

+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
+31 -69
View File
@@ -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()
+11 -1
View File
@@ -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
+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))