Add Enclosing Lasso as an optional tool

Squashed from #5362 onto main.
This commit is contained in:
dotCooCoo
2026-11-03 23:30:06 +07:00
committed by bflatastic
parent 63ac1395fd
commit bbbce4318f
46 changed files with 850 additions and 22 deletions
+26 -10
View File
@@ -880,6 +880,16 @@ pub fn compound_part_at(n: &Node, p: Point, tol: f64) -> Option<NodeId> {
/// 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<NodeId>, leaves: bool) -> Vec<NodeId> {
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<NodeId>) -> Vec<NodeId> {
marquee_with(doc, r, scope, true, true)
}
fn marquee_with(doc: &Document, r: Rect, scope: Option<NodeId>, leaves: bool, whole_compounds: bool) -> Vec<NodeId> {
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<Node>, Rect)> = match scope.and_then(|s| doc.node(s)) {
@@ -924,13 +934,19 @@ pub fn marquee(doc: &Document, r: Rect, scope: Option<NodeId>, 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<NodeId>) -> bool {
fn touches(n: &Node, s: &SymbolBoxes, r: Rect, scope: Option<NodeId>, 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<NodeId>, leaves: bool) ->
}),
}
}
fn collect_leaves(n: &Node, s: &SymbolBoxes, r: Rect, scope: Option<NodeId>, out: &mut Vec<NodeId>) {
fn collect_leaves(n: &Node, s: &SymbolBoxes, r: Rect, scope: Option<NodeId>, whole_compounds: bool, out: &mut Vec<NodeId>) {
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<NodeId>, 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);
}
}
+4 -1
View File
@@ -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),
+10 -1
View File
@@ -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());
}
+8 -3
View File
@@ -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());
+180
View File
@@ -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, &region.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, &region)))
.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, &region), "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)), &notch));
assert!(enclosed(&line((2.0, 2.0), (8.0, 2.0)), &notch));
assert!(enclosed(&Rect::new(0.0, 0.0, 10.0, 10.0).to_path(0.01), &region), "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(), &region), "the hole does not cross the object's outline");
assert!(!path_inside(&path, FillRule::NonZero, true, &region));
assert!(path_inside(&path, FillRule::NonZero, false, &region), "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, &region));
for item in &mut node.appearance.items {
if let AppearanceItem::Fill(fill) = item {
fill.visible = false;
}
}
assert!(node_inside(&node, &symbols, &region), "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(), &region));
}
#[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, &region)).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:?}"
);
}
}
+1 -1
View File
@@ -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]]
+1
View File
@@ -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),
+3 -2
View File
@@ -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.<name>.*`).
//! 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> {
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()),
@@ -0,0 +1,8 @@
use serde_json::json;
use vectorcraft_tools::addons::enclosing_lasso;
use crate::menus::Item;
pub fn menu() -> Vec<Item> {
vec![Item::Cmd("Enclosing Lasso Tool", "tool.select", json!({"tool": enclosing_lasso::ID}))]
}
+4 -1
View File
@@ -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`") };
+17
View File
@@ -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());
+17 -2
View File
@@ -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}");
}
+2
View File
@@ -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… Посл. ефект…
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -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
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -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
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -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
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -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
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -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…
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -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… Τελευταίο εφέ…
Can't render this file because it contains an unexpected character in line 163 and column 5.
+2
View File
@@ -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…
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -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
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -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…
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -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}
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -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
1 # Indonesian (id) catalog. Same format as zh-hant.tsv: context <TAB> source <TAB> translation
1179
1180
1181
1182
1183
1184
1185
1186
+2
View File
@@ -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…
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -1992,6 +1992,8 @@ digits Malayalam マラヤーラム数字
Large 大
Large Tabs 大きいタブ
Lasso Tool なげなわツール
Enclosing Lasso Tool 囲みなげなわツール
Enclosing Lasso 囲みなげなわ
Last Effect: {name} 最後の効果:{name}
Last Page 最後のページ
Layer Options… レイヤーオプション…
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -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 ხაზის სეგმენტის ხელსაწყო
Can't render this file because it is too large.
+2
View File
@@ -1982,6 +1982,8 @@ digits Malayalam 말라얄람어
Large 크게
Large Tabs 큰 탭
Lasso Tool 올가미 도구
Enclosing Lasso Tool 둘러싸기 올가미 도구
Enclosing Lasso 둘러싸기 올가미
Last Effect: {name} 마지막 효과: {name}
Last Page 마지막 페이지
Layer Options… 레이어 옵션…
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -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
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -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
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -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
Can't render this file because it contains an unexpected character in line 6 and column 47.
+2
View File
@@ -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… Последний эффект…
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -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…
Can't render this file because it contains an unexpected character in line 5 and column 25.
+2
View File
@@ -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…
Can't render this file because it contains an unexpected character in line 5 and column 25.
+2
View File
@@ -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… Останній ефект…
Can't render this file because it contains an unexpected character in line 177 and column 5.
+2
View File
@@ -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}
Can't render this file because it contains an unexpected character in line 199 and column 5.
+2
View File
@@ -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… 上次效果…
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -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… 最後一次效果…
Can't render this file because it contains an unexpected character in line 6 and column 47.
+24
View File
@@ -902,6 +902,30 @@ fn press(key: Key, m: Modifiers) -> Vec<egui::Event> {
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();
+1 -1
View File
@@ -235,7 +235,7 @@ fn body(app: &mut VectorcraftApp, ui: &mut Ui, cols: usize, floating: Option<egu
let shown_id = if slot.contains(&active) {
active.to_string()
} else {
app.ui.slot_tool.get(slot[0]).cloned().unwrap_or_else(|| slot[0].to_string())
app.ui.slot_tool.get(slot[0]).filter(|id| slot.contains(&id.as_str())).cloned().unwrap_or_else(|| slot[0].to_string())
};
let Some(shown) = tool_info(&shown_id).or_else(|| tool_info(slot[0])) else { continue };
let is_active = slot.contains(&active);
+11
View File
@@ -100,6 +100,17 @@ fn the_tool_buttons_are_centred_in_the_panel() {
/// Screen readers name each tool button by its tool tip and say which tool is active, and name
/// the title bar's column toggle and grip and the paint buttons under the tools.
#[test]
fn saved_flyout_choices_must_still_belong_to_the_slot() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.run("file.new", json!({"width": 300, "height": 300})).unwrap();
for (saved, shown) in [(vectorcraft_tools::addons::enclosing_lasso::ID, "lasso"), ("magicWand", "magicWand")] {
app.ui.slot_tool.insert("lasso".into(), saved.into());
let tree = tree(|ui| show(&mut app, ui));
assert_eq!(role_and_state(&tree, &tip(tool_info(shown).unwrap())), (Role::Button, Some(Toggled::False)));
}
}
#[test]
fn screen_readers_name_the_tool_buttons_and_the_active_tool() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
+7
View File
@@ -8,6 +8,11 @@ Creating a gradient from solid or empty paint uses the document's color model fo
**Feature evidence last updated:** 2026-11-03.
M1 selection: Enclosing Lasso selects whole objects enclosed by a freehand loop. It is an add-on
under Object › Plug-ins and has its own heading in All Tools. It has no default shortcut or Basic
toolbar slot. Shift adds and Alt subtracts enclosed objects; Escape cancels the loop.
This is a VectorCraft addition; milestone estimates and parity scores are unchanged.
The shared assessment status line and revision history are current upstream; this dated section records the submission's feature evidence and revisions without rescoring upstream or claiming a new combined build or runtime measurement.
- Current-main conflict reconciliation retains upstream and feature behavior; scores and estimates unchanged
@@ -21,6 +26,7 @@ Artboard Options forwards the active Artboard tool's Scale Artwork with Artboard
| Date | Change | Summary |
|---|---|---|
| 2026-11-03 | minor | M1: Enclosing Lasso provides whole-object enclosure selection as an add-on with no default shortcut or Basic toolbar slot; estimates unchanged. |
| 2026-11-03 | minor | New gradients use CMYK default stops in CMYK documents, including Gradient tool creation. Scores and estimates unchanged. |
| 2026-10-30 | minor | M4: G9: `.ai`/EPS editing data: a blend takes the transparency (opacity, blend mode, knockout) in its state block after its record, so its steps knock each other out as the page draws them; a user's file comes in 0.6% from its page instead of 1.3%. Scores and estimates unchanged. |
| 2026-10-30 | minor | M4: File formats: `.ai`/EPS editing-data fidelity from an audit of a user's files: All Caps and Small Caps (#4708); placed files at their box's corner, kept where they draw closer to the page (#4664, #4723, #4742); pages cropped to the art, with `UserUnit` (#4658, #4727); gradient mesh corners with handle weights of 0 and the state written after a mesh (#4738, #4744, #4748); type on a path spaced as the file lays it out (#4743); synthetic regressions in `crates/eps`; scores and estimates unchanged. |
@@ -150,6 +156,7 @@ separate. Scores and hours unchanged. [Behavior and command details](development
| Date | Change | Summary |
|---|---|---|
| 2026-11-03 | minor | M1: Enclosing Lasso adds freehand whole-object selection under Object › Plug-ins; estimates unchanged. |
| 2026-11-02 | minor | Font-family fields in Character, Properties and Control step selected text with the wheel (#5253), with one undo step per notch; scores and estimates unchanged. |
| 2026-11-03 | minor | M7 records Find Font's shipped replacement-source chooser (#5217); estimates unchanged. |
| 2026-11-03 | minor | Symbol editing shows draggable 9-slice guides (#5280). Guides outside the artwork remain unsupported. Scores and hours remain unchanged. |
+3
View File
@@ -27,6 +27,7 @@ Artboard Options forwards the active Artboard tool's Scale Artwork with Artboard
| 2026-11-02 | minor | Legacy `.ai` saves (version 7) write type with rotated characters as outlines that keep each character's rotation, with a warning unless `outlineText` is on (#5186); scores unchanged. |
| 2026-11-03 | minor | Record shipped Find Font replacement sources: Document, Recent and System (#5217); scores and estimates unchanged. |
| 2026-10-31 | minor | Object › Path › Smooth smooths a path along its length before refitting it, so Auto-Smooth takes a shaky line to one smooth curve instead of leaving its jags, and with anchor points direct-selected it smooths only that section; scores unchanged. |
| 2026-11-03 | minor | Enclosing Lasso adds freehand whole-object enclosure selection under Object › Plug-ins; scores and estimates unchanged. |
| 2026-10-31 | minor | New Document: More Settings has the Save Preset button beside the name too, saving its settings (artboard layout included) as a preset (#4893); scores unchanged. |
| 2026-10-31 | minor | Object › Rasterize in a CMYK document with its CMYK colour model makes a CMYK image of the art's ink amounts (as CMYK exports draw them), so the Properties panel says CMYK and CMYK exports keep its inks; a transparent background becomes the image's opacity mask (#4882); scores unchanged. |
| 2026-11-03 | minor | Composition spacing stays along the line under Character Rotation, including the turning point and unrotated placement; scores and estimates unchanged. |
@@ -699,6 +700,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
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).
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.
A marquee selects a clip group, or an object on a layer with a clipping mask, only when it reaches the area inside the clipping path, and it measures the art only within that path's bounds. Within the bounds of a mask that is not a rectangle, it can still select art that the mask hides. Direct Selection's marquee still selects the hidden anchor points inside it (#2345).
A marquee selects a Shaper Group only when it reaches the art the group shows, not the original shapes kept under it, and Direct Selection's clicks and marquee do not select those shapes' anchor points. In Construction mode a marquee selects the original shapes, and Direct Selection selects their anchor points.
While an opacity mask or a pattern is edited, a Shaper double-click on a Shaper Group in the mask or pattern art selects the original shape under the pointer without entering Construction mode, the Shaper Group's mode widget does not show, and a Shaper scribble, Release and Expand keep the editing layer isolated.
@@ -1561,6 +1563,7 @@ A double-click on a Repeat (Radial, Grid or Mirror) with the Selection tool ente
| Date | Change | Summary |
|---|---|---|
| 2026-11-03 | minor | Enclosing Lasso is an add-on with no default shortcut or Basic toolbar slot. Scores and estimates remain unchanged. |
| 2026-11-02 | minor | Wheel input over closed font-family fields changes selected text (#5253); public documentation and application choices distinguished; scores and estimates unchanged. |
| 2026-11-03 | minor | Find Font's replacement-source chooser offers Document, Recent and System (#5217); scores and estimates unchanged. |
| 2026-11-03 | minor | SVG imports preserve exported ellipses as four-anchor paths (#5243). Inline graphics use manual kerning (#5245). Symbol editing shows draggable 9-slice guides (#5280). Recolor Artwork offers smooth and segmented wheels and color bars (#5249). Scores and hours remain unchanged. |
+11
View File
@@ -8,6 +8,15 @@ Creating a gradient from solid or empty paint uses the document's color model fo
**Feature evidence last updated:** 2026-11-03.
Enclosing Lasso is an add-on under Object › Plug-ins with its own heading in All Tools. It has
no default shortcut or Basic toolbar slot. It selects whole objects only
when their geometry is fully enclosed by the freehand loop. Shift adds, Alt subtracts, and
Escape cancels. Ordinary group members can be selected separately; compound paths stay whole.
Path checks follow curves, excluding stroke width and effects; other objects use their geometric
box. The loop closes on release, uses even-odd regions when it crosses itself, and includes
objects touching its boundary. Existing Lasso anchor selection and its Q shortcut are unchanged.
Scores and estimates are unchanged.
The shared assessment status line and revision history are current upstream; this dated section records the submission's feature evidence and revisions without rescoring upstream or claiming a new combined build or runtime measurement.
- Current-main conflict reconciliation retains upstream and feature behavior; scores and estimates unchanged
@@ -21,6 +30,7 @@ Artboard Options forwards the active Artboard tool's Scale Artwork with Artboard
| Date | Change | Summary |
|---|---|---|
| 2026-11-03 | minor | Enclosing Lasso provides whole-object enclosure selection as an add-on with no default shortcut or Basic toolbar slot. Scores and estimates remain unchanged. |
| 2026-11-03 | minor | Transform previews valid typed values while numeric fields remain focused. Effect dialogs use the object, group, or layer targeted in Appearance. |
| 2026-11-03 | minor | New gradients use CMYK default stops in CMYK documents, including Gradient tool creation. Scores and estimates unchanged. |
| 2026-10-26 | minor | Pencil Fidelity goes down to 0.01 pt (dialog, tool and engine; Smooth keeps 0.05) on a logarithmic slider across the Tool Options row, with hundredths in the field and key steps that fit the range (#3328); scores and estimates unchanged (estimated) |
@@ -408,6 +418,7 @@ separate. Scores and hours unchanged. [Behavior and command details](development
| Date | Change | Summary |
|---|---|---|
| 2026-11-03 | minor | Enclosing Lasso selects fully enclosed objects with a freehand loop under Object › Plug-ins; scores and estimates unchanged. |
| 2026-11-03 | minor | Type on a Path Options has a Preview checkbox (#5360, as public tutorials of the dialog show); scores and estimates unchanged |
| 2026-11-02 | minor | Font-family fields in Character, Properties and Control accept the wheel without opening the menu (#5253); scores and estimates unchanged. |
| 2026-11-03 | minor | Symbol editing draws and drags 9-slice guides on the canvas (#5280). Guides outside the artwork remain unsupported. Scores and hours remain unchanged. |