From 05527ee0f13e70fc57360e96b2b1e2bc659a8dee Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 14 Nov 2026 18:49:20 -0800 Subject: [PATCH] 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`. --- crates/engine/src/cmd/addons/cleanup.rs | 155 +++++++--- crates/engine/src/cmd/addons/cleanup/tests.rs | 280 +++++++++++++++++- docs/mcp.md | 17 +- docs/target-app-parity.md | 7 + 4 files changed, 400 insertions(+), 59 deletions(-) diff --git a/crates/engine/src/cmd/addons/cleanup.rs b/crates/engine/src/cmd/addons/cleanup.rs index cba512cd18..d5f241b4ee 100644 --- a/crates/engine/src/cmd/addons/cleanup.rs +++ b/crates/engine/src/cmd/addons/cleanup.rs @@ -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 { "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 { "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 { "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 { 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 { +/// 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(d: &Document, items: &mut Vec, 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 { 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 { } }); } - 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 { 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, 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, 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, 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) { + let locks = d.locked_artboards(); + fn visit(n: &Node, clips: bool, locks: &LockedArtboards, out: &mut Vec) { + 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 { 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 = { - 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 { } // 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 { Ok(json!({ "ids": selected.iter().map(|i| i.0).collect::>(), "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 { + fn visit(n: &Node, above: (bool, bool), out: &mut Vec) { + 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 { 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 = 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 = 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 = 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 = 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 = kept.iter().map(|k| k.0).collect(); @@ -369,7 +440,9 @@ fn rejoin(s: &mut Session, p: &Value) -> Result { 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(()) })?; } diff --git a/crates/engine/src/cmd/addons/cleanup/tests.rs b/crates/engine/src/cmd/addons/cleanup/tests.rs index 4d4ed6461b..e1a50b928b 100644 --- a/crates/engine/src/cmd/addons/cleanup/tests.rs +++ b/crates/engine/src/cmd/addons/cleanup/tests.rs @@ -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::>())); + assert_eq!(r["before"], 16 * 6); + assert_eq!(r["after"].as_u64(), Some(all.iter().map(|id| anchors(&s, *id) as u64).sum::())); + 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>) -> 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>) -> NodeKind { + NodeKind::Group { children: c, clip: false } +} + +fn sublayer(c: Vec>) -> 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()); +} diff --git a/docs/mcp.md b/docs/mcp.md index d4e03892e9..e1291c0554 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -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"}}} diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index cad5851e98..28d03d4c99 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -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,