From ce89ab5375e2c3f142a5d556dcf04beb4a18ca43 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Thu, 1 Oct 2026 10:28:29 -0400 Subject: [PATCH 1/2] M4.1: Straighten tool (drag along the horizon or a vertical) + Auto straighten (crop.autoStraighten) Auto uses Upright's consensus level angle as the crop angle; sign verified on a tilted test image and pinned by a test. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/develop.rs | 22 +++++++++++++ crates/pipeline/src/tests_geometry.rs | 20 ++++++++++++ crates/pipeline/src/upright.rs | 7 ++++ crates/ui-egui/src/panels/detail.rs | 46 +++++++++++++++++++++++++++ crates/ui-egui/src/panels/right.rs | 11 +++++++ docs/parity.md | 8 ++--- 6 files changed, 110 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/cmd/develop.rs b/crates/engine/src/cmd/develop.rs index b7b79067..7b805ae2 100644 --- a/crates/engine/src/cmd/develop.rs +++ b/crates/engine/src/cmd/develop.rs @@ -330,6 +330,28 @@ pub fn specs() -> Vec { }) } ), + cmd!( + "crop.autoStraighten", + "Auto Straighten", + [], + None, + "{} — level the horizon (or plumb lines) with the crop angle", + has_active, + |s, _| { + let id = active(s, "crop.autoStraighten")?; + let src = s.source_now(id, SourceLevel::Preview).map_err(|e| bad("crop.autoStraighten", e))?; + let info = s.catalog.photo(id).map(|p| crate::media::source_info(p)).unwrap_or_default(); + let d = s.develop_of(id).unwrap_or_default(); + let Some(level) = lightcraft_pipeline::upright::level_degrees(&src, &info, &d) else { + return Ok(json!({"changed": false, "reason": "no dominant horizontal or vertical lines"})); + }; + // Same convention as the crop angle (verified: a horizon descending 6° to the right + // comes out level with angle = level ≈ −6°). + let angle = level.clamp(-45.0, 45.0); + s.execute("crop.straighten", &json!({"angle": (angle * 100.0).round() / 100.0}))?; + Ok(json!({"changed": true, "angle": angle})) + } + ), cmd!( "crop.aspect", "Crop Aspect", diff --git a/crates/pipeline/src/tests_geometry.rs b/crates/pipeline/src/tests_geometry.rs index f2e7c237..99c8a140 100644 --- a/crates/pipeline/src/tests_geometry.rs +++ b/crates/pipeline/src/tests_geometry.rs @@ -467,3 +467,23 @@ fn debug_upright_scene() { eprintln!("{m:?}: {:?}", crate::upright::solve(m, &a).0); } } + +/// Sign convention for Auto straighten: a horizon descending 6° to the right needs crop angle −6°, +/// which `level_degrees` must report (the crop panel uses it as the crop angle directly). +#[test] +fn level_degrees_sign_matches_crop_angle() { + let (w, h) = (900usize, 600usize); + let t = 6f32.to_radians().tan(); + let src = Rgb32f::from_fn(w, h, |x, y| { + let yh = h as f32 / 2.0 + (x as f32 - w as f32 / 2.0) * t; + if (y as f32) < yh { + [0.6, 0.7, 0.9] + } else if (((y as f32 - yh) / 30.0) as i32) % 2 == 0 { + [0.05, 0.1, 0.2] + } else { + [0.1, 0.2, 0.3] + } + }); + let deg = crate::upright::level_degrees(&src, &SourceInfo::default(), &DevelopSettings::default()).expect("lines found"); + assert!((deg + 6.0).abs() < 0.5, "{deg}"); +} diff --git a/crates/pipeline/src/upright.rs b/crates/pipeline/src/upright.rs index f6d25056..93dffad0 100644 --- a/crates/pipeline/src/upright.rs +++ b/crates/pipeline/src/upright.rs @@ -569,6 +569,13 @@ pub fn analyze_source(src: &Rgb32f, info: &SourceInfo, s: &DevelopSettings) -> A a } +/// The rotation (degrees, clockwise +) that levels `src`'s dominant horizon/plumb lines, if they agree +/// (the consensus rule of Upright Level). Used by the crop panel's Auto straighten. +pub fn level_degrees(src: &Rgb32f, info: &SourceInfo, s: &DevelopSettings) -> Option { + let a = analyze_source(src, info, s); + level_angle(&a.vertical, &a.horizontal) +} + /// The automatic Upright correction for `mode` on `src` (identity for Off and Guided). pub fn auto_transform(src: &Rgb32f, info: &SourceInfo, s: &DevelopSettings, mode: Upright) -> Homography { if matches!(mode, Upright::Off | Upright::Guided) { diff --git a/crates/ui-egui/src/panels/detail.rs b/crates/ui-egui/src/panels/detail.rs index f0f252e6..012c4479 100644 --- a/crates/ui-egui/src/panels/detail.rs +++ b/crates/ui-egui/src/panels/detail.rs @@ -28,6 +28,10 @@ pub enum Gesture { start: lightcraft_geom::Rect, angle: f64, }, + /// Straighten tool: a line drawn along something that should be level (or plumb). + StraightenLine { + a: Pos2, + }, CropRotate { start_angle: f64, a0: f32, @@ -330,6 +334,10 @@ fn crop_overlay(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Respons guided_overlay(app, ui, resp, map, frame, d); return; } + if app.ui.tool == "straighten" { + straighten_overlay(app, ui, resp); + return; + } let quad = frame_crop_quad(d, frame); let pts: Vec = quad.iter().map(|q| map.screen(*q)).collect(); let p = ui.painter(); @@ -782,3 +790,41 @@ fn filmstrip(app: &mut LightcraftApp, ui: &mut egui::Ui, r: Rect) { } }); } + +/// Straighten tool: drag along a horizon (or a vertical) to set the crop angle; double-click = Auto. +/// The image is shown unrotated in the crop view, so the line's on-screen angle is its image angle. +fn straighten_overlay(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Response) { + ui.ctx().set_cursor_icon(egui::CursorIcon::Crosshair); + if resp.double_clicked() { + let _ = app.run("crop.autoStraighten", json!({})); + app.ui.tool.clear(); + app.gesture = None; + return; + } + if resp.drag_started() + && let Some(q) = ui.input(|i| i.pointer.press_origin()).or(resp.interact_pointer_pos()) + { + app.gesture = Some(Gesture::StraightenLine { a: q }); + } + let Some(Gesture::StraightenLine { a }) = app.gesture.clone() else { return }; + let Some(b) = resp.interact_pointer_pos().or(resp.hover_pos()) else { return }; + let p = ui.painter(); + p.line_segment([a, b], Stroke::new(3.0, Color32::from_black_alpha(140))); + p.line_segment([a, b], Stroke::new(1.5, Color32::WHITE)); + if resp.drag_stopped() { + app.gesture = None; + let v = b - a; + if v.length() > 8.0 { + let mut deg = v.y.atan2(v.x).to_degrees() as f64; + // fold to the nearest axis: a line closer to vertical straightens plumb lines + while deg > 45.0 { + deg -= 90.0; + } + while deg < -45.0 { + deg += 90.0; + } + let _ = app.run("crop.straighten", json!({"angle": (-deg * 100.0).round() / 100.0})); + } + app.ui.tool.clear(); + } +} diff --git a/crates/ui-egui/src/panels/right.rs b/crates/ui-egui/src/panels/right.rs index 45e65291..097d9b8b 100644 --- a/crates/ui-egui/src/panels/right.rs +++ b/crates/ui-egui/src/panels/right.rs @@ -116,6 +116,17 @@ fn crop(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { let out = slider(ui, &spec, d.crop.geometry.angle, true, Some("Straighten")); super::edit::apply_slider_out(app, &spec, out, |app, v| app.run("crop.straighten", json!({"angle": v}))); } + padded(ui, |ui| { + ui.horizontal(|ui| { + let on = app.ui.tool == "straighten"; + if text_button(ui, "straightenTool", "Straighten Tool", on).on_hover_text("Drag along the horizon; double-click for Auto").clicked() { + app.ui.tool = if on { String::new() } else { "straighten".into() }; + } + if text_button(ui, "straightenAuto", "Auto", false).clicked() { + let _ = app.run("crop.autoStraighten", json!({})); + } + }); + }); padded(ui, |ui| { ui.label("Overlay"); { diff --git a/docs/parity.md b/docs/parity.md index edeead7f..4516f962 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -28,7 +28,7 @@ and prints the summary; `cargo xtask parity --write` refreshes the summary table | E. Metadata (META) | 2 | 2 | 2 | 0 | 2/2 (100%) | 0/2 (0%) | | F. Edit panel — global adjustments (EDIT) | 35 | 3 | 10 | 1 | 27/28 (96%) | 8/14 (57%) | | G. Profiles (PROF) | 3 | 1 | 6 | 0 | 2/3 (67%) | 1/3 (33%) | -| H. Crop & rotate (CROP) | 5 | 4 | 0 | 1 | 5/6 (83%) | 0/3 (0%) | +| H. Crop & rotate (CROP) | 7 | 2 | 0 | 1 | 6/6 (100%) | 1/3 (33%) | | I. Remove / healing (REM) | 3 | 3 | 4 | 2 | 2/4 (50%) | 1/3 (33%) | | J. Red eye (EYE) | 0 | 0 | 2 | 0 | — | 0/1 (0%) | | K. Masking (MASK) | 6 | 10 | 7 | 0 | 4/8 (50%) | 2/5 (40%) | @@ -48,7 +48,7 @@ and prints the summary; `cargo xtask parity --write` refreshes the summary table | Y. Menus | 42 | 19 | 24 | 8 | 37/47 (79%) | 3/23 (13%) | | Z. Keyboard shortcuts (desktop) | 45 | 16 | 19 | 1 | 39/52 (75%) | 6/23 (26%) | | Lightroom Classic extras | 1 | 17 | 61 | 9 | — | 1/21 (5%) | -| **Total** | 184 | 110 | 206 | 37 | 148/198 (75%) | 33/144 (23%) | +| **Total** | 186 | 108 | 206 | 37 | 149/198 (75%) | 34/144 (24%) | ## Top gaps @@ -309,8 +309,8 @@ under the UI command that wraps it (a no-op error); the UI command now wins. |---|---|---|---|---|---| | LR-CROP-RECT | Crop rectangle | P0 | ✅ | `cmd:crop.set`, `crates/ui-egui/src/panels/detail.rs` | | | LR-CROP-ASPECT | Aspect ratios | P0 | ✅ | `cmd:crop.aspect`, `cmd:crop.rotateAspect` | no "As Shot"; custom ratio via command params only | -| LR-CROP-STRAIGHTEN | Straighten tool | P0 | 🟡 | `cmd:crop.straighten` | rotate by dragging outside the crop; no draw-along-horizon tool / double-click auto | -| LR-CROP-AUTO | Auto straighten | P1 | 🟡 | `cmd:geometry.upright` (`level`) | via Upright Level; no Auto button in the crop panel | +| LR-CROP-STRAIGHTEN | Straighten tool | P0 | ✅ | `cmd:crop.straighten`, `cmd:crop.autoStraighten` | Straighten Tool button: drag along a horizon/vertical; double-click or Auto levels automatically | +| 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` | | | LR-CROP-ROTATE90 | Rotate 90° | P0 | ✅ | `cmd:photo.rotateLeft`, `cmd:photo.rotateRight` | | | LR-CROP-FLIP | Flip | P0 | ✅ | `cmd:photo.flipHorizontal`, `cmd:photo.flipVertical` | | From b7f8beaa31e9831657494c287cddef3ee64e124b Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Thu, 1 Oct 2026 10:31:59 -0400 Subject: [PATCH 2/2] M2.8: vignette style picker (Highlight / Color priority / Paint overlay) Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/panels/edit.rs | 20 ++++++++++++++++++++ docs/parity.md | 6 +++--- 2 files changed, 23 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/panels/edit.rs b/crates/ui-egui/src/panels/edit.rs index 0ffd398a..20d63a35 100644 --- a/crates/ui-egui/src/panels/edit.rs +++ b/crates/ui-egui/src/panels/edit.rs @@ -191,6 +191,26 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { control(app, ui, d, c, true); } sub_title(ui, "Vignette"); + { + use lightcraft_develop::VignetteStyle as V; + let styles = [ + (V::HighlightPriority, "Highlight", "highlightPriority"), + (V::ColorPriority, "Color", "colorPriority"), + (V::PaintOverlay, "Paint", "paintOverlay"), + ]; + let items: Vec<(&str, &str)> = styles.iter().map(|(_, l, k)| (*l, *k)).collect(); + let active = styles.iter().position(|(v, _, _)| *v == d.vignette.style); + let mut chosen = None; + egui::Frame::NONE.inner_margin(egui::Margin { left: 24, right: 22, top: 2, bottom: 4 }).show(ui, |ui| { + chosen = crate::widgets::segmented(ui, "vignetteStyle", &items, active, 3); + }); + if let Some(i) = chosen { + let _ = app.run( + "develop.merge", + serde_json::json!({"settings": {"vignette": {"style": serde_json::to_value(styles[i].0).unwrap_or_default()}}}), + ); + } + } for c in ["vignette.amount", "vignette.midpoint", "vignette.feather", "vignette.roundness", "vignette.highlights"] { let enabled = c == "vignette.amount" || d.vignette.amount != 0.0; control(app, ui, d, c, enabled); diff --git a/docs/parity.md b/docs/parity.md index 4516f962..aa57982b 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -26,7 +26,7 @@ and prints the summary; `cargo xtask parity --write` refreshes the summary table | C. Views & navigation (VIEW) | 7 | 2 | 8 | 0 | 7/9 (78%) | 0/4 (0%) | | D. Search & filter (FILT) | 2 | 7 | 4 | 0 | 2/4 (50%) | 0/4 (0%) | | E. Metadata (META) | 2 | 2 | 2 | 0 | 2/2 (100%) | 0/2 (0%) | -| F. Edit panel — global adjustments (EDIT) | 35 | 3 | 10 | 1 | 27/28 (96%) | 8/14 (57%) | +| F. Edit panel — global adjustments (EDIT) | 36 | 2 | 10 | 1 | 28/28 (100%) | 8/14 (57%) | | G. Profiles (PROF) | 3 | 1 | 6 | 0 | 2/3 (67%) | 1/3 (33%) | | H. Crop & rotate (CROP) | 7 | 2 | 0 | 1 | 6/6 (100%) | 1/3 (33%) | | I. Remove / healing (REM) | 3 | 3 | 4 | 2 | 2/4 (50%) | 1/3 (33%) | @@ -48,7 +48,7 @@ and prints the summary; `cargo xtask parity --write` refreshes the summary table | Y. Menus | 42 | 19 | 24 | 8 | 37/47 (79%) | 3/23 (13%) | | Z. Keyboard shortcuts (desktop) | 45 | 16 | 19 | 1 | 39/52 (75%) | 6/23 (26%) | | Lightroom Classic extras | 1 | 17 | 61 | 9 | — | 1/21 (5%) | -| **Total** | 186 | 108 | 206 | 37 | 149/198 (75%) | 34/144 (24%) | +| **Total** | 187 | 107 | 206 | 37 | 150/198 (76%) | 34/144 (24%) | ## Top gaps @@ -265,7 +265,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins. | LR-EDIT-EFFECTS-TEXTURE | Texture | P0 | ✅ | `ctl:effects.texture` | | | LR-EDIT-EFFECTS-CLARITY | Clarity | P0 | ✅ | `ctl:effects.clarity` | | | LR-EDIT-EFFECTS-DEHAZE | Dehaze | P0 | ✅ | `ctl:effects.dehaze` | | -| LR-EDIT-EFFECTS-VIGNETTE | Post-crop vignette | P0 | 🟡 | `ctl:vignette.*`, `crates/pipeline/src/finish.rs` | style (highlight / colour priority / paint) rendered but not selectable in the UI | +| LR-EDIT-EFFECTS-VIGNETTE | Post-crop vignette | P0 | ✅ | `ctl:vignette.*`, `crates/pipeline/src/finish.rs`, `crates/ui-egui/src/panels/edit.rs` | style picker (Highlight / Color / Paint) in the Effects section | | LR-EDIT-EFFECTS-GRAIN | Grain | P1 | ✅ | `ctl:grain.*` | | | LR-EDIT-DETAIL-SHARPEN | Sharpening | P0 | ✅ | `ctl:detail.sharpenAmount`, `ctl:detail.sharpenRadius`, `ctl:detail.sharpenDetail`, `ctl:detail.sharpenMasking` | no Alt-drag mask preview | | LR-EDIT-DETAIL-NR | Luminance noise reduction | P0 | ✅ | `ctl:detail.nrLuminance`, `ctl:detail.nrDetail`, `ctl:detail.nrContrast` | |