diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index 189cf01b..243854c5 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -12,9 +12,10 @@ next to them. |---|---|---|---|---| | `assets/fonts/Inter-Regular.ttf`, `Inter-Medium.ttf`, `Inter-SemiBold.ttf` | Inter 4.001 (UI font) | The Inter Project Authors (Rasmus Andersson) | | SIL OFL 1.1, [`assets/fonts/OFL-Inter.txt`](assets/fonts/OFL-Inter.txt) | | `assets/fonts/JetBrainsMono-Regular.ttf` | JetBrains Mono 2.305 (numeric font) | The JetBrains Mono Project Authors | | SIL OFL 1.1, [`assets/fonts/OFL-JetBrainsMono.txt`](assets/fonts/OFL-JetBrainsMono.txt) | -| `assets/icons/*.svg` except `slice-knife.svg` (108 files) | Lucide icons | Lucide Icons and Contributors | (`icons/.svg`; `align-*` are now named `text-align-*` upstream) | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) | +| `assets/icons/*.svg` except `slice-knife.svg`, `eraser-background.svg` and `eraser-magic.svg` (108 files) | Lucide icons | Lucide Icons and Contributors | (`icons/.svg`; `align-*` are now named `text-align-*` upstream) | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) | | `assets/icons/{check,chevron-down,chevron-right,chevron-up,chevrons-left,chevrons-right,circle,clock,compass,info,link,lock,minus,moon,move,navigation,plus,search,square,trash,triangle,type,x,zoom-in}.svg` | Lucide icons derived from Feather (subset of the row above) | Cole Bemis (Feather), Lucide Contributors | via Lucide | MIT (Feather) and ISC (Lucide), [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) | | `assets/icons/slice-knife.svg` | Slice tool glyph | PhotoCraft contributors | Original work | MIT OR Apache-2.0 | +| `assets/icons/eraser-background.svg`, `assets/icons/eraser-magic.svg` | Background Eraser and Magic Eraser tool glyphs: Lucide `eraser` plus added marks | Lucide Icons and Contributors; marks by PhotoCraft contributors | Derived from `icons/eraser.svg` | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) | | `assets/dict/en_US-scowl-50.txt.gz` | English word list for Check Spelling (SCOWL 2020.12.07, size 50, en_US; 72,403 words) | Kevin Atkinson and the SCOWL contributors | | SCOWL license (permissive, keep the notice), [`assets/dict/LICENSE-SCOWL.txt`](assets/dict/LICENSE-SCOWL.txt), which also has the build recipe | | `crates/cms/profiles/photocraft-coated-cmyk.icc` | PhotoCraft Coated CMYK (synthetic) ICC profile | PhotoCraft contributors (generated by `photocraft-cms`, `src/synth.rs`) | Original work, see [`crates/cms/README.md`](crates/cms/README.md) | CC0-1.0 | | `assets/app-icon/` (all files) | PhotoCraft app icon (nine-tailed kitsune) | The project owner (drawn in ArtCraft, vectorised) | Original work, see [`assets/app-icon/README.md`](assets/app-icon/README.md) | MIT OR Apache-2.0, [`assets/app-icon/LICENSE.txt`](assets/app-icon/LICENSE.txt) | diff --git a/assets/icons/eraser-background.svg b/assets/icons/eraser-background.svg new file mode 100644 index 00000000..f6dbec59 --- /dev/null +++ b/assets/icons/eraser-background.svg @@ -0,0 +1,18 @@ + + + + + + + + diff --git a/assets/icons/eraser-magic.svg b/assets/icons/eraser-magic.svg new file mode 100644 index 00000000..14ac740a --- /dev/null +++ b/assets/icons/eraser-magic.svg @@ -0,0 +1,16 @@ + + + + + + diff --git a/crates/algo/src/erase.rs b/crates/algo/src/erase.rs new file mode 100644 index 00000000..6f644d11 --- /dev/null +++ b/crates/algo/src/erase.rs @@ -0,0 +1,164 @@ +//! Magic Eraser: erase a wand [`Region`] to transparency (or, on a layer with locked transparency, +//! to a colour), tile by tile. +//! +//! Works in the surface's own channels at any depth and in any colour model: only the alpha +//! channel changes (or, under a transparency lock, the colour channels move toward `lock_color`). + +use photocraft_color::{read_sample, write_sample}; +use photocraft_geom::Rect; +use photocraft_raster::Surface; + +use crate::selection::Region; + +/// Erases `region` from `target` at `amount` (0–1, the tool's opacity), weighted by the selection's +/// coverage. With `lock_color` (native colour channels, e.g. the background colour converted to the +/// surface's model) the pixels are painted toward that colour instead and alpha is kept, as +/// Photoshop does on layers with locked transparency. Returns the damaged rectangle. +pub fn magic_erase(target: &mut Surface, region: &Region, amount: f32, selection: Option<&Surface>, lock_color: Option<&[f32]>) -> Rect { + let amount = if amount.is_finite() { amount.clamp(0.0, 1.0) } else { 0.0 }; + let bbox = region.bbox; + let bw = bbox.width() as usize; + if bbox.is_empty() || amount <= 0.0 || region.mask.len() != bw * bbox.height() as usize { + return Rect::EMPTY; + } + let fmt = target.format(); + let nc = fmt.mode.color_channels(); + let alpha = fmt.alpha.then_some(nc); + // Without alpha and without a lock colour there is nothing an eraser can do. + if alpha.is_none() && lock_color.is_none() { + return Rect::EMPTY; + } + let lock: Option> = lock_color.map(|c| (0..nc).map(|i| c.get(i).copied().unwrap_or(0.0)).collect()); + let rects: Vec = bbox.tiles().map(|tc| tc.rect().intersect(&bbox)).filter(|r| !r.is_empty()).collect(); + let src: &Surface = target; + let (sample, bpp) = (fmt.sample, fmt.bytes_per_pixel()); + // Each tile is edited as encoded bytes (only the touched samples are decoded), in parallel; + // the results are copied back serially. + let work = |r: Rect| -> Option<(Rect, Vec)> { + let w = r.width() as usize; + let cov = |x: i32, y: i32| region.mask.get((y - bbox.y0) as usize * bw + (x - bbox.x0) as usize).copied().unwrap_or(0); + // Skip tiles the region doesn't touch. + if !(r.y0..r.y1).any(|y| (r.x0..r.x1).any(|x| cov(x, y) != 0)) { + return None; + } + let mut bytes = src.to_interleaved(r); + let sel = selection.map(|s| (s.channels(), s.read_region(r))); + for y in r.y0..r.y1 { + for x in r.x0..r.x1 { + let c = cov(x, y); + if c == 0 { + continue; + } + let i = (y - r.y0) as usize * w + (x - r.x0) as usize; + let s = sel.as_ref().map_or(1.0, |(sc, v)| v.get(i * sc).copied().unwrap_or(0.0)); + let k = f32::from(c) / 255.0 * amount * s; + if k <= 0.0 { + continue; + } + let Some(px) = bytes.get_mut(i * bpp..(i + 1) * bpp) else { continue }; + match (&lock, alpha) { + (Some(col), _) => { + for (ch, t) in col.iter().enumerate() { + let v = read_sample(px, sample, ch); + write_sample(px, sample, ch, v + (t - v) * k); + } + } + (None, Some(a)) => { + let v = read_sample(px, sample, a) * (1.0 - k); + if k >= 1.0 || v <= 0.0 { + // Fully erased pixels become the empty pixel, so tiles can be pruned. + px.fill(0); + } else { + write_sample(px, sample, a, v); + } + } + (None, None) => {} + } + } + } + Some((r, bytes)) + }; + #[cfg(not(target_arch = "wasm32"))] + let done: Vec<(Rect, Vec)> = { + use rayon::prelude::*; + rects.into_par_iter().filter_map(work).collect() + }; + #[cfg(target_arch = "wasm32")] + let done: Vec<(Rect, Vec)> = rects.into_iter().filter_map(work).collect(); + let mut dmg = Rect::EMPTY; + for (r, bytes) in done { + target.write_interleaved(r, &bytes); + dmg = dmg.union(&r); + } + if lock.is_none() { + target.prune(); + } + dmg +} + +#[cfg(test)] +mod tests { + use super::*; + use photocraft_color::{ColorMode, PixelFormat, SampleType}; + use photocraft_raster::from_rgba; + + fn region(bbox: Rect, v: u8) -> Region { + Region { bbox, mask: vec![v; bbox.width() as usize * bbox.height() as usize] } + } + + #[test] + fn erases_alpha_in_every_format() { + for mode in [ColorMode::Rgb, ColorMode::Cmyk, ColorMode::Grayscale, ColorMode::Lab] { + for sample in [SampleType::U8, SampleType::U16, SampleType::F32] { + let fmt = PixelFormat::new(mode, sample, true); + let mut s = Surface::new(fmt); + s.fill_rect(Rect::new(0, 0, 600, 300), &from_rgba(&fmt, [0.2, 0.5, 0.7, 1.0])); + let before = s.pixel(10, 10); + let d = magic_erase(&mut s, ®ion(Rect::new(0, 0, 300, 300), 255), 1.0, None, None); + assert_eq!(d, Rect::new(0, 0, 300, 300)); + assert_eq!(s.rgba(10, 10)[3], 0.0, "{fmt:?}"); + assert_eq!(s.pixel(400, 10), before, "outside the region is untouched {fmt:?}"); + // Half opacity halves alpha and keeps colour. + let mut h = Surface::new(fmt); + h.fill_rect(Rect::new(0, 0, 10, 10), &from_rgba(&fmt, [0.2, 0.5, 0.7, 1.0])); + magic_erase(&mut h, ®ion(Rect::new(0, 0, 10, 10), 255), 0.5, None, None); + let p = h.pixel(5, 5); + let a = fmt.mode.color_channels(); + assert!((p[a] - 0.5).abs() < 0.01, "{fmt:?} {p:?}"); + assert!((p[0] - before[0]).abs() < 1e-6, "colour kept {fmt:?}"); + } + } + } + + #[test] + fn respects_selection_and_lock() { + let fmt = PixelFormat::RGBA8; + let mut s = Surface::new(fmt); + s.fill_rect(Rect::new(0, 0, 20, 20), &[1.0, 0.0, 0.0, 1.0]); + let mut sel = Surface::new(PixelFormat::GRAY8); + sel.fill_rect(Rect::new(0, 0, 10, 20), &[1.0]); + magic_erase(&mut s, ®ion(Rect::new(0, 0, 20, 20), 255), 1.0, Some(&sel), None); + assert_eq!(s.rgba(5, 5)[3], 0.0); + assert_eq!(s.rgba(15, 5)[3], 1.0); + // Locked transparency paints the lock colour, alpha kept. + let mut l = Surface::new(fmt); + l.fill_rect(Rect::new(0, 0, 20, 20), &[1.0, 0.0, 0.0, 1.0]); + magic_erase(&mut l, ®ion(Rect::new(0, 0, 20, 20), 255), 1.0, None, Some(&[1.0, 1.0, 1.0])); + assert_eq!(l.rgba(5, 5), [1.0, 1.0, 1.0, 1.0]); + } + + #[test] + fn degenerate_inputs_do_nothing() { + let mut s = Surface::new(PixelFormat::RGBA8); + s.fill_rect(Rect::new(0, 0, 4, 4), &[1.0, 0.0, 0.0, 1.0]); + let orig = s.clone(); + // Mask length mismatch, zero/NaN amount, empty box. + assert!(magic_erase(&mut s, &Region { bbox: Rect::new(0, 0, 4, 4), mask: vec![255; 3] }, 1.0, None, None).is_empty()); + assert!(magic_erase(&mut s, ®ion(Rect::new(0, 0, 4, 4), 255), f32::NAN, None, None).is_empty()); + assert!(magic_erase(&mut s, ®ion(Rect::EMPTY, 255), 1.0, None, None).is_empty()); + assert_eq!(s, orig); + // No alpha channel and no lock colour: nothing to erase. + let mut g = Surface::new(PixelFormat::GRAY8); + assert!(magic_erase(&mut g, ®ion(Rect::new(0, 0, 4, 4), 255), 1.0, None, None).is_empty()); + } +} diff --git a/crates/algo/src/lib.rs b/crates/algo/src/lib.rs index 368888d8..05b524cb 100644 --- a/crates/algo/src/lib.rs +++ b/crates/algo/src/lib.rs @@ -27,6 +27,7 @@ pub mod content_aware; mod denoise; mod distort; mod distort2; +pub mod erase; pub mod exif; pub mod features; mod fxutil; diff --git a/crates/engine/src/canvas_geom.rs b/crates/engine/src/canvas_geom.rs new file mode 100644 index 00000000..8d742203 --- /dev/null +++ b/crates/engine/src/canvas_geom.rs @@ -0,0 +1,286 @@ +//! Canvas-wide geometry: Image › Image Rotation (90°, 180°, flips, arbitrary), Canvas Size, Crop, +//! Trim and Image Size move everything in the document, not only pixels. Pixels are remapped by +//! the callers; this module moves the rest: type, shape and smart-object transforms, vector masks, +//! gradient fill angles, effect reference points, artboards, and document-level paths, guides, +//! slices, notes and measurement marks. Layer effect angles are left alone (Photoshop keeps the +//! Global Light when the canvas rotates). + +use std::sync::Arc; + +use photocraft_doc::{Document, Fill, Layer, LayerContent, TextLayer}; +use photocraft_geom::{Affine, Point, Rect}; +use photocraft_raster::Surface; + +use crate::pixels::remap_surface; + +const EPS: f64 = 1e-9; + +fn map_pt(a: &Affine, p: [f64; 2]) -> [f64; 2] { + let q = a.apply(Point::new(p[0], p[1])); + [q.x, q.y] +} + +/// Bounding box `[x0, y0, x1, y1]` of the four mapped corners of `[x0, y0, x1, y1]`. +fn map_box(a: &Affine, b: [f64; 4]) -> [f64; 4] { + let c = [[b[0], b[1]], [b[2], b[1]], [b[2], b[3]], [b[0], b[3]]].map(|p| map_pt(a, p)); + c.iter().fold([f64::MAX, f64::MAX, f64::MIN, f64::MIN], |r, p| [r[0].min(p[0]), r[1].min(p[1]), r[2].max(p[0]), r[3].max(p[1])]) +} + +fn map_rect(a: &Affine, r: Rect) -> Rect { + if r.is_empty() { + return r; + } + let b = map_box(a, [f64::from(r.x0), f64::from(r.y0), f64::from(r.x1), f64::from(r.y1)]); + let i = |v: f64| v.round().clamp(f64::from(i32::MIN / 2), f64::from(i32::MAX / 2)) as i32; + Rect::new(i(b[0]), i(b[1]), i(b[2]), i(b[3])) +} + +/// A gradient angle (degrees, counter-clockwise on screen) carried through `a`'s linear part. +fn map_angle(a: &Affine, deg: f32) -> f32 { + let (s, c) = f64::from(deg).to_radians().sin_cos(); + // Direction in y-down document space. + let (dx, dy) = (c, -s); + let (nx, ny) = (a.m[0] * dx + a.m[2] * dy, a.m[1] * dx + a.m[3] * dy); + if nx.abs() < EPS && ny.abs() < EPS { + return deg; + } + let out = (-ny).atan2(nx).to_degrees(); + // Keep whole-degree angles whole (90° turns and flips are exact up to rounding noise). + let out = if (out - out.round()).abs() < 1e-6 { out.round() } else { out }; + // (−180°, 180°]: −0 from atan2 would otherwise give −180°. + (if out <= -180.0 { out + 360.0 } else { out }) as f32 +} + +fn map_fill(a: &Affine, f: &mut Fill) { + if let Fill::Gradient { angle, .. } = f { + *angle = map_angle(a, *angle); + } +} + +/// Rebuilds a type layer's PSD `TySh` block for its current transform, keeping its pixels. +fn retag_text(dpi: f32, t: &mut TextLayer) { + if t.psd_raw.is_none() { + return; + } + let mut eng = photocraft_text::shared().lock().unwrap_or_else(|e| e.into_inner()); + let layout = eng.layout(t, dpi); + t.psd_raw = Some(Arc::new(photocraft_text::psd::build_tysh(t, dpi, layout.bounds()))); +} + +/// Applies `a` to one layer's (and its children's) non-pixel geometry. With `content` false only +/// the parts Free Transform (`transform_layer`) doesn't move are touched: unlinked vector masks, +/// gradient fill angles, effect reference points and artboards. +pub(crate) fn transform_layer_geometry(l: &mut Layer, a: &Affine, content: bool, dpi: f32) { + if let Some(vm) = l.vector_mask.as_mut() + && (content || !vm.linked) + { + vm.path = vm.path.transform(a); + } + if let Some((x, y)) = l.effects.reference { + let p = map_pt(a, [x, y]); + l.effects.reference = Some((p[0], p[1])); + } + if !content { + // Free Transform doesn't warp fill caches: drop it, the fill re-renders from its model. + l.fill_cache = None; + } else if let Some(fc) = &mut l.fill_cache { + map_fill(a, &mut fc.fill); + } + match &mut l.content { + LayerContent::Fill(f) => map_fill(a, f), + LayerContent::Shape(sh) => { + if let Some(f) = &mut sh.fill { + map_fill(a, f); + } + if content { + crate::vector_cmds::transform_shape(sh, a); + } + } + LayerContent::Text(t) if content => { + t.transform = a.mul(&t.transform); + retag_text(dpi, t); + } + LayerContent::Smart(sm) if content => sm.transform = crate::smart_cmds::snap_affine(a.mul(&sm.transform)), + LayerContent::Group(g) => { + if let Some(ab) = &mut g.artboard { + ab.rect = map_rect(a, ab.rect); + } + for c in &mut g.children { + transform_layer_geometry(c, a, content, dpi); + } + } + _ => {} + } +} + +/// Document-level geometry: paths, notes, Count/Ruler marks, slices and guides. Guides survive +/// axis-aligned maps (and swap orientation under 90° turns); any other map clears them. +pub(crate) fn transform_doc_marks(doc: &mut Document, a: &Affine) { + for p in &mut doc.paths { + p.path = p.path.transform(a); + } + if let Some(wp) = &mut doc.work_path { + *wp = wp.transform(a); + } + for n in &mut doc.notes { + n.position = map_pt(a, n.position); + n.popup = map_box(a, n.popup); + } + for g in &mut doc.measurement.count_groups { + for p in &mut g.points { + *p = map_pt(a, *p); + } + } + if let Some(r) = &mut doc.measurement.ruler { + r.start = map_pt(a, r.start); + r.end = map_pt(a, r.end); + if let Some(p) = &mut r.protractor { + *p = map_pt(a, *p); + } + } + for s in &mut doc.slices.list { + s.rect = map_rect(a, s.rect); + } + let [m0, m1, m2, m3, tx, ty] = a.m; + let g = &mut doc.guides; + if m1.abs() < EPS && m2.abs() < EPS { + g.horizontal.iter_mut().for_each(|y| *y = (m3 * f64::from(*y) + ty) as f32); + g.vertical.iter_mut().for_each(|x| *x = (m0 * f64::from(*x) + tx) as f32); + } else if m0.abs() < EPS && m3.abs() < EPS { + // A horizontal guide at y becomes a vertical one at x' = m2·y + tx, and vice versa. + let v: Vec = g.horizontal.iter().map(|y| (m2 * f64::from(*y) + tx) as f32).collect(); + let h: Vec = g.vertical.iter().map(|x| (m1 * f64::from(*x) + ty) as f32).collect(); + g.horizontal = h; + g.vertical = v; + } else { + g.horizontal.clear(); + g.vertical.clear(); + } +} + +/// Applies `a` to all non-pixel geometry of the document (layers and document marks). +pub(crate) fn transform_geometry(doc: &mut Document, a: &Affine) { + let dpi = doc.resolution_dpi; + for l in &mut doc.layers { + transform_layer_geometry(l, a, true, dpi); + } + transform_doc_marks(doc, a); +} + +/// What to re-render after the geometry moved. +#[derive(Clone, Copy, PartialEq, Eq)] +pub(crate) enum Refresh { + /// Shapes only (their pixels are clipped to the canvas, which may have grown). + Shapes, + /// Type, shapes and smart objects (after a resampling map). Smart objects whose source is + /// unavailable keep their (already mapped) cache. + All, +} + +pub(crate) fn refresh(doc: &mut Document, what: Refresh) { + fn rec(snapshot: &Document, layers: &mut [Layer], what: Refresh) { + for l in layers { + match &mut l.content { + LayerContent::Group(g) => rec(snapshot, &mut g.children, what), + LayerContent::Shape(sh) => crate::vector_cmds::refresh_shape(snapshot, sh), + LayerContent::Text(t) if what == Refresh::All => crate::type_cmds::refresh(snapshot, t), + LayerContent::Smart(_) if what == Refresh::All => { + let _ = crate::smart_cmds::refresh_layer(snapshot, l); + } + _ => {} + } + } + } + let snapshot = doc.clone(); + rec(&snapshot, &mut doc.layers, what); +} + +/// Image › Image Rotation by a right angle or a flip. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum Turn { + FlipHorizontal, + FlipVertical, + Rotate180, + Cw90, + Ccw90, +} + +impl Turn { + /// Pixel map `(x, y) → (x', y')` (pixel indices) for a `w × h` canvas. + pub(crate) fn pixel_map(self, w: i32, h: i32) -> impl Fn(i32, i32) -> (i32, i32) { + move |x, y| match self { + Turn::FlipHorizontal => (w - 1 - x, y), + Turn::FlipVertical => (x, h - 1 - y), + Turn::Rotate180 => (w - 1 - x, h - 1 - y), + Turn::Cw90 => (h - 1 - y, x), + Turn::Ccw90 => (y, w - 1 - x), + } + } + /// The same map in continuous (pixel-edge) coordinates. + pub(crate) fn affine(self, w: f64, h: f64) -> Affine { + Affine { + m: match self { + Turn::FlipHorizontal => [-1.0, 0.0, 0.0, 1.0, w, 0.0], + Turn::FlipVertical => [1.0, 0.0, 0.0, -1.0, 0.0, h], + Turn::Rotate180 => [-1.0, 0.0, 0.0, -1.0, w, h], + Turn::Cw90 => [0.0, 1.0, -1.0, 0.0, h, 0.0], + Turn::Ccw90 => [0.0, -1.0, 1.0, 0.0, 0.0, w], + }, + } + } + fn swaps(self) -> bool { + matches!(self, Turn::Cw90 | Turn::Ccw90) + } +} + +/// Every pixel surface a canvas turn moves: content, rendered caches (type, shape, smart object, +/// fill), masks (layer, smart filter), alpha channels, Quick Mask and the selection. +fn remap_pixels(doc: &mut Document, map: &dyn Fn(i32, i32) -> (i32, i32)) { + fn rec(layers: &mut [Layer], map: &dyn Fn(i32, i32) -> (i32, i32)) { + let re = |s: &mut Surface| *s = remap_surface(s, map); + for l in layers { + if let Some(m) = &mut l.mask { + re(&mut m.surface); + } + if let Some(fc) = &mut l.fill_cache { + re(&mut fc.surface); + } + match &mut l.content { + LayerContent::Raster(s) => re(s), + LayerContent::Text(t) => t.cache.iter_mut().for_each(re), + LayerContent::Shape(sh) => sh.cache.iter_mut().for_each(re), + LayerContent::Smart(sm) => { + sm.cache.iter_mut().for_each(re); + if let Some(m) = &mut sm.filter_mask { + re(&mut m.surface); + } + } + LayerContent::Group(g) => rec(&mut g.children, map), + _ => {} + } + } + } + rec(&mut doc.layers, map); + for ch in doc.channels.iter_mut().chain(doc.quick_mask.as_mut()) { + ch.surface = remap_surface(&ch.surface, map); + } + if let Some(sel) = &doc.selection { + doc.selection = Some(remap_surface(sel, map)); + } +} + +/// Turns or flips the whole canvas. Pixels (and rendered caches) are remapped exactly, so nothing +/// is re-rendered; vector geometry follows through the matching affine map. +pub(crate) fn turn_canvas(doc: &mut Document, turn: Turn) { + let (w, h) = (doc.size.width as i32, doc.size.height as i32); + let map = turn.pixel_map(w, h); + remap_pixels(doc, &map); + let a = turn.affine(f64::from(doc.size.width), f64::from(doc.size.height)); + if turn.swaps() { + doc.size = photocraft_doc::Size::new(doc.size.height, doc.size.width); + } + transform_geometry(doc, &a); +} + +#[cfg(test)] +mod tests; diff --git a/crates/engine/src/canvas_geom/tests.rs b/crates/engine/src/canvas_geom/tests.rs new file mode 100644 index 00000000..a6f0a441 --- /dev/null +++ b/crates/engine/src/canvas_geom/tests.rs @@ -0,0 +1,277 @@ +use super::*; +use crate::Session; +use photocraft_doc::vector::LiveShape; +use serde_json::json; + +const W: u32 = 64; +const H: u32 = 40; + +fn doc(s: &Session) -> &Document { + &s.active().unwrap().doc +} + +/// Paints the active raster layer with `f(x, y)` (straight RGBA). +fn paint(s: &mut Session, f: impl Fn(i32, i32) -> [f32; 4]) { + s.edit("setup", |doc, active| { + let b = doc.bounds(); + let surf = doc.layer_mut(active.unwrap()).unwrap().surface_mut().unwrap(); + let fmt = surf.format(); + let mut data = Vec::new(); + for y in b.y0..b.y1 { + for x in b.x0..b.x1 { + data.extend(photocraft_raster::from_rgba(&fmt, f(x, y))); + } + } + surf.write_region(b, &data); + surf.prune(); + Ok(()) + }) + .unwrap(); +} + +/// An asymmetric document: a gradient Background, a type layer, a shape layer, a raster layer +/// with a vector mask and a smart object, plus a guide, a saved path and a slice. +fn mixed(depth: u64, mode: &str) -> Session { + let mut s = Session::new(); + s.execute("file.new", json!({"width": W, "height": H, "depth": depth, "mode": mode})).unwrap(); + paint(&mut s, |x, y| [x as f32 / W as f32, y as f32 / H as f32, 0.3, 1.0]); + s.execute("type.create", json!({"x": 4, "y": 16, "text": "Fj", "size": 14, "color": "#102080"})).unwrap(); + s.execute("shape.create", json!({"kind": "rect", "rect": [40, 4, 18, 9], "fill": "#ff8800"})).unwrap(); + s.execute("shape.create", json!({"kind": "ellipse", "rect": [6, 24, 12, 10], "fill": "#00aa44"})).unwrap(); + s.execute("layer.new.layer", json!({})).unwrap(); + paint(&mut s, |x, y| if (28..60).contains(&x) && (18..38).contains(&y) { [0.9, 0.1, 0.6, 1.0] } else { [0.0; 4] }); + s.execute("path.set", json!({"path": {"subpaths": [{"knots": [[30, 20], [58, 22], [36, 37]]}]}})).unwrap(); + s.execute("layer.vectorMask.currentPath", json!({})).unwrap(); + s.execute("layer.new.layer", json!({})).unwrap(); + paint(&mut s, |x, y| if (20..27).contains(&x) && (2..12).contains(&y) { [0.1, 0.8, 0.9, 1.0] } else { [0.0; 4] }); + s.execute("layer.smartObjects.convertToSmartObject", json!({})).unwrap(); + s.execute("view.newGuide", json!({"orientation": "vertical", "position": 10})).unwrap(); + s.execute("view.newGuide", json!({"orientation": "horizontal", "position": 5})).unwrap(); + s.execute("path.set", json!({"name": "Saved", "path": {"subpaths": [{"knots": [[2, 2], [12, 2], [12, 8]]}]}})).unwrap(); + s.execute("slice.new", json!({"rect": [1, 2, 10, 6]})).unwrap(); + s +} + +fn command(t: Turn) -> &'static str { + match t { + Turn::FlipHorizontal => "image.imageRotation.flipCanvasHorizontal", + Turn::FlipVertical => "image.imageRotation.flipCanvasVertical", + Turn::Rotate180 => "image.imageRotation.180", + Turn::Cw90 => "image.imageRotation.90cw", + Turn::Ccw90 => "image.imageRotation.90ccw", + } +} + +const TURNS: [Turn; 5] = [Turn::FlipHorizontal, Turn::FlipVertical, Turn::Rotate180, Turn::Cw90, Turn::Ccw90]; + +/// The flattened original, remapped by the turn's pixel map. +fn expected(before: &photocraft_compose::Buffer, t: Turn, nw: u32) -> Vec<[f32; 4]> { + let map = t.pixel_map(W as i32, H as i32); + let mut out = vec![[0.0f32; 4]; before.px.len()]; + for y in 0..H as i32 { + for x in 0..W as i32 { + let (nx, ny) = map(x, y); + out[ny as usize * nw as usize + nx as usize] = before.px[y as usize * W as usize + x as usize]; + } + } + out +} + +/// (mean absolute difference, fraction of pixels differing by more than 0.2). +fn diff(a: &[[f32; 4]], b: &[[f32; 4]]) -> (f32, f32) { + assert_eq!(a.len(), b.len()); + let mut sum = 0.0; + let mut big = 0usize; + for (p, q) in a.iter().zip(b) { + let d = (0..4).map(|c| (p[c] * p[3] - q[c] * q[3]).abs()).fold(0.0f32, f32::max); + sum += d; + big += usize::from(d > 0.2); + } + (sum / a.len() as f32, big as f32 / a.len() as f32) +} + +fn check_turn(depth: u64, mode: &str, t: Turn) { + let mut s = mixed(depth, mode); + let before = photocraft_compose::flatten(doc(&s)); + s.execute(command(t), json!({})).unwrap(); + let nw = doc(&s).size.width; + let want = expected(&before, t, nw); + // Caches are remapped exactly; vector masks re-rasterize from their mapped path. + let got = photocraft_compose::flatten(doc(&s)); + let (mean, big) = diff(&got.px, &want); + assert!(mean < 1.0 / 255.0 && big < 0.01, "{t:?} {depth} {mode}: mean {mean} big {big}"); + // Re-rendering every type, shape and smart object from its *geometry* gives the same image, + // so the transforms (not only the cached pixels) moved. + let mut d = doc(&s).clone(); + refresh(&mut d, Refresh::All); + let (mean, big) = diff(&photocraft_compose::flatten(&d).px, &want); + assert!(mean < 2.0 / 255.0 && big < 0.01, "{t:?} {depth} {mode} re-rendered: mean {mean} big {big}"); + // Undo restores the original. + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(photocraft_compose::flatten(doc(&s)).px, before.px); +} + +#[test] +fn right_angle_turns_move_vectors_type_and_smart_objects() { + for t in TURNS { + check_turn(8, "rgb", t); + } +} + +#[test] +fn turns_at_every_depth_and_mode() { + for (depth, mode) in [(16, "rgb"), (32, "rgb"), (8, "cmyk"), (16, "cmyk"), (8, "gray"), (16, "gray")] { + check_turn(depth, mode, Turn::Cw90); + } + check_turn(32, "rgb", Turn::FlipHorizontal); + check_turn(8, "cmyk", Turn::Ccw90); +} + +#[test] +fn turns_map_guides_paths_slices_and_live_shapes() { + let mut s = mixed(8, "rgb"); + s.execute("image.imageRotation.90cw", json!({})).unwrap(); + let d = doc(&s); + assert_eq!((d.size.width, d.size.height), (H, W)); + // A vertical guide at x=10 becomes a horizontal one at y=10; a horizontal one at y=5 becomes + // a vertical one at x = H − 5. + assert_eq!(d.guides.horizontal, vec![10.0]); + assert_eq!(d.guides.vertical, vec![(H - 5) as f32]); + // Slice [1,2 10×6] → x' = H − y: [H−8, 1, 6×10]. + let sl = &d.slices.list[0]; + assert_eq!(sl.rect, Rect::new(H as i32 - 8, 1, H as i32 - 2, 11)); + let saved = d.paths.iter().find(|p| p.name == "Saved").unwrap(); + let k = saved.path.subpaths[0].knots[1].anchor; + assert!((k.x - (f64::from(H) - 2.0)).abs() < 1e-9 && (k.y - 12.0).abs() < 1e-9, "{k:?}"); + // The rectangle stays a live rectangle with the rotated box. + let live: Vec<&LiveShape> = d + .walk() + .into_iter() + .filter_map(|(_, _, l)| match &l.content { + LayerContent::Shape(sh) => sh.live.as_ref(), + _ => None, + }) + .collect(); + assert!( + live.iter() + .any(|l| matches!(l, LiveShape::Rect { rect, .. } if rect.iter().zip([f64::from(H) - 13.0, 40.0, 9.0, 18.0]).all(|(a, b)| (a - b).abs() < 1e-9))), + "{live:?}" + ); + assert!(live.iter().any(|l| matches!(l, LiveShape::Ellipse { .. }))); + // Flip twice is the identity for the geometry. + let before = doc(&s).clone(); + s.execute("image.imageRotation.flipCanvasVertical", json!({})).unwrap(); + s.execute("image.imageRotation.flipCanvasVertical", json!({})).unwrap(); + let after = doc(&s); + assert_eq!(after.guides, before.guides); + assert_eq!(after.slices.list[0].rect, before.slices.list[0].rect); + assert_eq!(photocraft_compose::flatten(after).px, photocraft_compose::flatten(&before).px); +} + +#[test] +fn gradient_fill_angle_follows_the_canvas() { + let a = Turn::Cw90.affine(10.0, 20.0); + // Up (90°) turns clockwise to the right (0°); flips mirror. + assert_eq!(map_angle(&a, 90.0), 0.0); + assert_eq!(map_angle(&Turn::FlipHorizontal.affine(10.0, 20.0), 0.0), 180.0); + assert_eq!(map_angle(&Turn::FlipVertical.affine(10.0, 20.0), 30.0), -30.0); + assert_eq!(map_angle(&Turn::Ccw90.affine(10.0, 20.0), 0.0), 90.0); +} + +fn type_and_shape_origin(d: &Document) -> ([f64; 6], [f64; 2]) { + let mut t = None; + let mut k = None; + for (_, _, l) in d.walk() { + match &l.content { + LayerContent::Text(x) => t = Some(x.transform.m), + LayerContent::Shape(sh) if k.is_none() => k = Some([sh.path.subpaths[0].knots[0].anchor.x, sh.path.subpaths[0].knots[0].anchor.y]), + _ => {} + } + } + (t.unwrap(), k.unwrap()) +} + +#[test] +fn canvas_size_and_crop_move_vector_geometry() { + let mut s = mixed(8, "rgb"); + let before = photocraft_compose::flatten(doc(&s)); + let (t0, k0) = type_and_shape_origin(doc(&s)); + s.execute("image.canvasSize", json!({"width": 20, "height": 10, "relative": true, "anchor": "bottomRight", "extensionColor": "transparent"})).unwrap(); + let d = doc(&s); + let (t1, k1) = type_and_shape_origin(d); + assert_eq!((t1[4] - t0[4], t1[5] - t0[5]), (20.0, 10.0)); + assert_eq!((k1[0] - k0[0], k1[1] - k0[1]), (20.0, 10.0)); + assert_eq!(d.guides.vertical, vec![30.0]); + assert_eq!(d.slices.list[0].rect, Rect::new(21, 12, 31, 18)); + // The old canvas shows unchanged at its new offset, also after re-rendering from geometry. + let mut r = d.clone(); + refresh(&mut r, Refresh::All); + for img in [photocraft_compose::flatten(d), photocraft_compose::flatten(&r)] { + let nw = img.rect.width() as usize; + let shifted: Vec<[f32; 4]> = (0..H as usize).flat_map(|y| img.px[(y + 10) * nw + 20..(y + 10) * nw + 20 + W as usize].to_vec()).collect(); + let (mean, big) = diff(&shifted, &before.px); + assert!(mean < 2.0 / 255.0 && big < 0.01, "mean {mean} big {big}"); + } + // Crop back: everything returns. + s.execute("image.crop", json!({"x": 20, "y": 10, "width": W, "height": H})).unwrap(); + let (t2, k2) = type_and_shape_origin(doc(&s)); + assert_eq!((t2, k2), (t0, k0)); + let (mean, _) = diff(&photocraft_compose::flatten(doc(&s)).px, &before.px); + assert!(mean < 1.0 / 255.0, "{mean}"); +} + +#[test] +fn image_size_scales_vector_geometry() { + let mut s = mixed(8, "rgb"); + let (t0, k0) = type_and_shape_origin(doc(&s)); + s.execute("image.imageSize", json!({"width": W * 2, "height": H * 2})).unwrap(); + let d = doc(&s); + let (t1, k1) = type_and_shape_origin(d); + assert!((t1[0] - 2.0 * t0[0]).abs() < 1e-9 && (t1[4] - 2.0 * t0[4]).abs() < 1e-9, "{t0:?} {t1:?}"); + assert_eq!((k1[0], k1[1]), (2.0 * k0[0], 2.0 * k0[1])); + assert_eq!(d.guides.vertical, vec![20.0]); +} + +#[test] +fn arbitrary_rotation_moves_marks_and_unlinked_vector_masks() { + let mut s = mixed(8, "rgb"); + // Unlink the vector mask: a canvas rotation still moves it. + s.edit("unlink", |doc, _| { + for l in doc.layers.iter_mut() { + if let Some(vm) = &mut l.vector_mask { + vm.linked = false; + } + } + Ok(()) + }) + .unwrap(); + let before = doc(&s).clone(); + s.execute("image.rotation.arbitrary", json!({"angle": 90, "direction": "cw"})).unwrap(); + let d = doc(&s); + let a = Turn::Cw90.affine(f64::from(W), f64::from(H)); + let vm = |d: &Document| d.walk().into_iter().find_map(|(_, _, l)| l.vector_mask.as_ref().map(|v| v.path.subpaths[0].knots[0].anchor)).unwrap(); + let (p, q) = (a.apply(vm(&before)), vm(d)); + assert!((p.x - q.x).abs() < 1e-6 && (p.y - q.y).abs() < 1e-6, "{p:?} {q:?}"); + assert_eq!(d.slices.list[0].rect, map_rect(&a, before.slices.list[0].rect)); + // 90° arbitrary == 90° clockwise for the composite. + let mut s2 = mixed(8, "rgb"); + s2.execute("image.imageRotation.90cw", json!({})).unwrap(); + let (mean, big) = diff(&photocraft_compose::flatten(d).px, &photocraft_compose::flatten(doc(&s2)).px); + assert!(mean < 3.0 / 255.0 && big < 0.02, "mean {mean} big {big}"); +} + +#[test] +fn turns_on_empty_and_tiny_documents_never_panic() { + for (w, h) in [(1, 1), (1, 7), (5, 1)] { + let mut s = Session::new(); + s.execute("file.new", json!({"width": w, "height": h})).unwrap(); + for t in TURNS { + s.execute(command(t), json!({"junk": [1, 2]})).unwrap(); + } + s.execute("image.rotation.arbitrary", json!({"angle": 33})).unwrap(); + } + let mut s = Session::new(); + for t in TURNS { + assert!(s.execute(command(t), json!({})).is_err(), "no document"); + } +} diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index 5dbf94b7..2d1fe6a1 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -87,6 +87,15 @@ fn has_selection(s: &Session) -> std::result::Result<(), String> { s.active().filter(|d| d.doc.selection.is_some()).map(|_| ()).ok_or_else(|| "no selection".into()) } +/// Image › Image Rotation by a right angle or a flip (pixels, vectors, guides, … all move). +fn turn(s: &mut Session, label: &str, t: crate::canvas_geom::Turn) -> Result { + s.edit(label, |doc, _| { + crate::canvas_geom::turn_canvas(doc, t); + Ok(()) + })?; + Ok(Value::Null) +} + macro_rules! cmd { ($id:literal, $label:literal, [$($m:literal),*], $sc:expr, $params:literal, $en:expr, $run:expr) => { CommandSpec { id: $id, label: $label, menu: &[$($m),*], shortcut: $sc, params: $params, enabled: $en, run: $run, journal: true } @@ -695,46 +704,19 @@ fn build() -> Vec { ), // Image cmd!("image.imageRotation.flipCanvasHorizontal", "Flip Canvas Horizontal", ["Image", "Image Rotation"], None, "{}", has_doc, |s, _| { - s.edit("Flip Canvas Horizontal", |doc, _| { - let w = doc.size.width as i32; - pixels::remap_document(doc, &|x, y| (w - 1 - x, y)); - Ok(()) - })?; - Ok(Value::Null) + turn(s, "Flip Canvas Horizontal", crate::canvas_geom::Turn::FlipHorizontal) }), cmd!("image.imageRotation.flipCanvasVertical", "Flip Canvas Vertical", ["Image", "Image Rotation"], None, "{}", has_doc, |s, _| { - s.edit("Flip Canvas Vertical", |doc, _| { - let h = doc.size.height as i32; - pixels::remap_document(doc, &|x, y| (x, h - 1 - y)); - Ok(()) - })?; - Ok(Value::Null) + turn(s, "Flip Canvas Vertical", crate::canvas_geom::Turn::FlipVertical) }), cmd!("image.imageRotation.180", "180°", ["Image", "Image Rotation"], None, "{}", has_doc, |s, _| { - s.edit("Rotate Canvas", |doc, _| { - let (w, h) = (doc.size.width as i32, doc.size.height as i32); - pixels::remap_document(doc, &|x, y| (w - 1 - x, h - 1 - y)); - Ok(()) - })?; - Ok(Value::Null) + turn(s, "Rotate Canvas", crate::canvas_geom::Turn::Rotate180) }), cmd!("image.imageRotation.90cw", "90° Clockwise", ["Image", "Image Rotation"], None, "{}", has_doc, |s, _| { - s.edit("Rotate Canvas", |doc, _| { - let h = doc.size.height as i32; - pixels::remap_document(doc, &|x, y| (h - 1 - y, x)); - doc.size = Size::new(doc.size.height, doc.size.width); - Ok(()) - })?; - Ok(Value::Null) + turn(s, "Rotate Canvas", crate::canvas_geom::Turn::Cw90) }), cmd!("image.imageRotation.90ccw", "90° Counter Clockwise", ["Image", "Image Rotation"], None, "{}", has_doc, |s, _| { - s.edit("Rotate Canvas", |doc, _| { - let w = doc.size.width as i32; - pixels::remap_document(doc, &|x, y| (y, w - 1 - x)); - doc.size = Size::new(doc.size.height, doc.size.width); - Ok(()) - })?; - Ok(Value::Null) + turn(s, "Rotate Canvas", crate::canvas_geom::Turn::Ccw90) }), cmd!("image.adjustments.desaturate", "Desaturate", ["Image", "Adjustments"], Some("Cmd+Shift+U"), "{}", has_pixel_layer, |s, _| { destructive_adjust( @@ -960,6 +942,7 @@ fn build() -> Vec { v.extend(crate::edit_cmds::specs()); v.extend(crate::color_cmds::specs()); v.extend(crate::brush_cmds::specs()); + v.extend(crate::eraser_cmds::specs()); v.extend(crate::retouch_cmds::specs()); v.extend(crate::image_cmds::specs()); v.extend(crate::selection_cmds::specs()); diff --git a/crates/engine/src/eraser_cmds.rs b/crates/engine/src/eraser_cmds.rs new file mode 100644 index 00000000..e0b545f9 --- /dev/null +++ b/crates/engine/src/eraser_cmds.rs @@ -0,0 +1,192 @@ +//! The Eraser group's colour-aware tools: Magic Eraser (`paint.magicEraser`, a wand-style region +//! erased to transparency) and Background Eraser (`paint.backgroundEraser`, a brush that erases +//! colours similar to the one under its hotspot). +//! +//! Like Photoshop, both turn a Background layer into a normal layer ("Layer 0") first, since the +//! Background can't hold transparency. The Magic Eraser paints the background colour on other +//! layers with locked transparency; the Background Eraser refuses them. Both respect the selection, +//! work in the layer's own colour model and depth, and are one undo step per click / stroke. + +use photocraft_algo::selection as sel; +use photocraft_doc::{Layer, LayerContent, LayerId}; +use photocraft_geom::Rect; +use photocraft_paint::Stroke; +use photocraft_paint::bg_erase::{BgEraseSettings, apply_background_eraser}; +use photocraft_paint::replace::{Limits, Sampling}; +use serde_json::{Value, json}; + +use crate::commands::CommandSpec; +use crate::{EngineError, Result, Session}; + +fn bad(cmd: &str, msg: impl Into) -> EngineError { + EngineError::BadParams { cmd: cmd.into(), msg: msg.into() } +} + +fn flag(p: &Value, k: &str, d: bool) -> bool { + p.get(k).and_then(Value::as_bool).unwrap_or(d) +} + +/// A finite number param clamped to `range`, `default` when absent; non-numbers are an error. +fn number(p: &Value, cmd: &str, k: &str, default: f64, range: std::ops::RangeInclusive) -> Result { + match p.get(k) { + None | Some(Value::Null) => Ok(default), + Some(v) => { + v.as_f64().filter(|f| f.is_finite()).map(|f| f.clamp(*range.start(), *range.end())).ok_or_else(|| bad(cmd, format!("`{k}` must be a number"))) + } + } +} + +fn enum_param(p: &Value, cmd: &str, k: &str, default: T, allowed: &str) -> Result { + match p.get(k) { + None | Some(Value::Null) => Ok(default), + Some(v) => serde_json::from_value(v.clone()).map_err(|_| bad(cmd, format!("`{k}` must be one of {allowed}"))), + } +} + +/// The active layer must hold pixels (the erasers don't work on type, shapes, smart objects…). +fn has_pixel_layer(s: &Session) -> std::result::Result<(), String> { + let d = s.active().ok_or("no document open")?; + let l = d.active_layer.and_then(|id| d.doc.layer(id)).ok_or("no active layer")?; + if matches!(l.content, LayerContent::Raster(_)) { Ok(()) } else { Err(format!("active layer is a {} layer, not a pixel layer", l.content.kind_name())) } +} + +fn active_pixel_layer(s: &Session, cmd: &str) -> Result { + has_pixel_layer(s).map_err(|m| bad(cmd, m))?; + s.active().and_then(|d| d.active_layer).ok_or_else(|| bad(cmd, "no active layer")) +} + +fn is_background(l: &Layer) -> bool { + l.name == "Background" && l.locks.transparency && l.locks.position && matches!(l.content, LayerContent::Raster(_)) +} + +/// Photoshop converts the Background to a normal layer before erasing to transparency. +fn unlock_background(l: &mut Layer) { + if is_background(l) { + l.name = "Layer 0".into(); + l.locks.transparency = false; + l.locks.position = false; + } +} + +fn pixels_locked(l: &Layer) -> Option { + (l.locks.pixels || l.locks.all).then(|| EngineError::Other(format!("Could not complete your request because the layer \"{}\" is locked", l.name))) +} + +fn damage(s: &mut Session, dmg: Rect, erased: bool) -> Value { + if let Some(st) = s.active_mut() + && !dmg.is_empty() + { + st.last_damage = Some(dmg); + } + json!({ "erased": erased, "damage": [dmg.x0, dmg.y0, dmg.width(), dmg.height()] }) +} + +/// `paint.magicEraser`: erase the wand region around `(x, y)`. +fn magic_eraser(s: &mut Session, p: &Value) -> Result { + const CMD: &str = "paint.magicEraser"; + let coord = |k: &str| -> Result { + p.get(k).and_then(Value::as_f64).filter(|v| v.is_finite() && v.abs() < 1e9).ok_or_else(|| bad(CMD, format!("missing or invalid `{k}`"))) + }; + let (x, y) = (coord("x")?.floor() as i32, coord("y")?.floor() as i32); + let tolerance = number(p, CMD, "tolerance", 32.0, 0.0..=255.0)? as f32; + let opacity = number(p, CMD, "opacity", 100.0, 1.0..=100.0)? as f32 / 100.0; + let (anti_alias, contiguous, sample_all) = (flag(p, "antiAlias", true), flag(p, "contiguous", true), flag(p, "sampleAllLayers", false)); + let id = active_pixel_layer(s, CMD)?; + { + let d = s.active().ok_or(EngineError::NoDocument)?; + if let Some(e) = d.doc.layer(id).and_then(pixels_locked) { + return Err(e); + } + // Clicking off the canvas erases nothing (and records no history step). + if !d.doc.bounds().contains(x, y) { + return Ok(damage(s, Rect::EMPTY, false)); + } + } + let (area, img) = crate::selection_cmds::sample_rgba8(s, sample_all)?; + let region = sel::wand_region(&img, area, (x, y), tolerance, contiguous, anti_alias); + drop(img); + let Some(region) = region else { return Ok(damage(s, Rect::EMPTY, false)) }; + let bg = s.tools.background; + let dmg = s.edit("Magic Eraser", |doc, _| { + let selection = doc.selection.clone(); + let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?; + unlock_background(l); + let lock = l.locks.transparency; + let surf = l.surface_mut().ok_or_else(|| bad(CMD, "not a pixel layer"))?; + let fmt = surf.format(); + // Locked transparency: paint the background colour instead (Photoshop). + let lock_color = lock.then(|| photocraft_raster::from_rgba(&fmt, [bg[0], bg[1], bg[2], 1.0])); + Ok(photocraft_algo::erase::magic_erase(surf, ®ion, opacity, selection.as_ref(), lock_color.as_deref())) + })?; + Ok(damage(s, dmg, true)) +} + +/// `paint.backgroundEraser`: a stroke erasing colours similar to the sampled one. +fn background_eraser(s: &mut Session, p: &Value) -> Result { + const CMD: &str = "paint.backgroundEraser"; + let pts = crate::brush_cmds::parse_points(p, CMD)?; + if pts.iter().any(|q| !q.x.is_finite() || !q.y.is_finite() || q.x.abs() > 1e7 || q.y.abs() > 1e7) { + return Err(bad(CMD, "`points` must be finite canvas coordinates")); + } + let brush = crate::brush_cmds::resolve_brush(s, p, CMD)?; + if !(brush.size.is_finite() && brush.size <= 5000.0) { + return Err(bad(CMD, "`size` must be at most 5000 px")); + } + let bs = BgEraseSettings { + sampling: enum_param(p, CMD, "sampling", Sampling::Continuous, "continuous|once|backgroundSwatch")?, + limits: enum_param(p, CMD, "limits", Limits::Contiguous, "discontiguous|contiguous|findEdges")?, + tolerance: number(p, CMD, "tolerance", 50.0, 0.0..=100.0)? as f32 / 100.0, + protect_foreground: flag(p, "protectForegroundColor", false), + foreground: s.tools.foreground, + background: s.tools.background, + }; + let id = active_pixel_layer(s, CMD)?; + { + let d = s.active().ok_or(EngineError::NoDocument)?; + let l = d.doc.layer(id).ok_or(EngineError::NoLayer(id))?; + if let Some(e) = pixels_locked(l) { + return Err(e); + } + if l.locks.transparency && !is_background(l) { + return Err(EngineError::Other(format!("Could not use the Background Eraser because the transparency of layer \"{}\" is locked", l.name))); + } + } + let stroke = Stroke { brush, points: pts }; + let dmg = s.edit("Background Eraser", |doc, _| { + let selection = doc.selection.clone(); + let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?; + unlock_background(l); + let surf = l.surface_mut().ok_or_else(|| bad(CMD, "not a pixel layer"))?; + Ok(apply_background_eraser(surf, &stroke, &bs, selection.as_ref())) + })?; + Ok(damage(s, dmg, true)) +} + +/// Eraser-group command specs. +pub fn specs() -> Vec { + vec![ + CommandSpec { + id: "paint.magicEraser", + label: "Magic Eraser", + menu: &[], + shortcut: None, + params: r##"{"x":px,"y":px,"tolerance":0..255=32,"antiAlias":bool=true,"contiguous":bool=true,"sampleAllLayers":bool=false,"opacity":1..100=100}"##, + enabled: has_pixel_layer, + run: magic_eraser, + journal: true, + }, + CommandSpec { + id: "paint.backgroundEraser", + label: "Background Eraser", + menu: &[], + shortcut: None, + params: r##"{"points":[[x,y,pressure?],…],"size":px?,"hardness":0..1?,"brush":{…}?,"preset":name?,"sampling":"continuous|once|backgroundSwatch"="continuous","limits":"discontiguous|contiguous|findEdges"="contiguous","tolerance":0..100=50,"protectForegroundColor":bool=false,"seed":u64?}"##, + enabled: has_pixel_layer, + run: background_eraser, + journal: true, + }, + ] +} + +#[cfg(test)] +mod tests; diff --git a/crates/engine/src/eraser_cmds/tests.rs b/crates/engine/src/eraser_cmds/tests.rs new file mode 100644 index 00000000..1946f346 --- /dev/null +++ b/crates/engine/src/eraser_cmds/tests.rs @@ -0,0 +1,277 @@ +use super::*; +use photocraft_raster::Surface; + +const MODES: [&str; 3] = ["rgb", "cmyk", "gray"]; +const DEPTHS: [u32; 3] = [8, 16, 32]; + +/// White document with a black 20×20 square at (10, 10) on its Background layer. +fn session(mode: &str, depth: u32) -> Session { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 64, "height": 48, "mode": mode, "depth": depth})).unwrap(); + s.execute("select.rect", json!({"x": 10, "y": 10, "width": 20, "height": 20, "antiAlias": false})).unwrap(); + s.execute("edit.fill", json!({"color": "#000000"})).unwrap(); + s.execute("select.deselect", json!({})).unwrap(); + s +} + +fn layer(s: &Session) -> &Layer { + let d = s.active().unwrap(); + d.doc.layer(d.active_layer.unwrap()).unwrap() +} + +fn surface(s: &Session) -> &Surface { + layer(s).surface().unwrap() +} + +fn alpha(s: &Session, x: i32, y: i32) -> f32 { + surface(s).rgba(x, y)[3] +} + +fn history_len(s: &Session) -> usize { + s.active().unwrap().history.past_len() +} + +#[test] +fn magic_eraser_turns_the_background_into_a_layer_at_every_depth_and_mode() { + for mode in MODES { + for depth in DEPTHS { + let mut s = session(mode, depth); + let before = surface(&s).clone(); + let h = history_len(&s); + let r = s.execute("paint.magicEraser", json!({"x": 2, "y": 2, "antiAlias": false})).unwrap(); + assert_eq!(r["erased"], true); + assert_eq!(history_len(&s), h + 1, "one undo step {mode} {depth}"); + let l = layer(&s); + assert_eq!(l.name, "Layer 0", "{mode} {depth}"); + assert!(!l.locks.transparency && !l.locks.position); + assert_eq!(alpha(&s, 2, 2), 0.0, "white erased {mode} {depth}"); + assert_eq!(alpha(&s, 60, 40), 0.0, "contiguous white erased {mode} {depth}"); + assert_eq!(alpha(&s, 20, 20), 1.0, "black square kept {mode} {depth}"); + // The square's colour is untouched in the document's own channels. + assert_eq!(surface(&s).pixel(20, 20), before.pixel(20, 20)); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(layer(&s).name, "Background"); + assert_eq!(surface(&s), &before); + } + } +} + +#[test] +fn magic_eraser_options() { + // Non-contiguous: a second, separate black square goes too. + let mut s = session("rgb", 8); + s.execute("select.rect", json!({"x": 40, "y": 10, "width": 10, "height": 10, "antiAlias": false})).unwrap(); + s.execute("edit.fill", json!({"color": "#000000"})).unwrap(); + s.execute("select.deselect", json!({})).unwrap(); + let mut a = s; + a.execute("paint.magicEraser", json!({"x": 20, "y": 20, "contiguous": true, "antiAlias": false})).unwrap(); + assert_eq!(alpha(&a, 20, 20), 0.0); + assert_eq!(alpha(&a, 45, 15), 1.0, "contiguous keeps the other square"); + a.execute("edit.undo", json!({})).unwrap(); + a.execute("paint.magicEraser", json!({"x": 20, "y": 20, "contiguous": false, "antiAlias": false})).unwrap(); + assert_eq!(alpha(&a, 45, 15), 0.0, "non-contiguous erases every match"); + assert_eq!(alpha(&a, 2, 2), 1.0); + + // Opacity 50: half erased. + let mut s = session("rgb", 16); + s.execute("paint.magicEraser", json!({"x": 2, "y": 2, "opacity": 50, "antiAlias": false})).unwrap(); + assert!((alpha(&s, 2, 2) - 0.5).abs() < 0.01); + + // Tolerance: a near-white grey is erased only within tolerance. + for (tol, gone) in [(0.0, false), (40.0, true)] { + let mut s = session("rgb", 8); + s.execute("select.rect", json!({"x": 0, "y": 40, "width": 64, "height": 8, "antiAlias": false})).unwrap(); + s.execute("edit.fill", json!({"color": "#e6e6e6"})).unwrap(); + s.execute("select.deselect", json!({})).unwrap(); + s.execute("paint.magicEraser", json!({"x": 2, "y": 2, "tolerance": tol, "antiAlias": false})).unwrap(); + assert_eq!(alpha(&s, 5, 44) == 0.0, gone, "tolerance {tol}"); + } + + // The selection limits the erased area. + let mut s = session("rgb", 8); + s.execute("select.rect", json!({"x": 0, "y": 0, "width": 32, "height": 48, "antiAlias": false})).unwrap(); + s.execute("paint.magicEraser", json!({"x": 2, "y": 2, "antiAlias": false})).unwrap(); + assert_eq!(alpha(&s, 2, 2), 0.0); + assert_eq!(alpha(&s, 50, 2), 1.0, "outside the selection kept"); +} + +#[test] +fn magic_eraser_samples_all_layers_and_paints_background_under_a_transparency_lock() { + // A half-opaque red band over the Background: the composite band is split by the black square, + // the layer's own band isn't. + let band = |s: &mut Session| { + s.execute("layer.new.layer", json!({})).unwrap(); + s.execute("select.rect", json!({"x": 0, "y": 14, "width": 64, "height": 4, "antiAlias": false})).unwrap(); + s.execute("edit.fill", json!({"color": "#ff0000"})).unwrap(); + s.execute("select.deselect", json!({})).unwrap(); + let id = s.active().unwrap().active_layer.unwrap(); + s.edit("opacity", |doc, _| { + doc.layer_mut(id).unwrap().opacity = 0.5; + Ok(()) + }) + .unwrap(); + }; + let mut s = session("rgb", 8); + band(&mut s); + s.execute("paint.magicEraser", json!({"x": 2, "y": 15, "tolerance": 0, "sampleAllLayers": true, "antiAlias": false})).unwrap(); + assert_eq!(alpha(&s, 2, 15), 0.0); + assert_eq!(alpha(&s, 50, 15), 1.0, "the composite band is split by the black square"); + let mut s = session("rgb", 8); + band(&mut s); + s.execute("paint.magicEraser", json!({"x": 2, "y": 15, "tolerance": 0, "antiAlias": false})).unwrap(); + assert_eq!(alpha(&s, 50, 15), 0.0, "the layer's own band is one region"); + + // A normal layer with locked transparency gets the background colour instead. + let mut s = session("rgb", 8); + s.execute("layer.new.layer", json!({})).unwrap(); + s.execute("edit.fill", json!({"color": "#ff0000"})).unwrap(); + s.execute("tools.setColors", json!({"background": "#00ff00"})).unwrap(); + let id = s.active().unwrap().active_layer.unwrap(); + s.edit("lock", |doc, _| { + doc.layer_mut(id).unwrap().locks.transparency = true; + Ok(()) + }) + .unwrap(); + s.execute("paint.magicEraser", json!({"x": 2, "y": 2, "antiAlias": false})).unwrap(); + assert_eq!(surface(&s).rgba(2, 2), [0.0, 1.0, 0.0, 1.0]); +} + +#[test] +fn magic_eraser_fails_gracefully() { + let mut s = session("rgb", 8); + for p in [json!({}), json!({"x": 1}), json!({"x": "a", "y": 1}), json!({"x": 1, "y": 1, "tolerance": "high"}), json!({"x": 1e300, "y": 1})] { + assert!(s.execute("paint.magicEraser", p.clone()).is_err(), "{p}"); + } + // Off the canvas: nothing erased, no history step. + let h = history_len(&s); + let r = s.execute("paint.magicEraser", json!({"x": -5, "y": 400})).unwrap(); + assert_eq!(r["erased"], false); + assert_eq!(history_len(&s), h); + // Out-of-range numbers clamp. + s.execute("paint.magicEraser", json!({"x": 2, "y": 2, "tolerance": 9999, "opacity": -3})).unwrap(); + // Locked pixels and non-pixel layers are refused. + let id = s.active().unwrap().active_layer.unwrap(); + s.edit("lock", |doc, _| { + doc.layer_mut(id).unwrap().locks.pixels = true; + Ok(()) + }) + .unwrap(); + assert!(s.execute("paint.magicEraser", json!({"x": 2, "y": 2})).is_err()); + let mut e = Session::new(); + assert!(e.execute("paint.magicEraser", json!({"x": 2, "y": 2})).is_err(), "no document"); +} + +#[test] +fn background_eraser_erases_the_sampled_colour_at_every_depth_and_mode() { + for mode in MODES { + for depth in DEPTHS { + let mut s = session(mode, depth); + let h = history_len(&s); + // Start on white, drag across the black square: only white goes. + s.execute( + "paint.backgroundEraser", + json!({"points": [[4, 20], [40, 20]], "size": 16, "hardness": 1, "sampling": "once", "limits": "discontiguous", "tolerance": 10}), + ) + .unwrap(); + assert_eq!(history_len(&s), h + 1); + assert_eq!(layer(&s).name, "Layer 0", "{mode} {depth}"); + assert_eq!(alpha(&s, 6, 20), 0.0, "white under the brush erased {mode} {depth}"); + assert_eq!(alpha(&s, 20, 20), 1.0, "black kept {mode} {depth}"); + assert_eq!(alpha(&s, 36, 20), 0.0, "white past the square erased {mode} {depth}"); + assert_eq!(alpha(&s, 6, 40), 1.0, "outside the brush kept {mode} {depth}"); + } + } +} + +#[test] +fn background_eraser_options() { + // Continuous sampling erases the black square once the hotspot is over it. + let mut s = session("rgb", 8); + s.execute( + "paint.backgroundEraser", + json!({"points": [[4, 20], [40, 20]], "size": 16, "hardness": 1, "sampling": "continuous", "limits": "discontiguous", "tolerance": 10}), + ) + .unwrap(); + assert_eq!(alpha(&s, 20, 20), 0.0); + // Protect Foreground Color (black) keeps the square even under continuous sampling. + let mut s = session("rgb", 8); + s.execute("tools.setColors", json!({"foreground": "#000000"})).unwrap(); + s.execute( + "paint.backgroundEraser", + json!({"points": [[4, 20], [40, 20]], "size": 16, "hardness": 1, "limits": "discontiguous", "tolerance": 10, "protectForegroundColor": true}), + ) + .unwrap(); + assert_eq!(alpha(&s, 20, 20), 1.0); + assert_eq!(alpha(&s, 6, 20), 0.0); + // Background swatch: with a black swatch only the square goes. + let mut s = session("rgb", 8); + s.execute("tools.setColors", json!({"background": "#000000"})).unwrap(); + s.execute( + "paint.backgroundEraser", + json!({"points": [[4, 20], [40, 20]], "size": 16, "hardness": 1, "sampling": "backgroundSwatch", "limits": "discontiguous", "tolerance": 10}), + ) + .unwrap(); + assert_eq!(alpha(&s, 20, 20), 0.0); + assert_eq!(alpha(&s, 6, 20), 1.0); +} + +#[test] +fn background_eraser_fails_gracefully() { + let mut s = session("rgb", 8); + for p in [ + json!({}), + json!({"points": []}), + json!({"points": "x"}), + json!({"points": [[1, 1]], "sampling": "sometimes"}), + json!({"points": [[1, 1]], "limits": 3}), + json!({"points": [[1, 1]], "tolerance": "lots"}), + json!({"points": [[1, 1]], "size": 1e9}), + json!({"points": [[1e300, 1]]}), + ] { + assert!(s.execute("paint.backgroundEraser", p.clone()).is_err(), "{p}"); + } + // A normal layer with locked transparency is refused. + s.execute("layer.new.layer", json!({})).unwrap(); + let id = s.active().unwrap().active_layer.unwrap(); + s.edit("lock", |doc, _| { + doc.layer_mut(id).unwrap().locks.transparency = true; + Ok(()) + }) + .unwrap(); + assert!(s.execute("paint.backgroundEraser", json!({"points": [[4, 4]]})).is_err()); +} + +/// `cargo test --release -p photocraft-engine --lib bench_magic_eraser -- --ignored --nocapture` +#[test] +#[ignore] +fn bench_magic_eraser_24mp() { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 6000, "height": 4000})).unwrap(); + s.execute("select.rect", json!({"x": 1500, "y": 1000, "width": 3000, "height": 2000, "ellipse": true})).unwrap(); + s.execute("edit.fill", json!({"color": "#204080"})).unwrap(); + s.execute("select.deselect", json!({})).unwrap(); + for (label, p) in [ + ("contiguous, background (~19 MP)", json!({"x": 10, "y": 10})), + ("non-contiguous", json!({"x": 10, "y": 10, "contiguous": false})), + ("contiguous, ellipse (~4.7 MP)", json!({"x": 3000, "y": 2000})), + ] { + let mut best = f64::MAX; + for _ in 0..3 { + let t0 = std::time::Instant::now(); + s.execute("paint.magicEraser", p.clone()).unwrap(); + best = best.min(t0.elapsed().as_secs_f64() * 1000.0); + s.execute("edit.undo", json!({})).unwrap(); + } + println!("magic eraser 6000x4000 {label}: {best:.1} ms"); + } + // Phase breakdown. + let t0 = std::time::Instant::now(); + let (area, img) = crate::selection_cmds::sample_rgba8(&s, false).unwrap(); + let t1 = t0.elapsed().as_secs_f64() * 1000.0; + let region = sel::wand_region(&img, area, (10, 10), 32.0, true, true).unwrap(); + let t2 = t0.elapsed().as_secs_f64() * 1000.0; + let mut surf = surface(&s).clone(); + photocraft_algo::erase::magic_erase(&mut surf, ®ion, 1.0, None, None); + let t3 = t0.elapsed().as_secs_f64() * 1000.0; + println!("phases: sample {t1:.1} ms, wand {:.1} ms, erase {:.1} ms", t2 - t1, t3 - t2); +} diff --git a/crates/engine/src/image_cmds.rs b/crates/engine/src/image_cmds.rs index e14c4ba0..9c1075c7 100644 --- a/crates/engine/src/image_cmds.rs +++ b/crates/engine/src/image_cmds.rs @@ -129,25 +129,17 @@ fn image_size(s: &mut Session, p: &Value) -> Result { *surf = resize_surface(surf, sx, sy, if is_mask { Resample::Bilinear } else { filter }); }); let k = ((sx + sy) / 2.0) as f32; - for_each_layer(&mut doc.layers, &mut |l| { - scale_effects(&mut l.effects, k); - if let Some(r) = &mut l.effects.reference { - *r = (r.0 * sx, r.1 * sy); - } - }); + for_each_layer(&mut doc.layers, &mut |l| scale_effects(&mut l.effects, k)); for ch in doc.channels.iter_mut().chain(doc.quick_mask.as_mut()) { ch.surface = resize_surface(&ch.surface, sx, sy, Resample::Bilinear); } if let Some(sel) = &doc.selection { doc.selection = Some(resize_surface(sel, sx, sy, Resample::Bilinear)); } - for g in &mut doc.guides.horizontal { - *g *= sy as f32; - } - for g in &mut doc.guides.vertical { - *g *= sx as f32; - } + // Vector geometry, guides and marks scale with the pixels; vectors re-render sharp. + crate::canvas_geom::transform_geometry(doc, &photocraft_geom::Affine { m: [sx, 0.0, 0.0, sy, 0.0, 0.0] }); doc.size = Size::new(nw, nh); + crate::canvas_geom::refresh(doc, crate::canvas_geom::Refresh::All); Ok(()) })?; Ok(json!({ "width": nw, "height": nh })) @@ -159,29 +151,15 @@ fn translate_doc(doc: &mut Document, dx: i32, dy: i32) { return; } for_each_surface(&mut doc.layers, true, &mut |surf, _| *surf = translate_surface(surf, dx, dy)); - fn shift_fx_reference(layers: &mut [photocraft_doc::Layer], dx: f64, dy: f64) { - for l in layers { - if let Some(r) = &mut l.effects.reference { - *r = (r.0 + dx, r.1 + dy); - } - if let LayerContent::Group(g) = &mut l.content { - shift_fx_reference(&mut g.children, dx, dy); - } - } - } - shift_fx_reference(&mut doc.layers, f64::from(dx), f64::from(dy)); for ch in doc.channels.iter_mut().chain(doc.quick_mask.as_mut()) { ch.surface = translate_surface(&ch.surface, dx, dy); } if let Some(sel) = &doc.selection { doc.selection = Some(translate_surface(sel, dx, dy)); } - for g in &mut doc.guides.horizontal { - *g += dy as f32; - } - for g in &mut doc.guides.vertical { - *g += dx as f32; - } + // Type, shapes, smart objects, vector masks, paths, guides, slices, notes… (caches above + // are already translated exactly, so nothing needs re-rendering for the move itself). + crate::canvas_geom::transform_geometry(doc, &photocraft_geom::Affine::translate(f64::from(dx), f64::from(dy))); } /// Crops the document to `r` (in current document coordinates). @@ -191,6 +169,7 @@ fn crop_doc(doc: &mut Document, r: Rect, delete_pixels: bool) { } translate_doc(doc, -r.x0, -r.y0); doc.size = Size::new(r.width(), r.height()); + crate::canvas_geom::refresh(doc, crate::canvas_geom::Refresh::Shapes); } fn anchor_factors(a: &str) -> (f64, f64) { @@ -242,6 +221,7 @@ fn canvas_size(s: &mut Session, p: &Value) -> Result { s.edit("Canvas Size", |doc, _| { translate_doc(doc, dx, dy); doc.size = Size::new(nw, nh); + crate::canvas_geom::refresh(doc, crate::canvas_geom::Refresh::Shapes); let canvas = doc.bounds(); let old = Rect::from_xywh(dx, dy, ow as u32, oh as u32); // The locked Background layer is extended with the extension colour. diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 8ed0a773..a1e164ab 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -15,6 +15,7 @@ pub mod artboard_cmds; pub mod automate_cmds; pub mod brush_cmds; pub mod build_info; +mod canvas_geom; pub mod channel_cmds; pub mod color_cmds; pub mod commands; @@ -23,6 +24,7 @@ pub mod display_color; pub mod distort_cmds; pub mod edit_cmds; pub mod edit_menu_cmds; +pub mod eraser_cmds; pub mod extra_cmds; pub mod file_cmds; pub mod filters; diff --git a/crates/engine/src/mode_cmds.rs b/crates/engine/src/mode_cmds.rs index b23a46f1..34006d22 100644 --- a/crates/engine/src/mode_cmds.rs +++ b/crates/engine/src/mode_cmds.rs @@ -81,6 +81,7 @@ fn rotate_arbitrary(s: &mut Session, p: &Value) -> Result { doc.size = new; let canvas = doc.bounds(); let fmt = doc.pixel_format(); + let dpi = doc.resolution_dpi; for l in doc.layers.iter_mut() { let background = l.name == "Background" && l.locks.position && matches!(l.content, LayerContent::Raster(_)); if background && let Some(surf) = l.surface_mut() { @@ -104,22 +105,18 @@ fn rotate_arbitrary(s: &mut Session, p: &Value) -> Result { crate::transform_cmds::transform_layer(None, l, &h, Some(a), interp)?; l.locks = locks; } + // What Free Transform leaves alone: unlinked vector masks, gradient angles, artboards… + crate::canvas_geom::transform_layer_geometry(l, &a, false, dpi); } + crate::canvas_geom::transform_doc_marks(doc, &a); for ch in doc.channels.iter_mut().chain(doc.quick_mask.as_mut()) { ch.surface = crate::transform_cmds::warp_gray(&ch.surface, &h, interp); } if let Some(sel) = &doc.selection { doc.selection = Some(crate::transform_cmds::warp_gray(sel, &h, Interp::Bilinear)).filter(|s| !s.content_bounds().is_empty()); } - // Type and smart objects re-render from their new transforms. - let ids: Vec = - doc.walk().iter().filter(|(_, _, l)| matches!(l.content, LayerContent::Text(_) | LayerContent::Smart(_))).map(|(_, _, l)| l.id).collect(); - for id in ids { - let snapshot = doc.clone(); - if let Some(l) = doc.layer_mut(id) { - crate::transform_cmds::refresh_text(&snapshot, l); - } - } + // Type, shapes and smart objects re-render from their new geometry. + crate::canvas_geom::refresh(doc, crate::canvas_geom::Refresh::All); doc.guides = Default::default(); Ok(new) })?; diff --git a/crates/engine/src/pixels.rs b/crates/engine/src/pixels.rs index 23e49b9c..21dd7b53 100644 --- a/crates/engine/src/pixels.rs +++ b/crates/engine/src/pixels.rs @@ -97,29 +97,6 @@ pub fn remap_surface(s: &Surface, map: impl Fn(i32, i32) -> (i32, i32)) -> Surfa out } -/// Apply a coordinate mapping to every raster surface and mask in the document. -pub fn remap_document(doc: &mut Document, map: &dyn Fn(i32, i32) -> (i32, i32)) { - fn rec(layers: &mut [Layer], map: &dyn Fn(i32, i32) -> (i32, i32)) { - for l in layers { - if let Some(m) = &mut l.mask { - m.surface = remap_surface(&m.surface, map); - } - match &mut l.content { - LayerContent::Raster(s) => *s = remap_surface(s, map), - LayerContent::Group(g) => rec(&mut g.children, map), - _ => {} - } - } - } - rec(&mut doc.layers, map); - for ch in doc.channels.iter_mut().chain(doc.quick_mask.as_mut()) { - ch.surface = remap_surface(&ch.surface, map); - } - if let Some(sel) = &doc.selection { - doc.selection = Some(remap_surface(sel, map)); - } -} - /// Merge `upper` onto `lower` producing a raster layer (Layer → Merge Down). pub fn merge_down(doc_bounds: Rect, lower: &Layer, upper: &Layer, format: photocraft_color::PixelFormat) -> Layer { let area = [lower, upper].iter().map(|l| l.surface().map(|s| s.content_bounds()).unwrap_or(doc_bounds)).fold(Rect::EMPTY, |a, b| a.union(&b)); diff --git a/crates/engine/src/vector_cmds.rs b/crates/engine/src/vector_cmds.rs index 2660650c..9cab6c21 100644 --- a/crates/engine/src/vector_cmds.rs +++ b/crates/engine/src/vector_cmds.rs @@ -408,6 +408,24 @@ pub fn transform_shape(sh: &mut ShapeLayer, a: &Affine) { LiveShape::Line { from, to, weight } => LiveShape::Line { from: pt(from), to: pt(to), weight }, }) } + // Flips and 90° turns keep rectangles, ellipses and lines live (Image Rotation): the new + // live box is the mapped box. Polygons/stars would change orientation, so they don't. + (Some(l), false) if (m1.abs() < 1e-12 && m2.abs() < 1e-12) || (m0.abs() < 1e-12 && m3.abs() < 1e-12) => { + let pt = |p: [f64; 2]| [m0 * p[0] + m2 * p[1] + tx, m1 * p[0] + m3 * p[1] + ty]; + let r = |r: [f64; 4]| { + let (p, q) = (pt([r[0], r[1]]), pt([r[0] + r[2], r[1] + r[3]])); + [p[0].min(q[0]), p[1].min(q[1]), (q[0] - p[0]).abs(), (q[1] - p[1]).abs()] + }; + let k = (m0.abs() + m1.abs()).min(m2.abs() + m3.abs()); + match l { + LiveShape::Rect { rect, radii } if radii.iter().all(|v| (v - radii[0]).abs() < 1e-9) => { + Some(LiveShape::Rect { rect: r(rect), radii: radii.map(|v| v * k) }) + } + LiveShape::Ellipse { rect } => Some(LiveShape::Ellipse { rect: r(rect) }), + LiveShape::Line { from, to, weight } => Some(LiveShape::Line { from: pt(from), to: pt(to), weight: weight * k }), + _ => None, + } + } _ => None, }; sh.psd_raw = None; diff --git a/crates/paint/src/bg_erase.rs b/crates/paint/src/bg_erase.rs new file mode 100644 index 00000000..14b3df39 --- /dev/null +++ b/crates/paint/src/bg_erase.rs @@ -0,0 +1,270 @@ +//! Background Eraser: along a stroke, sample the colour under the brush hotspot and erase pixels +//! of similar colour inside the brush to transparency. +//! +//! Colours are compared in the surface's own colour channels (RGB, CMYK, Gray, Lab… at any depth), +//! so nothing is converted through RGB; only alpha changes. + +use photocraft_geom::Rect; +use photocraft_raster::{Surface, from_rgba}; +use serde::{Deserialize, Serialize}; + +use crate::Stroke; +use crate::replace::{Limits, Sampling}; +use crate::retouch::{Region, alpha_index, apply_dab_stroke}; + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct BgEraseSettings { + pub sampling: Sampling, + pub limits: Limits, + /// 0..1: largest per-channel difference from the sampled colour that is erased. + pub tolerance: f32, + /// Keep pixels that match the foreground colour. + pub protect_foreground: bool, + /// Straight RGBA foreground colour (for Protect Foreground Color). + pub foreground: [f32; 4], + /// Straight RGBA background swatch (for [`Sampling::BackgroundSwatch`]). + pub background: [f32; 4], +} + +impl Default for BgEraseSettings { + fn default() -> Self { + Self { + sampling: Sampling::Continuous, + limits: Limits::Contiguous, + tolerance: 0.5, + protect_foreground: false, + foreground: [0.0, 0.0, 0.0, 1.0], + background: [1.0; 4], + } + } +} + +/// Largest per-channel difference over the colour channels. +fn dist(a: &[f32], b: &[f32]) -> f32 { + a.iter().zip(b).fold(0.0f32, |m, (x, y)| m.max((x - y).abs())) +} + +/// Erase along `stroke`. The brush opacity is ignored (Photoshop's Background Eraser has none); +/// the selection limits the effect. Returns the damaged rectangle; empty when the surface has no +/// alpha channel. +pub fn apply_background_eraser(target: &mut Surface, stroke: &Stroke, bs: &BgEraseSettings, selection: Option<&Surface>) -> Rect { + let fmt = target.format(); + let Some(a) = alpha_index(&fmt) else { return Rect::EMPTY }; + let nc = a; + let tol = if bs.tolerance.is_finite() { bs.tolerance.clamp(0.0, 1.0) } else { 0.0 }; + let soft = bs.limits != Limits::FindEdges; + let native = |c: [f32; 4]| -> Vec { from_rgba(&fmt, [c[0], c[1], c[2], 1.0])[..nc].to_vec() }; + let swatch = native(bs.background); + let fg = native(bs.foreground); + let mut once: Option>> = None; + let match_w = |d: f32| -> f32 { + if d > tol { + 0.0 + } else if soft && tol > 0.0 { + // Soft falloff over the last quarter of the tolerance. + ((tol - d) / (tol * 0.25)).clamp(0.0, 1.0) + } else { + 1.0 + } + }; + let mut stroke = stroke.clone(); + stroke.brush.opacity = 1.0; + stroke.brush.erase = false; + apply_dab_stroke(target, &stroke, selection, false, 0, |work: &mut Region, fp| { + let r = fp.rect.intersect(&work.rect); + if r.is_empty() { + return; + } + let (cx, cy) = (fp.dab.center.x.floor() as i32, fp.dab.center.y.floor() as i32); + // The hotspot's colour (None where it is already transparent or off the working area). + let hotspot = |work: &Region| -> Option> { + if !work.rect.contains(cx, cy) { + return None; + } + let p = work.px(cx, cy); + (p[a] > 0.0).then(|| p[..nc].to_vec()) + }; + let sampled = match bs.sampling { + Sampling::Continuous => hotspot(work), + Sampling::Once => once.get_or_insert_with(|| hotspot(work)).clone(), + Sampling::BackgroundSwatch => Some(swatch.clone()), + }; + let Some(sampled) = sampled else { return }; + let (w, h) = (r.width() as usize, r.height() as usize); + // Per-pixel match weight. Erasing only lowers alpha, so the working copy still holds the + // pre-stroke colours. + let mut weight = vec![0.0f32; w * h]; + for y in r.y0..r.y1 { + for x in r.x0..r.x1 { + if fp.at(x, y) <= 0.0 { + continue; + } + let p = work.px(x, y); + if p[a] <= 0.0 { + continue; + } + let c = &p[..nc]; + if bs.protect_foreground && dist(c, &fg) <= tol { + continue; + } + weight[(y - r.y0) as usize * w + (x - r.x0) as usize] = match_w(dist(c, &sampled)); + } + } + if bs.limits != Limits::Discontiguous { + keep_connected(&mut weight, w, h, (cx.clamp(r.x0, r.x1 - 1) - r.x0) as usize, (cy.clamp(r.y0, r.y1 - 1) - r.y0) as usize); + } + for y in r.y0..r.y1 { + for x in r.x0..r.x1 { + let k = fp.at(x, y) * weight[(y - r.y0) as usize * w + (x - r.x0) as usize]; + if k <= 0.0 { + continue; + } + let p = work.px_mut(x, y); + p[a] *= 1.0 - k.min(1.0); + if p[a] <= 0.0 { + p.fill(0.0); + } + } + } + }) +} + +/// Zeroes the weights not 4-connected to `(sx, sy)` through non-zero weights. +fn keep_connected(weight: &mut [f32], w: usize, h: usize, sx: usize, sy: usize) { + let mut keep = vec![false; w * h]; + let si = sy * w + sx; + if weight.get(si).is_some_and(|v| *v > 0.0) { + let mut stack = vec![si]; + keep[si] = true; + while let Some(i) = stack.pop() { + let (x, y) = (i % w, i / w); + let mut nb = |j: usize| { + if !keep[j] && weight[j] > 0.0 { + keep[j] = true; + stack.push(j); + } + }; + if x > 0 { + nb(i - 1); + } + if x + 1 < w { + nb(i + 1); + } + if y > 0 { + nb(i - w); + } + if y + 1 < h { + nb(i + w); + } + } + } + for (wv, k) in weight.iter_mut().zip(&keep) { + if !k { + *wv = 0.0; + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{BrushSettings, StrokePoint}; + use photocraft_color::{ColorMode, PixelFormat, SampleType}; + + const GREEN: [f32; 4] = [0.2, 0.6, 0.2, 1.0]; + const BLUE: [f32; 4] = [0.1, 0.1, 0.5, 1.0]; + + fn setup(fmt: PixelFormat) -> Surface { + let mut s = Surface::new(fmt); + s.fill_rect(Rect::new(0, 0, 40, 40), &from_rgba(&fmt, GREEN)); + s.fill_rect(Rect::new(40, 0, 80, 40), &from_rgba(&fmt, BLUE)); + s + } + + fn stroke(points: &[(f64, f64)]) -> Stroke { + Stroke { + brush: BrushSettings { size: 30.0, hardness: 1.0, spacing: 0.25, pressure_size: false, ..Default::default() }, + points: points.iter().map(|&(x, y)| StrokePoint::new(x, y, 1.0)).collect(), + } + } + + fn alpha(s: &Surface, x: i32, y: i32) -> f32 { + s.rgba(x, y)[3] + } + + #[test] + fn once_erases_only_the_sampled_colour_in_every_format() { + for mode in [ColorMode::Rgb, ColorMode::Cmyk, ColorMode::Grayscale] { + for sample in [SampleType::U8, SampleType::U16, SampleType::F32] { + let fmt = PixelFormat::new(mode, sample, true); + let mut s = setup(fmt); + let bs = BgEraseSettings { sampling: Sampling::Once, limits: Limits::Discontiguous, tolerance: 0.05, ..Default::default() }; + let d = apply_background_eraser(&mut s, &stroke(&[(20.0, 20.0), (60.0, 20.0)]), &bs, None); + assert!(!d.is_empty()); + assert_eq!(alpha(&s, 20, 20), 0.0, "{fmt:?} green under the hotspot erased"); + assert_eq!(alpha(&s, 45, 20), 1.0, "{fmt:?} blue kept"); + assert_eq!(alpha(&s, 20, 39), 1.0, "{fmt:?} outside the brush kept"); + } + } + } + + #[test] + fn continuous_follows_the_hotspot_and_swatch_matches_background() { + let fmt = PixelFormat::RGBA8; + let mut s = setup(fmt); + let bs = BgEraseSettings { sampling: Sampling::Continuous, limits: Limits::Discontiguous, tolerance: 0.05, ..Default::default() }; + apply_background_eraser(&mut s, &stroke(&[(20.0, 20.0), (60.0, 20.0)]), &bs, None); + assert_eq!(alpha(&s, 20, 20), 0.0); + assert_eq!(alpha(&s, 60, 20), 0.0, "blue erased once the hotspot reached it"); + // Background swatch = blue: only blue goes, wherever the hotspot is. + let mut t = setup(fmt); + let bs = BgEraseSettings { sampling: Sampling::BackgroundSwatch, background: BLUE, ..bs }; + apply_background_eraser(&mut t, &stroke(&[(30.0, 20.0), (50.0, 20.0)]), &bs, None); + assert_eq!(alpha(&t, 30, 20), 1.0); + assert_eq!(alpha(&t, 50, 20), 0.0); + } + + #[test] + fn contiguous_and_protect_foreground() { + let fmt = PixelFormat::RGBA32F; + let mut s = Surface::new(fmt); + s.fill_rect(Rect::new(0, 0, 60, 40), &GREEN); + // A blue wall separates a green island at x 30..40. + s.fill_rect(Rect::new(26, 0, 30, 40), &BLUE); + for (limits, island_erased) in [(Limits::Contiguous, false), (Limits::FindEdges, false), (Limits::Discontiguous, true)] { + let mut t = s.clone(); + let bs = BgEraseSettings { sampling: Sampling::Once, limits, tolerance: 0.05, ..Default::default() }; + apply_background_eraser(&mut t, &stroke(&[(20.0, 20.0)]), &bs, None); + assert_eq!(alpha(&t, 31, 20) == 0.0, island_erased, "{limits:?}"); + assert_eq!(alpha(&t, 20, 20), 0.0); + } + // Protecting a foreground equal to the sampled colour erases nothing. + let mut t = s.clone(); + let bs = BgEraseSettings { sampling: Sampling::Once, tolerance: 0.05, protect_foreground: true, foreground: GREEN, ..Default::default() }; + apply_background_eraser(&mut t, &stroke(&[(20.0, 20.0)]), &bs, None); + assert_eq!(t, s); + } + + #[test] + fn selection_limits_and_degenerate_inputs() { + let fmt = PixelFormat::RGBA8; + let mut s = setup(fmt); + let mut sel = Surface::new(PixelFormat::GRAY8); + sel.fill_rect(Rect::new(0, 0, 20, 40), &[1.0]); + let bs = BgEraseSettings { sampling: Sampling::Once, limits: Limits::Discontiguous, tolerance: 0.05, ..Default::default() }; + apply_background_eraser(&mut s, &stroke(&[(20.0, 20.0)]), &bs, Some(&sel)); + assert_eq!(alpha(&s, 15, 20), 0.0); + assert_eq!(alpha(&s, 25, 20), 1.0, "outside the selection kept"); + // Sampling a transparent hotspot erases nothing; NaN tolerance acts as 0; no alpha → no-op. + let mut t = Surface::new(fmt); + assert!(apply_background_eraser(&mut t, &stroke(&[(5.0, 5.0)]), &bs, None).width() > 0); + assert_eq!(t.content_bounds(), Rect::EMPTY); + let mut u = setup(fmt); + let nan = BgEraseSettings { tolerance: f32::NAN, ..bs.clone() }; + apply_background_eraser(&mut u, &stroke(&[(20.0, 20.0)]), &nan, None); + assert_eq!(alpha(&u, 20, 20), 0.0, "exact match still erased at tolerance 0"); + let mut g = Surface::new(PixelFormat::GRAY8); + assert!(apply_background_eraser(&mut g, &stroke(&[(5.0, 5.0)]), &bs, None).is_empty()); + } +} diff --git a/crates/paint/src/lib.rs b/crates/paint/src/lib.rs index 48a3111e..b1208240 100644 --- a/crates/paint/src/lib.rs +++ b/crates/paint/src/lib.rs @@ -18,6 +18,7 @@ use photocraft_geom::{Point, Rect}; use photocraft_raster::Surface; use serde::{Deserialize, Serialize}; +pub mod bg_erase; pub mod brush; pub mod dynamics; pub mod mixer; diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index bb4d3a64..4427e551 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -1059,7 +1059,8 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: _ => { painter.circle_stroke(p, r + 0.5, Stroke::new(1.0, Color32::from_black_alpha(140))); painter.circle_stroke(p, r, Stroke::new(1.0, Color32::from_white_alpha(220))); - if cur.show_crosshair_in_brush_tip || r > 6.0 { + // The Background Eraser always shows its sampling hotspot (Photoshop). + if cur.show_crosshair_in_brush_tip || r > 6.0 || tool == Tool::BackgroundEraser { crosshair(3.0); } egui::CursorIcon::None @@ -1408,6 +1409,10 @@ pub fn tool_event(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers) let _ = app.run("select.magicWand", json!({"x": x.floor(), "y": y.floor(), "tolerance": o.tolerance, "contiguous": o.contiguous, "antiAlias": o.anti_alias, "sampleAllLayers": o.sample_all_layers, "mode": mode})); return; } + Tool::MagicEraser => { + crate::eraser_ui::click(app, tool, x, y); + return; + } Tool::PaintBucket => { let o = app.ui.tool_options.clone(); let contents = if o.bucket_fill_pattern { "pattern" } else { "foreground" }; @@ -1464,7 +1469,7 @@ pub fn tool_event(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers) fn finish_gesture(app: &mut PhotocraftApp, d: Drag) { let end = d.points.last().copied().unwrap_or([d.start[0], d.start[1], 1.0]); - if crate::retouch_ui::finish_stroke(app, d.tool, &d.points, d.modifiers) { + if crate::eraser_ui::finish_stroke(app, d.tool, &d.points) || crate::retouch_ui::finish_stroke(app, d.tool, &d.points, d.modifiers) { return; } match d.tool { diff --git a/crates/ui-egui/src/eraser_ui.rs b/crates/ui-egui/src/eraser_ui.rs new file mode 100644 index 00000000..15638ed4 --- /dev/null +++ b/crates/ui-egui/src/eraser_ui.rs @@ -0,0 +1,99 @@ +//! UI for the Eraser group's colour-aware tools: Magic Eraser (click → `paint.magicEraser`) and +//! Background Eraser (stroke → `paint.backgroundEraser`), with their options bars. + +use serde_json::{Value, json}; + +use crate::PhotocraftApp; +use crate::state::Tool; +use crate::theme::Tokens; +use crate::{icons, widgets}; + +fn report(app: &mut PhotocraftApp, r: Result) { + if let Err(e) = r { + app.ui.status = e; + app.ui.status_error = true; + } +} + +/// Magic Eraser click at document `(x, y)`. Returns false for other tools. +pub fn click(app: &mut PhotocraftApp, tool: Tool, x: f64, y: f64) -> bool { + if tool != Tool::MagicEraser { + return false; + } + let o = &app.ui.tool_options; + let p = json!({ + "x": x.floor(), + "y": y.floor(), + "tolerance": o.tolerance, + "antiAlias": o.anti_alias, + "contiguous": o.contiguous, + "sampleAllLayers": o.sample_all_layers, + "opacity": o.magic_eraser_opacity, + }); + let r = app.run("paint.magicEraser", p); + report(app, r); + true +} + +/// Finishes a Background Eraser stroke. Returns false for other tools. +pub fn finish_stroke(app: &mut PhotocraftApp, tool: Tool, points: &[[f64; 3]]) -> bool { + if tool != Tool::BackgroundEraser { + return false; + } + let o = &app.ui.tool_options; + let p = json!({ + "points": points, + "sampling": o.bg_sampling, + "limits": o.bg_limits, + "tolerance": o.bg_tolerance, + "protectForegroundColor": o.bg_protect_fg, + }); + let r = app.run("paint.backgroundEraser", p); + report(app, r); + true +} + +fn opt(ui: &mut egui::Ui, text: &str) { + let t = Tokens::get(ui.ctx()); + ui.label(egui::RichText::new(text).color(t.text_dim).size(12.0)); +} + +/// Options bar for the Magic Eraser and Background Eraser. Returns false for other tools. +pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui, tool: Tool) -> bool { + let o = &mut app.ui.tool_options; + match tool { + Tool::MagicEraser => { + opt(ui, "Tolerance:"); + widgets::value_field(ui, &mut o.tolerance, 0.0..=255.0, "", 58.0); + widgets::checkbox(ui, &mut o.anti_alias, "Anti-alias"); + widgets::checkbox(ui, &mut o.contiguous, "Contiguous"); + widgets::checkbox(ui, &mut o.sample_all_layers, "Sample All Layers"); + widgets::vline(ui, 22.0); + opt(ui, "Opacity:"); + widgets::value_field(ui, &mut o.magic_eraser_opacity, 1.0..=100.0, "%", 62.0); + } + Tool::BackgroundEraser => { + // Sampling: Continuous / Once / Background Swatch, as icon toggles (Photoshop). + ui.spacing_mut().item_spacing.x = 2.0; + for (k, icon, tip) in [ + ("continuous", "pipette", "Sampling: Continuous"), + ("once", "circle-dot", "Sampling: Once"), + ("backgroundSwatch", "square", "Sampling: Background Swatch"), + ] { + if icons::button(ui, icon, 24.0, o.bg_sampling == k, tip).clicked() { + o.bg_sampling = k.into(); + } + } + ui.spacing_mut().item_spacing.x = 8.0; + widgets::vline(ui, 22.0); + opt(ui, "Limits:"); + let limits = [("discontiguous".to_string(), "Discontiguous"), ("contiguous".to_string(), "Contiguous"), ("findEdges".to_string(), "Find Edges")]; + widgets::dropdown(ui, "bg-eraser-limits", &mut o.bg_limits, &limits, 110.0); + opt(ui, "Tolerance:"); + widgets::value_field(ui, &mut o.bg_tolerance, 0.0..=100.0, "%", 62.0); + widgets::checkbox(ui, &mut o.bg_protect_fg, "Protect Foreground Color"); + } + _ => return false, + } + true +} diff --git a/crates/ui-egui/src/icon_data.rs b/crates/ui-egui/src/icon_data.rs index 0665c188..00b26437 100644 --- a/crates/ui-egui/src/icon_data.rs +++ b/crates/ui-egui/src/icon_data.rs @@ -31,6 +31,9 @@ pub static ICONS: &[(&str, &[u8])] = &[ ("droplet", include_bytes!("../../../assets/icons/droplet.svg")), ("ellipsis", include_bytes!("../../../assets/icons/ellipsis.svg")), ("eraser", include_bytes!("../../../assets/icons/eraser.svg")), + // Derived from Lucide's eraser (ISC): with a sparkle (Magic Eraser) / a transparency grid (Background Eraser). + ("eraser-background", include_bytes!("../../../assets/icons/eraser-background.svg")), + ("eraser-magic", include_bytes!("../../../assets/icons/eraser-magic.svg")), ("eye", include_bytes!("../../../assets/icons/eye.svg")), ("eye-off", include_bytes!("../../../assets/icons/eye-off.svg")), ("file", include_bytes!("../../../assets/icons/file.svg")), diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs index 1a5142bb..ca5df7b1 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -49,6 +49,8 @@ pub fn tool_icon(t: Tool) -> &'static str { Tool::EllipseMarquee => "circle-dashed", Tool::Brush => "brush", Tool::Eraser => "eraser", + Tool::BackgroundEraser => "eraser-background", + Tool::MagicEraser => "eraser-magic", Tool::Eyedropper => "pipette", Tool::Ruler => "ruler", Tool::Note => "message-square", diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 359c9ec4..746bc371 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -27,6 +27,7 @@ pub mod discard_ui; pub mod distort_ui; pub mod doc_props_ui; pub mod enable_rules; +pub mod eraser_ui; pub mod export_dialog; pub mod file_open; pub mod file_ui; diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index 6dbae1a4..9cc888c5 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -28,7 +28,7 @@ const TOOL_SECTIONS: &[&[&[Tool]]] = &[ &[Tool::Brush], &[Tool::CloneStamp], &[Tool::HistoryBrush], - &[Tool::Eraser], + &[Tool::Eraser, Tool::BackgroundEraser, Tool::MagicEraser], &[Tool::Gradient, Tool::PaintBucket], &[Tool::Blur, Tool::Sharpen, Tool::Smudge], &[Tool::Dodge, Tool::Burn, Tool::Sponge], @@ -364,7 +364,8 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { brush_preset_chip(ui, &mut app.session.tools.brush); widgets::vline(ui, 22.0); } - if crate::retouch_ui::options_bar(app, ui, tool) + if crate::eraser_ui::options_bar(app, ui, tool) + || crate::retouch_ui::options_bar(app, ui, tool) || crate::vector_ui::options_bar(app, ui, tool) || crate::analysis_ui::options_bar(app, ui, tool) || crate::slice_ui::options_bar(app, ui, tool) diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 3b93b27c..4a402ece 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -18,6 +18,8 @@ pub enum Tool { Count, Brush, Eraser, + BackgroundEraser, + MagicEraser, Gradient, PaintBucket, Type, @@ -48,7 +50,7 @@ pub enum Tool { } impl Tool { - pub const ALL: [Tool; 40] = [ + pub const ALL: [Tool; 42] = [ Tool::Move, Tool::RectMarquee, Tool::EllipseMarquee, @@ -62,6 +64,8 @@ impl Tool { Tool::Count, Tool::Brush, Tool::Eraser, + Tool::BackgroundEraser, + Tool::MagicEraser, Tool::Gradient, Tool::PaintBucket, Tool::Type, @@ -98,6 +102,8 @@ impl Tool { Tool::EllipseMarquee => "Elliptical Marquee Tool", Tool::Brush => "Brush Tool", Tool::Eraser => "Eraser Tool", + Tool::BackgroundEraser => "Background Eraser Tool", + Tool::MagicEraser => "Magic Eraser Tool", Tool::Eyedropper => "Eyedropper Tool", Tool::Ruler => "Ruler Tool", Tool::Note => "Note Tool", @@ -141,6 +147,7 @@ impl Tool { self, Tool::Brush | Tool::Eraser + | Tool::BackgroundEraser | Tool::SpotHealing | Tool::Healing | Tool::CloneStamp @@ -159,7 +166,7 @@ impl Tool { Tool::Move => 'V', Tool::RectMarquee | Tool::EllipseMarquee => 'M', Tool::Brush => 'B', - Tool::Eraser => 'E', + Tool::Eraser | Tool::BackgroundEraser | Tool::MagicEraser => 'E', Tool::Eyedropper | Tool::Ruler | Tool::Note | Tool::Count => 'I', Tool::Lasso | Tool::PolygonLasso => 'L', Tool::MagicWand => 'W', @@ -358,6 +365,15 @@ pub struct ToolOptions { pub crop_ratio: String, #[serde(default = "yes")] pub crop_delete: bool, + /// Magic Eraser opacity % (tolerance, anti-alias, contiguous and sample-all are shared with the + /// Magic Wand and Paint Bucket). + pub magic_eraser_opacity: f32, + /// Background Eraser: sampling (continuous | once | backgroundSwatch), limits (discontiguous | + /// contiguous | findEdges), tolerance % and Protect Foreground Color. + pub bg_sampling: String, + pub bg_limits: String, + pub bg_tolerance: f32, + pub bg_protect_fg: bool, } fn yes() -> bool { @@ -420,6 +436,11 @@ impl Default for ToolOptions { move_show_transform: false, crop_ratio: String::new(), crop_delete: true, + magic_eraser_opacity: 100.0, + bg_sampling: "continuous".into(), + bg_limits: "contiguous".into(), + bg_tolerance: 50.0, + bg_protect_fg: false, } } }