Merge pull request #47 from storytold/pr/m3-stroke-depth

M3: width profile library, thirty more arrowheads, whole-stroke bounds and hit-testing (M3.61–M3.63)
This commit is contained in:
BFlat 2026-10-05 02:59:27 +07:00 committed by GitHub
commit 2b07aa2ac9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
25 changed files with 1868 additions and 251 deletions

View File

@ -48,6 +48,7 @@ impl Dash {
}
}
/// An arrowhead shape. Saved by variant name: new shapes are appended, never renamed or reordered.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum Arrowhead {
Triangle,
@ -60,21 +61,130 @@ pub enum Arrowhead {
Diamond,
Arrow,
ArrowOpen,
Barbed,
/// One barb, on the left of the direction the head points.
HalfArrowLeft,
/// One barb, on the right of the direction the head points.
HalfArrowRight,
Concave,
DoubleBar,
Feather,
DotOnBar,
Chevron,
DoubleArrow,
Target,
Star,
Cross,
Plus,
Hexagon,
HexagonOpen,
Tag,
TagOpen,
HalfCircle,
Drop,
Slash,
DoubleSlash,
DiamondOpen,
TriangleReverse,
Swallowtail,
Bracket,
Fork,
Leaf,
Kite,
TriangleBar,
Oval,
}
impl Arrowhead {
pub const ALL: [Arrowhead; 10] = [
/// Every arrowhead, in menu order (arrows, then outlined shapes, then bars and marks).
pub const ALL: [Arrowhead; 40] = [
Arrowhead::Arrow,
Arrowhead::ArrowOpen,
Arrowhead::Barbed,
Arrowhead::Concave,
Arrowhead::DoubleArrow,
Arrowhead::HalfArrowLeft,
Arrowhead::HalfArrowRight,
Arrowhead::Chevron,
Arrowhead::Feather,
Arrowhead::Swallowtail,
Arrowhead::Triangle,
Arrowhead::TriangleOpen,
Arrowhead::TriangleReverse,
Arrowhead::TriangleBar,
Arrowhead::Kite,
Arrowhead::Leaf,
Arrowhead::Drop,
Arrowhead::Circle,
Arrowhead::CircleOpen,
Arrowhead::HalfCircle,
Arrowhead::Oval,
Arrowhead::Target,
Arrowhead::Square,
Arrowhead::SquareOpen,
Arrowhead::Tag,
Arrowhead::TagOpen,
Arrowhead::Diamond,
Arrowhead::DiamondOpen,
Arrowhead::Hexagon,
Arrowhead::HexagonOpen,
Arrowhead::Star,
Arrowhead::Cross,
Arrowhead::Plus,
Arrowhead::Bar,
Arrowhead::DoubleBar,
Arrowhead::DotOnBar,
Arrowhead::Slash,
Arrowhead::DoubleSlash,
Arrowhead::Bracket,
Arrowhead::Fork,
];
/// The name the arrowhead menus show.
pub fn label(self) -> &'static str {
match self {
Arrowhead::Triangle => "Triangle",
Arrowhead::TriangleOpen => "Triangle (open)",
Arrowhead::Circle => "Circle",
Arrowhead::CircleOpen => "Circle (open)",
Arrowhead::Square => "Square",
Arrowhead::SquareOpen => "Square (open)",
Arrowhead::Bar => "Bar",
Arrowhead::Diamond => "Diamond",
Arrowhead::Arrow => "Arrow",
Arrowhead::ArrowOpen => "Arrow (open)",
Arrowhead::Barbed => "Barbed",
Arrowhead::HalfArrowLeft => "Half Arrow (left)",
Arrowhead::HalfArrowRight => "Half Arrow (right)",
Arrowhead::Concave => "Concave",
Arrowhead::DoubleBar => "Double Bar",
Arrowhead::Feather => "Feather",
Arrowhead::DotOnBar => "Dot on Bar",
Arrowhead::Chevron => "Chevron",
Arrowhead::DoubleArrow => "Double Arrow",
Arrowhead::Target => "Target",
Arrowhead::Star => "Star",
Arrowhead::Cross => "Cross",
Arrowhead::Plus => "Plus",
Arrowhead::Hexagon => "Hexagon",
Arrowhead::HexagonOpen => "Hexagon (open)",
Arrowhead::Tag => "Tag",
Arrowhead::TagOpen => "Tag (open)",
Arrowhead::HalfCircle => "Half Circle",
Arrowhead::Drop => "Drop",
Arrowhead::Slash => "Slash",
Arrowhead::DoubleSlash => "Double Slash",
Arrowhead::DiamondOpen => "Diamond (open)",
Arrowhead::TriangleReverse => "Reverse Triangle",
Arrowhead::Swallowtail => "Swallowtail",
Arrowhead::Bracket => "Bracket",
Arrowhead::Fork => "Fork",
Arrowhead::Leaf => "Leaf",
Arrowhead::Kite => "Kite",
Arrowhead::TriangleBar => "Triangle to Bar",
Arrowhead::Oval => "Oval",
}
}
}
/// Where an arrowhead sits relative to the end of its path. In both modes the stroke stops under
@ -106,9 +216,12 @@ pub struct ProfilePreset {
}
impl WidthProfile {
/// Width factor at `t` (average of both sides), linear between points.
/// (left, right) width factors at `t`, linear between points.
pub fn at(&self, t: f64) -> (f64, f64) {
let p = &self.points;
Self::at_points(&self.points, t)
}
/// [`Self::at`] of a profile's points (a preset's or a saved profile's).
pub fn at_points(p: &[(f64, f64, f64)], t: f64) -> (f64, f64) {
if p.is_empty() {
return (1.0, 1.0);
}
@ -125,11 +238,17 @@ impl WidthProfile {
(l.1, l.2)
}
/// The built-in profiles, in menu order. "uniform" is the plain stroke (no profile).
pub const PRESETS: [ProfilePreset; 4] = [
pub const PRESETS: [ProfilePreset; 7] = [
ProfilePreset { id: "uniform", label: "Uniform", points: &[(0.0, 1.0, 1.0), (1.0, 1.0, 1.0)] },
ProfilePreset { id: "lens", label: "Lens", points: &[(0.0, 0.0, 0.0), (0.5, 1.0, 1.0), (1.0, 0.0, 0.0)] },
ProfilePreset { id: "taperStart", label: "Taper Start", points: &[(0.0, 0.0, 0.0), (1.0, 1.0, 1.0)] },
ProfilePreset { id: "taperEnd", label: "Taper End", points: &[(0.0, 1.0, 1.0), (1.0, 0.0, 0.0)] },
// Full width at both ends, pinched to a quarter in the middle.
ProfilePreset { id: "pinch", label: "Pinch", points: &[(0.0, 1.0, 1.0), (0.5, 0.25, 0.25), (1.0, 1.0, 1.0)] },
// A round head a fifth of the way along, then a long taper to the end.
ProfilePreset { id: "teardrop", label: "Teardrop", points: &[(0.0, 0.0, 0.0), (0.2, 1.0, 1.0), (1.0, 0.0, 0.0)] },
// Two swells between narrow necks.
ProfilePreset { id: "wave", label: "Wave", points: &[(0.0, 0.3, 0.3), (0.25, 1.0, 1.0), (0.5, 0.3, 0.3), (0.75, 1.0, 1.0), (1.0, 0.3, 0.3)] },
];
/// The built-in profile with this id.
pub fn preset(id: &str) -> Option<Self> {
@ -155,6 +274,14 @@ impl WidthProfile {
}
}
/// A width profile saved to the Profile list under a name (Add to Profiles), kept with the
/// preferences.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct SavedProfile {
pub name: String,
pub profile: WidthProfile,
}
/// A live effect in an appearance stack. Parameters are interpreted by `vectorcraft-effects`.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Effect {
@ -239,6 +366,10 @@ pub struct StrokeLayer {
pub overprint: bool,
}
/// How far any arrowhead reaches from its tip, in units of its weight (the open arrow's arms are
/// cut square a little behind its 4 × 4 box).
pub(crate) const HEAD_REACH: f64 = 4.6;
fn ten() -> f64 {
10.0
}
@ -276,16 +407,50 @@ impl StrokeLayer {
}
}
/// 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.
/// a quarter point. A head of weight `hw` fits a box about `4·hw` long and wide.
pub fn arrow_weight(&self, end: bool) -> f64 {
let pct = if end { self.arrow_scale.1 } else { self.arrow_scale.0 };
(self.width * pct / 100.0).max(0.25)
}
/// How far the 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.
/// How far the start (`end == false`) or end arrowhead can reach from its end point (0
/// without one): the head's diagonal, or with [`ArrowAlign::Extend`] its length, plus the cap.
pub fn head_reach(&self, end: bool) -> f64 {
let head = if end { self.end_arrow } else { self.start_arrow };
head.map_or(0.0, |_| HEAD_REACH * self.arrow_weight(end) + self.width / 2.0)
}
/// How far the arrowheads can reach from the path's end points (0 without heads).
pub fn arrow_reach(&self) -> f64 {
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))
self.head_reach(false).max(self.head_reach(true))
}
/// The widest the width profile makes the stroke, as a factor of its weight (1 without one).
pub fn profile_max(&self) -> f64 {
self.profile.as_ref().and_then(|p| p.points.iter().map(|&(_, l, r)| l.max(r)).reduce(f64::max)).unwrap_or(1.0).max(0.0)
}
/// The stroke's body across a path that is `closed` or open: how far it reaches from the path
/// at full width (half the weight centred; the whole weight on one side when aligned inside or
/// outside a closed path, which open paths ignore), and for aligned strokes which side it
/// paints (`Some(true)`: inside).
pub fn body(&self, closed: bool) -> (f64, Option<bool>) {
match self.align {
StrokeAlign::Outside if closed => (self.width, Some(false)),
StrokeAlign::Inside if closed => (self.width, Some(true)),
_ => (self.width / 2.0, None),
}
}
/// How far the body paints outside a path that is `closed` or open, at the profile's widest.
pub fn side_reach(&self, closed: bool) -> f64 {
match self.body(closed) {
(_, Some(true)) => 0.0,
(half, _) => half * self.profile_max(),
}
}
/// The farthest this stroke can paint from any path: its body, miter spikes up to the miter
/// limit, projecting caps' corners and the arrowheads ([`Appearance::outset`]).
pub fn reach(&self) -> f64 {
let side = self.side_reach(true).max(self.side_reach(false));
let miter = if self.join == LineJoin::Miter { self.miter_limit.max(1.0) } else { 1.0 };
let cap = if self.cap == LineCap::Square { std::f64::consts::SQRT_2 } else { 1.0 };
(side * miter.max(cap)).max(self.arrow_reach())
}
/// The box an unplaced gradient on this stroke fits to (see [`stroke_paint_bounds`]).
pub fn paint_bounds(&self, geometric: vectorcraft_geom::Rect) -> vectorcraft_geom::Rect {
@ -529,22 +694,17 @@ impl Appearance {
Some(i) => self.items.get_mut(i).map(AppearanceItem::effects_mut),
}
}
/// Largest distance the painted area extends beyond the geometry (for visual bounds).
/// The visible strokes that paint something.
pub fn painted_strokes(&self) -> impl Iterator<Item = &StrokeLayer> {
self.items.iter().filter_map(|i| match i {
AppearanceItem::Stroke(s) if s.visible && !s.paint.is_none() => Some(s),
_ => None,
})
}
/// Largest distance the painted area can extend beyond any geometry (see
/// [`StrokeLayer::reach`]); paths measure their own with [`Self::stroked_bounds`].
pub fn outset(&self) -> f64 {
self.items
.iter()
.filter_map(|i| match i {
AppearanceItem::Stroke(s) if s.visible && !s.paint.is_none() => Some(
match s.align {
StrokeAlign::Center => s.width / 2.0 * if s.join == LineJoin::Miter { s.miter_limit.min(4.0) } else { 1.0 },
StrokeAlign::Outside => s.width,
StrokeAlign::Inside => 0.0,
}
.max(s.arrow_reach()),
),
_ => None,
})
.fold(0.0, f64::max)
self.painted_strokes().map(StrokeLayer::reach).fold(0.0, f64::max)
}
/// Scale stroke weights (Scale Strokes & Effects).
pub fn scale_strokes(&mut self, s: f64) {
@ -863,6 +1023,60 @@ mod tests {
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 arrowhead_names_are_stable_and_every_one_is_listed_once() {
// Saved files store these names: they may only ever be appended to.
const SAVED: [&str; 40] = [
"Triangle",
"TriangleOpen",
"Circle",
"CircleOpen",
"Square",
"SquareOpen",
"Bar",
"Diamond",
"Arrow",
"ArrowOpen",
"Barbed",
"HalfArrowLeft",
"HalfArrowRight",
"Concave",
"DoubleBar",
"Feather",
"DotOnBar",
"Chevron",
"DoubleArrow",
"Target",
"Star",
"Cross",
"Plus",
"Hexagon",
"HexagonOpen",
"Tag",
"TagOpen",
"HalfCircle",
"Drop",
"Slash",
"DoubleSlash",
"DiamondOpen",
"TriangleReverse",
"Swallowtail",
"Bracket",
"Fork",
"Leaf",
"Kite",
"TriangleBar",
"Oval",
];
for name in SAVED {
let a: Arrowhead = serde_json::from_value(serde_json::json!(name)).unwrap();
assert_eq!(serde_json::to_value(a).unwrap(), name);
assert_eq!(Arrowhead::ALL.iter().filter(|x| **x == a).count(), 1, "{name} in the menu once");
}
let labels: std::collections::HashSet<&str> = Arrowhead::ALL.iter().map(|a| a.label()).collect();
assert_eq!(labels.len(), Arrowhead::ALL.len(), "labels are distinct");
}
#[test]
fn arrow_align_defaults_to_extend_and_round_trips() {
let mut st = StrokeLayer::new(Paint::solid(Color::BLACK), 2.0);
@ -886,8 +1100,9 @@ mod tests {
st.arrow_scale = (100.0, 200.0);
assert_eq!(st.arrow_weight(false), 4.0);
assert_eq!(st.arrow_weight(true), 8.0);
assert_eq!((st.head_reach(false), st.head_reach(true)), (0.0, HEAD_REACH * 8.0 + 2.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);
assert_eq!(a.outset(), HEAD_REACH * 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");
}
@ -899,8 +1114,28 @@ mod tests {
assert_eq!(a.outset(), 5.0);
a.stroke_mut().unwrap().align = StrokeAlign::Outside;
assert_eq!(a.outset(), 10.0);
// Open paths stroke inside-aligned strokes centred.
a.stroke_mut().unwrap().align = StrokeAlign::Inside;
assert_eq!(a.outset(), 0.0);
assert_eq!(a.outset(), 5.0);
assert_eq!((a.stroke().unwrap().side_reach(true), a.stroke().unwrap().body(true)), (0.0, (10.0, Some(true))));
}
#[test]
fn outset_covers_miter_spikes_projecting_caps_and_profile_maxima() {
let mut a = Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 2.0);
// Miter joins can spike out to the limit × half the weight.
assert_eq!(a.outset(), 10.0);
let st = a.stroke_mut().unwrap();
st.join = LineJoin::Bevel;
st.cap = LineCap::Square;
assert!((a.outset() - std::f64::consts::SQRT_2).abs() < 1e-12, "a projecting cap's corner");
let st = a.stroke_mut().unwrap();
st.cap = LineCap::Round;
st.profile = Some(WidthProfile { points: vec![(0.0, 0.5, 2.5), (1.0, 1.0, 1.0)] });
assert_eq!(st.profile_max(), 2.5);
assert_eq!(a.outset(), 2.5);
a.stroke_mut().unwrap().paint = Paint::None;
assert_eq!(a.outset(), 0.0, "an unpainted stroke");
}
#[test]

View File

@ -99,7 +99,7 @@ fn hit_children(parent: &Node, p: Point, opt: HitOptions, chain: &mut Vec<NodeId
if !c.visible || c.locked {
continue;
}
if let Some(b) = c.visual_bounds()
if let Some(b) = c.reach_bounds()
&& !b.inflate(opt.tol, opt.tol).contains(p)
{
continue;
@ -117,13 +117,22 @@ fn hit_children(parent: &Node, p: Point, opt: HitOptions, chain: &mut Vec<NodeId
None
}
/// A click on the strokes of a path or compound path `n` along `bp`: what any visible stroke
/// paints there (arrowheads, alignment, width profile, projecting caps, miter spikes), else its
/// outline within the tolerance. Outline mode hits the outline only.
fn hit_stroke(n: &Node, bp: &vectorcraft_geom::BezPath, rule: vectorcraft_geom::FillRule, p: Point, opt: HitOptions) -> Option<HitKind> {
if !opt.outline && n.appearance.stroke_hit(bp, rule, p, opt.tol) {
return Some(HitKind::Stroke);
}
stroke_contains(bp, 0.0, opt.tol, p).then_some(HitKind::Outline)
}
fn hit_leaf(n: &Node, p: Point, opt: HitOptions) -> Option<HitKind> {
match &n.kind {
NodeKind::Path { path, rule, .. } => {
let bp = path.to_bezpath();
let sw = n.appearance.stroke_width();
if stroke_contains(&bp, if opt.outline { 0.0 } else { sw }, opt.tol, p) {
return Some(if opt.outline || sw == 0.0 { HitKind::Outline } else { HitKind::Stroke });
if let Some(k) = hit_stroke(n, &bp, *rule, p, opt) {
return Some(k);
}
let filled = !n.appearance.fill_paint().is_none();
if !opt.outline && !opt.path_only && filled && fill_contains(&bp, *rule, p) {
@ -131,15 +140,10 @@ fn hit_leaf(n: &Node, p: Point, opt: HitOptions) -> Option<HitKind> {
}
None
}
NodeKind::Compound { children, rule } => {
let mut bp = vectorcraft_geom::BezPath::new();
for c in children {
if let Some(pd) = c.path_data() {
bp.extend(pd.to_bezpath());
}
}
if stroke_contains(&bp, n.appearance.stroke_width(), opt.tol, p) {
return Some(HitKind::Stroke);
NodeKind::Compound { rule, .. } => {
let bp = n.stroke_path()?;
if let Some(k) = hit_stroke(n, &bp, *rule, p, opt) {
return Some(k);
}
(!opt.outline && fill_contains(&bp, *rule, p)).then_some(HitKind::Fill)
}

View File

@ -13,6 +13,7 @@ pub mod node;
pub mod overprint;
pub mod pattern;
mod pixels;
mod reach;
pub mod selection;
pub mod swatches;
pub mod text;
@ -34,8 +35,8 @@ pub(crate) mod skip {
}
pub use appearance::{
Appearance, AppearanceItem, ArrowAlign, Arrowhead, Dash, Effect, FillLayer, LineCap, LineJoin, ProfilePreset, StrokeAlign, StrokeLayer,
WidthProfile,
Appearance, AppearanceItem, ArrowAlign, Arrowhead, Dash, Effect, FillLayer, LineCap, LineJoin, ProfilePreset, SavedProfile, StrokeAlign,
StrokeLayer, WidthProfile,
};
pub use graph::{GraphKind, GraphSpec};
pub use hit::{Hit, HitKind};

View File

@ -426,6 +426,30 @@ impl Node {
_ => None,
}
}
/// The path a path's or compound path's own strokes follow (a compound's members' together).
pub fn stroke_path(&self) -> Option<vectorcraft_geom::BezPath> {
match &self.kind {
NodeKind::Path { path, .. } => Some(path.to_bezpath()),
NodeKind::Compound { children, .. } => {
let mut bp = vectorcraft_geom::BezPath::new();
children.iter().filter_map(|c| c.path_data()).for_each(|pd| bp.extend(pd.to_bezpath()));
Some(bp)
}
_ => None,
}
}
/// A quick box round everything the object can paint, for culling clicks: paths and compound
/// paths grow their geometric bounds by the farthest their strokes can reach from any path
/// ([`Appearance::outset`]) instead of measuring along their shape.
pub fn reach_bounds(&self) -> Option<Rect> {
match &self.kind {
NodeKind::Path { .. } | NodeKind::Compound { .. } => {
let o = self.appearance.outset();
self.geometric_bounds().map(|b| b.inflate(o, o))
}
_ => self.visual_bounds(),
}
}
/// Geometric bounds (no stroke), recursively. Clip groups are bounded by their clip path.
pub fn geometric_bounds(&self) -> Option<Rect> {
match &self.kind {
@ -449,7 +473,8 @@ impl Node {
NodeKind::Repeat(r) => r.bounds(),
}
}
/// Visual bounds (includes stroke outset).
/// Visual bounds: what the object paints, its strokes included (paths and compound paths
/// measure theirs along their own shape, see [`Appearance::stroked_bounds`]).
pub fn visual_bounds(&self) -> Option<Rect> {
match &self.kind {
NodeKind::Group { children, clip: true } | NodeKind::Layer { children, clip: true, .. } => {
@ -469,6 +494,10 @@ impl Node {
let o = crate::live::max_outset(content);
self.geometric_bounds().map(|b| b.inflate(o, o))
}
NodeKind::Path { .. } | NodeKind::Compound { .. } => {
let b = self.geometric_bounds()?;
Some(self.appearance.stroked_bounds(b, || self.stroke_path().unwrap_or_default()))
}
_ => {
let o = self.appearance.outset();
self.geometric_bounds().map(|b| b.inflate(o, o))

431
crates/doc/src/reach.rs Normal file
View File

@ -0,0 +1,431 @@
//! Where a path's strokes paint, from the path's geometry without building their outlines: the
//! visual bounds and the clicks of a stroke take in its alignment, its width profile, miter spikes
//! up to the miter limit, projecting caps and arrowheads.
use vectorcraft_geom::bez::{CORNER_COS, kink, segments, subpaths, tangent, unit};
use vectorcraft_geom::hit::fill_contains;
use vectorcraft_geom::kurbo::{ParamCurveArclen, ParamCurveNearest};
use vectorcraft_geom::{BezPath, FillRule, ParamCurve, PathSeg, Point, Rect, Shape, Vec2};
use crate::appearance::HEAD_REACH;
use crate::{Appearance, ArrowAlign, Dash, LineCap, LineJoin, StrokeAlign, StrokeLayer, WidthProfile};
/// Arc-length accuracy for placing a point along a profile (document points).
const ARCLEN_ACCURACY: f64 = 1e-3;
/// The segments of a subpath that have length (zero-length ones, all of whose points coincide,
/// have no direction).
fn drawn(els: &[vectorcraft_geom::PathEl]) -> Vec<PathSeg> {
let moves = |s: &PathSeg| match s {
PathSeg::Line(l) => l.p1 != l.p0,
PathSeg::Quad(q) => q.p1 != q.p0 || q.p2 != q.p0,
PathSeg::Cubic(c) => c.p1 != c.p0 || c.p2 != c.p0 || c.p3 != c.p0,
};
segments(els).into_iter().filter(moves).collect()
}
/// The open subpath ends of `segs`: (point, unit direction out of the path) at its start and end.
fn ends(segs: &[PathSeg]) -> Option<[(Point, Vec2); 2]> {
let (first, last) = (segs.first()?, segs.last()?);
Some([(first.start(), -tangent(first, 0.0)), (last.end(), tangent(last, 1.0))])
}
/// The joints between consecutive segments (and, `closed`, where the subpath closes): the point
/// and the unit tangents in and out.
fn joints(segs: &[PathSeg], closed: bool) -> impl Iterator<Item = (Point, Vec2, Vec2)> + '_ {
let n = segs.len();
let pairs = if closed && n > 1 { n } else { n.saturating_sub(1) };
(0..pairs).map(move |i| {
let (a, b) = (&segs[i], &segs[(i + 1) % n]);
(a.end(), tangent(a, 1.0), tangent(b, 0.0))
})
}
/// The miter spike at a corner with tangents `t_in`, `t_out` of a stroke reaching `side` from its
/// path: its tip and the corner's outer offset points, or `None` when the path doesn't turn there
/// or the miter limit bevels it.
fn miter(at: Point, t_in: Vec2, t_out: Vec2, side: f64, limit: f64) -> Option<[Point; 3]> {
let cos = t_in.dot(t_out);
// cos(half the turn) is sin(half the angle between the segments): the miter is side / it.
let half = ((1.0 + cos) / 2.0).max(0.0).sqrt();
if cos >= CORNER_COS || half * limit < 1.0 {
return None;
}
let out = unit(t_in - t_out);
// The offset of each segment on the outer side of the turn.
let outer = |t: Vec2| {
let n = Vec2::new(-t.y, t.x);
if n.dot(out) >= 0.0 { n } else { -n }
};
Some([at + outer(t_in) * side, at + out * (side / half), at + outer(t_out) * side])
}
/// The heads' ends of `bp`: the start of its first subpath and the end of its last, when open (as
/// the stroke geometry places them), each with `true` for the end head.
fn head_ends(st: &StrokeLayer, bp: &BezPath) -> Vec<(Point, Vec2, bool)> {
if st.start_arrow.is_none() && st.end_arrow.is_none() {
return vec![];
}
let subs = subpaths(bp);
let open_ends = |sub: Option<&(std::ops::Range<usize>, bool)>| sub.filter(|s| !s.1).and_then(|s| ends(&drawn(&bp.elements()[s.0.clone()])));
let mut out = vec![];
if st.start_arrow.is_some()
&& let Some([(at, dir), _]) = open_ends(subs.first())
{
out.push((at, dir, false));
}
if st.end_arrow.is_some()
&& let Some([_, (at, dir)]) = open_ends(subs.last())
{
out.push((at, dir, true));
}
out
}
/// (left, right) width factors where `p`'s nearest point on the subpath `segs` sits (segment `i`
/// at `t`): the profile at that fraction of the subpath's length, or wider where an end of the
/// segment is a corner. The stroke draws the sides there straight to the corner's join and inner
/// meeting point, which can lie outside the exact width all along the segment.
fn profile_at(profile: &WidthProfile, segs: &[PathSeg], closed: bool, i: usize, t: f64) -> (f64, f64) {
let n = segs.len();
let lens: Vec<f64> = segs.iter().map(|s| s.arclen(ARCLEN_ACCURACY)).collect();
let total: f64 = lens.iter().sum::<f64>().max(1e-12);
let before = lens[..i].iter().sum::<f64>();
let mut w = profile.at((before + segs[i].subsegment(0.0..t).arclen(ARCLEN_ACCURACY)) / total);
let corner = |a: usize, b: usize| (closed || a < b) && kink(&segs[a], &segs[b]);
let starts = n > 1 && corner((i + n - 1) % n, i);
let ends = n > 1 && corner(i, (i + 1) % n);
// A closed subpath's last segment ends where it starts.
let end = if i + 1 == n { 0.0 } else { before + lens[i] };
for (is, at) in [(starts, before), (ends, end)] {
if is {
let (l, r) = profile.at(at / total);
w = (w.0.max(l), w.1.max(r));
}
}
w
}
/// Is `p` inside the convex polygon `pts` (either winding), or within `tol` of it?
fn in_convex(pts: &[Point], p: Point, tol: f64) -> bool {
let n = pts.len();
// Signed distances from each edge's line.
let (lo, hi) = (0..n).fold((f64::INFINITY, f64::NEG_INFINITY), |(lo, hi), i| {
let e = pts[(i + 1) % n] - pts[i];
let d = e.cross(p - pts[i]) / e.hypot().max(1e-12);
(lo.min(d), hi.max(d))
});
lo >= -tol || hi <= tol
}
impl StrokeLayer {
/// Does bounding this stroke need its path's shape: miter spikes, projecting caps, arrowheads,
/// or an alignment that depends on whether the path is closed? Else it reaches evenly round it.
fn needs_shape(&self) -> bool {
self.join == LineJoin::Miter
|| self.cap == LineCap::Square
|| self.start_arrow.is_some()
|| self.end_arrow.is_some()
|| self.align != StrokeAlign::Center
}
/// The box this stroke paints along `bp`, whose geometric bounds are `bounds`: the bounds grown
/// by the body's reach, with the tips of the miter spikes the limit keeps, the corners of
/// projecting caps and the reach of the arrowheads round their end points.
pub fn bounds_on(&self, bp: &BezPath, bounds: Rect) -> Rect {
let els = bp.elements();
let mut out = bounds;
let mut side_max: f64 = 0.0;
// Dashes put a cap on every dash: projecting ones reach a corner's diagonal anywhere.
let capped_dashes = self.cap == LineCap::Square && self.dash.as_ref().is_some_and(Dash::is_dashed);
for (r, closed) in subpaths(bp) {
let side = self.side_reach(closed);
side_max = side_max.max(if capped_dashes { side * std::f64::consts::SQRT_2 } else { side });
if side <= 0.0 {
continue;
}
let segs = drawn(&els[r]);
if self.join == LineJoin::Miter {
for (at, t_in, t_out) in joints(&segs, closed) {
if let Some([_, tip, _]) = miter(at, t_in, t_out, side, self.miter_limit) {
out = out.union_pt(tip);
}
}
}
if self.cap == LineCap::Square && !closed {
for (at, dir) in ends(&segs).into_iter().flatten() {
let n = Vec2::new(-dir.y, dir.x) * side;
out = out.union_pt(at + dir * side + n).union_pt(at + dir * side - n);
}
}
}
for (at, _, end) in head_ends(self, bp) {
let r = self.head_reach(end);
out = out.union(Rect::from_center_size(at, (2.0 * r, 2.0 * r)));
}
out.union(bounds.inflate(side_max, side_max))
}
/// Does this stroke along `bp` (filled with `rule`) paint within `tol` of `p`? Inside and
/// outside strokes of closed paths count on their own side only (and within `tol` of the
/// path); a width profile counts at its width where `p` is nearest.
pub fn hits(&self, bp: &BezPath, rule: FillRule, p: Point, tol: f64) -> bool {
let reach = self.reach() + tol;
if self.width.is_nan() || self.width <= 0.0 || !bp.bounding_box().inflate(reach, reach).contains(p) {
return false;
}
if head_ends(self, bp).into_iter().any(|(at, out, end)| self.head_contains(at, out, end, p, tol)) {
return true;
}
let els = bp.elements();
// Aligned strokes paint on one side of the path only.
let on_side = |region: Option<bool>, d: f64| region.is_none_or(|inside| d <= tol || fill_contains(bp, rule, p) == inside);
// Every dash gets the caps, as wide as the stroke where the dash ends: round ones reach as
// far as the widest point, projecting ones a corner's diagonal.
let dash_caps = match self.cap {
LineCap::Butt => None,
_ if !self.dash.as_ref().is_some_and(Dash::is_dashed) => None,
LineCap::Round => Some(1.0),
LineCap::Square => Some(std::f64::consts::SQRT_2),
};
for (r, closed) in subpaths(bp) {
let segs = drawn(&els[r]);
let (half, region) = self.body(closed);
let full = half * self.profile_max() * dash_caps.unwrap_or(1.0);
// The nearest point on this subpath.
let near = segs.iter().enumerate().map(|(i, s)| (i, s.nearest(p, 1e-9))).min_by(|a, b| a.1.distance_sq.total_cmp(&b.1.distance_sq));
if let Some((i, nr)) = near
&& nr.distance_sq.sqrt() <= full + tol
{
let d = nr.distance_sq.sqrt();
let factor = if dash_caps.is_some() {
full / half.max(1e-12)
} else {
self.profile.as_ref().map_or(1.0, |pr| {
let (l, rt) = profile_at(pr, &segs, closed, i, nr.t);
// Nearest to a segment's end, `p` is beside a join or past a cap, which
// spans the widths of both sides.
let at_end = nr.t <= 1e-9 || nr.t >= 1.0 - 1e-9;
// Left of the direction of travel (y down) reads the left width.
let tn = tangent(&segs[i], nr.t);
let left = (p - segs[i].eval(nr.t)).dot(Vec2::new(tn.y, -tn.x)) >= 0.0;
if at_end {
l.max(rt)
} else if left {
l
} else {
rt
}
})
};
if d <= half * factor.max(0.0) + tol && on_side(region, d) {
return true;
}
}
if full <= 0.0 {
continue;
}
if self.join == LineJoin::Miter
&& joints(&segs, closed).any(|(at, t_in, t_out)| {
miter(at, t_in, t_out, full, self.miter_limit).is_some_and(|[a, tip, b]| in_convex(&[at, a, tip, b], p, tol))
})
&& on_side(region, f64::INFINITY)
{
return true;
}
if self.cap == LineCap::Square
&& !closed
&& ends(&segs).into_iter().flatten().any(|(at, dir)| {
let n = Vec2::new(-dir.y, dir.x) * full;
in_convex(&[at + n, at + n + dir * full, at - n + dir * full, at - n], p, tol)
})
{
return true;
}
}
false
}
/// Is `p` within `tol` of where the start (`end == false`) or end arrowhead at `at` (the path
/// leaving along `out`) can paint? Heads are at most about 4.6 weights long and 4 wide. With
/// [`ArrowAlign::Extend`] a head points along `out` with its tip up to its length (or the cap)
/// past the end; with [`ArrowAlign::Tip`] its tip is on the end and it lies back along the
/// chord the path bends through under it, so anywhere within its length of the end.
fn head_contains(&self, at: Point, out: Vec2, end: bool, p: Point, tol: f64) -> bool {
let hw = self.arrow_weight(end);
let v = p - at;
match self.arrow_align {
ArrowAlign::Tip => v.hypot() <= HEAD_REACH * hw + tol,
ArrowAlign::Extend => {
let along = v.dot(out);
v.cross(out).abs() <= 2.0 * hw + tol && along <= 4.0 * hw + self.width / 2.0 + tol && along >= -HEAD_REACH * hw - tol
}
}
}
}
impl Appearance {
/// The visual bounds of a path with geometric bounds `bounds` painted with this appearance:
/// the bounds with what each visible stroke paints round it ([`StrokeLayer::bounds_on`]).
/// `bp` (the path) is only built when a stroke needs its shape.
pub fn stroked_bounds(&self, bounds: Rect, bp: impl FnOnce() -> BezPath) -> Rect {
let mut path = Some(bp);
let mut shape = None;
self.painted_strokes().fold(bounds, |acc, s| {
if !s.needs_shape() {
let o = s.side_reach(false);
return acc.union(bounds.inflate(o, o));
}
let bp: &BezPath = shape.get_or_insert_with(|| path.take().map(|f| f()).unwrap_or_default());
acc.union(s.bounds_on(bp, bounds))
})
}
/// Does a visible stroke of this appearance along `bp` (filled with `rule`) paint within
/// `tol` of `p` ([`StrokeLayer::hits`])?
pub fn stroke_hit(&self, bp: &BezPath, rule: FillRule, p: Point, tol: f64) -> bool {
self.painted_strokes().any(|s| s.hits(bp, rule, p, tol))
}
}
#[cfg(test)]
mod tests {
use vectorcraft_color::{Color, Paint};
use vectorcraft_geom::{PathData, shapes};
use super::*;
use crate::{Arrowhead, Document, Node, StrokeAlign, hit};
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 line() -> BezPath {
shapes::line(Point::new(0.0, 0.0), Point::new(100.0, 0.0)).to_bezpath()
}
/// A thin triangle whose 22.6° apex at (100, 20) miters about 5 times half the weight out.
fn triangle() -> BezPath {
let mut bp = BezPath::new();
bp.move_to((0.0, 0.0));
bp.line_to((100.0, 20.0));
bp.line_to((0.0, 40.0));
bp.close_path();
bp
}
fn square() -> BezPath {
shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)).to_bezpath()
}
#[test]
fn a_bigger_arrowhead_grows_the_bounds() {
let l = line();
let b = l.bounding_box();
let at = |scale: f64| {
stroke(2.0, |s| {
s.join = LineJoin::Round;
s.end_arrow = Some(Arrowhead::Triangle);
s.arrow_scale = (100.0, scale);
})
};
let (small, big) = (at(100.0).bounds_on(&l, b), at(300.0).bounds_on(&l, b));
assert!(big.x1 > small.x1 + 15.0 && big.y1 > small.y1 + 7.0, "{small:?} → {big:?}");
// A 300% head on a 2 pt stroke is 24 pt long and wide: its tip, past the end, is inside.
assert!(big.x1 >= 100.0 + 24.0 && big.y1 >= 12.0 && big.x0 == -1.0, "{big:?}");
// The start has no head.
assert_eq!(at(300.0).head_reach(false), 0.0);
}
#[test]
fn rectangles_keep_exact_bounds_and_sharp_corners_spike_up_to_the_limit() {
let sq = square();
let b = sq.bounding_box();
let st = stroke(4.0, |_| {});
assert_eq!(st.bounds_on(&sq, b), b.inflate(2.0, 2.0), "right-angle miters stay in the box");
// A sharp spike: a thin triangle's apex miters far out unless the limit bevels it.
let tri = triangle();
let tb = tri.bounding_box();
let spiked = st.bounds_on(&tri, tb);
assert!(spiked.x1 > 100.0 + 10.0, "{spiked:?}");
let mut low = st.clone();
low.miter_limit = 2.0;
assert!(low.bounds_on(&tri, tb).x1 <= 100.0 + 2.0 + 1e-9, "beveled");
// Projecting caps reach past the ends; inside strokes stay in.
let l = line();
let cap = stroke(4.0, |s| s.cap = LineCap::Square).bounds_on(&l, l.bounding_box());
assert_eq!((cap.x0, cap.x1, cap.y1), (-2.0, 102.0, 2.0));
assert_eq!(stroke(4.0, |s| s.align = StrokeAlign::Inside).bounds_on(&sq, b), b);
assert_eq!(stroke(4.0, |s| s.align = StrokeAlign::Outside).bounds_on(&sq, b), b.inflate(4.0, 4.0));
}
#[test]
fn clicks_hit_outside_strokes_on_their_side_only() {
let sq = square();
let w = 10.0;
let st = stroke(w, |s| s.align = StrokeAlign::Outside);
assert!(st.hits(&sq, FillRule::NonZero, Point::new(-0.8 * w, 50.0), 0.5), "0.8·w outside");
assert!(!st.hits(&sq, FillRule::NonZero, Point::new(0.8 * w, 50.0), 0.5), "nothing painted inside");
assert!(st.hits(&sq, FillRule::NonZero, Point::new(0.3, 50.0), 0.5), "on the path");
let inside = stroke(w, |s| s.align = StrokeAlign::Inside);
assert!(inside.hits(&sq, FillRule::NonZero, Point::new(0.8 * w, 50.0), 0.5));
assert!(!inside.hits(&sq, FillRule::NonZero, Point::new(-0.8 * w, 50.0), 0.5));
// A centred stroke reaches half its weight each side.
let centre = stroke(w, |_| {});
assert!(centre.hits(&sq, FillRule::NonZero, Point::new(-4.0, 50.0), 0.5));
assert!(!centre.hits(&sq, FillRule::NonZero, Point::new(-8.0, 50.0), 0.5));
}
#[test]
fn clicks_follow_the_width_profile() {
let l = line();
let lens = stroke(10.0, |s| s.profile = Some(WidthProfile::lens()));
assert!(lens.hits(&l, FillRule::NonZero, Point::new(50.0, 4.8), 0.1), "the lens's widest point");
assert!(!lens.hits(&l, FillRule::NonZero, Point::new(5.0, 4.8), 0.1), "its thin end");
// A profile wider than the weight on one side.
let wide = stroke(10.0, |s| s.profile = Some(WidthProfile { points: vec![(0.0, 3.0, 1.0), (1.0, 3.0, 1.0)] }));
// Left of travel (+x, y down) is −y.
assert!(wide.hits(&l, FillRule::NonZero, Point::new(50.0, -14.0), 0.1));
assert!(!wide.hits(&l, FillRule::NonZero, Point::new(50.0, 14.0), 0.1));
assert!(wide.bounds_on(&l, l.bounding_box()).y0 <= -15.0);
}
#[test]
fn clicks_hit_heads_caps_and_spikes() {
let l = line();
let head = stroke(2.0, |s| {
s.end_arrow = Some(Arrowhead::Triangle);
s.arrow_scale = (100.0, 300.0);
});
// The 24 pt head sits past the end in extend mode, 12 pt either side of the line.
assert!(head.hits(&l, FillRule::NonZero, Point::new(110.0, 6.0), 0.1));
assert!(!head.hits(&l, FillRule::NonZero, Point::new(50.0, 6.0), 0.1));
let mut tip = head.clone();
tip.arrow_align = ArrowAlign::Tip;
assert!(tip.hits(&l, FillRule::NonZero, Point::new(90.0, 6.0), 0.1) && !tip.hits(&l, FillRule::NonZero, Point::new(130.0, 0.0), 0.1));
let cap = stroke(10.0, |s| s.cap = LineCap::Square);
assert!(cap.hits(&l, FillRule::NonZero, Point::new(104.0, 4.5), 0.1), "the cap's corner");
assert!(!stroke(10.0, |_| {}).hits(&l, FillRule::NonZero, Point::new(104.0, 4.5), 0.1), "a butt end");
let tri = triangle();
let spike = stroke(4.0, |_| {});
assert!(spike.hits(&tri, FillRule::NonZero, Point::new(106.0, 20.0), 0.1), "in the miter spike");
assert!(!spike.hits(&tri, FillRule::NonZero, Point::new(106.0, 23.0), 0.1));
}
#[test]
fn document_hit_testing_and_visual_bounds_use_the_whole_stroke() {
let mut d = Document::new(300.0, 300.0);
let (l, id) = (d.layers[0].id, d.alloc_id());
let mut ap = Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 10.0);
ap.stroke_mut().unwrap().align = StrokeAlign::Outside;
let sq = PathData::from_bezpath(&shapes::rectangle(Rect::new(100.0, 100.0, 200.0, 200.0)).to_bezpath());
d.insert(Some(l), 0, Node::path(id, sq, ap)).unwrap();
let opt = hit::HitOptions { tol: 1.0, ..Default::default() };
let h = hit::hit_test(&d, Point::new(92.0, 150.0), opt).expect("0.8·w outside the path");
assert_eq!((h.leaf, h.kind), (id, hit::HitKind::Stroke));
assert!(hit::hit_test(&d, Point::new(108.0, 150.0), opt).is_none(), "an unfilled interior");
assert_eq!(d.node(id).unwrap().visual_bounds(), Some(Rect::new(90.0, 90.0, 210.0, 210.0)));
}
}

View File

@ -17,8 +17,9 @@ mod width;
use std::borrow::Cow;
use kurbo::{BezPath, ParamCurve, ParamCurveDeriv, PathEl, PathSeg, Vec2};
use kurbo::{BezPath, PathEl, PathSeg};
use vectorcraft_doc::{LineCap, LineJoin, StrokeAlign, StrokeLayer};
pub(crate) use vectorcraft_geom::bez::{kink, segments, subpaths, tangent, unit};
pub use arrow::Arrow;
pub use dash::{Dashed, Dot, dash, dot_outline};
@ -27,9 +28,6 @@ pub use width::width_outline;
/// Arc-length accuracy for dashing and trimming (document points).
const ARCLEN_ACCURACY: f64 = 1e-6;
/// Tangents meeting at more than about 1° (this cosine) make a corner: fitted dashes centre on it
/// and variable-width strokes give it the stroke's join.
const CORNER_COS: f64 = 0.9998;
/// The geometry one stroke paints, in the coordinate space of its path.
#[derive(Clone, Debug)]
@ -119,40 +117,6 @@ pub fn line_outline(line: &BezPath, st: &StrokeLayer, width: f64, tol: f64) -> B
}
}
/// Do segments `a` and `b` (`b` following `a`) meet at a corner?
pub(crate) fn kink(a: &PathSeg, b: &PathSeg) -> bool {
tangent(a, 1.0).dot(tangent(b, 0.0)) < CORNER_COS
}
/// 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 {
@ -162,38 +126,11 @@ pub(crate) fn push_seg(out: &mut BezPath, seg: &PathSeg) {
}
}
/// 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;
#[cfg(test)]
mod tests_arrows;
#[cfg(test)]
mod tests_fit;
#[cfg(test)]
mod tests_reach;

View File

@ -1,14 +1,16 @@
//! 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
//! Arrowheads. A head of weight `hw` (stroke weight × scale) fits a box `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.
//! past the end point. Heads added after the first ten are drawn in local units ([`local`]).
use std::borrow::Cow;
use kurbo::{BezPath, ParamCurve, ParamCurveArclen, PathSeg, Point, Shape, Vec2};
use std::f64::consts::{FRAC_PI_2, PI};
use kurbo::{Affine, 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};
@ -185,6 +187,10 @@ fn inset(kind: Arrowhead, hw: f64, cap: f64) -> f64 {
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),
_ => match local_stop(kind) {
Stop::At(u) => (u * hw).max(cap),
Stop::Wall(back) => hollow(back * hw),
},
}
}
@ -224,6 +230,150 @@ fn shape(kind: Arrowhead, tip: Point, dir: Vec2, hw: f64) -> BezPath {
let t = bar(hw);
polygon(&mut p, &[tip + n * half, tip + n * half - dir * t, tip - n * half - dir * t, tip - n * half]);
}
_ => {
// Local x runs back from the tip, y across to the right of `dir`; a unit is `hw`.
p = local(kind);
p.apply_affine(Affine::new([-dir.x * hw, -dir.y * hw, n.x * hw, n.y * hw, tip.x, tip.y]));
}
}
p
}
/// Where the stroke ends under a head drawn in local units.
enum Stop {
/// This many units back from the tip (or as far as the cap needs).
At(f64),
/// In the back wall of a ring whose back is this many units from the tip.
Wall(f64),
}
/// Flat-to-flat length of the hexagons (their corners touch the sides of the 4-unit box).
const HEX: f64 = 2.0 * 1.732_050_807_568_877_2;
/// Flattening tolerance of the curved local heads, in units.
const LOCAL_TOL: f64 = 1e-3;
fn local_stop(kind: Arrowhead) -> Stop {
match kind {
Arrowhead::Target | Arrowhead::TagOpen | Arrowhead::DiamondOpen => Stop::Wall(4.0),
Arrowhead::HexagonOpen => Stop::Wall(HEX),
Arrowhead::DoubleBar | Arrowhead::DotOnBar => Stop::At(0.375),
Arrowhead::Bracket => Stop::At(0.3),
Arrowhead::Fork => Stop::At(2.7),
Arrowhead::HalfArrowLeft | Arrowhead::HalfArrowRight | Arrowhead::Feather => Stop::At(1.3),
Arrowhead::Swallowtail => Stop::At(1.4),
Arrowhead::Chevron => Stop::At(1.0),
Arrowhead::Slash => Stop::At(1.2),
Arrowhead::DoubleSlash => Stop::At(1.05),
Arrowhead::DoubleArrow => Stop::At(1.7),
Arrowhead::HalfCircle => Stop::At(1.0),
Arrowhead::Concave => Stop::At(2.6),
Arrowhead::Hexagon => Stop::At(HEX / 2.0),
_ => Stop::At(2.0),
}
}
/// The outline of a head added after the first ten, in local units: x from the tip (0) back
/// along the line (to at most 4), y across it (−2 to 2, positive to the right of the direction
/// the head points). Every shape is generated here.
fn local(kind: Arrowhead) -> BezPath {
let mut p = BezPath::new();
let poly = |p: &mut BezPath, pts: &[(f64, f64)]| polygon(p, &pts.iter().map(|&q| Point::from(q)).collect::<Vec<_>>());
let ring_poly = |p: &mut BezPath, pts: &[(f64, f64)]| ring_polygon(p, &pts.iter().map(|&q| Point::from(q)).collect::<Vec<_>>(), ring(1.0));
let circle = |p: &mut BezPath, c: (f64, f64), r: f64| p.extend(kurbo::Circle::new(c, r).path_elements(LOCAL_TOL));
// A regular polygon of `n` corners around `c`, the first at angle `a0`, alternating radii.
let star = |c: (f64, f64), radii: &[f64], n: usize, a0: f64| -> Vec<(f64, f64)> {
(0..n).map(|i| (a0 + i as f64 * 2.0 * PI / n as f64, radii[i % radii.len()])).map(|(a, r)| (c.0 + r * a.cos(), c.1 + r * a.sin())).collect()
};
// A plus of arms `h` long and `k` thick round (2, 0), turned by `turn`.
let plus = |h: f64, k: f64, turn: f64| -> Vec<(f64, f64)> {
let arm = [(k, h), (k, k), (h, k)];
let (sin, cos) = turn.sin_cos();
(0..4)
.flat_map(|q| arm.iter().map(move |&(x, y)| [(x, y), (y, -x), (-x, -y), (-y, x)][q]))
.map(|(x, y)| (2.0 + x * cos - y * sin, x * sin + y * cos))
.collect()
};
let hexagon = star((HEX / 2.0, 0.0), &[2.0], 6, PI / 6.0);
match kind {
Arrowhead::Barbed => poly(&mut p, &[(0.0, 0.0), (3.6, 2.0), (2.6, 0.55), (4.0, 0.55), (4.0, -0.55), (2.6, -0.55), (3.6, -2.0)]),
Arrowhead::HalfArrowLeft => poly(&mut p, &[(0.0, 0.0), (4.0, -2.0), (3.0, -0.5), (3.0, 0.5), (1.0, 0.5)]),
Arrowhead::HalfArrowRight => poly(&mut p, &[(0.0, 0.0), (4.0, 2.0), (3.0, 0.5), (3.0, -0.5), (1.0, -0.5)]),
Arrowhead::Concave => {
p.move_to((0.0, 0.0));
p.quad_to((2.4, 0.4), (4.0, 2.0));
p.line_to((4.0, -2.0));
p.quad_to((2.4, -0.4), (0.0, 0.0));
p.close_path();
}
Arrowhead::DoubleBar => {
poly(&mut p, &[(0.0, -2.0), (0.75, -2.0), (0.75, 2.0), (0.0, 2.0)]);
poly(&mut p, &[(1.5, -2.0), (2.25, -2.0), (2.25, 2.0), (1.5, 2.0)]);
}
Arrowhead::Feather => poly(&mut p, &[(0.0, 0.5), (1.4, 2.0), (4.0, 2.0), (2.6, 0.5), (2.6, -0.5), (4.0, -2.0), (1.4, -2.0), (0.0, -0.5)]),
Arrowhead::DotOnBar => {
poly(&mut p, &[(0.0, -2.0), (0.75, -2.0), (0.75, 2.0), (0.0, 2.0)]);
circle(&mut p, (2.5, 0.0), 1.25);
}
Arrowhead::Chevron => poly(&mut p, &[(0.0, 0.0), (2.0, 2.0), (4.0, 2.0), (2.0, 0.0), (4.0, -2.0), (2.0, -2.0)]),
Arrowhead::DoubleArrow => poly(&mut p, &[(0.0, 0.0), (2.2, 1.8), (1.9, 0.5), (4.0, 2.0), (3.4, 0.0), (4.0, -2.0), (1.9, -0.5), (2.2, -1.8)]),
Arrowhead::Target => {
circle(&mut p, (2.0, 0.0), 2.0);
p.extend(kurbo::Circle::new((2.0, 0.0), 2.0 - ring(1.0)).to_path(LOCAL_TOL).reverse_subpaths());
circle(&mut p, (2.0, 0.0), 0.75);
}
Arrowhead::Star => poly(&mut p, &star((2.0, 0.0), &[2.0, 0.85], 10, PI)),
Arrowhead::Cross => poly(&mut p, &plus(2.2, 0.4, PI / 4.0)),
Arrowhead::Plus => poly(&mut p, &plus(1.9, 0.4, 0.0)),
Arrowhead::Hexagon => poly(&mut p, &hexagon),
Arrowhead::HexagonOpen => ring_poly(&mut p, &hexagon),
Arrowhead::Tag => poly(&mut p, &[(0.0, 0.0), (1.6, 2.0), (4.0, 2.0), (4.0, -2.0), (1.6, -2.0)]),
Arrowhead::TagOpen => ring_poly(&mut p, &[(0.0, 0.0), (1.6, 2.0), (4.0, 2.0), (4.0, -2.0), (1.6, -2.0)]),
Arrowhead::HalfCircle => {
p.move_to((2.0, 2.0));
p.extend(kurbo::Arc::new((2.0, 0.0), (2.0, 2.0), FRAC_PI_2, PI, 0.0).append_iter(LOCAL_TOL));
p.close_path();
}
Arrowhead::Drop => {
// Straight sides from the tip, tangent to a round back.
let (c, r) = (Point::new(2.6, 0.0), 1.4);
let a = PI - (r / c.x).acos();
p.move_to((0.0, 0.0));
p.line_to(c + Vec2::from_angle(a) * r);
p.extend(kurbo::Arc::new(c, (r, r), a, -2.0 * a, 0.0).append_iter(LOCAL_TOL));
p.close_path();
}
Arrowhead::Slash => poly(&mut p, &[(0.0, 2.0), (0.8, 2.0), (2.4, -2.0), (1.6, -2.0)]),
Arrowhead::DoubleSlash => {
poly(&mut p, &[(0.0, 2.0), (0.7, 2.0), (2.1, -2.0), (1.4, -2.0)]);
poly(&mut p, &[(1.6, 2.0), (2.3, 2.0), (3.7, -2.0), (3.0, -2.0)]);
}
Arrowhead::DiamondOpen => ring_poly(&mut p, &[(0.0, 0.0), (2.0, 2.0), (4.0, 0.0), (2.0, -2.0)]),
Arrowhead::TriangleReverse => poly(&mut p, &[(0.0, 2.0), (4.0, 0.0), (0.0, -2.0)]),
Arrowhead::Swallowtail => poly(&mut p, &[(0.0, 2.0), (4.0, 2.0), (2.8, 0.0), (4.0, -2.0), (0.0, -2.0)]),
Arrowhead::Bracket => poly(&mut p, &[(0.0, 2.0), (2.0, 2.0), (2.0, 1.4), (0.6, 1.4), (0.6, -1.4), (2.0, -1.4), (2.0, -2.0), (0.0, -2.0)]),
Arrowhead::Fork => poly(&mut p, &[(0.0, 2.0), (3.0, 2.0), (3.0, -2.0), (0.0, -2.0), (0.0, -1.4), (2.4, -1.4), (2.4, 1.4), (0.0, 1.4)]),
Arrowhead::Leaf => {
p.move_to((0.0, 0.0));
p.quad_to((2.0, 3.2), (4.0, 0.0));
p.quad_to((2.0, -3.2), (0.0, 0.0));
p.close_path();
}
Arrowhead::Kite => poly(&mut p, &[(0.0, 0.0), (1.3, 1.7), (4.0, 0.0), (1.3, -1.7)]),
Arrowhead::TriangleBar => {
poly(&mut p, &[(0.0, 2.0), (0.6, 2.0), (0.6, 0.25), (4.0, 1.8), (4.0, -1.8), (0.6, -0.25), (0.6, -2.0), (0.0, -2.0)])
}
Arrowhead::Oval => p.extend(kurbo::Ellipse::new((2.0, 0.0), (2.0, 1.2), 0.0).path_elements(LOCAL_TOL)),
// The first ten are drawn by `shape`.
Arrowhead::Triangle
| Arrowhead::TriangleOpen
| Arrowhead::Circle
| Arrowhead::CircleOpen
| Arrowhead::Square
| Arrowhead::SquareOpen
| Arrowhead::Bar
| Arrowhead::Diamond
| Arrowhead::Arrow
| Arrowhead::ArrowOpen => {}
}
p
}

View File

@ -0,0 +1,83 @@
//! Every arrowhead's outline: drawn, inside its box (4 × 4 weights; the open arrow's square-cut
//! arms reach a little further back), without self-intersections, and covering the end of the
//! stroke under it.
use kurbo::{BezPath, PathEl, Point, Shape};
use vectorcraft_color::{Color, Paint};
use vectorcraft_doc::{ArrowAlign, Arrowhead, StrokeLayer};
use super::stroke_pieces;
/// A 2 pt line along +x ending at (100, 0) with `kind` at its end, tip on the end point: heads
/// of weight 2, so 8 long and 8 wide.
fn head(kind: Arrowhead) -> super::Arrow {
let mut st = StrokeLayer::new(Paint::solid(Color::BLACK), 2.0);
st.end_arrow = Some(kind);
st.arrow_align = ArrowAlign::Tip;
let mut l = BezPath::new();
l.move_to((0.0, 0.0));
l.line_to((100.0, 0.0));
stroke_pieces(&l, &st).heads.remove(0)
}
/// The outline's closed contours as polygons (curves flattened).
fn contours(bp: &BezPath) -> Vec<Vec<Point>> {
let mut out: Vec<Vec<Point>> = vec![];
kurbo::flatten(bp.iter(), 1e-3, |el| match el {
PathEl::MoveTo(p) => out.push(vec![p]),
PathEl::LineTo(p) => out.last_mut().unwrap().push(p),
PathEl::ClosePath => {
let c = out.last_mut().unwrap();
if c.len() > 1 && c[0].distance(*c.last().unwrap()) < 1e-9 {
c.pop();
}
}
_ => unreachable!("flattened"),
});
out
}
/// Do segments `a` and `b` cross at a point inside both (touching ends don't count)?
fn cross(a: (Point, Point), b: (Point, Point)) -> bool {
let orient = |p: Point, q: Point, r: Point| (q - p).cross(r - p);
let (d1, d2) = (orient(b.0, b.1, a.0), orient(b.0, b.1, a.1));
let (d3, d4) = (orient(a.0, a.1, b.0), orient(a.0, a.1, b.1));
d1 * d2 < -1e-12 && d3 * d4 < -1e-12
}
#[test]
fn every_outline_is_drawn_inside_its_box_without_self_intersections() {
let (o, d) = (Point::ORIGIN, Point::new(2.0, 2.0));
assert!(cross((o, d), (Point::new(0.0, 2.0), Point::new(2.0, 0.0))) && !cross((o, d), (d, Point::new(4.0, 0.0))), "the check itself");
for kind in Arrowhead::ALL {
let h = head(kind);
assert!(h.outline.area().abs() > 4.0, "{kind:?} is drawn");
let b = h.outline.bounding_box();
assert!(b.x0 >= 91.0 - 1e-6 && b.x1 <= 100.0 + 1e-6 && b.y0 >= -4.0 - 1e-6 && b.y1 <= 4.0 + 1e-6, "{kind:?} leaves its box: {b:?}");
let edges: Vec<(Point, Point)> = contours(&h.outline).iter().flat_map(|c| (0..c.len()).map(|i| (c[i], c[(i + 1) % c.len()]))).collect();
assert!(edges.len() >= 3, "{kind:?}");
for (i, a) in edges.iter().enumerate() {
for b in &edges[i + 1..] {
assert!(!cross(*a, *b), "{kind:?}: {a:?} crosses {b:?}");
}
}
}
}
#[test]
fn the_stroke_ends_inside_every_head() {
for kind in Arrowhead::ALL {
let h = head(kind);
// Just in front of where the line stops, on its centre line.
let end = h.tip - h.dir * (h.inset - 0.1);
assert_ne!(h.outline.winding(end), 0, "{kind:?}: the line ends outside the head (inset {})", h.inset);
}
}
#[test]
fn half_arrows_barb_on_their_own_side() {
// Pointing along +x on a y-down page, left is −y.
assert!(head(Arrowhead::HalfArrowLeft).outline.bounding_box().y0 < -3.0);
assert!(head(Arrowhead::HalfArrowLeft).outline.bounding_box().y1 < 1.5);
assert!(head(Arrowhead::HalfArrowRight).outline.bounding_box().y1 > 3.0);
}

View File

@ -0,0 +1,92 @@
//! The document's stroke bounds and clicks (`vectorcraft_doc` reach) against the geometry the
//! stroke really paints: every painted point is inside the visual bounds and hits.
use kurbo::{BezPath, Point, Rect, Shape};
use vectorcraft_color::{Color, Paint};
use vectorcraft_doc::{ArrowAlign, Arrowhead, Dash, LineCap, LineJoin, StrokeAlign, StrokeLayer, WidthProfile};
use vectorcraft_geom::hit::fill_contains;
use vectorcraft_geom::{FillRule, PathData};
use super::outline_region;
fn paths() -> Vec<BezPath> {
let mut curve = BezPath::new();
curve.move_to((10.0, 60.0));
curve.curve_to((30.0, 0.0), (70.0, 120.0), (90.0, 40.0));
let mut zigzag = BezPath::new();
zigzag.move_to((10.0, 20.0));
zigzag.line_to((40.0, 70.0));
zigzag.line_to((55.0, 15.0));
zigzag.line_to((90.0, 60.0));
let mut tri = BezPath::new();
tri.move_to((10.0, 10.0));
tri.line_to((90.0, 30.0));
tri.line_to((10.0, 50.0));
tri.close_path();
vec![curve, zigzag, tri, Rect::new(20.0, 20.0, 80.0, 70.0).to_path(0.1)]
}
/// Check `st` along `bp`: the painted region's box is inside the bounds, and points of the
/// region (on a 1.5 pt grid) hit within a quarter point, as the painted outlines are flattened.
/// Variable widths are drawn straight between their offsets at the path's samples and inner
/// corners, which beside sharp corners can paint up to about a point past the exact width there.
fn check(bp: &BezPath, st: &StrokeLayer, what: &str) {
let region = outline_region(&PathData::from_bezpath(bp), FillRule::NonZero, st).to_bezpath();
if region.elements().is_empty() {
return;
}
let painted = region.bounding_box();
let bounds = st.bounds_on(bp, bp.bounding_box());
assert!(bounds.inflate(0.05, 0.05).contains_rect(painted), "{what}: paints {painted:?} outside {bounds:?}");
let tol = if st.profile.is_some() { 1.0 } else { 0.25 };
// Off the outline's own coordinates, so no sample sits level with a vertex.
let (mut x, mut checked) = (painted.x0 + 0.371, 0);
while x <= painted.x1 {
let mut y = painted.y0 + 0.293;
while y <= painted.y1 {
let p = Point::new(x, y);
if fill_contains(&region, FillRule::EvenOdd, p) {
assert!(st.hits(bp, FillRule::NonZero, p, tol), "{what}: painted {p:?} doesn't hit");
checked += 1;
}
y += 1.5;
}
x += 1.5;
}
assert!(checked > 0, "{what}: no samples");
}
#[test]
fn bounds_and_clicks_cover_what_every_stroke_paints() {
let profiles = [None, Some(WidthProfile::lens()), Some(WidthProfile { points: vec![(0.0, 2.0, 0.5), (0.6, 0.8, 1.6), (1.0, 1.2, 0.3)] })];
for (pi, bp) in paths().iter().enumerate() {
for align in [StrokeAlign::Center, StrokeAlign::Inside, StrokeAlign::Outside] {
for cap in [LineCap::Butt, LineCap::Round, LineCap::Square] {
for (join, limit) in [(LineJoin::Miter, 10.0), (LineJoin::Miter, 2.0), (LineJoin::Round, 10.0), (LineJoin::Bevel, 10.0)] {
for (fi, profile) in profiles.iter().enumerate() {
for dash in [None, Some(Dash { pattern: vec![7.0, 4.0], offset: 0.0, align_corners: false })] {
let mut st = StrokeLayer::new(Paint::solid(Color::BLACK), 6.0);
(st.align, st.cap, st.join, st.miter_limit, st.profile, st.dash) =
(align, cap, join, limit, profile.clone(), dash.clone());
check(bp, &st, &format!("path {pi} {align:?} {cap:?} {join:?} {limit} profile {fi} dashed {}", dash.is_some()));
}
}
}
}
}
}
}
#[test]
fn bounds_and_clicks_cover_every_arrowhead() {
let bp = &paths()[0];
for kind in Arrowhead::ALL {
for align in [ArrowAlign::Extend, ArrowAlign::Tip] {
for (scale, cap) in [(50.0, LineCap::Round), (100.0, LineCap::Butt), (300.0, LineCap::Square)] {
let mut st = StrokeLayer::new(Paint::solid(Color::BLACK), 3.0);
(st.start_arrow, st.end_arrow, st.arrow_align, st.arrow_scale, st.cap) = (Some(kind), Some(kind), align, (scale, scale), cap);
check(bp, &st, &format!("{kind:?} {align:?} {scale}%"));
}
}
}
}

View File

@ -1,9 +1,9 @@
//! The Stroke panel: weight, cap, join, alignment, dashes, arrowheads, width profiles and brushes.
//! With type selected it edits the characters' stroke (weight, cap, join, miter limit, dashes).
use serde_json::Value;
use serde_json::{Value, json};
use vectorcraft_color::{Color, Paint};
use vectorcraft_doc::{ArrowAlign, Arrowhead, CharStyle, Dash, LineCap, LineJoin, Node, NodeKind, StrokeLayer, Unit, WidthProfile};
use vectorcraft_doc::{ArrowAlign, Arrowhead, CharStyle, Dash, LineCap, LineJoin, Node, NodeKind, SavedProfile, StrokeLayer, Unit, WidthProfile};
use super::appearance::{ItemTarget, edit_items, item_target};
use super::paint::painted;
@ -18,7 +18,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Stroke Options",
["Window", "Stroke"],
None,
"{weight?: pt, 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; a new pattern keeps the current offset and alignment), dashOffset? (exact dashes only), alignDashes?: bool (true: dashes fitted to corners and path ends, every run between them holding whole periods with a dash centred on each corner and end; false, the default for a new pattern: exact lengths), 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\", item?: stroke item index|null (omitted: the Appearance panel's active item if it is a stroke, else the top stroke, created when missing), ids?} Without a stroke item, type takes weight, cap, join, miterLimit and the dash options as its characters' stroke (every run; text.setRangeStyle `strokeOptions` styles a range), and images and symbol instances (also in groups) are left alone",
"{weight?: pt, 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; a new pattern keeps the current offset and alignment), dashOffset? (exact dashes only), alignDashes?: bool (true: dashes fitted to corners and path ends, every run between them holding whole periods with a dash centred on each corner and end; false, the default for a new pattern: exact lengths), startArrow?, endArrow?: Arrow|ArrowOpen|Barbed|Concave|DoubleArrow|HalfArrowLeft|HalfArrowRight|Chevron|Feather|Swallowtail|Triangle|TriangleOpen|TriangleReverse|TriangleBar|Kite|Leaf|Drop|Circle|CircleOpen|HalfCircle|Oval|Target|Square|SquareOpen|Tag|TagOpen|Diamond|DiamondOpen|Hexagon|HexagonOpen|Star|Cross|Plus|Bar|DoubleBar|DotOnBar|Slash|DoubleSlash|Bracket|Fork|null (HalfArrowLeft/Right: one barb, left or right of the direction the head points), arrowAlign?: \"extend\" (tip past the end point, default)|\"tip\" (tip on the end point; the stroke is shortened), profile?: \"uniform\"|\"lens\"|\"taperStart\"|\"taperEnd\"|\"pinch\"|\"teardrop\"|\"wave\"|a saved profile's name (stroke.widthProfile.list), item?: stroke item index|null (omitted: the Appearance panel's active item if it is a stroke, else the top stroke, created when missing), ids?} Without a stroke item, type takes weight, cap, join, miterLimit and the dash options as its characters' stroke (every run; text.setRangeStyle `strokeOptions` styles a range), and images and symbol instances (also in groups) are left alone",
has_doc,
stroke_set
),
@ -31,6 +31,42 @@ pub fn specs() -> Vec<CommandSpec> {
has_doc,
stroke_advanced
),
cmd!(
"stroke.widthProfile.add",
"Add to Profiles",
[],
None,
"{name?} save the selected stroke's variable width to the Profile list (kept with the preferences) under `name` (default \"Width Profile N\"; unique, not a built-in's) → {name}",
can_add_profile,
profile_add
),
cmd!(
"stroke.widthProfile.delete",
"Delete Profile",
[],
None,
"{name?} remove a saved profile from the Profile list (default: the selected stroke's); built-in profiles can't be deleted, and strokes keep their widths → {deleted}",
has_saved_profiles,
profile_delete
),
cmd!(
"stroke.widthProfile.reset",
"Reset Profiles",
[],
None,
"{} remove every saved profile, leaving the built-ins → {removed: count}",
has_saved_profiles,
profile_reset
),
cmd!(
query "stroke.widthProfile.list",
"Width Profiles",
[],
None,
"{} → {profiles: [{id (stroke.set `profile`), label, builtIn, points: [[t, left, right]…]}], current: the selected stroke's profile id or name, \"custom\" when it isn't listed, null without a stroke}",
always,
profile_list
),
]
}
@ -154,8 +190,7 @@ fn stroke_set(s: &mut Session, p: &Value) -> Result<Value> {
};
let profile = match str_param(p, "profile") {
None => None,
Some("uniform") => Some(None),
Some(id) => Some(Some(WidthProfile::preset(id).ok_or_else(|| bad(C, format!("unknown profile {id}")))?)),
Some(id) => Some(s.resolve_profile(id).ok_or_else(|| bad(C, format!("unknown profile {id} (see stroke.widthProfile.list)")))?),
};
edit_items(s, &ids, item, C, "Stroke", false, |n, index| {
if index.is_none()
@ -314,3 +349,118 @@ fn stroke_advanced(s: &mut Session, p: &Value) -> Result<Value> {
})?;
ok()
}
/// One row of the Profile list: a built-in profile or a saved one.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct ProfileEntry<'a> {
/// What `stroke.set {profile}` takes: a built-in's id, a saved profile's name.
pub id: &'a str,
pub label: &'a str,
pub points: &'a [(f64, f64, f64)],
pub built_in: bool,
}
impl ProfileEntry<'_> {
/// Does this row describe `p` (`None`: the plain stroke, the Uniform row)?
pub fn matches(&self, p: Option<&WidthProfile>) -> bool {
match p {
None => self.built_in && self.id == "uniform",
Some(p) => p.points == self.points,
}
}
}
impl Session {
/// The Profile list: the built-in profiles, then the saved ones.
pub fn profile_entries(&self) -> impl Iterator<Item = ProfileEntry<'_>> {
let built_in = WidthProfile::PRESETS.iter().map(|p| ProfileEntry { id: p.id, label: p.label, points: p.points, built_in: true });
let saved =
self.prefs.width_profiles.iter().map(|p| ProfileEntry { id: &p.name, label: &p.name, points: &p.profile.points, built_in: false });
built_in.chain(saved)
}
/// The Profile list row showing `p` (`None`: the plain stroke), if it is listed.
pub fn profile_entry(&self, p: Option<&WidthProfile>) -> Option<ProfileEntry<'_>> {
self.profile_entries().find(|e| e.matches(p))
}
/// The profile `stroke.set {profile: key}` applies: `Some(None)` for "uniform" (the plain
/// stroke), else a built-in's id or a saved profile's name; `None` when nothing is called `key`.
pub fn resolve_profile(&self, key: &str) -> Option<Option<WidthProfile>> {
let e = self.profile_entries().find(|e| e.id == key)?;
Some((!e.matches(None)).then(|| WidthProfile { points: e.points.to_vec() }))
}
/// The name Add to Profiles suggests: the first free "Width Profile N".
pub fn next_profile_name(&self) -> String {
(1..).map(|n| format!("Width Profile {n}")).find(|n| !self.profile_name_taken(n)).unwrap_or_default()
}
/// Does a listed profile have `name` as its id or label (ignoring case)?
fn profile_name_taken(&self, name: &str) -> bool {
self.profile_entries().any(|e| e.id.eq_ignore_ascii_case(name) || e.label.eq_ignore_ascii_case(name))
}
/// The variable width Add to Profiles saves: the shown stroke's profile, when it isn't listed.
fn profile_to_add(&self) -> std::result::Result<WidthProfile, String> {
let p = self.shown_stroke().and_then(|s| s.profile).ok_or("select a stroke with a variable width")?;
match self.profile_entry(Some(&p)) {
Some(e) => Err(format!("the selected stroke's profile is already listed as {}", e.label)),
None => Ok(p),
}
}
}
fn can_add_profile(s: &Session) -> std::result::Result<(), String> {
has_doc(s)?;
s.profile_to_add().map(|_| ())
}
fn has_saved_profiles(s: &Session) -> std::result::Result<(), String> {
if s.prefs.width_profiles.is_empty() { Err("no saved profiles".into()) } else { Ok(()) }
}
fn profile_add(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "stroke.widthProfile.add";
let profile = s.profile_to_add().map_err(|e| bad(C, e))?;
let name = match str_param(p, "name").map(str::trim) {
None => s.next_profile_name(),
Some("") => return Err(bad(C, "the name is empty")),
Some(n) if s.profile_name_taken(n) => return Err(bad(C, format!("a profile is already called {n}"))),
Some(n) => n.to_string(),
};
s.prefs.width_profiles.push(SavedProfile { name: name.clone(), profile });
Ok(json!({ "name": name }))
}
fn profile_delete(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "stroke.widthProfile.delete";
let name = match str_param(p, "name") {
Some(n) => n.to_string(),
None => {
let shown = s.shown_stroke().and_then(|st| st.profile);
let entry = shown.as_ref().and_then(|p| s.profile_entry(Some(p)));
entry.map(|e| e.id.to_string()).ok_or_else(|| bad(C, "give a name, or select a stroke with a saved profile"))?
}
};
if WidthProfile::PRESETS.iter().any(|b| b.id == name || b.label == name) {
return Err(bad(C, format!("{name} is built in and can't be deleted")));
}
let i = s.prefs.width_profiles.iter().position(|sp| sp.name == name).ok_or_else(|| bad(C, format!("no saved profile called {name}")))?;
s.prefs.width_profiles.remove(i);
Ok(json!({ "deleted": name }))
}
fn profile_reset(s: &mut Session, _: &Value) -> Result<Value> {
let removed = std::mem::take(&mut s.prefs.width_profiles).len();
Ok(json!({ "removed": removed }))
}
fn profile_list(s: &mut Session, _: &Value) -> Result<Value> {
let profiles: Vec<Value> = s
.profile_entries()
.map(|e| json!({"id": e.id, "label": e.label, "builtIn": e.built_in, "points": e.points.iter().map(|&(t, l, r)| [t, l, r]).collect::<Vec<_>>()}))
.collect();
let current = s.shown_stroke().map(|st| s.profile_entry(st.profile.as_ref()).map_or("custom", |e| e.id).to_string());
Ok(json!({ "profiles": profiles, "current": current }))
}

