mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-12 15:52:52 +00:00
Merge main into fix/plugin-approval-ok-delay
This commit is contained in:
Generated
+1
@@ -6726,6 +6726,7 @@ name = "vectorcraft-scriptcheck"
|
||||
version = "0.24.0"
|
||||
dependencies = [
|
||||
"proptest",
|
||||
"unicode-general-category",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)]);
|
||||
|
||||
@@ -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
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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
@@ -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| {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(©), line(&d));
|
||||
d.remove(g1).unwrap();
|
||||
assert_eq!(line(&d), [(Some(g2), 9.0, false, Some(sub))]);
|
||||
assert_eq!(line(©).len(), 2);
|
||||
// A new layer with a guide, then the layers reordered and one removed.
|
||||
let top = d.add_layer(None);
|
||||
let g3 = d.add_ruler_guide(&Guide { layer: Some(top), ..Guide::new(true, 1.0) });
|
||||
assert_eq!(line(&d).len(), 2);
|
||||
d.layers.reverse();
|
||||
assert_eq!(line(&d), [(Some(g2), 9.0, false, Some(sub)), (Some(g3), 1.0, false, Some(top))]);
|
||||
d.layers.retain(|l| l.id != layer);
|
||||
assert_eq!(line(&d), [(Some(g3), 1.0, false, Some(top))]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strip_ruler_guides_leaves_a_layers_art() {
|
||||
let (mut d, a, b) = doc_with_rects();
|
||||
let layer = d.layers[0].id;
|
||||
let guide = |id| Node::new(id, NodeKind::RulerGuide { vertical: false, pos: 3.0, artboard: None });
|
||||
let sub = d.alloc_id();
|
||||
d.insert(Some(layer), usize::MAX, Node::layer(sub, "Sub", LayerColor::Preset(1))).unwrap();
|
||||
let (g1, g2) = (d.alloc_id(), d.alloc_id());
|
||||
d.insert(Some(layer), usize::MAX, guide(g1)).unwrap();
|
||||
d.insert(Some(sub), usize::MAX, guide(g2)).unwrap();
|
||||
let mut n = (*d.layers[0]).clone();
|
||||
assert!(n.holds_ruler_guides());
|
||||
n.strip_ruler_guides();
|
||||
assert!(!n.holds_ruler_guides());
|
||||
let mut left = vec![];
|
||||
n.walk(&mut |c| left.push(c.id));
|
||||
assert_eq!(left, [layer, a, b, sub]);
|
||||
// Other nodes hold none and are left as they are.
|
||||
let mut p = d.node(a).unwrap().clone();
|
||||
p.strip_ruler_guides();
|
||||
assert_eq!(&p, d.node(a).unwrap());
|
||||
}
|
||||
|
||||
/// The saved `guides` list becomes ruler guide nodes and is written back from them unchanged:
|
||||
/// guides on a layer, a sublayer, no layer and a deleted layer, with an artboard guide, keep their
|
||||
/// order, ids, names and their own lock and visibility; a saved id that another node has is
|
||||
/// replaced; and the saved form of an older entry still reads.
|
||||
#[test]
|
||||
fn saved_guides_become_nodes_and_are_saved_back_the_same() {
|
||||
let (mut d, a, _) = doc_with_rects();
|
||||
let layer = d.layers[0].id;
|
||||
let top = d.add_layer(Some("Top"));
|
||||
let sub = d.alloc_id();
|
||||
d.insert(Some(layer), usize::MAX, Node::layer(sub, "Sub", LayerColor::Preset(1))).unwrap();
|
||||
let json = serde_json::json!([
|
||||
{"vertical": true, "pos": 10.0, "layer": layer},
|
||||
{"vertical": false, "pos": 20.0, "layer": sub, "artboard": 1},
|
||||
{"vertical": true, "pos": 30.0},
|
||||
{"vertical": false, "pos": 40.0, "layer": 9999},
|
||||
{"vertical": true, "pos": 50.0, "layer": layer, "id": 500, "name": "Edge", "locked": true, "hidden": true},
|
||||
{"vertical": true, "pos": 60.0, "layer": layer, "id": a},
|
||||
]);
|
||||
d.saved_guides = serde_json::from_value(json).unwrap();
|
||||
let before = d.peek_next_id();
|
||||
assert_eq!(d.guides_into_nodes(), 6);
|
||||
assert!(d.saved_guides.is_empty());
|
||||
let guides = d.ruler_guides();
|
||||
let lines: Vec<(f64, Option<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;
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)? {
|
||||
|
||||
@@ -519,17 +519,23 @@ pub(crate) fn checked_ids_param(s: &Session, p: &Value, key: &str, command: &str
|
||||
}
|
||||
|
||||
/// The objects a command acts on: `ids` when given (every one checked, and those inside another
|
||||
/// given one dropped), else the selected objects.
|
||||
/// given one dropped), else (`ids` left out or null) the selected objects.
|
||||
pub(crate) fn checked_ids_or_selection(s: &Session, p: &Value, command: &str) -> Result<Vec<NodeId>> {
|
||||
if p.get("ids").is_some() { Ok(edit::outermost(&s.doc()?.doc, checked_ids_param(s, p, "ids", command)?)) } else { edit::selected_roots(s) }
|
||||
if p.get("ids").is_some_and(|v| !v.is_null()) {
|
||||
Ok(edit::outermost(&s.doc()?.doc, checked_ids_param(s, p, "ids", command)?))
|
||||
} else {
|
||||
edit::selected_roots(s)
|
||||
}
|
||||
}
|
||||
|
||||
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 +695,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 +749,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 })
|
||||
|
||||
@@ -806,7 +806,7 @@ fn remove_breaks(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
};
|
||||
// The characters a Type tool has selected (after Select All in threaded text, all the text of
|
||||
// every frame of the story), else all the text of the selected type.
|
||||
let editing = if p.get("ids").is_some() { None } else { editing_range(s)?.filter(|(_, a, b)| a != b) };
|
||||
let editing = if p.get("ids").is_some_and(|v| !v.is_null()) { None } else { editing_range(s)?.filter(|(_, a, b)| a != b) };
|
||||
let story = if editing.is_some() { editing_story(s) } else { vec![] };
|
||||
let d = &s.doc()?.doc;
|
||||
// Each type object with the breaks that go from it.
|
||||
|
||||
@@ -288,6 +288,18 @@ fn remove_breaks_in_the_characters_a_type_tool_selected() {
|
||||
assert_eq!(obj(&s, id).plain_text(), "a b\nc");
|
||||
}
|
||||
|
||||
/// `ids: null` is `ids` left out: the characters the Type tool selected lose their breaks, and
|
||||
/// the rest of the text keeps them.
|
||||
#[test]
|
||||
fn remove_breaks_with_ids_null_in_the_characters_a_type_tool_selected() {
|
||||
let mut s = session();
|
||||
let id = text(&mut s, json!({"x": 40, "y": 60, "text": "a\nb\nc", "size": 12}));
|
||||
select(&mut s, &[id]);
|
||||
edit_text(&mut s, id, 0, 2);
|
||||
s.execute("addon.cleanup.removeBreaks", &json!({"kind": "paragraph", "ids": null})).unwrap();
|
||||
assert_eq!(obj(&s, id).plain_text(), "a b\nc");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn split_at_the_cursor_and_around_a_selection() {
|
||||
for (anchor, caret, want) in [(6, 6, &["Split", "here please"][..]), (6, 10, &["Split", "here", "please"][..])] {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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()))),
|
||||
|
||||
@@ -31,7 +31,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Edit Parametric Shape",
|
||||
[],
|
||||
None,
|
||||
"{id, kind?, center?, width?, height?, rotation?, count?, a?, b?, radius?} change a parametric shape's numbers (as addon.pathtools.shape.create takes them; a new kind takes its own defaults for the values not given), as one undo step; with none, or the same ones, nothing changes → {id, shape (its numbers now)}",
|
||||
"{id, kind?, center?, width?, height?, rotation?: degrees clockwise (0: upright), count?, a?, b?, radius?} change a parametric shape's numbers (as addon.pathtools.shape.create takes them; a new kind takes its own defaults for the values not given), as one undo step; with none, or the same ones, nothing changes → {id, shape (its numbers now)}",
|
||||
has_doc,
|
||||
set
|
||||
),
|
||||
|
||||
@@ -26,7 +26,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Rectangle",
|
||||
[],
|
||||
None,
|
||||
"{x, y, width, height, radius?: pt, radii?: [tl,tr,br,bl], startAngle?, endAngle?, arcMode?: whole|pie|chord|arc, rotation?: deg} → {id}",
|
||||
"{x, y, width, height, radius?: pt, radii?: [tl,tr,br,bl], startAngle?, endAngle?: degrees (the part of the outline kept, clockwise from its first anchor, the top-left corner; each side an equal share of 360; default 0 and 360), arcMode?: whole|pie|chord|arc, rotation?: deg clockwise about the centre (0: upright)} → {id}",
|
||||
has_doc,
|
||||
rectangle
|
||||
),
|
||||
@@ -35,7 +35,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Ellipse",
|
||||
[],
|
||||
None,
|
||||
"{x, y, width, height, startAngle?, endAngle?, arcMode?: whole|pie|chord|arc, rotation?: deg} → {id}",
|
||||
"{x, y, width, height, startAngle?, endAngle?: degrees (counter-clockwise from 3 o'clock, as object.setLiveShape's pieStart and pieEnd; default 0 and 360), arcMode?: whole|pie|chord|arc, rotation?: deg clockwise about the centre (0: upright)} → {id}",
|
||||
has_doc,
|
||||
ellipse
|
||||
),
|
||||
@@ -44,7 +44,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Polygon",
|
||||
[],
|
||||
None,
|
||||
"{cx, cy, radius, sides=6, rounded=0, randomized=0, startAngle?, endAngle?, arcMode?: whole|pie|chord|arc, rotation?: deg} → {id}",
|
||||
"{cx, cy, radius, sides=6, rounded=0, randomized=0, startAngle?, endAngle?: degrees (the part of the outline kept, clockwise from its first anchor, the top corner; each side an equal share of 360; default 0 and 360), arcMode?: whole|pie|chord|arc, rotation?: deg clockwise (0: a corner straight up)} → {id}",
|
||||
has_doc,
|
||||
polygon
|
||||
),
|
||||
@@ -53,7 +53,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Star",
|
||||
[],
|
||||
None,
|
||||
"{cx, cy, radius1, radius2?, points=5, rounded=0, randomized=0, startAngle?, endAngle?, arcMode?: whole|pie|chord|arc, rotation?: deg} → {id}",
|
||||
"{cx, cy, radius1, radius2?, points=5, rounded=0, randomized=0, startAngle?, endAngle?: degrees (the part of the outline kept, clockwise from its first anchor, the top point; each side an equal share of 360; default 0 and 360), arcMode?: whole|pie|chord|arc, rotation?: deg clockwise (0: a point straight up)} → {id}",
|
||||
has_doc,
|
||||
star
|
||||
),
|
||||
@@ -71,7 +71,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"3D Box",
|
||||
[],
|
||||
None,
|
||||
"{x, y, width, height, angleX=30, angleY=30, angleZ=0, parallelX=false, parallelY=true, parallelZ=false} → {id}",
|
||||
"{x, y, width, height, angleX=30 (degrees about the level axis: positive tips the top away from you), angleY=30 (degrees about the upright axis: positive turns the right side away from you), angleZ=0 (degrees clockwise in the page), parallelX=false, parallelY=true, parallelZ=false} → {id}",
|
||||
has_doc,
|
||||
box3d
|
||||
),
|
||||
@@ -80,7 +80,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Configure Shape",
|
||||
[],
|
||||
None,
|
||||
"{ids?, radius?, radii?: [tl,tr,br,bl], startAngle?, endAngle?, arcMode?: whole|pie|chord|arc, sides?, isStar?, spokeRatio?, rounded?, randomized?, turns?, divergence?, innerRadius?, angleX?, angleY?, angleZ?, parallelX?, parallelY?, parallelZ?, reset?: bool} → {updated}",
|
||||
"{ids?, radius?, radii?: [tl,tr,br,bl], startAngle?, endAngle?: degrees (as addon.shapes.rectangle, ellipse, polygon and star take them: an ellipse's counter-clockwise from 3 o'clock, the others' clockwise along the outline), arcMode?: whole|pie|chord|arc, sides?, isStar?, spokeRatio?, rounded?, randomized?, turns?, divergence?, innerRadius?, angleX?, angleY?: degrees (as addon.shapes.box3d: positive tips the top, or turns the right side, away from you), angleZ?: degrees clockwise in the page, parallelX?, parallelY?, parallelZ?, reset?: bool} → {updated}",
|
||||
has_selection,
|
||||
configure
|
||||
),
|
||||
|
||||
@@ -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"));
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -172,6 +172,7 @@ pub const BUILTIN: &[BuiltinBrushLibrary] = &[
|
||||
BuiltinBrushLibrary { id: "pattern-borders", name: "Pattern Borders", make: pattern_borders },
|
||||
BuiltinBrushLibrary { id: "scatter-shapes", name: "Scatter Shapes", make: scatter_shapes },
|
||||
BuiltinBrushLibrary { id: "game-trails", name: "Game Trails", make: game_trails },
|
||||
BuiltinBrushLibrary { id: "energy-beams", name: "Energy Beams", make: energy_beams },
|
||||
];
|
||||
|
||||
/// Built-in library `id`, made once.
|
||||
@@ -416,6 +417,35 @@ fn game_trails() -> BrushLibrary {
|
||||
built("Game Trails", Document::new(100.0, 100.0), brushes)
|
||||
}
|
||||
|
||||
/// Energy Beams: art brushes for laser, plasma and charge strokes (original game VFX art).
|
||||
fn energy_beams() -> BrushLibrary {
|
||||
let laser = {
|
||||
let n = 32;
|
||||
let half = |x: f64| 1.6 * (1.0 - (x / 100.0 - 0.5).abs() * 1.6).max(0.12);
|
||||
let xs = |i: usize| 100.0 * i as f64 / n as f64;
|
||||
let mut pts: Vec<Point> = (0..=n).map(xs).map(|x| Point::new(x, -half(x))).collect();
|
||||
pts.extend((0..n).rev().map(xs).map(|x| Point::new(x, half(x))));
|
||||
PathData::single(SubPath::polyline(&pts, true))
|
||||
};
|
||||
let plasma = band(100.0, 2.2, 48, |x| 4.0 * (x / 100.0 * std::f64::consts::PI).sin());
|
||||
let charge = band(100.0, 1.6, 56, |x| 3.5 * (x / 100.0 * std::f64::consts::TAU).sin());
|
||||
let spark = group(vec![
|
||||
black(shapes::rounded_rectangle(Rect::new(0.0, -1.2, 10.0, 1.2), 0.8)),
|
||||
black(shapes::rounded_rectangle(Rect::new(18.0, -0.9, 28.0, 0.9), 0.7)),
|
||||
black(shapes::rounded_rectangle(Rect::new(36.0, -1.4, 48.0, 1.4), 0.9)),
|
||||
black(shapes::rounded_rectangle(Rect::new(56.0, -0.8, 66.0, 0.8), 0.6)),
|
||||
black(shapes::rounded_rectangle(Rect::new(74.0, -1.1, 86.0, 1.1), 0.8)),
|
||||
black(shapes::rounded_rectangle(Rect::new(92.0, -0.7, 100.0, 0.7), 0.5)),
|
||||
]);
|
||||
let brushes = vec![
|
||||
art("Laser Bolt", black(laser), Colorization::Tints),
|
||||
art("Plasma Arc", black(plasma), Colorization::Tints),
|
||||
art("Charge Wave", black(charge), Colorization::Tints),
|
||||
art("Spark Trail", spark, Colorization::Tints),
|
||||
];
|
||||
built("Energy Beams", Document::new(100.0, 100.0), brushes)
|
||||
}
|
||||
|
||||
// ---------- commands ----------
|
||||
|
||||
fn builtin_info(b: &BuiltinBrushLibrary) -> LibraryInfo {
|
||||
|
||||
@@ -144,7 +144,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Brush Options…",
|
||||
[],
|
||||
None,
|
||||
"{name, params?: {…fields to change (brush.get shows them; values outside their ranges are clamped). calligraphic: angle −180..180°, roundness 0..100%, size 0..1296 pt, variation: [angle°, roundness%, size pt], modes: [fixed|random|pressure|tilt|bearing|rotation for angle, roundness, size] (pressure goes from value − variation at the lightest pen pressure to value + variation at the heaviest; tilt from value − variation with the pen lying flat to value + variation with it upright; bearing and rotation add variation × the pen's bearing (which way it leans, counter-clockwise from leaning right) or its barrel's counter-clockwise rotation over 180°; the angle turns on past ±180°). scatter: size [a, b] 1..10000%, spacing [a, b] 1..10000%, scatter [a, b] −1000..1000%, rotation [a, b] −180..180°, modes: [fixed|random|pressure|tilt|bearing|rotation for size, spacing, scatter, rotation] (fixed: a; random: between a and b; pressure: a at the lightest pen pressure to b at the heaviest; tilt: a with the pen lying flat to b upright; bearing and rotation: a at −180° to b at 180°), rotation_relative_to_path. art: width 1..1000%, width_mode: null (fixed)|pressure, width_max 1..1000% (pressure: width at the lightest pen pressure to width_max at the heaviest, half way on a stroke drawn without pressure), scale: {mode: proportional|stretch|betweenGuides, start, end: 0..1 of the art length}, direction: leftToRight|rightToLeft|topToBottom|bottomToTop, flip_along, flip_across, overlap. pattern: scale 1..10000%, spacing 0..10000%, fit: stretch|addSpace|approximate, flip_along, flip_across, outer_corner and inner_corner: corner tile art (drawn as the corner that joins a side tile on its right to one below it) or null, outer_source and inner_source: what that kind of corner draws without its art: none|autoCentered|autoBetween|autoSliced|autoOverlap (a tile made from the side tile), or null (the other kind of corner's art); side_tile, outer_corner_tile, inner_corner_tile, start_tile, end_tile: a document pattern name, original, or none (side refuses none); corner tiles also take autoCentered|autoBetween|autoSliced|autoOverlap. Each tile also accepts {pattern: name} to select a swatch whose name is a reserved choice. scatter, art and pattern: colorization: {method: none|tints|tintsAndShades|hueShift, key: colour (hueShift: the art colour that becomes the stroke colour; without a key: the solid color the art's fills and strokes use most, black when they use none)}; colorization and the art brush's scale change only the fields given}, newName? (trimmed; blank: the name is kept; a new bristle brush name has at most 31 characters, and a longer name the brush has is kept)} edit a brush definition (strokes using it update) → {name}",
|
||||
"{name, params?: {…fields to change (brush.get shows them; values outside their ranges are clamped). calligraphic: angle −180..180° (counter-clockwise from level), roundness 0..100%, size 0..1296 pt, variation: [angle°, roundness%, size pt], modes: [fixed|random|pressure|tilt|bearing|rotation for angle, roundness, size] (pressure goes from value − variation at the lightest pen pressure to value + variation at the heaviest; tilt from value − variation with the pen lying flat to value + variation with it upright; bearing and rotation add variation × the pen's bearing (which way it leans, counter-clockwise from leaning right) or its barrel's counter-clockwise rotation over 180°; the angle turns on past ±180°). scatter: size [a, b] 1..10000%, spacing [a, b] 1..10000%, scatter [a, b] −1000..1000%, rotation [a, b] −180..180° (counter-clockwise), modes: [fixed|random|pressure|tilt|bearing|rotation for size, spacing, scatter, rotation] (fixed: a; random: between a and b; pressure: a at the lightest pen pressure to b at the heaviest; tilt: a with the pen lying flat to b upright; bearing and rotation: a at −180° to b at 180°), rotation_relative_to_path. art: width 1..1000%, width_mode: null (fixed)|pressure, width_max 1..1000% (pressure: width at the lightest pen pressure to width_max at the heaviest, half way on a stroke drawn without pressure), scale: {mode: proportional|stretch|betweenGuides, start, end: 0..1 of the art length}, direction: leftToRight|rightToLeft|topToBottom|bottomToTop, flip_along, flip_across, overlap. pattern: scale 1..10000%, spacing 0..10000%, fit: stretch|addSpace|approximate, flip_along, flip_across, outer_corner and inner_corner: corner tile art (drawn as the corner that joins a side tile on its right to one below it) or null, outer_source and inner_source: what that kind of corner draws without its art: none|autoCentered|autoBetween|autoSliced|autoOverlap (a tile made from the side tile), or null (the other kind of corner's art); side_tile, outer_corner_tile, inner_corner_tile, start_tile, end_tile: a document pattern name, original, or none (side refuses none); corner tiles also take autoCentered|autoBetween|autoSliced|autoOverlap. Each tile also accepts {pattern: name} to select a swatch whose name is a reserved choice. scatter, art and pattern: colorization: {method: none|tints|tintsAndShades|hueShift, key: colour (hueShift: the art colour that becomes the stroke colour; without a key: the solid color the art's fills and strokes use most, black when they use none)}; colorization and the art brush's scale change only the fields given}, newName? (trimmed; blank: the name is kept; a new bristle brush name has at most 31 characters, and a longer name the brush has is kept)} edit a brush definition (strokes using it update) → {name}",
|
||||
has_doc,
|
||||
brush_options
|
||||
),
|
||||
|
||||
@@ -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) };
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,19 +17,43 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Rectangle",
|
||||
[],
|
||||
None,
|
||||
"{x, y, width, height, radius?: pt, rotation?: deg (about centre)} → {id}",
|
||||
"{x, y, width, height, radius?: pt, rotation?: deg clockwise about the centre (0: upright)} → {id}",
|
||||
has_doc,
|
||||
rectangle
|
||||
),
|
||||
cmd!("shape.ellipse", "Ellipse", [], None, "{x, y, width, height, rotation?: deg (about centre)} → {id}", has_doc, ellipse),
|
||||
cmd!("shape.polygon", "Polygon", [], None, "{cx, cy, radius, sides=6, rotation?: deg} → {id}", has_doc, polygon),
|
||||
cmd!("shape.star", "Star", [], None, "{cx, cy, radius1, radius2, points=5, rotation?: deg} → {id}", has_doc, star),
|
||||
cmd!(
|
||||
"shape.ellipse",
|
||||
"Ellipse",
|
||||
[],
|
||||
None,
|
||||
"{x, y, width, height, rotation?: deg clockwise about the centre (0: upright)} → {id}",
|
||||
has_doc,
|
||||
ellipse
|
||||
),
|
||||
cmd!(
|
||||
"shape.polygon",
|
||||
"Polygon",
|
||||
[],
|
||||
None,
|
||||
"{cx, cy, radius, sides=6, rotation?: deg clockwise (0: a corner straight up)} → {id}",
|
||||
has_doc,
|
||||
polygon
|
||||
),
|
||||
cmd!(
|
||||
"shape.star",
|
||||
"Star",
|
||||
[],
|
||||
None,
|
||||
"{cx, cy, radius1, radius2, points=5, rotation?: deg clockwise (0: a point straight up)} → {id}",
|
||||
has_doc,
|
||||
star
|
||||
),
|
||||
cmd!(
|
||||
"shape.flare",
|
||||
"Flare",
|
||||
[],
|
||||
None,
|
||||
"{cx, cy, diameter=100, opacity=50 (%), brightness=30 (%), growth=20 (%), fuzziness=50 (%), rays=15, longest=300 (%), rayFuzziness=100 (%), rotation=0 (deg, turns the rays clockwise), x2?, y2? (ring end; else pathLength at direction), pathLength=300, rings=10, largest=50 (%), direction=45 (deg), seed?} → {id}",
|
||||
"{cx, cy, diameter=100, opacity=50 (%), brightness=30 (%), growth=20 (%), fuzziness=50 (%), rays=15, longest=300 (%), rayFuzziness=100 (%), rotation=0 (deg, turns the rays clockwise), x2?, y2? (ring end; else pathLength at direction), pathLength=300, rings=10, largest=50 (%), direction=45 (deg counter-clockwise from 3 o'clock: where the rings run from the centre), seed?} → {id}",
|
||||
has_doc,
|
||||
flare
|
||||
),
|
||||
@@ -49,7 +73,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Rectangular Grid",
|
||||
[],
|
||||
None,
|
||||
"{x, y, width, height, rows=5, columns=5, rowSkew=0, columnSkew=0 (−500…500 %: + crowds the horizontal dividers toward the top, the vertical ones toward the right), frame=true (the outside as one rectangle, else its four sides), fill=false (the current fill, else none), rotation?: deg (about centre)} → {id}",
|
||||
"{x, y, width, height, rows=5, columns=5, rowSkew=0, columnSkew=0 (−500…500 %: + crowds the horizontal dividers toward the top, the vertical ones toward the right), frame=true (the outside as one rectangle, else its four sides), fill=false (the current fill, else none), rotation?: deg clockwise about the centre (0: upright)} → {id}",
|
||||
has_doc,
|
||||
rect_grid
|
||||
),
|
||||
@@ -58,7 +82,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Polar Grid",
|
||||
[],
|
||||
None,
|
||||
"{x, y, width, height, concentric=5, radial=5, concentricSkew=0, radialSkew=0 (−500…500 %: + crowds the ellipses toward the outside, the radial dividers clockwise of the top one), compound=false (the ellipses pair up into compound-path rings), fill=false (the current fill, else none), rotation?: deg (about centre)} → {id}",
|
||||
"{x, y, width, height, concentric=5, radial=5, concentricSkew=0, radialSkew=0 (−500…500 %: + crowds the ellipses toward the outside, the radial dividers clockwise of the top one), compound=false (the ellipses pair up into compound-path rings), fill=false (the current fill, else none), rotation?: deg clockwise about the centre (0: upright)} → {id}",
|
||||
has_doc,
|
||||
polar_grid
|
||||
),
|
||||
@@ -85,7 +109,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Create Text",
|
||||
[],
|
||||
None,
|
||||
"{x, y, text, rotation?: deg (about x, y), vertical?: bool = false, size?: pt, font?: family, style? (default Regular, or the family's closest style when it has no Regular), color? (#rrggbbaa and #rgba are refused), keepModel?: bool (as paint.setFill), area?: {width, height, fit?: none|autoHeight|shrinkText, fitMinPercent?}, placeholder?: bool (fill it with placeholder text instead, as the Type tools do with prefs placeholderText), justify?: \"auto\" (default: the start of each paragraph's direction)|\"left\"|\"center\"|\"right\"|\"justifyAll\" (point type: aligned on x, so \"center\" centres each line on the point and \"right\" ends it there; text.setStyle changes it later), leadingModel?: \"romanBaseline\"|\"emBoxTop\", charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\" (default: emBoxTop and emBoxCenter while the interface is in Japanese, else romanBaseline)} → {id, bounds: {x, y, width, height} (the new type's box as document.inspect reports it, after justify), overflow?: [id] (area type whose text doesn't fit its frame: the lines past it aren't drawn; left out when it fits), warning?: text (the font or style given isn't available: the type is drawn in the fallback font or the family's closest style)}; new area type gets Auto Size (fit autoHeight) when the autoSizeAreaType preference is on",
|
||||
"{x, y, text, rotation?: deg clockwise about x, y (0: level), vertical?: bool = false, size?: pt, font?: family, style? (default Regular, or the family's closest style when it has no Regular), color? (#rrggbbaa and #rgba are refused), keepModel?: bool (as paint.setFill), area?: {width, height, fit?: none|autoHeight|shrinkText, fitMinPercent?}, placeholder?: bool (fill it with placeholder text instead, as the Type tools do with prefs placeholderText), justify?: \"auto\" (default: the start of each paragraph's direction)|\"left\"|\"center\"|\"right\"|\"justifyAll\" (point type: aligned on x, so \"center\" centres each line on the point and \"right\" ends it there; text.setStyle changes it later), leadingModel?: \"romanBaseline\"|\"emBoxTop\", charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\" (default: emBoxTop and emBoxCenter while the interface is in Japanese, else romanBaseline)} → {id, bounds: {x, y, width, height} (the new type's box as document.inspect reports it, after justify), overflow?: [id] (area type whose text doesn't fit its frame: the lines past it aren't drawn; left out when it fits), warning?: text (the font or style given isn't available: the type is drawn in the fallback font or the family's closest style)}; new area type gets Auto Size (fit autoHeight) when the autoSizeAreaType preference is on",
|
||||
has_doc,
|
||||
text_create
|
||||
),
|
||||
|
||||
@@ -71,7 +71,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Liquify",
|
||||
[],
|
||||
None,
|
||||
"{tool: warp|twirl|pucker|bloat|scallop|crystallize|wrinkle, points: [[x,y,pressure?]…] (the brush stroke; pen pressure 0..1, default 1), diameter?|width?, height? (pt, default 100), angle?, intensity? (0..1 or %), usePressure?: bool (each point's pressure is the intensity there), detail? (1..10), simplify?: 0..100, simplifyOn?: bool (default true; both for warp, twirl, pucker, bloat), rate? (twirl °), complexity? (0..15), horizontal?, vertical? (wrinkle, 0..1 or %), affectAnchors?, affectIn?, affectOut?: bool (scallop, crystallize, wrinkle; default true), ids?|id? (default: selection, else every path the brush touches)}",
|
||||
"{tool: warp|twirl|pucker|bloat|scallop|crystallize|wrinkle, points: [[x,y,pressure?]…] (the brush stroke; pen pressure 0..1, default 1), diameter?|width?, height? (pt, default 100), angle? (deg clockwise: the brush's width axis from level), intensity? (0..1 or %), usePressure?: bool (each point's pressure is the intensity there), detail? (1..10), simplify?: 0..100, simplifyOn?: bool (default true; both for warp, twirl, pucker, bloat), rate? (twirl °, positive turns clockwise), complexity? (0..15), horizontal?, vertical? (wrinkle, 0..1 or %), affectAnchors?, affectIn?, affectOut?: bool (scallop, crystallize, wrinkle; default true), ids?|id? (default: selection, else every path the brush touches)}",
|
||||
has_doc,
|
||||
liquify
|
||||
),
|
||||
@@ -80,7 +80,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Puppet Warp",
|
||||
[],
|
||||
None,
|
||||
"{id?|ids? (default: selection), pins: [[x,y]…] (current pin positions), moved: [[x,y]…] (targets, same length), angles?: [deg|null…] (same length: the turn the art takes around each pin, as Alt-dragging around a pin does; null leaves it free), expand?: pt, rest?: bool, auto?: bool (as object.puppetWarp.pins reports; ignored)} as-rigid-as-possible mesh warp of anchors and handles. rest: true = the Puppet Warp tool's pins: `pins` are points on the shape the pins started from (object.puppetWarp.pins), each must be on its mesh, the warp replaces the previous one instead of adding to it, and the pins are kept with the document for Undo (an empty list removes them all, leaving the art as it is)",
|
||||
"{id?|ids? (default: selection), pins: [[x,y]…] (current pin positions), moved: [[x,y]…] (targets, same length), angles?: [deg|null…] (same length: the turn the art takes around each pin, clockwise, as Alt-dragging around a pin does; null leaves it free), expand?: pt, rest?: bool, auto?: bool (as object.puppetWarp.pins reports; ignored)} as-rigid-as-possible mesh warp of anchors and handles. rest: true = the Puppet Warp tool's pins: `pins` are points on the shape the pins started from (object.puppetWarp.pins), each must be on its mesh, the warp replaces the previous one instead of adding to it, and the pins are kept with the document for Undo (an empty list removes them all, leaving the art as it is)",
|
||||
has_doc,
|
||||
puppet_warp
|
||||
),
|
||||
@@ -89,7 +89,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Puppet Warp Pins",
|
||||
[],
|
||||
None,
|
||||
"{id?|ids? (default: selection), expand?: pt} → {ids, pins, moved, angles, expand, rest: true, auto} the Puppet Warp pins on the art: pins = where each sits on the shape they started from, moved = where it is now, angles = the turn it holds (deg, null = free); auto: none placed yet (the tool's automatic pins: the centre and the end of each limb). Change moved/angles (or add/remove pins) and pass it back to object.puppetWarp",
|
||||
"{id?|ids? (default: selection), expand?: pt} → {ids, pins, moved, angles, expand, rest: true, auto} the Puppet Warp pins on the art: pins = where each sits on the shape they started from, moved = where it is now, angles = the turn it holds (deg clockwise, null = free); auto: none placed yet (the tool's automatic pins: the centre and the end of each limb). Change moved/angles (or add/remove pins) and pass it back to object.puppetWarp",
|
||||
has_doc,
|
||||
puppet_pins
|
||||
),
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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(())
|
||||
})?;
|
||||
|
||||
@@ -92,7 +92,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Eraser",
|
||||
[],
|
||||
None,
|
||||
"{points: [[x,y]…] or [[x,y,pressure]…] (pen pressure 0..1), size?: pt (10), sizeMode?: \"fixed\" (default) | \"pressure\" (Eraser Tool Options' Size set to Pressure: the tip is size − sizeVariation across where the pen presses lightest and size + sizeVariation where it presses hardest, as path.blob), sizeVariation?: pt (0), angle?: ° (0), roundness?: % (100, kept within 1–100)} erase the stroke of an elliptical tip from paths (selected, or all) → {ids}. The tip is `size` across its long axis, which runs at `angle` counter-clockwise from horizontal; its short axis is `roundness` percent of the long one",
|
||||
"{points: [[x,y]…] or [[x,y,pressure]…] (pen pressure 0..1), size?: pt (10), sizeMode?: \"fixed\" (default) | \"pressure\" (Eraser Tool Options' Size set to Pressure: the tip is size − sizeVariation across where the pen presses lightest and size + sizeVariation where it presses hardest, as path.blob), sizeVariation?: pt (0), angle?: ° (0: the tip's long axis counter-clockwise from level), roundness?: % (100, kept within 1–100)} erase the stroke of an elliptical tip from paths (selected, or all) → {ids}. The tip is `size` across its long axis, which runs at `angle` counter-clockwise from horizontal; its short axis is `roundness` percent of the long one",
|
||||
has_doc,
|
||||
erase_region
|
||||
),
|
||||
|
||||
@@ -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() }));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 => {}
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Gradient",
|
||||
["Window", "Gradient"],
|
||||
None,
|
||||
"{stroke?: bool (default: the targeted item's kind, else the active proxy), kind?: linear|radial|freeform (freeform places points on each object, coloured along the stops), mode?: points|lines (freeform: how the Gradient tool adds points), method?: classic|perceptual (how the colours between the stops mix: in display RGB, or perceptually (OKLab) so the steps look even; SVG exports draw a Perceptual gradient as an image, and PDF exports do too unless it is linear or radial, has spot color stops, every other stop is paper white or written in CMYK or gray (an export that converts colors to a CMYK destination profile writes RGB and Lab stops in CMYK, and so does a CMYK document unless the export converts colors to an RGB destination profile), it is not a dithered fill and its curve does not have too many stops: then PDF writes tints of one spot ink as a Separation shading in that ink, and spot inks mixed with other spot, CMYK or gray inks as a DeviceN shading; freeform gradients mix their own way), stops?: [{offset 0..1, color? (needed without swatch; #rrggbbaa and #rgba are refused), opacity? 0..1 (or 0..100), midpoint? 0.13..0.87, swatch?: colour swatch name (a global or spot colour or tint swatch links the stop, so swatch edits recolour it and a spot stop prints on its plate; a process colour just gives its colour), tint?: 0..100 (% of the linked swatch; default 100, or a tint swatch's own)}] (at least 2; a freeform gradient's points are recoloured along them), keepModel?: bool (as paint.setFill; a stop color the gradient already has keeps its model), angle?: deg, aspect?: %, reverse?: bool, dither?: bool (fills only: a linear or radial gradient fill is drawn with fine noise that hides banding, and as pixels in SVG and PDF exports), item?: fill/stroke item index|null (omitted: the Appearance panel's active item when it is of the edited kind), ids?|id?, strokeMode?: within|along|across (strokes only: the gradient lies on the page and shows through the stroke, runs from the start of each subpath to its end, or runs from the stroke's left edge to its right all along it; with nothing selected, for the next object drawn; type characters' own strokes always paint within), target?: \"object\"|\"contents\"} edit the gradient in place (keeps its placement); solid/none paints become the default gradient",
|
||||
"{stroke?: bool (default: the targeted item's kind, else the active proxy), kind?: linear|radial|freeform (freeform places points on each object, coloured along the stops), mode?: points|lines (freeform: how the Gradient tool adds points), method?: classic|perceptual (how the colours between the stops mix: in display RGB, or perceptually (OKLab) so the steps look even; SVG exports draw a Perceptual gradient as an image, and PDF exports do too unless it is linear or radial, has spot color stops, every other stop is paper white or written in CMYK or gray (an export that converts colors to a CMYK destination profile writes RGB and Lab stops in CMYK, and so does a CMYK document unless the export converts colors to an RGB destination profile), it is not a dithered fill and its curve does not have too many stops: then PDF writes tints of one spot ink as a Separation shading in that ink, and spot inks mixed with other spot, CMYK or gray inks as a DeviceN shading; freeform gradients mix their own way), stops?: [{offset 0..1, color? (needed without swatch; #rrggbbaa and #rgba are refused), opacity? 0..1 (or 0..100), midpoint? 0.13..0.87, swatch?: colour swatch name (a global or spot colour or tint swatch links the stop, so swatch edits recolour it and a spot stop prints on its plate; a process colour just gives its colour), tint?: 0..100 (% of the linked swatch; default 100, or a tint swatch's own)}] (at least 2; a freeform gradient's points are recoloured along them), keepModel?: bool (as paint.setFill; a stop color the gradient already has keeps its model), angle?: deg counter-clockwise (0: the gradient runs left to right), aspect?: %, reverse?: bool, dither?: bool (fills only: a linear or radial gradient fill is drawn with fine noise that hides banding, and as pixels in SVG and PDF exports), item?: fill/stroke item index|null (omitted: the Appearance panel's active item when it is of the edited kind), ids?|id?, strokeMode?: within|along|across (strokes only: the gradient lies on the page and shows through the stroke, runs from the start of each subpath to its end, or runs from the stroke's left edge to its right all along it; with nothing selected, for the next object drawn; type characters' own strokes always paint within), target?: \"object\"|\"contents\"} edit the gradient in place (keeps its placement); solid/none paints become the default gradient",
|
||||
has_doc,
|
||||
edit_gradient
|
||||
),
|
||||
|
||||
@@ -19,7 +19,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Vector Halftone…",
|
||||
["Object"],
|
||||
None,
|
||||
"{shape?: \"circle\"|\"ellipse\"|\"square\"|\"diamond\"|\"line\" (circle), frequency?: lines per inch 1–300 (20), angle?: deg (45; CMYK: the black screen's, cyan −30°, magenta +30°, yellow −45° from it), mode?: \"mono\"|\"cmyk\" (mono), color?: mono dot colour (\"#000000\"; #rrggbbaa and #rgba are refused), invert?: bool (dots for light tones), clip?: true (clip the dots to the art's outline), keepOriginal?: false} the selection as vector halftone dots sized by its tone, a group of one path per screen (CMYK screens multiply), as one undo step → {id, dots, warning? (an image in the art couldn't be decoded, as object.rasterize says)}",
|
||||
"{shape?: \"circle\"|\"ellipse\"|\"square\"|\"diamond\"|\"line\" (circle), frequency?: lines per inch 1–300 (20), angle?: deg clockwise (45; 0: level rows of dots; CMYK: the black screen's, cyan −30°, magenta +30°, yellow −45° from it), mode?: \"mono\"|\"cmyk\" (mono), color?: mono dot colour (\"#000000\"; #rrggbbaa and #rgba are refused), invert?: bool (dots for light tones), clip?: true (clip the dots to the art's outline), keepOriginal?: false} the selection as vector halftone dots sized by its tone, a group of one path per screen (CMYK screens multiply), as one undo step → {id, dots, warning? (an image in the art couldn't be decoded, as object.rasterize says)}",
|
||||
has_selection,
|
||||
halftone
|
||||
)]
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
});
|
||||
|
||||
@@ -61,7 +61,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Shear…",
|
||||
["Object", "Transform"],
|
||||
None,
|
||||
"{ids?: [id…] (selected first; default: the selection), angle: deg, axis?: \"horizontal\"|\"vertical\", origin?, copy?, objects?: bool (Transform Objects, default true; false: the art stays and, with patterns, only its pattern tiles transform), patterns?: bool (Transform Patterns: pattern fills and strokes transform with the art; default: prefs transformPatternTiles), typeAreas?: bool (area type: the frame is reshaped and the text reflows at its size)}. With anchors selected with Direct Selection (and no copy), only those anchors and their handles transform, about the centre of their own box.",
|
||||
"{ids?: [id…] (selected first; default: the selection), angle: deg (positive turns the vertical edges clockwise with the horizontal axis, the top to the right; the horizontal edges clockwise with the vertical axis, the right side down), axis?: \"horizontal\"|\"vertical\", origin?, copy?, objects?: bool (Transform Objects, default true; false: the art stays and, with patterns, only its pattern tiles transform), patterns?: bool (Transform Patterns: pattern fills and strokes transform with the art; default: prefs transformPatternTiles), typeAreas?: bool (area type: the frame is reshaped and the text reflows at its size)}. With anchors selected with Direct Selection (and no copy), only those anchors and their handles transform, about the centre of their own box.",
|
||||
has_selection,
|
||||
shear
|
||||
),
|
||||
@@ -297,7 +297,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Live Shape Properties",
|
||||
[],
|
||||
None,
|
||||
"{id?, ids?, radius?: pt (or % of each corner's maximum when `relativeRadii` is true), kind?: \"round\"|\"invertedRound\"|\"chamfer\", relativeRadii?: bool (Relative rounding, as in the reference app: store radii as fractions of each corner's maximum so they scale with the shape), corners?: [i…], items?: [{id, corners?: [i…]}] (several objects, each with its own corners, in place of ids and corners: the canvas's corner widgets across a selection), sides?: n, polygonAngle?: degrees (counterclockwise from its first vertex straight up), polygonRadius?: pt (centre to vertex), sideLength?: pt (sets the radius), makeSidesEqual?: true (a polygon scaled unevenly: drops the uneven scale and shear, keeping its centre, angle and mean radius), pieStart?, pieEnd?: degrees (an ellipse's pie, counterclockwise from 3 o'clock; 0 to 360 is the whole ellipse), invertPie?: true (swaps them: the other part of the ellipse)} (Live Corners on any path: radius and kind set its corners (anchors that aren't smooth, where two sides, straight or curved (#1144), meet at an angle): `corners` (anchor indices of the path with its corners uncut, counting every subpath's anchors in order: a rectangle's 0 top-left, 1 top-right, 2 bottom-right, 3 bottom-left; a polygon's from the first vertex clockwise), else the corners with a Direct-Selected anchor, else every corner; each radius is drawn no larger than half the corner's shorter side allows; a path that isn't a live shape keeps its uncut outline so its corners stay editable, and is plain again once none is cut; sides: a polygon's, which keep the radius they shared; the polygon params apply in that order: sides, makeSidesEqual, polygonAngle, then sideLength or else polygonRadius)",
|
||||
"{id?, ids?, radius?: pt (or % of each corner's maximum when `relativeRadii` is true), kind?: \"round\"|\"invertedRound\"|\"chamfer\", relativeRadii?: bool (Relative rounding, as in the reference app: store radii as fractions of each corner's maximum so they scale with the shape), corners?: [i…], items?: [{id, corners?: [i…]}] (several objects, each with its own corners, in place of ids and corners: the canvas's corner widgets across a selection), sides?: n, polygonAngle?: degrees (counter-clockwise from its first vertex straight up), polygonRadius?: pt (centre to vertex), sideLength?: pt (sets the radius), makeSidesEqual?: true (a polygon scaled unevenly: drops the uneven scale and shear, keeping its centre, angle and mean radius), pieStart?, pieEnd?: degrees (an ellipse's pie, counter-clockwise from 3 o'clock; 0 to 360 is the whole ellipse), invertPie?: true (swaps them: the other part of the ellipse)} (Live Corners on any path: radius and kind set its corners (anchors that aren't smooth, where two sides, straight or curved (#1144), meet at an angle): `corners` (anchor indices of the path with its corners uncut, counting every subpath's anchors in order: a rectangle's 0 top-left, 1 top-right, 2 bottom-right, 3 bottom-left; a polygon's from the first vertex clockwise), else the corners with a Direct-Selected anchor, else every corner; each radius is drawn no larger than half the corner's shorter side allows; a path that isn't a live shape keeps its uncut outline so its corners stay editable, and is plain again once none is cut; sides: a polygon's, which keep the radius they shared; the polygon params apply in that order: sides, makeSidesEqual, polygonAngle, then sideLength or else polygonRadius)",
|
||||
has_selection,
|
||||
set_live_shape
|
||||
),
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -21,7 +21,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Fill",
|
||||
[],
|
||||
None,
|
||||
"{color?: \"#rrggbb\"|[r,g,b]|{c,m,y,k}|{gray}|{l,a,b} (CIE Lab; #rrggbbaa and #rgba are refused), none?: true, swatch?: name (a global or spot colour stays linked, so swatch edits recolour it; a tint swatch links to its base at its tint; a gradient swatch is recorded as the gradient's swatch and fits each object, keeping its aspect; the built-in \"[Registration]\" prints on every plate), tint?: 0..100 (% of a global or spot `swatch`; default 100, or a tint swatch's own), gradient?: {kind?: linear|radial|freeform, method?: classic|perceptual (as paint.editGradient; default classic), stops?: [{offset 0..1, color, opacity? 0..1 (or 0..100), midpoint? 0.13..0.87, swatch?: global or spot colour (or tint) swatch the stop links to (its colour comes from the swatch), tint?: 0..100}] (at least 2; default white→black), angle?: deg, start?: [x,y], end?: [x,y] (the vector in document coordinates, both or neither; type objects keep it in text space), aspect?: % (radial; without start/end the gradient is placed on each object's bounds), focal?: [x,y] (radial, with start/end: the focal point, where the first stop sits), dither?: bool (linear and radial fills: drawn with fine noise that hides banding, and as pixels in SVG and PDF exports; strokes ignore it), swatch?: linked gradient swatch name}, item?: appearance item index|null (omitted: the Appearance panel's active item if it is a fill, else the top fill), ids?|id?, focus?: true (false keeps the active proxy), keepModel?: false (in a CMYK document, RGB colours and gradient stops are stored as CMYK unless true; Gray stays Gray), target?: \"object\"|\"contents\", span?: \"object\" (default: each target fits a gradient to its own bounds)|\"selection\" (a linear or radial `gradient` without start/end is placed once on the union of the targets' bounds, so it runs across them)} sets the selection's fill and the default (new art fits a gradient to itself); a solid colour recolours only the gradient mesh point last clicked with the Mesh tool or Direct Selection; while the Type tool has characters selected (and no ids or item are given), the paint goes to those characters only; without `item`, a grayscale or 1-bit image takes a solid color as the color it is drawn in (white pixels transparent; `none` takes it off; an image with an alpha channel takes only a spot color, black or white); with an object of a dynamic symbol instance selected by Direct Selection and no ids, id or item, that object's fill changes for this instance only (solid, none, gradient or pattern) → {objects: how many objects took the paint (the objects inside a group, unless an appearance item of the group's own is targeted; 0 when only the default changed)}",
|
||||
"{color?: \"#rrggbb\"|[r,g,b]|{c,m,y,k}|{gray}|{l,a,b} (CIE Lab; #rrggbbaa and #rgba are refused), none?: true, swatch?: name (a global or spot colour stays linked, so swatch edits recolour it; a tint swatch links to its base at its tint; a gradient swatch is recorded as the gradient's swatch and fits each object, keeping its aspect; the built-in \"[Registration]\" prints on every plate), tint?: 0..100 (% of a global or spot `swatch`; default 100, or a tint swatch's own), gradient?: {kind?: linear|radial|freeform, method?: classic|perceptual (as paint.editGradient; default classic), stops?: [{offset 0..1, color, opacity? 0..1 (or 0..100), midpoint? 0.13..0.87, swatch?: global or spot colour (or tint) swatch the stop links to (its colour comes from the swatch), tint?: 0..100}] (at least 2; default white→black), angle?: deg counter-clockwise (0: the gradient runs left to right), start?: [x,y], end?: [x,y] (the vector in document coordinates, both or neither; type objects keep it in text space), aspect?: % (radial; without start/end the gradient is placed on each object's bounds), focal?: [x,y] (radial, with start/end: the focal point, where the first stop sits), dither?: bool (linear and radial fills: drawn with fine noise that hides banding, and as pixels in SVG and PDF exports; strokes ignore it), swatch?: linked gradient swatch name}, item?: appearance item index|null (omitted: the Appearance panel's active item if it is a fill, else the top fill), ids?|id?, focus?: true (false keeps the active proxy), keepModel?: false (in a CMYK document, RGB colours and gradient stops are stored as CMYK unless true; Gray stays Gray), target?: \"object\"|\"contents\", span?: \"object\" (default: each target fits a gradient to its own bounds)|\"selection\" (a linear or radial `gradient` without start/end is placed once on the union of the targets' bounds, so it runs across them)} sets the selection's fill and the default (new art fits a gradient to itself); a solid colour recolours only the gradient mesh point last clicked with the Mesh tool or Direct Selection; while the Type tool has characters selected (and no ids or item are given), the paint goes to those characters only; without `item`, a grayscale or 1-bit image takes a solid color as the color it is drawn in (white pixels transparent; `none` takes it off; an image with an alpha channel takes only a spot color, black or white); with an object of a dynamic symbol instance selected by Direct Selection and no ids, id or item, that object's fill changes for this instance only (solid, none, gradient or pattern) → {objects: how many objects took the paint (the objects inside a group, unless an appearance item of the group's own is targeted; 0 when only the default changed)}",
|
||||
has_doc,
|
||||
|s, p| set_paint(s, p, true)
|
||||
),
|
||||
@@ -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,
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -51,7 +51,7 @@ pub struct ParamKeys<'a> {
|
||||
|
||||
/// The text between the braces of the object `doc` starts with and what follows it, or why it
|
||||
/// has none.
|
||||
fn object_body(doc: &str) -> std::result::Result<(&str, &str), String> {
|
||||
pub(crate) fn object_body(doc: &str) -> std::result::Result<(&str, &str), String> {
|
||||
// (Named, not written in the `Err`: these are for the tests of the descriptions, not messages
|
||||
// the app shows, which the message catalogs would have to translate.)
|
||||
let (no_object, not_closed) = ("the description doesn't start with the params object `{…}`", "the params object `{…}` isn't closed");
|
||||
@@ -78,7 +78,7 @@ fn object_body(doc: &str) -> std::result::Result<(&str, &str), String> {
|
||||
}
|
||||
|
||||
/// `s` split at each `sep` that is outside brackets, quotes and backticks.
|
||||
fn split_top(s: &str, sep: char) -> Vec<&str> {
|
||||
pub(crate) fn split_top(s: &str, sep: char) -> Vec<&str> {
|
||||
let (mut out, mut depth, mut quote, mut start) = (Vec::new(), 0usize, None, 0);
|
||||
for (i, c) in s.char_indices() {
|
||||
match (quote, c) {
|
||||
@@ -98,7 +98,7 @@ fn split_top(s: &str, sep: char) -> Vec<&str> {
|
||||
out
|
||||
}
|
||||
|
||||
fn is_key(s: &str) -> bool {
|
||||
pub(crate) fn is_key(s: &str) -> bool {
|
||||
let mut chars = s.chars();
|
||||
chars.next().is_some_and(|c| c.is_ascii_alphabetic() || c == '_') && chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
})?;
|
||||
|
||||
@@ -116,7 +116,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Mirror",
|
||||
["Object", "Repeat"],
|
||||
None,
|
||||
"{ids?, angle?: deg (90 = vertical axis), offset?: pt (10)} mirror repeat of the selection → {id}",
|
||||
"{ids?, angle?: deg clockwise from level (the mirror axis; 90 = vertical), offset?: pt (10)} mirror repeat of the selection → {id}",
|
||||
has_selection,
|
||||
repeat_mirror
|
||||
),
|
||||
@@ -134,7 +134,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Options…",
|
||||
["Object", "Repeat"],
|
||||
None,
|
||||
"{instances?, radius?, reverseOverlap?, startAngle?, endAngle?, center?: [x,y] | hSpacing?, vSpacing?, rows?, cols?, gridType?, brickOffset?, flipRows?, flipCols? | angle?} set the options of the selected repeats",
|
||||
"{instances?, radius?, reverseOverlap?, startAngle?, endAngle?: deg clockwise (0: where the source art sits), center?: [x,y] | hSpacing?, vSpacing?, rows?, cols?, gridType?, brickOffset?, flipRows?, flipCols? | angle?: deg clockwise from level (the mirror axis; 90 = vertical)} set the options of the selected repeats",
|
||||
has_repeat,
|
||||
repeat_options
|
||||
),
|
||||
@@ -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;
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -23,7 +23,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Print Setup",
|
||||
[],
|
||||
None,
|
||||
"{preset?: name ([Default] or a saved print preset, any case: print.presets.list; its settings replace the document's, as choosing it in the Print dialog does), settings?: {copies: 1 (1–999), collate: true, reverse, artboards: all|range|ignore (all the art as one page), range: \"1-3, 5\" (1-based, with artboards: range), skipBlank (leave out artboards with no art that prints), media: letter|legal|tabloid|a3|a4|a5|a6|b4|b5|b4 (jis)|b5 (jis)|b6 (jis)|japanese postcard|japanese double postcard|japanese envelope #3|japanese envelope #4|kaku #2|kaku #3|yonroku-ban|custom (a paper is named by its id or its dialog label, any case and spacing, so `B5 (JIS)` works; b4 and b5 stay the ISO 216 sizes, 250 × 353 and 176 × 250 mm, and the JIS sizes are b4 (jis) 257 × 364 mm, b5 (jis) 182 × 257 mm and b6 (jis) 128 × 182 mm), width, height (custom paper, 72–14400 pt), orientation: portrait|landscape|portraitFlipped|landscapeFlipped, autoRotate: true (turn the paper to each artboard; orientation ignored), transverse (the page a quarter turn on the paper), printLayers: visiblePrintable|visible|all (template layers never print), placement: {origin: topLeft|top|topRight|left|center|right|bottomLeft|bottom|bottomRight (the point of the printed area, the artboard with its bleed and marks, on the same point of the imageable area), x, y (pt, right and down)}, scaling: none|fit|custom|tileFull|tileImageable, scale: {width: 100, height: 100} (% with custom and tiling), overlap: 0 (pt between tiles), tileRange: \"\" (1-based tiles across then down; empty: all), copiesPerSheet: false (not with tiling: as many copies of each artboard, with its bleed and marks, as fit the imageable area, across then down, not turned; the placement places the grid; an artboard that doesn't fit prints one copy per sheet, with a warning), gutter: 0 (pt between copies, 0–720), margin: 0 (pt the device can't print around the paper: the imageable area is inside it), marks: {trim, registration, colorBars, pageInfo, kind: roman|japanese, weight: 0.25, offset: 6} (as document.exportPdf, at the paper's scale; page information adds the tile and the ink), bleed: {useDocument: true (the document's bleed, document.setup), top, bottom, left, right} (pt), output: {mode: composite|separations (one grey page per ink that prints), emulsion: up|down (down mirrors), image: positive|negative, spotsToProcess, inks: [{name: \"Cyan\"|…|a spot swatch, print: true, frequency (lpi, default 60), angle (default C 15, M 75, Y 0, K 45, spots 45)}]}, graphics: {autoFlatness: true, flatness: 1 (0.2–100), fonts: none|subset|complete}, color: {intent: perceptual|relativeColorimetric|saturation|absoluteColorimetric, preserveNumbers: true (CMYK colours keep their values), profile: \"\" (the printer profile, an RGB or CMYK one: composite PDF colours are converted to it with the intent, separations separate with a CMYK one; empty: as they are)}, advanced: {printAsBitmap (composite pages print as one image each, at the document's raster effects resolution, or lower for a page larger than 64 Mpx or 32,768 px a side), overprints: preserve|discard|simulate (composite output; preserve writes them to overprint (/OP), simulate prints them as Overprint Preview shows them; separations always keep them), flattenerPreset: \"\" (a flattener preset, built-in or saved: transparency is flattened before printing; empty: it prints live; PostScript defaults to medium)}, tileOrigin: {placed: false, x, y} (where the Print Tiling tool put the pages: print.tiling.set)}} store the print settings with the document, over the ones it has or the preset's (null keeps a value), as one undo step; no settings or preset → {settings} (the current ones, defaults if never set up)",
|
||||
"{preset?: name ([Default] or a saved print preset, any case: print.presets.list; its settings replace the document's, as choosing it in the Print dialog does), settings?: {copies: 1 (1–999), collate: true, reverse, artboards: all|range|ignore (all the art as one page), range: \"1-3, 5\" (1-based, with artboards: range), skipBlank (leave out artboards with no art that prints), media: letter|legal|tabloid|a3|a4|a5|a6|b4|b5|b4 (jis)|b5 (jis)|b6 (jis)|japanese postcard|japanese double postcard|japanese envelope #3|japanese envelope #4|kaku #2|kaku #3|yonroku-ban|custom (a paper is named by its id or its dialog label, any case and spacing, so `B5 (JIS)` works; b4 and b5 stay the ISO 216 sizes, 250 × 353 and 176 × 250 mm, and the JIS sizes are b4 (jis) 257 × 364 mm, b5 (jis) 182 × 257 mm and b6 (jis) 128 × 182 mm), width, height (custom paper, 72–14400 pt), orientation: portrait|landscape|portraitFlipped|landscapeFlipped, autoRotate: true (turn the paper to each artboard; orientation ignored), transverse (the page a quarter turn on the paper), printLayers: visiblePrintable|visible|all (template layers never print), placement: {origin: topLeft|top|topRight|left|center|right|bottomLeft|bottom|bottomRight (the point of the printed area, the artboard with its bleed and marks, on the same point of the imageable area), x, y (pt, right and down)}, scaling: none|fit|custom|tileFull|tileImageable, scale: {width: 100, height: 100} (% with custom and tiling), overlap: 0 (pt between tiles), tileRange: \"\" (1-based tiles across then down; empty: all), copiesPerSheet: false (not with tiling: as many copies of each artboard, with its bleed and marks, as fit the imageable area, across then down, not turned; the placement places the grid; an artboard that doesn't fit prints one copy per sheet, with a warning), gutter: 0 (pt between copies, 0–720), margin: 0 (pt the device can't print around the paper: the imageable area is inside it), marks: {trim, registration, colorBars, pageInfo, kind: roman|japanese, weight: 0.25, offset: 6} (as document.exportPdf, at the paper's scale; page information adds the tile and the ink), bleed: {useDocument: true (the document's bleed, document.setup), top, bottom, left, right} (pt), output: {mode: composite|separations (one grey page per ink that prints), emulsion: up|down (down mirrors), image: positive|negative, spotsToProcess, inks: [{name: \"Cyan\"|…|a spot swatch, print: true, frequency (lpi, default 60), angle (screen angle, degrees counter-clockwise as PostScript setscreen takes it; default C 15, M 75, Y 0, K 45, spots 45)}]}, graphics: {autoFlatness: true, flatness: 1 (0.2–100), fonts: none|subset|complete}, color: {intent: perceptual|relativeColorimetric|saturation|absoluteColorimetric, preserveNumbers: true (CMYK colours keep their values), profile: \"\" (the printer profile, an RGB or CMYK one: composite PDF colours are converted to it with the intent, separations separate with a CMYK one; empty: as they are)}, advanced: {printAsBitmap (composite pages print as one image each, at the document's raster effects resolution, or lower for a page larger than 64 Mpx or 32,768 px a side), overprints: preserve|discard|simulate (composite output; preserve writes them to overprint (/OP), simulate prints them as Overprint Preview shows them; separations always keep them), flattenerPreset: \"\" (a flattener preset, built-in or saved: transparency is flattened before printing; empty: it prints live; PostScript defaults to medium)}, tileOrigin: {placed: false, x, y} (where the Print Tiling tool put the pages: print.tiling.set)}} store the print settings with the document, over the ones it has or the preset's (null keeps a value), as one undo step; no settings or preset → {settings} (the current ones, defaults if never set up)",
|
||||
has_doc,
|
||||
setup
|
||||
),
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -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)
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()));
|
||||
|
||||
@@ -40,7 +40,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Character",
|
||||
[],
|
||||
None,
|
||||
"{id? (default: the one selected type object), start?: byte, end?: byte (default: all text), story?: bool = false (all the text of every frame of the threaded story `id` is a frame of, in one undo step; no `start` or `end`), font?, style?, size?: pt, leading?: pt|\"auto\", tracking?, kerning?: 1/1000 em|\"auto\"|\"optical\", baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg, fill?: colour|\"none\", stroke?: colour|\"none\" (#rrggbbaa and #rgba are refused in both), keepModel?: bool (fill and stroke, as paint.setFill), strokeWidth?: pt, strokeOptions?: {weight?, cap?, join?, miterLimit?, align?, dash?, dashOffset?, alignDashes?} (as stroke.set: the character stroke), underline?, strikethrough?, allCaps?: bool, smallCaps?: bool, position?: \"normal\"|\"superscript\"|\"subscript\" (sizes from Document Setup), features?: [\"dlig\", \"-liga\", …], featureChanges?: [\"tnum\", \"-onum\", …] (OpenType features turned on, or with - off, on top of each character's own; its other features are kept), charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\", proportionalMetrics?: bool (full-width glyphs on the font's proportional widths: `palt` in horizontal type, `vpal` in vertical type), tateChuYoko?: bool (Character panel menu › Tate-chu-yoko, with Show East Asian Options: in vertical type those characters stand side by side across one em of the column; kept on the characters and ignored in horizontal type), tcyUpDown?, tcyLeftRight?: pt -1296..1296 (Character panel menu › Tate-chu-yoko Settings: in vertical type a block of tate-chu-yoko characters moves up by a positive Up/Down and down by a negative one, right by a positive Left/Right and left by a negative one, as one piece with the values of its first character; on a vertical path a positive Up/Down moves it toward the path's start and a positive Left/Right to the path's left; 0 by default; automatic digit blocks and horizontal type do not move; neither turns tateChuYoko on), standardVerticalRoman?: bool (Character panel menu › Standard Vertical Roman Alignment, with Show East Asian Options: in vertical type those half-width characters, such as Latin letters and digits, stand upright one below the other instead of being rotated; spaces and tate-chu-yoko blocks are not changed; kept on the characters and ignored in horizontal type; off by default), noBreak?: bool (Character panel menu › No Break: these characters stay on one line, including across a space inside the run), kashidas?: bool (Character panel menu › Kashidas: selected characters allow insertion only when both joined neighbors have it on; on by default), charDirection?: \"default\"|\"leftToRight\"|\"rightToLeft\" (Character panel menu › Character Direction: the characters are set left to right or right to left inside their paragraph, as a Unicode bidi embedding; default: as the bidi algorithm sets them), digits?: \"default\"|\"arabic\"|\"hindi\"|\"farsi\"|\"devanagari\"|\"bengali\"|\"gurmukhi\"|\"gujarati\"|\"odia\"|\"tamil\"|\"telugu\"|\"kannada\"|\"malayalam\" (Character panel menu › Digits: the numeral system every digit is drawn in, whatever was typed: Western, Arabic-Indic (hindi), Eastern Arabic-Indic (farsi) or an Indic script's own digits), fontHeightReference?: \"emBox\"|\"capHeight\"|\"xHeight\"|\"icfBox\" (Character panel › Set Font Height Reference: the height of the font that size measures, the em box by default; with a font that has no such height, or one outside 0.1 to 2 em, the em box), language?: name|tag (Character panel › Language: one of Document Setup's languages by name or BCP 47 tag, case-insensitive, such as \"Turkish\" or \"tr\"; the characters are shaped in it, so the font uses that language's forms (OpenType `locl`); saved as the tag, English: USA, the default, as none; new type takes the document's language), fontAxes?: {\"wght\": 650, …} (Character panel › Variable Font: the setting of each variation axis named, in the axis's units and clamped to its range; null: back to the style's own setting; other axes keep theirs; tags the font has no axis for are left out; a font or style change clears them; text.fontList {family, style} lists the axes)} style a character range (size/leading/baselineShift are document points and hScale includes the object transform; runs are split at the range ends) → {id, runs, warning?: text (the font or style set isn't available: its type is drawn in the fallback font or the family's closest style)}",
|
||||
"{id? (default: the one selected type object), start?: byte, end?: byte (default: all text), story?: bool = false (all the text of every frame of the threaded story `id` is a frame of, in one undo step; no `start` or `end`), font?, style?, size?: pt, leading?: pt|\"auto\", tracking?, kerning?: 1/1000 em|\"auto\"|\"optical\", baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg counter-clockwise (0: upright), fill?: colour|\"none\", stroke?: colour|\"none\" (#rrggbbaa and #rgba are refused in both), keepModel?: bool (fill and stroke, as paint.setFill), strokeWidth?: pt, strokeOptions?: {weight?, cap?, join?, miterLimit?, align?, dash?, dashOffset?, alignDashes?} (as stroke.set: the character stroke), underline?, strikethrough?, allCaps?: bool, smallCaps?: bool, position?: \"normal\"|\"superscript\"|\"subscript\" (sizes from Document Setup), features?: [\"dlig\", \"-liga\", …], featureChanges?: [\"tnum\", \"-onum\", …] (OpenType features turned on, or with - off, on top of each character's own; its other features are kept), charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\", proportionalMetrics?: bool (full-width glyphs on the font's proportional widths: `palt` in horizontal type, `vpal` in vertical type), tateChuYoko?: bool (Character panel menu › Tate-chu-yoko, with Show East Asian Options: in vertical type those characters stand side by side across one em of the column; kept on the characters and ignored in horizontal type), tcyUpDown?, tcyLeftRight?: pt -1296..1296 (Character panel menu › Tate-chu-yoko Settings: in vertical type a block of tate-chu-yoko characters moves up by a positive Up/Down and down by a negative one, right by a positive Left/Right and left by a negative one, as one piece with the values of its first character; on a vertical path a positive Up/Down moves it toward the path's start and a positive Left/Right to the path's left; 0 by default; automatic digit blocks and horizontal type do not move; neither turns tateChuYoko on), standardVerticalRoman?: bool (Character panel menu › Standard Vertical Roman Alignment, with Show East Asian Options: in vertical type those half-width characters, such as Latin letters and digits, stand upright one below the other instead of being rotated; spaces and tate-chu-yoko blocks are not changed; kept on the characters and ignored in horizontal type; off by default), noBreak?: bool (Character panel menu › No Break: these characters stay on one line, including across a space inside the run), kashidas?: bool (Character panel menu › Kashidas: selected characters allow insertion only when both joined neighbors have it on; on by default), charDirection?: \"default\"|\"leftToRight\"|\"rightToLeft\" (Character panel menu › Character Direction: the characters are set left to right or right to left inside their paragraph, as a Unicode bidi embedding; default: as the bidi algorithm sets them), digits?: \"default\"|\"arabic\"|\"hindi\"|\"farsi\"|\"devanagari\"|\"bengali\"|\"gurmukhi\"|\"gujarati\"|\"odia\"|\"tamil\"|\"telugu\"|\"kannada\"|\"malayalam\" (Character panel menu › Digits: the numeral system every digit is drawn in, whatever was typed: Western, Arabic-Indic (hindi), Eastern Arabic-Indic (farsi) or an Indic script's own digits), fontHeightReference?: \"emBox\"|\"capHeight\"|\"xHeight\"|\"icfBox\" (Character panel › Set Font Height Reference: the height of the font that size measures, the em box by default; with a font that has no such height, or one outside 0.1 to 2 em, the em box), language?: name|tag (Character panel › Language: one of Document Setup's languages by name or BCP 47 tag, case-insensitive, such as \"Turkish\" or \"tr\"; the characters are shaped in it, so the font uses that language's forms (OpenType `locl`); saved as the tag, English: USA, the default, as none; new type takes the document's language), fontAxes?: {\"wght\": 650, …} (Character panel › Variable Font: the setting of each variation axis named, in the axis's units and clamped to its range; null: back to the style's own setting; other axes keep theirs; tags the font has no axis for are left out; a font or style change clears them; text.fontList {family, style} lists the axes)} style a character range (size/leading/baselineShift are document points and hScale includes the object transform; runs are split at the range ends) → {id, runs, warning?: text (the font or style set isn't available: its type is drawn in the fallback font or the family's closest style)}",
|
||||
has_doc,
|
||||
set_range_style
|
||||
),
|
||||
@@ -49,7 +49,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Touch Type",
|
||||
[],
|
||||
None,
|
||||
"{id, start: byte (a character of it), dx?: pt, dy?: pt, hScale?: %, vScale?: %, rotation?: deg} change one character (a ligature, one glyph for several characters, as a whole) as a Touch Type tool drag does, as one undo step: dx moves it along its line in reading order by changing the kerning of the character before it on the line and its own kerning, and the characters after it stay where they are (an error for the first logical character of a line or at a reading-direction boundary; supported in either reading direction; limited to what kerning from -1000 to 10000 can give); dy raises it (baseline shift, up from its line whatever its rotation, also for the first character of a line and a right-to-left character); dx and dy are document points; hScale (including the object transform), vScale and rotation set its scales and rotation as text.setRangeStyle does. The kerning of the two characters also keeps the character and the ones after it where they were along the line when the font kerned them with their neighbors; a wider or narrower character moves the ones after it by the change in its width → {id, start, end} (the character's bytes)",
|
||||
"{id, start: byte (a character of it), dx?: pt, dy?: pt, hScale?: %, vScale?: %, rotation?: deg counter-clockwise (0: upright)} change one character (a ligature, one glyph for several characters, as a whole) as a Touch Type tool drag does, as one undo step: dx moves it along its line in reading order by changing the kerning of the character before it on the line and its own kerning, and the characters after it stay where they are (an error for the first logical character of a line or at a reading-direction boundary; supported in either reading direction; limited to what kerning from -1000 to 10000 can give); dy raises it (baseline shift, up from its line whatever its rotation, also for the first character of a line and a right-to-left character); dx and dy are document points; hScale (including the object transform), vScale and rotation set its scales and rotation as text.setRangeStyle does. The kerning of the two characters also keeps the character and the ones after it where they were along the line when the font kerned them with their neighbors; a wider or narrower character moves the ones after it by the change in its width → {id, start, end} (the character's bytes)",
|
||||
has_doc,
|
||||
touch_type
|
||||
),
|
||||
|
||||
@@ -23,7 +23,9 @@ static NEW_VIEW: ParamSchema = ParamSchema::new(
|
||||
Param::new("name", Type::String).default_note("the first free \"View N\"").null_omitted(),
|
||||
Param::new("center", Type::Point),
|
||||
Param::new("zoom", Type::Number { min: Some(Bound::Exclusive(0.0)), max: None, unit: None }).doc("1 = 100%"),
|
||||
Param::new("rotation", Type::Number { min: None, max: None, unit: Some("deg") }).defaults_to("0"),
|
||||
Param::new("rotation", Type::Number { min: None, max: None, unit: Some("deg") })
|
||||
.defaults_to("0")
|
||||
.doc("clockwise on screen, as view.rotate turns the view"),
|
||||
],
|
||||
"save a view in the document (the app fills in the current one; at most 25, names up to 255 characters, each name once)",
|
||||
)
|
||||
@@ -46,7 +48,8 @@ static EDIT_VIEWS: ParamSchema = ParamSchema::new(
|
||||
"rename or delete saved views, all in one undo step; names are those before the edit and must stay unique; no params → {views}",
|
||||
);
|
||||
|
||||
static LIST_VIEWS: ParamSchema = ParamSchema::new(&[], "").returns("{views: [{name, center, zoom, rotation}]}").writes(Writes::Nothing);
|
||||
static LIST_VIEWS: ParamSchema =
|
||||
ParamSchema::new(&[], "").returns("{views: [{name, center, zoom, rotation (degrees clockwise)}]}").writes(Writes::Nothing);
|
||||
|
||||
static TRANSPARENCY_GRID: ParamSchema = ParamSchema::new(
|
||||
&[Param::new("on", Type::Boolean).default_note("toggle").null_omitted()],
|
||||
@@ -86,7 +89,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"New View…",
|
||||
["View"],
|
||||
None,
|
||||
"{name?: string (default: the first free \"View N\"), center: [x, y], zoom: number > 0 (1 = 100%), rotation?: number deg (default: 0)} save a view in the document (the app fills in the current one; at most 25, names up to 255 characters, each name once) → {name, index}",
|
||||
"{name?: string (default: the first free \"View N\"), center: [x, y], zoom: number > 0 (1 = 100%), rotation?: number deg (default: 0) (clockwise on screen, as view.rotate turns the view)} save a view in the document (the app fills in the current one; at most 25, names up to 255 characters, each name once) → {name, index}",
|
||||
has_doc,
|
||||
new_view,
|
||||
schema: &NEW_VIEW
|
||||
@@ -101,7 +104,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
edit_views,
|
||||
schema: &EDIT_VIEWS
|
||||
),
|
||||
cmd!(query "view.saved.list", "Saved Views", [], None, "{} → {views: [{name, center, zoom, rotation}]}", has_doc, list_views, schema: &LIST_VIEWS),
|
||||
cmd!(query "view.saved.list", "Saved Views", [], None, "{} → {views: [{name, center, zoom, rotation (degrees clockwise)}]}", has_doc, list_views, schema: &LIST_VIEWS),
|
||||
cmd!(
|
||||
query "view.transparencyGrid",
|
||||
"Show Transparency Grid",
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -430,6 +430,133 @@ impl ScriptScope {
|
||||
}
|
||||
Ok((real, bytes))
|
||||
}
|
||||
|
||||
/// The first granted folder, as people read it (`None` for a script given as text).
|
||||
pub(crate) fn folder(&self) -> Option<PathBuf> {
|
||||
self.roots.first().map(|r| crate::cmd::findfiles::plain(r.clone()))
|
||||
}
|
||||
|
||||
/// Does the absolute path `path` name a granted folder or a place inside one, by its names
|
||||
/// alone, without regard to case? A path that does not is refused before the file system is
|
||||
/// asked anything about it.
|
||||
fn names_inside(&self, path: &Path) -> bool {
|
||||
let lower = |c: Component| c.as_os_str().to_string_lossy().to_lowercase();
|
||||
path.is_absolute()
|
||||
&& self.roots.iter().any(|r| {
|
||||
let root = crate::cmd::findfiles::plain(r.clone());
|
||||
let mut names = path.components();
|
||||
root.components().all(|c| names.next().is_some_and(|n| lower(n) == lower(c)))
|
||||
})
|
||||
}
|
||||
|
||||
/// The real path of the file or folder at `path`, which must be absolute: refused as
|
||||
/// [`ScriptScope::read`] refuses a path, and also when its names alone place it outside every
|
||||
/// granted folder, in which case the file system is not asked about it.
|
||||
pub(crate) fn locate(&self, path: &Path) -> Result<PathBuf, ScriptRead> {
|
||||
if self.roots.is_empty() || Self::refused_by_name(path) || !self.names_inside(path) {
|
||||
return Err(ScriptRead::Refused);
|
||||
}
|
||||
probed();
|
||||
if check_read(&path.to_string_lossy()).is_err() {
|
||||
return Err(ScriptRead::Refused);
|
||||
}
|
||||
let real = match std::fs::canonicalize(path) {
|
||||
Ok(real) => real,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
|
||||
return Err(if std::fs::symlink_metadata(path).is_ok() { ScriptRead::Refused } else { ScriptRead::Missing });
|
||||
}
|
||||
Err(e) => return Err(ScriptRead::Failed(e.to_string())),
|
||||
};
|
||||
if !self.roots.iter().any(|r| inside(&real, r)) {
|
||||
return Err(ScriptRead::Refused);
|
||||
}
|
||||
Ok(real)
|
||||
}
|
||||
|
||||
/// What the file or folder at `path` is, located as [`ScriptScope::locate`] locates it.
|
||||
pub(crate) fn stat(&self, path: &Path) -> Result<ScriptEntry, ScriptRead> {
|
||||
let real = self.locate(path)?;
|
||||
let meta = std::fs::metadata(&real).map_err(|e| ScriptRead::Failed(e.to_string()))?;
|
||||
let link = std::fs::symlink_metadata(path).is_ok_and(|m| m.file_type().is_symlink());
|
||||
#[cfg(windows)]
|
||||
let hidden = std::os::windows::fs::MetadataExt::file_attributes(&meta) & 2 != 0;
|
||||
#[cfg(not(windows))]
|
||||
let hidden = real.file_name().is_some_and(|n| n.to_string_lossy().starts_with('.'));
|
||||
Ok(ScriptEntry {
|
||||
dir: meta.is_dir(),
|
||||
file: meta.is_file(),
|
||||
len: meta.len(),
|
||||
created: meta.created().ok(),
|
||||
modified: meta.modified().ok(),
|
||||
hidden,
|
||||
readonly: meta.permissions().readonly(),
|
||||
link,
|
||||
})
|
||||
}
|
||||
|
||||
/// The names in the folder at `path` that `keep` keeps, with whether each is a folder, in the
|
||||
/// order of their names. A name whose links lead out of every granted folder, or out of the
|
||||
/// automation read roots in force, is left out. More
|
||||
/// than `max` names kept, or more than [`MAX_LISTED`] names looked at, is
|
||||
/// [`ScriptRead::TooLarge`].
|
||||
pub(crate) fn list(&self, path: &Path, max: usize, keep: impl Fn(&str) -> bool) -> Result<Vec<(String, bool)>, ScriptRead> {
|
||||
let real = self.locate(path)?;
|
||||
let entries = std::fs::read_dir(&real).map_err(|e| ScriptRead::Failed(e.to_string()))?;
|
||||
let mut out = Vec::new();
|
||||
for (n, entry) in entries.enumerate() {
|
||||
if n >= MAX_LISTED {
|
||||
return Err(ScriptRead::TooLarge);
|
||||
}
|
||||
let Ok(entry) = entry else { continue };
|
||||
let name = entry.file_name().to_string_lossy().into_owned();
|
||||
if !keep(&name) {
|
||||
continue;
|
||||
}
|
||||
let Ok(target) = std::fs::canonicalize(entry.path()) else { continue };
|
||||
if !self.roots.iter().any(|r| inside(&target, r)) || check_read(&entry.path().to_string_lossy()).is_err() {
|
||||
continue;
|
||||
}
|
||||
if out.len() >= max {
|
||||
return Err(ScriptRead::TooLarge);
|
||||
}
|
||||
out.push((name, target.is_dir()));
|
||||
}
|
||||
out.sort();
|
||||
Ok(out)
|
||||
}
|
||||
}
|
||||
|
||||
/// The most names [`ScriptScope::list`] looks at in one folder.
|
||||
pub(crate) const MAX_LISTED: usize = 65_536;
|
||||
|
||||
/// A file or folder as [`ScriptScope::stat`] gives it.
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct ScriptEntry {
|
||||
pub dir: bool,
|
||||
/// A regular file.
|
||||
pub file: bool,
|
||||
pub len: u64,
|
||||
pub created: Option<std::time::SystemTime>,
|
||||
pub modified: Option<std::time::SystemTime>,
|
||||
pub hidden: bool,
|
||||
pub readonly: bool,
|
||||
/// The path itself is a symbolic link.
|
||||
pub link: bool,
|
||||
}
|
||||
|
||||
thread_local! {
|
||||
/// How many paths [`ScriptScope::locate`] has asked the file system about on this thread.
|
||||
static PROBES: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
|
||||
}
|
||||
|
||||
fn probed() {
|
||||
PROBES.with(|p| p.set(p.get().saturating_add(1)));
|
||||
}
|
||||
|
||||
/// How many paths [`ScriptScope::locate`] has asked the file system about on this thread.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn script_probes() -> usize {
|
||||
PROBES.with(std::cell::Cell::get)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -26,10 +26,10 @@ use crate::cmd::{CommandSpec, bad, command_specs};
|
||||
use crate::{EngineError, Result};
|
||||
|
||||
/// The API level of the commands' parameters.
|
||||
pub const API_VERSION: u32 = 2;
|
||||
pub const API_VERSION: u32 = 3;
|
||||
|
||||
/// [`schema_hash`] at each API level, oldest first; the last row is [`API_VERSION`]'s.
|
||||
pub const SCHEMA_HASHES: &[(u32, u64)] = &[(1, 0x9cdb_1087_7074_855c), (2, 0x0e31_5d96_7bc9_2653)];
|
||||
pub const SCHEMA_HASHES: &[(u32, u64)] = &[(1, 0x9cdb_1087_7074_855c), (2, 0x0e31_5d96_7bc9_2653), (3, 0xffe0_58a3_2f0c_aa2a)];
|
||||
|
||||
/// The hash of every built-in command's schema ([`vectorcraft_apischema::hash`]).
|
||||
pub fn schema_hash() -> u64 {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -251,6 +251,11 @@ impl Files {
|
||||
self.include_path.join(";")
|
||||
}
|
||||
|
||||
/// The folders the run's own files are read from.
|
||||
pub(crate) fn scope(&self) -> &ScriptScope {
|
||||
&self.scope
|
||||
}
|
||||
|
||||
/// The script's own text, `text`, with its preprocessor lines read.
|
||||
pub(crate) fn expand_script(&mut self, text: &str) -> Result<Expanded, Failure> {
|
||||
self.bytes = self.bytes.saturating_add(text.len());
|
||||
|
||||
@@ -36,6 +36,8 @@
|
||||
//!
|
||||
//! `alert`, `confirm` and `prompt` open dialogs ([`dialogs`]): in the app the run waits for the
|
||||
//! person's answer, and a run with nobody to ask takes its answers from `script.run`'s `answers`.
|
||||
//!
|
||||
//! `File` and `Folder` read files inside the script's own folder ([`files`]).
|
||||
|
||||
pub mod console;
|
||||
pub mod dialogs;
|
||||
@@ -58,6 +60,7 @@ use crate::{
|
||||
Clipboard, DocState, DrawMode, EngineError, Journal, NodeId, PaintDefaults, Result, Session, cmd, command_specs, guard, waits_for_tool_edit,
|
||||
};
|
||||
|
||||
pub mod files;
|
||||
pub mod folder;
|
||||
|
||||
/// The most bytes of source a run takes, as a file or as `source`.
|
||||
@@ -502,6 +505,8 @@ struct Host {
|
||||
dialogs: dialogs::Dialogs,
|
||||
/// The files the run reads: its includes and `$.evalFile`'s.
|
||||
files: RefCell<include::Files>,
|
||||
/// What `File` and `Folder` have open and have read.
|
||||
disk: RefCell<files::Disk>,
|
||||
/// Where the lines of the script's expanded source came from.
|
||||
script_map: RefCell<include::SourceMap>,
|
||||
/// The same for each source `$.evalFile` gave, in order.
|
||||
@@ -527,6 +532,10 @@ struct Counts {
|
||||
command_list_reads: Cell<u64>,
|
||||
vc_log: Cell<u64>,
|
||||
console_writes: Cell<u64>,
|
||||
/// Calls of `File` and `Folder` that reach the host ([`files`]).
|
||||
file_calls: Cell<u64>,
|
||||
/// `execute` calls, which open nothing.
|
||||
file_executes: Cell<u64>,
|
||||
}
|
||||
|
||||
impl Counts {
|
||||
@@ -537,6 +546,8 @@ impl Counts {
|
||||
"commandListReads": self.command_list_reads.get(),
|
||||
"vcLog": self.vc_log.get(),
|
||||
"consoleWrites": self.console_writes.get(),
|
||||
"fileCalls": self.file_calls.get(),
|
||||
"fileExecutes": self.file_executes.get(),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1146,7 +1157,7 @@ fn kind_id(kind: &RunErrorKind) -> &'static str {
|
||||
|
||||
/// `message`, with what to do about a failure that comes from a script written for another program.
|
||||
fn explained(kind: &RunErrorKind, message: &str) -> String {
|
||||
const HOST_OBJECTS: &[&str] = &["app", "File", "Folder", "Window", "BridgeTalk", "activeDocument", "documents"];
|
||||
const HOST_OBJECTS: &[&str] = &["app", "Window", "BridgeTalk", "activeDocument", "documents"];
|
||||
let names_host_object =
|
||||
message.strip_prefix("ReferenceError: ").is_some_and(|m| HOST_OBJECTS.iter().any(|o| m.split_whitespace().next() == Some(o)));
|
||||
if *kind == RunErrorKind::Thrown && names_host_object {
|
||||
@@ -1394,6 +1405,7 @@ impl Session {
|
||||
commands: RefCell::default(),
|
||||
run_nanos: Cell::new(0),
|
||||
dialogs: dialogs::Dialogs::new(self.script_frames, &realm, request.answers),
|
||||
disk: RefCell::new(files::Disk::new(files.scope())),
|
||||
files: RefCell::new(files),
|
||||
script_map: RefCell::default(),
|
||||
eval_maps: RefCell::default(),
|
||||
@@ -1419,6 +1431,7 @@ impl Session {
|
||||
.and_then(|()| realm.define_host_object("vc", vc_object(&host, args)))
|
||||
.and_then(|()| realm.define_host_object("$", dollar_object(&host, info, &localization)))
|
||||
.and_then(|()| dialogs::install(&mut realm, &host))
|
||||
.and_then(|()| files::install(&mut realm, &host))
|
||||
.and_then(|()| realm.start(&expanded.source))
|
||||
.map_err(|e| located(&host, realm.error_source(), realm.error_mark(), &e))
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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())
|
||||
{
|
||||
|
||||
@@ -155,6 +155,7 @@ mod script_dialogs;
|
||||
mod script_dollar;
|
||||
mod script_folder;
|
||||
mod script_include;
|
||||
mod script_io;
|
||||
mod script_run;
|
||||
mod scripts;
|
||||
mod select_ids_first;
|
||||
@@ -192,6 +193,7 @@ mod toolsettings;
|
||||
mod transformfx;
|
||||
mod transparencygrid;
|
||||
mod trap;
|
||||
mod turn_direction;
|
||||
mod typearea;
|
||||
mod typeglyphbounds;
|
||||
mod typescale;
|
||||
@@ -215,6 +217,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();
|
||||
|
||||
@@ -104,7 +104,7 @@ fn the_builtin_libraries_are_original_brushes_generated_in_code() {
|
||||
let list = run(&mut s, "brush.libraries", json!({}));
|
||||
let libs = list["libraries"].as_array().unwrap();
|
||||
let ids: Vec<&str> = libs.iter().map(|l| l["id"].as_str().unwrap()).collect();
|
||||
assert_eq!(ids, ["art-ribbons", "calligraphic-nibs", "pattern-borders", "scatter-shapes", "game-trails"]);
|
||||
assert_eq!(ids, ["art-ribbons", "calligraphic-nibs", "pattern-borders", "scatter-shapes", "game-trails", "energy-beams"]);
|
||||
assert!(libs.iter().all(|l| l["category"] == "builtIn"));
|
||||
let lib = run(&mut s, "brush.library", json!({"library": "art ribbons"}));
|
||||
assert_eq!(
|
||||
@@ -118,6 +118,16 @@ fn the_builtin_libraries_are_original_brushes_generated_in_code() {
|
||||
["Speed Line", "Dash Trail", "Burst Ray", "Zigzag"]
|
||||
);
|
||||
assert_eq!(libs[4]["count"].as_u64(), Some(4));
|
||||
let beams = run(&mut s, "brush.library", json!({"library": "energy-beams"}));
|
||||
assert_eq!(
|
||||
beams["brushes"].as_array().unwrap().iter().map(|b| b["name"].as_str().unwrap()).collect::<Vec<_>>(),
|
||||
["Laser Bolt", "Plasma Arc", "Charge Wave", "Spark Trail"]
|
||||
);
|
||||
assert_eq!(libs[5]["count"].as_u64(), Some(4));
|
||||
run(&mut s, "file.new", json!({"width": 200, "height": 200}));
|
||||
let id = run(&mut s, "shape.rectangle", json!({"x": 10, "y": 20, "width": 40, "height": 8}))["id"].as_u64().unwrap();
|
||||
let added = run(&mut s, "brush.addFromLibrary", json!({"library": "energy-beams", "name": "Laser Bolt", "apply": true, "ids": [id]}));
|
||||
assert_eq!(strs(&added["added"]), ["Laser Bolt"]);
|
||||
// One kind of brush per library, every brush drawing something.
|
||||
for (id, kind) in [
|
||||
("art-ribbons", "art"),
|
||||
@@ -125,6 +135,7 @@ fn the_builtin_libraries_are_original_brushes_generated_in_code() {
|
||||
("pattern-borders", "pattern"),
|
||||
("scatter-shapes", "scatter"),
|
||||
("game-trails", "art"),
|
||||
("energy-beams", "art"),
|
||||
] {
|
||||
let (_, lib) = cmd::brushlib::library(&s, id).unwrap();
|
||||
assert!(lib.len() >= 4, "{id}");
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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}));
|
||||
|
||||
@@ -436,9 +436,7 @@ fn an_ids_entry_that_is_not_an_id_is_refused_and_changes_nothing() {
|
||||
/// With the rectangle apart (`d`) selected, the same commands with `ids` (or `slices`) left out and
|
||||
/// with it null do the same: what the selection's list does for the commands that take the
|
||||
/// selection, and the command's own default for the others (`object.slice.select` deselects the
|
||||
/// slices). `addon.color.filter` and `addon.color.applyNearestSwatch` read `ids` through
|
||||
/// [`cmd::checked_ids_or_selection`], which refuses null as a value that is not a list and changes
|
||||
/// nothing.
|
||||
/// slices).
|
||||
#[test]
|
||||
fn ids_left_out_or_null_take_the_selection_or_the_default() {
|
||||
let (_, [.., d], _) = doc_with_type();
|
||||
@@ -450,15 +448,7 @@ fn ids_left_out_or_null_take_the_selection_or_the_default() {
|
||||
let mut null = params.clone();
|
||||
null[key] = serde_json::Value::Null;
|
||||
let with_null = outcome(cmd, &null, &[d], slice);
|
||||
if matches!(cmd, "addon.color.filter" | "addon.color.applyNearestSwatch") {
|
||||
let (mut s, ..) = doc_with_type();
|
||||
s.execute("select.set", &json!({ "ids": [d] })).unwrap();
|
||||
let st = s.doc().unwrap();
|
||||
let refused = with_null.0.as_ref().is_err_and(|e| e.ends_with("ids must be an array of non-negative integer object ids"));
|
||||
if !refused || (&with_null.1, &with_null.2, with_null.3) != (&*st.doc, &st.selection, st.history.undo.len()) {
|
||||
failures.push(format!("{cmd} {key}: null is not refused with nothing changed ({:.160?})", with_null.0));
|
||||
}
|
||||
} else if with_null != left_out {
|
||||
if with_null != left_out {
|
||||
failures.push(format!("{cmd} {key}: null does not do what leaving it out does ({:.160?})", left_out.0));
|
||||
}
|
||||
let mut listed = params.clone();
|
||||
@@ -477,6 +467,65 @@ fn ids_left_out_or_null_take_the_selection_or_the_default() {
|
||||
assert!(failures.is_empty(), "{} fail:\n{}", failures.len(), failures.join("\n"));
|
||||
}
|
||||
|
||||
/// The commands that read `ids` through [`cmd::checked_ids_or_selection`] take `ids: null` as left
|
||||
/// out: with the rectangle apart (`d`) or the type object (`t`) selected, null gives the same
|
||||
/// result or error, document, selection and undo history as leaving `ids` out. Print Ink Fixes
|
||||
/// runs in a CMYK document.
|
||||
#[test]
|
||||
fn ids_null_takes_the_selection_in_every_command_that_falls_back_to_it() {
|
||||
let (_, [.., d], t) = doc_with_type();
|
||||
let mut failures = vec![];
|
||||
let (rect, both, text) = (&[d][..], &[d, t][..], &[t][..]);
|
||||
let cases = [
|
||||
("addon.arrange.randomizeOpacity", json!({"seed": 1}), rect),
|
||||
("addon.arrange.sort", json!({"by": "area", "apply": "x"}), both),
|
||||
("addon.cleanup.simplify", json!({}), rect),
|
||||
("addon.cleanup.deepUngroup", json!({}), rect),
|
||||
("addon.cleanup.rejoin", json!({}), rect),
|
||||
("addon.color.nearestSwatch", json!({}), rect),
|
||||
("addon.pathtools.cornerSet", json!({"radius": 2}), rect),
|
||||
("addon.pathtools.cornerRemove", json!({}), rect),
|
||||
("addon.pathtools.shape.convert", json!({}), rect),
|
||||
("addon.pathtools.erase", json!({"points": [[0, 20], [200, 20]]}), rect),
|
||||
("addon.pathtools.crossings", json!({}), rect),
|
||||
("addon.pathtools.expandDashes", json!({}), rect),
|
||||
("object.path.smooth", json!({}), rect),
|
||||
("object.textWrap.make", json!({}), rect),
|
||||
("object.textWrap.release", json!({}), rect),
|
||||
("object.textWrap.options", json!({}), rect),
|
||||
("variable.bind", json!({"variable": "v"}), rect),
|
||||
("variable.unbind", json!({}), rect),
|
||||
("addon.cleanup.breakText", json!({}), text),
|
||||
("addon.cleanup.combineText", json!({}), text),
|
||||
("addon.cleanup.joinText", json!({}), text),
|
||||
("addon.cleanup.removeBreaks", json!({"kind": "line"}), text),
|
||||
("addon.cleanup.resetTextTransform", json!({}), text),
|
||||
("addon.cleanup.alignInPlace", json!({"align": "left"}), text),
|
||||
];
|
||||
for (cmd, params, selected) in cases {
|
||||
let left_out = outcome(cmd, ¶ms, selected, false);
|
||||
let mut null = params.clone();
|
||||
null["ids"] = serde_json::Value::Null;
|
||||
let with_null = outcome(cmd, &null, selected, false);
|
||||
if with_null != left_out {
|
||||
failures.push(format!("{cmd}: null gives {:.160?}; left out gives {:.160?}", with_null.0, left_out.0));
|
||||
}
|
||||
}
|
||||
let print_fixes = |p: &serde_json::Value| {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 200, "height": 200, "colorMode": "cmyk", "created": null})).unwrap();
|
||||
s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 50, "height": 40})).unwrap();
|
||||
let r = s.execute("addon.color.printFixes", p).map_err(|e| e.to_string());
|
||||
let st = s.doc().unwrap();
|
||||
(r, (*st.doc).clone(), st.selection.clone(), st.history.undo.len())
|
||||
};
|
||||
let (left_out, with_null) = (print_fixes(&json!({"inkLimit": 200})), print_fixes(&json!({"inkLimit": 200, "ids": null})));
|
||||
if with_null != left_out {
|
||||
failures.push(format!("addon.color.printFixes: null gives {:.160?}; left out gives {:.160?}", with_null.0, left_out.0));
|
||||
}
|
||||
assert!(failures.is_empty(), "{} fail:\n{}", failures.len(), failures.join("\n"));
|
||||
}
|
||||
|
||||
/// With no document open, every command that takes `ids` refuses a list with an entry that is not
|
||||
/// a non-negative integer, and a value that is not a list, with the same error as with a document
|
||||
/// open (`effect.list` and `links.findFiles` run without a document).
|
||||
|
||||
@@ -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]
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user