From 66ff3bf7eb2cfd1d617758ff68a44a89a17eba9d Mon Sep 17 00:00:00 2001 From: Korg1ch <57859048+Korg1ch@users.noreply.github.com> Date: Mon, 19 Oct 2026 20:38:52 +0300 Subject: [PATCH] test(engine): a Displace smart filter keeps its embedded map; an outside new map is refused before it is read (#7423) --- .../tests/smart_filter_authorization.rs | 23 +++++++ crates/engine/src/doc_path/tests.rs | 30 +++++++++ crates/engine/src/filters.rs | 61 +++++++++++++++++++ crates/io/src/displace_map.rs | 34 +++++++++++ crates/ui-egui/src/filter_dialog.rs | 46 ++++++++++++++ 5 files changed, 194 insertions(+) diff --git a/crates/automation/tests/smart_filter_authorization.rs b/crates/automation/tests/smart_filter_authorization.rs index 30f17b795b..594832c41d 100644 --- a/crates/automation/tests/smart_filter_authorization.rs +++ b/crates/automation/tests/smart_filter_authorization.rs @@ -257,3 +257,26 @@ fn an_ambient_map_or_lut_never_renders_unfiltered_pixels_into_the_stack_cache() assert_eq!(bytes, smart_cmds::stack_cache_bytes(), "a denied render cached no stage"); } } + +/// Choosing another Displace map through `setParams` embeds it (#7423), so the new map is read +/// before the edit: a path outside the workspace is refused before that read, and the filter +/// keeps the map it had. +#[test] +fn a_new_displace_map_outside_the_workspace_is_refused_before_it_is_read() { + let f = Fixture::new(); + let (mut h, layer) = f.setup(false); + let params = |h: &Headless| { + let d = h.session.active().unwrap(); + let LayerContent::Smart(sm) = &d.doc.layer(layer).unwrap().content else { panic!("smart object") }; + sm.smart_filters[0].params.clone() + }; + let before = params(&h); + let map = photocraft_io::smart_map::displace_embedded_map(&before).expect("the open document's map, embedded"); + assert!(map.data.iter().all(|v| *v < 0.9), "the grey map"); + let doc = h.session.active().unwrap().doc.clone(); + let edit = json!({"layer": layer.0, "params": {"mapPath": f.outside("white.png")}}); + assert_denied(h.session.execute("layer.smartFilter.setParams", edit.clone())); + assert_denied(h.handle("engine.execute", json!({"command": "layer.smartFilter.setParams", "params": edit}))); + assert!(Arc::ptr_eq(&doc, &h.session.active().unwrap().doc)); + assert_eq!(params(&h), before, "no white map was read into the filter"); +} diff --git a/crates/engine/src/doc_path/tests.rs b/crates/engine/src/doc_path/tests.rs index 393e85a062..1881860ae1 100644 --- a/crates/engine/src/doc_path/tests.rs +++ b/crates/engine/src/doc_path/tests.rs @@ -191,6 +191,36 @@ fn smart_filters_reread_only_followed_files() { assert_eq!(replayed(&mut s, id, json!({"file": network_alias(&d.join("invert.cube"))})), unfiltered); } +/// #7423 (review on #7799): Displace on a Smart Object keeps a `mapPath` as a path, as before +/// (only a map from an open document is embedded). A path relative to the saved document, set +/// through the filter's own edit, displaces as the absolute one did, and the dialog's "None" +/// (`mapDocument: -1`) names no map instead of failing. +#[test] +fn smart_displace_keeps_a_relative_map_path() { + let d = dir("displace-relative"); + png(&d.join("ramp.png"), |x| [(x * 8) as u8, 0, 0]); + png(&d.join("map.png"), |_| [255, 255, 255]); + let (mut s, id) = document(&d, &d.join("ramp.png")); + let path = d.join("map.png").to_string_lossy().into_owned(); + let unfiltered = photocraft_compose::render(&s.active().unwrap().doc, s.active().unwrap().doc.bounds()).px; + s.execute("filter.distort.displace", json!({"layer": id.0, "mapPath": path, "horizontal": 50, "vertical": 0})).unwrap(); + let state = |s: &Session| { + let doc = &s.active().unwrap().doc; + let Some(LayerContent::Smart(sm)) = doc.layer(id).map(|l| &l.content) else { panic!("a smart object") }; + (sm.smart_filters[0].params.clone(), photocraft_compose::render(doc, doc.bounds()).px) + }; + let (params, displaced) = state(&s); + assert_eq!(params["mapPath"], json!(path)); + assert!(photocraft_io::smart_map::displace_embedded_map(¶ms).is_none(), "a path, not an embedded map"); + assert_ne!(displaced, unfiltered, "the map moves the pixels"); + s.execute("layer.smartFilter.setParams", json!({"layer": id.0, "index": 0, "params": {"mapPath": "map.png"}})).unwrap(); + let (params, relative) = state(&s); + assert_eq!(params["mapPath"], json!("map.png")); + assert_eq!(relative, displaced, "a relative map next to the document"); + s.execute("layer.smartFilter.setParams", json!({"layer": id.0, "index": 0, "params": {"mapDocument": -1}})).unwrap(); + assert_eq!(state(&s).1, displaced, "\"None\" changes nothing"); +} + #[test] fn data_sets_and_footage_read_only_followed_files() { let d = dir("datasets"); diff --git a/crates/engine/src/filters.rs b/crates/engine/src/filters.rs index b3ab2c4be7..9c2303195d 100644 --- a/crates/engine/src/filters.rs +++ b/crates/engine/src/filters.rs @@ -952,4 +952,65 @@ mod tests { assert!(calls.load(std::sync::atomic::Ordering::SeqCst) > 2); assert!(new.tiles().all(|(c, _)| got.tile(*c).is_some())); } + + /// #7423: Displace on a Smart Object keeps its map (read from an open document) in the smart + /// filter, as Photoshop embeds it, so the filter displaces and keeps displacing when the + /// object re-renders. A map chosen again replaces it. + #[test] + fn displace_on_a_smart_object_embeds_its_map() { + let mut s = Session::new(); + // Documents 0 and 1: a white and a black displacement map. + s.execute("file.new", json!({"width": 8, "height": 8, "background": "white"})).unwrap(); + s.execute("file.new", json!({"width": 8, "height": 8, "background": "black"})).unwrap(); + s.execute("file.new", json!({"width": 48, "height": 32, "background": "transparent"})).unwrap(); + s.edit("square", |doc, active| { + let fmt = doc.pixel_format(); + let surf = doc.layer_mut(active.unwrap()).unwrap().surface_mut().unwrap(); + surf.fill_rect(photocraft_geom::Rect::new(10, 10, 20, 12), &photocraft_raster::from_rgba(&fmt, [1.0, 0.0, 0.0, 1.0])); + Ok(()) + }) + .unwrap(); + s.execute("layer.smartObjects.convertToSmartObject", json!({})).unwrap(); + let original = { + let d = s.active().unwrap(); + let LayerContent::Smart(sm) = &d.doc.layer(d.active_layer.unwrap()).unwrap().content else { panic!("not a smart object") }; + sm.cache.as_ref().unwrap().read_region(d.doc.bounds()) + }; + let smart = |s: &Session| { + let d = s.active().unwrap(); + let LayerContent::Smart(sm) = &d.doc.layer(d.active_layer.unwrap()).unwrap().content else { panic!("not a smart object") }; + (sm.smart_filters[0].params.clone(), sm.cache.as_ref().unwrap().read_region(d.doc.bounds())) + }; + let first_value = |params: &Value| photocraft_io::smart_map::displace_embedded_map(params).expect("an embedded map").data[0]; + + s.execute("filter.distort.displace", json!({"horizontal": 10, "vertical": 0, "mapDocument": 0})).unwrap(); + let (params, displaced) = smart(&s); + assert!(params.get("mapDocument").is_none(), "{params}"); + assert_eq!(first_value(¶ms), 1.0, "the white map"); + assert_ne!(displaced, original, "the smart object is displaced"); + // Re-rendered from its params alone (the filter hidden and shown again): still displaced. + s.execute("layer.smartFilter.setVisible", json!({"index": 0, "visible": false})).unwrap(); + assert_eq!(smart(&s).1, original); + s.execute("layer.smartFilter.setVisible", json!({"index": 0, "visible": true})).unwrap(); + assert_eq!(smart(&s).1, displaced); + + // Another map chosen for the filter replaces the embedded one. + s.execute("layer.smartFilter.setParams", json!({"index": 0, "params": {"mapDocument": 1}})).unwrap(); + let (params, again) = smart(&s); + assert!(params.get("mapDocument").is_none(), "{params}"); + assert_eq!(first_value(¶ms), 0.0, "the black map"); + assert_ne!(again, displaced, "displaced the other way"); + + // The stored params passed back whole (the old embedded map included) with another map + // named: the named map wins, for the filter's own edit and for a new Displace alike. + let mut back = params.as_object().unwrap().clone(); + back.insert("mapDocument".into(), json!(0)); + s.execute("layer.smartFilter.setParams", json!({"index": 0, "params": back.clone()})).unwrap(); + assert_eq!(first_value(&smart(&s).0), 1.0, "the white map, not the embedded black one"); + back.insert("mapDocument".into(), json!(1)); + s.execute("filter.distort.displace", Value::Object(back)).unwrap(); + let d = s.active().unwrap(); + let LayerContent::Smart(sm) = &d.doc.layer(d.active_layer.unwrap()).unwrap().content else { panic!("not a smart object") }; + assert_eq!(first_value(&sm.smart_filters[1].params), 0.0, "the black map, not the passed-back white one"); + } } diff --git a/crates/io/src/displace_map.rs b/crates/io/src/displace_map.rs index 0843375b0d..48b34c4b01 100644 --- a/crates/io/src/displace_map.rs +++ b/crates/io/src/displace_map.rs @@ -407,4 +407,38 @@ mod tests { } assert!(embedded_map(&json!({TEMPLATE_KEY: "0".repeat(MAX_TEMPLATE_BYTES * 2 + 2)})).is_none()); } + + /// #7423: a map from a file or an open document, embedded for a smart filter, is read back as + /// the filter's map and written to PSD like an imported one. + #[test] + fn an_embedded_map_reads_back_and_exports() { + let rect = Rect::new(0, 0, 3, 2); + // RGBA: red is the horizontal map, green the vertical one. + let rgba = Image { rect, ch: 4, data: (0..6).flat_map(|i| [i as f32 / 5.0, 1.0 - i as f32 / 5.0, 0.0, 1.0]).collect() }; + let params = json!({"horizontal": 20, "vertical": -5, "fit": "tile", "undefinedAreas": "wrap", "mapDocument": 0, "mapLayer": 3}); + let embedded = embed_map(¶ms, &rgba).unwrap(); + for key in ["mapPath", "mapDocument", "mapLayer"] { + assert!(embedded.get(key).is_none(), "{key}"); + } + assert_eq!(embedded["horizontal"], json!(20)); + let map = embedded_map(&embedded).unwrap(); + assert_eq!((map.rect, map.ch), (rect, 3)); + let h: Vec = map.data.chunks(3).map(|p| p[0]).collect(); + let v: Vec = map.data.chunks(3).map(|p| p[1]).collect(); + assert_eq!(h, [0.0, 51.0 / 255.0, 102.0 / 255.0, 153.0 / 255.0, 204.0 / 255.0, 1.0]); + assert_eq!(v, [1.0, 204.0 / 255.0, 153.0 / 255.0, 102.0 / 255.0, 51.0 / 255.0, 0.0]); + // Written to PSD with the params' own settings. + let f = export_item(&embedded).unwrap(); + let fltr = get_desc(&f, "Fltr").unwrap(); + assert_eq!(num(fltr.get("HrzS")), Some(20.0)); + assert_eq!(enum_of(fltr, "DspM"), Some(&b"Tile"[..])); + // A grey map displaces the same both ways. + let grey = Image { rect, ch: 1, data: vec![0.25; 6] }; + let map = embedded_map(&embed_map(&json!({}), &grey).unwrap()).unwrap(); + assert_eq!(map.ch, 1); + // Nothing to embed. + assert!(embed_map(&json!({}), &Image { rect: Rect::new(0, 0, 0, 0), ch: 4, data: vec![] }).is_none()); + assert!(embed_map(&json!({}), &Image { rect, ch: 4, data: vec![0.0; 5] }).is_none(), "short data"); + assert!(embed_map(&json!(3), &grey).is_none(), "params not an object"); + } } diff --git a/crates/ui-egui/src/filter_dialog.rs b/crates/ui-egui/src/filter_dialog.rs index 9943998bdc..ad185f315c 100644 --- a/crates/ui-egui/src/filter_dialog.rs +++ b/crates/ui-egui/src/filter_dialog.rs @@ -1779,6 +1779,52 @@ mod tests { assert_eq!(label("wavelengthMin"), "Wavelength Min"); } + /// #7423 (review on #7799): re-editing a Displace smart filter and picking another open + /// document as its map previews with that document, as OK applies it. The preview session held + /// only the edited document, and the map's `mapDocument` sits inside the filter's `params`. + #[test] + fn smart_displace_preview_uses_the_map_document() { + let mut s = photocraft_engine::Session::new(); + // Documents 0 and 1: a white and a black map, opened before the edited one. + s.execute("file.new", json!({"width": 8, "height": 8, "background": "white"})).unwrap(); + s.execute("file.new", json!({"width": 8, "height": 8, "background": "black"})).unwrap(); + s.execute("file.new", json!({"width": 48, "height": 32, "background": "transparent"})).unwrap(); + s.edit("square", |doc, active| { + let fmt = doc.pixel_format(); + let surf = doc.layer_mut(active.unwrap()).unwrap().surface_mut().unwrap(); + surf.fill_rect(photocraft_geom::Rect::new(10, 10, 20, 12), &photocraft_raster::from_rgba(&fmt, [1.0, 0.0, 0.0, 1.0])); + Ok(()) + }) + .unwrap(); + s.execute("layer.smartObjects.convertToSmartObject", json!({})).unwrap(); + s.execute("filter.distort.displace", json!({"horizontal": 10, "vertical": 0, "mapDocument": 0})).unwrap(); + let doc = s.active().unwrap().doc.clone(); + let layer = s.active().unwrap().active_layer; + let documents: Vec<_> = s.documents().iter().map(|d| d.doc.clone()).collect(); + for map in [1, 0] { + let p = json!({"index": 0, "params": {"mapDocument": map}}); + let mut applied = photocraft_engine::Session::new(); + for d in &documents { + applied.add_document((**d).clone(), None); + } + applied.execute(SMART_FILTER_SET_PARAMS, p.clone()).unwrap(); + let want = photocraft_compose::render(&applied.active().unwrap().doc, doc.bounds()).px; + let preview = preview_document_for_target( + &doc, + layer, + SMART_FILTER_SET_PARAMS, + &p, + 1, + None, + photocraft_engine::channel_cmds::ChannelTarget::Composite, + &documents, + &ColorSettings::default(), + ) + .unwrap_or_else(|| panic!("map {map}: no preview")); + assert!(photocraft_compose::render(&preview, doc.bounds()).px == want, "map {map}: the preview differs from what OK applies"); + } + } + /// Match Color's preview reads the Source document from the open documents, as OK does: the /// preview session held only the edited document, so a Source showed no change (#4851). #[test]