//! 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:?}"); }