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:
commit
ba2fc64d89
|
|
@ -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
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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})),
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
@ -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));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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!({})),
|
||||
|
|
|
|||
|
|
@ -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.
|
|
|
@ -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.
|
|
|
@ -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.
|
|
|
@ -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.
|
|
|
@ -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.
|
|
|
@ -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.
|
|
|
@ -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.
|
|
|
@ -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.
|
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
Loading…
Reference in New Issue