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 <noreply@anthropic.com>
This commit is contained in:
ruchirguitar
2026-11-04 22:57:57 +07:00
committed by bflatastic
co-authored by Claude Opus 5.5
parent aee5671734
commit 7e016a0bcc
6 changed files with 208 additions and 10 deletions
+2
View File
@@ -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<CommandSpec> {
v.extend(document::specs());
v.extend(pathtools::specs());
v.extend(pen::specs());
v.extend(select::specs());
v.extend(typetools::specs());
v
}
+63
View File
@@ -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<CommandSpec> {
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<Value> {
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::<Option<Vec<_>>>().ok_or_else(|| bad(C, "no such artboard"))?;
let symbols = st.doc.symbol_boxes();
let ids: Vec<NodeId> = 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() }))
}
@@ -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<NodeId> {
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<NodeId>| {
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}");
}
}
+8 -10
View File
@@ -321,21 +321,19 @@ fn all_on_artboard(s: &mut Session, p: &Value) -> Result<Value> {
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<NodeId> = 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<NodeId> =
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<Value> {
s.select(|_, sel| sel.clear())?;
ok()
+3
View File
@@ -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]})),
+23
View File
@@ -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