From d4a30cc084b8d3552e09b23c927cb6776ca91b6a Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 28 Oct 2026 04:21:22 -0700 Subject: [PATCH 1/3] Brushes: Pattern Brush Options' corner tiles can be None or made from the side tile Pattern Brush Options has a menu below the Outer Corner Tile and the Inner Corner Tile. Each can be the brush's own corner art (Original), None, or one of four tiles made from the side tile at each corner of the path: - Auto-Centered stretches one side tile around the corner, centered on it. - Auto-Between bends a side tile on each side into the corner, the two meeting on the corner's diagonal. - Auto-Sliced lays a side tile on each side past the corner and masks it at the diagonal, so the two meet like a mitered frame. - Auto-Overlap lays a side tile on each side past the corner, overlapping. The tiles are straight at the corner and follow the path towards the side tiles, so they meet a curve that runs into the corner. A side tile taller than the corner can reach is stretched along until the two sides meet. On a short path, a corner's tile is squeezed along so that it covers at most the path up to an open path's end and half way to the next corner, and its two sides still meet on the corner's diagonal. The definition stores the choice as `outer_source` and `inner_source` (`none`, `autoCentered`, `autoBetween`, `autoSliced`, `autoOverlap`), which `brush.options` sets. Corner art wins over a source. A brush saved without a source draws as before: a kind of corner without art takes the other kind's art. None leaves that kind of corner empty. Docs: docs/mcp.md, docs/target-app-parity.md. --- crates/brush/src/lib.rs | 48 +++ crates/brush/src/tests.rs | 299 ++++++++++++++++++ crates/brush/src/warp.rs | 233 ++++++++++++-- crates/engine/src/cmd/brushsym.rs | 2 +- .../ui-egui/src/dialogs/brush_art_options.rs | 111 +++++-- .../src/dialogs/brush_options/tests.rs | 66 ++++ crates/ui-egui/src/i18n/bg.tsv | 4 + crates/ui-egui/src/i18n/cs.tsv | 4 + crates/ui-egui/src/i18n/da.tsv | 4 + crates/ui-egui/src/i18n/de.tsv | 4 + crates/ui-egui/src/i18n/el.tsv | 4 + crates/ui-egui/src/i18n/es.tsv | 4 + crates/ui-egui/src/i18n/fi.tsv | 4 + crates/ui-egui/src/i18n/fr.tsv | 4 + crates/ui-egui/src/i18n/hu.tsv | 4 + crates/ui-egui/src/i18n/id.tsv | 4 + crates/ui-egui/src/i18n/it.tsv | 4 + crates/ui-egui/src/i18n/ja.tsv | 4 + crates/ui-egui/src/i18n/ka.tsv | 4 + crates/ui-egui/src/i18n/ko.tsv | 4 + crates/ui-egui/src/i18n/nl.tsv | 4 + crates/ui-egui/src/i18n/pl.tsv | 4 + crates/ui-egui/src/i18n/pt-br.tsv | 4 + crates/ui-egui/src/i18n/ru.tsv | 4 + crates/ui-egui/src/i18n/sv.tsv | 4 + crates/ui-egui/src/i18n/tr.tsv | 4 + crates/ui-egui/src/i18n/uk.tsv | 4 + crates/ui-egui/src/i18n/vi.tsv | 4 + crates/ui-egui/src/i18n/zh-hans.tsv | 4 + crates/ui-egui/src/i18n/zh-hant.tsv | 4 + docs/mcp.md | 6 +- docs/target-app-parity.md | 1 + 32 files changed, 819 insertions(+), 43 deletions(-) diff --git a/crates/brush/src/lib.rs b/crates/brush/src/lib.rs index 422f09d9c..e76e67ba5 100644 --- a/crates/brush/src/lib.rs +++ b/crates/brush/src/lib.rs @@ -343,6 +343,44 @@ pub enum PatternFit { Approximate, } +/// What a pattern brush draws at a kind of corner it has no corner art for: no tile, or a tile +/// made from the side tile. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum CornerSource { + /// No corner tile: the side tiles run up to the corner. + None, + /// One side tile stretched around the corner and centered on it. + AutoCentered, + /// A side tile on each side reaching into the corner, stretched so the two meet on the + /// corner's diagonal. + AutoBetween, + /// A side tile on each side cut on the corner's diagonal, the two meeting like a mitered frame. + AutoSliced, + /// A side tile on each side running past the corner, the two overlapping there. + AutoOverlap, +} + +impl CornerSource { + pub const ALL: [CornerSource; 5] = + [CornerSource::None, CornerSource::AutoCentered, CornerSource::AutoBetween, CornerSource::AutoSliced, CornerSource::AutoOverlap]; + + /// The id in a brush definition and in `brush.options` params. + pub fn id(self) -> &'static str { + match self { + CornerSource::None => "none", + CornerSource::AutoCentered => "autoCentered", + CornerSource::AutoBetween => "autoBetween", + CornerSource::AutoSliced => "autoSliced", + CornerSource::AutoOverlap => "autoOverlap", + } + } + + pub fn parse(s: &str) -> Option { + CornerSource::ALL.into_iter().find(|a| a.id() == s) + } +} + /// Pattern brush: tiles repeated along the path, with corner and end tiles. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(default)] @@ -352,6 +390,14 @@ pub struct PatternBrush { pub outer_corner: Option, #[serde(skip_serializing_if = "Option::is_none")] pub inner_corner: 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")] + pub outer_source: Option, + /// What the inner corners take when there is no `inner_corner` art. Without art or a source, + /// they take the `outer_corner` art. + #[serde(skip_serializing_if = "Option::is_none")] + pub inner_source: Option, #[serde(skip_serializing_if = "Option::is_none")] pub start: Option, #[serde(skip_serializing_if = "Option::is_none")] @@ -378,6 +424,8 @@ impl Default for PatternBrush { side: defaults::stitch_tile(), outer_corner: None, inner_corner: None, + outer_source: None, + inner_source: None, start: None, end: None, scale: 100.0, diff --git a/crates/brush/src/tests.rs b/crates/brush/src/tests.rs index 0968f5d6c..1945f4527 100644 --- a/crates/brush/src/tests.rs +++ b/crates/brush/src/tests.rs @@ -284,6 +284,305 @@ fn pattern_places_corner_tiles() { assert_eq!(out.len() - corners, 36); } +/// The 10 × 4 pt side tile of [`pattern_brush`] with the given corner sources and no corner art. +fn auto_corner_brush(outer: Option, inner: Option) -> Brush { + let mut b = pattern_brush(PatternFit::Stretch, false); + if let BrushKind::Pattern(p) = &mut b.kind { + p.outer_source = outer; + p.inner_source = inner; + } + b +} + +const AUTO: [CornerSource; 4] = [CornerSource::AutoCentered, CornerSource::AutoBetween, CornerSource::AutoSliced, CornerSource::AutoOverlap]; + +/// Whether `n` paints a fill over `q`: inside one of its paths, and inside the mask of every +/// clipping group it is in. +fn covers(n: &Node, q: Point) -> bool { + let inside = |n: &Node| n.path_data().is_some_and(|p| kurbo::Shape::winding(&p.to_bezpath(), q) != 0); + match &n.kind { + NodeKind::Group { children, clip: true } => children.first().is_some_and(|m| inside(m)) && children.iter().skip(1).any(|c| covers(c, q)), + NodeKind::Path { .. } => inside(n), + _ => n.children().is_some_and(|c| c.iter().any(|c| covers(c, q))), + } +} + +fn points_of(nodes: &[Node]) -> Vec { + let mut pts = vec![]; + for n in nodes { + all_points(n, &mut pts); + } + pts +} + +/// The corners of a 100 pt square and, for each, the outer corner of the 4 pt band around it. +const SQUARE_CORNERS: [((f64, f64), (f64, f64)); 4] = + [((0.0, 0.0), (-2.0, -2.0)), ((100.0, 0.0), (102.0, -2.0)), ((100.0, 100.0), (102.0, 102.0)), ((0.0, 100.0), (-2.0, 102.0))]; + +/// Whether some point of `pts` is within `tol` of `(x, y)`. +fn reaches(pts: &[Point], (x, y): (f64, f64), tol: f64) -> bool { + pts.iter().any(|p| p.distance(Point::new(x, y)) <= tol) +} + +/// Distance from `p` to the outline of the square (0, 0)–(100, 100). +fn from_square_outline(p: Point) -> f64 { + let dx = (p.x - p.x.clamp(0.0, 100.0)).abs(); + let dy = (p.y - p.y.clamp(0.0, 100.0)).abs(); + if dx > 0.0 || dy > 0.0 { dx.hypot(dy) } else { p.x.min(100.0 - p.x).min(p.y).min(100.0 - p.y) } +} + +#[test] +fn pattern_auto_corners_fill_each_corner_of_a_square() { + let sq = shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)).to_bezpath(); + let mut outputs = vec![]; + for kind in AUTO { + let out = stroke_pieces(&Document::new(1.0, 1.0), &auto_corner_brush(Some(kind), Some(kind)), &sq, &stroke(1.0)); + let pts = points_of(&out); + for (corner, outside) in SQUARE_CORNERS { + assert!(reaches(&pts, outside, 0.8), "{kind:?}: the art reaches the band's outer corner at {corner:?}"); + } + let far = pts.iter().copied().map(from_square_outline).fold(0.0, f64::max); + assert!(far <= 2.0 * 2f64.sqrt() + 0.05, "{kind:?}: every point stays in the 4 pt band, {far}"); + // The band is filled up to each corner along both of its sides. + let filled = |x: f64, y: f64| out.iter().any(|n| covers(n, Point::new(x, y))); + for ((cx, cy), _) in SQUARE_CORNERS { + let (sx, sy) = ((50.0 - cx).signum(), (50.0 - cy).signum()); + for d in [0.5, 2.5, 4.5, 6.5, 8.5] { + assert!(filled(cx + sx * d, cy) && filled(cx, cy + sy * d), "{kind:?}: filled {d} pt from ({cx}, {cy})"); + } + } + outputs.push(out); + } + for i in 0..outputs.len() { + for j in i + 1..outputs.len() { + assert_ne!(outputs[i], outputs[j], "{:?} and {:?} draw differently", AUTO[i], AUTO[j]); + } + } +} + +/// A side tile three times as tall as it is long still fills a square's corners without a gap: +/// the two sides meet on the corner's diagonal. +#[test] +fn pattern_auto_corners_of_a_tall_tile_meet_on_the_diagonal() { + let sq = shapes::rectangle(Rect::new(0.0, 0.0, 200.0, 200.0)).to_bezpath(); + for kind in AUTO { + let mut b = auto_corner_brush(Some(kind), Some(kind)); + if let BrushKind::Pattern(p) = &mut b.kind { + p.side = rect_art(10.0, 30.0); + } + let out = stroke_pieces(&Document::new(1.0, 1.0), &b, &sq, &stroke(1.0)); + let filled = |x: f64, y: f64| out.iter().any(|n| covers(n, Point::new(x, y))); + for (cx, cy) in [(0.0_f64, 0.0_f64), (200.0, 0.0), (200.0, 200.0), (0.0, 200.0)] { + let (sx, sy) = ((100.0 - cx).signum(), (100.0 - cy).signum()); + for d in [1.0, 5.0, 10.0, 14.0] { + // Along the diagonal, outside and inside the square, and along both sides. + assert!(filled(cx - sx * d, cy - sy * d), "{kind:?}: outside ({cx}, {cy}) by {d}"); + assert!(filled(cx + sx * d, cy + sy * d), "{kind:?}: inside ({cx}, {cy}) by {d}"); + assert!(filled(cx + sx * 3.0 * d, cy) && filled(cx, cy + sy * 3.0 * d), "{kind:?}: {} pt from ({cx}, {cy})", 3.0 * d); + } + } + } +} + +/// Where a curve meets a sharp corner, the corner tile reaches the side tiles on the curve: the +/// middle of the band is filled all the way along the path. +#[test] +fn pattern_auto_corners_join_a_curve_into_the_corner() { + // A straight run into a quarter circle of radius 10, then a sharp turn. + let mut bp = BezPath::new(); + bp.move_to((-50.0, 0.0)); + bp.line_to((10.0, 0.0)); + bp.curve_to((15.52, 0.0), (20.0, 4.48), (20.0, 10.0)); + bp.line_to((-80.0, 10.0)); + let track = &crate::track::tracks(&bp, 0.05)[0]; + let l = track.len(); + for kind in AUTO { + let out = stroke_pieces(&Document::new(1.0, 1.0), &auto_corner_brush(Some(kind), Some(kind)), &bp, &stroke(1.0)); + let mut s = 0.25; + while s < l - 0.25 { + let q = track.map(s, 0.0); + assert!(out.iter().any(|n| covers(n, q)), "{kind:?}: the band is filled at {s:.2} of {l:.2} ({q:?})"); + s += 0.25; + } + } +} + +/// Where a curve meets a sharp corner, the two halves of a corner tile still meet on the corner's +/// diagonal: the band is filled across its width near the corner, and both Auto-Between halves end +/// at the diagonal's two ends. +#[test] +fn pattern_auto_corners_after_a_curve_meet_across_the_band() { + // A quarter circle of radius 4 straight into a sharp turn. + let mut bp = BezPath::new(); + bp.move_to((-50.0, 0.0)); + bp.line_to((10.0, 0.0)); + bp.curve_to((12.21, 0.0), (14.0, 1.79), (14.0, 4.0)); + bp.line_to((-80.0, 4.0)); + let track = &crate::track::tracks(&bp, 0.05)[0]; + let corner = track.corner_positions().first().map(|c| c.1).unwrap(); + for kind in [CornerSource::AutoCentered, CornerSource::AutoBetween] { + let out = stroke_pieces(&Document::new(1.0, 1.0), &auto_corner_brush(Some(kind), Some(kind)), &bp, &stroke(1.0)); + let mut s = corner - 3.0; + while s < corner + 3.0 { + for j in -19..=19 { + let y = j as f64 / 10.0; + let q = track.map(s, y); + assert!(out.iter().any(|n| covers(n, q)), "{kind:?}: the band is filled at {s:.2} ({y} across, {q:?})"); + } + s += 0.05; + } + } + // The path leaves the corner at (14, 4) heading left, so the 4 pt band's diagonal ends on the + // lines y = 2 and y = 6, where both Auto-Between halves end at the same point. + let both = auto_corner_brush(Some(CornerSource::AutoBetween), Some(CornerSource::AutoBetween)); + let out = stroke_pieces(&Document::new(1.0, 1.0), &both, &bp, &stroke(1.0)); + for edge in [2.0, 6.0] { + let on_edge: Vec> = out + .iter() + .map(|n| points_of(std::slice::from_ref(n)).into_iter().filter(|q| near(q.y, edge, 1e-6) && near(q.x, 14.0, 3.0)).collect()) + .collect(); + let shared = on_edge.iter().enumerate().any(|(i, a)| on_edge.iter().skip(i + 1).any(|b| a.iter().any(|p| reaches(b, (p.x, p.y), 1e-6)))); + assert!(shared, "both halves end at the same point on y = {edge}"); + } +} + +/// On a path shorter than the side tile, a corner tile is squeezed into the path it has: it does +/// not run past the ends, nor past half the way to the next corner. +#[test] +fn pattern_auto_corners_stay_within_a_short_path() { + let mut bp = BezPath::new(); + bp.move_to((0.0, 0.0)); + bp.line_to((2.0, 0.0)); + bp.line_to((2.0, 2.0)); + let mut zigzag = BezPath::new(); + zigzag.move_to((0.0, 0.0)); + for j in 1..=6 { + zigzag.line_to((3.0 * j as f64, if j % 2 == 1 { 3.0 } else { 0.0 })); + } + for kind in AUTO { + for (path, (x0, y0, x1, y1)) in [(&bp, (0.0, 0.0, 2.0, 2.0)), (&zigzag, (0.0, 0.0, 18.0, 3.0))] { + let out = stroke_pieces(&Document::new(1.0, 1.0), &auto_corner_brush(Some(kind), Some(kind)), path, &stroke(1.0)); + assert!(!out.is_empty(), "{kind:?}: something is drawn"); + // The band is 4 pt wide, and the diagonal of a sharp turn reaches further from it. + let reach = 2.0 * 3.0; + for p in points_of(&out) { + assert!(p.x >= x0 - reach && p.x <= x1 + reach && p.y >= y0 - reach && p.y <= y1 + reach, "{kind:?}: {p:?} is past the path's ends"); + } + } + } +} + +/// A corner squeezed into a path shorter than the band is wide still fills the band up to the +/// corner's diagonal from both sides, with no gap between the two halves. +#[test] +fn pattern_auto_corners_on_a_short_path_meet_on_the_diagonal() { + let mut bp = BezPath::new(); + bp.move_to((0.0, 0.0)); + bp.line_to((1.0, 0.0)); + bp.line_to((1.0, 1.0)); + for kind in [CornerSource::AutoCentered, CornerSource::AutoBetween] { + let out = stroke_pieces(&Document::new(1.0, 1.0), &auto_corner_brush(Some(kind), Some(kind)), &bp, &stroke(1.0)); + // The diagonal runs from the corner (1, 0) to the band's outer corner (3, -2). + for f in [0.25, 0.5, 0.75] { + let (dx, dy) = (1.0 + 2.0 * f, -2.0 * f); + for q in [Point::new(dx - 0.05, dy), Point::new(dx, dy - 0.05)] { + assert!(out.iter().any(|n| covers(n, q)), "{kind:?}: the band is filled at {q:?}"); + } + } + } +} + +#[test] +fn pattern_without_corner_tiles_or_auto_corners_leaves_corners_empty() { + let sq = shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)).to_bezpath(); + let doc = Document::new(1.0, 1.0); + let plain = stroke_pieces(&doc, &auto_corner_brush(None, None), &sq, &stroke(1.0)); + assert_eq!(plain.len(), 40, "ten side tiles a side"); + let pts = points_of(&plain); + for (corner, outside) in SQUARE_CORNERS { + assert!(!reaches(&pts, outside, 1.5), "no art at the band's outer corner at {corner:?}"); + } + // A square turns the same way at every corner, so only one of the two kinds of corner tile + // draws on it; the other leaves the stroke as it is without automatic corners. + let outer = stroke_pieces(&doc, &auto_corner_brush(Some(CornerSource::AutoBetween), None), &sq, &stroke(1.0)); + let inner = stroke_pieces(&doc, &auto_corner_brush(None, Some(CornerSource::AutoBetween)), &sq, &stroke(1.0)); + assert!((outer == plain) != (inner == plain), "exactly one kind of corner draws on a square"); +} + +#[test] +fn pattern_corner_art_wins_over_corner_sources() { + let sq = shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)).to_bezpath(); + let doc = Document::new(1.0, 1.0); + let saved = pattern_brush(PatternFit::Stretch, true); + let mut with_auto = saved.clone(); + if let BrushKind::Pattern(p) = &mut with_auto.kind { + p.outer_source = Some(CornerSource::AutoSliced); + p.inner_source = Some(CornerSource::None); + } + assert_eq!(stroke_pieces(&doc, &with_auto, &sq, &stroke(1.0)), stroke_pieces(&doc, &saved, &sq, &stroke(1.0))); +} + +/// A kind of corner without art takes the other kind's art, as saved brushes draw, unless its +/// source is None. +#[test] +fn pattern_corner_source_none_leaves_that_kind_of_corner_empty() { + let sq = shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)).to_bezpath(); + let doc = Document::new(1.0, 1.0); + let draw = |b: &Brush| stroke_pieces(&doc, b, &sq, &stroke(1.0)); + let (saved, plain) = (draw(&pattern_brush(PatternFit::Stretch, true)), draw(&auto_corner_brush(None, None))); + let without = |outer: bool, source: Option| { + let mut b = pattern_brush(PatternFit::Stretch, true); + if let BrushKind::Pattern(p) = &mut b.kind { + if outer { + (p.outer_corner, p.outer_source) = (None, source); + } else { + (p.inner_corner, p.inner_source) = (None, source); + } + } + draw(&b) + }; + assert!(without(true, None) == saved && without(false, None) == saved, "the other kind's art fills in"); + let (outer, inner) = (without(true, Some(CornerSource::None)), without(false, Some(CornerSource::None))); + assert!((outer == plain && inner == saved) || (inner == plain && outer == saved), "only the corners set to None are empty"); +} + +#[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(); + assert!(saved.get("outer_source").is_none() && saved.get("inner_source").is_none(), "{saved}"); + let BrushKind::Pattern(p) = Brush::from_json(saved).unwrap().kind else { panic!("a pattern brush") }; + assert_eq!((p.outer_source, p.inner_source), (None, None)); + let v = serde_json::to_value(auto_corner_brush(Some(CornerSource::AutoCentered), Some(CornerSource::None))).unwrap(); + assert_eq!((v["outer_source"].as_str(), v["inner_source"].as_str()), (Some("autoCentered"), Some("none"))); + let BrushKind::Pattern(p) = Brush::from_json(v).unwrap().kind else { panic!("a pattern brush") }; + assert_eq!((p.outer_source, p.inner_source), (Some(CornerSource::AutoCentered), Some(CornerSource::None))); +} + +/// Auto-Sliced masks each side tile at the corner's diagonal instead of cutting its paths, so a +/// tile's outline gains no stroke along the cut. +#[test] +fn pattern_auto_sliced_masks_stroked_tiles_at_the_diagonal() { + let mut b = auto_corner_brush(Some(CornerSource::AutoSliced), Some(CornerSource::AutoSliced)); + if let BrushKind::Pattern(p) = &mut b.kind { + p.side.appearance = Appearance::basic(Paint::solid(Color::BLACK), Paint::solid(Color::WHITE), 0.5).into(); + } + let sq = shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)).to_bezpath(); + let out = stroke_pieces(&Document::new(1.0, 1.0), &b, &sq, &stroke(1.0)); + let clips: Vec<&Node> = out.iter().filter(|n| matches!(n.kind, NodeKind::Group { clip: true, .. })).collect(); + assert_eq!(clips.len(), 8, "two masked tiles at each corner"); + for g in clips { + let kids = g.children().unwrap(); + assert_eq!(kids.len(), 2); + assert!(kids[0].appearance.items.is_empty(), "the mask paints nothing"); + let mask = kids[0].path_data().unwrap().to_bezpath(); + let tile = kids[1].path_data().unwrap(); + assert!( + tile.anchors().any(|(_, _, a)| kurbo::Shape::winding(&mask, a.p) == 0), + "the tile keeps its outline past the diagonal, where the mask hides it" + ); + } +} + #[test] fn bristle_makes_translucent_strands() { let b = Brush { name: "b".into(), kind: BrushKind::Bristle(Bristle::default()) }; diff --git a/crates/brush/src/warp.rs b/crates/brush/src/warp.rs index d20fe5700..865d62b3d 100644 --- a/crates/brush/src/warp.rs +++ b/crates/brush/src/warp.rs @@ -6,11 +6,11 @@ use std::sync::Arc; -use vectorcraft_doc::{Node, NodeKind, PressureProfile}; -use vectorcraft_geom::{Affine, BezPath, PathData, PathEl, Point, Rect, SubPath}; +use vectorcraft_doc::{Appearance, Node, NodeId, NodeKind, PressureProfile}; +use vectorcraft_geom::{Affine, BezPath, PathData, PathEl, Point, Rect, SubPath, Vec2}; -use crate::track::{Rng, Track, seed_of, tracks}; -use crate::{ArtBrush, ArtScale, Direction, PatternBrush, PatternFit, Scatter, Variation, tolerance}; +use crate::track::{Rng, Track, normal, seed_of, tracks}; +use crate::{ArtBrush, ArtScale, CornerSource, Direction, PatternBrush, PatternFit, Scatter, Variation, tolerance}; /// Longest mapped segment (points in document space). const MAX_SEG: f64 = 1.5; @@ -31,6 +31,8 @@ const MAX_SUB: usize = 4000; const MAX_ANCHORS: usize = 2_000_000; /// Nodes [`anchors_of`] walks into before it gives up and charges the whole budget. const MAX_WALK: usize = 10_000; +/// Points on each side of the box an Auto-Sliced pattern brush corner masks its tiles with. +const MASK_STEPS: usize = 32; /// Bend every path under `n` through `f`. Paths are flattened with `flat_tol` (art space) and /// segments subdivided so none is longer than `max_du` along x. Stroke widths scale by @@ -403,6 +405,156 @@ fn tile_on(t: &Track, tile: &(Node, Rect), s0: f64, s1: f64, cross: f64) -> Opti bend(t, &tile.0, tile.1, AlongMap::linear(tile.1.width(), s0, s1), cross) } +/// What a pattern brush draws at a corner: corner tile art (oriented, with its bounds), or what +/// [`CornerSource`] gives. +#[derive(Clone, Copy)] +enum Corner<'a> { + Art(&'a (Node, Rect)), + Source(CornerSource), +} + +/// The pieces of the corner tile that `source` makes at corner `i` of `t` from `side` (oriented, +/// with its bounds) at scale `k`, and the length of path on each side of the corner they cover, +/// which is at most `room`. +/// +/// At the corner, the pieces run straight along the directions the path arrives at and leaves +/// the corner in, and towards their far ends they move onto the path itself, so they meet the side +/// tiles where the path curves. The corner's diagonal is the line through the corner that halves +/// the turn, where the two offset edges of the band meet. [`CornerSource::None`] and a turn of +/// nearly 180° get no pieces. +fn auto_corner(t: &Track, i: usize, source: CornerSource, side: &(Node, Rect), k: f64, room: f64) -> (Vec, f64) { + let (art, b) = side; + let (a_dir, b_dir) = t.in_out(i); + let (Some(&p), Some(&sc)) = (t.verts.get(i), t.cum.get(i)) else { return (vec![], 0.0) }; + let bt = a_dir + b_dir; + if source == CornerSource::None || a_dir.dot(bt) < 1e-3 { + return (vec![], 0.0); + } + let (tw, r) = (b.width() * k, b.height() * k / 2.0); + let (x0, cy) = (b.x0, b.center().y); + // Art point → (distance along from the left edge, offset across), in points. + let local = move |q: Point| ((q.x - x0) * k, (q.y - cy) * k); + // How far along `d` (from the corner) the diagonal is at offset `y` across `d`'s left normal. + let diag = move |d: Vec2, y: f64| -y * normal(d).dot(bt) / d.dot(bt); + // `u` along `d` from the corner and `y` across, moved from the straight line onto the path as + // `u` goes from `from` to `far` (signed: negative before the corner; `from` is the corner or + // a point between it and `far`). + let on_path = move |d: Vec2, u: f64, y: f64, from: f64, far: f64| { + let straight = p + d * u + normal(d) * y; + let span = far - from; + let w = if span.abs() > 1e-9 { ((u - from) / span).clamp(0.0, 1.0) } else { 0.0 }; + if w > 0.0 { straight.lerp(t.map(sc + u, y), w) } else { straight } + }; + // The most the diagonal is ahead of the corner, along either direction, within the band. Each + // source stretches the side tile along to `len` where the band is too tall for the tile to + // reach the diagonal, so the two sides always meet on it. + let ahead = r * normal(a_dir).dot(bt).abs() / a_dir.dot(bt); + // Before the corner, `u` in [-len, 0] maps onto [-len, diagonal]; after it, [0, len] onto + // [diagonal, len]. Both ends stay on the diagonal when `len` is shorter than `ahead`, and the + // points on the diagonal stay on the straight lines, where the two sides meet. + let into = move |u: f64, y: f64, len: f64| { + let reach = len.max(ahead); + let e = diag(a_dir, y).clamp(-reach, reach); + on_path(a_dir, -len + (u + len) * (e + len) / len.max(1e-9), y, e.min(0.0), -len) + }; + let out_of = move |u: f64, y: f64, len: f64| { + let reach = len.max(ahead); + let e = diag(b_dir, y).clamp(-reach, reach); + on_path(b_dir, e + u * (len - e) / len.max(1e-9), y, e.max(0.0), len) + }; + let flat_tol = tolerance(b.height().max(b.width()) * k) / k; + // The tile is squeezed along so that it covers at most `room` of the path on each side. + let room = room.max(0.0); + let len = tw.max(2.0 * ahead).min(match source { + CornerSource::AutoCentered => 2.0 * room, + CornerSource::AutoBetween => room, + _ => room + ahead, + }); + if len <= 1e-9 { + return (vec![], 0.0); + } + let s = if tw > 1e-9 { len / tw } else { 1.0 }; + let max_du = MAX_SEG / (2.0 * k * s.max(1.0)); + let warp = |f: &dyn Fn(Point) -> Point| warp_node(art, f, flat_tol, max_du, k); + // Art point → (distance along from the left edge, stretched to `len`, and offset across). + let along = move |q: Point| { + let (x, y) = local(q); + (x * s, y) + }; + match source { + CornerSource::None => (vec![], 0.0), + CornerSource::AutoCentered => { + let half = len / 2.0; + let f = move |q: Point| { + let (x, y) = along(q); + let u = x - half; + if u < 0.0 { into(u, y, half) } else { out_of(u, y, half) } + }; + (warp(&f).into_iter().collect(), half) + } + CornerSource::AutoBetween => { + let before = move |q: Point| { + let (x, y) = along(q); + into(x - len, y, len) + }; + let after = move |q: Point| { + let (x, y) = along(q); + out_of(x, y, len) + }; + (warp(&before).into_iter().chain(warp(&after)).collect(), len) + } + CornerSource::AutoSliced | CornerSource::AutoOverlap => { + // Each side's tile runs `ahead` past the corner. + let far = len - ahead; + let before = move |q: Point| { + let (x, y) = along(q); + on_path(a_dir, x - far, y, 0.0, -far) + }; + let after = move |q: Point| { + let (x, y) = along(q); + on_path(b_dir, x - ahead, y, 0.0, far) + }; + let (first, second) = (warp(&before), warp(&after)); + if source == CornerSource::AutoOverlap { + return (first.into_iter().chain(second).collect(), far); + } + // Auto-Sliced masks each tile by the part of the outline of the box round its art + // (strokes included), placed as the tile is, on its own side of the diagonal. + let vb = art.visual_bounds().unwrap_or(*b); + let corners = [(vb.x0, vb.y0), (vb.x1, vb.y0), (vb.x1, vb.y1), (vb.x0, vb.y1), (vb.x0, vb.y0)].map(|(x, y)| Point::new(x, y)); + let outline = |f: &dyn Fn(Point) -> Point| -> Vec { + let steps = (0..MASK_STEPS).map(|j| j as f64 / MASK_STEPS as f64); + corners.windows(2).flat_map(|w| steps.clone().map(move |s| w[0].lerp(w[1], s))).map(f).collect() + }; + let pieces = + first.and_then(|n| masked(n, &outline(&before), p, bt)).into_iter().chain(second.and_then(|n| masked(n, &outline(&after), p, -bt))); + (pieces.collect(), far) + } + } +} + +/// `n` in a clipping group whose mask is the part of polygon `quad` on the side of the line through +/// `p` that `towards` points away from. `None` when nothing of `quad` is on that side. +fn masked(n: Node, quad: &[Point], p: Point, towards: Vec2) -> Option { + let side = |q: Point| (q - p).dot(towards); + let mut kept = vec![]; + for (j, &a) in quad.iter().enumerate() { + let b = quad.get(j + 1).or(quad.first()).copied().unwrap_or(a); + let (da, db) = (side(a), side(b)); + if da <= 0.0 { + kept.push(a); + } + if (da <= 0.0) != (db <= 0.0) { + kept.push(a.lerp(b, da / (da - db))); + } + } + if kept.len() < 3 { + return None; + } + let mask = Node::path(NodeId(0), PathData::new(vec![SubPath::polyline(&kept, true)]), Appearance::default()); + Some(Node::new(NodeId(0), NodeKind::Group { children: vec![Arc::new(mask), Arc::new(n)], clip: true })) +} + 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![] }; @@ -413,6 +565,9 @@ pub(crate) fn pattern(pb: &PatternBrush, bp: &BezPath, weight: f64) -> Vec let end = pb.end.as_ref().and_then(orient); 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. + let outer_tile = outer.as_ref().map(Corner::Art).or(pb.outer_source.map(Corner::Source)); + let inner_tile = inner.as_ref().map(Corner::Art).or(pb.inner_source.map(Corner::Source)); let mut out = Budget::default(); for t in tracks(bp, 0.05) { if out.full() { @@ -423,13 +578,51 @@ pub(crate) fn pattern(pb: &PatternBrush, bp: &BezPath, weight: f64) -> Vec continue; } let corners = t.corner_positions(); - // Corner tiles, centred on the corner and turned to the bisector. + // Corner tiles, centred on the corner and turned to the bisector. The path on each side of + // a corner is shortened by `trims` (`None`: half the widest corner tile art). let mut corner_half = 0.0_f64; - for &(i, _) in &corners { + let mut trims: Vec> = Vec::with_capacity(corners.len()); + // The path a tile made at corner `ci` may cover on each side: up to the ends of an open + // track, and half the way to the next corner. + let room = |ci: usize| { + let at = |j: usize| corners.get(j).map(|c| c.1); + let s = at(ci).unwrap_or(0.0); + let last = corners.len().saturating_sub(1); + let before = match (ci.checked_sub(1).and_then(at), t.closed) { + (Some(prev), _) => (s - prev) / 2.0, + (None, true) => (s - (at(last).unwrap_or(s) - l)) / 2.0, + (None, false) => s, + }; + let after = match (at(ci + 1), t.closed) { + (Some(next), _) => (next - s) / 2.0, + (None, true) => (at(0).unwrap_or(s) + l - s) / 2.0, + (None, false) => l - s, + }; + before.min(after) + }; + for (ci, &(i, _)) in corners.iter().enumerate() { + if out.full() { + break; + } let (a, b) = t.in_out(i); // Turning towards the left normal puts the tile's +y side on the inside. - let tile = if a.cross(b) > 0.0 { inner.as_ref().or(outer.as_ref()) } else { outer.as_ref().or(inner.as_ref()) }; - let Some((art, bb)) = tile else { continue }; + let tile = if a.cross(b) > 0.0 { inner_tile.or(outer.as_ref().map(Corner::Art)) } else { outer_tile.or(inner.as_ref().map(Corner::Art)) }; + let (art, bb) = match tile { + Some(Corner::Art(tile)) => tile, + Some(Corner::Source(source)) => { + let (pieces, trim) = auto_corner(&t, i, source, &side, k, room(ci)); + for n in pieces { + out.take(Some(n)); + } + trims.push(Some(trim)); + continue; + } + None => { + trims.push(Some(0.0)); + continue; + } + }; + trims.push(None); corner_half = corner_half.max(bb.width() * k / 2.0); let bis = a + b; let bis = if bis.hypot() < 1e-9 { a } else { bis / bis.hypot() }; @@ -441,30 +634,30 @@ pub(crate) fn pattern(pb: &PatternBrush, bp: &BezPath, weight: f64) -> Vec n.transform(xf, true); out.take(Some(n)); } - // Runs between corners (closed tracks wrap past the end). + let trim = |c: Option| c.and_then(|c| trims.get(c).copied()).map_or(0.0, |t| t.unwrap_or(corner_half)); + // Runs between corners (closed tracks wrap past the end), with the corners they start and + // end at. let mut bounds: Vec = corners.iter().map(|c| c.1).collect(); - let runs: Vec<(f64, f64, bool, bool)> = if bounds.is_empty() { - vec![(0.0, l, false, false)] + let nc = bounds.len(); + let runs: Vec<(f64, f64, Option, Option)> = if bounds.is_empty() { + vec![(0.0, l, None, None)] } else if t.closed { let first = bounds[0]; bounds.push(first + l); - bounds.windows(2).map(|w| (w[0], w[1], true, true)).collect() + bounds.windows(2).enumerate().map(|(i, w)| (w[0], w[1], Some(i), Some((i + 1) % nc))).collect() } else { let mut v = vec![0.0]; v.extend(bounds); v.push(l); - let n = v.len(); - v.windows(2).enumerate().map(|(i, w)| (w[0], w[1], i > 0, i + 2 < n)).collect() + v.windows(2).enumerate().map(|(i, w)| (w[0], w[1], i.checked_sub(1), (i < nc).then_some(i))).collect() }; let nruns = runs.len(); - let corner_tiles = inner.is_some() || outer.is_some(); for (ri, (mut a, mut b, ca, cb)) in runs.into_iter().enumerate() { - if ca && corner_tiles { - a += corner_half; - } - if cb && corner_tiles { - b -= corner_half; + if out.full() { + break; } + a += trim(ca); + b -= trim(cb); if !t.closed && ri == 0 && let Some(st) = &start diff --git a/crates/engine/src/cmd/brushsym.rs b/crates/engine/src/cmd/brushsym.rs index f3c5b3e5b..8ef171d32 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. 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). 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/ui-egui/src/dialogs/brush_art_options.rs b/crates/ui-egui/src/dialogs/brush_art_options.rs index 1898172a8..2c150b4bd 100644 --- a/crates/ui-egui/src/dialogs/brush_art_options.rs +++ b/crates/ui-egui/src/dialogs/brush_art_options.rs @@ -14,14 +14,17 @@ //! `guideEnd` (% of the art's length), `direction` (`leftToRight` …), `flipAlong`, `flipAcross`, //! `overlap` (bend on each run between sharp corners). //! Pattern: `scale`, `spacing` (%), `fit` (`stretch` | `addSpace` | `approximate`), `flipAlong`, -//! `flipAcross`, `__tiles` (slot → preview key, for the tiles the brush has). +//! `flipAcross`, `outerCornerTile` and `innerCornerTile` (`original`: the corner art the brush +//! draws there | `none` | `autoCentered` | `autoBetween` | `autoSliced` | `autoOverlap`: a tile +//! made from the side tile), +//! `__tiles` (slot → preview key, for the tiles the brush has). use std::hash::{Hash, Hasher}; use std::sync::Arc; use egui::{Color32, Sense, Stroke, pos2, vec2}; use serde_json::{Map, Value, json}; -use vectorcraft_brush::{ArtBrush, ArtScale, Brush, BrushKind, Colorization, Direction, PatternBrush, PatternFit, Scatter, Variation}; +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}; @@ -41,6 +44,25 @@ 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")]; +/// 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] = + [("outerCornerTile", "outer_corner", "outer_source", "inner_corner"), ("innerCornerTile", "inner_corner", "inner_source", "outer_corner")]; + +/// The art field that corner tile `field` draws with in definition `def`: its own art, or, with +/// neither art nor a source, the other kind of corner's art (as [`PatternBrush`] draws it). +fn corner_art_field<'a>(def: Option<&Value>, field: &'a str) -> Option<&'a str> { + let set = |f: &str| def.and_then(|d| d.get(f)).is_some_and(|v| !v.is_null()); + let (_, art, source, other) = CORNER_TILES.into_iter().find(|(_, art, _, _)| *art == field)?; + if set(art) { + Some(art) + } else if !set(source) && set(other) { + Some(other) + } else { + None + } +} + /// The most key colours offered. const MAX_COLORS: usize = 16; @@ -121,6 +143,10 @@ pub(super) fn fields(b: &Brush, def: Value, f: &mut Map) { f.insert("fit".into(), json!(id_of(&p.fit))); f.insert("flipAlong".into(), json!(p.flip_along)); f.insert("flipAcross".into(), json!(p.flip_across)); + for ((key, art, _, _), source) in CORNER_TILES.into_iter().zip([p.outer_source, p.inner_source]) { + let choice = if corner_art_field(Some(&def), art).is_some() { "original" } else { source.map_or("none", CornerSource::id) }; + f.insert(key.into(), json!(choice)); + } // A preview key per tile the brush has: its art's hash, so a changed tile redraws. let tiles: Map = TILES .iter() @@ -200,13 +226,30 @@ pub(super) fn params(d: &Dialog) -> Value { "overlap": d.bool("overlap"), }) } - _ => json!({ - "scale": clamped(d, "scale", PatternBrush::SCALE_RANGE), - "spacing": clamped(d, "spacing", PatternBrush::SPACING_RANGE), - "fit": id_of(&parse_field::(d, "fit")), - "flip_along": d.bool("flipAlong"), - "flip_across": d.bool("flipAcross"), - }), + _ => { + let mut p = json!({ + "scale": clamped(d, "scale", PatternBrush::SCALE_RANGE), + "spacing": clamped(d, "spacing", PatternBrush::SPACING_RANGE), + "fit": id_of(&parse_field::(d, "fit")), + "flip_along": d.bool("flipAlong"), + "flip_across": d.bool("flipAcross"), + }); + // None or an automatic tile replaces the corner art. Original keeps the art the corner + // draws, the other kind of corner's art included, so changing the other kind leaves it. + let def = d.fields.get("__def"); + for (key, art, source, _) in CORNER_TILES { + let choice = d.str(key); + if let Some(s) = CornerSource::parse(&choice) { + p[art] = Value::Null; + p[source] = json!(s.id()); + } else if choice == "original" + && let Some(from) = corner_art_field(def, art).filter(|f| *f != art) + { + p[art] = def.and_then(|d| d.get(from)).cloned().unwrap_or_default(); + } + } + p + } }; p["colorization"] = serde_json::to_value(colorization(d)).unwrap_or_default(); p @@ -379,21 +422,44 @@ fn pattern_body(ui: &mut egui::Ui, d: &mut Dialog) { radios(ui, d, "fit", &fits); } -/// The pattern's five tiles, each with its art (or None when the brush has no such tile). -fn tiles(ui: &mut egui::Ui, d: &Dialog) { +/// 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. +fn corner_choices() -> [(&'static str, &'static str); 6] { + [ + ("original", tl!("Original")), + (CornerSource::None.id(), tl!("None")), + (CornerSource::AutoCentered.id(), tl!("Auto-Centered")), + (CornerSource::AutoBetween.id(), tl!("Auto-Between")), + (CornerSource::AutoSliced.id(), tl!("Auto-Sliced")), + (CornerSource::AutoOverlap.id(), tl!("Auto-Overlap")), + ] +} + +/// 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. +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"); + let keys = d.fields.get("__tiles").cloned(); ui.horizontal(|ui| { ui.spacing_mut().item_spacing.x = 6.0; 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)); + // 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); 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.and_then(|k| k.get(slot)).and_then(Value::as_str); + 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| { widgets::doc_preview(ui, key, r.size(), |w, h| { - let art: Node = serde_json::from_value(d.fields.get("__def")?.get(field)?.clone()).ok()?; + let art: Node = serde_json::from_value(d.fields.get("__def")?.get(drawn?)?.clone()).ok()?; tile_doc(&art, w, h) }) }); @@ -402,18 +468,21 @@ fn tiles(ui: &mut egui::Ui, d: &Dialog) { ui.painter().image(tex.id(), r, egui::Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE); } None => { - ui.painter().text( - r.center(), - egui::Align2::CENTER_CENTER, - tl!("None"), - egui::FontId::proportional(11.5), - Color32::from_gray(140), - ); + ui.painter().text(r.center(), egui::Align2::CENTER_CENTER, label, egui::FontId::proportional(10.5), Color32::from_gray(140)); } } 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)) + { + d.fields.insert(key.into(), json!(id)); + } + } }); } }); diff --git a/crates/ui-egui/src/dialogs/brush_options/tests.rs b/crates/ui-egui/src/dialogs/brush_options/tests.rs index 7ca4b316c..094a49f1e 100644 --- a/crates/ui-egui/src/dialogs/brush_options/tests.rs +++ b/crates/ui-egui/src/dialogs/brush_options/tests.rs @@ -443,6 +443,72 @@ fn pattern_options_show_the_tiles_and_set_scale_spacing_fit() { assert!((drawn_height(&app, id) / before - 2.0).abs() < 1e-6, "tiles twice the size"); } +/// Each corner tile has a menu below it: Original (only when the brush has corner art), None and +/// the four automatic tiles. OK saves the choice, and an automatic tile or None replaces the art. +#[test] +fn pattern_options_set_each_corner_tile_to_original_none_or_an_automatic_tile() { + use crate::widgets::accessibility_tests::tree; + let mut app = app(); + app.run("ui.brushOptions", json!({"name": "Diamonds"})).unwrap(); + let d = app.ui.dialog.as_ref().unwrap(); + assert_eq!((d.str("outerCornerTile"), d.str("innerCornerTile")), ("none".into(), "none".into()), "Diamonds has no corner art"); + let shown_tree = tree(|ui| super::super::show(&mut app, ui.ctx())); + for name in ["Outer Corner Tile", "Inner Corner Tile"] { + let roles: Vec<_> = shown_tree.iter().filter(|n| n.label() == Some(name)).map(|n| n.role()).collect(); + 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}"); + } + set(&mut app, json!({"outerCornerTile": "autoBetween", "innerCornerTile": "autoOverlap"})); + let text = shown(&mut app); + assert!(text.contains("Auto-Between") && text.contains("Auto-Overlap"), "{text}"); + super::super::confirm(&mut app).unwrap(); + let def = app.run("brush.get", json!({"name": "Diamonds"})).unwrap(); + assert_eq!((&def["outer_source"], &def["inner_source"]), (&json!("autoBetween"), &json!("autoOverlap"))); + // Chain has corner art: Original keeps it, None and an automatic tile replace it, one corner + // at a time. + app.run("ui.brushOptions", json!({"name": "Chain"})).unwrap(); + let d = app.ui.dialog.as_ref().unwrap(); + assert_eq!((d.str("outerCornerTile"), d.str("innerCornerTile")), ("original".into(), "original".into())); + super::super::confirm(&mut app).unwrap(); + let def = app.run("brush.get", json!({"name": "Chain"})).unwrap(); + assert!(def["outer_corner"].is_object() && def["inner_corner"].is_object() && def.get("outer_source").is_none(), "{def}"); + app.run("ui.brushOptions", json!({"name": "Chain"})).unwrap(); + set(&mut app, json!({"innerCornerTile": "none"})); + super::super::confirm(&mut app).unwrap(); + let def = app.run("brush.get", json!({"name": "Chain"})).unwrap(); + assert!(def["outer_corner"].is_object() && def.get("inner_corner").is_none(), "{def}"); + assert_eq!(def["inner_source"], json!("none")); + app.run("ui.brushOptions", json!({"name": "Chain"})).unwrap(); + let d = app.ui.dialog.as_ref().unwrap(); + assert_eq!((d.str("outerCornerTile"), d.str("innerCornerTile")), ("original".into(), "none".into())); + set(&mut app, json!({"outerCornerTile": "autoSliced"})); + super::super::confirm(&mut app).unwrap(); + let def = app.run("brush.get", json!({"name": "Chain"})).unwrap(); + assert!(def.get("outer_corner").is_none() && def.get("inner_corner").is_none(), "{def}"); + assert_eq!((&def["outer_source"], &def["inner_source"]), (&json!("autoSliced"), &json!("none"))); + app.run("ui.brushOptions", json!({"name": "Chain"})).unwrap(); + assert_eq!(app.ui.dialog.as_ref().unwrap().str("outerCornerTile"), "autoSliced"); +} + +/// 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] +fn pattern_options_keep_the_corner_art_a_corner_borrows() { + let mut app = app(); + let art = app.run("brush.get", json!({"name": "Chain"})).unwrap()["outer_corner"].clone(); + app.run("brush.new", json!({"type": "pattern", "name": "One Corner", "params": {"side": art.clone(), "outer_corner": art}})).unwrap(); + app.run("ui.brushOptions", json!({"name": "One Corner"})).unwrap(); + let d = app.ui.dialog.as_ref().unwrap(); + assert_eq!((d.str("outerCornerTile"), d.str("innerCornerTile")), ("original".into(), "original".into())); + set(&mut app, json!({"outerCornerTile": "none"})); + super::super::confirm(&mut app).unwrap(); + let def = app.run("brush.get", json!({"name": "One Corner"})).unwrap(); + assert!(def.get("outer_corner").is_none() && def["inner_corner"].is_object(), "{def}"); + assert_eq!(def["outer_source"], json!("none")); +} + /// Art Brush Options' Width and Pattern Brush Options' Scale each have a slider beside their /// field, which screen readers name as the row, with the field's number and text over the /// value's whole range. diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index e50096104..11e8aedb2 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -1903,6 +1903,10 @@ font-technology Variable Променливи Outer Corner Tile Плочка за външен ъгъл Side Tile Странична плочка Inner Corner Tile Плочка за вътрешен ъгъл + Auto-Centered Авто-центрирана + Auto-Between Авто-между + Auto-Sliced Авто-срязана + Auto-Overlap Авто-застъпване Start Tile Начална плочка End Tile Крайна плочка Fit Пълно прилягане diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index dd933ed4a..668f75051 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -2072,6 +2072,10 @@ lighting Light Světlo Outer Corner Tile Dlaždice vnějšího rohu Side Tile Boční dlaždice Inner Corner Tile Dlaždice vnitřního rohu + Auto-Centered Auto: na střed + Auto-Between Auto: mezi + Auto-Sliced Auto: seříznuté + Auto-Overlap Auto: přesah Start Tile Počáteční dlaždice End Tile Koncová dlaždice Fit Přizpůsobení diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv index 0d455bc7f..9641546cc 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -1887,6 +1887,10 @@ lighting Light Lys Outer Corner Tile Ydre hjørneflise Side Tile Sideflise Inner Corner Tile Indre hjørneflise + Auto-Centered Auto-centreret + Auto-Between Auto-mellem + Auto-Sliced Auto-skåret + Auto-Overlap Auto-overlap Start Tile Startflise End Tile Slutflise Fit Tilpas diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index a35c7a912..4b2f24c45 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -4528,6 +4528,10 @@ theme Appearance Erscheinungsbild Side Tile Seitenkachel Outer Corner Tile Äußere Eckkachel Inner Corner Tile Innere Eckkachel + Auto-Centered Auto-zentriert + Auto-Between Auto-dazwischen + Auto-Sliced Auto-geschnitten + Auto-Overlap Auto-überlappend Start Tile Anfangskachel End Tile Endkachel Fit Einpassen diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 2763621ab..0dcaa0931 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -2687,6 +2687,10 @@ theme Appearance Εμφάνιση Outer Corner Tile Πλακίδιο εξωτερικής γωνίας Side Tile Πλευρικό πλακίδιο Inner Corner Tile Πλακίδιο εσωτερικής γωνίας + Auto-Centered Αυτόματο κεντραρισμένο + Auto-Between Αυτόματο ενδιάμεσο + Auto-Sliced Αυτόματο κομμένο + Auto-Overlap Αυτόματη επικάλυψη Start Tile Αρχικό πλακίδιο End Tile Τελικό πλακίδιο Stretch to fit Επέκταση για προσαρμογή diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index b8f1ee63a..cfe0019e7 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -1955,6 +1955,10 @@ font-technology Variable Variables Outer Corner Tile Mosaico de esquina exterior Side Tile Mosaico lateral Inner Corner Tile Mosaico de esquina interior + Auto-Centered Auto-centrado + Auto-Between Auto-entre + Auto-Sliced Auto-cortado + Auto-Overlap Auto-superposición Start Tile Mosaico inicial End Tile Mosaico final Fit Ajuste diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index 8f29f6759..353e6ace4 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -2033,6 +2033,10 @@ lighting Light Valo Outer Corner Tile Ulkokulman ruutu Side Tile Sivuruutu Inner Corner Tile Sisäkulman ruutu + Auto-Centered Autom. keskitetty + Auto-Between Autom. välissä + Auto-Sliced Autom. viistetty + Auto-Overlap Autom. limittäin Start Tile Alkuruutu End Tile Loppuruutu Fit Sovita diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 58c60a720..ef43c205e 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -1952,6 +1952,10 @@ font-technology Variable Variables Outer Corner Tile Carreau d'angle extérieur Side Tile Carreau latéral Inner Corner Tile Carreau d'angle intérieur + Auto-Centered Auto-centré + Auto-Between Auto-entre + Auto-Sliced Auto-biseauté + Auto-Overlap Auto-chevauchement Start Tile Carreau de début End Tile Carreau de fin Fit Ajustement diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 193e22dbf..9fd8704e2 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -1959,6 +1959,10 @@ file-filter Templates Sablonok Ink Outlines Tuskontúrok Ink Outlines… Tuskontúrok… Inner Corner Tile Belső sarokcsempe + Auto-Centered Auto: középre + Auto-Between Auto: közé + Auto-Sliced Auto: szeletelt + Auto-Overlap Auto: átfedés Inner Glow… Belső ragyogás… Input Black Bemeneti fekete Input White Bemeneti fehér diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index afeef2a41..e33d57294 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -1887,6 +1887,10 @@ lighting Light Cahaya Outer Corner Tile Ubin sudut luar Side Tile Ubin sisi Inner Corner Tile Ubin sudut dalam + Auto-Centered Otomatis-tengah + Auto-Between Otomatis-antara + Auto-Sliced Otomatis-iris + Auto-Overlap Otomatis-tumpang Start Tile Ubin awal End Tile Ubin akhir Fit Pas diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 2886160d2..3e2c30079 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -1982,6 +1982,10 @@ file-filter Templates Modelli Outer Corner Tile Tassello angolo esterno Side Tile Tassello laterale Inner Corner Tile Tassello angolo interno + Auto-Centered Auto-centrato + Auto-Between Auto-tra + Auto-Sliced Auto-tagliato + Auto-Overlap Auto-sovrapposto Start Tile Tassello iniziale End Tile Tassello finale Fit Adattamento diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index c578f751e..4166e0eb5 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -2107,6 +2107,10 @@ font-technology Variable バリアブル Outer Corner Tile 外角タイル Side Tile サイドタイル Inner Corner Tile 内角タイル + Auto-Centered 自動(中央) + Auto-Between 自動(間) + Auto-Sliced 自動(スライス) + Auto-Overlap 自動(重なり) Start Tile 始点タイル End Tile 終点タイル Fit 調整 diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index ba52ef67c..dd782c070 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -1785,6 +1785,10 @@ lighting Light სინათლე Outer Corner Tile გარე კუთხე Side Tile გვერდითი Inner Corner Tile შიდა კუთხე + Auto-Centered ავტო-ცენტრირებული + Auto-Between ავტო-შორის + Auto-Sliced ავტო-დაჭრილი + Auto-Overlap ავტო-გადაფარვა Start Tile საწყისი End Tile ბოლო ფილა Fit მორგება diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 1d63ccca3..4659fc3c3 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -2102,6 +2102,10 @@ font-technology Variable 가변 Outer Corner Tile 바깥쪽 모서리 타일 Side Tile 측면 타일 Inner Corner Tile 안쪽 모서리 타일 + Auto-Centered 자동(가운데) + Auto-Between 자동(사이) + Auto-Sliced 자동(자르기) + Auto-Overlap 자동(겹치기) Start Tile 시작 타일 End Tile 끝 타일 Fit 맞춤 diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index 3f2683282..46e5dca54 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -1901,6 +1901,10 @@ lighting Light Licht Outer Corner Tile Tegel voor buitenhoek Side Tile Zijtegel Inner Corner Tile Tegel voor binnenhoek + Auto-Centered Auto-gecentreerd + Auto-Between Auto-tussen + Auto-Sliced Auto-verstek + Auto-Overlap Auto-overlap Start Tile Begintegel End Tile Eindtegel Fit Passen diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 2e3c245e6..1a458e992 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -1990,6 +1990,10 @@ lighting Light Światło Outer Corner Tile Kafelek narożnika zewnętrznego Side Tile Kafelek boczny Inner Corner Tile Kafelek narożnika wewnętrznego + Auto-Centered Auto-wyśrodkowany + Auto-Between Auto-pomiędzy + Auto-Sliced Auto-cięty + Auto-Overlap Auto-nakładanie Start Tile Kafelek początkowy End Tile Kafelek końcowy Fit Dopasowanie diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 8e7c1b989..a3561648f 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -3240,6 +3240,10 @@ font-technology Variable Variáveis Outer Corner Tile Ladrilho de canto externo Side Tile Ladrilho lateral Inner Corner Tile Ladrilho de canto interno + Auto-Centered Auto-centralizado + Auto-Between Auto-entre + Auto-Sliced Auto-fatiado + Auto-Overlap Auto-sobreposto Start Tile Ladrilho inicial End Tile Ladrilho final Fit Ajustar diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index b3f59b850..5ad5ae6fe 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -1978,6 +1978,10 @@ file-filter Templates Шаблоны Outer Corner Tile Фрагмент внешнего угла Side Tile Боковой фрагмент Inner Corner Tile Фрагмент внутреннего угла + Auto-Centered Авто: по центру + Auto-Between Авто: между + Auto-Sliced Авто: срез + Auto-Overlap Авто: перекрытие Start Tile Начальный фрагмент End Tile Конечный фрагмент Fit Подгонка diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index 94c74cec3..355b52783 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -1903,6 +1903,10 @@ font-technology Variable Variabla Outer Corner Tile Yttre hörnplatta Side Tile Sidoruta Inner Corner Tile Innerhörnsruta + Auto-Centered Auto-centrerad + Auto-Between Auto-mellan + Auto-Sliced Auto-skuren + Auto-Overlap Auto-överlapp Start Tile Startruta End Tile Slutruta Fit Anpassa diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 11067ba32..50c3e5f4c 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -1796,6 +1796,10 @@ font-technology Variable Değişken Outer Corner Tile Dış Köşe Döşemesi Side Tile Yan Kiremit Inner Corner Tile İç Köşe Döşemesi + Auto-Centered Otomatik Ortalı + Auto-Between Otomatik Arada + Auto-Sliced Otomatik Dilimli + Auto-Overlap Otomatik Örtüşen Start Tile Döşemeyi Başlat End Tile Son Döşeme Fit Uygun diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index cac83cf5e..ea16da598 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -1974,6 +1974,10 @@ file-filter Templates Шаблони Outer Corner Tile Фрагмент зовнішнього кута Side Tile Бічний фрагмент Inner Corner Tile Фрагмент внутрішнього кута + Auto-Centered Авто: по центру + Auto-Between Авто: між + Auto-Sliced Авто: зріз + Auto-Overlap Авто: перекриття Start Tile Початковий фрагмент End Tile Кінцевий фрагмент Fit Припасування diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index edd80b1c7..10b8ea281 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -1836,6 +1836,10 @@ file-filter Templates Mẫu Ink Outlines Đường bao mực Ink Outlines… Đường bao mực… Inner Corner Tile Ô góc trong + Auto-Centered Tự động - Giữa + Auto-Between Tự động - Ở giữa hai cạnh + Auto-Sliced Tự động - Cắt chéo + Auto-Overlap Tự động - Chồng lấp Inner Glow… Phát sáng trong… Input Black Đầu vào đen Input White Đầu vào trắng diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index fa99dbf31..ac80bc510 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -1865,6 +1865,10 @@ file-filter Templates 模板 Outer Corner Tile 外角拼贴 Side Tile 边线拼贴 Inner Corner Tile 内角拼贴 + Auto-Centered 自动居中 + Auto-Between 自动居间 + Auto-Sliced 自动切片 + Auto-Overlap 自动重叠 Start Tile 起点拼贴 End Tile 终点拼贴 Fit 适合 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index b21ed9ccf..f96254830 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -2756,6 +2756,10 @@ file-filter Templates 範本 Outer Corner Tile 外角拼貼 Side Tile 邊線拼貼 Inner Corner Tile 內角拼貼 + Auto-Centered 自動置中 + Auto-Between 自動居間 + Auto-Sliced 自動切片 + Auto-Overlap 自動重疊 Start Tile 起點拼貼 End Tile 終點拼貼 Fit 符合 diff --git a/docs/mcp.md b/docs/mcp.md index d3af1e0c6..6c33e8dc3 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -1502,7 +1502,11 @@ already walks Image Trace. Short agent entry points: `brush.new` refuses a longer one, counting the number it adds to a taken name, and `brush.options` refuses a longer `newName`. `brush.duplicate` does not check the length. A bristle brush that already has a longer name keeps it, and `brush.options` without a new name, or with a `newName` that only trims the white space around the - name, does not refuse. Other brush types take a name of any length. + name, does not refuse. Other brush types take a name of any length. A pattern brush's `outer_source` and `inner_source` set + 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. - 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 b7f1c55af..be8b5a6ba 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -894,6 +894,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur Document Info has a Repeat Objects category after Embedded Images. It lists each radial, grid and mirror Repeat object by name with its kind and the number of instances it makes. For a grid it gives the rows and columns instead, followed by the number of instances it draws when that is fewer. With Selection Only it lists the Repeat objects in the selection. The Document Info report and Package's report list them too (`document.info {category: "repeats"}`, `repeatObjects`). 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). - **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. From 1c4894b2f744fdbb026f8f73eb8115fd1377db1d Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 28 Oct 2026 08:01:14 -0700 Subject: [PATCH 2/3] Brushes: Pattern Brush Options' OK leaves corner tiles that were not set OK used to save None for a corner tile the dialog showed as None because the brush had no art or source for it, so that corner stopped drawing the other kind of corner's art once the brush got some. It also copied the art a corner borrows from the other kind of corner when that corner kept its art. Now OK saves a corner tile's choice only when it was set from its menu or with `ui.dialog.set`, or when the art it borrows goes away. --- crates/ui-egui/src/control.rs | 2 +- crates/ui-egui/src/dialogs.rs | 9 +++++ .../ui-egui/src/dialogs/brush_art_options.rs | 29 +++++++++++++- .../src/dialogs/brush_options/tests.rs | 38 +++++++++++++++++++ 4 files changed, 75 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 14d6fe831..68bba9ade 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -327,7 +327,7 @@ pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context, req: &ControlReques return err(format!("the `{}` dialog has no field `{f}`; its fields: {names}", d.kind)); } for (f, value) in fields { - d.fields.insert(f, value); + crate::dialogs::set_field(d, f, value); } ok(serde_json::to_value(&*d).unwrap_or_default()) } diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 49dfe37b9..6920c8466 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -410,6 +410,15 @@ pub fn accepts_field(d: &Dialog, field: &str) -> bool { || (spec.passes)(d, field) } +/// Set `field` of `d` to `value` for `ui.dialog.set`, as the dialog's own control for it does: a +/// Pattern Brush Options corner tile set this way is saved by OK, as one chosen from its menu is. +pub fn set_field(d: &mut Dialog, field: String, value: Value) { + if d.kind == brush_options::KIND { + brush_art_options::note_set(d, &field); + } + d.fields.insert(field, value); +} + /// The fields `ui.dialog.set` can set on `d`, sorted, without the `__` ones: those it holds, /// `discard`, `preview` and the spec's `inputs` for its kind. pub fn field_names(d: &Dialog) -> Vec { diff --git a/crates/ui-egui/src/dialogs/brush_art_options.rs b/crates/ui-egui/src/dialogs/brush_art_options.rs index 2c150b4bd..f9b5bf558 100644 --- a/crates/ui-egui/src/dialogs/brush_art_options.rs +++ b/crates/ui-egui/src/dialogs/brush_art_options.rs @@ -63,6 +63,24 @@ 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. +fn corner_set_key(key: &str) -> String { + format!("__{key}Set") +} + +/// Sets corner 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. +pub(super) fn note_set(d: &mut Dialog, field: &str) { + if CORNER_TILES.iter().any(|(key, ..)| *key == field) { + d.fields.insert(corner_set_key(field), json!(true)); + } +} + /// The most key colours offered. const MAX_COLORS: usize = 16; @@ -236,14 +254,21 @@ pub(super) fn params(d: &Dialog) -> Value { }); // None or an automatic tile replaces the corner art. Original keeps the art the corner // draws, the other kind of corner's art included, so changing the other kind leaves it. + // A None the menu was not set to, on a corner without art or a source, leaves the + // definition as it is: that corner still draws the other kind's art when it gets some. let def = d.fields.get("__def"); - for (key, art, source, _) in CORNER_TILES { + for (key, art, source, other) in CORNER_TILES { let choice = d.str(key); + 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()); } 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(); } @@ -480,7 +505,7 @@ fn tiles(ui: &mut egui::Ui, d: &mut Dialog) { if let Some((id, _)) = widgets::dropdown_named(ui, ("brush-pattern-corner", key), Some(name), current, &labels, 76.0).and_then(|i| choices.get(i)) { - d.fields.insert(key.into(), json!(id)); + choose_corner(d, key, id); } } }); diff --git a/crates/ui-egui/src/dialogs/brush_options/tests.rs b/crates/ui-egui/src/dialogs/brush_options/tests.rs index 094a49f1e..9adeffed1 100644 --- a/crates/ui-egui/src/dialogs/brush_options/tests.rs +++ b/crates/ui-egui/src/dialogs/brush_options/tests.rs @@ -509,6 +509,44 @@ fn pattern_options_keep_the_corner_art_a_corner_borrows() { assert_eq!(def["outer_source"], json!("none")); } +/// 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. +#[test] +fn pattern_options_ok_leaves_corner_tiles_the_menus_were_not_set_to() { + let mut app = app(); + app.run("ui.brushOptions", json!({"name": "Diamonds"})).unwrap(); + set(&mut app, json!({"scale": 150})); + super::super::confirm(&mut app).unwrap(); + let def = app.run("brush.get", json!({"name": "Diamonds"})).unwrap(); + assert!(def.get("outer_source").is_none() && def.get("inner_source").is_none(), "{def}"); + let art = app.run("brush.get", json!({"name": "Chain"})).unwrap()["outer_corner"].clone(); + app.run("brush.options", json!({"name": "Diamonds", "params": {"outer_corner": art.clone()}})).unwrap(); + app.run("ui.brushOptions", json!({"name": "Diamonds"})).unwrap(); + assert_eq!(app.ui.dialog.as_ref().unwrap().str("innerCornerTile"), "original", "the inner corners draw the outer art"); + super::super::confirm(&mut app).unwrap(); + let def = app.run("brush.get", json!({"name": "Diamonds"})).unwrap(); + assert!(def["outer_corner"].is_object() && def.get("inner_corner").is_none(), "{def}"); + + app.run("brush.new", json!({"type": "pattern", "name": "Plain", "params": {"side": art}})).unwrap(); + app.run("ui.brushOptions", json!({"name": "Plain"})).unwrap(); + let d = app.ui.dialog.as_mut().unwrap(); + crate::dialogs::brush_art_options::choose_corner(d, "outerCornerTile", "autoCentered"); + crate::dialogs::brush_art_options::choose_corner(d, "outerCornerTile", "none"); + super::super::confirm(&mut app).unwrap(); + let def = app.run("brush.get", json!({"name": "Plain"})).unwrap(); + assert_eq!(def["outer_source"], json!("none")); + assert!(def.get("inner_source").is_none(), "{def}"); + // None set through the control channel is saved too. + app.run("ui.brushOptions", json!({"name": "Plain"})).unwrap(); + let (set, _) = crate::control::ControlRequest::new("ui.dialog.set", json!({"field": "innerCornerTile", "value": "none"})); + let crate::control::Outcome::Done(r) = crate::control::handle(&mut app, &egui::Context::default(), &set) else { panic!("not done") }; + assert!(r.get("error").is_none(), "{r}"); + super::super::confirm(&mut app).unwrap(); + let def = app.run("brush.get", json!({"name": "Plain"})).unwrap(); + assert_eq!(def["inner_source"], json!("none")); +} + /// Art Brush Options' Width and Pattern Brush Options' Scale each have a slider beside their /// field, which screen readers name as the row, with the field's number and text over the /// value's whole range. From f80160521305fa43e9d745e6a344f51618571c60 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 28 Oct 2026 08:20:29 -0700 Subject: [PATCH 3/3] Brushes: Auto-Sliced corners keep the stroke of a squeezed side tile On a corner whose legs are too short for the side tile, the tile is squeezed along the path but its stroke keeps its width. The mask round each slice was squeezed with the tile, so it cut off the stroke at the slice's far end. Its margins along the tile now grow by as much as the tile is squeezed. --- crates/brush/src/tests.rs | 42 +++++++++++++++++++++++++++++++++++++++ crates/brush/src/warp.rs | 8 ++++++-- 2 files changed, 48 insertions(+), 2 deletions(-) diff --git a/crates/brush/src/tests.rs b/crates/brush/src/tests.rs index 1945f4527..069e1bf3c 100644 --- a/crates/brush/src/tests.rs +++ b/crates/brush/src/tests.rs @@ -583,6 +583,48 @@ fn pattern_auto_sliced_masks_stroked_tiles_at_the_diagonal() { } } +/// Auto-Sliced on a corner whose legs are too short for the side tile squeezes the tile along, +/// but not its stroke: the mask still takes in the stroke past the tile's far end. +#[test] +fn pattern_auto_sliced_masks_keep_the_stroke_of_a_squeezed_tile() { + let mut b = auto_corner_brush(Some(CornerSource::AutoSliced), Some(CornerSource::AutoSliced)); + if let BrushKind::Pattern(p) = &mut b.kind { + p.side.appearance = Appearance::basic(Paint::solid(Color::BLACK), Paint::solid(Color::WHITE), 4.0).into(); + } + let sq = shapes::rectangle(Rect::new(0.0, 0.0, 8.0, 8.0)).to_bezpath(); + let out = stroke_pieces(&Document::new(1.0, 1.0), &b, &sq, &stroke(1.0)); + let clips: Vec<&Node> = out.iter().filter(|n| matches!(n.kind, NodeKind::Group { clip: true, .. })).collect(); + assert_eq!(clips.len(), 8, "two masked tiles at each corner"); + let center = Point::new(4.0, 4.0); + for g in clips { + let kids = g.children().unwrap(); + let mask = kids[0].path_data().unwrap().to_bezpath(); + let half = kids[1].appearance.stroke_width() / 2.0; + assert!(half > 0.0); + let tile = kids[1].path_data().unwrap(); + let anchors: Vec = tile.anchors().map(|(_, _, a)| a.p).collect(); + let mid = anchors.iter().fold(Point::ZERO, |m, a| m + a.to_vec2() / anchors.len() as f64); + // The tile's corner, and its diagonal through the square's center; each mask keeps one + // side of the diagonal. + let p = [(0.0, 0.0), (8.0, 0.0), (8.0, 8.0), (0.0, 8.0)] + .map(|(x, y)| Point::new(x, y)) + .into_iter() + .min_by(|a, b| a.distance(mid).total_cmp(&b.distance(mid))) + .unwrap(); + let cross = |q: Point| (q - p).cross(center - p); + let side = cross(mid).signum(); + for a in &anchors { + // The points of the stroke round the anchor, along and across the tile's leg. + for d in [(1.0, 0.0), (-1.0, 0.0), (0.0, 1.0), (0.0, -1.0)] { + let q = *a + kurbo::Vec2::new(d.0, d.1) * half * 0.9; + if cross(q) * side > 0.01 && (q - p).hypot() < 7.0 { + assert!(kurbo::Shape::winding(&mask, q) != 0, "the mask takes in the stroke at {q:?} (half width {half})"); + } + } + } + } +} + #[test] fn bristle_makes_translucent_strands() { let b = Brush { name: "b".into(), kind: BrushKind::Bristle(Bristle::default()) }; diff --git a/crates/brush/src/warp.rs b/crates/brush/src/warp.rs index 865d62b3d..066797176 100644 --- a/crates/brush/src/warp.rs +++ b/crates/brush/src/warp.rs @@ -519,9 +519,13 @@ fn auto_corner(t: &Track, i: usize, source: CornerSource, side: &(Node, Rect), k return (first.into_iter().chain(second).collect(), far); } // Auto-Sliced masks each tile by the part of the outline of the box round its art - // (strokes included), placed as the tile is, on its own side of the diagonal. + // (strokes included), placed as the tile is, on its own side of the diagonal. A tile + // squeezed along keeps its stroke width, so the box's margins at its ends grow by as + // much as the tile is squeezed. let vb = art.visual_bounds().unwrap_or(*b); - let corners = [(vb.x0, vb.y0), (vb.x1, vb.y0), (vb.x1, vb.y1), (vb.x0, vb.y1), (vb.x0, vb.y0)].map(|(x, y)| Point::new(x, y)); + let grow = 1.0 / s.min(1.0); + let (x0, x1) = (b.x0 - (b.x0 - vb.x0).max(0.0) * grow, b.x1 + (vb.x1 - b.x1).max(0.0) * grow); + let corners = [(x0, vb.y0), (x1, vb.y0), (x1, vb.y1), (x0, vb.y1), (x0, vb.y0)].map(|(x, y)| Point::new(x, y)); let outline = |f: &dyn Fn(Point) -> Point| -> Vec { let steps = (0..MASK_STEPS).map(|j| j as f64 / MASK_STEPS as f64); corners.windows(2).flat_map(|w| steps.clone().map(move |s| w[0].lerp(w[1], s))).map(f).collect()