Part of #1429: Edit and Object commands on many objects take time linear in the document
Delete, Cut, Group, Make Compound Path, Make Clipping Mask, Send to Current Layer, Ungroup and Release Compound Path removed, inserted or moved one object at a time. duplicate_in inserted one copy at a time; Duplicate, Copy in the Move, Rotate, Scale, Reflect and Shear dialogs, Transform Again after a copy, Transform Each with Copy, the perspective copies and each move of an Alt-drag call it. Each insertion or removal moved the later objects of the same layer or group to other indices. The next lookup by id then walked the tree, and after every fourth such walk Document::index_path rebuilt the whole lookup cache. Each command did work that grew with the number of objects times the size of the document: with 25,000 rectangles selected, a release build of vectorcraft-cli took 7.2 s to group them and 11.5 s to copy them with object.transform. Document::remove_many, Document::wrap and Document::insert_beside remove nodes, wrap them in a new node, or put nodes directly above or in place of their anchors, in one pass over the children of each parent. Each finds the nodes with one index_paths call, checks every id before it changes anything, and clears the lookup cache once. The commands above use them. The objects keep their order, the copies take their ids in the same order, and the selection, the undo step and the journal are the same. duplicate_in still copies one root at a time when a root is listed twice or lies inside another root. A release build with this change groups the 25,000 rectangles in 27 ms and copies them in 121 ms. Docs: docs/target-app-parity.md.
This commit is contained in:
parent
25c33b6927
commit
ffa948dcc3
|
|
@ -693,6 +693,10 @@ fn yes() -> bool {
|
|||
true
|
||||
}
|
||||
|
||||
/// Listed nodes by parent: the parent's index path (empty for the top-level layers), then the index
|
||||
/// of each listed child in it, in order, with the child's place in the list.
|
||||
type Places = BTreeMap<Vec<usize>, Vec<(usize, usize)>>;
|
||||
|
||||
pub const FORMAT_VERSION: u32 = 1;
|
||||
|
||||
impl Document {
|
||||
|
|
@ -1021,6 +1025,128 @@ impl Document {
|
|||
Some((parent, idx, n))
|
||||
}
|
||||
|
||||
/// Remove the nodes `ids` names and return them in the order of `ids`, in one pass over the
|
||||
/// children of each parent. Each id is listed once and none lies inside another listed node, as
|
||||
/// in a list of top-level objects. For an id that is not in the tree, is listed twice or lies
|
||||
/// inside another listed node, it fails with `NoNode` and leaves the document as it was.
|
||||
pub fn remove_many(&mut self, ids: &[NodeId]) -> Result<Vec<Arc<Node>>, DocError> {
|
||||
let places = self.places(ids)?;
|
||||
let removed = self.take(places, ids.len())?;
|
||||
self.index.clear();
|
||||
Ok(removed)
|
||||
}
|
||||
|
||||
/// Remove the nodes `ids` names, as [`Document::remove_many`] does, and put the node that `make`
|
||||
/// builds from them (in the order of `ids`) in the parent of the last of them, at that node's
|
||||
/// index less the number of listed nodes below it there. Returns the new node's id.
|
||||
pub fn wrap(&mut self, ids: &[NodeId], make: impl FnOnce(Vec<Arc<Node>>) -> Node) -> Result<NodeId, DocError> {
|
||||
let places = self.places(ids)?;
|
||||
let last = ids.len().checked_sub(1);
|
||||
let found = places.iter().find_map(|(up, kids)| kids.iter().find(|(_, k)| Some(*k) == last).map(|(i, _)| (up.clone(), *i)));
|
||||
let Some((up, i)) = found else { return Err(DocError::Invalid("nothing to wrap".into())) };
|
||||
// The parent and each node it is in move down by the listed nodes before them.
|
||||
let below = |parent: &[usize], index: usize| places.get(parent).map_or(0, |kids| kids.partition_point(|(j, _)| *j < index));
|
||||
let to: Vec<usize> = up.iter().enumerate().map(|(l, &j)| j.saturating_sub(below(&up[..l], j))).collect();
|
||||
let at = i.saturating_sub(below(&up, i));
|
||||
let node = make(self.take(places, ids.len())?);
|
||||
let id = node.id;
|
||||
let children = self.children_at(&to).ok_or_else(|| DocError::Invalid("the container of the listed objects is gone".into()))?;
|
||||
let at = at.min(children.len());
|
||||
children.insert(at, Arc::new(node));
|
||||
self.index.clear();
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
/// Put each list of nodes in `items` directly above its anchor (`replace`: in its place), in
|
||||
/// list order, in one pass over the children of each parent. Each anchor is listed once and none
|
||||
/// lies inside another. For an anchor that is not in the tree, is listed twice or lies inside
|
||||
/// another listed anchor, it fails with `NoNode` and leaves the document as it was.
|
||||
pub fn insert_beside(&mut self, items: Vec<(NodeId, Vec<Node>)>, replace: bool) -> Result<(), DocError> {
|
||||
let anchors: Vec<NodeId> = items.iter().map(|(id, _)| *id).collect();
|
||||
let places = self.places(&anchors)?;
|
||||
let mut lists: Vec<Vec<Node>> = items.into_iter().map(|(_, nodes)| nodes).collect();
|
||||
self.edit_children(places, |k, anchor, children| {
|
||||
if !replace {
|
||||
children.push(anchor);
|
||||
}
|
||||
let nodes = lists.get_mut(k).map(std::mem::take).unwrap_or_default();
|
||||
children.extend(nodes.into_iter().map(Arc::new));
|
||||
})?;
|
||||
self.index.clear();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Where each of `ids` is ([`Places`]). Fails with `NoNode` for an id that is not in the tree,
|
||||
/// is listed twice or lies inside another listed node.
|
||||
fn places(&self, ids: &[NodeId]) -> Result<Places, DocError> {
|
||||
let paths = self.index_paths(ids.iter().copied());
|
||||
let mut places = Places::new();
|
||||
for (k, id) in ids.iter().enumerate() {
|
||||
let (&i, up) = paths.get(id).and_then(|p| p.split_last()).ok_or(DocError::NoNode(*id))?;
|
||||
match places.get_mut(up) {
|
||||
Some(kids) => kids.push((i, k)),
|
||||
None => {
|
||||
places.insert(up.to_vec(), vec![(i, k)]);
|
||||
}
|
||||
}
|
||||
}
|
||||
for kids in places.values_mut() {
|
||||
kids.sort_unstable();
|
||||
}
|
||||
// An id listed twice has one path, so its parent lists the same child twice. A listed node
|
||||
// lies inside another when its parent's path, or a part of it from the top, is the path of a
|
||||
// listed node.
|
||||
let twice = places.values().flat_map(|kids| kids.windows(2)).find(|w| w[0].0 == w[1].0).map(|w| w[1].1);
|
||||
let listed = |path: &[usize]| {
|
||||
path.split_last().is_some_and(|(i, up)| places.get(up).is_some_and(|kids| kids.binary_search_by_key(i, |(j, _)| *j).is_ok()))
|
||||
};
|
||||
let nested = places.iter().find(|(up, _)| (1..=up.len()).any(|l| listed(&up[..l])));
|
||||
let inner = nested.and_then(|(_, kids)| kids.first()).map(|(_, k)| *k);
|
||||
if let Some(id) = twice.or(inner).and_then(|k| ids.get(k)) {
|
||||
return Err(DocError::NoNode(*id));
|
||||
}
|
||||
Ok(places)
|
||||
}
|
||||
|
||||
/// Take the nodes `places` lists out of their parents and return them in the order of the list,
|
||||
/// which is `len` long.
|
||||
fn take(&mut self, places: Places, len: usize) -> Result<Vec<Arc<Node>>, DocError> {
|
||||
let mut taken = vec![None; len];
|
||||
self.edit_children(places, |k, node, _| {
|
||||
if let Some(t) = taken.get_mut(k) {
|
||||
*t = Some(node);
|
||||
}
|
||||
})?;
|
||||
Ok(taken.into_iter().flatten().collect())
|
||||
}
|
||||
|
||||
/// Rebuild the children of each parent in `places` once, the parents later in tree order
|
||||
/// first. `each` gets each listed child, its place in the list and the parent's new children,
|
||||
/// onto which it pushes what takes the child's place.
|
||||
fn edit_children(&mut self, places: Places, mut each: impl FnMut(usize, Arc<Node>, &mut Vec<Arc<Node>>)) -> Result<(), DocError> {
|
||||
for (up, kids) in places.into_iter().rev() {
|
||||
let children = self.children_at(&up).ok_or_else(|| DocError::Invalid("a container of the listed objects is gone".into()))?;
|
||||
let old = std::mem::take(children);
|
||||
children.reserve(old.len());
|
||||
let mut kids = kids.into_iter().peekable();
|
||||
for (i, node) in old.into_iter().enumerate() {
|
||||
match kids.next_if(|(j, _)| *j == i) {
|
||||
Some((_, k)) => each(k, node, children),
|
||||
None => children.push(node),
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The children of the node at index path `path`, or the top-level layers for an empty path.
|
||||
fn children_at(&mut self, path: &[usize]) -> Option<&mut Vec<Arc<Node>>> {
|
||||
match path {
|
||||
[] => Some(&mut self.layers),
|
||||
_ => self.node_mut_at(path)?.children_mut(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Default target layer: the topmost visible, unlocked layer.
|
||||
pub fn default_layer(&self) -> Option<NodeId> {
|
||||
self.layers.iter().rev().find(|l| l.visible && !l.locked).or(self.layers.last()).map(|l| l.id)
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
//! The batched lookups ([`Document::index_paths`], `node_at`, `node_mut_at`, `ancestry_at`) agree
|
||||
//! with the one-id ones on nested trees with missing and duplicated ids.
|
||||
//! with the one-id ones on nested trees with missing and duplicated ids, and the edits of many nodes
|
||||
//! ([`Document::remove_many`], `wrap`, `insert_beside`) leave the tree that `remove`, `insert` and
|
||||
//! `move_node` leave one node at a time.
|
||||
use super::*;
|
||||
use vectorcraft_geom::shapes;
|
||||
|
||||
|
|
@ -154,3 +156,129 @@ fn cached_lookups_follow_any_edit_of_the_tree() {
|
|||
agree(&snapshot, &ids, "the earlier clone");
|
||||
}
|
||||
}
|
||||
|
||||
/// Nodes of `d` picked and ordered by `seed`. None lies inside another, and each of them, and each
|
||||
/// node it is in, appears in the tree once.
|
||||
fn picks(d: &Document, seed: u64) -> Vec<NodeId> {
|
||||
let mut count = std::collections::HashMap::<NodeId, usize>::new();
|
||||
d.walk(|n| *count.entry(n.id).or_default() += 1);
|
||||
let once = |id: &NodeId| count.get(id) == Some(&1);
|
||||
let mut found: Vec<(Vec<usize>, NodeId)> = count
|
||||
.keys()
|
||||
.filter(|id| !(id.0 + seed).is_multiple_of(3))
|
||||
.filter_map(|id| d.index_path(*id).map(|p| (p, *id)))
|
||||
.filter(|(p, _)| d.ancestry_at(p).is_some_and(|a| a.iter().all(once)))
|
||||
.collect();
|
||||
found.sort();
|
||||
let mut kept: Vec<(Vec<usize>, NodeId)> = vec![];
|
||||
for (p, id) in found {
|
||||
if !kept.iter().any(|(q, _)| p.starts_with(q)) {
|
||||
kept.push((p, id));
|
||||
}
|
||||
}
|
||||
let mut out: Vec<NodeId> = kept.into_iter().map(|(_, id)| id).collect();
|
||||
out.sort_by_key(|id| id.0.wrapping_mul(0x9e37_79b9_7f4a_7c15) ^ seed);
|
||||
out
|
||||
}
|
||||
|
||||
/// `a` and `b` hold the same tree, and their lookups agree with each other and with a walk.
|
||||
fn same(a: &Document, b: &Document, ids: &[NodeId], what: &str) {
|
||||
assert_eq!(serde_json::to_string(a).ok(), serde_json::to_string(b).ok(), "{what}");
|
||||
for id in ids {
|
||||
assert_eq!(a.index_path(*id), b.index_path(*id), "{what}: {id:?}");
|
||||
}
|
||||
agree(b, ids, what);
|
||||
}
|
||||
|
||||
/// A 1 pt square with a new id.
|
||||
fn square(d: &mut Document) -> Node {
|
||||
Node::path(d.alloc_id(), shapes::rectangle(Rect::new(0.0, 0.0, 1.0, 1.0)), Appearance::default_art())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remove_many_matches_removing_one_at_a_time() {
|
||||
for seed in 1..40 {
|
||||
let (d, ids) = tree(seed);
|
||||
let picked = picks(&d, seed);
|
||||
let (mut a, mut b) = (d.clone(), d.clone());
|
||||
let one: Vec<Arc<Node>> = picked.iter().map(|id| a.remove(*id).unwrap()).collect();
|
||||
let many = b.remove_many(&picked).unwrap();
|
||||
assert_eq!(many.len(), one.len(), "seed {seed}");
|
||||
assert!(one.iter().zip(&many).all(|(x, y)| Arc::ptr_eq(x, y)), "seed {seed}: the same nodes, in the order of the list");
|
||||
same(&a, &b, &ids, &format!("seed {seed}"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn insert_beside_matches_inserting_one_at_a_time() {
|
||||
for seed in 1..40 {
|
||||
for replace in [false, true] {
|
||||
let (mut d, mut ids) = tree(seed);
|
||||
let picked = picks(&d, seed);
|
||||
// None to three new nodes for each anchor.
|
||||
let items: Vec<(NodeId, Vec<Node>)> =
|
||||
picked.iter().enumerate().map(|(k, id)| (*id, (0..k % 4).map(|_| square(&mut d)).collect())).collect();
|
||||
ids.extend(items.iter().flat_map(|(_, new)| new.iter().map(|n| n.id)));
|
||||
let (mut a, mut b) = (d.clone(), d.clone());
|
||||
for (id, new) in items.clone() {
|
||||
let (parent, at, _) = a.position(id).unwrap();
|
||||
let at = if replace {
|
||||
a.remove(id).unwrap();
|
||||
at
|
||||
} else {
|
||||
at + 1
|
||||
};
|
||||
for (k, n) in new.into_iter().enumerate() {
|
||||
a.insert(parent, at + k, n).unwrap();
|
||||
}
|
||||
}
|
||||
b.insert_beside(items, replace).unwrap();
|
||||
same(&a, &b, &ids, &format!("seed {seed}, replace {replace}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wrap_matches_inserting_a_group_and_moving_the_nodes_into_it_one_at_a_time() {
|
||||
for seed in 1..40 {
|
||||
let (mut d, mut ids) = tree(seed);
|
||||
let picked = picks(&d, seed);
|
||||
let Some(&last) = picked.last() else { continue };
|
||||
let group = d.alloc_id();
|
||||
ids.push(group);
|
||||
let (mut a, mut b) = (d.clone(), d.clone());
|
||||
let (parent, at, _) = a.position(last).unwrap();
|
||||
a.insert(parent, at + 1, Node::group(group, vec![])).unwrap();
|
||||
for id in &picked {
|
||||
a.move_node(*id, Some(group), usize::MAX).unwrap();
|
||||
}
|
||||
assert_eq!(b.wrap(&picked, |nodes| Node::group(group, nodes)), Ok(group), "seed {seed}");
|
||||
same(&a, &b, &ids, &format!("seed {seed}"));
|
||||
}
|
||||
}
|
||||
|
||||
/// An id not in the tree, listed twice, or inside another listed node fails with `NoNode`, and the
|
||||
/// document stays as it was.
|
||||
#[test]
|
||||
fn edits_of_many_refuse_missing_repeated_and_nested_ids() {
|
||||
let mut d = Document::new(612.0, 792.0);
|
||||
let layer = d.layers[0].id;
|
||||
let (x, y, z) = (square(&mut d), square(&mut d), square(&mut d));
|
||||
let (inner, other) = (y.id, z.id);
|
||||
let group = d.alloc_id();
|
||||
d.insert(Some(layer), 0, Node::group(group, vec![Arc::new(x), Arc::new(y)])).unwrap();
|
||||
d.insert(Some(layer), 1, z).unwrap();
|
||||
let saved = serde_json::to_string(&d).ok();
|
||||
let missing = NodeId(999_999);
|
||||
for (list, bad) in [(vec![other, missing], missing), (vec![other, other], other), (vec![group, inner], inner), (vec![inner, group], inner)] {
|
||||
let what = format!("{list:?}");
|
||||
let mut b = d.clone();
|
||||
assert_eq!(b.remove_many(&list).err(), Some(DocError::NoNode(bad)), "{what}");
|
||||
assert_eq!(b.wrap(&list, |nodes| Node::group(NodeId(1_000_000), nodes)).err(), Some(DocError::NoNode(bad)), "{what}");
|
||||
for replace in [false, true] {
|
||||
let items = list.iter().map(|id| (*id, vec![])).collect();
|
||||
assert_eq!(b.insert_beside(items, replace).err(), Some(DocError::NoNode(bad)), "{what}");
|
||||
}
|
||||
assert_eq!(serde_json::to_string(&b).ok(), saved, "{what}: the document stays as it was");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -384,9 +384,7 @@ fn clear(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
return ok();
|
||||
}
|
||||
s.edit("Clear", |d, sel| {
|
||||
for id in &ids {
|
||||
d.remove(*id)?;
|
||||
}
|
||||
d.remove_many(&ids)?;
|
||||
if with_guides {
|
||||
let gone = sel.guides.clone();
|
||||
d.retain_guides(sel, |i, _| !gone.contains(&i));
|
||||
|
|
@ -560,16 +558,35 @@ fn duplicate(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
Ok(json!({ "ids": ids.iter().map(|i| i.0).collect::<Vec<_>>() }))
|
||||
}
|
||||
|
||||
/// Duplicate `roots` directly above each original, transform the copies, select them.
|
||||
/// Duplicate `roots` directly above each original, transform the copies, select them. The copies
|
||||
/// take new ids in the order of `roots`. Roots listed twice, or inside another root, are copied one
|
||||
/// at a time: each copy is made from the document as the copies before it left it.
|
||||
pub(crate) fn duplicate_in(d: &mut Document, sel: &mut vectorcraft_doc::Selection, roots: &[NodeId], xf: Affine) -> Result<Vec<NodeId>> {
|
||||
let mut out = vec![];
|
||||
for id in roots {
|
||||
let Some((par, idx, _)) = d.position(*id) else { continue };
|
||||
let Some(n) = d.node(*id).cloned() else { continue };
|
||||
let mut c = d.reid(&n);
|
||||
c.transform(xf, true);
|
||||
out.push(d.insert(par, idx + 1, c)?);
|
||||
}
|
||||
let paths = d.index_paths(roots.iter().copied());
|
||||
let mut found: Vec<_> = roots.iter().filter_map(|id| paths.get(id)).collect();
|
||||
found.sort_unstable();
|
||||
let out = if found.windows(2).all(|w| !w[1].starts_with(w[0])) {
|
||||
let mut copies = Vec::with_capacity(roots.len());
|
||||
for id in roots {
|
||||
let Some(n) = paths.get(id).and_then(|p| d.node_at(p)).cloned() else { continue };
|
||||
let mut c = d.reid(&n);
|
||||
c.transform(xf, true);
|
||||
copies.push((*id, vec![c]));
|
||||
}
|
||||
let out: Vec<NodeId> = copies.iter().flat_map(|(_, c)| c.iter().map(|n| n.id)).collect();
|
||||
d.insert_beside(copies, false)?;
|
||||
out
|
||||
} else {
|
||||
let mut out = vec![];
|
||||
for id in roots {
|
||||
let Some((par, idx, _)) = d.position(*id) else { continue };
|
||||
let Some(n) = d.node(*id).cloned() else { continue };
|
||||
let mut c = d.reid(&n);
|
||||
c.transform(xf, true);
|
||||
out.push(d.insert(par, idx + 1, c)?);
|
||||
}
|
||||
out
|
||||
};
|
||||
sel.set(out.iter().copied());
|
||||
Ok(out)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -505,9 +505,8 @@ fn send_to_current_layer(s: &mut Session, _: &Value) -> Result<Value> {
|
|||
return ok();
|
||||
}
|
||||
s.edit("Send to Current Layer", |d, _| {
|
||||
for id in &ids {
|
||||
d.move_node(*id, Some(layer), usize::MAX)?;
|
||||
}
|
||||
let moved = d.remove_many(&ids)?;
|
||||
d.children_mut(Some(layer))?.extend(moved);
|
||||
Ok(())
|
||||
})?;
|
||||
ok()
|
||||
|
|
@ -525,14 +524,11 @@ fn group(s: &mut Session, _: &Value) -> Result<Value> {
|
|||
|
||||
/// Put `ids` (top-level objects, in paint order) in a new group where the front-most one was.
|
||||
pub(crate) fn group_nodes(d: &mut Document, ids: &[NodeId]) -> Result<NodeId> {
|
||||
let Some(top) = ids.last().copied() else { return Err(EngineError::Other("nothing selected".into())) };
|
||||
let (par, idx, _) = d.position(top).ok_or(EngineError::NoNode(top))?;
|
||||
let gid = d.alloc_id();
|
||||
d.insert(par, idx + 1, Node::group(gid, vec![]))?;
|
||||
for id in ids {
|
||||
d.move_node(*id, Some(gid), usize::MAX)?;
|
||||
if ids.is_empty() {
|
||||
return Err(EngineError::Other("nothing selected".into()));
|
||||
}
|
||||
Ok(gid)
|
||||
let gid = d.alloc_id();
|
||||
Ok(d.wrap(ids, |children| Node::group(gid, children))?)
|
||||
}
|
||||
|
||||
fn ungroup(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
|
|
@ -544,31 +540,30 @@ fn ungroup(s: &mut Session, _: &Value) -> Result<Value> {
|
|||
}
|
||||
s.edit("Ungroup", |d, sel| {
|
||||
let mut new_sel = vec![];
|
||||
let mut ungrouped = vec![];
|
||||
for id in &ids {
|
||||
let Some(n) = d.node(*id) else { continue };
|
||||
let NodeKind::Group { children, clip } = &n.kind else {
|
||||
new_sel.push(*id);
|
||||
continue;
|
||||
};
|
||||
let children = children.clone();
|
||||
let clip = *clip;
|
||||
let (par, idx, _) = d.position(*id).ok_or(EngineError::NoNode(*id))?;
|
||||
let opacity = n.opacity;
|
||||
d.remove(*id)?;
|
||||
for (k, c) in children.into_iter().enumerate() {
|
||||
let mut c = (*c).clone();
|
||||
let mut out = Vec::with_capacity(children.len());
|
||||
for (k, c) in children.iter().enumerate() {
|
||||
let mut c = (**c).clone();
|
||||
// The clipping path keeps its paint (none, unless painted after Make).
|
||||
if clip
|
||||
if *clip
|
||||
&& k == 0
|
||||
&& let NodeKind::Path { clipping, .. } = &mut c.kind
|
||||
{
|
||||
*clipping = false;
|
||||
}
|
||||
c.opacity *= opacity;
|
||||
c.opacity *= n.opacity;
|
||||
new_sel.push(c.id);
|
||||
d.insert(par, idx + k, c)?;
|
||||
out.push(c);
|
||||
}
|
||||
ungrouped.push((*id, out));
|
||||
}
|
||||
d.insert_beside(ungrouped, true)?;
|
||||
sel.set(new_sel);
|
||||
Ok(())
|
||||
})?;
|
||||
|
|
@ -666,10 +661,8 @@ fn show_all(s: &mut Session, _: &Value) -> Result<Value> {
|
|||
fn compound_make(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
let ids = selected_roots(s)?;
|
||||
let Some(backmost) = ids.first().copied() else { return Err(EngineError::Other("nothing selected".into())) };
|
||||
let top = ids.last().copied().unwrap_or(backmost);
|
||||
let id = s.edit("Make Compound Path", |d, sel| {
|
||||
let appearance = d.node(backmost).ok_or(EngineError::NoNode(backmost))?.appearance.clone();
|
||||
let (par, idx, _) = d.position(top).ok_or(EngineError::NoNode(top))?;
|
||||
let mut paths: Vec<Arc<Node>> = vec![];
|
||||
for id in &ids {
|
||||
let n = d.node(*id).cloned().ok_or(EngineError::NoNode(*id))?;
|
||||
|
|
@ -689,11 +682,7 @@ fn compound_make(s: &mut Session, _: &Value) -> Result<Value> {
|
|||
let cid = d.alloc_id();
|
||||
let mut c = Node::new(cid, NodeKind::Compound { children: vec![], rule: FillRule::NonZero });
|
||||
c.appearance = appearance;
|
||||
d.insert(par, idx + 1, c)?;
|
||||
for id in &ids {
|
||||
d.remove(*id)?;
|
||||
}
|
||||
let ch = d.node_mut(cid).and_then(|n| n.children_mut()).ok_or(EngineError::NoNode(cid))?;
|
||||
let ch = c.children_mut().ok_or(EngineError::NoNode(cid))?;
|
||||
// The paths in front run against the backmost one, so where they overlap it they cut holes
|
||||
// under the non-zero fill rule (#1197).
|
||||
let direction = |n: &Node| n.path_data().and_then(|pd| pd.subpaths.first()).map_or(0.0, |sp| sp.signed_area().signum());
|
||||
|
|
@ -710,6 +699,7 @@ fn compound_make(s: &mut Session, _: &Value) -> Result<Value> {
|
|||
}
|
||||
ch.push(p);
|
||||
}
|
||||
d.wrap(&ids, |_| c)?;
|
||||
sel.set([cid]);
|
||||
Ok(cid)
|
||||
})?;
|
||||
|
|
@ -736,17 +726,17 @@ fn compound_release(s: &mut Session, _: &Value) -> Result<Value> {
|
|||
}
|
||||
s.edit("Release Compound Path", |d, sel| {
|
||||
let mut out = vec![];
|
||||
let mut released = vec![];
|
||||
for id in &ids {
|
||||
let Some(n) = d.node(*id).cloned().filter(releases) else { continue };
|
||||
let (par, idx, _) = d.position(*id).ok_or(EngineError::NoNode(*id))?;
|
||||
d.remove(*id)?;
|
||||
let mut parts = vec![];
|
||||
match &n.kind {
|
||||
NodeKind::Compound { children, .. } => {
|
||||
for (k, c) in children.iter().enumerate() {
|
||||
for c in children {
|
||||
let mut c = (**c).clone();
|
||||
c.appearance = n.appearance.clone();
|
||||
out.push(c.id);
|
||||
d.insert(par, idx + k, c)?;
|
||||
parts.push(c);
|
||||
}
|
||||
}
|
||||
// Each subpath becomes a path of its own with the path's paint, in the same
|
||||
|
|
@ -758,12 +748,14 @@ fn compound_release(s: &mut Session, _: &Value) -> Result<Value> {
|
|||
(*path, *live) = (PathData::single(sp.clone()), None);
|
||||
}
|
||||
out.push(c.id);
|
||||
d.insert(par, idx + k, c)?;
|
||||
parts.push(c);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
released.push((*id, parts));
|
||||
}
|
||||
d.insert_beside(released, true)?;
|
||||
sel.set(out);
|
||||
Ok(())
|
||||
})?;
|
||||
|
|
@ -811,13 +803,12 @@ fn clip_make(s: &mut Session, _: &Value) -> Result<Value> {
|
|||
let Some(&top) = ids.last() else { return Err(EngineError::Other("nothing selected".into())) };
|
||||
let gid = s.edit("Make Clipping Mask", |d, sel| {
|
||||
make_clipping_path(d, top)?;
|
||||
let (par, idx, _) = d.position(top).ok_or(EngineError::NoNode(top))?;
|
||||
let gid = d.alloc_id();
|
||||
d.insert(par, idx + 1, Node::new(gid, NodeKind::Group { children: vec![], clip: true }))?;
|
||||
d.move_node(top, Some(gid), 0)?;
|
||||
for id in &ids[..ids.len() - 1] {
|
||||
d.move_node(*id, Some(gid), usize::MAX)?;
|
||||
}
|
||||
// The topmost object, the clipping path, goes first, and the others follow in paint order.
|
||||
d.wrap(&ids, |mut children| {
|
||||
children.rotate_right(1);
|
||||
Node::new(gid, NodeKind::Group { children, clip: true })
|
||||
})?;
|
||||
sel.set([gid]);
|
||||
Ok(gid)
|
||||
})?;
|
||||
|
|
|
|||
|
|
@ -1740,6 +1740,8 @@ mod tests_knockout;
|
|||
#[cfg(test)]
|
||||
mod tests_labspots;
|
||||
#[cfg(test)]
|
||||
mod tests_large_edits;
|
||||
#[cfg(test)]
|
||||
mod tests_layerclip;
|
||||
#[cfg(test)]
|
||||
mod tests_layers;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,361 @@
|
|||
//! Commands that remove, insert or copy many objects, and an Alt-drag that copies them, each finish
|
||||
//! within 20 s on one layer of 25,000 to 100,000 objects, and objects in two layers and inside a
|
||||
//! group keep their order and ids.
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::{Appearance, Document, Node, NodeId, NodeKind};
|
||||
use vectorcraft_geom::{FillRule, Rect, shapes};
|
||||
use vectorcraft_tools::{Mods, PointerEvent, PointerKind};
|
||||
|
||||
use super::*;
|
||||
|
||||
/// Squares in a row of [`square_at`].
|
||||
const ROW: usize = 200;
|
||||
|
||||
/// Square `k`: 10 pt wide, in rows of 200 squares 20 pt apart.
|
||||
fn square_at(k: usize) -> Rect {
|
||||
let (x, y) = ((k % ROW) as f64 * 20.0, (k / ROW) as f64 * 20.0);
|
||||
Rect::new(x, y, x + 10.0, y + 10.0)
|
||||
}
|
||||
|
||||
/// Square `k` ([`square_at`]) as a path with a new id.
|
||||
fn square(d: &mut Document, k: usize) -> Node {
|
||||
Node::path(d.alloc_id(), shapes::rectangle(square_at(k)), Appearance::default_art())
|
||||
}
|
||||
|
||||
/// A session whose document holds `make(d, k)` for each `k` below `n` on its one layer, all of it
|
||||
/// selected.
|
||||
fn session(n: usize, mut make: impl FnMut(&mut Document, usize) -> Node) -> Session {
|
||||
let mut d = Document::new(ROW as f64 * 20.0, (n / ROW + 1) as f64 * 20.0);
|
||||
let art: Vec<Arc<Node>> = (0..n).map(|k| Arc::new(make(&mut d, k))).collect();
|
||||
if let Some(ch) = Arc::make_mut(&mut d.layers[0]).children_mut() {
|
||||
*ch = art;
|
||||
}
|
||||
let mut s = Session::new();
|
||||
s.add_document(d, None);
|
||||
s.execute("select.all", &json!({})).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
/// The ids of `n`'s children.
|
||||
fn ids_of(n: &Node) -> Vec<NodeId> {
|
||||
n.children().into_iter().flatten().map(|c| c.id).collect()
|
||||
}
|
||||
|
||||
/// The ids of the objects on layer `i`.
|
||||
fn layer(s: &Session, i: usize) -> Vec<NodeId> {
|
||||
ids_of(&s.doc().unwrap().doc.layers[i])
|
||||
}
|
||||
|
||||
/// The ids of the children of `id`.
|
||||
fn children(s: &Session, id: NodeId) -> Vec<NodeId> {
|
||||
ids_of(s.doc().unwrap().doc.node(id).unwrap())
|
||||
}
|
||||
|
||||
/// `n` ids counting up from `first`.
|
||||
fn ids_from(first: u64, n: usize) -> Vec<NodeId> {
|
||||
(first..).take(n).map(NodeId).collect()
|
||||
}
|
||||
|
||||
/// The ids of `a` and `b` in turn: each id of `a` followed by the id of `b` at the same place.
|
||||
fn in_turn(a: &[NodeId], b: &[NodeId]) -> Vec<NodeId> {
|
||||
a.iter().zip(b).flat_map(|(a, b)| [*a, *b]).collect()
|
||||
}
|
||||
|
||||
/// Runs `f` on `s` on a thread of its own and fails the test when it takes more than 20 s.
|
||||
fn soon<T: Send + 'static>(mut s: Session, what: &'static str, f: impl FnOnce(&mut Session) -> T + Send + 'static) -> (Session, T) {
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
std::thread::spawn(move || {
|
||||
let r = f(&mut s);
|
||||
tx.send((s, r)).ok();
|
||||
});
|
||||
rx.recv_timeout(Duration::from_secs(20)).unwrap_or_else(|_| panic!("{what} did not finish within 20 s"))
|
||||
}
|
||||
|
||||
/// Runs command `cmd` with `params` through [`soon`] and returns its result.
|
||||
fn run_soon(s: Session, cmd: &'static str, params: Value) -> (Session, Value) {
|
||||
let (s, r) = soon(s, cmd, move |s| s.execute(cmd, ¶ms));
|
||||
(s, r.unwrap())
|
||||
}
|
||||
|
||||
/// The ids in the `ids` array of a command's result.
|
||||
fn ids_in(r: &Value) -> Vec<NodeId> {
|
||||
r["ids"].as_array().unwrap().iter().map(|v| NodeId(v.as_u64().unwrap())).collect()
|
||||
}
|
||||
|
||||
/// Runs Undo and checks that it gives back `before`.
|
||||
fn undo_gives_back(s: &mut Session, before: &Document) {
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert!(*s.doc().unwrap().doc == *before, "Undo gives back the document");
|
||||
}
|
||||
|
||||
/// Whether the selected objects are `ids`, in that order.
|
||||
fn selected(s: &Session, ids: &[NodeId]) -> bool {
|
||||
s.doc().unwrap().selection.objects == ids
|
||||
}
|
||||
|
||||
/// The geometric bounds of object `id`.
|
||||
fn bounds(s: &Session, id: NodeId) -> Rect {
|
||||
s.doc().unwrap().doc.node(id).unwrap().geometric_bounds().unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_of_100000_objects_finishes_within_20_s() {
|
||||
let s = session(100_000, square);
|
||||
let before = s.doc().unwrap().doc.clone();
|
||||
let (mut s, _) = run_soon(s, "edit.clear", json!({}));
|
||||
assert!(layer(&s, 0).is_empty());
|
||||
assert!(selected(&s, &[]));
|
||||
undo_gives_back(&mut s, &before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn group_of_50000_objects_finishes_within_20_s() {
|
||||
let s = session(50_000, square);
|
||||
let (before, ids) = (s.doc().unwrap().doc.clone(), layer(&s, 0));
|
||||
let (mut s, r) = run_soon(s, "object.group", json!({}));
|
||||
let g = NodeId(r["id"].as_u64().unwrap());
|
||||
assert_eq!(layer(&s, 0), [g]);
|
||||
assert!(children(&s, g) == ids, "the group holds the objects in their order");
|
||||
assert!(selected(&s, &[g]));
|
||||
undo_gives_back(&mut s, &before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clipping_mask_of_50000_objects_finishes_within_20_s() {
|
||||
let s = session(50_000, square);
|
||||
let (before, ids) = (s.doc().unwrap().doc.clone(), layer(&s, 0));
|
||||
let (mut s, r) = run_soon(s, "object.clippingMask.make", json!({}));
|
||||
let g = NodeId(r["id"].as_u64().unwrap());
|
||||
assert_eq!(layer(&s, 0), [g]);
|
||||
// The topmost object comes first, as the clipping path, and the others follow in their order.
|
||||
let mut want = ids.clone();
|
||||
want.rotate_right(1);
|
||||
assert!(children(&s, g) == want, "the clip group holds the objects in their order");
|
||||
let doc = &s.doc().unwrap().doc;
|
||||
assert!(matches!(doc.node(g).unwrap().kind, NodeKind::Group { clip: true, .. }));
|
||||
assert!(matches!(doc.node(want[0]).unwrap().kind, NodeKind::Path { clipping: true, .. }));
|
||||
assert!(selected(&s, &[g]));
|
||||
undo_gives_back(&mut s, &before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicate_of_50000_objects_finishes_within_20_s() {
|
||||
let s = session(50_000, square);
|
||||
let (before, ids, first) = (s.doc().unwrap().doc.clone(), layer(&s, 0), s.doc().unwrap().doc.peek_next_id());
|
||||
let (mut s, r) = run_soon(s, "edit.duplicate", json!({"dx": 5}));
|
||||
// Each copy goes directly above its object and takes the next id, in the objects' order.
|
||||
let copies = ids_from(first, ids.len());
|
||||
assert!(ids_in(&r) == copies);
|
||||
assert!(layer(&s, 0) == in_turn(&ids, &copies), "each copy directly above its object");
|
||||
assert!(selected(&s, &copies));
|
||||
assert_eq!(bounds(&s, copies[0]), square_at(0) + vectorcraft_geom::Vec2::new(5.0, 0.0));
|
||||
undo_gives_back(&mut s, &before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transform_with_copy_of_50000_objects_finishes_within_20_s() {
|
||||
let s = session(50_000, square);
|
||||
let (before, ids, first) = (s.doc().unwrap().doc.clone(), layer(&s, 0), s.doc().unwrap().doc.peek_next_id());
|
||||
let (mut s, r) = run_soon(s, "object.transform", json!({"matrix": [1, 0, 0, 1, 5, 0], "copy": true}));
|
||||
let copies = ids_from(first, ids.len());
|
||||
assert!(ids_in(&r) == copies);
|
||||
assert!(layer(&s, 0) == in_turn(&ids, &copies), "each copy directly above its object");
|
||||
assert!(selected(&s, &copies));
|
||||
let last = ids.len() - 1;
|
||||
assert_eq!(bounds(&s, copies[last]), square_at(last) + vectorcraft_geom::Vec2::new(5.0, 0.0));
|
||||
assert_eq!(bounds(&s, ids[last]), square_at(last));
|
||||
undo_gives_back(&mut s, &before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transform_each_with_copy_of_50000_objects_finishes_within_20_s() {
|
||||
let s = session(50_000, square);
|
||||
let (before, ids, first) = (s.doc().unwrap().doc.clone(), layer(&s, 0), s.doc().unwrap().doc.peek_next_id());
|
||||
let (mut s, r) = run_soon(s, "object.transformEach", json!({"moveH": 5, "copy": true}));
|
||||
let copies = ids_from(first, ids.len());
|
||||
assert!(ids_in(&r) == copies);
|
||||
assert!(layer(&s, 0) == in_turn(&ids, &copies), "each copy directly above its object");
|
||||
assert!(selected(&s, &copies));
|
||||
assert_eq!(bounds(&s, copies[1]), square_at(1) + vectorcraft_geom::Vec2::new(5.0, 0.0));
|
||||
undo_gives_back(&mut s, &before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compound_path_of_100000_objects_finishes_within_20_s() {
|
||||
let s = session(100_000, square);
|
||||
let (before, ids) = (s.doc().unwrap().doc.clone(), layer(&s, 0));
|
||||
let (mut s, r) = run_soon(s, "object.compoundPath.make", json!({}));
|
||||
let c = NodeId(r["id"].as_u64().unwrap());
|
||||
assert_eq!(layer(&s, 0), [c]);
|
||||
assert!(children(&s, c) == ids, "the compound path holds the paths in their order");
|
||||
assert!(selected(&s, &[c]));
|
||||
undo_gives_back(&mut s, &before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn send_to_current_layer_of_100000_objects_finishes_within_20_s() {
|
||||
let mut s = session(100_000, square);
|
||||
let ids = layer(&s, 0);
|
||||
let to = NodeId(s.execute("layer.new", &json!({})).unwrap()["id"].as_u64().unwrap());
|
||||
s.execute("select.all", &json!({})).unwrap();
|
||||
s.execute("layer.setCurrent", &json!({"id": to.0})).unwrap();
|
||||
let before = s.doc().unwrap().doc.clone();
|
||||
let (mut s, _) = run_soon(s, "object.arrange.sendToCurrentLayer", json!({}));
|
||||
assert!(layer(&s, 0).is_empty());
|
||||
assert!(children(&s, to) == ids, "the layer holds the objects in their order");
|
||||
assert!(selected(&s, &ids));
|
||||
undo_gives_back(&mut s, &before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ungroup_of_25000_groups_finishes_within_20_s() {
|
||||
// Each group holds two squares, and ungrouping one adds an object to the layer.
|
||||
let s = session(25_000, |d, k| {
|
||||
let pair = [square(d, 2 * k), square(d, 2 * k + 1)].map(Arc::new);
|
||||
Node::group(d.alloc_id(), pair.to_vec())
|
||||
});
|
||||
let before = s.doc().unwrap().doc.clone();
|
||||
let squares: Vec<NodeId> = layer(&s, 0).into_iter().flat_map(|g| children(&s, g)).collect();
|
||||
let (mut s, _) = run_soon(s, "object.ungroup", json!({}));
|
||||
assert!(layer(&s, 0) == squares, "each group's objects where the group was");
|
||||
assert!(selected(&s, &squares));
|
||||
undo_gives_back(&mut s, &before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compound_path_release_of_25000_compound_paths_finishes_within_20_s() {
|
||||
let s = session(25_000, |d, k| {
|
||||
let pair = [square(d, 2 * k), square(d, 2 * k + 1)].map(Arc::new);
|
||||
Node::new(d.alloc_id(), NodeKind::Compound { children: pair.to_vec(), rule: FillRule::NonZero })
|
||||
});
|
||||
let before = s.doc().unwrap().doc.clone();
|
||||
let paths: Vec<NodeId> = layer(&s, 0).into_iter().flat_map(|c| children(&s, c)).collect();
|
||||
let (mut s, _) = run_soon(s, "object.compoundPath.release", json!({}));
|
||||
assert!(layer(&s, 0) == paths, "each compound path's paths where it was");
|
||||
assert!(selected(&s, &paths));
|
||||
undo_gives_back(&mut s, &before);
|
||||
}
|
||||
|
||||
/// With Alt held, a Selection tool press on one of 30,000 selected squares, four moves of 20 pt
|
||||
/// and the release copy the squares once, as one undo step.
|
||||
#[test]
|
||||
fn alt_drag_of_30000_objects_finishes_within_20_s() {
|
||||
let s = session(30_000, square);
|
||||
let (before, ids, first) = (s.doc().unwrap().doc.clone(), layer(&s, 0), s.doc().unwrap().doc.peek_next_id());
|
||||
// The press lands on a square in the middle of the art, away from the handles of the
|
||||
// selection's box.
|
||||
let grab = square_at(15_100).center();
|
||||
let (mut s, r) = soon(s, "the Alt-drag", move |s| {
|
||||
let (v, alt) = (ViewInfo::default(), Mods { alt: true, ..Mods::default() });
|
||||
s.select_tool("selection", v)?;
|
||||
let at = |kind, dx: f64| PointerEvent::new(kind, grab.x + dx, grab.y).with_mods(alt);
|
||||
s.pointer(&at(PointerKind::Down, 0.0), v)?;
|
||||
for dx in [20.0, 40.0, 60.0, 80.0] {
|
||||
s.pointer(&at(PointerKind::Drag, dx), v)?;
|
||||
}
|
||||
s.pointer(&at(PointerKind::Up, 80.0), v)
|
||||
});
|
||||
r.unwrap();
|
||||
let copies = ids_from(first, ids.len());
|
||||
assert!(layer(&s, 0) == in_turn(&ids, &copies), "each copy directly above its square");
|
||||
assert!(selected(&s, &copies));
|
||||
assert_eq!(s.doc().unwrap().history.undo.last().map(|e| e.label.as_str()), Some("Copy"));
|
||||
// The squares stay; their copies move together.
|
||||
let moved = bounds(&s, copies[0]).origin() - square_at(0).origin();
|
||||
assert!(moved.x > 0.0, "{moved:?}");
|
||||
for (k, (id, copy)) in ids.iter().zip(&copies).enumerate() {
|
||||
assert_eq!(bounds(&s, *id), square_at(k));
|
||||
assert_eq!(bounds(&s, *copy), square_at(k) + moved);
|
||||
}
|
||||
undo_gives_back(&mut s, &before);
|
||||
}
|
||||
|
||||
/// The ids under `n`: a child that can hold children is a list of its id and theirs.
|
||||
fn outline(n: &Node) -> Value {
|
||||
let ids = n.children().into_iter().flatten().map(|c| match c.children() {
|
||||
Some(_) => {
|
||||
let mut v = vec![json!(c.id.0)];
|
||||
v.extend(outline(c).as_array().cloned().unwrap_or_default());
|
||||
Value::Array(v)
|
||||
}
|
||||
None => json!(c.id.0),
|
||||
});
|
||||
Value::Array(ids.collect())
|
||||
}
|
||||
|
||||
/// [`outline`] of each layer.
|
||||
fn layers(s: &Session) -> Vec<Value> {
|
||||
s.doc().unwrap().doc.layers.iter().map(|l| outline(l)).collect()
|
||||
}
|
||||
|
||||
/// A session whose first layer holds a, b, c and a group of d, e and f, and whose second layer
|
||||
/// holds g and h, with a, c, e, g and h selected. Returns the session and the ids of a to h and of
|
||||
/// the group.
|
||||
fn two_layers() -> (Session, [u64; 9]) {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 800, "height": 600})).unwrap();
|
||||
let rect = |s: &mut Session| {
|
||||
let x = 20.0 * s.doc().unwrap().doc.node_count() as f64;
|
||||
s.execute("shape.rectangle", &json!({"x": x, "y": 10, "width": 10, "height": 10})).unwrap()["id"].as_u64().unwrap()
|
||||
};
|
||||
let [a, b, c, d, e, f] = [(); 6].map(|()| rect(&mut s));
|
||||
s.execute("select.set", &json!({"ids": [d, e, f]})).unwrap();
|
||||
let group = s.execute("object.group", &json!({})).unwrap()["id"].as_u64().unwrap();
|
||||
s.execute("layer.new", &json!({})).unwrap();
|
||||
let [g, h] = [(); 2].map(|()| rect(&mut s));
|
||||
s.execute("select.set", &json!({"ids": [a, c, e, g, h]})).unwrap();
|
||||
assert_eq!(layers(&s), [json!([a, b, c, [group, d, e, f]]), json!([g, h])]);
|
||||
(s, [a, b, c, d, e, f, g, h, group])
|
||||
}
|
||||
|
||||
/// After Group, Delete and Duplicate of objects in two layers and inside a group, the layers hold
|
||||
/// the objects and the new ones in the order, and with the ids, that each check lists, and Undo
|
||||
/// gives back the document.
|
||||
#[test]
|
||||
fn group_delete_and_duplicate_across_layers_and_inside_a_group_keep_order_and_ids() {
|
||||
let (mut s, [a, b, c, d, e, f, g, h, group]) = two_layers();
|
||||
let before = s.doc().unwrap().doc.clone();
|
||||
let next = before.peek_next_id();
|
||||
|
||||
let r = s.execute("object.group", &json!({})).unwrap();
|
||||
assert_eq!(r["id"], json!(next));
|
||||
assert_eq!(layers(&s), [json!([b, [group, d, f]]), json!([[next, a, c, e, g, h]])]);
|
||||
assert!(selected(&s, &[NodeId(next)]));
|
||||
undo_gives_back(&mut s, &before);
|
||||
|
||||
s.execute("edit.clear", &json!({})).unwrap();
|
||||
assert_eq!(layers(&s), [json!([b, [group, d, f]]), json!([])]);
|
||||
undo_gives_back(&mut s, &before);
|
||||
|
||||
let r = s.execute("edit.duplicate", &json!({})).unwrap();
|
||||
let [a2, c2, e2, g2, h2] = [0, 1, 2, 3, 4].map(|k| next + k);
|
||||
assert_eq!(r["ids"], json!([a2, c2, e2, g2, h2]));
|
||||
assert_eq!(layers(&s), [json!([a, a2, b, c, c2, [group, d, e, e2, f]]), json!([g, g2, h, h2])]);
|
||||
assert!(selected(&s, &ids_from(next, 5)));
|
||||
undo_gives_back(&mut s, &before);
|
||||
}
|
||||
|
||||
/// `object.transform` copies ids listed twice, and an object listed before a group around it.
|
||||
/// Each copy goes directly above its original, a later copy of the same object goes below an
|
||||
/// earlier one, and the copy of the group holds a copy of the copy made inside the group before it.
|
||||
#[test]
|
||||
fn copies_of_ids_listed_twice_or_inside_another_listed_id_keep_their_order_and_ids() {
|
||||
let (mut s, [a, b, c, d, e, f, g, h, group]) = two_layers();
|
||||
let before = s.doc().unwrap().doc.clone();
|
||||
let n = before.peek_next_id();
|
||||
|
||||
let r = s.execute("object.transform", &json!({"matrix": [1, 0, 0, 1, 5, 0], "copy": true, "ids": [e, group]})).unwrap();
|
||||
assert_eq!(r["ids"], json!([n, n + 1]));
|
||||
let copied = json!([n + 1, n + 2, n + 3, n + 4, n + 5]);
|
||||
assert_eq!(layers(&s), [json!([a, b, c, [group, d, e, n, f], copied]), json!([g, h])]);
|
||||
undo_gives_back(&mut s, &before);
|
||||
|
||||
let r = s.execute("object.transform", &json!({"matrix": [1, 0, 0, 1, 5, 0], "copy": true, "ids": [a, a]})).unwrap();
|
||||
assert_eq!(r["ids"], json!([n, n + 1]));
|
||||
assert_eq!(layers(&s), [json!([a, n + 1, n, b, c, [group, d, e, f]]), json!([g, h])]);
|
||||
undo_gives_back(&mut s, &before);
|
||||
}
|
||||
|
|
@ -506,6 +506,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
|
|||
cursors. DPI/hotspot and preference checks in `cursors.rs` and `tests_prefs.rs` cover #1269;
|
||||
actual OS display and Caps Lock remain unverified. Behaviour reference: [public tool-pointer documentation](https://helpx.adobe.com/illustrator/using/tools.html#change_tool_pointers).
|
||||
- **Performance:** 20k shapes + 1k texts render in 27 ms per full-retina frame (7.8 ms zoomed), 7× faster than the first version. The UI thread never blocks. The web build is 7.1 MB gzipped.
|
||||
Delete, Cut, Group, Ungroup, Make and Release Compound Path, Make Clipping Mask, Send to Current Layer, Duplicate, Copy in the Move, Rotate, Scale, Reflect and Shear dialogs, Transform Each with Copy and each move of an Alt-drag take time in proportion to the size of the document (#1429).
|
||||
- **Graphics processor:** the canvas is rasterized on the CPU and the GPU only composites it, so any GPU that can show the window will do. Preferences › Performance › Graphics Processor (`gpuPreference`) is Automatic by default: the power-saving GPU on Windows and macOS (the integrated one on hybrid-graphics laptops, where presenting from the discrete GPU made the window flicker, #306), and the GPU the desktop runs on under Linux, where KWin on an NVIDIA GPU refused frames from a Ryzen's integrated GPU and 0.5.0 crashed at startup (#502). Power Saving and High Performance pick a kind of GPU; `WGPU_POWER_PREF`, `WGPU_ADAPTER_NAME` and `WGPU_BACKEND` override them. The app picks among the adapters that can present to the window (native backends before OpenGL, hardware before software, and on Windows a GPU's DX12 adapter before its Vulkan one: Intel's Vulkan driver made the whole window flicker black, #545), and if the window fails on one while starting up it starts again without it instead of crashing; Help › About, `ui.inspect` and the log name the adapter in use. When the browser drops the page's WebGPU device or WebGL context, the web build writes recovery copies, starts again on new graphics with the same app and uploads its textures again; if it can't, it removes the stale canvas and says to reload, which offers the copies back. The desktop app writes the copies and says to restart (#369). On Windows and Linux the tools' cursors are OS cursors (bitmaps rasterized once per display scale), which the system moves itself: painted into the window, they trailed the pointer by the frames waiting to be shown, and the window now queues one frame instead of two (#444). macOS and the web still paint them (macOS sizes cursor bitmaps in points, eframe has no bitmap cursors on the web). Pens and touch drive the tools as the mouse does, and a tap pressed and lifted within one frame clicks where it tapped (#491); pen pressure reaches the Liquify tools' Use Pressure Pen on Windows, while winit 0.30 reports none on macOS or Linux and egui none on the web. On Linux, see `docs/development.md` for pens under Wayland.
|
||||
- **Tests and robustness:** ~2,840 automated tests: model-based property tests, a junk-parameter sweep over every command, import fuzzing (SVG, PDF, libraries), golden renders, and MCP end-to-end tests over stdio. Shipped code never panics (workspace lints and a rollback safety net; see `docs/development.md`).
|
||||
- **Raster-effect resolution in PDF:** when the 64 million pixel cap on an effect image renders an object's raster effects below the document's raster effects resolution, PDF export warns once per object with the requested and the lowest achieved ppi (`crates/engine/src/tests_pdfraster.rs`).
|
||||
|
|
|
|||
Loading…
Reference in New Issue