diff --git a/apps/vectorcraft-cli/src/main.rs b/apps/vectorcraft-cli/src/main.rs index fab468c391..57ab103590 100644 --- a/apps/vectorcraft-cli/src/main.rs +++ b/apps/vectorcraft-cli/src/main.rs @@ -486,7 +486,12 @@ fn info(args: &[String]) -> Result<(), String> { let base = h.call("engine.execute", json!({"command": "file.info", "params": {}}))?; let doc = h.session.doc().map_err(|e| e.to_string())?.doc.clone(); let mut kinds: std::collections::BTreeMap<&'static str, usize> = Default::default(); - doc.walk(|n| *kinds.entry(n.kind_label()).or_default() += 1); + // Ruler guides are not counted among the objects. + doc.walk(|n| { + if !n.is_ruler_guide() { + *kinds.entry(n.kind_label()).or_default() += 1; + } + }); let fonts = h.call("engine.execute", json!({"command": "text.fonts", "params": {}})).unwrap_or(Value::Null); let artboards: Vec = doc.artboards.iter().map(|a| json!({"name": a.name, "rect": [a.rect.x0, a.rect.y0, a.rect.width(), a.rect.height()]})).collect(); diff --git a/crates/cad/src/scene.rs b/crates/cad/src/scene.rs index 45ed992995..3faf94423f 100644 --- a/crates/cad/src/scene.rs +++ b/crates/cad/src/scene.rs @@ -583,7 +583,8 @@ impl<'a> Scene<'a> { } fn node(&mut self, n: &Node, force: bool) { - if let NodeKind::Layer { template: true, .. } = n.kind { + // Ruler guides draw on screen only: no entity and no loss warning. + if n.is_ruler_guide() || n.is_template() { return; } // Symbols that hold symbols are written in full for each instance: depth alone doesn't @@ -676,6 +677,8 @@ impl<'a> Scene<'a> { self.node(c, false); } } + // Ruler guides draw on screen only. + NodeKind::RulerGuide { .. } => {} } self.opacity = opacity; } diff --git a/crates/doc/src/artboard.rs b/crates/doc/src/artboard.rs index ac5db48c76..fd06ea9093 100644 --- a/crates/doc/src/artboard.rs +++ b/crates/doc/src/artboard.rs @@ -1,4 +1,4 @@ -use super::{Arc, Deserialize, LayerColor, Node, NodeId, NodeKind, Rect, Serialize, SymbolBoxes, skip}; +use super::{Arc, Deserialize, LayerColor, Node, NodeId, Rect, Serialize, SymbolBoxes, skip}; #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct Artboard { @@ -204,12 +204,7 @@ impl LockedArtboards<'_> { /// [`LockedArtboards::holds`] for new art too: is `n` on a locked artboard, whether or not it /// is new art? pub fn overlaps(&self, n: &Node) -> bool { - if self.rects.is_empty() - || !n.visible - || n.is_layer() - || matches!(n.kind, NodeKind::Path { guide: true, .. }) - || self.pattern_art.contains(&n.id) - { + if self.rects.is_empty() || !n.visible || n.is_layer() || n.is_guide() || self.pattern_art.contains(&n.id) { return false; } if let Some(held) = self.held.borrow().get(&n.id) { diff --git a/crates/doc/src/canvas_range.rs b/crates/doc/src/canvas_range.rs index 1142356968..e4f267a8b6 100644 --- a/crates/doc/src/canvas_range.rs +++ b/crates/doc/src/canvas_range.rs @@ -123,6 +123,24 @@ fn drop_children_beyond(node: &mut Arc, dropped: &mut usize, layers: &mut }); } +/// Remove the ruler guides of layer `layer` and of its sublayers that `out` picks, adding how many +/// to `dropped`. Only layers holding one are copied. +fn drop_guides(layer: &mut Arc, out: &impl Fn(&Node) -> bool, dropped: &mut usize) { + fn holds(n: &Node, out: &impl Fn(&Node) -> bool) -> bool { + n.is_layer() && n.children().is_some_and(|ch| ch.iter().any(|c| out(c) || holds(c, out))) + } + if !holds(layer, out) { + return; + } + let Some(children) = Arc::make_mut(layer).children_mut() else { return }; + let before = children.len(); + children.retain(|c| !out(c)); + *dropped += before - children.len(); + for c in children.iter_mut().filter(|c| c.is_layer()) { + drop_guides(c, out, dropped); + } +} + impl Document { /// Remove what a read file has beyond the canvas ([`MAX_COORD`], or not finite): objects /// (see [`BeyondCanvas::objects`]; a top-level layer clipping by a path beyond it is emptied @@ -141,8 +159,9 @@ impl Document { for c in children.iter() { layers_in(c, &mut layers); } - gone.objects += children.len(); - children.clear(); + // The layer's own ruler guides stay; only its art goes. + gone.objects += children.iter().filter(|c| !c.is_ruler_guide()).count(); + children.retain(|c| c.is_ruler_guide()); *clip = false; } } else { @@ -161,9 +180,18 @@ impl Document { self.retire_artboard_id(*id); } gone.artboards = removed.len(); - let guides = self.guides.len(); - self.guides.retain(|g| in_range(g.pos) && g.artboard.is_none_or(|a| !removed.contains(&a)) && g.layer.is_none_or(|l| !layers.contains(&l))); - gone.guides = guides - self.guides.len(); + // The saved list, when the guides are not nodes yet ([`Document::guides_into_nodes`]). + let guides = self.saved_guides.len(); + self.saved_guides + .retain(|g| in_range(g.pos) && g.artboard.is_none_or(|a| !removed.contains(&a)) && g.layer.is_none_or(|l| !layers.contains(&l))); + gone.guides = guides - self.saved_guides.len(); + let out = |n: &Node| match n.kind { + NodeKind::RulerGuide { pos, artboard, .. } => !in_range(pos) || artboard.is_some_and(|a| removed.contains(&a)), + _ => false, + }; + for layer in &mut self.layers { + drop_guides(layer, &out, &mut gone.guides); + } if !(in_range(self.ruler_origin.x) && in_range(self.ruler_origin.y)) { self.ruler_origin = Point::ZERO; gone.ruler_origin = true; @@ -232,12 +260,18 @@ mod tests { let far = Artboard { id: 9, rect: Rect::new(0.0, 1e17, 100.0, 1e17 + 100.0), ..d.artboards[0].clone() }; d.artboards.push(far); let guide = |pos: f64, artboard| Guide { artboard, ..Guide::new(false, pos) }; - d.guides = vec![guide(50.0, None), guide(1e17, None), guide(f64::INFINITY, None), guide(20.0, Some(9))]; + d.saved_guides = vec![guide(50.0, None), guide(1e17, None), guide(f64::INFINITY, None), guide(20.0, Some(9))]; d.ruler_origin = Point::new(1e17, 0.0); + // The same guides as nodes, as a document has them once read. + let mut nodes = d.clone(); + assert_eq!(nodes.guides_into_nodes(), 4); let gone = d.drop_beyond_canvas(); assert_eq!(gone, BeyondCanvas { objects: 0, artboards: 1, guides: 3, ruler_origin: true }); assert_eq!(d.artboards.len(), 1); - assert_eq!(d.guides, [guide(50.0, None)], "the far artboard's own guide goes with it"); + assert_eq!(d.saved_guides, [guide(50.0, None)], "the far artboard's own guide goes with it"); + assert_eq!(nodes.drop_beyond_canvas(), gone); + let left: Vec<(bool, f64, Option)> = nodes.ruler_guides().iter().map(|g| (g.vertical, g.pos, g.artboard)).collect(); + assert_eq!(left, [(false, 50.0, None)]); assert_eq!(d.ruler_origin, Point::ZERO); assert_eq!( gone.note().unwrap(), @@ -310,7 +344,8 @@ mod tests { let probe = Document::new(1.0, 1.0); let sub = sublayer(&probe, 40, vec![rect(41, 1e17), rect(42, 0.0)], true); let mut d = doc(vec![rect(10, 0.0), rect(11, 1e17), sub]); - d.guides = vec![Guide { layer: Some(NodeId(40)), ..Guide::new(false, 5.0) }, Guide { layer: Some(d.layers[0].id), ..Guide::new(true, 5.0) }]; + d.saved_guides = + vec![Guide { layer: Some(NodeId(40)), ..Guide::new(false, 5.0) }, Guide { layer: Some(d.layers[0].id), ..Guide::new(true, 5.0) }]; d.saved_selections = vec![crate::records::SavedSelection { name: "s".into(), objects: vec![NodeId(10), NodeId(11)] }].into(); d.assets = vec![ crate::assets::ExportAsset { id: 1, name: "far".into(), nodes: vec![NodeId(11)], format_3d: None }, @@ -320,7 +355,7 @@ mod tests { d.variables.bindings.insert(NodeId(10), ["w".to_string()].into()); let gone = d.drop_beyond_canvas(); assert_eq!((gone.objects, gone.guides), (2, 1), "the far rectangle, the clipping sublayer and its layer guide"); - assert_eq!(d.guides.len(), 1, "the guide of a layer that stays, stays"); + assert_eq!(d.saved_guides.len(), 1, "the guide of a layer that stays, stays"); assert_eq!(d.saved_selections[0].objects, [NodeId(10)]); assert_eq!(d.assets.iter().map(|a| (a.id, a.nodes.clone())).collect::>(), [(2, vec![NodeId(10)])]); assert_eq!(d.variables.bindings.keys().copied().collect::>(), [NodeId(10)]); diff --git a/crates/doc/src/depth.rs b/crates/doc/src/depth.rs index 5abba9c63b..63b7e0ce7c 100644 --- a/crates/doc/src/depth.rs +++ b/crates/doc/src/depth.rs @@ -132,7 +132,7 @@ fn limit(nodes: &mut Vec>, depth: usize) -> bool { out.push(a); continue; } - let clip = matches!(n.kind, NodeKind::Group { clip: true, .. } | NodeKind::Layer { clip: true, .. }); + let clip = n.clips(); let (visible, locked) = (n.visible, n.locked); let mut held = n.children_mut().map(std::mem::take).unwrap_or_default(); if clip && !held.is_empty() { @@ -148,6 +148,13 @@ fn limit(nodes: &mut Vec>, depth: usize) -> bool { // What it held takes its place, and is looked at in its turn. left.extend(held.into_iter().rev()); } + // The ruler guides of a sublayer merged into its parent layer go above the parent's art, with + // the parent's own. + if out.iter().any(|c| c.is_ruler_guide()) { + let (guides, art): (Vec<_>, Vec<_>) = out.into_iter().partition(|c| c.is_ruler_guide()); + out = art; + out.extend(guides); + } *nodes = out; true } diff --git a/crates/doc/src/document.rs b/crates/doc/src/document.rs index fecdc9bacb..708d0aee03 100644 --- a/crates/doc/src/document.rs +++ b/crates/doc/src/document.rs @@ -10,7 +10,7 @@ use super::{ impl Document { /// Is everything but the layers the same in `other`, as the next version of the document after - /// an edit of its art? The id counter and the id index are left out, and art outside the layers + /// an edit of its art? The id counter and the caches are left out, and art outside the layers /// (symbols, patterns, graph designs) compares by identity, which an edit keeps for what it /// doesn't change: quick for every frame of an animation. pub fn same_outside_layers(&self, other: &Self) -> bool { @@ -38,7 +38,7 @@ impl Document { text_threads, symbols, graph_designs, - guides, + saved_guides, views, saved_selections, variables, @@ -77,6 +77,7 @@ impl Document { opened_artboard: _, units_from_file: _, index: _, + guide_cache: _, } = self; *version == other.version && *title == other.title @@ -98,7 +99,7 @@ impl Document { && symbols.iter().zip(&other.symbols).all(|(a, b)| a.name == b.name && Arc::ptr_eq(&a.art, &b.art)) && graph_designs.len() == other.graph_designs.len() && graph_designs.iter().zip(&other.graph_designs).all(|(a, b)| a.name == b.name && Arc::ptr_eq(&a.art, &b.art)) - && *guides == other.guides + && *saved_guides == other.saved_guides && *views == other.views && *saved_selections == other.saved_selections && *variables == other.variables @@ -221,7 +222,7 @@ impl Document { text_threads: vec![], symbols: vec![], graph_designs: vec![], - guides: vec![], + saved_guides: vec![], views: vec![], saved_selections: Default::default(), variables: Default::default(), @@ -260,6 +261,7 @@ impl Document { opened_artboard: None, units_from_file: false, index: Default::default(), + guide_cache: Default::default(), }; let id = d.alloc_id(); d.layers.push(Arc::new(Node::layer(id, "Layer 1", LayerColor::Preset(0)))); @@ -501,11 +503,13 @@ impl Document { } } - /// Insert `node` into `parent` at `index` (clamped; `usize::MAX` = top). + /// Insert `node` into `parent` at `index` (clamped; `usize::MAX` = top). In a layer, art goes + /// below the layer's ruler guides and a ruler guide among them ([`Node::insert_index`]). pub fn insert(&mut self, parent: Option, index: usize, node: Node) -> Result { let id = node.id; + self.check_ruler_guide_place(&node, parent)?; let ch = self.children_mut(parent)?; - let i = index.min(ch.len()); + let i = Node::insert_index(ch, index, &node); ch.insert(i, Arc::new(node)); Ok(id) } @@ -522,20 +526,33 @@ impl Document { Ok(ch.remove(idx)) } - /// Move a node to `parent` at `index` (index measured after removal). + /// Move a node to `parent` at `index` (index measured after removal), clamped as + /// [`Document::insert`] clamps it. pub fn move_node(&mut self, id: NodeId, parent: Option, index: usize) -> Result<(), DocError> { if let Some(p) = parent && self.ancestry(p).is_some_and(|a| a.contains(&id)) { return Err(DocError::Invalid("cannot move an object into itself".into())); } + if let Some(n) = self.node(id) { + self.check_ruler_guide_place(n, parent)?; + } let n = self.remove(id)?; let ch = self.children_mut(parent)?; - let i = index.min(ch.len()); + let i = Node::insert_index(ch, index, &n); ch.insert(i, n); Ok(()) } + /// An error when `n` is a ruler guide and `parent` is not a layer or sublayer: a ruler guide is + /// always a direct child of one ([`NodeKind::RulerGuide`]). + fn check_ruler_guide_place(&self, n: &Node, parent: Option) -> Result<(), DocError> { + if n.is_ruler_guide() && !parent.and_then(|p| self.node(p)).is_some_and(Node::is_layer) { + return Err(DocError::Invalid("a ruler guide goes only directly in a layer or sublayer".into())); + } + Ok(()) + } + /// Position of `id` within its parent: (parent, index, sibling count). pub fn position(&self, id: NodeId) -> Option<(Option, usize, usize)> { let parent = self.parent_of(id); @@ -739,7 +756,7 @@ impl Document { for c in n.children().into_iter().flatten() { if c.is_layer() { objects(c, out); - } else { + } else if !c.is_ruler_guide() { out.insert(c.id); } } @@ -757,7 +774,8 @@ impl Document { } /// The object of a layer or sublayer that holds node `id`: `id` itself when it is one, else - /// the group or other object around it right inside a layer. None for a layer or sublayer. + /// the group or other object around it right inside a layer. None for a layer, a sublayer or a + /// ruler guide. pub fn layer_object(&self, id: NodeId) -> Option<&Node> { self.layer_object_at(&self.index_path(id)?) } @@ -768,6 +786,9 @@ impl Document { let mut nodes = &self.layers; for &i in path { let n = nodes.get(i)?; + if n.is_ruler_guide() { + return None; + } if !n.is_layer() { return Some(n.as_ref()); } diff --git a/crates/doc/src/document_ext.rs b/crates/doc/src/document_ext.rs index ebb2fc44eb..0f1ec463c4 100644 --- a/crates/doc/src/document_ext.rs +++ b/crates/doc/src/document_ext.rs @@ -56,9 +56,12 @@ impl Document { if self.saved_selections.is_empty() { return; } + // A saved selection names objects; ruler guides are not among them. let mut ids = std::collections::HashSet::new(); self.walk(|n| { - ids.insert(n.id); + if !n.is_ruler_guide() { + ids.insert(n.id); + } }); let mut kept: Vec = vec![]; for s in std::mem::take(&mut self.saved_selections) { @@ -101,12 +104,13 @@ impl Document { /// Leave opacity-mask editing and symbol editing: drop the temporary editing layer, a working /// copy of art the mask or the symbol already holds (the engine syncs it after every edit). The /// layer is removed wherever it is, also as a sublayer of another layer; an id that names - /// anything other than a layer is left alone. + /// anything other than a layer is left alone. Ruler guides on the layer stay in the document + /// ([`Self::remove_edit_layer`]). pub fn drop_edit_modes(&mut self) { let layers = [self.mask_edit.take().map(|me| me.layer), self.symbol_edit.take().map(|se| se.layer)]; for id in layers.into_iter().flatten() { if self.node(id).is_some_and(Node::is_layer) { - let _ = self.remove(id); + let _ = self.remove_edit_layer(id); } } } diff --git a/crates/doc/src/document_guides.rs b/crates/doc/src/document_guides.rs index 510de3a4de..ca6b5ed882 100644 --- a/crates/doc/src/document_guides.rs +++ b/crates/doc/src/document_guides.rs @@ -1,6 +1,8 @@ use super::document_ext::free_id; use super::guides::Guide; +use super::limits::MAX_NEXT_ID; use super::{Affine, Arc, Document, Node, NodeId, NodeKind, PatternDef, Point, Rect, Selection, SymbolBoxes, Vec2, artboard_holds}; +use std::collections::{BTreeMap, HashSet}; impl Document { /// Union of the geometric bounds of `ids` (their visual bounds when `visual`), with symbol @@ -151,7 +153,7 @@ impl Document { rest.iter().any(|m| has(m, s, inside, true)) } NodeKind::Layer { children, .. } | NodeKind::Group { children, .. } => children.iter().any(|m| has(m, s, rect, exact)), - NodeKind::Path { guide: true, .. } => false, + NodeKind::Path { guide: true, .. } | NodeKind::RulerGuide { .. } => false, _ => art_box_meets(c, s, rect, exact), } } @@ -171,6 +173,195 @@ impl Document { art } + /// Put the ruler guide nodes a file holds where ruler guides go: directly in a layer or + /// sublayer, above its art. A ruler guide below art in a layer moves up to the layer's other + /// guides, keeping their order. A ruler guide anywhere else (at the top level, in a group or + /// another object, in opacity-mask art, in a symbol, a graph design or a pattern) is removed. + /// Returns how many were removed. + pub fn tidy_ruler_guides(&mut self) -> usize { + let mut gone = self.layers.len(); + self.layers.retain(|l| !l.is_ruler_guide()); + gone -= self.layers.len(); + for l in &mut self.layers { + if layer_untidy(l) { + gone += tidy_layer(Arc::make_mut(l)); + } + } + let others = self + .symbols + .iter_mut() + .map(|s| &mut s.art) + .chain(self.graph_designs.iter_mut().map(|g| &mut g.art)) + .chain(self.patterns.iter_mut().flat_map(|p| p.art.iter_mut())) + .chain(self.pattern_edit.iter_mut().filter_map(|pe| pe.original.as_mut()).flat_map(|p| p.art.iter_mut())); + for root in others { + if root.is_ruler_guide() { + *root = Arc::new(Node::group(root.id, vec![])); + gone += 1; + } else if any_guide(root) { + gone += drop_guides_below(Arc::make_mut(root)); + } + } + gone + } + + /// Make the saved ruler guides (read from a file: [`Document::guides_to_saved`]'s list) into + /// [`NodeKind::RulerGuide`] nodes, each at the top of its layer, in list order. A guide on a + /// layer or sublayer the document has goes on it; one on no layer, or on a layer that is gone, + /// goes on the top visible, unlocked layer ([`Self::default_layer`]), or on a new "Layer 1" + /// when there is none. A guide keeps its saved id when no other node, slice or guide has it, + /// and its name, lock and visibility. The saved list is emptied, so running this again does + /// nothing. Returns how many guides became nodes. + pub fn guides_into_nodes(&mut self) -> usize { + let saved = std::mem::take(&mut self.saved_guides); + if saved.is_empty() { + return 0; + } + let mut used = HashSet::new(); + self.walk(|n| { + used.insert(n.id); + }); + used.extend(self.slices.iter().map(|s| s.id)); + used.extend(self.assets.iter().map(|a| NodeId(a.id))); + let kept: Vec> = saved.iter().map(|g| g.id.filter(|id| id.0 > 0 && id.0 < MAX_NEXT_ID && used.insert(*id))).collect(); + if let Some(max) = kept.iter().flatten().map(|id| id.0).max() { + self.raise_next_id(max.saturating_add(1)); + } + let mut home = None; + // The guides of each layer, in list order. + let mut by_layer: BTreeMap>> = BTreeMap::new(); + let mut order = vec![]; + for (g, id) in saved.iter().zip(kept) { + let layer = match g.layer.filter(|l| self.node(*l).is_some_and(Node::is_layer)) { + Some(l) => l, + None => match home { + Some(h) => h, + None => { + let h = self.default_layer().unwrap_or_else(|| self.add_layer(None)); + home = Some(h); + h + } + }, + }; + let id = id.unwrap_or_else(|| self.alloc_id()); + let mut n = Node::new(id, NodeKind::RulerGuide { vertical: g.vertical, pos: g.pos, artboard: g.artboard }); + n.name = g.name.clone(); + n.locked = g.locked; + n.visible = !g.hidden; + if !by_layer.contains_key(&layer) { + order.push(layer); + } + by_layer.entry(layer).or_default().push(Arc::new(n)); + } + for layer in order { + let guides = by_layer.remove(&layer).unwrap_or_default(); + if let Ok(ch) = self.children_mut(Some(layer)) { + ch.extend(guides); + } + } + saved.len() + } + + /// Take the ruler guide nodes out of the layers and put them in the saved list, by ascending + /// id, each naming its layer, after any entries not yet made into nodes: the form a file keeps + /// them in, readable by versions from before ruler guides were nodes. For saving a copy of + /// the document; [`Self::guides_into_nodes`] reverses it. + pub fn guides_to_saved(&mut self) { + let guides = self.ruler_guides(); + if guides.is_empty() { + return; + } + for l in &mut self.layers { + if l.holds_ruler_guides() { + Arc::make_mut(l).strip_ruler_guides(); + } + } + self.saved_guides.extend(guides); + } + + /// Every ruler guide, by ascending id, with the layer or sublayer it is on, its id, name and + /// its own lock and visibility. A guide's place in this list is its index in the guide + /// commands. + pub fn ruler_guides(&self) -> Vec { + if let Some(listed) = self.guide_cache.get(&self.layers) { + return listed.as_ref().clone(); + } + fn visit(layer: &Node, out: &mut Vec) { + for c in layer.children().into_iter().flatten() { + if let Some(g) = guide_of(c, layer.id) { + out.push(g); + } else if c.is_layer() { + visit(c, out); + } + } + } + let mut out = vec![]; + for l in &self.layers { + visit(l, &mut out); + } + out.sort_by_key(|g| g.id); + self.guide_cache.set(&self.layers, &out); + out + } + + /// Ruler guide `id`, as [`Self::ruler_guides`] lists it. None when `id` is not a ruler guide + /// on a layer. + pub fn ruler_guide(&self, id: NodeId) -> Option { + let layer = self.parent_of(id)?; + guide_of(self.node(id)?, layer) + } + + /// Change ruler guide `id` with `f` (its line, its artboard; the layer, id, name, lock and + /// visibility fields are not read back). Returns false when `id` is not a ruler guide. + pub fn edit_ruler_guide(&mut self, id: NodeId, f: impl FnOnce(&mut Guide)) -> bool { + let Some(mut g) = self.ruler_guide(id) else { return false }; + f(&mut g); + let Some(n) = self.node_mut(id) else { return false }; + n.kind = NodeKind::RulerGuide { vertical: g.vertical, pos: g.pos, artboard: g.artboard }; + true + } + + /// Add ruler guide `g` as a node with a new id at the top of its layer: `g.layer` when the + /// document has that layer or sublayer, else the top visible, unlocked layer + /// ([`Self::default_layer`]), or a new "Layer 1" when there is none. Its name, lock and + /// visibility come from `g`. Returns the new id. + pub fn add_ruler_guide(&mut self, g: &Guide) -> NodeId { + let layer = match g.layer.filter(|l| self.node(*l).is_some_and(Node::is_layer)) { + Some(l) => l, + None => self.default_layer().unwrap_or_else(|| self.add_layer(None)), + }; + let id = self.alloc_id(); + let mut n = Node::new(id, NodeKind::RulerGuide { vertical: g.vertical, pos: g.pos, artboard: g.artboard }); + n.name = g.name.clone(); + n.locked = g.locked; + n.visible = !g.hidden; + if let Ok(ch) = self.children_mut(Some(layer)) { + ch.push(Arc::new(n)); + } + id + } + + /// Remove node `id`, the temporary layer of opacity-mask, symbol or pattern editing, keeping the + /// ruler guides on it and on its sublayers: they go to the top of the top visible, unlocked + /// layer left ([`Self::default_layer`]), or of a new "Layer 1" when no layer is left, where a + /// saved guide whose layer is gone opens. Returns the removed node, without the guides. + pub fn remove_edit_layer(&mut self, id: NodeId) -> Result, crate::DocError> { + let mut layer = self.remove(id)?; + if !layer.holds_ruler_guides() { + return Ok(layer); + } + let mut guides = vec![]; + layer.walk(&mut |c| { + if c.is_ruler_guide() { + guides.push(Arc::new(c.clone())); + } + }); + Arc::make_mut(&mut layer).strip_ruler_guides(); + let home = self.default_layer().unwrap_or_else(|| self.add_layer(None)); + self.children_mut(Some(home))?.extend(guides); + Ok(layer) + } + /// Where ruler guide `g` runs along its line (the y range of a vertical guide): across its /// artboard for an artboard guide, None (the whole canvas) for a canvas guide or one whose /// artboard is gone. @@ -179,79 +370,39 @@ impl Document { Some(if g.vertical { (r.y0, r.y1) } else { (r.x0, r.x1) }) } + /// The ruler guide node `g` names, if this document has it. + fn guide_node(&self, g: &Guide) -> Option { + g.id.filter(|id| self.node(*id).is_some_and(Node::is_ruler_guide)) + } + /// The layer (or sublayer) ruler guide `g` is on, if it is on one this document has. fn guide_layer(&self, g: &Guide) -> Option { g.layer.filter(|l| self.node(*l).is_some_and(Node::is_layer)) } - /// Is ruler guide `g` shown: on no layer, or on a layer that (with the layers around it) is - /// visible? A guide whose layer is gone shows as one on no layer. + /// Is ruler guide `g` shown: its node, and the layers it is on, visible? A guide that is not a + /// node of this document shows unless hidden itself or on a hidden layer. pub fn guide_shown(&self, g: &Guide) -> bool { - self.guide_layer(g).is_none_or(|l| self.is_visible(l)) - } - - /// Can ruler guide `g` be picked, moved and deleted: shown, and its layer unlocked? - pub fn guide_editable(&self, g: &Guide) -> bool { - self.guide_layer(g).is_none_or(|l| self.is_editable(l)) - } - - /// Put the ruler guides on no layer this document has (from files saved before guides had - /// layers, or of layers merged into `layer`) on layer `layer`. Returns how many. - pub fn rehome_guides(&mut self, layer: NodeId) -> usize { - let lost: Vec = self.guides.iter().map(|g| self.guide_layer(g).is_none()).collect(); - let mut n = 0; - for (g, lost) in self.guides.iter_mut().zip(lost) { - if lost { - g.layer = Some(layer); - n += 1; - } + match self.guide_node(g) { + Some(id) => self.is_visible(id), + None => !g.hidden && self.guide_layer(g).is_none_or(|l| self.is_visible(l)), } - n } - /// Put the ruler guides on no layer (from files saved before guides had layers), or on one that - /// is gone, on the top visible, unlocked layer ([`Self::default_layer`]). Returns how many. - pub fn adopt_guides(&mut self) -> usize { - self.default_layer().map_or(0, |home| self.rehome_guides(home)) + /// Can ruler guide `g` be picked, moved and deleted: shown, and neither it nor its layers + /// locked? + pub fn guide_editable(&self, g: &Guide) -> bool { + match self.guide_node(g) { + Some(id) => self.is_editable(id), + None => !g.hidden && !g.locked && self.guide_layer(g).is_none_or(|l| self.is_editable(l)), + } } - /// Delete the ruler guides whose layer was deleted (they go with it), keeping `sel`'s guides in - /// step. Guides on no layer stay. Returns how many went. - pub fn drop_guides_of_deleted_layers(&mut self, sel: &mut Selection) -> usize { - let gone: Vec = self.guides.iter().map(|g| g.layer.is_some() && self.guide_layer(g).is_none()).collect(); - self.retain_guides(sel, |i, _| !gone.get(i).copied().unwrap_or(false)) - } - - /// Copy the ruler guides of layer `from` and of its sublayers onto their copies in `copy` (made - /// from `from` by [`Self::reid`]: the same tree with fresh ids), as duplicating a layer copies - /// the objects on it. Returns how many. - pub fn copy_layer_guides(&mut self, from: &Node, copy: &Node) -> usize { - let layers = |n: &Node| { - let mut out = vec![]; - n.walk(&mut |c| { - if c.is_layer() { - out.push(c.id); - } - }); - out - }; - let pairs: Vec<(NodeId, NodeId)> = layers(from).into_iter().zip(layers(copy)).collect(); - let copies: Vec = self - .guides - .iter() - .filter_map(|g| { - let to = pairs.iter().find(|(a, _)| Some(*a) == g.layer)?.1; - Some(Guide { layer: Some(to), ..g.clone() }) - }) - .collect(); - let n = copies.len(); - self.guides.extend(copies); - n - } - - /// The ruler guides on layer `layer` (not its sublayers'), by index. - pub fn guides_on(&self, layer: NodeId) -> impl Iterator + '_ { - self.guides.iter().enumerate().filter(move |(_, g)| g.layer == Some(layer)) + /// The ruler guides directly on layer `layer` (not its sublayers'), by ascending id. + pub fn guides_on(&self, layer: NodeId) -> Vec { + let mut out: Vec = self.children(Some(layer)).into_iter().flatten().filter_map(|c| guide_of(c, layer)).collect(); + out.sort_by_key(|g| g.id); + out } /// Does ruler guide `g` run past `p` (up to `tol` beyond its ends)? @@ -260,45 +411,86 @@ impl Document { self.guide_span(g).is_none_or(|(a, b)| along >= a - tol && along <= b + tol) } - /// Keep the ruler guides `keep` accepts (by index), the selected ones left keeping their - /// place among the rest (the selected art stays selected). Returns how many went. - pub fn retain_guides(&mut self, sel: &mut Selection, keep: impl Fn(usize, &Guide) -> bool) -> usize { - let kept: Vec = self.guides.iter().enumerate().map(|(i, g)| keep(i, g)).collect(); - // Where each kept guide ends up. - let (mut to, mut n) = (Vec::with_capacity(kept.len()), 0); - for k in &kept { - to.push(k.then_some(n)); - n += usize::from(*k); + /// Keep the ruler guides `keep` accepts, deleting the others; `sel` forgets the deleted ones + /// (the selected art stays selected). Returns how many went. + pub fn retain_guides(&mut self, sel: &mut Selection, keep: impl Fn(&Guide) -> bool) -> usize { + let gone: HashSet = self.ruler_guides().into_iter().filter(|g| !keep(g)).filter_map(|g| g.id).collect(); + if gone.is_empty() { + return 0; + } + fn drop(layer: &mut Arc, gone: &HashSet) { + fn holds(n: &Node, gone: &HashSet) -> bool { + n.is_layer() && n.children().is_some_and(|ch| ch.iter().any(|c| gone.contains(&c.id) || holds(c, gone))) + } + if !holds(layer, gone) { + return; + } + let Some(ch) = Arc::make_mut(layer).children_mut() else { return }; + ch.retain(|c| !(c.is_ruler_guide() && gone.contains(&c.id))); + for c in ch.iter_mut().filter(|c| c.is_layer()) { + drop(c, gone); + } + } + for l in &mut self.layers { + drop(l, &gone); + } + sel.guides.retain(|id| !gone.contains(id)); + if sel.key_guide.is_some_and(|k| gone.contains(&k)) { + sel.key_guide = None; + } + gone.len() + } + + /// Change the line of every ruler guide on artboard `id` with `f` (whether it is vertical, its + /// position). + fn edit_artboard_guides(&mut self, id: u32, f: &mut impl FnMut(bool, &mut f64)) { + fn visit(layer: &mut Arc, id: u32, f: &mut impl FnMut(bool, &mut f64)) { + fn holds(n: &Node, id: u32) -> bool { + n.is_layer() + && n.children().is_some_and(|ch| { + ch.iter().any(|c| matches!(c.kind, NodeKind::RulerGuide { artboard: Some(a), .. } if a == id) || holds(c, id)) + }) + } + if !holds(layer, id) { + return; + } + let Some(ch) = Arc::make_mut(layer).children_mut() else { return }; + for c in ch.iter_mut() { + if matches!(c.kind, NodeKind::RulerGuide { artboard: Some(a), .. } if a == id) { + if let NodeKind::RulerGuide { vertical, pos, .. } = &mut Arc::make_mut(c).kind { + f(*vertical, pos); + } + } else if c.is_layer() { + visit(c, id, f); + } + } + } + for l in &mut self.layers { + visit(l, id, f); } - sel.guides = sel.guides.iter().filter_map(|i| to.get(*i).copied().flatten()).collect(); - sel.key_guide = sel.key_guide.and_then(|i| to.get(i).copied().flatten()); - let before = self.guides.len(); - let mut flags = kept.into_iter(); - self.guides.retain(|_| flags.next().unwrap_or(true)); - before - self.guides.len() } /// Move the guides of artboard `id` by `d` (along with their artboard). pub fn move_artboard_guides(&mut self, id: u32, d: Vec2) { - for g in self.guides.iter_mut().filter(|g| g.artboard == Some(id)) { - *g = g.moved(d); - } + self.edit_artboard_guides(id, &mut |vertical, pos| *pos += if vertical { d.x } else { d.y }); } /// Map the guides of artboard `id` through `xf`, an axis-aligned scale and move (Scale Artwork /// with Artboard). pub fn map_artboard_guides(&mut self, id: u32, xf: Affine) { - for g in self.guides.iter_mut().filter(|g| g.artboard == Some(id)) { - g.pos = if g.vertical { (xf * Point::new(g.pos, 0.0)).x } else { (xf * Point::new(0.0, g.pos)).y }; - } + self.edit_artboard_guides(id, &mut |vertical, pos| { + *pos = if vertical { (xf * Point::new(*pos, 0.0)).x } else { (xf * Point::new(0.0, *pos)).y }; + }); } /// Copy the guides of artboard `from` onto artboard `to`, `d` away (with a copy of their - /// artboard). + /// artboard), each on its guide's layer with a new id. pub fn copy_artboard_guides(&mut self, from: u32, to: u32, d: Vec2) { let copies: Vec = - self.guides.iter().filter(|g| g.artboard == Some(from)).map(|g| Guide { artboard: Some(to), ..g.moved(d) }).collect(); - self.guides.extend(copies); + self.ruler_guides().into_iter().filter(|g| g.artboard == Some(from)).map(|g| Guide { artboard: Some(to), ..g.moved(d) }).collect(); + for g in &copies { + self.add_ruler_guide(g); + } } /// An id for a new artboard: one no artboard has, above the ids given out before and the ids of @@ -367,3 +559,122 @@ impl Document { n } } + +/// Does `n`, or anything below it (opacity-mask art included), hold a ruler guide? +fn any_guide(n: &Node) -> bool { + let mut any = false; + crate::swatches::walk_with_masks(n, &mut |m| any |= m.is_ruler_guide()); + any +} + +/// Remove every ruler guide below `n`, in its members and its opacity-mask art (`n` itself +/// stays). Mask art that is a ruler guide becomes an empty group. Returns how many went. +fn drop_guides_below(n: &mut Node) -> usize { + let mut gone = drop_mask_guides(n); + if let Some(ch) = n.children_mut() { + let before = ch.len(); + ch.retain(|c| !c.is_ruler_guide()); + gone += before - ch.len(); + for c in ch.iter_mut().filter(|c| any_guide(c)) { + gone += drop_guides_below(Arc::make_mut(c)); + } + } + gone +} + +/// Remove every ruler guide in the opacity-mask art of `n`. Mask art that is a ruler guide +/// becomes an empty group. Returns how many went. +fn drop_mask_guides(n: &mut Node) -> usize { + let Some(mask) = n.extras_if_any().and_then(|e| e.mask.as_deref_mut()) else { return 0 }; + if mask.art.is_ruler_guide() { + mask.art = Arc::new(Node::group(mask.art.id, vec![])); + 1 + } else if any_guide(&mask.art) { + drop_guides_below(Arc::make_mut(&mut mask.art)) + } else { + 0 + } +} + +/// Does layer `l` hold a ruler guide out of place: below art, in an object or in mask art, or so +/// in one of its sublayers? +fn layer_untidy(l: &Node) -> bool { + let Some(ch) = l.children() else { return false }; + let start = Node::guide_run_start(ch); + l.extras().mask.as_deref().is_some_and(|m| any_guide(&m.art)) + || ch.iter().take(start).any(|c| c.is_ruler_guide() || if c.is_layer() { layer_untidy(c) } else { any_guide(c) }) +} + +/// [`Document::tidy_ruler_guides`] for layer `l`. Returns how many guides were removed. +fn tidy_layer(l: &mut Node) -> usize { + let mut gone = drop_mask_guides(l); + let Some(ch) = l.children_mut() else { return gone }; + for c in ch.iter_mut() { + if c.is_layer() { + if layer_untidy(c) { + gone += tidy_layer(Arc::make_mut(c)); + } + } else if !c.is_ruler_guide() && any_guide(c) { + gone += drop_guides_below(Arc::make_mut(c)); + } + } + if ch.iter().take(Node::guide_run_start(ch)).any(|c| c.is_ruler_guide()) { + let (guides, art): (Vec<_>, Vec<_>) = std::mem::take(ch).into_iter().partition(|c| c.is_ruler_guide()); + *ch = art; + ch.extend(guides); + } + gone +} + +/// Node `n`, on layer `layer`, as a [`Guide`] value, if it is a ruler guide. +fn guide_of(n: &Node, layer: NodeId) -> Option { + let NodeKind::RulerGuide { vertical, pos, artboard } = n.kind else { return None }; + Some(Guide { vertical, pos, artboard, layer: Some(layer), id: Some(n.id), name: n.name.clone(), locked: n.locked, hidden: !n.visible }) +} + +/// The list [`Document::ruler_guides`] last made, with weak pointers to the top-level layers it was +/// made from. While a weak pointer to a layer's allocation is held, nothing else can be at that +/// address, and the layer can't be changed in place (`Arc::get_mut` refuses it and `Arc::make_mut` +/// moves it), so any edit inside a layer, and any layer added, removed or replaced, gives a list of +/// layers the cache does not match. Clones of a document share the cached list. +#[derive(Default)] +pub(crate) struct GuideCache(std::sync::RwLock>); + +/// The top-level layers a guide list was made from, and the list. +type Listed = (Vec>, Arc>); + +impl GuideCache { + /// The cached list, when it was made from `layers`. + fn get(&self, layers: &[Arc]) -> Option>> { + let g = self.0.read().ok()?; + let (from, listed) = g.as_ref()?; + let same = from.len() == layers.len() && from.iter().zip(layers).all(|(w, l)| std::ptr::eq(w.as_ptr(), Arc::as_ptr(l))); + same.then(|| listed.clone()) + } + + /// Keep `listed`, made from `layers`. + fn set(&self, layers: &[Arc], listed: &[Guide]) { + if let Ok(mut g) = self.0.write() { + *g = Some((layers.iter().map(Arc::downgrade).collect(), Arc::new(listed.to_vec()))); + } + } +} + +impl Clone for GuideCache { + fn clone(&self) -> Self { + GuideCache(std::sync::RwLock::new(self.0.read().ok().and_then(|g| g.clone()))) + } +} + +/// A cache, not document content: two documents are equal whatever their caches hold. +impl PartialEq for GuideCache { + fn eq(&self, _: &Self) -> bool { + true + } +} + +impl std::fmt::Debug for GuideCache { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("GuideCache") + } +} diff --git a/crates/doc/src/guides.rs b/crates/doc/src/guides.rs index 08f828391b..a3fb9969eb 100644 --- a/crates/doc/src/guides.rs +++ b/crates/doc/src/guides.rs @@ -1,5 +1,8 @@ use super::{Deserialize, NodeId, Serialize, Vec2}; +/// A ruler guide as a value: an entry of the saved `guides` list, or a ruler guide node of a +/// document as [`crate::Document::ruler_guides`] lists it. In a document, ruler guides are +/// [`crate::NodeKind::RulerGuide`] nodes on their layers. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct Guide { /// true = vertical guide at `pos` (x), false = horizontal at `pos` (y). @@ -14,12 +17,25 @@ pub struct Guide { /// from before guides had layers, and from files that don't say), always there. #[serde(default, skip_serializing_if = "Option::is_none")] pub layer: Option, + /// The id of its node. None in an entry written before ruler guides were nodes: it gets a new + /// id when the document is read. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub id: Option, + /// The name it shows in the Layers panel, if it was given one. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub name: Option, + /// Locked on its own (its layer's lock is separate). + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub locked: bool, + /// Hidden on its own (its layer's visibility is separate). + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub hidden: bool, } impl Guide { /// A canvas guide. pub fn new(vertical: bool, pos: f64) -> Self { - Self { vertical, pos, artboard: None, layer: None } + Self { vertical, pos, artboard: None, layer: None, id: None, name: None, locked: false, hidden: false } } /// Moved by `d` (a vertical guide across, a horizontal one down). diff --git a/crates/doc/src/hit.rs b/crates/doc/src/hit.rs index 3412a7ff40..94fbb5ff52 100644 --- a/crates/doc/src/hit.rs +++ b/crates/doc/src/hit.rs @@ -588,7 +588,8 @@ fn may_hit(parent: &Node, i: usize, c: &Node, p: Point, opt: HitOptions, symbols if !c.visible || editable_only && (c.locked || c.is_template()) { return false; } - if opt.skip_guides && matches!(c.kind, NodeKind::Path { guide: true, .. }) { + // A ruler guide is picked by the guide commands, never by an object hit test. + if c.is_ruler_guide() || opt.skip_guides && matches!(c.kind, NodeKind::Path { guide: true, .. }) { return false; } // (An envelope's content sits where it was, not where the envelope draws it.) @@ -1256,7 +1257,7 @@ fn marquee_with(doc: &Document, r: Rect, scope: Option, leaves: bool, wh // marquee touches none of it, as clicks don't (#5309). Some(s) if !doc.is_editable(s.id) => vec![], Some(s) => match s.children() { - Some(c) => c.iter().enumerate().filter(|(i, _)| !shaper_hides(s, *i, scope)).map(|(_, c)| (c, r)).collect(), + Some(c) => c.iter().enumerate().filter(|(i, c)| !c.is_ruler_guide() && !shaper_hides(s, *i, scope)).map(|(_, c)| (c, r)).collect(), // A single object isolated: only it. None => doc .parent_of(s.id) @@ -1285,7 +1286,7 @@ fn marquee_with(doc: &Document, r: Rect, scope: Option, leaves: bool, wh if c.visible && !c.locked && !c.is_template() { out.extend(layer_objects(c, r)); } - } else { + } else if !c.is_ruler_guide() { out.push((c, r)); } } diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 1f555b74e3..41a684abde 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -207,8 +207,12 @@ pub struct Document { /// Object › Graph › Design…: art graphs can draw as markers. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub graph_designs: Vec, - #[serde(default)] - pub guides: Vec, + /// The saved form of the ruler guides: read from a file, then made into + /// [`NodeKind::RulerGuide`] nodes on their layers ([`Document::guides_into_nodes`]), and + /// written from those nodes when saving ([`Document::guides_to_saved`]). Empty in between. + /// Each entry read counts as an object against the read's node budget. + #[serde(default, rename = "guides", deserialize_with = "crate::node::counted_list")] + saved_guides: Vec, /// View → New View… (at most [`SavedView::MAX`]); saved with the document. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub views: Vec, @@ -351,6 +355,9 @@ pub struct Document { /// Where each node is in the tree, for lookups by id ([`id_index`]): a cache, never saved. #[serde(skip)] index: id_index::IdIndex, + /// The ruler guides as [`Document::ruler_guides`] lists them: a cache, never saved. + #[serde(skip)] + guide_cache: document_guides::GuideCache, } fn ppi72() -> f64 { diff --git a/crates/doc/src/live/expand.rs b/crates/doc/src/live/expand.rs index 66d20f27b6..1cc8ba0863 100644 --- a/crates/doc/src/live/expand.rs +++ b/crates/doc/src/live/expand.rs @@ -140,6 +140,8 @@ pub(super) fn map_node(n: &Node, w: &Warper) -> Node { } // Its art is plain, so it warps at this depth. NodeKind::PlacedDocument(_) => return map_node(&expanded_group_hooks(n, w.hooks), w), + // A ruler guide has no shape to warp. + NodeKind::RulerGuide { .. } => {} } out } diff --git a/crates/doc/src/mem.rs b/crates/doc/src/mem.rs index e431fc9d15..e2d27650b5 100644 --- a/crates/doc/src/mem.rs +++ b/crates/doc/src/mem.rs @@ -88,7 +88,7 @@ impl Document { } let mut n = 0; n += vec_bytes(&self.artboards) + self.artboards.iter().map(|a| a.name.capacity()).sum::(); - n += vec_bytes(&self.guides) + vec_bytes(&self.views) + vec_bytes(&self.slices) + vec_bytes(&self.assets); + n += vec_bytes(&self.saved_guides) + vec_bytes(&self.views) + vec_bytes(&self.slices) + vec_bytes(&self.assets); n += vec_bytes(&self.text_threads) + self.text_threads.iter().map(vec_bytes).sum::(); n += self.images.iter().map(|(k, b)| 4 * size_of::() + k.capacity() + b.mime.capacity() + size_of_val(b)).sum::(); n += map_bytes(&self.export_settings) + map_bytes(&self.extra) + self.print_setup.as_ref().map_or(0, json_bytes); diff --git a/crates/doc/src/node.rs b/crates/doc/src/node.rs index 774e824390..eeec6ab162 100644 --- a/crates/doc/src/node.rs +++ b/crates/doc/src/node.rs @@ -33,6 +33,7 @@ pub use live_shape::{LiveShape, Scaling}; pub use opacity::{Knockout, OpacityMask, whiten}; pub use ops::{flattens, keeps_gradient_fit}; use wire::NodeWire; +pub(crate) use wire::counted_list; pub use wire::{MAX_READ_NODES, with_node_budget}; fn yes() -> bool { diff --git a/crates/doc/src/node/clip.rs b/crates/doc/src/node/clip.rs index 208bdda7ff..cb66ba1148 100644 --- a/crates/doc/src/node/clip.rs +++ b/crates/doc/src/node/clip.rs @@ -19,7 +19,7 @@ impl Node { fn push_clip_shapes(&self, text: Outliner, out: &mut Vec<(BezPath, FillRule)>) { match &self.kind { - NodeKind::Path { guide: true, .. } | NodeKind::SymbolInstance { .. } => {} + NodeKind::Path { guide: true, .. } | NodeKind::RulerGuide { .. } | NodeKind::SymbolInstance { .. } => {} NodeKind::Path { path, rule, .. } => out.push((path.to_bezpath(), *rule)), NodeKind::Compound { children, rule } => { let mut bp = BezPath::new(); diff --git a/crates/doc/src/node/kind.rs b/crates/doc/src/node/kind.rs index 22470cbf3e..7f9e21a0ff 100644 --- a/crates/doc/src/node/kind.rs +++ b/crates/doc/src/node/kind.rs @@ -122,6 +122,17 @@ pub enum NodeKind { Intertwine { children: Vec>, }, + /// A ruler guide: a straight line across the canvas, or across one artboard, that draws on + /// screen only. It is always a direct child of a layer or sublayer, above the layer's other + /// children, and has no bounds. + RulerGuide { + /// true: a vertical guide at x = `pos`; false: a horizontal one at y = `pos`. + vertical: bool, + pos: f64, + /// The [`crate::Artboard::id`] of the artboard it runs across; None: across the canvas. + #[serde(default, skip_serializing_if = "Option::is_none")] + artboard: Option, + }, } /// Reading a [`NodeKind`]. Derived, an internally tagged enum buffers each kind (with every @@ -215,6 +226,12 @@ mod kind_de { Intertwine { children: Vec>, }, + RulerGuide { + vertical: bool, + pos: f64, + #[serde(default)] + artboard: Option, + }, } impl<'de> Deserialize<'de> for NodeKind { diff --git a/crates/doc/src/node/ops.rs b/crates/doc/src/node/ops.rs index eb7e0d39b9..415b2946e3 100644 --- a/crates/doc/src/node/ops.rs +++ b/crates/doc/src/node/ops.rs @@ -80,6 +80,16 @@ impl Node { matches!(self.kind, NodeKind::Layer { .. }) } + /// A ruler guide ([`NodeKind::RulerGuide`]). + pub fn is_ruler_guide(&self) -> bool { + matches!(self.kind, NodeKind::RulerGuide { .. }) + } + + /// A guide of either kind: a ruler guide, or a path made into a guide. + pub fn is_guide(&self) -> bool { + matches!(self.kind, NodeKind::RulerGuide { .. } | NodeKind::Path { guide: true, .. }) + } + /// A template layer (Layer Options → Template). pub fn is_template(&self) -> bool { matches!(self.kind, NodeKind::Layer { template: true, .. }) @@ -91,7 +101,7 @@ impl Node { pub fn layer_art(&self, editable: bool) -> Vec { fn visit(n: &Node, editable: bool, out: &mut Vec) { for c in n.children().into_iter().flatten() { - if editable && (!c.visible || c.locked || c.is_template()) { + if c.is_ruler_guide() || (editable && (!c.visible || c.locked || c.is_template())) { continue; } if c.is_layer() { @@ -142,6 +152,7 @@ impl Node { NodeKind::Repeat(r) => r.kind.label(), NodeKind::PlacedDocument(_) => "Placed Document", NodeKind::Intertwine { .. } => "Intertwine", + NodeKind::RulerGuide { .. } => "Guide", } } @@ -310,6 +321,8 @@ impl Node { NodeKind::PlacedDocument(p) => Some(p.bounds()), // The members drawn over the others are drawn where they are. NodeKind::Intertwine { children } => union(children), + // A ruler guide runs across the canvas (or an artboard): it adds nothing to a box. + NodeKind::RulerGuide { .. } => None, } } @@ -522,6 +535,9 @@ impl Node { } NodeKind::Mesh(m) => m.transform(a), NodeKind::Repeat(r) => r.transform(a, *sc), + // A ruler guide stays where it is when the art around it (its layer) is transformed: + // the guide commands move it. + NodeKind::RulerGuide { .. } => {} } // The frames of the objects attached to a path move with them, and the distances along the // path follow the path as it is now (a live shape's path is regenerated). @@ -557,10 +573,10 @@ impl Node { } } - /// Count of nodes in this subtree. + /// Count of nodes in this subtree, ruler guides left out. pub fn count(&self) -> usize { let mut n = 0; - self.walk(&mut |_| n += 1); + self.walk(&mut |c| n += usize::from(!c.is_ruler_guide())); n } @@ -569,7 +585,7 @@ impl Node { pub fn count_with_masks(&self) -> usize { let mut n = 0; self.walk(&mut |c| { - n += 1; + n += usize::from(!c.is_ruler_guide()); if let Some(m) = &c.extras().mask { n += m.art.count_with_masks(); } @@ -688,7 +704,7 @@ impl Node { /// groups ([`Self::passes_knockout_through`]) contribute their children instead of themselves. pub fn knockout_elements(children: &[Arc]) -> Vec<&Arc> { fn push<'a>(children: &'a [Arc], out: &mut Vec<&'a Arc>) { - for c in children.iter().filter(|c| c.visible) { + for c in children.iter().filter(|c| c.visible && !c.is_ruler_guide()) { match c.children() { Some(ch) if c.passes_knockout_through() => push(ch, out), _ => out.push(c), @@ -701,8 +717,62 @@ impl Node { } /// Whether this is a clip group or a layer with a clipping mask: its first child clips the rest. + /// A layer whose first child is a ruler guide holds no art, so a guide never clips it. pub fn clips(&self) -> bool { - matches!(self.kind, NodeKind::Group { clip: true, .. } | NodeKind::Layer { clip: true, .. }) + match &self.kind { + NodeKind::Group { clip, .. } => *clip, + NodeKind::Layer { clip, children, .. } => *clip && children.first().is_none_or(|c| !c.is_ruler_guide()), + _ => false, + } + } + + /// Where the ruler guides at the end of `children` (a layer's) begin: the index past the last + /// child that is not one. Art goes below this index and ruler guides at or above it. + pub fn guide_run_start(children: &[Arc]) -> usize { + children.iter().rposition(|c| !c.is_ruler_guide()).map_or(0, |i| i + 1) + } + + /// Remove the ruler guides of this layer and of its sublayers. Other nodes hold none. + pub fn strip_ruler_guides(&mut self) { + if !self.holds_ruler_guides() { + return; + } + if let Some(ch) = self.children_mut() { + ch.retain(|c| !c.is_ruler_guide()); + for c in ch.iter_mut().filter(|c| c.holds_ruler_guides()) { + Arc::make_mut(c).strip_ruler_guides(); + } + } + } + + /// Call `f` on the position (along the axis it crosses) of each ruler guide of this layer and + /// of its sublayers, with whether the guide is vertical. Only layers that hold one are copied. + pub fn map_ruler_guide_positions(&mut self, f: &mut impl FnMut(bool, &mut f64)) { + if !self.holds_ruler_guides() { + return; + } + if let Some(ch) = self.children_mut() { + for c in ch.iter_mut().filter(|c| c.is_ruler_guide() || c.holds_ruler_guides()) { + let c = Arc::make_mut(c); + match &mut c.kind { + NodeKind::RulerGuide { vertical, pos, .. } => f(*vertical, pos), + _ => c.map_ruler_guide_positions(f), + } + } + } + } + + /// Whether this layer or one of its sublayers holds a ruler guide. + pub fn holds_ruler_guides(&self) -> bool { + self.is_layer() && self.children().is_some_and(|ch| ch.iter().any(|c| c.is_ruler_guide() || c.holds_ruler_guides())) + } + + /// The index that an insert into `children` at `index` uses for `node`: art stays below the + /// layer's ruler guides and a ruler guide goes among them. + pub fn insert_index(children: &[Arc], index: usize, node: &Node) -> usize { + let start = Self::guide_run_start(children); + let i = index.min(children.len()); + if node.is_ruler_guide() { i.max(start) } else { i.min(start) } } /// Make this group or layer clip its children by its first child, or stop it. diff --git a/crates/doc/src/node/wire.rs b/crates/doc/src/node/wire.rs index b54d3f4b84..2722e7bee0 100644 --- a/crates/doc/src/node/wire.rs +++ b/crates/doc/src/node/wire.rs @@ -145,9 +145,10 @@ fn count_one() -> Result<(), E> { } /// A list read from a file (a member's Intertwine areas of tier 2 and up, an instance's symbol -/// overrides), each item counting against [`with_node_budget`] as an object does: a compressed -/// file of repeated empty items stays small while they take memory once read. -fn counted_list<'de, D: serde::Deserializer<'de>, T: Deserialize<'de>>(d: D) -> Result, D::Error> { +/// overrides, a document's saved ruler guides), each item counting against [`with_node_budget`] +/// as an object does: a compressed file of repeated empty items stays small while they take +/// memory once read. +pub(crate) fn counted_list<'de, D: serde::Deserializer<'de>, T: Deserialize<'de>>(d: D) -> Result, D::Error> { struct Items(std::marker::PhantomData); impl<'de, T: Deserialize<'de>> serde::de::Visitor<'de> for Items { type Value = Vec; diff --git a/crates/doc/src/pattern.rs b/crates/doc/src/pattern.rs index 2a98df62ce..d1333bef0b 100644 --- a/crates/doc/src/pattern.rs +++ b/crates/doc/src/pattern.rs @@ -584,6 +584,8 @@ impl<'a> NestedCount<'a> { (NodeKind::Repeat(r), _) => r.in_all(self), (NodeKind::SymbolInstance { symbol, .. }, _) => Cost { instances: self.symbol(symbol), copies: 1 }, (NodeKind::Path { path, .. }, _) => Cost { instances: 0, copies: path.anchor_count().saturating_add(1) }, + // A ruler guide draws nothing, so a repeat makes no copies of it. + (NodeKind::RulerGuide { .. }, _) => Cost::default(), (_, Some(c)) => self.nodes(c).plus(Cost { instances: 0, copies: 1 }), _ => Cost { instances: 0, copies: 1 }, }; diff --git a/crates/doc/src/selection.rs b/crates/doc/src/selection.rs index 643f81c365..c514aab996 100644 --- a/crates/doc/src/selection.rs +++ b/crates/doc/src/selection.rs @@ -39,16 +39,16 @@ pub struct Selection { /// clears them. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub slices: Vec, - /// Ruler guides selected with a selection tool, as indexes into [`Document::guides`]. A new + /// Ruler guides selected with a selection tool, by node id ([`Document::ruler_guides`]). A new /// selection ([`Self::set`]) clears them; Shift-adding or -removing objects keeps them, so /// guides and objects can be selected together, in either order. #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub guides: Vec, - /// A selected ruler guide that is the key object for Align instead of [`Self::key`] (an index - /// into [`Document::guides`], one of [`Self::guides`]): the objects line up on it. Never set - /// with `key`; [`Self::prune`] clears it when no object is selected to align to it. + pub guides: Vec, + /// A selected ruler guide that is the key object for Align instead of [`Self::key`] (one of + /// [`Self::guides`]): the objects line up on it. Never set with `key`; [`Self::prune`] clears + /// it when no object is selected to align to it. #[serde(default, skip_serializing_if = "Option::is_none")] - pub key_guide: Option, + pub key_guide: Option, /// The new art on a locked artboard that this selection keeps editable /// ([`Document::new_art`]). Undo, Redo and a failed batch restore it with the selection, and /// the engine then sets the document's from it. Editing state, never saved. @@ -139,10 +139,10 @@ impl Selection { } } } - /// Select ruler guides `indexes` (see [`Selection::guides`]) and nothing else. - pub fn set_guides(&mut self, indexes: impl IntoIterator) { + /// Select ruler guides `ids` (see [`Selection::guides`]) and nothing else. + pub fn set_guides(&mut self, ids: impl IntoIterator) { self.clear(); - for i in indexes { + for i in ids { if !self.guides.contains(&i) { self.guides.push(i); } @@ -150,7 +150,7 @@ impl Selection { } /// Add ruler guide `i` to the selection, or take it out, keeping the selected objects (a /// Shift-click on a guide). - pub fn toggle_guide(&mut self, i: usize) { + pub fn toggle_guide(&mut self, i: NodeId) { self.symbol_part = None; if let Some(k) = self.guides.iter().position(|g| *g == i) { self.guides.remove(k); @@ -201,7 +201,9 @@ impl Selection { pub fn prune(&mut self, doc: &Document) { // Every id this looks up, found in one walk of the tree. let ids = self.objects.iter().chain(self.anchors.keys()).chain(self.segments.keys()).copied().chain(self.key).chain(self.target); - let found = doc.index_paths(ids); + let mut found = doc.index_paths(ids); + // A ruler guide is selected in `guides` only, never as an object, an anchor or the key. + found.retain(|_, p| !doc.node_at(p).is_some_and(|n| n.is_ruler_guide())); self.objects.retain(|id| found.contains_key(id)); self.anchors.retain(|id, _| found.contains_key(id)); self.segments.retain(|id, refs| { @@ -209,8 +211,12 @@ impl Selection { refs.retain(|&(si, seg)| path.subpaths.get(si).is_some_and(|sp| seg < sp.segment_count())); !refs.is_empty() && self.objects.contains(id) && self.anchors.contains_key(id) }); - // Guides on a hidden or locked layer can't stay selected. - self.guides.retain(|i| doc.guides.get(*i).is_some_and(|g| doc.guide_editable(g))); + // Guides that are gone, or hidden or locked (themselves or by their layer), can't stay + // selected. + if !self.guides.is_empty() { + let editable = doc.editable(); + self.guides.retain(|id| doc.node(*id).is_some_and(|n| n.is_ruler_guide()) && editable(*id)); + } // The key object stays while another selected object lies outside it, or while ruler // guides are selected for it to line up (#2143). let keep = self.key.and_then(|k| found.get(&k)).and_then(|p| doc.node_at(p)).is_some_and(|n| { diff --git a/crates/doc/src/selection/tests.rs b/crates/doc/src/selection/tests.rs index 7882fef731..d0d81faee6 100644 --- a/crates/doc/src/selection/tests.rs +++ b/crates/doc/src/selection/tests.rs @@ -20,25 +20,30 @@ fn basic_ops() { fn guides_are_selected_on_their_own() { let mut s = Selection::default(); s.add(NodeId(1)); - s.set_guides([2, 0, 2]); - assert_eq!((s.objects.clone(), s.guides.clone()), (vec![], vec![2, 0])); + let (g0, g2) = (NodeId(10), NodeId(12)); + s.set_guides([g2, g0, g2]); + assert_eq!((s.objects.clone(), s.guides.clone()), (vec![], vec![g2, g0])); assert!(s.has_objects_or_guides() && s.is_empty()); // Shift-adding an object keeps them; a new selection doesn't. s.add(NodeId(1)); - assert_eq!(s.guides, vec![2, 0]); + assert_eq!(s.guides, vec![g2, g0]); s.set([NodeId(1)]); assert!(s.guides.is_empty()); // Toggled, a guide joins (or leaves) the selected objects. - s.toggle_guide(0); - assert_eq!((s.objects.clone(), s.guides.clone()), (vec![NodeId(1)], vec![0])); - s.toggle_guide(0); + s.toggle_guide(g0); + assert_eq!((s.objects.clone(), s.guides.clone()), (vec![NodeId(1)], vec![g0])); + s.toggle_guide(g0); assert_eq!((s.objects.clone(), s.guides.clone()), (vec![NodeId(1)], vec![])); - // Pruning drops guides the document no longer has. + // Pruning drops guides the document no longer has, ids of other objects, and guides that are + // locked or hidden themselves. let mut d = Document::new(100.0, 100.0); - d.guides.push(crate::Guide::new(true, 10.0)); - s.set_guides([0, 1]); + let layer = d.layers[0].id; + let kept = d.add_ruler_guide(&crate::Guide::new(true, 10.0)); + let locked = d.add_ruler_guide(&crate::Guide { locked: true, ..crate::Guide::new(true, 20.0) }); + let hidden = d.add_ruler_guide(&crate::Guide { hidden: true, ..crate::Guide::new(true, 30.0) }); + s.set_guides([kept, NodeId(9999), layer, locked, hidden]); s.prune(&d); - assert_eq!(s.guides, vec![0]); + assert_eq!(s.guides, vec![kept]); } /// A picked object of a dynamic symbol's instance stays picked while it and the groups and layers @@ -131,16 +136,26 @@ fn guides_show_and_lock_with_their_layer() { assert!(d.guide_shown(&crate::Guide::new(false, 5.0))); let gone = crate::Guide { layer: Some(NodeId(9999)), ..crate::Guide::new(true, 1.0) }; assert!(d.guide_shown(&gone) && d.guide_editable(&gone)); - d.guides.push(g); - assert_eq!(d.guides_on(layer).count(), 1); - // Guides on no layer, or a gone one, are adopted by the top visible, unlocked layer. + // As a node, a guide shows and locks with its layer and on its own. + let id = d.add_ruler_guide(&g); + let node = d.ruler_guide(id).unwrap(); + assert_eq!(d.guides_on(layer), std::slice::from_ref(&node)); + assert!(!d.guide_shown(&node) && !d.guide_editable(&node), "its layer is hidden"); d.node_mut(layer).unwrap().visible = true; d.node_mut(layer).unwrap().locked = false; - d.guides.push(crate::Guide::new(false, 5.0)); - d.guides.push(gone); - assert_eq!(d.adopt_guides(), 2); - assert!(d.guides.iter().all(|g| g.layer == Some(layer))); - assert_eq!(d.adopt_guides(), 0, "once"); + assert!(d.guide_shown(&node) && d.guide_editable(&node)); + d.node_mut(id).unwrap().locked = true; + let node = d.ruler_guide(id).unwrap(); + assert!(d.guide_shown(&node) && !d.guide_editable(&node), "locked itself"); + d.node_mut(id).unwrap().visible = false; + assert!(!d.guide_shown(&node), "hidden itself"); + // Saved guides on no layer, or a gone one, go on the top visible, unlocked layer. + d.saved_guides.push(crate::Guide::new(false, 5.0)); + d.saved_guides.push(gone); + assert_eq!(d.guides_into_nodes(), 2); + assert!(d.ruler_guides().iter().all(|g| g.layer == Some(layer))); + assert_eq!(d.ruler_guides().len(), 3); + assert_eq!(d.guides_into_nodes(), 0, "once"); // A guide saved before guides had layers reads as one on no layer. let old: crate::Guide = serde_json::from_str(r#"{"vertical": true, "pos": 5.0}"#).unwrap(); assert_eq!(old.layer, None); @@ -267,7 +282,7 @@ fn random_selection(r: &mut u64, ids: &[NodeId]) -> Selection { s.key = s.objects.get(below(r, s.objects.len() as u64) as usize).copied(); s.target = ids.get(below(r, ids.len() as u64) as usize).copied().filter(|_| below(r, 2) == 0); s.slices = ids.iter().copied().take(below(r, 3) as usize).collect(); - s.guides = vec![1, 0]; + s.guides = vec![NodeId(1), NodeId(0)]; s } diff --git a/crates/doc/src/symbol_nine_slice.rs b/crates/doc/src/symbol_nine_slice.rs index 995445f578..6949aa86fb 100644 --- a/crates/doc/src/symbol_nine_slice.rs +++ b/crates/doc/src/symbol_nine_slice.rs @@ -492,7 +492,8 @@ impl NineSliceMap { /// [`Self::most_placed`] for a container placed by [`Self::container`]. fn most_container(&self, n: &Node, boxes: &SymbolBoxes, depth: u32) -> u64 { let own = self.most_mask(n, boxes, depth).saturating_add(1); - n.children().into_iter().flatten().fold(own, |sum, c| sum.saturating_add(self.most_at(c, boxes, depth + 1))) + // As [`Self::container`] places it: a layer's ruler guides left out. + n.children().into_iter().flatten().filter(|c| !c.is_ruler_guide()).fold(own, |sum, c| sum.saturating_add(self.most_at(c, boxes, depth + 1))) } /// [`Self::most_placed`] for the opacity mask of `n` as [`Self::place_mask`] places it: a @@ -560,7 +561,8 @@ impl NineSliceMap { let kids = out.children_mut().map(std::mem::take).unwrap_or_default(); out.transform(center, false); if let Some(children) = out.children_mut() { - *children = kids.iter().map(|c| Arc::new(self.place_at(c, boxes, depth + 1))).collect(); + // A ruler guide of a layer is not art: it is not placed. + *children = kids.iter().filter(|c| !c.is_ruler_guide()).map(|c| Arc::new(self.place_at(c, boxes, depth + 1))).collect(); } self.place_mask(n, &mut out, boxes, depth); out diff --git a/crates/doc/src/tests.rs b/crates/doc/src/tests.rs index 21fe8d7ce9..dcbcd87028 100644 --- a/crates/doc/src/tests.rs +++ b/crates/doc/src/tests.rs @@ -91,6 +91,268 @@ fn an_inch_ruler_steps_in_binary_fractions() { assert!([0.1, 0.2, 0.5, 1.0, 2.0, 5.0, 10.0, 25.0].contains(&step(Unit::Millimeters, 5.0)), "{}", step(Unit::Millimeters, 5.0)); } +/// Ruler guide nodes on a layer and a sublayer are not art: Select All, a layer's target circle, +/// the object count, the art's bounds, clicks, the marquee and a knockout layer's elements are +/// the same as without them, and transforming the layer leaves them where they are. +#[test] +fn ruler_guide_nodes_are_not_art() { + use crate::hit::{HitOptions, hit_test, marquee, objects_at}; + let (mut d, a, b) = doc_with_rects(); + let layer = d.layers[0].id; + let sub = d.alloc_id(); + d.insert(Some(layer), usize::MAX, Node::layer(sub, "Sub", LayerColor::Preset(2))).unwrap(); + let s1 = d.alloc_id(); + d.insert(Some(sub), usize::MAX, Node::path(s1, shapes::rectangle(Rect::new(40.0, 0.0, 50.0, 10.0)), Appearance::default_art())).unwrap(); + let opt = HitOptions::default(); + let all = Rect::new(-100.0, -100.0, 700.0, 900.0); + let before = ( + d.selectable_art(), + d.layers[0].layer_art(false), + d.node_count(), + d.art_bounds(), + marquee(&d, all, None, false), + hit_test(&d, Point::new(5.0, 5.0), opt).map(|h| h.top_object(None)), + ); + let guide = |id, vertical, pos| Node::new(id, NodeKind::RulerGuide { vertical, pos, artboard: None }); + let (g1, g2, g3) = (d.alloc_id(), d.alloc_id(), d.alloc_id()); + d.insert(Some(layer), usize::MAX, guide(g1, true, 5.0)).unwrap(); + d.insert(Some(layer), usize::MAX, guide(g2, false, 5.0)).unwrap(); + d.insert(Some(sub), usize::MAX, guide(g3, true, 45.0)).unwrap(); + let after = ( + d.selectable_art(), + d.layers[0].layer_art(false), + d.node_count(), + d.art_bounds(), + marquee(&d, all, None, false), + hit_test(&d, Point::new(5.0, 5.0), opt).map(|h| h.top_object(None)), + ); + assert_eq!(after, before); + assert_eq!(after.0, [a, b, s1]); + for p in [Point::new(5.0, 300.0), Point::new(300.0, 5.0), Point::new(45.0, 300.0)] { + assert!(hit_test(&d, p, opt).is_none(), "nothing to click on the guide at {p:?}"); + assert!(objects_at(&d, p, opt, None).is_empty()); + } + let mut sel = Selection::default(); + sel.set_target(&d, layer); + assert_eq!(sel.objects, [a, b, s1]); + let children = d.layers[0].children().unwrap().clone(); + assert_eq!(Node::knockout_elements(&children).iter().map(|c| c.id).collect::>(), [a, b, s1]); + assert!(d.node(g1).is_some_and(Node::is_ruler_guide) && d.node(g1).is_some_and(Node::is_guide)); + assert_eq!(d.node(g1).map(Node::geometric_bounds), Some(None)); + let mut moved = (*d.layers[0]).clone(); + moved.transform(Affine::translate((10.0, 10.0)), true); + let g = moved.children().unwrap().iter().find(|c| c.id == g1).unwrap(); + assert!(matches!(g.kind, NodeKind::RulerGuide { pos, .. } if pos == 5.0)); +} + +/// Art inserted or moved onto a layer goes below the layer's ruler guides, a ruler guide goes +/// among them, and a clipped layer whose art is gone does not clip by a guide. +#[test] +fn layer_art_stays_below_its_ruler_guides() { + let (mut d, a, b) = doc_with_rects(); + let layer = d.layers[0].id; + let guide = |id, pos| Node::new(id, NodeKind::RulerGuide { vertical: true, pos, artboard: None }); + let (g1, g2) = (d.alloc_id(), d.alloc_id()); + d.insert(Some(layer), usize::MAX, guide(g1, 5.0)).unwrap(); + // Asked for the bottom, a guide still goes above the art. + d.insert(Some(layer), 0, guide(g2, 7.0)).unwrap(); + let ids = |d: &Document| d.layers[0].children().unwrap().iter().map(|c| c.id).collect::>(); + assert_eq!(ids(&d), [a, b, g2, g1]); + let c = d.alloc_id(); + d.insert(Some(layer), usize::MAX, Node::path(c, shapes::rectangle(Rect::new(0.0, 0.0, 5.0, 5.0)), Appearance::default_art())).unwrap(); + assert_eq!(ids(&d), [a, b, c, g2, g1]); + d.move_node(a, Some(layer), usize::MAX).unwrap(); + assert_eq!(ids(&d), [b, c, a, g2, g1]); + d.move_node(g1, Some(layer), 0).unwrap(); + assert_eq!(ids(&d), [b, c, a, g1, g2]); + assert!(d.layer_object(g1).is_none()); + assert_eq!(d.layer_object(a).map(|n| n.id), Some(a)); + + // A clipped layer clips by its first art object; with only guides left it clips nothing. + if let Some(NodeKind::Layer { clip, .. }) = d.node_mut(layer).map(|n| &mut n.kind) { + *clip = true; + } + assert!(d.node(layer).is_some_and(Node::clips)); + for id in [a, b, c] { + d.remove(id).unwrap(); + } + assert!(!d.node(layer).is_some_and(Node::clips)); + + // Reading a file that clips a layer by a path beyond the canvas empties the layer of its art + // only: its guides stay. + let far = d.alloc_id(); + d.insert(Some(layer), 0, Node::path(far, shapes::rectangle(Rect::new(0.0, 0.0, 1e9, 10.0)), Appearance::default_art())).unwrap(); + assert!(d.node(layer).is_some_and(Node::clips)); + let gone = d.drop_beyond_canvas(); + assert_eq!(gone.objects, 1); + assert_eq!(ids(&d), [g1, g2]); +} + +/// `ruler_guides` keeps its list while the layers are unchanged, and lists again after any change +/// inside a layer, made in place or on a shared copy, and after layers are added, removed or +/// reordered. +#[test] +fn ruler_guides_follow_every_edit() { + let (mut d, _, _) = doc_with_rects(); + let layer = d.layers[0].id; + let sub = d.alloc_id(); + d.insert(Some(layer), usize::MAX, Node::layer(sub, "Sub", LayerColor::Preset(1))).unwrap(); + let g1 = d.add_ruler_guide(&Guide { layer: Some(layer), ..Guide::new(true, 5.0) }); + let g2 = d.add_ruler_guide(&Guide { layer: Some(sub), ..Guide::new(false, 6.0) }); + let line = |d: &Document| d.ruler_guides().iter().map(|g| (g.id, g.pos, g.hidden, g.layer)).collect::>(); + assert_eq!(line(&d), [(Some(g1), 5.0, false, Some(layer)), (Some(g2), 6.0, false, Some(sub))]); + // Changed in place: the document is the only holder of its layers. + assert!(d.edit_ruler_guide(g2, |g| g.pos = 9.0)); + assert_eq!(line(&d)[1], (Some(g2), 9.0, false, Some(sub))); + d.node_mut(g1).unwrap().visible = false; + assert_eq!(line(&d)[0], (Some(g1), 5.0, true, Some(layer))); + // A copy shares the list until one of them changes. + let copy = d.clone(); + assert_eq!(line(©), line(&d)); + d.remove(g1).unwrap(); + assert_eq!(line(&d), [(Some(g2), 9.0, false, Some(sub))]); + assert_eq!(line(©).len(), 2); + // A new layer with a guide, then the layers reordered and one removed. + let top = d.add_layer(None); + let g3 = d.add_ruler_guide(&Guide { layer: Some(top), ..Guide::new(true, 1.0) }); + assert_eq!(line(&d).len(), 2); + d.layers.reverse(); + assert_eq!(line(&d), [(Some(g2), 9.0, false, Some(sub)), (Some(g3), 1.0, false, Some(top))]); + d.layers.retain(|l| l.id != layer); + assert_eq!(line(&d), [(Some(g3), 1.0, false, Some(top))]); +} + +#[test] +fn strip_ruler_guides_leaves_a_layers_art() { + let (mut d, a, b) = doc_with_rects(); + let layer = d.layers[0].id; + let guide = |id| Node::new(id, NodeKind::RulerGuide { vertical: false, pos: 3.0, artboard: None }); + let sub = d.alloc_id(); + d.insert(Some(layer), usize::MAX, Node::layer(sub, "Sub", LayerColor::Preset(1))).unwrap(); + let (g1, g2) = (d.alloc_id(), d.alloc_id()); + d.insert(Some(layer), usize::MAX, guide(g1)).unwrap(); + d.insert(Some(sub), usize::MAX, guide(g2)).unwrap(); + let mut n = (*d.layers[0]).clone(); + assert!(n.holds_ruler_guides()); + n.strip_ruler_guides(); + assert!(!n.holds_ruler_guides()); + let mut left = vec![]; + n.walk(&mut |c| left.push(c.id)); + assert_eq!(left, [layer, a, b, sub]); + // Other nodes hold none and are left as they are. + let mut p = d.node(a).unwrap().clone(); + p.strip_ruler_guides(); + assert_eq!(&p, d.node(a).unwrap()); +} + +/// The saved `guides` list becomes ruler guide nodes and is written back from them unchanged: +/// guides on a layer, a sublayer, no layer and a deleted layer, with an artboard guide, keep their +/// order, ids, names and their own lock and visibility; a saved id that another node has is +/// replaced; and the saved form of an older entry still reads. +#[test] +fn saved_guides_become_nodes_and_are_saved_back_the_same() { + let (mut d, a, _) = doc_with_rects(); + let layer = d.layers[0].id; + let top = d.add_layer(Some("Top")); + let sub = d.alloc_id(); + d.insert(Some(layer), usize::MAX, Node::layer(sub, "Sub", LayerColor::Preset(1))).unwrap(); + let json = serde_json::json!([ + {"vertical": true, "pos": 10.0, "layer": layer}, + {"vertical": false, "pos": 20.0, "layer": sub, "artboard": 1}, + {"vertical": true, "pos": 30.0}, + {"vertical": false, "pos": 40.0, "layer": 9999}, + {"vertical": true, "pos": 50.0, "layer": layer, "id": 500, "name": "Edge", "locked": true, "hidden": true}, + {"vertical": true, "pos": 60.0, "layer": layer, "id": a}, + ]); + d.saved_guides = serde_json::from_value(json).unwrap(); + let before = d.peek_next_id(); + assert_eq!(d.guides_into_nodes(), 6); + assert!(d.saved_guides.is_empty()); + let guides = d.ruler_guides(); + let lines: Vec<(f64, Option)> = guides.iter().map(|g| (g.pos, g.layer)).collect(); + // A guide on no layer, or on a deleted one, goes on the top visible, unlocked layer. + assert_eq!(lines[..4], [(50.0, Some(layer)), (10.0, Some(layer)), (20.0, Some(sub)), (30.0, Some(top))], "by id: the kept id first"); + assert_eq!(lines[4..], [(40.0, Some(top)), (60.0, Some(layer))]); + assert_eq!(guides[0].id, Some(NodeId(500))); + assert!(guides[1..].iter().all(|g| g.id.unwrap().0 > 500), "new ids start above a kept one"); + assert_ne!(guides[5].id, Some(a), "an id another object has is not reused"); + assert!(d.peek_next_id() > 500 && before <= 500); + let edge = d.node(NodeId(500)).unwrap(); + assert!(edge.locked && !edge.visible && edge.name.as_deref() == Some("Edge")); + assert_eq!(guides[2].artboard, Some(1)); + // Each layer's guides sit at its top, above its art and its sublayer. + let kids: Vec = d.node(layer).unwrap().children().unwrap().iter().map(|c| c.is_ruler_guide()).collect(); + assert_eq!(kids, [false, false, false, true, true, true]); + assert_eq!(d.guides_into_nodes(), 0, "a second run does nothing"); + + // Saved back: the guides leave the layers and the list names each one's layer and id. + let mut saved = d.clone(); + saved.guides_to_saved(); + assert!(saved.ruler_guides().is_empty()); + assert_eq!(saved.node_count(), d.node_count()); + assert_eq!(saved.saved_guides, guides); + let json = serde_json::to_value(&saved).unwrap(); + assert_eq!(json["guides"].as_array().map(Vec::len), Some(6)); + assert_eq!( + json["guides"][0], + serde_json::json!({"vertical": true, "pos": 50.0, "layer": layer, "id": 500, "name": "Edge", "locked": true, "hidden": true}) + ); + // Read again, the same guides come back with the same ids. + let mut back: Document = serde_json::from_value(json).unwrap(); + back.fix_next_id(); + back.guides_into_nodes(); + assert_eq!(back.ruler_guides(), guides); + // An older reader of the list ignores the new fields. + #[derive(serde::Deserialize, PartialEq, Debug)] + struct Old { + vertical: bool, + pos: f64, + #[serde(default)] + artboard: Option, + #[serde(default)] + layer: Option, + } + let old: Vec = serde_json::from_value(serde_json::to_value(&saved.saved_guides).unwrap()).unwrap(); + assert_eq!(old[0], Old { vertical: true, pos: 50.0, artboard: None, layer: Some(layer) }); + + // A document with no layer gets a "Layer 1" for its guides. + let mut empty = Document::new(10.0, 10.0); + empty.layers.clear(); + empty.saved_guides = vec![Guide::new(true, 1.0)]; + assert_eq!(empty.guides_into_nodes(), 1); + assert_eq!(empty.layers.len(), 1); + assert_eq!(empty.layers[0].name.as_deref(), Some("Layer 1")); + assert_eq!(empty.ruler_guides().len(), 1); +} + +/// Editing, deleting and the artboard moves of ruler guide nodes, and the selection keeping step. +#[test] +fn ruler_guide_nodes_are_edited_by_id() { + let (mut d, a, _) = doc_with_rects(); + let layer = d.layers[0].id; + let g1 = d.add_ruler_guide(&Guide { layer: Some(layer), ..Guide::new(true, 10.0) }); + let g2 = d.add_ruler_guide(&Guide { artboard: Some(1), ..Guide::new(false, 20.0) }); + assert!(d.edit_ruler_guide(g1, |g| g.pos = 15.0)); + assert!(!d.edit_ruler_guide(a, |g| g.pos = 15.0), "not a guide"); + assert_eq!(d.ruler_guide(g1).map(|g| g.pos), Some(15.0)); + d.move_artboard_guides(1, Vec2::new(5.0, 7.0)); + assert_eq!(d.ruler_guide(g2).map(|g| g.pos), Some(27.0)); + d.map_artboard_guides(1, Affine::scale(2.0)); + assert_eq!(d.ruler_guide(g2).map(|g| g.pos), Some(54.0)); + assert_eq!(d.ruler_guide(g1).map(|g| g.pos), Some(15.0), "a canvas guide stays"); + d.copy_artboard_guides(1, 2, Vec2::new(0.0, 100.0)); + let copies: Vec<(f64, Option)> = d.ruler_guides().iter().filter(|g| g.artboard == Some(2)).map(|g| (g.pos, g.artboard)).collect(); + assert_eq!(copies, [(154.0, Some(2))]); + let mut sel = Selection::default(); + sel.set_guides([g1, g2]); + sel.key_guide = Some(g2); + assert_eq!(d.retain_guides(&mut sel, |g| g.artboard.is_none()), 2); + assert_eq!(sel.guides, [g1]); + assert_eq!(sel.key_guide, None); + assert_eq!(d.ruler_guides().iter().map(|g| g.id).collect::>(), [Some(g1)]); + assert_eq!(d.retain_guides(&mut sel, |_| true), 0); +} + fn doc_with_rects() -> (Document, NodeId, NodeId) { let mut d = Document::new(612.0, 792.0); let layer = d.layers[0].id; diff --git a/crates/effects/src/bake.rs b/crates/effects/src/bake.rs index be3e99522f..5d27ac60af 100644 --- a/crates/effects/src/bake.rs +++ b/crates/effects/src/bake.rs @@ -107,7 +107,8 @@ fn path_kind(d: &mut Document, path: PathData, rule: FillRule) -> NodeKind { /// `n` with its geometry effects evaluated (`None` = unchanged). fn bake_node(d: &mut Document, n: &Node) -> Option { // A cancelled export leaves the rest of the document as it is: the caller drops the result. - if !needs_bake(n) || vectorcraft_doc::cancel::is_cancelled() { + // A ruler guide draws nothing and stays as it is, last in its layer. + if n.is_ruler_guide() || !needs_bake(n) || vectorcraft_doc::cancel::is_cancelled() { return None; } // Colour adjustments: the object recoloured (inside it too), then baked as it is. diff --git a/crates/effects/src/group.rs b/crates/effects/src/group.rs index 73d7266c31..99e9d9e6fe 100644 --- a/crates/effects/src/group.rs +++ b/crates/effects/src/group.rs @@ -455,6 +455,17 @@ pub fn pathfinder_children(doc: Option<&Document>, group: &Node, hook: Option] = group.children()?; + // A layer's ruler guides take no part in the operation: they stay above its result. + let art = Node::guide_run_start(source); + if art < source.len() { + let mut without = group.clone(); + if let Some(ch) = without.children_mut() { + ch.truncate(art); + } + let mut out = pathfinder_children(doc, &without, hook)?; + out.extend(source.iter().skip(art).cloned()); + return Some(out); + } let mut stack: Vec> = if group.is_compound_shape() { // The group's own appearance paints the area ([`evaluate_container`]), so the art has none. match compound_shape_area(doc, group, hook) { @@ -727,9 +738,11 @@ pub fn evaluate_container(doc: Option<&Document>, n: &Node) -> Option { return None; } let mut m = n.clone(); + // A layer's ruler guides take no part: they go back above the evaluated art at the end. + let guides = m.children_mut().map(|ch| ch.split_off(Node::guide_run_start(ch))).unwrap_or_default(); if pathfinder { let hook = |c: &Node| crate::outline_object(doc, c); - let result = pathfinder_children(doc, n, Some(&hook)).unwrap_or_default(); + let result = pathfinder_children(doc, &m, Some(&hook)).unwrap_or_default(); if let Some(ch) = m.children_mut() { *ch = result; } @@ -762,6 +775,9 @@ pub fn evaluate_container(doc: Option<&Document>, n: &Node) -> Option { } m.appearance.items.clear(); m.appearance.contents_index = None; + if let Some(ch) = m.children_mut() { + ch.extend(guides); + } Some(m) } diff --git a/crates/effects/src/group/tests.rs b/crates/effects/src/group/tests.rs index 94456c7d57..bf702b2732 100644 --- a/crates/effects/src/group/tests.rs +++ b/crates/effects/src/group/tests.rs @@ -820,3 +820,45 @@ fn nested_compound_shapes_take_part_as_their_area() { assert!((area(&out[0]) - 50.0).abs() < 0.01); assert_eq!(out[0].appearance.fill_paint().color(), Some(Color::rgb(0.0, 1.0, 0.0)), "styled like the compound shape"); } + +/// The ruler guides of a layer with a Pathfinder effect, a Transform effect that copies its art, or +/// fills and strokes of its own take no part: they stay last in the layer, once and unchanged. +#[test] +fn a_layers_ruler_guides_stay_last_through_its_effects() { + use vectorcraft_doc::LayerColor; + let guide = |id, pos| Arc::new(Node::new(NodeId(id), NodeKind::RulerGuide { vertical: true, pos, artboard: None })); + let layer = |effect: &str, params: serde_json::Value| { + let mut l = Node::layer(NodeId(10), "Layer 1", LayerColor::Preset(0)); + if let Some(ch) = l.children_mut() { + *ch = vec![sq(1, 0.0, Color::rgb(1.0, 0.0, 0.0)), sq(2, 5.0, Color::rgb(0.0, 0.0, 1.0)), guide(20, 3.0), guide(21, 8.0)]; + } + l.appearance.effects.push(Effect { id: effect.into(), params, visible: true }); + l + }; + let tail = |n: &Node| { + let ch = n.children().unwrap(); + assert_eq!(ch.iter().filter(|c| c.is_ruler_guide()).count(), 2, "each guide once"); + ch.iter().rev().take(2).map(|c| c.kind.clone()).collect::>() + }; + let guides = vec![guide(21, 8.0).kind.clone(), guide(20, 3.0).kind.clone()]; + + let add = layer("pathfinder.add", serde_json::json!({})); + let out = pathfinder_children(None, &add, None).unwrap(); + assert_eq!(out.len(), 3, "the united area, then the two guides"); + assert!((area(&out[0]) - 150.0).abs() < 0.01); + assert!(out[1].is_ruler_guide() && out[2].is_ruler_guide()); + assert_eq!(tail(&evaluate_container(None, &add).unwrap()), guides); + + let copies = layer("distort.transform", serde_json::json!({"moveH": 30, "copies": 2})); + let art = evaluate_container(None, &copies).unwrap(); + assert_eq!(tail(&art), guides); + assert_eq!(art.children().unwrap().len(), 5, "three groups of the art, then the two guides"); + let mut art = copies.clone(); + crate::reshape::reshape_leaves(&mut art, &copies.appearance.effects.iter().collect::>(), &copies.appearance.effects, None); + assert_eq!(tail(&art), guides); + assert_eq!(art.children().unwrap().len(), 5, "reshaped as members too"); + + let mut painted = layer("distort.transform", serde_json::json!({"moveH": 30})); + painted.appearance.items = Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0).items; + assert_eq!(tail(&evaluate_container(None, &painted).unwrap()), guides); +} diff --git a/crates/effects/src/reshape.rs b/crates/effects/src/reshape.rs index 0bdc134625..c629cc10ca 100644 --- a/crates/effects/src/reshape.rs +++ b/crates/effects/src/reshape.rs @@ -974,7 +974,7 @@ fn effects_cost(leaf: &Node, path: &PathData) -> Budget { /// Remove the guides anywhere under `n`. fn without_guides(n: &mut Node) { let Some(children) = n.children_mut() else { return }; - children.retain(|c| !matches!(c.kind, NodeKind::Path { guide: true, .. })); + children.retain(|c| !c.is_guide()); children.iter_mut().for_each(|c| without_guides(Arc::make_mut(c))); } @@ -984,7 +984,9 @@ fn without_guides(n: &mut Node) { /// [`crate::distort::MAX_COPY_OBJECTS`] nodes in all, the art of opacity masks counted. Returns what /// the art costs after. fn copy_art(art: &mut Node, m: Affine, copies: usize, scaling: vectorcraft_doc::Scaling, budget: Budget) -> Budget { - let Some(members) = art.children().cloned() else { return art_cost(art) }; + let Some(mut members) = art.children().cloned() else { return art_cost(art) }; + // A layer's ruler guides are not copied: they stay above the copies, once. + let guides = members.split_off(Node::guide_run_start(&members)); let original = Node::group(art.id, members); let mut made = copy_cost(&original); let mut drawn = original.clone(); @@ -1014,6 +1016,7 @@ fn copy_art(art: &mut Node, m: Affine, copies: usize, scaling: vectorcraft_doc:: // The last copy is the lowest. placements.reverse(); placements.push(Arc::new(original)); + placements.extend(guides); if let Some(children) = art.children_mut() { *children = placements; } diff --git a/crates/effects/src/solids.rs b/crates/effects/src/solids.rs index 95ef265cc9..be6c68ba74 100644 --- a/crates/effects/src/solids.rs +++ b/crates/effects/src/solids.rs @@ -348,7 +348,7 @@ pub(crate) fn members(n: &Node, label: &str) -> Result, String> { fn walk(n: &Node, opacity: f32, scopes: &[Scope], w: &mut Walk) -> Result<(), String> { let label = w.label; for c in n.children().into_iter().flatten() { - if !c.visible || matches!(c.kind, NodeKind::Path { guide: true, .. }) { + if !c.visible || c.is_guide() { continue; } let opacity = opacity * c.opacity; diff --git a/crates/effects/src/solids/source.rs b/crates/effects/src/solids/source.rs index ad9495cf08..66ca2c1d72 100644 --- a/crates/effects/src/solids/source.rs +++ b/crates/effects/src/solids/source.rs @@ -191,7 +191,7 @@ impl Resolver<'_> { let mut changed = false; for c in children { self.spend(1)?; - if !c.visible || matches!(c.kind, NodeKind::Path { guide: true, .. }) { + if !c.visible || c.is_guide() { continue; } if matches!(c.kind, NodeKind::Image(_)) { @@ -279,7 +279,7 @@ impl Resolver<'_> { kids = nest(n, cut, 0); } else { for c in n.children().into_iter().flatten() { - if !c.visible || matches!(c.kind, NodeKind::Path { guide: true, .. }) { + if !c.visible || c.is_guide() { continue; } if let Some(c) = self.clipped(c, clip, depth + 1)? { diff --git a/crates/engine/src/cmd.rs b/crates/engine/src/cmd.rs index c76492f454..7b6cdcc58b 100644 --- a/crates/engine/src/cmd.rs +++ b/crates/engine/src/cmd.rs @@ -526,10 +526,12 @@ pub(crate) fn checked_ids_or_selection(s: &Session, p: &Value, command: &str) -> pub(crate) fn checked_id(s: &Session, value: &Value, command: &str) -> Result { let id = value.as_u64().map(NodeId).ok_or_else(|| bad(command, format!("invalid object id {value}: expected a non-negative integer")))?; - if s.doc()?.doc.node(id).is_none() { - return Err(bad(command, format!("no such object id {value}"))); + match s.doc()?.doc.node(id) { + None => Err(bad(command, format!("no such object id {value}"))), + // The guide commands take ruler guides by index. + Some(n) if n.is_ruler_guide() => Err(bad(command, format!("id {value} is a ruler guide, not an object"))), + Some(_) => Ok(id), } - Ok(id) } /// `v` as exactly `N` finite numbers (`[x, y]`, `[x, y, width, height]`…): `None` when it isn't an @@ -689,10 +691,11 @@ pub(crate) fn leaf_targets(s: &Session, ids: &[NodeId]) -> Result> { let compound = |id: NodeId| d.parent_of(id).filter(|p| d.node(*p).is_some_and(owns)); let mut out = vec![]; for id in ids { - let Some(n) = d.node(*id) else { continue }; + // A ruler guide has no appearance to paint, given or in a layer. + let Some(n) = d.node(*id).filter(|n| !n.is_ruler_guide()) else { continue }; match &n.kind { NodeKind::Group { .. } | NodeKind::Layer { .. } if !n.is_compound_shape() => n.walk(&mut |c| { - if !c.is_container() || owns(c) { + if (!c.is_container() && !c.is_ruler_guide()) || owns(c) { out.push(c.id) } }), @@ -742,6 +745,7 @@ pub(crate) fn unique_name_from(base: &str, next: &mut std::collections::HashMap< /// The most presets one presets file imports: each is looked up among the saved ones. pub(crate) const MAX_IMPORTED_PRESETS: usize = 1000; +pub use docmenu::guide_indexes; pub(crate) use edit::{settle_interaction, typing_in_progress}; pub(crate) use object::bounds_rewrite; pub(crate) use object::{keep_aligned_in, refresh_pixel_aligned}; diff --git a/crates/engine/src/cmd/addons/cleanup/checks.rs b/crates/engine/src/cmd/addons/cleanup/checks.rs index dfb450faed..4fd1f1ce96 100644 --- a/crates/engine/src/cmd/addons/cleanup/checks.rs +++ b/crates/engine/src/cmd/addons/cleanup/checks.rs @@ -185,7 +185,7 @@ fn objects<'a>(d: &'a Document, scope: &Scope) -> Vec<(&'a Node, Place)> { } fn visit<'a>(n: &'a Node, place: Place, out: &mut Vec<(&'a Node, Place)>) { - if !n.visible || n.locked || out.len() >= MAX_OBJECTS { + if !n.visible || n.locked || n.is_ruler_guide() || out.len() >= MAX_OBJECTS { return; } let inner = |clip: bool, i: usize, top: bool| Place { clips: clip && i == 0, top, in_compound: false }; @@ -216,7 +216,7 @@ fn is_generated(n: &Node) -> bool { } fn is_guide_or_clip(n: &Node) -> bool { - matches!(n.kind, NodeKind::Path { guide: true, .. } | NodeKind::Path { clipping: true, .. }) + n.is_guide() || matches!(n.kind, NodeKind::Path { clipping: true, .. }) } fn is_shape(n: &Node) -> bool { @@ -484,7 +484,7 @@ fn artboards(d: &mut Document, sel: &mut Selection, scope: &Scope) -> Result 0.0 && (!self.text_only || matches!(n.kind, NodeKind::Text(_))); let mut counts = (white, usize::from(blacks && black.apply(n, spots))); - for c in n.children_mut().into_iter().flatten() { + for c in n.children_mut().into_iter().flatten().filter(|c| !c.is_ruler_guide()) { let (w, b) = self.overprint(Arc::make_mut(c), black, spots); counts = (counts.0 + w, counts.1 + b); } diff --git a/crates/engine/src/cmd/addons/document.rs b/crates/engine/src/cmd/addons/document.rs index fafdfc07dc..654138a1dd 100644 --- a/crates/engine/src/cmd/addons/document.rs +++ b/crates/engine/src/cmd/addons/document.rs @@ -164,11 +164,11 @@ fn export_all_open(s: &mut Session, p: &Value) -> Result { } /// `n` with each layer in it, and itself, made a group of the same art and clipping (a symbol holds -/// no layers). +/// no layers, and no ruler guides: a layer's are left out). fn without_layers(n: &Node) -> Node { let mut n = n.clone(); if let NodeKind::Layer { children, clip, .. } = &n.kind { - n.kind = NodeKind::Group { children: children.clone(), clip: *clip }; + n.kind = NodeKind::Group { children: children.iter().filter(|c| !c.is_ruler_guide()).cloned().collect(), clip: *clip }; } if let Some(children) = n.children_mut() { for c in children.iter_mut().filter(|c| matches!(c.kind, NodeKind::Layer { .. })) { @@ -203,8 +203,10 @@ fn symbols_from_layers(s: &mut Session, p: &Value) -> Result { let (mut made, mut skipped) = (vec![], vec![]); for layer in layers.iter().filter_map(|id| d.layers.iter().find(|l| l.id == *id)) { let name = layer.display_name(); - let roots: Vec = layer.children().map(|c| c.iter().map(|n| n.id).collect()).unwrap_or_default(); - let art = match layer.children().map(Vec::as_slice).unwrap_or_default() { + // The layer's ruler guides stay where they are. + let children: Vec> = layer.children().into_iter().flatten().filter(|n| !n.is_ruler_guide()).cloned().collect(); + let roots: Vec = children.iter().map(|n| n.id).collect(); + let art = match children.as_slice() { [] => None, [one] => Some(without_layers(one)), many => Some(without_layers(&Node::group(NodeId(0), many.to_vec()))), diff --git a/crates/engine/src/cmd/appearance.rs b/crates/engine/src/cmd/appearance.rs index 024358adfe..2dfeb497d0 100644 --- a/crates/engine/src/cmd/appearance.rs +++ b/crates/engine/src/cmd/appearance.rs @@ -1189,7 +1189,7 @@ fn target_contents(s: &mut Session, p: &Value) -> Result { .iter() .filter_map(|id| d.node(*id)) .filter(|n| matches!(n.kind, NodeKind::Group { .. } | NodeKind::Layer { .. })) - .flat_map(|n| n.children().into_iter().flatten().filter(|c| c.visible && !c.locked).map(|c| c.id)) + .flat_map(|n| n.children().into_iter().flatten().filter(|c| c.visible && !c.locked && !c.is_ruler_guide()).map(|c| c.id)) .collect(); if ids.is_empty() { return Err(bad("appearance.targetContents", "select a group or layer with visible, unlocked members")); diff --git a/crates/engine/src/cmd/assets.rs b/crates/engine/src/cmd/assets.rs index 0b57565af4..1ebde5c026 100644 --- a/crates/engine/src/cmd/assets.rs +++ b/crates/engine/src/cmd/assets.rs @@ -116,13 +116,15 @@ pub(crate) fn art_under(doc: &vectorcraft_doc::Document, id: NodeId, out: &mut V let Some(n) = doc.node(id) else { return }; if n.is_layer() { if let Some(kids) = n.children() { - for c in kids { + for c in kids.iter().filter(|c| !c.is_ruler_guide()) { art_under(doc, c.id, out); } } return; } - out.push(id); + if !n.is_ruler_guide() { + out.push(id); + } } fn add(s: &mut Session, p: &Value) -> Result { diff --git a/crates/engine/src/cmd/brushsym/dynamic.rs b/crates/engine/src/cmd/brushsym/dynamic.rs index 32edc44f86..888e450085 100644 --- a/crates/engine/src/cmd/brushsym/dynamic.rs +++ b/crates/engine/src/cmd/brushsym/dynamic.rs @@ -137,7 +137,7 @@ fn path_param(p: &Value, art: &Node, cmd: &str) -> Result> { /// stroke has a Convert to Shape or Outline Object effect. fn paint_refusal(n: &Node, key: &str, cmd: &str) -> Option { Some(match &n.kind { - NodeKind::Path { guide: true, .. } => bad(cmd, format!("{key} cannot be set on a guide")), + NodeKind::Path { guide: true, .. } | NodeKind::RulerGuide { .. } => bad(cmd, format!("{key} cannot be set on a guide")), NodeKind::Path { .. } | NodeKind::Compound { .. } | NodeKind::Text(_) | NodeKind::Group { .. } | NodeKind::Layer { .. } => return None, NodeKind::Image(_) => { let effects = if key == "fill" { n.appearance.fill().map(|f| &f.effects) } else { n.appearance.stroke().map(|s| &s.effects) }; diff --git a/crates/engine/src/cmd/clipboard.rs b/crates/engine/src/cmd/clipboard.rs index 5ff09dc284..9e52454de1 100644 --- a/crates/engine/src/cmd/clipboard.rs +++ b/crates/engine/src/cmd/clipboard.rs @@ -322,7 +322,18 @@ impl Clipboard { /// Every object of `src` (each top-level layer's contents) with its resources, for Place and /// pasting outside SVG. Their layers don't count for Paste Remembers Layers. pub fn from_document(src: &Document) -> Self { - let nodes = src.layers.iter().flat_map(|l| l.children().into_iter().flatten()).map(|n| (**n).clone()).collect(); + // Ruler guides are not objects: the ones of `src`'s layers and sublayers stay behind. + let nodes = src + .layers + .iter() + .flat_map(|l| l.children().into_iter().flatten()) + .filter(|n| !n.is_ruler_guide()) + .map(|n| { + let mut n = (**n).clone(); + n.strip_ruler_guides(); + n + }) + .collect(); Self::gather(src, nodes) } diff --git a/crates/engine/src/cmd/colorcmds.rs b/crates/engine/src/cmd/colorcmds.rs index ac082d7a05..65f1056895 100644 --- a/crates/engine/src/cmd/colorcmds.rs +++ b/crates/engine/src/cmd/colorcmds.rs @@ -462,7 +462,7 @@ fn run_pass(s: &mut Session, p: &Value, cmd: &str, label: &str, pass: Recolor) - pub(crate) fn left_out(d: &Document, iso: Option, roots: &[NodeId]) -> Vec<(NodeId, bool)> { fn visit(n: &Node, hidden: bool, locked: bool, all: &HashSet, board_locked: &dyn Fn(&Node) -> bool, out: &mut Vec<(NodeId, bool)>) { for c in n.children().into_iter().flatten() { - if c.is_template() { + if c.is_template() || c.is_ruler_guide() { continue; } let (hidden, locked) = (hidden || !c.visible, locked || c.locked); diff --git a/crates/engine/src/cmd/colormgmt.rs b/crates/engine/src/cmd/colormgmt.rs index 03b171de71..da7dc42ece 100644 --- a/crates/engine/src/cmd/colormgmt.rs +++ b/crates/engine/src/cmd/colormgmt.rs @@ -582,7 +582,7 @@ fn map_more_colors(n: &mut vectorcraft_doc::Node, f: &mut dyn FnMut(&Color, proo } } if let Some(ch) = n.children_mut() { - for c in ch.iter_mut() { + for c in ch.iter_mut().filter(|c| !c.is_ruler_guide()) { map_more_colors(Arc::make_mut(c), f); } } diff --git a/crates/engine/src/cmd/docinfo.rs b/crates/engine/src/cmd/docinfo.rs index e6e75c1f3a..cb1bb86fbd 100644 --- a/crates/engine/src/cmd/docinfo.rs +++ b/crates/engine/src/cmd/docinfo.rs @@ -370,7 +370,8 @@ fn info(s: &mut Session, p: &Value) -> Result { return; } let k = match &n.kind { - NodeKind::Layer { .. } => None, + // The Guides row counts paths made into guides; ruler guides are not objects. + NodeKind::Layer { .. } | NodeKind::RulerGuide { .. } => None, NodeKind::Group { clip: true, .. } => Some("clipGroups"), NodeKind::Group { .. } => Some("groups"), NodeKind::Path { guide: true, .. } => Some("guides"), diff --git a/crates/engine/src/cmd/docmenu.rs b/crates/engine/src/cmd/docmenu.rs index 5f8508f4e9..db792ab1c6 100644 --- a/crates/engine/src/cmd/docmenu.rs +++ b/crates/engine/src/cmd/docmenu.rs @@ -666,7 +666,7 @@ fn release_guides(s: &mut Session, p: &Value) -> Result { if (index.is_some() || ids.is_some()) && s.menu.guides_locked { return Err(EngineError::Other("guides are locked".into())); } - let (rulers, paths): (Vec, Vec) = if index.is_some() || ids.is_some() { + let (rulers, paths): (Vec, Vec) = if index.is_some() || ids.is_some() { let rulers = match index { Some(v) => vec![editable_guide_index(s, Some(v), C)?], None => vec![], @@ -689,10 +689,10 @@ fn release_guides(s: &mut Session, p: &Value) -> Result { let st = s.doc()?; let all = guide_paths(&st.doc); let sel: Vec = all.iter().copied().filter(|id| st.selection.contains(*id)).collect(); - let rulers: Vec = if s.menu.guides_locked { + let rulers: Vec = if s.menu.guides_locked { vec![] } else { - st.selection.guides.iter().copied().filter(|i| st.doc.guides.get(*i).is_some_and(|g| st.doc.guide_editable(g))).collect() + st.selection.guides.iter().copied().filter(|id| st.doc.ruler_guide(*id).is_some_and(|g| st.doc.guide_editable(&g))).collect() }; let editable = st.doc.editable(); if sel.is_empty() && rulers.is_empty() { (vec![], all.into_iter().filter(|id| editable(*id)).collect()) } else { (rulers, sel) } @@ -702,8 +702,8 @@ fn release_guides(s: &mut Session, p: &Value) -> Result { let artboards = st.doc.artboards.iter().map(|a| a.rect).reduce(|a, b| a.union(b)); let mut lines = Vec::with_capacity(rulers.len()); for &i in &rulers { - let Some(g) = st.doc.guides.get(i) else { continue }; - let span = st.doc.guide_span(g).or_else(|| artboards.map(|r| if g.vertical { (r.y0, r.y1) } else { (r.x0, r.x1) })); + let Some(g) = st.doc.ruler_guide(i) else { continue }; + let span = st.doc.guide_span(&g).or_else(|| artboards.map(|r| if g.vertical { (r.y0, r.y1) } else { (r.x0, r.x1) })); let Some((a, b)) = span else { continue }; let (a, b) = if g.vertical { (Point::new(g.pos, a), Point::new(g.pos, b)) } else { (Point::new(a, g.pos), Point::new(b, g.pos)) }; let parent = match g.layer.filter(|l| st.doc.node(*l).is_some_and(Node::is_layer)) { @@ -729,7 +729,7 @@ fn release_guides(s: &mut Session, p: &Value) -> Result { d.insert(parent, usize::MAX, Node::path(id, vectorcraft_geom::shapes::line(a, b), look))?; made.push(id); } - d.retain_guides(sel, |i, _| !lines.iter().any(|l| l.0 == i)); + d.retain_guides(sel, |g| !lines.iter().any(|l| Some(l.0) == g.id)); sel.set(paths.iter().copied().chain(made)); Ok(()) })?; @@ -745,12 +745,12 @@ fn lock_guides(s: &mut Session, p: &Value) -> Result { fn clear_guides(s: &mut Session, _: &Value) -> Result { let st = s.doc()?; let paths = guide_paths(&st.doc); - let n = paths.len() + st.doc.guides.len(); + let n = paths.len() + st.doc.ruler_guides().len(); if n == 0 { return Ok(json!({ "count": 0 })); } - s.edit("Clear Guides", |d, _| { - d.guides.clear(); + s.edit("Clear Guides", |d, sel| { + d.retain_guides(sel, |_| false); let mut gone = Beside::replacing(d, &paths); for id in &paths { if d.node(*id).is_some() { @@ -780,17 +780,29 @@ fn guide_add(s: &mut Session, p: &Value) -> Result { // Where new art goes (refused when no layer is shown and unlocked), or that group's layer. None => st.target_parent()?.and_then(|p| if st.doc.node(p).is_some_and(Node::is_layer) { Some(p) } else { st.doc.layer_containing(p) }), }; - let i = s.edit("New Guide", |d, _| { - d.guides.push(Guide { artboard, layer, ..Guide::new(vertical, pos) }); - Ok(d.guides.len() - 1) - })?; + let id = s.edit("New Guide", |d, _| Ok(d.add_ruler_guide(&Guide { artboard, layer, ..Guide::new(vertical, pos) })))?; + let i = guide_indexes(&s.doc()?.doc, &[id]).first().copied(); Ok(json!({ "index": i })) } +/// The ids of the active document's ruler guides by index: the order of `guide.list` (ascending +/// id). +pub(crate) fn guide_ids(d: &vectorcraft_doc::Document) -> Vec { + d.ruler_guides().into_iter().filter_map(|g| g.id).collect() +} + +/// The `guide.list` indexes of ruler guides `ids`, in the order of `ids` (an id that is not a ruler +/// guide is left out). +pub fn guide_indexes(d: &vectorcraft_doc::Document, ids: &[NodeId]) -> Vec { + let all = guide_ids(d); + ids.iter().filter_map(|id| all.binary_search(id).ok()).collect() +} + fn guide_list(s: &mut Session, _: &Value) -> Result { let st = s.doc()?; let row = |(i, g): (usize, &Guide)| { - let mut r = json!({ "index": i, "vertical": g.vertical, "pos": g.pos, "selected": st.selection.guides.contains(&i) }); + let selected = g.id.is_some_and(|id| st.selection.guides.contains(&id)); + let mut r = json!({ "index": i, "vertical": g.vertical, "pos": g.pos, "selected": selected }); if let Some(ab) = st.doc.artboards.iter().position(|a| Some(a.id) == g.artboard) { r["artboard"] = json!(ab); } @@ -801,31 +813,38 @@ fn guide_list(s: &mut Session, _: &Value) -> Result { r["editable"] = json!(st.doc.guide_editable(g)); r }; - Ok(Value::Array(st.doc.guides.iter().enumerate().map(row).collect())) + Ok(Value::Array(st.doc.ruler_guides().iter().enumerate().map(row).collect())) } -/// Ruler guide `index` of the active document (`index` validated). -fn guide_index(s: &Session, v: Option<&Value>, cmd: &str) -> Result { +/// The id of ruler guide `index` in `ids` ([`guide_ids`]; `index` validated). +fn guide_index_in(ids: &[NodeId], v: Option<&Value>, cmd: &str) -> Result { let i = v.and_then(Value::as_u64).and_then(|i| usize::try_from(i).ok()).ok_or_else(|| bad(cmd, "an index must be a whole number"))?; - if i >= s.doc()?.doc.guides.len() { - return Err(bad(cmd, format!("no guide {i}"))); + ids.get(i).copied().ok_or_else(|| bad(cmd, format!("no guide {i}"))) +} + +/// The id of ruler guide `index` of the active document (`index` validated). +fn guide_index(s: &Session, v: Option<&Value>, cmd: &str) -> Result { + guide_index_in(&guide_ids(&s.doc()?.doc), v, cmd) +} + +/// Is ruler guide `id` one that can be moved or deleted: not on a hidden or locked layer? +fn check_editable_guide(s: &Session, id: NodeId) -> Result { + let d = &s.doc()?.doc; + if !d.ruler_guide(id).is_some_and(|g| d.guide_editable(&g)) { + return Err(EngineError::Other("the guide is on a hidden or locked layer".into())); } - Ok(i) + Ok(id) } /// [`guide_index`] of a guide that can be moved or deleted: not on a hidden or locked layer. -fn editable_guide_index(s: &Session, v: Option<&Value>, cmd: &str) -> Result { - let i = guide_index(s, v, cmd)?; - let d = &s.doc()?.doc; - if !d.guides.get(i).is_some_and(|g| d.guide_editable(g)) { - return Err(EngineError::Other("the guide is on a hidden or locked layer".into())); - } - Ok(i) +fn editable_guide_index(s: &Session, v: Option<&Value>, cmd: &str) -> Result { + check_editable_guide(s, guide_index(s, v, cmd)?) } fn guide_select(s: &mut Session, p: &Value) -> Result { let list = p.get("indexes").and_then(Value::as_array).ok_or_else(|| bad("guide.select", "missing indexes"))?; - let picked = list.iter().map(|v| guide_index(s, Some(v), "guide.select")).collect::>>()?; + let ids = guide_ids(&s.doc()?.doc); + let picked = list.iter().map(|v| guide_index_in(&ids, Some(v), "guide.select")).collect::>>()?; if bool_or(p, "toggle", false) { // Added to (or taken from) what is selected, the art included. s.select(|_, sel| { @@ -836,12 +855,13 @@ fn guide_select(s: &mut Session, p: &Value) -> Result { } else { s.select(|_, sel| sel.set_guides(picked))?; } - Ok(json!({ "selected": s.doc()?.selection.guides })) + let st = s.doc()?; + Ok(json!({ "selected": guide_indexes(&st.doc, &st.selection.guides) })) } /// Delete ruler guide `index`, else the selected guides, in one undo step → {count}. pub(crate) fn guide_remove(s: &mut Session, p: &Value) -> Result { - let gone: Vec = match p.get("index") { + let gone: Vec = match p.get("index") { Some(v) => vec![editable_guide_index(s, Some(v), "guide.remove")?], None => s.doc()?.selection.guides.clone(), }; @@ -850,7 +870,7 @@ pub(crate) fn guide_remove(s: &mut Session, p: &Value) -> Result { } let label = if gone.len() == 1 { "Delete Guide" } else { "Delete Guides" }; s.edit(label, |d, sel| { - d.retain_guides(sel, |i, _| !gone.contains(&i)); + d.retain_guides(sel, |g| !g.id.is_some_and(|id| gone.contains(&id))); Ok(()) })?; Ok(json!({ "count": gone.len() })) @@ -866,18 +886,22 @@ fn guide_set_layer(s: &mut Session, p: &Value) -> Result { if super::layer::locked_within(d, layer) { return Err(EngineError::Other("the layer or group is locked".into())); } - let moving: Vec = match (p.get("index"), p.get("indexes").and_then(Value::as_array)) { + let moving: Vec = match (p.get("index"), p.get("indexes").and_then(Value::as_array)) { (Some(v), _) => vec![editable_guide_index(s, Some(v), C)?], - (None, Some(list)) => list.iter().take(10_000).map(|v| editable_guide_index(s, Some(v), C)).collect::>>()?, + (None, Some(list)) => { + let ids = guide_ids(&s.doc()?.doc); + list.iter().take(10_000).map(|v| check_editable_guide(s, guide_index_in(&ids, Some(v), C)?)).collect::>>()? + } (None, None) => s.doc()?.selection.guides.clone(), }; if moving.is_empty() { return Err(bad(C, "no guide selected (or give an index)")); } s.edit("Move Guide to Layer", |d, _| { - for i in &moving { - if let Some(g) = d.guides.get_mut(*i) { - g.layer = Some(layer); + // Each keeps its id (and so its index), at the top of the layer. + for id in &moving { + if d.parent_of(*id) != Some(layer) && d.ruler_guide(*id).is_some() { + d.move_node(*id, Some(layer), usize::MAX)?; } } Ok(()) @@ -891,9 +915,7 @@ pub(crate) fn guide_move(s: &mut Session, p: &Value) -> Result { let i = editable_guide_index(s, Some(v), "guide.move")?; let pos = f64_req(p, "pos", "guide.move")?; s.edit("Move Guide", |d, _| { - if let Some(g) = d.guides.get_mut(i) { - g.pos = pos; - } + d.edit_ruler_guide(i, |g| g.pos = pos); Ok(()) })?; return ok(); @@ -917,18 +939,14 @@ pub(crate) fn guide_move(s: &mut Session, p: &Value) -> Result { s.edit(if copy { "Copy Guide" } else { "Move Guide" }, |d, sel| { let mut copies = vec![]; for &i in &moving { - let Some(g) = d.guides.get_mut(i) else { continue }; - let moved = g.moved(Vec2::new(dx, dy)); if copy { - copies.push(moved); + copies.extend(d.ruler_guide(i).map(|g| d.add_ruler_guide(&g.moved(Vec2::new(dx, dy))))); } else { - *g = moved; + d.edit_ruler_guide(i, |g| *g = g.moved(Vec2::new(dx, dy))); } } if copy { - let first = d.guides.len(); - d.guides.extend(copies); - sel.set_guides(first..d.guides.len()); + sel.set_guides(copies); } Ok(()) })?; diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 3558f1b569..7a8a91f0af 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -991,8 +991,8 @@ fn copy(s: &mut Session, p: &Value) -> Result { .selection .guides .iter() - .filter_map(|i| st.doc.guides.get(*i)) - .map(|g| vectorcraft_doc::Guide { layer: None, artboard: None, ..g.clone() }) + .filter_map(|i| st.doc.ruler_guide(*i)) + .map(|g| vectorcraft_doc::Guide { layer: None, artboard: None, id: None, ..g }) .collect(); let roots = if st.selection.is_empty() && !guides.is_empty() { vec![] } else { targets }; let mut clip = Clipboard::copy(st, &roots, direct); @@ -1004,7 +1004,7 @@ fn copy(s: &mut Session, p: &Value) -> Result { // Guides alone: the artboard the first one belongs to, or crosses, is where in-place pastes // measure from, as the objects' artboard is. if clip.nodes.is_empty() - && let Some(g) = st.selection.guides.first().and_then(|i| st.doc.guides.get(*i)) + && let Some(g) = st.selection.guides.first().and_then(|i| st.doc.ruler_guide(*i)) { let board = g.artboard.and_then(|id| st.doc.artboards.iter().find(|a| a.id == id)).or_else(|| { st.doc @@ -1053,7 +1053,7 @@ fn cut_direct_selection(s: &mut Session, roots: &[NodeId]) -> Result { s.edit("Cut", |d, selection| { // The selected ruler guides were copied too (#3226): they go in the same undo step. let gone = selection.guides.clone(); - d.retain_guides(selection, |i, _| !gone.contains(&i)); + d.retain_guides(selection, |g| !g.id.is_some_and(|id| gone.contains(&id))); let mut survivors = vec![]; let mut pieces = super::pathops::Beside::replacing(d, roots); for id in roots { @@ -1153,7 +1153,7 @@ fn clear(s: &mut Session, p: &Value) -> Result { } if with_guides { let gone = sel.guides.clone(); - d.retain_guides(sel, |i, _| !gone.contains(&i)); + d.retain_guides(sel, |g| !g.id.is_some_and(|id| gone.contains(&id))); } if is_explicit { for id in &ids { @@ -1438,8 +1438,7 @@ fn paste_clip(s: &mut Session, p: &Value, mode: PasteMode, clip: &Clipboard, cho for g in &clip.guides { let pos = g.pos + if g.vertical { dv.x } else { dv.y }; if pos.is_finite() { - sel.guides.push(d.guides.len()); - d.guides.push(vectorcraft_doc::Guide { pos, layer: guide_layer, ..g.clone() }); + sel.guides.push(d.add_ruler_guide(&vectorcraft_doc::Guide { pos, layer: guide_layer, ..g.clone() })); } } } diff --git a/crates/engine/src/cmd/effectcmd.rs b/crates/engine/src/cmd/effectcmd.rs index 89d4f5011c..30d927a966 100644 --- a/crates/engine/src/cmd/effectcmd.rs +++ b/crates/engine/src/cmd/effectcmd.rs @@ -889,6 +889,10 @@ fn expand_pieces( let mut seen = std::collections::HashSet::from([id]); let mut expanded = std::collections::HashMap::new(); for c in m.children_mut().into_iter().flatten() { + // A layer's ruler guides are not art: they keep their ids and expand to nothing. + if c.is_ruler_guide() { + continue; + } let c = Arc::make_mut(c); let mut listed = None; if all || c.id == id { @@ -1084,6 +1088,11 @@ fn expanded_node(d: &mut Document, mut n: Node, brushes: &[vectorcraft_brush::Br // The place of each member in the walk of `n`: after `n` and the members before it. let mut at = 1; for c in n.children_mut().into_iter().flatten() { + if c.is_ruler_guide() { + // A layer's ruler guide expands to nothing, but the walk of `n` still visits it. + at += 1; + continue; + } let from = out.len(); match members.remove(&c.id) { Some(listed) => out.extend(listed), @@ -1097,7 +1106,8 @@ fn expanded_node(d: &mut Document, mut n: Node, brushes: &[vectorcraft_brush::Br for (_, place) in &mut out[from..] { *place += at; } - at += c.count(); + // Every node the walk visits, ruler guides in a sublayer too (`Node::count` leaves them out). + c.walk(&mut |_| at += 1); } } changed.then_some(n) diff --git a/crates/engine/src/cmd/expand.rs b/crates/engine/src/cmd/expand.rs index ffb58d9d4b..8dce25b11a 100644 --- a/crates/engine/src/cmd/expand.rs +++ b/crates/engine/src/cmd/expand.rs @@ -534,7 +534,7 @@ fn expand_piece_leaves( } return; } - for c in n.children_mut().into_iter().flatten() { + for c in n.children_mut().into_iter().flatten().filter(|c| !c.is_ruler_guide()) { expand_piece_leaves(d, Arc::make_mut(c), left, expanded, expand); } } diff --git a/crates/engine/src/cmd/fileio/load.rs b/crates/engine/src/cmd/fileio/load.rs index 1b217e6c2d..58fff3cad1 100644 --- a/crates/engine/src/cmd/fileio/load.rs +++ b/crates/engine/src/cmd/fileio/load.rs @@ -306,10 +306,10 @@ pub(super) fn outlined_if_asked(mut doc: Document, warnings: Vec, opts: (doc, warnings) } -/// Does `doc` hold no art: only layers, with nothing in them? +/// Does `doc` hold no art: only layers, with nothing in them but ruler guides? fn draws_nothing(doc: &Document) -> bool { let mut art = false; - doc.walk(|n| art |= !n.is_layer()); + doc.walk(|n| art |= !n.is_layer() && !n.is_ruler_guide()); !art } diff --git a/crates/engine/src/cmd/flatten/apply.rs b/crates/engine/src/cmd/flatten/apply.rs index 409db3f1b2..c1366dba3c 100644 --- a/crates/engine/src/cmd/flatten/apply.rs +++ b/crates/engine/src/cmd/flatten/apply.rs @@ -621,7 +621,7 @@ pub(crate) fn flatten_expanded(doc: &Document, o: &FlattenOptions) -> Result>()); + // A sublayer that becomes flat art gives its ruler guides to the layer that held it. + let guides = held_guides(d, plan.flat.iter().flat_map(|f| f.roots.iter().copied())); for f in plan.flat { // A group always has objects. let Some(top) = place_of(d, &f.roots) else { continue }; @@ -703,6 +705,9 @@ pub(super) fn apply(d: &mut Document, sel: &mut vectorcraft_doc::Selection, plan if !flat.is_empty() { d.insert_beside(flat, true)?; } + for (layer, guide) in guides { + d.insert(Some(layer), usize::MAX, guide)?; + } let brushes = if o.strokes_to_outlines { vectorcraft_brush::library(d) } else { vec![] }; for mut id in plan.kept { if o.text_to_outlines { @@ -747,6 +752,23 @@ fn place_of(d: &Document, roots: &[NodeId]) -> Option { Some(place.map_or(top, |(r, _)| *r)) } +/// The ruler guides of each of `roots` that is a sublayer (in its sublayers too), with the layer +/// that holds it, in order. Other objects hold none. +fn held_guides(d: &Document, roots: impl IntoIterator) -> Vec<(NodeId, Node)> { + let mut out = vec![]; + for id in roots { + let (Some(n), Some(parent)) = (d.node(id), d.parent_of(id)) else { continue }; + if n.holds_ruler_guides() { + n.walk(&mut |c| { + if c.is_ruler_guide() { + out.push((parent, c.clone())); + } + }); + } + } + out +} + /// An image of `r` (in a clip group when it is clipped). fn raster_node(d: &mut Document, r: Raster) -> Node { let key = unique_key(d, "flattened"); @@ -829,3 +851,30 @@ fn drop_overprints(n: &mut Node) { } } } + +#[cfg(test)] +mod tests { + use serde_json::json; + + use super::*; + use crate::Session; + + #[test] + fn a_flattened_sublayer_gives_its_ruler_guides_to_its_layer() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + let layer = s.doc().unwrap().doc.layers[0].id; + let sub = NodeId(s.execute("layer.newSublayer", &json!({"parent": layer.0})).unwrap()["id"].as_u64().unwrap()); + s.execute("layer.setCurrent", &json!({"id": sub.0})).unwrap(); + let r = s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 20, "height": 20})).unwrap()["id"].clone(); + s.execute("transparency.set", &json!({"ids": [r], "opacity": 50})).unwrap(); + let d = Arc::make_mut(&mut s.doc_mut().unwrap().doc); + let g = Node::new(d.alloc_id(), NodeKind::RulerGuide { vertical: true, pos: 5.0, artboard: None }); + let g = d.insert(Some(sub), usize::MAX, g).unwrap(); + let flat = flatten_document(&s.doc().unwrap().doc, &FlattenOptions::default()).unwrap().unwrap(); + assert!(flat.node(sub).is_none(), "the sublayer became flat art"); + assert!(flat.node(g).is_some_and(Node::is_ruler_guide), "its guide is kept"); + assert_eq!(flat.parent_of(g), Some(layer)); + assert_eq!(flat.children(Some(layer)).and_then(|c| c.last()).map(|c| c.id), Some(g)); + } +} diff --git a/crates/engine/src/cmd/flatten/report.rs b/crates/engine/src/cmd/flatten/report.rs index 3cac1ffe86..41b08b7a05 100644 --- a/crates/engine/src/cmd/flatten/report.rs +++ b/crates/engine/src/cmd/flatten/report.rs @@ -12,7 +12,9 @@ pub(super) fn target_roots(s: &Session, p: &Value) -> Result> { let mut ids = vec![]; for id in targets(s, p)? { match d.node(id) { - Some(n) if n.is_layer() => ids.extend(n.children().into_iter().flatten().map(|c| c.id)), + // Ruler guides are not art: they stay where they are. + Some(n) if n.is_layer() => ids.extend(n.children().into_iter().flatten().filter(|c| !c.is_ruler_guide()).map(|c| c.id)), + Some(n) if n.is_ruler_guide() => {} Some(_) => ids.push(id), None => {} } diff --git a/crates/engine/src/cmd/layer.rs b/crates/engine/src/cmd/layer.rs index 07571ad320..49b1015bf4 100644 --- a/crates/engine/src/cmd/layer.rs +++ b/crates/engine/src/cmd/layer.rs @@ -243,7 +243,8 @@ pub(crate) fn accepts(d: &Document, parent: Option, n: &Node) -> bool { None => n.is_layer(), Some(p) => match d.node(p).map(|x| &x.kind) { Some(NodeKind::Layer { .. }) => true, - Some(NodeKind::Group { .. }) => !n.is_layer(), + // A ruler guide stays directly in a layer or sublayer. + Some(NodeKind::Group { .. }) => !n.is_layer() && !n.is_ruler_guide(), _ => false, }, } @@ -478,8 +479,12 @@ fn clip_toggle(s: &mut Session, p: &Value) -> Result { super::object::release_clip(d, id); return Ok(false); } - let top = - n.children().and_then(|c| c.last()).map(|c| c.id).ok_or_else(|| EngineError::Other("the layer has no objects to clip by".into()))?; + // The topmost object: a layer's ruler guides above its art do not clip. + let top = n + .children() + .and_then(|c| c.iter().rev().find(|c| !c.is_ruler_guide())) + .map(|c| c.id) + .ok_or_else(|| EngineError::Other("the layer has no objects to clip by".into()))?; super::object::make_clipping_path(d, top)?; d.move_node(top, Some(id), 0)?; if let Some(n) = d.node_mut(id) { @@ -556,9 +561,8 @@ fn delete_rows(s: &mut Session, p: &Value) -> Result { return Err(EngineError::Other("a document needs at least one layer".into())); } s.edit("Delete Selection", |d, sel| { - d.remove_many(&ids)?; // The guides on the deleted layers go with them. - d.drop_guides_of_deleted_layers(sel); + d.remove_many(&ids)?; sel.prune(d); Ok(()) })?; @@ -577,10 +581,10 @@ fn duplicate_rows(s: &mut Session, p: &Value) -> Result { return Err(bad("layer.duplicate", "no such rows")); } let copies = s.edit("Duplicate Selection", |d, _| { - super::edit::copy_above(d, &ids, |d, n, c| { + // A copied layer's ruler guides are copied with it. + super::edit::copy_above(d, &ids, |_, n, c| { if n.is_layer() { c.name = Some(format!("{} copy", n.display_name())); - d.copy_layer_guides(n, c); } }) })?; @@ -706,6 +710,9 @@ fn select_art(s: &mut Session, p: &Value) -> Result { vec![] } else if n.is_layer() { n.layer_art(true) + } else if n.is_ruler_guide() { + // A ruler guide row selects the guide through `guide.select`, not as art. + vec![] } else { vec![id] }; @@ -784,11 +791,8 @@ fn move_rows(s: &mut Session, p: &Value) -> Result { 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); - } - nodes.push(Arc::new(c)); + // A copied layer's ruler guides are copied with it. + nodes.push(Arc::new(d.reid(&n))); } nodes } else { @@ -810,6 +814,8 @@ fn move_rows(s: &mut Session, p: &Value) -> Result { let ch = d.children_mut(parent)?; let index = index.min(ch.len()); ch.splice(index..index, nodes); + // A layer's ruler guides stay above its art, each in the order it now has. + ch.sort_by_key(|c| c.is_ruler_guide()); // Copied type whose layout caches `reid` dropped has them again. if copy { super::typecmd::refresh_bounds_of(d, &out); @@ -972,7 +978,7 @@ fn remove_artboards(d: &mut Document, sel: &mut vectorcraft_doc::Selection, indi for &i in indices.iter().rev() { let id = d.artboards.remove(i).id; d.retire_artboard_id(id); - d.retain_guides(sel, |_, g| g.artboard != Some(id)); + d.retain_guides(sel, |g| g.artboard != Some(id)); } Ok(()) } diff --git a/crates/engine/src/cmd/layerpanel.rs b/crates/engine/src/cmd/layerpanel.rs index 0a81a947ec..95c152e959 100644 --- a/crates/engine/src/cmd/layerpanel.rs +++ b/crates/engine/src/cmd/layerpanel.rs @@ -255,9 +255,9 @@ fn merge_into(d: &mut Document, into: NodeId, others: &[NodeId]) -> Result<()> { let mut all = std::mem::take(ch); all.extend(taken); all.sort_by(|a, b| keys.get(&a.id).cmp(&keys.get(&b.id))); + // Ruler guides (the merged layers' among them) stay above the art, each kind in that order. + all.sort_by_key(|c| c.is_ruler_guide()); *ch = all; - // The merged layers' guides go to it too. - d.rehome_guides(into); Ok(()) } @@ -318,9 +318,8 @@ 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| { - d.remove_many(&hidden)?; // Hidden layers are discarded with their guides. - d.drop_guides_of_deleted_layers(sel); + d.remove_many(&hidden)?; merge_into(d, into, &others)?; sel.prune(d); sel.deselect_uneditable(d); @@ -349,8 +348,8 @@ fn release(s: &mut Session, p: &Value) -> Result { NodeKind::Group { .. } => true, _ => return Err(bad(C, "release a layer or a group")), }; - // Its objects, bottom first (sublayers stay as they are). - let objects: Vec = n.children().into_iter().flatten().filter(|c| !c.is_layer()).map(|c| c.id).collect(); + // Its objects, bottom first (sublayers and ruler guides stay as they are). + let objects: Vec = n.children().into_iter().flatten().filter(|c| !c.is_layer() && !c.is_ruler_guide()).map(|c| c.id).collect(); if objects.is_empty() { return Err(EngineError::Other("there are no objects to release".into())); } @@ -386,7 +385,8 @@ fn release(s: &mut Session, p: &Value) -> Result { } let new: Vec = layers.iter().map(|l| l.id).collect(); let ch = d.children_mut(Some(parent))?; - let at = index.min(ch.len()); + // Below the parent's ruler guides. + let at = index.min(Node::guide_run_start(ch)); ch.splice(at..at, layers); // Copied type whose layout caches `reid` dropped has them again. if build { diff --git a/crates/engine/src/cmd/maskedit.rs b/crates/engine/src/cmd/maskedit.rs index f2d322a39c..debd0db684 100644 --- a/crates/engine/src/cmd/maskedit.rs +++ b/crates/engine/src/cmd/maskedit.rs @@ -259,10 +259,12 @@ pub(crate) fn follow_history(st: &mut crate::DocState, was: Option) { /// mode when the object or the layer is gone (e.g. deleted). pub(crate) fn sync(d: &mut Document) { let Some(me) = d.mask_edit else { return }; - let art: Option>> = d.node(me.layer).and_then(|l| l.children().cloned()); + // The layer's art: ruler guides on it are not part of the mask. + let art: Option>> = + d.node(me.layer).and_then(|l| l.children()).map(|ch| ch.iter().filter(|c| !c.is_ruler_guide()).cloned().collect()); let (Some(art), true) = (art, d.node(me.object).is_some_and(|n| n.extras().mask.is_some())) else { d.mask_edit = None; - let _ = d.remove(me.layer); + let _ = d.remove_edit_layer(me.layer); return; }; let new_art = match art.as_slice() { diff --git a/crates/engine/src/cmd/object/align.rs b/crates/engine/src/cmd/object/align.rs index b32275d1b7..c3a2898571 100644 --- a/crates/engine/src/cmd/object/align.rs +++ b/crates/engine/src/cmd/object/align.rs @@ -102,15 +102,15 @@ fn items_bounds(d: &Document, ids: &[NodeId], m: Measure) -> Vec<(NodeId, Rect)> ids.iter().filter_map(|id| Some((*id, m.of(d, *id)?))).collect() } -/// Move each object of `moves` by its offset and each ruler guide of `guides` (index, position) +/// Move each object of `moves` by its offset and each ruler guide of `guides` (id, position) /// to its position, as undo step `label`; pattern tiles move too when [`transform_patterns`] says /// so for `ids`. When every move is a rounding error (1e-9 pt or less on both axes), the document /// and its undo history stay as they are. -fn apply_moves(s: &mut Session, label: &str, p: &Value, ids: &[NodeId], moves: &[(NodeId, Vec2)], guides: &[(usize, f64)]) -> Result { +fn apply_moves(s: &mut Session, label: &str, p: &Value, ids: &[NodeId], moves: &[(NodeId, Vec2)], guides: &[(NodeId, f64)]) -> Result { let sc = Scaling { patterns: transform_patterns(s, p, ids)?, ..Scaling::default() }; - let guides: Vec<(usize, f64)> = { + let guides: Vec<(NodeId, f64)> = { let d = &s.doc()?.doc; - guides.iter().copied().filter(|&(i, pos)| d.guides.get(i).is_some_and(|g| (g.pos - pos).abs() > 1e-9)).collect() + guides.iter().copied().filter(|&(i, pos)| d.ruler_guide(i).is_some_and(|g| (g.pos - pos).abs() > 1e-9)).collect() }; if moves.iter().all(|(_, dv)| dv.x.abs() <= 1e-9 && dv.y.abs() <= 1e-9) && guides.is_empty() { return ok(); @@ -124,9 +124,7 @@ fn apply_moves(s: &mut Session, label: &str, p: &Value, ids: &[NodeId], moves: & super::pixelgrid::keep_compound_aligned(d, *id, sc); } for &(i, pos) in &guides { - if let Some(g) = d.guides.get_mut(i) { - g.pos = pos; - } + d.edit_ruler_guide(i, |g| g.pos = pos); } Ok(()) })?; @@ -143,7 +141,7 @@ fn align_to<'a>(s: &Session, p: &'a Value) -> Option<&'a str> { /// [`vectorcraft_doc::Selection::key_guide`]): its position, and whether it is vertical. fn key_guide(s: &Session, p: &Value) -> Option<(f64, bool)> { let st = s.doc().ok()?; - let g = st.doc.guides.get(st.selection.key_guide?)?; + let g = st.doc.ruler_guide(st.selection.key_guide?)?; (align_to(s, p) == Some("key")).then_some((g.pos, g.vertical)) } @@ -164,7 +162,7 @@ fn align_reference(s: &Session, p: &Value, ids: &[NodeId], m: Measure, own: Opti /// Bounds of the selected ruler guides (each guide is a zero-thickness line), for Align to /// Selection when no objects are selected. fn selected_guides_bounds(st: &crate::DocState) -> Option { - st.selection.guides.iter().filter_map(|&i| st.doc.guides.get(i)).fold(None, |acc: Option, g| { + st.selection.guides.iter().filter_map(|&i| st.doc.ruler_guide(i)).fold(None, |acc: Option, g| { let r = if g.vertical { Rect::new(g.pos, 0.0, g.pos, 0.0) } else { Rect::new(0.0, g.pos, 0.0, g.pos) }; Some(acc.map(|a| a.union(r)).unwrap_or(r)) }) @@ -264,7 +262,7 @@ pub(super) fn align(s: &mut Session, p: &Value) -> Result { /// `v` names. Align to the key object (or key guide) moves them too, and the key guide stays put. /// Align to the artboard moves them (#3463). Align to the selection moves them when only guides are /// selected; with objects in the selection they stay put. -fn guide_moves(s: &Session, p: &Value, r: Rect, h: Option<&str>, v: Option<&str>) -> Result> { +fn guide_moves(s: &Session, p: &Value, r: Rect, h: Option<&str>, v: Option<&str>) -> Result> { let st = s.doc()?; let to = align_to(s, p); let move_guides = match to { @@ -290,7 +288,7 @@ fn guide_moves(s: &Session, p: &Value, r: Rect, h: Option<&str>, v: Option<&str> .guides .iter() .filter(|&&i| !skip_key || Some(i) != st.selection.key_guide) - .filter_map(|&i| Some((i, at(st.doc.guides.get(i)?.vertical)?))) + .filter_map(|&i| Some((i, at(st.doc.ruler_guide(i)?.vertical)?))) .collect()) } @@ -361,7 +359,7 @@ pub(super) fn can_distribute(s: &Session) -> std::result::Result<(), String> { #[derive(Clone, Copy, PartialEq, Eq, Debug)] enum Spaced { Object(NodeId), - Guide(usize), + Guide(NodeId), } /// The selected objects `ids` with their bounds (measured by `m`), and the selected @@ -371,7 +369,7 @@ fn spaced_items(s: &Session, ids: &[NodeId], m: Measure, horiz: bool) -> Result< let st = s.doc()?; let objects = items_bounds(&st.doc, ids, m).into_iter().map(|(id, r)| (Spaced::Object(id), r)); let guides = st.selection.guides.iter().filter_map(|&i| { - let g = st.doc.guides.get(i).filter(|g| g.vertical == horiz)?; + let g = st.doc.ruler_guide(i).filter(|g| g.vertical == horiz)?; Some((Spaced::Guide(i), Rect::new(g.pos, g.pos, g.pos, g.pos))) }); Ok(objects.chain(guides).collect()) @@ -404,7 +402,7 @@ fn apply_spaced(s: &mut Session, label: &str, p: &Value, ids: &[NodeId], horiz: for &(it, delta) in deltas { match it { Spaced::Object(id) => moves.push((id, along(delta))), - Spaced::Guide(i) => guides.extend(d.guides.get(i).map(|g| (i, g.pos + delta))), + Spaced::Guide(i) => guides.extend(d.ruler_guide(i).map(|g| (i, g.pos + delta))), } } apply_moves(s, label, p, ids, &moves, &guides) diff --git a/crates/engine/src/cmd/object/arrange.rs b/crates/engine/src/cmd/object/arrange.rs index 7aef60fca0..ff5b950d03 100644 --- a/crates/engine/src/cmd/object/arrange.rs +++ b/crates/engine/src/cmd/object/arrange.rs @@ -39,7 +39,9 @@ pub(super) fn arrange(s: &mut Session, how: Arrange) -> Result { group.retain(|id| d.position(*id).is_some_and(|(_, idx, _)| idx >= floor)); } for (k, id) in group.iter().enumerate() { - let Some((_, idx, len)) = d.position(*id) else { continue }; + let Some((_, idx, _)) = d.position(*id) else { continue }; + // The parent's art, without the ruler guides that stay above it. + let len = d.children(par).map_or(0, |ch| Node::guide_run_start(ch)); if len <= floor { continue; } @@ -73,12 +75,16 @@ pub(super) fn send_to_current_layer(s: &mut Session, _: &Value) -> Result } // When the selected objects are already the topmost in the current layer, in paint order, the // document and its undo history stay as they are. - if st.doc.children(Some(layer)).is_some_and(|c| c.iter().rev().map(|n| n.id).take(ids.len()).eq(ids.iter().rev().copied())) { + // (The layer's ruler guides stay above its art.) + let art = |c: &&std::sync::Arc| !c.is_ruler_guide(); + if st.doc.children(Some(layer)).is_some_and(|c| c.iter().rev().filter(art).map(|n| n.id).take(ids.len()).eq(ids.iter().rev().copied())) { return ok(); } s.edit("Send to Current Layer", |d, _| { let moved = d.remove_many(&ids)?; - d.children_mut(Some(layer))?.extend(moved); + let ch = d.children_mut(Some(layer))?; + let at = Node::guide_run_start(ch); + ch.splice(at..at, moved); Ok(()) })?; ok() @@ -176,7 +182,7 @@ fn selected_lock_hide_roots(s: &Session) -> Result> { fn collect_flag(d: &Document, f: impl Fn(&Node) -> bool) -> Vec { let mut v = vec![]; d.walk(|n| { - if !n.is_layer() && f(n) { + if !n.is_layer() && !n.is_ruler_guide() && f(n) { v.push(n.id) } }); @@ -198,7 +204,7 @@ fn collect_flag_in_scope(s: &Session, cmd: &str, p: &Value, f: impl Fn(&Node) -> if let Some(node) = d.node(root) { for child in node.children().into_iter().flatten() { child.walk(&mut |n| { - if !n.is_layer() && f(n) { + if !n.is_layer() && !n.is_ruler_guide() && f(n) { ids.push(n.id); } }); @@ -222,7 +228,7 @@ fn collect_flag_in_scope(s: &Session, cmd: &str, p: &Value, f: impl Fn(&Node) -> } for c in d.node(*g).and_then(Node::children).into_iter().flatten() { c.walk(&mut |n| { - if !n.is_layer() && f(n) { + if !n.is_layer() && !n.is_ruler_guide() && f(n) { v.push(n.id) } }); diff --git a/crates/engine/src/cmd/object/transform.rs b/crates/engine/src/cmd/object/transform.rs index 6184860979..d102068947 100644 --- a/crates/engine/src/cmd/object/transform.rs +++ b/crates/engine/src/cmd/object/transform.rs @@ -145,16 +145,14 @@ fn apply_transform_moving_guides( } None if copy => { let guide_copies = if with_guides { - let copies: Vec<_> = sel.guides.iter().filter_map(|&i| d.guides.get(i).cloned()).collect(); + let copies: Vec<_> = sel.guides.iter().filter_map(|&i| d.ruler_guide(i)).collect(); (!copies.is_empty()).then_some(copies) } else { None }; let new_ids = duplicate_in(d, sel, &ids, Affine::IDENTITY)?; if let Some(copies) = guide_copies { - let first = d.guides.len(); - d.guides.extend(copies); - sel.guides = (first..d.guides.len()).collect(); + sel.guides = copies.iter().map(|g| d.add_ruler_guide(g)).collect(); } new_ids } @@ -177,9 +175,7 @@ fn apply_transform_moving_guides( } if with_guides && (delta.x != 0.0 || delta.y != 0.0) { for i in sel.guides.clone() { - if let Some(g) = d.guides.get_mut(i) { - *g = g.moved(delta); - } + d.edit_ruler_guide(i, |g| *g = g.moved(delta)); } } Ok(targets) @@ -224,10 +220,8 @@ pub(crate) fn preview_copies(s: &mut Session, roots: &[NodeId], with_guides: boo let ids = duplicate_in(Arc::make_mut(&mut doc), &mut selection, roots, Affine::IDENTITY)?; if has_guide_copies { let d = Arc::make_mut(&mut doc); - let first = d.guides.len(); - let copies: Vec<_> = st.selection.guides.iter().filter_map(|&i| d.guides.get(i).cloned()).collect(); - d.guides.extend(copies); - selection.guides = (first..d.guides.len()).collect(); + let copies: Vec<_> = st.selection.guides.iter().filter_map(|&i| d.ruler_guide(i)).collect(); + selection.guides = copies.iter().map(|g| d.add_ruler_guide(g)).collect(); } let k = Arc::new(crate::PreviewCopies { from: it.doc.clone(), roots: roots.to_vec(), doc, selection, ids }); it.copies = Some(k.clone()); diff --git a/crates/engine/src/cmd/overprint.rs b/crates/engine/src/cmd/overprint.rs index 7906a68e85..1e8d9ced62 100644 --- a/crates/engine/src/cmd/overprint.rs +++ b/crates/engine/src/cmd/overprint.rs @@ -214,7 +214,7 @@ fn attributes(s: &Session, p: &Value) -> Result { /// Overprint Black on `n` and its descendants; returns the objects changed. fn overprint_black_tree(n: &mut Node, op: &OverprintBlack, spots: &[String]) -> usize { let mut changed = usize::from(op.apply(n, spots)); - for c in n.children_mut().into_iter().flatten() { + for c in n.children_mut().into_iter().flatten().filter(|c| !c.is_ruler_guide()) { changed += overprint_black_tree(Arc::make_mut(c), op, spots); } changed diff --git a/crates/engine/src/cmd/paint.rs b/crates/engine/src/cmd/paint.rs index 4cc4bbc2e4..5cdb240e29 100644 --- a/crates/engine/src/cmd/paint.rs +++ b/crates/engine/src/cmd/paint.rs @@ -242,6 +242,10 @@ fn same_in_proxy(a: &Paint, b: &Paint) -> bool { /// change ([`leaf_targets`]): groups and layers stand for their contents (and so do blends and /// other containers inside them), a compound path for its parts. pub(crate) fn painted<'a>(n: &'a Node, top: bool, out: &mut Vec<&'a Node>) { + // A ruler guide has no paints. + if n.is_ruler_guide() { + return; + } let expand = match n.kind { NodeKind::Group { .. } | NodeKind::Layer { .. } => true, NodeKind::Compound { .. } => false, diff --git a/crates/engine/src/cmd/panelcmds.rs b/crates/engine/src/cmd/panelcmds.rs index 9f50507aff..34810fa766 100644 --- a/crates/engine/src/cmd/panelcmds.rs +++ b/crates/engine/src/cmd/panelcmds.rs @@ -352,7 +352,7 @@ pub(crate) fn artboard_copy(s: &mut Session, p: &Value, cut: bool) -> Result Result { s.edit("Clear", |d, selection| { if with_guides { let gone = selection.guides.clone(); - d.retain_guides(selection, |i, _| !gone.contains(&i)); + d.retain_guides(selection, |g| !g.id.is_some_and(|id| gone.contains(&id))); } let targets: BTreeSet = whole.into_iter().filter(|id| d.node(*id).is_some_and(|n| !n.is_layer())).collect(); let whole_ids: Vec = targets diff --git a/crates/engine/src/cmd/pathops.rs b/crates/engine/src/cmd/pathops.rs index 3da73c0385..5b9e25d354 100644 --- a/crates/engine/src/cmd/pathops.rs +++ b/crates/engine/src/cmd/pathops.rs @@ -1351,7 +1351,9 @@ fn split_into_grid(s: &mut Session, p: &Value) -> Result { let node = shape_node(d, c, Some(&top)); out.push(d.insert(par, idx + k, node)?); } - d.guides.extend(edges.iter().map(|&(vertical, pos)| vectorcraft_doc::Guide { layer, ..vectorcraft_doc::Guide::new(vertical, pos) })); + for &(vertical, pos) in &edges { + d.add_ruler_guide(&vectorcraft_doc::Guide { layer, ..vectorcraft_doc::Guide::new(vertical, pos) }); + } sel.set(out.iter().copied()); Ok(out) })?; diff --git a/crates/engine/src/cmd/patterncmds.rs b/crates/engine/src/cmd/patterncmds.rs index ef7820b71f..aeed4bf522 100644 --- a/crates/engine/src/cmd/patterncmds.rs +++ b/crates/engine/src/cmd/patterncmds.rs @@ -271,8 +271,9 @@ fn enter_edit(d: &mut Document, sel: &mut Selection, name: &str, original: Optio /// Leave editing mode: drop the layer, return the edit record. fn leave_edit(d: &mut Document, sel: &mut Selection) -> Result<(PatternEdit, Vec>)> { let pe = d.pattern_edit.take().ok_or_else(|| EngineError::Other("not in pattern editing mode".into()))?; - let art = match d.remove(pe.layer) { - Ok(l) => l.children().cloned().unwrap_or_default(), + // The tile art: ruler guides on the edit layer are not part of it and stay in the document. + let art = match d.remove_edit_layer(pe.layer) { + Ok(l) => l.children().into_iter().flatten().cloned().collect(), Err(_) => vec![], }; sel.clear(); @@ -427,7 +428,8 @@ fn pattern_save_copy(s: &mut Session, p: &Value) -> Result { let name = s.edit("Save a Copy", |d, _| { let pe = d.pattern_edit.clone().ok_or_else(|| EngineError::Other("not in pattern editing mode".into()))?; let mut def = d.pattern(&pe.pattern).cloned().ok_or_else(|| EngineError::Other("pattern missing".into()))?; - let art: Vec> = d.node(pe.layer).and_then(|l| l.children().cloned()).unwrap_or_default(); + let art: Vec> = + d.node(pe.layer).into_iter().flat_map(|l| l.children().into_iter().flatten()).filter(|c| !c.is_ruler_guide()).cloned().collect(); let name = unique_pattern_name(d, wanted.as_deref().unwrap_or(&format!("{} copy", pe.pattern.trim()))); def.name = name.clone(); def.art = copies(d, &art).into_iter().map(Arc::new).collect(); @@ -481,7 +483,8 @@ fn pattern_options(s: &mut Session, p: &Value) -> Result { let out_name = new_name.clone().unwrap_or(name.clone()); s.edit("Pattern Options", |d, _| { // While editing, the tile art is the edit layer's. - let live_art: Option>> = edit_layer.and_then(|l| d.node(l)).and_then(|l| l.children().cloned()); + let live_art: Option>> = + edit_layer.and_then(|l| d.node(l)).and_then(|l| l.children()).map(|ch| ch.iter().filter(|c| !c.is_ruler_guide()).cloned().collect()); let def = d.pattern_mut(&name).ok_or_else(|| EngineError::Other("pattern missing".into()))?; def.tile_type = tt; def.overlap = overlap; diff --git a/crates/engine/src/cmd/place.rs b/crates/engine/src/cmd/place.rs index db9bbf67d1..1ded346d86 100644 --- a/crates/engine/src/cmd/place.rs +++ b/crates/engine/src/cmd/place.rs @@ -377,7 +377,7 @@ fn placeable(l: &Node) -> bool { fn art_of<'a>(nodes: impl Iterator>) -> Vec { nodes .filter_map(|c| match &c.kind { - NodeKind::Path { guide: true, .. } => None, + _ if c.is_guide() => None, NodeKind::Layer { children, clip, .. } => placeable(c).then(|| { let mut g = (**c).clone(); g.kind = NodeKind::Group { children: art_of(children.iter()).into_iter().map(Arc::new).collect(), clip: *clip }; diff --git a/crates/engine/src/cmd/printadvanced.rs b/crates/engine/src/cmd/printadvanced.rs index 537658447b..8e1c3f5be8 100644 --- a/crates/engine/src/cmd/printadvanced.rs +++ b/crates/engine/src/cmd/printadvanced.rs @@ -48,12 +48,13 @@ pub(crate) fn prepare(cmd: &str, doc: &Document, set: &PrintSettings, saved: &[F Ok(Some(flatten_document(&printed, &o)?.unwrap_or_else(|| printed.into_owned()))) } -/// Leave guide paths out of `nodes` (the renderer draws them; they never print). +/// Leave guides out of `nodes`: guide paths (the renderer draws them; they never print) and ruler +/// guides. fn drop_guides(nodes: &mut Vec>) { fn has_guides(n: &Node) -> bool { - matches!(n.kind, NodeKind::Path { guide: true, .. }) || n.children().is_some_and(|c| c.iter().any(|c| has_guides(c))) + n.is_guide() || n.children().is_some_and(|c| c.iter().any(|c| has_guides(c))) } - nodes.retain(|n| !matches!(n.kind, NodeKind::Path { guide: true, .. })); + nodes.retain(|n| !n.is_guide()); for n in nodes.iter_mut().filter(|n| has_guides(n)) { if let Some(children) = Arc::make_mut(n).children_mut() { drop_guides(children); diff --git a/crates/engine/src/cmd/rasterfx.rs b/crates/engine/src/cmd/rasterfx.rs index d41a4529b7..566a16dbfd 100644 --- a/crates/engine/src/cmd/rasterfx.rs +++ b/crates/engine/src/cmd/rasterfx.rs @@ -263,7 +263,7 @@ fn retain_effects(n: &mut Node, members: bool, keep: &dyn Fn(&Effect) -> bool) { it.effects_mut().retain(keep); } if members { - for c in n.children_mut().into_iter().flatten() { + for c in n.children_mut().into_iter().flatten().filter(|c| !c.is_ruler_guide()) { retain_effects(Arc::make_mut(c), true, keep); } } @@ -367,7 +367,9 @@ pub(crate) fn replace_with_image(mut v: Node, image: Node) -> Node { v.appearance = Default::default(); v.set_clips(false); if let Some(ch) = v.children_mut() { - *ch = vec![Arc::new(image)]; + // Its ruler guides stay above the image. + let guides = ch.split_off(Node::guide_run_start(ch)); + *ch = std::iter::once(Arc::new(image)).chain(guides).collect(); } v } diff --git a/crates/engine/src/cmd/select.rs b/crates/engine/src/cmd/select.rs index 4ef1e6bc6a..25bf6db542 100644 --- a/crates/engine/src/cmd/select.rs +++ b/crates/engine/src/cmd/select.rs @@ -442,15 +442,16 @@ fn key(s: &mut Session, p: &Value) -> Result { if id.is_some() { return Err(bad("select.key", "give `id` or `guide`, not both")); } - if !sel.guides.contains(&g) { + let gid = super::docmenu::guide_ids(&s.doc()?.doc).get(g).copied(); + let Some(gid) = gid.filter(|id| sel.guides.contains(id)) else { return Err(bad("select.key", format!("guide {g} is not selected"))); - } + }; if sel.is_empty() { return Err(bad("select.key", "select objects to align to the guide")); } s.select(|_, sel| { sel.key = None; - sel.key_guide = Some(g); + sel.key_guide = Some(gid); })?; return key_result(s); } @@ -472,9 +473,9 @@ fn key(s: &mut Session, p: &Value) -> Result { /// `{key}`, with `guide` when a guide is the key. fn key_result(s: &Session) -> Result { - let sel = &s.doc()?.selection; - let mut out = json!({ "key": sel.key }); - if let Some(g) = sel.key_guide { + let st = s.doc()?; + let mut out = json!({ "key": st.selection.key }); + if let Some(g) = st.selection.key_guide.and_then(|id| super::docmenu::guide_indexes(&st.doc, &[id]).first().copied()) { out["guide"] = json!(g); } Ok(out) diff --git a/crates/engine/src/cmd/slices.rs b/crates/engine/src/cmd/slices.rs index 5792c44fdf..06ec067934 100644 --- a/crates/engine/src/cmd/slices.rs +++ b/crates/engine/src/cmd/slices.rs @@ -340,8 +340,9 @@ fn release(s: &mut Session, _: &Value) -> Result { fn from_guides(s: &mut Session, _: &Value) -> Result { let d = &s.doc()?.doc; let region = d.slice_clip().ok_or_else(|| EngineError::Other("Create from Guides: the document has no artboard".into()))?; + let guides = d.ruler_guides(); let cuts = |vertical: bool, lo: f64, hi: f64| -> Vec { - let mut v: Vec = d.guides.iter().filter(|g| g.vertical == vertical && g.pos > lo && g.pos < hi).map(|g| g.pos).collect(); + let mut v: Vec = guides.iter().filter(|g| g.vertical == vertical && g.pos > lo && g.pos < hi).map(|g| g.pos).collect(); v.extend([lo, hi]); v.sort_by(f64::total_cmp); v.dedup(); diff --git a/crates/engine/src/cmd/style.rs b/crates/engine/src/cmd/style.rs index d73f01eaae..d31031d5d4 100644 --- a/crates/engine/src/cmd/style.rs +++ b/crates/engine/src/cmd/style.rs @@ -153,7 +153,8 @@ fn descends(n: &Node, top: bool) -> bool { /// The objects whose appearance a style applied to `n` sets: `n`, or its painted contents. fn painted<'a>(n: &'a Node, top: bool, out: &mut Vec<&'a Node>) { match n.children() { - Some(ch) if descends(n, top) => ch.iter().for_each(|c| painted(c, false, out)), + // A layer's ruler guides have no appearance to set. + Some(ch) if descends(n, top) => ch.iter().filter(|c| !c.is_ruler_guide()).for_each(|c| painted(c, false, out)), _ => out.push(n), } } diff --git a/crates/engine/src/cmd/symboledit.rs b/crates/engine/src/cmd/symboledit.rs index 22cfc74e9f..a5f677f5dd 100644 --- a/crates/engine/src/cmd/symboledit.rs +++ b/crates/engine/src/cmd/symboledit.rs @@ -248,7 +248,7 @@ pub(crate) fn leave(s: &mut Session, _: &Value) -> Result { /// the instance editing was entered from. The symbol already holds the layer's art ([`sync`]). pub(crate) fn finish(d: &mut Document, sel: &mut Selection) { let Some(se) = d.symbol_edit.take() else { return }; - let _ = d.remove(se.layer); + let _ = d.remove_edit_layer(se.layer); match se.instance.filter(|i| d.node(*i).is_some()) { Some(i) => sel.set([i]), None => sel.clear(), @@ -480,18 +480,24 @@ fn follow_objects(d: &mut Document, name: &str, old: (&[Arc], bool), new: /// an instance of the symbol itself. pub(crate) fn sync(before: &Document, d: &mut Document) -> Result<()> { let Some(se) = d.symbol_edit.clone() else { return Ok(()) }; - let art: Option>> = d.node(se.layer).and_then(|l| l.children().cloned()); + // The layer's art: ruler guides on it are not part of the symbol. + let art_of = |d: &Document| -> Option>> { + d.node(se.layer).and_then(|l| l.children()).map(|ch| ch.iter().filter(|c| !c.is_ruler_guide()).cloned().collect()) + }; + let art = art_of(d); let (Some(art), Some(index)) = (art, d.symbols.iter().position(|x| x.name == se.symbol)) else { d.symbol_edit = None; - let _ = d.remove(se.layer); + let _ = d.remove_edit_layer(se.layer); return Ok(()); }; // Entering, and edits that left the layer's art alone, write nothing. let same = |b: &[Arc]| b.len() == art.len() && b.iter().zip(&art).all(|(a, b)| Arc::ptr_eq(a, b)); let (old, old_group) = match before.symbol_edit.as_ref().filter(|b| b.layer == se.layer) { None => return Ok(()), - Some(_) if before.node(se.layer).and_then(|l| l.children()).is_some_and(|b| same(b)) => return Ok(()), - Some(b) => (before.node(se.layer).and_then(|l| l.children().cloned()).unwrap_or_default(), b.group), + Some(b) => match art_of(before) { + Some(old) if same(&old) => return Ok(()), + old => (old.unwrap_or_default(), b.group), + }, }; if art.iter().any(|n| holds_symbol(d, n, &se.symbol, &mut HashSet::new())) { return Err(EngineError::Other("a symbol can't hold an instance of itself".into())); diff --git a/crates/engine/src/cmd/wand.rs b/crates/engine/src/cmd/wand.rs index 6e89a7544d..c79ec0b86e 100644 --- a/crates/engine/src/cmd/wand.rs +++ b/crates/engine/src/cmd/wand.rs @@ -137,7 +137,7 @@ fn paintable(doc: &Document) -> Vec { if matches!(n.kind, NodeKind::Compound { .. }) { compounds.push(n.id); out.push(n.id); - } else if !n.is_container() { + } else if !n.is_container() && !n.is_ruler_guide() { out.push(n.id); } }); diff --git a/crates/engine/src/inspect.rs b/crates/engine/src/inspect.rs index c474e25756..52ef35060d 100644 --- a/crates/engine/src/inspect.rs +++ b/crates/engine/src/inspect.rs @@ -112,6 +112,8 @@ pub fn node_summary_opts(n: &Node, opts: SummaryOpts, symbols: &SymbolBoxes) -> _ => {} } if let Some(ch) = n.children() { + // A layer's ruler guides are listed by `guide.list`, not as its objects. + let ch: Vec<&std::sync::Arc> = ch.iter().filter(|c| !c.is_ruler_guide()).collect(); let total = ch.len(); let take_n = opts.child_limit.map_or(total, |l| l.min(total as u64) as usize); if opts.depth.is_none_or(|d| d > 0) { @@ -203,6 +205,10 @@ fn ancestors(d: &Document, id: vectorcraft_doc::NodeId) -> Vec { } fn collect_kind<'a>(n: &'a Node, kind: &str, path: &mut Vec, out: &mut Vec>) { + // Ruler guides are not objects (`guide.list` lists them). + if n.is_ruler_guide() { + return; + } if n.kind_label().eq_ignore_ascii_case(kind) { out.push((n, path.clone())); } @@ -295,7 +301,7 @@ fn document_base(s: &Session) -> Value { // The key object Align aligns to and Distribute Spacing spaces from (`select.key`). "key": st.selection.key.map(|k| k.0), // The ruler guide that is the key instead (`select.key {guide}`): an index into `guide.list`. - "keyGuide": st.selection.key_guide, + "keyGuide": st.selection.key_guide.and_then(|id| crate::cmd::guide_indexes(d, &[id]).first().copied()), "history": st.history.undo.iter().map(|h| h.label.clone()).collect::>(), "redo": st.history.redo.iter().rev().map(|h| h.label.clone()).collect::>(), "objects": d.node_count(), @@ -432,7 +438,8 @@ pub fn find_nodes(doc: &Document, filter: &FindFilter, limit: u64) -> (Vec, } fn walk(nodes: &[Arc], w: &mut Walk) { - for n in nodes.iter().rev() { + // Ruler guides are not objects (`guide.list` lists them). + for n in nodes.iter().rev().filter(|n| !n.is_ruler_guide()) { if w.filter.hits(n, &w.symbols) { w.ids.push(n.id); if (w.out.len() as u64) < w.limit { diff --git a/crates/engine/src/sanity.rs b/crates/engine/src/sanity.rs index b28cc23ca8..22e0ce956f 100644 --- a/crates/engine/src/sanity.rs +++ b/crates/engine/src/sanity.rs @@ -45,7 +45,7 @@ pub(crate) fn doc_sane(d: &Document, sel: &Selection) -> bool { && d.ruler_origin.x.abs() <= MAX_COORD && d.ruler_origin.y.abs() <= MAX_COORD && d.artboards.iter().all(|a| rect_in_range(a.rect)) - && d.guides.iter().all(|g| g.pos.is_finite() && g.pos.abs() <= MAX_COORD) + && d.ruler_guides().iter().all(|g| g.pos.is_finite() && g.pos.abs() <= MAX_COORD) && objects_in_range(d, &sel.objects) } diff --git a/crates/engine/src/session/documents.rs b/crates/engine/src/session/documents.rs index e34eb711bd..63fbcd5a78 100644 --- a/crates/engine/src/session/documents.rs +++ b/crates/engine/src/session/documents.rs @@ -300,8 +300,8 @@ impl Session { cmd::compositefonts::filter_loaded(&mut doc); // Inline graphics in text take their size from their symbols' art (not saved). doc.resolve_inline_art(); - // Guides from files saved before guides had layers go on a layer. - doc.adopt_guides(); + // Ruler guides a reader left in the saved list become nodes on their layers. + doc.guides_into_nodes(); cmd::dimension::conform(&mut doc); self.reset_tool_for_doc_switch(); let mut st = DocState::new(doc, path); @@ -328,7 +328,7 @@ impl Session { doc.limit_depth(); cmd::compositefonts::filter_loaded(&mut doc); doc.resolve_inline_art(); - doc.adopt_guides(); + doc.guides_into_nodes(); cmd::dimension::conform(&mut doc); // In the back, type set in a font named like a composite font is measured when the document // comes to the front and its own composite fonts are registered, not in the front diff --git a/crates/engine/src/session/execute.rs b/crates/engine/src/session/execute.rs index 34c4eed727..abe9121eff 100644 --- a/crates/engine/src/session/execute.rs +++ b/crates/engine/src/session/execute.rs @@ -99,6 +99,13 @@ impl Session { // the artboards the journal recorded need only a document too, whatever is selected and // whichever tool is chosen. let given = given_ids(spec.params, params); + // The guide commands take ruler guides by index; an object command given one's id is refused. + if let (Some(ids), Some(st)) = (&given, self.active()) + && let Some(guide) = ids.iter().find(|i| st.doc.node(**i).is_some_and(|n| n.is_ruler_guide())) + { + let value = guide.0; + return Err(cmd::bad(id, format!("id {value} is a ruler guide, not an object"))); + } let enabled = if (id == "edit.clear" && params.get("ids").is_some()) || (matches!(id, "edit.copy" | "edit.cut") && params.get("selectedArtboards").is_some()) { diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 9c6de6a89e..bc0323246d 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -215,6 +215,18 @@ fn rect(s: &mut Session, x: f64, y: f64, w: f64, h: f64) -> NodeId { NodeId(r["id"].as_u64().unwrap()) } +/// The active document's selected ruler guides, by their `guide.list` indexes. +pub(crate) fn selected_guides(s: &Session) -> Vec { + let st = s.doc().unwrap(); + crate::cmd::guide_indexes(&st.doc, &st.selection.guides) +} + +/// The active document's key guide, by its `guide.list` index. +pub(crate) fn key_guide(s: &Session) -> Option { + let st = s.doc().unwrap(); + st.selection.key_guide.and_then(|k| crate::cmd::guide_indexes(&st.doc, &[k]).first().copied()) +} + #[test] fn create_and_undo_redo() { let mut s = session(); diff --git a/crates/engine/src/tests/constrain_tools.rs b/crates/engine/src/tests/constrain_tools.rs index 6a8317cfeb..a93965eb0b 100644 --- a/crates/engine/src/tests/constrain_tools.rs +++ b/crates/engine/src/tests/constrain_tools.rs @@ -78,7 +78,7 @@ fn constrain_angle_turns_the_arrow_keys() { let g = s.execute("guide.add", &json!({"vertical": true, "pos": 50})).unwrap()["index"].as_u64().unwrap(); s.execute("guide.select", &json!({"indexes": [g]})).unwrap(); s.execute("object.nudge", &json!({"dx": 1, "dy": 0})).unwrap(); - let pos = s.doc().unwrap().doc.guides[g as usize].pos; + let pos = s.doc().unwrap().doc.ruler_guides()[g as usize].pos; assert!(close(pos, 60.0), "a vertical guide moves 10 right: {pos}"); } @@ -167,7 +167,7 @@ fn constrain_angle_keeps_guides_selected_with_art_on_the_page_axes() { let h = s.execute("guide.add", &json!({"vertical": false, "pos": 50})).unwrap()["index"].as_u64().unwrap() as usize; s.execute("select.set", &json!({"ids": [id.0]})).unwrap(); s.execute("guide.select", &json!({"indexes": [v, h], "toggle": true})).unwrap(); - let pos = |s: &Session| s.doc().unwrap().doc.guides.iter().map(|g| g.pos).collect::>(); + let pos = |s: &Session| s.doc().unwrap().doc.ruler_guides().iter().map(|g| g.pos).collect::>(); let at = centre(&s, id); s.execute("object.nudge", &json!({"dx": 1, "dy": 0})).unwrap(); assert_eq!(centre(&s, id) - at, Vec2::new(0.0, -10.0), "the art moves along the turned axis"); diff --git a/crates/engine/src/tests/guide_clipboard.rs b/crates/engine/src/tests/guide_clipboard.rs index d509dfa206..4211511021 100644 --- a/crates/engine/src/tests/guide_clipboard.rs +++ b/crates/engine/src/tests/guide_clipboard.rs @@ -12,7 +12,7 @@ fn run(s: &mut Session, id: &str, p: Value) -> Value { /// The active document's guides: (vertical, pos, layer). fn guides(s: &Session) -> Vec<(bool, f64, Option)> { - s.doc().unwrap().doc.guides.iter().map(|g| (g.vertical, g.pos, g.layer)).collect() + s.doc().unwrap().doc.ruler_guides().iter().map(|g| (g.vertical, g.pos, g.layer)).collect() } #[test] @@ -31,7 +31,7 @@ fn ruler_guides_copy_and_paste_into_another_document() { let r = run(&mut s, "edit.paste", json!({})); assert_eq!(r["guides"], 2); assert_eq!(guides(&s), [(true, 120.0, Some(layer)), (false, 80.0, Some(layer))], "where they were, not offset"); - assert_eq!(s.doc().unwrap().selection.guides, [0, 1]); + assert_eq!(super::selected_guides(&s), [0, 1]); assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1); run(&mut s, "edit.undo", json!({})); assert!(guides(&s).is_empty()); @@ -104,9 +104,9 @@ fn two_pastes_add_their_own_guides_on_the_current_layer_and_svg_has_none() { assert!(s.clipboard.flavour_types(true, true).is_empty(), "nor any other format (the app empties the system clipboard)"); let layer = NodeId(run(&mut s, "layer.new", json!({}))["id"].as_u64().unwrap()); run(&mut s, "edit.paste", json!({})); - assert_eq!(s.doc().unwrap().selection.guides, [1], "the first paste selects its own guide"); + assert_eq!(super::selected_guides(&s), [1], "the first paste selects its own guide"); run(&mut s, "edit.paste", json!({})); - assert_eq!(s.doc().unwrap().selection.guides, [2], "the second paste selects its own guide"); + assert_eq!(super::selected_guides(&s), [2], "the second paste selects its own guide"); let g = guides(&s); assert_eq!(g.len(), 3, "two pastes, two more guides: {g:?}"); assert_eq!((g[1].2, g[2].2), (Some(layer), Some(layer)), "on the layer new art goes to"); @@ -116,7 +116,7 @@ fn two_pastes_add_their_own_guides_on_the_current_layer_and_svg_has_none() { // Hiding that layer hides the guides pasted on it: they follow its visibility (and lock). run(&mut s, "layer.setProps", json!({"id": layer.0, "visible": false})); let st = s.doc().unwrap(); - let shown: Vec = st.doc.guides.iter().map(|g| st.doc.guide_shown(g)).collect(); + let shown: Vec = st.doc.ruler_guides().iter().map(|g| st.doc.guide_shown(g)).collect(); assert_eq!(shown, [true, false, false]); // With an object, the SVG holds the object and no guide. run(&mut s, "layer.setProps", json!({"id": layer.0, "visible": true})); diff --git a/crates/engine/src/tests/keyguide.rs b/crates/engine/src/tests/keyguide.rs index 09acdd726d..327c0b4b24 100644 --- a/crates/engine/src/tests/keyguide.rs +++ b/crates/engine/src/tests/keyguide.rs @@ -37,14 +37,14 @@ fn a_clicked_guide_is_the_key_and_align_lines_the_objects_up_on_it() { // Click an object, Shift-click the guide, then click the guide again. click(&mut s, 110.0, 110.0, false); click(&mut s, 500.0, 400.0, true); - assert_eq!((s.doc().unwrap().selection.objects.clone(), s.doc().unwrap().selection.guides.clone()), (vec![a], vec![0])); + assert_eq!((s.doc().unwrap().selection.objects.clone(), super::selected_guides(&s)), (vec![a], vec![0])); click(&mut s, 500.0, 400.0, false); - assert_eq!(s.doc().unwrap().selection.key_guide, Some(0), "the guide is the key"); + assert_eq!(super::key_guide(&s), Some(0), "the guide is the key"); assert_eq!(s.execute("document.inspect", &json!({})).unwrap()["keyGuide"], 0); // Shift-click a third object: the guide stays the key. click(&mut s, 310.0, 260.0, true); assert_eq!(s.doc().unwrap().selection.objects, vec![a, b]); - assert_eq!(s.doc().unwrap().selection.key_guide, Some(0)); + assert_eq!(super::key_guide(&s), Some(0)); let (ya, yb) = (bounds(&s, a).y0, bounds(&s, b).y0); s.execute("object.align", &json!({"horizontal": "center", "vertical": "center"})).unwrap(); // Both centred on the guide's x; a vertical guide fixes no y, so they keep theirs. @@ -52,12 +52,12 @@ fn a_clicked_guide_is_the_key_and_align_lines_the_objects_up_on_it() { let r = bounds(&s, id); assert!((r.center().x - 500.0).abs() < 1e-6 && (r.y0 - y).abs() < 1e-6, "{id:?}: {r:?}"); } - assert_eq!(s.doc().unwrap().doc.guides[0].pos, 500.0, "the guide stays put"); + assert_eq!(positions(&s)[0], 500.0, "the guide stays put"); s.execute("object.align", &json!({"horizontal": "left"})).unwrap(); assert!((bounds(&s, a).x0 - 500.0).abs() < 1e-6 && (bounds(&s, b).x0 - 500.0).abs() < 1e-6); // A click on the key guide again: no key. click(&mut s, 500.0, 400.0, false); - assert_eq!(s.doc().unwrap().selection.key_guide, None); + assert_eq!(super::key_guide(&s), None); } #[test] @@ -96,22 +96,22 @@ fn select_key_takes_only_a_selected_guide_with_objects() { s.execute("select.key", &json!({"guide": 1})).unwrap(); // An object key replaces the guide key, and the other way round. s.execute("select.key", &json!({"id": b.0})).unwrap(); - assert_eq!((s.doc().unwrap().selection.key, s.doc().unwrap().selection.key_guide), (Some(b), None)); + assert_eq!((s.doc().unwrap().selection.key, super::key_guide(&s)), (Some(b), None)); s.execute("select.key", &json!({"guide": 1})).unwrap(); - assert_eq!((s.doc().unwrap().selection.key, s.doc().unwrap().selection.key_guide), (None, Some(1))); + assert_eq!((s.doc().unwrap().selection.key, super::key_guide(&s)), (None, Some(1))); // Deleting a guide before it keeps the key on the same guide. s.execute("guide.remove", &json!({"index": 0})).unwrap(); - assert_eq!(s.doc().unwrap().selection.key_guide, Some(0)); - assert_eq!(s.doc().unwrap().doc.guides[0].pos, 600.0); + assert_eq!(super::key_guide(&s), Some(0)); + assert_eq!(positions(&s)[0], 600.0); // Taking the guide out of the selection, or every object, lets the key go. s.execute("guide.select", &json!({"indexes": [0], "toggle": true})).unwrap(); - assert_eq!(s.doc().unwrap().selection.key_guide, None); + assert_eq!(super::key_guide(&s), None); s.execute("guide.select", &json!({"indexes": [0], "toggle": true})).unwrap(); s.execute("select.key", &json!({"guide": 0})).unwrap(); s.execute("select.toggle", &json!({"id": a.0})).unwrap(); - assert_eq!(s.doc().unwrap().selection.key_guide, Some(0), "one object is enough"); + assert_eq!(super::key_guide(&s), Some(0), "one object is enough"); s.execute("select.toggle", &json!({"id": b.0})).unwrap(); - assert_eq!(s.doc().unwrap().selection.key_guide, None, "no object to align"); + assert_eq!(super::key_guide(&s), None, "no object to align"); } #[test] @@ -141,12 +141,12 @@ fn selected_guides_align_to_a_clicked_key_object() { click(&mut s, 110.0, 110.0, false); click(&mut s, 500.0, 300.0, true); click(&mut s, 300.0, 400.0, true); - assert_eq!(s.doc().unwrap().selection.guides, vec![0, 1]); + assert_eq!(super::selected_guides(&s), vec![0, 1]); click(&mut s, 110.0, 110.0, false); assert_eq!(s.doc().unwrap().selection.key, Some(a), "the object is the key"); let before = bounds(&s, a); s.execute("object.align", &json!({"horizontal": "center", "vertical": "center"})).unwrap(); - let guides = |s: &Session| s.doc().unwrap().doc.guides.iter().map(|g| g.pos).collect::>(); + let guides = positions; assert_eq!(guides(&s), [120.0, 110.0], "the vertical guide on the centre's x, the horizontal one on its y"); assert_eq!(bounds(&s, a), before, "the key stays put"); s.execute("object.align", &json!({"horizontal": "right"})).unwrap(); @@ -174,15 +174,15 @@ fn guides_align_to_a_key_object_among_others_and_parallel_ones_to_a_key_guide() s.execute("select.key", &json!({"id": b.0})).unwrap(); s.execute("object.align", &json!({"horizontal": "left"})).unwrap(); assert_eq!(bounds(&s, a).x0, 300.0); - assert!(s.doc().unwrap().doc.guides.iter().all(|g| g.pos == 300.0)); + assert!(positions(&s).iter().all(|p| *p == 300.0)); // Aligning to the selection leaves guides alone. s.execute("object.align", &json!({"horizontal": "right", "to": "selection"})).unwrap(); - assert!(s.doc().unwrap().doc.guides.iter().all(|g| g.pos == 300.0)); + assert!(positions(&s).iter().all(|p| *p == 300.0)); // A key guide stays put, and the other vertical guide lines up on it. s.execute("guide.move", &json!({"index": 1, "pos": 700})).unwrap(); s.execute("select.key", &json!({"guide": 1})).unwrap(); s.execute("object.align", &json!({"horizontal": "center"})).unwrap(); - assert_eq!(s.doc().unwrap().doc.guides.iter().map(|g| g.pos).collect::>(), [700.0, 700.0]); + assert_eq!(positions(&s), [700.0, 700.0]); } /// Objects 40 pt wide at `xs` (y 100) and a vertical guide at `pos` that is their key. @@ -208,7 +208,7 @@ fn distribute_with_a_spacing_spaces_the_objects_from_a_key_guide() { for (h, want) in [("left", [330.0, 340.0, 360.0]), ("center", [310.0, 320.0, 340.0]), ("right", [290.0, 300.0, 320.0])] { s.execute("object.distribute", &json!({"horizontal": h, "spacing": 10})).unwrap(); assert_eq!(lefts(&s, &ids), want, "{h}"); - assert_eq!(s.doc().unwrap().doc.guides[0].pos, 350.0, "{h}: the guide stays put"); + assert_eq!(positions(&s)[0], 350.0, "{h}: the guide stays put"); s.execute("edit.undo", &json!({})).unwrap(); assert_eq!(lefts(&s, &ids), [100.0, 300.0, 500.0], "{h}: one undo step"); } @@ -300,14 +300,14 @@ fn guides_alone(vertical: &[f64], horizontal: &[f64]) -> Session { /// Selects the objects `ids` and the ruler guides `guides`, and nothing else. fn select_with_guides(s: &mut Session, ids: &[NodeId], guides: &[usize]) { s.execute("select.set", &json!({"ids": ids.iter().map(|id| id.0).collect::>()})).unwrap(); - let now = s.doc().unwrap().selection.guides.clone(); - let flip: Vec = (0..s.doc().unwrap().doc.guides.len()).filter(|i| now.contains(i) != guides.contains(i)).collect(); + let now = super::selected_guides(s); + let flip: Vec = (0..positions(s).len()).filter(|i| now.contains(i) != guides.contains(i)).collect(); s.execute("guide.select", &json!({"indexes": flip, "toggle": true})).unwrap(); assert_eq!((s.doc().unwrap().selection.objects.len(), s.doc().unwrap().selection.guides.len()), (ids.len(), guides.len())); } fn positions(s: &Session) -> Vec { - s.doc().unwrap().doc.guides.iter().map(|g| g.pos).collect() + s.doc().unwrap().doc.ruler_guides().iter().map(|g| g.pos).collect() } #[test] diff --git a/crates/engine/src/tests/menucmds.rs b/crates/engine/src/tests/menucmds.rs index e1def8a6fa..e9a30c57e1 100644 --- a/crates/engine/src/tests/menucmds.rs +++ b/crates/engine/src/tests/menucmds.rs @@ -3686,7 +3686,7 @@ fn make_release_clear_guides() { s.execute("guide.add", &json!({"vertical": true, "pos": 100})).unwrap(); let n = undo_len(&s); assert_eq!(s.execute("view.guides.clear", &json!({})).unwrap()["count"], 2); - assert!(s.doc().unwrap().doc.guides.is_empty()); + assert!(s.doc().unwrap().doc.ruler_guides().is_empty()); assert!(s.doc().unwrap().doc.node(a).is_none()); assert_eq!(undo_len(&s), n + 1); } @@ -3751,13 +3751,13 @@ fn ruler_guides_and_lock() { let mut s = session(); assert_eq!(s.execute("guide.add", &json!({"vertical": false, "pos": 50})).unwrap()["index"], 0); s.execute("guide.move", &json!({"index": 0, "pos": 70})).unwrap(); - assert_eq!(s.doc().unwrap().doc.guides[0].pos, 70.0); + assert_eq!(s.doc().unwrap().doc.ruler_guides()[0].pos, 70.0); assert_eq!(s.execute("view.guides.lock", &json!({})).unwrap()["locked"], true); assert!(s.guides_locked()); assert!(matches!(s.execute("guide.remove", &json!({"index": 0})), Err(EngineError::Disabled(..)))); s.execute("view.guides.lock", &json!({})).unwrap(); s.execute("guide.remove", &json!({"index": 0})).unwrap(); - assert!(s.doc().unwrap().doc.guides.is_empty()); + assert!(s.doc().unwrap().doc.ruler_guides().is_empty()); assert!(s.execute("guide.remove", &json!({"index": 0})).is_err()); } @@ -3804,7 +3804,7 @@ fn invalid_guide_lock_parameters_keep_guides_selected() { for params in [json!({"locked": "false"}), json!({"locked": 1})] { assert!(s.execute("view.guides.lock", ¶ms).is_err(), "accepted {params}"); assert!(!s.guides_locked(), "locked guides for {params}"); - assert_eq!(s.doc().unwrap().selection.guides, vec![0], "cleared selection for {params}"); + assert_eq!(selected_guides(&s), vec![0], "cleared selection for {params}"); assert_eq!(s.doc().unwrap().history.undo.len(), before_undo, "recorded undo for {params}"); } // Left out, or null as for every command, it toggles. @@ -3987,11 +3987,11 @@ fn a_failed_preview_leaves_locked_guides_deselected() { } fn guides(s: &Session) -> Vec<(bool, f64)> { - s.doc().unwrap().doc.guides.iter().map(|g| (g.vertical, g.pos)).collect() + s.doc().unwrap().doc.ruler_guides().iter().map(|g| (g.vertical, g.pos)).collect() } fn selected_guides(s: &Session) -> Vec { - s.doc().unwrap().selection.guides.clone() + super::selected_guides(s) } /// #414: ruler guides are selected, moved, copied, nudged and deleted like art, over commands. @@ -4219,10 +4219,12 @@ fn guides_and_art_later_cases(s: &mut Session, a: NodeId) { assert_eq!(guides(s).len(), 4); assert_eq!(guides(s)[2], (true, 210.0)); assert_eq!(guides(s)[3], (false, 320.0)); - assert_eq!(s.doc().unwrap().doc.layers[0].children().unwrap().len(), 2); + // The layer's art (its ruler guides are listed after it). + let art = |s: &Session| s.doc().unwrap().doc.layers[0].children().unwrap().iter().filter(|c| !c.is_ruler_guide()).count(); + assert_eq!(art(s), 2); s.execute("edit.undo", &json!({})).unwrap(); assert_eq!(guides(s).len(), 2); - assert_eq!(s.doc().unwrap().doc.layers[0].children().unwrap().len(), 1); + assert_eq!(art(s), 1); } /// #451: an artboard guide (`guide.add {artboard}`) runs across its artboard only, and is moved, @@ -4244,7 +4246,7 @@ fn artboard_guides_go_with_their_artboard() { ); let spans = |s: &Session| { let d = &s.doc().unwrap().doc; - d.guides.iter().map(|g| d.guide_span(g)).collect::>() + d.ruler_guides().iter().map(|g| d.guide_span(g)).collect::>() }; assert_eq!(spans(&s), [Some((0.0, 300.0)), None]); for bad in [json!(5), json!("x"), json!(-1)] { @@ -4270,7 +4272,7 @@ fn artboard_guides_go_with_their_artboard() { // Rearranged, they follow their artboards. s.execute("artboard.rearrange", &json!({"columns": 4})).unwrap(); let d = &s.doc().unwrap().doc; - for (g, ab) in d.guides.iter().filter_map(|g| Some((g, d.artboards.iter().find(|a| Some(a.id) == g.artboard)?))) { + for (g, ab) in d.ruler_guides().iter().filter_map(|g| Some((g, d.artboards.iter().find(|a| Some(a.id) == g.artboard)?))) { assert_eq!(g.pos - ab.rect.x0, 160.5, "{g:?} on {ab:?}"); } // Deleted, the artboard takes its guides; the selected ones left stay selected. @@ -4405,13 +4407,16 @@ fn cmd_ruler_drags_make_guides_of_the_active_artboard_when_the_preference_is_on( #[test] fn guides_from_older_files_are_put_on_a_layer() { let mut s = Session::new(); - let mut d = vectorcraft_doc::Document::new(400.0, 300.0); - d.guides.push(vectorcraft_doc::Guide::new(true, 50.0)); - d.guides.push(vectorcraft_doc::Guide::new(false, 80.0)); + // A document read with its guides on no layer, as such a file holds them. + let mut v = serde_json::to_value(vectorcraft_doc::Document::new(400.0, 300.0)).unwrap(); + v["guides"] = json!([{"vertical": true, "pos": 50.0}, {"vertical": false, "pos": 80.0}]); + let d: vectorcraft_doc::Document = serde_json::from_value(v).unwrap(); s.add_document(d, None); let st = s.doc().unwrap(); let top = st.doc.default_layer(); - assert!(top.is_some() && st.doc.guides.iter().all(|g| g.layer == top), "{:?}", st.doc.guides); + let guides = st.doc.ruler_guides(); + assert_eq!(guides.len(), 2); + assert!(top.is_some() && guides.iter().all(|g| g.layer == top), "{guides:?}"); } /// Ruler guides are layer objects: made on the current layer, hidden, locked and deleted with it, @@ -4421,10 +4426,10 @@ fn ruler_guides_live_on_layers() { let mut s = session(); let first = s.doc().unwrap().current_layer().unwrap(); s.execute("guide.add", &json!({"vertical": true, "pos": 200})).unwrap(); - assert_eq!(s.doc().unwrap().doc.guides[0].layer, Some(first), "on the current layer"); + assert_eq!(s.doc().unwrap().doc.ruler_guides()[0].layer, Some(first), "on the current layer"); let second = id_of(&s.execute("layer.new", &json!({"name": "Guides"})).unwrap()); s.execute("guide.add", &json!({"vertical": false, "pos": 300})).unwrap(); - assert_eq!(s.doc().unwrap().doc.guides[1].layer, Some(second), "the new layer is current"); + assert_eq!(s.doc().unwrap().doc.ruler_guides()[1].layer, Some(second), "the new layer is current"); assert!(s.execute("guide.add", &json!({"vertical": true, "pos": 1, "layer": 99999})).is_err()); // Hidden or locked with its layer. s.execute("layer.setProps", &json!({"ids": [second.0], "visible": false})).unwrap(); @@ -4435,12 +4440,12 @@ fn ruler_guides_live_on_layers() { s.execute("layer.setProps", &json!({"ids": [second.0], "locked": false})).unwrap(); // Moved to the first layer, then back. s.execute("guide.setLayer", &json!({"index": 1, "layer": first.0})).unwrap(); - assert_eq!(s.doc().unwrap().doc.guides[1].layer, Some(first)); + assert_eq!(s.doc().unwrap().doc.ruler_guides()[1].layer, Some(first)); s.execute("guide.setLayer", &json!({"index": 1, "layer": second.0})).unwrap(); assert!(s.execute("guide.setLayer", &json!({"index": 1, "layer": 99999})).is_err()); // Several at once, as dragging their rows onto a layer's row does. s.execute("guide.setLayer", &json!({"indexes": [0, 1], "layer": first.0})).unwrap(); - assert!(s.doc().unwrap().doc.guides.iter().all(|g| g.layer == Some(first))); + assert!(s.doc().unwrap().doc.ruler_guides().iter().all(|g| g.layer == Some(first))); s.execute("guide.setLayer", &json!({"indexes": [1], "layer": second.0})).unwrap(); assert!(s.execute("guide.setLayer", &json!({"indexes": [7], "layer": first.0})).is_err()); // Deleting a layer deletes its guides; undo brings them back. @@ -4476,7 +4481,7 @@ fn guides_on_hidden_or_locked_layers_are_left_alone() { s.execute("layer.setProps", &json!({"ids": [first.0], "locked": false})).unwrap(); // The current layer locked: the new guide goes on the top shown, unlocked layer. s.execute("guide.add", &json!({"vertical": true, "pos": 20})).unwrap(); - assert_eq!(s.doc().unwrap().doc.guides[2].layer, Some(first)); + assert_eq!(s.doc().unwrap().doc.ruler_guides()[2].layer, Some(first)); assert_eq!(guides(&s).len(), 3); } @@ -4489,7 +4494,7 @@ fn guides_go_with_their_layers_when_duplicated_merged_and_flattened() { s.execute("guide.add", &json!({"vertical": true, "pos": 200})).unwrap(); let second = id_of(&s.execute("layer.new", &json!({})).unwrap()); s.execute("guide.add", &json!({"vertical": false, "pos": 300})).unwrap(); - let on = |s: &Session, l: NodeId| s.doc().unwrap().doc.guides_on(l).map(|(_, g)| (g.vertical, g.pos)).collect::>(); + let on = |s: &Session, l: NodeId| s.doc().unwrap().doc.guides_on(l).iter().map(|g| (g.vertical, g.pos)).collect::>(); // Duplicate Layer copies its guides onto the copy. let copy = s.execute("layer.duplicate", &json!({"ids": [second.0]})).unwrap()["ids"][0].as_u64().map(NodeId).unwrap(); assert_eq!((on(&s, second), on(&s, copy)), (vec![(false, 300.0)], vec![(false, 300.0)])); diff --git a/crates/engine/src/tests/panelcmds.rs b/crates/engine/src/tests/panelcmds.rs index 4d549c31ef..7e7204f6e1 100644 --- a/crates/engine/src/tests/panelcmds.rs +++ b/crates/engine/src/tests/panelcmds.rs @@ -798,7 +798,7 @@ fn copied_artboards_take_a_free_id_and_keep_their_own_guides() { let mut kept = vec![]; for i in [1, 0] { s.execute("artboard.delete", &json!({"index": i})).unwrap(); - kept.push(s.doc().unwrap().doc.guides.iter().map(|g| g.artboard).collect::>()); + kept.push(s.doc().unwrap().doc.ruler_guides().iter().map(|g| g.artboard).collect::>()); s.execute("edit.undo", &json!({})).unwrap(); } got.push((cmd, s.doc().unwrap().doc.artboards.iter().map(|a| a.id).collect::>(), kept)); @@ -1675,12 +1675,12 @@ fn delete_empty_artboards_deletes_those_with_no_art_in_one_step() { assert_eq!(s.execute("artboard.deleteEmpty", &json!({})).unwrap(), json!({"deleted": [0, 2]})); let st = s.doc().unwrap(); assert_eq!(artboard_names(&s), ["Artboard 2"]); - assert_eq!(st.doc.guides.len(), 1, "the deleted artboards' guides go with them"); + assert_eq!(st.doc.ruler_guides().len(), 1, "the deleted artboards' guides go with them"); assert_eq!(st.doc.node(art).map(|n| n.geometric_bounds()), before.node(art).map(|n| n.geometric_bounds()), "the art stays"); assert_eq!(st.history.undo.len(), steps + 1); s.execute("edit.undo", &json!({})).unwrap(); assert_eq!(artboard_names(&s), ["Artboard 1", "Artboard 2", "Artboard 3"]); - assert_eq!(s.doc().unwrap().doc.guides.len(), 3); + assert_eq!(s.doc().unwrap().doc.ruler_guides().len(), 3); } /// What counts as art: hidden and locked objects, an object straddling two artboards and one diff --git a/crates/engine/src/tests/pathops.rs b/crates/engine/src/tests/pathops.rs index 72aa559ae0..1397357061 100644 --- a/crates/engine/src/tests/pathops.rs +++ b/crates/engine/src/tests/pathops.rs @@ -656,13 +656,13 @@ fn simplify_refreshes_changed_path_selection_and_undo() { let kept = NodeId(s.execute("path.create", &json!({"anchors": [{"x": 350, "y": 180}]})).unwrap()["id"].as_u64().unwrap()); let unrelated = rect(&mut s, 400.0, 200.0, 40.0, 30.0); s.edit("selection fixture", |d, sel| { - d.guides.push(vectorcraft_doc::Guide::new(true, 30.0)); + let guide = d.add_ruler_guide(&vectorcraft_doc::Guide::new(true, 30.0)); sel.set([kept, id]); sel.anchors.insert(id, [(0, 19)].into_iter().collect()); sel.anchors.insert(kept, [(0, 0)].into_iter().collect()); sel.key = Some(kept); sel.target = Some(id); - sel.guides.push(0); + sel.guides.push(guide); Ok(()) }) .unwrap(); @@ -781,7 +781,7 @@ fn split_into_grid_options() { rect(&mut s, 10.0, 20.0, 110.0, 50.0); let r = s.execute("object.path.splitIntoGrid", &p).unwrap(); let cells: Vec = ids(&r).iter().map(|id| bounds(&s, *id)).collect(); - let guides: Vec<(bool, f64)> = s.doc().unwrap().doc.guides.iter().map(|g| (g.vertical, g.pos)).collect(); + let guides: Vec<(bool, f64)> = s.doc().unwrap().doc.ruler_guides().iter().map(|g| (g.vertical, g.pos)).collect(); assert_eq!(r["guides"], guides.len()); (cells, guides) }; @@ -812,13 +812,16 @@ fn split_into_grid_options() { assert!(close(bounds(&s, r[0]), Rect::new(0.0, 0.0, 40.0, 60.0), 1e-9) && close(bounds(&s, r[1]), Rect::new(60.0, 0.0, 100.0, 60.0), 1e-9)); let st = s.doc().unwrap(); assert!(r.iter().all(|id| node(&s, *id).appearance == node(&s, r[0]).appearance) && st.doc.node(back).is_none() && st.doc.node(front).is_none()); - assert_eq!(st.doc.layers[0].children().unwrap().iter().map(|n| n.id).collect::>(), [kept, r[0], r[1]], "above the path left alone"); + let art: Vec = st.doc.layers[0].children().unwrap().iter().filter(|n| !n.is_ruler_guide()).map(|n| n.id).collect(); + assert_eq!(art, [kept, r[0], r[1]], "above the path left alone"); + let children = st.doc.layers[0].children().unwrap(); + assert!(children.len() == 9 && children[3..].iter().all(|n| n.is_ruler_guide()), "the six guides are above the art"); assert_eq!(st.selection.objects, r); - assert!(st.doc.guides.iter().all(|g| g.layer == Some(st.doc.layers[0].id))); + assert!(st.doc.ruler_guides().iter().all(|g| g.layer == Some(st.doc.layers[0].id))); assert_eq!(st.history.undo.len(), undo + 1); s.execute("edit.undo", &json!({})).unwrap(); let st = s.doc().unwrap(); - assert!(st.doc.node(back).is_some() && st.doc.node(front).is_some() && st.doc.guides.is_empty()); + assert!(st.doc.node(back).is_some() && st.doc.node(front).is_some() && st.doc.ruler_guides().is_empty()); // A grid with no room for its gutters is refused and changes nothing. assert!(s.execute("object.path.splitIntoGrid", &json!({"columns": 4, "gutter": 50})).is_err()); assert!(s.execute("object.path.splitIntoGrid", &json!({"width": -5})).is_err()); diff --git a/crates/engine/src/tests/prefs.rs b/crates/engine/src/tests/prefs.rs index 8e3c043e4e..ed37d09695 100644 --- a/crates/engine/src/tests/prefs.rs +++ b/crates/engine/src/tests/prefs.rs @@ -1990,9 +1990,12 @@ fn real_time_drawing_off_still_shows_a_dragged_guide() { set_pref(&mut s, "realTimeDrawing", json!(false)); s.pointer(&PointerEvent::new(PointerKind::Down, 100.0, 200.0), v).unwrap(); s.pointer(&PointerEvent::new(PointerKind::Drag, 150.0, 200.0), v).unwrap(); - assert_eq!(s.doc().unwrap().doc.guides[0].pos, 150.0, "the guide moves while dragged"); + assert_eq!(s.doc().unwrap().doc.ruler_guides()[0].pos, 150.0, "the guide moves while dragged"); s.pointer(&PointerEvent::new(PointerKind::Up, 150.0, 200.0), v).unwrap(); - assert_eq!((s.doc().unwrap().doc.guides[0].pos, s.doc().unwrap().history.undo.last().map(|e| e.label.as_str())), (150.0, Some("Move Guide"))); + assert_eq!( + (s.doc().unwrap().doc.ruler_guides()[0].pos, s.doc().unwrap().history.undo.last().map(|e| e.label.as_str())), + (150.0, Some("Move Guide")) + ); } /// With Real-time Drawing off, a 9-slice guide of the edited symbol moved with the Selection tool diff --git a/crates/engine/src/tests/save.rs b/crates/engine/src/tests/save.rs index 68b8b57abc..d92365a356 100644 --- a/crates/engine/src/tests/save.rs +++ b/crates/engine/src/tests/save.rs @@ -579,7 +579,7 @@ fn units_and_ruler_guides_survive_save_and_reopen() { s.execute("file.close", &json!({})).unwrap(); s.execute("document.open", &json!({"path": p})).unwrap(); let doc = &s.doc().unwrap().doc; - let guides: Vec<_> = doc.guides.iter().map(|g| (g.vertical, g.pos)).collect(); + let guides: Vec<_> = doc.ruler_guides().iter().map(|g| (g.vertical, g.pos)).collect(); assert_eq!((doc.units, guides), (vectorcraft_doc::Unit::Millimeters, vec![(true, 50.0), (false, 30.0)]), "{ext}"); } let _ = std::fs::remove_dir_all(d); diff --git a/crates/engine/src/tests/slices.rs b/crates/engine/src/tests/slices.rs index 87d0088d97..79b2cbe662 100644 --- a/crates/engine/src/tests/slices.rs +++ b/crates/engine/src/tests/slices.rs @@ -93,7 +93,7 @@ fn too_many_guide_pieces_are_refused() { let doc = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc); for i in 1..3000 { for vertical in [true, false] { - doc.guides.push(vectorcraft_doc::Guide::new(vertical, f64::from(i) * 0.1)); + doc.add_ruler_guide(&vectorcraft_doc::Guide::new(vertical, f64::from(i) * 0.1)); } } assert!(s.execute("object.slice.fromGuides", &json!({})).unwrap_err().to_string().contains("more than")); @@ -440,9 +440,9 @@ fn a_canceled_undo_scope_leaves_locked_or_hidden_slices_deselected() { /// Select slice `id` and the first ruler guide together, through [`Session::select`]. fn pick_slice_and_guide(s: &mut Session, id: u64) { - s.select(|_, sel| { + s.select(|d, sel| { sel.slices = vec![NodeId(id)]; - sel.guides = vec![0]; + sel.guides = d.ruler_guides().first().and_then(|g| g.id).into_iter().collect(); }) .unwrap(); } diff --git a/crates/engine/src/tests/textedit.rs b/crates/engine/src/tests/textedit.rs index f49a88c80b..47dcaf8318 100644 --- a/crates/engine/src/tests/textedit.rs +++ b/crates/engine/src/tests/textedit.rs @@ -1016,7 +1016,7 @@ fn dropped_type_press_restores_the_original_text_and_selection() { let a = at(&s, first); click(&mut s, a.x + 0.1, a.y); assert!(s.tool_wants_text()); - s.select(|_, selection| selection.guides.push(0)).unwrap(); + s.select(|d, selection| selection.guides.extend(d.ruler_guides().first().and_then(|g| g.id))).unwrap(); let before = s.doc().unwrap().selection.clone(); let b = at(&s, second); s.pointer(&PointerEvent::new(PointerKind::Down, b.x + 0.1, b.y), v).unwrap(); diff --git a/crates/engine/src/tests/xform.rs b/crates/engine/src/tests/xform.rs index b096922e9a..e9aa3c5329 100644 --- a/crates/engine/src/tests/xform.rs +++ b/crates/engine/src/tests/xform.rs @@ -766,7 +766,7 @@ fn stroke_width(s: &Session, id: NodeId) -> f64 { } fn guide_positions(s: &Session) -> Vec { - s.doc().unwrap().doc.guides.iter().map(|g| g.pos).collect() + s.doc().unwrap().doc.ruler_guides().iter().map(|g| g.pos).collect() } /// Scale Artwork with Artboard (#602): `artboard.setProps {scaleArt}` takes the art fully inside the diff --git a/crates/engine/tests/it/beyond_canvas.rs b/crates/engine/tests/it/beyond_canvas.rs index 9dee868d54..6ac31398f6 100644 --- a/crates/engine/tests/it/beyond_canvas.rs +++ b/crates/engine/tests/it/beyond_canvas.rs @@ -51,7 +51,7 @@ fn an_artboard_a_guide_or_a_ruler_origin_beyond_the_canvas_no_longer_blocks_edit v["document"]["ruler_origin"] = json!([1e17, 0.0]); let (s, warnings) = open_and_move(&v, "setup"); let d = &s.doc().unwrap().doc; - assert_eq!((d.artboards.len(), d.guides.len(), d.ruler_origin.x), (1, 1, 0.0)); + assert_eq!((d.artboards.len(), d.ruler_guides().len(), d.ruler_origin.x), (1, 1, 0.0)); assert!(warnings.to_string().contains("1 artboard, 1 guide beyond the canvas"), "{warnings}"); assert!(warnings.to_string().contains("ruler origin"), "{warnings}"); } diff --git a/crates/engine/tests/it/clear_commands.rs b/crates/engine/tests/it/clear_commands.rs index 655c719965..a50c70c18d 100644 --- a/crates/engine/tests/it/clear_commands.rs +++ b/crates/engine/tests/it/clear_commands.rs @@ -177,12 +177,12 @@ fn clear_guides_and_empty_explicit_targets_keep_their_separate_behavior() { s.execute("edit.clear", &json!({"ids": []})).unwrap(); assert_eq!(s.active().unwrap().history.undo.len(), history); assert_eq!(s.active().unwrap().selection, selection); - assert_eq!(s.active().unwrap().doc.guides.len(), 1); + assert_eq!(s.active().unwrap().doc.ruler_guides().len(), 1); s.execute("edit.clear", &json!({})).unwrap(); - assert!(s.active().unwrap().doc.guides.is_empty()); + assert!(s.active().unwrap().doc.ruler_guides().is_empty()); assert!(s.active().unwrap().doc.node(a).is_some()); s.execute("edit.undo", &json!({})).unwrap(); - assert_eq!(s.active().unwrap().doc.guides.len(), 1); + assert_eq!(s.active().unwrap().doc.ruler_guides().len(), 1); } #[test] diff --git a/crates/engine/tests/it/main.rs b/crates/engine/tests/it/main.rs index 7f85e5acd3..9227ee199d 100644 --- a/crates/engine/tests/it/main.rs +++ b/crates/engine/tests/it/main.rs @@ -27,6 +27,8 @@ mod param_keys; mod paste_many; mod pen_click_after_smooth; mod pen_endpoint_picking; +mod ruler_guide_nodes; +mod ruler_guide_perf; mod ruler_origin; mod selection_commands; mod stroke_visibility; diff --git a/crates/engine/tests/it/ruler_guide_nodes.rs b/crates/engine/tests/it/ruler_guide_nodes.rs new file mode 100644 index 0000000000..95625d65d1 --- /dev/null +++ b/crates/engine/tests/it/ruler_guide_nodes.rs @@ -0,0 +1,439 @@ +//! Ruler guides held as nodes of a layer: commands that work on a layer's art leave them out and +//! keep them above the art. + +use std::sync::Arc; + +use serde_json::json; +use vectorcraft_doc::{Node, NodeId, NodeKind}; +use vectorcraft_engine::Session; + +fn id(v: &serde_json::Value) -> NodeId { + NodeId(v["id"].as_u64().unwrap()) +} + +fn rect(s: &mut Session, x: f64) -> NodeId { + id(&s.execute("shape.rectangle", &json!({"x": x, "y": 10, "width": 20, "height": 20})).unwrap()) +} + +/// A ruler guide added on top of `layer`'s contents. +fn guide(s: &mut Session, layer: NodeId, pos: f64) -> NodeId { + let d = Arc::make_mut(&mut s.doc_mut().unwrap().doc); + let g = Node::new(d.alloc_id(), NodeKind::RulerGuide { vertical: true, pos, artboard: None }); + d.insert(Some(layer), usize::MAX, g).unwrap() +} + +fn children(s: &Session, layer: NodeId) -> Vec { + s.doc().unwrap().doc.children(Some(layer)).unwrap().iter().map(|c| c.id).collect() +} + +/// A document with a layer holding two rectangles and two ruler guides: (layer, [art], [guides]). +fn session() -> (Session, NodeId, Vec, Vec) { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + let layer = s.doc().unwrap().doc.layers[0].id; + let art = vec![rect(&mut s, 10.0), rect(&mut s, 50.0)]; + let guides = vec![guide(&mut s, layer, 5.0), guide(&mut s, layer, 7.0)]; + (s, layer, art, guides) +} + +#[test] +fn layer_commands_keep_ruler_guides_above_the_art() { + // Send to Current Layer puts the object below the current layer's guides. + let (mut s, a, art, guides) = session(); + let b = id(&s.execute("layer.new", &json!({})).unwrap()); + let moved = rect(&mut s, 90.0); + s.execute("layer.setCurrent", &json!({"id": a.0})).unwrap(); + s.execute("select.set", &json!({"ids": [moved.0]})).unwrap(); + s.execute("object.arrange.sendToCurrentLayer", &json!({})).unwrap(); + assert_eq!(children(&s, a), [art[0], art[1], moved, guides[0], guides[1]]); + // Already on top of the art: nothing changes and no undo step is added. + let steps = s.doc().unwrap().history.undo.len(); + s.execute("object.arrange.sendToCurrentLayer", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().history.undo.len(), steps); + + // Moving a row on top of a layer's contents puts it below the guides. + let other = rect(&mut s, 130.0); + s.execute("layer.move", &json!({"id": other.0, "target": a.0})).unwrap(); + assert_eq!(children(&s, a), [art[0], art[1], moved, other, guides[0], guides[1]]); + + // Merging layers keeps every guide above the merged art. + let gb = guide(&mut s, b, 9.0); + let late = { + s.execute("layer.setCurrent", &json!({"id": b.0})).unwrap(); + rect(&mut s, 170.0) + }; + s.execute("layer.merge", &json!({"ids": [a.0, b.0]})).unwrap(); + let merged = children(&s, b); + let first_guide = merged.iter().position(|c| [guides[0], guides[1], gb].contains(c)).unwrap(); + assert_eq!(merged.len() - first_guide, 3, "{merged:?}"); + assert!(merged.contains(&late)); +} + +#[test] +fn release_and_clipping_leave_ruler_guides_alone() { + let (mut s, layer, art, guides) = session(); + // Make Clipping Mask clips by the top object, not by a guide above it. + assert_eq!(s.execute("layer.clippingMask.toggle", &json!({"id": layer.0})).unwrap()["clip"], json!(true)); + assert_eq!(children(&s, layer), [art[1], art[0], guides[0], guides[1]]); + s.execute("edit.undo", &json!({})).unwrap(); + + // Release to Layers makes layers of the art only, below the guides. + s.execute("layer.releaseToLayers", &json!({"id": layer.0})).unwrap(); + let ch = children(&s, layer); + assert_eq!(ch.len(), 4); + assert_eq!(ch[2..], [guides[0], guides[1]]); + let d = &s.doc().unwrap().doc; + assert!(ch[..2].iter().all(|c| d.node(*c).is_some_and(Node::is_layer))); +} + +#[test] +fn ruler_guides_are_not_art_to_select_paint_or_inspect() { + let (mut s, layer, art, guides) = session(); + // Target Contents selects the layer's objects only. + let out = s.execute("appearance.targetContents", &json!({"ids": [layer.0]})).unwrap(); + assert_eq!(out["ids"], json!([art[0].0, art[1].0])); + // Painting the layer paints its objects and leaves the guides with no appearance. + s.execute("paint.setFill", &json!({"ids": [layer.0], "color": "#ff0000"})).unwrap(); + let d = &s.doc().unwrap().doc; + for g in &guides { + assert_eq!(d.node(*g).unwrap().appearance, vectorcraft_doc::Appearance::default()); + } + // Document inspection lists the objects, not the guides. + assert_eq!(s.execute("document.find", &json!({"kind": "Guide"})).unwrap()["total"], json!(0)); + let layer_row = s.execute("document.inspect", &json!({"ids": [layer.0]})).unwrap(); + let listed = layer_row.to_string(); + assert!(guides.iter().all(|g| !listed.contains(&format!("\"id\":{}", g.0))), "{listed}"); + // Unlock All does not reach a locked guide. + Arc::make_mut(&mut s.doc_mut().unwrap().doc).node_mut(guides[0]).unwrap().locked = true; + s.execute("object.unlockAll", &json!({})).unwrap(); + assert!(s.doc().unwrap().doc.node(guides[0]).unwrap().locked); +} + +#[test] +fn flattening_a_sublayers_art_keeps_its_ruler_guides() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + let layer = s.doc().unwrap().doc.layers[0].id; + let sub = id(&s.execute("layer.newSublayer", &json!({"parent": layer.0})).unwrap()); + s.execute("layer.setCurrent", &json!({"id": sub.0})).unwrap(); + let r = rect(&mut s, 10.0); + s.execute("transparency.set", &json!({"ids": [r.0], "opacity": 50})).unwrap(); + let g = guide(&mut s, sub, 5.0); + s.execute("object.flattenTransparency", &json!({"ids": [sub.0]})).unwrap(); + // The sublayer stands for its art: the art is flattened and the guide stays on top of it. + let d = &s.doc().unwrap().doc; + assert!(d.node(r).is_none(), "the art was flattened"); + assert!(d.node(g).is_some_and(Node::is_ruler_guide), "the guide is kept"); + assert_eq!(d.parent_of(g), Some(sub)); + assert_eq!(children(&s, sub).len(), 2); + assert_eq!(children(&s, sub).last(), Some(&g)); + assert_eq!(d.parent_of(sub), Some(layer)); +} + +#[test] +fn object_commands_refuse_a_ruler_guide_id() { + let (mut s, _, art, guides) = session(); + s.execute("select.set", &json!({"ids": [art[0].0]})).unwrap(); + let before = (s.doc().unwrap().doc.clone(), s.doc().unwrap().selection.clone(), s.doc().unwrap().history.undo.len()); + for (command, params) in [ + ("select.set", json!({"ids": [guides[0].0]})), + ("select.set", json!({"ids": [art[1].0, guides[1].0]})), + ("edit.clear", json!({"ids": [guides[0].0]})), + ("object.lock", json!({"ids": [guides[0].0]})), + ("object.move", json!({"ids": [guides[0].0], "dx": 5, "dy": 0})), + ("paint.setFill", json!({"ids": [guides[1].0], "color": "#ff0000"})), + ] { + let e = s.execute(command, ¶ms).unwrap_err().to_string(); + assert!(e.contains("is a ruler guide, not an object"), "{command}: {e}"); + let d = s.doc().unwrap(); + assert_eq!((&d.doc, &d.selection, d.history.undo.len()), (&before.0, &before.1, before.2), "{command} changed something"); + } + // A selection holding a guide id as an object loses it when pruned; the guide stays. + let d = &s.doc().unwrap().doc; + let mut sel = vectorcraft_doc::Selection { objects: vec![art[0], guides[0]], key: Some(guides[1]), ..Default::default() }; + sel.prune(d); + assert_eq!((sel.objects, sel.key), (vec![art[0]], None)); + assert!(d.node(guides[0]).is_some_and(Node::is_ruler_guide)); +} + +#[test] +fn a_ruler_guide_stays_directly_in_a_layer() { + let (mut s, layer, art, guides) = session(); + s.execute("select.set", &json!({"ids": [art[0].0, art[1].0]})).unwrap(); + let group = id(&s.execute("object.group", &json!({})).unwrap()); + let before = (s.doc().unwrap().doc.clone(), s.doc().unwrap().history.undo.len()); + // Reorder refuses a group as a guide's parent, and the top level. + for parent in [json!(group.0), json!(null)] { + assert!(s.execute("node.move", &json!({"id": guides[0].0, "parent": parent, "index": 0})).is_err(), "{parent}"); + assert_eq!((&s.doc().unwrap().doc, s.doc().unwrap().history.undo.len()), (&before.0, before.1)); + } + // So do the document's own insert and move. + let d = Arc::make_mut(&mut s.doc_mut().unwrap().doc); + assert!(d.move_node(guides[1], Some(group), 0).is_err()); + let loose = Node::new(d.alloc_id(), NodeKind::RulerGuide { vertical: false, pos: 3.0, artboard: None }); + assert!(d.insert(Some(group), 0, loose.clone()).is_err()); + assert!(d.insert(None, 0, loose.clone()).is_err()); + assert_eq!(d.ruler_guides().iter().filter_map(|g| g.id).collect::>(), guides); + assert_eq!(children(&s, layer), [group, guides[0], guides[1]]); + // Within its layer it still moves among the guides. + s.execute("node.move", &json!({"id": guides[1].0, "parent": layer.0, "index": 0})).unwrap(); + assert_eq!(children(&s, layer), [group, guides[1], guides[0]]); +} + +/// Arrange counts only a layer's art: the guides above it are no place to move to. +#[test] +fn arranging_counts_only_a_layers_art() { + let (mut s, layer, art, guides) = session(); + let top = rect(&mut s, 90.0); + assert_eq!(children(&s, layer), [art[0], art[1], top, guides[0], guides[1]]); + s.execute("select.set", &json!({"ids": [art[0].0, top.0]})).unwrap(); + s.execute("object.arrange.bringToFront", &json!({})).unwrap(); + assert_eq!(children(&s, layer), [art[1], art[0], top, guides[0], guides[1]], "the selection keeps its order at the top of the art"); + // The topmost art brought to the front or forward: nothing changes and no undo step is added. + s.execute("select.set", &json!({"ids": [top.0]})).unwrap(); + let steps = s.doc().unwrap().history.undo.len(); + for command in ["object.arrange.bringToFront", "object.arrange.bringForward"] { + s.execute(command, &json!({})).unwrap(); + assert_eq!(children(&s, layer), [art[1], art[0], top, guides[0], guides[1]], "{command}"); + assert_eq!(s.doc().unwrap().history.undo.len(), steps, "{command}"); + } + s.execute("select.set", &json!({"ids": [art[1].0]})).unwrap(); + s.execute("object.arrange.bringForward", &json!({})).unwrap(); + assert_eq!(children(&s, layer), [art[0], art[1], top, guides[0], guides[1]]); +} + +/// A guide made while an opacity mask, a pattern or a symbol is edited goes on the temporary +/// editing layer, and stays in the document when editing ends; a save made during mask editing +/// keeps it. +#[test] +fn guides_made_while_editing_a_mask_a_pattern_or_a_symbol_stay() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + rect(&mut s, 10.0); + s.execute("transparency.makeOpacityMask", &json!({})).unwrap(); + let edit_layer = s.doc().unwrap().doc.mask_edit.expect("editing the mask").layer; + s.execute("guide.add", &json!({"vertical": true, "pos": 40})).unwrap(); + let made = s.doc().unwrap().doc.ruler_guides(); + assert_eq!(made.iter().map(|g| (g.layer, g.pos)).collect::>(), [(Some(edit_layer), 40.0)]); + let saved = s.doc().unwrap().doc.without_edit_modes().ruler_guides(); + assert_eq!(saved.iter().map(|g| (g.id, g.pos)).collect::>(), [(made[0].id, 40.0)], "a save keeps the guide"); + s.execute("transparency.stopEditingOpacityMask", &json!({})).unwrap(); + let d = &s.doc().unwrap().doc; + let kept = d.ruler_guides(); + assert_eq!(kept.iter().map(|g| (g.id, g.pos)).collect::>(), [(made[0].id, 40.0)]); + assert!(kept[0].layer.is_some_and(|l| l != edit_layer && d.node(l).is_some_and(Node::is_layer))); + + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + rect(&mut s, 10.0); + s.execute("object.pattern.make", &json!({})).unwrap(); + let tile_layer = s.doc().unwrap().doc.pattern_edit.as_ref().expect("editing the pattern").layer; + s.execute("guide.add", &json!({"vertical": false, "pos": 25})).unwrap(); + assert_eq!(s.doc().unwrap().doc.ruler_guides()[0].layer, Some(tile_layer)); + s.execute("object.pattern.done", &json!({})).unwrap(); + let d = &s.doc().unwrap().doc; + let kept = d.ruler_guides(); + assert_eq!(kept.iter().map(|g| g.pos).collect::>(), [25.0]); + assert!(kept[0].layer.is_some_and(|l| l != tile_layer && d.node(l).is_some_and(Node::is_layer))); + let tile = &d.patterns.last().expect("the new pattern").art; + assert!(tile.iter().all(|n| !n.is_ruler_guide()), "the guide is not part of the tile"); + + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + rect(&mut s, 10.0); + let instance = id(&s.execute("symbol.new", &json!({"name": "Mark"})).unwrap()); + s.execute("symbol.edit", &json!({"ids": [instance.0]})).unwrap(); + let edit_layer = s.doc().unwrap().doc.symbol_edit.as_ref().expect("editing the symbol").layer; + s.execute("guide.add", &json!({"vertical": true, "pos": 15})).unwrap(); + assert_eq!(s.doc().unwrap().doc.ruler_guides()[0].layer, Some(edit_layer)); + s.execute("symbol.exitEditing", &json!({})).unwrap(); + let d = &s.doc().unwrap().doc; + let kept = d.ruler_guides(); + assert_eq!(kept.iter().map(|g| g.pos).collect::>(), [15.0]); + assert!(kept[0].layer.is_some_and(|l| l != edit_layer && d.node(l).is_some_and(Node::is_layer))); + let art = &d.symbols.iter().find(|x| x.name == "Mark").expect("the symbol").art; + let mut in_symbol = false; + art.walk(&mut |n| in_symbol |= n.is_ruler_guide()); + assert!(!in_symbol, "the guide is not part of the symbol"); +} + +/// The bytes of each export format (PDF without the embedded native document, which keeps the +/// guides), and the box Fit All in Window fits (the art and every artboard). +fn exports(s: &mut Session) -> (Vec<(String, String)>, Option) { + let files = [json!({"format": "svg"}), json!({"format": "pdf", "preserveEditing": false}), json!({"format": "png"}), json!({"format": "psd"})] + .iter() + .map(|p| { + let out = s.execute("document.export", p).unwrap(); + (p["format"].as_str().unwrap().to_string(), out["dataBase64"].as_str().unwrap().to_string()) + }) + .collect(); + let d = &s.doc().unwrap().doc; + let fit = d.art_bounds().map(|a| d.artboards.iter().fold(a, |r, ab| r.union(ab.rect))); + (files, fit) +} + +/// Ruler guides on a layer, on a sublayer and on an artboard change no export and not the box +/// Fit All in Window fits. +#[test] +fn exports_and_fit_all_are_the_same_with_ruler_guides() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 200, "height": 150, "artboards": 2})).unwrap(); + let layer = s.doc().unwrap().doc.layers[0].id; + rect(&mut s, 10.0); + let sub = id(&s.execute("layer.newSublayer", &json!({"parent": layer.0})).unwrap()); + s.execute("layer.setCurrent", &json!({"id": sub.0})).unwrap(); + s.execute("shape.ellipse", &json!({"x": 60, "y": 30, "width": 40, "height": 30})).unwrap(); + let without = exports(&mut s); + assert_eq!(exports(&mut s), without, "the exports are repeatable"); + // Guides far outside the art too: they must not widen any box. + s.execute("guide.add", &json!({"vertical": true, "pos": -5000, "layer": layer.0})).unwrap(); + s.execute("guide.add", &json!({"vertical": false, "pos": 9000, "layer": sub.0})).unwrap(); + s.execute("guide.add", &json!({"vertical": true, "pos": 120, "artboard": 1})).unwrap(); + assert_eq!(s.doc().unwrap().doc.ruler_guides().len(), 3); + let with = exports(&mut s); + for ((format, a), (_, b)) in without.0.iter().zip(&with.0) { + assert!(a == b, "{format} export changed with ruler guides present"); + } + assert_eq!(with.1, without.1, "Fit All"); + // A PDF that preserves editing carries them in its native document, and opens with them. + let guides = s.doc().unwrap().doc.ruler_guides(); + let pdf = s.execute("document.export", &json!({"format": "pdf"})).unwrap()["dataBase64"].clone(); + s.execute("document.open", &json!({"name": "guides.pdf", "dataBase64": pdf})).unwrap(); + assert_eq!(s.doc().unwrap().doc.ruler_guides(), guides); +} + +/// One step of [`guide_commands_follow_a_list_model`]. +#[derive(Clone, Debug)] +enum GuideOp { + Add { vertical: bool, pos: u16, layer: usize }, + Remove(usize), + Copy(usize), + Paste, + SetCurrent(usize), + Undo, + Redo, +} + +fn guide_op() -> impl proptest::strategy::Strategy { + use proptest::prelude::*; + prop_oneof![ + 3 => (any::(), 0u16..400, 0usize..3).prop_map(|(vertical, pos, layer)| GuideOp::Add { vertical, pos, layer }), + 2 => (0usize..16).prop_map(GuideOp::Remove), + 1 => (0usize..16).prop_map(GuideOp::Copy), + 1 => Just(GuideOp::Paste), + 1 => (0usize..3).prop_map(GuideOp::SetCurrent), + 2 => Just(GuideOp::Undo), + 1 => Just(GuideOp::Redo), + ] +} + +/// A guide as `guide.list` reports it: (vertical, pos, layer). +type Line = (bool, f64, u64); + +fn listed(s: &mut Session) -> Vec { + let rows = s.execute("guide.list", &json!({})).unwrap(); + rows.as_array() + .unwrap() + .iter() + .enumerate() + .map(|(i, r)| { + assert_eq!(r["index"], json!(i)); + (r["vertical"].as_bool().unwrap(), r["pos"].as_f64().unwrap(), r["layer"].as_u64().unwrap()) + }) + .collect() +} + +/// Runs `ops` on a document with two layers and a sublayer, checking `guide.list` against a plain +/// list after every step: Add appends (a new guide takes the highest id), Remove takes the entry +/// out, Paste appends the copied guide on the current layer at the same place, and Undo and +/// Redo bring back the list as it was, the same indexes included. +fn run_guide_ops(ops: &[GuideOp]) -> Result<(), proptest::test_runner::TestCaseError> { + use proptest::prop_assert_eq; + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + let bottom = s.doc().unwrap().doc.layers[0].id; + let top = id(&s.execute("layer.new", &json!({})).unwrap()); + let sub = id(&s.execute("layer.newSublayer", &json!({"parent": bottom.0})).unwrap()); + let layers = [bottom, top, sub]; + s.execute("layer.setCurrent", &json!({"id": bottom.0})).unwrap(); + let mut current = bottom; + let mut model: Vec = vec![]; + let mut clip: Option<(bool, f64)> = None; + let (mut undo, mut redo): (Vec>, Vec>) = (vec![], vec![]); + for (step, op) in ops.iter().enumerate() { + let steps = s.doc().unwrap().history.undo.len(); + let before = model.clone(); + // Whether the step is an edit (one undo step). + let edit = match *op { + GuideOp::Add { vertical, pos, layer } => { + let l = layers[layer]; + let r = s.execute("guide.add", &json!({"vertical": vertical, "pos": pos, "layer": l.0})).unwrap(); + model.push((vertical, f64::from(pos), l.0)); + prop_assert_eq!(&r["index"], &json!(model.len() - 1), "step {}", step); + true + } + GuideOp::Remove(i) if !model.is_empty() => { + let i = i % model.len(); + s.execute("guide.remove", &json!({"index": i})).unwrap(); + model.remove(i); + true + } + GuideOp::Copy(i) if !model.is_empty() => { + let i = i % model.len(); + s.execute("guide.select", &json!({"indexes": [i]})).unwrap(); + s.execute("edit.copy", &json!({})).unwrap(); + clip = Some((model[i].0, model[i].1)); + false + } + GuideOp::Paste => match clip { + Some((vertical, pos)) => { + s.execute("edit.paste", &json!({})).unwrap(); + model.push((vertical, pos, current.0)); + true + } + None => false, + }, + GuideOp::SetCurrent(l) => { + current = layers[l]; + s.execute("layer.setCurrent", &json!({"id": current.0})).unwrap(); + false + } + GuideOp::Undo => { + if let Some(m) = undo.pop() { + s.execute("edit.undo", &json!({})).unwrap(); + redo.push(std::mem::replace(&mut model, m)); + } + false + } + GuideOp::Redo => { + if let Some(m) = redo.pop() { + s.execute("edit.redo", &json!({})).unwrap(); + undo.push(std::mem::replace(&mut model, m)); + } + false + } + GuideOp::Remove(_) | GuideOp::Copy(_) => false, + }; + let after = s.doc().unwrap().history.undo.len(); + if edit { + prop_assert_eq!(after, steps + 1, "step {} {:?}: one undo step", step, op); + undo.push(before); + redo.clear(); + } else if !matches!(op, GuideOp::Undo | GuideOp::Redo) { + prop_assert_eq!(after, steps, "step {} {:?}: no undo step", step, op); + } + prop_assert_eq!(listed(&mut s), model.clone(), "step {} {:?}", step, op); + } + Ok(()) +} + +proptest::proptest! { + #![proptest_config(proptest::test_runner::Config { cases: 64, failure_persistence: None, ..proptest::test_runner::Config::default() })] + + /// Add, remove, copy, paste, undo and redo keep `guide.list` in step with a plain list, on + /// three layers. + #[test] + fn guide_commands_follow_a_list_model(ops in proptest::collection::vec(guide_op(), 1..40)) { + run_guide_ops(&ops)?; + } +} diff --git a/crates/engine/tests/it/ruler_guide_perf.rs b/crates/engine/tests/it/ruler_guide_perf.rs new file mode 100644 index 0000000000..1b5de5c5e2 --- /dev/null +++ b/crates/engine/tests/it/ruler_guide_perf.rs @@ -0,0 +1,130 @@ +//! Timings of a document with 10,000 ruler guides and 100,000 objects: save, load, open, the guide +//! list, hovering and dragging with the Selection tool, an edit that does not touch guides and one +//! that does. Ignored by default; it prints its timings and sets no bound on them: +//! `cargo test -p vectorcraft-engine --test it ruler_guide_perf -- --ignored --nocapture`. + +use std::time::{Duration, Instant}; + +use serde_json::{Value, json}; +use vectorcraft_color::Color; +use vectorcraft_doc::Document; +use vectorcraft_engine::{Session, ViewInfo}; +use vectorcraft_geom::Rect; +use vectorcraft_testkit::fixtures::DocBuilder; +use vectorcraft_tools::{PointerEvent, PointerKind}; + +const GUIDES: usize = 10_000; +const LAYERS: usize = 4; +const GROUPS_PER_LAYER: usize = 250; +const RECTS_PER_GROUP: usize = 100; + +/// The `.vectorcraft` file of the document: 100,000 rectangles in groups of 100 on four layers, +/// and the ruler guides in its saved list, spread over the layers. +fn file() -> Vec { + let mut b = DocBuilder::new(2000.0, 2000.0); + let mut layers = vec![b.layer]; + for l in 0..LAYERS { + if l > 0 { + layers.push(b.layer(&format!("Layer {}", l + 1))); + } + for g in 0..GROUPS_PER_LAYER { + let kids = (0..RECTS_PER_GROUP) + .map(|r| { + let (x, y) = (((g * RECTS_PER_GROUP + r) % 400) as f64 * 5.0, (l * GROUPS_PER_LAYER + g) as f64 * 2.0); + b.rect_node(Rect::new(x, y, x + 3.0, y + 1.5), Color::rgb(0.2, 0.4, 0.6)) + }) + .collect(); + b.group(kids, false); + } + } + let d: Document = b.build(); + let mut v: Value = serde_json::from_slice(&vectorcraft_format::save(&d, false)).unwrap(); + v["document"]["guides"] = + (0..GUIDES).map(|i| json!({"vertical": i % 2 == 0, "pos": (i / 2) as f64 * 0.37 + 0.1, "layer": layers[i % LAYERS]})).collect(); + serde_json::to_vec(&v).unwrap() +} + +/// The median of `n` runs of `f`. +fn median(n: usize, mut f: impl FnMut()) -> Duration { + let mut t: Vec = (0..n) + .map(|_| { + let start = Instant::now(); + f(); + start.elapsed() + }) + .collect(); + t.sort(); + t[n / 2] +} + +fn report(what: &str, d: Duration) { + eprintln!("ruler_guide_perf: {what:<44} {:>10.3} ms", d.as_secs_f64() * 1000.0); +} + +fn pointer(s: &mut Session, kind: PointerKind, x: f64, y: f64) { + s.pointer(&PointerEvent::new(kind, x, y), ViewInfo::default()).unwrap(); +} + +#[test] +#[ignore = "a benchmark: run with --ignored --nocapture"] +fn ruler_guide_perf() { + let bytes = file(); + eprintln!("ruler_guide_perf: file of {} bytes", bytes.len()); + report("load (format::load), median of 5", median(5, || drop(vectorcraft_format::load(&bytes).unwrap()))); + let doc = vectorcraft_format::load(&bytes).unwrap(); + report("save (format::save), median of 5", median(5, || drop(vectorcraft_format::save(&doc, false)))); + + let mut s = Session::new(); + let data = vectorcraft_format::base64_encode(&bytes); + let start = Instant::now(); + s.execute("document.open", &json!({"name": "perf.vectorcraft", "dataBase64": data})).unwrap(); + report("document.open", start.elapsed()); + let listed = s.execute("guide.list", &json!({})).unwrap().as_array().map_or(0, Vec::len); + assert_eq!(listed, GUIDES); + report("guide.list, median of 11", median(11, || drop(s.execute("guide.list", &json!({})).unwrap()))); + + s.select_tool("selection", ViewInfo::default()).unwrap(); + let mut i = 0.0; + report( + "hover (Selection tool move), median of 201", + median(201, || { + i += 1.0; + pointer(&mut s, PointerKind::Move, 600.0 + i * 0.13, 300.0 + i * 0.07); + }), + ); + // A drag of the first rectangle's group (the rectangle is at 0,0 to 3,1.5), with its + // snapping: one undo step. + let steps = s.doc().unwrap().history.undo.len(); + pointer(&mut s, PointerKind::Down, 1.5, 0.75); + let mut j = 0.0; + report( + "drag (Selection tool drag step), median of 51", + median(51, || { + j += 1.0; + pointer(&mut s, PointerKind::Drag, 1.5 + j * 0.9, 0.75 + j * 0.4); + }), + ); + let start = Instant::now(); + pointer(&mut s, PointerKind::Up, 1.5 + j * 0.9, 0.75 + j * 0.4); + report("drag release (commit)", start.elapsed()); + assert_eq!(s.doc().unwrap().history.undo.len(), steps + 1, "the drag moved the group"); + + let mut k = 0.0; + report( + "edit without guides (shape.rectangle), median of 21", + median(21, || { + k += 1.0; + s.execute("shape.rectangle", &json!({"x": k, "y": 1900, "width": 5, "height": 5})).unwrap(); + }), + ); + report("undo, median of 11", median(11, || drop(s.execute("edit.undo", &json!({})).unwrap()))); + let mut m = 0.0; + report( + "guide edit (guide.move), median of 21", + median(21, || { + m += 1.0; + s.execute("guide.move", &json!({"index": GUIDES / 2, "pos": 100.0 + m})).unwrap(); + }), + ); + report("file.saveCopy, median of 3", median(3, || drop(s.execute("file.saveCopy", &json!({})).unwrap()))); +} diff --git a/crates/engine/tests/it/ruler_origin.rs b/crates/engine/tests/it/ruler_origin.rs index 4b5c06286d..f6a910515e 100644 --- a/crates/engine/tests/it/ruler_origin.rs +++ b/crates/engine/tests/it/ruler_origin.rs @@ -78,7 +78,7 @@ fn native_versions_and_compression_keep_the_origin_and_old_files_default_to_zero assert_eq!(back.ruler_origin_for(0), doc.ruler_origin_for(0), "v{version}, compression {compress}"); assert_eq!((back.rulers, &back.artboard_ruler_origins), (doc.rulers, &doc.artboard_ruler_origins), "v{version}, compression {compress}"); assert_eq!(back.layers, doc.layers); - assert_eq!(back.guides, doc.guides); + assert_eq!(back.ruler_guides(), doc.ruler_guides()); } } diff --git a/crates/eps/src/ai_editing/writer.rs b/crates/eps/src/ai_editing/writer.rs index c598e2cd9a..d77d8264ea 100644 --- a/crates/eps/src/ai_editing/writer.rs +++ b/crates/eps/src/ai_editing/writer.rs @@ -460,6 +460,9 @@ impl<'a> Writer<'a> { let flag = |b: bool| u8::from(b); let dim = dim_images.unwrap_or(50); let name = n.name.as_deref().unwrap_or("Layer"); + // Ruler guides are kept in the document's native copy only. + let children: Vec> = children.iter().filter(|c| !c.is_ruler_guide()).cloned().collect(); + let children = &children; // The last flag makes the layer a clipping layer: its top object clips the rest. Only a // clipping path or compound path, outside any art cut to a clipping object, is written as // one ([`Self::clip_group`] writes the others). @@ -583,6 +586,10 @@ impl<'a> Writer<'a> { if let NodeKind::Layer { .. } = n.kind { return self.write_layer(n, &[]); } + // Ruler guides are kept in the document's native copy only: no object, state or style. + if n.is_ruler_guide() { + return; + } if self.in_symbol { self.wrote_fixed |= symbols::redrawn(n); } @@ -697,6 +704,8 @@ impl<'a> Writer<'a> { } self.end_group("U", xy); } + // Ruler guides are kept in the document's native copy only. + NodeKind::RulerGuide { .. } => {} } if self.body.len() > start { // A group's dictionary goes between its end and its state block. diff --git a/crates/eps/src/ai_editing/writer/styles.rs b/crates/eps/src/ai_editing/writer/styles.rs index 65fe918599..c4ae9fda3b 100644 --- a/crates/eps/src/ai_editing/writer/styles.rs +++ b/crates/eps/src/ai_editing/writer/styles.rs @@ -28,6 +28,10 @@ pub(in crate::ai_editing) const STYLE_LINKS: &str = "links to graphic styles are /// How many objects of `doc`'s layers, opacity masks and symbols are linked to a graphic style. pub(in crate::ai_editing) fn style_links(doc: &Document) -> usize { fn count(n: &Node) -> usize { + // Ruler guides are not written as objects. + if n.is_ruler_guide() { + return 0; + } let own = usize::from(n.extras().graphic_style.is_some()); let mask = n.extras().mask.as_ref().map_or(0, |m| count(&m.art)); own + mask + n.children().map_or(0, |c| c.iter().map(|m| count(m)).sum()) diff --git a/crates/eps/src/import/ai/read/document.rs b/crates/eps/src/import/ai/read/document.rs index eb3d73c691..ad7f266036 100644 --- a/crates/eps/src/import/ai/read/document.rs +++ b/crates/eps/src/import/ai/read/document.rs @@ -1244,9 +1244,9 @@ impl<'a> Reader<'a> { None => None, }; let pos = if vertical { p.x } else { p.y }; - pos.is_finite().then_some(Guide { vertical, pos, artboard, layer: None }) + pos.is_finite().then_some(Guide { artboard, ..Guide::new(vertical, pos) }) }; - take_ruler_guides(&mut doc.layers, &pick, &mut doc.guides, 0); + take_ruler_guides(&mut doc.layers, &pick, 0); } let mut brushes = self.brush_library(); if let Some((lib, _)) = &brushes { @@ -1429,9 +1429,9 @@ fn ruler_guide(a: Point, b: Point, centre: Option<(f64, f64)>, boards: &[Rect]) } } -/// Moves the children of the layers and sublayers of `nodes` that `pick` makes a guide of into -/// `guides`, each on its layer. -fn take_ruler_guides(nodes: &mut [Arc], pick: &dyn Fn(NodeId) -> Option, guides: &mut Vec, depth: usize) { +/// Makes the children of the layers and sublayers of `nodes` that `pick` makes a guide of into +/// ruler guide nodes with the same ids, moved to the top of their layer in their order. +fn take_ruler_guides(nodes: &mut [Arc], pick: &dyn Fn(NodeId) -> Option, depth: usize) { if depth > MAX_DEPTH { return; } @@ -1439,16 +1439,17 @@ fn take_ruler_guides(nodes: &mut [Arc], pick: &dyn Fn(NodeId) -> Option { - guides.push(Guide { layer: Some(id), ..g }); + guides.push(Arc::new(Node::new(k.id, NodeKind::RulerGuide { vertical: g.vertical, pos: g.pos, artboard: g.artboard }))); false } None => true, }); - take_ruler_guides(kids, pick, guides, depth + 1); + take_ruler_guides(kids, pick, depth + 1); + kids.extend(guides); } } diff --git a/crates/eps/src/import/ai/tests.rs b/crates/eps/src/import/ai/tests.rs index c04d06c6ab..46386d2200 100644 --- a/crates/eps/src/import/ai/tests.rs +++ b/crates/eps/src/import/ai/tests.rs @@ -62,9 +62,14 @@ fn arrays_nested_past_the_depth_limit_are_refused() { assert!(nested(100_000)); } -/// The children of the first layer. +/// The children of the first layer, its ruler guides left out. fn art(doc: &Document) -> Vec> { - doc.layers.first().and_then(|l| l.children()).cloned().unwrap_or_default() + doc.layers.first().and_then(|l| l.children()).into_iter().flatten().filter(|c| !c.is_ruler_guide()).cloned().collect() +} + +/// The ruler guides of `doc` by their line, artboard and layer. +fn guide_lines(doc: &Document) -> Vec { + doc.ruler_guides().into_iter().map(|g| Guide { artboard: g.artboard, layer: g.layer, ..Guide::new(g.vertical, g.pos) }).collect() } fn fill(n: &Node) -> Paint { @@ -8459,17 +8464,16 @@ fn canvas_wide_guide_paths_are_ruler_guides_of_their_layer() { ); let doc = read(canvas_stream(&layer(&body)).as_bytes()).unwrap().doc; let top = &doc.layers[0]; - let kids = top.children().unwrap(); + let kids = art(&doc); assert_eq!(kids.len(), 2, "the square and the sublayer"); assert!(matches!(kids[0].kind, NodeKind::Path { guide: false, .. })); let sub = &kids[1]; - assert!(sub.children().unwrap().is_empty()); - let want = [ - Guide { vertical: false, pos: 60.0, artboard: None, layer: Some(top.id) }, - Guide { vertical: true, pos: 25.0, artboard: None, layer: Some(top.id) }, - Guide { vertical: false, pos: 10.0, artboard: None, layer: Some(sub.id) }, - ]; - assert_eq!(doc.guides, want); + assert!(sub.children().unwrap().iter().all(|c| c.is_ruler_guide())); + let at = |vertical, pos, layer| Guide { layer: Some(layer), ..Guide::new(vertical, pos) }; + let want = [at(false, 60.0, top.id), at(true, 25.0, top.id), at(false, 10.0, sub.id)]; + assert_eq!(guide_lines(&doc), want); + // The guides sit above the layers' art. + assert!(top.children().unwrap()[2..].iter().all(|c| c.is_ruler_guide())); } /// #779: a straight `(N) *` guide path from edge to edge of one artboard is a ruler guide of that @@ -8478,7 +8482,7 @@ fn canvas_wide_guide_paths_are_ruler_guides_of_their_layer() { fn artboard_wide_guide_paths_are_ruler_guides_of_their_artboard() { // The crop marks' artboard, 0 0 200 100. let doc = read(canvas_stream(&layer(&guide_line(0.0, 30.0, 200.0, 30.0, "N"))).as_bytes()).unwrap().doc; - assert_eq!(doc.guides, [Guide { vertical: false, pos: 70.0, artboard: Some(1), layer: Some(doc.layers[0].id) }]); + assert_eq!(guide_lines(&doc), [Guide { artboard: Some(1), layer: Some(doc.layers[0].id), ..Guide::new(false, 70.0) }]); assert!(art(&doc).is_empty()); // The second of two artboards: art space x 400 to 600, y -150 to 0. let boards = "%_/Document :\n%_/Dictionary :\n%_/Array :\n\ @@ -8487,7 +8491,7 @@ fn artboard_wide_guide_paths_are_ruler_guides_of_their_artboard() { %_; (ArtboardArray) ,\n%_; /NotRecorded ,\n%_;\n"; let doc = read_ok(&format!("{boards}{}", layer(&guide_line(500.0, 0.0, 500.0, -150.0, "N")))).doc; assert_eq!(doc.artboards[1].id, 2); - assert_eq!(doc.guides, [Guide { vertical: true, pos: 500.0, artboard: Some(2), layer: Some(doc.layers[0].id) }]); + assert_eq!(guide_lines(&doc), [Guide { artboard: Some(2), layer: Some(doc.layers[0].id), ..Guide::new(true, 500.0) }]); } /// Guide paths that aren't ruler guides stay guide paths: `(S) *` ones (guides made from objects), @@ -8506,7 +8510,7 @@ fn other_guide_paths_stay_guide_paths() { ]; for body in cases { let doc = read(canvas_stream(&layer(&body)).as_bytes()).unwrap().doc; - assert!(doc.guides.is_empty(), "{body}"); + assert!(doc.ruler_guides().is_empty(), "{body}"); assert_eq!(art(&doc).len(), 1, "{body}"); } } diff --git a/crates/eps/src/import/native/build.rs b/crates/eps/src/import/native/build.rs index 31cd90d79b..c86cdb9516 100644 --- a/crates/eps/src/import/native/build.rs +++ b/crates/eps/src/import/native/build.rs @@ -1669,7 +1669,7 @@ fn page_boards(doc: &Document, visible: &Document, first: usize, pages: &[Rect]) /// Drops the guides of artboards `doc` no longer has: an artboard guide goes with its artboard. fn drop_guides_off_boards(doc: &mut Document) { let kept: Vec = doc.artboards.iter().map(|a| a.id).collect(); - doc.guides.retain(|g| g.artboard.is_none_or(|id| kept.contains(&id))); + doc.retain_guides(&mut vectorcraft_doc::Selection::default(), |g| g.artboard.is_none_or(|id| kept.contains(&id))); } /// Draw `doc`'s layers, artboards and guides `k` times as large, from the origin, with their @@ -1682,13 +1682,12 @@ pub(crate) fn scale_document(doc: &mut Document, k: f64, styles: bool) { let layer = Arc::make_mut(layer); layer.transform(a, scaling); scale_wrap_offsets(layer, k); + // A transform leaves ruler guides where they are: their positions scale here. + layer.map_ruler_guide_positions(&mut |_, pos| *pos *= k); } for board in &mut doc.artboards { board.rect = a.transform_rect_bbox(board.rect); } - for guide in &mut doc.guides { - guide.pos *= k; - } if styles { for g in doc.graphic_styles.iter_mut() { g.appearance.scale_strokes(k); diff --git a/crates/eps/src/import/native/compare.rs b/crates/eps/src/import/native/compare.rs index 5b87a03c7d..34f458f328 100644 --- a/crates/eps/src/import/native/compare.rs +++ b/crates/eps/src/import/native/compare.rs @@ -122,7 +122,7 @@ pub(super) fn comparison_samples(doc: &Document) -> u32 { let (mut images, mut vector) = (false, false); doc.walk(|n| match n.kind { NodeKind::Image(_) => images = true, - NodeKind::Layer { .. } | NodeKind::Group { .. } | NodeKind::Path { clipping: true, .. } => {} + NodeKind::Layer { .. } | NodeKind::Group { .. } | NodeKind::Path { clipping: true, .. } | NodeKind::RulerGuide { .. } => {} _ => vector = true, }); if images && !vector { 4 } else { 1 } diff --git a/crates/eps/src/import/tests_native.rs b/crates/eps/src/import/tests_native.rs index f9983c6e83..45a7a92c8c 100644 --- a/crates/eps/src/import/tests_native.rs +++ b/crates/eps/src/import/tests_native.rs @@ -77,6 +77,14 @@ fn names(d: &Document) -> Vec { d.layers.iter().map(|l| l.name.clone().unwrap_or_default()).collect() } +/// The ruler guides of `d` by their line, artboard and layer. +fn guide_lines(d: &Document) -> Vec { + d.ruler_guides() + .into_iter() + .map(|g| vectorcraft_doc::Guide { artboard: g.artboard, layer: g.layer, ..vectorcraft_doc::Guide::new(g.vertical, g.pos) }) + .collect() +} + #[test] fn ruler_units_in_the_program_header_alone_are_kept() { let art = layer("Art", true, &square(10, 10)); @@ -2219,11 +2227,11 @@ fn an_ai_files_ruler_guides_come_in_on_their_layers() { assert!(read, "{w:?}"); let jig = d.layers[0].id; let want = [ - vectorcraft_doc::Guide { vertical: false, pos: 60.0 * k, artboard: None, layer: Some(jig) }, - vectorcraft_doc::Guide { vertical: true, pos: 20.0 * k, artboard: Some(d.artboards[0].id), layer: Some(jig) }, + vectorcraft_doc::Guide { layer: Some(jig), ..vectorcraft_doc::Guide::new(false, 60.0 * k) }, + vectorcraft_doc::Guide { artboard: Some(d.artboards[0].id), layer: Some(jig), ..vectorcraft_doc::Guide::new(true, 20.0 * k) }, ]; - assert_eq!(d.guides, want); - assert_eq!(d.layers[0].children().map_or(0, |c| c.len()), 1, "the square alone"); + assert_eq!(guide_lines(&d), want); + assert_eq!(d.layers[0].children().map_or(0, |c| c.iter().filter(|c| !c.is_ruler_guide()).count()), 1, "the square alone"); } } @@ -2241,7 +2249,10 @@ fn an_eps_of_its_art_box_keeps_the_guides_of_its_first_artboard() { assert_eq!(names(&r.document), ["Art"], "{:?}", r.warnings); assert_eq!(r.document.artboards.len(), 1); let (board, layer) = (r.document.artboards[0].id, r.document.layers[0].id); - assert_eq!(r.document.guides, [vectorcraft_doc::Guide { vertical: true, pos: 25.0, artboard: Some(board), layer: Some(layer) }]); + assert_eq!( + guide_lines(&r.document), + [vectorcraft_doc::Guide { artboard: Some(board), layer: Some(layer), ..vectorcraft_doc::Guide::new(true, 25.0) }] + ); } /// A PDF part read from a `UserUnit` page cropped to the art: the art's box, not the artboard, is diff --git a/crates/eps/src/legacy_ai/writer.rs b/crates/eps/src/legacy_ai/writer.rs index c95940b95d..26a46401fd 100644 --- a/crates/eps/src/legacy_ai/writer.rs +++ b/crates/eps/src/legacy_ai/writer.rs @@ -152,7 +152,9 @@ impl<'a> Writer<'a> { self.locked = false; self.line("0 A"); self.in_hidden = !n.visible; - self.contents(children, *clip, false); + // Ruler guides are kept in the document's native copy only. + let art: Vec> = children.iter().filter(|c| !c.is_ruler_guide()).cloned().collect(); + self.contents(&art, *clip, false); self.in_hidden = false; self.line("LB\n%AI5_EndLayer--"); true @@ -209,6 +211,10 @@ impl<'a> Writer<'a> { /// One object (inside a container locked when `locked`). fn node(&mut self, n: &Node, locked: bool) { + // Ruler guides are kept in the document's native copy only. + if n.is_ruler_guide() { + return; + } if !n.visible && !self.in_hidden { match self.hidden { HiddenObjects::Omit => { @@ -292,6 +298,8 @@ impl<'a> Writer<'a> { self.nest -= 1; } } + // Ruler guides are kept in the document's native copy only. + NodeKind::RulerGuide { .. } => {} } } diff --git a/crates/eps/src/scene.rs b/crates/eps/src/scene.rs index 974831b703..8c9b04ff80 100644 --- a/crates/eps/src/scene.rs +++ b/crates/eps/src/scene.rs @@ -389,6 +389,8 @@ impl<'a> Scene<'a> { self.node(c, false); } } + // Ruler guides draw on screen only. + NodeKind::RulerGuide { .. } => {} } if self.body.len() > MAX_BODY { self.body.truncate(was); diff --git a/crates/format/src/lib.rs b/crates/format/src/lib.rs index 667b39aad2..38545b8292 100644 --- a/crates/format/src/lib.rs +++ b/crates/format/src/lib.rs @@ -239,6 +239,9 @@ struct Body { fn body(doc: &Document, o: &SaveOptions) -> Result { let version = o.version; let mut d = doc.without_edit_modes().into_owned(); + // Ruler guides are saved in the document's `guides` list, as before they were nodes, so every + // version reads them. + d.guides_to_saved(); // Older apps don't know placed documents: they get the art the objects show, with its // resources. Otherwise resources only exporting adds are never saved. if version < VERSION && d.has_placed() { @@ -602,6 +605,12 @@ fn load_file_within(bytes: &[u8], max_nodes: usize, max_values: usize) -> Result ))); } depth_notes.extend(beyond.note()); + // Untrusted: ruler guide nodes only directly in layers, above their art (after the depth limit: + // the walk recurses). + doc.tidy_ruler_guides(); + // The saved ruler guides become nodes on their layers (after the guides beyond the canvas are + // dropped, and after `fix_next_id`: new ids start above the file's). + doc.guides_into_nodes(); // Untrusted: the type's sizes, leading, baseline shifts and scales, its indents and paragraph // spacing, and its tab stops, each within its range (after the depth limit: the walk recurses). doc.keep_type_values_in_range(); @@ -1203,6 +1212,66 @@ mod tests { assert_eq!(load(&bytes).unwrap().node_count(), n, "the real budget opens ordinary documents"); } + /// The saved ruler guides become objects when read, so each counts against the same budget. + #[test] + fn saved_ruler_guides_count_against_the_object_budget() { + let mut d = Document::new(100.0, 100.0); + let l = d.add_layer(None); + let id = d.alloc_id(); + d.insert(Some(l), 0, Node::path(id, shapes::ellipse(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::default_art())).unwrap(); + for i in 0..50 { + d.add_ruler_guide(&vectorcraft_doc::Guide::new(i % 2 == 0, f64::from(i))); + } + let n = d.node_count() + d.ruler_guides().len(); + let bytes = save_with(&d, &SaveOptions { compress: true, ..SaveOptions::default() }).unwrap(); + let opened = load_file_within(&bytes, n, vectorcraft_doc::node::MAX_BUFFERED_VALUES).unwrap().doc; + assert_eq!(opened.ruler_guides().len(), 50, "a document of exactly the budget opens with its guides"); + let Err(FormatError::NotVectorcraft(why)) = load_file_within(&bytes, n - 1, vectorcraft_doc::node::MAX_BUFFERED_VALUES) else { + panic!("one guide past the budget must be refused") + }; + assert!(why.contains(&format!("more than {} objects", n - 1)), "{why}"); + } + + /// Ruler guide nodes a file holds out of place are put right as it is read: one below a layer's + /// art moves up to the layer's guides, and one in a group, beside the layers or in a symbol is + /// removed. Saving again writes no ruler guide node, which builds from before ruler guides + /// were nodes could not read. + #[test] + fn ruler_guide_nodes_out_of_place_in_a_file_are_put_right() { + let mut d = Document::new(100.0, 100.0); + let l = d.add_layer(None); + let (a, b, group) = (d.alloc_id(), d.alloc_id(), d.alloc_id()); + let path = |id| Node::path(id, shapes::ellipse(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::default_art()); + d.insert(Some(l), 0, path(a)).unwrap(); + d.insert(Some(l), usize::MAX, Node::group(group, vec![Arc::new(path(b))])).unwrap(); + let li = d.layers.iter().position(|x| x.id == l).unwrap(); + let mut v: serde_json::Value = serde_json::from_slice(&save(&d, false)).unwrap(); + let guide = |id: u64| serde_json::json!({"id": id, "kind": {"type": "rulerguide", "vertical": true, "pos": 5.0}}); + let kids = &mut v["document"]["layers"][li]["kind"]["children"]; + kids.as_array_mut().unwrap().insert(0, guide(50)); + kids[2]["kind"]["children"].as_array_mut().unwrap().push(guide(51)); + v["document"]["layers"].as_array_mut().unwrap().push(guide(52)); + let opened = load(&serde_json::to_vec(&v).unwrap()).unwrap(); + let listed: Vec = opened.ruler_guides().iter().filter_map(|g| g.id).map(|id| id.0).collect(); + assert_eq!(listed, [50]); + let layer = opened.layers.iter().find(|x| x.id == l).unwrap(); + assert_eq!(layer.children().unwrap().iter().map(|c| c.id.0).collect::>(), [a.0, group.0, 50], "art first, then the guide"); + assert_eq!(opened.node(group).unwrap().children().unwrap().len(), 1, "the group keeps only its path"); + assert!(opened.layers.iter().all(|x| !x.is_ruler_guide())); + assert!(!String::from_utf8(save(&opened, false)).unwrap().contains("rulerguide")); + + // A ruler guide in a symbol's art is removed too. + let mut d = Document::new(100.0, 100.0); + let sym = d.alloc_id(); + let art = Node::group(sym, vec![Arc::new(path(d.alloc_id()))]); + d.symbols.push(vectorcraft_doc::Symbol { name: "S".into(), art: Arc::new(art), dynamic: false }); + let mut v: serde_json::Value = serde_json::from_slice(&save(&d, false)).unwrap(); + v["document"]["symbols"][0]["art"]["kind"]["children"].as_array_mut().unwrap().push(guide(60)); + let opened = load(&serde_json::to_vec(&v).unwrap()).unwrap(); + assert_eq!(opened.symbols[0].art.children().unwrap().len(), 1); + assert!(!opened.symbols[0].art.children().unwrap().iter().any(|c| c.is_ruler_guide())); + } + /// #3587: a reordered file (its objects' fields before their `type`, as a JSON tool that /// sorts keys writes them) still opens; one whose fields before a `type` hold more values than /// the budget, such as a long array of numbers, is refused as they are read. diff --git a/crates/format/tests/it/main.rs b/crates/format/tests/it/main.rs index a5f028c384..2272f42f72 100644 --- a/crates/format/tests/it/main.rs +++ b/crates/format/tests/it/main.rs @@ -3,6 +3,7 @@ #![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] mod links; +mod native_guides; mod native_v3; mod native_v4; mod save_options; diff --git a/crates/format/tests/it/native_guides.rs b/crates/format/tests/it/native_guides.rs new file mode 100644 index 0000000000..614ceded63 --- /dev/null +++ b/crates/format/tests/it/native_guides.rs @@ -0,0 +1,146 @@ +//! Ruler guides in `.vectorcraft` files of every version: the saved `guides` list becomes ruler +//! guide nodes on their layers when a file opens, and is written back as the same list. + +use serde::Deserialize; +use serde_json::{Value, json}; +use vectorcraft_doc::{Document, LayerColor, Node, NodeId}; +use vectorcraft_format::{MIN_VERSION, SaveOptions, VERSION, load, save_with}; +use vectorcraft_testkit::fixtures; + +fn json_of(bytes: &[u8]) -> Value { + serde_json::from_slice(bytes).unwrap() +} + +fn save_v(d: &Document, version: u32) -> Vec { + save_with(d, &SaveOptions { version, ..SaveOptions::default() }).unwrap() +} + +/// The document of a saved file without its guide list and its next id (giving guides ids +/// raises the next id). +fn without_guides(v: &Value) -> Value { + let mut d = v["document"].clone(); + let o = d.as_object_mut().unwrap(); + o.remove("guides"); + o.remove("next_id"); + d +} + +/// A document with art on two layers, a sublayer in the bottom one, and two artboards: +/// (document, bottom layer, sublayer, top layer, second artboard's id). +fn doc() -> (Document, NodeId, NodeId, NodeId, u32) { + let mut d = (*fixtures::rich_session().doc().unwrap().doc).clone(); + let bottom = d.layers[0].id; + let sub = d.alloc_id(); + d.insert(Some(bottom), usize::MAX, Node::layer(sub, "Sub", LayerColor::Preset(1))).unwrap(); + let top = d.add_layer(Some("Top")); + let mut ab = d.artboards[0].clone(); + ab.id = d.next_artboard_id(); + d.artboards.push(ab); + let board = d.artboards[1].id; + (d, bottom, sub, top, board) +} + +/// Each version's file with a guide on no layer, one on a sublayer, one on a layer that is gone, +/// and an artboard guide, as files written before ruler guides were nodes hold them: it opens +/// with the guides on their layers, saves the same lines with their layers and ids, and saving +/// what that file opens to gives the same `guides` list again. The rest of the document is saved +/// as it was. +#[test] +fn every_version_reads_and_writes_back_the_same_guides() { + let (d, bottom, sub, top, board) = doc(); + for version in MIN_VERSION..=VERSION { + let mut v = json_of(&save_v(&d, version)); + assert_eq!(v["version"], version); + let plain = without_guides(&v); + v["document"]["guides"] = json!([ + {"vertical": true, "pos": 12.5}, + {"vertical": false, "pos": 40.0, "layer": sub}, + {"vertical": true, "pos": 70.0, "layer": 99_999}, + {"vertical": false, "pos": 90.0, "layer": bottom, "artboard": board}, + ]); + let opened = load(&serde_json::to_vec(&v).unwrap()).unwrap(); + let guides = opened.ruler_guides(); + let lines: Vec<_> = guides.iter().map(|g| (g.vertical, g.pos, g.artboard, g.layer)).collect(); + // On no layer and on a deleted layer: the top visible, unlocked layer. + assert_eq!( + lines, + [(true, 12.5, None, Some(top)), (false, 40.0, None, Some(sub)), (true, 70.0, None, Some(top)), (false, 90.0, Some(board), Some(bottom))], + "version {version}" + ); + for g in &guides { + let n = opened.node(g.id.unwrap()).unwrap(); + assert!(n.is_ruler_guide() && n.visible && !n.locked && n.name.is_none(), "version {version}"); + // Each sits at the top of its layer, above the art. + let kids = opened.node(g.layer.unwrap()).unwrap().children().unwrap(); + let first_guide = kids.iter().position(|c| c.is_ruler_guide()).unwrap(); + assert!(kids[first_guide..].iter().all(|c| c.is_ruler_guide()), "version {version}"); + } + + let once = json_of(&save_v(&opened, version)); + assert_eq!(once["version"], version); + let saved: Vec<_> = once["document"]["guides"] + .as_array() + .unwrap() + .iter() + .map(|g| (g["vertical"].clone(), g["pos"].clone(), g.get("artboard").cloned(), g["layer"].clone(), g["id"].is_u64())) + .collect(); + assert_eq!( + saved, + [ + (json!(true), json!(12.5), None, json!(top), true), + (json!(false), json!(40.0), None, json!(sub), true), + (json!(true), json!(70.0), None, json!(top), true), + (json!(false), json!(90.0), Some(json!(board)), json!(bottom), true), + ], + "version {version}" + ); + assert_eq!(without_guides(&once), plain, "version {version}: the rest of the document"); + + let reopened = load(&serde_json::to_vec(&once).unwrap()).unwrap(); + assert_eq!(reopened.ruler_guides(), guides, "version {version}: same ids, layers and lines"); + let twice = json_of(&save_v(&reopened, version)); + assert_eq!(twice["document"]["guides"], once["document"]["guides"], "version {version}"); + } +} + +/// The guide entry of a build from before ruler guides were nodes, field for field. +#[derive(Debug, PartialEq, Deserialize)] +struct OldGuide { + vertical: bool, + pos: f64, + #[serde(default)] + artboard: Option, + #[serde(default)] + layer: Option, +} + +/// An older build reads the guides a file now saves, the new fields (id, name, lock, visibility) +/// included, as the same lines on the same layers. +#[test] +fn an_older_guide_entry_reads_the_saved_guides() { + let (mut d, bottom, sub, _, board) = doc(); + let mut g = vectorcraft_doc::Guide::new(true, 15.0); + g.layer = Some(sub); + g.name = Some("Fold".into()); + g.locked = true; + g.hidden = true; + d.add_ruler_guide(&g); + d.add_ruler_guide(&vectorcraft_doc::Guide { layer: Some(bottom), artboard: Some(board), ..vectorcraft_doc::Guide::new(false, 25.0) }); + for version in MIN_VERSION..=VERSION { + let v = json_of(&save_v(&d, version)); + let entries = v["document"]["guides"].as_array().unwrap(); + assert_eq!(entries[0]["name"], "Fold", "the new fields are written"); + let old: Vec = serde_json::from_value(v["document"]["guides"].clone()).unwrap(); + assert_eq!( + old, + [ + OldGuide { vertical: true, pos: 15.0, artboard: None, layer: Some(sub) }, + OldGuide { vertical: false, pos: 25.0, artboard: Some(board), layer: Some(bottom) }, + ], + "version {version}" + ); + // And this build reads its own lock, visibility and name back. + let back = load(&serde_json::to_vec(&v).unwrap()).unwrap(); + assert_eq!(back.ruler_guides(), d.ruler_guides(), "version {version}"); + } +} diff --git a/crates/format/tests/it/native_v3.rs b/crates/format/tests/it/native_v3.rs index 6a96fae79d..512025375e 100644 --- a/crates/format/tests/it/native_v3.rs +++ b/crates/format/tests/it/native_v3.rs @@ -293,13 +293,21 @@ fn artboard_guides_round_trip_and_canvas_guides_save_as_before() { use vectorcraft_doc::Guide; let mut d = Document::new(100.0, 100.0); let id = d.artboards[0].id; - d.guides = vec![Guide::new(true, 10.0), Guide { artboard: Some(id), ..Guide::new(false, 20.0) }]; + d.add_ruler_guide(&Guide::new(true, 10.0)); + d.add_ruler_guide(&Guide { artboard: Some(id), ..Guide::new(false, 20.0) }); let mut v = json_of(&save(&d, false)); - assert_eq!(v["document"]["guides"], json!([{"vertical": true, "pos": 10.0}, {"vertical": false, "pos": 20.0, "artboard": id}])); - assert_eq!(load(&save(&d, false)).unwrap().guides, d.guides); + let lines: Vec<_> = v["document"]["guides"] + .as_array() + .unwrap() + .iter() + .map(|g| json!({"vertical": g["vertical"], "pos": g["pos"], "artboard": g.get("artboard")})) + .collect(); + assert_eq!(lines, [json!({"vertical": true, "pos": 10.0, "artboard": null}), json!({"vertical": false, "pos": 20.0, "artboard": id})]); + assert_eq!(load(&save(&d, false)).unwrap().ruler_guides(), d.ruler_guides()); v["document"]["guides"] = json!([{"vertical": false, "pos": 20.0}]); let old = load(&serde_json::to_vec(&v).unwrap()).unwrap(); - assert_eq!(old.guides, [Guide::new(false, 20.0)]); + let old_lines: Vec<_> = old.ruler_guides().into_iter().map(|g| (g.vertical, g.pos, g.artboard)).collect(); + assert_eq!(old_lines, [(false, 20.0, None)]); } #[test] diff --git a/crates/metafile/src/scene.rs b/crates/metafile/src/scene.rs index 87398c1f07..343b422093 100644 --- a/crates/metafile/src/scene.rs +++ b/crates/metafile/src/scene.rs @@ -176,7 +176,8 @@ impl<'a> Scene<'a> { } fn node(&mut self, n: &Node, force: bool) { - if let NodeKind::Layer { template: true, .. } = n.kind { + // Ruler guides draw on screen only: no record and no loss warning. + if n.is_ruler_guide() || n.is_template() { return; } if !force && !n.visible { @@ -237,6 +238,8 @@ impl<'a> Scene<'a> { self.node(c, false); } } + // Ruler guides draw on screen only. + NodeKind::RulerGuide { .. } => {} } self.opacity = opacity; } diff --git a/crates/pdf/src/export/entry.rs b/crates/pdf/src/export/entry.rs index 75b87209c5..143fa848f3 100644 --- a/crates/pdf/src/export/entry.rs +++ b/crates/pdf/src/export/entry.rs @@ -92,7 +92,7 @@ fn export_with_report_inner(doc: &Document, opts: &PdfOptions, left_out: &mut Ha let links = !set.standard.is_pdfx(); let mut linked = false; for layer in doc.layers.iter().filter(|_| !links) { - layer.walk(&mut |n| linked |= n.url().is_some()); + layer.walk(&mut |n| linked |= !n.is_ruler_guide() && n.url().is_some()); } if linked { warnings.push(format!("{} files have no links: the objects' URLs aren't clickable", set.standard.label())); diff --git a/crates/pdf/src/export/nodes.rs b/crates/pdf/src/export/nodes.rs index 2d9a9cc175..fde13f4d3c 100644 --- a/crates/pdf/src/export/nodes.rs +++ b/crates/pdf/src/export/nodes.rs @@ -318,6 +318,8 @@ impl Exporter<'_> { self.node(s, c, page, false); } } + // Ruler guides draw on screen only. + NodeKind::RulerGuide { .. } => {} } self.knockout = enclosing; for _ in 0..pushes { diff --git a/crates/plugins/src/objects.rs b/crates/plugins/src/objects.rs index d0a4619622..2238ab41e0 100644 --- a/crates/plugins/src/objects.rs +++ b/crates/plugins/src/objects.rs @@ -1195,7 +1195,10 @@ impl Plan { if self.top.as_ref().is_some_and(|(p, _)| *p == parent) && let Some((_, nodes)) = self.top.take() { - out.get_or_insert_with(|| list.to_vec()).extend(nodes.into_iter().map(Arc::new)); + // On a layer, new objects go below its ruler guides, which stay on top. + let o = out.get_or_insert_with(|| list.to_vec()); + let at = Node::guide_run_start(o); + o.splice(at..at, nodes.into_iter().map(Arc::new)); } out } diff --git a/crates/plugins/src/objects/tests.rs b/crates/plugins/src/objects/tests.rs index ba4cccae21..c04ffe6a0c 100644 --- a/crates/plugins/src/objects/tests.rs +++ b/crates/plugins/src/objects/tests.rs @@ -164,6 +164,21 @@ fn compound_guides_are_not_filter_targets() { assert!(targets.contains(&made[1]), "the plain compound path"); } +/// New objects put on top of a layer go below its ruler guides, which stay on top; a ruler guide +/// is never a filter target. +#[test] +fn new_objects_go_below_a_layers_ruler_guides() { + let (mut d, _, layer) = doc(); + let guide = Node::new(d.alloc_id(), NodeKind::RulerGuide { vertical: true, pos: 5.0, artboard: None }); + let gid = d.insert(Some(layer), usize::MAX, guide).unwrap(); + assert!(!filter_targets(&d, &[layer]).contains(&gid)); + let out = json!({"objects": [{"name": "N", "type": "path", "path": square(0.0)}]}); + let made = apply_output(&mut d, &[], decode_json(out).unwrap(), Some(layer), &keep).unwrap(); + let ids: Vec = d.children(Some(layer)).unwrap().iter().map(|n| n.id).collect(); + assert_eq!(ids.last(), Some(&gid)); + assert_eq!(ids.get(ids.len() - 2), made.first()); +} + /// `guide` and `locked` make objects guides (a compound path's members too) and lock them, or undo /// both; locked objects and guides are not [`selectable`]. Other values are refused. #[test] diff --git a/crates/render/src/encode.rs b/crates/render/src/encode.rs index 201931a8ac..635f906839 100644 --- a/crates/render/src/encode.rs +++ b/crates/render/src/encode.rs @@ -248,7 +248,7 @@ fn drawn_bounds_with(symbols: &SymbolBoxes, n: &Node, held: Option<&Arc>, return None; } match &n.kind { - NodeKind::Layer { template: true, .. } | NodeKind::Path { guide: true, .. } => None, + NodeKind::Layer { template: true, .. } | NodeKind::Path { guide: true, .. } | NodeKind::RulerGuide { .. } => None, NodeKind::Layer { children, clip: false, .. } | NodeKind::Group { children, clip: false } if !fx::has_object_fx(n) => children .iter() .skip(usize::from(n.extras().shaper.is_some())) @@ -289,7 +289,7 @@ fn without_undrawn(n: &Node) -> Option { if keeps_all || !matches!(n.kind, NodeKind::Layer { clip: false, .. } | NodeKind::Group { clip: false, .. }) { return None; } - let undrawn = |c: &Node| !c.visible || matches!(c.kind, NodeKind::Layer { template: true, .. } | NodeKind::Path { guide: true, .. }); + let undrawn = |c: &Node| !c.visible || c.is_guide() || c.is_template(); let mut changed = false; let kept: Vec> = n .children()? diff --git a/crates/render/src/encode/psd.rs b/crates/render/src/encode/psd.rs index 777276e696..98c031c89a 100644 --- a/crates/render/src/encode/psd.rs +++ b/crates/render/src/encode/psd.rs @@ -447,7 +447,7 @@ impl Parts<'_> { /// Node `n` (a top-level layer, or with Maximum Editability anything in one) and its parts. fn add(&mut self, n: &Node) { // Templates and guides aren't artwork. - if matches!(n.kind, NodeKind::Layer { template: true, .. } | NodeKind::Path { guide: true, .. }) || !(n.visible || self.o.hidden_layers) { + if n.is_template() || n.is_guide() || !(n.visible || self.o.hidden_layers) { return; } let (hidden, opacity) = (!n.visible, opacity_byte(n.opacity)); diff --git a/crates/render/src/encode/tests_psd.rs b/crates/render/src/encode/tests_psd.rs index e56278f1de..a488cceff2 100644 --- a/crates/render/src/encode/tests_psd.rs +++ b/crates/render/src/encode/tests_psd.rs @@ -271,6 +271,28 @@ fn guides_are_not_layers() { assert_eq!(parse(&export(&b.build(), &o)).count, -3, "the group and the square"); } +/// Ruler guides on a layer and a sublayer change nothing in the export: the same file with and +/// without Maximum Editability, the same drawn bounds and the same cached size of each layer. +#[test] +fn ruler_guides_are_not_exported() { + use vectorcraft_doc::{NodeId, NodeKind}; + let plain = doc(); + let mut guided = plain.clone(); + let guide = |id, vertical, pos| Node::new(NodeId(id), NodeKind::RulerGuide { vertical, pos, artboard: None }); + let (bottom, inner) = (guided.layers[0].id, guided.layers[1].children().unwrap().last().unwrap().id); + guided.insert(Some(bottom), usize::MAX, guide(900, true, 20.0)).unwrap(); + guided.insert(Some(bottom), usize::MAX, guide(901, false, 15.0)).unwrap(); + guided.insert(Some(inner), usize::MAX, guide(902, true, 3.0)).unwrap(); + for psd in [PsdOptions::default(), PsdOptions { max_editability: true, ..PsdOptions::default() }] { + assert_eq!(export(&guided, &opts(psd.clone())), export(&plain, &opts(psd))); + } + assert_eq!(art_bounds(&guided), art_bounds(&plain)); + for (g, p) in guided.layers.iter().zip(&plain.layers) { + assert_eq!(crate::drawn_bounds(&guided, g), crate::drawn_bounds(&plain, p)); + assert_eq!(crate::lru::node_bytes(g), crate::lru::node_bytes(p)); + } +} + #[test] fn a_background_colour_is_the_bottom_layer() { let doc = doc(); diff --git a/crates/render/src/lru.rs b/crates/render/src/lru.rs index 54ab4e51ed..85f327b89b 100644 --- a/crates/render/src/lru.rs +++ b/crates/render/src/lru.rs @@ -243,10 +243,14 @@ pub(crate) fn path_bytes(p: &BezPath) -> usize { size_of_val(p.elements()) } -/// Approximate bytes of node `n` and its members: the nodes and their path anchors. +/// Approximate bytes of node `n` and its members: the nodes and their path anchors. Ruler guides +/// are not drawn art and are not counted. pub(crate) fn node_bytes(n: &Node) -> usize { let mut b = 0; n.walk(&mut |c| { + if c.is_ruler_guide() { + return; + } b += size_of::() + NODE_OVERHEAD; if let Some(p) = c.path_data() { b += p.subpaths.iter().map(|s| size_of::() + s.anchors.len() * size_of::()).sum::(); diff --git a/crates/render/src/node_checks.rs b/crates/render/src/node_checks.rs index 900a59c836..bc516567dd 100644 --- a/crates/render/src/node_checks.rs +++ b/crates/render/src/node_checks.rs @@ -108,9 +108,11 @@ pub(crate) fn holds_instance(n: &Node) -> bool { } /// Is `a` left out of a frame `doc` draws with `opts`, before any culling: hidden, a template or -/// guide the view hides, drawn hidden, or the instance its symbol is being edited from? +/// guide the view hides, a ruler guide (the canvas draws those), drawn hidden, or the instance its +/// symbol is being edited from? pub(crate) fn left_out(opts: &crate::RenderOptions, doc: &vectorcraft_doc::Document, a: &Node) -> bool { - let view_item = (opts.skip_templates || opts.hide_templates) && matches!(a.kind, NodeKind::Layer { template: true, .. }) + let view_item = a.is_ruler_guide() + || (opts.skip_templates || opts.hide_templates) && matches!(a.kind, NodeKind::Layer { template: true, .. }) || (opts.skip_templates || opts.hide_guides) && matches!(a.kind, NodeKind::Path { guide: true, .. }); !a.visible || view_item || opts.hidden.contains(&a.id) || edited_instance(opts, doc, a.id) } diff --git a/crates/render/src/pattern.rs b/crates/render/src/pattern.rs index a722c799e2..2372abdbf2 100644 --- a/crates/render/src/pattern.rs +++ b/crates/render/src/pattern.rs @@ -361,7 +361,8 @@ impl Renderer { let Some(pe) = &f.doc.pattern_edit else { return false }; let Some(layer) = f.doc.layers.iter().find(|l| l.id == pe.layer) else { return false }; let Some(def) = f.doc.pattern(&pe.pattern) else { return false }; - let children: Vec> = layer.children().cloned().unwrap_or_default(); + // The tile's art: the layer's ruler guides are not part of it. + let children: Vec> = layer.children().into_iter().flatten().filter(|c| !c.is_ruler_guide()).cloned().collect(); if layer.visible && !children.is_empty() { let dim = (def.dim_copies / 100.0).clamp(0.0, 1.0); let (before, after) = def.preview_offsets_dimmed_sides(); diff --git a/crates/render/src/renderer/nodes.rs b/crates/render/src/renderer/nodes.rs index 9b59367deb..ca6611ea5f 100644 --- a/crates/render/src/renderer/nodes.rs +++ b/crates/render/src/renderer/nodes.rs @@ -596,6 +596,8 @@ impl Renderer { Some(a) => self.draw_live_body(ctx, f, &a, true), None => self.draw_live_node(ctx, f, n), }, + // The canvas draws ruler guides over the art; the renderer draws nothing for them. + NodeKind::RulerGuide { .. } => {} } } @@ -680,18 +682,20 @@ impl Renderer { self.draw_knockout(ctx, f, children); } else if let Some((_, path)) = self.isolation_path(f) { // Isolation mode, in a container on the way down to the isolated one: each run of - // members around it draws dimmed as one (#833). + // members around it draws dimmed as one (#833). A run of ruler guides alone draws + // nothing, so it gets no dimmed pass. + let draws = |run: &[Arc]| run.iter().any(|c| !c.is_ruler_guide()); let mut rest = children; while let Some(i) = rest.iter().position(|c| path.contains(&c.id)) { let (around, from) = rest.split_at(i); - if !around.is_empty() { + if draws(around) { self.dimmed(ctx, f, &mut |r, c, fr| around.iter().for_each(|n| r.draw_arc(c, fr, n))); } let Some((on_path, after)) = from.split_first() else { break }; self.draw_arc(ctx, f, on_path); rest = after; } - if !rest.is_empty() { + if draws(rest) { self.dimmed(ctx, f, &mut |r, c, fr| rest.iter().for_each(|n| r.draw_arc(c, fr, n))); } } else { diff --git a/crates/svg/src/export/nodes.rs b/crates/svg/src/export/nodes.rs index 129bd83e28..746ab84d21 100644 --- a/crates/svg/src/export/nodes.rs +++ b/crates/svg/src/export/nodes.rs @@ -633,6 +633,10 @@ impl Writer<'_> { /// An object, inside `` when it links to a URL (Attributes panel). pub(super) fn node(&mut self, n: &Node) { + // Ruler guides are not written, not even as a link. + if n.is_ruler_guide() { + return; + } let layer = usize::from(n.is_layer()); self.layer_nest += layer; match n.url().filter(|_| n.visible && !self.anonymous) { @@ -717,7 +721,7 @@ impl Writer<'_> { self.line(""); } } - NodeKind::Path { guide: true, .. } => {} + NodeKind::Path { guide: true, .. } | NodeKind::RulerGuide { .. } => {} NodeKind::Path { path, rule, .. } => { if path.is_empty() { return; diff --git a/crates/svg/src/export/output.rs b/crates/svg/src/export/output.rs index fb06f80057..4f91d6c970 100644 --- a/crates/svg/src/export/output.rs +++ b/crates/svg/src/export/output.rs @@ -170,6 +170,10 @@ impl Writer<'_> { } let mut named: Vec<(NodeId, String)> = Vec::new(); self.doc.walk(|n| { + // Ruler guides are not written, so their names reserve no id. + if n.is_ruler_guide() { + return; + } if let Some(name) = &n.name { named.push((n.id, sanitize_id(name))); } diff --git a/crates/svg/src/glyph.rs b/crates/svg/src/glyph.rs index c019f9b0a8..22697f58a3 100644 --- a/crates/svg/src/glyph.rs +++ b/crates/svg/src/glyph.rs @@ -44,18 +44,20 @@ pub fn glyph_art(document: &[u8], gid: u32, upem: f64, fill: Color, stroke: Opti // usvg resolves `context-fill` and `context-stroke` only for markers. let svg = svg.replace("context-fill", &hex(fill)).replace("context-stroke", &stroke.map_or_else(|| "none".into(), hex)); let (doc, _) = crate::import::import(&svg, &ImportOptions::default(), crate::MAX_NODES).ok()?; + // A layer's art, its ruler guides left out. + let art_of = |l: &Node| l.children().into_iter().flatten().filter(|c| !c.is_ruler_guide()).cloned().collect::>(); let mut art = if whole { - Node::group(NodeId(0), doc.layers.iter().map(|l| Arc::new(Node::group(NodeId(0), l.children().cloned().unwrap_or_default()))).collect()) + Node::group(NodeId(0), doc.layers.iter().map(|l| Arc::new(Node::group(NodeId(0), art_of(l)))).collect()) } else { // The glyph, top-level, is a layer when a ``, else an object named after its id. let named = |n: &Node| n.name.as_deref() == Some(id.as_str()); match doc.layers.iter().find(|l| named(l)) { - Some(layer) => Node::group(NodeId(0), layer.children().cloned().unwrap_or_default()), + Some(layer) => Node::group(NodeId(0), art_of(layer)), None => { let mut found = None; for layer in &doc.layers { layer.walk(&mut |n| { - if found.is_none() && named(n) { + if found.is_none() && !n.is_ruler_guide() && named(n) { found = Some(n.clone()); } }); @@ -199,7 +201,7 @@ fn own_art(n: &Node, doc: &Document) -> Option { im.pixels = Some(Arc::new(blob.clone())); } if let Some(children) = n.children_mut() { - *children = children.iter().filter_map(|c| own_art(c, doc).map(Arc::new)).collect(); + *children = children.iter().filter(|c| !c.is_ruler_guide()).filter_map(|c| own_art(c, doc).map(Arc::new)).collect(); } Some(n) } diff --git a/crates/svg/src/import/importer.rs b/crates/svg/src/import/importer.rs index 56efe7b3a8..ca31f28b85 100644 --- a/crates/svg/src/import/importer.rs +++ b/crates/svg/src/import/importer.rs @@ -500,20 +500,23 @@ impl Importer { } /// Put `art` in layer `l` at `index` (at most its end) unclipped: a clipping mask on the layer - /// becomes a clip group in it, holding the art it clipped. + /// becomes a clip group in it, holding the art it clipped. The layer's ruler guides stay last, + /// outside the clip group. pub(super) fn add_unclipped(&mut self, l: &mut Node, index: usize, art: Vec>) { if art.is_empty() { return; } if let NodeKind::Layer { children, clip: clip @ true, .. } = &mut l.kind { *clip = false; - let clipped = std::mem::take(children); + let mut clipped = std::mem::take(children); + let guides = clipped.split_off(Node::guide_run_start(&clipped)); if let Some(group) = self.named("", NodeKind::Group { children: clipped, clip: true }) { children.push(Arc::new(group)); } + children.extend(guides); } if let Some(ch) = l.children_mut() { - let i = index.min(ch.len()); + let i = index.min(Node::guide_run_start(ch)); ch.splice(i..i, art); } } diff --git a/crates/svg/tests/it/ids.rs b/crates/svg/tests/it/ids.rs index 0781397fa3..c41dd4bcf7 100644 --- a/crates/svg/tests/it/ids.rs +++ b/crates/svg/tests/it/ids.rs @@ -159,3 +159,21 @@ fn unique_ids_differ_when_only_gradient_stops_differ() { assert!(!ids1.is_empty() && !ids2.is_empty()); assert!(ids1.iter().all(|id| !ids2.contains(id)), "IDs must not collide: {ids1:?} vs {ids2:?}"); } + +/// Ruler guides on each layer, named as a layer and an object above them are, with a URL, are not +/// written: the SVG is the same as without them, with every kind of object id. +#[test] +fn ruler_guides_are_not_written_and_take_no_id() { + let plain = doc("a", 0.0); + let mut guided = plain.clone(); + for (i, (l, name)) in [(guided.layers[0].id, "Top & Front"), (guided.layers[1].id, "Logo mark (v2)")].into_iter().enumerate() { + let mut g = Node::new(guided.alloc_id(), vectorcraft_doc::NodeKind::RulerGuide { vertical: i == 0, pos: 30.0, artboard: None }); + g.name = Some(name.into()); + g.edit_attrs(|a| a.url = "https://example.com/".into()); + guided.insert(Some(l), usize::MAX, g).unwrap(); + } + for object_ids in [ObjectIds::LayerNames, ObjectIds::Minimal, ObjectIds::Unique] { + let opts = ExportOptions { object_ids, ..Default::default() }; + assert_eq!(export(&guided, &opts), export(&plain, &opts), "{object_ids:?}"); + } +} diff --git a/crates/testkit/src/fixtures.rs b/crates/testkit/src/fixtures.rs index 656bf4a8bb..8deb84a45b 100644 --- a/crates/testkit/src/fixtures.rs +++ b/crates/testkit/src/fixtures.rs @@ -263,20 +263,20 @@ impl DocBuilder { } } -/// All non-layer nodes of a document in paint order. +/// All nodes of a document other than layers and ruler guides, in paint order. pub fn art_nodes(doc: &Document) -> Vec<&Node> { let mut v = Vec::new(); doc.walk(|n| { - if !n.is_layer() { + if !n.is_layer() && !n.is_ruler_guide() { v.push(n) } }); v } -/// Ids of the top-level art (direct children of layers), bottom first. +/// Ids of the top-level art (direct children of layers other than ruler guides), bottom first. pub fn top_level_ids(doc: &Document) -> Vec { - doc.layers.iter().flat_map(|l| l.children().into_iter().flatten().map(|n| n.id)).collect() + doc.layers.iter().flat_map(|l| l.children().into_iter().flatten().filter(|n| !n.is_ruler_guide()).map(|n| n.id)).collect() } /// Every node id (layers included), paint order. diff --git a/crates/testkit/src/invariants.rs b/crates/testkit/src/invariants.rs index c03b33dd53..8791acf428 100644 --- a/crates/testkit/src/invariants.rs +++ b/crates/testkit/src/invariants.rs @@ -3,7 +3,7 @@ use std::collections::HashSet; use serde_json::{Value, json}; -use vectorcraft_doc::{Document, NodeKind}; +use vectorcraft_doc::{Document, Node, NodeKind}; use vectorcraft_engine::Session; /// Serialize a document to JSON (the canonical form for equality checks). @@ -11,7 +11,8 @@ pub fn doc_json(doc: &Document) -> Value { serde_json::to_value(doc).expect("document serializes") } -/// Tree invariants: ids unique, `node_count` equals the number of walked nodes, `next_id` above +/// Tree invariants: ids unique, `node_count` equals the number of walked nodes other than ruler +/// guides, ruler guides only at the top of a layer, `next_id` above /// every id, top level made of layers, layers only at the top or inside layers, clip groups have /// a first child, compound children are paths, no image holds its own pixels (the document keeps /// them). @@ -21,11 +22,21 @@ pub fn check_document(doc: &Document) -> Result<(), String> { let mut max = 0u64; let mut err = None; doc.walk(|n| { - walked += 1; + // `node_count` counts objects: ruler guides are left out of it. + walked += usize::from(!n.is_ruler_guide()); max = max.max(n.id.0); if !seen.insert(n.id) && err.is_none() { err = Some(format!("duplicate id {}", n.id)); } + let guides = n.children().map_or(0, |ch| ch.iter().filter(|c| c.is_ruler_guide()).count()); + if guides > 0 && err.is_none() { + let ch = n.children().map(Vec::as_slice).unwrap_or_default(); + if !n.is_layer() { + err = Some(format!("{} holds a ruler guide but is not a layer", n.id)); + } else if ch.len() - Node::guide_run_start(ch) != guides { + err = Some(format!("layer {} has a ruler guide below its art", n.id)); + } + } match &n.kind { NodeKind::Group { children, .. } if children.iter().any(|c| c.is_layer()) && err.is_none() => { err = Some(format!("group {} contains a layer", n.id)); diff --git a/crates/tools/src/direct/tests.rs b/crates/tools/src/direct/tests.rs index d7d4bb85b4..0ac443c731 100644 --- a/crates/tools/src/direct/tests.rs +++ b/crates/tools/src/direct/tests.rs @@ -40,7 +40,7 @@ fn a_press_where_a_mask_hides_a_selected_object_drags_it() { #[test] fn direct_and_group_selection_pick_ruler_guides() { let (mut d, id) = doc_with_rect(); - d.guides.push(vectorcraft_doc::Guide::new(true, 100.0)); + d.add_ruler_guide(&vectorcraft_doc::Guide::new(true, 100.0)); let (s, p) = (Selection::default(), paint()); let c = cx(&d, &s, &p); let down = |t: &mut DirectSelectionTool, y: f64| t.pointer(&c, &PointerEvent::new(PointerKind::Down, 100.0, y)); @@ -62,7 +62,7 @@ fn direct_and_group_selection_pick_ruler_guides() { #[test] fn a_cmd_shift_double_click_releases_the_guide_under_the_pointer() { let (mut d, id) = doc_with_rect(); - d.guides.push(vectorcraft_doc::Guide::new(false, 300.0)); + d.add_ruler_guide(&vectorcraft_doc::Guide::new(false, 300.0)); let Some(NodeKind::Path { guide, .. }) = d.node_mut(id).map(|n| &mut n.kind) else { panic!("the rectangle is a path") }; *guide = true; let (s, p) = (Selection::default(), paint()); @@ -848,7 +848,7 @@ fn crossed_segments_match_testing_a_path_for_each_segment() { #[test] fn marquee_selects_ruler_guides() { let (mut d, id) = doc_with_rect(); - d.guides.push(vectorcraft_doc::Guide::new(false, 80.0)); + d.add_ruler_guide(&vectorcraft_doc::Guide::new(false, 80.0)); let s = Selection::default(); let p = paint(); let cx = cx(&d, &s, &p); diff --git a/crates/tools/src/guides/targets.rs b/crates/tools/src/guides/targets.rs index c50889d030..41b9f517ee 100644 --- a/crates/tools/src/guides/targets.rs +++ b/crates/tools/src/guides/targets.rs @@ -579,7 +579,7 @@ impl Targets { if cx.guides { self.rulers = cx .doc - .guides + .ruler_guides() .iter() .filter(|g| cx.doc.guide_shown(g)) .map(|g| Ruler { vertical: g.vertical, pos: g.pos, span: cx.doc.guide_span(g) }) diff --git a/crates/tools/src/guides/tests.rs b/crates/tools/src/guides/tests.rs index c34f9ab7de..a4f3bc49fd 100644 --- a/crates/tools/src/guides/tests.rs +++ b/crates/tools/src/guides/tests.rs @@ -442,9 +442,9 @@ fn drawn_points_land_on_intersections() { let id = d.alloc_id(); d.insert(Some(l), 1, Node::path(id, path, Appearance::default_art())).unwrap(); } - d.guides.push(vectorcraft_doc::Guide::new(true, 400.0)); - d.guides.push(vectorcraft_doc::Guide::new(false, 300.0)); - d.guides.push(vectorcraft_doc::Guide::new(false, 150.0)); + d.add_ruler_guide(&vectorcraft_doc::Guide::new(true, 400.0)); + d.add_ruler_guide(&vectorcraft_doc::Guide::new(false, 300.0)); + d.add_ruler_guide(&vectorcraft_doc::Guide::new(false, 150.0)); let (s, p) = (Selection::default(), paint()); let c = ToolContext { guides: true, ..cx(&d, &s, &p) }; let t = Targets::for_drawing(&d, &[], None, None).styled(&c).with_rulers(&c); @@ -612,8 +612,8 @@ fn bisection_finds_what_a_scan_finds() { #[test] fn drawn_points_snap_to_ruler_guides_with_smart_guides_on() { let (mut d, _) = doc_with_rect(); - d.guides.push(vectorcraft_doc::Guide::new(false, 313.0)); - d.guides.push(vectorcraft_doc::Guide::new(true, 331.0)); + d.add_ruler_guide(&vectorcraft_doc::Guide::new(false, 313.0)); + d.add_ruler_guide(&vectorcraft_doc::Guide::new(true, 331.0)); let (s, p) = (Selection::default(), paint()); let c = cx(&d, &s, &p); let mut snap = DrawSnap::default(); diff --git a/crates/tools/src/rulerguide.rs b/crates/tools/src/rulerguide.rs index 3297769382..97d8bed289 100644 --- a/crates/tools/src/rulerguide.rs +++ b/crates/tools/src/rulerguide.rs @@ -12,27 +12,34 @@ //! or locked layer, can't be picked. use serde_json::json; +use vectorcraft_doc::{Guide, NodeId}; use vectorcraft_geom::{Point, Rect}; use crate::guides::Targets; use crate::{Action, Cursor, Overlay, PointerEvent, PointerKind, ToolContext}; -/// The ruler guide under `p`: the nearest within the selection tolerance (the newest of equals). +/// The ruler guide under `p`, by its index in `guide.list`: the nearest within the selection +/// tolerance (the newest of equals). pub fn guide_at(cx: &ToolContext, p: Point) -> Option { + guide_under(cx, p).map(|(i, _)| i) +} + +/// [`guide_at`], with the guide. +fn guide_under(cx: &ToolContext, p: Point) -> Option<(usize, Guide)> { if !cx.guides { return None; } let tol = cx.pick_tol(); let off = |pos: f64, vertical: bool| (if vertical { p.x } else { p.y } - pos).abs(); cx.doc - .guides - .iter() + .ruler_guides() + .into_iter() .enumerate() .filter(|(_, g)| cx.doc.guide_editable(g) && cx.doc.guide_passes(g, p, tol)) - .map(|(i, g)| (i, off(g.pos, g.vertical))) - .filter(|(_, d)| *d <= tol) + .map(|(i, g)| (i, off(g.pos, g.vertical), g)) + .filter(|(_, d, _)| *d <= tol) .min_by(|a, b| a.1.total_cmp(&b.1).then(b.0.cmp(&a.0))) - .map(|(i, _)| i) + .map(|(i, _, g)| (i, g)) } /// What a selection tool's marquee `r` does to the ruler guides, after its action selecting the @@ -42,19 +49,20 @@ pub fn guide_at(cx: &ToolContext, p: Point) -> Option { pub fn marquee_select(cx: &ToolContext, r: Rect, enclosed: bool) -> Option { let mut guides = guides_in_rect(cx, r); if enclosed { - guides.retain(|i| cx.doc.guides.get(*i).is_some_and(|g| guide_inside(cx, r, g))); + guides.retain(|(_, g)| guide_inside(cx, r, g)); } + let guides: Vec = guides.into_iter().map(|(i, _)| i).collect(); (!guides.is_empty()).then(|| Action::Exec("guide.select".into(), json!({ "indexes": guides, "toggle": true }))) } -/// The editable ruler guides intersecting marquee rectangle `r`. -fn guides_in_rect(cx: &ToolContext, r: Rect) -> Vec { +/// The editable ruler guides intersecting marquee rectangle `r`, with their `guide.list` indexes. +fn guides_in_rect(cx: &ToolContext, r: Rect) -> Vec<(usize, Guide)> { if !cx.guides { return vec![]; } cx.doc - .guides - .iter() + .ruler_guides() + .into_iter() .enumerate() .filter(|(_, g)| cx.doc.guide_editable(g)) .filter(|(_, g)| { @@ -64,14 +72,13 @@ fn guides_in_rect(cx: &ToolContext, r: Rect) -> Vec { r.min_y() <= g.pos && g.pos <= r.max_y() && cx.doc.guide_span(g).is_none_or(|(x0, x1)| r.max_x() >= x0 && r.min_x() <= x1) } }) - .map(|(i, _)| i) .collect() } /// Does ruler guide `g`, crossed by marquee `r`, run along its line within the marquee? An artboard /// guide does when the marquee spans its artboard; a canvas guide runs across the whole canvas and /// never does. -fn guide_inside(cx: &ToolContext, r: Rect, g: &vectorcraft_doc::Guide) -> bool { +fn guide_inside(cx: &ToolContext, r: Rect, g: &Guide) -> bool { let Some((a, b)) = cx.doc.guide_span(g) else { return false }; let (lo, hi) = if g.vertical { (r.min_y(), r.max_y()) } else { (r.min_x(), r.max_x()) }; lo <= a.min(b) && a.max(b) <= hi @@ -85,6 +92,8 @@ fn guide_cursor(vertical: bool) -> Cursor { #[derive(Clone, Copy, Debug)] struct Drag { index: usize, + /// The guide's node. + id: NodeId, vertical: bool, /// Where the guide was, and where it is now (x of a vertical guide, y of a horizontal one). from: f64, @@ -262,11 +271,12 @@ impl GuideEdit { /// A press `ev` on a ruler guide: it gets selected and the drag starts. None (and nothing /// done) when no guide is there. pub fn press(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Option> { - let index = guide_at(cx, ev.pos)?; - let g = cx.doc.guides.get(index)?; - let (shift, selected) = (ev.mods.shift, cx.selection.guides.contains(&index)); + let (index, g) = guide_under(cx, ev.pos)?; + let id = g.id?; + let (shift, selected) = (ev.mods.shift, cx.selection.guides.contains(&id)); self.drag = Some(Drag { index, + id, vertical: g.vertical, from: g.pos, at: g.pos, @@ -307,7 +317,7 @@ impl GuideEdit { vec![Action::Commit] } else if d.deselect { vec![Action::Exec("guide.select".into(), json!({ "indexes": [d.index], "toggle": true }))] - } else if d.key && cx.selection.key_guide == Some(d.index) { + } else if d.key && cx.selection.key_guide == Some(d.id) { // A click on the key guide again: no key. vec![Action::Exec("select.key".into(), json!({}))] } else if d.key { @@ -349,7 +359,7 @@ impl GuideEdit { if let Some(d) = &self.drag { return Some(guide_cursor(d.vertical).turned(cx.view_angle())); } - guide_at(cx, p).and_then(|i| cx.doc.guides.get(i)).map(|g| guide_cursor(g.vertical).turned(cx.view_angle())) + guide_under(cx, p).map(|(_, g)| guide_cursor(g.vertical).turned(cx.view_angle())) } /// While a guide is dragged: the smart guide it snapped to and its position by the pointer. diff --git a/crates/tools/src/rulerguide/tests.rs b/crates/tools/src/rulerguide/tests.rs index 4b4f1be72c..f80f31c776 100644 --- a/crates/tools/src/rulerguide/tests.rs +++ b/crates/tools/src/rulerguide/tests.rs @@ -10,10 +10,16 @@ fn ev(kind: PointerKind, x: f64, y: f64, mods: Mods) -> PointerEvent { fn guides_doc() -> vectorcraft_doc::Document { let (mut d, _) = doc_with_rect(); - d.guides = vec![Guide::new(true, 300.0), Guide::new(false, 400.0)]; + d.add_ruler_guide(&Guide::new(true, 300.0)); + d.add_ruler_guide(&Guide::new(false, 400.0)); d } +/// The node id of ruler guide `i` of `d` (its index in the guide commands). +fn gid(d: &vectorcraft_doc::Document, i: usize) -> vectorcraft_doc::NodeId { + d.ruler_guides()[i].id.unwrap() +} + #[test] fn picks_the_guide_within_the_tolerance_unless_hidden_or_locked() { let (d, s, p) = (guides_doc(), Selection::default(), paint()); @@ -37,7 +43,7 @@ fn a_drag_moves_the_selected_guides_in_one_step() { assert_eq!(g.press(&c, &ev(PointerKind::Down, 300.0, 50.0, m)), Some(vec![Action::Exec("guide.select".into(), json!({"indexes": [0]}))])); // The tool sees the guide selected from now on. let mut s = s; - s.set_guides([0]); + s.set_guides([gid(&d, 0)]); let c = cx(&d, &s, &p); let a = g.pointer(&c, &ev(PointerKind::Drag, 340.0, 60.0, m)).unwrap(); assert_eq!(a[0], Action::Begin("Move Guide".into())); @@ -52,7 +58,7 @@ fn a_drag_moves_the_selected_guides_in_one_step() { fn the_dragged_guide_snaps_to_art_ruler_ticks_and_the_grid() { let d = guides_doc(); let mut s = Selection::default(); - s.set_guides([0]); + s.set_guides([gid(&d, 0)]); let p = paint(); let drag_to = |c: &ToolContext, x: f64, mods: Mods| { let mut g = GuideEdit::default(); @@ -253,7 +259,7 @@ fn new_ruler_guide_alt_snaps_on_the_effective_axis() { fn with_smart_guides_off_snap_to_point_pulls_guides_onto_anchors() { let d = guides_doc(); let mut s = Selection::default(); - s.set_guides([0]); + s.set_guides([gid(&d, 0)]); let p = paint(); let off = ToolContext { smart_guides: false, ..cx(&d, &s, &p) }; let moved_to = |c: &ToolContext, x: f64| { @@ -291,7 +297,7 @@ fn an_artboard_guide_is_picked_and_snapped_to_across_its_artboard_only() { color: None, guide_label: None, }); - d.guides = vec![Guide { artboard: Some(id), ..Guide::new(false, 150.0) }]; + d.add_ruler_guide(&Guide { artboard: Some(id), ..Guide::new(false, 150.0) }); let (s, p) = (Selection::default(), paint()); let c = cx(&d, &s, &p); assert_eq!(guide_at(&c, Point::new(700.0, 151.0)), Some(0)); @@ -306,7 +312,7 @@ fn an_artboard_guide_is_picked_and_snapped_to_across_its_artboard_only() { fn shift_click_toggles_and_alt_drag_copies() { let d = guides_doc(); let mut s = Selection::default(); - s.set_guides([0, 1]); + s.set_guides([gid(&d, 0), gid(&d, 1)]); let p = paint(); let c = cx(&d, &s, &p); let (shift, alt) = (Mods { shift: true, ..Default::default() }, Mods { alt: true, ..Default::default() }); @@ -336,20 +342,20 @@ fn a_click_on_a_guide_selected_with_objects_makes_it_the_key() { }; let mut s = Selection::default(); s.set([rect]); - s.toggle_guide(0); + s.toggle_guide(gid(&d, 0)); let make = Action::Exec("select.key".into(), json!({"guide": 0})); assert_eq!(click(&s, Mods::default()), vec![make]); - s.key_guide = Some(0); + s.key_guide = Some(gid(&d, 0)); assert_eq!(click(&s, Mods::default()), vec![Action::Exec("select.key".into(), json!({}))], "the key guide again"); let shift = Mods { shift: true, ..Default::default() }; assert_eq!(click(&s, shift), vec![Action::Exec("guide.select".into(), json!({"indexes": [0], "toggle": true}))]); - s.set_guides([0]); + s.set_guides([gid(&d, 0)]); assert_eq!(click(&s, Mods::default()), vec![], "a guide alone has nothing to align"); // Not selected yet: the first click selects it. s.set([rect]); assert_eq!(click(&s, Mods::default()), vec![Action::Exec("guide.select".into(), json!({"indexes": [0]}))]); // A drag moves it. - s.toggle_guide(0); + s.toggle_guide(gid(&d, 0)); let c = cx(&d, &s, &p); let mut g = GuideEdit::default(); g.press(&c, &ev(PointerKind::Down, 302.0, 50.0, Mods::default())); @@ -362,7 +368,7 @@ fn dropped_on_its_ruler_the_guide_is_deleted() { let d = guides_doc(); let p = paint(); let mut s = Selection::default(); - s.set_guides([1]); + s.set_guides([gid(&d, 1)]); // The window shows the document from (0, 0) at 100 %: above y = 0 is the top ruler. let screen = ScreenFrame { origin: Point::ZERO, right: Vec2::new(1.0, 0.0), down: Vec2::new(0.0, 1.0), size: (800.0, 600.0) }; let c = ToolContext { screen: Some(screen), ..cx(&d, &s, &p) }; @@ -457,7 +463,7 @@ fn ruler_guide_shift_move_uses_document_origin() { let paint = paint(); for (index, vertical, from) in [(0, true, 300.0), (1, false, 400.0)] { let mut selection = Selection::default(); - selection.set_guides([index]); + selection.set_guides([gid(&doc, index)]); let c = ToolContext { unit, zoom, smart_guides: false, snap_to_point: false, ..cx(&doc, &selection, &paint) }; for copy in [false, true] { let mut guide = GuideEdit::default(); @@ -498,7 +504,7 @@ fn ruler_guide_shift_zero_and_invalid_origins_use_zero() { let paint = paint(); for (index, vertical, from, want) in [(0, true, 300.0, 225.0), (1, false, 400.0, 235.0)] { let mut selection = Selection::default(); - selection.set_guides([index]); + selection.set_guides([gid(&doc, index)]); let c = ToolContext { smart_guides: false, snap_to_point: false, ..cx(&doc, &selection, &paint) }; let mods = Mods { shift: true, ..Default::default() }; let mut new = NewGuide::new(&c, vertical, None); @@ -522,7 +528,7 @@ fn ruler_guide_origin_leaves_other_snap_modes_unchanged() { let paint = paint(); for (index, vertical, from) in [(0, true, 300.0), (1, false, 400.0)] { let mut selection = Selection::default(); - selection.set_guides([index]); + selection.set_guides([gid(&doc, index)]); let off = ToolContext { smart_guides: false, snap_to_point: false, ..cx(&doc, &selection, &paint) }; for (c, pointer, expected) in [ (ToolContext { ..off }, Point::new(226.4, 237.6), Point::new(226.4, 237.6)), @@ -561,7 +567,7 @@ fn ruler_guide_origin_readouts_are_relative_for_new_and_moved_guides() { for unit in [vectorcraft_doc::Unit::Points, vectorcraft_doc::Unit::Millimeters] { for (index, vertical, from) in [(0, true, 300.0), (1, false, 400.0)] { let mut selection = Selection::default(); - selection.set_guides([index]); + selection.set_guides([gid(&doc, index)]); let c = ToolContext { unit, smart_guides: false, snap_to_point: false, ..cx(&doc, &selection, &paint) }; let want = format!("{}: {}", if vertical { "X" } else { "Y" }, c.len(if vertical { expected.x } else { expected.y })); let check = |overlays: Vec| { diff --git a/crates/tools/src/select/tests.rs b/crates/tools/src/select/tests.rs index 67f5c91b5d..9069cda939 100644 --- a/crates/tools/src/select/tests.rs +++ b/crates/tools/src/select/tests.rs @@ -502,7 +502,7 @@ fn click_selects_object() { #[test] fn a_press_on_a_ruler_guide_picks_it_over_the_art() { let (mut d, _) = doc_with_rect(); - d.guides.push(vectorcraft_doc::Guide::new(false, 150.0)); + d.add_ruler_guide(&vectorcraft_doc::Guide::new(false, 150.0)); let s = Selection::default(); let p = paint(); let cx = cx(&d, &s, &p); @@ -524,7 +524,7 @@ fn a_press_on_a_ruler_guide_picks_it_over_the_art() { #[test] fn a_cmd_shift_double_click_releases_the_guide_under_the_pointer() { let (mut d, id) = doc_with_rect(); - d.guides.push(vectorcraft_doc::Guide::new(false, 300.0)); + d.add_ruler_guide(&vectorcraft_doc::Guide::new(false, 300.0)); let Some(vectorcraft_doc::NodeKind::Path { guide, .. }) = d.node_mut(id).map(|n| &mut n.kind) else { panic!("the rectangle is a path") }; *guide = true; let s = Selection::default(); @@ -679,7 +679,7 @@ fn box_cursors_turn_with_the_view() { let mut s = Selection::default(); s.add(id); let p = paint(); - d.guides.push(vectorcraft_doc::Guide::new(false, 300.0)); + d.add_ruler_guide(&vectorcraft_doc::Guide::new(false, 300.0)); let mut c = cx(&d, &s, &p); c.screen = turned_view(90.0); assert!((c.view_angle() - 90.0).abs() < 1e-9); @@ -752,7 +752,7 @@ fn escape_cancels_a_move_or_an_alt_drag_copy() { #[test] fn a_move_snaps_the_grabbed_point_to_a_ruler_guide_with_smart_guides_on() { let (mut d, id) = doc_with_rect(); - d.guides = vec![vectorcraft_doc::Guide::new(true, 173.4)]; + d.add_ruler_guide(&vectorcraft_doc::Guide::new(true, 173.4)); let mut s = Selection::default(); s.add(id); let p = paint(); @@ -775,7 +775,7 @@ fn a_move_snaps_the_grabbed_point_to_a_ruler_guide_with_smart_guides_on() { #[test] fn a_shift_move_lands_the_edges_on_a_ruler_guide() { let (mut d, id) = doc_with_rect(); - d.guides = vec![vectorcraft_doc::Guide::new(true, 333.0)]; + d.add_ruler_guide(&vectorcraft_doc::Guide::new(true, 333.0)); let mut s = Selection::default(); s.add(id); let p = paint(); @@ -1118,7 +1118,7 @@ fn a_shift_move_snapped_by_its_grabbed_point_stays_on_its_line() { let other = d.alloc_id(); let sq = vectorcraft_geom::shapes::rectangle(Rect::new(300.0, 151.5, 350.0, 201.5)); d.insert(Some(l), 1, vectorcraft_doc::Node::path(other, sq, vectorcraft_doc::Appearance::default_art())).unwrap(); - d.guides = vec![vectorcraft_doc::Guide::new(true, 420.5)]; + d.add_ruler_guide(&vectorcraft_doc::Guide::new(true, 420.5)); let mut s = Selection::default(); s.add(id); let p = paint(); @@ -1132,7 +1132,7 @@ fn a_shift_move_snapped_by_its_grabbed_point_stays_on_its_line() { assert!(moved_by(m, (270.5, 270.5)), "{m:?}"); // Smart Guides on, the selection alone with a ruler guide 1.5 below the grabbed point. let (mut d, id) = doc_with_rect(); - d.guides = vec![vectorcraft_doc::Guide::new(false, 330.5)]; + d.add_ruler_guide(&vectorcraft_doc::Guide::new(false, 330.5)); let mut s = Selection::default(); s.add(id); let (m, _) = shift_moved(&cx(&d, &s, &p), (329.0, 329.0)); @@ -1259,7 +1259,7 @@ fn a_click_on_a_selected_object_makes_it_the_key() { let mut one = Selection::default(); one.add(rect); assert!(click(&one, 150.0, 150.0).is_empty()); - one.guides = vec![0]; + one.guides = vec![vectorcraft_doc::NodeId(999)]; assert_eq!(click(&one, 150.0, 150.0), vec![Action::Exec("select.key".into(), json!({"id": rect.0}))]); } @@ -1482,8 +1482,8 @@ fn directional_marquee_uses_screen_direction_and_a_dead_zone() { fn e_during_a_marquee_takes_only_the_guides_inside() { let (mut d, id) = doc_with_rect(); let board = d.artboards[0].id; - d.guides.push(vectorcraft_doc::Guide::new(true, 150.0)); - d.guides.push(vectorcraft_doc::Guide { artboard: Some(board), ..vectorcraft_doc::Guide::new(false, 150.0) }); + d.add_ruler_guide(&vectorcraft_doc::Guide::new(true, 150.0)); + d.add_ruler_guide(&vectorcraft_doc::Guide { artboard: Some(board), ..vectorcraft_doc::Guide::new(false, 150.0) }); let s = Selection::default(); let p = paint(); let cx = cx(&d, &s, &p); @@ -1506,7 +1506,7 @@ fn e_during_a_marquee_takes_only_the_guides_inside() { #[test] fn marquee_selects_ruler_guides() { let (mut d, id) = doc_with_rect(); - d.guides.push(vectorcraft_doc::Guide::new(false, 80.0)); + d.add_ruler_guide(&vectorcraft_doc::Guide::new(false, 80.0)); let s = Selection::default(); let p = paint(); let cx = cx(&d, &s, &p); @@ -1523,7 +1523,7 @@ fn marquee_selects_ruler_guides() { #[test] fn marquee_selects_only_guide() { let (mut d, _) = doc_with_rect(); - d.guides.push(vectorcraft_doc::Guide::new(true, 20.0)); + d.add_ruler_guide(&vectorcraft_doc::Guide::new(true, 20.0)); let s = Selection::default(); let p = paint(); let cx = cx(&d, &s, &p); @@ -1540,7 +1540,7 @@ fn marquee_selects_only_guide() { #[test] fn marquee_ignores_locked_guides() { let (mut d, id) = doc_with_rect(); - d.guides.push(vectorcraft_doc::Guide::new(false, 80.0)); + d.add_ruler_guide(&vectorcraft_doc::Guide::new(false, 80.0)); let s = Selection::default(); let p = paint(); let mut cx_locked = cx(&d, &s, &p); @@ -1769,7 +1769,7 @@ fn handle_drag_snaps_to_anchors_and_ruler_guides_with_snap_to_point() { let other = d.alloc_id(); let sq = vectorcraft_geom::shapes::rectangle(Rect::new(250.0, 250.0, 300.0, 300.0)); d.insert(Some(l), 1, Node::path(other, sq, Appearance::default_art())).unwrap(); - d.guides = vec![vectorcraft_doc::Guide::new(true, 230.5)]; + d.add_ruler_guide(&vectorcraft_doc::Guide::new(true, 230.5)); let mut s = Selection::default(); s.add(id); let p = paint(); @@ -2436,7 +2436,7 @@ fn a_thin_shapes_centre_point_gives_the_handles_the_pointer() { #[test] fn a_ruler_guide_under_the_centre_point_keeps_the_pointer() { let (mut d, id) = doc_with_live_rect(); - d.guides.push(vectorcraft_doc::Guide::new(true, 150.0)); + d.add_ruler_guide(&vectorcraft_doc::Guide::new(true, 150.0)); let mut s = Selection::default(); s.add(id); let p = paint(); diff --git a/crates/tools/src/slice.rs b/crates/tools/src/slice.rs index 8b092d8ad0..69bbf9051f 100644 --- a/crates/tools/src/slice.rs +++ b/crates/tools/src/slice.rs @@ -41,9 +41,9 @@ fn has_area(r: Rect) -> bool { fn snap(cx: &ToolContext, p: Point) -> (Point, Vec) { let (mut q, ov) = crate::guides::snap_draw(cx, p, &[]); let tol = cx.tol(5.0); + let guides = cx.doc.ruler_guides(); let nearest = |vertical: bool, v: f64| { - cx.doc - .guides + guides .iter() .filter(|g| g.vertical == vertical && (g.pos - v).abs() <= tol && cx.doc.guide_shown(g) && cx.doc.guide_passes(g, p, tol)) .min_by(|a, b| (a.pos - v).abs().total_cmp(&(b.pos - v).abs())) @@ -390,7 +390,7 @@ mod tests { #[test] fn the_slice_tool_drags_out_a_slice_in_one_undo_step() { let (mut d, _) = doc_with_rect(); - d.guides.push(Guide::new(true, 403.0)); + d.add_ruler_guide(&Guide::new(true, 403.0)); let s = Selection::default(); let p = paint(); let mut c = cx(&d, &s, &p); @@ -557,7 +557,7 @@ mod tests { assert!(near(resize(&neither, (60.0, 25.0), (97.0, 25.0), none), Rect::new(10.0, 10.0, 97.0, 40.0))); // A ruler guide at x = 98 takes the side dragged 1 px from it, over the rectangle's edge // 1 px the other way, with Smart Guides on or off. - d.guides.push(Guide::new(true, 98.0)); + d.add_ruler_guide(&Guide::new(true, 98.0)); for c in [cx(&d, &sel, &p), ToolContext { smart_guides: false, snap_to_point: false, ..cx(&d, &sel, &p) }] { assert!(near(resize(&c, (60.0, 25.0), (99.0, 25.0), none), Rect::new(10.0, 10.0, 98.0, 40.0))); } diff --git a/crates/tools/src/xform/measure.rs b/crates/tools/src/xform/measure.rs index e3a0090561..cccb1ebcae 100644 --- a/crates/tools/src/xform/measure.rs +++ b/crates/tools/src/xform/measure.rs @@ -281,7 +281,7 @@ mod tests { #[test] fn shift_drag_meets_a_ruler_guide() { let (mut d, _) = doc_with_rect(); - d.guides.push(vectorcraft_doc::Guide::new(true, 50.0)); + d.add_ruler_guide(&vectorcraft_doc::Guide::new(true, 50.0)); let s = Selection::default(); let p = paint(); let mut cx = cx(&d, &s, &p); diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 56f121ad6d..50136f1a9c 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -1119,11 +1119,12 @@ fn guides_of(app: &VectorcraftApp, st: &vectorcraft_engine::DocState, (painter, let look = LineLook::guides(&app.session.prefs, t); let picked = LineLook { color: t.selection, ..look }; let labels = guide_labels(doc); - let selected: &[usize] = &st.selection.guides; + let selected: &[vectorcraft_doc::NodeId] = &st.selection.guides; let key = st.selection.key_guide; let view = view_bounds(xf); // Guides on a hidden layer hide with it. - for (i, g) in doc.guides.iter().enumerate().filter(|(_, g)| doc.guide_shown(g)) { + for g in doc.ruler_guides().iter().filter(|g| doc.guide_shown(g)) { + let Some(i) = g.id else { continue }; let at = |along: f64| xf.to_screen(if g.vertical { Point::new(g.pos, along) } else { Point::new(along, g.pos) }); // An artboard guide runs across its artboard, a canvas guide across the window (the part // of its document line that the canvas shows, turned with the view). diff --git a/crates/ui-egui/src/canvas/tests.rs b/crates/ui-egui/src/canvas/tests.rs index 81008bb0be..efe59c127e 100644 --- a/crates/ui-egui/src/canvas/tests.rs +++ b/crates/ui-egui/src/canvas/tests.rs @@ -1192,7 +1192,7 @@ fn a_drag_from_a_ruler_onto_the_canvas_makes_a_guide() { frame(app, &ctx, vec![egui::Event::PointerMoved(to)]); frame(app, &ctx, vec![button(to, false)]); }; - let guides = |app: &VectorcraftApp| app.session.active().unwrap().doc.guides.iter().map(|g| (g.vertical, g.pos)).collect::>(); + let guides = |app: &VectorcraftApp| app.session.active().unwrap().doc.ruler_guides().iter().map(|g| (g.vertical, g.pos)).collect::>(); // From the top ruler: a horizontal guide where the button was released. let to = pos2(rect.center().x, rect.top() + 120.0); drag(&mut app, pos2(rect.center().x, rect.top() - RULER / 2.0), to); @@ -1234,8 +1234,8 @@ fn artboard_guide_label_colors_only_its_own_guides() { { let st = app.session.active_mut().unwrap(); let doc = std::sync::Arc::make_mut(&mut st.doc); - let last = doc.guides.len() - 1; - doc.guides[last].artboard = Some(gone); + let last = doc.ruler_guides().last().and_then(|g| g.id).unwrap(); + assert!(doc.edit_ruler_guide(last, |g| g.artboard = Some(gone))); } app.ui.view.guides = true; let ctx = egui::Context::default(); @@ -1304,7 +1304,7 @@ fn a_guide_dragged_out_of_a_ruler_shows_while_dragged() { }; assert!(shapes(&mut app, &ctx).iter().any(across), "real-time drawing {real_time}: the guide shows while dragged"); frame(&mut app, &ctx, vec![button(at, false)]); - assert_eq!(app.session.active().unwrap().doc.guides.len(), 1, "real-time drawing {real_time}: released, it is made"); + assert_eq!(app.session.active().unwrap().doc.ruler_guides().len(), 1, "real-time drawing {real_time}: released, it is made"); } } @@ -1383,7 +1383,7 @@ fn new_ruler_guides_follow_alt_through_preview_release_and_undo() { frame(&mut app, &ctx, vec![button(from, true, initially_alt)]); frame(&mut app, &ctx, vec![Event::PointerMoved(at)]); let assert_guide = |app: &VectorcraftApp, alt: bool, pos: Point| { - let guides = &app.session.active().unwrap().doc.guides; + let guides = &app.session.active().unwrap().doc.ruler_guides(); assert_eq!(guides.len(), 1); assert_eq!(guides[0].vertical, source_vertical != alt); let expected = if source_vertical != alt { pos.x } else { pos.y }; @@ -1463,7 +1463,7 @@ fn new_ruler_guide_alt_cancel_keeps_document_and_redo() { frame(&mut app, &ctx, vec![Event::ModifiersChanged(Modifiers::ALT), Event::PointerMoved(from)]); frame(&mut app, &ctx, vec![button(from, true)]); frame(&mut app, &ctx, vec![Event::PointerMoved(at)]); - let guides = &app.session.active().unwrap().doc.guides; + let guides = &app.session.active().unwrap().doc.ruler_guides(); assert_eq!(guides.len(), 1); assert_eq!(guides[0].vertical, !source_vertical); let artboard = (tool == "artboard").then_some(before.artboards[0].id); @@ -1474,7 +1474,7 @@ fn new_ruler_guide_alt_cancel_keeps_document_and_redo() { assert_eq!(history(&app), before_history); app.session.execute("edit.redo", &json!({})).unwrap(); assert!(app.session.active().unwrap().doc.art_bounds().is_some()); - assert!(app.session.active().unwrap().doc.guides.is_empty()); + assert!(app.session.active().unwrap().doc.ruler_guides().is_empty()); } } } @@ -1513,14 +1513,14 @@ fn cmd_ruler_drags_make_artboard_guides_with_the_preference_on() { let board = app.session.active().unwrap().doc.artboards[0].id; drag(&mut app, Point::new(80.0, 120.0)); let st = app.session.active().unwrap(); - assert_eq!(st.doc.guides.len(), 1, "vertical {source_vertical}, on {on}"); - assert_eq!(st.doc.guides[0].vertical, source_vertical); - assert_eq!(st.doc.guides[0].artboard, on.then_some(board), "vertical {source_vertical}, on {on}"); + assert_eq!(st.doc.ruler_guides().len(), 1, "vertical {source_vertical}, on {on}"); + assert_eq!(st.doc.ruler_guides()[0].vertical, source_vertical); + assert_eq!(st.doc.ruler_guides()[0].artboard, on.then_some(board), "vertical {source_vertical}, on {on}"); // Beyond the artboard's right or bottom edge. let beyond = xf.to_doc(rect.right_bottom() - egui::vec2(2.0, 2.0)); assert!(beyond.x > 400.0 && beyond.y > 300.0, "{beyond:?}"); drag(&mut app, beyond); - assert_eq!(app.session.active().unwrap().doc.guides.len(), if on { 1 } else { 2 }, "vertical {source_vertical}, on {on}"); + assert_eq!(app.session.active().unwrap().doc.ruler_guides().len(), if on { 1 } else { 2 }, "vertical {source_vertical}, on {on}"); } } } @@ -1547,7 +1547,7 @@ fn guides_from_the_rulers_snap_and_artboard_guides_span_their_artboard() { frame(app, &ctx, vec![egui::Event::PointerMoved(to)]); frame(app, &ctx, vec![button(to, false)]); }; - let guides = |app: &VectorcraftApp| app.session.active().unwrap().doc.guides.iter().map(|g| (g.vertical, g.pos)).collect::>(); + let guides = |app: &VectorcraftApp| app.session.active().unwrap().doc.ruler_guides().iter().map(|g| (g.vertical, g.pos)).collect::>(); // 3 px off the middle of the top and bottom sides: onto the line through them. let mid = xf.to_screen(Point::new(200.0, 150.0)); drag(&mut app, pos2(rect.left() - RULER / 2.0, mid.y), mid + vec2(3.0, 40.0)); @@ -1596,7 +1596,7 @@ fn the_selection_tool_moves_and_deletes_ruler_guides() { frame(app, &ctx, vec![egui::Event::PointerMoved(to)]); frame(app, &ctx, vec![button(to, false)]); }; - let guides = |app: &VectorcraftApp| app.session.active().unwrap().doc.guides.iter().map(|g| (g.vertical, g.pos)).collect::>(); + let guides = |app: &VectorcraftApp| app.session.active().unwrap().doc.ruler_guides().iter().map(|g| (g.vertical, g.pos)).collect::>(); // Two guides out of the rulers. let (h, v) = (pos2(rect.center().x + 30.0, rect.center().y + 20.0), pos2(rect.center().x - 40.0, rect.center().y)); drag(&mut app, pos2(h.x, rect.top() - RULER / 2.0), h); @@ -1607,7 +1607,7 @@ fn the_selection_tool_moves_and_deletes_ruler_guides() { drag(&mut app, h, to); let st = app.session.active().unwrap(); assert_eq!(guides(&app), [(false, xf.to_doc(to).y), (true, xf.to_doc(v).x)]); - assert_eq!((st.selection.guides.clone(), st.selection.objects.clone()), (vec![0], vec![])); + assert_eq!((vectorcraft_engine::cmd::guide_indexes(&st.doc, &st.selection.guides), st.selection.objects.clone()), (vec![0], vec![])); assert_eq!(st.history.undo.last().unwrap().label, "Move Guide"); let picked = Tokens::get(&ctx).selection; let s = shapes(&mut app, &ctx); @@ -9512,7 +9512,7 @@ fn ruler_guide_origin_ui(alt: bool, cancel: bool) { frame(&mut app, &ctx, vec![button(from, true)]); frame(&mut app, &ctx, vec![Event::PointerMoved(to)]); let check = |app: &VectorcraftApp, switched: bool, expected: Point| { - let guides = &app.session.active().unwrap().doc.guides; + let guides = &app.session.active().unwrap().doc.ruler_guides(); assert_eq!(guides.len(), 1); let vertical = source_vertical != switched; assert_eq!(guides[0].vertical, vertical); @@ -9522,7 +9522,7 @@ fn ruler_guide_origin_ui(alt: bool, cancel: bool) { assert_eq!(history(&app).0, original_history.0, "preview has no undo entry"); if cancel { // Cancellation is a baseline positive control, independent of snap correctness. - assert_eq!(app.session.active().unwrap().doc.guides.len(), 1); + assert_eq!(app.session.active().unwrap().doc.ruler_guides().len(), 1); frame(&mut app, &ctx, vec![Event::PointerMoved(from), button(from, false)]); assert_eq!(app.session.active().unwrap().doc, before); assert_eq!(app.session.active().unwrap().selection, selection); @@ -9543,7 +9543,17 @@ fn ruler_guide_origin_ui(alt: bool, cancel: bool) { check(&app, alt, release); let st = app.session.active().unwrap(); assert_eq!(st.doc.ruler_origin_for(0), origin); - assert_eq!(st.doc.layers, before.layers, "the rectangle tool draws no artwork"); + let art = |d: &vectorcraft_doc::Document| { + d.layers + .iter() + .map(|l| { + let mut l = vectorcraft_doc::Node::clone(l); + l.strip_ruler_guides(); + l + }) + .collect::>() + }; + assert_eq!(art(&st.doc), art(&before), "the rectangle tool draws no artwork"); assert_eq!(st.selection, selection); assert_eq!(history(&app), (original_history.0 + 1, 0, true)); assert_eq!(st.history.undo.last().unwrap().label, "New Guide"); @@ -9621,8 +9631,9 @@ fn ruler_guide_origin_tooltip_matches_selected_guide_fields() { frame(&mut app, &ctx, vec![Event::PointerMoved(from)]); frame(&mut app, &ctx, vec![button(from, true)]); frame(&mut app, &ctx, vec![Event::PointerMoved(to)]); - assert!((app.session.active().unwrap().doc.guides[0].pos - target).abs() < 1e-4); - assert_eq!(app.session.active().unwrap().selection.guides, vec![0]); + assert!((app.session.active().unwrap().doc.ruler_guides()[0].pos - target).abs() < 1e-4); + let st = app.session.active().unwrap(); + assert_eq!(vectorcraft_engine::cmd::guide_indexes(&st.doc, &st.selection.guides), vec![0]); // Observe the real selected-guide field; no expected strings are inserted into UI state. let fields = egui::Context::default(); crate::theme::install_fonts(&fields); @@ -9652,7 +9663,7 @@ fn ruler_guide_origin_tooltip_matches_selected_guide_fields() { assert_eq!(app.session.active().unwrap().history.undo.len(), undo); frame(&mut app, &ctx, vec![button(to, false)]); assert_eq!(app.session.active().unwrap().history.undo.len(), undo + 1); - assert!((app.session.active().unwrap().doc.guides[0].pos - target).abs() < 1e-4, "stored coordinate remains absolute"); + assert!((app.session.active().unwrap().doc.ruler_guides()[0].pos - target).abs() < 1e-4, "stored coordinate remains absolute"); app.run("edit.undo", json!({})).unwrap(); assert_eq!(app.session.active().unwrap().doc, before); } diff --git a/crates/ui-egui/src/chrome/tests.rs b/crates/ui-egui/src/chrome/tests.rs index 452fec1cc0..d0b06cf325 100644 --- a/crates/ui-egui/src/chrome/tests.rs +++ b/crates/ui-egui/src/chrome/tests.rs @@ -64,6 +64,18 @@ use crate::theme::Tokens; use serde_json::json; use vectorcraft_engine::Session; +/// `layers` without their ruler guides: the art. +fn art_of(layers: &[Arc]) -> Vec { + layers + .iter() + .map(|l| { + let mut l = vectorcraft_doc::Node::clone(l); + l.strip_ruler_guides(); + l + }) + .collect() +} + #[test] fn help_bar_and_status_bar_render_independently() { for (help, status, screen_mode) in [(true, true, 0), (false, true, 0), (true, false, 0), (false, false, 0), (true, true, 3)] { @@ -1556,8 +1568,8 @@ fn artboard_document_tab_roundtrip_properties_delete_uses_shown_board() { assert_eq!(selected_before_delete, json!([1])); assert_eq!(active_before_delete, json!(1)); assert_eq!(st.doc.artboards, vec![before.artboards[0].clone()], "the original first board survives"); - assert_eq!(st.doc.guides, vec![before.guides[0].clone()], "only the deleted board's guide is removed"); - assert_eq!(st.doc.layers, before.layers, "artwork and symbol instances stay unchanged"); + assert_eq!(st.doc.ruler_guides(), vec![before.ruler_guides()[0].clone()], "only the deleted board's guide is removed"); + assert_eq!(art_of(&st.doc.layers), art_of(&before.layers), "artwork and symbol instances stay unchanged"); assert_eq!(st.doc.symbols, before.symbols, "symbol definitions stay unchanged"); assert_eq!(st.selection, selection); assert_eq!(app.run_count, commands.wrapping_add(1), "one command from the actual Properties control"); @@ -1853,9 +1865,9 @@ fn delete_shown_board(app: &mut crate::VectorcraftApp, ctx: &egui::Context, base "ARTBOARD_CLOSE_TARGET_WRONG_SURVIVOR: Properties showing Shown must delete Shown after closing B" ); assert_eq!(st.doc.as_ref(), expected, "only Shown and its attached guide may be removed from the full document"); - assert_eq!(st.doc.layers, before.layers, "all artwork and symbol instances stay unchanged"); + assert_eq!(art_of(&st.doc.layers), art_of(&before.layers), "all artwork and symbol instances stay unchanged"); assert_eq!(st.doc.symbols, before.symbols); - assert_eq!(st.doc.guides, vec![before.guides[0].clone()]); + assert_eq!(st.doc.ruler_guides(), vec![before.ruler_guides()[0].clone()]); assert_eq!(&st.selection, selection); assert_eq!(st.uid, *uid); assert_eq!(st.revision, revision + 1); @@ -1915,13 +1927,14 @@ fn artboard_close_active_tab_properties_delete_uses_shown_board() { let history_before = history_of(st); let history_limits = (st.history.limit, st.history.byte_budget); assert_eq!(before.artboards.iter().map(|a| a.name.as_str()).collect::>(), vec!["Keep", "Shown"]); - assert_eq!(before.guides.len(), 2); + assert_eq!(before.ruler_guides().len(), 2); assert!(!selection.objects.is_empty()); assert!(history_before.1.is_empty()); assert!(!st.is_dirty()); let mut expected = before.as_ref().clone(); expected.artboards = vec![before.artboards[0].clone()]; - expected.guides = vec![before.guides[0].clone()]; + let keep = before.ruler_guides()[0].id; + expected.retain_guides(&mut vectorcraft_doc::Selection::default(), |g| g.id == keep); let base = Baseline { uid, revision, before, selection, history_before, history_limits, expected }; close_b_tab(&mut app, &ctx, &base); delete_shown_board(&mut app, &ctx, &base); diff --git a/crates/ui-egui/src/dialogs/tests.rs b/crates/ui-egui/src/dialogs/tests.rs index 2ad8bdf213..a7d62b6daf 100644 --- a/crates/ui-egui/src/dialogs/tests.rs +++ b/crates/ui-egui/src/dialogs/tests.rs @@ -32,7 +32,7 @@ fn open(app: &mut VectorcraftApp, kind: &str, fields: serde_json::Value) { } fn objects(app: &VectorcraftApp) -> usize { - app.session.doc().unwrap().doc.layers.iter().map(|l| l.children().map_or(0, |c| c.len())).sum() + app.session.doc().unwrap().doc.layers.iter().map(|l| l.children().map_or(0, |c| c.iter().filter(|c| !c.is_ruler_guide()).count())).sum() } #[test] @@ -570,11 +570,11 @@ fn split_into_grid_opens_at_the_selections_size_and_previews() { ]); frame(&mut app, Default::default()); // The preview: 2 × 3 cells and the guides along their edges, not yet an undo step. - assert_eq!((objects(&app), app.session.doc().unwrap().doc.guides.len()), (6, 10)); + assert_eq!((objects(&app), app.session.doc().unwrap().doc.ruler_guides().len()), (6, 10)); assert!(app.session.in_interaction()); confirm(&mut app).unwrap(); let st = app.session.doc().unwrap(); - assert_eq!((objects(&app), st.doc.guides.len(), st.history.undo.len()), (6, 10, undo + 1)); + assert_eq!((objects(&app), st.doc.ruler_guides().len(), st.history.undo.len()), (6, 10, undo + 1)); assert!(app.ui.dialog.is_none() && !app.session.in_interaction()); // Cancel rolls a preview back. app.run("select.all", json!({})).unwrap(); diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index e69dc7b97c..b6ac6977a5 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -4755,6 +4755,8 @@ axis Both И двете @msg invalid object id {value}: expected a non-negative integer невалиден идентификатор на обект {value}: очаква се неотрицателно цяло число @msg `{key}` entry {index} is {value}, not a non-negative integer object id елемент {index} на `{key}` е {value}, а не идентификатор на обект (неотрицателно цяло число) @msg no such object id {value} няма обект с идентификатор {value} +@msg id {value} is a ruler guide, not an object идентификатор {value} е на водач, а не на обект +@msg a ruler guide goes only directly in a layer or sublayer водачът може да е само пряко в слой или подслой @msg automation filesystem access is not granted: read authority is absent: {path} достъпът на автоматизацията до файловата система не е разрешен: липсва право за четене: {path} @msg automation filesystem access is not granted: write authority is absent: {path} достъпът на автоматизацията до файловата система не е разрешен: липсва право за запис: {path} @msg automation path rejected: outside the read root {root}: {path} пътят за автоматизация е отхвърлен: извън корена за четене {root}: {path} diff --git a/crates/ui-egui/src/i18n/bs.tsv b/crates/ui-egui/src/i18n/bs.tsv index fc4610e0d0..1f452ccc01 100644 --- a/crates/ui-egui/src/i18n/bs.tsv +++ b/crates/ui-egui/src/i18n/bs.tsv @@ -5110,6 +5110,8 @@ imageTrace Outline Obris @msg invalid object id {value}: expected a non-negative integer nevažeći ID objekta {value}: očekuje se nenegativan cijeli broj @msg `{key}` entry {index} is {value}, not a non-negative integer object id stavka {index} u `{key}` je {value}, a ne ID objekta (nenegativan cijeli broj) @msg no such object id {value} nema objekta s ID-om {value} +@msg id {value} is a ruler guide, not an object ID {value} je vodilica, a ne objekat +@msg a ruler guide goes only directly in a layer or sublayer vodilica može biti samo direktno u sloju ili podsloju @msg id {value} is a layer, not an object id {value} je sloj, a ne objekat @msg id {value} is a layer, not an object; move layers with layer.move id {value} je sloj, a ne objekat; slojeve pomjerajte s layer.move @msg automation filesystem access is not granted: read authority is absent: {path} pristup datotečnom sistemu za automatizaciju nije odobren: nema ovlaštenja za čitanje: {path} diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index 44562ac0f1..35220f435e 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -5518,6 +5518,8 @@ digits Malayalam Malaiàlam @msg no such document no existeix aquest document @msg no such layer or object no existeix aquesta capa o objecte @msg no such object id {value} no existeix l’id d’objecte {value} +@msg id {value} is a ruler guide, not an object l’id {value} és una guia, no un objecte +@msg a ruler guide goes only directly in a layer or sublayer una guia només pot anar directament en una capa o subcapa @msg no such object {0} no existeix l’objecte {0} @msg no such perspective grid preset no existeix aquest preajust de quadrícula de perspectiva @msg no such recent file no existeix aquest fitxer recent diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index e42f9bd215..2da7d613da 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -5207,6 +5207,8 @@ imageTrace Outline Obrys @msg invalid object id {value}: expected a non-negative integer neplatné ID objektu {value}: očekává se nezáporné celé číslo @msg `{key}` entry {index} is {value}, not a non-negative integer object id položka {index} v `{key}` je {value}, nikoli ID objektu (nezáporné celé číslo) @msg no such object id {value} objekt s ID {value} neexistuje +@msg id {value} is a ruler guide, not an object ID {value} patří vodítku, ne objektu +@msg a ruler guide goes only directly in a layer or sublayer vodítko může být jen přímo ve vrstvě nebo podvrstvě @msg id {value} is a layer, not an object ID {value} je vrstva, nikoli objekt @msg id {value} is a layer, not an object; move layers with layer.move ID {value} je vrstva, nikoli objekt; vrstvy přesunete pomocí layer.move @msg automation filesystem access is not granted: read authority is absent: {path} přístup automatizace k systému souborů není udělen: chybí oprávnění ke čtení: {path} diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv index cf7aa67b3f..1d8f6c4b00 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -4802,6 +4802,8 @@ imageTrace Outline Kontur @msg invalid object id {value}: expected a non-negative integer ugyldigt objekt-id {value}: et ikke-negativt heltal var forventet @msg `{key}` entry {index} is {value}, not a non-negative integer object id element {index} i `{key}` er {value}, ikke et objekt-id (et ikke-negativt heltal) @msg no such object id {value} der findes intet objekt med id {value} +@msg id {value} is a ruler guide, not an object id {value} er en hjælpelinje, ikke et objekt +@msg a ruler guide goes only directly in a layer or sublayer en hjælpelinje kan kun ligge direkte i et lag eller underlag @msg automation filesystem access is not granted: read authority is absent: {path} automatiseringens adgang til filsystemet er ikke tildelt: læserettighed mangler: {path} @msg automation filesystem access is not granted: write authority is absent: {path} automatiseringens adgang til filsystemet er ikke tildelt: skriverettighed mangler: {path} @msg automation path rejected: outside the read root {root}: {path} automatiseringssti afvist: uden for læseroden {root}: {path} diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index b339033e66..708e3a4fac 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -4863,6 +4863,8 @@ theme Appearance Erscheinungsbild @msg invalid object id {value}: expected a non-negative integer ungültige Objekt-ID {value}: erwartet wird eine nicht negative ganze Zahl @msg `{key}` entry {index} is {value}, not a non-negative integer object id Eintrag {index} von `{key}` ist {value}, keine Objekt-ID (nicht negative ganze Zahl) @msg no such object id {value} kein Objekt mit der ID {value} +@msg id {value} is a ruler guide, not an object die ID {value} gehört zu einer Hilfslinie, nicht zu einem Objekt +@msg a ruler guide goes only directly in a layer or sublayer eine Hilfslinie kann nur direkt in einer Ebene oder Unterebene liegen @msg id {value} is a layer, not an object die ID {value} ist eine Ebene, kein Objekt @msg id {value} is a layer, not an object; move layers with layer.move die ID {value} ist eine Ebene, kein Objekt; verschieben Sie Ebenen mit layer.move @msg automation filesystem access is not granted: read authority is absent: {path} Dateisystemzugriff für die Automatisierung ist nicht gewährt: Die Leseberechtigung fehlt: {path} diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 114ed5a524..f5eb0b86d4 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -4886,6 +4886,8 @@ digits Malayalam Μαλαγιάλαμ @msg invalid object id {value}: expected a non-negative integer μη έγκυρο αναγνωριστικό αντικειμένου {value}: αναμένεται μη αρνητικός ακέραιος @msg `{key}` entry {index} is {value}, not a non-negative integer object id το στοιχείο {index} του `{key}` είναι {value}, όχι αναγνωριστικό αντικειμένου (μη αρνητικός ακέραιος) @msg no such object id {value} δεν υπάρχει αντικείμενο με αναγνωριστικό {value} +@msg id {value} is a ruler guide, not an object το αναγνωριστικό {value} ανήκει σε οδηγό, όχι σε αντικείμενο +@msg a ruler guide goes only directly in a layer or sublayer ένας οδηγός μπορεί να βρίσκεται μόνο απευθείας σε επίπεδο ή υποεπίπεδο @msg id {value} is a layer, not an object το αναγνωριστικό {value} είναι επίπεδο, όχι αντικείμενο @msg id {value} is a layer, not an object; move layers with layer.move το αναγνωριστικό {value} είναι επίπεδο, όχι αντικείμενο· μετακινήστε τα επίπεδα με το layer.move @msg automation filesystem access is not granted: read authority is absent: {path} δεν έχει επιτραπεί πρόσβαση αυτοματισμού στο σύστημα αρχείων: δεν υπάρχει δικαίωμα ανάγνωσης: {path} diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index c0e50d82e9..633d8d39b3 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -4925,6 +4925,8 @@ axis Both Ambos @msg invalid object id {value}: expected a non-negative integer identificador de objeto no válido {value}: se esperaba un entero no negativo @msg `{key}` entry {index} is {value}, not a non-negative integer object id la entrada {index} de `{key}` es {value}, no un identificador de objeto (un entero no negativo) @msg no such object id {value} no existe el objeto con el identificador {value} +@msg id {value} is a ruler guide, not an object el identificador {value} es de una guía, no de un objeto +@msg a ruler guide goes only directly in a layer or sublayer una guía solo puede estar directamente en una capa o subcapa @msg id {value} is a layer, not an object el identificador {value} es una capa, no un objeto @msg id {value} is a layer, not an object; move layers with layer.move el identificador {value} es una capa, no un objeto; mueva las capas con layer.move @msg automation filesystem access is not granted: read authority is absent: {path} la automatización no tiene acceso al sistema de archivos: falta el permiso de lectura: {path} diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index cdbd875764..64f64bcf81 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -5109,6 +5109,8 @@ imageTrace Outline Ääriviiva @msg invalid object id {value}: expected a non-negative integer virheellinen objektitunnus {value}: odotettiin ei-negatiivista kokonaislukua @msg `{key}` entry {index} is {value}, not a non-negative integer object id `{key}`-luettelon kohde {index} on {value}, ei objektitunnus (ei-negatiivinen kokonaisluku) @msg no such object id {value} objektia tunnuksella {value} ei ole +@msg id {value} is a ruler guide, not an object tunnus {value} on apuviivan, ei objektin +@msg a ruler guide goes only directly in a layer or sublayer apuviiva voi olla vain suoraan tasossa tai alatasossa @msg id {value} is a layer, not an object tunnus {value} on taso, ei objekti @msg id {value} is a layer, not an object; move layers with layer.move tunnus {value} on taso, ei objekti; siirrä tasoja komennolla layer.move @msg automation filesystem access is not granted: read authority is absent: {path} automaation tiedostojärjestelmäkäyttöä ei ole myönnetty: lukuoikeus puuttuu: {path} diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index cb81c4902c..2ad984ee88 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -4932,6 +4932,8 @@ axis Both Les deux @msg invalid object id {value}: expected a non-negative integer identifiant d’objet {value} non valide (un entier positif ou nul est attendu) @msg `{key}` entry {index} is {value}, not a non-negative integer object id l’entrée {index} de `{key}` vaut {value}, pas un identifiant d’objet (entier positif ou nul) @msg no such object id {value} aucun objet ne porte l’identifiant {value} +@msg id {value} is a ruler guide, not an object l’identifiant {value} est celui d’un repère, pas d’un objet +@msg a ruler guide goes only directly in a layer or sublayer un repère ne peut se trouver que directement dans un calque ou un sous-calque @msg id {value} is a layer, not an object l’identifiant {value} désigne un calque, pas un objet @msg id {value} is a layer, not an object; move layers with layer.move l’identifiant {value} désigne un calque, pas un objet ; déplacez les calques avec layer.move @msg automation filesystem access is not granted: read authority is absent: {path} l’automatisation n’a pas accès au système de fichiers : aucune autorisation de lecture : {path} diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index 296f18e805..fe87b81073 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -6944,6 +6944,8 @@ antiAlias Sharp शार्प @msg no such layer or object ऐसी कोई लेयर या ऑब्जेक्ट नहीं @msg no such object {0} ऐसी कोई ऑब्जेक्ट नहीं {0} @msg no such object id {value} ऐसी कोई ऑब्जेक्ट नहीं id {value} +@msg id {value} is a ruler guide, not an object id {value} एक गाइड है, ऑब्जेक्ट नहीं +@msg a ruler guide goes only directly in a layer or sublayer गाइड केवल सीधे किसी लेयर या सबलेयर में हो सकता है @msg no such objects ऐसी कोई ऑब्जेक्ट नहीं @msg no such path anchor, or the path would reach past the canvas इस तरह के कोई पाथ एंकर नहीं, या पाथ कैनवास तक पहुंच जाएगा @msg no such perspective grid preset ऐसा कोई पर्सपेक्टिव ग्रिड पूर्व निर्धारित नहीं है diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 34b6be0ab7..e46bd20911 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -5222,6 +5222,8 @@ imageTrace Outline Körvonal @msg invalid object id {value}: expected a non-negative integer érvénytelen objektumazonosító: {value} (nemnegatív egész szám kell) @msg `{key}` entry {index} is {value}, not a non-negative integer object id `{key}` {index} indexű eleme {value}, nem objektumazonosító (nemnegatív egész szám kell) @msg no such object id {value} nincs ilyen objektumazonosító: {value} +@msg id {value} is a ruler guide, not an object ez az azonosító segédvonalé, nem objektumé: {value} +@msg a ruler guide goes only directly in a layer or sublayer segédvonal csak közvetlenül rétegben vagy alrétegben lehet @msg id {value} is a layer, not an object a(z) {value} azonosító egy réteg, nem objektum @msg id {value} is a layer, not an object; move layers with layer.move a(z) {value} azonosító egy réteg, nem objektum; a rétegeket a layer.move paranccsal helyezheti át @msg object {0} cannot have children ennek az objektumnak nem lehetnek gyermekei: {0} diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 16854b5bb7..292819a1e8 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -4802,6 +4802,8 @@ imageTrace Outline Kontur @msg invalid object id {value}: expected a non-negative integer id objek {value} tidak valid: harus berupa bilangan bulat non-negatif @msg `{key}` entry {index} is {value}, not a non-negative integer object id entri {index} dari `{key}` adalah {value}, bukan id objek (bilangan bulat non-negatif) @msg no such object id {value} tidak ada objek dengan id {value} +@msg id {value} is a ruler guide, not an object id {value} adalah panduan, bukan objek +@msg a ruler guide goes only directly in a layer or sublayer panduan hanya dapat berada langsung di lapisan atau sublapisan @msg automation filesystem access is not granted: read authority is absent: {path} akses sistem berkas otomatisasi tidak diberikan: otoritas baca tidak ada: {path} @msg automation filesystem access is not granted: write authority is absent: {path} akses sistem berkas otomatisasi tidak diberikan: otoritas tulis tidak ada: {path} @msg automation path rejected: outside the read root {root}: {path} jalur otomatisasi ditolak: di luar akar baca {root}: {path} diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 6a8f3d7253..5c8be8c905 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -4968,6 +4968,8 @@ axis Both Entrambi @msg invalid object id {value}: expected a non-negative integer ID oggetto {value} non valido: è richiesto un intero non negativo @msg `{key}` entry {index} is {value}, not a non-negative integer object id la voce {index} di `{key}` è {value}, non un ID oggetto (intero non negativo) @msg no such object id {value} nessun oggetto con ID {value} +@msg id {value} is a ruler guide, not an object l’ID {value} è di una guida, non di un oggetto +@msg a ruler guide goes only directly in a layer or sublayer una guida può stare solo direttamente in un livello o sottolivello @msg id {value} is a layer, not an object l’ID {value} è un livello, non un oggetto @msg id {value} is a layer, not an object; move layers with layer.move l’ID {value} è un livello, non un oggetto; sposta i livelli con layer.move @msg automation filesystem access is not granted: read authority is absent: {path} l'automazione non ha accesso al file system: manca l'autorizzazione di lettura: {path} diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index aabe534b69..fb16ab4a68 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -5009,6 +5009,8 @@ swatch Bright Violet ビビッドバイオレット @msg invalid object id {value}: expected a non-negative integer オブジェクト ID {value} は正しくありません: 0 以上の整数を指定してください @msg `{key}` entry {index} is {value}, not a non-negative integer object id `{key}` の要素 {index} は {value} で、オブジェクト ID (0 以上の整数) ではありません @msg no such object id {value} ID が {value} のオブジェクトは見つかりません +@msg id {value} is a ruler guide, not an object ID {value} はオブジェクトではなくガイドです +@msg a ruler guide goes only directly in a layer or sublayer ガイドはレイヤーまたはサブレイヤーの直下にのみ置けます @msg id {value} is a layer, not an object ID {value} はオブジェクトではなくレイヤーです @msg id {value} is a layer, not an object; move layers with layer.move ID {value} はオブジェクトではなくレイヤーです。レイヤーは layer.move で移動してください @msg automation filesystem access is not granted: read authority is absent: {path} 自動化にはファイルシステムへのアクセスが許可されていません: 読み込み権限がありません: {path} diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index bb6a5da7d9..1750b42f18 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -4599,6 +4599,8 @@ font-technology Variable ცვლადი @msg invalid object id {value}: expected a non-negative integer ობიექტის არასწორი id {value}: მოსალოდნელია არაუარყოფითი მთელი რიცხვი @msg `{key}` entry {index} is {value}, not a non-negative integer object id `{key}`-ის ელემენტი {index} არის {value} და არა ობიექტის id (არაუარყოფითი მთელი რიცხვი) @msg no such object id {value} ობიექტი id-ით {value} არ არსებობს +@msg id {value} is a ruler guide, not an object id {value} დამხმარე ხაზისაა და არა ობიექტის +@msg a ruler guide goes only directly in a layer or sublayer დამხმარე ხაზი შეიძლება იყოს მხოლოდ უშუალოდ შრეში ან ქვეშრეში @msg id {value} is a layer, not an object id {value} შრეა და არა ობიექტი @msg id {value} is a layer, not an object; move layers with layer.move id {value} შრეა და არა ობიექტი; შრეების გადასატანად გამოიყენეთ layer.move @msg automation filesystem access is not granted: read authority is absent: {path} ავტომატიზაციას ფაილურ სისტემაზე წვდომა არ აქვს: წაკითხვის უფლება არ არის: {path} diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index b7f5824635..0218b8cdb1 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -4936,6 +4936,8 @@ theme Appearance 외관 @msg invalid object id {value}: expected a non-negative integer 개체 id {value}이(가) 올바르지 않습니다: 음이 아닌 정수여야 합니다 @msg `{key}` entry {index} is {value}, not a non-negative integer object id `{key}`의 항목 {index}은(는) {value}이며, 음이 아닌 정수인 개체 id가 아닙니다 @msg no such object id {value} id가 {value}인 개체가 없습니다 +@msg id {value} is a ruler guide, not an object id {value}은(는) 개체가 아니라 안내선입니다 +@msg a ruler guide goes only directly in a layer or sublayer 안내선은 레이어나 하위 레이어 바로 아래에만 둘 수 있습니다 @msg id {value} is a layer, not an object id {value}은(는) 개체가 아닌 레이어입니다 @msg id {value} is a layer, not an object; move layers with layer.move id {value}은(는) 개체가 아닌 레이어입니다. 레이어는 layer.move로 이동하세요 @msg automation filesystem access is not granted: read authority is absent: {path} 자동화 파일 시스템 접근이 허용되지 않았습니다: 읽기 권한이 없습니다: {path} diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index 32db6785bb..b2a76d374e 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -4814,6 +4814,8 @@ imageTrace Outline Contour @msg invalid object id {value}: expected a non-negative integer ongeldige object-id {value}: een niet-negatief geheel getal verwacht @msg `{key}` entry {index} is {value}, not a non-negative integer object id item {index} van `{key}` is {value}, geen object-id (een niet-negatief geheel getal) @msg no such object id {value} er is geen object met id {value} +@msg id {value} is a ruler guide, not an object id {value} is van een hulplijn, niet van een object +@msg a ruler guide goes only directly in a layer or sublayer een hulplijn kan alleen direct in een laag of sublaag staan @msg automation filesystem access is not granted: read authority is absent: {path} toegang tot het bestandssysteem voor automatisering is niet verleend: de leesbevoegdheid ontbreekt: {path} @msg automation filesystem access is not granted: write authority is absent: {path} toegang tot het bestandssysteem voor automatisering is niet verleend: de schrijfbevoegdheid ontbreekt: {path} @msg automation path rejected: outside the read root {root}: {path} automatiseringspad afgewezen: buiten de leesroot {root}: {path} diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 398adf5f50..ab5f88106d 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -5033,6 +5033,8 @@ imageTrace Outline Kontur @msg invalid object id {value}: expected a non-negative integer nieprawidłowy identyfikator obiektu {value}: oczekiwano nieujemnej liczby całkowitej @msg `{key}` entry {index} is {value}, not a non-negative integer object id element {index} w `{key}` to {value}, a nie identyfikator obiektu (nieujemna liczba całkowita) @msg no such object id {value} brak obiektu o identyfikatorze {value} +@msg id {value} is a ruler guide, not an object identyfikator {value} należy do linii pomocniczej, a nie do obiektu +@msg a ruler guide goes only directly in a layer or sublayer linia pomocnicza może się znajdować tylko bezpośrednio w warstwie lub podwarstwie @msg id {value} is a layer, not an object identyfikator {value} oznacza warstwę, a nie obiekt @msg id {value} is a layer, not an object; move layers with layer.move identyfikator {value} oznacza warstwę, a nie obiekt; warstwy przenosi się za pomocą layer.move @msg automation filesystem access is not granted: read authority is absent: {path} nie przyznano dostępu automatyzacji do systemu plików: brak uprawnień do odczytu: {path} diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 9ede5521a8..8cbe52e076 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -4433,6 +4433,8 @@ axis Both Ambos @msg invalid object id {value}: expected a non-negative integer id de objeto inválido {value}: era esperado um inteiro não negativo @msg `{key}` entry {index} is {value}, not a non-negative integer object id a entrada {index} de `{key}` é {value}, não um id de objeto (inteiro não negativo) @msg no such object id {value} não existe objeto com o id {value} +@msg id {value} is a ruler guide, not an object o id {value} é de uma guia, não de um objeto +@msg a ruler guide goes only directly in a layer or sublayer uma guia só pode ficar diretamente em uma camada ou subcamada @msg id {value} is a layer, not an object o id {value} é uma camada, não um objeto @msg id {value} is a layer, not an object; move layers with layer.move o id {value} é uma camada, não um objeto; mova camadas com layer.move @msg automation filesystem access is not granted: read authority is absent: {path} o acesso da automação ao sistema de arquivos não foi concedido: falta autorização de leitura: {path} diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 50eb9e969d..c25d7022b8 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -4964,6 +4964,8 @@ axis Both Оба @msg invalid object id {value}: expected a non-negative integer недопустимый идентификатор объекта {value}: ожидается неотрицательное целое число @msg `{key}` entry {index} is {value}, not a non-negative integer object id элемент {index} в `{key}` равен {value}, а не идентификатору объекта (неотрицательному целому числу) @msg no such object id {value} объект с идентификатором {value} не существует +@msg id {value} is a ruler guide, not an object идентификатор {value} принадлежит направляющей, а не объекту +@msg a ruler guide goes only directly in a layer or sublayer направляющая может находиться только непосредственно в слое или подслое @msg id {value} is a layer, not an object идентификатор {value} относится к слою, а не к объекту @msg id {value} is a layer, not an object; move layers with layer.move идентификатор {value} относится к слою, а не к объекту; перемещайте слои с помощью layer.move @msg automation filesystem access is not granted: read authority is absent: {path} у автоматизации нет доступа к файловой системе: нет права на чтение: {path} diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index 60324bb91e..8ebb74081c 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -4841,6 +4841,8 @@ axis Both Båda @msg invalid object id {value}: expected a non-negative integer ogiltigt objekt-id {value}: ett icke-negativt heltal förväntades @msg `{key}` entry {index} is {value}, not a non-negative integer object id post {index} i `{key}` är {value}, inte ett objekt-id (ett icke-negativt heltal) @msg no such object id {value} det finns inget objekt med id {value} +@msg id {value} is a ruler guide, not an object id {value} tillhör en stödlinje, inte ett objekt +@msg a ruler guide goes only directly in a layer or sublayer en stödlinje kan bara ligga direkt i ett lager eller underlager @msg id {value} is a layer, not an object id {value} är ett lager, inte ett objekt @msg id {value} is a layer, not an object; move layers with layer.move id {value} är ett lager, inte ett objekt; flytta lager med layer.move @msg automation filesystem access is not granted: read authority is absent: {path} automatiseringen har inte fått åtkomst till filsystemet: läsbehörighet saknas: {path} diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 496dba8cf5..7bbef2f652 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -5672,6 +5672,8 @@ theme Appearance Görünüm @msg invalid object id {value}: expected a non-negative integer geçersiz nesne kimliği {value}: negatif olmayan bir tam sayı bekleniyordu @msg `{key}` entry {index} is {value}, not a non-negative integer object id `{key}` içindeki {index} dizinli öğe {value}, negatif olmayan tam sayı bir nesne kimliği değil @msg no such object id {value} böyle bir nesne kimliği yok: {value} +@msg id {value} is a ruler guide, not an object bu kimlik bir nesneye değil, bir kılavuza ait: {value} +@msg a ruler guide goes only directly in a layer or sublayer bir kılavuz yalnızca doğrudan bir katmanda veya alt katmanda bulunabilir @msg id {value} is a layer, not an object {value} kimliği bir katmana ait, bir nesneye değil @msg id {value} is a layer, not an object; move layers with layer.move {value} kimliği bir katmana ait, bir nesneye değil; katmanları layer.move ile taşıyın @msg {key} must be Unix seconds (an integer) or null {key}, Unix saniyesi (bir tam sayı) veya null olmalıdır diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index c1b1c3bdaf..73142d8947 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -4952,6 +4952,8 @@ axis Both Обидві @msg invalid object id {value}: expected a non-negative integer неприпустимий ідентифікатор об’єкта {value}: очікується невід’ємне ціле число @msg `{key}` entry {index} is {value}, not a non-negative integer object id елемент {index} у `{key}` дорівнює {value}, а не ідентифікатору об’єкта (невід’ємному цілому числу) @msg no such object id {value} об’єкт з ідентифікатором {value} не існує +@msg id {value} is a ruler guide, not an object ідентифікатор {value} належить напрямній, а не об’єкту +@msg a ruler guide goes only directly in a layer or sublayer напрямна може бути лише безпосередньо в шарі або підшарі @msg id {value} is a layer, not an object ідентифікатор {value} належить шару, а не об’єкту @msg id {value} is a layer, not an object; move layers with layer.move ідентифікатор {value} належить шару, а не об’єкту; переміщуйте шари за допомогою layer.move @msg automation filesystem access is not granted: read authority is absent: {path} автоматизація не має доступу до файлової системи: немає права на читання: {path} diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index d458c348ee..f4b8829314 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -4877,6 +4877,8 @@ theme Appearance Giao diện @msg invalid object id {value}: expected a non-negative integer id đối tượng {value} không hợp lệ: cần một số nguyên không âm @msg `{key}` entry {index} is {value}, not a non-negative integer object id mục {index} của `{key}` là {value}, không phải id đối tượng (số nguyên không âm) @msg no such object id {value} không có đối tượng nào có id {value} +@msg id {value} is a ruler guide, not an object id {value} là của đường căn, không phải của đối tượng +@msg a ruler guide goes only directly in a layer or sublayer đường căn chỉ có thể nằm trực tiếp trong lớp hoặc lớp con @msg id {value} is a layer, not an object id {value} là một lớp, không phải đối tượng @msg id {value} is a layer, not an object; move layers with layer.move id {value} là một lớp, không phải đối tượng; hãy di chuyển lớp bằng layer.move @msg object {0} cannot have children đối tượng {0} không thể có phần tử con diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 028bd60571..134347d899 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -4713,6 +4713,8 @@ imageTrace Outline 轮廓 @msg invalid object id {value}: expected a non-negative integer 无效的对象 id {value}:应为非负整数 @msg `{key}` entry {index} is {value}, not a non-negative integer object id `{key}` 中索引 {index} 处的项为 {value},不是非负整数对象 id @msg no such object id {value} 没有 id 为 {value} 的对象 +@msg id {value} is a ruler guide, not an object id {value} 是参考线,不是对象 +@msg a ruler guide goes only directly in a layer or sublayer 参考线只能直接位于图层或子图层中 @msg id {value} is a layer, not an object id {value} 是图层,不是对象 @msg id {value} is a layer, not an object; move layers with layer.move id {value} 是图层,不是对象;请使用 layer.move 移动图层 @msg automation filesystem access is not granted: read authority is absent: {path} 未授予自动化文件系统访问权限:缺少读取权限:{path} diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 7cb218f99f..5378031708 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -5558,6 +5558,8 @@ lighting Light 光源 @msg invalid object id {value}: expected a non-negative integer 無效的物件 id {value}:應為非負整數 @msg `{key}` entry {index} is {value}, not a non-negative integer object id `{key}` 中索引 {index} 處的項目為 {value},不是非負整數物件 id @msg no such object id {value} 沒有 id 為 {value} 的物件 +@msg id {value} is a ruler guide, not an object id {value} 是參考線,不是物件 +@msg a ruler guide goes only directly in a layer or sublayer 參考線只能直接位於圖層或子圖層中 @msg id {value} is a layer, not an object id {value} 是圖層,不是物件 @msg id {value} is a layer, not an object; move layers with layer.move id {value} 是圖層,不是物件;請使用 layer.move 移動圖層 Move Anchor Point 移動錨點 diff --git a/crates/ui-egui/src/panels/align.rs b/crates/ui-egui/src/panels/align.rs index 0c322f0a05..e1076145d3 100644 --- a/crates/ui-egui/src/panels/align.rs +++ b/crates/ui-egui/src/panels/align.rs @@ -210,7 +210,9 @@ fn has_key(app: &VectorcraftApp) -> bool { /// The first selected ruler guide while objects are selected too: the one Align to Key Object /// makes the key when there is no other object to be it. fn guide_for_key(app: &VectorcraftApp) -> Option { - app.session.active().filter(|st| !st.selection.is_empty()).and_then(|st| st.selection.guides.first().copied()) + let st = app.session.active().filter(|st| !st.selection.is_empty())?; + let first = st.selection.guides.first().copied()?; + vectorcraft_engine::cmd::guide_indexes(&st.doc, &[first]).first().copied() } /// The number of selected ruler guides, which Distribute spaces with the objects (#1597). diff --git a/crates/ui-egui/src/panels/align/tests.rs b/crates/ui-egui/src/panels/align/tests.rs index 76b7e3b60d..6cd9364cda 100644 --- a/crates/ui-egui/src/panels/align/tests.rs +++ b/crates/ui-egui/src/panels/align/tests.rs @@ -28,11 +28,13 @@ fn align_to_key_object_makes_a_selected_guide_the_key() { let ctx = egui::Context::default(); let ([_, key, _], left) = panel_buttons(&mut app, &ctx); click(&ctx, &mut app, key.center(), show); - assert_eq!(app.session.active().unwrap().selection.key_guide, Some(0), "{}", app.ui.status); + let st = app.session.active().unwrap(); + let key = st.selection.key_guide.map(|k| vectorcraft_engine::cmd::guide_indexes(&st.doc, &[k])); + assert_eq!(key, Some(vec![0]), "{}", app.ui.status); assert_eq!(align_to(&app), AlignTo::Key); click(&ctx, &mut app, left.center(), show); assert_eq!(left_edge(&app, &a), 120.0, "on the guide, not the artboard: {}", app.ui.status); - assert_eq!(app.session.active().unwrap().doc.guides[0].pos, 120.0); + assert_eq!(app.session.active().unwrap().doc.ruler_guides()[0].pos, 120.0); } /// A Shift-click that leaves the key object selected on its own lets the key go, and the align @@ -532,13 +534,13 @@ fn the_distribute_buttons_send_the_spacing_with_a_key_guide() { app.ui.align_spacing = 15.0; click(&ctx, &mut app, left.center(), show); assert_eq!(lefts(&app), [70.0, 85.0, 115.0], "15 pt apart, outward from the guide: {}", app.ui.status); - assert_eq!(app.session.active().unwrap().doc.guides[0].pos, 100.0, "the guide stays put"); + assert_eq!(app.session.active().unwrap().doc.ruler_guides()[0].pos, 100.0, "the guide stays put"); // Horizontal Distribute Space with 15: the object before the guide ends 15 pt before it, // the one after it starts 15 pt after it. click(&ctx, &mut app, space.center(), show); assert_eq!(sent(&app), ("object.distributeSpacing".to_string(), json!("key"), Some(json!(15.0)))); assert_eq!(lefts(&app), [10.0, 45.0, 115.0], "{}", app.ui.status); - assert_eq!(app.session.active().unwrap().doc.guides[0].pos, 100.0, "the guide stays put"); + assert_eq!(app.session.active().unwrap().doc.ruler_guides()[0].pos, 100.0, "the guide stays put"); } /// Three vertical ruler guides selected without objects: Align Objects buttons are on, and @@ -560,7 +562,7 @@ fn the_align_buttons_align_guides_selected_alone_to_artboard() { row.sort_by(|a, b| a.left().total_cmp(&b.left())); assert!(row[0].width() > 0.0, "Horizontal Align Left is enabled"); click(&ctx, &mut app, row[0].center(), show); - let pos = |app: &VectorcraftApp| app.session.active().unwrap().doc.guides.iter().map(|g| g.pos).collect::>(); + let pos = |app: &VectorcraftApp| app.session.active().unwrap().doc.ruler_guides().iter().map(|g| g.pos).collect::>(); assert_eq!(pos(&app), [0.0, 0.0, 0.0], "{}", app.ui.status); } @@ -585,7 +587,7 @@ fn the_distribute_buttons_space_guides_selected_alone() { assert_eq!(row.len(), 6, "the Distribute row"); // Its fifth button is Horizontal Distribute Center. click(&ctx, &mut app, row[4].center(), show); - let pos = |app: &VectorcraftApp| app.session.active().unwrap().doc.guides.iter().map(|g| g.pos).collect::>(); + let pos = |app: &VectorcraftApp| app.session.active().unwrap().doc.ruler_guides().iter().map(|g| g.pos).collect::>(); assert_eq!(pos(&app), [20.0, 100.0, 180.0], "{}", app.ui.status); app.run("edit.undo", json!({})).unwrap(); assert_eq!(pos(&app), [20.0, 50.0, 180.0], "one undo step"); diff --git a/crates/ui-egui/src/panels/layers.rs b/crates/ui-egui/src/panels/layers.rs index 313242bb48..f027f2c717 100644 --- a/crates/ui-egui/src/panels/layers.rs +++ b/crates/ui-egui/src/panels/layers.rs @@ -151,8 +151,10 @@ struct View<'a> { /// Where the row thumbnails come from. thumbs: ThumbSource<'a>, sel: HashSet, - /// The selected ruler guides. + /// The selected ruler guides, as indexes into [`Self::ruler_guides`]. guides: Vec, + /// The document's ruler guides ([`Document::ruler_guides`]). + ruler_guides: Vec, target: Option, current: Option, rows: Vec, @@ -262,7 +264,8 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { doc: &doc, thumbs: ThumbSource::of(app, st, &doc), sel: st.selection.objects.iter().copied().collect(), - guides: st.selection.guides.clone(), + guides: vectorcraft_engine::cmd::guide_indexes(&doc, &st.selection.guides), + ruler_guides: doc.ruler_guides(), target: st.selection.target, current: st.current_layer(), rows, @@ -468,7 +471,8 @@ fn listed_when_opened(doc: &Document, open: &OpenRows, opts: &PanelOptions, quer open.set(*a, true); } let mut rows = vec![]; - push_rows(layer, 0, false, (Color32::PLACEHOLDER, true), (&open, opts, query, filter, doc), &mut rows); + // Only object rows are looked for, so the guide rows are left out. + push_rows(layer, 0, false, (Color32::PLACEHOLDER, true), (&open, opts, query, filter, &[]), &mut rows); rows.iter().any(|r| r.node.id == id && r.guide.is_none() && !r.spine) } diff --git a/crates/ui-egui/src/panels/layers/model.rs b/crates/ui-egui/src/panels/layers/model.rs index bbafbb7559..c24863ffbc 100644 --- a/crates/ui-egui/src/panels/layers/model.rs +++ b/crates/ui-egui/src/panels/layers/model.rs @@ -1,5 +1,5 @@ use super::rows::key; -use super::{Arc, Color32, Document, LiveShape, Node, NodeKind, OpenRows, PanelOptions, Ui, Value, VectorcraftApp}; +use super::{Arc, Color32, Document, LiveShape, Node, NodeId, NodeKind, OpenRows, PanelOptions, Ui, Value, VectorcraftApp}; /// Where a dragged row goes relative to the row it is dropped on. #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -42,7 +42,8 @@ pub(crate) fn shown_name(app: &VectorcraftApp, n: &Node, lang: crate::i18n::Lang pub(super) fn opens(n: &Node, opts: &PanelOptions) -> bool { match n.kind { NodeKind::Compound { .. } => false, - _ => n.children().is_some_and(|c| c.iter().any(|c| !opts.layers_only || c.is_layer())), + // Ruler guide nodes are not object rows (a layer's guide rows are counted by its caller). + _ => n.children().is_some_and(|c| c.iter().any(|c| !c.is_ruler_guide() && (!opts.layers_only || c.is_layer()))), } } @@ -226,25 +227,29 @@ struct RowsKey { /// contains `query` (lowercase) and that `filter` keeps, and every row holding one. pub(super) fn list_rows(doc: &Document, open: &OpenRows, opts: &PanelOptions, query: &str, filter: &Filter) -> Vec { let mask_layer = doc.mask_edit.map(|m| m.layer); + // The ruler guides' ids by `guide.list` index, listed once for every layer. + let guides: Vec = doc.ruler_guides().into_iter().filter_map(|g| g.id).collect(); let mut out = vec![]; for l in doc.layers.iter().rev().filter(|l| mask_layer.is_none_or(|m| m == l.id)) { - push_rows(l, 0, false, (Color32::PLACEHOLDER, true), (open, opts, query, filter, doc), &mut out); + push_rows(l, 0, false, (Color32::PLACEHOLDER, true), (open, opts, query, filter, &guides), &mut out); } out } /// Add the rows of `n` and those open inside it to `out`; `(colour, shown)` are those of what holds -/// it (the layer's colour, and whether it is visible). +/// it (the layer's colour, and whether it is visible). The last item of `cx` is the document's +/// ruler guide ids by `guide.list` index. pub(super) fn push_rows( n: &Arc, depth: usize, clip_path: bool, (colour, shown): (Color32, bool), - cx: (&OpenRows, &PanelOptions, &str, &Filter, &Document), + cx: (&OpenRows, &PanelOptions, &str, &Filter, &[NodeId]), out: &mut Vec, ) { - let (open_rows, opts, query, filter, doc) = cx; - if opts.layers_only && !n.is_layer() { + let (open_rows, opts, query, filter, all_guides) = cx; + // A ruler guide node gets no object row: its layer lists the guide rows. + if n.is_ruler_guide() || (opts.layers_only && !n.is_layer()) { return; } let colour = match &n.kind { @@ -257,10 +262,16 @@ pub(super) fn push_rows( let shown = shown && n.visible; let searching = !query.is_empty(); let narrowing = searching || filter.is_on(); - // A layer's ruler guides are rows in it, above its art (not while searching, nor while the - // filter leaves them out). - let guides: Vec = - if n.is_layer() && !opts.layers_only && !searching && filter.keeps_guides() { doc.guides_on(n.id).map(|(i, _)| i).collect() } else { vec![] }; + // A layer's ruler guides are rows in it, above its art, the highest `guide.list` index first + // (not while searching, nor while the filter leaves them out). + let guides: Vec = if n.is_layer() && !opts.layers_only && !searching && filter.keeps_guides() { + let mut found: Vec = + n.children().into_iter().flatten().filter(|c| c.is_ruler_guide()).filter_map(|c| all_guides.binary_search(&c.id).ok()).collect(); + found.sort_unstable(); + found + } else { + vec![] + }; let opens = opens(n, opts) || !guides.is_empty(); let open = opens && (narrowing || open_rows.contains(n.id)); let at = out.len(); diff --git a/crates/ui-egui/src/panels/layers/rows.rs b/crates/ui-egui/src/panels/layers/rows.rs index 376c021c41..13b6ff1e2b 100644 --- a/crates/ui-egui/src/panels/layers/rows.rs +++ b/crates/ui-egui/src/panels/layers/rows.rs @@ -122,7 +122,7 @@ fn takes(n: &Node, drag: &LayersDrag, doc: &Document) -> bool { fn guide_row(ui: &mut Ui, view: &View, item: &Row, i: usize, out: &mut Out) { let (t, h) = (&view.t, view.h); let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), h), Sense::click_and_drag()); - let Some(g) = view.doc.guides.get(i) else { return }; + let Some(g) = view.ruler_guides.get(i) else { return }; let selected = view.guides.contains(&i); let editable = view.doc.guide_editable(g); if editable && resp.hovered() { diff --git a/crates/ui-egui/src/panels/layers/tests.rs b/crates/ui-egui/src/panels/layers/tests.rs index cad2530e5f..2127789577 100644 --- a/crates/ui-egui/src/panels/layers/tests.rs +++ b/crates/ui-egui/src/panels/layers/tests.rs @@ -650,14 +650,17 @@ fn guide_rows_select_and_move_their_guides() { let (circles, _) = frame(&mut app, &ctx, vec![], false); assert_eq!(circles.len(), 3); let guide_row = egui::pos2(150.0, (circles[1].y + circles[2].y) / 2.0); - let guides = |app: &VectorcraftApp| app.session.active().unwrap().selection.guides.clone(); + let guides = |app: &VectorcraftApp| { + let st = app.session.active().unwrap(); + vectorcraft_engine::cmd::guide_indexes(&st.doc, &st.selection.guides) + }; click_with(&mut app, &ctx, guide_row, egui::Modifiers::NONE); assert_eq!(guides(&app), [0], "its row selects it"); // Dragged onto Layer 2's row, it goes on Layer 2, in one undo step. let undo = app.session.active().unwrap().history.undo.len(); drag(&mut app, &ctx, guide_row, egui::pos2(150.0, circles[0].y), false); let st = app.session.active().unwrap(); - assert_eq!((st.doc.guides[0].layer, st.history.undo.len()), (Some(second), undo + 1)); + assert_eq!((st.doc.ruler_guides()[0].layer, st.history.undo.len()), (Some(second), undo + 1)); // Now under Layer 2: locked, its row neither selects nor drags it. run(&mut app, "layer.setProps", json!({"ids": [second.0], "locked": true})); assert!(guides(&app).is_empty(), "deselected with its layer locked"); @@ -666,7 +669,7 @@ fn guide_rows_select_and_move_their_guides() { click_with(&mut app, &ctx, guide_row, egui::Modifiers::NONE); assert!(guides(&app).is_empty()); drag(&mut app, &ctx, guide_row, egui::pos2(150.0, circles[2].y), false); - assert_eq!(app.session.active().unwrap().doc.guides[0].layer, Some(second)); + assert_eq!(app.session.active().unwrap().doc.ruler_guides()[0].layer, Some(second)); } /// The selection squares drawn: (big ones on rows whose object is selected, small ones on rows @@ -1783,3 +1786,48 @@ fn a_blends_spine_is_a_row_below_its_keys_that_selects_the_blend() { click_with(&mut app, &ctx, spine_row, egui::Modifiers::NONE); assert!(selected(&app).is_empty(), "a locked blend's spine can't be selected"); } + +/// A ruler guide node gets no object row, and a layer that holds only one has no rows to open. +#[test] +fn ruler_guide_nodes_get_no_object_row() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 100, "height": 100})).unwrap(); + let rect = NodeId(app.session.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 50, "height": 50})).unwrap()["id"].as_u64().unwrap()); + expand(&mut app, &json!({})).unwrap(); + let st = app.session.active_mut().unwrap(); + let doc = Arc::make_mut(&mut st.doc); + let (first, second) = (doc.layers[0].id, doc.add_layer(Some("Guides only"))); + for l in [first, second] { + let g = Node::new(doc.alloc_id(), NodeKind::RulerGuide { vertical: true, pos: 20.0, artboard: None }); + doc.insert(Some(l), usize::MAX, g).unwrap(); + } + let rows = list_rows(&st.doc, &st.layers_open, &PanelOptions::default(), "", &Filter::default()); + let ids: Vec = rows.iter().filter(|r| r.guide.is_none()).map(|r| r.node.id).collect(); + assert_eq!(ids, [second, first, rect]); + // Each guide is a guide row of its layer instead. + let guide_rows: Vec = rows.iter().filter(|r| r.guide.is_some()).map(|r| r.node.id).collect(); + assert_eq!(guide_rows, [first], "the open layer lists its guide"); + assert!(rows[0].opens, "a layer with only a ruler guide opens to its guide row"); +} + +/// A layer lists its guide rows by `guide.list` index, the highest first, whatever the order of +/// the guide nodes in the layer (`guide.setLayer` puts a moved guide at the top of its new layer). +#[test] +fn guide_rows_list_by_index_whatever_the_node_order() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 100, "height": 100})).unwrap(); + for pos in [10, 20, 30] { + app.session.execute("guide.add", &json!({"vertical": true, "pos": pos})).unwrap(); + } + let st = app.session.active_mut().unwrap(); + let doc = Arc::make_mut(&mut st.doc); + let layer = doc.layers[0].id; + // The guide of index 0 moves to the top of the layer's guides. + let first = doc.ruler_guides()[0].id.unwrap(); + doc.move_node(first, Some(layer), usize::MAX).unwrap(); + let kids: Vec = doc.layers[0].children().unwrap().iter().map(|c| c.id).collect(); + assert_eq!(kids.last(), Some(&first)); + let rows = list_rows(&st.doc, &st.layers_open, &PanelOptions::default(), "", &Filter::default()); + let indexes: Vec = rows.iter().filter_map(|r| r.guide).collect(); + assert_eq!(indexes, [2, 1, 0]); +} diff --git a/crates/ui-egui/src/panels/layers/thumbs.rs b/crates/ui-egui/src/panels/layers/thumbs.rs index 2f15878133..dacf77425c 100644 --- a/crates/ui-egui/src/panels/layers/thumbs.rs +++ b/crates/ui-egui/src/panels/layers/thumbs.rs @@ -230,7 +230,8 @@ fn weigh(n: &Node, nodes: usize, anchors: usize, masks: bool, doc: Option<&Docum return false; } let mask = n.extras().mask.as_deref().filter(|m| left.masks && !m.disabled).map(|m| &m.art); - n.children().into_iter().flatten().chain(mask).all(|c| left.object() && count(c, doc, left, depth)) + // A layer's ruler guides draw nothing in its thumbnail and use no budget. + n.children().into_iter().flatten().filter(|c| !c.is_ruler_guide()).chain(mask).all(|c| left.object() && count(c, doc, left, depth)) } let mut left = Left { nodes, anchors, steps: THUMB_COUNT_STEPS, masks }; let small = count(n, doc, &mut left, 0); diff --git a/crates/ui-egui/src/panels/properties/tests.rs b/crates/ui-egui/src/panels/properties/tests.rs index ea1a60095a..a9853d3173 100644 --- a/crates/ui-egui/src/panels/properties/tests.rs +++ b/crates/ui-egui/src/panels/properties/tests.rs @@ -1733,7 +1733,7 @@ fn selected_guides_show_their_position_in_a_transform_section() { app.run("file.new", json!({"width": 200, "height": 100})).unwrap(); let v = app.run("guide.add", json!({"vertical": true, "pos": 30})).unwrap()["index"].as_u64().unwrap(); let h = app.run("guide.add", json!({"vertical": false, "pos": 20})).unwrap()["index"].as_u64().unwrap(); - let pos = |app: &VectorcraftApp, i: u64| app.session.doc().unwrap().doc.guides[i as usize].pos; + let pos = |app: &VectorcraftApp, i: u64| app.session.doc().unwrap().doc.ruler_guides()[i as usize].pos; let ctx = egui::Context::default(); // Steps the field labelled `label` up by one with ↑. let step = |app: &mut VectorcraftApp, label: &str| { diff --git a/crates/ui-egui/src/panels/transform.rs b/crates/ui-egui/src/panels/transform.rs index 2596b218b5..25ddd40764 100644 --- a/crates/ui-egui/src/panels/transform.rs +++ b/crates/ui-egui/src/panels/transform.rs @@ -223,7 +223,7 @@ pub(crate) fn selected_guides(app: &VectorcraftApp) -> Option<(Option, Opti return None; } let least = |vertical: bool| { - st.selection.guides.iter().filter_map(|i| st.doc.guides.get(*i)).filter(|g| g.vertical == vertical).map(|g| g.pos).min_by(f64::total_cmp) + st.selection.guides.iter().filter_map(|i| st.doc.ruler_guide(*i)).filter(|g| g.vertical == vertical).map(|g| g.pos).min_by(f64::total_cmp) }; Some((least(true), least(false))) } diff --git a/crates/ui-egui/src/tests/guide_escape.rs b/crates/ui-egui/src/tests/guide_escape.rs index 90c77f47dd..113d2b1e2f 100644 --- a/crates/ui-egui/src/tests/guide_escape.rs +++ b/crates/ui-egui/src/tests/guide_escape.rs @@ -90,7 +90,7 @@ impl GuideDrag { let point = |at| if vertical { Point::new(at, 60.0) } else { Point::new(60.0, at) }; (f.from, f.to, f.end) = (xf.to_screen(point(100.0)), xf.to_screen(point(140.0)), xf.to_screen(point(160.0))); assert!([f.from, f.to, f.end].into_iter().all(|p| rect.contains(p))); - assert_eq!(f.selection.guides, vec![0, 1]); + assert_eq!(vectorcraft_engine::cmd::guide_indexes(&f.before, &f.selection.guides), vec![0, 1]); f.app.session.active_mut().unwrap().mark_saved(); f.unchanged(); f @@ -140,17 +140,30 @@ impl GuideDrag { } fn guides_at_target(&self) { - let mut moved = self.before.guides.clone(); + let line = |g: &vectorcraft_doc::Guide| (g.vertical, g.pos, g.artboard, g.layer); + let before: Vec<_> = self.before.ruler_guides().iter().map(line).collect(); + let mut moved = before.clone(); for guide in &mut moved { - if guide.vertical == self.vertical { - guide.pos += 40.0; + if guide.0 == self.vertical { + guide.1 += 40.0; } } - let expected = if self.copy { self.before.guides.iter().cloned().chain(moved).collect::>() } else { moved }; + let expected = if self.copy { before.into_iter().chain(moved).collect::>() } else { moved }; let st = self.app.session.active().unwrap(); - assert_eq!(st.doc.guides, expected); - assert_eq!(st.doc.layers, self.before.layers); - assert_eq!(st.selection.guides, if self.copy { vec![2, 3] } else { vec![0, 1] }); + assert_eq!(st.doc.ruler_guides().iter().map(line).collect::>(), expected); + let art = |d: &vectorcraft_doc::Document| { + d.layers + .iter() + .map(|l| { + let mut l = vectorcraft_doc::Node::clone(l); + l.strip_ruler_guides(); + l + }) + .collect::>() + }; + assert_eq!(art(&st.doc), art(&self.before)); + let selected = vectorcraft_engine::cmd::guide_indexes(&st.doc, &st.selection.guides); + assert_eq!(selected, if self.copy { vec![2, 3] } else { vec![0, 1] }); } fn unchanged(&self) { diff --git a/crates/ui-egui/src/tests/ruler_origin.rs b/crates/ui-egui/src/tests/ruler_origin.rs index de4a0501e6..365cc92ef7 100644 --- a/crates/ui-egui/src/tests/ruler_origin.rs +++ b/crates/ui-egui/src/tests/ruler_origin.rs @@ -80,7 +80,7 @@ fn ruler_origin_drag_uses_the_rotated_zoomed_view_and_commits_only_on_release() assert!(st.doc.ruler_origin_for(0).distance(goal) < 1e-4, "{:?}", st.doc.ruler_origin_for(0)); assert_eq!(st.history.undo.len(), undo + 1); assert_eq!(st.doc.art_bounds(), None, "the drawing tool never receives the corner drag"); - assert!(st.doc.guides.is_empty()); + assert!(st.doc.ruler_guides().is_empty()); let origin = st.doc.ruler_origin_for(0); app.run("edit.undo", json!({})).unwrap(); assert_eq!(&*app.session.doc().unwrap().doc, &*before); diff --git a/crates/ui-egui/src/tests/sysclip.rs b/crates/ui-egui/src/tests/sysclip.rs index 595d12b51f..1da5b93322 100644 --- a/crates/ui-egui/src/tests/sysclip.rs +++ b/crates/ui-egui/src/tests/sysclip.rs @@ -174,8 +174,9 @@ pub(super) fn pasted(app: &VectorcraftApp) -> Vec { st.selection.objects.iter().map(|id| st.doc.node(*id).unwrap().clone()).collect() } +/// The objects on the first layer (its ruler guides left out). pub(super) fn count(app: &VectorcraftApp) -> usize { - app.session.active().unwrap().doc.layers[0].children().unwrap().len() + app.session.active().unwrap().doc.layers[0].children().unwrap().iter().filter(|c| !c.is_ruler_guide()).count() } /// An egui `PasteImage` from the host is the signal to read the rich system clipboard: it must diff --git a/crates/ui-egui/src/tests/sysclip_guides.rs b/crates/ui-egui/src/tests/sysclip_guides.rs index a54bd57eb6..50c4535c37 100644 --- a/crates/ui-egui/src/tests/sysclip_guides.rs +++ b/crates/ui-egui/src/tests/sysclip_guides.rs @@ -14,7 +14,7 @@ use crate::tests_sysclip::{app, app_with_busy_worker, copy_elsewhere, copy_rect, use crate::{Services, VectorcraftApp, background}; fn guides(app: &VectorcraftApp) -> usize { - app.session.active().unwrap().doc.guides.len() + app.session.active().unwrap().doc.ruler_guides().len() } /// Add a vertical guide at `pos`, select only it and copy it. @@ -35,7 +35,8 @@ fn a_guides_only_copy_pastes_the_guides_not_what_another_app_copied_before() { run(&mut app, "edit.paste", json!({})); assert_eq!(guides(&app), 2, "the copied guide was pasted"); assert_eq!(count(&app), 0, "and not the other app's text"); - assert_eq!(app.session.active().unwrap().selection.guides, [1]); + let st = app.session.active().unwrap(); + assert_eq!(vectorcraft_engine::cmd::guide_indexes(&st.doc, &st.selection.guides), [1]); // A second Paste takes the guides again. run(&mut app, "edit.paste", json!({})); assert_eq!((guides(&app), count(&app)), (3, 0)); diff --git a/docs/mcp.md b/docs/mcp.md index c766d812a1..6bf426fa41 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -2212,7 +2212,11 @@ kept with the workspace, and built-in workspaces and workspaces saved without it ## Ruler guides -Ruler guides are numbered in the order they were made. `guide.add {vertical, pos, artboard?}` makes one (the x of a +Each ruler guide is an object of a layer or sublayer, directly in it and above its art, and takes an id from the +document's object ids. Ruler guides are numbered by id, which is the order they were made (a guide read from an `.ai` +file takes its place in the file). An object command given a ruler guide's id (in `ids` or `id`) fails with an error, +and `node.move` cannot put a ruler guide into a group or out of its layers; the guide commands below take guides by +number. `guide.add {vertical, pos, artboard?}` makes one (the x of a vertical guide, the y of a horizontal one, in points) → `{index}`. With `artboard` (an artboard's index) it is an artboard guide: it runs across that artboard only, moves with it (`artboard.move`, a pure move in `artboard.setProps`, `artboard.rearrange`), scales with it (`artboard.setProps {scaleArt}`), is copied with it (`artboard.duplicate`, `artboard.move {copy}`) and is @@ -2236,8 +2240,8 @@ with guides unlocked, as the preference is off until `prefs.set` turns it on. `view.guides.release {index?, ids?}` (View › Guides › Release Guides) turns guides into paths → `{count}`. With `index` it releases that ruler guide, and with `ids` it releases those guide paths (and the guide paths inside them). Otherwise it releases the selected guide paths and ruler guides, and with no guide selected it releases every guide -path. A released ruler guide becomes a straight path with no fill or stroke, at the top of the guide's layer (or where -new art goes, for a guide on no layer). An artboard guide becomes a line across its artboard, and a canvas guide +path. A released ruler guide becomes a straight path with no fill or stroke, at the top of the art of the guide's +layer. An artboard guide becomes a line across its artboard, and a canvas guide becomes a line across the bounds of all the artboards. `index` and `ids` are refused while guides are locked. The release is one undo step, and the released paths are selected. With the Selection, Direct Selection or Group Selection tool, a double-click with Cmd/Ctrl and Shift held releases the ruler guide or guide path under the pointer