diff --git a/negpy/desktop/controller.py b/negpy/desktop/controller.py index 9283091f..584b9e56 100644 --- a/negpy/desktop/controller.py +++ b/negpy/desktop/controller.py @@ -50,6 +50,7 @@ from negpy.features.exposure.models import ExposureConfig from negpy.features.finish.models import FinishConfig from negpy.features.geometry.logic import apply_fine_rotation, detect_closest_aspect_ratio +from negpy.features.geometry.models import FINE_ROTATION_LIMIT from negpy.features.lab.models import LabConfig from negpy.features.local.models import LocalAdjustmentsConfig from negpy.features.process.models import ProcessMode, invalidate_local_bounds @@ -816,6 +817,21 @@ def handle_crop_rotation_changed(self, angle: float, persist: bool) -> None: else: self._render_debounce.start() + def handle_straighten_completed(self, delta_deg: float) -> None: + """Applies the straighten tool's measured correction on top of the current + fine rotation and closes the tool (one-shot, like a Lightroom straighten + line). ``delta_deg`` is stored-convention (positive = CCW on screen) and + display-space, so it composes additively under flips/90° turns.""" + if self.state.active_tool != ToolMode.STRAIGHTEN: + return + current = self.state.config.geometry.fine_rotation + new_angle = float(np.clip(current + delta_deg, -FINE_ROTATION_LIMIT, FINE_ROTATION_LIMIT)) + new_geo = replace(self.state.config.geometry, fine_rotation=new_angle) + self.session.update_config(replace(self.state.config, geometry=new_geo), persist=True) + self.rotation_guide_requested.emit() + self.set_active_tool(ToolMode.NONE) + self.request_render() + def confirm_manual_crop(self) -> None: """Close the crop tool (committing the current rect) — invoked by a double-click inside the crop box so the user needn't return to the Crop button.""" diff --git a/negpy/desktop/session.py b/negpy/desktop/session.py index f2ae0ace..cbd2a65f 100644 --- a/negpy/desktop/session.py +++ b/negpy/desktop/session.py @@ -23,6 +23,7 @@ class ToolMode(Enum): SCRATCH_PICK = auto() LOCAL_DRAW = auto() ANALYSIS_DRAW = auto() + STRAIGHTEN = auto() @dataclass diff --git a/negpy/desktop/view/canvas/overlay.py b/negpy/desktop/view/canvas/overlay.py index bc47f799..5933a16d 100644 --- a/negpy/desktop/view/canvas/overlay.py +++ b/negpy/desktop/view/canvas/overlay.py @@ -1,3 +1,4 @@ +import math import sys from typing import Dict, List, Optional, Tuple @@ -11,7 +12,7 @@ from negpy.desktop.session import AppState, ToolMode from negpy.desktop.view.canvas.crop_guides import CropGuide, guide_shapes from negpy.desktop.view.styles.theme import THEME -from negpy.features.geometry.logic import rotation_drag_angle, translate_manual_crop_rect +from negpy.features.geometry.logic import rotation_drag_angle, straighten_delta_degrees, translate_manual_crop_rect from negpy.features.local.logic import _rasterise_mask from negpy.kernel.system.config import APP_CONFIG @@ -64,6 +65,7 @@ class CanvasOverlay(QWidget): lasso_completed = pyqtSignal(list) scratch_completed = pyqtSignal(list) local_mask_selected = pyqtSignal(int) + straighten_completed = pyqtSignal(float) # fine-rotation delta, stored convention (CCW+) def __init__(self, state: AppState, parent=None): super().__init__(parent) @@ -110,6 +112,10 @@ def __init__(self, state: AppState, parent=None): self._local_mask_screen_polys: List[List[QPointF]] = [] self._mask_img_cache: Dict[tuple, QImage] = {} + # Straighten tool: reference-line drag (press -> drag -> release applies). + self._straighten_p1: Optional[QPointF] = None + self._straighten_p2: Optional[QPointF] = None + self.zoom_level: float = 1.0 self.pan_x: float = 0.0 self.pan_y: float = 0.0 @@ -192,6 +198,9 @@ def set_tool_mode(self, mode: ToolMode) -> None: self._lasso_drawing = False if mode != ToolMode.SCRATCH_PICK: self._scratch_pts = [] + if mode != ToolMode.STRAIGHTEN: + self._straighten_p1 = None + self._straighten_p2 = None self.update() def _end_crop_drag(self) -> None: @@ -220,6 +229,10 @@ def _cancel_lasso(self) -> None: elif self._tool_mode == ToolMode.SCRATCH_PICK and self._scratch_pts: self._scratch_pts = [] self.update() + elif self._tool_mode == ToolMode.STRAIGHTEN and self._straighten_p1 is not None: + self._straighten_p1 = None + self._straighten_p2 = None + self.update() def update_buffer( self, @@ -354,6 +367,8 @@ def _draw_ui(self, painter: QPainter) -> None: self._draw_placed_heals(painter) if self._tool_mode == ToolMode.SCRATCH_PICK: self._draw_scratch_in_progress(painter) + if self._tool_mode == ToolMode.STRAIGHTEN: + self._draw_straighten_line(painter) if self._rotation_grid_visible: self._draw_rotation_grid(painter, visible_rect) @@ -442,6 +457,36 @@ def _draw_scratch_in_progress(self, painter: QPainter) -> None: painter.setPen(Qt.PenStyle.NoPen) painter.drawEllipse(self._scratch_pts[0], 3.0, 3.0) + def _draw_straighten_line(self, painter: QPainter) -> None: + """Reference line being dragged with the straighten tool, plus a badge + previewing the correction (display convention: positive = clockwise).""" + if self._straighten_p1 is None or self._straighten_p2 is None: + return + p1, p2 = self._straighten_p1, self._straighten_p2 + + pen = QPen(Qt.GlobalColor.white, 1.5, Qt.PenStyle.SolidLine) + pen.setCosmetic(True) + painter.setPen(pen) + painter.setBrush(Qt.BrushStyle.NoBrush) + painter.drawLine(p1, p2) + painter.setBrush(QColor(255, 255, 255, 200)) + painter.setPen(Qt.PenStyle.NoPen) + painter.drawEllipse(p1, 3.0, 3.0) + painter.drawEllipse(p2, 3.0, 3.0) + + dx, dy = p2.x() - p1.x(), p2.y() - p1.y() + if math.hypot(dx, dy) < 8.0: + return + delta = straighten_delta_degrees(dx, dy) + vertical = abs(abs(math.degrees(math.atan2(dy, dx))) - 90.0) < 45.0 + label = f"{'Plumb' if vertical else 'Level'} {-delta:+.2f}°" + mid = QPointF((p1.x() + p2.x()) / 2.0, (p1.y() + p2.y()) / 2.0) + badge = QRectF(mid.x() - 52, mid.y() - 26, 104, 22) + painter.setBrush(QColor(0, 0, 0, 170)) + painter.drawRoundedRect(badge, 4, 4) + painter.setPen(QColor(THEME.accent_primary)) + painter.drawText(badge, Qt.AlignmentFlag.AlignCenter, label) + def _draw_placed_heals(self, painter: QPainter) -> None: """Thin outlines of committed heals (strokes + legacy spots) while a retouch tool is active.""" conf = self.state.config.retouch @@ -764,7 +809,9 @@ def _draw_rotation_handles(self, painter: QPainter, corners: Dict[str, QPointF]) painter.setPen(Qt.PenStyle.NoPen) painter.drawRoundedRect(badge, 4, 4) painter.setPen(QColor(THEME.accent_primary)) - painter.drawText(badge, Qt.AlignmentFlag.AlignCenter, f"{self._rotate_current:+.2f}°") + # Badge shows the display convention (positive = clockwise on screen), + # matching the Fine Rotation slider; _rotate_current is stored-convention. + painter.drawText(badge, Qt.AlignmentFlag.AlignCenter, f"{-self._rotate_current:+.2f}°") def _analysis_rect_screen(self) -> Optional[QRectF]: """Screen rect for the current analysis region, or None if unset.""" @@ -904,6 +951,16 @@ def mousePressEvent(self, event: QMouseEvent) -> None: event.accept() return + if self._tool_mode == ToolMode.STRAIGHTEN: + # Left-click draws the reference line; other buttons pass through. + if event.button() == Qt.MouseButton.LeftButton: + if self._view_rect.contains(event.position()): + self._straighten_p1 = event.position() + self._straighten_p2 = event.position() + self.update() + event.accept() + return + if self._tool_mode == ToolMode.ANALYSIS_DRAW: self._start_analysis_drag(event.position()) self.update() @@ -1059,6 +1116,15 @@ def mouseMoveEvent(self, event: QMouseEvent) -> None: event.accept() return + if self._tool_mode == ToolMode.STRAIGHTEN and self._straighten_p1 is not None: + self._straighten_p2 = QPointF( + float(np.clip(event.position().x(), self._view_rect.left(), self._view_rect.right())), + float(np.clip(event.position().y(), self._view_rect.top(), self._view_rect.bottom())), + ) + self.update() + event.accept() + return + if self._crop_drag_mode == "draw" and self._crop_draw_p1 is not None: mx = np.clip(event.position().x(), self._view_rect.left(), self._view_rect.right()) my = np.clip(event.position().y(), self._view_rect.top(), self._view_rect.bottom()) @@ -1181,6 +1247,18 @@ def mouseReleaseEvent(self, event: QMouseEvent) -> None: event.accept() return + if self._tool_mode == ToolMode.STRAIGHTEN and self._straighten_p1 is not None: + p1, p2 = self._straighten_p1, self._straighten_p2 or self._straighten_p1 + self._straighten_p1 = None + self._straighten_p2 = None + dx, dy = p2.x() - p1.x(), p2.y() - p1.y() + # Ignore accidental clicks — a reference line needs some length. + if math.hypot(dx, dy) >= 8.0: + self.straighten_completed.emit(straighten_delta_degrees(dx, dy)) + self.update() + event.accept() + return + if self._crop_drag_mode == "rotate": if self._rotate_current is not None: self.crop_rotation_changed.emit(self._rotate_current, True) diff --git a/negpy/desktop/view/canvas/widget.py b/negpy/desktop/view/canvas/widget.py index 627d0f98..1d96dbd0 100644 --- a/negpy/desktop/view/canvas/widget.py +++ b/negpy/desktop/view/canvas/widget.py @@ -36,6 +36,7 @@ def clamp_canvas_zoom_level(zoom: float) -> float: ToolMode.DUST_PICK: Qt.CursorShape.BlankCursor, ToolMode.LOCAL_DRAW: Qt.CursorShape.CrossCursor, ToolMode.ANALYSIS_DRAW: Qt.CursorShape.CrossCursor, + ToolMode.STRAIGHTEN: Qt.CursorShape.CrossCursor, } # Do not apply more than this many notch-equivalents in a single event (huge flings). _WHEEL_MAX_NOTCHES = 4.0 @@ -85,6 +86,7 @@ class ImageCanvas(QWidget): cursor_left_canvas = pyqtSignal() lasso_completed = pyqtSignal(list) scratch_completed = pyqtSignal(list) + straighten_completed = pyqtSignal(float) local_mask_selected = pyqtSignal(int) def __init__(self, state: AppState, parent=None): @@ -135,6 +137,7 @@ def __init__(self, state: AppState, parent=None): self.overlay.cursor_left.connect(self.cursor_left_canvas.emit) self.overlay.lasso_completed.connect(self.lasso_completed.emit) self.overlay.scratch_completed.connect(self.scratch_completed.emit) + self.overlay.straighten_completed.connect(self.straighten_completed.emit) self.overlay.local_mask_selected.connect(self.local_mask_selected.emit) self.hud = CanvasHud(self) diff --git a/negpy/desktop/view/keyboard_shortcuts.py b/negpy/desktop/view/keyboard_shortcuts.py index 35785f39..7917ccf1 100644 --- a/negpy/desktop/view/keyboard_shortcuts.py +++ b/negpy/desktop/view/keyboard_shortcuts.py @@ -45,6 +45,7 @@ def _build_actions(self) -> dict[str, Callable[[], None]]: "lock_bounds_toggle": lambda: controls.process_sidebar.lock_bounds_btn.toggle(), "pick_wb": lambda: controls.colour_sidebar.pick_wb_btn.toggle(), "manual_crop": lambda: controls.geometry_sidebar.manual_crop_btn.toggle(), + "straighten": lambda: controls.geometry_sidebar.straighten_btn.toggle(), "crop_guide_next": lambda: controls.geometry_sidebar.cycle_guide(), "crop_guide_orient": controller.cycle_crop_guide_orientation, "auto_crop": lambda: controls.geometry_sidebar.reset_crop_btn.toggle(), diff --git a/negpy/desktop/view/main_window.py b/negpy/desktop/view/main_window.py index 0a302203..2ddaca5b 100644 --- a/negpy/desktop/view/main_window.py +++ b/negpy/desktop/view/main_window.py @@ -326,6 +326,7 @@ def _connect_signals(self) -> None: self.canvas.analysis_confirmed.connect(self.controller.confirm_analysis_region) self.canvas.lasso_completed.connect(self.controller.handle_lasso_completed) self.canvas.scratch_completed.connect(self.controller.handle_heal_stroke_completed) + self.canvas.straighten_completed.connect(self.controller.handle_straighten_completed) self.canvas.local_mask_selected.connect(self.controller.select_local_mask) self.controller.export_progress.connect(self._on_export_progress) diff --git a/negpy/desktop/view/shortcut_registry.py b/negpy/desktop/view/shortcut_registry.py index b3b5319a..293f724f 100644 --- a/negpy/desktop/view/shortcut_registry.py +++ b/negpy/desktop/view/shortcut_registry.py @@ -19,8 +19,9 @@ class ShortcutEntry: "flip_v": ShortcutEntry("V", "Flip vertical", "Geometry"), "offset_dec": ShortcutEntry("Z", "Crop offset down", "Geometry"), "offset_inc": ShortcutEntry("X", "Crop offset up", "Geometry"), - "fine_rot_dec": ShortcutEntry("Alt+Shift+R", "Fine rotation down", "Geometry"), - "fine_rot_inc": ShortcutEntry("Alt+R", "Fine rotation up", "Geometry"), + "fine_rot_dec": ShortcutEntry("Alt+Shift+R", "Fine rotation counter-clockwise", "Geometry"), + "fine_rot_inc": ShortcutEntry("Alt+R", "Fine rotation clockwise", "Geometry"), + "straighten": ShortcutEntry("L", "Toggle straighten line tool", "Geometry"), "pick_wb": ShortcutEntry("Shift+W", "Toggle WB picker", "Tools"), "manual_crop": ShortcutEntry("Shift+C", "Toggle manual crop", "Tools"), "crop_guide_next": ShortcutEntry("O", "Next crop guide overlay", "Geometry"), diff --git a/negpy/desktop/view/sidebar/controls_panel.py b/negpy/desktop/view/sidebar/controls_panel.py index a0342f7c..3d866145 100644 --- a/negpy/desktop/view/sidebar/controls_panel.py +++ b/negpy/desktop/view/sidebar/controls_panel.py @@ -431,6 +431,14 @@ def apply_shortcut_tooltips(self) -> None: "manual_crop", ) ) + geo.straighten_btn.setToolTip( + tooltip_with_shortcut( + "Straighten with a reference line — draw along the horizon or a vertical edge " + "(a building, a door frame) and the image rotates to make it level or plumb. " + "Applies once per line; Esc cancels an in-progress line", + "straighten", + ) + ) geo.offset_slider.setToolTip( tooltip_with_shortcut( "Insets the auto-crop border from the detected film edge. Positive = trim more; negative = bleed outside", @@ -439,7 +447,7 @@ def apply_shortcut_tooltips(self) -> None: ) geo.fine_rot_slider.setToolTip( tooltip_with_shortcut( - "Sub-degree rotation correction for tilted scans, applied after auto-crop", + "Sub-degree rotation correction for tilted scans: positive turns clockwise, negative counter-clockwise", ["fine_rot_inc", "fine_rot_dec"], ) ) diff --git a/negpy/desktop/view/sidebar/geometry.py b/negpy/desktop/view/sidebar/geometry.py index cfb0988c..51ebad48 100644 --- a/negpy/desktop/view/sidebar/geometry.py +++ b/negpy/desktop/view/sidebar/geometry.py @@ -130,11 +130,31 @@ def _init_ui(self) -> None: self.layout.addWidget(section_subheader("ALIGNMENT")) - self.fine_rot_slider = CompactSlider("Fine Rotation", -FINE_ROTATION_LIMIT, FINE_ROTATION_LIMIT, conf.fine_rotation, unit="°") + align_row = QHBoxLayout() + self.straighten_btn = self._tool_toggle( + "fa5s.ruler", + "", + tooltip_with_shortcut( + "Straighten with a reference line — draw along the horizon or a vertical edge " + "(a building, a door frame) and the image rotates to make it level or plumb. " + "Applies once per line; Esc cancels an in-progress line", + "straighten", + ), + ) + self.straighten_btn.setFixedWidth(36) + + # Slider shows the photographer's convention — positive = clockwise on screen. + # Internally geometry.fine_rotation keeps the cv2/warp convention (positive = + # counter-clockwise, flip-independent because flips apply before fine rotation), + # so saved edits keep their meaning: display = -stored at this boundary. + self.fine_rot_slider = CompactSlider("Fine Rotation", -FINE_ROTATION_LIMIT, FINE_ROTATION_LIMIT, -conf.fine_rotation, unit="°") self.fine_rot_slider.setToolTip( - "Fine-tunes rotation to correct tilt (degrees). For quick rotation, drag the round handles outside the crop box in the Crop tool." + "Fine-tunes rotation to correct tilt (degrees): positive turns clockwise, negative counter-clockwise. " + "For quick rotation, drag the round handles outside the crop box in the Crop tool." ) - self.layout.addWidget(self.fine_rot_slider) + align_row.addWidget(self.straighten_btn, 0) + align_row.addWidget(self.fine_rot_slider, 1) + self.layout.addLayout(align_row) def cycle_guide(self) -> None: self.guide_combo.setCurrentIndex((self.guide_combo.currentIndex() + 1) % self.guide_combo.count()) @@ -159,12 +179,15 @@ def _connect_signals(self) -> None: ) self.offset_slider.valueCommitted.connect(self._on_offset_committed) + self.straighten_btn.toggled.connect(self._on_straighten_toggled) + + # Display convention is CW-positive; negate crossing into the stored convention. self.fine_rot_slider.valueChanged.connect( - lambda v: self.update_config_section("geometry", render=True, persist=False, readback_metrics=False, fine_rotation=v) + lambda v: self.update_config_section("geometry", render=True, persist=False, readback_metrics=False, fine_rotation=-v) ) self.fine_rot_slider.valueChanged.connect(lambda _v: self.controller.show_rotation_guide()) self.fine_rot_slider.valueCommitted.connect( - lambda v: self.update_config_section("geometry", render=True, persist=True, readback_metrics=True, fine_rotation=v) + lambda v: self.update_config_section("geometry", render=True, persist=True, readback_metrics=True, fine_rotation=-v) ) def _on_ratio_changed(self, ratio: str) -> None: @@ -197,6 +220,9 @@ def _on_offset_committed(self, v: float) -> None: def _on_manual_crop_toggled(self, checked: bool) -> None: self.controller.set_active_tool(ToolMode.CROP_MANUAL if checked else ToolMode.NONE) + def _on_straighten_toggled(self, checked: bool) -> None: + self.controller.set_active_tool(ToolMode.STRAIGHTEN if checked else ToolMode.NONE) + def _on_auto_crop_toggled(self, checked: bool) -> None: if checked: self.controller.apply_auto_crop() @@ -214,9 +240,10 @@ def sync_ui(self) -> None: self.mode_combo.setCurrentIndex(self.mode_combo.findData(conf.autocrop_mode)) self.offset_slider.setValue(float(conf.autocrop_offset)) - self.fine_rot_slider.setValue(conf.fine_rotation) + self.fine_rot_slider.setValue(-conf.fine_rotation) self.manual_crop_btn.setChecked(self.state.active_tool == ToolMode.CROP_MANUAL) + self.straighten_btn.setChecked(self.state.active_tool == ToolMode.STRAIGHTEN) self.reset_crop_btn.setChecked(conf.auto_crop_enabled) self.manual_crop_btn.set_crop_active(conf.manual_crop_rect is not None) self.reset_crop_btn.set_crop_active(conf.auto_crop_enabled) @@ -232,4 +259,5 @@ def block_signals(self, blocked: bool) -> None: self.offset_slider.blockSignals(blocked) self.fine_rot_slider.blockSignals(blocked) self.manual_crop_btn.blockSignals(blocked) + self.straighten_btn.blockSignals(blocked) self.reset_crop_btn.blockSignals(blocked) diff --git a/negpy/desktop/view/widgets/shortcuts_overlay.py b/negpy/desktop/view/widgets/shortcuts_overlay.py index 210a7610..50d03e90 100644 --- a/negpy/desktop/view/widgets/shortcuts_overlay.py +++ b/negpy/desktop/view/widgets/shortcuts_overlay.py @@ -58,17 +58,18 @@ def _init_ui(self) -> None: actions = QHBoxLayout() customize_btn = QPushButton("Customize") - customize_btn.setStyleSheet( - f"font-size: 12px; padding: 6px 20px; background: transparent; color: {THEME.text_primary}; border: 1px solid {THEME.border_primary}; border-radius: 3px;" - ) + # Padding-only override: a widget-level `background:` would beat the app + # stylesheet in every state and kill the global hover/pressed feedback. + customize_btn.setStyleSheet("font-size: 12px; padding: 6px 20px;") customize_btn.clicked.connect(self._customize) actions.addWidget(customize_btn) actions.addStretch() close_btn = QPushButton("Close") - close_btn.setStyleSheet( - f"font-size: 12px; padding: 6px 20px; background: {THEME.accent_primary}; color: white; border: none; border-radius: 3px;" - ) + close_btn.setStyleSheet("font-size: 12px; padding: 6px 20px;") + # Primary action styling from the app stylesheet (accent fill with its own + # hover/pressed shades), same as the Export panel's call-to-action. + close_btn.setProperty("primary", True) close_btn.clicked.connect(self.accept) actions.addWidget(close_btn) root.addLayout(actions) diff --git a/negpy/features/geometry/logic.py b/negpy/features/geometry/logic.py index f93c6eed..f5d22621 100644 --- a/negpy/features/geometry/logic.py +++ b/negpy/features/geometry/logic.py @@ -1158,6 +1158,30 @@ def toggle_flip(geo: GeometryConfig, horizontal: bool) -> GeometryConfig: return new_geo +def straighten_delta_degrees(dx: float, dy: float) -> float: + """ + Fine-rotation delta (stored convention: positive = CCW on screen) that levels a + line drawn on the displayed image, snapping to the user's intent: lines closer + to horizontal straighten to the horizon, lines closer to vertical straighten to + plumb (a building edge). Direction-agnostic — drawing the same line from either + end yields the same correction. + + Screen coords have y growing downward, so atan2(dy, dx) measures clockwise from + east. A line tilted right-end-down (angle +θ) needs the image rotated CCW by θ + to level — which is +θ in the stored convention — so the deviation from the + nearest axis is the delta directly. The result is in (-45°, 45°]; deltas are + additive on top of the current fine rotation because the stored angle rotates + the *displayed* frame CCW regardless of flips/90° turns (flips apply before + fine rotation in the pipeline). + """ + theta = math.degrees(math.atan2(dy, dx)) % 180.0 # fold direction ambiguity + if theta <= 45.0: + return theta # near-horizontal + if theta < 135.0: + return theta - 90.0 # near-vertical + return theta - 180.0 # near-horizontal, other fold + + def rotation_drag_angle( start_angle_deg: float, center: Tuple[float, float], diff --git a/tests/test_geometry_logic.py b/tests/test_geometry_logic.py index 70587e4a..d517d8c5 100644 --- a/tests/test_geometry_logic.py +++ b/tests/test_geometry_logic.py @@ -293,6 +293,48 @@ def test_translate_full_size_rect_no_movement(): assert translate_manual_crop_rect(rect, 0.5, -0.5) == rect +def test_straighten_horizontal_right_end_down_rotates_ccw(): + from pytest import approx + from negpy.features.geometry.logic import straighten_delta_degrees + + # Horizon drawn with the right end lower: lift it by rotating CCW (stored +). + assert straighten_delta_degrees(100.0, 5.0) == approx(2.8624, abs=1e-3) + # Same physical line drawn from the other end gives the same correction. + assert straighten_delta_degrees(-100.0, -5.0) == approx(2.8624, abs=1e-3) + + +def test_straighten_horizontal_right_end_up_rotates_cw(): + from pytest import approx + from negpy.features.geometry.logic import straighten_delta_degrees + + assert straighten_delta_degrees(100.0, -5.0) == approx(-2.8624, abs=1e-3) + + +def test_straighten_vertical_intent_snaps_to_plumb(): + from pytest import approx + from negpy.features.geometry.logic import straighten_delta_degrees + + # Building edge drawn bottom-to-top with the top leaning right (CW tilt): + # correct by rotating CCW (stored +). + assert straighten_delta_degrees(10.0, -100.0) == approx(5.7106, abs=1e-3) + # Top leaning left corrects CW (stored -). + assert straighten_delta_degrees(-10.0, -100.0) == approx(-5.7106, abs=1e-3) + + +def test_straighten_exact_axes_need_no_correction(): + from negpy.features.geometry.logic import straighten_delta_degrees + + assert straighten_delta_degrees(50.0, 0.0) == 0.0 + assert straighten_delta_degrees(0.0, 50.0) == 0.0 + + +def test_straighten_result_bounded_to_quarter_turn(): + from negpy.features.geometry.logic import straighten_delta_degrees + + for dx, dy in ((1, 1), (-1, 1), (1, -3), (7, 2), (-5, -9)): + assert -45.0 <= straighten_delta_degrees(float(dx), float(dy)) <= 45.0 + + def test_rotate_rect_ccw_quarter_turn(): from pytest import approx from negpy.features.geometry.logic import rotate_normalized_rect