View File

@ -322,6 +322,10 @@ pub struct Prefs {
/// edit it.
#[serde(skip_serializing_if = "Vec::is_empty")]
pub flattener_presets: Vec<cmd::FlattenerPreset>,
/// Width profiles saved to the Stroke panel's Profile list (`stroke.widthProfile.*`). Not a
/// Preferences dialog field, so it has no [`cmd::prefscmds::PREF_SPECS`] row.
#[serde(skip_serializing_if = "Vec::is_empty")]
pub width_profiles: Vec<vectorcraft_doc::SavedProfile>,
}
impl Default for Prefs {
@ -441,6 +445,7 @@ impl Default for Prefs {
},
eyedropper: Default::default(),
flattener_presets: vec![],
width_profiles: vec![],
}
}
}
@ -841,6 +846,8 @@ mod tests_proxyitems;
#[cfg(test)]
mod tests_strokegeom;
#[cfg(test)]
mod tests_strokereach;
#[cfg(test)]
mod tests_strokeux;
#[cfg(test)]
mod tests_stylepanel;
@ -861,4 +868,6 @@ mod tests_tileedge;
#[cfg(test)]
mod tests_tints;
#[cfg(test)]
mod tests_widthprofiles;
#[cfg(test)]
mod tests_xform;

View File

