mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-01 21:57:12 -06:00
Select: Start Global Edit edits the objects of the same shape together, each in proportion
Squashed from #3781 onto main.
This commit is contained in:
@@ -412,6 +412,7 @@ fn undo(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
st.sync_new_art();
|
||||
super::maskedit::follow_history(st, editing);
|
||||
leave_isolation_if_gone(st);
|
||||
super::globaledit::follow_history(st);
|
||||
Ok(json!({ "undone": label }))
|
||||
}
|
||||
|
||||
@@ -438,6 +439,7 @@ fn redo(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
st.sync_new_art();
|
||||
super::maskedit::follow_history(st, editing);
|
||||
leave_isolation_if_gone(st);
|
||||
super::globaledit::follow_history(st);
|
||||
Ok(json!({ "redone": label }))
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,587 @@
|
||||
//! Select › Start Global Edit / Stop Global Edit, and the Global Edit Options.
|
||||
//!
|
||||
//! Behaviour from the public help pages "Edit similar objects"
|
||||
//! (<https://helpx.adobe.com/illustrator/desktop/manage-objects/edit-objects/edit-similar-objects.html>)
|
||||
//! and "Update assets quickly with Global Edit"
|
||||
//! (<https://helpx.adobe.com/ph_fil/illustrator/how-to/global-editing-sensei.html>), in our words:
|
||||
//!
|
||||
//! - With one object selected, Start Global Edit finds the objects that repeat its shape: the
|
||||
//! same outline, wherever it lies and at whatever size. Two options narrow the match: Appearance
|
||||
//! (the same fill and stroke too) and Size (the same width and height too). The search covers
|
||||
//! every artboard and the canvas around them by default; it can be limited to a range of
|
||||
//! artboards (`1, 3-5`) and can leave out the objects that lie on no artboard. Images, type,
|
||||
//! masks, linked files and selections of more than one object are not supported.
|
||||
//! - While Global Edit is on, an edit made to one of the found objects is made to all of them,
|
||||
//! each in proportion to its own size: halving the height of a 100 pt object halves a 20 pt
|
||||
//! match to 10 pt. Stop Global Edit (or Escape) ends it.
|
||||
//!
|
||||
//! Here the found objects ([`GlobalEdit::members`]) stay highlighted while the selection keeps the
|
||||
//! object that was selected, so that the Transform panel and the bounding box measure that object
|
||||
//! and not the whole set. Each member keeps the transform (a move and a uniform scale) that maps
|
||||
//! the first object's outline onto its own. After every edit ([`propagate`], run inside
|
||||
//! [`Session::edit`], so the whole change is one undo step), the first selected member the edit
|
||||
//! changed is the model: what changed about it is given to every other member, and nothing else,
|
||||
//! so members found by shape alone keep their own fills and strokes where the edit didn't touch
|
||||
//! them:
|
||||
//!
|
||||
//! - its outline (and anchors), placed through that transform, so in proportion to each member's
|
||||
//! size. Only geometry is placed so: no stroke weight is scaled to a member's size;
|
||||
//! - its opacity, blending, knockout and the object's effects, each when it changed;
|
||||
//! - of its fills and strokes, each setting that changed (paint, weight, opacity, blending,
|
||||
//! effects, caps, dashes, arrowheads …), as it is: it goes to the member's fill or stroke of the
|
||||
//! same rank (the second fill from the bottom to the second fill), and a member without one is
|
||||
//! left without it. A weight set to 4 pt is 4 pt on a smaller member too. The pen pressure a
|
||||
//! stroke was drawn with stays each member's own;
|
||||
//! - a fill or stroke added or removed: a member whose fills and strokes lie in the model's
|
||||
//! former order gains the same item at the same place (or loses the one at that place). A
|
||||
//! member with another order, and any other change to the list (items moved, or several added
|
||||
//! or removed at once), takes the model's whole list of fills and strokes.
|
||||
//!
|
||||
//! Members that are hidden or locked at the time are left as they are. An edit that would take any
|
||||
//! member past the canvas (beyond [`vectorcraft_doc::MAX_COORD`]) is refused whole, as one that
|
||||
//! takes the selection there is. Whenever the document is put back (Undo, Redo, a cancelled drag
|
||||
//! or scrub, an edit that failed), the members it lacks (or no longer supports) leave the Global
|
||||
//! Edit, and it ends when fewer than two are left ([`follow_history`]), so that an object made
|
||||
//! later under a freed id is never edited. Rotated copies, groups (the help page "Edit groups with
|
||||
//! similar objects") and excluding a member with a Shift-click are not covered.
|
||||
|
||||
use serde::Serialize;
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::{Appearance, AppearanceItem, Document, FillLayer, Node, NodeId, NodeKind, Scaling, Shared, StrokeLayer};
|
||||
use vectorcraft_geom::{Affine, BezPath, PathData, Point, Rect, Shape};
|
||||
|
||||
use super::wand::{fill_of, stroke_of};
|
||||
use super::*;
|
||||
|
||||
const C: &str = "select.globalEdit";
|
||||
const OPTIONS: &str = "select.globalEditOptions";
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!(
|
||||
"select.globalEdit",
|
||||
"Start Global Edit",
|
||||
["Select"],
|
||||
None,
|
||||
"{appearance?: bool, size?: bool, artboards?: \"all\" | range such as \"1, 3-5\", includeCanvas?: bool} (each defaults to the Global Edit Options) while Global Edit is off: start it on the one selected object, with the objects of the same shape (and, with `appearance`, the same fill and stroke; with `size`, the same width and height) on the artboards given (and, with `includeCanvas`, off every artboard) → {active: true, count, ids}; while on: stop it (Escape does the same) → {active: false}",
|
||||
can_toggle,
|
||||
toggle
|
||||
),
|
||||
cmd!(
|
||||
"select.globalEditOptions",
|
||||
"Global Edit Options",
|
||||
[],
|
||||
None,
|
||||
"{appearance?: bool, size?: bool, artboards?: \"all\" | range such as \"1, 3-5\", includeCanvas?: bool} set the options Start Global Edit matches with (defaults: shape only, all artboards, objects on the canvas included) → {appearance, size, artboards, includeCanvas, count: the objects Start Global Edit would take now (0 unless one supported object is selected)}",
|
||||
has_doc,
|
||||
set_options
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
/// The Global Edit Options: what a similar object must share with the selected one besides its
|
||||
/// shape, and where it may lie. Kept for the session.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Options {
|
||||
/// Match the fill and stroke too.
|
||||
pub appearance: bool,
|
||||
/// Match the width and height too.
|
||||
pub size: bool,
|
||||
/// The artboards to search (1-based range as typed); `None`: all of them.
|
||||
pub artboards: Option<String>,
|
||||
/// Include the objects that lie on no artboard.
|
||||
pub include_canvas: bool,
|
||||
}
|
||||
|
||||
impl Default for Options {
|
||||
fn default() -> Self {
|
||||
Self { appearance: false, size: false, artboards: None, include_canvas: true }
|
||||
}
|
||||
}
|
||||
|
||||
impl Options {
|
||||
/// These options with the ones `p` gives, checked against `d`'s artboards.
|
||||
fn with(&self, p: &Value, d: &Document, cmd: &str) -> Result<Self> {
|
||||
let mut o = self.clone();
|
||||
o.appearance = bool_or(p, "appearance", o.appearance);
|
||||
o.size = bool_or(p, "size", o.size);
|
||||
o.include_canvas = bool_or(p, "includeCanvas", o.include_canvas);
|
||||
match p.get("artboards") {
|
||||
None | Some(Value::Null) => {}
|
||||
Some(Value::String(s)) if s.trim().is_empty() || s.trim().eq_ignore_ascii_case("all") => o.artboards = None,
|
||||
Some(Value::String(s)) => {
|
||||
vectorcraft_doc::range::parse_range(s, d.artboards.len()).map_err(|e| bad(cmd, e))?;
|
||||
o.artboards = Some(s.trim().to_string());
|
||||
}
|
||||
Some(v) => return Err(bad(cmd, format!("`artboards`: all or a range such as 1, 3-5, not {v}"))),
|
||||
}
|
||||
Ok(o)
|
||||
}
|
||||
|
||||
fn json(&self, count: usize) -> Value {
|
||||
json!({
|
||||
"appearance": self.appearance,
|
||||
"size": self.size,
|
||||
"artboards": self.artboards.as_deref().unwrap_or("all"),
|
||||
"includeCanvas": self.include_canvas,
|
||||
"count": count,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// 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)>,
|
||||
}
|
||||
|
||||
/// An outline reduced to its form: the anchors and handles placed in a unit box, with what tells
|
||||
/// such outlines apart (kind, fill rule, subpaths and their anchor counts, open or closed).
|
||||
struct Form {
|
||||
tag: Vec<u64>,
|
||||
symbol: Option<String>,
|
||||
points: Vec<Point>,
|
||||
/// The unit box → the object.
|
||||
frame: Affine,
|
||||
}
|
||||
|
||||
impl Form {
|
||||
fn of(n: &Node) -> Option<Self> {
|
||||
let mut tag = vec![];
|
||||
let mut points = vec![];
|
||||
let mut symbol = None;
|
||||
let mut push = |path: &PathData, tag: &mut Vec<u64>| {
|
||||
tag.push(path.subpaths.len() as u64);
|
||||
for sp in &path.subpaths {
|
||||
tag.extend([u64::from(sp.closed), sp.anchors.len() as u64]);
|
||||
points.extend(sp.anchors.iter().flat_map(|a| [a.p, a.h_in, a.h_out]));
|
||||
}
|
||||
};
|
||||
let rule = |r: vectorcraft_geom::FillRule| u64::from(r == vectorcraft_geom::FillRule::EvenOdd);
|
||||
match &n.kind {
|
||||
NodeKind::Path { path, rule: r, clipping: false, guide: false, .. } => {
|
||||
tag.extend([0, rule(*r)]);
|
||||
push(path, &mut tag);
|
||||
}
|
||||
NodeKind::Compound { children, rule: r } => {
|
||||
tag.extend([1, rule(*r), children.len() as u64]);
|
||||
for c in children {
|
||||
push(c.path_data()?, &mut tag);
|
||||
}
|
||||
}
|
||||
NodeKind::SymbolInstance { symbol: s, xf } => {
|
||||
tag.push(2);
|
||||
symbol = Some(s.clone());
|
||||
points.extend([*xf * Point::ZERO, *xf * Point::new(1.0, 0.0), *xf * Point::new(0.0, 1.0)]);
|
||||
}
|
||||
_ => return None,
|
||||
}
|
||||
let first = *points.first()?;
|
||||
let b = points.iter().fold(Rect::from_points(first, first), |r, p| r.union_pt(*p));
|
||||
let side = b.width().max(b.height());
|
||||
if !(side.is_finite() && side > 1e-9) {
|
||||
return None;
|
||||
}
|
||||
let frame = Affine::translate(b.origin().to_vec2()) * Affine::scale(side);
|
||||
let back = frame.inverse();
|
||||
points.iter_mut().for_each(|p| *p = back * *p);
|
||||
Some(Self { tag, symbol, points, frame })
|
||||
}
|
||||
|
||||
fn same(&self, o: &Form) -> bool {
|
||||
self.tag == o.tag
|
||||
&& self.symbol == o.symbol
|
||||
&& self.points.len() == o.points.len()
|
||||
&& self.points.iter().zip(&o.points).all(|(a, b)| a.distance(*b) < 1e-6)
|
||||
}
|
||||
}
|
||||
|
||||
/// Global Edit supports paths, compound paths and symbol instances; not images, type, linked
|
||||
/// files, objects with an opacity mask, clipping paths or guides (the help page lists images,
|
||||
/// text, masks and links as unsupported).
|
||||
fn supported(n: &Node) -> bool {
|
||||
n.extras().mask.is_none() && Form::of(n).is_some()
|
||||
}
|
||||
|
||||
fn close(a: f64, b: f64) -> bool {
|
||||
(a - b).abs() <= 1e-6 * a.abs().max(b.abs()).max(1.0)
|
||||
}
|
||||
|
||||
/// The members a Global Edit started on the one selected object would take, with `o`: that
|
||||
/// object first, then the others in paint order.
|
||||
fn matches(s: &Session, o: &Options) -> Result<Vec<(NodeId, Affine)>> {
|
||||
let st = s.doc()?;
|
||||
let d = &st.doc;
|
||||
let [id] = st.selection.objects.as_slice() else { return Err(bad(C, "select one object")) };
|
||||
let key = d.node(*id).ok_or_else(|| bad(C, "nothing selected"))?;
|
||||
let form = match Form::of(key) {
|
||||
Some(f) if supported(key) => f,
|
||||
_ => return Err(bad(C, "Global Edit works on paths, compound paths and symbol instances")),
|
||||
};
|
||||
let symbols = d.symbol_boxes();
|
||||
let size = |n: &Node| n.geometric_bounds_with(&symbols).map(|b| (b.width(), b.height()));
|
||||
let key_size = size(key);
|
||||
let (fill, stroke) = (fill_of(key), stroke_of(key));
|
||||
let boards: Option<Vec<Rect>> = match &o.artboards {
|
||||
None => None,
|
||||
Some(r) => Some(
|
||||
vectorcraft_doc::range::parse_range(r, d.artboards.len())
|
||||
.map_err(|e| bad(C, e))?
|
||||
.into_iter()
|
||||
.filter_map(|i| d.artboards.get(i).map(|a| a.rect))
|
||||
.collect(),
|
||||
),
|
||||
};
|
||||
let on = |r: Rect, b: Rect| b.intersect(r).area() > 0.0 || vectorcraft_doc::artboard_holds(r, b.center());
|
||||
let in_scope = |n: &Node| {
|
||||
let Some(b) = n.geometric_bounds_with(&symbols) else { return false };
|
||||
if !d.artboards.iter().any(|a| on(a.rect, b)) {
|
||||
return o.include_canvas;
|
||||
}
|
||||
boards.as_ref().is_none_or(|rs| rs.iter().any(|r| on(*r, b)))
|
||||
};
|
||||
let mut out = vec![(*id, Affine::IDENTITY)];
|
||||
let back = form.frame.inverse();
|
||||
super::select::walk_selectable(d, st.isolation, |n| {
|
||||
if n.id == *id {
|
||||
return false;
|
||||
}
|
||||
match &n.kind {
|
||||
// Look into layers and plain groups (clip groups too: their art, not their clipping path).
|
||||
NodeKind::Layer { .. } => return true,
|
||||
NodeKind::Group { .. } => {
|
||||
let x = n.extras();
|
||||
return x.graph.is_none() && x.shaper.is_none() && x.dimension.is_none() && x.objects_on_path.is_none() && x.trace.is_none();
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
if let Some(f) = Form::of(n).filter(|f| f.same(&form) && supported(n))
|
||||
&& (!o.size || matches!((key_size, size(n)), (Some((w, h)), Some((w2, h2))) if close(w, w2) && close(h, h2)))
|
||||
&& (!o.appearance || {
|
||||
let (f2, s2) = (fill_of(n), stroke_of(n));
|
||||
super::select::same_paint(&fill, &f2, true) && super::select::same_paint(&stroke.0, &s2.0, true) && close(stroke.1, s2.1)
|
||||
})
|
||||
&& in_scope(n)
|
||||
{
|
||||
out.push((n.id, f.frame * back));
|
||||
}
|
||||
// Compound paths, symbol instances and the other objects are taken whole.
|
||||
false
|
||||
});
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn can_toggle(s: &Session) -> std::result::Result<(), String> {
|
||||
let st = s.active().ok_or("no document open")?;
|
||||
if st.global_edit.is_some() {
|
||||
return Ok(());
|
||||
}
|
||||
let [id] = st.selection.objects.as_slice() else {
|
||||
return Err(if st.selection.is_empty() { "nothing selected" } else { "select one object" }.into());
|
||||
};
|
||||
match st.doc.node(*id) {
|
||||
Some(n) if supported(n) => Ok(()),
|
||||
_ => Err("Global Edit works on paths, compound paths and symbol instances".into()),
|
||||
}
|
||||
}
|
||||
|
||||
fn toggle(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if s.doc()?.global_edit.is_some() {
|
||||
let st = s.doc_mut()?;
|
||||
st.global_edit = None;
|
||||
st.revision += 1;
|
||||
return Ok(json!({ "active": false }));
|
||||
}
|
||||
let o = s.global_edit_options.with(p, &s.doc()?.doc, C)?;
|
||||
let members = matches(s, &o)?;
|
||||
if members.len() < 2 {
|
||||
return Err(bad(C, "no similar objects found (rotated or reflected copies are not matched); change the Global Edit Options and try again"));
|
||||
}
|
||||
let ids: Vec<u64> = members.iter().map(|(id, _)| id.0).collect();
|
||||
let st = s.doc_mut()?;
|
||||
st.global_edit = Some(GlobalEdit { members });
|
||||
st.revision += 1;
|
||||
Ok(json!({ "active": true, "count": ids.len(), "ids": ids }))
|
||||
}
|
||||
|
||||
fn set_options(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let o = s.global_edit_options.with(p, &s.doc()?.doc, OPTIONS)?;
|
||||
s.global_edit_options = o.clone();
|
||||
Ok(o.json(match_count(s)))
|
||||
}
|
||||
|
||||
/// How many objects Start Global Edit would take now with the session's options (the Result(s)
|
||||
/// line of the options): 0 unless one supported object is selected.
|
||||
pub fn match_count(s: &Session) -> usize {
|
||||
match s.active() {
|
||||
Some(st) if st.global_edit.is_none() => matches(s, &s.global_edit_options).map_or(0, |m| m.len()),
|
||||
Some(st) => st.global_edit.as_ref().map_or(0, |g| g.members.len()),
|
||||
None => 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Is `id` there, shown and unlocked, with every layer and group it is in?
|
||||
fn editable(d: &Document, id: NodeId) -> bool {
|
||||
d.ancestry(id).is_some_and(|a| a.iter().all(|i| d.node(*i).is_some_and(|n| n.visible && !n.locked && !n.is_template())))
|
||||
}
|
||||
|
||||
/// After the document of `st` was put back to an earlier one (Undo, Redo, a cancelled interaction
|
||||
/// or undo group, a failed or panicked command, a preview starting again, Discard Empty Type): the
|
||||
/// members it lacks (or no longer supports) leave the Global Edit, so that an object made later
|
||||
/// under a freed id is never edited; with fewer than two left, Global Edit ends.
|
||||
pub(crate) fn follow_history(st: &mut crate::DocState) {
|
||||
let Some(ge) = &mut st.global_edit else { return };
|
||||
let d = &st.doc;
|
||||
ge.members.retain(|(id, _)| d.node(*id).is_some_and(supported));
|
||||
if ge.members.len() < 2 {
|
||||
st.global_edit = None;
|
||||
}
|
||||
}
|
||||
|
||||
/// What an edit changed about a member: what [`propagate`] gives the others.
|
||||
#[derive(Default)]
|
||||
struct Changed {
|
||||
outline: bool,
|
||||
/// The member's appearance before and after the edit, when it changed.
|
||||
appearance: Option<[Shared<Appearance>; 2]>,
|
||||
opacity: bool,
|
||||
blend: bool,
|
||||
isolate: bool,
|
||||
knockout: bool,
|
||||
knockout_shape: bool,
|
||||
}
|
||||
|
||||
impl Changed {
|
||||
fn between(a: &Node, b: &Node) -> Self {
|
||||
Self {
|
||||
outline: a.kind != b.kind || a.bbox_angle != b.bbox_angle,
|
||||
appearance: (a.appearance != b.appearance).then(|| [a.appearance.clone(), b.appearance.clone()]),
|
||||
opacity: a.opacity != b.opacity,
|
||||
blend: a.blend != b.blend,
|
||||
isolate: a.isolate != b.isolate,
|
||||
knockout: a.knockout != b.knockout,
|
||||
knockout_shape: a.knockout_shape != b.knockout_shape,
|
||||
}
|
||||
}
|
||||
|
||||
fn any(&self) -> bool {
|
||||
self.outline || self.appearance.is_some() || self.opacity || self.blend || self.isolate || self.knockout || self.knockout_shape
|
||||
}
|
||||
|
||||
/// Give `n` what changed, from `model` (the changed member placed on `n`).
|
||||
fn apply(&self, model: Node, n: &mut Node) {
|
||||
if self.outline {
|
||||
n.kind = model.kind;
|
||||
n.bbox_angle = model.bbox_angle;
|
||||
}
|
||||
if let Some([a, b]) = &self.appearance {
|
||||
give_appearance(a, b, &model.appearance, &mut n.appearance);
|
||||
}
|
||||
if self.opacity {
|
||||
n.opacity = model.opacity;
|
||||
}
|
||||
if self.blend {
|
||||
n.blend = model.blend;
|
||||
}
|
||||
if self.isolate {
|
||||
n.isolate = model.isolate;
|
||||
}
|
||||
if self.knockout {
|
||||
n.knockout = model.knockout;
|
||||
}
|
||||
if self.knockout_shape {
|
||||
n.knockout_shape = model.knockout_shape;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Set each field of `$n` that differs between `$a` and `$b` to the binding of the same name.
|
||||
macro_rules! give_fields {
|
||||
($a:ident, $b:ident, $n:ident: $($f:ident),+) => {
|
||||
$(
|
||||
if $a.$f != $b.$f {
|
||||
$n.$f = Clone::clone($f);
|
||||
}
|
||||
)+
|
||||
};
|
||||
}
|
||||
|
||||
/// Give `n` what changed about the model's appearance from `a` (before the edit) to `b` (after),
|
||||
/// with the values of `m` (`b` placed on `n`'s object); the module doc gives the rules.
|
||||
fn give_appearance(a: &Appearance, b: &Appearance, m: &Appearance, n: &mut Shared<Appearance>) {
|
||||
let Appearance { items, effects, contents_index } = m;
|
||||
if a.effects != b.effects || a.contents_index != b.contents_index {
|
||||
let n = &mut **n;
|
||||
give_fields!(a, b, n: effects, contents_index);
|
||||
}
|
||||
if a.items == b.items {
|
||||
return;
|
||||
}
|
||||
let kinds = |v: &[AppearanceItem]| v.iter().map(AppearanceItem::is_fill).collect::<Vec<_>>();
|
||||
if kinds(&a.items) == kinds(&b.items) {
|
||||
// Fills and strokes kept in order: each changed one goes to the member's of its kind and
|
||||
// rank.
|
||||
for (k, ((p, q), mq)) in a.items.iter().zip(&b.items).zip(items).enumerate() {
|
||||
if p == q {
|
||||
continue;
|
||||
}
|
||||
let rank = a.items.iter().take(k).filter(|x| x.is_fill() == p.is_fill()).count();
|
||||
if let Some(t) = n.items.iter_mut().filter(|x| x.is_fill() == p.is_fill()).nth(rank) {
|
||||
give_item(p, q, mq, t);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
// One item added (`grew`) or removed at `at`, the others kept in order; the member must hold
|
||||
// the model's former kinds in order.
|
||||
let one = |long: &[AppearanceItem], short: &[AppearanceItem]| {
|
||||
if long.len() != short.len() + 1 {
|
||||
return None;
|
||||
}
|
||||
let at = long.iter().zip(short).position(|(x, y)| x != y).unwrap_or(short.len());
|
||||
let mut rest = kinds(long);
|
||||
rest.remove(at);
|
||||
(rest == kinds(short)).then_some(at)
|
||||
};
|
||||
let step = match (one(&b.items, &a.items), one(&a.items, &b.items)) {
|
||||
(Some(at), _) => Some((at, true)),
|
||||
(None, Some(at)) => Some((at, false)),
|
||||
_ => None,
|
||||
};
|
||||
match step {
|
||||
Some((at, grew)) if kinds(&n.items) == kinds(&a.items) => {
|
||||
let n = &mut n.items;
|
||||
for (i, (p, t)) in a.items.iter().zip(n.iter_mut()).enumerate() {
|
||||
// The model's item after the edit that was `p` before it.
|
||||
let j = match (grew, i < at) {
|
||||
(_, true) => i,
|
||||
(true, false) => i + 1,
|
||||
(false, false) if i == at => continue,
|
||||
(false, false) => i - 1,
|
||||
};
|
||||
if let (Some(q), Some(mq)) = (b.items.get(j), items.get(j))
|
||||
&& p != q
|
||||
{
|
||||
give_item(p, q, mq, t);
|
||||
}
|
||||
}
|
||||
match (grew, items.get(at)) {
|
||||
(true, Some(new)) => n.insert(at.min(n.len()), new.clone()),
|
||||
(false, _) if at < n.len() => {
|
||||
n.remove(at);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
_ => n.items = items.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Give the member's fill or stroke `n` the settings of the model's that changed from `a` to `b`,
|
||||
/// with the values of `m` (`b` placed on the member). A stroke's pen pressure stays its own.
|
||||
fn give_item(a: &AppearanceItem, b: &AppearanceItem, m: &AppearanceItem, n: &mut AppearanceItem) {
|
||||
use AppearanceItem::{Fill, Stroke};
|
||||
match (a, b, m, n) {
|
||||
(Fill(a), Fill(b), Fill(m), Fill(n)) => {
|
||||
let FillLayer { paint, opacity, blend, visible, effects, overprint } = m;
|
||||
give_fields!(a, b, n: paint, opacity, blend, visible, effects, overprint);
|
||||
}
|
||||
(Stroke(a), Stroke(b), Stroke(m), Stroke(n)) => {
|
||||
let StrokeLayer {
|
||||
paint,
|
||||
width,
|
||||
cap,
|
||||
join,
|
||||
miter_limit,
|
||||
align,
|
||||
dash,
|
||||
start_arrow,
|
||||
end_arrow,
|
||||
arrow_scale,
|
||||
profile,
|
||||
brush,
|
||||
opacity,
|
||||
blend,
|
||||
visible,
|
||||
effects,
|
||||
arrow_align,
|
||||
overprint,
|
||||
gradient_mode,
|
||||
pressure: _,
|
||||
} = m;
|
||||
give_fields!(a, b, n: paint, width, cap, join, miter_limit, align, dash, start_arrow, end_arrow, arrow_scale, profile, brush, opacity, blend, visible, effects, arrow_align, overprint, gradient_mode);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// Give the children of a copy placed on another object ids of that object's: its own children's
|
||||
/// in order, new ids for any more and for everything deeper.
|
||||
fn renumber(children: &mut [std::sync::Arc<Node>], own: &[NodeId], d: &mut Document) {
|
||||
for (i, c) in children.iter_mut().enumerate() {
|
||||
let c = std::sync::Arc::make_mut(c);
|
||||
c.id = own.get(i).copied().unwrap_or_else(|| d.alloc_id());
|
||||
if let Some(ch) = c.children_mut() {
|
||||
renumber(ch, &[], d);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// After an edit made while Global Edit is on (`before` → `d`): the first member selected before
|
||||
/// or after the edit that it changed is the model, and every other member that is shown and
|
||||
/// unlocked gets what changed about it, placed by the members' transforms. → the members it
|
||||
/// changed so (which [`Session::edit`] checks stay within the canvas, as it checks the selection).
|
||||
pub(crate) fn propagate(before: &Document, d: &mut Document, ge: &GlobalEdit, selected: [&[NodeId]; 2]) -> Vec<NodeId> {
|
||||
let model = ge.members.iter().filter(|(id, _)| selected.iter().any(|s| s.contains(id))).find_map(|(id, xf)| {
|
||||
let (a, b) = (before.node(*id)?, d.node(*id)?);
|
||||
if std::ptr::eq(a, b) {
|
||||
return None;
|
||||
}
|
||||
let changed = Changed::between(a, b);
|
||||
changed.any().then(|| (*id, *xf, changed, b.clone()))
|
||||
});
|
||||
let Some((model_id, model_xf, changed, model)) = model else { return vec![] };
|
||||
let back = model_xf.inverse();
|
||||
let mut edited = vec![];
|
||||
for (id, xf) in &ge.members {
|
||||
if *id == model_id || !editable(d, *id) {
|
||||
continue;
|
||||
}
|
||||
let mut placed = model.clone();
|
||||
placed.transform(*xf * back, Scaling::default());
|
||||
if changed.outline {
|
||||
let own: Vec<NodeId> = d.node(*id).and_then(Node::children).map(|c| c.iter().map(|c| c.id).collect()).unwrap_or_default();
|
||||
if let Some(ch) = placed.children_mut() {
|
||||
renumber(ch, &own, d);
|
||||
}
|
||||
}
|
||||
if let Some(n) = d.node_mut(*id) {
|
||||
changed.apply(placed, n);
|
||||
edited.push(*id);
|
||||
}
|
||||
}
|
||||
edited
|
||||
}
|
||||
|
||||
/// The outlines of the members of the active document's Global Edit that are not selected, in
|
||||
/// their layer's colour: the canvas highlights them while Global Edit is on.
|
||||
pub fn highlights(s: &Session) -> Vec<(BezPath, [u8; 3])> {
|
||||
let Some(st) = s.active() else { return vec![] };
|
||||
let Some(ge) = &st.global_edit else { return vec![] };
|
||||
let symbols = st.doc.symbol_boxes();
|
||||
ge.members
|
||||
.iter()
|
||||
.filter(|(id, _)| !st.selection.contains(*id))
|
||||
.filter_map(|(id, _)| {
|
||||
let n = st.doc.node(*id)?;
|
||||
let path = match n.stroke_path() {
|
||||
Some(p) => p,
|
||||
None => n.geometric_bounds_with(&symbols)?.to_path(0.1),
|
||||
};
|
||||
Some((path, st.doc.layer_color(*id)))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
@@ -28,6 +28,7 @@ pub mod flatten;
|
||||
pub mod fontfiles;
|
||||
mod fonts;
|
||||
pub(crate) mod freeform;
|
||||
pub mod globaledit;
|
||||
pub(crate) mod gradient;
|
||||
pub(crate) mod graph;
|
||||
mod halftone;
|
||||
@@ -215,6 +216,7 @@ pub fn command_specs() -> &'static [CommandSpec] {
|
||||
v.extend(object::specs());
|
||||
v.extend(path::specs());
|
||||
v.extend(select::specs());
|
||||
v.extend(globaledit::specs());
|
||||
v.extend(wand::specs());
|
||||
v.extend(paint::specs());
|
||||
// Split out of `paint` (M3.8); kept next to it so lists in registry order (the command
|
||||
|
||||
@@ -559,7 +559,7 @@ fn anchors_many(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
/// Select › Same › Fill/Stroke Color: is `a` the same colour as `b`? Tints of one global or spot
|
||||
/// swatch are, unless `tint` (General › Select Same Tint %) asks for the same tint too.
|
||||
fn same_paint(a: &Paint, b: &Paint, tint: bool) -> bool {
|
||||
pub(crate) fn same_paint(a: &Paint, b: &Paint, tint: bool) -> bool {
|
||||
match (a, b) {
|
||||
(Paint::Solid { swatch: Some(x), tint: ta, .. }, Paint::Solid { swatch: Some(y), tint: tb, .. }) if x == y => !tint || (ta - tb).abs() < 1e-4,
|
||||
_ => a == b,
|
||||
|
||||
@@ -1217,6 +1217,7 @@ fn discard_empty(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
st.doc = created.doc.clone();
|
||||
st.history.undo.truncate(k);
|
||||
st.selection.prune(&st.doc);
|
||||
super::globaledit::follow_history(st);
|
||||
st.revision += 1;
|
||||
return Ok(json!({"removed": true}));
|
||||
}
|
||||
|
||||
@@ -348,6 +348,9 @@ pub struct DocState {
|
||||
pub variables_highlight: Option<String>,
|
||||
/// Isolation mode container.
|
||||
pub isolation: Option<NodeId>,
|
||||
/// Select › Start Global Edit: the objects edited together until Stop Global Edit or Escape
|
||||
/// ([`cmd::globaledit`]). Editing state: not saved, not undoable.
|
||||
pub global_edit: Option<cmd::globaledit::GlobalEdit>,
|
||||
pub interaction: Option<Interaction>,
|
||||
/// Edits made while this is open are one undo step (a scrubbed numeric field).
|
||||
pub undo_group: Option<UndoGroup>,
|
||||
@@ -551,6 +554,7 @@ impl DocState {
|
||||
layer_rows: vec![],
|
||||
variables_highlight: None,
|
||||
isolation: None,
|
||||
global_edit: None,
|
||||
interaction: None,
|
||||
undo_group: None,
|
||||
last_transform: None,
|
||||
@@ -644,6 +648,7 @@ impl DocState {
|
||||
self.doc = it.doc;
|
||||
self.selection = Arc::unwrap_or_clone(it.selection);
|
||||
self.sync_new_art();
|
||||
cmd::globaledit::follow_history(self);
|
||||
self.active_layer = it.active_layer;
|
||||
self.layer_rows = it.layer_rows;
|
||||
self.variables_highlight = it.variables_highlight;
|
||||
@@ -861,16 +866,22 @@ const _: () = assert!(vectorcraft_doc::MAX_DEPTH == 32, "TOO_DEEP (and its catal
|
||||
/// Cheap sanity check after an edit: artboards and the objects just touched (the selection) must
|
||||
/// have finite, in-range geometry, so saved files always reload and renderers never see NaN/∞.
|
||||
fn doc_sane(d: &Document, sel: &Selection) -> bool {
|
||||
let ok = |r: vectorcraft_geom::Rect| [r.x0, r.y0, r.x1, r.y1].iter().all(|v| v.is_finite() && v.abs() <= MAX_COORD);
|
||||
d.ruler_origin.is_finite()
|
||||
&& d.ruler_origin.x.abs() <= MAX_COORD
|
||||
&& d.ruler_origin.y.abs() <= MAX_COORD
|
||||
&& d.artboards.iter().all(|a| ok(a.rect))
|
||||
&& d.artboards.iter().all(|a| rect_in_range(a.rect))
|
||||
&& d.guides.iter().all(|g| g.pos.is_finite() && g.pos.abs() <= MAX_COORD)
|
||||
&& {
|
||||
let paths = d.index_paths(sel.objects.iter().copied());
|
||||
sel.objects.iter().all(|id| paths.get(id).and_then(|p| d.node_at(p)).and_then(|n| n.geometric_bounds()).is_none_or(ok))
|
||||
}
|
||||
&& objects_in_range(d, &sel.objects)
|
||||
}
|
||||
|
||||
fn rect_in_range(r: vectorcraft_geom::Rect) -> bool {
|
||||
[r.x0, r.y0, r.x1, r.y1].iter().all(|v| v.is_finite() && v.abs() <= MAX_COORD)
|
||||
}
|
||||
|
||||
/// Do the objects `ids` have finite geometry within [`MAX_COORD`] (the ones not there pass)?
|
||||
fn objects_in_range(d: &Document, ids: &[NodeId]) -> bool {
|
||||
let paths = d.index_paths(ids.iter().copied());
|
||||
ids.iter().all(|id| paths.get(id).and_then(|p| d.node_at(p)).and_then(|n| n.geometric_bounds()).is_none_or(rect_in_range))
|
||||
}
|
||||
|
||||
/// Where new art goes (Illustrator's drawing modes, Shift+D cycles).
|
||||
@@ -1456,6 +1467,9 @@ pub struct Session {
|
||||
pub link_stamps: std::collections::HashMap<(u64, String), link_watch::Stamp>,
|
||||
/// The stamps being taken on a worker thread ([`Session::start_link_scan`]).
|
||||
pub link_scan: Option<link_watch::LinkScan>,
|
||||
/// The Global Edit Options Start Global Edit matches with (`select.globalEditOptions`); not
|
||||
/// saved.
|
||||
pub global_edit_options: cmd::globaledit::Options,
|
||||
}
|
||||
|
||||
impl Default for Session {
|
||||
@@ -1538,6 +1552,7 @@ impl Session {
|
||||
search_threads: None,
|
||||
link_stamps: Default::default(),
|
||||
link_scan: None,
|
||||
global_edit_options: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1839,6 +1854,7 @@ impl Session {
|
||||
st.doc = doc;
|
||||
st.selection = selection;
|
||||
st.interaction = interaction;
|
||||
cmd::globaledit::follow_history(st);
|
||||
st.revision += 1;
|
||||
}
|
||||
if active.is_some_and(|i| i < self.docs.len()) {
|
||||
@@ -1868,8 +1884,14 @@ impl Session {
|
||||
let before = st.doc.clone();
|
||||
let before_sel = st.selection.clone();
|
||||
let doc = Arc::make_mut(&mut st.doc);
|
||||
// The Global Edit members the edit reached besides the selection.
|
||||
let mut spread = vec![];
|
||||
// Objects attached to a path follow it, and dimensions follow the art they measure.
|
||||
let result = match f(doc, &mut st.selection).and_then(|v| {
|
||||
// Global Edit: the other members take what the edit changed about the one it edited.
|
||||
if let Some(ge) = &st.global_edit {
|
||||
spread = cmd::globaledit::propagate(&before, doc, ge, [&before_sel.objects, &st.selection.objects]);
|
||||
}
|
||||
cmd::objects_on_path::refresh(&before, doc);
|
||||
cmd::dimension::refresh(&before, doc)?;
|
||||
Ok(v)
|
||||
@@ -1902,7 +1924,7 @@ impl Session {
|
||||
if st.doc.too_deep(Some(&before)) {
|
||||
// Deeper trees overflow the stack of what walks them.
|
||||
Err(EngineError::Other(TOO_DEEP.into()))
|
||||
} else if doc_sane(&st.doc, &st.selection) {
|
||||
} else if doc_sane(&st.doc, &st.selection) && objects_in_range(&st.doc, &spread) {
|
||||
Ok(v)
|
||||
} else {
|
||||
Err(EngineError::Other("result would exceed the canvas (coordinates out of range)".into()))
|
||||
@@ -1934,6 +1956,7 @@ impl Session {
|
||||
Err(e) => {
|
||||
st.doc = before;
|
||||
st.selection = before_sel;
|
||||
cmd::globaledit::follow_history(st);
|
||||
Err(e)
|
||||
}
|
||||
}
|
||||
@@ -2052,6 +2075,7 @@ impl Session {
|
||||
if selection_changed {
|
||||
st.selection.clone_from(&it.selection);
|
||||
}
|
||||
cmd::globaledit::follow_history(st);
|
||||
}
|
||||
// The previous preview's paint is undone with it.
|
||||
self.pending_paint = None;
|
||||
@@ -2208,6 +2232,7 @@ impl Session {
|
||||
deselect_guides(&st.doc, &mut st.selection);
|
||||
}
|
||||
st.sync_new_art();
|
||||
cmd::globaledit::follow_history(st);
|
||||
st.revision += 1;
|
||||
// The journal drops only the canceled groups' own entries and keeps the ones made in other documents meanwhile.
|
||||
journal.extend(g.journal);
|
||||
@@ -2285,6 +2310,7 @@ impl Session {
|
||||
}
|
||||
st.doc = doc;
|
||||
st.selection = selection;
|
||||
cmd::globaledit::follow_history(st);
|
||||
if guides_locked {
|
||||
deselect_guides(&st.doc, &mut st.selection);
|
||||
}
|
||||
@@ -2429,6 +2455,8 @@ mod tests_fontlist;
|
||||
#[cfg(test)]
|
||||
mod tests_freeform;
|
||||
#[cfg(test)]
|
||||
mod tests_globaledit;
|
||||
#[cfg(test)]
|
||||
mod tests_gradient;
|
||||
#[cfg(test)]
|
||||
mod tests_gradpanel;
|
||||
|
||||
@@ -0,0 +1,445 @@
|
||||
//! Select › Start Global Edit (`select.globalEdit`, `select.globalEditOptions`): which objects
|
||||
//! count as similar, and edits made to one of them reaching the others in proportion, as one undo
|
||||
//! step. Behaviour: <https://helpx.adobe.com/illustrator/desktop/manage-objects/edit-objects/edit-similar-objects.html>.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::{Node, NodeKind};
|
||||
use vectorcraft_geom::{Affine, PathData, Rect};
|
||||
|
||||
use super::*;
|
||||
|
||||
fn session() -> Session {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 800, "height": 600})).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
fn rect(s: &mut Session, x: f64, y: f64, w: f64, h: f64) -> NodeId {
|
||||
NodeId(s.execute("shape.rectangle", &json!({"x": x, "y": y, "width": w, "height": h})).unwrap()["id"].as_u64().unwrap())
|
||||
}
|
||||
|
||||
fn node(s: &Session, id: NodeId) -> Node {
|
||||
s.doc().unwrap().doc.node(id).unwrap().clone()
|
||||
}
|
||||
|
||||
fn bounds(s: &Session, id: NodeId) -> Rect {
|
||||
node(s, id).geometric_bounds().unwrap()
|
||||
}
|
||||
|
||||
fn sel(s: &mut Session, ids: &[NodeId]) {
|
||||
s.execute("select.set", &json!({"ids": ids.iter().map(|i| i.0).collect::<Vec<_>>()})).unwrap();
|
||||
}
|
||||
|
||||
fn ids(v: &Value) -> Vec<NodeId> {
|
||||
v["ids"].as_array().unwrap().iter().map(|i| NodeId(i.as_u64().unwrap())).collect()
|
||||
}
|
||||
|
||||
fn start(s: &mut Session, key: NodeId, p: Value) -> Vec<NodeId> {
|
||||
sel(s, &[key]);
|
||||
let r = s.execute("select.globalEdit", &p).unwrap();
|
||||
assert_eq!(r["active"], true);
|
||||
ids(&r)
|
||||
}
|
||||
|
||||
fn fill(s: &Session, id: NodeId) -> String {
|
||||
node(s, id).appearance.fill_paint().color().unwrap().to_hex()
|
||||
}
|
||||
|
||||
fn stroke(s: &Session, id: NodeId) -> (String, f64) {
|
||||
let n = node(s, id);
|
||||
(n.appearance.stroke_paint().color().unwrap().to_hex(), n.appearance.stroke_width())
|
||||
}
|
||||
|
||||
fn members(s: &Session) -> Option<Vec<NodeId>> {
|
||||
s.doc().unwrap().global_edit.as_ref().map(|g| g.members.iter().map(|m| m.0).collect())
|
||||
}
|
||||
|
||||
fn close(a: Rect, b: Rect) -> bool {
|
||||
[(a.x0, b.x0), (a.y0, b.y0), (a.x1, b.x1), (a.y1, b.y1)].iter().all(|(p, q)| (p - q).abs() < 1e-6)
|
||||
}
|
||||
|
||||
/// `b`'s outline is `a`'s placed by `xf`, anchor for anchor.
|
||||
fn placed(s: &Session, a: NodeId, b: NodeId, xf: Affine) -> bool {
|
||||
let (pa, pb): (PathData, PathData) = (node(s, a).path_data().unwrap().transformed(xf), node(s, b).path_data().unwrap().clone());
|
||||
pa.subpaths.len() == pb.subpaths.len()
|
||||
&& pa.subpaths.iter().zip(&pb.subpaths).all(|(x, y)| {
|
||||
x.closed == y.closed
|
||||
&& x.anchors.len() == y.anchors.len()
|
||||
&& x.anchors
|
||||
.iter()
|
||||
.zip(&y.anchors)
|
||||
.all(|(m, n)| m.p.distance(n.p) < 1e-6 && m.h_in.distance(n.h_in) < 1e-6 && m.h_out.distance(n.h_out) < 1e-6)
|
||||
})
|
||||
}
|
||||
|
||||
/// A 100 pt square, a copy of it, a 20 pt square (the same shape, smaller), a 100 × 50 rectangle
|
||||
/// and a 100 pt circle (other shapes).
|
||||
fn squares(s: &mut Session) -> [NodeId; 5] {
|
||||
[rect(s, 0.0, 0.0, 100.0, 100.0), rect(s, 300.0, 0.0, 100.0, 100.0), rect(s, 500.0, 0.0, 20.0, 20.0), rect(s, 0.0, 300.0, 100.0, 50.0), {
|
||||
let r = s.execute("shape.ellipse", &json!({"x": 300, "y": 300, "width": 100, "height": 100})).unwrap();
|
||||
NodeId(r["id"].as_u64().unwrap())
|
||||
}]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn start_global_edit_takes_the_objects_of_the_same_shape_at_any_place_and_size() {
|
||||
let mut s = session();
|
||||
let [a, b, c, wide, circle] = squares(&mut s);
|
||||
let members = start(&mut s, a, json!({}));
|
||||
assert_eq!(members, [a, b, c], "the copy and the smaller square; not the rectangle {wide} nor the circle {circle}");
|
||||
// The selected object stays the selection; the others are highlighted on the canvas.
|
||||
assert_eq!(s.doc().unwrap().selection.objects, [a]);
|
||||
assert_eq!(crate::cmd::globaledit::highlights(&s).len(), 2);
|
||||
let r = s.execute("select.globalEdit", &json!({})).unwrap();
|
||||
assert_eq!(r["active"], false, "the second run is Stop Global Edit");
|
||||
assert!(s.doc().unwrap().global_edit.is_none() && crate::cmd::globaledit::highlights(&s).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn size_and_appearance_options_narrow_the_match() {
|
||||
let mut s = session();
|
||||
let [a, b, c, ..] = squares(&mut s);
|
||||
assert_eq!(start(&mut s, a, json!({"size": true})), [a, b], "the smaller square has another size");
|
||||
s.execute("select.globalEdit", &json!({})).unwrap();
|
||||
sel(&mut s, &[b]);
|
||||
s.execute("paint.setFill", &json!({"color": "#12ab34"})).unwrap();
|
||||
assert_eq!(start(&mut s, a, json!({"appearance": true})), [a, c], "the copy has another fill now");
|
||||
s.execute("select.globalEdit", &json!({})).unwrap();
|
||||
// The options are kept for the session, and report how many objects they take.
|
||||
sel(&mut s, &[a]);
|
||||
let r = s.execute("select.globalEditOptions", &json!({"size": true, "appearance": true})).unwrap();
|
||||
assert_eq!((r["size"].clone(), r["appearance"].clone(), r["count"].clone()), (json!(true), json!(true), json!(1)));
|
||||
let e = s.execute("select.globalEdit", &json!({})).unwrap_err().to_string();
|
||||
assert!(e.contains("no similar objects"), "{e}");
|
||||
s.execute("select.globalEditOptions", &json!({"size": false, "appearance": false})).unwrap();
|
||||
assert_eq!(start(&mut s, a, json!({})), [a, b, c]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_edit_reaches_every_member_in_proportion_as_one_undo_step() {
|
||||
let mut s = session();
|
||||
let [a, b, c, wide, circle] = squares(&mut s);
|
||||
start(&mut s, a, json!({}));
|
||||
let before = (*s.doc().unwrap().doc).clone();
|
||||
let steps = s.doc().unwrap().history.undo.len();
|
||||
let (wide_was, circle_was) = (node(&s, wide), node(&s, circle));
|
||||
// Height 100 → 50 from the top left (the Transform panel): the 20 pt square goes to 10 pt.
|
||||
s.execute("object.setBounds", &json!({"height": 50, "reference": 0})).unwrap();
|
||||
assert!(close(bounds(&s, a), Rect::new(0.0, 0.0, 100.0, 50.0)), "{:?}", bounds(&s, a));
|
||||
assert!(close(bounds(&s, b), Rect::new(300.0, 0.0, 400.0, 50.0)), "{:?}", bounds(&s, b));
|
||||
assert!(close(bounds(&s, c), Rect::new(500.0, 0.0, 520.0, 10.0)), "{:?}", bounds(&s, c));
|
||||
assert_eq!((node(&s, wide), node(&s, circle)), (wide_was, circle_was), "objects of other shapes are left alone");
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), steps + 1, "one undo step");
|
||||
crate::tests_large_edits::undo_gives_back(&mut s, &before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_anchor_moved_on_one_member_moves_on_the_others() {
|
||||
let mut s = session();
|
||||
let [a, b, c, ..] = squares(&mut s);
|
||||
start(&mut s, a, json!({}));
|
||||
s.execute("select.anchors", &json!({"id": a.0, "anchors": [[0, 0]]})).unwrap();
|
||||
s.execute("path.moveAnchors", &json!({"dx": 10, "dy": 30})).unwrap();
|
||||
assert!(placed(&s, a, b, Affine::translate((300.0, 0.0))));
|
||||
assert!(placed(&s, a, c, Affine::translate((500.0, 0.0)) * Affine::scale(0.2)));
|
||||
assert!(!placed(&s, a, c, Affine::translate((500.0, 0.0))), "the smaller square's anchor moves a fifth as far");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_drag_moves_the_members_with_each_preview_and_commits_one_step() {
|
||||
let mut s = session();
|
||||
let [a, b, c, ..] = squares(&mut s);
|
||||
start(&mut s, a, json!({}));
|
||||
let before = (*s.doc().unwrap().doc).clone();
|
||||
s.begin_interaction("Move").unwrap();
|
||||
s.preview("object.move", &json!({"dx": 30, "dy": 0})).unwrap();
|
||||
s.preview("object.move", &json!({"dx": 40, "dy": 0})).unwrap();
|
||||
assert!(close(bounds(&s, b), Rect::new(340.0, 0.0, 440.0, 100.0)), "the second preview replaces the first: {:?}", bounds(&s, b));
|
||||
s.commit_interaction().unwrap();
|
||||
assert!(close(bounds(&s, c), Rect::new(508.0, 0.0, 528.0, 20.0)), "{:?}", bounds(&s, c));
|
||||
assert_eq!(s.doc().unwrap().history.undo.last().unwrap().label, "Move");
|
||||
crate::tests_large_edits::undo_gives_back(&mut s, &before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn any_selected_member_leads_and_fills_reach_the_others_only_when_they_change() {
|
||||
let mut s = session();
|
||||
let [a, b, c, ..] = squares(&mut s);
|
||||
sel(&mut s, &[c]);
|
||||
s.execute("paint.setFill", &json!({"color": "#123456"})).unwrap();
|
||||
start(&mut s, a, json!({}));
|
||||
let c_fill = fill(&s, c);
|
||||
// Moving the copy (selected during Global Edit) moves the others in proportion and keeps
|
||||
// their own fills.
|
||||
sel(&mut s, &[b]);
|
||||
s.execute("object.move", &json!({"dx": 50, "dy": 0})).unwrap();
|
||||
assert!(close(bounds(&s, a), Rect::new(50.0, 0.0, 150.0, 100.0)), "{:?}", bounds(&s, a));
|
||||
assert!(close(bounds(&s, c), Rect::new(510.0, 0.0, 530.0, 20.0)), "{:?}", bounds(&s, c));
|
||||
assert_eq!(fill(&s, c), c_fill);
|
||||
// A new fill reaches all of them, and the stroke weight stays the model's, not scaled.
|
||||
s.execute("paint.setFill", &json!({"color": "#ff0000"})).unwrap();
|
||||
s.execute("stroke.set", &json!({"weight": 4})).unwrap();
|
||||
for id in [a, b, c] {
|
||||
assert_eq!(fill(&s, id), "#ff0000");
|
||||
assert_eq!(node(&s, id).appearance.stroke_width(), 4.0);
|
||||
}
|
||||
// Selection changes alone edit nothing; after Stop Global Edit an edit stays on its object.
|
||||
s.execute("select.globalEdit", &json!({})).unwrap();
|
||||
s.execute("object.move", &json!({"dx": 5, "dy": 0})).unwrap();
|
||||
assert!(close(bounds(&s, a), Rect::new(50.0, 0.0, 150.0, 100.0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn locked_and_hidden_objects_are_left_out() {
|
||||
let mut s = session();
|
||||
let [a, b, c, ..] = squares(&mut s);
|
||||
let locked = rect(&mut s, 0.0, 450.0, 100.0, 100.0);
|
||||
let hidden = rect(&mut s, 150.0, 450.0, 100.0, 100.0);
|
||||
s.execute("object.lock", &json!({"ids": [locked.0]})).unwrap();
|
||||
s.execute("object.hide", &json!({"ids": [hidden.0]})).unwrap();
|
||||
assert_eq!(start(&mut s, a, json!({})), [a, b, c], "locked and hidden squares are not found");
|
||||
// A member locked after the start is not edited either.
|
||||
s.execute("object.lock", &json!({"ids": [b.0]})).unwrap();
|
||||
let b_was = node(&s, b);
|
||||
sel(&mut s, &[a]);
|
||||
s.execute("object.move", &json!({"dx": 0, "dy": 10})).unwrap();
|
||||
assert_eq!(node(&s, b), b_was);
|
||||
assert!(close(bounds(&s, c), Rect::new(500.0, 2.0, 520.0, 22.0)), "{:?}", bounds(&s, c));
|
||||
assert_eq!(bounds(&s, locked), Rect::new(0.0, 450.0, 100.0, 550.0));
|
||||
assert_eq!(bounds(&s, hidden), Rect::new(150.0, 450.0, 250.0, 550.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn artboard_options_limit_where_similar_objects_are_found() {
|
||||
let mut s = session();
|
||||
s.execute("artboard.new", &json!({"x": 1000, "y": 0, "width": 400, "height": 400})).unwrap();
|
||||
let key = rect(&mut s, 10.0, 10.0, 50.0, 50.0);
|
||||
let same_board = rect(&mut s, 200.0, 10.0, 50.0, 50.0);
|
||||
let second = rect(&mut s, 1100.0, 10.0, 50.0, 50.0);
|
||||
let canvas = rect(&mut s, 2000.0, 2000.0, 50.0, 50.0);
|
||||
assert_eq!(start(&mut s, key, json!({})), [key, same_board, second, canvas], "all artboards and the canvas by default");
|
||||
s.execute("select.globalEdit", &json!({})).unwrap();
|
||||
assert_eq!(start(&mut s, key, json!({"includeCanvas": false})), [key, same_board, second]);
|
||||
s.execute("select.globalEdit", &json!({})).unwrap();
|
||||
assert_eq!(start(&mut s, key, json!({"artboards": "2"})), [key, second, canvas]);
|
||||
s.execute("select.globalEdit", &json!({})).unwrap();
|
||||
assert_eq!(start(&mut s, key, json!({"artboards": "2", "includeCanvas": false})), [key, second]);
|
||||
s.execute("select.globalEdit", &json!({})).unwrap();
|
||||
let e = s.execute("select.globalEdit", &json!({"artboards": "3"})).unwrap_err().to_string();
|
||||
assert!(e.contains("1 to 2"), "{e}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compound_paths_match_whole_and_keep_their_own_member_ids() {
|
||||
let mut s = session();
|
||||
let compound = |s: &mut Session, x: f64, k: f64| {
|
||||
let (p, q) = (rect(s, x, 0.0, 100.0 * k, 100.0 * k), rect(s, x + 25.0 * k, 25.0 * k, 50.0 * k, 50.0 * k));
|
||||
sel(s, &[p, q]);
|
||||
NodeId(s.execute("object.compoundPath.make", &json!({})).unwrap()["id"].as_u64().unwrap())
|
||||
};
|
||||
let (one, two) = (compound(&mut s, 0.0, 1.0), compound(&mut s, 300.0, 0.5));
|
||||
let lone = rect(&mut s, 600.0, 0.0, 100.0, 100.0);
|
||||
assert_eq!(start(&mut s, one, json!({})), [one, two], "not the square {lone} alone");
|
||||
let kids = |s: &Session, id: NodeId| node(s, id).children().unwrap().iter().map(|c| c.id).collect::<Vec<_>>();
|
||||
let two_kids = kids(&s, two);
|
||||
s.execute("object.setBounds", &json!({"width": 200, "reference": 0})).unwrap();
|
||||
assert!(close(bounds(&s, two), Rect::new(300.0, 0.0, 400.0, 50.0)), "{:?}", bounds(&s, two));
|
||||
assert_eq!(kids(&s, two), two_kids);
|
||||
assert!(matches!(node(&s, two).kind, NodeKind::Compound { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn symbol_instances_of_one_symbol_match() {
|
||||
let mut s = session();
|
||||
let r = rect(&mut s, 0.0, 0.0, 40.0, 40.0);
|
||||
let made = s.execute("symbol.new", &json!({"ids": [r.0], "name": "Tile"})).unwrap();
|
||||
let first = NodeId(made["id"].as_u64().unwrap());
|
||||
let placed = s.execute("symbol.place", &json!({"name": "Tile", "x": 300, "y": 300})).unwrap();
|
||||
let second = NodeId(placed["id"].as_u64().unwrap());
|
||||
let other = rect(&mut s, 500.0, 500.0, 40.0, 40.0);
|
||||
assert_eq!(start(&mut s, first, json!({})), [first, second], "not the plain square {other}");
|
||||
let xf = |s: &Session, id: NodeId| match node(s, id).kind {
|
||||
NodeKind::SymbolInstance { xf, .. } => xf,
|
||||
_ => panic!("not an instance"),
|
||||
};
|
||||
let was = xf(&s, second);
|
||||
s.execute("object.move", &json!({"dx": 10, "dy": 0})).unwrap();
|
||||
let (now, want) = (xf(&s, second).as_coeffs(), (Affine::translate((10.0, 0.0)) * was).as_coeffs());
|
||||
assert!(now.iter().zip(want).all(|(p, q)| (p - q).abs() < 1e-6), "{now:?} vs {want:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn global_edit_needs_one_supported_object() {
|
||||
let mut s = session();
|
||||
let [a, b, ..] = squares(&mut s);
|
||||
sel(&mut s, &[a, b]);
|
||||
assert!(matches!(s.execute("select.globalEdit", &json!({})), Err(EngineError::Disabled(..))), "a selection of two");
|
||||
let t = NodeId(s.execute("text.create", &json!({"x": 50, "y": 500, "text": "Hi"})).unwrap()["id"].as_u64().unwrap());
|
||||
sel(&mut s, &[t]);
|
||||
assert!(matches!(s.execute("select.globalEdit", &json!({})), Err(EngineError::Disabled(..))), "type");
|
||||
let lone = rect(&mut s, 0.0, 500.0, 30.0, 70.0);
|
||||
// A copy turned a quarter turn (70 × 30) is not taken: rotated copies are not matched, and the
|
||||
// message says so.
|
||||
let turned = rect(&mut s, 200.0, 500.0, 30.0, 70.0);
|
||||
sel(&mut s, &[turned]);
|
||||
s.execute("object.rotate", &json!({"angle": 90})).unwrap();
|
||||
sel(&mut s, &[lone]);
|
||||
let e = s.execute("select.globalEdit", &json!({})).unwrap_err().to_string();
|
||||
assert!(e.contains("no similar objects") && e.contains("rotated"), "{e}");
|
||||
assert!(s.doc().unwrap().global_edit.is_none());
|
||||
// A group of two squares is refused, though another such group exists.
|
||||
let group = |s: &mut Session, x: f64| {
|
||||
let (p, q) = (rect(s, x, 100.0, 10.0, 10.0), rect(s, x + 20.0, 100.0, 10.0, 10.0));
|
||||
sel(s, &[p, q]);
|
||||
NodeId(s.execute("object.group", &json!({})).unwrap()["id"].as_u64().unwrap())
|
||||
};
|
||||
let (g, _) = (group(&mut s, 600.0), group(&mut s, 700.0));
|
||||
sel(&mut s, &[g]);
|
||||
let e = s.execute("select.globalEdit", &json!({})).unwrap_err();
|
||||
assert!(matches!(e, EngineError::Disabled(..)) && e.to_string().contains("paths, compound paths and symbol instances"), "{e}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn global_edit_is_not_saved() {
|
||||
let mut s = session();
|
||||
let [a, ..] = squares(&mut s);
|
||||
start(&mut s, a, json!({}));
|
||||
let saved = s.execute("document.serialize", &json!({})).unwrap().to_string();
|
||||
assert!(!saved.to_ascii_lowercase().contains("globaledit"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_stroke_weight_change_leaves_each_members_own_fill_and_stroke_colour() {
|
||||
let mut s = session();
|
||||
let [a, b, c, ..] = squares(&mut s);
|
||||
sel(&mut s, &[b]);
|
||||
s.execute("paint.setFill", &json!({"color": "#0000ff"})).unwrap();
|
||||
s.execute("paint.setStroke", &json!({"color": "#00ff00"})).unwrap();
|
||||
start(&mut s, a, json!({}));
|
||||
let (a_fill, c_fill) = (fill(&s, a), fill(&s, c));
|
||||
s.execute("stroke.set", &json!({"weight": 5})).unwrap();
|
||||
assert_eq!(fill(&s, b), "#0000ff", "the copy keeps its own fill");
|
||||
assert_eq!(stroke(&s, b), ("#00ff00".to_string(), 5.0), "and its own stroke colour, with the new weight");
|
||||
assert_eq!(stroke(&s, c).1, 5.0, "the weight is the model's, not scaled");
|
||||
assert_eq!((fill(&s, a), fill(&s, c)), (a_fill, c_fill));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_fill_colour_change_reaches_the_fills_and_not_the_strokes() {
|
||||
let mut s = session();
|
||||
let [a, b, c, ..] = squares(&mut s);
|
||||
sel(&mut s, &[b]);
|
||||
s.execute("paint.setStroke", &json!({"color": "#00ff00"})).unwrap();
|
||||
s.execute("stroke.set", &json!({"weight": 3})).unwrap();
|
||||
start(&mut s, a, json!({}));
|
||||
let (a_stroke, c_stroke) = (stroke(&s, a), stroke(&s, c));
|
||||
s.execute("paint.setFill", &json!({"color": "#ff0000"})).unwrap();
|
||||
for id in [a, b, c] {
|
||||
assert_eq!(fill(&s, id), "#ff0000");
|
||||
}
|
||||
assert_eq!(stroke(&s, b), ("#00ff00".to_string(), 3.0), "the copy keeps its own stroke");
|
||||
assert_eq!((stroke(&s, a), stroke(&s, c)), (a_stroke, c_stroke));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_fill_or_stroke_added_or_removed_reaches_the_members_that_keep_their_others() {
|
||||
let mut s = session();
|
||||
let [a, b, c, ..] = squares(&mut s);
|
||||
sel(&mut s, &[b]);
|
||||
s.execute("paint.setFill", &json!({"color": "#0000ff"})).unwrap();
|
||||
// The small square has two fills: not the model's fills and strokes in order.
|
||||
sel(&mut s, &[c]);
|
||||
s.execute("appearance.addFill", &json!({})).unwrap();
|
||||
start(&mut s, a, json!({}));
|
||||
let kinds = |s: &Session, id| node(s, id).appearance.items.iter().map(|i| i.kind_name()).collect::<Vec<_>>();
|
||||
s.execute("appearance.addStroke", &json!({})).unwrap();
|
||||
assert_eq!(kinds(&s, a), ["fill", "stroke", "stroke"]);
|
||||
assert_eq!(kinds(&s, b), kinds(&s, a), "the copy gains the stroke");
|
||||
assert_eq!(fill(&s, b), "#0000ff", "and keeps its own fill");
|
||||
assert_eq!(kinds(&s, c), kinds(&s, a), "a member with other fills and strokes takes the model's list");
|
||||
s.execute("appearance.removeItem", &json!({"index": 0})).unwrap();
|
||||
assert_eq!(kinds(&s, a), ["stroke", "stroke"]);
|
||||
assert_eq!(kinds(&s, b), kinds(&s, a), "the copy loses its fill");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn undo_and_redo_drop_the_members_the_document_lacks() {
|
||||
let mut s = session();
|
||||
let a = rect(&mut s, 0.0, 0.0, 100.0, 100.0);
|
||||
let b = rect(&mut s, 300.0, 0.0, 100.0, 100.0);
|
||||
let c = rect(&mut s, 500.0, 0.0, 20.0, 20.0);
|
||||
assert_eq!(start(&mut s, a, json!({})), [a, b, c]);
|
||||
// Undoing the making of the small square takes it out of the Global Edit, so a square made
|
||||
// under its freed id is not edited with the others.
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(members(&s), Some(vec![a, b]));
|
||||
let d = rect(&mut s, 500.0, 0.0, 20.0, 20.0);
|
||||
assert_eq!(d, c, "the id is used again");
|
||||
let d_was = node(&s, d);
|
||||
sel(&mut s, &[a]);
|
||||
s.execute("paint.setFill", &json!({"color": "#ff0000"})).unwrap();
|
||||
assert_eq!(fill(&s, b), "#ff0000", "Global Edit goes on with the members left");
|
||||
assert_eq!(node(&s, d), d_was);
|
||||
// Redoing the deletion of a member takes it out too; with one left, Global Edit ends.
|
||||
sel(&mut s, &[b]);
|
||||
s.execute("edit.clear", &json!({})).unwrap();
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(members(&s), Some(vec![a, b]), "every member is there again");
|
||||
s.execute("edit.redo", &json!({})).unwrap();
|
||||
assert_eq!(members(&s), None);
|
||||
}
|
||||
|
||||
/// Two 100 pt squares and a 20 pt one made after `begin`, all three in a Global Edit; `put_back`
|
||||
/// takes the document back to before the small one. The small square leaves the Global Edit, and
|
||||
/// a square made under its freed id is not edited with the others.
|
||||
fn put_back_drops_the_member<T>(begin: impl FnOnce(&mut Session) -> T, put_back: impl FnOnce(&mut Session, T)) {
|
||||
let mut s = session();
|
||||
let a = rect(&mut s, 0.0, 0.0, 100.0, 100.0);
|
||||
let b = rect(&mut s, 300.0, 0.0, 100.0, 100.0);
|
||||
let t = begin(&mut s);
|
||||
let c = rect(&mut s, 500.0, 0.0, 20.0, 20.0);
|
||||
assert_eq!(start(&mut s, a, json!({})), [a, b, c]);
|
||||
put_back(&mut s, t);
|
||||
assert_eq!(members(&s), Some(vec![a, b]));
|
||||
let d = rect(&mut s, 500.0, 0.0, 20.0, 20.0);
|
||||
assert_eq!(d, c, "the id is used again");
|
||||
let d_was = node(&s, d);
|
||||
sel(&mut s, &[a]);
|
||||
s.execute("paint.setFill", &json!({"color": "#ff0000"})).unwrap();
|
||||
assert_eq!(fill(&s, b), "#ff0000", "Global Edit goes on with the members left");
|
||||
assert_eq!(node(&s, d), d_was);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_cancelled_drag_scrub_or_undo_scope_drops_the_members_it_takes_away() {
|
||||
put_back_drops_the_member(|s| s.begin_interaction("Draw").unwrap(), |s, ()| s.cancel_interaction().unwrap());
|
||||
put_back_drops_the_member(Session::begin_undo_group, |s, ()| s.end_undo_group(true));
|
||||
put_back_drops_the_member(
|
||||
|s| {
|
||||
s.begin_undo_group();
|
||||
s.begin_undo_scope().unwrap()
|
||||
},
|
||||
|s, scope| {
|
||||
s.end_undo_scope(scope, true);
|
||||
s.end_undo_group(false);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_edit_that_takes_a_member_past_the_canvas_is_refused_whole() {
|
||||
let mut s = session();
|
||||
let a = rect(&mut s, 0.0, 0.0, 2.0, 2.0);
|
||||
let big = rect(&mut s, 0.0, 0.0, 2000.0, 2000.0);
|
||||
assert_eq!(start(&mut s, a, json!({})), [a, big]);
|
||||
let before = (node(&s, a), node(&s, big));
|
||||
let steps = s.doc().unwrap().history.undo.len();
|
||||
// 4000 pt keeps the small square in range; the square 1000 times its size moves 1000 times as
|
||||
// far, past `MAX_COORD`.
|
||||
let e = s.execute("object.move", &json!({"dx": 4000, "dy": 0})).unwrap_err().to_string();
|
||||
assert!(e.contains("out of range"), "{e}");
|
||||
assert_eq!((node(&s, a), node(&s, big)), before, "nothing moved");
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), steps);
|
||||
s.execute("object.move", &json!({"dx": 3000, "dy": 0})).unwrap();
|
||||
assert!(close(bounds(&s, big), Rect::new(3.0e6, 0.0, 3.0e6 + 2000.0, 2000.0)), "{:?}", bounds(&s, big));
|
||||
}
|
||||
@@ -489,6 +489,10 @@ impl Session {
|
||||
let place = w.show.then_some(WidgetPlace { screen: view.screen.as_ref(), corner: w.position });
|
||||
v.splice(0..0, vectorcraft_tools::distort::perspective::grid_overlays_in(&d.doc, 1.0 / view.zoom.max(1e-9), place));
|
||||
}
|
||||
// Global Edit: the objects edited with the selected one are highlighted.
|
||||
for (path, color) in crate::cmd::globaledit::highlights(self) {
|
||||
v.push(Overlay::Path { path, color, width: 1.0, dashed: false });
|
||||
}
|
||||
if let Some((cmd, p)) = deferred_preview {
|
||||
v.extend(self.deferred_preview_overlays_from_command(&cmd, &p));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user