From 5ccb73c6bb5c35f218510c498ba2ad8354dbc0d9 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 7 Nov 2026 07:25:45 -0800 Subject: [PATCH] Fix #6110: pattern brush corner tiles line up with the side tiles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A corner tile was turned so that its x axis followed the mean of the directions the path arrives and leaves in. A tile drawn as a right-angle corner then came out turned 45° at every corner, so a rectangle drew its corners as diagonal chevrons. Outer and inner corner art are now both read as the corner that joins a side tile on its right to one below it, as in the help's steps for making corner tiles. Each corner tile is turned so that those two arms meet the sides: on a rectangle the tile is square to the side tiles and turns 90° from one corner to the next, and at other angles it is turned halfway between the two sides. The arms follow Flip Along and Flip Across, and a corner that takes the other kind's art turns it the same way. Which art each corner takes is unchanged. The PatternBrush field docs, the brush.options params text, docs/mcp.md and the progress doc describe how corner art is drawn. --- crates/brush/src/lib.rs | 4 ++ crates/brush/src/tests.rs | 97 ++++++++++++++++++++++++++++++- crates/brush/src/warp.rs | 16 +++-- crates/engine/src/cmd/brushsym.rs | 2 +- docs/mcp.md | 4 +- docs/target-app-parity.md | 2 + 6 files changed, 116 insertions(+), 9 deletions(-) diff --git a/crates/brush/src/lib.rs b/crates/brush/src/lib.rs index 093ce531a..448b2c2ba 100644 --- a/crates/brush/src/lib.rs +++ b/crates/brush/src/lib.rs @@ -475,6 +475,10 @@ pub struct PatternBrush { pub side_frame: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub side_swatch: Option, + /// Art for the corners where the path turns towards the side tile's top (`-y`); `inner_corner` + /// is the art for the corners where it turns the other way. Both are drawn as the corner that + /// joins a side tile on its right to one below it, and each corner tile is turned so that + /// those two arms meet the sides. #[serde(skip_serializing_if = "Option::is_none")] pub outer_corner: Option, #[serde(default, skip_serializing_if = "Option::is_none")] diff --git a/crates/brush/src/tests.rs b/crates/brush/src/tests.rs index 8bcc8ba2b..11c83d97b 100644 --- a/crates/brush/src/tests.rs +++ b/crates/brush/src/tests.rs @@ -1,6 +1,6 @@ use vectorcraft_color::{Color, Paint}; use vectorcraft_doc::{Appearance, Document, Node, NodeId, NodeKind, StrokeLayer}; -use vectorcraft_geom::{Affine, BezPath, Point, Rect, shapes}; +use vectorcraft_geom::{Affine, BezPath, Point, Rect, Vec2, shapes}; use super::*; @@ -299,6 +299,101 @@ fn pattern_places_corner_tiles() { } } +/// A pattern brush whose corner tiles have a 4 × 4 pt frame and, as art, a 2 × 2 pt square in the +/// top-left quarter of the frame. Corner art joins a side tile on its right to one below it, so +/// that quarter is on the outside of the turn. `inner` and `outer` say which kinds of corner have +/// art; `flips` are the brush's Flip Along and Flip Across. +fn marked_corner_brush(inner: bool, outer: bool, (flip_along, flip_across): (bool, bool)) -> Brush { + let marker = + Node::path(NodeId(0), shapes::rectangle(Rect::new(0.0, -2.0, 2.0, 0.0)), Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0)); + let mut b = pattern_brush(PatternFit::Stretch, false); + if let BrushKind::Pattern(p) = &mut b.kind { + let frame = Some(Rect::new(0.0, -2.0, 4.0, 2.0)); + if inner { + (p.inner_corner, p.inner_corner_frame) = (Some(marker.clone()), frame); + } + if outer { + (p.outer_corner, p.outer_corner_frame) = (Some(marker), frame); + } + (p.flip_along, p.flip_across) = (flip_along, flip_across); + } + b +} + +/// Each combination of corner art (both kinds, inner only, outer only) and of the two flips. +fn marked_corner_brushes() -> Vec<(String, Brush)> { + let mut v = vec![]; + for (inner, outer) in [(true, true), (true, false), (false, true)] { + for flips in [(false, false), (true, false), (false, true), (true, true)] { + v.push((format!("inner {inner}, outer {outer}, flips {flips:?}"), marked_corner_brush(inner, outer, flips))); + } + } + v +} + +/// The bounds of the 2 × 2 pt corner markers among `out`, turned or not. +fn marker_bounds(out: &[Node]) -> Vec { + out.iter().filter_map(Node::geometric_bounds).filter(|r| r.width() < 3.0 && r.height() < 3.0).collect() +} + +/// Corner tiles are square to the sides at a right angle and turn 90° with each turn of the path: +/// on a square drawn either way round, each corner's marker fills the quarter of the band's corner +/// outside the turn, with the brush flipped or not and with art for one kind of corner only +/// (#6110). +#[test] +fn pattern_corner_tiles_are_square_to_the_sides() { + let doc = Document::new(1.0, 1.0); + let sq = shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)).to_bezpath(); + let mut reversed = BezPath::new(); + reversed.move_to((0.0, 0.0)); + for (x, y) in [(0.0, 100.0), (100.0, 100.0), (100.0, 0.0)] { + reversed.line_to((x, y)); + } + reversed.close_path(); + // The square turns the same way at all four corners, and the reversed square the other way, + // so one draws only inner corner tiles and the other only outer ones. + for (name, brush) in marked_corner_brushes() { + for path in [&sq, &reversed] { + let out = stroke_pieces(&doc, &brush, path, &stroke(1.0)); + let marks = marker_bounds(&out); + assert_eq!(marks.len(), 4, "{name}: one marker a corner"); + for (corner, outside) in SQUARE_CORNERS { + let want = Rect::from_points(corner, outside); + assert!( + marks + .iter() + .any(|r| near(r.x0, want.x0, 1e-6) && near(r.y0, want.y0, 1e-6) && near(r.x1, want.x1, 1e-6) && near(r.y1, want.y1, 1e-6)), + "{name}: the marker at {corner:?} fills {want:?}: {marks:?}" + ); + } + } + } +} + +/// At a corner that is not a right angle, a corner tile splits the difference between the two +/// sides: its marker lies on the line that halves the angle, outside the turn, for sharp and +/// shallow turns either way. Near a hairpin the marker moves smoothly towards the line the path +/// arrives on. +#[test] +fn pattern_corner_tiles_sit_on_the_corners_diagonal() { + let doc = Document::new(1.0, 1.0); + for (name, brush) in marked_corner_brushes() { + for turn in [-179.9_f64, -170.0, -135.0, -60.0, -45.0, 45.0, 60.0, 135.0, 170.0, 179.9] { + let (a, b) = (Vec2::new(1.0, 0.0), Vec2::new(turn.to_radians().cos(), turn.to_radians().sin())); + let mut bp = BezPath::new(); + bp.move_to((0.0, 0.0)); + bp.line_to((50.0, 0.0)); + bp.line_to(Point::new(50.0, 0.0) + b * 50.0); + let out = stroke_pieces(&doc, &brush, &bp, &stroke(1.0)); + let marks = marker_bounds(&out); + assert_eq!(marks.len(), 1, "{name}, {turn}°: one corner marker"); + let away = a - b; + let want = Point::new(50.0, 0.0) + away / away.hypot() * 2.0_f64.sqrt(); + assert!(marks[0].center().distance(want) < 1e-6, "{name}, {turn}°: the marker is at {:?}, not {want:?}", marks[0].center()); + } + } +} + /// 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); diff --git a/crates/brush/src/warp.rs b/crates/brush/src/warp.rs index c52f7f630..b34a1cf31 100644 --- a/crates/brush/src/warp.rs +++ b/crates/brush/src/warp.rs @@ -879,12 +879,16 @@ pub(crate) fn pattern(pb: &PatternBrush, bp: &BezPath, weight: f64) -> Vec }; 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() }; - let xf = Affine::translate(t.verts[i].to_vec2()) - * Affine::rotate(bis.y.atan2(bis.x)) - * Affine::scale(k) - * Affine::translate(-bb.center().to_vec2()); + // Inner and outer corner art is the corner that joins a side tile on its right to one + // below it, so its arms point along +x and +y (as the flips leave them). The tile is + // turned so that the line halving its arms lies on the line halving the sides at the + // corner, back along the incoming side and on along the outgoing one: at a right angle + // the arms meet the side tiles, and at other angles the difference is split between + // the two sides (#6110). + let arms = Vec2::new(if pb.flip_along { -1.0 } else { 1.0 }, if pb.flip_across { -1.0 } else { 1.0 }); + let sides = b - a; + let angle = sides.y.atan2(sides.x) - arms.y.atan2(arms.x); + let xf = Affine::translate(t.verts[i].to_vec2()) * Affine::rotate(angle) * Affine::scale(k) * Affine::translate(-bb.center().to_vec2()); let mut n = art.clone(); n.transform(xf, true); out.take(Some(n)); diff --git a/crates/engine/src/cmd/brushsym.rs b/crates/engine/src/cmd/brushsym.rs index 0fa03042c..8cf3a2195 100644 --- a/crates/engine/src/cmd/brushsym.rs +++ b/crates/engine/src/cmd/brushsym.rs @@ -143,7 +143,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|tilt|bearing|rotation for angle, roundness, size] (pressure goes from value − variation at the lightest pen pressure to value + variation at the heaviest; tilt from value − variation with the pen lying flat to value + variation with it upright; bearing and rotation add variation × the pen's bearing (which way it leans, counter-clockwise from leaning right) or its barrel's counter-clockwise rotation over 180°; the angle turns on past ±180°). 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|tilt|bearing|rotation for size, spacing, scatter, rotation] (fixed: a; random: between a and b; pressure: a at the lightest pen pressure to b at the heaviest; tilt: a with the pen lying flat to b upright; bearing and rotation: a at −180° to b at 180°), rotation_relative_to_path. art: width 1..1000%, width_mode: null (fixed)|pressure, width_max 1..1000% (pressure: width at the lightest pen pressure to width_max at the heaviest, half way on a stroke drawn without pressure), scale: {mode: proportional|stretch|betweenGuides, start, end: 0..1 of the art length}, direction: leftToRight|rightToLeft|topToBottom|bottomToTop, flip_along, flip_across, overlap. pattern: scale 1..10000%, spacing 0..10000%, fit: stretch|addSpace|approximate, flip_along, flip_across, outer_corner and inner_corner: corner tile art or null, outer_source and inner_source: what that kind of corner draws without its art: none|autoCentered|autoBetween|autoSliced|autoOverlap (a tile made from the side tile), or null (the other kind of corner's art); side_tile, outer_corner_tile, inner_corner_tile, start_tile, end_tile: a document pattern name, original, or none (side refuses none); corner tiles also take autoCentered|autoBetween|autoSliced|autoOverlap. Each tile also accepts {pattern: name} to select a swatch whose name is a reserved choice. scatter, art and pattern: colorization: {method: none|tints|tintsAndShades|hueShift, key: colour (hueShift: the art colour that becomes the stroke colour; without a key: the solid color the art's fills and strokes use most, black when they use none)}; colorization and the art brush's scale change only the fields given}, newName? (trimmed; blank: the name is kept; a new bristle brush name has at most 31 characters, and a longer name the brush has is kept)} edit a brush definition (strokes using it update) → {name}", + "{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|tilt|bearing|rotation for angle, roundness, size] (pressure goes from value − variation at the lightest pen pressure to value + variation at the heaviest; tilt from value − variation with the pen lying flat to value + variation with it upright; bearing and rotation add variation × the pen's bearing (which way it leans, counter-clockwise from leaning right) or its barrel's counter-clockwise rotation over 180°; the angle turns on past ±180°). 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|tilt|bearing|rotation for size, spacing, scatter, rotation] (fixed: a; random: between a and b; pressure: a at the lightest pen pressure to b at the heaviest; tilt: a with the pen lying flat to b upright; bearing and rotation: a at −180° to b at 180°), 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 (drawn as the corner that joins a side tile on its right to one below it) or null, outer_source and inner_source: what that kind of corner draws without its art: none|autoCentered|autoBetween|autoSliced|autoOverlap (a tile made from the side tile), or null (the other kind of corner's art); side_tile, outer_corner_tile, inner_corner_tile, start_tile, end_tile: a document pattern name, original, or none (side refuses none); corner tiles also take autoCentered|autoBetween|autoSliced|autoOverlap. Each tile also accepts {pattern: name} to select a swatch whose name is a reserved choice. scatter, art and pattern: colorization: {method: none|tints|tintsAndShades|hueShift, key: colour (hueShift: the art colour that becomes the stroke colour; without a key: the solid color the art's fills and strokes use most, black when they use none)}; colorization and the art brush's scale change only the fields given}, newName? (trimmed; blank: the name is kept; a new bristle brush name has at most 31 characters, and a longer name the brush has is kept)} edit a brush definition (strokes using it update) → {name}", has_doc, brush_options ), diff --git a/docs/mcp.md b/docs/mcp.md index 5881c97cc..db1d43ae8 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -1775,7 +1775,9 @@ already walks Image Trace. Short agent entry points: what that kind of corner draws when the brush has no `outer_corner` or `inner_corner` art for it: `none` draws no corner tile, and `autoCentered`, `autoBetween`, `autoSliced` and `autoOverlap` make one from the side tile. Without a source, the corner draws the other kind of corner's art. `brush.options {params: {outer_corner: null}}` removes - the art. Pattern Brush Options also accepts `side_tile`, `outer_corner_tile`, `inner_corner_tile`, + the art. Outer and inner corner art are both drawn as the corner that joins a side tile on its right to one below + it. The brush turns each corner tile so that those two arms meet the sides at the corner: 90° from one corner to + the next on a rectangle, and halfway between the two sides at other angles. Pattern Brush Options also accepts `side_tile`, `outer_corner_tile`, `inner_corner_tile`, `start_tile` and `end_tile` in `params`. Each takes `none`, `original` or the name of a pattern swatch in the active document, and `{"pattern": ""}` names a swatch even when its name is one of those words. Corner tiles also take `autoCentered`, `autoBetween`, `autoSliced` and `autoOverlap`. A swatch choice copies its whole art and tile frame into the brush. `original` diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 934767da1..61b07fa97 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1165,6 +1165,8 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur The Brushes panel menu's Select All Unused selects the brushes that are not used in the document, as the [Brushes](https://web.archive.org/web/20241230205225/https://helpx.adobe.com/illustrator/using/brushes.html) page (archived copy) describes. A brush counts as used when a stroke paints with it in the art (opacity masks included), in symbols, graph designs, pattern tiles or graphic styles, or in the art of a used brush (`brush.unused`). A rename in Brush Options and Delete Brush change every stroke that paints with the brush: in the layers and opacity masks, symbols, graph designs, pattern tiles, graphic styles and the art of other brushes. A rename gives those strokes the new name, and Delete Brush makes them plain strokes, in one undo step. In Pattern Brush Options, a menu below the Outer Corner Tile and the Inner Corner Tile sets each to the brush's own corner art (Original), None, or a tile made from the side tile: Auto-Centered stretches one side tile around the corner, Auto-Between bends a side tile on each side into the corner's diagonal, Auto-Sliced cuts a side tile on each side along the diagonal, and Auto-Overlap runs a side tile on each side past the corner. Auto-Sliced masks the tiles at the diagonal, so their outlines stay as drawn. Brushes saved without these choices draw their corners as before (`brush.options` `outer_source`, `inner_source`). Evidence: `tests::pattern_auto_corners_fill_each_corner_of_a_square`, `tests::pattern_corner_source_none_leaves_that_kind_of_corner_empty` (vectorcraft-brush), `dialogs::brush_options::tests::pattern_options_set_each_corner_tile_to_original_none_or_an_automatic_tile` (vectorcraft-ui-egui). + + Pattern brush corner tiles are square to the side tiles at a right-angle corner and turn 90° from one corner to the next, as the [Using Illustrator CS5](https://help.adobe.com/archive/en_US/illustrator/cs5/illustrator_cs5_help.pdf) help (About patterns) describes. That document's steps for making corner tiles build both the outer and the inner tile as the corner joining a side tile on its right to one below it, and the brush reads corner art that way, with Flip Along and Flip Across or without (#6110). At corners that are not right angles, the tile is turned halfway between the two sides, which is VectorCraft's own rule. Evidence: `tests::pattern_corner_tiles_are_square_to_the_sides`, `tests::pattern_corner_tiles_sit_on_the_corners_diagonal` (vectorcraft-brush). - The [About brushes](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/apply-and-edit-strokes/about-brushes.html) page describes up to five Pattern brush tiles: sides, inner corner, outer corner, beginning and end. The archived [Brushes](https://web.archive.org/web/20241230205225/https://helpx.adobe.com/illustrator/using/brushes.html) page says to add pattern tiles to the Swatches panel and select a swatch from the scroll list for each tile. Pattern Brush Options offers document pattern swatches in all five tile menus, including swatches named like built-in choices. The brush copies each swatch's whole art and tile frame. Side requires art; explicit None on a corner prevents fallback to the other corner's art. After a swatch is deleted, the brush keeps drawing its copied art and shows it as Original. The cited pages do not describe copying the whole art, requiring Side, suppressing corner fallback after explicit None, or showing deleted swatch art as Original. `brush.options` accepts swatch names and an object form for names that match built-in choices, and rejects unknown patterns before editing. Evidence: `tests::brushsym::pattern_tile_choices_copy_whole_swatch_art_and_reject_invalid_side` and `tests::brushsym::pattern_tile_object_selects_reserved_swatch_names` (vectorcraft-engine), `tests::pattern_corner_source_none_leaves_that_kind_of_corner_empty` (vectorcraft-brush), and `dialogs::brush_options::tests::pattern_tile_menus_use_document_patterns_and_side_none_keeps_dialog_open`, `dialogs::brush_options::tests::pattern_options_borrowed_swatch_corner_keeps_its_frame`, and `dialogs::brush_options::tests::pattern_options_selects_swatches_with_reserved_names` (vectorcraft-ui-egui). - **Appearance:** the Appearance panel's selected row drives every paint, stroke, gradient and transparency edit (`item` on the commands); effects apply to one fill or stroke; per-item Opacity popups, whose blend-mode list and opacity slider stay open inside them (#430; the Control bar's Stroke popover too); Mixed Appearances, Layers target dots and basic-appearance rules. Effects can be reordered, moved between items and copied by dragging; Show All Hidden Attributes; clicking an effect edits it in place; the object thumbnail drags onto art; effects render on type, images, symbols, blends, envelopes, meshes and repeats, and per-item raster effects export. Groups, layers and type have their own fills, strokes and effects (Contents and Characters rows); the Layers panel's target circles target layers, groups and objects; Expand Appearance turns fills, strokes, effects and brushes into objects. Fill and stroke paint (Fill, Stroke, None, Apply Last Color and Apply Last Gradient, also on characters selected with the Type tool), the Stroke panel's options, gradient and freeform point edits, Swap Fill and Stroke, Default Fill and Stroke, the Eyedropper, and the Appearance panel's item edits, Clear Appearance, Reduce to Basic Appearance, Remove Item, item reordering and Move Appearance record no undo step when they change nothing.