diff --git a/crates/engine/src/atn_cmds.rs b/crates/engine/src/atn_cmds.rs index ffa29aec14..621b5bb4a8 100644 --- a/crates/engine/src/atn_cmds.rs +++ b/crates/engine/src/atn_cmds.rs @@ -2135,7 +2135,11 @@ fn rotate_from(d: &Descriptor) -> Mapped { } else if targets_layer(d) { return Mapped::Unsupported { harmless: false }; } else { - return step("image.rotation.arbitrary", json!({"angle": angle.abs(), "direction": if angle < 0.0 { "ccw" } else { "cw" }})); + let mut p = json!({"angle": angle.abs(), "direction": if angle < 0.0 { "ccw" } else { "cw" }}); + if let Some(i) = get_enum(d, "Intr").and_then(|i| lookup(INTERPOLATION, &i)).filter(|i| matches!(*i, "bicubic" | "bilinear" | "nearest")) { + p["interpolation"] = i.into(); + } + return step("image.rotation.arbitrary", p); }; none(if targets_layer(d) { layer } else { doc }) } @@ -2155,7 +2159,20 @@ fn rotate_to(id: &str, p: &Value) -> Option { if layer { d = d.with("null", target_ref("Lyr ")); } - Some(d.with("Angl", ang(angle))) + d = d.with("Angl", ang(angle)); + if id == "image.rotation.arbitrary" + && let Some(i) = p.get("interpolation") + { + // The command resamples with nearest, bilinear or bicubic; any other value can't be + // carried faithfully, so refuse rather than replay with a different interpolation. + let i = match i.as_str()? { + "nearestNeighbor" => "nearest", + i @ ("bicubic" | "bilinear" | "nearest") => i, + _ => return None, + }; + d = d.with("Intr", enm("Intp", lookup_back(INTERPOLATION, i)?)); + } + Some(d) } fn flip_from(d: &Descriptor) -> Mapped { diff --git a/crates/engine/src/atn_cmds/tests.rs b/crates/engine/src/atn_cmds/tests.rs index 1b91235428..b19dc25d8b 100644 --- a/crates/engine/src/atn_cmds/tests.rs +++ b/crates/engine/src/atn_cmds/tests.rs @@ -1199,6 +1199,25 @@ fn fractional_translation_only_transform_keeps_offset_and_interpolation() { assert_eq!(steps[0].1.get("interpolation"), Some(&json!("bilinear"))); } +/// Arbitrary canvas rotation must keep its interpolation through a strict export: dropping it +/// silently replays the rotation with the default bicubic resampling. +#[test] +fn arbitrary_rotation_keeps_interpolation_through_atn() { + for params in [ + json!({"angle": 17, "direction": "ccw", "interpolation": "nearest"}), + json!({"angle": 17, "direction": "ccw", "interpolation": "bilinear"}), + json!({"angle": 17, "direction": "ccw", "interpolation": "bicubic"}), + json!({"angle": 17, "direction": "ccw"}), + ] { + let a = Action { name: "Rot".into(), steps: vec![("image.rotation.arbitrary".into(), params.clone())], ..Default::default() }; + let (bytes, skipped) = export("Rot", &[&a], &[], false).unwrap(); + assert!(skipped.is_empty()); + let imported = import(&bytes).unwrap(); + assert!(imported.unsupported.is_empty()); + assert_eq!(imported.actions[0].steps, vec![("image.rotation.arbitrary".to_string(), params)]); + } +} + /// A filter recorded against a layer mask must not export as a plain filter that edits the /// layer's pixels on replay: Photoshop's descriptor has no field for PhotoCraft's `target`. #[test]