> ## Documentation Index
> Fetch the complete documentation index at: https://aegean.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Enhancing the TurtleBot Maze World with Textured Walls and ArUco Markers

> Create an enhanced Gazebo simulation world with taller textured walls and ArUco markers by building a parameterized world variant alongside the original.

Simulation environments play a critical role in robotics development. A richer visual environment helps test vision-based navigation, SLAM algorithms, and marker detection before deploying to hardware. In this tutorial, we walk through enhancing a TurtleBot3 maze world by adding taller walls with PBR textures, ArUco marker models, and a parameterized launch system, all while keeping the original world unchanged.

## What You'll Build

By the end of this tutorial, you'll have:

* An **enhanced maze world** (`sim_house_enhanced.sdf.xacro`) with 3-meter walls and colored PBR textures
* **ArUco marker models** that can optionally spawn in the environment
* A **parameterized launch system** that lets you switch between the original and enhanced worlds
* A new **Docker Compose service** (`demo-world-enhanced`) for one-command launch

<Note>
  This tutorial assumes familiarity with ROS 2 (Jazzy), Gazebo Sim (gz-sim), SDF/Xacro world files, and Docker Compose. It also assumes you have a working `demo-world` service that launches the original maze with a functional Nav2 navigation stack. The complete source is in the [turtlebot-maze](https://github.com/pantelis/turtlebot-maze) repository on the `feature/enhanced-maze` branch.
</Note>

## Prerequisites

* A working clone of the [turtlebot-maze](https://github.com/pantelis/turtlebot-maze) repository
* Docker and Docker Compose installed
* The `demo-world` service launching successfully with Nav2 active
* Basic familiarity with SDF world files and ROS 2 launch files

## Starting Point: The Original Maze

The original world file `sim_house.sdf.xacro` defines a house layout with 30 walls. Each wall is 1 meter tall, uses the default `Gazebo/Wood` material, and looks identical, a featureless maze of short gray walls. While functional for basic navigation testing, this environment offers little for vision-based algorithms.

Each wall in the SDF follows this pattern:

```xml theme={null}
<link name='Wall_0'>
  <collision name='Wall_0_Collision'>
    <geometry>
      <box>
        <size>2.5 0.15 1</size>     {/* width depth HEIGHT */}
      </box>
    </geometry>
    <pose>0 0 0.5 0 -0 0</pose>     {/* z-center = height/2 */}
  </collision>
  <visual name='Wall_0_Visual'>
    <pose>0 0 0.5 0 -0 0</pose>
    <geometry>
      <box>
        <size>2.5 0.15 1</size>
      </box>
    </geometry>
    <material>
      <script>
        <uri>file://media/materials/scripts/gazebo.material</uri>
        <name>Gazebo/Wood</name>
      </script>
    </material>
  </visual>
</link>
```

Our goal is to create an **enhanced variant** of this world without modifying the original, taller walls, colored textures, and optional ArUco markers, all controlled through launch parameters.

## Step 1: Gather Texture and Marker Assets

Before modifying any world files, you need the raw assets: wall textures and ArUco marker models.

### Wall Textures

Create a `textures/` directory inside the worlds folder and add three PNG texture images:

```
tb_worlds/worlds/textures/
  bricks.png      # Brick wall texture
  concrete.png    # Concrete texture
  wood_.png       # Wood grain texture
```

These should be standard PBR-compatible albedo maps. You can source them from texture libraries like [ambientCG](https://ambientcg.com/) or [Poly Haven](https://polyhaven.com/), or create simple solid-color images for testing.

### ArUco Marker Models

ArUco markers are commonly used for visual localization in robotics. Each marker model needs a directory under `tb_worlds/models/` containing:

```
tb_worlds/models/aruco_id_60/
  model.config          # Gazebo model metadata
  model.sdf             # Model description (link, visual, collision)
  meshes/
    aruco_cube_60.obj   # 3D mesh
    aruco_cube_60.mtl   # Material definition
  materials/textures/
    aruco_60.jpg        # Marker face texture
```

Create similar directories for each marker ID you want (e.g., `aruco_id_60`, `aruco_id_80`). You can generate ArUco marker images using OpenCV's `cv2.aruco` module and wrap them onto cube meshes using a 3D tool like Blender.

<Tip>
  If your ArUco model SDFs reference meshes via Gazebo Fuel URLs (e.g., `https://fuel.gazebosim.org/...`), replace them with local `model://` URIs. Fuel URLs require network access and fail inside Docker containers:

  ```xml theme={null}
  {/* Before: Fuel URL (requires network) */}
  <uri>https://fuel.gazebosim.org/1.0/myoan/models/aruco cube id 80/2/files/meshes/aruco_cube_80.obj</uri>

  {/* After: local model URI */}
  <uri>model://aruco_id_80/meshes/aruco_cube_80.obj</uri>
  ```

  The `model://` URI scheme resolves via `GZ_SIM_RESOURCE_PATH`, which we configure in Step 5.
</Tip>

### Install Assets via CMakeLists.txt

Verify that your package's `CMakeLists.txt` installs the new directories. The `tb_worlds` package already includes:

```cmake theme={null}
install(DIRECTORY ... worlds models ... DESTINATION share/${PROJECT_NAME})
```

This covers both the `textures/` subdirectory (inside `worlds/`) and the new `models/` entries. If your CMakeLists doesn't install `models`, add it.

## Step 2: Create the Enhanced World SDF

Copy `sim_house.sdf.xacro` to `sim_house_enhanced.sdf.xacro` and make three categories of changes: taller walls, shifted centers, and PBR textures with color fallbacks.

### Add the Xacro Texture Argument

Add a `texture_base` xacro argument after the existing `headless` argument. This receives the absolute path to the texture directory at launch time:

```xml theme={null}
<xacro:arg name="headless" default="false"/>
<xacro:arg name="texture_base" default=""/>
```

### Transform Wall Geometry

For all 30 walls, make these changes:

| Property                          | Original | Enhanced |
| --------------------------------- | -------- | -------- |
| `<size>` height (3rd component)   | `1`      | `3`      |
| `<pose>` z-center (3rd component) | `0.5`    | `1.5`    |

With 30 walls, each having both collision and visual elements, this means 60 size changes and 60 pose changes. A Python script makes this reliable:

```python theme={null}
import re

with open('sim_house.sdf.xacro', 'r') as f:
    content = f.read()

# Change wall heights from 1 to 3
def replace_wall_size(m):
    parts = m.group(2).split()
    if len(parts) == 3 and parts[2] == '1':
        parts[2] = '3'
    return m.group(1) + ' '.join(parts) + m.group(3)

content = re.sub(
    r'(<size>)([\d.]+ [\d.]+ 1)(</size>)',
    replace_wall_size,
    content
)

# Shift z-centers from 0.5 to 1.5
content = content.replace(
    '<pose>0 0 0.5 0 -0 0</pose>',
    '<pose>0 0 1.5 0 -0 0</pose>'
)
```

### Replace Materials with PBR Textures and Color Fallbacks

Gazebo Sim (gz-sim) uses the ogre2 render engine which supports PBR materials. Replace each `Gazebo/Wood` material block with a PBR `<albedo_map>` texture reference. Crucially, also add `<ambient>` and `<diffuse>` color tags as fallbacks, these ensure walls are visually distinct even when PBR textures don't render:

```xml theme={null}
{/* Enhanced material: color fallback + PBR texture */}
<material>
  <ambient>0.7 0.3 0.2 1</ambient>
  <diffuse>0.8 0.4 0.3 1</diffuse>
  <pbr>
    <metal>
      <albedo_map>$(arg texture_base)/bricks.png</albedo_map>
    </metal>
  </pbr>
</material>
```

Cycle through three texture/color combinations across the walls for visual variety:

| Texture        | Ambient Color             | Diffuse Color  |
| -------------- | ------------------------- | -------------- |
| `bricks.png`   | `0.7 0.3 0.2` (brick red) | `0.8 0.4 0.3`  |
| `concrete.png` | `0.6 0.6 0.6` (gray)      | `0.7 0.7 0.7`  |
| `wood_.png`    | `0.5 0.35 0.2` (brown)    | `0.6 0.4 0.25` |

<Warning>
  PBR textures in gz-sim require absolute file paths in `<albedo_map>` when the SDF is generated from xacro into a temp file (since relative paths would resolve from `/tmp/`). The `texture_base` xacro argument solves this by injecting the absolute path at launch time.
</Warning>

## Step 3: Create the ArUco Marker Spawner

The ArUco marker spawner is a ROS 2 launch file that uses `ros_gz_sim`'s `gz_spawn_model.launch.py` to place markers in the simulation at runtime.

```python theme={null}
# tb_worlds/launch/aruco_marker_spawner.launch.py
import os
import tempfile
from ament_index_python.packages import get_package_share_directory
from launch import LaunchDescription
from launch.actions import IncludeLaunchDescription, TimerAction
from launch.launch_description_sources import PythonLaunchDescriptionSource

def generate_launch_description():
    pkg_dir = get_package_share_directory("tb_worlds")
    gz_spawn_launch = os.path.join(
        get_package_share_directory("ros_gz_sim"),
        "launch", "gz_spawn_model.launch.py",
    )

    markers = [
        {"name": "aruco_id_80", "model_dir": "aruco_id_80",
         "x": "-1.0", "y": "-2.0", "z": "0.01", "Y": "0"},
        {"name": "aruco_id_60", "model_dir": "aruco_id_60",
         "x": "-1.5", "y": "-2.05", "z": "0.23", "Y": "0"},
    ]

    spawn_actions = []
    for m in markers:
        model_dir = os.path.join(pkg_dir, "models", m["model_dir"])
        sdf_path = os.path.join(model_dir, "model.sdf")

        # Substitute model:// URIs with absolute paths
        with open(sdf_path, "r") as f:
            sdf_content = f.read()
        sdf_content = sdf_content.replace(
            f"model://{m['model_dir']}/", f"{model_dir}/"
        )

        # Write temp SDF with resolved paths
        tmp_sdf = tempfile.NamedTemporaryFile(
            prefix=f"aruco_{m['name']}_", suffix=".sdf",
            mode="w", delete=False,
        )
        tmp_sdf.write(sdf_content)
        tmp_sdf.close()

        spawn_actions.append(
            IncludeLaunchDescription(
                PythonLaunchDescriptionSource(gz_spawn_launch),
                launch_arguments={
                    "world": "",
                    "file": tmp_sdf.name,
                    "entity_name": m["name"],
                    "x": m["x"], "y": m["y"],
                    "z": m["z"], "Y": m["Y"],
                }.items(),
            )
        )

    # Delay to allow gz-sim to fully initialize
    delayed_spawn = TimerAction(period=10.0, actions=spawn_actions)
    return LaunchDescription([delayed_spawn])
```

### Key Implementation Details

<AccordionGroup>
  <Accordion title="Why entity_name instead of name?">
    The `gz_spawn_model.launch.py` in ROS 2 Jazzy declares its launch argument as `entity_name`, which it then maps to the `name` ROS parameter internally. Passing `name` directly as a launch argument has no effect, the parameter stays empty and the spawn fails.
  </Accordion>

  <Accordion title="Why write temp SDF files?">
    The `ros_gz_sim create` node reads the SDF file and sends its content to the gz-sim server. If the SDF contains `model://` URIs, the `create` node may crash (SIGABRT) because it tries to resolve them locally before the server does. Writing a temp SDF with absolute paths avoids this issue entirely.
  </Accordion>

  <Accordion title="Why the 10-second delay?">
    The ArUco spawner launches in parallel with gz-sim. If the `create` service call arrives before gz-sim's entity creation service is ready, the spawner crashes. A `TimerAction` delay gives gz-sim time to initialize.
  </Accordion>
</AccordionGroup>

## Step 4: Parameterize the Launch File

The existing `tb_demo_world.launch.py` hardcodes the world file. We add two new launch arguments to make it configurable without breaking existing behavior:

```python theme={null}
from launch.substitutions import LaunchConfiguration, PathJoinSubstitution

# New launch arguments
world_name = LaunchConfiguration("world_name")
use_aruco = LaunchConfiguration("use_aruco")

declare_world_name_cmd = DeclareLaunchArgument(
    "world_name",
    default_value="sim_house.sdf.xacro",
    description="World filename relative to tb_worlds/worlds/",
)

declare_use_aruco_cmd = DeclareLaunchArgument(
    "use_aruco",
    default_value="False",
    description="Whether to spawn ArUco markers",
)

# Construct full world path from world_name
world_path = PathJoinSubstitution([bringup_dir, "worlds", world_name])
```

Pass the constructed path to `tb_world.launch.py`:

```python theme={null}
sim_cmd = IncludeLaunchDescription(
    PythonLaunchDescriptionSource(
        os.path.join(bringup_dir, "launch", "tb_world.launch.py")
    ),
    launch_arguments={
        "namespace": namespace,
        "use_sim_time": use_sim_time,
        "world": world_path,
    }.items(),
)
```

Add the conditional ArUco spawner:

```python theme={null}
aruco_spawner_cmd = IncludeLaunchDescription(
    PythonLaunchDescriptionSource(
        os.path.join(bringup_dir, "launch", "aruco_marker_spawner.launch.py")
    ),
    condition=IfCondition(use_aruco),
)
```

### Passing texture\_base Through to Xacro

The `tb_world.launch.py` runs xacro on the world file. It needs to forward the `texture_base` argument so the enhanced SDF can resolve texture paths. Add a `texture_base` launch argument with a sensible default:

```python theme={null}
declare_texture_base_cmd = DeclareLaunchArgument(
    "texture_base",
    default_value=os.path.join(bringup_dir, "worlds", "textures"),
    description="Absolute path to wall texture directory",
)
```

And pass it to the xacro command using a nested list (ROS 2 launch concatenates adjacent elements):

```python theme={null}
world_sdf_xacro = ExecuteProcess(
    cmd=[
        "xacro", "-o", world_sdf, world,
        ["texture_base:=", texture_base],
    ]
)
```

<Note>
  The default value `sim_house.sdf.xacro` for `world_name` means existing users of `demo-world` are completely unaffected, the original world loads exactly as before.
</Note>

## Step 5: Add the Docker Compose Service

Add a new service after `demo-world` in `docker-compose.yaml`:

```yaml theme={null}
# Enhanced demo world with taller textured walls and ArUco markers
demo-world-enhanced:
  extends: overlay
  environment:
    - GZ_SIM_RESOURCE_PATH=/overlay_ws/install/tb_worlds/share/tb_worlds/worlds:/overlay_ws/install/tb_worlds/share/tb_worlds/models
  command: >
    ros2 launch tb_worlds tb_demo_world.launch.py
    world_name:=sim_house_enhanced.sdf.xacro
    use_aruco:=True
```

The `GZ_SIM_RESOURCE_PATH` environment variable is critical, it tells gz-sim where to find `model://` URIs for mesh and texture resolution. It must include both the `worlds/` directory (for texture files) and the `models/` directory (for ArUco marker meshes).

## Step 6: Build and Launch

```bash theme={null}
# Build the image
docker compose build demo-world-enhanced

# Launch ONLY the enhanced world (not all services!)
docker compose up demo-world-enhanced
```

<Warning>
  **Never run `docker compose up` without specifying a service.** Because all services use `network_mode: host`, launching both `demo-world` and `demo-world-enhanced` simultaneously creates two gz-sim instances competing for the same ports, resulting in two Gazebo windows and a broken Nav2 stack.
</Warning>

## Verification

After launch, you should see:

1. **Gazebo Sim**, Tall colored walls (brick red, concrete gray, wood brown) instead of the original short gray walls
2. **RViz2**, The map loads and Nav2 shows "active" for both Navigation and Localization
3. **Nav2**, Fully functional; the same `sim_house_map.yaml` and costmaps work because the wall footprint is unchanged

Check the logs for confirmation:

```bash theme={null}
docker compose logs demo-world-enhanced | grep "Managed nodes are active"
```

You should see two lines, one for `lifecycle_manager_localization` and one for `lifecycle_manager_navigation`.

## Summary of Files Changed

| File                                              | Change                                               |
| ------------------------------------------------- | ---------------------------------------------------- |
| `tb_worlds/worlds/textures/*.png`                 | Wall texture images (brick, concrete, wood)          |
| `tb_worlds/models/aruco_id_\{60,80\}/`            | ArUco marker models with local mesh URIs             |
| `tb_worlds/worlds/sim_house_enhanced.sdf.xacro`   | New enhanced world (3m walls, PBR + color fallbacks) |
| `tb_worlds/launch/aruco_marker_spawner.launch.py` | New ArUco spawner with temp SDF and delay            |
| `tb_worlds/launch/tb_demo_world.launch.py`        | Added `world_name` and `use_aruco` params            |
| `tb_worlds/launch/tb_world.launch.py`             | Added `texture_base` xacro passthrough               |
| `docker-compose.yaml`                             | Added `demo-world-enhanced` service                  |

## Next Steps

* **Add more textures**, Download additional PBR texture packs and assign them per-room
* **Fix ArUco spawning**, The OBJ mesh spawning via `ros_gz_sim create` currently triggers SIGABRT in some gz-sim versions; consider embedding markers directly in the world SDF instead
* **Create a SLAM-friendly variant**, The taller walls improve SLAM performance since the lidar has more surface to reflect off of at different heights
* **Add ceiling and floor textures**, Complete the visual environment for camera-based navigation testing

***

<Callout icon="pen-to-square" iconType="regular">
  [Edit this page on GitHub](https://github.com/aegean-ai/eaia/edit/main/src/blog/tutorials/enhancing-turtlebot-maze/index.mdx) or [file an issue](https://github.com/aegean-ai/eaia/issues/new/choose).
</Callout>
