diff --git a/crates/doc/src/appearance.rs b/crates/doc/src/appearance.rs index 3853f5aec..2dc6cbabb 100644 --- a/crates/doc/src/appearance.rs +++ b/crates/doc/src/appearance.rs @@ -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, - #[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::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) -> Option { - 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 { - 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 { - 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, - /// 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, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub start_arrow: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub end_arrow: Option, - /// 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, - /// Brush applied to the stroke (by brush name). - #[serde(default, skip_serializing_if = "Option::is_none")] - pub brush: Option, - #[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, - /// 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, -} - -/// 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::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) { - 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)] diff --git a/crates/doc/src/appearance/layers.rs b/crates/doc/src/appearance/layers.rs new file mode 100644 index 000000000..6103e7dfd --- /dev/null +++ b/crates/doc/src/appearance/layers.rs @@ -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, + /// 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, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub start_arrow: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub end_arrow: Option, + /// 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, + /// Brush applied to the stroke (by brush name). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub brush: Option, + #[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, + /// 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, +} + +/// 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::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) { + 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) +} diff --git a/crates/doc/src/appearance/profile.rs b/crates/doc/src/appearance/profile.rs new file mode 100644 index 000000000..56df51ef6 --- /dev/null +++ b/crates/doc/src/appearance/profile.rs @@ -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::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) -> Option { + 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 { + 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 { + 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, +} diff --git a/crates/doc/src/appearance/stroke_style.rs b/crates/doc/src/appearance/stroke_style.rs new file mode 100644 index 000000000..3e6a29a32 --- /dev/null +++ b/crates/doc/src/appearance/stroke_style.rs @@ -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, + #[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, +}