snapshots.rs
1 use kurbo::{Affine, BezPath, ParamCurve as _, Point, Rect, Vec2}; 2 use libtest_mimic::{Arguments, Failed, Trial}; 3 use linesweeper::{ 4 sweep::{SweepLineBuffers, SweepLineRange, SweepLineRangeBuffers, Sweeper}, 5 topology::Topology, 6 Segment, Segments, 7 }; 8 use std::{ 9 collections::HashMap, 10 path::{Path, PathBuf}, 11 }; 12 use tiny_skia::Pixmap; 13 14 fn main() { 15 let args = Arguments::from_args(); 16 let mut tests = sweep_snapshot_diffs(); 17 tests.extend(position_snapshot_diffs()); 18 19 libtest_mimic::run(&args, tests).exit(); 20 } 21 22 fn path_color(idx: usize) -> tiny_skia::Color { 23 let palette = [ 24 tiny_skia::Color::from_rgba8(0x00, 0x5F, 0x73, 0xFF), 25 tiny_skia::Color::from_rgba8(0x94, 0xD2, 0xBD, 0xFF), 26 tiny_skia::Color::from_rgba8(0xE9, 0xD8, 0xA6, 0xFF), 27 tiny_skia::Color::from_rgba8(0xEE, 0x9B, 0x00, 0xFF), 28 tiny_skia::Color::from_rgba8(0xCA, 0x67, 0x02, 0xFF), 29 tiny_skia::Color::from_rgba8(0xBB, 0x3E, 0x03, 0xFF), 30 tiny_skia::Color::from_rgba8(0xAE, 0x20, 0x12, 0xFF), 31 ]; 32 palette[idx % palette.len()] 33 } 34 35 fn sweep_line_color() -> tiny_skia::Color { 36 tiny_skia::Color::from_rgba8(0x9B, 0x22, 0x26, 0xFF) 37 } 38 39 fn sweep_snapshot_diffs() -> Vec<Trial> { 40 let ws = std::env::var("CARGO_MANIFEST_DIR").unwrap(); 41 let paths = glob::glob(&format!("{ws}/tests/snapshots/inputs/sweep/**/*.svg")).unwrap(); 42 paths 43 .into_iter() 44 .map(|p| { 45 let p = p.unwrap(); 46 let name = input_path_base(&p).display().to_string(); 47 Trial::test(name, || generate_sweep_snapshot(p)) 48 }) 49 .collect() 50 } 51 52 fn position_snapshot_diffs() -> Vec<Trial> { 53 let ws = std::env::var("CARGO_MANIFEST_DIR").unwrap(); 54 let paths = glob::glob(&format!("{ws}/tests/snapshots/inputs/position/**/*.svg")).unwrap(); 55 56 paths 57 .into_iter() 58 .map(|p| { 59 let p = p.unwrap(); 60 let name = input_path_base(&p).display().to_string(); 61 Trial::test(name, || generate_position_snapshot(p)) 62 }) 63 .collect() 64 } 65 66 fn input_path_base(input_path: &Path) -> &Path { 67 let ws = std::env::var("CARGO_MANIFEST_DIR").unwrap(); 68 let base = format!("{ws}/tests/snapshots/inputs"); 69 input_path.strip_prefix(base).unwrap() 70 } 71 72 fn output_path_for(input_path: &Path) -> PathBuf { 73 let mut ws: PathBuf = std::env::var_os("CARGO_MANIFEST_DIR").unwrap().into(); 74 ws.push("target/snapshots/snapshots"); 75 ws.push(input_path); 76 ws.set_extension("png"); 77 ws 78 } 79 80 fn saved_snapshot_path_for(input_path: &Path) -> PathBuf { 81 let mut ws: PathBuf = std::env::var_os("CARGO_MANIFEST_DIR").unwrap().into(); 82 ws.push("tests/snapshots/snapshots"); 83 ws.push(input_path); 84 ws.set_extension("png"); 85 ws 86 } 87 88 fn skia_path(elts: impl IntoIterator<Item = kurbo::PathEl>) -> tiny_skia::Path { 89 let mut pb = tiny_skia::PathBuilder::new(); 90 for elt in elts { 91 match elt { 92 kurbo::PathEl::MoveTo(p) => pb.move_to(p.x as f32, p.y as f32), 93 kurbo::PathEl::LineTo(p) => pb.line_to(p.x as f32, p.y as f32), 94 kurbo::PathEl::QuadTo(p0, p1) => { 95 pb.quad_to(p0.x as f32, p0.y as f32, p1.x as f32, p1.y as f32) 96 } 97 kurbo::PathEl::CurveTo(p0, p1, p2) => pb.cubic_to( 98 p0.x as f32, 99 p0.y as f32, 100 p1.x as f32, 101 p1.y as f32, 102 p2.x as f32, 103 p2.y as f32, 104 ), 105 kurbo::PathEl::ClosePath => pb.close(), 106 } 107 } 108 pb.finish().unwrap() 109 } 110 111 fn skia_kurbo_seg(seg: kurbo::PathSeg) -> tiny_skia::Path { 112 let mut pb = tiny_skia::PathBuilder::new(); 113 let start = seg.start(); 114 pb.move_to(start.x as f32, start.y as f32); 115 match seg { 116 kurbo::PathSeg::Line(ell) => pb.line_to(ell.p1.x as f32, ell.p1.y as f32), 117 kurbo::PathSeg::Quad(q) => { 118 pb.quad_to(q.p1.x as f32, q.p1.y as f32, q.p2.x as f32, q.p2.y as f32) 119 } 120 kurbo::PathSeg::Cubic(c) => pb.cubic_to( 121 c.p1.x as f32, 122 c.p1.y as f32, 123 c.p2.x as f32, 124 c.p2.y as f32, 125 c.p3.x as f32, 126 c.p3.y as f32, 127 ), 128 } 129 pb.finish().unwrap() 130 } 131 132 fn skia_cubic(s: &kurbo::CubicBez) -> tiny_skia::Path { 133 let mut pb = tiny_skia::PathBuilder::new(); 134 pb.move_to(s.p0.x as f32, s.p0.y as f32); 135 pb.cubic_to( 136 s.p1.x as f32, 137 s.p1.y as f32, 138 s.p2.x as f32, 139 s.p2.y as f32, 140 s.p3.x as f32, 141 s.p3.y as f32, 142 ); 143 pb.finish().unwrap() 144 } 145 146 fn skia_segment(s: &Segment) -> tiny_skia::Path { 147 skia_cubic(&s.to_kurbo_cubic()) 148 } 149 150 fn line(p: impl Into<Point>, q: impl Into<Point>) -> tiny_skia::Path { 151 let mut pb = tiny_skia::PathBuilder::new(); 152 let p = p.into(); 153 pb.move_to(p.x as f32, p.y as f32); 154 let q = q.into(); 155 pb.line_to(q.x as f32, q.y as f32); 156 pb.finish().unwrap() 157 } 158 159 fn two_lines(p: impl Into<Point>, q: impl Into<Point>, r: impl Into<Point>) -> tiny_skia::Path { 160 let mut pb = tiny_skia::PathBuilder::new(); 161 let p = p.into(); 162 pb.move_to(p.x as f32, p.y as f32); 163 let q = q.into(); 164 pb.line_to(q.x as f32, q.y as f32); 165 let r = r.into(); 166 pb.line_to(r.x as f32, r.y as f32); 167 pb.finish().unwrap() 168 } 169 170 fn draw_orig_path(pixmap: &mut Pixmap, path: &BezPath, offset: kurbo::Point) { 171 let p = skia_path(path); 172 let mut paint = tiny_skia::Paint::default(); 173 paint.set_color_rgba8(0, 0, 0, 255); 174 let stroke = tiny_skia::Stroke { 175 width: 1.0, 176 ..Default::default() 177 }; 178 let transform = tiny_skia::Transform::from_translate(offset.x as f32, offset.y as f32); 179 180 pixmap.stroke_path(&p, &paint, &stroke, transform, None); 181 } 182 183 fn adjust_x_positions(orig: &[f64], padding: f64, min_x: f64, max_x: f64) -> Vec<f64> { 184 if orig.is_empty() { 185 return Vec::new(); 186 } 187 188 let mut padded = Vec::with_capacity(orig.len()); 189 let mut max_so_far = f64::NEG_INFINITY; 190 for &x in orig { 191 let x = x.max(max_so_far + padding); 192 padded.push(x); 193 max_so_far = x; 194 } 195 196 let orig_first = *orig.first().unwrap(); 197 let orig_last = *orig.last().unwrap(); 198 let padded_min = *padded.first().unwrap(); 199 let padded_max = *padded.last().unwrap(); 200 201 let mut mid_shift = (orig_first + orig_last - padded_min - padded_max) / 2.0; 202 mid_shift = mid_shift.clamp(min_x - padded_min, max_x - padded_max); 203 if padded_max - padded_min > max_x - min_x { 204 mid_shift = (max_x + min_x - padded_max - padded_min) / 2.0; 205 } 206 for x in &mut padded { 207 *x += mid_shift; 208 } 209 padded 210 } 211 212 fn color(c: tiny_skia::Color) -> tiny_skia::Paint<'static> { 213 let mut p = tiny_skia::Paint::default(); 214 p.set_color(c); 215 p 216 } 217 218 fn draw_sweep_line_range( 219 pixmap: &mut Pixmap, 220 segments: &Segments, 221 range: SweepLineRange<'_, '_, '_>, 222 bbox: kurbo::Rect, 223 padding: f64, 224 ) { 225 let y = range.line().y(); 226 let mut paint = tiny_skia::Paint::default(); 227 paint.set_color(sweep_line_color()); 228 let stroke = tiny_skia::Stroke { 229 width: 2.0, 230 ..Default::default() 231 }; 232 let thick_stroke = tiny_skia::Stroke { 233 width: 4.0, 234 ..Default::default() 235 }; 236 let s_line = line( 237 (bbox.min_x() - padding, bbox.min_y() + y), 238 (bbox.max_x() + padding, bbox.min_y() + y), 239 ); 240 241 pixmap.stroke_path( 242 &s_line, 243 &paint, 244 &stroke, 245 tiny_skia::Transform::identity(), 246 None, 247 ); 248 249 let old_segs: Vec<_> = range.old_segment_range().collect(); 250 let new_segs: Vec<_> = range.segment_range().collect(); 251 let really_new_segs = new_segs.iter().filter(|s| !old_segs.contains(s)); 252 let seg_color: HashMap<_, _> = old_segs 253 .iter() 254 .chain(really_new_segs.clone()) 255 .enumerate() 256 .map(|(color_idx, seg_idx)| (seg_idx, color_idx)) 257 .collect(); 258 let origin = bbox.origin(); 259 260 for seg_idx in old_segs.iter().chain(really_new_segs) { 261 let color_idx = seg_color[seg_idx]; 262 let seg = &segments[*seg_idx]; 263 let p = skia_segment(seg); 264 let mut paint = tiny_skia::Paint::default(); 265 paint.set_color(path_color(color_idx)); 266 let transform = tiny_skia::Transform::from_translate(origin.x as f32, origin.y as f32); 267 pixmap.stroke_path(&p, &paint, &stroke, transform, None); 268 } 269 270 let old_seg_positions: Vec<_> = old_segs.iter().map(|s| segments[*s].at_y(y)).collect(); 271 let padded_old_seg_positions: Vec<_> = adjust_x_positions( 272 &old_seg_positions, 273 padding / 2.0, 274 bbox.min_x() - padding, 275 bbox.max_x() + padding, 276 ); 277 278 for ((&px, &x), seg_idx) in padded_old_seg_positions 279 .iter() 280 .zip(&old_seg_positions) 281 .zip(&old_segs) 282 { 283 let p = two_lines( 284 (bbox.min_x() + px, bbox.min_y()), 285 (bbox.min_x() + px, bbox.min_y() + y - padding / 2.0), 286 (bbox.min_x() + x, bbox.min_y() + y), 287 ); 288 let color_idx = seg_color[seg_idx]; 289 let c = path_color(color_idx); 290 291 pixmap.stroke_path( 292 &p, 293 &color(c), 294 &thick_stroke, 295 tiny_skia::Transform::identity(), 296 None, 297 ); 298 } 299 let new_seg_positions: Vec<_> = new_segs.iter().map(|s| segments[*s].at_y(y)).collect(); 300 let padded_new_seg_positions: Vec<_> = adjust_x_positions( 301 &new_seg_positions, 302 padding / 2.0, 303 bbox.min_x() - padding, 304 bbox.max_x() + padding, 305 ); 306 for ((&px, &x), seg_idx) in padded_new_seg_positions 307 .iter() 308 .zip(&new_seg_positions) 309 .zip(&new_segs) 310 { 311 let p = two_lines( 312 (bbox.min_x() + x, bbox.min_y() + y), 313 (bbox.min_x() + px, bbox.min_y() + y + padding / 2.0), 314 (bbox.min_x() + px, bbox.max_y()), 315 ); 316 let color_idx = seg_color[seg_idx]; 317 let c = path_color(color_idx); 318 319 pixmap.stroke_path( 320 &p, 321 &color(c), 322 &thick_stroke, 323 tiny_skia::Transform::identity(), 324 None, 325 ); 326 } 327 } 328 329 fn generate_sweep_snapshot(path: PathBuf) -> Result<(), Failed> { 330 let input = std::fs::read_to_string(&path).unwrap(); 331 let tree = usvg::Tree::from_str(&input, &usvg::Options::default()).unwrap(); 332 let bezs = linesweeper_util::svg_to_bezpaths(&tree); 333 let bbox = linesweeper_util::bezier_bounding_box(bezs.iter()); 334 let bez: BezPath = bezs 335 .into_iter() 336 .flat_map(|p| Affine::translate(-bbox.origin().to_vec2()) * p) 337 .collect(); 338 339 let mut segments = Segments::default(); 340 segments.add_bez_path(&bez).unwrap(); 341 segments.check_invariants(); 342 343 let eps = 16.0; 344 let mut range_bufs = SweepLineRangeBuffers::default(); 345 let mut line_bufs = SweepLineBuffers::default(); 346 347 // Run through once just to count the ranges. 348 let mut sweep_state = Sweeper::new(&segments, eps); 349 let mut num_ranges = 0; 350 while let Some(mut line) = sweep_state.next_line(&mut line_bufs) { 351 while line.next_range(&mut range_bufs, &segments).is_some() { 352 num_ranges += 1; 353 } 354 } 355 356 let pad = 32.0; 357 let mut sweep_state = Sweeper::new(&segments, eps); 358 let mut pixmap = Pixmap::new( 359 (bbox.width() + 2.0 * pad).ceil() as u32, 360 ((bbox.height() + pad) * num_ranges as f64 + pad).ceil() as u32, 361 ) 362 .unwrap(); 363 364 let mut b = Rect::new(pad, pad, pad + bbox.width(), pad + bbox.height()); 365 while let Some(mut line) = sweep_state.next_line(&mut line_bufs) { 366 while let Some(range) = line.next_range(&mut range_bufs, &segments) { 367 draw_orig_path(&mut pixmap, &bez, b.origin()); 368 draw_sweep_line_range(&mut pixmap, &segments, range, b, pad); 369 370 b = b + Vec2::new(0.0, bbox.height() + pad); 371 } 372 } 373 374 let base_path = input_path_base(&path); 375 let out_path = output_path_for(base_path); 376 std::fs::create_dir_all(out_path.parent().unwrap()).unwrap(); 377 pixmap.save_png(&out_path).unwrap(); 378 379 let new_image = kompari::load_image(&out_path)?; 380 let snapshot = kompari::load_image(&saved_snapshot_path_for(base_path))?; 381 match kompari::compare_images(&snapshot, &new_image) { 382 kompari::ImageDifference::None => Ok(()), 383 _ => Err("image comparison failed".into()), 384 } 385 } 386 387 fn generate_position_snapshot(path: PathBuf) -> Result<(), Failed> { 388 let input = std::fs::read_to_string(&path).unwrap(); 389 let tree = usvg::Tree::from_str(&input, &usvg::Options::default()).unwrap(); 390 let bezs = linesweeper_util::svg_to_bezpaths(&tree); 391 let bbox = linesweeper_util::bezier_bounding_box(bezs.iter()); 392 let bez: BezPath = bezs 393 .into_iter() 394 .flat_map(|p| Affine::translate(-bbox.origin().to_vec2()) * p) 395 .collect(); 396 397 let eps = 16.0; 398 let top = Topology::from_paths_binary(&bez, &BezPath::new(), eps).unwrap(); 399 let out_paths = top.compute_positions(); 400 401 let pad = 2.0 * eps; 402 let bbox = top.bounding_box(); 403 let mut pixmap = Pixmap::new( 404 (bbox.width() + 2.0 * pad).ceil() as u32, 405 (bbox.height() + 2.0 * pad).ceil() as u32, 406 ) 407 .unwrap(); 408 let pad_transform = tiny_skia::Transform::from_translate( 409 (pad - bbox.min_x()) as f32, 410 (pad - bbox.min_y()) as f32, 411 ); 412 413 let stroke = tiny_skia::Stroke { 414 width: 1.0, 415 ..Default::default() 416 }; 417 for out_idx in top.segment_indices() { 418 let (path, far_idx) = &out_paths[out_idx]; 419 for (idx, seg) in path.segments().enumerate() { 420 let skia_seg = skia_kurbo_seg(seg); 421 422 let c = if far_idx == &Some(idx) { 423 path_color(0) 424 } else { 425 path_color(3) 426 }; 427 428 pixmap.stroke_path(&skia_seg, &color(c), &stroke, pad_transform, None); 429 430 let p0 = seg.start(); 431 let p0 = tiny_skia::PathBuilder::from_circle(p0.x as f32, p0.y as f32, 2.0).unwrap(); 432 let p1 = seg.end(); 433 let p1 = tiny_skia::PathBuilder::from_circle(p1.x as f32, p1.y as f32, 2.0).unwrap(); 434 let black = color(tiny_skia::Color::BLACK); 435 pixmap.fill_path( 436 &p0, 437 &black, 438 tiny_skia::FillRule::Winding, 439 pad_transform, 440 None, 441 ); 442 pixmap.fill_path( 443 &p1, 444 &black, 445 tiny_skia::FillRule::Winding, 446 pad_transform, 447 None, 448 ); 449 } 450 } 451 let base_path = input_path_base(&path); 452 let out_path = output_path_for(base_path); 453 std::fs::create_dir_all(out_path.parent().unwrap()).unwrap(); 454 pixmap.save_png(&out_path).unwrap(); 455 456 let new_image = kompari::load_image(&out_path)?; 457 let snapshot = kompari::load_image(&saved_snapshot_path_for(base_path))?; 458 match kompari::compare_images(&snapshot, &new_image) { 459 kompari::ImageDifference::None => Ok(()), 460 _ => Err("image comparison failed".into()), 461 } 462 }