diff --git a/crates/doc/src/hit.rs b/crates/doc/src/hit.rs index 573d8fec4..cb972b94f 100644 --- a/crates/doc/src/hit.rs +++ b/crates/doc/src/hit.rs @@ -880,6 +880,16 @@ pub fn compound_part_at(n: &Node, p: Point, tol: f64) -> Option { /// clipping mask counts only when the marquee meets the region its clipping path clips to, and then /// only within that region's bounds. pub fn marquee(doc: &Document, r: Rect, scope: Option, leaves: bool) -> Vec { + marquee_with(doc, r, scope, leaves, false) +} + +/// Candidates for whole-object enclosure by a loop bounded by `r`. Eligibility and group +/// traversal follow [`marquee`]; compounds contribute the complete path they paint. +pub fn enclosure_candidates(doc: &Document, r: Rect, scope: Option) -> Vec { + marquee_with(doc, r, scope, true, true) +} + +fn marquee_with(doc: &Document, r: Rect, scope: Option, leaves: bool, whole_compounds: bool) -> Vec { let mut out = Vec::new(); // `tops` pairs each object with the part of `r` in which the marquee can touch it. let tops: Vec<(&std::sync::Arc, Rect)> = match scope.and_then(|s| doc.node(s)) { @@ -924,13 +934,19 @@ pub fn marquee(doc: &Document, r: Rect, scope: Option, leaves: bool) -> /// ([`shaper_hides`]). An Intertwine group is touched by its members, and a repeat is touched /// by the copies it draws, as clicks hit them. Objects of every other kind except paths, /// compound paths, layers and groups are touched by their box. - fn touches(n: &Node, s: &SymbolBoxes, r: Rect, scope: Option) -> bool { + fn touches(n: &Node, s: &SymbolBoxes, r: Rect, scope: Option, whole_compounds: bool) -> bool { match &n.kind { NodeKind::Path { path, .. } => vectorcraft_geom::hit::intersects_rect(path, r), - NodeKind::Layer { children, .. } | NodeKind::Group { children, .. } | NodeKind::Intertwine { children } => marquee_in(n, r) - .is_some_and(|r| children.iter().enumerate().any(|(i, c)| c.visible && !shaper_hides(n, i, scope) && touches(c, s, r, scope))), - NodeKind::Compound { children, .. } => children.iter().any(|c| c.visible && touches(c, s, r, scope)), - NodeKind::Repeat(spec) => spec.expand(Some(s.document())).iter().any(|copy| touches(copy, s, r, scope)), + NodeKind::Layer { children, .. } | NodeKind::Group { children, .. } | NodeKind::Intertwine { children } => { + marquee_in(n, r).is_some_and(|r| { + children.iter().enumerate().any(|(i, c)| c.visible && !shaper_hides(n, i, scope) && touches(c, s, r, scope, whole_compounds)) + }) + } + NodeKind::Compound { children, .. } if whole_compounds => { + children.iter().filter_map(|c| c.path_data()).any(|path| vectorcraft_geom::hit::intersects_rect(path, r)) + } + NodeKind::Compound { children, .. } => children.iter().any(|c| c.visible && touches(c, s, r, scope, whole_compounds)), + NodeKind::Repeat(spec) => spec.expand(Some(s.document())).iter().any(|copy| touches(copy, s, r, scope, whole_compounds)), // A box with no width or no height (an instance of a symbol that draws a straight // line) is touched where the marquee crosses it. _ => n.geometric_bounds_with(s).is_some_and(|b| { @@ -939,18 +955,18 @@ pub fn marquee(doc: &Document, r: Rect, scope: Option, leaves: bool) -> }), } } - fn collect_leaves(n: &Node, s: &SymbolBoxes, r: Rect, scope: Option, out: &mut Vec) { + fn collect_leaves(n: &Node, s: &SymbolBoxes, r: Rect, scope: Option, whole_compounds: bool, out: &mut Vec) { match &n.kind { NodeKind::Layer { children, .. } | NodeKind::Group { children, .. } | NodeKind::Intertwine { children } => { let Some(r) = marquee_in(n, r) else { return }; for (i, c) in children.iter().enumerate() { if c.visible && !c.locked && !shaper_hides(n, i, scope) { - collect_leaves(c, s, r, scope, out); + collect_leaves(c, s, r, scope, whole_compounds, out); } } } _ => { - if touches(n, s, r, scope) { + if touches(n, s, r, scope, whole_compounds) { out.push(n.id); } } @@ -963,8 +979,8 @@ pub fn marquee(doc: &Document, r: Rect, scope: Option, leaves: bool) -> continue; } if leaves { - collect_leaves(t, &symbols, r, scope, &mut out); - } else if touches(t, &symbols, r, scope) { + collect_leaves(t, &symbols, r, scope, whole_compounds, &mut out); + } else if touches(t, &symbols, r, scope, whole_compounds) { out.push(t.id); } } diff --git a/crates/mcp/src/headless.rs b/crates/mcp/src/headless.rs index 5ed71fcc6..64a7f0ea1 100644 --- a/crates/mcp/src/headless.rs +++ b/crates/mcp/src/headless.rs @@ -352,7 +352,10 @@ impl Headless { "document.node" => self.exec("document.node", p), "document.json" => self.exec("document.json", &json!({})), "ui.tool.select" => self.select_tool(p), - "ui.tool.list" => Ok(serde_json::to_value(TOOL_GROUPS).unwrap_or_default()), + "ui.tool.list" => { + let groups: Vec<_> = TOOL_GROUPS.iter().chain(vectorcraft_tools::addons::GROUPS).copied().collect(); + Ok(serde_json::to_value(groups).unwrap_or_default()) + } "ui.pointer" => self.pointer(p), "ui.key" => self.key(p), "ui.render" | "ui.screenshot" => self.render(p), diff --git a/crates/mcp/src/tests.rs b/crates/mcp/src/tests.rs index f61b8839c..2e153b62b 100644 --- a/crates/mcp/src/tests.rs +++ b/crates/mcp/src/tests.rs @@ -1297,7 +1297,16 @@ fn headless_backend_methods() { assert_eq!((r["width"].as_u64(), r["height"].as_u64()), (Some(600), Some(400))); let cmds = h.call("engine.commands", json!({})).unwrap(); assert!(cmds.as_array().unwrap().iter().any(|c| c["id"] == "file.export")); - assert!(h.call("ui.tool.list", json!({})).unwrap().is_array()); + let listed = h.call("ui.tool.list", json!({})).unwrap(); + let groups = listed.as_array().unwrap(); + for tool in vectorcraft_tools::addons::GROUPS.iter().flat_map(|group| group.iter()) { + let matching: Vec<_> = groups.iter().flat_map(|group| group.as_array().unwrap()).filter(|entry| entry["id"] == tool.id).collect(); + assert_eq!(matching.len(), 1, "{}", tool.id); + assert_eq!(matching[0]["label"], tool.label); + assert!(matching[0]["shortcut"].is_null()); + let selected = h.call("ui.tool.select", json!({"tool": tool.id})).unwrap(); + assert_eq!(selected["tool"], tool.id); + } assert!(h.call("ui.resize", json!({})).is_err()); } diff --git a/crates/tools/src/addons.rs b/crates/tools/src/addons.rs index aff7069a5..42643538e 100644 --- a/crates/tools/src/addons.rs +++ b/crates/tools/src/addons.rs @@ -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..*`). +//! 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> { 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()); diff --git a/crates/tools/src/addons/enclosing_lasso.rs b/crates/tools/src/addons/enclosing_lasso.rs new file mode 100644 index 000000000..cd9b0a324 --- /dev/null +++ b/crates/tools/src/addons/enclosing_lasso.rs @@ -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, + 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 { + 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 { + if key == ToolKey::Escape { + self.active = false; + self.points.clear(); + } + vec![] + } + + fn overlays(&self, _cx: &ToolContext) -> Vec { + 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; diff --git a/crates/tools/src/addons/enclosing_lasso/region.rs b/crates/tools/src/addons/enclosing_lasso/region.rs new file mode 100644 index 000000000..fdf4ac8b5 --- /dev/null +++ b/crates/tools/src/addons/enclosing_lasso/region.rs @@ -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, + rows: Vec>, +} + +impl Region { + pub(super) fn new(path: BezPath) -> Option { + 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::>()?; + 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 { + 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 { + if !b.is_finite() { + return Some(false); + } + self.near(b).next().is_none().then(|| self.contains(b.origin())) + } +} diff --git a/crates/tools/src/addons/enclosing_lasso/tests.rs b/crates/tools/src/addons/enclosing_lasso/tests.rs new file mode 100644 index 000000000..517461352 --- /dev/null +++ b/crates/tools/src/addons/enclosing_lasso/tests.rs @@ -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) -> Vec { + 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::().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:?}" + ); + } +} diff --git a/crates/tools/src/cursors.toml b/crates/tools/src/cursors.toml index 5447261e6..0ac3e610a 100644 --- a/crates/tools/src/cursors.toml +++ b/crates/tools/src/cursors.toml @@ -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]] diff --git a/crates/tools/src/lib.rs b/crates/tools/src/lib.rs index 7aa338ab4..bf2edac34 100644 --- a/crates/tools/src/lib.rs +++ b/crates/tools/src/lib.rs @@ -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), diff --git a/crates/ui-egui/src/addons.rs b/crates/ui-egui/src/addons.rs index 548a8c7da..92895fea0 100644 --- a/crates/ui-egui/src/addons.rs +++ b/crates/ui-egui/src/addons.rs @@ -1,10 +1,10 @@ -//! VectorCraft's built-in add-ons in the interface: each has a panel and a submenu at the top of -//! Object › Plug-ins, and acts through its engine commands (`addon..*`). +//! The module defines VectorCraft's built-in add-on menus and panels. Object › Plug-ins lists their tools and commands. pub mod arrange; pub mod cleanup; pub mod color; pub mod document; +pub mod enclosing_lasso; pub mod pathtools; pub mod pen; pub mod typetools; @@ -18,6 +18,7 @@ pub fn plugin_menu() -> Vec { Item::Sub("Artwork Cleanup", cleanup::menu()), Item::Sub("Color Effects", color::menu()), Item::Sub("Document Tools", document::menu()), + Item::Sub("Enclosing Lasso", enclosing_lasso::menu()), Item::Sub("Path Precision Tools", pathtools::menu()), Item::Sub("Smart Pen", pen::menu()), Item::Sub("Type Tools", typetools::menu()), diff --git a/crates/ui-egui/src/addons/enclosing_lasso.rs b/crates/ui-egui/src/addons/enclosing_lasso.rs new file mode 100644 index 000000000..f0b8e959c --- /dev/null +++ b/crates/ui-egui/src/addons/enclosing_lasso.rs @@ -0,0 +1,8 @@ +use serde_json::json; +use vectorcraft_tools::addons::enclosing_lasso; + +use crate::menus::Item; + +pub fn menu() -> Vec { + vec![Item::Cmd("Enclosing Lasso Tool", "tool.select", json!({"tool": enclosing_lasso::ID}))] +} diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 7f1c6cf03..d3278e97e 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -270,7 +270,10 @@ pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context, req: &ControlReques "ui.menu.list" => ok(serde_json::to_value(crate::menus::menu_entries(app)).unwrap_or_default()), "ui.contextMenu.list" => ok(serde_json::to_value(crate::menus::context_entries(app)).unwrap_or_default()), "ui.tool.select" => wrap(app.run("tool.select", json!({"tool": s("tool").unwrap_or("")}))), - "ui.tool.list" => ok(serde_json::to_value(vectorcraft_tools::TOOL_GROUPS).unwrap_or_default()), + "ui.tool.list" => { + let groups: Vec<_> = vectorcraft_tools::TOOL_GROUPS.iter().chain(vectorcraft_tools::addons::GROUPS).copied().collect(); + ok(serde_json::to_value(groups).unwrap_or_default()) + } "ui.pointer" => pointer_events(app, ctx, req), "ui.key" => { let Some(k) = s("key").and_then(key_from) else { return err("unknown or missing `key`") }; diff --git a/crates/ui-egui/src/control/tests.rs b/crates/ui-egui/src/control/tests.rs index ded29508a..d084d7ee0 100644 --- a/crates/ui-egui/src/control/tests.rs +++ b/crates/ui-egui/src/control/tests.rs @@ -11,6 +11,23 @@ fn input_request(app: &mut VectorcraftApp, method: &str, params: Value) -> Value r } +#[test] +fn control_lists_and_selects_optional_addon_tools() { + let mut app = VectorcraftApp::new(Session::new(), Services::default()); + let listed = input_request(&mut app, "ui.tool.list", json!({})); + assert_eq!(listed["ok"], true); + let groups = listed["result"].as_array().unwrap(); + for tool in vectorcraft_tools::addons::GROUPS.iter().flat_map(|group| group.iter()) { + let matching: Vec<_> = groups.iter().flat_map(|group| group.as_array().unwrap()).filter(|entry| entry["id"] == tool.id).collect(); + assert_eq!(matching.len(), 1, "{}", tool.id); + assert_eq!(matching[0]["label"], tool.label); + assert!(matching[0]["shortcut"].is_null()); + let selected = input_request(&mut app, "ui.tool.select", json!({"tool": tool.id})); + assert_eq!(selected["ok"], true, "{selected}"); + assert_eq!(selected["result"]["tool"], tool.id); + } +} + #[test] fn ui_set_turns_the_image_trace_preview_off_and_on() { let mut app = VectorcraftApp::new(Session::new(), Services::default()); diff --git a/crates/ui-egui/src/edit_toolbar/tests.rs b/crates/ui-egui/src/edit_toolbar/tests.rs index 93cdc8eda..85309507f 100644 --- a/crates/ui-egui/src/edit_toolbar/tests.rs +++ b/crates/ui-egui/src/edit_toolbar/tests.rs @@ -142,6 +142,19 @@ fn every_tool_is_listed_once_under_a_heading() { assert!(!heads.contains(&"More"), "every tool has a named heading: {heads:?}"); } +#[test] +fn enclosing_lasso_is_an_optional_addon_tool() { + let mut app = app(); + let id = vectorcraft_tools::addons::enclosing_lasso::ID; + assert_eq!(heading(id), "Enclosing Lasso"); + assert!(slot_of(&app, id).is_none()); + app.run("toolbar.addTool", json!({"tool": id})).unwrap(); + assert!(slot_of(&app, id).is_some()); + app.run("tool.select", json!({"tool": id})).unwrap(); + app.run("toolbar.reset", json!({})).unwrap(); + assert!(slot_of(&app, id).is_none()); +} + #[test] fn the_commands_add_move_remove_and_reset_tools() { let mut app = app(); @@ -515,10 +528,11 @@ fn drop_targets_follow_the_buttons_edges() { #[test] fn addon_tools_are_in_the_drawer_not_the_toolbars() { let mut app = app(); + let enclosing = vectorcraft_tools::addons::enclosing_lasso::ID; let addon: Vec<&str> = vectorcraft_tools::addons::GROUPS.iter().flat_map(|g| g.iter().map(|t| t.id)).collect(); assert_eq!( addon, - ["anchorEraserBrush", "extendPath", "measureInspect", "parametricShape", "cornerTool", "slideAnchor", "sharedEdge", "smartPen"] + ["anchorEraserBrush", "extendPath", "measureInspect", "parametricShape", "cornerTool", "slideAnchor", "sharedEdge", "smartPen", enclosing] ); for advanced in [false, true] { if advanced { @@ -531,7 +545,8 @@ fn addon_tools_are_in_the_drawer_not_the_toolbars() { }; assert_eq!(listed("extendPath"), Some(("Path Precision Tools", addon[..7].to_vec()))); assert_eq!(listed("smartPen"), Some(("Smart Pen", vec!["smartPen"]))); - for tool in ["extendPath", "smartPen"] { + assert_eq!(listed(enclosing), Some(("Enclosing Lasso", vec![enclosing]))); + for tool in ["extendPath", "smartPen", enclosing] { app.run("toolbar.addTool", json!({"tool": tool})).unwrap(); assert!(slot_of(&app, tool).is_some(), "{tool}"); } diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index 0d9d78ae0..3bda3f6b8 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -1662,6 +1662,8 @@ font-technology Variable Променливи Large Thumbnail View Изглед с големи миниатюри Large Thumbnails Големи миниатюри Lasso Tool Инструмент „Ласо“ + Enclosing Lasso Tool Инструмент „Обхващащо ласо“ + Enclosing Lasso Обхващащо ласо Last Effect: {name} Посл. ефект: {name} Last Effect: {} Посл. ефект: {} Last Effect… Посл. ефект… diff --git a/crates/ui-egui/src/i18n/bs.tsv b/crates/ui-egui/src/i18n/bs.tsv index c2f440d93..705c5d7e7 100644 --- a/crates/ui-egui/src/i18n/bs.tsv +++ b/crates/ui-egui/src/i18n/bs.tsv @@ -1245,6 +1245,8 @@ lighting Light Svjetlo Hand Ruka Join Tool Alat za spajanje Lasso Tool Alat laso + Enclosing Lasso Tool Alat laso za obuhvatanje + Enclosing Lasso Laso za obuhvatanje Lasso Laso Line Cut Tool Alat za rez linijom Line Graph Tool Alat za linijski grafikon diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index c22392c0d..97d2024dd 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -2101,6 +2101,8 @@ digits Malayalam Malaiàlam Largest: El més gran: Lasso Llaç Lasso Tool Eina de llaç + Enclosing Lasso Tool Eina de llaç envoltant + Enclosing Lasso Llaç envoltant Last Últim Last Artboard Últim tauler Last commit Últim commit diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index a08037b98..56228845d 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -1266,6 +1266,8 @@ lighting Light Světlo Hand Ručička Join Tool Nástroj Spojení Lasso Tool Nástroj Laso + Enclosing Lasso Tool Nástroj obklopující laso + Enclosing Lasso Obklopující laso Lasso Laso Line Cut Tool Nástroj Řez čarou Line Graph Tool Nástroj Spojnicový graf diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv index 8bf51a415..8556ae44e 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -1179,6 +1179,8 @@ lighting Light Lys Hand Hånd Join Tool Sammenføjningsværktøj Lasso Tool Lassoværktøj + Enclosing Lasso Tool Omsluttende lassoværktøj + Enclosing Lasso Omsluttende lasso Lasso Lasso Line Cut Tool Linjeskæringsværktøj Line Graph Tool Linjediagramværktøj diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index f49f384dc..11793dc22 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -1710,6 +1710,8 @@ file-filter Templates Vorlagen Large Thumbnail View Große Miniaturansicht Large Thumbnails Große Miniaturen Lasso Tool Lasso-Werkzeug + Enclosing Lasso Tool Umschließendes Lasso-Werkzeug + Enclosing Lasso Umschließendes Lasso Last Effect: {name} Letzter Effekt: {name} Last Effect: {} Letzter Effekt: {} Last Effect… Letzter Effekt… diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 9b411c318..687a4a8e8 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -1620,6 +1620,8 @@ font-technology Variable Μεταβλητές Large Thumbnail View Προβολή μεγάλων μικρογραφιών Large Thumbnails Μεγάλες μικρογραφίες Lasso Tool Λάσο (Lasso Tool) + Enclosing Lasso Tool Εργαλείο περικλείοντος λάσου + Enclosing Lasso Περικλείον λάσο Last Effect: {name} Τελευταίο εφέ: {name} Last Effect: {} Τελευταίο εφέ: {} Last Effect… Τελευταίο εφέ… diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 7d36a7a0b..eb0aeaeab 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -1713,6 +1713,8 @@ font-technology Variable Variables Large Thumbnail View Vista de miniaturas grandes Large Thumbnails Miniaturas grandes Lasso Tool Herramienta Lazo + Enclosing Lasso Tool Herramienta Lazo envolvente + Enclosing Lasso Lazo envolvente Last Effect: {name} Último efecto: {name} Last Effect: {} Último efecto: {} Last Effect… Último efecto… diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index d2ab88cd1..806ad1dbd 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -1242,6 +1242,8 @@ lighting Light Valo Hand Käsi Join Tool Liitostyökalu Lasso Tool Lassotyökalu + Enclosing Lasso Tool Sulkeva lassotyökalu + Enclosing Lasso Sulkeva lasso Lasso Lasso Line Cut Tool Viivaleikkaustyökalu Line Graph Tool Viivakaaviotyökalu diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 09a8c4bdc..f64610257 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -1707,6 +1707,8 @@ font-technology Variable Variables Large Thumbnail View Grandes vignettes Large Thumbnails Grandes vignettes Lasso Tool Outil Lasso + Enclosing Lasso Tool Outil Lasso englobant + Enclosing Lasso Lasso englobant Last Effect: {name} Dernier effet : {name} Last Effect: {} Dernier effet : {} Last Effect… Dernier effet… diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 971f33348..d5c9f279a 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -2156,6 +2156,8 @@ file-filter Templates Sablonok Largest: Legnagyobb: Lasso Lasszó Lasso Tool Lasszó + Enclosing Lasso Tool Körülzáró lasszó + Enclosing Lasso Körülzáró lasszó Last Utolsó Last commit Utolsó commit Last Effect: {name} Legutóbbi effektus: {name} diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 8cae36f2f..3e784fe33 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -1179,6 +1179,8 @@ lighting Light Cahaya Hand Tangan Join Tool Alat sambung Lasso Tool Alat lasso + Enclosing Lasso Tool Alat laso pengeliling + Enclosing Lasso Laso pengeliling Lasso Lasso Line Cut Tool Alat potong garis Line Graph Tool Alat grafik garis diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 44101c3dc..c95b9d71d 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -1742,6 +1742,8 @@ file-filter Templates Modelli Large Thumbnail View Vista miniature grandi Large Thumbnails Miniature grandi Lasso Tool Strumento Lazo + Enclosing Lasso Tool Strumento Lazo di inclusione + Enclosing Lasso Lazo di inclusione Last Effect: {name} Ultimo effetto: {name} Last Effect: {} Ultimo effetto: {} Last Effect… Ultimo effetto… diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 61a664e0b..eb3d1fc5e 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -1992,6 +1992,8 @@ digits Malayalam マラヤーラム数字 Large 大 Large Tabs 大きいタブ Lasso Tool なげなわツール + Enclosing Lasso Tool 囲みなげなわツール + Enclosing Lasso 囲みなげなわ Last Effect: {name} 最後の効果:{name} Last Page 最後のページ Layer Options… レイヤーオプション… diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index 38e6081c0..a504987b6 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -1108,6 +1108,8 @@ lighting Light სინათლე Hand Tool ხელის ხელსაწყო Join Tool შეერთების ხელსაწყო Lasso Tool ლასოს ხელსაწყო + Enclosing Lasso Tool შემომსაზღვრელი ლასოს ხელსაწყო + Enclosing Lasso შემომსაზღვრელი ლასო Line Cut Tool ხაზით ჭრის ხელსაწყო Line Graph Tool ხაზოვანი დიაგრამის ხელსაწყო Line Segment Tool ხაზის სეგმენტის ხელსაწყო diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index ee42b20a6..1702c2d15 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -1982,6 +1982,8 @@ digits Malayalam 말라얄람어 Large 크게 Large Tabs 큰 탭 Lasso Tool 올가미 도구 + Enclosing Lasso Tool 둘러싸기 올가미 도구 + Enclosing Lasso 둘러싸기 올가미 Last Effect: {name} 마지막 효과: {name} Last Page 마지막 페이지 Layer Options… 레이어 옵션… diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index e85951caa..263545d27 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -1193,6 +1193,8 @@ lighting Light Licht Hand Hand Join Tool Samenvoegen-gereedschap Lasso Tool Lasso-gereedschap + Enclosing Lasso Tool Omsluitend lassogereedschap + Enclosing Lasso Omsluitende lasso Lasso Lasso Line Cut Tool Lijnknip-gereedschap Line Graph Tool Lijndiagram-gereedschap diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 2df99c3ee..a2bce6ac5 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -1190,6 +1190,8 @@ lighting Light Światło Hand Tool Narzędzie Rączka Join Tool Narzędzie Łączenie Lasso Tool Narzędzie Lasso + Enclosing Lasso Tool Narzędzie lassa obejmującego + Enclosing Lasso Lasso obejmujące Line Cut Tool Narzędzie Cięcie linią Line Graph Tool Narzędzie Wykres liniowy Line Segment Tool Narzędzie Odcinek diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index f4f3d118c..b6efe4bf7 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -1460,6 +1460,8 @@ Large Grande Large Tabs Guias Grandes Lasso Tool Ferramenta Laço + Enclosing Lasso Tool Ferramenta Laço envolvente + Enclosing Lasso Laço envolvente Last Effect: {name} Último Efeito: {name} Last Effect… Último Efeito… Last Page Última Página diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index ef1b730e6..999b1f155 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -1739,6 +1739,8 @@ file-filter Templates Шаблоны Large Thumbnail View Крупные миниатюры Large Thumbnails Крупные миниатюры Lasso Tool Инструмент «Лассо» + Enclosing Lasso Tool Инструмент «Охватывающее лассо» + Enclosing Lasso Охватывающее лассо Last Effect: {name} Последний эффект: {name} Last Effect: {} Последний эффект: {} Last Effect… Последний эффект… diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index 87eb2906e..7f815addf 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -1668,6 +1668,8 @@ font-technology Variable Variabla Large Thumbnail View Stor miniatyrvy Large Thumbnails Stora miniatyrer Lasso Tool Lassoverktyg + Enclosing Lasso Tool Omslutande lassoverktyg + Enclosing Lasso Omslutande lasso Last Effect: {name} Senaste effekten: {name} Last Effect: {} Senaste effekten: {} Last Effect… Senaste effekten… diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index bb72d6e47..0517c552a 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -1550,6 +1550,8 @@ font-technology Variable Değişken Large Thumbnail View Büyük Küçük Resim Görünümü Large Thumbnails Büyük Küçük Resimler Lasso Tool Kement Aracı + Enclosing Lasso Tool Çevreleyen Kement Aracı + Enclosing Lasso Çevreleyen Kement Last Effect: {name} Son Etki: {name} Last Effect: {} Son Etki: {} Last Effect… Son Etki… diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 82a5346ac..71d331032 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -1736,6 +1736,8 @@ file-filter Templates Шаблони Large Thumbnail View Великі мініатюри Large Thumbnails Великі мініатюри Lasso Tool Інструмент «Ласо» + Enclosing Lasso Tool Інструмент «Охоплювальне ласо» + Enclosing Lasso Охоплювальне ласо Last Effect: {name} Останній ефект: {name} Last Effect: {} Останній ефект: {} Last Effect… Останній ефект… diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 11a36e8b5..8110a2d20 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -1990,6 +1990,8 @@ digits Hindi Hindi Large Thumbnails Hình thu nhỏ lớn Largest: Lớn nhất: Lasso Tool Công cụ Lasso + Enclosing Lasso Tool Công cụ Lasso bao quanh + Enclosing Lasso Lasso bao quanh Last Cuối cùng Last commit Commit cuối cùng Last Effect: {name} Hiệu ứng vừa dùng: {name} diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 0d2c42c06..aaf791fea 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -1629,6 +1629,8 @@ file-filter Templates 模板 Large Thumbnail View 大缩略图视图 Large Thumbnails 大缩略图 Lasso Tool 套索工具 + Enclosing Lasso Tool 包围套索工具 + Enclosing Lasso 包围套索 Last Effect: {name} 最后一次效果:{name} Last Effect: {} 上次效果:{} Last Effect… 上次效果… diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index c7f59b040..a0bf8bf80 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -2576,6 +2576,8 @@ file-filter Templates 範本 Large Thumbnail View 大縮圖檢視 Large Thumbnails 大縮圖 Lasso Tool 套索工具 + Enclosing Lasso Tool 包圍套索工具 + Enclosing Lasso 包圍套索 Last Effect: {name} 最後一次效果:{name} Last Effect: {} 上次效果:{} Last Effect… 最後一次效果… diff --git a/crates/ui-egui/src/shortcuts/tests.rs b/crates/ui-egui/src/shortcuts/tests.rs index 50ecd06fa..efe991749 100644 --- a/crates/ui-egui/src/shortcuts/tests.rs +++ b/crates/ui-egui/src/shortcuts/tests.rs @@ -902,6 +902,30 @@ fn press(key: Key, m: Modifiers) -> Vec { vec![egui::Event::ModifiersChanged(m), egui::Event::Key { key, physical_key: Some(key), pressed: true, repeat: false, modifiers: m }] } +#[test] +fn enclosing_lasso_escape_cancels_and_q_returns_to_anchor_lasso() { + use vectorcraft_tools::{PointerEvent, PointerKind}; + + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); + crate::shortcut_editor::sync(&app.ui); + app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); + let id = app.run("shape.rectangle", json!({"x": 100, "y": 100, "width": 100, "height": 100})).unwrap()["id"].as_u64().unwrap(); + app.run("select.set", json!({"ids": [id]})).unwrap(); + app.select_tool(vectorcraft_tools::addons::enclosing_lasso::ID); + let view = app.view_info(); + app.session.pointer(&PointerEvent::new(PointerKind::Down, 90.0, 90.0), view).unwrap(); + app.session.pointer(&PointerEvent::new(PointerKind::Drag, 210.0, 90.0), view).unwrap(); + assert!(app.session.tool_busy()); + frame(&mut app, press(Key::Escape, Modifiers::NONE)); + assert!(!app.session.tool_busy()); + app.session.pointer(&PointerEvent::new(PointerKind::Up, 90.0, 210.0), view).unwrap(); + assert_eq!(app.session.active().unwrap().selection.objects, vec![vectorcraft_doc::NodeId(id)]); + let mut keys = press(Key::Q, Modifiers::NONE); + keys.push(egui::Event::Text("q".into())); + frame(&mut app, keys); + assert_eq!(app.session.tool_id(), "lasso"); +} + /// Binds Show Grid to `chord`, or with `None` leaves every command its default shortcut. fn bind_grid(app: &mut VectorcraftApp, chord: Option<&str>) { app.ui.shortcut_overrides.clear(); diff --git a/crates/ui-egui/src/toolbar.rs b/crates/ui-egui/src/toolbar.rs index 1061d6c7f..94054a0d4 100644 --- a/crates/ui-egui/src/toolbar.rs +++ b/crates/ui-egui/src/toolbar.rs @@ -235,7 +235,7 @@ fn body(app: &mut VectorcraftApp, ui: &mut Ui, cols: usize, floating: Option