Retouch tools paint the mask/channel target; Sample All Layers for Blur/Sharpen/Smudge; premultiplied Smudge (#207) (#234) (#234)

This commit is contained in:
Brandon Thomas authored and GitHub committed 2026-10-05 11:26:20 -04:00
1 parent 866c33f7c1
commit e2444ceb68
5 files changed
+563 -51

No files matched your search

+13
View File
@@ -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.
+112 -38
View File
@@ -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<String>) -> 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<LayerId>)> {
let pts: Vec<StrokePoint> = 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<Rect>) -> Result<Rect> {
/// 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<LayerId>,
p: &Value,
f: impl FnOnce(&Document, &mut Surface, Option<&Surface>, bool) -> Result<Rect>,
) -> Result<Rect> {
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<SampleLayers> {
/// 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<LayerId>, 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<usize>, 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<LayerId>, 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<LayerId>, 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<Value> {
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<Value> {
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<Value> {
// 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<Value> {
"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<Value> {
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<Value> {
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<Value> {
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<Region>,
}
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<Value> {
let cmd = if sharpen { "paint.sharpen" } else { "paint.blur" };
@@ -514,11 +576,13 @@ fn focus_cmd(s: &mut Session, p: &Value, sharpen: bool) -> Result<Value> {
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<Value> {
}
}
}
};
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<Value> {
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<Value> {
/// 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<CommandSpec> {
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<CommandSpec> {
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<CommandSpec> {
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,
+233
View File
@@ -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<f32> {
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");
}
}
+131 -10
View File
@@ -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<Vec<f32>>,
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<usize>,
/// Carried colour (premultiplied when `alpha` is set).
carry: Option<Vec<f32>>,
}
impl Smudge {
pub fn new(strength: f32, finger: Option<Vec<f32>>, 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<usize>) -> 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<f32> { 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<f32> {
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<usize>) {
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]);
}
}
+74 -3
View File
@@ -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}");
}
}
}
}