@ -0,0 +1,76 @@
//! Visual bounds and clicks take in the whole stroke: arrowheads, outside alignment, width
//! profiles (through the Selection tool and the commands that use visual bounds).
use serde_json::json;
use vectorcraft_tools::{PointerEvent, PointerKind};
use super::*;
fn session() -> Session {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap();
s
}
fn id_of(v: &serde_json::Value) -> NodeId {
NodeId(v["id"].as_u64().unwrap())
}
/// Click with the Selection tool at (x, y) on a 100% view: what is selected afterwards.
fn click(s: &mut Session, x: f64, y: f64) -> Vec<NodeId> {
let v = ViewInfo::default();
s.select_tool("selection", v).unwrap();
for k in [PointerKind::Down, PointerKind::Up] {
s.pointer(&PointerEvent::new(k, x, y), v).unwrap();
}
s.active().unwrap().selection.objects.clone()
}
/// The artboard after Fit to Selected Art (which fits the selection's visual bounds).
fn fitted(s: &mut Session) -> vectorcraft_geom::Rect {
s.execute("artboard.fitToSelection", &json!({})).unwrap();
s.doc().unwrap().doc.artboards[0].rect
}
#[test]
fn a_300_percent_arrowhead_grows_the_visual_bounds() {
let mut s = session();
s.execute("shape.line", &json!({"x1": 100, "y1": 200, "x2": 300, "y2": 200})).unwrap();
s.execute("stroke.set", &json!({"weight": 2, "join": "round", "endArrow": "Triangle"})).unwrap();
let plain = fitted(&mut s);
s.execute("stroke.setAdvanced", &json!({"arrowScale": [100, 300]})).unwrap();
let big = fitted(&mut s);
// The 24 pt head reaches past the end and 12 pt to each side of the line.
assert!(big.x1 > plain.x1 + 15.0 && big.y1 >= 212.0 && big.y0 <= 188.0, "{plain:?} → {big:?}");
assert_eq!(big.x0, plain.x0, "the start has no head");
}
#[test]
fn a_click_outside_an_outside_stroke_selects_it() {
let mut s = session();
let r = id_of(&s.execute("shape.rectangle", &json!({"x": 100, "y": 100, "width": 200, "height": 200})).unwrap());
s.execute("paint.setFill", &json!({"none": true})).unwrap();
s.execute("stroke.set", &json!({"weight": 20, "align": "outside"})).unwrap();
s.execute("select.none", &json!({})).unwrap();
// 0.8 × the weight outside the path: past where a centred stroke (plus the tolerance) reaches.
assert_eq!(click(&mut s, 84.0, 200.0), [r]);
s.execute("select.none", &json!({})).unwrap();
assert!(click(&mut s, 116.0, 200.0).is_empty(), "nothing paints inside");
s.execute("stroke.set", &json!({"ids": [r.0], "align": "center"})).unwrap();
s.execute("select.none", &json!({})).unwrap();
assert!(click(&mut s, 84.0, 200.0).is_empty(), "a centred stroke doesn't reach");
}
#[test]
fn a_click_at_the_lens_maximum_selects_its_stroke() {
let mut s = session();
let l = id_of(&s.execute("shape.line", &json!({"x1": 100, "y1": 200, "x2": 300, "y2": 200})).unwrap());
s.execute("stroke.set", &json!({"weight": 20, "profile": "lens"})).unwrap();
s.execute("select.none", &json!({})).unwrap();
assert_eq!(click(&mut s, 200.0, 209.0), [l], "the widest point");
s.execute("select.none", &json!({})).unwrap();
assert!(click(&mut s, 110.0, 209.0).is_empty(), "the thin end");
// Fitted bounds take the profile in too.
s.execute("select.set", &json!({"ids": [l.0]})).unwrap();
assert_eq!(fitted(&mut s).y1, 210.0);
}

