M3.62: thirty more generated arrowheads with rendered previews

Arrowhead gains 30 original shapes generated in code, appended after the first
ten so saved names stay stable: Barbed, Half Arrow (left/right), Concave,
Double Arrow, Chevron, Feather, Swallowtail, Reverse Triangle, Triangle to Bar,
Kite, Leaf, Drop, Half Circle, Oval, Target, Tag (+ open), Diamond (open),
Hexagon (+ open), Star, Cross, Plus, Double Bar, Dot on Bar, Slash, Double
Slash, Bracket and Fork. Each is drawn in local units of the head's weight
inside the usual 4 x 4 box, with its own stop for the stroke under it.
Arrowhead::label() names them in the menus. The Stroke panel's arrowhead
dropdowns render every head as the canvas draws it (cached textures, a shared
widgets::doc_preview that the Brushes panel now uses too) in a scrolling list.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
bflatastic 2026-10-05 02:17:05 +07:00
parent 3e73ad8581
commit fdad11a99c
10 changed files with 549 additions and 101 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)] #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum Arrowhead { pub enum Arrowhead {
Triangle, Triangle,
@ -60,21 +61,130 @@ pub enum Arrowhead {
Diamond, Diamond,
Arrow, Arrow,
ArrowOpen, 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 { 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::Arrow,
Arrowhead::ArrowOpen, Arrowhead::ArrowOpen,
Arrowhead::Barbed,
Arrowhead::Concave,
Arrowhead::DoubleArrow,
Arrowhead::HalfArrowLeft,
Arrowhead::HalfArrowRight,
Arrowhead::Chevron,
Arrowhead::Feather,
Arrowhead::Swallowtail,
Arrowhead::Triangle, Arrowhead::Triangle,
Arrowhead::TriangleOpen, Arrowhead::TriangleOpen,
Arrowhead::TriangleReverse,
Arrowhead::TriangleBar,
Arrowhead::Kite,
Arrowhead::Leaf,
Arrowhead::Drop,
Arrowhead::Circle, Arrowhead::Circle,
Arrowhead::CircleOpen, Arrowhead::CircleOpen,
Arrowhead::HalfCircle,
Arrowhead::Oval,
Arrowhead::Target,
Arrowhead::Square, Arrowhead::Square,
Arrowhead::SquareOpen, Arrowhead::SquareOpen,
Arrowhead::Tag,
Arrowhead::TagOpen,
Arrowhead::Diamond, Arrowhead::Diamond,
Arrowhead::DiamondOpen,
Arrowhead::Hexagon,
Arrowhead::HexagonOpen,
Arrowhead::Star,
Arrowhead::Cross,
Arrowhead::Plus,
Arrowhead::Bar, 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 /// Where an arrowhead sits relative to the end of its path. In both modes the stroke stops under
@ -880,6 +990,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)]); 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] #[test]
fn arrow_align_defaults_to_extend_and_round_trips() { fn arrow_align_defaults_to_extend_and_round_trips() {
let mut st = StrokeLayer::new(Paint::solid(Color::BLACK), 2.0); let mut st = StrokeLayer::new(Paint::solid(Color::BLACK), 2.0);

View File

@ -196,4 +196,6 @@ pub(crate) fn segments(els: &[PathEl]) -> Vec<PathSeg> {
#[cfg(test)] #[cfg(test)]
mod tests; mod tests;
#[cfg(test)] #[cfg(test)]
mod tests_arrows;
#[cfg(test)]
mod tests_fit; mod tests_fit;

View File

@ -1,14 +1,16 @@
//! Arrowheads. A head of weight `hw` (stroke weight × scale) is `4·hw` long and `4·hw` wide. //! Arrowheads. A head of weight `hw` (stroke weight × scale) fits a box `4·hw` long and `4·hw`
//! Hollow kinds are rings (`hw/2` walls) or, for the open arrow, a chevron with `hw` arms. The //! wide. Hollow kinds are rings (`hw/2` walls) or, for the open arrow, a chevron with `hw` arms.
//! stroke ends under the head at the head's *inset* from the tip: halfway into solid heads, in //! The stroke ends under the head at the head's *inset* from the tip: halfway into solid heads,
//! the back wall of hollow ones (with the cap kept out of the hole) and at the chevron's inner //! 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 //! 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 //! 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 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 vectorcraft_doc::{ArrowAlign, Arrowhead, StrokeLayer};
use super::{ARCLEN_ACCURACY, StrokePieces, cap_extent, push_seg, segments, subpaths, tangent, unit}; 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), Arrowhead::TriangleOpen | Arrowhead::CircleOpen | Arrowhead::SquareOpen => hollow(l),
// The inner corner of the chevron: arm / sin(half angle). // 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), 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); let t = bar(hw);
polygon(&mut p, &[tip + n * half, tip + n * half - dir * t, tip - n * half - dir * t, tip - n * half]); 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 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

@ -18,7 +18,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Stroke Options", "Stroke Options",
["Window", "Stroke"], ["Window", "Stroke"],
None, 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\"|\"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", "{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, has_doc,
stroke_set stroke_set
), ),

