mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-28 03:37:00 -06:00
refactor(doc): move appearance.rs items to appearance/{layers,profile,stroke_style}.rs
This commit is contained in:
@@ -4,658 +4,14 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use vectorcraft_color::{BlendMode, Color, Paint};
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub enum LineCap {
|
||||
#[default]
|
||||
Butt,
|
||||
Round,
|
||||
Square,
|
||||
}
|
||||
mod layers;
|
||||
mod profile;
|
||||
mod stroke_style;
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub enum LineJoin {
|
||||
#[default]
|
||||
Miter,
|
||||
Round,
|
||||
Bevel,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub enum StrokeAlign {
|
||||
#[default]
|
||||
Center,
|
||||
Inside,
|
||||
Outside,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Dash {
|
||||
/// Dash, gap, dash, gap… (up to 6 values in the Stroke panel).
|
||||
pub pattern: Vec<f64>,
|
||||
#[serde(default)]
|
||||
pub offset: f64,
|
||||
/// "Aligns dashes to corners and path ends, adjusting lengths to fit".
|
||||
#[serde(default)]
|
||||
pub align_corners: bool,
|
||||
}
|
||||
|
||||
impl Dash {
|
||||
/// Whether the pattern draws dashes. As in PDF (`d` operator) and SVG (`stroke-dasharray`), a
|
||||
/// pattern with a negative or non-finite value, or whose values are all zero, is invalid and the
|
||||
/// stroke is drawn solid.
|
||||
pub fn is_dashed(&self) -> bool {
|
||||
self.pattern.iter().all(|v| v.is_finite() && *v >= 0.0) && self.pattern.iter().any(|v| *v > 0.0)
|
||||
}
|
||||
/// Scale the dash and gap lengths and the offset by `s`.
|
||||
pub fn scale(&mut self, s: f64) {
|
||||
self.pattern.iter_mut().for_each(|v| *v *= s);
|
||||
self.offset *= s;
|
||||
}
|
||||
}
|
||||
|
||||
/// An arrowhead shape. Saved by variant name: new shapes are appended, never renamed or reordered.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub enum Arrowhead {
|
||||
Triangle,
|
||||
TriangleOpen,
|
||||
Circle,
|
||||
CircleOpen,
|
||||
Square,
|
||||
SquareOpen,
|
||||
Bar,
|
||||
Diamond,
|
||||
Arrow,
|
||||
ArrowOpen,
|
||||
Barbed,
|
||||
/// One barb, on the left of the direction the head points.
|
||||
HalfArrowLeft,
|
||||
/// One barb, on the right of the direction the head points.
|
||||
HalfArrowRight,
|
||||
Concave,
|
||||
DoubleBar,
|
||||
Feather,
|
||||
DotOnBar,
|
||||
Chevron,
|
||||
DoubleArrow,
|
||||
Target,
|
||||
Star,
|
||||
Cross,
|
||||
Plus,
|
||||
Hexagon,
|
||||
HexagonOpen,
|
||||
Tag,
|
||||
TagOpen,
|
||||
HalfCircle,
|
||||
Drop,
|
||||
Slash,
|
||||
DoubleSlash,
|
||||
DiamondOpen,
|
||||
TriangleReverse,
|
||||
Swallowtail,
|
||||
Bracket,
|
||||
Fork,
|
||||
Leaf,
|
||||
Kite,
|
||||
TriangleBar,
|
||||
Oval,
|
||||
}
|
||||
|
||||
impl Arrowhead {
|
||||
/// Every arrowhead, in menu order (arrows, then outlined shapes, then bars and marks).
|
||||
pub const ALL: [Arrowhead; 40] = [
|
||||
Arrowhead::Arrow,
|
||||
Arrowhead::ArrowOpen,
|
||||
Arrowhead::Barbed,
|
||||
Arrowhead::Concave,
|
||||
Arrowhead::DoubleArrow,
|
||||
Arrowhead::HalfArrowLeft,
|
||||
Arrowhead::HalfArrowRight,
|
||||
Arrowhead::Chevron,
|
||||
Arrowhead::Feather,
|
||||
Arrowhead::Swallowtail,
|
||||
Arrowhead::Triangle,
|
||||
Arrowhead::TriangleOpen,
|
||||
Arrowhead::TriangleReverse,
|
||||
Arrowhead::TriangleBar,
|
||||
Arrowhead::Kite,
|
||||
Arrowhead::Leaf,
|
||||
Arrowhead::Drop,
|
||||
Arrowhead::Circle,
|
||||
Arrowhead::CircleOpen,
|
||||
Arrowhead::HalfCircle,
|
||||
Arrowhead::Oval,
|
||||
Arrowhead::Target,
|
||||
Arrowhead::Square,
|
||||
Arrowhead::SquareOpen,
|
||||
Arrowhead::Tag,
|
||||
Arrowhead::TagOpen,
|
||||
Arrowhead::Diamond,
|
||||
Arrowhead::DiamondOpen,
|
||||
Arrowhead::Hexagon,
|
||||
Arrowhead::HexagonOpen,
|
||||
Arrowhead::Star,
|
||||
Arrowhead::Cross,
|
||||
Arrowhead::Plus,
|
||||
Arrowhead::Bar,
|
||||
Arrowhead::DoubleBar,
|
||||
Arrowhead::DotOnBar,
|
||||
Arrowhead::Slash,
|
||||
Arrowhead::DoubleSlash,
|
||||
Arrowhead::Bracket,
|
||||
Arrowhead::Fork,
|
||||
];
|
||||
|
||||
/// The name the arrowhead menus show.
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
Arrowhead::Triangle => "Triangle",
|
||||
Arrowhead::TriangleOpen => "Triangle (open)",
|
||||
Arrowhead::Circle => "Circle",
|
||||
Arrowhead::CircleOpen => "Circle (open)",
|
||||
Arrowhead::Square => "Square",
|
||||
Arrowhead::SquareOpen => "Square (open)",
|
||||
Arrowhead::Bar => "Bar",
|
||||
Arrowhead::Diamond => "Diamond",
|
||||
Arrowhead::Arrow => "Arrow",
|
||||
Arrowhead::ArrowOpen => "Arrow (open)",
|
||||
Arrowhead::Barbed => "Barbed",
|
||||
Arrowhead::HalfArrowLeft => "Half Arrow (left)",
|
||||
Arrowhead::HalfArrowRight => "Half Arrow (right)",
|
||||
Arrowhead::Concave => "Concave",
|
||||
Arrowhead::DoubleBar => "Double Bar",
|
||||
Arrowhead::Feather => "Feather",
|
||||
Arrowhead::DotOnBar => "Dot on Bar",
|
||||
Arrowhead::Chevron => "Chevron",
|
||||
Arrowhead::DoubleArrow => "Double Arrow",
|
||||
Arrowhead::Target => "Target",
|
||||
Arrowhead::Star => "Star",
|
||||
Arrowhead::Cross => "Cross",
|
||||
Arrowhead::Plus => "Plus",
|
||||
Arrowhead::Hexagon => "Hexagon",
|
||||
Arrowhead::HexagonOpen => "Hexagon (open)",
|
||||
Arrowhead::Tag => "Tag",
|
||||
Arrowhead::TagOpen => "Tag (open)",
|
||||
Arrowhead::HalfCircle => "Half Circle",
|
||||
Arrowhead::Drop => "Drop",
|
||||
Arrowhead::Slash => "Slash",
|
||||
Arrowhead::DoubleSlash => "Double Slash",
|
||||
Arrowhead::DiamondOpen => "Diamond (open)",
|
||||
Arrowhead::TriangleReverse => "Reverse Triangle",
|
||||
Arrowhead::Swallowtail => "Swallowtail",
|
||||
Arrowhead::Bracket => "Bracket",
|
||||
Arrowhead::Fork => "Fork",
|
||||
Arrowhead::Leaf => "Leaf",
|
||||
Arrowhead::Kite => "Kite",
|
||||
Arrowhead::TriangleBar => "Triangle to Bar",
|
||||
Arrowhead::Oval => "Oval",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Where an arrowhead sits relative to the end of its path. In both modes the stroke stops under
|
||||
/// the head, so the line never shows through a hollow head or past its tip.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub enum ArrowAlign {
|
||||
/// The tip extends past the end point (the path keeps its length).
|
||||
#[default]
|
||||
Extend,
|
||||
/// The tip sits on the end point (the stroke is shortened by the head).
|
||||
Tip,
|
||||
}
|
||||
|
||||
/// Variable-width profile: (position 0..1 along the path, left width factor, right width factor),
|
||||
/// in order along the path. Two points at the same position make a discontinuous point: the
|
||||
/// first is the width just before it, the second the width just after it (a step).
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
pub struct WidthProfile {
|
||||
pub points: Vec<(f64, f64, f64)>,
|
||||
}
|
||||
|
||||
/// A built-in width profile (the Stroke panel's Profile list).
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct ProfilePreset {
|
||||
/// Stable id used by `stroke.set {profile}`.
|
||||
pub id: &'static str,
|
||||
/// Menu label.
|
||||
pub label: &'static str,
|
||||
/// (t, left, right) width points.
|
||||
pub points: &'static [(f64, f64, f64)],
|
||||
}
|
||||
|
||||
impl WidthProfile {
|
||||
/// (left, right) width factors at `t`, linear between points.
|
||||
pub fn at(&self, t: f64) -> (f64, f64) {
|
||||
Self::at_points(&self.points, t)
|
||||
}
|
||||
/// [`Self::at`] of a profile's points (a preset's or a saved profile's).
|
||||
pub fn at_points(p: &[(f64, f64, f64)], t: f64) -> (f64, f64) {
|
||||
if p.is_empty() {
|
||||
return (1.0, 1.0);
|
||||
}
|
||||
if t <= p[0].0 {
|
||||
return (p[0].1, p[0].2);
|
||||
}
|
||||
for w in p.windows(2) {
|
||||
if t <= w[1].0 {
|
||||
let u = (t - w[0].0) / (w[1].0 - w[0].0).max(1e-9);
|
||||
return (w[0].1 + (w[1].1 - w[0].1) * u, w[0].2 + (w[1].2 - w[0].2) * u);
|
||||
}
|
||||
}
|
||||
p.last().map_or((1.0, 1.0), |l| (l.1, l.2))
|
||||
}
|
||||
/// The built-in profiles, in menu order. "uniform" is the plain stroke (no profile).
|
||||
pub const PRESETS: [ProfilePreset; 7] = [
|
||||
ProfilePreset { id: "uniform", label: "Uniform", points: &[(0.0, 1.0, 1.0), (1.0, 1.0, 1.0)] },
|
||||
ProfilePreset { id: "lens", label: "Lens", points: &[(0.0, 0.0, 0.0), (0.5, 1.0, 1.0), (1.0, 0.0, 0.0)] },
|
||||
ProfilePreset { id: "taperStart", label: "Taper Start", points: &[(0.0, 0.0, 0.0), (1.0, 1.0, 1.0)] },
|
||||
ProfilePreset { id: "taperEnd", label: "Taper End", points: &[(0.0, 1.0, 1.0), (1.0, 0.0, 0.0)] },
|
||||
// Full width at both ends, pinched to a quarter in the middle.
|
||||
ProfilePreset { id: "pinch", label: "Pinch", points: &[(0.0, 1.0, 1.0), (0.5, 0.25, 0.25), (1.0, 1.0, 1.0)] },
|
||||
// A round head a fifth of the way along, then a long taper to the end.
|
||||
ProfilePreset { id: "teardrop", label: "Teardrop", points: &[(0.0, 0.0, 0.0), (0.2, 1.0, 1.0), (1.0, 0.0, 0.0)] },
|
||||
// Two swells between narrow necks.
|
||||
ProfilePreset { id: "wave", label: "Wave", points: &[(0.0, 0.3, 0.3), (0.25, 1.0, 1.0), (0.5, 0.3, 0.3), (0.75, 1.0, 1.0), (1.0, 0.3, 0.3)] },
|
||||
];
|
||||
/// The built-in profile with this id.
|
||||
pub fn preset(id: &str) -> Option<Self> {
|
||||
Self::PRESETS.iter().find(|p| p.id == id).map(|p| Self { points: p.points.to_vec() })
|
||||
}
|
||||
/// The id of the built-in profile these points match, if any.
|
||||
pub fn preset_id(&self) -> Option<&'static str> {
|
||||
Self::PRESETS.iter().find(|p| p.points == self.points.as_slice()).map(|p| p.id)
|
||||
}
|
||||
/// The id of a stroke's profile: "uniform" without one, "custom" when it matches no preset.
|
||||
pub fn id_of(p: Option<&Self>) -> &'static str {
|
||||
p.map_or(Some("uniform"), Self::preset_id).unwrap_or("custom")
|
||||
}
|
||||
/// The lens profile (thin ends, full width in the middle).
|
||||
pub fn lens() -> Self {
|
||||
Self::preset("lens").unwrap_or_default()
|
||||
}
|
||||
pub fn taper_end() -> Self {
|
||||
Self::preset("taperEnd").unwrap_or_default()
|
||||
}
|
||||
pub fn taper_start() -> Self {
|
||||
Self::preset("taperStart").unwrap_or_default()
|
||||
}
|
||||
/// The (left, right) factors just before and just after `t`: they differ only at a
|
||||
/// discontinuous point (two points at `t`, within `1e-7`).
|
||||
pub fn around(&self, t: f64) -> ((f64, f64), (f64, f64)) {
|
||||
let p = &self.points;
|
||||
if let Some(i) = p.iter().position(|q| (q.0 - t).abs() <= 1e-7) {
|
||||
let j = i + p[i..].iter().take_while(|q| (q.0 - p[i].0).abs() <= 1e-9).count() - 1;
|
||||
if j > i {
|
||||
return ((p[i].1, p[i].2), (p[j].1, p[j].2));
|
||||
}
|
||||
}
|
||||
let v = self.at(t);
|
||||
(v, v)
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 };
|
||||
// (Positions go forward; a profile read from a file is searched as if they do, in log time.)
|
||||
let v = if t <= first.0 {
|
||||
first.1
|
||||
} else {
|
||||
let i = p.partition_point(|q| q.0 < t);
|
||||
match (i.checked_sub(1).and_then(|j| p.get(j)), p.get(i)) {
|
||||
(Some(a), Some(b)) => a.1 + (b.1 - a.1) * ((t - a.0) / (b.0 - a.0).max(1e-9)).clamp(0.0, 1.0),
|
||||
_ => 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)]
|
||||
pub struct SavedProfile {
|
||||
pub name: String,
|
||||
pub profile: WidthProfile,
|
||||
}
|
||||
|
||||
/// A live effect in an appearance stack. Parameters are interpreted by `vectorcraft-effects`.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Effect {
|
||||
/// Stable id, e.g. `stylize.dropShadow`, `distort.roughen`, `path.offsetPath`, `warp.arc`.
|
||||
pub id: String,
|
||||
#[serde(default)]
|
||||
pub params: serde_json::Value,
|
||||
#[serde(default = "yes")]
|
||||
pub visible: bool,
|
||||
}
|
||||
|
||||
fn yes() -> bool {
|
||||
true
|
||||
}
|
||||
fn one() -> f32 {
|
||||
1.0
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct FillLayer {
|
||||
pub paint: Paint,
|
||||
#[serde(default = "one", skip_serializing_if = "crate::skip::is_one")]
|
||||
pub opacity: f32,
|
||||
#[serde(default, skip_serializing_if = "crate::skip::is_default")]
|
||||
pub blend: BlendMode,
|
||||
#[serde(default = "yes", skip_serializing_if = "crate::skip::is_true")]
|
||||
pub visible: bool,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub effects: Vec<Effect>,
|
||||
/// Overprint Fill: on press the fill's inks print over the inks below instead of knocking
|
||||
/// them out (Overprint Preview and Separations Preview show it).
|
||||
#[serde(default, skip_serializing_if = "crate::skip::is_default")]
|
||||
pub overprint: bool,
|
||||
}
|
||||
|
||||
impl FillLayer {
|
||||
pub fn new(paint: Paint) -> Self {
|
||||
Self { paint, opacity: 1.0, blend: BlendMode::Normal, visible: true, effects: vec![], overprint: false }
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct StrokeLayer {
|
||||
pub paint: Paint,
|
||||
/// Weight in points.
|
||||
pub width: f64,
|
||||
#[serde(default, skip_serializing_if = "crate::skip::is_default")]
|
||||
pub cap: LineCap,
|
||||
#[serde(default, skip_serializing_if = "crate::skip::is_default")]
|
||||
pub join: LineJoin,
|
||||
#[serde(default = "ten", skip_serializing_if = "is_ten")]
|
||||
pub miter_limit: f64,
|
||||
#[serde(default, skip_serializing_if = "crate::skip::is_default")]
|
||||
pub align: StrokeAlign,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub dash: Option<Dash>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub start_arrow: Option<Arrowhead>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub end_arrow: Option<Arrowhead>,
|
||||
/// Arrowhead scale in percent (start, end).
|
||||
#[serde(default = "hundreds", skip_serializing_if = "is_hundreds")]
|
||||
pub arrow_scale: (f64, f64),
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub profile: Option<WidthProfile>,
|
||||
/// Brush applied to the stroke (by brush name).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub brush: Option<String>,
|
||||
#[serde(default = "one", skip_serializing_if = "crate::skip::is_one")]
|
||||
pub opacity: f32,
|
||||
#[serde(default, skip_serializing_if = "crate::skip::is_default")]
|
||||
pub blend: BlendMode,
|
||||
#[serde(default = "yes", skip_serializing_if = "crate::skip::is_true")]
|
||||
pub visible: bool,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub effects: Vec<Effect>,
|
||||
/// Arrowhead placement at both ends.
|
||||
#[serde(default, skip_serializing_if = "crate::skip::is_default")]
|
||||
pub arrow_align: ArrowAlign,
|
||||
/// Overprint Stroke (see [`FillLayer::overprint`]).
|
||||
#[serde(default, skip_serializing_if = "crate::skip::is_default")]
|
||||
pub overprint: bool,
|
||||
/// 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
|
||||
/// name: new modes are appended, never renamed.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub enum StrokeGradientMode {
|
||||
/// Placed on the page like a fill's gradient and seen through the stroke.
|
||||
#[default]
|
||||
Within,
|
||||
/// From the start of each subpath to its end, following the path.
|
||||
Along,
|
||||
/// From the stroke's left edge to its right edge (left of the path's direction), all along it.
|
||||
Across,
|
||||
}
|
||||
|
||||
impl StrokeGradientMode {
|
||||
pub const ALL: [StrokeGradientMode; 3] = [StrokeGradientMode::Within, StrokeGradientMode::Along, StrokeGradientMode::Across];
|
||||
/// The name commands use (`within`, `along`, `across`).
|
||||
pub fn name(self) -> &'static str {
|
||||
match self {
|
||||
StrokeGradientMode::Within => "within",
|
||||
StrokeGradientMode::Along => "along",
|
||||
StrokeGradientMode::Across => "across",
|
||||
}
|
||||
}
|
||||
/// Parse a mode name (any case).
|
||||
pub fn parse(s: &str) -> Option<Self> {
|
||||
Self::ALL.into_iter().find(|m| m.name().eq_ignore_ascii_case(s))
|
||||
}
|
||||
}
|
||||
|
||||
/// How far any arrowhead reaches from its tip, in units of its weight (the open arrow's arms are
|
||||
/// cut square a little behind its 4 × 4 box).
|
||||
pub(crate) const HEAD_REACH: f64 = 4.6;
|
||||
|
||||
fn ten() -> f64 {
|
||||
10.0
|
||||
}
|
||||
fn hundreds() -> (f64, f64) {
|
||||
(100.0, 100.0)
|
||||
}
|
||||
fn is_ten(v: &f64) -> bool {
|
||||
*v == 10.0
|
||||
}
|
||||
fn is_hundreds(v: &(f64, f64)) -> bool {
|
||||
*v == (100.0, 100.0)
|
||||
}
|
||||
|
||||
impl StrokeLayer {
|
||||
pub fn new(paint: Paint, width: f64) -> Self {
|
||||
Self {
|
||||
paint,
|
||||
width,
|
||||
cap: LineCap::Butt,
|
||||
join: LineJoin::Miter,
|
||||
miter_limit: 10.0,
|
||||
align: StrokeAlign::Center,
|
||||
dash: None,
|
||||
start_arrow: None,
|
||||
end_arrow: None,
|
||||
arrow_scale: (100.0, 100.0),
|
||||
profile: None,
|
||||
brush: None,
|
||||
opacity: 1.0,
|
||||
blend: BlendMode::Normal,
|
||||
visible: true,
|
||||
effects: vec![],
|
||||
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
|
||||
/// a quarter point. A head of weight `hw` fits a box about `4·hw` long and wide.
|
||||
pub fn arrow_weight(&self, end: bool) -> f64 {
|
||||
let pct = if end { self.arrow_scale.1 } else { self.arrow_scale.0 };
|
||||
(self.width * pct / 100.0).max(0.25)
|
||||
}
|
||||
/// How far the start (`end == false`) or end arrowhead can reach from its end point (0
|
||||
/// without one): the head's diagonal, or with [`ArrowAlign::Extend`] its length, plus the cap.
|
||||
pub fn head_reach(&self, end: bool) -> f64 {
|
||||
let head = if end { self.end_arrow } else { self.start_arrow };
|
||||
head.map_or(0.0, |_| HEAD_REACH * self.arrow_weight(end) + self.width / 2.0)
|
||||
}
|
||||
/// How far the arrowheads can reach from the path's end points (0 without heads).
|
||||
pub fn arrow_reach(&self) -> f64 {
|
||||
self.head_reach(false).max(self.head_reach(true))
|
||||
}
|
||||
/// The widest the width profile makes the stroke, as a factor of its weight (1 without one).
|
||||
pub fn profile_max(&self) -> f64 {
|
||||
self.profile.as_ref().and_then(|p| p.points.iter().map(|&(_, l, r)| l.max(r)).reduce(f64::max)).unwrap_or(1.0).max(0.0)
|
||||
}
|
||||
/// The stroke's body across a path that is `closed` or open: how far it reaches from the path
|
||||
/// at full width (half the weight centred; the whole weight on one side when aligned inside or
|
||||
/// outside a closed path, which open paths ignore), and for aligned strokes which side it
|
||||
/// paints (`Some(true)`: inside).
|
||||
pub fn body(&self, closed: bool) -> (f64, Option<bool>) {
|
||||
match self.align {
|
||||
StrokeAlign::Outside if closed => (self.width, Some(false)),
|
||||
StrokeAlign::Inside if closed => (self.width, Some(true)),
|
||||
_ => (self.width / 2.0, None),
|
||||
}
|
||||
}
|
||||
/// How far the body paints outside a path that is `closed` or open, at the profile's widest.
|
||||
pub fn side_reach(&self, closed: bool) -> f64 {
|
||||
match self.body(closed) {
|
||||
(_, Some(true)) => 0.0,
|
||||
(half, _) => half * self.profile_max(),
|
||||
}
|
||||
}
|
||||
/// The farthest this stroke can paint from any path: its body, miter spikes up to the miter
|
||||
/// limit, projecting caps' corners and the arrowheads ([`Appearance::outset`]).
|
||||
pub fn reach(&self) -> f64 {
|
||||
let side = self.side_reach(true).max(self.side_reach(false));
|
||||
let miter = if self.join == LineJoin::Miter { self.miter_limit.max(1.0) } else { 1.0 };
|
||||
let cap = if self.cap == LineCap::Square { std::f64::consts::SQRT_2 } else { 1.0 };
|
||||
(side * miter.max(cap)).max(self.arrow_reach())
|
||||
}
|
||||
/// The box an unplaced gradient on this stroke fits to (see [`stroke_paint_bounds`]).
|
||||
pub fn paint_bounds(&self, geometric: vectorcraft_geom::Rect) -> vectorcraft_geom::Rect {
|
||||
stroke_paint_bounds(geometric, self.width)
|
||||
}
|
||||
/// The gradient this stroke lays along or across its path: its linear or radial gradient
|
||||
/// paint when [`Self::gradient_mode`] isn't Within (freeform gradients always paint within).
|
||||
pub fn path_gradient(&self) -> Option<&vectorcraft_color::GradientPaint> {
|
||||
match &self.paint {
|
||||
Paint::Gradient(g)
|
||||
if self.gradient_mode != StrokeGradientMode::Within && g.gradient.kind != vectorcraft_color::GradientKind::Freeform =>
|
||||
{
|
||||
Some(g)
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The box an unplaced gradient on a stroke of `width` fits to: the geometric bounds grown by half
|
||||
/// the weight (object strokes and type strokes alike, on screen and in exports).
|
||||
pub fn stroke_paint_bounds(geometric: vectorcraft_geom::Rect, width: f64) -> vectorcraft_geom::Rect {
|
||||
geometric.inflate(width / 2.0, width / 2.0)
|
||||
}
|
||||
pub(crate) use layers::HEAD_REACH;
|
||||
pub use layers::{Effect, FillLayer, StrokeGradientMode, StrokeLayer, stroke_paint_bounds};
|
||||
pub use profile::{PressureProfile, ProfilePreset, SavedProfile, WidthProfile};
|
||||
pub use stroke_style::{ArrowAlign, Arrowhead, Dash, LineCap, LineJoin, StrokeAlign};
|
||||
|
||||
/// One entry of the appearance stack.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
|
||||
@@ -0,0 +1,240 @@
|
||||
use super::profile::{PressureProfile, WidthProfile};
|
||||
use super::stroke_style::{ArrowAlign, Arrowhead, Dash, LineCap, LineJoin, StrokeAlign};
|
||||
use super::*;
|
||||
|
||||
/// A live effect in an appearance stack. Parameters are interpreted by `vectorcraft-effects`.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Effect {
|
||||
/// Stable id, e.g. `stylize.dropShadow`, `distort.roughen`, `path.offsetPath`, `warp.arc`.
|
||||
pub id: String,
|
||||
#[serde(default)]
|
||||
pub params: serde_json::Value,
|
||||
#[serde(default = "yes")]
|
||||
pub visible: bool,
|
||||
}
|
||||
|
||||
fn yes() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn one() -> f32 {
|
||||
1.0
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct FillLayer {
|
||||
pub paint: Paint,
|
||||
#[serde(default = "one", skip_serializing_if = "crate::skip::is_one")]
|
||||
pub opacity: f32,
|
||||
#[serde(default, skip_serializing_if = "crate::skip::is_default")]
|
||||
pub blend: BlendMode,
|
||||
#[serde(default = "yes", skip_serializing_if = "crate::skip::is_true")]
|
||||
pub visible: bool,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub effects: Vec<Effect>,
|
||||
/// Overprint Fill: on press the fill's inks print over the inks below instead of knocking
|
||||
/// them out (Overprint Preview and Separations Preview show it).
|
||||
#[serde(default, skip_serializing_if = "crate::skip::is_default")]
|
||||
pub overprint: bool,
|
||||
}
|
||||
|
||||
impl FillLayer {
|
||||
pub fn new(paint: Paint) -> Self {
|
||||
Self { paint, opacity: 1.0, blend: BlendMode::Normal, visible: true, effects: vec![], overprint: false }
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct StrokeLayer {
|
||||
pub paint: Paint,
|
||||
/// Weight in points.
|
||||
pub width: f64,
|
||||
#[serde(default, skip_serializing_if = "crate::skip::is_default")]
|
||||
pub cap: LineCap,
|
||||
#[serde(default, skip_serializing_if = "crate::skip::is_default")]
|
||||
pub join: LineJoin,
|
||||
#[serde(default = "ten", skip_serializing_if = "is_ten")]
|
||||
pub miter_limit: f64,
|
||||
#[serde(default, skip_serializing_if = "crate::skip::is_default")]
|
||||
pub align: StrokeAlign,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub dash: Option<Dash>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub start_arrow: Option<Arrowhead>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub end_arrow: Option<Arrowhead>,
|
||||
/// Arrowhead scale in percent (start, end).
|
||||
#[serde(default = "hundreds", skip_serializing_if = "is_hundreds")]
|
||||
pub arrow_scale: (f64, f64),
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub profile: Option<WidthProfile>,
|
||||
/// Brush applied to the stroke (by brush name).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub brush: Option<String>,
|
||||
#[serde(default = "one", skip_serializing_if = "crate::skip::is_one")]
|
||||
pub opacity: f32,
|
||||
#[serde(default, skip_serializing_if = "crate::skip::is_default")]
|
||||
pub blend: BlendMode,
|
||||
#[serde(default = "yes", skip_serializing_if = "crate::skip::is_true")]
|
||||
pub visible: bool,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub effects: Vec<Effect>,
|
||||
/// Arrowhead placement at both ends.
|
||||
#[serde(default, skip_serializing_if = "crate::skip::is_default")]
|
||||
pub arrow_align: ArrowAlign,
|
||||
/// Overprint Stroke (see [`FillLayer::overprint`]).
|
||||
#[serde(default, skip_serializing_if = "crate::skip::is_default")]
|
||||
pub overprint: bool,
|
||||
/// 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
|
||||
/// name: new modes are appended, never renamed.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub enum StrokeGradientMode {
|
||||
/// Placed on the page like a fill's gradient and seen through the stroke.
|
||||
#[default]
|
||||
Within,
|
||||
/// From the start of each subpath to its end, following the path.
|
||||
Along,
|
||||
/// From the stroke's left edge to its right edge (left of the path's direction), all along it.
|
||||
Across,
|
||||
}
|
||||
|
||||
impl StrokeGradientMode {
|
||||
pub const ALL: [StrokeGradientMode; 3] = [StrokeGradientMode::Within, StrokeGradientMode::Along, StrokeGradientMode::Across];
|
||||
/// The name commands use (`within`, `along`, `across`).
|
||||
pub fn name(self) -> &'static str {
|
||||
match self {
|
||||
StrokeGradientMode::Within => "within",
|
||||
StrokeGradientMode::Along => "along",
|
||||
StrokeGradientMode::Across => "across",
|
||||
}
|
||||
}
|
||||
/// Parse a mode name (any case).
|
||||
pub fn parse(s: &str) -> Option<Self> {
|
||||
Self::ALL.into_iter().find(|m| m.name().eq_ignore_ascii_case(s))
|
||||
}
|
||||
}
|
||||
|
||||
/// How far any arrowhead reaches from its tip, in units of its weight (the open arrow's arms are
|
||||
/// cut square a little behind its 4 × 4 box).
|
||||
pub(crate) const HEAD_REACH: f64 = 4.6;
|
||||
|
||||
fn ten() -> f64 {
|
||||
10.0
|
||||
}
|
||||
|
||||
fn hundreds() -> (f64, f64) {
|
||||
(100.0, 100.0)
|
||||
}
|
||||
|
||||
fn is_ten(v: &f64) -> bool {
|
||||
*v == 10.0
|
||||
}
|
||||
|
||||
fn is_hundreds(v: &(f64, f64)) -> bool {
|
||||
*v == (100.0, 100.0)
|
||||
}
|
||||
|
||||
impl StrokeLayer {
|
||||
pub fn new(paint: Paint, width: f64) -> Self {
|
||||
Self {
|
||||
paint,
|
||||
width,
|
||||
cap: LineCap::Butt,
|
||||
join: LineJoin::Miter,
|
||||
miter_limit: 10.0,
|
||||
align: StrokeAlign::Center,
|
||||
dash: None,
|
||||
start_arrow: None,
|
||||
end_arrow: None,
|
||||
arrow_scale: (100.0, 100.0),
|
||||
profile: None,
|
||||
brush: None,
|
||||
opacity: 1.0,
|
||||
blend: BlendMode::Normal,
|
||||
visible: true,
|
||||
effects: vec![],
|
||||
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
|
||||
/// a quarter point. A head of weight `hw` fits a box about `4·hw` long and wide.
|
||||
pub fn arrow_weight(&self, end: bool) -> f64 {
|
||||
let pct = if end { self.arrow_scale.1 } else { self.arrow_scale.0 };
|
||||
(self.width * pct / 100.0).max(0.25)
|
||||
}
|
||||
/// How far the start (`end == false`) or end arrowhead can reach from its end point (0
|
||||
/// without one): the head's diagonal, or with [`ArrowAlign::Extend`] its length, plus the cap.
|
||||
pub fn head_reach(&self, end: bool) -> f64 {
|
||||
let head = if end { self.end_arrow } else { self.start_arrow };
|
||||
head.map_or(0.0, |_| HEAD_REACH * self.arrow_weight(end) + self.width / 2.0)
|
||||
}
|
||||
/// How far the arrowheads can reach from the path's end points (0 without heads).
|
||||
pub fn arrow_reach(&self) -> f64 {
|
||||
self.head_reach(false).max(self.head_reach(true))
|
||||
}
|
||||
/// The widest the width profile makes the stroke, as a factor of its weight (1 without one).
|
||||
pub fn profile_max(&self) -> f64 {
|
||||
self.profile.as_ref().and_then(|p| p.points.iter().map(|&(_, l, r)| l.max(r)).reduce(f64::max)).unwrap_or(1.0).max(0.0)
|
||||
}
|
||||
/// The stroke's body across a path that is `closed` or open: how far it reaches from the path
|
||||
/// at full width (half the weight centred; the whole weight on one side when aligned inside or
|
||||
/// outside a closed path, which open paths ignore), and for aligned strokes which side it
|
||||
/// paints (`Some(true)`: inside).
|
||||
pub fn body(&self, closed: bool) -> (f64, Option<bool>) {
|
||||
match self.align {
|
||||
StrokeAlign::Outside if closed => (self.width, Some(false)),
|
||||
StrokeAlign::Inside if closed => (self.width, Some(true)),
|
||||
_ => (self.width / 2.0, None),
|
||||
}
|
||||
}
|
||||
/// How far the body paints outside a path that is `closed` or open, at the profile's widest.
|
||||
pub fn side_reach(&self, closed: bool) -> f64 {
|
||||
match self.body(closed) {
|
||||
(_, Some(true)) => 0.0,
|
||||
(half, _) => half * self.profile_max(),
|
||||
}
|
||||
}
|
||||
/// The farthest this stroke can paint from any path: its body, miter spikes up to the miter
|
||||
/// limit, projecting caps' corners and the arrowheads ([`Appearance::outset`]).
|
||||
pub fn reach(&self) -> f64 {
|
||||
let side = self.side_reach(true).max(self.side_reach(false));
|
||||
let miter = if self.join == LineJoin::Miter { self.miter_limit.max(1.0) } else { 1.0 };
|
||||
let cap = if self.cap == LineCap::Square { std::f64::consts::SQRT_2 } else { 1.0 };
|
||||
(side * miter.max(cap)).max(self.arrow_reach())
|
||||
}
|
||||
/// The box an unplaced gradient on this stroke fits to (see [`stroke_paint_bounds`]).
|
||||
pub fn paint_bounds(&self, geometric: vectorcraft_geom::Rect) -> vectorcraft_geom::Rect {
|
||||
stroke_paint_bounds(geometric, self.width)
|
||||
}
|
||||
/// The gradient this stroke lays along or across its path: its linear or radial gradient
|
||||
/// paint when [`Self::gradient_mode`] isn't Within (freeform gradients always paint within).
|
||||
pub fn path_gradient(&self) -> Option<&vectorcraft_color::GradientPaint> {
|
||||
match &self.paint {
|
||||
Paint::Gradient(g)
|
||||
if self.gradient_mode != StrokeGradientMode::Within && g.gradient.kind != vectorcraft_color::GradientKind::Freeform =>
|
||||
{
|
||||
Some(g)
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The box an unplaced gradient on a stroke of `width` fits to: the geometric bounds grown by half
|
||||
/// the weight (object strokes and type strokes alike, on screen and in exports).
|
||||
pub fn stroke_paint_bounds(geometric: vectorcraft_geom::Rect, width: f64) -> vectorcraft_geom::Rect {
|
||||
geometric.inflate(width / 2.0, width / 2.0)
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
use super::*;
|
||||
|
||||
/// Variable-width profile: (position 0..1 along the path, left width factor, right width factor),
|
||||
/// in order along the path. Two points at the same position make a discontinuous point: the
|
||||
/// first is the width just before it, the second the width just after it (a step).
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
pub struct WidthProfile {
|
||||
pub points: Vec<(f64, f64, f64)>,
|
||||
}
|
||||
|
||||
/// A built-in width profile (the Stroke panel's Profile list).
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct ProfilePreset {
|
||||
/// Stable id used by `stroke.set {profile}`.
|
||||
pub id: &'static str,
|
||||
/// Menu label.
|
||||
pub label: &'static str,
|
||||
/// (t, left, right) width points.
|
||||
pub points: &'static [(f64, f64, f64)],
|
||||
}
|
||||
|
||||
impl WidthProfile {
|
||||
/// (left, right) width factors at `t`, linear between points.
|
||||
pub fn at(&self, t: f64) -> (f64, f64) {
|
||||
Self::at_points(&self.points, t)
|
||||
}
|
||||
/// [`Self::at`] of a profile's points (a preset's or a saved profile's).
|
||||
pub fn at_points(p: &[(f64, f64, f64)], t: f64) -> (f64, f64) {
|
||||
if p.is_empty() {
|
||||
return (1.0, 1.0);
|
||||
}
|
||||
if t <= p[0].0 {
|
||||
return (p[0].1, p[0].2);
|
||||
}
|
||||
for w in p.windows(2) {
|
||||
if t <= w[1].0 {
|
||||
let u = (t - w[0].0) / (w[1].0 - w[0].0).max(1e-9);
|
||||
return (w[0].1 + (w[1].1 - w[0].1) * u, w[0].2 + (w[1].2 - w[0].2) * u);
|
||||
}
|
||||
}
|
||||
p.last().map_or((1.0, 1.0), |l| (l.1, l.2))
|
||||
}
|
||||
/// The built-in profiles, in menu order. "uniform" is the plain stroke (no profile).
|
||||
pub const PRESETS: [ProfilePreset; 7] = [
|
||||
ProfilePreset { id: "uniform", label: "Uniform", points: &[(0.0, 1.0, 1.0), (1.0, 1.0, 1.0)] },
|
||||
ProfilePreset { id: "lens", label: "Lens", points: &[(0.0, 0.0, 0.0), (0.5, 1.0, 1.0), (1.0, 0.0, 0.0)] },
|
||||
ProfilePreset { id: "taperStart", label: "Taper Start", points: &[(0.0, 0.0, 0.0), (1.0, 1.0, 1.0)] },
|
||||
ProfilePreset { id: "taperEnd", label: "Taper End", points: &[(0.0, 1.0, 1.0), (1.0, 0.0, 0.0)] },
|
||||
// Full width at both ends, pinched to a quarter in the middle.
|
||||
ProfilePreset { id: "pinch", label: "Pinch", points: &[(0.0, 1.0, 1.0), (0.5, 0.25, 0.25), (1.0, 1.0, 1.0)] },
|
||||
// A round head a fifth of the way along, then a long taper to the end.
|
||||
ProfilePreset { id: "teardrop", label: "Teardrop", points: &[(0.0, 0.0, 0.0), (0.2, 1.0, 1.0), (1.0, 0.0, 0.0)] },
|
||||
// Two swells between narrow necks.
|
||||
ProfilePreset { id: "wave", label: "Wave", points: &[(0.0, 0.3, 0.3), (0.25, 1.0, 1.0), (0.5, 0.3, 0.3), (0.75, 1.0, 1.0), (1.0, 0.3, 0.3)] },
|
||||
];
|
||||
/// The built-in profile with this id.
|
||||
pub fn preset(id: &str) -> Option<Self> {
|
||||
Self::PRESETS.iter().find(|p| p.id == id).map(|p| Self { points: p.points.to_vec() })
|
||||
}
|
||||
/// The id of the built-in profile these points match, if any.
|
||||
pub fn preset_id(&self) -> Option<&'static str> {
|
||||
Self::PRESETS.iter().find(|p| p.points == self.points.as_slice()).map(|p| p.id)
|
||||
}
|
||||
/// The id of a stroke's profile: "uniform" without one, "custom" when it matches no preset.
|
||||
pub fn id_of(p: Option<&Self>) -> &'static str {
|
||||
p.map_or(Some("uniform"), Self::preset_id).unwrap_or("custom")
|
||||
}
|
||||
/// The lens profile (thin ends, full width in the middle).
|
||||
pub fn lens() -> Self {
|
||||
Self::preset("lens").unwrap_or_default()
|
||||
}
|
||||
pub fn taper_end() -> Self {
|
||||
Self::preset("taperEnd").unwrap_or_default()
|
||||
}
|
||||
pub fn taper_start() -> Self {
|
||||
Self::preset("taperStart").unwrap_or_default()
|
||||
}
|
||||
/// The (left, right) factors just before and just after `t`: they differ only at a
|
||||
/// discontinuous point (two points at `t`, within `1e-7`).
|
||||
pub fn around(&self, t: f64) -> ((f64, f64), (f64, f64)) {
|
||||
let p = &self.points;
|
||||
if let Some(i) = p.iter().position(|q| (q.0 - t).abs() <= 1e-7) {
|
||||
let j = i + p[i..].iter().take_while(|q| (q.0 - p[i].0).abs() <= 1e-9).count() - 1;
|
||||
if j > i {
|
||||
return ((p[i].1, p[i].2), (p[j].1, p[j].2));
|
||||
}
|
||||
}
|
||||
let v = self.at(t);
|
||||
(v, v)
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 };
|
||||
// (Positions go forward; a profile read from a file is searched as if they do, in log time.)
|
||||
let v = if t <= first.0 {
|
||||
first.1
|
||||
} else {
|
||||
let i = p.partition_point(|q| q.0 < t);
|
||||
match (i.checked_sub(1).and_then(|j| p.get(j)), p.get(i)) {
|
||||
(Some(a), Some(b)) => a.1 + (b.1 - a.1) * ((t - a.0) / (b.0 - a.0).max(1e-9)).clamp(0.0, 1.0),
|
||||
_ => 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)]
|
||||
pub struct SavedProfile {
|
||||
pub name: String,
|
||||
pub profile: WidthProfile,
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
use super::*;
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub enum LineCap {
|
||||
#[default]
|
||||
Butt,
|
||||
Round,
|
||||
Square,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub enum LineJoin {
|
||||
#[default]
|
||||
Miter,
|
||||
Round,
|
||||
Bevel,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub enum StrokeAlign {
|
||||
#[default]
|
||||
Center,
|
||||
Inside,
|
||||
Outside,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Dash {
|
||||
/// Dash, gap, dash, gap… (up to 6 values in the Stroke panel).
|
||||
pub pattern: Vec<f64>,
|
||||
#[serde(default)]
|
||||
pub offset: f64,
|
||||
/// "Aligns dashes to corners and path ends, adjusting lengths to fit".
|
||||
#[serde(default)]
|
||||
pub align_corners: bool,
|
||||
}
|
||||
|
||||
impl Dash {
|
||||
/// Whether the pattern draws dashes. As in PDF (`d` operator) and SVG (`stroke-dasharray`), a
|
||||
/// pattern with a negative or non-finite value, or whose values are all zero, is invalid and the
|
||||
/// stroke is drawn solid.
|
||||
pub fn is_dashed(&self) -> bool {
|
||||
self.pattern.iter().all(|v| v.is_finite() && *v >= 0.0) && self.pattern.iter().any(|v| *v > 0.0)
|
||||
}
|
||||
/// Scale the dash and gap lengths and the offset by `s`.
|
||||
pub fn scale(&mut self, s: f64) {
|
||||
self.pattern.iter_mut().for_each(|v| *v *= s);
|
||||
self.offset *= s;
|
||||
}
|
||||
}
|
||||
|
||||
/// An arrowhead shape. Saved by variant name: new shapes are appended, never renamed or reordered.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub enum Arrowhead {
|
||||
Triangle,
|
||||
TriangleOpen,
|
||||
Circle,
|
||||
CircleOpen,
|
||||
Square,
|
||||
SquareOpen,
|
||||
Bar,
|
||||
Diamond,
|
||||
Arrow,
|
||||
ArrowOpen,
|
||||
Barbed,
|
||||
/// One barb, on the left of the direction the head points.
|
||||
HalfArrowLeft,
|
||||
/// One barb, on the right of the direction the head points.
|
||||
HalfArrowRight,
|
||||
Concave,
|
||||
DoubleBar,
|
||||
Feather,
|
||||
DotOnBar,
|
||||
Chevron,
|
||||
DoubleArrow,
|
||||
Target,
|
||||
Star,
|
||||
Cross,
|
||||
Plus,
|
||||
Hexagon,
|
||||
HexagonOpen,
|
||||
Tag,
|
||||
TagOpen,
|
||||
HalfCircle,
|
||||
Drop,
|
||||
Slash,
|
||||
DoubleSlash,
|
||||
DiamondOpen,
|
||||
TriangleReverse,
|
||||
Swallowtail,
|
||||
Bracket,
|
||||
Fork,
|
||||
Leaf,
|
||||
Kite,
|
||||
TriangleBar,
|
||||
Oval,
|
||||
}
|
||||
|
||||
impl Arrowhead {
|
||||
/// Every arrowhead, in menu order (arrows, then outlined shapes, then bars and marks).
|
||||
pub const ALL: [Arrowhead; 40] = [
|
||||
Arrowhead::Arrow,
|
||||
Arrowhead::ArrowOpen,
|
||||
Arrowhead::Barbed,
|
||||
Arrowhead::Concave,
|
||||
Arrowhead::DoubleArrow,
|
||||
Arrowhead::HalfArrowLeft,
|
||||
Arrowhead::HalfArrowRight,
|
||||
Arrowhead::Chevron,
|
||||
Arrowhead::Feather,
|
||||
Arrowhead::Swallowtail,
|
||||
Arrowhead::Triangle,
|
||||
Arrowhead::TriangleOpen,
|
||||
Arrowhead::TriangleReverse,
|
||||
Arrowhead::TriangleBar,
|
||||
Arrowhead::Kite,
|
||||
Arrowhead::Leaf,
|
||||
Arrowhead::Drop,
|
||||
Arrowhead::Circle,
|
||||
Arrowhead::CircleOpen,
|
||||
Arrowhead::HalfCircle,
|
||||
Arrowhead::Oval,
|
||||
Arrowhead::Target,
|
||||
Arrowhead::Square,
|
||||
Arrowhead::SquareOpen,
|
||||
Arrowhead::Tag,
|
||||
Arrowhead::TagOpen,
|
||||
Arrowhead::Diamond,
|
||||
Arrowhead::DiamondOpen,
|
||||
Arrowhead::Hexagon,
|
||||
Arrowhead::HexagonOpen,
|
||||
Arrowhead::Star,
|
||||
Arrowhead::Cross,
|
||||
Arrowhead::Plus,
|
||||
Arrowhead::Bar,
|
||||
Arrowhead::DoubleBar,
|
||||
Arrowhead::DotOnBar,
|
||||
Arrowhead::Slash,
|
||||
Arrowhead::DoubleSlash,
|
||||
Arrowhead::Bracket,
|
||||
Arrowhead::Fork,
|
||||
];
|
||||
|
||||
/// The name the arrowhead menus show.
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
Arrowhead::Triangle => "Triangle",
|
||||
Arrowhead::TriangleOpen => "Triangle (open)",
|
||||
Arrowhead::Circle => "Circle",
|
||||
Arrowhead::CircleOpen => "Circle (open)",
|
||||
Arrowhead::Square => "Square",
|
||||
Arrowhead::SquareOpen => "Square (open)",
|
||||
Arrowhead::Bar => "Bar",
|
||||
Arrowhead::Diamond => "Diamond",
|
||||
Arrowhead::Arrow => "Arrow",
|
||||
Arrowhead::ArrowOpen => "Arrow (open)",
|
||||
Arrowhead::Barbed => "Barbed",
|
||||
Arrowhead::HalfArrowLeft => "Half Arrow (left)",
|
||||
Arrowhead::HalfArrowRight => "Half Arrow (right)",
|
||||
Arrowhead::Concave => "Concave",
|
||||
Arrowhead::DoubleBar => "Double Bar",
|
||||
Arrowhead::Feather => "Feather",
|
||||
Arrowhead::DotOnBar => "Dot on Bar",
|
||||
Arrowhead::Chevron => "Chevron",
|
||||
Arrowhead::DoubleArrow => "Double Arrow",
|
||||
Arrowhead::Target => "Target",
|
||||
Arrowhead::Star => "Star",
|
||||
Arrowhead::Cross => "Cross",
|
||||
Arrowhead::Plus => "Plus",
|
||||
Arrowhead::Hexagon => "Hexagon",
|
||||
Arrowhead::HexagonOpen => "Hexagon (open)",
|
||||
Arrowhead::Tag => "Tag",
|
||||
Arrowhead::TagOpen => "Tag (open)",
|
||||
Arrowhead::HalfCircle => "Half Circle",
|
||||
Arrowhead::Drop => "Drop",
|
||||
Arrowhead::Slash => "Slash",
|
||||
Arrowhead::DoubleSlash => "Double Slash",
|
||||
Arrowhead::DiamondOpen => "Diamond (open)",
|
||||
Arrowhead::TriangleReverse => "Reverse Triangle",
|
||||
Arrowhead::Swallowtail => "Swallowtail",
|
||||
Arrowhead::Bracket => "Bracket",
|
||||
Arrowhead::Fork => "Fork",
|
||||
Arrowhead::Leaf => "Leaf",
|
||||
Arrowhead::Kite => "Kite",
|
||||
Arrowhead::TriangleBar => "Triangle to Bar",
|
||||
Arrowhead::Oval => "Oval",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Where an arrowhead sits relative to the end of its path. In both modes the stroke stops under
|
||||
/// the head, so the line never shows through a hollow head or past its tip.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub enum ArrowAlign {
|
||||
/// The tip extends past the end point (the path keeps its length).
|
||||
#[default]
|
||||
Extend,
|
||||
/// The tip sits on the end point (the stroke is shortened by the head).
|
||||
Tip,
|
||||
}
|
||||
Reference in New Issue
Block a user