Files
vectorcraft/crates/engine/src/cmd/globaledit.rs
T
2026-10-26 09:56:24 +07:00

601 lines
26 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Select › Start Global Edit / Stop Global Edit, and the Global Edit Options.
//!
//! Behaviour from the public help pages "Edit similar objects"
//! (<https://helpx.adobe.com/illustrator/desktop/manage-objects/edit-objects/edit-similar-objects.html>)
//! and "Update assets quickly with Global Edit"
//! (<https://helpx.adobe.com/ph_fil/illustrator/how-to/global-editing-sensei.html>), in our words:
//!
//! - With one object selected, Start Global Edit finds the objects that repeat its shape: the
//! same outline, wherever it lies and at whatever size. Two options narrow the match: Appearance
//! (the same fill and stroke too) and Size (the same width and height too). The search covers
//! every artboard and the canvas around them by default; it can be limited to a range of
//! artboards (`1, 3-5`) and can leave out the objects that lie on no artboard. Images, type,
//! masks, linked files and selections of more than one object are not supported.
//! - While Global Edit is on, an edit made to one of the found objects is made to all of them,
//! each in proportion to its own size: halving the height of a 100 pt object halves a 20 pt
//! match to 10 pt. Stop Global Edit (or Escape) ends it.
//!
//! Here the found objects ([`GlobalEdit::members`]) stay highlighted while the selection keeps the
//! object that was selected, so that the Transform panel and the bounding box measure that object
//! and not the whole set. Each member keeps the transform (a move and a uniform scale) that maps
//! the first object's outline onto its own. After every edit ([`propagate`], run inside
//! [`Session::edit`], so the whole change is one undo step), the first selected member the edit
//! changed is the model: what changed about it is given to every other member, and nothing else,
//! so members found by shape alone keep their own fills and strokes where the edit didn't touch
//! them:
//!
//! - its outline (and anchors), placed through that transform, so in proportion to each member's
//! size. Only geometry is placed so: no stroke weight is scaled to a member's size;
//! - its opacity, blending, knockout and the object's effects, each when it changed;
//! - of its fills and strokes, each setting that changed (paint, weight, opacity, blending,
//! effects, caps, dashes, arrowheads …), as it is: it goes to the member's fill or stroke of the
//! same rank (the second fill from the bottom to the second fill), and a member without one is
//! left without it. A weight set to 4 pt is 4 pt on a smaller member too. The pen pressure a
//! stroke was drawn with stays each member's own;
//! - a fill or stroke added or removed: a member whose fills and strokes lie in the model's
//! former order gains the same item at the same place (or loses the one at that place). A
//! member with another order, and any other change to the list (items moved, or several added
//! or removed at once), takes the model's whole list of fills and strokes.
//!
//! Members that are hidden or locked at the time are left as they are. An edit that would take any
//! member past the canvas (beyond [`vectorcraft_doc::MAX_COORD`]) is refused whole, as one that
//! takes the selection there is. Whenever the document is put back (Undo, Redo, a cancelled drag
//! or scrub, an edit that failed), the members it lacks (or no longer supports) leave the Global
//! Edit, and it ends when fewer than two are left ([`follow_history`]), so that an object made
//! later under a freed id is never edited. Rotated copies, groups (the help page "Edit groups with
//! similar objects") and excluding a member with a Shift-click are not covered.
use serde::Serialize;
use serde_json::{Value, json};
use vectorcraft_doc::{Appearance, AppearanceItem, Document, FillLayer, Node, NodeId, NodeKind, Scaling, Shared, StrokeLayer};
use vectorcraft_geom::{Affine, BezPath, PathData, Point, Rect, Shape};
use super::wand::{fill_of, stroke_of};
use super::*;
const C: &str = "select.globalEdit";
const OPTIONS: &str = "select.globalEditOptions";
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(
"select.globalEdit",
"Start Global Edit",
["Select"],
None,
"{appearance?: bool, size?: bool, artboards?: \"all\" | range such as \"1, 3-5\", includeCanvas?: bool} (each defaults to the Global Edit Options) while Global Edit is off: start it on the one selected object, with the objects of the same shape (and, with `appearance`, the same fill and stroke; with `size`, the same width and height) on the artboards given (and, with `includeCanvas`, off every artboard) → {active: true, count, ids}; while on: stop it (Escape does the same) → {active: false}",
can_toggle,
toggle
),
cmd!(
"select.globalEditOptions",
"Global Edit Options",
[],
None,
"{appearance?: bool, size?: bool, artboards?: \"all\" | range such as \"1, 3-5\", includeCanvas?: bool} set the options Start Global Edit matches with (defaults: shape only, all artboards, objects on the canvas included) → {appearance, size, artboards, includeCanvas, count: the objects Start Global Edit would take now (0 unless one supported object is selected)}",
has_doc,
set_options
),
]
}
/// The Global Edit Options: what a similar object must share with the selected one besides its
/// shape, and where it may lie. Kept for the session.
#[derive(Clone, Debug, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct Options {
/// Match the fill and stroke too.
pub appearance: bool,
/// Match the width and height too.
pub size: bool,
/// The artboards to search (1-based range as typed); `None`: all of them.
pub artboards: Option<String>,
/// Include the objects that lie on no artboard.
pub include_canvas: bool,
}
impl Default for Options {
fn default() -> Self {
Self { appearance: false, size: false, artboards: None, include_canvas: true }
}
}
impl Options {
/// These options with the ones `p` gives, checked against `d`'s artboards.
fn with(&self, p: &Value, d: &Document, cmd: &str) -> Result<Self> {
let mut o = self.clone();
o.appearance = bool_or(p, "appearance", o.appearance);
o.size = bool_or(p, "size", o.size);
o.include_canvas = bool_or(p, "includeCanvas", o.include_canvas);
match p.get("artboards") {
None | Some(Value::Null) => {}
Some(Value::String(s)) if s.trim().is_empty() || s.trim().eq_ignore_ascii_case("all") => o.artboards = None,
Some(Value::String(s)) => {
vectorcraft_doc::range::parse_range(s, d.artboards.len()).map_err(|e| bad(cmd, e))?;
o.artboards = Some(s.trim().to_string());
}
Some(v) => return Err(bad(cmd, format!("`artboards`: all or a range such as 1, 3-5, not {v}"))),
}
Ok(o)
}
fn json(&self, count: usize) -> Value {
json!({
"appearance": self.appearance,
"size": self.size,
"artboards": self.artboards.as_deref().unwrap_or("all"),
"includeCanvas": self.include_canvas,
"count": count,
})
}
}
/// Outlines with their layer colours ([`highlights`]).
pub type Outlines = Vec<(BezPath, [u8; 3])>;
/// A Global Edit in progress (per document; not saved, not an undo step).
#[derive(Clone, Debug, PartialEq)]
pub struct GlobalEdit {
/// The objects edited together, the one selected at the start first, each with the transform
/// (a move and a uniform scale) that maps that first object's outline onto its own.
pub members: Vec<(NodeId, Affine)>,
/// [`highlights`] as of a document revision: the canvas asks every frame.
outlines: std::cell::RefCell<Option<(u64, Outlines)>>,
}
/// An outline reduced to its form: the anchors and handles placed in a unit box, with what tells
/// such outlines apart (kind, fill rule, subpaths and their anchor counts, open or closed).
struct Form {
tag: Vec<u64>,
symbol: Option<String>,
points: Vec<Point>,
/// The unit box → the object.
frame: Affine,
}
impl Form {
fn of(n: &Node) -> Option<Self> {
let mut tag = vec![];
let mut points = vec![];
let mut symbol = None;
let mut push = |path: &PathData, tag: &mut Vec<u64>| {
tag.push(path.subpaths.len() as u64);
for sp in &path.subpaths {
tag.extend([u64::from(sp.closed), sp.anchors.len() as u64]);
points.extend(sp.anchors.iter().flat_map(|a| [a.p, a.h_in, a.h_out]));
}
};
let rule = |r: vectorcraft_geom::FillRule| u64::from(r == vectorcraft_geom::FillRule::EvenOdd);
match &n.kind {
NodeKind::Path { path, rule: r, clipping: false, guide: false, .. } => {
tag.extend([0, rule(*r)]);
push(path, &mut tag);
}
NodeKind::Compound { children, rule: r } => {
tag.extend([1, rule(*r), children.len() as u64]);
for c in children {
push(c.path_data()?, &mut tag);
}
}
NodeKind::SymbolInstance { symbol: s, xf } => {
tag.push(2);
symbol = Some(s.clone());
points.extend([*xf * Point::ZERO, *xf * Point::new(1.0, 0.0), *xf * Point::new(0.0, 1.0)]);
}
_ => return None,
}
let first = *points.first()?;
let b = points.iter().fold(Rect::from_points(first, first), |r, p| r.union_pt(*p));
let side = b.width().max(b.height());
if !(side.is_finite() && side > 1e-9) {
return None;
}
let frame = Affine::translate(b.origin().to_vec2()) * Affine::scale(side);
let back = frame.inverse();
points.iter_mut().for_each(|p| *p = back * *p);
Some(Self { tag, symbol, points, frame })
}
fn same(&self, o: &Form) -> bool {
self.tag == o.tag
&& self.symbol == o.symbol
&& self.points.len() == o.points.len()
&& self.points.iter().zip(&o.points).all(|(a, b)| a.distance(*b) < 1e-6)
}
}
/// Global Edit supports paths, compound paths and symbol instances; not images, type, linked
/// files, objects with an opacity mask, clipping paths or guides (the help page lists images,
/// text, masks and links as unsupported).
fn supported(n: &Node) -> bool {
n.extras().mask.is_none() && Form::of(n).is_some()
}
fn close(a: f64, b: f64) -> bool {
(a - b).abs() <= 1e-6 * a.abs().max(b.abs()).max(1.0)
}
/// The members a Global Edit started on the one selected object would take, with `o`: that
/// object first, then the others in paint order.
fn matches(s: &Session, o: &Options) -> Result<Vec<(NodeId, Affine)>> {
let st = s.doc()?;
let d = &st.doc;
let [id] = st.selection.objects.as_slice() else { return Err(bad(C, "select one object")) };
let key = d.node(*id).ok_or_else(|| bad(C, "nothing selected"))?;
let form = match Form::of(key) {
Some(f) if supported(key) => f,
_ => return Err(bad(C, "Global Edit works on paths, compound paths and symbol instances")),
};
let symbols = d.symbol_boxes();
let size = |n: &Node| n.geometric_bounds_with(&symbols).map(|b| (b.width(), b.height()));
let key_size = size(key);
let (fill, stroke) = (fill_of(key), stroke_of(key));
let boards: Option<Vec<Rect>> = match &o.artboards {
None => None,
Some(r) => Some(
vectorcraft_doc::range::parse_range(r, d.artboards.len())
.map_err(|e| bad(C, e))?
.into_iter()
.filter_map(|i| d.artboards.get(i).map(|a| a.rect))
.collect(),
),
};
let on = |r: Rect, b: Rect| b.intersect(r).area() > 0.0 || vectorcraft_doc::artboard_holds(r, b.center());
let in_scope = |n: &Node| {
let Some(b) = n.geometric_bounds_with(&symbols) else { return false };
if !d.artboards.iter().any(|a| on(a.rect, b)) {
return o.include_canvas;
}
boards.as_ref().is_none_or(|rs| rs.iter().any(|r| on(*r, b)))
};
let mut out = vec![(*id, Affine::IDENTITY)];
let back = form.frame.inverse();
super::select::walk_selectable(d, st.isolation, |n| {
if n.id == *id {
return false;
}
match &n.kind {
// Look into layers and plain groups (clip groups too: their art, not their clipping path).
NodeKind::Layer { .. } => return true,
NodeKind::Group { .. } => {
let x = n.extras();
return x.graph.is_none() && x.shaper.is_none() && x.dimension.is_none() && x.objects_on_path.is_none() && x.trace.is_none();
}
_ => {}
}
if let Some(f) = Form::of(n).filter(|f| f.same(&form) && supported(n))
&& (!o.size || matches!((key_size, size(n)), (Some((w, h)), Some((w2, h2))) if close(w, w2) && close(h, h2)))
&& (!o.appearance || {
let (f2, s2) = (fill_of(n), stroke_of(n));
super::select::same_paint(&fill, &f2, true) && super::select::same_paint(&stroke.0, &s2.0, true) && close(stroke.1, s2.1)
})
&& in_scope(n)
{
out.push((n.id, f.frame * back));
}
// Compound paths, symbol instances and the other objects are taken whole.
false
});
Ok(out)
}
fn can_toggle(s: &Session) -> std::result::Result<(), String> {
let st = s.active().ok_or("no document open")?;
if st.global_edit.is_some() {
return Ok(());
}
let [id] = st.selection.objects.as_slice() else {
return Err(if st.selection.is_empty() { "nothing selected" } else { "select one object" }.into());
};
match st.doc.node(*id) {
Some(n) if supported(n) => Ok(()),
_ => Err("Global Edit works on paths, compound paths and symbol instances".into()),
}
}
fn toggle(s: &mut Session, p: &Value) -> Result<Value> {
if s.doc()?.global_edit.is_some() {
let st = s.doc_mut()?;
st.global_edit = None;
st.revision += 1;
return Ok(json!({ "active": false }));
}
let o = s.global_edit_options.with(p, &s.doc()?.doc, C)?;
let members = matches(s, &o)?;
if members.len() < 2 {
return Err(bad(C, "no similar objects found (rotated or reflected copies are not matched); change the Global Edit Options and try again"));
}
let ids: Vec<u64> = members.iter().map(|(id, _)| id.0).collect();
let st = s.doc_mut()?;
st.global_edit = Some(GlobalEdit { members, outlines: Default::default() });
st.revision += 1;
Ok(json!({ "active": true, "count": ids.len(), "ids": ids }))
}
fn set_options(s: &mut Session, p: &Value) -> Result<Value> {
let o = s.global_edit_options.with(p, &s.doc()?.doc, OPTIONS)?;
s.global_edit_options = o.clone();
Ok(o.json(match_count(s)))
}
/// How many objects Start Global Edit would take now with the session's options (the Result(s)
/// line of the options): 0 unless one supported object is selected.
pub fn match_count(s: &Session) -> usize {
match s.active() {
Some(st) if st.global_edit.is_none() => matches(s, &s.global_edit_options).map_or(0, |m| m.len()),
Some(st) => st.global_edit.as_ref().map_or(0, |g| g.members.len()),
None => 0,
}
}
/// Is `id` there, shown and unlocked, with every layer and group it is in?
fn editable(d: &Document, id: NodeId) -> bool {
d.ancestry(id).is_some_and(|a| a.iter().all(|i| d.node(*i).is_some_and(|n| n.visible && !n.locked && !n.is_template())))
}
/// After the document of `st` was put back to an earlier one (Undo, Redo, a cancelled interaction
/// or undo group, a failed or panicked command, a preview starting again, Discard Empty Type): the
/// members it lacks (or no longer supports) leave the Global Edit, so that an object made later
/// under a freed id is never edited; with fewer than two left, Global Edit ends.
pub(crate) fn follow_history(st: &mut crate::DocState) {
let Some(ge) = &mut st.global_edit else { return };
let d = &st.doc;
ge.members.retain(|(id, _)| d.node(*id).is_some_and(supported));
if ge.members.len() < 2 {
st.global_edit = None;
}
}
/// What an edit changed about a member: what [`propagate`] gives the others.
#[derive(Default)]
struct Changed {
outline: bool,
/// The member's appearance before and after the edit, when it changed.
appearance: Option<[Shared<Appearance>; 2]>,
opacity: bool,
blend: bool,
isolate: bool,
knockout: bool,
knockout_shape: bool,
}
impl Changed {
fn between(a: &Node, b: &Node) -> Self {
Self {
outline: a.kind != b.kind || a.bbox_angle != b.bbox_angle,
appearance: (a.appearance != b.appearance).then(|| [a.appearance.clone(), b.appearance.clone()]),
opacity: a.opacity != b.opacity,
blend: a.blend != b.blend,
isolate: a.isolate != b.isolate,
knockout: a.knockout != b.knockout,
knockout_shape: a.knockout_shape != b.knockout_shape,
}
}
fn any(&self) -> bool {
self.outline || self.appearance.is_some() || self.opacity || self.blend || self.isolate || self.knockout || self.knockout_shape
}
/// Give `n` what changed, from `model` (the changed member placed on `n`).
fn apply(&self, model: Node, n: &mut Node) {
if self.outline {
n.kind = model.kind;
n.bbox_angle = model.bbox_angle;
}
if let Some([a, b]) = &self.appearance {
give_appearance(a, b, &model.appearance, &mut n.appearance);
}
if self.opacity {
n.opacity = model.opacity;
}
if self.blend {
n.blend = model.blend;
}
if self.isolate {
n.isolate = model.isolate;
}
if self.knockout {
n.knockout = model.knockout;
}
if self.knockout_shape {
n.knockout_shape = model.knockout_shape;
}
}
}
/// Set each field of `$n` that differs between `$a` and `$b` to the binding of the same name.
macro_rules! give_fields {
($a:ident, $b:ident, $n:ident: $($f:ident),+) => {
$(
if $a.$f != $b.$f {
$n.$f = Clone::clone($f);
}
)+
};
}
/// Give `n` what changed about the model's appearance from `a` (before the edit) to `b` (after),
/// with the values of `m` (`b` placed on `n`'s object); the module doc gives the rules.
fn give_appearance(a: &Appearance, b: &Appearance, m: &Appearance, n: &mut Shared<Appearance>) {
let Appearance { items, effects, contents_index } = m;
if a.effects != b.effects || a.contents_index != b.contents_index {
let n = &mut **n;
give_fields!(a, b, n: effects, contents_index);
}
if a.items == b.items {
return;
}
let kinds = |v: &[AppearanceItem]| v.iter().map(AppearanceItem::is_fill).collect::<Vec<_>>();
if kinds(&a.items) == kinds(&b.items) {
// Fills and strokes kept in order: each changed one goes to the member's of its kind and
// rank.
for (k, ((p, q), mq)) in a.items.iter().zip(&b.items).zip(items).enumerate() {
if p == q {
continue;
}
let rank = a.items.iter().take(k).filter(|x| x.is_fill() == p.is_fill()).count();
if let Some(t) = n.items.iter_mut().filter(|x| x.is_fill() == p.is_fill()).nth(rank) {
give_item(p, q, mq, t);
}
}
return;
}
// One item added (`grew`) or removed at `at`, the others kept in order; the member must hold
// the model's former kinds in order.
let one = |long: &[AppearanceItem], short: &[AppearanceItem]| {
if long.len() != short.len() + 1 {
return None;
}
let at = long.iter().zip(short).position(|(x, y)| x != y).unwrap_or(short.len());
let mut rest = kinds(long);
rest.remove(at);
(rest == kinds(short)).then_some(at)
};
let step = match (one(&b.items, &a.items), one(&a.items, &b.items)) {
(Some(at), _) => Some((at, true)),
(None, Some(at)) => Some((at, false)),
_ => None,
};
match step {
Some((at, grew)) if kinds(&n.items) == kinds(&a.items) => {
let n = &mut n.items;
for (i, (p, t)) in a.items.iter().zip(n.iter_mut()).enumerate() {
// The model's item after the edit that was `p` before it.
let j = match (grew, i < at) {
(_, true) => i,
(true, false) => i + 1,
(false, false) if i == at => continue,
(false, false) => i - 1,
};
if let (Some(q), Some(mq)) = (b.items.get(j), items.get(j))
&& p != q
{
give_item(p, q, mq, t);
}
}
match (grew, items.get(at)) {
(true, Some(new)) => n.insert(at.min(n.len()), new.clone()),
(false, _) if at < n.len() => {
n.remove(at);
}
_ => {}
}
}
_ => n.items = items.clone(),
}
}
/// Give the member's fill or stroke `n` the settings of the model's that changed from `a` to `b`,
/// with the values of `m` (`b` placed on the member). A stroke's pen pressure stays its own.
fn give_item(a: &AppearanceItem, b: &AppearanceItem, m: &AppearanceItem, n: &mut AppearanceItem) {
use AppearanceItem::{Fill, Stroke};
match (a, b, m, n) {
(Fill(a), Fill(b), Fill(m), Fill(n)) => {
let FillLayer { paint, opacity, blend, visible, effects, overprint } = m;
give_fields!(a, b, n: paint, opacity, blend, visible, effects, overprint);
}
(Stroke(a), Stroke(b), Stroke(m), Stroke(n)) => {
let StrokeLayer {
paint,
width,
cap,
join,
miter_limit,
align,
dash,
start_arrow,
end_arrow,
arrow_scale,
profile,
brush,
opacity,
blend,
visible,
effects,
arrow_align,
overprint,
gradient_mode,
pressure: _,
} = m;
give_fields!(a, b, n: paint, width, cap, join, miter_limit, align, dash, start_arrow, end_arrow, arrow_scale, profile, brush, opacity, blend, visible, effects, arrow_align, overprint, gradient_mode);
}
_ => {}
}
}
/// Give the children of a copy placed on another object ids of that object's: its own children's
/// in order, new ids for any more and for everything deeper.
fn renumber(children: &mut [std::sync::Arc<Node>], own: &[NodeId], d: &mut Document) {
for (i, c) in children.iter_mut().enumerate() {
let c = std::sync::Arc::make_mut(c);
c.id = own.get(i).copied().unwrap_or_else(|| d.alloc_id());
if let Some(ch) = c.children_mut() {
renumber(ch, &[], d);
}
}
}
/// After an edit made while Global Edit is on (`before` → `d`): the first member selected before
/// or after the edit that it changed is the model, and every other member that is shown and
/// unlocked gets what changed about it, placed by the members' transforms. → the members it
/// changed so (which [`Session::edit`] checks stay within the canvas, as it checks the selection).
pub(crate) fn propagate(before: &Document, d: &mut Document, ge: &GlobalEdit, selected: [&[NodeId]; 2]) -> Vec<NodeId> {
let model = ge.members.iter().filter(|(id, _)| selected.iter().any(|s| s.contains(id))).find_map(|(id, xf)| {
let (a, b) = (before.node(*id)?, d.node(*id)?);
if std::ptr::eq(a, b) {
return None;
}
let changed = Changed::between(a, b);
changed.any().then(|| (*id, *xf, changed, b.clone()))
});
let Some((model_id, model_xf, changed, model)) = model else { return vec![] };
let back = model_xf.inverse();
let mut edited = vec![];
for (id, xf) in &ge.members {
if *id == model_id || !editable(d, *id) {
continue;
}
let mut placed = model.clone();
placed.transform(*xf * back, Scaling::default());
if changed.outline {
let own: Vec<NodeId> = d.node(*id).and_then(Node::children).map(|c| c.iter().map(|c| c.id).collect()).unwrap_or_default();
if let Some(ch) = placed.children_mut() {
renumber(ch, &own, d);
}
}
if let Some(n) = d.node_mut(*id) {
changed.apply(placed, n);
edited.push(*id);
}
}
edited
}
/// The outlines of the members of the active document's Global Edit that are not selected, in
/// their layer's colour: the canvas highlights them while Global Edit is on.
pub fn highlights(s: &Session) -> Outlines {
let Some(st) = s.active() else { return vec![] };
let Some(ge) = &st.global_edit else { return vec![] };
if let Some((rev, out)) = &*ge.outlines.borrow()
&& *rev == st.revision
{
return out.clone();
}
let symbols = st.doc.symbol_boxes();
let out: Vec<_> = ge
.members
.iter()
.filter(|(id, _)| !st.selection.contains(*id))
.filter_map(|(id, _)| {
let n = st.doc.node(*id)?;
let path = match n.stroke_path() {
Some(p) => p,
None => n.geometric_bounds_with(&symbols)?.to_path(0.1),
};
Some((path, st.doc.layer_color(*id)))
})
.collect();
*ge.outlines.borrow_mut() = Some((st.revision, out.clone()));
out
}