mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-15 23:05:39 +00:00
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:
@@ -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(())
|
||||
})?;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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"}}}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user