Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 8 additions & 5 deletions orbit/export/lane_builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,14 +39,16 @@ def convert_road_mark_type(road_mark_type: RoadMarkType) -> str:
class LaneBuilder:
"""Builds lane XML elements for OpenDRIVE export."""

def __init__(self, scale_x: float = 1.0):
def __init__(self, scale_x: float = 1.0, opendrive_version: str = "1.8"):
"""
Initialize lane builder.

Args:
scale_x: Scale factor in meters per pixel for s-coordinate conversion
opendrive_version: Target OpenDRIVE version (e.g. "1.8" or "1.4")
"""
self.scale_x = scale_x
self.opendrive_version = opendrive_version

def create_lanes(
self,
Expand Down Expand Up @@ -241,10 +243,11 @@ def _create_lane(
lane.set('level', 'true' if lane_obj.level else 'false')

# V1.8 direction and advisory attributes
if lane_obj.direction is not None:
lane.set('direction', lane_obj.direction)
if lane_obj.advisory is not None:
lane.set('advisory', lane_obj.advisory)
if self.opendrive_version == "1.8":
if lane_obj.direction is not None:
lane.set('direction', lane_obj.direction)
if lane_obj.advisory is not None:
lane.set('advisory', lane_obj.advisory)

# Lane link with predecessor/successor
pred_id = lane_obj.predecessor_id
Expand Down
27 changes: 19 additions & 8 deletions orbit/export/opendrive_writer.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ class ExportOptions:
country_code: str = "se"
use_tmerc: bool = False
use_german_codes: bool = False
opendrive_version: str = "1.8"
offset_x: float = 0.0
offset_y: float = 0.0
geo_reference_string: Optional[str] = None
Expand All @@ -52,6 +53,7 @@ def __init__(
country_code: str = "se",
use_tmerc: bool = False,
use_german_codes: bool = False,
opendrive_version: str = "1.8",
offset_x: float = 0.0,
offset_y: float = 0.0,
geo_reference_string: Optional[str] = None,
Expand All @@ -73,6 +75,7 @@ def __init__(
country_code=country_code,
use_tmerc=use_tmerc,
use_german_codes=use_german_codes,
opendrive_version=opendrive_version,
offset_x=offset_x,
offset_y=offset_y,
geo_reference_string=geo_reference_string,
Expand All @@ -81,6 +84,7 @@ def __init__(
self.right_hand_traffic = opts.right_hand_traffic
self.country_code = opts.country_code.lower()
self.use_tmerc = opts.use_tmerc
self.opendrive_version = opts.opendrive_version
self.offset_x = opts.offset_x
self.offset_y = opts.offset_y
self.geo_reference_string = opts.geo_reference_string
Expand Down Expand Up @@ -113,7 +117,7 @@ def __init__(
self.lane_analyzer = LaneAnalyzer(project, self.right_hand_traffic, scale_factors, transformer)

# Initialize builders
self.lane_builder = LaneBuilder(scale_x=self.scale_x)
self.lane_builder = LaneBuilder(scale_x=self.scale_x, opendrive_version=self.opendrive_version)
self.signal_builder = SignalBuilder(
scale_x=self.scale_x,
country_code=opts.country_code,
Expand Down Expand Up @@ -290,9 +294,12 @@ def write_and_validate(

def _create_opendrive_root(self) -> etree.Element:
"""Create the root OpenDRIVE element with all content."""
# OpenDRIVE 1.8 namespace
nsmap = {None: "http://code.asam.net/simulation/standard/opendrive_schema"}
root = etree.Element('OpenDRIVE', nsmap=nsmap)
# OpenDRIVE namespace (only for 1.8)
if self.opendrive_version == "1.8":
nsmap = {None: "http://code.asam.net/simulation/standard/opendrive_schema"}
root = etree.Element('OpenDRIVE', nsmap=nsmap)
else:
root = etree.Element('OpenDRIVE')

Comment on lines 295 to 303
Copy link

Copilot AI Apr 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

opendrive_version is treated as a free-form string and any value other than "1.8" silently falls back to 1.4 behavior (no namespace, revMinor set to 4, and 1.8-only elements omitted). This makes typos like "1.18" or "1.8.0" produce an incorrect 1.4 file without any warning. Consider validating the value early (e.g., in __init__) and raising a clear error or normalizing accepted aliases.

Copilot uses AI. Check for mistakes.
# Add header
header = self._create_header()
Expand Down Expand Up @@ -566,7 +573,10 @@ def _create_header(self) -> etree.Element:
"""Create OpenDrive header element."""
header = etree.Element('header')
header.set('revMajor', '1')
header.set('revMinor', '8')
if self.opendrive_version == "1.8":
header.set('revMinor', '8')
else:
header.set('revMinor', '4')

# Use map name from project, fallback to 'ORBIT Export' if empty
map_name = self.project.map_name if self.project.map_name else 'ORBIT Export'
Expand Down Expand Up @@ -1571,7 +1581,7 @@ def _create_junction(self, junction: Junction, junction_numeric_id: int) -> Opti
connection_id += 1

# Add boundary (V1.8 feature)
if junction.boundary and junction.boundary.segments:
if self.opendrive_version == "1.8" and junction.boundary and junction.boundary.segments:
boundary_elem = etree.SubElement(junction_elem, 'boundary')
for segment in junction.boundary.segments:
seg_elem = etree.SubElement(boundary_elem, 'segment')
Expand All @@ -1598,7 +1608,7 @@ def _create_junction(self, junction: Junction, junction_numeric_id: int) -> Opti
seg_elem.set('transitionLength', f'{segment.transition_length:.6g}')

# Add elevation grid (V1.8 feature)
if junction.elevation_grid and junction.elevation_grid.elevations:
if self.opendrive_version == "1.8" and junction.elevation_grid and junction.elevation_grid.elevations:
eg_elem = etree.SubElement(junction_elem, 'elevationGrid')
if junction.elevation_grid.grid_spacing:
eg_elem.set('gridSpacing', junction.elevation_grid.grid_spacing)
Expand Down Expand Up @@ -1660,6 +1670,7 @@ def export_to_opendrive(
country_code: str = "se",
use_tmerc: bool = False,
use_german_codes: bool = False,
opendrive_version: str = "1.8",
offset_x: float = 0.0,
offset_y: float = 0.0,
geo_reference_string: Optional[str] = None,
Expand Down Expand Up @@ -1692,7 +1703,7 @@ def export_to_opendrive(
curve_fitter = CurveFitter(line_tolerance, arc_tolerance, preserve_geometry)
writer = OpenDriveWriter(
project, transformer, curve_fitter, right_hand_traffic,
country_code, use_tmerc, use_german_codes,
country_code, use_tmerc, use_german_codes, opendrive_version,
offset_x, offset_y, geo_reference_string, export_object_types
)
return writer.write(output_path)
Expand Down
8 changes: 8 additions & 0 deletions orbit/gui/dialogs/export_dialog.py
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,13 @@ def setup_ui(self):
self.arc_tolerance_spin.setToolTip("Maximum deviation for arc fitting")
options_layout.addRow("Arc Tolerance:", self.arc_tolerance_spin)

# OpenDRIVE version selection
self.opendrive_version_combo = QComboBox()
self.opendrive_version_combo.addItem("1.8", "1.8")
self.opendrive_version_combo.addItem("1.4", "1.4")
self.opendrive_version_combo.setToolTip("Select the OpenDRIVE version to export.")
options_layout.addRow("OpenDRIVE Version:", self.opendrive_version_combo)

# Preserve geometry checkbox
self.preserve_geometry_checkbox = QCheckBox("Preserve all polyline points")
self.preserve_geometry_checkbox.setChecked(True)
Expand Down Expand Up @@ -502,6 +509,7 @@ def do_export(self):
country_code=country_code,
use_tmerc=use_tmerc,
use_german_codes=self.use_german_codes_checkbox.isChecked(),
opendrive_version=self.opendrive_version_combo.currentData(),
offset_x=offset_x,
offset_y=offset_y,
geo_reference_string=geo_reference_string,
Expand Down
115 changes: 114 additions & 1 deletion tests/unit/test_export/test_opendrive_writer.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,13 @@
from orbit.export.curve_fitting import CurveFitter, GeometryElement, GeometryType
from orbit.export.opendrive_writer import OpenDriveWriter, export_to_opendrive, validate_opendrive_file
from orbit.models import Junction, LineType, Polyline, Project, Road
from orbit.models.junction import LaneConnection
from orbit.models.junction import (
JunctionBoundary,
JunctionBoundarySegment,
JunctionElevationGrid,
JunctionElevationGridPoint,
LaneConnection,
)
from orbit.models.lane import Lane
from orbit.models.lane import LaneType as ModelLaneType
from orbit.models.road import RoadType
Expand Down Expand Up @@ -926,6 +932,112 @@ def test_export_to_opendrive_with_options(self, simple_project, mock_transformer

assert result is True

def test_export_opendrive_1_4_suppresses_1_8_features(self, mock_transformer, tmp_path):
"""Export to OpenDRIVE 1.4 suppresses 1.8-specific features like boundary, elevationGrid, direction, advisory."""
project = Project()

centerline = Polyline(id="1")
centerline.line_type = LineType.CENTERLINE
centerline.points = [(0, 0), (100, 0)]
project.polylines.append(centerline)

road = Road(id="1")
road.centerline_id = centerline.id
road.polyline_ids = [centerline.id]

# Add a lane with direction and advisory
from orbit.models.lane import Lane
from orbit.models.lane import LaneType as ModelLaneType
from orbit.models.lane_section import LaneSection
lane_center = Lane(id=0, lane_type=ModelLaneType.NONE)
lane = Lane(id=1, lane_type=ModelLaneType.DRIVING)
lane.direction = "samedir"
lane.advisory = "advisory1"
road.left_count = 1
road.right_count = 0
road.lane_sections = [LaneSection(section_number=1, s_start=0.0, s_end=100.0, lanes=[lane_center, lane])]
project.roads.append(road)

road2 = Road(id="2", junction_id="1")
road2.centerline_id = centerline.id
road2.polyline_ids = [centerline.id]
project.roads.append(road2)

# Add a junction with boundary and elevation_grid
junction = Junction(id="1")
junction.name = "TestJunction"
junction.connected_road_ids = ['1', '2']
junction.lane_connections = [
LaneConnection(from_road_id='1', to_road_id='2', from_lane_id=-1, to_lane_id=-1, connecting_road_id='2')
]
junction.boundary = JunctionBoundary(
segments=[JunctionBoundarySegment(segment_type='lane', road_id='1')]
)
junction.elevation_grid = JunctionElevationGrid(
elevations=[JunctionElevationGridPoint(center="1")]
)
project.junctions.append(junction)

output_path_1_4 = tmp_path / "export_1_4.xodr"
output_path_1_8 = tmp_path / "export_1_8.xodr"

# Export 1.8
result_1_8 = export_to_opendrive(
project,
mock_transformer,
str(output_path_1_8),
opendrive_version="1.8"
)
assert result_1_8 is True

# Export 1.4
result_1_4 = export_to_opendrive(
project,
mock_transformer,
str(output_path_1_4),
opendrive_version="1.4"
)
assert result_1_4 is True

import xml.etree.ElementTree as ET

# Parse 1.8 and assert features exist
tree_1_8 = ET.parse(output_path_1_8)
root_1_8 = tree_1_8.getroot()
ns = {"od": "http://code.asam.net/simulation/standard/opendrive_schema"}

# In 1.8, namespace is used
header_1_8 = root_1_8.find('od:header', ns)
assert header_1_8.attrib['revMinor'] == '8'

junction_1_8 = root_1_8.find('.//od:junction', ns)
assert junction_1_8 is not None
assert junction_1_8.find('od:boundary', ns) is not None
assert junction_1_8.find('od:elevationGrid', ns) is not None

lane_1_8 = root_1_8.find('.//od:lane', ns)
assert lane_1_8 is not None
assert lane_1_8.get('direction') == 'samedir'
assert lane_1_8.get('advisory') == 'advisory1'

# Parse 1.4 and assert features are suppressed
tree_1_4 = ET.parse(output_path_1_4)
root_1_4 = tree_1_4.getroot()

# In 1.4, no namespace is used
header_1_4 = root_1_4.find('header')
assert header_1_4.attrib['revMinor'] == '4'

junction_1_4 = root_1_4.find('.//junction')
assert junction_1_4 is not None
assert junction_1_4.find('boundary') is None
assert junction_1_4.find('elevationGrid') is None

lane_1_4 = root_1_4.find('.//lane')
assert lane_1_4 is not None
assert lane_1_4.get('direction') is None
assert lane_1_4.get('advisory') is None
Comment on lines +1018 to +1039
Copy link

Copilot AI Apr 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test relies on the first .//od:lane match being the left lane with direction/advisory set (i.e., it depends on element ordering). To make the test robust to any future XML ordering/refactors, consider selecting the specific lane by id (e.g., the lane with id="1") before asserting on direction/advisory, and likewise for the 1.4 document.

Copilot uses AI. Check for mistakes.


class TestValidateOpendrive:
"""Tests for validate_opendrive_file function."""
Expand Down Expand Up @@ -1523,3 +1635,4 @@ def test_remapped_ids_consistent_across_elements(self, mock_transformer, tmp_pat
assert ref_id in all_road_ids, (
f"CR link {tag} references road {ref_id} not in {all_road_ids}"
)

Loading