Skip to content
Merged
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
4 changes: 3 additions & 1 deletion orbit/export/lane_analyzer.py
Original file line number Diff line number Diff line change
Expand Up @@ -395,9 +395,11 @@ def _get_directional_scale(self, centerline: Polyline) -> float:
return 1.0

scale_x, scale_y = self.scale_factors
# Lateral offsets are perpendicular to the road, so use the across-road
# (perpendicular) scale to convert pixel widths to metres.
return calculate_directional_scale(
centerline.points, scale_x, scale_y,
default_scale=(scale_x + scale_y) / 2
default_scale=(scale_x + scale_y) / 2, perpendicular=True
)

def suggest_lane_widths(self, road: Road, verbose: bool = False) -> Optional[Dict[str, float]]:
Expand Down
6 changes: 4 additions & 2 deletions orbit/gui/graphics/connecting_road_item.py
Original file line number Diff line number Diff line change
Expand Up @@ -335,8 +335,10 @@ def update_graphics(self) -> None:
self.connecting_road.cr_lane_count_right,
self.connecting_road.cr_lane_count_left)

# Get lane polygons
lane_polygons = self.connecting_road.get_cr_lane_polygons(scale)
# Get lane polygons (pass anisotropic scales for correct metric width)
sx, sy = (self.scale_factors if self.scale_factors
else (self.DEFAULT_SCALE, self.DEFAULT_SCALE))
lane_polygons = self.connecting_road.get_cr_lane_polygons(scale, sx, sy)

# Create interactive polygon for each lane
for lane_id, polygon_points in lane_polygons.items():
Expand Down
109 changes: 67 additions & 42 deletions orbit/gui/graphics/lane_item.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
from orbit.gui.constants import DEFAULT_SCALE_M_PER_PX
from orbit.models import BoundaryMode, Polyline, Road
from orbit.utils.geometry import (
build_lane_polygon_metric,
calculate_directional_scale,
calculate_offset_polyline,
create_lane_polygon,
Expand Down Expand Up @@ -143,6 +144,12 @@ def _get_directional_scale(self) -> float:
default_scale=self.DEFAULT_SCALE
)

def _scale_xy(self) -> Tuple[float, float]:
"""Anisotropic (scale_x, scale_y) in m/px for metric-space width building."""
if self.scale_factors:
return self.scale_factors[0], self.scale_factors[1]
return self.DEFAULT_SCALE, self.DEFAULT_SCALE

def update_graphics(self) -> None:
"""Update all lane graphics based on current road configuration."""
# Remove existing lanes
Expand Down Expand Up @@ -241,12 +248,12 @@ def _is_inner_lane(lane_id: int, other_id: int) -> bool:
return other_id > 0 and other_id < lane_id
return other_id < 0 and abs(other_id) < abs(lane_id)

def _cumulative_inner_offset(self, lane, sorted_lanes, scale):
"""Calculate cumulative pixel offset of all lanes inner to this one."""
def _cumulative_inner_offset(self, lane, sorted_lanes):
"""Cumulative width (metres) of all lanes inner to this one."""
total = 0.0
for inner_lane in sorted_lanes:
if self._is_inner_lane(lane.id, inner_lane.id):
total += inner_lane.width / scale
total += inner_lane.width
return total

