Part of gap 5: Scatter, Art and Pattern Brush Options

Double-clicking a Scatter, Art or Pattern brush in the Brushes panel (or the
panel menu's Brush Options...) now opens its options dialog, as Calligraphic
and Bristle brushes already did. OK runs brush.options and every stroke
painted with the brush redraws.

- Scatter Brush Options: Name; Size (10-1000 %), Spacing (10-10000 %),
  Scatter (-1000-1000 %) and Rotation (-180-180 deg), each Fixed (the first
  value), Random (between the two values) or Pressure (the first value at
  the lightest pen pressure, the second at the heaviest); Rotation relative
  to Page or Path. Scatter brushes now keep these modes (`modes`); brushes
  saved before keep their look (Random where the two values differ).
- Art Brush Options: Name; Width (1-1000 %); Scale Proportionately, Stretch
  to Fit Stroke Length or Stretch Between Guides with the guides' Start and
  End (% of the art length); the four Directions; Flip Along and Across.
- Pattern Brush Options: Name; Scale (1-10000 %), Spacing (0-10000 %); the
  five tiles (Outer Corner, Side, Inner Corner, Start, End) drawn with their
  art or None; Flip Along and Across; Stretch to fit, Add space to fit or
  Approximate path.
- All three: Colorization (None, Tints, Tints and Shades, Hue Shift with its
  Key Color picked from the art's colours, the most used one by default) and
  a live preview of a stroke painted with the edited brush.

Brush definitions from files and commands are now kept within these ranges
(Brush::sanitize, also for Calligraphic and Bristle values); unknown modes,
directions, fits or colorization methods are refused. The brush.options and
ui.brushOptions docs list the new params and fields. Tests: the brush model
(modes, pressure, sanitizing, art colours), engine params reaching the
brush and strokes redrawing, junk params in command_sweep, and headless
dialog tests for each dialog.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
bflatastic 2026-10-10 17:34:48 +07:00
parent 5b198f3c00
commit 2bc6c66c94
22 changed files with 1246 additions and 70 deletions

File diff suppressed because one or more lines are too long

View File

@ -115,3 +115,31 @@ pub fn instance_art(art: &Node, inst: &Node) -> Node {
}
a
}
/// The solid colours of the fills and strokes under `nodes`, most used first (then in the order
/// they first appear): the colours Brush Options offers as Hue Shift's key colour, the first being
/// its default.
pub fn art_colors<'a>(nodes: impl IntoIterator<Item = &'a Node>) -> Vec<Color> {
fn walk(n: &Node, seen: &mut std::collections::HashMap<String, (usize, usize, Color)>) {
for it in &n.appearance.items {
let (visible, paint) = match it {
AppearanceItem::Fill(f) => (f.visible, &f.paint),
AppearanceItem::Stroke(s) => (s.visible, &s.paint),
};
if let (true, Some(c)) = (visible, paint.color()) {
let order = seen.len();
seen.entry(c.to_hex()).or_insert((0, order, c)).0 += 1;
}
}
for c in n.children().into_iter().flatten() {
walk(c, seen);
}
}
let mut seen = std::collections::HashMap::new();
for n in nodes {
walk(n, &mut seen);
}
let mut v: Vec<(usize, usize, Color)> = seen.into_values().collect();
v.sort_by(|a, b| b.0.cmp(&a.0).then(a.1.cmp(&b.1)));
v.into_iter().map(|(.., c)| c).collect()
}

View File

