diff --git a/crates/doc/src/hit.rs b/crates/doc/src/hit.rs index d20c1ffe4..573d8fec4 100644 --- a/crates/doc/src/hit.rs +++ b/crates/doc/src/hit.rs @@ -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 { + 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], 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 => { diff --git a/crates/doc/src/hit/tests.rs b/crates/doc/src/hit/tests.rs index c4d6c565e..452055a8f 100644 --- a/crates/doc/src/hit/tests.rs +++ b/crates/doc/src/hit/tests.rs @@ -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) { + 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 { + 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 { + 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 = (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> = [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); +} diff --git a/docs/gaps.md b/docs/gaps.md index 7c00ad0b9..dc081640d 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -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. |