Files
lightcraft/crates/engine/src/tests_crop_angle.rs
T
Guillem Hermida 053b7420fe LR-CROP-STRAIGHTEN: turning a crop shrinks it about its centre instead of swapping it for the largest crop (#1794)
CropGeometry::with_angle replaced a crop that no longer fit at the new
angle with the largest centred crop of its aspect. For a crop dragged in
from a corner (which still touches two edges) that happened at the first
fraction of a degree, so the crop jumped back to nearly the whole photo.

A crop that no longer fits now shrinks about its own centre, keeping its
aspect, to the largest size that fits there. The full photo, or a crop
an earlier turn shrank to the largest of its aspect, still follows the
largest crop and grows back towards 0 degrees (#1388). A crop whose
centre is off the turned photo is centred at its own size at most. No
crop the user made ever grows.
2026-10-14 23:09:11 +02:00

154 lines
7.1 KiB
Rust

//! Every way of setting the crop angle keeps the crop inside the photo (issue #742): the generic
//! control path (`develop.set` on `crop.angle`, as MCP, the control channel and `lightcraft-cli
//! render --set` use it, plus `develop.adjust`, `develop.quickAdjust`, `develop.resetControl`) gives
//! the same crop as `crop.straighten` (the rotate handle, the Straighten slider, the Angle field)
//! and `crop.set {angle}`.
use lightcraft_geom::{CropGeometry, Rect};
use serde_json::{Value, json};
use crate::Session;
fn geometry(s: &Session) -> CropGeometry {
s.develop_of(s.active().unwrap()).unwrap().crop.geometry
}
fn dims(s: &Session) -> (f64, f64) {
let p = s.catalog.photo(s.active().unwrap()).unwrap();
let (w, h) = (p.width as f64, p.height as f64);
if p.develop.orientation.swaps_axes() { (h, w) } else { (w, h) }
}
/// Run `cmd` from the current state, return the crop it leaves, and undo it.
fn try_cmd(s: &mut Session, cmd: &str, p: Value) -> CropGeometry {
s.execute(cmd, &p).unwrap();
let g = geometry(s);
s.execute("edit.undo", &json!({})).unwrap();
g
}
fn close(a: CropGeometry, b: CropGeometry) -> bool {
let r = |g: CropGeometry| [g.rect.x0, g.rect.y0, g.rect.x1, g.rect.y1, g.angle];
r(a).iter().zip(r(b)).all(|(x, y)| (x - y).abs() < 1e-9)
}
#[test]
fn every_way_of_setting_the_angle_gives_the_straighten_crop() {
let mut s = Session::with_demo();
let (w, h) = dims(&s);
for aspect in [json!("original"), json!("free"), json!("1x1"), json!("16x9"), json!("4x5")] {
s.execute("crop.aspect", &json!({"aspect": aspect})).unwrap();
let start = geometry(&s);
for angle in [-45.0, -30.0, -7.5, 0.5, 2.0, 15.0, 33.3] {
let want = try_cmd(&mut s, "crop.straighten", json!({"angle": angle}));
assert!(want.is_within_image(w, h), "{aspect} {angle}°: straighten fits");
assert_eq!(want.angle, angle);
for (cmd, p) in [
("develop.set", json!({"control": "crop.angle", "value": angle})),
("develop.set", json!({"values": {"crop.angle": angle, "light.exposure": 0.3}})),
("crop.set", json!({"angle": angle})),
("develop.adjust", json!({"control": "crop.angle", "delta": angle - start.angle})),
] {
let got = try_cmd(&mut s, cmd, p.clone());
assert!(close(got, want), "{aspect} {angle}°: {cmd} {p} gave {got:?}, crop.straighten {want:?}");
}
assert!(close(geometry(&s), start), "undo restored the crop");
}
}
}
#[test]
fn a_crop_that_still_fits_is_kept() {
let mut s = Session::with_demo();
s.execute("crop.set", &json!({"rect": [0.3, 0.3, 0.7, 0.7]})).unwrap();
for angle in [-4.0, 1.0, 3.0, 6.0] {
s.execute("develop.set", &json!({"control": "crop.angle", "value": angle})).unwrap();
let g = geometry(&s);
assert_eq!(g.rect, Rect::new(0.3, 0.3, 0.7, 0.7), "no needless shrink at {angle}°");
assert_eq!(g.angle, angle);
}
}
#[test]
fn quick_develop_reset_and_batch_paths_fit_too() {
let mut s = Session::with_demo();
let (w, h) = dims(&s);
let id = s.active().unwrap();
let want = try_cmd(&mut s, "crop.straighten", json!({"angle": 12.0}));
let got = try_cmd(&mut s, "develop.quickAdjust", json!({"control": "crop.angle", "delta": 12.0, "ids": [id.0]}));
assert!(close(got, want), "quickAdjust {got:?} vs {want:?}");
let got = try_cmd(&mut s, "develop.set", json!({"control": "crop.angle", "value": 12.0, "ids": [id.0]}));
assert!(close(got, want), "develop.set ids {got:?} vs {want:?}");
// back to 0°: the crop shrunk to fit at 12° grows back to the full photo (issue #1388)
s.execute("develop.set", &json!({"control": "crop.angle", "value": 12.0})).unwrap();
s.execute("develop.resetControl", &json!({"control": "crop.angle"})).unwrap();
let g = geometry(&s);
assert_eq!((g.rect, g.angle), (Rect::UNIT, 0.0));
assert!(g.is_within_image(w, h));
}
#[test]
fn a_straighten_slider_drag_fits_live_and_is_one_undo_step() {
let mut s = Session::with_demo();
let (w, h) = dims(&s);
let start = geometry(&s);
let undo = s.undo.len();
s.execute("develop.beginInteraction", &json!({"label": "Straighten"})).unwrap();
for angle in [1.0, 4.0, 9.0, 15.0] {
s.execute("develop.set", &json!({"control": "crop.angle", "value": angle})).unwrap();
assert!(geometry(&s).is_within_image(w, h), "fitted at every step ({angle}°)");
}
s.execute("develop.endInteraction", &json!({})).unwrap();
assert_eq!(s.undo.len(), undo + 1, "the drag is one undo step");
assert_eq!(geometry(&s).angle, 15.0);
s.execute("edit.undo", &json!({})).unwrap();
assert!(close(geometry(&s), start));
}
#[test]
fn turning_a_crop_dragged_in_from_a_corner_keeps_that_crop() {
// issue #1794: lock the aspect, drag a corner in, then rotate: the crop went back to the photo's
// size, because the dragged crop still touches two edges and stops fitting at once
let mut s = Session::with_demo();
let (w, h) = dims(&s);
s.execute("crop.aspect", &json!({"aspect": "original"})).unwrap();
s.execute("crop.drag", &json!({"handle": "bottomRight", "from": [1.0, 1.0], "to": [0.6, 0.6]})).unwrap();
let made = geometry(&s).rect_px(w, h);
assert!(made.width() < 0.7 * w, "the drag cropped: {made:?}");
s.execute("develop.beginInteraction", &json!({"label": "Crop"})).unwrap();
for angle in [0.25, 1.0, 2.5, 5.0, -3.0] {
s.execute("crop.straighten", &json!({"angle": angle})).unwrap();
let g = geometry(&s);
let r = g.rect_px(w, h);
assert!(g.is_within_image(w, h), "{angle}°: inside the photo");
assert!(r.width() <= made.width() + 1e-6, "{angle}°: no larger than the crop made, got {r:?} from {made:?}");
assert!((r.aspect() - made.aspect()).abs() < 1e-6, "{angle}°: the locked aspect holds");
}
s.execute("develop.endInteraction", &json!({})).unwrap();
// the first small turn only trims it
s.execute("edit.undo", &json!({})).unwrap();
s.execute("crop.straighten", &json!({"angle": 0.25})).unwrap();
let r = geometry(&s).rect_px(w, h);
assert!(r.width() > 0.97 * made.width(), "0.25° keeps nearly all of it: {r:?} from {made:?}");
}
#[test]
fn a_crop_shrunk_by_straightening_grows_back_towards_0() {
// issue #1388: slide the Straighten slider out and back, in one drag or in several
let mut s = Session::with_demo();
let (w, h) = dims(&s);
s.execute("develop.beginInteraction", &json!({"label": "Straighten"})).unwrap();
for angle in [5.0, 12.0, 20.0, 8.0, -20.0, -3.0, 0.0] {
s.execute("crop.straighten", &json!({"angle": angle})).unwrap();
let g = geometry(&s);
assert!(close(g, lightcraft_geom::crop_fit_angle(w, h, angle, None)), "the largest crop at {angle}°, got {g:?}");
}
s.execute("develop.endInteraction", &json!({})).unwrap();
s.execute("crop.straighten", &json!({"angle": 20.0})).unwrap();
s.execute("crop.straighten", &json!({"angle": 0.0})).unwrap();
let g = geometry(&s);
assert!(close(g, CropGeometry::default()), "two separate drags: back to the full photo at 0°, got {g:?}");
}