mirror of
https://github.com/storytold/photocraft.git
synced 2026-10-14 19:26:51 -04:00
Retouch tools paint the mask/channel target; Sample All Layers for Blur/Sharpen/Smudge; premultiplied Smudge (#207) (#234) (#234)
This commit is contained in:
1 parent
866c33f7c1
commit
e2444ceb68
5 files changed
+563
-51
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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]);
|
||||
}
|
||||
}
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user