diff --git a/crates/engine/src/cmd/addons/cleanup.rs b/crates/engine/src/cmd/addons/cleanup.rs index b65fc75ac..cba512cd1 100644 --- a/crates/engine/src/cmd/addons/cleanup.rs +++ b/crates/engine/src/cmd/addons/cleanup.rs @@ -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 → {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; open ends stay, and locked and hidden paths are left out → {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 → {ids (the objects left, selected), 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; 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)}", has_selection, deep_ungroup ), @@ -91,7 +91,8 @@ pub fn specs() -> Vec { v } -/// The paths (not guides) under `roots`, compound path members included. +/// 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 { let mut out = vec![]; for n in roots.iter().filter_map(|id| d.node(*id)) { @@ -101,6 +102,8 @@ fn paths_under(d: &Document, roots: &[NodeId]) -> Vec { } }); } + let editable = d.editable(); + out.retain(|id| editable(*id)); out } @@ -256,9 +259,9 @@ 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. +/// 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) { + if !ungroupable(&n, clips) || n.locked || !n.visible { out.push(n); return; } @@ -282,6 +285,11 @@ fn deep_ungroup(s: &mut Session, p: &Value) -> Result { let clips = switch_or(p, "includeClipGroups", false, C)?; let 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() + }; let (mut items, mut selected, mut count) = (vec![], vec![], 0); for id in &roots { match d.node(*id).filter(|n| ungroupable(n, clips)) { @@ -294,13 +302,18 @@ fn deep_ungroup(s: &mut Session, p: &Value) -> Result { None => selected.push(*id), } } - // With no group to take apart, the document and its undo history stay as they are. - if !items.is_empty() { - let ids = selected.clone(); - s.edit("Deep Ungroup", |d, sel| { + // 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() { + selected = s.doc()?.selection.objects.to_vec(); + } else { + selected = s.edit("Deep Ungroup", move |d, sel| { d.insert_beside(items, true)?; - sel.set(ids); - Ok(()) + // Members that are locked or hidden themselves keep that, and are not selected. + let editable = d.editable(); + selected.retain(|id| editable(*id)); + sel.set(selected.iter().copied()); + Ok(selected) })?; } Ok(json!({ "ids": selected.iter().map(|i| i.0).collect::>(), "ungrouped": count })) diff --git a/crates/engine/src/cmd/addons/cleanup/tests.rs b/crates/engine/src/cmd/addons/cleanup/tests.rs index d02e1e4c0..83b67c6b1 100644 --- a/crates/engine/src/cmd/addons/cleanup/tests.rs +++ b/crates/engine/src/cmd/addons/cleanup/tests.rs @@ -104,6 +104,39 @@ fn simplify_keeps_the_shape_with_fewer_anchors_in_one_step() { } } +#[test] +fn simplify_leaves_locked_and_hidden_paths_alone() { + let mut s = session(); + let mut ids = vec![]; + for x in [20.0, 140.0, 260.0] { + ids.push(id_of(&s.execute("shape.ellipse", &json!({"x": x, "y": 50, "width": 100, "height": 100})).unwrap())); + s.execute("object.path.addAnchorPoints", &json!({})).unwrap(); + s.execute("object.path.addAnchorPoints", &json!({})).unwrap(); + } + select(&mut s, &ids); + let g = id_of(&s.execute("object.group", &json!({})).unwrap()); + change(&mut s, ids[1], |n| n.locked = true); + change(&mut s, ids[2], |n| n.visible = false); + select(&mut s, &[g]); + let r = s.execute("addon.cleanup.simplify", &json!({})).unwrap(); + assert_eq!(r["ids"], json!([ids[0].0])); + 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); + 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(); + 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); +} + /// Each kind: what makes the issue in a fresh document (beside a clean rectangle). fn make_issue(s: &mut Session, kind: &str) { match kind { @@ -333,6 +366,51 @@ fn deep_ungroup_takes_apart_every_level_but_clipping_groups() { assert!(matches!(d.node(clip).unwrap().kind, NodeKind::Path { clipping: false, .. })); } +#[test] +fn deep_ungroup_keeps_hidden_art_hidden_and_locked_art_locked() { + 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 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); + select(&mut s, &[b]); + let hidden = id_of(&s.execute("object.group", &json!({})).unwrap()); + select(&mut s, &[c]); + let locked = id_of(&s.execute("object.group", &json!({})).unwrap()); + change(&mut s, hidden, |n| n.visible = false); + change(&mut s, locked, |n| n.locked = true); + change(&mut s, e, |n| n.locked = true); + s.edit("t", |d, _| { + let w = d.alloc_id(); + Ok(d.wrap(&[a, hidden, locked, e], |ch| Node::group(w, ch))?) + }) + .unwrap(); + let outer = s.doc().unwrap().doc.layers[0].children().unwrap()[0].id; + select(&mut s, &[outer]); + let r = s.execute("addon.cleanup.deepUngroup", &json!({})).unwrap(); + assert_eq!(r["ungrouped"], 1); + assert_eq!(r["ids"], json!([a.0])); + let d = &s.doc().unwrap().doc; + let layer: Vec = d.layers[0].children().unwrap().iter().map(|n| n.id).collect(); + assert_eq!(layer, [a, hidden, locked, e], "the hidden and locked groups stay whole"); + 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. + 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); + // 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(); + assert_eq!((r["ids"].clone(), r["ungrouped"].clone()), (json!([a.0]), json!(0))); + assert_eq!(s.doc().unwrap().selection.objects, [a]); +} + #[test] fn rejoin_joins_pieces_within_the_gap_only() { let mut s = session(); diff --git a/docs/mcp.md b/docs/mcp.md index 5a7948614..06700e7bc 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -5970,8 +5970,9 @@ 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. 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. 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. 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, @@ -6005,7 +6006,8 @@ 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. +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. `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