feat: generate grant Webots objects

This commit is contained in:
Даниил Грабарь
2026-11-08 10:47:53 +10:00
parent dd11119176
commit e29ae921c4
2 changed files with 623 additions and 0 deletions
@@ -0,0 +1,350 @@
"""Turn a validated ScenarioSpec into deterministic Webots ``Solid`` nodes.
The generated nodes are sent one at a time to
``/Ros2Supervisor/spawn_node_from_string``. Their order is:
fixture -> body -> rack -> working tools -> tray -> screws -> cap
All positions are the initial scene only. The fixture, body, rack and tray are
fixed. A loose hatch has physics; an installed hatch is locked. Screws and
tools are static in their tray/socket: this package does not implement grasp,
tool docking or dynamic hatch fixation, so a later skill executor must release
them before a robot can pick them up.
"""
from __future__ import annotations
import json
import math
import re
from pathlib import Path
from .scenario import ScenarioSpec
ASSETS = {
'fixture': 'stapel.obj',
'body': 'base-detail.obj',
'rack': 'tool-magizine.obj',
'tray': 'lotok.obj',
'screw': 'vint.obj',
'cap': 'cap.obj',
'vacuum': 'vacum-tool.obj',
'magnetic_tool': 'magnit-tool.obj',
'sealant_tool': 'germetic-tool.obj',
'torque_tool': 'wranch-tool.obj',
}
TOOL_NODE_NAMES = {
'vacuum': 'grant_vacuum',
'magnetic_tool': 'grant_magnetic_tool',
'sealant_tool': 'grant_sealant_tool',
'torque_tool': 'grant_torque_tool',
}
TOOL_SIZE = {
'vacuum': (0.0821, 0.14, 0.081),
'magnetic_tool': (0.036, 0.14, 0.036),
'sealant_tool': (0.05608, 0.14, 0.05608),
'torque_tool': (0.049, 0.14, 0.049),
}
TOOL_APPEARANCE = {
'vacuum': '0.16 0.47 0.72',
'magnetic_tool': '0.43 0.30 0.65',
'sealant_tool': '0.23 0.57 0.31',
'torque_tool': '0.80 0.32 0.18',
}
def rotation_matrix(yaw_deg: float) -> tuple[tuple[float, float, float], ...]:
"""Rz(yaw) @ Rx(90): the OBJ Y axis becomes Webots Z."""
angle = math.radians(yaw_deg)
cosine, sine = math.cos(angle), math.sin(angle)
return (
(cosine, 0.0, sine),
(sine, 0.0, -cosine),
(0.0, 1.0, 0.0),
)
def rotation_axis_angle(yaw_deg: float) -> tuple[float, float, float, float]:
"""Return the Webots axis-angle equivalent of :func:`rotation_matrix`."""
matrix = rotation_matrix(yaw_deg)
trace = matrix[0][0] + matrix[1][1] + matrix[2][2]
cosine = max(-1.0, min(1.0, (trace - 1.0) / 2.0))
angle = math.acos(cosine)
if angle < 1e-12:
return 0.0, 0.0, 1.0, 0.0
if math.pi - angle < 1e-9:
# 180 degrees: recover the axis from the symmetric part.
diagonal = [matrix[i][i] for i in range(3)]
axis = [math.sqrt(max(0.0, (diagonal[i] + 1.0) / 2.0)) for i in range(3)]
return tuple(axis) + (angle,)
scale = 2.0 * math.sin(angle)
axis = (
(matrix[2][1] - matrix[1][2]) / scale,
(matrix[0][2] - matrix[2][0]) / scale,
(matrix[1][0] - matrix[0][1]) / scale,
)
return tuple(axis) + (angle,)
def _vector(values) -> str:
return ' '.join(format(value, '.12g') for value in values)
def _asset_path(asset_root: Path, name: str) -> Path:
path = asset_root / ASSETS[name]
if not path.is_file():
raise FileNotFoundError(f'Webots asset does not exist: {path}')
source = path.resolve()
for line in source.read_text(encoding='utf-8', errors='replace').splitlines():
parts = line.split()
if parts and parts[0].lower() == 'mtllib':
for material in parts[1:]:
material_path = source.parent / material
if not material_path.is_file():
raise FileNotFoundError(
f'Webots material does not exist: {material_path} '
f'(referenced by {source})'
)
return path
def _mesh_shape(asset: Path, color: str, scale: float | None = None) -> str:
shape = f'''Shape {{
appearance PBRAppearance {{
baseColor {color}
roughness 0.5
metalness 0.3
}}
geometry Mesh {{
url [ {json.dumps(str(asset))} ]
}}
}}'''
if scale is None:
return shape
return f'''Transform {{
scale {_vector((scale, scale, scale))}
children [
{_indent(shape, 4)}
]
}}'''
def _indent(text: str, spaces: int) -> str:
prefix = ' ' * spaces
return '\n'.join(prefix + line for line in text.splitlines())
def _solid(
*,
name: str,
translation,
rotation,
children: str,
bounding: str,
locked: bool,
physics_mass: float | None = None,
) -> str:
lines = [
'Solid {',
f' translation {_vector(translation)}',
f' rotation {_vector(rotation)}',
f' name "{name}"',
]
if locked:
lines.append(' locked TRUE')
lines.extend([
' children [',
_indent(children, 4),
' ]',
' boundingObject ' + bounding,
])
if physics_mass is not None:
lines.append(f' physics Physics {{ mass {format(physics_mass, ".12g")} }}')
lines.append('}')
return '\n'.join(lines)
def _fixture_node(spec: ScenarioSpec, asset: Path) -> str:
pose = spec.fixture
bounding = '''Group {
children [
Pose {
translation 0 -0.005 0
children [ Box { size 0.35 0.01 0.22 } ]
}
Pose {
translation -0.104 0.008 0
children [ Box { size 0.014 0.016 0.156 } ]
}
Pose {
translation 0.104 0.008 0
children [ Box { size 0.014 0.016 0.156 } ]
}
]
}'''
return _solid(
name='grant_fixture',
translation=(pose.x, pose.y, pose.z),
rotation=rotation_axis_angle(pose.yaw_deg),
children=_mesh_shape(asset, '0.30 0.34 0.39'),
bounding=bounding,
locked=True,
)
def _body_node(spec: ScenarioSpec, asset: Path) -> str:
pose = spec.body
bounding = '''Pose {
translation 0 0.000695 0
children [ Box { size 0.30 0.00915 0.17 } ]
}'''
return _solid(
name='grant_body',
translation=(pose.x, pose.y, pose.z),
rotation=rotation_axis_angle(pose.yaw_deg),
children=_mesh_shape(asset, '0.64 0.68 0.72'),
bounding=bounding,
locked=True,
)
def _rack_node(spec: ScenarioSpec, asset: Path) -> str:
pose = spec.rack
# The magazine model was validated with its local X turned around, so the
# tool sockets face the robot. It still maps the OBJ Y axis to Webots Z.
rotation = rotation_axis_angle(pose.yaw_deg + 180.0)
bounding = '''Pose {
translation 0 0.005 0
children [ Box { size 0.42 0.01 0.14 } ]
}'''
return _solid(
name='grant_rack',
translation=(pose.x, pose.y, pose.z),
rotation=rotation,
children=_mesh_shape(asset, '0.72 0.47 0.14'),
bounding=bounding,
locked=True,
)
def _tool_node(tool, asset: Path) -> str:
scale = 10.0 if tool.name == 'sealant_tool' else None
bounding = f'''Pose {{
translation 0 0.07 0
children [ Box {{ size {_vector(TOOL_SIZE[tool.name])} }} ]
}}'''
return _solid(
name=TOOL_NODE_NAMES[tool.name],
translation=(tool.pose.x, tool.pose.y, tool.pose.z),
rotation=rotation_axis_angle(tool.pose.yaw_deg + 180.0),
children=_mesh_shape(asset, TOOL_APPEARANCE[tool.name], scale=scale),
bounding=bounding,
locked=True,
)
def _tray_node(spec: ScenarioSpec, asset: Path) -> str:
pose = spec.tray_pose
assert pose is not None
bounding = '''Group {
children [
Pose {
translation 0 0.0012 0
children [ Box { size 0.12 0.0024 0.07 } ]
}
Pose {
translation -0.059 0.005 0
children [ Box { size 0.002 0.01 0.07 } ]
}
Pose {
translation 0.059 0.005 0
children [ Box { size 0.002 0.01 0.07 } ]
}
Pose {
translation 0 0.005 -0.034
children [ Box { size 0.12 0.01 0.002 } ]
}
Pose {
translation 0 0.005 0.034
children [ Box { size 0.12 0.01 0.002 } ]
}
]
}'''
return _solid(
name='grant_tray',
translation=(pose.x, pose.y, pose.z),
rotation=rotation_axis_angle(pose.yaw_deg),
children=_mesh_shape(asset, '0.25 0.25 0.28'),
bounding=bounding,
locked=True,
)
def _screw_node(index: int, screw, asset: Path) -> str:
match = re.fullmatch(r'screw_(\d+)', screw.name)
number = int(match.group(1)) if match else index
bounding = '''Pose {
translation 0 0.0045 0
children [ Box { size 0.0055 0.009 0.0055 } ]
}'''
return _solid(
name=f'grant_screw_{number:02d}',
translation=(screw.pose.x, screw.pose.y, screw.pose.z),
rotation=rotation_axis_angle(screw.pose.yaw_deg),
children=_mesh_shape(asset, '0.62 0.64 0.67'),
bounding=bounding,
locked=True,
)
def _cap_node(spec: ScenarioSpec, asset: Path) -> str:
pose = spec.cap_pose
assert pose is not None
bounding = '''Pose {
translation 0 0.004165 0
children [ Box { size 0.1384 0.00367 0.0984 } ]
}'''
installed = spec.cap_state == 'installed'
return _solid(
name='grant_cap',
translation=(pose.x, pose.y, pose.z),
rotation=rotation_axis_angle(pose.yaw_deg),
children=_mesh_shape(asset, '0.78 0.82 0.86'),
bounding=bounding,
locked=installed,
physics_mass=None if installed else 0.1,
)
def build_nodes(spec: ScenarioSpec, asset_root: Path | str) -> list[str]:
"""Return the deterministic Webots node strings for one experiment."""
root = Path(asset_root)
if not root.is_dir():
raise FileNotFoundError(f'Webots asset directory does not exist: {root}')
required = ['fixture', 'body', 'rack']
required.extend(tool.name for tool in spec.tools)
if spec.tray_pose is not None:
required.extend(['tray', 'screw'])
if spec.cap_state != 'absent':
required.append('cap')
assets = {name: _asset_path(root, name) for name in required}
nodes = [
_fixture_node(spec, assets['fixture']),
_body_node(spec, assets['body']),
_rack_node(spec, assets['rack']),
]
nodes.extend(_tool_node(tool, assets[tool.name]) for tool in spec.tools)
if spec.tray_pose is not None:
nodes.append(_tray_node(spec, assets['tray']))
nodes.extend(
_screw_node(index, screw, assets['screw'])
for index, screw in enumerate(spec.screws, start=1)
)
if spec.cap_state != 'absent':
nodes.append(_cap_node(spec, assets['cap']))
return nodes
@@ -0,0 +1,273 @@
"""Deterministic Webots nodes derived from each scenario."""
import math
import re
from pathlib import Path
import pytest
from ament_index_python.packages import get_package_share_directory
from iiwa_experement.scenario import TOOL_NAMES, load_scenario
from iiwa_experement.webots_nodes import (
ASSETS,
TOOL_NODE_NAMES,
build_nodes,
rotation_axis_angle,
rotation_matrix,
)
PACKAGE = Path(__file__).resolve().parents[1]
SCENES = PACKAGE / 'config' / 'scenes.yaml'
ASSET_ROOT = (
Path(get_package_share_directory('iiwa_description')) / 'objects' / 'grant'
)
NAME_RE = re.compile(r'name "([^"]+)"')
TRANSLATION_RE = re.compile(r'translation ([^\n]+)')
ROTATION_RE = re.compile(r'rotation ([^\n]+)')
URL_RE = re.compile(r'url \[ "([^"]+)" \]')
def spec(experiment_id):
return load_scenario(experiment_id, SCENES)
def nodes(experiment_id):
return build_nodes(spec(experiment_id), ASSET_ROOT)
def by_name(node_list):
result = {}
for node in node_list:
match = NAME_RE.search(node)
assert match, node
result[match.group(1)] = node
return result
def translation(node):
return tuple(map(float, TRANSLATION_RE.search(node).group(1).split()))
def rotation(node):
return tuple(map(float, ROTATION_RE.search(node).group(1).split()))
def obj_y_bounds(asset):
low, high = math.inf, -math.inf
for line in (ASSET_ROOT / asset).read_text(encoding='utf-8', errors='replace').splitlines():
if line.startswith('v '):
y = float(line.split()[2])
low, high = min(low, y), max(high, y)
return low, high
def test_scenarios_one_six_eleven_sixteen_nineteen_are_complete():
expected = {
1: ['grant_fixture', 'grant_body', 'grant_rack', 'grant_vacuum', 'grant_cap'],
6: ['grant_fixture', 'grant_body', 'grant_rack', 'grant_sealant_tool'],
11: [
'grant_fixture', 'grant_body', 'grant_rack', 'grant_magnetic_tool',
'grant_tray', *[f'grant_screw_{i:02d}' for i in range(1, 11)],
'grant_cap',
],
16: [
'grant_fixture', 'grant_body', 'grant_rack',
'grant_vacuum', 'grant_magnetic_tool', 'grant_sealant_tool',
'grant_torque_tool', 'grant_tray',
*[f'grant_screw_{i:02d}' for i in range(1, 11)], 'grant_cap',
],
19: [
'grant_fixture', 'grant_body', 'grant_rack',
'grant_vacuum', 'grant_magnetic_tool', 'grant_sealant_tool',
'grant_torque_tool', 'grant_tray',
*[f'grant_screw_{i:02d}' for i in range(1, 11)], 'grant_cap',
],
}
for experiment_id, names in expected.items():
assert [NAME_RE.search(node).group(1) for node in nodes(experiment_id)] == names
def test_node_names_are_unique_and_never_duplicate_patron():
for experiment_id in range(1, 21):
node_list = nodes(experiment_id)
names = [NAME_RE.search(node).group(1) for node in node_list]
assert len(names) == len(set(names))
assert 'patron' not in names
assert all('patron' not in node for node in node_list)
assert all('GrantToolRack' not in node for node in node_list)
def test_all_mesh_urls_are_existing_installed_assets():
for experiment_id in range(1, 21):
for node in nodes(experiment_id):
urls = URL_RE.findall(node)
assert len(urls) == 1, node
path = Path(urls[0])
assert path.is_file(), path
assert path.parent == ASSET_ROOT
assert 'output' not in path.parts
def test_every_required_obj_and_mtl_exists_for_all_twenty_scenarios():
for experiment_id in range(1, 21):
scenario = spec(experiment_id)
required = {'fixture', 'body', 'rack'}
required.update(tool.name for tool in scenario.tools)
if scenario.tray_pose is not None:
required.update({'tray', 'screw'})
if scenario.cap_state != 'absent':
required.add('cap')
for name in required:
asset = ASSET_ROOT / ASSETS[name]
assert asset.is_file(), (experiment_id, asset)
for line in asset.read_text(encoding='utf-8').splitlines():
if line.startswith('mtllib'):
assert (ASSET_ROOT / line.split()[1]).is_file()
def test_shared_pose_translations_match_the_scenario():
for experiment_id in (1, 2, 3, 10, 19):
scenario = spec(experiment_id)
parsed = by_name(nodes(experiment_id))
assert translation(parsed['grant_fixture']) == pytest.approx(
(scenario.fixture.x, scenario.fixture.y, scenario.fixture.z)
)
assert translation(parsed['grant_body']) == pytest.approx(
(scenario.body.x, scenario.body.y, scenario.body.z)
)
assert translation(parsed['grant_rack']) == pytest.approx(
(scenario.rack.x, scenario.rack.y, scenario.rack.z)
)
def test_working_tools_are_in_their_own_sockets():
scenario = spec(16)
parsed = by_name(nodes(16))
for tool in scenario.tools:
assert translation(parsed[TOOL_NODE_NAMES[tool.name]]) == pytest.approx(
(tool.pose.x, tool.pose.y, tool.pose.z)
)
positions = {
translation(parsed[TOOL_NODE_NAMES[name]]) for name in TOOL_NAMES
}
assert len(positions) == 4
def test_experiment_nineteen_shifts_the_tray_and_all_ten_screws():
standard = by_name(nodes(11))
shifted = by_name(nodes(19))
tray = translation(shifted['grant_tray'])
assert tray == pytest.approx((-0.10, 0.36, 0.793))
for index in range(1, 11):
name = f'grant_screw_{index:02d}'
old = translation(standard[name])
new = translation(shifted[name])
assert new[0] == pytest.approx(old[0] - 0.02)
assert new[1] == pytest.approx(old[1] + 0.02)
assert len({translation(shifted[f'grant_screw_{i:02d}']) for i in range(1, 11)}) == 10
def test_obj_vertical_axis_maps_to_webots_z():
axis = (0.0, 1.0, 0.0)
for yaw in (0.0, 45.0, 60.0, 135.0, 180.0):
matrix = rotation_matrix(yaw)
x = sum(matrix[0][i] * axis[i] for i in range(3))
y = sum(matrix[1][i] * axis[i] for i in range(3))
z = sum(matrix[2][i] * axis[i] for i in range(3))
assert (x, y, z) == pytest.approx((0.0, 0.0, 1.0), abs=1e-12)
trace = sum(matrix[i][i] for i in range(3))
assert math.isclose(trace, math.cos(math.radians(yaw)), abs_tol=1e-12)
def test_cap_rotations_match_45_135_and_60_degrees():
assert rotation(by_name(nodes(1))['grant_cap']) == pytest.approx(
rotation_axis_angle(45), abs=1e-9
)
assert rotation(by_name(nodes(2))['grant_cap']) == pytest.approx(
rotation_axis_angle(135), abs=1e-9
)
assert rotation(by_name(nodes(19))['grant_cap']) == pytest.approx(
rotation_axis_angle(60), abs=1e-9
)
assert rotation(by_name(nodes(1))['grant_cap'])[3] == pytest.approx(
1.717771517458, abs=1e-9
)
def test_only_the_sealant_mesh_is_scaled_by_ten():
for experiment_id in range(1, 21):
scenario = spec(experiment_id)
parsed = by_name(nodes(experiment_id))
count = sum(node.count('scale 10 10 10') for node in parsed.values())
assert count == (1 if 'sealant_tool' in scenario.tool_names else 0)
if 'sealant_tool' in scenario.tool_names:
assert 'scale 10 10 10' in parsed['grant_sealant_tool']
def test_loose_cap_touches_the_table_and_installed_cap_touches_the_body():
cap_min, _ = obj_y_bounds(ASSETS['cap'])
_, body_max = obj_y_bounds(ASSETS['body'])
for experiment_id in range(1, 21):
scenario = spec(experiment_id)
if scenario.cap_state == 'absent':
continue
cap = by_name(nodes(experiment_id))['grant_cap']
cap_z = translation(cap)[2]
if scenario.cap_state == 'free':
assert cap_z + cap_min == pytest.approx(0.793, abs=1e-9)
else:
body_z = translation(by_name(nodes(experiment_id))['grant_body'])[2]
assert cap_z + cap_min == pytest.approx(body_z + body_max, abs=1e-9)
def test_free_cap_has_physics_and_installed_cap_is_locked():
free_cap = by_name(nodes(1))['grant_cap']
assert 'physics Physics {' in free_cap
assert 'locked TRUE' not in free_cap
installed_cap = by_name(nodes(11))['grant_cap']
assert 'locked TRUE' in installed_cap
assert 'physics Physics {' not in installed_cap
def test_ten_screws_are_distinct_and_inside_the_tray():
for experiment_id in (11, 19):
scenario = spec(experiment_id)
parsed = by_name(nodes(experiment_id))
positions = [
translation(parsed[f'grant_screw_{index:02d}']) for index in range(1, 11)
]
assert len(set(positions)) == 10
tray = scenario.tray_pose
assert tray is not None
for x, y, z in positions:
assert abs(x - tray.x) < 0.06
assert abs(y - tray.y) < 0.035
assert z == pytest.approx(0.7954)
def mirror_assets(tmp_path, missing):
for source in ASSET_ROOT.iterdir():
if source.name == missing:
continue
(tmp_path / source.name).symlink_to(source)
def test_missing_obj_is_a_hard_error(tmp_path):
mirror_assets(tmp_path, ASSETS['screw'])
with pytest.raises(FileNotFoundError, match='vint.obj'):
build_nodes(spec(11), tmp_path)
def test_missing_mtl_is_a_hard_error(tmp_path):
for source in ASSET_ROOT.iterdir():
target = tmp_path / source.name
if source.name in (ASSETS['screw'], 'vint.mtl'):
continue
target.symlink_to(source)
(tmp_path / ASSETS['screw']).write_text(
(ASSET_ROOT / ASSETS['screw']).read_text(encoding='utf-8'),
encoding='utf-8',
)
with pytest.raises(FileNotFoundError, match='vint.mtl'):
build_nodes(spec(11), tmp_path)