From 5ac7d97d85e8f75f962fae9743a01b776b256637 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 11 Nov 2026 08:01:01 -0800 Subject: [PATCH] Guides: ruler guides are document objects on their layers Ruler guides were a list kept beside the layers. Each guide is now an object of its layer or sublayer, always directly in it and above the layer's art. These behave as on main: - Guides draw on screen only. Exports (SVG, PDF, PNG, PSD), Fit All, Select All, the art bounds, clicks and marquees, and a layer's Pathfinder and Transform effects leave them out. The Layers panel lists them as guide rows of their layer, the highest guide.list index first. - Deleting a layer deletes its guides, and merging layers keeps their guides in the merged layer. - The guide commands number guides as guide.list does, in the order they were made. New: each guide takes an id from the document's object ids. An object command given a guide's id fails with an error, and node.move cannot put a guide into a group or out of its layers. A guide made while an opacity mask, a symbol or a pattern is edited moves to the top visible, unlocked layer when editing ends. File format: native files still save the guides in the document's guides list, in every file version, so older builds read them. Each entry may now also carry the guide's id, name, locked and hidden flags; older builds ignore those fields. Opening a file puts each saved guide on its layer (on the top visible, unlocked layer when the file names none or a layer it does not have), and each saved guide counts against the object limit for opening a file. A ruler guide node that a file holds below a layer's art moves up to the layer's guides, and one anywhere else (in a group or another object, beside the layers, or in a symbol) is removed. Ruler guides read from an .ai file become guide objects on the layer they were on, numbered in the order the file lists them. --- apps/vectorcraft-cli/src/main.rs | 7 +- crates/cad/src/scene.rs | 5 +- crates/doc/src/artboard.rs | 9 +- crates/doc/src/canvas_range.rs | 53 +- crates/doc/src/depth.rs | 9 +- crates/doc/src/document.rs | 41 +- crates/doc/src/document_ext.rs | 10 +- crates/doc/src/document_guides.rs | 485 ++++++++++++++---- crates/doc/src/guides.rs | 18 +- crates/doc/src/hit.rs | 7 +- crates/doc/src/lib.rs | 11 +- crates/doc/src/live/expand.rs | 2 + crates/doc/src/mem.rs | 2 +- crates/doc/src/node.rs | 1 + crates/doc/src/node/clip.rs | 2 +- crates/doc/src/node/kind.rs | 17 + crates/doc/src/node/ops.rs | 82 ++- crates/doc/src/node/wire.rs | 7 +- crates/doc/src/pattern.rs | 2 + crates/doc/src/selection.rs | 32 +- crates/doc/src/selection/tests.rs | 53 +- crates/doc/src/symbol_nine_slice.rs | 6 +- crates/doc/src/tests.rs | 262 ++++++++++ crates/effects/src/bake.rs | 3 +- crates/effects/src/group.rs | 18 +- crates/effects/src/group/tests.rs | 42 ++ crates/effects/src/reshape.rs | 7 +- crates/effects/src/solids.rs | 2 +- crates/effects/src/solids/source.rs | 4 +- crates/engine/src/cmd.rs | 14 +- .../engine/src/cmd/addons/cleanup/checks.rs | 6 +- .../src/cmd/addons/color/print_fixes.rs | 2 +- crates/engine/src/cmd/addons/document.rs | 10 +- crates/engine/src/cmd/appearance.rs | 2 +- crates/engine/src/cmd/assets.rs | 6 +- crates/engine/src/cmd/brushsym/dynamic.rs | 2 +- crates/engine/src/cmd/clipboard.rs | 13 +- crates/engine/src/cmd/colorcmds.rs | 2 +- crates/engine/src/cmd/colormgmt.rs | 2 +- crates/engine/src/cmd/docinfo.rs | 3 +- crates/engine/src/cmd/docmenu.rs | 110 ++-- crates/engine/src/cmd/edit.rs | 13 +- crates/engine/src/cmd/effectcmd.rs | 12 +- crates/engine/src/cmd/expand.rs | 2 +- crates/engine/src/cmd/fileio/load.rs | 4 +- crates/engine/src/cmd/flatten/apply.rs | 51 +- crates/engine/src/cmd/flatten/report.rs | 4 +- crates/engine/src/cmd/layer.rs | 32 +- crates/engine/src/cmd/layerpanel.rs | 14 +- crates/engine/src/cmd/maskedit.rs | 6 +- crates/engine/src/cmd/object/align.rs | 26 +- crates/engine/src/cmd/object/arrange.rs | 18 +- crates/engine/src/cmd/object/transform.rs | 16 +- crates/engine/src/cmd/overprint.rs | 2 +- crates/engine/src/cmd/paint.rs | 4 + crates/engine/src/cmd/panelcmds.rs | 2 +- crates/engine/src/cmd/path.rs | 6 +- crates/engine/src/cmd/pathops.rs | 4 +- crates/engine/src/cmd/patterncmds.rs | 11 +- crates/engine/src/cmd/place.rs | 2 +- crates/engine/src/cmd/printadvanced.rs | 7 +- crates/engine/src/cmd/rasterfx.rs | 6 +- crates/engine/src/cmd/select.rs | 13 +- crates/engine/src/cmd/slices.rs | 3 +- crates/engine/src/cmd/style.rs | 3 +- crates/engine/src/cmd/symboledit.rs | 16 +- crates/engine/src/cmd/wand.rs | 2 +- crates/engine/src/inspect.rs | 11 +- crates/engine/src/sanity.rs | 2 +- crates/engine/src/session/documents.rs | 6 +- crates/engine/src/session/execute.rs | 7 + crates/engine/src/tests.rs | 12 + crates/engine/src/tests/constrain_tools.rs | 4 +- crates/engine/src/tests/guide_clipboard.rs | 10 +- crates/engine/src/tests/keyguide.rs | 42 +- crates/engine/src/tests/menucmds.rs | 45 +- crates/engine/src/tests/panelcmds.rs | 6 +- crates/engine/src/tests/pathops.rs | 15 +- crates/engine/src/tests/prefs.rs | 7 +- crates/engine/src/tests/save.rs | 2 +- crates/engine/src/tests/slices.rs | 6 +- crates/engine/src/tests/textedit.rs | 2 +- crates/engine/src/tests/xform.rs | 2 +- crates/engine/tests/it/beyond_canvas.rs | 2 +- crates/engine/tests/it/clear_commands.rs | 6 +- crates/engine/tests/it/main.rs | 2 + crates/engine/tests/it/ruler_guide_nodes.rs | 439 ++++++++++++++++ crates/engine/tests/it/ruler_guide_perf.rs | 130 +++++ crates/engine/tests/it/ruler_origin.rs | 2 +- crates/eps/src/ai_editing/writer.rs | 9 + crates/eps/src/ai_editing/writer/styles.rs | 4 + crates/eps/src/import/ai/read/document.rs | 17 +- crates/eps/src/import/ai/tests.rs | 30 +- crates/eps/src/import/native/build.rs | 7 +- crates/eps/src/import/native/compare.rs | 2 +- crates/eps/src/import/tests_native.rs | 21 +- crates/eps/src/legacy_ai/writer.rs | 10 +- crates/eps/src/scene.rs | 2 + crates/format/src/lib.rs | 69 +++ crates/format/tests/it/main.rs | 1 + crates/format/tests/it/native_guides.rs | 146 ++++++ crates/format/tests/it/native_v3.rs | 16 +- crates/metafile/src/scene.rs | 5 +- crates/pdf/src/export/entry.rs | 2 +- crates/pdf/src/export/nodes.rs | 2 + crates/plugins/src/objects.rs | 5 +- crates/plugins/src/objects/tests.rs | 15 + crates/render/src/encode.rs | 4 +- crates/render/src/encode/psd.rs | 2 +- crates/render/src/encode/tests_psd.rs | 22 + crates/render/src/lru.rs | 6 +- crates/render/src/node_checks.rs | 6 +- crates/render/src/pattern.rs | 3 +- crates/render/src/renderer/nodes.rs | 10 +- crates/svg/src/export/nodes.rs | 6 +- crates/svg/src/export/output.rs | 4 + crates/svg/src/glyph.rs | 10 +- crates/svg/src/import/importer.rs | 9 +- crates/svg/tests/it/ids.rs | 18 + crates/testkit/src/fixtures.rs | 8 +- crates/testkit/src/invariants.rs | 17 +- crates/tools/src/direct/tests.rs | 6 +- crates/tools/src/guides/targets.rs | 2 +- crates/tools/src/guides/tests.rs | 10 +- crates/tools/src/rulerguide.rs | 46 +- crates/tools/src/rulerguide/tests.rs | 36 +- crates/tools/src/select/tests.rs | 30 +- crates/tools/src/slice.rs | 8 +- crates/tools/src/xform/measure.rs | 2 +- crates/ui-egui/src/canvas.rs | 5 +- crates/ui-egui/src/canvas/tests.rs | 51 +- crates/ui-egui/src/chrome/tests.rs | 25 +- crates/ui-egui/src/dialogs/tests.rs | 6 +- crates/ui-egui/src/i18n/bg.tsv | 2 + crates/ui-egui/src/i18n/bs.tsv | 2 + crates/ui-egui/src/i18n/ca.tsv | 2 + crates/ui-egui/src/i18n/cs.tsv | 2 + crates/ui-egui/src/i18n/da.tsv | 2 + crates/ui-egui/src/i18n/de.tsv | 2 + crates/ui-egui/src/i18n/el.tsv | 2 + crates/ui-egui/src/i18n/es.tsv | 2 + crates/ui-egui/src/i18n/fi.tsv | 2 + crates/ui-egui/src/i18n/fr.tsv | 2 + crates/ui-egui/src/i18n/hi.tsv | 2 + crates/ui-egui/src/i18n/hu.tsv | 2 + crates/ui-egui/src/i18n/id.tsv | 2 + crates/ui-egui/src/i18n/it.tsv | 2 + crates/ui-egui/src/i18n/ja.tsv | 2 + crates/ui-egui/src/i18n/ka.tsv | 2 + crates/ui-egui/src/i18n/ko.tsv | 2 + crates/ui-egui/src/i18n/nl.tsv | 2 + crates/ui-egui/src/i18n/pl.tsv | 2 + crates/ui-egui/src/i18n/pt-br.tsv | 2 + crates/ui-egui/src/i18n/ru.tsv | 2 + crates/ui-egui/src/i18n/sv.tsv | 2 + crates/ui-egui/src/i18n/tr.tsv | 2 + crates/ui-egui/src/i18n/uk.tsv | 2 + crates/ui-egui/src/i18n/vi.tsv | 2 + crates/ui-egui/src/i18n/zh-hans.tsv | 2 + crates/ui-egui/src/i18n/zh-hant.tsv | 2 + crates/ui-egui/src/panels/align.rs | 4 +- crates/ui-egui/src/panels/align/tests.rs | 14 +- crates/ui-egui/src/panels/layers.rs | 10 +- crates/ui-egui/src/panels/layers/model.rs | 33 +- crates/ui-egui/src/panels/layers/rows.rs | 2 +- crates/ui-egui/src/panels/layers/tests.rs | 54 +- crates/ui-egui/src/panels/layers/thumbs.rs | 3 +- crates/ui-egui/src/panels/properties/tests.rs | 2 +- crates/ui-egui/src/panels/transform.rs | 2 +- crates/ui-egui/src/tests/guide_escape.rs | 29 +- crates/ui-egui/src/tests/ruler_origin.rs | 2 +- crates/ui-egui/src/tests/sysclip.rs | 3 +- crates/ui-egui/src/tests/sysclip_guides.rs | 5 +- docs/mcp.md | 10 +- 174 files changed, 2735 insertions(+), 617 deletions(-) create mode 100644 crates/engine/tests/it/ruler_guide_nodes.rs create mode 100644 crates/engine/tests/it/ruler_guide_perf.rs create mode 100644 crates/format/tests/it/native_guides.rs 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