mirror of
https://github.com/storytold/photocraft.git
synced 2026-10-27 17:51:59 +01:00
- Each tile still copied a source window of (tile +- halo) clipped to the dab bounds, but a rotated read can land up to a dab diameter away: a leftward drag [[110,40],[20,40]] at size 40 read empty pixels at x = 64 (seam), and a large click still copied about the whole dab per tile. The stamp now samples the history state directly (no window, no StampParams::halo), and bilinear reads at pixel centres, so an unrotated dab copies the history pixel instead of a 2x2 blend. - Tolerance compared colour only, so an erased patch (the Eraser keeps the colour, lowers alpha 1 -> 0.6) was skipped at tolerance 25: the difference is now the larger of the mean colour change and the alpha change. - The brush had its own history lookup (row 0 unless `state` was passed): it now uses the History Brush's source (history.setSource, snapshots, redo rows, refusal after a canvas size change). - A stroke that wrote nothing at tolerance 0 (wholly outside the selection, or on Lock Transparency over transparent pixels) failed with "no stroke points"; it is now the same quiet no-op as a stroke under the tolerance (damage null, no History step). - A mode other than Normal (agents only: the options bar sends none) blended each dab with the already-mixed result at full strength, so Multiply with a white source over 0.5 grey fell to 0.33 five pixels from a dab's centre instead of staying 0.5. Other modes are refused until the stroke is buffered, and the wrong path is removed. - TOOL-213-20 is partial (8 of 10 styles, Area scales spacing); docs follow. Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
1710 lines
78 KiB
Rust
1710 lines
78 KiB
Rust
//! Retouching tools as commands: Clone Stamp, Pattern Stamp, Healing Brush, Spot Healing Brush,
|
||
//! Patch, Content-Aware Move, Remove, Dodge, Burn, Sponge, Blur, Sharpen, Smudge, History Brush and
|
||
//! Art History Brush.
|
||
//!
|
||
//! 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. 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.
|
||
|
||
use photocraft_algo::inpaint::{self, CompleteParams};
|
||
use photocraft_algo::poisson;
|
||
use photocraft_algo::retouch::{ToneRange, dodge_burn, local_blur, local_sharpen, sponge};
|
||
use photocraft_color::{BlendMode, PixelFormat, SampleType};
|
||
use photocraft_doc::{Document, LayerContent, LayerId};
|
||
use photocraft_geom::Rect;
|
||
use photocraft_paint::retouch::{Footprint, Region, Smudge, alpha_index, apply_coverage, apply_dab_stroke};
|
||
use photocraft_paint::{BrushSettings, Stroke, StrokePoint};
|
||
use photocraft_raster::{Surface, from_rgba_into, to_rgba};
|
||
use rayon::prelude::*;
|
||
use serde_json::{Value, json};
|
||
|
||
use crate::commands::{CommandSpec, blend_from_str};
|
||
use crate::{EngineError, Result, Session};
|
||
|
||
mod art_history;
|
||
mod content_aware_move;
|
||
mod patch;
|
||
mod pattern_stamp;
|
||
pub use pattern_stamp::live_impressionist as live_pattern_impressionist;
|
||
mod periodic;
|
||
mod remove;
|
||
pub use content_aware_move::{ContentAwarePreview, preview as content_aware_move_preview};
|
||
pub use patch::preview as patch_preview;
|
||
|
||
fn bad(cmd: &str, msg: impl Into<String>) -> EngineError {
|
||
EngineError::BadParams { cmd: cmd.into(), msg: msg.into() }
|
||
}
|
||
|
||
/// A single stroke's accumulated coverage must stay at or under this many pixels (8192², so a
|
||
/// stroke corner to corner across a 24–36 MP canvas fits). Larger footprints return an error
|
||
/// instead of allocating.
|
||
const MAX_STROKE_PIXELS: u64 = 67_108_864;
|
||
|
||
/// Refuse a stroke whose footprint would not fit the coverage budget, from the points and brush
|
||
/// size alone, before the renderer allocates anything for it.
|
||
fn reject_huge_stroke(cmd: &str, stroke: &Stroke) -> Result<()> {
|
||
// Dynamics place dabs beyond the input points, so half the tip is not the whole reach:
|
||
// Scattering offsets a centre by up to its amount times the tip size, and a Dual Brush dab
|
||
// reaches its own radius further. Pad by what those can add so this estimate does not fall
|
||
// short of what the renderer will cover. It is only the cheap early-out; the rendered extent
|
||
// is admitted for real in `coverage` below.
|
||
let b = &stroke.brush;
|
||
let size = f64::from(b.size.max(1.0));
|
||
let scatter = if b.scattering.enabled { size * f64::from(b.scattering.scatter.jitter.max(0.0)) } else { 0.0 };
|
||
let dual = if b.dual_brush.enabled { f64::from(b.dual_brush.size.max(0.0)) * 0.5 } else { 0.0 };
|
||
let pad = size.mul_add(0.5, 4.0) + scatter + dual;
|
||
let mut min_x = f64::INFINITY;
|
||
let mut min_y = f64::INFINITY;
|
||
let mut max_x = f64::NEG_INFINITY;
|
||
let mut max_y = f64::NEG_INFINITY;
|
||
for q in &stroke.points {
|
||
min_x = min_x.min(q.x);
|
||
min_y = min_y.min(q.y);
|
||
max_x = max_x.max(q.x);
|
||
max_y = max_y.max(q.y);
|
||
}
|
||
if !min_x.is_finite() || !min_y.is_finite() || !max_x.is_finite() || !max_y.is_finite() {
|
||
return Err(bad(cmd, "point coordinates must be finite"));
|
||
}
|
||
let w = (max_x - min_x + pad * 2.0).ceil().max(1.0);
|
||
let h = (max_y - min_y + pad * 2.0).ceil().max(1.0);
|
||
if !w.is_finite() || !h.is_finite() || w > 1.0e9 || h > 1.0e9 {
|
||
return Err(bad(cmd, format!("stroke too large (at most {MAX_STROKE_PIXELS} px)")));
|
||
}
|
||
let area = (w as u64).saturating_mul(h as u64);
|
||
if area > MAX_STROKE_PIXELS {
|
||
return Err(bad(cmd, format!("stroke too large ({w:.0}×{h:.0} px, at most {MAX_STROKE_PIXELS})")));
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
/// The stroke's coverage, with the **rendered** extent admitted before the dense buffer exists.
|
||
///
|
||
/// `reject_huge_stroke` estimates from the input points; this is the real bound. Rendering is
|
||
/// tiled, so it holds only the tiles the dabs touch; the extent is read from those tiles once
|
||
/// every dab is placed (tips, dynamics and scattering included) and checked against `max`, and
|
||
/// only a stroke that fits is made dense. `max` is a parameter so tests can inject a small budget.
|
||
fn coverage_within(cmd: &str, stroke: &Stroke, max: u64) -> Result<(Rect, Vec<f32>)> {
|
||
photocraft_paint::retouch::stroke_coverage_within(stroke, max).ok_or_else(|| bad(cmd, format!("stroke too large (at most {max} px)")))
|
||
}
|
||
|
||
/// [`coverage_within`] at the shared allowance.
|
||
fn coverage(cmd: &str, stroke: &Stroke) -> Result<(Rect, Vec<f32>)> {
|
||
coverage_within(cmd, stroke, MAX_STROKE_PIXELS)
|
||
}
|
||
|
||
/// 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(_)) || l.mask.is_some() {
|
||
Ok(())
|
||
} else {
|
||
Err(format!("active layer is {} {} layer, not a pixel layer", l.content.article(), 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 {
|
||
p.get(k).and_then(Value::as_f64).map_or(d, |v| v as f32)
|
||
}
|
||
fn flag(p: &Value, k: &str, d: bool) -> bool {
|
||
p.get(k).and_then(Value::as_bool).unwrap_or(d)
|
||
}
|
||
fn string<'a>(p: &'a Value, k: &str, d: &'a str) -> &'a str {
|
||
p.get(k).and_then(Value::as_str).unwrap_or(d)
|
||
}
|
||
fn point(p: &Value, k: &str) -> Option<(f64, f64)> {
|
||
let a = p.get(k)?.as_array()?;
|
||
Some((a.first()?.as_f64()?, a.get(1)?.as_f64()?))
|
||
}
|
||
|
||
/// The brush a retouching stroke paints with: the tool's brush (`base`, its tip, hardness and
|
||
/// shape dynamics) with the shared params of `p` applied. UI previews of a stroke (the Spot Healing
|
||
/// Brush's trail) render their coverage with it, so they match what the command changes.
|
||
pub fn stroke_brush(base: &BrushSettings, p: &Value) -> BrushSettings {
|
||
let pct = |k: &str, d: f32, lo: f32, hi: f32| num(p, k, d).clamp(lo, hi) / 100.0;
|
||
BrushSettings {
|
||
size: num(p, "size", base.size).max(1.0),
|
||
hardness: pct("hardness", base.hardness * 100.0, 0.0, 100.0),
|
||
opacity: pct("opacity", 100.0, 1.0, 100.0),
|
||
flow: pct("flow", 100.0, 1.0, 100.0),
|
||
// Brush Settings › Brush Tip Shape › Spacing, as the Brush uses it (#2140).
|
||
spacing: pct("spacing", base.spacing * 100.0, 1.0, 1000.0),
|
||
erase: false,
|
||
..base.clone()
|
||
}
|
||
}
|
||
|
||
/// 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)
|
||
.ok_or_else(|| bad(cmd, "missing `points`"))?
|
||
.iter()
|
||
.filter_map(|v| {
|
||
let a = v.as_array()?;
|
||
Some(StrokePoint::new(a.first()?.as_f64()?, a.get(1)?.as_f64()?, a.get(2).and_then(Value::as_f64).unwrap_or(1.0) as f32))
|
||
})
|
||
.collect();
|
||
if pts.is_empty() {
|
||
return Err(bad(cmd, "`points` is empty"));
|
||
}
|
||
crate::brush_cmds::check_coords(&pts, cmd)?;
|
||
let brush = stroke_brush(&s.tools.brush, p);
|
||
crate::brush_cmds::validate_brush(&brush, cmd)?;
|
||
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 }, Some(id)))
|
||
}
|
||
|
||
/// 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 (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)
|
||
})?;
|
||
if let Some(st) = s.active_mut() {
|
||
st.last_damage = Some(dmg);
|
||
}
|
||
Ok(dmg)
|
||
}
|
||
|
||
fn damage_json(r: Rect) -> Value {
|
||
json!([r.x0, r.y0, r.width(), r.height()])
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------------------------
|
||
// Sampling sources
|
||
// ---------------------------------------------------------------------------------------------
|
||
|
||
/// Which pixels Clone Stamp / Healing Brush sample.
|
||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||
pub(crate) enum SampleLayers {
|
||
Current,
|
||
CurrentAndBelow,
|
||
All,
|
||
}
|
||
|
||
fn sample_layers(p: &Value, cmd: &str) -> Result<SampleLayers> {
|
||
match string(p, "sampleLayer", "current") {
|
||
"current" => Ok(SampleLayers::Current),
|
||
"currentAndBelow" => Ok(SampleLayers::CurrentAndBelow),
|
||
"all" => Ok(SampleLayers::All),
|
||
o => Err(bad(cmd, format!("unknown sampleLayer `{o}` (current|currentAndBelow|all)"))),
|
||
}
|
||
}
|
||
|
||
/// Composite pixels of `rect` in `fmt` (native channels): the whole document, or the target layer
|
||
/// and everything below it.
|
||
pub(crate) fn composite_region(pre: &Document, id: Option<LayerId>, which: SampleLayers, rect: Rect, fmt: PixelFormat) -> Region {
|
||
let mut doc = pre.clone();
|
||
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) {
|
||
for (path, _) in &walk[pos + 1..] {
|
||
if let Some(l) = doc.layer_at_mut(path) {
|
||
l.visible = false;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
let buf = photocraft_compose::render(&doc, rect);
|
||
let n = fmt.channels();
|
||
let mut out = Region::new(rect, n);
|
||
let mut enc = [0.0f32; 8];
|
||
for (i, px) in buf.px.iter().enumerate() {
|
||
from_rgba_into(&fmt, *px, &mut enc);
|
||
out.data[i * n..(i + 1) * n].copy_from_slice(&enc[..n]);
|
||
}
|
||
out
|
||
}
|
||
|
||
/// Pixels to sample at `rect` (document coordinates, already offset to the source position).
|
||
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()),
|
||
}
|
||
}
|
||
|
||
/// Where a clone stroke samples: `offset` / `source` params, else the active Clone Source slot,
|
||
/// with the slot's (or the call's) scale, rotation and flips (see `presets::clone_source`).
|
||
fn clone_mapping(s: &mut Session, p: &Value, stroke: &Stroke, cmd: &str) -> Result<crate::presets::clone_source::Mapping> {
|
||
let f = stroke.points[0];
|
||
crate::presets::clone_source::mapping(s, p, (f.x, f.y), cmd)
|
||
}
|
||
|
||
/// Source pixels for destination `rect`: a translated read, or a bilinear resample when the
|
||
/// clone source is scaled, rotated or flipped.
|
||
#[allow(clippy::too_many_arguments)]
|
||
fn clone_sample(
|
||
pre: &Document,
|
||
id: Option<LayerId>,
|
||
surf: &Surface,
|
||
which: SampleLayers,
|
||
rect: Rect,
|
||
map: &crate::presets::clone_source::Mapping,
|
||
wrap: bool,
|
||
cmd: &str,
|
||
) -> Result<Region> {
|
||
let read = |rect| {
|
||
if wrap { periodic::sample(pre, id, surf, which, rect, cmd) } else { Ok(sample(pre, id, surf, which, rect)) }
|
||
};
|
||
if map.is_translation() {
|
||
let off = map.offset();
|
||
let src = rect.translate(off.0, off.1);
|
||
// `translate` saturates at the i32 edges, so a far source (issue #1111) yields a window
|
||
// smaller than `rect`; relabelling that as `rect` would index past the sampled pixels.
|
||
if src.width() != rect.width() || src.height() != rect.height() {
|
||
return Err(bad(cmd, format!("source offset [{}, {}] is too far: the sample window leaves the coordinate range", off.0, off.1)));
|
||
}
|
||
let mut r = read(src)?;
|
||
r.rect = rect;
|
||
return Ok(r);
|
||
}
|
||
let src = read(map.source_rect(rect))?;
|
||
Ok(crate::presets::clone_source::resample(&src, rect, map, alpha_index(&surf.format())))
|
||
}
|
||
|
||
/// Read-only clone overlay. Shares the stroke sampler without anchoring a source or editing history.
|
||
/// Both destination and transformed source allocations are bounded for interactive use.
|
||
pub fn clone_preview(s: &Session, rect: Rect, map: &crate::presets::clone_source::Mapping, sample_layer: &str) -> Result<photocraft_compose::Buffer> {
|
||
clone_preview_periodic(s, rect, map, sample_layer, false)
|
||
}
|
||
|
||
/// Clone-source overlay with optional source repetition for View > Pattern Preview.
|
||
pub fn clone_preview_periodic(
|
||
s: &Session,
|
||
rect: Rect,
|
||
map: &crate::presets::clone_source::Mapping,
|
||
sample_layer: &str,
|
||
wrap: bool,
|
||
) -> Result<photocraft_compose::Buffer> {
|
||
if ![map.source.0, map.source.1, map.anchor.0, map.anchor.1].into_iter().chain(map.m).all(|v| v.is_finite() && v.abs() < 1_000_000.0) {
|
||
return Err(bad("clone.preview", "invalid preview mapping"));
|
||
}
|
||
if ![rect.x0, rect.y0, rect.x1, rect.y1].into_iter().all(|v| v.abs_diff(0) < 1_000_000)
|
||
|| ![(rect.x0, rect.y0), (rect.x1, rect.y1), (rect.x0, rect.y1), (rect.x1, rect.y0)].into_iter().all(|(x, y)| {
|
||
let (x, y) = map.map(f64::from(x), f64::from(y));
|
||
x.is_finite() && y.is_finite() && x.abs() < 1_000_000.0 && y.abs() < 1_000_000.0
|
||
})
|
||
{
|
||
return Err(bad("clone.preview", "preview coordinates exceed their budget"));
|
||
}
|
||
let bounded = |r: Rect| r.width() > 0 && r.height() > 0 && r.width() <= 1024 && r.height() <= 1024;
|
||
if !bounded(rect) || !bounded(map.source_rect(rect)) {
|
||
return Err(bad("clone.preview", "preview region exceeds its budget"));
|
||
}
|
||
let d = s.active().ok_or(EngineError::NoDocument)?;
|
||
let id = d.active_layer.ok_or_else(|| bad("clone.preview", "no active layer"))?;
|
||
let surf = d.doc.layer(id).and_then(|l| l.surface()).ok_or_else(|| bad("clone.preview", "no pixel surface"))?;
|
||
let which = sample_layers(&json!({"sampleLayer": sample_layer}), "clone.preview")?;
|
||
let region = clone_sample(&d.doc, Some(id), surf, which, rect, map, wrap, "clone.preview")?;
|
||
let px = region.data.chunks_exact(surf.format().channels()).map(|p| to_rgba(&surf.format(), p)).collect();
|
||
Ok(photocraft_compose::Buffer { rect, px })
|
||
}
|
||
|
||
/// Result JSON shared by Clone Stamp and Healing Brush: the offset used and where the next stroke
|
||
/// should sample from if the UI keeps Photoshop's "Aligned" semantics.
|
||
fn clone_result(dmg: Rect, off: (i32, i32), aligned: bool, p: &Value, stroke: &Stroke) -> Value {
|
||
let f = stroke.points[0];
|
||
let source = point(p, "source").unwrap_or((f.x + off.0 as f64, f.y + off.1 as f64));
|
||
// Aligned: the offset persists (next stroke samples at its own first point + offset).
|
||
// Non-aligned: every stroke restarts at the original source point.
|
||
json!({ "damage": damage_json(dmg), "offset": [off.0, off.1], "aligned": aligned, "nextSource": if aligned { Value::Null } else { json!([source.0, source.1]) } })
|
||
}
|
||
|
||
fn blend_param(p: &Value, cmd: &str) -> Result<BlendMode> {
|
||
match p.get("mode").and_then(Value::as_str) {
|
||
None => Ok(BlendMode::Normal),
|
||
Some(m) => blend_from_str(m).ok_or_else(|| bad(cmd, format!("unknown blend mode `{m}`"))),
|
||
}
|
||
}
|
||
|
||
/// Clamp results into the storable range: integer depths to 0..1, float depths only below at 0;
|
||
/// alpha always 0..1.
|
||
fn clamp_samples(fmt: &PixelFormat, data: &mut [f32]) {
|
||
let n = fmt.channels();
|
||
let a = alpha_index(fmt);
|
||
let float = fmt.sample == SampleType::F32;
|
||
for px in data.chunks_exact_mut(n) {
|
||
for (c, v) in px.iter_mut().enumerate() {
|
||
*v = if Some(c) == a || !float { v.clamp(0.0, 1.0) } else { v.max(0.0) };
|
||
}
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------------------------
|
||
// Clone Stamp / Healing Brush / History Brush
|
||
// ---------------------------------------------------------------------------------------------
|
||
|
||
fn clone_stamp(s: &mut Session, p: &Value) -> Result<Value> {
|
||
const CMD: &str = "paint.cloneStamp";
|
||
let (stroke, id) = parse_brush(s, p, CMD)?;
|
||
reject_huge_stroke(CMD, &stroke)?;
|
||
let which = sample_layers(p, CMD)?;
|
||
let mode = blend_param(p, CMD)?;
|
||
let aligned = flag(p, "aligned", true);
|
||
// The live stroke the canvas already drew for exactly this commit: take its pixels instead of
|
||
// rendering the stroke again (the hitch on release).
|
||
let live = s.live_commit.take().filter(|l| l.matches(s, CMD, p, &stroke.points));
|
||
let from_live = live.is_some();
|
||
let map = clone_mapping(s, p, &stroke, CMD)?;
|
||
let off = map.offset();
|
||
let dmg =
|
||
run_stroke(s, "Clone Stamp", id, p, |pre, surf, sel, lock| {
|
||
if let Some(l) = live {
|
||
*surf = l.surface;
|
||
return Ok(l.bounds);
|
||
}
|
||
if flag(p, "wrap", false) {
|
||
let mut renderer = photocraft_paint::StrokeRenderer::new(&stroke.brush, None, 1.0);
|
||
renderer.push(&stroke.points);
|
||
renderer.finish();
|
||
let before = surf.clone();
|
||
let bounds = renderer.bounds();
|
||
return periodic::Composite { target: surf, before: &before, tile: pre.bounds(), opacity: stroke.brush.opacity, selection: sel, lock, mode }
|
||
.draw(bounds, &stroke.brush, &stroke.points, |r| clone_sample(pre, id, &before, which, r, &map, true, CMD));
|
||
}
|
||
let (bounds, cov) = coverage(CMD, &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, flag(p, "wrap", false), CMD)?;
|
||
Ok(apply_coverage(surf, bounds, &cov, stroke.brush.opacity, sel, lock, &paint, mode))
|
||
})?;
|
||
let mut r = clone_result(dmg, off, aligned, p, &stroke);
|
||
if from_live {
|
||
r["fromLive"] = json!(true);
|
||
}
|
||
Ok(r)
|
||
}
|
||
|
||
/// What a live retouching stroke paints through its coverage.
|
||
enum LivePaint {
|
||
/// The clone source (Clone Stamp; the Healing Brush shows its texture while drawing and
|
||
/// blends it on release).
|
||
Clone { which: SampleLayers, map: crate::presets::clone_source::Mapping, cmd: &'static str },
|
||
/// The History Brush's history state.
|
||
History(Surface),
|
||
/// The Pattern Stamp's pattern, tiled from its aligned placement (Impressionist strokes are
|
||
/// laid dab by dab: `pattern_stamp::live_impressionist`).
|
||
Pattern { tile: std::sync::Arc<photocraft_compose::pattern::Tile>, place: photocraft_compose::pattern::Placement },
|
||
}
|
||
|
||
/// A retouching stroke shown while it is drawn: the dabs so far composited onto a copy of the
|
||
/// active document as the command with the same params commits them (the same coverage,
|
||
/// sampling and blend): Clone Stamp, History Brush, and the Healing Brush's texture.
|
||
pub struct LiveRetouch {
|
||
/// The active document with the stroke so far.
|
||
pub doc: std::sync::Arc<Document>,
|
||
cmd: String,
|
||
renderer: photocraft_paint::StrokeRenderer,
|
||
pre: std::sync::Arc<Document>,
|
||
pre_surf: Surface,
|
||
id: Option<LayerId>,
|
||
params: Value,
|
||
paint: LivePaint,
|
||
mode: BlendMode,
|
||
opacity: f32,
|
||
sel: Option<Surface>,
|
||
lock: bool,
|
||
/// Where the doc shows what finishing the stroke now would add (a lone first dab, the
|
||
/// smoothing catch-up), redrawn on every step as the Brush's live stroke does.
|
||
tail: Rect,
|
||
/// The document and revision the stroke started on.
|
||
base: (photocraft_doc::DocId, u64),
|
||
brush: Box<BrushSettings>,
|
||
/// Every point rendered so far, in order.
|
||
points: Vec<StrokePoint>,
|
||
}
|
||
|
||
impl LiveRetouch {
|
||
/// Start a live stroke of `cmd` (`paint.cloneStamp`, `paint.healingBrush`,
|
||
/// `paint.historyBrush` or an aligned, non-Impressionist `paint.patternStamp`) from its
|
||
/// params; their `points` are rendered.
|
||
pub fn begin(s: &Session, cmd: &str, p: &Value) -> Result<Self> {
|
||
has_pixel_layer(s).map_err(EngineError::Other)?;
|
||
if cmd == "paint.patternStamp" {
|
||
let st = pattern_stamp::Setup::parse(s, p)?;
|
||
if st.impressionist {
|
||
return Err(bad(cmd, "Impressionist strokes are laid dab by dab (pattern_stamp::live_impressionist)"));
|
||
}
|
||
let paint = LivePaint::Pattern { place: st.placement(), tile: st.tile };
|
||
return Self::with_paint(s, cmd, p, st.stroke, st.id, paint, st.mode);
|
||
}
|
||
let (stroke, id) = parse_brush(s, p, cmd)?;
|
||
let mode = blend_param(p, cmd)?;
|
||
let paint = match cmd {
|
||
"paint.cloneStamp" | "paint.healingBrush" => {
|
||
let f = *stroke.points.first().ok_or_else(|| bad(cmd, "`points` is empty"))?;
|
||
LivePaint::Clone {
|
||
which: sample_layers(p, cmd)?,
|
||
map: crate::presets::clone_source::resolve(s, p, (f.x, f.y), cmd)?.0,
|
||
cmd: if cmd == "paint.healingBrush" { "paint.healingBrush" } else { "paint.cloneStamp" },
|
||
}
|
||
}
|
||
"paint.historyBrush" => LivePaint::History(history_source(s, id, p, cmd)?),
|
||
other => return Err(bad(other, "live retouching strokes are the Clone Stamp, Healing Brush, History Brush and Pattern Stamp")),
|
||
};
|
||
Self::with_paint(s, cmd, p, stroke, id, paint, mode)
|
||
}
|
||
|
||
/// Start a live stroke of `cmd` painting `paint` through `stroke`'s coverage.
|
||
fn with_paint(s: &Session, cmd: &str, p: &Value, stroke: Stroke, id: Option<LayerId>, paint: LivePaint, mode: BlendMode) -> Result<Self> {
|
||
let st = s.active().ok_or(EngineError::NoDocument)?;
|
||
let (pre, base) = (st.doc.clone(), (st.doc.id, st.revision));
|
||
let mut doc = (*pre).clone();
|
||
let sel = doc.selection.clone();
|
||
let (surf, lock) = crate::channel_cmds::target_surface(&mut doc, id, p)?;
|
||
let pre_surf = surf.clone();
|
||
// As `stroke_coverage`, which the commit uses.
|
||
let renderer = photocraft_paint::StrokeRenderer::new(&stroke.brush, None, 1.0);
|
||
let opacity = stroke.brush.opacity;
|
||
let brush = Box::new(stroke.brush.clone());
|
||
let paint = match paint {
|
||
LivePaint::History(src) if src.format() != pre_surf.format() => LivePaint::History(src.convert(pre_surf.format())),
|
||
other => other,
|
||
};
|
||
let mut live = Self {
|
||
doc: std::sync::Arc::new(doc),
|
||
cmd: cmd.into(),
|
||
renderer,
|
||
pre,
|
||
pre_surf,
|
||
id,
|
||
params: p.clone(),
|
||
paint,
|
||
mode,
|
||
opacity,
|
||
sel,
|
||
lock,
|
||
tail: Rect::EMPTY,
|
||
base,
|
||
brush,
|
||
points: Vec::new(),
|
||
};
|
||
live.push(&stroke.points)?;
|
||
Ok(live)
|
||
}
|
||
|
||
/// Everything the stroke has touched so far.
|
||
pub fn bounds(&self) -> Rect {
|
||
let bounds = self.renderer.bounds().union(&self.tail);
|
||
if self.cmd == "paint.cloneStamp" && flag(&self.params, "wrap", false) {
|
||
periodic::project(bounds, self.pre.bounds()).into_iter().fold(Rect::EMPTY, |a, b| a.union(&b))
|
||
} else {
|
||
bounds
|
||
}
|
||
}
|
||
|
||
/// Every point rendered so far, in order.
|
||
pub fn points(&self) -> &[StrokePoint] {
|
||
&self.points
|
||
}
|
||
|
||
/// The finished stroke as its commit would take it over ([`Session::offer_live_retouch`]).
|
||
/// Only the Clone Stamp and History Brush draw what they commit (the Healing Brush blends on
|
||
/// release), and only while the brush, layer and clone source are still the ones the stroke
|
||
/// started with: the document revision doesn't cover those.
|
||
pub(crate) fn into_commit(mut self, s: &Session) -> Result<crate::brush_cmds::LiveCommit> {
|
||
let (stroke, id) = parse_brush(s, &self.params, &self.cmd)?;
|
||
let source_unchanged = match &self.paint {
|
||
LivePaint::Clone { map, .. } if self.cmd == "paint.cloneStamp" => {
|
||
let f = stroke.points.first().ok_or_else(|| bad(&self.cmd, "`points` is empty"))?;
|
||
crate::presets::clone_source::resolve(s, &self.params, (f.x, f.y), &self.cmd)?.0 == *map
|
||
}
|
||
LivePaint::History(_) if self.cmd == "paint.historyBrush" => true,
|
||
_ => return Err(bad(&self.cmd, "only Clone Stamp and History Brush strokes commit their live pixels")),
|
||
};
|
||
if !source_unchanged || *self.brush != stroke.brush || id != self.id {
|
||
return Err(bad(&self.cmd, "the brush, layer or clone source changed during the stroke"));
|
||
}
|
||
let bounds = self.bounds();
|
||
let (surf, _) = crate::channel_cmds::target_surface(std::sync::Arc::make_mut(&mut self.doc), self.id, &self.params)?;
|
||
let surface = surf.clone();
|
||
Ok(crate::brush_cmds::LiveCommit { mixer: None, cmd: self.cmd, base: self.base, params: self.params, seed: None, points: self.points, surface, bounds })
|
||
}
|
||
|
||
/// Render more points; returns the rectangle that changed. Each changed area is redrawn from
|
||
/// the pre-stroke pixels with the stroke's coverage so far, so overlapping dabs build up as in
|
||
/// the commit, and pixels painted earlier in the stroke are never re-cloned. What finishing
|
||
/// the stroke now would add is drawn too (a lone first dab shows on the press), and redrawn on
|
||
/// every step.
|
||
/// Invalid coordinates reject the whole batch without changing the preview.
|
||
/// Only the position and pressure of `pts` are used: the commands take no tilt or times.
|
||
pub fn push(&mut self, pts: &[StrokePoint]) -> Result<Rect> {
|
||
crate::brush_cmds::check_coords(pts, &self.cmd)?;
|
||
let pts: Vec<_> = pts.iter().map(|q| StrokePoint::new(q.x, q.y, q.pressure)).collect();
|
||
self.points.extend_from_slice(&pts);
|
||
self.renderer.push(&pts);
|
||
if self.cmd == "paint.cloneStamp" && flag(&self.params, "wrap", false) {
|
||
return self.push_periodic();
|
||
}
|
||
let old = std::mem::replace(&mut self.tail, Rect::EMPTY);
|
||
let mut dmg = Rect::EMPTY;
|
||
if !old.is_empty() {
|
||
// Back to the stroke without the previous tail.
|
||
let (surf, _) = crate::channel_cmds::target_surface(std::sync::Arc::make_mut(&mut self.doc), self.id, &self.params)?;
|
||
surf.write_region(old, &self.pre_surf.read_region(old));
|
||
self.renderer.mark_dirty(old);
|
||
dmg = old;
|
||
}
|
||
let r = self.renderer.take_dirty_rect();
|
||
let cov = self.renderer.coverage_in(r);
|
||
dmg = dmg.union(&self.composite(r, &cov)?);
|
||
if let Some(mut tail) = self.renderer.tail_preview() {
|
||
let r = tail.take_dirty_rect();
|
||
let cov = tail.coverage_in(r);
|
||
self.tail = self.composite(r, &cov)?;
|
||
dmg = dmg.union(&self.tail);
|
||
}
|
||
Ok(dmg)
|
||
}
|
||
|
||
fn push_periodic(&mut self) -> Result<Rect> {
|
||
let mut dirty = self.renderer.take_dirty_rect().union(&self.tail);
|
||
let mut tail = self.renderer.tail_preview();
|
||
self.tail = tail.as_mut().map_or(Rect::EMPTY, |t| t.take_dirty_rect());
|
||
dirty = dirty.union(&self.tail);
|
||
let LivePaint::Clone { which, map, cmd } = &self.paint else {
|
||
return Err(bad(&self.cmd, "periodic painting requires a clone source"));
|
||
};
|
||
let (surf, _) = crate::channel_cmds::target_surface(std::sync::Arc::make_mut(&mut self.doc), self.id, &self.params)?;
|
||
periodic::Composite {
|
||
target: surf,
|
||
before: &self.pre_surf,
|
||
tile: self.pre.bounds(),
|
||
opacity: self.opacity,
|
||
selection: self.sel.as_ref(),
|
||
lock: self.lock,
|
||
mode: self.mode,
|
||
}
|
||
.draw(dirty, &self.brush, &self.points, |r| clone_sample(&self.pre, self.id, &self.pre_surf, *which, r, map, true, cmd))
|
||
}
|
||
|
||
/// Redraw `r` from the pre-stroke pixels with the clone source through coverage `cov`.
|
||
fn composite(&mut self, r: Rect, cov: &[f32]) -> Result<Rect> {
|
||
if r.is_empty() {
|
||
return Ok(r);
|
||
}
|
||
let paint = match &self.paint {
|
||
LivePaint::Clone { which, map, cmd } => {
|
||
clone_sample(&self.pre, self.id, &self.pre_surf, *which, r, map, *cmd == "paint.cloneStamp" && flag(&self.params, "wrap", false), cmd)?
|
||
}
|
||
LivePaint::History(src) => Region::read(src, r),
|
||
LivePaint::Pattern { tile, place } => pattern_stamp::paint_region(tile, place, &self.pre_surf.format(), r),
|
||
};
|
||
let (surf, _) = crate::channel_cmds::target_surface(std::sync::Arc::make_mut(&mut self.doc), self.id, &self.params)?;
|
||
surf.write_region(r, &self.pre_surf.read_region(r));
|
||
Ok(apply_coverage(surf, r, cov, self.opacity, self.sel.as_ref(), self.lock, &paint, self.mode).union(&r))
|
||
}
|
||
}
|
||
|
||
/// Grow a stroke's coverage map by `m` pixels of zero coverage (a Dirichlet boundary for the solve).
|
||
fn pad_coverage(bounds: Rect, cov: &[f32], m: i32) -> (Rect, Vec<f32>) {
|
||
let g = bounds.inflate(m);
|
||
let (w, gw) = (bounds.width() as usize, g.width() as usize);
|
||
let mut out = vec![0.0f32; gw * g.height() as usize];
|
||
for (i, c) in cov.iter().enumerate() {
|
||
let (x, y) = (i % w + m as usize, i / w + m as usize);
|
||
out[y * gw + x] = *c;
|
||
}
|
||
(g, out)
|
||
}
|
||
|
||
/// Seamless-clone `src` into `dst` over the mask (both regions cover the same rect). The solve runs
|
||
/// only on the mask's bounding box plus a one-pixel Dirichlet ring; the rest is `dst`.
|
||
fn heal_region(fmt: &PixelFormat, src: &Region, dst: &Region, mask: &[bool]) -> Region {
|
||
let w = dst.width();
|
||
let bbox = mask.iter().enumerate().filter(|(_, m)| **m).fold(Rect::EMPTY, |r, (i, _)| {
|
||
let (x, y) = (dst.rect.x0 + (i % w) as i32, dst.rect.y0 + (i / w) as i32);
|
||
r.union(&Rect::new(x, y, x + 1, y + 1))
|
||
});
|
||
let mut out = dst.clone();
|
||
if bbox.is_empty() {
|
||
return out;
|
||
}
|
||
let r = bbox.inflate(1).intersect(&dst.rect);
|
||
let (s, d) = (src.crop(r), dst.crop(r));
|
||
let m: Vec<bool> = (r.y0..r.y1).flat_map(|y| (r.x0..r.x1).map(move |x| (x, y))).map(|(x, y)| mask[dst.index(x, y)]).collect();
|
||
let mut data = poisson::seamless_clone(d.width(), d.height(), d.ch, &s.data, &d.data, &m);
|
||
clamp_samples(fmt, &mut data);
|
||
let (n, rw) = (dst.ch, r.width() as usize);
|
||
for y in r.y0..r.y1 {
|
||
let o = out.index(r.x0, y) * n;
|
||
let i = (y - r.y0) as usize * rw * n;
|
||
out.data[o..o + rw * n].copy_from_slice(&data[i..i + rw * n]);
|
||
}
|
||
out
|
||
}
|
||
|
||
fn healing_brush(s: &mut Session, p: &Value) -> Result<Value> {
|
||
const CMD: &str = "paint.healingBrush";
|
||
let (stroke, id) = parse_brush(s, p, CMD)?;
|
||
reject_huge_stroke(CMD, &stroke)?;
|
||
let which = sample_layers(p, CMD)?;
|
||
let mode = blend_param(p, CMD)?;
|
||
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, p, |pre, surf, sel, lock| {
|
||
let (bounds, cov) = coverage(CMD, &stroke)?;
|
||
let (g, cov) = pad_coverage(bounds, &cov, 2);
|
||
let fmt = surf.format();
|
||
let src = clone_sample(pre, id, surf, which, g, &map, false, CMD)?;
|
||
// The destination the texture is fitted to: the layer itself, or what the user sees when
|
||
// sampling several layers (so healing onto an empty layer matches the composite).
|
||
let dst = if which == SampleLayers::Current { Region::read(surf, g) } else { composite_region(pre, id, which, g, fmt) };
|
||
let mask: Vec<bool> = cov.iter().map(|c| *c > 0.0).collect();
|
||
let healed = heal_region(&fmt, &src, &dst, &mask);
|
||
Ok(apply_coverage(surf, g, &cov, stroke.brush.opacity, sel, lock, &healed, mode))
|
||
})?;
|
||
Ok(clone_result(dmg, off, aligned, p, &stroke))
|
||
}
|
||
|
||
/// The History Brush's source: the targeted surface (pixels, mask, channel) as it was in History
|
||
/// panel row `state` or snapshot `snapshot` (default: the source set in the History panel,
|
||
/// `history.setSource`, else the oldest state still held, the "Open" state unless trimmed).
|
||
fn history_source(s: &Session, id: Option<LayerId>, p: &Value, cmd: &str) -> Result<Surface> {
|
||
let st = s.active().ok_or(EngineError::NoDocument)?;
|
||
let source_doc = crate::history_source_cmds::paint_source(st, p, cmd)?;
|
||
crate::channel_cmds::read_target_surface(source_doc.as_ref(), id, p)
|
||
.cloned()
|
||
.map_err(|_| EngineError::Other("the target did not exist (as pixels, a mask or a channel) in that history state".into()))
|
||
}
|
||
|
||
fn history_brush(s: &mut Session, p: &Value) -> Result<Value> {
|
||
const CMD: &str = "paint.historyBrush";
|
||
let (stroke, id) = parse_brush(s, p, CMD)?;
|
||
let mode = blend_param(p, CMD)?;
|
||
// The coverage and the source region below are both dense over the stroke's bounds, so a
|
||
// thin stroke across a large canvas would still ask for the whole box. Refuse it from the
|
||
// points and brush size, before the source surface is cloned or anything is rendered (#4961).
|
||
reject_huge_stroke(CMD, &stroke)?;
|
||
let live = s.live_commit.take().filter(|l| l.matches(s, CMD, p, &stroke.points));
|
||
let from_live = live.is_some();
|
||
let src_surface = history_source(s, id, p, CMD)?;
|
||
let dmg = run_stroke(s, "History Brush", id, p, |_, surf, sel, lock| {
|
||
if let Some(l) = live {
|
||
*surf = l.surface;
|
||
return Ok(l.bounds);
|
||
}
|
||
let fmt = surf.format();
|
||
let src = if src_surface.format() == fmt { src_surface } else { src_surface.convert(fmt) };
|
||
let (bounds, cov) = coverage(CMD, &stroke)?;
|
||
let paint = Region::read(&src, bounds);
|
||
Ok(apply_coverage(surf, bounds, &cov, stroke.brush.opacity, sel, lock, &paint, mode))
|
||
})?;
|
||
let mut r = json!({ "damage": damage_json(dmg) });
|
||
if from_live {
|
||
r["fromLive"] = json!(true);
|
||
}
|
||
Ok(r)
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------------------------
|
||
// Spot Healing
|
||
// ---------------------------------------------------------------------------------------------
|
||
|
||
/// Dilate a mask by `r` pixels (square structuring element).
|
||
fn dilate(w: usize, h: usize, m: &[bool], r: usize) -> Vec<bool> {
|
||
let mut tmp = vec![false; w * h];
|
||
for y in 0..h {
|
||
for x in 0..w {
|
||
if m[y * w + x] {
|
||
for xx in x.saturating_sub(r)..(x + r + 1).min(w) {
|
||
tmp[y * w + xx] = true;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
let mut out = vec![false; w * h];
|
||
for y in 0..h {
|
||
for x in 0..w {
|
||
if tmp[y * w + x] {
|
||
for yy in y.saturating_sub(r)..(y + r + 1).min(h) {
|
||
out[yy * w + x] = true;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
out
|
||
}
|
||
|
||
/// Copy a translated Proximity Match patch from the *pre-healing* pixels. Reading
|
||
/// `out` here would make overlapping offsets repeatedly copy the pixels just written,
|
||
/// leaving streaks instead of the selected source texture.
|
||
fn proximity_source_patch(img: &[f32], w: usize, h: usize, ch: usize, mask: &[bool], dx: i32, dy: i32) -> Option<Vec<f32>> {
|
||
if w == 0 || h == 0 || ch == 0 || mask.len() != w.checked_mul(h)? || img.len() != mask.len().checked_mul(ch)? {
|
||
return None;
|
||
}
|
||
let mut out = img.to_vec();
|
||
for (p, masked) in mask.iter().enumerate() {
|
||
if !masked {
|
||
continue;
|
||
}
|
||
let x = i32::try_from(p % w).ok()?.checked_add(dx)?;
|
||
let y = i32::try_from(p / w).ok()?.checked_add(dy)?;
|
||
if x < 0 || y < 0 || x >= i32::try_from(w).ok()? || y >= i32::try_from(h).ok()? {
|
||
return None;
|
||
}
|
||
let src = (usize::try_from(y).ok()?.checked_mul(w)? + usize::try_from(x).ok()?).checked_mul(ch)?;
|
||
let dst = p.checked_mul(ch)?;
|
||
out.get_mut(dst..dst + ch)?.copy_from_slice(img.get(src..src + ch)?);
|
||
}
|
||
Some(out)
|
||
}
|
||
|
||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||
enum SpotType {
|
||
ContentAware,
|
||
CreateTexture,
|
||
ProximityMatch,
|
||
}
|
||
|
||
/// Fill the stroke area with no source, then gradient-domain blend it into its surroundings. With
|
||
/// `all` (Sample All Layers) it heals what is visible, so it works on an empty layer above the image.
|
||
///
|
||
/// Photoshop 25.4 (measured): a dab changes exactly the brush's footprint, and Content-Aware takes
|
||
/// its texture from around it, as Edit › Fill does from its window
|
||
/// ([`crate::fill_cmds::sampling_window`] of the stroke's bounds), which is what this samples.
|
||
fn spot_heal_surface(surf: &mut Surface, pre: &Document, stroke: &Stroke, kind: SpotType, all: bool, sel: Option<&Surface>, lock: bool) -> Result<Rect> {
|
||
let (bounds, cov) = coverage("paint.spotHealing", stroke)?;
|
||
let size = stroke.brush.size;
|
||
let region_rect = crate::fill_cmds::sampling_window(bounds, pre.bounds());
|
||
if region_rect.is_empty() {
|
||
return Ok(Rect::EMPTY);
|
||
}
|
||
// Proximity Match looks for its source anywhere in that window.
|
||
let margin = (region_rect.width().max(region_rect.height()) / 2) as i32;
|
||
let fmt = surf.format();
|
||
let img = if all { composite_region(pre, None, SampleLayers::All, region_rect, fmt) } else { Region::read(surf, region_rect) };
|
||
let (w, h, ch) = (img.width(), img.height(), img.ch);
|
||
let bw = bounds.width() as usize;
|
||
let mut stroke_cov = vec![0.0f32; w * h];
|
||
for (i, c) in cov.iter().enumerate() {
|
||
let (x, y) = (bounds.x0 + (i % bw) as i32, bounds.y0 + (i / bw) as i32);
|
||
if region_rect.contains(x, y) {
|
||
stroke_cov[img.index(x, y)] = *c;
|
||
}
|
||
}
|
||
let hole: Vec<bool> = stroke_cov.iter().map(|c| *c > 0.0).collect();
|
||
if !hole.iter().any(|h| *h) {
|
||
return Ok(Rect::EMPTY);
|
||
}
|
||
// Synthesise over a slightly larger domain than the Poisson region, so the solve's boundary pixels
|
||
// carry synthesised values that differ from the untouched surroundings: that mismatch is what the
|
||
// membrane corrects (otherwise the Dirichlet data would be all zero and the blend a no-op).
|
||
let domain = dilate(w, h, &hole, 2);
|
||
let heal_mask = dilate(w, h, &hole, 1);
|
||
let content_aware =
|
||
|img: &[f32]| inpaint::complete(w, h, ch, img, &domain, &CompleteParams::default()).unwrap_or_else(|| poisson::membrane_fill(w, h, ch, img, &domain));
|
||
let filled = match kind {
|
||
SpotType::ContentAware => content_aware(&img.data),
|
||
SpotType::CreateTexture => inpaint::synthesize(w, h, ch, &img.data, &domain, (size / 4.0).clamp(6.0, 32.0) as usize, 0x5eed),
|
||
SpotType::ProximityMatch => {
|
||
let ring = (size / 8.0).clamp(3.0, 16.0) as usize;
|
||
match inpaint::best_offset(w, h, ch, &img.data, &domain, ring, margin) {
|
||
Some((dx, dy)) => proximity_source_patch(&img.data, w, h, ch, &domain, dx, dy).unwrap_or_else(|| content_aware(&img.data)),
|
||
None => content_aware(&img.data),
|
||
}
|
||
}
|
||
};
|
||
let src = Region { rect: region_rect, ch, data: filled };
|
||
let healed = heal_region(&fmt, &src, &img, &heal_mask);
|
||
let dmg = bounds.intersect(®ion_rect);
|
||
let dmg_cov: Vec<f32> = (dmg.y0..dmg.y1).flat_map(|y| (dmg.x0..dmg.x1).map(move |x| (x, y))).map(|(x, y)| stroke_cov[img.index(x, y)]).collect();
|
||
Ok(apply_coverage(surf, dmg, &dmg_cov, stroke.brush.opacity, sel, lock, &healed, BlendMode::Normal))
|
||
}
|
||
|
||
fn spot_healing(s: &mut Session, p: &Value) -> Result<Value> {
|
||
const CMD: &str = "paint.spotHealing";
|
||
let (stroke, id) = parse_brush(s, p, CMD)?;
|
||
reject_huge_stroke(CMD, &stroke)?;
|
||
let kind = match string(p, "type", "contentAware") {
|
||
"contentAware" => SpotType::ContentAware,
|
||
"createTexture" => SpotType::CreateTexture,
|
||
"proximityMatch" => SpotType::ProximityMatch,
|
||
o => return Err(bad(CMD, format!("unknown type `{o}` (contentAware|createTexture|proximityMatch)"))),
|
||
};
|
||
let all = sample_all_layers(p);
|
||
let dmg = run_stroke(s, "Spot Healing Brush", id, p, |pre, surf, sel, lock| spot_heal_surface(surf, pre, &stroke, kind, all, sel, lock))?;
|
||
Ok(json!({ "damage": damage_json(dmg) }))
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------------------------
|
||
// Toning (Dodge / Burn / Sponge) and focus (Blur / Sharpen), Smudge
|
||
// ---------------------------------------------------------------------------------------------
|
||
|
||
/// Per-dab strength such that one pass over a point totals roughly `e`, whatever the spacing
|
||
/// (about `1/spacing` dabs overlap each point on the stroke's centre line).
|
||
#[inline]
|
||
fn per_dab(e: f32, spacing: f32) -> f32 {
|
||
1.0 - (1.0 - e.clamp(0.0, 1.0)).powf(spacing.clamp(0.01, 1.0))
|
||
}
|
||
|
||
/// Apply an RGB colour transform `f(rgb, strength)` to each covered pixel of a dab (through straight
|
||
/// RGBA, so it works for Gray/CMYK/Lab too; untouched pixels are never round-tripped).
|
||
fn color_dab(fmt: &PixelFormat, work: &mut Region, fp: &Footprint, strength: f32, spacing: f32, f: impl Fn([f32; 3], f32) -> [f32; 3]) {
|
||
let r = fp.rect.intersect(&work.rect);
|
||
let n = fmt.channels();
|
||
let mut enc = [0.0f32; 8];
|
||
for y in r.y0..r.y1 {
|
||
for x in r.x0..r.x1 {
|
||
let k = per_dab(fp.at(x, y) * strength, spacing);
|
||
if k <= 0.0 {
|
||
continue;
|
||
}
|
||
let px = work.px_mut(x, y);
|
||
let rgba = to_rgba(fmt, px);
|
||
let o = f([rgba[0], rgba[1], rgba[2]], k);
|
||
from_rgba_into(fmt, [o[0], o[1], o[2], rgba[3]], &mut enc);
|
||
let a = alpha_index(fmt);
|
||
for c in 0..n {
|
||
if Some(c) != a {
|
||
px[c] = enc[c];
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Dodge and Burn: a stroke tones each pixel once, from its original colour, mixed in by the
|
||
/// stroke's coverage there (`toned` is the full-exposure colour). Dabs build up coverage as in a
|
||
/// brush stroke, capped at full coverage, so Exposure caps a stroke the way Opacity caps a paint
|
||
/// stroke: overlapping dabs and passes within one stroke don't compound the tone curve, and a
|
||
/// new stroke builds on the last.
|
||
fn tone_stroke(fmt: PixelFormat, toned: impl Fn([f32; 3]) -> [f32; 3] + 'static) -> DabEffect {
|
||
/// Side of the cells that remember, per touched pixel, the coverage applied so far and the
|
||
/// original pixel (copied from the working pixels the first time a dab reaches it). Dense
|
||
/// cells rather than a map per pixel: a map lookup per covered pixel made long strokes with
|
||
/// large brushes over ten times slower.
|
||
const CELL: i32 = 64;
|
||
struct Cell {
|
||
cov: Vec<f32>,
|
||
orig: Vec<f32>,
|
||
}
|
||
let mut cells: std::collections::HashMap<(i32, i32), Cell> = std::collections::HashMap::new();
|
||
let a = alpha_index(&fmt);
|
||
let n = fmt.channels();
|
||
Box::new(move |work, fp| {
|
||
let r = fp.rect.intersect(&work.rect);
|
||
if r.is_empty() {
|
||
return;
|
||
}
|
||
let mut enc = [0.0f32; 8];
|
||
for cy in r.y0.div_euclid(CELL)..=(r.y1 - 1).div_euclid(CELL) {
|
||
for cx in r.x0.div_euclid(CELL)..=(r.x1 - 1).div_euclid(CELL) {
|
||
let cr = Rect::new(cx * CELL, cy * CELL, cx * CELL + CELL, cy * CELL + CELL);
|
||
let part = r.intersect(&cr);
|
||
if part.is_empty() {
|
||
continue;
|
||
}
|
||
let len = (CELL * CELL) as usize;
|
||
// Coverage -1: not touched yet, so `orig` isn't filled in for that pixel.
|
||
let cell = cells.entry((cx, cy)).or_insert_with(|| Cell { cov: vec![-1.0; len], orig: vec![0.0; len * n] });
|
||
for y in part.y0..part.y1 {
|
||
for x in part.x0..part.x1 {
|
||
let k = fp.at(x, y).clamp(0.0, 1.0);
|
||
if k <= 0.0 {
|
||
continue;
|
||
}
|
||
let i = ((y - cr.y0) * CELL + (x - cr.x0)) as usize;
|
||
let (Some(prev), Some(src)) = (cell.cov.get_mut(i), cell.orig.get_mut(i * n..i * n + n)) else { continue };
|
||
if *prev < 0.0 {
|
||
// First touch: the working pixel is still the original.
|
||
src.copy_from_slice(work.px(x, y));
|
||
*prev = 0.0;
|
||
}
|
||
// Dabs build up the stroke's coverage as a brush stroke's do (flow), up
|
||
// to full coverage; the tone is applied once, from the original.
|
||
let k = *prev + k * (1.0 - *prev);
|
||
if k <= *prev {
|
||
continue;
|
||
}
|
||
*prev = k;
|
||
let o = to_rgba(&fmt, src);
|
||
let t = toned([o[0], o[1], o[2]]);
|
||
let m = [o[0] + (t[0] - o[0]) * k, o[1] + (t[1] - o[1]) * k, o[2] + (t[2] - o[2]) * k];
|
||
from_rgba_into(&fmt, [m[0], m[1], m[2], o[3]], &mut enc);
|
||
let px = work.px_mut(x, y);
|
||
for c in 0..n {
|
||
if Some(c) != a {
|
||
px[c] = enc[c];
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
})
|
||
}
|
||
|
||
fn tone_range(p: &Value, cmd: &str) -> Result<ToneRange> {
|
||
match string(p, "range", "midtones") {
|
||
"shadows" => Ok(ToneRange::Shadows),
|
||
"midtones" => Ok(ToneRange::Midtones),
|
||
"highlights" => Ok(ToneRange::Highlights),
|
||
o => Err(bad(cmd, format!("unknown range `{o}` (shadows|midtones|highlights)"))),
|
||
}
|
||
}
|
||
|
||
/// A sequential-dab tool's settings (Dodge, Burn, Sponge, Blur, Sharpen, Smudge), parsed once and
|
||
/// turned into its per-dab effect, for the commit and the live preview alike.
|
||
#[derive(Clone, Debug, PartialEq)]
|
||
enum DabTool {
|
||
Tone { burn: bool, range: ToneRange, exposure: f32, protect: bool },
|
||
Sponge { saturate: bool, vibrance: bool },
|
||
Focus { sharpen: bool, strength: f32, protect: bool, sigma: f32 },
|
||
Smudge { strength: f32, finger: Option<[f32; 4]>, max_size: f32 },
|
||
}
|
||
|
||
/// One dab's edit of the working copy.
|
||
pub(crate) type DabEffect = Box<dyn FnMut(&mut Region, &Footprint)>;
|
||
|
||
/// Pixels an effect reads beyond the dab it writes. Smudge's pickup is a fixed-size square,
|
||
/// independent of pressure, roundness and the dab's current angle.
|
||
#[derive(Clone, Copy)]
|
||
enum DabNeighborhood {
|
||
Halo(i32),
|
||
Smudge(i32),
|
||
}
|
||
|
||
impl DabNeighborhood {
|
||
fn halo(self) -> i32 {
|
||
match self {
|
||
Self::Halo(radius) | Self::Smudge(radius) => radius,
|
||
}
|
||
}
|
||
|
||
fn read_rect(self, rect: Rect, dab: &photocraft_paint::Dab) -> Rect {
|
||
match self {
|
||
Self::Halo(radius) => rect.inflate(radius),
|
||
Self::Smudge(radius) => {
|
||
let (x, y) = (dab.center.x.floor() as i32, dab.center.y.floor() as i32);
|
||
rect.union(&Rect::new(
|
||
x.saturating_sub(radius),
|
||
y.saturating_sub(radius),
|
||
x.saturating_add(radius).saturating_add(1),
|
||
y.saturating_add(radius).saturating_add(1),
|
||
))
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
impl DabTool {
|
||
/// The tool for `cmd` and its history label.
|
||
fn parse(s: &Session, cmd: &str, p: &Value, stroke: &Stroke) -> Result<(Self, &'static str)> {
|
||
Ok(match cmd {
|
||
"paint.dodge" | "paint.burn" => {
|
||
let burn = cmd == "paint.burn";
|
||
let tool = Self::Tone {
|
||
burn,
|
||
range: tone_range(p, cmd)?,
|
||
exposure: num(p, "exposure", 50.0).clamp(1.0, 100.0) / 100.0,
|
||
protect: flag(p, "protectTones", true),
|
||
};
|
||
(tool, if burn { "Burn Tool" } else { "Dodge Tool" })
|
||
}
|
||
"paint.sponge" => {
|
||
let saturate = match string(p, "mode", "desaturate") {
|
||
"desaturate" => false,
|
||
"saturate" => true,
|
||
o => return Err(bad(cmd, format!("unknown mode `{o}` (desaturate|saturate)"))),
|
||
};
|
||
(Self::Sponge { saturate, vibrance: flag(p, "vibrance", true) }, "Sponge Tool")
|
||
}
|
||
"paint.blur" | "paint.sharpen" => {
|
||
let sharpen = cmd == "paint.sharpen";
|
||
let tool = Self::Focus {
|
||
sharpen,
|
||
strength: num(p, "strength", 50.0).clamp(1.0, 100.0) / 100.0,
|
||
protect: flag(p, "protectDetail", true),
|
||
sigma: (stroke.brush.size * 0.03).clamp(1.0, 4.0),
|
||
};
|
||
(tool, if sharpen { "Sharpen Tool" } else { "Blur Tool" })
|
||
}
|
||
"paint.smudge" => {
|
||
let finger = flag(p, "fingerPainting", false).then_some(s.tools.foreground);
|
||
(Self::Smudge { strength: num(p, "strength", 50.0).clamp(1.0, 100.0) / 100.0, finger, max_size: stroke.brush.size }, "Smudge Tool")
|
||
}
|
||
o => return Err(bad(o, "not a sequential-dab tool")),
|
||
})
|
||
}
|
||
|
||
/// How far around each dab the effect reads (Blur and Sharpen read a neighbourhood).
|
||
fn neighborhood(&self) -> DabNeighborhood {
|
||
match self {
|
||
Self::Focus { sigma, .. } => DabNeighborhood::Halo((sigma * 3.0).ceil() as i32 + 1),
|
||
Self::Smudge { max_size, .. } => DabNeighborhood::Smudge(Smudge::sampling_radius(*max_size)),
|
||
_ => DabNeighborhood::Halo(0),
|
||
}
|
||
}
|
||
|
||
/// The per-dab effect on a working copy in `fmt`, for a brush of `spacing`.
|
||
fn effect(&self, fmt: PixelFormat, spacing: f32) -> DabEffect {
|
||
let sp = spacing;
|
||
match self.clone() {
|
||
Self::Tone { burn, range, exposure, protect } => tone_stroke(fmt, move |c| dodge_burn(c, exposure, range, burn, protect)),
|
||
// Flow is already in each dab's coverage; a full-flow pass moves colours half-way.
|
||
Self::Sponge { saturate, vibrance } => Box::new(move |work, fp| color_dab(&fmt, work, fp, 0.5, sp, |c, k| sponge(c, k, saturate, vibrance))),
|
||
Self::Focus { sharpen, strength, protect, sigma } => {
|
||
let a = alpha_index(&fmt);
|
||
Box::new(move |work, fp| {
|
||
let r = fp.rect.intersect(&work.rect);
|
||
if r.is_empty() {
|
||
return;
|
||
}
|
||
let (w, h, n) = (work.width(), work.height(), work.ch);
|
||
let win = ((r.x0 - work.rect.x0) as usize, (r.y0 - work.rect.y0) as usize, (r.x1 - work.rect.x0) as usize, (r.y1 - work.rect.y0) as usize);
|
||
let out = if sharpen { local_sharpen(&work.data, w, h, n, a, win, 1.0, protect) } else { local_blur(&work.data, w, h, n, a, win, sigma) };
|
||
let rw = r.width() as usize;
|
||
// Row-parallel, like the blur itself (#5426).
|
||
let (wx0, wy0) = (work.rect.x0, work.rect.y0);
|
||
work.data.par_chunks_mut(w * n).enumerate().for_each(|(row, line)| {
|
||
let y = wy0 + row as i32;
|
||
if y < r.y0 || y >= r.y1 {
|
||
return;
|
||
}
|
||
for x in r.x0..r.x1 {
|
||
let k = per_dab(fp.at(x, y) * strength, sp);
|
||
if k <= 0.0 {
|
||
continue;
|
||
}
|
||
let o = ((y - r.y0) as usize * rw + (x - r.x0) as usize) * n;
|
||
let i = (x - wx0) as usize * n;
|
||
let px = &mut line[i..i + n];
|
||
for c in 0..n {
|
||
px[c] += (out[o + c] - px[c]) * k;
|
||
}
|
||
}
|
||
});
|
||
})
|
||
}
|
||
Self::Smudge { strength, finger, max_size } => {
|
||
let finger_px = finger.map(|fg| photocraft_raster::from_rgba(&fmt, fg));
|
||
// 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));
|
||
Box::new(move |work, fp| sm.dab(work, fp))
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Dodge, Burn, Sponge, Blur, Sharpen and Smudge: each dab edits a working copy in order
|
||
/// (`apply_dab_stroke`). Blur, Sharpen and Smudge can sample all layers.
|
||
fn dab_cmd(s: &mut Session, p: &Value, cmd: &str) -> Result<Value> {
|
||
let (stroke, id) = parse_brush(s, p, cmd)?;
|
||
let (tool, label) = DabTool::parse(s, cmd, p, &stroke)?;
|
||
let all = matches!(tool, DabTool::Focus { .. } | DabTool::Smudge { .. }) && sample_all_layers(p);
|
||
// The live stroke the canvas already drew for exactly this commit (`LiveDab::into_commit`):
|
||
// take its pixels instead of running every dab again (the hitch on release).
|
||
let live = s.live_commit.take().filter(|l| l.matches(s, cmd, p, &stroke.points));
|
||
let from_live = live.is_some();
|
||
let dmg = run_stroke(s, label, id, p, |pre, surf, sel, lock| {
|
||
if let Some(l) = live {
|
||
*surf = l.surface;
|
||
return Ok(l.bounds);
|
||
}
|
||
let fmt = surf.format();
|
||
let mut effect = tool.effect(fmt, stroke.brush.spacing);
|
||
let mut sampler = all.then(|| AllLayers::new(pre, fmt));
|
||
Ok(apply_dab_stroke(surf, &stroke, sel, lock, tool.neighborhood().halo(), |work, fp| match &mut sampler {
|
||
Some(all) => all.dab(work, fp, |w, f| effect(w, f)),
|
||
None => effect(work, fp),
|
||
}))
|
||
})?;
|
||
let mut r = json!({ "damage": damage_json(dmg) });
|
||
if from_live {
|
||
r["fromLive"] = json!(true);
|
||
}
|
||
Ok(r)
|
||
}
|
||
|
||
/// Side of the tiles a live sequential-dab stroke keeps its working pixels in ([`WorkTiles`]).
|
||
const WORK_TILE: i32 = 128;
|
||
|
||
/// The working copy of a live sequential-dab stroke: the target's native channels as `f32`, in
|
||
/// tiles copied from the pre-stroke surface the first time a dab writes to them. Unlike a
|
||
/// `Surface`, it isn't rounded to the document's depth between dabs, so each dab sees what the
|
||
/// commit's working copy (`apply_dab_stroke`) holds and the preview is the commit's pixels.
|
||
struct WorkTiles {
|
||
ch: usize,
|
||
tiles: std::collections::HashMap<(i32, i32), Vec<f32>>,
|
||
}
|
||
|
||
impl WorkTiles {
|
||
fn new(ch: usize) -> Self {
|
||
Self { ch, tiles: std::collections::HashMap::new() }
|
||
}
|
||
|
||
fn tile_rect((tx, ty): (i32, i32)) -> Rect {
|
||
Rect::new(tx * WORK_TILE, ty * WORK_TILE, tx * WORK_TILE + WORK_TILE, ty * WORK_TILE + WORK_TILE)
|
||
}
|
||
|
||
/// The tiles `r` overlaps.
|
||
fn coords(r: Rect) -> impl Iterator<Item = (i32, i32)> {
|
||
let (x0, x1) = (r.x0.div_euclid(WORK_TILE), (r.x1 - 1).div_euclid(WORK_TILE));
|
||
let (y0, y1) = (r.y0.div_euclid(WORK_TILE), (r.y1 - 1).div_euclid(WORK_TILE));
|
||
let empty = r.is_empty();
|
||
(y0..=y1).filter(move |_| !empty).flat_map(move |ty| (x0..=x1).map(move |tx| (tx, ty)))
|
||
}
|
||
|
||
/// The working pixels of `r`: the pre-stroke pixels `pre` where no dab has written yet.
|
||
fn read(&self, pre: &Surface, r: Rect) -> Region {
|
||
let mut out = Region::new(r, self.ch);
|
||
let n = self.ch;
|
||
let mut buf = Vec::new();
|
||
for c in Self::coords(r) {
|
||
let tr = Self::tile_rect(c);
|
||
let part = r.intersect(&tr);
|
||
let w = part.width() as usize * n;
|
||
let tile = self.tiles.get(&c);
|
||
if tile.is_none() {
|
||
pre.read_region_into(part, &mut buf);
|
||
}
|
||
for y in part.y0..part.y1 {
|
||
let src = match tile {
|
||
Some(t) => t.get(((y - tr.y0) * WORK_TILE + (part.x0 - tr.x0)) as usize * n..).and_then(|s| s.get(..w)),
|
||
None => buf.get((y - part.y0) as usize * w..).and_then(|s| s.get(..w)),
|
||
};
|
||
let dst = out.index(part.x0, y) * n;
|
||
if let (Some(s), Some(d)) = (src, out.data.get_mut(dst..dst + w)) {
|
||
d.copy_from_slice(s);
|
||
}
|
||
}
|
||
}
|
||
out
|
||
}
|
||
|
||
/// Store `region` as the working pixels of its rectangle.
|
||
fn write(&mut self, pre: &Surface, region: &Region) {
|
||
let (r, n) = (region.rect, self.ch);
|
||
for c in Self::coords(r) {
|
||
let tr = Self::tile_rect(c);
|
||
let tile = self.tiles.entry(c).or_insert_with(|| pre.read_region(tr));
|
||
let part = r.intersect(&tr);
|
||
let w = part.width() as usize * n;
|
||
for y in part.y0..part.y1 {
|
||
let dst = ((y - tr.y0) * WORK_TILE + (part.x0 - tr.x0)) as usize * n;
|
||
let src = region.index(part.x0, y) * n;
|
||
if let (Some(s), Some(d)) = (region.data.get(src..src + w), tile.get_mut(dst..dst + w)) {
|
||
d.copy_from_slice(s);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// A Dodge, Burn, Sponge, Blur, Sharpen or Smudge stroke shown while it is drawn: each new dab
|
||
/// runs the tool's effect on a working copy of the target, in order, and the changed area is
|
||
/// mixed back over the original at the brush opacity, as `apply_dab_stroke` does on release.
|
||
/// The working copy keeps full precision ([`WorkTiles`]), so the pixels are the commit's, and the
|
||
/// commit takes them over ([`Session::offer_live_dab`]) instead of running every dab again.
|
||
/// Sample All Layers isn't previewed (its composite spans the whole stroke).
|
||
pub struct LiveDab {
|
||
/// The active document with the stroke so far.
|
||
pub doc: std::sync::Arc<Document>,
|
||
cmd: String,
|
||
renderer: photocraft_paint::StrokeRenderer,
|
||
done: usize,
|
||
effect: DabEffect,
|
||
neighborhood: DabNeighborhood,
|
||
pre_surf: Surface,
|
||
work: WorkTiles,
|
||
id: Option<LayerId>,
|
||
params: Value,
|
||
opacity: f32,
|
||
sel: Option<Surface>,
|
||
lock: bool,
|
||
bounds: Rect,
|
||
/// Show the dabs finishing the stroke would add, so the preview at the last point is the
|
||
/// commit. Only for effects that a repeated dab doesn't change (Color Replacement): the
|
||
/// tail runs on a scratch copy at every push, so an effect that carries paint from dab to
|
||
/// dab (Smudge) would drift.
|
||
preview_tail: bool,
|
||
/// Where the shown tail is.
|
||
tail: Rect,
|
||
/// The tool and brush the stroke started with, which its commit must still have: the
|
||
/// document revision doesn't cover the session's brush or foreground colour. `None` for
|
||
/// strokes whose commit renders itself (Color Replacement).
|
||
tool: Option<(DabTool, Box<BrushSettings>)>,
|
||
/// The document and revision the stroke started on, for strokes started from the session's
|
||
/// active document ([`Self::with_effect`]); `None` for strokes on a prepared copy.
|
||
base: Option<(photocraft_doc::DocId, u64)>,
|
||
/// Every point rendered so far, in order.
|
||
points: Vec<StrokePoint>,
|
||
}
|
||
|
||
impl LiveDab {
|
||
/// Start a live stroke of `cmd` from its params; their `points` are rendered.
|
||
pub fn begin(s: &Session, cmd: &str, p: &Value) -> Result<Self> {
|
||
has_pixel_layer(s).map_err(EngineError::Other)?;
|
||
if sample_all_layers(p) {
|
||
return Err(bad(cmd, "Sample All Layers strokes aren't previewed live"));
|
||
}
|
||
let (stroke, id) = parse_brush(s, p, cmd)?;
|
||
let (tool, _) = DabTool::parse(s, cmd, p, &stroke)?;
|
||
let (neighborhood, spacing, brush) = (tool.neighborhood(), stroke.brush.spacing, Box::new(stroke.brush.clone()));
|
||
let effect = tool.clone();
|
||
let mut live = Self::with_neighborhood(s, cmd, p, stroke, id, neighborhood, |pre| effect.effect(pre.format(), spacing))?;
|
||
live.tool = Some((tool, brush));
|
||
Ok(live)
|
||
}
|
||
|
||
/// Start a live stroke of `cmd` that runs `effect` (made from the target before the stroke)
|
||
/// at each dab of `stroke`, reading `halo` pixels around it. `p` gives the target.
|
||
pub(crate) fn with_effect(
|
||
s: &Session,
|
||
cmd: &str,
|
||
p: &Value,
|
||
stroke: Stroke,
|
||
id: Option<LayerId>,
|
||
halo: i32,
|
||
effect: impl FnOnce(&Surface) -> DabEffect,
|
||
) -> Result<Self> {
|
||
Self::with_neighborhood(s, cmd, p, stroke, id, DabNeighborhood::Halo(halo), effect)
|
||
}
|
||
|
||
fn with_neighborhood(
|
||
s: &Session,
|
||
cmd: &str,
|
||
p: &Value,
|
||
stroke: Stroke,
|
||
id: Option<LayerId>,
|
||
neighborhood: DabNeighborhood,
|
||
effect: impl FnOnce(&Surface) -> DabEffect,
|
||
) -> Result<Self> {
|
||
let st = s.active().ok_or(EngineError::NoDocument)?;
|
||
let base = (st.doc.id, st.revision);
|
||
let doc = (*st.doc).clone();
|
||
let mut live = Self::with_neighborhood_on(doc, cmd, p, stroke, id, neighborhood, effect)?;
|
||
live.base = Some(base);
|
||
Ok(live)
|
||
}
|
||
|
||
/// [`Self::with_effect`] on `doc`, a copy of the active document with what the commit's own
|
||
/// edit does first (the Background Eraser's Background turned into a normal layer, say).
|
||
pub(crate) fn with_effect_on(
|
||
doc: Document,
|
||
cmd: &str,
|
||
p: &Value,
|
||
stroke: Stroke,
|
||
id: Option<LayerId>,
|
||
halo: i32,
|
||
effect: impl FnOnce(&Surface) -> DabEffect,
|
||
) -> Result<Self> {
|
||
Self::with_neighborhood_on(doc, cmd, p, stroke, id, DabNeighborhood::Halo(halo), effect)
|
||
}
|
||
|
||
fn with_neighborhood_on(
|
||
mut doc: Document,
|
||
cmd: &str,
|
||
p: &Value,
|
||
stroke: Stroke,
|
||
id: Option<LayerId>,
|
||
neighborhood: DabNeighborhood,
|
||
effect: impl FnOnce(&Surface) -> DabEffect,
|
||
) -> Result<Self> {
|
||
let sel = doc.selection.clone();
|
||
let (surf, lock) = crate::channel_cmds::target_surface(&mut doc, id, p)?;
|
||
let pre_surf = surf.clone();
|
||
let effect = effect(&pre_surf);
|
||
// `apply_dab_stroke` generates the same dabs (`dabs`: zoom 1, no smoothing scale).
|
||
let renderer = photocraft_paint::StrokeRenderer::new(&stroke.brush, None, 1.0).record_dabs();
|
||
let mut live = Self {
|
||
doc: std::sync::Arc::new(doc),
|
||
cmd: cmd.into(),
|
||
renderer,
|
||
done: 0,
|
||
effect,
|
||
neighborhood,
|
||
work: WorkTiles::new(pre_surf.channels()),
|
||
pre_surf,
|
||
id,
|
||
params: p.clone(),
|
||
opacity: stroke.brush.opacity,
|
||
sel,
|
||
lock,
|
||
bounds: Rect::EMPTY,
|
||
preview_tail: false,
|
||
tail: Rect::EMPTY,
|
||
tool: None,
|
||
base: None,
|
||
points: Vec::new(),
|
||
};
|
||
live.push(&stroke.points)?;
|
||
Ok(live)
|
||
}
|
||
|
||
/// Also show the stroke's finishing dabs (see `preview_tail`).
|
||
pub(crate) fn with_tail_preview(mut self) -> Result<Self> {
|
||
self.preview_tail = true;
|
||
self.push(&[])?;
|
||
Ok(self)
|
||
}
|
||
|
||
/// Everything the stroke has touched so far.
|
||
pub fn bounds(&self) -> Rect {
|
||
self.bounds
|
||
}
|
||
|
||
/// Run the dabs the renderer has generated since the last call on the working copy; returns
|
||
/// the rectangle they changed.
|
||
fn run_new_dabs(&mut self) -> Rect {
|
||
let ctx = self.renderer.ctx.clone();
|
||
let new: Vec<_> = self.renderer.dabs().get(self.done..).unwrap_or_default().to_vec();
|
||
let mut dmg = Rect::EMPTY;
|
||
for (k, d) in new.iter().enumerate() {
|
||
let r = ctx.dab_rect(d, false);
|
||
if r.is_empty() {
|
||
continue;
|
||
}
|
||
let mut region = self.work.read(&self.pre_surf, self.neighborhood.read_rect(r, d));
|
||
(self.effect)(&mut region, &ctx.footprint(d, self.done + k));
|
||
self.work.write(&self.pre_surf, ®ion.crop(r));
|
||
dmg = dmg.union(&r);
|
||
}
|
||
self.done += new.len();
|
||
dmg
|
||
}
|
||
|
||
/// Mix the working pixels of `r` back over the original into the shown document.
|
||
fn show(&mut self, r: Rect, work: &Region) -> Result<()> {
|
||
let (surf, _) = crate::channel_cmds::target_surface(std::sync::Arc::make_mut(&mut self.doc), self.id, &self.params)?;
|
||
photocraft_paint::retouch::mix_back(surf, &Region::read(&self.pre_surf, r), work, r, self.opacity, self.sel.as_ref(), self.lock);
|
||
Ok(())
|
||
}
|
||
|
||
/// Render more points; returns the rectangle that changed.
|
||
/// Invalid coordinates reject the whole batch without changing the preview.
|
||
/// Only the position and pressure of `pts` are used: the commands take no tilt or times.
|
||
pub fn push(&mut self, pts: &[StrokePoint]) -> Result<Rect> {
|
||
crate::brush_cmds::check_coords(pts, &self.cmd)?;
|
||
let pts: Vec<_> = pts.iter().map(|q| StrokePoint::new(q.x, q.y, q.pressure)).collect();
|
||
self.points.extend_from_slice(&pts);
|
||
self.renderer.push(&pts);
|
||
let dmg = self.run_new_dabs();
|
||
let ctx = self.renderer.ctx.clone();
|
||
// The finishing dabs, run on a scratch copy of the working pixels.
|
||
let mut tail = None;
|
||
if self.preview_tail {
|
||
let ds = self.renderer.tail_dabs();
|
||
let r = ds.iter().fold(Rect::EMPTY, |a, d| a.union(&ctx.dab_rect(d, false)));
|
||
if !r.is_empty() {
|
||
let mut region = self.work.read(&self.pre_surf, r.inflate(self.neighborhood.halo()));
|
||
for (k, d) in ds.iter().enumerate() {
|
||
(self.effect)(&mut region, &ctx.footprint(d, self.done + k));
|
||
}
|
||
tail = Some((r, region.crop(r)));
|
||
}
|
||
}
|
||
// Back to the stroke without the previous tail.
|
||
let old = std::mem::replace(&mut self.tail, Rect::EMPTY);
|
||
let shown = dmg.union(&old);
|
||
let tail_rect = tail.as_ref().map_or(Rect::EMPTY, |(r, _)| *r);
|
||
if shown.is_empty() && tail_rect.is_empty() {
|
||
return Ok(Rect::EMPTY);
|
||
}
|
||
self.bounds = self.bounds.union(&dmg).union(&tail_rect);
|
||
if !shown.is_empty() {
|
||
let work = self.work.read(&self.pre_surf, shown);
|
||
self.show(shown, &work)?;
|
||
}
|
||
if let Some((r, work)) = tail {
|
||
self.show(r, &work)?;
|
||
self.tail = r;
|
||
}
|
||
Ok(shown.union(&tail_rect))
|
||
}
|
||
|
||
/// The finished stroke as its commit would take it over ([`Session::offer_live_dab`]): the
|
||
/// finishing dabs are run, and the pixels are the ones the command with the same params and
|
||
/// points paints. Only while the brush, tool settings and layer are still the ones the stroke
|
||
/// started with: the document revision doesn't cover those.
|
||
pub(crate) fn into_commit(mut self, s: &Session) -> Result<crate::brush_cmds::LiveCommit> {
|
||
let (Some((tool, brush)), Some(base)) = (self.tool.take(), self.base) else {
|
||
return Err(bad(&self.cmd, "this stroke's commit renders itself"));
|
||
};
|
||
let (stroke, id) = parse_brush(s, &self.params, &self.cmd)?;
|
||
let (now, _) = DabTool::parse(s, &self.cmd, &self.params, &stroke)?;
|
||
if now != tool || stroke.brush != *brush || id != self.id {
|
||
return Err(bad(&self.cmd, "the brush, tool options or layer changed during the stroke"));
|
||
}
|
||
self.renderer.finish();
|
||
let dmg = self.run_new_dabs();
|
||
let shown = dmg.union(&self.tail);
|
||
if !shown.is_empty() {
|
||
let work = self.work.read(&self.pre_surf, shown);
|
||
self.show(shown, &work)?;
|
||
}
|
||
self.bounds = self.bounds.union(&dmg);
|
||
let (surf, _) = crate::channel_cmds::target_surface(std::sync::Arc::make_mut(&mut self.doc), self.id, &self.params)?;
|
||
let surface = surf.clone();
|
||
Ok(crate::brush_cmds::LiveCommit {
|
||
mixer: None,
|
||
cmd: self.cmd,
|
||
base,
|
||
params: self.params,
|
||
seed: None,
|
||
points: self.points,
|
||
surface,
|
||
bounds: self.bounds,
|
||
})
|
||
}
|
||
}
|
||
|
||
/// "Sample All Layers" for Spot Healing, 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));
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------------------------
|
||
// Registry
|
||
// ---------------------------------------------------------------------------------------------
|
||
|
||
/// 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":1..5000 px=tool size,"hardness":0..100=tool hardness,"opacity":1..100=100,"flow":1..100=100,"spacing":1..1000 (% of size)=Brush Settings spacing,"layer":id?=active,"target":"pixels"|"mask"|"filterMask"|"quickMask"|{"channel":i}=Channels panel target"#, $extra, "}")
|
||
};
|
||
}
|
||
|
||
/// Retouching command specs.
|
||
pub fn specs() -> Vec<CommandSpec> {
|
||
vec![
|
||
CommandSpec {
|
||
id: "paint.cloneStamp",
|
||
label: "Clone Stamp",
|
||
menu: &[],
|
||
shortcut: None,
|
||
params: brush_params!(
|
||
r#","source":[sx,sy] (sampled under the first point) | "offset":[dx,dy],"aligned":bool=true,"sampleLayer":"current|currentAndBelow|all"="current","mode":"normal|multiply|…"="normal","wrap":bool=false (repeat source and stroke at document edges for Pattern Preview) → {"damage","offset","aligned","nextSource"}"#
|
||
),
|
||
enabled: has_pixel_layer,
|
||
run: clone_stamp,
|
||
journal: true,
|
||
},
|
||
CommandSpec {
|
||
id: "paint.patternStamp",
|
||
label: "Pattern Stamp",
|
||
menu: &[],
|
||
shortcut: None,
|
||
params: brush_params!(
|
||
r#","pattern":id|name?=Patterns panel current,"scale":1..1000 %=100,"angle":deg=0,"aligned":bool=true,"impressionist":bool=false,"mode":"normal|multiply|…"="normal","phase":[px,py]? → {"damage","phase","aligned","pattern"}"#
|
||
),
|
||
enabled: pattern_stamp::enabled,
|
||
run: pattern_stamp::run,
|
||
journal: true,
|
||
},
|
||
CommandSpec {
|
||
id: "paint.healingBrush",
|
||
label: "Healing Brush",
|
||
menu: &[],
|
||
shortcut: None,
|
||
params: brush_params!(
|
||
r#","source":[sx,sy] | "offset":[dx,dy],"aligned":bool=true,"sampleLayer":"current|currentAndBelow|all"="current","mode":"normal|…"="normal" → {"damage","offset","aligned","nextSource"}"#
|
||
),
|
||
enabled: has_pixel_layer,
|
||
run: healing_brush,
|
||
journal: true,
|
||
},
|
||
CommandSpec {
|
||
id: "paint.spotHealing",
|
||
label: "Spot Healing Brush",
|
||
menu: &[],
|
||
shortcut: None,
|
||
params: brush_params!(r#","type":"contentAware|createTexture|proximityMatch"="contentAware","sampleAllLayers":bool=false"#),
|
||
enabled: has_pixel_layer,
|
||
run: spot_healing,
|
||
journal: true,
|
||
},
|
||
CommandSpec {
|
||
id: "paint.patch",
|
||
label: "Patch",
|
||
menu: &[],
|
||
shortcut: None,
|
||
params: r#"{"offset":[dx,dy] (how far the selection was dragged),"mode":"source|destination"="source","contentAware":bool=false (fill the selection content-aware from the dragged-to place; a background job),"structure":1..7=4 (content-aware: 3..7 copy the middle exactly, 1..2 re-synthesise more of it),"color":0..10=0 (content-aware: how far the copy's level adapts to the selection's surroundings),"sampleAllLayers":bool=false (content-aware),"layer":id?=active,"target":"pixels"|"mask"|"filterMask"|"quickMask"|{"channel":i}=Channels panel target} → {"damage","offset"}"#,
|
||
enabled: patch::enabled,
|
||
run: patch::patch,
|
||
journal: true,
|
||
},
|
||
CommandSpec {
|
||
id: "paint.contentAwareMove",
|
||
label: "Content-Aware Move",
|
||
menu: &[],
|
||
shortcut: None,
|
||
params: r#"{"offset":[dx,dy] (how far the selection was dragged),"mode":"move|extend"="move","structure":1..7=4 (3..7 copy the content exactly but for a band about a patch wide along its edge; 2 widens the band, 1 re-synthesises the content too),"color":0..10=0 (0 keeps the content's level; higher values bring it towards its new place, by up to about 0.04 × color),"sampleAllLayers":bool=false,"scaleX":%=100,"scaleY":%=100 (Transform On Drop: 1..10000, negative flips),"rotate":deg=0 (Transform On Drop, clockwise; scale and rotation are about the selection's centre, before the offset; a transformed patch may reach past the canvas edge),"layer":id?=active,"target":"pixels"|"mask"|"filterMask"|"quickMask"|{"channel":i}=Channels panel target} → {"damage","offset","mode","transformed"?} (a background job; the selection moves, and is transformed, with the content)"#,
|
||
enabled: content_aware_move::enabled,
|
||
run: content_aware_move::content_aware_move,
|
||
journal: true,
|
||
},
|
||
CommandSpec {
|
||
id: "paint.remove",
|
||
label: "Remove",
|
||
menu: &[],
|
||
shortcut: None,
|
||
params: brush_params!(
|
||
r#","strokes":[[[x,y,pressure?],…],…]? (several strokes removed together, as one job and one undo step; else "points"),"closeLoops":bool=true (a stroke around an object removes what it encloses),"sampleAllLayers":bool=false,"newLayer":bool=false (the fill goes to a new layer above the target, which stays as it was) → {"damage","layer"?} (a background job)"#
|
||
),
|
||
enabled: has_pixel_layer,
|
||
run: remove::remove,
|
||
journal: true,
|
||
},
|
||
CommandSpec {
|
||
id: "paint.dodge",
|
||
label: "Dodge",
|
||
menu: &[],
|
||
shortcut: None,
|
||
params: brush_params!(r#","range":"shadows|midtones|highlights"="midtones","exposure":1..100=50,"protectTones":bool=true"#),
|
||
enabled: has_pixel_layer,
|
||
run: |s, p| dab_cmd(s, p, "paint.dodge"),
|
||
journal: true,
|
||
},
|
||
CommandSpec {
|
||
id: "paint.burn",
|
||
label: "Burn",
|
||
menu: &[],
|
||
shortcut: None,
|
||
params: brush_params!(r#","range":"shadows|midtones|highlights"="midtones","exposure":1..100=50,"protectTones":bool=true"#),
|
||
enabled: has_pixel_layer,
|
||
run: |s, p| dab_cmd(s, p, "paint.burn"),
|
||
journal: true,
|
||
},
|
||
CommandSpec {
|
||
id: "paint.sponge",
|
||
label: "Sponge",
|
||
menu: &[],
|
||
shortcut: None,
|
||
params: brush_params!(r#","mode":"desaturate|saturate"="desaturate","vibrance":bool=true (flow = sponge strength)"#),
|
||
enabled: has_pixel_layer,
|
||
run: |s, p| dab_cmd(s, p, "paint.sponge"),
|
||
journal: true,
|
||
},
|
||
CommandSpec {
|
||
id: "paint.blur",
|
||
label: "Blur",
|
||
menu: &[],
|
||
shortcut: None,
|
||
params: brush_params!(r#","strength":1..100=50,"sampleAllLayers":bool=false"#),
|
||
enabled: has_pixel_layer,
|
||
run: |s, p| dab_cmd(s, p, "paint.blur"),
|
||
journal: true,
|
||
},
|
||
CommandSpec {
|
||
id: "paint.sharpen",
|
||
label: "Sharpen",
|
||
menu: &[],
|
||
shortcut: None,
|
||
params: brush_params!(r#","strength":1..100=50,"protectDetail":bool=true,"sampleAllLayers":bool=false"#),
|
||
enabled: has_pixel_layer,
|
||
run: |s, p| dab_cmd(s, p, "paint.sharpen"),
|
||
journal: true,
|
||
},
|
||
CommandSpec {
|
||
id: "paint.smudge",
|
||
label: "Smudge",
|
||
menu: &[],
|
||
shortcut: None,
|
||
params: brush_params!(r#","strength":1..100=50,"fingerPainting":bool=false (starts with the foreground colour),"sampleAllLayers":bool=false"#),
|
||
enabled: has_pixel_layer,
|
||
run: |s, p| dab_cmd(s, p, "paint.smudge"),
|
||
journal: true,
|
||
},
|
||
CommandSpec {
|
||
id: "paint.historyBrush",
|
||
label: "History Brush",
|
||
menu: &[],
|
||
shortcut: None,
|
||
params: brush_params!(
|
||
r#","state":row (a History panel row, as history.setSource: 0 = oldest state, then the current one, then the greyed redo states),"snapshot":id (a history.newSnapshot id; default: the History panel's source, else the oldest held state; a source of another canvas size is an error)"#
|
||
),
|
||
enabled: has_pixel_layer,
|
||
run: history_brush,
|
||
journal: true,
|
||
},
|
||
CommandSpec {
|
||
id: "paint.artHistoryBrush",
|
||
label: "Art History Brush",
|
||
menu: &[],
|
||
shortcut: None,
|
||
params: brush_params!(
|
||
r#","state":row (a History panel row, as history.setSource),"snapshot":id (a history.newSnapshot id; default: the History panel's source, as paint.historyBrush; a source of another canvas size is an error),"style":"tightShort|tightMedium|tightLong|looseMedium|looseLong|dab|tightCurl|looseCurl"="tightMedium","area":0..100=50,"tolerance":0..100=0"#
|
||
),
|
||
enabled: has_pixel_layer,
|
||
run: art_history::run,
|
||
journal: true,
|
||
},
|
||
]
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests;
|