def _compute_lane_polygon(
Expand Down Expand Up @@ -282,11 +289,12 @@ def _compute_explicit_boundary_polygon(
if not outer_polyline or len(outer_polyline.points) < 2:
return None

inner_offset_px = self._cumulative_inner_offset(lane, sorted_lanes, scale)
inner_boundary = calculate_offset_polyline(
section_centerline,
inner_offset_px if lane.id > 0 else -inner_offset_px,
closed=False
inner_offset_m = self._cumulative_inner_offset(lane, sorted_lanes)
signed_offset = inner_offset_m if lane.id > 0 else -inner_offset_m
sx, sy = self._scale_xy()
inner_boundary = build_lane_polygon_metric(
section_centerline, sx, sy,
lambda cl: calculate_offset_polyline(cl, signed_offset, closed=False)
)
if self.verbose:
logger.debug(" Lane %d: Using explicit outer boundary", lane.id)
Expand Down Expand Up @@ -331,23 +339,30 @@ def _compute_polynomial_polygon(
section_length_m, scale
):
"""Create polygon using polynomial width evaluation at each point."""
# Widths returned in METRES; geometry is built in metric space below so
# the perpendicular offset is correct under anisotropic pixel scales.
# s is a length along the road, so s_px -> s_m keeps the directional scale.
def inner_width_func(s_px):
s_m = s_px * scale
total = 0.0
for inner_lane in sorted_lanes:
if self._is_inner_lane(lane.id, inner_lane.id):
total += inner_lane.get_width_at_s(s_m, section_length_m) / scale
total += inner_lane.get_width_at_s(s_m, section_length_m)
return total

def lane_width_func(s_px):
s_m = s_px * scale
return lane.get_width_at_s(s_m, section_length_m) / scale
return lane.get_width_at_s(s_m, section_length_m)

if self.verbose:
logger.debug(" Lane %d: Using polynomial width", lane.id)
return create_polynomial_width_lane_polygon(
section_centerline, lane.id, inner_width_func,
lane_width_func, section_s_values, is_left_lane=(lane.id > 0)
sx, sy = self._scale_xy()
return build_lane_polygon_metric(
section_centerline, sx, sy,
lambda cl: create_polynomial_width_lane_polygon(
cl, lane.id, inner_width_func,
lane_width_func, section_s_values, is_left_lane=(lane.id > 0)
)
)

def _compute_variable_width_polygon(
Expand All @@ -358,36 +373,44 @@ def _compute_variable_width_polygon(
inner_offset_end = 0.0
for inner_lane in sorted_lanes:
if self._is_inner_lane(lane.id, inner_lane.id):
inner_offset_start += inner_lane.width / scale
inner_offset_end += inner_lane.get_width_at_end() / scale
inner_offset_start += inner_lane.width
inner_offset_end += inner_lane.get_width_at_end()

outer_offset_start = inner_offset_start + lane.width / scale
outer_offset_end = inner_offset_end + lane.get_width_at_end() / scale
outer_offset_start = inner_offset_start + lane.width
outer_offset_end = inner_offset_end + lane.get_width_at_end()

if lane.id > 0:
inner_offset_start = -inner_offset_start
outer_offset_start = -outer_offset_start
inner_offset_end = -inner_offset_end
outer_offset_end = -outer_offset_end

return create_variable_width_lane_polygon(
section_centerline, inner_offset_start, outer_offset_start,
inner_offset_end, outer_offset_end
sx, sy = self._scale_xy()
return build_lane_polygon_metric(
section_centerline, sx, sy,
lambda cl: create_variable_width_lane_polygon(
cl, inner_offset_start, outer_offset_start,
inner_offset_end, outer_offset_end
)
)

def _compute_constant_width_polygon(
self, lane, sorted_lanes, section_centerline, scale
):
"""Create polygon using constant lane width offset."""
inner_offset = self._cumulative_inner_offset(lane, sorted_lanes, scale)
outer_offset = inner_offset + lane.width / scale
inner_offset = self._cumulative_inner_offset(lane, sorted_lanes)
outer_offset = inner_offset + lane.width

if lane.id > 0:
inner_offset = -inner_offset
outer_offset = -outer_offset

return create_lane_polygon(
section_centerline, inner_offset, outer_offset, closed=False
sx, sy = self._scale_xy()
return build_lane_polygon_metric(
section_centerline, sx, sy,
lambda cl: create_lane_polygon(
cl, inner_offset, outer_offset, closed=False
)
)

def _add_lane_scene_item(self, lane_id, section_number, polygon_points):
Expand All @@ -413,8 +436,10 @@ def _create_legacy_lanes(self, centerline_points: List[Tuple[float, float]], sca
right_count = self.road.lane_info.right_count
lane_width_m = self.road.lane_info.lane_width

# Convert lane width to pixels
lane_width_px = lane_width_m / scale
# Lane widths stay in metres; polygons are built in metric space so the
# perpendicular offset is anisotropy-correct (see build_lane_polygon_metric).
sx, sy = self._scale_xy()
lane_width_px = lane_width_m / scale # for verbose logging only

# Verbose output for debugging
if self.verbose:
Expand All @@ -437,14 +462,14 @@ def _create_legacy_lanes(self, centerline_points: List[Tuple[float, float]], sca
# Create right-hand lanes (negative IDs in OpenDRIVE: -1, -2, -3, ...)
# Use POSITIVE offsets to place on right side (in screen coords: positive = right)
for lane_num in range(1, right_count + 1):
inner_offset = (lane_num - 1) * lane_width_px
outer_offset = lane_num * lane_width_px

polygon_points = create_lane_polygon(
centerline_points,
inner_offset,
outer_offset,
closed=self.centerline.closed
inner_offset = (lane_num - 1) * lane_width_m
outer_offset = lane_num * lane_width_m

polygon_points = build_lane_polygon_metric(
centerline_points, sx, sy,
lambda cl, io=inner_offset, oo=outer_offset: create_lane_polygon(
cl, io, oo, closed=self.centerline.closed
)
)

if polygon_points:
Expand All @@ -454,14 +479,14 @@ def _create_legacy_lanes(self, centerline_points: List[Tuple[float, float]], sca
# Create left-hand lanes (positive IDs in OpenDRIVE: 1, 2, 3, ...)
# Use NEGATIVE offsets to place on left side (in screen coords: negative = left)
for lane_num in range(1, left_count + 1):
inner_offset = -(lane_num - 1) * lane_width_px
outer_offset = -lane_num * lane_width_px

polygon_points = create_lane_polygon(
centerline_points,
inner_offset,
outer_offset,
closed=self.centerline.closed
inner_offset = -(lane_num - 1) * lane_width_m
outer_offset = -lane_num * lane_width_m

polygon_points = build_lane_polygon_metric(
centerline_points, sx, sy,
lambda cl, io=inner_offset, oo=outer_offset: create_lane_polygon(
cl, io, oo, closed=self.centerline.closed
)
)

if polygon_points:
Expand Down
21 changes: 21 additions & 0 deletions orbit/gui/graphics/object_graphics_item.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,10 @@ def __init__(self, obj: RoadObject, scale_factor: float = 0.0, parent=None):
self.obj = obj
self.scale_factor = scale_factor # Meters per pixel
self.object_changed = None # Callback function for changes
# True while repositioning programmatically (e.g. from geo coords after
# an adjustment) so itemChange does not treat it as a user drag and clear
# geo_position.
self._programmatic_move = False

# Main shape item
self.shape_item = QGraphicsPathItem()
Expand Down Expand Up @@ -68,7 +72,9 @@ def __init__(self, obj: RoadObject, scale_factor: float = 0.0, parent=None):
# Set position and update graphics
# Don't use setPos for polylines or polygon objects - they're in scene coordinates
if obj.type.get_shape_type() != "polyline" and not is_polygon_building and not is_polygon:
self._programmatic_move = True
self.setPos(obj.position[0], obj.position[1])
self._programmatic_move = False

self.update_graphics()

Expand All @@ -88,6 +94,16 @@ def update_graphics(self):
shape_type = self.obj.type.get_shape_type()
color = get_object_color(self.obj.type)

# Point objects are positioned via setPos (path is centred at origin), so
# rebuilding the path alone won't move them. Re-sync scene position from
# the (possibly adjustment-updated) model position. Polyline/polygon
# objects carry their geometry in obj.points and are excluded.
if (shape_type != "polyline" and not self._is_polygon_with_points()
and self.pos() != QPointF(*self.obj.position)):
self._programmatic_move = True
self.setPos(self.obj.position[0], self.obj.position[1])
self._programmatic_move = False

# Clear old point handles
for point_item in self.point_items:
self.removeFromGroup(point_item)
Expand Down Expand Up @@ -218,6 +234,11 @@ def _update_selection_highlight(self, base_path):
def itemChange(self, change, value):
"""Handle item changes (position, selection)."""
if change == QGraphicsItemGroup.GraphicsItemChange.ItemPositionHasChanged:
# Programmatic moves (e.g. re-projection from geo coords after an
# adjustment) must not be treated as user edits.
if self._programmatic_move:
return super().itemChange(change, value)

# Update object position (for point objects only)
if self.obj.type.get_shape_type() != "polyline":
pos = self.pos()
Expand Down
9 changes: 7 additions & 2 deletions orbit/gui/image_view.py
Original file line number Diff line number Diff line change
Expand Up @@ -4372,8 +4372,10 @@ def mouseMoveEvent(self, event: QMouseEvent):
item = self.polyline_items[self.drag_polyline_id]
drag_x, drag_y = scene_pos.x(), scene_pos.y()

# Endpoint snap detection
if self._dragging_endpoint and self.project:
# Endpoint snap detection. Holding Shift bypasses snapping so a point
# coincident with another road's endpoint can be pulled apart.
snap_bypassed = bool(event.modifiers() & Qt.KeyboardModifier.ShiftModifier)
if self._dragging_endpoint and self.project and not snap_bypassed:
road = self._find_road_by_centerline(self.drag_polyline_id)
exclude_id = road.id if road else None
nearby = self.project.find_nearby_road_endpoints(
Expand All @@ -4387,6 +4389,9 @@ def mouseMoveEvent(self, event: QMouseEvent):
else:
self._snap_target = None
self._remove_snap_indicator()
elif self._dragging_endpoint:
self._snap_target = None
self._remove_snap_indicator()

item.polyline.update_point(self.drag_point_index, drag_x, drag_y)
item.update_graphics()
Expand Down
32 changes: 28 additions & 4 deletions orbit/gui/main_window.py
Original file line number Diff line number Diff line change
Expand Up @@ -2251,7 +2251,14 @@ def _restore_adjustment_from_project(self):
adj = TransformAdjustment.from_dict(self.project.transform_adjustment)
if adj.is_identity():
return
self.image_view.current_adjustment = adj
# For drone-assisted the stored adjustment is the permanent base, applied
# directly to the transformer below. current_adjustment is the LIVE delta
# and must stay identity, otherwise _apply_active_adjustment would later
# compose the base on top of itself (double transform — visible when
# returning from aerial view). For homography/affine the stored value IS
# an unbaked live adjustment, so it is restored into current_adjustment.
if self.project.transform_method != 'drone_assisted':
self.image_view.current_adjustment = adj
if self._cached_transformer is None:
self._cached_transformer = self._create_transformer(use_validation=True)
if self._cached_transformer is not None:
Expand Down Expand Up @@ -2729,14 +2736,23 @@ def on_adjustment_changed(self, adjustment: TransformAdjustment):
self.adjustment_panel.update_display(adjustment)

if self._cached_transformer is not None:
self._cached_transformer.set_adjustment(adjustment)
# Compose the live delta onto any stored drone base so interactive
# edits build on the applied correction instead of replacing it
# (otherwise the first keypress drops the base — a large jump).
# For non-drone methods this applies the adjustment unchanged.
self._apply_active_adjustment(self._cached_transformer)
self.image_view.update_all_from_geo_coords(self._cached_transformer)

def reset_adjustment(self):
"""Reset all adjustment values."""
self.image_view.reset_adjustment()
if self._cached_transformer is not None:
self._cached_transformer.clear_adjustment()
# Drone-assisted: the applied correction lives in
# project.transform_adjustment, not in control points. Re-apply it so
# resetting the live delta only discards the in-progress adjustment and
# keeps the already-applied correction visible. No-op for other methods.
self._apply_active_adjustment(self._cached_transformer)
self.refresh_imported_geometry()
self._remove_adjustment_ghost()
self.statusBar().showMessage("Adjustment reset")
Expand All @@ -2752,8 +2768,11 @@ def _show_adjustment_ghost(self):
def _remove_adjustment_ghost(self):
"""Remove the ghost overlay from the scene."""
if self._adjustment_ghost_overlay is not None:
if self._adjustment_ghost_overlay.scene():
self.image_view.scene.removeItem(self._adjustment_ghost_overlay)
try:
if self._adjustment_ghost_overlay.scene():
self.image_view.scene.removeItem(self._adjustment_ghost_overlay)
except RuntimeError:
pass # Underlying C++ item already deleted by scene.clear()
self._adjustment_ghost_overlay = None

def _on_autofit_toggled(self, enabled: bool):
Expand Down Expand Up @@ -3299,6 +3318,11 @@ def _switch_to_original(self):

def get_current_scale(self):
"""Get current scale (m/px) from georeferencing, or None."""
# In aerial view the active transformer is the aerial tile transformer,
# which has a different m/px than the original image. Edit-triggered
# redraws must use it so lane widths match the initial aerial render.
if self._aerial_view_active and self._aerial_transformer is not None:
return self._aerial_transformer.get_scale_factor()
return self.controller.get_current_scale()

def _initialize_and_refresh_geo_coords(self):
Expand Down
13 changes: 13 additions & 0 deletions orbit/import/opendrive_importer.py
Original file line number Diff line number Diff line change
Expand Up @@ -1075,6 +1075,19 @@ def _import_lane_sections(
orbit_lane = self._convert_lane(odr_lane, side='left')
section.lanes.append(orbit_lane)

# For single-section roads, express a purely-linear width taper using
# the native width_end field so the lane editor shows an editable
# "Width at End". Restricted to single-section roads so road_length
# is both the section length and the render length, keeping the linear
# interpolation exactly equivalent to the original polynomial. Higher
# order polynomials are left intact (editable via advanced controls).
if len(odr_sections) == 1 and road_length_meters > 0:
for lane in section.lanes:
if (lane.width_b != 0.0 and lane.width_c == 0.0
and lane.width_d == 0.0):
lane.width_end = lane.width + lane.width_b * road_length_meters
lane.width_b = 0.0

# If no lanes were created, add default
if not section.lanes:
section.lanes = [
Expand Down
Loading
Loading