diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs index 9a471889..0cf998e0 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -90,10 +90,16 @@ struct Pen<'a> { r: Rect, s: Stroke, k: f32, + /// (cos, sin) of a turn about the grid's centre, clockwise on screen ([`paint_turned`]). + turn: Option<(f32, f32)>, } impl Pen<'_> { fn pt(&self, x: f32, y: f32) -> Pos2 { + let (x, y) = match self.turn { + Some((c, s)) => (10.0 + (x - 10.0) * c - (y - 10.0) * s, 10.0 + (x - 10.0) * s + (y - 10.0) * c), + None => (x, y), + }; pos2(self.r.min.x + x * self.k, self.r.min.y + y * self.k) } fn line(&self, pts: &[(f32, f32)]) { @@ -144,10 +150,20 @@ fn star_pts(cx: f32, cy: f32, r: f32) -> Vec<(f32, f32)> { /// Paint `icon` centred in `rect` (square area used) with `color`. pub fn paint(p: &Painter, rect: Rect, icon: Icon, color: Color32) { + paint_with(p, rect, icon, color, None); +} + +/// [`paint`], turned `radians` clockwise about its centre. Only for icons drawn with lines, arcs +/// and circles (rectangles stay square to the screen). +pub fn paint_turned(p: &Painter, rect: Rect, icon: Icon, color: Color32, radians: f32) { + paint_with(p, rect, icon, color, Some((radians.cos(), radians.sin()))); +} + +fn paint_with(p: &Painter, rect: Rect, icon: Icon, color: Color32, turn: Option<(f32, f32)>) { let side = rect.width().min(rect.height()); let r = Rect::from_center_size(rect.center(), vec2(side, side)); let k = side / 20.0; - let pen = Pen { p, r, s: Stroke::new((1.5 * k).max(1.0), color), k }; + let pen = Pen { p, r, s: Stroke::new((1.5 * k).max(1.0), color), k, turn }; use Icon::*; match icon { Sidebar => { diff --git a/crates/ui-egui/src/panels/detail.rs b/crates/ui-egui/src/panels/detail.rs index c3678ae2..77c731c2 100644 --- a/crates/ui-egui/src/panels/detail.rs +++ b/crates/ui-egui/src/panels/detail.rs @@ -1285,7 +1285,7 @@ fn crop_overlay(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Respons None if inside(hq) && !rotating => egui::CursorIcon::Move, // a drag here rotates: no system cursor shows that, so draw a curved double arrow None => { - rotate_cursor(ui, hq); + rotate_cursor(ui, hq, rotate_cursor_turn(&pts, hq, inside(hq))); egui::CursorIcon::None } }; @@ -1383,15 +1383,38 @@ fn top_painter(ui: &egui::Ui, name: &'static str) -> egui::Painter { ui.ctx().layer_painter(egui::LayerId::new(egui::Order::Tooltip, egui::Id::new(name))) } -/// The rotation pointer: a curved double arrow on a dark disc, so it reads over any photo. -fn rotate_cursor(ui: &egui::Ui, at: Pos2) { +/// The rotation pointer: a curved double arrow on a dark disc, so it reads over any photo, +/// turned `turn` radians clockwise ([`rotate_cursor_turn`]). +fn rotate_cursor(ui: &egui::Ui, at: Pos2, turn: f32) { let p = top_painter(ui, "crop-rotate-cursor"); let r = Rect::from_center_size(at, vec2(22.0, 22.0)); p.circle_filled(at, 12.0, Color32::from_black_alpha(150)); - crate::icons::paint(&p, r.shrink(2.0), crate::icons::Icon::RotateDrag, Color32::WHITE); + crate::icons::paint_turned(&p, r.shrink(2.0), crate::icons::Icon::RotateDrag, Color32::WHITE, turn); register(ui.ctx(), "cropRotateCursor", r); } +/// How far to turn the rotation pointer (radians, clockwise) so its arc, drawn bulging up, bulges +/// away from the crop box `pts` (its four corners on screen, maybe rotated): up above the top edge, +/// right beside the right edge, down below it, diagonally off a corner. Measured from the nearest +/// point of the box's outline; `inside` (a rotate drag that has come back over the box) faces it +/// the other way, still away from that edge. +fn rotate_cursor_turn(pts: &[Pos2], q: Pos2, inside: bool) -> f32 { + let nearest = (0..pts.len()) + .map(|i| { + let (a, b) = (pts[i], pts[(i + 1) % pts.len()]); + let ab = b - a; + let t = if ab.length_sq() > 0.0 { ((q - a).dot(ab) / ab.length_sq()).clamp(0.0, 1.0) } else { 0.0 }; + a + ab * t + }) + .min_by(|a, b| a.distance_sq(q).total_cmp(&b.distance_sq(q))); + let Some(nearest) = nearest else { return 0.0 }; + let away = if inside { nearest - q } else { q - nearest }; + if away.length() < 0.5 { + return 0.0; + } + away.y.atan2(away.x) + std::f32::consts::FRAC_PI_2 +} + /// While rotating: the angle, next to the pointer, kept inside `bounds` (the canvas): on the /// pointer's other side when it would run past an edge. fn angle_readout(ui: &egui::Ui, at: Pos2, angle: f64, bounds: Rect) { @@ -2143,6 +2166,36 @@ fn straighten_overlay(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::R #[cfg(test)] mod tests { + /// The rotation pointer's arc bulges away from the side of the crop box it is on, and follows + /// a box that is itself rotated. + #[test] + fn rotate_cursor_faces_away_from_the_crop_box() { + use egui::{Pos2, pos2}; + use std::f32::consts::{FRAC_PI_2, FRAC_PI_4, PI}; + let near = |a: f32, b: f32| ((a - b + PI).rem_euclid(2.0 * PI) - PI).abs() < 1e-3; + let pts = [pos2(100.0, 100.0), pos2(300.0, 100.0), pos2(300.0, 200.0), pos2(100.0, 200.0)]; + let turn = |x: f32, y: f32| super::rotate_cursor_turn(&pts, pos2(x, y), false); + assert!(near(turn(200.0, 80.0), 0.0), "above: as drawn"); + assert!(near(turn(320.0, 150.0), FRAC_PI_2), "right"); + assert!(near(turn(200.0, 230.0), PI), "below"); + assert!(near(turn(70.0, 150.0), -FRAC_PI_2), "left"); + assert!(near(turn(290.0, 60.0), 0.0), "above the top edge near its end: still up"); + assert!(near(turn(320.0, 80.0), FRAC_PI_4), "off the top-right corner: diagonal"); + assert!(near(super::rotate_cursor_turn(&pts, pos2(200.0, 110.0), true), 0.0), "dragged back over the top edge: still up"); + // a box turned 90°: its top edge is now on the right + let turned = [pts[3], pts[0], pts[1], pts[2]].map(|p| pos2(400.0 - p.y, p.x)); + assert!(near(super::rotate_cursor_turn(&turned, pos2(420.0, 200.0), false), FRAC_PI_2)); + // a box turned 30°: straight out from its top edge is 30° clockwise of up + let c = pos2(200.0, 150.0); + let rot = |p: Pos2| { + let (s, co) = (30f32.to_radians().sin(), 30f32.to_radians().cos()); + let d = p - c; + pos2(c.x + d.x * co - d.y * s, c.y + d.x * s + d.y * co) + }; + let tilted = pts.map(rot); + assert!(near(super::rotate_cursor_turn(&tilted, rot(pos2(200.0, 60.0)), false), 30f32.to_radians())); + } + use super::{film_label, texture_side}; /// Issue #652: a native host reports the GPU's texture limit in the first frame's input only. diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 09941108..ef7c04c8 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -246,7 +246,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). |---|---|---|---|---|---| | LR-CROP-RECT | Crop rectangle | P0 | ✅ | `cmd:crop.set`, `cmd:crop.drag`, `crates/geom/src/crop_drag.rs`, `crates/ui-egui/src/panels/detail.rs` | double-click inside the box applies the crop (like Return) | | LR-CROP-ASPECT | Aspect ratios | P0 | ✅ | `cmd:crop.aspect`, `cmd:crop.rotateAspect`, `crates/ui-egui/src/panels/right.rs` | menu with presets + custom W × H, visible Lock toggle (also A), button names the active ratio; every handle honours the lock and the drag stops at the image edge instead of shifting the frame (issue #295); "Original" is the photo's own shape (no separate As Shot) | -| LR-CROP-STRAIGHTEN | Straighten tool | P0 | ✅ | `cmd:crop.straighten`, `cmd:crop.autoStraighten`, `crates/ui-egui/src/panels/detail.rs` (`rotate_cursor`, `angle_readout`), `crates/ui-egui/src/panels/right.rs` (Angle field), `crates/ui-egui/src/tests_crop_rotate.rs` | Straighten Tool button: drag along a horizon/vertical; double-click or Auto levels automatically. Dragging outside the crop box rotates it: there the pointer is a curved double arrow (with ⌘ held, the straighten crosshair), and while rotating the angle shows next to it, kept on the canvas (issue #534); an exact angle goes in the Angle field under Straighten (`cropAngleField`: applies on Return or leaving it, Esc keeps the old angle, takes a decimal comma; a drag on it is one undo step) or by clicking the Straighten value (its hover says so) | +| LR-CROP-STRAIGHTEN | Straighten tool | P0 | ✅ | `cmd:crop.straighten`, `cmd:crop.autoStraighten`, `crates/ui-egui/src/panels/detail.rs` (`rotate_cursor`, `angle_readout`), `crates/ui-egui/src/panels/right.rs` (Angle field), `crates/ui-egui/src/tests_crop_rotate.rs` | Straighten Tool button: drag along a horizon/vertical; double-click or Auto levels automatically. Dragging outside the crop box rotates it: there the pointer is a curved double arrow that bulges away from the side of the box it is on, diagonally off a corner and following a rotated box (`rotate_cursor_turn`, tested by `rotate_cursor_faces_away_from_the_crop_box`) (with ⌘ held, the straighten crosshair), and while rotating the angle shows next to it, kept on the canvas (issue #534); an exact angle goes in the Angle field under Straighten (`cropAngleField`: applies on Return or leaving it, Esc keeps the old angle, takes a decimal comma; a drag on it is one undo step) or by clicking the Straighten value (its hover says so) | | LR-CROP-AUTO | Auto straighten | P1 | ✅ | `cmd:crop.autoStraighten` | crop-angle leveling from detected horizon/plumb lines (consensus required) | | LR-CROP-ANGLE | Angle slider | P0 | ✅ | `ctl:crop.angle`, `crates/ui-egui/src/panels/right.rs` | the Straighten slider; an exact angle can also be typed in the Angle field under it (see LR-CROP-STRAIGHTEN) | | LR-CROP-ROTATE90 | Rotate 90° | P0 | ✅ | `cmd:photo.rotateLeft`, `cmd:photo.rotateRight` | |