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()