@ -172,6 +172,7 @@ pub fn defaults() -> &'static [Brush] {
rotation: (-180.0, 180.0),
rotation_relative_to_path: false,
colorization: Colorization::Tints,
modes: None,
}),
},
Brush {
@ -184,6 +185,7 @@ pub fn defaults() -> &'static [Brush] {
rotation: (-35.0, 35.0),
rotation_relative_to_path: true,
colorization: Colorization::None,
modes: None,
}),
},
Brush {

View File

@ -23,7 +23,7 @@ use vectorcraft_color::Paint;
use vectorcraft_doc::{Appearance, AppearanceItem, Document, FillLayer, Node, NodeId, NodeKind, StrokeLayer};
use vectorcraft_geom::{BezPath, Rect};
pub use colorize::{colorize, instance_art, stain, tint_node};
pub use colorize::{art_colors, colorize, instance_art, stain, tint_node};
pub use defaults::defaults;
pub use serde_json;
@ -71,6 +71,15 @@ impl BrushKind {
BrushKind::Bristle(_) => "bristle",
}
}
/// How the brush's art takes the stroke colour (Calligraphic and Bristle brushes paint with it).
pub fn colorization(&self) -> Colorization {
match self {
BrushKind::Art(a) => a.colorization,
BrushKind::Scatter(s) => s.colorization,
BrushKind::Pattern(p) => p.colorization,
BrushKind::Calligraphic(_) | BrushKind::Bristle(_) => Colorization::None,
}
}
}
/// How brush art takes the stroke colour.
@ -88,6 +97,29 @@ pub enum Colorization {
HueShift { key: vectorcraft_color::Color },
}
impl Colorization {
pub const IDS: [&'static str; 4] = ["none", "tints", "tintsAndShades", "hueShift"];
/// The name brush definitions use (`method`).
pub fn id(&self) -> &'static str {
match self {
Colorization::None => "none",
Colorization::Tints => "tints",
Colorization::TintsAndShades => "tintsAndShades",
Colorization::HueShift { .. } => "hueShift",
}
}
/// The method named `id` (`key`: the key colour of Hue Shift).
pub fn parse(id: &str, key: vectorcraft_color::Color) -> Option<Self> {
Some(match id {
"none" => Colorization::None,
"tints" => Colorization::Tints,
"tintsAndShades" => Colorization::TintsAndShades,
"hueShift" => Colorization::HueShift { key },
_ => return None,
})
}
}
/// Calligraphic brush: an elliptical nib swept along the path. Sizes are at 1 pt stroke weight
/// (the stroke weight scales the brush).
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
@ -181,6 +213,12 @@ pub struct Scatter {
/// Rotation relative to the path direction (else to the page).
pub rotation_relative_to_path: bool,
pub colorization: Colorization,
/// How size, spacing, scatter and rotation vary between their two values: Fixed takes the
/// first, Random any value between them, Pressure the first at the lightest pen pressure and
/// the second at the heaviest. `None` (brushes saved before there were modes): Random where
/// the two differ, else Fixed — see [`Scatter::modes`].
#[serde(skip_serializing_if = "Option::is_none")]
pub modes: Option<[Variation; 4]>,
}
impl Default for Scatter {
@ -193,10 +231,25 @@ impl Default for Scatter {
rotation: (0.0, 0.0),
rotation_relative_to_path: false,
colorization: Colorization::None,
modes: None,
}
}
}
impl Scatter {
/// The ranges of size (%), spacing (%), scatter (%) and rotation (°).
pub const RANGES: [(f64, f64); 4] = [(1.0, 10000.0), (1.0, 10000.0), (-1000.0, 1000.0), (-180.0, 180.0)];
/// Size, spacing, scatter and rotation, each as its (first, second) values.
pub fn values(&self) -> [(f64, f64); 4] {
[self.size, self.spacing, self.scatter, self.rotation]
}
/// How size, spacing, scatter and rotation vary.
pub fn modes(&self) -> [Variation; 4] {
self.modes.unwrap_or_else(|| self.values().map(|(a, b)| if a != b { Variation::Random } else { Variation::Fixed }))
}
}
/// Direction the art runs in the brush definition.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
@ -236,6 +289,11 @@ pub struct ArtBrush {
pub colorization: Colorization,
}
impl ArtBrush {
/// The range of the width (%).
pub const WIDTH_RANGE: (f64, f64) = (1.0, 1000.0);
}
impl Default for ArtBrush {
fn default() -> Self {
Self {
@ -286,6 +344,12 @@ pub struct PatternBrush {
pub colorization: Colorization,
}
impl PatternBrush {
/// The ranges of the scale and of the spacing (%).
pub const SCALE_RANGE: (f64, f64) = (1.0, 10000.0);
pub const SPACING_RANGE: (f64, f64) = (0.0, 10000.0);
}
impl Default for PatternBrush {
fn default() -> Self {
Self {
@ -340,12 +404,104 @@ pub struct Bristle {
pub stiffness: f64,
}
impl Bristle {
/// The ranges of the size (pt), the bristle length (%) and the other percentages.
pub const SIZE_RANGE: (f64, f64) = (0.1, 1296.0);
pub const LENGTH_RANGE: (f64, f64) = (25.0, 300.0);
pub const PERCENT_RANGE: (f64, f64) = (1.0, 100.0);
}
impl Default for Bristle {
fn default() -> Self {
Self { shape: BristleShape::RoundPoint, size: 6.0, length: 100.0, density: 50.0, thickness: 40.0, opacity: 75.0, stiffness: 50.0 }
}
}
// ---------- validation ----------
/// `v` within `(lo, hi)`; `fallback` (kept within too) when it isn't a number.
fn within(v: f64, (lo, hi): (f64, f64), fallback: f64) -> f64 {
if v.is_finite() { v.clamp(lo, hi) } else { fallback.clamp(lo, hi) }
}
fn within_pair((a, b): (f64, f64), range: (f64, f64), fallback: f64) -> (f64, f64) {
(within(a, range, fallback), within(b, range, fallback))
}
impl Colorization {
/// A Hue Shift key colour that isn't a colour (components out of reach) becomes black.
fn sanitize(&mut self) {
if let Colorization::HueShift { key } = self
&& !key.to_rgb().iter().all(|c| c.is_finite())
{
*key = vectorcraft_color::Color::BLACK;
}
}
}
impl Brush {
/// The art the brush is made of: a scatter or art brush's art, a pattern brush's tiles.
pub fn art(&self) -> Vec<&Node> {
match &self.kind {
BrushKind::Scatter(s) => vec![&s.art],
BrushKind::Art(a) => vec![&a.art],
BrushKind::Pattern(p) => {
std::iter::once(&p.side).chain([&p.outer_corner, &p.inner_corner, &p.start, &p.end].into_iter().flatten()).collect()
}
BrushKind::Calligraphic(_) | BrushKind::Bristle(_) => vec![],
}
}
/// Keep every value of the definition within its range (definitions come from files and
/// commands, so they are untrusted): numbers are clamped, non-numbers take the default.
pub fn sanitize(&mut self) {
match &mut self.kind {
BrushKind::Calligraphic(c) => {
let d = Calligraphic::default();
let [angle, roundness, size] = Calligraphic::RANGES;
c.angle = within(c.angle, angle, d.angle);
c.roundness = within(c.roundness, roundness, d.roundness);
c.size = within(c.size, size, d.size);
for (v, max) in c.variation.iter_mut().zip(Calligraphic::MAX_VARIATION) {
*v = within(*v, (0.0, max), 0.0);
}
}
BrushKind::Scatter(s) => {
let d = Scatter::default();
let [size, spacing, scatter, rotation] = Scatter::RANGES;
s.size = within_pair(s.size, size, d.size.0);
s.spacing = within_pair(s.spacing, spacing, d.spacing.0);
s.scatter = within_pair(s.scatter, scatter, 0.0);
s.rotation = within_pair(s.rotation, rotation, 0.0);
s.colorization.sanitize();
}
BrushKind::Art(a) => {
a.width = within(a.width, ArtBrush::WIDTH_RANGE, 100.0);
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);
}
a.colorization.sanitize();
}
BrushKind::Pattern(p) => {
p.scale = within(p.scale, PatternBrush::SCALE_RANGE, 100.0);
p.spacing = within(p.spacing, PatternBrush::SPACING_RANGE, 0.0);
p.colorization.sanitize();
}
BrushKind::Bristle(b) => {
let d = Bristle::default();
b.size = within(b.size, Bristle::SIZE_RANGE, d.size);
b.length = within(b.length, Bristle::LENGTH_RANGE, d.length);
for (v, fallback) in
[(&mut b.density, d.density), (&mut b.thickness, d.thickness), (&mut b.opacity, d.opacity), (&mut b.stiffness, d.stiffness)]
{
*v = within(*v, Bristle::PERCENT_RANGE, fallback);
}
}
}
}
}
// ---------- library ----------
/// The brush library of `doc`: its stored brushes, or the defaults when it has none.
@ -356,9 +512,15 @@ pub fn library(doc: &Document) -> Vec<Brush> {
}
}
/// Parse a stored library value (malformed entries are skipped).
/// Parse a stored library value (malformed entries are skipped, values kept within their
/// ranges — see [`Brush::sanitize`]).
pub fn parse_library(v: &serde_json::Value) -> Vec<Brush> {
v.as_array().map(|a| a.iter().filter_map(|b| serde_json::from_value(b.clone()).ok()).collect()).unwrap_or_default()
let parse = |b: &serde_json::Value| {
let mut b: Brush = serde_json::from_value(b.clone()).ok()?;
b.sanitize();
Some(b)
};
v.as_array().map(|a| a.iter().filter_map(parse).collect()).unwrap_or_default()
}
/// Store `lib` as the document's brush library.
@ -404,15 +566,10 @@ pub fn stroke_pieces(brush: &Brush, bp: &BezPath, stroke: &StrokeLayer) -> Vec<N
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::Scatter(s) => warp::scatter(s, bp, w, &brush.name),
BrushKind::Scatter(s) => warp::scatter(s, bp, w, stroke.pressure.as_ref(), &brush.name),
BrushKind::Pattern(p) => warp::pattern(p, bp, w),
};
let colorization = match &brush.kind {
BrushKind::Art(a) => a.colorization,
BrushKind::Scatter(s) => s.colorization,
BrushKind::Pattern(p) => p.colorization,
_ => Colorization::None,
};
let colorization = brush.kind.colorization();
for n in &mut out {
colorize(n, colorization, &stroke.paint);
}

View File

@ -447,3 +447,77 @@ fn junk_pressure_brushes_draw_finite_art() {
}
}
}
/// A scatter brush of 10 × 4 pt rectangles whose size goes from `lo` % to `hi` % as `mode` says.
fn sized_scatter(lo: f64, hi: f64, mode: Variation) -> Brush {
let s = scatter_brush(100.0);
let BrushKind::Scatter(sc) = s.kind else { panic!("not a scatter brush") };
let modes = [mode, Variation::Fixed, Variation::Fixed, Variation::Fixed];
Brush { kind: BrushKind::Scatter(Scatter { size: (lo, hi), modes: Some(modes), ..sc }), ..s }
}
/// Scatter Brush Options: Fixed takes the first value, Pressure goes from the first at the
/// lightest pen pressure to the second at the heaviest; brushes saved before modes keep their look.
#[test]
fn scatter_values_follow_their_modes() {
let bp = line(0.0, 0.0, 400.0, 0.0);
let heights =
|b: &Brush, st: &StrokeLayer| -> Vec<f64> { stroke_pieces(b, &bp, st).iter().map(|n| n.geometric_bounds().unwrap().height()).collect() };
// Fixed: every copy at 50 %, whatever the second value.
assert!(heights(&sized_scatter(50.0, 300.0, Variation::Fixed), &stroke(1.0)).iter().all(|h| near(*h, 2.0, 1e-6)));
// Pressure: light at the start (50 %: 2 pt tall), heavy at the end (300 %: 12 pt).
let ramp = pressed(vec![(0.0, 0.0), (1.0, 1.0)]);
let h = heights(&sized_scatter(50.0, 300.0, Variation::Pressure), &ramp);
let (first, last) = (h[0], h[h.len() - 1]);
assert!(near(first, 2.0, 0.1) && last > 10.0, "{h:?}");
assert!(h.windows(2).all(|w| w[1] >= w[0] - 1e-9), "grows with the pressure: {h:?}");
// Without recorded pressure, half way between the two.
assert!(heights(&sized_scatter(50.0, 300.0, Variation::Pressure), &stroke(1.0)).iter().all(|h| near(*h, 7.0, 1e-6)));
// No modes saved: Random where the values differ, as before; Fixed and Random draw the same.
let old = sized_scatter(50.0, 300.0, Variation::Random);
let BrushKind::Scatter(sc) = &old.kind else { panic!("not a scatter brush") };
assert_eq!(Scatter { modes: None, ..sc.clone() }.modes(), [Variation::Random, Variation::Fixed, Variation::Fixed, Variation::Fixed]);
let unsaved = Brush { kind: BrushKind::Scatter(Scatter { modes: None, ..sc.clone() }), ..old.clone() };
assert_eq!(stroke_pieces(&old, &bp, &stroke(1.0)), stroke_pieces(&unsaved, &bp, &stroke(1.0)));
assert!(serde_json::to_value(&unsaved).unwrap().get("modes").is_none(), "not saved when unset");
}
/// Definitions from files and commands are kept within their ranges.
#[test]
fn sanitize_keeps_values_within_their_ranges() {
let mut v = serde_json::to_value(sized_scatter(1e308, -1e308, Variation::Random)).unwrap();
v["rotation"] = serde_json::json!([720.0, -1e300]);
v["scatter"] = serde_json::json!([5000.0, 0.0]);
let lib = parse_library(&serde_json::json!([
v,
{"name": "a", "type": "art", "art": rect_art(10.0, 4.0), "width": 1e308, "scale": {"mode": "betweenGuides", "start": -3.0, "end": 9.0}},
{"name": "p", "type": "pattern", "side": rect_art(10.0, 4.0), "scale": 0.0, "spacing": -5.0},
{"name": "c", "type": "calligraphic", "angle": 400.0, "roundness": -1.0, "size": 1e9, "variation": [1e9, -1.0, 2.0]},
{"name": "b", "type": "bristle", "size": 0.0, "length": 1000.0, "density": 0.0},
]));
let [s, a, p, c, b] = lib.as_slice() else { panic!("{lib:?}") };
let BrushKind::Scatter(s) = &s.kind else { panic!() };
assert_eq!((s.size, s.rotation, s.scatter), ((10000.0, 1.0), (180.0, -180.0), (1000.0, 0.0)));
let BrushKind::Art(a) = &a.kind else { panic!() };
assert_eq!((a.width, a.scale), (1000.0, ArtScale::BetweenGuides { start: 0.0, end: 1.0 }));
let BrushKind::Pattern(p) = &p.kind else { panic!() };
assert_eq!((p.scale, p.spacing), (1.0, 0.0));
let BrushKind::Calligraphic(c) = &c.kind else { panic!() };
assert_eq!((c.angle, c.roundness, c.size, c.variation), (180.0, 0.0, 1296.0, [180.0, 0.0, 2.0]));
let BrushKind::Bristle(b) = &b.kind else { panic!() };
assert_eq!((b.size, b.length, b.density), (0.1, 300.0, 1.0));
}
/// Hue Shift's key colour defaults to the art's most used colour.
#[test]
fn art_colors_list_the_most_used_first() {
let red = Color::rgb(1.0, 0.0, 0.0);
let blue = Color::rgb(0.0, 0.0, 1.0);
let path =
|c: Color| Node::path(NodeId(0), shapes::rectangle(Rect::new(0.0, 0.0, 1.0, 1.0)), Appearance::basic(Paint::solid(c), Paint::None, 0.0));
let g = Node::group(NodeId(0), vec![Arc::new(path(red)), Arc::new(path(blue)), Arc::new(path(blue))]);
assert_eq!(art_colors([&g]), vec![blue, red]);
let b = defaults().iter().find(|b| b.name == "Chain").unwrap();
assert_eq!(b.art().len(), 3, "the side and both corner tiles");
assert!(calli(0.0, 100.0, 3.0).art().is_empty());
}

View File

