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.
This commit is contained in:
dotCooCoo
2026-11-11 09:52:28 -08:00
parent 61acb79de3
commit 5ac7d97d85
174 changed files with 2735 additions and 617 deletions
+6 -1
View File
@@ -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<Value> =
doc.artboards.iter().map(|a| json!({"name": a.name, "rect": [a.rect.x0, a.rect.y0, a.rect.width(), a.rect.height()]})).collect();
+4 -1
View File
@@ -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;
}
+2 -7
View File
@@ -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) {
+44 -9
View File
@@ -123,6 +123,24 @@ fn drop_children_beyond(node: &mut Arc<Node>, 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<Node>, 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<u32>)> = 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::<Vec<_>>(), [(2, vec![NodeId(10)])]);
assert_eq!(d.variables.bindings.keys().copied().collect::<Vec<_>>(), [NodeId(10)]);
+8 -1
View File
@@ -132,7 +132,7 @@ fn limit(nodes: &mut Vec<Arc<Node>>, 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<Arc<Node>>, 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
}
+31 -10
View File
@@ -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<NodeId>, index: usize, node: Node) -> Result<NodeId, DocError> {
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<NodeId>, 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<NodeId>) -> 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<NodeId>, 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());
}
+7 -3
View File
@@ -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<SavedSelection> = 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);
}
}
}
+398 -87
View File
@@ -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<Option<NodeId>> = 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<NodeId, Vec<Arc<Node>>> = 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<Guide> {
if let Some(listed) = self.guide_cache.get(&self.layers) {
return listed.as_ref().clone();
}
fn visit(layer: &Node, out: &mut Vec<Guide>) {
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<Guide> {
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<Arc<Node>, 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<NodeId> {
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<NodeId> {
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<bool> = 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<bool> = 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<Guide> = 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<Item = (usize, &Guide)> + '_ {
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<Guide> {
let mut out: Vec<Guide> = 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<bool> = 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<NodeId> = 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<Node>, gone: &HashSet<NodeId>) {
fn holds(n: &Node, gone: &HashSet<NodeId>) -> 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<Node>, 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<Guide> =
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<Guide> {
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<Option<Listed>>);
/// The top-level layers a guide list was made from, and the list.
type Listed = (Vec<std::sync::Weak<Node>>, Arc<Vec<Guide>>);
impl GuideCache {
/// The cached list, when it was made from `layers`.
fn get(&self, layers: &[Arc<Node>]) -> Option<Arc<Vec<Guide>>> {
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<Node>], 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")
}
}
+17 -1
View File
@@ -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<NodeId>,
/// 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<NodeId>,
/// The name it shows in the Layers panel, if it was given one.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
/// 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).
+4 -3
View File
@@ -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<NodeId>, 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<NodeId>, 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));
}
}
+9 -2
View File
@@ -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<GraphDesign>,
#[serde(default)]
pub guides: Vec<Guide>,
/// 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<Guide>,
/// View → New View… (at most [`SavedView::MAX`]); saved with the document.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub views: Vec<SavedView>,
@@ -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 {
+2
View File
@@ -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
}
+1 -1
View File
@@ -88,7 +88,7 @@ impl Document {
}
let mut n = 0;
n += vec_bytes(&self.artboards) + self.artboards.iter().map(|a| a.name.capacity()).sum::<usize>();
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::<usize>();
n += self.images.iter().map(|(k, b)| 4 * size_of::<usize>() + k.capacity() + b.mime.capacity() + size_of_val(b)).sum::<usize>();
n += map_bytes(&self.export_settings) + map_bytes(&self.extra) + self.print_setup.as_ref().map_or(0, json_bytes);
+1
View File
@@ -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 {
+1 -1
View File
@@ -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();
+17
View File
@@ -122,6 +122,17 @@ pub enum NodeKind {
Intertwine {
children: Vec<Arc<Node>>,
},
/// 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<u32>,
},
}
/// Reading a [`NodeKind`]. Derived, an internally tagged enum buffers each kind (with every
@@ -215,6 +226,12 @@ mod kind_de {
Intertwine {
children: Vec<Arc<Node>>,
},
RulerGuide {
vertical: bool,
pos: f64,
#[serde(default)]
artboard: Option<u32>,
},
}
impl<'de> Deserialize<'de> for NodeKind {
+76 -6
View File
@@ -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<NodeId> {
fn visit(n: &Node, editable: bool, out: &mut Vec<NodeId>) {
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<Node>]) -> Vec<&Arc<Node>> {
fn push<'a>(children: &'a [Arc<Node>], out: &mut Vec<&'a Arc<Node>>) {
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<Node>]) -> 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<Node>], 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.
+4 -3
View File
@@ -145,9 +145,10 @@ fn count_one<E: serde::de::Error>() -> 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<Vec<T>, 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<Vec<T>, D::Error> {
struct Items<T>(std::marker::PhantomData<T>);
impl<'de, T: Deserialize<'de>> serde::de::Visitor<'de> for Items<T> {
type Value = Vec<T>;
+2
View File
@@ -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 },
};
+19 -13
View File
@@ -39,16 +39,16 @@ pub struct Selection {
/// clears them.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub slices: Vec<NodeId>,
/// 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<usize>,
/// 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<NodeId>,
/// 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<usize>,
pub key_guide: Option<NodeId>,
/// 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<Item = usize>) {
/// Select ruler guides `ids` (see [`Selection::guides`]) and nothing else.
pub fn set_guides(&mut self, ids: impl IntoIterator<Item = NodeId>) {
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| {
+34 -19
View File
@@ -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
}
+4 -2
View File
@@ -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
+262
View File
@@ -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::<Vec<_>>(), [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::<Vec<_>>();
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::<Vec<_>>();
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(&copy), line(&d));
d.remove(g1).unwrap();
assert_eq!(line(&d), [(Some(g2), 9.0, false, Some(sub))]);
assert_eq!(line(&copy).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<NodeId>)> = 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<bool> = 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<u32>,
#[serde(default)]
layer: Option<NodeId>,
}
let old: Vec<Old> = 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<u32>)> = 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::<Vec<_>>(), [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;
+2 -1
View File
@@ -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<Node> {
// 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.
+17 -1
View File
@@ -455,6 +455,17 @@ pub fn pathfinder_children(doc: Option<&Document>, group: &Node, hook: Option<Ou
return None;
}
let source: &[Arc<Node>] = 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<Arc<Node>> = 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<Node> {
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<Node> {
}
m.appearance.items.clear();
m.appearance.contents_index = None;
if let Some(ch) = m.children_mut() {
ch.extend(guides);
}
Some(m)
}
+42
View File
@@ -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::<Vec<_>>()
};
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::<Vec<_>>(), &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);
}
+5 -2
View File
@@ -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;
}
+1 -1
View File
@@ -348,7 +348,7 @@ pub(crate) fn members(n: &Node, label: &str) -> Result<Vec<Profile>, 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;
+2 -2
View File
@@ -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)? {
+9 -5
View File
@@ -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<NodeId> {
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<Vec<NodeId>> {
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};
@@ -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<Out
if i < d.artboards.len() {
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(Outcome { count: empty.len(), ids: vec![], artboards: empty })
@@ -107,7 +107,7 @@ impl Fixes {
let white = usize::from(self.remove_white_overprint && self.unset_white(n));
let blacks = self.overprint_black > 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);
}
+6 -4
View File
@@ -164,11 +164,11 @@ fn export_all_open(s: &mut Session, p: &Value) -> Result<Value> {
}
/// `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<Value> {
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<NodeId> = 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<Arc<Node>> = layer.children().into_iter().flatten().filter(|n| !n.is_ruler_guide()).cloned().collect();
let roots: Vec<NodeId> = 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()))),
+1 -1
View File
@@ -1189,7 +1189,7 @@ fn target_contents(s: &mut Session, p: &Value) -> Result<Value> {
.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"));
+4 -2
View File
@@ -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<Value> {
+1 -1
View File
@@ -137,7 +137,7 @@ fn path_param(p: &Value, art: &Node, cmd: &str) -> Result<Vec<u32>> {
/// stroke has a Convert to Shape or Outline Object effect.
fn paint_refusal(n: &Node, key: &str, cmd: &str) -> Option<EngineError> {
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) };
+12 -1
View File
@@ -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)
}
+1 -1
View File
@@ -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<NodeId>, roots: &[NodeId]) -> Vec<(NodeId, bool)> {
fn visit(n: &Node, hidden: bool, locked: bool, all: &HashSet<NodeId>, 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);
+1 -1
View File
@@ -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);
}
}
+2 -1
View File
@@ -370,7 +370,8 @@ fn info(s: &mut Session, p: &Value) -> Result<Value> {
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"),
+64 -46
View File
@@ -666,7 +666,7 @@ fn release_guides(s: &mut Session, p: &Value) -> Result<Value> {
if (index.is_some() || ids.is_some()) && s.menu.guides_locked {
return Err(EngineError::Other("guides are locked".into()));
}
let (rulers, paths): (Vec<usize>, Vec<NodeId>) = if index.is_some() || ids.is_some() {
let (rulers, paths): (Vec<NodeId>, Vec<NodeId>) = 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<Value> {
let st = s.doc()?;
let all = guide_paths(&st.doc);
let sel: Vec<NodeId> = all.iter().copied().filter(|id| st.selection.contains(*id)).collect();
let rulers: Vec<usize> = if s.menu.guides_locked {
let rulers: Vec<NodeId> = 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<Value> {
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<Value> {
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<Value> {
fn clear_guides(s: &mut Session, _: &Value) -> Result<Value> {
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<Value> {
// 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<NodeId> {
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<usize> {
let all = guide_ids(d);
ids.iter().filter_map(|id| all.binary_search(id).ok()).collect()
}
fn guide_list(s: &mut Session, _: &Value) -> Result<Value> {
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<Value> {
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<usize> {
/// The id of ruler guide `index` in `ids` ([`guide_ids`]; `index` validated).
fn guide_index_in(ids: &[NodeId], v: Option<&Value>, cmd: &str) -> Result<NodeId> {
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<NodeId> {
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<NodeId> {
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<usize> {
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<NodeId> {
check_editable_guide(s, guide_index(s, v, cmd)?)
}
fn guide_select(s: &mut Session, p: &Value) -> Result<Value> {
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::<Result<Vec<_>>>()?;
let ids = guide_ids(&s.doc()?.doc);
let picked = list.iter().map(|v| guide_index_in(&ids, Some(v), "guide.select")).collect::<Result<Vec<_>>>()?;
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<Value> {
} 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<Value> {
let gone: Vec<usize> = match p.get("index") {
let gone: Vec<NodeId> = 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<Value> {
}
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<Value> {
if super::layer::locked_within(d, layer) {
return Err(EngineError::Other("the layer or group is locked".into()));
}
let moving: Vec<usize> = match (p.get("index"), p.get("indexes").and_then(Value::as_array)) {
let moving: Vec<NodeId> = 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::<Result<Vec<_>>>()?,
(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::<Result<Vec<_>>>()?
}
(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<Value> {
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<Value> {
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(())
})?;
+6 -7
View File
@@ -991,8 +991,8 @@ fn copy(s: &mut Session, p: &Value) -> Result<Value> {
.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<Value> {
// 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<Value> {
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<Value> {
}
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() }));
}
}
}
+11 -1
View File
@@ -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)
+1 -1
View File
@@ -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);
}
}
+2 -2
View File
@@ -306,10 +306,10 @@ pub(super) fn outlined_if_asked(mut doc: Document, warnings: Vec<String>, 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
}
+50 -1
View File
@@ -621,7 +621,7 @@ pub(crate) fn flatten_expanded(doc: &Document, o: &FlattenOptions) -> Result<Opt
let mut roots = vec![];
for l in doc.layers.iter().filter(|l| l.visible && !matches!(l.kind, NodeKind::Layer { template: true, .. })) {
let children = l.children().map_or(&[][..], Vec::as_slice);
roots.extend(children.iter().skip(usize::from(l.clips())).filter(|c| c.visible).map(|c| c.id));
roots.extend(children.iter().skip(usize::from(l.clips())).filter(|c| c.visible && !c.is_ruler_guide()).map(|c| c.id));
}
let plan = plan(doc, &roots, o, true)?;
if plan.flat.is_empty() && !plan.kept.iter().any(|id| doc.node(*id).is_some_and(|n| kept_work(n, o))) {
@@ -640,6 +640,8 @@ pub(super) fn apply(d: &mut Document, sel: &mut vectorcraft_doc::Selection, plan
// The groups replace their objects in one pass at the end. When an object is listed twice or
// lies inside another listed one, each group replaces its objects as it is made instead.
let one_pass = separate(d, &plan.flat.iter().flat_map(|f| f.roots.iter().copied()).collect::<Vec<_>>());
// 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<NodeId> {
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<Item = NodeId>) -> 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));
}
}
+3 -1
View File
@@ -12,7 +12,9 @@ pub(super) fn target_roots(s: &Session, p: &Value) -> Result<Vec<NodeId>> {
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 => {}
}
+19 -13
View File
@@ -243,7 +243,8 @@ pub(crate) fn accepts(d: &Document, parent: Option<NodeId>, 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<Value> {
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<Value> {
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<Value> {
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<Value> {
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<Value> {
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<Value> {
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(())
}
+7 -7
View File
@@ -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<Value> {
let others: Vec<NodeId> = d.layers.iter().filter(|l| l.id != into && l.visible && !l.is_template()).map(|l| l.id).collect();
let hidden: Vec<NodeId> = 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<Value> {
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<NodeId> = 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<NodeId> = 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<Value> {
}
let new: Vec<NodeId> = 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 {
+4 -2
View File
@@ -259,10 +259,12 @@ pub(crate) fn follow_history(st: &mut crate::DocState, was: Option<NodeId>) {
/// 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<Vec<Arc<Node>>> = 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<Vec<Arc<Node>>> =
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() {
+12 -14
View File
@@ -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<Value> {
fn apply_moves(s: &mut Session, label: &str, p: &Value, ids: &[NodeId], moves: &[(NodeId, Vec2)], guides: &[(NodeId, f64)]) -> Result<Value> {
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<Rect> {
st.selection.guides.iter().filter_map(|&i| st.doc.guides.get(i)).fold(None, |acc: Option<Rect>, g| {
st.selection.guides.iter().filter_map(|&i| st.doc.ruler_guide(i)).fold(None, |acc: Option<Rect>, 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<Value> {
/// `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<Vec<(usize, f64)>> {
fn guide_moves(s: &Session, p: &Value, r: Rect, h: Option<&str>, v: Option<&str>) -> Result<Vec<(NodeId, f64)>> {
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)
+12 -6
View File
@@ -39,7 +39,9 @@ pub(super) fn arrange(s: &mut Session, how: Arrange) -> Result<Value> {
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<Value>
}
// 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<Node>| !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<Vec<NodeId>> {
fn collect_flag(d: &Document, f: impl Fn(&Node) -> bool) -> Vec<NodeId> {
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)
}
});
+5 -11
View File
@@ -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());
+1 -1
View File
@@ -214,7 +214,7 @@ fn attributes(s: &Session, p: &Value) -> Result<AttributesInfo> {
/// 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
+4
View File
@@ -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,
+1 -1
View File
@@ -352,7 +352,7 @@ pub(crate) fn artboard_copy(s: &mut Session, p: &Value, cut: bool) -> Result<Val
for &i in indices.iter().rev() {
let gone = d.artboards.remove(i).id;
d.retire_artboard_id(gone);
d.retain_guides(sel, |_, g| g.artboard != Some(gone));
d.retain_guides(sel, |g| g.artboard != Some(gone));
}
d.remove_many(&art)?;
sel.prune(d);
+2 -4
View File
@@ -289,9 +289,7 @@ pub(crate) fn transform_anchors(s: &mut Session, label: &str, xf: vectorcraft_ge
}
if is_translation && (delta.x != 0.0 || delta.y != 0.0) {
for &i in &sel.guides {
if let Some(g) = doc.guides.get_mut(i) {
*g = g.moved(delta);
}
doc.edit_ruler_guide(i, |g| *g = g.moved(delta));
}
}
Ok(())
@@ -1232,7 +1230,7 @@ pub(crate) fn delete_anchors(s: &mut Session, p: &Value) -> Result<Value> {
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<NodeId> = whole.into_iter().filter(|id| d.node(*id).is_some_and(|n| !n.is_layer())).collect();
let whole_ids: Vec<NodeId> = targets
+3 -1
View File
@@ -1351,7 +1351,9 @@ fn split_into_grid(s: &mut Session, p: &Value) -> Result<Value> {
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)
})?;
+7 -4
View File
@@ -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<Arc<Node>>)> {
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<Value> {
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<Arc<Node>> = d.node(pe.layer).and_then(|l| l.children().cloned()).unwrap_or_default();
let art: Vec<Arc<Node>> =
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<Value> {
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<Vec<Arc<Node>>> = edit_layer.and_then(|l| d.node(l)).and_then(|l| l.children().cloned());
let live_art: Option<Vec<Arc<Node>>> =
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;
+1 -1
View File
@@ -377,7 +377,7 @@ fn placeable(l: &Node) -> bool {
fn art_of<'a>(nodes: impl Iterator<Item = &'a Arc<Node>>) -> Vec<Node> {
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 };
+4 -3
View File
@@ -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<Arc<Node>>) {
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);
+4 -2
View File
@@ -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
}
+7 -6
View File
@@ -442,15 +442,16 @@ fn key(s: &mut Session, p: &Value) -> Result<Value> {
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<Value> {
/// `{key}`, with `guide` when a guide is the key.
fn key_result(s: &Session) -> Result<Value> {
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)
+2 -1
View File
@@ -340,8 +340,9 @@ fn release(s: &mut Session, _: &Value) -> Result<Value> {
fn from_guides(s: &mut Session, _: &Value) -> Result<Value> {
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<f64> {
let mut v: Vec<f64> = d.guides.iter().filter(|g| g.vertical == vertical && g.pos > lo && g.pos < hi).map(|g| g.pos).collect();
let mut v: Vec<f64> = 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();
+2 -1
View File
@@ -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),
}
}
+11 -5
View File
@@ -248,7 +248,7 @@ pub(crate) fn leave(s: &mut Session, _: &Value) -> Result<Value> {
/// 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<Node>], 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<Vec<Arc<Node>>> = 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<Vec<Arc<Node>>> {
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<Node>]| 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()));
+1 -1
View File
@@ -137,7 +137,7 @@ fn paintable(doc: &Document) -> Vec<NodeId> {
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);
}
});
+9 -2
View File
@@ -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<Node>> = 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<u64> {
}
fn collect_kind<'a>(n: &'a Node, kind: &str, path: &mut Vec<u64>, out: &mut Vec<Hit<'a>>) {
// 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::<Vec<_>>(),
"redo": st.history.redo.iter().rev().map(|h| h.label.clone()).collect::<Vec<_>>(),
"objects": d.node_count(),
@@ -432,7 +438,8 @@ pub fn find_nodes(doc: &Document, filter: &FindFilter, limit: u64) -> (Vec<Value
ids: Vec<NodeId>,
}
fn walk(nodes: &[Arc<Node>], 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 {
+1 -1
View File
@@ -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)
}
+3 -3
View File
@@ -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
+7
View File
@@ -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())
{
+12
View File
@@ -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<usize> {
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<usize> {
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();
+2 -2
View File
@@ -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::<Vec<_>>();
let pos = |s: &Session| s.doc().unwrap().doc.ruler_guides().iter().map(|g| g.pos).collect::<Vec<_>>();
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");
+5 -5
View File
@@ -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<NodeId>)> {
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<bool> = st.doc.guides.iter().map(|g| st.doc.guide_shown(g)).collect();
let shown: Vec<bool> = 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}));
+21 -21
View File
@@ -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::<Vec<_>>();
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::<Vec<_>>(), [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::<Vec<_>>()})).unwrap();
let now = s.doc().unwrap().selection.guides.clone();
let flip: Vec<usize> = (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<usize> = (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<f64> {
s.doc().unwrap().doc.guides.iter().map(|g| g.pos).collect()
s.doc().unwrap().doc.ruler_guides().iter().map(|g| g.pos).collect()
}
#[test]
+25 -20
View File
@@ -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", &params).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<usize> {
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::<Vec<_>>()
d.ruler_guides().iter().map(|g| d.guide_span(g)).collect::<Vec<_>>()
};
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::<Vec<_>>();
let on = |s: &Session, l: NodeId| s.doc().unwrap().doc.guides_on(l).iter().map(|g| (g.vertical, g.pos)).collect::<Vec<_>>();
// 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)]));
+3 -3
View File
@@ -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::<Vec<_>>());
kept.push(s.doc().unwrap().doc.ruler_guides().iter().map(|g| g.artboard).collect::<Vec<_>>());
s.execute("edit.undo", &json!({})).unwrap();
}
got.push((cmd, s.doc().unwrap().doc.artboards.iter().map(|a| a.id).collect::<Vec<_>>(), 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
+9 -6
View File
@@ -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<Rect> = 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::<Vec<_>>(), [kept, r[0], r[1]], "above the path left alone");
let art: Vec<NodeId> = 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());
+5 -2
View File
@@ -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
+1 -1
View File
@@ -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);
+3 -3
View File
@@ -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();
}
+1 -1
View File
@@ -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();
+1 -1
View File
@@ -766,7 +766,7 @@ fn stroke_width(s: &Session, id: NodeId) -> f64 {
}
fn guide_positions(s: &Session) -> Vec<f64> {
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
+1 -1
View File
@@ -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}");
}
+3 -3
View File
@@ -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]
+2
View File
@@ -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;
+439
View File
@@ -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<NodeId> {
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<NodeId>, Vec<NodeId>) {
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, &params).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::<Vec<_>>(), 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::<Vec<_>>(), [(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::<Vec<_>>(), [(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::<Vec<_>>(), [(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::<Vec<_>>(), [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::<Vec<_>>(), [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<vectorcraft_geom::Rect>) {
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<Value = GuideOp> {
use proptest::prelude::*;
prop_oneof![
3 => (any::<bool>(), 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<Line> {
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<Line> = vec![];
let mut clip: Option<(bool, f64)> = None;
let (mut undo, mut redo): (Vec<Vec<Line>>, Vec<Vec<Line>>) = (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)?;
}
}
+130
View File
@@ -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<u8> {
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<Duration> = (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())));
}
+1 -1
View File
@@ -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());
}
}
+9
View File
@@ -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<Arc<Node>> = 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.
@@ -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())
+9 -8
View File
@@ -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<Node>], pick: &dyn Fn(NodeId) -> Option<Guide>, guides: &mut Vec<Guide>, 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<Node>], pick: &dyn Fn(NodeId) -> Option<Guide>, depth: usize) {
if depth > MAX_DEPTH {
return;
}
@@ -1439,16 +1439,17 @@ fn take_ruler_guides(nodes: &mut [Arc<Node>], pick: &dyn Fn(NodeId) -> Option<Gu
if !matches!(n.kind, NodeKind::Layer { .. }) {
continue;
}
let id = n.id;
let Some(kids) = Arc::make_mut(n).children_mut() else { continue };
let mut guides = vec![];
kids.retain(|k| match pick(k.id) {
Some(g) => {
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);
}
}
+17 -13
View File
@@ -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<Arc<Node>> {
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<Guide> {
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}");
}
}
+3 -4
View File
@@ -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<u32> = 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);
+1 -1
View File
@@ -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 }
+16 -5
View File
@@ -77,6 +77,14 @@ fn names(d: &Document) -> Vec<String> {
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<vectorcraft_doc::Guide> {
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
+9 -1
View File
@@ -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<Arc<Node>> = 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 { .. } => {}
}
}
+2
View File
@@ -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);
+69
View File
@@ -239,6 +239,9 @@ struct Body {
fn body(doc: &Document, o: &SaveOptions) -> Result<Body, FormatError> {
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<u64> = 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::<Vec<_>>(), [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.
+1
View File
@@ -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;

Some files were not shown because too many files have changed in this diff Show More