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
21 changes: 11 additions & 10 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 Expand Up @@ -273,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')
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')

# 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
6 changes: 4 additions & 2 deletions tests/unit/test_export/test_lane_builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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',
Expand All @@ -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',
Expand Down Expand Up @@ -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
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


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}"
)