diff --git a/crates/io/examples/export_bench.rs b/crates/io/examples/export_bench.rs new file mode 100644 index 000000000..d5ae0d436 --- /dev/null +++ b/crates/io/examples/export_bench.rs @@ -0,0 +1,79 @@ +//! Export timing on a realistic large drawing: `examples/apartment.dxf` repeated on a grid +//! (a floor plan of many apartments). +//! +//! `cargo run --release -p cadcraft-io --example export_bench -- [FORMAT] [GRID] [RUNS] [OUT_DIR]` +//! +//! FORMAT is `png`, `pdf`, `svg`, `dxf` or `all` (default `png`), GRID the apartments per side +//! (default 10), RUNS the timed runs per format (default 7). Prints the median and minimum wall +//! time of each format's export (from the drawing in memory to the file's bytes). With OUT_DIR, +//! the last run's output is written there as `bench.FORMAT` to compare builds. + +use std::time::{Duration, Instant}; + +use cadcraft_doc::{Drawing, Space}; +use cadcraft_geom::{Mat3, Vec2}; + +fn drawing(grid: usize) -> Drawing { + let src = include_bytes!("../../../examples/apartment.dxf"); + let Ok(mut d) = cadcraft_io::read(src, "apartment.dxf") else { + eprintln!("apartment.dxf did not read"); + std::process::exit(1); + }; + let ents: Vec<_> = d.space(&Space::Model).map(|s| s.iter().map(|e| (**e).clone()).collect()).unwrap_or_default(); + let ext = d.extents(&Space::Model); + let step = Vec2::new(ext.width() * 1.1, ext.height() * 1.1); + for i in 0..grid { + for j in 0..grid { + if (i, j) == (0, 0) { + continue; + } + let m = Mat3::translate(Vec2::new(step.x * j as f64, step.y * i as f64)); + for e in &ents { + let mut e = e.clone(); + e.handle = d.new_handle(); + e.kind.transform(&m); + if let Some(s) = d.space_mut(&Space::Model) { + s.push(e); + } + } + } + } + d +} + +fn main() { + let args: Vec = std::env::args().skip(1).collect(); + let format = args.first().map(String::as_str).unwrap_or("png").to_string(); + let grid: usize = args.get(1).and_then(|a| a.parse().ok()).unwrap_or(10); + let runs: usize = args.get(2).and_then(|a| a.parse().ok()).unwrap_or(7).max(1); + let out_dir = args.get(3); + let d = drawing(grid); + eprintln!("{} entities", d.entity_count()); + let formats: Vec<&str> = if format == "all" { vec!["dxf", "png", "pdf", "svg"] } else { vec![format.as_str()] }; + for f in formats { + let name = format!("bench.{f}"); + let mut times: Vec = Vec::new(); + let mut last = Vec::new(); + for _ in 0..runs { + let t = Instant::now(); + match cadcraft_io::write(&d, &name) { + Ok(bytes) => { + times.push(t.elapsed()); + last = bytes; + } + Err(e) => { + eprintln!("{f}: {e}"); + std::process::exit(1); + } + } + } + times.sort(); + let ms = |t: Duration| t.as_secs_f64() * 1000.0; + println!("{f}: median {:.1} ms, min {:.1} ms, {} runs, {} bytes", ms(times[times.len() / 2]), ms(times[0]), runs, last.len()); + if let Some(dir) = out_dir + && let Err(e) = std::fs::write(std::path::Path::new(dir).join(&name), &last) + { + eprintln!("{name}: {e}"); + } + } +} diff --git a/crates/render/src/raster.rs b/crates/render/src/raster.rs index 379c083e6..72ac821d0 100644 --- a/crates/render/src/raster.rs +++ b/crates/render/src/raster.rs @@ -78,6 +78,8 @@ pub fn render(list: &DisplayList, view: &View, o: &RasterOptions) -> Option = Vec::new(); for p in &list.prims { let c = p.display_rgb(o.background); let mut paint = Paint::default(); @@ -85,23 +87,32 @@ pub fn render(list: &DisplayList, view: &View, o: &RasterOptions) -> Option { let bb = Bounds2::from_points(pts.iter().copied()); if !bb.intersects(&vis) { continue; } + let w = if o.px_per_mm > 0.0 && p.lw > 0.0 { (p.lw * o.px_per_mm).max(o.hairline) } else { o.hairline }; + screen.clear(); + screen.extend(pts.iter().map(|q| view.to_screen(*q))); + if is_hairline(w, o.antialias) && screen.len() >= 2 && screen.iter().all(|&(x, y)| batchable(x) && batchable(y)) { + hairlines.add(&mut pm, [c.0, c.1, c.2, p.alpha()], w, &screen, o.antialias); + continue; + } + hairlines.flush(&mut pm, o.antialias); let mut pb = PathBuilder::new(); - for (i, q) in pts.iter().enumerate() { - let (x, y) = view.to_screen(*q); + for (i, (x, y)) in screen.iter().enumerate() { if i == 0 { - pb.move_to(x, y); + pb.move_to(*x, *y); } else { - pb.line_to(x, y); + pb.line_to(*x, *y); } } if let Some(path) = pb.finish() { - let w = if o.px_per_mm > 0.0 && p.lw > 0.0 { (p.lw * o.px_per_mm).max(o.hairline) } else { o.hairline }; let stroke = Stroke { width: w, line_cap: LineCap::Round, line_join: LineJoin::Round, ..Default::default() }; pm.stroke_path(&path, &paint, &stroke, Transform::identity(), None); } @@ -152,9 +163,65 @@ pub fn render(list: &DisplayList, view: &View, o: &RasterOptions) -> Option bool { + width == 0.0 || (antialias && width <= 1.0) +} + +/// Largest screen coordinate (pixels) of a polyline stroked together with others. Beyond it a +/// path's integer bounds could overflow, so such a polyline is stroked on its own as before. +const MAX_BATCH_COORD: f32 = 1.0e8; + +fn batchable(v: f32) -> bool { + v.is_finite() && v.abs() <= MAX_BATCH_COORD +} + +/// Consecutive hairline polylines with the same colour, alpha and width, stroked as one path. +/// +/// tiny-skia draws a hairline segment by segment straight onto the pixmap (each segment with +/// its own caps, a polyline's first and last segment extended for round caps), so one path of +/// several polylines paints exactly the pixels, in the same order, as stroking them one by one; +/// it saves building a paint pipeline and a path per polyline. Thicker strokes are filled +/// outlines, where overlaps would cover once instead of twice, and are never batched. +#[derive(Default)] +struct Hairlines { + key: Option<([u8; 4], f32)>, + pb: PathBuilder, +} + +impl Hairlines { + fn add(&mut self, pm: &mut Pixmap, rgba: [u8; 4], width: f32, pts: &[(f32, f32)], antialias: bool) { + if self.key.is_some_and(|k| k != (rgba, width)) { + self.flush(pm, antialias); + } + self.key = Some((rgba, width)); + for (i, (x, y)) in pts.iter().enumerate() { + if i == 0 { + self.pb.move_to(*x, *y); + } else { + self.pb.line_to(*x, *y); + } + } + } + + fn flush(&mut self, pm: &mut Pixmap, antialias: bool) { + let Some(([r, g, b, a], width)) = self.key.take() else { return }; + let pb = std::mem::take(&mut self.pb); + let Some(path) = pb.finish() else { return }; + let mut paint = Paint::default(); + paint.set_color_rgba8(r, g, b, a); + paint.anti_alias = antialias; + let stroke = Stroke { width, line_cap: LineCap::Round, line_join: LineJoin::Round, ..Default::default() }; + pm.stroke_path(&path, &paint, &stroke, Transform::identity(), None); + self.pb = path.clear(); + } +} + /// Render and encode as PNG. pub fn render_png(list: &DisplayList, view: &View, o: &RasterOptions) -> Option> { render(list, view, o)?.encode_png().ok() diff --git a/crates/render/src/tests.rs b/crates/render/src/tests.rs index fc4b99156..ae35f27f9 100644 --- a/crates/render/src/tests.rs +++ b/crates/render/src/tests.rs @@ -999,3 +999,126 @@ fn wide_polyline_centre_line_is_drawn_on_screen_only() { assert_eq!(lines(&see_through), 0, "{lt}: a transparent band gets no hairline"); } } + +/// Each primitive stroked or filled on its own, as `render` did before it batched hairlines. +fn render_one_by_one(list: &DisplayList, view: &View, o: &RasterOptions) -> tiny_skia::Pixmap { + use tiny_skia::{FillRule, LineCap, LineJoin, Paint, PathBuilder, Pixmap, Stroke, Transform}; + let mut pm = Pixmap::new(view.width, view.height).unwrap(); + pm.fill(tiny_skia::Color::from_rgba8(o.background.0, o.background.1, o.background.2, 255)); + for p in &list.prims { + let c = p.display_rgb(o.background); + let mut paint = Paint::default(); + paint.set_color_rgba8(c.0, c.1, c.2, p.alpha()); + paint.anti_alias = o.antialias; + let mut pb = PathBuilder::new(); + match p.kind { + Kind::Polyline => { + for (i, q) in list.points(p).iter().enumerate() { + let (x, y) = view.to_screen(*q); + if i == 0 { + pb.move_to(x, y); + } else { + pb.line_to(x, y); + } + } + if let Some(path) = pb.finish() { + let w = if o.px_per_mm > 0.0 && p.lw > 0.0 { (p.lw * o.px_per_mm).max(o.hairline) } else { o.hairline }; + let stroke = Stroke { width: w, line_cap: LineCap::Round, line_join: LineJoin::Round, ..Default::default() }; + pm.stroke_path(&path, &paint, &stroke, Transform::identity(), None); + } + } + Kind::Tris => { + for t in list.points(p).chunks(3) { + let [a, b, c] = t else { continue }; + let ((ax, ay), (bx, by), (cx, cy)) = (view.to_screen(*a), view.to_screen(*b), view.to_screen(*c)); + pb.move_to(ax, ay); + pb.line_to(bx, by); + pb.line_to(cx, cy); + pb.close(); + } + if let Some(path) = pb.finish() { + pm.fill_path(&path, &paint, FillRule::Winding, Transform::identity(), None); + } + } + _ => panic!("not drawn by this reference"), + } + } + pm +} + +fn push_prim(l: &mut DisplayList, kind: Kind, color: Rgb, transparency: u8, lw: f32, pts: &[Vec2]) { + let arr = if kind == Kind::Tris { &mut l.tris } else { &mut l.verts }; + let start = arr.len() as u32; + arr.extend_from_slice(pts); + for p in pts.iter().filter(|p| p.is_finite() && p.x.abs() < 1e6) { + l.bounds.add(*p); + } + l.prims.push(DPrim { handle: VIEWPORT_CONTENT, color, aci7: false, lw, transparency, kind, start, len: pts.len() as u32 }); +} + +/// Hairline polylines with the same paint are stroked as one path; the pixels must be exactly +/// those of stroking each on its own, whatever breaks a run (colour, transparency, lineweight, +/// fills, unusable coordinates) and with overlaps, caps and dots. +#[test] +fn raster_hairline_batches_match_one_by_one() { + let red = Rgb(255, 0, 0); + let blue = Rgb(0, 0, 255); + let mut l = DisplayList::default(); + // A hatch-like run of crossing, overlapping and touching lines in one colour. + for i in 0..40 { + let x = f64::from(i) * 0.37; + push_prim(&mut l, Kind::Polyline, red, 0, 0.0, &[Vec2::new(x, 0.0), Vec2::new(x + 6.0, 9.0)]); + push_prim(&mut l, Kind::Polyline, red, 0, 0.0, &[Vec2::new(x + 3.0, 0.0), Vec2::new(x - 2.0, 9.0), Vec2::new(x + 1.0, 4.0)]); + } + // Zero-length (a dot), single-point (nothing), and repeated lines. + push_prim(&mut l, Kind::Polyline, red, 0, 0.0, &[Vec2::new(2.0, 2.0), Vec2::new(2.0, 2.0)]); + push_prim(&mut l, Kind::Polyline, red, 0, 0.0, &[Vec2::new(3.0, 3.0)]); + push_prim(&mut l, Kind::Polyline, red, 0, 0.0, &[Vec2::new(1.0, 1.0), Vec2::new(8.0, 2.0)]); + push_prim(&mut l, Kind::Polyline, red, 0, 0.0, &[Vec2::new(1.0, 1.0), Vec2::new(8.0, 2.0)]); + // Transparent lines build up where they overlap, within a run and across runs. + for i in 0..10 { + let y = f64::from(i) * 0.2; + push_prim(&mut l, Kind::Polyline, blue, 50, 0.0, &[Vec2::new(0.0, y), Vec2::new(12.0, 8.0 - y)]); + } + push_prim(&mut l, Kind::Polyline, blue, 20, 0.0, &[Vec2::new(0.0, 5.0), Vec2::new(12.0, 5.0)]); + // A lineweight (a filled outline when lineweights are shown), a fill, then the run again. + push_prim(&mut l, Kind::Polyline, red, 0, 0.5, &[Vec2::new(0.0, 6.0), Vec2::new(12.0, 7.0), Vec2::new(5.0, 2.0)]); + push_prim(&mut l, Kind::Tris, blue, 30, 0.0, &[Vec2::new(4.0, 4.0), Vec2::new(9.0, 4.0), Vec2::new(6.0, 8.0)]); + push_prim(&mut l, Kind::Polyline, red, 0, 0.0, &[Vec2::new(0.0, 8.0), Vec2::new(12.0, 1.0)]); + // Unusable or far-out coordinates are stroked on their own, as before. + push_prim(&mut l, Kind::Polyline, red, 0, 0.0, &[Vec2::new(1.0, 1.0), Vec2::new(f64::NAN, 3.0), Vec2::new(9.0, 9.0)]); + push_prim(&mut l, Kind::Polyline, red, 0, 0.0, &[Vec2::new(-1e12, 4.0), Vec2::new(1e12, 4.5)]); + push_prim(&mut l, Kind::Polyline, red, 0, 0.0, &[Vec2::new(5.0, -1e7), Vec2::new(5.5, 1e7)]); + push_prim(&mut l, Kind::Polyline, red, 0, 0.0, &[Vec2::new(0.0, 0.5), Vec2::new(12.0, 0.5)]); + // Partly and wholly off screen. + push_prim(&mut l, Kind::Polyline, red, 0, 0.0, &[Vec2::new(-30.0, -30.0), Vec2::new(40.0, 30.0)]); + push_prim(&mut l, Kind::Polyline, red, 0, 0.0, &[Vec2::new(100.0, 100.0), Vec2::new(120.0, 101.0)]); + let view = View::fit(&l.bounds, 160, 120, 0.05); + let bg = Rgb(33, 40, 48); + for o in [ + RasterOptions::default(), + RasterOptions { antialias: false, ..Default::default() }, + RasterOptions { hairline: 0.5, ..Default::default() }, + RasterOptions { hairline: 0.0, antialias: false, ..Default::default() }, + RasterOptions { px_per_mm: 4.0, background: Rgb(255, 255, 255), ..Default::default() }, + RasterOptions { hairline: 1.5, background: bg, ..Default::default() }, + ] { + let fast = render(&l, &view, &o).unwrap(); + let reference = render_one_by_one(&l, &view, &o); + assert!(fast.data() == reference.data(), "pixels differ with {o:?}"); + } +} + +/// A real hatch pattern (many parallel lines in one colour) renders exactly as stroked line by +/// line. +#[test] +fn raster_hatch_pattern_matches_one_by_one() { + let mut d = sample(); + d.add(&Space::Model, Common::default(), EntityKind::Hatch(pattern_hatch("ANSI31", 0.2, (0.0, 0.0, 10.0, 10.0)))).unwrap(); + let l = build(&d, &Space::Model, &Options::default()); + assert!(l.prims.len() > 20); + let view = View::fit(&l.bounds, 300, 200, 0.05); + for o in [RasterOptions::default(), RasterOptions { background: Rgb(255, 255, 255), ..Default::default() }] { + assert!(render(&l, &view, &o).unwrap().data() == render_one_by_one(&l, &view, &o).data()); + } +}