mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-31 05:07:51 -06:00
Merge pull request #948 from storytold/feat/brush-options-scatter-art-pattern
Part of gap 5: Scatter, Art and Pattern Brush Options
This commit is contained in:
+9
-7
File diff suppressed because one or more lines are too long
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
+167
-10
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)?;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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})),
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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 contains an unexpected character in line 659 and column 40.
|
@@ -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.
|
@@ -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.
|
@@ -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",
|
||||
|
||||
@@ -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());
|
||||
|
||||
Reference in New Issue
Block a user