fix(engine): Displace smart filter ignores its mapPath displacement map (#3676)

A Displace applied to a smart object is recorded as a smart filter and
rendered by apply_smart_filters -> filters::apply_filter_to_surface, both
when it is added and on every later re-render (replace contents, toggling
a filter, transforms). That path built FilterParams with params_for alone,
which leaves Displace's map as None; only run_filter resolved `mapPath`
(through filters_ext::resolve, which needs the session). With no map,
algo's displace returns the source unchanged, so the smart filter was a
silent no-op.

apply_filter_to_surface now resolves `mapPath` through the same file read
and import the direct command uses (filters_ext::map_from_path, shared by
resolve and the new session-free resolve_map_path). A map that can no
longer be read on re-render leaves the pixels alone, as for an unknown
filter; the command still refuses an unreadable map when the filter is
added. `mapDocument` needs the session and stays unresolved on re-render.

Regression test: smart_cmds::tests::
displace_smart_filter_reads_its_map_path_on_re_render (8/16/32-bit).

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Scott MacdonaldandClaude Opus 5.5 authored and GitHub committed 2026-10-12 06:28:33 -04:00
1 parent 64a02107ba
commit 2cb396d154
3 files changed
+60 -5

No files matched your search

+4 -1
View File
@@ -185,7 +185,10 @@ pub fn apply_filter_to_surface(
if id == crate::plugin_cmds::RUN {
return crate::plugin_cmds::apply_to_surface(id, params, surf, selection, canvas);
}
let fp = params_for(id, params)?;
let mut fp = params_for(id, params)?;
// A map that can no longer be read (moved or deleted since) leaves the pixels alone, like an
// unknown filter; the command itself already refused an unreadable map when it was added.
crate::filters_ext::resolve_map_path(&mut fp, params).ok()?;
let sel_bounds = selection.map(photocraft_raster::Surface::content_bounds);
let content = surf.content_bounds();
let area = algo::output_area(&fp, content, bounds, sel_bounds);
+26 -4
View File
@@ -435,10 +435,8 @@ pub(crate) fn resolve(s: &Session, fp: &mut FilterParams, p: &Value) -> Result<(
return Ok(());
}
let layer = p.get("mapLayer").and_then(Value::as_u64);
let img = if let Some(path) = p.get("mapPath").and_then(Value::as_str).filter(|p| !p.is_empty()) {
let bytes = std::fs::read(path).map_err(|e| EngineError::Other(format!("cannot read displacement map `{path}`: {e}")))?;
let imported = crate::file_cmds::import(path, &bytes).map_err(|e| EngineError::Other(format!("cannot open displacement map `{path}`: {e}")))?;
map_from_document(&imported, layer)
let img = if let Some(path) = map_path(p) {
map_from_path(path, layer)?
} else if let Some(d) = p.get("mapDocument") {
let docs = s.documents();
let found = match d {
@@ -454,6 +452,30 @@ pub(crate) fn resolve(s: &Session, fp: &mut FilterParams, p: &Value) -> Result<(
Ok(())
}
fn map_path(p: &Value) -> Option<&str> {
p.get("mapPath").and_then(Value::as_str).filter(|p| !p.is_empty())
}
/// Displace's map read from a file (flattened, or its `mapLayer`).
fn map_from_path(path: &str, layer: Option<u64>) -> Result<Option<algo::Image>> {
let bytes = std::fs::read(path).map_err(|e| EngineError::Other(format!("cannot read displacement map `{path}`: {e}")))?;
let imported = crate::file_cmds::import(path, &bytes).map_err(|e| EngineError::Other(format!("cannot open displacement map `{path}`: {e}")))?;
Ok(map_from_document(&imported, layer))
}
/// The session-free part of [`resolve`]: Displace's `mapPath`. Smart filters record params, not
/// the map, so their re-render reads the map again here; without it the filter passed the pixels
/// through. `mapDocument` needs the session and is left unresolved.
pub(crate) fn resolve_map_path(fp: &mut FilterParams, p: &Value) -> Result<()> {
if let FilterParams::Displace { map, .. } = fp
&& map.is_none()
&& let Some(path) = map_path(p)
{
*map = map_from_path(path, p.get("mapLayer").and_then(Value::as_u64))?.map(Arc::new);
}
Ok(())
}
/// Resolves inputs read from the target layer itself: the layer mask as Lens Blur depth.
pub(crate) fn resolve_in_layer(fp: &mut FilterParams, layer: &Layer, bounds: Rect) {
if let FilterParams::LensBlur { depth: DepthSource::LayerMask, depth_map, .. } = fp
+30
View File
@@ -977,3 +977,33 @@ fn inspect_reports_the_smart_source() {
assert_eq!(source(&s)["kind"], "embedded");
assert!(source(&s)["fileName"].is_string());
}
#[test]
fn displace_smart_filter_reads_its_map_path_on_re_render() {
let dir = std::env::temp_dir().join(format!("pcraft-displace-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
// A white map displaces by the full scale; mid-grey would not move anything.
let white = Document::with_background(
"map",
Size::new(W as u32, H as u32),
photocraft_color::ColorMode::Rgb,
photocraft_color::SampleType::U8,
photocraft_color::Color::rgba(1.0, 1.0, 1.0, 1.0),
);
let map = dir.join("white.png");
std::fs::write(&map, photocraft_io::export(&white, "png", &Default::default()).unwrap().bytes).unwrap();
for depth in DEPTHS {
let mut s = session(depth);
paint(&mut s);
convert(&mut s);
let before = flat(&s);
s.execute("filter.distort.displace", json!({"horizontal": 8, "vertical": 8, "mapPath": map.to_str().unwrap()})).unwrap();
assert!(max_diff(&before, &flat(&s)) > 0.1, "the smart filter displaced the pixels at {depth}-bit");
// A later re-render (here: hide and show the filter) reads the map again.
s.execute("layer.smartFilter.setVisible", json!({"index": 0, "visible": false})).unwrap();
assert_eq!(max_diff(&before, &flat(&s)), 0.0, "hidden at {depth}-bit");
s.execute("layer.smartFilter.setVisible", json!({"index": 0, "visible": true})).unwrap();
assert!(max_diff(&before, &flat(&s)) > 0.1, "shown again at {depth}-bit");
}
std::fs::remove_dir_all(&dir).ok();
}