@ -6,11 +6,11 @@
use std::sync::Arc;
use vectorcraft_doc::{Node, NodeKind};
use vectorcraft_doc::{Node, NodeKind, PressureProfile};
use vectorcraft_geom::{Affine, BezPath, PathData, PathEl, Point, Rect, SubPath};
use crate::track::{Rng, Track, seed_of, tracks};
use crate::{ArtBrush, ArtScale, Direction, PatternBrush, PatternFit, Scatter, tolerance};
use crate::{ArtBrush, ArtScale, Direction, PatternBrush, PatternFit, Scatter, Variation, tolerance};
/// Longest mapped segment (points in document space).
const MAX_SEG: f64 = 1.5;
@ -188,13 +188,28 @@ pub(crate) fn art(a: &ArtBrush, bp: &BezPath, weight: f64) -> Vec<Node> {
out
}
pub(crate) fn scatter(sc: &Scatter, bp: &BezPath, weight: f64, name: &str) -> Vec<Node> {
/// One of a scatter brush's values for the next copy: Fixed takes the first of `(a, b)`, Random a
/// value between them, Pressure the first at pen pressure 0 and the second at 1. Every value
/// draws, so a copy's random values stay the same whatever the other modes.
fn vary(rng: &mut Rng, (a, b): (f64, f64), mode: Variation, pressure: f64) -> f64 {
let r = rng.range(a, b);
match mode {
Variation::Fixed => a,
Variation::Random => r,
Variation::Pressure => a + (b - a) * pressure.clamp(0.0, 1.0),
}
}
pub(crate) fn scatter(sc: &Scatter, bp: &BezPath, weight: f64, pressure: Option<&PressureProfile>, name: &str) -> Vec<Node> {
let Some(b) = sc.art.geometric_bounds() else { return vec![] };
let (aw, ah) = (b.width().max(1e-3), b.height().max(1e-3));
let mut rng = Rng::new(seed_of(bp, name));
let [size_mode, spacing_mode, scatter_mode, rotation_mode] = sc.modes();
let mut out = vec![];
for t in tracks(bp, 0.05) {
let l = t.len();
// The pen pressure at arc length `at` (half way without a recorded one).
let pressure_at = |at: f64| pressure.map_or(PressureProfile::MID, |p| p.at(if l > 1e-9 { at / l } else { 0.0 }));
let mut s: Option<f64> = None;
let mut guard = 0;
loop {
@ -202,8 +217,10 @@ pub(crate) fn scatter(sc: &Scatter, bp: &BezPath, weight: f64, name: &str) -> Ve
if guard > 20000 {
break;
}
let size = rng.range(sc.size.0, sc.size.1).max(1.0) / 100.0 * weight;
let spacing = rng.range(sc.spacing.0, sc.spacing.1).max(1.0) / 100.0;
// Size and spacing place the next copy, so they take the pressure at the last one.
let p = pressure_at(s.unwrap_or(0.0));
let size = vary(&mut rng, sc.size, size_mode, p).max(1.0) / 100.0 * weight;
let spacing = vary(&mut rng, sc.spacing, spacing_mode, p).max(1.0) / 100.0;
let step = aw * size * spacing;
let pos = match s {
None => step / 2.0,
@ -213,8 +230,9 @@ pub(crate) fn scatter(sc: &Scatter, bp: &BezPath, weight: f64, name: &str) -> Ve
break;
}
s = Some(pos);
let off = rng.range(sc.scatter.0, sc.scatter.1) / 100.0 * ah * size;
let rot = rng.range(sc.rotation.0, sc.rotation.1).to_radians();
let p = pressure_at(pos);
let off = vary(&mut rng, sc.scatter, scatter_mode, p) / 100.0 * ah * size;
let rot = vary(&mut rng, sc.rotation, rotation_mode, p).to_radians();
let (p, tg) = t.frame(pos);
let place = p + crate::track::normal(tg) * off;
// Page rotation is counter-clockwise on screen (y-down), like the calligraphic angle.

View File

@ -63,7 +63,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Brush Options…",
[],
None,
"{name, params?: {…fields to change (brush.get shows them; 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)}, newName?} 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. 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?} edit a brush definition (strokes using it update) → {name}",
has_doc,
brush_options
),
@ -383,12 +383,18 @@ fn brush_new(s: &mut Session, p: &Value) -> Result<Value> {
}
v[k] = serde_json::to_value(&art).unwrap_or_default();
}
let b: Brush = serde_json::from_value(v).map_err(|e| bad(C, format!("invalid brush: {e}")))?;
lib.push(b);
lib.push(parse_brush(C, v)?);
save_library(s, "New Brush", lib, None)?;
Ok(json!({ "name": name }))
}
/// A brush definition from command params, its values kept within their ranges.
fn parse_brush(cmd: &str, v: Value) -> Result<Brush> {
let mut b: Brush = serde_json::from_value(v).map_err(|e| bad(cmd, format!("invalid brush: {e}")))?;
b.sanitize();
Ok(b)
}
fn brush_delete(s: &mut Session, p: &Value) -> Result<Value> {
let name = name_param(p, "brush.delete")?.to_string();
let mut lib = brush::library(&s.doc()?.doc);
@ -431,7 +437,7 @@ fn brush_options(s: &mut Session, p: &Value) -> Result<Value> {
v["name"] = json!(name);
}
v["type"] = json!(lib[i].kind.type_id());
lib[i] = serde_json::from_value(v).map_err(|e| bad(C, format!("invalid brush: {e}")))?;
lib[i] = parse_brush(C, v)?;
let out = lib[i].name.clone();
let rename = new_name.map(|nn| (name, Some(nn)));
save_library(s, "Brush Options", lib, rename)?;

View File

@ -297,6 +297,77 @@ fn brush_options_set_variation_modes() {
assert_eq!(s.execute("brush.get", &json!({"name": "3 pt. Round"})).unwrap(), b, "a refused edit changes nothing");
}
/// The bounds of the brush art `id`'s strokes draw.
fn brushed_bounds(s: &Session, id: NodeId) -> Rect {
let st = s.doc().unwrap();
let pieces = vectorcraft_brush::node_pieces(&st.doc, &node(s, id)).expect("brushed");
vectorcraft_brush::pieces_bounds(&pieces).expect("draws")
}
/// Scatter, Art and Pattern Brush Options: the params reach the brush and every stroke painted
/// with it redraws; values out of range are clamped, unknown choices refused.
#[test]
fn scatter_art_and_pattern_brush_options_update_strokes() {
let mut s = session();
// A rectangle as an art brush (straight edges: no flattening between sizes).
let r = rect(&mut s, 10.0, 200.0, 40.0, 8.0);
let bar = s.execute("brush.new", &json!({"type": "art", "name": "Bar", "ids": [r.0]})).unwrap()["name"].as_str().unwrap().to_string();
let id = line(&mut s);
select(&mut s, &[id]);
let edit = |s: &mut Session, name: &str, params: Value| -> f64 {
s.execute("brush.apply", &json!({ "name": name })).unwrap();
let before = brushed_bounds(s, id).height();
s.execute("brush.options", &json!({ "name": name, "params": params })).unwrap();
brushed_bounds(s, id).height() / before
};
// Art: Width 300 % draws the art three times as wide across the path.
let art = json!({"width": 300, "direction": "rightToLeft", "flip_across": true});
assert!((edit(&mut s, &bar, art) - 3.0).abs() < 1e-6);
s.execute("brush.options", &json!({"name": bar, "params": {"scale": {"mode": "betweenGuides", "start": 0.2, "end": 0.6}}})).unwrap();
let b = s.execute("brush.get", &json!({ "name": bar })).unwrap();
assert_eq!(
(&b["direction"], &b["flip_across"], &b["scale"]),
(&json!("rightToLeft"), &json!(true), &json!({"mode": "betweenGuides", "start": 0.2, "end": 0.6}))
);
// Pattern: Scale 200 % doubles the tiles.
assert!((edit(&mut s, "Stitches", json!({"scale": 200, "spacing": 50, "fit": "addSpace", "flip_along": true})) - 2.0).abs() < 0.05);
let b = s.execute("brush.get", &json!({"name": "Stitches"})).unwrap();
assert_eq!((&b["spacing"], &b["fit"]), (&json!(50.0), &json!("addSpace")));
// Scatter: a fixed Size of 200 % doubles the dots; the modes and Hue Shift are kept.
let key = json!({"model": "rgb", "r": 0.0, "g": 0.0, "b": 0.0});
let scatter = json!({"size": [200, 50], "modes": ["fixed", "fixed", "fixed", "random"], "rotation_relative_to_path": true, "colorization": {"method": "hueShift", "key": key}});
assert!((edit(&mut s, "Dots", scatter) - 2.0).abs() < 0.05);
let b = s.execute("brush.get", &json!({"name": "Dots"})).unwrap();
assert_eq!(
(&b["modes"], &b["colorization"]["method"], &b["colorization"]["key"]),
(&json!(["fixed", "fixed", "fixed", "random"]), &json!("hueShift"), &key)
);
// Out of range: clamped. Unknown choices: refused, nothing changes.
let junk = json!({"size": [1e308, -1e308], "spacing": [0, 1e308], "rotation": [720, -1e9], "scatter": [-1e308, 5]});
s.execute("brush.options", &json!({"name": "Dots", "params": junk})).unwrap();
let b = s.execute("brush.get", &json!({"name": "Dots"})).unwrap();
assert_eq!(
(&b["size"], &b["spacing"], &b["rotation"], &b["scatter"]),
(&json!([10000.0, 1.0]), &json!([1.0, 10000.0]), &json!([180.0, -180.0]), &json!([-1000.0, 5.0]))
);
assert!(brushed_bounds(&s, id).is_finite());
for (name, bad) in [
("Dots", json!({"modes": ["fixed", "tilt", "fixed", "fixed"]})),
("Dots", json!({"modes": ["fixed"]})),
("Dots", json!({"colorization": {"method": "sparkle"}})),
("Arrow", json!({"direction": "diagonal"})),
("Arrow", json!({"width": "wide"})),
("Chain", json!({"fit": "squeeze"})),
] {
let before = s.execute("brush.get", &json!({ "name": name })).unwrap();
assert!(s.execute("brush.options", &json!({ "name": name, "params": bad })).is_err(), "{bad}");
assert_eq!(s.execute("brush.get", &json!({ "name": name })).unwrap(), before);
}
s.execute("brush.options", &json!({"name": "Chain", "params": {"scale": 1e308, "spacing": -1e308}})).unwrap();
let b = s.execute("brush.get", &json!({"name": "Chain"})).unwrap();
assert_eq!((&b["scale"], &b["spacing"]), (&json!(10000.0), &json!(0.0)));
}
// ---------- symbols ----------
fn make_symbol(s: &mut Session) -> (NodeId, String, Rect) {

View File

@ -227,6 +227,20 @@ fn structured_junk() {
"brush.options",
json!({"name": "3 pt. Round", "params": {"modes": ["pressure", "pressure", "pressure"], "variation": [1e308, 1e308, 1e308], "size": 1e308, "angle": -1e308}}),
),
(
"brush.options",
json!({"name": "Confetti", "params": {"size": [1e308, -1e308], "spacing": [0, 1e308], "scatter": [-1e308, 1e308], "rotation": [1e308, -1e308], "modes": ["pressure", "random", "fixed", "pressure"]}}),
),
("brush.options", json!({"name": "Dots", "params": {"modes": ["fixed", "tilt", "fixed", "fixed"], "size": "x"}})),
("brush.options", json!({"name": "Dots", "params": {"modes": ["fixed"], "colorization": {"method": "sparkle"}}})),
(
"brush.options",
json!({"name": "Dots", "params": {"colorization": {"method": "hueShift", "key": {"model": "rgb", "r": 1e308, "g": -1e308, "b": 0}}}}),
),
("brush.options", json!({"name": "Arrow", "params": {"width": 1e308, "scale": {"mode": "betweenGuides", "start": 1e308, "end": -1e308}}})),
("brush.options", json!({"name": "Arrow", "params": {"width": -5, "direction": "diagonal", "flip_along": "yes"}})),
("brush.options", json!({"name": "Chain", "params": {"scale": 1e308, "spacing": -1e308, "fit": "approximate", "flip_across": true}})),
("brush.options", json!({"name": "Chain", "params": {"scale": 0, "spacing": 1e308, "fit": "nope", "side": null}})),
("path.curvature", json!({"points": [{"x": 0, "y": 0}]})),
("path.curvatureEdit", json!({"id": 2, "op": "move", "anchor": 1000, "x": 0, "y": 0})),
("path.curvatureEdit", json!({"id": 2, "op": "move", "anchor": 0, "x": 1e308, "y": -1e308})),

View File

@ -0,0 +1,413 @@
//! Scatter, Art and Pattern Brush Options: the [`super::brush_options`] of the brushes made of
//! art. Each has the brush's own values, Colorization (Method, and Key Color for Hue Shift, picked
//! from the art's colours) and a live preview of a stroke.
//!
//! Fields (besides `__brush`, `__type` and `name`): `__def` (the brush definition, for the preview),
//! `__colors` (the art's colours, most used first), `colorization` (`none` | `tints` |
//! `tintsAndShades` | `hueShift`), `keyColor` (a colour).
//! 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`.
//! Pattern: `scale`, `spacing` (%), `fit` (`stretch` | `addSpace` | `approximate`), `flipAlong`,
//! `flipAcross`, `__tiles` (slot → preview key, for the tiles the brush has).
use std::hash::{Hash, Hasher};
use egui::{Color32, Sense, Stroke, pos2, vec2};
use serde_json::{Map, Value, json};
use vectorcraft_brush::{ArtBrush, ArtScale, Brush, BrushKind, Colorization, Direction, PatternBrush, PatternFit, Scatter, Variation};
use vectorcraft_color::{Color, Paint};
use vectorcraft_doc::{Document, Node};
use super::brush_options::{clamped, mode_key, stroke_preview};
use crate::state::Dialog;
use crate::theme::Tokens;
use crate::widgets;
/// Scatter values: (field, suffix), in [`Scatter::RANGES`] order.
const SCATTER: [(&str, &str); 4] = [("size", "%"), ("spacing", "%"), ("scatter", "%"), ("rotation", "°")];
/// Art brush directions, in the order of their arrows (→ ← ↓ ↑).
const DIRECTIONS: [Direction; 4] = [Direction::LeftToRight, Direction::RightToLeft, Direction::TopToBottom, Direction::BottomToTop];
/// Pattern brush tiles: (field of the definition, slot), in the dialog's order.
const TILES: [(&str, &str); 5] =
[("outer_corner", "outerCorner"), ("side", "side"), ("inner_corner", "innerCorner"), ("start", "start"), ("end", "end")];
/// The most key colours offered.
const MAX_COLORS: usize = 16;
/// Width of the label column.
const LABEL_W: f32 = 132.0;
/// Width of the stroke preview.
const PREVIEW_W: f32 = 420.0;
pub(super) fn heading(ty: &str) -> &'static str {
match ty {
"scatter" => tl!("Scatter Brush Options"),
"art" => tl!("Art Brush Options"),
_ => tl!("Pattern Brush Options"),
}
}
fn min_key(key: &str) -> String {
format!("{key}Min")
}
fn max_key(key: &str) -> String {
format!("{key}Max")
}
/// A definition id of a serde enum (`leftToRight`, `addSpace` …).
fn id_of<T: serde::Serialize>(v: &T) -> String {
serde_json::to_value(v).ok().and_then(|v| v.as_str().map(str::to_string)).unwrap_or_default()
}
/// The value of the serde enum `T` that field `key` names, else `T`'s default.
fn parse_field<T: serde::de::DeserializeOwned + Default>(d: &Dialog, key: &str) -> T {
serde_json::from_value(json!(d.str(key))).unwrap_or_default()
}
// ---------- fields ----------
/// The fields of brush `b` (a scatter, art or pattern brush) whose definition is `def`.
pub(super) fn fields(b: &Brush, def: Value, f: &mut Map<String, Value>) {
match &b.kind {
BrushKind::Scatter(s) => {
for ((key, _), ((lo, hi), mode)) in SCATTER.iter().zip(s.values().into_iter().zip(s.modes())) {
f.insert(min_key(key), json!(lo));
f.insert(max_key(key), json!(hi));
f.insert(mode_key(key), json!(mode.id()));
}
f.insert("rotationRelativeTo".into(), json!(if s.rotation_relative_to_path { "path" } else { "page" }));
}
BrushKind::Art(a) => {
let (mode, start, end) = match a.scale {
ArtScale::Proportional => ("proportional", 0.0, 1.0),
ArtScale::Stretch => ("stretch", 0.0, 1.0),
ArtScale::BetweenGuides { start, end } => ("betweenGuides", start, end),
};
f.insert("width".into(), json!(a.width));
f.insert("scaleMode".into(), json!(mode));
f.insert("guideStart".into(), json!(start * 100.0));
f.insert("guideEnd".into(), json!(end * 100.0));
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));
}
BrushKind::Pattern(p) => {
f.insert("scale".into(), json!(p.scale));
f.insert("spacing".into(), json!(p.spacing));
f.insert("fit".into(), json!(id_of(&p.fit)));
f.insert("flipAlong".into(), json!(p.flip_along));
f.insert("flipAcross".into(), json!(p.flip_across));
// A preview key per tile the brush has: its art's hash, so a changed tile redraws.
let tiles: Map<String, Value> = TILES
.iter()
.filter_map(|(field, slot)| {
let art = def.get(*field).filter(|v| !v.is_null())?;
let mut h = std::collections::hash_map::DefaultHasher::new();
art.to_string().hash(&mut h);
Some(((*slot).to_string(), json!(format!("brush-tile:{:x}", h.finish()))))
})
.collect();
f.insert("__tiles".into(), Value::Object(tiles));
}
BrushKind::Calligraphic(_) | BrushKind::Bristle(_) => {}
}
let colors = vectorcraft_brush::art_colors(b.art());
let colorization = b.kind.colorization();
let key = match colorization {
Colorization::HueShift { key } => key,
_ => colors.first().copied().unwrap_or(Color::BLACK),
};
f.insert("colorization".into(), json!(colorization.id()));
f.insert("keyColor".into(), serde_json::to_value(key).unwrap_or_default());
f.insert("__colors".into(), serde_json::to_value(colors.into_iter().take(MAX_COLORS).collect::<Vec<_>>()).unwrap_or_default());
f.insert("__def".into(), def);
}
fn key_color(d: &Dialog) -> Color {
d.fields.get("keyColor").and_then(|v| serde_json::from_value(v.clone()).ok()).unwrap_or(Color::BLACK)
}
fn colorization(d: &Dialog) -> Colorization {
Colorization::parse(&d.str("colorization"), key_color(d)).unwrap_or_default()
}
/// How scatter value `key` varies.
fn scatter_mode(d: &Dialog, key: &str) -> Variation {
Variation::parse(&d.str(&mode_key(key))).unwrap_or_default()
}
/// `brush.options` params from the fields.
pub(super) fn params(d: &Dialog) -> Value {
let mut p = match d.str("__type").as_str() {
"scatter" => {
let mut p = json!({ "rotation_relative_to_path": d.str("rotationRelativeTo") == "path" });
for ((key, _), range) in SCATTER.iter().zip(Scatter::RANGES) {
p[*key] = json!([clamped(d, &min_key(key), range), clamped(d, &max_key(key), range)]);
}
p["modes"] = json!(SCATTER.map(|(key, _)| scatter_mode(d, key).id()));
p
}
"art" => {
let scale = match d.str("scaleMode").as_str() {
"proportional" => ArtScale::Proportional,
"betweenGuides" => ArtScale::BetweenGuides {
start: clamped(d, "guideStart", (0.0, 100.0)) / 100.0,
end: clamped(d, "guideEnd", (0.0, 100.0)) / 100.0,
},
_ => ArtScale::Stretch,
};
json!({
"width": clamped(d, "width", ArtBrush::WIDTH_RANGE),
"scale": serde_json::to_value(scale).unwrap_or_default(),
"direction": id_of(&parse_field::<Direction>(d, "direction")),
"flip_along": d.bool("flipAlong"),
"flip_across": d.bool("flipAcross"),
})
}
_ => json!({
"scale": clamped(d, "scale", PatternBrush::SCALE_RANGE),
"spacing": clamped(d, "spacing", PatternBrush::SPACING_RANGE),
"fit": id_of(&parse_field::<PatternFit>(d, "fit")),
"flip_along": d.bool("flipAlong"),
"flip_across": d.bool("flipAcross"),
}),
};
p["colorization"] = serde_json::to_value(colorization(d)).unwrap_or_default();
p
}
// ---------- body ----------
pub(super) fn body(ui: &mut egui::Ui, d: &mut Dialog) {
match d.str("__type").as_str() {
"scatter" => scatter_body(ui, d),
"art" => art_body(ui, d),
_ => pattern_body(ui, d),
}
ui.add_space(6.0);
colorization_body(ui, d);
ui.add_space(10.0);
// The brush as OK would make it, on a sample stroke.
let mut def = d.fields.get("__def").cloned().unwrap_or_default();
if let (Some(o), Value::Object(p)) = (def.as_object_mut(), params(d)) {
o.extend(p);
}
let ty = d.str("__type");
stroke_preview(ui, &ty, def, vec2(PREVIEW_W, 64.0));
}
/// A whole-number field (`suffix`: % or °) bound to `d.fields[key]`, kept within `range`.
fn number(ui: &mut egui::Ui, d: &mut Dialog, key: &str, range: (f64, f64), suffix: &str, enabled: bool) {
ui.add_enabled_ui(enabled, |ui| {
if let Some(v) = widgets::range_field(ui, ("brush-art-value", key), clamped(d, key, range), range.0..=range.1, suffix, 0, 72.0) {
d.fields.insert(key.into(), json!(v));
}
});
}
/// A row of radio buttons bound to the string field `key`: (value, label).
fn radios(ui: &mut egui::Ui, d: &mut Dialog, key: &str, options: &[(&str, &str)]) {
let cur = d.str(key);
for (value, label) in options {
if widgets::radio(ui, label, cur == *value, true) {
d.fields.insert(key.into(), json!(value));
}
}
}
fn flips(ui: &mut egui::Ui, d: &mut Dialog) {
widgets::label_row(ui, tl!("Flip:"), LABEL_W, |ui| {
super::form::check(ui, d, "flipAlong", tl!("Flip Along"));
ui.add_space(12.0);
super::form::check(ui, d, "flipAcross", tl!("Flip Across"));
});
}
fn scatter_body(ui: &mut egui::Ui, d: &mut Dialog) {
let labels = [tl!("Size:"), tl!("Spacing:"), tl!("Scatter:"), tl!("Rotation:")];
let modes = Variation::ALL.map(Variation::label);
egui::Grid::new("brush-options-scatter").num_columns(4).spacing([8.0, 8.0]).show(ui, |ui| {
for (((key, suffix), range), text) in SCATTER.into_iter().zip(Scatter::RANGES).zip(labels) {
super::swatch_options::label(ui, text);
let mode = scatter_mode(d, key);
for (field, enabled) in [(min_key(key), true), (max_key(key), mode != Variation::Fixed)] {
ui.add_enabled_ui(enabled, |ui| {
if let Some(v) =
widgets::range_field(ui, ("brush-scatter", field.as_str()), clamped(d, &field, range), range.0..=range.1, suffix, 0, 72.0)
{
d.fields.insert(field.clone(), json!(v));
}
});
}
if let Some(m) = widgets::dropdown(ui, ("brush-scatter-mode", key), mode.label(), &modes, 104.0).and_then(|i| Variation::ALL.get(i)) {
d.fields.insert(mode_key(key), json!(m.id()));
}
ui.end_row();
}
});
ui.add_space(4.0);
let relative = [("page", tl!("Page")), ("path", tl!("Path"))];
super::form::choice(ui, d, "rotationRelativeTo", tl!("Rotation relative to:"), (LABEL_W, 104.0), &relative);
}
fn art_body(ui: &mut egui::Ui, d: &mut Dialog) {
widgets::label_row(ui, tl!("Width:"), LABEL_W, |ui| number(ui, d, "width", ArtBrush::WIDTH_RANGE, "%", true));
ui.add_space(4.0);
widgets::section_header(ui, tl!("Brush Scale Options"));
let scales = [
("proportional", tl!("Scale Proportionately")),
("stretch", tl!("Stretch to Fit Stroke Length")),
("betweenGuides", tl!("Stretch Between Guides")),
];
radios(ui, d, "scaleMode", &scales);
let guides = d.str("scaleMode") == "betweenGuides";
ui.horizontal(|ui| {
ui.add_space(18.0);
widgets::label_row(ui, tl!("Start:"), 40.0, |ui| number(ui, d, "guideStart", (0.0, 100.0), "%", guides));
ui.add_space(8.0);
widgets::label_row(ui, tl!("End:"), 34.0, |ui| number(ui, d, "guideEnd", (0.0, 100.0), "%", guides));
});
ui.add_space(6.0);
widgets::label_row(ui, tl!("Direction:"), LABEL_W, |ui| {
let tips = [tl!("Left to Right"), tl!("Right to Left"), tl!("Top to Bottom"), tl!("Bottom to Top")];
let cur = parse_field::<Direction>(d, "direction");
for (dir, tip) in DIRECTIONS.into_iter().zip(tips) {
if arrow_button(ui, dir, dir == cur, tip) {
d.fields.insert("direction".into(), json!(id_of(&dir)));
}
}
});
flips(ui, d);
}
/// A toggle button showing an arrow pointing the way `dir` runs. Whether it was clicked.
fn arrow_button(ui: &mut egui::Ui, dir: Direction, selected: bool, tip: &str) -> bool {
let t = Tokens::get(ui.ctx());
let (r, resp) = ui.allocate_exact_size(vec2(26.0, 24.0), Sense::click());
let p = ui.painter();
if selected {
p.rect_filled(r, 3.0, t.row_selected);
} else if resp.hovered() {
p.rect_filled(r, 3.0, t.hover);
}
p.rect_stroke(r, 3.0, Stroke::new(1.0, if selected { t.accent } else { t.input_border }), egui::StrokeKind::Inside);
let u = match dir {
Direction::LeftToRight => vec2(1.0, 0.0),
Direction::RightToLeft => vec2(-1.0, 0.0),
Direction::TopToBottom => vec2(0.0, 1.0),
Direction::BottomToTop => vec2(0.0, -1.0),
};
let (c, n) = (r.center(), vec2(-u.y, u.x));
let (tail, tip_at) = (c - u * 7.0, c + u * 7.0);
let stroke = Stroke::new(1.5, if selected { t.text_strong } else { t.text });
p.line_segment([tail, tip_at], stroke);
p.line_segment([tip_at, tip_at - u * 4.0 + n * 4.0], stroke);
p.line_segment([tip_at, tip_at - u * 4.0 - n * 4.0], stroke);
resp.on_hover_text(tip).clicked()
}
fn pattern_body(ui: &mut egui::Ui, d: &mut Dialog) {
widgets::label_row(ui, tl!("Scale:"), LABEL_W, |ui| number(ui, d, "scale", PatternBrush::SCALE_RANGE, "%", true));
widgets::label_row(ui, tl!("Spacing:"), LABEL_W, |ui| number(ui, d, "spacing", PatternBrush::SPACING_RANGE, "%", true));
ui.add_space(6.0);
tiles(ui, d);
ui.add_space(6.0);
flips(ui, d);
widgets::section_header(ui, tl!("Fit"));
let fits = [("stretch", tl!("Stretch to fit")), ("addSpace", tl!("Add space to fit")), ("approximate", tl!("Approximate path"))];
radios(ui, d, "fit", &fits);
}
/// The pattern's five tiles, each with its art (or None when the brush has no such tile).
fn tiles(ui: &mut egui::Ui, d: &Dialog) {
let t = Tokens::get(ui.ctx());
let names = [tl!("Outer Corner Tile"), tl!("Side Tile"), tl!("Inner Corner Tile"), tl!("Start Tile"), tl!("End Tile")];
let keys = d.fields.get("__tiles");
ui.horizontal(|ui| {
ui.spacing_mut().item_spacing.x = 6.0;
for ((field, slot), name) in TILES.into_iter().zip(names) {
ui.vertical(|ui| {
let (r, resp) = ui.allocate_exact_size(vec2(76.0, 44.0), Sense::hover());
ui.painter().rect_filled(r, 0.0, Color32::WHITE);
let key = keys.and_then(|k| k.get(slot)).and_then(Value::as_str);
let tex = key.and_then(|key| {
widgets::doc_preview(ui, key, r.size(), |w, h| {
let art: Node = serde_json::from_value(d.fields.get("__def")?.get(field)?.clone()).ok()?;
tile_doc(&art, w, h)
})
});
match tex {
Some(tex) => {
ui.painter().image(tex.id(), r, egui::Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE);
}
None => {
ui.painter().text(
r.center(),
egui::Align2::CENTER_CENTER,
tl!("None"),
egui::FontId::proportional(11.5),
Color32::from_gray(140),
);
}
}
ui.painter().rect_stroke(r, 0.0, Stroke::new(1.0, t.input_border), egui::StrokeKind::Inside);
resp.on_hover_text(name);
ui.add_sized(vec2(76.0, 14.0), egui::Label::new(egui::RichText::new(name).size(10.5).color(t.text_dim)).truncate());
});
}
});
}
/// A document of `w` × `h` showing `art` scaled to fit, centred.
fn tile_doc(art: &Node, w: f64, h: f64) -> Option<Document> {
let b = art.visual_bounds().or_else(|| art.geometric_bounds())?;
let k = ((w - 8.0) / b.width().max(1e-6)).min((h - 8.0) / b.height().max(1e-6));
if !k.is_finite() || k <= 0.0 {
return None;
}
let mut doc = Document::new(w, h);
let mut n = doc.reid(art);
let xf = vectorcraft_geom::Affine::translate((w / 2.0, h / 2.0))
* vectorcraft_geom::Affine::scale(k)
* vectorcraft_geom::Affine::translate(-b.center().to_vec2());
n.transform(xf, true);
let l = doc.layers.first()?.id;
doc.insert(Some(l), 0, n).ok()?;
Some(doc)
}
fn colorization_body(ui: &mut egui::Ui, d: &mut Dialog) {
widgets::section_header(ui, tl!("Colorization"));
let methods = [tl!("None"), tl!("Tints"), tl!("Tints and Shades"), tl!("Hue Shift")];
let cur = d.str("colorization");
let at = Colorization::IDS.iter().position(|id| *id == cur).unwrap_or(0);
widgets::label_row(ui, tl!("Method:"), LABEL_W, |ui| {
if let Some(id) = widgets::dropdown_names(ui, "brush-colorization", methods.get(at).copied().unwrap_or_default(), &methods, 150.0)
.and_then(|i| Colorization::IDS.get(i))
{
d.fields.insert("colorization".into(), json!(id));
}
});
// Hue Shift's key colour: the art colour that becomes the stroke colour.
let colors: Vec<Color> = d.fields.get("__colors").and_then(|v| serde_json::from_value(v.clone()).ok()).unwrap_or_default();
let hue_shift = cur == "hueShift";
let key = key_color(d);
widgets::label_row(ui, tl!("Key Color:"), LABEL_W, |ui| {
ui.add_enabled_ui(hue_shift, |ui| {
ui.spacing_mut().item_spacing.x = 3.0;
for c in colors {
let (r, resp) = ui.allocate_exact_size(vec2(18.0, 18.0), Sense::click());
widgets::swatch_tile(ui, r, &Paint::solid(c), hue_shift && c == key, resp.hovered());
if resp.on_hover_text(c.to_hex()).clicked() {
d.fields.insert("keyColor".into(), serde_json::to_value(c).unwrap_or_default());
}
}
});
});
}

