From 6f9d023c1e999dfc37ea2013ec0276afa1f42077 Mon Sep 17 00:00:00 2001 From: Bastian Havers-Zulka Date: Wed, 1 Apr 2026 14:36:05 +0200 Subject: [PATCH 1/3] implement first version of 1.4 export --- orbit/export/lane_builder.py | 13 ++- orbit/export/opendrive_writer.py | 27 +++-- orbit/gui/dialogs/export_dialog.py | 8 ++ .../unit/test_export/test_opendrive_writer.py | 108 +++++++++++++++++- 4 files changed, 142 insertions(+), 14 deletions(-) diff --git a/orbit/export/lane_builder.py b/orbit/export/lane_builder.py index 2330115..65abb35 100644 --- a/orbit/export/lane_builder.py +++ b/orbit/export/lane_builder.py @@ -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, @@ -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 diff --git a/orbit/export/opendrive_writer.py b/orbit/export/opendrive_writer.py index 31e73a0..34e7ede 100644 --- a/orbit/export/opendrive_writer.py +++ b/orbit/export/opendrive_writer.py @@ -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 @@ -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, @@ -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, @@ -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 @@ -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, @@ -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') # Add header header = self._create_header() @@ -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' @@ -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') @@ -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) @@ -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, @@ -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) diff --git a/orbit/gui/dialogs/export_dialog.py b/orbit/gui/dialogs/export_dialog.py index 42a0b1a..3c25d8f 100644 --- a/orbit/gui/dialogs/export_dialog.py +++ b/orbit/gui/dialogs/export_dialog.py @@ -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) @@ -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, diff --git a/tests/unit/test_export/test_opendrive_writer.py b/tests/unit/test_export/test_opendrive_writer.py index 0137d36..6483f06 100644 --- a/tests/unit/test_export/test_opendrive_writer.py +++ b/tests/unit/test_export/test_opendrive_writer.py @@ -8,7 +8,7 @@ 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 LaneConnection, JunctionBoundary, JunctionBoundarySegment, JunctionElevationGrid, JunctionElevationGridPoint from orbit.models.lane import Lane from orbit.models.lane import LaneType as ModelLaneType from orbit.models.road import RoadType @@ -926,6 +926,111 @@ 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, 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 + class TestValidateOpendrive: """Tests for validate_opendrive_file function.""" @@ -1523,3 +1628,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}" ) + From 3ee41d17f484e5ba60c8b0c00de9913f94959a65 Mon Sep 17 00:00:00 2001 From: Bastian Havers-Zulka Date: Wed, 1 Apr 2026 14:55:49 +0200 Subject: [PATCH 2/3] lint --- tests/unit/test_export/test_opendrive_writer.py | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/tests/unit/test_export/test_opendrive_writer.py b/tests/unit/test_export/test_opendrive_writer.py index 6483f06..20a4066 100644 --- a/tests/unit/test_export/test_opendrive_writer.py +++ b/tests/unit/test_export/test_opendrive_writer.py @@ -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, JunctionBoundary, JunctionBoundarySegment, JunctionElevationGrid, JunctionElevationGridPoint +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 @@ -938,9 +944,10 @@ def test_export_opendrive_1_4_suppresses_1_8_features(self, mock_transformer, tm 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, LaneType as ModelLaneType + 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) @@ -998,7 +1005,7 @@ def test_export_opendrive_1_4_suppresses_1_8_features(self, mock_transformer, tm 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' @@ -1016,7 +1023,7 @@ def test_export_opendrive_1_4_suppresses_1_8_features(self, mock_transformer, tm # 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' From 057c94571d7f217c3316fbeedc39b3171dc958bb Mon Sep 17 00:00:00 2001 From: Bastian Havers-Zulka Date: Tue, 21 Apr 2026 14:47:39 +0200 Subject: [PATCH 3/3] Fix TypeError in lane_builder when width_end is None The _create_lane method in lane_builder.py crashed with: TypeError: unsupported operand type(s) for -: 'NoneType' and 'float' The condition 'lane_obj.has_variable_width' can be True when only polynomial coefficients (width_b/c/d) are non-zero but width_end is None. In that case, the line: width_b = (lane_obj.width_end - lane_obj.width) / section_length_m attempts None - float, causing the crash. Fix: check 'lane_obj.width_end is not None' instead, matching the safe guard already used in opendrive_writer._create_connecting_lane_element. When width_end is None but polynomial coefficients are set, they are now correctly preserved as-is. Also adds the missing width_end=None to test mocks. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- orbit/export/lane_builder.py | 8 +++----- tests/unit/test_export/test_lane_builder.py | 6 ++++-- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/orbit/export/lane_builder.py b/orbit/export/lane_builder.py index 65abb35..f42017e 100644 --- a/orbit/export/lane_builder.py +++ b/orbit/export/lane_builder.py @@ -276,13 +276,11 @@ def _create_lane( width_d = lane_obj.width_d # Override with linear transition if width_end is specified - if lane_obj.has_variable_width and section_length_m > 0: + if lane_obj.width_end is not None and section_length_m > 0: width_a = lane_obj.width width_b = (lane_obj.width_end - lane_obj.width) / section_length_m - # Keep c and d as 0 for linear transition (unless already set) - if width_c == 0.0 and width_d == 0.0: - width_c = 0.0 - width_d = 0.0 + width_c = 0.0 + width_d = 0.0 width_elem = etree.SubElement(lane, 'width') width_elem.set('sOffset', '0.0') diff --git a/tests/unit/test_export/test_lane_builder.py b/tests/unit/test_export/test_lane_builder.py index b22a372..bc4d5d5 100644 --- a/tests/unit/test_export/test_lane_builder.py +++ b/tests/unit/test_export/test_lane_builder.py @@ -125,6 +125,7 @@ def mock_road(self): right_lane.predecessor_id = None right_lane.successor_id = None right_lane.width = 3.5 + right_lane.width_end = None right_lane.width_b = 0.0 right_lane.width_c = 0.0 right_lane.width_d = 0.0 @@ -285,7 +286,7 @@ def test_left_lanes_sorted(self, builder): lane1 = Mock(id=2, lane_type=LaneType.DRIVING, level=False, direction=None, advisory=None, predecessor_id=None, successor_id=None, - width=3.5, width_b=0.0, width_c=0.0, width_d=0.0, + width=3.5, width_end=None, width_b=0.0, width_c=0.0, width_d=0.0, has_variable_width=False, road_mark_type=RoadMarkType.SOLID, road_mark_weight='standard', road_mark_color='white', @@ -294,7 +295,7 @@ def test_left_lanes_sorted(self, builder): lane2 = Mock(id=1, lane_type=LaneType.DRIVING, level=False, direction=None, advisory=None, predecessor_id=None, successor_id=None, - width=3.5, width_b=0.0, width_c=0.0, width_d=0.0, + width=3.5, width_end=None, width_b=0.0, width_c=0.0, width_d=0.0, has_variable_width=False, road_mark_type=RoadMarkType.SOLID, road_mark_weight='standard', road_mark_color='white', @@ -391,6 +392,7 @@ def mock_lane(self): lane.predecessor_id = None lane.successor_id = None lane.width = 3.5 + lane.width_end = None lane.width_b = 0.0 lane.width_c = 0.0 lane.width_d = 0.0