From 9a1ac9dad32dbb7a5a48a977080a26abb60b0c01 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Mon, 2 Nov 2026 20:38:22 -0500 Subject: [PATCH] Brush and symbol libraries: look definitions up by name Making a brush or symbol library, and gathering the resources copied art uses, found each referenced swatch, symbol, pattern, graphic style, text style and brush by scanning all the definitions of its kind, and checked library membership and panel order by scanning the lists again, so a library or document of N definitions cost N squared comparisons. Each kind is now indexed by name the first time a reference to it is followed, and the library code looks names up in sets and maps built once. --- crates/engine/src/cmd/brushlib.rs | 11 +++- crates/engine/src/cmd/clipboard.rs | 13 +++- crates/engine/src/cmd/clipboard/resources.rs | 68 ++++++++++++++++---- crates/engine/src/cmd/symbollib.rs | 41 ++++++++++-- 4 files changed, 111 insertions(+), 22 deletions(-) diff --git a/crates/engine/src/cmd/brushlib.rs b/crates/engine/src/cmd/brushlib.rs index 04498111c..8f36e16f6 100644 --- a/crates/engine/src/cmd/brushlib.rs +++ b/crates/engine/src/cmd/brushlib.rs @@ -107,10 +107,14 @@ impl BrushLibrary { /// `name`: the brushes and every resource their art uses. pub fn from_document(src: &Document, names: &[String], name: String) -> std::result::Result { let all = brush::library(src); - if let Some(n) = names.iter().find(|n| !all.iter().any(|b| b.name == **n)) { + // Names looked up in sets: scanning the lists for each brush made a library of N brushes + // cost N² comparisons. + let have: HashSet<&str> = all.iter().map(|b| b.name.as_str()).collect(); + if let Some(n) = names.iter().find(|n| !have.contains(n.as_str())) { return Err(format!("no brush `{n}`")); } - let picked = all.iter().filter(|b| names.is_empty() || names.contains(&b.name)); + let wanted: HashSet<&str> = names.iter().map(String::as_str).collect(); + let picked = all.iter().filter(|b| names.is_empty() || wanted.contains(b.name.as_str())); let clip = Clipboard::gather(src, picked.map(|b| stroke(&b.name)).collect()); let mut doc = clip.to_document(); for l in &mut doc.layers { @@ -120,7 +124,8 @@ impl BrushLibrary { } // The brushes picked and those their art paints with, in the panel order of the document // they came from. - let brushes: Vec = all.into_iter().filter(|b| clip.brushes.iter().any(|c| c.name == b.name)).collect(); + let kept: HashSet<&str> = clip.brushes.iter().map(|c| c.name.as_str()).collect(); + let brushes: Vec = all.iter().filter(|b| kept.contains(b.name.as_str())).cloned().collect(); brush::store(&mut doc, &brushes); doc.title = name.clone(); Ok(Self { name, brushes, doc }) diff --git a/crates/engine/src/cmd/clipboard.rs b/crates/engine/src/cmd/clipboard.rs index ec2722197..29933309e 100644 --- a/crates/engine/src/cmd/clipboard.rs +++ b/crates/engine/src/cmd/clipboard.rs @@ -161,10 +161,19 @@ impl Clipboard { d.para_styles = self.para_styles.clone().into(); if !self.brushes.is_empty() { let mut lib = vectorcraft_brush::library(&d); + // Each brush's place in the library by name: finding it by scanning the growing list + // made a library of N brushes cost N² comparisons. + let mut at: std::collections::HashMap = std::collections::HashMap::new(); + for (i, b) in lib.iter().enumerate() { + at.entry(b.name.clone()).or_insert(i); + } for b in &self.brushes { - match lib.iter_mut().find(|x| x.name == b.name) { + match at.get(&b.name).and_then(|&i| lib.get_mut(i)) { Some(x) => *x = b.clone(), - None => lib.push(b.clone()), + None => { + at.insert(b.name.clone(), lib.len()); + lib.push(b.clone()); + } } } vectorcraft_brush::store(&mut d, &lib); diff --git a/crates/engine/src/cmd/clipboard/resources.rs b/crates/engine/src/cmd/clipboard/resources.rs index e6bd96371..6a436ab31 100644 --- a/crates/engine/src/cmd/clipboard/resources.rs +++ b/crates/engine/src/cmd/clipboard/resources.rs @@ -14,7 +14,7 @@ //! uses its resources as they are now. use std::cell::OnceCell; -use std::collections::{BTreeMap, BTreeSet}; +use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet}; use std::sync::Arc; use serde_json::{Map, Value, json}; @@ -149,6 +149,39 @@ struct Defs<'a> { char_styles: &'a [TextStyleDef], para_styles: &'a [TextStyleDef], brushes: Brushes<'a>, + /// Where each name is defined, built the first time a kind is followed. + index: DefIndex, +} + +/// The positions of a kind's definitions by name. +type NameIndex = HashMap>; + +/// [`Defs`]' definitions by name (graphic styles by id), each kind indexed the first time a +/// reference to it is followed: finding a name's definitions by scanning them all made a +/// document or library of N definitions that use each other cost N² comparisons. +#[derive(Default)] +struct DefIndex { + swatches: OnceCell, + symbols: OnceCell, + patterns: OnceCell, + styles: OnceCell>>, + char_styles: OnceCell, + para_styles: OnceCell, + brushes: OnceCell, +} + +/// The items of `list` named `name`, in order, `index` holding their positions (built from +/// `list` the first time). +fn named<'l, T>(index: &OnceCell, list: &'l [T], name_of: impl Fn(&T) -> &str, name: &str) -> impl Iterator { + let index = index.get_or_init(|| { + let mut by: NameIndex = HashMap::new(); + for (i, item) in list.iter().enumerate() { + by.entry(name_of(item).to_string()).or_default().push(i); + } + by + }); + let at: &[usize] = index.get(name).map_or(&[], Vec::as_slice); + at.iter().filter_map(move |&i| list.get(i)) } /// The brushes references lead to: a document's library, read the first time a brush is used @@ -178,6 +211,7 @@ impl<'a> Defs<'a> { char_styles: &d.char_styles, para_styles: &d.para_styles, brushes: Brushes::Lazy(d, brushes), + index: DefIndex::default(), } } } @@ -219,8 +253,16 @@ impl Used { fn note<'a>(&mut self, defs: &Defs<'a>, r: Ref<'_>, todo: &mut Vec>) { let (res, name) = match r { Ref::Style(id) => { - if self.styles.insert(id) { - todo.extend(defs.styles.iter().filter(|g| g.id != 0 && g.id == id).map(|g| Source::Appearance(&g.appearance))); + if self.styles.insert(id) && id != 0 { + let index = defs.index.styles.get_or_init(|| { + let mut by: HashMap> = HashMap::new(); + for (i, g) in defs.styles.iter().enumerate() { + by.entry(g.id).or_default().push(i); + } + by + }); + let at = index.get(&id).map_or(&[][..], Vec::as_slice); + todo.extend(at.iter().filter_map(|&i| defs.styles.get(i)).map(|g| Source::Appearance(&g.appearance))); } return; } @@ -237,15 +279,16 @@ impl Used { return; } set.insert(name.to_string()); + let ix = &defs.index; match res { // A tint swatch's base, a gradient swatch's linked stops. - Res::Swatch => todo.extend(defs.swatches.iter().filter(|w| w.name == name).map(|w| Source::Paint(&w.paint))), - Res::Symbol => todo.extend(defs.symbols.iter().filter(|s| s.name == name).map(|s| Source::Node(&s.art))), - Res::Pattern => todo.extend(defs.patterns.iter().filter(|p| p.name == name).flat_map(|p| p.art.iter().map(|a| Source::Node(a)))), - Res::CharStyle => todo.extend(defs.char_styles.iter().filter(|s| s.name == name).map(|s| Source::Attrs(&s.attrs))), - Res::ParaStyle => todo.extend(defs.para_styles.iter().filter(|s| s.name == name).map(|s| Source::Attrs(&s.attrs))), + Res::Swatch => todo.extend(named(&ix.swatches, &defs.swatches, |w| &w.name, name).map(|w| Source::Paint(&w.paint))), + Res::Symbol => todo.extend(named(&ix.symbols, defs.symbols, |s| &s.name, name).map(|s| Source::Node(&s.art))), + Res::Pattern => todo.extend(named(&ix.patterns, defs.patterns, |p| &p.name, name).flat_map(|p| p.art.iter().map(|a| Source::Node(a)))), + Res::CharStyle => todo.extend(named(&ix.char_styles, defs.char_styles, |s| &s.name, name).map(|s| Source::Attrs(&s.attrs))), + Res::ParaStyle => todo.extend(named(&ix.para_styles, defs.para_styles, |s| &s.name, name).map(|s| Source::Attrs(&s.attrs))), // A scatter or art brush's art, a pattern brush's tiles. - Res::Brush => todo.extend(defs.brushes.get().iter().filter(|b| b.name == name).flat_map(|b| b.art().into_iter().map(Source::Node))), + Res::Brush => todo.extend(named(&ix.brushes, defs.brushes.get(), |b| &b.name, name).flat_map(|b| b.art().into_iter().map(Source::Node))), _ => {} } } @@ -649,6 +692,7 @@ impl Clipboard { char_styles: &self.char_styles, para_styles: &self.para_styles, brushes: Brushes::Ready(&self.brushes), + index: DefIndex::default(), }; Used::of(&defs, &self.nodes) } @@ -671,7 +715,7 @@ impl Clipboard { self.import_swatches(d, &used, choices, same_doc, &mut out); // Brushes whose art shows no symbols or patterns, before the symbols and patterns whose art // they may brush; the others once those are in. - let mut added_brushes = self.import_brushes(d, &used, same_doc, false, &mut out); + let mut added_brushes: HashSet = self.import_brushes(d, &used, same_doc, false, &mut out).into_iter().collect(); // Character and paragraph styles (their colours relinked first). for (r, mine, theirs) in [(Res::CharStyle, &self.char_styles, &mut d.char_styles), (Res::ParaStyle, &self.para_styles, &mut d.para_styles)] { for def in mine.iter().filter(|s| used.has(r, &s.name)) { @@ -683,14 +727,14 @@ impl Clipboard { } // Symbols and patterns, compared once relinked. Those added are relinked at the end, when // every new name and graphic style id is known. - let mut added_symbols = vec![]; + let mut added_symbols = HashSet::new(); for def in self.symbols.iter().filter(|s| used.has(Res::Symbol, &s.name)) { let r = &out.relink; let p = place(&mut d.symbols, def, same_doc, |a, b| same_art(r, std::slice::from_ref(&a.art), std::slice::from_ref(&b.art))); added_symbols.extend(p.new_name(&def.name)); out.placed(Res::Symbol, &def.name, &p); } - let mut added_patterns = vec![]; + let mut added_patterns = HashSet::new(); for def in self.patterns.iter().filter(|p| used.has(Res::Pattern, &p.name)) { // Tile options alike, and the art once relinked. let bare = |p: &PatternDef| PatternDef { name: String::new(), art: vec![], ..p.clone() }; diff --git a/crates/engine/src/cmd/symbollib.rs b/crates/engine/src/cmd/symbollib.rs index 49e9f5a61..e5f2ccfe2 100644 --- a/crates/engine/src/cmd/symbollib.rs +++ b/crates/engine/src/cmd/symbollib.rs @@ -7,7 +7,7 @@ //! document VectorCraft opens. `symbol.addFromLibrary` copies symbols into the document; //! `symbol.saveLibrary` writes the document's. -use std::collections::HashSet; +use std::collections::{HashMap, HashSet}; use std::sync::{Arc, LazyLock}; use serde_json::{Value, json}; @@ -104,10 +104,17 @@ impl SymbolLibrary { /// `name`: the symbols, the symbols they nest and every other resource they use, with their /// Symbol Options. pub fn from_document(src: &Document, names: &[String], name: String) -> std::result::Result { - if let Some(n) = names.iter().find(|n| !src.symbols.iter().any(|s| s.name == **n)) { + // Each name's first place in the panel, looked up rather than scanned for: scanning the + // symbols for each one made a library of N symbols cost N² comparisons. + let mut order: HashMap<&str, usize> = HashMap::new(); + for (i, s) in src.symbols.iter().enumerate() { + order.entry(s.name.as_str()).or_insert(i); + } + if let Some(n) = names.iter().find(|n| !order.contains_key(n.as_str())) { return Err(format!("no symbol `{n}`")); } - let picked = src.symbols.iter().filter(|s| names.is_empty() || names.contains(&s.name)); + let wanted: HashSet<&str> = names.iter().map(String::as_str).collect(); + let picked = src.symbols.iter().filter(|s| names.is_empty() || wanted.contains(s.name.as_str())); let clip = Clipboard::gather(src, picked.map(|s| instance(&s.name)).collect()); let mut doc = clip.to_document(); for l in &mut doc.layers { @@ -116,8 +123,7 @@ impl SymbolLibrary { } } // The panel order of the document they came from. - let order = |n: &str| src.symbols.iter().position(|s| s.name == n); - doc.symbols.sort_by_key(|s| order(&s.name)); + doc.symbols.sort_by_key(|s| order.get(s.name.as_str()).copied()); for s in &doc.symbols.clone() { copy_options(src, &s.name, &mut doc, &s.name); } @@ -344,3 +350,28 @@ fn load(s: &mut Session, p: &Value) -> Result { })?; Ok(json!({"library": info.id, "name": info.name, "count": lib.len()})) } + +#[cfg(test)] +mod tests { + use super::*; + + /// A library made from thousands of symbols holds them all, once each, in the panel order of + /// the document they came from, a name given twice kept at its first place. + #[test] + fn many_symbols_keep_their_panel_order() { + let mut src = Document::new(100.0, 100.0); + let n = 4000; + // Names out of alphabetical order, so the order kept is the panel's. + src.symbols = (0..n).map(|i| Symbol { name: format!("s{}", (i * 7919) % n), art: Arc::new(Node::group(NodeId(i as u64 + 1), vec![])), dynamic: false }).collect(); + let lib = SymbolLibrary::from_document(&src, &[], "Many".into()).unwrap(); + let names = |d: &Document| d.symbols.iter().map(|s| s.name.clone()).collect::>(); + assert_eq!(names(&lib.doc), names(&src)); + let picked = ["s3".to_string(), "s1".to_string()]; + let lib = SymbolLibrary::from_document(&src, &picked, "Two".into()).unwrap(); + let order = |name: &str| src.symbols.iter().position(|s| s.name == name); + let mut want = vec!["s3", "s1"]; + want.sort_by_key(|n| order(n)); + assert_eq!(names(&lib.doc), want); + assert!(SymbolLibrary::from_document(&src, &["nope".to_string()], "None".into()).is_err()); + } +}