diff --git a/crates/doc/src/hit.rs b/crates/doc/src/hit.rs index 8b498fd3d..b3b4394a2 100644 --- a/crates/doc/src/hit.rs +++ b/crates/doc/src/hit.rs @@ -6,9 +6,10 @@ use std::rc::Rc; use std::sync::{Arc, OnceLock}; use vectorcraft_geom::hit::{fill_contains, stroke_contains}; -use vectorcraft_geom::{BezPath, FillRule, Point, Rect}; +use vectorcraft_geom::kurbo::ParamCurveNearest; +use vectorcraft_geom::{BezPath, FillRule, PathData, PathSeg, Point, Rect}; -use crate::{AppearanceItem, Document, Node, NodeId, NodeKind, SymbolBoxes}; +use crate::{AppearanceItem, Document, Node, NodeId, NodeKind, StrokeAlign, SymbolBoxes}; #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum HitKind { @@ -724,6 +725,43 @@ fn hit_stroke(n: &Node, bp: &vectorcraft_geom::BezPath, rule: vectorcraft_geom:: stroke_contains(bp, 0.0, opt.tol, p).then_some(HitKind::Outline) } +/// Check the body without assembling a `BezPath`. A miss near a joint or cap may still hit a +/// miter or square cap, so the caller falls back to the full stroke check there. +fn near_path_body(path: &PathData, p: Point, radius: f64, decoration_reach: f64) -> Option<(bool, bool)> { + if !radius.is_finite() || radius < 0.0 || !decoration_reach.is_finite() || decoration_reach < 0.0 { + return None; + } + let radius_sq = radius * radius; + let decoration_sq = decoration_reach * decoration_reach; + let mut near_decoration = false; + for sp in &path.subpaths { + for i in 0..sp.segment_count() { + let c = sp.segment(i); + if decoration_reach > 0.0 && !near_decoration && ([c.p0, c.p3].iter().any(|q| (*q - p).hypot2() <= decoration_sq)) { + near_decoration = true; + } + let controls = [c.p0, c.p1, c.p2, c.p3]; + if controls.iter().all(|q| q.x < p.x - radius) + || controls.iter().all(|q| q.x > p.x + radius) + || controls.iter().all(|q| q.y < p.y - radius) + || controls.iter().all(|q| q.y > p.y + radius) + { + continue; + } + let seg = if sp.segment_is_line(i) { PathSeg::Line(vectorcraft_geom::kurbo::Line::new(c.p0, c.p3)) } else { PathSeg::Cubic(c) }; + if seg.nearest(p, 1e-9).distance_sq <= radius_sq { + if vectorcraft_geom::bez::has_length(&seg) { + return Some((true, near_decoration)); + } + // A zero-length segment can still hit as an outline, though a painted stroke + // needs a segment with direction. Let the existing check decide its hit kind. + near_decoration = true; + } + } + } + Some((false, near_decoration)) +} + #[cfg(test)] thread_local! { /// How many objects [`hit_leaf`] tested on this thread. @@ -735,6 +773,43 @@ fn hit_leaf(n: &Node, p: Point, opt: HitOptions, symbols: &SymbolBoxes) -> Optio LEAVES_TESTED.with(|c| c.set(c.get() + 1)); match &n.kind { NodeKind::Path { path, rule, .. } => { + if opt.outline + && let Some((hit, needs_full_check)) = near_path_body(path, p, opt.tol, 0.0) + { + if hit { + return Some(HitKind::Outline); + } + if !needs_full_check { + return None; + } + } + let mut strokes = n.appearance.painted_strokes(); + if !opt.outline + && n.appearance.fill().is_none_or(|f| f.paint.is_none()) + && let Some(s) = strokes.next() + && strokes.next().is_none() + && s.align == StrokeAlign::Center + && s.width.is_finite() + && s.width > 0.0 + && s.dash.as_ref().is_none_or(|d| !d.is_dashed()) + && s.profile.is_none() + && s.start_arrow.is_none() + && s.end_arrow.is_none() + && s.brush.is_none() + && let Some((hit, near_decoration)) = near_path_body( + path, + p, + s.width / 2.0 + opt.tol, + if s.join == crate::LineJoin::Miter || s.cap == crate::LineCap::Square { s.reach() + opt.tol } else { 0.0 }, + ) + { + if hit { + return Some(HitKind::Stroke); + } + if !near_decoration { + return None; + } + } let bp = path.to_bezpath(); if let Some(k) = hit_stroke(n, &bp, *rule, p, opt) { return Some(k); diff --git a/crates/doc/src/hit/tests.rs b/crates/doc/src/hit/tests.rs index 452055a8f..9a5c40478 100644 --- a/crates/doc/src/hit/tests.rs +++ b/crates/doc/src/hit/tests.rs @@ -38,6 +38,39 @@ fn unfilled_interior_misses() { assert!(hit_test(&d, Point::new(50.5, 25.0), HitOptions::default()).is_some()); } +#[test] +fn long_plain_stroke_hits_its_middle_and_curved_end() { + let mut d = Document::new(200.0, 200.0); + let l = d.layers[0].id; + let id = d.alloc_id(); + let mut sp = vectorcraft_geom::SubPath::polyline(&(0..=4097).map(|i| Point::new(i as f64 * 10.0, 0.0)).collect::>(), false); + sp.anchors[4096].h_out = Point::new(40960.0, 100.0); + sp.anchors[4097].h_in = Point::new(40970.0, 100.0); + sp.anchors.push(vectorcraft_geom::Anchor::corner(Point::new(40970.0, 100.0))); + sp.anchors.push(vectorcraft_geom::Anchor::corner(Point::new(0.0, 100.0))); + let path = vectorcraft_geom::PathData::single(sp); + let look = Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 1.0); + d.insert(Some(l), 0, Node::path(id, path, look)).unwrap(); + let outline = HitOptions { outline: true, ..Default::default() }; + for point in [Point::new(20485.0, 0.0), Point::new(40965.0, 75.0)] { + assert_eq!(hit_test(&d, point, HitOptions::default()).map(|h| h.kind), Some(HitKind::Stroke)); + assert_eq!(hit_test(&d, point, outline).map(|h| h.kind), Some(HitKind::Outline)); + } + assert!(hit_test(&d, Point::new(20485.0, 50.0), HitOptions::default()).is_none()); +} + +#[test] +fn coincident_anchors_remain_hittable_as_an_outline() { + let mut d = Document::new(200.0, 200.0); + let id = d.alloc_id(); + let path = vectorcraft_geom::PathData::single(vectorcraft_geom::SubPath::polyline(&[Point::new(50.0, 50.0); 2], false)); + let look = Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 1.0); + d.insert(d.layers.first().map(|l| l.id), 0, Node::path(id, path, look)).unwrap(); + let at = Point::new(50.0, 50.0); + assert_eq!(hit_test(&d, at, HitOptions::default()).map(|h| h.kind), Some(HitKind::Outline)); + assert_eq!(hit_test(&d, at, HitOptions { outline: true, ..Default::default() }).map(|h| h.kind), Some(HitKind::Outline)); +} + /// #1103: a marquee drawn over a background picks what is on it, not the background, whose /// outline it doesn't touch. #[test] diff --git a/docs/gaps.md b/docs/gaps.md index b8d014b96..5bdf50322 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -651,6 +651,12 @@ 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-05):** hovering over a nearby plain stroke or path outline checks the + candidate segments directly. On a 200,000-anchor open path, a hit near its middle took about + 11–13 ms before and 1.0–1.4 ms after in paired runs on a busy Windows machine. A miss between + two long strokes, inside their box, took 18.7 ms before and 1.6 ms after; a distant miss stayed + about 0.7 ms. Other stroke styles and fill hits retain the full-path check; the idle + performance-budget run is still open. - **Improved (2026-11-05):** moving a large selection reuses the object locations found while validating the transform command. A 20,000-object nudge took 58.07 ms before and 51.26 ms after in paired median runs on a busy Windows machine; copies and active typing still look up their @@ -775,6 +781,7 @@ trap, and the defaults (0.25 pt, 100%, 40%) are VectorCraft's own, the pages giv | Date | Change | Summary | |---|---|---| +| 2026-11-05 | minor | G15: nearby hits on plain long strokes and outlines check their candidate segments directly; a 200,000-anchor middle hit took about 11–13 ms before and 1.0–1.4 ms after in paired runs. An inside-bounds miss fell from 18.7 ms to 1.6 ms. Estimate unchanged; idle budget run still open. | | 2026-11-05 | minor | G15: an image zoomed in on is separated, colorized or proofed only for the tiles in view (#4155); 100% zoom on that file 8.6 s → 0.05 s. Estimate unchanged. | | 2026-11-05 | minor | G15: a document's first frame renders on the render worker (#4155); the longest UI-thread stall opening that file went from 8.7 s to 64 ms. Estimate unchanged. | | 2026-11-05 | minor | G15: a reduced image is shrunk before it is separated into ink planes (#4155); first frame 9.2 s → 1.6 s, sharp frame after a pan 3.9 s → 8 ms on that file. Estimate unchanged. | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index a7ac88b16..da895eef8 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -31,6 +31,7 @@ Artboard Options forwards the active Artboard tool's Scale Artwork with Artboard | Date | Change | Summary | |---|---|---| +| 2026-11-05 | minor | A nearby hit on a plain long stroke or outline avoids assembling its whole path. A hit near the middle of a 200,000-anchor path took about 11–13 ms before and 1.0–1.4 ms after in paired runs on a busy Windows machine; a miss inside the path's box fell from 18.7 ms to 1.6 ms, while a distant miss was unchanged. Other stroke styles and fill hits keep their existing checks. Scores and estimates unchanged; idle performance budgets remain unverified. | | 2026-11-05 | minor | Translating a type object without changing its text or formatting reuses glyph geometry and expanded character-stroke outlines between frames. The text appears at its new position with unchanged pixels. Scaling the type, editing its content or formatting, or changing installed fonts rebuilds the geometry; scores and estimates unchanged. | | 2026-11-05 | minor | Moving a large selection reuses the object locations found during command validation. A 20,000-object nudge took 58.07 ms before and 51.26 ms after in paired median runs; object positions, selection, and undo behavior stay the same. Scores and estimates unchanged. | | 2026-11-05 | minor | Canvas: an image drawn larger than 1,024 pixels at the size it shows, with its colours made again (separated into ink planes, colorized or shown through Separations Preview), has them made only for the 1,024-pixel tiles in view, each kept for later frames (#4155). Zooming in to 50%, 100% and 200% on the file of #4155 went from 8.6–9.0 s to 0.78 s, 0.05 s and 0.05 s (measured, M3 Pro, one run each), with the same pixels. Scores and estimates unchanged. | @@ -360,7 +361,7 @@ what keeps it from beta, not from alpha. | File formats | 75% | 15–30 | estimated | [file-format-parity.md](file-format-parity.md) | | Hardware (GPU, pen, touch, displays) | 35% | 25–45 | estimated | [hardware-parity.md](hardware-parity.md) | | Localization (the 12 key languages, equally weighted) | 74% | 50–85 | measured from catalogs | [localization-parity.md](localization-parity.md) | -| Performance | ~60% | 15–25 | estimated (unverified budgets) | [gaps.md](gaps.md) | +| Performance | ~60% | 15–25 | estimated (unverified budgets; measured long-path hits and large-selection nudges) | [gaps.md](gaps.md) | | Stability | ~60% | 30–50 | estimated | [gaps.md](gaps.md) | | Platforms (macOS, Windows x64/arm64, Linux, FreeBSD, web) | 80% | 15–25 | estimated | [gaps.md](gaps.md) | | Ecosystem and plug-ins (scripting, third-party plug-ins) | 15% | 30–60 | estimated | [gaps.md](gaps.md) |