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 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas
2026-10-01 10:28:29 -04:00
co-authored by Claude Opus 5.5
parent 6015af99b7
commit ce89ab5375
6 changed files with 110 additions and 4 deletions
+22
View File
@@ -330,6 +330,28 @@ pub fn specs() -> Vec<CommandSpec> {
})
}
),
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",
+20
View File
@@ -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}");
}
+7
View File
@@ -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<f64> {
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) {
+46
View File
@@ -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<Pos2> = 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();
}
}
+11
View File
@@ -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");
{
+4 -4
View File
@@ -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%) |
<!-- /parity:summary -->
## 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` | |