Merge pull request #906 from storytold/feat/852-pressure-brushes

Part of #852: Brush Options for Calligraphic and Bristle brushes, pen pressure
This commit is contained in:
BFlat 2026-10-10 12:38:10 +07:00 committed by GitHub
commit ba2fc64d89
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
29 changed files with 1333 additions and 82 deletions

1
Cargo.lock generated
View File

@ -4741,6 +4741,7 @@ dependencies = [
"serde",
"serde_json",
"toml",
"vectorcraft-brush",
"vectorcraft-cad",
"vectorcraft-color",
"vectorcraft-doc",

File diff suppressed because one or more lines are too long

View File

@ -4,13 +4,21 @@
//! in the directions normal to the path. For nib axes `u`, `v` with semi-axes `a`, `b` the
//! support point in direction `d` is `(a²(u·d)u + b²(v·d)v) / √(a²(u·d)² + b²(v·d)²)`, so the
//! stroke's local width across direction `n` is `2·√(a²(u·n)² + b²(v·n)²)`.
//!
//! A Calligraphic brush with Pressure variation changes its nib along the stroke with the pen
//! pressure recorded on it (`StrokeLayer::pressure`): the sweep is sampled densely and each
//! sample takes the nib for the pressure there.
use vectorcraft_color::Paint;
use vectorcraft_doc::Node;
use vectorcraft_doc::{Node, PressureProfile, StrokeLayer};
use vectorcraft_geom::{BezPath, PathData, Point, SubPath, Vec2};
use crate::track::{Rng, Track, normal, seed_of, tracks};
use crate::{Bristle, BristleShape, Calligraphic, filled, tolerance};
use crate::{Bristle, BristleShape, Calligraphic, Variation, filled, tolerance};
/// The most pieces a varying nib cuts one segment of a subpath into, and about how many samples
/// it takes along a whole subpath.
const MAX_STEPS: f64 = 1024.0;
/// An elliptical nib.
#[derive(Clone, Copy, Debug)]
@ -43,39 +51,53 @@ fn rot(base: Vec2, angle: f64) -> Vec2 {
base * angle.cos() + normal(base) * angle.sin()
}
/// Samples (point, tangent) along a track, with extra tangents fanned in at corners so the
/// outer edge of the sweep follows the nib around the corner.
fn samples(t: &Track) -> Vec<(Point, Vec2)> {
/// Samples (point, tangent, fraction of the track's length) along a track, with extra tangents
/// fanned in at corners so the outer edge of the sweep follows the nib around the corner. `step`
/// (for a nib that varies along the track): segments are cut into pieces at most that long.
fn samples(t: &Track, step: Option<f64>) -> Vec<(Point, Vec2, f64)> {
let mut out = vec![];
let n = t.segments();
let len = t.len().max(1e-12);
for i in 0..=n {
let p = t.verts[i];
let f = t.cum[i] / len;
if t.corner[i] {
let (a, b) = t.in_out(i);
let turn = a.cross(b).atan2(a.dot(b));
let steps = (turn.abs() / 10f64.to_radians()).ceil().max(1.0) as usize;
for k in 0..=steps {
out.push((p, rot(a, turn * k as f64 / steps as f64)));
out.push((p, rot(a, turn * k as f64 / steps as f64), f));
}
} else {
out.push((p, t.tangents[i]));
out.push((p, t.tangents[i], f));
}
if let Some(step) = step
&& i < n
{
let seg = t.cum[i + 1] - t.cum[i];
let pieces = (seg / step).ceil().clamp(1.0, MAX_STEPS) as usize;
for k in 1..pieces {
let u = k as f64 / pieces as f64;
out.push((p.lerp(t.verts[i + 1], u), t.dirs[i], (t.cum[i] + seg * u) / len));
}
}
}
out
}
/// The swept outline of `nib` along `t` as closed subpaths.
pub(crate) fn sweep(t: &Track, nib: &Nib) -> Vec<SubPath> {
let s = samples(t);
let left: Vec<Point> = s.iter().map(|(p, tg)| *p + nib.support(normal(*tg))).collect();
let right: Vec<Point> = s.iter().map(|(p, tg)| *p + nib.support(-normal(*tg))).collect();
/// The swept outline along `t` of the nib `nib(f)` (`f`: the fraction of the track's length) as
/// closed subpaths. `step`: how often a varying nib is sampled (None: a constant nib).
pub(crate) fn sweep(t: &Track, nib: &dyn Fn(f64) -> Nib, step: Option<f64>) -> Vec<SubPath> {
let s = samples(t, step);
let left: Vec<Point> = s.iter().map(|(p, tg, f)| *p + nib(*f).support(normal(*tg))).collect();
let right: Vec<Point> = s.iter().map(|(p, tg, f)| *p + nib(*f).support(-normal(*tg))).collect();
if t.closed {
let mut r = right;
r.reverse();
return vec![SubPath::polyline(&left, true), SubPath::polyline(&r, true)];
}
let cap = |p: Point, tg: Vec2, from: f64, to: f64, out: &mut Vec<Point>| {
let steps = 16;
let cap = |(p, tg, f): (Point, Vec2, f64), from: f64, to: f64, out: &mut Vec<Point>| {
let (steps, nib) = (16, nib(f));
for k in 1..steps {
let ang = from + (to - from) * k as f64 / steps as f64;
out.push(p + nib.support(rot(tg, ang)));
@ -83,31 +105,66 @@ pub(crate) fn sweep(t: &Track, nib: &Nib) -> Vec<SubPath> {
};
let half = std::f64::consts::FRAC_PI_2;
let mut poly = left.clone();
let (Some(&(pe, te)), Some(&(ps, ts))) = (s.last(), s.first()) else { return Vec::new() };
cap(pe, te, half, -half, &mut poly);
let (Some(&end), Some(&start)) = (s.last(), s.first()) else { return Vec::new() };
cap(end, half, -half, &mut poly);
poly.extend(right.iter().rev());
cap(ps, ts, -half, -3.0 * half, &mut poly);
cap(start, -half, -3.0 * half, &mut poly);
vec![SubPath::polyline(&poly, true)]
}
fn randomized(c: &Calligraphic, rng: &mut Rng) -> (f64, f64, f64) {
let [va, vr, vs] = c.variation;
let angle = c.angle + rng.range(-va, va);
let round = (c.roundness + rng.range(-vr, vr)).clamp(1.0, 100.0);
let size = (c.size + rng.range(-vs, vs)).max(0.05);
(angle, round, size)
/// The stroke's angle, roundness and size: Random ones drawn for this stroke (from `rng`), the
/// others the brush's values.
fn randomized(c: &Calligraphic, rng: &mut Rng) -> [f64; 3] {
let mut out = [c.angle, c.roundness, c.size];
for ((v, var), mode) in out.iter_mut().zip(c.variation).zip(c.modes()) {
// Every value draws, so a stroke's random values stay the same whatever the other modes.
let r = rng.range(-var, var);
if mode == Variation::Random {
*v += r;
}
}
let [angle, round, size] = out;
[angle, round.clamp(1.0, 100.0), size.max(0.05)]
}
pub(crate) fn calligraphic(c: &Calligraphic, bp: &BezPath, weight: f64, paint: &Paint, name: &str) -> Vec<Node> {
/// The nib's angle, roundness and size at pen pressure `p` (0..1): each Pressure value goes from
/// the brush's value less its variation (lightest) to the value plus the variation (heaviest),
/// kept within its range; the others are `base`'s.
fn pressured(c: &Calligraphic, base: [f64; 3], p: f64) -> [f64; 3] {
let values = [c.angle, c.roundness, c.size];
let mut out = base;
for (i, (v, mode)) in out.iter_mut().zip(c.modes()).enumerate() {
let (Some(&value), Some(&var), Some(&(lo, hi))) = (values.get(i), c.variation.get(i), Calligraphic::RANGES.get(i)) else { continue };
if mode == Variation::Pressure {
let x = value + var * (2.0 * p.clamp(0.0, 1.0) - 1.0);
*v = if x.is_finite() { x.clamp(lo, hi) } else { value.clamp(lo, hi) };
}
}
let [angle, round, size] = out;
[angle, round.clamp(1.0, 100.0), size.max(0.05)]
}
pub(crate) fn calligraphic(c: &Calligraphic, bp: &BezPath, weight: f64, stroke: &StrokeLayer, name: &str) -> Vec<Node> {
let mut rng = Rng::new(seed_of(bp, name));
let (angle, round, size) = randomized(c, &mut rng);
let size = size * weight;
let nib = Nib::new(angle, round / 100.0, size);
let subs: Vec<SubPath> = tracks(bp, tolerance(size)).iter().flat_map(|t| sweep(t, &nib)).collect();
let base = randomized(c, &mut rng);
let subs: Vec<SubPath> = if c.uses_pressure() {
let pressure = stroke.pressure.as_ref();
let nib = |f: f64| {
let [angle, round, size] = pressured(c, base, pressure.map_or(PressureProfile::MID, |p| p.at(f)));
Nib::new(angle, round / 100.0, size * weight)
};
// Flattened for the largest nib the pressure gives.
let size = pressured(c, base, 0.0)[2].max(pressured(c, base, 1.0)[2]) * weight;
tracks(bp, tolerance(size)).iter().flat_map(|t| sweep(t, &nib, Some((t.len() / MAX_STEPS).max(0.25)))).collect()
} else {
let [angle, round, size] = base;
let nib = Nib::new(angle, round / 100.0, size * weight);
tracks(bp, tolerance(size * weight)).iter().flat_map(|t| sweep(t, &|_| nib, None)).collect()
};
if subs.is_empty() {
return vec![];
}
vec![filled(PathData::new(subs), paint)]
vec![filled(PathData::new(subs), &stroke.paint)]
}
/// Offset a track sideways by `d` (a polyline following it).
@ -165,7 +222,7 @@ pub(crate) fn bristle(b: &Bristle, bp: &BezPath, weight: f64, paint: &Paint, nam
let Some(sp) = offset_track(&t, off, t0.min(l * 0.3), t1.min(l * 0.3)) else { continue };
let w = thick * (0.6 + 0.4 * edge) * rng.range(0.8, 1.2);
let nib = Nib::new(0.0, 1.0, w);
let subs: Vec<SubPath> = tracks(&sp, tolerance(w)).iter().flat_map(|tt| sweep(tt, &nib)).collect();
let subs: Vec<SubPath> = tracks(&sp, tolerance(w)).iter().flat_map(|tt| sweep(tt, &|_| nib, None)).collect();
if subs.is_empty() {
continue;
}

View File

@ -132,7 +132,7 @@ fn diamond_tile() -> Node {
}
fn calli(name: &str, angle: f64, roundness: f64, size: f64) -> Brush {
Brush { name: name.into(), kind: BrushKind::Calligraphic(Calligraphic { angle, roundness, size, variation: [0.0; 3] }) }
Brush { name: name.into(), kind: BrushKind::Calligraphic(Calligraphic { angle, roundness, size, ..Default::default() }) }
}
/// The default library.

View File

@ -99,13 +99,69 @@ pub struct Calligraphic {
pub roundness: f64,
/// Nib diameter in points.
pub size: f64,
/// Random variation per stroke: angle (°), roundness (%), size (pt).
/// How far angle (°), roundness (%) and size (pt) vary each way, as [`Self::modes`] say.
pub variation: [f64; 3],
/// How angle, roundness and size vary. `None` (brushes saved before there were modes): Random
/// where the variation is non-zero, else Fixed — see [`Calligraphic::modes`].
#[serde(skip_serializing_if = "Option::is_none")]
pub modes: Option<[Variation; 3]>,
}
impl Default for Calligraphic {
fn default() -> Self {
Self { angle: 0.0, roundness: 100.0, size: 3.0, variation: [0.0; 3] }
Self { angle: 0.0, roundness: 100.0, size: 3.0, variation: [0.0; 3], modes: None }
}
}
impl Calligraphic {
/// The ranges of angle (°), roundness (%) and size (pt), and the most each varies.
pub const RANGES: [(f64, f64); 3] = [(-180.0, 180.0), (0.0, 100.0), (0.0, 1296.0)];
pub const MAX_VARIATION: [f64; 3] = [180.0, 100.0, 1296.0];
/// How angle, roundness and size vary.
pub fn modes(&self) -> [Variation; 3] {
self.modes.unwrap_or_else(|| self.variation.map(|v| if v != 0.0 { Variation::Random } else { Variation::Fixed }))
}
/// Does the pen's pressure change the nib?
pub fn uses_pressure(&self) -> bool {
self.modes().contains(&Variation::Pressure)
}
}
/// How a Calligraphic brush's angle, roundness or size varies (Brush Options). Saved by name: new
/// modes are appended, never renamed.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum Variation {
/// Always the value.
#[default]
Fixed,
/// A random value within the variation either side, new for each stroke.
Random,
/// From the value less the variation at the lightest pen pressure to the value plus the
/// variation at the heaviest.
Pressure,
}
impl Variation {
pub const ALL: [Variation; 3] = [Variation::Fixed, Variation::Random, Variation::Pressure];
/// The name brush definitions use.
pub fn id(self) -> &'static str {
match self {
Variation::Fixed => "fixed",
Variation::Random => "random",
Variation::Pressure => "pressure",
}
}
pub fn label(self) -> &'static str {
match self {
Variation::Fixed => "Fixed",
Variation::Random => "Random",
Variation::Pressure => "Pressure",
}
}
pub fn parse(id: &str) -> Option<Self> {
Self::ALL.into_iter().find(|v| v.id() == id)
}
}
@ -345,7 +401,7 @@ fn tolerance(scale: f64) -> f64 {
pub fn stroke_pieces(brush: &Brush, bp: &BezPath, stroke: &StrokeLayer) -> Vec<Node> {
let w = if stroke.width > 0.0 { stroke.width } else { 1.0 };
let mut out = match &brush.kind {
BrushKind::Calligraphic(c) => calli::calligraphic(c, bp, w, &stroke.paint, &brush.name),
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),

View File

@ -16,7 +16,7 @@ fn stroke(w: f64) -> StrokeLayer {
}
fn calli(angle: f64, roundness: f64, size: f64) -> Brush {
Brush { name: "c".into(), kind: BrushKind::Calligraphic(Calligraphic { angle, roundness, size, variation: [0.0; 3] }) }
Brush { name: "c".into(), kind: BrushKind::Calligraphic(Calligraphic { angle, roundness, size, ..Default::default() }) }
}
fn bounds(nodes: &[Node]) -> Rect {
@ -334,3 +334,116 @@ fn expand_keeps_fill_and_adds_pieces() {
let plain = Node::path(NodeId(9), shapes::rectangle(Rect::new(0.0, 0.0, 1.0, 1.0)), Appearance::default_art());
assert!(expand(&d, &plain).is_none());
}
/// A Calligraphic brush (round nib) whose size follows the pen pressure by `var` either way.
fn pressure_calli(size: f64, var: f64) -> Brush {
let c = Calligraphic {
size,
variation: [0.0, 0.0, var],
modes: Some([Variation::Fixed, Variation::Fixed, Variation::Pressure]),
..Default::default()
};
Brush { name: "p".into(), kind: BrushKind::Calligraphic(c) }
}
/// The stroke's half-width (largest |y|) among outline points with x in `x0..x1`.
fn half_width(nodes: &[Node], x0: f64, x1: f64) -> f64 {
let mut pts = vec![];
for n in nodes {
all_points(n, &mut pts);
}
pts.iter().filter(|p| p.x >= x0 && p.x <= x1).map(|p| p.y.abs()).fold(0.0, f64::max)
}
fn pressed(points: Vec<(f64, f64)>) -> StrokeLayer {
StrokeLayer { pressure: Some(vectorcraft_doc::PressureProfile { points }), ..stroke(1.0) }
}
/// #852: with Size on Pressure the nib goes from size − variation at the lightest pressure to
/// size + variation at the heaviest, along the stroke where the pen pressed.
#[test]
fn pressure_maps_to_size_along_the_stroke() {
let b = pressure_calli(10.0, 4.0);
let light_to_heavy = pressed(vec![(0.0, 0.0), (1.0, 1.0)]);
let out = stroke_pieces(&b, &line(0.0, 0.0, 100.0, 0.0), &light_to_heavy);
// Lightest: 6 pt (half 3); heaviest: 14 pt (half 7); in between the pressure there.
assert!(near(half_width(&out, 0.5, 1.5), 3.0, 0.1), "{}", half_width(&out, 0.5, 1.5));
assert!(near(half_width(&out, 98.5, 99.5), 7.0, 0.1), "{}", half_width(&out, 98.5, 99.5));
assert!(near(half_width(&out, 49.5, 50.5), 5.0, 0.1), "{}", half_width(&out, 49.5, 50.5));
// The stroke weight scales it.
let heavy = StrokeLayer { width: 2.0, ..light_to_heavy };
let out = stroke_pieces(&b, &line(0.0, 0.0, 100.0, 0.0), &heavy);
assert!(near(half_width(&out, 98.5, 99.5), 14.0, 0.2));
// No pressure recorded (a mouse, or a path the brush was applied to): the size itself.
let out = stroke_pieces(&b, &line(0.0, 0.0, 100.0, 0.0), &stroke(1.0));
assert!(near(bounds(&out).height(), 10.0, 0.05), "{:?}", bounds(&out));
// Kept within the valid range: 2 ± 4 never goes below nothing.
let out = stroke_pieces(&pressure_calli(2.0, 4.0), &line(0.0, 0.0, 100.0, 0.0), &pressed(vec![(0.0, 0.0), (1.0, 0.0)]));
assert!(!out.is_empty() && bounds(&out).height() < 0.2, "{:?}", bounds(&out));
}
/// Angle and roundness follow the pressure the same way, clamped to their ranges.
#[test]
fn pressure_varies_angle_and_roundness() {
let c = |modes| Calligraphic { angle: 0.0, roundness: 60.0, size: 10.0, variation: [90.0, 60.0, 0.0], modes: Some(modes) };
let horizontal = line(0.0, 0.0, 100.0, 0.0);
let draw = |c: Calligraphic, p: f64| {
let b = Brush { name: "a".into(), kind: BrushKind::Calligraphic(c) };
bounds(&stroke_pieces(&b, &horizontal, &pressed(vec![(0.0, p), (1.0, p)]))).height()
};
use Variation::{Fixed, Pressure};
// Angle 0 ± 90: heavy pressure stands the flat nib up across the stroke.
assert!(near(draw(c([Pressure, Fixed, Fixed]), 0.5), 6.0, 0.05));
assert!(near(draw(c([Pressure, Fixed, Fixed]), 1.0), 10.0, 0.05));
// Roundness 60 ± 60, kept within 1..100: light is a hairline, heavy is round.
let vertical = Calligraphic { angle: 90.0, ..c([Fixed, Pressure, Fixed]) };
let wide = |p| {
let b = Brush { name: "r".into(), kind: BrushKind::Calligraphic(vertical.clone()) };
bounds(&stroke_pieces(&b, &line(0.0, 0.0, 0.0, 100.0), &pressed(vec![(0.0, p), (1.0, p)]))).width()
};
assert!(near(wide(0.0), 0.1, 0.05), "{}", wide(0.0));
assert!(near(wide(1.0), 10.0, 0.05), "{}", wide(1.0));
}
/// Brushes saved before the variation modes keep their look: a variation means Random, none
/// means Fixed, and they are written back without modes.
#[test]
fn brushes_without_modes_vary_randomly_as_before() {
let old: Brush = serde_json::from_value(serde_json::json!({"name": "x", "type": "calligraphic", "size": 8, "variation": [0, 0, 3]})).unwrap();
let BrushKind::Calligraphic(c) = &old.kind else { panic!() };
assert_eq!(c.modes, None);
assert_eq!(c.modes(), [Variation::Fixed, Variation::Fixed, Variation::Random]);
assert!(!c.uses_pressure());
assert!(serde_json::to_value(&old).unwrap().get("modes").is_none(), "written back as it was");
// The same strokes as the brush with its modes spelled out.
let explicit = Brush {
kind: BrushKind::Calligraphic(Calligraphic { modes: Some([Variation::Fixed, Variation::Fixed, Variation::Random]), ..c.clone() }),
..old.clone()
};
let bp = preview_path(120.0, 24.0);
assert_eq!(stroke_pieces(&old, &bp, &stroke(1.0)), stroke_pieces(&explicit, &bp, &stroke(1.0)));
// Fixed ignores the variation; Random still draws a different size per stroke.
let fixed = Brush { kind: BrushKind::Calligraphic(Calligraphic { modes: Some([Variation::Fixed; 3]), ..c.clone() }), ..old.clone() };
assert!(near(bounds(&stroke_pieces(&fixed, &line(0.0, 0.0, 100.0, 0.0), &stroke(1.0))).height(), 8.0, 0.05));
// Recorded pressure changes nothing for a brush that doesn't use it.
assert_eq!(stroke_pieces(&old, &bp, &stroke(1.0)), stroke_pieces(&old, &bp, &pressed(vec![(0.0, 0.0), (1.0, 1.0)])));
// Modes are saved by name.
assert_eq!(serde_json::to_value(Variation::ALL).unwrap(), serde_json::json!(["fixed", "random", "pressure"]));
assert!(Variation::ALL.iter().all(|v| Variation::parse(v.id()) == Some(*v)));
}
/// Junk definitions and pressure never panic and never draw non-finite art.
#[test]
fn junk_pressure_brushes_draw_finite_art() {
let bp = preview_path(120.0, 24.0);
for (size, var) in [(1e308, 1e308), (-1e308, 1e308), (0.0, -1e308), (f64::MAX, f64::MAX)] {
for profile in [vec![], vec![(0.0, 2.0), (0.0, -1.0), (5.0, 0.5)], vec![(1.0, 1.0), (0.0, 0.0)]] {
let out = stroke_pieces(&pressure_calli(size, var), &bp, &pressed(profile));
let mut pts = vec![];
for n in &out {
all_points(n, &mut pts);
}
assert!(pts.iter().all(|p| p.x.is_finite() && p.y.is_finite()), "{size} {var}");
}
}
}

View File

@ -293,6 +293,129 @@ impl WidthProfile {
}
}
/// Pen pressure recorded along a stroke (the Paintbrush with a graphics tablet): (position 0..1
/// along the path, pressure 0..1) samples in order, positions measured as a [`WidthProfile`]'s
/// are. Calligraphic brushes with Pressure variation read it; everything else ignores it.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
pub struct PressureProfile {
pub points: Vec<(f64, f64)>,
}
impl PressureProfile {
/// The pressure of a stroke drawn without one (a mouse, or a path the brush was applied to):
/// half way, so a Pressure brush draws its own value there.
pub const MID: f64 = 0.5;
/// The most samples a stroke keeps (after simplifying them).
pub const MAX_POINTS: usize = 512;
/// How far (in pressure) simplifying may move the recorded curve.
const TOLERANCE: f64 = 0.01;
/// The pressure at `t` (0..1), linear between samples; [`Self::MID`] without any.
pub fn at(&self, t: f64) -> f64 {
let p = &self.points;
let (Some(first), Some(last)) = (p.first(), p.last()) else { return Self::MID };
let v = if t <= first.0 {
first.1
} else {
p.windows(2)
.find_map(|w| match w {
[a, b] if t <= b.0 => Some(a.1 + (b.1 - a.1) * ((t - a.0) / (b.0 - a.0).max(1e-9)).clamp(0.0, 1.0)),
_ => None,
})
.unwrap_or(last.1)
};
if v.is_finite() { v.clamp(0.0, 1.0) } else { Self::MID }
}
/// A profile from (position, pressure) samples in order along the stroke: values are clamped
/// into 0..1 (non-finite ones dropped), the curve is simplified to within 1 % and kept to
/// [`Self::MAX_POINTS`]. `None` without a sample.
pub fn from_samples(samples: impl IntoIterator<Item = (f64, f64)>) -> Option<Self> {
let pts: Vec<(f64, f64)> =
samples.into_iter().filter(|(t, p)| t.is_finite() && p.is_finite()).map(|(t, p)| (t.clamp(0.0, 1.0), p.clamp(0.0, 1.0))).collect();
if pts.is_empty() {
return None;
}
// Simplifying is quadratic at worst: a huge stroke is thinned evenly first.
let mut pts = thin(pts, 16 * Self::MAX_POINTS);
// Positions only ever go forward.
let mut prev = 0.0;
for p in &mut pts {
p.0 = p.0.max(prev);
prev = p.0;
}
let keep = simplify_1d(&pts, Self::TOLERANCE);
let pts = thin(pts.iter().zip(keep).filter(|(_, k)| *k).map(|(p, _)| *p).collect(), Self::MAX_POINTS);
let round = |v: f64| (v * 1e4).round() / 1e4;
Some(Self { points: pts.into_iter().map(|(t, p)| (round(t), round(p))).collect() })
}
/// The pressure of a path `old_len` long (`old`; None: drawn without pressure) continued at its
/// end by a stroke `added_len` long (`added`). None when neither has pressure.
pub fn extended(old: Option<&Self>, old_len: f64, added: Option<&Self>, added_len: f64) -> Option<Self> {
if old.is_none() && added.is_none() {
return None;
}
let (old_len, added_len) = (old_len.max(0.0), added_len.max(0.0));
let total = old_len + added_len;
if !total.is_finite() || total <= 1e-9 {
return old.or(added).cloned();
}
let flat = Self { points: vec![(0.0, Self::MID), (1.0, Self::MID)] };
let (old, added) = (old.unwrap_or(&flat), added.unwrap_or(&flat));
let o = old_len / total;
let old_pts = old.points.iter().map(|&(t, p)| (t * o, p));
Self::from_samples(old_pts.chain(added.points.iter().map(|&(u, p)| (o + u * (1.0 - o), p))))
}
/// The pressure of the path reversed.
pub fn reversed(&self) -> Self {
Self { points: self.points.iter().rev().map(|&(t, p)| (1.0 - t, p)).collect() }
}
/// A light–heavy–light stroke, for previews of pressure-sensitive brushes.
pub fn sample() -> Self {
Self { points: vec![(0.0, 0.0), (0.5, 1.0), (1.0, 0.0)] }
}
}
/// At most `max` of `pts`, evenly spread (the ends kept).
fn thin(pts: Vec<(f64, f64)>, max: usize) -> Vec<(f64, f64)> {
let n = pts.len();
if n <= max || max < 2 {
return pts;
}
(0..max).filter_map(|i| pts.get(i * (n - 1) / (max - 1)).copied()).collect()
}
/// Douglas–Peucker over a function sampled at `pts`: which samples to keep so the polyline through
/// them stays within `tol` (vertically) of every sample. The ends are always kept.
fn simplify_1d(pts: &[(f64, f64)], tol: f64) -> Vec<bool> {
let mut keep = vec![false; pts.len()];
let Some(last) = pts.len().checked_sub(1) else { return keep };
for i in [0, last] {
if let Some(k) = keep.get_mut(i) {
*k = true;
}
}
let mut stack = vec![(0, last)];
while let Some((i, j)) = stack.pop() {
let (Some(&a), Some(&b)) = (pts.get(i), pts.get(j)) else { continue };
let line = |t: f64| if b.0 - a.0 > 1e-12 { a.1 + (b.1 - a.1) * (t - a.0) / (b.0 - a.0) } else { (a.1 + b.1) / 2.0 };
let far = (i + 1..j).filter_map(|k| pts.get(k).map(|q| (k, (q.1 - line(q.0)).abs()))).max_by(|x, y| x.1.total_cmp(&y.1));
if let Some((k, d)) = far
&& d > tol
{
if let Some(f) = keep.get_mut(k) {
*f = true;
}
stack.push((i, k));
stack.push((k, j));
}
}
keep
}
/// A width profile saved to the Profile list under a name (Add to Profiles), kept with the
/// preferences.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
@ -386,6 +509,11 @@ pub struct StrokeLayer {
/// How a gradient paint maps onto the stroke (within, along or across it).
#[serde(default, skip_serializing_if = "crate::skip::is_default")]
pub gradient_mode: StrokeGradientMode,
/// The pen pressure the stroke was drawn with (Paintbrush with a tablet): pressure-sensitive
/// brushes vary with it. It belongs to this path's shape, so appearances copied to other
/// art leave it behind ([`Appearance::without_pressure`]).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub pressure: Option<PressureProfile>,
}
/// How a gradient on a stroke is laid out (the Gradient panel's Stroke buttons). Saved by variant
@ -456,6 +584,7 @@ impl StrokeLayer {
arrow_align: ArrowAlign::Extend,
overprint: false,
gradient_mode: StrokeGradientMode::Within,
pressure: None,
}
}
/// Weight of the start (`end == false`) or end arrowhead: stroke weight × its scale, at least
@ -640,6 +769,15 @@ impl Appearance {
pub fn stroke_mut(&mut self) -> Option<&mut StrokeLayer> {
self.items.iter_mut().rev().find_map(|i| if let AppearanceItem::Stroke(s) = i { Some(s) } else { None })
}
/// This appearance for other art: the pen pressure its strokes were drawn with stays with them.
pub fn without_pressure(mut self) -> Self {
for it in &mut self.items {
if let AppearanceItem::Stroke(s) = it {
s.pressure = None;
}
}
self
}
pub fn fill_paint(&self) -> Paint {
self.fill().map(|f| f.paint.clone()).unwrap_or(Paint::None)
}
@ -1103,6 +1241,58 @@ mod tests {
assert!(matches!(a.items[0], AppearanceItem::Fill(_)));
}
/// #852: pen pressure is kept simplified along the stroke and read back by position.
#[test]
fn pressure_profile_samples_simplify_and_interpolate() {
// A steady ramp keeps only its ends; a bump keeps its peak.
let ramp = PressureProfile::from_samples((0..=100).map(|i| (i as f64 / 100.0, i as f64 / 100.0))).unwrap();
assert_eq!(ramp.points, vec![(0.0, 0.0), (1.0, 1.0)]);
assert!((ramp.at(0.25) - 0.25).abs() < 1e-9);
let bump = PressureProfile::from_samples([(0.0, 0.2), (0.3, 0.2), (0.5, 0.9), (0.7, 0.2), (1.0, 0.2)]).unwrap();
assert!(bump.points.contains(&(0.5, 0.9)) && (bump.at(0.4) - 0.55).abs() < 1e-9, "{bump:?}");
// Junk is clamped or dropped; positions never go back; nothing at all is no profile.
let junk = PressureProfile::from_samples([(f64::NAN, 0.5), (0.5, 7.0), (0.2, -1.0), (2.0, f64::INFINITY)]).unwrap();
assert_eq!(junk.points, vec![(0.5, 1.0), (0.5, 0.0)]);
assert!(PressureProfile::from_samples([(f64::NAN, 0.5)]).is_none());
assert_eq!(PressureProfile::default().at(0.3), PressureProfile::MID);
// A long noisy stroke stays bounded.
let noisy = PressureProfile::from_samples((0..1_000_000).map(|i| (i as f64 / 1e6, (i % 2) as f64))).unwrap();
assert!(noisy.points.len() <= PressureProfile::MAX_POINTS);
// Out-of-order points (a hand-edited file) still read within 0..1.
let odd = PressureProfile { points: vec![(1.0, 5.0), (0.0, -3.0)] };
assert!((0.0..=1.0).contains(&odd.at(0.5)));
}
#[test]
fn pressure_profile_extends_and_reverses() {
let old = PressureProfile { points: vec![(0.0, 0.0), (1.0, 1.0)] };
let added = PressureProfile { points: vec![(0.0, 1.0), (1.0, 0.0)] };
// A 30 pt path continued by 10 pt: its ramp fills the first three quarters.
let joined = PressureProfile::extended(Some(&old), 30.0, Some(&added), 10.0).unwrap();
assert!((joined.at(0.75) - 1.0).abs() < 1e-9 && (joined.at(0.375) - 0.5).abs() < 1e-9 && joined.at(1.0) == 0.0, "{joined:?}");
// A side without pressure is half way.
let half = PressureProfile::extended(None, 10.0, Some(&added), 10.0).unwrap();
assert_eq!(half.at(0.25), PressureProfile::MID);
assert!(PressureProfile::extended(None, 10.0, None, 10.0).is_none());
assert_eq!(PressureProfile::extended(Some(&old), 0.0, None, f64::NAN), Some(old.clone()));
assert_eq!(old.reversed().points, vec![(0.0, 1.0), (1.0, 0.0)]);
}
/// Strokes saved before pen pressure load as they were, and are written without it.
#[test]
fn strokes_without_pressure_round_trip_unchanged() {
let json = serde_json::to_value(StrokeLayer::new(Paint::None, 2.0)).unwrap();
assert!(json.get("pressure").is_none());
let st: StrokeLayer = serde_json::from_value(json).unwrap();
assert_eq!(st.pressure, None);
let pressed = StrokeLayer { pressure: Some(PressureProfile::sample()), ..st };
let back: StrokeLayer = serde_json::from_value(serde_json::to_value(&pressed).unwrap()).unwrap();
assert_eq!(back, pressed);
let mut ap = Appearance::basic(Paint::None, Paint::None, 1.0);
ap.stroke_mut().unwrap().pressure = Some(PressureProfile::sample());
assert_eq!(ap.without_pressure().stroke().unwrap().pressure, None);
}
#[test]
fn profile_interp() {
let p = WidthProfile::lens();

View File

@ -60,8 +60,8 @@ pub(crate) mod skip {
pub use appearance::StrokeGradientMode;
pub use appearance::{
Appearance, AppearanceItem, ArrowAlign, Arrowhead, Dash, Effect, FillLayer, LineCap, LineJoin, ProfilePreset, SavedProfile, StrokeAlign,
StrokeLayer, WidthProfile,
Appearance, AppearanceItem, ArrowAlign, Arrowhead, Dash, Effect, FillLayer, LineCap, LineJoin, PressureProfile, ProfilePreset, SavedProfile,
StrokeAlign, StrokeLayer, WidthProfile,
};
pub use assets::ExportAsset;
pub use corners::LiveCorners;

View File

@ -63,7 +63,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Brush Options…",
[],
None,
"{name, params?: {…fields to change}, newName?} edit a brush definition (strokes using it update) → {name}",
"{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}",
has_doc,
brush_options
),

View File

@ -6,7 +6,7 @@ use std::sync::Arc;
use kurbo::{ParamCurve, ParamCurveNearest};
use serde_json::{Value, json};
use vectorcraft_color::{Color, Paint};
use vectorcraft_doc::{Appearance, Document, Node, NodeId, NodeKind, Selection};
use vectorcraft_doc::{Appearance, Document, Node, NodeId, NodeKind, PressureProfile, Selection};
use vectorcraft_geom::hit::fill_contains;
use vectorcraft_geom::{Anchor, AnchorKind, BezPath, FillRule, PathData, Point, Rect, SubPath, Vec2};
use vectorcraft_pathops as po;
@ -23,7 +23,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Pencil",
[],
None,
"{points: [[x,y]…], fidelity?: pt (1.5), closed?, style?: \"pencil\"|\"brush\", fill?: bool, extend?: {id, end: \"start\"|\"end\"}} fit a freehand stroke → {id}",
"{points: [[x,y]…] or [[x,y,pressure]…] (pen pressure 0..1: the stroke keeps it, for Calligraphic brushes with Pressure variation; a point without one presses fully), fidelity?: pt (1.5), closed?, style?: \"pencil\"|\"brush\", fill?: bool, extend?: {id, end: \"start\"|\"end\"}} fit a freehand stroke → {id}",
has_doc,
freehand
),
@ -151,15 +151,21 @@ pub fn specs() -> Vec<CommandSpec> {
/// `[[x,y]…]` or `[{x,y}…]`; non-finite points are dropped.
fn points_param(p: &Value, key: &str, cmd: &str) -> Result<Vec<Point>> {
Ok(samples_param(p, key, cmd)?.into_iter().map(|(pt, _)| pt).collect())
}
/// [`points_param`] with each point's pen pressure, if it has one: `[[x,y,pressure]…]` or
/// `[{x,y,pressure}…]` (0..1).
fn samples_param(p: &Value, key: &str, cmd: &str) -> Result<Vec<(Point, Option<f64>)>> {
let a = p.get(key).and_then(Value::as_array).ok_or_else(|| bad(cmd, format!("missing array `{key}`")))?;
let pts: Vec<Point> = a
let pts: Vec<(Point, Option<f64>)> = a
.iter()
.filter_map(|v| match v {
Value::Array(xy) => Some(Point::new(xy.first()?.as_f64()?, xy.get(1)?.as_f64()?)),
Value::Object(_) => Some(Point::new(v.get("x")?.as_f64()?, v.get("y")?.as_f64()?)),
Value::Array(xy) => Some((Point::new(xy.first()?.as_f64()?, xy.get(1)?.as_f64()?), xy.get(2).and_then(Value::as_f64))),
Value::Object(_) => Some((Point::new(v.get("x")?.as_f64()?, v.get("y")?.as_f64()?), v.get("pressure").and_then(Value::as_f64))),
_ => None,
})
.filter(|p| p.x.is_finite() && p.y.is_finite())
.filter(|(p, _)| p.x.is_finite() && p.y.is_finite())
.collect();
if pts.is_empty() {
return Err(bad(cmd, "need at least one point"));
@ -167,6 +173,31 @@ fn points_param(p: &Value, key: &str, cmd: &str) -> Result<Vec<Point>> {
Ok(pts)
}
/// The pen pressure along a freehand stroke (positions measured along its polyline), when any of
/// its samples has one; samples without one count as full pressure, as a pen event without one.
fn stroke_pressure(samples: &[(Point, Option<f64>)]) -> Option<PressureProfile> {
if samples.iter().all(|(_, p)| p.is_none()) {
return None;
}
let mut at = 0.0;
let mut prev: Option<Point> = None;
let along: Vec<(f64, f64)> = samples
.iter()
.map(|(pt, p)| {
at += prev.map_or(0.0, |q| q.distance(*pt));
prev = Some(*pt);
(at, p.unwrap_or(1.0))
})
.collect();
PressureProfile::from_samples(along.into_iter().map(|(s, p)| (if at > 1e-9 { s / at } else { 0.0 }, p)))
}
/// The length of a subpath.
fn subpath_len(sp: &SubPath) -> f64 {
use kurbo::ParamCurveArclen;
PathData::single(sp.clone()).to_bezpath().segments().map(|s| s.arclen(0.01)).sum()
}
fn usize_req(p: &Value, key: &str, cmd: &str) -> Result<usize> {
p.get(key).and_then(Value::as_u64).map(|v| v as usize).ok_or_else(|| bad(cmd, format!("missing integer `{key}`")))
}
@ -501,7 +532,9 @@ fn fit_freehand(pts: &[Point], tol: f64, closed: bool) -> SubPath {
fn freehand(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "path.freehand";
let mut pts = points_param(p, "points", C)?;
let samples = samples_param(p, "points", C)?;
let pressure = stroke_pressure(&samples);
let mut pts: Vec<Point> = samples.into_iter().map(|(pt, _)| pt).collect();
let tol = f64_or(p, "fidelity", 1.5).clamp(0.05, 100.0);
let closed = bool_or(p, "closed", false);
let brush = str_param(p, "style") == Some("brush");
@ -511,6 +544,7 @@ fn freehand(s: &mut Session, p: &Value) -> Result<Value> {
let (pd, _) = path_of(&s.doc()?.doc, id)?;
let si = pd.subpaths.iter().rposition(|s| !s.closed && !s.anchors.is_empty()).ok_or_else(|| bad(C, "path has no open end"))?;
let mut sp = pd.subpaths[si].clone();
let old_len = subpath_len(&sp);
if at_start {
sp.reverse();
}
@ -547,12 +581,24 @@ fn freehand(s: &mut Session, p: &Value) -> Result<Value> {
for a in &mut sp.anchors {
*a = Anchor::with_handles(a.p, a.h_in, a.h_out);
}
// The path's pressure (in the direction it is continued in) followed by the stroke's.
let old_pressure = s.doc()?.doc.node(id).and_then(|n| n.appearance.stroke()).and_then(|st| st.pressure.clone());
let joined = (old_pressure.is_some() || pressure.is_some()).then(|| {
let old = old_pressure.map(|p| if at_start { p.reversed() } else { p });
let total = subpath_len(&sp);
PressureProfile::extended(old.as_ref(), old_len, pressure.as_ref(), total - old_len)
});
s.edit(if brush { "Paintbrush" } else { "Pencil" }, |d, sel| {
let path = path_mut(d, id)?;
if at_start && !sp.closed {
let reverse = at_start && !sp.closed;
if reverse {
sp.reverse();
}
path.subpaths[si] = sp;
path_mut(d, id)?.subpaths[si] = sp;
if let Some(joined) = joined
&& let Some(st) = d.node_mut(id).and_then(|n| n.appearance.stroke_mut())
{
st.pressure = if reverse { joined.map(|p| p.reversed()) } else { joined };
}
sel.set([id]);
Ok(())
})?;
@ -563,7 +609,10 @@ fn freehand(s: &mut Session, p: &Value) -> Result<Value> {
}
let sp = fit_freehand(&pts, tol, closed);
let fill = if bool_or(p, "fill", false) { s.paint.fill.clone() } else { Paint::None };
let look = s.new_art_look(fill, stroke_paint(s), s.paint.stroke_width.max(0.1));
let mut look = s.new_art_look(fill, stroke_paint(s), s.paint.stroke_width.max(0.1));
if let Some(st) = look.appearance.stroke_mut() {
st.pressure = pressure;
}
add_look(s, if brush { "Paintbrush" } else { "Pencil" }, path_kind(PathData::single(sp)), look, None)
}

View File

@ -215,9 +215,9 @@ fn unitized(mut ap: Appearance, from: Option<Rect>) -> Appearance {
/// The appearance a new or redefined style takes from `n`: its own. A group without fills or
/// strokes of its own lends those of its topmost painted object, and type its characters' fill
/// and stroke. Placed gradients come relative to the unit box (from the bounds of the object
/// they're on).
/// they're on). The pen pressure of strokes stays with their own path.
pub(crate) fn captured(n: &Node) -> Appearance {
let mut ap = n.appearance.clone();
let mut ap = n.appearance.clone().without_pressure();
if !ap.items.is_empty() {
return unitized(ap, n.geometric_bounds());
}

View File

@ -1,7 +1,7 @@
//! Brushes and symbols: commands, tools, rendering and persistence.
use serde_json::{Value, json};
use vectorcraft_doc::{Node, NodeKind};
use vectorcraft_doc::{Document, Node, NodeKind};
use vectorcraft_geom::Rect;
use vectorcraft_tools::{Mods, PointerEvent, PointerKind};
@ -193,6 +193,110 @@ fn paintbrush_tool_uses_the_current_brush() {
assert!(s.execute("brush.setCurrent", &json!({"name": "Nope"})).is_err());
}
/// A Size: Pressure variant of "3 pt. Round" (3 ± 3 pt).
fn pressure_round(s: &mut Session) {
let params = json!({"variation": [0, 0, 3], "modes": ["fixed", "fixed", "pressure"]});
s.execute("brush.options", &json!({"name": "3 pt. Round", "params": params})).unwrap();
}
fn pressure_of(s: &Session, id: NodeId) -> Option<vectorcraft_doc::PressureProfile> {
node(s, id).appearance.stroke().and_then(|st| st.pressure.clone())
}
/// #852: the Paintbrush records the pen pressure on the stroke, the brush draws it, and it lasts
/// through saving and every export; a mouse stroke records none.
#[test]
fn paintbrush_records_pen_pressure_on_the_stroke() {
let mut s = session();
pressure_round(&mut s);
s.execute("brush.setCurrent", &json!({"name": "3 pt. Round"})).unwrap();
let v = ViewInfo::default();
s.select_tool("paintbrush", v).unwrap();
let strokes = |s: &mut Session, y: f64, pressure: [f32; 4]| {
let kinds = [PointerKind::Down, PointerKind::Drag, PointerKind::Drag, PointerKind::Up];
for ((k, x), p) in kinds.into_iter().zip([20.0, 120.0, 220.0, 320.0]).zip(pressure) {
s.pointer(&PointerEvent { pressure: p, ..PointerEvent::new(k, x, y) }, v).unwrap();
}
let id = s.doc().unwrap().selection.objects[0];
// Deselected, so the next stroke starts a path of its own.
select(s, &[]);
id
};
let pen = strokes(&mut s, 60.0, [0.0, 0.4, 0.8, 1.0]);
let p = pressure_of(&s, pen).expect("pressure recorded");
assert!(p.at(0.0) < 0.05 && p.at(1.0) > 0.95 && (p.at(0.5) - 0.6).abs() < 0.05, "{p:?}");
let mouse = strokes(&mut s, 200.0, [1.0; 4]);
assert_eq!(pressure_of(&s, mouse), None, "a mouse presses fully: nothing to record");
// Drawn: thin where the pen pressed lightly, 6 pt where it pressed fully.
let doc: Document = (*s.doc().unwrap().doc).clone();
let art = |d: &Document, id: NodeId| vectorcraft_brush::node_pieces(d, d.node(id).unwrap()).unwrap();
let heavy_end = vectorcraft_brush::pieces_bounds(&art(&doc, pen)).unwrap();
assert!((heavy_end.height() - 6.0).abs() < 0.3, "{heavy_end:?}");
let even = vectorcraft_brush::pieces_bounds(&art(&doc, mouse)).unwrap();
assert!((even.height() - 3.0).abs() < 0.1, "no pressure draws the size itself: {even:?}");
// Saved and reopened as it was.
let back = vectorcraft_format::load(&vectorcraft_format::save(&doc, false)).unwrap();
assert_eq!(back.node(pen).unwrap().appearance.stroke().unwrap().pressure, Some(p));
// SVG and PDF draw the brush through the same stroke code: without the pressure they differ.
let mut flat = doc.clone();
flat.node_mut(pen).unwrap().appearance.stroke_mut().unwrap().pressure = None;
assert_ne!(vectorcraft_svg::export(&doc, &Default::default()), vectorcraft_svg::export(&flat, &Default::default()));
let pdf = |d: &Document| {
vectorcraft_pdf::export(d, &vectorcraft_pdf::PdfOptions { created: Some(0), ..vectorcraft_pdf::PdfOptions::uncompressed() }).unwrap()
};
assert_ne!(pdf(&doc), pdf(&flat));
}
/// Continuing a pressure stroke keeps its pressure where it was and adds the new stroke's after it.
#[test]
fn continuing_a_stroke_joins_its_pressure() {
let mut s = session();
let r = s.execute("path.freehand", &json!({"points": [[0, 50, 0], [100, 50, 1]], "style": "brush"})).unwrap();
let id = NodeId(r["id"].as_u64().unwrap());
s.execute("path.freehand", &json!({"points": [[100, 50, 1], [200, 50, 0]], "style": "brush", "extend": {"id": id.0, "end": "end"}})).unwrap();
let p = pressure_of(&s, id).unwrap();
assert!(p.at(0.0) < 0.05 && (p.at(0.5) - 1.0).abs() < 0.05 && p.at(1.0) < 0.05, "{p:?}");
// Continued at its start: the new stroke comes first, running backwards from where it joined.
let r = s.execute("path.freehand", &json!({"points": [[0, 150], [100, 150]], "style": "brush"})).unwrap();
let id = NodeId(r["id"].as_u64().unwrap());
s.execute("path.freehand", &json!({"points": [[0, 150, 1], [-100, 150, 0]], "style": "brush", "extend": {"id": id.0, "end": "start"}})).unwrap();
let p = pressure_of(&s, id).unwrap();
assert!(p.at(0.0) < 0.05 && (p.at(0.5) - 1.0).abs() < 0.05, "{p:?}");
assert_eq!(p.at(1.0), vectorcraft_doc::PressureProfile::MID, "the old part had no pressure");
}
/// Pen pressure belongs to its path: new art, graphic styles and Brush Options' edits leave it.
#[test]
fn pressure_stays_with_its_own_path() {
let mut s = session();
s.execute("appearance.setNewArtBasic", &json!({"on": false})).unwrap();
let r = s.execute("brush.freehand", &json!({"points": [[0, 50, 0.2], [100, 50, 0.9]], "style": "brush", "brush": "3 pt. Round"})).unwrap();
let id = NodeId(r["id"].as_u64().unwrap());
assert!(pressure_of(&s, id).is_some());
select(&mut s, &[id]);
s.execute("graphicStyle.new", &json!({"name": "Inked"})).unwrap();
let style = s.doc().unwrap().doc.graphic_style("Inked").unwrap().appearance.clone();
assert!(style.stroke().is_some_and(|st| st.pressure.is_none()));
let other = rect(&mut s, 10.0, 100.0, 50.0, 50.0);
assert_eq!(pressure_of(&s, other), None, "new art takes the look, not the pressure");
pressure_round(&mut s);
assert!(pressure_of(&s, id).is_some(), "editing the brush keeps the strokes' pressure");
}
/// Brush Options sets the variation modes; bad modes are refused, never a panic.
#[test]
fn brush_options_set_variation_modes() {
let mut s = session();
pressure_round(&mut s);
let b = s.execute("brush.get", &json!({"name": "3 pt. Round"})).unwrap();
assert_eq!(b["modes"], json!(["fixed", "fixed", "pressure"]));
assert_eq!(b["variation"], json!([0.0, 0.0, 3.0]));
for bad in [json!({"modes": ["fixed", "fixed", "tilt"]}), json!({"modes": "pressure"}), json!({"variation": "x"}), json!({"modes": [1, 2, 3]})] {
assert!(s.execute("brush.options", &json!({"name": "3 pt. Round", "params": bad})).is_err(), "{bad}");
}
assert_eq!(s.execute("brush.get", &json!({"name": "3 pt. Round"})).unwrap(), b, "a refused edit changes nothing");
}
// ---------- symbols ----------
fn make_symbol(s: &mut Session) -> (NodeId, String, Rect) {

View File

@ -214,6 +214,19 @@ fn structured_junk() {
("path.freehand", json!({"points": [[0, 0]]})),
("path.freehand", json!({"points": [[0, 0], [0, 0], [0, 0], [0, 0]]})),
("path.freehand", json!({"points": [[1e308, 1e308], [-1e308, 0]], "fidelity": 0})),
("path.freehand", json!({"points": [[0, 0, -5], [10, 0, 1e308], [20, 0, "x"], [30, 0, null], [40, 0, 0.5]], "style": "brush"})),
("path.freehand", json!({"points": [[0, 0, 0.2], [0, 0, 0.9], [0, 0, 0.1]]})),
(
"path.freehand",
json!({"points": [{"x": 0, "y": 0, "pressure": 2}, {"x": 1e308, "y": 0, "pressure": -1}], "extend": {"id": 2, "end": "start"}}),
),
("brush.freehand", json!({"points": [[0, 0, 0], [50, 50, 1]], "brush": "3 pt. Round"})),
("brush.options", json!({"name": "3 pt. Round", "params": {"modes": ["pressure", "random", "nope"]}})),
("brush.options", json!({"name": "3 pt. Round", "params": {"modes": "pressure", "variation": [1e308, -1e308, 1e308]}})),
(
"brush.options",
json!({"name": "3 pt. Round", "params": {"modes": ["pressure", "pressure", "pressure"], "variation": [1e308, 1e308, 1e308], "size": 1e308, "angle": -1e308}}),
),
("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

@ -5,7 +5,9 @@
//! and commit on release; Alt on release (or ending near the start) closes the path, and starting
//! near an end of a selected open path continues it. The Pencil's new path starts and ends on
//! Smart Guides ([`DrawSnap`]: an anchor, a path, in line with the art), hovering too; the points
//! between follow the hand. The other tools collect the drag polyline (shown as an overlay) and run
//! between follow the hand. The Paintbrush records the pen pressure of each point (a stroke a pen
//! pressed less than fully on sends `[x, y, pressure]` points) for pressure-sensitive brushes. The
//! other tools collect the drag polyline (shown as an overlay) and run
//! one command on release.
use serde_json::{Value, json};
@ -19,6 +21,8 @@ use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, Tool
pub struct GestureTool {
id: &'static str,
points: Vec<Point>,
/// The pen pressure of each of `points` (the Paintbrush only).
pressures: Vec<f32>,
active: bool,
began: bool,
/// Pencil/Paintbrush continuing a selected open path: (path, continue from its start).
@ -53,6 +57,7 @@ impl GestureTool {
Self {
id,
points: vec![],
pressures: vec![],
active: false,
began: false,
extend: None,
@ -74,6 +79,30 @@ impl GestureTool {
matches!(self.id, "pencil" | "paintbrush")
}
/// Add a stroke sample.
fn push(&mut self, p: Point, pressure: f32) {
self.points.push(p);
if self.id == "paintbrush" {
self.pressures.push(pressure);
}
}
/// Forget the stroke.
fn clear(&mut self) {
self.points.clear();
self.pressures.clear();
}
/// The stroke's points, each `[x, y, pressure]` when a pen pressed less than fully anywhere
/// along it (a mouse always presses fully).
fn stroke_json(&self) -> Value {
if self.pressures.len() == self.points.len() && self.pressures.iter().any(|p| *p < 1.0) {
Value::Array(self.points.iter().zip(&self.pressures).map(|(p, f)| json!([p.x, p.y, (f64::from(*f) * 1000.0).round() / 1000.0])).collect())
} else {
points_json(&self.points)
}
}
fn label(&self) -> &'static str {
match self.id {
"paintbrush" => "Paintbrush",
@ -110,7 +139,7 @@ impl GestureTool {
/// Parameters of `path.freehand` for the current stroke.
pub fn freehand_params(&self, closed: bool) -> Value {
let mut v = json!({
"points": points_json(&self.points),
"points": self.stroke_json(),
"fidelity": self.fidelity,
"closed": closed,
"style": if self.id == "paintbrush" { "brush" } else { "pencil" },
@ -168,7 +197,8 @@ impl Tool for GestureTool {
self.extend = if self.draws_path() { self.find_extend(cx, p) } else { None };
// A path continued starts at its end; a new one where it snaps.
let start = if self.snaps() && self.extend.is_none() { self.snap.press(cx, p, &[], None) } else { p };
self.points = vec![start];
self.clear();
self.push(start, ev.pressure);
vec![]
}
PointerKind::Drag => {
@ -182,7 +212,7 @@ impl Tool for GestureTool {
if self.points.last().is_some_and(|l| l.distance(p) < cx.tol(1.0)) {
return vec![];
}
self.points.push(p);
self.push(p, ev.pressure);
if !self.draws_path() {
return vec![];
}
@ -201,7 +231,7 @@ impl Tool for GestureTool {
let end = if self.snaps() { self.snap.drag(cx, p, None) } else { p };
self.snap.clear();
if self.points.last().is_some_and(|l| l.distance(p) >= cx.tol(1.0)) {
self.points.push(end);
self.push(end, ev.pressure);
} else if self.snaps()
&& self.points.len() > 1
&& let Some(last) = self.points.last_mut()
@ -212,7 +242,7 @@ impl Tool for GestureTool {
let out = self.finish(cx, ev.mods);
self.active = false;
self.began = false;
self.points.clear();
self.clear();
self.extend = None;
out
}
@ -226,7 +256,7 @@ impl Tool for GestureTool {
self.snap.clear();
self.active = false;
self.began = false;
self.points.clear();
self.clear();
if began { vec![Action::Cancel] } else { vec![] }
}
ToolKey::BracketLeft if matches!(self.id, "blobBrush" | "eraser") => {
@ -245,7 +275,7 @@ impl Tool for GestureTool {
self.snap.clear();
self.active = false;
self.began = false;
self.points.clear();
self.clear();
if began { vec![Action::Commit] } else { vec![] }
}
fn overlays(&self, cx: &ToolContext) -> Vec<Overlay> {
@ -321,6 +351,29 @@ mod tests {
assert_eq!(v["points"][0], json!([102.0, 98.0]));
}
/// #852: the Paintbrush sends each point's pen pressure; a mouse (always fully pressed) and
/// the Pencil send plain points.
#[test]
fn paintbrush_sends_pen_pressure() {
let (d, _) = doc_with_rect();
let s = Selection::default();
let p = paint();
let cx = cx(&d, &s, &p);
let stroke = |id: &str, pressures: [f32; 3]| {
let mut t = GestureTool::new(id);
let kinds = [PointerKind::Down, PointerKind::Drag, PointerKind::Up];
let mut last = vec![];
for ((k, x), f) in kinds.into_iter().zip([0.0, 40.0, 80.0]).zip(pressures) {
last = t.pointer(&cx, &PointerEvent { pressure: f, ..PointerEvent::new(k, x, 0.0) });
}
let Some(Action::Preview(_, v)) = last.first() else { panic!("{last:?}") };
v["points"].clone()
};
assert_eq!(stroke("paintbrush", [0.25, 0.5, 1.0]), json!([[0.0, 0.0, 0.25], [40.0, 0.0, 0.5], [80.0, 0.0, 1.0]]));
assert_eq!(stroke("paintbrush", [1.0; 3]), json!([[0.0, 0.0], [40.0, 0.0], [80.0, 0.0]]));
assert_eq!(stroke("pencil", [0.25, 0.5, 1.0]), json!([[0.0, 0.0], [40.0, 0.0], [80.0, 0.0]]));
}
#[test]
fn pencil_previews_and_commits() {
let (d, _) = doc_with_rect();

View File

@ -12,6 +12,7 @@ workspace = true
vectorcraft-geom = { workspace = true }
vectorcraft-color = { workspace = true }
vectorcraft-doc = { workspace = true }
vectorcraft-brush = { workspace = true }
vectorcraft-render = { workspace = true }
vectorcraft-text = { workspace = true }
vectorcraft-tools = { workspace = true }

View File

@ -0,0 +1,344 @@
//! 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.
//!
//! Fields: `__brush` (the brush's name), `__type` (`calligraphic` | `bristle`), `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`…),
//! `size` (pt), `length` (25..300 %), `density`, `thickness`, `opacity`, `stiffness` (1..100 %).
use egui::{Color32, Sense, Stroke, pos2, vec2};
use serde_json::{Map, Value, json};
use vectorcraft_brush::{Brush, BrushKind, Calligraphic, Variation};
use super::swatch_options::{grid, label};
use super::{DialogSpec, form};
use crate::state::Dialog;
use crate::theme::Tokens;
use crate::{VectorcraftApp, widgets};
/// The dialog kind of Brush Options.
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 tip shapes: (value, label).
const SHAPES: [(&str, &str); 10] = [
("roundPoint", "Round Point"),
("flatPoint", "Flat Point"),
("roundBlunt", "Round Blunt"),
("flatBlunt", "Flat Blunt"),
("roundCurve", "Round Curve"),
("flatCurve", "Flat Curve"),
("roundAngle", "Round Angle"),
("flatAngle", "Flat Angle"),
("roundFan", "Round Fan"),
("flatFan", "Flat Fan"),
];
/// Width of the bristle dialog's label column.
const LABEL_W: f32 = 112.0;
/// Widths of the nib preview and of the whole preview row.
const NIB_W: f32 = 120.0;
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(),
}
}
fn mode_key(key: &str) -> String {
format!("{key}Mode")
}
fn variation_key(key: &str) -> String {
format!("{key}Variation")
}
/// Open the options of brush `name` (default: the selected path's brush, else the current one).
pub fn open(app: &mut VectorcraftApp, name: Option<&str>) -> Result<Value, String> {
let name = match name {
Some(n) => n.to_string(),
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 mut f = Map::new();
f.insert("__brush".into(), json!(b.name));
f.insert("name".into(), json!(b.name));
f.insert("__type".into(), json!(b.kind.type_id()));
match &b.kind {
BrushKind::Calligraphic(c) => {
let values = [c.angle, c.roundness, c.size];
for (i, (key, ..)) in CALLI.iter().enumerate() {
let (Some(v), Some(var), Some(mode)) = (values.get(i), c.variation.get(i), c.modes().get(i).copied()) else { continue };
f.insert((*key).into(), json!(v));
f.insert(variation_key(key), json!(var));
f.insert(mode_key(key), json!(mode.id()));
}
}
BrushKind::Bristle(b) => {
let v = serde_json::to_value(b).map_err(|e| e.to_string())?;
for key in ["shape", "size"].into_iter().chain(BRISTLE.map(|r| r.0)) {
f.insert(key.into(), v.get(key).cloned().unwrap_or(Value::Null));
}
}
_ => return Err("this kind of brush has no options dialog yet".into()),
}
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)?
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()))
}
/// The brush Brush Options opens on without a name: the selected path's brush, else the current one.
fn target(app: &VectorcraftApp) -> Option<String> {
let st = app.session.active()?;
let selected = st.selection.subjects().first().and_then(|id| st.doc.node(*id)).and_then(|n| n.appearance.stroke()?.brush.clone());
selected.or_else(|| vectorcraft_brush::current(&st.doc))
}
/// The value of field `key` within `range`.
fn clamped(d: &Dialog, key: &str, (lo, hi): (f64, f64)) -> f64 {
d.f64(key, lo).clamp(lo, hi)
}
/// The Calligraphic brush the fields describe.
fn calligraphic(d: &Dialog) -> Calligraphic {
let value = |i: usize| CALLI.get(i).zip(Calligraphic::RANGES.get(i)).map_or(0.0, |((key, ..), r)| clamped(d, key, *r));
let variation =
|i: usize| CALLI.get(i).zip(Calligraphic::MAX_VARIATION.get(i)).map_or(0.0, |((key, ..), max)| clamped(d, &variation_key(key), (0.0, *max)));
let mode = |i: usize| CALLI.get(i).and_then(|(key, ..)| Variation::parse(&d.str(&mode_key(key)))).unwrap_or_default();
Calligraphic { angle: value(0), roundness: value(1), size: value(2), variation: [0, 1, 2].map(variation), modes: Some([0, 1, 2].map(mode)) }
}
/// `brush.options` params from the fields.
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)));
}
p
}
_ => serde_json::to_value(calligraphic(d)).unwrap_or_default(),
}
}
fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
grid(ui, |ui| {
label(ui, tl!("Name:"));
form::text(ui, d, "name", 240.0);
ui.end_row();
});
ui.add_space(10.0);
match d.str("__type").as_str() {
"bristle" => bristle_body(app, ui, d),
_ => calligraphic_body(ui, d),
}
false
}
fn calligraphic_body(ui: &mut egui::Ui, d: &mut Dialog) {
let c = calligraphic(d);
ui.horizontal(|ui| {
nib_preview(ui, &c, vec2(NIB_W, 84.0));
ui.add_space(8.0);
stroke_preview(ui, "calligraphic", serde_json::to_value(&c).unwrap_or_default(), vec2(STROKE_W - NIB_W - 8.0, 84.0));
});
ui.add_space(12.0);
egui::Grid::new("brush-options-calli").num_columns(5).spacing([8.0, 8.0]).show(ui, |ui| {
for (i, (key, text, suffix, decimals)) in CALLI.into_iter().enumerate() {
let (Some(&range), Some(&max)) = (Calligraphic::RANGES.get(i), Calligraphic::MAX_VARIATION.get(i)) else { continue };
label(ui, tl!(text));
if let Some(v) = widgets::range_field(ui, ("brush-value", key), clamped(d, key, range), range.0..=range.1, suffix, decimals, 64.0) {
d.fields.insert(key.into(), json!(v));
}
let mode = Variation::parse(&d.str(&mode_key(key))).unwrap_or_default();
let labels = Variation::ALL.map(Variation::label);
if let Some(m) = widgets::dropdown(ui, ("brush-mode", key), mode.label(), &labels, 104.0).and_then(|i| Variation::ALL.get(i)) {
d.fields.insert(mode_key(key), json!(m.id()));
}
label(ui, tl!("Variation:"));
let vkey = variation_key(key);
ui.add_enabled_ui(mode != Variation::Fixed, |ui| {
if let Some(v) = widgets::range_field(ui, ("brush-var", key), clamped(d, &vkey, (0.0, max)), 0.0..=max, suffix, decimals, 64.0) {
d.fields.insert(vkey.clone(), json!(v));
}
});
ui.end_row();
}
});
}
fn bristle_body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) {
form::choice(ui, d, "shape", tl!("Shape:"), (LABEL_W, 150.0), &SHAPES);
widgets::label_row(ui, tl!("Size:"), LABEL_W, |ui| {
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 {
widgets::label_row(ui, tl!(text), LABEL_W, |ui| form::slider_field(ui, d, key, lo..=hi, "%", &|_| rail));
}
ui.add_space(8.0);
let p = params(d);
stroke_preview(ui, "bristle", p, vec2(STROKE_W, 64.0));
}
/// 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) {
def["name"] = json!("preview");
def["type"] = json!(ty);
let (r, _) = ui.allocate_exact_size(size, Sense::hover());
crate::panels::brushes::chip(ui, r, &def);
ui.painter().rect_stroke(r, 0.0, Stroke::new(1.0, Tokens::get(ui.ctx()).input_border), egui::StrokeKind::Inside);
}
/// The nib of `c` at its values (black), and — for values that vary — at their lightest and
/// heaviest (grey), on white like the brush previews, its largest nib filling the box.
fn nib_preview(ui: &mut egui::Ui, c: &Calligraphic, size: egui::Vec2) {
let t = Tokens::get(ui.ctx());
let (r, _) = ui.allocate_exact_size(size, Sense::hover());
let p = ui.painter_at(r);
p.rect_filled(r, 0.0, Color32::WHITE);
let guide = Stroke::new(1.0, Color32::from_gray(220));
p.line_segment([pos2(r.left(), r.center().y), pos2(r.right(), r.center().y)], guide);
p.line_segment([pos2(r.center().x, r.top()), pos2(r.center().x, r.bottom())], guide);
let values = [c.angle, c.roundness, c.size];
let at = |sign: f64| -> [f64; 3] {
let mut v = values;
for (i, (x, mode)) in v.iter_mut().zip(c.modes()).enumerate() {
let (Some(&var), Some(&(lo, hi))) = (c.variation.get(i), Calligraphic::RANGES.get(i)) else { continue };
if mode != Variation::Fixed {
*x = (*x + sign * var).clamp(lo, hi);
}
}
v
};
let nibs = [at(-1.0), at(1.0), values];
let largest = nibs.iter().map(|n| n[2]).fold(0.0, f64::max).max(1e-6);
let scale = (r.height().min(r.width()) - 12.0) / largest as f32;
for (k, [angle, round, size]) in nibs.into_iter().enumerate() {
let fill = if k < 2 { Color32::from_gray(200) } else { Color32::BLACK };
let (a, b) = ((size as f32 * scale / 2.0).max(0.5), (size * round.clamp(1.0, 100.0) / 100.0) as f32 * scale / 2.0);
let th = (angle as f32).to_radians();
let (u, v) = (vec2(th.cos(), -th.sin()), vec2(th.sin(), th.cos()));
let pts: Vec<egui::Pos2> = (0..48)
.map(|i| {
let s = i as f32 / 48.0 * std::f32::consts::TAU;
r.center() + u * (a * s.cos()) + v * (b.max(0.5) * s.sin())
})
.collect();
p.add(egui::Shape::convex_polygon(pts, fill, Stroke::NONE));
}
p.rect_stroke(r, 0.0, Stroke::new(1.0, t.input_border), egui::StrokeKind::Inside);
}
/// OK: edit the brush (and rename it). A name another brush has keeps the dialog open.
fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
let name = d.str("name").trim().to_string();
let r = app.run("brush.options", json!({ "name": d.str("__brush"), "newName": name, "params": params(d) }));
if r.is_ok() {
app.ui.dialog = None;
}
r
}
#[cfg(test)]
mod tests {
use super::*;
use vectorcraft_engine::Session;
fn app() -> VectorcraftApp {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.run("file.new", json!({"width": 300, "height": 200})).unwrap();
app
}
fn set(app: &mut VectorcraftApp, fields: Value) {
let d = app.ui.dialog.as_mut().unwrap();
for (k, v) in fields.as_object().unwrap() {
d.fields.insert(k.clone(), v.clone());
}
}
fn shown(app: &mut VectorcraftApp) -> String {
crate::tests_labels::painted_text(app, |app, ui| super::super::show(app, ui.ctx()))
}
#[test]
fn calligraphic_options_show_each_value_with_its_variation_and_ok_applies_them() {
let mut app = app();
app.run("ui.brushOptions", json!({"name": "6 pt. Flat"})).unwrap();
let text = shown(&mut app);
for label in ["Calligraphic Brush Options", "Name:", "Angle:", "Roundness:", "Size:", "Variation:", "Fixed", "OK", "Cancel"] {
assert!(text.contains(label), "{label} in {text}");
}
let d = app.ui.dialog.as_ref().unwrap();
assert_eq!((d.f64("angle", 0.0), d.f64("roundness", 0.0), d.f64("size", 0.0)), (45.0, 15.0, 6.0));
// Out-of-range values are kept within their ranges.
set(&mut app, json!({"angleMode": "random", "angleVariation": 500, "roundness": 140, "sizeMode": "pressure", "sizeVariation": 4}));
assert!(shown(&mut app).contains("Pressure"));
super::super::confirm(&mut app).unwrap();
let def = app.run("brush.get", json!({"name": "6 pt. Flat"})).unwrap();
assert_eq!(def["modes"], json!(["random", "fixed", "pressure"]));
assert_eq!((def["variation"].clone(), def["roundness"].clone()), (json!([180.0, 0.0, 4.0]), json!(100.0)));
// Opened again, it shows what was set.
app.run("ui.brushOptions", json!({"name": "6 pt. Flat"})).unwrap();
let d = app.ui.dialog.as_ref().unwrap();
assert_eq!((d.str("angleMode"), d.f64("sizeVariation", 0.0)), ("random".into(), 4.0));
}
#[test]
fn a_taken_name_keeps_the_dialog_open() {
let mut app = app();
app.run("ui.brushOptions", json!({"name": "6 pt. Flat"})).unwrap();
set(&mut app, json!({"name": "3 pt. Round"}));
assert!(super::super::confirm(&mut app).is_err());
assert!(app.ui.dialog.is_some());
}
#[test]
fn bristle_options_and_brushes_without_a_dialog() {
let mut app = app();
app.run("ui.brushOptions", json!({"name": "Bristle Round"})).unwrap();
let text = shown(&mut app);
for label in ["Bristle Brush Options", "Shape:", "Bristle Length:", "Stiffness:", "Paint Opacity:"] {
assert!(text.contains(label), "{label} in {text}");
}
set(&mut app, json!({"shape": "flatFan", "density": 0, "length": 120}));
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());
assert!(app.run("ui.brushOptions", json!({"name": "Nope"})).is_err());
}
}

View File

@ -393,18 +393,30 @@ pub(super) fn slider_w(
track: &dyn Fn(f32) -> egui::Color32,
) {
let t = Tokens::get(ui.ctx());
ui.horizontal(|ui| {
ui.add_sized([label_w, 22.0], egui::Label::new(egui::RichText::new(tl!(label)).color(t.text)));
slider_field(ui, d, key, range, suffix, track);
});
}
/// The rail and value field of [`slider`] without its label (for a row that draws its own).
pub(super) fn slider_field(
ui: &mut egui::Ui,
d: &mut Dialog,
key: &str,
range: std::ops::RangeInclusive<f64>,
suffix: &str,
track: &dyn Fn(f32) -> egui::Color32,
) {
let (min, max) = (*range.start(), *range.end());
let v = d.f64(key, 0.0).clamp(min, max);
let mut new = None;
ui.horizontal(|ui| {
ui.add_sized([label_w, 22.0], egui::Label::new(egui::RichText::new(tl!(label)).color(t.text)));
if let (Some(x), _) = crate::widgets::color_slider(ui, ("dlg-slider", key), ((v - min) / (max - min)) as f32, SLIDER_WIDTH, track) {
new = Some((min + x as f64 * (max - min)).round());
}
if let Some(x) = crate::widgets::plain_field(ui, ("dlg-field", key), v, suffix, 0, 52.0) {
new = Some(x.round().clamp(min, max));
}
});
if let (Some(x), _) = crate::widgets::color_slider(ui, ("dlg-slider", key), ((v - min) / (max - min)) as f32, SLIDER_WIDTH, track) {
new = Some((min + x as f64 * (max - min)).round());
}
if let Some(x) = crate::widgets::plain_field(ui, ("dlg-field", key), v, suffix, 0, 52.0) {
new = Some(x.round().clamp(min, max));
}
if let Some(n) = new.filter(|n| *n != v) {
d.fields.insert(key.into(), json!(n));
}

View File

@ -10,6 +10,7 @@ mod about;
mod all_tools;
mod artboard_options;
pub mod blend_options;
pub mod brush_options;
pub mod color_balance;
pub mod color_guide_options;
mod color_picker;
@ -278,6 +279,7 @@ registry! {
PerspectiveGridPresets: [perspective_presets::KIND] => perspective_presets::SPEC,
PerspectiveGridOptions: [perspective_options::KIND] => perspective_options::SPEC,
BlendOptions: [blend_options::KIND] => blend_options::SPEC,
BrushOptions: [brush_options::KIND] => brush_options::SPEC,
EditSelection: [edit_selection::KIND] => edit_selection::SPEC,
PerspectivePlane: [perspective_plane::KIND] => perspective_plane::SPEC,
LayerOptions: [layer_options::KIND] => layer_options::SPEC,

View File

@ -234,6 +234,8 @@ fn dialogs_do_not_stretch_to_the_screen() {
("ui.swatchOptions", json!({"name": "White"})),
("ui.colorPicker", json!({})),
("ui.graphicStyleOptions", json!({})),
("ui.brushOptions", json!({"name": "3 pt. Round"})),
("ui.brushOptions", json!({"name": "Bristle Round"})),
("ui.colorGuideOptions", json!({})),
("ui.colorBalanceDialog", json!({})),
("ui.saturateDialog", json!({})),

View File

@ -1545,6 +1545,27 @@
Artboard Mesa de trabajo
Exit Salir
New Bristle Brush Nuevo pincel de cerdas
Calligraphic Brush Options Opciones de pincel caligráfico
Bristle Brush Options Opciones de pincel de cerdas
Roundness: Redondez:
Fixed Fijo
Pressure Presión
Shape: Forma:
Bristle Length: Longitud de cerdas:
Bristle Density: Densidad de cerdas:
Bristle Thickness: Grosor de cerdas:
Paint Opacity: Opacidad de pintura:
Stiffness: Rigidez:
Round Point Punta redonda
Flat Point Punta plana
Round Blunt Romo redondo
Flat Blunt Romo plano
Round Curve Curvo redondo
Flat Curve Curvo plano
Round Angle Sesgado redondo
Flat Angle Sesgado plano
Round Fan Abanico redondo
Flat Fan Abanico plano
New Brush… Nuevo pincel…
New Calligraphic Brush Nuevo pincel caligráfico
New Character Style Nuevo estilo de carácter
@ -3422,6 +3443,8 @@ axis Both Ambos
@msg no blend options no hay opciones de fusión
@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

@ -1542,6 +1542,27 @@
New Art Has Basic Appearance Aspect de base pour les nouvelles illustrations
New Artboard Nouveau plan de travail
New Bristle Brush Nouvelle forme Soies
Calligraphic Brush Options Options de forme calligraphique
Bristle Brush Options Options de forme Soies
Roundness: Arrondi :
Fixed Fixe
Pressure Pression
Shape: Forme :
Bristle Length: Longueur des soies :
Bristle Density: Densité des soies :
Bristle Thickness: Épaisseur des soies :
Paint Opacity: Opacité de la peinture :
Stiffness: Rigidité :
Round Point Pointe ronde
Flat Point Pointe plate
Round Blunt Rond émoussé
Flat Blunt Plat émoussé
Round Curve Rond incurvé
Flat Curve Plat incurvé
Round Angle Rond biseauté
Flat Angle Plat biseauté
Round Fan Éventail rond
Flat Fan Éventail plat
New Brush… Nouvelle forme…
New Calligraphic Brush Nouvelle forme calligraphique
New Character Style Nouveau style de caractère
@ -3419,6 +3440,8 @@ axis Both Les deux
@msg no blend options aucune option de dégradé de formes
@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

@ -1545,6 +1545,27 @@
Artboard Tavola da disegno
Exit Esci
New Bristle Brush Nuovo pennello a setole
Calligraphic Brush Options Opzioni pennello calligrafico
Bristle Brush Options Opzioni pennello a setole
Roundness: Rotondità:
Fixed Fisso
Pressure Pressione
Shape: Forma:
Bristle Length: Lunghezza setole:
Bristle Density: Densità setole:
Bristle Thickness: Spessore setole:
Paint Opacity: Opacità colore:
Stiffness: Rigidità:
Round Point Punta tonda
Flat Point Punta piatta
Round Blunt Tonda smussata
Flat Blunt Piatta smussata
Round Curve Tonda curva
Flat Curve Piatta curva
Round Angle Tonda angolata
Flat Angle Piatta angolata
Round Fan Ventaglio tondo
Flat Fan Ventaglio piatto
New Brush… Nuovo pennello…
New Calligraphic Brush Nuovo pennello calligrafico
New Character Style Nuovo stile carattere
@ -3422,6 +3443,8 @@ axis Both Entrambi
@msg no blend options nessuna opzione di fusione
@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

@ -1677,6 +1677,27 @@
Artboard アートボード
Exit 終了
New Bristle Brush 新規剛毛ブラシ
Calligraphic Brush Options 絵筆ブラシオプション
Bristle Brush Options 剛毛ブラシオプション
Roundness: 真円率:
Fixed 固定
Pressure 筆圧
Shape: 形状:
Bristle Length: 毛の長さ:
Bristle Density: 毛の密度:
Bristle Thickness: 毛の太さ:
Paint Opacity: 塗料の不透明度:
Stiffness: 硬さ:
Round Point 丸・先細
Flat Point 平・先細
Round Blunt 丸・先丸
Flat Blunt 平・先丸
Round Curve 丸・カーブ
Flat Curve 平・カーブ
Round Angle 丸・斜め
Flat Angle 平・斜め
Round Fan 丸・扇形
Flat Fan 平・扇形
New Brush… 新規ブラシ…
New Calligraphic Brush 新規絵筆ブラシ
New Character Style 新規文字スタイル
@ -3237,6 +3258,8 @@ theme Appearance 外観
@msg Show All Hidden Attributes: nothing is hidden 非表示の属性をすべて表示: 非表示の属性はありません
@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

@ -1541,6 +1541,27 @@
New Art Has Basic Appearance Новый рисунок имеет основное оформление
New Artboard Новая монтажная область
New Bristle Brush Новая щетинная кисть
Calligraphic Brush Options Параметры каллиграфической кисти
Bristle Brush Options Параметры щетинной кисти
Roundness: Округлость:
Fixed Фиксированный
Pressure Нажим
Shape: Форма:
Bristle Length: Длина щетинок:
Bristle Density: Плотность щетинок:
Bristle Thickness: Толщина щетинок:
Paint Opacity: Непрозрачность краски:
Stiffness: Жёсткость:
Round Point Круглая заострённая
Flat Point Плоская заострённая
Round Blunt Круглая тупая
Flat Blunt Плоская тупая
Round Curve Круглая изогнутая
Flat Curve Плоская изогнутая
Round Angle Круглая скошенная
Flat Angle Плоская скошенная
Round Fan Круглая веерная
Flat Fan Плоская веерная
New Brush… Новая кисть…
New Calligraphic Brush Новая каллиграфическая кисть
New Character Style Новый стиль символа
@ -3418,6 +3439,8 @@ axis Both Оба
@msg no blend options нет параметров перехода
@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

@ -1537,6 +1537,27 @@
New Art Has Basic Appearance Новий малюнок має основне оформлення
New Artboard Нова монтажна область
New Bristle Brush Новий щетинний пензель
Calligraphic Brush Options Параметри каліграфічного пензля
Bristle Brush Options Параметри щетинного пензля
Roundness: Округлість:
Fixed Фіксований
Pressure Натиск
Shape: Форма:
Bristle Length: Довжина щетинок:
Bristle Density: Щільність щетинок:
Bristle Thickness: Товщина щетинок:
Paint Opacity: Непрозорість фарби:
Stiffness: Жорсткість:
Round Point Кругла загострена
Flat Point Пласка загострена
Round Blunt Кругла тупа
Flat Blunt Пласка тупа
Round Curve Кругла вигнута
Flat Curve Пласка вигнута
Round Angle Кругла скошена
Flat Angle Пласка скошена
Round Fan Кругла віялова
Flat Fan Пласка віялова
New Brush… Новий пензель…
New Calligraphic Brush Новий каліграфічний пензель
New Character Style Новий стиль символу
@ -3405,6 +3426,8 @@ axis Both Обидві
@msg no blend options немає параметрів переходу
@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

@ -1501,6 +1501,27 @@
New Artboard 新建画板
Exit 退出
New Bristle Brush 新建鬃毛画笔
Calligraphic Brush Options 书法画笔选项
Bristle Brush Options 鬃毛画笔选项
Roundness: 圆度:
Fixed 固定
Pressure 压力
Shape: 形状:
Bristle Length: 鬃毛长度:
Bristle Density: 鬃毛密度:
Bristle Thickness: 鬃毛粗细:
Paint Opacity: 上色不透明度:
Stiffness: 硬度:
Round Point 圆尖头
Flat Point 扁尖头
Round Blunt 圆钝头
Flat Blunt 扁钝头
Round Curve 圆弯头
Flat Curve 扁弯头
Round Angle 圆斜头
Flat Angle 扁斜头
Round Fan 圆扇头
Flat Fan 扁扇头
New Brush… 新建画笔…
New Calligraphic Brush 新建书法画笔
New Character Style 新建字符样式

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

View File

@ -1545,6 +1545,27 @@
Artboard 工作區域
Exit 結束
New Bristle Brush 新增鬃毛筆刷
Calligraphic Brush Options 書法筆刷選項
Bristle Brush Options 鬃毛筆刷選項
Roundness: 圓度:
Fixed 固定
Pressure 壓力
Shape: 形狀:
Bristle Length: 鬃毛長度:
Bristle Density: 鬃毛密度:
Bristle Thickness: 鬃毛粗細:
Paint Opacity: 上色不透明度:
Stiffness: 硬度:
Round Point 圓尖頭
Flat Point 扁尖頭
Round Blunt 圓鈍頭
Flat Blunt 扁鈍頭
Round Curve 圓彎頭
Flat Curve 扁彎頭
Round Angle 圓斜頭
Flat Angle 扁斜頭
Round Fan 圓扇頭
Flat Fan 扁扇頭
New Brush… 新增筆刷…
New Calligraphic Brush 新增書法筆刷
New Character Style 新增字元樣式

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

View File

@ -337,6 +337,12 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[
"",
"{stroke?: bool (default: the active proxy), color?: \"#rrggbb\"|[r,g,b]|{c,m,y,k}|{gray} (default: the proxy's colour)} open the Color Picker (fields: hex or color, channel, webOnly, swatches); OK runs paint.setFill / paint.setStroke",
),
(
"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",
),
(
"ui.graphicStyleOptions",
"Graphic Style Options…",
@ -1206,6 +1212,7 @@ pub fn run_ui_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option<R
}
"ui.colorPicker" => crate::dialogs::open_color_picker(app, p),
"ui.graphicStyleOptions" => crate::dialogs::graphic_style_options::open(app, s("name").as_deref()),
"ui.brushOptions" => crate::dialogs::brush_options::open(app, s("name").as_deref()),
"tool.options" => crate::toolbar::open_options(app, &s("tool").unwrap_or_default()),
"ui.colorGuideOptions" => {
crate::dialogs::color_guide_options::open(app);
@ -1871,6 +1878,7 @@ pub fn enabled(app: &VectorcraftApp, id: &str) -> bool {
"ui.findFontsInFolder" => crate::picks::can(app, &crate::picks::PickRequest::Folder) && app.session.active().is_some(),
"ui.swatchOptions" | "ui.newSwatch" | "ui.newColorGroup" => app.session.active().is_some(),
"ui.graphicStyleOptions" => app.session.active().is_some(),
"ui.brushOptions" => crate::dialogs::brush_options::available(app),
"ui.colorBalanceDialog" | "ui.saturateDialog" => app.session.active().is_some_and(|d| !d.selection.is_empty()),
"ui.saveSwatchLibrary" => app.session.active().is_some(),
id if id.starts_with(crate::panels::swatches::USER_SLOT) => crate::panels::swatches::user_library(app, id).is_some(),

View File

@ -1,14 +1,16 @@
//! Brushes panel: the document's brush library with rendered stroke previews. Clicking a brush
//! applies it to the selected paths (and makes it the Paintbrush's current brush).
//! applies it to the selected paths (and makes it the Paintbrush's current brush); double-clicking
//! one opens its Brush Options.
use std::collections::HashMap;
use egui::{Sense, Ui, vec2};
use serde_json::{Value, json};
use vectorcraft_doc::{Appearance, Document, Node, color::Color, color::Paint};
use vectorcraft_doc::{Appearance, Document, Node, PressureProfile, color::Color, color::Paint};
use super::{first_selected, pstate, set_pstate};
use crate::VectorcraftApp;
use crate::dialogs::brush_options;
use crate::theme::Tokens;
use crate::widgets::{self, PanelDrag, menu_item};
@ -26,14 +28,17 @@ pub fn brushes(app: &mut VectorcraftApp) -> (Vec<(String, String)>, Option<Strin
}
/// A stroke preview for brush definition `def`, rendered at `size` and cached by the definition's
/// JSON.
/// JSON. The stroke goes from light to heavy pen pressure and back, so pressure-sensitive brushes
/// show how they vary.
fn preview(ui: &Ui, def: &Value, size: egui::Vec2) -> Option<egui::TextureHandle> {
widgets::doc_preview(ui, &format!("brush:{def}"), size, |w, h| {
let mut doc = Document::new(w, h);
doc.unknown.insert("brushes".into(), json!([def]));
let name = def["name"].as_str()?.to_string();
let mut ap = Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 1.0);
ap.stroke_mut()?.brush = Some(name);
let st = ap.stroke_mut()?;
st.brush = Some(name);
st.pressure = Some(PressureProfile::sample());
// A gentle S-curve across the swatch.
let mut bp = vectorcraft_geom::BezPath::new();
let (x0, x1) = (h * 0.5, w - h * 0.5);
@ -68,6 +73,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
let defs: HashMap<String, Value> =
list.iter().filter_map(|(n, _)| app.run("brush.get", json!({"name": n})).ok().map(|v| (n.clone(), v))).collect();
let mut clicked: Option<String> = None;
let mut options: Option<String> = None;
let list_rect = widgets::list_box(ui, |ui| {
ui.set_min_height(110.0);
ui.set_width(ui.available_width());
@ -83,6 +89,7 @@ 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.
@ -112,18 +119,22 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
}
resp.on_hover_text(name)
};
// 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 {
options = Some(name.clone());
} else if resp.clicked() {
clicked = Some(name.clone());
}
};
if list_view {
for (name, _) in group {
if row(ui, name, vec2(ui.available_width(), 26.0), true).clicked() {
clicked = Some(name.clone());
}
pick(row(ui, name, vec2(ui.available_width(), 26.0), true), name);
}
} else {
ui.horizontal_wrapped(|ui| {
for (name, _) in group {
if row(ui, name, vec2(64.0, 28.0), false).clicked() {
clicked = Some(name.clone());
}
pick(row(ui, name, vec2(64.0, 28.0), false), name);
}
});
}
@ -146,6 +157,11 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
app.run("brush.setCurrent", json!({"name": name})).ok();
}
}
if let Some(name) = options
&& let Err(e) = app.run("ui.brushOptions", json!({ "name": name }))
{
app.status(e);
}
let has_brush = sel_brush.is_some();
let target = sel_brush.clone().or(current.clone());
let has_sel = app.session.active().is_some_and(|d| !d.selection.is_empty());
@ -204,6 +220,12 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) {
if menu_item(ui, tl!("Expand Brush Strokes"), sel.is_some(), false) {
app.run("object.expandBrush", json!({})).ok();
}
// On the selected path's brush, else the current one, as the other items.
if menu_item(ui, tl!("Brush Options…"), brush_options::available(app), false)
&& let Err(e) = app.run("ui.brushOptions", json!({}))
{
app.status(e);
}
ui.separator();
let mut hidden: Vec<String> = pstate(ui.ctx(), "br-hidden");
for (ty, label) in KINDS {
@ -246,6 +268,48 @@ mod tests {
egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }
}
/// #852: double-clicking a brush opens its Brush Options; OK changes the brush (and strokes
/// painted with it). A single click only applies the brush.
#[test]
fn double_clicking_a_brush_opens_its_options() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.run("file.new", json!({"width": 200, "height": 100})).unwrap();
let ctx = egui::Context::default();
let clicks = |at: Pos2, n: usize| -> Vec<egui::Event> {
std::iter::once(egui::Event::PointerMoved(at)).chain((0..n).flat_map(|_| [button(at, true), button(at, false)])).collect()
};
let at_time = |app: &mut VectorcraftApp, time: f64, events: Vec<egui::Event>| {
let raw = egui::RawInput {
screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(236.0, 600.0))),
time: Some(time),
events,
..Default::default()
};
ctx.run_ui(raw, |ui| show(app, ui)).textures_delta.clear();
};
at_time(&mut app, 0.0, vec![]);
let (list, _) = brushes(&mut app);
let first = list[0].0.clone();
assert_eq!(list[0].1, "calligraphic");
// The first tile, clicked once: it becomes current, no dialog.
let tile = pos2(36.0, 16.0);
at_time(&mut app, 1.0, clicks(tile, 1));
assert!(app.ui.dialog.is_none());
assert_eq!(brushes(&mut app).1.as_deref(), Some(first.as_str()));
// Double-clicked: Calligraphic Brush Options on that brush.
at_time(&mut app, 3.0, clicks(tile, 2));
let d = app.ui.dialog.as_mut().expect("Brush Options opened");
assert_eq!((d.kind.as_str(), d.str("name"), d.str("sizeMode")), (crate::dialogs::brush_options::KIND, first.clone(), "fixed".into()));
d.fields.insert("sizeMode".into(), json!("pressure"));
d.fields.insert("sizeVariation".into(), json!(2));
d.fields.insert("name".into(), json!("Pressure Round"));
crate::dialogs::confirm(&mut app).unwrap();
assert!(app.ui.dialog.is_none());
let def = app.run("brush.get", json!({"name": "Pressure Round"})).unwrap();
assert_eq!((def["modes"].clone(), def["variation"][2].clone()), (json!(["fixed", "fixed", "pressure"]), json!(2.0)));
assert_eq!(brushes(&mut app).1.as_deref(), Some("Pressure Round"), "renamed, still current");
}
#[test]
fn art_dropped_on_the_panel_becomes_an_art_brush_and_a_brush_drags_out() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());