diff --git a/crates/algo/src/content_aware.rs b/crates/algo/src/content_aware.rs index 981e854cc5..84b15a215b 100644 --- a/crates/algo/src/content_aware.rs +++ b/crates/algo/src/content_aware.rs @@ -114,6 +114,21 @@ struct Variant { ok: Vec, } +/// Most pixels the mosaic of the image and its variants may span. It is one dense rectangle, so +/// thin images turned to different angles leave it mostly empty: the cost grows with the square +/// of their length, not with their pixels. +const MAX_MOSAIC_PIXELS: usize = 1 << 25; + +/// Size of the mosaic of an `w`×`h` image and `extra` variant sizes separated by `gap`, or `None` +/// when it spans more than [`MAX_MOSAIC_PIXELS`] (or overflows). +fn mosaic_size(w: usize, h: usize, gap: usize, extra: &[(usize, usize)]) -> Option<(usize, usize)> { + let col_w = extra.iter().map(|v| v.0).max().unwrap_or(0); + let col_h = extra.iter().try_fold(0usize, |n, v| n.checked_add(v.1)?.checked_add(gap))?; + let mw = w.checked_add(gap)?.checked_add(col_w)?; + let mh = h.max(col_h); + (mw.checked_mul(mh)? <= MAX_MOSAIC_PIXELS).then_some((mw, mh)) +} + /// A source buffer and which of its pixels may be sampled. #[derive(Clone, Copy)] struct Src<'a> { @@ -149,13 +164,20 @@ fn sample(src: Src, x: f32, y: f32, out: &mut [f32]) -> bool { /// Transformed copy of the source: rotation (degrees) and scale about the centre, optional mirror. /// `Err` when cancelled. -fn variant(src: Src, deg: f32, scale: f32, mirror: bool, ctl: &Interrupt) -> Result { - let Src { w, h, ch, .. } = src; +/// Dimensions of the [`variant`] copy: the same bounding-box maths without building its pixels. +fn variant_dims(w: usize, h: usize, deg: f32, scale: f32) -> (usize, usize) { let (s, c) = (-deg.to_radians()).sin_cos(); - // Output size: the rotated, scaled bounding box. let (fw, fh) = (w as f32 * scale, h as f32 * scale); let ow = ((fw * c.abs() + fh * s.abs()).round() as usize).max(1); let oh = ((fw * s.abs() + fh * c.abs()).round() as usize).max(1); + (ow, oh) +} + +fn variant(src: Src, deg: f32, scale: f32, mirror: bool, ctl: &Interrupt) -> Result { + let Src { w, h, ch, .. } = src; + // Output size: the rotated, scaled bounding box. + let (ow, oh) = variant_dims(w, h, deg, scale); + let (s, c) = (-deg.to_radians()).sin_cos(); let (icx, icy) = ((w as f32 - 1.0) / 2.0, (h as f32 - 1.0) / 2.0); let (ocx, ocy) = ((ow as f32 - 1.0) / 2.0, (oh as f32 - 1.0) / 2.0); let mut out = Variant { w: ow, h: oh, img: vec![0.0; ow * oh * ch], ok: vec![false; ow * oh] }; @@ -243,17 +265,27 @@ fn fill_cow( let gap = 2 * params.patch_radius + 2; let ok: Vec = par::map(w * h, true, |i| !hole[i] && source[i]); let src = Src { w, h, ch, img: &img, ok: &ok }; - // Variants: the identity (which holds the hole) first, then the adaptations. - let mut extra: Vec = Vec::new(); + // Variants: the identity (which holds the hole) first, then the adaptations. Their pixels are + // expensive, so the mosaic budget is settled on their dimensions before any variant is built; + // when the variants would not fit, the image itself is completed without them. + let mut specs: Vec<(f32, f32, bool)> = Vec::new(); if opts.mirror { - extra.push(variant(src, 0.0, 1.0, true, ctl)?); + specs.push((0.0, 1.0, true)); } for ° in &opts.rotations { - extra.push(variant(src, deg, 1.0, false, ctl)?); + specs.push((deg, 1.0, false)); } if opts.scale { - extra.push(variant(src, 0.0, 0.8, false, ctl)?); - extra.push(variant(src, 0.0, 1.25, false, ctl)?); + specs.push((0.0, 0.8, false)); + specs.push((0.0, 1.25, false)); + } + let dims: Vec<(usize, usize)> = specs.iter().map(|&(deg, scale, _)| variant_dims(w, h, deg, scale)).collect(); + let size = if specs.is_empty() { None } else { mosaic_size(w, h, gap, &dims) }; + let mut extra: Vec = Vec::new(); + if size.is_some() { + for &(deg, scale, mirror) in &specs { + extra.push(variant(src, deg, scale, mirror, ctl)?); + } } let role = |s: usize| { if hole[s] { @@ -265,14 +297,9 @@ fn fill_cow( } }; // Mosaic: identity at the left, variants in a column to its right, gaps that are no image. - // Without variants it is the image itself. - let (mw, mh, mosaic, roles): (usize, usize, Option>, Vec) = if extra.is_empty() { - (w, h, None, par::map(w * h, true, role)) - } else { - let col_w = extra.iter().map(|v| v.w).max().unwrap_or(0); - let col_h: usize = extra.iter().map(|v| v.h + gap).sum(); - let mw = w + gap + col_w; - let mh = h.max(col_h); + // Without variants it is the image itself; so it is when the variants would not fit + // (`size` above is then None and `extra` is empty). + let (mw, mh, mosaic, roles): (usize, usize, Option>, Vec) = if let Some((mw, mh)) = size { let mut mimg = vec![0.0f32; mw * mh * ch]; let mut roles = vec![Px::Void; mw * mh]; for y in 0..h { @@ -297,6 +324,8 @@ fn fill_cow( oy += v.h + gap; } (mw, mh, Some(mimg), roles) + } else { + (w, h, None, par::map(w * h, true, role)) }; drop((extra, ok)); ctl.check()?; @@ -695,6 +724,20 @@ pub fn default_brush_size(hole: Rect) -> f64 { #[cfg(test)] mod tests { + use super::mosaic_size; + + fn blank(w: usize, h: usize) -> (usize, usize) { + (w, h) + } + + #[test] + fn a_thin_image_with_turned_variants_does_not_get_a_quadratic_mosaic() { + // 40000×1 plus its 90° copy (1×40000): about 1.6 billion mosaic cells for 80000 pixels. + assert_eq!(mosaic_size(40_000, 1, 8, &[blank(1, 40_000)]), None); + assert_eq!(mosaic_size(usize::MAX, 1, 8, &[blank(1, 1)]), None); + assert_eq!(mosaic_size(100, 100, 8, &[blank(100, 100)]), Some((208, 108))); + } + use super::*; /// Vertical stripes (period 6) in grey, with a hole in the middle. diff --git a/crates/algo/src/inpaint.rs b/crates/algo/src/inpaint.rs index 2dd40198d8..87ed2a3576 100644 --- a/crates/algo/src/inpaint.rs +++ b/crates/algo/src/inpaint.rs @@ -319,6 +319,10 @@ fn peel_affordable(w: usize, px: &[Px], hb: Bounds, sources: usize) -> bool { /// Bounds of the hole pixels of a role grid. fn hole_bounds(w: usize, h: usize, px: &[Px]) -> Option { + // A zero-width grid holds no pixels whatever its height; don't size a row table by it. + if w == 0 || h == 0 { + return None; + } // Per row the first and last hole column; the union of the rows' spans. let rows: Vec> = par::map(h, w * h >= PAR_MIN, |y| { let row = px.get(y * w..(y + 1) * w)?; @@ -2047,6 +2051,14 @@ mod tests { assert!((a.gain.0..=a.gain.1).contains(&g) && b.abs() <= a.bias + 1e-6); } + /// A zero-width image has no pixels however tall it is claimed to be: no per-row table. + #[test] + fn zero_width_images_complete_without_sizing_by_height() { + let p = CompleteParams::default(); + assert_eq!(complete(0, 1 << 40, 3, &[], &[], &p), Some(Vec::new())); + assert_eq!(complete(1 << 40, 0, 3, &[], &[], &p), Some(Vec::new())); + } + /// Smooth gradient plus deterministic fine noise. fn texture(w: usize, h: usize) -> Vec { let mut rng = Rng::new(7); diff --git a/crates/algo/src/lib.rs b/crates/algo/src/lib.rs index 35c195f399..3336a7690b 100644 --- a/crates/algo/src/lib.rs +++ b/crates/algo/src/lib.rs @@ -560,7 +560,7 @@ pub enum FilterParams { impl FilterParams { /// Pixels read outside an output tile. pub fn halo(&self) -> Halo { - let g = |sigma: f32| Halo::Radius((sigma.max(0.0) * 3.0).ceil() as i32 + 1); + let g = |sigma: f32| Halo::Radius(((sigma.max(0.0) * 3.0).ceil() as i32).saturating_add(1)); match self { FilterParams::GaussianBlur { radius } | FilterParams::HighPass { radius } => g(*radius), FilterParams::UnsharpMask { radius, .. } => Halo::Radius(sharpen::exact::halo(*radius)), @@ -570,9 +570,9 @@ impl FilterParams { | FilterParams::Median { radius } | FilterParams::DustAndScratches { radius, .. } | FilterParams::Minimum { radius, .. } - | FilterParams::Maximum { radius, .. } => Halo::Radius(radius.max(0.0).ceil() as i32 + 1), + | FilterParams::Maximum { radius, .. } => Halo::Radius((radius.max(0.0).ceil() as i32).saturating_add(1)), FilterParams::MotionBlur { distance, .. } => Halo::Radius((distance.abs() / 2.0).ceil() as i32 + 2), - FilterParams::Mosaic { cell_size } => Halo::Radius(cell_size.max(1.0).ceil() as i32 + 1), + FilterParams::Mosaic { cell_size } => Halo::Radius((cell_size.max(1.0).ceil() as i32).saturating_add(1)), FilterParams::Emboss { height, .. } => Halo::Radius(height.abs().ceil() as i32 + 2), FilterParams::FindEdges => Halo::Radius(1), FilterParams::AddNoise { .. } | FilterParams::Solarize | FilterParams::Invert | FilterParams::Desaturate => Halo::Radius(0), @@ -697,7 +697,7 @@ impl FilterParams { /// Halos of the second filter batch. fn halo_ext(p: &FilterParams) -> Halo { - let r = |v: f32| Halo::Radius(v.max(0.0).ceil() as i32 + 1); + let r = |v: f32| Halo::Radius((v.max(0.0).ceil() as i32).saturating_add(1)); match p { FilterParams::ColorHalftone { max_radius, .. } => r(max_radius * 3.0), FilterParams::Crystallize { cell_size, .. } => r(cell_size.max(1.0) * 3.0), @@ -718,8 +718,9 @@ fn halo_ext(p: &FilterParams) -> Halo { FilterParams::Relight { intensity, .. } if *intensity == 0.0 => Halo::Radius(0), FilterParams::Relight { .. } => Halo::Radius(relight::halo_radius_max()), FilterParams::ReduceNoise { .. } => r(denoise::reach()), - FilterParams::SmartBlur { radius, .. } => r(*radius), - FilterParams::LensBlur { radius, .. } => r(*radius), + // The kernels clamp the radius to 100 px: size the source window by what they use. + FilterParams::SmartBlur { radius, .. } => r(radius.min(100.0)), + FilterParams::LensBlur { radius, .. } => r(radius.min(100.0)), FilterParams::ShapeBlur { radius, .. } => r(shape_blur::radius(*radius)), FilterParams::TiltShift { blur, .. } => r(gallery::reach(*blur)), FilterParams::IrisBlur { pins } => r(gallery::reach(pins.iter().map(|p| p.blur).fold(0.0, f32::max))), @@ -998,10 +999,18 @@ pub fn apply_in_with( } /// Results do not depend on the tiling, so wide-halo filters use bigger tiles to keep the -/// re-read margin (and its cost) below ~2× the tile area. +/// re-read margin (and its cost) below ~2× the tile area. The radius is input-derived, so the +/// halo arithmetic is done in `i64` and the result capped: an `i32::MAX` radius must not +/// overflow in debug builds, and must not produce a tile bigger than the hard cap. fn auto_tile(params: &FilterParams, bounds: Rect) -> i32 { match params.halo_for(bounds) { - Halo::Radius(r) => TILE.max((2 * r + 63) / 64 * 64).min(2048), + // The radius is input-derived: widen to i64 before the arithmetic and cap the + // conversion, so an i32::MAX radius cannot overflow (debug panic) and cannot make a + // tile bigger than the hard cap below (131_072 = 2048 * 64, one tile row). + Halo::Radius(r) => { + let halo = ((2 * i64::from(r) + 63) / 64 * 64).min(131_072); + TILE.max(halo as i32).min(2048) + } Halo::Bounds => TILE, } } @@ -1095,14 +1104,16 @@ fn apply_tiled_with_impl( return None; } let halo = params.halo_for(bounds); - if matches!(params, FilterParams::UnsharpMask { .. }) { - let Halo::Radius(reach) = halo else { return None }; + // Every windowed filter: a Filter Gallery stack sums its effects' reach, and a radius is + // caller-supplied, so a short parameter set can ask for a huge source window per tile. + if let Halo::Radius(reach) = halo { let (w, h) = (area.width().min(tile as u32) as usize, area.height().min(tile as u32) as usize); let read_samples = w.checked_add(2 * reach as usize)?.checked_mul(h.checked_add(2 * reach as usize)?)?.checked_mul(fmt.channels())?; let tile_count = u64::from(area.width()).div_ceil(tile as u64).checked_mul(u64::from(area.height()).div_ceil(tile as u64))?; // Reject hostile source-window sizes before Image::read allocates them. // Ordinary filter parameters and automatic tiles fit these bounds. - if read_samples > 512 * 1024 * 1024 || read_samples.checked_mul(std::mem::size_of::())? > isize::MAX as usize || tile_count > 1_048_576 { + let too_many_tiles = matches!(params, FilterParams::UnsharpMask { .. }) && tile_count > 1_048_576; + if read_samples > 512 * 1024 * 1024 || read_samples.checked_mul(std::mem::size_of::())? > isize::MAX as usize || too_many_tiles { return None; } } diff --git a/crates/algo/src/liquify.rs b/crates/algo/src/liquify.rs index ee678d1aa9..9ebb3115c9 100644 --- a/crates/algo/src/liquify.rs +++ b/crates/algo/src/liquify.rs @@ -209,11 +209,20 @@ pub fn auto_cell(bounds: Rect) -> f64 { if bounds.size().area() <= 4_200_000 { 2.0 } else { 4.0 } } +/// Most nodes of a field's displacement grid (12 bytes each with the freeze mask): 192 MB. +const MAX_FIELD_NODES: usize = 1 << 24; + impl LiquifyField { pub fn new(bounds: Rect, cell: f64) -> Self { - let cell = cell.clamp(1.0, 64.0); - let w = (f64::from(bounds.width()) / cell).ceil() as usize + 2; - let h = (f64::from(bounds.height()) / cell).ceil() as usize + 2; + let mut cell = cell.clamp(1.0, 64.0); + let nodes = |cell: f64| ((f64::from(bounds.width()) / cell).ceil() as usize + 2, (f64::from(bounds.height()) / cell).ceil() as usize + 2); + let (mut w, mut h) = nodes(cell); + // The extent is the document canvas, which a file declares: past the node budget the + // field gets coarser (it is interpolated anyway) instead of asking for gigabytes. + while w.saturating_mul(h) > MAX_FIELD_NODES { + cell *= 2.0; + (w, h) = nodes(cell); + } LiquifyField { bounds, cell, w, h, d: vec![[0.0; 2]; w * h], freeze: vec![0.0; w * h], cursor: None } } @@ -1122,6 +1131,15 @@ mod tests { assert!(dd < 1e-3, "slow and fast pointer fields differ by {dd} px"); } + #[test] + fn a_huge_canvas_gets_a_coarser_field_not_a_huge_one() { + // 400000² px at 2 px per node is 4·10¹⁰ nodes. + let f = LiquifyField::new(Rect::new(0, 0, 400_000, 400_000), 2.0); + assert!(f.w * f.h <= MAX_FIELD_NODES && f.d.len() == f.w * f.h && f.freeze.len() == f.d.len()); + assert!(f.cell > 64.0); + assert_eq!(LiquifyField::new(Rect::new(0, 0, 100, 100), 2.0).cell, 2.0); + } + #[test] fn a_stroke_interrupted_by_a_jump_restarts_its_spacing() { // A segment that does not start where the last one ended begins a new run of dabs there. diff --git a/crates/algo/src/resample.rs b/crates/algo/src/resample.rs index bd728e379d..4ad84a10c0 100644 --- a/crates/algo/src/resample.rs +++ b/crates/algo/src/resample.rs @@ -159,8 +159,37 @@ fn cover(o: i32, scale: f64) -> (i32, i32) { (a, ((o + 1) as f64 / scale + 1e-9).floor().max(a as f64 + 1.0) as i32) } -/// Unclamped weights of output sample `o` over source samples `lo..lo + len`. -fn weights(o: i32, scale: f64, kernel: Kernel, edge: Option<(i32, i32)>) -> (i32, Vec) { +/// The source samples worth a weight each: the edge, or the content with one sample of slack on +/// each side to carry the weight of everything beyond it (read as the default pixel). +fn keep_range(edge: Option<(i32, i32)>, content: (i32, i32)) -> (i64, i64) { + let (a, b) = edge.unwrap_or((content.0.saturating_sub(1), content.1.saturating_add(1))); + (i64::from(a), i64::from(b.max(a))) +} + +/// Adds `v` spread evenly over the source samples `[a, b)` to `w`, which holds the samples +/// from `lo`: samples beyond either end of it add to that end slot instead of needing a slot. +fn spread(w: &mut [f64], lo: i64, a: i64, b: i64, v: f64) { + let Some(last) = w.len().checked_sub(1) else { return }; + let each = v / (b - a).max(1) as f64; + let end = lo + w.len() as i64; + let (ia, ib) = (a.max(lo), b.min(end)); + if ia < ib { + for slot in w.get_mut((ia - lo) as usize..(ib - lo) as usize).into_iter().flatten() { + *slot += each; + } + } + if a < lo { + w[0] += each * (b.min(lo) - a) as f64; + } + if b > end { + w[last] += each * (b - a.max(end)) as f64; + } +} + +/// Weights of output sample `o` over source samples `lo..lo + len`, unclamped except that +/// reductions fold what lies beyond the edge (or the `content`) onto its ends, so a tiny scale +/// doesn't cost a weight per source sample it covers. +fn weights(o: i32, scale: f64, kernel: Kernel, edge: Option<(i32, i32)>, content: (i32, i32)) -> (i32, Vec) { match kernel { Kernel::Nearest => { let u = (o as f64 + 0.5) / scale - 0.5; @@ -168,7 +197,11 @@ fn weights(o: i32, scale: f64, kernel: Kernel, edge: Option<(i32, i32)>) -> (i32 } Kernel::Bilinear if scale < 0.5 => { let (a, b) = cover(o, scale); - (a, vec![1.0 / (b - a) as f64; (b - a) as usize]) + let (ka, kb) = keep_range(edge, content); + let (lo, hi) = (i64::from(a).clamp(ka, kb), (i64::from(b) - 1).clamp(ka, kb)); + let mut w = vec![0.0; (hi - lo + 1) as usize]; + spread(&mut w, lo, i64::from(a), i64::from(b), 1.0); + (lo as i32, w) } Kernel::Bilinear => { let u = position(o, scale, edge); @@ -179,18 +212,18 @@ fn weights(o: i32, scale: f64, kernel: Kernel, edge: Option<(i32, i32)>) -> (i32 // Averaged down to four times the target first (pixels of `inner`), then filtered. let inner = 4.0 * scale; let inner_edge = edge.map(|(a, b)| ((a as f64 * inner - 1e-9).ceil() as i32, ((b + 1) as f64 * inner - 1e-9).ceil() as i32 - 1)); - let (k0, kw) = weights(o, 0.25, Kernel::Keys { a, stretch }, inner_edge); + let (k0, kw) = weights(o, 0.25, Kernel::Keys { a, stretch }, inner_edge, (i32::MIN / 4, i32::MAX / 4)); let clamp = |k: i32| inner_edge.map_or(k, |(a, b)| k.clamp(a, b.max(a))); let boxes: Vec<(i32, i32)> = (k0..).zip(&kw).map(|(k, _)| cover(clamp(k), inner)).collect(); - let lo = boxes.iter().map(|b| b.0).min().unwrap_or(0); - let hi = boxes.iter().map(|b| b.1).max().unwrap_or(lo); - let mut w = vec![0.0; (hi - lo).max(0) as usize]; + let (ka, kb) = keep_range(edge, content); + let lo = i64::from(boxes.iter().map(|b| b.0).min().unwrap_or(0)); + let hi = i64::from(boxes.iter().map(|b| b.1).max().unwrap_or(lo as i32)); + let (klo, khi) = (lo.clamp(ka, kb), (hi - 1).clamp(ka, kb)); + let mut w = vec![0.0; (khi - klo + 1) as usize]; for ((a, b), v) in boxes.into_iter().zip(kw) { - for slot in w.get_mut((a - lo) as usize..(b - lo) as usize).into_iter().flatten() { - *slot += v / (b - a) as f64; - } + spread(&mut w, klo, i64::from(a), i64::from(b), v); } - (lo, w) + (klo as i32, w) } Kernel::Keys { a, stretch } => { let u = position(o, scale, edge); @@ -225,7 +258,7 @@ fn taps(dst0: i32, dst1: i32, scale: f64, kernel: Kernel, edge: Option<(i32, i32 let kernel = if scale == 1.0 { Kernel::Nearest } else { kernel }; (dst0..dst1) .map(|o| { - let (lo, w) = weights(o, scale, kernel, edge); + let (lo, w) = weights(o, scale, kernel, edge, content); let hi = lo + w.len() as i32 - 1; // Fold the weights of taps beyond the edge onto the edge sample. let start = clamp(lo); @@ -724,6 +757,22 @@ mod tests { assert!((p[0] - 0.5).abs() < 0.05, "{p:?}"); } + /// A reduction by a tiny scale covers millions of source samples per output sample; only the + /// ones in the content get a weight, the rest fold onto its ends or into `outside`. + #[test] + fn tiny_scales_do_not_get_a_weight_per_covered_sample() { + for kernel in [Kernel::Bilinear, Kernel::Keys { a: -0.5, stretch: 1.0 }] { + for edge in [None, Some((0, 99))] { + let t = taps(0, 3, 1e-6, kernel, edge, (0, 99)); + for t in &t { + assert!(t.weights.len() <= 100, "{}", t.weights.len()); + let total = t.weights.iter().sum::() + t.outside; + assert!((total - 1.0).abs() < 1e-3, "{total}"); + } + } + } + } + #[test] fn transparent_edges_do_not_darken() { let mut s = Surface::new(PixelFormat::RGBA32F); diff --git a/crates/algo/src/tests.rs b/crates/algo/src/tests.rs index b7fc92113c..6d7e879346 100644 --- a/crates/algo/src/tests.rs +++ b/crates/algo/src/tests.rs @@ -640,3 +640,12 @@ fn mix_selection_reads_the_selection_once_per_tile() { assert!(a.iter().zip(&b).all(|(x, y)| x.to_bits() == y.to_bits()), "alpha {alpha}: results differ"); } } + +#[test] +fn blur_halos_follow_the_clamped_radius_and_never_overflow() { + let smart = |radius| FilterParams::SmartBlur { radius, threshold: 25.0, quality: BlurQuality::default(), mode: SmartBlurMode::default() }; + assert_eq!(smart(1e9).halo(), Halo::Radius(101)); + assert_eq!(smart(f32::INFINITY).halo(), Halo::Radius(101)); + let gaussian = FilterParams::GaussianBlur { radius: f32::INFINITY }; + assert_eq!(gaussian.halo(), Halo::Radius(i32::MAX)); +} diff --git a/crates/algo/src/warp.rs b/crates/algo/src/warp.rs index dd20daf6c9..87c6bde2c3 100644 --- a/crates/algo/src/warp.rs +++ b/crates/algo/src/warp.rs @@ -164,6 +164,11 @@ pub fn warp_triangles(src: &Surface, src_rect: Rect, verts: &[([f64; 2], [f64; 2 /// [`warp_triangles`], reporting progress per destination tile to `ctl`. A cancelled `ctl` skips /// the remaining tiles; the caller discards the result. +/// Destination tiles one triangle render may keep bookkeeping for (a 1M-tile render is 69 Gpx). +const MAX_DST_TILES: u64 = 1 << 20; +/// Source pixels one destination tile may read densely. +const MAX_FOOTPRINT_PIXELS: u64 = 1 << 25; + fn triangles(src: &Surface, src_rect: Rect, verts: &[([f64; 2], [f64; 2])], tris: &[[usize; 3]], interp: Interp, ctl: &Interrupt) -> Surface { let mut fmt = src.format(); let converted; @@ -190,6 +195,12 @@ fn triangles(src: &Surface, src_rect: Rect, verts: &[([f64; 2], [f64; 2])], tris y1 = y1.max(v.0[1]); } let dst = Rect::new(x0.max(-lim).floor() as i32 - 1, y0.max(-lim).floor() as i32 - 1, x1.min(lim).ceil() as i32 + 1, y1.min(lim).ceil() as i32 + 1); + // Bookkeeping is kept per destination tile, so a mesh flung across the whole coordinate range + // would ask for millions of entries before drawing anything: draw nothing instead. + let ts_px = photocraft_geom::TILE_SIZE as u64; + if u64::from(dst.width()).div_ceil(ts_px).saturating_add(1).saturating_mul(u64::from(dst.height()).div_ceil(ts_px).saturating_add(1)) > MAX_DST_TILES { + return out; + } // Triangles: (vertex indices), binned per destination tile. let tiles: Vec = dst.tiles().map(|tc| tc.rect().intersect(&dst)).filter(|r| !r.is_empty()).collect(); let mut bins: Vec> = vec![Vec::new(); tiles.len()]; @@ -304,7 +315,11 @@ fn triangles(src: &Surface, src_rect: Rect, verts: &[([f64; 2], [f64; 2])], tris return None; } let foot = Rect::new(fx0.floor() as i32 - 3, fy0.floor() as i32 - 3, fx1.ceil() as i32 + 3, fy1.ceil() as i32 + 3).intersect(&src_rect); - if foot.is_empty() || !src.has_tiles_in(foot) { + // Sparse components far apart in the source can land in one tile: the dense window + // between them is not read (the tile stays empty). The pixel-area cap is checked first: + // `has_tiles_in` walks the source tile grid, so an oversized footprint must return + // without paying for that scan. + if foot.is_empty() || foot.size().area() > MAX_FOOTPRINT_PIXELS || !src.has_tiles_in(foot) { return None; } let mut px = src.read_region(foot); @@ -510,6 +525,18 @@ mod tests { surf } + #[test] + fn a_mesh_flung_across_the_coordinate_range_gets_no_per_tile_bookkeeping() { + // About 8000 × 8000 destination tiles from three vertices. + let src = sample(SampleType::U8); + let far = 1_000_000.0; + let verts = [([-far, -far], [0.0, 0.0]), ([far, -far], [40.0, 0.0]), ([0.0, far], [0.0, 24.0])]; + let t = std::time::Instant::now(); + let out = warp_triangles(&src, Rect::new(0, 0, 40, 24), &verts, &[[0, 1, 2]], Interp::Nearest); + assert!(out.content_bounds().is_empty()); + assert!(t.elapsed().as_secs() < 5); + } + #[test] fn a_thin_layer_stretched_into_a_curve_keeps_the_curve() { // #5793: a 3 px wide column stretched 100x and bent into an arch. The grid was sized in