//! A small scanline rasteriser and image filters for the quality harness: test inputs are drawn //! from ground-truth vector shapes here, never loaded from files. use kurbo::{BezPath, PathEl, Point}; use vectorcraft_trace::{Raster, TracedGradient}; /// The closed polylines of `path` (one per subpath), flattened within `tol`. pub fn polylines(path: &BezPath, tol: f64) -> Vec> { let (mut out, mut cur) = (vec![], vec![]); kurbo::flatten(path.iter(), tol, |el| match el { PathEl::MoveTo(p) => { if cur.len() > 1 { out.push(std::mem::take(&mut cur)); } cur.clear(); cur.push(p); } PathEl::LineTo(p) => cur.push(p), _ => {} }); if cur.len() > 1 { out.push(cur); } out } /// Adds the non-zero-winding coverage (0–1 per pixel) of `path` to `cov` (`w × h`); returns the /// rows touched. Five sub-scanlines per row, exact along x. pub fn coverage_into(path: &BezPath, w: usize, h: usize, cov: &mut [f32]) -> (usize, usize) { const SS: usize = 5; let mut edges = vec![]; for p in polylines(path, 0.02) { for i in 0..p.len() { let (a, b) = (p[i], p[(i + 1) % p.len()]); if a.y < b.y { edges.push((a, b, 1)); } else if b.y < a.y { edges.push((b, a, -1)); } } } let (lo, hi) = edges.iter().fold((f64::INFINITY, f64::NEG_INFINITY), |(lo, hi), e| (lo.min(e.0.y), hi.max(e.1.y))); if edges.is_empty() { return (0, 0); } let (r0, r1) = (lo.floor().clamp(0.0, h as f64) as usize, hi.ceil().clamp(0.0, h as f64) as usize); let mut xs: Vec<(f64, i32)> = vec![]; for row in r0..r1 { for k in 0..SS { let y = row as f64 + (k as f64 + 0.5) / SS as f64; xs.clear(); xs.extend(edges.iter().filter(|(a, b, _)| a.y <= y && y < b.y).map(|(a, b, d)| (a.x + (y - a.y) * (b.x - a.x) / (b.y - a.y), *d))); xs.sort_by(|p, q| p.0.total_cmp(&q.0)); let (mut wind, mut start) = (0, 0.0); for &(x, d) in &xs { let before = wind; wind += d; if before == 0 { start = x; } else if wind == 0 { let (x0, x1) = (start.clamp(0.0, w as f64), x.clamp(0.0, w as f64)); for px in x0.floor() as usize..(x1.ceil() as usize).min(w) { cov[row * w + px] += ((x1.min(px as f64 + 1.0) - x0.max(px as f64)).max(0.0) / SS as f64) as f32; } } } } } (r0, r1) } /// The coverage of `path` alone. pub fn coverage(path: &BezPath, w: usize, h: usize) -> Vec { let mut cov = vec![0.0; w * h]; coverage_into(path, w, h, &mut cov); cov } /// Paints `scene` (bottom first) over `bg`, anti-aliased, the way a renderer shows a trace. pub fn render(scene: &[(BezPath, [u8; 3])], bg: [u8; 3], w: usize, h: usize) -> Raster { let scene: Vec<(BezPath, [u8; 3], Option<&TracedGradient>)> = scene.iter().map(|(p, c)| (p.clone(), *c, None)).collect(); render_painted(&scene, bg, w, h) } /// [`render`], with the paths that have a gradient filled with it. pub fn render_painted(scene: &[(BezPath, [u8; 3], Option<&TracedGradient>)], bg: [u8; 3], w: usize, h: usize) -> Raster { let mut buf = vec![bg.map(f32::from); w * h]; let mut cov = vec![0.0f32; w * h]; for (path, color, gradient) in scene { let (r0, r1) = coverage_into(path, w, h, &mut cov); for i in r0 * w..r1 * w { let a = cov[i].min(1.0); if a > 0.0 { let color = match gradient { Some(g) => g.color_at(Point::new((i % w) as f64 + 0.5, (i / w) as f64 + 0.5)).map(|v| v as f32), None => color.map(f32::from), }; for ch in 0..3 { buf[i][ch] += (color[ch] - buf[i][ch]) * a; } cov[i] = 0.0; } } } from_rgb(w, h, &buf) } /// Draws ground-truth art: each pixel is the average of `K × K` hard samples of the top-most /// shape, so abutting shapes blend with each other on their shared edge and never with the /// background behind them (which painting them one after another would let through). pub fn render_truth(scene: &[(BezPath, [u8; 3])], bg: [u8; 3], w: usize, h: usize, k: usize) -> Raster { let (bw, bh) = (w * k, h * k); let mut top = vec![bg; bw * bh]; let mut cov = vec![0.0f32; bw * bh]; for (path, color) in scene { let mut big = path.clone(); big.apply_affine(kurbo::Affine::scale(k as f64)); let (r0, r1) = coverage_into(&big, bw, bh, &mut cov); for i in r0 * bw..r1 * bw { if cov[i] >= 0.5 { top[i] = *color; } cov[i] = 0.0; } } let mut buf = vec![[0.0f32; 3]; w * h]; for y in 0..bh { for x in 0..bw { for ch in 0..3 { buf[(y / k) * w + x / k][ch] += f32::from(top[y * bw + x][ch]) / (k * k) as f32; } } } from_rgb(w, h, &buf) } fn from_rgb(w: usize, h: usize, buf: &[[f32; 3]]) -> Raster { let mut rgba = Vec::with_capacity(w * h * 4); for p in buf { rgba.extend(p.map(|v| v.round().clamp(0.0, 255.0) as u8)); rgba.push(255); } Raster::new(w as u32, h as u32, rgba).unwrap() } /// A deterministic hash of a pixel position and a salt, as a value in `-1.0..1.0`. pub fn noise(x: u32, y: u32, salt: u32) -> f64 { let mut v = x.wrapping_mul(0x9E37_79B1) ^ y.wrapping_mul(0x85EB_CA77) ^ salt.wrapping_mul(0xC2B2_AE3D); v ^= v >> 15; v = v.wrapping_mul(0x2C1B_3C6D); v ^= v >> 12; v = v.wrapping_mul(0x297A_2D39); v ^= v >> 15; f64::from(v) / f64::from(u32::MAX) * 2.0 - 1.0 } fn map(img: &Raster, f: impl Fn(u32, u32, [f64; 3]) -> [f64; 3]) -> Raster { Raster::from_fn(img.width, img.height, |x, y| { let p = img.pixel(x, y); let c = f(x, y, [f64::from(p[0]), f64::from(p[1]), f64::from(p[2])]); [c[0].round().clamp(0.0, 255.0) as u8, c[1].round().clamp(0.0, 255.0) as u8, c[2].round().clamp(0.0, 255.0) as u8, 255] }) } /// A slight blur (the 1-4-6-4-1 binomial kernel each way, about one pixel of sigma): a resized or /// re-sampled image. pub fn blur(img: &Raster) -> Raster { const K: [f64; 5] = [1.0, 4.0, 6.0, 4.0, 1.0]; let (w, h) = (img.width as i64, img.height as i64); let pass = |src: &Raster, dx: i64, dy: i64| { map(src, |x, y, _| { let mut s = [0.0; 3]; for (k, wt) in K.iter().enumerate() { let o = k as i64 - 2; let p = src.pixel((i64::from(x) + o * dx).clamp(0, w - 1) as u32, (i64::from(y) + o * dy).clamp(0, h - 1) as u32); for ch in 0..3 { s[ch] += f64::from(p[ch]) * wt / 16.0; } } s }) }; pass(&pass(img, 1, 0), 0, 1) } /// Compression-like noise: an offset per 8 × 8 block plus ringing-sized noise per pixel. pub fn noisy(img: &Raster) -> Raster { map(img, |x, y, c| { let block = 3.0 * noise(x / 8, y / 8, 7); [0, 1, 2].map(|ch| c[ch] + block + 5.0 * noise(x, y, 11 + ch as u32)) }) } /// The colour a scan gives a clean colour, before shading, blur and grain: ink on cream paper. pub fn scan_color(c: [u8; 3]) -> [u8; 3] { const PAPER: [f64; 3] = [236.0, 230.0, 214.0]; const INK: [f64; 3] = [34.0, 32.0, 44.0]; [0, 1, 2].map(|ch| (INK[ch] + (PAPER[ch] - INK[ch]) * f64::from(c[ch]) / 255.0).round() as u8) } /// Scanned ink on paper: the colours of [`scan_color`], uneven lighting, a slight blur and grain. pub fn scanned(img: &Raster) -> Raster { let (w, h) = (f64::from(img.width), f64::from(img.height)); let toned = map(img, |x, y, c| { let s = scan_color(c.map(|v| v as u8)); let shade = 1.0 - 0.07 * (f64::from(x) / w) - 0.05 * ((f64::from(y) / h) * 3.0).sin().abs(); s.map(|v| f64::from(v) * shade) }); map(&blur(&toned), |x, y, c| { let g = 4.0 * noise(x, y, 23); c.map(|v| v + g) }) }