diff --git a/apps/vectorcraft-cli/src/perf.rs b/apps/vectorcraft-cli/src/perf.rs index 33815fbf3..b77222834 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> { + 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 = 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)) }