diff --git a/orbit/export/lane_analyzer.py b/orbit/export/lane_analyzer.py index b84048b..59fc080 100644 --- a/orbit/export/lane_analyzer.py +++ b/orbit/export/lane_analyzer.py @@ -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]]: diff --git a/orbit/gui/graphics/connecting_road_item.py b/orbit/gui/graphics/connecting_road_item.py index 46c792f..cd9248d 100644 --- a/orbit/gui/graphics/connecting_road_item.py +++ b/orbit/gui/graphics/connecting_road_item.py @@ -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(): diff --git a/orbit/gui/graphics/lane_item.py b/orbit/gui/graphics/lane_item.py index db6422e..6d87dad 100644 --- a/orbit/gui/graphics/lane_item.py +++ b/orbit/gui/graphics/lane_item.py @@ -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, @@ -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 @@ -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( @@ -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) @@ -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( @@ -358,11 +373,11 @@ 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 @@ -370,24 +385,32 @@ def _compute_variable_width_polygon( 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): @@ -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: @@ -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: @@ -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: diff --git a/orbit/gui/graphics/object_graphics_item.py b/orbit/gui/graphics/object_graphics_item.py index 73106cb..77ee827 100644 --- a/orbit/gui/graphics/object_graphics_item.py +++ b/orbit/gui/graphics/object_graphics_item.py @@ -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() @@ -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() @@ -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) @@ -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() diff --git a/orbit/gui/image_view.py b/orbit/gui/image_view.py index 4e0fd70..5d01256 100644 --- a/orbit/gui/image_view.py +++ b/orbit/gui/image_view.py @@ -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( @@ -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() diff --git a/orbit/gui/main_window.py b/orbit/gui/main_window.py index a3124c2..3921a6d 100644 --- a/orbit/gui/main_window.py +++ b/orbit/gui/main_window.py @@ -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: @@ -2729,7 +2736,11 @@ 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): @@ -2737,6 +2748,11 @@ def reset_adjustment(self): 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") @@ -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): @@ -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): diff --git a/orbit/import/opendrive_importer.py b/orbit/import/opendrive_importer.py index 60ba3ef..5b2392c 100644 --- a/orbit/import/opendrive_importer.py +++ b/orbit/import/opendrive_importer.py @@ -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 = [ diff --git a/orbit/models/project.py b/orbit/models/project.py index 8ed5710..25f67b2 100644 --- a/orbit/models/project.py +++ b/orbit/models/project.py @@ -630,6 +630,18 @@ def remove_road(self, road_id: str) -> None: if road.predecessor_id == road_id: road.predecessor_id = None + # Unattach objects/signals/parking that were anchored to this road, + # so they don't leave dangling road_id references on export. + for obj in self.objects: + if obj.road_id == road_id: + obj.road_id = None + for signal in self.signals: + if signal.road_id == road_id: + signal.road_id = None + for parking in self.parking_spaces: + if parking.road_id == road_id: + parking.road_id = None + # Remove from junctions (connected_road_ids, connecting_road_ids, # lane_connections, entry/exit_roads) for junction in self.junctions: @@ -1353,6 +1365,7 @@ def remove_junction(self, junction_id: str, cleanup_road_refs: bool = True) -> N references on roads that point to this junction. Set to False when the junction is being re-added immediately (e.g. modify). """ + connecting_road_ids: List[str] = [] if cleanup_road_refs: junction = self.get_junction(junction_id) if junction: @@ -1361,9 +1374,15 @@ def remove_junction(self, junction_id: str, cleanup_road_refs: bool = True) -> N road.predecessor_junction_id = None if road.successor_junction_id == junction_id: road.successor_junction_id = None + # Connecting roads exist only within their junction; remove them + # so they don't leave dangling junction_id references on export. + connecting_road_ids = list(junction.connecting_road_ids) self.junctions = [j for j in self.junctions if j.id != junction_id] + for cr_id in connecting_road_ids: + self.remove_road(cr_id) + def get_junction(self, junction_id: str) -> Optional[Junction]: """Get a junction by ID.""" for junction in self.junctions: @@ -1852,6 +1871,7 @@ def load(cls, file_path: Path) -> 'Project': project.cleanup_junction_connected_road_ids() project.cleanup_empty_junctions() project.clear_cross_junction_road_links() + project.prune_dangling_references() return project def clear(self) -> None: @@ -1935,6 +1955,39 @@ def cleanup_empty_junctions(self) -> int: logger.info(f"Removed {len(to_remove)} empty junction(s): {to_remove}") return len(to_remove) + def prune_dangling_references(self) -> int: + """ + Remove/clear references left dangling by older deletions. + + Drops connecting roads whose junction no longer exists, and clears + road_id on objects/signals/parking that point to a deleted road. + Repairs files saved before deletion cleanup was complete. + + Returns: + Number of dangling references repaired + """ + repaired = 0 + junction_ids = {j.id for j in self.junctions} + + orphan_crs = [ + r.id for r in self.roads + if r.is_connecting_road and r.junction_id not in junction_ids + ] + for cr_id in orphan_crs: + self.remove_road(cr_id) + repaired += 1 + + road_ids = {r.id for r in self.roads} + for entities in (self.objects, self.signals, self.parking_spaces): + for entity in entities: + if entity.road_id and entity.road_id not in road_ids: + entity.road_id = None + repaired += 1 + + if repaired > 0: + logger.info(f"Pruned {repaired} dangling reference(s) from deleted entities") + return repaired + def clear_cross_junction_road_links(self) -> int: """ Clear predecessor/successor links between roads that connect through junctions. diff --git a/orbit/models/road.py b/orbit/models/road.py index b5de281..ea13ec4 100644 --- a/orbit/models/road.py +++ b/orbit/models/road.py @@ -322,14 +322,21 @@ def get_cr_lane(self, lane_id: int) -> Optional[Lane]: return lane return None - def get_cr_lane_polygons(self, scale: float) -> Dict[int, List[Tuple[float, float]]]: + def get_cr_lane_polygons( + self, scale: float, + scale_x: Optional[float] = None, + scale_y: Optional[float] = None, + ) -> Dict[int, List[Tuple[float, float]]]: """ Generate lane boundary polygons for connecting road visualization. Only applicable to connecting roads (roads with inline_path set). Args: - scale: Meters per pixel scale factor + scale: Meters per pixel along the path (for length/s conversion). + scale_x, scale_y: Anisotropic m/px used to build lane width in metric + space so the perpendicular width is correct for any direction + (curves, roundabouts). Defaults to isotropic ``scale``. Returns: Dictionary mapping lane IDs to polygon point lists @@ -340,6 +347,9 @@ def get_cr_lane_polygons(self, scale: float) -> Dict[int, List[Tuple[float, floa create_variable_width_lane_polygon, ) + sx = scale_x if scale_x else scale + sy = scale_y if scale_y else scale + path = self.inline_path if not path or len(path) < 2: return {} @@ -360,6 +370,14 @@ def get_cr_lane_polygons(self, scale: float) -> Dict[int, List[Tuple[float, floa dy = path[i][1] - path[i - 1][1] s_values.append(s_values[-1] + math.sqrt(dx * dx + dy * dy)) + # Build lane widths in metric space so the perpendicular offset is correct + # for any direction under anisotropic scales. Offsets below are in metres; + # polygons are converted back to pixels via _to_px. + metric_path = [(x * sx, y * sy) for x, y in path] + + def _to_px(poly): + return [(x / sx, y / sy) for x, y in poly] + polygons: Dict[int, List[Tuple[float, float]]] = {} # Use distance-based (s_values) rendering when any lane has non-constant width. @@ -381,27 +399,28 @@ def get_cr_lane_polygons(self, scale: float) -> Dict[int, List[Tuple[float, floa if uses_distance_based_width and path_length_m > 0: def inner_width_func(s_px, _il=inner_lanes): s_m = s_px * scale - return sum(il.get_width_at_s(s_m, path_length_m) / scale for il in _il) + return sum(il.get_width_at_s(s_m, path_length_m) for il in _il) def lane_width_func(s_px, _l=lane): s_m = s_px * scale - return _l.get_width_at_s(s_m, path_length_m) / scale + return _l.get_width_at_s(s_m, path_length_m) polygon_points = create_polynomial_width_lane_polygon( - path, lane_id, inner_width_func, lane_width_func, s_values, is_left_lane=False) + metric_path, lane_id, inner_width_func, lane_width_func, + s_values, is_left_lane=False) elif lane.has_variable_width or any(il.has_variable_width for il in inner_lanes): - inner_offset_start = sum(il.width / scale for il in inner_lanes) - inner_offset_end = sum(il.get_width_at_end() / scale for il in inner_lanes) + inner_offset_start = sum(il.width for il in inner_lanes) + inner_offset_end = sum(il.get_width_at_end() for il in inner_lanes) polygon_points = create_variable_width_lane_polygon( - path, inner_offset_start, inner_offset_start + lane.width / scale, - inner_offset_end, inner_offset_end + lane.get_width_at_end() / scale) + metric_path, inner_offset_start, inner_offset_start + lane.width, + inner_offset_end, inner_offset_end + lane.get_width_at_end()) else: - inner_offset = sum(il.width / scale for il in inner_lanes) + inner_offset = sum(il.width for il in inner_lanes) polygon_points = create_lane_polygon( - path, inner_offset, inner_offset + lane.width / scale, closed=False) + metric_path, inner_offset, inner_offset + lane.width, closed=False) if polygon_points and len(polygon_points) >= 3: - polygons[lane_id] = polygon_points + polygons[lane_id] = _to_px(polygon_points) # Left-hand lanes (positive IDs) for lane_num in range(1, self.cr_lane_count_left + 1): @@ -414,27 +433,28 @@ def lane_width_func(s_px, _l=lane): if uses_distance_based_width and path_length_m > 0: def inner_width_func(s_px, _il=inner_lanes): s_m = s_px * scale - return sum(il.get_width_at_s(s_m, path_length_m) / scale for il in _il) + return sum(il.get_width_at_s(s_m, path_length_m) for il in _il) def lane_width_func(s_px, _l=lane): s_m = s_px * scale - return _l.get_width_at_s(s_m, path_length_m) / scale + return _l.get_width_at_s(s_m, path_length_m) polygon_points = create_polynomial_width_lane_polygon( - path, lane_id, inner_width_func, lane_width_func, s_values, is_left_lane=True) + metric_path, lane_id, inner_width_func, lane_width_func, + s_values, is_left_lane=True) elif lane.has_variable_width or any(il.has_variable_width for il in inner_lanes): - inner_offset_start = -sum(il.width / scale for il in inner_lanes) - inner_offset_end = -sum(il.get_width_at_end() / scale for il in inner_lanes) + inner_offset_start = -sum(il.width for il in inner_lanes) + inner_offset_end = -sum(il.get_width_at_end() for il in inner_lanes) polygon_points = create_variable_width_lane_polygon( - path, inner_offset_start, inner_offset_start - lane.width / scale, - inner_offset_end, inner_offset_end - lane.get_width_at_end() / scale) + metric_path, inner_offset_start, inner_offset_start - lane.width, + inner_offset_end, inner_offset_end - lane.get_width_at_end()) else: - inner_offset = -sum(il.width / scale for il in inner_lanes) + inner_offset = -sum(il.width for il in inner_lanes) polygon_points = create_lane_polygon( - path, inner_offset, inner_offset - lane.width / scale, closed=False) + metric_path, inner_offset, inner_offset - lane.width, closed=False) if polygon_points and len(polygon_points) >= 3: - polygons[lane_id] = polygon_points + polygons[lane_id] = _to_px(polygon_points) return polygons diff --git a/orbit/utils/coordinate_transform.py b/orbit/utils/coordinate_transform.py index 5ff21e8..9c63fa7 100644 --- a/orbit/utils/coordinate_transform.py +++ b/orbit/utils/coordinate_transform.py @@ -1323,7 +1323,10 @@ def pixel_to_geo(self, pixel_x: float, pixel_y: float) -> Tuple[float, float]: g = self.transform_matrix @ p east = g[0] / g[2] north = g[1] / g[2] - lat, lon = self.meters_to_latlon(east, north) + # transform_matrix yields local ENU metres around the nadir, so the + # inverse must use the local conversion regardless of any export + # projection (which only governs geo->projected metres for writing). + lat, lon = self.local_meters_to_latlon(east, north) return lon, lat def geo_to_pixel(self, longitude: float, latitude: float) -> Tuple[float, float]: @@ -1331,7 +1334,7 @@ def geo_to_pixel(self, longitude: float, latitude: float) -> Tuple[float, float] if self.inverse_matrix is None: raise RuntimeError("Transformation not initialized") - east, north = self.latlon_to_meters(latitude, longitude) + east, north = self.latlon_to_local_meters(latitude, longitude) g = np.array([east, north, 1.0]) p = self.inverse_matrix @ g pixel_x = p[0] / p[2] @@ -1344,7 +1347,7 @@ def geo_to_pixel(self, longitude: float, latitude: float) -> Tuple[float, float] def geo_to_pixel_unadjusted(self, longitude: float, latitude: float) -> Tuple[float, float]: """Convert geographic coordinates to pixel coordinates without adjustment.""" - east, north = self.latlon_to_meters(latitude, longitude) + east, north = self.latlon_to_local_meters(latitude, longitude) g = np.array([east, north, 1.0]) p = self.inverse_matrix @ g return p[0] / p[2], p[1] / p[2] diff --git a/orbit/utils/geometry.py b/orbit/utils/geometry.py index fe20a9a..7602b19 100644 --- a/orbit/utils/geometry.py +++ b/orbit/utils/geometry.py @@ -63,6 +63,31 @@ def offset_point(point: Tuple[float, float], perpendicular: Tuple[float, float], ) +def build_lane_polygon_metric( + centerline_px: List[Tuple[float, float]], + scale_x: float, + scale_y: float, + build_fn, +) -> List[Tuple[float, float]]: + """Run a perpendicular-offset polygon builder in metric space. + + Lane width is a real-world (perpendicular) distance, so under anisotropic + pixel scales (scale_x != scale_y) it cannot be reproduced by a single pixel + offset. This scales the centerline into metres, lets ``build_fn`` offset by + metres along the (now metric) perpendicular, then scales the polygon back to + pixels — exact for any road direction, including curves and roundabouts. + + ``build_fn`` receives the metric centerline and must return a metric polygon; + every offset/width it uses must be in metres. With no/zero scale the builder + is called directly on the pixel centerline (offsets treated as pixels). + """ + if not scale_x or not scale_y: + return build_fn(centerline_px) + metric_cl = [(x * scale_x, y * scale_y) for x, y in centerline_px] + metric_poly = build_fn(metric_cl) + return [(x / scale_x, y / scale_y) for x, y in metric_poly] + + def calculate_offset_polyline(points: List[Tuple[float, float]], offset_distance: float, closed: bool = False) -> List[Tuple[float, float]]: @@ -1211,16 +1236,20 @@ def calculate_directional_scale( points: List[Tuple[float, float]], scale_x: float, scale_y: float, - default_scale: Optional[float] = None + default_scale: Optional[float] = None, + perpendicular: bool = False, ) -> float: """ Calculate appropriate scale factor based on polyline direction. - For roads/polylines running primarily horizontal (east-west), weight scale_x more. - For roads/polylines running primarily vertical (north-south), weight scale_y more. - For diagonal roads, interpolate between scale_x and scale_y. + Default (``perpendicular=False``) gives the scale *along* the polyline, + used for length/s conversions: a horizontal road weights scale_x. - This accounts for non-uniform pixel scales in images where scale_x != scale_y. + With ``perpendicular=True`` it gives the scale across the polyline (i.e. for + lane width / lateral offset): a horizontal road's width is vertical, so it + weights scale_y. Note this is a single per-polyline scalar — exact only for + straight roads; build width in metric space (build_lane_polygon_metric) for + curves/roundabouts. Args: points: List of (x, y) points defining the polyline. @@ -1228,12 +1257,14 @@ def calculate_directional_scale( scale_y: Scale factor for vertical direction (m/px). default_scale: Value to return if scale cannot be calculated. Defaults to average of scale_x and scale_y. + perpendicular: If True, return the across-polyline (width) scale. Returns: Scale factor in meters per pixel appropriate for this polyline's direction. Example: - scale = calculate_directional_scale(centerline.points, scale_x, scale_y) + # lateral width conversion (perpendicular): + scale = calculate_directional_scale(centerline.points, sx, sy, perpendicular=True) width_m = width_px * scale """ if len(points) < 2: @@ -1264,7 +1295,10 @@ def calculate_directional_scale( weight_x = total_dx / total_dist weight_y = total_dy / total_dist - # Interpolate between scale_x and scale_y based on direction + # Interpolate between scale_x and scale_y based on direction. For width + # (perpendicular) the axes swap: a horizontal road's width runs vertical. + if perpendicular: + return weight_x * scale_y + weight_y * scale_x return weight_x * scale_x + weight_y * scale_y diff --git a/pyproject.toml b/pyproject.toml index a580ba0..9cbda40 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "orbit" -version = "0.11.1" +version = "0.12.1" description = "OpenDrive Road Builder from Imagery Tool" readme = "README.md" requires-python = ">=3.10" diff --git a/tests/unit/test_gui/test_adjustment_edit_compose.py b/tests/unit/test_gui/test_adjustment_edit_compose.py new file mode 100644 index 0000000..f08db4e --- /dev/null +++ b/tests/unit/test_gui/test_adjustment_edit_compose.py @@ -0,0 +1,68 @@ +"""Regression test: live adjustment edits must compose onto the drone base. + +on_adjustment_changed must apply the live delta on top of the stored drone-assisted +base (not replace it), otherwise the first keypress drops the base correction and the +geometry jumps — perceived as a shift rather than a stretch. +""" + +import os +import types + +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + +from orbit.gui.main_window import MainWindow +from orbit.models.project import Project +from orbit.utils.coordinate_transform import TransformAdjustment + +BASE = TransformAdjustment( + translation_x=-6.5, translation_y=42.0, rotation=-2.55, + scale_x=1.003, scale_y=0.86, pivot_x=1920.0, pivot_y=1080.0, +) + + +class _RecordingTransformer: + def __init__(self): + self.adjustment = None + + def set_adjustment(self, adj): + self.adjustment = adj + + +def _make_self(method, current_adjustment): + project = Project(transform_method=method) + project.transform_adjustment = BASE.to_dict() + obj = types.SimpleNamespace( + project=project, + image_view=types.SimpleNamespace( + current_adjustment=current_adjustment, + update_all_from_geo_coords=lambda t: None, + ), + _cached_transformer=_RecordingTransformer(), + adjustment_panel=types.SimpleNamespace(update_display=lambda *a, **k: None), + ) + for name in ("on_adjustment_changed", "_apply_active_adjustment", + "_compose_with_drone_base"): + setattr(obj, name, types.MethodType(getattr(MainWindow, name), obj)) + return obj + + +def test_live_edit_composes_onto_drone_base(): + """A live stretch delta must be composed with the base, not replace it.""" + delta = TransformAdjustment(scale_y=1.005, pivot_x=1920.0, pivot_y=1080.0) + s = _make_self("drone_assisted", delta) + s.on_adjustment_changed(delta) + + applied = s._cached_transformer.adjustment + import numpy as np + expected = delta.get_adjustment_matrix() @ BASE.get_adjustment_matrix() + # The applied adjustment must equal base composed with delta (not the bare delta). + assert np.allclose(applied.get_adjustment_matrix(), expected, atol=1e-6) + assert not np.allclose(applied.get_adjustment_matrix(), + delta.get_adjustment_matrix(), atol=1e-6) + + +def test_identity_edit_keeps_drone_base(): + """An identity delta must leave the stored base applied.""" + s = _make_self("drone_assisted", TransformAdjustment(pivot_x=1920.0, pivot_y=1080.0)) + s.on_adjustment_changed(s.image_view.current_adjustment) + assert s._cached_transformer.adjustment.to_dict() == BASE.to_dict() diff --git a/tests/unit/test_gui/test_object_graphics_reposition.py b/tests/unit/test_gui/test_object_graphics_reposition.py new file mode 100644 index 0000000..83cc895 --- /dev/null +++ b/tests/unit/test_gui/test_object_graphics_reposition.py @@ -0,0 +1,54 @@ +"""ObjectGraphicsItem must follow its model position when re-projected. + +Point objects (trees, lampposts, cones, simple buildings) are positioned via +setPos with the path centred at the origin. update_graphics rebuilds the path +but must also re-sync the scene position from obj.position; otherwise an +adjustment that re-projects geo coords moves the model but leaves the on-screen +object behind (the reported "trees don't move when shifting/stretching" bug). +""" + +import os + +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + +from PyQt6.QtCore import QPointF +from PyQt6.QtWidgets import QApplication + +from orbit.gui.graphics.object_graphics_item import ObjectGraphicsItem +from orbit.models.object import ObjectType, RoadObject + +_app = QApplication.instance() or QApplication([]) + + +def _tree(position): + obj = RoadObject(object_id="t1", position=position, + object_type=ObjectType.TREE_CONIFER) + obj.geo_position = (12.0, 57.0) + return obj + + +def test_point_object_follows_model_position_on_update(): + obj = _tree((100.0, 200.0)) + item = ObjectGraphicsItem(obj) + assert item.pos() == QPointF(100.0, 200.0) + + # Simulate an adjustment re-projecting geo->pixel to a new position. + obj.position = (150.0, 260.0) + item.update_graphics() + + assert item.pos() == QPointF(150.0, 260.0) + # Programmatic move must not wipe the geo source of truth. + assert obj.geo_position == (12.0, 57.0) + + +def test_programmatic_move_does_not_notify_change(): + obj = _tree((0.0, 0.0)) + item = ObjectGraphicsItem(obj) + calls = [] + item.object_changed = calls.append + + obj.position = (40.0, 40.0) + item.update_graphics() + + assert calls == [] # no spurious "modified" notification + assert obj.geo_position == (12.0, 57.0) diff --git a/tests/unit/test_gui/test_reset_adjustment.py b/tests/unit/test_gui/test_reset_adjustment.py new file mode 100644 index 0000000..2a4131d --- /dev/null +++ b/tests/unit/test_gui/test_reset_adjustment.py @@ -0,0 +1,105 @@ +"""Regression tests for MainWindow.reset_adjustment in drone-assisted mode. + +For drone-assisted transformers the applied correction lives in +project.transform_adjustment (it cannot be baked into control points). +Resetting the live adjustment must therefore re-apply that stored correction +so the visible alignment keeps the already-applied change rather than +reverting to the uncorrected image. +""" + +import os +import types + +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + +from orbit.gui.main_window import MainWindow +from orbit.models.project import Project +from orbit.utils.coordinate_transform import TransformAdjustment + + +class _FakeImageView: + """Stand-in for ImageView exposing only what reset_adjustment touches.""" + + def __init__(self, adjustment): + self.current_adjustment = adjustment + + def reset_adjustment(self): + # Mirrors ImageView.reset_adjustment: collapse the live delta to identity. + if self.current_adjustment is not None: + self.current_adjustment.reset() + + def get_adjustment(self): + return self.current_adjustment + + +def _make_self(transformer, project, image_view): + """Build a minimal object that the real MainWindow methods can run against.""" + obj = types.SimpleNamespace( + project=project, + image_view=image_view, + _cached_transformer=transformer, + _refreshed=False, + ) + obj.refresh_imported_geometry = lambda: setattr(obj, "_refreshed", True) + obj._remove_adjustment_ghost = lambda: None + obj.statusBar = lambda: types.SimpleNamespace(showMessage=lambda *a, **k: None) + # Bind the real methods under test so their actual bodies run. + obj._apply_active_adjustment = types.MethodType( + MainWindow._apply_active_adjustment, obj) + obj.reset_adjustment = types.MethodType(MainWindow.reset_adjustment, obj) + return obj + + +class _RecordingTransformer: + """Transformer stub tracking the last adjustment applied to it.""" + + def __init__(self): + self.adjustment = None + + def set_adjustment(self, adj): + self.adjustment = adj + + def clear_adjustment(self): + self.adjustment = None + + +def test_reset_reapplies_stored_drone_adjustment(): + """Reset must restore the stored correction for drone-assisted mode.""" + stored = TransformAdjustment( + translation_x=12.0, translation_y=-7.0, + rotation=0.15, scale_x=1.02, scale_y=0.98, + pivot_x=300.0, pivot_y=250.0, + ) + project = Project(transform_method="drone_assisted") + project.transform_adjustment = stored.to_dict() + + transformer = _RecordingTransformer() + # A live UI delta that should be discarded by reset. + image_view = _FakeImageView(TransformAdjustment(translation_x=5.0)) + + self_obj = _make_self(transformer, project, image_view) + self_obj.reset_adjustment() + + # The stored correction must be re-applied to the transformer (not cleared). + assert transformer.adjustment is not None + assert not transformer.adjustment.is_identity() + assert transformer.adjustment.to_dict() == stored.to_dict() + # The live delta is collapsed to identity. + assert image_view.current_adjustment.is_identity() + assert self_obj._refreshed is True + + +def test_reset_clears_adjustment_for_non_drone(): + """Reset leaves the transformer cleared when no stored correction applies.""" + project = Project(transform_method="affine") + project.transform_adjustment = None + + transformer = _RecordingTransformer() + transformer.set_adjustment(TransformAdjustment(translation_x=9.0)) + image_view = _FakeImageView(TransformAdjustment(translation_x=9.0)) + + self_obj = _make_self(transformer, project, image_view) + self_obj.reset_adjustment() + + # Nothing to restore: transformer stays cleared. + assert transformer.adjustment is None diff --git a/tests/unit/test_gui/test_restore_adjustment.py b/tests/unit/test_gui/test_restore_adjustment.py new file mode 100644 index 0000000..54dd9d3 --- /dev/null +++ b/tests/unit/test_gui/test_restore_adjustment.py @@ -0,0 +1,81 @@ +"""Regression test: drone-assisted adjustment must not be applied twice. + +On load the stored drone-assisted adjustment is the permanent base. It must +not also be placed into image_view.current_adjustment, or _apply_active_adjustment +would later compose the base on top of itself (double transform — observed when +returning from aerial view). +""" + +import os +import types + +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + +from orbit.gui.main_window import MainWindow +from orbit.models.project import Project +from orbit.utils.coordinate_transform import TransformAdjustment + +A = TransformAdjustment( + translation_x=-6.5, translation_y=42.0, rotation=-2.55, + scale_x=1.003, scale_y=0.86, pivot_x=1920.0, pivot_y=1080.0, +) + + +class _RecordingTransformer: + def __init__(self): + self.adjustment = None + + def set_adjustment(self, adj): + self.adjustment = adj + + def clear_adjustment(self): + self.adjustment = None + + +class _FakeImageView: + def __init__(self): + self.current_adjustment = None + self.updated_with = None + + def update_all_from_geo_coords(self, transformer): + self.updated_with = transformer + + +def _make_self(method): + project = Project(transform_method=method) + project.transform_adjustment = A.to_dict() + obj = types.SimpleNamespace( + project=project, + image_view=_FakeImageView(), + _cached_transformer=_RecordingTransformer(), + adjustment_panel=types.SimpleNamespace(update_display=lambda *a, **k: None), + ) + obj._create_transformer = lambda **k: _RecordingTransformer() + for name in ("_restore_adjustment_from_project", "_apply_active_adjustment", + "_compose_with_drone_base"): + setattr(obj, name, types.MethodType(getattr(MainWindow, name), obj)) + return obj + + +def test_drone_restore_keeps_current_adjustment_identity(): + """Drone-assisted: restore must not push the stored adjustment into the live delta.""" + s = _make_self("drone_assisted") + s._restore_adjustment_from_project() + + # Live delta stays empty; the base is on the transformer (single application). + assert s.image_view.current_adjustment is None + assert s._cached_transformer.adjustment.to_dict() == A.to_dict() + + # A subsequent _apply_active_adjustment (as on an aerial switch) must apply the + # stored base ONCE, not compose it with a live copy of itself. + fresh = _RecordingTransformer() + s._apply_active_adjustment(fresh) + assert fresh.adjustment.to_dict() == A.to_dict() + + +def test_non_drone_restore_loads_live_adjustment(): + """Homography/affine: the stored value is an unbaked live adjustment to restore.""" + s = _make_self("homography") + s._restore_adjustment_from_project() + assert s.image_view.current_adjustment is not None + assert s.image_view.current_adjustment.to_dict() == A.to_dict() diff --git a/tests/unit/test_import/test_opendrive_importer.py b/tests/unit/test_import/test_opendrive_importer.py index b083ce9..e457c56 100644 --- a/tests/unit/test_import/test_opendrive_importer.py +++ b/tests/unit/test_import/test_opendrive_importer.py @@ -899,6 +899,64 @@ def test_import_section_with_lanes(self, importer): assert len(sections) == 1 assert len(sections[0].lanes) == 2 # left + right (no center) + @staticmethod + def _tapering_lane(lane_id): + width = Mock() + width.a, width.b, width.c, width.d = 3.0, 0.01, 0.0, 0.0 + lane = Mock() + lane.id = lane_id + lane.type = "driving" + lane.widths = [width] + lane.road_marks = [] + lane.speed_limits = [] + lane.materials = [] + lane.heights = [] + lane.link = None + lane.direction = "forward" + lane.advisory = None + lane.level = False + return lane + + def test_single_section_linear_taper_sets_width_end(self, importer): + """A linear width polynomial becomes an editable width_end on a single-section road.""" + mock_section = Mock() + mock_section.s = 0.0 + mock_section.single_side = None + mock_section.left_lanes = [] + mock_section.right_lanes = [self._tapering_lane(-1)] + + sections = importer._import_lane_sections( + [mock_section], 100.0, [(0, 0), (100, 0)] + ) + + lane = sections[0].lanes[0] + # width_end = a + b*length = 3.0 + 0.01*100 = 4.0; polynomial cleared. + assert lane.width == pytest.approx(3.0) + assert lane.width_end == pytest.approx(4.0) + assert lane.width_b == 0.0 + assert lane.has_variable_width + + def test_multi_section_keeps_polynomial(self, importer): + """Multi-section roads keep the polynomial (render length differs from section).""" + s1 = Mock() + s1.s = 0.0 + s1.single_side = None + s1.left_lanes = [] + s1.right_lanes = [self._tapering_lane(-1)] + s2 = Mock() + s2.s = 50.0 + s2.single_side = None + s2.left_lanes = [] + s2.right_lanes = [self._tapering_lane(-1)] + + sections = importer._import_lane_sections( + [s1, s2], 100.0, [(0, 0), (50, 0), (100, 0)] + ) + + lane = sections[0].lanes[0] + assert lane.width_b == pytest.approx(0.01) + assert lane.width_end is None + class TestImportResultWarnings: """Tests for ImportResult warnings handling.""" diff --git a/tests/unit/test_models/test_road.py b/tests/unit/test_models/test_road.py index 8d803da..bcd7c9b 100644 --- a/tests/unit/test_models/test_road.py +++ b/tests/unit/test_models/test_road.py @@ -1018,6 +1018,15 @@ def test_variable_width_uses_distance_based_rendering(self): # Polygon should exist and have a reasonable number of points assert len(polygons[-1]) >= 6 + def test_anisotropic_horizontal_cr_width_uses_scale_y(self): + """A horizontal CR's width runs in Y, so it must convert with scale_y.""" + cr = self._make_cr(lane_width_start=3.5, lane_width_end=3.5) + sx, sy = 0.05, 0.10 # anisotropic + polygons = cr.get_cr_lane_polygons((sx + sy) / 2, sx, sy) + half_width_px = max(abs(y) for _, y in polygons[-1]) + # Correct = 3.5/scale_y = 35 px; the old single-scalar bug gave ~3.5/0.075 = 47. + assert half_width_px == pytest.approx(3.5 / sy, abs=1.0) + def test_per_lane_variable_width_different_polygons(self): """Left and right lanes with different widths produce different polygons.""" cr = self._make_cr(lane_width_start=3.0, lane_width_end=3.0) diff --git a/tests/unit/test_utils/test_metric_lane_width.py b/tests/unit/test_utils/test_metric_lane_width.py new file mode 100644 index 0000000..031624a --- /dev/null +++ b/tests/unit/test_utils/test_metric_lane_width.py @@ -0,0 +1,76 @@ +"""Lane width must be correct under anisotropic pixel scales (scale_x != scale_y). + +Width is a real-world perpendicular distance, so converting metres->pixels must use +the across-road axis, per-point. build_lane_polygon_metric builds the polygon in +metric space, which is exact for any direction (straight, diagonal, roundabout). +""" + +import pytest + +from orbit.utils.geometry import ( + build_lane_polygon_metric, + calculate_directional_scale, + create_lane_polygon, +) + +SX, SY = 0.05, 0.10 # anisotropic m/px + + +def _constant_lane(centerline, sx, sy, width_m=3.5): + return build_lane_polygon_metric( + centerline, sx, sy, + lambda c: create_lane_polygon(c, 0.0, width_m, closed=False)) + + +def test_horizontal_road_width_uses_scale_y(): + """A horizontal road's width runs in Y, so it must convert with scale_y.""" + poly = _constant_lane([(0, 0), (100, 0)], SX, SY) + half_width_px = max(abs(y) for _, y in poly) + assert half_width_px == pytest.approx(3.5 / SY) # 35 px, not 3.5/SX = 70 + + +def test_vertical_road_width_uses_scale_x(): + """A vertical road's width runs in X, so it must convert with scale_x.""" + poly = _constant_lane([(0, 0), (0, 100)], SX, SY) + half_width_px = max(abs(x) for x, _ in poly) + assert half_width_px == pytest.approx(3.5 / SX) # 70 px + + +def test_direction_aware_for_bent_road(): + """An L-shaped road gets the correct width on each leg from a single build.""" + poly = _constant_lane([(0, 0), (100, 0), (100, 100)], SX, SY) + # Horizontal leg edge (low-y region): perpendicular offset is in Y ~ 3.5/SY. + h_leg = [(x, y) for x, y in poly if y < 50] + assert max(abs(y) for _, y in h_leg) == pytest.approx(3.5 / SY, abs=2.0) # ~35 + # Vertical leg edge (high-y region): perpendicular offset is in X ~ 3.5/SX. + v_leg = [(x, y) for x, y in poly if y > 50] + assert max(abs(x - 100) for x, _ in v_leg) == pytest.approx(3.5 / SX, abs=2.0) # ~70 + + +def test_isotropic_matches_plain_pixel_build(): + """With scale_x == scale_y the metric build equals the plain pixel build.""" + s = 0.08 + metric = _constant_lane([(0, 0), (100, 0)], s, s) + plain = create_lane_polygon([(0, 0), (100, 0)], 0.0, 3.5 / s, closed=False) + assert len(metric) == len(plain) + for (mx, my), (px, py) in zip(metric, plain): + assert mx == pytest.approx(px) + assert my == pytest.approx(py) + + +def test_perpendicular_directional_scale(): + """perpendicular=True swaps axes for width (horizontal -> scale_y).""" + horiz = [(0, 0), (100, 0)] + vert = [(0, 0), (0, 100)] + assert calculate_directional_scale(horiz, SX, SY, perpendicular=True) == pytest.approx(SY) + assert calculate_directional_scale(vert, SX, SY, perpendicular=True) == pytest.approx(SX) + # Default (length) keeps the parallel axis. + assert calculate_directional_scale(horiz, SX, SY) == pytest.approx(SX) + + +def test_no_scale_falls_back_to_pixel_offsets(): + """Zero/None scale leaves the builder operating directly in pixels.""" + cl = [(0, 0), (100, 0)] + poly = build_lane_polygon_metric( + cl, 0.0, 0.0, lambda c: create_lane_polygon(c, 0.0, 10.0, closed=False)) + assert max(abs(y) for _, y in poly) == pytest.approx(10.0) diff --git a/uv.lock b/uv.lock index d24de0f..565e3a4 100644 --- a/uv.lock +++ b/uv.lock @@ -524,7 +524,7 @@ wheels = [ [[package]] name = "orbit" -version = "0.11.0" +version = "0.12.0" source = { editable = "." } dependencies = [ { name = "geomag" },