mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-31 21:27:00 -06:00
Add-ons: Artwork Cleanup — simplify keeping shape, health check, deep ungroup, rejoin
A built-in add-on under Object › Plug-ins › Artwork Cleanup: - Simplify Anchors (Keep Shape) removes anchors while the outline stays within a tolerance, optionally keeping sharp corners. - Document Health Check finds and fixes 16 kinds of problems in the selection or the document (stray points, duplicate and collinear anchors, tiny handles, nearly closed paths, needless groups, compound paths and clipping groups, unpainted and invisible objects, duplicate objects, art off every artboard, empty artboards…), with Show, Fix Selected and Fix All, each fix one undo step. - Deep Ungroup and Rejoin Path Pieces. The add-on lives in its own modules (pathops, engine and ui-egui addons folders) and leaves the existing commands, tools and menus as they were.
This commit is contained in:
@@ -0,0 +1,11 @@
|
||||
//! VectorCraft's built-in add-ons: extra tools beyond the standard menus, each a module of its own
|
||||
//! with commands under `addon.<name>.*` (see `docs/mcp.md`).
|
||||
|
||||
mod cleanup;
|
||||
|
||||
use super::CommandSpec;
|
||||
|
||||
/// The commands of every built-in add-on.
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
cleanup::specs()
|
||||
}
|
||||
@@ -0,0 +1,362 @@
|
||||
//! Artwork Cleanup (`addon.cleanup.*`), a built-in add-on: Simplify Anchors (Keep Shape), the
|
||||
//! Document Health Check (check, show and fix the issues it finds), Deep Ungroup and Rejoin Path
|
||||
//! Pieces. Its panel and its submenu, Object › Plug-ins › Artwork Cleanup, are the UI's.
|
||||
|
||||
mod checks;
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
use serde_json::{Map, Value, json};
|
||||
use vectorcraft_doc::{Document, Node, NodeId, NodeKind};
|
||||
use vectorcraft_geom::{PathData, SubPath};
|
||||
use vectorcraft_pathops::addons as po;
|
||||
|
||||
use self::checks::{Kind, MAX_OBJECTS, Outcome, Scope, set_path};
|
||||
use super::super::edit::selected_roots;
|
||||
use super::super::{CommandSpec, bad, checked_ids_or_selection, cmd, has_doc, has_selection, number_or, switch_or, text_param};
|
||||
use crate::{EngineError, Result, Session};
|
||||
|
||||
/// Simplify Anchors' tolerance by default, and the drift (pt) each step of it allows.
|
||||
const TOLERANCE: f64 = 20.0;
|
||||
const PT_PER_TOLERANCE: f64 = 0.02;
|
||||
/// Rejoin Path Pieces' gap by default, and the widest (pt).
|
||||
const GAP: f64 = 0.1;
|
||||
const MAX_GAP: f64 = 6.0;
|
||||
/// Most anchors Simplify Anchors takes at once.
|
||||
const MAX_ANCHORS: usize = 2_000_000;
|
||||
/// Most rounds of fixes `addon.cleanup.fix` runs.
|
||||
const FIX_ROUNDS: usize = 3;
|
||||
/// Most ids a finding lists (its count counts them all).
|
||||
const MAX_LISTED: usize = 1000;
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!(
|
||||
"addon.cleanup.simplify",
|
||||
"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",
|
||||
has_selection,
|
||||
simplify
|
||||
),
|
||||
cmd!(
|
||||
query "addon.cleanup.check",
|
||||
"Health Check",
|
||||
["Object", "Plug-ins", "Artwork Cleanup"],
|
||||
None,
|
||||
"{scope?: \"selection\"|\"document\" (default: the selection, or the document while nothing is selected), kinds?: [kind…] (default: all: emptyText, strayPoints (paths of one anchor), unpainted, zeroOpacity, duplicateObjects (a path stacked on an identical one), offArtboard, duplicateAnchors (neighbours within 0.01 pt), tinyHandles (under 0.01 pt), collinearAnchors (corners on a straight run, turning less than sine 0.005), nearPoints (anchors within 0.01 pt of other paths' anchors), nearlyClosed (ends within 0.01 pt), filledOpenPaths (filled, unstroked open paths), needlessClipGroups (an unpainted clip that clips nothing, hides everything, or is a rectangle round all its art), needlessCompounds (one path or none), needlessGroups (one member or none, nothing of their own), emptyArtboards (the document only))} find issues without changing anything; locked and hidden objects are left out → {scope, total, findings: [{kind, label, count, ids (the objects, at most 1000), artboards (indexes)}]} for the kinds found",
|
||||
has_doc,
|
||||
check
|
||||
),
|
||||
cmd!(
|
||||
query "addon.cleanup.show",
|
||||
"Show Issue",
|
||||
["Object", "Plug-ins", "Artwork Cleanup"],
|
||||
None,
|
||||
"{kind: kind (see addon.cleanup.check), scope?: \"selection\"|\"document\"} select the objects with that issue → {kind, count, ids, artboards}",
|
||||
has_doc,
|
||||
show
|
||||
),
|
||||
cmd!(
|
||||
"addon.cleanup.fix",
|
||||
"Fix All Issues",
|
||||
["Object", "Plug-ins", "Artwork Cleanup"],
|
||||
None,
|
||||
"{scope?: \"selection\"|\"document\", kinds?: [kind…] (default: all; see addon.cleanup.check)} fix the issues addon.cleanup.check finds, in up to three rounds while a round still finds some (a fix can leave issues for another kind), as one undo step: what is empty, unpainted, invisible, duplicated or off the artboards goes, anchors merge, snap or go, tiny handles retract, paths close and needless groups, compound paths and clipping groups give way to their contents → {scope, total, fixed: {kind: count}}",
|
||||
has_doc,
|
||||
fix
|
||||
),
|
||||
cmd!(
|
||||
"addon.cleanup.deepUngroup",
|
||||
"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)}",
|
||||
has_selection,
|
||||
deep_ungroup
|
||||
),
|
||||
cmd!(
|
||||
"addon.cleanup.rejoin",
|
||||
"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)}",
|
||||
has_selection,
|
||||
rejoin
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
/// The paths (not guides) under `roots`, compound path members included.
|
||||
fn paths_under(d: &Document, roots: &[NodeId]) -> Vec<NodeId> {
|
||||
let mut out = vec![];
|
||||
for n in roots.iter().filter_map(|id| d.node(*id)) {
|
||||
n.walk(&mut |c| {
|
||||
if matches!(c.kind, NodeKind::Path { guide: false, .. }) {
|
||||
out.push(c.id);
|
||||
}
|
||||
});
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn simplify(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "addon.cleanup.simplify";
|
||||
let tolerance = number_or(p, "tolerance", TOLERANCE, C)?;
|
||||
if !(1.0..=100.0).contains(&tolerance) {
|
||||
return Err(bad(C, "`tolerance` must be from 1 to 100"));
|
||||
}
|
||||
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 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 {
|
||||
return Err(EngineError::Other("Simplify Anchors: the paths have too many anchors to simplify at once".into()));
|
||||
}
|
||||
let changed: Vec<(NodeId, PathData)> = paths
|
||||
.iter()
|
||||
.filter_map(|(id, path)| {
|
||||
let np = po::simplify_keep_shape(path, tolerance * PT_PER_TOLERANCE, protect);
|
||||
(np != **path).then_some((*id, np))
|
||||
})
|
||||
.collect();
|
||||
let removed: usize = changed.iter().filter_map(|(id, np)| Some(d.node(*id)?.path_data()?.anchor_count().saturating_sub(np.anchor_count()))).sum();
|
||||
let after = before.saturating_sub(removed);
|
||||
// When no anchor can go, the document and its undo history stay as they are.
|
||||
if !changed.is_empty() {
|
||||
s.edit("Simplify Anchors (Keep Shape)", |d, sel| {
|
||||
for (id, np) in changed {
|
||||
// The anchors left are new ones: the path is selected whole.
|
||||
sel.anchors.remove(&id);
|
||||
sel.segments.remove(&id);
|
||||
set_path(d, id, np);
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
}
|
||||
Ok(json!({ "ids": ids.iter().map(|i| i.0).collect::<Vec<_>>(), "before": before, "after": after }))
|
||||
}
|
||||
|
||||
/// The scope a check, show or fix request names (by default the selection, or the document while
|
||||
/// nothing is selected) and its name.
|
||||
fn scope_of(s: &Session, p: &Value, cmd: &str) -> Result<(Scope, &'static str)> {
|
||||
let roots = selected_roots(s)?;
|
||||
match text_param(p, "scope", cmd)? {
|
||||
Some("document") => Ok((Scope::Document, "document")),
|
||||
None if roots.is_empty() => Ok((Scope::Document, "document")),
|
||||
Some("selection") if roots.is_empty() => Err(bad(cmd, "nothing selected")),
|
||||
Some("selection") | None => Ok((Scope::Objects(roots), "selection")),
|
||||
Some(other) => Err(bad(cmd, format!("`scope` must be \"selection\" or \"document\", not {other:?}"))),
|
||||
}
|
||||
}
|
||||
|
||||
/// The kinds a request names (all by default), in the order Fix runs them.
|
||||
fn kinds_of(p: &Value, cmd: &str) -> Result<Vec<Kind>> {
|
||||
let given: Vec<Kind> = match p.get("kinds") {
|
||||
None | Some(Value::Null) => return Ok(Kind::ALL.to_vec()),
|
||||
Some(Value::Array(a)) => {
|
||||
a.iter().map(|v| v.as_str().and_then(Kind::parse).ok_or_else(|| bad(cmd, format!("unknown issue kind {v}")))).collect::<Result<_>>()?
|
||||
}
|
||||
Some(_) => return Err(bad(cmd, "`kinds` must be a list of issue kinds")),
|
||||
};
|
||||
Ok(Kind::ALL.into_iter().filter(|k| given.contains(k)).collect())
|
||||
}
|
||||
|
||||
/// Kind `kind`'s fix run on a copy of the active document: what Fix would change.
|
||||
fn dry_run(s: &Session, kind: Kind, scope: &Scope) -> Result<Outcome> {
|
||||
let st = s.doc()?;
|
||||
let (mut d, mut sel, mut scope) = ((*st.doc).clone(), st.selection.clone(), scope.clone());
|
||||
kind.fix(&mut d, &mut sel, &mut scope)
|
||||
}
|
||||
|
||||
fn finding(kind: Kind, o: &Outcome) -> Value {
|
||||
let ids: Vec<u64> = o.ids.iter().take(MAX_LISTED).map(|i| i.0).collect();
|
||||
json!({ "kind": kind.id(), "label": kind.label(), "count": o.count, "ids": ids, "artboards": o.artboards })
|
||||
}
|
||||
|
||||
fn check(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "addon.cleanup.check";
|
||||
let (scope, name) = scope_of(s, p, C)?;
|
||||
let mut findings = vec![];
|
||||
let mut total = 0;
|
||||
for kind in kinds_of(p, C)? {
|
||||
let o = dry_run(s, kind, &scope)?;
|
||||
if o.count > 0 {
|
||||
total += o.count;
|
||||
findings.push(finding(kind, &o));
|
||||
}
|
||||
}
|
||||
Ok(json!({ "scope": name, "total": total, "findings": findings }))
|
||||
}
|
||||
|
||||
fn show(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "addon.cleanup.show";
|
||||
let id = text_param(p, "kind", C)?.ok_or_else(|| bad(C, "missing `kind`"))?;
|
||||
let kind = Kind::parse(id).ok_or_else(|| bad(C, format!("unknown issue kind {id:?}")))?;
|
||||
let (scope, _) = scope_of(s, p, C)?;
|
||||
let o = dry_run(s, kind, &scope)?;
|
||||
if o.count == 0 {
|
||||
return Err(EngineError::Other("None of these issues are left: check again".into()));
|
||||
}
|
||||
// Artboards are no objects to select: the selection stays as it is for them.
|
||||
let ids = o.ids.clone();
|
||||
if !ids.is_empty() {
|
||||
s.select(|_, sel| sel.set(ids))?;
|
||||
}
|
||||
Ok(finding(kind, &o))
|
||||
}
|
||||
|
||||
fn fix(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "addon.cleanup.fix";
|
||||
let (mut scope, name) = scope_of(s, p, C)?;
|
||||
let kinds = kinds_of(p, C)?;
|
||||
// Fixed on a copy, kind after kind (what one leaves, the next cleans up), then kept as one step.
|
||||
let st = s.doc()?;
|
||||
let (mut d, mut sel) = ((*st.doc).clone(), st.selection.clone());
|
||||
let mut fixed = Map::new();
|
||||
let mut total = 0;
|
||||
// A fix can leave issues for a kind that ran before it (groups unwrapped side by side may hold
|
||||
// duplicates): the kinds run again, a few rounds at most, until a round finds nothing.
|
||||
for _ in 0..FIX_ROUNDS {
|
||||
let before = total;
|
||||
for &kind in &kinds {
|
||||
let o = kind.fix(&mut d, &mut sel, &mut scope)?;
|
||||
if o.count > 0 {
|
||||
total += o.count;
|
||||
let n = fixed.get(kind.id()).and_then(Value::as_u64).unwrap_or(0);
|
||||
fixed.insert(kind.id().into(), json!(n + o.count as u64));
|
||||
}
|
||||
}
|
||||
if total == before {
|
||||
break;
|
||||
}
|
||||
}
|
||||
// With nothing to fix, the document and its undo history stay as they are.
|
||||
if total > 0 {
|
||||
sel.prune(&d);
|
||||
s.edit("Fix Issues", |doc, selection| {
|
||||
*doc = d;
|
||||
*selection = sel;
|
||||
Ok(())
|
||||
})?;
|
||||
}
|
||||
Ok(json!({ "scope": name, "total": total, "fixed": fixed }))
|
||||
}
|
||||
|
||||
/// Is `n` a group Deep Ungroup takes apart (a clipping group only with `clips`)?
|
||||
fn ungroupable(n: &Node, clips: bool) -> bool {
|
||||
matches!(n.kind, NodeKind::Group { clip, .. } if clips || !clip)
|
||||
}
|
||||
|
||||
/// `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.
|
||||
fn flatten(n: Node, clips: bool, out: &mut Vec<Node>, count: &mut usize) {
|
||||
if !ungroupable(&n, clips) {
|
||||
out.push(n);
|
||||
return;
|
||||
}
|
||||
let NodeKind::Group { children, clip } = &n.kind else { return };
|
||||
*count += 1;
|
||||
for (k, c) in children.iter().enumerate() {
|
||||
let mut c = (**c).clone();
|
||||
if *clip
|
||||
&& k == 0
|
||||
&& let NodeKind::Path { clipping, .. } = &mut c.kind
|
||||
{
|
||||
*clipping = false;
|
||||
}
|
||||
c.opacity *= n.opacity;
|
||||
flatten(c, clips, out, count);
|
||||
}
|
||||
}
|
||||
|
||||
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 d = &s.doc()?.doc;
|
||||
let (mut items, mut selected, mut count) = (vec![], vec![], 0);
|
||||
for id in &roots {
|
||||
match d.node(*id).filter(|n| ungroupable(n, clips)) {
|
||||
Some(n) => {
|
||||
let mut out = vec![];
|
||||
flatten(n.clone(), clips, &mut out, &mut count);
|
||||
selected.extend(out.iter().map(|n| n.id));
|
||||
items.push((*id, out));
|
||||
}
|
||||
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| {
|
||||
d.insert_beside(items, true)?;
|
||||
sel.set(ids);
|
||||
Ok(())
|
||||
})?;
|
||||
}
|
||||
Ok(json!({ "ids": selected.iter().map(|i| i.0).collect::<Vec<_>>(), "ungrouped": count }))
|
||||
}
|
||||
|
||||
/// 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)>) {
|
||||
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()));
|
||||
}
|
||||
}
|
||||
NodeKind::Group { children, .. } | NodeKind::Layer { children, .. } => children.iter().for_each(|c| visit(c, 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));
|
||||
out
|
||||
}
|
||||
|
||||
fn rejoin(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "addon.cleanup.rejoin";
|
||||
let gap = number_or(p, "gap", GAP, C)?;
|
||||
if !(0.0..=MAX_GAP).contains(&gap) {
|
||||
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);
|
||||
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.
|
||||
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));
|
||||
}
|
||||
if !kept.is_empty() {
|
||||
let ids: Vec<NodeId> = kept.iter().map(|k| k.0).collect();
|
||||
s.edit("Rejoin Path Pieces", |d, sel| {
|
||||
for (id, sp) in &kept {
|
||||
set_path(d, *id, PathData::single(sp.clone()));
|
||||
}
|
||||
d.remove_many(&gone)?;
|
||||
sel.set(ids);
|
||||
Ok(())
|
||||
})?;
|
||||
}
|
||||
let ids: Vec<u64> = kept.iter().map(|k| k.0.0).collect();
|
||||
Ok(json!({ "ids": ids, "joined": kept.len(), "removed": gone.len() }))
|
||||
}
|
||||
@@ -0,0 +1,454 @@
|
||||
//! Document Health Check: the kinds of issue Artwork Cleanup looks for, and how each is fixed. A
|
||||
//! check runs a kind's fix on a copy of the document, so what a check finds is what Fix changes.
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
use vectorcraft_color::BlendMode;
|
||||
use vectorcraft_doc::appearance::AppearanceItem;
|
||||
use vectorcraft_doc::{Appearance, Document, Knockout, Node, NodeExtras, NodeId, NodeKind, Selection, SymbolBoxes};
|
||||
use vectorcraft_geom::{PathData, Point, Rect};
|
||||
use vectorcraft_pathops::addons as po;
|
||||
|
||||
use super::super::super::edit::outermost;
|
||||
use super::super::super::layer::empty_artboards;
|
||||
use crate::Result;
|
||||
|
||||
/// Sine of the turn below which an anchor on a straight run is collinear.
|
||||
const COLLINEAR: f64 = 0.005;
|
||||
/// Distance (pt) within which anchors coincide, handles are tiny and path ends meet.
|
||||
const NEAR: f64 = 0.01;
|
||||
/// Most objects a check looks at; the rest of a larger document is left as it is.
|
||||
pub(super) const MAX_OBJECTS: usize = 250_000;
|
||||
/// Most anchors Near Anchors compares.
|
||||
const MAX_ANCHORS: usize = 1_000_000;
|
||||
/// Most rounds of unwrapping nested groups.
|
||||
const MAX_PASSES: usize = 64;
|
||||
|
||||
/// One kind of issue.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(super) enum Kind {
|
||||
EmptyText,
|
||||
StrayPoints,
|
||||
Unpainted,
|
||||
ZeroOpacity,
|
||||
DuplicateObjects,
|
||||
OffArtboard,
|
||||
DuplicateAnchors,
|
||||
TinyHandles,
|
||||
CollinearAnchors,
|
||||
NearPoints,
|
||||
NearlyClosed,
|
||||
FilledOpenPaths,
|
||||
NeedlessClipGroups,
|
||||
NeedlessCompounds,
|
||||
NeedlessGroups,
|
||||
EmptyArtboards,
|
||||
}
|
||||
|
||||
impl Kind {
|
||||
/// Every kind, in the order Fix runs them (removing art first can leave groups to unwrap).
|
||||
pub(super) const ALL: [Kind; 16] = [
|
||||
Kind::EmptyText,
|
||||
Kind::StrayPoints,
|
||||
Kind::Unpainted,
|
||||
Kind::ZeroOpacity,
|
||||
Kind::DuplicateObjects,
|
||||
Kind::OffArtboard,
|
||||
Kind::DuplicateAnchors,
|
||||
Kind::TinyHandles,
|
||||
Kind::CollinearAnchors,
|
||||
Kind::NearPoints,
|
||||
Kind::NearlyClosed,
|
||||
Kind::FilledOpenPaths,
|
||||
Kind::NeedlessClipGroups,
|
||||
Kind::NeedlessCompounds,
|
||||
Kind::NeedlessGroups,
|
||||
Kind::EmptyArtboards,
|
||||
];
|
||||
|
||||
/// (id, label) of the kind.
|
||||
fn names(self) -> (&'static str, &'static str) {
|
||||
match self {
|
||||
Kind::EmptyText => ("emptyText", "Empty Text"),
|
||||
Kind::StrayPoints => ("strayPoints", "Stray Points"),
|
||||
Kind::Unpainted => ("unpainted", "Unpainted Objects"),
|
||||
Kind::ZeroOpacity => ("zeroOpacity", "Zero-Opacity Objects"),
|
||||
Kind::DuplicateObjects => ("duplicateObjects", "Duplicate Objects"),
|
||||
Kind::OffArtboard => ("offArtboard", "Off-Artboard Art"),
|
||||
Kind::DuplicateAnchors => ("duplicateAnchors", "Duplicate Anchors"),
|
||||
Kind::TinyHandles => ("tinyHandles", "Tiny Handles"),
|
||||
Kind::CollinearAnchors => ("collinearAnchors", "Collinear Anchors"),
|
||||
Kind::NearPoints => ("nearPoints", "Nearly Coincident Anchors"),
|
||||
Kind::NearlyClosed => ("nearlyClosed", "Nearly Closed Paths"),
|
||||
Kind::FilledOpenPaths => ("filledOpenPaths", "Filled Open Paths"),
|
||||
Kind::NeedlessClipGroups => ("needlessClipGroups", "Needless Clipping Groups"),
|
||||
Kind::NeedlessCompounds => ("needlessCompounds", "Needless Compound Paths"),
|
||||
Kind::NeedlessGroups => ("needlessGroups", "Needless Groups"),
|
||||
Kind::EmptyArtboards => ("emptyArtboards", "Empty Artboards"),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn id(self) -> &'static str {
|
||||
self.names().0
|
||||
}
|
||||
|
||||
pub(super) fn label(self) -> &'static str {
|
||||
self.names().1
|
||||
}
|
||||
|
||||
pub(super) fn parse(id: &str) -> Option<Kind> {
|
||||
Kind::ALL.into_iter().find(|k| k.id() == id)
|
||||
}
|
||||
|
||||
/// Fix this kind of issue in `scope` of `d` → what changed.
|
||||
pub(super) fn fix(self, d: &mut Document, sel: &mut Selection, scope: &mut Scope) -> Result<Outcome> {
|
||||
match self {
|
||||
Kind::DuplicateAnchors => reshape(d, scope, |_, p| p.subpaths.iter_mut().map(|sp| po::merge_duplicate_anchors(sp, NEAR)).sum()),
|
||||
Kind::TinyHandles => reshape(d, scope, |_, p| p.subpaths.iter_mut().map(|sp| po::retract_tiny_handles(sp, NEAR)).sum()),
|
||||
Kind::CollinearAnchors => reshape(d, scope, |_, p| p.subpaths.iter_mut().map(|sp| po::remove_collinear_anchors(sp, COLLINEAR)).sum()),
|
||||
Kind::NearlyClosed => reshape(d, scope, |_, p| p.subpaths.iter_mut().map(|sp| usize::from(po::close_nearly_closed(sp, NEAR))).sum()),
|
||||
Kind::FilledOpenPaths => reshape(d, scope, close_filled),
|
||||
Kind::NearPoints => near_points(d, scope),
|
||||
Kind::EmptyArtboards => artboards(d, sel, scope),
|
||||
_ => restructure(d, scope, self),
|
||||
}
|
||||
}
|
||||
|
||||
/// What replaces object `n` (at `place`) when it is this kind of issue: nothing to remove it,
|
||||
/// its members to unwrap it. `None` when it isn't one.
|
||||
fn replacement(self, cx: &Context, n: &Node, place: Place) -> Option<Vec<Node>> {
|
||||
let removable = !place.clips && !is_guide_or_clip(n);
|
||||
let gone = |is: bool| is.then(Vec::new);
|
||||
match self {
|
||||
Kind::EmptyText => gone(matches!(&n.kind, NodeKind::Text(t) if t.plain_text().trim().is_empty())),
|
||||
Kind::StrayPoints => gone(removable && matches!(&n.kind, NodeKind::Path { path, .. } if path.anchor_count() <= 1)),
|
||||
Kind::Unpainted => gone(removable && !place.in_compound && is_shape(n) && !is_painted(&n.appearance)),
|
||||
Kind::ZeroOpacity => gone(removable && !place.in_compound && n.opacity <= 0.0),
|
||||
Kind::DuplicateObjects => gone(removable && duplicates_the_one_below(cx.d, n)),
|
||||
Kind::OffArtboard => gone(place.top && !is_guide_or_clip(n) && off_artboards(cx, n)),
|
||||
Kind::NeedlessGroups => needless_group(n),
|
||||
Kind::NeedlessCompounds => needless_compound(n),
|
||||
Kind::NeedlessClipGroups => needless_clip_group(cx, n),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What a fix changed (or a check found): how many issues, the objects they are on and the
|
||||
/// artboards (by index) they are.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub(super) struct Outcome {
|
||||
pub count: usize,
|
||||
pub ids: Vec<NodeId>,
|
||||
pub artboards: Vec<usize>,
|
||||
}
|
||||
|
||||
/// What a check looks at: the whole document, or the objects given and what is inside them.
|
||||
#[derive(Clone, Debug)]
|
||||
pub(super) enum Scope {
|
||||
Document,
|
||||
Objects(Vec<NodeId>),
|
||||
}
|
||||
|
||||
/// Where an object sits.
|
||||
#[derive(Clone, Copy, Default)]
|
||||
struct Place {
|
||||
/// A member of a compound path.
|
||||
in_compound: bool,
|
||||
/// The clipping path of its group or layer.
|
||||
clips: bool,
|
||||
/// A top-level object of the scope (on a layer, or given).
|
||||
top: bool,
|
||||
}
|
||||
|
||||
/// The document a pass looks at, with its symbols measured once.
|
||||
struct Context<'a> {
|
||||
d: &'a Document,
|
||||
symbols: SymbolBoxes<'a>,
|
||||
}
|
||||
|
||||
/// The visible, unlocked objects in `scope` with where they sit, in paint order: groups, layers,
|
||||
/// clip groups and compound paths are looked into; live and generated art (blends, envelopes,
|
||||
/// graphs, symbols…) is taken as it is.
|
||||
fn objects<'a>(d: &'a Document, scope: &Scope) -> Vec<(&'a Node, Place)> {
|
||||
let mut out = vec![];
|
||||
let top = Place { top: true, ..Place::default() };
|
||||
match scope {
|
||||
Scope::Document => d.layers.iter().for_each(|l| visit(l, top, &mut out)),
|
||||
Scope::Objects(ids) => ids.iter().filter_map(|id| d.node(*id)).for_each(|n| visit(n, top, &mut out)),
|
||||
}
|
||||
let editable = d.editable();
|
||||
out.retain(|(n, _)| editable(n.id));
|
||||
out
|
||||
}
|
||||
|
||||
fn visit<'a>(n: &'a Node, place: Place, out: &mut Vec<(&'a Node, Place)>) {
|
||||
if !n.visible || n.locked || out.len() >= MAX_OBJECTS {
|
||||
return;
|
||||
}
|
||||
let inner = |clip: bool, i: usize, top: bool| Place { clips: clip && i == 0, top, in_compound: false };
|
||||
match &n.kind {
|
||||
// A layer's objects are top-level ones.
|
||||
NodeKind::Layer { children, clip, .. } => children.iter().enumerate().for_each(|(i, c)| visit(c, inner(*clip, i, true), out)),
|
||||
NodeKind::Group { children, clip } if !is_generated(n) => {
|
||||
out.push((n, place));
|
||||
children.iter().enumerate().for_each(|(i, c)| visit(c, inner(*clip, i, false), out));
|
||||
}
|
||||
NodeKind::Compound { children, .. } => {
|
||||
out.push((n, place));
|
||||
children.iter().for_each(|c| visit(c, Place { in_compound: true, ..Place::default() }, out));
|
||||
}
|
||||
_ => out.push((n, place)),
|
||||
}
|
||||
}
|
||||
|
||||
/// A group that stands for live or generated art: its members aren't loose art to clean.
|
||||
fn is_generated(n: &Node) -> bool {
|
||||
let e = n.extras();
|
||||
e.graph.is_some() || e.shaper.is_some() || e.dimension.is_some() || e.objects_on_path.is_some() || e.live_paint_gaps.is_some() || e.compound_shape
|
||||
}
|
||||
|
||||
fn is_guide_or_clip(n: &Node) -> bool {
|
||||
matches!(n.kind, NodeKind::Path { guide: true, .. } | NodeKind::Path { clipping: true, .. })
|
||||
}
|
||||
|
||||
fn is_shape(n: &Node) -> bool {
|
||||
matches!(n.kind, NodeKind::Path { .. } | NodeKind::Compound { .. })
|
||||
}
|
||||
|
||||
fn is_painted(a: &Appearance) -> bool {
|
||||
a.items.iter().any(|i| match i {
|
||||
AppearanceItem::Fill(f) => f.visible && !f.paint.is_none(),
|
||||
AppearanceItem::Stroke(s) => s.visible && !s.paint.is_none() && s.width > 0.0,
|
||||
})
|
||||
}
|
||||
|
||||
/// A container with nothing of its own to show: full opacity, normal blending, no fill, stroke,
|
||||
/// effect, mask, knockout or anything else attached.
|
||||
fn is_plain(n: &Node) -> bool {
|
||||
n.opacity >= 1.0
|
||||
&& n.blend == BlendMode::Normal
|
||||
&& n.appearance.items.is_empty()
|
||||
&& n.appearance.effects.is_empty()
|
||||
&& !n.isolate
|
||||
&& n.knockout == Knockout::Neutral
|
||||
&& *n.extras() == NodeExtras::default()
|
||||
}
|
||||
|
||||
/// Is `n` a plain path (full opacity, normal blending) identical to the object just below it?
|
||||
fn duplicates_the_one_below(d: &Document, n: &Node) -> bool {
|
||||
if !matches!(n.kind, NodeKind::Path { .. }) || n.opacity < 1.0 || n.blend != BlendMode::Normal {
|
||||
return false;
|
||||
}
|
||||
let below = d.position(n.id).and_then(|(parent, i, _)| d.children(parent)?.get(i.checked_sub(1)?).cloned());
|
||||
below.is_some_and(|b| {
|
||||
b.visible && b.kind == n.kind && b.appearance == n.appearance && b.opacity == n.opacity && b.blend == n.blend && b.extras() == n.extras()
|
||||
})
|
||||
}
|
||||
|
||||
fn inside(a: Rect, b: Rect) -> bool {
|
||||
a.x0 >= b.x0 && a.y0 >= b.y0 && a.x1 <= b.x1 && a.y1 <= b.y1
|
||||
}
|
||||
|
||||
fn meets(a: Rect, b: Rect) -> bool {
|
||||
a.x0 <= b.x1 && b.x0 <= a.x1 && a.y0 <= b.y1 && b.y0 <= a.y1
|
||||
}
|
||||
|
||||
/// Does `n` lie wholly off every artboard (a document without artboards has none off them)?
|
||||
fn off_artboards(cx: &Context, n: &Node) -> bool {
|
||||
let Some(b) = n.visual_bounds_with(&cx.symbols) else { return false };
|
||||
// As Delete Empty Artboards measures: a millionth of a point covers rounding at the edges.
|
||||
!cx.d.artboards.is_empty() && !cx.d.artboards.iter().any(|a| meets(a.rect.inflate(1e-6, 1e-6), b))
|
||||
}
|
||||
|
||||
/// A plain group of one member or none: its member, or nothing.
|
||||
fn needless_group(n: &Node) -> Option<Vec<Node>> {
|
||||
let NodeKind::Group { children, clip: false } = &n.kind else { return None };
|
||||
(children.len() <= 1 && is_plain(n)).then(|| children.iter().map(|c| (**c).clone()).collect())
|
||||
}
|
||||
|
||||
/// A compound path of one path or none: that path, painted as the compound was, or nothing.
|
||||
fn needless_compound(n: &Node) -> Option<Vec<Node>> {
|
||||
let NodeKind::Compound { children, rule } = &n.kind else { return None };
|
||||
let mut path = match children.as_slice() {
|
||||
[] => return Some(vec![]),
|
||||
[one] => (**one).clone(),
|
||||
_ => return None,
|
||||
};
|
||||
let NodeKind::Path { rule: r, .. } = &mut path.kind else { return None };
|
||||
*r = *rule;
|
||||
path.appearance = n.appearance.clone();
|
||||
path.opacity = n.opacity;
|
||||
path.blend = n.blend;
|
||||
path.extras = n.extras.clone();
|
||||
Some(vec![path])
|
||||
}
|
||||
|
||||
/// A plain clip group whose unpainted clipping path changes nothing: it clips nothing or lies wholly
|
||||
/// away from its art (nothing shows: nothing is left), or it is a rectangle around all its art (its
|
||||
/// art is left).
|
||||
fn needless_clip_group(cx: &Context, n: &Node) -> Option<Vec<Node>> {
|
||||
let NodeKind::Group { children, clip: true } = &n.kind else { return None };
|
||||
if !is_plain(n) {
|
||||
return None;
|
||||
}
|
||||
let Some((clip, art)) = children.split_first() else { return Some(vec![]) };
|
||||
let paint = clip.clip_paint();
|
||||
if paint.fill.is_some() || paint.stroke.is_some() {
|
||||
return None;
|
||||
}
|
||||
let clip_box = clip.geometric_bounds()?;
|
||||
let art_box = art.iter().filter_map(|a| a.visual_bounds_with(&cx.symbols)).reduce(|a, b| a.union(b));
|
||||
match art_box {
|
||||
None => Some(vec![]),
|
||||
Some(b) if !meets(clip_box, b) => Some(vec![]),
|
||||
Some(b) if is_rectangle(clip) && inside(b, clip_box.inflate(1e-6, 1e-6)) => Some(art.iter().map(|a| (**a).clone()).collect()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Is `n` a path drawing a square-to-the-page rectangle: four corners on its bounds, no curves?
|
||||
fn is_rectangle(n: &Node) -> bool {
|
||||
let NodeKind::Path { path, .. } = &n.kind else { return false };
|
||||
let (Some(b), [sp]) = (path.bounds(), path.subpaths.as_slice()) else { return false };
|
||||
let corner = |p: Point| ((p.x - b.x0).abs() < 1e-6 || (p.x - b.x1).abs() < 1e-6) && ((p.y - b.y0).abs() < 1e-6 || (p.y - b.y1).abs() < 1e-6);
|
||||
sp.closed && sp.anchors.len() == 4 && sp.anchors.iter().all(|a| !a.has_in() && !a.has_out() && corner(a.p))
|
||||
}
|
||||
|
||||
/// Replace the objects with this kind of issue (the outermost ones each round, until none is
|
||||
/// left) by what [`Kind::replacement`] gives.
|
||||
fn restructure(d: &mut Document, scope: &mut Scope, kind: Kind) -> Result<Outcome> {
|
||||
let mut out = Outcome::default();
|
||||
for _ in 0..MAX_PASSES {
|
||||
let found: Vec<(NodeId, Vec<Node>)> = {
|
||||
let d: &Document = d;
|
||||
let cx = Context { d, symbols: d.symbol_boxes() };
|
||||
let all: Vec<(NodeId, Vec<Node>)> =
|
||||
objects(d, scope).into_iter().filter_map(|(n, pl)| Some((n.id, kind.replacement(&cx, n, pl)?))).collect();
|
||||
let keep: HashSet<NodeId> = outermost(d, all.iter().map(|(id, _)| *id).collect()).into_iter().collect();
|
||||
all.into_iter().filter(|(id, _)| keep.contains(id)).collect()
|
||||
};
|
||||
if found.is_empty() {
|
||||
break;
|
||||
}
|
||||
if let Scope::Objects(roots) = scope {
|
||||
for (id, nodes) in &found {
|
||||
if let Some(at) = roots.iter().position(|r| r == id) {
|
||||
roots.splice(at..=at, nodes.iter().map(|n| n.id));
|
||||
}
|
||||
}
|
||||
}
|
||||
out.ids.extend(found.iter().map(|(id, _)| *id));
|
||||
d.insert_beside(found, true)?;
|
||||
}
|
||||
out.count = out.ids.len();
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Run `f` on a copy of each path in scope (not guides), and keep the copies it changed: `f`
|
||||
/// returns how many issues it fixed.
|
||||
fn reshape(d: &mut Document, scope: &Scope, f: impl Fn(&Node, &mut PathData) -> usize) -> Result<Outcome> {
|
||||
let changed: Vec<(NodeId, PathData, usize)> = objects(d, scope)
|
||||
.into_iter()
|
||||
.filter_map(|(n, _)| {
|
||||
let NodeKind::Path { path, guide: false, .. } = &n.kind else { return None };
|
||||
let mut p = path.clone();
|
||||
let k = f(n, &mut p);
|
||||
(k > 0).then_some((n.id, p, k))
|
||||
})
|
||||
.collect();
|
||||
let mut out = Outcome::default();
|
||||
for (id, p, k) in changed {
|
||||
set_path(d, id, p);
|
||||
out.count += k;
|
||||
out.ids.push(id);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Give path `id` the geometry `p` (it stops being a live shape).
|
||||
pub(super) fn set_path(d: &mut Document, id: NodeId, p: PathData) {
|
||||
if let Some(NodeKind::Path { path, live, .. }) = d.node_mut(id).map(|n| &mut n.kind) {
|
||||
*path = p;
|
||||
*live = None;
|
||||
}
|
||||
}
|
||||
|
||||
/// Filled Open Paths: close the open subpaths of a path with a fill and no stroke.
|
||||
fn close_filled(n: &Node, p: &mut PathData) -> usize {
|
||||
let items = &n.appearance.items;
|
||||
let filled = items.iter().any(|i| matches!(i, AppearanceItem::Fill(f) if f.visible && !f.paint.is_none()));
|
||||
let stroked = items.iter().any(|i| matches!(i, AppearanceItem::Stroke(s) if s.visible && !s.paint.is_none() && s.width > 0.0));
|
||||
if !filled || stroked {
|
||||
return 0;
|
||||
}
|
||||
let mut count = 0;
|
||||
for sp in p.subpaths.iter_mut().filter(|sp| !sp.closed && sp.anchors.len() >= 2) {
|
||||
sp.closed = true;
|
||||
count += 1;
|
||||
}
|
||||
count
|
||||
}
|
||||
|
||||
/// Nearly Coincident Anchors: anchors of the paths in scope within [`NEAR`] of one another (but
|
||||
/// not neighbours on one path, which Duplicate Anchors merges) move onto the first of them.
|
||||
fn near_points(d: &mut Document, scope: &Scope) -> Result<Outcome> {
|
||||
// Each anchor: (path, subpath, anchor, its subpath's length and closedness).
|
||||
let mut refs: Vec<(NodeId, usize, usize, usize, bool)> = vec![];
|
||||
let mut points: Vec<Point> = vec![];
|
||||
for (n, _) in objects(d, scope) {
|
||||
let NodeKind::Path { path, guide: false, .. } = &n.kind else { continue };
|
||||
for (si, sp) in path.subpaths.iter().enumerate() {
|
||||
for (ai, a) in sp.anchors.iter().enumerate().take(MAX_ANCHORS.saturating_sub(points.len())) {
|
||||
refs.push((n.id, si, ai, sp.anchors.len(), sp.closed));
|
||||
points.push(a.p);
|
||||
}
|
||||
}
|
||||
}
|
||||
let neighbours = |i: usize, j: usize| {
|
||||
let (Some(a), Some(b)) = (refs.get(i), refs.get(j)) else { return false };
|
||||
let gap = a.2.abs_diff(b.2);
|
||||
a.0 == b.0 && a.1 == b.1 && (gap == 1 || (a.4 && gap + 1 == a.3))
|
||||
};
|
||||
let mut moves: HashMap<NodeId, Vec<(usize, usize, Point)>> = HashMap::new();
|
||||
let mut count = 0;
|
||||
for group in po::near_point_groups(&points, NEAR, neighbours) {
|
||||
let Some(&to) = group.first().and_then(|&i| points.get(i)) else { continue };
|
||||
for &i in &group {
|
||||
if let (Some(&(id, si, ai, ..)), Some(&p)) = (refs.get(i), points.get(i))
|
||||
&& p != to
|
||||
{
|
||||
moves.entry(id).or_default().push((si, ai, to));
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut ids: Vec<NodeId> = moves.keys().copied().collect();
|
||||
ids.sort_unstable();
|
||||
for id in &ids {
|
||||
let Some(mut p) = d.node(*id).and_then(Node::path_data).cloned() else { continue };
|
||||
for &(si, ai, to) in moves.get(id).into_iter().flatten() {
|
||||
if let Some(a) = p.anchor_mut(si, ai) {
|
||||
a.translate(to - a.p);
|
||||
}
|
||||
}
|
||||
set_path(d, *id, p);
|
||||
}
|
||||
Ok(Outcome { count, ids, artboards: vec![] })
|
||||
}
|
||||
|
||||
/// Empty Artboards: the artboards with no art on them (as Delete Empty Artboards finds them),
|
||||
/// deleted with their guides. Only a check of the whole document looks at artboards.
|
||||
fn artboards(d: &mut Document, sel: &mut Selection, scope: &Scope) -> Result<Outcome> {
|
||||
let Scope::Document = scope else { return Ok(Outcome::default()) };
|
||||
let empty = empty_artboards(d);
|
||||
// The last first, so the others keep their places.
|
||||
for &i in empty.iter().rev() {
|
||||
if i < d.artboards.len() {
|
||||
let id = d.artboards.remove(i).id;
|
||||
d.retire_artboard_id(id);
|
||||
d.retain_guides(sel, |_, g| g.artboard != Some(id));
|
||||
}
|
||||
}
|
||||
Ok(Outcome { count: empty.len(), ids: vec![], artboards: empty })
|
||||
}
|
||||
@@ -0,0 +1,336 @@
|
||||
//! Artwork Cleanup's commands: Simplify Anchors (Keep Shape), the health check's kinds found and
|
||||
//! fixed one by one, Fix as one undo step, scopes, Show, Deep Ungroup and Rejoin Path Pieces.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_color::{Color, Paint};
|
||||
use vectorcraft_doc::{Appearance, Node, NodeId, NodeKind};
|
||||
use vectorcraft_geom::{FillRule, Rect};
|
||||
|
||||
use crate::Session;
|
||||
|
||||
fn session() -> Session {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
fn id_of(v: &Value) -> NodeId {
|
||||
NodeId(v["id"].as_u64().unwrap())
|
||||
}
|
||||
|
||||
fn rect(s: &mut Session, x: f64, y: f64, w: f64, h: f64) -> NodeId {
|
||||
id_of(&s.execute("shape.rectangle", &json!({"x": x, "y": y, "width": w, "height": h})).unwrap())
|
||||
}
|
||||
|
||||
/// A path through `pts` (anchors as `{x, y, out?}` objects).
|
||||
fn path(s: &mut Session, anchors: Value, closed: bool) -> NodeId {
|
||||
id_of(&s.execute("path.create", &json!({"anchors": anchors, "closed": closed})).unwrap())
|
||||
}
|
||||
|
||||
fn line(s: &mut Session, pts: &[(f64, f64)]) -> NodeId {
|
||||
path(s, pts.iter().map(|(x, y)| json!({"x": x, "y": y})).collect(), false)
|
||||
}
|
||||
|
||||
fn node(s: &Session, id: NodeId) -> Option<Node> {
|
||||
s.doc().unwrap().doc.node(id).cloned()
|
||||
}
|
||||
|
||||
fn change(s: &mut Session, id: NodeId, f: impl FnOnce(&mut Node)) {
|
||||
s.edit("t", |d, _| {
|
||||
f(d.node_mut(id).unwrap());
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
/// Put `id` inside a new container that `kind` makes of its members.
|
||||
fn wrap(s: &mut Session, id: NodeId, kind: fn(Vec<std::sync::Arc<Node>>) -> NodeKind) -> NodeId {
|
||||
s.edit("t", |d, _| {
|
||||
let w = d.alloc_id();
|
||||
Ok(d.wrap(&[id], |c| Node::new(w, kind(c)))?)
|
||||
})
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn select(s: &mut Session, ids: &[NodeId]) {
|
||||
s.select(|_, sel| sel.set(ids.iter().copied())).unwrap();
|
||||
}
|
||||
|
||||
fn anchors(s: &Session, id: NodeId) -> usize {
|
||||
node(s, id).unwrap().path_data().unwrap().anchor_count()
|
||||
}
|
||||
|
||||
/// How many issues of `kind` a check of `scope` finds.
|
||||
fn found(s: &mut Session, kind: &str, scope: &str) -> usize {
|
||||
let r = s.execute("addon.cleanup.check", &json!({"kinds": [kind], "scope": scope})).unwrap();
|
||||
r["findings"].as_array().unwrap().iter().find(|f| f["kind"] == kind).map_or(0, |f| f["count"].as_u64().unwrap() as usize)
|
||||
}
|
||||
|
||||
fn undo_steps(s: &Session) -> usize {
|
||||
s.doc().unwrap().history.undo.len()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn simplify_keeps_the_shape_with_fewer_anchors_in_one_step() {
|
||||
let mut s = session();
|
||||
let e = id_of(&s.execute("shape.ellipse", &json!({"x": 50, "y": 50, "width": 100, "height": 100})).unwrap());
|
||||
s.execute("object.path.addAnchorPoints", &json!({})).unwrap();
|
||||
s.execute("object.path.addAnchorPoints", &json!({})).unwrap();
|
||||
assert_eq!(anchors(&s, e), 16);
|
||||
let bounds = node(&s, e).unwrap().geometric_bounds().unwrap();
|
||||
let steps = undo_steps(&s);
|
||||
let r = s.execute("addon.cleanup.simplify", &json!({})).unwrap();
|
||||
assert_eq!((r["before"].as_u64(), r["after"].as_u64()), (Some(16), Some(anchors(&s, e) as u64)));
|
||||
assert!(anchors(&s, e) <= 8, "{r}");
|
||||
let now = node(&s, e).unwrap().geometric_bounds().unwrap();
|
||||
for (a, b) in [(bounds.x0, now.x0), (bounds.y0, now.y0), (bounds.x1, now.x1), (bounds.y1, now.y1)] {
|
||||
assert!((a - b).abs() < 0.4, "{bounds:?} → {now:?}");
|
||||
}
|
||||
assert_eq!(undo_steps(&s), steps + 1);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(anchors(&s, e), 16);
|
||||
// A rectangle's corners stay; the anchors on its sides go.
|
||||
let r = rect(&mut s, 200.0, 50.0, 100.0, 60.0);
|
||||
s.execute("object.path.addAnchorPoints", &json!({})).unwrap();
|
||||
assert_eq!(anchors(&s, r), 8);
|
||||
s.execute("addon.cleanup.simplify", &json!({"protectCorners": true})).unwrap();
|
||||
assert_eq!(anchors(&s, r), 4);
|
||||
// Nothing left to remove: no undo step.
|
||||
let steps = undo_steps(&s);
|
||||
s.execute("addon.cleanup.simplify", &json!({})).unwrap();
|
||||
assert_eq!(undo_steps(&s), steps);
|
||||
for bad in [json!({"tolerance": 0}), json!({"tolerance": 101}), json!({"tolerance": "20"}), json!({"protectCorners": 1})] {
|
||||
assert!(s.execute("addon.cleanup.simplify", &bad).is_err(), "{bad}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Each kind: what makes the issue in a fresh document (beside a clean rectangle).
|
||||
fn make_issue(s: &mut Session, kind: &str) {
|
||||
match kind {
|
||||
"emptyText" => {
|
||||
s.execute("text.create", &json!({"x": 20, "y": 20, "text": " "})).unwrap();
|
||||
}
|
||||
"strayPoints" => {
|
||||
path(s, json!([{"x": 30, "y": 30}]), false);
|
||||
}
|
||||
"unpainted" => {
|
||||
let id = rect(s, 20.0, 150.0, 30.0, 30.0);
|
||||
change(s, id, |n| n.appearance = Appearance::default().into());
|
||||
}
|
||||
"zeroOpacity" => {
|
||||
let id = rect(s, 20.0, 150.0, 30.0, 30.0);
|
||||
change(s, id, |n| n.opacity = 0.0);
|
||||
}
|
||||
"duplicateObjects" => {
|
||||
let id = rect(s, 20.0, 150.0, 30.0, 30.0);
|
||||
s.edit("t", |d, _| {
|
||||
let mut copy = d.node(id).unwrap().clone();
|
||||
copy.id = d.alloc_id();
|
||||
let (parent, i, _) = d.position(id).unwrap();
|
||||
Ok(d.insert(parent, i + 1, copy)?)
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
"offArtboard" => {
|
||||
rect(s, -500.0, -500.0, 30.0, 30.0);
|
||||
}
|
||||
"duplicateAnchors" => {
|
||||
line(s, &[(10.0, 150.0), (50.0, 150.0), (50.005, 150.0), (50.0, 190.0)]);
|
||||
}
|
||||
"tinyHandles" => {
|
||||
path(s, json!([{"x": 10, "y": 150, "out": [10.004, 150]}, {"x": 60, "y": 190}]), false);
|
||||
}
|
||||
"collinearAnchors" => {
|
||||
line(s, &[(10.0, 150.0), (30.0, 150.0), (50.0, 150.0), (50.0, 190.0)]);
|
||||
}
|
||||
"nearPoints" => {
|
||||
line(s, &[(10.0, 150.0), (50.0, 150.0)]);
|
||||
line(s, &[(50.005, 150.0), (90.0, 190.0)]);
|
||||
}
|
||||
"nearlyClosed" => {
|
||||
line(s, &[(10.0, 150.0), (50.0, 150.0), (50.0, 190.0), (10.005, 150.0)]);
|
||||
}
|
||||
"filledOpenPaths" => {
|
||||
let id = line(s, &[(10.0, 150.0), (50.0, 150.0), (50.0, 190.0)]);
|
||||
change(s, id, |n| n.appearance = Appearance::basic(Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::None, 1.0).into());
|
||||
}
|
||||
"needlessClipGroups" => {
|
||||
let inner = rect(s, 30.0, 160.0, 20.0, 20.0);
|
||||
let clip = rect(s, 20.0, 150.0, 50.0, 50.0);
|
||||
select(s, &[inner, clip]);
|
||||
s.execute("object.clippingMask.make", &json!({})).unwrap();
|
||||
}
|
||||
"needlessCompounds" => {
|
||||
// A compound path holds the paint of its paths.
|
||||
let id = rect(s, 20.0, 150.0, 30.0, 30.0);
|
||||
let look = node(s, id).unwrap().appearance;
|
||||
let compound = wrap(s, id, |children| NodeKind::Compound { children, rule: FillRule::NonZero });
|
||||
change(s, compound, |n| n.appearance = look);
|
||||
}
|
||||
"needlessGroups" => {
|
||||
let id = rect(s, 20.0, 150.0, 30.0, 30.0);
|
||||
wrap(s, id, |children| NodeKind::Group { children, clip: false });
|
||||
}
|
||||
"emptyArtboards" => {
|
||||
s.edit("t", |d, _| {
|
||||
let mut a = d.artboards[0].clone();
|
||||
a.id = d.next_artboard_id();
|
||||
a.rect = Rect::new(1000.0, 1000.0, 1100.0, 1100.0);
|
||||
d.artboards.push(a);
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
_ => panic!("no setup for {kind}"),
|
||||
}
|
||||
}
|
||||
|
||||
const KINDS: [&str; 16] = [
|
||||
"emptyText",
|
||||
"strayPoints",
|
||||
"unpainted",
|
||||
"zeroOpacity",
|
||||
"duplicateObjects",
|
||||
"offArtboard",
|
||||
"duplicateAnchors",
|
||||
"tinyHandles",
|
||||
"collinearAnchors",
|
||||
"nearPoints",
|
||||
"nearlyClosed",
|
||||
"filledOpenPaths",
|
||||
"needlessClipGroups",
|
||||
"needlessCompounds",
|
||||
"needlessGroups",
|
||||
"emptyArtboards",
|
||||
];
|
||||
|
||||
#[test]
|
||||
fn each_check_finds_and_fixes_its_issue_and_leaves_clean_art_alone() {
|
||||
let mut clean = session();
|
||||
let r = rect(&mut clean, 200.0, 20.0, 80.0, 60.0);
|
||||
let before = node(&clean, r).unwrap();
|
||||
assert_eq!(clean.execute("addon.cleanup.check", &json!({})).unwrap()["total"], 0, "clean art has no issues");
|
||||
assert_eq!(clean.execute("addon.cleanup.fix", &json!({"scope": "document"})).unwrap()["total"], 0);
|
||||
assert_eq!(node(&clean, r).unwrap(), before);
|
||||
for kind in KINDS {
|
||||
let mut s = session();
|
||||
let r = rect(&mut s, 200.0, 20.0, 80.0, 60.0);
|
||||
make_issue(&mut s, kind);
|
||||
select(&mut s, &[]);
|
||||
assert!(found(&mut s, kind, "document") > 0, "{kind} not found");
|
||||
let fixed = s.execute("addon.cleanup.fix", &json!({"kinds": [kind], "scope": "document"})).unwrap();
|
||||
assert!(fixed["fixed"][kind].as_u64().unwrap() > 0, "{kind}: {fixed}");
|
||||
assert_eq!(found(&mut s, kind, "document"), 0, "{kind} still found after Fix");
|
||||
assert_eq!(s.execute("addon.cleanup.check", &json!({})).unwrap()["total"], 0, "{kind} left other issues");
|
||||
assert_eq!(node(&s, r).unwrap(), before, "{kind} changed the clean rectangle");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fix_all_is_one_undo_step() {
|
||||
let mut s = session();
|
||||
for kind in KINDS {
|
||||
make_issue(&mut s, kind);
|
||||
}
|
||||
select(&mut s, &[]);
|
||||
let total = s.execute("addon.cleanup.check", &json!({})).unwrap()["total"].as_u64().unwrap();
|
||||
assert!(total >= 16, "{total}");
|
||||
let (steps, doc) = (undo_steps(&s), s.doc().unwrap().doc.clone());
|
||||
let r = s.execute("addon.cleanup.fix", &json!({})).unwrap();
|
||||
assert_eq!(r["scope"], "document");
|
||||
assert_eq!(undo_steps(&s), steps + 1);
|
||||
assert_eq!(s.execute("addon.cleanup.check", &json!({})).unwrap()["total"], 0);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().doc.layers, doc.layers);
|
||||
assert_eq!(s.doc().unwrap().doc.artboards.len(), doc.artboards.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_scope_is_the_selection_or_the_document() {
|
||||
let mut s = session();
|
||||
let a = path(&mut s, json!([{"x": 30, "y": 30}]), false);
|
||||
let b = path(&mut s, json!([{"x": 60, "y": 30}]), false);
|
||||
select(&mut s, &[a]);
|
||||
let r = s.execute("addon.cleanup.check", &json!({"kinds": ["strayPoints"]})).unwrap();
|
||||
assert_eq!((r["scope"].as_str(), r["total"].as_u64()), (Some("selection"), Some(1)));
|
||||
assert_eq!(found(&mut s, "strayPoints", "document"), 2);
|
||||
s.execute("addon.cleanup.fix", &json!({"kinds": ["strayPoints"]})).unwrap();
|
||||
assert!(node(&s, a).is_none() && node(&s, b).is_some(), "only the selection is fixed");
|
||||
// Nothing selected: the document.
|
||||
let r = s.execute("addon.cleanup.check", &json!({})).unwrap();
|
||||
assert_eq!((r["scope"].as_str(), r["total"].as_u64()), (Some("document"), Some(1)));
|
||||
assert!(s.execute("addon.cleanup.check", &json!({"scope": "selection"})).is_err(), "nothing selected");
|
||||
for bad in [json!({"scope": "page"}), json!({"kinds": ["nope"]}), json!({"kinds": "strayPoints"}), json!({"kinds": [1]})] {
|
||||
assert!(s.execute("addon.cleanup.check", &bad).is_err(), "{bad}");
|
||||
assert!(s.execute("addon.cleanup.fix", &bad).is_err(), "{bad}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn show_selects_the_objects_with_the_issue() {
|
||||
let mut s = session();
|
||||
rect(&mut s, 200.0, 20.0, 80.0, 60.0);
|
||||
let stray = path(&mut s, json!([{"x": 30, "y": 30}]), false);
|
||||
select(&mut s, &[]);
|
||||
let r = s.execute("addon.cleanup.show", &json!({"kind": "strayPoints"})).unwrap();
|
||||
assert_eq!(r["count"], 1);
|
||||
assert_eq!(s.doc().unwrap().selection.objects, [stray]);
|
||||
assert!(s.execute("addon.cleanup.show", &json!({"kind": "emptyText", "scope": "document"})).is_err(), "no such issue");
|
||||
assert!(s.execute("addon.cleanup.show", &json!({})).is_err() && s.execute("addon.cleanup.show", &json!({"kind": "x"})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deep_ungroup_takes_apart_every_level_but_clipping_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 c = rect(&mut s, 70.0, 10.0, 20.0, 20.0);
|
||||
select(&mut s, &[b, c]);
|
||||
let inner = id_of(&s.execute("object.group", &json!({})).unwrap());
|
||||
change(&mut s, inner, |n| n.opacity = 0.5);
|
||||
let clipped = rect(&mut s, 100.0, 10.0, 10.0, 10.0);
|
||||
let clip = rect(&mut s, 95.0, 5.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];
|
||||
select(&mut s, &[a, inner, clip_group]);
|
||||
let outer = id_of(&s.execute("object.group", &json!({})).unwrap());
|
||||
let steps = undo_steps(&s);
|
||||
let r = s.execute("addon.cleanup.deepUngroup", &json!({})).unwrap();
|
||||
assert_eq!(r["ungrouped"], 2);
|
||||
assert_eq!(undo_steps(&s), steps + 1);
|
||||
let d = &s.doc().unwrap().doc;
|
||||
let layer: Vec<NodeId> = d.layers[0].children().unwrap().iter().map(|n| n.id).collect();
|
||||
assert_eq!(layer, [a, b, c, clip_group]);
|
||||
assert!(d.node(outer).is_none() && d.node(inner).is_none());
|
||||
assert_eq!(d.node(b).unwrap().opacity, 0.5, "members keep their groups' opacity");
|
||||
assert_eq!(s.doc().unwrap().selection.objects.len(), 4);
|
||||
// Asked to, clipping groups come apart too, their clipping paths kept as plain paths.
|
||||
select(&mut s, &[clip_group]);
|
||||
s.execute("addon.cleanup.deepUngroup", &json!({"includeClipGroups": true})).unwrap();
|
||||
let d = &s.doc().unwrap().doc;
|
||||
assert!(d.node(clip_group).is_none());
|
||||
assert!(matches!(d.node(clip).unwrap().kind, NodeKind::Path { clipping: false, .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejoin_joins_pieces_within_the_gap_only() {
|
||||
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, 50.0)]);
|
||||
let far = line(&mut s, &[(92.0, 50.0), (120.0, 50.0)]);
|
||||
select(&mut s, &[a, b, far]);
|
||||
let r = s.execute("addon.cleanup.rejoin", &json!({})).unwrap();
|
||||
assert_eq!((r["joined"].as_u64(), r["removed"].as_u64()), (Some(1), Some(1)));
|
||||
// The topmost piece takes the joined path.
|
||||
assert!(node(&s, a).is_none());
|
||||
assert_eq!(anchors(&s, b), 3);
|
||||
assert_eq!(anchors(&s, far), 2, "beyond the gap");
|
||||
select(&mut s, &[b, far]);
|
||||
let r = s.execute("addon.cleanup.rejoin", &json!({"gap": 3})).unwrap();
|
||||
assert_eq!(r["joined"], 1);
|
||||
assert_eq!(anchors(&s, far), 4);
|
||||
for bad in [json!({"gap": -1}), json!({"gap": 7}), json!({"gap": "1"})] {
|
||||
assert!(s.execute("addon.cleanup.rejoin", &bad).is_err(), "{bad}");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user