mirror of
https://github.com/storytold/photocraft.git
synced 2026-10-22 23:24:07 -04:00
Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
389a1d5bd6 | ||
|
|
eb2ef35279 | ||
|
|
f93d544996 | ||
|
|
defec59203 | ||
|
|
5306423a8e |
No files matched your search
@@ -0,0 +1,87 @@
|
||||
//! Release cost of a live sequential-dab stroke on a 24 MP layer, in release mode: the commit
|
||||
//! rendering the stroke again (no offer) against taking the live pixels (`offer_live_dab`, which
|
||||
//! runs the finishing dabs, plus the commit). Also the live stroke's per-frame cost while drawn.
|
||||
use std::time::Instant;
|
||||
|
||||
use photocraft_engine::{EngineError, Session, paint::StrokePoint, retouch_cmds::LiveDab};
|
||||
use photocraft_geom::Rect;
|
||||
use serde_json::json;
|
||||
|
||||
const FRAMES: usize = 60;
|
||||
|
||||
fn ms(t: Instant) -> f64 {
|
||||
t.elapsed().as_secs_f64() * 1e3
|
||||
}
|
||||
|
||||
fn run() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", json!({"width":6000,"height":4000,"depth":8,"mode":"rgb"}))?;
|
||||
s.execute("layer.new.layer", json!({}))?;
|
||||
s.edit("stripes", |doc, active| {
|
||||
let id = active.ok_or_else(|| EngineError::Other("no layer".into()))?;
|
||||
let surface = doc.layer_mut(id).and_then(|l| l.surface_mut()).ok_or_else(|| EngineError::Other("no surface".into()))?;
|
||||
for x in (0..6000).step_by(64) {
|
||||
let c = if x % 128 == 0 { [0.9, 0.3, 0.1, 1.0] } else { [0.1, 0.4, 0.8, 1.0] };
|
||||
surface.fill_rect(Rect::new(x, 0, (x + 64).min(6000), 4000), &c);
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
// ~1000 px/s at 60 fps: 16 px of drag per frame, in four samples.
|
||||
let pts: Vec<[f64; 2]> = std::iter::once([1000.0, 2000.0])
|
||||
.chain((1..=FRAMES).flat_map(|f| (1..=4).map(move |k| [1000.0 + (f as f64 - 1.0) * 16.0 + k as f64 * 4.0, 2000.0])))
|
||||
.collect();
|
||||
for cmd in ["paint.blur", "paint.smudge", "paint.dodge"] {
|
||||
for size in [50, 150, 300, 600] {
|
||||
let p = |v: &[[f64; 2]]| json!({"points": v, "size": size, "hardness": 0, "strength": 50, "spacing": 25});
|
||||
let draw = |s: &Session| -> Result<(LiveDab, Vec<f64>), Box<dyn std::error::Error>> {
|
||||
let mut live = LiveDab::begin(s, cmd, &p(&pts[..1]))?;
|
||||
let mut frames = Vec::new();
|
||||
for batch in pts[1..].chunks(4) {
|
||||
let b: Vec<_> = batch.iter().map(|q| StrokePoint::new(q[0], q[1], 1.0)).collect();
|
||||
let t = Instant::now();
|
||||
live.push(&b)?;
|
||||
frames.push(ms(t));
|
||||
}
|
||||
Ok((live, frames))
|
||||
};
|
||||
let (mut before, mut after) = (Vec::new(), Vec::new());
|
||||
let mut frames = Vec::new();
|
||||
for _ in 0..3 {
|
||||
let (_, f) = draw(&s)?;
|
||||
frames.extend(f);
|
||||
let t = Instant::now();
|
||||
s.execute(cmd, p(&pts))?;
|
||||
before.push(ms(t));
|
||||
s.execute("edit.undo", json!({}))?;
|
||||
let (live, _) = draw(&s)?;
|
||||
let t = Instant::now();
|
||||
s.offer_live_dab(live)?;
|
||||
let r = s.execute(cmd, p(&pts))?;
|
||||
after.push(ms(t));
|
||||
if r.get("fromLive").is_none() {
|
||||
return Err("the commit didn't take the live pixels".into());
|
||||
}
|
||||
s.execute("edit.undo", json!({}))?;
|
||||
}
|
||||
before.sort_by(f64::total_cmp);
|
||||
after.sort_by(f64::total_cmp);
|
||||
frames.sort_by(f64::total_cmp);
|
||||
let total: f64 = frames.iter().sum::<f64>() / 3.0;
|
||||
println!(
|
||||
"{cmd:12} size={size:4} release: render again={:7.1} ms take live={:6.1} ms | live frames: max={:6.2} ms total={:7.1} ms",
|
||||
before[1],
|
||||
after[1],
|
||||
frames.last().copied().unwrap_or(0.0),
|
||||
total
|
||||
);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn main() {
|
||||
if let Err(e) = run() {
|
||||
eprintln!("{e}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
@@ -672,6 +672,17 @@ impl Session {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// [`Self::offer_live_stroke`] for a live Dodge, Burn, Sponge, Blur, Sharpen or Smudge stroke,
|
||||
/// taken by the command it was drawn for: the stroke's finishing dabs are run first. Refused
|
||||
/// when the brush, tool options or layer changed during the stroke.
|
||||
pub fn offer_live_dab(&mut self, live: retouch_cmds::LiveDab) -> Result<()> {
|
||||
// A refused offer withdraws the previous one too.
|
||||
self.live_commit = None;
|
||||
let commit = live.into_commit(self)?;
|
||||
self.live_commit = Some(commit);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Withdraw an offer the command didn't take.
|
||||
pub fn clear_live_stroke(&mut self) {
|
||||
self.live_commit = None;
|
||||
|
||||
@@ -900,7 +900,7 @@ fn tone_range(p: &Value, cmd: &str) -> Result<ToneRange> {
|
||||
|
||||
/// 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)]
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
enum DabTool {
|
||||
Tone { burn: bool, range: ToneRange, exposure: f32, protect: bool },
|
||||
Sponge { saturate: bool, vibrance: bool },
|
||||
@@ -911,6 +911,37 @@ enum DabTool {
|
||||
/// 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)> {
|
||||
@@ -952,10 +983,11 @@ impl DabTool {
|
||||
}
|
||||
|
||||
/// How far around each dab the effect reads (Blur and Sharpen read a neighbourhood).
|
||||
fn halo(&self) -> i32 {
|
||||
fn neighborhood(&self) -> DabNeighborhood {
|
||||
match self {
|
||||
Self::Focus { sigma, .. } => (sigma * 3.0).ceil() as i32 + 1,
|
||||
_ => 0,
|
||||
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),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1008,21 +1040,110 @@ 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.halo(), |work, fp| match &mut sampler {
|
||||
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),
|
||||
}))
|
||||
})?;
|
||||
Ok(json!({ "damage": damage_json(dmg) }))
|
||||
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.
|
||||
@@ -1031,9 +1152,9 @@ pub struct LiveDab {
|
||||
renderer: photocraft_paint::StrokeRenderer,
|
||||
done: usize,
|
||||
effect: DabEffect,
|
||||
halo: i32,
|
||||
neighborhood: DabNeighborhood,
|
||||
pre_surf: Surface,
|
||||
work: Surface,
|
||||
work: WorkTiles,
|
||||
id: Option<LayerId>,
|
||||
params: Value,
|
||||
opacity: f32,
|
||||
@@ -1047,6 +1168,14 @@ pub struct LiveDab {
|
||||
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.
|
||||
base: (photocraft_doc::DocId, u64),
|
||||
/// Every point rendered so far, in order.
|
||||
points: Vec<StrokePoint>,
|
||||
}
|
||||
|
||||
impl LiveDab {
|
||||
@@ -1058,8 +1187,11 @@ impl LiveDab {
|
||||
}
|
||||
let (stroke, id) = parse_brush(s, p, cmd)?;
|
||||
let (tool, _) = DabTool::parse(s, cmd, p, &stroke)?;
|
||||
let (halo, spacing) = (tool.halo(), stroke.brush.spacing);
|
||||
Self::with_effect(s, cmd, p, stroke, id, halo, |pre| tool.effect(pre.format(), spacing))
|
||||
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)
|
||||
@@ -1073,7 +1205,21 @@ impl LiveDab {
|
||||
halo: i32,
|
||||
effect: impl FnOnce(&Surface) -> DabEffect,
|
||||
) -> Result<Self> {
|
||||
let mut doc = (*s.active().ok_or(EngineError::NoDocument)?.doc).clone();
|
||||
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 mut doc = (*st.doc).clone();
|
||||
let sel = doc.selection.clone();
|
||||
let (surf, lock) = crate::channel_cmds::target_surface(&mut doc, id, p)?;
|
||||
let pre_surf = surf.clone();
|
||||
@@ -1086,8 +1232,8 @@ impl LiveDab {
|
||||
renderer,
|
||||
done: 0,
|
||||
effect,
|
||||
halo,
|
||||
work: pre_surf.clone(),
|
||||
neighborhood,
|
||||
work: WorkTiles::new(pre_surf.channels()),
|
||||
pre_surf,
|
||||
id,
|
||||
params: p.clone(),
|
||||
@@ -1097,6 +1243,9 @@ impl LiveDab {
|
||||
bounds: Rect::EMPTY,
|
||||
preview_tail: false,
|
||||
tail: Rect::EMPTY,
|
||||
tool: None,
|
||||
base,
|
||||
points: Vec::new(),
|
||||
};
|
||||
live.push(&stroke.points)?;
|
||||
Ok(live)
|
||||
@@ -1114,11 +1263,9 @@ impl LiveDab {
|
||||
self.bounds
|
||||
}
|
||||
|
||||
/// Render more points; returns the rectangle that changed.
|
||||
/// Invalid coordinates reject the whole batch without changing the preview.
|
||||
pub fn push(&mut self, pts: &[StrokePoint]) -> Result<Rect> {
|
||||
crate::brush_cmds::check_coords(pts, &self.cmd)?;
|
||||
self.renderer.push(pts);
|
||||
/// 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;
|
||||
@@ -1127,19 +1274,39 @@ impl LiveDab {
|
||||
if r.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let mut region = Region::read(&self.work, r.inflate(self.halo));
|
||||
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));
|
||||
region.crop(r).write(&mut self.work);
|
||||
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 = Region::read(&self.work, r.inflate(self.halo));
|
||||
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));
|
||||
}
|
||||
@@ -1154,17 +1321,42 @@ impl LiveDab {
|
||||
return Ok(Rect::EMPTY);
|
||||
}
|
||||
self.bounds = self.bounds.union(&dmg).union(&tail_rect);
|
||||
let (surf, _) = crate::channel_cmds::target_surface(std::sync::Arc::make_mut(&mut self.doc), self.id, &self.params)?;
|
||||
if !shown.is_empty() {
|
||||
let (orig, work) = (Region::read(&self.pre_surf, shown), Region::read(&self.work, shown));
|
||||
photocraft_paint::retouch::mix_back(surf, &orig, &work, shown, self.opacity, self.sel.as_ref(), self.lock);
|
||||
let work = self.work.read(&self.pre_surf, shown);
|
||||
self.show(shown, &work)?;
|
||||
}
|
||||
if let Some((r, work)) = tail {
|
||||
photocraft_paint::retouch::mix_back(surf, &Region::read(&self.pre_surf, r), &work, r, self.opacity, self.sel.as_ref(), self.lock);
|
||||
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)) = self.tool.take() 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 { cmd: self.cmd, base: self.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
|
||||
|
||||
@@ -1601,6 +1601,122 @@ fn live_dodge_matches_the_commit_across_cells() {
|
||||
assert!(worst <= 2e-4, "live and commit differ by {worst}");
|
||||
}
|
||||
|
||||
/// The sequential-dab tools' options for the live hand-off tests.
|
||||
fn dab_cases() -> [(&'static str, Value); 6] {
|
||||
[
|
||||
("paint.dodge", json!({"exposure": 60, "range": "midtones"})),
|
||||
("paint.burn", json!({"exposure": 40, "range": "shadows", "protectTones": false})),
|
||||
("paint.sponge", json!({"mode": "saturate"})),
|
||||
("paint.blur", json!({"strength": 80})),
|
||||
("paint.sharpen", json!({"strength": 60})),
|
||||
("paint.smudge", json!({"strength": 70})),
|
||||
]
|
||||
}
|
||||
|
||||
/// A stroke's params: `points`, a 40 px brush of `hardness`, and the tool's options.
|
||||
fn dab_opts(points: &[[f64; 2]], hardness: u32, extra: &Value) -> Value {
|
||||
let mut o = json!({"points": points, "size": 40, "hardness": hardness, "spacing": 25});
|
||||
for (k, v) in extra.as_object().unwrap() {
|
||||
o[k] = v.clone();
|
||||
}
|
||||
o
|
||||
}
|
||||
|
||||
/// The active layer's pixels over the whole canvas.
|
||||
fn layer_pixels(s: &Session) -> Vec<f32> {
|
||||
let st = s.active().unwrap();
|
||||
st.doc.layer(st.active_layer.unwrap()).unwrap().surface().unwrap().read_region(st.doc.bounds())
|
||||
}
|
||||
|
||||
/// Draw `points` live as the canvas does (the first with `begin`, the rest in pen batches that
|
||||
/// carry tilt and times, which the commands don't take) and offer the stroke to its commit.
|
||||
fn offer_live(s: &mut Session, cmd: &str, points: &[[f64; 2]], hardness: u32, extra: &Value) {
|
||||
let mut live = LiveDab::begin(s, cmd, &dab_opts(&points[..1], hardness, extra)).unwrap();
|
||||
for (i, batch) in points[1..].chunks(2).enumerate() {
|
||||
let pts: Vec<_> = batch
|
||||
.iter()
|
||||
.map(|q| {
|
||||
let mut p = StrokePoint::new(q[0], q[1], 1.0);
|
||||
(p.tilt_x, p.time) = (25.0, 16.0 * i as f64);
|
||||
p
|
||||
})
|
||||
.collect();
|
||||
live.push(&pts).unwrap();
|
||||
}
|
||||
s.offer_live_dab(live).unwrap();
|
||||
}
|
||||
|
||||
/// Releasing a live Dodge, Burn, Sponge, Blur, Sharpen or Smudge stroke commits the pixels it
|
||||
/// already drew instead of running every dab again, and they are exactly what the command paints
|
||||
/// on its own: at every depth (the live working copy isn't rounded between dabs), soft and hard,
|
||||
/// over semi-transparent pixels and where the stroke doubles back.
|
||||
#[test]
|
||||
fn live_dab_strokes_commit_their_pixels_and_they_are_the_commands() {
|
||||
let points = [[10.0, 40.0], [100.0, 60.0], [180.0, 40.0], [60.0, 30.0], [150.0, 70.0], [151.0, 70.5], [-15.0, 85.0]];
|
||||
for depth in DEPTHS {
|
||||
let mut s = session(200, 90, depth, "rgb");
|
||||
s.execute("layer.new.layer", json!({})).unwrap();
|
||||
paint_layer(&mut s, |x, y| {
|
||||
let mut c = texture(x, y);
|
||||
c[3] = if (x / 23 + y / 17) % 4 == 0 { 0.0 } else { 0.35 + 0.065 * ((x * 7 + y * 3) % 11) as f32 };
|
||||
c
|
||||
});
|
||||
let before = layer_pixels(&s);
|
||||
for (cmd, extra) in dab_cases() {
|
||||
for hardness in [0, 100] {
|
||||
let rendered = s.execute(cmd, dab_opts(&points, hardness, &extra)).unwrap();
|
||||
let want = layer_pixels(&s);
|
||||
assert_ne!(want, before, "{cmd} {depth} {hardness}: the stroke changes pixels");
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert!(rendered.get("fromLive").is_none());
|
||||
|
||||
offer_live(&mut s, cmd, &points, hardness, &extra);
|
||||
let taken = s.execute(cmd, dab_opts(&points, hardness, &extra)).unwrap();
|
||||
assert_eq!(taken["fromLive"], json!(true), "{cmd} {depth} {hardness}");
|
||||
assert_eq!(taken["damage"], rendered["damage"], "{cmd} {depth} {hardness}");
|
||||
assert!(layer_pixels(&s) == want, "{cmd} at {depth}-bit, hardness {hardness}: the live pixels aren't the command's");
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert!(layer_pixels(&s) == before, "{cmd} {depth} {hardness}: undo restores the layer");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The offer is only taken by its own stroke: another stroke, a brush or option changed during
|
||||
/// the stroke, or a stroke whose commit renders itself (Color Replacement) render as before.
|
||||
#[test]
|
||||
fn live_dab_offers_are_refused_or_dropped_when_they_dont_match() {
|
||||
let points = [[10.0, 40.0], [100.0, 60.0], [180.0, 40.0]];
|
||||
let blur = json!({"strength": 80});
|
||||
let mut s = session(200, 90, 8, "rgb");
|
||||
paint_layer(&mut s, texture);
|
||||
// Another stroke drops the offer and renders itself.
|
||||
offer_live(&mut s, "paint.blur", &points, 0, &blur);
|
||||
let other = [[10.0, 40.0], [100.0, 61.0], [180.0, 40.0]];
|
||||
let r = s.execute("paint.blur", dab_opts(&other, 0, &blur)).unwrap();
|
||||
assert!(r.get("fromLive").is_none());
|
||||
assert!(s.live_commit.is_none(), "the offer is used up");
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
// The session brush (not in the params) changed during the stroke; the refusal also
|
||||
// withdraws an earlier offer.
|
||||
offer_live(&mut s, "paint.blur", &points, 0, &blur);
|
||||
let live = LiveDab::begin(&s, "paint.blur", &dab_opts(&points, 0, &blur)).unwrap();
|
||||
let roundness = s.tools.brush.roundness;
|
||||
s.tools.brush.roundness = roundness * 0.5;
|
||||
assert!(s.offer_live_dab(live).is_err());
|
||||
assert!(s.live_commit.is_none(), "a refused offer leaves no earlier one behind");
|
||||
s.tools.brush.roundness = roundness;
|
||||
// Finger Painting's colour changed during the stroke.
|
||||
let finger = json!({"strength": 70, "fingerPainting": true});
|
||||
let live = LiveDab::begin(&s, "paint.smudge", &dab_opts(&points, 0, &finger)).unwrap();
|
||||
s.tools.foreground = [0.9, 0.1, 0.1, 1.0];
|
||||
assert!(s.offer_live_dab(live).is_err());
|
||||
// Color Replacement's live stroke runs on `LiveDab` too, but its commit renders itself.
|
||||
let live = crate::brush_cmds::live_color_replacement(&s, &json!({"points": points, "color": [1.0, 0.0, 0.0, 1.0]})).unwrap();
|
||||
assert!(s.offer_live_dab(live).is_err());
|
||||
assert!(s.live_commit.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn retouch_strokes_refuse_absurd_point_coordinates() {
|
||||
// #976: a segment to (1e300, 0) has an infinite length and the dab walker would never stop, so
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
use photocraft_engine::{Session, retouch_cmds::LiveDab};
|
||||
use photocraft_paint::StrokePoint;
|
||||
use serde_json::json;
|
||||
|
||||
fn setup(depth: u32, mode: &str) -> Session {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", json!({"width":200,"height":120,"depth":depth,"mode":mode})).unwrap();
|
||||
s.execute("layer.new.layer", json!({})).unwrap();
|
||||
s.edit("texture", |doc, active| {
|
||||
let bounds = doc.bounds();
|
||||
let surf = doc.layer_mut(active.unwrap()).unwrap().surface_mut().unwrap();
|
||||
let fmt = surf.format();
|
||||
let mut px = Vec::new();
|
||||
for y in 0..120 {
|
||||
for x in 0..200 {
|
||||
px.extend(photocraft_raster::from_rgba(
|
||||
&fmt,
|
||||
[((x * 7 + y * 3) % 31) as f32 / 31.0, x as f32 / 200.0, y as f32 / 120.0, 0.4 + ((x + y) % 7) as f32 / 12.0],
|
||||
));
|
||||
}
|
||||
}
|
||||
surf.write_region(bounds, &px);
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
fn pixels(s: &Session) -> Vec<f32> {
|
||||
let st = s.active().unwrap();
|
||||
st.doc.layer(st.active_layer.unwrap()).unwrap().surface().unwrap().read_region(st.doc.bounds())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pressure_shape_and_smoothing_match_replay() {
|
||||
let mut failures = Vec::new();
|
||||
for cmd in ["paint.smudge", "paint.blur", "paint.sharpen", "paint.dodge", "paint.burn", "paint.sponge"] {
|
||||
for case in ["pressure", "locked", "ellipse", "smoothing", "scattering"] {
|
||||
for depth in [8, 16, 32] {
|
||||
let mut s = setup(depth, "rgb");
|
||||
match case {
|
||||
"locked" => {
|
||||
s.edit("lock alpha", |doc, active| {
|
||||
doc.layer_mut(active.unwrap()).unwrap().locks.transparency = true;
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
"ellipse" => {
|
||||
s.tools.brush.roundness = 0.2;
|
||||
s.tools.brush.angle = 37.0;
|
||||
}
|
||||
"smoothing" => {
|
||||
s.tools.brush.smoothing.amount = 0.6;
|
||||
}
|
||||
"scattering" => {
|
||||
s.tools.brush.scattering.enabled = true;
|
||||
s.tools.brush.scattering.scatter.jitter = 1.0;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
let points = [[40.0, 40.0, 0.1], [65.0, 50.0, 0.4], [100.0, 60.0, 1.0], [140.0, 70.0, 0.7], [60.0, 40.0, 0.2]];
|
||||
let params = json!({"points":points,"size":40,"hardness":70,"strength":80,"opacity":55,"spacing":25});
|
||||
s.execute(cmd, params.clone()).unwrap();
|
||||
let want = pixels(&s);
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
let mut start = params.clone();
|
||||
start["points"] = json!([points[0]]);
|
||||
let mut live = LiveDab::begin(&s, cmd, &start).unwrap();
|
||||
for q in &points[1..] {
|
||||
live.push(&[StrokePoint::new(q[0], q[1], q[2] as f32)]).unwrap();
|
||||
}
|
||||
s.offer_live_dab(live).unwrap();
|
||||
let result = s.execute(cmd, params).unwrap();
|
||||
assert_eq!(result["fromLive"], true);
|
||||
let got = pixels(&s);
|
||||
let count = got.iter().zip(&want).filter(|(a, b)| a != b).count();
|
||||
let max = got.iter().zip(&want).map(|(a, b)| (a - b).abs()).fold(0.0f32, f32::max);
|
||||
if count > 0 {
|
||||
failures.push(format!("{cmd} {case} depth={depth}: {count} channels differ, max={max}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(failures.is_empty(), "{}", failures.join("\n"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_modes_selection_and_lock_match_replay() {
|
||||
for mode in ["rgb", "gray", "cmyk", "lab"] {
|
||||
for depth in [8, 16, 32] {
|
||||
let mut s = setup(depth, mode);
|
||||
s.edit("lock alpha", |doc, active| {
|
||||
doc.layer_mut(active.unwrap()).unwrap().locks.transparency = true;
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
s.execute("select.rect", json!({"x":30,"y":20,"width":110,"height":60})).unwrap();
|
||||
let points = [[-10.0, 30.0], [80.0, 50.0], [160.0, 80.0], [60.0, 60.0]];
|
||||
for cmd in ["paint.blur", "paint.sharpen", "paint.dodge", "paint.burn", "paint.sponge", "paint.smudge"] {
|
||||
let params = json!({"points":points,"size":30,"hardness":40,"strength":70,"opacity":37});
|
||||
let expected_damage = s.execute(cmd, params.clone()).unwrap()["damage"].clone();
|
||||
let want = pixels(&s);
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
let mut start = params.clone();
|
||||
start["points"] = json!([points[0]]);
|
||||
let mut live = LiveDab::begin(&s, cmd, &start).unwrap();
|
||||
for q in &points[1..] {
|
||||
live.push(&[StrokePoint::new(q[0], q[1], 1.0)]).unwrap();
|
||||
}
|
||||
s.offer_live_dab(live).unwrap();
|
||||
let result = s.execute(cmd, params).unwrap();
|
||||
assert_eq!(result["fromLive"], true);
|
||||
assert_eq!(result["damage"], expected_damage);
|
||||
assert_eq!(pixels(&s), want, "{mode} {depth} {cmd}");
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn changed_document_refuses_live_pixels() {
|
||||
let mut s = setup(8, "rgb");
|
||||
let p = json!({"points":[[40,40],[80,60]],"size":30});
|
||||
let live = LiveDab::begin(&s, "paint.blur", &p).unwrap();
|
||||
s.execute("paint.stroke", json!({"points":[[40,40],[80,60]],"color":"#ffffff","size":20})).unwrap();
|
||||
let mut replay = Session::new();
|
||||
replay.add_document((*s.active().unwrap().doc).clone(), None);
|
||||
replay.tools.brush = s.tools.brush.clone();
|
||||
s.offer_live_dab(live).unwrap();
|
||||
assert!(s.execute("paint.blur", p.clone()).unwrap().get("fromLive").is_none());
|
||||
replay.execute("paint.blur", p).unwrap();
|
||||
assert_eq!(pixels(&s), pixels(&replay));
|
||||
}
|
||||
|
||||
/// An independent paint-level oracle gives Smudge a generously padded working region. Pixels
|
||||
/// picked up by the initial small dab must not depend on the final stroke's bounding rectangle.
|
||||
#[test]
|
||||
fn smudge_pickup_matches_full_neighborhood_when_pressure_expands() {
|
||||
use photocraft_paint::{
|
||||
Stroke,
|
||||
retouch::{Smudge, apply_dab_stroke},
|
||||
};
|
||||
|
||||
let points = [[40.0, 40.0, 0.1], [65.0, 50.0, 0.4], [100.0, 60.0, 1.0], [140.0, 70.0, 0.7], [60.0, 40.0, 0.2]];
|
||||
for depth in [8, 16, 32] {
|
||||
let mut s = setup(depth, "rgb");
|
||||
let params = json!({"points":points,"size":40,"hardness":70,"strength":80,"opacity":55,"spacing":25});
|
||||
let (mut expected, lock) = {
|
||||
let st = s.active().unwrap();
|
||||
let id = st.active_layer.unwrap();
|
||||
(st.doc.layer(id).unwrap().surface().unwrap().clone(), st.doc.effective_locks(id).transparency)
|
||||
};
|
||||
let stroke = Stroke {
|
||||
brush: photocraft_engine::retouch_cmds::stroke_brush(&s.tools.brush, ¶ms),
|
||||
points: points.iter().map(|q| StrokePoint::new(q[0], q[1], q[2] as f32)).collect(),
|
||||
};
|
||||
let mut effect = Smudge::new(0.8, None, 40.0).with_alpha(Some(3));
|
||||
apply_dab_stroke(&mut expected, &stroke, None, lock, 40, |work, fp| effect.dab(work, fp));
|
||||
let bounds = s.active().unwrap().doc.bounds();
|
||||
let want = expected.read_region(bounds);
|
||||
s.execute("paint.smudge", params.clone()).unwrap();
|
||||
assert!(pixels(&s) == want, "command must read the whole pickup square at depth {depth}");
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
let live = LiveDab::begin(&s, "paint.smudge", ¶ms).unwrap();
|
||||
s.offer_live_dab(live).unwrap();
|
||||
assert_eq!(s.execute("paint.smudge", params).unwrap()["fromLive"], true);
|
||||
assert!(pixels(&s) == want, "a single batch uses the same pickup square at depth {depth}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pressure_sized_live_dabs_commit_only_the_requested_mask_or_channel() {
|
||||
use photocraft_raster::Surface;
|
||||
|
||||
let fill = |surface: &mut Surface| {
|
||||
let values: Vec<f32> = (0..120).flat_map(|y| (0..200).map(move |x| ((x * 7 + y * 3) % 31) as f32 / 31.0)).collect();
|
||||
surface.write_region(photocraft_geom::Rect::new(0, 0, 200, 120), &values);
|
||||
};
|
||||
for depth in [8, 16, 32] {
|
||||
let mut s = setup(depth, "rgb");
|
||||
s.execute("layer.new.layer", json!({})).unwrap();
|
||||
s.execute("layer.layerMask.revealAll", json!({})).unwrap();
|
||||
s.execute("channel.new", json!({"fill":"white"})).unwrap();
|
||||
s.execute("select.editInQuickMaskMode", json!({"on":true})).unwrap();
|
||||
s.edit("mask texture", |doc, active| {
|
||||
fill(&mut doc.layer_mut(active.unwrap()).unwrap().mask.as_mut().unwrap().surface);
|
||||
fill(&mut doc.channels[0].surface);
|
||||
fill(&mut doc.quick_mask.as_mut().unwrap().surface);
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
let before = s.active().unwrap().doc.clone();
|
||||
for target in [json!("mask"), json!({"channel":0}), json!("quickMask")] {
|
||||
for cmd in ["paint.smudge", "paint.blur", "paint.sharpen", "paint.dodge", "paint.burn", "paint.sponge"] {
|
||||
let points = [[40.0, 40.0, 0.1], [65.0, 50.0, 0.4], [100.0, 60.0, 1.0], [140.0, 70.0, 0.7], [60.0, 40.0, 0.2]];
|
||||
let params = json!({"target":target,"points":points,"size":40,"hardness":70,"strength":80,"opacity":55,"spacing":25});
|
||||
s.execute(cmd, params.clone()).unwrap();
|
||||
let want = s.active().unwrap().doc.clone();
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
let mut start = params.clone();
|
||||
start["points"] = json!([points[0]]);
|
||||
let mut live = LiveDab::begin(&s, cmd, &start).unwrap();
|
||||
for q in &points[1..] {
|
||||
live.push(&[StrokePoint::new(q[0], q[1], q[2] as f32)]).unwrap();
|
||||
}
|
||||
s.offer_live_dab(live).unwrap();
|
||||
assert_eq!(s.execute(cmd, params).unwrap()["fromLive"], true);
|
||||
assert_eq!(s.active().unwrap().doc, want, "depth={depth} {cmd} target={target}");
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(s.active().unwrap().doc, before);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -263,6 +263,10 @@ pub fn mix_back(target: &mut Surface, orig: &Region, work: &Region, rect: Rect,
|
||||
let wv = work.px(x, y);
|
||||
let i = out.index(x, y) * n;
|
||||
let o = &mut out.data[i..i + n];
|
||||
// Untouched by the dabs: keep the pixel exactly (the premultiplied mix can round it).
|
||||
if *o == *wv {
|
||||
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),
|
||||
@@ -315,8 +319,14 @@ pub struct Smudge {
|
||||
}
|
||||
|
||||
impl Smudge {
|
||||
/// Radius of the carried-colour buffer, including pixels outside a pressure-sized dab.
|
||||
/// Both the first pickup and every subsequent pickup need this entire neighbourhood.
|
||||
pub fn sampling_radius(max_brush_size: f32) -> i32 {
|
||||
((max_brush_size / 2.0).ceil() as i32).saturating_add(2)
|
||||
}
|
||||
|
||||
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, alpha: None, carry: None }
|
||||
Self { strength: strength.clamp(0.0, 1.0), finger, radius: Self::sampling_radius(max_brush_size), alpha: None, carry: None }
|
||||
}
|
||||
|
||||
/// Mix in premultiplied space using alpha channel `a` (the format's [`alpha_index`]).
|
||||
|
||||
@@ -755,16 +755,30 @@ pub(crate) fn live_retouch_points(app: &PhotocraftApp) -> Option<Vec<photocraft_
|
||||
}
|
||||
}
|
||||
|
||||
/// Offer the Clone Stamp's or History Brush's live stroke, with the drag points it hasn't rendered
|
||||
/// yet, to the command `retouch_ui::finish_stroke` commits, which then takes its pixels.
|
||||
/// Offer the Clone Stamp's or History Brush's live stroke, or a Dodge, Burn, Sponge, Blur, Sharpen
|
||||
/// or Smudge one, with the drag points it hasn't rendered yet, to the command
|
||||
/// `retouch_ui::finish_stroke` commits, which then takes its pixels instead of rendering the
|
||||
/// stroke again.
|
||||
fn offer_live_retouch(app: &mut PhotocraftApp, d: &Drag) {
|
||||
if !matches!(d.tool, Tool::CloneStamp | Tool::HistoryBrush) {
|
||||
let dab = crate::retouch_ui::dab_params(app, d.tool).is_some();
|
||||
if !dab && !matches!(d.tool, Tool::CloneStamp | Tool::HistoryBrush) {
|
||||
return;
|
||||
}
|
||||
let Some(LiveStroke { stroke: EngineStroke::Retouch(mut live), fed, .. }) = app.live_stroke.take() else { return };
|
||||
let Some(LiveStroke { stroke, fed, .. }) = app.live_stroke.take() else { return };
|
||||
let rest: Vec<_> = d.points.get(fed..).unwrap_or_default().iter().map(|q| photocraft_engine::paint::StrokePoint::new(q[0], q[1], q[2] as f32)).collect();
|
||||
if rest.is_empty() || live.push(&rest).is_ok() {
|
||||
let _ = app.session.offer_live_retouch(live);
|
||||
// A refused offer (the brush changed mid-stroke, say) leaves the commit to render the stroke.
|
||||
match stroke {
|
||||
EngineStroke::Retouch(mut live) if !dab => {
|
||||
if rest.is_empty() || live.push(&rest).is_ok() {
|
||||
let _ = app.session.offer_live_retouch(live);
|
||||
}
|
||||
}
|
||||
EngineStroke::Dab(mut live) if dab => {
|
||||
if rest.is_empty() || live.push(&rest).is_ok() {
|
||||
let _ = app.session.offer_live_dab(live);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5858,8 +5872,8 @@ pub(crate) fn finish_gesture(app: &mut PhotocraftApp, d: Drag) {
|
||||
{
|
||||
app.last_stroke_end = Some((st.doc.id, end));
|
||||
}
|
||||
// A live Clone Stamp preview ends here; the commit below replaces it (the Clone Stamp's and
|
||||
// History Brush's with the pixels it already drew).
|
||||
// A live retouching preview ends here; the commit below replaces it (the Clone Stamp's,
|
||||
// History Brush's and sequential-dab tools' with the pixels it already drew).
|
||||
offer_live_retouch(app, &d);
|
||||
if live_retouch_command(d.tool).is_some() || crate::retouch_ui::dab_params(app, d.tool).is_some() || d.tool == Tool::ColorReplacement {
|
||||
app.live_stroke = None;
|
||||
|
||||
@@ -591,6 +591,9 @@ pub struct PhotocraftApp {
|
||||
/// A live stroke just committed with its own pixels: the canvas caches that showed it
|
||||
/// refresh only what it drew after the step they showed (`canvas::damage_since`).
|
||||
committed_stroke: Option<canvas::CommittedStroke>,
|
||||
/// Whether the last retouching stroke's commit took the pixels its live stroke drew
|
||||
/// (`"fromLive"`) instead of rendering the stroke again (`retouch_ui::finish_stroke`).
|
||||
pub(crate) retouch_from_live: bool,
|
||||
/// Footprint trail of a retouching drag (see `stroke_trail`).
|
||||
trail: Option<stroke_trail::Trail>,
|
||||
/// Remove tool strokes waiting to be applied together (Remove after each stroke off), the
|
||||
@@ -863,6 +866,7 @@ impl PhotocraftApp {
|
||||
tool_override: None,
|
||||
live_stroke: None,
|
||||
committed_stroke: None,
|
||||
retouch_from_live: false,
|
||||
trail: None,
|
||||
remove_pending: Vec::new(),
|
||||
remove_pending_doc: None,
|
||||
|
||||
@@ -144,7 +144,9 @@ pub fn finish_stroke(app: &mut PhotocraftApp, tool: Tool, points: &[[f64; 3]], m
|
||||
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) {
|
||||
let result = app.run(cmd, p);
|
||||
app.retouch_from_live = matches!(&result, Ok(r) if r.get("fromLive").and_then(Value::as_bool) == Some(true));
|
||||
match result {
|
||||
Ok(r) if matches!(tool, Tool::Healing | Tool::CloneStamp) => {
|
||||
// Aligned: keep the offset for later strokes; non-aligned: every stroke restarts at the source.
|
||||
app.ui.clone_offset = r.get("offset").and_then(|v| serde_json::from_value(v.clone()).ok()).filter(|_| o.clone_aligned);
|
||||
|
||||
@@ -425,3 +425,101 @@ fn releasing_a_clone_stamp_stroke_commits_what_a_replay_paints() {
|
||||
assert_eq!(a.px.len(), b.px.len());
|
||||
assert!(a.px.iter().zip(&b.px).all(|(x, y)| x == y), "live commit and replay differ");
|
||||
}
|
||||
|
||||
/// A Blur, Smudge or Dodge stroke through the canvas: releasing it commits the pixels its live
|
||||
/// stroke drew (`Session::offer_live_dab`), which are what replaying the journaled command paints.
|
||||
#[test]
|
||||
fn releasing_a_sequential_dab_stroke_commits_what_a_replay_paints() {
|
||||
for (tool, cmd) in [(Tool::Blur, "paint.blur"), (Tool::Smudge, "paint.smudge"), (Tool::Dodge, "paint.dodge")] {
|
||||
let setup = |run: &mut dyn FnMut(&str, Value)| {
|
||||
run("tools.setBrush", json!({"brush": {"size": 24, "hardness": 0.2}}));
|
||||
run("paint.stroke", json!({"points": [[10, 20], [190, 80]], "size": 30, "hardness": 0.5, "color": "#2060c0"}));
|
||||
};
|
||||
let mut h = harness();
|
||||
setup(&mut |id, p| {
|
||||
h.state_mut().run(id, p).unwrap();
|
||||
});
|
||||
h.state_mut().ui.tool = tool;
|
||||
h.run_steps(1);
|
||||
move_to(&mut h, 40.0, 30.0);
|
||||
button(&mut h, 40.0, 30.0, true);
|
||||
for (x, y) in [(70.0, 40.0), (100.0, 55.0), (130.0, 50.0), (160.0, 70.0)] {
|
||||
move_to(&mut h, x, y);
|
||||
}
|
||||
assert!(h.state().live_stroke.is_some(), "{cmd}: a live stroke");
|
||||
h.state_mut().retouch_from_live = false;
|
||||
button(&mut h, 160.0, 70.0, false);
|
||||
h.run_steps(2);
|
||||
let app = h.state();
|
||||
assert!(app.retouch_from_live, "{cmd}: the release took the live pixels instead of rendering the stroke again");
|
||||
let (_, p) = app.session.journal.iter().rev().find(|(id, _)| id == cmd).cloned().expect("a committed stroke");
|
||||
let mut fresh = photocraft_engine::Session::new();
|
||||
fresh.execute("file.new", json!({"width": 200, "height": 100, "background": "white"})).unwrap();
|
||||
setup(&mut |id, p| {
|
||||
fresh.execute(id, p).unwrap();
|
||||
});
|
||||
let replayed = fresh.execute(cmd, p).unwrap();
|
||||
assert!(replayed.get("fromLive").is_none());
|
||||
let st = app.session.active().unwrap();
|
||||
let a = photocraft_compose::render(&st.doc, st.doc.bounds());
|
||||
let b = photocraft_compose::render(&fresh.active().unwrap().doc, st.doc.bounds());
|
||||
assert_eq!(a.px.len(), b.px.len());
|
||||
assert!(a.px.iter().zip(&b.px).all(|(x, y)| x == y), "{cmd}: live commit and replay differ");
|
||||
}
|
||||
}
|
||||
|
||||
/// Pressure changes Smudge's painted footprint, but not the square of colour it carries.
|
||||
/// Exercise the actual pen → live preview → release handoff → journal replay, then undo/redo.
|
||||
#[test]
|
||||
fn pressure_sized_smudge_release_matches_replay_and_undo_redo() {
|
||||
use crate::stylus::PenSample;
|
||||
|
||||
let setup = |run: &mut dyn FnMut(&str, Value)| {
|
||||
run("tools.setBrush", json!({"brush": {"size":40,"hardness":0.7,"pressureSize":true,"smoothing":{"amount":0.4}}}));
|
||||
run("paint.stroke", json!({"points":[[10,20],[190,80]],"size":30,"color":"#2060c0"}));
|
||||
};
|
||||
let mut h = harness();
|
||||
h.state_mut().ui.tool = Tool::Smudge;
|
||||
h.run_steps(1);
|
||||
setup(&mut |id, p| {
|
||||
h.state_mut().run(id, p).unwrap();
|
||||
});
|
||||
let before = h.state().session.active().unwrap().doc.clone();
|
||||
h.state_mut().ui.tool_options.strength = 80.0;
|
||||
h.run_steps(1);
|
||||
let pen = |pressure| Some(PenSample { pressure, ..Default::default() });
|
||||
h.state().stylus.feed.set(pen(0.1));
|
||||
move_to(&mut h, 40.0, 30.0);
|
||||
button(&mut h, 40.0, 30.0, true);
|
||||
for (x, y, pressure) in [(65.0, 40.0, 0.4), (100.0, 55.0, 1.0), (140.0, 70.0, 0.7), (60.0, 40.0, 0.2)] {
|
||||
h.state().stylus.feed.set(pen(pressure));
|
||||
move_to(&mut h, x, y);
|
||||
}
|
||||
assert!(h.state().live_stroke.is_some());
|
||||
button(&mut h, 60.0, 40.0, false);
|
||||
h.run_steps(2);
|
||||
assert!(h.state().retouch_from_live, "release must actually reuse the live pixels");
|
||||
let (_, params) = h.state().session.journal.iter().rev().find(|(id, _)| id == "paint.smudge").cloned().unwrap();
|
||||
let points = params["points"].as_array().unwrap();
|
||||
assert!(points.iter().any(|p| p[2].as_f64().unwrap() < 0.2));
|
||||
assert!(points.iter().any(|p| p[2].as_f64().unwrap() > 0.9));
|
||||
|
||||
let mut fresh = photocraft_engine::Session::new();
|
||||
fresh.execute("file.new", json!({"width":200,"height":100,"background":"white"})).unwrap();
|
||||
setup(&mut |id, p| {
|
||||
fresh.execute(id, p).unwrap();
|
||||
});
|
||||
assert_eq!(h.state().session.tools.brush, fresh.tools.brush, "replay uses the Smudge tool's actual brush");
|
||||
assert!(
|
||||
photocraft_compose::render(&before, before.bounds()).px == photocraft_compose::render(&fresh.active().unwrap().doc, before.bounds()).px,
|
||||
"the replay fixture starts with identical pixels"
|
||||
);
|
||||
assert!(fresh.execute("paint.smudge", params).unwrap().get("fromLive").is_none());
|
||||
let committed = h.state().session.active().unwrap().doc.clone();
|
||||
let expected = photocraft_compose::render(&fresh.active().unwrap().doc, committed.bounds());
|
||||
assert!(photocraft_compose::render(&committed, committed.bounds()).px == expected.px, "live commit and replay must paint identical pixels");
|
||||
h.state_mut().run("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(h.state().session.active().unwrap().doc, before);
|
||||
h.state_mut().run("edit.redo", json!({})).unwrap();
|
||||
assert_eq!(h.state().session.active().unwrap().doc, committed);
|
||||
}
|
||||
Reference in new issue
Block a user