Files
vectorcraft/crates/engine/src/cmd/globaledit.rs
T

699 lines
31 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.
//! - The help page "Edit groups with similar objects"
//! (<https://helpx.adobe.com/illustrator/desktop/manage-objects/edit-objects/edit-groups-with-similar-objects.html>):
//! 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.
//! - 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.
//!
//! 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. Rotated copies 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::{CommandSpec, Result, Session, bad, bool_or, cmd, has_doc};
const C: &str = "select.globalEdit";
const OPTIONS: &str = "select.globalEditOptions";
/// 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<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 (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
),
]
}
/// 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). A
/// group's is its objects' together, in order.
struct Form {
tag: Vec<u64>,
symbols: Vec<String>,
points: Vec<Point>,
/// The unit box → the object.
frame: Affine,
}
impl Form {
fn of(n: &Node) -> Option<Self> {
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() && Form::of(n).is_some()
}
/// 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.
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, 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<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 || 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(());
}
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: building its shape here would walk
// every object in it each time the menu or the Properties panel asks.
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<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);
}
}
}
/// 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<NodeId> = 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<NodeId> {
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);
if let Some(slot) = d.node_mut(*id) {
*slot = 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_map(|(id, _)| {
let n = st.doc.node(*id).filter(|n| !holds(n, &st.selection.objects))?;
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
}