Merge pull request #4445 from dotCooCoo/feat/pattern-auto-corners

Brushes: Pattern Brush Options' corner tiles can be None or made from the side tile
This commit is contained in:
BFlat
2026-10-29 12:17:20 +07:00
committed by GitHub
34 changed files with 938 additions and 44 deletions
+48
View File
@@ -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> {
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<Node>,
#[serde(skip_serializing_if = "Option::is_none")]
pub inner_corner: Option<Node>,
/// 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<CornerSource>,
/// 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<CornerSource>,
#[serde(skip_serializing_if = "Option::is_none")]
pub start: Option<Node>,
#[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,
+341
View File
@@ -284,6 +284,347 @@ 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<CornerSource>, inner: Option<CornerSource>) -> 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<Point> {
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<Vec<Point>> = 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<CornerSource>| {
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"
);
}
}
/// 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<Point> = 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()) };
+217 -20
View File
@@ -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,160 @@ 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<Node>, 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. 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 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<Point> {
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<Node> {
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<Node> {
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 +569,9 @@ pub(crate) fn pattern(pb: &PatternBrush, bp: &BezPath, weight: f64) -> Vec<Node>
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 +582,51 @@ pub(crate) fn pattern(pb: &PatternBrush, bp: &BezPath, weight: f64) -> Vec<Node>
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<Option<f64>> = 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 +638,30 @@ pub(crate) fn pattern(pb: &PatternBrush, bp: &BezPath, weight: f64) -> Vec<Node>
n.transform(xf, true);
out.take(Some(n));
}
// Runs between corners (closed tracks wrap past the end).
let trim = |c: Option<usize>| 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<f64> = 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<usize>, Option<usize>)> = 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
+1 -1
View File
@@ -134,7 +134,7 @@ pub fn specs() -> Vec<CommandSpec> {
"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
),
+1 -1
View File
@@ -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())
}
+9
View File
@@ -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<String> {
+115 -21
View File
@@ -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,43 @@ 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 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;
@@ -121,6 +161,10 @@ pub(super) fn fields(b: &Brush, def: Value, f: &mut Map<String, Value>) {
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<String, Value> = TILES
.iter()
@@ -200,13 +244,37 @@ 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::<PatternFit>(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::<PatternFit>(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.
// 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, 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();
}
}
p
}
};
p["colorization"] = serde_json::to_value(colorization(d)).unwrap_or_default();
p
@@ -379,21 +447,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 +493,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))
{
choose_corner(d, key, id);
}
}
});
}
});
@@ -443,6 +443,110 @@ 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"));
}
/// 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.
+4
View File
@@ -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 Пълно прилягане
Can't render this file because it contains an unexpected character in line 2 and column 73.
+4
View File
@@ -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í
Can't render this file because it contains an unexpected character in line 2 and column 73.
+4
View File
@@ -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
Can't render this file because it contains an unexpected character in line 2 and column 73.
+4
View File
@@ -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
Can't render this file because it contains an unexpected character in line 2 and column 73.
+4
View File
@@ -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 Επέκταση για προσαρμογή
Can't render this file because it contains an unexpected character in line 2070 and column 19.
+4
View File
@@ -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
Can't render this file because it contains an unexpected character in line 2 and column 73.
+4
View File
@@ -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
Can't render this file because it contains an unexpected character in line 2 and column 73.
+4
View File
@@ -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
Can't render this file because it contains an unexpected character in line 2 and column 73.
+4
View File
@@ -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
Can't render this file because it contains an unexpected character in line 2 and column 73.
+4
View File
@@ -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
1 # Indonesian (id) catalog. Same format as zh-hant.tsv: context <TAB> source <TAB> translation
1887
1888
1889
1890
1891
1892
1893
1894
1895
1896
+4
View File
@@ -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
Can't render this file because it contains an unexpected character in line 2 and column 73.
+4
View File
@@ -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 調整
Can't render this file because it contains an unexpected character in line 2 and column 73.
+4
View File
@@ -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 მორგება
Can't render this file because it contains an unexpected character in line 2 and column 73.
+4
View File
@@ -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 맞춤
Can't render this file because it contains an unexpected character in line 2 and column 73.
+4
View File
@@ -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
Can't render this file because it contains an unexpected character in line 2 and column 73.
+4
View File
@@ -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
Can't render this file because it contains an unexpected character in line 2 and column 73.
+4
View File
@@ -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
Can't render this file because it contains an unexpected character in line 6 and column 47.
+4
View File
@@ -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 Подгонка
Can't render this file because it contains an unexpected character in line 2 and column 73.
+4
View File
@@ -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
Can't render this file because it contains an unexpected character in line 5 and column 25.
+4
View File
@@ -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
Can't render this file because it contains an unexpected character in line 5 and column 25.
+4
View File
@@ -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 Припасування
Can't render this file because it contains an unexpected character in line 749 and column 40.
+4
View File
@@ -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
1 # Vietnamese (vi) catalog. Same format as zh-hant.tsv: context <TAB> source <TAB> translation
1836
1837
1838
1839
1840
1841
1842
1843
1844
1845
+4
View File
@@ -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 适合
Can't render this file because it contains an unexpected character in line 2 and column 73.
+4
View File
@@ -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 符合
Can't render this file because it contains an unexpected character in line 6 and column 47.
+5 -1
View File
@@ -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
+1
View File
@@ -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.