Compare commits

...
Author SHA1 Message Date
skylar taylor-barrick 389a1d5bd6 test(retouch): name live dab replay regressions by behavior 2026-10-19 14:03:11 -06:00
skylar taylor-barrick eb2ef35279 fix(retouch): preserve Smudge pickup through live stroke release 2026-10-19 13:58:58 -06:00
Khawar Zamman Wani f93d544996 Merge origin/main into claude/blur-tool-lag-684245 2026-10-18 14:27:53 +05:30
Khawar Zamman WaniandClaude Opus 5.5 defec59203 review: a refused live-dab offer withdraws the previous one; test the canvas hand-off
- Session::offer_live_dab clears any earlier offer before converting, so a
  refused offer can't leave an older one behind (test fails without it).
- retouch_ui::finish_stroke records whether the commit took the live pixels
  (PhotocraftApp::retouch_from_live), and the ui-egui release test asserts
  it, so a second render on release can't pass unnoticed.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 12:43:51 +05:30
Khawar Zamman WaniandClaude Opus 5.5 5306423a8e perf(retouch): releasing a Blur/Smudge/Dodge stroke takes its live pixels
Releasing a Dodge, Burn, Sponge, Blur, Sharpen or Smudge stroke ran every
dab of the stroke again on the UI thread (70-350 ms for a 960 px stroke on
24 MP, growing with stroke length). The Brush, Clone Stamp and History
Brush already hand their live pixels to the commit; LiveDab didn't, and
its preview wasn't the commit's pixels anyway: its working copy was a
native-depth Surface rounded after every dab (at 8-bit a live Blur
differed on 21% of pixels by up to 3 levels), the finishing dabs weren't
run, and canvas points carried tilt/times the commands don't take.

- LiveDab keeps its working copy in f32 tiles (WorkTiles), drops tilt and
  times like LiveRetouch, and into_commit runs the finishing dabs.
- Session::offer_live_dab; dab_cmd takes a matching offer ("fromLive").
  Refused when the brush, tool options or layer changed mid-stroke, and
  for Color Replacement.
- mix_back leaves pixels the dabs didn't change exactly as they were.
- The canvas offers the live stroke on release.

Release: Blur 20/70/140/303 ms -> 0.2-0.4 ms at 50/150/300/600 px;
Smudge and Dodge likewise. Live frame cost unchanged.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 12:17:30 +05:30
10 changed files with 785 additions and 35 deletions

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);
}
}
+11
View File
@@ -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;
+217 -25
View File
@@ -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, &region.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
+116
View File
@@ -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, &params),
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", &params).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);
}
}
}
}
+11 -1
View File
@@ -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`]).
+22 -8
View File
@@ -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;
+4
View File
@@ -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,
+3 -1
View File
@@ -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);
+98
View File
@@ -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);
}