View File

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

View File

@ -1,9 +1,10 @@
//! Stroke panel: weight spinner + presets, cap / corner / align toggles, miter limit, dashed line //! 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 serde_json::{Value, json};
use vectorcraft_doc::{ArrowAlign, Arrowhead, Dash, LineCap, LineJoin, 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 vectorcraft_engine::inspect::StrokeMixed;
use super::{character, current_stroke, pstate, set_pstate, stroke_mixed}; use super::{character, current_stroke, pstate, set_pstate, stroke_mixed};
@ -64,14 +65,8 @@ pub fn dash_pattern(fields: &[Option<f64>; 6]) -> Vec<f64> {
out out
} }
fn arrow_label(a: Option<Arrowhead>) -> String { fn arrow_label(a: Option<Arrowhead>) -> &'static str {
match a { a.map_or("None", Arrowhead::label)
None => "None".into(),
Some(a) => {
let s = format!("{a:?}");
s.replace("Open", " (open)")
}
}
} }
/// Apply Stroke panel options (`stroke.set` params): while the Type tool edits text, to the /// Apply Stroke panel options (`stroke.set` params): while the Type tool edits text, to the
@ -284,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) { fn paint_arrow(ui: &Ui, r: Rect, a: Option<Arrowhead>, start: bool, color: Color32) {
let y = r.center().y; if let Some(tex) = widgets::doc_preview(ui, &format!("arrow:{a:?}:{start}"), r.size(), |w, h| arrow_doc(a, start, w, h)) {
let (tip_x, back) = if start { (r.left() + 4.0, 1.0) } else { (r.right() - 4.0, -1.0) }; ui.painter().image(tex.id(), r, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), color);
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);
} }
/// Dropdown whose button and items show drawn arrowhead previews. Returns Some(choice). /// Dropdown whose button and items show drawn arrowhead previews. Returns Some(choice).
@ -323,28 +321,29 @@ 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 resp = resp.on_hover_text(if start { "Start arrowhead" } else { "End arrowhead" });
let mut out = None; let mut out = None;
egui::Popup::menu(&resp).id(egui::Id::new(("arrow-pop", id))).show(|ui| { egui::Popup::menu(&resp).id(egui::Id::new(("arrow-pop", id))).show(|ui| {
ui.set_min_width(140.0); ui.set_min_width(170.0);
let opts: Vec<Option<Arrowhead>> = std::iter::once(None).chain(Arrowhead::ALL.iter().copied().map(Some)).collect(); egui::ScrollArea::vertical().max_height(360.0).show(ui, |ui| {
for a in opts { for a in std::iter::once(None).chain(Arrowhead::ALL.map(Some)) {
let (row, rr) = ui.allocate_exact_size(vec2(140.0, 22.0), Sense::click()); let (row, rr) = ui.allocate_exact_size(vec2(170.0, 22.0), Sense::click());
if a == cur { if a == cur {
ui.painter().rect_filled(row, 0.0, t.row_selected); ui.painter().rect_filled(row, 0.0, t.row_selected);
} else if rr.hovered() { } else if rr.hovered() {
ui.painter().rect_filled(row, 0.0, t.hover); 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 out
} }

View File

@ -87,3 +87,34 @@ fn dropdown_lists_saved_profiles_and_applies_them() {
click(&ctx, &mut app, text_rect(&t, "Mine").center(), stroke::show); click(&ctx, &mut app, text_rect(&t, "Mine").center(), stroke::show);
assert_eq!(profile_points(&app), Some(custom().points)); 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

@ -1027,3 +1027,33 @@ pub fn drag_source(ui: &Ui, resp: &Response, drag: impl FnOnce() -> PanelDrag) {
egui::DragAndDrop::set_payload(ui.ctx(), drag()); 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

@ -499,3 +499,11 @@ and `stroke.widthProfile.reset` removes every saved one. `stroke.set {profile}`
{"name":"run_command","arguments":{"command":"stroke.widthProfile.add","params":{"name":"Ribbon"}}} {"name":"run_command","arguments":{"command":"stroke.widthProfile.add","params":{"name":"Ribbon"}}}
{"name":"run_command","arguments":{"command":"stroke.set","params":{"ids":[9],"profile":"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.