diff --git a/crates/svg/src/raster.rs b/crates/svg/src/raster.rs index b1b9d93b..502626e8 100644 --- a/crates/svg/src/raster.rs +++ b/crates/svg/src/raster.rs @@ -8,7 +8,7 @@ use rayon::prelude::*; use crate::{BlendMode, Doc, FillRule, Gradient, GradientKind, Group, Node, Paint, Shape, Spread}; -fn mat3(a: Affine) -> Mat3 { +pub(crate) fn mat3(a: Affine) -> Mat3 { let c = a.as_coeffs(); Mat3([[c[0], c[2], c[4]], [c[1], c[3], c[5]], [0.0, 0.0, 1.0]]) } @@ -395,14 +395,15 @@ fn draw_shape(s: &Shape, m: Affine, opacity: f64, dst: &mut Image) { let m = m * s.transform; let path = s.geom.to_path(); let bbox = path.bounding_box(); - let (w, h) = (dst.width, dst.height); if let Some(fill) = &s.fill { let rule = match s.fill_rule { FillRule::NonZero => effectcraft_path::FillRule::NonZero, FillRule::EvenOdd => effectcraft_path::FillRule::EvenOdd, }; - let cov = effectcraft_path::fill_coverage(std::slice::from_ref(&path), &mat3(m), w, h, rule); - paint(dst, &cov, fill, (opacity * s.fill_opacity) as f32, m, bbox); + if let Some(r) = device_rect(&path, m, dst) { + let cov = effectcraft_path::fill_coverage(std::slice::from_ref(&path), &mat3(r.local(m)), r.w, r.h, rule); + paint(dst, &cov, r, fill, (opacity * s.fill_opacity) as f32, m, bbox); + } } if let Some(st) = &s.stroke { let style = effectcraft_path::StrokeStyle { @@ -422,15 +423,53 @@ fn draw_shape(s: &Shape, m: Affine, opacity: f64, dst: &mut Image) { taper: None, wave: None, }; - let cov = effectcraft_path::stroke_coverage(std::slice::from_ref(&path), &style, &mat3(m), w, h); - paint(dst, &cov, &st.paint, (opacity * st.opacity) as f32, m, bbox); + // The stroke's outline (what `stroke_coverage` fills), bounded like a fill. + if let Some(outline) = effectcraft_path::stroke_outline(std::slice::from_ref(&path), &style, mat3(m).mean_scale()) + && let Some(r) = device_rect(&outline, m, dst) + { + let cov = effectcraft_path::fill_coverage(std::slice::from_ref(&outline), &mat3(r.local(m)), r.w, r.h, effectcraft_path::FillRule::NonZero); + paint(dst, &cov, r, &st.paint, (opacity * st.opacity) as f32, m, bbox); + } } } -/// Composite paint through coverage. -fn paint(dst: &mut Image, cov: &Mask, p: &Paint, opacity: f32, m: Affine, bbox: kurbo::Rect) { +/// The pixels of a canvas a shape can touch: its device-space bounding box (plus a pixel of +/// anti-aliasing) clipped to the canvas. Rasterising and painting only there keeps a shape's +/// cost proportional to its own size, not the canvas'. +#[derive(Clone, Copy, Debug)] +pub(crate) struct PixRect { + pub(crate) x: u32, + pub(crate) y: u32, + pub(crate) w: u32, + pub(crate) h: u32, +} + +impl PixRect { + /// `m` (shape → canvas) re-based so the rectangle's corner is the origin. + fn local(self, m: Affine) -> Affine { + Affine::translate((-(self.x as f64), -(self.y as f64))) * m + } +} + +pub(crate) fn device_rect(path: &kurbo::BezPath, m: Affine, dst: &Image) -> Option { + let b = (m * path.clone()).bounding_box(); + let (cw, ch) = (dst.width as f64, dst.height as f64); + if !(b.x0.is_finite() && b.y0.is_finite() && b.x1.is_finite() && b.y1.is_finite()) { + // Unknown extent: the whole canvas, as before. + return (dst.width > 0 && dst.height > 0).then_some(PixRect { x: 0, y: 0, w: dst.width, h: dst.height }); + } + let x0 = (b.x0.floor() - 1.0).clamp(0.0, cw); + let y0 = (b.y0.floor() - 1.0).clamp(0.0, ch); + let x1 = (b.x1.ceil() + 1.0).clamp(0.0, cw); + let y1 = (b.y1.ceil() + 1.0).clamp(0.0, ch); + (x1 > x0 && y1 > y0).then_some(PixRect { x: x0 as u32, y: y0 as u32, w: (x1 - x0) as u32, h: (y1 - y0) as u32 }) +} + +/// Composite paint through coverage `cov` of the canvas rectangle `r`. +fn paint(dst: &mut Image, cov: &Mask, r: PixRect, p: &Paint, opacity: f32, m: Affine, bbox: kurbo::Rect) { let w = dst.width as usize; - if w == 0 { + let (rx, ry, rw, rh) = (r.x as usize, r.y as usize, r.w as usize, r.h as usize); + if w == 0 || rw == 0 || cov.data.len() < rw * rh { return; } let shader = match p { @@ -444,13 +483,14 @@ fn paint(dst: &mut Image, cov: &Mask, p: &Paint, opacity: f32, m: Affine, bbox: Shader::Gradient(g, to_grad) } }; - dst.data.par_chunks_mut(w).enumerate().for_each(|(y, row)| { - for (x, d) in row.iter_mut().enumerate() { - let c = cov.data[y * w + x]; + dst.data.par_chunks_mut(w).enumerate().skip(ry).take(rh).for_each(|(y, row)| { + let Some(crow) = cov.data.get((y - ry) * rw..(y - ry + 1) * rw) else { return }; + let Some(drow) = row.get_mut(rx..rx + rw) else { return }; + for (i, (d, &c)) in drow.iter_mut().zip(crow).enumerate() { if c <= 0.0 { continue; } - let col = shader.at(x as f64 + 0.5, y as f64 + 0.5); + let col = shader.at((rx + i) as f64 + 0.5, y as f64 + 0.5); let a = col[3] * c * opacity; if a <= 0.0 { continue; diff --git a/crates/svg/src/tests.rs b/crates/svg/src/tests.rs index a7a9bef3..577828ef 100644 --- a/crates/svg/src/tests.rs +++ b/crates/svg/src/tests.rs @@ -123,3 +123,108 @@ fn deep_nesting_errors_instead_of_overflowing_the_stack() { assert_eq!(crate::xml_depth(&s), 2); assert!(crate::parse(s.as_bytes()).is_ok()); } + +/// Many small shapes over a large canvas: fills (both rules), strokes (miter, round, square +/// caps, dashes), linear and radial gradients in user and bounding-box units, rotated and +/// skewed shapes, shapes partly or wholly off the canvas and a clipped group. +fn stress_svg() -> String { + let mut s = String::from( + "\ + \ + \ + ", + ); + for i in 0..60 { + let (x, y) = (((i * 53) % 700) as f64 - 30.0, ((i * 37) % 460) as f64 - 30.0); + let fill = ["#3a7", "url(#lg)", "url(#rg)", "none"][i % 4]; + let stroke = ["none", "#222", "#c0c", "url(#lg)"][(i / 2) % 4]; + let extra = match i % 5 { + 0 => " stroke-linejoin=\"miter\" stroke-miterlimit=\"10\"".to_string(), + 1 => " stroke-linecap=\"round\" stroke-linejoin=\"round\"".to_string(), + 2 => " stroke-linecap=\"square\" stroke-dasharray=\"6 3\"".to_string(), + 3 => format!(" transform=\"rotate({} {x} {y})\"", i * 17), + _ => format!(" transform=\"translate({x} {y}) skewX(20) scale(1.5 0.7)\" fill-rule=\"evenodd\""), + }; + let w = 8 + (i * 7) % 50; + s += &format!( + "", + x + 2.0, + y + 2.0, + 1 + i % 7 + ); + } + s += ""; + s +} + +fn fnv(img: &effectcraft_raster::Image) -> u64 { + let mut h = 0xcbf29ce484222325u64; + for p in &img.data { + for c in p { + for b in c.to_bits().to_le_bytes() { + h = (h ^ b as u64).wrapping_mul(0x100000001b3); + } + } + } + h +} + +/// Rasterising each shape only within its own device bounds (it covered the whole canvas for +/// every shape: O(shapes × canvas), minutes per frame for an Illustrator file of thousands of +/// small shapes, SceneYard "Retro Desktop Movie Popups") keeps the output stable. Against the +/// whole-canvas version the pixels differ only on a few anti-aliased edges (float rounding of +/// the re-based transform: basic 26 of 20 000 pixels by at most 0.17, stress 5 of 256 000). +#[test] +fn rasterising_within_shape_bounds_is_stable() { + let stress = stress_svg(); + let cases: [(&str, &[u8], u32, u32, f64, u64); 4] = [ + ("basic", BASIC, 200, 100, 1.0, PINNED[0]), + ("paths", PATHS, 0, 0, 1.0, PINNED[1]), + ("stress", stress.as_bytes(), 640, 400, 1.0, PINNED[2]), + ("stress-2.5x", stress.as_bytes(), 1600, 1000, 2.5, PINNED[3]), + ]; + for (name, bytes, w, h, scale, pinned) in cases { + let d = parse(bytes).unwrap(); + let (w, h) = if w == 0 { d.pixel_size() } else { (w, h) }; + let img = rasterize(&d, w, h, scale); + let got = fnv(&img); + if std::env::var_os("SVG_RASTER_PRINT").is_some() { + println!("{name}: 0x{got:016x}"); + continue; + } + assert!(img.data.iter().any(|p| p[3] > 0.0), "{name} draws something"); + assert_eq!(got, pinned, "{name} pixels changed"); + } +} + +const PINNED: [u64; 4] = [0xcc3e2c3d49c37fbf, 0xd99451e0745d7c28, 0x8c6ad41b7c4042cc, 0x1f2d56138fa0bc95]; + +/// Everything a shape's fill or stroke covers on the whole canvas lies inside the rectangle it is +/// rasterised in: bounding nothing away. +#[test] +fn shape_rectangles_hold_all_coverage() { + let d = parse(stress_svg().as_bytes()).unwrap(); + let (w, h) = (640u32, 400u32); + let dst = effectcraft_raster::Image::new(w, h); + let mut checked = 0; + for (m, _, s) in d.flatten() { + let m = m * s.transform; + let path = s.geom.to_path(); + let style = effectcraft_path::StrokeStyle { width: s.stroke.as_ref().map_or(0.0, |st| st.width), miter: 10.0, ..Default::default() }; + let outline = effectcraft_path::stroke_outline(std::slice::from_ref(&path), &style, crate::raster::mat3(m).mean_scale()); + for p in [Some(path.clone()), outline].into_iter().flatten() { + let full = effectcraft_path::fill_coverage(std::slice::from_ref(&p), &crate::raster::mat3(m), w, h, effectcraft_path::FillRule::NonZero); + let r = crate::raster::device_rect(&p, m, &dst); + for y in 0..h { + for x in 0..w { + if full.data[(y * w + x) as usize] > 0.0 { + let r = r.unwrap_or_else(|| panic!("covered pixel ({x},{y}) but no rectangle")); + assert!(x >= r.x && x < r.x + r.w && y >= r.y && y < r.y + r.h, "({x},{y}) outside {r:?}"); + checked += 1; + } + } + } + } + } + assert!(checked > 10_000, "{checked}"); +}