diff --git a/crates/engine/src/cmd/brushsym.rs b/crates/engine/src/cmd/brushsym.rs index 28f853f09..ed9e0e639 100644 --- a/crates/engine/src/cmd/brushsym.rs +++ b/crates/engine/src/cmd/brushsym.rs @@ -389,30 +389,78 @@ fn merge(base: &mut Value, patch: &Value) { } } -fn save_library(s: &mut Session, label: &str, lib: Vec, renames: &[(String, Option)]) -> Result<()> { +/// Sets brush `to` on the strokes in `items` that paint with brush `from`. +fn set_stroke_brush(items: &mut [AppearanceItem], from: &str, to: Option<&str>) { + for it in items { + if let AppearanceItem::Stroke(st) = it + && st.brush.as_deref() == Some(from) + { + st.brush = to.map(str::to_string); + } + } +} + +/// Whether a stroke in `n` (its opacity masks included) paints with brush `name`. +fn paints_with(n: &Node, name: &str) -> bool { + let mut hit = false; + vectorcraft_doc::swatches::walk_with_masks(n, &mut |m| { + hit |= m.appearance.items.iter().any(|i| matches!(i, AppearanceItem::Stroke(st) if st.brush.as_deref() == Some(name))); + }); + hit +} + +/// Applies [`set_stroke_brush`] to every stroke in `n` and its opacity masks. Only the objects that +/// hold such a stroke, and those that hold them, are copied. +fn set_brush_in(n: &mut Node, from: &str, to: Option<&str>) { + set_stroke_brush(&mut n.appearance.items, from, to); + if let Some(mask) = n.extras_if_any().and_then(|e| e.mask.as_mut()) + && paints_with(&mask.art, from) + { + set_brush_in(Arc::make_mut(&mut mask.art), from, to); + } + for c in n.children_mut().into_iter().flatten() { + if paints_with(c, from) { + set_brush_in(Arc::make_mut(c), from, to); + } + } +} + +/// Returns the art a brush is made of ([`Brush::art`]) for editing. +fn brush_art_mut(b: &mut Brush) -> Vec<&mut Node> { + use vectorcraft_brush::BrushKind; + match &mut b.kind { + BrushKind::Scatter(s) => vec![&mut s.art], + BrushKind::Art(a) => vec![&mut a.art], + BrushKind::Pattern(p) => { + std::iter::once(&mut p.side).chain([&mut p.outer_corner, &mut p.inner_corner, &mut p.start, &mut p.end].into_iter().flatten()).collect() + } + BrushKind::Calligraphic(_) | BrushKind::Bristle(_) => vec![], + } +} + +/// Sets brush `to` (`None`: no brush) on every stroke that paints with brush `from`: in the art +/// (opacity masks included), symbols, graph designs, pattern tiles and the tiles that pattern +/// editing's Cancel puts back, graphic styles and the art of the brushes in `lib`. +fn rename_brush_strokes(d: &mut Document, lib: &mut [Brush], from: &str, to: Option<&str>) { + let trees = d.layers.iter_mut().chain(d.symbols.iter_mut().map(|s| &mut s.art)).chain(d.graph_designs.iter_mut().map(|g| &mut g.art)); + let patterns = d.patterns.iter_mut().chain(d.pattern_edit.iter_mut().filter_map(|pe| pe.original.as_mut())); + for n in trees.chain(patterns.flat_map(|p| &mut p.art)) { + if paints_with(n, from) { + set_brush_in(Arc::make_mut(n), from, to); + } + } + for g in &mut d.graphic_styles { + set_stroke_brush(&mut g.appearance.items, from, to); + } + for n in lib.iter_mut().flat_map(brush_art_mut) { + set_brush_in(n, from, to); + } +} + +fn save_library(s: &mut Session, label: &str, mut lib: Vec, renames: &[(String, Option)]) -> Result<()> { s.edit(label, |d, _| { - brush::store(d, &lib); for (from, to) in renames { - let ids: Vec = { - let mut v = vec![]; - d.walk(|n| { - if n.appearance.items.iter().any(|i| matches!(i, AppearanceItem::Stroke(st) if st.brush.as_deref() == Some(from.as_str()))) { - v.push(n.id); - } - }); - v - }; - for id in ids { - if let Some(n) = d.node_mut(id) { - for it in &mut n.appearance.items { - if let AppearanceItem::Stroke(st) = it - && st.brush.as_deref() == Some(from.as_str()) - { - st.brush = to.clone(); - } - } - } - } + rename_brush_strokes(d, &mut lib, from, to.as_deref()); if brush::current(d).as_deref() == Some(from.as_str()) { match to { Some(t) => d.unknown.insert(brush::CURRENT_KEY.into(), json!(t)), @@ -420,6 +468,7 @@ fn save_library(s: &mut Session, label: &str, lib: Vec, renames: &[(Strin }; } } + brush::store(d, &lib); Ok(()) }) } diff --git a/crates/engine/src/tests_brushsym.rs b/crates/engine/src/tests_brushsym.rs index b8e85dc4e..5d16e01b0 100644 --- a/crates/engine/src/tests_brushsym.rs +++ b/crates/engine/src/tests_brushsym.rs @@ -123,6 +123,113 @@ fn new_brushes_from_params_and_selection() { assert!(s.execute("brush.new", &json!({"type": "nope"})).is_err()); } +/// The brush of every stroke in symbol "Mark", graph design "Bar", pattern `pattern`, graphic style +/// "Linked", the opacity mask in the layers and the art of brush "Holder", each as ": +/// " ("-" for a stroke without one). +fn brushes_outside_the_layers(s: &Session, pattern: &str) -> Vec { + let d = &s.doc().unwrap().doc; + let mut out = vec![]; + let mut items = |place: &str, items: &[vectorcraft_doc::AppearanceItem]| { + for it in items { + if let vectorcraft_doc::AppearanceItem::Stroke(st) = it { + out.push(format!("{place}: {}", st.brush.as_deref().unwrap_or("-"))); + } + } + }; + let mut tree = |place: &str, n: &Node| n.walk(&mut |m| items(place, &m.appearance.items)); + tree("symbol", &d.symbols.iter().find(|x| x.name == "Mark").unwrap().art); + tree("design", &d.graph_designs.iter().find(|x| x.name == "Bar").unwrap().art); + d.pattern(pattern).unwrap().art.iter().for_each(|n| tree("pattern", n)); + let mut masks = vec![]; + d.walk(|n| masks.extend(n.extras().mask.as_deref().map(|m| m.art.clone()))); + masks.iter().for_each(|n| tree("mask", n)); + let holder = vectorcraft_brush::find(d, "Holder").unwrap(); + holder.art().into_iter().for_each(|n| tree("brush art", n)); + items("style", &d.graphic_styles.iter().find(|x| x.name == "Linked").unwrap().appearance.items); + out +} + +/// Renaming a brush in Brush Options and deleting it reach every stroke that paints with it, not +/// only those in the layers: strokes in symbols, graph designs, pattern tiles, graphic styles, +/// opacity masks and the art of other brushes are renamed, or become plain strokes, in the same +/// undo step. +#[test] +fn brush_rename_and_delete_reach_strokes_outside_the_layers() { + let mut s = session(); + let brushed = |s: &mut Session| { + let id = rect(s, 10.0, 10.0, 40.0, 20.0); + s.execute("brush.apply", &json!({"name": "Charcoal", "ids": [id.0]})).unwrap(); + id + }; + let clear = |s: &mut Session, id: NodeId| { + if s.doc().unwrap().doc.node(id).is_some() { + s.execute("edit.clear", &json!({"ids": [id.0]})).unwrap(); + } + }; + brushed(&mut s); + let sym = s.execute("symbol.new", &json!({"name": "Mark"})).unwrap(); + clear(&mut s, NodeId(sym["id"].as_u64().unwrap())); + let id = brushed(&mut s); + s.execute("graph.design", &json!({"new": "Bar"})).unwrap(); + clear(&mut s, id); + let id = brushed(&mut s); + let pattern = s.execute("object.pattern.make", &json!({"edit": false})).unwrap()["name"].as_str().unwrap().to_string(); + clear(&mut s, id); + let id = brushed(&mut s); + s.execute("graphicStyle.new", &json!({"name": "Linked", "id": id.0})).unwrap(); + clear(&mut s, id); + let id = brushed(&mut s); + s.execute("brush.new", &json!({"type": "art", "name": "Holder"})).unwrap(); + clear(&mut s, id); + let masked = rect(&mut s, 100.0, 100.0, 60.0, 60.0); + let mask = brushed(&mut s); + select(&mut s, &[masked, mask]); + s.execute("transparency.makeOpacityMask", &json!({})).unwrap(); + let a = brushed(&mut s); + let plain = rect(&mut s, 200.0, 10.0, 40.0, 20.0); + let shared = |s: &Session| s.doc().unwrap().doc.layers[0].children().unwrap().iter().find(|c| c.id == plain).unwrap().clone(); + let plain_before = shared(&s); + let before = brushes_outside_the_layers(&s, &pattern); + for place in ["symbol", "design", "pattern", "mask", "brush art", "style"] { + assert!(before.contains(&format!("{place}: Charcoal")), "{place} in {before:?}"); + } + let with = |to: &str| -> Vec { before.iter().map(|e| e.replace(": Charcoal", &format!(": {to}"))).collect() }; + let undo = s.doc().unwrap().history.undo.len(); + s.execute("brush.options", &json!({"name": "Charcoal", "newName": "Chalk"})).unwrap(); + assert_eq!((brush_of(&s, a).as_deref(), brushes_outside_the_layers(&s, &pattern)), (Some("Chalk"), with("Chalk"))); + assert!(std::sync::Arc::ptr_eq(&shared(&s), &plain_before), "an object without the brush is not copied"); + assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!((brush_of(&s, a).as_deref(), brushes_outside_the_layers(&s, &pattern)), (Some("Charcoal"), before.clone())); + s.execute("brush.delete", &json!({"name": "Charcoal"})).unwrap(); + assert_eq!((brush_of(&s, a), brushes_outside_the_layers(&s, &pattern)), (None, with("-"))); + assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1); + // A brush made later with the deleted brush's name does not paint the strokes that used it. + s.execute("brush.new", &json!({"type": "calligraphic", "name": "Charcoal"})).unwrap(); + assert_eq!(brushes_outside_the_layers(&s, &pattern), with("-")); + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!((brush_of(&s, a).as_deref(), brushes_outside_the_layers(&s, &pattern)), (Some("Charcoal"), before)); +} + +/// A brush renamed while a pattern is being edited keeps its new name in the tile that pattern +/// editing's Cancel puts back. +#[test] +fn brush_rename_reaches_the_tile_that_pattern_cancel_puts_back() { + let mut s = session(); + let id = rect(&mut s, 10.0, 10.0, 40.0, 20.0); + s.execute("brush.apply", &json!({"name": "Charcoal", "ids": [id.0]})).unwrap(); + let pattern = s.execute("object.pattern.make", &json!({"edit": false})).unwrap()["name"].as_str().unwrap().to_string(); + s.execute("object.pattern.edit", &json!({"name": pattern})).unwrap(); + s.execute("brush.options", &json!({"name": "Charcoal", "newName": "Chalk"})).unwrap(); + s.execute("object.pattern.cancel", &json!({})).unwrap(); + let mut brushes = vec![]; + for n in &s.doc().unwrap().doc.pattern(&pattern).unwrap().art { + n.walk(&mut |m| brushes.extend(m.appearance.stroke().and_then(|st| st.brush.clone()))); + } + assert_eq!(brushes, ["Chalk"]); +} + #[test] fn brush_options_rename_duplicate_and_delete_update_strokes() { let mut s = session(); diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index cf5d5efef..314b29eee 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -722,6 +722,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur In the Brushes panel, Shift-click selects a range of brushes and Ctrl/Cmd-click adds or removes one. Delete Brush and Duplicate Brush then act on all of them (`brush.delete {names}`, `brush.duplicate {names}`). Document Info has a Brushes category after Graphic Styles. It lists the Brushes panel's brushes, or with Selection Only it lists the brushes that the selected objects' strokes use. The Document Info report and Package's report list them too (`document.info {category: "brushes"}`, `brushNames`). The Brushes panel menu's Select All Unused selects the brushes that are not used in the document, as the [Brushes](https://web.archive.org/web/20241230205225/https://helpx.adobe.com/illustrator/using/brushes.html) page (archived copy) describes. A brush counts as used when a stroke paints with it in the art (opacity masks included), in symbols, graph designs, pattern tiles or graphic styles, or in the art of a used brush (`brush.unused`). + A rename in Brush Options and Delete Brush change every stroke that paints with the brush: in the layers and opacity masks, symbols, graph designs, pattern tiles, graphic styles and the art of other brushes. A rename gives those strokes the new name, and Delete Brush makes them plain strokes, in one undo step. - **Appearance:** the Appearance panel's selected row drives every paint, stroke, gradient and transparency edit (`item` on the commands); effects apply to one fill or stroke; per-item Opacity popups, whose blend-mode list and opacity slider stay open inside them (#430; the Control bar's Stroke popover too); Mixed Appearances, Layers target dots and basic-appearance rules. Effects can be reordered, moved between items and copied by dragging; Show All Hidden Attributes; clicking an effect edits it in place; the object thumbnail drags onto art; effects render on type, images, symbols, blends, envelopes, meshes and repeats, and per-item raster effects export. Groups, layers and type have their own fills, strokes and effects (Contents and Characters rows); the Layers panel's target circles target layers, groups and objects; Expand Appearance turns fills, strokes, effects and brushes into objects. Fill and stroke paint (Fill, Stroke, None, Apply Last Color and Apply Last Gradient, also on characters selected with the Type tool), the Stroke panel's options, gradient and freeform point edits, Swap Fill and Stroke, Default Fill and Stroke, the Eyedropper, and the Appearance panel's item edits, Clear Appearance, Reduce to Basic Appearance, Remove Item, item reordering and Move Appearance record no undo step when they change nothing. An image's own fills and strokes draw the shape that a Convert to Shape or Outline Object effect on them gives them (the image's box or its frame, reshaped by the effects after it), under or over the image as its Contents row says, on the canvas, in exports and through Expand Appearance; new fills and strokes on an image go under it. Without one of those effects they draw nothing, and Use Preview Bounds measures an image by its frame.