View File

@ -0,0 +1,140 @@
//! The width profile library (`stroke.widthProfile.*`): saved profiles kept with the preferences
//! next to the built-in ones, applied by name with `stroke.set {profile}`.
use serde_json::{Value, json};
use vectorcraft_doc::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 profile(s: &Session, id: NodeId) -> Option<WidthProfile> {
s.doc().unwrap().doc.node(id).unwrap().appearance.stroke().unwrap().profile.clone()
}
fn custom() -> WidthProfile {
WidthProfile { points: vec![(0.0, 0.2, 0.2), (0.3, 1.6, 0.8), (1.0, 0.5, 0.5)] }
}
/// Give the line `p` as its variable width (as the Width tool would).
fn set_profile(s: &mut Session, id: NodeId, p: WidthProfile) {
s.edit("Width", |d, _| {
d.node_mut(id).unwrap().appearance.stroke_mut().unwrap().profile = Some(p);
Ok(())
})
.unwrap();
}
fn ids(list: &Value) -> Vec<&str> {
list["profiles"].as_array().unwrap().iter().map(|p| p["id"].as_str().unwrap()).collect()
}
#[test]
fn add_list_apply_delete_reset() {
let (mut s, id) = session_with_line();
let built_in: Vec<&str> = WidthProfile::PRESETS.iter().map(|p| p.id).collect();
let list = s.execute("stroke.widthProfile.list", &json!({})).unwrap();
assert_eq!(ids(&list), built_in);
assert_eq!(list["current"], "uniform");
// Nothing to save from a plain stroke, or from a built-in profile.
assert!(s.execute("stroke.widthProfile.add", &json!({})).is_err());
s.execute("stroke.set", &json!({"profile": "lens"})).unwrap();
assert!(s.execute("stroke.widthProfile.add", &json!({})).is_err());
set_profile(&mut s, id, custom());
assert_eq!(s.execute("stroke.widthProfile.list", &json!({})).unwrap()["current"], "custom");
let undo_depth = s.doc().unwrap().history.undo.len();
assert_eq!(s.execute("stroke.widthProfile.add", &json!({})).unwrap()["name"], "Width Profile 1");
assert_eq!(s.doc().unwrap().history.undo.len(), undo_depth, "the library is not part of the document");
// Listed after the built-ins, and now the stroke's profile.
let list = s.execute("stroke.widthProfile.list", &json!({})).unwrap();
assert_eq!(ids(&list).last(), Some(&"Width Profile 1"));
assert_eq!(list["current"], "Width Profile 1");
let row = list["profiles"].as_array().unwrap().last().unwrap().clone();
assert_eq!((row["builtIn"].clone(), row["points"][1].clone()), (json!(false), json!([0.3, 1.6, 0.8])));
// The same profile can't be saved twice.
assert!(s.execute("stroke.widthProfile.add", &json!({"name": "Again"})).is_err());
// Apply it by name to another stroke.
let other = NodeId(s.execute("shape.line", &json!({"x1": 10, "y1": 150, "x2": 150, "y2": 150})).unwrap()["id"].as_u64().unwrap());
assert!(profile(&s, other).is_none());
s.execute("stroke.set", &json!({"profile": "Width Profile 1"})).unwrap();
assert_eq!(profile(&s, other), Some(custom()));
assert!(s.execute("stroke.set", &json!({"profile": "Nope"})).is_err());
// Delete it (the selected stroke's): strokes keep their widths.
assert_eq!(s.execute("stroke.widthProfile.delete", &json!({})).unwrap()["deleted"], "Width Profile 1");
assert_eq!(ids(&s.execute("stroke.widthProfile.list", &json!({})).unwrap()), built_in);
assert_eq!(profile(&s, other), Some(custom()));
assert!(s.execute("stroke.set", &json!({"profile": "Width Profile 1"})).is_err());
// Reset removes every saved profile.
s.execute("stroke.widthProfile.add", &json!({"name": " Ribbon "})).unwrap();
set_profile(&mut s, other, WidthProfile { points: vec![(0.0, 1.0, 0.0), (1.0, 0.0, 1.0)] });
s.execute("stroke.widthProfile.add", &json!({"name": "Twist"})).unwrap();
assert_eq!(s.prefs.width_profiles.iter().map(|p| p.name.as_str()).collect::<Vec<_>>(), ["Ribbon", "Twist"]);
assert_eq!(s.execute("stroke.widthProfile.reset", &json!({})).unwrap()["removed"], 2);
assert!(s.prefs.width_profiles.is_empty());
assert!(s.execute("stroke.widthProfile.reset", &json!({})).is_err(), "nothing left to reset");
}
#[test]
fn built_ins_cant_be_deleted_and_names_stay_unique() {
let (mut s, id) = session_with_line();
set_profile(&mut s, id, custom());
for taken in ["lens", "Taper Start", "UNIFORM"] {
assert!(s.execute("stroke.widthProfile.add", &json!({"name": taken})).is_err(), "{taken}");
}
assert!(s.execute("stroke.widthProfile.add", &json!({"name": " "})).is_err());
s.execute("stroke.widthProfile.add", &json!({"name": "Mine"})).unwrap();
for b in ["lens", "Lens", "uniform", "wave"] {
let e = s.execute("stroke.widthProfile.delete", &json!({"name": b})).unwrap_err().to_string();
assert!(e.contains("built in"), "{b}: {e}");
}
// With a built-in profile selected, Delete has nothing to delete.
s.execute("stroke.set", &json!({"profile": "pinch"})).unwrap();
assert!(s.execute("stroke.widthProfile.delete", &json!({})).is_err());
assert!(s.execute("stroke.widthProfile.delete", &json!({"name": "Other"})).is_err());
assert_eq!(s.next_profile_name(), "Width Profile 1");
assert_eq!(s.execute("stroke.widthProfile.delete", &json!({"name": "Mine"})).unwrap()["deleted"], "Mine");
}
#[test]
fn saved_profiles_round_trip_with_the_preferences() {
let (mut s, id) = session_with_line();
set_profile(&mut s, id, custom());
s.execute("stroke.widthProfile.add", &json!({"name": "Mine"})).unwrap();
let json = s.prefs.to_json();
assert_eq!(json["widthProfiles"][0]["name"], "Mine");
let back: Prefs = serde_json::from_value(json).unwrap();
assert_eq!(back, s.prefs);
// Without saved profiles nothing is written, and older preference files load with none.
assert!(Prefs::default().to_json().get("widthProfiles").is_none());
let old: Prefs = serde_json::from_value(json!({"keyboardIncrement": 2})).unwrap();
assert!(old.width_profiles.is_empty());
// Setting and resetting other preferences keeps them.
s.execute("prefs.set", &json!({"key": "keyboardIncrement", "value": 3})).unwrap();
s.execute("prefs.reset", &json!({})).unwrap();
assert_eq!(s.prefs.width_profiles.len(), 1);
// A restarted session (preferences applied from the saved file) lists them again.
let mut fresh = Session::new();
fresh.apply_prefs(back);
assert!(fresh.resolve_profile("Mine").is_some_and(|p| p == Some(custom())));
}
#[test]
fn every_built_in_applies_and_the_params_doc_names_them() {
let (mut s, id) = session_with_line();
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);
s.execute("stroke.set", &json!({"profile": p.id})).unwrap();
assert_eq!(s.execute("stroke.widthProfile.list", &json!({})).unwrap()["current"], p.id);
assert_eq!(profile(&s, id).map(|w| w.points), (p.id != "uniform").then(|| p.points.to_vec()));
}
}

