//! Select › Start Global Edit / Stop Global Edit, and the Global Edit Options. //! //! Behaviour from the public help pages "Edit similar objects" //! () //! and "Update assets quickly with Global Edit" //! (), 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. //! - The help page "Edit groups with similar objects" //! (): //! with a group selected, Start Global Edit finds the groups like it, and an edit made to an //! object inside one of them is made to the matching object in each of the others. Here a group //! is like another when it holds as many objects, in the same order, each like the one at its //! place (paths, compound paths, symbol instances and groups of them, clipping paths included), //! and they lie the same way relative to each other: the group as a whole is moved or scaled. //! With Appearance on, each of those objects must have the same fill and stroke too; with Size, //! the groups the same width and height. //! - Selecting one of the repeated objects with the Selection tool highlights the similar objects //! at once, before Global Edit starts. Here they are the objects Start Global Edit would take //! with the current Global Edit Options ([`similar`]). //! - 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, //! tilt, bearing and rotation a stroke was drawn with stay 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. //! //! A member that is a group takes, of an edit made inside the model group, what changed about //! each object in it, given by the same rules to the object at the same place in the member (its //! hidden and locked objects are left as they are), and what changed about the group itself (its //! opacity, blending, fills and effects). When the model group gains or loses objects, or one of //! them becomes another kind of object, each member takes the model's objects at that level //! whole, keeping its own ids in order. At most [`MAX_MEMBERS`] objects are taken, so that a huge //! document can't stall the start or every edit. //! //! In the how-to above, a Shift-click on one of the found objects takes it out of the edit. Here a //! Shift-click (not a Shift-drag) with the Selection tool on a highlighted member, or on an object //! inside it, leaves that member out (`select.globalEditExclude`): it is no longer highlighted, //! and later edits leave it as it is. //! The object the edit started from can't be left out (a Shift-click on it selects it or //! deselects it as before), Global Edit ends when fewer than two members are left, and leaving //! one out is not an undo step. Rotated copies 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::{CommandSpec, Result, Session, bad, bool_or, checked_id, cmd, has_doc}; const C: &str = "select.globalEdit"; const OPTIONS: &str = "select.globalEditOptions"; const EXCLUDE: &str = "select.globalEditExclude"; /// The most objects a Global Edit takes, the selected one included. pub const MAX_MEMBERS: usize = 10_000; const UNSUPPORTED: &str = "Global Edit works on paths, compound paths, symbol instances and groups of them"; pub fn specs() -> Vec { 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 (a path, compound path, symbol instance or group of them), with the objects of the same shape (for a group: the groups holding objects like its own, at the same places) (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), at most 10000 → {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 ), cmd!( "select.globalEditExclude", "Leave Out of Global Edit", [], None, "{id} while Global Edit is on: leave the object `id` of the Global Edit (not the one it started from) out of it, as a Shift-click on it with the Selection tool does: it is no longer highlighted and later edits leave it as it is; with fewer than two objects left, Global Edit ends; not an undo step → {active, count, ids} (the objects left in it; none once it has ended)", is_on, exclude ), ] } /// 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, /// 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 { 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>, } /// 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). A /// group's is its objects' together, in order. struct Form { tag: Vec, symbols: Vec, points: Vec, /// The unit box → the object. frame: Affine, } impl Form { fn of(n: &Node) -> Option { let mut f = Self { tag: vec![], symbols: vec![], points: vec![], frame: Affine::IDENTITY }; f.add(n, 0)?; let first = *f.points.first()?; let b = f.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; } f.frame = Affine::translate(b.origin().to_vec2()) * Affine::scale(side); let back = f.frame.inverse(); f.points.iter_mut().for_each(|p| *p = back * *p); Some(f) } /// Add `n`, `depth` groups down from the selected object; `None` when it isn't supported. fn add(&mut self, n: &Node, depth: usize) -> Option<()> { let rule = |r: vectorcraft_geom::FillRule| u64::from(r == vectorcraft_geom::FillRule::EvenOdd); match &n.kind { // A clipping path only as part of a (clip) group. NodeKind::Path { path, rule: r, clipping, guide: false, .. } if !*clipping || depth > 0 => { self.tag.extend([0, rule(*r), u64::from(*clipping)]); self.push(path); } NodeKind::Compound { children, rule: r } => { self.tag.extend([1, rule(*r), children.len() as u64]); for c in children { self.push(c.path_data()?); } } NodeKind::SymbolInstance { symbol, xf } => { self.tag.push(2); self.symbols.push(symbol.clone()); self.points.extend([*xf * Point::ZERO, *xf * Point::new(1.0, 0.0), *xf * Point::new(0.0, 1.0)]); } // Documents are never deeper than `MAX_DEPTH`: the check only bounds the recursion. NodeKind::Group { children, clip } if plain_group(n) && depth < vectorcraft_doc::MAX_DEPTH => { self.tag.extend([3, u64::from(*clip), children.len() as u64]); for c in children { if c.extras().mask.is_some() { return None; } self.add(c, depth + 1)?; } } _ => return None, } Some(()) } fn push(&mut self, path: &PathData) { self.tag.push(path.subpaths.len() as u64); for sp in &path.subpaths { self.tag.extend([u64::from(sp.closed), sp.anchors.len() as u64]); self.points.extend(sp.anchors.iter().flat_map(|a| [a.p, a.h_in, a.h_out])); } } fn same(&self, o: &Form) -> bool { self.tag == o.tag && self.symbols == o.symbols && 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, symbol instances and groups of them; 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() && has_form(n) } /// Does [`Form::of`] give `n` a form? The same walk and the same box, without collecting the /// points or placing them in a unit box. fn has_form(n: &Node) -> bool { let mut b = None; form_box(n, 0, &mut b).is_some() && b.is_some_and(|b: Rect| { let side = b.width().max(b.height()); side.is_finite() && side > 1e-9 }) } /// [`Form::add`] without the form: `b` grows by each point it would add; `None` when `n` isn't /// supported. fn form_box(n: &Node, depth: usize, b: &mut Option) -> Option<()> { match &n.kind { NodeKind::Path { path, clipping, guide: false, .. } if !*clipping || depth > 0 => grow_by_path(b, path), NodeKind::Compound { children, .. } => { for c in children { grow_by_path(b, c.path_data()?); } } NodeKind::SymbolInstance { xf, .. } => { for p in [*xf * Point::ZERO, *xf * Point::new(1.0, 0.0), *xf * Point::new(0.0, 1.0)] { grow(b, p); } } NodeKind::Group { children, .. } if plain_group(n) && depth < vectorcraft_doc::MAX_DEPTH => { for c in children { if c.extras().mask.is_some() { return None; } form_box(c, depth + 1, b)?; } } _ => return None, } Some(()) } fn grow_by_path(b: &mut Option, path: &PathData) { for a in path.subpaths.iter().flat_map(|sp| &sp.anchors) { for p in [a.p, a.h_in, a.h_out] { grow(b, p); } } } /// `b` grown to take in `p`, as [`Form::of`] measures its points. fn grow(b: &mut Option, p: Point) { *b = Some(b.unwrap_or(Rect::from_points(p, p)).union_pt(p)); } /// A plain group (a clip group too), not one that is live art (a graph, Shaper group, dimension, /// objects on a path or a tracing). fn plain_group(n: &Node) -> bool { let x = n.extras(); matches!(n.kind, NodeKind::Group { .. }) && x.graph.is_none() && x.shaper.is_none() && x.dimension.is_none() && x.objects_on_path.is_none() && x.trace.is_none() } /// Do `a` and `b` show the same fill and stroke, and so does each object in them (groups like each /// other)? fn same_look(a: &Node, b: &Node) -> bool { let (sa, sb) = (stroke_of(a), stroke_of(b)); super::select::same_paint(&fill_of(a), &fill_of(b), true) && super::select::same_paint(&sa.0, &sb.0, true) && close(sa.1, sb.1) && match (&a.kind, &b.kind) { (NodeKind::Group { children: x, .. }, NodeKind::Group { children: y, .. }) => x.iter().zip(y).all(|(p, q)| same_look(p, q)), _ => true, } } /// Is `n`, or an object in it, among `ids`? fn holds(n: &Node, ids: &[NodeId]) -> bool { let mut found = false; n.walk(&mut |c| found = found || ids.contains(&c.id)); found } 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. Refused while an object of a dynamic symbol /// instance's art is selected. fn matches(s: &Session, o: &Options) -> Result> { super::edit::refuse_symbol_part(s)?; 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, UNSUPPORTED)), }; 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 (group, count) = (matches!(key.kind, NodeKind::Group { .. }), key.children().map(Vec::len)); let boards: Option> = 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 || out.len() >= MAX_MEMBERS { return false; } // Look into layers and plain groups (clip groups too: their art, not their clipping path). let look_in = match &n.kind { NodeKind::Layer { .. } => return true, NodeKind::Group { .. } if !plain_group(n) => return false, NodeKind::Group { .. } if !group => return true, NodeKind::Group { .. } => true, // Only groups are like a group. _ if group => return false, _ => false, }; // The object count first: it turns most objects down before their form is made. if n.children().map(Vec::len) == count && 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 || same_look(key, n)) && in_scope(n) { out.push((n.id, f.frame * back)); // A group taken is taken whole, as compound paths, symbol instances and the others are. return false; } look_in }); 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(()); } super::edit::no_symbol_part(s)?; let [id] = st.selection.objects.as_slice() else { return Err(if st.selection.is_empty() { "nothing selected" } else { "select one object" }.into()); }; // A group's contents are checked when Global Edit starts: measuring them here would walk // every object in it each time the menu or the Properties panel asks. Other objects are // measured ([`supported`]), not reduced to their form: the Control bar asks every frame. match st.doc.node(*id) { Some(n) if n.extras().mask.is_none() && plain_group(n) => Ok(()), Some(n) if supported(n) => Ok(()), _ => Err(UNSUPPORTED.into()), } } fn toggle(s: &mut Session, p: &Value) -> Result { 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 = 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 is_on(s: &Session) -> std::result::Result<(), String> { match s.active() { None => Err("no document open".into()), Some(st) if st.global_edit.is_none() => Err("Global Edit is off".into()), Some(_) => Ok(()), } } /// Leave one object out of the Global Edit (a Shift-click on it): not the object it started from. fn exclude(s: &mut Session, p: &Value) -> Result { let id = checked_id(s, p.get("id").ok_or_else(|| bad(EXCLUDE, "missing id"))?, EXCLUDE)?; let st = s.doc_mut()?; let Some(ge) = &mut st.global_edit else { return Err(bad(EXCLUDE, "Global Edit is off")) }; match ge.members.iter().position(|(m, _)| *m == id) { Some(i) if i > 0 => { ge.members.remove(i); } _ => return Err(bad(EXCLUDE, "only an object of the Global Edit other than the one it started from can be left out")), } let ids: Vec = if ge.members.len() < 2 { st.global_edit = None; vec![] } else { ge.members.iter().map(|(id, _)| id.0).collect() }; st.revision += 1; Ok(json!({ "active": !ids.is_empty(), "count": ids.len(), "ids": ids })) } fn set_options(s: &mut Session, p: &Value) -> Result { 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 Results /// 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; 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) { 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::>(); 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, tilt, bearing and /// rotation stay 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: _, tilt: _, bearing: _, rotation: _, } = 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], 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); } } } /// Give member `n` what changed from `a` to `b` (the model before and after the edit), with the /// values of `m` (`b` placed on `n`): in a group that kept its objects (as many, the group still a /// group of the same kind), each object's change to the shown and unlocked object at its place; /// otherwise as one object, whose new children keep `n`'s ids in order. fn give(a: &Node, b: &Node, mut m: Node, n: &mut Node, d: &mut Document) { let mut changed = Changed::between(a, b); let len = a.children().map(Vec::len); let kept = matches!((&a.kind, &b.kind, &n.kind), (NodeKind::Group { clip: x, .. }, NodeKind::Group { clip: y, .. }, NodeKind::Group { .. }) if x == y) && [b, &m, &*n].iter().all(|x| x.children().map(Vec::len) == len); if kept { if let (Some(ac), Some(bc), Some(mc), Some(nc)) = (a.children(), b.children(), m.children(), n.children_mut()) { for (((p, q), mq), t) in ac.iter().zip(bc).zip(mc).zip(nc.iter_mut()) { if !std::sync::Arc::ptr_eq(p, q) && t.visible && !t.locked { give(p, q, (**mq).clone(), std::sync::Arc::make_mut(t), d); } } } if a.bbox_angle != b.bbox_angle { n.bbox_angle = m.bbox_angle; } changed.outline = false; } else if changed.outline { let own: Vec = n.children().map(|c| c.iter().map(|c| c.id).collect()).unwrap_or_default(); if let Some(ch) = m.children_mut() { renumber(ch, &own, d); } } changed.apply(m, n); } /// After an edit made while Global Edit is on (`before` → `d`): the first member selected before /// or after the edit (or holding an object that is) 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 { let model = ge.members.iter().find_map(|(id, xf)| { let (a, b) = (before.node(*id)?, d.node(*id)?); if std::ptr::eq(a, b) || !Changed::between(a, b).any() || !selected.iter().any(|s| holds(a, s) || holds(b, s)) { return None; } Some((*id, *xf, a, b.clone())) }); let Some((model_id, model_xf, was, 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 Some(mut n) = d.node(*id).cloned() else { continue }; let mut placed = model.clone(); placed.transform(*xf * back, Scaling::default()); give(was, &model, placed, &mut n, d); super::object::keep_pixel_aligned(&mut n, Scaling::default()); if let Some(slot) = d.node_mut(*id) { *slot = n; edited.push(*id); } super::object::keep_compound_aligned(d, *id, Scaling::default()); } edited } /// The outlines of the members of the active document's Global Edit that are not selected, hold /// no selected object and sit inside no selected object, in their layer's colour: the canvas /// highlights them while Global Edit is on. While it is off, with one supported object selected, /// they are the outlines of the objects Start Global Edit would take with the Global Edit Options /// ([`similar`]). pub fn highlights(s: &Session) -> Outlines { let Some(st) = s.active() else { return vec![] }; let Some(ge) = &st.global_edit else { return similar(s) }; if let Some((rev, out)) = &*ge.outlines.borrow() && *rev == st.revision { return out.clone(); } let out = outlines(st, &ge.members); *ge.outlines.borrow_mut() = Some((st.revision, out.clone())); out } /// [`similar`]'s outlines as of a document revision and the options they were found with. pub type SimilarCache = std::cell::RefCell>; /// Before Global Edit starts: with one supported object selected, the outlines of the other /// objects Start Global Edit would take now with the session's Global Edit Options, so the canvas /// shows them as soon as the object is selected. None while an interaction (a drag) is in /// progress, or when fewer than two objects would be taken. Kept per document revision and /// options, as finding them walks the whole document. pub fn similar(s: &Session) -> Outlines { let Some(st) = s.active() else { return vec![] }; if st.global_edit.is_some() || st.interaction.is_some() || st.selection.objects.len() != 1 { return vec![]; } let o = &s.global_edit_options; if let Some((rev, opts, out)) = &*st.similar_outlines.borrow() && *rev == st.revision && opts == o { return out.clone(); } let out = match matches(s, o) { Ok(m) if m.len() >= 2 => outlines(st, &m), _ => vec![], }; *st.similar_outlines.borrow_mut() = Some((st.revision, o.clone(), out.clone())); out } /// The outlines of `members` that are not selected, hold no selected object and sit inside no /// selected object, in their layer's color. fn outlines(st: &crate::DocState, members: &[(NodeId, Affine)]) -> Outlines { let symbols = st.doc.symbol_boxes(); members .iter() .filter_map(|(id, _)| { let n = st.doc.node(*id).filter(|n| !holds(n, &st.selection.objects))?; if st.doc.ancestry(*id).is_some_and(|a| a.iter().any(|p| st.selection.objects.contains(p))) { return None; } 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() } #[cfg(test)] mod tests { use super::*; use std::sync::Arc; use vectorcraft_doc::OpacityMask; use vectorcraft_geom::{Anchor, SubPath}; /// Global Edit's support as it was decided by building the form. fn by_form(n: &Node) -> bool { n.extras().mask.is_none() && Form::of(n).is_some() } /// A small seeded generator (xorshift64*), so that a failure repeats. struct Rng(u64); impl Rng { fn next(&mut self) -> u64 { self.0 ^= self.0 >> 12; self.0 ^= self.0 << 25; self.0 ^= self.0 >> 27; self.0.wrapping_mul(0x2545_f491_4f6c_dd1d) } fn below(&mut self, n: u64) -> u64 { self.next() % n } fn unit(&mut self) -> f64 { (self.next() >> 11) as f64 / (1u64 << 53) as f64 } /// A spread for the points of one object: none, below and around the smallest size a form /// takes, ordinary, or past every bound. fn spread(&mut self) -> f64 { [0.0, 1e-10, 5e-10, 1e-9, 2e-9, 1.0, 1.0, 100.0, 100.0, 100.0, f64::INFINITY, f64::NAN][self.below(12) as usize] } fn point(&mut self, base: Point, spread: f64) -> Point { let mut c = |b: f64| match self.below(400) { 0 => f64::NAN, 1 => f64::INFINITY, 2 => f64::NEG_INFINITY, _ => b + (self.unit() - 0.5) * spread, }; Point::new(c(base.x), c(base.y)) } } fn path_data(r: &mut Rng) -> PathData { let (base, spread) = (Point::new(r.unit() * 200.0, r.unit() * 200.0), r.spread()); let subpaths = (0..r.below(3)) .map(|_| { let anchors = (0..r.below(4)) .map(|_| Anchor { p: r.point(base, spread), h_in: r.point(base, spread), h_out: r.point(base, spread), ..Anchor::corner(base) }) .collect(); SubPath::new(anchors, r.below(2) == 0) }) .collect(); PathData::new(subpaths) } /// A random object: paths (clipping paths and guides too), compound paths, symbol instances, /// groups (clip groups, live art, members with an opacity mask), type, blends and intertwined /// objects, any of them with an opacity mask. fn object(r: &mut Rng, d: &mut Document, text: &Node, depth: usize) -> Node { let leaf = depth >= 3; let mut n = match r.below(if leaf { 4 } else { 8 }) { 0 | 1 => { let mut n = Node::path(d.alloc_id(), path_data(r), Appearance::default()); if let NodeKind::Path { clipping, guide, .. } = &mut n.kind { *clipping = r.below(6) == 0; *guide = r.below(8) == 0; } n } 2 => { let xf = match r.below(4) { 0 => Affine::scale(0.0), 1 => Affine::scale(1e-10), _ => Affine::translate((r.unit() * 100.0, r.unit() * 100.0)) * Affine::scale(r.spread()), }; Node::new(d.alloc_id(), NodeKind::SymbolInstance { symbol: "s".into(), xf }) } 3 => { let mut t = text.clone(); t.id = d.alloc_id(); t } 4 => { let children = (0..r.below(4)) .map(|_| match r.below(5) { 0 => Arc::new(object(r, d, text, depth + 1)), _ => Arc::new(Node::path(d.alloc_id(), path_data(r), Appearance::default())), }) .collect(); Node::new(d.alloc_id(), NodeKind::Compound { children, rule: vectorcraft_geom::FillRule::NonZero }) } 5 => { let children = (0..r.below(3)).map(|_| Arc::new(object(r, d, text, depth + 1))).collect(); Node::new(d.alloc_id(), NodeKind::Blend { children, spec: Box::default() }) } _ => { let children = (0..r.below(4)).map(|_| Arc::new(object(r, d, text, depth + 1))).collect(); let mut g = Node::new(d.alloc_id(), NodeKind::Group { children, clip: r.below(3) == 0 }); if r.below(8) == 0 { g.extras_mut().trace = Some(Box::new(Value::Null)); } g } }; if r.below(10) == 0 { n.extras_mut().mask = Some(Box::new(OpacityMask::new(Node::path(d.alloc_id(), path_data(r), Appearance::default()), true))); } n } /// `n` and every object in it. fn every(n: &Node) -> Vec { let mut out = vec![]; n.walk(&mut |c| out.push(c.clone())); out } #[test] fn support_is_decided_as_building_the_form_decides() { let mut s = Session::new(); s.execute("file.new", &json!({"width": 800, "height": 600})).unwrap(); let t = NodeId(s.execute("text.create", &json!({"x": 50, "y": 50, "text": "Hi"})).unwrap()["id"].as_u64().unwrap()); let text = s.doc().unwrap().doc.node(t).unwrap().clone(); let layer = s.doc().unwrap().doc.layers[0].id; let (mut checked, mut taken, mut asked) = (0, 0, 0); for seed in 1..=400u64 { let mut r = Rng(seed.wrapping_mul(0x9e37_79b9_7f4a_7c15) | 1); let n = object(&mut r, Arc::make_mut(&mut s.doc_mut().unwrap().doc), &text, 0); for c in every(&n) { assert_eq!(supported(&c), by_form(&c), "seed {seed}: {c:?}"); checked += 1; taken += usize::from(by_form(&c)); } // The enabled check of Start Global Edit on it, and Start Global Edit itself. let id = Arc::make_mut(&mut s.doc_mut().unwrap().doc).insert(Some(layer), usize::MAX, n.clone()).unwrap(); s.execute("select.set", &json!({"ids": [id.0]})).unwrap(); if s.doc().unwrap().selection.objects != [id] { continue; } asked += 1; let today = n.extras().mask.is_none() && plain_group(&n) || by_form(&n); assert_eq!(can_toggle(&s).is_ok(), today, "seed {seed}"); match s.execute(C, &json!({})) { Ok(_) => { assert!(by_form(&n), "seed {seed}"); s.execute(C, &json!({})).unwrap(); } Err(e) => assert_eq!(e.to_string().contains(UNSUPPORTED), !by_form(&n), "seed {seed}: {e}"), } } // Both answers came up often, and most objects could be selected alone. assert!(taken > 100 && checked - taken > 100 && asked > 200, "{taken} of {checked}, {asked} selected"); } #[test] fn support_of_empty_degenerate_and_deep_objects_is_decided_as_building_the_form_decides() { let mut d = Document::new(800.0, 600.0); let corner = |x: f64, y: f64| Anchor::corner(Point::new(x, y)); let path = |d: &mut Document, subpaths: Vec| Node::path(d.alloc_id(), PathData::new(subpaths), Appearance::default()); let mut cases = vec![ path(&mut d, vec![]), path(&mut d, vec![SubPath::new(vec![], true)]), path(&mut d, vec![SubPath::new(vec![corner(5.0, 5.0)], false)]), path(&mut d, vec![SubPath::new(vec![corner(0.0, 0.0), corner(0.0, 1e-9)], false)]), path(&mut d, vec![SubPath::new(vec![corner(0.0, 0.0), corner(0.0, 2e-9)], false)]), path(&mut d, vec![SubPath::new(vec![corner(0.0, 0.0), corner(f64::INFINITY, 0.0)], false)]), path(&mut d, vec![SubPath::new(vec![corner(f64::NAN, 0.0), corner(10.0, 0.0), corner(0.0, 0.0)], false)]), path(&mut d, vec![SubPath::new(vec![corner(f64::NAN, f64::NAN)], false)]), Node::new(d.alloc_id(), NodeKind::Compound { children: vec![], rule: Default::default() }), Node::group(d.alloc_id(), vec![]), Node::new(d.alloc_id(), NodeKind::SymbolInstance { symbol: "s".into(), xf: Affine::scale(0.0) }), Node::new(d.alloc_id(), NodeKind::SymbolInstance { symbol: "s".into(), xf: Affine::IDENTITY }), ]; // A group as deep as the walk goes, and one level deeper. let mut deep = path(&mut d, vec![SubPath::new(vec![corner(0.0, 0.0), corner(10.0, 10.0)], false)]); for _ in 0..=vectorcraft_doc::MAX_DEPTH { deep = Node::group(d.alloc_id(), vec![Arc::new(deep)]); cases.push(deep.clone()); } let expected = [false, false, false, false, true, false, true, false, false, false, false, true]; for (i, n) in cases.iter().enumerate() { assert_eq!(supported(n), by_form(n), "case {i}: {n:?}"); if let Some(e) = expected.get(i) { assert_eq!(supported(n), *e, "case {i}"); } } assert!(supported(&cases[cases.len() - 2]) && !supported(&cases[cases.len() - 1])); } }