import json import os import tempfile import xml.etree.ElementTree as ET from ament_index_python.packages import get_package_share_directory from launch import LaunchDescription from launch.actions import DeclareLaunchArgument, IncludeLaunchDescription, OpaqueFunction, SetEnvironmentVariable from launch.conditions import IfCondition from launch.launch_description_sources import PythonLaunchDescriptionSource from launch.substitutions import LaunchConfiguration from launch_ros.actions import Node HOUSE_FURNITURE_BOXES = { # mailbox.dae uses inch units and an internal transform. These values preserve # its rendered bounds while keeping the visual box upright on the floor. 'mailbox/': ('0.133 0.355 1.067', '0.002 0.017 0.533 0 0 0'), 'cafe_table/': ('0.94 0.94 0.78', None), 'first_2015_trash_can/': ('0.44 0.44 0.76', None), 'table_marble/': ('1.40 0.86 0.76', None), } LOCAL_GROUND_PLANE_MODEL = ''' 1 0 0 1100 100 false0 0 1100 1000.24 0.27 0.32 10.24 0.27 0.32 1 ''' LOCAL_SUN_LIGHT = ''' false0 0 10 0 0 00.8 0.8 0.8 10.2 0.2 0.2 1-0.5 0.1 -0.9 ''' MKMINI_WORLD_SPACING_SCALE = 1.0 def replace_house_visual_meshes_with_boxes(house_model): """Keep physical mesh collisions, but render furniture as inexpensive boxes.""" for visual in house_model.findall('.//visual'): geometry = visual.find('geometry') mesh = geometry.find('mesh') if geometry is not None else None uri = mesh.findtext('uri', default='') if mesh is not None else '' box_config = next((value for key, value in HOUSE_FURNITURE_BOXES.items() if key in uri), None) if box_config is None: continue size, pose = box_config geometry.remove(mesh) box = ET.SubElement(geometry, 'box') ET.SubElement(box, 'size').text = size if pose is not None: visual_pose = visual.find('pose') if visual_pose is None: visual_pose = ET.SubElement(visual, 'pose') visual_pose.text = pose # The house references Gazebo's external mailbox asset, which is not shipped # with the module image. Replace both its visual and collision with the same # local box so it remains visible and navigable in the flat indoor world. mailbox = next( (model for model in house_model.findall('.//model') if model.attrib.get('name') == 'Mailbox'), None, ) if mailbox is None: return mailbox_link = mailbox.find('link') if mailbox_link is None: return for element in list(mailbox_link): if element.tag in {'visual', 'collision'}: mailbox_link.remove(element) # The laser is roughly 17 cm above the ground. Keep the mailbox body above # that plane and retain the single thin support from mailbox.dae. mailbox_parts = ( ('body', '0.133 0.355 0.29', '0.002 0.017 0.922 0 0 0'), ('leg', '0.043 0.044 0.803', '0.000 0.012 0.401 0 0 0'), ) for element_type in ('collision', 'visual'): for name, size, pose in mailbox_parts: element = ET.SubElement(mailbox_link, element_type, name=f'mailbox_{element_type}_{name}') ET.SubElement(element, 'pose').text = pose geometry = ET.SubElement(element, 'geometry') box = ET.SubElement(geometry, 'box') ET.SubElement(box, 'size').text = size def create_flat_house_world(gazebo_dir): """Create a texture-free house copy with box-rendered furniture.""" world_path = os.path.join(gazebo_dir, 'worlds', 'turtlebot3_house.world') model_path = os.path.join(gazebo_dir, 'models', 'turtlebot3_house', 'model.sdf') flat_models_dir = os.path.join(tempfile.gettempdir(), 'nav2_flat_models') flat_model_dir = os.path.join(flat_models_dir, 'turtlebot3_house_flat') os.makedirs(flat_model_dir, exist_ok=True) house_model_tree = ET.parse(model_path) house_sdf = house_model_tree.getroot() house_model = house_sdf.find('model') if house_model is None: raise RuntimeError('turtlebot3_house model is missing from model.sdf') house_model.attrib['name'] = 'turtlebot3_house_flat' house_static = house_model.find('static') if house_static is None: house_static = ET.Element('static') house_model.insert(0, house_static) house_static.text = '1' replace_house_visual_meshes_with_boxes(house_model) for visual in house_model.findall('.//visual'): material = visual.find('material') if material is None: material = ET.SubElement(visual, 'material') for script in material.findall('script'): material.remove(script) for name, value in ( ('ambient', '0.62 0.67 0.76 1'), ('diffuse', '0.62 0.67 0.76 1'), ('specular', '0.05 0.05 0.05 1'), ): color = material.find(name) if color is None: color = ET.SubElement(material, name) color.text = value shadows = visual.find('cast_shadows') if shadows is None: shadows = ET.SubElement(visual, 'cast_shadows') shadows.text = 'false' lighting = visual.find('lighting') if lighting is None: lighting = ET.SubElement(visual, 'lighting') lighting.text = 'false' # ElementTree serializes Gazebo's frame='' as frame="", which is invalid SDF. for element in house_sdf.iter(): if element.attrib.get('frame') == '': del element.attrib['frame'] house_model_tree.write( os.path.join(flat_model_dir, 'model.sdf'), encoding='utf-8', xml_declaration=True, ) with open(os.path.join(flat_model_dir, 'model.config'), 'w', encoding='utf-8') as config: config.write( 'turtlebot3_house_flat' '1.0model.sdf' ) with open(world_path, 'r', encoding='utf-8') as source: flat_world = source.read() flat_world = flat_world.replace( ' \n model://ground_plane\n ', LOCAL_GROUND_PLANE_MODEL, ).replace( ' \n model://sun\n ', LOCAL_SUN_LIGHT, ).replace('model://turtlebot3_house', 'model://turtlebot3_house_flat') flat_world_path = os.path.join(tempfile.gettempdir(), 'nav2_turtlebot3_house_flat.world') with open(flat_world_path, 'w', encoding='utf-8') as destination: destination.write(flat_world) existing_model_path = os.environ.get('GAZEBO_MODEL_PATH', '') os.environ['GAZEBO_MODEL_PATH'] = os.pathsep.join( part for part in (flat_models_dir, existing_model_path) if part ) os.environ['GAZEBO_MODEL_DATABASE_URI'] = '' return flat_world_path def create_mkmini_world(gazebo_dir): """Create the MKMini world variant without changing map-scale coordinates.""" source_model_path = os.path.join(gazebo_dir, 'models', 'turtlebot3_world', 'model.sdf') models_dir = os.path.join(tempfile.gettempdir(), 'nav2_mkmini_models') model_dir = os.path.join(models_dir, 'turtlebot3_world_mkmini') os.makedirs(model_dir, exist_ok=True) model_tree = ET.parse(source_model_path) model_sdf = model_tree.getroot() model = model_sdf.find('model') if model is None: raise RuntimeError('turtlebot3_world model is missing from model.sdf') model.attrib['name'] = 'turtlebot3_world_mkmini' # Keep object geometry and coordinates aligned with the base world. MKMini # clearance comes from its footprint and inflation configuration. for pose in model.findall('.//pose'): values = pose.text.split() if len(values) != 6: continue try: values[0] = str(float(values[0]) * MKMINI_WORLD_SPACING_SCALE) values[1] = str(float(values[1]) * MKMINI_WORLD_SPACING_SCALE) except ValueError: continue pose.text = ' '.join(values) # The black wall is centered at the origin, so its pose is unaffected by # the spacing expansion. Enlarge both its visual and collision mesh to # retain the same relationship with the widened green obstacles. for mesh in model.findall('.//mesh'): if not (mesh.findtext('uri', default='').endswith('/wall.dae')): continue scale = mesh.find('scale') if scale is None: continue values = scale.text.split() if len(values) != 3: continue try: scale.text = ' '.join(str(float(value) * MKMINI_WORLD_SPACING_SCALE) for value in values) except ValueError: continue # Keep only the centre post from TurtleBot3 World's 3x3 white-pillar grid. # This is applied to the generated MKMini world only; Burger keeps the # original obstacle layout. Remove both visual and collision shapes so the # Gazebo scene and Nav2 costmap describe the same space. for link in model.findall('.//link'): for element in list(link): if element.tag not in {'collision', 'visual'}: continue cylinder = element.find('./geometry/cylinder') if cylinder is None: continue if cylinder.findtext('radius') != '0.15' or cylinder.findtext('length') != '0.5': continue pose = element.find('pose') values = pose.text.split() if pose is not None and pose.text else [] if len(values) != 6: continue try: x, y = float(values[0]), float(values[1]) except ValueError: continue if abs(x) > 1e-6 or abs(y) > 1e-6: link.remove(element) continue # The MKMini lidar is centered at 0.52 m. Raise the remaining # centre post so it stays observable instead of below the scan. cylinder.find('length').text = '1.2' values[2] = '0.6' pose.text = ' '.join(values) model_tree.write( os.path.join(model_dir, 'model.sdf'), encoding='utf-8', xml_declaration=True, ) with open(os.path.join(model_dir, 'model.config'), 'w', encoding='utf-8') as config: config.write( 'turtlebot3_world_mkmini' '1.0model.sdf' ) source_world_path = os.path.join(gazebo_dir, 'worlds', 'turtlebot3_world.world') with open(source_world_path, 'r', encoding='utf-8') as source: world = source.read().replace('model://turtlebot3_world', 'model://turtlebot3_world_mkmini') world_path = os.path.join(tempfile.gettempdir(), 'nav2_mkmini_world.world') with open(world_path, 'w', encoding='utf-8') as destination: destination.write(world) return world_path, models_dir def create_custom_layout_world(): """Build a Gazebo world from the editor's persisted 2D layout.""" layout_path = os.environ.get('WORLD_LAYOUT_PATH', '/modules/world-layout.json') try: with open(layout_path, 'r', encoding='utf-8') as source: objects = json.load(source).get('objects', []) except (OSError, ValueError, AttributeError): objects = [] models = [] for index, item in enumerate(objects): if not isinstance(item, dict): continue position = item.get('position') or [0, 0, 0] rotation = item.get('rotation') or [0, 0, 0] size = item.get('size') or [1, 1, 1] try: x, y, z = (float(position[0]), float(position[1]), float(position[2])) yaw = float(rotation[2]) sx, sy, sz = (max(0.05, float(size[0])), max(0.05, float(size[1])), max(0.05, float(size[2]))) except (IndexError, TypeError, ValueError): continue name = f"custom_{item.get('type', 'box')}_{index}" if item.get('type') == 'pillar': geometry = f'{sx / 2.0}{sz}' else: geometry = f'{sx} {sy} {sz}' models.append( f''' true {x} {y} {z} 0 0 {yaw} {geometry} {geometry}0.34 0.58 0.82 10.34 0.58 0.82 1 ''' ) world_path = os.path.join(tempfile.gettempdir(), 'nav2_custom_layout.world') with open(world_path, 'w', encoding='utf-8') as destination: destination.write(''' model://sun model://ground_plane 1000 ''') destination.write('\n'.join(models)) destination.write('\n \n\n') return world_path def launch_gazebo(context): gazebo_dir = get_package_share_directory('turtlebot3_gazebo') simulator_dir = get_package_share_directory('nav2_turtlebot_simulator') gazebo_ros_dir = get_package_share_directory('gazebo_ros') world = LaunchConfiguration('world').perform(context) render_mode = LaunchConfiguration('render_mode').perform(context).strip().lower() platform = LaunchConfiguration('platform').perform(context).strip().lower() spawn_x = LaunchConfiguration('x_pose').perform(context) spawn_y = LaunchConfiguration('y_pose').perform(context) extra_model_path = '' if render_mode not in {'flat', 'full'}: raise RuntimeError('render_mode must be either flat or full') if world == 'empty_world.world' and os.path.isfile(os.environ.get('WORLD_LAYOUT_PATH', '/modules/world-layout.json')): world_path = create_custom_layout_world() elif world == 'turtlebot3_house.world' and render_mode == 'flat': world_path = create_flat_house_world(gazebo_dir) elif world == 'turtlebot3_world.world' and platform in {'mkmini', 'mk-mini'}: world_path, extra_model_path = create_mkmini_world(gazebo_dir) else: world_path = os.path.join( simulator_dir if world == 'empty_world.world' else gazebo_dir, 'worlds', world, ) if platform in {'turtlebot3', 'turtlebot3_burger', 'burger'}: entity_name = 'turtlebot3_burger_3d_lidar' model_path = os.path.join(gazebo_dir, 'models', 'turtlebot3_burger', 'model.sdf') model_directory = os.path.join(gazebo_dir, 'models') static_transforms = [ Node( package='tf2_ros', executable='static_transform_publisher', name='turtlebot3_base_link_tf', arguments=[ '--x', '0', '--y', '0', '--z', '0.01', '--roll', '0', '--pitch', '0', '--yaw', '0', '--frame-id', 'base_footprint', '--child-frame-id', 'base_link', ], ), Node( package='tf2_ros', executable='static_transform_publisher', name='turtlebot3_laser_tf', arguments=[ '--x', '-0.032', '--y', '0', '--z', '0.171', '--roll', '0', '--pitch', '0', '--yaw', '0', '--frame-id', 'base_link', '--child-frame-id', 'base_scan', ], ), ] elif platform in {'mkmini', 'mk-mini'}: entity_name = 'mkmini_gazebo' model_directory = os.path.join(simulator_dir, 'models') model_path = os.path.join(model_directory, 'mkmini_gazebo', 'model.sdf') # The world obstacles are spread out for MKMini, but the original # TurtleBot spawn is already inside the navigable centre of that map. # Scaling it as well placed the robot against the generated map edge. if spawn_x == '-2.0' and spawn_y == '-0.5': spawn_x = '-1.4' # Gazebo's fixed joint does not reliably publish this transform to the # separate SLAM container. Publish the sensor mounting point explicitly. static_transforms = [ Node( package='tf2_ros', executable='static_transform_publisher', name='mkmini_lidar_tf', arguments=[ '--x', '0.15', '--y', '0', '--z', '0.578', '--roll', '0', '--pitch', '0', '--yaw', '0', '--frame-id', 'base_link', '--child-frame-id', 'os_lidar', ], ), ] else: raise RuntimeError('platform must be turtlebot3 or mkmini') existing_model_path = os.environ.get('GAZEBO_MODEL_PATH', '') return [ SetEnvironmentVariable( name='GAZEBO_MODEL_PATH', value=os.pathsep.join(part for part in (extra_model_path, model_directory, existing_model_path) if part), ), IncludeLaunchDescription( PythonLaunchDescriptionSource(os.path.join(gazebo_ros_dir, 'launch', 'gzserver.launch.py')), launch_arguments={ 'world': world_path, 'extra_gazebo_args': '-slibgazebo_ros_state.so', }.items(), ), Node( package='gazebo_ros', executable='spawn_entity.py', arguments=[ '-entity', entity_name, '-file', model_path, '-x', spawn_x, '-y', spawn_y, '-z', '0.01', ], output='screen', ), *static_transforms, ] def generate_launch_description(): gazebo_ros_dir = get_package_share_directory('gazebo_ros') return LaunchDescription([ DeclareLaunchArgument('gui', default_value='false'), DeclareLaunchArgument('world', default_value='turtlebot3_world.world'), DeclareLaunchArgument('render_mode', default_value='flat'), DeclareLaunchArgument('platform', default_value='turtlebot3'), DeclareLaunchArgument('x_pose', default_value='-2.0'), DeclareLaunchArgument('y_pose', default_value='-0.5'), OpaqueFunction(function=launch_gazebo), IncludeLaunchDescription( PythonLaunchDescriptionSource(os.path.join(gazebo_ros_dir, 'launch', 'gzclient.launch.py')), condition=IfCondition(LaunchConfiguration('gui')), ), ])