Magic Eraser and Background Eraser tools; canvas rotation/flip/size move vector geometry (#70)
* Magic Eraser and Background Eraser engine commands paint.magicEraser erases the wand region (tolerance, anti-alias, contiguous, sample all layers, opacity) to transparency, tile by tile in parallel; paint.backgroundEraser erases colours similar to the one under the brush hotspot (continuous / once / background swatch sampling, discontiguous / contiguous / find-edges limits, tolerance, protect foreground colour). Both compare and erase in the layer's own channels at any depth, respect the selection, turn the Background into Layer 0 like Photoshop, and are one undo step. The Magic Eraser paints the background colour on layers with locked transparency. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Magic Eraser and Background Eraser tools in the Eraser group Shift+E / E cycles Eraser, Background Eraser and Magic Eraser; the toolbar flyout lists all three. Options bars: Magic Eraser tolerance, anti-alias, contiguous, sample all layers and opacity; Background Eraser sampling toggles, limits, tolerance and protect foreground colour. The Background Eraser cursor always shows its sampling hotspot. Icons are derived from Lucide's eraser (ISC), recorded in ATTRIBUTION.md. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Image Rotation, Canvas Size, Crop, Trim and Image Size move vector geometry Canvas-wide operations remapped only pixels: type, shape and smart-object transforms, vector masks, saved/work paths, guides, slices, notes and Count/Ruler marks stayed put, so they jumped back on the next re-render. New engine module canvas_geom applies the canvas map to all non-pixel geometry. 90°/180°/flip turns remap every pixel surface exactly (incl. type/shape/smart/fill caches and smart filter masks) and move geometry through the matching affine; guides swap orientation under 90° turns; rect/ellipse/line live shapes stay live; gradient fill angles follow. Canvas Size/Crop/Trim translate geometry (shapes re-render for the new canvas), Image Size scales it and re-renders vectors, Arbitrary rotation also moves unlinked vector masks and document marks. Layer effect angles stay (Global Light), as in Photoshop. Tests compare the CPU composite of a rotated document (type, shapes, vector mask, smart object) against the rotated flattened original, also after re-rendering everything from geometry, at 8/16/32-bit RGB, CMYK and Gray. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Magic Eraser: edit encoded tile bytes in parallel (24 MP: 1260 -> 375 ms) Tiles are copied out as encoded bytes and only the touched samples are decoded, so the parallel workers do all the work and the serial write-back is a memcpy. The ignored bench prints a phase breakdown. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
37c8f81032
commit
abf785f7b1
|
|
@ -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) | <https://github.com/rsms/inter> | 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 | <https://github.com/JetBrains/JetBrainsMono> | 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 | <https://github.com/lucide-icons/lucide> (`icons/<name>.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 | <https://github.com/lucide-icons/lucide> (`icons/<name>.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 | <https://github.com/feathericons/feather> 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 <https://github.com/lucide-icons/lucide> `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 | <http://wordlist.aspell.net/> | 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) |
|
||||
|
|
|
|||
|
|
@ -0,0 +1,18 @@
|
|||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M21 21H8a2 2 0 0 1-1.42-.587l-3.994-3.999a2 2 0 0 1 0-2.828l10-10a2 2 0 0 1 2.829 0l5.999 6a2 2 0 0 1 0 2.828L12.834 21" />
|
||||
<path d="m5.082 11.09 8.828 8.828" />
|
||||
<path d="M2 2h1" />
|
||||
<path d="M6 2h1" />
|
||||
<path d="M2 6h1" />
|
||||
<path d="M4 4h1" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 471 B |
|
|
@ -0,0 +1,16 @@
|
|||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M21 21H8a2 2 0 0 1-1.42-.587l-3.994-3.999a2 2 0 0 1 0-2.828l10-10a2 2 0 0 1 2.829 0l5.999 6a2 2 0 0 1 0 2.828L12.834 21" />
|
||||
<path d="m5.082 11.09 8.828 8.828" />
|
||||
<path d="M5 1v5" />
|
||||
<path d="M7.5 3.5h-5" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 432 B |
|
|
@ -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<Vec<f32>> = lock_color.map(|c| (0..nc).map(|i| c.get(i).copied().unwrap_or(0.0)).collect());
|
||||
let rects: Vec<Rect> = 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<u8>)> {
|
||||
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<u8>)> = {
|
||||
use rayon::prelude::*;
|
||||
rects.into_par_iter().filter_map(work).collect()
|
||||
};
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
let done: Vec<(Rect, Vec<u8>)> = 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());
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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<f32> = g.horizontal.iter().map(|y| (m2 * f64::from(*y) + tx) as f32).collect();
|
||||
let h: Vec<f32> = 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;
|
||||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
@ -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<Value> {
|
||||
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<CommandSpec> {
|
|||
),
|
||||
// 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<CommandSpec> {
|
|||
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());
|
||||
|
|
|
|||
|
|
@ -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<String>) -> 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<f64>) -> Result<f64> {
|
||||
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<T: serde::de::DeserializeOwned>(p: &Value, cmd: &str, k: &str, default: T, allowed: &str) -> Result<T> {
|
||||
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<LayerId> {
|
||||
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<EngineError> {
|
||||
(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<Value> {
|
||||
const CMD: &str = "paint.magicEraser";
|
||||
let coord = |k: &str| -> Result<f64> {
|
||||
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<Value> {
|
||||
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<CommandSpec> {
|
||||
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;
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -129,25 +129,17 @@ fn image_size(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
*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<Value> {
|
|||
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.
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -81,6 +81,7 @@ fn rotate_arbitrary(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
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<Value> {
|
|||
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<LayerId> =
|
||||
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)
|
||||
})?;
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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<f32> { 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<Option<Vec<f32>>> = 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<Vec<f32>> {
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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<Value, String>) {
|
||||
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
|
||||
}
|
||||
|
|
@ -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")),
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue