mirror of
https://github.com/storytold/photocraft.git
synced 2026-11-14 13:32:45 +00:00
The live on-canvas filter preview runs the real command on a downsampled proxy of the document, but the proxy dropped the selection, so on documents large enough to need a proxy (over ~5 MP) the preview filtered the whole layer while OK only changed the selection. The proxy now downsamples the selection with the pixels, so the preview is limited to the (feathered) selection exactly like the result. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: Brandon Thomas <echelon@gmail.com>
138 lines
5.9 KiB
Rust
138 lines
5.9 KiB
Rust
//! Downsampled proxy documents: every pixel surface scaled down by an integer factor `k`, so a
|
|
//! composite of the proxy approximates the document's composite at 1/k resolution for ~1/k² of
|
|
//! the work. Used for interactive previews and for thumbnails of large documents (navigator,
|
|
//! channel thumbnails, histograms, file previews), where a full-resolution composite of a 200 MP
|
|
//! document would cost seconds and gigabytes.
|
|
|
|
use photocraft_doc::{Document, Layer, LayerContent, Size};
|
|
use photocraft_geom::Rect;
|
|
use photocraft_raster::Surface;
|
|
|
|
/// Nearest-neighbour downsample of a surface by integer factor `k` (document coordinates / k).
|
|
pub fn downsample(s: &Surface, k: u32) -> Surface {
|
|
let mut out = Surface::with_default(s.format(), &s.default_pixel());
|
|
let b = s.content_bounds();
|
|
if b.is_empty() || k <= 1 {
|
|
return if k <= 1 { s.clone() } else { out };
|
|
}
|
|
// Surfaces span at most ±2^30 px; larger factors reduce everything to one pixel anyway.
|
|
let k = k.min(1 << 20) as i32;
|
|
let bpp = s.format().bytes_per_pixel();
|
|
let (x0, x1) = (b.x0.div_euclid(k), b.x1.div_euclid(k) + i32::from(b.x1.rem_euclid(k) != 0));
|
|
let (y0, y1) = (b.y0.div_euclid(k), b.y1.div_euclid(k) + i32::from(b.y1.rem_euclid(k) != 0));
|
|
let w = (x1 - x0).max(0) as usize;
|
|
let mut row = vec![0u8; w * bpp];
|
|
for oy in y0..y1 {
|
|
let src = s.to_interleaved(Rect::new(x0 * k, oy * k, x1 * k, oy * k + 1));
|
|
for (i, px) in row.chunks_exact_mut(bpp).enumerate() {
|
|
px.copy_from_slice(&src[i * k as usize * bpp..(i * k as usize + 1) * bpp]);
|
|
}
|
|
out.write_interleaved(Rect::new(x0, oy, x1, oy + 1), &row);
|
|
}
|
|
out
|
|
}
|
|
|
|
fn shrink_layer(l: &mut Layer, k: u32) {
|
|
if let Some(m) = &mut l.mask {
|
|
m.surface = downsample(&m.surface, k);
|
|
}
|
|
if let Some(fc) = &mut l.fill_cache {
|
|
fc.surface = downsample(&fc.surface, k);
|
|
}
|
|
match &mut l.content {
|
|
LayerContent::Raster(s) => *s = downsample(s, k),
|
|
LayerContent::Group(g) => {
|
|
if let Some(ab) = &mut g.artboard {
|
|
let divisor = i64::from(k);
|
|
// Pixel x samples source x*k: both half-open edges round up, including negatives.
|
|
let edge = |v: i32| {
|
|
let v = i64::from(v);
|
|
(v.div_euclid(divisor) + i64::from(v.rem_euclid(divisor) != 0)) as i32
|
|
};
|
|
ab.rect = Rect::new(edge(ab.rect.x0), edge(ab.rect.y0), edge(ab.rect.x1), edge(ab.rect.y1));
|
|
}
|
|
for c in &mut g.children {
|
|
shrink_layer(c, k);
|
|
}
|
|
}
|
|
LayerContent::Text(t) => t.cache = t.cache.as_ref().map(|s| downsample(s, k)),
|
|
LayerContent::Shape(sh) => sh.cache = sh.cache.as_ref().map(|s| downsample(s, k)),
|
|
LayerContent::Smart(so) => so.cache = so.cache.as_ref().map(|s| downsample(s, k)),
|
|
LayerContent::Adjustment(_) | LayerContent::Fill(_) => {}
|
|
}
|
|
}
|
|
|
|
/// A copy of `doc` scaled down by `k` (same layer ids, so adjustments can be swapped in). The
|
|
/// selection scales with it, so a filter previewed on the proxy stays inside the (feathered)
|
|
/// selection exactly as the full-size result does.
|
|
pub fn proxy_document(doc: &Document, k: u32) -> Document {
|
|
let mut p = doc.clone();
|
|
if k <= 1 {
|
|
return p;
|
|
}
|
|
p.size = Size::new(doc.size.width.div_ceil(k), doc.size.height.div_ceil(k));
|
|
for l in &mut p.layers {
|
|
shrink_layer(l, k);
|
|
}
|
|
// Spot channels are printed under Multichannel composites.
|
|
for c in &mut p.channels {
|
|
c.surface = downsample(&c.surface, k);
|
|
}
|
|
p.selection = p.selection.as_ref().map(|s| downsample(s, k));
|
|
p
|
|
}
|
|
|
|
/// Whether a proxy composite is a faithful reduction of `doc`: layer effects (whose sizes are in
|
|
/// document pixels) and vector masks (paths in document coordinates) would not scale with it.
|
|
pub fn proxy_faithful(doc: &Document) -> bool {
|
|
doc.walk().iter().all(|(_, _, l)| !crate::effects::has_effects(l) && l.vector_mask.as_ref().is_none_or(|v| !v.enabled))
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use photocraft_color::{Color, ColorMode, PixelFormat, SampleType};
|
|
|
|
#[test]
|
|
fn downsample_picks_every_kth_pixel() {
|
|
let mut s = Surface::new(PixelFormat::RGBA8);
|
|
s.fill_rect(Rect::new(0, 0, 8, 8), &[1.0, 0.0, 0.0, 1.0]);
|
|
s.write_pixel(4, 4, &[0.0, 0.0, 1.0, 1.0]);
|
|
let d = downsample(&s, 4);
|
|
assert_eq!(d.pixel(0, 0), vec![1.0, 0.0, 0.0, 1.0]);
|
|
assert_eq!(d.pixel(1, 1), vec![0.0, 0.0, 1.0, 1.0]);
|
|
assert_eq!(d.pixel(2, 2)[3], 0.0);
|
|
}
|
|
|
|
#[test]
|
|
fn downsample_handles_negative_coordinates() {
|
|
let mut s = Surface::new(PixelFormat::RGBA8);
|
|
s.fill_rect(Rect::new(-8, -8, 0, 0), &[0.0, 1.0, 0.0, 1.0]);
|
|
let d = downsample(&s, 4);
|
|
assert_eq!(d.pixel(-1, -1), vec![0.0, 1.0, 0.0, 1.0]);
|
|
assert_eq!(d.pixel(0, 0)[3], 0.0);
|
|
}
|
|
|
|
#[test]
|
|
fn proxy_keeps_structure_and_ids() {
|
|
let doc = Document::with_background("d", Size::new(4000, 3000), ColorMode::Rgb, SampleType::U8, Color::WHITE);
|
|
let p = proxy_document(&doc, 4);
|
|
assert_eq!((p.size.width, p.size.height), (1000, 750));
|
|
assert_eq!(p.layers[0].id, doc.layers[0].id);
|
|
assert_eq!(p.layers[0].surface().unwrap().pixel(999, 749), vec![1.0; 4]);
|
|
assert!(proxy_faithful(&doc));
|
|
}
|
|
|
|
#[test]
|
|
fn proxy_scales_the_selection() {
|
|
let mut doc = Document::with_background("d", Size::new(64, 64), ColorMode::Rgb, SampleType::U8, Color::WHITE);
|
|
let mut sel = Surface::new(PixelFormat::GRAY8);
|
|
sel.fill_rect(Rect::new(0, 0, 32, 64), &[1.0]);
|
|
doc.selection = Some(sel);
|
|
let p = proxy_document(&doc, 4);
|
|
let s = p.selection.as_ref().expect("selection kept");
|
|
assert_eq!((s.pixel(7, 15)[0], s.pixel(8, 0)[0]), (1.0, 0.0));
|
|
assert!(proxy_document(&Document::new("n", Size::new(8, 8), ColorMode::Rgb, SampleType::U8), 4).selection.is_none());
|
|
}
|
|
}
|