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>
This commit is contained in:
parent
a666ab5a85
commit
d0fc974aae
|
|
@ -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<LayerId>,
|
||||
|
|
@ -213,8 +215,12 @@ fn clone_sample(
|
|||
which: SampleLayers,
|
||||
rect: Rect,
|
||||
map: &crate::presets::clone_source::Mapping,
|
||||
wrap: bool,
|
||||
cmd: &str,
|
||||
) -> Result<Region> {
|
||||
let read = |rect| {
|
||||
if wrap { periodic::sample(pre, id, surf, which, rect, cmd) } else { Ok(sample(pre, id, surf, which, rect)) }
|
||||
};
|
||||
if map.is_translation() {
|
||||
let off = map.offset();
|
||||
let src = rect.translate(off.0, off.1);
|
||||
|
|
@ -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<photocraft_compose::Buffer> {
|
||||
clone_preview_periodic(s, rect, map, sample_layer, false)
|
||||
}
|
||||
|
||||
/// Clone-source overlay with optional source repetition for View > Pattern Preview.
|
||||
pub fn clone_preview_periodic(
|
||||
s: &Session,
|
||||
rect: Rect,
|
||||
map: &crate::presets::clone_source::Mapping,
|
||||
sample_layer: &str,
|
||||
wrap: bool,
|
||||
) -> Result<photocraft_compose::Buffer> {
|
||||
if ![map.source.0, map.source.1, map.anchor.0, map.anchor.1].into_iter().chain(map.m).all(|v| v.is_finite() && v.abs() < 1_000_000.0) {
|
||||
return Err(bad("clone.preview", "invalid preview mapping"));
|
||||
}
|
||||
|
|
@ -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<Value> {
|
|||
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<Rect> {
|
||||
let mut dirty = self.renderer.take_dirty_rect().union(&self.tail);
|
||||
let mut tail = self.renderer.tail_preview();
|
||||
self.tail = tail.as_mut().map_or(Rect::EMPTY, |t| t.take_dirty_rect());
|
||||
dirty = dirty.union(&self.tail);
|
||||
let LivePaint::Clone { which, map, cmd } = &self.paint else {
|
||||
return Err(bad(&self.cmd, "periodic painting requires a clone source"));
|
||||
};
|
||||
let (surf, _) = crate::channel_cmds::target_surface(std::sync::Arc::make_mut(&mut self.doc), self.id, &self.params)?;
|
||||
periodic::Composite {
|
||||
target: surf,
|
||||
before: &self.pre_surf,
|
||||
tile: self.pre.bounds(),
|
||||
opacity: self.opacity,
|
||||
selection: self.sel.as_ref(),
|
||||
lock: self.lock,
|
||||
mode: self.mode,
|
||||
}
|
||||
.draw(dirty, &self.brush, &self.points, |r| clone_sample(&self.pre, self.id, &self.pre_surf, *which, r, map, true, cmd))
|
||||
}
|
||||
|
||||
/// Redraw `r` from the pre-stroke pixels with the clone source through coverage `cov`.
|
||||
fn composite(&mut self, r: Rect, cov: &[f32]) -> Result<Rect> {
|
||||
if r.is_empty() {
|
||||
return Ok(r);
|
||||
}
|
||||
let paint = match &self.paint {
|
||||
LivePaint::Clone { which, map, cmd } => clone_sample(&self.pre, self.id, &self.pre_surf, *which, r, map, cmd)?,
|
||||
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<Value> {
|
|||
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<CommandSpec> {
|
|||
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,
|
||||
|
|
|
|||
|
|
@ -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<Rect> {
|
||||
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<Region>) -> Result<Rect> {
|
||||
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<Region>,
|
||||
work: &mut u64,
|
||||
) -> Result<PatchStroke> {
|
||||
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<f32>,
|
||||
depth: Vec<f32>,
|
||||
// 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<usize>) {
|
||||
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<usize>) {
|
||||
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<LayerId>, surf: &Surface, which: SampleLayers, rect: Rect, cmd: &str) -> Result<Region> {
|
||||
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)
|
||||
}
|
||||
|
|
@ -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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<f32>, mut depth: Option<&mut Vec<f32>>) {
|
||||
pub fn rasterize_into(&self, d: &Dab, dual: bool, rect: Rect, out: &mut Vec<f32>, mut depth: Option<&mut Vec<f32>>) {
|
||||
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<f32>, dual: Option<f32>, x: i32, y: i32) -> f32 {
|
||||
pub fn stroke_mask_wet(&self, c: f32, depth: Option<f32>, dual: Option<f32>, x: i32, y: i32) -> f32 {
|
||||
let b = &self.brush;
|
||||
let mut m = match depth {
|
||||
Some(dp) => c * wet_factor(1.0 - dp),
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ pub(crate) fn clone_params(app: &PhotocraftApp) -> Option<Value> {
|
|||
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)] {
|
||||
|
|
|
|||
Loading…
Reference in New Issue