diff --git a/crates/engine/src/channel_cmds.rs b/crates/engine/src/channel_cmds.rs index db6ab3538e..47360103c6 100644 --- a/crates/engine/src/channel_cmds.rs +++ b/crates/engine/src/channel_cmds.rs @@ -517,6 +517,19 @@ fn routed(id: &str) -> bool { || id == "plugin.run" || id.starts_with("image.adjustments.") || matches!(id, "paint.stroke" | "paint.pencil" | "paint.bucket" | "paint.gradient" | "paint.mixerBrush" | "edit.fill" | "image.applyImage") + || matches!( + id, + "paint.cloneStamp" + | "paint.healingBrush" + | "paint.spotHealing" + | "paint.dodge" + | "paint.burn" + | "paint.sponge" + | "paint.blur" + | "paint.sharpen" + | "paint.smudge" + | "paint.historyBrush" + ) } /// Fill in `"target"` from the targeted channel (or Quick Mask mode) when the caller gave none. diff --git a/crates/engine/src/retouch_cmds.rs b/crates/engine/src/retouch_cmds.rs index 307dd34618..787cd1d9ea 100644 --- a/crates/engine/src/retouch_cmds.rs +++ b/crates/engine/src/retouch_cmds.rs @@ -3,7 +3,9 @@ //! //! Every command takes a Photoshop-style brush (`points`, `size`, `hardness`, `opacity`, `flow`, //! `spacing`, `layer`), respects the active selection as a mask and the layer's transparency lock, and -//! works at any depth (8/16/32f) and colour model. The pixel algorithms live in `photocraft-algo` +//! works at any depth (8/16/32f) and colour model. Like the Brush, each paints the targeted surface: +//! the layer's pixels, its mask (`"target":"mask"`), an alpha channel or the Quick Mask (`"target"`, +//! filled in from the Channels panel when absent). The pixel algorithms live in `photocraft-algo` //! (`poisson`, `inpaint`, `retouch`) and the dab machinery in `photocraft-paint::retouch`; this module //! only parses parameters and wires them together. @@ -25,10 +27,23 @@ fn bad(cmd: &str, msg: impl Into) -> EngineError { EngineError::BadParams { cmd: cmd.into(), msg: msg.into() } } +/// A pixel layer, a layer with a mask, or a targeted alpha channel / Quick Mask (as the Brush). fn has_pixel_layer(s: &Session) -> std::result::Result<(), String> { + if crate::channel_cmds::edits_channel(s) { + return Ok(()); + } let d = s.active().ok_or("no document open")?; let l = d.active_layer.and_then(|id| d.doc.layer(id)).ok_or("no active layer")?; - if matches!(l.content, LayerContent::Raster(_)) { Ok(()) } else { Err(format!("active layer is a {} layer, not a pixel layer", l.content.kind_name())) } + if matches!(l.content, LayerContent::Raster(_)) || l.mask.is_some() { + Ok(()) + } else { + Err(format!("active layer is a {} layer, not a pixel layer", l.content.kind_name())) + } +} + +/// The stroke paints layer pixels (not a mask or channel), so other layers can be sampled. +fn targets_pixels(p: &Value) -> bool { + crate::channel_cmds::target_of(p) == crate::channel_cmds::Target::Pixels } fn num(p: &Value, k: &str, d: f32) -> f32 { @@ -45,8 +60,9 @@ fn point(p: &Value, k: &str) -> Option<(f64, f64)> { Some((a.first()?.as_f64()?, a.get(1)?.as_f64()?)) } -/// Parse the shared brush params into a stroke on a layer. -fn parse_brush(s: &Session, p: &Value, cmd: &str) -> Result<(Stroke, LayerId)> { +/// Parse the shared brush params into a stroke on a layer (`None` when an alpha channel or the +/// Quick Mask is targeted). +fn parse_brush(s: &Session, p: &Value, cmd: &str) -> Result<(Stroke, Option)> { let pts: Vec = p .get("points") .and_then(Value::as_array) @@ -71,24 +87,29 @@ fn parse_brush(s: &Session, p: &Value, cmd: &str) -> Result<(Stroke, LayerId)> { erase: false, ..base }; + if crate::channel_cmds::is_channel_target(p) { + return Ok((Stroke { brush, points: pts }, None)); + } let id = match p.get("layer").and_then(Value::as_u64) { Some(id) => LayerId(id), None => s.active().and_then(|d| d.active_layer).ok_or(EngineError::Other("no active layer".into()))?, }; - Ok((Stroke { brush, points: pts }, id)) + Ok((Stroke { brush, points: pts }, Some(id))) } -/// Run `f` on the target layer's surface inside one undoable step. `f` gets the pre-stroke document -/// (a cheap copy-on-write snapshot), the surface, the selection and the transparency lock. -fn run_stroke(s: &mut Session, label: &str, id: LayerId, f: impl FnOnce(&Document, &mut Surface, Option<&Surface>, bool) -> Result) -> Result { +/// Run `f` on the targeted surface (layer pixels, layer mask, alpha channel or Quick Mask: the +/// Brush's paint target) inside one undoable step. `f` gets the pre-stroke document (a cheap +/// copy-on-write snapshot), the surface, the selection and the transparency lock. +fn run_stroke( + s: &mut Session, + label: &str, + id: Option, + p: &Value, + f: impl FnOnce(&Document, &mut Surface, Option<&Surface>, bool) -> Result, +) -> Result { let dmg = s.edit(label, |doc, _| { let pre = doc.clone(); - let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?; - if l.locks.all || l.locks.pixels { - return Err(EngineError::Other(format!("layer \"{}\" is locked", l.name))); - } - let lock = l.locks.transparency; - let surf = l.surface_mut().ok_or_else(|| EngineError::Other("not a pixel layer".into()))?; + let (surf, lock) = crate::channel_cmds::target_surface(doc, id, p)?; // No prune: retouching only rewrites pixels inside the stroke, and scanning every tile of a // large layer would dominate the cost of a dab. f(&pre, surf, pre.selection.as_ref(), lock) @@ -126,9 +147,11 @@ fn sample_layers(p: &Value, cmd: &str) -> Result { /// Composite pixels of `rect` in `fmt` (native channels): the whole document, or the target layer /// and everything below it. -fn composite_region(pre: &Document, id: LayerId, which: SampleLayers, rect: Rect, fmt: PixelFormat) -> Region { +fn composite_region(pre: &Document, id: Option, which: SampleLayers, rect: Rect, fmt: PixelFormat) -> Region { let mut doc = pre.clone(); - if which == SampleLayers::CurrentAndBelow { + if which == SampleLayers::CurrentAndBelow + && let Some(id) = id + { // Hide everything composited after (above) the target in the bottom-to-top walk. let walk: Vec<(Vec, LayerId)> = pre.walk().into_iter().map(|(p, _, l)| (p, l.id)).collect(); if let Some(pos) = walk.iter().position(|(_, l)| *l == id) { @@ -151,7 +174,7 @@ fn composite_region(pre: &Document, id: LayerId, which: SampleLayers, rect: Rect } /// Pixels to sample at `rect` (document coordinates, already offset to the source position). -fn sample(pre: &Document, id: LayerId, surf: &Surface, which: SampleLayers, rect: Rect) -> Region { +fn sample(pre: &Document, id: Option, surf: &Surface, which: SampleLayers, rect: Rect) -> Region { match which { SampleLayers::Current => Region::read(surf, rect), _ => composite_region(pre, id, which, rect, surf.format()), @@ -167,7 +190,7 @@ fn clone_mapping(s: &mut Session, p: &Value, stroke: &Stroke, cmd: &str) -> Resu /// Source pixels for destination `rect`: a translated read, or a bilinear resample when the /// clone source is scaled, rotated or flipped. -fn clone_sample(pre: &Document, id: LayerId, surf: &Surface, which: SampleLayers, rect: Rect, map: &crate::presets::clone_source::Mapping) -> Region { +fn clone_sample(pre: &Document, id: Option, surf: &Surface, which: SampleLayers, rect: Rect, map: &crate::presets::clone_source::Mapping) -> Region { if map.is_translation() { let off = map.offset(); let mut r = sample(pre, id, surf, which, rect.translate(off.0, off.1)); @@ -220,7 +243,7 @@ fn clone_stamp(s: &mut Session, p: &Value) -> Result { let aligned = flag(p, "aligned", true); let map = clone_mapping(s, p, &stroke, CMD)?; let off = map.offset(); - let dmg = run_stroke(s, "Clone Stamp", id, |pre, surf, sel, lock| { + let dmg = run_stroke(s, "Clone Stamp", id, p, |pre, surf, sel, lock| { let (bounds, cov) = stroke_coverage(&stroke); // Samples come from the pre-stroke state, so pixels painted earlier in the stroke are never re-cloned. let paint = clone_sample(pre, id, surf, which, bounds, &map); @@ -275,7 +298,7 @@ fn healing_brush(s: &mut Session, p: &Value) -> Result { let aligned = flag(p, "aligned", true); let map = clone_mapping(s, p, &stroke, CMD)?; let off = map.offset(); - let dmg = run_stroke(s, "Healing Brush", id, |pre, surf, sel, lock| { + let dmg = run_stroke(s, "Healing Brush", id, p, |pre, surf, sel, lock| { let (bounds, cov) = stroke_coverage(&stroke); let (g, cov) = pad_coverage(bounds, &cov, 2); let fmt = surf.format(); @@ -302,12 +325,12 @@ fn history_brush(s: &mut Session, p: &Value) -> Result { // Default: the oldest state still held (the "Open" snapshot unless it was trimmed). None => st.history.state(0).unwrap_or_else(|| st.doc.clone()), }; - let src_surface = source_doc - .layer(id) - .and_then(|l| l.surface()) - .cloned() - .ok_or_else(|| EngineError::Other("the layer did not exist (as a pixel layer) in that history state".into()))?; - let dmg = run_stroke(s, "History Brush", id, |_, surf, sel, lock| { + // The same target (pixels, mask, channel) as it was in that state. + let mut source_doc = (*source_doc).clone(); + let src_surface = crate::channel_cmds::target_surface(&mut source_doc, id, p) + .map(|(surf, _)| surf.clone()) + .map_err(|_| EngineError::Other("the target did not exist (as pixels, a mask or a channel) in that history state".into()))?; + let dmg = run_stroke(s, "History Brush", id, p, |_, surf, sel, lock| { let fmt = surf.format(); let src = if src_surface.format() == fmt { src_surface } else { src_surface.convert(fmt) }; let (bounds, cov) = stroke_coverage(&stroke); @@ -422,7 +445,7 @@ fn spot_healing(s: &mut Session, p: &Value) -> Result { "proximityMatch" => SpotType::ProximityMatch, o => return Err(bad(CMD, format!("unknown type `{o}` (contentAware|createTexture|proximityMatch)"))), }; - let dmg = run_stroke(s, "Spot Healing Brush", id, |pre, surf, sel, lock| Ok(spot_heal_surface(surf, pre.bounds(), &stroke, kind, sel, lock)))?; + let dmg = run_stroke(s, "Spot Healing Brush", id, p, |pre, surf, sel, lock| Ok(spot_heal_surface(surf, pre.bounds(), &stroke, kind, sel, lock)))?; Ok(json!({ "damage": damage_json(dmg) })) } @@ -479,7 +502,7 @@ fn dodge_burn_cmd(s: &mut Session, p: &Value, burn: bool) -> Result { let exposure = num(p, "exposure", 50.0).clamp(1.0, 100.0) / 100.0; let protect = flag(p, "protectTones", true); let label = if burn { "Burn Tool" } else { "Dodge Tool" }; - let dmg = run_stroke(s, label, id, |_, surf, sel, lock| { + let dmg = run_stroke(s, label, id, p, |_, surf, sel, lock| { let fmt = surf.format(); let sp = stroke.brush.spacing; Ok(apply_dab_stroke(surf, &stroke, sel, lock, 0, |work, fp| color_dab(&fmt, work, fp, exposure, sp, |c, k| dodge_burn(c, k, range, burn, protect)))) @@ -496,7 +519,7 @@ fn sponge_cmd(s: &mut Session, p: &Value) -> Result { o => return Err(bad(CMD, format!("unknown mode `{o}` (desaturate|saturate)"))), }; let vibrance = flag(p, "vibrance", true); - let dmg = run_stroke(s, "Sponge Tool", id, |_, surf, sel, lock| { + let dmg = run_stroke(s, "Sponge Tool", id, p, |_, surf, sel, lock| { let fmt = surf.format(); let sp = stroke.brush.spacing; // Flow is already in each dab's coverage; a full-flow pass moves colours half-way. @@ -505,6 +528,45 @@ fn sponge_cmd(s: &mut Session, p: &Value) -> Result { Ok(json!({ "damage": damage_json(dmg) })) } +/// "Sample All Layers" for Blur, Sharpen and Smudge (layer pixels only: a mask or channel has +/// nothing to sample from other layers). +fn sample_all_layers(p: &Value) -> bool { + flag(p, "sampleAllLayers", false) && targets_pixels(p) +} + +/// Sample All Layers for the sequential-dab tools: each dab's effect runs on a copy of the visible +/// composite (kept across dabs, so a dab sees the earlier ones), and the result is laid onto the +/// layer under the dab. An empty layer above the image so picks up the blurred or smudged picture, +/// and pixels the effect didn't change look as before (they are what was already visible). +struct AllLayers<'a> { + pre: &'a Document, + fmt: PixelFormat, + visible: Option, +} + +impl<'a> AllLayers<'a> { + fn new(pre: &'a Document, fmt: PixelFormat) -> Self { + Self { pre, fmt, visible: None } + } + + fn dab(&mut self, work: &mut Region, fp: &Footprint, effect: impl FnOnce(&mut Region, &Footprint)) { + let (pre, fmt) = (self.pre, self.fmt); + let visible = self.visible.get_or_insert_with(|| composite_region(pre, None, SampleLayers::All, work.rect, fmt)); + if visible.rect != work.rect || visible.ch != work.ch { + return effect(work, fp); + } + effect(visible, fp); + let r = fp.rect.intersect(&work.rect); + for y in r.y0..r.y1 { + for x in r.x0..r.x1 { + if fp.at(x, y) > 0.0 { + work.px_mut(x, y).copy_from_slice(visible.px(x, y)); + } + } + } + } +} + /// Blur (`sharpen = false`) or Sharpen each dab's footprint in the working copy. fn focus_cmd(s: &mut Session, p: &Value, sharpen: bool) -> Result { let cmd = if sharpen { "paint.sharpen" } else { "paint.blur" }; @@ -514,11 +576,13 @@ fn focus_cmd(s: &mut Session, p: &Value, sharpen: bool) -> Result { let sigma = (stroke.brush.size * 0.03).clamp(1.0, 4.0); let halo = (sigma * 3.0).ceil() as i32 + 1; let label = if sharpen { "Sharpen Tool" } else { "Blur Tool" }; - let dmg = run_stroke(s, label, id, |_, surf, sel, lock| { + let all = sample_all_layers(p); + let dmg = run_stroke(s, label, id, p, |pre, surf, sel, lock| { let fmt = surf.format(); let a = alpha_index(&fmt); let sp = stroke.brush.spacing; - Ok(apply_dab_stroke(surf, &stroke, sel, lock, halo, |work, fp| { + let mut sampler = all.then(|| AllLayers::new(pre, fmt)); + let focus = |work: &mut Region, fp: &Footprint| { let r = fp.rect.intersect(&work.rect); if r.is_empty() { return; @@ -540,6 +604,10 @@ fn focus_cmd(s: &mut Session, p: &Value, sharpen: bool) -> Result { } } } + }; + Ok(apply_dab_stroke(surf, &stroke, sel, lock, halo, |work, fp| match &mut sampler { + Some(all) => all.dab(work, fp, focus), + None => focus(work, fp), })) })?; Ok(json!({ "damage": damage_json(dmg) })) @@ -551,12 +619,18 @@ fn smudge_cmd(s: &mut Session, p: &Value) -> Result { let strength = num(p, "strength", 50.0).clamp(1.0, 100.0) / 100.0; let finger = flag(p, "fingerPainting", false); let fg = s.tools.foreground; - let dmg = run_stroke(s, "Smudge Tool", id, |_, surf, sel, lock| { + let all = sample_all_layers(p); + let dmg = run_stroke(s, "Smudge Tool", id, p, |pre, surf, sel, lock| { let fmt = surf.format(); let finger_px = finger.then(|| photocraft_raster::from_rgba(&fmt, fg)); let max_size = stroke.brush.size; - let mut sm = Smudge::new(strength, finger_px, max_size); - Ok(apply_dab_stroke(surf, &stroke, sel, lock, 0, |work, fp| sm.dab(work, fp))) + // Premultiplied mixing: transparent pixels carry no colour, so no dark fringes. + let mut sm = Smudge::new(strength, finger_px, max_size).with_alpha(alpha_index(&fmt)); + let mut sampler = all.then(|| AllLayers::new(pre, fmt)); + Ok(apply_dab_stroke(surf, &stroke, sel, lock, 0, |work, fp| match &mut sampler { + Some(all) => all.dab(work, fp, |w, f| sm.dab(w, f)), + None => sm.dab(work, fp), + })) })?; Ok(json!({ "damage": damage_json(dmg) })) } @@ -568,7 +642,7 @@ fn smudge_cmd(s: &mut Session, p: &Value) -> Result { /// The brush every retouching command shares, plus the tool's own params. macro_rules! brush_params { ($extra:literal) => { - concat!("{", r#""points":[[x,y,pressure?],…],"size":px=tool size,"hardness":0..100=tool hardness,"opacity":1..100=100,"flow":1..100=100,"spacing":1..1000 (% of size)=25,"layer":id?=active"#, $extra, "}") + concat!("{", r#""points":[[x,y,pressure?],…],"size":px=tool size,"hardness":0..100=tool hardness,"opacity":1..100=100,"flow":1..100=100,"spacing":1..1000 (% of size)=25,"layer":id?=active,"target":"pixels"|"mask"|"quickMask"|{"channel":i}=Channels panel target"#, $extra, "}") }; } @@ -644,7 +718,7 @@ pub fn specs() -> Vec { label: "Blur", menu: &[], shortcut: None, - params: brush_params!(r#","strength":1..100=50"#), + params: brush_params!(r#","strength":1..100=50,"sampleAllLayers":bool=false"#), enabled: has_pixel_layer, run: |s, p| focus_cmd(s, p, false), journal: true, @@ -654,7 +728,7 @@ pub fn specs() -> Vec { label: "Sharpen", menu: &[], shortcut: None, - params: brush_params!(r#","strength":1..100=50,"protectDetail":bool=true"#), + params: brush_params!(r#","strength":1..100=50,"protectDetail":bool=true,"sampleAllLayers":bool=false"#), enabled: has_pixel_layer, run: |s, p| focus_cmd(s, p, true), journal: true, @@ -664,7 +738,7 @@ pub fn specs() -> Vec { label: "Smudge", menu: &[], shortcut: None, - params: brush_params!(r#","strength":1..100=50,"fingerPainting":bool=false (starts with the foreground colour)"#), + params: brush_params!(r#","strength":1..100=50,"fingerPainting":bool=false (starts with the foreground colour),"sampleAllLayers":bool=false"#), enabled: has_pixel_layer, run: smudge_cmd, journal: true, diff --git a/crates/engine/src/retouch_cmds/tests.rs b/crates/engine/src/retouch_cmds/tests.rs index d45bdb85d9..d1cda7dce1 100644 --- a/crates/engine/src/retouch_cmds/tests.rs +++ b/crates/engine/src/retouch_cmds/tests.rs @@ -305,3 +305,236 @@ fn retouch_commands_are_registered_and_need_a_pixel_layer() { assert!(s.execute("paint.dodge", json!({"points": [[1, 1]], "range": "bogus"})).is_err()); assert!(s.execute("paint.spotHealing", json!({"points": [[1, 1]], "type": "bogus"})).is_err()); } + +// --------------------------------------------------------------------------------------------- +// Paint target (#207): mask, alpha channel, Quick Mask +// --------------------------------------------------------------------------------------------- + +const RETOUCH_IDS: [&str; 10] = [ + "paint.cloneStamp", + "paint.healingBrush", + "paint.spotHealing", + "paint.dodge", + "paint.burn", + "paint.sponge", + "paint.blur", + "paint.sharpen", + "paint.smudge", + "paint.historyBrush", +]; + +/// Grey noise in 0.3..0.7 (no period along the clone offset used below). +fn gray_noise(x: i32, y: i32) -> f32 { + 0.3 + ((x * 7 + y * 13).rem_euclid(10)) as f32 * 0.04 +} + +fn texture_surface(surf: &mut Surface, b: Rect) { + let mut data = Vec::new(); + for y in b.y0..b.y1 { + for x in b.x0..b.x1 { + data.push(gray_noise(x, y)); + } + } + surf.write_region(b, &data); +} + +fn active_layer(s: &Session) -> &photocraft_doc::Layer { + let d = s.active().unwrap(); + d.doc.layer(d.active_layer.unwrap()).unwrap() +} + +fn read_all(surf: &Surface, b: Rect) -> Vec { + surf.read_region(b) +} + +/// A textured pixel layer with a textured mask and a textured alpha channel. Returns the history +/// index of the state before the mask and channel were textured (for the History Brush). +fn targeted_session() -> (Session, usize) { + let mut s = session(60, 40, 8, "rgb"); + s.execute("layer.new.layer", json!({})).unwrap(); + paint_layer(&mut s, texture); + s.execute("layer.layerMask.revealAll", json!({})).unwrap(); + s.execute("channel.new", json!({"fill": "white"})).unwrap(); + let before = s.active().unwrap().history.past_len(); + s.edit("texture", |doc, active| { + let b = doc.bounds(); + texture_surface(&mut doc.layer_mut(active.unwrap()).unwrap().mask.as_mut().unwrap().surface, b); + texture_surface(&mut doc.channels[0].surface, b); + Ok(()) + }) + .unwrap(); + (s, before) +} + +fn retouch_params(id: &str, history_state: usize, target: Value) -> Value { + let mut p = json!({"points": [[20, 20], [40, 20]], "size": 12, "hardness": 100, "strength": 100, "exposure": 100, "target": target}); + match id { + "paint.cloneStamp" | "paint.healingBrush" => p["offset"] = json!([-13, 3]), + "paint.historyBrush" => p["state"] = json!(history_state), + "paint.sponge" => p["mode"] = json!("saturate"), + _ => {} + } + p +} + +/// Tools whose stroke must visibly change grey noise (Sponge has no colour to change on grey, and +/// healing a noise texture with itself may land on the same values). +fn changes_gray(id: &str) -> bool { + !matches!(id, "paint.sponge" | "paint.healingBrush" | "paint.spotHealing") +} + +#[test] +fn retouch_tools_paint_only_the_targeted_mask() { + for id in RETOUCH_IDS { + let (mut s, state) = targeted_session(); + let b = s.active().unwrap().doc.bounds(); + let px0 = read_all(active_layer(&s).surface().unwrap(), b); + let mask0 = read_all(&active_layer(&s).mask.as_ref().unwrap().surface, b); + let ch0 = read_all(&s.active().unwrap().doc.channels[0].surface, b); + s.execute(id, retouch_params(id, state, json!("mask"))).unwrap_or_else(|e| panic!("{id}: {e}")); + assert_eq!(read_all(active_layer(&s).surface().unwrap(), b), px0, "{id}: layer pixels untouched"); + assert_eq!(read_all(&s.active().unwrap().doc.channels[0].surface, b), ch0, "{id}: channel untouched"); + if changes_gray(id) { + assert_ne!(read_all(&active_layer(&s).mask.as_ref().unwrap().surface, b), mask0, "{id}: the mask changed"); + } + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(read_all(&active_layer(&s).mask.as_ref().unwrap().surface, b), mask0, "{id}: one undo step"); + } +} + +#[test] +fn retouch_tools_paint_only_the_targeted_alpha_channel() { + for id in RETOUCH_IDS { + let (mut s, state) = targeted_session(); + let b = s.active().unwrap().doc.bounds(); + let px0 = read_all(active_layer(&s).surface().unwrap(), b); + let mask0 = read_all(&active_layer(&s).mask.as_ref().unwrap().surface, b); + let ch0 = read_all(&s.active().unwrap().doc.channels[0].surface, b); + s.execute(id, retouch_params(id, state, json!({"channel": 0}))).unwrap_or_else(|e| panic!("{id}: {e}")); + assert_eq!(read_all(active_layer(&s).surface().unwrap(), b), px0, "{id}: layer pixels untouched"); + assert_eq!(read_all(&active_layer(&s).mask.as_ref().unwrap().surface, b), mask0, "{id}: mask untouched"); + if changes_gray(id) { + assert_ne!(read_all(&s.active().unwrap().doc.channels[0].surface, b), ch0, "{id}: the channel changed"); + } + } +} + +#[test] +fn retouch_tools_follow_the_channels_panel_target() { + // No "target" param: the targeted alpha channel (Channels panel) is painted, like the Brush. + for id in RETOUCH_IDS.into_iter().filter(|id| changes_gray(id)) { + let (mut s, state) = targeted_session(); + let b = s.active().unwrap().doc.bounds(); + let px0 = read_all(active_layer(&s).surface().unwrap(), b); + let ch0 = read_all(&s.active().unwrap().doc.channels[0].surface, b); + s.active_mut().unwrap().channel_view.target = crate::channel_cmds::ChannelTarget::Alpha(0); + let mut p = retouch_params(id, state, Value::Null); + p.as_object_mut().unwrap().remove("target"); + assert!(s.is_enabled(id), "{id}"); + s.execute(id, p).unwrap_or_else(|e| panic!("{id}: {e}")); + assert_eq!(read_all(active_layer(&s).surface().unwrap(), b), px0, "{id}: layer pixels untouched"); + assert_ne!(read_all(&s.active().unwrap().doc.channels[0].surface, b), ch0, "{id}: the channel changed"); + } +} + +#[test] +fn retouch_tools_paint_the_quick_mask() { + let (mut s, _) = targeted_session(); + s.execute("select.rect", json!({"x": 0, "y": 0, "width": 30, "height": 40})).unwrap(); + s.execute("select.editInQuickMaskMode", json!({"on": true})).unwrap(); + let b = s.active().unwrap().doc.bounds(); + let px0 = read_all(active_layer(&s).surface().unwrap(), b); + let q0 = read_all(&s.active().unwrap().doc.quick_mask.as_ref().unwrap().surface, b); + s.execute("paint.smudge", json!({"points": [[20, 20], [45, 20]], "size": 12, "hardness": 100, "strength": 90})).unwrap(); + assert_eq!(read_all(active_layer(&s).surface().unwrap(), b), px0, "layer pixels untouched"); + assert_ne!(read_all(&s.active().unwrap().doc.quick_mask.as_ref().unwrap().surface, b), q0, "the Quick Mask changed"); +} + +#[test] +fn retouch_targets_fail_gracefully() { + let mut s = session(30, 30, 8, "rgb"); + for id in RETOUCH_IDS { + let p = |t: Value| json!({"points": [[5, 5], [10, 5]], "size": 6, "state": 0, "offset": [1, 1], "target": t}); + assert!(s.execute(id, p(json!("mask"))).is_err(), "{id}: no mask"); + assert!(s.execute(id, p(json!({"channel": 7}))).is_err(), "{id}: no channel 7"); + assert!(s.execute(id, p(json!("quickMask"))).is_err(), "{id}: not in Quick Mask mode"); + let _ = s.execute(id, p(json!(42))); + let _ = s.execute(id, p(json!({"channel": "x"}))); + let _ = s.execute(id, json!({"points": [[5, 5]], "sampleAllLayers": "yes"})); + } +} + +// --------------------------------------------------------------------------------------------- +// Sample All Layers (#207) +// --------------------------------------------------------------------------------------------- + +/// A Background with `bottom` and an empty layer above it (active). +fn two_layers(bottom: impl Fn(i32, i32) -> [f32; 4]) -> Session { + let mut s = session(100, 30, 8, "rgb"); + paint_layer(&mut s, bottom); + s.execute("layer.new.layer", json!({})).unwrap(); + s +} + +#[test] +fn sample_all_layers_blur_and_sharpen_work_on_an_empty_layer() { + let checker = |x: i32, y: i32| if (x + y) % 2 == 0 { [0.4, 0.4, 0.4, 1.0] } else { [0.6, 0.6, 0.6, 1.0] }; + for (id, extra) in [("paint.blur", json!({})), ("paint.sharpen", json!({"protectDetail": false}))] { + let stroke = |all: bool| { + let mut p = json!({"points": [[10, 15], [40, 15]], "size": 16, "hardness": 100, "strength": 100, "sampleAllLayers": all}); + p.as_object_mut().unwrap().extend(extra.as_object().unwrap().clone()); + p + }; + // Off: the empty layer has nothing to blur. + let mut s = two_layers(checker); + s.execute(id, stroke(false)).unwrap(); + assert_eq!(rgba(&s, 25, 15)[3], 0.0, "{id}: without Sample All Layers the layer stays empty"); + // On: the layer picks up the blurred / sharpened picture. + let mut s = two_layers(checker); + s.execute(id, stroke(true)).unwrap(); + let (a, b) = (rgba(&s, 25, 15), rgba(&s, 26, 15)); + assert!(a[3] > 0.99, "{id}: {a:?}"); + let contrast = (a[0] - b[0]).abs(); + if id == "paint.blur" { + assert!(contrast < 0.1, "{id}: blurred ({contrast})"); + } else { + assert!(contrast > 0.25, "{id}: sharpened ({contrast})"); + } + assert_eq!(rgba(&s, 80, 15)[3], 0.0, "{id}: outside the stroke untouched"); + } +} + +#[test] +fn sample_all_layers_smudge_drags_colour_from_below() { + let edge = |x: i32, _: i32| if x < 30 { [1.0, 0.0, 0.0, 1.0] } else { [1.0, 1.0, 1.0, 1.0] }; + let stroke = |all: bool| json!({"points": [[20, 15], [70, 15]], "size": 12, "hardness": 100, "strength": 80, "sampleAllLayers": all}); + let mut s = two_layers(edge); + s.execute("paint.smudge", stroke(false)).unwrap(); + assert_eq!(rgba(&s, 40, 15)[3], 0.0, "without Sample All Layers the layer stays empty"); + let mut s = two_layers(edge); + s.execute("paint.smudge", stroke(true)).unwrap(); + let p = rgba(&s, 36, 15); + assert!(p[3] > 0.99 && p[1] < 0.6, "red dragged onto the empty layer: {p:?}"); + // A mask target ignores Sample All Layers (nothing to sample from other layers). + s.execute("layer.layerMask.revealAll", json!({})).unwrap(); + s.execute("paint.smudge", json!({"points": [[20, 25], [70, 25]], "size": 6, "sampleAllLayers": true, "target": "mask"})).unwrap(); +} + +#[test] +fn smudge_across_a_transparent_edge_has_no_dark_fringe() { + for depth in DEPTHS { + let mut s = session(100, 30, depth, "rgb"); + s.execute("layer.new.layer", json!({})).unwrap(); + paint_layer(&mut s, |x, _| if x < 30 { [1.0, 0.8, 0.0, 1.0] } else { [0.0, 0.0, 0.0, 0.0] }); + s.execute("paint.smudge", json!({"points": [[20, 15], [70, 15]], "size": 12, "hardness": 50, "strength": 80})).unwrap(); + let mut n = 0; + for x in 30..80 { + let p = rgba(&s, x, 15); + if p[3] > 0.02 { + n += 1; + assert!(p[0] > 0.97 && (p[1] - 0.8).abs() < 0.03, "depth {depth} x {x}: {p:?}"); + } + } + assert!(n > 5, "depth {depth}: colour dragged into the transparent area"); + } +} diff --git a/crates/paint/src/retouch.rs b/crates/paint/src/retouch.rs index a0da053b00..9e316d106f 100644 --- a/crates/paint/src/retouch.rs +++ b/crates/paint/src/retouch.rs @@ -256,11 +256,17 @@ pub fn apply_dab_stroke( let wv = work.px(x, y); let i = out.index(x, y) * n; let o = &mut out.data[i..i + n]; - for c in 0..n { - if lock_transparency && Some(c) == a { - continue; + match a { + // Alpha changes: mix premultiplied, so colour under transparent pixels doesn't bleed in. + Some(a) if !lock_transparency => mix_premultiplied(o, wv, a, k), + _ => { + for c in 0..n { + if lock_transparency && Some(c) == a { + continue; + } + o[c] += (wv[c] - o[c]) * k; + } } - o[c] += (wv[c] - o[c]) * k; } } } @@ -268,6 +274,23 @@ pub fn apply_dab_stroke( bounds } +/// `dst ← lerp(dst, src, k)` in premultiplied space (straight in, straight out; `a` = alpha index). +/// Where the result is fully transparent the colour is mixed straight, so it stays defined. +#[inline] +pub fn mix_premultiplied(dst: &mut [f32], src: &[f32], a: usize, k: f32) { + let (Some(&da), Some(&sa)) = (dst.get(a), src.get(a)) else { return }; + let oa = da + (sa - da) * k; + for (c, (d, s)) in dst.iter_mut().zip(src).enumerate() { + if c == a { + continue; + } + *d = if oa > 1e-6 { (*d * da + (*s * sa - *d * da) * k) / oa } else { *d + (*s - *d) * k }; + } + if let Some(d) = dst.get_mut(a) { + *d = oa; + } +} + /// Smudge state: a colour buffer carried from dab to dab (centred on the dab), which is laid down at /// `strength` and then re-picks-up the pixels under the dab. Use with [`apply_dab_stroke`]. #[derive(Clone, Debug)] @@ -278,12 +301,22 @@ pub struct Smudge { /// the first dab. pub finger: Option>, radius: i32, + /// Alpha channel index: when set, the carried colour is premultiplied, so smudging into or out + /// of transparency never drags in the (meaningless) colour of transparent pixels. + alpha: Option, + /// Carried colour (premultiplied when `alpha` is set). carry: Option>, } impl Smudge { pub fn new(strength: f32, finger: Option>, max_brush_size: f32) -> Self { - Self { strength: strength.clamp(0.0, 1.0), finger, radius: (max_brush_size / 2.0).ceil() as i32 + 2, carry: None } + Self { strength: strength.clamp(0.0, 1.0), finger, radius: (max_brush_size / 2.0).ceil() as i32 + 2, alpha: None, carry: None } + } + + /// Mix in premultiplied space using alpha channel `a` (the format's [`alpha_index`]). + pub fn with_alpha(mut self, a: Option) -> Self { + self.alpha = a; + self } /// Apply one dab to the working copy. @@ -292,13 +325,23 @@ impl Smudge { let side = (2 * r + 1) as usize; let (cx, cy) = (fp.dab.center.x.floor() as i32, fp.dab.center.y.floor() as i32); let wr = work.rect; - let clamp_px = |work: &Region, x: i32, y: i32| -> Vec { work.px(x.clamp(wr.x0, wr.x1 - 1), y.clamp(wr.y0, wr.y1 - 1)).to_vec() }; + let alpha = self.alpha.filter(|a| *a < n); + let clamp_px = |work: &Region, x: i32, y: i32| -> Vec { + let mut v = work.px(x.clamp(wr.x0, wr.x1 - 1), y.clamp(wr.y0, wr.y1 - 1)).to_vec(); + premultiply(&mut v, alpha); + v + }; let carry = self.carry.get_or_insert_with(|| { let mut c = vec![0.0f32; side * side * n]; for qy in 0..side { for qx in 0..side { let v = match &self.finger { - Some(f) => f.clone(), + Some(f) => { + let mut f = f.clone(); + f.resize(n, 1.0); + premultiply(&mut f, alpha); + f + } None => clamp_px(work, cx + qx as i32 - r, cy + qy as i32 - r), }; c[(qy * side + qx) * n..(qy * side + qx + 1) * n].copy_from_slice(&v[..n]); @@ -316,8 +359,28 @@ impl Smudge { } let q = (qy as usize * side + qx as usize) * n; let px = work.px_mut(x, y); - for c in 0..n { - px[c] += (carry[q + c] - px[c]) * k; + match alpha { + Some(a) => { + // Back to straight for the mix (premultiplied lerp towards the carried colour). + let ca = carry[q + a]; + let mut buf = [0.0f32; 16]; + let m = n.min(buf.len()); + buf[..m].copy_from_slice(&carry[q..q + m]); + let src = &mut buf[..m]; + if ca > 1e-6 { + for (c, v) in src.iter_mut().enumerate() { + if c != a { + *v /= ca; + } + } + } + mix_premultiplied(px, src, a, k); + } + None => { + for c in 0..n { + px[c] += (carry[q + c] - px[c]) * k; + } + } } } } @@ -331,14 +394,26 @@ impl Smudge { } let q = (qy * side + qx) * n; let px = work.px(x, y); + let pa = alpha.map_or(1.0, |a| px[a]); for c in 0..n { - carry[q + c] = carry[q + c] * keep + px[c] * (1.0 - keep); + let v = if alpha.is_some_and(|a| a != c) { px[c] * pa } else { px[c] }; + carry[q + c] = carry[q + c] * keep + v * (1.0 - keep); } } } } } +/// Premultiply a straight pixel in place (no-op without alpha). +fn premultiply(px: &mut [f32], alpha: Option) { + let Some(pa) = alpha.and_then(|a| px.get(a).copied()) else { return }; + for (c, v) in px.iter_mut().enumerate() { + if Some(c) != alpha { + *v *= pa; + } + } +} + #[cfg(test)] mod tests { use super::*; @@ -425,4 +500,50 @@ mod tests { assert!(s2.pixel(22, 20)[0] < 0.5); } } + + #[test] + fn smudge_into_transparency_leaves_no_dark_fringe() { + // #207: straight mixing dragged the black of transparent pixels into the colour. + for fmt in [PixelFormat::RGBA8, PixelFormat::RGBA16, PixelFormat::RGBA32F] { + let mut s = Surface::new(fmt); + s.fill_rect(Rect::new(0, 0, 30, 40), &[1.0, 0.8, 0.0, 1.0]); + let st = stroke(&[(20.0, 20.0), (70.0, 20.0)], 12.0, 0.5); + let mut sm = Smudge::new(0.8, None, 12.0).with_alpha(Some(3)); + apply_dab_stroke(&mut s, &st, None, false, 0, |w, fp| sm.dab(w, fp)); + let mut smudged = 0; + for x in 30..80 { + for y in 14..27 { + let p = s.rgba(x, y); + if p[3] > 0.02 { + smudged += 1; + assert!(p[0] > 0.97 && (p[1] - 0.8).abs() < 0.03, "{fmt:?} ({x},{y}): {p:?} darkened"); + } + } + } + assert!(smudged > 50, "{fmt:?}: colour was dragged into the transparent area"); + // And at partial opacity the mix back is premultiplied too. + let mut s = Surface::new(fmt); + s.fill_rect(Rect::new(0, 0, 30, 40), &[1.0, 0.8, 0.0, 1.0]); + let mut st = st.clone(); + st.brush.opacity = 0.5; + let mut sm = Smudge::new(0.8, None, 12.0).with_alpha(Some(3)); + apply_dab_stroke(&mut s, &st, None, false, 0, |w, fp| sm.dab(w, fp)); + let p = s.rgba(34, 20); + assert!(p[3] > 0.02 && p[0] > 0.97, "{fmt:?}: {p:?}"); + } + } + + #[test] + fn premultiplied_mix_ignores_colour_of_transparent_pixels() { + let mut d = [0.0, 0.0, 0.0, 0.0]; + mix_premultiplied(&mut d, &[1.0, 0.5, 0.0, 1.0], 3, 0.25); + assert_eq!(d, [1.0, 0.5, 0.0, 0.25]); + let mut d = [0.2, 0.2, 0.2, 1.0]; + mix_premultiplied(&mut d, &[0.0, 0.0, 0.0, 0.0], 3, 1.0); + assert_eq!(d[3], 0.0); + // Out-of-range alpha index: untouched, no panic. + let mut d = [0.5, 0.5]; + mix_premultiplied(&mut d, &[1.0, 1.0], 7, 0.5); + assert_eq!(d, [0.5, 0.5]); + } } diff --git a/crates/ui-egui/src/retouch_ui.rs b/crates/ui-egui/src/retouch_ui.rs index 467d33ad45..93f8cba752 100644 --- a/crates/ui-egui/src/retouch_ui.rs +++ b/crates/ui-egui/src/retouch_ui.rs @@ -34,9 +34,9 @@ pub fn finish_stroke(app: &mut PhotocraftApp, tool: Tool, points: &[[f64; 3]], m (if tool == Tool::Healing { "paint.healingBrush" } else { "paint.cloneStamp" }, p) } Tool::HistoryBrush => ("paint.historyBrush", json!({})), - Tool::Blur => ("paint.blur", json!({"strength": o.strength})), - Tool::Sharpen => ("paint.sharpen", json!({"strength": o.strength, "protectDetail": o.protect_detail})), - Tool::Smudge => ("paint.smudge", json!({"strength": o.strength, "fingerPainting": o.finger_painting})), + Tool::Blur => ("paint.blur", json!({"strength": o.strength, "sampleAllLayers": o.sample_all_layers})), + Tool::Sharpen => ("paint.sharpen", json!({"strength": o.strength, "protectDetail": o.protect_detail, "sampleAllLayers": o.sample_all_layers})), + Tool::Smudge => ("paint.smudge", json!({"strength": o.strength, "fingerPainting": o.finger_painting, "sampleAllLayers": o.sample_all_layers})), Tool::Dodge | Tool::Burn => ( if tool == Tool::Dodge { "paint.dodge" } else { "paint.burn" }, json!({"range": o.tone_range, "exposure": o.exposure, "protectTones": o.protect_tones}), @@ -53,6 +53,8 @@ pub fn finish_stroke(app: &mut PhotocraftApp, tool: Tool, points: &[[f64; 3]], m _ => return false, }; p["points"] = pts; + // The layer mask, an alpha channel or the Quick Mask when targeted, as the Brush paints. + p["target"] = crate::canvas::paint_target(app); match app.run(cmd, p) { Ok(r) if matches!(tool, Tool::Healing | Tool::CloneStamp) => { // Aligned: keep the offset for later strokes; non-aligned: every stroke restarts at the source. @@ -198,3 +200,72 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui, tool: Tool) -> bo } true } + +#[cfg(test)] +mod tests { + use super::*; + use crate::canvas::{ToolEvent, tool_event}; + + fn app() -> PhotocraftApp { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 100, "height": 60})).unwrap(); + app.run("tools.setBrush", json!({"brush": {"size": 16, "hardness": 1.0}})).unwrap(); + app + } + + fn drag(app: &mut PhotocraftApp, tool: Tool) { + app.ui.tool = tool; + let m = egui::Modifiers::NONE; + tool_event(app, ToolEvent::Down { x: 10.0, y: 30.0, pressure: 1.0 }, m); + tool_event(app, ToolEvent::Move { x: 50.0, y: 30.0, pressure: 1.0 }, m); + tool_event(app, ToolEvent::Up { x: 50.0, y: 30.0 }, m); + assert!(!app.ui.status_error, "{tool:?}: {}", app.ui.status); + } + + fn active(app: &PhotocraftApp) -> &photocraft_doc::Layer { + let st = app.session.active().unwrap(); + st.active_layer.and_then(|id| st.doc.layer(id)).unwrap() + } + + fn stripes(app: &mut PhotocraftApp, step: usize, target: &str) { + for x in (0..100).step_by(step) { + app.run("paint.pencil", json!({"points": [[x, 0], [x, 60]], "size": 2, "color": "#606060", "target": target})).unwrap(); + } + } + + #[test] + fn retouch_strokes_paint_the_targeted_mask() { + // #207: with the mask targeted, retouching changes the mask, never the pixels. + for tool in [Tool::Blur, Tool::Sharpen, Tool::Smudge, Tool::Dodge, Tool::Burn] { + let mut app = app(); + app.run("layer.new.layer", json!({})).unwrap(); + stripes(&mut app, 4, "pixels"); + app.run("layer.layerMask.revealAll", json!({})).unwrap(); + stripes(&mut app, 6, "mask"); + app.ui.mask_target = true; + app.ui.tool_options.protect_detail = false; + let b = app.session.active().unwrap().doc.bounds(); + let px0 = active(&app).surface().unwrap().read_region(b); + let mask0 = active(&app).mask.as_ref().unwrap().surface.read_region(b); + drag(&mut app, tool); + assert_eq!(active(&app).surface().unwrap().read_region(b), px0, "{tool:?}: pixels untouched"); + assert_ne!(active(&app).mask.as_ref().unwrap().surface.read_region(b), mask0, "{tool:?}: mask changed"); + } + } + + #[test] + fn sample_all_layers_reaches_blur_sharpen_and_smudge() { + // #207: the options-bar checkbox reaches the command. + for tool in [Tool::Blur, Tool::Sharpen, Tool::Smudge] { + for all in [false, true] { + let mut app = app(); + stripes(&mut app, 4, "pixels"); + app.run("layer.new.layer", json!({})).unwrap(); + app.ui.tool_options.sample_all_layers = all; + drag(&mut app, tool); + let a = active(&app).surface().unwrap().rgba(30, 30)[3]; + assert_eq!(a > 0.0, all, "{tool:?} sampleAllLayers={all}: alpha {a}"); + } + } + } +}