mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-14 19:27:01 -04:00
LR-CROP-STRAIGHTEN: every way of setting the crop angle keeps the crop inside the photo (#742)
develop.set / develop.adjust / develop.quickAdjust / develop.resetControl on crop.angle
(MCP set_develop, the control channel, lightcraft-cli render --set) and crop.set {angle}
now turn the crop as crop.straighten does, through the new CropGeometry::with_angle:
kept if it still fits, else the largest crop of its aspect inside the rotated photo.
Before, they left the full rect, and the export took in smeared edge pixels in the corners.
This commit is contained in:
1 parent
b70ae1741e
commit
b8f868830b
7 files changed
+211
-18
No files matched your search
@@ -37,6 +37,17 @@ fn crop_dims(s: &Session, id: PhotoId) -> (f64, f64) {
|
||||
if o.swaps_axes() { (h, w) } else { (w, h) }
|
||||
}
|
||||
|
||||
/// After a generic control write (`develop.set`, `develop.adjust`, …) that touched `crop.angle`:
|
||||
/// refit the crop as `crop.straighten` does, from the crop it had `before`, so the box never takes in
|
||||
/// canvas outside the photo (issue #742).
|
||||
fn refit_crop_angle(touched: bool, before: CropGeometry, d: &mut DevelopSettings, (w, h): (f64, f64)) {
|
||||
if touched {
|
||||
d.crop.geometry = before.with_angle(d.crop.geometry.angle, w, h);
|
||||
}
|
||||
}
|
||||
|
||||
const CROP_ANGLE: &str = "crop.angle";
|
||||
|
||||
/// `v` as a list of numbers; `None` unless it is an array of nothing but numbers.
|
||||
fn numbers(v: Option<&Value>) -> Option<Vec<f64>> {
|
||||
v?.as_array()?.iter().map(Value::as_f64).collect()
|
||||
@@ -146,7 +157,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Set Develop Values",
|
||||
[],
|
||||
None,
|
||||
"{control?: controlId, value?: number, values?: {controlId: number}, ids?: [photo]} — see develop.controls",
|
||||
"{control?: controlId, value?: number, values?: {controlId: number}, ids?: [photo]} — see develop.controls; `crop.angle` turns the crop as crop.straighten does (kept if it still fits, else the largest of its aspect inside the photo)",
|
||||
has_active,
|
||||
|s, p| {
|
||||
let mut vals: Vec<(String, f64)> = Vec::new();
|
||||
@@ -167,7 +178,9 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
}
|
||||
}
|
||||
let label = if vals.len() == 1 { controls::find(&vals[0].0).map(|c| c.label).unwrap_or("Edit").to_string() } else { "Edit".into() };
|
||||
let apply = |d: &mut DevelopSettings, info: &lightcraft_pipeline::SourceInfo| {
|
||||
let angle = vals.iter().any(|(k, _)| k == CROP_ANGLE);
|
||||
let apply = |d: &mut DevelopSettings, info: &lightcraft_pipeline::SourceInfo, dims: (f64, f64)| {
|
||||
let before = d.crop.geometry;
|
||||
if d.wb.mode == WbMode::AsShot && vals.iter().any(|(k, _)| k == "wb.temp" || k == "wb.tint") {
|
||||
d.wb.temp = info.as_shot_temp;
|
||||
d.wb.tint = info.as_shot_tint;
|
||||
@@ -178,6 +191,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
d.wb.mode = WbMode::Custom;
|
||||
}
|
||||
}
|
||||
refit_crop_angle(angle, before, d, dims);
|
||||
};
|
||||
if let Some(ids) = p.get("ids").and_then(Value::as_array) {
|
||||
let ids: Vec<PhotoId> = ids.iter().filter_map(Value::as_u64).map(PhotoId).collect();
|
||||
@@ -187,7 +201,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
.filter_map(|(id, d)| {
|
||||
let mut d = (*d).clone();
|
||||
let info = s.source_info(id);
|
||||
apply(&mut d, &info);
|
||||
apply(&mut d, &info, crop_dims(s, id));
|
||||
s.develop_op(id, d, &label)
|
||||
})
|
||||
.collect();
|
||||
@@ -196,8 +210,9 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
}
|
||||
let id = active(s, "develop.set")?;
|
||||
let info = s.source_info(id);
|
||||
let dims = crop_dims(s, id);
|
||||
edit(s, "develop.set", &label, |d| {
|
||||
apply(d, &info);
|
||||
apply(d, &info, dims);
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
@@ -206,8 +221,10 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
let c = str_param(p, "control").ok_or_else(|| bad("develop.adjust", "missing control"))?.to_string();
|
||||
let spec = controls::find(&c).ok_or_else(|| bad("develop.adjust", "unknown control"))?;
|
||||
let delta = f64_req(p, "delta", "develop.adjust")?;
|
||||
let info = s.source_info(active(s, "develop.adjust")?);
|
||||
let id = active(s, "develop.adjust")?;
|
||||
let (info, dims) = (s.source_info(id), crop_dims(s, id));
|
||||
edit(s, "develop.adjust", spec.label, |d| {
|
||||
let before = d.crop.geometry;
|
||||
if c == "wb.temp" || c == "wb.tint" {
|
||||
if d.wb.mode == WbMode::AsShot {
|
||||
d.wb.temp = info.as_shot_temp;
|
||||
@@ -217,6 +234,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
}
|
||||
let v = controls::get(d, &c).unwrap_or(spec.default);
|
||||
controls::set(d, &c, v + delta);
|
||||
refit_crop_angle(c == CROP_ANGLE, before, d, dims);
|
||||
Ok(())
|
||||
})
|
||||
}),
|
||||
@@ -302,8 +320,11 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
cmd!("develop.resetControl", "Reset Control", [], None, "{control}", has_active, |s, p| {
|
||||
let c = str_param(p, "control").ok_or_else(|| bad("develop.resetControl", "missing control"))?.to_string();
|
||||
let spec = controls::find(&c).ok_or_else(|| bad("develop.resetControl", "unknown control"))?;
|
||||
let dims = crop_dims(s, active(s, "develop.resetControl")?);
|
||||
edit(s, "develop.resetControl", spec.label, |d| {
|
||||
let before = d.crop.geometry;
|
||||
controls::set(d, &c, spec.default);
|
||||
refit_crop_angle(c == CROP_ANGLE, before, d, dims);
|
||||
Ok(())
|
||||
})
|
||||
}),
|
||||
@@ -489,10 +510,12 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
}
|
||||
d.crop.geometry.rect = Rect::new(v[0].min(v[2]), v[1].min(v[3]), v[0].max(v[2]), v[1].max(v[3]));
|
||||
}
|
||||
if let Some(a) = p.get("angle").and_then(Value::as_f64) {
|
||||
d.crop.geometry.angle = a.clamp(-45.0, 45.0);
|
||||
}
|
||||
d.crop.geometry = d.crop.geometry.constrained(w, h);
|
||||
let angle = p.get("angle").and_then(Value::as_f64).map(|a| a.clamp(-45.0, 45.0));
|
||||
d.crop.geometry = match (p.get("rect"), angle) {
|
||||
// a new angle alone turns the crop as Straighten does (issue #742)
|
||||
(None, Some(a)) => d.crop.geometry.with_angle(a, w, h),
|
||||
(_, a) => CropGeometry { angle: a.unwrap_or(d.crop.geometry.angle), ..d.crop.geometry }.constrained(w, h),
|
||||
};
|
||||
Ok(())
|
||||
})
|
||||
}),
|
||||
@@ -548,11 +571,8 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
let (w, h) = crop_dims(s, id);
|
||||
let angle = f64_req(p, "angle", "crop.straighten")?.clamp(-45.0, 45.0);
|
||||
edit(s, "crop.straighten", "Straighten", |d| {
|
||||
let r = d.crop.geometry.rect_px(w, h);
|
||||
let fit = crop_fit_angle(w, h, angle, Some(r.width() / r.height()));
|
||||
// keep the current crop size if it still fits, else shrink
|
||||
let cand = CropGeometry { rect: d.crop.geometry.rect, angle };
|
||||
d.crop.geometry = if cand.is_within_image(w, h) { cand } else { fit };
|
||||
// keep the current crop if it still fits, else the largest of its aspect that does
|
||||
d.crop.geometry = d.crop.geometry.with_angle(angle, w, h);
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
@@ -852,8 +872,10 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
nd.wb.temp = info.as_shot_temp;
|
||||
nd.wb.tint = info.as_shot_tint;
|
||||
}
|
||||
let before = nd.crop.geometry;
|
||||
let v = controls::get(&nd, &ctl).unwrap_or(spec.default);
|
||||
controls::set(&mut nd, &ctl, v + delta);
|
||||
refit_crop_angle(ctl == CROP_ANGLE, before, &mut nd, crop_dims(s, id));
|
||||
if ctl == "wb.temp" || ctl == "wb.tint" {
|
||||
nd.wb.mode = WbMode::Custom;
|
||||
}
|
||||
|
||||
@@ -869,6 +869,8 @@ mod tests_album_order;
|
||||
#[cfg(test)]
|
||||
mod tests_color;
|
||||
#[cfg(test)]
|
||||
mod tests_crop_angle;
|
||||
#[cfg(test)]
|
||||
mod tests_denoise;
|
||||
#[cfg(test)]
|
||||
mod tests_export;
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
//! 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 that fitted at 12° fits at 0° too and is kept
|
||||
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), (want.rect, 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));
|
||||
}
|
||||
@@ -79,6 +79,22 @@ impl CropGeometry {
|
||||
let _ = best;
|
||||
CropGeometry { rect: nr.scale(1.0 / w, 1.0 / h), angle: self.angle }
|
||||
}
|
||||
|
||||
/// This crop turned to `angle_deg` on a `w × h` image: kept as it is when it still fits inside
|
||||
/// the rotated image, else the largest crop of its aspect that fits, centred (Lightroom's
|
||||
/// "Constrain to image"). Every way of setting the angle goes through here (the rotate handle,
|
||||
/// the Straighten slider and Angle field, `crop.straighten`, `develop.set` on `crop.angle`), so
|
||||
/// they all give the same crop (issue #742).
|
||||
pub fn with_angle(&self, angle_deg: f64, w: f64, h: f64) -> CropGeometry {
|
||||
let cand = CropGeometry { rect: self.rect, angle: angle_deg };
|
||||
if cand.is_within_image(w, h) {
|
||||
return cand;
|
||||
}
|
||||
let r = self.rect_px(w, h);
|
||||
// a degenerate rect has no aspect to keep: fall back to the image's own
|
||||
let aspect = Some(r.width() / r.height()).filter(|a| a.is_finite() && *a > 0.0);
|
||||
crop_fit_angle(w, h, angle_deg, aspect)
|
||||
}
|
||||
}
|
||||
|
||||
/// Largest scale `s` such that a centred `cw·s × ch·s` rectangle fits inside a `w × h` image rotated
|
||||
@@ -133,6 +149,38 @@ mod tests {
|
||||
assert!(k.is_within_image(300.0, 200.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn with_angle_keeps_a_crop_that_still_fits() {
|
||||
// a small centred crop fits at a few degrees: the angle changes, the rect does not
|
||||
let c = CropGeometry { rect: Rect::new(0.3, 0.3, 0.7, 0.7), angle: 0.0 };
|
||||
for ang in [-3.0, 0.5, 2.0, 5.0] {
|
||||
let t = c.with_angle(ang, 600.0, 400.0);
|
||||
assert_eq!(t.rect, c.rect, "{ang}°");
|
||||
assert_eq!(t.angle, ang);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn with_angle_shrinks_a_crop_that_no_longer_fits_keeping_its_aspect() {
|
||||
for (rect, ang) in [(Rect::UNIT, 15.0), (Rect::new(0.0, 0.1, 0.8, 0.9), -20.0), (Rect::new(0.2, 0.0, 0.8, 1.0), 45.0)] {
|
||||
let c = CropGeometry { rect, angle: 0.0 };
|
||||
let t = c.with_angle(ang, 600.0, 400.0);
|
||||
assert!(t.is_within_image(600.0, 400.0), "{rect:?} at {ang}°");
|
||||
let (a, b) = (c.rect_px(600.0, 400.0), t.rect_px(600.0, 400.0));
|
||||
assert!((a.aspect() - b.aspect()).abs() < 1e-9, "aspect kept: {} vs {}", a.aspect(), b.aspect());
|
||||
assert_eq!(t.angle, ang);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn with_angle_survives_a_degenerate_crop() {
|
||||
// zero height: no aspect to keep (width / height = ∞)
|
||||
let c = CropGeometry { rect: Rect::new(0.0, 0.5, 1.0, 0.5), angle: 0.0 };
|
||||
let t = c.with_angle(30.0, 600.0, 400.0);
|
||||
assert!(t.is_within_image(600.0, 400.0));
|
||||
assert!([t.rect.x0, t.rect.y0, t.rect.x1, t.rect.y1].iter().all(|v| v.is_finite()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn half_crop_maps_to_centre_quadrant() {
|
||||
let c = CropGeometry { rect: Rect::new(0.25, 0.25, 0.75, 0.75), angle: 0.0 };
|
||||
|
||||
@@ -31,6 +31,17 @@ fn angle(h: &Headless) -> f64 {
|
||||
h.app.session.catalog.photo(id).expect("the photo").develop.crop.geometry.angle
|
||||
}
|
||||
|
||||
/// The crop box lies inside the rotated photo (issue #742: a typed or slid angle shrinks it to fit,
|
||||
/// as the rotate handle does).
|
||||
fn crop_fits(h: &Headless) -> bool {
|
||||
let id = h.app.session.active().expect("a photo");
|
||||
let p = h.app.session.catalog.photo(id).expect("the photo");
|
||||
let (w, h) = (p.width as f64, p.height as f64);
|
||||
let (w, h) = if p.develop.orientation.swaps_axes() { (h, w) } else { (w, h) };
|
||||
let g = p.develop.crop.geometry;
|
||||
g.rect != lightcraft_geom::Rect::UNIT && g.is_within_image(w, h)
|
||||
}
|
||||
|
||||
fn hover(h: &mut Headless, p: egui::Pos2) {
|
||||
let r = h.request("ui.move", json!({"x": p.x, "y": p.y}), T);
|
||||
assert_eq!(r["ok"], true, "{r}");
|
||||
@@ -88,6 +99,7 @@ fn the_straighten_value_takes_an_exact_angle() {
|
||||
assert_eq!(r["ok"], true, "{r}");
|
||||
h.settle(SETTLE);
|
||||
assert!((angle(&h) - 2.5).abs() < 1e-9, "typed 2.5°, got {}", angle(&h));
|
||||
assert!(crop_fits(&h), "the Straighten value shrinks the crop to fit the photo");
|
||||
}
|
||||
|
||||
/// The angle next to the pointer reads like the Straighten value, in degrees.
|
||||
@@ -145,6 +157,7 @@ fn the_crop_panel_has_an_angle_field() {
|
||||
assert_eq!(r["ok"], true, "{r}");
|
||||
h.settle(SETTLE);
|
||||
assert!((angle(&h) + 3.25).abs() < 1e-9, "typed -3.25°, got {}", angle(&h));
|
||||
assert!(crop_fits(&h), "the Angle field shrinks the crop to fit the photo");
|
||||
}
|
||||
|
||||
/// Rotating with the pointer at the canvas's corner keeps the angle readout on the canvas (and so
|
||||
|
||||
+2
-2
@@ -164,9 +164,9 @@ per-request `_meta` receive `resultType: complete` and list/read cache hints; re
|
||||
| `select_photos {ids, active?, mode?}` | Set the selection / active photo. Every id (and `active`) must be in the library: an unknown id is a tool error (`no such photo 9999`) and the selection and active photo are left as they were |
|
||||
| `list_controls {section?}` | Every develop slider: id (`light.exposure`…), range, default, current value |
|
||||
| `get_develop {id?}` | Full develop-settings JSON, with `process`: the rendering process the photo is on ([process-versions.md](process-versions.md)) |
|
||||
| `set_develop {id?, values?, settings?, label?}` | `values`: `{controlId: number}`; `settings`: partial develop JSON deep-merged. Undoable |
|
||||
| `set_develop {id?, values?, settings?, label?}` | `values`: `{controlId: number}`; `settings`: partial develop JSON deep-merged. Undoable. A `crop.angle` value turns the crop as `crop.straighten` does: kept if it still fits, else shrunk to the largest crop of its aspect inside the photo (`settings` is merged as given, unfitted) |
|
||||
| `apply_preset {preset, amount?, ids?}` | Apply a preset (ids from `cmd_presets_list`) |
|
||||
| `crop {id?, rect?, angle?, reset?}` | Normalized crop rect `[x0,y0,x1,y1]` and straighten angle; at least one of `rect`, `angle`, `reset: true` |
|
||||
| `crop {id?, rect?, angle?, reset?}` | Normalized crop rect `[x0,y0,x1,y1]` and straighten angle; at least one of `rect`, `angle`, `reset: true`. The crop always ends inside the photo: an `angle` alone keeps the crop if it still fits, else shrinks it to the largest of its aspect that does |
|
||||
| `render_photo {id?, size?, format?, path?}` | Render with current settings → **image content** (PNG, or JPEG with `format: "jpeg"`), long edge `size` (default 1024) |
|
||||
| `export {path \| dir, id? \| ids?, format?, longEdge? \| shortEdge? \| width?/height? \| megapixels? \| percent?, dontEnlarge?, ppi?, quality?, colorSpace?, bitDepth?, …}` | Full-quality render to `.png` / `.jpg` / `.tif` / `.webp` / `.avif`; `format: "original"` copies the file + an XMP sidecar with the edits, `format: "dng"` writes raw photos as DNG with the edits embedded. No size param = 3000 px long edge; `longEdge: 0` = full size (cropped, native resolution); `width` + `height` fit either orientation; `dontEnlarge` defaults to true |
|
||||
|
||||
|
||||
+2
-2
@@ -312,9 +312,9 @@ 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`, `cmd:crop.drag`, `crates/geom/src/crop_drag.rs`, `crates/ui-egui/src/panels/detail.rs` | double-click inside the box applies the crop (like Return) |
|
||||
| LR-CROP-ASPECT | Aspect ratios | P0 | ✅ | `cmd:crop.aspect`, `cmd:crop.rotateAspect`, `crates/ui-egui/src/panels/right.rs` | menu with presets + custom W × H, visible Lock toggle (also A), button names the active ratio; every handle honours the lock and the drag stops at the image edge instead of shifting the frame (issue #295); "Original" is the photo's own shape (no separate As Shot) |
|
||||
| LR-CROP-STRAIGHTEN | Straighten tool | P0 | ✅ | `cmd:crop.straighten`, `cmd:crop.autoStraighten`, `crates/ui-egui/src/panels/detail.rs` (`rotate_cursor`, `angle_readout`), `crates/ui-egui/src/panels/right.rs` (Angle field), `crates/ui-egui/src/tests_crop_rotate.rs` | Straighten Tool button: drag along a horizon/vertical; double-click or Auto levels automatically. Dragging outside the crop box rotates it: there the pointer is a curved double arrow (with ⌘ held, the straighten crosshair), and while rotating the angle shows next to it, kept on the canvas (issue #534); an exact angle goes in the Angle field under Straighten (`cropAngleField`: applies on Return or leaving it, Esc keeps the old angle, takes a decimal comma; a drag on it is one undo step) or by clicking the Straighten value (its hover says so) |
|
||||
| LR-CROP-STRAIGHTEN | Straighten tool | P0 | ✅ | `cmd:crop.straighten`, `cmd:crop.autoStraighten`, `crates/ui-egui/src/panels/detail.rs` (`rotate_cursor`, `angle_readout`), `crates/ui-egui/src/panels/right.rs` (Angle field), `crates/ui-egui/src/tests_crop_rotate.rs` | Straighten Tool button: drag along a horizon/vertical; double-click or Auto levels automatically. Dragging outside the crop box rotates it: there the pointer is a curved double arrow (with ⌘ held, the straighten crosshair), and while rotating the angle shows next to it, kept on the canvas (issue #534); an exact angle goes in the Angle field under Straighten (`cropAngleField`: applies on Return or leaving it, Esc keeps the old angle, takes a decimal comma; a drag on it is one undo step) or by clicking the Straighten value (its hover says so). Every way of setting the angle (rotate handle, Straighten slider, Angle field, `crop.straighten`, `crop.set {angle}`, and the generic `develop.set` / `develop.adjust` / `develop.quickAdjust` / `develop.resetControl` on `crop.angle`, as MCP, the control channel and `lightcraft-cli render --set` use them) keeps the crop if it still fits, else shrinks it to the largest crop of its aspect inside the photo (`CropGeometry::with_angle`, `crates/engine/src/tests_crop_angle.rs`; before issue #742 the generic path left the box, and the export, past the photo with smeared edge pixels in the corners) |
|
||||
| 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`, `crates/ui-egui/src/panels/right.rs` | the Straighten slider; an exact angle can also be typed in the Angle field under it (see LR-CROP-STRAIGHTEN) |
|
||||
| LR-CROP-ANGLE | Angle slider | P0 | ✅ | `ctl:crop.angle`, `crates/ui-egui/src/panels/right.rs` | the Straighten slider; an exact angle can also be typed in the Angle field under it (see LR-CROP-STRAIGHTEN); setting `crop.angle` through `develop.set` fits the crop inside the photo as Straighten does (issue #742) |
|
||||
| LR-CROP-ROTATE90 | Rotate 90° | P0 | ✅ | `cmd:photo.rotateLeft`, `cmd:photo.rotateRight` | |
|
||||
| LR-CROP-FLIP | Flip | P0 | ✅ | `cmd:photo.flipHorizontal`, `cmd:photo.flipVertical` | |
|
||||
| LR-CROP-OVERLAY | Crop overlays | P1 | ✅ | `cmd:view.cropOverlay`, `cmd:view.cropOverlayOrientation`, `crates/ui-egui/src/panels/crop_overlay.rs` | thirds, grid, golden ratio, diagonal, triangle, golden spiral (mirrored with ⇧O while cropping); no aspect-ratio overlays |
|
||||
|
||||
Reference in new issue
Block a user