From 3c463fa5fb03f1f7cb0c381f268b2794b5195a36 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 13 Nov 2026 08:34:40 -0800 Subject: [PATCH 1/4] Brushes: strokes draw with the first of two brushes with one name When a document holds two brushes with the same name, the canvas drew a stroke that names it with the last of them, while brush.get, Brush Options, expanding, outlining and the SVG, PDF, EPS and .ai exports took the first. Document Info gave the last one's type, and every row of that name in the Brushes panel previewed and dragged the last one. The renderer, Document Info and the Brushes panel now take the first brush of a name, so the canvas, the panel and the exports agree. --- crates/engine/src/cmd/docinfo.rs | 11 ++++++---- crates/render/src/brush_fx.rs | 25 +++++++++++++++++++++- crates/ui-egui/src/panels/brushes.rs | 12 ++++++++++- crates/ui-egui/src/panels/brushes/tests.rs | 24 +++++++++++++++++++++ docs/target-app-parity.md | 2 ++ 5 files changed, 68 insertions(+), 6 deletions(-) diff --git a/crates/engine/src/cmd/docinfo.rs b/crates/engine/src/cmd/docinfo.rs index a43325becd..a58ff8e1ee 100644 --- a/crates/engine/src/cmd/docinfo.rs +++ b/crates/engine/src/cmd/docinfo.rs @@ -633,10 +633,13 @@ fn info(s: &mut Session, p: &Value) -> Result { fonts.extend(super::fonts::art_texts_of(d).into_iter().flat_map(|(_, t, _)| t.runs.iter().map(|r| super::fonts::font_of(&r.style)))); } let font_names: Vec = fonts.iter().map(super::fonts::font_label).collect(); - // Each brush the art uses with its type, found by name in the Brushes panel as the renderer finds it (the last of two + // Each brush the art uses with its type, found by name in the Brushes panel as the renderer finds it (the first of two // brushes with one name); a brush the panel does not hold draws as a plain stroke and has no type. A row names the type // as the New Brush dialog does (`Art Brush`), and the panel translates these values when it shows them. - let kinds: HashMap<&str, &vectorcraft_brush::BrushKind> = library.iter().map(|b| (b.name.as_str(), &b.kind)).collect(); + let mut kinds: HashMap<&str, &vectorcraft_brush::BrushKind> = HashMap::new(); + for b in &library { + kinds.entry(b.name.as_str()).or_insert(&b.kind); + } let brush_entries: Vec = brushes.iter().map(|name| json!({ "name": name, "type": kinds.get(name.as_str()).map(|k| k.type_id()) })).collect(); let brush_rows: Vec<(String, String)> = brushes @@ -910,7 +913,7 @@ mod tests { let d = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc); d.node_mut(NodeId(missing)).unwrap().appearance.stroke_mut().unwrap().paint = Paint::None; assert_eq!(s.execute("document.info", &json!({"category": "brushes"})).unwrap()["brushes"], json!([])); - // Two panel brushes with one name: the type is the one the renderer draws with, the last one's. + // Two panel brushes with one name: the type is the one the renderer draws with, the first one's. let twin = square(&mut s, 140.0); s.execute("brush.apply", &json!({"ids": [twin], "name": "Charcoal"})).unwrap(); let d = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc); @@ -920,7 +923,7 @@ mod tests { lib.extend(kinds.into_iter().map(|kind| vectorcraft_brush::Brush { name: "Twin".into(), kind })); vectorcraft_brush::store(d, &lib); d.node_mut(NodeId(twin)).unwrap().appearance.stroke_mut().unwrap().brush = Some("Twin".into()); - assert_eq!(s.execute("document.info", &json!({"category": "brushes"})).unwrap()["brushes"], json!([{"name": "Twin", "type": "scatter"}])); + assert_eq!(s.execute("document.info", &json!({"category": "brushes"})).unwrap()["brushes"], json!([{"name": "Twin", "type": "art"}])); // Type: a brushed stroke on drawn characters is listed, one on spaces alone is not. let blank = s.execute("text.create", &json!({"x": 10, "y": 60, "text": " "})).unwrap()["id"].as_u64().unwrap(); let word = s.execute("text.create", &json!({"x": 10, "y": 90, "text": "Hi"})).unwrap()["id"].as_u64().unwrap(); diff --git a/crates/render/src/brush_fx.rs b/crates/render/src/brush_fx.rs index b1b46b3327..3c6ea922d4 100644 --- a/crates/render/src/brush_fx.rs +++ b/crates/render/src/brush_fx.rs @@ -107,7 +107,12 @@ impl BrushCache { vectorcraft_brush::serde_json::to_string(v).unwrap_or_default().hash(&mut h); h.finish() }); - self.lib = vectorcraft_brush::library(doc).into_iter().map(|b| (b.name.clone(), Arc::new(b))).collect(); + // Of two brushes with one name, a stroke that names it draws with the first, as + // `vectorcraft_brush::find` and the exports take it. + self.lib.clear(); + for b in vectorcraft_brush::library(doc) { + self.lib.entry(b.name.clone()).or_insert_with(|| Arc::new(b)); + } } /// [`Self::key`] for the brush library `doc` holds (checked once a frame, `stamp`). @@ -388,6 +393,24 @@ mod tests { assert!(!dark(a.pixel(100, 58)) && dark(b.pixel(100, 58))); } + /// Of two brushes with one name, a stroke that names it draws with the first. + #[test] + fn a_stroke_draws_with_the_first_of_two_brushes_with_its_name() { + use vectorcraft_brush::{Brush, BrushKind, Calligraphic}; + let nib = |size: f64| Brush { name: "Twin".into(), kind: BrushKind::Calligraphic(Calligraphic { size, ..Default::default() }) }; + let doc = |lib: &[Brush]| { + let mut d = doc_with_line(Some("Twin"), 1.0); + vectorcraft_brush::store(&mut d, lib); + d + }; + let (thin, wide) = (nib(1.0), nib(20.0)); + let first = render(&mut Renderer::new(), &doc(std::slice::from_ref(&thin))).pixels; + let second = render(&mut Renderer::new(), &doc(std::slice::from_ref(&wide))).pixels; + assert_ne!(first, second, "the two nibs paint differently"); + let both = render(&mut Renderer::new(), &doc(&[thin, wide])).pixels; + assert!(both == first, "the stroke draws with the first brush named Twin"); + } + #[test] fn stained_symbol_instance() { let mut d = Document::new(100.0, 100.0); diff --git a/crates/ui-egui/src/panels/brushes.rs b/crates/ui-egui/src/panels/brushes.rs index 8f29c70475..969fa14aeb 100644 --- a/crates/ui-egui/src/panels/brushes.rs +++ b/crates/ui-egui/src/panels/brushes.rs @@ -69,6 +69,16 @@ pub(crate) fn document_brushes(st: &DocState) -> Rc> { MEMO.with(|m| m.borrow_mut().get(&st.doc, st.revision, || vectorcraft_brush::library(&st.doc).iter().map(|b| Shown::of(&st.doc, b)).collect())) } +/// The brushes of `shown` by name: of two with one name, the first, the one a stroke that names +/// it draws with and `brush.get` gives. +fn by_name(shown: &[Shown]) -> HashMap<&str, &Shown> { + let mut map = HashMap::new(); + for b in shown { + map.entry(b.name.as_str()).or_insert(b); + } + map +} + /// The cache key of the stroke preview of brush definition `def` in `doc` (`art`: its art): the /// definition and every resource of `doc` the art uses ([`art_resources`]: patterns with their /// options, symbols, swatches and colour groups, gradients, images, styles…), so the preview @@ -298,7 +308,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { // definitions and preview keys are the document version's, worked out once. let doc = app.session.active().map(|d| d.doc.clone()); let memo = app.session.active().map(document_brushes).unwrap_or_default(); - let defs: HashMap<&str, &Shown> = memo.iter().map(|b| (b.name.as_str(), b)).collect(); + let defs = by_name(&memo); let mut clicked: Option<(String, egui::Modifiers)> = None; let mut options: Option = None; let mut moved: Option = None; diff --git a/crates/ui-egui/src/panels/brushes/tests.rs b/crates/ui-egui/src/panels/brushes/tests.rs index fcab4373ce..2d9305fc52 100644 --- a/crates/ui-egui/src/panels/brushes/tests.rs +++ b/crates/ui-egui/src/panels/brushes/tests.rs @@ -821,3 +821,27 @@ fn previews_follow_the_swatches_images_and_pattern_options_their_art_uses() { assert_eq!(change(&mut app, what, f), (brushes, symbols, what.to_string())); } } + +/// Of two brushes with one name, both rows preview and drag the first, the one `brush.get` gives +/// and a stroke that names it draws with. +#[test] +fn rows_of_two_brushes_with_one_name_show_the_first() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 200, "height": 100})).unwrap(); + app.session + .edit("Brushes", |d, _| { + let mut lib = vectorcraft_brush::library(d); + let kinds: Vec<_> = ["Charcoal", "Dots"].iter().map(|n| lib.iter().find(|b| b.name == *n).unwrap().kind.clone()).collect(); + lib.extend(kinds.into_iter().map(|kind| Brush { name: "Twin".into(), kind })); + vectorcraft_brush::store(d, &lib); + Ok(()) + }) + .unwrap(); + let twins: Vec = brushes(&mut app).0.into_iter().filter(|b| b.0 == "Twin").map(|b| b.1).collect(); + assert_eq!(twins, ["art", "scatter"], "the panel lists both"); + let got = app.run("brush.get", json!({"name": "Twin"})).unwrap(); + let memo = document_brushes(app.session.active().unwrap()); + let shown = by_name(&memo)["Twin"]; + assert_eq!(shown.ty, "art"); + assert_eq!(shown.def, got, "the row shows and drags what brush.get gives"); +} diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 65b58c6dc5..4cd01e8945 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1766,6 +1766,8 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Brush art of copies:** symbol instances, inline graphics and repeat copies of a brushed path keep their own brush art between frames, as the same path on the page does. The renderer also keeps the stroke outlines of brush art and the evaluation of a live object in it, such as an envelope that a scatter brush places. The brush art is made again when the path, its stroke or the brush changes, and when the art of a symbol or pattern that the brush art can show changes. On one thread at 1000 × 1000 pixels (the median frame, with the median fastest frame in brackets), 2,000 paths on the page whose scatter brush art is an envelope take 94 ms (80 ms) a frame instead of 400 ms (306 ms), and 2,000 instances of a symbol whose art is a brushed path take 13.4 ms (12.0 ms) instead of 19.3 ms (15.6 ms), with the same pixels. +- **Two brushes with one name:** when a document's Brushes panel holds two brushes with the same name, a stroke that names it draws on the canvas with the first of them, as `brush.get`, Brush Options, expanding, outlining and the SVG, PDF, EPS and `.ai` exports take it. Document Info gives that brush's type, and both rows of the Brushes panel preview and drag the first brush. + - **Live objects in symbols and masks:** symbol instances and inline graphics whose symbol is a blend, an envelope, a repeat, an Intertwine object or a gradient mesh, or a group that holds one, draw the evaluation the renderer keeps for that object between frames, as the same object on the page does. So do opacity masks whose art is such an object, and such objects in clip groups in Outline view. The object is evaluated again when the symbols, patterns, fonts or color settings it shows change. Symbol art with effects of its own, or that holds a symbol instance, an image, a placed file, type with an inline graphic or a raster effect, is still placed and evaluated again in every frame. At 1000 × 1000 pixels, a page of 2,000 instances of a symbol that holds an envelope takes 28 ms a frame instead of 126 ms, with the same pixels. - **Live objects in documents with many symbols:** a live object, or an object with effects, that shows symbols or patterns is not walked again in a later frame to find them while the document's symbols and patterns stay the same. One that shows a placed file is walked in every frame. At 1000 × 1000 pixels, a page of 2,000 envelopes around instances of the last 2,000 of 10,000 symbols takes 30 ms a frame, and 83 ms when every frame walks them again, with the same pixels. From 453d869c984694c91e1c3af9d292ac0df3229ed4 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 13 Nov 2026 10:18:09 -0800 Subject: [PATCH 2/4] Tests: brush art tests replace the default Dots brush The two brush art tests in render's memory tests added a scatter brush named Dots next to the default library's own Dots. Strokes now draw with the first brush of a name, so the stroke drew the default brush and the edits to the symbols and patterns its art shows no longer changed the frame. The tests now remove the default Dots before adding their own. --- crates/render/src/tests/memory.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/crates/render/src/tests/memory.rs b/crates/render/src/tests/memory.rs index 011f9fd7fe..d286fa7e3c 100644 --- a/crates/render/src/tests/memory.rs +++ b/crates/render/src/tests/memory.rs @@ -931,6 +931,8 @@ fn brush_art_draws_edits_of_the_symbols_and_patterns_it_shows() { painted.transform(Affine::translate((0.0, 8.0)), true); let scatter = Node::group(d.alloc_id(), vec![Arc::new(instance), Arc::new(painted)]); let mut lib = vectorcraft_brush::library(&d); + // The default library has its own `Dots`, which a stroke would draw with first. + lib.retain(|b| b.name != "Dots"); lib.push(Brush { name: "Dots".into(), kind: BrushKind::Scatter(Scatter { art: scatter, spacing: (150.0, 150.0), ..Default::default() }) }); vectorcraft_brush::store(&mut d, &lib); let mut ap = vectorcraft_doc::Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 1.0); @@ -997,6 +999,8 @@ fn brush_art_draws_renamed_symbols_and_patterns_it_shows() { painted.transform(Affine::translate((0.0, 8.0)), true); let scatter = Node::group(d.alloc_id(), vec![Arc::new(instance), Arc::new(painted)]); let mut lib = vectorcraft_brush::library(&d); + // The default library has its own `Dots`, which a stroke would draw with first. + lib.retain(|b| b.name != "Dots"); lib.push(Brush { name: "Dots".into(), kind: BrushKind::Scatter(Scatter { art: scatter, spacing: (150.0, 150.0), ..Default::default() }) }); vectorcraft_brush::store(&mut d, &lib); let mut ap = vectorcraft_doc::Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 1.0); From 64f80a5eb5d48d2bfd092c825401f2db4000e0a4 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 13 Nov 2026 16:17:57 -0800 Subject: [PATCH 3/4] Tests: the Brushes panel lists the second brush of a repeated name as Twin 2 Reading a brush library now numbers the second and later brushes of a repeated name, so the panel lists Twin and Twin 2 rather than two Twin rows. The test expects those rows, and checks that rows of one name that reach the panel unnumbered still preview and drag the first. --- crates/ui-egui/src/panels/brushes/tests.rs | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/crates/ui-egui/src/panels/brushes/tests.rs b/crates/ui-egui/src/panels/brushes/tests.rs index 2d9305fc52..aeab927e23 100644 --- a/crates/ui-egui/src/panels/brushes/tests.rs +++ b/crates/ui-egui/src/panels/brushes/tests.rs @@ -822,26 +822,35 @@ fn previews_follow_the_swatches_images_and_pattern_options_their_art_uses() { } } -/// Of two brushes with one name, both rows preview and drag the first, the one `brush.get` gives -/// and a stroke that names it draws with. +/// Of two brushes with one name, the row of that name previews and drags the first, the one +/// `brush.get` gives and a stroke that names it draws with. Reading the library numbers the second +/// ("Twin 2"), so it gets a row of its own. #[test] fn rows_of_two_brushes_with_one_name_show_the_first() { let mut app = VectorcraftApp::new(Session::new(), Default::default()); app.run("file.new", json!({"width": 200, "height": 100})).unwrap(); + let mut twins: Vec = Vec::new(); app.session .edit("Brushes", |d, _| { let mut lib = vectorcraft_brush::library(d); let kinds: Vec<_> = ["Charcoal", "Dots"].iter().map(|n| lib.iter().find(|b| b.name == *n).unwrap().kind.clone()).collect(); - lib.extend(kinds.into_iter().map(|kind| Brush { name: "Twin".into(), kind })); + twins = kinds.into_iter().map(|kind| Brush { name: "Twin".into(), kind }).collect(); + lib.extend(twins.iter().cloned()); vectorcraft_brush::store(d, &lib); Ok(()) }) .unwrap(); - let twins: Vec = brushes(&mut app).0.into_iter().filter(|b| b.0 == "Twin").map(|b| b.1).collect(); - assert_eq!(twins, ["art", "scatter"], "the panel lists both"); + let rows: Vec<(String, String)> = brushes(&mut app).0.into_iter().filter(|b| b.0.starts_with("Twin")).map(|b| (b.0, b.1)).collect(); + assert_eq!(rows, [("Twin".to_string(), "art".to_string()), ("Twin 2".to_string(), "scatter".to_string())], "the panel lists both"); let got = app.run("brush.get", json!({"name": "Twin"})).unwrap(); let memo = document_brushes(app.session.active().unwrap()); let shown = by_name(&memo)["Twin"]; assert_eq!(shown.ty, "art"); assert_eq!(shown.def, got, "the row shows and drags what brush.get gives"); + // Rows of one name that reach the panel unnumbered still show the first. + let st = app.session.active().unwrap(); + let raw: Vec = twins.iter().map(|b| Shown::of(&st.doc, b)).collect(); + let shown = by_name(&raw)["Twin"]; + assert_eq!(shown.ty, "art"); + assert_eq!(shown.def, got, "the first of two rows of one name is the one shown"); } From 40f69a12045f7515ecac89a7043c0d119cad3526 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 13 Nov 2026 16:27:27 -0800 Subject: [PATCH 4/4] Docs: the Brushes panel lists the second brush of a repeated name in its own numbered row Reading a brush library numbers the second brush of a repeated name, so the panel's row of that name previews and drags the first brush, and the second brush has a numbered row of its own. --- docs/target-app-parity.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index c227ddec4b..10084f87e2 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1829,7 +1829,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Brush art of copies:** symbol instances, inline graphics and repeat copies of a brushed path keep their own brush art between frames, as the same path on the page does. The renderer also keeps the stroke outlines of brush art and the evaluation of a live object in it, such as an envelope that a scatter brush places. The brush art is made again when the path, its stroke or the brush changes, and when the art of a symbol or pattern that the brush art can show changes. On one thread at 1000 × 1000 pixels (the median frame, with the median fastest frame in brackets), 2,000 paths on the page whose scatter brush art is an envelope take 94 ms (80 ms) a frame instead of 400 ms (306 ms), and 2,000 instances of a symbol whose art is a brushed path take 13.4 ms (12.0 ms) instead of 19.3 ms (15.6 ms), with the same pixels. -- **Two brushes with one name:** when a document's Brushes panel holds two brushes with the same name, a stroke that names it draws on the canvas with the first of them, as `brush.get`, Brush Options, expanding, outlining and the SVG, PDF, EPS and `.ai` exports take it. Document Info gives that brush's type, and both rows of the Brushes panel preview and drag the first brush. +- **Two brushes with one name:** when a document stores two brushes with the same name, a stroke that names it draws on the canvas with the first of them, as `brush.get`, Brush Options, expanding, outlining and the SVG, PDF, EPS and `.ai` exports take it. Document Info gives that brush's type, and the Brushes panel's row of that name previews and drags the first brush. Reading the library numbers the second brush ("Name 2"), so the panel lists it in a row of its own that shows it. - **Live objects in symbols and masks:** symbol instances and inline graphics whose symbol is a blend, an envelope, a repeat, an Intertwine object or a gradient mesh, or a group that holds one, draw the evaluation the renderer keeps for that object between frames, as the same object on the page does. So do opacity masks whose art is such an object, and such objects in clip groups in Outline view. The object is evaluated again when the symbols, patterns, fonts or color settings it shows change. Symbol art with effects of its own, or that holds a symbol instance, an image, a placed file, type with an inline graphic or a raster effect, is still placed and evaluated again in every frame. At 1000 × 1000 pixels, a page of 2,000 instances of a symbol that holds an envelope takes 28 ms a frame instead of 126 ms, with the same pixels.