From 7e016a0bcc364deebcff2a75e936a475ce83afdb Mon Sep 17 00:00:00 2001 From: ruchirguitar <113586074+ruchirguitar@users.noreply.github.com> Date: Wed, 4 Nov 2026 18:30:07 +0530 Subject: [PATCH] Select by Artboards: select objects overlapping, inside or outside several artboards (#5525) addon.select.byArtboards {artboards, relation} in a new Selection Tools add-on. Overlapping is All on Active Artboard's test, now one shared helper (select::on_artboard); Fully inside is within one artboard, edges included; Fully outside is on none. Objects are those Select All takes. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/addons.rs | 2 + crates/engine/src/cmd/addons/select.rs | 63 +++++++++++ crates/engine/src/cmd/addons/select/tests.rs | 109 +++++++++++++++++++ crates/engine/src/cmd/select.rs | 18 ++- crates/engine/tests/command_sweep.rs | 3 + docs/mcp.md | 23 ++++ 6 files changed, 208 insertions(+), 10 deletions(-) create mode 100644 crates/engine/src/cmd/addons/select.rs create mode 100644 crates/engine/src/cmd/addons/select/tests.rs diff --git a/crates/engine/src/cmd/addons.rs b/crates/engine/src/cmd/addons.rs index 02f886442..ac9597cbd 100644 --- a/crates/engine/src/cmd/addons.rs +++ b/crates/engine/src/cmd/addons.rs @@ -7,6 +7,7 @@ mod color; mod document; mod pathtools; mod pen; +mod select; mod typetools; use super::CommandSpec; @@ -19,6 +20,7 @@ pub fn specs() -> Vec { v.extend(document::specs()); v.extend(pathtools::specs()); v.extend(pen::specs()); + v.extend(select::specs()); v.extend(typetools::specs()); v } diff --git a/crates/engine/src/cmd/addons/select.rs b/crates/engine/src/cmd/addons/select.rs new file mode 100644 index 000000000..ce2869abc --- /dev/null +++ b/crates/engine/src/cmd/addons/select.rs @@ -0,0 +1,63 @@ +//! Selection Tools (`addon.select.*`), a built-in add-on: Select by Artboards selects the objects +//! overlapping, fully inside or fully outside one or more chosen artboards (#5525). Its submenu +//! (Object › Plug-ins › Selection Tools) and dialog are the UI's. + +#[cfg(test)] +mod tests; + +use serde_json::{Value, json}; +use vectorcraft_doc::NodeId; +use vectorcraft_geom::Point; + +use super::super::select::{on_artboard, selectable}; +use super::super::{CommandSpec, bad, cmd, has_doc, point_indices, text_param}; +use crate::{Result, Session}; + +/// How Select by Artboards relates an object to the chosen artboards (`relation`). +pub const RELATIONS: [&str; 3] = ["overlap", "inside", "outside"]; + +pub fn specs() -> Vec { + vec![cmd!( + "addon.select.byArtboards", + "Select by Artboards", + ["Object", "Plug-ins", "Selection Tools"], + None, + "{artboards: [index…] (0-based, at least one), relation?: \"overlap\" (default: on at least one of them, as select.allOnArtboard takes it: the bounds overlap the artboard or the centre is on it) | \"inside\" (the bounds lie wholly within one of them, edges included) | \"outside\" (on none of them)} select those objects among the ones Select All takes (in isolation mode, the isolated container's); hidden and locked objects, objects on hidden, locked or template layers and art held by a locked artboard are left out. Bounds are geometric, a symbol instance's measured by its art → {count}", + has_doc, + by_artboards + )] +} + +fn by_artboards(s: &mut Session, p: &Value) -> Result { + const C: &str = "addon.select.byArtboards"; + let relation = text_param(p, "relation", C)?.unwrap_or("overlap"); + if !RELATIONS.contains(&relation) { + return Err(bad(C, "`relation` must be overlap, inside or outside")); + } + let listed = p.get("artboards").ok_or_else(|| bad(C, "missing `artboards`"))?; + let indices = point_indices(listed).ok_or_else(|| bad(C, "`artboards` must be a list of artboard indices"))?; + if indices.is_empty() { + return Err(bad(C, "choose at least one artboard")); + } + let st = s.doc()?; + let rects = + indices.iter().map(|i| st.doc.artboards.get(*i).map(|a| a.rect)).collect::>>().ok_or_else(|| bad(C, "no such artboard"))?; + let symbols = st.doc.symbol_boxes(); + let ids: Vec = selectable(&st.doc, st.isolation) + .into_iter() + .filter(|id| { + let Some(n) = st.doc.node(*id) else { return false }; + match relation { + "inside" => n.geometric_bounds_with(&symbols).is_some_and(|b| { + rects.iter().any(|r| { + vectorcraft_doc::artboard_holds(*r, Point::new(b.x0, b.y0)) && vectorcraft_doc::artboard_holds(*r, Point::new(b.x1, b.y1)) + }) + }), + "outside" => !rects.iter().any(|r| on_artboard(n, &symbols, *r)), + _ => rects.iter().any(|r| on_artboard(n, &symbols, *r)), + } + }) + .collect(); + s.select(|_, sel| sel.set(ids))?; + Ok(json!({ "count": s.doc()?.selection.objects.len() })) +} diff --git a/crates/engine/src/cmd/addons/select/tests.rs b/crates/engine/src/cmd/addons/select/tests.rs new file mode 100644 index 000000000..cfcc96e0a --- /dev/null +++ b/crates/engine/src/cmd/addons/select/tests.rs @@ -0,0 +1,109 @@ +//! Select by Artboards. + +use serde_json::{Value, json}; +use vectorcraft_doc::NodeId; + +use crate::Session; + +const C: &str = "addon.select.byArtboards"; + +/// A document with three 100 × 100 artboards in a row, 50 pt apart: 0 at x 0, 1 at x 150, 2 at x 300. +fn three_boards() -> Session { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 100, "height": 100})).unwrap(); + for x in [150, 300] { + s.execute("artboard.new", &json!({"x": x, "y": 0, "width": 100, "height": 100})).unwrap(); + } + s +} + +fn rect(s: &mut Session, x: f64, y: f64, w: f64, h: f64) -> NodeId { + NodeId(s.execute("shape.rectangle", &json!({"x": x, "y": y, "width": w, "height": h})).unwrap()["id"].as_u64().unwrap()) +} + +fn pick(s: &mut Session, p: Value) -> Vec { + s.execute("select.none", &json!({})).unwrap(); + let r = s.execute(C, &p).unwrap(); + let mut ids = s.doc().unwrap().selection.objects.clone(); + assert_eq!(r["count"], json!(ids.len())); + ids.sort(); + ids +} + +#[test] +fn selects_by_overlap_inside_and_outside_several_artboards() { + let mut s = three_boards(); + let a = rect(&mut s, 10.0, 10.0, 20.0, 20.0); // inside 0 + let ab = rect(&mut s, 90.0, 40.0, 80.0, 20.0); // across 0 and 1 + let b = rect(&mut s, 160.0, 10.0, 20.0, 20.0); // inside 1 + let c = rect(&mut s, 310.0, 10.0, 20.0, 20.0); // inside 2 + let off = rect(&mut s, 0.0, 200.0, 20.0, 20.0); // on no board + let edge = rect(&mut s, 0.0, 0.0, 100.0, 100.0); // flush with board 0 + let want = |mut v: Vec| { + v.sort(); + v + }; + assert_eq!(pick(&mut s, json!({"artboards": [0, 1]})), want(vec![a, ab, b, edge])); + assert_eq!(pick(&mut s, json!({"artboards": [0, 1], "relation": "overlap"})), want(vec![a, ab, b, edge])); + // Wholly within one board, edges included; straddling two boards is not inside either. + assert_eq!(pick(&mut s, json!({"artboards": [0, 1], "relation": "inside"})), want(vec![a, b, edge])); + assert_eq!(pick(&mut s, json!({"artboards": [0, 1], "relation": "outside"})), want(vec![c, off])); + assert_eq!(pick(&mut s, json!({"artboards": [2], "relation": "outside"})), want(vec![a, ab, b, off, edge])); + // One board does what All on Active Artboard does. + for i in 0..3u64 { + s.execute("select.allOnArtboard", &json!({"artboard": i})).unwrap(); + let mut on = s.doc().unwrap().selection.objects.clone(); + on.sort(); + assert_eq!(pick(&mut s, json!({"artboards": [i]})), on, "board {i}"); + } + // A board listed twice counts once. + assert_eq!(pick(&mut s, json!({"artboards": [2, 2]})), vec![c]); +} + +#[test] +fn hidden_locked_and_isolated_art_follow_select_all() { + let mut s = three_boards(); + let a = rect(&mut s, 10.0, 10.0, 20.0, 20.0); + let hidden = rect(&mut s, 40.0, 10.0, 20.0, 20.0); + let locked = rect(&mut s, 70.0, 10.0, 20.0, 20.0); + let far = rect(&mut s, 0.0, 300.0, 20.0, 20.0); + s.execute("object.setProps", &json!({"ids": [hidden.0], "visible": false})).unwrap(); + s.execute("object.setProps", &json!({"ids": [locked.0], "locked": true})).unwrap(); + assert_eq!(pick(&mut s, json!({"artboards": [0]})), vec![a]); + assert_eq!(pick(&mut s, json!({"artboards": [0], "relation": "outside"})), vec![far]); + // Art held by a locked artboard is left out. + s.execute("artboard.setProps", &json!({"index": 0, "locked": true})).unwrap(); + assert_eq!(pick(&mut s, json!({"artboards": [0]})), vec![]); + s.execute("artboard.setProps", &json!({"index": 0, "locked": false})).unwrap(); + // In isolation mode only the isolated group's objects are looked at. + let b = rect(&mut s, 20.0, 50.0, 10.0, 10.0); + s.execute("select.none", &json!({})).unwrap(); + s.execute("select.set", &json!({"ids": [a.0, b.0]})).unwrap(); + let g = s.execute("object.group", &json!({})).unwrap()["id"].as_u64().unwrap(); + s.execute("object.isolate", &json!({"id": g})).unwrap(); + let mut both = vec![a, b]; + both.sort(); + assert_eq!(pick(&mut s, json!({"artboards": [0], "relation": "inside"})), both); + assert_eq!(pick(&mut s, json!({"artboards": [1]})), vec![]); +} + +#[test] +fn bad_params_are_errors_and_leave_the_selection() { + let mut s = three_boards(); + let a = rect(&mut s, 10.0, 10.0, 20.0, 20.0); + for p in [ + json!({}), + json!({"artboards": []}), + json!({"artboards": [3]}), + json!({"artboards": [-1]}), + json!({"artboards": [0.5]}), + json!({"artboards": "0"}), + json!({"artboards": [0], "relation": "near"}), + json!({"artboards": [0], "relation": 1}), + json!({"artboards": [0], "relatoin": "inside"}), + ] { + s.execute("select.set", &json!({"ids": [a.0]})).unwrap(); + assert!(s.execute(C, &p).is_err(), "{p}"); + assert_eq!(s.doc().unwrap().selection.objects, vec![a], "{p}"); + } +} diff --git a/crates/engine/src/cmd/select.rs b/crates/engine/src/cmd/select.rs index 25e0123ca..0f16246df 100644 --- a/crates/engine/src/cmd/select.rs +++ b/crates/engine/src/cmd/select.rs @@ -321,21 +321,19 @@ fn all_on_artboard(s: &mut Session, p: &Value) -> Result { let st = s.doc()?; let i = p.get("artboard").and_then(Value::as_u64).map_or(0, index); let r = st.doc.artboards.get(i).map(|a| a.rect).ok_or_else(|| bad("select.allOnArtboard", "no such artboard"))?; - // Symbol instances are measured by their art. let symbols = st.doc.symbol_boxes(); - let ids: Vec = selectable(&st.doc, st.isolation) - .into_iter() - .filter(|id| { - st.doc - .node(*id) - .and_then(|n| n.geometric_bounds_with(&symbols)) - .is_some_and(|b| b.intersect(r).area() > 0.0 || vectorcraft_doc::artboard_holds(r, b.center())) - }) - .collect(); + let ids: Vec = + selectable(&st.doc, st.isolation).into_iter().filter(|id| st.doc.node(*id).is_some_and(|n| on_artboard(n, &symbols, r))).collect(); s.select(|_, sel| sel.set(ids.iter().copied()))?; ok() } +/// Is `n` on the artboard at `r`, as All on Active Artboard takes it: its geometric bounds (a +/// symbol instance's measured by its art) overlap the artboard, or its center is on it. +pub(crate) fn on_artboard(n: &Node, symbols: &vectorcraft_doc::SymbolBoxes, r: vectorcraft_geom::Rect) -> bool { + n.geometric_bounds_with(symbols).is_some_and(|b| b.intersect(r).area() > 0.0 || vectorcraft_doc::artboard_holds(r, b.center())) +} + fn none(s: &mut Session, _: &Value) -> Result { s.select(|_, sel| sel.clear())?; ok() diff --git a/crates/engine/tests/command_sweep.rs b/crates/engine/tests/command_sweep.rs index 45f6b4768..3010901ca 100644 --- a/crates/engine/tests/command_sweep.rs +++ b/crates/engine/tests/command_sweep.rs @@ -236,6 +236,9 @@ fn structured_junk() { ("select.anchorsMany", json!({"items": [{"id": 2, "anchors": [[0, 0]]}], "mode": "subtract"})), ("select.anchorsMany", json!({"items": [], "mode": 7})), ("select.toggle", json!({"ids": [2, u64::MAX, "x"]})), + ("addon.select.byArtboards", json!({"artboards": [0, u64::MAX, -1, "x", null, 1.5], "relation": "inside"})), + ("addon.select.byArtboards", json!({"artboards": [1e308], "relation": [1]})), + ("addon.select.byArtboards", json!({"artboards": {"a": 1}, "relation": "outside"})), ("object.transform", json!({"matrix": [0, 0, 0, 0, 0, 0]})), ("object.transform", json!({"matrix": [1e308, 1e308, 1e308, 1e308, 1e308, 1e308]})), ("object.transform", json!({"matrix": [1, 2, 3]})), diff --git a/docs/mcp.md b/docs/mcp.md index 6b6b1f465..08464ef6d 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -6794,6 +6794,29 @@ on the object or on one of its fills or strokes; a group carrying one is selecte `grayImages` (images whose pixels are RGB, CMYK, or grayscale or 1-bit, read from the image's header). It returns the `count` selected. +### Selection Tools + +Selection Tools is a built-in add-on: its commands are `addon.select.*`, and in the desktop app it lives in Object › +Plug-ins › Selection Tools. + +`addon.select.byArtboards {artboards, relation?}` (Select by Artboards…, #5525) selects objects by where they sit +relative to one or more artboards. `artboards` lists 0-based artboard indices (at least one; one listed twice counts +once). `relation` is `overlap` (the default: on at least one of them, as `select.allOnArtboard` takes it, the bounds +overlapping the artboard or the centre on it), `inside` (the bounds wholly within one of them, edges included; an object +straddling two touching artboards is inside neither) or `outside` (on none of them). It looks at the objects Select All +takes, or the isolated container's in isolation mode, so hidden and locked objects, objects on hidden, locked or template +layers and art held by a locked artboard are left out. Bounds are geometric, a symbol instance's measured by its art. It +returns the `count` selected. An empty list, an index with no artboard, a value that isn't an index, or another +relation is an error, and the selection is left as it was. + +```json +{"name":"run_command","arguments":{"command":"addon.select.byArtboards","params":{"artboards":[2,6,11],"relation":"inside"}}} +``` + +The dialog lists every artboard with its number, name and size in the document's units. Search filters the list by +name; a click chooses one artboard, Shift-click adds a range and Cmd/Ctrl-click toggles one, and Select All / None act +on the rows shown (rows hidden by the search stay chosen). It opens with the active artboard chosen and Overlapping. + ### Vector Halftone (live) `addon.color.halftone` (Effect › Color Effects › Vector Halftone…, also in Object › Plug-ins › Color Effects) draws its