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

1003 lines
44 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,
//! 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<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
),
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<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() && 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<Rect>) -> 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<Rect>, 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<Rect>, 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<Vec<(NodeId, Affine)>> {
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<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(());
}
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<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 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<Value> {
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<u64> = 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<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 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<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, 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<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);
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.
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))?;
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();
*ge.outlines.borrow_mut() = Some((st.revision, out.clone()));
out
}
#[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<Node> {
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<SubPath>| 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]));
}
}