diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 84f925fd..081e5888 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -558,10 +558,20 @@ fn duplicate(s: &mut Session, p: &Value) -> Result { Ok(json!({ "ids": ids.iter().map(|i| i.0).collect::>() })) } -/// 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. +/// Duplicate `roots` directly above each original, transform the copies, select them +/// ([`copy_above`]). pub(crate) fn duplicate_in(d: &mut Document, sel: &mut vectorcraft_doc::Selection, roots: &[NodeId], xf: Affine) -> Result> { + let out = copy_above(d, roots, |_, _, c| c.transform(xf, true))?; + sel.set(out.iter().copied()); + Ok(out) +} + +/// Copy `roots` directly above each original and return the copies' ids. `change` gets the +/// document, each original and its copy before the copy goes in, and must leave the document's +/// tree as it is. 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 copy_above(d: &mut Document, roots: &[NodeId], mut change: impl FnMut(&mut Document, &Node, &mut Node)) -> Result> { let paths = d.index_paths(roots.iter().copied()); let mut found: Vec<_> = roots.iter().filter_map(|id| paths.get(id)).collect(); found.sort_unstable(); @@ -570,7 +580,7 @@ pub(crate) fn duplicate_in(d: &mut Document, sel: &mut vectorcraft_doc::Selectio 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); + change(d, &n, &mut c); copies.push((*id, vec![c])); } let out: Vec = copies.iter().flat_map(|(_, c)| c.iter().map(|n| n.id)).collect(); @@ -582,12 +592,11 @@ pub(crate) fn duplicate_in(d: &mut Document, sel: &mut vectorcraft_doc::Selectio 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); + change(d, &n, &mut c); out.push(d.insert(par, idx + 1, c)?); } out }; - sel.set(out.iter().copied()); Ok(out) } diff --git a/crates/engine/src/cmd/layer.rs b/crates/engine/src/cmd/layer.rs index 8840c7c0..e254a219 100644 --- a/crates/engine/src/cmd/layer.rs +++ b/crates/engine/src/cmd/layer.rs @@ -234,23 +234,47 @@ pub(crate) fn locked_within(d: &Document, id: NodeId) -> bool { d.ancestry(id).unwrap_or_default().iter().any(|a| d.node(*a).is_some_and(|n| n.locked)) } -/// Every layer and sublayer, depth first. -fn all_layers(d: &Document) -> Vec<&Node> { - let mut out = vec![]; - d.walk(|n| { +/// The number and the names of a document's layers and sublayers, from which [`NewLayers::make`] +/// names and colors a new layer. [`NewLayers::of`] reads them in one walk of the document, and +/// [`NewLayers::add`] counts each new layer as it goes in. +pub(crate) struct NewLayers { + count: usize, + taken: HashSet, +} + +impl NewLayers { + pub(crate) fn of(d: &Document) -> Self { + let mut layers = Self { count: 0, taken: HashSet::new() }; + d.walk(|n| layers.note(n)); + layers + } + + /// A new layer named `name` (default: the next `Layer N`, as [`Document::next_layer_name`] + /// gives it) in the next layer color. + pub(crate) fn make(&self, d: &mut Document, name: Option<&str>) -> Node { + let name = name + .map(str::to_string) + .unwrap_or_else(|| (self.count + 1..).map(|i| format!("Layer {i}")).find(|name| !self.taken.contains(name)).unwrap_or_default()); + let id = d.alloc_id(); + Node::layer(id, &name, LayerColor::Preset((self.count % LAYER_COLORS.len()) as u8)) + } + + /// Count `n`, a layer going into the document, and the layers inside it. + pub(crate) fn add(&mut self, n: &Node) { + n.walk(&mut |c| self.note(c)); + } + + fn note(&mut self, n: &Node) { if n.is_layer() { - out.push(n); + self.count += 1; + self.taken.extend(n.name.clone()); } - }); - out + } } /// A new layer named `name` (default: the next `Layer N`) in the next layer colour. pub(crate) fn make_layer(d: &mut Document, name: Option<&str>) -> Node { - let n = all_layers(d).len(); - let name = name.map(str::to_string).unwrap_or_else(|| d.next_layer_name()); - let id = d.alloc_id(); - Node::layer(id, &name, LayerColor::Preset((n % LAYER_COLORS.len()) as u8)) + NewLayers::of(d).make(d, name) } /// A layer colour: a preset index (0–26), `#rrggbb` or a preset's name (any case). @@ -464,13 +488,12 @@ fn delete_rows(s: &mut Session, p: &Value) -> Result { if ids.is_empty() { return Err(bad("layer.delete", "no such rows")); } - if st.doc.layers.iter().all(|l| ids.contains(&l.id)) { + let listed: HashSet = ids.iter().copied().collect(); + if st.doc.layers.iter().all(|l| listed.contains(&l.id)) { return Err(EngineError::Other("a document needs at least one layer".into())); } s.edit("Delete Selection", |d, sel| { - for id in &ids { - d.remove(*id)?; - } + d.remove_many(&ids)?; // The guides on the deleted layers go with them. d.drop_guides_of_deleted_layers(sel); sel.prune(d); @@ -491,18 +514,12 @@ fn duplicate_rows(s: &mut Session, p: &Value) -> Result { return Err(bad("layer.duplicate", "no such rows")); } let copies = s.edit("Duplicate Selection", |d, _| { - let mut out = vec![]; - for id in &ids { - let (par, idx, _) = d.position(*id).ok_or(EngineError::NoNode(*id))?; - let n = d.node(*id).cloned().ok_or(EngineError::NoNode(*id))?; - let mut c = d.reid(&n); + super::edit::copy_above(d, &ids, |d, n, c| { if n.is_layer() { c.name = Some(format!("{} copy", n.display_name())); - d.copy_layer_guides(&n, &c); + d.copy_layer_guides(n, c); } - out.push(d.insert(par, idx + 1, c)?); - } - Ok(out) + }) })?; s.doc_mut()?.layer_rows = copies.clone(); Ok(json!({ "ids": copies.iter().map(|i| i.0).collect::>() })) @@ -579,14 +596,23 @@ fn collect(s: &mut Session, p: &Value) -> Result { let lid = s.edit("Collect in New Layer", |d, sel| { // Where the topmost of them is: beside it in its layer, or on top of the layer holding // its group. - let (parent, index) = match d.position(top) { - Some((par, i, _)) if par.is_none_or(|p| d.node(p).is_some_and(Node::is_layer)) => (par, i + 1), - _ => (d.layer_containing(top), usize::MAX), - }; - let layer = make_layer(d, None); - let lid = d.insert(parent, index, layer)?; - for id in &ids { - d.move_node(*id, Some(lid), usize::MAX)?; + let beside = d.position(top).is_some_and(|(par, _, _)| par.is_none_or(|p| d.node(p).is_some_and(Node::is_layer))); + let holder = d.layer_containing(top); + let mut layer = make_layer(d, None); + let lid = layer.id; + if beside { + d.wrap(&ids, |rows| { + if let Some(ch) = layer.children_mut() { + *ch = rows; + } + layer + })?; + } else { + let rows = d.remove_many(&ids)?; + if let Some(ch) = layer.children_mut() { + *ch = rows; + } + d.insert(holder, usize::MAX, layer)?; } sel.prune(d); sel.deselect_uneditable(d); @@ -683,19 +709,20 @@ fn move_rows(s: &mut Session, p: &Value) -> Result { } let label = if copy { "Duplicate Rows" } else { "Move Rows" }; let moved = s.edit(label, |d, sel| { - let mut nodes = vec![]; - for id in &ids { - nodes.push(if copy { + let nodes = if copy { + let mut nodes = vec![]; + for id in &ids { let n = d.node(*id).cloned().ok_or(EngineError::NoNode(*id))?; let c = d.reid(&n); if n.is_layer() { d.copy_layer_guides(&n, &c); } - c - } else { - Arc::unwrap_or_clone(d.remove(*id)?) - }); - } + nodes.push(Arc::new(c)); + } + nodes + } else { + d.remove_many(&ids)? + }; let count = d.children(parent).map_or(0, Vec::len); let mut index = match anchor { None => count, @@ -708,11 +735,10 @@ fn move_rows(s: &mut Session, p: &Value) -> Result { if count > 0 && parent.and_then(|p| d.node(p)).is_some_and(Node::clips) { index = index.max(1); } - let mut out = vec![]; - for n in nodes { - out.push(d.insert(parent, index, n)?); - index += 1; - } + let out: Vec = nodes.iter().map(|n| n.id).collect(); + let ch = d.children_mut(parent)?; + let index = index.min(ch.len()); + ch.splice(index..index, nodes); sel.prune(d); sel.deselect_uneditable(d); Ok(out) diff --git a/crates/engine/src/cmd/layerpanel.rs b/crates/engine/src/cmd/layerpanel.rs index 16eed0ec..4978ddef 100644 --- a/crates/engine/src/cmd/layerpanel.rs +++ b/crates/engine/src/cmd/layerpanel.rs @@ -9,7 +9,7 @@ use std::sync::Arc; use serde_json::{Value, json}; use vectorcraft_doc::{Document, Node, NodeId, NodeKind}; -use super::layer::{make_layer, row_roots, rows_param}; +use super::layer::{NewLayers, row_roots, rows_param}; use super::*; use crate::EngineError; @@ -234,28 +234,23 @@ fn template(s: &mut Session, p: &Value) -> Result { /// another) into layer `into`, keeping the document's stacking order, then delete the emptied /// layers. fn merge_into(d: &mut Document, into: NodeId, others: &[NodeId]) -> Result<()> { + let listed: HashSet = others.iter().copied().collect(); let own: Vec = - d.node(into).and_then(|n| n.children()).map(|c| c.iter().map(|c| c.id).filter(|c| !others.contains(c)).collect()).unwrap_or_default(); - let mut moved = vec![]; + d.node(into).and_then(|n| n.children()).map(|c| c.iter().map(|c| c.id).filter(|c| !listed.contains(c)).collect()).unwrap_or_default(); + let (mut moved, mut emptied) = (vec![], vec![]); for id in others { let n = d.node(*id).ok_or(EngineError::NoNode(*id))?; if n.is_layer() { moved.extend(n.children().into_iter().flatten().map(|c| c.id)); + emptied.push(*id); } else { moved.push(*id); } } // Where everything is now: the merged layer stacks its contents in this order. let keys: HashMap> = own.iter().chain(&moved).filter_map(|id| Some((*id, d.index_path(*id)?))).collect(); - let mut taken = vec![]; - for id in &moved { - taken.push(d.remove(*id)?); - } - for id in others { - if d.node(*id).is_some() { - d.remove(*id)?; - } - } + let taken = d.remove_many(&moved)?; + d.remove_many(&emptied)?; let ch = d.children_mut(Some(into))?; let mut all = std::mem::take(ch); all.extend(taken); @@ -312,9 +307,7 @@ fn flatten(s: &mut Session, p: &Value) -> Result { let others: Vec = d.layers.iter().filter(|l| l.id != into && l.visible && !l.is_template()).map(|l| l.id).collect(); let hidden: Vec = d.layers.iter().filter(|l| l.id != into && !l.visible && !l.is_template()).map(|l| l.id).collect(); s.edit("Flatten Artwork", |d, sel| { - for h in &hidden { - d.remove(*h)?; - } + d.remove_many(&hidden)?; // Hidden layers are discarded with their guides. d.drop_guides_of_deleted_layers(sel); merge_into(d, into, &others)?; @@ -352,39 +345,38 @@ fn release(s: &mut Session, p: &Value) -> Result { } let new = s.edit(if build { "Release to Layers (Build)" } else { "Release to Layers (Sequence)" }, |d, sel| { // A group's objects go into sublayers of the layer holding it, where it was. - let (parent, mut index) = if group { + let (parent, index) = if group { let layer = d.layer_containing(id).ok_or(EngineError::NoNode(id))?; let (par, i, _) = d.position(id).ok_or(EngineError::NoNode(id))?; if par == Some(layer) { (layer, i) } else { (layer, usize::MAX) } } else { (id, 0) }; - let mut originals = vec![]; - for o in &objects { - originals.push(Arc::unwrap_or_clone(d.remove(*o)?)); - } + let originals = d.remove_many(&objects)?; if group { d.remove(id)?; } - let mut new = vec![]; + let mut names = NewLayers::of(d); + let mut layers = Vec::with_capacity(originals.len()); let last = originals.len().saturating_sub(1); for k in 0..originals.len() { - let mut layer = make_layer(d, None); - let lid = layer.id; - let held: Vec = if build { + let mut layer = names.make(d, None); + let held: Vec> = if build { // Copies of the objects below and itself; the top layer keeps the originals. - (0..=k).filter_map(|j| originals.get(j)).map(|o| if k == last { o.clone() } else { d.reid(o) }).collect() + (0..=k).filter_map(|j| originals.get(j)).map(|o| if k == last { o.clone() } else { Arc::new(d.reid(o)) }).collect() } else { originals.get(k).cloned().into_iter().collect() }; if let Some(ch) = layer.children_mut() { - ch.extend(held.into_iter().map(Arc::new)); + ch.extend(held); } - let at = if group { index } else { index.min(d.children(Some(parent)).map_or(0, Vec::len)) }; - d.insert(Some(parent), at, layer)?; - index = at.saturating_add(1); - new.push(lid); + names.add(&layer); + layers.push(Arc::new(layer)); } + let new: Vec = layers.iter().map(|l| l.id).collect(); + let ch = d.children_mut(Some(parent))?; + let at = index.min(ch.len()); + ch.splice(at..at, layers); sel.prune(d); Ok(new) })?; diff --git a/crates/engine/src/cmd/panelcmds.rs b/crates/engine/src/cmd/panelcmds.rs index f5484ffa..be521511 100644 --- a/crates/engine/src/cmd/panelcmds.rs +++ b/crates/engine/src/cmd/panelcmds.rs @@ -136,10 +136,7 @@ pub(crate) fn artboard_copy(s: &mut Session, p: &Value, cut: bool) -> Result>(); + + // A layer's objects go into sublayers of their own, below its sublayers. + let r = s.execute("layer.releaseToLayers", &json!({"id": l2})).unwrap(); + assert_eq!(r["ids"], json!([n, n + 1])); + assert_eq!(layers(&s), [json!([a, b, c, [group, d, e, f]]), json!([[n, g], [n + 1, h], [sub]])]); + assert_eq!(names(&s, &r), ["Layer 4", "Layer 5"]); + undo_gives_back(&mut s, &before); + + // Build: a sublayer holds copies of the objects below its own and a copy of its own, and the + // top one holds the objects. + let r = s.execute("layer.releaseToLayersBuild", &json!({"id": l2})).unwrap(); + assert_eq!(r["ids"], json!([n, n + 2])); + assert_eq!(layers(&s), [json!([a, b, c, [group, d, e, f]]), json!([[n, n + 1], [n + 2, g, h], [sub]])]); + assert_eq!(names(&s, &r), ["Layer 4", "Layer 5"]); + undo_gives_back(&mut s, &before); + + // A group's objects go into sublayers of its layer, where the group was. + let r = s.execute("layer.releaseToLayers", &json!({"id": group})).unwrap(); + assert_eq!(r["ids"], json!([n, n + 1, n + 2])); + assert_eq!(layers(&s), [json!([a, b, c, [n, d], [n + 1, e], [n + 2, f]]), json!([g, h, [sub]])]); + assert_eq!(names(&s, &r), ["Layer 4", "Layer 5", "Layer 6"]); + undo_gives_back(&mut s, &before); + + // Flatten Artwork into Layer 1 deletes the two hidden layers and keeps the template layer. + for p in [json!({"top": true, "visible": false}), json!({"top": true, "visible": false}), json!({"top": true, "template": true})] { + s.execute("layer.new", &p).unwrap(); + } + let before = s.doc().unwrap().doc.clone(); + assert_eq!(s.execute("layer.flatten", &json!({"id": l1})).unwrap()["discarded"], 2); + assert_eq!(layers(&s), [json!([a, b, c, [group, d, e, f], g, h, [sub]]), json!([])]); + undo_gives_back(&mut s, &before); +} + +/// Duplicate Artboard and `artboard.move` with `copy` and `moveArt` (the Artboard tool's Alt-drag +/// with Move/Copy Artwork with Artboard on) put a copy of each object on the artboard directly +/// above it, moved with the artboard's copy, and Cut Artboard removes those objects. An object off +/// the artboard stays. The layers then hold the objects in the order, and with the ids, that each +/// check lists, and Undo gives back the document. +#[test] +fn artboard_copies_and_cut_keep_order_and_ids() { + let (mut s, [a, b, c, d, e, f, g, h, group]) = two_layers(); + let off = s.execute("shape.rectangle", &json!({"x": 900, "y": 10, "width": 10, "height": 10})).unwrap()["id"].as_u64().unwrap(); + s.execute("artboard.new", &json!({})).unwrap(); + let before = s.doc().unwrap().doc.clone(); + let n = before.peek_next_id(); + let copies = json!([n, n + 1, n + 2, n + 3, n + 7, n + 8]); + let copied = [json!([a, n, b, n + 1, c, n + 2, [group, d, e, f], [n + 3, n + 4, n + 5, n + 6]]), json!([g, n + 7, h, n + 8, off])]; + // The copy of h, the last object on the artboard, sits where h sits, moved by `dx`. + let moved_by = |s: &Session, dx: f64| bounds(s, NodeId(n + 8)) == bounds(s, NodeId(h)) + vectorcraft_geom::Vec2::new(dx, 0.0); + + // Duplicate Artboard places the copy 20 pt right of the last artboard. + let right = before.artboards.iter().map(|ab| ab.rect.x1).fold(f64::MIN, f64::max); + assert_eq!(s.execute("artboard.duplicate", &json!({"index": 0, "art": true})).unwrap()["ids"], copies); + assert_eq!(layers(&s), copied); + assert!(moved_by(&s, right + 20.0)); + undo_gives_back(&mut s, &before); + + let r = s.execute("artboard.move", &json!({"index": 0, "dx": 900, "dy": 0, "copy": true, "moveArt": true})).unwrap(); + assert_eq!(r["moved"], copies); + assert_eq!(layers(&s), copied); + assert!(moved_by(&s, 900.0)); + undo_gives_back(&mut s, &before); + + assert_eq!(s.execute("artboard.cut", &json!({"index": 0, "art": true})).unwrap()["copied"], 6); + assert_eq!(layers(&s), [json!([]), json!([off])]); + 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. diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 28354717..dfa73f18 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -510,6 +510,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **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). An Alt-drag with the Selection, Direct Selection, Group Selection, Rotate, Scale, Reflect or Shear tool copies the selection on its first move, and the moves after it transform those copies; the previews of the Move, Rotate, Scale, Reflect and Shear dialogs with Copy and Preview on copy the selection once in the same way (#1429). + The Layers panel's Delete Selection, Duplicate Selection, Collect in New Layer, Merge Selected, Flatten Artwork, Release to Layers (Sequence) and row drags also take time in proportion to the size of the document, and so do Cut Artboard and Duplicate Artboard with their art and each move of an Alt-drag of one artboard with the Artboard tool while Move/Copy Artwork with Artboard is on (#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`).