100
crates/geom/src/bez.rs Normal file
View File

@ -0,0 +1,100 @@
//! Walking a [`BezPath`] as strokes see it: its subpaths (open or closed), their segments, unit
//! tangents and corners. Shared by the stroke geometry (`vectorcraft-effects`) and the visual
//! bounds and hit testing of strokes (`vectorcraft-doc`).
use kurbo::{BezPath, ParamCurve, ParamCurveDeriv, PathEl, PathSeg, Vec2};
/// Tangents meeting at more than about 1° (this cosine) make a corner: fitted dashes centre on it,
/// variable-width strokes give it the stroke's join and miter spikes grow from it.
pub const CORNER_COS: f64 = 0.9998;
/// Element ranges of the subpaths of `bp` (each with whether it is closed).
pub 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 fn segments(els: &[PathEl]) -> Vec<PathSeg> {
kurbo::segments(els.iter().copied()).collect()
}
/// Unit tangent of `s` at `t`, robust at ends where control points coincide with the end point.
pub 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 fn unit(v: Vec2) -> Vec2 {
let h = v.hypot();
if h > 1e-12 { v / h } else { Vec2::new(1.0, 0.0) }
}
/// Do segments `a` and `b` (`b` following `a`) meet at a corner?
pub fn kink(a: &PathSeg, b: &PathSeg) -> bool {
tangent(a, 1.0).dot(tangent(b, 0.0)) < CORNER_COS
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn subpaths_and_tangents() {
let mut bp = BezPath::new();
bp.move_to((0.0, 0.0));
bp.line_to((10.0, 0.0));
bp.line_to((10.0, 10.0));
bp.close_path();
bp.move_to((20.0, 0.0));
bp.curve_to((20.0, 0.0), (30.0, 0.0), (30.0, 10.0));
let subs = subpaths(&bp);
assert_eq!(subs, vec![(0..4, true), (4..6, false)]);
let segs = segments(&bp.elements()[subs[0].0.clone()]);
assert_eq!(segs.len(), 3, "the close adds a segment");
assert!(kink(&segs[0], &segs[1]));
let c = segments(&bp.elements()[subs[1].0.clone()]);
// A handle on its anchor: the direction comes from the next control point.
assert_eq!(tangent(&c[0], 0.0), Vec2::new(1.0, 0.0));
assert_eq!(tangent(&c[0], 1.0), Vec2::new(0.0, 1.0));
assert_eq!(unit(Vec2::ZERO), Vec2::new(1.0, 0.0));
}
}

