Files
photocraft/crates/compose/src/proxy.rs
T
8f1e0d83ce Filter previews stay inside the selection on large documents (#740) (#836)
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>
2026-10-07 20:38:29 -04:00

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());
}
}