#1172: Pathfinder Outline 19x, SVG export 1.5x and open 1.2x faster, same output
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VmMmc1J8XeNLxhRihWv9cc
This commit is contained in:
parent
4c5809302d
commit
7652439b40
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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::<serde::de::IgnoredAny>();
|
||||
if values.next().is_some_and(|v| v.is_ok()) {
|
||||
let end = values.byte_offset();
|
||||
|
|
|
|||
|
|
@ -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<Shape> {
|
||||
// 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<Seg>, u64)> = Vec::new();
|
||||
for s in shapes {
|
||||
|
|
@ -375,11 +377,19 @@ fn outline_edges(arr: &Arrangement, shapes: &[Shape]) -> Vec<Shape> {
|
|||
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<f64> = 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<Shape> {
|
|||
}
|
||||
}
|
||||
}
|
||||
// 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<Seg>, 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<usize>> = 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()
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)]
|
||||
|
|
|
|||
Loading…
Reference in New Issue