From d0fc974aaed5494de5f2800a7990066f81de86cb Mon Sep 17 00:00:00 2001 From: jonathanwxh-cell Date: Wed, 14 Oct 2026 00:48:33 +0800 Subject: [PATCH] Wrap Clone Stamp strokes and sampling in Pattern Preview (#4997) * Wrap Clone Stamp sampling and strokes in Pattern Preview * Preserve per-dab flow and source alpha across pattern seams --------- Co-authored-by: jonathnwxh <258340642+jonathanwxh-cell@users.noreply.github.com> --- crates/engine/src/retouch_cmds.rs | 92 +++++-- crates/engine/src/retouch_cmds/periodic.rs | 272 +++++++++++++++++++++ crates/engine/src/retouch_cmds/tests.rs | 263 ++++++++++++++++++++ crates/paint/src/render.rs | 4 +- crates/ui-egui/src/canvas.rs | 24 +- crates/ui-egui/src/retouch_ui.rs | 25 ++ 6 files changed, 657 insertions(+), 23 deletions(-) create mode 100644 crates/engine/src/retouch_cmds/periodic.rs diff --git a/crates/engine/src/retouch_cmds.rs b/crates/engine/src/retouch_cmds.rs index c25608bb..9ea7fd59 100644 --- a/crates/engine/src/retouch_cmds.rs +++ b/crates/engine/src/retouch_cmds.rs @@ -26,6 +26,7 @@ use crate::{EngineError, Result, Session}; mod content_aware_move; mod patch; mod pattern_stamp; +mod periodic; mod remove; pub use content_aware_move::{ContentAwarePreview, preview as content_aware_move_preview}; pub use patch::preview as patch_preview; @@ -206,6 +207,7 @@ fn clone_mapping(s: &mut Session, p: &Value, stroke: &Stroke, cmd: &str) -> Resu /// Source pixels for destination `rect`: a translated read, or a bilinear resample when the /// clone source is scaled, rotated or flipped. +#[allow(clippy::too_many_arguments)] fn clone_sample( pre: &Document, id: Option, @@ -213,8 +215,12 @@ fn clone_sample( which: SampleLayers, rect: Rect, map: &crate::presets::clone_source::Mapping, + wrap: bool, cmd: &str, ) -> Result { + let read = |rect| { + if wrap { periodic::sample(pre, id, surf, which, rect, cmd) } else { Ok(sample(pre, id, surf, which, rect)) } + }; if map.is_translation() { let off = map.offset(); let src = rect.translate(off.0, off.1); @@ -223,17 +229,28 @@ fn clone_sample( if src.width() != rect.width() || src.height() != rect.height() { return Err(bad(cmd, format!("source offset [{}, {}] is too far: the sample window leaves the coordinate range", off.0, off.1))); } - let mut r = sample(pre, id, surf, which, src); + let mut r = read(src)?; r.rect = rect; return Ok(r); } - let src = sample(pre, id, surf, which, map.source_rect(rect)); + let src = read(map.source_rect(rect))?; Ok(crate::presets::clone_source::resample(&src, rect, map, alpha_index(&surf.format()))) } /// Read-only clone overlay. Shares the stroke sampler without anchoring a source or editing history. /// Both destination and transformed source allocations are bounded for interactive use. pub fn clone_preview(s: &Session, rect: Rect, map: &crate::presets::clone_source::Mapping, sample_layer: &str) -> Result { + clone_preview_periodic(s, rect, map, sample_layer, false) +} + +/// Clone-source overlay with optional source repetition for View > Pattern Preview. +pub fn clone_preview_periodic( + s: &Session, + rect: Rect, + map: &crate::presets::clone_source::Mapping, + sample_layer: &str, + wrap: bool, +) -> Result { if ![map.source.0, map.source.1, map.anchor.0, map.anchor.1].into_iter().chain(map.m).all(|v| v.is_finite() && v.abs() < 1_000_000.0) { return Err(bad("clone.preview", "invalid preview mapping")); } @@ -253,7 +270,7 @@ pub fn clone_preview(s: &Session, rect: Rect, map: &crate::presets::clone_source let id = d.active_layer.ok_or_else(|| bad("clone.preview", "no active layer"))?; let surf = d.doc.layer(id).and_then(|l| l.surface()).ok_or_else(|| bad("clone.preview", "no pixel surface"))?; let which = sample_layers(&json!({"sampleLayer": sample_layer}), "clone.preview")?; - let region = clone_sample(&d.doc, Some(id), surf, which, rect, map, "clone.preview")?; + let region = clone_sample(&d.doc, Some(id), surf, which, rect, map, wrap, "clone.preview")?; let px = region.data.chunks_exact(surf.format().channels()).map(|p| to_rgba(&surf.format(), p)).collect(); Ok(photocraft_compose::Buffer { rect, px }) } @@ -304,16 +321,26 @@ fn clone_stamp(s: &mut Session, p: &Value) -> Result { let from_live = live.is_some(); let map = clone_mapping(s, p, &stroke, CMD)?; let off = map.offset(); - let dmg = run_stroke(s, "Clone Stamp", id, p, |pre, surf, sel, lock| { - if let Some(l) = live { - *surf = l.surface; - return Ok(l.bounds); - } - let (bounds, cov) = stroke_coverage(&stroke); - // Samples come from the pre-stroke state, so pixels painted earlier in the stroke are never re-cloned. - let paint = clone_sample(pre, id, surf, which, bounds, &map, CMD)?; - Ok(apply_coverage(surf, bounds, &cov, stroke.brush.opacity, sel, lock, &paint, mode)) - })?; + let dmg = + run_stroke(s, "Clone Stamp", id, p, |pre, surf, sel, lock| { + if let Some(l) = live { + *surf = l.surface; + return Ok(l.bounds); + } + if flag(p, "wrap", false) { + let mut renderer = photocraft_paint::StrokeRenderer::new(&stroke.brush, None, 1.0); + renderer.push(&stroke.points); + renderer.finish(); + let before = surf.clone(); + let bounds = renderer.bounds(); + return periodic::Composite { target: surf, before: &before, tile: pre.bounds(), opacity: stroke.brush.opacity, selection: sel, lock, mode } + .draw(bounds, &stroke.brush, &stroke.points, |r| clone_sample(pre, id, &before, which, r, &map, true, CMD)); + } + let (bounds, cov) = stroke_coverage(&stroke); + // Samples come from the pre-stroke state, so pixels painted earlier in the stroke are never re-cloned. + let paint = clone_sample(pre, id, surf, which, bounds, &map, flag(p, "wrap", false), CMD)?; + Ok(apply_coverage(surf, bounds, &cov, stroke.brush.opacity, sel, lock, &paint, mode)) + })?; let mut r = clone_result(dmg, off, aligned, p, &stroke); if from_live { r["fromLive"] = json!(true); @@ -414,7 +441,12 @@ impl LiveRetouch { /// Everything the stroke has touched so far. pub fn bounds(&self) -> Rect { - self.renderer.bounds().union(&self.tail) + let bounds = self.renderer.bounds().union(&self.tail); + if self.cmd == "paint.cloneStamp" && flag(&self.params, "wrap", false) { + periodic::project(bounds, self.pre.bounds()).into_iter().fold(Rect::EMPTY, |a, b| a.union(&b)) + } else { + bounds + } } /// Every point rendered so far, in order. @@ -457,6 +489,9 @@ impl LiveRetouch { let pts: Vec<_> = pts.iter().map(|q| StrokePoint::new(q.x, q.y, q.pressure)).collect(); self.points.extend_from_slice(&pts); self.renderer.push(&pts); + if self.cmd == "paint.cloneStamp" && flag(&self.params, "wrap", false) { + return self.push_periodic(); + } let old = std::mem::replace(&mut self.tail, Rect::EMPTY); let mut dmg = Rect::EMPTY; if !old.is_empty() { @@ -478,13 +513,36 @@ impl LiveRetouch { Ok(dmg) } + fn push_periodic(&mut self) -> Result { + let mut dirty = self.renderer.take_dirty_rect().union(&self.tail); + let mut tail = self.renderer.tail_preview(); + self.tail = tail.as_mut().map_or(Rect::EMPTY, |t| t.take_dirty_rect()); + dirty = dirty.union(&self.tail); + let LivePaint::Clone { which, map, cmd } = &self.paint else { + return Err(bad(&self.cmd, "periodic painting requires a clone source")); + }; + let (surf, _) = crate::channel_cmds::target_surface(std::sync::Arc::make_mut(&mut self.doc), self.id, &self.params)?; + periodic::Composite { + target: surf, + before: &self.pre_surf, + tile: self.pre.bounds(), + opacity: self.opacity, + selection: self.sel.as_ref(), + lock: self.lock, + mode: self.mode, + } + .draw(dirty, &self.brush, &self.points, |r| clone_sample(&self.pre, self.id, &self.pre_surf, *which, r, map, true, cmd)) + } + /// Redraw `r` from the pre-stroke pixels with the clone source through coverage `cov`. fn composite(&mut self, r: Rect, cov: &[f32]) -> Result { if r.is_empty() { return Ok(r); } let paint = match &self.paint { - LivePaint::Clone { which, map, cmd } => clone_sample(&self.pre, self.id, &self.pre_surf, *which, r, map, cmd)?, + LivePaint::Clone { which, map, cmd } => { + clone_sample(&self.pre, self.id, &self.pre_surf, *which, r, map, *cmd == "paint.cloneStamp" && flag(&self.params, "wrap", false), cmd)? + } LivePaint::History(src) => Region::read(src, r), }; let (surf, _) = crate::channel_cmds::target_surface(std::sync::Arc::make_mut(&mut self.doc), self.id, &self.params)?; @@ -543,7 +601,7 @@ fn healing_brush(s: &mut Session, p: &Value) -> Result { let (bounds, cov) = stroke_coverage(&stroke); let (g, cov) = pad_coverage(bounds, &cov, 2); let fmt = surf.format(); - let src = clone_sample(pre, id, surf, which, g, &map, CMD)?; + let src = clone_sample(pre, id, surf, which, g, &map, false, CMD)?; // The destination the texture is fitted to: the layer itself, or what the user sees when // sampling several layers (so healing onto an empty layer matches the composite). let dst = if which == SampleLayers::Current { Region::read(surf, g) } else { composite_region(pre, id, which, g, fmt) }; @@ -1168,7 +1226,7 @@ pub fn specs() -> Vec { menu: &[], shortcut: None, params: brush_params!( - r#","source":[sx,sy] (sampled under the first point) | "offset":[dx,dy],"aligned":bool=true,"sampleLayer":"current|currentAndBelow|all"="current","mode":"normal|multiply|…"="normal" → {"damage","offset","aligned","nextSource"}"# + r#","source":[sx,sy] (sampled under the first point) | "offset":[dx,dy],"aligned":bool=true,"sampleLayer":"current|currentAndBelow|all"="current","mode":"normal|multiply|…"="normal","wrap":bool=false (repeat source and stroke at document edges for Pattern Preview) → {"damage","offset","aligned","nextSource"}"# ), enabled: has_pixel_layer, run: clone_stamp, diff --git a/crates/engine/src/retouch_cmds/periodic.rs b/crates/engine/src/retouch_cmds/periodic.rs new file mode 100644 index 00000000..f31542fb --- /dev/null +++ b/crates/engine/src/retouch_cmds/periodic.rs @@ -0,0 +1,272 @@ +//! Bounded reads from the repeating document tile used by Pattern Preview. + +use super::*; + +/// At most four disjoint rectangles in the document tile. Projecting the dirty area separately +/// from the stroke bounds avoids scanning the whole document when a small dab crosses a seam. +pub(super) fn project(rect: Rect, tile: Rect) -> Vec { + if rect.is_empty() || tile.is_empty() { + return Vec::new(); + } + let axis = |a: i32, b: i32, lo: i32, hi: i32| { + let period = i64::from(hi) - i64::from(lo); + let length = i64::from(b) - i64::from(a); + if length >= period { + return vec![(lo, hi)]; + } + let start = i64::from(lo) + (i64::from(a) - i64::from(lo)).rem_euclid(period); + let end = start + length; + if end <= i64::from(hi) { vec![(start as i32, end as i32)] } else { vec![(start as i32, hi), (lo, (i64::from(lo) + end - i64::from(hi)) as i32)] } + }; + let xs = axis(rect.x0, rect.x1, tile.x0, tile.x1); + let ys = axis(rect.y0, rect.y1, tile.y0, tile.y1); + ys.into_iter().flat_map(|(y0, y1)| xs.iter().map(move |&(x0, x1)| Rect::new(x0, y0, x1, y1))).collect() +} + +/// Composite periodic copies of one stroke, restoring each dirty patch from pre-stroke pixels. +/// Each dab wraps once, then builds flow towards its opacity ceiling. Stroke opacity and +/// selection are applied once after all dabs, including repeated visits through other tiles. +pub(super) struct Composite<'a> { + pub target: &'a mut Surface, + pub before: &'a Surface, + pub tile: Rect, + pub opacity: f32, + pub selection: Option<&'a Surface>, + pub lock: bool, + pub mode: BlendMode, +} + +impl Composite<'_> { + pub fn draw(&mut self, dirty: Rect, brush: &BrushSettings, points: &[StrokePoint], mut paint: impl FnMut(Rect) -> Result) -> Result { + let ctx = photocraft_paint::BrushContext::new(brush); + let mut generator = photocraft_paint::dynamics::DabGenerator::new(brush, 1.0); + let (mut primary, mut dual) = (Vec::new(), Vec::new()); + generator.push(points, &mut primary, &mut dual); + generator.finish(&mut primary, &mut dual); + let primary: Vec<_> = primary + .into_iter() + .map(|d| { + let r = ctx.dab_rect(&d, false); + (d, r) + }) + .collect(); + let dual: Vec<_> = dual + .into_iter() + .map(|d| { + let r = ctx.dab_rect(&d, true); + (d, r) + }) + .collect(); + let mut damage = Rect::EMPTY; + let mut work = 0_u64; + for rect in project(dirty, self.tile) { + let mut y = rect.y0; + while y < rect.y1 { + let y1 = y.saturating_add(64).min(rect.y1); + let mut x = rect.x0; + while x < rect.x1 { + let x1 = x.saturating_add(64).min(rect.x1); + let dst = Rect::new(x, y, x1, y1); + let mut stroke = PatchStroke::new(dst, self.target.channels()); + let mut dual_stroke = PatchStroke::new(dst, 1); + let alpha = alpha_index(&self.target.format()); + for (dab, bounds) in &primary { + if !project(*bounds, self.tile).iter().any(|r| !r.intersect(&dst).is_empty()) { + continue; + } + let footprint = self.fold_dab(&ctx, dab, *bounds, dst, false, &mut paint, &mut work)?; + stroke.accumulate(dab, &footprint, alpha); + } + for (dab, bounds) in &dual { + if !project(*bounds, self.tile).iter().any(|r| !r.intersect(&dst).is_empty()) { + continue; + } + let footprint = self.fold_dab(&ctx, dab, *bounds, dst, true, &mut paint, &mut work)?; + let mut dab = *dab; + dab.opacity = 1.0; + dual_stroke.accumulate(&dab, &footprint, None); + } + stroke.normalize(alpha); + for py in dst.y0..dst.y1 { + for px in dst.x0..dst.x1 { + let i = stroke.paint.index(px, py); + let depth = brush.wet_edges.then(|| stroke.depth.get(i).copied().unwrap_or(0.0)); + let dual = brush.dual_brush.enabled.then(|| dual_stroke.coverage.get(i).copied().unwrap_or(0.0)); + if let Some(c) = stroke.coverage.get_mut(i) { + *c = ctx.stroke_mask_wet(*c, depth, dual, px, py); + } + } + } + self.target.write_region(dst, &self.before.read_region(dst)); + apply_coverage(self.target, dst, &stroke.coverage, self.opacity, self.selection, self.lock, &stroke.paint, self.mode); + damage = damage.union(&dst); + x = x1; + } + y = y1; + } + } + Ok(damage) + } + + #[allow(clippy::too_many_arguments)] + fn fold_dab( + &self, + ctx: &photocraft_paint::BrushContext, + dab: &photocraft_paint::Dab, + bounds: Rect, + dst: Rect, + dual: bool, + paint: &mut impl FnMut(Rect) -> Result, + work: &mut u64, + ) -> Result { + let mut result = PatchStroke::new(dst, if dual { 1 } else { self.target.channels() }); + let (w, h) = (i64::from(self.tile.width()), i64::from(self.tile.height())); + let nx0 = (i64::from(bounds.x0) - i64::from(dst.x1)).div_euclid(w) + 1; + let nx1 = (i64::from(bounds.x1) - 1 - i64::from(dst.x0)).div_euclid(w); + let ny0 = (i64::from(bounds.y0) - i64::from(dst.y1)).div_euclid(h) + 1; + let ny1 = (i64::from(bounds.y1) - 1 - i64::from(dst.y0)).div_euclid(h); + let (mut values, mut depths) = (Vec::new(), Vec::new()); + let wet = ctx.brush.wet_edges && !dual; + for ny in ny0..=ny1 { + for nx in nx0..=nx1 { + let (dx, dy) = (nx * w, ny * h); + let r = Rect::new( + (i64::from(dst.x0) + dx).max(i64::from(bounds.x0)) as i32, + (i64::from(dst.y0) + dy).max(i64::from(bounds.y0)) as i32, + (i64::from(dst.x1) + dx).min(i64::from(bounds.x1)) as i32, + (i64::from(dst.y1) + dy).min(i64::from(bounds.y1)) as i32, + ); + *work = work.saturating_add(r.width() as u64 * r.height() as u64); + if *work > 64 * 1024 * 1024 { + return Err(bad("paint.cloneStamp", "wrapped stroke exceeds its coverage budget")); + } + ctx.rasterize_into(dab, dual, r, &mut values, wet.then_some(&mut depths)); + let mut sampled = None; + for sy in r.y0..r.y1 { + for sx in r.x0..r.x1 { + let source_index = ((sy - r.y0) as usize) * r.width() as usize + (sx - r.x0) as usize; + let value = values.get(source_index).copied().unwrap_or(0.0); + if value <= 0.0 { + continue; + } + let (tx, ty) = ((i64::from(sx) - dx) as i32, (i64::from(sy) - dy) as i32); + let i = result.paint.index(tx, ty); + if let Some(depth) = result.depth.get_mut(i) { + *depth = depth.max(depths.get(source_index).copied().unwrap_or(0.0)); + } + let Some(previous) = result.coverage.get_mut(i) else { continue }; + if value <= *previous { + continue; + } + if !dual { + if sampled.is_none() { + sampled = Some(paint(r)?); + } + if let Some(source) = &sampled { + result.paint.px_mut(tx, ty).copy_from_slice(source.px(sx, sy)); + } + } + *previous = value; + } + } + } + } + Ok(result) + } +} + +struct PatchStroke { + coverage: Vec, + depth: Vec, + // Native colours weighted by coverage and source alpha; alpha weighted by coverage. + paint: Region, +} + +impl PatchStroke { + fn new(rect: Rect, channels: usize) -> Self { + let len = rect.width() as usize * rect.height() as usize; + Self { coverage: vec![0.0; len], depth: vec![0.0; len], paint: Region::new(rect, channels) } + } + + fn accumulate(&mut self, dab: &photocraft_paint::Dab, footprint: &PatchStroke, alpha: Option) { + let rect = self.paint.rect; + for y in rect.y0..rect.y1 { + for x in rect.x0..rect.x1 { + let i = self.paint.index(x, y); + let Some(c) = self.coverage.get_mut(i) else { continue }; + let value = footprint.coverage.get(i).copied().unwrap_or(0.0); + if value <= 0.0 { + continue; + } + if let Some(depth) = self.depth.get_mut(i) { + *depth = depth.max(footprint.depth.get(i).copied().unwrap_or(0.0)); + } + if dab.opacity <= *c { + continue; + } + let next = *c + value * (dab.opacity - *c); + let weight = next - *c; + let source = footprint.paint.px(x, y); + let sa = alpha.and_then(|a| source.get(a)).copied().unwrap_or(1.0); + for (channel, (dst, src)) in self.paint.px_mut(x, y).iter_mut().zip(source).enumerate() { + *dst += if Some(channel) == alpha { weight * sa } else { weight * sa * src }; + } + *c = next; + } + } + } + + fn normalize(&mut self, alpha: Option) { + let rect = self.paint.rect; + for y in rect.y0..rect.y1 { + for x in rect.x0..rect.x1 { + let i = self.paint.index(x, y); + let coverage = self.coverage.get(i).copied().unwrap_or(0.0); + let px = self.paint.px_mut(x, y); + let weight = alpha.and_then(|a| px.get(a)).copied().unwrap_or(coverage); + for (channel, value) in px.iter_mut().enumerate() { + let denominator = if Some(channel) == alpha { coverage } else { weight }; + *value = if denominator > 0.0 { *value / denominator } else { 0.0 }; + } + } + } + } +} + +pub(super) fn sample(pre: &Document, id: Option, surf: &Surface, which: SampleLayers, rect: Rect, cmd: &str) -> Result { + let tile = pre.bounds(); + if tile.is_empty() { + return Err(bad(cmd, "cannot repeat an empty document")); + } + if rect.is_empty() { + return Ok(Region::new(Rect::EMPTY, surf.channels())); + } + let (w, h, n) = (rect.width() as usize, rect.height() as usize, surf.channels()); + let len = w + .checked_mul(h) + .and_then(|v| v.checked_mul(n)) + .filter(|&v| v <= 16 * 1024 * 1024) + .ok_or_else(|| bad(cmd, "periodic source window exceeds its 64 MiB budget"))?; + let mut out = Region { rect, ch: n, data: vec![0.0; len] }; + // Split at tile seams. Read only the required rectangles, including the bilinear apron, + // rather than allocating or compositing the entire document for a small clone dab. + let mut dy = 0; + while dy < h { + let sy = (i64::from(rect.y0) + dy as i64 - i64::from(tile.y0)).rem_euclid(i64::from(tile.height())) as i32 + tile.y0; + let rows = (h - dy).min((tile.y1 - sy) as usize); + let mut dx = 0; + while dx < w { + let sx = (i64::from(rect.x0) + dx as i64 - i64::from(tile.x0)).rem_euclid(i64::from(tile.width())) as i32 + tile.x0; + let cols = (w - dx).min((tile.x1 - sx) as usize); + let part = super::sample(pre, id, surf, which, Rect::new(sx, sy, sx + cols as i32, sy + rows as i32)); + for (dst, src) in out.data.chunks_exact_mut(w * n).skip(dy).take(rows).zip(part.data.chunks_exact(cols * n)) { + if let Some(dst) = dst.get_mut(dx * n..(dx + cols) * n) { + dst.copy_from_slice(src); + } + } + dx += cols; + } + dy += rows; + } + Ok(out) +} diff --git a/crates/engine/src/retouch_cmds/tests.rs b/crates/engine/src/retouch_cmds/tests.rs index 5febba9c..9a0ae22f 100644 --- a/crates/engine/src/retouch_cmds/tests.rs +++ b/crates/engine/src/retouch_cmds/tests.rs @@ -3,6 +3,171 @@ use photocraft_color::ColorMode; const DEPTHS: [u64; 3] = [8, 16, 32]; +#[test] +fn periodic_clone_large_footprint_applies_opacity_once_and_respects_destination_masks() { + for depth in DEPTHS { + for lock in [false, true] { + let mut s = session(8, 8, depth, "rgb"); + paint_layer(&mut s, |x, _| { + if x < 4 { + [1.0, 0.0, 0.0, 1.0] + } else if x == 7 { + [0.0; 4] + } else { + [1.0; 4] + } + }); + s.edit("lock", |doc, active| { + doc.layer_mut(active.unwrap()).unwrap().locks.transparency = lock; + Ok(()) + }) + .unwrap(); + s.execute("select.rect", json!({"x":5,"y":0,"width":3,"height":8})).unwrap(); + s.execute("paint.cloneStamp", json!({"points":[[6,4]],"offset":[4,0],"size":32,"hardness":100,"opacity":50,"wrap":true})).unwrap(); + assert_eq!(rgba(&s, 4, 4), [1.0; 4], "selection is evaluated at the wrapped destination"); + let opaque = rgba(&s, 6, 4); + assert!((opaque[1] - 0.5).abs() <= tol(depth), "overlapping tile copies must not multiply opacity: {opaque:?}"); + let transparent = rgba(&s, 7, 4); + if lock { + assert_eq!(transparent[3], 0.0, "locked transparent destination stays transparent"); + } else { + assert!((transparent[3] - 0.5).abs() <= tol(depth), "unlocked transparent destination receives one stroke: {transparent:?}"); + assert_eq!(transparent[0], 1.0); + } + } + } +} + +#[test] +fn periodic_clone_selection_uses_native_cmyk_pixels_across_seam() { + let mut s = session(64, 32, 8, "cmyk"); + paint_layer(&mut s, |x, _| if x < 32 { [0.0, 0.0, 1.0, 1.0] } else { [1.0, 1.0, 0.0, 1.0] }); + s.execute("select.rect", json!({"x":0,"y":0,"width":4,"height":32})).unwrap(); + s.execute("paint.cloneStamp", json!({"points":[[63,16]],"offset":[-23,0],"size":16,"hardness":100,"wrap":true})).unwrap(); + let (inside, outside) = (rgba(&s, 1, 16), rgba(&s, 5, 16)); + assert!(inside[0] > 0.8 && inside[2] < 0.3, "selected wrapped destination becomes yellow: {inside:?}"); + assert!(outside[2] > outside[0] + 0.2, "unselected neighbour remains blue: {outside:?}"); +} + +#[test] +fn periodic_clone_projection_is_disjoint_and_matches_pixel_modulo() { + use std::collections::HashSet; + let tile = Rect::new(0, 0, 8, 6); + for rect in [Rect::new(-2, -3, 3, 2), Rect::new(-10, -9, 19, 14), Rect::new(7, 5, 10, 8), Rect::new(i32::MIN, i32::MIN, i32::MIN + 3, i32::MIN + 2)] { + let expected: HashSet<_> = (rect.y0..rect.y1).flat_map(|y| (rect.x0..rect.x1).map(move |x| (x.rem_euclid(8), y.rem_euclid(6)))).collect(); + let mut actual = HashSet::new(); + for r in periodic::project(rect, tile) { + for y in r.y0..r.y1 { + for x in r.x0..r.x1 { + assert!(actual.insert((x, y)), "projected rectangles overlap"); + } + } + } + assert_eq!(actual, expected); + } +} + +#[test] +fn periodic_clone_preview_interpolates_across_transformed_source_seam() { + use crate::presets::clone_source::Mapping; + for depth in DEPTHS { + let mut s = session(32, 24, depth, "rgb"); + paint_layer(&mut s, |x, _| if x == 0 { [1.0, 0.0, 0.0, 1.0] } else { [0.0, 0.0, 1.0, 1.0] }); + let before = s.active().unwrap().doc.clone(); + // A rotated mapping samples halfway between the last and first columns. + let map = Mapping { source: (31.5, 10.0), anchor: (0.0, 0.0), m: [0.0, 1.0, -1.0, 0.0] }; + let r = Rect::new(0, 0, 1, 1); + let wrapped = clone_preview_periodic(&s, r, &map, "current", true).unwrap(); + assert_eq!(wrapped.px, [[0.5, 0.0, 0.5, 1.0]]); + let ordinary = clone_preview(&s, r, &map, "current").unwrap(); + assert_eq!(ordinary.px, [[0.0, 0.0, 1.0, 0.5]]); + assert_eq!(s.active().unwrap().doc, before); + } +} + +#[test] +fn periodic_clone_destination_wraps_edges_and_undoes_at_every_depth() { + for depth in DEPTHS { + for wrap in [false, true] { + let mut s = session(100, 60, depth, "rgb"); + paint_layer(&mut s, |x, y| if x < 24 && y < 24 { [1.0, 0.0, 0.0, 1.0] } else { [1.0; 4] }); + let before = s.active().unwrap().doc.clone(); + s.execute("paint.cloneStamp", json!({"points":[[98,30]],"source":[10,10],"size":16,"hardness":100,"opacity":50,"wrap":wrap})).unwrap(); + for x in [98, 1] { + let expected = if x == 98 || wrap { [1.0, 0.5, 0.5, 1.0] } else { [1.0; 4] }; + let actual = rgba(&s, x, 30); + for (a, b) in actual.into_iter().zip(expected) { + assert!((a - b).abs() <= tol(depth), "depth={depth} wrap={wrap} x={x}: {actual:?}"); + } + } + assert!(s.undo()); + assert_eq!(s.active().unwrap().doc, before, "one undo restores the whole wrapped stroke"); + } + } +} + +#[test] +fn periodic_clone_live_matches_commit_through_a_corner() { + for depth in DEPTHS { + let mut s = session(32, 24, depth, "rgb"); + paint_layer(&mut s, texture); + let before = s.active().unwrap().doc.clone(); + let mut params = json!({"points":[[30,22]],"source":[10,10],"size":16,"hardness":70,"opacity":45,"flow":60,"wrap":true}); + let mut live = LiveRetouch::begin(&s, "paint.cloneStamp", ¶ms).unwrap(); + let mut points = vec![json!([30, 22])]; + for next in [None, Some([34.0, 26.0]), Some([38.0, 28.0]), Some([30.0, 22.0])] { + if let Some([x, y]) = next { + live.push(&[StrokePoint::new(x, y, 1.0)]).unwrap(); + points.push(json!([x, y])); + } + params["points"] = json!(points); + let mut committed = Session::new(); + committed.add_document((*before).clone(), None); + committed.execute("paint.cloneStamp", params.clone()).unwrap(); + let id = s.active().unwrap().active_layer.unwrap(); + let actual = live.doc.layer(id).unwrap().surface().unwrap().read_region(before.bounds()); + let expected = committed.active().unwrap().doc.layer(id).unwrap().surface().unwrap().read_region(before.bounds()); + assert_eq!(actual, expected, "live versus committed pixels at depth {depth}, points {points:?}"); + } + } +} + +#[test] +fn periodic_clone_source_repeats_both_axes_at_every_depth() { + for depth in DEPTHS { + let mut s = session(6, 4, depth, "rgb"); + paint_layer(&mut s, |x, y| [x as f32 / 6.0, y as f32 / 4.0, 0.25, 1.0]); + let st = s.active().unwrap(); + let surf = st.doc.layer(st.active_layer.unwrap()).unwrap().surface().unwrap(); + let r = Rect::new(-8, -6, 15, 11); + for which in [SampleLayers::Current, SampleLayers::CurrentAndBelow, SampleLayers::All] { + let result = periodic::sample(&st.doc, st.active_layer, surf, which, r, "test").unwrap(); + for y in r.y0..r.y1 { + for x in r.x0..r.x1 { + let expected = surf.rgba(x.rem_euclid(6), y.rem_euclid(4)); + let actual = to_rgba(&surf.format(), result.px(x, y)); + for (a, b) in actual.into_iter().zip(expected) { + assert!((a - b).abs() <= tol(depth), "{depth}-bit ({x},{y}): {actual:?} != {expected:?}"); + } + } + } + } + } +} + +#[test] +fn periodic_clone_source_bounds_allocations_and_accepts_far_coordinates() { + let s = session(6, 4, 8, "rgb"); + let st = s.active().unwrap(); + let surf = st.doc.layer(st.active_layer.unwrap()).unwrap().surface().unwrap(); + assert!(periodic::sample(&st.doc, st.active_layer, surf, SampleLayers::Current, Rect::new(0, 0, 100_000, 100_000), "test").is_err()); + for r in [Rect::new(i32::MIN, i32::MIN, i32::MIN + 3, i32::MIN + 2), Rect::new(i32::MAX - 3, i32::MAX - 2, i32::MAX, i32::MAX)] { + let result = periodic::sample(&st.doc, st.active_layer, surf, SampleLayers::Current, r, "test").unwrap(); + assert_eq!(result.data.len(), 3 * 2 * surf.channels()); + assert!(result.data.iter().all(|v| *v == 1.0)); + } +} + #[test] fn proximity_match_reads_original_pixels_for_overlapping_offsets() { // Shifting source one pixel to the left overlaps the destination in a @@ -1791,3 +1956,101 @@ fn a_live_retouch_stroke_ignores_tilt_and_times() { let read = |l: &LiveRetouch| l.doc.layer(id).unwrap().surface().unwrap().read_region(b); assert_eq!(read(&plain), read(&pen)); } + +#[test] +fn periodic_clone_low_flow_revisits_accumulate_across_tile_aliases() { + // Independent oracle: paint the same continuous stroke over two physical copies of a + // tile, then combine the two coverage values using the brush's flow accumulation rule. + let setup = |width| { + let mut s = Session::new(); + s.execute("file.new", json!({"width":width,"height":24,"depth":32})).unwrap(); + for x in (8..width).step_by(32) { + s.execute("paint.pencil", json!({"points":[[x,12]],"size":16,"color":"#ff0000"})).unwrap(); + } + s + }; + let params = |wrap| json!({"points":[[22,12],[54,12]],"offset":[-18,0],"size":2,"hardness":100,"flow":20,"opacity":100,"wrap":wrap}); + let green = |s: &Session, x| { + let st = s.active().unwrap(); + st.doc.layer(st.active_layer.unwrap()).unwrap().surface().unwrap().rgba(x, 12)[1] + }; + let mut physical = setup(64); + physical.execute("paint.cloneStamp", params(false)).unwrap(); + let (a, b) = (green(&physical, 22), green(&physical, 54)); + assert!(a > 0.0 && a < 1.0 && b > 0.0 && b < 1.0, "oracle needs two partial visits: {a}, {b}"); + let expected = a * b; + let mut periodic = setup(32); + periodic.execute("paint.cloneStamp", params(true)).unwrap(); + let actual = green(&periodic, 22); + assert!((actual - expected).abs() < 0.0001, "repeated visits should build flow: actual green={actual}, expected={expected}, physical copies={a},{b}"); +} + +#[test] +fn periodic_clone_live_offer_commits_and_undoes_the_same_pixels_as_replay() { + for depth in [8, 16, 32] { + let mut s = Session::new(); + s.execute("file.new", json!({"width":32,"height":24,"depth":depth})).unwrap(); + s.execute("paint.pencil", json!({"points":[[8,8]],"size":12,"color":"#d03060"})).unwrap(); + let before = s.active().unwrap().doc.clone(); + let id = s.active().unwrap().active_layer.unwrap(); + let params = json!({"points":[[30,22],[34,26],[38,28],[30,22]],"source":[8,8],"size":16,"hardness":70,"opacity":45,"flow":60,"wrap":true}); + let live = LiveRetouch::begin(&s, "paint.cloneStamp", ¶ms).unwrap(); + let shown = live.doc.layer(id).unwrap().surface().unwrap().read_region(before.bounds()); + s.offer_live_retouch(live).unwrap(); + let result = s.execute("paint.cloneStamp", params.clone()).unwrap(); + assert_eq!(result["fromLive"], true); + let read = |s: &Session| s.active().unwrap().doc.layer(id).unwrap().surface().unwrap().read_region(before.bounds()); + assert_eq!(read(&s), shown, "offered preview at depth {depth}"); + let mut replay = Session::new(); + replay.add_document((*before).clone(), None); + replay.execute("paint.cloneStamp", params).unwrap(); + assert_eq!(read(&s), read(&replay), "command replay at depth {depth}"); + assert!(s.undo()); + assert_eq!(s.active().unwrap().doc, before, "one undo at depth {depth}"); + } +} +#[test] +fn periodic_clone_interior_matches_ordinary_brush_masks_and_transparency() { + let cases = [ + json!({"wetEdges":true}), + json!({"dualBrush":{"enabled":true,"size":7,"hardness":0.5,"spacing":0.2}}), + json!({"texture":{"enabled":true,"eachTip":false,"depth":0.6}}), + json!({"pressureOpacity":true,"pressureSize":false}), + json!({"wetEdges":true,"dualBrush":{"enabled":true,"size":7},"pressureOpacity":true}), + ]; + for depth in DEPTHS { + for brush in &cases { + let setup = || { + let mut s = session(96, 64, depth, "rgb"); + paint_layer(&mut s, |x, y| { + let mut px = texture(x, y); + px[3] = if x < 40 { 0.4 } else { 0.75 }; + px + }); + s.execute("tools.setBrush", json!({"brush":brush})).unwrap(); + s.execute("select.rect", json!({"x":48,"y":16,"width":36,"height":36,"feather":2})).unwrap(); + s + }; + let mut ordinary = setup(); + let mut periodic = setup(); + let mut p = json!({"points":[[55,30,0.4],[65,34,0.8],[72,30,0.3]],"source":[20,20],"size":12,"hardness":65,"flow":35,"opacity":60}); + ordinary.execute("paint.cloneStamp", p.clone()).unwrap(); + p["wrap"] = json!(true); + periodic.execute("paint.cloneStamp", p).unwrap(); + let read = |s: &Session| { + let st = s.active().unwrap(); + st.doc.layer(st.active_layer.unwrap()).unwrap().surface().unwrap().read_region(st.doc.bounds()) + }; + let (a, b) = (read(&ordinary), read(&periodic)); + let tolerance = if depth == 8 { + 1.1 / 255.0 + } else if depth == 16 { + 1.1 / 65535.0 + } else { + 0.00001 + }; + let error = a.iter().zip(&b).map(|(a, b)| (a - b).abs()).fold(0.0_f32, f32::max); + assert!(error <= tolerance, "depth={depth}, brush={brush}, max native-channel error={error}"); + } + } +} diff --git a/crates/paint/src/render.rs b/crates/paint/src/render.rs index 89c0abeb..5f59aafa 100644 --- a/crates/paint/src/render.rs +++ b/crates/paint/src/render.rs @@ -234,7 +234,7 @@ impl BrushContext { /// [`rasterize`](Self::rasterize); with `depth`, Wet Edges is left out of `out` and each /// painted pixel's depth inside the dab (`1 − rn`) goes to `depth`, for the stroke to apply it. - fn rasterize_into(&self, d: &Dab, dual: bool, rect: Rect, out: &mut Vec, mut depth: Option<&mut Vec>) { + pub fn rasterize_into(&self, d: &Dab, dual: bool, rect: Rect, out: &mut Vec, mut depth: Option<&mut Vec>) { let (w, h) = (rect.width() as usize, rect.height() as usize); out.clear(); out.resize(w * h, 0.0); @@ -394,7 +394,7 @@ impl BrushContext { /// [`stroke_mask`](Self::stroke_mask) with Wet Edges from the stroke's depth at the pixel /// (the deepest any dab reaches it, see [`CoverageMap::depth`]). #[inline] - fn stroke_mask_wet(&self, c: f32, depth: Option, dual: Option, x: i32, y: i32) -> f32 { + pub fn stroke_mask_wet(&self, c: f32, depth: Option, dual: Option, x: i32, y: i32) -> f32 { let b = &self.brush; let mut m = match depth { Some(dp) => c * wet_factor(1.0 - dp), diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index c84c2f1f..62a9fda7 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -50,11 +50,15 @@ fn draw_clone_preview(app: &mut PhotocraftApp, painter: &egui::Painter, xf: &Vie (h.finish(), radius.to_bits(), at.map(f64::to_bits)) }); let key = format!( - "{:?}:{}:{:?}:{rect:?}:{map:?}:{}:{}:{}:{}:{tip_key:?}", - st.doc.id, st.revision, st.active_layer, app.ui.tool_options.clone_sample, display_key, overlay.invert, overlay.blend + "{:?}:{}:{:?}:{rect:?}:{map:?}:{}:{}:{}:{}:{}:{tip_key:?}", + st.doc.id, st.revision, st.active_layer, app.ui.tool_options.clone_sample, display_key, overlay.invert, overlay.blend, app.ui.view.pattern_preview ); if app.clone_preview.as_ref().is_none_or(|c| c.key != key) { - let Ok(mut buf) = photocraft_engine::retouch_cmds::clone_preview(&app.session, rect, &map, &app.ui.tool_options.clone_sample) else { return }; + let Ok(mut buf) = + photocraft_engine::retouch_cmds::clone_preview_periodic(&app.session, rect, &map, &app.ui.tool_options.clone_sample, app.ui.view.pattern_preview) + else { + return; + }; if overlay.invert { for px in &mut buf.px { for c in px.iter_mut().take(3) { @@ -66,7 +70,9 @@ fn draw_clone_preview(app: &mut PhotocraftApp, painter: &egui::Painter, xf: &Vie // composite, whatever Sample reads), so Difference turns black where they line up. if overlay.blend != "normal" { let here = Mapping { source: (0.0, 0.0), anchor: (0.0, 0.0), m: [1.0, 0.0, 0.0, 1.0] }; - let Ok(under) = photocraft_engine::retouch_cmds::clone_preview(&app.session, rect, &here, "all") else { return }; + let Ok(under) = photocraft_engine::retouch_cmds::clone_preview_periodic(&app.session, rect, &here, "all", app.ui.view.pattern_preview) else { + return; + }; for (px, d) in buf.px.iter_mut().zip(&under.px) { *px = overlay_blend(&overlay.blend, *px, *d); } @@ -6419,6 +6425,16 @@ mod tests { assert!(mesh.vertices.iter().all(|v| v.pos.distance(center) <= 10.01)); assert_eq!(app.session.active().unwrap().revision, revision); assert!(app.session.presets.clone.active().anchor.is_none()); + let ordinary_key = app.clone_preview.as_ref().unwrap().key.clone(); + app.ui.view.pattern_preview = true; + let mut periodic_output = ctx.run_ui(Default::default(), |ui| { + let painter = ui.ctx().layer_painter(egui::LayerId::new(egui::Order::Foreground, egui::Id::new("clone-test"))); + draw_clone_preview(&mut app, &painter, &xf, [32.0, 32.0], None); + }); + assert_ne!(app.clone_preview.as_ref().unwrap().key, ordinary_key, "toggling Pattern Preview invalidates source pixels"); + assert!(!periodic_output.textures_delta.set.is_empty(), "the overlay texture is refreshed without a document edit"); + periodic_output.textures_delta.clear(); + assert_eq!(app.session.active().unwrap().revision, revision); app.session.presets.clone.overlay.show = false; let mut output = ctx.run_ui(Default::default(), |ui| { let ctx = ui.ctx(); diff --git a/crates/ui-egui/src/retouch_ui.rs b/crates/ui-egui/src/retouch_ui.rs index 48beefba..adf3c8cf 100644 --- a/crates/ui-egui/src/retouch_ui.rs +++ b/crates/ui-egui/src/retouch_ui.rs @@ -29,6 +29,7 @@ pub(crate) fn clone_params(app: &PhotocraftApp) -> Option { let o = &app.ui.tool_options; let mut p = json!({"aligned": o.clone_aligned, "sampleLayer": o.clone_sample}); if app.ui.tool == Tool::CloneStamp { + p["wrap"] = json!(app.ui.view.pattern_preview); p["opacity"] = json!(app.session.tools.brush.opacity * 100.0); p["flow"] = json!(app.session.tools.brush.flow * 100.0); } @@ -693,6 +694,30 @@ mod tests { } } + #[test] + fn clone_stamp_in_pattern_preview_wraps_a_dab_across_the_right_edge() { + for pattern_preview in [false, true] { + let mut app = app(); + app.run("paint.pencil", json!({"points": [[10, 10]], "size": 24, "color": "#ff0000"})).unwrap(); + app.ui.tool = Tool::CloneStamp; + app.ui.clone_source = Some([10.0, 10.0]); + app.ui.view.pattern_preview = pattern_preview; + let modifiers = egui::Modifiers::NONE; + tool_event(&mut app, ToolEvent::Down { x: 98.0, y: 30.0, pressure: 1.0 }, modifiers); + tool_event(&mut app, ToolEvent::Up { x: 98.0, y: 30.0 }, modifiers); + assert!(!app.ui.status_error, "{}", app.ui.status); + let surface = active(&app).surface().unwrap(); + let center = surface.rgba(98, 30); + assert!(center[0] > 0.9 && center[1] < 0.1, "clone center: {center:?}"); + let opposite = surface.rgba(1, 30); + if pattern_preview { + assert!(opposite[0] > 0.9 && opposite[1] < 0.1, "Pattern Preview must wrap the clone footprint: {opposite:?}"); + } else { + assert!(opposite[0] > 0.9 && opposite[1] > 0.9, "ordinary cloning must not wrap: {opposite:?}"); + } + } + } + #[test] fn alt_flips_dodge_burn_and_blur_sharpen() { for (tool, flipped) in [(Tool::Dodge, Tool::Burn), (Tool::Burn, Tool::Dodge), (Tool::Blur, Tool::Sharpen), (Tool::Sharpen, Tool::Blur)] {