View File

@ -1,10 +1,12 @@
//! Brush Options (double-click a brush in the Brushes panel, or the panel menu's Brush Options…).
//! Calligraphic Brush Options: Name, then Angle, Roundness and Size, each Fixed, Random or
//! Pressure with its Variation, over live previews of the nib and of a stroke (light, heavy, light
//! pressure). Bristle Brush Options: Name, Shape and the bristle sliders. OK runs `brush.options`
//! (strokes painted with the brush update); a name another brush has keeps the dialog open.
//! pressure). Bristle Brush Options: Name, Shape and the bristle sliders. Scatter, Art and Pattern
//! Brush Options: see [`super::brush_art_options`]. OK runs `brush.options` (strokes painted with
//! the brush update); a name another brush has keeps the dialog open.
//!
//! Fields: `__brush` (the brush's name), `__type` (`calligraphic` | `bristle`), `name`.
//! Fields: `__brush` (the brush's name), `__type` (`calligraphic` | `bristle` | `scatter` | `art` |
//! `pattern`), `name`.
//! Calligraphic: `angle` (−180..180°), `roundness` (0..100 %), `size` (0..1296 pt), and per value
//! `angleMode`, `roundnessMode`, `sizeMode` (`fixed` | `random` | `pressure`) and
//! `angleVariation`, `roundnessVariation`, `sizeVariation`. Bristle: `shape` (`roundPoint`…),
@ -12,10 +14,10 @@
use egui::{Color32, Sense, Stroke, pos2, vec2};
use serde_json::{Map, Value, json};
use vectorcraft_brush::{Brush, BrushKind, Calligraphic, Variation};
use vectorcraft_brush::{Bristle, Brush, BrushKind, Calligraphic, Variation};
use super::swatch_options::{grid, label};
use super::{DialogSpec, form};
use super::{DialogSpec, brush_art_options, form};
use crate::state::Dialog;
use crate::theme::Tokens;
use crate::{VectorcraftApp, widgets};
@ -25,19 +27,16 @@ pub const KIND: &str = "brushOptions";
pub(super) const SPEC: DialogSpec = DialogSpec { heading, body, confirm, min_width: 440.0, ..DialogSpec::FORM };
/// The brush types that have an options dialog.
pub const TYPES: [&str; 2] = ["calligraphic", "bristle"];
/// Calligraphic values: (field, label, suffix, decimals), in [`Calligraphic::RANGES`] order.
const CALLI: [(&str, &str, &str, usize); 3] = [("angle", "Angle:", "°", 0), ("roundness", "Roundness:", "%", 0), ("size", "Size:", " pt", 1)];
/// Bristle sliders: (field, label, min, max), in percent.
const BRISTLE: [(&str, &str, f64, f64); 5] = [
("length", "Bristle Length:", 25.0, 300.0),
("density", "Bristle Density:", 1.0, 100.0),
("thickness", "Bristle Thickness:", 1.0, 100.0),
("opacity", "Paint Opacity:", 1.0, 100.0),
("stiffness", "Stiffness:", 1.0, 100.0),
/// Bristle sliders: (field, label, range), in percent.
const BRISTLE: [(&str, &str, (f64, f64)); 5] = [
("length", "Bristle Length:", Bristle::LENGTH_RANGE),
("density", "Bristle Density:", Bristle::PERCENT_RANGE),
("thickness", "Bristle Thickness:", Bristle::PERCENT_RANGE),
("opacity", "Paint Opacity:", Bristle::PERCENT_RANGE),
("stiffness", "Stiffness:", Bristle::PERCENT_RANGE),
];
/// Bristle tip shapes: (value, label).
@ -64,11 +63,12 @@ const STROKE_W: f32 = 400.0;
fn heading(d: &Dialog) -> String {
match d.str("__type").as_str() {
"bristle" => tl!("Bristle Brush Options").into(),
_ => tl!("Calligraphic Brush Options").into(),
"calligraphic" => tl!("Calligraphic Brush Options").into(),
ty => brush_art_options::heading(ty).into(),
}
}
fn mode_key(key: &str) -> String {
pub(super) fn mode_key(key: &str) -> String {
format!("{key}Mode")
}
@ -83,7 +83,7 @@ pub fn open(app: &mut VectorcraftApp, name: Option<&str>) -> Result<Value, Strin
None => target(app).ok_or("no brush selected")?,
};
let def = app.run("brush.get", json!({ "name": name }))?;
let b: Brush = serde_json::from_value(def).map_err(|e| e.to_string())?;
let b: Brush = serde_json::from_value(def.clone()).map_err(|e| e.to_string())?;
let mut f = Map::new();
f.insert("__brush".into(), json!(b.name));
f.insert("name".into(), json!(b.name));
@ -104,16 +104,17 @@ pub fn open(app: &mut VectorcraftApp, name: Option<&str>) -> Result<Value, Strin
f.insert(key.into(), v.get(key).cloned().unwrap_or(Value::Null));
}
}
_ => return Err("this kind of brush has no options dialog yet".into()),
// The definition goes along, for the preview (and the art it carries).
BrushKind::Scatter(_) | BrushKind::Art(_) | BrushKind::Pattern(_) => brush_art_options::fields(&b, def, &mut f),
}
app.ui.dialog = Some(Dialog { kind: KIND.into(), fields: f });
Ok(Value::Null)
}
/// Can Brush Options open without a name (its brush has a type with a dialog)?
/// Can Brush Options open without a name (the selected path's brush, else the current one, exists)?
pub fn available(app: &VectorcraftApp) -> bool {
let Some(st) = app.session.active() else { return false };
target(app).and_then(|n| vectorcraft_brush::find(&st.doc, &n)).is_some_and(|b| TYPES.contains(&b.kind.type_id()))
target(app).is_some_and(|n| vectorcraft_brush::find(&st.doc, &n).is_some())
}
/// The brush Brush Options opens on without a name: the selected path's brush, else the current one.
@ -124,7 +125,7 @@ fn target(app: &VectorcraftApp) -> Option<String> {
}
/// The value of field `key` within `range`.
fn clamped(d: &Dialog, key: &str, (lo, hi): (f64, f64)) -> f64 {
pub(super) fn clamped(d: &Dialog, key: &str, (lo, hi): (f64, f64)) -> f64 {
d.f64(key, lo).clamp(lo, hi)
}
@ -141,13 +142,14 @@ fn calligraphic(d: &Dialog) -> Calligraphic {
fn params(d: &Dialog) -> Value {
match d.str("__type").as_str() {
"bristle" => {
let mut p = json!({ "shape": d.str("shape"), "size": clamped(d, "size", (0.1, 1296.0)) });
for (key, _, lo, hi) in BRISTLE {
p[key] = json!(clamped(d, key, (lo, hi)));
let mut p = json!({ "shape": d.str("shape"), "size": clamped(d, "size", Bristle::SIZE_RANGE) });
for (key, _, range) in BRISTLE {
p[key] = json!(clamped(d, key, range));
}
p
}
_ => serde_json::to_value(calligraphic(d)).unwrap_or_default(),
"calligraphic" => serde_json::to_value(calligraphic(d)).unwrap_or_default(),
_ => brush_art_options::params(d),
}
}
@ -160,7 +162,8 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
ui.add_space(10.0);
match d.str("__type").as_str() {
"bristle" => bristle_body(app, ui, d),
_ => calligraphic_body(ui, d),
"calligraphic" => calligraphic_body(ui, d),
_ => brush_art_options::body(ui, d),
}
false
}
@ -203,7 +206,7 @@ fn bristle_body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) {
form::length(ui, d, "size", app.session.general_unit(), 80.0);
});
let rail = Tokens::get(ui.ctx()).input_border;
for (key, text, lo, hi) in BRISTLE {
for (key, text, (lo, hi)) in BRISTLE {
widgets::label_row(ui, tl!(text), LABEL_W, |ui| form::slider_field(ui, d, key, lo..=hi, "%", &|_| rail));
}
ui.add_space(8.0);
@ -212,7 +215,7 @@ fn bristle_body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) {
}
/// A stroke painted with the brush the fields describe (type `ty`, definition fields `def`).
fn stroke_preview(ui: &mut egui::Ui, ty: &str, mut def: Value, size: egui::Vec2) {
pub(super) fn stroke_preview(ui: &mut egui::Ui, ty: &str, mut def: Value, size: egui::Vec2) {
def["name"] = json!("preview");
def["type"] = json!(ty);
let (r, _) = ui.allocate_exact_size(size, Sense::hover());
@ -325,7 +328,7 @@ mod tests {
}
#[test]
fn bristle_options_and_brushes_without_a_dialog() {
fn bristle_options_and_an_unknown_brush() {
let mut app = app();
app.run("ui.brushOptions", json!({"name": "Bristle Round"})).unwrap();
let text = shown(&mut app);
@ -336,9 +339,158 @@ mod tests {
super::super::confirm(&mut app).unwrap();
let def = app.run("brush.get", json!({"name": "Bristle Round"})).unwrap();
assert_eq!((def["shape"].clone(), def["density"].clone(), def["length"].clone()), (json!("flatFan"), json!(1.0), json!(120.0)));
// Art, scatter and pattern brushes have no dialog yet: an error, nothing opens.
assert!(app.run("ui.brushOptions", json!({"name": "Arrow"})).is_err());
assert!(app.ui.dialog.is_none());
// No such brush: an error, nothing opens.
app.ui.dialog = None;
assert!(app.run("ui.brushOptions", json!({"name": "Nope"})).is_err());
assert!(app.ui.dialog.is_none());
}
/// A path painted with brush `name`, and the height of the art its stroke draws.
fn brushed(app: &mut VectorcraftApp, name: &str) -> (vectorcraft_doc::NodeId, f64) {
let id = app.run("shape.line", json!({"x1": 20, "y1": 100, "x2": 280, "y2": 100})).unwrap()["id"].as_u64().unwrap();
let id = vectorcraft_doc::NodeId(id);
app.run("brush.apply", json!({ "name": name, "ids": [id.0] })).unwrap();
(id, drawn_height(app, id))
}
fn drawn_height(app: &VectorcraftApp, id: vectorcraft_doc::NodeId) -> f64 {
let st = app.session.active().unwrap();
let pieces = vectorcraft_brush::node_pieces(&st.doc, st.doc.node(id).unwrap()).unwrap();
vectorcraft_brush::pieces_bounds(&pieces).unwrap().height()
}
#[test]
fn scatter_options_show_each_value_with_its_mode_and_ok_applies_them() {
let mut app = app();
let (id, before) = brushed(&mut app, "Confetti");
app.run("ui.brushOptions", json!({"name": "Confetti"})).unwrap();
let text = shown(&mut app);
for label in [
"Scatter Brush Options",
"Name:",
"Size:",
"Spacing:",
"Scatter:",
"Rotation:",
"Random",
"Rotation relative to:",
"Page",
"Colorization",
"Method:",
"Tints",
"Key Color:",
] {
assert!(text.contains(label), "{label} in {text}");
}
let d = app.ui.dialog.as_ref().unwrap();
assert_eq!((d.f64("sizeMin", 0.0), d.f64("sizeMax", 0.0), d.str("sizeMode")), (60.0, 140.0, "random".into()));
assert_eq!((d.str("rotationRelativeTo"), d.str("colorization")), ("page".into(), "tints".into()));
// Values are kept within their ranges (rotation here); Fixed takes the first value.
set(
&mut app,
json!({"sizeMode": "fixed", "sizeMin": 5000, "spacingMode": "pressure", "rotationMin": -400, "rotationRelativeTo": "path", "colorization": "hueShift"}),
);
assert!(shown(&mut app).contains("Hue Shift"));
super::super::confirm(&mut app).unwrap();
assert!(app.ui.dialog.is_none());
let def = app.run("brush.get", json!({"name": "Confetti"})).unwrap();
assert_eq!(
(&def["size"], &def["rotation"][0], &def["modes"]),
(&json!([5000.0, 140.0]), &json!(-180.0), &json!(["fixed", "pressure", "random", "random"]))
);
assert_eq!((&def["rotation_relative_to_path"], &def["colorization"]["method"]), (&json!(true), &json!("hueShift")));
assert!(def["colorization"]["key"].is_object(), "the art's colour as the key: {def}");
assert!(drawn_height(&app, id) > before, "the stroke redraws with the bigger confetti");
// Opened again, it shows what was set.
app.run("ui.brushOptions", json!({"name": "Confetti"})).unwrap();
let d = app.ui.dialog.as_ref().unwrap();
assert_eq!((d.str("sizeMode"), d.str("spacingMode"), d.str("colorization")), ("fixed".into(), "pressure".into(), "hueShift".into()));
}
#[test]
fn art_options_set_width_scale_direction_flips_and_colorization() {
let mut app = app();
let (id, before) = brushed(&mut app, "Arrow");
app.run("ui.brushOptions", json!({"name": "Arrow"})).unwrap();
let text = shown(&mut app);
for label in [
"Art Brush Options",
"Width:",
"Brush Scale Options",
"Scale Proportionately",
"Stretch to Fit Stroke Length",
"Stretch Between Guides",
"Start:",
"End:",
"Direction:",
"Flip Along",
"Flip Across",
"Colorization",
] {
assert!(text.contains(label), "{label} in {text}");
}
let d = app.ui.dialog.as_ref().unwrap();
assert_eq!(
(d.f64("width", 0.0), d.str("scaleMode"), d.f64("guideEnd", 0.0), d.str("direction")),
(100.0, "betweenGuides".into(), 78.0, "leftToRight".into())
);
set(
&mut app,
json!({"width": 250, "guideStart": 10, "guideEnd": 140, "direction": "bottomToTop", "flipAcross": true, "colorization": "none"}),
);
super::super::confirm(&mut app).unwrap();
let def = app.run("brush.get", json!({"name": "Arrow"})).unwrap();
assert_eq!(
(&def["width"], &def["scale"], &def["direction"]),
(&json!(250.0), &json!({"mode": "betweenGuides", "start": 0.1, "end": 1.0}), &json!("bottomToTop"))
);
assert_eq!((&def["flip_across"], &def["flip_along"], &def["colorization"]), (&json!(true), &json!(false), &json!({"method": "none"})));
assert!(drawn_height(&app, id) != before, "the stroke redraws");
// Stretch to Fit drops the guides.
app.run("ui.brushOptions", json!({"name": "Arrow"})).unwrap();
set(&mut app, json!({"scaleMode": "stretch"}));
super::super::confirm(&mut app).unwrap();
assert_eq!(app.run("brush.get", json!({"name": "Arrow"})).unwrap()["scale"], json!({"mode": "stretch"}));
}
#[test]
fn pattern_options_show_the_tiles_and_set_scale_spacing_fit() {
let mut app = app();
app.run("ui.brushOptions", json!({"name": "Chain"})).unwrap();
let text = shown(&mut app);
for label in [
"Pattern Brush Options",
"Scale:",
"Spacing:",
"Outer Corner Tile",
"Side Tile",
"Inner Corner Tile",
"Start Tile",
"End Tile",
"None",
"Fit",
"Stretch to fit",
"Add space to fit",
"Approximate path",
"Flip Along",
] {
assert!(text.contains(label), "{label} in {text}");
}
let d = app.ui.dialog.as_ref().unwrap();
let mut tiles: Vec<&String> = d.fields["__tiles"].as_object().unwrap().keys().collect();
tiles.sort();
assert_eq!(tiles, ["innerCorner", "outerCorner", "side"], "Chain has side and corner tiles, no start or end");
// Diamonds (straight-edged tiles, so sizes compare exactly).
let (id, before) = brushed(&mut app, "Diamonds");
app.run("ui.brushOptions", json!({"name": "Diamonds"})).unwrap();
assert_eq!((app.ui.dialog.as_ref().unwrap().f64("spacing", 0.0), app.ui.dialog.as_ref().unwrap().str("fit")), (25.0, "addSpace".into()));
set(&mut app, json!({"scale": 200, "spacing": 30, "fit": "approximate", "flipAlong": true}));
super::super::confirm(&mut app).unwrap();
let def = app.run("brush.get", json!({"name": "Diamonds"})).unwrap();
assert_eq!(
(&def["scale"], &def["spacing"], &def["fit"], &def["flip_along"]),
(&json!(200.0), &json!(30.0), &json!("approximate"), &json!(true))
);
assert!((drawn_height(&app, id) / before - 2.0).abs() < 1e-6, "tiles twice the size");
}
}

View File

@ -10,6 +10,7 @@ mod about;
mod all_tools;
pub mod artboard_options;
pub mod blend_options;
mod brush_art_options;
pub mod brush_options;
pub mod color_balance;
pub mod color_guide_options;

View File

@ -1588,6 +1588,37 @@
New Bristle Brush Nuevo pincel de cerdas
Calligraphic Brush Options Opciones de pincel caligráfico
Bristle Brush Options Opciones de pincel de cerdas
Scatter Brush Options Opciones de pincel de dispersión
Art Brush Options Opciones de pincel de arte
Pattern Brush Options Opciones de pincel de motivo
Scatter: Dispersión:
Rotation: Rotación:
Rotation relative to: Rotación relativa a:
Brush Scale Options Opciones de escala del pincel
Scale Proportionately Escalar proporcionalmente
Stretch to Fit Stroke Length Estirar para ajustar a la longitud del trazo
Stretch Between Guides Estirar entre guías
Start: Inicio:
End: Final:
Left to Right De izquierda a derecha
Right to Left De derecha a izquierda
Top to Bottom De arriba abajo
Bottom to Top De abajo arriba
Flip: Voltear:
Outer Corner Tile Mosaico de esquina exterior
Side Tile Mosaico lateral
Inner Corner Tile Mosaico de esquina interior
Start Tile Mosaico inicial
End Tile Mosaico final
Fit Ajuste
Stretch to fit Estirar para ajustar
Add space to fit Agregar espacio para ajustar
Approximate path Aproximar trazado
Colorization Coloración
Tints Matices
Tints and Shades Matices y sombras
Hue Shift Cambio de tono
Key Color: Color clave:
Roundness: Redondez:
Fixed Fijo
Pressure Presión
@ -3502,7 +3533,6 @@ axis Both Ambos
@msg no bounds sin límites
@msg no brush named `{name}` no hay ningún pincel llamado `{name}`
@msg no brush selected no hay ningún pincel seleccionado
@msg this kind of brush has no options dialog yet este tipo de pincel aún no tiene un cuadro de opciones
@msg no catalog no hay catálogo
@msg no changes since the last save no hay cambios desde la última vez que se guardó
@msg no clock to keep a heartbeat by no hay reloj para mantener la señal de actividad

Can't render this file because it contains an unexpected character in line 2 and column 73.

View File

@ -1585,6 +1585,37 @@
New Bristle Brush Nouvelle forme Soies
Calligraphic Brush Options Options de forme calligraphique
Bristle Brush Options Options de forme Soies
Scatter Brush Options Options de forme de diffusion
Art Brush Options Options de forme artistique
Pattern Brush Options Options de forme de motif
Scatter: Dispersion :
Rotation: Rotation :
Rotation relative to: Rotation par rapport à :
Brush Scale Options Options de mise à l'échelle de la forme
Scale Proportionately Mettre à l'échelle proportionnellement
Stretch to Fit Stroke Length Étirer selon la longueur du contour
Stretch Between Guides Étirer entre les repères
Start: Début :
End: Fin :
Left to Right De gauche à droite
Right to Left De droite à gauche
Top to Bottom De haut en bas
Bottom to Top De bas en haut
Flip: Retourner :
Outer Corner Tile Carreau d'angle extérieur
Side Tile Carreau latéral
Inner Corner Tile Carreau d'angle intérieur
Start Tile Carreau de début
End Tile Carreau de fin
Fit Ajustement
Stretch to fit Étirer pour ajuster
Add space to fit Ajouter de l'espace pour ajuster
Approximate path Tracé approximatif
Colorization Colorisation
Tints Teintes
Tints and Shades Teintes et ombres
Hue Shift Décalage de teinte
Key Color: Couleur clé :
Roundness: Arrondi :
Fixed Fixe
Pressure Pression
@ -3499,7 +3530,6 @@ axis Both Les deux
@msg no bounds aucune limite
@msg no brush named `{name}` aucune forme nommée `{name}`
@msg no brush selected aucune forme sélectionnée
@msg this kind of brush has no options dialog yet ce type de forme n’a pas encore de boîte de dialogue d’options
@msg no catalog aucun catalogue
@msg no changes since the last save aucune modification depuis le dernier enregistrement
@msg no clock to keep a heartbeat by aucune horloge pour entretenir le signal d’activité

Can't render this file because it contains an unexpected character in line 2 and column 73.

View File

@ -1588,6 +1588,37 @@
New Bristle Brush Nuovo pennello a setole
Calligraphic Brush Options Opzioni pennello calligrafico
Bristle Brush Options Opzioni pennello a setole
Scatter Brush Options Opzioni pennello a dispersione
Art Brush Options Opzioni pennello artistico
Pattern Brush Options Opzioni pennello motivo
Scatter: Dispersione:
Rotation: Rotazione:
Rotation relative to: Rotazione relativa a:
Brush Scale Options Opzioni di scala del pennello
Scale Proportionately Ridimensiona proporzionalmente
Stretch to Fit Stroke Length Allunga per adattare alla lunghezza del tratto
Stretch Between Guides Allunga tra le guide
Start: Inizio:
End: Fine:
Left to Right Da sinistra a destra
Right to Left Da destra a sinistra
Top to Bottom Dall'alto in basso
Bottom to Top Dal basso in alto
Flip: Capovolgi:
Outer Corner Tile Tassello angolo esterno
Side Tile Tassello laterale
Inner Corner Tile Tassello angolo interno
Start Tile Tassello iniziale
End Tile Tassello finale
Fit Adattamento
Stretch to fit Allunga per adattare
Add space to fit Aggiungi spazio per adattare
Approximate path Approssima tracciato
Colorization Colorazione
Tints Tinte
Tints and Shades Tinte e ombre
Hue Shift Variazione tonalità
Key Color: Colore chiave:
Roundness: Rotondità:
Fixed Fisso
Pressure Pressione
@ -3502,7 +3533,6 @@ axis Both Entrambi
@msg no bounds nessun limite
@msg no brush named `{name}` nessun pennello denominato `{name}`
@msg no brush selected nessun pennello selezionato
@msg this kind of brush has no options dialog yet questo tipo di pennello non ha ancora una finestra di opzioni
@msg no catalog nessun catalogo
@msg no changes since the last save nessuna modifica dall'ultimo salvataggio
@msg no clock to keep a heartbeat by nessun orologio con cui mantenere il segnale di attività

Can't render this file because it contains an unexpected character in line 2 and column 73.

View File

@ -1681,6 +1681,37 @@
New Bristle Brush 新規剛毛ブラシ
Calligraphic Brush Options 絵筆ブラシオプション
Bristle Brush Options 剛毛ブラシオプション
Scatter Brush Options 散布ブラシオプション
Art Brush Options アートブラシオプション
Pattern Brush Options パターンブラシオプション
Scatter: 散布:
Rotation: 回転:
Rotation relative to: 回転の基準:
Brush Scale Options ブラシ伸縮オプション
Scale Proportionately 縦横比を保持して拡大・縮小
Stretch to Fit Stroke Length ストロークの長さに合わせて伸縮
Stretch Between Guides ガイド間で伸縮
Start: 始点:
End: 終点:
Left to Right 左から右
Right to Left 右から左
Top to Bottom 上から下
Bottom to Top 下から上
Flip: 反転:
Outer Corner Tile 外角タイル
Side Tile サイドタイル
Inner Corner Tile 内角タイル
Start Tile 始点タイル
End Tile 終点タイル
Fit 調整
Stretch to fit 伸縮して合わせる
Add space to fit 間隔を空けて合わせる
Approximate path パスに近似
Colorization 着色
Tints 淡彩
Tints and Shades 淡彩と陰影
Hue Shift 色相シフト
Key Color: キーカラー:
Roundness: 真円率:
Fixed 固定
Pressure 筆圧
@ -3317,7 +3348,6 @@ theme Appearance 外観
@msg Expand: no brush strokes selected 拡張: ブラシストロークが選択されていません
@msg no brush named `{name}` `{name}` という名前のブラシが見つかりません
@msg no brush selected ブラシが選択されていません
@msg this kind of brush has no options dialog yet この種類のブラシにはまだオプションダイアログがありません
@msg no symbol instances selected シンボルインスタンスが選択されていません
@msg select a symbol instance シンボルインスタンスを選択してください
@msg the art has no bounds アートに境界がありません

Can't render this file because it contains an unexpected character in line 2 and column 73.

View File

@ -1584,6 +1584,37 @@
New Bristle Brush Новая щетинная кисть
Calligraphic Brush Options Параметры каллиграфической кисти
Bristle Brush Options Параметры щетинной кисти
Scatter Brush Options Параметры кисти-распыления
Art Brush Options Параметры художественной кисти
Pattern Brush Options Параметры узорной кисти
Scatter: Рассеивание:
Rotation: Поворот:
Rotation relative to: Поворот относительно:
Brush Scale Options Параметры масштабирования кисти
Scale Proportionately Масштабировать пропорционально
Stretch to Fit Stroke Length Растянуть по длине обводки
Stretch Between Guides Растянуть между направляющими
Start: Начало:
End: Конец:
Left to Right Слева направо
Right to Left Справа налево
Top to Bottom Сверху вниз
Bottom to Top Снизу вверх
Flip: Отразить:
Outer Corner Tile Фрагмент внешнего угла
Side Tile Боковой фрагмент
Inner Corner Tile Фрагмент внутреннего угла
Start Tile Начальный фрагмент
End Tile Конечный фрагмент
Fit Подгонка
Stretch to fit Растянуть для подгонки
Add space to fit Добавить интервалы для подгонки
Approximate path Приблизить к контуру
Colorization Перекрашивание
Tints Оттенки
Tints and Shades Оттенки и тени
Hue Shift Сдвиг тона
Key Color: Ключевой цвет:
Roundness: Округлость:
Fixed Фиксированный
Pressure Нажим
@ -3498,7 +3529,6 @@ axis Both Оба
@msg no bounds нет границ
@msg no brush named `{name}` нет кисти с именем `{name}`
@msg no brush selected кисть не выбрана
@msg this kind of brush has no options dialog yet у кисти этого типа пока нет окна параметров
@msg no catalog нет каталога
@msg no changes since the last save нет изменений с момента последнего сохранения
@msg no clock to keep a heartbeat by нет часов для поддержания сигнала

Can't render this file because it contains an unexpected character in line 2 and column 73.

View File

@ -1580,6 +1580,37 @@
New Bristle Brush Новий щетинний пензель
Calligraphic Brush Options Параметри каліграфічного пензля
Bristle Brush Options Параметри щетинного пензля
Scatter Brush Options Параметри пензля-розпорошення
Art Brush Options Параметри художнього пензля
Pattern Brush Options Параметри візерункового пензля
Scatter: Розсіювання:
Rotation: Поворот:
Rotation relative to: Поворот відносно:
Brush Scale Options Параметри масштабування пензля
Scale Proportionately Масштабувати пропорційно
Stretch to Fit Stroke Length Розтягнути за довжиною обведення
Stretch Between Guides Розтягнути між напрямними
Start: Початок:
End: Кінець:
Left to Right Зліва направо
Right to Left Справа наліво
Top to Bottom Згори вниз
Bottom to Top Знизу вгору
Flip: Відобразити:
Outer Corner Tile Фрагмент зовнішнього кута
Side Tile Бічний фрагмент
Inner Corner Tile Фрагмент внутрішнього кута
Start Tile Початковий фрагмент
End Tile Кінцевий фрагмент
Fit Припасування
Stretch to fit Розтягнути для припасування
Add space to fit Додати інтервали для припасування
Approximate path Наблизити до контуру
Colorization Перефарбування
Tints Відтінки
Tints and Shades Відтінки й тіні
Hue Shift Зсув тону
Key Color: Ключовий колір:
Roundness: Округлість:
Fixed Фіксований
Pressure Натиск
@ -3485,7 +3516,6 @@ axis Both Обидві
@msg no bounds немає кордонів
@msg no brush named `{name}` немає пензля з ім’ям `{name}`
@msg no brush selected пензель не вибрано
@msg this kind of brush has no options dialog yet пензель цього типу ще не має вікна параметрів
@msg no catalog немає каталогу
@msg no changes since the last save немає змін з моменту останнього збереження
@msg no clock to keep a heartbeat by немає годинника для підтримки сигналу

Can't render this file because it has a wrong number of fields in line 13.

View File

@ -1519,6 +1519,34 @@
New Bristle Brush 新建鬃毛画笔
Calligraphic Brush Options 书法画笔选项
Bristle Brush Options 鬃毛画笔选项
Scatter Brush Options 散布画笔选项
Art Brush Options 艺术画笔选项
Pattern Brush Options 图案画笔选项
Scatter: 散布:
Rotation: 旋转:
Rotation relative to: 旋转相对于:
Brush Scale Options 画笔缩放选项
Scale Proportionately 等比缩放
Stretch to Fit Stroke Length 伸展以适合描边长度
Stretch Between Guides 在参考线之间伸展
Start: 起点:
End: 终点:
Left to Right 从左到右
Right to Left 从右到左
Top to Bottom 从上到下
Bottom to Top 从下到上
Flip: 翻转:
Outer Corner Tile 外角拼贴
Side Tile 边线拼贴
Inner Corner Tile 内角拼贴
Start Tile 起点拼贴
End Tile 终点拼贴
Fit 适合
Stretch to fit 伸展以适合
Add space to fit 添加间距以适合
Approximate path 近似路径
Colorization 着色
Key Color: 主色:
Roundness: 圆度:
Fixed 固定
Pressure 压力

Can't render this file because it contains an unexpected character in line 2 and column 73.

View File

@ -1563,6 +1563,37 @@
New Bristle Brush 新增鬃毛筆刷
Calligraphic Brush Options 書法筆刷選項
Bristle Brush Options 鬃毛筆刷選項
Scatter Brush Options 散佈筆刷選項
Art Brush Options 藝術筆刷選項
Pattern Brush Options 圖樣筆刷選項
Scatter: 散佈:
Rotation: 旋轉:
Rotation relative to: 旋轉相對於:
Brush Scale Options 筆刷縮放選項
Scale Proportionately 等比例縮放
Stretch to Fit Stroke Length 延伸以符合筆畫長度
Stretch Between Guides 在參考線之間延伸
Start: 起點:
End: 終點:
Left to Right 由左至右
Right to Left 由右至左
Top to Bottom 由上而下
Bottom to Top 由下而上
Flip: 翻轉:
Outer Corner Tile 外角拼貼
Side Tile 邊線拼貼
Inner Corner Tile 內角拼貼
Start Tile 起點拼貼
End Tile 終點拼貼
Fit 符合
Stretch to fit 延伸以符合
Add space to fit 增加間距以符合
Approximate path 近似路徑
Colorization 上色
Tints 淺色
Tints and Shades 淺色與暗色
Hue Shift 色相偏移
Key Color: 主要顏色:
Roundness: 圓度:
Fixed 固定
Pressure 壓力

Can't render this file because it contains an unexpected character in line 6 and column 47.

View File

@ -346,7 +346,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[
"ui.brushOptions",
"Brush Options…",
"",
"{name?} (default: the selected path's brush, else the current brush) open its Brush Options, what double-clicking a brush in the Brushes panel opens: Calligraphic (dialog `brushOptions`, fields name, angle −180..180, roundness 0..100, size 0..1296 pt, angleMode/roundnessMode/sizeMode: fixed|random|pressure, angleVariation/roundnessVariation/sizeVariation) or Bristle (fields name, shape, size, length, density, thickness, opacity, stiffness); OK runs brush.options. Other brush types: error",
"{name?} (default: the selected path's brush, else the current brush) open its Brush Options, what double-clicking a brush in the Brushes panel opens (dialog `brushOptions`): Calligraphic (fields name, angle −180..180, roundness 0..100, size 0..1296 pt, angleMode/roundnessMode/sizeMode: fixed|random|pressure, angleVariation/roundnessVariation/sizeVariation), Bristle (fields name, shape, size, length, density, thickness, opacity, stiffness), Scatter (fields sizeMin/sizeMax/sizeMode, spacingMin/spacingMax/spacingMode, scatterMin/scatterMax/scatterMode, rotationMin/rotationMax/rotationMode with modes fixed|random|pressure, rotationRelativeTo: page|path), Art (fields width %, scaleMode: proportional|stretch|betweenGuides, guideStart/guideEnd % of the art length, direction: leftToRight|rightToLeft|topToBottom|bottomToTop, flipAlong, flipAcross) or Pattern (fields scale %, spacing %, fit: stretch|addSpace|approximate, flipAlong, flipAcross); Scatter, Art and Pattern also colorization: none|tints|tintsAndShades|hueShift and keyColor; OK runs brush.options",
),
(
"ui.graphicStyleOptions",

View File

@ -89,7 +89,6 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
if group.is_empty() {
continue;
}
let has_options = brush_options::TYPES.contains(&ty);
let row = |ui: &mut Ui, name: &str, size: egui::Vec2, label: bool| -> egui::Response {
let (r, resp) = ui.allocate_exact_size(size, Sense::click_and_drag());
// Dragged onto a path, the brush is applied to it.
@ -121,7 +120,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
};
// A click applies the brush; the second click of a double-click opens its options.
let mut pick = |resp: egui::Response, name: &String| {
if resp.double_clicked() && has_options {
if resp.double_clicked() {
options = Some(name.clone());
} else if resp.clicked() {
clicked = Some(name.clone());