diff --git a/crates/brush/src/lib.rs b/crates/brush/src/lib.rs index 91523163e..a53d20519 100644 --- a/crates/brush/src/lib.rs +++ b/crates/brush/src/lib.rs @@ -287,6 +287,8 @@ pub struct ArtBrush { pub width: 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. + pub overlap: bool, pub colorization: Colorization, } @@ -304,6 +306,7 @@ impl Default for ArtBrush { width: 100.0, flip_along: false, flip_across: false, + overlap: false, colorization: Colorization::Tints, } } diff --git a/crates/brush/src/tests.rs b/crates/brush/src/tests.rs index 94e9e6014..6f11bbe5c 100644 --- a/crates/brush/src/tests.rs +++ b/crates/brush/src/tests.rs @@ -152,6 +152,24 @@ fn art_brush_between_guides_keeps_ends() { assert!(near(h.x0, 280.0, 1e-6) && near(h.x1, 300.0, 1e-6), "{h:?}"); } +#[test] +fn art_brush_overlap_splits_at_corners() { + let mut bp = BezPath::new(); + bp.move_to((0.0, 0.0)); + bp.line_to((100.0, 0.0)); + bp.line_to((100.0, 100.0)); + let brush = |overlap: bool| { + Brush { + name: "a".into(), + kind: BrushKind::Art(ArtBrush { art: rect_art(10.0, 4.0), scale: ArtScale::Stretch, overlap, colorization: Colorization::None, ..Default::default() }), + } + }; + let one = stroke_pieces(&Document::new(1.0, 1.0), &brush(false), &bp, &stroke(1.0)); + let two = stroke_pieces(&Document::new(1.0, 1.0), &brush(true), &bp, &stroke(1.0)); + assert_eq!(one.len(), 1, "one bent copy along the whole path"); + assert_eq!(two.len(), 2, "one copy per run between corners"); +} + #[test] fn art_brush_follows_curves() { let bp = shapes::ellipse(Rect::new(0.0, 0.0, 200.0, 200.0)).to_bezpath(); diff --git a/crates/brush/src/warp.rs b/crates/brush/src/warp.rs index 8eef08c04..35031741e 100644 --- a/crates/brush/src/warp.rs +++ b/crates/brush/src/warp.rs @@ -154,35 +154,65 @@ fn bend(t: &Track, art: &Node, b: Rect, along: AlongMap, cross: f64) -> Option Vec<(f64, f64)> { + let l = t.len(); + let corners = t.corner_positions(); + if corners.is_empty() { + return vec![(0.0, l)]; + } + if t.closed { + let first = corners[0].1; + let mut bounds = corners.iter().map(|c| c.1).collect::>(); + bounds.push(first + l); + return bounds.windows(2).map(|w| (w[0], w[1])).collect(); + } + let mut bounds = vec![0.0]; + bounds.extend(corners.iter().map(|c| c.1)); + bounds.push(l); + 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 { + let l = s1 - s0; + if l <= 1e-9 { + return None; + } + 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 * a.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; + 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) + } + } + }; + bend(t, art, b, along, cross) +} + pub(crate) fn art(a: &ArtBrush, bp: &BezPath, weight: f64) -> Vec { 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 mut out = vec![]; for t in tracks(bp, 0.05) { - let l = t.len(); - if l <= 1e-9 { + if t.len() <= 1e-9 { continue; } - let w = b.width(); - let (along, cross) = match a.scale { - ArtScale::Stretch => (AlongMap::linear(w, 0.0, l), cross), - ArtScale::Proportional => { - let k = l / w; - (AlongMap::linear(w, 0.0, l), k * a.width / 100.0) + 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) { + out.push(n); } - 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; - if fixed >= l || g1 - g0 < 1e-9 { - (AlongMap::linear(w, 0.0, l), cross) - } else { - (AlongMap { xs: [0.0, g0, g1, w], ss: [0.0, g0 * cross, l - (w - g1) * cross, l] }, cross) - } - } - }; - if let Some(n) = bend(&t, &art, b, along, cross) { - out.push(n); } } out diff --git a/crates/engine/src/cmd/brushsym.rs b/crates/engine/src/cmd/brushsym.rs index ba6911408..61e994993 100644 --- a/crates/engine/src/cmd/brushsym.rs +++ b/crates/engine/src/cmd/brushsym.rs @@ -89,7 +89,7 @@ pub fn specs() -> Vec { "Brush Options…", [], None, - "{name, params?: {…fields to change (brush.get shows them; values outside their ranges are clamped). calligraphic: angle −180..180°, roundness 0..100%, size 0..1296 pt, variation: [angle°, roundness%, size pt], modes: [fixed|random|pressure 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. 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)}}, newName? (trimmed; blank: the name 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%, 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)}}, newName? (trimmed; blank: the name is kept)} edit a brush definition (strokes using it update) → {name}", has_doc, brush_options ), diff --git a/crates/ui-egui/src/dialogs/brush_art_options.rs b/crates/ui-egui/src/dialogs/brush_art_options.rs index cf2edf290..43e747d8b 100644 --- a/crates/ui-egui/src/dialogs/brush_art_options.rs +++ b/crates/ui-egui/src/dialogs/brush_art_options.rs @@ -8,7 +8,8 @@ //! 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 -//! `guideEnd` (% of the art's length), `direction` (`leftToRight` …), `flipAlong`, `flipAcross`. +//! `guideEnd` (% of the art's length), `direction` (`leftToRight` …), `flipAlong`, `flipAcross`, +//! `overlap` (bend on each run between sharp corners). //! Pattern: `scale`, `spacing` (%), `fit` (`stretch` | `addSpace` | `approximate`), `flipAlong`, //! `flipAcross`, `__tiles` (slot → preview key, for the tiles the brush has). @@ -96,6 +97,7 @@ pub(super) fn fields(b: &Brush, def: Value, f: &mut Map) { f.insert("direction".into(), json!(id_of(&a.direction))); f.insert("flipAlong".into(), json!(a.flip_along)); f.insert("flipAcross".into(), json!(a.flip_across)); + f.insert("overlap".into(), json!(a.overlap)); } BrushKind::Pattern(p) => { f.insert("scale".into(), json!(p.scale)); @@ -168,6 +170,7 @@ pub(super) fn params(d: &Dialog) -> Value { "direction": id_of(&parse_field::(d, "direction")), "flip_along": d.bool("flipAlong"), "flip_across": d.bool("flipAcross"), + "overlap": d.bool("overlap"), }) } _ => json!({ @@ -284,6 +287,8 @@ fn art_body(ui: &mut egui::Ui, d: &mut Dialog) { } }); flips(ui, d); + ui.add_space(4.0); + super::form::check(ui, d, "overlap", tl!("Overlap")); } /// A toggle button showing an arrow pointing the way `dir` runs. Whether it was clicked.