collective

Browse / github.com/storytold/lightcraft / patch

Issue #3056: Paste Selected Settings pastes crop and Remove left out of the copy

Remove and Crop settings do not paste when copying settings between images When copying edit settings from one image and pasting them onto another, the **Remove** and **Crop** settings are not applied (possibly others as well). Other settings paste normally. ### Steps to reproduce 1. Edit an image with Remove and/or Crop adjustments 2. Copy the settings (Copy Edit Settings) 3. Select a different image 4. Paste Edit settings or Paste Selected Settings 5. Observe that Remove/Crop edits do not appear on the target image ### Expected Remove and Crop settings, especially when selected with "Paste Selected Settings" should paste like other adjustments. ### Actual Remove

Status
open
Score
0.00
Adopters
0 other operators
Author
THEtheChad (imported from pull request #3148, open)
Base
662df66f1c08bdb1b1f1d0c1c638c4cef936d0b0
Size
+550 −57 in 29 files
Review
pending
Shared
2026-10-28
Id
119badb6b78b64c8ff159321

How it went

No other operator has reported using it yet.

Diff

raw · crates/develop/src/lib.rs crates/develop/src/settings.rs crates/engine/src/cmd/develop.rs crates/engine/src/lib.rs crates/engine/src/tests.rs crates/geom/src/crop.rs crates/geom/src/lib.rs crates/ui-egui/locales/cs-formats.json crates/ui-egui/locales/de-formats.json crates/ui-egui/locales/es-formats.json crates/ui-egui/locales/fi-formats.json crates/ui-egui/locales/fr-formats.json crates/ui-egui/locales/id-formats.json crates/ui-egui/locales/it-formats.json crates/ui-egui/locales/ja-formats.json crates/ui-egui/locales/ka-formats.json crates/ui-egui/locales/pl-formats.json crates/ui-egui/locales/pt-br-formats.json crates/ui-egui/locales/ru-formats.json crates/ui-egui/locales/sv-formats.json crates/ui-egui/locales/uk-formats.json crates/ui-egui/locales/vi-formats.json crates/ui-egui/locales/zh-hans-formats.json crates/ui-egui/locales/zh-hant-formats.json crates/ui-egui/src/dialogs.rs crates/ui-egui/src/state/dialog.rs crates/ui-egui/src/tests/copy_paste_dialogs.rs docs/parity-checklist.md docs/whats-new.md

diff --git a/crates/develop/src/lib.rs b/crates/develop/src/lib.rs--- a/crates/develop/src/lib.rs+++ b/crates/develop/src/lib.rs@@ -363,4 +363,22 @@ mod tests {         s.reset_section(Section::Detail);         assert_eq!(s.enhance.denoise_on, None);     }++    /// Issue #3056 (review): a refitted crop keeps an explicit aspect lock, also on the whole photo,+    /// and an "Original" lock follows the new photo's shape; the lock stays true to the crop.+    #[test]+    fn crop_refit_honours_the_aspect_lock() {+        let (land, tall) = ((6000.0, 4000.0), (4000.0, 6000.0));+        let three_two = Crop { aspect: Some((300, 200)), ..Crop::default() };+        let r = three_two.refit(land, tall);+        assert_eq!(r.aspect, Some((300, 200)));+        assert!((r.geometry.rect_px(tall.0, tall.1).aspect() - 1.5).abs() < 1e-9, "{:?}", r.geometry);+        let original = Crop { aspect: Some(Crop::original_aspect(land.0, land.1)), flip_h: true, ..Crop::default() };+        let r = original.refit(land, tall);+        assert_eq!(r.aspect, Some(Crop::original_aspect(tall.0, tall.1)));+        assert_eq!(r.geometry, lightcraft_geom::CropGeometry::default());+        assert!(r.flip_h);+        // unlocked, the whole photo stays the whole photo+        assert_eq!(Crop::default().refit(land, tall), Crop::default());+    } }diff --git a/crates/develop/src/settings.rs b/crates/develop/src/settings.rs--- a/crates/develop/src/settings.rs+++ b/crates/develop/src/settings.rs@@ -3,7 +3,7 @@ //! exception is `process`: missing (or unreadable), it is V1, the rendering those older files were //! made with. -use lightcraft_geom::{CropGeometry, Homography, Orientation, Point};+use lightcraft_geom::{CropGeometry, Homography, Orientation, Point, RefitShape}; use serde::{Deserialize, Serialize};  pub const SCHEMA_VERSION: u32 = 1;@@ -814,12 +814,36 @@ impl Geometry { #[serde(default)] pub struct Crop {     pub geometry: CropGeometry,-    /// Locked aspect (w, h); None = free / original.+    /// Locked aspect (w × 100, h × 100); None = free. "Original" is the photo's own size as+    /// oriented ([`Crop::original_aspect`]).     pub aspect: Option<(u32, u32)>,     pub flip_h: bool,     pub flip_v: bool, } +impl Crop {+    /// The "Original" aspect lock of a `w × h` photo (as oriented): its own size, in hundredths.+    pub fn original_aspect(w: f64, h: f64) -> (u32, u32) {+        ((w * 100.0) as u32, (h * 100.0) as u32)+    }++    /// This crop, made on a photo `from` (w, h, as oriented) in size, for a photo of size `to`+    /// (pasting or syncing it, [`CropGeometry::refit`]): a locked aspect ratio is kept, also by a+    /// crop of the whole photo, and an "Original" lock takes the new photo's shape (and becomes its+    /// Original lock); unlocked, the whole photo stays the whole photo and any other crop keeps its+    /// aspect ratio.+    pub fn refit(&self, from: (f64, f64), to: (f64, f64)) -> Crop {+        let valid = |(w, h): (f64, f64)| w.is_finite() && h.is_finite() && w > 0.0 && h > 0.0;+        let shape = match self.aspect {+            None => RefitShape::Free,+            Some(a) if valid(from) && a == Self::original_aspect(from.0, from.1) => RefitShape::Photo,+            Some(_) => RefitShape::Locked,+        };+        let aspect = if shape == RefitShape::Photo && valid(to) { Some(Self::original_aspect(to.0, to.1)) } else { self.aspect };+        Crop { geometry: self.geometry.refit(from, to, shape), aspect, ..*self }+    }+}+ // --------------------------------------------------------------------------------------------- // Masks and local adjustments diff --git a/crates/engine/src/cmd/develop.rs b/crates/engine/src/cmd/develop.rs--- a/crates/engine/src/cmd/develop.rs+++ b/crates/engine/src/cmd/develop.rs@@ -6,7 +6,7 @@ use lightcraft_geom::{CropGeometry, CropHandle, MIN_CROP_FRACTION, Point, Rect, use serde_json::{Value, json};  use super::{CommandSpec, always, bad, bool_or, cmd, f64_or, f64_req, has_active, has_clipboard, has_selection, index_req, ok, str_param};-use crate::{Result, Session, media::SourceLevel};+use crate::{Result, Session, SettingsClipboard, media::SourceLevel};  fn active(s: &Session, c: &str) -> Result<PhotoId> {     s.active().ok_or_else(|| bad(c, "no active photo"))@@ -33,6 +33,20 @@ fn groups_param(p: &Value, c: &str) -> Result<Option<Vec<SettingsGroup>>> {     } } +/// The settings `partial` (groups taken from a photo `from` in size, as oriented) pasted onto photo+/// `id`'s settings `to`. A pasted crop keeps its shape on a photo of another shape, as in Lightroom+/// ([`lightcraft_develop::Crop::refit`]); without both sizes it is pasted as it is. The size of `id`+/// is taken with the pasted orientation (the Crop group carries it).+fn paste_onto(s: &Session, partial: &Value, from: Option<(f64, f64)>, id: PhotoId, to: &DevelopSettings) -> DevelopSettings {+    let mut d = lightcraft_develop::apply_partial(to, partial, 1.0);+    if partial.get("crop").is_some()+        && let (Some(from), Some(dims)) = (from, s.oriented_dims(id, d.orientation))+    {+        d.crop = d.crop.refit(from, dims);+    }+    d+}+ fn crop_dims(s: &Session, id: PhotoId) -> (f64, f64) {     let p = s.catalog.photo(id);     let (w, h) = p.map(|p| (p.width.max(1) as f64, p.height.max(1) as f64)).unwrap_or((3.0, 2.0));@@ -683,7 +697,7 @@ pub fn specs() -> Vec<CommandSpec> {                 let aspect = match p.get("aspect") {                     Some(Value::String(a)) if a == "toggle" => None,                     Some(Value::String(a)) if a == "free" => None,-                    Some(Value::String(a)) if a == "original" => Some(((w * 100.0) as u32, (h * 100.0) as u32)),+                    Some(Value::String(a)) if a == "original" => Some(lightcraft_develop::Crop::original_aspect(w, h)),                     Some(Value::String(a)) => {                         let (x, y) = a.split_once('x').ok_or_else(|| bad("crop.aspect", "use WxH"))?;                         let (x, y): (f64, f64) =@@ -826,8 +840,10 @@ pub fn specs() -> Vec<CommandSpec> {                 if let Some(g) = groups_param(p, c)? {                     s.copy_groups = g;                 }-                let d = s.develop_of(id).unwrap_or_default();-                s.clipboard = Some(lightcraft_develop::extract_groups(&d, &s.copy_groups));+                // the whole settings: Paste Selected can paste a group the copy left out (#3056)+                let settings = s.develop_of(id).unwrap_or_default();+                let dims = s.oriented_dims(id, settings.orientation);+                s.clipboard = Some(SettingsClipboard { settings, dims });                 Ok(json!({"groups": s.copy_groups}))             }         ),@@ -843,12 +859,14 @@ pub fn specs() -> Vec<CommandSpec> {                 let prev =                     s.previous_active.filter(|id| s.catalog.photo(*id).is_some_and(|ph| !ph.deleted)).ok_or_else(|| bad(c, "no previous photo"))?;                 let groups = groups_param(p, c)?.unwrap_or_else(|| s.copy_groups.clone());-                let partial = lightcraft_develop::extract_groups(&s.develop_of(prev).unwrap_or_default(), &groups);+                let from = s.develop_of(prev).unwrap_or_default();+                let partial = lightcraft_develop::extract_groups(&from, &groups);+                let dims = s.oriented_dims(prev, from.orientation);                 let ids: Vec<PhotoId> = s.targets(p)?.into_iter().filter(|id| *id != prev).collect();                 let ops = ids                     .iter()                     .filter_map(|id| s.develop_of(*id).map(|d| (*id, d)))-                    .filter_map(|(id, d)| s.develop_op(id, lightcraft_develop::apply_partial(&d, &partial, 1.0), "Paste from Previous"))+                    .filter_map(|(id, d)| s.develop_op(id, paste_onto(s, &partial, dims, id, &d), "Paste from Previous"))                     .collect::<Vec<_>>();                 let n = ops.len();                 s.commit("Paste from Previous", Op::Batch { ops })?;@@ -860,22 +878,18 @@ pub fn specs() -> Vec<CommandSpec> {             "Paste Edit Settings",             ["Edit"],             Some("Cmd+V"),-            "{ids?, groups?: [settingsGroup] (paste only these of the copied groups)}",+            "{ids?, groups?: [settingsGroup] (default: the groups chosen when copying; any group of the copied photo, Paste Selected Settings)}",             has_clipboard,             |s, p| {                 let c = "develop.paste";-                let mut clip = s.clipboard.clone().unwrap_or_default();-                if let Some(only) = groups_param(p, c)? {-                    // the clipboard holds whole groups: rebuild it with just the chosen (copied) ones-                    let only: Vec<_> = only.into_iter().filter(|g| s.copy_groups.contains(g)).collect();-                    let full = lightcraft_develop::apply_partial(&DevelopSettings::default(), &clip, 1.0);-                    clip = lightcraft_develop::extract_groups(&full, &only);-                }+                let clip = s.clipboard.clone().ok_or_else(|| bad(c, "no settings copied"))?;+                let groups = groups_param(p, c)?.unwrap_or_else(|| s.copy_groups.clone());+                let partial = lightcraft_develop::extract_groups(&clip.settings, &groups);                 let ids = s.targets(p)?;                 let ops = ids                     .iter()                     .filter_map(|id| s.develop_of(*id).map(|d| (*id, d)))-                    .filter_map(|(id, d)| s.develop_op(id, lightcraft_develop::apply_partial(&d, &clip, 1.0), "Paste Settings"))+                    .filter_map(|(id, d)| s.develop_op(id, paste_onto(s, &partial, clip.dims, id, &d), "Paste Settings"))                     .collect::<Vec<_>>();                 let n = ops.len();                 s.commit("Paste Settings", Op::Batch { ops })?;@@ -895,6 +909,7 @@ pub fn specs() -> Vec<CommandSpec> {                 let groups = groups_param(p, c)?.unwrap_or_else(SettingsGroup::default_copy);                 let d = s.develop_of(id).unwrap_or_default();                 let partial = lightcraft_develop::extract_groups(&d, &groups);+                let dims = s.oriented_dims(id, d.orientation);                 let ops = s                     .selection                     .ids@@ -902,7 +917,7 @@ pub fn specs() -> Vec<CommandSpec> {                     .into_iter()                     .filter(|x| *x != id)                     .filter_map(|x| s.develop_of(x).map(|d| (x, d)))-                    .filter_map(|(x, dd)| s.develop_op(x, lightcraft_develop::apply_partial(&dd, &partial, 1.0), "Sync Settings"))+                    .filter_map(|(x, dd)| s.develop_op(x, paste_onto(s, &partial, dims, x, &dd), "Sync Settings"))                     .collect::<Vec<_>>();                 let n = ops.len();                 s.commit("Sync Settings", Op::Batch { ops })?;@@ -1013,6 +1028,8 @@ pub fn specs() -> Vec<CommandSpec> {             let amount = f64_or(p, "amount", 100.0).clamp(0.0, 200.0) / 100.0;             // `id` names the preset here: the photos are `ids`, else the selection             let ids = s.targets(&json!({"ids": p.get("ids")}))?;+            // a preset's crop is applied as stored: it has no source photo whose shape a refit+            // could start from (unlike Paste and Sync, issue #3056)             let ops = ids                 .iter()                 .filter_map(|id| s.develop_of(*id).map(|d| (*id, d)))diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs--- a/crates/engine/src/lib.rs+++ b/crates/engine/src/lib.rs@@ -224,6 +224,18 @@ fn filter_depends_on_clock(filter: &Filter, catalog: &Catalog) -> bool { static VISIBLE_GEN: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1); static LIBRARY_GEN: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1); +/// Copied develop settings (Copy Edit Settings). The copied photo's settings are kept whole, so+/// Paste Selected Settings can paste any group, also one the copy left out (crop, Remove, …; issue+/// #3056); a plain Paste applies [`Session::copy_groups`].+#[derive(Clone, Debug)]+pub struct SettingsClipboard {+    /// The copied photo's settings, every group.+    pub settings: Arc<DevelopSettings>,+    /// The copied photo's size as oriented, to refit a pasted crop to a photo of another shape;+    /// `None` when unknown (the crop is then pasted as it is).+    pub dims: Option<(f64, f64)>,+}+ pub struct Session {     library_generation: u64,     /// Auto Sync: edits to the active photo also change the other selected photos (the settings@@ -294,8 +306,8 @@ pub struct Session {     /// Reading focus points for photos imported before they were read (`photo.readFocusPoints`, `focus.pump`).     #[cfg(not(target_arch = "wasm32"))]     pub(crate) focus_scan: focus_scan::FocusScan,-    /// Copied develop settings (partial JSON) for Paste.-    pub clipboard: Option<Value>,+    /// Copied develop settings for Paste (Copy Edit Settings).+    pub clipboard: Option<SettingsClipboard>,     /// The folder on disk the [`LibrarySource::Folder`] view browses.     pub browse: Option<Browse>,     /// The folder the [`LibrarySource::LibraryFolder`] view shows: the library's photos imported@@ -305,7 +317,7 @@ pub struct Session {     pub meta_clipboard: Option<Value>,     /// The photo that was active before the current one (Paste Settings from Previous).     pub previous_active: Option<PhotoId>,-    /// Groups last used for Copy (Lightroom remembers them).+    /// Groups last used for Copy (Lightroom remembers them): what a plain Paste applies.     pub copy_groups: Vec<lightcraft_develop::SettingsGroup>,     /// Built-in then user presets; its generation tells savers and caches when it changed.     pub presets: presets::PresetList,@@ -969,8 +981,11 @@ impl Session {     /// With Auto Sync on, the ops that carry an edit of the active photo `id` (to `new`, as asked,     /// before its crop is fitted) over to the other selected photos: only the settings that changed;     /// never spot removal or red eye (they belong to one photo's pixels), nor the rendering process-    /// (Sync doesn't carry it either), nor history / snapshot restores. Constrain Crop then fits each-    /// photo's own crop: the active photo's fitted crop is never copied over another's.+    /// (Sync doesn't carry it either), nor history / snapshot restores. A changed crop rect or aspect+    /// lock is refitted to each photo's shape as Sync does ([`lightcraft_develop::Crop::refit`]),+    /// leaving the crop fields the edit didn't change (a rect edit keeps each photo's angle).+    /// Constrain Crop then fits each photo's own crop: the active photo's fitted crop is never+    /// copied over another's.     fn auto_sync_ops(&self, id: PhotoId, new: &DevelopSettings, label: &str) -> Vec<Op> {         if !self.auto_sync             || self.active() != Some(id)@@ -1005,12 +1020,45 @@ impl Session {             .filter(|x| **x != id)             .filter_map(|x| self.develop_of(*x).map(|d| (*x, d)))             .filter_map(|(x, d)| {-                let synced = self.fit_constrained_crop(x, lightcraft_develop::apply_partial(&d, &delta, 1.0));+                let mut synced = lightcraft_develop::apply_partial(&d, &delta, 1.0);+                // a changed crop takes this photo's shape, as Sync and Paste give it (issue #3056).+                // Only what changed: the refit starts from this photo's crop with the changed leaves+                // applied (a new rect keeps this photo's angle) under the lock the edit was made with,+                // and sets only the rect and lock the edit changed (the refit keeps angle and flips).+                let crop = delta.get("crop");+                let rect_changed = crop.and_then(|c| c.get("geometry")).is_some_and(|g| g.get("rect").is_some());+                let aspect_changed = crop.is_some_and(|c| c.get("aspect").is_some());+                if (rect_changed || aspect_changed)+                    && let (Some(from), Some(to)) = (self.oriented_dims(id, new.orientation), self.oriented_dims(x, synced.orientation))+                {+                    let mut start = lightcraft_develop::Crop { aspect: new.crop.aspect, ..synced.crop };+                    // a dragged edge or two mean nothing in a photo of another shape: it refits the+                    // active photo's whole rect (at its own angle); one of the same shape takes the edges+                    let same_shape = (from.0 * to.1 - to.0 * from.1).abs() <= 1e-6 * (to.0 * from.1);+                    if rect_changed && !same_shape {+                        start.geometry.rect = new.crop.geometry.rect;+                    }+                    let fit = start.refit(from, to);+                    if rect_changed {+                        synced.crop.geometry.rect = fit.geometry.rect;+                    }+                    if aspect_changed {+                        synced.crop.aspect = fit.aspect;+                    }+                }+                let synced = self.fit_constrained_crop(x, synced);                 (synced != *d).then(|| self.develop_op(x, synced, label)).flatten()             })             .collect()     } +    /// Photo `id`'s size as oriented with `orientation`; `None` when the catalog doesn't know it.+    pub(crate) fn oriented_dims(&self, id: PhotoId, orientation: lightcraft_geom::Orientation) -> Option<(f64, f64)> {+        let p = self.catalog.photo(id)?;+        let (w, h) = (p.width as f64, p.height as f64);+        (w > 0.0 && h > 0.0).then_some(if orientation.swaps_axes() { (h, w) } else { (w, h) })+    }+     /// The op that sets a photo's develop settings and appends a History entry (for batches).     pub fn develop_op(&self, id: PhotoId, settings: DevelopSettings, label: &str) -> Option<Op> {         self.catalog.photo(id)?;diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs--- a/crates/engine/src/tests.rs+++ b/crates/engine/src/tests.rs@@ -568,22 +568,255 @@ fn memory_report_counts_decoded_sources_and_renders() { }  #[test]-fn paste_selected_settings_pastes_only_chosen_copied_groups() {+fn paste_selected_settings_pastes_any_chosen_group_of_the_copied_photo() {     let mut s = demo();     let ids: Vec<_> = s.visible().iter().copied().take(2).collect();     s.execute("library.select", &json!({"ids": [ids[0].0]})).unwrap();     s.execute("develop.set", &json!({"values": {"light.exposure": 1.0, "color.vibrance": 30, "detail.sharpenAmount": 90}})).unwrap();     s.execute("develop.copy", &json!({"groups": ["light", "color"]})).unwrap();-    // `detail` was not copied: asking for it pastes nothing for it+    // `detail` was left out of the copy: Paste Selected still pastes it, and only what was chosen     s.execute("develop.paste", &json!({"ids": [ids[1].0], "groups": ["light", "detail"]})).unwrap();     let d = s.develop_of(ids[1]).unwrap();     let base = lightcraft_develop::DevelopSettings::default();     assert_eq!(d.light.exposure, 1.0);+    assert_eq!(d.detail.sharpen_amount, 90.0);     assert_eq!(d.color.vibrance, base.color.vibrance);-    assert_ne!(d.detail.sharpen_amount, 90.0);-    // without groups: everything copied+    // without groups: the copied groups+    s.execute("develop.reset", &json!({"ids": [ids[1].0]})).unwrap();     s.execute("develop.paste", &json!({"ids": [ids[1].0]})).unwrap();-    assert_eq!(s.develop_of(ids[1]).unwrap().color.vibrance, 30.0);+    let d = s.develop_of(ids[1]).unwrap();+    assert_eq!((d.light.exposure, d.color.vibrance), (1.0, 30.0));+    assert_eq!(d.detail.sharpen_amount, base.detail.sharpen_amount, "a plain paste leaves out what the copy left out");+}++/// Two demo photos of the same shape and one of the other (landscape / portrait).+fn photos_by_shape(s: &mut Session) -> (lightcraft_catalog::PhotoId, lightcraft_catalog::PhotoId, lightcraft_catalog::PhotoId) {+    let vis = s.visible_cloned();+    let portrait = |id: &lightcraft_catalog::PhotoId| s.catalog.photo(*id).is_some_and(|p| p.height > p.width);+    let land: Vec<_> = vis.iter().copied().filter(|id| !portrait(id)).take(2).collect();+    let tall = vis.iter().copied().find(portrait).expect("the demo library has a portrait photo");+    (land[0], land[1], tall)+}++/// The crop of `id` in pixels of its oriented frame.+fn crop_px(s: &Session, id: lightcraft_catalog::PhotoId) -> lightcraft_geom::Rect {+    let p = s.catalog.photo(id).unwrap();+    let (w, h) = if p.develop.orientation.swaps_axes() { (p.height, p.width) } else { (p.width, p.height) };+    p.develop.crop.geometry.rect_px(w as f64, h as f64)+}++/// Issue #3056: Copy leaves crop and Remove out by default, as Lightroom does, so a plain paste+/// doesn't carry them; ticking them in Paste Selected Settings pastes them (they used to be dropped+/// because the clipboard held only the copied groups) onto every target, in one undo step.+#[test]+fn paste_selected_settings_pastes_crop_and_remove_left_out_of_the_copy() {+    let mut s = demo();+    let (a, b, c) = photos_by_shape(&mut s);+    let c2 = s.visible_cloned().into_iter().find(|id| ![a, b, c].contains(id) && s.catalog.photo(*id).is_some_and(|p| p.width > p.height)).unwrap();+    s.execute("library.select", &json!({"ids": [a.0]})).unwrap();+    s.execute("crop.set", &json!({"rect": [0.1, 0.2, 0.7, 0.8]})).unwrap();+    s.execute("spot.add", &json!({"points": [[0.4, 0.5]], "size": 0.05, "mode": "heal"})).unwrap();+    s.execute("develop.set", &json!({"values": {"light.exposure": 0.5}})).unwrap();+    let src = (*s.develop_of(a).unwrap()).clone();+    s.execute("develop.copy", &json!({})).unwrap();++    s.execute("develop.paste", &json!({"ids": [b.0]})).unwrap();+    let d = s.develop_of(b).unwrap();+    assert_eq!(d.light.exposure, 0.5);+    assert!(d.crop.geometry.is_identity() && d.spots.is_empty(), "a plain paste carries no crop or Remove: {:?}", d.crop);++    let before: Vec<_> = [b, c2].iter().map(|id| (*s.develop_of(*id).unwrap()).clone()).collect();+    let r = s.execute("develop.paste", &json!({"ids": [b.0, c2.0], "groups": ["crop", "spots"]})).unwrap();+    assert_eq!(r["changed"], 2);+    for id in [b, c2] {+        let d = s.develop_of(id).unwrap();+        assert_eq!(d.crop.geometry, src.crop.geometry, "same shape: the same crop");+        assert_eq!(d.spots, src.spots);+    }+    s.execute("edit.undo", &json!({})).unwrap();+    for (id, was) in [b, c2].iter().zip(&before) {+        assert_eq!(*s.develop_of(*id).unwrap(), *was, "one undo step takes back the whole paste");+    }+}++/// A crop pasted (or synced) onto a photo of another shape keeps its aspect ratio, as in Lightroom,+/// instead of its normalized rectangle (which would stretch it).+#[test]+fn pasted_crop_keeps_its_aspect_on_a_photo_of_another_shape() {+    let mut s = demo();+    let (a, _, tall) = photos_by_shape(&mut s);+    s.execute("library.select", &json!({"ids": [a.0]})).unwrap();+    // a square on the 3:2 landscape (6000 × 4000 nominal)+    s.execute("crop.set", &json!({"rect": [0.3, 0.2, 0.3 + 0.6 * 4.0 / 6.0, 0.8]})).unwrap();+    let src = crop_px(&s, a);+    assert!((src.aspect() - 1.0).abs() < 1e-6, "{src:?}");+    s.execute("develop.copy", &json!({})).unwrap();+    s.execute("develop.paste", &json!({"ids": [tall.0], "groups": ["crop"]})).unwrap();+    let r = crop_px(&s, tall);+    assert!((r.aspect() - 1.0).abs() < 1e-6, "the square stays square on the portrait photo: {r:?}");+    let d = s.develop_of(tall).unwrap();+    let (w, h) = (s.catalog.photo(tall).unwrap().width as f64, s.catalog.photo(tall).unwrap().height as f64);+    assert!(d.crop.geometry.is_within_image(w, h));++    // Sync Settings with the crop does the same+    s.execute("develop.reset", &json!({"ids": [tall.0]})).unwrap();+    s.execute("library.select", &json!({"ids": [a.0, tall.0], "active": a.0})).unwrap();+    s.execute("develop.sync", &json!({"groups": ["crop"]})).unwrap();+    let r = crop_px(&s, tall);+    assert!((r.aspect() - 1.0).abs() < 1e-6, "sync: {r:?}");+}++fn near(r: lightcraft_geom::Rect, want: [f64; 4]) -> bool {+    [r.x0, r.y0, r.x1, r.y1].iter().zip(want).all(|(a, b)| (a - b).abs() < 1e-6)+}++/// Review of #3056: a 3:2 lock chosen on a 3:2 photo crops nothing, yet it is a lock: pasted onto a+/// portrait photo it gives a centred 3:2 crop and keeps the lock, while an "Original" lock takes the+/// portrait's own shape (and becomes its Original lock).+#[test]+fn pasted_crop_keeps_an_explicit_aspect_lock_that_crops_nothing() {+    let mut s = demo();+    let (a, _, tall) = photos_by_shape(&mut s);+    s.execute("library.select", &json!({"ids": [a.0]})).unwrap();+    s.execute("crop.aspect", &json!({"aspect": "3x2"})).unwrap();+    let d = s.develop_of(a).unwrap();+    assert_eq!((d.crop.geometry.rect, d.crop.aspect), (lightcraft_geom::Rect::UNIT, Some((300, 200))), "3:2 on 6000 × 4000");+    s.execute("develop.copy", &json!({})).unwrap();+    s.execute("develop.paste", &json!({"ids": [tall.0], "groups": ["crop"]})).unwrap();+    let d = s.develop_of(tall).unwrap();+    assert!(near(d.crop.geometry.rect, [0.0, 1.0 / 3.6, 1.0, 1.0 - 1.0 / 3.6]), "a centred 3:2 band: {:?}", d.crop);+    assert_eq!(d.crop.aspect, Some((300, 200)), "the lock is kept");+    assert!((crop_px(&s, tall).aspect() - 1.5).abs() < 1e-9);++    s.execute("crop.aspect", &json!({"aspect": "original"})).unwrap();+    assert_eq!(s.develop_of(a).unwrap().crop.aspect, Some((600_000, 400_000)));+    s.execute("develop.copy", &json!({})).unwrap();+    s.execute("develop.paste", &json!({"ids": [tall.0], "groups": ["crop"]})).unwrap();+    let d = s.develop_of(tall).unwrap();+    assert_eq!((d.crop.geometry.rect, d.crop.aspect), (lightcraft_geom::Rect::UNIT, Some((400_000, 600_000))), "Original: the whole portrait");+}++/// The Crop group carries the orientation: the target's shape is the one it has after the paste. A+/// crop from a landscape photo turned upright lands as it is on another landscape photo (turned+/// upright by the same paste), not refitted to the target's old landscape shape.+#[test]+fn pasted_crop_refits_to_the_orientation_it_carries() {+    let mut s = demo();+    let (a, b, _) = photos_by_shape(&mut s);+    s.execute("library.select", &json!({"ids": [a.0]})).unwrap();+    s.execute("photo.rotateRight", &json!({"ids": [a.0]})).unwrap();+    // a square on the upright 4000 × 6000 frame+    s.execute("crop.set", &json!({"rect": [0.2, 0.1, 0.8, 0.5]})).unwrap();+    let src = s.develop_of(a).unwrap();+    assert!(src.orientation.swaps_axes());+    assert!((crop_px(&s, a).aspect() - 1.0).abs() < 1e-9);+    s.execute("develop.copy", &json!({})).unwrap();+    s.execute("develop.paste", &json!({"ids": [b.0], "groups": ["crop"]})).unwrap();+    let d = s.develop_of(b).unwrap();+    assert_eq!(d.orientation, src.orientation);+    assert_eq!(d.crop, src.crop, "same shape once turned: the crop as it is");+}++/// Paste Settings from Previous refits a crop as Paste does.+#[test]+fn paste_from_previous_refits_the_crop() {+    let mut s = demo();+    let (a, _, tall) = photos_by_shape(&mut s);+    s.execute("library.select", &json!({"ids": [a.0]})).unwrap();+    s.execute("crop.set", &json!({"rect": [0.3, 0.2, 0.7, 0.8]})).unwrap();+    s.execute("library.select", &json!({"ids": [tall.0]})).unwrap();+    s.execute("develop.pastePrevious", &json!({"groups": ["crop"]})).unwrap();+    let r = crop_px(&s, tall);+    assert!((r.aspect() - 1.0).abs() < 1e-6, "the square stays square: {r:?}");+}++/// Auto Sync carries a crop as Sync Settings does: refitted to each photo's shape, not as the same+/// normalized rectangle (which stretches it).+#[test]+fn auto_sync_refits_a_crop_like_sync() {+    let crop_of_tall = |auto: bool| {+        let mut s = demo();+        let (a, _, tall) = photos_by_shape(&mut s);+        s.execute("library.select", &json!({"ids": [a.0, tall.0], "active": a.0})).unwrap();+        if auto {+            s.execute("develop.autoSync", &json!({"on": true})).unwrap();+        }+        s.execute("crop.set", &json!({"rect": [0.3, 0.2, 0.7, 0.8]})).unwrap();+        if !auto {+            s.execute("develop.sync", &json!({"groups": ["crop"]})).unwrap();+        }+        (s.develop_of(tall).unwrap().crop, crop_px(&s, tall))+    };+    let (synced, auto) = (crop_of_tall(false), crop_of_tall(true));+    assert!((auto.1.aspect() - 1.0).abs() < 1e-6, "the square stays square: {:?}", auto.1);+    assert_eq!(auto.0, synced.0);+}++/// Review of #3056: Auto Sync carries only the crop fields the edit changed. A new crop rect on the+/// active photo is refitted to each other photo at that photo's own angle, which it keeps.+#[test]+fn auto_sync_of_a_crop_rect_keeps_each_photos_angle() {+    let mut s = demo();+    let (a, b, tall) = photos_by_shape(&mut s);+    for (id, angle) in [(b, 5.0), (tall, -3.0)] {+        s.execute("library.select", &json!({"ids": [id.0]})).unwrap();+        s.execute("crop.straighten", &json!({"angle": angle})).unwrap();+    }+    s.execute("library.select", &json!({"ids": [a.0, b.0, tall.0], "active": a.0})).unwrap();+    s.execute("develop.autoSync", &json!({"on": true})).unwrap();+    s.execute("crop.set", &json!({"rect": [0.3, 0.2, 0.7, 0.8]})).unwrap();+    let src = s.develop_of(a).unwrap().crop.geometry;+    assert_eq!(src.angle, 0.0);+    let d = s.develop_of(b).unwrap();+    assert_eq!((d.crop.geometry.rect, d.crop.geometry.angle), (src.rect, 5.0), "same shape: the rect, at b's own angle");+    let d = s.develop_of(tall).unwrap();+    assert_eq!(d.crop.geometry.angle, -3.0, "the portrait keeps its angle");+    let p = s.catalog.photo(tall).unwrap();+    assert!(d.crop.geometry.is_within_image(p.width as f64, p.height as f64), "{:?}", d.crop.geometry);+    let r = crop_px(&s, tall);+    assert!((r.aspect() - 1.0).abs() < 1e-6, "the square, refitted at the portrait's angle: {r:?}");+}++/// Review of #3056: dragging one edge changes one leaf of the rect. A photo of another shape takes+/// the active photo's whole crop refitted, not its own rect with that edge spliced in.+#[test]+fn auto_sync_of_one_crop_edge_refits_the_whole_crop_onto_another_shape() {+    let mut s = demo();+    let (a, _, tall) = photos_by_shape(&mut s);+    s.execute("library.select", &json!({"ids": [tall.0]})).unwrap();+    s.execute("crop.set", &json!({"rect": [0.1, 0.1, 0.9, 0.9], "angle": -3.0})).unwrap();+    s.execute("library.select", &json!({"ids": [a.0, tall.0], "active": a.0})).unwrap();+    s.execute("develop.autoSync", &json!({"on": true})).unwrap();+    s.execute("crop.set", &json!({"rect": [0.0, 0.0, 0.6, 1.0]})).unwrap();+    let (src, r) = (crop_px(&s, a), crop_px(&s, tall));+    assert!((r.aspect() - src.aspect()).abs() < 1e-6, "the active crop's shape: {src:?} vs {r:?}");+    assert_eq!(s.develop_of(tall).unwrap().crop.geometry.angle, -3.0, "the portrait keeps its angle");+}++/// Review of #3056: a new angle on the active photo (its crop still fits, so only the angle+/// changes) turns the other photos and leaves their own crop rect and aspect lock alone.+#[test]+fn auto_sync_of_a_crop_angle_keeps_each_photos_rect_and_lock() {+    let mut s = demo();+    let (a, b, tall) = photos_by_shape(&mut s);+    s.execute("library.select", &json!({"ids": [a.0]})).unwrap();+    s.execute("crop.set", &json!({"rect": [0.35, 0.35, 0.65, 0.65]})).unwrap();+    s.execute("library.select", &json!({"ids": [b.0]})).unwrap();+    s.execute("crop.set", &json!({"rect": [0.15, 0.15, 0.5, 0.6]})).unwrap();+    s.execute("library.select", &json!({"ids": [tall.0]})).unwrap();+    s.execute("crop.aspect", &json!({"aspect": "4x5"})).unwrap();+    let before: Vec<_> = [b, tall].iter().map(|id| s.develop_of(*id).unwrap().crop).collect();+    assert_eq!(before[1].aspect, Some((400, 500)));+    s.execute("library.select", &json!({"ids": [a.0, b.0, tall.0], "active": a.0})).unwrap();+    s.execute("develop.autoSync", &json!({"on": true})).unwrap();+    let rect = s.develop_of(a).unwrap().crop.geometry.rect;+    s.execute("crop.straighten", &json!({"angle": 3.0})).unwrap();+    assert_eq!(s.develop_of(a).unwrap().crop.geometry.rect, rect, "the small crop still fits: only the angle changed");+    for (id, was) in [b, tall].iter().zip(&before) {+        let d = s.develop_of(*id).unwrap();+        assert_eq!(d.crop.geometry.angle, 3.0, "the angle is synced");+        assert_eq!((d.crop.geometry.rect, d.crop.aspect), (was.geometry.rect, was.aspect), "its own rect and lock");+    } }  /// A crop of no size (or a hair wide), as a sidecar can set it, made the render'sdiff --git a/crates/geom/src/crop.rs b/crates/geom/src/crop.rs--- a/crates/geom/src/crop.rs+++ b/crates/geom/src/crop.rs@@ -23,6 +23,18 @@ impl Default for CropGeometry {     } } +/// The shape a crop takes when [`CropGeometry::refit`] moves it to an image of another shape.+#[derive(Clone, Copy, Debug, PartialEq, Eq)]+pub enum RefitShape {+    /// No aspect lock: the uncropped image (straightened or not) becomes the whole new image; any+    /// other crop keeps its pixel aspect ratio.+    Free,+    /// An aspect ratio lock: the crop keeps its pixel aspect ratio, also when it is the whole image.+    Locked,+    /// Locked to the image's own shape ("Original"): the crop takes the new image's aspect ratio.+    Photo,+}+ impl CropGeometry {     pub fn is_identity(&self) -> bool {         self.angle == 0.0 && self.rect == Rect::UNIT@@ -125,6 +137,36 @@ impl CropGeometry {         CropGeometry { rect: Rect::from_center(f.center(), f.width() * s, f.height() * s).scale(1.0 / w, 1.0 / h), angle: angle_deg }     } +    /// This crop, made on a `from` (w, h) image, for an image of size `to` (pasting or syncing a crop,+    /// as Lightroom does): the same crop when the two have the same shape. Otherwise the crop keeps+    /// its angle, its size relative to the largest crop of its aspect, and its centre (in normalized+    /// coordinates) as far as the turned image allows (a crop that would stick out is moved in, so a+    /// straightened crop off the centre can move), and takes the shape `shape` gives it: its own pixel+    /// aspect ratio, or the new image's. Sizes that are not finite and positive, and a crop of no+    /// size, keep the crop as it is.+    pub fn refit(&self, (fw, fh): (f64, f64), (tw, th): (f64, f64), shape: RefitShape) -> CropGeometry {+        if ![fw, fh, tw, th].iter().all(|v| v.is_finite() && *v > 0.0) || ((fw / fh) - (tw / th)).abs() <= 1e-6 * (tw / th) {+            return *self;+        }+        let own = crop_fit_angle(fw, fh, self.angle, None).rect;+        let same = |a: Rect, b: Rect| [(a.x0, b.x0), (a.y0, b.y0), (a.x1, b.x1), (a.y1, b.y1)].iter().all(|(a, b)| (a - b).abs() < 1e-9);+        if shape == RefitShape::Free && same(self.rect, own) {+            return crop_fit_angle(tw, th, self.angle, None);+        }+        let r = self.rect_px(fw, fh);+        // a degenerate rect has no aspect to keep: left as it is (the render copes with it)+        let Some(aspect) = Some(r.width() / r.height()).filter(|a| a.is_finite() && *a > 0.0) else {+            return *self;+        };+        let largest = crop_fit_angle(fw, fh, self.angle, Some(aspect)).rect_px(fw, fh);+        let k = Some(r.width() / largest.width()).filter(|k| k.is_finite() && *k > 0.0).map_or(1.0, |k| k.min(1.0));+        let new_aspect = if shape == RefitShape::Photo { tw / th } else { aspect };+        let t = crop_fit_angle(tw, th, self.angle, Some(new_aspect)).rect_px(tw, th);+        let c = self.rect.center();+        let rect = Rect::from_center(Point::new(c.x * tw, c.y * th), t.width() * k, t.height() * k).scale(1.0 / tw, 1.0 / th);+        CropGeometry { rect, angle: self.angle }.constrained(tw, th)+    }+     /// The largest scale, at most 1, at which this crop shrunk about its own centre lies inside the     /// rotated image; `None` when its centre is outside it.     fn fit_scale_about_centre(&self, w: f64, h: f64) -> Option<f64> {@@ -309,4 +351,87 @@ mod tests {             }         }     }++    #[test]+    fn refit_keeps_the_same_crop_on_a_photo_of_the_same_shape() {+        let c = CropGeometry { rect: Rect::new(0.1, 0.2, 0.6, 0.9), angle: 4.0 };+        assert_eq!(c.refit((600.0, 400.0), (3000.0, 2000.0), RefitShape::Free), c);+    }++    #[test]+    fn refit_keeps_the_crop_aspect_on_a_photo_of_another_shape() {+        let px = |c: CropGeometry, w: f64, h: f64| c.rect_px(w, h);+        // a square crop left of centre in a 3:2 landscape, pasted onto a 2:3 portrait+        let sq = CropGeometry { rect: Rect::new(0.3 - 100.0 / 600.0, 0.1, 0.3 + 100.0 / 600.0, 0.6), angle: 0.0 };+        assert!((px(sq, 600.0, 400.0).aspect() - 1.0).abs() < 1e-9);+        let t = sq.refit((600.0, 400.0), (400.0, 600.0), RefitShape::Free);+        let r = px(t, 400.0, 600.0);+        assert!((r.aspect() - 1.0).abs() < 1e-9, "{r:?}");+        // half the largest square on either photo (400 px tall there, 400 px wide here)+        assert!((r.width() - 200.0).abs() < 1e-6, "{r:?}");+        let (c0, c1) = (sq.rect.center(), t.rect.center());+        assert!((c0.x - c1.x).abs() < 1e-9 && (c0.y - c1.y).abs() < 1e-9, "centred where it was: {:?}", t.rect);+        assert!(t.is_within_image(400.0, 600.0));+        // a 16:9 crop straightened by 8° onto a square photo stays 16:9 and inside the turned photo+        let wide = crop_fit_angle(600.0, 400.0, 8.0, Some(16.0 / 9.0));+        let t = wide.refit((600.0, 400.0), (500.0, 500.0), RefitShape::Free);+        assert!((px(t, 500.0, 500.0).aspect() - 16.0 / 9.0).abs() < 1e-9);+        assert_eq!(t.angle, 8.0);+        assert!(t.is_within_image(500.0, 500.0));+    }++    #[test]+    fn refit_of_an_uncropped_photo_is_the_whole_new_photo() {+        assert_eq!(CropGeometry::default().refit((600.0, 400.0), (400.0, 600.0), RefitShape::Free), CropGeometry::default());+        let straight = crop_fit_angle(600.0, 400.0, 5.0, None);+        assert_eq!(straight.refit((600.0, 400.0), (400.0, 600.0), RefitShape::Free), crop_fit_angle(400.0, 600.0, 5.0, None));+    }++    fn near(a: Rect, b: [f64; 4]) -> bool {+        [a.x0, a.y0, a.x1, a.y1].iter().zip(b).all(|(a, b)| (a - b).abs() < 1e-6)+    }++    #[test]+    fn refit_keeps_a_locked_aspect_also_on_the_whole_photo() {+        // 3:2 chosen on a 3:2 photo is the whole photo; on a 2:3 one it is a centred 3:2 band+        let t = CropGeometry::default().refit((6000.0, 4000.0), (4000.0, 6000.0), RefitShape::Locked);+        assert!(near(t.rect, [0.0, 1.0 / 3.6, 1.0, 1.0 - 1.0 / 3.6]), "{:?}", t.rect);+        assert!((t.rect_px(4000.0, 6000.0).aspect() - 1.5).abs() < 1e-9);+        // straightened as well: the largest 3:2 crop at that angle+        let straight = crop_fit_angle(600.0, 400.0, 5.0, None);+        let t = straight.refit((600.0, 400.0), (400.0, 600.0), RefitShape::Locked);+        let want = crop_fit_angle(400.0, 600.0, 5.0, Some(1.5));+        assert!(t.angle == 5.0 && near(t.rect, [want.rect.x0, want.rect.y0, want.rect.x1, want.rect.y1]), "{t:?} vs {want:?}");+    }++    #[test]+    fn refit_of_an_original_lock_takes_the_new_photos_shape() {+        assert_eq!(CropGeometry::default().refit((600.0, 400.0), (400.0, 600.0), RefitShape::Photo), CropGeometry::default());+        // half the photo, 3:2, centred left of the middle: half the portrait, 2:3, centred there too+        let c = CropGeometry { rect: Rect::from_center(Point::new(0.4, 0.5), 0.5, 0.5), angle: 0.0 };+        let t = c.refit((600.0, 400.0), (400.0, 600.0), RefitShape::Photo);+        assert!(near(t.rect, [0.15, 0.25, 0.65, 0.75]), "{:?}", t.rect);+    }++    #[test]+    fn refit_moves_a_turned_crop_off_the_centre_inside_the_new_photo() {+        // a square turned 20° left of centre: kept square and turned, but moved in to fit+        let c = CropGeometry { rect: Rect::new(0.1, 0.2, 0.5, 0.8), angle: 20.0 };+        assert!((c.rect_px(600.0, 400.0).aspect() - 1.0).abs() < 1e-9);+        let t = c.refit((600.0, 400.0), (400.0, 600.0), RefitShape::Free);+        assert_eq!(t.angle, 20.0);+        assert!((t.rect_px(400.0, 600.0).aspect() - 1.0).abs() < 1e-9, "{:?}", t.rect);+        assert!(t.is_within_image(400.0, 600.0), "{:?}", t.rect);+        assert!(near(t.rect, [0.08125, 0.3, 0.68125, 0.7]), "{:?}", t.rect);+    }++    #[test]+    fn refit_survives_degenerate_crops_and_sizes() {+        let flat = CropGeometry { rect: Rect::new(0.0, 0.5, 1.0, 0.5), angle: 0.0 };+        assert_eq!(flat.refit((600.0, 400.0), (400.0, 600.0), RefitShape::Free), flat);+        let c = CropGeometry { rect: Rect::new(0.1, 0.1, 0.5, 0.5), angle: 0.0 };+        for (from, to) in [((0.0, 400.0), (400.0, 600.0)), ((600.0, 400.0), (f64::NAN, 600.0)), ((600.0, 400.0), (400.0, f64::INFINITY))] {+            assert_eq!(c.refit(from, to, RefitShape::Free), c);+        }+    } }diff --git a/crates/geom/src/lib.rs b/crates/geom/src/lib.rs--- a/crates/geom/src/lib.rs+++ b/crates/geom/src/lib.rs@@ -10,7 +10,7 @@ mod crop_drag; mod homography; mod real; -pub use crop::{CropGeometry, crop_fit_angle, max_inscribed_scale};+pub use crop::{CropGeometry, RefitShape, crop_fit_angle, max_inscribed_scale}; pub use crop_drag::{CropHandle, MAX_RATIO, MIN_CROP_FRACTION, drag_crop}; pub use homography::Homography; pub use real::{Interval, Real};diff --git a/crates/ui-egui/locales/cs-formats.json b/crates/ui-egui/locales/cs-formats.json--- a/crates/ui-egui/locales/cs-formats.json+++ b/crates/ui-egui/locales/cs-formats.json@@ -110,7 +110,7 @@   "New Folder in “{name}”": "Nová složka v „{name}“",   "On {have} of {of} selected photos": "Na {have} z {of} vybraných fotografií",   "Painting “{k}”: click photos to add or remove it · Esc stops": "Nanášení „{k}“: kliknutím na fotografie přidáte nebo odeberete slovo · Esc ukončí režim",-  "Paste into {n} photo{} — only settings that were copied are pasted": "Vložit do fotografií: {n}{:.0} — vloží se pouze zkopírovaná nastavení",+  "Paste into {n} photo{}": "Vložit do fotografií: {n}{:.0}",   "Preset: {name}": "Přednastavení: {name}",   "Preview only — editing the camera's embedded JPEG ({})": "Pouze náhled — úpravy vloženého JPEG z fotoaparátu ({})",   "Preview only — {}": "Pouze náhled — {}",diff --git a/crates/ui-egui/locales/de-formats.json b/crates/ui-egui/locales/de-formats.json--- a/crates/ui-egui/locales/de-formats.json+++ b/crates/ui-egui/locales/de-formats.json@@ -90,7 +90,7 @@   "Move {what} to Recently Deleted?": "{what} nach „Zuletzt gelöscht“ verschieben?",   "Named ({named_n})": "Benannt ({named_n})",   "Painting “{k}”: click photos to add or remove it · Esc stops": "„{k}“ auftragen: Fotos anklicken, um es hinzuzufügen oder zu entfernen · Esc beendet",-  "Paste into {n} photo{} — only settings that were copied are pasted": "In {n} Foto{} einfügen – nur kopierte Einstellungen werden eingefügt",+  "Paste into {n} photo{}": "In {n} Foto{} einfügen",   "Preset: {name}": "Vorgabe: {name}",   "Preview only — editing the camera's embedded JPEG ({})": "Nur Vorschau – das eingebettete Kamera-JPEG wird bearbeitet ({})",   "Preview only — {}": "Nur Vorschau – {}",diff --git a/crates/ui-egui/locales/es-formats.json b/crates/ui-egui/locales/es-formats.json--- a/crates/ui-egui/locales/es-formats.json+++ b/crates/ui-egui/locales/es-formats.json@@ -90,7 +90,7 @@   "Move {what} to Recently Deleted?": "¿Mover {what} a Eliminadas recientemente?",   "Named ({named_n})": "Con nombre ({named_n})",   "Painting “{k}”: click photos to add or remove it · Esc stops": "Pintando “{k}”: hacer clic en las fotos para añadirla o quitarla · Esc para terminar",-  "Paste into {n} photo{} — only settings that were copied are pasted": "Pegar en {n} foto{}: solo se pegan los ajustes copiados",+  "Paste into {n} photo{}": "Pegar en {n} foto{}",   "Preset: {name}": "Ajuste preestablecido: {name}",   "Preview only — editing the camera's embedded JPEG ({})": "Solo vista previa: editando el JPEG incrustado de la cámara ({})",   "Preview only — {}": "Solo vista previa: {}",diff --git a/crates/ui-egui/locales/fi-formats.json b/crates/ui-egui/locales/fi-formats.json--- a/crates/ui-egui/locales/fi-formats.json+++ b/crates/ui-egui/locales/fi-formats.json@@ -136,7 +136,7 @@   "New Folder in “{name}”": "Uusi kansio kohteessa ”{name}”",   "On {have} of {of} selected photos": "{have} / {of} valitulla kuvalla",   "Painting “{k}”: click photos to add or remove it · Esc stops": "Maalataan ”{k}”: napsauta kuvia lisätäksesi tai poistaaksesi sen · Esc lopettaa",-  "Paste into {n} photo{} — only settings that were copied are pasted": "Liitä kuviin: {n}{:.0} — vain kopioidut asetukset liitetään",+  "Paste into {n} photo{}": "Liitä kuviin: {n}{:.0}",   "Preset: {name}": "Esiasetus: {name}",   "Preview only — editing the camera's embedded JPEG ({})": "Vain esikatselu — muokataan kameran upotettua JPEG-kuvaa ({})",   "Preview only — {}": "Vain esikatselu — {}",diff --git a/crates/ui-egui/locales/fr-formats.json b/crates/ui-egui/locales/fr-formats.json--- a/crates/ui-egui/locales/fr-formats.json+++ b/crates/ui-egui/locales/fr-formats.json@@ -90,7 +90,7 @@   "Move {what} to Recently Deleted?": "Déplacer {what} vers « Supprimés récemment » ?",   "Named ({named_n})": "Nommés ({named_n})",   "Painting “{k}”: click photos to add or remove it · Esc stops": "Application de « {k} » : cliquez sur les photos pour l’ajouter ou le retirer · Échap pour arrêter",-  "Paste into {n} photo{} — only settings that were copied are pasted": "Coller dans {n} photo{} — seuls les réglages copiés seront collés",+  "Paste into {n} photo{}": "Coller dans {n} photo{}",   "Preset: {name}": "Préréglage : {name}",   "Preview only — editing the camera's embedded JPEG ({})": "Aperçu uniquement — modification du JPEG intégré de l’appareil photo ({})",   "Preview only — {}": "Aperçu uniquement — {}",diff --git a/crates/ui-egui/locales/id-formats.json b/crates/ui-egui/locales/id-formats.json--- a/crates/ui-egui/locales/id-formats.json+++ b/crates/ui-egui/locales/id-formats.json@@ -46,7 +46,7 @@   "Move {what} to Recently Deleted?": "Pindahkan {what} ke Baru dihapus?",   "Moved {n} photo{} to “{}”. Edit → Undo moves them back.": "{n} foto dipindahkan ke “{1}”{0:.0}. Pilih Edit → Urungkan untuk mengembalikannya.",   "Named ({named_n})": "Bernama ({named_n})",-  "Paste into {n} photo{} — only settings that were copied are pasted": "Tempel ke {n} foto{:.0} — hanya menempelkan pengaturan yang disalin",+  "Paste into {n} photo{}": "Tempel ke {n} foto{:.0}",   "Preset: {name}": "Preset: {name}",   "Preview only — editing the camera's embedded JPEG ({})": "Hanya pratinjau — yang diedit adalah JPEG bawaan kamera ({})",   "Preview only — {}": "Hanya pratinjau — {}",diff --git a/crates/ui-egui/locales/it-formats.json b/crates/ui-egui/locales/it-formats.json--- a/crates/ui-egui/locales/it-formats.json+++ b/crates/ui-egui/locales/it-formats.json@@ -88,7 +88,7 @@   "Move {what} to Recently Deleted?": "Spostare {what} in Eliminate di recente?",   "Named ({named_n})": "Con nome ({named_n})",   "Painting “{k}”: click photos to add or remove it · Esc stops": "Pennello “{k}”: fai clic sulle foto per aggiungerla o toglierla · Esc interrompe",-  "Paste into {n} photo{} — only settings that were copied are pasted": "Incolla in {n} foto{:.0}: vengono incollate solo le impostazioni copiate",+  "Paste into {n} photo{}": "Incolla in {n} foto{:.0}",   "Preset: {name}": "Preset: {name}",   "Preview only — editing the camera's embedded JPEG ({})": "Solo anteprima: si modifica il JPEG incorporato dalla fotocamera ({})",   "Preview only — {}": "Solo anteprima: {}",diff --git a/crates/ui-egui/locales/ja-formats.json b/crates/ui-egui/locales/ja-formats.json--- a/crates/ui-egui/locales/ja-formats.json+++ b/crates/ui-egui/locales/ja-formats.json@@ -46,7 +46,7 @@   "Move {what} to Recently Deleted?": "{what}を「最近削除した写真」に移動しますか?",   "Moved {n} photo{} to “{}”. Edit → Undo moves them back.": "{n}枚を「{1}」に移動しました。編集 → 取り消す で元に戻せます。{0:.0}",   "Named ({named_n})": "名前付き({named_n})",-  "Paste into {n} photo{} — only settings that were copied are pasted": "{n}枚に貼り付け{:.0}(コピーされた設定だけを適用)",+  "Paste into {n} photo{}": "{n}枚に貼り付け{:.0}",   "Preset: {name}": "プリセット:{name}",   "Preview only — editing the camera's embedded JPEG ({})": "プレビューのみ:カメラ内蔵のJPEGを編集中({})",   "Preview only — {}": "プレビューのみ:{}",diff --git a/crates/ui-egui/locales/ka-formats.json b/crates/ui-egui/locales/ka-formats.json--- a/crates/ui-egui/locales/ka-formats.json+++ b/crates/ui-egui/locales/ka-formats.json@@ -110,7 +110,7 @@   "New Folder in “{name}”": "ახალი საქაღალდე „{name}“-ში",   "On {have} of {of} selected photos": "{of} მონიშნული ფოტოდან {have}-ზე",   "Painting “{k}”: click photos to add or remove it · Esc stops": "„{k}“-ის მინიჭება: დაწკაპეთ ფოტოებზე მის დასამატებლად ან მოსახსნელად · Esc ასრულებს",-  "Paste into {n} photo{} — only settings that were copied are pasted": "ჩასმა ფოტოებში: {n}{:.0} — ჩაისმება მხოლოდ კოპირებული პარამეტრები",+  "Paste into {n} photo{}": "ჩასმა ფოტოებში: {n}{:.0}",   "Preset: {name}": "პრესეტი: {name}",   "Preview only — editing the camera's embedded JPEG ({})": "მხოლოდ წინასწარი ხედი — რედაქტირდება კამერის ჩაშენებული JPEG ({})",   "Preview only — {}": "მხოლოდ წინასწარი ხედი — {}",diff --git a/crates/ui-egui/locales/pl-formats.json b/crates/ui-egui/locales/pl-formats.json--- a/crates/ui-egui/locales/pl-formats.json+++ b/crates/ui-egui/locales/pl-formats.json@@ -81,7 +81,7 @@   "Move {what} to Recently Deleted?": "Przenieść {what} do Ostatnio usuniętych?",   "Named ({named_n})": "Nazwane ({named_n})",   "Painting “{k}”: click photos to add or remove it · Esc stops": "Przypisywanie „{k}”: klikaj zdjęcia, aby dodać lub usunąć słowo kluczowe · Esc kończy",-  "Paste into {n} photo{} — only settings that were copied are pasted": "Wklej do zdjęć ({n}){:.0} — wklejone zostaną tylko skopiowane ustawienia",+  "Paste into {n} photo{}": "Wklej do zdjęć ({n}){:.0}",   "Preset: {name}": "Ustawienie wstępne: {name}",   "Preview only — editing the camera's embedded JPEG ({})": "Tylko podgląd — edycja pliku JPEG osadzonego przez aparat ({})",   "Preview only — {}": "Tylko podgląd — {}",diff --git a/crates/ui-egui/locales/pt-br-formats.json b/crates/ui-egui/locales/pt-br-formats.json--- a/crates/ui-egui/locales/pt-br-formats.json+++ b/crates/ui-egui/locales/pt-br-formats.json@@ -63,7 +63,7 @@   "Move {what} to Recently Deleted?": "Mover {what} para Excluídos recentemente?",   "Moved {n} photo{} to “{}”. Edit → Undo moves them back.": "{n} foto{0} movida{0} para “{1}”. Editar → Desfazer as traz de volta.",   "Named ({named_n})": "Nomeadas ({named_n})",-  "Paste into {n} photo{} — only settings that were copied are pasted": "Colar em {n} foto{} — somente os ajustes copiados são colados",+  "Paste into {n} photo{}": "Colar em {n} foto{}",   "Preset: {name}": "Predefinição: {name}",   "Preview only — editing the camera's embedded JPEG ({})": "Somente pré-visualização — editando o JPEG incorporado da câmera ({})",   "Preview only — {}": "Somente pré-visualização — {}",diff --git a/crates/ui-egui/locales/ru-formats.json b/crates/ui-egui/locales/ru-formats.json--- a/crates/ui-egui/locales/ru-formats.json+++ b/crates/ui-egui/locales/ru-formats.json@@ -95,7 +95,7 @@   "Moved; {} photo(s) relinked": "Перемещено; перепривязано фото: {}",   "Named ({named_n})": "Именованные ({named_n})",   "Painting “{k}”: click photos to add or remove it · Esc stops": "Назначение «{k}»: щёлкайте по фото, чтобы добавить или снять его · Esc — завершить",-  "Paste into {n} photo{} — only settings that were copied are pasted": "Вставка в фото: {n}{:.0} — вставляются только скопированные настройки",+  "Paste into {n} photo{}": "Вставка в фото: {n}{:.0}",   "Preset: {name}": "Пресет: {name}",   "Preview only — editing the camera's embedded JPEG ({})": "Только просмотр — правка встроенного в камеру JPEG ({})",   "Preview only — {}": "Только просмотр — {}",diff --git a/crates/ui-egui/locales/sv-formats.json b/crates/ui-egui/locales/sv-formats.json--- a/crates/ui-egui/locales/sv-formats.json+++ b/crates/ui-egui/locales/sv-formats.json@@ -132,7 +132,7 @@   "not added to Apple Photos: {e}": "inte tillagt i Apple Photos: {e}",   "On {have} of {of} selected photos": "På {have} av {of} markerade foton",   "Painting “{k}”: click photos to add or remove it · Esc stops": "Målar ”{k}”: klicka på foton för att lägga till eller ta bort det · Esc avslutar",-  "Paste into {n} photo{} — only settings that were copied are pasted": "Klistra in i {n} foto{} – endast inställningar som kopierades klistras in",+  "Paste into {n} photo{}": "Klistra in i {n} foto{}",   "Preset: {name}": "Förinställning: {name}",   "Preview only — editing the camera's embedded JPEG ({})": "Endast förhandsgranskning – du redigerar kamerans inbäddade JPEG ({})",   "Preview only — {}": "Endast förhandsgranskning – {}",diff --git a/crates/ui-egui/locales/uk-formats.json b/crates/ui-egui/locales/uk-formats.json--- a/crates/ui-egui/locales/uk-formats.json+++ b/crates/ui-egui/locales/uk-formats.json@@ -90,7 +90,7 @@   "Move {what} to Recently Deleted?": "Перемістити {what} до нещодавно видалених?",   "Named ({named_n})": "З іменами ({named_n})",   "Painting “{k}”: click photos to add or remove it · Esc stops": "Призначення «{k}»: клацайте фото, щоб додати або вилучити його · Esc завершує",-  "Paste into {n} photo{} — only settings that were copied are pasted": "Вставка у фото: {n}{:.0} — вставляються лише скопійовані налаштування",+  "Paste into {n} photo{}": "Вставка у фото: {n}{:.0}",   "Preset: {name}": "Набір налаштувань: {name}",   "Preview only — editing the camera's embedded JPEG ({})": "Лише перегляд — редагування вбудованого JPEG камери ({})",   "Preview only — {}": "Лише перегляд — {}",diff --git a/crates/ui-egui/locales/vi-formats.json b/crates/ui-egui/locales/vi-formats.json--- a/crates/ui-egui/locales/vi-formats.json+++ b/crates/ui-egui/locales/vi-formats.json@@ -136,7 +136,7 @@   "New Folder in “{name}”": "Thư mục mới trong «{name}»",   "On {have} of {of} selected photos": "Trên {have} / {of} ảnh đã chọn",   "Painting “{k}”: click photos to add or remove it · Esc stops": "Gán «{k}»: nhấp ảnh để thêm hoặc gỡ · Esc dừng",-  "Paste into {n} photo{} — only settings that were copied are pasted": "Dán vào {n} ảnh{:.0} — chỉ dán các thiết lập đã sao chép",+  "Paste into {n} photo{}": "Dán vào {n} ảnh{:.0}",   "Preset: {name}": "Thiết lập sẵn: {name}",   "Preview only — editing the camera's embedded JPEG ({})": "Chỉ xem trước — chỉnh sửa JPEG nhúng của máy ảnh ({})",   "Preview only — {}": "Chỉ xem trước — {}",diff --git a/crates/ui-egui/locales/zh-hans-formats.json b/crates/ui-egui/locales/zh-hans-formats.json--- a/crates/ui-egui/locales/zh-hans-formats.json+++ b/crates/ui-egui/locales/zh-hans-formats.json@@ -46,7 +46,7 @@   "Move {what} to Recently Deleted?": "将 {what} 移到最近删除?",   "Moved {n} photo{} to “{}”. Edit → Undo moves them back.": "已将 {n} 张照片移到“{1}”{0:.0}。编辑 → 撤销 可移回。",   "Named ({named_n})": "已命名({named_n})",-  "Paste into {n} photo{} — only settings that were copied are pasted": "粘贴到 {n} 张照片 — 仅粘贴已复制的设置{0:.0}",+  "Paste into {n} photo{}": "粘贴到 {n} 张照片{0:.0}",   "Preset: {name}": "预设:{name}",   "Preview only — editing the camera's embedded JPEG ({})": "仅预览 — 正在编辑相机内嵌的 JPEG({0})",   "Preview only — {}": "仅预览 — {0}",diff --git a/crates/ui-egui/locales/zh-hant-formats.json b/crates/ui-egui/locales/zh-hant-formats.json--- a/crates/ui-egui/locales/zh-hant-formats.json+++ b/crates/ui-egui/locales/zh-hant-formats.json@@ -60,7 +60,7 @@   "Move {what} to Recently Deleted?": "要將{what}移至「最近刪除」嗎?",   "Moved {n} photo{} to “{}”. Edit → Undo moves them back.": "已將 {n} 張照片移到「{1}」{0:.0}。編輯 → 復原 可移回。",   "Named ({named_n})": "已命名({named_n})",-  "Paste into {n} photo{} — only settings that were copied are pasted": "貼上至 {n} 張照片{:.0} — 只貼上已複製的設定",+  "Paste into {n} photo{}": "貼上至 {n} 張照片{:.0}",   "Preset: {name}": "預設集:{name}",   "Preview only — editing the camera's embedded JPEG ({})": "僅供預覽 — 正在編輯相機內嵌的 JPEG({})",   "Preview only — {}": "僅供預覽 — {}",diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs--- a/crates/ui-egui/src/dialogs.rs+++ b/crates/ui-egui/src/dialogs.rs@@ -648,10 +648,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context, blocked: bool) {                 Dialog::CopySettings { groups } => group_checklist(ui, "copyGroup", groups),                 Dialog::PasteSettings { groups } => {                     let n = app.session.selected().len();-                    ui.label(-                        egui::RichText::new(crate::i18n::tr_format!("Paste into {n} photo{} — only settings that were copied are pasted", if n == 1 { "" } else { "s" }, n = n))-                            .color(t.text_dim),-                    );+                    ui.label(egui::RichText::new(crate::i18n::tr_format!("Paste into {n} photo{}", if n == 1 { "" } else { "s" }, n = n)).color(t.text_dim));                     group_checklist(ui, "pasteGroup", groups);                 }                 Dialog::SlideshowExport { settings } => slideshow_export_body(app, ui, settings),diff --git a/crates/ui-egui/src/state/dialog.rs b/crates/ui-egui/src/state/dialog.rs--- a/crates/ui-egui/src/state/dialog.rs+++ b/crates/ui-egui/src/state/dialog.rs@@ -214,7 +214,7 @@ pub enum Dialog {     CopySettings {         groups: Vec<String>,     },-    /// Paste Selected Settings: which of the copied groups to paste.+    /// Paste Selected Settings: which groups of the copied photo to paste.     PasteSettings {         groups: Vec<String>,     },diff --git a/crates/ui-egui/src/tests/copy_paste_dialogs.rs b/crates/ui-egui/src/tests/copy_paste_dialogs.rs--- a/crates/ui-egui/src/tests/copy_paste_dialogs.rs+++ b/crates/ui-egui/src/tests/copy_paste_dialogs.rs@@ -89,3 +89,20 @@ fn escape_closes_paste_settings_without_pasting() {     assert!(h.app.ui.dialog.is_none(), "Esc closes the dialog");     assert_eq!(exposure(&h, ids[1]), 0.0, "Esc pastes nothing"); }++/// Issue #3056: ticking Crop in Paste Selected Settings pastes the crop, which the copy (light and+/// colour) left out; it used to be dropped silently.+#[test]+fn ticking_crop_pastes_the_crop_the_copy_left_out() {+    let (mut h, ids) = edited();+    exec(&mut h, "crop.set", json!({"rect": [0.1, 0.2, 0.7, 0.8]}));+    paste_dialog(&mut h, ids);+    let r = h.request("ui.clickWidget", json!({"id": "pasteGroup:crop"}), T);+    assert_eq!(r["ok"], true, "{r}");+    h.settle(SETTLE);+    key(&mut h, json!({"key": "Enter"}));+    assert!(h.app.ui.dialog.is_none(), "Return closes the dialog");+    let crop = h.app.session.develop_of(PhotoId(ids[1])).map(|d| d.crop.geometry).unwrap_or_default();+    assert!(!crop.is_identity(), "the crop is pasted: {crop:?}");+    assert_eq!(exposure(&h, ids[1]), 1.0, "with the copied settings");+}diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md--- a/docs/parity-checklist.md+++ b/docs/parity-checklist.md@@ -1,6 +1,6 @@ # Lightroom parity checklist -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-25 · **Change:** minor (LR-BEHAV-TITLEBAR: a double-click follows the system setting, and a panel over the bar keeps the window still; KEYC-METADATA, LRC-LIB-KEYWORDS: ⌥0 / ⌥⇧0 go to the next / previous keyword set; LRC-DEV-HISTOGRAM-DRAG: the histogram's five tonal zones — hover for the value, drag to adjust the Basic control the press locked, double-click to reset; LR-VIEW-HISTOGRAM note updated; MENU-BAR: on macOS a menu item that yields its key to a focused text field or to shortcut recording really lets it go; LR-MERGE-HDR / LR-MERGE-PANO: Lanczos-3 warps; LR-IMP-LOCAL: browsing from the sidebar records the folder; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LR-IMP-ADD-DIALOG: hierarchical import album destination reconciled with main, #2362; KEY-SLIDER-NUDGE: vertical wheel nudges need a click or drag in the row; leaving it disarms them; LR-EDIT-OPTICS-PROFILE: Fujifilm nine-knot lens tables read at the plain radius; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LRC-LIB-LIBFOLDERS, LR-BEHAV-BG-TASKS: Synchronize Folder's scan shows its folder listing and stops it on ✕; LRC-LIB-LIBFOLDERS: Synchronize Folder says a folder is up to date; LR-LIB-STACK: filmstrip stack badge click; LRC-LIB-REFVIEW / CATALOG-SWITCH: Escape preserves edits; catalog-local pins; deleted and malformed references refused; LRC-LIB-COMPARE: the Info panel labels the active photo while culling, #2398; KEYC-RATING: `\` shows / hides the Filter Bar in the grids; LR-VIEW-PHOTOGRID: the grid reads only the photos an import batch or an edit touched, not the whole list; earlier: LRC-PRINT-PAGE: Print Page Options; LR-META-XMP: fewer false unimported-XMP warnings; KEYC-METADATA: ⌘K / Ctrl+K Add Keywords; LRC-DEV-HISTOGRAM-DRAG: the histogram's five tonal zones — hover for the value, drag to adjust the Basic control the press locked, double-click to reset; LR-VIEW-HISTOGRAM note updated; MENU-BAR: on macOS a menu item that yields its key to a focused text field or to shortcut recording really lets it go; LR-MERGE-HDR / LR-MERGE-PANO: Lanczos-3 warps; LR-IMP-LOCAL: browsing from the sidebar records the folder; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LR-IMP-ADD-DIALOG: hierarchical import album destination reconciled with main, #2362; KEY-SLIDER-NUDGE: vertical wheel nudges need a click or drag in the row; leaving it disarms them; LR-EDIT-OPTICS-PROFILE: Fujifilm nine-knot lens tables read at the plain radius; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LRC-LIB-LIBFOLDERS, LR-BEHAV-BG-TASKS: Synchronize Folder's scan shows its folder listing and stops it on ✕; LRC-LIB-LIBFOLDERS: Synchronize Folder says a folder is up to date; LR-LIB-STACK: filmstrip stack badge click; LRC-LIB-REFVIEW / CATALOG-SWITCH: Escape preserves edits; catalog-local pins; deleted and malformed references refused; LRC-LIB-COMPARE: the Info panel labels the active photo while culling, #2398; KEYC-RATING: `\` shows / hides the Filter Bar in the grids; LR-VIEW-PHOTOGRID: the grid reads only the photos an import batch or an edit touched, not the whole list; earlier: LRC-PRINT-PAGE: Print Page Options; LR-META-XMP: fewer false unimported-XMP warnings) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6+> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-27 · **Change:** minor (MENU-EDIT-PASTESELECTED, LR-SYNC-PASTE: Paste Selected Settings pastes crop and Remove left out of the copy, and a pasted crop keeps its aspect on a photo of another shape, #3056; LR-BEHAV-TITLEBAR: a double-click follows the system setting, and a panel over the bar keeps the window still; KEYC-METADATA, LRC-LIB-KEYWORDS: ⌥0 / ⌥⇧0 go to the next / previous keyword set; LRC-DEV-HISTOGRAM-DRAG: the histogram's five tonal zones — hover for the value, drag to adjust the Basic control the press locked, double-click to reset; LR-VIEW-HISTOGRAM note updated; MENU-BAR: on macOS a menu item that yields its key to a focused text field or to shortcut recording really lets it go; LR-MERGE-HDR / LR-MERGE-PANO: Lanczos-3 warps; LR-IMP-LOCAL: browsing from the sidebar records the folder; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LR-IMP-ADD-DIALOG: hierarchical import album destination reconciled with main, #2362; KEY-SLIDER-NUDGE: vertical wheel nudges need a click or drag in the row; leaving it disarms them; LR-EDIT-OPTICS-PROFILE: Fujifilm nine-knot lens tables read at the plain radius; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LRC-LIB-LIBFOLDERS, LR-BEHAV-BG-TASKS: Synchronize Folder's scan shows its folder listing and stops it on ✕; LRC-LIB-LIBFOLDERS: Synchronize Folder says a folder is up to date; LR-LIB-STACK: filmstrip stack badge click; LRC-LIB-REFVIEW / CATALOG-SWITCH: Escape preserves edits; catalog-local pins; deleted and malformed references refused; LRC-LIB-COMPARE: the Info panel labels the active photo while culling, #2398; KEYC-RATING: `\` shows / hides the Filter Bar in the grids; LR-VIEW-PHOTOGRID: the grid reads only the photos an import batch or an edit touched, not the whole list; earlier: LRC-PRINT-PAGE: Print Page Options; LR-META-XMP: fewer false unimported-XMP warnings; KEYC-METADATA: ⌘K / Ctrl+K Add Keywords; LRC-DEV-HISTOGRAM-DRAG: the histogram's five tonal zones — hover for the value, drag to adjust the Basic control the press locked, double-click to reset; LR-VIEW-HISTOGRAM note updated; MENU-BAR: on macOS a menu item that yields its key to a focused text field or to shortcut recording really lets it go; LR-MERGE-HDR / LR-MERGE-PANO: Lanczos-3 warps; LR-IMP-LOCAL: browsing from the sidebar records the folder; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LR-IMP-ADD-DIALOG: hierarchical import album destination reconciled with main, #2362; KEY-SLIDER-NUDGE: vertical wheel nudges need a click or drag in the row; leaving it disarms them; LR-EDIT-OPTICS-PROFILE: Fujifilm nine-knot lens tables read at the plain radius; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LRC-LIB-LIBFOLDERS, LR-BEHAV-BG-TASKS: Synchronize Folder's scan shows its folder listing and stops it on ✕; LRC-LIB-LIBFOLDERS: Synchronize Folder says a folder is up to date; LR-LIB-STACK: filmstrip stack badge click; LRC-LIB-REFVIEW / CATALOG-SWITCH: Escape preserves edits; catalog-local pins; deleted and malformed references refused; LRC-LIB-COMPARE: the Info panel labels the active photo while culling, #2398; KEYC-RATING: `\` shows / hides the Filter Bar in the grids; LR-VIEW-PHOTOGRID: the grid reads only the photos an import batch or an edit touched, not the whole list; earlier: LRC-PRINT-PAGE: Print Page Options; LR-META-XMP: fewer false unimported-XMP warnings) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6  The measured, row-by-row checklist: one row per Lightroom feature, menu item and shortcut, what we have and what is missing. It answers *does the feature exist?* (feature breadth). Whether it is good@@ -276,7 +276,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-REM-REFLECT | Remove reflections | P2 | ⬜ | | | | LR-REM-DUST | Dust detection | P2 | ✅ | `cmd:spot.findDust`, `crates/pipeline/src/dust.rs` | Remove panel ▸ Find Dust Spots: small, soft, round dark spots on smooth areas become heal spots (sensitivity; one undo step); Visualize Spots for checking by eye | | LR-REM-BLEMISH | Find blemishes on faces (beyond Lightroom) | P2 | ✅ | `cmd:spot.findBlemishes`, `crates/pipeline/src/blemish.rs`, `crates/engine/src/cmd/face_detect.rs` | Remove panel ▸ Find Blemishes: with the YuNet detector installed, small darker-or-redder round spots on each face's skin (eyes, brows, nostrils and mouth cut out around the landmarks) become heal spots (one undo step); faces under 160 px and faces with more than 12 candidates (stubble, freckles) get none, at most 6 per face; sensitivity 0–100. Classical, no learned model beyond the detector; not a Lightroom feature |-| LR-REM-SYNC | Sync spots | P1 | ✅ | `cmd:develop.copy` (`groups`), `crates/develop/src/presets.rs` | |+| LR-REM-SYNC | Sync spots | P1 | ✅ | `cmd:develop.copy` (`groups`), `cmd:develop.paste` (`groups`), `crates/develop/src/presets.rs` | left out of Copy by default, as in Lightroom; ticking Remove in Copy or in Paste Selected Settings pastes the spots (#3056) |  ## J. Red eye (EYE) @@ -312,7 +312,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-MASK-OVERLAYALL | Show all mask overlays | P1 | ✅ | `cmd:view.maskOverlayAll`, `crates/ui-egui/src/panels/masking.rs`, `crates/pipeline/src/visualize.rs` | one diagnostic tint over every visible mask at once (CPU + GPU): the masks still stack their adjustments as before — this only paints each mask's region together, so you can see all masked areas at a glance (⌘⇧O / Ctrl+Shift+O) | | LR-MASK-PINS | Pins | P1 | ✅ | `cmd:view.maskPins`, `cmd:view.toolOverlay`, `crates/ui-egui/src/panels/detail.rs`, `crates/ui-egui/src/tests/tool_overlay.rs` | a pin per radial/linear/brush component: click selects its mask, drag moves it; Show Pins on follows the Tool Overlay mode (Auto: while the pointer is over the photo, hidden while brushing; Always; Selected: the selected mask's; Never; ⇧H cycles, #224); no pins for range/AI components | | LR-MASK-UPDATE | Recompute AI masks | P2 | ⬜ | | |-| LR-MASK-SYNC | Copy masks to other photos | P1 | ✅ | `cmd:develop.copy` (`groups`) | |+| LR-MASK-SYNC | Copy masks to other photos | P1 | ✅ | `cmd:develop.copy` (`groups`), `cmd:develop.paste` (`groups`) | left out of Copy by default, as in Lightroom; ticking Masking in Copy or in Paste Selected Settings pastes them | | LR-MASK-ADAPTIVE | Adaptive presets | P2 | ⬜ | | |  ## L. Presets (PRE)@@ -344,7 +344,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). |---|---|---|---|---|---| | LR-SYNC-COPY | Copy edit settings | P0 | ✅ | `cmd:develop.copy` | | | LR-SYNC-CHOOSE | Choose settings to copy | P0 | ✅ | `cmd:dialog.copySettings` | groups are coarser than per-slider |-| LR-SYNC-PASTE | Paste to selection | P0 | ✅ | `cmd:develop.paste` | no separate "paste selected" (choose at copy time instead) |+| LR-SYNC-PASTE | Paste to selection | P0 | ✅ | `cmd:develop.paste`, `crates/geom/src/crop.rs` (`CropGeometry::refit`) | pastes the groups chosen at copy time onto every selected photo, one undo step; Paste Selected Settings (MENU-EDIT-PASTESELECTED) chooses at paste time. A crop pasted, synced or Auto Synced onto a photo of another shape keeps its aspect ratio, angle and relative size, as in Lightroom, centred where it was unless that would leave the turned photo; a locked ratio is kept even on an uncropped photo, an Original lock or an unlocked uncropped photo takes the new photo's shape (`Crop::refit`); Auto Sync carries only the crop fields the edit changed (a new rect keeps each photo's angle, a new angle each photo's rect); presets apply their crop as stored | | LR-SYNC-SYNCBTN | Sync active → selected | P1 | ✅ | `cmd:develop.sync` | | | LR-SYNC-PREVIOUS | Paste from previous / auto sync [Classic] | P2 | ✅ | `cmd:develop.pastePrevious`, `cmd:develop.autoSync` | ⌥⌘V pastes the previously active photo's settings (copy groups); Auto Sync (⌥⇧⌘A) | @@ -522,7 +522,7 @@ visible menu bar yet: the menu model is only exposed through the control channel | MENU-EDIT-REDO | Redo | P0 | ✅ | `cmd:edit.redo` | | | MENU-EDIT-COPYPASTE | Copy / paste (edit settings) | P0 | ✅ | `cmd:develop.copy`, `cmd:develop.paste` | | | MENU-EDIT-CHOOSECOPY | Choose Edit Settings to Copy… | P0 | ✅ | `cmd:dialog.copySettings` | Return copies, Esc cancels |-| MENU-EDIT-PASTESELECTED | Paste Selected Settings | P0 | ✅ | `cmd:dialog.pasteSettings`, `cmd:develop.paste` (`groups`) | checklist prefilled with the copied groups; only copied groups can be pasted; Return pastes, Esc cancels |+| MENU-EDIT-PASTESELECTED | Paste Selected Settings | P0 | ✅ | `cmd:dialog.pasteSettings`, `cmd:develop.paste` (`groups`) | checklist prefilled with the copied groups; any group of the copied photo can be pasted, also one the copy left out (Crop, Remove, Masking, Red Eye: the clipboard keeps the photo's whole settings, #3056); Return pastes, Esc cancels | | MENU-EDIT-SELECTALL | Select All | P0 | ✅ | `cmd:library.selectAll` | | | MENU-EDIT-SELECTNONE | Select None | P0 | ✅ | `cmd:library.selectNone` | | | MENU-EDIT-SELECTBY | Select by flag / rating | P1 | ✅ | `cmd:library.selectBy` | Edit → Select by: picks, rejects, unflagged, ★…★★★★★ and higher, unrated, colour labels; `add` extends the selection |@@ -799,6 +799,7 @@ Latin stays upright. Advanced Japanese composition remains open. Regression cove  | Date | Change | Summary | |---|---|---|+| 2026-10-27 | minor | MENU-EDIT-PASTESELECTED, LR-SYNC-PASTE, LR-REM-SYNC, LR-MASK-SYNC: the settings clipboard keeps the copied photo's whole settings, so Paste Selected Settings pastes any ticked group, also Crop and Remove left out of the copy (issue #3056); a crop pasted, pasted from previous, synced or Auto Synced onto a photo of another shape keeps its aspect ratio and aspect lock (`CropGeometry::refit`, `Crop::refit`), Auto Sync refitting only the crop fields the edit changed; LR-SYNC-PASTE no longer says there is no Paste Selected; status counts unchanged | | 2026-10-25 | patch | LR-BEHAV-TITLEBAR: the top bar's title-bar gestures come from craft-window's `title_bar`: a double-click follows the system setting, and a press on a button, the search field or a panel floating over the bar never moves the window; status counts unchanged | | 2026-10-24 | minor | KEYC-METADATA, LRC-LIB-KEYWORDS: ⌥0 / ⌥⇧0 (`keyword.nextSet`, `keyword.previousSet`) go to the next / previous keyword set; status counts unchanged (KEYC-METADATA stays 🟡) | | 2026-10-24 | minor | KEYC-METADATA: ⌘K / Ctrl+K (`panel.addKeywords`, Add Keywords) shows the Keywords panel, opens Keywording and puts the cursor in the Add keyword box; status counts unchanged (KEYC-METADATA stays 🟡) |diff --git a/docs/whats-new.md b/docs/whats-new.md--- a/docs/whats-new.md+++ b/docs/whats-new.md@@ -1,5 +1,18 @@ # What's new in LightCraft +## 2026-10-27++### Paste Selected Settings pastes the crop and Remove+- Copy Edit Settings still leaves Crop, Remove, Masking and Red Eye out by default, as Lightroom does, so a plain+  Paste doesn't change them. Ticking one of them in Paste Selected Settings (⇧⌘V) now pastes it from the copied photo;+  before, a group the copy had left out was silently skipped.+- A crop pasted, pasted from the previous photo, synced or carried by Auto Sync onto a photo of another shape keeps its+  aspect ratio, angle and relative size instead of being stretched, and stays centred where it was as far as the photo+  allows (a straightened crop off the centre can move in to stay inside the turned photo). A locked ratio such as 3:2+  is kept even when it crops nothing on the copied photo; an Original lock takes the new photo's own shape. On a photo+  of the same shape the crop is pasted as it is. Presets apply their crop as stored. Auto Sync carries only what the+  edit changed: a new crop rectangle keeps each photo's own angle, and a new angle each photo's own rectangle.+ ## 2026-10-24  ### Relative-date filters and smart albums follow the clock