Merge pull request #7383 from dotCooCoo/feat/ai-art-brush-options

.ai import and save: Art brushes keep None, their width, scale and flips
This commit is contained in:
BFlat
2026-11-14 16:54:16 +07:00
committed by GitHub
11 changed files with 455 additions and 145 deletions
+64 -12
View File
@@ -8321,7 +8321,7 @@ impl ArtBrushOptions {
/// A path with the Calligraphic brush, and one with an Art brush in Tints whose art bends along the
/// path, come back as those paths with the brush on their stroke at its weight, not as their drawn
/// look; a path with the Art brush that keeps its art's colors comes back as its drawn look.
/// look; so does a path with the Art brush that keeps its art's colors.
#[test]
fn brushed_paths_are_read_back_brushed() {
let mut doc = brushes_document();
@@ -8341,8 +8341,63 @@ fn brushed_paths_are_read_back_brushed() {
assert_eq!(art[1].path_data(), ours[1].path_data());
brushed(&mut doc, "Plain ribbon", (20.0, 180.0), (280.0, 180.0), 1.0);
let out = export_ai_editing(&doc, &opts()).unwrap();
assert!(out.warnings.iter().any(|w| w == writer::STROKES), "{:?}", out.warnings);
assert!(matches!(read_back(&doc).layers[0].children().unwrap()[2].kind, NodeKind::Group { .. }));
assert!(!out.warnings.iter().any(|w| w == writer::STROKES), "{:?}", out.warnings);
let back = read_back(&doc);
let n = &back.layers[0].children().unwrap()[2];
assert!(matches!(n.kind, NodeKind::Path { .. }), "{n:#?}");
assert_eq!(n.appearance.stroke().and_then(|s| s.brush.as_deref()), Some("Plain ribbon"));
}
/// Art brushes at another width, flipped along or across, scaled proportionately or stretched
/// between guides (flipped along too, or at guides the written numbers round) are written with those
/// options, and a path with each comes back as the path with the brush on its stroke, except Scale
/// Proportionately's, which is written as its brush's art.
#[test]
fn art_brushes_with_options_are_read_back_with_them() {
use vectorcraft_brush::{ArtBrush, ArtScale, Brush, BrushKind};
let thirds = ArtScale::BetweenGuides { start: 1.0 / 3.0, end: 2.0 / 3.0 };
let options: Vec<(&str, ArtBrush)> = vec![
("Wide", ArtBrush { width: 250.0, ..ArtBrush::default() }),
("Along", ArtBrush { flip_along: true, ..ArtBrush::default() }),
("Across", ArtBrush { flip_across: true, ..ArtBrush::default() }),
("Proportional", ArtBrush { scale: ArtScale::Proportional, width: 50.0, ..ArtBrush::default() }),
("Guides", ArtBrush { scale: ArtScale::BetweenGuides { start: 0.25, end: 0.5 }, ..ArtBrush::default() }),
("Guides along", ArtBrush { scale: ArtScale::BetweenGuides { start: 0.25, end: 0.5 }, flip_along: true, ..ArtBrush::default() }),
("Thirds", ArtBrush { scale: thirds, flip_along: true, ..ArtBrush::default() }),
];
let lib: Vec<Brush> = options
.iter()
.map(|(name, a)| Brush { name: (*name).into(), kind: BrushKind::Art(ArtBrush { art: brush_art_node(), ..a.clone() }) })
.collect();
let mut doc = Document::new(300.0, 200.0);
vectorcraft_brush::store(&mut doc, &lib);
for (i, b) in lib.iter().enumerate() {
let y = 20.0 + 25.0 * i as f64;
brushed(&mut doc, &b.name, (20.0, y), (280.0, y), 1.0);
}
let out = export_ai_editing(&doc, &opts()).unwrap();
assert!(!out.warnings.iter().any(|w| w == writer::BRUSHES), "{:?}", out.warnings);
let back = read_back(&doc);
let got = vectorcraft_brush::library(&back);
assert_eq!(got.len(), lib.len());
for (g, w) in got.iter().zip(&lib) {
let (BrushKind::Art(g), BrushKind::Art(w)) = (&g.kind, &w.kind) else { panic!("art") };
if w.scale == thirds {
let ArtScale::BetweenGuides { start, end } = g.scale else { panic!("guides: {g:?}") };
assert!((start - 1.0 / 3.0).abs() < 1e-6 && (end - 2.0 / 3.0).abs() < 1e-6, "{g:?}");
assert_eq!(ArtBrushOptions::of(&ArtBrush { scale: thirds, ..g.clone() }), ArtBrushOptions::of(w));
} else {
assert_eq!(ArtBrushOptions::of(g), ArtBrushOptions::of(w));
}
}
for (n, b) in back.layers[0].children().unwrap().iter().zip(&lib) {
if b.name == "Proportional" {
assert!(matches!(n.kind, NodeKind::Group { .. }), "{}: the brush's art: {n:#?}", b.name);
continue;
}
assert!(matches!(n.kind, NodeKind::Path { .. }), "{}: {n:#?}", b.name);
assert_eq!(n.appearance.stroke().and_then(|s| s.brush.as_deref()), Some(b.name.as_str()));
}
}
/// A compound path with the Calligraphic brush comes back as that compound path with the brush on its
@@ -8646,8 +8701,8 @@ fn brushes_write_the_same_bytes_again() {
/// beside a brush that comes back, and each changed from a brush that comes back by the one setting
/// that leaves it out: an Art brush whose art is a group with a fill of its own, a Scatter brush whose
/// art is a path without points, an Art brush whose art is a group without members, Pattern and Bristle
/// brushes, Art brushes flipped along or across, overlapping, at another width, with a width that
/// varies, in another direction or scale, Art and Scatter brushes in Tints and Shades or Hue Shift, a
/// brushes, Art brushes overlapping, with a width that varies, in another direction or with guides
/// that leave no art between them, Art and Scatter brushes in Tints and Shades or Hue Shift, a
/// brush whose name holds a slash and a second brush of a name. A brush left out doesn't take its
/// name from a later one.
#[test]
@@ -8671,13 +8726,10 @@ fn brushes_the_reader_cant_read_are_left_out() {
("empty group", art_with(&|a| a.art = Node::group(vectorcraft_doc::NodeId(1), vec![]))),
("pattern", BrushKind::Pattern(PatternBrush::default())),
("bristle", BrushKind::Bristle(Bristle::default())),
("flipped along", art_with(&|a| a.flip_along = true)),
("flipped across", art_with(&|a| a.flip_across = true)),
("overlap", art_with(&|a| a.overlap = true)),
("width", art_with(&|a| a.width = 50.0)),
("width varies", art_with(&|a| a.width_mode = Some(Variation::Pressure))),
("direction", art_with(&|a| a.direction = Direction::RightToLeft)),
("proportional", art_with(&|a| a.scale = ArtScale::Proportional)),
("guides with no art between them", art_with(&|a| a.scale = ArtScale::BetweenGuides { start: 0.5, end: 0.5 })),
("art tints and shades", art_with(&|a| a.colorization = Colorization::TintsAndShades)),
("art hue shift", art_with(&|a| a.colorization = hue)),
(
@@ -8887,8 +8939,8 @@ fn an_empty_brushes_panel_comes_back_empty() {
}
/// A path with one of a new document's brushes writes the brushes the reader reads, and keeps its
/// brush. The new document's Art brush with other options, its Pattern brushes and its Bristle
/// brushes are left out with a warning, so the file opens without them.
/// brush. The new document's Pattern brushes and its Bristle brushes are left out with a warning,
/// so the file opens without them.
#[test]
fn a_new_documents_brush_on_a_path_writes_the_brushes() {
let mut doc = Document::new(300.0, 200.0);
@@ -8898,7 +8950,7 @@ fn a_new_documents_brush_on_a_path_writes_the_brushes() {
let back = read_back(&doc);
let n = &back.layers[0].children().unwrap()[0];
assert_eq!(n.appearance.stroke().and_then(|s| s.brush.as_deref()), Some("3 pt. Round"));
let left_out = ["Arrow", "Chain", "Stitches", "Diamonds", "Bristle Round", "Bristle Flat Fan"];
let left_out = ["Chain", "Stitches", "Diamonds", "Bristle Round", "Bristle Flat Fan"];
let want: Vec<&str> = vectorcraft_brush::defaults().iter().map(|b| b.name.as_str()).filter(|n| !left_out.contains(n)).collect();
let lib = vectorcraft_brush::library(&back);
assert_eq!(lib.iter().map(|b| b.name.as_str()).collect::<Vec<_>>(), want);
+67 -33
View File
@@ -23,7 +23,7 @@ use super::{Writer, Xy};
use crate::ai_ops;
use crate::ps;
pub(in crate::ai_editing) const BRUSHES: &str = "Pattern and Bristle brushes, Art brushes that don't stretch their art along the path from left to right at 100% unflipped without overlap, Scatter and Art brushes in Tints and Shades or Hue Shift, brushes whose art holds an empty path or group or more than paths, compound paths and groups with one solid fill and one plain stroke each, brushes whose names hold a slash or repeat an earlier brush's name, brushes that would open with other settings, brushes past the number or the amount of art one file's brushes can hold, and every brush on a large canvas are left out of the Brushes panel in the editing data for other apps";
pub(in crate::ai_editing) const BRUSHES: &str = "Pattern and Bristle brushes, Art brushes that don't run their art from left to right, that overlap or whose width follows the pen, Scatter and Art brushes in Tints and Shades or Hue Shift, brushes whose art holds an empty path or group or more than paths, compound paths and groups with one solid fill and one plain stroke each, brushes whose names hold a slash or repeat an earlier brush's name, brushes that would open with other settings, brushes past the number or the amount of art one file's brushes can hold, and every brush on a large canvas are left out of the Brushes panel in the editing data for other apps";
/// Most brushes written (the reader's limit).
const MAX_BRUSHES: usize = 4096;
@@ -145,22 +145,71 @@ fn scatter_params(s: &Scatter, art: &str) -> Option<String> {
Some(format!("2 /{art}/ 5 0 0 0 / {}", nums6(&numbers)))
}
/// An Art brush's parameters, its art the section `art`: `None` for options the reader doesn't read
/// (it reads an Art brush that stretches its art along the path from left to right at 100%,
/// unflipped, without overlap, in Tints or keeping its colors).
fn art_params(a: &ArtBrush, art: &str) -> Option<String> {
let plain = a.direction == Direction::LeftToRight
&& matches!(a.scale, ArtScale::Stretch)
&& a.width == 100.0
&& a.width_mode.is_none()
&& !a.flip_along
&& !a.flip_across
&& !a.overlap;
if !plain {
/// An Art brush's settings as its parameters' numbers 2 to 15 give them and a brushed stroke's filter
/// holds them ([`ART_SETTINGS`]): `None` for options the reader doesn't read (it reads an Art brush
/// that runs its art from left to right without overlap, at a fixed width, in Tints or keeping its
/// colors). The guides of Stretch Between Guides are the lengths of the art before the first and
/// after the second, measured before the art is flipped along the path.
fn art_settings(a: &ArtBrush) -> Option<[f64; 14]> {
if a.direction != Direction::LeftToRight || a.width_mode.is_some() || a.overlap {
return None;
}
let c = colorization_code(a.colorization)?;
let numbers = [1.0, c, 1.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0];
let length = a.art.geometric_bounds().map_or(0.0, |b| b.width());
let (proportional, segmented, before, after) = match a.scale {
ArtScale::Stretch => (0.0, 0.0, 0.0, 0.0),
ArtScale::Proportional => (1.0, 0.0, 0.0, 0.0),
ArtScale::BetweenGuides { start, end } if start < end => {
let (before, after) = (start * length, (1.0 - end) * length);
let (before, after) = if a.flip_along { (after, before) } else { (before, after) };
(0.0, 1.0, before, after)
}
ArtScale::BetweenGuides { .. } => return None,
};
let scaling = a.width / 100.0;
let flip = |on: bool| f64::from(u8::from(on));
Some([
colorization_code(a.colorization)?,
scaling,
0.0,
1.0,
flip(a.flip_along),
flip(a.flip_across),
proportional,
scaling,
scaling,
0.0,
segmented,
before,
after,
1.0,
])
}
/// The entries of a brushed stroke's filter that hold an Art brush's settings, in the order of
/// [`art_settings`], with how each is written.
const ART_SETTINGS: [(&str, &str); 14] = [
("Colorization", "Int"),
("Scaling", "Real"),
("Spacing", "Real"),
("Adjustment", "Int"),
("Counterclockwise", "Int"),
("UpsideDown", "Int"),
("Proportional", "Bool"),
("MinScaling", "Real"),
("MaxScaling", "Real"),
("ScaleYDependsOn", "Int"),
("Segmented", "Bool"),
("StartSegmentLength", "Real"),
("EndSegmentLength", "Real"),
("FoldAdjustment", "Bool"),
];
/// An Art brush's parameters, its art the section `art`: `None` for options the reader doesn't read
/// ([`art_settings`]).
fn art_params(a: &ArtBrush, art: &str) -> Option<String> {
let settings = art_settings(a)?;
let mut numbers = vec![1.0];
numbers.extend(settings);
Some(format!("2 / {art}/ / / / / 0 0/ 5 0 0 0 / {}", nums6(&numbers)))
}
@@ -458,25 +507,10 @@ impl Writer<'_> {
d.push_str("0 /Int (AngleRelativeTo) ,\n");
"(Calligraphic Brush) /Title ,\n"
}
BrushKind::Art(_) => {
BrushKind::Art(a) => {
d.push_str("15 /Int (Adobe Effect Expand Before Version) ,\n"); // brand-ok: the entry's name in the files
for (key, value, kind) in [
("StartSegmentLength", "0", "Real"),
("EndSegmentLength", "0", "Real"),
("Counterclockwise", "0", "Int"),
("MinScaling", "1", "Real"),
("MaxScaling", "1", "Real"),
("Colorization", "1", "Int"),
("Spacing", "0", "Real"),
("Adjustment", "1", "Int"),
("Proportional", "0", "Bool"),
("Segmented", "0", "Bool"),
("UpsideDown", "0", "Int"),
("FoldAdjustment", "1", "Bool"),
("ScaleYDependsOn", "0", "Int"),
("Scaling", "1", "Real"),
] {
let _ = writeln!(d, "{value} /{kind} ({key}) ,");
for ((key, kind), value) in ART_SETTINGS.iter().zip(art_settings(a)?) {
let _ = writeln!(d, "{} /{kind} ({key}) ,", nums6(&[value]));
}
"(Art Brush) /Title ,\n"
}
+135 -32
View File
@@ -36,7 +36,13 @@
//! - Scatter: the size (2, 3), spacing (5, 6) and scatter (8, 9) as (low, high) factors of the
//! art, the rotation in degrees (11, 12), how each varies (4, 7, 10, 13) and whether the
//! rotation follows the path (14), and 1 Tints (1).
//! - Art: 1 Tints (2).
//! - Art: numbers 2 to 15 are the settings a path's art style holds as entries ([`ArtSettings`]):
//! the colorization (2: 0 None, 1 Tints; other codes color the art otherwise), the width as a factor
//! (3), `Spacing` (4), `Adjustment` (5), flipped along (6) and across (7), Scale Proportionately
//! (8), the lowest and highest width when the width follows the pen (9, 10, not drawn otherwise),
//! how the width varies (11, 0 Fixed), Stretch Between Guides (12) and the lengths of the art
//! before its first guide and after its second (13, 14, in the art's units, measured before
//! the art is flipped along the path), and `FoldAdjustment` (15).
//! - Pattern: its scale as a factor (3).
//! - A Bristle brush's parameters start with 8 numbers: two codes, then the stiffness, bristle
//! thickness, density, paint opacity and bristle length as fractions and the size in
@@ -45,14 +51,15 @@
//! [`super::read`]), usually in a group after its frame, an unpainted rectangle around it. A frame
//! at the art's bounds isn't kept ([`without_frame`]); a wider one is.
//!
//! What isn't read keeps its default: the Art brush's other options, the pattern brush's tiles
//! What isn't read keeps its default: an Art brush's settings that VectorCraft doesn't draw as the
//! file does ([`art_options`]) apart from its colorization, the pattern brush's tiles
//! (which slot is which tile isn't known) and the colorization of the other brushes, which is left
//! at None so the art keeps its own colors.
use std::collections::{HashMap, HashSet};
use kurbo::Affine;
use vectorcraft_brush::{ArtBrush, Bristle, Brush, BrushKind, Calligraphic, Colorization, Scatter, Variation};
use vectorcraft_brush::{ArtBrush, ArtScale, Bristle, Brush, BrushKind, Calligraphic, Colorization, Scatter, Variation};
use vectorcraft_color::Paint;
use vectorcraft_doc::{AppearanceItem, Node, NodeKind};
@@ -118,13 +125,13 @@ pub(super) enum DraftKind {
brush: Scatter,
art: String,
},
/// The art is the section named `art`. `plain`: the brush stretches its art along the path,
/// unflipped, at 100% in Tints (see [`art_is_plain`]), so [`Self::Art`]'s `brush` draws as the
/// file's brush does.
/// The art is the section named `art`. `settings`: the brush's settings when [`art_options`]
/// reads them, so [`Self::Art`]'s `brush` draws as the file's brush does (its guides set from
/// its art's length by [`finish_brushes`]).
Art {
brush: ArtBrush,
art: String,
plain: bool,
settings: Option<ArtSettings>,
},
/// The five art sections the brush names, in its order (which is which tile isn't known), and
/// its scale (%).
@@ -226,12 +233,17 @@ pub(super) fn calligraphic(values: [f64; 3], variation: [f64; 3], codes: [f64; 3
Calligraphic { angle, roundness, size, variation, modes: Some(modes) }
}
/// An Art brush's settings: its object's numbers 2 to 15, which a path's art style holds as the
/// entries `Colorization`, `Scaling`, `Spacing`, `Adjustment`, `Counterclockwise`, `UpsideDown`,
/// `Proportional`, `MinScaling`, `MaxScaling`, `ScaleYDependsOn`, `Segmented`,
/// `StartSegmentLength`, `EndSegmentLength` and `FoldAdjustment`, in that order.
pub(super) type ArtSettings = [f64; 14];
/// The settings a path's art style gives its stroke's brush.
#[derive(Clone, Debug, PartialEq)]
pub(super) enum PathBrush {
Calligraphic(Calligraphic),
/// An Art brush whose art stretches along the path, at the brush's own width, in Tints.
Art,
Art(ArtSettings),
}
/// Does an Art brush draw `art` as the file does? It bends paths in solid colors: other objects
@@ -250,8 +262,9 @@ pub(super) fn art_bends(art: &Node) -> bool {
bends
}
/// The art sections of `drafts`' Art brushes that an Art brush draws as the file does.
pub(super) fn bending_art(drafts: &[BrushDraft], art: &HashMap<String, Node>) -> HashSet<String> {
/// The art sections of `drafts`' Art brushes that an Art brush draws as the file does, with the
/// length of each (its width, the art running left to right).
pub(super) fn bending_art(drafts: &[BrushDraft], art: &HashMap<String, Node>) -> HashMap<String, f64> {
let names: HashSet<&String> = drafts
.iter()
.filter_map(|d| match &d.kind {
@@ -259,13 +272,15 @@ pub(super) fn bending_art(drafts: &[BrushDraft], art: &HashMap<String, Node>) ->
_ => None,
})
.collect();
names.into_iter().filter(|n| art.get(*n).is_some_and(art_bends)).cloned().collect()
names.into_iter().filter_map(|n| art.get(n).filter(|a| art_bends(a)).and_then(|a| a.geometric_bounds()).map(|b| (n.clone(), b.width()))).collect()
}
/// Does the brush `draft` draw as `settings`, the settings a path's art style gives it? A path whose
/// settings differ (Options of Selected Object) keeps its drawn look: a stroke names a brush, and
/// has no settings of its own. `bending`: [`bending_art`].
pub(super) fn draws_as(draft: &BrushDraft, settings: &PathBrush, bending: &HashSet<String>) -> bool {
/// has no settings of its own. An Art brush's settings that don't draw ([`drawn`]) may differ, and
/// the path's own settings must be ones the brush would open with ([`art_options`], [`opens_live`]).
/// `bending`: [`bending_art`].
pub(super) fn draws_as(draft: &BrushDraft, settings: &PathBrush, bending: &HashMap<String, f64>) -> bool {
let close = |a: f64, b: f64| (a - b).abs() <= 1e-3 * a.abs().max(b.abs()).max(1.0);
match (&draft.kind, settings) {
(DraftKind::Calligraphic(d), PathBrush::Calligraphic(c)) => {
@@ -275,16 +290,90 @@ pub(super) fn draws_as(draft: &BrushDraft, settings: &PathBrush, bending: &HashS
&& d.variation.iter().zip(&c.variation).all(|(a, b)| close(*a, *b))
&& d.modes() == c.modes()
}
(DraftKind::Art { plain: true, art: name, .. }, PathBrush::Art) => bending.contains(name),
(DraftKind::Art { settings: Some(d), art: name, .. }, PathBrush::Art(s)) => {
let (dd, ds) = (drawn(d), drawn(s));
art_options(s).is_some()
&& bending.get(name).is_some_and(|length| {
// The guides match as lengths and as fractions of the art's length, so they differ
// neither on short art nor near the ends of long art.
dd.iter().zip(ds).all(|(a, b)| close(*a, b))
&& [11, 12].iter().all(|&i| close(dd[i] / length, ds[i] / length))
&& opens_live(d, *length)
&& opens_live(s, *length)
})
}
_ => false,
}
}
/// Are an Art brush's last 15 numbers those of a brush that stretches its art along the path,
/// unflipped, at 100% in Tints? The numbers aren't read otherwise: any other value keeps its paths'
/// drawn look.
fn art_is_plain(numbers: &[f64; 15]) -> bool {
*numbers == [1.0, 1.0, 1.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0]
/// Art brush settings `s` with those that don't draw set to 0: the lowest and highest width while the
/// width is fixed, and the guides' lengths without Stretch Between Guides.
fn drawn(s: &ArtSettings) -> ArtSettings {
let mut out = *s;
if s[9] == 0.0 {
(out[7], out[8]) = (0.0, 0.0);
}
if s[10] == 0.0 {
(out[11], out[12]) = (0.0, 0.0);
}
out
}
/// What an Art brush's settings give VectorCraft's Art brush, its guides apart ([`guides`]): `None`
/// when VectorCraft doesn't draw them as the file does: a colorization other than None or Tints, a
/// width outside [`ArtBrush::WIDTH_RANGE`] or one that varies, both Scale Proportionately and Stretch
/// Between Guides, a flip or scale that isn't 0 or 1, or a `Spacing`, `Adjustment` or
/// `FoldAdjustment` other than 0, 1 and 1 (what other values change isn't known).
fn art_options(s: &ArtSettings) -> Option<ArtBrush> {
let [colorization, scaling, spacing, adjustment, along, across, proportional, _, _, varies, segmented, _, _, fold] = *s;
let switch = |v: f64| match v {
0.0 => Some(false),
1.0 => Some(true),
_ => None,
};
let colorization = match colorization {
0.0 => Colorization::None,
1.0 => Colorization::Tints,
_ => return None,
};
let width = scaling * 100.0;
let (low, high) = ArtBrush::WIDTH_RANGE;
if !(low..=high).contains(&width) || varies != 0.0 || spacing != 0.0 || adjustment != 1.0 || fold != 1.0 {
return None;
}
let scale = match (switch(proportional)?, switch(segmented)?) {
(false, false) => ArtScale::Stretch,
(true, false) => ArtScale::Proportional,
(false, true) => ArtScale::BetweenGuides { start: 0.0, end: 1.0 },
(true, true) => return None,
};
Some(ArtBrush { scale, width, flip_along: switch(along)?, flip_across: switch(across)?, colorization, ..ArtBrush::default() })
}
/// The guides of Art brush settings `s` (with Stretch Between Guides) for art of length `length`, as
/// [`ArtScale::BetweenGuides`] fractions of the art as it runs along the path: `None` when they leave
/// no art between them. The settings measure the art before it is flipped along the path.
fn guides(s: &ArtSettings, length: f64) -> Option<(f64, f64)> {
let (before, after) = (s[11], s[12]);
if !(before >= 0.0 && after >= 0.0 && before + after < length) {
return None;
}
let (before, after) = if s[4] == 1.0 { (after, before) } else { (before, after) };
Some((before / length, 1.0 - after / length))
}
/// Do Art brush settings `s` draw art of length `length`: no Stretch Between Guides, or guides with
/// art between them?
fn guides_fit(s: &ArtSettings, length: f64) -> bool {
s[10] == 0.0 || guides(s, length).is_some()
}
/// Does a path with Art brush settings `s`, its art of length `length`, open with the brush on its
/// stroke? Its guides must fit ([`guides_fit`]). Scale Proportionately keeps the drawn look:
/// VectorCraft's Art brush sizes proportional art by the path's length and not by the stroke's
/// weight.
fn opens_live(s: &ArtSettings, length: f64) -> bool {
s[6] == 0.0 && guides_fit(s, length)
}
/// The brush named `name` of kind `kind` from its parameters `params`.
@@ -342,15 +431,22 @@ pub(super) fn parse_tool_object(kind: Tool, name: &str, params: &str) -> Result<
Tool::Art => {
// The older form's 8 numbers aren't known: its brush keeps the defaults and its
// paths their drawn look. The count tells the form, not the first number.
// Any other count isn't a form this reads.
let (colorization, plain) = if let Ok(n15) = <&[f64; 15]>::try_from(n.as_slice()) {
(if n15[1] == 1.0 { Colorization::Tints } else { Colorization::None }, art_is_plain(n15))
// Any other count isn't a form this reads. Settings that aren't read keep the
// defaults but the colorization, and their paths their drawn look.
let (brush, settings) = if let Ok(&[first, ref settings @ ..]) = <&[f64; 15]>::try_from(n.as_slice()) {
match art_options(settings).filter(|_| first == 1.0) {
Some(brush) => (brush, Some(*settings)),
None => {
let colorization = if settings[0] == 1.0 { Colorization::Tints } else { Colorization::None };
(ArtBrush { colorization, ..ArtBrush::default() }, None)
}
}
} else if n.len() == 8 {
(ArtBrush::default().colorization, false)
(ArtBrush::default(), None)
} else {
return Err(BrushError::Values);
};
DraftKind::Art { brush: ArtBrush { colorization, ..ArtBrush::default() }, art: art()?, plain }
DraftKind::Art { brush, art: art()?, settings }
}
_ => {
let [_, _, p3, ..] = n.as_slice() else { return Err(BrushError::Values) };
@@ -453,10 +549,15 @@ pub(super) fn finish_brushes_within(
_ if !kept => None,
DraftKind::Calligraphic(c) => Some(BrushKind::Calligraphic(c)),
DraftKind::Scatter { brush, art: name } => copy(&name).map(|a| BrushKind::Scatter(Scatter { art: a, ..brush })),
DraftKind::Art { brush, art: name, .. } => copy(&name).map(|mut a| {
DraftKind::Art { mut brush, art: name, settings } => copy(&name).map(|mut a| {
// The side of the section with y up draws on the right of the path, and a brush
// draws art's y (down here) on the left of the path.
a.transform(Affine::scale_non_uniform(1.0, -1.0), false);
if let ArtScale::BetweenGuides { start, end } = &mut brush.scale {
// Guides that leave no art between them stretch the whole art.
let length = a.geometric_bounds().map_or(0.0, |b| b.width());
(*start, *end) = settings.and_then(|s| guides(&s, length)).unwrap_or((0.0, 1.0));
}
BrushKind::Art(ArtBrush { art: a, ..brush })
}),
DraftKind::Pattern { .. } => None,
@@ -586,8 +687,8 @@ pub(crate) struct BrushRead {
pub brush: Brush,
/// A path whose art style names the brush, at the brush's own settings, opens with the brush on
/// its stroke: a Calligraphic brush, or an Art brush whose art an Art brush draws as the file
/// does ([`art_bends`]) and whose numbers are those of a brush that stretches its art along the
/// path, unflipped, at 100% in Tints.
/// does ([`art_bends`]) and whose settings VectorCraft draws as the file does ([`art_options`],
/// with guides that leave art between them and without Scale Proportionately: [`opens_live`]).
pub live: bool,
}
@@ -604,7 +705,9 @@ pub(crate) fn read_brush_object(plugin: &str, name: &str, params: &str, art: Opt
let live = kept(&drafts, &sections).first().copied().unwrap_or(false)
&& match &drafts[0].kind {
DraftKind::Calligraphic(_) => true,
DraftKind::Art { plain: true, art: section, .. } => bending_art(&drafts, &sections).contains(section),
DraftKind::Art { settings: Some(s), art: section, .. } => {
bending_art(&drafts, &sections).get(section).is_some_and(|length| opens_live(s, *length))
}
_ => false,
};
let [draft] = drafts;
@@ -643,8 +746,8 @@ pub(crate) struct BrushesRead {
/// The brush a stroke that names a brush is drawn with, by name: the first one kept
/// ([`kept`]), as an index in [`Self::drafts`].
by_name: HashMap<String, usize>,
/// The art an Art brush draws as the file does ([`bending_art`]).
bending: HashSet<String>,
/// The art an Art brush draws as the file does, with its length ([`bending_art`]).
bending: HashMap<String, f64>,
}
impl BrushesRead {
@@ -681,7 +784,7 @@ impl BrushesRead {
st.items.iter().all(|item| match item {
StyleItem::Stroke { brush: Some((name, settings)), .. } => {
let Some(draft) = self.by_name.get(name).and_then(|i| self.drafts.get(*i)) else { return false };
draws_as(draft, settings, &self.bending) && !(matches!(settings, PathBrush::Art) && !st.effects.is_empty())
draws_as(draft, settings, &self.bending) && !(matches!(settings, PathBrush::Art(_)) && !st.effects.is_empty())
}
_ => true,
})
+2 -2
View File
@@ -438,8 +438,8 @@ struct BrushesKept {
read: (usize, usize),
/// Which of [`Reader::brush_drafts`] are kept as brushes.
kept: Vec<bool>,
/// The art an Art brush draws as the file does.
bending: HashSet<String>,
/// The art an Art brush draws as the file does, with its length.
bending: HashMap<String, f64>,
}
struct Reader<'a> {
+1 -1
View File
@@ -1181,7 +1181,7 @@ mod tests {
assert!(nodes > 0);
let draft = |i: usize| brush::BrushDraft {
name: format!("Brush {i}"),
kind: brush::DraftKind::Art { brush: vectorcraft_brush::ArtBrush::default(), art: "A".to_string(), plain: false },
kind: brush::DraftKind::Art { brush: vectorcraft_brush::ArtBrush::default(), art: "A".to_string(), settings: None },
};
let drafts: Vec<_> = (0..5).map(draft).collect();
let (kept, left_out) = brush::finish_brushes_within(drafts.clone(), &reader.brush_art, None, 3 * nodes);
+5 -5
View File
@@ -992,8 +992,8 @@ struct StylePaints<'a> {
brushes: &'a [super::super::brush::BrushDraft],
/// Which of [`Self::brushes`] are kept as brushes ([`super::super::brush::kept`]).
kept: &'a [bool],
/// The art an Art brush draws as the file does ([`super::super::brush::bending_art`]).
bending: &'a HashSet<String>,
/// The art an Art brush draws as the file does, with its length ([`super::super::brush::bending_art`]).
bending: &'a HashMap<String, f64>,
space: Affine,
}
@@ -1270,7 +1270,7 @@ fn styled(object: &Node, look: &Node, st: &crate::import::ai::style::ArtStyle, p
if !super::super::brush::draws_as(draft, settings, paints.bending) {
return None;
}
let art = matches!(settings, super::super::brush::PathBrush::Art);
let art = matches!(settings, super::super::brush::PathBrush::Art(_));
let on_a_path = matches!(object.kind, NodeKind::Path { .. } | NodeKind::Compound { .. });
if !on_a_path && (art || !slot) {
return None;
@@ -1570,7 +1570,7 @@ mod tests {
let st = &styles["Inked"];
let brushes = [parse_tool_object(Tool::Calligraphic, "N", "1 4 8 30 50 -150 210 2 5 6 0").unwrap()];
let (gradients, patterns, names, v7_patterns, bending) =
(HashMap::new(), HashMap::new(), ShownNames::default(), HashSet::new(), HashSet::new());
(HashMap::new(), HashMap::new(), ShownNames::default(), HashSet::new(), HashMap::new());
let paints = StylePaints {
gradients: &gradients,
patterns: &patterns,
@@ -1616,7 +1616,7 @@ mod tests {
// GUARDS: an image with a Warp in its art style came in as its drawn look.
let warp = ("Adobe Deform", "1 /Int (DeformStyle) ,\n0.4 /Real (DeformValue) ,\n"); // brand-ok: the filter's name in the files
let (gradients, patterns, names, v7_patterns, bending) =
(HashMap::new(), HashMap::new(), ShownNames::default(), HashSet::new(), HashSet::new());
(HashMap::new(), HashMap::new(), ShownNames::default(), HashSet::new(), HashMap::new());
let paints = StylePaints {
gradients: &gradients,
patterns: &patterns,
+29 -25
View File
@@ -1500,11 +1500,30 @@ const ART_ENTRIES: [&str; 18] = [
"Adobe Effect Expand Before Version", // brand-ok: the entry's name in the files
];
/// An Art brush's filter `f` as the stroke it paints: `None` when it isn't a stroke in a color, names
/// no Art brush, holds an entry that isn't read, or its settings aren't those of art that stretches
/// along the path, unflipped, at the brush's own width (`Scaling` 1), in Tints (`Colorization` 1),
/// with `FoldAdjustment` 1, which is [`vectorcraft_brush::ArtBrush`]'s `overlap: false`: its drawn
/// look stays.
/// The entries of an Art brush's filter that hold its settings, in the order of
/// [`super::brush::ArtSettings`], with how each is written and its value when the filter leaves it
/// out (`None`: the filter must hold it).
const ART_SETTINGS: [(&str, Kind, Option<f64>); 14] = [
("Colorization", Kind::Int, None),
("Scaling", Kind::Real, None),
("Spacing", Kind::Real, Some(0.0)),
("Adjustment", Kind::Int, Some(1.0)),
("Counterclockwise", Kind::Int, Some(0.0)),
("UpsideDown", Kind::Int, Some(0.0)),
("Proportional", Kind::Bool, Some(0.0)),
("MinScaling", Kind::Real, Some(1.0)),
("MaxScaling", Kind::Real, Some(1.0)),
("ScaleYDependsOn", Kind::Int, Some(0.0)),
("Segmented", Kind::Bool, Some(0.0)),
("StartSegmentLength", Kind::Real, Some(0.0)),
("EndSegmentLength", Kind::Real, Some(0.0)),
("FoldAdjustment", Kind::Bool, Some(1.0)),
];
/// An Art brush's filter `f` as the stroke it paints, with its settings: `None` when it isn't a
/// stroke in a color, names no Art brush, holds an entry that isn't read or a setting that isn't a
/// number of its kind, or tints its art with a swatch's color: its drawn look stays. Whether the
/// brush draws as these settings is [`super::brush::draws_as`]'s to say.
fn art_stroke(f: &Filter) -> Option<StyleItem> {
if !f.paints.0
|| f.gradient
@@ -1519,28 +1538,13 @@ fn art_stroke(f: &Filter) -> Option<StyleItem> {
if plugin != ART_BRUSH {
return None;
}
let off = |key: &str, kind: Kind| matches!(entry(f, key, kind), Ok(None | Some(0.0)));
let plain = off("Proportional", Kind::Bool)
&& off("Segmented", Kind::Bool)
&& off("UpsideDown", Kind::Int)
&& off("Counterclockwise", Kind::Int)
&& off("ScaleYDependsOn", Kind::Int)
&& off("Spacing", Kind::Real)
&& off("StartSegmentLength", Kind::Real)
&& off("EndSegmentLength", Kind::Real);
if !plain || entry(f, "Adjustment", Kind::Int).ok()?.unwrap_or(1.0) != 1.0 || entry(f, "FoldAdjustment", Kind::Bool).ok()?.unwrap_or(1.0) != 1.0 {
return None;
}
let one = |key: &str| entry(f, key, Kind::Real).is_ok_and(|v| v.unwrap_or(1.0) == 1.0);
if entry(f, "Scaling", Kind::Real).ok()?? != 1.0 || !one("MinScaling") || !one("MaxScaling") {
return None;
}
if entry(f, "Colorization", Kind::Int).ok()?? != 1.0 {
return None;
let mut settings = [0.0; 14];
for (slot, (key, kind, missing)) in settings.iter_mut().zip(ART_SETTINGS) {
*slot = entry(f, key, kind).ok()?.or(missing)?;
}
let paint = f.stroke.clone()?;
// The tinted pieces are process colors, so a swatch's ink would be lost.
if matches!(paint, Paint::Solid { swatch: Some(_), .. }) {
if settings[0] == 1.0 && matches!(paint, Paint::Solid { swatch: Some(_), .. }) {
return None;
}
Some(StyleItem::Stroke {
@@ -1552,7 +1556,7 @@ fn art_stroke(f: &Filter) -> Option<StyleItem> {
dash: None,
profile: None,
align: StrokeAlign::Center,
brush: Some((name.clone(), PathBrush::Art)),
brush: Some((name.clone(), PathBrush::Art(settings))),
})
}
+137 -23
View File
@@ -9797,7 +9797,7 @@ fn a_pattern_brushs_five_slots_are_kept() {
fn an_older_art_brush_comes_in_with_its_art() {
const OLD_RIBBON: &str = "1 / Ribbon art/ / / / / 0 1 / 1 0 1 0 1 0 0 0";
let d = brush::parse_tool_object(brush::Tool::Art, "Ribbon", OLD_RIBBON).unwrap();
assert!(matches!(d.kind, brush::DraftKind::Art { ref art, plain: false, .. } if art == "Ribbon art"), "{d:?}");
assert!(matches!(d.kind, brush::DraftKind::Art { ref art, settings: None, .. } if art == "Ribbon art"), "{d:?}");
let brush::DraftKind::Art { ref brush, .. } = d.kind else { panic!("art") };
assert_eq!(brush.colorization, vectorcraft_brush::ArtBrush::default().colorization, "the default colorization");
// A count of numbers neither form has isn't read.
@@ -9807,8 +9807,8 @@ fn an_older_art_brush_comes_in_with_its_art() {
assert_eq!(brush::parse_tool_object(brush::Tool::Scatter, "Spray", "1 /Dot art/ 0 1 / 1 0 1 0 1 0 0 0"), Err(brush::BrushError::Values));
// 8 numbers are the older form whatever the first number says: read with the defaults.
let d = brush::parse_tool_object(brush::Tool::Art, "Ribbon", "0 / Ribbon art/ / / / / 0 1 / 1 0 1 0 1 0 0 0").unwrap();
let brush::DraftKind::Art { ref brush, ref art, plain } = d.kind else { panic!("art") };
assert_eq!((art.as_str(), plain, brush.colorization), ("Ribbon art", false, vectorcraft_brush::ArtBrush::default().colorization));
let brush::DraftKind::Art { ref brush, ref art, settings } = d.kind else { panic!("art") };
assert_eq!((art.as_str(), settings, brush.colorization), ("Ribbon art", None, vectorcraft_brush::ArtBrush::default().colorization));
let objects = [plugin_object(ART_BRUSH, "Ribbon", OLD_RIBBON), plugin_object(ART_BRUSH, "Bare", "1 / / / / / / 0 1 / 1 0 1 0 1 0 0 0")].concat();
let r = read_ok(&format!("{}{objects}{}", brush_art("Ribbon art", 4.0), layer("")));
let lib = vectorcraft_brush::library(&r.doc);
@@ -10471,9 +10471,109 @@ fn an_art_brush_bends_only_paths_in_solid_fills() {
assert!(wrong.is_empty(), "{wrong:?}");
}
/// The entries of an Art brush's filter that hold its settings, in the order of its object's numbers 2
/// to 15, with how each is written.
const ART_KEYS: [(&str, &str); 14] = [
("Colorization", "Int"),
("Scaling", "Real"),
("Spacing", "Real"),
("Adjustment", "Int"),
("Counterclockwise", "Int"),
("UpsideDown", "Int"),
("Proportional", "Bool"),
("MinScaling", "Real"),
("MaxScaling", "Real"),
("ScaleYDependsOn", "Int"),
("Segmented", "Bool"),
("StartSegmentLength", "Real"),
("EndSegmentLength", "Real"),
("FoldAdjustment", "Bool"),
];
/// The Art brush `Ribbon` (its art 40 long: [`lopsided_art`]) whose settings, numbers 2 to 15 of its
/// object, are those of [`RIBBON`] with `changes` (index in the 14, value), and the style `Inked` that
/// gives a path the same settings.
fn ribbon_with(changes: &[(usize, &str)]) -> (String, String) {
let mut settings = ["1", "1", "0", "1", "0", "0", "0", "1", "1", "0", "0", "0", "0", "1"];
for (i, v) in changes {
settings[*i] = v;
}
let brush = format!("2 / Ribbon art/ / / / / 0 0/ 5 0 0 0 / 1 {}", settings.join(" "));
let entries: Vec<(&str, &str, &str)> = ART_KEYS.iter().zip(settings).map(|((key, kind), value)| (*key, value, *kind)).collect();
(format!("{}{}", lopsided_art("Ribbon art"), plugin_object(ART_BRUSH, "Ribbon", &brush)), art_style("Ribbon", 1.0, &entries, ""))
}
/// A path with an Art brush whose settings are its own comes in brushed, the brush with those
/// settings: None, another width (the lowest and highest widths aren't drawn while the width is
/// fixed), flipped along or across, and Stretch Between Guides, whose guides come in as fractions of
/// the art as it runs along the path (measured before it is flipped along it, so a flip along swaps
/// them). Lengths without Stretch Between Guides aren't guides. A brush that scales its art
/// proportionately comes into the panel with that option, and its path keeps its drawn look.
#[test]
fn an_art_brush_with_options_comes_in_brushed_with_them() {
use vectorcraft_brush::{ArtBrush, ArtScale, Colorization};
let check_file = |why: &str, (def, style): (String, String), want: &dyn Fn(&ArtBrush) -> bool| {
let r = read_ok(&format!("{def}{style}{}", layer(&brushed_path())));
let a = art(&r.doc);
assert!(matches!(a[0].kind, NodeKind::Path { .. }), "{why}: the path, not its look: {a:#?}");
assert_eq!(a[0].appearance.stroke().and_then(|s| s.brush.as_deref()), Some("Ribbon"), "{why}");
let lib = vectorcraft_brush::library(&r.doc);
let vectorcraft_brush::BrushKind::Art(brush) = brush_named(&lib, "Ribbon") else { panic!("{why}: art") };
assert!(want(brush), "{why}: {brush:?}");
r
};
let check = |why: &str, changes: &[(usize, &str)], want: &dyn Fn(&ArtBrush) -> bool| check_file(why, ribbon_with(changes), want);
let near = |a: f64, b: f64| (a - b).abs() < 1e-9;
// Settings that don't draw may differ between the brush and the path: the lowest and highest
// width while the width is fixed, and lengths without Stretch Between Guides.
let (def, style) = ribbon_with(&[(1, "2"), (7, "2"), (8, "2")]);
let style = style.replace("2 /Real (MinScaling) ,\n", "").replace("2 /Real (MaxScaling) ,\n", "");
check_file("the path leaves out the widths the pen would give", (def, style), &|b| b.width == 200.0);
let (def, _) = ribbon_with(&[]);
let (_, style) = ribbon_with(&[(11, "5"), (12, "10")]);
check_file("lengths on the path alone", (def, style), &|b| b.scale == ArtScale::Stretch);
check("its art's colors", &[(0, "0")], &|b| b.colorization == Colorization::None);
check("twice as wide", &[(1, "2"), (7, "2"), (8, "2")], &|b| b.width == 200.0);
check("half as wide, the pen's widths left", &[(1, "0.5")], &|b| b.width == 50.0 && b.width_mode.is_none());
let along = check("flipped along", &[(4, "1")], &|b| b.flip_along && !b.flip_across);
let across = check("flipped across", &[(5, "1")], &|b| b.flip_across && !b.flip_along);
let (def, style) = ribbon_with(&[(6, "1")]);
let r = read_ok(&format!("{def}{style}{}", layer(&brushed_path())));
assert!(matches!(art(&r.doc)[0].kind, NodeKind::Group { .. }), "proportional: the drawn look");
let lib = vectorcraft_brush::library(&r.doc);
let vectorcraft_brush::BrushKind::Art(brush) = brush_named(&lib, "Ribbon") else { panic!("proportional: art") };
assert_eq!(brush.scale, ArtScale::Proportional);
check(
"between guides",
&[(10, "1"), (11, "5"), (12, "10")],
&|b| matches!(b.scale, ArtScale::BetweenGuides { start, end } if near(start, 5.0 / 40.0) && near(end, 30.0 / 40.0)),
);
check("between guides, flipped along", &[(10, "1"), (11, "5"), (12, "10"), (4, "1")], &|b| {
b.flip_along && matches!(b.scale, ArtScale::BetweenGuides { start, end } if near(start, 10.0 / 40.0) && near(end, 35.0 / 40.0))
});
check("lengths without guides", &[(11, "5"), (12, "10")], &|b| b.scale == ArtScale::Stretch);
// Along a straight line, y down: unflipped, the wide block is above the line and the narrow one
// below it, at the start.
let line = kurbo::BezPath::from_vec(vec![kurbo::PathEl::MoveTo(Point::new(0.0, 0.0)), kurbo::PathEl::LineTo(Point::new(100.0, 0.0))]);
let filled = |r: &Structure, x: f64, y: f64| {
let lib = vectorcraft_brush::library(&r.doc);
let st = art(&r.doc)[0].appearance.stroke().cloned().unwrap();
let ribbon = lib.iter().find(|b| b.name == "Ribbon").unwrap();
let pieces = vectorcraft_brush::stroke_pieces(&r.doc, ribbon, &line, &st);
pieces.iter().any(|n| {
let mut hit = false;
n.walk(&mut |c| hit |= c.path_data().is_some_and(|p| kurbo::Shape::winding(&p.to_bezpath(), Point::new(x, y)) != 0));
hit
})
};
// At weight 1 the art is 8 across: each block is 4 across, on its side of the line.
assert!(filled(&across, 50.0, 2.0) && filled(&across, 10.0, -2.0) && !filled(&across, 50.0, -2.0), "across: the wide block below");
assert!(filled(&along, 50.0, -2.0) && filled(&along, 90.0, 2.0) && !filled(&along, 10.0, 2.0), "along: the narrow block at the end");
}
/// An Art brushed path keeps its drawn look when the brush wouldn't draw it the way the file does:
/// its settings aren't those of art that stretches along the path at the brush's own width and
/// color, the brush's own aren't, its art isn't read, or the brush isn't one the panel keeps.
/// its settings differ from the brush's, the brush's are ones VectorCraft doesn't draw as the file
/// does, its art isn't read, or the brush isn't one the panel keeps.
#[test]
fn an_art_brushed_path_the_brush_cant_draw_keeps_its_drawn_look() {
let good = format!("{}{}", lopsided_art("Ribbon art"), plugin_object(ART_BRUSH, "Ribbon", RIBBON));
@@ -10516,21 +10616,7 @@ fn an_art_brushed_path_the_brush_cant_draw_keeps_its_drawn_look() {
),
plain_style.clone(),
),
(
"a width other than the brush's 100%",
format!("{}{}", lopsided_art("Ribbon art"), plugin_object(ART_BRUSH, "Ribbon", wide)),
art_style(
"Ribbon",
1.0,
&STRETCHED.map(|e| if matches!(e.0, "Scaling" | "MinScaling" | "MaxScaling") { (e.0, "2", e.2) } else { e }),
"",
),
),
(
"a brush that keeps its art's colors",
format!("{}{}", lopsided_art("Ribbon art"), plugin_object(ART_BRUSH, "Ribbon", own_colors)),
art_style("Ribbon", 1.0, &STRETCHED.map(|e| if e.0 == "Colorization" { (e.0, "0", e.2) } else { e }), ""),
),
("a brush that keeps its art's colors, the path in Tints", format!("{}{}", lopsided_art("Ribbon art"), plugin_object(ART_BRUSH, "Ribbon", own_colors)), plain_style.clone()),
("an entry that isn't read", good.clone(), art_style("Ribbon", 1.0, &STRETCHED, "1 /Int (SomeOtherSetting) ,\n")),
(
"an effect on the path",
@@ -10543,17 +10629,16 @@ fn an_art_brushed_path_the_brush_cant_draw_keeps_its_drawn_look() {
("proportional", good.clone(), style_with("Proportional", "1")),
("between guides", good.clone(), style_with("Segmented", "1")),
("its own width", good.clone(), style_with("Scaling", "2")),
("a width that varies", good.clone(), style_with("MaxScaling", "2")),
("a width on the pen", good.clone(), style_with("ScaleYDependsOn", "2")),
("spacing", good.clone(), style_with("Spacing", "1")),
("flipped", good.clone(), style_with("UpsideDown", "1")),
("a spacing close to the brush's 0", good.clone(), style_with("Spacing", "0.0005")),
("flipped",good.clone(), style_with("UpsideDown", "1")),
("flipped the other way", good.clone(), style_with("Counterclockwise", "1")),
("another adjustment", good.clone(), style_with("Adjustment", "2")),
("no fold adjustment", good.clone(), style_with("FoldAdjustment", "0")),
("its own colorization", good.clone(), style_with("Colorization", "0")),
("tints and shades", good.clone(), style_with("Colorization", "2")),
("hue shift", good.clone(), style_with("Colorization", "3")),
("start of a guide", good.clone(), art_style("Ribbon", 1.0, &STRETCHED, "5 /Real (StartSegmentLength) ,\n")),
("variable width", good.clone(), art_style("Ribbon", 1.0, &STRETCHED, "2 /Int (BSWidthSource) ,\n")),
(
"width arrays",
@@ -10570,6 +10655,35 @@ fn an_art_brushed_path_the_brush_cant_draw_keeps_its_drawn_look() {
("art not read", plugin_object(ART_BRUSH, "Ribbon", RIBBON), plain_style.clone()),
("no such brush", good.clone(), art_style("Other", 1.0, &STRETCHED, "")),
];
// The brush's own settings, which the path shares, but VectorCraft doesn't draw as the file does.
let own = [
("both brush and path: tints and shades", &[(0, "2")][..]),
("both: hue shift", &[(0, "3")]),
("both: spacing", &[(2, "1")]),
("both: another adjustment", &[(3, "2")]),
("both: no fold adjustment", &[(13, "0")]),
("both: a width on the pen", &[(9, "1"), (7, "0.5"), (8, "2")]),
("both: a width past the range", &[(1, "20"), (7, "20"), (8, "20")]),
("both: a flip of another value", &[(4, "2")]),
("both: proportional", &[(6, "1")]),
("both: proportional and between guides", &[(6, "1"), (10, "1"), (11, "5"), (12, "10")]),
("both: guides that leave no art between them", &[(10, "1"), (11, "30"), (12, "10")]),
("both: a guide past the start", &[(10, "1"), (11, "-5"), (12, "10")]),
];
let mut cases: Vec<(&str, String, String)> = cases
.into_iter()
.chain(own.iter().map(|(why, changes)| {
let (def, style) = ribbon_with(changes);
(*why, def, style)
}))
.collect();
// The brush's guides leave art between them, and the path's, close to them, leave none.
let (def, style) = ribbon_with(&[(10, "1"), (11, "20"), (12, "19.99")]);
cases.push(("path guides close to the brush's that leave no art between them", def, style.replace("19.99 /", "20 /")));
// On art 0.01 long, a guide at 0.0035 is 35% of the way along it, and the brush's at 0.003 is 30%.
let (def, style) = ribbon_with(&[(10, "1"), (11, "0.003"), (12, "0.002")]);
let short = format!("%AI8_BeginBrushPattern\n(Ribbon art)\n0 A\n0 g\n{}f\n%AI8_EndBrushPattern\n", rect_path(0.0, 0.0, 0.01, 4.0));
cases.push(("path guides elsewhere on short art", def.replace(&lopsided_art("Ribbon art"), &short), style.replace("0.003 /", "0.0035 /")));
let mut wrong = vec![];
for (why, def, style) in cases {
let r = read_ok(&format!("{def}{style}{}", layer(&brushed_path())));
File diff suppressed because one or more lines are too long
+6 -4
View File
@@ -213,10 +213,12 @@ macOS cursor delivery (#2292) is narrowed to a native host adapter using the exi
weight, where the object's brush settings are the brush's own and its angle is on the page (#372, from a
user's file of 6D Art Pen strokes: all 455 of its brushed styles), and a Calligraphic brush's Tilt, Bearing and
Rotation (codes 4, 5 and 6) come in as those channels instead of Random. Others keep their drawn look.
Paths with an Art brush open the same way when the brush stretches art of solid-filled paths along the path,
unflipped, at 100% in Tints, and the path has none of its own settings, variable width, effects or a swatch
stroke. Other Art brushes (None or another colorization, another width, Proportional, between guides, flipped,
art with strokes, gradients or patterns) and type with an Art brush keep the drawn look.
Paths with an Art brush open the same way when the brush bends art of solid-filled paths along the path, and
the path has none of its own settings, variable width, effects or a swatch stroke in Tints. The brush may be
in None or Tints, at a fixed width, stretched to the path or between guides, and flipped along or across.
Other Art brushes (Tints and Shades or Hue Shift, a width that follows the pen, Scale Proportionately, guides
that leave no art between them, a `Spacing`, `Adjustment` or `FoldAdjustment` other than 0, 1 and 1, art with
strokes, gradients or patterns) and type with an Art brush keep the drawn look.
- **Done (2026-11-12):** Explicitly requested editing data retains a single artboard larger than a capped 14,400 pt PDF page (#6865); the default import and other size mismatches remain unchanged. Synthetic importer regression; scores unchanged.
- **Missing:** faithful layer and group structure from the editing data in real files (#951: 109 layers instead
+1 -1
View File
@@ -1692,7 +1692,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
In a `.ai` opened from its editing data, an object in a pattern's art or a brush's art with an art style VectorCraft reads opens as itself with its effects live, as an object on a layer does, and an object in a pattern's art keeps the id of a graphic style in the file's list (`crates/eps/src/import/ai/tests.rs`).
A graph in a `.ai` whose records have the shape and values the published version 7 specification gives ([the specification](https://13thmonkey.org/documentation/fonts/AI7FileFormat.pdf), printed pp. 109–112 and 146–158) opens as a live grouped column, stacked column, line or area graph with its saved art, table, axes and settings, and Graph Data edits it ([the help page](https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/visualize-data/add-graph-data.html)); other graphs keep their art as plain objects with a note, and saving a graph to `.ai` warns that its data and settings aren't written (`crates/eps/src/import/ai/tests.rs`, `crates/engine/src/cmd/graph/tests.rs`).
Blends and envelopes in the definitions of a `.ai`'s symbols, patterns and brushes' art count against the same per-file limits on blend layout and envelope checks as the layers' art, so a file with many definitions does not get those limits again for each one (`crates/eps/src/import/ai/read/directives.rs`).
A `.ai` or EPS whose editing data has brushes opens with the file's Calligraphic, Scatter, Art and Bristle brushes in the Brushes panel in place of the default ones, in the file's order: the Calligraphic size, roundness and angle with their variation, the Scatter size, spacing, scatter and rotation, the Bristle settings, and the Scatter and Art brushes' art (Art brushes in the older and newer forms of their objects). A brush library `.ai` (brushes and no layers) opens from its PDF part with its brushes, so Other Library lists them. An Art brush in Tints keeps that colorization. Pattern brushes and brushes that can't be read are left out with a note. Paths with a Calligraphic brush, and paths with an Art brush in the newer form at 100% in Tints that stretches art of solid-filled paths unflipped, open as the path with the brush on its stroke. Paths with an Art brush in the older form, and other brushed paths, still come in as their drawn look (part of #372 and #637; `crates/eps/src/import/ai/tests.rs`, `crates/eps/src/import/tests_native.rs`).
A `.ai` or EPS whose editing data has brushes opens with the file's Calligraphic, Scatter, Art and Bristle brushes in the Brushes panel in place of the default ones, in the file's order: the Calligraphic size, roundness and angle with their variation, the Scatter size, spacing, scatter and rotation, the Bristle settings, and the Scatter and Art brushes' art (Art brushes in the older and newer forms of their objects). A brush library `.ai` (brushes and no layers) opens from its PDF part with its brushes, so Other Library lists them. An Art brush in None or Tints keeps that colorization, and one in the newer form also keeps its fixed width, Flip Along and Flip Across, and Scale Proportionately, Stretch to Fit Stroke Length or Stretch Between Guides with its guides ([Art brush options](https://web.archive.org/web/20220911143322/https://helpx.adobe.com/pdf/cs6/illustrator_reference.pdf), printed page 193). Pattern brushes and brushes that can't be read are left out with a note. Paths with a Calligraphic brush, and paths with an Art brush in the newer form that bends art of solid-filled paths with those options, open as the path with the brush on its stroke, except paths whose brush scales its art proportionately. Paths with an Art brush in the older form, and other brushed paths, still come in as their drawn look (part of #372 and #637; `crates/eps/src/import/ai/tests.rs`, `crates/eps/src/import/tests_native.rs`).
In a `.ai` opened from its editing data, a path or type object with Offset Path, Outline Object or Outline Stroke on the whole object opens as itself with those effects live, in their order and shown or hidden; on a fill or stroke of type they stay on that fill or stroke. Offset Path keeps its Offset, Joins and Miter Limit, and Outline Stroke takes its width from the stroke. An Offset Path with a setting missing, out of range or not read keeps the drawn art, as does a path with a Path effect on one fill or stroke (`crates/eps/src/import/ai/style/tests.rs`, `crates/eps/src/import/ai/tests.rs`, `crates/eps/src/import/tests_native.rs`).
In a `.ai` or EPS opened from its editing data, a Scatter brush whose last 15 numbers start with 1 comes into the Brushes panel in Tints, and each brush art section is read without its frame, an unnamed, unpainted rectangle with no settings of its own at the art's bounds (within 1/1000 pt). A wider frame stays in the art, and an art group with a state or look of its own (hidden, locked, isolated, knockout, an opacity, a blend mode, a name, a mask, a note or an appearance) stays a group (`crates/eps/src/import/ai/tests.rs`).
In a `.ai` opened from its editing data, type with Warp on the object or on one of its fills or strokes opens as type with the effect live when the live warp draws as the art saved with it. When it draws differently, or when the file's limit of checks has no check left for it, it opens as that art, and one note says how many did. Type stays live without a comparison when the saved art paints nothing or draws type of its own, in frames of threaded type, when a text variable binds it, and when its text object doesn't come in as one text object (`crates/eps/src/import/ai/envelope.rs`, `crates/eps/src/import/tests_native.rs`).