From a33d83d84f71661cffd3f59509ac2f88c560988b Mon Sep 17 00:00:00 2001 From: FredApps <153173854+FredApps@users.noreply.github.com> Date: Thu, 5 Nov 2026 10:38:50 +0100 Subject: [PATCH 1/2] #1280: perf --deep measures pointing; PathData::bounds builds no path First slice of https://github.com/storytold/vectorcraft/pull/1281, split as the review asked. `--deep` gains rows for hovering, a hit test that misses, marquee, smart guide targets and shift-click on a large document, so the later slices have a baseline. PathData::bounds built a kurbo path on every call. It now measures the segments in place, with the same guards, and a proptest compares the result bit for bit with what the path gave. Co-Authored-By: arhammusheer Co-Authored-By: Claude Opus 5.5 --- apps/vectorcraft-cli/src/perf.rs | 43 +++++++++++++++++++++++++++ crates/geom/src/path.rs | 50 ++++++++++++++++++++++++++++++-- 2 files changed, 91 insertions(+), 2 deletions(-) diff --git a/apps/vectorcraft-cli/src/perf.rs b/apps/vectorcraft-cli/src/perf.rs index 33815fbf3..47da3558f 100644 --- a/apps/vectorcraft-cli/src/perf.rs +++ b/apps/vectorcraft-cli/src/perf.rs @@ -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> { + let err = |e: vectorcraft_engine::EngineError| e.to_string(); + use vectorcraft_engine::tools::{Mods, PointerEvent, PointerKind}; + 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 = 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. { diff --git a/crates/geom/src/path.rs b/crates/geom/src/path.rs index b1847d9ce..b0b9f3cbe 100644 --- a/crates/geom/src/path.rs +++ b/crates/geom/src/path.rs @@ -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 { 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 = 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 { @@ -720,6 +733,39 @@ fn push_curve(cur: &mut Option, 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 { + 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| 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::()).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)) } From 0a8566e1b0487c8052a941e11d8d3c83366baf2d Mon Sep 17 00:00:00 2001 From: FredApps <153173854+FredApps@users.noreply.github.com> Date: Thu, 5 Nov 2026 10:39:06 +0100 Subject: [PATCH 2/2] perf: the import comes first Co-Authored-By: Claude Opus 5.5 --- apps/vectorcraft-cli/src/perf.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/vectorcraft-cli/src/perf.rs b/apps/vectorcraft-cli/src/perf.rs index 47da3558f..b77222834 100644 --- a/apps/vectorcraft-cli/src/perf.rs +++ b/apps/vectorcraft-cli/src/perf.rs @@ -190,8 +190,8 @@ fn budget_row(name: &str, ms: f64, budget: f64, ok: bool) -> String { /// 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> { - let err = |e: vectorcraft_engine::EngineError| e.to_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() };