mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-01 21:57:12 -06:00
Fix #4926: New Symbols from Layers
This commit is contained in:
@@ -1,29 +1,45 @@
|
||||
//! Document Tools (`addon.document.*`), a built-in add-on: Save a Timestamped Copy writes a copy of
|
||||
//! the document named with the date and time through File › Save a Copy's save. Its submenu
|
||||
//! (Object › Plug-ins › Document Tools) is the UI's.
|
||||
//! the document named with the date and time through File › Save a Copy's save; New Symbols from
|
||||
//! Layers makes a symbol of each layer's art as New Symbol does. Its submenu (Object › Plug-ins ›
|
||||
//! Document Tools) is the UI's.
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::{Node, NodeId, NodeKind, SymbolRegistration};
|
||||
|
||||
use super::super::brushsym::{instance_in_place, normalize, place_xf, store_symbol};
|
||||
use super::super::fileio::{self, SaveMode};
|
||||
use super::super::{CommandSpec, bad, clock_date, cmd, date_param, has_doc, str_param};
|
||||
use super::super::{CommandSpec, bad, checked_ids_param, clock_date, cmd, date_param, has_doc, str_param, switch_or};
|
||||
use crate::{Result, Session};
|
||||
|
||||
/// The most numbers (`-2`, `-3`…) tried after a timestamped name that is taken.
|
||||
const MAX_NUMBER: u32 = 999;
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![cmd!(
|
||||
"addon.document.saveTimestampedCopy",
|
||||
"Save a Timestamped Copy",
|
||||
["Object", "Plug-ins", "Document Tools"],
|
||||
None,
|
||||
"{folder? (default: the document's own folder; needed for a document never saved), format?: vectorcraft|pdf|svg|svgz|ai|eps (default: the document's own, else vectorcraft), time?: Unix seconds (default: now)} write a copy of the document named <name>_YYYY-MM-DD_HH-MM.<ext> (the date and time in UTC; -2, -3… added when that name is taken) as File › Save a Copy writes it: the document keeps its path, title and modified state, and no undo step is made. Off where there is no clock (the web) → {path, format, bytes, warnings}",
|
||||
has_clock,
|
||||
save_timestamped_copy
|
||||
)]
|
||||
vec![
|
||||
cmd!(
|
||||
"addon.document.saveTimestampedCopy",
|
||||
"Save a Timestamped Copy",
|
||||
["Object", "Plug-ins", "Document Tools"],
|
||||
None,
|
||||
"{folder? (default: the document's own folder; needed for a document never saved), format?: vectorcraft|pdf|svg|svgz|ai|eps (default: the document's own, else vectorcraft), time?: Unix seconds (default: now)} write a copy of the document named <name>_YYYY-MM-DD_HH-MM.<ext> (the date and time in UTC; -2, -3… added when that name is taken) as File › Save a Copy writes it: the document keeps its path, title and modified state, and no undo step is made. Off where there is no clock (the web) → {path, format, bytes, warnings}",
|
||||
has_clock,
|
||||
save_timestamped_copy
|
||||
),
|
||||
cmd!(
|
||||
"addon.document.symbolsFromLayers",
|
||||
"New Symbols from Layers",
|
||||
["Object", "Plug-ins", "Document Tools"],
|
||||
None,
|
||||
"{layers?: [layer id…] (top-level layers; default: those highlighted in the Layers panel when two or more are, else every one), replace?: bool (false: the art stays; true: each layer's art is replaced by an instance of its symbol in the same place)} make one symbol of each layer's art as symbol.new makes one (registration in the centre), named after the layer (with a number added when a symbol has that name), as one undo step. Layers with no art are skipped, and with replace so are locked layers; with replace the instances are selected → {symbols: [name…], ids: [instance id…] (with replace), skipped: [{layer, name, reason: \"empty\"|\"locked\"}…]}",
|
||||
has_doc,
|
||||
symbols_from_layers
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
/// A document is open and the time can be told.
|
||||
@@ -71,3 +87,80 @@ fn save_timestamped_copy(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
.ok_or_else(|| bad(C, "every numbered name for the copy is taken"))?;
|
||||
fileio::save_document(s, SaveMode::Copy, &json!({ "path": path, "format": format.id }))
|
||||
}
|
||||
|
||||
/// `n` with each layer in it, and itself, made a group of the same art and clipping (a symbol holds
|
||||
/// no layers).
|
||||
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 };
|
||||
}
|
||||
if let Some(children) = n.children_mut() {
|
||||
for c in children.iter_mut().filter(|c| matches!(c.kind, NodeKind::Layer { .. })) {
|
||||
*c = Arc::new(without_layers(c));
|
||||
}
|
||||
}
|
||||
n
|
||||
}
|
||||
|
||||
fn symbols_from_layers(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "addon.document.symbolsFromLayers";
|
||||
let replace = switch_or(p, "replace", false, C)?;
|
||||
let st = s.doc()?;
|
||||
let d = &st.doc;
|
||||
let layers: Vec<NodeId> = match p.get("layers").filter(|v| !v.is_null()) {
|
||||
// The layers highlighted in the Layers panel when two or more are (one is mostly just the
|
||||
// current layer), else every one.
|
||||
None => {
|
||||
let rows = st.highlighted_rows();
|
||||
let picked: Vec<NodeId> = d.layers.iter().map(|l| l.id).filter(|id| rows.contains(id)).collect();
|
||||
if picked.len() > 1 { picked } else { d.layers.iter().map(|l| l.id).collect() }
|
||||
}
|
||||
Some(_) => {
|
||||
let ids = checked_ids_param(s, p, "layers", C)?;
|
||||
if let Some(NodeId(id)) = ids.iter().find(|id| !d.layers.iter().any(|l| l.id == **id)) {
|
||||
return Err(bad(C, format!("{id} is no top-level layer")));
|
||||
}
|
||||
d.layers.iter().map(|l| l.id).filter(|id| ids.contains(id)).collect()
|
||||
}
|
||||
};
|
||||
// Each layer with art: its id and name, the art's roots, and the art ready for a symbol.
|
||||
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() {
|
||||
[] => None,
|
||||
[one] => Some(without_layers(one)),
|
||||
many => Some(without_layers(&Node::group(NodeId(0), many.to_vec()))),
|
||||
};
|
||||
let reason = if replace && layer.locked { Some("locked") } else { None };
|
||||
let ready = art.and_then(|mut art| {
|
||||
crate::cmd::threads::keep_shown_text(d, &mut art);
|
||||
normalize(art).ok()
|
||||
});
|
||||
match (reason, ready) {
|
||||
(None, Some(ready)) => made.push((name, roots, ready)),
|
||||
(reason, _) => skipped.push(json!({ "layer": layer.id.0, "name": name, "reason": reason.unwrap_or("empty") })),
|
||||
}
|
||||
}
|
||||
if made.is_empty() {
|
||||
return Ok(json!({ "symbols": [], "ids": [], "skipped": skipped }));
|
||||
}
|
||||
let (names, ids) = s.edit("New Symbols from Layers", |d, sel| {
|
||||
let (mut names, mut ids) = (vec![], vec![]);
|
||||
for (name, roots, (art, size, center)) in &made {
|
||||
let name = d.unique_symbol_name(name);
|
||||
store_symbol(d, art, &name, *size);
|
||||
if replace {
|
||||
ids.push(instance_in_place(d, roots, &name, place_xf(*center, *size, SymbolRegistration::CENTER))?);
|
||||
}
|
||||
names.push(name);
|
||||
}
|
||||
if replace {
|
||||
sel.set(ids.iter().copied());
|
||||
}
|
||||
Ok((names, ids))
|
||||
})?;
|
||||
Ok(json!({ "symbols": names, "ids": ids.iter().map(|i| i.0).collect::<Vec<_>>(), "skipped": skipped }))
|
||||
}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
//! Document Tools' commands: the copy's name and place, the document left as it was, and bad
|
||||
//! params refused.
|
||||
//! Document Tools' commands: the timestamped copy's name and place with the document left as it
|
||||
//! was, symbols made of layers, and bad params refused.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::{NodeId, NodeKind};
|
||||
|
||||
use crate::Session;
|
||||
|
||||
@@ -96,3 +97,100 @@ fn bad_params_are_refused_and_writes_stay_inside_the_automation_roots() {
|
||||
fn enabled_without_document() -> bool {
|
||||
Session::new().commands().into_iter().find(|c| c.id == "addon.document.saveTimestampedCopy").unwrap().enabled
|
||||
}
|
||||
|
||||
fn layer_ids(s: &Session) -> Vec<u64> {
|
||||
s.doc().unwrap().doc.layers.iter().map(|l| l.id.0).collect()
|
||||
}
|
||||
|
||||
fn symbol_names(s: &Session) -> Vec<String> {
|
||||
s.doc().unwrap().doc.symbols.iter().map(|x| x.name.clone()).collect()
|
||||
}
|
||||
|
||||
/// A document of three layers: `Layer 1` with two rectangles, an empty one, and `Icons` with one
|
||||
/// ellipse.
|
||||
fn layered() -> Session {
|
||||
let mut s = session();
|
||||
s.execute("shape.rectangle", &json!({"x": 200, "y": 10, "width": 50, "height": 50})).unwrap();
|
||||
s.execute("layer.new", &json!({})).unwrap();
|
||||
s.execute("layer.new", &json!({"name": "Icons"})).unwrap();
|
||||
s.execute("shape.ellipse", &json!({"x": 0, "y": 100, "width": 40, "height": 20})).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_symbol_is_made_of_each_layer_and_empty_layers_are_reported() {
|
||||
let mut s = layered();
|
||||
let before = symbol_names(&s).len();
|
||||
let n = s.doc().unwrap().history.undo.len();
|
||||
let r = s.execute("addon.document.symbolsFromLayers", &json!({})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), n + 1, "one undo step");
|
||||
let names: Vec<String> = serde_json::from_value(r["symbols"].clone()).unwrap();
|
||||
assert_eq!(names.len(), 2, "{r}");
|
||||
assert!(names.contains(&"Icons".to_string()));
|
||||
assert_eq!(symbol_names(&s).len(), before + 2);
|
||||
assert_eq!(r["skipped"].as_array().unwrap().len(), 1, "the empty layer: {r}");
|
||||
assert_eq!(r["skipped"][0]["reason"], json!("empty"));
|
||||
assert_eq!(r["ids"], json!([]), "without replace the art stays");
|
||||
assert_eq!(s.doc().unwrap().doc.layers[0].children().unwrap().len(), 2);
|
||||
// Again: the names are taken, so they get numbers.
|
||||
let r = s.execute("addon.document.symbolsFromLayers", &json!({})).unwrap();
|
||||
assert!(r["symbols"].as_array().unwrap().contains(&json!("Icons 2")), "{r}");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(symbol_names(&s).len(), before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replace_puts_an_instance_where_the_layers_art_was() {
|
||||
let mut s = layered();
|
||||
let first = layer_ids(&s)[0];
|
||||
let bounds = |s: &Session, id: NodeId| s.doc().unwrap().doc.bounds_of(&[id], false).unwrap();
|
||||
let layer = NodeId(first);
|
||||
let art = bounds(&s, layer);
|
||||
let r = s.execute("addon.document.symbolsFromLayers", &json!({"layers": [first], "replace": true})).unwrap();
|
||||
let ids = r["ids"].as_array().unwrap();
|
||||
assert_eq!(ids.len(), 1);
|
||||
let inst = NodeId(ids[0].as_u64().unwrap());
|
||||
let st = s.doc().unwrap();
|
||||
let kids = st.doc.layers[0].children().unwrap();
|
||||
assert_eq!(kids.len(), 1);
|
||||
assert!(matches!(&kids[0].kind, NodeKind::SymbolInstance { .. }));
|
||||
assert_eq!(st.selection.objects, [inst]);
|
||||
let now = bounds(&s, layer);
|
||||
assert!((now.x0 - art.x0).abs() < 1e-6 && (now.y1 - art.y1).abs() < 1e-6 && (now.width() - art.width()).abs() < 1e-6, "{art:?} {now:?}");
|
||||
assert_eq!(s.doc().unwrap().doc.layers.len(), 3, "the other layers untouched");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn locked_layers_keep_their_art_and_bad_layers_are_refused() {
|
||||
let mut s = layered();
|
||||
let ids = layer_ids(&s);
|
||||
s.execute("layer.setProps", &json!({"id": ids[2], "locked": true})).unwrap();
|
||||
let r = s.execute("addon.document.symbolsFromLayers", &json!({"replace": true})).unwrap();
|
||||
let skipped: Vec<&str> = r["skipped"].as_array().unwrap().iter().map(|k| k["reason"].as_str().unwrap()).collect();
|
||||
assert_eq!(skipped, ["empty", "locked"]);
|
||||
let before = (s.doc().unwrap().doc.clone(), s.doc().unwrap().history.undo.len());
|
||||
let rect = s.doc().unwrap().doc.layers[0].children().unwrap()[0].id.0;
|
||||
for p in [json!({"layers": [rect]}), json!({"layers": [u64::MAX]}), json!({"layers": "all"}), json!({"layers": [-1]}), json!({"replace": "yes"})]
|
||||
{
|
||||
assert!(s.execute("addon.document.symbolsFromLayers", &p).is_err(), "{p}");
|
||||
}
|
||||
assert!(*s.doc().unwrap().doc == *before.0 && s.doc().unwrap().history.undo.len() == before.1);
|
||||
// Only an empty layer: nothing made, no step.
|
||||
let r = s.execute("addon.document.symbolsFromLayers", &json!({"layers": [ids[1]]})).unwrap();
|
||||
assert_eq!((r["symbols"].clone(), s.doc().unwrap().history.undo.len()), (json!([]), before.1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_layers_highlighted_in_the_layers_panel_are_the_default() {
|
||||
let mut s = layered();
|
||||
let ids = layer_ids(&s);
|
||||
// One highlighted row is just the current layer: every layer.
|
||||
s.execute("layer.setCurrent", &json!({"id": ids[0]})).unwrap();
|
||||
let r = s.execute("addon.document.symbolsFromLayers", &json!({})).unwrap();
|
||||
assert_eq!(r["symbols"].as_array().unwrap().len(), 2);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
s.execute("layer.highlight", &json!({"ids": [ids[1], ids[2]]})).unwrap();
|
||||
let r = s.execute("addon.document.symbolsFromLayers", &json!({})).unwrap();
|
||||
assert_eq!((r["symbols"].clone(), r["skipped"].as_array().unwrap().len()), (json!(["Icons"]), 1));
|
||||
}
|
||||
|
||||
@@ -32,7 +32,7 @@ use brushes::{
|
||||
brush_apply, brush_delete, brush_duplicate, brush_freehand, brush_get, brush_list, brush_move, brush_new, brush_options, brush_remove,
|
||||
brush_selection_art, brush_set_current, brush_unused, expand_brush,
|
||||
};
|
||||
pub(crate) use symbols::{natural_size, normalize, place_xf, set_natural_size};
|
||||
pub(crate) use symbols::{instance_in_place, natural_size, normalize, place_xf, set_natural_size, store_symbol};
|
||||
use symbols::{
|
||||
symbol_adjust, symbol_break_link, symbol_delete, symbol_duplicate, symbol_edit, symbol_list, symbol_new, symbol_options, symbol_place,
|
||||
symbol_replace, symbol_select_instances, symbol_set_current, symbol_spray, symbol_update,
|
||||
|
||||
@@ -134,6 +134,25 @@ pub(super) fn symbol_list(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
Ok(json!({ "symbols": list, "current": current_symbol(d).or_else(|| symbol_names(d).into_iter().next()) }))
|
||||
}
|
||||
|
||||
/// Store `art` ([`normalize`]d, of natural size `size`) as symbol `name`, which becomes the
|
||||
/// current symbol.
|
||||
pub(crate) fn store_symbol(d: &mut Document, art: &Node, name: &str, size: (f64, f64)) {
|
||||
let art = d.reid(art);
|
||||
d.symbols.push(Symbol { name: name.into(), art: Arc::new(art) });
|
||||
set_natural_size(d, name, Some(size));
|
||||
d.unknown.insert(CURRENT_SYMBOL.into(), json!(name));
|
||||
}
|
||||
|
||||
/// Replace the root nodes `ids` with one instance of symbol `name` (already stored) placed by
|
||||
/// `xf`. Frames of threaded text among them leave their threads with the text they show. Returns
|
||||
/// the instance's id.
|
||||
pub(crate) fn instance_in_place(d: &mut Document, ids: &[NodeId], name: &str, xf: Affine) -> Result<NodeId> {
|
||||
let threaded = crate::cmd::threads::frames(d);
|
||||
let id = replace_with_instance(d, ids, name, xf)?;
|
||||
crate::cmd::threads::leave_threads(d, &threaded);
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
/// Replace the root nodes `ids` with one instance of `name` (art already stored). Returns the id.
|
||||
fn replace_with_instance(d: &mut Document, ids: &[NodeId], name: &str, xf: Affine) -> Result<NodeId> {
|
||||
let top = *ids.last().ok_or_else(|| EngineError::Other("nothing selected".into()))?;
|
||||
@@ -163,17 +182,11 @@ pub(super) fn symbol_new(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
// Keep the art where it was: the geometric centre stays at `c`, whatever the registration is.
|
||||
let place = place_xf(c + reg.offset_from_center(size.0, size.1), size, reg);
|
||||
let id = s.edit("New Symbol", |d, sel| {
|
||||
let art = d.reid(&art);
|
||||
d.symbols.push(Symbol { name: name.clone(), art: Arc::new(art) });
|
||||
set_natural_size(d, &name, Some(size));
|
||||
store_symbol(d, &art, &name, size);
|
||||
d.set_symbol_registration(&name, reg);
|
||||
d.set_symbol_export_type(&name, export);
|
||||
d.set_symbol_nine_slice(&name, nine);
|
||||
d.unknown.insert(CURRENT_SYMBOL.into(), json!(name));
|
||||
let threaded = crate::cmd::threads::frames(d);
|
||||
let id = replace_with_instance(d, &ids, &name, place)?;
|
||||
// Frames of threaded text in the symbol leave their threads with the text they show.
|
||||
crate::cmd::threads::leave_threads(d, &threaded);
|
||||
let id = instance_in_place(d, &ids, &name, place)?;
|
||||
sel.set([id]);
|
||||
Ok(id)
|
||||
})?;
|
||||
@@ -370,10 +383,7 @@ pub(super) fn symbol_update(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
}
|
||||
let reg = d.symbol_registration(&name);
|
||||
let threaded = crate::cmd::threads::frames(d);
|
||||
let id = replace_with_instance(d, &ids, &name, place_xf(c + reg.offset_from_center(size.0, size.1), size, reg))?;
|
||||
// Frames of threaded text in the symbol leave their threads with the text they show.
|
||||
crate::cmd::threads::leave_threads(d, &threaded);
|
||||
let id = instance_in_place(d, &ids, &name, place_xf(c + reg.offset_from_center(size.0, size.1), size, reg))?;
|
||||
sel.set([id]);
|
||||
Ok(id)
|
||||
})?;
|
||||
|
||||
@@ -595,6 +595,9 @@ fn structured_junk() {
|
||||
// Refused before anything is written: no format of that name, a time that is no time.
|
||||
("addon.document.saveTimestampedCopy", json!({"format": "png", "folder": 7})),
|
||||
("addon.document.saveTimestampedCopy", json!({"format": "svg", "time": [1e308], "folder": null})),
|
||||
("addon.document.symbolsFromLayers", json!({"layers": "x", "replace": 3})),
|
||||
("addon.document.symbolsFromLayers", json!({"layers": [1e308, -1, null, u64::MAX], "replace": true})),
|
||||
("addon.document.symbolsFromLayers", json!({"layers": [], "replace": true})),
|
||||
// Smart Pen add-on.
|
||||
("addon.pen.append", json!({"id": 2, "x": 1e308, "y": 0, "in": [1e308, -1e308]})),
|
||||
("addon.pen.append", json!({"id": 2, "subpath": u64::MAX, "x": 0, "y": 0, "segment": "curved"})),
|
||||
|
||||
Reference in New Issue
Block a user