mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-11 02:19:56 -06:00
@@ -1,9 +1,10 @@
|
||||
//! The tools of VectorCraft's built-in add-ons. They aren't in the toolbars as they ship: the All
|
||||
//! Tools drawer lists them (under their add-on's heading) so they can be added, and each add-on's
|
||||
//! submenu selects them. Like every tool they only emit commands (`addon.<name>.*`).
|
||||
//! submenu selects them. Tool gestures emit engine commands.
|
||||
|
||||
pub mod anchor_eraser;
|
||||
pub mod corner_tool;
|
||||
pub mod enclosing_lasso;
|
||||
pub mod extend_path;
|
||||
pub mod measure_inspect;
|
||||
pub mod parametric_shape;
|
||||
@@ -27,12 +28,14 @@ pub const PATH_TOOLS: &[ToolInfo] = &[
|
||||
/// Smart Pen's tool, a flyout group of its own.
|
||||
pub const SMART_PEN: &[ToolInfo] = &[ToolInfo { id: smart_pen::ID, label: "Smart Pen Tool", shortcut: None, icon: "spline-pointer" }];
|
||||
|
||||
pub const ENCLOSING_LASSO: &[ToolInfo] = &[ToolInfo { id: enclosing_lasso::ID, label: "Enclosing Lasso Tool", shortcut: None, icon: "tool-lasso" }];
|
||||
|
||||
/// The add-ons' tool groups, after the toolbar's own ([`crate::TOOL_GROUPS`]), each with the
|
||||
/// heading the All Tools drawer lists it under (its add-on's name).
|
||||
const HEADED: &[(&[ToolInfo], &str)] = &[(PATH_TOOLS, "Path Precision Tools"), (SMART_PEN, "Smart Pen")];
|
||||
const HEADED: &[(&[ToolInfo], &str)] = &[(PATH_TOOLS, "Path Precision Tools"), (SMART_PEN, "Smart Pen"), (ENCLOSING_LASSO, "Enclosing Lasso")];
|
||||
|
||||
/// The add-ons' tool groups, after the toolbar's own ([`crate::TOOL_GROUPS`]).
|
||||
pub const GROUPS: &[&[ToolInfo]] = &[PATH_TOOLS, SMART_PEN];
|
||||
pub const GROUPS: &[&[ToolInfo]] = &[PATH_TOOLS, SMART_PEN, ENCLOSING_LASSO];
|
||||
|
||||
/// The heading the All Tools drawer lists tool `id` under: its add-on's name.
|
||||
pub fn heading(id: &str) -> Option<&'static str> {
|
||||
@@ -50,6 +53,7 @@ pub fn create(id: &str) -> Option<Box<dyn Tool>> {
|
||||
shared_edge::ID => Box::new(shared_edge::SharedEdgeTool::default()),
|
||||
slide_anchor::ID => Box::new(slide_anchor::SlideAnchorTool::default()),
|
||||
smart_pen::ID => Box::new(smart_pen::SmartPenTool::default()),
|
||||
enclosing_lasso::ID => Box::new(enclosing_lasso::EnclosingLassoTool::default()),
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
@@ -69,6 +73,7 @@ mod tests {
|
||||
}
|
||||
assert!(PATH_TOOLS.iter().all(|t| heading(t.id) == Some("Path Precision Tools")));
|
||||
assert_eq!(heading(smart_pen::ID), Some("Smart Pen"));
|
||||
assert_eq!(heading(enclosing_lasso::ID), Some("Enclosing Lasso"));
|
||||
assert_eq!(GROUPS.len(), HEADED.len());
|
||||
assert!(crate::catalog::all_tools().any(|t| t.id == extend_path::ID));
|
||||
assert!(create("pen").is_none() && heading("pen").is_none());
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
//! Enclosing Lasso selects whole objects by a freehand loop. Path geometry is measured without
|
||||
//! stroke width or effects, as with the Selection tool's enclosed marquee. Other objects use
|
||||
//! their geometric box. Ordinary groups are traversed; compound paths stay one object.
|
||||
|
||||
mod region;
|
||||
|
||||
use serde_json::json;
|
||||
use vectorcraft_doc::hit::enclosure_candidates;
|
||||
use vectorcraft_doc::{AppearanceItem, Node, NodeKind, SymbolBoxes};
|
||||
use vectorcraft_geom::kurbo::{ParamCurve, PathEl};
|
||||
use vectorcraft_geom::{BezPath, FillRule, PathData, Point, Rect, Shape};
|
||||
use vectorcraft_pathops::{BoolOp, DEFAULT_PRECISION, try_boolean};
|
||||
|
||||
use self::region::Region;
|
||||
use crate::xform::{BLUE, polygon};
|
||||
|
||||
use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext, ToolKey, json_ids};
|
||||
|
||||
pub const ID: &str = "superLasso";
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct EnclosingLassoTool {
|
||||
points: Vec<Point>,
|
||||
active: bool,
|
||||
}
|
||||
|
||||
/// Splitting at every crossing catches curves that leave a concave loop between their anchors.
|
||||
fn enclosed(path: &BezPath, region: &Region) -> bool {
|
||||
let mut any = false;
|
||||
// A subpath can be a single anchor with no segments. Check every start, including stray
|
||||
// points beside other subpaths, so a whole object's disconnected points cannot be missed.
|
||||
for el in path.elements() {
|
||||
if let PathEl::MoveTo(p) = el {
|
||||
any = true;
|
||||
if !region.contains(*p) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
for seg in path.segments() {
|
||||
any = true;
|
||||
let mut ts = vec![0.0, 1.0];
|
||||
for line in region.near(seg.bounding_box()) {
|
||||
ts.extend(seg.intersect_line(*line).iter().map(|x| x.segment_t).filter(|t| t.is_finite() && *t > 0.0 && *t < 1.0));
|
||||
}
|
||||
ts.sort_by(f64::total_cmp);
|
||||
ts.dedup();
|
||||
if !ts.iter().all(|t| region.contains(seg.eval(*t))) || !ts.windows(2).all(|w| region.contains(seg.eval((w[0] + w[1]) / 2.0))) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
any
|
||||
}
|
||||
|
||||
fn path_inside(path: &PathData, rule: FillRule, filled: bool, region: &Region) -> bool {
|
||||
if !enclosed(&path.to_bezpath(), region) {
|
||||
return false;
|
||||
}
|
||||
// A self-crossing loop can have a hole wholly inside a filled object without crossing its
|
||||
// outline. The filled difference also checks that interior (and implicit fill closures).
|
||||
!filled || try_boolean(path, rule, ®ion.data, FillRule::EvenOdd, BoolOp::Difference, DEFAULT_PRECISION).is_ok_and(|outside| outside.is_empty())
|
||||
}
|
||||
|
||||
fn node_inside(n: &Node, symbols: &SymbolBoxes, region: &Region) -> bool {
|
||||
let mut bounds = n.geometric_bounds_with(symbols);
|
||||
// Curve bounds omit isolated MoveTo points when other subpaths have segments.
|
||||
let mut include_points = |path: &PathData| {
|
||||
for p in path.subpaths.iter().filter(|sp| sp.segment_count() == 0).filter_map(|sp| sp.anchors.first().map(|a| a.p)) {
|
||||
bounds = Some(bounds.map_or_else(|| Rect::from_points(p, p), |b| b.union_pt(p)));
|
||||
}
|
||||
};
|
||||
match &n.kind {
|
||||
NodeKind::Path { path, .. } => include_points(path),
|
||||
NodeKind::Compound { children, .. } => children.iter().filter_map(|c| c.path_data()).for_each(include_points),
|
||||
_ => {}
|
||||
}
|
||||
// Most candidates lie well inside the loop. A box untouched by its boundary is entirely
|
||||
// inside or outside, so it needs no curve intersections or filled boolean operation.
|
||||
if let Some(covered) = bounds.and_then(|b| region.box_coverage(b)) {
|
||||
return covered;
|
||||
}
|
||||
let filled = n.appearance.items.iter().any(|item| matches!(item, AppearanceItem::Fill(fill) if fill.visible && !fill.paint.is_none()));
|
||||
match &n.kind {
|
||||
NodeKind::Path { path, rule, .. } => path_inside(path, *rule, filled, region),
|
||||
NodeKind::Compound { rule, .. } => n.stroke_path().is_some_and(|path| path_inside(&PathData::from_bezpath(&path), *rule, filled, region)),
|
||||
_ => {
|
||||
n.geometric_bounds_with(symbols).is_some_and(|b| path_inside(&PathData::from_bezpath(&b.to_path(0.01)), FillRule::NonZero, true, region))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Tool for EnclosingLassoTool {
|
||||
fn id(&self) -> &'static str {
|
||||
ID
|
||||
}
|
||||
|
||||
fn busy(&self) -> bool {
|
||||
self.active
|
||||
}
|
||||
|
||||
fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec<Action> {
|
||||
if !ev.pos.is_finite() {
|
||||
if ev.kind == PointerKind::Up {
|
||||
self.active = false;
|
||||
self.points.clear();
|
||||
}
|
||||
return vec![];
|
||||
}
|
||||
match ev.kind {
|
||||
PointerKind::Down => {
|
||||
self.points = vec![ev.pos];
|
||||
self.active = true;
|
||||
}
|
||||
PointerKind::Drag if self.active => {
|
||||
if self.points.last().is_none_or(|p| p.distance(ev.pos) >= cx.tol(1.0)) {
|
||||
// Cancel rather than testing a truncated loop that could select other art.
|
||||
if self.points.len() >= 4096 {
|
||||
self.active = false;
|
||||
self.points.clear();
|
||||
return vec![];
|
||||
}
|
||||
self.points.push(ev.pos);
|
||||
}
|
||||
}
|
||||
PointerKind::Up if self.active => {
|
||||
self.active = false;
|
||||
let mut points = std::mem::take(&mut self.points);
|
||||
if points.last().is_some_and(|p| p.distance(ev.pos) >= cx.tol(1.0)) {
|
||||
points.push(ev.pos);
|
||||
}
|
||||
let symbols = cx.doc.symbol_boxes();
|
||||
let region = (points.len() >= 3).then(|| Region::new(polygon(&points, true))).flatten();
|
||||
let mut ids = if let Some(region) = region {
|
||||
enclosure_candidates(cx.doc, region.path.bounding_box(), cx.isolation)
|
||||
.into_iter()
|
||||
.filter(|id| cx.doc.node(*id).is_some_and(|n| node_inside(n, &symbols, ®ion)))
|
||||
.collect()
|
||||
} else {
|
||||
vec![]
|
||||
};
|
||||
let command = if ev.mods.alt {
|
||||
ids.retain(|id| cx.selection.contains(*id));
|
||||
"select.toggle"
|
||||
} else if ev.mods.shift {
|
||||
"select.add"
|
||||
} else {
|
||||
"select.set"
|
||||
};
|
||||
if ids.is_empty() && (ev.mods.alt || ev.mods.shift) {
|
||||
return vec![];
|
||||
}
|
||||
return vec![Action::Exec(command.into(), json!({"ids": json_ids(&ids)}))];
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
vec![]
|
||||
}
|
||||
|
||||
fn key(&mut self, _cx: &ToolContext, key: ToolKey, _mods: Mods) -> Vec<Action> {
|
||||
if key == ToolKey::Escape {
|
||||
self.active = false;
|
||||
self.points.clear();
|
||||
}
|
||||
vec![]
|
||||
}
|
||||
|
||||
fn overlays(&self, _cx: &ToolContext) -> Vec<Overlay> {
|
||||
if !self.active || self.points.len() < 2 {
|
||||
return vec![];
|
||||
}
|
||||
vec![Overlay::Path { path: polygon(&self.points, true), color: BLUE, width: 1.0, dashed: false }]
|
||||
}
|
||||
|
||||
fn cursor(&self, _cx: &ToolContext, _p: Point, _m: Mods) -> Cursor {
|
||||
Cursor::Lasso
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,93 @@
|
||||
//! A freehand loop prepared once for an Enclosing Lasso selection gesture.
|
||||
|
||||
use vectorcraft_geom::kurbo::{Line, ParamCurveNearest, PathSeg};
|
||||
use vectorcraft_geom::{BezPath, FillRule, PathData, Point, Rect, Shape};
|
||||
use vectorcraft_pathops::try_normalize;
|
||||
|
||||
pub(super) struct Region {
|
||||
pub(super) path: BezPath,
|
||||
pub(super) data: PathData,
|
||||
bounds: Rect,
|
||||
edges: Vec<Line>,
|
||||
rows: Vec<Vec<usize>>,
|
||||
}
|
||||
|
||||
impl Region {
|
||||
pub(super) fn new(path: BezPath) -> Option<Self> {
|
||||
let data = PathData::from_bezpath(&path);
|
||||
// A figure eight can have zero signed area and still enclose two regions.
|
||||
if try_normalize(&data, FillRule::EvenOdd).ok()?.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let bounds = path.bounding_box();
|
||||
if !bounds.width().is_finite() || !bounds.height().is_finite() || bounds.width() <= 0.0 || bounds.height() <= 0.0 {
|
||||
return None;
|
||||
}
|
||||
let edges: Vec<_> = path.segments().map(|s| if let PathSeg::Line(l) = s { Some(l) } else { None }).collect::<Option<_>>()?;
|
||||
let rows = vec![vec![]; edges.len().clamp(1, 256)];
|
||||
let mut region = Self { path, data, bounds, edges, rows };
|
||||
for (i, edge) in region.edges.iter().enumerate() {
|
||||
let first = region.row(edge.p0.y.min(edge.p1.y) - 1e-7);
|
||||
let last = region.row(edge.p0.y.max(edge.p1.y) + 1e-7);
|
||||
for row in region.rows.get_mut(first..=last)? {
|
||||
row.push(i);
|
||||
}
|
||||
}
|
||||
Some(region)
|
||||
}
|
||||
|
||||
fn row(&self, y: f64) -> usize {
|
||||
(((y - self.bounds.y0) / self.bounds.height()) * self.rows.len() as f64).floor().clamp(0.0, self.rows.len() as f64 - 1.0) as usize
|
||||
}
|
||||
|
||||
/// Only one row participates in a horizontal containment ray.
|
||||
pub(super) fn contains(&self, p: Point) -> bool {
|
||||
if !p.is_finite() || p.x < self.bounds.x0 - 1e-7 || p.x > self.bounds.x1 + 1e-7 || p.y < self.bounds.y0 - 1e-7 || p.y > self.bounds.y1 + 1e-7
|
||||
{
|
||||
return false;
|
||||
}
|
||||
let mut inside = false;
|
||||
for edge in self.rows.get(self.row(p.y)).into_iter().flatten().filter_map(|i| self.edges.get(*i)) {
|
||||
if edge.nearest(p, 1e-9).distance_sq <= 1e-14 {
|
||||
return true;
|
||||
}
|
||||
let (a, b) = (edge.p0, edge.p1);
|
||||
if (a.y > p.y) != (b.y > p.y) && p.x < (b.x - a.x) * (p.y - a.y) / (b.y - a.y) + a.x {
|
||||
inside = !inside;
|
||||
}
|
||||
}
|
||||
inside
|
||||
}
|
||||
|
||||
/// Edges near a box, conservatively, each returned only in its first queried row.
|
||||
pub(super) fn near(&self, b: Rect) -> impl Iterator<Item = &Line> {
|
||||
let first = self.row(b.y0 - 1e-7);
|
||||
let last = self.row(b.y1 + 1e-7);
|
||||
self.rows
|
||||
.get(first..=last)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.enumerate()
|
||||
.flat_map(move |(offset, row)| {
|
||||
row.iter().filter_map(move |i| {
|
||||
let edge = self.edges.get(*i)?;
|
||||
let start = self.row(edge.p0.y.min(edge.p1.y) - 1e-7);
|
||||
(first + offset == first.max(start)).then_some(edge)
|
||||
})
|
||||
})
|
||||
.filter(move |e| {
|
||||
e.p0.x.min(e.p1.x) <= b.x1 + 1e-7
|
||||
&& e.p0.x.max(e.p1.x) >= b.x0 - 1e-7
|
||||
&& e.p0.y.min(e.p1.y) <= b.y1 + 1e-7
|
||||
&& e.p0.y.max(e.p1.y) >= b.y0 - 1e-7
|
||||
})
|
||||
}
|
||||
|
||||
/// Without a boundary through the box, all of it lies on the same side of the loop.
|
||||
pub(super) fn box_coverage(&self, b: Rect) -> Option<bool> {
|
||||
if !b.is_finite() {
|
||||
return Some(false);
|
||||
}
|
||||
self.near(b).next().is_none().then(|| self.contains(b.origin()))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,369 @@
|
||||
use super::*;
|
||||
use crate::testutil::*;
|
||||
use vectorcraft_doc::{Appearance, Document, NodeId, Selection};
|
||||
use vectorcraft_geom::{Rect, shapes};
|
||||
|
||||
fn gesture(d: &Document, s: &Selection, points: &[(f64, f64)], mods: Mods, isolation: Option<NodeId>) -> Vec<Action> {
|
||||
let p = paint();
|
||||
let cx = ToolContext { isolation, ..cx(d, s, &p) };
|
||||
let mut tool = crate::create(ID);
|
||||
assert_eq!(tool.id(), ID);
|
||||
let &(x, y) = points.first().unwrap();
|
||||
tool.pointer(&cx, &PointerEvent::new(PointerKind::Down, x, y));
|
||||
for &(x, y) in points.iter().skip(1) {
|
||||
tool.pointer(&cx, &PointerEvent::new(PointerKind::Drag, x, y));
|
||||
}
|
||||
let &(x, y) = points.last().unwrap();
|
||||
tool.pointer(&cx, &PointerEvent::new(PointerKind::Up, x, y).with_mods(mods))
|
||||
}
|
||||
|
||||
const AROUND: &[(f64, f64)] = &[(90.0, 90.0), (210.0, 90.0), (210.0, 210.0), (90.0, 210.0)];
|
||||
|
||||
#[test]
|
||||
fn enclosing_lasso_selects_only_whole_objects_and_preserves_modifier_rules() {
|
||||
let (d, id) = doc_with_rect();
|
||||
let mut s = Selection::default();
|
||||
assert_eq!(gesture(&d, &s, AROUND, Mods::default(), None), vec![Action::Exec("select.set".into(), json!({"ids": [id.0]}))]);
|
||||
let corner = &[(90.0, 90.0), (150.0, 90.0), (90.0, 150.0)];
|
||||
assert_eq!(gesture(&d, &s, corner, Mods::default(), None), vec![Action::Exec("select.set".into(), json!({"ids": []}))]);
|
||||
let shift = Mods { shift: true, ..Mods::default() };
|
||||
let alt = Mods { alt: true, ..Mods::default() };
|
||||
assert_eq!(gesture(&d, &s, AROUND, shift, None), vec![Action::Exec("select.add".into(), json!({"ids": [id.0]}))]);
|
||||
assert!(gesture(&d, &s, AROUND, alt, None).is_empty());
|
||||
s.objects.push(id);
|
||||
assert_eq!(gesture(&d, &s, AROUND, alt, None), vec![Action::Exec("select.toggle".into(), json!({"ids": [id.0]}))]);
|
||||
assert!(gesture(&d, &s, corner, shift, None).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enclosing_lasso_checks_curves_and_concave_edges_between_inside_anchors() {
|
||||
let region = crate::xform::polygon(&[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);
|
||||
let region = Region::new(region).unwrap();
|
||||
let mut curve = BezPath::new();
|
||||
curve.move_to((2.0, 5.0));
|
||||
curve.curve_to((3.0, -15.0), (7.0, -15.0), (8.0, 5.0));
|
||||
assert!(!enclosed(&curve, ®ion), "inside endpoints do not contain a curve that bulges outside");
|
||||
let notch = crate::xform::polygon(
|
||||
&[
|
||||
Point::new(0.0, 0.0),
|
||||
Point::new(10.0, 0.0),
|
||||
Point::new(10.0, 10.0),
|
||||
Point::new(6.0, 10.0),
|
||||
Point::new(6.0, 4.0),
|
||||
Point::new(4.0, 4.0),
|
||||
Point::new(4.0, 10.0),
|
||||
Point::new(0.0, 10.0),
|
||||
],
|
||||
true,
|
||||
);
|
||||
let line = |a, b| {
|
||||
let mut p = BezPath::new();
|
||||
p.move_to(a);
|
||||
p.line_to(b);
|
||||
p
|
||||
};
|
||||
let notch = Region::new(notch).unwrap();
|
||||
assert!(!enclosed(&line((2.0, 8.0), (8.0, 8.0)), ¬ch));
|
||||
assert!(enclosed(&line((2.0, 2.0), (8.0, 2.0)), ¬ch));
|
||||
assert!(enclosed(&Rect::new(0.0, 0.0, 10.0, 10.0).to_path(0.01), ®ion), "boundary contact is enclosed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enclosing_lasso_uses_path_geometry_instead_of_its_bounding_box() {
|
||||
let (mut d, id) = doc_with_rect();
|
||||
let mut triangle = BezPath::new();
|
||||
triangle.move_to((100.0, 100.0));
|
||||
triangle.line_to((190.0, 100.0));
|
||||
triangle.line_to((100.0, 190.0));
|
||||
triangle.close_path();
|
||||
let NodeKind::Path { path, .. } = &mut d.node_mut(id).unwrap().kind else { panic!() };
|
||||
*path = PathData::from_bezpath(&triangle);
|
||||
let loop_points = &[(90.0, 90.0), (210.0, 90.0), (90.0, 210.0)];
|
||||
assert_eq!(
|
||||
gesture(&d, &Selection::default(), loop_points, Mods::default(), None),
|
||||
vec![Action::Exec("select.set".into(), json!({"ids": [id.0]}))]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enclosing_lasso_skips_locked_hidden_and_out_of_isolation_objects() {
|
||||
let (mut d, id) = doc_with_rect();
|
||||
let s = Selection::default();
|
||||
d.node_mut(id).unwrap().locked = true;
|
||||
assert_eq!(gesture(&d, &s, AROUND, Mods::default(), None), vec![Action::Exec("select.set".into(), json!({"ids": []}))]);
|
||||
d.node_mut(id).unwrap().locked = false;
|
||||
d.node_mut(id).unwrap().visible = false;
|
||||
assert_eq!(gesture(&d, &s, AROUND, Mods::default(), None), vec![Action::Exec("select.set".into(), json!({"ids": []}))]);
|
||||
d.node_mut(id).unwrap().visible = true;
|
||||
let other = d.alloc_id();
|
||||
d.insert(Some(d.layers[0].id), 1, Node::path(other, shapes::rectangle(Rect::new(300.0, 300.0, 350.0, 350.0)), Appearance::default_art()))
|
||||
.unwrap();
|
||||
assert_eq!(gesture(&d, &s, AROUND, Mods::default(), Some(other)), vec![Action::Exec("select.set".into(), json!({"ids": []}))]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enclosing_lasso_escape_cancels_without_changing_selection() {
|
||||
let (d, _) = doc_with_rect();
|
||||
let (s, p) = (Selection::default(), paint());
|
||||
let cx = cx(&d, &s, &p);
|
||||
let mut tool = EnclosingLassoTool::default();
|
||||
tool.pointer(&cx, &PointerEvent::new(PointerKind::Down, 90.0, 90.0));
|
||||
tool.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 210.0, 90.0));
|
||||
assert!(tool.busy());
|
||||
assert_eq!(tool.overlays(&cx).len(), 1);
|
||||
assert!(tool.key(&cx, ToolKey::Escape, Mods::default()).is_empty());
|
||||
assert!(!tool.busy());
|
||||
assert!(tool.overlays(&cx).is_empty());
|
||||
assert!(tool.pointer(&cx, &PointerEvent::new(PointerKind::Up, 90.0, 210.0)).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enclosing_lasso_degenerate_loop_does_not_select_a_boundary_line() {
|
||||
let (mut d, id) = doc_with_rect();
|
||||
let mut line = BezPath::new();
|
||||
line.move_to((100.0, 100.0));
|
||||
line.line_to((200.0, 200.0));
|
||||
let n = d.node_mut(id).unwrap();
|
||||
*n.path_data_mut().unwrap() = PathData::from_bezpath(&line);
|
||||
n.appearance.set_fill(vectorcraft_color::Paint::None);
|
||||
let points = &[(90.0, 90.0), (150.0, 150.0), (210.0, 210.0)];
|
||||
assert_eq!(gesture(&d, &Selection::default(), points, Mods::default(), None), vec![Action::Exec("select.set".into(), json!({"ids": []}))]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enclosing_lasso_invalid_release_cancels_the_gesture() {
|
||||
let (d, _) = doc_with_rect();
|
||||
let (s, p) = (Selection::default(), paint());
|
||||
let cx = cx(&d, &s, &p);
|
||||
let mut tool = EnclosingLassoTool::default();
|
||||
tool.pointer(&cx, &PointerEvent::new(PointerKind::Down, 90.0, 90.0));
|
||||
tool.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 210.0, 90.0));
|
||||
assert!(tool.pointer(&cx, &PointerEvent::new(PointerKind::Up, f64::NAN, 210.0)).is_empty());
|
||||
assert!(!tool.busy());
|
||||
assert!(tool.overlays(&cx).is_empty());
|
||||
}
|
||||
|
||||
fn loop_with_hole() -> BezPath {
|
||||
crate::xform::polygon(
|
||||
&[
|
||||
Point::new(0.0, 0.0),
|
||||
Point::new(10.0, 0.0),
|
||||
Point::new(10.0, 10.0),
|
||||
Point::new(0.0, 10.0),
|
||||
Point::new(0.0, 0.0),
|
||||
Point::new(3.0, 3.0),
|
||||
Point::new(3.0, 7.0),
|
||||
Point::new(7.0, 7.0),
|
||||
Point::new(7.0, 3.0),
|
||||
Point::new(3.0, 3.0),
|
||||
Point::new(0.0, 0.0),
|
||||
],
|
||||
true,
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enclosing_lasso_rejects_filled_objects_covering_a_hole_in_the_loop() {
|
||||
let region = Region::new(loop_with_hole()).unwrap();
|
||||
let path = shapes::rectangle(Rect::new(2.0, 2.0, 8.0, 8.0));
|
||||
assert!(enclosed(&path.to_bezpath(), ®ion), "the hole does not cross the object's outline");
|
||||
assert!(!path_inside(&path, FillRule::NonZero, true, ®ion));
|
||||
assert!(path_inside(&path, FillRule::NonZero, false, ®ion), "an unfilled outline may surround the hole");
|
||||
let mut node = Node::path(NodeId(10), path, Appearance::default_art());
|
||||
let doc = Document::new(20.0, 20.0);
|
||||
let symbols = doc.symbol_boxes();
|
||||
assert!(!node_inside(&node, &symbols, ®ion));
|
||||
for item in &mut node.appearance.items {
|
||||
if let AppearanceItem::Fill(fill) = item {
|
||||
fill.visible = false;
|
||||
}
|
||||
}
|
||||
assert!(node_inside(&node, &symbols, ®ion), "a hidden fill does not cover the loop's hole");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enclosing_lasso_selects_group_members_but_keeps_compounds_whole() {
|
||||
let (mut d, id) = doc_with_rect();
|
||||
let group = d.alloc_id();
|
||||
let a = d.alloc_id();
|
||||
let b = d.alloc_id();
|
||||
let children = vec![
|
||||
std::sync::Arc::new(Node::path(a, shapes::rectangle(Rect::new(120.0, 120.0, 140.0, 140.0)), Appearance::default_art())),
|
||||
std::sync::Arc::new(Node::path(b, shapes::rectangle(Rect::new(300.0, 300.0, 320.0, 320.0)), Appearance::default_art())),
|
||||
];
|
||||
d.insert(Some(d.layers[0].id), 1, Node::new(group, NodeKind::Group { children: children.clone(), clip: false })).unwrap();
|
||||
let s = Selection::default();
|
||||
assert_eq!(gesture(&d, &s, AROUND, Mods::default(), None), vec![Action::Exec("select.set".into(), json!({"ids": [id.0, a.0]}))]);
|
||||
d.node_mut(group).unwrap().kind = NodeKind::Compound { children, rule: FillRule::NonZero };
|
||||
assert_eq!(gesture(&d, &s, AROUND, Mods::default(), None), vec![Action::Exec("select.set".into(), json!({"ids": [id.0]}))]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enclosing_lasso_accepts_a_figure_eight_with_zero_signed_area() {
|
||||
let (mut d, id) = doc_with_rect();
|
||||
*d.node_mut(id).unwrap().path_data_mut().unwrap() = shapes::rectangle(Rect::new(130.0, 105.0, 170.0, 115.0));
|
||||
let points = &[(90.0, 90.0), (210.0, 210.0), (90.0, 210.0), (210.0, 90.0)];
|
||||
assert_eq!(gesture(&d, &Selection::default(), points, Mods::default(), None), vec![Action::Exec("select.set".into(), json!({"ids": [id.0]}))]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enclosing_lasso_checks_nonpath_box_interiors_as_well_as_edges() {
|
||||
let (d, _) = doc_with_rect();
|
||||
let mut n = Node::new(NodeId(900), NodeKind::SymbolInstance { symbol: "missing".into(), xf: vectorcraft_geom::Affine::IDENTITY });
|
||||
// Place the missing-symbol fallback box exactly inside the outer loop.
|
||||
let b = n.geometric_bounds().unwrap();
|
||||
let xf = vectorcraft_geom::Affine::translate((2.0, 2.0))
|
||||
* vectorcraft_geom::Affine::scale_non_uniform(6.0 / b.width(), 6.0 / b.height())
|
||||
* vectorcraft_geom::Affine::translate((-b.x0, -b.y0));
|
||||
n.kind = NodeKind::SymbolInstance { symbol: "missing".into(), xf };
|
||||
let region = Region::new(loop_with_hole()).unwrap();
|
||||
assert!(!node_inside(&n, &d.symbol_boxes(), ®ion));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enclosing_lasso_uses_the_release_position_and_click_clears_only_without_modifiers() {
|
||||
let (d, id) = doc_with_rect();
|
||||
let (s, p) = (Selection::default(), paint());
|
||||
let cx = cx(&d, &s, &p);
|
||||
let mut tool = EnclosingLassoTool::default();
|
||||
tool.pointer(&cx, &PointerEvent::new(PointerKind::Down, 90.0, 90.0));
|
||||
tool.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 210.0, 90.0));
|
||||
tool.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 210.0, 210.0));
|
||||
assert_eq!(tool.pointer(&cx, &PointerEvent::new(PointerKind::Up, 90.0, 210.0)), vec![Action::Exec("select.set".into(), json!({"ids": [id.0]}))]);
|
||||
assert!(!tool.busy());
|
||||
assert!(tool.overlays(&cx).is_empty());
|
||||
assert_eq!(gesture(&d, &s, &[(90.0, 90.0)], Mods::default(), None), vec![Action::Exec("select.set".into(), json!({"ids": []}))]);
|
||||
assert!(gesture(&d, &s, &[(90.0, 90.0)], Mods { shift: true, ..Mods::default() }, None).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enclosing_lasso_selects_single_anchor_objects_but_checks_every_disconnected_point() {
|
||||
let (mut d, id) = doc_with_rect();
|
||||
let mut point = BezPath::new();
|
||||
point.move_to((150.0, 150.0));
|
||||
*d.node_mut(id).unwrap().path_data_mut().unwrap() = PathData::from_bezpath(&point);
|
||||
assert_eq!(gesture(&d, &Selection::default(), AROUND, Mods::default(), None), vec![Action::Exec("select.set".into(), json!({"ids": [id.0]}))]);
|
||||
let mut path = shapes::rectangle(Rect::new(120.0, 120.0, 140.0, 140.0)).to_bezpath();
|
||||
path.move_to((300.0, 300.0));
|
||||
*d.node_mut(id).unwrap().path_data_mut().unwrap() = PathData::from_bezpath(&path);
|
||||
assert_eq!(gesture(&d, &Selection::default(), AROUND, Mods::default(), None), vec![Action::Exec("select.set".into(), json!({"ids": []}))]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enclosing_lasso_cancels_an_overlong_gesture_instead_of_silently_truncating_it() {
|
||||
let (d, _) = doc_with_rect();
|
||||
let (s, p) = (Selection::default(), paint());
|
||||
let cx = cx(&d, &s, &p);
|
||||
let mut tool = EnclosingLassoTool::default();
|
||||
tool.pointer(&cx, &PointerEvent::new(PointerKind::Down, 0.0, 0.0));
|
||||
for i in 1..=4096 {
|
||||
assert!(tool.pointer(&cx, &PointerEvent::new(PointerKind::Drag, f64::from(i), 100.0)).is_empty());
|
||||
}
|
||||
assert!(!tool.busy());
|
||||
assert!(tool.overlays(&cx).is_empty());
|
||||
assert!(tool.pointer(&cx, &PointerEvent::new(PointerKind::Up, 0.0, 200.0)).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "manual large-document selection timing"]
|
||||
fn enclosing_lasso_dense_document_timing() {
|
||||
let count = std::env::var("ENCLOSING_LASSO_BENCH_OBJECTS").ok().and_then(|v| v.parse::<usize>().ok()).unwrap_or(1000).clamp(1, 50_000);
|
||||
let d = Document::new(1000.0, 1000.0);
|
||||
let symbols = d.symbol_boxes();
|
||||
let nodes: Vec<_> = (0..count)
|
||||
.map(|i| {
|
||||
let (x, y) = if count <= 1000 {
|
||||
(350.0 + (i % 25) as f64 * 5.0, 350.0 + (i / 25) as f64 * 5.0)
|
||||
} else {
|
||||
(250.0 + (i % 250) as f64 * 2.0, 300.0 + (i / 250) as f64 * 2.0)
|
||||
};
|
||||
Node::path(NodeId(i as u64 + 10), shapes::rectangle(Rect::new(x, y, x + 2.0, y + 2.0)), Appearance::default_art())
|
||||
})
|
||||
.collect();
|
||||
let points: Vec<_> = (0..1000)
|
||||
.map(|i| {
|
||||
let angle = f64::from(i) * std::f64::consts::TAU / 1000.0;
|
||||
Point::new(500.0 + angle.cos() * 450.0, 500.0 + angle.sin() * 450.0)
|
||||
})
|
||||
.collect();
|
||||
let start = std::time::Instant::now();
|
||||
let region = Region::new(crate::xform::polygon(&points, true)).unwrap();
|
||||
let selected = nodes.iter().filter(|n| node_inside(n, &symbols, ®ion)).count();
|
||||
let elapsed = start.elapsed();
|
||||
assert_eq!(selected, count);
|
||||
eprintln!("Enclosing Lasso: {selected} objects, 1000-point loop, {} microseconds", elapsed.as_micros());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enclosing_lasso_selection_does_not_change_when_a_concave_loop_has_more_samples() {
|
||||
let (d, _) = doc_with_rect();
|
||||
let sparse = [(90.0, 90.0), (210.0, 90.0), (210.0, 210.0), (160.0, 210.0), (160.0, 150.0), (140.0, 150.0), (140.0, 210.0), (90.0, 210.0)];
|
||||
let mut dense = vec![];
|
||||
for (a, b) in sparse.iter().zip(sparse.iter().cycle().skip(1)) {
|
||||
let (a, b) = (Point::new(a.0, a.1), Point::new(b.0, b.1));
|
||||
for i in 0..40 {
|
||||
let p = a.lerp(b, f64::from(i) / 40.0);
|
||||
dense.push((p.x, p.y));
|
||||
}
|
||||
}
|
||||
let s = Selection::default();
|
||||
assert_eq!(gesture(&d, &s, &sparse, Mods::default(), None), vec![Action::Exec("select.set".into(), json!({"ids": []}))]);
|
||||
assert_eq!(gesture(&d, &s, &dense, Mods::default(), None), gesture(&d, &s, &sparse, Mods::default(), None));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enclosing_lasso_boundary_query_does_not_repeat_an_edge_across_its_height() {
|
||||
let region = Region::new(Rect::new(0.0, 0.0, 10.0, 10.0).to_path(0.01)).unwrap();
|
||||
assert_eq!(
|
||||
region.near(Rect::new(0.0, 0.0, 10.0, 10.0)).count(),
|
||||
4,
|
||||
"one intersection check per boundary edge, even for a query spanning its full height"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enclosing_lasso_considers_the_geometry_a_visible_compound_paints() {
|
||||
let (mut d, rect) = doc_with_rect();
|
||||
let compound = d.alloc_id();
|
||||
let mut children = vec![];
|
||||
for x in [120.0, 160.0] {
|
||||
let mut member = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(x, 120.0, x + 20.0, 140.0)), Appearance::default_art());
|
||||
member.visible = false;
|
||||
children.push(std::sync::Arc::new(member));
|
||||
}
|
||||
// Compound painting assembles all member paths, independently of their own visibility flags.
|
||||
let mut object = Node::new(compound, NodeKind::Compound { children, rule: FillRule::NonZero });
|
||||
object.appearance = Appearance::default_art().into();
|
||||
d.insert(Some(d.layers[0].id), 1, object).unwrap();
|
||||
let s = Selection::default();
|
||||
assert_eq!(
|
||||
vectorcraft_doc::hit::marquee(&d, Rect::new(90.0, 90.0, 210.0, 210.0), None, true),
|
||||
vec![rect],
|
||||
"the existing rectangular marquee rule is unchanged"
|
||||
);
|
||||
assert_eq!(gesture(&d, &s, AROUND, Mods::default(), None), vec![Action::Exec("select.set".into(), json!({"ids": [rect.0, compound.0]}))]);
|
||||
d.node_mut(compound).unwrap().locked = true;
|
||||
assert_eq!(gesture(&d, &s, AROUND, Mods::default(), None), vec![Action::Exec("select.set".into(), json!({"ids": [rect.0]}))]);
|
||||
d.node_mut(compound).unwrap().locked = false;
|
||||
d.node_mut(compound).unwrap().visible = false;
|
||||
assert_eq!(gesture(&d, &s, AROUND, Mods::default(), None), vec![Action::Exec("select.set".into(), json!({"ids": [rect.0]}))]);
|
||||
d.node_mut(compound).unwrap().visible = true;
|
||||
let NodeKind::Compound { children, .. } = &mut d.node_mut(compound).unwrap().kind else { panic!() };
|
||||
*std::sync::Arc::make_mut(&mut children[1]).path_data_mut().unwrap() = shapes::rectangle(Rect::new(300.0, 120.0, 320.0, 140.0));
|
||||
assert_eq!(
|
||||
gesture(&d, &s, AROUND, Mods::default(), None),
|
||||
vec![Action::Exec("select.set".into(), json!({"ids": [rect.0]}))],
|
||||
"every member contributes to the compound's geometry"
|
||||
);
|
||||
for visible in [[true, false], [false, true], [true, true], [false, false]] {
|
||||
let NodeKind::Compound { children, .. } = &mut d.node_mut(compound).unwrap().kind else { panic!() };
|
||||
for (child, visible) in children.iter_mut().zip(visible) {
|
||||
std::sync::Arc::make_mut(child).visible = visible;
|
||||
}
|
||||
assert_eq!(
|
||||
gesture(&d, &s, AROUND, Mods::default(), None),
|
||||
vec![Action::Exec("select.set".into(), json!({"ids": [rect.0]}))],
|
||||
"a compound with an outside member is not enclosed: {visible:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -672,7 +672,7 @@ hotspot = "The tip."
|
||||
[[cursor]]
|
||||
id = "Lasso"
|
||||
group = "Drawing and editing tools"
|
||||
shown_when = ["The Lasso tool: drag a loop round anchor points and segments to select them; Shift adds to the selection, Alt takes from it."]
|
||||
shown_when = ["The Lasso tool: drag a loop round anchor points and segments to select them; Shift adds to the selection, Alt takes from it.", "The Enclosing Lasso tool: draw a loop to select fully enclosed objects; Shift adds, Alt subtracts."]
|
||||
hotspot = "Where the loop is drawn from."
|
||||
|
||||
[[cursor]]
|
||||
|
||||
@@ -538,6 +538,7 @@ mod cursor_tests {
|
||||
let away = Point::new(400.0, 400.0);
|
||||
for (id, c) in [
|
||||
("lasso", Cursor::Lasso),
|
||||
(addons::enclosing_lasso::ID, Cursor::Lasso),
|
||||
("magicWand", Cursor::MagicWand),
|
||||
("paintbrush", Cursor::Paintbrush),
|
||||
("blobBrush", Cursor::BlobBrush),
|
||||
|
||||
Reference in New Issue
Block a user