View File

@ -7,6 +7,7 @@
//! Coordinates are document points (1/72 in), y pointing down.
#![forbid(unsafe_code)]
pub mod bez;
pub mod hit;
pub mod path;
pub mod recognize;

View File

@ -440,9 +440,9 @@ fn options_decimals_minify_responsive_artboard() {
assert!(s.contains("M10.12 20"), "{s}");
let s = export(&d, &ExportOptions::default());
assert!(s.contains("width=\"200\" height=\"100\"") && s.contains("M10.123 20"), "{s}");
// All art bounds.
// All art bounds: the rectangle and half its 1 pt stroke (right-angle miters stay inside).
let s = export(&d, &ExportOptions { artboard: None, ..Default::default() });
assert!(s.contains("viewBox=\"0 0 43.877 34\""), "{s}");
assert!(s.contains("viewBox=\"0 0 40.877 31\""), "{s}");
let s = export(&d, &ExportOptions { object_ids: false, ..Default::default() });
assert!(!s.contains("id=\"Layer_1\""));
}

View File

@ -127,8 +127,7 @@ pub fn control_bar(app: &mut VectorcraftApp, ui: &mut Ui) {
let link = ui.link(egui::RichText::new("Stroke:").size(12.0).color(t.text).underline()).on_hover_text("Stroke options");
stroke_panel::popover(app, &link);
stroke_panel::weight_field(app, ui, "cb-stroke", weight, 100.0);
let profile = vectorcraft_doc::WidthProfile::id_of(shown_stroke.as_ref().and_then(|s| s.profile.as_ref()));
if let Some(id) = stroke_panel::profile_dropdown(ui, profile) {
if let Some(id) = stroke_panel::profile_dropdown(app, ui, shown_stroke.as_ref().and_then(|s| s.profile.as_ref())) {
app.run("stroke.set", json!({"profile": id})).ok();
}
ui.add_space(4.0);

View File

@ -599,7 +599,7 @@ fn stroke_notes(ui: &Ui, r: Rect, st: &vectorcraft_doc::StrokeLayer) {
if let Some(p) = st.profile.as_ref().filter(|p| p.preset_id() != Some("uniform")) {
let pr = Rect::from_min_size(pos2(x, r.center().y - 5.0), vec2(28.0, 10.0));
if pr.right() <= r.right() {
paint_profile(ui, pr, Some(p), t.text);
paint_profile(ui, pr, Some(&p.points), t.text);
}
}
}

View File

@ -1,7 +1,6 @@
//! Brushes panel: the document's brush library with rendered stroke previews. Clicking a brush
//! applies it to the selected paths (and makes it the Paintbrush's current brush).
use std::cell::RefCell;
use std::collections::HashMap;
use egui::{Sense, Ui, vec2};
@ -26,43 +25,25 @@ pub fn brushes(app: &mut VectorcraftApp) -> (Vec<(String, String)>, Option<Strin
(list, v["current"].as_str().map(str::to_string))
}
/// A stroke preview for brush definition `def`, rendered at `size` pixels and cached by the
/// definition's JSON.
/// A stroke preview for brush definition `def`, rendered at `size` and cached by the definition's
/// JSON.
fn preview(ui: &Ui, def: &Value, size: egui::Vec2) -> Option<egui::TextureHandle> {
thread_local! {
static RENDERER: RefCell<vectorcraft_render::Renderer> = RefCell::new(vectorcraft_render::Renderer::new());
static CACHE: RefCell<HashMap<String, egui::TextureHandle>> = RefCell::new(HashMap::new());
}
let ppp = ui.ctx().pixels_per_point() as f64;
let (w, h) = (size.x as f64, size.y as f64);
let key = format!("{w}x{h}@{ppp}:{def}");
if let Some(t) = CACHE.with(|c| c.borrow().get(&key).cloned()) {
return Some(t);
}
let mut doc = Document::new(w, h);
doc.unknown.insert("brushes".into(), json!([def]));
let name = def["name"].as_str()?.to_string();
let mut ap = Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 1.0);
ap.stroke_mut()?.brush = Some(name);
// A gentle S-curve across the swatch.
let mut bp = vectorcraft_geom::BezPath::new();
let (x0, x1) = (h * 0.5, w - h * 0.5);
bp.move_to((x0, h * 0.5));
bp.curve_to((x0 + (x1 - x0) * 0.35, h * 0.1), (x0 + (x1 - x0) * 0.65, h * 0.9), (x1, h * 0.5));
let id = doc.alloc_id();
let l = doc.layers[0].id;
doc.insert(Some(l), 0, Node::path(id, vectorcraft_geom::PathData::from_bezpath(&bp), ap)).ok()?;
let img = RENDERER.with(|r| r.borrow_mut().render_region(&doc, vectorcraft_geom::Rect::new(0.0, 0.0, w, h), ppp, false));
let color = egui::ColorImage::from_rgba_premultiplied([img.width as usize, img.height as usize], &img.pixels);
let tex = ui.ctx().load_texture(format!("brush-{key}"), color, egui::TextureOptions::LINEAR);
CACHE.with(|c| {
let mut c = c.borrow_mut();
if c.len() > 256 {
c.clear();
}
c.insert(key, tex.clone());
});
Some(tex)
widgets::doc_preview(ui, &format!("brush:{def}"), size, |w, h| {
let mut doc = Document::new(w, h);
doc.unknown.insert("brushes".into(), json!([def]));
let name = def["name"].as_str()?.to_string();
let mut ap = Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 1.0);
ap.stroke_mut()?.brush = Some(name);
// A gentle S-curve across the swatch.
let mut bp = vectorcraft_geom::BezPath::new();
let (x0, x1) = (h * 0.5, w - h * 0.5);
bp.move_to((x0, h * 0.5));
bp.curve_to((x0 + (x1 - x0) * 0.35, h * 0.1), (x0 + (x1 - x0) * 0.65, h * 0.9), (x1, h * 0.5));
let id = doc.alloc_id();
let l = doc.layers[0].id;
doc.insert(Some(l), 0, Node::path(id, vectorcraft_geom::PathData::from_bezpath(&bp), ap)).ok()?;
Some(doc)
})
}
fn selected_brush(app: &VectorcraftApp) -> Option<String> {

View File

@ -441,4 +441,6 @@ mod tests_freeform;
#[cfg(test)]
mod tests_stroke;
#[cfg(test)]
mod tests_strokedepth;
#[cfg(test)]
mod tests_strokeux;

View File

@ -1,9 +1,10 @@
//! Stroke panel: weight spinner + presets, cap / corner / align toggles, miter limit, dashed line
//! with three dash/gap pairs, arrowheads with drawn previews, arrow scale, width profiles.
//! with three dash/gap pairs, arrowheads with rendered previews, arrow scale, width profiles.
use egui::{Color32, Pos2, Rect, Sense, Stroke, StrokeKind, Ui, pos2, vec2};
use egui::{Color32, Rect, Sense, Stroke, StrokeKind, Ui, pos2, vec2};
use serde_json::{Value, json};
use vectorcraft_doc::{ArrowAlign, Arrowhead, Dash, LineCap, LineJoin, ProfilePreset, StrokeAlign, StrokeLayer, WidthProfile};
use vectorcraft_color::{Color, Paint};
use vectorcraft_doc::{Appearance, ArrowAlign, Arrowhead, Dash, Document, LineCap, LineJoin, Node, StrokeAlign, StrokeLayer, WidthProfile};
use vectorcraft_engine::inspect::StrokeMixed;
use super::{character, current_stroke, pstate, set_pstate, stroke_mixed};
@ -14,9 +15,9 @@ use crate::{VectorcraftApp, icons};
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];
/// The silhouette drawn for profile `id` (None draws the plain uniform bar).
fn profile_of(id: &str) -> Option<WidthProfile> {
WidthProfile::preset(id).filter(|_| id != "uniform")
/// A profile's points as its silhouette: none (the plain bar) for the uniform stroke.
fn silhouette(points: &[(f64, f64, f64)]) -> Option<&[(f64, f64, f64)]> {
(points != WidthProfile::PRESETS[0].points).then_some(points)
}
/// The Arrowheads Align buttons: (alignment, `stroke.set` value, icon, tooltip).
@ -64,14 +65,8 @@ pub fn dash_pattern(fields: &[Option<f64>; 6]) -> Vec<f64> {
out
}
fn arrow_label(a: Option<Arrowhead>) -> String {
match a {
None => "None".into(),
Some(a) => {
let s = format!("{a:?}");
s.replace("Open", " (open)")
}
}
fn arrow_label(a: Option<Arrowhead>) -> &'static str {
a.map_or("None", Arrowhead::label)
}
/// Apply Stroke panel options (`stroke.set` params): while the Type tool edits text, to the
@ -269,10 +264,9 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
});
widgets::divider(ui);
// Profile.
let pid = WidthProfile::id_of(st.as_ref().and_then(|s| s.profile.as_ref()));
ui.horizontal(|ui| {
row_label(ui, "Profile:");
if let Some(id) = profile_dropdown(ui, pid) {
if let Some(id) = profile_dropdown(app, ui, st.as_ref().and_then(|s| s.profile.as_ref())) {
set(app, json!({"profile": id}));
}
let can_flip = st.as_ref().is_some_and(|s| s.profile.is_some());
@ -285,31 +279,34 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
});
}
/// Draw an arrowhead preview: a line with the head at the left (`start`) or right end.
/// The document an arrowhead preview renders: a `w`×`h` pt line with head `a` at its left
/// (`start`) or right end, its tip on the end point, drawn white (the preview is tinted).
pub(super) fn arrow_doc(a: Option<Arrowhead>, start: bool, w: f64, h: f64) -> Option<Document> {
let mut doc = Document::new(w, h);
let mut ap = Appearance::basic(Paint::None, Paint::solid(Color::WHITE), 1.5);
let st = ap.stroke_mut()?;
// Heads of weight 2.5 pt: 10 pt long and wide.
st.arrow_scale = (500.0 / 3.0, 500.0 / 3.0);
st.arrow_align = ArrowAlign::Tip;
if start {
st.start_arrow = a
} else {
st.end_arrow = a
}
let mut bp = vectorcraft_geom::BezPath::new();
bp.move_to((3.0, h / 2.0));
bp.line_to((w - 3.0, h / 2.0));
let (id, l) = (doc.alloc_id(), doc.layers[0].id);
doc.insert(Some(l), 0, Node::path(id, vectorcraft_geom::PathData::from_bezpath(&bp), ap)).ok()?;
Some(doc)
}
/// Draw an arrowhead preview (the head as the canvas draws it) in `r`: a line with the head at the
/// left (`start`) or right end.
fn paint_arrow(ui: &Ui, r: Rect, a: Option<Arrowhead>, start: bool, color: Color32) {
let y = r.center().y;
let (tip_x, back) = if start { (r.left() + 4.0, 1.0) } else { (r.right() - 4.0, -1.0) };
let s = Stroke::new(1.5, color);
let line_from = if a.is_some() { tip_x + back * 8.0 } else { tip_x };
let other = if start { r.right() - 4.0 } else { r.left() + 4.0 };
ui.painter().line_segment([pos2(line_from, y), pos2(other, y)], s);
let Some(a) = a else { return };
let p = |dx: f32, dy: f32| -> Pos2 { pos2(tip_x + back * dx, y + dy) };
let solid = |pts: Vec<Pos2>| egui::Shape::convex_polygon(pts, color, Stroke::NONE);
let open = |pts: Vec<Pos2>| egui::Shape::closed_line(pts, s);
let shape = match a {
Arrowhead::Triangle => solid(vec![p(0.0, 0.0), p(9.0, -4.5), p(9.0, 4.5)]),
Arrowhead::TriangleOpen => open(vec![p(0.0, 0.0), p(9.0, -4.5), p(9.0, 4.5)]),
Arrowhead::Arrow => solid(vec![p(0.0, 0.0), p(10.0, -5.0), p(7.0, 0.0), p(10.0, 5.0)]),
Arrowhead::ArrowOpen => egui::Shape::line(vec![p(9.0, -5.0), p(0.0, 0.0), p(9.0, 5.0)], s),
Arrowhead::Circle => egui::Shape::circle_filled(p(4.5, 0.0), 4.5, color),
Arrowhead::CircleOpen => egui::Shape::circle_stroke(p(4.5, 0.0), 4.0, s),
Arrowhead::Square => egui::Shape::rect_filled(Rect::from_center_size(p(4.5, 0.0), vec2(8.0, 8.0)), 0.0, color),
Arrowhead::SquareOpen => egui::Shape::rect_stroke(Rect::from_center_size(p(4.5, 0.0), vec2(8.0, 8.0)), 0.0, s, StrokeKind::Middle),
Arrowhead::Diamond => solid(vec![p(0.0, 0.0), p(5.0, -5.0), p(10.0, 0.0), p(5.0, 5.0)]),
Arrowhead::Bar => egui::Shape::line_segment([p(0.0, -6.0), p(0.0, 6.0)], Stroke::new(2.0, color)),
};
ui.painter().add(shape);
if let Some(tex) = widgets::doc_preview(ui, &format!("arrow:{a:?}:{start}"), r.size(), |w, h| arrow_doc(a, start, w, h)) {
ui.painter().image(tex.id(), r, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), color);
}
}
/// Dropdown whose button and items show drawn arrowhead previews. Returns Some(choice).
@ -324,41 +321,42 @@ fn arrow_dropdown(ui: &mut Ui, id: &str, cur: Option<Arrowhead>, start: bool) ->
let resp = resp.on_hover_text(if start { "Start arrowhead" } else { "End arrowhead" });
let mut out = None;
egui::Popup::menu(&resp).id(egui::Id::new(("arrow-pop", id))).show(|ui| {
ui.set_min_width(140.0);
let opts: Vec<Option<Arrowhead>> = std::iter::once(None).chain(Arrowhead::ALL.iter().copied().map(Some)).collect();
for a in opts {
let (row, rr) = ui.allocate_exact_size(vec2(140.0, 22.0), Sense::click());
if a == cur {
ui.painter().rect_filled(row, 0.0, t.row_selected);
} else if rr.hovered() {
ui.painter().rect_filled(row, 0.0, t.hover);
ui.set_min_width(170.0);
egui::ScrollArea::vertical().max_height(360.0).show(ui, |ui| {
for a in std::iter::once(None).chain(Arrowhead::ALL.map(Some)) {
let (row, rr) = ui.allocate_exact_size(vec2(170.0, 22.0), Sense::click());
if a == cur {
ui.painter().rect_filled(row, 0.0, t.row_selected);
} else if rr.hovered() {
ui.painter().rect_filled(row, 0.0, t.hover);
}
paint_arrow(ui, Rect::from_min_size(row.min + vec2(4.0, 0.0), vec2(56.0, 22.0)), a, start, t.text_strong);
ui.painter().text(
row.left_center() + vec2(66.0, 0.0),
egui::Align2::LEFT_CENTER,
arrow_label(a),
egui::FontId::proportional(11.5),
t.text,
);
if rr.clicked() {
out = Some(a);
ui.close();
}
}
paint_arrow(ui, Rect::from_min_size(row.min + vec2(4.0, 0.0), vec2(56.0, 22.0)), a, start, t.text_strong);
ui.painter().text(
row.left_center() + vec2(66.0, 0.0),
egui::Align2::LEFT_CENTER,
arrow_label(a),
egui::FontId::proportional(11.5),
t.text,
);
if rr.clicked() {
out = Some(a);
ui.close();
}
}
});
});
out
}
/// Draw a width profile's silhouette into `r`.
pub fn paint_profile(ui: &Ui, r: Rect, prof: Option<&WidthProfile>, color: Color32) {
/// Draw a width profile's silhouette (its points; None: the plain bar) into `r`.
pub fn paint_profile(ui: &Ui, r: Rect, points: Option<&[(f64, f64, f64)]>, color: Color32) {
let n = 32;
let half = r.height() / 2.0 - 1.0;
let mut top = vec![];
let mut bot = vec![];
for i in 0..=n {
let tt = i as f64 / n as f64;
let (l, rr) = prof.map(|p| p.at(tt)).unwrap_or((0.35, 0.35));
let (l, rr) = points.map_or((0.35, 0.35), |p| WidthProfile::at_points(p, tt));
let x = r.left() + tt as f32 * r.width();
top.push(pos2(x, r.center().y - l as f32 * half));
bot.push(pos2(x, r.center().y + rr as f32 * half));
@ -376,31 +374,41 @@ pub fn paint_profile(ui: &Ui, r: Rect, prof: Option<&WidthProfile>, color: Color
ui.painter().add(egui::Shape::mesh(mesh));
}
pub(crate) fn profile_dropdown(ui: &mut Ui, cur: &str) -> Option<&'static str> {
/// The Profile dropdown (Stroke panel, Control bar): the stroke's own silhouette on the button
/// (a custom profile too), the built-in profiles and then the saved ones in the list. Returns the
/// id or name picked.
pub(crate) fn profile_dropdown(app: &VectorcraftApp, ui: &mut Ui, cur: Option<&WidthProfile>) -> Option<String> {
let t = Tokens::get(ui.ctx());
let (r, resp) = ui.allocate_exact_size(vec2(100.0, 24.0), Sense::click());
ui.painter().rect_filled(r, 2, t.input);
ui.painter().rect_stroke(r, 2, Stroke::new(1.0, if resp.hovered() { t.text } else { t.input_border }), StrokeKind::Inside);
let body = Rect::from_min_max(r.min + vec2(6.0, 5.0), pos2(r.right() - 20.0, r.bottom() - 5.0));
paint_profile(ui, body, profile_of(cur).as_ref(), t.text_strong);
paint_profile(ui, body, cur.and_then(|p| silhouette(&p.points)), t.text_strong);
icons::paint(ui, "chevron-down", Rect::from_center_size(pos2(r.right() - 9.0, r.center().y), vec2(10.0, 10.0)), t.icon);
let resp = resp.on_hover_text("Variable Width Profile");
let mut out = None;
egui::Popup::menu(&resp).show(|ui| {
for ProfilePreset { id, label, .. } in WidthProfile::PRESETS {
let (row, rr) = ui.allocate_exact_size(vec2(170.0, 26.0), Sense::click());
if id == cur {
ui.painter().rect_filled(row, 0.0, t.row_selected);
} else if rr.hovered() {
ui.painter().rect_filled(row, 0.0, t.hover);
egui::ScrollArea::vertical().max_height(360.0).show(ui, |ui| {
// A divider between the built-in profiles and the saved ones.
let mut divide = true;
for e in app.session.profile_entries() {
if !e.built_in && std::mem::take(&mut divide) {
ui.separator();
}
let (row, rr) = ui.allocate_exact_size(vec2(170.0, 26.0), Sense::click());
if e.matches(cur) {
ui.painter().rect_filled(row, 0.0, t.row_selected);
} else if rr.hovered() {
ui.painter().rect_filled(row, 0.0, t.hover);
}
paint_profile(ui, Rect::from_min_size(row.min + vec2(6.0, 6.0), vec2(70.0, 14.0)), silhouette(e.points), t.text_strong);
ui.painter().text(row.left_center() + vec2(84.0, 0.0), egui::Align2::LEFT_CENTER, e.label, egui::FontId::proportional(11.5), t.text);
if rr.clicked() {
out = Some(e.id.to_string());
ui.close();
}
}
paint_profile(ui, Rect::from_min_size(row.min + vec2(6.0, 6.0), vec2(70.0, 14.0)), profile_of(id).as_ref(), t.text_strong);
ui.painter().text(row.left_center() + vec2(84.0, 0.0), egui::Align2::LEFT_CENTER, label, egui::FontId::proportional(11.5), t.text);
if rr.clicked() {
out = Some(id);
ui.close();
}
}
});
});
out
}
@ -410,10 +418,20 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) {
if menu_item(ui, if hidden { "Show Options" } else { "Hide Options" }, true, false) {
set_pstate(ui.ctx(), "stroke-hide-options", !hidden);
}
menu_item(ui, "Add to Profiles", false, false);
menu_item(ui, "Delete Profile", false, false);
if menu_item(ui, "Reset Profile", current_stroke(app).is_some_and(|s| s.profile.is_some()), false) {
set(app, json!({"profile": "uniform"}));
ui.separator();
if menu_item(ui, "Add to Profiles…", crate::menus::enabled(app, "stroke.widthProfile.add"), false) {
let name = app.session.next_profile_name();
let p = json!({"command": "stroke.widthProfile.add", "label": "Variable Width Profile", "params": {"name": name}});
app.run("ui.paramDialog", p).ok();
}
// Deletes the selected stroke's saved profile.
let shown = current_stroke(app).and_then(|s| s.profile);
let saved = shown.as_ref().and_then(|p| app.session.profile_entry(Some(p))).is_some_and(|e| !e.built_in);
if menu_item(ui, "Delete Profile", saved, false) {
app.run("stroke.widthProfile.delete", json!({})).ok();
}
if menu_item(ui, "Reset Profiles", crate::menus::enabled(app, "stroke.widthProfile.reset"), false) {
app.run("stroke.widthProfile.reset", json!({})).ok();
}
}
@ -437,9 +455,9 @@ mod tests {
#[test]
fn profile_silhouettes_and_arrow_labels() {
assert!(profile_of("uniform").is_none());
assert_eq!(profile_of("lens"), Some(WidthProfile::lens()));
assert!(profile_of("custom").is_none());
assert!(silhouette(WidthProfile::PRESETS[0].points).is_none(), "uniform draws the plain bar");
let lens = WidthProfile::lens();
assert_eq!(silhouette(&lens.points), Some(lens.points.as_slice()));
assert_eq!(arrow_label(Some(Arrowhead::CircleOpen)), "Circle (open)");
}

View File

@ -0,0 +1,120 @@
//! Headless frames of the Stroke panel's width profile library: the ≡ menu's Add to Profiles…,
//! Delete Profile and Reset Profiles, and the Profile dropdown listing saved profiles.
use serde_json::json;
use vectorcraft_doc::WidthProfile;
use super::tests_appearance::{click, frame_events, run, text_rect};
use super::*;
fn app_with_line() -> VectorcraftApp {
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default());
run(&mut app, "file.new", json!({"width": 200, "height": 200}));
run(&mut app, "shape.line", json!({"x1": 10, "y1": 100, "x2": 150, "y2": 100}));
app
}
fn custom() -> WidthProfile {
WidthProfile { points: vec![(0.0, 0.3, 0.3), (0.6, 1.4, 1.4), (1.0, 0.0, 0.0)] }
}
/// Give the selected line `p` as its variable width.
fn set_profile(app: &mut VectorcraftApp, p: WidthProfile) {
app.session
.edit("Width", |d, sel| {
let id = sel.objects[0];
d.node_mut(id).unwrap().appearance.stroke_mut().unwrap().profile = Some(p);
Ok(())
})
.unwrap();
}
fn panel_and_menu(app: &mut VectorcraftApp, ui: &mut Ui) {
stroke::show(app, ui);
stroke::menu(app, ui);
}
fn profile_points(app: &VectorcraftApp) -> Option<Vec<(f64, f64, f64)>> {
current_stroke(app).and_then(|s| s.profile).map(|p| p.points)
}
#[test]
fn menu_saves_deletes_and_resets_profiles() {
let ctx = egui::Context::default();
let mut app = app_with_line();
set_profile(&mut app, custom());
let t = frame_events(&ctx, &mut app, vec![], panel_and_menu);
click(&ctx, &mut app, text_rect(&t, " Add to Profiles…").center(), panel_and_menu);
// A name dialog (OK runs stroke.widthProfile.add).
let d = app.ui.dialog.clone().expect("name dialog");
assert_eq!((d.str("__command"), d.str("name")), ("stroke.widthProfile.add".to_string(), "Width Profile 1".to_string()));
app.ui.dialog.as_mut().unwrap().fields.insert("name".into(), json!("Mine"));
crate::dialogs::confirm(&mut app).unwrap();
assert_eq!(app.session.prefs.width_profiles[0].name, "Mine");
// Saved profiles don't trip the Preferences dialog, which only edits preferences.
crate::prefs_dialog::open(&mut app, None);
crate::prefs_dialog::confirm(&mut app).unwrap();
assert_eq!(app.session.prefs.width_profiles.len(), 1);
// Delete Profile removes the selected stroke's saved profile; the stroke keeps its widths.
let t = frame_events(&ctx, &mut app, vec![], panel_and_menu);
click(&ctx, &mut app, text_rect(&t, " Delete Profile").center(), panel_and_menu);
assert!(app.session.prefs.width_profiles.is_empty());
assert_eq!(profile_points(&app), Some(custom().points));
run(&mut app, "stroke.widthProfile.add", json!({"name": "A"}));
let t = frame_events(&ctx, &mut app, vec![], panel_and_menu);
click(&ctx, &mut app, text_rect(&t, " Reset Profiles").center(), panel_and_menu);
assert!(app.session.prefs.width_profiles.is_empty());
}
#[test]
fn dropdown_lists_saved_profiles_and_applies_them() {
let ctx = egui::Context::default();
let mut app = app_with_line();
set_profile(&mut app, custom());
run(&mut app, "stroke.widthProfile.add", json!({"name": "Mine"}));
run(&mut app, "stroke.set", json!({"profile": "uniform"}));
let t = frame_events(&ctx, &mut app, vec![], stroke::show);
// The dropdown sits right of the "Profile:" label.
let label = text_rect(&t, "Profile:");
let button = egui::pos2(label.right() + ctx.global_style().spacing.item_spacing.x + 50.0, label.center().y);
click(&ctx, &mut app, button, stroke::show);
let t = frame_events(&ctx, &mut app, vec![], stroke::show);
for p in WidthProfile::PRESETS {
text_rect(&t, p.label);
}
click(&ctx, &mut app, text_rect(&t, "Mine").center(), stroke::show);
assert_eq!(profile_points(&app), Some(custom().points));
}
#[test]
fn every_arrowhead_preview_draws_its_head() {
use vectorcraft_doc::Arrowhead;
// Rendered on transparent: the head adds ink to its half of the preview.
let ink = |a: Option<Arrowhead>, start: bool| {
let d = stroke::arrow_doc(a, start, 56.0, 22.0).unwrap();
let img = vectorcraft_render::Renderer::new().render_region(&d, vectorcraft_geom::Rect::new(0.0, 0.0, 56.0, 22.0), 2.0, false);
let xs = if start { 0..56 } else { 56..112 };
xs.flat_map(|x| (0..44).map(move |y| (x, y))).filter(|&(x, y)| img.pixel(x, y)[3] > 64).count()
};
let plain = (ink(None, false), ink(None, true));
for a in Arrowhead::ALL {
assert!(ink(Some(a), false) > plain.0 + 20, "{a:?} at the end");
assert!(ink(Some(a), true) > plain.1 + 20, "{a:?} at the start");
}
}
#[test]
fn arrowhead_dropdown_offers_the_new_heads() {
let ctx = egui::Context::default();
let mut app = app_with_line();
let t = frame_events(&ctx, &mut app, vec![], stroke::show);
// The end-arrowhead dropdown is the second 64 pt button right of the "Arrowheads:" label.
let label = text_rect(&t, "Arrowheads:");
let sp = ctx.global_style().spacing.item_spacing.x;
click(&ctx, &mut app, egui::pos2(label.right() + 2.0 * sp + 64.0 + 32.0, label.center().y), stroke::show);
let t = frame_events(&ctx, &mut app, vec![], stroke::show);
click(&ctx, &mut app, text_rect(&t, "Barbed").center(), stroke::show);
assert_eq!(current_stroke(&app).and_then(|s| s.end_arrow), Some(vectorcraft_doc::Arrowhead::Barbed));
}

View File

@ -60,7 +60,7 @@ fn library_submenus_are_disabled_placeholders() {
#[test]
fn stroke_profiles_have_descriptive_names() {
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", "Pinch", "Teardrop", "Wave"]);
}
#[test]

View File

@ -1027,3 +1027,33 @@ pub fn drag_source(ui: &Ui, resp: &Response, drag: impl FnOnce() -> PanelDrag) {
egui::DragAndDrop::set_payload(ui.ctx(), drag());
}
}
/// A small document rendered to a texture at the screen's pixel density (stroke previews: brushes,
/// arrowheads), transparent where nothing paints. `build` makes the `w`×`h` pt document only when
/// the texture for `key` at this size isn't cached yet.
pub fn doc_preview(ui: &Ui, key: &str, size: Vec2, build: impl FnOnce(f64, f64) -> Option<vectorcraft_doc::Document>) -> Option<egui::TextureHandle> {
use std::cell::RefCell;
use std::collections::HashMap;
thread_local! {
static RENDERER: RefCell<vectorcraft_render::Renderer> = RefCell::new(vectorcraft_render::Renderer::new());
static CACHE: RefCell<HashMap<String, egui::TextureHandle>> = RefCell::new(HashMap::new());
}
let ppp = ui.ctx().pixels_per_point() as f64;
let (w, h) = (size.x as f64, size.y as f64);
let key = format!("{w}x{h}@{ppp}:{key}");
if let Some(t) = CACHE.with(|c| c.borrow().get(&key).cloned()) {
return Some(t);
}
let doc = build(w, h)?;
let img = RENDERER.with(|r| r.borrow_mut().render_region(&doc, vectorcraft_geom::Rect::new(0.0, 0.0, w, h), ppp, false));
let color = egui::ColorImage::from_rgba_premultiplied([img.width as usize, img.height as usize], &img.pixels);
let tex = ui.ctx().load_texture(format!("preview-{key}"), color, egui::TextureOptions::LINEAR);
CACHE.with(|c| {
let mut c = c.borrow_mut();
if c.len() > 256 {
c.clear();
}
c.insert(key, tex.clone());
});
Some(tex)
}

View File

@ -484,4 +484,33 @@ and flat-colour regions, plus `regions` (bounds, and `id` for objects) for the `
```json
{"name":"run_command","arguments":{"command":"flattener.preview","params":{"highlight":"allRasterized","preset":"low"}}}
## Width profiles
The Profile list holds the built-in variable-width profiles (`uniform`, `lens`, `taperStart`, `taperEnd`, `pinch`,
`teardrop`, `wave`) and profiles saved from strokes; saved ones are kept with the preferences, not in the document
(no undo step). `stroke.widthProfile.list` returns every row (`id`, `label`, `builtIn`, `points` as `[t, left,
right]`) and `current`, the selected stroke's profile (`"custom"` when it isn't listed).
`stroke.widthProfile.add {name?}` saves the selected stroke's variable width (default name "Width Profile N"),
`stroke.widthProfile.delete {name?}` removes a saved one (default: the selected stroke's; built-ins can't be deleted)
and `stroke.widthProfile.reset` removes every saved one. `stroke.set {profile}` takes a built-in id or a saved name.
```json
{"name":"run_command","arguments":{"command":"stroke.widthProfile.add","params":{"name":"Ribbon"}}}
{"name":"run_command","arguments":{"command":"stroke.set","params":{"ids":[9],"profile":"Ribbon"}}}
```
## Arrowheads
`stroke.set {startArrow, endArrow}` takes any of 40 generated heads by name (the params doc lists them; `null` for
none): arrows (`Arrow`, `Barbed`, `Concave`, `DoubleArrow`, `HalfArrowLeft`/`Right`, `Chevron`, `Feather`,
`Swallowtail`…), filled and open shapes (`Triangle`, `Circle`, `Oval`, `Target`, `Tag`, `Diamond`, `Hexagon`, `Star`,
the `…Open` rings…) and marks (`Bar`, `DoubleBar`, `DotOnBar`, `Slash`, `DoubleSlash`, `Bracket`, `Fork`, `Cross`,
`Plus`). Each head fits a box about four times its weight (stroke weight × `arrowScale`) long and wide.
## Stroke bounds and clicks
Visual bounds (Fit to Selected Art, Rasterize, exporting all art) and clicks on the canvas take
in the whole stroke as drawn: arrowheads, inside/outside alignment (an outside stroke is hit outside the path, an
inside one inside it), the width profile's width where you click, projecting caps and miter spikes up to the miter
limit. A rectangle's right-angle miters stay within half the weight of its edges.