mirror of
https://github.com/storytold/cadcraft.git
synced 2026-10-27 20:36:48 -06:00
Merge pull request #2331 from Silvertree2010/perf/export-speed
Speed up PNG export: stroke runs of hairline polylines as one path
This commit is contained in:
@@ -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<String> = 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<Duration> = 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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -78,6 +78,8 @@ pub fn render(list: &DisplayList, view: &View, o: &RasterOptions) -> Option<Pixm
|
||||
let mut pm = Pixmap::new(view.width, view.height)?;
|
||||
pm.fill(tiny_skia::Color::from_rgba8(o.background.0, o.background.1, o.background.2, 255));
|
||||
let vis = view.world_bounds();
|
||||
let mut hairlines = Hairlines::default();
|
||||
let mut screen: Vec<(f32, f32)> = 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<Pixm
|
||||
paint.set_color_rgba8(c.0, c.1, c.2, p.alpha());
|
||||
paint.anti_alias = o.antialias;
|
||||
let pts = list.points(p);
|
||||
if p.kind != Kind::Polyline {
|
||||
hairlines.flush(&mut pm, o.antialias);
|
||||
}
|
||||
match p.kind {
|
||||
Kind::Polyline => {
|
||||
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<Pixm
|
||||
}
|
||||
}
|
||||
}
|
||||
hairlines.flush(&mut pm, o.antialias);
|
||||
Some(pm)
|
||||
}
|
||||
|
||||
/// Whether tiny-skia strokes a line `width` pixels wide (untransformed) as a hairline: zero
|
||||
/// width, or at most one pixel when anti-aliased.
|
||||
fn is_hairline(width: f32, antialias: bool) -> 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<Vec<u8>> {
|
||||
render(list, view, o)?.encode_png().ok()
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user