mirror of
https://github.com/storytold/photocraft.git
synced 2026-10-30 19:22:07 +01:00
* 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>
119 lines
4.8 KiB
Rust
119 lines
4.8 KiB
Rust
//! Destructive pixel operations on layers (Image → Adjustments, flips, fills).
|
|
|
|
use photocraft_compose::{Buffer, adjust};
|
|
use photocraft_doc::{Adjustment, Document, Layer, LayerContent};
|
|
use photocraft_geom::Rect;
|
|
use photocraft_raster::{Surface, from_rgba, to_rgba};
|
|
|
|
/// Apply an adjustment destructively to a surface, weighted by an optional selection.
|
|
/// Applies `adj` to a surface (any colour model and depth, via straight RGBA) through the
|
|
/// selection; `mode` is the document's, for the tone transfer (e.g. Exposure in Grayscale).
|
|
pub fn adjust_surface(s: &mut Surface, adj: &Adjustment, selection: Option<&Surface>, mode: photocraft_color::ColorMode) {
|
|
let r = s.content_bounds();
|
|
if r.is_empty() {
|
|
return;
|
|
}
|
|
let fmt = s.format();
|
|
let n = fmt.channels();
|
|
let raw = s.read_region(r);
|
|
let mut buf = Buffer { rect: r, px: raw.chunks_exact(n).map(|p| to_rgba(&fmt, p)).collect() };
|
|
let orig = buf.clone();
|
|
adjust::apply_with(adj, &mut buf, adjust::Transfer::for_mode(mode));
|
|
let w = r.width() as usize;
|
|
let mut out = Vec::with_capacity(raw.len());
|
|
for (i, (a, o)) in buf.px.iter().zip(&orig.px).enumerate() {
|
|
let k = selection.map_or(1.0, |sel| sel.sample_channel(r.x0 + (i % w) as i32, r.y0 + (i / w) as i32, 0));
|
|
let mixed: [f32; 4] = std::array::from_fn(|c| o[c] + (a[c] - o[c]) * k);
|
|
let mut enc = [0.0f32; 8];
|
|
let m = photocraft_raster::from_rgba_into(&fmt, mixed, &mut enc);
|
|
out.extend_from_slice(&enc[..m]);
|
|
}
|
|
s.write_region(r, &out);
|
|
}
|
|
|
|
/// Fill (respecting selection coverage) with a straight RGBA colour.
|
|
pub fn fill_surface(s: &mut Surface, area: Rect, color: [f32; 4], selection: Option<&Surface>, lock_transparency: bool) {
|
|
let fmt = s.format();
|
|
let n = fmt.channels();
|
|
let mut region = s.read_region(area);
|
|
let w = area.width() as usize;
|
|
for (i, px) in region.chunks_exact_mut(n).enumerate() {
|
|
let x = area.x0 + (i % w) as i32;
|
|
let y = area.y0 + (i / w) as i32;
|
|
let k = selection.map_or(1.0, |sel| sel.sample_channel(x, y, 0));
|
|
if k <= 0.0 {
|
|
continue;
|
|
}
|
|
let d = to_rgba(&fmt, px);
|
|
let sa = color[3] * k;
|
|
let oa = sa + d[3] * (1.0 - sa);
|
|
let mut o = [0.0f32; 4];
|
|
if oa > 0.0 {
|
|
for c in 0..3 {
|
|
o[c] = (color[c] * sa + d[c] * d[3] * (1.0 - sa)) / oa;
|
|
}
|
|
}
|
|
o[3] = if lock_transparency { d[3] } else { oa };
|
|
let mut enc = [0.0f32; 8];
|
|
photocraft_raster::from_rgba_into(&fmt, o, &mut enc);
|
|
px.copy_from_slice(&enc[..n]);
|
|
}
|
|
s.write_region(area, ®ion);
|
|
}
|
|
|
|
/// Clear pixels (make transparent) within the selection.
|
|
pub fn clear_surface(s: &mut Surface, area: Rect, selection: Option<&Surface>) {
|
|
let fmt = s.format();
|
|
let n = fmt.channels();
|
|
if !fmt.alpha {
|
|
return;
|
|
}
|
|
let mut region = s.read_region(area);
|
|
let w = area.width() as usize;
|
|
for (i, px) in region.chunks_exact_mut(n).enumerate() {
|
|
let k = selection.map_or(1.0, |sel| sel.sample_channel(area.x0 + (i % w) as i32, area.y0 + (i / w) as i32, 0));
|
|
px[n - 1] *= 1.0 - k;
|
|
// Fully cleared pixels become the empty pixel, so bounds and tile pruning see them as gone.
|
|
if px[n - 1] <= 0.0 {
|
|
px.fill(0.0);
|
|
}
|
|
}
|
|
s.write_region(area, ®ion);
|
|
s.prune();
|
|
}
|
|
|
|
/// Remap a surface through a pixel-coordinate mapping (used for flips/rotations).
|
|
pub fn remap_surface(s: &Surface, map: impl Fn(i32, i32) -> (i32, i32)) -> Surface {
|
|
let r = s.content_bounds();
|
|
let mut out = Surface::with_default(s.format(), &s.default_pixel());
|
|
let n = s.channels();
|
|
let raw = s.read_region(r);
|
|
let w = r.width() as usize;
|
|
for (i, px) in raw.chunks_exact(n).enumerate() {
|
|
let (x, y) = (r.x0 + (i % w) as i32, r.y0 + (i / w) as i32);
|
|
let (nx, ny) = map(x, y);
|
|
out.write_pixel(nx, ny, px);
|
|
}
|
|
out
|
|
}
|
|
|
|
/// 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));
|
|
let stack = vec![lower.clone(), upper.clone()];
|
|
let mut tmp = Document::new("merge", doc_bounds.size(), format.mode, format.sample);
|
|
tmp.layers = stack;
|
|
let buf = photocraft_compose::render(&tmp, area);
|
|
let mut s = Surface::new(format);
|
|
let data: Vec<f32> = buf.px.iter().flat_map(|p| from_rgba(&format, *p)).collect();
|
|
if !area.is_empty() {
|
|
s.write_region(area, &data);
|
|
s.prune();
|
|
}
|
|
let mut merged = Layer::new(lower.name.clone(), LayerContent::Raster(s));
|
|
merged.id = lower.id;
|
|
merged.blend = lower.blend;
|
|
merged.opacity = 1.0;
|
|
merged
|
|
}
|