mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-26 10:47:11 -06:00
Brushes: Art Brush Options' Width can follow the pen pressure
Art Brush Options' Width had no pen tablet option, so an art brush drew at one width whatever the pressure. The Width row now has a Fixed or Pressure menu and a second width. With Pressure the art is Width wide at the lightest pen pressure and the second width at the heaviest, following the pressure the Paintbrush recorded along the stroke, and half way between the two on a stroke drawn without pressure. brush.options takes width_mode and width_max; a brush without them is saved as before.
This commit is contained in:
+24
-1
@@ -285,6 +285,13 @@ pub struct ArtBrush {
|
||||
pub scale: ArtScale,
|
||||
/// Width in percent.
|
||||
pub width: f64,
|
||||
/// How the width varies along a stroke: `None` (Fixed) always `width`; Pressure goes from
|
||||
/// `width` at the lightest pen pressure to [`Self::width_max`] at the heaviest.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub width_mode: Option<Variation>,
|
||||
/// The width at the heaviest pen pressure (%), for Pressure (`width` without one).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub width_max: Option<f64>,
|
||||
pub flip_along: bool,
|
||||
pub flip_across: bool,
|
||||
/// When true, art is placed on each run between sharp corners so folds do not overlap.
|
||||
@@ -295,6 +302,15 @@ pub struct ArtBrush {
|
||||
impl ArtBrush {
|
||||
/// The range of the width (%).
|
||||
pub const WIDTH_RANGE: (f64, f64) = (1.0, 1000.0);
|
||||
|
||||
/// The widths (%) at the lightest and the heaviest pen pressure: `width` for both unless the
|
||||
/// width follows the pressure.
|
||||
pub fn width_range(&self) -> (f64, f64) {
|
||||
match self.width_mode {
|
||||
Some(Variation::Pressure) => (self.width, self.width_max.unwrap_or(self.width)),
|
||||
_ => (self.width, self.width),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for ArtBrush {
|
||||
@@ -304,6 +320,8 @@ impl Default for ArtBrush {
|
||||
direction: Direction::LeftToRight,
|
||||
scale: ArtScale::Stretch,
|
||||
width: 100.0,
|
||||
width_mode: None,
|
||||
width_max: None,
|
||||
flip_along: false,
|
||||
flip_across: false,
|
||||
overlap: false,
|
||||
@@ -508,6 +526,11 @@ impl Brush {
|
||||
}
|
||||
BrushKind::Art(a) => {
|
||||
a.width = within(a.width, ArtBrush::WIDTH_RANGE, 100.0);
|
||||
// The width follows the pen's pressure or stays fixed.
|
||||
if a.width_mode != Some(Variation::Pressure) {
|
||||
a.width_mode = None;
|
||||
}
|
||||
a.width_max = a.width_max.map(|w| within(w, ArtBrush::WIDTH_RANGE, a.width));
|
||||
if let ArtScale::BetweenGuides { start, end } = &mut a.scale {
|
||||
*start = within(*start, (0.0, 1.0), 0.0);
|
||||
*end = within(*end, (0.0, 1.0), 1.0);
|
||||
@@ -592,7 +615,7 @@ pub fn stroke_pieces(doc: &Document, brush: &Brush, bp: &BezPath, stroke: &Strok
|
||||
let mut out = match &brush.kind {
|
||||
BrushKind::Calligraphic(c) => calli::calligraphic(c, bp, w, stroke, &brush.name),
|
||||
BrushKind::Bristle(b) => calli::bristle(b, bp, w, &stroke.paint, &brush.name),
|
||||
BrushKind::Art(a) => warp::art(a, bp, w),
|
||||
BrushKind::Art(a) => warp::art(a, bp, w, stroke.pressure.as_ref()),
|
||||
BrushKind::Scatter(s) => warp::scatter(s, bp, w, stroke.pressure.as_ref(), &brush.name),
|
||||
BrushKind::Pattern(p) => warp::pattern(p, bp, w),
|
||||
};
|
||||
|
||||
@@ -585,6 +585,78 @@ fn scatter_values_follow_their_modes() {
|
||||
assert!(serde_json::to_value(&unsaved).unwrap().get("modes").is_none(), "not saved when unset");
|
||||
}
|
||||
|
||||
/// Art Brush Options: with Width on Pressure the art goes from Width at the lightest pen pressure
|
||||
/// to the second width at the heaviest, along the stroke where the pen pressed; Fixed keeps Width.
|
||||
#[test]
|
||||
fn art_brush_width_follows_the_pressure() {
|
||||
let brush = |mode: Option<Variation>, max: Option<f64>| {
|
||||
let mut b = art_brush(rect_art(10.0, 4.0), ArtScale::Stretch);
|
||||
if let BrushKind::Art(a) = &mut b.kind {
|
||||
(a.width, a.width_mode, a.width_max) = (50.0, mode, max);
|
||||
}
|
||||
b
|
||||
};
|
||||
let bp = line(0.0, 0.0, 100.0, 0.0);
|
||||
let draw = |b: &Brush, st: &StrokeLayer| stroke_pieces(&Document::new(1.0, 1.0), b, &bp, st);
|
||||
let ramp = pressed(vec![(0.0, 0.0), (1.0, 1.0)]);
|
||||
// 4 pt art at 50 %: 1 pt either side at the lightest; at 300 %: 6 pt at the heaviest.
|
||||
let out = draw(&brush(Some(Variation::Pressure), Some(300.0)), &ramp);
|
||||
assert!(near(half_width(&out, 0.0, 1.0), 1.0, 0.1), "{}", half_width(&out, 0.0, 1.0));
|
||||
assert!(near(half_width(&out, 99.0, 100.0), 6.0, 0.1), "{}", half_width(&out, 99.0, 100.0));
|
||||
assert!(near(half_width(&out, 49.0, 51.0), 3.5, 0.15), "{}", half_width(&out, 49.0, 51.0));
|
||||
// The stroke weight scales it.
|
||||
let out = draw(&brush(Some(Variation::Pressure), Some(300.0)), &StrokeLayer { width: 2.0, ..ramp.clone() });
|
||||
assert!(near(half_width(&out, 99.0, 100.0), 12.0, 0.2), "{}", half_width(&out, 99.0, 100.0));
|
||||
// Without recorded pressure, half way between the two widths.
|
||||
assert!(near(bounds(&draw(&brush(Some(Variation::Pressure), Some(300.0)), &stroke(1.0))).height(), 7.0, 1e-6));
|
||||
// Fixed (or a definition without a mode) keeps Width, whatever the pressure and the second width.
|
||||
for (mode, max) in [(None, Some(300.0)), (None, None), (Some(Variation::Pressure), None)] {
|
||||
let out = draw(&brush(mode, max), &ramp);
|
||||
assert!(near(bounds(&out).height(), 2.0, 1e-6), "{mode:?} {max:?}: {:?}", bounds(&out));
|
||||
}
|
||||
// A stroke in the art takes the width at the middle of its path: 50 % and 300 % for an even
|
||||
// light and heavy pressure, 175 % without recorded pressure.
|
||||
let mut stroked = brush(Some(Variation::Pressure), Some(300.0));
|
||||
if let BrushKind::Art(a) = &mut stroked.kind {
|
||||
let mut bp = BezPath::new();
|
||||
bp.move_to((0.0, 0.0));
|
||||
bp.line_to((10.0, 0.0));
|
||||
a.art = Node::path(NodeId(0), vectorcraft_geom::PathData::from_bezpath(&bp), Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 2.0));
|
||||
}
|
||||
for (st, want) in [(pressed(vec![(0.0, 0.0), (1.0, 0.0)]), 1.0), (pressed(vec![(0.0, 1.0), (1.0, 1.0)]), 6.0), (stroke(1.0), 3.5)] {
|
||||
let out = draw(&stroked, &st);
|
||||
let w = out.first().and_then(|n| n.appearance.stroke()).map(|s| s.width);
|
||||
assert!(w.is_some_and(|w| near(w, want, 1e-6)), "{want}: {w:?}");
|
||||
}
|
||||
// Stretch Between Guides: an end outside the guides keeps the proportions of the width at its
|
||||
// end of the stroke. A 2 pt marker at the start of 10 × 4 pt art (guides at 20 % and 80 %),
|
||||
// 100 % to 300 % wide: 2 pt long at the lightest pressure, 6 pt at the heaviest.
|
||||
let marker =
|
||||
Node::path(NodeId(0), shapes::rectangle(Rect::new(0.0, -1.0, 2.0, 1.0)), Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0));
|
||||
let mut spacer = rect_art(10.0, 4.0);
|
||||
spacer.appearance = Appearance::default().into();
|
||||
let art = Node::group(NodeId(0), vec![std::sync::Arc::new(spacer), std::sync::Arc::new(marker)]);
|
||||
let mut guided = art_brush(art, ArtScale::BetweenGuides { start: 0.2, end: 0.8 });
|
||||
if let BrushKind::Art(a) = &mut guided.kind {
|
||||
(a.width_mode, a.width_max) = (Some(Variation::Pressure), Some(300.0));
|
||||
}
|
||||
for (p, long) in [(0.0, 2.0), (1.0, 6.0)] {
|
||||
let out = draw(&guided, &pressed(vec![(0.0, p), (1.0, p)]));
|
||||
let m = out.first().and_then(|g| g.children()).and_then(|c| c.get(1)).and_then(|m| m.geometric_bounds());
|
||||
assert!(m.is_some_and(|m| near(m.x0, 0.0, 1e-6) && near(m.x1, long, 1e-6)), "{p}: {m:?}");
|
||||
}
|
||||
// A mode the width does not take is read as Fixed, and the second width is kept within range.
|
||||
let lib = parse_library(&serde_json::json!([
|
||||
{"name": "a", "type": "art", "art": rect_art(10.0, 4.0), "width_mode": "random", "width_max": 1e308},
|
||||
{"name": "b", "type": "art", "art": rect_art(10.0, 4.0), "width_mode": "pressure", "width_max": 0.0},
|
||||
]));
|
||||
let [BrushKind::Art(a), BrushKind::Art(b)] = [&lib[0].kind, &lib[1].kind] else { panic!("{lib:?}") };
|
||||
assert_eq!((a.width_mode, a.width_max, b.width_mode, b.width_max), (None, Some(1000.0), Some(Variation::Pressure), Some(1.0)));
|
||||
// A brush without the new fields is written back without them.
|
||||
let plain = serde_json::to_value(art_brush(rect_art(10.0, 4.0), ArtScale::Stretch)).unwrap();
|
||||
assert!(plain.get("width_mode").is_none() && plain.get("width_max").is_none(), "{plain}");
|
||||
}
|
||||
|
||||
/// Definitions from files and commands are kept within their ranges.
|
||||
#[test]
|
||||
fn sanitize_keeps_values_within_their_ranges() {
|
||||
|
||||
+64
-11
@@ -169,6 +169,33 @@ fn bend(t: &Track, art: &Node, b: Rect, along: AlongMap, cross: f64) -> Option<N
|
||||
warp_node(art, &f, flat_tol, MAX_SEG / along.max_scale(), cross.abs())
|
||||
}
|
||||
|
||||
/// [`bend`], with the cross scale at arc length `s` multiplied by `widen(s)` (0..=1). The strokes
|
||||
/// of each object in the art take the factor at the middle of the object.
|
||||
fn bend_widened(t: &Track, art: &Node, b: Rect, along: AlongMap, cross: f64, widen: &dyn Fn(f64) -> f64) -> Option<Node> {
|
||||
let cy = b.center().y;
|
||||
let x0 = b.x0;
|
||||
let f = move |p: Point| {
|
||||
let s = along.at(p.x - x0);
|
||||
t.map(s, (p.y - cy) * cross * widen(s))
|
||||
};
|
||||
let factor = |n: &Node| n.geometric_bounds().map_or(1.0, |r| widen(along.at(r.center().x - x0)));
|
||||
let art = widen_strokes(art, &factor);
|
||||
let scale = along.max_scale().max(cross.abs()).max(1e-6);
|
||||
let flat_tol = tolerance(b.height().max(b.width()) * scale) / scale;
|
||||
warp_node(&art, &f, flat_tol, MAX_SEG / along.max_scale(), cross.abs())
|
||||
}
|
||||
|
||||
/// `n` with the strokes of it and of everything inside it scaled by `factor` of the object they
|
||||
/// belong to.
|
||||
fn widen_strokes(n: &Node, factor: &dyn Fn(&Node) -> f64) -> Node {
|
||||
let mut out = n.clone();
|
||||
out.appearance.scale_strokes(factor(n));
|
||||
if let NodeKind::Group { children, .. } | NodeKind::Layer { children, .. } | NodeKind::Compound { children, .. } = &mut out.kind {
|
||||
*children = children.iter().map(|c| Arc::new(widen_strokes(c, factor))).collect();
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Arc-length spans to place art on when [`ArtBrush::overlap`] is on: one run between each sharp
|
||||
/// corner (the whole path when it has none).
|
||||
fn art_runs(t: &Track) -> Vec<(f64, f64)> {
|
||||
@@ -189,7 +216,17 @@ fn art_runs(t: &Track) -> Vec<(f64, f64)> {
|
||||
bounds.windows(2).map(|w| (w[0], w[1])).collect()
|
||||
}
|
||||
|
||||
fn art_on_run(a: &ArtBrush, t: &Track, s0: f64, s1: f64, art: &Node, b: Rect, cross: f64) -> Option<Node> {
|
||||
/// The art on [s0, s1] of `t`, `cross` across where `widen` (0..=1, by arc length) is 1, and
|
||||
/// `width` the width (%) there.
|
||||
fn art_on_run(
|
||||
t: &Track,
|
||||
(s0, s1): (f64, f64),
|
||||
a: &ArtBrush,
|
||||
(art, b): (&Node, Rect),
|
||||
cross: f64,
|
||||
width: f64,
|
||||
widen: &dyn Fn(f64) -> f64,
|
||||
) -> Option<Node> {
|
||||
let l = s1 - s0;
|
||||
if l <= 1e-9 {
|
||||
return None;
|
||||
@@ -199,33 +236,49 @@ fn art_on_run(a: &ArtBrush, t: &Track, s0: f64, s1: f64, art: &Node, b: Rect, cr
|
||||
ArtScale::Stretch => (AlongMap::linear(w, s0, s1), cross),
|
||||
ArtScale::Proportional => {
|
||||
let k = l / w;
|
||||
(AlongMap::linear(w, s0, s1), k * a.width / 100.0)
|
||||
(AlongMap::linear(w, s0, s1), k * width / 100.0)
|
||||
}
|
||||
ArtScale::BetweenGuides { start, end } => {
|
||||
let (g0, g1) = (start.clamp(0.0, 1.0) * w, end.clamp(0.0, 1.0) * w);
|
||||
let (g0, g1) = (g0.min(g1), g0.max(g1));
|
||||
let fixed = (g0 + (w - g1)) * cross;
|
||||
// The ends keep the proportions of the width at their end of the run.
|
||||
let (c0, c1) = (cross * widen(s0), cross * widen(s1));
|
||||
let fixed = g0 * c0 + (w - g1) * c1;
|
||||
if fixed >= l || g1 - g0 < 1e-9 {
|
||||
(AlongMap::linear(w, s0, s1), cross)
|
||||
} else {
|
||||
(AlongMap { xs: [0.0, g0, g1, w], ss: [s0, s0 + g0 * cross, s1 - (w - g1) * cross, s1] }, cross)
|
||||
(AlongMap { xs: [0.0, g0, g1, w], ss: [s0, s0 + g0 * c0, s1 - (w - g1) * c1, s1] }, cross)
|
||||
}
|
||||
}
|
||||
};
|
||||
bend(t, art, b, along, cross)
|
||||
bend_widened(t, art, b, along, cross, widen)
|
||||
}
|
||||
|
||||
pub(crate) fn art(a: &ArtBrush, bp: &BezPath, weight: f64) -> Vec<Node> {
|
||||
/// An art brush's art along `bp`. With a width that follows the pen's pressure, the art is as
|
||||
/// wide as the pressure recorded at each point of the stroke gives (half way without a recording).
|
||||
pub(crate) fn art(a: &ArtBrush, bp: &BezPath, weight: f64, pressure: Option<&PressureProfile>) -> Vec<Node> {
|
||||
let Some((art, b)) = oriented(&a.art, a.direction, a.flip_along, a.flip_across) else { return vec![] };
|
||||
let cross = weight * a.width / 100.0;
|
||||
let (light, heavy) = a.width_range();
|
||||
let width = light.max(heavy);
|
||||
let cross = weight * width / 100.0;
|
||||
let mut out = vec![];
|
||||
for t in tracks(bp, 0.05) {
|
||||
if t.len() <= 1e-9 {
|
||||
let l = t.len();
|
||||
if l <= 1e-9 {
|
||||
continue;
|
||||
}
|
||||
let runs = if a.overlap { art_runs(&t) } else { vec![(0.0, t.len())] };
|
||||
for (s0, s1) in runs {
|
||||
if let Some(n) = art_on_run(a, &t, s0, s1, &art, b, cross) {
|
||||
let widen = |s: f64| {
|
||||
if light == heavy || width <= 0.0 {
|
||||
return 1.0;
|
||||
}
|
||||
// A run past the start of a closed track wraps round to it.
|
||||
let at = if s > l { s - l } else { s };
|
||||
let p = pressure.map_or(PressureProfile::MID, |p| p.at(at / l)).clamp(0.0, 1.0);
|
||||
(light + (heavy - light) * p) / width
|
||||
};
|
||||
let runs = if a.overlap { art_runs(&t) } else { vec![(0.0, l)] };
|
||||
for run in runs {
|
||||
if let Some(n) = art_on_run(&t, run, a, (&art, b), cross, width, &widen) {
|
||||
out.push(n);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -117,7 +117,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%, 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. 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
|
||||
),
|
||||
|
||||
@@ -9,7 +9,8 @@
|
||||
//! open).
|
||||
//! Scatter: per value (`size`, `spacing`, `scatter` %, `rotation` °) `…Min`, `…Max` and `…Mode`
|
||||
//! (`fixed` | `random` | `pressure`), `rotationRelativeTo` (`page` | `path`).
|
||||
//! Art: `width` (%), `scaleMode` (`proportional` | `stretch` | `betweenGuides`), `guideStart` and
|
||||
//! Art: `width` (%), `widthMode` (`fixed` | `pressure`), `widthMax` (the width at the heaviest pen
|
||||
//! pressure, %), `scaleMode` (`proportional` | `stretch` | `betweenGuides`), `guideStart` and
|
||||
//! `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`,
|
||||
@@ -104,6 +105,8 @@ pub(super) fn fields(b: &Brush, def: Value, f: &mut Map<String, Value>) {
|
||||
ArtScale::BetweenGuides { start, end } => ("betweenGuides", start, end),
|
||||
};
|
||||
f.insert("width".into(), json!(a.width));
|
||||
f.insert("widthMode".into(), json!(a.width_mode.unwrap_or_default().id()));
|
||||
f.insert("widthMax".into(), json!(a.width_range().1));
|
||||
f.insert("scaleMode".into(), json!(mode));
|
||||
f.insert("guideStart".into(), json!(start * 100.0));
|
||||
f.insert("guideEnd".into(), json!(end * 100.0));
|
||||
@@ -157,6 +160,14 @@ fn scatter_mode(d: &Dialog, key: &str) -> Variation {
|
||||
Variation::parse(&d.str(&mode_key(key))).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// How an art brush's width varies: Fixed or Pressure.
|
||||
fn art_width_mode(d: &Dialog) -> Variation {
|
||||
match Variation::parse(&d.str("widthMode")) {
|
||||
Some(Variation::Pressure) => Variation::Pressure,
|
||||
_ => Variation::Fixed,
|
||||
}
|
||||
}
|
||||
|
||||
/// `brush.options` params from the fields.
|
||||
pub(super) fn params(d: &Dialog) -> Value {
|
||||
let mut p = match d.str("__type").as_str() {
|
||||
@@ -177,8 +188,11 @@ pub(super) fn params(d: &Dialog) -> Value {
|
||||
},
|
||||
_ => ArtScale::Stretch,
|
||||
};
|
||||
let pressure = art_width_mode(d) == Variation::Pressure;
|
||||
json!({
|
||||
"width": clamped(d, "width", ArtBrush::WIDTH_RANGE),
|
||||
"width_mode": if pressure { json!(Variation::Pressure.id()) } else { Value::Null },
|
||||
"width_max": if pressure { json!(clamped(d, "widthMax", ArtBrush::WIDTH_RANGE)) } else { Value::Null },
|
||||
"scale": serde_json::to_value(scale).unwrap_or_default(),
|
||||
"direction": id_of(&parse_field::<Direction>(d, "direction")),
|
||||
"flip_along": d.bool("flipAlong"),
|
||||
@@ -285,7 +299,18 @@ fn scatter_body(ui: &mut egui::Ui, d: &mut Dialog) {
|
||||
}
|
||||
|
||||
fn art_body(ui: &mut egui::Ui, d: &mut Dialog) {
|
||||
slider_row(ui, d, "width", tl!("Width:"), ArtBrush::WIDTH_RANGE);
|
||||
// Width, then the width at the heaviest pen pressure (only with Pressure) and how it varies.
|
||||
let rail = Tokens::get(ui.ctx()).input_border;
|
||||
let range = ArtBrush::WIDTH_RANGE;
|
||||
widgets::label_row(ui, tl!("Width:"), LABEL_W, |ui| {
|
||||
super::form::slider_field_named(ui, d, "width", tl!("Width:"), range.0..=range.1, "%", &|_| rail);
|
||||
let mode = art_width_mode(d);
|
||||
number(ui, d, "widthMax", range, "%", mode == Variation::Pressure);
|
||||
let modes = [Variation::Fixed, Variation::Pressure];
|
||||
if let Some(m) = widgets::dropdown(ui, "brush-art-width-mode", mode.label(), &modes.map(Variation::label), 104.0).and_then(|i| modes.get(i)) {
|
||||
d.fields.insert("widthMode".into(), json!(m.id()));
|
||||
}
|
||||
});
|
||||
ui.add_space(4.0);
|
||||
widgets::section_header(ui, tl!("Brush Scale Options"));
|
||||
let scales = [
|
||||
|
||||
@@ -586,6 +586,34 @@ mod tests {
|
||||
assert_eq!(app.run("brush.get", json!({"name": "Arrow"})).unwrap()["scale"], json!({"mode": "stretch"}));
|
||||
}
|
||||
|
||||
/// Art Brush Options' Width menu: Pressure takes a second width for the heaviest pen pressure,
|
||||
/// and a stroke drawn without pressure is half way between the two; Fixed drops the second width.
|
||||
#[test]
|
||||
fn art_options_width_follows_the_pen_pressure() {
|
||||
let mut app = app();
|
||||
let (id, before) = brushed(&mut app, "Tapered Stroke");
|
||||
app.run("ui.brushOptions", json!({"name": "Tapered Stroke"})).unwrap();
|
||||
let d = app.ui.dialog.as_ref().unwrap();
|
||||
assert_eq!((d.str("widthMode"), d.f64("widthMax", 0.0)), ("fixed".into(), 100.0));
|
||||
assert!(shown(&mut app).contains("Fixed"));
|
||||
set(&mut app, json!({"widthMode": "pressure", "widthMax": 300}));
|
||||
assert!(shown(&mut app).contains("Pressure"));
|
||||
super::super::confirm(&mut app).unwrap();
|
||||
let def = app.run("brush.get", json!({"name": "Tapered Stroke"})).unwrap();
|
||||
assert_eq!((&def["width"], &def["width_mode"], &def["width_max"]), (&json!(100.0), &json!("pressure"), &json!(300.0)));
|
||||
let after = drawn_height(&app, id);
|
||||
assert!((after - 2.0 * before).abs() < 0.01 * before, "200 % half way: {before} then {after}");
|
||||
// Opened again, it shows what was set; Fixed drops the second width.
|
||||
app.run("ui.brushOptions", json!({"name": "Tapered Stroke"})).unwrap();
|
||||
let d = app.ui.dialog.as_ref().unwrap();
|
||||
assert_eq!((d.str("widthMode"), d.f64("widthMax", 0.0)), ("pressure".into(), 300.0));
|
||||
set(&mut app, json!({"widthMode": "fixed"}));
|
||||
super::super::confirm(&mut app).unwrap();
|
||||
let def = app.run("brush.get", json!({"name": "Tapered Stroke"})).unwrap();
|
||||
assert!(def.get("width_mode").is_none() && def.get("width_max").is_none(), "{def}");
|
||||
assert!((drawn_height(&app, id) - before).abs() < 1e-6);
|
||||
}
|
||||
|
||||
/// A context with the app's fonts and theme, for [`frame`].
|
||||
fn ctx() -> egui::Context {
|
||||
let ctx = egui::Context::default();
|
||||
|
||||
@@ -841,6 +841,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
|
||||
- **Brushes (part of #852):** double-clicking a brush in the Brushes panel (or the panel menu's Brush Options…) opens Calligraphic Brush Options (Name; Angle, Roundness and Size, each Fixed, Random or Pressure with its Variation; live previews of the nib and of a light–heavy–light stroke) or Bristle Brush Options (Shape and the bristle sliders); OK edits the brush and every stroke painted with it. The Paintbrush records the pen pressure along each stroke (kept with the path, saved, and joined when a stroke continues a path); a Calligraphic value on Pressure goes from value − variation at the lightest pressure to value + variation at the heaviest, on the canvas and in SVG, PDF and the other exports. Brushes saved before keep their look. Scatter Brush Options (Size, Spacing, Scatter and Rotation, each Fixed, Random between two values or Pressure from the first at the lightest pressure to the second at the heaviest; Rotation relative to Page or Path), Art Brush Options (Width, Scale Proportionately, Stretch to Fit Stroke Length or Stretch Between Guides with the guides' start and end, the four Directions, Flip Along and Across) and Pattern Brush Options (Scale, Spacing, the five tiles shown, Flip Along and Across, Stretch to fit, Add space to fit or Approximate path) each have Colorization (None, Tints, Tints and Shades, Hue Shift with its Key Color picked from the art's colours) and a live stroke preview; values from files and commands are clamped to their ranges. Still to come: the Art brush's Overlap, auto-generated Pattern brush corners, new tile art picked in Pattern Brush Options, Stylus Wheel, Tilt, Bearing and Rotation, and pen pressure on macOS.
|
||||
Type in a scatter brush's art takes the brush's colorization as its paths do: Tints, Tints and Shades and Hue Shift recolor the characters' solid fills and strokes in each copy the brush draws, and Brush Options' Key Color list, whose first color is Hue Shift's default key, counts the characters' fills and their strokes wider than 0. The Brushes page says that a scatter, art or pattern brush takes its colors from the current stroke color and the brush's colorization method (<https://web.archive.org/web/20241230205225/https://helpx.adobe.com/illustrator/using/brushes.html>) and does not mention type there; recoloring the characters' paints like the art's fills and strokes is VectorCraft's own rule. The Symbol Stainer mixes the characters' strokes of type toward the stain as it mixes their fills. Evidence: `tests::scatter_colorization_recolors_type`, `tests::art_colors_list_the_most_used_first`, `tests::stain_mixes_type_strokes_too` (vectorcraft-brush).
|
||||
Art Brush Options' Width (1% to 1000%) and Pattern Brush Options' Scale (1% to 10000%) each have a slider beside the field, over the same range in whole percents; screen readers name the slider as its row and read the field's value.
|
||||
Art Brush Options' Width has a Fixed or Pressure menu, from the pen tablet options of the Width pop-up menu on the [Brushes](https://web.archive.org/web/20241230205225/https://helpx.adobe.com/illustrator/using/brushes.html) page (archived copy). With Pressure, the art is Width wide at the lightest pen pressure and the second width at the heaviest, following the pressure the Paintbrush recorded along the stroke, and half way between them on a stroke without recorded pressure (`brush.options` `width_mode` and `width_max`); a stroke inside the art takes the width at the middle of its object. The page does not say how the pressure maps to the width; taking the Scatter brush's minimum and maximum rule and the stroke rule are VectorCraft's own. Stylus Wheel, Tilt, Bearing and Rotation are not built.
|
||||
Scatter, Art and Pattern Brush Options have the Tips button for Colorization that the [Brushes](https://web.archive.org/web/20241230205225/https://helpx.adobe.com/illustrator/using/brushes.html) page (archived copy) describes, beside Method. It opens Colorization Tips, which shows each method's name and what it does beside a sample of the brush, as OK would make it, painted with that method on a blue stroke; Hue Shift's sample uses the dialog's Key Color. `ui.dialog.set {field: "tips", value: true}` opens it. Screen readers name the button, the popover and each sample.
|
||||
Hue Shift set by `brush.new` or `brush.options` without a key color takes the color the art's fills and strokes use most, as Brush Options already offers it, following the Brushes page, where the key color defaults to the color that stands out most in the art (<https://web.archive.org/web/20241230205225/https://helpx.adobe.com/illustrator/using/brushes.html>). The page does not state how prominence is measured. Counting the fills and strokes that use each solid color, with the first one found winning a tie and black for art with no solid color, is VectorCraft's own rule. A `brush.options` edit that gives only the method keeps the brush's key color, and one that gives only an art brush's start guide keeps its end guide. Evidence: `tests_brushsym::hue_shift_without_a_key_takes_the_most_used_art_color`, `tests_brushsym::art_brush_options_keep_the_scale_fields_an_edit_leaves_out`.
|
||||
Bristle Brush Options and `brush.new` take a bristle brush name of at most 31 characters, as the Brushes page states for the Bristle Brush Options. The Name field stops taking characters at 31, and `brush.new` and `brush.options` refuse a longer new name with an error that names the limit. The page does not say how the limit counts characters or what happens to longer names already saved; counting characters and the number added to a taken name, keeping longer saved names, and leaving `brush.duplicate` unlimited are VectorCraft's own rules. A bristle brush that a document or a saved file already holds under a longer name keeps it, and the other brush types take a name of any length. Test: `bristle_brush_names_have_at_most_31_characters` in `tests_brushsym.rs`. Public reference: [Brushes](https://web.archive.org/web/20241230205225/https://helpx.adobe.com/illustrator/using/brushes.html) (archived copy).
|
||||
|
||||
Reference in New Issue
Block a user