Merge pull request #5778 from storytold/perf/1280-bounds-without-bezpath

#1280: perf --deep measures pointing; PathData::bounds builds no path
This commit is contained in:
BFlat
2026-11-05 17:02:47 +07:00
committed by GitHub
2 changed files with 91 additions and 2 deletions
+43
View File
@@ -187,6 +187,47 @@ fn budget_row(name: &str, ms: f64, budget: f64, ok: bool) -> String {
format!(" {name:<42} {ms:>8.2} ms {budget:>7.0} ms {}", if ok { "ok" } else { "OVER" })
}
/// Tracked rows for pointing at things in an n-path document: what hovering, clicking, marquee
/// selection and snapping cost when nothing narrows the search.
fn pointing(n: usize, rows: &mut Vec<(String, f64)>) -> Result<(), String> {
use vectorcraft_engine::tools::{Mods, PointerEvent, PointerKind};
let err = |e: vectorcraft_engine::EngineError| e.to_string();
let mut s = vectorcraft_engine::Session::new();
s.add_document(synthetic(n)?, None);
let view = vectorcraft_engine::ViewInfo { zoom: 1.0, ..Default::default() };
s.select_tool("selection", view).map_err(err)?;
// Off the art (nothing under the pointer), the case every hover over empty canvas pays.
let off = Point::new(-200.0, -200.0);
let hover = |s: &mut vectorcraft_engine::Session| {
let _ = s.pointer(&PointerEvent::new(PointerKind::Move, off.x, off.y), view);
let _ = s.cursor(off, Mods::default(), view);
};
rows.push((format!("hover over empty canvas ({n} paths)"), median_ms(5, || hover(&mut s))));
s.execute("select.all", &json!({})).map_err(err)?;
rows.push((format!("hover over empty canvas, all selected ({n} paths)"), median_ms(5, || hover(&mut s))));
let doc = s.doc().map_err(err)?.doc.clone();
rows.push((format!("hit test, a miss ({n} paths)"), median_ms(5, || drop(hit::hit_test(&doc, off, hit::HitOptions::default())))));
let r = Rect::new(600.0, 450.0, 1000.0, 750.0);
rows.push((format!("marquee 400×300 ({n} paths)"), median_ms(5, || drop(hit::marquee(&doc, r, None, false)))));
let view_rect = Rect::new(0.0, 0.0, 1600.0, 1200.0);
rows.push((
format!("smart guide targets in view ({n} paths)"),
median_ms(5, || drop(vectorcraft_engine::tools::guides::Targets::collect(&doc, &[], Some(view_rect)))),
));
// Shift-clicking one more object into a selection of half the document.
let ids: Vec<u64> = doc.layers[0].children().map(|c| c.iter().map(|n| n.id.0).collect()).unwrap_or_default();
s.execute("select.set", &json!({"ids": ids[..ids.len() / 2]})).map_err(err)?;
let mut k = ids.len() / 2;
rows.push((
format!("shift-click into a {}-object selection", ids.len() / 2),
median_ms(5, || {
k += 1;
let _ = s.execute("select.toggle", &json!({"id": ids[k % ids.len()]}));
}),
));
Ok(())
}
/// `--deep`: tracked rows (no budget yet) for work the budgets above don't reach: editing a whole
/// selection, live effects, every Pathfinder operation, typing in a long story, and memory, with
/// the peak so far where `rss_mb` reads one.
@@ -282,6 +323,8 @@ fn deep(n: usize) -> Result<(), String> {
}),
));
note("after typing");
pointing(n, &mut rows)?;
note("after pointing");
// Four embedded 24 MP photos drawn at fit page by three renderers, as the app's canvas, render
// worker and a panel each draw them.
{
+48 -2
View File
@@ -547,17 +547,30 @@ impl PathData {
/// Tight geometric bounds (curve extrema, not control points), measured by [`curve_bounds`]: a
/// segment with a control point that is not finite or past `f64::MAX / 16` in x or y is measured
/// by its control points.
///
/// Walks the segments in place, without building the path: the same segments in the same order,
/// so the result is bit for bit `curve_bounds(&self.to_bezpath())` (a path of lone anchors, with
/// no segment, is `Rect::ZERO`, as there).
pub fn bounds(&self) -> Option<Rect> {
if self.is_empty() {
return None;
}
let bp = self.to_bezpath();
if self.anchor_count() == 1
&& let Some(a) = self.subpaths.iter().find_map(|s| s.anchors.first())
{
return Some(Rect::from_points(a.p, a.p));
}
Some(curve_bounds(&bp))
let mut bbox: Option<Rect> = None;
for sp in &self.subpaths {
// (a closed subpath's last segment ends on its first anchor: its `ClosePath` adds no line)
for i in 0..sp.segment_count() {
let c = sp.segment(i);
// `to_bezpath_into` writes a line where neither handle is out, else a cubic.
let seg = segment_bounds(if sp.segment_is_line(i) { PathSeg::Line(kurbo::Line::new(c.p0, c.p3)) } else { PathSeg::Cubic(c) });
bbox = Some(bbox.map_or(seg, |bb| bb.union(seg)));
}
}
Some(bbox.unwrap_or_default())
}
/// Bounds including control handles (used for quick culling).
pub fn control_bounds(&self) -> Option<Rect> {
@@ -720,6 +733,39 @@ fn push_curve(cur: &mut Option<SubPath>, last: Point, c1: Point, c2: Point, p: P
mod tests {
use super::*;
/// `bounds` walks the anchors in place: bit for bit what the kurbo path it stands for gives,
/// for lines, curves, open and closed subpaths, lone anchors and empty subpaths.
#[test]
fn bounds_match_the_bezpath_bit_for_bit() {
use proptest::prelude::*;
fn reference(pd: &PathData) -> Option<Rect> {
if pd.is_empty() {
return None;
}
if pd.anchor_count() == 1 {
let a = pd.subpaths.iter().find_map(|s| s.anchors.first())?;
return Some(Rect::from_points(a.p, a.p));
}
Some(curve_bounds(&pd.to_bezpath()))
}
let bits = |r: Option<Rect>| r.map(|r| [r.x0, r.y0, r.x1, r.y1].map(f64::to_bits));
let coord = || prop_oneof![(-1e3f64..1e3), Just(0.0), Just(1e-300), Just(-7.25)];
let pt = move || (coord(), coord()).prop_map(|(x, y)| Point::new(x, y));
// Handles retracted (equal to the anchor) a third of the time: lines and curves mix.
let handle = move || prop_oneof![Just(None), pt().prop_map(Some)];
let anchor =
(pt(), handle(), handle()).prop_map(|(p, i, o)| Anchor { p, h_in: i.unwrap_or(p), h_out: o.unwrap_or(p), kind: AnchorKind::Corner });
let sub = (proptest::collection::vec(anchor, 0..6), any::<bool>()).prop_map(|(a, c)| SubPath::new(a, c));
let path = proptest::collection::vec(sub, 0..4).prop_map(PathData::new);
proptest!(ProptestConfig::with_cases(2000), |(pd in path)| {
prop_assert_eq!(bits(pd.bounds()), bits(reference(&pd)));
});
// Two lone anchors: no segment, as the path has none.
let lone = PathData::new(vec![SubPath::polyline(&[Point::new(5.0, 5.0)], false), SubPath::polyline(&[Point::new(9.0, 1.0)], true)]);
assert_eq!(lone.bounds(), Some(Rect::ZERO));
assert_eq!(bits(lone.bounds()), bits(reference(&lone)));
}
fn square() -> PathData {
PathData::single(SubPath::polyline(&[Point::new(0.0, 0.0), Point::new(10.0, 0.0), Point::new(10.0, 10.0), Point::new(0.0, 10.0)], true))
}