Merge pull request #7421 from dotCooCoo/fix/art-brush-proportional-weight

Brushes: Scale Proportionately art grows with the stroke weight
This commit is contained in:
BFlat
2026-11-14 20:37:17 +07:00
committed by GitHub
4 changed files with 52 additions and 17 deletions
+2 -1
View File
@@ -351,7 +351,8 @@ pub enum Direction {
#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", tag = "mode")]
pub enum ArtScale {
/// Keep the art's proportions (length follows the path; width scales with it).
/// Keep the art's proportions (length follows the path; width scales with it, times the
/// stroke weight and the brush width).
Proportional,
/// Stretch the art to the path length.
#[default]
+44
View File
@@ -141,6 +141,50 @@ fn art_brush_proportional_keeps_aspect() {
assert!(near(bounds(&out).height(), 8.0, 1e-6));
}
/// Scale Proportionately art is scaled across by the stroke weight and the Width, as Stretch art
/// is, on top of the scale the path length gives it.
#[test]
fn art_brush_proportional_grows_with_weight() {
let brush = |width: f64, overlap: bool| {
let mut b = art_brush(rect_art(100.0, 4.0), ArtScale::Proportional);
if let BrushKind::Art(a) = &mut b.kind {
(a.width, a.overlap) = (width, overlap);
}
b
};
let draw = |b: &Brush, bp: &BezPath, st: &StrokeLayer| stroke_pieces(&Document::new(1.0, 1.0), b, bp, st);
// 3 pt stroke → 3× wider than at 1 pt (8 pt); the length still follows the path.
let b = bounds(&draw(&brush(100.0, false), &line(0.0, 0.0, 200.0, 0.0), &stroke(3.0)));
assert!(near(b.height(), 24.0, 1e-6) && near(b.x0, 0.0, 1e-6) && near(b.x1, 200.0, 1e-6), "{b:?}");
// The Width (50 %) applies on top of the stroke weight.
let b = bounds(&draw(&brush(50.0, false), &line(0.0, 0.0, 200.0, 0.0), &stroke(3.0)));
assert!(near(b.height(), 12.0, 1e-6), "{b:?}");
// A path twice as long at twice the weight (as Scale Strokes & Effects at 200 % leaves it):
// 2× from the length and 2× from the weight.
let b = bounds(&draw(&brush(100.0, false), &line(0.0, 0.0, 400.0, 0.0), &stroke(2.0)));
assert!(near(b.height(), 32.0, 1e-6), "{b:?}");
// With Overlap, each run between corners is scaled by its own length and by the weight: the
// 100 pt run 4 pt × 3 across, the 50 pt run 2 pt × 3.
let mut bent = BezPath::new();
bent.move_to((0.0, 0.0));
bent.line_to((100.0, 0.0));
bent.line_to((100.0, 50.0));
let out = draw(&brush(100.0, true), &bent, &stroke(3.0));
assert_eq!(out.len(), 2);
let (r0, r1) = (out[0].geometric_bounds().unwrap(), out[1].geometric_bounds().unwrap());
assert!(near(r0.height(), 12.0, 1e-6) && near(r1.width(), 6.0, 1e-6), "{r0:?} {r1:?}");
// A width that follows the pen: 50 % at the lightest pressure, 300 % at the heaviest, times
// the 2 pt weight (4 pt art on a 100 pt path: 4 pt across at 100 %, 1 pt either side).
let mut pen = brush(50.0, false);
if let BrushKind::Art(a) = &mut pen.kind {
(a.width_mode, a.width_max) = (Some(Variation::Pressure), Some(300.0));
}
let ramp = StrokeLayer { width: 2.0, ..pressed(vec![(0.0, 0.0), (1.0, 1.0)]) };
let out = draw(&pen, &line(0.0, 0.0, 100.0, 0.0), &ramp);
assert!(near(half_width(&out, 0.0, 1.0), 2.0, 0.1), "{}", half_width(&out, 0.0, 1.0));
assert!(near(half_width(&out, 99.0, 100.0), 12.0, 0.2), "{}", half_width(&out, 99.0, 100.0));
}
#[test]
fn art_brush_between_guides_keeps_ends() {
// Head (last 20 % of a 100 pt art) keeps its 20 pt length on a 300 pt path.
+5 -16
View File
@@ -465,17 +465,8 @@ fn art_runs(t: &Track) -> Vec<(f64, f64)> {
bounds.windows(2).map(|w| (w[0], w[1])).collect()
}
/// 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> {
/// The art on [s0, s1] of `t`, `cross` across where `widen` (0..=1, by arc length) is 1.
fn art_on_run(t: &Track, (s0, s1): (f64, f64), a: &ArtBrush, (art, b): (&Node, Rect), cross: f64, widen: &dyn Fn(f64) -> f64) -> Option<Node> {
let l = s1 - s0;
if l <= 1e-9 {
return None;
@@ -483,10 +474,8 @@ fn art_on_run(
let w = b.width();
let (along, cross) = match a.scale {
ArtScale::Stretch => (AlongMap::linear(w, s0, s1), cross),
ArtScale::Proportional => {
let k = l / w;
(AlongMap::linear(w, s0, s1), k * width / 100.0)
}
// Scaled across as much as along, times the stroke weight and the width, as the other modes are.
ArtScale::Proportional => (AlongMap::linear(w, s0, s1), l / w * cross),
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));
@@ -528,7 +517,7 @@ pub(crate) fn art(a: &ArtBrush, bp: &BezPath, weight: f64, stroke: &StrokeLayer)
};
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) {
if let Some(n) = art_on_run(&t, run, a, (&art, b), cross, &widen) {
out.push(n);
}
}
+1
View File
@@ -1549,6 +1549,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
- **Strokes:** one stroke-geometry module shared by the canvas, SVG, PDF and Outline Stroke; dotted lines (zero-length dashes draw dots or squares); arrowheads with hollow outlines, tip-on-end or extend-past-end alignment and one opacity for line and head. SVG and PDF export arrowheads, width profiles, brushes and aligned strokes exactly as the canvas draws them; Outline Stroke and the live Outline Stroke effect outline every stroke item as drawn (dashes, heads, profiles, alignment); dashes can be fitted to corners and path ends; width profiles carry through dashes, and their corners take the join. The Stroke panel edits type's character strokes, follows the stroke units (its weight presets are round values in that unit: `0.25 mm`, `0.0625 in`, `1 px`, #426; fields show four decimals in mm, cm and inches, three in points), shows mixed values blank and opens from the Control bar. A width profile library, thirty-one more arrowheads (among them Line Arrow: two arms of the head's weight (stroke weight × arrowhead scale, at least 0.25 pt: the line's weight at 100 % for lines of 0.25 pt or more) at right angles, cut square, #5421), and Width Point Edit with multi-select and discontinuous points. Visual bounds and clicks cover the whole painted stroke. Scale Strokes & Effects, Scale Corners and Use Preview Bounds work everywhere, and new art inherits stroke options, a graphic style or the whole last appearance. A width profile's sides run as a smooth C1 curve between the width points (a monotone cubic Hermite, Fritsch–Carlson, with the one-sided three-point PCHIP slope at the profile's ends and flat either side of a step), sampled adaptively along the path (two samples per profile segment, refined where the width bends, and leaning on dense path flattening) so straight runs draw it too; added samples offset along the source curve's exact tangent, not the flattened chord, so high zoom follows the true width; the sampled edges are fitted with G1 cubics within a tenth of the flattening tolerance (0.001 pt at the default 0.01 pt) rather than one cubic per flatten step, so exports stay close to a uniform stroke's path data; a point reshapes the curve as far as its neighbours' neighbours (their slopes depend on it), and coincident points still step.
- **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, Pressure, Tilt, Bearing or Rotation 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, Pressure from the first at the lightest pressure to the second at the heaviest, Tilt, Bearing or Rotation; 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, Overlap) 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. Art Brush Options › Overlap places art on each run between sharp corners so folds do not overlap (`ArtBrush.overlap`; `art_brush_overlap_splits_at_corners`). Still to come: Stylus Wheel, pen pressure on macOS where the windowing layer reports none, and tilt, bearing and rotation for Pattern and Bristle brushes and on native Wayland and the web.
Under Tints and Tints and Shades, art in black ink alone goes by its ink amount, and a process or gray stroke color's tints and shades stay in that color's model; these are VectorCraft's own rules.
Scale Proportionately art is scaled across by the stroke weight and the Width, as Stretch to Fit Stroke Length and Stretch Between Guides art is, on top of the scale the path length gives it, so a 3 pt stroke draws the art three times as wide as a 1 pt stroke. The [Brushes](https://web.archive.org/web/20241230205225/https://helpx.adobe.com/illustrator/using/brushes.html) page says Scale Proportionately keeps the art's proportions and does not say how the stroke weight applies; that rule is VectorCraft's own. Evidence: `tests::art_brush_proportional_grows_with_weight` (vectorcraft-brush).
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).
- **Pen tilt, bearing and rotation (part of #764):** Calligraphic and Scatter Brush Options offer Tilt, Bearing and Rotation beside Fixed, Random and Pressure. The Paintbrush records along each stroke how far the pen leans, which way, and how far its barrel is turned (kept with the path, saved, and joined when a stroke continues a path). A Calligraphic value on Tilt goes from value − variation with the pen lying flat to value + variation upright; on Bearing or Rotation it adds variation × the angle over 180°, so Angle on Bearing with a Variation of 180° turns the nib with the way the pen leans. A Scatter value goes from its first value to its second over the same ranges. A stroke without that input draws the brush's own value. On Linux under X11 and XWayland the app reads the tilt and an Art Pen's barrel rotation from XInput2, on macOS it reads them from AppKit's tablet events, and `path.freehand` points and `pointer_gesture` events carry them on every platform. The [Brushes](https://web.archive.org/web/20241230205225/https://helpx.adobe.com/illustrator/using/brushes.html) page defines the three options and does not say which way each one runs; that mapping is VectorCraft's own rule. Evidence: `tests::tilt_maps_to_roundness_along_the_stroke`, `tests::bearing_and_rotation_turn_the_nib`, `tests::scatter_values_follow_tilt_and_bearing` (vectorcraft-brush), `tests_brushsym::paintbrush_records_the_pens_tilt_on_the_stroke` (vectorcraft-engine), `tablet::tests::a_pens_tilt_and_rotation_go_into_the_feed` and `mac_tablet::tests::a_pens_tilt_and_rotation_go_into_the_feed` (vectorcraft).
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.