mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-09 20:03:56 +00:00
Direct Selection: a marquee over a long path tests its segments without building a path for each
The marquee's segment test built a two-anchor path for every segment and ran hit::intersects_rect on it. It now skips a segment whose anchors and handles all lie past one side of the marquee, and tests the rest directly with the same bounds and edge checks, so it selects the same segments. A marquee over 10,000 anchors, press to release (anchor_bench, median of 7): one open path of 200,000 anchors 84.9 ms -> 24.1 ms, 2,000 closed paths of 50 anchors 27.5 ms -> 13.5 ms, one closed path of 50,000 anchors 14.6 ms -> 8.5 ms.
This commit is contained in:
@@ -51,7 +51,7 @@ use std::collections::{BTreeMap, BTreeSet};
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::hit::{HitKind, compound_part_at, hit_test, selected_hidden_at};
|
||||
use vectorcraft_doc::{AnchorRef, Node, NodeId, NodeKind, Selection};
|
||||
use vectorcraft_geom::{Anchor, PathData, Point, Rect, Vec2};
|
||||
use vectorcraft_geom::{Anchor, PathData, PathSeg, Point, Rect, Vec2};
|
||||
|
||||
use crate::bbox::move_delta;
|
||||
use crate::corners::{self, CornerDrag, over_widget};
|
||||
@@ -590,7 +590,17 @@ fn anchors_json(v: &[AnchorRef]) -> Value {
|
||||
/// The segments of `path` that marquee `r` crosses with neither of their anchors inside it, as
|
||||
/// (subpath, segment) pairs: the reference app selects a segment that a marquee is dragged "over
|
||||
/// part of" (<http://web.archive.org/web/20211230015528/https://helpx.adobe.com/illustrator/using/selecting-objects.html>).
|
||||
/// Each segment is judged as `hit::intersects_rect` judges a path of that segment alone. A segment
|
||||
/// whose anchors and handles all lie past one side of `r` is skipped without the exact test.
|
||||
fn crossed_segments(path: &PathData, r: Rect) -> Vec<AnchorRef> {
|
||||
let (x0, x1, y0, y1) = (r.min_x(), r.max_x(), r.min_y(), r.max_y());
|
||||
let edges = [
|
||||
vectorcraft_geom::Line::new((r.x0, r.y0), (r.x1, r.y0)),
|
||||
vectorcraft_geom::Line::new((r.x1, r.y0), (r.x1, r.y1)),
|
||||
vectorcraft_geom::Line::new((r.x1, r.y1), (r.x0, r.y1)),
|
||||
vectorcraft_geom::Line::new((r.x0, r.y1), (r.x0, r.y0)),
|
||||
];
|
||||
let degenerate = |x: Rect| x.width() == 0.0 || x.height() == 0.0;
|
||||
let mut out = vec![];
|
||||
for (si, sp) in path.subpaths.iter().enumerate() {
|
||||
let n = sp.anchors.len();
|
||||
@@ -600,8 +610,24 @@ fn crossed_segments(path: &PathData, r: Rect) -> Vec<AnchorRef> {
|
||||
if r.contains(a.p) || r.contains(b.p) {
|
||||
continue;
|
||||
}
|
||||
let piece = PathData::single(vectorcraft_geom::SubPath::new(vec![*a, *b], false));
|
||||
if vectorcraft_geom::hit::intersects_rect(&piece, r) {
|
||||
// A segment with no handles is drawn as a line, as `SubPath::to_bezpath_into` draws it.
|
||||
let line = !a.has_out() && !b.has_in();
|
||||
let pts = if line { [a.p, b.p, b.p, b.p] } else { [a.p, a.h_out, b.h_in, b.p] };
|
||||
// A segment lies within the box of its anchors and handles. The comparisons are false
|
||||
// for a NaN coordinate, so such a segment goes on to the exact test.
|
||||
if pts.iter().all(|p| p.x < x0) || pts.iter().all(|p| p.x > x1) || pts.iter().all(|p| p.y < y0) || pts.iter().all(|p| p.y > y1) {
|
||||
continue;
|
||||
}
|
||||
let piece = if line {
|
||||
PathSeg::Line(vectorcraft_geom::Line::new(a.p, b.p))
|
||||
} else {
|
||||
PathSeg::Cubic(vectorcraft_geom::CubicBez::new(a.p, a.h_out, b.h_in, b.p))
|
||||
};
|
||||
let bounds = vectorcraft_geom::path::segment_bounds(piece);
|
||||
if bounds.intersect(r).area() <= 0.0 && !degenerate(bounds) && !degenerate(r) {
|
||||
continue;
|
||||
}
|
||||
if edges.iter().any(|e| !piece.intersect_line(*e).is_empty()) {
|
||||
out.push((si, seg));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -587,6 +587,133 @@ fn a_marquee_over_part_of_a_segment_selects_it() {
|
||||
assert!(!drag(&mut g, Mods::default()).iter().any(|a| matches!(a, Action::Exec(c, _) if c == "select.segments")));
|
||||
}
|
||||
|
||||
/// The segments `r` crosses, found by testing a two-anchor path built for each segment with
|
||||
/// `hit::intersects_rect`. `crossed_segments` gives the same result without building the paths.
|
||||
fn crossed_by_pieces(path: &PathData, r: Rect) -> Vec<AnchorRef> {
|
||||
let mut out = vec![];
|
||||
for (si, sp) in path.subpaths.iter().enumerate() {
|
||||
let n = sp.anchors.len();
|
||||
let count = if sp.closed { n } else { n.saturating_sub(1) };
|
||||
for seg in 0..count {
|
||||
let (Some(a), Some(b)) = (sp.anchors.get(seg), sp.anchors.get((seg + 1) % n)) else { continue };
|
||||
if r.contains(a.p) || r.contains(b.p) {
|
||||
continue;
|
||||
}
|
||||
let piece = PathData::single(vectorcraft_geom::SubPath::new(vec![*a, *b], false));
|
||||
if vectorcraft_geom::hit::intersects_rect(&piece, r) {
|
||||
out.push((si, seg));
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// A cubic segment from `p0` to `p3` with handles `p1` and `p2`, as an open path.
|
||||
fn curve(p0: (f64, f64), p1: (f64, f64), p2: (f64, f64), p3: (f64, f64)) -> PathData {
|
||||
let a = Anchor::with_handles(p0.into(), p0.into(), p1.into());
|
||||
let b = Anchor::with_handles(p3.into(), p2.into(), p3.into());
|
||||
PathData::single(vectorcraft_geom::SubPath::new(vec![a, b], false))
|
||||
}
|
||||
|
||||
fn polyline(points: &[(f64, f64)], closed: bool) -> PathData {
|
||||
let pts: Vec<Point> = points.iter().map(|p| (*p).into()).collect();
|
||||
PathData::single(vectorcraft_geom::SubPath::polyline(&pts, closed))
|
||||
}
|
||||
|
||||
/// A Direct Selection marquee crosses a segment, with neither of its anchors inside, only where
|
||||
/// the segment itself meets the marquee: a straight segment, a curve that reaches the marquee only
|
||||
/// between its anchors, and not a curve whose handles reach it while the curve stays outside.
|
||||
#[test]
|
||||
fn a_marquee_crosses_a_segment_only_where_the_segment_meets_it() {
|
||||
let line = polyline(&[(0.0, 0.0), (100.0, 0.0)], false);
|
||||
assert_eq!(crossed_segments(&line, Rect::new(40.0, -10.0, 60.0, 10.0)), vec![(0, 0)], "across a straight segment");
|
||||
assert!(crossed_segments(&line, Rect::new(40.0, 5.0, 60.0, 10.0)).is_empty(), "beside it");
|
||||
// The curve rises to y = 67.5 between its anchors on y = 0; its handles reach y = 90.
|
||||
let arch = curve((0.0, 0.0), (0.0, 90.0), (100.0, 90.0), (100.0, 0.0));
|
||||
assert_eq!(crossed_segments(&arch, Rect::new(40.0, 60.0, 60.0, 70.0)), vec![(0, 0)], "the curve crosses between its anchors");
|
||||
assert!(crossed_segments(&arch, Rect::new(40.0, 75.0, 60.0, 85.0)).is_empty(), "only the handles reach the marquee");
|
||||
// Within the curve's box, above the curve where it leaves (0, 0): at x = 10 it is near y = 42.
|
||||
assert!(crossed_segments(&arch, Rect::new(0.5, 55.0, 10.0, 66.0)).is_empty(), "the curve's box reaches the marquee");
|
||||
assert!(crossed_segments(&arch, Rect::new(40.0, 95.0, 60.0, 105.0)).is_empty(), "the handles stop short of it");
|
||||
}
|
||||
|
||||
/// A segment that touches the marquee's edge is crossed as `hit::intersects_rect` judges it: a
|
||||
/// straight segment along the edge is crossed, and a curve whose top only touches it is not.
|
||||
#[test]
|
||||
fn a_marquee_touching_a_segment_crosses_it_as_the_hit_test_does() {
|
||||
let along = polyline(&[(0.0, 10.0), (100.0, 10.0)], false);
|
||||
let r = Rect::new(40.0, 10.0, 60.0, 20.0);
|
||||
assert_eq!(crossed_segments(&along, r), crossed_by_pieces(&along, r));
|
||||
assert_eq!(crossed_segments(&along, r), vec![(0, 0)], "a straight segment along the edge");
|
||||
// The curve's top is at y = 67.5.
|
||||
let arch = curve((0.0, 0.0), (0.0, 90.0), (100.0, 90.0), (100.0, 0.0));
|
||||
let r = Rect::new(40.0, 67.5, 60.0, 80.0);
|
||||
assert_eq!(crossed_segments(&arch, r), crossed_by_pieces(&arch, r));
|
||||
assert!(crossed_segments(&arch, r).is_empty(), "a curve whose top touches the edge");
|
||||
let r = Rect::new(40.0, 67.0, 60.0, 80.0);
|
||||
assert_eq!(crossed_segments(&arch, r), vec![(0, 0)], "the same curve a little into the marquee");
|
||||
}
|
||||
|
||||
/// Empty and one-anchor paths have no segments to cross; an open path's last anchor has no
|
||||
/// segment after it, a closed path's closing segment is crossed, and each subpath of a compound
|
||||
/// path is tested with its own index.
|
||||
#[test]
|
||||
fn a_marquee_crosses_segments_of_open_closed_and_compound_paths() {
|
||||
let r = Rect::new(-10.0, 40.0, 10.0, 60.0);
|
||||
assert!(crossed_segments(&PathData::default(), r).is_empty(), "an empty path");
|
||||
for closed in [false, true] {
|
||||
assert!(crossed_segments(&polyline(&[(0.0, 50.0)], closed), r).is_empty(), "one anchor (closed: {closed})");
|
||||
}
|
||||
let square = [(0.0, 0.0), (100.0, 0.0), (100.0, 100.0), (0.0, 100.0)];
|
||||
assert!(crossed_segments(&polyline(&square, false), r).is_empty(), "an open path has no segment from its last anchor");
|
||||
assert_eq!(crossed_segments(&polyline(&square, true), r), vec![(0, 3)], "the closing segment");
|
||||
let mut compound = polyline(&square, true);
|
||||
compound.subpaths.extend(polyline(&[(200.0, 0.0), (200.0, 100.0), (300.0, 100.0)], false).subpaths);
|
||||
let r = Rect::new(190.0, 40.0, 210.0, 60.0);
|
||||
assert_eq!(crossed_segments(&compound, r), vec![(1, 0)], "the second subpath");
|
||||
let wide = Rect::new(-10.0, 40.0, 210.0, 60.0);
|
||||
assert_eq!(crossed_segments(&compound, wide), vec![(0, 1), (0, 3), (1, 0)], "both subpaths");
|
||||
}
|
||||
|
||||
/// Over a grid of marquees, zero-width ones and ones touching anchors and edges included,
|
||||
/// `crossed_segments` picks the same segments as testing a path built for each segment.
|
||||
#[test]
|
||||
fn crossed_segments_match_testing_a_path_for_each_segment() {
|
||||
let mut compound = polyline(&[(0.0, 0.0), (100.0, 0.0), (100.0, 100.0), (0.0, 100.0)], true);
|
||||
compound.subpaths.extend(curve((20.0, 20.0), (20.0, 90.0), (80.0, 90.0), (80.0, 20.0)).subpaths);
|
||||
let mut loops = curve((0.0, 50.0), (150.0, -40.0), (-50.0, -40.0), (100.0, 50.0));
|
||||
loops.subpaths[0].closed = true;
|
||||
let paths = [
|
||||
polyline(&[(0.0, 0.0), (100.0, 0.0)], false),
|
||||
polyline(&[(0.0, 10.0), (100.0, 10.0)], false),
|
||||
polyline(&[(0.0, 0.0), (100.0, 100.0)], false),
|
||||
polyline(&[(50.0, -20.0), (50.0, 120.0), (90.0, 30.0)], false),
|
||||
curve((0.0, 0.0), (0.0, 90.0), (100.0, 90.0), (100.0, 0.0)),
|
||||
curve((0.0, 100.0), (30.0, -50.0), (70.0, 150.0), (100.0, 0.0)),
|
||||
loops,
|
||||
compound,
|
||||
polyline(&[(30.0, 30.0)], true),
|
||||
PathData::default(),
|
||||
];
|
||||
let sizes = [0.0, 5.0, 10.0, 35.0, 120.0];
|
||||
let mut crossings = 0;
|
||||
for path in &paths {
|
||||
for x in (-30..=120).step_by(5) {
|
||||
for y in (-30..=120).step_by(5) {
|
||||
for w in sizes {
|
||||
for h in sizes {
|
||||
let r = Rect::new(f64::from(x), f64::from(y), f64::from(x) + w, f64::from(y) + h);
|
||||
let want = crossed_by_pieces(path, r);
|
||||
assert_eq!(crossed_segments(path, r), want, "{r:?} over {path:?}");
|
||||
crossings += want.len();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(crossings > 1000, "the grid crosses segments ({crossings})");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn marquee_selects_ruler_guides() {
|
||||
let (mut d, id) = doc_with_rect();
|
||||
|
||||
@@ -709,6 +709,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
|
||||
In Construction mode, a Selection-tool drag that drops an original shape wholly outside the bounds of the group's original shapes takes it out of the group in the move's undo step (Remove from Shaper Group, `shaper.releaseSource`): the shape becomes a path above the group and stays selected, and a group left with no shapes is removed. Engine tests drag a shape out of a two-shape group and back across the bounds, take the last shape out, undo, and save the result.
|
||||
- **Selection:** Selection, Direct/Group Selection, Magic Wand, Lasso. A Shift-drag marquee toggles what it reaches: objects with the Selection tool, anchors with Direct and Group Selection (#483); the Lasso adds with Shift and subtracts with Alt. Dragging a path segment with Direct Selection bends it when it's curved (its anchors stay) and moves its two anchors when it's straight, one undo step; the fill, Shift or Alt still pick the whole path (Alt-drag copies it).
|
||||
A Direct Selection marquee over part of a segment, with neither of its anchors inside, selects that segment as a click on it does (Shift toggles it), as [Selecting objects](http://web.archive.org/web/20211230015528/https://helpx.adobe.com/illustrator/using/selecting-objects.html) describes (#4654).
|
||||
To find those segments, the marquee skips each segment whose anchors and handles all lie past one side of it and tests only the rest exactly, without building a path for each segment. It selects the same segments as before, and a marquee over a path of many anchors finishes sooner.
|
||||
With the Selection or Group Selection tool, pressing E while dragging a marquee makes the marquee select only the objects that lie entirely inside it. Pressing E again switches the marquee back to its default selection.
|
||||
The Lasso picks anchor points only on art a click can pick, and the Add Anchor Point, Delete Anchor Point, Anchor Point and Scissors tools pick anchor points and segments only on the selected paths and on art a click can pick: not on a Shaper Group's original shapes outside Construction mode, and in isolation mode only inside the isolated group or object. Smart Guides and Snap to Point do not snap to a Shaper Group's original shapes, and the reference point of the transform tools snaps to one only when it is selected, or under the pointer in Construction mode.
|
||||
Enclosing Lasso provides whole-object enclosure selection under Object › Plug-ins and its own heading in All Tools. It has no default shortcut or Basic toolbar slot. It follows path curves and keeps partially enclosed objects out, including objects crossing a concave part of the loop. Ordinary group members can be selected individually; compound paths remain whole. This is a VectorCraft addition; scores and estimates are unchanged.
|
||||
|
||||
Reference in New Issue
Block a user