Files
vectorcraft/crates/engine/src/cmd/brushlib.rs
T

503 lines
24 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Brush libraries (Window → Brush Libraries, the Brushes panel's library button): read-only sets
//! of brushes the library panel shows. A library is a document holding the brushes with the
//! resources their art uses (patterns, symbols, swatches, graphic styles, images); saved, it is a
//! native `.vectorcraft` file, which File › Open also opens. The built-in libraries are generated
//! here ([`BUILTIN`]); User Defined ones are the `.vectorcraft` files in the user library folder
//! ([`Libraries`]), and Other Library… loads the brushes of any document VectorCraft opens.
//! `brush.addFromLibrary` copies brushes into the document; `brush.saveLibrary` writes the
//! document's.
use std::collections::HashSet;
use std::sync::{Arc, LazyLock};
use serde_json::{Value, json};
use vectorcraft_brush::{self as brush, ArtBrush, Brush, BrushKind, Calligraphic, Colorization, PatternBrush, PatternFit, Scatter, Variation};
use vectorcraft_color::{Color, Paint};
use vectorcraft_doc::{Appearance, AppearanceItem, Document, FillLayer, Node, NodeId, NodeKind, PatternDef, Symbol};
use vectorcraft_geom::{Affine, FillRule, PathData, Point, Rect, SubPath, shapes};
use super::brushsym::{apply_brush, brush_targets, roots, stroke_paint};
use super::clipboard::Clipboard;
use super::clipboard::resources::SwatchChoices;
use super::patterncmds::add_pattern;
use super::swatch::str_list;
use super::swatchlib::{FileData, LibraryFile, LibraryInfo, stem};
use super::{CommandSpec, Result, Session, always, bad, bool_or, cmd, has_doc, str_param};
/// The extension of saved brush libraries: native documents.
pub const BRUSHES_EXT: &str = vectorcraft_format::EXTENSION;
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(
query "brush.libraries",
"Brush Libraries",
[],
None,
"{} → {libraries: [{id, name, category: \"builtIn\"|\"user\" (User Defined: the .vectorcraft files in the user library folder, rescanned now)|\"loaded\" (brush.loadLibrary), count, path?}], userFolder} every brush library the library panel opens, in menu order",
always,
list
),
cmd!(
query "brush.library",
"Brush Library",
[],
None,
"{library: id or name} → {id, name, category, brushes: [{name, type}] (as brush.list)}",
always,
get
),
cmd!(
"brush.addFromLibrary",
"Add to Brushes",
["Window", "Brush Libraries"],
None,
"{library: id or name, name? | names?: [brush names in the library] (default: all of it), apply?: false (also apply the first one to paths, as brush.apply does), ids?|id? (the paths apply takes; default the selected ones), swatchConflict? (as edit.paste)} copy library brushes into the document's Brushes as one undo step (with the apply; named Apply Brush when no brush is added and it only applies one), with the resources their art uses (patterns, symbols, swatches, graphic styles, images). A brush the document already has (same name and definition) isn't added again; a name another brush has gets a number (\"Ribbon 2\"). The first one becomes the current brush → {library, names: [each brush's name in the document], added: [names added], existing: [names already there], applied: [ids of the paths it was applied to]}",
has_doc,
add
),
cmd!(
"brush.saveLibrary",
"Save Brush Library…",
["Window", "Brushes"],
None,
"{path?, names?: [brush names] (default: all), name?: library name (default: the document's), user?: false (save into the user library folder, listed under User Defined)} save the document's brushes, with the resources their art uses, as a brush library: a .vectorcraft document holding only them → {path, count, library?: id when saved to the user folder}; without path or user → {dataBase64: the file's bytes, count}",
has_doc,
save
),
cmd!(
"brush.loadLibrary",
"Other Library…",
["Window", "Brush Libraries"],
None,
"{path? | data?: file text | dataBase64?, name?: file name (default: the path's)} load the brushes of any document VectorCraft opens (see document.formats; one without brushes of its own has the default ones), a saved brush library included, for the library panel (Window → Brush Libraries lists it until the app quits; a file of the user library folder, or a file with the same bytes as one there, opens as that User Defined library) → {library: id, name, count}",
always,
load
),
]
}
/// A brush library: its name, its brushes (in panel order) and a document holding them with the
/// resources their art uses; the document has no art of its own.
#[derive(Clone, Debug)]
pub struct BrushLibrary {
pub name: String,
pub brushes: Vec<Brush>,
pub doc: Document,
}
impl Default for BrushLibrary {
fn default() -> Self {
Self { name: String::new(), brushes: vec![], doc: Document::new(100.0, 100.0) }
}
}
impl BrushLibrary {
pub fn len(&self) -> usize {
self.brushes.len()
}
pub fn is_empty(&self) -> bool {
self.brushes.is_empty()
}
pub fn brush(&self, name: &str) -> Option<&Brush> {
self.brushes.iter().find(|b| b.name == name)
}
/// Document `src`'s brushes `names` (empty: all of them, in panel order) as a library named
/// `name`: the brushes and every resource their art uses.
pub fn from_document(src: &Document, names: &[String], name: String) -> std::result::Result<Self, String> {
let all = brush::library(src);
if let Some(n) = names.iter().find(|n| !all.iter().any(|b| b.name == **n)) {
return Err(format!("no brush `{n}`"));
}
let picked = all.iter().filter(|b| names.is_empty() || names.contains(&b.name));
let clip = Clipboard::gather(src, picked.map(|b| stroke(&b.name)).collect());
let mut doc = clip.to_document();
for l in &mut doc.layers {
if let Some(ch) = Arc::make_mut(l).children_mut() {
ch.clear();
}
}
// The brushes picked and those their art paints with, in the panel order of the document
// they came from.
let brushes: Vec<Brush> = all.into_iter().filter(|b| clip.brushes.iter().any(|c| c.name == b.name)).collect();
brush::store(&mut doc, &brushes);
doc.title = name.clone();
Ok(Self { name, brushes, doc })
}
}
impl LibraryFile for BrushLibrary {
const EXTS: &'static [&'static str] = &[BRUSHES_EXT];
fn read(bytes: &[u8], stem: &str) -> std::result::Result<Self, String> {
let doc = vectorcraft_format::load(bytes).map_err(|e| e.to_string())?;
BrushLibrary::from_document(&doc, &[], stem.to_string())
}
fn name(&self) -> &str {
&self.name
}
}
/// User Defined and loaded brush libraries.
pub type Libraries = super::swatchlib::Libraries<BrushLibrary>;
/// A path stroked with brush `name` (what the resource gathering follows).
fn stroke(name: &str) -> Node {
let mut ap = Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 1.0);
if let Some(st) = ap.stroke_mut() {
st.brush = Some(name.into());
}
let line = SubPath::polyline(&[Point::new(0.0, 0.0), Point::new(100.0, 0.0)], false);
Node::path(NodeId(0), PathData::single(line), ap)
}
// ---------- built-in libraries ----------
/// A library generated in code.
pub struct BuiltinBrushLibrary {
pub id: &'static str,
pub name: &'static str,
make: fn() -> BrushLibrary,
}
/// The built-in brush libraries, in menu order: original brushes generated in code, one library
/// per kind of brush.
pub const BUILTIN: &[BuiltinBrushLibrary] = &[
BuiltinBrushLibrary { id: "art-ribbons", name: "Art Ribbons", make: art_ribbons },
BuiltinBrushLibrary { id: "calligraphic-nibs", name: "Calligraphic Nibs", make: calligraphic_nibs },
BuiltinBrushLibrary { id: "pattern-borders", name: "Pattern Borders", make: pattern_borders },
BuiltinBrushLibrary { id: "scatter-shapes", name: "Scatter Shapes", make: scatter_shapes },
];
/// Built-in library `id`, made once.
fn builtin_library(id: &str) -> Option<Arc<BrushLibrary>> {
static MADE: LazyLock<Vec<Arc<BrushLibrary>>> = LazyLock::new(|| BUILTIN.iter().map(|b| Arc::new((b.make)())).collect());
BUILTIN.iter().position(|b| b.id == id).and_then(|i| MADE.get(i).cloned())
}
/// Library `name` of `brushes`, `doc` holding the resources their art uses.
fn built(name: &str, mut doc: Document, brushes: Vec<Brush>) -> BrushLibrary {
brush::store(&mut doc, &brushes);
doc.title = name.into();
BrushLibrary { name: name.into(), brushes, doc }
}
fn filled(p: PathData, paint: Paint) -> Node {
Node::path(NodeId(0), p, Appearance { items: vec![AppearanceItem::Fill(FillLayer::new(paint))], ..Default::default() })
}
fn black(p: PathData) -> Node {
filled(p, Paint::solid(Color::BLACK))
}
fn group(children: Vec<Node>) -> Node {
Node::group(NodeId(0), children.into_iter().map(Arc::new).collect())
}
fn polygon(pts: &[(f64, f64)]) -> PathData {
let pts: Vec<Point> = pts.iter().map(|&(x, y)| Point::new(x, y)).collect();
PathData::single(SubPath::polyline(&pts, true))
}
/// A band along y = `f(x)` for x in 0..`len`, `thick` pt thick, in `n` steps.
fn band(len: f64, thick: f64, n: usize, f: impl Fn(f64) -> f64) -> PathData {
let xs: Vec<f64> = (0..=n).map(|i| len * i as f64 / n as f64).collect();
let mut pts: Vec<Point> = xs.iter().map(|&x| Point::new(x, f(x) - thick / 2.0)).collect();
pts.extend(xs.iter().rev().map(|&x| Point::new(x, f(x) + thick / 2.0)));
PathData::single(SubPath::polyline(&pts, true))
}
/// An even-odd ring of radii `outer` and `inner` around `c`.
fn ring(c: Point, outer: f64, inner: f64) -> Node {
let disc = |r: f64| shapes::ellipse(Rect::new(c.x - r, c.y - r, c.x + r, c.y + r));
let children = [disc(outer), disc(inner)].into_iter().map(|p| Arc::new(Node::path(NodeId(0), p, Appearance::default()))).collect();
let mut n = Node::new(NodeId(0), NodeKind::Compound { children, rule: FillRule::EvenOdd });
n.appearance = Appearance { items: vec![AppearanceItem::Fill(FillLayer::new(Paint::solid(Color::BLACK)))], ..Default::default() }.into();
n
}
fn art(name: &str, art: Node, colorization: Colorization) -> Brush {
Brush { name: name.into(), kind: BrushKind::Art(ArtBrush { art, colorization, ..Default::default() }) }
}
/// Art Ribbons: art brushes, 100 pt long, drawn here; Striped Ribbon is filled with the library's
/// own pattern, Pinstripes.
fn art_ribbons() -> BrushLibrary {
let mut doc = Document::new(100.0, 100.0);
let stripe = Node::path(doc.alloc_id(), shapes::rectangle(Rect::new(0.0, 0.0, 6.0, 1.5)), {
let mut a = Appearance::default();
a.items.push(AppearanceItem::Fill(FillLayer::new(Paint::solid(Color::from_hex("#2f80c9").unwrap_or(Color::BLACK)))));
a
});
add_pattern(&mut doc, PatternDef { tile: Rect::new(0.0, 0.0, 6.0, 3.0), ..PatternDef::new("Pinstripes", vec![Arc::new(stripe)]) });
let notched = polygon(&[(0.0, -3.0), (100.0, -3.0), (95.0, 0.0), (100.0, 3.0), (0.0, 3.0), (5.0, 0.0)]);
let double = group(vec![black(shapes::rectangle(Rect::new(0.0, -2.5, 100.0, -1.5))), black(shapes::rectangle(Rect::new(0.0, 1.5, 100.0, 2.5)))]);
let wave = band(100.0, 1.6, 64, |x| 2.5 * (x / 100.0 * 4.0 * std::f64::consts::TAU).sin());
// Round and thick at the start, tapering to a point.
let teardrop = {
let n = 48;
let half = |x: f64| 3.5 * (1.0 - x / 100.0).powf(0.8) * (x / 4.0).min(1.0).sqrt();
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 striped = {
let mut n =
filled(shapes::rectangle(Rect::new(0.0, -4.0, 100.0, 4.0)), Paint::Pattern { pattern: "Pinstripes".into(), xf: Default::default() });
n.appearance.set_stroke(Paint::solid(Color::from_hex("#1d4f7c").unwrap_or(Color::BLACK)));
n
};
let brushes = vec![
art("Notched Ribbon", black(notched), Colorization::Tints),
art("Double Line", double, Colorization::Tints),
art("Wave", black(wave), Colorization::Tints),
art("Teardrop", black(teardrop), Colorization::Tints),
art("Striped Ribbon", striped, Colorization::None),
];
built("Art Ribbons", doc, brushes)
}
fn nib(name: &str, angle: f64, roundness: f64, size: f64) -> Brush {
Brush { name: name.into(), kind: BrushKind::Calligraphic(Calligraphic { angle, roundness, size, ..Default::default() }) }
}
/// Calligraphic Nibs: pen nibs of several shapes; Pressure Pen's size follows the pen pressure.
fn calligraphic_nibs() -> BrushLibrary {
let pressure = Brush {
name: "Pressure Pen".into(),
kind: BrushKind::Calligraphic(Calligraphic {
angle: 30.0,
roundness: 60.0,
size: 5.0,
variation: [0.0, 0.0, 4.0],
modes: Some([Variation::Fixed, Variation::Fixed, Variation::Pressure]),
}),
};
let brushes = vec![
nib("Fine Nib", 45.0, 25.0, 2.0),
nib("Broad Nib", 45.0, 15.0, 9.0),
nib("Chisel", 0.0, 8.0, 6.0),
nib("Round Pen", 0.0, 100.0, 4.0),
pressure,
];
built("Calligraphic Nibs", Document::new(100.0, 100.0), brushes)
}
fn border(name: &str, side: Node, corner: Option<Node>, fit: PatternFit) -> Brush {
let kind = BrushKind::Pattern(PatternBrush { side, outer_corner: corner.clone(), inner_corner: corner, fit, ..Default::default() });
Brush { name: name.into(), kind }
}
/// An invisible rectangle that fixes a tile's width.
fn spacer(r: Rect) -> Node {
Node::path(NodeId(0), shapes::rectangle(r), Appearance::default())
}
/// Pattern Borders: pattern brushes of tiles drawn here; Beads has corner tiles.
fn pattern_borders() -> BrushLibrary {
let zigzag = black(polygon(&[(0.0, 1.4), (5.0, -2.6), (10.0, 1.4), (10.0, 2.6), (5.0, -1.4), (0.0, 2.6)]));
let beads = group(vec![black(shapes::rectangle(Rect::new(0.0, -0.3, 8.0, 0.3))), black(shapes::ellipse(Rect::new(1.5, -2.5, 6.5, 2.5)))]);
let dash_dot = group(vec![
spacer(Rect::new(0.0, -1.0, 12.0, 1.0)),
black(shapes::rounded_rectangle(Rect::new(0.5, -0.75, 6.5, 0.75), 0.75)),
black(shapes::ellipse(Rect::new(8.5, -1.0, 10.5, 1.0))),
]);
let scallops = black(band(10.0, 1.0, 20, |x| -3.0 * (x / 10.0 * std::f64::consts::PI).sin()));
let brushes = vec![
border("Zigzag", zigzag, None, PatternFit::Stretch),
border("Beads", beads, Some(ring(Point::ZERO, 3.5, 1.5)), PatternFit::Stretch),
border("Dash Dot", dash_dot, None, PatternFit::AddSpace),
border("Scallops", scallops, None, PatternFit::Stretch),
];
built("Pattern Borders", Document::new(100.0, 100.0), brushes)
}
fn scatter(name: &str, art: Node, s: Scatter) -> Brush {
Brush { name: name.into(), kind: BrushKind::Scatter(Scatter { art, ..s }) }
}
/// Scatter Shapes: scatter brushes of shapes drawn here; Sparkles scatters the library's own
/// symbol, Sparkle.
fn scatter_shapes() -> BrushLibrary {
let mut doc = Document::new(100.0, 100.0);
// A four-pointed gold sparkle in the ±10 pt box symbol instances assume.
let points: Vec<Point> = (0..8)
.map(|i| {
let r = if i % 2 == 0 { 10.0 } else { 2.5 };
let a = std::f64::consts::FRAC_PI_4 * i as f64;
Point::new(r * a.cos(), r * a.sin())
})
.collect();
let gold = Color::from_hex("#e0a526").unwrap_or(Color::BLACK);
let sparkle = Node::path(doc.alloc_id(), PathData::single(SubPath::polyline(&points, true)), {
let mut a = Appearance::default();
a.items.push(AppearanceItem::Fill(FillLayer::new(Paint::solid(gold))));
a
});
doc.symbols.push(Symbol { name: "Sparkle".into(), art: Arc::new(sparkle) });
let instance = Node::new(NodeId(0), NodeKind::SymbolInstance { symbol: "Sparkle".into(), xf: Affine::scale(0.3) });
let random = |a: f64, b: f64| (a, b);
let base = Scatter { colorization: Colorization::Tints, ..Default::default() };
let brushes = vec![
scatter(
"Stars",
black(shapes::star(Point::ZERO, 4.0, 1.7, 5, 0.0)),
Scatter {
size: random(70.0, 130.0),
spacing: random(150.0, 220.0),
scatter: random(-80.0, 80.0),
rotation: random(-180.0, 180.0),
..base.clone()
},
),
scatter("Rings", ring(Point::ZERO, 2.5, 1.5), Scatter { spacing: (140.0, 140.0), ..base.clone() }),
scatter(
"Triangles",
black(shapes::polygon(Point::ZERO, 3.0, 3, 0.0)),
Scatter { spacing: (120.0, 120.0), rotation: (90.0, 90.0), rotation_relative_to_path: true, ..base.clone() },
),
scatter(
"Sparkles",
instance,
Scatter {
size: random(60.0, 140.0),
spacing: random(110.0, 180.0),
scatter: random(-60.0, 60.0),
rotation: random(-45.0, 45.0),
colorization: Colorization::None,
..base
},
),
];
built("Scatter Shapes", doc, brushes)
}
// ---------- commands ----------
fn builtin_info(b: &BuiltinBrushLibrary) -> LibraryInfo {
LibraryInfo { id: b.id.into(), name: b.name.into(), category: "builtIn" }
}
/// Every brush library, in menu order: built-in, User Defined, loaded.
pub fn libraries(s: &Session) -> Vec<LibraryInfo> {
BUILTIN.iter().map(builtin_info).chain(s.brush_libraries.infos().cloned()).collect()
}
/// Library `key` (an id, or a name in any case) with its info.
pub fn library(s: &Session, key: &str) -> Option<(LibraryInfo, Arc<BrushLibrary>)> {
if let Some(found) = s.brush_libraries.get(key) {
return Some(found);
}
if let Some(b) = BUILTIN.iter().find(|b| b.id == key) {
return Some((builtin_info(b), builtin_library(key)?));
}
let id = libraries(s).into_iter().find(|l| l.name.eq_ignore_ascii_case(key))?.id;
library(s, &id)
}
fn library_param(s: &Session, p: &Value, cmd: &str) -> Result<(LibraryInfo, Arc<BrushLibrary>)> {
let key = str_param(p, "library").ok_or_else(|| bad(cmd, "missing `library` (see brush.libraries)"))?;
library(s, key).ok_or_else(|| bad(cmd, format!("no brush library `{key}` (see brush.libraries)")))
}
fn list(s: &mut Session, _: &Value) -> Result<Value> {
s.brush_libraries.rescan();
let libs: Vec<Value> = libraries(s)
.into_iter()
.map(|l| {
let count = library(s, &l.id).map_or(0, |(_, lib)| lib.len());
json!({"id": l.id, "name": l.name, "category": l.category, "count": count, "path": s.brush_libraries.path(&l.id)})
})
.collect();
Ok(json!({ "libraries": libs, "userFolder": s.brush_libraries.user_dir() }))
}
fn get(s: &mut Session, p: &Value) -> Result<Value> {
let (info, lib) = library_param(s, p, "brush.library")?;
let brushes: Vec<Value> = lib.brushes.iter().map(|b| json!({"name": b.name, "type": b.kind.type_id()})).collect();
Ok(json!({"id": info.id, "name": info.name, "category": info.category, "brushes": brushes}))
}
fn add(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "brush.addFromLibrary";
let (info, lib) = library_param(s, p, C)?;
let mut names = str_list(p, "names");
names.extend(str_param(p, "name").map(str::to_string));
if let Some(n) = names.iter().find(|n| lib.brush(n).is_none()) {
return Err(bad(C, format!("no brush `{n}` in `{}`", lib.name)));
}
let mut picked: Vec<String> = if names.is_empty() { lib.brushes.iter().map(|b| b.name.clone()).collect() } else { names };
// Each once, in the order asked for.
let mut seen = HashSet::new();
picked.retain(|n| seen.insert(n.clone()));
let apply = bool_or(p, "apply", false);
if apply && picked.is_empty() {
return Err(bad(C, format!("`{}` has no brushes to apply", lib.name)));
}
let choices = SwatchChoices::parse(C, p)?;
let clip = Clipboard::gather(&lib.doc, picked.iter().map(|n| stroke(n)).collect());
// The typing `s.edit` would settle first is settled before the probe below, so the document
// the edit installs is the one it starts from.
if !s.previewing && super::typing_in_progress(s) {
super::settle_interaction(s)?;
}
let st = s.doc()?;
let before: HashSet<String> = brush::library(&st.doc).into_iter().map(|b| b.name).collect();
// A library brush's name in the document: its own, or the one the import gave it.
let target = |renamed: &[Value], n: &str| -> String {
let to = renamed.iter().find(|r| r["kind"] == "brush" && r["from"] == n).and_then(|r| r["to"].as_str());
to.unwrap_or(n).to_string()
};
// Where the brushes land, worked out on a copy that the edit then installs: nothing to add and
// nothing to apply is no edit, and a click that only applies brushes the document has is
// named for that.
let mut probe = (*st.doc).clone();
let imported = clip.import_into(&mut probe, &choices, false);
let doc_names: Vec<String> = picked.iter().map(|n| target(&imported.renamed, n)).collect();
let paths = if apply { brush_targets(&st.doc, &roots(s, p)?) } else { vec![] };
let paint = stroke_paint(s);
if imported.added > 0 || !paths.is_empty() {
let label = if imported.added > 0 { "Add to Brushes" } else { "Apply Brush" };
s.edit(label, |d, _| {
*d = probe;
if let Some(first) = doc_names.first() {
d.unknown.insert(brush::CURRENT_KEY.into(), json!(first));
apply_brush(d, &paths, first, &paint);
}
Ok(())
})?;
} else if let Some(first) = doc_names.first() {
s.execute("brush.setCurrent", &json!({ "name": first }))?;
}
let (added, existing): (Vec<&String>, Vec<&String>) = doc_names.iter().partition(|n| !before.contains(*n));
let applied: Vec<u64> = paths.iter().map(|id| id.0).collect();
Ok(json!({"library": info.id, "names": doc_names, "added": added, "existing": existing, "applied": applied}))
}
fn save(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "brush.saveLibrary";
let st = s.doc()?;
let name = str_param(p, "name").map(str::trim).filter(|n| !n.is_empty()).map_or_else(|| stem(&st.title()).to_string(), str::to_string);
let lib = BrushLibrary::from_document(&st.doc, &str_list(p, "names"), name).map_err(|e| bad(C, e))?;
if lib.is_empty() {
return Err(bad(C, "the document has no brushes to save"));
}
let mut out = json!({ "count": lib.len() });
let bytes = vectorcraft_format::save_file(&lib.doc);
s.brush_libraries.write(p, &lib.name, BRUSHES_EXT, FileData::Binary(bytes), &mut out, C)?;
Ok(out)
}
fn load(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "brush.loadLibrary";
let (info, lib) = s.brush_libraries.load(p, C, |bytes, file| {
let loaded = super::fileio::load(file, bytes).map_err(|e| bad(C, e.to_string()))?;
if loaded.preview_only {
return Err(bad(C, "only this Affinity file's embedded preview could be read; use File › Open to see it with its warning"));
}
BrushLibrary::from_document(&loaded.doc, &[], stem(file).to_string()).map_err(|e| bad(C, e))
})?;
Ok(json!({"library": info.id, "name": info.name, "count": lib.len()}))
}