mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-25 02:30:15 -06:00
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.
154 lines
7.1 KiB
Rust
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:?}");
|
|
}
|