From fc0b40dae50301c08a6f302f32ecc10a39d20158 Mon Sep 17 00:00:00 2001 From: FinnNk Date: Fri, 23 Oct 2026 22:55:54 +0100 Subject: [PATCH] fix(atn): keep Photoshop's other transform interpolation modes on import (#8698) The .atn importer kept only bicubic, bilinear and nearest from a transform's Intr, so a recorded Bicubic Automatic, Sharper or Smoother Transform Selection replayed with the command's default (bilinear) and re-exported without its choice. The bicubic variants are now kept (the transforms already render them as Bicubic), Automatic becomes Bicubic Automatic and Preserve Details Bicubic, and both transform commands list the variants (#8697). Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas --- crates/engine/src/atn_cmds.rs | 10 +++++++++- crates/engine/src/atn_cmds/tests.rs | 22 ++++++++++++++++++++++ crates/engine/src/select_extra_cmds.rs | 22 +++++++++++++++++++++- crates/engine/src/transform_cmds.rs | 2 +- 4 files changed, 53 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/atn_cmds.rs b/crates/engine/src/atn_cmds.rs index 30a05192ec..cedfa5aeed 100644 --- a/crates/engine/src/atn_cmds.rs +++ b/crates/engine/src/atn_cmds.rs @@ -1949,7 +1949,15 @@ fn transform_from(d: &Descriptor) -> Mapped { let whole_pixels = ["dx", "dy"].iter().all(|k| p.get(*k).and_then(Value::as_f64).is_none_or(|v| v.fract() == 0.0)); let geometry_only = p.keys().all(|k| k == "dx" || k == "dy") && whole_pixels; // The resampling applies to a selection as much as to a layer (#7515). - let interpolation = get_enum(d, "Intr").and_then(|i| lookup(INTERPOLATION, &i)).filter(|i| matches!(*i, "bicubic" | "bilinear" | "nearest")); + // Photoshop's bicubic variants are kept (the transforms render them as Bicubic, and they export + // back unchanged); Automatic and Preserve Details become the nearest bicubic choice. Dropping + // them fell back to the command's default, bilinear for a selection (#8697). + let interpolation = get_enum(d, "Intr").and_then(|i| lookup(INTERPOLATION, &i)).and_then(|i| match i { + "bicubic" | "bilinear" | "nearest" | "bicubicSharper" | "bicubicSmoother" | "bicubicAutomatic" => Some(i), + "automatic" => Some("bicubicAutomatic"), + "preserveDetails" => Some("bicubic"), + _ => None, + }); match (ref_class(r).as_deref(), target(r)) { (Some("Chnl"), Target::Property(prop)) if prop == "fsel" => { if let Some(i) = interpolation { diff --git a/crates/engine/src/atn_cmds/tests.rs b/crates/engine/src/atn_cmds/tests.rs index 02474b8858..cca5491979 100644 --- a/crates/engine/src/atn_cmds/tests.rs +++ b/crates/engine/src/atn_cmds/tests.rs @@ -2053,6 +2053,28 @@ fn options_the_descriptors_cannot_carry_refuse_strict_export() { } } +/// #8697: Photoshop's other transform interpolation modes survive import: its bicubic variants +/// are kept (they render as Bicubic and export back unchanged), Automatic becomes Bicubic +/// Automatic and Preserve Details Bicubic; before, they fell back to the command's default. +#[test] +fn photoshop_transform_interpolation_modes_survive_import() { + let one = |id: &str, p: Value| Action { name: "Probe".into(), steps: vec![(id.into(), p)], ..Default::default() }; + for id in ["select.transformSelection", "edit.transform"] { + for (mode, imported) in [ + ("bicubicSharper", "bicubicSharper"), + ("bicubicSmoother", "bicubicSmoother"), + ("bicubicAutomatic", "bicubicAutomatic"), + ("automatic", "bicubicAutomatic"), + ("preserveDetails", "bicubic"), + ] { + let (bytes, _) = export("Audit8", &[&one(id, json!({"scaleX": 140, "interpolation": mode}))], &[], false).unwrap(); + let steps = import(&bytes).unwrap().actions[0].steps.clone(); + let got = steps.first().and_then(|(_, p)| p.get("interpolation")).and_then(Value::as_str).map(str::to_owned); + assert_eq!(got.as_deref(), Some(imported), "{id} {mode}: {steps:?}"); + } + } +} + /// #8531: a partial `layer.setAdjustment` of Color Balance or Hue/Saturation can't be written /// (their descriptors carry every setting, filling absent ones with defaults), so strict export /// refuses it instead of replaying an edit that also resets the rest. Complete ones still export. diff --git a/crates/engine/src/select_extra_cmds.rs b/crates/engine/src/select_extra_cmds.rs index b3fae926eb..8ca012266d 100644 --- a/crates/engine/src/select_extra_cmds.rs +++ b/crates/engine/src/select_extra_cmds.rs @@ -250,7 +250,7 @@ pub fn specs() -> Vec { label: "Transform Selection", menu: &["Select"], shortcut: None, - params: r##"{"rect":[x0,y0,x1,y1]? (frame; default = selection bounds),"quad"|"corners":[[x,y]×4]? (where the frame's corners go: distort/perspective),"matrix":[a,b,c,d,e,f]?,"scaleX":%=100,"scaleY":%=100,"rotate":deg=0,"skewX":deg=0,"skewY":deg=0,"dx":px=0,"dy":px=0,"reference":"center|topLeft|top|topRight|left|right|bottomLeft|bottom|bottomRight"|[x,y]="center","style"|"mesh"|"grid"|"warp":… (warp, as edit.transform.warp),"interpolation":"bilinear|bicubic|nearest"="bilinear"}"##, + params: r##"{"rect":[x0,y0,x1,y1]? (frame; default = selection bounds),"quad"|"corners":[[x,y]×4]? (where the frame's corners go: distort/perspective),"matrix":[a,b,c,d,e,f]?,"scaleX":%=100,"scaleY":%=100,"rotate":deg=0,"skewX":deg=0,"skewY":deg=0,"dx":px=0,"dy":px=0,"reference":"center|topLeft|top|topRight|left|right|bottomLeft|bottom|bottomRight"|[x,y]="center","style"|"mesh"|"grid"|"warp":… (warp, as edit.transform.warp),"interpolation":"bilinear|bicubic|nearest|bicubicSharper|bicubicSmoother|bicubicAutomatic"="bilinear" (Photoshop's bicubic variants render as Bicubic)}"##, enabled: has_selection, run: transform_selection, journal: true, @@ -297,6 +297,26 @@ mod tests { s } + /// #8697: Photoshop's bicubic variants are accepted and render as Bicubic, for a selection + /// and for a layer (an imported action keeps them). + #[test] + fn photoshop_bicubic_variants_transform_as_bicubic() { + let run = |id: &str, interpolation: &str| { + let mut s = landscape(8); + s.execute("select.rect", json!({"x": 20, "y": 10, "width": 60, "height": 40, "feather": 3})).unwrap(); + s.execute(id, json!({"scaleX": 137, "scaleY": 81, "rotate": 7, "interpolation": interpolation})).unwrap(); + let st = s.active().unwrap(); + (st.doc.selection.clone(), st.doc.layers.iter().map(|l| l.surface().cloned()).collect::>()) + }; + for id in ["select.transformSelection", "edit.transform"] { + let bicubic = run(id, "bicubic"); + assert_ne!(bicubic, run(id, "bilinear"), "{id}: the interpolation matters here"); + for variant in ["bicubicSharper", "bicubicSmoother", "bicubicAutomatic"] { + assert_eq!(run(id, variant), bicubic, "{id} {variant}"); + } + } + } + #[test] fn sky_selects_the_sky_at_every_depth() { for depth in [8, 16, 32] { diff --git a/crates/engine/src/transform_cmds.rs b/crates/engine/src/transform_cmds.rs index fce8c1be49..a1b1aac379 100644 --- a/crates/engine/src/transform_cmds.rs +++ b/crates/engine/src/transform_cmds.rs @@ -859,7 +859,7 @@ pub fn specs() -> Vec { label: "Free Transform", menu: &[], shortcut: None, - params: r##"{"layer":id?,"rect":[x0,y0,x1,y1]? (source frame; default = layer content ∩ selection),"quad":[[x,y]×4]? (where the frame's corners go, clockwise from top-left),"matrix":[a,b,c,d,e,f]? (affine alternative),"scaleX":%?,"scaleY":%?,"rotate":deg?,"dx":px?,"dy":px? (relative alternative: scale and rotate about the frame's centre, then move, as a Photoshop action records a Free Transform),"interpolation":"bicubic|bilinear|nearest"="bicubic","target":"pixels"|"mask"|"quickMask"|{"channel":i}=Channels panel target (an unlinked mask, an alpha channel or the Quick Mask transforms alone; a linked mask moves with its layer)}"##, + params: r##"{"layer":id?,"rect":[x0,y0,x1,y1]? (source frame; default = layer content ∩ selection),"quad":[[x,y]×4]? (where the frame's corners go, clockwise from top-left),"matrix":[a,b,c,d,e,f]? (affine alternative),"scaleX":%?,"scaleY":%?,"rotate":deg?,"dx":px?,"dy":px? (relative alternative: scale and rotate about the frame's centre, then move, as a Photoshop action records a Free Transform),"interpolation":"bicubic|bilinear|nearest|bicubicSharper|bicubicSmoother|bicubicAutomatic"="bicubic" (Photoshop's bicubic variants render as Bicubic),"target":"pixels"|"mask"|"quickMask"|{"channel":i}=Channels panel target (an unlinked mask, an alpha channel or the Quick Mask transforms alone; a linked mask moves with its layer)}"##, enabled: has_layer, journal: true, run: transform,