From 7652439b4007af0cabc9dffbe58d5e5037666e20 Mon Sep 17 00:00:00 2001 From: arhammusheer Date: Sun, 11 Oct 2026 18:34:50 +0530 Subject: [PATCH] #1172: Pathfinder Outline 19x, SVG export 1.5x and open 1.2x faster, same output MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit At 50,000 paths unless noted, against main: - Pathfinder Outline (300 paths): 145 → 7.5 ms. Each segment was tested against every junction of the arrangement, and each chain against every chain kept so far. Now a segment tests only the junctions inside its control box (junctions are sorted by x; a point within reach of a curve is within reach of that box), with the same nearest-point test. Coincident chains are found through a grid on their midpoints, taking the first match in the same order a scan does. - export SVG: 267 → 176 ms. Path data is written straight into one string (`push_num`, the same digits and trimming as `fmt_num`) instead of a string per number, point and segment. - open .vectorcraft: 239 → 195 ms. The JSON was parsed once only to find where it ends: - A file with no NUL has no data after it. - Otherwise the marker (which holds the first NUL, as JSON can't) is taken when it directly follows the JSON's last byte, as the writer puts it. - Anything else takes the old parse. `perf --deep` prints output hashes of an SVG export and a save → open → save round trip; main and this branch print the same ones. pathfinder_pinned (Outline included) passes, and so do the pathops, format and svg suites (345 tests). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VmMmc1J8XeNLxhRihWv9cc --- apps/vectorcraft-cli/src/perf.rs | 7 +++++ crates/format/src/lib.rs | 13 +++++++++ crates/pathops/src/pathfinder.rs | 47 +++++++++++++++++++++++++------- crates/svg/src/export.rs | 20 ++++++++++---- crates/svg/src/lib.rs | 22 +++++++++++++-- 5 files changed, 91 insertions(+), 18 deletions(-) diff --git a/apps/vectorcraft-cli/src/perf.rs b/apps/vectorcraft-cli/src/perf.rs index afafd6db..9652d4f7 100644 --- a/apps/vectorcraft-cli/src/perf.rs +++ b/apps/vectorcraft-cli/src/perf.rs @@ -207,6 +207,13 @@ fn deep(n: usize) -> Result<(), String> { }), )); note("after typing"); + // Output hashes, to compare builds: the SVG export and a save → open → save round trip. + let fnv = |b: &[u8]| b.iter().fold(0xcbf2_9ce4_8422_2325u64, |h, &x| (h ^ u64::from(x)).wrapping_mul(0x100_0000_01b3)); + let doc = synthetic(n)?; + let svg = vectorcraft_svg::export(&doc, &vectorcraft_svg::ExportOptions::default()); + let saved = vectorcraft_format::save_file(&doc); + let reopened = vectorcraft_format::load(&saved).map_err(|e| e.to_string())?; + outln!(" output hash: SVG export {:016x}, save→open→save {:016x}", fnv(svg.as_bytes()), fnv(&vectorcraft_format::save_file(&reopened))); outln!(" tracked (no budget yet)"); for (name, ms) in &rows { outln!(" {name:<52} {ms:>10.2} ms"); diff --git a/crates/format/src/lib.rs b/crates/format/src/lib.rs index 42ea6f3d..48e82f7c 100644 --- a/crates/format/src/lib.rs +++ b/crates/format/src/lib.rs @@ -145,6 +145,19 @@ fn preview_mime(bytes: &[u8]) -> &'static str { /// The JSON of a file and the data after it (v4), split at [`BLOB_MAGIC`] after the first JSON /// value. fn split_tail(text: &[u8]) -> (&[u8], &[u8]) { + // JSON holds no raw NUL, so the marker (which has one) starts at the first NUL, if the file + // has one. The writer puts it right after the JSON's last byte; found there, it is where the + // first JSON value ends, without parsing the JSON twice. Anything else takes the parse. + let Some(nul) = text.iter().position(|&b| b == 0) else { + // No NUL, so no marker: the parse below would find none either. + return (text, &[]); + }; + if let Some(at) = nul.checked_sub(1) + && text.get(at..).is_some_and(|r| r.starts_with(BLOB_MAGIC)) + && at.checked_sub(1).and_then(|k| text.get(k)).is_some_and(|b| matches!(b, b'}' | b']' | b'"')) + { + return (text.get(..at).unwrap_or(text), text.get(at + BLOB_MAGIC.len()..).unwrap_or_default()); + } let mut values = serde_json::Deserializer::from_slice(text).into_iter::(); if values.next().is_some_and(|v| v.is_ok()) { let end = values.byte_offset(); diff --git a/crates/pathops/src/pathfinder.rs b/crates/pathops/src/pathfinder.rs index 1bd65c8f..c9861266 100644 --- a/crates/pathops/src/pathfinder.rs +++ b/crates/pathops/src/pathfinder.rs @@ -358,9 +358,11 @@ impl FaceMerger { /// Pathfinder Outline: split every shape's boundary at junctions of the arrangement. fn outline_edges(arr: &Arrangement, shapes: &[Shape]) -> Vec { + // Sorted by x (then y), so a segment needs only the junctions inside its control box. let junctions = arr.junctions(); let scale = shapes.iter().filter_map(|s| s.path.control_bounds()).map(|r| r.width().max(r.height())).fold(1.0, f64::max); let tol = scale * 1e-7; + let reach = tol.max(1e-6); // (chain, key, source index) let mut chains: Vec<(Vec, u64)> = Vec::new(); for s in shapes { @@ -375,11 +377,19 @@ fn outline_edges(arr: &Arrangement, shapes: &[Shape]) -> Vec { for i in 0..sp.segment_count() { let c = sp.segment(i); let line = sp.segment_is_line(i); + // A point within `reach` of the curve is within `reach` of its control box (the + // curve stays inside its control points' hull), so the other junctions can't match. + let b = kurbo::Shape::bounding_box(&c).union(kurbo::Rect::from_points(c.p1, c.p2)).inflate(reach, reach); + let lo = junctions.partition_point(|j| j.x < b.x0); let mut ts: Vec = junctions + .get(lo..) + .unwrap_or_default() .iter() + .take_while(|j| j.x <= b.x1) + .filter(|j| j.y >= b.y0 && j.y <= b.y1) .filter_map(|&j| { let nr = c.nearest(j, 1e-9); - (nr.distance_sq.sqrt() <= tol.max(1e-6)).then_some(nr.t) + (nr.distance_sq.sqrt() <= reach).then_some(nr.t) }) .collect(); ts.sort_by(f64::total_cmp); @@ -417,24 +427,41 @@ fn outline_edges(arr: &Arrangement, shapes: &[Shape]) -> Vec { } } } - // De-duplicate coincident chains, keeping the front-most key (later shapes overwrite). + // De-duplicate coincident chains, keeping the front-most key (later shapes overwrite). A + // match has its midpoint within `near` of this one's, so only the chains in the grid cells + // around it are candidates; the first of them in `out` order is the one a scan would find. let mut out: Vec<(Vec, u64)> = Vec::new(); + let mut sigs: Vec<(Point, Point, Point)> = Vec::new(); let sig = |c: &[Seg]| -> (Point, Point, Point) { let a = c[0].c.p0; let b = c[c.len() - 1].c.p3; let mid = mid_point(c); (a, b, mid) }; + let d = reach * 10.0; + let near = |p: Point, q: Point| p.distance(q) <= d; + let cell = |p: Point| ((p.x / d).floor() as i64, (p.y / d).floor() as i64); + let mut grid: HashMap<(i64, i64), Vec> = HashMap::new(); for (chain, key) in chains { let (a, b, m) = sig(&chain); - let near = |p: Point, q: Point| p.distance(q) <= tol.max(1e-6) * 10.0; - if let Some(e) = out.iter_mut().find(|(c, _)| { - let (a2, b2, m2) = sig(c); - near(m, m2) && ((near(a, a2) && near(b, b2)) || (near(a, b2) && near(b, a2))) - }) { - e.1 = key; - } else { - out.push((chain, key)); + let (cx, cy) = cell(m); + let found = (cx - 1..=cx + 1) + .flat_map(|x| (cy - 1..=cy + 1).map(move |y| (x, y))) + .filter_map(|k| grid.get(&k)) + .flatten() + .copied() + .filter(|&i| { + let (a2, b2, m2) = sigs[i]; + near(m, m2) && ((near(a, a2) && near(b, b2)) || (near(a, b2) && near(b, a2))) + }) + .min(); + match found { + Some(i) => out[i].1 = key, + None => { + grid.entry((cx, cy)).or_default().push(out.len()); + sigs.push((a, b, m)); + out.push((chain, key)); + } } } out.into_iter() diff --git a/crates/svg/src/export.rs b/crates/svg/src/export.rs index 2605109e..50e7291a 100644 --- a/crates/svg/src/export.rs +++ b/crates/svg/src/export.rs @@ -656,9 +656,13 @@ impl Writer<'_> { fn path_d(&self, p: &PathData, m: Affine) -> String { let mut s = String::new(); - let pt = |p: Point| { + // Each point as "x y", appended in place (no string per number or per point). + let dec = self.opts.decimals; + let pt = |s: &mut String, p: Point| { let p = m * p; - format!("{} {}", self.num(p.x), self.num(p.y)) + crate::push_num(s, p.x, dec); + s.push(' '); + crate::push_num(s, p.y, dec); }; for sp in &p.subpaths { let n = sp.anchors.len(); @@ -669,16 +673,22 @@ impl Writer<'_> { s.push(' '); } s.push('M'); - s.push_str(&pt(sp.anchors[0].p)); + pt(&mut s, sp.anchors[0].p); for i in 0..sp.segment_count() { let (a, b) = (&sp.anchors[i], &sp.anchors[(i + 1) % n]); if sp.segment_is_line(i) { if sp.closed && i == n - 1 { break; } - s.push_str(&format!(" L{}", pt(b.p))); + s.push_str(" L"); + pt(&mut s, b.p); } else { - s.push_str(&format!(" C{} {} {}", pt(a.h_out), pt(b.h_in), pt(b.p))); + s.push_str(" C"); + pt(&mut s, a.h_out); + s.push(' '); + pt(&mut s, b.h_in); + s.push(' '); + pt(&mut s, b.p); } } if sp.closed { diff --git a/crates/svg/src/lib.rs b/crates/svg/src/lib.rs index 3789d8f4..7cbc8531 100644 --- a/crates/svg/src/lib.rs +++ b/crates/svg/src/lib.rs @@ -567,10 +567,26 @@ pub fn xml_escape(s: &str) -> String { /// Format a number with at most `decimals` places, trimming trailing zeros. pub fn fmt_num(v: f64, decimals: u8) -> String { + let mut s = String::new(); + push_num(&mut s, v, decimals); + s +} + +/// [`fmt_num`] appended to `out`, without allocating. +pub fn push_num(out: &mut String, v: f64, decimals: u8) { + use std::fmt::Write as _; let v = if v.is_finite() { v } else { 0.0 }; - let s = format!("{:.*}", decimals as usize, v); - let s = if s.contains('.') { s.trim_end_matches('0').trim_end_matches('.').to_string() } else { s }; - if s == "-0" || s.is_empty() { "0".into() } else { s } + let start = out.len(); + let _ = write!(out, "{:.*}", decimals as usize, v); // writing to a String can't fail + let s = out.get(start..).unwrap_or_default(); + if s.contains('.') { + let keep = s.trim_end_matches('0').trim_end_matches('.').len(); + out.truncate(start + keep); + } + if matches!(out.get(start..), Some("-0" | "")) { + out.truncate(start); + out.push('0'); + } } #[cfg(test)]