diff --git a/docs/Screenshot_lane_connections_dialog.png b/docs/Screenshot_lane_connections_dialog.png new file mode 100644 index 0000000..f3eba58 Binary files /dev/null and b/docs/Screenshot_lane_connections_dialog.png differ diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md index 7489b12..4467c53 100644 --- a/docs/USER_GUIDE.md +++ b/docs/USER_GUIDE.md @@ -2,7 +2,7 @@ Complete guide for creating OpenDRIVE road networks from aerial imagery using ORBIT. -**Version**: 0.6.0 | **OpenDRIVE**: 1.8 +**Version**: 0.13.0 | **OpenDRIVE**: 1.8 --- @@ -29,6 +29,24 @@ Complete guide for creating OpenDRIVE road networks from aerial imagery using OR - [Troubleshooting](#troubleshooting) - [Best Practices](#best-practices) +### How Do I...? + +| Task | See | +|------|-----| +| Trace a road on the image | [Drawing Polylines](#drawing-polylines) | +| Turn polylines into a road with lanes | [Creating Roads](#creating-roads) | +| Change lane count / width for a whole road | [Creating Roads → Road Properties](#road-properties) | +| Change lanes along part of a road | [Lane Sections](#lane-sections) | +| Add a turn lane / model a lane split or merge | [Adding and Removing Lanes](#adding-and-removing-lanes) | +| Connect roads at an intersection | [Junctions](#junctions), [Connecting Roads](#connecting-roads) | +| Define which lane leads where in a junction | [Lane Connections](#lane-connections) | +| Build a roundabout | [Junctions → Roundabout Wizard](#roundabout-wizard) | +| Place traffic signs, poles, buildings | [Signals & Objects](#signals--objects) | +| Map pixels to real-world coordinates | [Georeferencing](#georeferencing) | +| Start from OpenStreetMap data | [Import Features](#import-features) | +| Produce the .xodr file | [Export to OpenDRIVE](#export-to-opendrive) | +| Fix an export or drawing problem | [Troubleshooting](#troubleshooting) | + --- ## Getting Started @@ -76,12 +94,12 @@ Load Image → Draw Polylines → Create Roads → Add Junctions → Georeferenc ### Quick Start Example 1. **Load image**: `orbit intersection.jpg` or File → Load Image -2. **Draw centerline**: Click "New Line", trace road center, double-click to finish +2. **Draw centerline**: Click "New Polyline", trace road center, double-click to finish 3. **Set as centerline**: Double-click polyline, change Line Type to "Centerline" 4. **Draw boundaries**: Trace lane markings with appropriate road mark types 5. **Create road**: Select polylines, press Ctrl+G to group into road -6. **Add control points**: Tools → Georeferencing (minimum 3 points) -7. **Export**: File → Export to OpenDrive +6. **Add control points**: Georeferencing → Control Points (minimum 3 points) +7. **Export**: File → Export → Export to OpenDrive --- @@ -103,9 +121,9 @@ New polylines default to **Lane Boundary**. Change type via double-click. ### Drawing Operations **Start Drawing**: -- Click "New Line" button (right sidebar) +- Click "New Polyline" in the toolbar - Or press **Ctrl+P** -- Or menu: Tools → New Polyline +- Or menu: Draw → New Polyline **Add Points**: - Click on image to add points @@ -150,7 +168,7 @@ Roads group polylines and define lane configuration. ### Method 1: Quick Creation 1. Select a polyline by clicking on it -2. Press **Ctrl+G** (or Tools → Group to Road) +2. Press **Ctrl+G** (or Roads → Group to Road) 3. Fill in road properties 4. Click OK @@ -163,6 +181,8 @@ Roads group polylines and define lane configuration. ### Road Properties +![Road properties dialog](screenshot_road_properties_dialog.png) + **Basic Properties**: - **Road Name**: Descriptive name (e.g., "Main Street") - **Road Type**: ASAM OpenDRIVE road type (motorway, town, rural, etc.) @@ -200,8 +220,18 @@ The Roads panel shows a hierarchical tree: ## Lane Sections +![Road Tree with sections and context menu](screenshot_road_tree_sections.png) + Roads can be divided into sections where lane configuration changes. +Section commands live in three places: + +| Right-click on... | Menu items | +|---|---| +| A **centerline point** on the canvas | Split Section Here, Split Road Here, Delete Point | +| A **section** in the Road Tree | Edit Section Properties, Add Lane..., Delete Section (shown only when the road has more than one section) | +| A **lane** in the Road Tree | Edit Lane Properties, Remove Lane | + ### Creating Sections 1. Right-click on a centerline point @@ -226,12 +256,102 @@ Two or more consecutive sections with the same lane IDs can be merged back into > **Note**: Merging resets the width polynomial to linear. If sections had cubic width polynomials those are simplified. +### Adding and Removing Lanes + +![Add lane dialog](screenshot_add_lane_dialog.png) + +A lane can be added to or removed from a single section — this is how lane +splits, merges, and turn pockets are modeled: + +1. In the Road Tree, expand the road and right-click a section → **Add Lane...** +2. Choose the side (left/right) and insert position (innermost = next to the + center lane) +3. Set lane type and road mark +4. Set width: equal start/end values give a constant width; different values + give a linear taper (e.g. 0 → 3.5 m for a lane that appears) + +Outward lanes on the same side are renumbered automatically, and lane +links and junction lane connections are updated to follow the renumbered +lanes. Lanes of type **median** are drawn with a diagonal hatch pattern on +the canvas (in the side color) so painted separation areas stand out from +driving lanes. To remove a lane, right-click it in the Road Tree → **Remove Lane** +(junction connections referencing the lane are deleted; undo restores them). + +> **Warning**: Changing the road-level lane counts in Road Properties +> regenerates all lanes and replaces custom sections with a single uniform +> section. A confirmation is shown when sections would be lost. + +### Example: Turn Lane at a Junction (Lane Split) + +To model a turn pocket that tapers out of a through lane before a junction: + +1. Split the section twice on the approach: right-click a centerline point at + the start of the taper → **Split Section Here**, and again where the pocket + reaches full width. This gives three sections: approach / taper / pocket. +2. **Taper section** → Add Lane...: travel side, innermost position, driving, + width 0 → 3.5 m. +3. **Pocket section** → Add Lane...: same position, constant 3.5 m. +4. Open the junction's **Edit Lane Connections** and add the turn movement + from the new pocket lane (the existing connections keep following the + renumbered through lane automatically). + +For a merge lane after the junction, mirror the pattern with a +3.5 → 0 m taper. + +### Example: Approach Splitting into Left/Right Turn Lanes + +Where a single-lane approach to a T-junction widens into a dedicated +left-turn lane and right-turn lane: + +1. Split the approach section twice: where both lanes reach full width and + where the widening begins. This gives full-width / taper / single-lane + sections. +2. **Full-width section** → Add Lane...: approach side, **innermost** + position, driving, constant width. The innermost lane is the leftmost + lane in the direction of travel, so it becomes the left-turn lane; the + original lane is renumbered outward and becomes the right-turn lane. +3. **Taper section** → Add Lane...: same position, tapering to 0 toward the + single-lane section. Width start/end follow the road's s-direction: if + the junction is at the road's *start*, the taper is 3.5 → 0 m; if at the + *end*, 0 → 3.5 m. +4. Open **Edit Lane Connections**: the original lane's movements were + remapped to the renumbered (outer) lane. Move the left-turn connection to + the new innermost lane and keep the right turn on the outer lane. + ### Section Properties - **Section Number**: Sequential numbering (1, 2, 3...) - **s_start / s_end**: Position along centerline - **singleSide**: OpenDRIVE attribute (left, right, or none) +### Lane Properties + +![Lane properties dialog](screenshot_lane_properties_dialog.png) + +Double-click a lane in the Road Tree (or right-click → **Edit Lane +Properties**) to edit an individual lane within a section: + +- **Lane Type**: driving, biking, sidewalk, shoulder, parking, etc. +- **Road Mark**: type, color, weight, and mark width +- **Width**: constant, or check **Variable width** for a linear start → end + taper; advanced polynomial coefficients (b, c, d) are available under + Advanced +- **Speed**: per-lane speed limit +- **Access**: bicycle/pedestrian access restrictions +- **Outer Boundary**: bind the lane's outer edge to a drawn polyline + (explicit boundary) instead of the width-based offset +- **OpenDRIVE 1.8**: direction, advisory, level flag, and explicit + predecessor/successor lane links (0 = none) +- **Junction Connections**: every junction movement that starts or ends in + this lane, with editable connecting road and connecting-road lane — a + quick alternative to the junction's full lane connection editor (which is + still needed to add or remove movements) + +For **connecting road lanes** (double-click a lane inside a junction), the +dialog instead shows a **Road Attachments** group: the incoming and outgoing +road lanes this connecting-road lane links, each editable — pick a different +lane to reattach the movement, e.g. onto a newly added turn pocket. + --- ## Junctions @@ -240,7 +360,7 @@ Junctions handle intersections where multiple roads meet. ### Creating a Junction -1. Press **Ctrl+J** or Tools → Add Junction +1. Press **Ctrl+J** or Roads → Add Junction 2. Click on the map where the intersection is located 3. Fill in junction properties: - **Junction Name**: e.g., "Main & Oak Intersection" @@ -264,10 +384,10 @@ Junctions handle intersections where multiple roads meet. ![Roundabout wizard dialog](screenshot_roundabout_wizard.png) -Create roundabouts via Tools → Create Roundabout (**Ctrl+Shift+R**): +Create roundabouts via Roads → Create Roundabout (**Ctrl+Shift+R**): - **Center Point**: Click "Pick on Map" or enter coordinates -- **Radius**: Inner and outer radius +- **Radius**: Ring radius (enter or "Pick Radius from Map") and ring resolution - **Lanes**: Number of circular lanes - **Traffic Direction**: Clockwise or counter-clockwise - **Approach Roads**: Select connecting roads @@ -322,6 +442,8 @@ Junction groups combine multiple junctions into a logical unit, as defined in Op Lane connections define the lane-level mappings through junctions — which incoming lane connects to which outgoing lane. +![Lane connections dialog](screenshot_lane_connections_dialog.png) + ### Editing Lane Connections 1. Right-click a junction in the Elements tree → **Edit Lane Connections** @@ -342,7 +464,7 @@ Lane connections define the lane-level mappings through junctions — which inco Place traffic signs from country-specific sign libraries. **Adding a Signal**: -1. Press **Ctrl+T** or Tools → Add Signal +1. Press **Ctrl+T** or Draw → Add Signal 2. Click on the map to place the signal 3. In the signal dialog: - Select from the **sign library** (organized by category) @@ -359,7 +481,7 @@ Place traffic signs from country-specific sign libraries. Place physical objects along roads. **Adding an Object**: -1. Press **Ctrl+Alt+O** or Tools → Add Object +1. Press **Ctrl+Alt+O** or Draw → Add Object 2. Click on the map to place the object 3. Select object type: - Lamppost @@ -379,7 +501,7 @@ Place physical objects along roads. Draw polygon areas representing land use and natural features. **Adding a Land Use Area**: -1. Press **Ctrl+Shift+L** or Tools → Add Land Use +1. Press **Ctrl+Shift+L** or Draw → Add Land Use 2. A dialog asks for the land use type: - Forest, Farmland, Meadow / Grass, Scrub / Heath, Water, Wetland 3. Click on the map to place polygon vertices; double-click or press **Enter** to finish @@ -397,7 +519,7 @@ Draw polygon areas representing land use and natural features. ### Adding Parking -1. Press **Ctrl+Shift+P** or Tools → Add Parking +1. Press **Ctrl+Shift+P** or Draw → Add Parking 2. Choose drawing mode: - **Single space**: Click to place an individual parking space - **Polygon area**: Multi-click to draw a parking lot outline, double-click to finish @@ -436,7 +558,7 @@ Georeferencing converts pixel coordinates to real-world geographic coordinates. ### Adding Control Points -1. Go to **Tools → Georeferencing** (Ctrl+Shift+G) +1. Go to **Georeferencing → Control Points** (Ctrl+Shift+G) 2. Click "Pick Point on Image" 3. Click on a distinctive feature 4. Enter latitude/longitude coordinates @@ -494,7 +616,7 @@ Import road networks from OSM via Overpass API. - At least 3 control points set up **Process**: -1. File → Import OpenStreetMap Data (Ctrl+Shift+I) +1. File → Import → Import OpenStreetMap Data (Ctrl+Shift+I) 2. Configure options: - Import Mode: Add or Replace - Detail Level: Moderate or Full @@ -510,7 +632,7 @@ See the [OSM Import Guide](OSM_IMPORT.md) for details. Import existing .xodr files for round-trip editing: -1. File → Import OpenDRIVE (Ctrl+Shift+O) +1. File → Import → Import from OpenDrive (Ctrl+Shift+O) 2. Select .xodr file 3. Review the **import dialog** with options and preview 4. Check the import report for: @@ -526,27 +648,33 @@ Import existing .xodr files for round-trip editing: ### Export Process -1. Press **Ctrl+E** or File → Export to OpenDrive +1. Press **Ctrl+E** or File → Export → Export to OpenDrive 2. Review the Export Dialog: - Project Summary: counts of elements - Georeferencing Status: Active (green) required - Transformation Info: control points, RMS error, scale 3. Set export options: - - **Preserve Geometry**: Keep all polyline points (default) - - **Curve Fitting**: Enable line/arc fitting with configurable tolerances: - - **Line tolerance**: Maximum deviation for line segments (meters) - - **Arc tolerance**: Maximum deviation for arc segments (meters) - - **Clothoid tolerance**: Maximum deviation for clothoid segments (meters) - - **Enable Clothoids**: Toggle clothoid (Euler spiral) fitting - - **XSD Validation**: Toggle schema validation against ASAM XSD (requires `--xodr_schema` flag) + - **Preserve Geometry**: Keep all polyline points (default). Uncheck to + enable curve fitting with **Line/Arc Tolerance** (maximum deviation in + meters) + - **Projection**: UTM, Transverse Mercator, or Preserved + - **Origin Point**: Control point or custom latitude/longitude for the + coordinate origin + - **Signal Codes**: Optionally use German (DE) equivalent codes for signals + - **Compatibility**: CARLA compatibility mode (OpenDRIVE 1.4) + - **Feature Categories**: Select which object types to include (shown when + the project contains objects) 4. Click "Browse" to select output location 5. Click "Export" +> **Note**: XSD schema validation runs automatically when ORBIT was launched +> with the `--xodr_schema` flag — there is no toggle in the dialog. + ### Export Georeferencing Export georeferencing parameters separately: -1. File → Export Georeferencing +1. File → Export → Export Georeferencing 2. Select output location for the JSON file 3. The export includes: - Control points (pixel and geographic coordinates) @@ -583,7 +711,7 @@ Export a semantic segmentation mask where each pixel value identifies a lane reg ### Access -File menu → Export Layout Mask... +File → Export → Export Layout Mask... ### Export Methods @@ -734,7 +862,7 @@ Access via **Edit → Preferences**. - Try absolute path **Can't draw polylines** -- Ensure "New Line" mode is active +- Ensure "New Polyline" mode is active (toolbar button toggled) - Check that image is loaded **Can't export** @@ -795,4 +923,4 @@ Access via **Edit → Preferences**. --- -**Last Updated**: 2026-02 +**Last Updated**: 2026-07 diff --git a/docs/screenshot_add_lane_dialog.png b/docs/screenshot_add_lane_dialog.png new file mode 100644 index 0000000..99fd58e Binary files /dev/null and b/docs/screenshot_add_lane_dialog.png differ diff --git a/docs/screenshot_drawing_mode.png b/docs/screenshot_drawing_mode.png index f5b6e90..7962e55 100644 Binary files a/docs/screenshot_drawing_mode.png and b/docs/screenshot_drawing_mode.png differ diff --git a/docs/screenshot_export_dialog.png b/docs/screenshot_export_dialog.png index 54aca79..e6fab11 100644 Binary files a/docs/screenshot_export_dialog.png and b/docs/screenshot_export_dialog.png differ diff --git a/docs/screenshot_lane_properties_dialog.png b/docs/screenshot_lane_properties_dialog.png new file mode 100644 index 0000000..dd0a822 Binary files /dev/null and b/docs/screenshot_lane_properties_dialog.png differ diff --git a/docs/screenshot_road_properties_dialog.png b/docs/screenshot_road_properties_dialog.png new file mode 100644 index 0000000..bb9a5ee Binary files /dev/null and b/docs/screenshot_road_properties_dialog.png differ diff --git a/docs/screenshot_road_view_sections.png b/docs/screenshot_road_view_sections.png new file mode 100644 index 0000000..37470fd Binary files /dev/null and b/docs/screenshot_road_view_sections.png differ diff --git a/orbit/export/lane_builder.py b/orbit/export/lane_builder.py index 24ada3f..a87c20e 100644 --- a/orbit/export/lane_builder.py +++ b/orbit/export/lane_builder.py @@ -13,6 +13,13 @@ from .lane_analyzer import BoundaryInfo +def _same_lane_id_set(section_a, section_b) -> bool: + """Whether two sections have identical non-center lane IDs.""" + ids_a = {lane.id for lane in section_a.lanes if lane.id != 0} + ids_b = {lane.id for lane in section_b.lanes if lane.id != 0} + return ids_a == ids_b + + def convert_road_mark_type(road_mark_type: RoadMarkType) -> str: """ Convert ORBIT RoadMarkType enum to OpenDRIVE road mark type string. @@ -148,9 +155,20 @@ def _create_section_based_lanes( # OpenDRIVE requires elements in order: left, center, right # Only add left/right if there are lanes - # First section needs predecessor lane links, last needs successor - add_pred = (idx == 0) and has_road_predecessor - add_succ = (idx == num_sections - 1) and has_road_successor + # Default same-ID links apply at road boundaries and at interior + # section boundaries where both sections have identical lane IDs. + # Where lane IDs differ (lane split/merge), only explicit links + # set on the Lane objects are emitted. + prev_sec = road.lane_sections[idx - 1] if idx > 0 else None + next_sec = road.lane_sections[idx + 1] if idx < num_sections - 1 else None + add_pred = ( + ((idx == 0) and has_road_predecessor) + or (prev_sec is not None and _same_lane_id_set(prev_sec, section)) + ) + add_succ = ( + ((idx == num_sections - 1) and has_road_successor) + or (next_sec is not None and _same_lane_id_set(section, next_sec)) + ) # Left lanes (positive IDs) left_lanes = [lane for lane in section.lanes if lane.id > 0] @@ -258,10 +276,11 @@ def _create_lane( pred_id = lane_obj.predecessor_id succ_id = lane_obj.successor_id - # Default to same lane ID for road-to-road connections - if pred_id is None and add_pred_link: + # Default to same lane ID for road-to-road connections, + # unless the lane explicitly has no link (e.g. appearing/merging pocket lane) + if pred_id is None and add_pred_link and not lane_obj.no_predecessor: pred_id = lane_obj.id - if succ_id is None and add_succ_link: + if succ_id is None and add_succ_link and not lane_obj.no_successor: succ_id = lane_obj.id link = etree.SubElement(lane, 'link') diff --git a/orbit/gui/dialogs/__init__.py b/orbit/gui/dialogs/__init__.py index 570cdae..2fd16ff 100644 --- a/orbit/gui/dialogs/__init__.py +++ b/orbit/gui/dialogs/__init__.py @@ -2,6 +2,7 @@ Dialog components for ORBIT GUI. """ +from .add_lane_dialog import AddLaneDialog from .base_dialog import BaseDialog from .batch_delete_dialog import BatchDeleteDialog from .connecting_road_dialog import ConnectingRoadDialog @@ -58,4 +59,5 @@ 'JunctionGroupDialog', 'BatchDeleteDialog', 'RoadLaneLinksDialog', + 'AddLaneDialog', ] diff --git a/orbit/gui/dialogs/add_lane_dialog.py b/orbit/gui/dialogs/add_lane_dialog.py new file mode 100644 index 0000000..a024ea8 --- /dev/null +++ b/orbit/gui/dialogs/add_lane_dialog.py @@ -0,0 +1,128 @@ +""" +Add lane dialog for ORBIT. + +Collects the position and properties for a lane inserted into one section. +""" + +from typing import Optional + +from PyQt6.QtWidgets import QComboBox, QDialog, QDoubleSpinBox, QLabel + +from orbit.models import Lane, LaneSection, LaneType, Road, RoadMarkType + +from .base_dialog import BaseDialog + + +class AddLaneDialog(BaseDialog): + """Dialog for adding a lane to a single lane section.""" + + def __init__(self, section: LaneSection, road: Road, parent=None): + super().__init__( + f"Add Lane to Section {section.section_number}", parent, min_width=400) + self.section = section + self.road = road + self.setup_ui() + + def setup_ui(self): + """Setup the dialog UI.""" + position_layout = self.add_form_group("Position") + + self.side_combo = QComboBox() + if self.section.single_side != "right": + self.side_combo.addItem("Left", 1) + if self.section.single_side != "left": + self.side_combo.addItem("Right", -1) + self.side_combo.currentIndexChanged.connect(self._rebuild_positions) + position_layout.addRow("Side:", self.side_combo) + + self.position_combo = QComboBox() + position_layout.addRow("Insert at:", self.position_combo) + self._rebuild_positions() + + props_layout = self.add_form_group("Lane Properties") + + self.lane_type_combo = QComboBox() + for lane_type in LaneType: + self.lane_type_combo.addItem(lane_type.value, lane_type) + self.lane_type_combo.setCurrentText(LaneType.DRIVING.value) + props_layout.addRow("Lane Type:", self.lane_type_combo) + + self.road_mark_combo = QComboBox() + for mark_type in RoadMarkType: + self.road_mark_combo.addItem(mark_type.value, mark_type) + self.road_mark_combo.setCurrentText(RoadMarkType.BROKEN.value) + props_layout.addRow("Road Mark:", self.road_mark_combo) + + width_layout = self.add_form_group_with_info( + "Width", + "Use different start/end widths for a taper: 0 → 3.5 m for a lane " + "that appears (split), 3.5 → 0 m for a lane that merges." + ) + self.width_start_spin = self._make_width_spin(3.5) + width_layout.addRow("Width at Start:", self.width_start_spin) + self.width_end_spin = self._make_width_spin(3.5) + width_layout.addRow("Width at End:", self.width_end_spin) + + lanes_on_side = self._lane_ids_on_side() + info = QLabel(f"Section has {len(lanes_on_side)} lane(s) on the selected side. " + "Outward lanes are renumbered; lane links and junction " + "connections are updated automatically.") + info.setWordWrap(True) + self.get_main_layout().addWidget(info) + + self.create_button_box() + + @staticmethod + def _make_width_spin(value: float) -> QDoubleSpinBox: + """Create a width spinbox in meters.""" + spin = QDoubleSpinBox() + spin.setRange(0.0, 20.0) + spin.setSingleStep(0.1) + spin.setDecimals(2) + spin.setSuffix(" m") + spin.setValue(value) + return spin + + def _lane_ids_on_side(self): + """Absolute lane IDs on the currently selected side.""" + sign = self.side_combo.currentData() or 1 + return sorted(abs(lane.id) for lane in self.section.lanes + if lane.id != 0 and (lane.id > 0) == (sign > 0)) + + def _rebuild_positions(self): + """Rebuild the insert-position combo for the selected side.""" + sign = self.side_combo.currentData() or 1 + count = len(self._lane_ids_on_side()) + self.position_combo.clear() + for pos in range(1, count + 2): + if pos == 1: + label = f"Innermost (lane {sign * pos})" + elif pos == count + 1: + label = f"Outermost (lane {sign * pos})" + else: + label = f"Lane {sign * pos}" + self.position_combo.addItem(label, sign * pos) + + def get_spec(self) -> dict: + """Build the lane spec from current form values.""" + width_start = self.width_start_spin.value() + width_end = self.width_end_spin.value() + lane = Lane( + id=0, # Assigned by Road.insert_lane_in_section + lane_type=self.lane_type_combo.currentData(), + road_mark_type=self.road_mark_combo.currentData(), + width=width_start, + width_end=width_end if width_end != width_start else None, + ) + return { + 'new_lane_id': self.position_combo.currentData(), + 'lane': lane, + } + + @classmethod + def get_lane_spec(cls, section: LaneSection, road: Road, parent=None) -> Optional[dict]: + """Show the dialog; returns {'new_lane_id', 'lane'} or None if cancelled.""" + dialog = cls(section, road, parent) + if dialog.exec() == QDialog.DialogCode.Accepted: + return dialog.get_spec() + return None diff --git a/orbit/gui/dialogs/lane_properties_dialog.py b/orbit/gui/dialogs/lane_properties_dialog.py index 23ccc34..657f2e5 100644 --- a/orbit/gui/dialogs/lane_properties_dialog.py +++ b/orbit/gui/dialogs/lane_properties_dialog.py @@ -50,6 +50,8 @@ def __init__(self, lane: Lane, project: Optional[Project] = None, road_id: Optio self.project = project self.road_id = road_id self.connecting_road = connecting_road # For connecting road lanes + # True after accept() if junction connection rows were modified + self.junction_connections_changed = False self.setup_ui() self.load_properties() @@ -75,6 +77,10 @@ def setup_ui(self): self._setup_advanced_section() if self.project and self.road_id: self._create_boundary_section() + if self.project and self.road_id and self.connecting_road is None: + self._create_junction_connections_section() + if self.project and self.connecting_road is not None: + self._create_cr_attachment_section() self.create_button_box() self.update_description() @@ -251,6 +257,143 @@ def _create_boundary_section(self): self.outer_boundary_combo.currentIndexChanged.connect(self._update_fit_button_state) self._update_fit_button_state() + def _create_junction_connections_section(self): + """List this lane's junction movements with editable connecting road/lane.""" + self._connection_rows = [] + connections = self._find_lane_connections() + if not connections: + return + layout = self.add_form_group_with_info( + "Junction Connections", + "Movements through junctions that start or end in this lane. " + "Choose which connecting road and connecting-road lane carries each " + "movement. To add or remove movements, use Edit Junction Lane " + "Connections on the road." + ) + for junction, lane_connection in connections: + self._add_connection_row(layout, junction, lane_connection) + + def _find_lane_connections(self): + """Junction lane connections where this lane is the source or target.""" + result = [] + for junction in self.project.junctions: + for lc in junction.lane_connections: + if ((lc.from_road_id == self.road_id and lc.from_lane_id == self.lane.id) + or (lc.to_road_id == self.road_id and lc.to_lane_id == self.lane.id)): + result.append((junction, lc)) + return result + + def _add_connection_row(self, layout, junction, lane_connection): + """Add one editable row: connecting road combo + connecting lane combo.""" + outgoing = (lane_connection.from_road_id == self.road_id + and lane_connection.from_lane_id == self.lane.id) + other_road_id = (lane_connection.to_road_id if outgoing + else lane_connection.from_road_id) + other_road = self.project.get_road(other_road_id) + other_name = other_road.name if other_road else f"road {other_road_id}" + direction = "to" if outgoing else "from" + label = (f"{lane_connection.get_turn_type_display()} {direction} " + f"{other_name} ({junction.name}):") + + cr_combo = QComboBox() + for cr_id in junction.connecting_road_ids: + cr = self.project.get_road(cr_id) + cr_combo.addItem(f"{cr.name} ({cr_id})" if cr else cr_id, cr_id) + set_combo_by_data(cr_combo, lane_connection.connecting_road_id) + + lane_combo = QComboBox() + self._populate_connecting_lanes(lane_combo, cr_combo.currentData(), + lane_connection.connecting_lane_id) + cr_combo.currentIndexChanged.connect( + lambda _idx, cc=cr_combo, lc_combo=lane_combo: + self._populate_connecting_lanes(lc_combo, cc.currentData(), None)) + + row = QWidget() + row_layout = QHBoxLayout(row) + row_layout.setContentsMargins(0, 0, 0, 0) + row_layout.addWidget(cr_combo, 2) + row_layout.addWidget(QLabel("Lane:")) + row_layout.addWidget(lane_combo, 1) + layout.addRow(label, row) + self._connection_rows.append((lane_connection, cr_combo, lane_combo)) + + def _populate_connecting_lanes(self, combo: QComboBox, cr_id, current_lane_id): + """Fill combo with the connecting road's driving lane IDs.""" + combo.clear() + cr = self.project.get_road(cr_id) if cr_id else None + lane_ids = [] + if cr and cr.lane_sections: + lane_ids = [lane.id for lane in cr.lane_sections[0].lanes if lane.id != 0] + elif cr: + lane_ids = ([i for i in range(1, cr.cr_lane_count_left + 1)] + + [-i for i in range(1, cr.cr_lane_count_right + 1)]) + for lane_id in sorted(lane_ids, reverse=True): + combo.addItem(str(lane_id), lane_id) + if current_lane_id is not None: + set_combo_by_data(combo, current_lane_id) + + def _create_cr_attachment_section(self): + """Editable incoming/outgoing road-lane attachments for a connecting road lane.""" + self._attachment_rows = [] + junction = self.project.get_junction(self.connecting_road.junction_id) + if junction is None: + return + connections = [ + lc for lc in junction.lane_connections + if lc.connecting_road_id == self.connecting_road.id + and lc.connecting_lane_id == self.lane.id + ] + if not connections: + return + layout = self.add_form_group_with_info( + "Road Attachments", + "The incoming and outgoing road lanes this connecting-road lane " + "links. Pick a different lane to reattach the movement." + ) + for lane_connection in connections: + self._add_attachment_row(layout, junction, lane_connection) + + def _add_attachment_row(self, layout, junction, lane_connection): + """Add one row: from-road lane combo → to-road lane combo.""" + from_road = self.project.get_road(lane_connection.from_road_id) + to_road = self.project.get_road(lane_connection.to_road_id) + from_combo = self._road_end_lane_combo( + junction, from_road, lane_connection.from_lane_id) + to_combo = self._road_end_lane_combo( + junction, to_road, lane_connection.to_lane_id) + + row = QWidget() + row_layout = QHBoxLayout(row) + row_layout.setContentsMargins(0, 0, 0, 0) + from_name = from_road.name if from_road else lane_connection.from_road_id + to_name = to_road.name if to_road else lane_connection.to_road_id + row_layout.addWidget(QLabel(f"From {from_name} lane")) + row_layout.addWidget(from_combo) + row_layout.addWidget(QLabel(f"to {to_name} lane")) + row_layout.addWidget(to_combo) + row_layout.addStretch() + layout.addRow(f"{lane_connection.get_turn_type_display()}:", row) + self._attachment_rows.append((lane_connection, from_combo, to_combo)) + + def _road_end_lane_combo(self, junction, road, current_lane_id) -> QComboBox: + """Combo with the road's lane IDs at its junction-adjacent section.""" + combo = QComboBox() + lane_ids = [] + if road is not None and road.lane_sections: + touches_start = self.project._junction_touches_road_start(junction, road) + section = (road.lane_sections[0] if touches_start + else road.lane_sections[-1]) + lane_ids = [lane.id for lane in section.lanes if lane.id != 0] + for lane_id in sorted(lane_ids, reverse=True): + combo.addItem(str(lane_id), lane_id) + if current_lane_id is not None: + set_combo_by_data(combo, current_lane_id) + if combo.currentData() != current_lane_id: + # Keep a stale reference visible instead of silently changing it + combo.insertItem(0, str(current_lane_id), current_lane_id) + combo.setCurrentIndex(0) + return combo + def _populate_boundary_polylines(self, combo: QComboBox): """Populate combo box with boundary polylines from the road.""" if not self.project or not self.road_id: @@ -1007,6 +1150,22 @@ def accept(self): access_restrictions.append('pedestrian') self.lane.access_restrictions = access_restrictions + # Update junction connections (connecting road / connecting lane) + for lane_connection, cr_combo, lane_combo in getattr(self, '_connection_rows', []): + if (lane_connection.connecting_road_id != cr_combo.currentData() + or lane_connection.connecting_lane_id != lane_combo.currentData()): + self.junction_connections_changed = True + lane_connection.connecting_road_id = cr_combo.currentData() + lane_connection.connecting_lane_id = lane_combo.currentData() + + # Update road attachments (connecting road lane → from/to road lanes) + for lane_connection, from_combo, to_combo in getattr(self, '_attachment_rows', []): + if (lane_connection.from_lane_id != from_combo.currentData() + or lane_connection.to_lane_id != to_combo.currentData()): + self.junction_connections_changed = True + lane_connection.from_lane_id = from_combo.currentData() + lane_connection.to_lane_id = to_combo.currentData() + # Update advanced properties (if available - not for connecting road lanes) if hasattr(self, 'width_b_spin'): self.lane.width_b = self.width_b_spin.value() diff --git a/orbit/gui/dialogs/properties_dialog.py b/orbit/gui/dialogs/properties_dialog.py index ff5dea4..b13e141 100644 --- a/orbit/gui/dialogs/properties_dialog.py +++ b/orbit/gui/dialogs/properties_dialog.py @@ -34,6 +34,7 @@ from orbit.utils.logging_config import get_logger from ..utils import set_combo_by_data +from ..utils.message_helpers import ask_yes_no from .base_dialog import InfoIconLabel logger = get_logger(__name__) @@ -778,7 +779,12 @@ def save_data(self): if (old_left_count != new_left_count or old_right_count != new_right_count or old_lane_width != new_lane_width): - self.road.generate_lanes() + if self._confirm_lane_regeneration(): + self.road.generate_lanes() + else: + self.road.lane_info.left_count = old_left_count + self.road.lane_info.right_count = old_right_count + self.road.lane_info.lane_width = old_lane_width # Save profile tables self.road.elevation_profile = self._get_profile_from_table(self.elevation_table) @@ -786,6 +792,19 @@ def save_data(self): self.road.lane_offset = self._get_profile_from_table(self.lane_offset_table) self.road.surface_crg = self._get_crg_from_table() + def _confirm_lane_regeneration(self) -> bool: + """Ask before replacing custom lane sections with one uniform section.""" + num_sections = len(self.road.lane_sections) + if num_sections <= 1: + return True + return ask_yes_no( + self, + f"Changing the lane configuration will regenerate all lanes and " + f"replace this road's {num_sections} lane sections with a single " + f"uniform section.\n\nContinue?", + "Replace Lane Sections?" + ) + def update_centerline_warning(self): """Update the centerline warning based on combo selection.""" if not self.project or not hasattr(self, 'centerline_warning_label'): diff --git a/orbit/gui/graphics/interactive_lane.py b/orbit/gui/graphics/interactive_lane.py index de0611f..8679471 100644 --- a/orbit/gui/graphics/interactive_lane.py +++ b/orbit/gui/graphics/interactive_lane.py @@ -10,6 +10,8 @@ from PyQt6.QtGui import QBrush, QColor, QPen, QPolygonF from PyQt6.QtWidgets import QGraphicsPolygonItem +from orbit.models.lane import LaneType + if TYPE_CHECKING: from ..image_view import ImageView @@ -28,7 +30,7 @@ class InteractiveLanePolygon(QGraphicsPolygonItem): def __init__(self, lane_id: int, section_number: int, road_id: str, polygon_points: List[tuple], parent_view: 'ImageView', - is_connecting_road: bool = False) -> None: + is_connecting_road: bool = False, lane_type=None) -> None: """ Create an interactive lane polygon. @@ -39,6 +41,7 @@ def __init__(self, lane_id: int, section_number: int, road_id: str, polygon_points: List of (x, y) points forming the lane polygon parent_view: Parent ImageView for signaling is_connecting_road: True if this is a connecting road lane, False for regular road lane + lane_type: Optional LaneType; median lanes are drawn with a hatch pattern """ # Create polygon polygon = QPolygonF() @@ -58,6 +61,7 @@ def __init__(self, lane_id: int, section_number: int, road_id: str, self.is_selected = False self.is_linked = False # True when this lane is connected to a selected lane self.is_connecting_road = is_connecting_road + self.lane_type = lane_type # Choose base color based on lane side (OpenDRIVE convention) # Connecting road lanes use darker shades to distinguish from regular lanes @@ -109,6 +113,10 @@ def _update_brush(self, alpha: int, tinted: bool = False) -> None: alpha: Alpha value (0-255) tinted: If True, blend base color with LINKED_TINT for connected lanes """ + is_median = self.lane_type == LaneType.MEDIAN + if is_median: + # Hatch coverage is sparse; raise alpha so the pattern stays visible + alpha = min(255, int(alpha * 1.6)) if tinted: # Blend base color with yellow/orange tint (50% blend) r = (self.base_color.red() + self.LINKED_TINT.red()) // 2 @@ -118,7 +126,11 @@ def _update_brush(self, alpha: int, tinted: bool = False) -> None: else: color = QColor(self.base_color) color.setAlpha(alpha) - self.setBrush(QBrush(color)) + brush = QBrush(color) + if is_median: + # Diagonal hatch mirrors real hatched median road paint + brush.setStyle(Qt.BrushStyle.BDiagPattern) + self.setBrush(brush) def set_selected(self, selected: bool) -> None: """Set selection state and update appearance.""" diff --git a/orbit/gui/graphics/lane_item.py b/orbit/gui/graphics/lane_item.py index 6d87dad..5c692b4 100644 --- a/orbit/gui/graphics/lane_item.py +++ b/orbit/gui/graphics/lane_item.py @@ -423,8 +423,11 @@ def _add_lane_scene_item(self, lane_id, section_number, polygon_points): parent_view = view break if parent_view: + section = self.road.get_section(section_number) + lane = section.get_lane(lane_id) if section else None lane_polygon = InteractiveLanePolygon( - lane_id, section_number, self.road.id, polygon_points, parent_view + lane_id, section_number, self.road.id, polygon_points, parent_view, + lane_type=lane.lane_type if lane else None, ) self.scene.addItem(lane_polygon) self.lane_items.append(lane_polygon) diff --git a/orbit/gui/image_view.py b/orbit/gui/image_view.py index 5d01256..4f6b5bb 100644 --- a/orbit/gui/image_view.py +++ b/orbit/gui/image_view.py @@ -144,6 +144,7 @@ class ImageView(QGraphicsView): object_selected = pyqtSignal(str) # Emits object ID when selected in view object_placement_requested = pyqtSignal(float, float, object) # Emits x, y coordinates and ObjectType parking_placement_requested = pyqtSignal(float, float, object, object) # Emits x, y, ParkingType, ParkingAccess + placement_cancelled = pyqtSignal(str) # Emits mode ('signal', 'object', 'parking') on Esc parking_polygon_completed = pyqtSignal(list, object, object) # Emits points list, ParkingType, ParkingAccess object_polygon_completed = pyqtSignal(list, object) # Emits points list, ObjectType section_split_requested = pyqtSignal(str, str, int) # Emits road_id, polyline_id, point_index @@ -4276,19 +4277,10 @@ def _handle_right_press(self, scene_pos, event: QMouseEvent): elif self.drawing_guardrail: self._finish_guardrail() - elif self.signal_mode: - for signal_id, item in self.signal_items.items(): - if item.contains(item.mapFromScene(scene_pos)): - self._show_signal_menu(event.pos(), signal_id) - return - - elif self.object_mode: - for object_id, item in self.object_items.items(): - if self._is_click_on_object(item, scene_pos): - self._show_object_menu(event.pos(), object_id, scene_pos) - return - elif not self.drawing_mode: + # Placement modes (signal/object/parking) fall through to the + # generic dispatcher, which checks signals and objects first — + # right-click keeps working on all other elements meanwhile self._handle_right_click_context_menu(scene_pos, event) def _handle_right_click_context_menu(self, scene_pos, event: QMouseEvent): @@ -4669,6 +4661,7 @@ def keyPressEvent(self, event: QKeyEvent): self.current_polyline_item = None self.drawing_mode = False self.setDragMode(QGraphicsView.DragMode.ScrollHandDrag) + self.placement_cancelled.emit('polyline') elif self.measure_mode: if event.key() == Qt.Key.Key_Escape: # Exit measure mode - will be handled by MainWindow @@ -4680,17 +4673,33 @@ def keyPressEvent(self, event: QKeyEvent): if event.key() in (Qt.Key.Key_Return, Qt.Key.Key_Enter): self._finish_parking_polygon() elif event.key() == Qt.Key.Key_Escape: - self._cancel_parking_polygon() + # First Esc cancels the partial polygon, second exits the mode + if self.parking_polygon_points: + self._cancel_parking_polygon() + else: + self.placement_cancelled.emit('parking') elif self.object_mode and self.object_polygon_mode: if event.key() in (Qt.Key.Key_Return, Qt.Key.Key_Enter): self._finish_object_polygon() elif event.key() == Qt.Key.Key_Escape: - self._cancel_object_polygon() + # First Esc cancels the partial polygon, second exits the mode + if self.object_polygon_points: + self._cancel_object_polygon() + else: + self.placement_cancelled.emit('object') elif self.drawing_guardrail: if event.key() in (Qt.Key.Key_Return, Qt.Key.Key_Enter): self._finish_guardrail() elif event.key() == Qt.Key.Key_Escape: self._cancel_guardrail() + elif event.key() == Qt.Key.Key_Escape and self.junction_mode: + self.placement_cancelled.emit('junction') + elif event.key() == Qt.Key.Key_Escape and self.signal_mode: + self.placement_cancelled.emit('signal') + elif event.key() == Qt.Key.Key_Escape and self.object_mode: + self.placement_cancelled.emit('object') + elif event.key() == Qt.Key.Key_Escape and self.parking_mode: + self.placement_cancelled.emit('parking') else: if event.key() == Qt.Key.Key_Delete: # Delete selected polyline diff --git a/orbit/gui/main_window.py b/orbit/gui/main_window.py index 3921a6d..a364cdc 100644 --- a/orbit/gui/main_window.py +++ b/orbit/gui/main_window.py @@ -127,6 +127,7 @@ def setup_ui(self): self.image_view.object_selected.connect(self.on_object_selected_in_view) self.image_view.object_placement_requested.connect(self.on_object_placement_requested) self.image_view.parking_placement_requested.connect(self.on_parking_placement_requested) + self.image_view.placement_cancelled.connect(self.on_placement_cancelled) self.image_view.parking_polygon_completed.connect(self.on_parking_polygon_completed) self.image_view.object_polygon_completed.connect(self.on_object_polygon_completed) self.image_view.section_split_requested.connect(self.on_section_split_requested) @@ -557,6 +558,7 @@ def create_dock_widgets(self): self.elements_tree.parking_deleted.connect(self.on_parking_deleted) self.elements_tree.connecting_road_selected.connect(self.on_connecting_road_selected) self.elements_tree.connecting_road_modified.connect(self.on_connecting_road_modified) + self.elements_tree.junction_realign_requested.connect(self.on_junction_realign_requested) self.elements_tree.connecting_road_lane_selected.connect(self.on_connecting_road_lane_selected) # Roads dock with tree widget @@ -584,6 +586,9 @@ def create_dock_widgets(self): self.road_tree.roads_merge_requested.connect(self.on_roads_merge_requested) self.road_tree.sections_merge_requested.connect(self.on_sections_merge_requested) self.road_tree.section_delete_requested.connect(self.on_section_delete_requested) + self.road_tree.lane_add_requested.connect(self.on_lane_add_requested) + self.road_tree.lane_remove_requested.connect(self.on_lane_remove_requested) + self.road_tree.junction_realign_requested.connect(self.on_junction_realign_requested) # Adjustment dock for transform adjustment self.adjustment_dock = QDockWidget("Alignment Adjustment", self) @@ -1795,6 +1800,32 @@ def add_signal(self): self.add_signal_action.setChecked(False) self.statusBar().showMessage("Ready") + def on_placement_cancelled(self, mode: str): + """Exit a placement mode cancelled with Esc in the image view.""" + if mode == 'polyline': + # Drawing already cancelled view-side; just sync the toolbar + self.new_polyline_action.setChecked(False) + elif mode == 'junction': + self.junction_mode_active = False + self.image_view.set_junction_mode(False) + self.add_junction_action.setChecked(False) + elif mode == 'signal': + self.signal_mode_active = False + self.image_view.set_signal_mode(False) + self.add_signal_action.setChecked(False) + elif mode == 'object': + # Object mode serves both Add Object and Add Land Use + self.object_mode_active = False + self.landuse_mode_active = False + self.image_view.set_object_mode(False) + self.add_object_action.setChecked(False) + self.add_landuse_action.setChecked(False) + elif mode == 'parking': + self.parking_mode_active = False + self.image_view.set_parking_mode(False) + self.add_parking_action.setChecked(False) + self.statusBar().showMessage("Ready") + def add_object(self): """Add a roadside object by selecting type and clicking on the map.""" from .dialogs.object_selection_dialog import ObjectSelectionDialog @@ -3500,6 +3531,23 @@ def _align_all_junction_connecting_roads(self, scale_factors): """Apply lane alignment to all junctions' connecting roads.""" self.controller.align_all_junction_crs(scale_factors) + def on_junction_realign_requested(self, junction_id: str): + """Re-align one junction's CRs and refresh their graphics.""" + scale_factors = self.get_current_scale() + modified_ids = self.controller.align_junction_crs(junction_id, scale_factors) + for cr_id in modified_ids: + self.image_view.update_connecting_road_graphics(cr_id, scale_factors) + if modified_ids: + self.modified = True + self.update_window_title() + + def realign_junctions_for_road(self, road_id: str): + """Re-align every junction whose lane connections reference the road.""" + for junction in self.project.junctions: + if any(road_id in (lc.from_road_id, lc.to_road_id) + for lc in junction.lane_connections): + self.on_junction_realign_requested(junction.id) + def _realign_all_junctions(self): """Re-align all junction CRs and refresh graphics (menu action).""" scale_factors = self.get_current_scale() @@ -4316,6 +4364,63 @@ def on_section_delete_requested(self, road_id: str, section_number: int, re_snap self.statusBar().showMessage(f"Section {section_number} deleted") self.update_affected_road_lanes() + def on_lane_add_requested(self, road_id: str, section_number: int): + """Handle add-lane request from RoadTreeWidget.""" + from .dialogs import AddLaneDialog + + road = self.project.get_road(road_id) + section = road.get_section(section_number) if road else None + if section is None: + return + spec = AddLaneDialog.get_lane_spec(section, road, self) + if spec: + self._apply_section_lane_change(road, section_number, spec, None) + + def on_lane_remove_requested(self, road_id: str, section_number: int, lane_id: int): + """Handle remove-lane request from RoadTreeWidget.""" + road = self.project.get_road(road_id) + if road: + self._apply_section_lane_change(road, section_number, None, lane_id) + + def _apply_section_lane_change(self, road, section_number: int, + add_spec: Optional[dict], remove_lane_id: Optional[int]): + """Insert or remove a lane in one section as a single undoable command.""" + from .undo_commands import ChangeSectionLanesCommand + + old_road_data = road.to_dict() + old_junctions = {j.id: j.to_dict() for j in self.project.junctions} + + if add_spec is not None: + label = "Add Lane" + mapping = road.insert_lane_in_section( + section_number, add_spec['new_lane_id'], add_spec['lane']) + else: + label = "Remove Lane" + mapping = road.remove_lane_in_section(section_number, remove_lane_id) + + if mapping is None: + show_warning(self, "The lane position is not valid for this section.", + f"{label} Failed") + return + + road.fix_links_after_lane_change(section_number, mapping) + affected = self.project.remap_junction_lane_ids(road, section_number, mapping) + + junction_snapshots = [ + (jid, old_junctions[jid], self.project.get_junction(jid).to_dict()) + for jid in affected + ] + cmd = ChangeSectionLanesCommand( + self, road.id, old_road_data, road.to_dict(), junction_snapshots, label) + self.undo_stack.push(cmd) + + self.road_tree.refresh_tree() + message = f"{label}: section {section_number} of road {road.id}" + if affected: + message += f" (junction connections updated: {', '.join(affected)})" + self.statusBar().showMessage(message) + self.update_affected_road_lanes() + def on_road_split_requested(self, road_id: str, polyline_id: str, point_index: int): """ Handle road split request from ImageView. @@ -4658,7 +4763,8 @@ def on_lane_edit_requested(self, road_id: str, section_number: int, lane_id: int return # Open lane properties dialog - if LanePropertiesDialog.edit_lane(lane, self.project, road_id, None, parent=self): + dialog = LanePropertiesDialog(lane, self.project, road_id, None, parent=self) + if dialog.exec() == QDialog.DialogCode.Accepted: # Properties were modified, update the view scale_factors = self.get_current_scale() self.image_view.update_road_lanes(road_id, scale_factors) @@ -4666,6 +4772,8 @@ def on_lane_edit_requested(self, road_id: str, section_number: int, lane_id: int self.road_tree.refresh_tree() self.update_window_title() self.statusBar().showMessage(f"Lane properties updated: {lane.get_display_name()}") + if dialog.junction_connections_changed: + self.realign_junctions_for_road(road_id) def on_connecting_road_lane_edit_requested(self, connecting_road_id: str, lane_id: int): """ @@ -4687,9 +4795,12 @@ def on_connecting_road_lane_edit_requested(self, connecting_road_id: str, lane_i lane = connecting_road.get_cr_lane(lane_id) if not lane: return - # Open lane properties dialog (without project/road_id since connecting roads are standalone) - if LanePropertiesDialog.edit_lane(lane, None, None, connecting_road, parent=self): + # Open lane properties dialog (project enables the Road Attachments section) + dialog = LanePropertiesDialog(lane, self.project, None, connecting_road, parent=self) + if dialog.exec() == QDialog.DialogCode.Accepted: # Properties were modified, update the view + if dialog.junction_connections_changed and connecting_road.junction_id: + self.on_junction_realign_requested(connecting_road.junction_id) scale_factors = self.get_current_scale() self.image_view.update_connecting_road_graphics(connecting_road_id, scale_factors) self.modified = True diff --git a/orbit/gui/project_controller.py b/orbit/gui/project_controller.py index 2798fbc..9ac96ae 100644 --- a/orbit/gui/project_controller.py +++ b/orbit/gui/project_controller.py @@ -102,24 +102,28 @@ def refresh_connecting_road_geo_path(self, conn_road) -> None: def align_all_junction_crs(self, scale_factors) -> Dict[str, Set[str]]: """Align all junctions' CRs. Returns {junction_id: set of modified CR IDs}.""" - from orbit.utils.connecting_road_alignment import align_connecting_road_paths - - scale = self._avg_scale(scale_factors) result: Dict[str, Set[str]] = {} - for junction in self.project.junctions: - if junction.lane_connections and junction.connecting_road_ids: - modified_ids = align_connecting_road_paths( - junction, self.project, scale - ) - if modified_ids: - for cr_id in junction.connecting_road_ids: - cr = self.project.get_road(cr_id) - if cr and cr.id in modified_ids: - self.refresh_connecting_road_geo_path(cr) - result[junction.id] = modified_ids + modified_ids = self.align_junction_crs(junction.id, scale_factors) + if modified_ids: + result[junction.id] = modified_ids return result + def align_junction_crs(self, junction_id: str, scale_factors) -> Set[str]: + """Align one junction's CRs. Returns the modified CR IDs.""" + from orbit.utils.connecting_road_alignment import align_connecting_road_paths + + junction = self.project.get_junction(junction_id) + if not junction or not (junction.lane_connections and junction.connecting_road_ids): + return set() + scale = self._avg_scale(scale_factors) + modified_ids = align_connecting_road_paths(junction, self.project, scale) + for cr_id in junction.connecting_road_ids: + cr = self.project.get_road(cr_id) + if cr and cr.id in modified_ids: + self.refresh_connecting_road_geo_path(cr) + return modified_ids + def regenerate_affected_crs(self, polyline_id: str) -> List[str]: """Regenerate CRs affected by a centerline change. diff --git a/orbit/gui/undo_commands.py b/orbit/gui/undo_commands.py index ffdfbbd..428fd26 100644 --- a/orbit/gui/undo_commands.py +++ b/orbit/gui/undo_commands.py @@ -772,6 +772,58 @@ def _apply_road_data(self, data: dict): self.main_window._refresh_trees() +class ChangeSectionLanesCommand(QUndoCommand): + """Command for adding/removing a lane in a single lane section.""" + + def __init__(self, main_window: 'MainWindow', road_id: str, + old_road_data: dict, new_road_data: dict, + junction_snapshots: List[Tuple[str, dict, dict]], + description: str = "Change Section Lanes"): + super().__init__(description) + self.main_window = main_window + self.road_id = road_id + self.old_road_data = old_road_data + self.new_road_data = new_road_data + # (junction_id, old_junction_dict, new_junction_dict) + self.junction_snapshots = junction_snapshots + self._first_redo = True + + def redo(self): + if self._first_redo: + self._first_redo = False + return + self._apply_state(self.new_road_data, use_old=False) + + def undo(self): + self._apply_state(self.old_road_data, use_old=True) + + def _apply_state(self, road_data: dict, use_old: bool): + # Restore lane sections and junction lane connections in-place — + # do NOT remove/add road or junction from project, as remove_road + # clears predecessor_id/successor_id on connecting roads. + from orbit.models.lane_section import LaneSection as LS + road = self.main_window.project.get_road(self.road_id) + if not road: + return + road.lane_sections = [LS.from_dict(s) for s in road_data.get('lane_sections', [])] + for junction_id, old_data, new_data in self.junction_snapshots: + junction = self.main_window.project.get_junction(junction_id) + if junction is None: + continue + data = old_data if use_old else new_data + junction.lane_connections = [ + LaneConnection.from_dict(lc) + for lc in data.get('lane_connections', []) + ] + self.main_window.image_view.remove_road_lanes(self.road_id) + if road.centerline_id: + scale_factors = self.main_window.get_current_scale() + self.main_window.image_view.add_road_lanes_graphics(road, scale_factors) + self.main_window.image_view.remove_section_boundaries(self.road_id) + self.main_window.image_view.draw_section_boundaries(road) + self.main_window._refresh_trees() + + class SplitRoadCommand(QUndoCommand): """Command for splitting a road into two roads.""" diff --git a/orbit/gui/widgets/elements_tree.py b/orbit/gui/widgets/elements_tree.py index ad70c29..2dea888 100644 --- a/orbit/gui/widgets/elements_tree.py +++ b/orbit/gui/widgets/elements_tree.py @@ -9,6 +9,7 @@ from PyQt6.QtGui import QAction from PyQt6.QtWidgets import ( QApplication, + QDialog, QLineEdit, QMenu, QStyle, @@ -33,6 +34,7 @@ class ElementsTreeWidget(QWidget): junction_selected = pyqtSignal(str) # Emits junction ID junction_modified = pyqtSignal(str) # Emits junction ID junction_deleted = pyqtSignal(str) # Emits junction ID + junction_realign_requested = pyqtSignal(str) # Emits junction ID needing CR re-alignment signal_selected = pyqtSignal(str) # Emits signal ID signal_modified = pyqtSignal(str) # Emits signal ID signal_deleted = pyqtSignal(str) # Emits signal ID @@ -608,12 +610,14 @@ def edit_connecting_road_lane(self, connecting_road_id: str, lane_id: int): return # Open lane properties dialog with connecting road for start/end width editing - result = LanePropertiesDialog.edit_lane( - lane, None, None, + dialog = LanePropertiesDialog( + lane, self.project, None, connecting_road=connecting_road, parent=self ) - if result: + if dialog.exec() == QDialog.DialogCode.Accepted: + if dialog.junction_connections_changed and connecting_road.junction_id: + self.junction_realign_requested.emit(connecting_road.junction_id) # Emit modification signal self.connecting_road_modified.emit(connecting_road_id) self.refresh_tree() diff --git a/orbit/gui/widgets/road_tree.py b/orbit/gui/widgets/road_tree.py index df62a75..37b851b 100644 --- a/orbit/gui/widgets/road_tree.py +++ b/orbit/gui/widgets/road_tree.py @@ -11,6 +11,7 @@ from PyQt6.QtWidgets import ( QAbstractItemView, QApplication, + QDialog, QHBoxLayout, QLineEdit, QMenu, @@ -170,6 +171,9 @@ class RoadTreeWidget(QWidget): polyline_delete_requested = pyqtSignal(str) # Emits polyline ID - handler does deletion lane_selected = pyqtSignal(str, int, int) # Emits road_id, section_number, lane_id section_delete_requested = pyqtSignal(str, int, bool) # Emits road_id, section_number, re_snap + lane_add_requested = pyqtSignal(str, int) # Emits road_id, section_number + lane_remove_requested = pyqtSignal(str, int, int) # Emits road_id, section_number, lane_id + junction_realign_requested = pyqtSignal(str) # Emits junction ID needing CR re-alignment def __init__(self, project: Project, parent=None, verbose: bool = False): super().__init__(parent) @@ -674,6 +678,12 @@ def show_context_menu(self, position): )) menu.addAction(edit_action) + add_lane_action = QAction("Add Lane...", self) + add_lane_action.triggered.connect( + lambda checked=False, sn=data["section_number"], + rid=data["road_id"]: self.lane_add_requested.emit(rid, sn)) + menu.addAction(add_lane_action) + road = self.project.get_road(data["road_id"]) if road and len(road.lane_sections) > 1: delete_action = QAction("Delete Section", self) @@ -687,6 +697,13 @@ def show_context_menu(self, position): edit_action.triggered.connect(lambda: self.edit_lane(data["lane_id"], data["road_id"])) menu.addAction(edit_action) + if data["lane_id"] != 0 and data.get("section_number") is not None: + remove_action = QAction("Remove Lane", self) + remove_action.triggered.connect( + lambda checked=False, rid=data["road_id"], sn=data["section_number"], + lid=data["lane_id"]: self.lane_remove_requested.emit(rid, sn, lid)) + menu.addAction(remove_action) + menu.exec(self.tree.viewport().mapToGlobal(position)) def edit_road(self, road_id: str): @@ -817,10 +834,16 @@ def edit_lane(self, lane_id: int, road_id: str): if road: lane = road.get_lane(lane_id) if lane: - if LanePropertiesDialog.edit_lane(lane, self.project, road_id, None, parent=self): + dialog = LanePropertiesDialog(lane, self.project, road_id, None, parent=self) + if dialog.exec() == QDialog.DialogCode.Accepted: # Lane was modified, refresh displays self.road_modified.emit(road_id) self.refresh_tree() + if dialog.junction_connections_changed: + for junction in self.project.junctions: + if any(road_id in (lc.from_road_id, lc.to_road_id) + for lc in junction.lane_connections): + self.junction_realign_requested.emit(junction.id) def edit_section(self, section_number: int, road_id: str): """Edit a lane section's properties.""" diff --git a/orbit/models/lane.py b/orbit/models/lane.py index 0432929..f9a2406 100644 --- a/orbit/models/lane.py +++ b/orbit/models/lane.py @@ -107,6 +107,9 @@ class Lane: # Lane predecessor/successor links (lane IDs) predecessor_id: Optional[int] = None successor_id: Optional[int] = None + # Explicitly no link (suppresses export's same-ID default, e.g. for pocket lanes) + no_predecessor: bool = False + no_successor: bool = False # OpenDRIVE 1.8 attributes direction: Optional[str] = None # "standard", "reversed", "both" advisory: Optional[str] = None # "none", "inner", "outer", "both" @@ -162,6 +165,10 @@ def to_dict(self) -> Dict[str, Any]: data['predecessor_id'] = self.predecessor_id if self.successor_id is not None: data['successor_id'] = self.successor_id + if self.no_predecessor: + data['no_predecessor'] = self.no_predecessor + if self.no_successor: + data['no_successor'] = self.no_successor # Only include V1.8 attributes if set if self.direction is not None: data['direction'] = self.direction @@ -208,6 +215,8 @@ def from_dict(cls, data: Dict[str, Any]) -> 'Lane': heights=[tuple(h) for h in data.get('heights', [])], predecessor_id=data.get('predecessor_id'), successor_id=data.get('successor_id'), + no_predecessor=data.get('no_predecessor', False), + no_successor=data.get('no_successor', False), direction=data.get('direction'), advisory=data.get('advisory'), level=data.get('level', False), diff --git a/orbit/models/project.py b/orbit/models/project.py index 25f67b2..3926cf0 100644 --- a/orbit/models/project.py +++ b/orbit/models/project.py @@ -1390,6 +1390,87 @@ def get_junction(self, junction_id: str) -> Optional[Junction]: return junction return None + def remap_junction_lane_ids( + self, road: Road, section_number: int, + mapping: Dict[int, Optional[int]], + ) -> List[str]: + """ + Remap junction lane connections after lanes changed in a road section. + + Only junctions touching the road end where the section sits are + affected; connections referencing a removed lane are deleted. + Returns the IDs of junctions whose connections changed. + """ + if not road.lane_sections: + return [] + is_first = road.lane_sections[0].section_number == section_number + is_last = road.lane_sections[-1].section_number == section_number + if not (is_first or is_last): + return [] + affected = [] + for junction in self.junctions: + if road.is_connecting_road and junction.id != road.junction_id: + continue + references_road = any( + road.id in (lc.from_road_id, lc.to_road_id, lc.connecting_road_id) + for lc in junction.lane_connections + ) + if not references_road: + continue + touches_start = self._junction_touches_road_start(junction, road) + if not ((touches_start and is_first) or (not touches_start and is_last)): + continue + if self._remap_junction_connections(junction, road, mapping): + affected.append(junction.id) + return affected + + def _junction_touches_road_start(self, junction: Junction, road: Road) -> bool: + """Whether the junction sits nearer the road's start than its end.""" + if road.predecessor_junction_id == junction.id: + return True + if road.successor_junction_id == junction.id: + return False + points = road.get_reference_points(self) + if len(points) < 2 or not junction.center_point: + return True + cx, cy = junction.center_point + start_d = (points[0][0] - cx) ** 2 + (points[0][1] - cy) ** 2 + end_d = (points[-1][0] - cx) ** 2 + (points[-1][1] - cy) ** 2 + return start_d <= end_d + + @staticmethod + def _remap_junction_connections( + junction: Junction, road: Road, + mapping: Dict[int, Optional[int]], + ) -> bool: + """Apply a lane-ID mapping to a junction's connections for one road.""" + changed = False + kept = [] + for lc in junction.lane_connections: + removed = False + for road_attr, lane_attr in ( + ('from_road_id', 'from_lane_id'), + ('to_road_id', 'to_lane_id'), + ('connecting_road_id', 'connecting_lane_id'), + ): + if getattr(lc, road_attr) != road.id: + continue + old_lane_id = getattr(lc, lane_attr) + if old_lane_id not in mapping: + continue + new_lane_id = mapping[old_lane_id] + if new_lane_id is None: + removed = True + elif new_lane_id != old_lane_id: + setattr(lc, lane_attr, new_lane_id) + changed = True + if removed: + changed = True + else: + kept.append(lc) + junction.lane_connections = kept + return changed + # Signal management def add_signal(self, signal: Signal) -> None: """Add a traffic signal to the project. Auto-assigns ID if empty.""" diff --git a/orbit/models/road.py b/orbit/models/road.py index ea13ec4..4217d61 100644 --- a/orbit/models/road.py +++ b/orbit/models/road.py @@ -913,6 +913,185 @@ def get_section(self, section_number: int) -> Optional[LaneSection]: return section return None + def insert_lane_in_section( + self, section_number: int, new_lane_id: int, lane_template: Lane + ) -> Optional[Dict[int, Optional[int]]]: + """ + Insert a lane at new_lane_id in one section, shifting outward lanes. + + Returns {old_id: new_id} for pre-existing lanes (identity entries + included), or None if the position is invalid. Lane 0 never moves. + """ + section = self.get_section(section_number) + if section is None or new_lane_id == 0: + return None + existing_ids = [lane.id for lane in section.lanes] + if new_lane_id > 0: + max_left = max([i for i in existing_ids if i > 0], default=0) + if not 1 <= new_lane_id <= max_left + 1: + return None + else: + max_right = max([-i for i in existing_ids if i < 0], default=0) + if not 1 <= -new_lane_id <= max_right + 1: + return None + mapping: Dict[int, Optional[int]] = {} + for lane in section.lanes: + old_id = lane.id + if new_lane_id > 0 and lane.id >= new_lane_id: + lane.id += 1 + elif new_lane_id < 0 and lane.id <= new_lane_id: + lane.id -= 1 + mapping[old_id] = lane.id + lane_template.id = new_lane_id + section.lanes.append(lane_template) + section.lanes.sort(key=lambda lane: lane.id) + return mapping + + def remove_lane_in_section( + self, section_number: int, lane_id: int + ) -> Optional[Dict[int, Optional[int]]]: + """ + Remove a lane from one section, collapsing the ID gap. + + Returns {old_id: new_id} with the removed lane mapped to None, + or None if the lane cannot be removed. Lane 0 cannot be removed. + """ + section = self.get_section(section_number) + if section is None or lane_id == 0 or section.get_lane(lane_id) is None: + return None + mapping: Dict[int, Optional[int]] = {} + kept = [] + for lane in section.lanes: + old_id = lane.id + if lane.id == lane_id: + mapping[old_id] = None + continue + if lane_id > 0 and lane.id > lane_id: + lane.id -= 1 + elif lane_id < 0 and lane.id < lane_id: + lane.id += 1 + mapping[old_id] = lane.id + kept.append(lane) + section.lanes = kept + return mapping + + def fix_links_after_lane_change( + self, section_number: int, mapping: Dict[int, Optional[int]] + ) -> None: + """ + Repair lane links after insert/remove so continuity follows old IDs. + + Assumes mapping comes from insert_lane_in_section/remove_lane_in_section + applied to the section identified by section_number. + """ + index = next( + (i for i, sec in enumerate(self.lane_sections) + if sec.section_number == section_number), + None, + ) + if index is None: + return + section = self.lane_sections[index] + prev_section = self.lane_sections[index - 1] if index > 0 else None + next_section = (self.lane_sections[index + 1] + if index < len(self.lane_sections) - 1 else None) + old_by_new = {new: old for old, new in mapping.items() if new is not None} + inserted_ids = ({lane.id for lane in section.lanes} + - set(old_by_new) - {0}) + has_pred = bool(self.predecessor_id or self.predecessor_junction_id) + has_succ = bool(self.successor_id or self.successor_junction_id) + + if prev_section is not None: + self._remap_neighbor_links(prev_section, mapping, inserted_ids, 'successor') + if next_section is not None: + self._remap_neighbor_links(next_section, mapping, inserted_ids, 'predecessor') + + for lane in section.lanes: + if lane.id == 0: + continue + if lane.id in inserted_ids: + self._init_inserted_lane_links( + lane, prev_section, next_section, has_pred, has_succ) + else: + old_id = old_by_new[lane.id] + if (lane.predecessor_id is None and not lane.no_predecessor + and (prev_section is not None or has_pred)): + lane.predecessor_id = old_id + if (lane.successor_id is None and not lane.no_successor + and (next_section is not None or has_succ)): + lane.successor_id = old_id + + @staticmethod + def _init_inserted_lane_links( + lane: Lane, + prev_section: Optional[LaneSection], + next_section: Optional[LaneSection], + has_pred: bool, + has_succ: bool, + ) -> None: + """ + Set links for a newly inserted lane. + + Links to a same-ID neighbor lane only when that lane already points + back at this one (set by _remap_neighbor_links for dangling lanes); + otherwise the lane appears/ends here and links are explicitly none. + """ + lane.predecessor_id = None + lane.successor_id = None + if prev_section is not None: + prev_lane = prev_section.get_lane(lane.id) + if prev_lane is not None and prev_lane.successor_id == lane.id: + lane.predecessor_id = lane.id + else: + lane.no_predecessor = True + elif has_pred: + lane.no_predecessor = True + if next_section is not None: + next_lane = next_section.get_lane(lane.id) + if next_lane is not None and next_lane.predecessor_id == lane.id: + lane.successor_id = lane.id + else: + lane.no_successor = True + elif has_succ: + lane.no_successor = True + + @staticmethod + def _remap_neighbor_links( + neighbor: LaneSection, + mapping: Dict[int, Optional[int]], + inserted_ids: set, + side: str, + ) -> None: + """Remap a neighbor section's links into the changed section ('successor' or 'predecessor').""" + attr = side + '_id' + flag = 'no_' + side + for lane in neighbor.lanes: + if lane.id == 0: + continue + target = getattr(lane, attr) + if target is not None: + if target in mapping: + new_target = mapping[target] + setattr(lane, attr, new_target) + if new_target is None: + setattr(lane, flag, True) + elif getattr(lane, flag): + # Dangling lane (appears/ends here): an inserted same-ID lane + # continues it; nothing else can + if lane.id in inserted_ids: + setattr(lane, attr, lane.id) + setattr(lane, flag, False) + elif lane.id in mapping: + # Implied same-ID link: make explicit with the remapped target + new_target = mapping[lane.id] + setattr(lane, attr, new_target) + if new_target is None: + setattr(lane, flag, True) + elif lane.id in inserted_ids: + # Inserted lane provides a same-ID continuation for this lane + setattr(lane, attr, lane.id) + setattr(lane, flag, False) + def to_dict(self) -> Dict[str, Any]: """Convert road to dictionary for JSON serialization.""" data = { diff --git a/orbit/utils/connecting_road_alignment.py b/orbit/utils/connecting_road_alignment.py index ff03836..c0fc8bf 100644 --- a/orbit/utils/connecting_road_alignment.py +++ b/orbit/utils/connecting_road_alignment.py @@ -40,18 +40,27 @@ def align_connecting_road_paths( if not cr or not cr.inline_path or len(cr.inline_path) < 2: continue - # Bidirectional CRs (left AND right lanes) are generated from CL-to-CL - # endpoints and carry equal traffic in both directions. Their reference - # line is already at the road centerline — no lateral shift is needed. - if cr.cr_lane_count_left > 0 and cr.cr_lane_count_right > 0: - continue - - # Find primary lane connection for this CR + # Find lane connections for this CR cr_conns = [ c for c in junction.lane_connections if c.connecting_road_id == cr.id ] if not cr_conns: continue + + # Bidirectional CRs (left AND right lanes) carry a movement in each + # direction. Anchor each endpoint at the average of the per-direction + # lane targets — ~the road centerline for symmetric 1+1 roads, but it + # follows the target lanes on multi-lane approaches (turn pockets). + if cr.cr_lane_count_left > 0 and cr.cr_lane_count_right > 0: + cr.ensure_cr_lanes_initialized() + start_shift, end_shift = _bidirectional_endpoint_shifts( + cr, cr_conns, project, scale + ) + if start_shift or end_shift: + regenerate_connecting_road_path(cr, start_shift, end_shift) + modified_ids.append(cr.id) + continue + conn = cr_conns[0] # Determine CR lane id: right lane (-1) or left lane (+1). # Fall back to the CR's actual lane config when not explicitly set. @@ -113,6 +122,55 @@ def align_connecting_road_paths( return modified_ids +def _bidirectional_endpoint_shifts( + cr, + cr_conns, + project: Project, + scale: float, +) -> Tuple[Optional[Tuple[float, float]], Optional[Tuple[float, float]]]: + """Average per-direction lane alignment shifts for a bidirectional CR.""" + + def shifts_for_end(road_id, contact, endpoint, fwd_p1, fwd_p2, at_start): + shifts = [] + for conn in cr_conns: + if conn.connecting_lane_id is None: + continue + if conn.from_road_id == road_id: + target_lane_id = conn.from_lane_id + elif conn.to_road_id == road_id: + target_lane_id = conn.to_lane_id + else: + continue + cr_lane = cr.get_cr_lane(conn.connecting_lane_id) + if cr_lane is None: + continue + width = cr_lane.width if at_start else cr_lane.get_width_at_end() + shift = _compute_lane_alignment_shift( + project=project, road_id=road_id, contact_point=contact, + target_lane_id=target_lane_id, + cr_lane_id=conn.connecting_lane_id, + cr_lane_width=width, cr_endpoint=endpoint, + cr_fwd_p1=fwd_p1, cr_fwd_p2=fwd_p2, scale=scale, + ) + shifts.append(shift or (0.0, 0.0)) + if not shifts: + return None + avg = (sum(dx for dx, _ in shifts) / len(shifts), + sum(dy for _, dy in shifts) / len(shifts)) + # Symmetric targets cancel out; skip trivial net shifts (< 1 px) + if abs(avg[0]) < 1.0 and abs(avg[1]) < 1.0: + return None + return avg + + start_shift = shifts_for_end( + cr.predecessor_id, cr.predecessor_contact, + cr.inline_path[0], cr.inline_path[0], cr.inline_path[1], True) + end_shift = shifts_for_end( + cr.successor_id, cr.successor_contact, + cr.inline_path[-1], cr.inline_path[-2], cr.inline_path[-1], False) + return start_shift, end_shift + + def _compute_lane_alignment_shift( project: Project, road_id: str, diff --git a/pyproject.toml b/pyproject.toml index 9fee81e..97b0b26 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "orbit" -version = "0.13.0" +version = "0.14.0" description = "OpenDrive Road Builder from Imagery Tool" readme = "README.md" requires-python = ">=3.10" diff --git a/tests/unit/test_export/test_lane_builder.py b/tests/unit/test_export/test_lane_builder.py index cd0deb5..68ae55f 100644 --- a/tests/unit/test_export/test_lane_builder.py +++ b/tests/unit/test_export/test_lane_builder.py @@ -593,3 +593,71 @@ def test_lane_type_biking(self, builder, mock_lane): result = builder._create_lane(mock_lane) assert result.get('type') == 'biking' + + +class TestInteriorSectionLinks: + """Tests for default lane links at interior section boundaries.""" + + @pytest.fixture + def builder(self): + return LaneBuilder(scale_x=0.1) + + def _make_road(self, section_lane_ids, predecessor=None, successor=None): + """Real Road with sections holding driving lanes with the given IDs.""" + from orbit.models import Lane, LaneSection, Road + road = Road(id="r1", predecessor_id=predecessor, successor_id=successor) + per = 100.0 + for i, lane_ids in enumerate(section_lane_ids): + lanes = [Lane(id=0, width=0.0)] + lanes += [Lane(id=lane_id, width=3.5) for lane_id in lane_ids] + road.lane_sections.append(LaneSection( + section_number=i + 1, s_start=i * per, s_end=(i + 1) * per, + lanes=lanes)) + return road + + def _links(self, lanes_elem, section_idx, lane_id): + section = lanes_elem.findall('laneSection')[section_idx] + for lane in section.iter('lane'): + if lane.get('id') == str(lane_id): + link = lane.find('link') + pred = link.find('predecessor') if link is not None else None + succ = link.find('successor') if link is not None else None + return (pred.get('id') if pred is not None else None, + succ.get('id') if succ is not None else None) + return (None, None) + + def test_equal_id_sets_get_default_interior_links(self, builder): + road = self._make_road([[1, -1], [1, -1]]) + result = builder.create_lanes(road, 100.0, []) + + assert self._links(result, 0, 1) == (None, '1') + assert self._links(result, 1, 1) == ('1', None) + assert self._links(result, 1, -1) == ('-1', None) + + def test_differing_id_sets_suppress_default_links(self, builder): + road = self._make_road([[1, -1], [1, 2, -1]]) + result = builder.create_lanes(road, 100.0, []) + + # No explicit links set: nothing should be emitted at the boundary + assert self._links(result, 0, 1) == (None, None) + assert self._links(result, 1, 2) == (None, None) + + def test_explicit_links_emitted_when_sets_differ(self, builder): + road = self._make_road([[1, -1], [1, 2, -1]]) + road.lane_sections[0].get_lane(1).successor_id = 2 + road.lane_sections[1].get_lane(2).predecessor_id = 1 + result = builder.create_lanes(road, 100.0, []) + + assert self._links(result, 0, 1) == (None, '2') + assert self._links(result, 1, 2) == ('1', None) + + def test_no_link_flags_suppress_road_boundary_defaults(self, builder): + road = self._make_road([[1, -1]], predecessor="r0", successor="r2") + lane = road.lane_sections[0].get_lane(1) + lane.no_predecessor = True + lane.no_successor = True + result = builder.create_lanes(road, 100.0, []) + + assert self._links(result, 0, 1) == (None, None) + # Other lane keeps the same-ID defaults + assert self._links(result, 0, -1) == ('-1', '-1') diff --git a/tests/unit/test_models/test_lane.py b/tests/unit/test_models/test_lane.py index cf7d6fa..9ad4bb4 100644 --- a/tests/unit/test_models/test_lane.py +++ b/tests/unit/test_models/test_lane.py @@ -587,3 +587,21 @@ def test_roundtrip_with_all_optional_fields(self): assert restored.level == original.level assert restored.turn_directions == original.turn_directions assert restored.boundary_mode == original.boundary_mode + + +class TestLaneNoLinkFlags: + """Test explicit no-link flags serialization.""" + + def test_flags_default_false_and_omitted(self): + lane = Lane(id=1) + assert lane.no_predecessor is False + assert lane.no_successor is False + data = lane.to_dict() + assert 'no_predecessor' not in data + assert 'no_successor' not in data + + def test_flags_round_trip(self): + lane = Lane(id=1, no_predecessor=True, no_successor=True) + restored = Lane.from_dict(lane.to_dict()) + assert restored.no_predecessor is True + assert restored.no_successor is True diff --git a/tests/unit/test_models/test_project.py b/tests/unit/test_models/test_project.py index 79d4610..945bf7a 100644 --- a/tests/unit/test_models/test_project.py +++ b/tests/unit/test_models/test_project.py @@ -1664,3 +1664,59 @@ def test_both_ends_enforced(self, empty_project): cl_b = empty_project.get_polyline(road_b.centerline_id) assert cl_b.points[0] == (100.0, 0.0) assert cl_b.points[-1] == (200.0, 0.0) + + +class TestRemapJunctionLaneIds: + """Test junction lane connection remapping after section lane edits.""" + + def _make_project(self): + from orbit.models import Lane, LaneConnection, LaneSection + project = Project() + road = Road(id="r1", centerline_id="p1") + for i in range(2): + road.lane_sections.append(LaneSection( + section_number=i + 1, s_start=i * 100.0, s_end=(i + 1) * 100.0, + lanes=[Lane(id=0, width=0.0), Lane(id=1), Lane(id=-1)])) + project.add_road(road) + # Centerline from (0,0) to (0,200); junction at the road end + project.add_polyline(Polyline(id="p1", points=[(0, 0), (0, 200)])) + junction = Junction(id="j1", center_point=(0, 210)) + junction.lane_connections = [ + LaneConnection(id="lc1", from_road_id="r1", from_lane_id=1, + to_road_id="r2", to_lane_id=-1, turn_type="straight"), + LaneConnection(id="lc2", from_road_id="r2", from_lane_id=1, + to_road_id="r1", to_lane_id=-1, turn_type="straight"), + ] + project.junctions.append(junction) + return project, road, junction + + def test_remap_at_junction_end(self): + from orbit.models import Lane + project, road, junction = self._make_project() + mapping = road.insert_lane_in_section(2, 1, Lane(id=0)) + + affected = project.remap_junction_lane_ids(road, 2, mapping) + + assert affected == ["j1"] + assert junction.lane_connections[0].from_lane_id == 2 + assert junction.lane_connections[1].to_lane_id == -1 + + def test_section_away_from_junction_untouched(self): + from orbit.models import Lane + project, road, junction = self._make_project() + mapping = road.insert_lane_in_section(1, 1, Lane(id=0)) + + affected = project.remap_junction_lane_ids(road, 1, mapping) + + assert affected == [] + assert junction.lane_connections[0].from_lane_id == 1 + + def test_removed_lane_deletes_connections(self): + project, road, junction = self._make_project() + mapping = road.remove_lane_in_section(2, 1) + + affected = project.remap_junction_lane_ids(road, 2, mapping) + + assert affected == ["j1"] + remaining_ids = [lc.id for lc in junction.lane_connections] + assert remaining_ids == ["lc2"] diff --git a/tests/unit/test_models/test_road.py b/tests/unit/test_models/test_road.py index bcd7c9b..bee46f8 100644 --- a/tests/unit/test_models/test_road.py +++ b/tests/unit/test_models/test_road.py @@ -1046,3 +1046,100 @@ def test_per_lane_variable_width_different_polygons(self): # Right lane polygon should be wider at end than left lane # (they should be different shapes) assert polygons[-1] != polygons[1] + + +class TestSectionLaneEditing: + """Test per-section lane insertion/removal and link fixup.""" + + def _make_road(self, num_sections=3): + """Road with num_sections sections of lanes 0, +1, -1.""" + road = Road(id="r1") + per = 100.0 + for i in range(num_sections): + lanes = [ + Lane(id=0, width=0.0), + Lane(id=1, width=3.5), + Lane(id=-1, width=3.5), + ] + road.lane_sections.append(LaneSection( + section_number=i + 1, + s_start=i * per, + s_end=(i + 1) * per, + lanes=lanes, + )) + return road + + def test_insert_left_innermost_renumbers(self): + road = self._make_road(1) + mapping = road.insert_lane_in_section(1, 1, Lane(id=0, width=3.5)) + + assert mapping == {0: 0, 1: 2, -1: -1} + section = road.lane_sections[0] + assert sorted(lane.id for lane in section.lanes) == [-1, 0, 1, 2] + + def test_insert_right_outermost(self): + road = self._make_road(1) + mapping = road.insert_lane_in_section(1, -2, Lane(id=0, width=3.5)) + + assert mapping == {0: 0, 1: 1, -1: -1} + assert road.lane_sections[0].get_lane(-2) is not None + + def test_insert_invalid_position_rejected(self): + road = self._make_road(1) + assert road.insert_lane_in_section(1, 3, Lane(id=0)) is None + assert road.insert_lane_in_section(1, 0, Lane(id=0)) is None + assert road.insert_lane_in_section(99, 1, Lane(id=0)) is None + + def test_remove_lane_collapses_ids(self): + road = self._make_road(1) + road.insert_lane_in_section(1, 1, Lane(id=0, width=3.5)) + + mapping = road.remove_lane_in_section(1, 1) + + assert mapping == {0: 0, 1: None, 2: 1, -1: -1} + section = road.lane_sections[0] + assert sorted(lane.id for lane in section.lanes) == [-1, 0, 1] + + def test_remove_center_lane_rejected(self): + road = self._make_road(1) + assert road.remove_lane_in_section(1, 0) is None + + def test_pocket_lane_workflow_links(self): + """Taper + pocket sections keep through-lane and pocket-lane chains apart.""" + road = self._make_road(3) + + mapping = road.insert_lane_in_section(2, 1, Lane(id=0, width=0.0, width_end=3.5)) + road.fix_links_after_lane_change(2, mapping) + mapping = road.insert_lane_in_section(3, 1, Lane(id=0, width=3.5)) + road.fix_links_after_lane_change(3, mapping) + + s1, s2, s3 = road.lane_sections + # Through lane chain: S1.1 -> S2.2 -> S3.2 + assert s1.get_lane(1).successor_id == 2 + assert s2.get_lane(2).predecessor_id == 1 + assert s2.get_lane(2).successor_id == 2 + assert s3.get_lane(2).predecessor_id == 2 + # Pocket lane appears in S2, continues to S3 + pocket2, pocket3 = s2.get_lane(1), s3.get_lane(1) + assert pocket2.predecessor_id is None and pocket2.no_predecessor + assert pocket2.successor_id == 1 and not pocket2.no_successor + assert pocket3.predecessor_id == 1 + # Unchanged right side keeps continuity + assert s1.get_lane(-1).successor_id == -1 + assert s2.get_lane(-1).predecessor_id == -1 + + def test_remove_pocket_restores_dangling_flag(self): + road = self._make_road(3) + mapping = road.insert_lane_in_section(2, 1, Lane(id=0, width=0.0, width_end=3.5)) + road.fix_links_after_lane_change(2, mapping) + mapping = road.insert_lane_in_section(3, 1, Lane(id=0, width=3.5)) + road.fix_links_after_lane_change(3, mapping) + + mapping = road.remove_lane_in_section(3, 1) + road.fix_links_after_lane_change(3, mapping) + + s2, s3 = road.lane_sections[1], road.lane_sections[2] + assert s2.get_lane(1).successor_id is None + assert s2.get_lane(1).no_successor + # Through lane back at ID 1, still linked to S2.2 + assert s3.get_lane(1).predecessor_id == 2