Artwork Cleanup: path commands take locked and hidden art named by ids

Simplify Anchors, Deep Ungroup and Rejoin Path Pieces now follow the
rule the text tools use: with `ids`, they act on the locked and hidden
paths and groups the ids name, and on those inside the groups and
layers they name, but not on art on a locked artboard. From the
selection they still take only art that can be edited.

- Rejoin Path Pieces joins only pieces alike in being hidden and in
  being locked (counting the groups and layers above them), so a joined
  path does not show hidden art or unlock locked art. Locked and hidden
  paths it makes are not selected. A compound path member named
  directly stays out, as members reached through the compound do.
- Deep Ungroup takes apart named locked and hidden groups, and the
  groups inside a named group or layer; a named layer stays. Objects of
  a hidden group become hidden, and objects of a locked group become
  locked.
- Simplify Anchors reports every named path it took in `ids`.
This commit is contained in:
dotCooCoo
2026-11-14 18:49:20 -08:00
parent 6016d6200f
commit 05527ee0f1
4 changed files with 400 additions and 59 deletions
+114 -41
View File
@@ -9,7 +9,7 @@ mod tests;
mod text;
use serde_json::{Map, Value, json};
use vectorcraft_doc::{Document, Node, NodeId, NodeKind};
use vectorcraft_doc::{Document, LockedArtboards, Node, NodeId, NodeKind};
use vectorcraft_geom::{PathData, SubPath};
use vectorcraft_pathops::addons as po;
@@ -37,7 +37,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Simplify Anchors (Keep Shape)…",
["Object", "Plug-ins", "Artwork Cleanup"],
None,
"{tolerance?: 1..100 (20; higher removes more anchors and lets the outline move further from where it was, up to tolerance × 0.02 pt), protectCorners?: bool (false; anchors where the path turns by more than 30° stay), ids?: [id…] (default: the selection)} remove the anchors each path under the objects can do without, refitting the handles beside each so the outline keeps its shape, as one undo step; open ends stay, and locked and hidden paths are left out → {ids, before, after} anchor counts",
"{tolerance?: 1..100 (20; higher removes more anchors and lets the outline move further from where it was, up to tolerance × 0.02 pt), protectCorners?: bool (false; anchors where the path turns by more than 30° stay), ids?: [id…] (default: the selection)} remove the anchors each path under the objects can do without, refitting the handles beside each so the outline keeps its shape, as one undo step when a path changes; open ends stay; from the selection, locked and hidden paths are left out, and named by `ids`, the paths of the objects, groups and layers it names are taken even when locked or hidden, but not those on a locked artboard; a changed path loses its selected anchors and segments → {ids, before, after} anchor counts",
has_selection,
simplify
),
@@ -73,7 +73,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Deep Ungroup…",
["Object", "Plug-ins", "Artwork Cleanup"],
None,
"{includeClipGroups?: bool (false: clipping groups, and the art inside them, stay as they are; true: they are ungrouped too and their clipping paths become plain paths), ids?: [id…] (default: the selection)} ungroup every level of the groups among the objects, as one undo step; each object keeps the opacity of the groups it was in; locked and hidden groups stay whole, and locked and hidden objects stay locked or hidden and are not selected → {ids (the objects selected: those left, or with no group to take apart the selection as it was), ungrouped (groups)}",
"{includeClipGroups?: bool (false: clipping groups, and the art inside them, stay as they are; true: they are ungrouped too and their clipping paths become plain paths), ids?: [id…] (default: the selection)} ungroup every level of the groups among the objects, as one undo step when a group goes; each object keeps the opacity of the groups it was in; from the selection, locked and hidden groups stay whole; named by `ids`, the groups it names and the groups in the groups and layers it names are ungrouped even when locked or hidden (a named layer stays), but not those on a locked artboard, and their objects become hidden or locked when a group was; locked and hidden objects stay locked or hidden and are not selected → {ids (the objects selected: those left, or with no group to take apart the selection as it was), ungrouped (groups)}",
has_selection,
deep_ungroup
),
@@ -82,7 +82,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Rejoin Path Pieces…",
["Object", "Plug-ins", "Artwork Cleanup"],
None,
"{gap?: pt 0..6 (0.1), ids?: [id…] (default: the selection)} weld the open paths under the objects whose ends lie within the gap of each other back into paths: an end joins one other end, the one the path turns least to go on into, pieces may run either way, and a chain whose ends meet closes; each path takes the place and paint of its topmost piece, as one undo step → {ids, joined (paths made), removed (pieces merged into them)}",
"{gap?: pt 0..6 (0.1), ids?: [id…] (default: the selection)} weld the open paths under the objects whose ends lie within the gap of each other back into paths: an end joins one other end, the one the path turns least to go on into, pieces may run either way, and a chain whose ends meet closes; each path takes the place and paint of its topmost piece, as one undo step when a path is made; from the selection, locked and hidden paths are left out, and named by `ids`, they are taken, but not those on a locked artboard; only pieces alike in being hidden (by themselves or a group or layer above them) and in being locked join; locked and hidden paths made are not selected → {ids, joined (paths made), removed (pieces merged into them)}",
has_selection,
rejoin
),
@@ -91,9 +91,28 @@ pub fn specs() -> Vec<CommandSpec> {
v
}
/// The paths (not guides) under `roots`, compound path members included; locked and hidden
/// paths, and those in locked or hidden groups, stay out.
fn paths_under(d: &Document, roots: &[NodeId]) -> Vec<NodeId> {
/// Whether a request names its objects with `ids` (present and not null) rather than taking the
/// selection.
fn named(p: &Value) -> bool {
p.get("ids").is_some_and(|v| !v.is_null())
}
/// Keep the items of `items` whose object (`id`) a command acts on: when `named`, every one not on
/// a locked artboard, locked and hidden ones (and ones in a locked or hidden group or layer)
/// included; else only those that can be edited.
fn retain_targets<T>(d: &Document, items: &mut Vec<T>, named: bool, id: impl Fn(&T) -> NodeId) {
if named {
let locks = d.locked_artboards();
items.retain(|t| !locks.holds_id(id(t)));
} else {
let editable = d.editable();
items.retain(|t| editable(id(t)));
}
}
/// The paths (not guides) under `roots`, compound path members included, that a command acts on
/// ([`retain_targets`]).
fn paths_under(d: &Document, roots: &[NodeId], named: bool) -> Vec<NodeId> {
let mut out = vec![];
for n in roots.iter().filter_map(|id| d.node(*id)) {
n.walk(&mut |c| {
@@ -102,8 +121,7 @@ fn paths_under(d: &Document, roots: &[NodeId]) -> Vec<NodeId> {
}
});
}
let editable = d.editable();
out.retain(|id| editable(*id));
retain_targets(d, &mut out, named, |id| *id);
out
}
@@ -116,7 +134,7 @@ fn simplify(s: &mut Session, p: &Value) -> Result<Value> {
let protect = switch_or(p, "protectCorners", false, C)?;
let roots = checked_ids_or_selection(s, p, C)?;
let d = &s.doc()?.doc;
let ids = paths_under(d, &roots);
let ids = paths_under(d, &roots, named(p));
let paths: Vec<(NodeId, &PathData)> = ids.iter().filter_map(|id| Some((*id, d.node(*id)?.path_data()?))).collect();
let before: usize = paths.iter().map(|(_, p)| p.anchor_count()).sum();
if before > MAX_ANCHORS {
@@ -258,10 +276,11 @@ fn ungroupable(n: &Node, clips: bool) -> bool {
}
/// `n` with every level of grouping Deep Ungroup takes apart removed, into `out`; `count` counts
/// the groups. Members take on the opacity of the groups they were in, and a clipping path
/// becomes a plain path. A locked or hidden group stays whole.
fn flatten(n: Node, clips: bool, out: &mut Vec<Node>, count: &mut usize) {
if !ungroupable(&n, clips) || n.locked || !n.visible {
/// the groups. Members take on the opacity of the groups they were in, are hidden when one of them
/// was hidden and locked when one of them was locked, and a clipping path becomes a plain path. A
/// locked or hidden group stays whole unless `named`.
fn flatten(n: Node, clips: bool, named: bool, out: &mut Vec<Node>, count: &mut usize) {
if !ungroupable(&n, clips) || (!named && (n.locked || !n.visible)) {
out.push(n);
return;
}
@@ -276,26 +295,57 @@ fn flatten(n: Node, clips: bool, out: &mut Vec<Node>, count: &mut usize) {
*clipping = false;
}
c.opacity *= n.opacity;
flatten(c, clips, out, count);
c.visible &= n.visible;
c.locked |= n.locked;
flatten(c, clips, named, out, count);
}
}
/// The outermost groups Deep Ungroup takes apart in layer `n`, its sublayers included, in paint
/// order, leaving out those on a locked artboard.
fn groups_in_layer(d: &Document, n: &Node, clips: bool, out: &mut Vec<NodeId>) {
let locks = d.locked_artboards();
fn visit(n: &Node, clips: bool, locks: &LockedArtboards, out: &mut Vec<NodeId>) {
for c in n.children().into_iter().flatten() {
if c.is_layer() {
visit(c, clips, locks, out);
} else if ungroupable(c, clips) && !locks.holds_id(c.id) {
out.push(c.id);
}
}
}
visit(n, clips, &locks, out);
}
fn deep_ungroup(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "addon.cleanup.deepUngroup";
let clips = switch_or(p, "includeClipGroups", false, C)?;
let roots = checked_ids_or_selection(s, p, C)?;
let named = named(p);
let mut roots = checked_ids_or_selection(s, p, C)?;
let d = &s.doc()?.doc;
// Locked and hidden objects, and those in locked or hidden groups, stay as they are.
let roots: Vec<NodeId> = {
let editable = d.editable();
roots.into_iter().filter(|id| editable(*id)).collect()
};
// From the selection, locked and hidden objects, and those in locked or hidden groups, stay as
// they are; named, only those on a locked artboard do.
retain_targets(d, &mut roots, named, |id| *id);
// A named layer stays: the groups in it, and in its sublayers, are taken apart.
let mut groups = vec![];
for id in roots {
match d.node(id) {
Some(n) if named && n.is_layer() => {
let at = groups.len();
groups_in_layer(d, n, clips, &mut groups);
if groups.len() == at {
groups.push(id);
}
}
_ => groups.push(id),
}
}
let (mut items, mut selected, mut count) = (vec![], vec![], 0);
for id in &roots {
for id in &groups {
match d.node(*id).filter(|n| ungroupable(n, clips)) {
Some(n) => {
let mut out = vec![];
flatten(n.clone(), clips, &mut out, &mut count);
flatten(n.clone(), clips, named, &mut out, &mut count);
selected.extend(out.iter().map(|n| n.id));
items.push((*id, out));
}
@@ -304,7 +354,7 @@ fn deep_ungroup(s: &mut Session, p: &Value) -> Result<Value> {
}
// With no group to take apart, the document, the selection and the undo history stay as they
// are, and the reply lists the selection.
if items.is_empty() {
if count == 0 {
selected = s.doc()?.selection.objects.to_vec();
} else {
selected = s.edit("Deep Ungroup", move |d, sel| {
@@ -319,26 +369,39 @@ fn deep_ungroup(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({ "ids": selected.iter().map(|i| i.0).collect::<Vec<_>>(), "ungrouped": count }))
}
/// One path Rejoin Path Pieces takes: its id, its subpath, and whether it and every group and layer
/// above it are visible, and whether they are all unlocked.
type Piece = (NodeId, SubPath, (bool, bool));
/// The open single-subpath paths under `roots` that Rejoin Path Pieces takes, in paint order
/// (compound path members, clipping paths and guides stay out).
fn pieces(d: &Document, roots: &[NodeId]) -> Vec<(NodeId, SubPath)> {
fn visit(n: &Node, out: &mut Vec<(NodeId, SubPath)>) {
/// (compound path members, clipping paths and guides stay out; [`retain_targets`]).
fn pieces(d: &Document, roots: &[NodeId], named: bool) -> Vec<Piece> {
fn visit(n: &Node, above: (bool, bool), out: &mut Vec<Piece>) {
let state = (above.0 && n.visible, above.1 && !n.locked);
match &n.kind {
NodeKind::Path { path, guide: false, clipping: false, .. } => {
if let [sp] = path.subpaths.as_slice()
&& !sp.closed
{
out.push((n.id, sp.clone()));
out.push((n.id, sp.clone(), state));
}
}
NodeKind::Group { children, .. } | NodeKind::Layer { children, .. } => children.iter().for_each(|c| visit(c, out)),
NodeKind::Group { children, .. } | NodeKind::Layer { children, .. } => children.iter().for_each(|c| visit(c, state, out)),
_ => {}
}
}
let mut out = vec![];
roots.iter().filter_map(|id| d.node(*id)).for_each(|n| visit(n, &mut out));
let editable = d.editable();
out.retain(|(id, _)| editable(*id));
for (id, n) in roots.iter().filter_map(|id| Some((*id, d.node(*id)?))) {
// The groups and layers above the root, and their state.
let above = d.ancestry(id).unwrap_or_default();
let above = above.split_last().map_or(&[][..], |(_, up)| up);
if above.last().and_then(|i| d.node(*i)).is_some_and(|parent| matches!(parent.kind, NodeKind::Compound { .. })) {
continue;
}
let state = above.iter().filter_map(|i| d.node(*i)).fold((true, true), |(v, u), a| (v && a.visible, u && !a.locked));
visit(n, state, &mut out);
}
retain_targets(d, &mut out, named, |piece| piece.0);
out
}
@@ -349,18 +412,26 @@ fn rejoin(s: &mut Session, p: &Value) -> Result<Value> {
return Err(bad(C, "`gap` must be from 0 to 6 pt"));
}
let roots = checked_ids_or_selection(s, p, C)?;
let found = pieces(&s.doc()?.doc, &roots);
let found = pieces(&s.doc()?.doc, &roots, named(p));
if found.len() > MAX_OBJECTS {
return Err(EngineError::Other("Rejoin Path Pieces: too many paths to rejoin at once".into()));
}
let subpaths: Vec<SubPath> = found.iter().map(|(_, sp)| sp.clone()).collect();
// Each chain becomes its topmost piece (the last in paint order); the others go.
// Only pieces alike in being shown and in being unlocked join, so that a joined path neither
// shows hidden art nor hides shown art, nor locks or unlocks a piece.
let (mut kept, mut gone) = (vec![], vec![]);
for j in po::rejoin(&subpaths, gap) {
let ids: Vec<NodeId> = j.members.iter().filter_map(|&m| found.get(m).map(|f| f.0)).collect();
let Some(&top) = j.members.iter().max().and_then(|&m| found.get(m).map(|f| &f.0)) else { continue };
gone.extend(ids.into_iter().filter(|id| *id != top));
kept.push((top, j.path));
for state in [(true, true), (true, false), (false, true), (false, false)] {
let part: Vec<&Piece> = found.iter().filter(|f| f.2 == state).collect();
if part.is_empty() {
continue;
}
let subpaths: Vec<SubPath> = part.iter().map(|f| f.1.clone()).collect();
// Each chain becomes its topmost piece (the last in paint order); the others go.
for j in po::rejoin(&subpaths, gap) {
let ids: Vec<NodeId> = j.members.iter().filter_map(|&m| part.get(m).map(|f| f.0)).collect();
let Some(top) = j.members.iter().max().and_then(|&m| part.get(m).map(|f| f.0)) else { continue };
gone.extend(ids.into_iter().filter(|id| *id != top));
kept.push((top, j.path));
}
}
if !kept.is_empty() {
let ids: Vec<NodeId> = kept.iter().map(|k| k.0).collect();
@@ -369,7 +440,9 @@ fn rejoin(s: &mut Session, p: &Value) -> Result<Value> {
set_path(d, *id, PathData::single(sp.clone()));
}
d.remove_many(&gone)?;
sel.set(ids);
// Locked and hidden paths, and those in locked or hidden groups, are not selected.
let editable = d.editable();
sel.set(ids.into_iter().filter(|id| editable(*id)));
Ok(())
})?;
}
+268 -12
View File
@@ -3,7 +3,7 @@
use serde_json::{Value, json};
use vectorcraft_color::{Color, Paint};
use vectorcraft_doc::{Appearance, Node, NodeId, NodeKind};
use vectorcraft_doc::{Appearance, LayerColor, Node, NodeId, NodeKind};
use vectorcraft_geom::{FillRule, Rect};
use crate::Session;
@@ -123,18 +123,100 @@ fn simplify_leaves_locked_and_hidden_paths_alone() {
assert_eq!(r["before"], 16);
assert!(anchors(&s, ids[0]) < 16);
assert_eq!((anchors(&s, ids[1]), anchors(&s, ids[2])), (16, 16));
// Named by `ids`, or in a locked group, a path stays too, with no undo step.
let steps = undo_steps(&s);
s.execute("addon.cleanup.simplify", &json!({"ids": [ids[1].0, ids[2].0]})).unwrap();
assert_eq!((anchors(&s, ids[1]), anchors(&s, ids[2])), (16, 16));
assert_eq!(undo_steps(&s), steps);
// In a selected locked group, a path stays too, with no undo step.
change(&mut s, ids[1], |n| n.locked = false);
change(&mut s, g, |n| n.locked = true);
let steps = undo_steps(&s);
let r = s.execute("addon.cleanup.simplify", &json!({"ids": [g.0]})).unwrap();
let r = s.execute("addon.cleanup.simplify", &json!({})).unwrap();
assert_eq!((r["before"].as_u64(), r["after"].as_u64()), (Some(0), Some(0)));
assert_eq!(anchors(&s, ids[1]), 16);
assert_eq!(undo_steps(&s), steps);
// Named by `ids`, the locked group's paths, its hidden one included, are simplified.
let r = s.execute("addon.cleanup.simplify", &json!({"ids": [g.0]})).unwrap();
assert_eq!(r["ids"], json!([ids[0].0, ids[1].0, ids[2].0]));
assert!(anchors(&s, ids[1]) < 16 && anchors(&s, ids[2]) < 16);
assert_eq!(undo_steps(&s), steps + 1);
}
/// A path of 16 anchors at (`x`, `y`): an ellipse with anchors added twice.
fn ellipse16(s: &mut Session, x: f64, y: f64) -> NodeId {
let e = id_of(&s.execute("shape.ellipse", &json!({"x": x, "y": y, "width": 50, "height": 50})).unwrap());
select(s, &[e]);
s.execute("object.path.addAnchorPoints", &json!({})).unwrap();
s.execute("object.path.addAnchorPoints", &json!({})).unwrap();
e
}
/// Simplify Anchors takes the locked and hidden paths `ids` names, those in the locked or hidden
/// groups and layers it names (their locked and hidden members too) and a hidden compound path's
/// member, as one undo step, without selecting them; from the selection it leaves them alone, and a
/// path on a locked artboard stays even when named.
#[test]
fn simplify_named_locked_and_hidden_paths() {
let mut s = session();
let locked = ellipse16(&mut s, 10.0, 10.0);
let hidden = ellipse16(&mut s, 70.0, 10.0);
change(&mut s, locked, |n| n.locked = true);
change(&mut s, hidden, |n| n.visible = false);
// A locked group with a hidden member, and a hidden group with a locked member.
let in_locked = ellipse16(&mut s, 130.0, 10.0);
let locked_group = wrap(&mut s, in_locked, |c| NodeKind::Group { children: c, clip: false });
change(&mut s, locked_group, |n| n.locked = true);
change(&mut s, in_locked, |n| n.visible = false);
let in_hidden = ellipse16(&mut s, 190.0, 10.0);
let hidden_group = wrap(&mut s, in_hidden, |c| NodeKind::Group { children: c, clip: false });
change(&mut s, hidden_group, |n| n.visible = false);
change(&mut s, in_hidden, |n| n.locked = true);
// A member of a hidden compound path.
let member = ellipse16(&mut s, 250.0, 10.0);
let compound = wrap(&mut s, member, |c| NodeKind::Compound { children: c, rule: FillRule::NonZero });
change(&mut s, compound, |n| n.visible = false);
// A path in a locked layer, named itself.
let layer = id_of(&s.execute("layer.new", &json!({})).unwrap());
let in_layer = ellipse16(&mut s, 10.0, 100.0);
let on_board = ellipse16(&mut s, 10.0, 200.0);
change(&mut s, layer, |n| n.locked = true);
let all = [locked, hidden, in_locked, in_hidden, member, in_layer];
select(&mut s, &[locked, hidden, locked_group, hidden_group, compound]);
let steps = undo_steps(&s);
let r = s.execute("addon.cleanup.simplify", &json!({})).unwrap();
assert_eq!(r["ids"], json!([]), "from the selection, none is taken");
assert_eq!(undo_steps(&s), steps);
assert!(all.iter().all(|id| anchors(&s, *id) == 16));
select(&mut s, &[]);
s.edit("Select Anchors", |_, sel| {
sel.anchors.insert(locked, [(0, 1)].into());
Ok(())
})
.unwrap();
let steps = undo_steps(&s);
let r = s.execute("addon.cleanup.simplify", &json!({"ids": [locked.0, hidden.0, locked_group.0, hidden_group.0, member.0, in_layer.0]})).unwrap();
assert_eq!(r["ids"], json!(all.iter().map(|i| i.0).collect::<Vec<_>>()));
assert_eq!(r["before"], 16 * 6);
assert_eq!(r["after"].as_u64(), Some(all.iter().map(|id| anchors(&s, *id) as u64).sum::<u64>()));
assert!(all.iter().all(|id| anchors(&s, *id) < 16), "{r}");
assert_eq!(undo_steps(&s), steps + 1);
let sel = &s.doc().unwrap().selection;
assert!(sel.objects.is_empty() && sel.anchors.is_empty(), "nothing is selected, and the changed path's anchors are not");
let n = node(&s, in_hidden).unwrap();
assert!(n.locked && n.visible && !node(&s, hidden_group).unwrap().visible);
s.execute("edit.undo", &json!({})).unwrap();
assert!(all.iter().all(|id| anchors(&s, *id) == 16));
s.execute("edit.redo", &json!({})).unwrap();
assert!(all.iter().all(|id| anchors(&s, *id) < 16));
// Nothing left to remove: no undo step.
let steps = undo_steps(&s);
s.execute("addon.cleanup.simplify", &json!({"ids": [locked.0]})).unwrap();
assert_eq!(undo_steps(&s), steps);
// On a locked artboard, a named path stays as it is.
s.edit("Lock Artboard", |d, _| {
d.artboards[0].locked = true;
Ok(())
})
.unwrap();
let steps = undo_steps(&s);
let r = s.execute("addon.cleanup.simplify", &json!({"ids": [on_board.0]})).unwrap();
assert_eq!((r["ids"].clone(), anchors(&s, on_board), undo_steps(&s)), (json!([]), 16, steps));
}
/// Each kind: what makes the issue in a fresh document (beside a clean rectangle).
@@ -610,14 +692,15 @@ fn deep_ungroup_keeps_hidden_art_hidden_and_locked_art_locked() {
assert!(!d.node(hidden).unwrap().visible && d.node(b).unwrap().visible);
assert!(d.node(locked).unwrap().locked && d.node(e).unwrap().locked);
assert_eq!(s.doc().unwrap().selection.objects, [a]);
// Named by `ids`, a locked group stays as it is, with no undo step.
// Named by `ids`, a locked group is taken apart as one undo step; its member becomes locked
// and is not selected.
let steps = undo_steps(&s);
let r = s.execute("addon.cleanup.deepUngroup", &json!({"ids": [locked.0]})).unwrap();
assert_eq!(r["ungrouped"], 0);
assert!(node(&s, locked).is_some());
assert_eq!(undo_steps(&s), steps);
assert_eq!(r["ungrouped"], 1);
assert!(node(&s, locked).is_none() && node(&s, c).unwrap().locked);
assert_eq!(undo_steps(&s), steps + 1);
assert_eq!(r["ids"], json!([]));
// With nothing ungrouped, the selection stays and the reply lists it.
assert_eq!(r["ids"], json!([a.0]));
let f = rect(&mut s, 130.0, 10.0, 20.0, 20.0);
select(&mut s, &[a]);
let r = s.execute("addon.cleanup.deepUngroup", &json!({"ids": [f.0]})).unwrap();
@@ -625,6 +708,108 @@ fn deep_ungroup_keeps_hidden_art_hidden_and_locked_art_locked() {
assert_eq!(s.doc().unwrap().selection.objects, [a]);
}
/// Put `ids` inside a new container that `kind` makes of its members.
fn wrap_all(s: &mut Session, ids: &[NodeId], kind: fn(Vec<std::sync::Arc<Node>>) -> NodeKind) -> NodeId {
s.edit("t", |d, _| {
let w = d.alloc_id();
Ok(d.wrap(ids, |c| Node::new(w, kind(c)))?)
})
.unwrap()
}
fn plain_group(c: Vec<std::sync::Arc<Node>>) -> NodeKind {
NodeKind::Group { children: c, clip: false }
}
fn sublayer(c: Vec<std::sync::Arc<Node>>) -> NodeKind {
NodeKind::Layer { color: LayerColor::Preset(0), template: false, printable: true, children: c, clip: false, preview: true, dim_images: None }
}
/// Named by `ids`, Deep Ungroup takes apart locked and hidden groups, those nested in a group it
/// names and those in a layer it names (its sublayers included, the layer staying), as one undo
/// step; their members become locked or hidden as the groups were, keep the groups' opacity and are
/// not selected. From the selection those groups stay whole, as do a clipping group and a group on a
/// locked artboard even when named.
#[test]
fn deep_ungroup_named_locked_and_hidden_groups() {
let mut s = session();
let a = rect(&mut s, 10.0, 10.0, 20.0, 20.0);
let b = rect(&mut s, 40.0, 10.0, 20.0, 20.0);
let locked = wrap_all(&mut s, &[a], plain_group);
let hidden = wrap_all(&mut s, &[b], plain_group);
change(&mut s, locked, |n| n.locked = true);
change(&mut s, hidden, |n| {
n.visible = false;
n.opacity = 0.5;
});
select(&mut s, &[locked, hidden]);
let steps = undo_steps(&s);
let r = s.execute("addon.cleanup.deepUngroup", &json!({})).unwrap();
assert_eq!((r["ungrouped"].clone(), undo_steps(&s)), (json!(0), steps), "from the selection, they stay whole");
assert!(node(&s, locked).is_some() && node(&s, hidden).is_some());
let r = s.execute("addon.cleanup.deepUngroup", &json!({"ids": [locked.0, hidden.0]})).unwrap();
assert_eq!((r["ungrouped"].clone(), r["ids"].clone()), (json!(2), json!([])));
assert_eq!(undo_steps(&s), steps + 1);
assert!(node(&s, locked).is_none() && node(&s, hidden).is_none());
let (na, nb) = (node(&s, a).unwrap(), node(&s, b).unwrap());
assert!(na.locked && na.visible && !nb.visible && !nb.locked);
assert_eq!(nb.opacity, 0.5);
assert!(s.doc().unwrap().selection.objects.is_empty());
s.execute("edit.undo", &json!({})).unwrap();
assert!(node(&s, locked).is_some() && !node(&s, a).unwrap().locked && node(&s, b).unwrap().visible);
s.execute("edit.redo", &json!({})).unwrap();
assert!(node(&s, locked).is_none() && node(&s, a).unwrap().locked);
// Locked and hidden groups nested in a named group that can be edited.
let c = rect(&mut s, 70.0, 10.0, 20.0, 20.0);
let e = rect(&mut s, 100.0, 10.0, 20.0, 20.0);
let f = rect(&mut s, 130.0, 10.0, 20.0, 20.0);
let inner_locked = wrap_all(&mut s, &[e], plain_group);
let inner_hidden = wrap_all(&mut s, &[f], plain_group);
change(&mut s, inner_locked, |n| n.locked = true);
change(&mut s, inner_hidden, |n| n.visible = false);
let outer = wrap_all(&mut s, &[c, inner_locked, inner_hidden], plain_group);
let r = s.execute("addon.cleanup.deepUngroup", &json!({"ids": [outer.0]})).unwrap();
assert_eq!((r["ungrouped"].clone(), r["ids"].clone()), (json!(3), json!([c.0])));
assert!(node(&s, e).unwrap().locked && !node(&s, f).unwrap().visible);
assert_eq!(s.doc().unwrap().selection.objects, [c]);
// In a named layer and its sublayer; the layers and a clipping group stay.
let layer = id_of(&s.execute("layer.new", &json!({})).unwrap());
let g = rect(&mut s, 10.0, 100.0, 20.0, 20.0);
let h = rect(&mut s, 40.0, 100.0, 20.0, 20.0);
let layer_locked = wrap_all(&mut s, &[g], plain_group);
change(&mut s, layer_locked, |n| n.locked = true);
let layer_hidden = wrap_all(&mut s, &[h], plain_group);
change(&mut s, layer_hidden, |n| n.visible = false);
let sub = wrap_all(&mut s, &[layer_hidden], sublayer);
let clipped = rect(&mut s, 100.0, 100.0, 10.0, 10.0);
let clip = rect(&mut s, 95.0, 95.0, 30.0, 30.0);
select(&mut s, &[clipped, clip]);
s.execute("object.clippingMask.make", &json!({})).unwrap();
let clip_group = s.doc().unwrap().selection.objects[0];
let steps = undo_steps(&s);
let r = s.execute("addon.cleanup.deepUngroup", &json!({"ids": [layer.0]})).unwrap();
assert_eq!((r["ungrouped"].clone(), undo_steps(&s)), (json!(2), steps + 1));
assert!(node(&s, layer_locked).is_none() && node(&s, layer_hidden).is_none());
assert!(node(&s, layer).is_some() && node(&s, clip_group).is_some());
assert!(node(&s, g).unwrap().locked && !node(&s, h).unwrap().visible);
assert_eq!(s.doc().unwrap().doc.parent_of(h), Some(sub));
// On a locked artboard, a named group stays whole, with the selection as it was.
let k = rect(&mut s, 10.0, 200.0, 20.0, 20.0);
let on_board = wrap_all(&mut s, &[k], plain_group);
let off_board = rect(&mut s, 500.0, 10.0, 20.0, 20.0);
s.edit("Lock Artboard", |d, _| {
d.artboards[0].locked = true;
Ok(())
})
.unwrap();
select(&mut s, &[off_board]);
let steps = undo_steps(&s);
let r = s.execute("addon.cleanup.deepUngroup", &json!({"ids": [on_board.0]})).unwrap();
assert_eq!((r["ungrouped"].clone(), r["ids"].clone(), undo_steps(&s)), (json!(0), json!([off_board.0]), steps));
assert!(node(&s, on_board).is_some());
assert_eq!(s.doc().unwrap().selection.objects, [off_board]);
}
#[test]
fn rejoin_joins_pieces_within_the_gap_only() {
let mut s = session();
@@ -646,3 +831,74 @@ fn rejoin_joins_pieces_within_the_gap_only() {
assert!(s.execute("addon.cleanup.rejoin", &bad).is_err(), "{bad}");
}
}
/// Named by `ids`, Rejoin Path Pieces takes locked and hidden pieces (in a locked group or layer
/// too) and joins only pieces alike in being hidden and in being locked, whichever is topmost, as one
/// undo step; a locked or hidden path it makes is not selected. From the selection it takes only
/// pieces that can be edited, and compound path members and pieces on a locked artboard stay out.
#[test]
fn rejoin_named_locked_and_hidden_pieces() {
let mut s = session();
let a = line(&mut s, &[(10.0, 10.0), (50.0, 10.0)]);
let b = line(&mut s, &[(50.05, 10.0), (90.0, 10.0)]);
let g = wrap_all(&mut s, &[a, b], plain_group);
change(&mut s, g, |n| n.locked = true);
select(&mut s, &[g]);
let steps = undo_steps(&s);
let r = s.execute("addon.cleanup.rejoin", &json!({})).unwrap();
assert_eq!((r["joined"].clone(), undo_steps(&s)), (json!(0), steps), "from the selection, none is taken");
let r = s.execute("addon.cleanup.rejoin", &json!({"ids": [g.0]})).unwrap();
assert_eq!((r["joined"].clone(), r["removed"].clone(), r["ids"].clone()), (json!(1), json!(1), json!([b.0])));
assert_eq!(undo_steps(&s), steps + 1);
assert!(node(&s, a).is_none() && anchors(&s, b) == 3);
assert!(s.doc().unwrap().selection.objects.is_empty(), "the locked path is not selected");
s.execute("edit.undo", &json!({})).unwrap();
assert!(node(&s, a).is_some() && anchors(&s, b) == 2);
s.execute("edit.redo", &json!({})).unwrap();
assert!(node(&s, a).is_none() && anchors(&s, b) == 3);
// A shown piece and two hidden ones, the hidden on top: only the hidden pair joins.
let c = line(&mut s, &[(10.0, 40.0), (50.0, 40.0)]);
let e = line(&mut s, &[(50.05, 40.0), (90.0, 40.0)]);
let h = line(&mut s, &[(90.05, 40.0), (130.0, 40.0)]);
change(&mut s, e, |n| n.visible = false);
change(&mut s, h, |n| n.visible = false);
let r = s.execute("addon.cleanup.rejoin", &json!({"ids": [c.0, e.0, h.0]})).unwrap();
assert_eq!((r["joined"].clone(), r["removed"].clone(), r["ids"].clone()), (json!(1), json!(1), json!([h.0])));
assert!(node(&s, e).is_none() && anchors(&s, c) == 2 && anchors(&s, h) == 3);
assert!(!node(&s, h).unwrap().visible && node(&s, c).unwrap().visible);
assert!(s.doc().unwrap().selection.objects.is_empty(), "the hidden path is not selected");
// An unlocked piece under a locked one, and a hidden piece under a shown one: none joins.
let k = line(&mut s, &[(10.0, 70.0), (50.0, 70.0)]);
let m = line(&mut s, &[(50.05, 70.0), (90.0, 70.0)]);
let n = line(&mut s, &[(10.0, 100.0), (50.0, 100.0)]);
let o = line(&mut s, &[(50.05, 100.0), (90.0, 100.0)]);
change(&mut s, m, |x| x.locked = true);
change(&mut s, n, |x| x.visible = false);
let steps = undo_steps(&s);
let r = s.execute("addon.cleanup.rejoin", &json!({"ids": [k.0, m.0, n.0, o.0]})).unwrap();
assert_eq!((r["joined"].clone(), r["removed"].clone(), undo_steps(&s)), (json!(0), json!(0), steps));
assert!([k, m, n, o].iter().all(|id| anchors(&s, *id) == 2));
// Named pieces in a locked layer join; members of a compound path stay out.
let layer = id_of(&s.execute("layer.new", &json!({})).unwrap());
let p = line(&mut s, &[(10.0, 130.0), (50.0, 130.0)]);
let q = line(&mut s, &[(50.05, 130.0), (90.0, 130.0)]);
let u = line(&mut s, &[(10.0, 160.0), (50.0, 160.0)]);
let v = line(&mut s, &[(50.05, 160.0), (90.0, 160.0)]);
wrap_all(&mut s, &[u, v], |c| NodeKind::Compound { children: c, rule: FillRule::NonZero });
let w = line(&mut s, &[(10.0, 200.0), (50.0, 200.0)]);
let x = line(&mut s, &[(50.05, 200.0), (90.0, 200.0)]);
change(&mut s, layer, |l| l.locked = true);
let r = s.execute("addon.cleanup.rejoin", &json!({"ids": [p.0, q.0, u.0, v.0]})).unwrap();
assert_eq!((r["joined"].clone(), r["ids"].clone()), (json!(1), json!([q.0])));
assert!(node(&s, p).is_none() && anchors(&s, u) == 2 && anchors(&s, v) == 2);
// On a locked artboard, named pieces stay as they are.
s.edit("Lock Artboard", |d, _| {
d.artboards[0].locked = true;
Ok(())
})
.unwrap();
let steps = undo_steps(&s);
let r = s.execute("addon.cleanup.rejoin", &json!({"ids": [w.0, x.0]})).unwrap();
assert_eq!((r["joined"].clone(), undo_steps(&s)), (json!(0), steps));
assert!(node(&s, w).is_some() && node(&s, x).is_some());
}
+11 -6
View File
@@ -11091,9 +11091,10 @@ Plug-ins › Artwork Cleanup and in its panel (`window.panel {panel: "artworkCle
`addon.cleanup.simplify {tolerance?, protectCorners?, ids?}` removes the anchors each selected path can do without and
refits the handles beside each one, so the outline keeps its shape. `tolerance` runs from 1 to 100 (20 by default); the
outline stays within tolerance × 0.02 pt of where it was, measured against the original outline, so removals don't add
up. With `protectCorners`, anchors where the path turns by more than 30° stay. Open ends always stay. Locked and hidden
paths, and paths in locked or hidden groups, are left out. It reports the anchor counts `before` and `after`, as one
undo step.
up. With `protectCorners`, anchors where the path turns by more than 30° stay. Open ends always stay. From the
selection, locked and hidden paths, and paths in locked or hidden groups, are left out. With `ids`, it also takes the
locked and hidden paths of the objects, groups and layers the ids name, but not paths on a locked artboard. It reports
the paths it took as `ids` and their anchor counts `before` and `after`, as one undo step when a path changed.
The health check looks for issues in the selection, or in the whole document while nothing is selected (`scope:
"selection"|"document"` picks one). Locked and hidden objects, and the insides of live or generated art (blends,
@@ -11127,13 +11128,17 @@ envelopes, graphs, symbols), are left out.
`addon.cleanup.deepUngroup {includeClipGroups?, ids?}` takes apart every level of grouping in the selection. Clipping
groups stay whole unless `includeClipGroups` is true; their clipping paths then become plain paths. Objects keep the
opacity of the groups they were in. Locked and hidden groups stay whole, and locked and hidden objects stay locked or
hidden and are left out of the selection.
opacity of the groups they were in. From the selection, locked and hidden groups stay whole. With `ids`, it also takes
apart the locked and hidden groups the ids name and the groups inside the groups and layers they name (a named layer
stays a layer), but not groups on a locked artboard; an object of a hidden group becomes hidden, and an object of a
locked group becomes locked. Locked and hidden objects stay locked or hidden and are left out of the selection.
`addon.cleanup.rejoin {gap?, ids?}` welds the selected open paths whose ends lie within `gap` (0 to 6 pt, 0.1 by
default) back into paths. Each end joins one other end: where several are in reach, the one the path turns least to go
on into. Pieces may run either way, and a chain whose ends meet closes. Each new path takes the place and paint of its
topmost piece.
topmost piece. With `ids`, it also takes locked and hidden open paths, but not paths on a locked artboard. Only pieces
that are alike in being hidden and in being locked (by themselves or by a group or layer above them) join, and locked
and hidden paths it makes are not selected.
```json
{"name":"run_command","arguments":{"command":"addon.cleanup.check","params":{"scope":"document"}}}
+7
View File
@@ -2182,6 +2182,13 @@ With `ids`, the text tools act on the type the ids name even when it, or its gro
rule). Tests: `named_locked_and_hidden_type`, `remove_breaks_named_locked_and_hidden_type` and
`remove_breaks_named_story_with_a_locked_frame` in `cmd::addons::cleanup::text::tests`.
With `ids`, Simplify Anchors, Deep Ungroup and Rejoin Path Pieces act the same way on the locked and hidden paths and
groups the ids name, and on those in the groups and layers they name, but not on art on a locked artboard; from the
selection they still take only art that can be edited (VectorCraft's own rule). Rejoin Path Pieces joins only pieces
alike in being hidden and in being locked, and the objects of a group Deep Ungroup takes apart keep its hidden or locked
state. Tests: `simplify_named_locked_and_hidden_paths`, `deep_ungroup_named_locked_and_hidden_groups` and
`rejoin_named_locked_and_hidden_pieces` in `cmd::addons::cleanup::tests`.
## Path Precision Tools add-on (2026-10-30)
A VectorCraft addition, not a feature of the reference app, so no area score moves. Object › Plug-ins › Path Precision Tools,