Selection: a click on a large compound path is culled by its control points

Every click and hover measured each path and compound path along its
curves to cull it (Node::reach_bounds_with), and a compound path was
then assembled whole to test its outline and fill. For a compound path
of thousands of members that took milliseconds per click.

Hit testing now culls paths and compound paths by the box of their
control points (which holds the curves), grown by their strokes' reach,
so the box is never smaller than the reach box it replaces. A compound
path's outline and fill are tested against only the members whose
control box, grown by the tolerance, holds the point: a member outside
that box is farther than the tolerance and winds zero times round the
point. Painted strokes still follow the whole compound path (alignment,
dashes, width profiles) and are measured along all of it, as before.

Tests (crates/doc/src/hit/tests.rs):
- a 2,000-member compound path hits exactly as the whole-path test did,
  at random points, on and next to anchors and on the edges of the
  members' boxes, for both fill rules, in Outline view and with Object
  Selection by Path Only;
- wide strokes painting past the control points are still hit, for a
  path and a compound path;
- the cull box holds the reach box for paths and compound paths.

Measured in a debug test build (one run, machine not idle): a click on
a 20,000-member compound path went from about 18 ms to about 1 ms.
vectorcraft-cli perf was not rerun.
This commit is contained in:
Ahmed Gomaa
2026-11-03 16:03:40 +07:00
committed by bflatastic
parent 84db6a54a0
commit f0e72fb11a
3 changed files with 280 additions and 7 deletions
+51 -6
View File
@@ -577,7 +577,41 @@ fn may_hit(parent: &Node, i: usize, c: &Node, p: Point, opt: HitOptions, symbols
// (An envelope's content sits where it was, not where the envelope draws it.)
edits_contents(c)
|| c.extras().shaper.is_some() && source_of(c, opt.scope)
|| c.reach_bounds_with(symbols).is_none_or(|b| b.inflate(opt.tol, opt.tol).contains(p))
|| cull_reach(c, symbols).is_none_or(|b| b.inflate(opt.tol, opt.tol).contains(p))
}
/// A box round everything `c` can paint, for culling clicks: [`Node::reach_bounds_with`], except
/// that paths and compound paths are measured by their control points ([`PathData::control_bounds`],
/// which hold the curves) instead of along their curves, which a compound path of thousands of
/// members takes milliseconds to do on every click and hover. The box only rules points out, and it
/// is at least as large, so the same objects are hit.
///
/// [`PathData::control_bounds`]: vectorcraft_geom::PathData::control_bounds
fn cull_reach(c: &Node, symbols: &SymbolBoxes) -> Option<Rect> {
let control = match &c.kind {
NodeKind::Path { path, .. } => path.control_bounds(),
NodeKind::Compound { children, .. } if children.iter().all(|m| m.path_data().is_some()) => {
children.iter().filter_map(|m| m.path_data().and_then(vectorcraft_geom::PathData::control_bounds)).reduce(|a, b| a.union(b))
}
_ => return c.reach_bounds_with(symbols),
};
let o = c.appearance.outset();
control.map(|b| b.inflate(o, o))
}
/// The members of compound path `children` that a click at `p` within `tol` can touch, as one
/// path: those whose control points' box, grown by `tol`, holds `p` (edges included). A member
/// outside that box is farther than `tol` from `p` and winds zero times round it, so this path's
/// outline and fill answer a click as the whole compound path's do, without assembling it.
fn compound_members_near(children: &[std::sync::Arc<Node>], p: Point, tol: f64) -> vectorcraft_geom::BezPath {
let near = |b: Rect| b.x0 - tol <= p.x && p.x <= b.x1 + tol && b.y0 - tol <= p.y && p.y <= b.y1 + tol;
let mut bp = vectorcraft_geom::BezPath::new();
for pd in children.iter().filter_map(|m| m.path_data()) {
if pd.control_bounds().is_some_and(near) {
bp.extend(pd.to_bezpath());
}
}
bp
}
/// Is the first child of `n` the isolated container `scope`?
@@ -710,12 +744,23 @@ fn hit_leaf(n: &Node, p: Point, opt: HitOptions, symbols: &SymbolBoxes) -> Optio
}
None
}
NodeKind::Compound { rule, .. } => {
let bp = n.stroke_path()?;
if let Some(k) = hit_stroke(n, &bp, *rule, p, opt) {
return Some(k);
NodeKind::Compound { children, rule } => {
// Painted strokes follow the whole compound path (alignment, dashes, width profiles):
// they are measured along all of it, as before.
if !opt.outline && n.appearance.painted_strokes().next().is_some() {
let bp = n.stroke_path()?;
if n.appearance.stroke_hit(&bp, *rule, p, opt.tol) {
return Some(HitKind::Stroke);
}
}
(!opt.outline && !opt.path_only && fill_contains(&bp, *rule, p)).then_some(HitKind::Fill)
// The outline and the fill only need the members near `p`. (Measured without
// `stroke_contains`'s box check: the box of the members near `p` is smaller than the
// whole path's, and its far edges would turn away points exactly `tol` away.)
let near = compound_members_near(children, p, opt.tol);
if vectorcraft_geom::hit::distance_to_outline(&near, p) <= opt.tol {
return Some(HitKind::Outline);
}
(!opt.outline && !opt.path_only && fill_contains(&near, *rule, p)).then_some(HitKind::Fill)
}
// The type path lies within the bounds: they rule out the rest before it is measured.
NodeKind::Text(t) if opt.type_path_only => {
+220
View File
@@ -1120,3 +1120,223 @@ fn visible_sampling_reads_locked_art_and_layers_but_excludes_hidden_art() {
d.node_mut(l).unwrap().visible = false;
assert!(hit_test_visible(&d, p, HitOptions::default()).is_none());
}
/// Deterministic xorshift in 0..1.
struct Rng(u64);
impl Rng {
fn next(&mut self) -> f64 {
self.0 ^= self.0 << 13;
self.0 ^= self.0 >> 7;
self.0 ^= self.0 << 17;
(self.0 % 1_000_000) as f64 / 1_000_000.0
}
}
/// A closed blob of `n` smooth anchors round `c` (radius about `r`), turning either way, with
/// handles long enough that its control points reach past its curves.
fn blob(rng: &mut Rng, c: Point, r: f64, n: usize, clockwise: bool) -> vectorcraft_geom::SubPath {
use vectorcraft_geom::{Anchor, Vec2};
let anchors = (0..n)
.map(|i| {
let k = if clockwise { i as f64 } else { (n - i) as f64 };
let a = k / n as f64 * std::f64::consts::TAU;
let rr = r * (0.6 + 0.8 * rng.next());
let p = c + Vec2::new(a.cos(), a.sin()) * rr;
let t = Vec2::new(-a.sin(), a.cos()) * (if clockwise { 1.0 } else { -1.0 }) * rr * (0.2 + 1.3 * rng.next());
Anchor::smooth(p, p + t)
})
.collect();
vectorcraft_geom::SubPath::new(anchors, true)
}
/// A document with one compound path of `members` blobs (some of them holes), its id and the
/// members' paths.
fn compound_doc(members: usize, rule: vectorcraft_geom::FillRule, appearance: Appearance) -> (Document, NodeId, Vec<vectorcraft_geom::PathData>) {
let mut d = Document::new(800.0, 800.0);
let l = d.layers[0].id;
let mut rng = Rng(0x9e37_79b9_7f4a_7c15);
let mut paths = vec![];
let mut children = vec![];
for i in 0..members {
let c = Point::new(20.0 + rng.next() * 600.0, 20.0 + rng.next() * 600.0);
let r = 3.0 + rng.next() * 9.0;
let pd = vectorcraft_geom::PathData::single(blob(&mut rng, c, r, 3 + i % 4, i % 3 != 0));
children.push(Arc::new(Node::path(d.alloc_id(), pd.clone(), Appearance::default())));
paths.push(pd);
}
let id = d.alloc_id();
let mut compound = Node::new(id, NodeKind::Compound { children, rule });
compound.appearance = appearance.into();
d.insert(Some(l), 0, compound).unwrap();
(d, id, paths)
}
/// What a click on compound path `n` gave before compound paths were culled by their control
/// points and their members near the click: the whole path assembled (`full`), culled by its
/// reach measured along the curves.
fn compound_hit_before(n: &Node, full: &vectorcraft_geom::BezPath, p: Point, opt: HitOptions) -> Option<HitKind> {
let NodeKind::Compound { rule, .. } = &n.kind else { return None };
if !n.reach_bounds().is_none_or(|b| b.inflate(opt.tol, opt.tol).contains(p)) {
return None;
}
if !opt.outline && n.appearance.stroke_hit(full, *rule, p, opt.tol) {
return Some(HitKind::Stroke);
}
if stroke_contains(full, 0.0, opt.tol, p) {
return Some(HitKind::Outline);
}
(!opt.outline && !opt.path_only && fill_contains(full, *rule, p)).then_some(HitKind::Fill)
}
/// The points a test clicks for `paths`: random ones over them and past them, and, for some
/// members, their anchors, points just over the tolerance from them, and the corners and edge
/// midpoints of their control and curve boxes, on them and grown by the tolerance.
fn probe_points(paths: &[vectorcraft_geom::PathData], tol: f64, random: usize, every: usize) -> Vec<Point> {
let all = paths.iter().filter_map(vectorcraft_geom::PathData::control_bounds).reduce(|a, b| a.union(b)).unwrap().inflate(30.0, 30.0);
let mut rng = Rng(7);
let mut pts: Vec<Point> = (0..random).map(|_| Point::new(all.x0 + rng.next() * all.width(), all.y0 + rng.next() * all.height())).collect();
for pd in paths.iter().step_by(every) {
for a in pd.subpaths.iter().flat_map(|sp| &sp.anchors) {
pts.extend([a.p, Point::new(a.p.x + tol, a.p.y), Point::new(a.p.x, a.p.y - tol), Point::new(a.p.x + tol * 1.0001, a.p.y)]);
}
for b in [pd.control_bounds().unwrap(), pd.bounds().unwrap()] {
for b in [b, b.inflate(tol, tol)] {
let (cx, cy) = (b.center().x, b.center().y);
pts.extend(
[(b.x0, b.y0), (b.x1, b.y0), (b.x1, b.y1), (b.x0, b.y1), (cx, b.y0), (cx, b.y1), (b.x0, cy), (b.x1, cy)]
.map(|(x, y)| Point::new(x, y)),
);
}
}
}
pts
}
/// A compound path of thousands of members hits exactly as it did when it was assembled
/// whole for every click: at random points, on and next to anchors, and on the edges of the
/// members' boxes (control points' and curves', with and without the tolerance), for both fill
/// rules, in outline mode and with Object Selection by Path Only.
#[test]
fn culled_compound_hits_as_the_whole_path() {
use vectorcraft_geom::FillRule;
for (rule, opt) in [
(FillRule::NonZero, HitOptions::default()),
(FillRule::EvenOdd, HitOptions { tol: 0.5, ..Default::default() }),
(FillRule::NonZero, HitOptions { outline: true, tol: 1.0, ..Default::default() }),
(FillRule::EvenOdd, HitOptions { path_only: true, ..Default::default() }),
] {
let (d, id, paths) = compound_doc(2000, rule, Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0));
let n = d.node(id).unwrap();
let full = n.stroke_path().unwrap();
// The whole path's box grown by the tolerance. Before, a point on its far edges (right
// and bottom: `Rect::contains` leaves them out) never hit, even exactly `tol` from the
// outline; now it hits there as on the near edges. Those points are clicked too, and
// checked against the distance alone.
let far = vectorcraft_geom::Shape::bounding_box(&full).inflate(opt.tol, opt.tol);
let mut pts = probe_points(&paths, opt.tol, 200, 120);
let rightmost = paths.iter().filter_map(vectorcraft_geom::PathData::bounds).max_by(|a, b| a.x1.total_cmp(&b.x1)).unwrap();
pts.push(Point::new(far.x1, rightmost.center().y));
let (mut hits, mut kinds) = (0, HashSet::new());
for p in pts {
let before = compound_hit_before(n, &full, p, opt);
let now = hit_test(&d, p, opt);
if p.x == far.x1 || p.y == far.y1 {
let outline = vectorcraft_geom::hit::distance_to_outline(&full, p) <= opt.tol;
assert_eq!(now.is_some(), outline || before.is_some(), "{rule:?} {opt:?} on the far edge at {p:?}");
continue;
}
assert_eq!(now.as_ref().map(|h| (h.leaf, h.kind)), before.map(|k| (id, k)), "{rule:?} {opt:?} at {p:?}");
hits += usize::from(before.is_some());
kinds.extend(before.map(|k| format!("{k:?}")));
}
// The points reach both hits and misses, and the outline as well as the fill.
assert!(hits > 100, "{rule:?} {opt:?}: {hits} hits");
assert!(kinds.contains("Outline"), "{rule:?} {opt:?}: {kinds:?}");
}
}
/// A wide stroke paints far past a path's control points: clicks on it there still hit, for a
/// path and for a compound path (whose strokes are measured along all of it), exactly as when
/// they were culled by their reach measured along the curves.
#[test]
fn wide_strokes_hit_past_the_control_points() {
use vectorcraft_geom::{Anchor, PathData, SubPath};
let mut d = Document::new(400.0, 400.0);
let l = d.layers[0].id;
let stroke = Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 40.0);
// A zigzag 10 pt high whose 40 pt stroke reaches 20 pt (more at its mitred turns) past it.
let zig = PathData::single(SubPath::new(
[(100.0, 100.0), (110.0, 110.0), (120.0, 100.0), (130.0, 110.0)].map(|(x, y)| Anchor::corner(Point::new(x, y))).to_vec(),
false,
));
let path = d.alloc_id();
d.insert(Some(l), 0, Node::path(path, zig.clone(), stroke.clone())).unwrap();
// A compound path of two small rings with the same stroke, further along.
let mut rng = Rng(3);
let members: Vec<Arc<Node>> = [Point::new(250.0, 250.0), Point::new(262.0, 250.0)]
.into_iter()
.map(|c| Arc::new(Node::path(d.alloc_id(), PathData::single(blob(&mut rng, c, 4.0, 4, true)), Appearance::default())))
.collect();
let compound = d.alloc_id();
let mut node = Node::new(compound, NodeKind::Compound { children: members, rule: vectorcraft_geom::FillRule::NonZero });
node.appearance = stroke.into();
d.insert(Some(l), 9, node).unwrap();
let symbols = d.symbol_boxes();
let opt = HitOptions::default();
for id in [path, compound] {
let n = d.node(id).unwrap();
let full = n.stroke_path().unwrap();
let control = match &n.kind {
NodeKind::Path { path, .. } => path.control_bounds().unwrap(),
_ => n.children().unwrap().iter().filter_map(|m| m.path_data()?.control_bounds()).reduce(|a, b| a.union(b)).unwrap(),
};
let (mut outside, mut checked) = (0, 0);
let area = control.inflate(40.0, 40.0);
for i in 0..=80 {
for j in 0..=80 {
let p = Point::new(area.x0 + area.width() * f64::from(i) / 80.0, area.y0 + area.height() * f64::from(j) / 80.0);
let before = match &n.kind {
NodeKind::Path { .. } => n
.reach_bounds_with(&symbols)
.is_none_or(|b| b.inflate(opt.tol, opt.tol).contains(p))
.then(|| hit_leaf(n, p, opt, &symbols))
.flatten(),
_ => compound_hit_before(n, &full, p, opt),
};
let now = hit_test(&d, p, opt).filter(|h| h.leaf == id).map(|h| h.kind);
assert_eq!(now, before, "{id:?} at {p:?}");
checked += 1;
outside += usize::from(before == Some(HitKind::Stroke) && !control.contains(p));
}
}
assert!(outside > 50, "{id:?}: {outside} of {checked} points hit the stroke outside the control points' box");
}
}
/// The box a click is culled by holds the object's reach for paths and compound paths (it is
/// measured by their control points, which hold their curves, and grown by the same stroke reach),
/// so culling by it never turns away a click the reach let through. (Up to rounding: where a curve's
/// extreme is an anchor, the two boxes measure the same point in different ways.)
#[test]
fn cull_box_holds_the_reach_of_paths_and_compound_paths() {
const E: f64 = 1e-9;
let holds =
|outer: Rect, inner: Rect| outer.x0 - E <= inner.x0 && outer.y0 - E <= inner.y0 && inner.x1 <= outer.x1 + E && inner.y1 <= outer.y1 + E;
let stroke = Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 12.0);
let (mut d, id, _) = compound_doc(200, vectorcraft_geom::FillRule::NonZero, stroke.clone());
let path_id = d.alloc_id();
let symbols = d.symbol_boxes();
let n = d.node(id).unwrap();
let mut checked = 0;
for node in std::iter::once(n).chain(n.children().unwrap().iter().map(|m| &**m)) {
let (cull, reach) = (cull_reach(node, &symbols).unwrap(), node.reach_bounds_with(&symbols).unwrap());
assert!(holds(cull, reach), "{:?}: {cull:?} does not hold {reach:?}", node.id);
checked += 1;
}
// A stroked plain path, its handles reaching past its curves.
let mut rng = Rng(11);
let path = Node::path(path_id, vectorcraft_geom::PathData::single(blob(&mut rng, Point::new(50.0, 50.0), 20.0, 5, true)), stroke);
let (cull, reach) = (cull_reach(&path, &symbols).unwrap(), path.reach_bounds_with(&symbols).unwrap());
assert!(holds(cull, reach), "{cull:?} does not hold {reach:?}");
assert_eq!(checked, 201);
}
+9 -1
View File
@@ -1,6 +1,6 @@
# Gaps
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-03 · **Change:** minor (font-family fields accept the mouse wheel, #5253; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; G12: Units › East Asian Type acts, with Q and Ha units; estimate unchanged) · **Target:** Adobe Illustrator 2026 (30.x)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-03 · **Change:** minor (G15: clicks on paths and compound paths are culled by their control points; estimate unchanged) · **Target:** Adobe Illustrator 2026 (30.x)
## Feature submission evidence
@@ -619,6 +619,13 @@ from G14 and the budget run of G15: **~170–270 h** one agent, ~50–80 h wall
## G15 Performance budgets and a GPU renderer
- **Improved (2026-11-02):** a click or hover culls paths and compound paths by the box of their control points
(grown by their strokes' reach) instead of measuring along their curves, and tests a compound path's outline and
fill against only the members whose control box, grown by the tolerance, holds the point; painted strokes are
still measured along the whole compound path. The same objects are hit (`crates/doc/src/hit/tests.rs`: a
2,000-member compound path against the whole-path test for both fill rules, Outline view and Object Selection by
Path Only; wide strokes past the control points). A 20,000-member compound path went from about 18 ms to about
1 ms a click in a debug test build (one run, not idle); `vectorcraft-cli perf` not rerun.
- **Improved (#1257):** Craquelure, Mosaic Tiles and Stained Glass reuse squared distances during nearest-cell searches, with the original distance ordering retained near rounding/tie boundaries. A brute-force boundary regression and full-viewport pixel comparisons agree with the old result. `texture_drag_perf` reproduces a moved CMYK rounded rectangle at 190.07% and DPI 1.5; timings from a busy development machine are supporting evidence, with idle Windows drag QA still pending.
- **Missing:** an idle-machine run of `vectorcraft-cli perf` at 50k paths (2026-10-11 at 25k paths, every budget
met, 400% zoom 12.3 ms against 16 ms, measured; runs of 0.10.0 on Linux with 4 logical CPUs, reported in #1172
@@ -717,6 +724,7 @@ trap, and the defaults (0.25 pt, 100%, 40%) are VectorCraft's own, the pages giv
| Date | Change | Summary |
|---|---|---|
| 2026-11-02 | minor | G15: hit testing culls paths and compound paths by their control points' box and tests a compound path's outline and fill against only its members near the click; the same objects are hit. Estimate unchanged. |
| 2026-11-03 | minor | Symbol editing shows draggable 9-slice guides within the artwork (#5280). Guides outside the artwork remain unsupported. The G5 estimate remains 14–24 h. |
| 2026-10-31 | minor | G12: Units › East Asian Type acts (#394): Q and Ha (a quarter of a millimetre each) are units that preference offers; 4 Preferences options remain stored but not acted on. Estimate unchanged. |
| 2026-11-01 | minor | G5: dynamic symbols (#4938): Symbol Options › Symbol Type, per-instance fill, stroke, opacity and blend for objects of a dynamic symbol's art that stay linked (redefining keeps them, Break Link bakes them, Reset and Static drop them; `symbol.instance.setAppearance`, `symbol.instance.reset`; `crates/engine/src/tests/dynamic_symbols.rs`); on-canvas Direct Selection of the parts and `.ai` round trip remain. Estimate 15–26 → 14–24 h. |