From 012af4fd49914f68f40f9a3288e6d8ce03371f14 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 30 Oct 2026 19:45:16 -0700 Subject: [PATCH] Brushes: each Pattern Brush Options tile can be set to a pattern swatch or None A menu under each of the five tiles in Pattern Brush Options lists None, Original and the document's pattern swatches; the corner menus keep the four automatic corners. A swatch choice copies the swatch's whole art and its tile frame into the brush, so the brush keeps drawing after the swatch is deleted, and the menu then shows that art as Original. Side cannot be None: OK shows "You must choose a side pattern." and keeps the dialog open. brush.options takes side_tile, outer_corner_tile, inner_corner_tile, start_tile and end_tile (a pattern name, none, original, the corner auto ids, or {"pattern": name} for a swatch named like one of those words), and rejects side_tile "none" and unknown names before editing. --- crates/brush/src/lib.rs | 43 ++++ crates/brush/src/tests.rs | 37 ++++ crates/brush/src/warp.rs | 22 +- crates/engine/src/cmd/brushsym.rs | 2 +- crates/engine/src/cmd/brushsym/brushes.rs | 70 ++++++ crates/engine/src/tests/brushsym.rs | 188 ++++++++++++++++ .../ui-egui/src/dialogs/brush_art_options.rs | 184 +++++++++++++--- crates/ui-egui/src/dialogs/brush_options.rs | 38 +++- .../src/dialogs/brush_options/tests.rs | 203 +++++++++++++++++- crates/ui-egui/src/i18n/bg.tsv | 3 + crates/ui-egui/src/i18n/bs.tsv | 3 + crates/ui-egui/src/i18n/ca.tsv | 3 + crates/ui-egui/src/i18n/cs.tsv | 3 + crates/ui-egui/src/i18n/da.tsv | 3 + crates/ui-egui/src/i18n/de.tsv | 3 + crates/ui-egui/src/i18n/el.tsv | 3 + crates/ui-egui/src/i18n/es.tsv | 3 + crates/ui-egui/src/i18n/fi.tsv | 3 + crates/ui-egui/src/i18n/fr.tsv | 3 + crates/ui-egui/src/i18n/hu.tsv | 3 + crates/ui-egui/src/i18n/id.tsv | 3 + crates/ui-egui/src/i18n/it.tsv | 3 + crates/ui-egui/src/i18n/ja.tsv | 3 + crates/ui-egui/src/i18n/ka.tsv | 3 + crates/ui-egui/src/i18n/ko.tsv | 3 + crates/ui-egui/src/i18n/nl.tsv | 3 + crates/ui-egui/src/i18n/pl.tsv | 3 + crates/ui-egui/src/i18n/pt-br.tsv | 3 + crates/ui-egui/src/i18n/ru.tsv | 3 + crates/ui-egui/src/i18n/sv.tsv | 3 + crates/ui-egui/src/i18n/tr.tsv | 3 + crates/ui-egui/src/i18n/uk.tsv | 3 + crates/ui-egui/src/i18n/vi.tsv | 3 + crates/ui-egui/src/i18n/zh-hans.tsv | 3 + crates/ui-egui/src/i18n/zh-hant.tsv | 3 + crates/ui-egui/src/widgets.rs | 2 +- docs/gaps.md | 8 +- docs/mcp.md | 7 +- docs/target-app-parity.md | 3 +- 39 files changed, 837 insertions(+), 48 deletions(-) diff --git a/crates/brush/src/lib.rs b/crates/brush/src/lib.rs index e76e67ba5..fbec57011 100644 --- a/crates/brush/src/lib.rs +++ b/crates/brush/src/lib.rs @@ -386,10 +386,22 @@ impl CornerSource { #[serde(default)] pub struct PatternBrush { pub side: Node, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub side_frame: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub side_swatch: Option, #[serde(skip_serializing_if = "Option::is_none")] pub outer_corner: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub outer_corner_frame: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub outer_corner_swatch: Option, #[serde(skip_serializing_if = "Option::is_none")] pub inner_corner: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub inner_corner_frame: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub inner_corner_swatch: Option, /// What the outer corners take when there is no `outer_corner` art. Without art or a source, /// they take the `inner_corner` art. #[serde(skip_serializing_if = "Option::is_none")] @@ -400,8 +412,16 @@ pub struct PatternBrush { pub inner_source: Option, #[serde(skip_serializing_if = "Option::is_none")] pub start: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub start_frame: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub start_swatch: Option, #[serde(skip_serializing_if = "Option::is_none")] pub end: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub end_frame: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub end_swatch: Option, /// Scale in percent. pub scale: f64, /// Space between tiles in percent of the tile width. @@ -422,12 +442,22 @@ impl Default for PatternBrush { fn default() -> Self { Self { side: defaults::stitch_tile(), + side_frame: None, + side_swatch: None, outer_corner: None, + outer_corner_frame: None, + outer_corner_swatch: None, inner_corner: None, + inner_corner_frame: None, + inner_corner_swatch: None, outer_source: None, inner_source: None, start: None, + start_frame: None, + start_swatch: None, end: None, + end_frame: None, + end_swatch: None, scale: 100.0, spacing: 0.0, fit: PatternFit::Stretch, @@ -500,6 +530,14 @@ fn within_pair((a, b): (f64, f64), range: (f64, f64), fallback: f64) -> (f64, f6 (within(a, range, fallback), within(b, range, fallback)) } +fn valid_tile_frame(frame: Rect) -> bool { + [frame.x0, frame.y0, frame.x1, frame.y1].into_iter().all(f64::is_finite) + && frame.width().is_finite() + && frame.height().is_finite() + && frame.width() > 1e-9 + && frame.height() > 1e-9 +} + impl Colorization { /// A Hue Shift key colour that isn't a colour (components out of reach) becomes black. fn sanitize(&mut self) { @@ -600,6 +638,11 @@ impl Brush { BrushKind::Pattern(p) => { p.scale = within(p.scale, PatternBrush::SCALE_RANGE, 100.0); p.spacing = within(p.spacing, PatternBrush::SPACING_RANGE, 0.0); + for frame in [&mut p.side_frame, &mut p.outer_corner_frame, &mut p.inner_corner_frame, &mut p.start_frame, &mut p.end_frame] { + if frame.is_some_and(|f| !valid_tile_frame(f)) { + *frame = None; + } + } p.colorization.sanitize(); } BrushKind::Bristle(b) => { diff --git a/crates/brush/src/tests.rs b/crates/brush/src/tests.rs index 28bbcda75..6362c8da8 100644 --- a/crates/brush/src/tests.rs +++ b/crates/brush/src/tests.rs @@ -561,6 +561,43 @@ fn pattern_corner_source_none_leaves_that_kind_of_corner_empty() { assert!((outer == plain && inner == saved) || (inner == plain && outer == saved), "only the corners set to None are empty"); } +#[test] +fn pattern_without_start_tile_runs_side_to_path_start() { + let path = line(0.0, 0.0, 100.0, 0.0); + let doc = Document::new(1.0, 1.0); + let mut b = pattern_brush(PatternFit::Stretch, false); + if let BrushKind::Pattern(p) = &mut b.kind { + p.start = Some(rect_art(20.0, 4.0)); + } + let with_start = stroke_pieces(&doc, &b, &path, &stroke(1.0)); + assert_eq!(with_start[0].geometric_bounds().unwrap().width(), 20.0); + if let BrushKind::Pattern(p) = &mut b.kind { + p.start = None; + } + let without_start = stroke_pieces(&doc, &b, &path, &stroke(1.0)); + assert_eq!(without_start.len(), 10); + assert!(near(without_start[0].geometric_bounds().unwrap().x0, 0.0, 1e-6)); + assert!(near(without_start[0].geometric_bounds().unwrap().width(), 10.0, 1e-6)); +} + +#[test] +fn pattern_without_end_tile_runs_side_to_path_end() { + let path = line(0.0, 0.0, 100.0, 0.0); + let doc = Document::new(1.0, 1.0); + let mut b = pattern_brush(PatternFit::Stretch, false); + if let BrushKind::Pattern(p) = &mut b.kind { + p.end = Some(rect_art(20.0, 4.0)); + } + let with_end = stroke_pieces(&doc, &b, &path, &stroke(1.0)); + assert!(with_end.iter().any(|n| near(n.geometric_bounds().unwrap().width(), 20.0, 1e-6))); + if let BrushKind::Pattern(p) = &mut b.kind { + p.end = None; + } + let without_end = stroke_pieces(&doc, &b, &path, &stroke(1.0)); + assert_eq!(without_end.len(), 10); + assert!(near(without_end.last().unwrap().geometric_bounds().unwrap().x1, 100.0, 1e-6)); +} + #[test] fn pattern_corner_sources_round_trip_and_stay_out_of_brushes_without_them() { let saved = serde_json::to_value(pattern_brush(PatternFit::Stretch, true)).unwrap(); diff --git a/crates/brush/src/warp.rs b/crates/brush/src/warp.rs index d66d5679f..78c2f547f 100644 --- a/crates/brush/src/warp.rs +++ b/crates/brush/src/warp.rs @@ -247,6 +247,16 @@ fn oriented(art: &Node, dir: Direction, flip_along: bool, flip_across: bool) -> (b.width() > 1e-9).then_some((a, b)) } +/// Orient a tile's repeat frame with its art. The frame can be smaller than the art. +fn oriented_tile(art: &Node, frame: Option, flip_along: bool, flip_across: bool) -> Option<(Node, Rect)> { + let (node, bounds) = oriented(art, Direction::LeftToRight, flip_along, flip_across)?; + let Some(frame) = frame else { return Some((node, bounds)) }; + let x = if flip_along { (-frame.x1, -frame.x0) } else { (frame.x0, frame.x1) }; + let y = if flip_across { (-frame.y1, -frame.y0) } else { (frame.y0, frame.y1) }; + let frame = Rect::new(x.0, y.0, x.1, y.1); + (frame.width() > 1e-9 && frame.height() > 1e-9).then_some((node, frame)) +} + /// Piecewise-linear map from art x (offset from the art's left edge) to arc length. #[derive(Clone, Copy, Debug)] pub(crate) struct AlongMap { @@ -800,13 +810,13 @@ fn masked(n: Node, quad: &[Point], p: Point, towards: Vec2) -> Option { } pub(crate) fn pattern(pb: &PatternBrush, bp: &BezPath, weight: f64) -> Vec { - let orient = |n: &Node| oriented(n, Direction::LeftToRight, pb.flip_along, pb.flip_across); - let Some(side) = orient(&pb.side) else { return vec![] }; + let orient = |n: &Node, frame| oriented_tile(n, frame, pb.flip_along, pb.flip_across); + let Some(side) = orient(&pb.side, pb.side_frame) else { return vec![] }; let k = weight * pb.scale.max(1.0) / 100.0; - let outer = pb.outer_corner.as_ref().and_then(orient); - let inner = pb.inner_corner.as_ref().and_then(orient); - let start = pb.start.as_ref().and_then(orient); - let end = pb.end.as_ref().and_then(orient); + let outer = pb.outer_corner.as_ref().and_then(|n| orient(n, pb.outer_corner_frame)); + let inner = pb.inner_corner.as_ref().and_then(|n| orient(n, pb.inner_corner_frame)); + let start = pb.start.as_ref().and_then(|n| orient(n, pb.start_frame)); + let end = pb.end.as_ref().and_then(|n| orient(n, pb.end_frame)); let tw = side.1.width() * k; let gap = tw * pb.spacing.max(0.0) / 100.0; // Each kind of corner takes its own art, else its source, else the other kind's art. diff --git a/crates/engine/src/cmd/brushsym.rs b/crates/engine/src/cmd/brushsym.rs index 7b37fea76..3b9a476d4 100644 --- a/crates/engine/src/cmd/brushsym.rs +++ b/crates/engine/src/cmd/brushsym.rs @@ -134,7 +134,7 @@ pub fn specs() -> Vec { "Brush Options…", [], None, - "{name, params?: {…fields to change (brush.get shows them; values outside their ranges are clamped). calligraphic: angle −180..180°, roundness 0..100%, size 0..1296 pt, variation: [angle°, roundness%, size pt], modes: [fixed|random|pressure for angle, roundness, size] (pressure goes from value − variation at the lightest pen pressure to value + variation at the heaviest). scatter: size [a, b] 1..10000%, spacing [a, b] 1..10000%, scatter [a, b] −1000..1000%, rotation [a, b] −180..180°, modes: [fixed|random|pressure for size, spacing, scatter, rotation] (fixed: a; random: between a and b; pressure: a at the lightest pen pressure to b at the heaviest), rotation_relative_to_path. art: width 1..1000%, width_mode: null (fixed)|pressure, width_max 1..1000% (pressure: width at the lightest pen pressure to width_max at the heaviest, half way on a stroke drawn without pressure), scale: {mode: proportional|stretch|betweenGuides, start, end: 0..1 of the art length}, direction: leftToRight|rightToLeft|topToBottom|bottomToTop, flip_along, flip_across, overlap. pattern: scale 1..10000%, spacing 0..10000%, fit: stretch|addSpace|approximate, flip_along, flip_across, outer_corner and inner_corner: corner tile art or null, outer_source and inner_source: what that kind of corner draws without its art: none|autoCentered|autoBetween|autoSliced|autoOverlap (a tile made from the side tile), or null (the other kind of corner's art). scatter, art and pattern: colorization: {method: none|tints|tintsAndShades|hueShift, key: colour (hueShift: the art colour that becomes the stroke colour; without a key: the solid color the art's fills and strokes use most, black when they use none)}; colorization and the art brush's scale change only the fields given}, newName? (trimmed; blank: the name is kept; a new bristle brush name has at most 31 characters, and a longer name the brush has is kept)} edit a brush definition (strokes using it update) → {name}", + "{name, params?: {…fields to change (brush.get shows them; values outside their ranges are clamped). calligraphic: angle −180..180°, roundness 0..100%, size 0..1296 pt, variation: [angle°, roundness%, size pt], modes: [fixed|random|pressure for angle, roundness, size] (pressure goes from value − variation at the lightest pen pressure to value + variation at the heaviest). scatter: size [a, b] 1..10000%, spacing [a, b] 1..10000%, scatter [a, b] −1000..1000%, rotation [a, b] −180..180°, modes: [fixed|random|pressure for size, spacing, scatter, rotation] (fixed: a; random: between a and b; pressure: a at the lightest pen pressure to b at the heaviest), rotation_relative_to_path. art: width 1..1000%, width_mode: null (fixed)|pressure, width_max 1..1000% (pressure: width at the lightest pen pressure to width_max at the heaviest, half way on a stroke drawn without pressure), scale: {mode: proportional|stretch|betweenGuides, start, end: 0..1 of the art length}, direction: leftToRight|rightToLeft|topToBottom|bottomToTop, flip_along, flip_across, overlap. pattern: scale 1..10000%, spacing 0..10000%, fit: stretch|addSpace|approximate, flip_along, flip_across, outer_corner and inner_corner: corner tile art or null, outer_source and inner_source: what that kind of corner draws without its art: none|autoCentered|autoBetween|autoSliced|autoOverlap (a tile made from the side tile), or null (the other kind of corner's art); side_tile, outer_corner_tile, inner_corner_tile, start_tile, end_tile: a document pattern name, original, or none (side refuses none); corner tiles also take autoCentered|autoBetween|autoSliced|autoOverlap. Each tile also accepts {pattern: name} to select a swatch whose name is a reserved choice. scatter, art and pattern: colorization: {method: none|tints|tintsAndShades|hueShift, key: colour (hueShift: the art colour that becomes the stroke colour; without a key: the solid color the art's fills and strokes use most, black when they use none)}; colorization and the art brush's scale change only the fields given}, newName? (trimmed; blank: the name is kept; a new bristle brush name has at most 31 characters, and a longer name the brush has is kept)} edit a brush definition (strokes using it update) → {name}", has_doc, brush_options ), diff --git a/crates/engine/src/cmd/brushsym/brushes.rs b/crates/engine/src/cmd/brushsym/brushes.rs index f5e5c73fc..9cc52c82c 100644 --- a/crates/engine/src/cmd/brushsym/brushes.rs +++ b/crates/engine/src/cmd/brushsym/brushes.rs @@ -390,7 +390,77 @@ pub(super) fn brush_options(s: &mut Session, p: &Value) -> Result { let i = lib.iter().position(|b| b.name == name).ok_or_else(|| bad(C, format!("no brush named `{name}`")))?; let mut v = serde_json::to_value(&lib[i]).unwrap_or_default(); if let Some(params) = p.get("params") { + let original = v.clone(); merge(&mut v, params); + if lib[i].kind.type_id() == "pattern" { + let tiles = [ + ("side_tile", "side"), + ("outer_corner_tile", "outer_corner"), + ("inner_corner_tile", "inner_corner"), + ("start_tile", "start"), + ("end_tile", "end"), + ]; + // Resolve all Original choices against the brush before any tile can clear the art + // another corner borrows. Keep the matching frame with each copied tile. + let originals: Vec<_> = tiles + .iter() + .map(|(_, art)| { + let from = if art.ends_with("corner") + && original.get(art).is_none_or(Value::is_null) + && original.get(if *art == "outer_corner" { "outer_source" } else { "inner_source" }).is_none_or(Value::is_null) + { + if *art == "outer_corner" { "inner_corner" } else { "outer_corner" } + } else { + *art + }; + (original.get(from).cloned().unwrap_or(Value::Null), original.get(format!("{from}_frame")).cloned().unwrap_or(Value::Null)) + }) + .collect(); + for ((tile, art), (original_art, original_frame)) in tiles.into_iter().zip(originals) { + let frame = format!("{art}_frame"); + let swatch = format!("{art}_swatch"); + let Some(choice) = params.get(tile) else { + if params.get(art).is_some() { + if params.get(frame.as_str()).is_none() { + v[frame.as_str()] = Value::Null; + } + if params.get(swatch.as_str()).is_none() { + v[swatch.as_str()] = Value::Null; + } + } + continue; + }; + let (choice, swatch_only) = if let Some(name) = choice.as_object() { + (name.get("pattern").and_then(Value::as_str).ok_or_else(|| bad(C, "invalid pattern tile choice"))?, true) + } else { + (choice.as_str().ok_or_else(|| bad(C, "invalid pattern tile choice"))?, false) + }; + if art == "side" && choice == "none" && !swatch_only { + return Err(bad(C, "You must choose a side pattern.")); + } + if choice == "original" && !swatch_only { + v[art] = original_art; + v[frame.as_str()] = original_frame; + v[swatch.as_str()] = Value::Null; + } else if !swatch_only && (choice == "none" || (art.ends_with("corner") && brush::CornerSource::parse(choice).is_some())) { + v[art] = Value::Null; + v[frame.as_str()] = Value::Null; + v[swatch.as_str()] = Value::Null; + if art.ends_with("corner") { + v[if art == "outer_corner" { "outer_source" } else { "inner_source" }] = json!(choice); + } + } else { + let pattern = s.doc()?.doc.pattern(choice).ok_or_else(|| bad(C, format!("no pattern named `{choice}`")))?; + let copy = Node::new(NodeId(0), NodeKind::Group { children: pattern.art.clone(), clip: false }); + v[art] = serde_json::to_value(copy).unwrap_or_default(); + v[frame.as_str()] = serde_json::to_value(pattern.tile).unwrap_or_default(); + v[swatch.as_str()] = json!(choice); + if art.ends_with("corner") { + v[if art == "outer_corner" { "outer_source" } else { "inner_source" }] = Value::Null; + } + } + } + } } let new_name = str_param(p, "newName").map(str::trim).filter(|n| !n.is_empty() && *n != name).map(str::to_string); if let Some(nn) = &new_name { diff --git a/crates/engine/src/tests/brushsym.rs b/crates/engine/src/tests/brushsym.rs index bba39230b..ef448da9d 100644 --- a/crates/engine/src/tests/brushsym.rs +++ b/crates/engine/src/tests/brushsym.rs @@ -832,6 +832,194 @@ fn art_brush_options_keep_the_scale_fields_an_edit_leaves_out() { assert_eq!(s.execute("brush.get", &json!({"name": "Bar"})).unwrap()["scale"], json!({"mode": "stretch"})); } +#[test] +fn pattern_tile_choices_copy_whole_swatch_art_and_reject_invalid_side() { + use std::sync::Arc; + use vectorcraft_doc::{PatternDef, StrokeLayer}; + use vectorcraft_geom::BezPath; + + let mut s = session(); + s.edit("Pattern", |d, _| { + let art = + Node::path(d.alloc_id(), vectorcraft_geom::shapes::rectangle(Rect::new(0.0, 0.0, 30.0, 4.0)), vectorcraft_doc::Appearance::default_art()); + d.patterns.push(PatternDef { tile: Rect::new(0.0, 0.0, 10.0, 4.0), ..PatternDef::new("Wide", vec![Arc::new(art)]) }); + Ok(()) + }) + .unwrap(); + let side = s.execute("brush.get", &json!({"name":"Diamonds"})).unwrap()["side"].clone(); + s.execute("brush.new", &json!({"type":"pattern", "name":"Plain Tiles", "params":{"side":side}})).unwrap(); + let original = s.execute("brush.get", &json!({"name":"Plain Tiles"})).unwrap(); + let steps = s.doc().unwrap().history.undo.len(); + for choice in ["none", "Missing"] { + let error = s.execute("brush.options", &json!({"name":"Plain Tiles", "params":{"side_tile":choice}})).unwrap_err().to_string(); + assert!(error.contains(if choice == "none" { "You must choose a side pattern." } else { "no pattern named" }), "{error}"); + assert_eq!(s.doc().unwrap().history.undo.len(), steps); + assert_eq!(s.execute("brush.get", &json!({"name":"Plain Tiles"})).unwrap(), original); + } + s.execute("brush.options", &json!({"name":"Plain Tiles", "params":{"side_tile":"Wide", "fit":"addSpace"}})).unwrap(); + let saved = s.execute("brush.get", &json!({"name":"Plain Tiles"})).unwrap(); + assert_eq!(saved["side_frame"], json!(Rect::new(0.0, 0.0, 10.0, 4.0))); + assert_eq!(saved["side_swatch"], "Wide"); + let brush: vectorcraft_brush::Brush = serde_json::from_value(saved).unwrap(); + let mut path = BezPath::new(); + path.move_to((0.0, 0.0)); + path.line_to((100.0, 0.0)); + let pieces = vectorcraft_brush::stroke_pieces( + &s.doc().unwrap().doc, + &brush, + &path, + &StrokeLayer::new(vectorcraft_color::Paint::solid(vectorcraft_color::Color::BLACK), 1.0), + ); + assert_eq!(pieces.len(), 10, "10 pt tile frame repeats ten times along 100 pt"); + assert!(pieces[0].geometric_bounds().unwrap().width() >= 29.9, "whole 30 pt art is copied"); + s.execute("brush.options", &json!({"name":"Plain Tiles", "params":{"inner_corner_tile":"Wide", "outer_corner_tile":"none"}})).unwrap(); + let corners: vectorcraft_brush::Brush = serde_json::from_value(s.execute("brush.get", &json!({"name":"Plain Tiles"})).unwrap()).unwrap(); + let mut fallback = corners.clone(); + if let (vectorcraft_brush::BrushKind::Pattern(actual), vectorcraft_brush::BrushKind::Pattern(legacy)) = (&corners.kind, &mut fallback.kind) { + assert_eq!(actual.outer_source, Some(vectorcraft_brush::CornerSource::None)); + assert!(actual.inner_corner.is_some()); + legacy.outer_source = None; + } + let square = |clockwise: bool| { + let mut p = BezPath::new(); + p.move_to((0.0, 0.0)); + if clockwise { + for point in [(100.0, 0.0), (100.0, 100.0), (0.0, 100.0)] { + p.line_to(point); + } + } else { + for point in [(0.0, 100.0), (100.0, 100.0), (100.0, 0.0)] { + p.line_to(point); + } + } + p.close_path(); + p + }; + let stroke = StrokeLayer::new(vectorcraft_color::Paint::solid(vectorcraft_color::Color::BLACK), 1.0); + assert!( + [false, true].into_iter().any(|clockwise| { + let path = square(clockwise); + vectorcraft_brush::stroke_pieces(&s.doc().unwrap().doc, &corners, &path, &stroke) + != vectorcraft_brush::stroke_pieces(&s.doc().unwrap().doc, &fallback, &path, &stroke) + }), + "explicit outer None omits the inner corner art on outer turns" + ); + let borrowed_art = s.execute("brush.get", &json!({"name":"Plain Tiles"})).unwrap()["inner_corner"].clone(); + s.execute("brush.new", &json!({"type":"pattern", "name":"Borrowed Corner", "params":{"side":original["side"], "inner_corner":borrowed_art}})) + .unwrap(); + s.execute("brush.options", &json!({"name":"Borrowed Corner", "params":{"outer_corner_tile":"original", "inner_corner_tile":"none"}})).unwrap(); + let borrowed = s.execute("brush.get", &json!({"name":"Borrowed Corner"})).unwrap(); + assert!(borrowed["outer_corner"].is_object()); + assert_eq!(borrowed["inner_source"], "none"); + s.edit("Delete pattern", |d, _| { + d.patterns.clear(); + Ok(()) + }) + .unwrap(); + let after: vectorcraft_brush::Brush = serde_json::from_value(s.execute("brush.get", &json!({"name":"Plain Tiles"})).unwrap()).unwrap(); + assert_eq!( + vectorcraft_brush::stroke_pieces( + &s.doc().unwrap().doc, + &after, + &path, + &StrokeLayer::new(vectorcraft_color::Paint::solid(vectorcraft_color::Color::BLACK), 1.0) + ) + .len(), + 10 + ); + let copied = s.execute("brush.get", &json!({"name":"Plain Tiles"})).unwrap()["side"].clone(); + s.execute("brush.options", &json!({"name":"Plain Tiles", "params":{"side_tile":"original"}})).unwrap(); + let original_choice = s.execute("brush.get", &json!({"name":"Plain Tiles"})).unwrap(); + assert_eq!(original_choice["side"], copied); + assert!(original_choice.get("side_swatch").is_none()); +} + +#[test] +fn pattern_original_corner_keeps_borrowed_art_and_frame_when_source_is_cleared() { + let mut s = session(); + let chain = s.execute("brush.get", &json!({"name": "Chain"})).unwrap(); + let art = chain["outer_corner"].clone(); + let frame = json!(Rect::new(1.0, 2.0, 15.0, 18.0)); + for (name, source, borrowed) in [("Borrow Outer", "inner", "outer"), ("Borrow Inner", "outer", "inner")] { + let source_art = format!("{source}_corner"); + let source_frame = format!("{source_art}_frame"); + let borrowed_art = format!("{borrowed}_corner"); + let borrowed_frame = format!("{borrowed_art}_frame"); + let source_tile = format!("{source_art}_tile"); + let borrowed_tile = format!("{borrowed_art}_tile"); + s.execute("brush.new", &json!({"type":"pattern", "name":name, "params":{"side":chain["side"], source_art:art, source_frame:frame}})).unwrap(); + s.execute("brush.options", &json!({"name":name, "params":{source_tile:"none", borrowed_tile:"original"}})).unwrap(); + let saved = s.execute("brush.get", &json!({"name":name})).unwrap(); + assert_eq!(saved[&borrowed_art], chain["outer_corner"], "{borrowed} art"); + assert_eq!(saved[&borrowed_frame], frame, "{borrowed} frame"); + assert_eq!(saved[format!("{source}_source")], "none"); + } +} + +#[test] +fn pattern_tile_object_selects_reserved_swatch_names() { + use std::sync::Arc; + use vectorcraft_doc::PatternDef; + + let mut s = session(); + let names = ["none", "original", "autoCentered", "autoBetween", "autoSliced", "autoOverlap"]; + s.edit("Patterns", |doc, _| { + for name in names { + let art = Node::path( + doc.alloc_id(), + vectorcraft_geom::shapes::rectangle(Rect::new(0.0, 0.0, 30.0, 4.0)), + vectorcraft_doc::Appearance::default_art(), + ); + doc.patterns.push(PatternDef { tile: Rect::new(0.0, 0.0, 10.0, 4.0), ..PatternDef::new(name, vec![Arc::new(art)]) }); + } + Ok(()) + }) + .unwrap(); + let before = s.execute("brush.get", &json!({"name":"Diamonds"})).unwrap(); + for invalid in [json!({}), json!({"pattern": null}), json!({"pattern": 1})] { + let error = s.execute("brush.options", &json!({"name":"Diamonds", "params":{"side_tile":invalid}})).unwrap_err().to_string(); + assert!(error.contains("invalid pattern tile choice"), "{error}"); + assert_eq!(s.execute("brush.get", &json!({"name":"Diamonds"})).unwrap(), before); + } + let slots = ["side_tile", "outer_corner_tile", "inner_corner_tile", "start_tile", "end_tile"]; + for (slot, name) in slots.into_iter().zip(names) { + s.execute("brush.options", &json!({"name":"Diamonds", "params":{(slot):{"pattern":name}}})).unwrap(); + } + let saved = s.execute("brush.get", &json!({"name":"Diamonds"})).unwrap(); + for (art, name) in ["side", "outer_corner", "inner_corner", "start", "end"].into_iter().zip(names) { + assert_eq!(saved[format!("{art}_swatch")], name); + assert_eq!(saved[format!("{art}_frame")], json!(Rect::new(0.0, 0.0, 10.0, 4.0))); + } + s.execute("brush.options", &json!({"name":"Diamonds", "params":{"outer_corner_tile":{"pattern":"autoOverlap"}}})).unwrap(); + assert_eq!(s.execute("brush.get", &json!({"name":"Diamonds"})).unwrap()["outer_corner_swatch"], "autoOverlap"); +} + +#[test] +fn pattern_end_tile_none_choices_leave_side_art_at_both_path_ends() { + use vectorcraft_doc::StrokeLayer; + use vectorcraft_geom::BezPath; + + let mut s = session(); + let side = s.execute("brush.get", &json!({"name":"Diamonds"})).unwrap()["side"].clone(); + let cap = s.execute("brush.get", &json!({"name":"Chain"})).unwrap()["outer_corner"].clone(); + s.execute("brush.new", &json!({"type":"pattern", "name":"Capped", "params":{"side":side, "start":cap, "end":cap}})).unwrap(); + s.execute("brush.options", &json!({"name":"Capped", "params":{"start_tile":"none", "end_tile":"none"}})).unwrap(); + let def = s.execute("brush.get", &json!({"name":"Capped"})).unwrap(); + assert!(def.get("start").is_none() && def.get("end").is_none()); + let brush: vectorcraft_brush::Brush = serde_json::from_value(def).unwrap(); + let mut path = BezPath::new(); + path.move_to((0.0, 0.0)); + path.line_to((100.0, 0.0)); + let pieces = vectorcraft_brush::stroke_pieces( + &s.doc().unwrap().doc, + &brush, + &path, + &StrokeLayer::new(vectorcraft_color::Paint::solid(vectorcraft_color::Color::BLACK), 1.0), + ); + let bounds = pieces.iter().fold(None, |bounds, node| vectorcraft_geom::union_opt(bounds, node.geometric_bounds())).unwrap(); + assert!((bounds.x0 - 0.0).abs() < 1e-6 && (bounds.x1 - 100.0).abs() < 1e-6, "{bounds:?}"); +} + // ---------- symbols ---------- fn make_symbol(s: &mut Session) -> (NodeId, String, Rect) { diff --git a/crates/ui-egui/src/dialogs/brush_art_options.rs b/crates/ui-egui/src/dialogs/brush_art_options.rs index f9b5bf558..c9071fd37 100644 --- a/crates/ui-egui/src/dialogs/brush_art_options.rs +++ b/crates/ui-egui/src/dialogs/brush_art_options.rs @@ -26,7 +26,7 @@ use egui::{Color32, Sense, Stroke, pos2, vec2}; use serde_json::{Map, Value, json}; use vectorcraft_brush::{ArtBrush, ArtScale, Brush, BrushKind, Colorization, CornerSource, Direction, PatternBrush, PatternFit, Scatter, Variation}; use vectorcraft_color::{Color, Paint}; -use vectorcraft_doc::{Appearance, Document, Node, NodeId}; +use vectorcraft_doc::{Appearance, Document, Node, NodeId, NodeKind, PatternDef}; use super::brush_options::{clamped, mode_key, stroke_preview}; use crate::VectorcraftApp; @@ -44,6 +44,55 @@ const DIRECTIONS: [Direction; 4] = [Direction::LeftToRight, Direction::RightToLe const TILES: [(&str, &str); 5] = [("outer_corner", "outerCorner"), ("side", "side"), ("inner_corner", "innerCorner"), ("start", "start"), ("end", "end")]; +fn tile_key(field: &str) -> String { + match field { + "outer_corner" => "outerCornerTile".into(), + "inner_corner" => "innerCornerTile".into(), + _ => format!("{field}Tile"), + } +} + +pub(super) fn pattern_names(d: &Dialog) -> Vec { + d.fields + .get("__patterns") + .and_then(Value::as_array) + .into_iter() + .flatten() + .filter_map(|v| v.get("name").and_then(Value::as_str).map(str::to_string)) + .collect() +} + +fn pattern_choice(d: &Dialog, choice: &str) -> Option { + let name = choice.strip_prefix("pattern:").unwrap_or(choice); + let reserved = choice == "none" || choice == "original" || CornerSource::parse(choice).is_some(); + (!reserved && pattern_names(d).iter().any(|candidate| candidate == name)).then(|| name.to_string()) +} + +pub(super) fn original_available(d: &Dialog, field: &str) -> bool { + let choice = d.str(&tile_key(field)); + let drawn = if field.ends_with("corner") { corner_art_field(d.fields.get("__def"), field) } else { Some(field) }; + drawn.is_some() && pattern_choice(d, &choice).is_none() +} + +pub(super) fn reconcile_pattern_choices(f: &mut Map) { + let names: Vec = f + .get("__patterns") + .and_then(Value::as_array) + .into_iter() + .flatten() + .filter_map(|v| v.get("name").and_then(Value::as_str).map(str::to_string)) + .collect(); + let def = f.get("__def").cloned().unwrap_or_default(); + for (art, _) in TILES { + let swatch = def.get(format!("{art}_swatch")).and_then(Value::as_str); + if let Some(name) = swatch.filter(|name| names.iter().any(|n| n == name)) { + f.insert(tile_key(art), json!(format!("pattern:{name}"))); + } else if !art.ends_with("corner") { + f.insert(tile_key(art), json!(if def.get(art).is_some_and(|v| !v.is_null()) { "original" } else { "none" })); + } + } +} + /// Pattern brush corner tiles: (dialog field, art field of the definition, source field, art field /// of the other kind of corner). const CORNER_TILES: [(&str, &str, &str, &str); 2] = @@ -63,20 +112,20 @@ fn corner_art_field<'a>(def: Option<&Value>, field: &'a str) -> Option<&'a str> } } -/// The dialog field that records that corner tile `key`'s menu was set. +/// The dialog field that records that tile `key`'s menu was set. fn corner_set_key(key: &str) -> String { format!("__{key}Set") } -/// Sets corner tile `key` to `choice` from its menu. +/// Sets tile `key` to `choice` from its menu. pub(super) fn choose_corner(d: &mut Dialog, key: &str, choice: &str) { d.fields.insert(key.into(), json!(choice)); note_set(d, key); } -/// Records that field `field` was set, from its menu or `ui.dialog.set`, when it is a corner tile. +/// Records that a tile field was set from its menu or `ui.dialog.set`. pub(super) fn note_set(d: &mut Dialog, field: &str) { - if CORNER_TILES.iter().any(|(key, ..)| *key == field) { + if CORNER_TILES.iter().any(|(key, ..)| *key == field) || TILES.iter().any(|(art, _)| tile_key(art) == field) { d.fields.insert(corner_set_key(field), json!(true)); } } @@ -259,18 +308,34 @@ pub(super) fn params(d: &Dialog) -> Value { let def = d.fields.get("__def"); for (key, art, source, other) in CORNER_TILES { let choice = d.str(key); + if let Some(name) = pattern_choice(d, &choice) { + if d.bool(&corner_set_key(key)) { + p[format!("{art}_tile")] = json!({"pattern": name}); + } + continue; + } let unset = |f: &str| def.and_then(|d| d.get(f)).is_none_or(Value::is_null); if choice == CornerSource::None.id() && !d.bool(&corner_set_key(key)) && unset(source) && corner_art_field(def, art).is_none() { continue; } if let Some(s) = CornerSource::parse(&choice) { - p[art] = Value::Null; - p[source] = json!(s.id()); + p[format!("{art}_tile")] = json!(s.id()); } else if choice == "original" && let Some(from) = corner_art_field(def, art).filter(|f| *f != art) && CORNER_TILES.iter().find(|(_, a, _, _)| *a == other).is_some_and(|(k, ..)| d.str(k) != "original") { p[art] = def.and_then(|d| d.get(from)).cloned().unwrap_or_default(); + p[format!("{art}_frame")] = def.and_then(|d| d.get(format!("{from}_frame"))).cloned().unwrap_or(Value::Null); + } + if choice == "original" && d.bool(&corner_set_key(key)) { + p[format!("{art}_tile")] = json!("original"); + } + } + for art in ["side", "start", "end"] { + let key = tile_key(art); + if d.bool(&corner_set_key(&key)) { + let choice = d.str(&key); + p[format!("{art}_tile")] = pattern_choice(d, &choice).map_or_else(|| json!(choice), |name| json!({"pattern": name})); } } p @@ -302,6 +367,35 @@ pub(super) fn edited_def(d: &Dialog) -> Value { if let (Some(o), Value::Object(p)) = (def.as_object_mut(), params(d)) { o.extend(p); } + if d.str("__type") == "pattern" { + for (art, _) in TILES { + let key = tile_key(art); + if !d.bool(&corner_set_key(&key)) { + continue; + } + let choice = d.str(&key); + if choice == "none" || (art.ends_with("corner") && CornerSource::parse(&choice).is_some()) { + def[art] = Value::Null; + def[format!("{art}_frame")] = Value::Null; + def[format!("{art}_swatch")] = Value::Null; + if art.ends_with("corner") { + def[if art == "outer_corner" { "outer_source" } else { "inner_source" }] = json!(choice); + } + } else if choice == "original" { + def[format!("{art}_swatch")] = Value::Null; + } else if let Some(name) = pattern_choice(d, &choice) + && let Some(pattern) = d.fields.get("__patterns").and_then(Value::as_array).and_then(|a| a.iter().find(|p| p["name"] == name)) + && let Ok(pattern) = serde_json::from_value::(pattern.clone()) + { + def[art] = serde_json::to_value(Node::new(NodeId(0), NodeKind::Group { children: pattern.art, clip: false })).unwrap_or_default(); + def[format!("{art}_frame")] = json!(pattern.tile); + def[format!("{art}_swatch")] = json!(name); + if art.ends_with("corner") { + def[if art == "outer_corner" { "outer_source" } else { "inner_source" }] = Value::Null; + } + } + } + } def } @@ -447,12 +541,12 @@ fn pattern_body(ui: &mut egui::Ui, d: &mut Dialog) { radios(ui, d, "fit", &fits); } -/// What a corner tile can be: (choice, label). Original (the corner art the brush draws there) is -/// offered only when the brush draws corner art there. +/// Built-in corner choices. Original is disabled when the slot has no retained art or a listed +/// swatch was selected. fn corner_choices() -> [(&'static str, &'static str); 6] { [ - ("original", tl!("Original")), (CornerSource::None.id(), tl!("None")), + ("original", tl!("Original")), (CornerSource::AutoCentered.id(), tl!("Auto-Centered")), (CornerSource::AutoBetween.id(), tl!("Auto-Between")), (CornerSource::AutoSliced.id(), tl!("Auto-Sliced")), @@ -460,31 +554,47 @@ fn corner_choices() -> [(&'static str, &'static str); 6] { ] } -/// The pattern's five tiles, each with its art (or None when the brush has no such tile). Below -/// each corner tile, a menu sets it to the brush's art, None or a tile made from the side tile. +pub(super) fn tile_choices(d: &Dialog, corner: bool) -> Vec<(String, String)> { + let mut choices: Vec<(String, String)> = if corner { + corner_choices().into_iter().map(|(id, label)| (id.into(), label.into())).collect() + } else { + vec![("none".into(), tl!("None").into()), ("original".into(), tl!("Original").into())] + }; + choices.extend(pattern_names(d).into_iter().map(|name| (format!("pattern:{name}"), name))); + choices +} + +/// The pattern's five tiles, each with its art (or None when the brush has no such tile). A menu +/// below each preview selects retained art, no art, or a document pattern; corners also offer +/// generated choices. fn tiles(ui: &mut egui::Ui, d: &mut Dialog) { let t = Tokens::get(ui.ctx()); let names = [tl!("Outer Corner Tile"), tl!("Side Tile"), tl!("Inner Corner Tile"), tl!("Start Tile"), tl!("End Tile")]; - let keys = d.fields.get("__tiles").cloned(); + let preview_def = edited_def(d); ui.horizontal(|ui| { ui.spacing_mut().item_spacing.x = 6.0; - for ((field, slot), name) in TILES.into_iter().zip(names) { + for ((field, _slot), name) in TILES.into_iter().zip(names) { let corner = CORNER_TILES.iter().find(|(_, art, _, _)| *art == field).map(|(key, ..)| *key); // The art the tile draws with: its own, or a corner's that falls back on the other's. - let drawn = if corner.is_some() { corner_art_field(d.fields.get("__def"), field) } else { Some(field) }; - let drawn_slot = drawn.and_then(|f| TILES.iter().find(|(tf, _)| *tf == f)).map_or(slot, |(_, s)| *s); - let choices: Vec<(&str, &str)> = corner_choices().into_iter().filter(|(c, _)| drawn.is_some() || *c != "original").collect(); - let choice = corner.map(|key| d.str(key)); + let drawn = if corner.is_some() { corner_art_field(Some(&preview_def), field) } else { Some(field) }; + let choice = d.str(&tile_key(field)); + let original_enabled = original_available(d, field); + let choices = tile_choices(d, corner.is_some()); // The label of the corner's choice when it shows no art. - let shown = choice.as_deref().filter(|c| *c != "original"); - let label = shown.and_then(|c| choices.iter().find(|(id, _)| *id == c)).map_or(tl!("None"), |(_, l)| *l); + let shown = (choice != "original" && pattern_choice(d, &choice).is_none()).then_some(choice.as_str()); + let label = shown.and_then(|c| choices.iter().find(|(id, _)| id == c)).map_or(tl!("None"), |(_, l)| l.as_str()); ui.vertical(|ui| { let (r, resp) = ui.allocate_exact_size(vec2(76.0, 44.0), Sense::hover()); ui.painter().rect_filled(r, 0.0, Color32::WHITE); - let key = keys.as_ref().filter(|_| shown.is_none()).and_then(|k| k.get(drawn_slot)).and_then(Value::as_str); - let tex = key.and_then(|key| { + let art_value = drawn.and_then(|f| preview_def.get(f)).filter(|_| shown.is_none()); + let key = art_value.map(|art| { + let mut h = std::collections::hash_map::DefaultHasher::new(); + art.to_string().hash(&mut h); + format!("brush-tile:{:x}", h.finish()) + }); + let tex = key.as_deref().and_then(|key| { widgets::doc_preview(ui, key, r.size(), |w, h| { - let art: Node = serde_json::from_value(d.fields.get("__def")?.get(drawn?)?.clone()).ok()?; + let art: Node = serde_json::from_value(art_value?.clone()).ok()?; tile_doc(&art, w, h) }) }); @@ -499,14 +609,26 @@ fn tiles(ui: &mut egui::Ui, d: &mut Dialog) { ui.painter().rect_stroke(r, 0.0, Stroke::new(1.0, t.input_border), egui::StrokeKind::Inside); resp.on_hover_text(name); ui.add_sized(vec2(76.0, 14.0), egui::Label::new(egui::RichText::new(name).size(10.5).color(t.text_dim)).truncate()); - if let (Some(key), Some(cur)) = (corner, &choice) { - let current = choices.iter().find(|(id, _)| id == cur).map_or(tl!("None"), |(_, l)| *l); - let labels: Vec<&str> = choices.iter().map(|(_, l)| *l).collect(); - if let Some((id, _)) = - widgets::dropdown_named(ui, ("brush-pattern-corner", key), Some(name), current, &labels, 76.0).and_then(|i| choices.get(i)) - { - choose_corner(d, key, id); - } + let current = choices.iter().find(|(id, _)| id == &choice).map_or(tl!("None"), |(_, l)| l.as_str()); + if let Some(id) = widgets::combo_named(ui, ("brush-pattern-tile", field), Some(name), current, 76.0, |ui| { + choices.iter().enumerate().find_map(|(i, (id, label))| { + ui.add_enabled(id != "original" || original_enabled, egui::Button::selectable(id == &choice, label.as_str())) + .clicked() + .then_some(i) + }) + }) + .and_then(|i| choices.get(i).map(|(id, _)| id.clone())) + { + let key = corner.unwrap_or_else(|| { + if field == "side" { + "sideTile" + } else if field == "start" { + "startTile" + } else { + "endTile" + } + }); + choose_corner(d, key, &id); } }); } diff --git a/crates/ui-egui/src/dialogs/brush_options.rs b/crates/ui-egui/src/dialogs/brush_options.rs index f03518b16..19a8e9c6e 100644 --- a/crates/ui-egui/src/dialogs/brush_options.rs +++ b/crates/ui-egui/src/dialogs/brush_options.rs @@ -97,6 +97,10 @@ pub fn open(app: &mut VectorcraftApp, name: Option<&str>) -> Result Option { /// opens. pub(super) fn open_new(app: &mut VectorcraftApp, ty: &str, ids: Option) -> Result { let st = app.session.active().ok_or("no document open")?; + let patterns = serde_json::to_value(&st.doc.patterns).unwrap_or_default(); let title: String = ty.chars().take(1).flat_map(char::to_uppercase).chain(ty.chars().skip(1)).collect(); let name = vectorcraft_brush::unique_name(&vectorcraft_brush::library(&st.doc), &format!("New {title} Brush")); let mut def = json!({ "name": name, "type": ty }); @@ -169,6 +174,8 @@ pub(super) fn open_new(app: &mut VectorcraftApp, ty: &str, ids: Option) - b.sanitize(); let def = serde_json::to_value(&b).map_err(|e| e.to_string())?; let mut f = fields(&b, def)?; + f.insert("__patterns".into(), patterns); + brush_art_options::reconcile_pattern_choices(&mut f); f.insert(CREATE.into(), json!(true)); if let Some(ids) = ids { f.insert(IDS.into(), ids); @@ -183,9 +190,38 @@ pub(super) fn open_new(app: &mut VectorcraftApp, ty: &str, ids: Option) - /// dialog closes once the brush is made. fn create(app: &mut VectorcraftApp, d: &Dialog) -> Result { let mut brush_params = params(d); + if d.str("__type") == "pattern" { + if d.str("sideTile") == "none" { + return Err("You must choose a side pattern.".into()); + } + let preview = brush_art_options::edited_def(d); + for key in [ + "side", + "side_frame", + "side_swatch", + "outer_corner", + "outer_corner_frame", + "outer_corner_swatch", + "outer_source", + "inner_corner", + "inner_corner_frame", + "inner_corner_swatch", + "inner_source", + "start", + "start_frame", + "start_swatch", + "end", + "end_frame", + "end_swatch", + ] { + if let Some(value) = preview.get(key) { + brush_params[key] = value.clone(); + } + } + } if let Some(def) = d.fields.get("__def") { for key in ["art", "side"] { - if let Some(art) = def.get(key).filter(|v| !v.is_null()) { + if let Some(art) = def.get(key).filter(|v| !v.is_null() && brush_params.get(key).is_none_or(Value::is_null)) { brush_params[key] = art.clone(); } } diff --git a/crates/ui-egui/src/dialogs/brush_options/tests.rs b/crates/ui-egui/src/dialogs/brush_options/tests.rs index 9adeffed1..83fe73e3c 100644 --- a/crates/ui-egui/src/dialogs/brush_options/tests.rs +++ b/crates/ui-egui/src/dialogs/brush_options/tests.rs @@ -458,7 +458,7 @@ fn pattern_options_set_each_corner_tile_to_original_none_or_an_automatic_tile() assert!(roles.contains(&egui::accesskit::Role::ComboBox), "a menu named {name}: {roles:?}"); } for name in ["Side Tile", "Start Tile", "End Tile"] { - assert!(!shown_tree.iter().any(|n| n.label() == Some(name) && n.role() == egui::accesskit::Role::ComboBox), "no menu for {name}"); + assert!(shown_tree.iter().any(|n| n.label() == Some(name) && n.role() == egui::accesskit::Role::ComboBox), "a menu for {name}"); } set(&mut app, json!({"outerCornerTile": "autoBetween", "innerCornerTile": "autoOverlap"})); let text = shown(&mut app); @@ -492,6 +492,98 @@ fn pattern_options_set_each_corner_tile_to_original_none_or_an_automatic_tile() assert_eq!(app.ui.dialog.as_ref().unwrap().str("outerCornerTile"), "autoSliced"); } +#[test] +fn pattern_tile_menus_use_document_patterns_and_side_none_keeps_dialog_open() { + use std::sync::Arc; + use vectorcraft_doc::{Node, PatternDef}; + use vectorcraft_geom::Rect; + + let mut app = app(); + app.session + .edit("Pattern", |doc, _| { + let art = Node::path( + doc.alloc_id(), + vectorcraft_geom::shapes::rectangle(Rect::new(0.0, 0.0, 30.0, 4.0)), + vectorcraft_doc::Appearance::default_art(), + ); + doc.patterns.push(PatternDef { tile: Rect::new(0.0, 0.0, 10.0, 4.0), ..PatternDef::new("Wide", vec![Arc::new(art)]) }); + Ok(()) + }) + .unwrap(); + let before = app.run("brush.get", json!({"name":"Diamonds"})).unwrap(); + app.run("ui.brushOptions", json!({"name":"Diamonds"})).unwrap(); + let d = app.ui.dialog.as_ref().unwrap(); + assert_eq!(crate::dialogs::brush_art_options::pattern_names(d), vec!["Wide".to_string()]); + for key in ["sideTile", "outerCornerTile", "innerCornerTile", "startTile", "endTile"] { + assert!(d.fields.contains_key(key), "{key}"); + } + for corner in [false, true] { + let ids: Vec<_> = crate::dialogs::brush_art_options::tile_choices(d, corner).into_iter().map(|(id, _)| id).collect(); + assert!(ids.contains(&"pattern:Wide".to_string())); + if corner { + for auto in ["autoCentered", "autoBetween", "autoSliced", "autoOverlap"] { + assert!(ids.contains(&auto.to_string())); + } + } else { + assert_eq!(ids.first().map(String::as_str), Some("none")); + assert_eq!(ids.get(1).map(String::as_str), Some("original")); + } + } + let d = app.ui.dialog.as_mut().unwrap(); + crate::dialogs::brush_art_options::choose_corner(d, "sideTile", "Wide"); + assert!(!crate::dialogs::brush_art_options::original_available(d, "side")); + let preview = crate::dialogs::brush_art_options::edited_def(d); + assert_eq!(preview["side_frame"], json!(Rect::new(0.0, 0.0, 10.0, 4.0))); + assert_ne!(preview["side"], before["side"]); + super::super::confirm(&mut app).unwrap(); + app.session + .edit("Delete pattern", |doc, _| { + doc.patterns.clear(); + Ok(()) + }) + .unwrap(); + app.run("ui.brushOptions", json!({"name":"Diamonds"})).unwrap(); + assert_eq!(app.ui.dialog.as_ref().unwrap().str("sideTile"), "original"); + let saved = app.run("brush.get", json!({"name":"Diamonds"})).unwrap(); + let undo = app.session.doc().unwrap().history.undo.len(); + let d = app.ui.dialog.as_mut().unwrap(); + crate::dialogs::brush_art_options::choose_corner(d, "sideTile", "none"); + let error = super::super::confirm(&mut app).unwrap_err(); + assert!(error.contains("You must choose a side pattern."), "{error}"); + assert!(app.ui.dialog.is_some()); + assert_eq!(app.run("brush.get", json!({"name":"Diamonds"})).unwrap(), saved); + assert_eq!(app.session.doc().unwrap().history.undo.len(), undo); +} + +#[test] +fn new_pattern_brush_keeps_a_tile_swatch_choice() { + use std::sync::Arc; + use vectorcraft_doc::{Node, PatternDef}; + use vectorcraft_geom::Rect; + + let mut app = app(); + app.run("shape.rectangle", json!({"x":0,"y":0,"width":20,"height":4})).unwrap(); + app.session + .edit("Pattern", |doc, _| { + let art = Node::path( + doc.alloc_id(), + vectorcraft_geom::shapes::rectangle(Rect::new(0.0, 0.0, 30.0, 4.0)), + vectorcraft_doc::Appearance::default_art(), + ); + doc.patterns.push(PatternDef { tile: Rect::new(0.0, 0.0, 10.0, 4.0), ..PatternDef::new("Wide", vec![Arc::new(art)]) }); + Ok(()) + }) + .unwrap(); + super::open_new(&mut app, "pattern", None).unwrap(); + let name = app.ui.dialog.as_ref().unwrap().str("name"); + let d = app.ui.dialog.as_mut().unwrap(); + crate::dialogs::brush_art_options::choose_corner(d, "sideTile", "Wide"); + super::super::confirm(&mut app).unwrap(); + let def = app.run("brush.get", json!({"name":name})).unwrap(); + assert_eq!(def["side_swatch"], "Wide"); + assert_eq!(def["side_frame"], json!(Rect::new(0.0, 0.0, 10.0, 4.0))); +} + /// A pattern brush with art for one kind of corner draws it at both kinds. Its other corner tile /// shows that art as Original, and OK keeps it there when the first corner tile changes. #[test] @@ -509,6 +601,115 @@ fn pattern_options_keep_the_corner_art_a_corner_borrows() { assert_eq!(def["outer_source"], json!("none")); } +#[test] +fn pattern_options_preview_and_save_borrowed_corner_before_clearing_source() { + use vectorcraft_geom::Rect; + + let mut app = app(); + let chain = app.run("brush.get", json!({"name": "Chain"})).unwrap(); + let art = chain["outer_corner"].clone(); + let frame = json!(Rect::new(1.0, 2.0, 15.0, 18.0)); + for (name, source, borrowed) in [("Borrow Outer", "inner", "outer"), ("Borrow Inner", "outer", "inner")] { + let source_art = format!("{source}_corner"); + let source_frame = format!("{source_art}_frame"); + let borrowed_art = format!("{borrowed}_corner"); + let borrowed_frame = format!("{borrowed_art}_frame"); + let source_tile = format!("{source}CornerTile"); + let borrowed_tile = format!("{borrowed}CornerTile"); + app.run("brush.new", json!({"type":"pattern", "name":name, "params":{"side":chain["side"], source_art:art, source_frame:frame}})).unwrap(); + app.run("ui.brushOptions", json!({"name":name})).unwrap(); + let d = app.ui.dialog.as_mut().unwrap(); + crate::dialogs::brush_art_options::choose_corner(d, &source_tile, "none"); + crate::dialogs::brush_art_options::choose_corner(d, &borrowed_tile, "original"); + let preview = crate::dialogs::brush_art_options::edited_def(d); + assert_eq!(preview[&borrowed_art], chain["outer_corner"], "{borrowed} preview art"); + assert_eq!(preview[&borrowed_frame], frame, "{borrowed} preview frame"); + super::super::confirm(&mut app).unwrap(); + let saved = app.run("brush.get", json!({"name":name})).unwrap(); + assert_eq!(saved[&borrowed_art], chain["outer_corner"], "{borrowed} saved art"); + assert_eq!(saved[&borrowed_frame], frame, "{borrowed} saved frame"); + } +} + +#[test] +fn pattern_options_borrowed_swatch_corner_keeps_its_frame() { + use std::sync::Arc; + use vectorcraft_doc::{Node, PatternDef}; + use vectorcraft_geom::Rect; + + let mut app = app(); + app.session + .edit("Pattern", |doc, _| { + let art = Node::path( + doc.alloc_id(), + vectorcraft_geom::shapes::rectangle(Rect::new(0.0, 0.0, 30.0, 4.0)), + vectorcraft_doc::Appearance::default_art(), + ); + doc.patterns.push(PatternDef { tile: Rect::new(0.0, 0.0, 10.0, 4.0), ..PatternDef::new("Corner", vec![Arc::new(art)]) }); + Ok(()) + }) + .unwrap(); + let side = app.run("brush.get", json!({"name":"Diamonds"})).unwrap()["side"].clone(); + app.run("brush.new", json!({"type":"pattern", "name":"Borrow", "params":{"side":side}})).unwrap(); + app.run("brush.options", json!({"name":"Borrow", "params":{"outer_corner_tile":{"pattern":"Corner"}}})).unwrap(); + let before = app.run("brush.get", json!({"name":"Borrow"})).unwrap(); + app.run("ui.brushOptions", json!({"name":"Borrow"})).unwrap(); + set(&mut app, json!({"outerCornerTile":"none"})); + super::super::confirm(&mut app).unwrap(); + let after = app.run("brush.get", json!({"name":"Borrow"})).unwrap(); + assert_eq!(after["inner_corner"], before["outer_corner"]); + assert_eq!(after["inner_corner_frame"], before["outer_corner_frame"]); + assert_eq!(after["outer_source"], json!("none")); +} + +#[test] +fn pattern_options_selects_swatches_with_reserved_names() { + use std::sync::Arc; + use vectorcraft_doc::{Node, PatternDef}; + use vectorcraft_geom::Rect; + + let mut app = app(); + let names = ["none", "original", "autoCentered", "autoBetween", "autoSliced", "autoOverlap"]; + app.session + .edit("Patterns", |doc, _| { + for name in names { + let art = Node::path( + doc.alloc_id(), + vectorcraft_geom::shapes::rectangle(Rect::new(0.0, 0.0, 30.0, 4.0)), + vectorcraft_doc::Appearance::default_art(), + ); + doc.patterns.push(PatternDef { tile: Rect::new(0.0, 0.0, 10.0, 4.0), ..PatternDef::new(name, vec![Arc::new(art)]) }); + } + Ok(()) + }) + .unwrap(); + app.run("ui.brushOptions", json!({"name":"Diamonds"})).unwrap(); + let d = app.ui.dialog.as_ref().unwrap(); + for name in names { + let choice = (format!("pattern:{name}"), name.to_string()); + assert!(crate::dialogs::brush_art_options::tile_choices(d, true).contains(&choice)); + } + for (field, name) in [ + ("sideTile", "none"), + ("outerCornerTile", "original"), + ("innerCornerTile", "autoCentered"), + ("startTile", "autoBetween"), + ("endTile", "autoSliced"), + ] { + crate::dialogs::brush_art_options::choose_corner(app.ui.dialog.as_mut().unwrap(), field, &format!("pattern:{name}")); + } + let preview = crate::dialogs::brush_art_options::edited_def(app.ui.dialog.as_ref().unwrap()); + assert_eq!(preview["side_swatch"], "none"); + super::super::confirm(&mut app).unwrap(); + let saved = app.run("brush.get", json!({"name":"Diamonds"})).unwrap(); + for (art, name) in + [("side", "none"), ("outer_corner", "original"), ("inner_corner", "autoCentered"), ("start", "autoBetween"), ("end", "autoSliced")] + { + assert_eq!(saved[format!("{art}_swatch")], name); + assert_eq!(saved[format!("{art}_frame")], json!(Rect::new(0.0, 0.0, 10.0, 4.0))); + } +} + /// OK leaves the corner fields of a corner tile the menus were not set to as they were: a corner /// without art keeps borrowing the other kind's art when it gets some, and a corner that borrows /// art still borrows it. A None picked from the menu is saved, after another choice too. diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index 0888ac645..2104ac7af 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -5899,6 +5899,9 @@ breakText Glyphs Глифи Gap 4 Разстояние 4 Dash 5 Тире 5 Gap 5 Разстояние 5 +@msg You must choose a side pattern. Трябва да изберете страничен шаблон. +@msg invalid pattern tile choice невалиден избор на плочка за шаблон +@msg no pattern named `{choice}` няма шаблон с име `{choice}` Delete the selected brushes? Да се изтрият избраните четки? Strokes use one or more of these brushes. Take the brushes off those strokes and delete the brushes? Щрихи използват една или повече от тези четки. Да се премахнат ли четките от тези щрихи и да се изтрият четките? Remove Strokes Премахване на щрихи diff --git a/crates/ui-egui/src/i18n/bs.tsv b/crates/ui-egui/src/i18n/bs.tsv index d89af156f..2d42bf75d 100644 --- a/crates/ui-egui/src/i18n/bs.tsv +++ b/crates/ui-egui/src/i18n/bs.tsv @@ -5931,6 +5931,9 @@ shape Open Otvoren @msg `follow` must be "average", "a" or "b" `follow` mora biti "average", "a" ili "b" @msg the paths have nothing to merge there putanje tu nemaju šta spojiti @msg the path is locked or hidden putanja je zaključana ili skrivena +@msg You must choose a side pattern. Morate odabrati bočni uzorak. +@msg invalid pattern tile choice nevažeći izbor pločice uzorka +@msg no pattern named `{choice}` nema uzorka pod nazivom `{choice}` Delete the selected brushes? Izbrisati odabrane četkice? Strokes use one or more of these brushes. Take the brushes off those strokes and delete the brushes? Potezi koriste jednu ili više ovih četkica. Ukloniti četkice s tih poteza i izbrisati četkice? Remove Strokes Ukloni poteze diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index 15bd85222..b7a150666 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -5999,6 +5999,9 @@ breakText Glyphs Glifs Gap 4 Separació 4 Dash 5 Guió 5 Gap 5 Separació 5 +@msg You must choose a side pattern. Heu de triar un patró lateral. +@msg invalid pattern tile choice opció de mosaic de patró no vàlida +@msg no pattern named `{choice}` no hi ha cap patró anomenat `{choice}` Delete the selected brushes? Voleu suprimir els pinzells seleccionats? Strokes use one or more of these brushes. Take the brushes off those strokes and delete the brushes? Hi ha traços que fan servir un o més d’aquests pinzells. Voleu treure els pinzells d’aquests traços i suprimir els pinzells? Remove Strokes Elimina els traços diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index fe80401bc..75765ab75 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -6008,6 +6008,9 @@ breakText Glyphs Glyfy Gap 4 Mezera 4 Dash 5 Čárka 5 Gap 5 Mezera 5 +@msg You must choose a side pattern. Musíte zvolit boční vzorek. +@msg invalid pattern tile choice neplatná volba dlaždice vzorku +@msg no pattern named `{choice}` vzorek s názvem `{choice}` neexistuje Delete the selected brushes? Odstranit vybrané štětce? Strokes use one or more of these brushes. Take the brushes off those strokes and delete the brushes? Tahy používají jeden nebo více z těchto štětců. Odebrat štětce z těchto tahů a odstranit štětce? Remove Strokes Odebrat tahy diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv index 74107e22c..8140d38e1 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -5913,6 +5913,9 @@ breakText Glyphs Glyffer Gap 4 Mellemrum 4 Dash 5 Streg 5 Gap 5 Mellemrum 5 +@msg You must choose a side pattern. Du skal vælge et sidemønster. +@msg invalid pattern tile choice ugyldigt valg af mønsterflise +@msg no pattern named `{choice}` intet mønster med navnet `{choice}` Delete the selected brushes? Slet de valgte pensler? Strokes use one or more of these brushes. Take the brushes off those strokes and delete the brushes? Streger bruger en eller flere af disse pensler. Fjern penslerne fra stregerne, og slet penslerne? Remove Strokes Fjern streger diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 447c98350..fa638758f 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -5594,6 +5594,9 @@ breakText Glyphs Glyphen Gap 4 Lücke 4 Dash 5 Strich 5 Gap 5 Lücke 5 +@msg You must choose a side pattern. Sie müssen ein Seitenmuster auswählen. +@msg invalid pattern tile choice ungültige Auswahl einer Musterkachel +@msg no pattern named `{choice}` kein Muster mit dem Namen `{choice}` Delete the selected brushes? Ausgewählte Pinsel löschen? Strokes use one or more of these brushes. Take the brushes off those strokes and delete the brushes? Konturen verwenden einen oder mehrere dieser Pinsel. Pinsel von diesen Konturen entfernen und die Pinsel löschen? Remove Strokes Konturen entfernen diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 8dea59f7f..baf6f52ec 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -5961,6 +5961,9 @@ breakText Glyphs Γλύφοι Gap 4 Διάκενο 4 Dash 5 Παύλα 5 Gap 5 Διάκενο 5 +@msg You must choose a side pattern. Πρέπει να επιλέξετε πλευρικό μοτίβο. +@msg invalid pattern tile choice μη έγκυρη επιλογή πλακιδίου μοτίβου +@msg no pattern named `{choice}` δεν υπάρχει μοτίβο με όνομα `{choice}` Delete the selected brushes? Να διαγραφούν τα επιλεγμένα πινέλα; Strokes use one or more of these brushes. Take the brushes off those strokes and delete the brushes? Περιγράμματα χρησιμοποιούν ένα ή περισσότερα από αυτά τα πινέλα. Να αφαιρεθούν τα πινέλα από αυτά τα περιγράμματα και να διαγραφούν τα πινέλα; Remove Strokes Αφαίρεση περιγραμμάτων diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index e6c7dd769..7b5b6aae3 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -5951,6 +5951,9 @@ breakText Glyphs Glifos Gap 4 Hueco 4 Dash 5 Guion 5 Gap 5 Hueco 5 +@msg You must choose a side pattern. Debe elegir un motivo lateral. +@msg invalid pattern tile choice selección de mosaico de motivo no válida +@msg no pattern named `{choice}` no hay ningún motivo llamado `{choice}` Delete the selected brushes? ¿Eliminar los pinceles seleccionados? Strokes use one or more of these brushes. Take the brushes off those strokes and delete the brushes? Algunos trazos usan uno o más de estos pinceles. ¿Quitar los pinceles de esos trazos y eliminar los pinceles? Remove Strokes Quitar trazos diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index 08122ed02..606118dba 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -6039,6 +6039,9 @@ breakText Glyphs Merkit Gap 4 Väli 4 Dash 5 Viiva 5 Gap 5 Väli 5 +@msg You must choose a side pattern. Valitse sivukuvio. +@msg invalid pattern tile choice virheellinen kuviolaatan valinta +@msg no pattern named `{choice}` kuviota nimeltä `{choice}` ei löydy Delete the selected brushes? Poistetaanko valitut siveltimet? Strokes use one or more of these brushes. Take the brushes off those strokes and delete the brushes? Viivat käyttävät yhtä tai useampaa näistä siveltimistä. Irrotetaanko siveltimet näistä viivoista ja poistetaanko siveltimet? Remove Strokes Poista viivat diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index b35297550..5e8203d51 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -6006,6 +6006,9 @@ breakText Glyphs Glyphes Gap 4 Écart 4 Dash 5 Tiret 5 Gap 5 Écart 5 +@msg You must choose a side pattern. Vous devez choisir un motif latéral. +@msg invalid pattern tile choice choix de tuile de motif non valide +@msg no pattern named `{choice}` aucun motif nommé `{choice}` Delete the selected brushes? Supprimer les formes sélectionnées ? Strokes use one or more of these brushes. Take the brushes off those strokes and delete the brushes? Des contours utilisent une ou plusieurs de ces formes. Retirer les formes de ces contours et supprimer les formes ? Remove Strokes Supprimer les contours diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index cac7189ed..aec44044d 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -5995,6 +5995,9 @@ breakText Glyphs Glifák Gap 4 Hézag 4 Dash 5 Vonal 5 Gap 5 Hézag 5 +@msg You must choose a side pattern. Válasszon oldalsó mintát. +@msg invalid pattern tile choice érvénytelen mintacsempe-választás +@msg no pattern named `{choice}` nincs `{choice}` nevű minta Delete the selected brushes? Törlöd a kijelölt ecseteket? Strokes use one or more of these brushes. Take the brushes off those strokes and delete the brushes? Körvonalak használnak egyet vagy többet ezek közül az ecsetek közül. Leveszed az ecseteket ezekről a körvonalakról, és törlöd az ecseteket? Remove Strokes Körvonalak eltávolítása diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 29e87826a..5329eea88 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -5913,6 +5913,9 @@ breakText Glyphs Glif Gap 4 Jarak 4 Dash 5 Garis 5 Gap 5 Jarak 5 +@msg You must choose a side pattern. Anda harus memilih pola sisi. +@msg invalid pattern tile choice pilihan ubin pola tidak valid +@msg no pattern named `{choice}` tidak ada pola bernama `{choice}` Delete the selected brushes? Hapus kuas yang dipilih? Strokes use one or more of these brushes. Take the brushes off those strokes and delete the brushes? Stroke menggunakan satu atau beberapa kuas ini. Lepaskan kuas dari stroke tersebut dan hapus kuasnya? Remove Strokes Hapus Stroke diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 51870220a..34c18549d 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -5628,6 +5628,9 @@ breakText Glyphs Glifi Gap 4 Spazio 4 Dash 5 Tratto 5 Gap 5 Spazio 5 +@msg You must choose a side pattern. Devi scegliere un motivo laterale. +@msg invalid pattern tile choice scelta della tessera motivo non valida +@msg no pattern named `{choice}` nessun motivo chiamato `{choice}` Delete the selected brushes? Eliminare i pennelli selezionati? Strokes use one or more of these brushes. Take the brushes off those strokes and delete the brushes? Alcune tracce usano uno o più di questi pennelli. Rimuovere i pennelli da queste tracce ed eliminare i pennelli? Remove Strokes Rimuovi tracce diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index c6c4227b6..5d8e12074 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -5694,6 +5694,9 @@ breakText Glyphs 字形 Gap 4 間隔 4 Dash 5 線分 5 Gap 5 間隔 5 +@msg You must choose a side pattern. 側面パターンを選択してください。 +@msg invalid pattern tile choice パターンタイルの選択が無効です +@msg no pattern named `{choice}` `{choice}` というパターンはありません Delete the selected brushes? 選択したブラシを削除しますか? Strokes use one or more of these brushes. Take the brushes off those strokes and delete the brushes? これらのブラシの 1 つ以上が線で使われています。それらの線からブラシを外して、ブラシを削除しますか? Remove Strokes 線を削除 diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index c02d40eb1..ab0f0704d 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -5998,6 +5998,9 @@ breakText Glyphs გლიფები Gap 4 შუალედი 4 Dash 5 ტირე 5 Gap 5 შუალედი 5 +@msg You must choose a side pattern. აირჩიეთ გვერდითი ნიმუში. +@msg invalid pattern tile choice ნიმუშის ფილა არასწორად არის არჩეული +@msg no pattern named `{choice}` ნიმუში სახელით `{choice}` არ არსებობს Delete the selected brushes? წაიშალოს არჩეული ფუნჯები? Strokes use one or more of these brushes. Take the brushes off those strokes and delete the brushes? კონტურები იყენებს ამ ფუნჯებიდან ერთს ან მეტს. მოიხსნას ფუნჯები ამ კონტურებიდან და წაიშალოს ფუნჯები? Remove Strokes კონტურების მოხსნა diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 5c24bd9ca..5da60d247 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -5591,6 +5591,9 @@ breakText Glyphs 글리프 Gap 4 간격 4 Dash 5 대시 5 Gap 5 간격 5 +@msg You must choose a side pattern. 측면 패턴을 선택해야 합니다. +@msg invalid pattern tile choice 잘못된 패턴 타일 선택 +@msg no pattern named `{choice}` `{choice}` 이름의 패턴이 없습니다 Delete the selected brushes? 선택한 브러시를 삭제하시겠습니까? Strokes use one or more of these brushes. Take the brushes off those strokes and delete the brushes? 선에서 이 브러시 중 하나 이상을 사용합니다. 해당 선에서 브러시를 제거하고 브러시를 삭제하시겠습니까? Remove Strokes 선 제거 diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index 282b0a4aa..ee4e09b61 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -5957,6 +5957,9 @@ breakText Glyphs Glyphs Gap 4 Tussenruimte 4 Dash 5 Streepje 5 Gap 5 Tussenruimte 5 +@msg You must choose a side pattern. Kies een zijpatroon. +@msg invalid pattern tile choice ongeldige keuze voor patroontegel +@msg no pattern named `{choice}` geen patroon met de naam `{choice}` Delete the selected brushes? Geselecteerde kwasten verwijderen? Strokes use one or more of these brushes. Take the brushes off those strokes and delete the brushes? Contouren gebruiken een of meer van deze kwasten. De kwasten van deze contouren halen en de kwasten verwijderen? Remove Strokes Contouren verwijderen diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 399971447..5c90b4d97 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -5864,6 +5864,9 @@ breakText Glyphs Glify Gap 4 Odstęp 4 Dash 5 Kreska 5 Gap 5 Odstęp 5 +@msg You must choose a side pattern. Musisz wybrać wzór boczny. +@msg invalid pattern tile choice nieprawidłowy wybór kafelka wzoru +@msg no pattern named `{choice}` nie ma wzoru o nazwie `{choice}` Delete the selected brushes? Usunąć zaznaczone pędzle? Strokes use one or more of these brushes. Take the brushes off those strokes and delete the brushes? Obrysy używają co najmniej jednego z tych pędzli. Zdjąć pędzle z tych obrysów i usunąć pędzle? Remove Strokes Usuń obrysy diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 4f75dd659..0694d8f18 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -5928,6 +5928,9 @@ breakText Glyphs Glifos Gap 4 Lacuna 4 Dash 5 Traço 5 Gap 5 Lacuna 5 +@msg You must choose a side pattern. Você deve escolher um padrão lateral. +@msg invalid pattern tile choice escolha inválida de ladrilho de padrão +@msg no pattern named `{choice}` não há padrão chamado `{choice}` Delete the selected brushes? Excluir os pincéis selecionados? Strokes use one or more of these brushes. Take the brushes off those strokes and delete the brushes? Traçados usam um ou mais destes pincéis. Remover os pincéis desses traçados e excluir os pincéis? Remove Strokes Remover Traçados diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index d412fa13b..105ba72d1 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -5986,6 +5986,9 @@ breakText Glyphs Глифы Gap 4 Зазор 4 Dash 5 Штрих 5 Gap 5 Зазор 5 +@msg You must choose a side pattern. Выберите боковой узор. +@msg invalid pattern tile choice недопустимый выбор плитки узора +@msg no pattern named `{choice}` узор с именем `{choice}` не найден Delete the selected brushes? Удалить выбранные кисти? Strokes use one or more of these brushes. Take the brushes off those strokes and delete the brushes? Обводки используют одну или несколько из этих кистей. Убрать кисти с этих обводок и удалить кисти? Remove Strokes Убрать обводки diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index 2a1ba3745..483111988 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -6061,6 +6061,9 @@ breakText Glyphs Glyfer Gap 4 Mellanrum 4 Dash 5 Streck 5 Gap 5 Mellanrum 5 +@msg You must choose a side pattern. Du måste välja ett sidomönster. +@msg invalid pattern tile choice ogiltigt val av mönsterplatta +@msg no pattern named `{choice}` inget mönster med namnet `{choice}` Delete the selected brushes? Radera de valda penslarna? Strokes use one or more of these brushes. Take the brushes off those strokes and delete the brushes? Konturer använder en eller flera av de här penslarna. Ta bort penslarna från konturerna och radera penslarna? Remove Strokes Ta bort konturer diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 215b7f8e6..23bdb6839 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -5966,6 +5966,9 @@ breakText Glyphs Glifler Gap 4 Boşluk 4 Dash 5 Çizgi 5 Gap 5 Boşluk 5 +@msg You must choose a side pattern. Bir yan desen seçmelisiniz. +@msg invalid pattern tile choice geçersiz desen döşemesi seçimi +@msg no pattern named `{choice}` `{choice}` adlı desen yok Delete the selected brushes? Seçili fırçalar silinsin mi? Strokes use one or more of these brushes. Take the brushes off those strokes and delete the brushes? Konturlar bu fırçalardan birini veya daha fazlasını kullanıyor. Fırçalar bu konturlardan kaldırılsın ve fırçalar silinsin mi? Remove Strokes Konturları Kaldır diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 13fab3c51..dde23c742 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -5608,6 +5608,9 @@ breakText Glyphs Гліфи Gap 4 Проміжок 4 Dash 5 Штрих 5 Gap 5 Проміжок 5 +@msg You must choose a side pattern. Виберіть бічний візерунок. +@msg invalid pattern tile choice недійсний вибір плитки візерунка +@msg no pattern named `{choice}` візерунка з назвою `{choice}` немає Delete the selected brushes? Видалити вибрані пензлі? Strokes use one or more of these brushes. Take the brushes off those strokes and delete the brushes? Обведення використовують один або кілька з цих пензлів. Забрати пензлі з цих обведень і видалити пензлі? Remove Strokes Забрати обведення diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index f45d5d04a..e060c0999 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -5610,6 +5610,9 @@ breakText Glyphs Glyph Gap 4 Khoảng hở 4 Dash 5 Nét 5 Gap 5 Khoảng hở 5 +@msg You must choose a side pattern. Bạn phải chọn mẫu bên. +@msg invalid pattern tile choice lựa chọn ô mẫu không hợp lệ +@msg no pattern named `{choice}` không có mẫu tên `{choice}` Delete the selected brushes? Xóa các cọ đã chọn? Strokes use one or more of these brushes. Take the brushes off those strokes and delete the brushes? Có nét đang dùng một hoặc nhiều cọ này. Gỡ cọ khỏi các nét đó và xóa cọ? Remove Strokes Gỡ nét diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 4e105573f..65842950a 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -5850,6 +5850,9 @@ breakText Glyphs 字形 Gap 4 间隔 4 Dash 5 虚线 5 Gap 5 间隔 5 +@msg You must choose a side pattern. 必须选择侧边图案。 +@msg invalid pattern tile choice 图案拼块选项无效 +@msg no pattern named `{choice}` 没有名为 `{choice}` 的图案 Delete the selected brushes? 删除选中的画笔吗? Strokes use one or more of these brushes. Take the brushes off those strokes and delete the brushes? 有描边使用了其中一个或多个画笔。要从这些描边移除画笔并删除画笔吗? Remove Strokes 移除描边 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index acb829199..fb06daa66 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -5647,6 +5647,9 @@ breakText Glyphs 字符 Gap 4 間隙 4 Dash 5 虛線 5 Gap 5 間隙 5 +@msg You must choose a side pattern. 必須選擇側邊圖樣。 +@msg invalid pattern tile choice 圖樣拼塊選項無效 +@msg no pattern named `{choice}` 沒有名為 `{choice}` 的圖樣 Delete the selected brushes? 要刪除選取的筆刷嗎? Strokes use one or more of these brushes. Take the brushes off those strokes and delete the brushes? 有筆畫使用了其中一個或多個筆刷。要從這些筆畫移除筆刷並刪除筆刷嗎? Remove Strokes 移除筆畫 diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 107350b74..c6b855ffd 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -40,7 +40,7 @@ pub use color::{ pub use drag::{PanelDrag, SwatchRows, art_drop, doc_preview, drag_source}; #[cfg(test)] use dropdowns::combo_button_id; -pub(crate) use dropdowns::{combo, combo_menu}; +pub(crate) use dropdowns::{combo, combo_menu, combo_named}; pub use dropdowns::{ dropdown, dropdown_named, dropdown_names, dropdown_names_named, dropdown_with, dropdown_with_named, list_scroll_style, menu_room, menu_scroll, panel_scroll, popover, set_menu_floor, strip_scroll, diff --git a/docs/gaps.md b/docs/gaps.md index b7ffe7f75..072ee477a 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -536,13 +536,11 @@ from G14 and the budget run of G15: **~170–270 h** one agent, ~50–80 h wall dragging one onto the Brushes panel adds it; Save Brushes / Save Brush Library writes the document's brushes as a `.vectorcraft` library into the user library folder (listed under User Defined) or a file (`brush.libraries`, `brush.addFromLibrary`, `brush.saveLibrary`, `brush.loadLibrary`). -- **Missing:** the Art brush's Overlap, auto-generated Pattern brush corners, new tile art in Pattern Brush - Options; more built-in brush, symbol and graphic-style libraries (original, generated in code, never - Adobe's); dynamic symbols; Global Edit of similar groups (edits to an object inside a group reaching the - matching objects of similar groups, [public source](https://helpx.adobe.com/illustrator/desktop/manage-objects/edit-objects/edit-groups-with-similar-objects.html)), +- **Missing:** the Art brush's Overlap; more built-in brush, symbol and graphic-style libraries (original, + generated in code, never Adobe's); dynamic symbols; Global Edit of similar groups (edits to an object inside a + group reaching the matching objects of similar groups, [public source](https://helpx.adobe.com/illustrator/desktop/manage-objects/edit-objects/edit-groups-with-similar-objects.html)), rotated copies as matches, Shift-click to leave a match out and highlighting matches before the start; drawing the 9-slice guides on the canvas in symbol edit mode and scaling by them. - Options; more built-in brush and symbol libraries (original, never Adobe's); dynamic symbols; Start Global Edit; drawing the 9-slice guides on the canvas in symbol edit mode and scaling by them. - **Estimate:** 15–26 h. ## G3 3D and Materials diff --git a/docs/mcp.md b/docs/mcp.md index 1f843271e..0595340df 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -1578,7 +1578,12 @@ already walks Image Trace. Short agent entry points: what that kind of corner draws when the brush has no `outer_corner` or `inner_corner` art for it: `none` draws no corner tile, and `autoCentered`, `autoBetween`, `autoSliced` and `autoOverlap` make one from the side tile. Without a source, the corner draws the other kind of corner's art. `brush.options {params: {outer_corner: null}}` removes - the art. + the art. Pattern Brush Options also accepts `side_tile`, `outer_corner_tile`, `inner_corner_tile`, + `start_tile` and `end_tile` in `params`. Each takes `none`, `original` or the name of a pattern + swatch in the active document, and `{"pattern": ""}` names a swatch even when its name is one of those + words. Corner tiles also take `autoCentered`, `autoBetween`, `autoSliced` and `autoOverlap`. A swatch choice copies its whole art and tile frame into the brush. `original` + keeps the current art and clears its swatch label. `side_tile: "none"` fails with "You must choose a + side pattern." An unknown swatch name also fails; neither error edits the brush or adds an Undo step. - Variables: `variable.list`, `variable.define {name, kind: text|visibility|image|graph}`, `variable.bind {variable, ids?}`, `variable.makeTextDynamic`, `variable.makeVisibilityDynamic`, `variable.makeImageDynamic`, `variable.makeGraphDynamic`, `variable.unbind`, `variable.rename`, `variable.delete`, `variable.highlight` (image values are file paths, graph diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index c53382a21..3d706ba6b 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -954,7 +954,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur With a gradient stop or a freeform gradient point selected, Alt-clicking a color swatch in the Swatches panel recolors it instead of the inactive Fill or Stroke. - **Freeform gradient colors in their own model:** Object ▸ Expand and Object ▸ Create Gradient Mesh (`object.mesh.create`) mix a freeform gradient's points in the color model they share: points of C50 M20 Y0 K10 give mesh points of C50 M20 Y0 K10, and RGB points keep their values in a working RGB space other than sRGB. Points in different models mix in display RGB, and a CMYK document stores the result as CMYK through the color settings. Points colored along CMYK stops (`paint.editGradient {stops}`, or a CMYK gradient switched to freeform) are CMYK, and so are a point `paint.freeform.splitLine` adds between CMYK points and a point `paint.freeform.addPoint` adds without `color` to CMYK points. Public reference: [Gradients](https://web.archive.org/web/20250714042311/https://helpx.adobe.com/illustrator/using/gradients.html) and [Meshes](https://web.archive.org/web/20250812201019/https://helpx.adobe.com/illustrator/using/meshes.html), which do not state how colors in different models mix; the rule is VectorCraft's own. - **Strokes:** one stroke-geometry module shared by the canvas, SVG, PDF and Outline Stroke; dotted lines (zero-length dashes draw dots or squares); arrowheads with hollow outlines, tip-on-end or extend-past-end alignment and one opacity for line and head. SVG and PDF export arrowheads, width profiles, brushes and aligned strokes exactly as the canvas draws them; Outline Stroke and the live Outline Stroke effect outline every stroke item as drawn (dashes, heads, profiles, alignment); dashes can be fitted to corners and path ends; width profiles carry through dashes, and their corners take the join. The Stroke panel edits type's character strokes, follows the stroke units (its weight presets are round values in that unit: `0.25 mm`, `0.0625 in`, `1 px`, #426; fields show four decimals in mm, cm and inches, three in points), shows mixed values blank and opens from the Control bar. A width profile library, thirty more arrowheads, and Width Point Edit with multi-select and discontinuous points. Visual bounds and clicks cover the whole painted stroke. Scale Strokes & Effects, Scale Corners and Use Preview Bounds work everywhere, and new art inherits stroke options, a graphic style or the whole last appearance. - - **Brushes (part of #852):** double-clicking a brush in the Brushes panel (or the panel menu's Brush Options…) opens Calligraphic Brush Options (Name; Angle, Roundness and Size, each Fixed, Random or Pressure with its Variation; live previews of the nib and of a light–heavy–light stroke) or Bristle Brush Options (Shape and the bristle sliders); OK edits the brush and every stroke painted with it. The Paintbrush records the pen pressure along each stroke (kept with the path, saved, and joined when a stroke continues a path); a Calligraphic value on Pressure goes from value − variation at the lightest pressure to value + variation at the heaviest, on the canvas and in SVG, PDF and the other exports. Brushes saved before keep their look. Scatter Brush Options (Size, Spacing, Scatter and Rotation, each Fixed, Random between two values or Pressure from the first at the lightest pressure to the second at the heaviest; Rotation relative to Page or Path), Art Brush Options (Width, Scale Proportionately, Stretch to Fit Stroke Length or Stretch Between Guides with the guides' start and end, the four Directions, Flip Along and Across) and Pattern Brush Options (Scale, Spacing, the five tiles shown, Flip Along and Across, Stretch to fit, Add space to fit or Approximate path) each have Colorization (None, Tints, Tints and Shades, Hue Shift with its Key Color picked from the art's colours) and a live stroke preview; values from files and commands are clamped to their ranges. Still to come: the Art brush's Overlap, auto-generated Pattern brush corners, new tile art picked in Pattern Brush Options, Stylus Wheel, Tilt, Bearing and Rotation, and pen pressure on macOS. + - **Brushes (part of #852):** double-clicking a brush in the Brushes panel (or the panel menu's Brush Options…) opens Calligraphic Brush Options (Name; Angle, Roundness and Size, each Fixed, Random or Pressure with its Variation; live previews of the nib and of a light–heavy–light stroke) or Bristle Brush Options (Shape and the bristle sliders); OK edits the brush and every stroke painted with it. The Paintbrush records the pen pressure along each stroke (kept with the path, saved, and joined when a stroke continues a path); a Calligraphic value on Pressure goes from value − variation at the lightest pressure to value + variation at the heaviest, on the canvas and in SVG, PDF and the other exports. Brushes saved before keep their look. Scatter Brush Options (Size, Spacing, Scatter and Rotation, each Fixed, Random between two values or Pressure from the first at the lightest pressure to the second at the heaviest; Rotation relative to Page or Path), Art Brush Options (Width, Scale Proportionately, Stretch to Fit Stroke Length or Stretch Between Guides with the guides' start and end, the four Directions, Flip Along and Across) and Pattern Brush Options (Scale, Spacing, the five tiles shown, Flip Along and Across, Stretch to fit, Add space to fit or Approximate path) each have Colorization (None, Tints, Tints and Shades, Hue Shift with its Key Color picked from the art's colours) and a live stroke preview; values from files and commands are clamped to their ranges. Still to come: the Art brush's Overlap, Stylus Wheel, Tilt, Bearing and Rotation, and pen pressure on macOS. Type in a scatter brush's art takes the brush's colorization as its paths do: Tints, Tints and Shades and Hue Shift recolor the characters' solid fills and strokes in each copy the brush draws, and Brush Options' Key Color list, whose first color is Hue Shift's default key, counts the characters' fills and their strokes wider than 0. The Brushes page says that a scatter, art or pattern brush takes its colors from the current stroke color and the brush's colorization method () and does not mention type there; recoloring the characters' paints like the art's fills and strokes is VectorCraft's own rule. The Symbol Stainer mixes the characters' strokes of type toward the stain as it mixes their fills. Evidence: `tests::scatter_colorization_recolors_type`, `tests::art_colors_list_the_most_used_first`, `tests::stain_mixes_type_strokes_too` (vectorcraft-brush). Art Brush Options' Width (1% to 1000%) and Pattern Brush Options' Scale (1% to 10000%) each have a slider beside the field, over the same range in whole percents; screen readers name the slider as its row and read the field's value. Art Brush Options' Width has a Fixed or Pressure menu, from the pen tablet options of the Width pop-up menu on the [Brushes](https://web.archive.org/web/20241230205225/https://helpx.adobe.com/illustrator/using/brushes.html) page (archived copy). With Pressure, the art is Width wide at the lightest pen pressure and the second width at the heaviest, following the pressure the Paintbrush recorded along the stroke, and half way between them on a stroke without recorded pressure (`brush.options` `width_mode` and `width_max`); a stroke inside the art takes the width at the middle of its object. The page does not say how the pressure maps to the width; taking the Scatter brush's minimum and maximum rule and the stroke rule are VectorCraft's own. Stylus Wheel, Tilt, Bearing and Rotation are not built. @@ -968,6 +968,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur 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. In Pattern Brush Options, a menu below the Outer Corner Tile and the Inner Corner Tile sets each to the brush's own corner art (Original), None, or a tile made from the side tile: Auto-Centered stretches one side tile around the corner, Auto-Between bends a side tile on each side into the corner's diagonal, Auto-Sliced cuts a side tile on each side along the diagonal, and Auto-Overlap runs a side tile on each side past the corner. Auto-Sliced masks the tiles at the diagonal, so their outlines stay as drawn. Brushes saved without these choices draw their corners as before (`brush.options` `outer_source`, `inner_source`). Evidence: `tests::pattern_auto_corners_fill_each_corner_of_a_square`, `tests::pattern_corner_source_none_leaves_that_kind_of_corner_empty` (vectorcraft-brush), `dialogs::brush_options::tests::pattern_options_set_each_corner_tile_to_original_none_or_an_automatic_tile` (vectorcraft-ui-egui). + - The [About brushes](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/apply-and-edit-strokes/about-brushes.html) page describes up to five Pattern brush tiles: sides, inner corner, outer corner, beginning and end. The archived [Brushes](https://web.archive.org/web/20241230205225/https://helpx.adobe.com/illustrator/using/brushes.html) page says to add pattern tiles to the Swatches panel and select a swatch from the scroll list for each tile. Pattern Brush Options offers document pattern swatches in all five tile menus, including swatches named like built-in choices. The brush copies each swatch's whole art and tile frame. Side requires art; explicit None on a corner prevents fallback to the other corner's art. After a swatch is deleted, the brush keeps drawing its copied art and shows it as Original. The cited pages do not describe copying the whole art, requiring Side, suppressing corner fallback after explicit None, or showing deleted swatch art as Original. `brush.options` accepts swatch names and an object form for names that match built-in choices, and rejects unknown patterns before editing. Evidence: `tests::brushsym::pattern_tile_choices_copy_whole_swatch_art_and_reject_invalid_side` and `tests::brushsym::pattern_tile_object_selects_reserved_swatch_names` (vectorcraft-engine), `tests::pattern_corner_source_none_leaves_that_kind_of_corner_empty` (vectorcraft-brush), and `dialogs::brush_options::tests::pattern_tile_menus_use_document_patterns_and_side_none_keeps_dialog_open`, `dialogs::brush_options::tests::pattern_options_borrowed_swatch_corner_keeps_its_frame`, and `dialogs::brush_options::tests::pattern_options_selects_swatches_with_reserved_names` (vectorcraft-ui-egui). - **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.