Merge wp/C1
# Conflicts: # crates/doc/src/appearance.rs # crates/effects/src/stroke/width.rs # crates/engine/src/lib.rs # crates/pdf/src/export.rs # crates/render/src/lib.rs
This commit is contained in:
commit
41f2af932c
|
|
@ -89,6 +89,8 @@ Generated-in-code art is original and has no file to list. This covers the defau
|
||||||
| `assets/icons/dc-appearance.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
|
| `assets/icons/dc-appearance.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
|
||||||
| `assets/icons/dc-arc.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
|
| `assets/icons/dc-arc.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
|
||||||
| `assets/icons/dc-arrow-down.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
|
| `assets/icons/dc-arrow-down.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
|
||||||
|
| `assets/icons/dc-arrow-extend.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
|
||||||
|
| `assets/icons/dc-arrow-tip.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
|
||||||
| `assets/icons/dc-arrow-up.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
|
| `assets/icons/dc-arrow-up.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
|
||||||
| `assets/icons/dc-artboard-options.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
|
| `assets/icons/dc-artboard-options.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
|
||||||
| `assets/icons/dc-artboards.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
|
| `assets/icons/dc-artboards.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="butt" stroke-linejoin="miter"><path d="M2 12H14"/><path d="M13 7.5L21.5 12L13 16.5Z" fill="currentColor" stroke="none"/><path d="M14 4V20" stroke-width="1" stroke-dasharray="2 1.5"/></svg>
|
||||||
|
After Width: | Height: | Size: 341 B |
|
|
@ -0,0 +1 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="butt" stroke-linejoin="miter"><path d="M2 12H10"/><path d="M9.5 7.5L18 12L9.5 16.5Z" fill="currentColor" stroke="none"/><path d="M18 4V20" stroke-width="1" stroke-dasharray="2 1.5"/></svg>
|
||||||
|
After Width: | Height: | Size: 341 B |
|
|
@ -77,12 +77,34 @@ impl Arrowhead {
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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).
|
/// Variable-width profile: (position 0..1 along the path, left width factor, right width factor).
|
||||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||||
pub struct WidthProfile {
|
pub struct WidthProfile {
|
||||||
pub points: Vec<(f64, f64, f64)>,
|
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 {
|
impl WidthProfile {
|
||||||
/// Width factor at `t` (average of both sides), linear between points.
|
/// Width factor at `t` (average of both sides), linear between points.
|
||||||
pub fn at(&self, t: f64) -> (f64, f64) {
|
pub fn at(&self, t: f64) -> (f64, f64) {
|
||||||
|
|
@ -102,15 +124,34 @@ impl WidthProfile {
|
||||||
let l = p.last().unwrap();
|
let l = p.last().unwrap();
|
||||||
(l.1, l.2)
|
(l.1, l.2)
|
||||||
}
|
}
|
||||||
/// Illustrator's "Width Profile 1" (lens shape) analogue.
|
/// The built-in profiles, in menu order. "uniform" is the plain stroke (no profile).
|
||||||
|
pub const PRESETS: [ProfilePreset; 4] = [
|
||||||
|
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)] },
|
||||||
|
];
|
||||||
|
/// 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 {
|
pub fn lens() -> Self {
|
||||||
Self { points: vec![(0.0, 0.0, 0.0), (0.5, 1.0, 1.0), (1.0, 0.0, 0.0)] }
|
Self::preset("lens").expect("built-in")
|
||||||
}
|
}
|
||||||
pub fn taper_end() -> Self {
|
pub fn taper_end() -> Self {
|
||||||
Self { points: vec![(0.0, 1.0, 1.0), (1.0, 0.0, 0.0)] }
|
Self::preset("taperEnd").expect("built-in")
|
||||||
}
|
}
|
||||||
pub fn taper_start() -> Self {
|
pub fn taper_start() -> Self {
|
||||||
Self { points: vec![(0.0, 0.0, 0.0), (1.0, 1.0, 1.0)] }
|
Self::preset("taperStart").expect("built-in")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -186,6 +227,9 @@ pub struct StrokeLayer {
|
||||||
pub visible: bool,
|
pub visible: bool,
|
||||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||||
pub effects: Vec<Effect>,
|
pub effects: Vec<Effect>,
|
||||||
|
/// Arrowhead placement at both ends.
|
||||||
|
#[serde(default, skip_serializing_if = "crate::skip::is_default")]
|
||||||
|
pub arrow_align: ArrowAlign,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn ten() -> f64 {
|
fn ten() -> f64 {
|
||||||
|
|
@ -220,8 +264,21 @@ impl StrokeLayer {
|
||||||
blend: BlendMode::Normal,
|
blend: BlendMode::Normal,
|
||||||
visible: true,
|
visible: true,
|
||||||
effects: vec![],
|
effects: vec![],
|
||||||
|
arrow_align: ArrowAlign::Extend,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
/// Weight of the start (`end == false`) or end arrowhead: stroke weight × its scale, at least
|
||||||
|
/// a quarter point. A head of weight `hw` is `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 arrowheads can reach from the path's end points (0 without heads): the
|
||||||
|
/// head's diagonal, or with [`ArrowAlign::Extend`] its length plus the cap past the end.
|
||||||
|
pub fn arrow_reach(&self) -> f64 {
|
||||||
|
let reach = |head: Option<Arrowhead>, end: bool| head.map_or(0.0, |_| 4.5 * self.arrow_weight(end) + self.width / 2.0);
|
||||||
|
reach(self.start_arrow, false).max(reach(self.end_arrow, true))
|
||||||
|
}
|
||||||
/// The box an unplaced gradient on this stroke fits to (see [`stroke_paint_bounds`]).
|
/// 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 {
|
pub fn paint_bounds(&self, geometric: vectorcraft_geom::Rect) -> vectorcraft_geom::Rect {
|
||||||
stroke_paint_bounds(geometric, self.width)
|
stroke_paint_bounds(geometric, self.width)
|
||||||
|
|
@ -313,11 +370,14 @@ impl Appearance {
|
||||||
self.items
|
self.items
|
||||||
.iter()
|
.iter()
|
||||||
.filter_map(|i| match i {
|
.filter_map(|i| match i {
|
||||||
AppearanceItem::Stroke(s) if s.visible && !s.paint.is_none() => Some(match s.align {
|
AppearanceItem::Stroke(s) if s.visible && !s.paint.is_none() => Some(
|
||||||
StrokeAlign::Center => s.width / 2.0 * if s.join == LineJoin::Miter { s.miter_limit.min(4.0) } else { 1.0 },
|
match s.align {
|
||||||
StrokeAlign::Outside => s.width,
|
StrokeAlign::Center => s.width / 2.0 * if s.join == LineJoin::Miter { s.miter_limit.min(4.0) } else { 1.0 },
|
||||||
StrokeAlign::Inside => 0.0,
|
StrokeAlign::Outside => s.width,
|
||||||
}),
|
StrokeAlign::Inside => 0.0,
|
||||||
|
}
|
||||||
|
.max(s.arrow_reach()),
|
||||||
|
),
|
||||||
_ => None,
|
_ => None,
|
||||||
})
|
})
|
||||||
.fold(0.0, f64::max)
|
.fold(0.0, f64::max)
|
||||||
|
|
@ -414,6 +474,48 @@ mod tests {
|
||||||
assert_eq!(p.at(2.0), (0.0, 0.0));
|
assert_eq!(p.at(2.0), (0.0, 0.0));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn profile_presets_round_trip_their_ids() {
|
||||||
|
for p in WidthProfile::PRESETS {
|
||||||
|
let prof = WidthProfile::preset(p.id).unwrap();
|
||||||
|
assert_eq!(prof.preset_id(), Some(p.id));
|
||||||
|
assert_eq!(WidthProfile::id_of(Some(&prof)), p.id);
|
||||||
|
}
|
||||||
|
assert_eq!(WidthProfile::id_of(None), "uniform");
|
||||||
|
assert_eq!(WidthProfile::id_of(Some(&WidthProfile { points: vec![(0.0, 0.3, 0.3)] })), "custom");
|
||||||
|
assert!(WidthProfile::preset("nope").is_none());
|
||||||
|
assert_eq!(WidthProfile::lens().points, vec![(0.0, 0.0, 0.0), (0.5, 1.0, 1.0), (1.0, 0.0, 0.0)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn arrow_align_defaults_to_extend_and_round_trips() {
|
||||||
|
let mut st = StrokeLayer::new(Paint::solid(Color::BLACK), 2.0);
|
||||||
|
assert_eq!(st.arrow_align, ArrowAlign::Extend);
|
||||||
|
// The default is not written, so older readers see the same JSON as before.
|
||||||
|
assert!(!serde_json::to_string(&st).unwrap().contains("arrow_align"));
|
||||||
|
let old: StrokeLayer = serde_json::from_str(r#"{"paint":{"type":"none"},"width":2.0}"#).unwrap();
|
||||||
|
assert_eq!(old.arrow_align, ArrowAlign::Extend);
|
||||||
|
st.arrow_align = ArrowAlign::Tip;
|
||||||
|
let back: StrokeLayer = serde_json::from_str(&serde_json::to_string(&st).unwrap()).unwrap();
|
||||||
|
assert_eq!(back, st);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn outset_covers_arrowheads() {
|
||||||
|
let mut a = Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 4.0);
|
||||||
|
a.stroke_mut().unwrap().join = LineJoin::Round;
|
||||||
|
assert_eq!(a.outset(), 2.0);
|
||||||
|
let st = a.stroke_mut().unwrap();
|
||||||
|
st.end_arrow = Some(Arrowhead::Triangle);
|
||||||
|
st.arrow_scale = (100.0, 200.0);
|
||||||
|
assert_eq!(st.arrow_weight(false), 4.0);
|
||||||
|
assert_eq!(st.arrow_weight(true), 8.0);
|
||||||
|
// A 32 pt head whose tip sits up to its length (plus the cap) past the end point.
|
||||||
|
assert_eq!(a.outset(), 4.5 * 8.0 + 2.0);
|
||||||
|
a.stroke_mut().unwrap().width = 0.01;
|
||||||
|
assert_eq!(a.stroke().unwrap().arrow_weight(true), 0.25, "heads keep a minimum size");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn outset_depends_on_align() {
|
fn outset_depends_on_align() {
|
||||||
let mut a = Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 10.0);
|
let mut a = Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 10.0);
|
||||||
|
|
|
||||||
|
|
@ -31,7 +31,10 @@ pub(crate) mod skip {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub use appearance::{Appearance, AppearanceItem, Arrowhead, Dash, Effect, FillLayer, LineCap, LineJoin, StrokeAlign, StrokeLayer, WidthProfile};
|
pub use appearance::{
|
||||||
|
Appearance, AppearanceItem, ArrowAlign, Arrowhead, Dash, Effect, FillLayer, LineCap, LineJoin, ProfilePreset, StrokeAlign, StrokeLayer,
|
||||||
|
WidthProfile,
|
||||||
|
};
|
||||||
pub use graph::{GraphKind, GraphSpec};
|
pub use graph::{GraphKind, GraphSpec};
|
||||||
pub use hit::{Hit, HitKind};
|
pub use hit::{Hit, HitKind};
|
||||||
pub use live::{BlendOrientation, BlendSpacing, BlendSpec, EnvelopeKind, GradientMesh, MeshPoint};
|
pub use live::{BlendOrientation, BlendSpacing, BlendSpec, EnvelopeKind, GradientMesh, MeshPoint};
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,8 @@
|
||||||
//! - **Raster effects** (Drop Shadow, Inner/Outer Glow, Feather, Gaussian Blur) are described by
|
//! - **Raster effects** (Drop Shadow, Inner/Outer Glow, Feather, Gaussian Blur) are described by
|
||||||
//! [`raster_effects`] and painted by the renderer; [`outset`] says how far they reach beyond
|
//! [`raster_effects`] and painted by the renderer; [`outset`] says how far they reach beyond
|
||||||
//! the geometry.
|
//! the geometry.
|
||||||
|
//! - **Stroke geometry** ([`stroke`]): arrowheads, dash patterns and width profiles, shared by the
|
||||||
|
//! renderer, the exporters and Outline Stroke.
|
||||||
//! - [`effect_catalog`] lists every effect with its menu path, parameter documentation and the
|
//! - [`effect_catalog`] lists every effect with its menu path, parameter documentation and the
|
||||||
//! defaults of Illustrator's dialogs. Missing parameters always fall back to those defaults.
|
//! defaults of Illustrator's dialogs. Missing parameters always fall back to those defaults.
|
||||||
//!
|
//!
|
||||||
|
|
@ -19,6 +21,7 @@ mod bake;
|
||||||
mod distort;
|
mod distort;
|
||||||
mod group;
|
mod group;
|
||||||
mod raster;
|
mod raster;
|
||||||
|
pub mod stroke;
|
||||||
mod stylize;
|
mod stylize;
|
||||||
mod util;
|
mod util;
|
||||||
mod warp;
|
mod warp;
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,182 @@
|
||||||
|
//! Stroke geometry shared by the canvas, the PDF/SVG exporters and Outline Stroke, so every
|
||||||
|
//! consumer paints the same shapes:
|
||||||
|
//!
|
||||||
|
//! - [`stroke_pieces`]: the centre line (trimmed under the arrowheads) and the arrowheads as
|
||||||
|
//! filled outlines (hollow kinds are rings), placed by [`ArrowAlign`](vectorcraft_doc::ArrowAlign);
|
||||||
|
//! - [`dash`]: the dash pattern; zero-length dashes become [`Dot`]s that a round or projecting cap
|
||||||
|
//! turns into discs or squares ([`dot_outline`]);
|
||||||
|
//! - [`width_outline`]: variable-width (profile) strokes;
|
||||||
|
//! - [`line_outline`]: the line part of a stroke as one filled outline (profile, dashes, dots,
|
||||||
|
//! caps and joins).
|
||||||
|
|
||||||
|
mod arrow;
|
||||||
|
mod dash;
|
||||||
|
mod width;
|
||||||
|
|
||||||
|
use std::borrow::Cow;
|
||||||
|
|
||||||
|
use kurbo::{BezPath, ParamCurve, ParamCurveDeriv, PathEl, PathSeg, Vec2};
|
||||||
|
use vectorcraft_doc::{LineCap, LineJoin, StrokeAlign, StrokeLayer};
|
||||||
|
|
||||||
|
pub use arrow::Arrow;
|
||||||
|
pub use dash::{Dashed, Dot, dash, dot_outline};
|
||||||
|
pub use width::width_outline;
|
||||||
|
|
||||||
|
/// Arc-length accuracy for dashing and trimming (document points).
|
||||||
|
const ARCLEN_ACCURACY: f64 = 1e-6;
|
||||||
|
|
||||||
|
/// The geometry one stroke paints, in the coordinate space of its path.
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct StrokePieces<'a> {
|
||||||
|
/// The centre line to stroke: the path with its ends trimmed under the arrowheads (borrowed
|
||||||
|
/// when nothing is trimmed). Empty when the heads cover the whole path.
|
||||||
|
pub line: Cow<'a, BezPath>,
|
||||||
|
/// The arrowheads, start first. Closed ends get none.
|
||||||
|
pub heads: Vec<Arrow>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The centre line and arrowheads of stroke `st` along `bp`.
|
||||||
|
pub fn stroke_pieces<'a>(bp: &'a BezPath, st: &StrokeLayer) -> StrokePieces<'a> {
|
||||||
|
if st.start_arrow.is_none() && st.end_arrow.is_none() {
|
||||||
|
return StrokePieces { line: Cow::Borrowed(bp), heads: vec![] };
|
||||||
|
}
|
||||||
|
arrow::pieces(bp, st)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Does `bp` end with a `ClosePath`?
|
||||||
|
pub fn is_closed(bp: &BezPath) -> bool {
|
||||||
|
bp.elements().last().is_some_and(|e| matches!(e, PathEl::ClosePath))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The width actually stroked: inside/outside strokes of closed paths are drawn twice as wide
|
||||||
|
/// and clipped to one side; open paths always stroke centred.
|
||||||
|
pub fn aligned_width(st: &StrokeLayer, closed: bool) -> f64 {
|
||||||
|
match st.align {
|
||||||
|
StrokeAlign::Inside | StrokeAlign::Outside if closed => st.width * 2.0,
|
||||||
|
_ => st.width,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How far a cap reaches past the end of a stroke of `width`.
|
||||||
|
pub fn cap_extent(cap: LineCap, width: f64) -> f64 {
|
||||||
|
match cap {
|
||||||
|
LineCap::Butt => 0.0,
|
||||||
|
LineCap::Round | LineCap::Square => width / 2.0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The stroke style (cap, join, miter limit) of `st` at `width`, without dashes.
|
||||||
|
pub fn style(st: &StrokeLayer, width: f64) -> kurbo::Stroke {
|
||||||
|
kurbo::Stroke::new(width)
|
||||||
|
.with_join(match st.join {
|
||||||
|
LineJoin::Miter => kurbo::Join::Miter,
|
||||||
|
LineJoin::Round => kurbo::Join::Round,
|
||||||
|
LineJoin::Bevel => kurbo::Join::Bevel,
|
||||||
|
})
|
||||||
|
.with_caps(match st.cap {
|
||||||
|
LineCap::Butt => kurbo::Cap::Butt,
|
||||||
|
LineCap::Round => kurbo::Cap::Round,
|
||||||
|
LineCap::Square => kurbo::Cap::Square,
|
||||||
|
})
|
||||||
|
.with_miter_limit(st.miter_limit)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Does the dash pattern of `st` draw anything other than a solid line?
|
||||||
|
pub fn is_dashed(st: &StrokeLayer) -> bool {
|
||||||
|
st.dash.as_ref().is_some_and(|d| d.pattern.iter().any(|v| *v > 0.0))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The filled outline of the line part of `st` along `line` (usually [`StrokePieces::line`]) at
|
||||||
|
/// `width` (see [`aligned_width`]), flattened/fitted to `tol`: the width profile, or the dash
|
||||||
|
/// pattern with its dots, with the stroke's caps and joins. Fill it with the non-zero rule.
|
||||||
|
/// Arrowheads are not included.
|
||||||
|
pub fn line_outline(line: &BezPath, st: &StrokeLayer, width: f64, tol: f64) -> BezPath {
|
||||||
|
if line.elements().is_empty() || width <= 0.0 || !width.is_finite() {
|
||||||
|
return BezPath::new();
|
||||||
|
}
|
||||||
|
let tol = tol.max(1e-4);
|
||||||
|
if let Some(profile) = st.profile.as_ref().filter(|_| !is_dashed(st)) {
|
||||||
|
return width_outline(line, width, profile, st.cap, tol);
|
||||||
|
}
|
||||||
|
let style = style(st, width);
|
||||||
|
match st.dash.as_ref().and_then(|d| dash(line, d)) {
|
||||||
|
Some(d) => {
|
||||||
|
let mut out = kurbo::stroke(d.path.iter(), &style, &kurbo::StrokeOpts::default(), tol);
|
||||||
|
out.extend(dot_outline(&d.dots, width, st.cap, tol).iter());
|
||||||
|
out
|
||||||
|
}
|
||||||
|
None => kurbo::stroke(line.iter(), &style, &kurbo::StrokeOpts::default(), tol),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Unit tangent of `s` at `t`, robust at ends where control points coincide with the end point.
|
||||||
|
pub(crate) fn tangent(s: &PathSeg, t: f64) -> Vec2 {
|
||||||
|
let d = match s {
|
||||||
|
PathSeg::Line(l) => l.p1 - l.p0,
|
||||||
|
PathSeg::Quad(q) => {
|
||||||
|
let v = q.deriv().eval(t).to_vec2();
|
||||||
|
if v.hypot() > 1e-9 { v } else { q.p2 - q.p0 }
|
||||||
|
}
|
||||||
|
PathSeg::Cubic(c) => {
|
||||||
|
let v = c.deriv().eval(t).to_vec2();
|
||||||
|
if v.hypot() > 1e-9 {
|
||||||
|
v
|
||||||
|
} else if t < 0.5 {
|
||||||
|
// The first control point that differs from the start gives the direction.
|
||||||
|
[c.p2, c.p3].into_iter().map(|p| p - c.p0).find(|v| v.hypot() > 1e-9).unwrap_or_default()
|
||||||
|
} else {
|
||||||
|
[c.p1, c.p0].into_iter().map(|p| c.p3 - p).find(|v| v.hypot() > 1e-9).unwrap_or_default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
unit(d)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `v` normalised, or +x when it has no length.
|
||||||
|
pub(crate) fn unit(v: Vec2) -> Vec2 {
|
||||||
|
let h = v.hypot();
|
||||||
|
if h > 1e-12 { v / h } else { Vec2::new(1.0, 0.0) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Append `seg` to `out` (which must already have a current point at its start).
|
||||||
|
pub(crate) fn push_seg(out: &mut BezPath, seg: &PathSeg) {
|
||||||
|
match seg {
|
||||||
|
PathSeg::Line(l) => out.line_to(l.p1),
|
||||||
|
PathSeg::Quad(q) => out.quad_to(q.p1, q.p2),
|
||||||
|
PathSeg::Cubic(c) => out.curve_to(c.p1, c.p2, c.p3),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Element ranges of the subpaths of `bp` (each with whether it is closed).
|
||||||
|
pub(crate) fn subpaths(bp: &BezPath) -> Vec<(std::ops::Range<usize>, bool)> {
|
||||||
|
let els = bp.elements();
|
||||||
|
let mut out = vec![];
|
||||||
|
let mut start = 0;
|
||||||
|
for (i, el) in els.iter().enumerate() {
|
||||||
|
match el {
|
||||||
|
PathEl::MoveTo(_) if i > start => {
|
||||||
|
out.push((start..i, false));
|
||||||
|
start = i;
|
||||||
|
}
|
||||||
|
PathEl::ClosePath => {
|
||||||
|
out.push((start..i + 1, true));
|
||||||
|
start = i + 1;
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if start < els.len() {
|
||||||
|
out.push((start..els.len(), false));
|
||||||
|
}
|
||||||
|
// A lone MoveTo (or a MoveTo straight after a ClosePath) draws nothing.
|
||||||
|
out.retain(|(r, _)| r.len() > 1);
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The segments of one subpath (`els` starts with its MoveTo; a ClosePath adds the closing line).
|
||||||
|
pub(crate) fn segments(els: &[PathEl]) -> Vec<PathSeg> {
|
||||||
|
kurbo::segments(els.iter().copied()).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests;
|
||||||
|
|
@ -0,0 +1,257 @@
|
||||||
|
//! Arrowheads. A head of weight `hw` (stroke weight × scale) is `4·hw` long and `4·hw` wide.
|
||||||
|
//! Hollow kinds are rings (`hw/2` walls) or, for the open arrow, a chevron with `hw` arms. The
|
||||||
|
//! stroke ends under the head at the head's *inset* from the tip: halfway into solid heads, in
|
||||||
|
//! the back wall of hollow ones (with the cap kept out of the hole) and at the chevron's inner
|
||||||
|
//! corner. With [`ArrowAlign::Tip`] the tip sits on the end point and the stroke is shortened by
|
||||||
|
//! the inset; with [`ArrowAlign::Extend`] the path keeps its length and the tip sits the inset
|
||||||
|
//! past the end point.
|
||||||
|
|
||||||
|
use std::borrow::Cow;
|
||||||
|
|
||||||
|
use kurbo::{BezPath, ParamCurve, ParamCurveArclen, PathSeg, Point, Shape, Vec2};
|
||||||
|
use vectorcraft_doc::{ArrowAlign, Arrowhead, StrokeLayer};
|
||||||
|
|
||||||
|
use super::{ARCLEN_ACCURACY, StrokePieces, cap_extent, push_seg, segments, subpaths, tangent, unit};
|
||||||
|
|
||||||
|
/// One arrowhead.
|
||||||
|
#[derive(Clone, Debug, PartialEq)]
|
||||||
|
pub struct Arrow {
|
||||||
|
pub kind: Arrowhead,
|
||||||
|
/// The filled outline (non-zero rule; hollow kinds are rings).
|
||||||
|
pub outline: BezPath,
|
||||||
|
pub tip: Point,
|
||||||
|
/// Unit direction the head points in.
|
||||||
|
pub dir: Vec2,
|
||||||
|
/// Distance from the tip back to where the stroke ends under the head.
|
||||||
|
pub inset: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [`super::stroke_pieces`] for a stroke with at least one arrowhead.
|
||||||
|
pub(super) fn pieces<'a>(bp: &'a BezPath, st: &StrokeLayer) -> StrokePieces<'a> {
|
||||||
|
let subs = subpaths(bp);
|
||||||
|
let els = bp.elements();
|
||||||
|
// Heads go on the start of the first subpath and the end of the last one, when open.
|
||||||
|
let first = subs.first().filter(|s| !s.1).map(|s| s.0.clone());
|
||||||
|
let last = subs.last().filter(|s| !s.1).map(|s| s.0.clone());
|
||||||
|
let cap = cap_extent(st.cap, st.width);
|
||||||
|
let (hw0, hw1) = (st.arrow_weight(false), st.arrow_weight(true));
|
||||||
|
let start = st.start_arrow.zip(first).map(|(k, r)| (k, r, inset(k, hw0, cap)));
|
||||||
|
let end = st.end_arrow.zip(last).map(|(k, r)| (k, r, inset(k, hw1, cap)));
|
||||||
|
if start.is_none() && end.is_none() {
|
||||||
|
return StrokePieces { line: Cow::Borrowed(bp), heads: vec![] };
|
||||||
|
}
|
||||||
|
let tip_mode = st.arrow_align == ArrowAlign::Tip;
|
||||||
|
let mut heads = vec![];
|
||||||
|
// Trims per subpath index (start, end), applied in tip mode only.
|
||||||
|
let mut trims: Vec<(f64, f64)> = vec![(0.0, 0.0); subs.len()];
|
||||||
|
let mut place = |kind: Arrowhead, range: std::ops::Range<usize>, at_start: bool, inset: f64, hw: f64| {
|
||||||
|
let segs = segments(&els[range]);
|
||||||
|
let (end_pt, outward) = end_of(&segs, at_start);
|
||||||
|
let (tip, dir) = if tip_mode {
|
||||||
|
// Point along the chord from where the stroke now ends to the end point.
|
||||||
|
let dir = cut_point(&segs, at_start, inset).map(|p| end_pt - p).filter(|v| v.hypot() > 1e-9).map(unit).unwrap_or(outward);
|
||||||
|
(end_pt, dir)
|
||||||
|
} else {
|
||||||
|
(end_pt + outward * inset, outward)
|
||||||
|
};
|
||||||
|
heads.push(Arrow { kind, outline: shape(kind, tip, dir, hw), tip, dir, inset });
|
||||||
|
};
|
||||||
|
if let Some((k, r, i)) = start {
|
||||||
|
place(k, r, true, i, hw0);
|
||||||
|
if tip_mode {
|
||||||
|
trims[0].0 = i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some((k, r, i)) = end {
|
||||||
|
place(k, r, false, i, hw1);
|
||||||
|
if tip_mode {
|
||||||
|
trims[subs.len() - 1].1 = i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !tip_mode {
|
||||||
|
return StrokePieces { line: Cow::Borrowed(bp), heads };
|
||||||
|
}
|
||||||
|
let mut line = BezPath::new();
|
||||||
|
for ((range, _), &(s0, s1)) in subs.iter().zip(&trims) {
|
||||||
|
if s0 == 0.0 && s1 == 0.0 {
|
||||||
|
line.extend(els[range.clone()].iter().copied());
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Only open subpaths get heads, so a trimmed subpath stays open.
|
||||||
|
let segs = trim(&segments(&els[range.clone()]), s0, s1);
|
||||||
|
if let Some(first) = segs.first() {
|
||||||
|
line.move_to(first.start());
|
||||||
|
for s in &segs {
|
||||||
|
push_seg(&mut line, s);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
StrokePieces { line: Cow::Owned(line), heads }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The end point of a subpath and the unit direction pointing out of it there.
|
||||||
|
fn end_of(segs: &[PathSeg], at_start: bool) -> (Point, Vec2) {
|
||||||
|
let long = |s: &&PathSeg| s.arclen(ARCLEN_ACCURACY) > 1e-9;
|
||||||
|
if at_start {
|
||||||
|
let p = segs.first().map_or(Point::ORIGIN, |s| s.start());
|
||||||
|
let d = segs.iter().find(long).map_or(Vec2::new(-1.0, 0.0), |s| -tangent(s, 0.0));
|
||||||
|
(p, d)
|
||||||
|
} else {
|
||||||
|
let p = segs.last().map_or(Point::ORIGIN, |s| s.end());
|
||||||
|
let d = segs.iter().rev().find(long).map_or(Vec2::new(1.0, 0.0), |s| tangent(s, 1.0));
|
||||||
|
(p, d)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The point `dist` along the subpath from its start (or back from its end), if it is that long.
|
||||||
|
fn cut_point(segs: &[PathSeg], from_start: bool, dist: f64) -> Option<Point> {
|
||||||
|
let (i, t) = if from_start { cut_from_start(segs, dist)? } else { cut_from_end(segs, dist)? };
|
||||||
|
Some(segs[i].eval(t))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// (segment, parameter) at arc length `dist` from the start.
|
||||||
|
fn cut_from_start(segs: &[PathSeg], dist: f64) -> Option<(usize, f64)> {
|
||||||
|
let mut acc = 0.0;
|
||||||
|
for (i, s) in segs.iter().enumerate() {
|
||||||
|
let len = s.arclen(ARCLEN_ACCURACY);
|
||||||
|
if acc + len >= dist {
|
||||||
|
return Some((i, if len > 0.0 { s.inv_arclen(dist - acc, ARCLEN_ACCURACY) } else { 0.0 }));
|
||||||
|
}
|
||||||
|
acc += len;
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// (segment, parameter) at arc length `dist` back from the end.
|
||||||
|
fn cut_from_end(segs: &[PathSeg], dist: f64) -> Option<(usize, f64)> {
|
||||||
|
let mut acc = 0.0;
|
||||||
|
for (i, s) in segs.iter().enumerate().rev() {
|
||||||
|
let len = s.arclen(ARCLEN_ACCURACY);
|
||||||
|
if acc + len >= dist {
|
||||||
|
return Some((i, if len > 0.0 { s.inv_arclen(len - (dist - acc), ARCLEN_ACCURACY) } else { 1.0 }));
|
||||||
|
}
|
||||||
|
acc += len;
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The segments with `s0` of arc length removed from the start and `s1` from the end (empty when
|
||||||
|
/// nothing is left).
|
||||||
|
fn trim(segs: &[PathSeg], s0: f64, s1: f64) -> Vec<PathSeg> {
|
||||||
|
let (Some((i0, t0)), Some((i1, t1))) = (cut_from_start(segs, s0), cut_from_end(segs, s1)) else { return vec![] };
|
||||||
|
if (i0, t0) >= (i1, t1) {
|
||||||
|
return vec![];
|
||||||
|
}
|
||||||
|
if i0 == i1 {
|
||||||
|
return vec![segs[i0].subsegment(t0..t1)];
|
||||||
|
}
|
||||||
|
let mut out = Vec::with_capacity(i1 - i0 + 1);
|
||||||
|
out.push(segs[i0].subsegment(t0..1.0));
|
||||||
|
out.extend_from_slice(&segs[i0 + 1..i1]);
|
||||||
|
out.push(segs[i1].subsegment(0.0..t1));
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Head length (and width) for weight `hw`.
|
||||||
|
fn len(hw: f64) -> f64 {
|
||||||
|
4.0 * hw
|
||||||
|
}
|
||||||
|
/// Wall of the hollow ring heads.
|
||||||
|
fn ring(hw: f64) -> f64 {
|
||||||
|
0.5 * hw
|
||||||
|
}
|
||||||
|
/// Arm thickness of the open arrow.
|
||||||
|
fn arm(hw: f64) -> f64 {
|
||||||
|
hw
|
||||||
|
}
|
||||||
|
/// Thickness of the bar.
|
||||||
|
fn bar(hw: f64) -> f64 {
|
||||||
|
0.75 * hw
|
||||||
|
}
|
||||||
|
/// Where the notch of the (open) arrow sits, as a fraction of the length.
|
||||||
|
const NOTCH: f64 = 0.7;
|
||||||
|
|
||||||
|
/// Distance from the tip where the stroke ends, for a stroke whose cap reaches `cap` past its end.
|
||||||
|
fn inset(kind: Arrowhead, hw: f64, cap: f64) -> f64 {
|
||||||
|
let l = len(hw);
|
||||||
|
// Solid heads: halfway in, far enough that the cap stays behind the tip.
|
||||||
|
let solid = |back: f64| (back / 2.0).max(cap);
|
||||||
|
// Hollow heads: in the back wall, with the cap kept out of the hole.
|
||||||
|
let hollow = |back: f64| (back - ring(hw) / 2.0).max(back - ring(hw) + cap);
|
||||||
|
match kind {
|
||||||
|
Arrowhead::Triangle | Arrowhead::Circle | Arrowhead::Square | Arrowhead::Diamond => solid(l),
|
||||||
|
Arrowhead::Arrow => solid(NOTCH * l),
|
||||||
|
Arrowhead::Bar => solid(bar(hw)),
|
||||||
|
Arrowhead::TriangleOpen | Arrowhead::CircleOpen | Arrowhead::SquareOpen => hollow(l),
|
||||||
|
// The inner corner of the chevron: arm / sin(half angle).
|
||||||
|
Arrowhead::ArrowOpen => (arm(hw) * (l * l + l * l / 4.0).sqrt() / (l / 2.0)).max(cap),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Outline of a `kind` head of weight `hw` with its tip at `tip`, pointing along `dir`.
|
||||||
|
fn shape(kind: Arrowhead, tip: Point, dir: Vec2, hw: f64) -> BezPath {
|
||||||
|
let n = Vec2::new(-dir.y, dir.x);
|
||||||
|
let l = len(hw);
|
||||||
|
let half = l / 2.0;
|
||||||
|
let base = tip - dir * l;
|
||||||
|
let mut p = BezPath::new();
|
||||||
|
let triangle = [tip, base + n * half, base - n * half];
|
||||||
|
let square = [tip + n * half, base + n * half, base - n * half, tip - n * half];
|
||||||
|
let centre = tip - dir * half;
|
||||||
|
match kind {
|
||||||
|
Arrowhead::Triangle => polygon(&mut p, &triangle),
|
||||||
|
Arrowhead::TriangleOpen => ring_polygon(&mut p, &triangle, ring(hw)),
|
||||||
|
Arrowhead::Arrow => polygon(&mut p, &[tip, base + n * half, tip - dir * (NOTCH * l), base - n * half]),
|
||||||
|
Arrowhead::ArrowOpen => {
|
||||||
|
// Arms of thickness `arm` from the tip to the base corners, cut square at their ends.
|
||||||
|
let t = arm(hw);
|
||||||
|
let (a, b) = (base + n * half, base - n * half);
|
||||||
|
let (ua, ub) = (unit(a - tip), unit(b - tip));
|
||||||
|
// Inward (towards the axis) normals of the two arms.
|
||||||
|
let (na, nb) = (Vec2::new(-ua.y, ua.x), Vec2::new(ub.y, -ub.x));
|
||||||
|
let inner = tip - dir * (t * (a - tip).hypot() / half);
|
||||||
|
polygon(&mut p, &[tip, a, a + na * t, inner, b + nb * t, b]);
|
||||||
|
}
|
||||||
|
Arrowhead::Circle => p.extend(kurbo::Circle::new(centre, half).path_elements(0.01)),
|
||||||
|
Arrowhead::CircleOpen => {
|
||||||
|
p.extend(kurbo::Circle::new(centre, half).path_elements(0.01));
|
||||||
|
p.extend(kurbo::Circle::new(centre, half - ring(hw)).to_path(0.01).reverse_subpaths());
|
||||||
|
}
|
||||||
|
Arrowhead::Square => polygon(&mut p, &square),
|
||||||
|
Arrowhead::SquareOpen => ring_polygon(&mut p, &square, ring(hw)),
|
||||||
|
Arrowhead::Diamond => polygon(&mut p, &[tip, centre + n * half, base, centre - n * half]),
|
||||||
|
Arrowhead::Bar => {
|
||||||
|
let t = bar(hw);
|
||||||
|
polygon(&mut p, &[tip + n * half, tip + n * half - dir * t, tip - n * half - dir * t, tip - n * half]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
p
|
||||||
|
}
|
||||||
|
|
||||||
|
fn polygon(p: &mut BezPath, pts: &[Point]) {
|
||||||
|
let Some((first, rest)) = pts.split_first() else { return };
|
||||||
|
p.move_to(*first);
|
||||||
|
for q in rest {
|
||||||
|
p.line_to(*q);
|
||||||
|
}
|
||||||
|
p.close_path();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A convex polygon with a hole: the polygon inset by `wall`, wound the other way.
|
||||||
|
fn ring_polygon(p: &mut BezPath, pts: &[Point], wall: f64) {
|
||||||
|
polygon(p, pts);
|
||||||
|
let n = pts.len();
|
||||||
|
// Signed area: which side of each edge is inside.
|
||||||
|
let area: f64 = (0..n).map(|i| pts[i].to_vec2().cross(pts[(i + 1) % n].to_vec2())).sum();
|
||||||
|
let inward = |e: Vec2| if area > 0.0 { Vec2::new(-e.y, e.x) } else { Vec2::new(e.y, -e.x) };
|
||||||
|
let inner: Vec<Point> = (0..n)
|
||||||
|
.map(|i| {
|
||||||
|
let (prev, cur, next) = (pts[(i + n - 1) % n], pts[i], pts[(i + 1) % n]);
|
||||||
|
let (n1, n2) = (inward(unit(cur - prev)), inward(unit(next - cur)));
|
||||||
|
// The corner where both edges, moved in by `wall`, meet.
|
||||||
|
cur + (n1 + n2) * (wall / (1.0 + n1.dot(n2)))
|
||||||
|
})
|
||||||
|
.rev()
|
||||||
|
.collect();
|
||||||
|
polygon(p, &inner);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,191 @@
|
||||||
|
//! Dash patterns. Dashes follow the path's arc length, restart on every subpath and join across
|
||||||
|
//! the start of a closed subpath. A dash of zero length is a [`Dot`]: with a round cap it paints
|
||||||
|
//! a disc, with a projecting cap a square turned along the path, with a butt cap nothing.
|
||||||
|
|
||||||
|
use kurbo::{BezPath, ParamCurve, ParamCurveArclen, PathSeg, Point, Shape, Vec2};
|
||||||
|
use vectorcraft_doc::{Dash, LineCap};
|
||||||
|
|
||||||
|
use super::{ARCLEN_ACCURACY, push_seg, segments, subpaths, tangent};
|
||||||
|
|
||||||
|
/// More dashes than this (a tiny pattern on a long path) draws the line solid instead.
|
||||||
|
const MAX_DASHES: f64 = 200_000.0;
|
||||||
|
/// A pattern whose longest entry is shorter than this can't advance along the path (each entry
|
||||||
|
/// ends within the generator's tolerance of where it starts), so it draws the line solid.
|
||||||
|
const MIN_ENTRY: f64 = 1e-6;
|
||||||
|
|
||||||
|
/// A zero-length dash: where it sits and the path's direction there.
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||||
|
pub struct Dot {
|
||||||
|
pub at: Point,
|
||||||
|
pub dir: Vec2,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A dashed path: the dashes (open subpaths to stroke without a dash pattern) and the dots.
|
||||||
|
#[derive(Clone, Debug, Default)]
|
||||||
|
pub struct Dashed {
|
||||||
|
pub path: BezPath,
|
||||||
|
pub dots: Vec<Dot>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Apply dash pattern `d` to `bp`. `None` when the pattern has no positive length (the line is
|
||||||
|
/// solid) or is too fine to draw (then the line is drawn solid too).
|
||||||
|
pub fn dash(bp: &BezPath, d: &Dash) -> Option<Dashed> {
|
||||||
|
if d.pattern.iter().any(|v| !v.is_finite()) || !d.offset.is_finite() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let mut pat: Vec<f64> = d.pattern.iter().map(|v| v.max(0.0)).collect();
|
||||||
|
if !pat.iter().any(|v| *v > MIN_ENTRY) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
// An odd pattern repeats with dashes and gaps swapped (as in SVG and PDF).
|
||||||
|
if pat.len() % 2 == 1 {
|
||||||
|
pat.extend_from_within(..);
|
||||||
|
}
|
||||||
|
let period: f64 = pat.iter().sum();
|
||||||
|
let subs: Vec<(Vec<PathSeg>, bool)> = subpaths(bp).into_iter().map(|(r, closed)| (segments(&bp.elements()[r]), closed)).collect();
|
||||||
|
let lens: Vec<Vec<f64>> = subs.iter().map(|(segs, _)| segs.iter().map(|s| s.arclen(ARCLEN_ACCURACY)).collect()).collect();
|
||||||
|
let total: f64 = lens.iter().flatten().sum();
|
||||||
|
if total / period * pat.len() as f64 > MAX_DASHES {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
// Where the pattern starts: `offset` into it.
|
||||||
|
let mut off = d.offset.rem_euclid(period);
|
||||||
|
let mut ix0 = 0;
|
||||||
|
while off > pat[ix0] {
|
||||||
|
off -= pat[ix0];
|
||||||
|
ix0 = (ix0 + 1) % pat.len();
|
||||||
|
}
|
||||||
|
let phase = Phase { ix: ix0, rem: pat[ix0] - off, on: ix0 % 2 == 0 };
|
||||||
|
let mut out = Dashed::default();
|
||||||
|
for ((segs, closed), lens) in subs.iter().zip(&lens) {
|
||||||
|
dash_subpath(&mut out, segs, lens, *closed, &pat, phase);
|
||||||
|
}
|
||||||
|
Some(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Position in the pattern: entry index, length left in it, and whether it is a dash.
|
||||||
|
#[derive(Clone, Copy)]
|
||||||
|
struct Phase {
|
||||||
|
ix: usize,
|
||||||
|
rem: f64,
|
||||||
|
on: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn dash_subpath(out: &mut Dashed, segs: &[PathSeg], lens: &[f64], closed: bool, pat: &[f64], mut ph: Phase) {
|
||||||
|
const EPS: f64 = 1e-9;
|
||||||
|
let first_dot = out.dots.len();
|
||||||
|
let starts_on = ph.on && ph.rem > 0.0;
|
||||||
|
let mut dashes: Vec<Vec<PathSeg>> = vec![];
|
||||||
|
let mut cur: Vec<PathSeg> = vec![];
|
||||||
|
let mut cuts = 0;
|
||||||
|
for (seg, &len) in segs.iter().zip(lens) {
|
||||||
|
let mut s = 0.0;
|
||||||
|
loop {
|
||||||
|
let left = len - s;
|
||||||
|
if ph.rem > left + EPS {
|
||||||
|
if ph.on && left > EPS {
|
||||||
|
cur.push(sub(seg, len, s, len));
|
||||||
|
}
|
||||||
|
ph.rem -= left;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
let end = (s + ph.rem).min(len);
|
||||||
|
if ph.on {
|
||||||
|
if pat[ph.ix] > 0.0 {
|
||||||
|
if end > s {
|
||||||
|
cur.push(sub(seg, len, s, end));
|
||||||
|
}
|
||||||
|
if !cur.is_empty() {
|
||||||
|
dashes.push(std::mem::take(&mut cur));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
let t = param(seg, len, end);
|
||||||
|
out.dots.push(Dot { at: seg.eval(t), dir: tangent(seg, t) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cuts += 1;
|
||||||
|
s = end;
|
||||||
|
ph.ix = (ph.ix + 1) % pat.len();
|
||||||
|
ph.rem = pat[ph.ix];
|
||||||
|
ph.on = !ph.on;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let ends_on = !cur.is_empty();
|
||||||
|
if ends_on {
|
||||||
|
dashes.push(cur);
|
||||||
|
}
|
||||||
|
if closed {
|
||||||
|
if cuts == 0 && ends_on {
|
||||||
|
// One dash all the way round: keep it closed so the start gets a join, not caps.
|
||||||
|
let segs = dashes.pop().unwrap_or_default();
|
||||||
|
write(&mut out.path, &segs);
|
||||||
|
out.path.close_path();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if starts_on && ends_on && dashes.len() > 1 {
|
||||||
|
// The dash running through the start point is one dash.
|
||||||
|
let mut last = dashes.pop().unwrap_or_default();
|
||||||
|
last.append(&mut dashes[0]);
|
||||||
|
dashes[0] = last;
|
||||||
|
}
|
||||||
|
// A dot on the start point is reached again at the end.
|
||||||
|
if out.dots.len() > first_dot + 1 && out.dots[first_dot].at.distance(out.dots[out.dots.len() - 1].at) < 1e-6 {
|
||||||
|
out.dots.pop();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for d in &dashes {
|
||||||
|
write(&mut out.path, d);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write(out: &mut BezPath, segs: &[PathSeg]) {
|
||||||
|
let Some(first) = segs.first() else { return };
|
||||||
|
out.move_to(first.start());
|
||||||
|
for s in segs {
|
||||||
|
push_seg(out, s);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Curve parameter at arc length `s` along `seg` (of total length `len`).
|
||||||
|
fn param(seg: &PathSeg, len: f64, s: f64) -> f64 {
|
||||||
|
if s <= 0.0 {
|
||||||
|
0.0
|
||||||
|
} else if s >= len {
|
||||||
|
1.0
|
||||||
|
} else if let PathSeg::Line(_) = seg {
|
||||||
|
s / len
|
||||||
|
} else {
|
||||||
|
seg.inv_arclen(s, ARCLEN_ACCURACY)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The part of `seg` between arc lengths `s0` and `s1`.
|
||||||
|
fn sub(seg: &PathSeg, len: f64, s0: f64, s1: f64) -> PathSeg {
|
||||||
|
seg.subsegment(param(seg, len, s0)..param(seg, len, s1))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Outlines of `dots` for a stroke of `width` with `cap`: discs (round), squares turned along the
|
||||||
|
/// path (projecting) or nothing (butt). Each outline winds like kurbo's stroke outlines, so a dot
|
||||||
|
/// touching a dash doesn't cancel it under the non-zero rule.
|
||||||
|
pub fn dot_outline(dots: &[Dot], width: f64, cap: LineCap, tol: f64) -> BezPath {
|
||||||
|
let mut out = BezPath::new();
|
||||||
|
let r = width / 2.0;
|
||||||
|
if r <= 0.0 {
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
for d in dots {
|
||||||
|
match cap {
|
||||||
|
LineCap::Butt => {}
|
||||||
|
LineCap::Round => out.extend(kurbo::Circle::new(d.at, r).path_elements(tol)),
|
||||||
|
LineCap::Square => {
|
||||||
|
let (u, n) = (d.dir * r, Vec2::new(-d.dir.y, d.dir.x) * r);
|
||||||
|
out.move_to(d.at - u - n);
|
||||||
|
out.line_to(d.at + u - n);
|
||||||
|
out.line_to(d.at + u + n);
|
||||||
|
out.line_to(d.at - u + n);
|
||||||
|
out.close_path();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,305 @@
|
||||||
|
use kurbo::{BezPath, PathEl, Point, Rect, Shape, Vec2};
|
||||||
|
use vectorcraft_color::{Color, Paint};
|
||||||
|
use vectorcraft_doc::{ArrowAlign, Arrowhead, Dash, LineCap, StrokeLayer, WidthProfile};
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn line(x0: f64, x1: f64) -> BezPath {
|
||||||
|
let mut b = BezPath::new();
|
||||||
|
b.move_to((x0, 0.0));
|
||||||
|
b.line_to((x1, 0.0));
|
||||||
|
b
|
||||||
|
}
|
||||||
|
|
||||||
|
fn square(size: f64) -> BezPath {
|
||||||
|
Rect::new(0.0, 0.0, size, size).to_path(0.1)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn stroke(width: f64, f: impl FnOnce(&mut StrokeLayer)) -> StrokeLayer {
|
||||||
|
let mut st = StrokeLayer::new(Paint::solid(Color::BLACK), width);
|
||||||
|
f(&mut st);
|
||||||
|
st
|
||||||
|
}
|
||||||
|
|
||||||
|
fn dashed(pattern: &[f64], offset: f64) -> Dash {
|
||||||
|
Dash { pattern: pattern.to_vec(), offset, align_corners: false }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn length(bp: &BezPath) -> f64 {
|
||||||
|
bp.segments().map(|s| kurbo::ParamCurveArclen::arclen(&s, 1e-9)).sum()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn moves(bp: &BezPath) -> usize {
|
||||||
|
bp.elements().iter().filter(|e| matches!(e, PathEl::MoveTo(_))).count()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn near(a: f64, b: f64, tol: f64) -> bool {
|
||||||
|
(a - b).abs() <= tol
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- dashes and dots
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn zero_length_dashes_are_dots_along_the_tangent() {
|
||||||
|
let d = dash(&line(0.0, 60.0), &dashed(&[0.0, 6.0], 0.0)).unwrap();
|
||||||
|
assert!(d.path.elements().is_empty(), "no dash has length");
|
||||||
|
let xs: Vec<f64> = d.dots.iter().map(|p| p.at.x).collect();
|
||||||
|
assert_eq!(d.dots.len(), 11, "{xs:?}");
|
||||||
|
for (i, dot) in d.dots.iter().enumerate() {
|
||||||
|
assert!(near(dot.at.x, 6.0 * i as f64, 1e-9) && near(dot.at.y, 0.0, 1e-12));
|
||||||
|
assert!(near(dot.dir.x, 1.0, 1e-12));
|
||||||
|
}
|
||||||
|
// The offset shifts the dots along the path.
|
||||||
|
let d = dash(&line(0.0, 60.0), &dashed(&[0.0, 6.0], 3.0)).unwrap();
|
||||||
|
assert_eq!(d.dots.len(), 10);
|
||||||
|
assert!(near(d.dots[0].at.x, 3.0, 1e-9));
|
||||||
|
// Dots on a curve follow its direction.
|
||||||
|
let c = kurbo::Circle::new((0.0, 0.0), 50.0).to_path(1e-6);
|
||||||
|
let d = dash(&c, &dashed(&[0.0, 10.0], 0.0)).unwrap();
|
||||||
|
for dot in &d.dots {
|
||||||
|
assert!(dot.dir.dot(dot.at.to_vec2()).abs() < 1e-3, "tangent ⟂ radius at {:?}", dot.at);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn dots_on_a_closed_path_are_not_doubled_at_the_start() {
|
||||||
|
let d = dash(&square(60.0), &dashed(&[0.0, 6.0], 0.0)).unwrap();
|
||||||
|
assert_eq!(d.dots.len(), 40);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn dashes_follow_arc_length_and_join_across_the_start_of_closed_paths() {
|
||||||
|
let d = dash(&line(10.0, 90.0), &dashed(&[10.0, 10.0], 0.0)).unwrap();
|
||||||
|
assert_eq!(moves(&d.path), 4);
|
||||||
|
assert!(near(length(&d.path), 40.0, 1e-6));
|
||||||
|
// An odd pattern repeats with dashes and gaps swapped: 10 on, 10 off, 10 on…
|
||||||
|
let odd = dash(&line(0.0, 80.0), &dashed(&[10.0], 0.0)).unwrap();
|
||||||
|
assert_eq!(moves(&odd.path), 4);
|
||||||
|
// Perimeter 400, dashes of 20 starting 10 into the pattern: the dash at 390..410 runs
|
||||||
|
// through the start point and is one dash.
|
||||||
|
let sq = dash(&square(100.0), &dashed(&[20.0, 20.0], 10.0)).unwrap();
|
||||||
|
assert_eq!(moves(&sq.path), 10);
|
||||||
|
assert!(near(length(&sq.path), 200.0, 1e-6));
|
||||||
|
// A pattern without length is a solid line; one dash round a closed path stays closed.
|
||||||
|
assert!(dash(&line(0.0, 10.0), &dashed(&[0.0, 0.0], 0.0)).is_none());
|
||||||
|
let whole = dash(&square(10.0), &dashed(&[100.0, 1.0], 0.0)).unwrap();
|
||||||
|
assert!(is_closed(&whole.path));
|
||||||
|
// A pattern far too fine for the path is drawn solid rather than as millions of dashes.
|
||||||
|
assert!(dash(&line(0.0, 1e6), &dashed(&[0.001, 0.001], 0.0)).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn dot_outlines_take_the_cap_shape_and_wind_like_stroked_dashes() {
|
||||||
|
let dots = [Dot { at: Point::new(10.0, 0.0), dir: Vec2::new(1.0, 0.0) }];
|
||||||
|
let round = dot_outline(&dots, 4.0, LineCap::Round, 1e-4);
|
||||||
|
assert!(near(round.area(), std::f64::consts::PI * 4.0, 1e-3));
|
||||||
|
let sq = dot_outline(&dots, 4.0, LineCap::Square, 1e-4);
|
||||||
|
assert!(near(sq.area(), 16.0, 1e-9));
|
||||||
|
assert!(sq.contains(Point::new(11.9, 1.9)) && !round.contains(Point::new(11.9, 1.9)));
|
||||||
|
assert!(dot_outline(&dots, 4.0, LineCap::Butt, 1e-4).elements().is_empty());
|
||||||
|
// A dot overlapping a dash adds to it under the non-zero rule instead of cancelling it.
|
||||||
|
let dash_outline = kurbo::stroke(line(0.0, 10.0).iter(), &kurbo::Stroke::new(4.0), &Default::default(), 1e-4);
|
||||||
|
for o in [&round, &sq] {
|
||||||
|
assert_eq!(o.area().signum(), dash_outline.area().signum());
|
||||||
|
}
|
||||||
|
let st = stroke(4.0, |s| {
|
||||||
|
s.cap = LineCap::Round;
|
||||||
|
s.dash = Some(dashed(&[10.0, 1.0, 0.0, 1.0], 0.0));
|
||||||
|
});
|
||||||
|
let o = line_outline(&line(0.0, 12.0), &st, 4.0, 1e-3);
|
||||||
|
assert!(o.contains(Point::new(10.5, 0.0)), "the dot overlapping the dash end is painted");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn line_outline_uses_the_profile_dashes_or_a_plain_stroke() {
|
||||||
|
let l = line(0.0, 100.0);
|
||||||
|
let plain = line_outline(&l, &stroke(10.0, |_| {}), 10.0, 1e-3);
|
||||||
|
assert!(near(plain.area().abs(), 1000.0, 1e-6));
|
||||||
|
let lens = line_outline(&l, &stroke(10.0, |s| s.profile = Some(WidthProfile::lens())), 10.0, 1e-3);
|
||||||
|
assert!(near(lens.area().abs(), 500.0, 1e-6));
|
||||||
|
// Dashes take precedence over a profile (as before).
|
||||||
|
let st = stroke(10.0, |s| {
|
||||||
|
s.profile = Some(WidthProfile::lens());
|
||||||
|
s.dash = Some(dashed(&[25.0, 25.0], 0.0));
|
||||||
|
});
|
||||||
|
assert!(near(line_outline(&l, &st, 10.0, 1e-3).area().abs(), 500.0, 1e-6));
|
||||||
|
assert!(line_outline(&BezPath::new(), &st, 10.0, 1e-3).elements().is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn aligned_width_doubles_only_closed_inside_and_outside_strokes() {
|
||||||
|
let mut st = stroke(3.0, |s| s.align = vectorcraft_doc::StrokeAlign::Inside);
|
||||||
|
assert_eq!(aligned_width(&st, true), 6.0);
|
||||||
|
assert_eq!(aligned_width(&st, false), 3.0);
|
||||||
|
st.align = vectorcraft_doc::StrokeAlign::Center;
|
||||||
|
assert_eq!(aligned_width(&st, true), 3.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- arrowheads
|
||||||
|
|
||||||
|
fn arrows(kind: Arrowhead, align: ArrowAlign, cap: LineCap) -> StrokeLayer {
|
||||||
|
stroke(2.0, |s| {
|
||||||
|
s.end_arrow = Some(kind);
|
||||||
|
s.arrow_align = align;
|
||||||
|
s.cap = cap;
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn no_heads_borrows_the_path_and_closed_paths_get_none() {
|
||||||
|
let l = line(0.0, 50.0);
|
||||||
|
assert!(matches!(stroke_pieces(&l, &stroke(2.0, |_| {})).line, Cow::Borrowed(_)));
|
||||||
|
let st = stroke(2.0, |s| {
|
||||||
|
s.start_arrow = Some(Arrowhead::Triangle);
|
||||||
|
s.end_arrow = Some(Arrowhead::Triangle);
|
||||||
|
s.arrow_align = ArrowAlign::Tip;
|
||||||
|
});
|
||||||
|
let sq = square(50.0);
|
||||||
|
let p = stroke_pieces(&sq, &st);
|
||||||
|
assert!(p.heads.is_empty());
|
||||||
|
assert_eq!(p.line.as_ref(), &sq);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tip_mode_shortens_the_stroke_by_the_head_and_puts_the_tip_on_the_end_point() {
|
||||||
|
for kind in Arrowhead::ALL {
|
||||||
|
let st = arrows(kind, ArrowAlign::Tip, LineCap::Butt);
|
||||||
|
let l = line(0.0, 100.0);
|
||||||
|
let p = stroke_pieces(&l, &st);
|
||||||
|
let [h] = &p.heads[..] else { panic!("one head") };
|
||||||
|
assert_eq!(h.tip, Point::new(100.0, 0.0), "{kind:?}");
|
||||||
|
assert!(near(h.dir.x, 1.0, 1e-9), "{kind:?}");
|
||||||
|
assert!(h.inset > 0.0 && h.inset <= 8.0, "{kind:?} inset {}", h.inset);
|
||||||
|
assert!(near(length(&p.line), 100.0 - h.inset, 1e-6), "{kind:?}");
|
||||||
|
// Nothing reaches past the tip.
|
||||||
|
assert!(h.outline.bounding_box().x1 <= 100.0 + 0.01, "{kind:?}");
|
||||||
|
assert!(h.outline.area().abs() > 0.5, "{kind:?}");
|
||||||
|
}
|
||||||
|
// Both ends, on a curve: the start head points back along the path.
|
||||||
|
let mut c = BezPath::new();
|
||||||
|
c.move_to((0.0, 0.0));
|
||||||
|
c.curve_to((30.0, -40.0), (70.0, -40.0), (100.0, 0.0));
|
||||||
|
let st = stroke(2.0, |s| {
|
||||||
|
s.start_arrow = Some(Arrowhead::Triangle);
|
||||||
|
s.end_arrow = Some(Arrowhead::Arrow);
|
||||||
|
s.arrow_align = ArrowAlign::Tip;
|
||||||
|
});
|
||||||
|
let p = stroke_pieces(&c, &st);
|
||||||
|
assert_eq!(p.heads.len(), 2);
|
||||||
|
assert_eq!(p.heads[0].tip, Point::new(0.0, 0.0));
|
||||||
|
assert!(p.heads[0].dir.x < 0.0 && p.heads[0].dir.y > 0.0, "{:?}", p.heads[0].dir);
|
||||||
|
let total = length(&c);
|
||||||
|
assert!(near(length(&p.line), total - p.heads[0].inset - p.heads[1].inset, 1e-5));
|
||||||
|
// A path shorter than its heads leaves no line.
|
||||||
|
let short = line(0.0, 3.0);
|
||||||
|
let p = stroke_pieces(&short, &arrows(Arrowhead::Triangle, ArrowAlign::Tip, LineCap::Butt));
|
||||||
|
assert!(p.line.elements().is_empty());
|
||||||
|
assert_eq!(p.heads.len(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn extend_mode_keeps_the_path_and_overhangs_by_the_inset() {
|
||||||
|
let l = line(0.0, 100.0);
|
||||||
|
let st = arrows(Arrowhead::Triangle, ArrowAlign::Extend, LineCap::Butt);
|
||||||
|
let p = stroke_pieces(&l, &st);
|
||||||
|
assert!(matches!(p.line, Cow::Borrowed(_)));
|
||||||
|
let h = &p.heads[0];
|
||||||
|
assert!(near(h.tip.x, 100.0 + h.inset, 1e-9));
|
||||||
|
// The same head as in tip mode, moved by the overhang.
|
||||||
|
let tip = stroke_pieces(&l, &arrows(Arrowhead::Triangle, ArrowAlign::Tip, LineCap::Butt));
|
||||||
|
let mut moved = tip.heads[0].outline.clone();
|
||||||
|
moved.apply_affine(kurbo::Affine::translate((h.inset, 0.0)));
|
||||||
|
let pts = |b: &BezPath| b.elements().iter().filter_map(|e| e.end_point()).collect::<Vec<_>>();
|
||||||
|
let (a, b) = (pts(&moved), pts(&h.outline));
|
||||||
|
assert_eq!(a.len(), b.len());
|
||||||
|
assert!(a.iter().zip(&b).all(|(p, q)| p.distance(*q) < 1e-9));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn hollow_heads_are_rings_and_the_stroke_stays_out_of_the_hole() {
|
||||||
|
for (kind, cap) in [(Arrowhead::CircleOpen, LineCap::Round), (Arrowhead::SquareOpen, LineCap::Square), (Arrowhead::TriangleOpen, LineCap::Butt)] {
|
||||||
|
let st = arrows(kind, ArrowAlign::Tip, cap);
|
||||||
|
let l = line(0.0, 100.0);
|
||||||
|
let p = stroke_pieces(&l, &st);
|
||||||
|
let h = &p.heads[0];
|
||||||
|
let line_o = line_outline(&p.line, &st, st.width, 1e-3);
|
||||||
|
// A point inside the hole is neither head nor line.
|
||||||
|
let hole = Point::new(100.0 - 8.0 * 0.45, 0.0);
|
||||||
|
assert_eq!(h.outline.winding(hole), 0, "{kind:?} hole");
|
||||||
|
assert_eq!(line_o.winding(hole), 0, "{kind:?} line in the hole");
|
||||||
|
// The line reaches the back wall, so they overlap (no seam).
|
||||||
|
let back_wall = Point::new(100.0 - 8.0 + 0.25, 0.0);
|
||||||
|
assert_ne!(h.outline.winding(back_wall), 0, "{kind:?} wall");
|
||||||
|
assert_ne!(line_o.winding(back_wall - Vec2::new(0.2, 0.0)), 0, "{kind:?} line reaches the wall");
|
||||||
|
}
|
||||||
|
// The open arrow is a chevron: open between its arms.
|
||||||
|
let l = line(0.0, 100.0);
|
||||||
|
let p = stroke_pieces(&l, &arrows(Arrowhead::ArrowOpen, ArrowAlign::Tip, LineCap::Butt));
|
||||||
|
let h = &p.heads[0];
|
||||||
|
assert_eq!(h.outline.winding(Point::new(93.0, 0.0)), 0);
|
||||||
|
assert_ne!(h.outline.winding(Point::new(99.0, 0.0)), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn round_caps_never_poke_past_the_tip() {
|
||||||
|
for kind in Arrowhead::ALL {
|
||||||
|
for scale in [10.0, 100.0, 300.0] {
|
||||||
|
let mut st = arrows(kind, ArrowAlign::Tip, LineCap::Round);
|
||||||
|
st.arrow_scale = (scale, scale);
|
||||||
|
let l = line(0.0, 100.0);
|
||||||
|
let p = stroke_pieces(&l, &st);
|
||||||
|
let o = line_outline(&p.line, &st, st.width, 1e-3);
|
||||||
|
assert!(o.bounding_box().x1 <= 100.0 + 1e-3, "{kind:?} at {scale}%: {}", o.bounding_box().x1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- robustness
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn degenerate_paths_and_patterns_neither_panic_nor_hang() {
|
||||||
|
let finite = |b: &BezPath| b.elements().iter().filter_map(|e| e.end_point()).all(|q| q.x.is_finite() && q.y.is_finite());
|
||||||
|
let mut point = BezPath::new();
|
||||||
|
point.move_to((0.0, 0.0));
|
||||||
|
let lone = point.clone();
|
||||||
|
point.line_to((0.0, 0.0));
|
||||||
|
let mut still_curve = BezPath::new();
|
||||||
|
still_curve.move_to((0.0, 0.0));
|
||||||
|
still_curve.curve_to((0.0, 0.0), (0.0, 0.0), (0.0, 0.0));
|
||||||
|
// Drawing on after a ClosePath, and curves whose handles sit on their end points.
|
||||||
|
let mut mixed = BezPath::new();
|
||||||
|
mixed.move_to((0.0, 0.0));
|
||||||
|
mixed.line_to((10.0, 0.0));
|
||||||
|
mixed.close_path();
|
||||||
|
mixed.line_to((5.0, 5.0));
|
||||||
|
mixed.move_to((20.0, 0.0));
|
||||||
|
mixed.curve_to((20.0, 0.0), (50.0, 50.0), (50.0, 50.0));
|
||||||
|
mixed.quad_to((50.0, 50.0), (90.0, 0.0));
|
||||||
|
let paths = [BezPath::new(), lone, point, still_curve, mixed, square(20.0)];
|
||||||
|
// Negative, non-finite and sub-tolerance entries (the last used to loop forever on a
|
||||||
|
// zero-length path), and offsets far outside the pattern.
|
||||||
|
let patterns: [&[f64]; 6] = [&[0.0, 6.0], &[-1.0, 3.0], &[f64::NAN, 1.0], &[1e-12, 1e-12], &[0.0], &[3.0, 0.0]];
|
||||||
|
for bp in &paths {
|
||||||
|
for kind in Arrowhead::ALL {
|
||||||
|
for align in [ArrowAlign::Extend, ArrowAlign::Tip] {
|
||||||
|
for cap in [LineCap::Butt, LineCap::Round, LineCap::Square] {
|
||||||
|
for pat in patterns {
|
||||||
|
for off in [0.0, -7.0, 1e12, f64::INFINITY] {
|
||||||
|
let st = stroke(2.0, |s| {
|
||||||
|
s.start_arrow = Some(kind);
|
||||||
|
s.end_arrow = Some(kind);
|
||||||
|
s.arrow_align = align;
|
||||||
|
s.cap = cap;
|
||||||
|
s.dash = Some(dashed(pat, off));
|
||||||
|
});
|
||||||
|
let p = stroke_pieces(bp, &st);
|
||||||
|
assert!(p.heads.iter().all(|h| finite(&h.outline)), "{bp:?} {kind:?}");
|
||||||
|
assert!(finite(&line_outline(&p.line, &st, 2.0, 0.01)), "{bp:?} {pat:?} {off}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert!(dash(&line(0.0, 1e-9), &dashed(&[1e-12, 1e-12], 0.0)).is_none(), "too fine to advance: solid");
|
||||||
|
}
|
||||||
|
|
@ -6,24 +6,10 @@
|
||||||
//! projecting caps (the round cap blends the two side widths); closed subpaths become two loops
|
//! projecting caps (the round cap blends the two side widths); closed subpaths become two loops
|
||||||
//! of opposite orientation filled with the non-zero rule.
|
//! of opposite orientation filled with the non-zero rule.
|
||||||
|
|
||||||
use vectorcraft_doc::{LineCap, StrokeAlign, StrokeLayer, WidthProfile};
|
use kurbo::{BezPath, PathEl, Point, Vec2};
|
||||||
use vectorcraft_geom::kurbo::{self, BezPath, PathEl, Point, Vec2};
|
use vectorcraft_doc::{LineCap, WidthProfile};
|
||||||
|
|
||||||
/// The outline to fill instead of stroking, when `st` has a width profile (and no dashes).
|
use super::unit;
|
||||||
pub(crate) fn outline_for(bp: &BezPath, st: &StrokeLayer, tol: f64) -> Option<BezPath> {
|
|
||||||
let profile = st.profile.as_ref()?;
|
|
||||||
if st.dash.as_ref().is_some_and(|d| d.is_dashed()) {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
let closed = bp.elements().last().is_some_and(|e| matches!(e, PathEl::ClosePath));
|
|
||||||
let width = match st.align {
|
|
||||||
StrokeAlign::Center => st.width,
|
|
||||||
_ if closed => st.width * 2.0,
|
|
||||||
_ => st.width,
|
|
||||||
};
|
|
||||||
let o = width_outline(bp, width, profile, st.cap, tol);
|
|
||||||
(!o.elements().is_empty()).then_some(o)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Filled outline of `bp` stroked with `width` scaled by `profile`, flattened to `tol`.
|
/// Filled outline of `bp` stroked with `width` scaled by `profile`, flattened to `tol`.
|
||||||
pub fn width_outline(bp: &BezPath, width: f64, profile: &WidthProfile, cap: LineCap, tol: f64) -> BezPath {
|
pub fn width_outline(bp: &BezPath, width: f64, profile: &WidthProfile, cap: LineCap, tol: f64) -> BezPath {
|
||||||
|
|
@ -72,11 +58,6 @@ fn subpaths(bp: &BezPath, tol: f64) -> Vec<(Vec<Point>, bool)> {
|
||||||
out
|
out
|
||||||
}
|
}
|
||||||
|
|
||||||
fn unit(v: Vec2) -> Vec2 {
|
|
||||||
let h = v.hypot();
|
|
||||||
if h > 1e-12 { v / h } else { Vec2::new(1.0, 0.0) }
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Left normal in y-down space (left of the direction of travel).
|
/// Left normal in y-down space (left of the direction of travel).
|
||||||
fn left(t: Vec2) -> Vec2 {
|
fn left(t: Vec2) -> Vec2 {
|
||||||
Vec2::new(t.y, -t.x)
|
Vec2::new(t.y, -t.x)
|
||||||
|
|
@ -255,7 +236,5 @@ mod tests {
|
||||||
let a = area(&width_outline(&c, 10.0, &p, LineCap::Butt, 0.001));
|
let a = area(&width_outline(&c, 10.0, &p, LineCap::Butt, 0.001));
|
||||||
let want = 2.0 * std::f64::consts::PI * 50.0 * 10.0;
|
let want = 2.0 * std::f64::consts::PI * 50.0 * 10.0;
|
||||||
assert!((a - want).abs() / want < 0.01, "{a} vs {want}");
|
assert!((a - want).abs() / want < 0.01, "{a} vs {want}");
|
||||||
let st = StrokeLayer { profile: None, ..StrokeLayer::new(vectorcraft_color::Paint::solid(vectorcraft_color::Color::BLACK), 4.0) };
|
|
||||||
assert!(outline_for(&c, &st, 0.1).is_none());
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
use vectorcraft_color::{Color, Paint};
|
use vectorcraft_color::{Color, Paint};
|
||||||
use vectorcraft_doc::{Arrowhead, Dash, LineCap, LineJoin, StrokeAlign, StrokeLayer};
|
use vectorcraft_doc::{ArrowAlign, Arrowhead, Dash, LineCap, LineJoin, StrokeAlign, StrokeLayer, WidthProfile};
|
||||||
|
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
|
@ -13,7 +13,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||||
"Stroke Options",
|
"Stroke Options",
|
||||||
["Window", "Stroke"],
|
["Window", "Stroke"],
|
||||||
None,
|
None,
|
||||||
"{weight?, cap?: butt|round|square, join?: miter|round|bevel, miterLimit?, align?: center|inside|outside, dash?: [d,g,…]|null, dashOffset?, alignDashes?, startArrow?, endArrow?: name|null, profile?: \"uniform\"|\"lens\"|\"taperStart\"|\"taperEnd\", ids?}",
|
"{weight?, cap?: butt|round|square, join?: miter|round|bevel, miterLimit?, align?: center|inside|outside, dash?: [d,g,…]|null (a 0 dash with a round or projecting cap draws dots or squares), dashOffset?, alignDashes?, startArrow?, endArrow?: Arrow|ArrowOpen|Triangle|TriangleOpen|Circle|CircleOpen|Square|SquareOpen|Diamond|Bar|null, arrowAlign?: \"extend\" (tip past the end point, default)|\"tip\" (tip on the end point; the stroke is shortened), profile?: \"uniform\"|\"lens\"|\"taperStart\"|\"taperEnd\", ids?}",
|
||||||
has_doc,
|
has_doc,
|
||||||
stroke_set
|
stroke_set
|
||||||
),
|
),
|
||||||
|
|
@ -48,6 +48,17 @@ fn stroke_set(s: &mut Session, p: &Value) -> Result<Value> {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
let (sa, ea) = (arrow("startArrow")?, arrow("endArrow")?);
|
let (sa, ea) = (arrow("startArrow")?, arrow("endArrow")?);
|
||||||
|
let arrow_align = match str_param(p, "arrowAlign") {
|
||||||
|
None => None,
|
||||||
|
Some("extend") => Some(ArrowAlign::Extend),
|
||||||
|
Some("tip") => Some(ArrowAlign::Tip),
|
||||||
|
Some(o) => return Err(bad("stroke.set", format!("arrowAlign must be extend|tip, got {o}"))),
|
||||||
|
};
|
||||||
|
let profile = match str_param(p, "profile") {
|
||||||
|
None => None,
|
||||||
|
Some("uniform") => Some(None),
|
||||||
|
Some(id) => Some(Some(WidthProfile::preset(id).ok_or_else(|| bad("stroke.set", format!("unknown profile {id}")))?)),
|
||||||
|
};
|
||||||
s.edit("Stroke", |d, _| {
|
s.edit("Stroke", |d, _| {
|
||||||
for id in &ids {
|
for id in &ids {
|
||||||
let Some(n) = d.node_mut(*id) else { continue };
|
let Some(n) = d.node_mut(*id) else { continue };
|
||||||
|
|
@ -97,12 +108,11 @@ fn stroke_set(s: &mut Session, p: &Value) -> Result<Value> {
|
||||||
if let Some(a) = ea {
|
if let Some(a) = ea {
|
||||||
st.end_arrow = a;
|
st.end_arrow = a;
|
||||||
}
|
}
|
||||||
match str_param(p, "profile") {
|
if let Some(a) = arrow_align {
|
||||||
Some("uniform") => st.profile = None,
|
st.arrow_align = a;
|
||||||
Some("lens") => st.profile = Some(vectorcraft_doc::WidthProfile::lens()),
|
}
|
||||||
Some("taperStart") => st.profile = Some(vectorcraft_doc::WidthProfile::taper_start()),
|
if let Some(pr) = &profile {
|
||||||
Some("taperEnd") => st.profile = Some(vectorcraft_doc::WidthProfile::taper_end()),
|
st.profile = pr.clone();
|
||||||
_ => {}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|
@ -111,11 +121,11 @@ fn stroke_set(s: &mut Session, p: &Value) -> Result<Value> {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Mirror a width profile along the path (t → 1 − t) or across it (swap left/right widths).
|
/// Mirror a width profile along the path (t → 1 − t) or across it (swap left/right widths).
|
||||||
pub(crate) fn flip_profile(p: &vectorcraft_doc::WidthProfile, along: bool) -> vectorcraft_doc::WidthProfile {
|
pub(crate) fn flip_profile(p: &WidthProfile, along: bool) -> WidthProfile {
|
||||||
let mut pts: Vec<(f64, f64, f64)> =
|
let mut pts: Vec<(f64, f64, f64)> =
|
||||||
if along { p.points.iter().map(|&(t, l, r)| (1.0 - t, l, r)).collect() } else { p.points.iter().map(|&(t, l, r)| (t, r, l)).collect() };
|
if along { p.points.iter().map(|&(t, l, r)| (1.0 - t, l, r)).collect() } else { p.points.iter().map(|&(t, l, r)| (t, r, l)).collect() };
|
||||||
pts.sort_by(|a, b| a.0.total_cmp(&b.0));
|
pts.sort_by(|a, b| a.0.total_cmp(&b.0));
|
||||||
vectorcraft_doc::WidthProfile { points: pts }
|
WidthProfile { points: pts }
|
||||||
}
|
}
|
||||||
|
|
||||||
fn stroke_advanced(s: &mut Session, p: &Value) -> Result<Value> {
|
fn stroke_advanced(s: &mut Session, p: &Value) -> Result<Value> {
|
||||||
|
|
|
||||||
|
|
@ -742,6 +742,8 @@ mod tests_prefs;
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests_swatches;
|
mod tests_swatches;
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
mod tests_strokegeom;
|
||||||
|
#[cfg(test)]
|
||||||
mod tests_textedit;
|
mod tests_textedit;
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests_xform;
|
mod tests_xform;
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,76 @@
|
||||||
|
//! Stroke geometry from the Stroke panel's commands (`cmd/stroke.rs`): arrowhead alignment and
|
||||||
|
//! the width-profile presets.
|
||||||
|
|
||||||
|
use serde_json::json;
|
||||||
|
use vectorcraft_doc::{ArrowAlign, StrokeLayer, WidthProfile};
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn session_with_line() -> (Session, NodeId) {
|
||||||
|
let mut s = Session::new();
|
||||||
|
s.execute("file.new", &json!({"width": 200, "height": 200})).unwrap();
|
||||||
|
let r = s.execute("shape.line", &json!({"x1": 10, "y1": 100, "x2": 150, "y2": 100})).unwrap();
|
||||||
|
(s, NodeId(r["id"].as_u64().unwrap()))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn stroke(s: &Session, id: NodeId) -> StrokeLayer {
|
||||||
|
s.doc().unwrap().doc.node(id).unwrap().appearance.stroke().unwrap().clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn arrow_align_is_set_per_stroke_and_validated() {
|
||||||
|
let (mut s, id) = session_with_line();
|
||||||
|
assert_eq!(stroke(&s, id).arrow_align, ArrowAlign::Extend);
|
||||||
|
s.execute("stroke.set", &json!({"endArrow": "TriangleOpen", "arrowAlign": "tip"})).unwrap();
|
||||||
|
let st = stroke(&s, id);
|
||||||
|
assert_eq!((st.end_arrow, st.arrow_align), (Some(vectorcraft_doc::Arrowhead::TriangleOpen), ArrowAlign::Tip));
|
||||||
|
s.execute("stroke.set", &json!({"arrowAlign": "extend", "ids": [id.0]})).unwrap();
|
||||||
|
assert_eq!(stroke(&s, id).arrow_align, ArrowAlign::Extend);
|
||||||
|
assert!(s.execute("stroke.set", &json!({"arrowAlign": "middle"})).is_err());
|
||||||
|
s.execute("edit.undo", &json!({})).unwrap();
|
||||||
|
assert_eq!(stroke(&s, id).arrow_align, ArrowAlign::Tip);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn profiles_come_from_the_preset_catalogue() {
|
||||||
|
let (mut s, id) = session_with_line();
|
||||||
|
for p in WidthProfile::PRESETS {
|
||||||
|
s.execute("stroke.set", &json!({"profile": p.id})).unwrap();
|
||||||
|
let st = stroke(&s, id);
|
||||||
|
assert_eq!(WidthProfile::id_of(st.profile.as_ref()), p.id);
|
||||||
|
}
|
||||||
|
assert!(stroke(&s, id).profile.is_some());
|
||||||
|
s.execute("stroke.set", &json!({"profile": "uniform"})).unwrap();
|
||||||
|
assert!(stroke(&s, id).profile.is_none(), "uniform is the plain stroke");
|
||||||
|
assert!(s.execute("stroke.set", &json!({"profile": "zigzag"})).is_err());
|
||||||
|
// The params doc (what agents read) lists every preset and the arrow alignment.
|
||||||
|
let spec = command_specs().iter().find(|c| c.id == "stroke.set").unwrap();
|
||||||
|
for p in WidthProfile::PRESETS {
|
||||||
|
assert!(spec.params.contains(&format!("\"{}\"", p.id)), "{}", p.id);
|
||||||
|
}
|
||||||
|
assert!(spec.params.contains("arrowAlign"));
|
||||||
|
// …and every arrowhead name `startArrow`/`endArrow` accept.
|
||||||
|
for a in vectorcraft_doc::Arrowhead::ALL {
|
||||||
|
let name = serde_json::to_value(a).unwrap();
|
||||||
|
assert!(spec.params.contains(name.as_str().unwrap()), "{name}");
|
||||||
|
s.execute("stroke.set", &json!({"endArrow": name})).unwrap();
|
||||||
|
assert_eq!(stroke(&s, id).end_arrow, Some(a));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn arrow_align_and_profiles_survive_the_native_format() {
|
||||||
|
let (mut s, id) = session_with_line();
|
||||||
|
s.execute(
|
||||||
|
"stroke.set",
|
||||||
|
&json!({"startArrow": "CircleOpen", "endArrow": "Arrow", "arrowAlign": "tip", "profile": "lens", "dash": [0, 6], "cap": "round"}),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let doc = &s.doc().unwrap().doc;
|
||||||
|
let back = vectorcraft_format::load(&vectorcraft_format::save(doc, false)).unwrap();
|
||||||
|
assert_eq!(back.node(id).unwrap().appearance.stroke().unwrap(), &stroke(&s, id));
|
||||||
|
// A stroke with the default alignment writes nothing new, so older builds read it unchanged.
|
||||||
|
s.execute("stroke.set", &json!({"arrowAlign": "extend"})).unwrap();
|
||||||
|
let text = String::from_utf8(vectorcraft_format::save(&s.doc().unwrap().doc, true)).unwrap();
|
||||||
|
assert!(!text.contains("arrow_align"));
|
||||||
|
}
|
||||||
|
|
@ -11,10 +11,10 @@ use krilla::num::NormalizedF32;
|
||||||
use krilla::page::PageSettings;
|
use krilla::page::PageSettings;
|
||||||
use krilla::paint::{Fill, LinearGradient, RadialGradient, SpreadMethod, Stop, Stroke, StrokeDash};
|
use krilla::paint::{Fill, LinearGradient, RadialGradient, SpreadMethod, Stop, Stroke, StrokeDash};
|
||||||
use krilla::surface::Surface;
|
use krilla::surface::Surface;
|
||||||
use kurbo::{ParamCurve, ParamCurveDeriv, PathEl, Shape, Vec2};
|
use kurbo::{PathEl, Shape, Vec2};
|
||||||
use vectorcraft_color::{BlendMode, Color, GradientKind, Paint};
|
use vectorcraft_color::{BlendMode, Color, GradientKind, Paint};
|
||||||
use vectorcraft_doc::appearance::stroke_paint_bounds;
|
use vectorcraft_doc::appearance::stroke_paint_bounds;
|
||||||
use vectorcraft_doc::{AppearanceItem, Arrowhead, Document, LineCap, LineJoin, Node, NodeKind, StrokeAlign, StrokeLayer, TextObject};
|
use vectorcraft_doc::{AppearanceItem, Document, LineCap, LineJoin, Node, NodeKind, StrokeAlign, StrokeLayer, TextObject};
|
||||||
use vectorcraft_geom::{Affine, BezPath, FillRule, Rect};
|
use vectorcraft_geom::{Affine, BezPath, FillRule, Rect};
|
||||||
|
|
||||||
use crate::{Compatibility, ExportReport, PdfError, PdfOptions};
|
use crate::{Compatibility, ExportReport, PdfError, PdfOptions};
|
||||||
|
|
@ -522,6 +522,18 @@ impl Exporter<'_> {
|
||||||
s.push_blend_mode(blend(st.blend));
|
s.push_blend_mode(blend(st.blend));
|
||||||
pushes += 1;
|
pushes += 1;
|
||||||
}
|
}
|
||||||
|
// The line ends under its arrowheads; they overlap, so both take the opacity once, as a group.
|
||||||
|
let pieces = vectorcraft_effects::stroke::stroke_pieces(bp, st);
|
||||||
|
let grouped = !pieces.heads.is_empty() && st.opacity < 1.0;
|
||||||
|
if grouped {
|
||||||
|
s.push_opacity(norm(st.opacity));
|
||||||
|
pushes += 1;
|
||||||
|
}
|
||||||
|
let opacity = if grouped { NormalizedF32::ONE } else { norm(st.opacity) };
|
||||||
|
let trimmed = match &pieces.line {
|
||||||
|
std::borrow::Cow::Owned(line) => Some(to_path(line)),
|
||||||
|
std::borrow::Cow::Borrowed(_) => None,
|
||||||
|
};
|
||||||
let width = match st.align {
|
let width = match st.align {
|
||||||
StrokeAlign::Center => st.width,
|
StrokeAlign::Center => st.width,
|
||||||
_ if closed => st.width * 2.0,
|
_ if closed => st.width * 2.0,
|
||||||
|
|
@ -566,20 +578,23 @@ impl Exporter<'_> {
|
||||||
LineJoin::Round => krilla::paint::LineJoin::Round,
|
LineJoin::Round => krilla::paint::LineJoin::Round,
|
||||||
LineJoin::Bevel => krilla::paint::LineJoin::Bevel,
|
LineJoin::Bevel => krilla::paint::LineJoin::Bevel,
|
||||||
},
|
},
|
||||||
opacity: norm(st.opacity),
|
opacity,
|
||||||
dash,
|
dash,
|
||||||
}));
|
}));
|
||||||
s.draw_path(path);
|
// A line fully covered by its heads (Tip alignment) has nothing left to stroke.
|
||||||
|
if let Some(line) = trimmed.as_ref().map_or(Some(path), Option::as_ref) {
|
||||||
|
s.draw_path(line);
|
||||||
|
}
|
||||||
s.set_stroke(None);
|
s.set_stroke(None);
|
||||||
// Pop the alignment clip before drawing arrowheads.
|
// Pop the alignment clip before drawing arrowheads.
|
||||||
if matches!(st.align, StrokeAlign::Inside | StrokeAlign::Outside) && closed && pushes > 0 {
|
if matches!(st.align, StrokeAlign::Inside | StrokeAlign::Outside) && closed && pushes > 0 {
|
||||||
s.pop();
|
s.pop();
|
||||||
pushes -= 1;
|
pushes -= 1;
|
||||||
}
|
}
|
||||||
if st.start_arrow.is_some() || st.end_arrow.is_some() {
|
if !pieces.heads.is_empty() {
|
||||||
for head in arrowheads(bp, st) {
|
s.set_fill(Some(Fill { paint, opacity, rule: krilla::paint::FillRule::NonZero }));
|
||||||
if let Some(p) = to_path(&head) {
|
for head in &pieces.heads {
|
||||||
s.set_fill(Some(Fill { paint: paint.clone(), opacity: norm(st.opacity), rule: krilla::paint::FillRule::NonZero }));
|
if let Some(p) = to_path(&head.outline) {
|
||||||
s.draw_path(&p);
|
s.draw_path(&p);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -678,78 +693,3 @@ impl Exporter<'_> {
|
||||||
s.pop();
|
s.pop();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Arrowhead outlines for a stroke (same geometry as the renderer).
|
|
||||||
fn arrowheads(bp: &BezPath, st: &StrokeLayer) -> Vec<BezPath> {
|
|
||||||
let segs: Vec<kurbo::PathSeg> = bp.segments().collect();
|
|
||||||
let mut out = Vec::new();
|
|
||||||
let (Some(first), Some(last)) = (segs.first(), segs.last()) else { return out };
|
|
||||||
if let Some(a) = st.start_arrow {
|
|
||||||
out.push(head(a, first.eval(0.0), tangent(first, 0.0) * -1.0, st.width * st.arrow_scale.0 / 100.0));
|
|
||||||
}
|
|
||||||
if let Some(a) = st.end_arrow {
|
|
||||||
out.push(head(a, last.eval(1.0), tangent(last, 1.0), st.width * st.arrow_scale.1 / 100.0));
|
|
||||||
}
|
|
||||||
out
|
|
||||||
}
|
|
||||||
|
|
||||||
fn tangent(s: &kurbo::PathSeg, t: f64) -> Vec2 {
|
|
||||||
let d = match s {
|
|
||||||
kurbo::PathSeg::Line(l) => l.p1 - l.p0,
|
|
||||||
kurbo::PathSeg::Quad(q) => q.deriv().eval(t).to_vec2(),
|
|
||||||
kurbo::PathSeg::Cubic(c) => {
|
|
||||||
let v = c.deriv().eval(t).to_vec2();
|
|
||||||
if v.hypot() < 1e-9 { c.p3 - c.p0 } else { v }
|
|
||||||
}
|
|
||||||
};
|
|
||||||
let l = d.hypot();
|
|
||||||
if l < 1e-12 { Vec2::new(1.0, 0.0) } else { d / l }
|
|
||||||
}
|
|
||||||
|
|
||||||
fn head(kind: Arrowhead, tip: kurbo::Point, dir: Vec2, w: f64) -> BezPath {
|
|
||||||
let w = w.max(0.25);
|
|
||||||
let n = Vec2::new(-dir.y, dir.x);
|
|
||||||
let len = 4.0 * w;
|
|
||||||
let half = 2.0 * w;
|
|
||||||
let base = tip - dir * len;
|
|
||||||
let mut p = BezPath::new();
|
|
||||||
match kind {
|
|
||||||
Arrowhead::Triangle | Arrowhead::TriangleOpen | Arrowhead::Arrow | Arrowhead::ArrowOpen => {
|
|
||||||
p.move_to(tip);
|
|
||||||
p.line_to(base + n * half);
|
|
||||||
if matches!(kind, Arrowhead::Arrow | Arrowhead::ArrowOpen) {
|
|
||||||
p.line_to(tip - dir * (len * 0.7));
|
|
||||||
}
|
|
||||||
p.line_to(base - n * half);
|
|
||||||
p.close_path();
|
|
||||||
}
|
|
||||||
Arrowhead::Circle | Arrowhead::CircleOpen => {
|
|
||||||
p = kurbo::Circle::new(tip - dir * half, half).to_path(0.01);
|
|
||||||
}
|
|
||||||
Arrowhead::Square | Arrowhead::SquareOpen => {
|
|
||||||
let c = tip - dir * half;
|
|
||||||
p.move_to(c + dir * half + n * half);
|
|
||||||
p.line_to(c - dir * half + n * half);
|
|
||||||
p.line_to(c - dir * half - n * half);
|
|
||||||
p.line_to(c + dir * half - n * half);
|
|
||||||
p.close_path();
|
|
||||||
}
|
|
||||||
Arrowhead::Diamond => {
|
|
||||||
let c = tip - dir * half;
|
|
||||||
p.move_to(tip);
|
|
||||||
p.line_to(c + n * half);
|
|
||||||
p.line_to(c - dir * half);
|
|
||||||
p.line_to(c - n * half);
|
|
||||||
p.close_path();
|
|
||||||
}
|
|
||||||
Arrowhead::Bar => {
|
|
||||||
let t = w * 0.75;
|
|
||||||
p.move_to(tip + n * half);
|
|
||||||
p.line_to(tip + n * half - dir * t);
|
|
||||||
p.line_to(tip - n * half - dir * t);
|
|
||||||
p.line_to(tip - n * half);
|
|
||||||
p.close_path();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
p
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -105,3 +105,5 @@ pub enum PdfError {
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests;
|
mod tests;
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests_stroke;
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,92 @@
|
||||||
|
//! Stroke geometry in PDF export: arrowheads and the trimmed line come from the shared
|
||||||
|
//! `vectorcraft_effects::stroke` geometry, and they take the stroke opacity once.
|
||||||
|
|
||||||
|
use vectorcraft_color::{Color, Paint};
|
||||||
|
use vectorcraft_doc::{Appearance, AppearanceItem, ArrowAlign, Arrowhead, Document, Node, NodeId, NodeKind, StrokeLayer};
|
||||||
|
use vectorcraft_effects::stroke::stroke_pieces;
|
||||||
|
use vectorcraft_geom::{Point, Rect, Shape, shapes};
|
||||||
|
|
||||||
|
use crate::*;
|
||||||
|
|
||||||
|
fn line_doc(st: StrokeLayer) -> (Document, Node) {
|
||||||
|
let mut d = Document::new(100.0, 100.0);
|
||||||
|
let mut n = Node::path(
|
||||||
|
NodeId(0),
|
||||||
|
shapes::line(Point::new(20.0, 50.0), Point::new(80.0, 50.0)),
|
||||||
|
Appearance { items: vec![AppearanceItem::Stroke(st)], effects: vec![] },
|
||||||
|
);
|
||||||
|
n.id = d.alloc_id();
|
||||||
|
let layer = d.default_layer().unwrap();
|
||||||
|
d.insert(Some(layer), 0, n.clone()).unwrap();
|
||||||
|
(d, n)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn arrow_stroke(kind: Arrowhead, align: ArrowAlign, opacity: f32) -> StrokeLayer {
|
||||||
|
let mut st = StrokeLayer::new(Paint::solid(Color::BLACK), 4.0);
|
||||||
|
st.end_arrow = Some(kind);
|
||||||
|
st.arrow_align = align;
|
||||||
|
st.opacity = opacity;
|
||||||
|
st
|
||||||
|
}
|
||||||
|
|
||||||
|
fn close(a: Rect, b: Rect) -> bool {
|
||||||
|
[(a.x0, b.x0), (a.y0, b.y0), (a.x1, b.x1), (a.y1, b.y1)].iter().all(|(p, q)| (p - q).abs() < 0.01)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bounding boxes of the filled and the stroked paths of a PDF read back.
|
||||||
|
fn painted_boxes(d: &Document) -> (Vec<Rect>, Vec<Rect>) {
|
||||||
|
let back = import(&export(d, &PdfOptions::default()).unwrap()).unwrap();
|
||||||
|
let (mut fills, mut strokes) = (vec![], vec![]);
|
||||||
|
back.walk(|m| {
|
||||||
|
let Some(bb) = m.geometric_bounds().filter(|_| matches!(m.kind, NodeKind::Path { .. } | NodeKind::Compound { .. })) else { return };
|
||||||
|
match m.appearance.items.first() {
|
||||||
|
Some(AppearanceItem::Fill(_)) => fills.push(bb),
|
||||||
|
Some(AppearanceItem::Stroke(_)) => strokes.push(bb),
|
||||||
|
None => {}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
(fills, strokes)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn arrowheads_and_the_trimmed_line_match_the_shared_geometry() {
|
||||||
|
for align in [ArrowAlign::Extend, ArrowAlign::Tip] {
|
||||||
|
for kind in Arrowhead::ALL {
|
||||||
|
let (d, n) = line_doc(arrow_stroke(kind, align, 1.0));
|
||||||
|
let bp = n.path_data().unwrap().to_bezpath();
|
||||||
|
let pieces = stroke_pieces(&bp, n.appearance.stroke().unwrap());
|
||||||
|
let (fills, strokes) = painted_boxes(&d);
|
||||||
|
let head = pieces.heads[0].outline.bounding_box();
|
||||||
|
assert_eq!(fills.len(), 1, "{kind:?} {align:?}: one head");
|
||||||
|
assert!(close(fills[0], head), "{kind:?} {align:?}: {:?} vs {head:?}", fills[0]);
|
||||||
|
assert_eq!(strokes.len(), 1, "{kind:?} {align:?}: one line");
|
||||||
|
assert!(close(strokes[0], pieces.line.bounding_box()), "{kind:?} {align:?}: {:?}", strokes[0]);
|
||||||
|
if align == ArrowAlign::Tip {
|
||||||
|
assert!((strokes[0].x1 - (80.0 - pieces.heads[0].inset)).abs() < 0.01, "{kind:?}: the stroke stops under the head");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn line_and_head_share_one_opacity_group() {
|
||||||
|
let (d, _) = line_doc(arrow_stroke(Arrowhead::Triangle, ArrowAlign::Tip, 0.5));
|
||||||
|
let back = import(&export(&d, &PdfOptions::default()).unwrap()).unwrap();
|
||||||
|
let mut group_opacity = None;
|
||||||
|
let mut leaf_opacities = vec![];
|
||||||
|
back.walk(|m| match &m.kind {
|
||||||
|
NodeKind::Group { .. } if m.opacity < 1.0 => group_opacity = Some(m.opacity),
|
||||||
|
NodeKind::Path { .. } => {
|
||||||
|
leaf_opacities.push(m.opacity);
|
||||||
|
for i in &m.appearance.items {
|
||||||
|
leaf_opacities.push(match i {
|
||||||
|
AppearanceItem::Fill(f) => f.opacity,
|
||||||
|
AppearanceItem::Stroke(s) => s.opacity,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
});
|
||||||
|
assert!(group_opacity.is_some_and(|o| (o - 0.5).abs() < 0.01), "{group_opacity:?}");
|
||||||
|
assert!(leaf_opacities.iter().all(|o| (o - 1.0).abs() < 1e-3), "{leaf_opacities:?}");
|
||||||
|
}
|
||||||
|
|
@ -12,23 +12,22 @@ mod live;
|
||||||
mod paint;
|
mod paint;
|
||||||
mod pattern;
|
mod pattern;
|
||||||
pub mod proof;
|
pub mod proof;
|
||||||
mod width;
|
|
||||||
|
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use vectorcraft_doc::appearance::stroke_paint_bounds;
|
use vectorcraft_doc::appearance::stroke_paint_bounds;
|
||||||
use vectorcraft_doc::{AppearanceItem, Document, LineCap, LineJoin, Node, NodeId, NodeKind, StrokeAlign, StrokeLayer, TextObject};
|
use vectorcraft_doc::{AppearanceItem, Document, Node, NodeId, NodeKind, StrokeAlign, StrokeLayer, TextObject};
|
||||||
use vectorcraft_geom::{Affine, BezPath, FillRule, Rect, Shape};
|
use vectorcraft_geom::{Affine, BezPath, FillRule, Rect, Shape};
|
||||||
use vello_cpu::kurbo;
|
use vello_cpu::kurbo;
|
||||||
use vello_cpu::peniko::{self, BlendMode, Compose, Mix};
|
use vello_cpu::peniko::{self, BlendMode, Compose, Mix};
|
||||||
use vello_cpu::{Pixmap, RenderContext, Resources};
|
use vello_cpu::{Pixmap, RenderContext, Resources};
|
||||||
|
|
||||||
|
pub use effects::stroke::width_outline;
|
||||||
pub use live::expand_live;
|
pub use live::expand_live;
|
||||||
pub use pattern::render_pattern_swatch;
|
pub use pattern::render_pattern_swatch;
|
||||||
pub use vectorcraft_effects as effects;
|
pub use vectorcraft_effects as effects;
|
||||||
pub use vello_cpu;
|
pub use vello_cpu;
|
||||||
pub use width::width_outline;
|
|
||||||
|
|
||||||
/// Largest raster an export may ask [`Renderer::render_region`] for, per side. The CPU rasteriser
|
/// Largest raster an export may ask [`Renderer::render_region`] for, per side. The CPU rasteriser
|
||||||
/// addresses at most `u16::MAX` pixels per side (and panics at that edge).
|
/// addresses at most `u16::MAX` pixels per side (and panics at that edge).
|
||||||
|
|
@ -673,57 +672,36 @@ impl Renderer {
|
||||||
}
|
}
|
||||||
|
|
||||||
fn draw_stroke(&mut self, ctx: &mut RenderContext, f: &Frame, bp: &BezPath, rule: FillRule, st: &StrokeLayer, bounds: Rect) {
|
fn draw_stroke(&mut self, ctx: &mut RenderContext, f: &Frame, bp: &BezPath, rule: FillRule, st: &StrokeLayer, bounds: Rect) {
|
||||||
let layered = st.opacity < 1.0 || st.blend != vectorcraft_color::BlendMode::Normal || st.align == StrokeAlign::Outside;
|
let pieces = effects::stroke::stroke_pieces(bp, st);
|
||||||
|
// The line and its heads overlap: they take the opacity once, in one layer, instead of
|
||||||
|
// each folding it into its paint.
|
||||||
|
let folded = if pieces.heads.is_empty() { 1.0 } else { std::mem::replace(&mut self.alpha, 1.0) };
|
||||||
|
let opacity = st.opacity * folded;
|
||||||
|
let layered = opacity < 1.0 || st.blend != vectorcraft_color::BlendMode::Normal || st.align == StrokeAlign::Outside;
|
||||||
if layered {
|
if layered {
|
||||||
ctx.set_transform(Affine::IDENTITY);
|
ctx.set_transform(Affine::IDENTITY);
|
||||||
ctx.push_layer(None, Some(blend_mode(st.blend)), Some(st.opacity), None, None);
|
ctx.push_layer(None, Some(blend_mode(st.blend)), Some(opacity), None, None);
|
||||||
}
|
}
|
||||||
ctx.set_transform(f.view);
|
ctx.set_transform(f.view);
|
||||||
let closed = bp.elements().last().is_some_and(|e| matches!(e, kurbo::PathEl::ClosePath));
|
let closed = effects::stroke::is_closed(bp);
|
||||||
let width = match st.align {
|
// Keep hairlines visible when zoomed far out (at least ~1 device pixel).
|
||||||
StrokeAlign::Center => st.width,
|
let width = effects::stroke::aligned_width(st, closed).max(f.px * 0.5);
|
||||||
_ if closed => st.width * 2.0,
|
|
||||||
_ => st.width,
|
|
||||||
};
|
|
||||||
// Keep hairlines visible when zoomed far out (Illustrator shows at least ~1 device pixel).
|
|
||||||
let width = width.max(f.px * 0.5);
|
|
||||||
let mut stroke = kurbo::Stroke::new(width)
|
|
||||||
.with_join(match st.join {
|
|
||||||
LineJoin::Miter => kurbo::Join::Miter,
|
|
||||||
LineJoin::Round => kurbo::Join::Round,
|
|
||||||
LineJoin::Bevel => kurbo::Join::Bevel,
|
|
||||||
})
|
|
||||||
.with_caps(match st.cap {
|
|
||||||
LineCap::Butt => kurbo::Cap::Butt,
|
|
||||||
LineCap::Round => kurbo::Cap::Round,
|
|
||||||
LineCap::Square => kurbo::Cap::Square,
|
|
||||||
})
|
|
||||||
.with_miter_limit(st.miter_limit);
|
|
||||||
if let Some(d) = &st.dash
|
|
||||||
&& d.is_dashed()
|
|
||||||
{
|
|
||||||
let mut pat = d.pattern.clone();
|
|
||||||
if pat.len() % 2 == 1 {
|
|
||||||
pat.extend(pat.clone());
|
|
||||||
}
|
|
||||||
stroke = stroke.with_dashes(d.offset, pat);
|
|
||||||
}
|
|
||||||
ctx.set_stroke(stroke.clone());
|
|
||||||
let inside = st.align == StrokeAlign::Inside && closed;
|
let inside = st.align == StrokeAlign::Inside && closed;
|
||||||
if inside {
|
if inside {
|
||||||
ctx.set_fill_rule(fill_rule(rule));
|
ctx.set_fill_rule(fill_rule(rule));
|
||||||
ctx.push_clip_layer(bp);
|
ctx.push_clip_layer(bp);
|
||||||
}
|
}
|
||||||
if paint::set_paint(ctx, &st.paint, bounds.inflate(st.width / 2.0, st.width / 2.0), f.doc) {
|
// One paint box for the line and the heads, so a gradient runs on into the heads.
|
||||||
|
let paint_bounds = bounds.inflate(st.width / 2.0, st.width / 2.0);
|
||||||
|
if paint::set_paint(ctx, &st.paint, paint_bounds, f.doc) {
|
||||||
self.fold_alpha(ctx, &st.paint);
|
self.fold_alpha(ctx, &st.paint);
|
||||||
if let Some(o) = width::outline_for(bp, st, f.px * 0.25) {
|
ctx.set_fill_rule(peniko::Fill::NonZero);
|
||||||
ctx.set_fill_rule(peniko::Fill::NonZero);
|
match self.cached_stroke(f, &pieces.line, st, width) {
|
||||||
ctx.fill_path(&o);
|
Some(o) => ctx.fill_path(&o),
|
||||||
} else if let Some(o) = self.cached_stroke(f, bp, st, &stroke) {
|
None => ctx.fill_path(&effects::stroke::line_outline(&pieces.line, st, width, f.px * 0.25)),
|
||||||
ctx.set_fill_rule(peniko::Fill::NonZero);
|
}
|
||||||
ctx.fill_path(&o);
|
for head in &pieces.heads {
|
||||||
} else {
|
ctx.fill_path(&head.outline);
|
||||||
ctx.stroke_path(bp);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if inside {
|
if inside {
|
||||||
|
|
@ -737,36 +715,29 @@ impl Renderer {
|
||||||
ctx.fill_path(bp);
|
ctx.fill_path(bp);
|
||||||
ctx.set_blend_mode(BlendMode::default());
|
ctx.set_blend_mode(BlendMode::default());
|
||||||
}
|
}
|
||||||
// Arrowheads.
|
|
||||||
if st.start_arrow.is_some() || st.end_arrow.is_some() {
|
|
||||||
for (arrow, head) in paint::arrowheads(bp, st) {
|
|
||||||
let _ = arrow;
|
|
||||||
if paint::set_paint(ctx, &st.paint, bounds, f.doc) {
|
|
||||||
ctx.set_fill_rule(peniko::Fill::NonZero);
|
|
||||||
ctx.fill_path(&head);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if layered {
|
if layered {
|
||||||
ctx.pop_layer();
|
ctx.pop_layer();
|
||||||
}
|
}
|
||||||
|
if !pieces.heads.is_empty() {
|
||||||
|
self.alpha = folded;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The stroke of the fast-path node `self.cur` expanded to a fill outline in document space,
|
/// The line part of a stroke of the fast-path node `self.cur` (see
|
||||||
/// cached across frames. The tolerance (vello's 0.25 device px) is bucketed to powers of two,
|
/// [`effects::stroke::line_outline`]), cached across frames. The tolerance (vello's 0.25
|
||||||
/// never coarser than needed, so zooming reuses outlines until the level changes.
|
/// device px) is bucketed to powers of two, never coarser than needed, so zooming reuses
|
||||||
fn cached_stroke(&mut self, f: &Frame, bp: &BezPath, st: &StrokeLayer, stroke: &kurbo::Stroke) -> Option<Arc<BezPath>> {
|
/// outlines until the level changes.
|
||||||
|
fn cached_stroke(&mut self, f: &Frame, line: &BezPath, st: &StrokeLayer, width: f64) -> Option<Arc<BezPath>> {
|
||||||
let node = self.cur.clone()?;
|
let node = self.cur.clone()?;
|
||||||
let level = (f.px * 0.25).log2().floor() as i32;
|
let level = (f.px * 0.25).log2().floor() as i32;
|
||||||
let key = (st as *const StrokeLayer as usize, stroke.width.to_bits(), level);
|
let key = (st as *const StrokeLayer as usize, width.to_bits(), level);
|
||||||
if let Some(e) = self.strokes.get_mut(&key)
|
if let Some(e) = self.strokes.get_mut(&key)
|
||||||
&& Arc::ptr_eq(&e.node, &node)
|
&& Arc::ptr_eq(&e.node, &node)
|
||||||
{
|
{
|
||||||
e.stamp = self.stamp;
|
e.stamp = self.stamp;
|
||||||
return Some(e.outline.clone());
|
return Some(e.outline.clone());
|
||||||
}
|
}
|
||||||
let tolerance = 2f64.powi(level);
|
let outline = Arc::new(effects::stroke::line_outline(line, st, width, 2f64.powi(level)));
|
||||||
let outline = Arc::new(kurbo::stroke(bp.iter(), stroke, &kurbo::StrokeOpts::default(), tolerance));
|
|
||||||
self.strokes.insert(key, StrokeEntry { node, outline: outline.clone(), stamp: self.stamp });
|
self.strokes.insert(key, StrokeEntry { node, outline: outline.clone(), stamp: self.stamp });
|
||||||
Some(outline)
|
Some(outline)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,9 @@
|
||||||
//! Paint conversion (solid, gradients, patterns), arrowheads and image decoding.
|
//! Paint conversion (solid, gradients, patterns) and image decoding.
|
||||||
|
|
||||||
use vectorcraft_color::{GradientKind, Paint};
|
use vectorcraft_color::{GradientKind, Paint};
|
||||||
use vectorcraft_doc::{Arrowhead, Document, StrokeLayer};
|
use vectorcraft_doc::Document;
|
||||||
use vectorcraft_geom::{Affine, BezPath, Point, Rect, Shape, Vec2};
|
use vectorcraft_geom::{Affine, Rect, Vec2};
|
||||||
use vello_cpu::RenderContext;
|
use vello_cpu::RenderContext;
|
||||||
use vello_cpu::kurbo::{self, ParamCurve, ParamCurveDeriv};
|
|
||||||
use vello_cpu::peniko::{self, ColorStop};
|
use vello_cpu::peniko::{self, ColorStop};
|
||||||
|
|
||||||
fn color(c: &vectorcraft_color::Color, alpha: f32) -> peniko::Color {
|
fn color(c: &vectorcraft_color::Color, alpha: f32) -> peniko::Color {
|
||||||
|
|
@ -54,86 +53,6 @@ pub fn set_paint(ctx: &mut RenderContext, p: &Paint, bounds: Rect, doc: &Documen
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Arrowhead geometry for a stroke: (which, filled outline) in document coordinates.
|
|
||||||
pub fn arrowheads(bp: &BezPath, st: &StrokeLayer) -> Vec<(Arrowhead, BezPath)> {
|
|
||||||
let segs: Vec<kurbo::PathSeg> = bp.segments().collect();
|
|
||||||
let mut out = Vec::new();
|
|
||||||
let (Some(first), Some(last)) = (segs.first(), segs.last()) else { return out };
|
|
||||||
if let Some(a) = st.start_arrow {
|
|
||||||
let p = first.eval(0.0);
|
|
||||||
let d = tangent(first, 0.0) * -1.0;
|
|
||||||
out.push((a, head(a, p, d, st.width * st.arrow_scale.0 / 100.0)));
|
|
||||||
}
|
|
||||||
if let Some(a) = st.end_arrow {
|
|
||||||
let p = last.eval(1.0);
|
|
||||||
let d = tangent(last, 1.0);
|
|
||||||
out.push((a, head(a, p, d, st.width * st.arrow_scale.1 / 100.0)));
|
|
||||||
}
|
|
||||||
out
|
|
||||||
}
|
|
||||||
|
|
||||||
fn tangent(s: &kurbo::PathSeg, t: f64) -> Vec2 {
|
|
||||||
let d = match s {
|
|
||||||
kurbo::PathSeg::Line(l) => l.p1 - l.p0,
|
|
||||||
kurbo::PathSeg::Quad(q) => q.deriv().eval(t).to_vec2(),
|
|
||||||
kurbo::PathSeg::Cubic(c) => {
|
|
||||||
let v = c.deriv().eval(t).to_vec2();
|
|
||||||
if v.hypot() < 1e-9 { c.p3 - c.p0 } else { v }
|
|
||||||
}
|
|
||||||
};
|
|
||||||
let l = d.hypot();
|
|
||||||
if l < 1e-12 { Vec2::new(1.0, 0.0) } else { d / l }
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Arrowhead outline pointing along `dir` with its tip at `tip`, sized for stroke weight `w`.
|
|
||||||
fn head(kind: Arrowhead, tip: Point, dir: Vec2, w: f64) -> BezPath {
|
|
||||||
let w = w.max(0.25);
|
|
||||||
let n = Vec2::new(-dir.y, dir.x);
|
|
||||||
let len = 4.0 * w;
|
|
||||||
let half = 2.0 * w;
|
|
||||||
let base = tip - dir * len;
|
|
||||||
let mut p = BezPath::new();
|
|
||||||
match kind {
|
|
||||||
Arrowhead::Triangle | Arrowhead::TriangleOpen | Arrowhead::Arrow | Arrowhead::ArrowOpen => {
|
|
||||||
p.move_to(tip);
|
|
||||||
p.line_to(base + n * half);
|
|
||||||
if matches!(kind, Arrowhead::Arrow | Arrowhead::ArrowOpen) {
|
|
||||||
p.line_to(tip - dir * (len * 0.7));
|
|
||||||
}
|
|
||||||
p.line_to(base - n * half);
|
|
||||||
p.close_path();
|
|
||||||
}
|
|
||||||
Arrowhead::Circle | Arrowhead::CircleOpen => {
|
|
||||||
p = kurbo::Circle::new(tip - dir * half, half).to_path(0.01);
|
|
||||||
}
|
|
||||||
Arrowhead::Square | Arrowhead::SquareOpen => {
|
|
||||||
let c = tip - dir * half;
|
|
||||||
p.move_to(c + dir * half + n * half);
|
|
||||||
p.line_to(c - dir * half + n * half);
|
|
||||||
p.line_to(c - dir * half - n * half);
|
|
||||||
p.line_to(c + dir * half - n * half);
|
|
||||||
p.close_path();
|
|
||||||
}
|
|
||||||
Arrowhead::Diamond => {
|
|
||||||
let c = tip - dir * half;
|
|
||||||
p.move_to(tip);
|
|
||||||
p.line_to(c + n * half);
|
|
||||||
p.line_to(c - dir * half);
|
|
||||||
p.line_to(c - n * half);
|
|
||||||
p.close_path();
|
|
||||||
}
|
|
||||||
Arrowhead::Bar => {
|
|
||||||
let t = w * 0.75;
|
|
||||||
p.move_to(tip + n * half);
|
|
||||||
p.line_to(tip + n * half - dir * t);
|
|
||||||
p.line_to(tip - n * half - dir * t);
|
|
||||||
p.line_to(tip - n * half);
|
|
||||||
p.close_path();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
p
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Decode encoded image bytes into a premultiplied pixmap.
|
/// Decode encoded image bytes into a premultiplied pixmap.
|
||||||
pub fn decode_pixmap(bytes: &[u8]) -> Option<vello_cpu::Pixmap> {
|
pub fn decode_pixmap(bytes: &[u8]) -> Option<vello_cpu::Pixmap> {
|
||||||
let img = image::load_from_memory(bytes).ok()?.to_rgba8();
|
let img = image::load_from_memory(bytes).ok()?.to_rgba8();
|
||||||
|
|
|
||||||
|
|
@ -202,8 +202,9 @@ fn arrowheads_render() {
|
||||||
let l = d.layers[0].id;
|
let l = d.layers[0].id;
|
||||||
d.insert(Some(l), 0, n).unwrap();
|
d.insert(Some(l), 0, n).unwrap();
|
||||||
let r = render(&d);
|
let r = render(&d);
|
||||||
// Arrowhead is wider than the 2 pt line.
|
// The arrowhead (past the end point by default: tip at 84) is wider than the 2 pt line.
|
||||||
assert!(r.pixel(73, 47)[0] < 128);
|
assert!(r.pixel(78, 48)[0] < 128);
|
||||||
|
assert!(r.pixel(85, 50)[0] > 200, "nothing past the tip");
|
||||||
}
|
}
|
||||||
|
|
||||||
use vectorcraft_doc::Arrowhead;
|
use vectorcraft_doc::Arrowhead;
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,184 @@
|
||||||
|
//! Pixel tests for the shared stroke geometry (`vectorcraft_effects::stroke`) on the canvas:
|
||||||
|
//! dotted lines (zero-length dashes) and arrowheads (hollow kinds, alignment, compositing).
|
||||||
|
|
||||||
|
use vectorcraft_color::{Color, Gradient, GradientKind, GradientPaint, GradientStop, Paint};
|
||||||
|
use vectorcraft_doc::{Appearance, AppearanceItem, ArrowAlign, Arrowhead, Dash, Document, LineCap, StrokeLayer};
|
||||||
|
use vectorcraft_geom::{Point, Rect, Shape, shapes};
|
||||||
|
use vectorcraft_render::{Renderer, effects};
|
||||||
|
use vectorcraft_testkit::fixtures::DocBuilder;
|
||||||
|
use vectorcraft_testkit::raster::{Image, assert_similar, render_region};
|
||||||
|
use vectorcraft_testkit::svg;
|
||||||
|
|
||||||
|
/// Pixels per point in these tests.
|
||||||
|
const S: f64 = 4.0;
|
||||||
|
|
||||||
|
fn stroked(width: f64, f: impl FnOnce(&mut StrokeLayer)) -> Appearance {
|
||||||
|
let mut st = StrokeLayer::new(Paint::solid(Color::BLACK), width);
|
||||||
|
f(&mut st);
|
||||||
|
Appearance { items: vec![AppearanceItem::Stroke(st)], effects: vec![] }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A horizontal line from x0 to x1 at y = 50 on a 100 × 100 artboard.
|
||||||
|
fn line_doc(x0: f64, x1: f64, app: Appearance, opacity: f32) -> Document {
|
||||||
|
let mut b = DocBuilder::new(100.0, 100.0);
|
||||||
|
b.path(shapes::line(Point::new(x0, 50.0), Point::new(x1, 50.0)), app, |n| n.opacity = opacity);
|
||||||
|
b.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render(d: &Document) -> Image {
|
||||||
|
render_region(d, Rect::new(0.0, 0.0, 100.0, 100.0), S)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Is the pixel at document point (x, y) dark?
|
||||||
|
fn dark(img: &Image, x: f64, y: f64) -> bool {
|
||||||
|
img.over_white((x * S) as u32, (y * S) as u32)[0] < 128
|
||||||
|
}
|
||||||
|
|
||||||
|
fn dotted(cap: LineCap) -> Document {
|
||||||
|
line_doc(
|
||||||
|
20.0,
|
||||||
|
80.0,
|
||||||
|
stroked(4.0, |s| {
|
||||||
|
s.cap = cap;
|
||||||
|
s.dash = Some(Dash { pattern: vec![0.0, 6.0], offset: 0.0, align_corners: false });
|
||||||
|
}),
|
||||||
|
1.0,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn zero_length_dashes_draw_dots_with_a_round_cap_and_squares_with_a_projecting_cap() {
|
||||||
|
let round = render(&dotted(LineCap::Round));
|
||||||
|
// 60 pt with a dot every 6 pt: 11 separate dots along the centre line.
|
||||||
|
let row: Vec<bool> = (0..round.width).map(|x| round.over_white(x, (50.0 * S) as u32)[0] < 128).collect();
|
||||||
|
let runs = row.windows(2).filter(|w| !w[0] && w[1]).count();
|
||||||
|
assert_eq!(runs, 11, "dots along the line");
|
||||||
|
for k in 0..=10 {
|
||||||
|
let x = 20.0 + 6.0 * k as f64;
|
||||||
|
assert!(dark(&round, x, 50.0), "dot {k}");
|
||||||
|
assert!(!dark(&round, x + 3.0, 50.0), "gap after dot {k}");
|
||||||
|
}
|
||||||
|
// A disc's corner region is empty; a square fills it.
|
||||||
|
assert!(!dark(&round, 20.0 + 1.8, 50.0 + 1.8));
|
||||||
|
let square = render(&dotted(LineCap::Square));
|
||||||
|
assert!(dark(&square, 20.0 + 1.8, 50.0 + 1.8));
|
||||||
|
assert!(dark(&square, 26.0 - 1.8, 50.0 - 1.8));
|
||||||
|
// A butt cap gives a zero-length dash no area at all.
|
||||||
|
assert_eq!(render(&dotted(LineCap::Butt)).ink(), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn dotted_lines_survive_svg_export_and_render_the_same() {
|
||||||
|
for cap in [LineCap::Round, LineCap::Square] {
|
||||||
|
let d = dotted(cap);
|
||||||
|
let text = svg::export(&d, &Default::default());
|
||||||
|
let back = svg::import(&text).unwrap();
|
||||||
|
assert_similar(&render(&d), &render(&back), 0.05, 0.001);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn arrow_doc(kind: Arrowhead, align: ArrowAlign, opacity: f32, f: impl FnOnce(&mut StrokeLayer)) -> Document {
|
||||||
|
line_doc(
|
||||||
|
20.0,
|
||||||
|
80.0,
|
||||||
|
stroked(4.0, |s| {
|
||||||
|
s.end_arrow = Some(kind);
|
||||||
|
s.arrow_align = align;
|
||||||
|
f(s);
|
||||||
|
}),
|
||||||
|
opacity,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The arrowhead the shared geometry computes for the line of `d`.
|
||||||
|
fn head_of(d: &Document) -> effects::stroke::Arrow {
|
||||||
|
let n = d.layers[0].children().unwrap()[0].clone();
|
||||||
|
let bp = n.path_data().unwrap().to_bezpath();
|
||||||
|
effects::stroke::stroke_pieces(&bp, n.appearance.stroke().unwrap()).heads.remove(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_hollow_circle_head_is_empty_inside() {
|
||||||
|
let d = arrow_doc(Arrowhead::CircleOpen, ArrowAlign::Extend, 1.0, |_| {});
|
||||||
|
let h = head_of(&d);
|
||||||
|
let centre = h.tip - h.dir * 8.0;
|
||||||
|
let img = render(&d);
|
||||||
|
assert!(!dark(&img, centre.x, centre.y), "the centre of an open circle is empty");
|
||||||
|
assert!(dark(&img, h.tip.x - 1.0, 50.0), "its wall is painted");
|
||||||
|
// The filled circle is solid.
|
||||||
|
let solid = render(&arrow_doc(Arrowhead::Circle, ArrowAlign::Extend, 1.0, |_| {}));
|
||||||
|
assert!(dark(&solid, centre.x, centre.y));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn heads_are_drawn_where_the_shared_geometry_puts_them_and_never_past_the_tip() {
|
||||||
|
for align in [ArrowAlign::Extend, ArrowAlign::Tip] {
|
||||||
|
for kind in Arrowhead::ALL {
|
||||||
|
let d = arrow_doc(kind, align, 1.0, |s| s.cap = LineCap::Round);
|
||||||
|
let h = head_of(&d);
|
||||||
|
if align == ArrowAlign::Tip {
|
||||||
|
assert_eq!(h.tip, Point::new(80.0, 50.0));
|
||||||
|
} else {
|
||||||
|
assert!((h.tip.x - (80.0 + h.inset)).abs() < 1e-9);
|
||||||
|
}
|
||||||
|
let img = render(&d);
|
||||||
|
// The rightmost ink is the tip (allowing for antialiasing).
|
||||||
|
let right = (0..img.width).rev().find(|&x| (0..img.height).any(|y| img.over_white(x, y)[0] < 250)).unwrap();
|
||||||
|
let bb = h.outline.bounding_box();
|
||||||
|
assert!((right as f64 / S - bb.x1).abs() <= 0.5, "{kind:?} {align:?}: ink ends at {} vs head {}", right as f64 / S, bb.x1);
|
||||||
|
assert!(!dark(&img, h.tip.x + 0.5, 50.0), "{kind:?} {align:?}: nothing past the tip");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Straight-alpha pixel at document point (x, y), rendered on transparent.
|
||||||
|
fn alpha_at(d: &Document, x: f64, y: f64) -> u8 {
|
||||||
|
let r = Renderer::new().render_region(d, Rect::new(0.0, 0.0, 100.0, 100.0), S, false);
|
||||||
|
r.pixel((x * S) as u32, (y * S) as u32)[3]
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn line_and_head_take_the_opacity_once() {
|
||||||
|
for (node, stroke) in [(0.5, 1.0), (1.0, 0.5)] {
|
||||||
|
let d = arrow_doc(Arrowhead::Triangle, ArrowAlign::Extend, node, |s| s.opacity = stroke);
|
||||||
|
let h = head_of(&d);
|
||||||
|
let line = alpha_at(&d, 40.0, 50.0);
|
||||||
|
assert!((120..=135).contains(&line), "half-opaque line: {line}");
|
||||||
|
// Where the line runs under the head, and in the head beside it.
|
||||||
|
let overlap = alpha_at(&d, h.tip.x - h.inset - 1.0, 50.0);
|
||||||
|
let beside = alpha_at(&d, h.tip.x - 12.0, 50.0 + 4.5);
|
||||||
|
assert_eq!(overlap, line, "no double darkening where line and head overlap");
|
||||||
|
assert_eq!(beside, line, "the head has the line's opacity");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_gradient_runs_on_into_the_head() {
|
||||||
|
let stops = vec![
|
||||||
|
GradientStop { offset: 0.0, color: Color::rgb(1.0, 0.0, 0.0), opacity: 1.0, midpoint: 0.5 },
|
||||||
|
GradientStop { offset: 1.0, color: Color::rgb(0.0, 0.0, 1.0), opacity: 1.0, midpoint: 0.5 },
|
||||||
|
];
|
||||||
|
let paint = Paint::Gradient(Box::new(GradientPaint::new(Gradient { kind: GradientKind::Linear, stops })));
|
||||||
|
let d = arrow_doc(Arrowhead::Square, ArrowAlign::Tip, 1.0, |s| s.paint = paint);
|
||||||
|
let h = head_of(&d);
|
||||||
|
let img = render(&d);
|
||||||
|
// Just behind the head's back edge (line) and just inside it (head): nearly the same colour.
|
||||||
|
let back = h.tip.x - 16.0;
|
||||||
|
let (a, b) = (img.over_white(((back - 0.5) * S) as u32, (50.0 * S) as u32), img.over_white(((back + 0.5) * S) as u32, (50.0 * S) as u32));
|
||||||
|
assert!(a[0].abs_diff(b[0]) < 12 && a[2].abs_diff(b[2]) < 12, "{a:?} vs {b:?}");
|
||||||
|
// The head is part of the same gradient: bluer than the line's start.
|
||||||
|
let start = img.over_white((22.0 * S) as u32, (50.0 * S) as u32);
|
||||||
|
assert!(b[2] > start[2] + 100, "{b:?} vs {start:?}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_head_past_the_end_counts_in_the_visual_bounds_and_is_never_culled() {
|
||||||
|
let d = arrow_doc(Arrowhead::Triangle, ArrowAlign::Extend, 1.0, |s| s.join = vectorcraft_doc::LineJoin::Round);
|
||||||
|
let h = head_of(&d);
|
||||||
|
let vb = d.layers[0].children().unwrap()[0].visual_bounds().unwrap();
|
||||||
|
let head = h.outline.bounding_box();
|
||||||
|
assert_eq!(vb.union(head), vb, "{vb:?} covers the head {head:?}");
|
||||||
|
// A view that shows only the overhanging part of the head (the line ends at 80).
|
||||||
|
let img = render_region(&d, Rect::new(83.0, 40.0, 100.0, 60.0), S);
|
||||||
|
assert!(img.ink() > 0, "the head is drawn");
|
||||||
|
}
|
||||||
|
|
@ -44,6 +44,8 @@ pub static ICONS: &[(&str, &[u8])] = &[
|
||||||
("dc-appearance", include_bytes!("../../../assets/icons/dc-appearance.svg")),
|
("dc-appearance", include_bytes!("../../../assets/icons/dc-appearance.svg")),
|
||||||
("dc-arc", include_bytes!("../../../assets/icons/dc-arc.svg")),
|
("dc-arc", include_bytes!("../../../assets/icons/dc-arc.svg")),
|
||||||
("dc-arrow-down", include_bytes!("../../../assets/icons/dc-arrow-down.svg")),
|
("dc-arrow-down", include_bytes!("../../../assets/icons/dc-arrow-down.svg")),
|
||||||
|
("dc-arrow-extend", include_bytes!("../../../assets/icons/dc-arrow-extend.svg")),
|
||||||
|
("dc-arrow-tip", include_bytes!("../../../assets/icons/dc-arrow-tip.svg")),
|
||||||
("dc-arrow-up", include_bytes!("../../../assets/icons/dc-arrow-up.svg")),
|
("dc-arrow-up", include_bytes!("../../../assets/icons/dc-arrow-up.svg")),
|
||||||
("dc-artboard-options", include_bytes!("../../../assets/icons/dc-artboard-options.svg")),
|
("dc-artboard-options", include_bytes!("../../../assets/icons/dc-artboard-options.svg")),
|
||||||
("dc-artboards", include_bytes!("../../../assets/icons/dc-artboards.svg")),
|
("dc-artboards", include_bytes!("../../../assets/icons/dc-artboards.svg")),
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
|
|
||||||
use egui::{Color32, Pos2, Rect, Sense, Stroke, StrokeKind, Ui, pos2, vec2};
|
use egui::{Color32, Pos2, Rect, Sense, Stroke, StrokeKind, Ui, pos2, vec2};
|
||||||
use serde_json::{Value, json};
|
use serde_json::{Value, json};
|
||||||
use vectorcraft_doc::{Arrowhead, LineCap, LineJoin, StrokeAlign, StrokeLayer, Unit, WidthProfile};
|
use vectorcraft_doc::{ArrowAlign, Arrowhead, LineCap, LineJoin, ProfilePreset, StrokeAlign, StrokeLayer, Unit, WidthProfile};
|
||||||
|
|
||||||
use super::{first_selected, pstate, set_pstate};
|
use super::{first_selected, pstate, set_pstate};
|
||||||
use crate::theme::Tokens;
|
use crate::theme::Tokens;
|
||||||
|
|
@ -13,27 +13,20 @@ use crate::{VectorcraftApp, icons};
|
||||||
pub const WEIGHT_PRESETS: [f64; 22] =
|
pub const WEIGHT_PRESETS: [f64; 22] =
|
||||||
[0.25, 0.5, 0.75, 1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0, 20.0, 30.0, 40.0, 50.0, 60.0, 70.0, 80.0, 90.0, 100.0];
|
[0.25, 0.5, 0.75, 1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0, 20.0, 30.0, 40.0, 50.0, 60.0, 70.0, 80.0, 90.0, 100.0];
|
||||||
|
|
||||||
/// Width profile presets: (id for `stroke.set`, label).
|
/// The silhouette drawn for profile `id` (None draws the plain uniform bar).
|
||||||
pub const PROFILES: [(&str, &str); 4] = [("uniform", "Uniform"), ("lens", "Lens"), ("taperStart", "Taper Start"), ("taperEnd", "Taper End")];
|
fn profile_of(id: &str) -> Option<WidthProfile> {
|
||||||
|
WidthProfile::preset(id).filter(|_| id != "uniform")
|
||||||
/// Which preset a stroke's profile is (None profile = uniform).
|
|
||||||
pub fn profile_id(p: Option<&WidthProfile>) -> &'static str {
|
|
||||||
let Some(p) = p else { return "uniform" };
|
|
||||||
for (id, prof) in [("lens", WidthProfile::lens()), ("taperStart", WidthProfile::taper_start()), ("taperEnd", WidthProfile::taper_end())] {
|
|
||||||
if prof.points == p.points {
|
|
||||||
return id;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
"custom"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn profile_of(id: &str) -> Option<WidthProfile> {
|
/// The Arrowheads Align buttons: (alignment, `stroke.set` value, icon, tooltip).
|
||||||
match id {
|
const ARROW_ALIGN: [(ArrowAlign, &str, &str, &str); 2] = [
|
||||||
"lens" => Some(WidthProfile::lens()),
|
(ArrowAlign::Extend, "extend", "dc-arrow-extend", "Tip extends past the end point"),
|
||||||
"taperStart" => Some(WidthProfile::taper_start()),
|
(ArrowAlign::Tip, "tip", "dc-arrow-tip", "Tip on the end point"),
|
||||||
"taperEnd" => Some(WidthProfile::taper_end()),
|
];
|
||||||
_ => None,
|
|
||||||
}
|
/// Which Align button is on, and whether they are usable (only a stroke with a head can be aligned).
|
||||||
|
fn arrow_align_state(st: Option<&StrokeLayer>) -> (ArrowAlign, bool) {
|
||||||
|
st.map_or((ArrowAlign::default(), false), |s| (s.arrow_align, s.start_arrow.is_some() || s.end_arrow.is_some()))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Dash pattern → the panel's six dash/gap fields (None = empty field).
|
/// Dash pattern → the panel's six dash/gap fields (None = empty field).
|
||||||
|
|
@ -211,11 +204,12 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||||
});
|
});
|
||||||
let scale = st.as_ref().map(|s| s.arrow_scale).unwrap_or((100.0, 100.0));
|
let scale = st.as_ref().map(|s| s.arrow_scale).unwrap_or((100.0, 100.0));
|
||||||
let linked: bool = pstate(ui.ctx(), "arrow-scale-link");
|
let linked: bool = pstate(ui.ctx(), "arrow-scale-link");
|
||||||
|
// Scale and Align only mean something for a stroke with a head.
|
||||||
|
let (arrow_align, has_head) = arrow_align_state(st.as_ref());
|
||||||
ui.horizontal(|ui| {
|
ui.horizontal(|ui| {
|
||||||
row_label(ui, "Scale:");
|
row_label(ui, "Scale:");
|
||||||
let on = st.is_some() && (sa.is_some() || ea.is_some());
|
|
||||||
let mut ns = None;
|
let mut ns = None;
|
||||||
ui.add_enabled_ui(on, |ui| {
|
ui.add_enabled_ui(has_head, |ui| {
|
||||||
if let Some(v) = widgets::plain_field(ui, "arrow-scale-s", scale.0, "%", 0, 56.0) {
|
if let Some(v) = widgets::plain_field(ui, "arrow-scale-s", scale.0, "%", 0, 56.0) {
|
||||||
ns = Some((v, if linked { v } else { scale.1 }));
|
ns = Some((v, if linked { v } else { scale.1 }));
|
||||||
}
|
}
|
||||||
|
|
@ -232,12 +226,15 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||||
});
|
});
|
||||||
ui.horizontal(|ui| {
|
ui.horizontal(|ui| {
|
||||||
row_label(ui, "Align:");
|
row_label(ui, "Align:");
|
||||||
widgets::icon_button_enabled(ui, "dc-cap-square", "Tip extends past the end (on the roadmap)", true, false, 22.0);
|
for (v, name, icon, tip) in ARROW_ALIGN {
|
||||||
widgets::icon_button_enabled(ui, "dc-cap-butt", "Tip on the end point (on the roadmap)", false, false, 22.0);
|
if widgets::icon_button_enabled(ui, icon, tip, arrow_align == v, has_head, 22.0).clicked() {
|
||||||
|
set(app, json!({"arrowAlign": name}));
|
||||||
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
widgets::divider(ui);
|
widgets::divider(ui);
|
||||||
// Profile.
|
// Profile.
|
||||||
let pid = profile_id(st.as_ref().and_then(|s| s.profile.as_ref()));
|
let pid = WidthProfile::id_of(st.as_ref().and_then(|s| s.profile.as_ref()));
|
||||||
ui.horizontal(|ui| {
|
ui.horizontal(|ui| {
|
||||||
row_label(ui, "Profile:");
|
row_label(ui, "Profile:");
|
||||||
if let Some(id) = profile_dropdown(ui, pid) {
|
if let Some(id) = profile_dropdown(ui, pid) {
|
||||||
|
|
@ -355,7 +352,7 @@ fn profile_dropdown(ui: &mut Ui, cur: &str) -> Option<&'static str> {
|
||||||
let resp = resp.on_hover_text("Variable Width Profile");
|
let resp = resp.on_hover_text("Variable Width Profile");
|
||||||
let mut out = None;
|
let mut out = None;
|
||||||
egui::Popup::menu(&resp).show(|ui| {
|
egui::Popup::menu(&resp).show(|ui| {
|
||||||
for (id, label) in PROFILES {
|
for ProfilePreset { id, label, .. } in WidthProfile::PRESETS {
|
||||||
let (row, rr) = ui.allocate_exact_size(vec2(170.0, 26.0), Sense::click());
|
let (row, rr) = ui.allocate_exact_size(vec2(170.0, 26.0), Sense::click());
|
||||||
if id == cur {
|
if id == cur {
|
||||||
ui.painter().rect_filled(row, 0.0, t.row_selected);
|
ui.painter().rect_filled(row, 0.0, t.row_selected);
|
||||||
|
|
@ -404,11 +401,51 @@ mod tests {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn profile_ids() {
|
fn profile_silhouettes_and_arrow_labels() {
|
||||||
assert_eq!(profile_id(None), "uniform");
|
assert!(profile_of("uniform").is_none());
|
||||||
assert_eq!(profile_id(Some(&WidthProfile::lens())), "lens");
|
assert_eq!(profile_of("lens"), Some(WidthProfile::lens()));
|
||||||
assert_eq!(profile_id(Some(&WidthProfile::taper_end())), "taperEnd");
|
assert!(profile_of("custom").is_none());
|
||||||
assert_eq!(profile_id(Some(&WidthProfile { points: vec![(0.0, 0.3, 0.3)] })), "custom");
|
|
||||||
assert_eq!(arrow_label(Some(Arrowhead::CircleOpen)), "Circle (open)");
|
assert_eq!(arrow_label(Some(Arrowhead::CircleOpen)), "Circle (open)");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn arrow_align_buttons_need_a_head() {
|
||||||
|
let mut st = StrokeLayer::new(vectorcraft_color::Paint::None, 1.0);
|
||||||
|
assert_eq!(arrow_align_state(None), (ArrowAlign::Extend, false));
|
||||||
|
assert_eq!(arrow_align_state(Some(&st)), (ArrowAlign::Extend, false));
|
||||||
|
st.start_arrow = Some(Arrowhead::Bar);
|
||||||
|
st.arrow_align = ArrowAlign::Tip;
|
||||||
|
assert_eq!(arrow_align_state(Some(&st)), (ArrowAlign::Tip, true));
|
||||||
|
for (_, _, icon, _) in ARROW_ALIGN {
|
||||||
|
assert!(icons::exists(icon), "{icon}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Run the panel and its ≡ menu for one headless frame.
|
||||||
|
fn frame(app: &mut VectorcraftApp) {
|
||||||
|
let ctx = egui::Context::default();
|
||||||
|
let mut out = ctx.run_ui(egui::RawInput::default(), |ui| {
|
||||||
|
show(app, ui);
|
||||||
|
menu(app, ui);
|
||||||
|
});
|
||||||
|
out.textures_delta.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn panel_draws_for_lines_with_and_without_heads() {
|
||||||
|
use vectorcraft_engine::Session;
|
||||||
|
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||||
|
let r = |app: &mut VectorcraftApp, id: &str, p: Value| app.session.execute(id, &p).unwrap();
|
||||||
|
r(&mut app, "file.new", json!({"width": 100, "height": 100}));
|
||||||
|
frame(&mut app);
|
||||||
|
r(&mut app, "shape.line", json!({"x1": 10, "y1": 50, "x2": 90, "y2": 50}));
|
||||||
|
frame(&mut app);
|
||||||
|
for p in [json!({"endArrow": "ArrowOpen", "arrowAlign": "tip"}), json!({"profile": "lens"}), json!({"dash": [0, 6], "cap": "round"})] {
|
||||||
|
r(&mut app, "stroke.set", p);
|
||||||
|
frame(&mut app);
|
||||||
|
}
|
||||||
|
let st = stroke_now(&app).unwrap();
|
||||||
|
assert_eq!((st.end_arrow, st.arrow_align), (Some(Arrowhead::ArrowOpen), ArrowAlign::Tip));
|
||||||
|
assert_eq!(WidthProfile::id_of(st.profile.as_ref()), "lens");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -56,7 +56,7 @@ fn library_submenus_are_disabled_placeholders() {
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn stroke_profiles_have_descriptive_names() {
|
fn stroke_profiles_have_descriptive_names() {
|
||||||
let labels: Vec<&str> = crate::panels::stroke::PROFILES.iter().map(|p| p.1).collect();
|
let labels: Vec<&str> = vectorcraft_doc::WidthProfile::PRESETS.iter().map(|p| p.label).collect();
|
||||||
assert_eq!(labels, ["Uniform", "Lens", "Taper Start", "Taper End"]);
|
assert_eq!(labels, ["Uniform", "Lens", "Taper Start", "Taper End"]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue