Stroke: custom arrowheads from an arrowheads file join the arrowhead menus

The Stroke panel menu's Edit Arrowheads… (stroke.arrowheads.edit) opens
Arrowheads.vectorcraft next to the preferences, creating it with one
example head when it is missing. Each symbol in the file is one head,
listed by name after the built-in heads in both arrowhead menus: it
points right, its registration point is the tip, and its height is drawn
as wide as the built-in heads. The file is read at startup and again
each time VectorCraft saves it; stroke.arrowheads.reload and
stroke.arrowheads.list read and list it.

Arrowhead gets a Custom variant, appended after the built-in names, that
carries the head's name and outline, so a document that uses one draws,
saves, reopens and exports it without the arrowheads file. stroke.set
takes {"custom": name} or the {"Custom": {name, path}} form that
document.inspect reports.

Part of #5421.
This commit is contained in:
dotCooCoo
2026-11-11 21:52:46 -08:00
parent 7f3a046b18
commit c1f7e2eeaf
59 changed files with 1143 additions and 59 deletions
+1 -1
View File
@@ -10,7 +10,7 @@ mod stroke_style;
pub use layers::{Effect, FillLayer, StrokeGradientMode, StrokeLayer, stroke_paint_bounds};
pub use profile::{AngleProfile, PressureProfile, ProfilePreset, SavedProfile, WidthProfile};
pub use stroke_style::{ArrowAlign, Arrowhead, Dash, LineCap, LineJoin, StrokeAlign};
pub use stroke_style::{ArrowAlign, Arrowhead, CustomArrowhead, Dash, LineCap, LineJoin, StrokeAlign};
/// One entry of the appearance stack.
// A stroke takes 328 bytes and a fill 104; an appearance holds a few of each.
+4 -2
View File
@@ -193,8 +193,10 @@ impl StrokeLayer {
/// 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, |k| k.reach() * self.arrow_weight(end) + self.width / 2.0)
let head = if end { &self.end_arrow } else { &self.start_arrow };
// A custom head can paint in front of its tip, which Extend puts past the end point.
let reach = |k: &Arrowhead| if self.arrow_align == ArrowAlign::Extend { k.reach().max(k.length()) } else { k.reach() };
head.as_ref().map_or(0.0, |k| reach(k) * 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 {
+96 -5
View File
@@ -1,3 +1,7 @@
use std::sync::Arc;
use vectorcraft_geom::{PathData, Rect};
use super::{Deserialize, Serialize};
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
@@ -53,8 +57,72 @@ impl Dash {
/// (the open arrow's arms are cut square a little behind the box).
const HEAD_REACH: f64 = 4.6;
/// An arrowhead the user drew (Stroke panel menu › Edit Arrowheads…), carried with its art so a
/// document that uses it draws it wherever it opens.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(from = "CustomArrowheadData")]
pub struct CustomArrowhead {
/// The name the arrowhead menus show (the symbol's name in the arrowheads file).
pub name: String,
/// The filled outline (non-zero rule) in head units: x from the tip (0) back along the line, y
/// across it, positive to the right of the direction the head points. A head is 4 units wide,
/// as the built-in heads are, and a unit is the head's weight.
pub path: PathData,
}
#[derive(Deserialize)]
struct CustomArrowheadData {
name: String,
#[serde(default)]
path: PathData,
}
impl From<CustomArrowheadData> for CustomArrowhead {
fn from(d: CustomArrowheadData) -> Self {
CustomArrowhead::new(d.name, d.path)
}
}
impl CustomArrowhead {
/// The most anchors a custom head's outline may have.
pub const MAX_ANCHORS: usize = 4096;
/// How far from the tip a custom head's outline may reach, in head units.
pub const MAX_UNITS: f64 = 64.0;
/// Head `name` with outline `path` (in head units). An outline with more than
/// [`Self::MAX_ANCHORS`] anchors, a point that is not finite, or a point farther than
/// [`Self::MAX_UNITS`] from the tip is dropped: the head then draws nothing.
pub fn new(name: String, path: PathData) -> Self {
let path = if Self::fits(&path) { path } else { PathData::default() };
CustomArrowhead { name, path }
}
/// Whether outline `path` is within [`Self::MAX_ANCHORS`] and [`Self::MAX_UNITS`], with finite
/// points only.
pub fn fits(path: &PathData) -> bool {
let anchors = path.subpaths.iter().map(|s| s.anchors.len()).sum::<usize>();
let near = |p: vectorcraft_geom::Point| p.x.is_finite() && p.y.is_finite() && p.x.abs() <= Self::MAX_UNITS && p.y.abs() <= Self::MAX_UNITS;
anchors <= Self::MAX_ANCHORS && path.subpaths.iter().flat_map(|s| &s.anchors).all(|a| near(a.p) && near(a.h_in) && near(a.h_out))
}
fn bounds(&self) -> Option<Rect> {
self.path.control_bounds()
}
/// How far any part of the head can be from its tip, in head units.
pub fn reach(&self) -> f64 {
self.bounds().map_or(0.0, |b| b.x0.hypot(b.y0).max(b.x1.hypot(b.y0)).max(b.x0.hypot(b.y1)).max(b.x1.hypot(b.y1)))
}
/// How far the head spreads either side of its line, in head units.
pub fn half_width(&self) -> f64 {
self.bounds().map_or(0.0, |b| b.y0.abs().max(b.y1.abs()))
}
/// Where the stroke ends under the head, in head units back from the tip: halfway along the
/// head's length.
pub fn stop(&self) -> f64 {
self.bounds().map_or(0.0, |b| b.x1.max(0.0) / 2.0)
}
}
/// 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, Debug, PartialEq, Serialize, Deserialize)]
pub enum Arrowhead {
Triangle,
TriangleOpen,
@@ -102,6 +170,8 @@ pub enum Arrowhead {
/// more) meeting at right angles in a sharp tip, cut square at their ends. Twice as wide as
/// the other heads.
LineArrow,
/// A head the user drew, with its art.
Custom(Arc<CustomArrowhead>),
}
impl Arrowhead {
@@ -151,23 +221,43 @@ impl Arrowhead {
];
/// How far any part of the head can be from its tip, in units of its weight.
pub fn reach(self) -> f64 {
pub fn reach(&self) -> f64 {
match self {
// Its farthest corners are 4 + ½√2 back and 4 − ½√2 out.
Arrowhead::LineArrow => 5.75,
Arrowhead::Custom(c) => c.reach(),
_ => HEAD_REACH,
}
}
/// How far the head spreads either side of its line, in units of its weight.
pub fn half_width(self) -> f64 {
pub fn half_width(&self) -> f64 {
match self {
Arrowhead::LineArrow => 4.0,
Arrowhead::Custom(c) => c.half_width(),
_ => 2.0,
}
}
/// How far past its end point a head placed with [`ArrowAlign::Extend`] can paint, in units of
/// its weight: the built-in heads' length, or a custom head's reach past its tip, which sits
/// [`CustomArrowhead::stop`] past the end point.
pub fn length(&self) -> f64 {
match self {
Arrowhead::Custom(c) => c.stop() + c.reach(),
_ => 4.0,
}
}
/// The name the arrowhead menus show.
pub fn label(self) -> &'static str {
/// The name the arrowhead menus show: [`Self::label`], or a custom head's own name.
pub fn name(&self) -> &str {
match self {
Arrowhead::Custom(c) => &c.name,
_ => self.label(),
}
}
/// The name the arrowhead menus show for a built-in head ("Custom" for a custom one, whose
/// own name is [`Self::name`]).
pub fn label(&self) -> &'static str {
match self {
Arrowhead::Triangle => "Triangle",
Arrowhead::TriangleOpen => "Triangle (open)",
@@ -210,6 +300,7 @@ impl Arrowhead {
Arrowhead::TriangleBar => "Triangle to Bar",
Arrowhead::Oval => "Oval",
Arrowhead::LineArrow => "Line Arrow",
Arrowhead::Custom(_) => "Custom",
}
}
}
+46 -1
View File
@@ -264,13 +264,58 @@ fn arrowhead_names_are_stable_and_every_one_is_listed_once() {
];
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!(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");
}
/// A custom head: a triangle from its tip back 3 units, 2 units right and 1 left of its line.
fn custom_head() -> Arrowhead {
use vectorcraft_geom::{PathData, Point, SubPath};
let path = PathData::single(SubPath::polyline(&[Point::new(0.0, 0.0), Point::new(3.0, 2.0), Point::new(3.0, -1.0)], true));
Arrowhead::Custom(std::sync::Arc::new(CustomArrowhead::new("Wedge".into(), path)))
}
#[test]
fn custom_arrowheads_carry_their_art_and_measure_it() {
let head = custom_head();
assert_eq!((head.name(), head.label()), ("Wedge", "Custom"));
assert_eq!(head.reach(), 3.0f64.hypot(2.0));
assert_eq!(head.half_width(), 2.0);
let Arrowhead::Custom(c) = &head else { panic!("custom") };
assert_eq!(c.stop(), 1.5, "the line stops halfway along the head");
// Saved with the stroke, art and all, beside the built-in heads' names.
let mut st = StrokeLayer::new(Paint::solid(Color::BLACK), 2.0);
(st.start_arrow, st.end_arrow) = (Some(Arrowhead::Triangle), Some(head.clone()));
let json = serde_json::to_value(&st).unwrap();
assert_eq!(json["start_arrow"], "Triangle");
assert_eq!(json["end_arrow"]["Custom"]["name"], "Wedge");
let back: StrokeLayer = serde_json::from_value(json).unwrap();
assert_eq!(back, st);
// Extended, the tip sits the stop past the end point; on it, only the reach counts.
assert_eq!(head.length(), 1.5 + head.reach());
assert_eq!(st.head_reach(true), head.length() * 2.0 + 1.0);
st.arrow_align = ArrowAlign::Tip;
assert_eq!(st.head_reach(true), head.reach() * 2.0 + 1.0);
}
#[test]
fn custom_arrowheads_out_of_bounds_draw_nothing() {
let Arrowhead::Custom(c) = custom_head() else { panic!("custom") };
let mut path = c.path.clone();
path.subpaths[0].anchors[1] = vectorcraft_geom::Anchor::corner(vectorcraft_geom::Point::new(1e9, 0.0));
// A file can hold what `new` drops: reading it drops it too.
let json = serde_json::to_value(CustomArrowhead { name: "Wedge".into(), path }).unwrap();
let far: CustomArrowhead = serde_json::from_value(json).unwrap();
assert_eq!((far.name.as_str(), far.path.subpaths.len()), ("Wedge", 0));
assert_eq!((far.reach(), far.half_width(), far.stop()), (0.0, 0.0, 0.0));
let anchors = vec![vectorcraft_geom::Anchor::corner(vectorcraft_geom::Point::new(1.0, 0.0)); CustomArrowhead::MAX_ANCHORS + 1];
let many = CustomArrowhead::new("Many".into(), vectorcraft_geom::PathData::single(vectorcraft_geom::SubPath::new(anchors, true)));
assert!(many.path.subpaths.is_empty(), "too many anchors");
}
#[test]
fn arrow_align_defaults_to_extend_and_round_trips() {
let mut st = StrokeLayer::new(Paint::solid(Color::BLACK), 2.0);
+2 -2
View File
@@ -82,8 +82,8 @@ pub(crate) mod skip;
pub use appearance::StrokeGradientMode;
pub use appearance::{
AngleProfile, Appearance, AppearanceItem, ArrowAlign, Arrowhead, Dash, Effect, FillLayer, LineCap, LineJoin, PressureProfile, ProfilePreset,
SavedProfile, StrokeAlign, StrokeLayer, WidthProfile,
AngleProfile, Appearance, AppearanceItem, ArrowAlign, Arrowhead, CustomArrowhead, Dash, Effect, FillLayer, LineCap, LineJoin, PressureProfile,
ProfilePreset, SavedProfile, StrokeAlign, StrokeLayer, WidthProfile,
};
pub use assets::ExportAsset;
pub use compound_shape::ShapeMode;
+4 -2
View File
@@ -391,13 +391,15 @@ impl StrokeLayer {
/// 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 Some(kind) = (if end { self.end_arrow } else { self.start_arrow }) else { return false };
let Some(kind) = (if end { &self.end_arrow } else { &self.start_arrow }) else { return false };
let (hw, v) = (self.arrow_weight(end), p - at);
match self.arrow_align {
ArrowAlign::Tip => v.hypot() <= kind.reach() * hw + tol,
ArrowAlign::Extend => {
let along = v.dot(out);
v.cross(out).abs() <= kind.half_width() * hw + tol && along <= 4.0 * hw + self.width / 2.0 + tol && along >= -kind.reach() * hw - tol
v.cross(out).abs() <= kind.half_width() * hw + tol
&& along <= kind.length() * hw + self.width / 2.0 + tol
&& along >= -kind.reach() * hw - tol
}
}
}
+19 -9
View File
@@ -4,7 +4,8 @@
//! 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. Heads added after the first ten are drawn in local units ([`local`]).
//! past the end point. Heads added after the first ten are drawn in local units ([`local`]), as are
//! custom heads, whose stroke ends halfway along their length.
use std::borrow::Cow;
@@ -37,8 +38,14 @@ pub(super) fn pieces<'a>(bp: &'a BezPath, st: &StrokeLayer) -> StrokePieces<'a>
let last = subs.last().filter(|s| !s.1).map(|s| s.0.clone());
let cap = cap_extent(st.cap, st.width);
let (hw0, hw1) = (st.arrow_weight(false), st.arrow_weight(true));
let start = st.start_arrow.zip(first).map(|(k, r)| (k, r, inset(k, hw0, cap)));
let end = st.end_arrow.zip(last).map(|(k, r)| (k, r, inset(k, hw1, cap)));
let start = st.start_arrow.clone().zip(first).map(|(k, r)| {
let i = inset(&k, hw0, cap);
(k, r, i)
});
let end = st.end_arrow.clone().zip(last).map(|(k, r)| {
let i = inset(&k, hw1, cap);
(k, r, i)
});
if start.is_none() && end.is_none() {
return StrokePieces { line: Cow::Borrowed(bp), heads: vec![] };
}
@@ -56,7 +63,8 @@ pub(super) fn pieces<'a>(bp: &'a BezPath, st: &StrokeLayer) -> StrokePieces<'a>
} else {
(end_pt + outward * inset, outward)
};
heads.push(Arrow { kind, outline: shape(kind, tip, dir, hw), tip, dir, inset });
let outline = shape(&kind, tip, dir, hw);
heads.push(Arrow { kind, outline, tip, dir, inset });
};
if let Some((k, r, i)) = start {
place(k, r, true, i, hw0);
@@ -174,7 +182,7 @@ fn bar(hw: f64) -> f64 {
const NOTCH: f64 = 0.7;
/// Distance from the tip where the stroke ends, for a stroke whose cap reaches `cap` past its end.
fn inset(kind: Arrowhead, hw: f64, cap: f64) -> f64 {
fn inset(kind: &Arrowhead, hw: f64, cap: f64) -> f64 {
let l = len(hw);
// Solid heads: halfway in, far enough that the cap stays behind the tip.
let solid = |back: f64| (back / 2.0).max(cap);
@@ -187,6 +195,7 @@ 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),
Arrowhead::Custom(c) => (c.stop() * hw).max(cap),
_ => match local_stop(kind) {
Stop::At(u) => (u * hw).max(cap),
Stop::Wall(back) => hollow(back * hw),
@@ -195,7 +204,7 @@ fn inset(kind: Arrowhead, hw: f64, cap: f64) -> f64 {
}
/// Outline of a `kind` head of weight `hw` with its tip at `tip`, pointing along `dir`.
fn shape(kind: Arrowhead, tip: Point, dir: Vec2, hw: f64) -> BezPath {
fn shape(kind: &Arrowhead, tip: Point, dir: Vec2, hw: f64) -> BezPath {
let n = Vec2::new(-dir.y, dir.x);
let l = len(hw);
let half = l / 2.0;
@@ -253,7 +262,7 @@ const HEX: f64 = 2.0 * 1.732_050_807_568_877_2;
const LOCAL_TOL: f64 = 1e-3;
/// Where the stroke ends under a head drawn in local units ([`local`]), in those units.
fn local_stop(kind: Arrowhead) -> Stop {
fn local_stop(kind: &Arrowhead) -> Stop {
match kind {
Arrowhead::Target | Arrowhead::TagOpen | Arrowhead::DiamondOpen => Stop::Wall(4.0),
Arrowhead::HexagonOpen => Stop::Wall(HEX),
@@ -276,8 +285,8 @@ fn local_stop(kind: Arrowhead) -> Stop {
/// 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; the line arrow's square-cut arms 4.71), y across it (−2 to 2,
/// the line arrow −4 to 4; positive to the right of the direction the head points). Every shape
/// is generated here.
fn local(kind: Arrowhead) -> BezPath {
/// is generated here, and a custom head's outline is its own.
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));
@@ -373,6 +382,7 @@ fn local(kind: Arrowhead) -> BezPath {
let t = FRAC_1_SQRT_2;
poly(&mut p, &[(0.0, 0.0), (4.0, 4.0), (4.0 + t, 4.0 - t), (2.0 * t, 0.0), (4.0 + t, t - 4.0), (4.0, -4.0)]);
}
Arrowhead::Custom(c) => p = c.path.to_bezpath(),
// The first ten are drawn by `shape`.
Arrowhead::Triangle
| Arrowhead::TriangleOpen
+5 -5
View File
@@ -282,7 +282,7 @@ fn no_heads_borrows_the_path_and_closed_paths_get_none() {
#[test]
fn tip_mode_shortens_the_stroke_by_the_head_and_puts_the_tip_on_the_end_point() {
for kind in Arrowhead::ALL {
let st = arrows(kind, ArrowAlign::Tip, LineCap::Butt);
let st = arrows(kind.clone(), ArrowAlign::Tip, LineCap::Butt);
let l = line(0.0, 100.0);
let p = stroke_pieces(&l, &st);
let [h] = &p.heads[..] else { panic!("one head") };
@@ -337,7 +337,7 @@ fn extend_mode_keeps_the_path_and_overhangs_by_the_inset() {
#[test]
fn hollow_heads_are_rings_and_the_stroke_stays_out_of_the_hole() {
for (kind, cap) in [(Arrowhead::CircleOpen, LineCap::Round), (Arrowhead::SquareOpen, LineCap::Square), (Arrowhead::TriangleOpen, LineCap::Butt)] {
let st = arrows(kind, ArrowAlign::Tip, cap);
let st = arrows(kind.clone(), ArrowAlign::Tip, cap);
let l = line(0.0, 100.0);
let p = stroke_pieces(&l, &st);
let h = &p.heads[0];
@@ -363,7 +363,7 @@ fn hollow_heads_are_rings_and_the_stroke_stays_out_of_the_hole() {
fn round_caps_never_poke_past_the_tip() {
for kind in Arrowhead::ALL {
for scale in [10.0, 100.0, 300.0] {
let mut st = arrows(kind, ArrowAlign::Tip, LineCap::Round);
let mut st = arrows(kind.clone(), ArrowAlign::Tip, LineCap::Round);
st.arrow_scale = (scale, scale);
let l = line(0.0, 100.0);
let p = stroke_pieces(&l, &st);
@@ -405,8 +405,8 @@ fn degenerate_paths_and_patterns_neither_panic_nor_hang() {
for pat in patterns {
for off in [0.0, -7.0, 1e12, f64::INFINITY] {
let st = stroke(2.0, |s| {
s.start_arrow = Some(kind);
s.end_arrow = Some(kind);
s.start_arrow = Some(kind.clone());
s.end_arrow = Some(kind.clone());
s.arrow_align = align;
s.cap = cap;
s.dash = Some(dashed(pat, off));
+29 -2
View File
@@ -50,7 +50,7 @@ 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);
let h = head(kind.clone());
assert!(h.outline.area().abs() > 4.0, "{kind:?} is drawn");
let b = h.outline.bounding_box();
let (reach, side) = (2.0 * kind.reach(), 2.0 * kind.half_width());
@@ -73,7 +73,7 @@ fn every_outline_is_drawn_inside_its_box_without_self_intersections() {
#[test]
fn the_stroke_ends_inside_every_head() {
for kind in Arrowhead::ALL {
let h = head(kind);
let h = head(kind.clone());
// 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);
@@ -112,3 +112,30 @@ fn half_arrows_barb_on_their_own_side() {
assert!(head(Arrowhead::HalfArrowLeft).outline.bounding_box().y1 < 1.5);
assert!(head(Arrowhead::HalfArrowRight).outline.bounding_box().y1 > 3.0);
}
#[test]
fn custom_heads_draw_their_own_art_at_the_tip() {
use vectorcraft_doc::CustomArrowhead;
use vectorcraft_geom::{PathData, SubPath};
// From the tip back 3 units, 2 to the right of the line and 1 to the left.
let wedge = PathData::single(SubPath::polyline(&[Point::new(0.0, 0.0), Point::new(3.0, 2.0), Point::new(3.0, -1.0)], true));
let kind = Arrowhead::Custom(std::sync::Arc::new(CustomArrowhead::new("Wedge".into(), wedge)));
let h = head(kind.clone());
// Weight 2: drawn back to x 94, right of +x is +y on a y-down page.
let b = h.outline.bounding_box();
assert!((b.x0 - 94.0).abs() < 1e-9 && (b.x1 - 100.0).abs() < 1e-9 && (b.y0 + 2.0).abs() < 1e-9 && (b.y1 - 4.0).abs() < 1e-9, "{b:?}");
assert_eq!(h.outline.winding(Point::new(95.0, 1.0)), h.outline.winding(Point::new(95.0, 0.5)));
assert_ne!(h.outline.winding(Point::new(95.0, 1.0)), 0);
// The line stops halfway along the head.
assert!((h.inset - 3.0).abs() < 1e-9, "inset {}", h.inset);
// Extended, the tip sits the inset past the end point, and scaling scales the art.
let mut st = StrokeLayer::new(Paint::solid(Color::BLACK), 2.0);
(st.start_arrow, st.arrow_scale) = (Some(kind), (200.0, 100.0));
let mut l = BezPath::new();
l.move_to((0.0, 0.0));
l.line_to((100.0, 0.0));
let p = stroke_pieces(&l, &st);
let [s] = &p.heads[..] else { panic!("one head") };
assert!((s.tip.x + 6.0).abs() < 1e-9 && s.dir.x < 0.0, "tip {:?}", s.tip);
assert!((s.outline.bounding_box().width() - 12.0).abs() < 1e-9);
}
+15 -2
View File
@@ -99,14 +99,27 @@ fn bounds_and_clicks_cover_what_every_stroke_paints() {
}
}
/// Custom heads: one wholly behind its tip, and a long one reaching as far in front of its tip as
/// behind it (its tip sits halfway along it past the end point with Extend).
fn custom_heads() -> Vec<Arrowhead> {
use vectorcraft_geom::{Point as P, SubPath};
let head = |name: &str, pts: &[(f64, f64)]| {
let pts: Vec<P> = pts.iter().map(|&(x, y)| P::new(x, y)).collect();
let path = PathData::single(SubPath::polyline(&pts, true));
Arrowhead::Custom(std::sync::Arc::new(vectorcraft_doc::CustomArrowhead::new(name.into(), path)))
};
vec![head("Wedge", &[(0.0, 0.0), (3.0, 2.0), (3.0, -1.0)]), head("Spindle", &[(-10.0, 0.0), (0.0, 2.0), (10.0, 0.0), (0.0, -2.0)])]
}
#[test]
fn bounds_and_clicks_cover_every_arrowhead() {
let bp = &paths()[0];
for kind in Arrowhead::ALL {
for kind in Arrowhead::ALL.into_iter().chain(custom_heads()) {
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);
(st.start_arrow, st.end_arrow, st.arrow_align, st.arrow_scale, st.cap) =
(Some(kind.clone()), Some(kind.clone()), align, (scale, scale), cap);
check(bp, &st, &format!("{kind:?} {align:?} {scale}%"));
}
}
+2
View File
@@ -4,6 +4,7 @@ pub mod actionset;
mod addons;
mod apiref;
pub(crate) mod appearance;
pub mod arrowheads;
mod assets;
mod attributes;
pub mod brushlib;
@@ -297,6 +298,7 @@ pub fn command_specs() -> &'static [CommandSpec] {
// Split out of `paint` (M3.8); kept next to it so lists in registry order (the command
// palette, `engine.commands`) show them where they always were.
v.extend(stroke::specs());
v.extend(arrowheads::specs());
v.extend(appearance::specs());
v.extend(style::specs());
v.extend(swatch::specs());
+423
View File
@@ -0,0 +1,423 @@
//! Custom arrowheads (Stroke panel menu › Edit Arrowheads…). The arrowheads file is a native
//! document kept next to the preferences ([`Session::attach_arrowheads_file`]). Each of its symbols
//! is one arrowhead, listed by the symbol's name after the built-in heads in the Stroke panel's
//! arrowhead menus. A head points right (+x): the symbol's registration point is its tip, and the
//! art's height (its extent across the line) is drawn as wide as the built-in heads, four times
//! the head's weight. A head covers what its art paints: the visible fills, and the visible
//! strokes outlined, of the paths, compound paths and groups in it, and its gradient meshes, with
//! their geometry effects applied and clip groups clipped. A head is one solid shape in the stroke's paint: art at 0 %
//! opacity is left out, and other opacities, blend modes and opacity masks do not change the
//! shape. Saving the file reloads the heads. A stroke that uses a custom head carries a copy of its art
//! ([`vectorcraft_doc::CustomArrowhead`]), so its document draws it anywhere.
use std::sync::Arc;
use serde_json::{Value, json};
use vectorcraft_color::{Color, Paint};
use vectorcraft_doc::{Appearance, AppearanceItem, CustomArrowhead, Document, Node, NodeId, NodeKind, Symbol, SymbolRegistration};
use vectorcraft_geom::{Affine, FillRule, PathData, Point, SubPath};
use vectorcraft_pathops::BoolOp;
use super::{CommandSpec, Result, Session, always, bad, cmd};
/// The arrowheads file's name, in the folder of the preferences.
pub const FILE_NAME: &str = "Arrowheads.vectorcraft";
/// The largest arrowheads file read.
pub const MAX_FILE_BYTES: u64 = 64 << 20;
/// The most heads read from the file; symbols past it are skipped.
pub const MAX_HEADS: usize = 256;
/// The most anchors a symbol's art, and the shapes it paints, may have; a symbol past it is skipped.
pub const MAX_ART_ANCHORS: usize = 16 * CustomArrowhead::MAX_ANCHORS;
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(
query "stroke.arrowheads.list",
"Arrowheads",
[],
None,
"{} → {arrowheads: [{name (stroke.set `startArrow`/`endArrow` as {custom: name}), reach, halfWidth (in head weights)}], file: the arrowheads file's path or null (no file here, as on the web), skipped: [{name, reason}] symbols of the file that are not heads, error: why the file could not be read, or null} the custom arrowheads, listed after the built-in ones in the Stroke panel's arrowhead menus",
always,
list
),
cmd!(
"stroke.arrowheads.edit",
"Edit Arrowheads",
[],
None,
"{} open the arrowheads file (a native document next to the preferences; created first with one example head when it is missing). Each symbol in it is one custom arrowhead named after the symbol: it points right, its registration point is the tip, and its height is drawn as wide as the built-in heads; its visible fills and outlined strokes make the head. Saving the file reloads the heads → {path, created: whether the file was made now}",
has_file,
edit
),
cmd!(
"stroke.arrowheads.reload",
"Reload Arrowheads",
[],
None,
"{} read the arrowheads file again (a save of it from VectorCraft does this already) → {count, skipped: [{name, reason}]}",
has_file,
reload
),
]
}
/// The custom arrowheads read from the arrowheads file.
#[derive(Default)]
pub struct Arrowheads {
path: Option<String>,
heads: Vec<Arc<CustomArrowhead>>,
skipped: Vec<(String, String)>,
error: Option<String>,
}
impl Arrowheads {
/// The arrowheads file's path; `None` without one (the web, runs without preferences).
pub fn path(&self) -> Option<&str> {
self.path.as_deref()
}
/// The heads, in the order of the file's symbols.
pub fn heads(&self) -> &[Arc<CustomArrowhead>] {
&self.heads
}
/// Head `name`.
pub fn get(&self, name: &str) -> Option<Arc<CustomArrowhead>> {
self.heads.iter().find(|h| h.name == name).cloned()
}
fn set(&mut self, read: Read) {
(self.heads, self.skipped) = (read.heads.into_iter().map(Arc::new).collect(), read.skipped);
self.error = None;
}
}
/// The heads a document holds, and the symbols that are not heads with the reason.
#[derive(Debug, Default)]
pub struct Read {
pub heads: Vec<CustomArrowhead>,
pub skipped: Vec<(String, String)>,
}
/// The heads of document `doc`: one per symbol, up to [`MAX_HEADS`] (see the module docs).
pub fn heads_of(doc: &Document) -> Read {
let mut d = doc.clone();
let brushes = vectorcraft_brush::library(&d);
let mut out = Read::default();
for (i, sym) in doc.symbols.iter().enumerate() {
if i >= MAX_HEADS {
out.skipped.push((sym.name.clone(), format!("only the first {MAX_HEADS} symbols are read")));
continue;
}
if out.heads.iter().any(|h| h.name == sym.name) {
out.skipped.push((sym.name.clone(), "another symbol has this name".into()));
continue;
}
match head(&mut d, &brushes, sym) {
Ok(h) => out.heads.push(h),
Err(why) => out.skipped.push((sym.name.clone(), why.into())),
}
}
out
}
/// Symbol `sym` of `d` as a head.
fn head(d: &mut Document, brushes: &[vectorcraft_brush::Brush], sym: &Symbol) -> std::result::Result<CustomArrowhead, &'static str> {
const TOO_DETAILED: &str = "its art is too long or too detailed for a head";
if art_anchors(&sym.art) > MAX_ART_ANCHORS {
return Err(TOO_DETAILED);
}
let art = baked_art(d, sym);
let mut shapes = Shapes::default();
painted(d, brushes, &art, &mut shapes);
if shapes.too_detailed {
return Err(TOO_DETAILED);
}
let area = shapes.united();
let b = area.bounds().filter(|b| b.height() > 1e-9 && b.is_finite()).ok_or("its art paints nothing")?;
let tip = b.center() + d.symbol_registration(&sym.name).offset_from_center(b.width(), b.height());
// Head units: x back from the tip, y across to the right of +x, 4 units to the art's height.
let k = 4.0 / b.height();
let local = Affine::new([-k, 0.0, 0.0, k, k * tip.x, -k * tip.y]);
let h = CustomArrowhead::new(sym.name.clone(), area.transformed(local));
if h.path.subpaths.is_empty() {
return Err(TOO_DETAILED);
}
Ok(h)
}
/// The anchors of the paths in `n` and of the pieces of its gradient meshes ([`mesh_anchors`]), its
/// members' included.
fn art_anchors(n: &Node) -> usize {
let own = match &n.kind {
NodeKind::Mesh(m) => mesh_anchors(m),
_ => n.path_data().map_or(0, PathData::anchor_count),
};
n.children().map_or(own, |c| c.iter().fold(own, |sum, c| sum.saturating_add(art_anchors(c))))
}
/// The art of symbol `sym` of `d` with every geometry effect in it applied (its objects', fills',
/// strokes' and groups'), as the canvas draws it: the art baked as for export, on a copy of `d`.
fn baked_art(d: &Document, sym: &Symbol) -> Node {
// A plain new layer, so the file's own layers' effects stay out of the art.
let mut tmp = d.clone();
tmp.layers = Document::new(1.0, 1.0).layers;
let Some(children) = tmp.layers.first_mut().and_then(|l| Arc::make_mut(l).children_mut()) else { return (*sym.art).clone() };
*children = vec![sym.art.clone()];
let baked = vectorcraft_render::effects::bake_document(&tmp);
baked.as_ref().and_then(|b| b.layers.first()?.children()?.first().map(|c| (**c).clone())).unwrap_or_else(|| (*sym.art).clone())
}
/// The shapes a symbol's art paints, up to [`MAX_ART_ANCHORS`] anchors in all.
#[derive(Default)]
struct Shapes {
list: Vec<(PathData, FillRule)>,
anchors: usize,
/// More anchors were asked for than fit; the shapes past the limit were not made.
too_detailed: bool,
}
impl Shapes {
/// Whether `anchors` more fit; when they do not, the shapes are too detailed.
fn room(&mut self, anchors: usize) -> bool {
self.too_detailed |= self.anchors.saturating_add(anchors) > MAX_ART_ANCHORS;
!self.too_detailed
}
fn push(&mut self, shape: (PathData, FillRule)) {
if self.room(shape.0.anchor_count()) {
self.anchors += shape.0.anchor_count();
self.list.push(shape);
}
}
fn united(&self) -> PathData {
let refs: Vec<(&PathData, FillRule)> = self.list.iter().map(|(p, r)| (p, *r)).collect();
vectorcraft_pathops::unite_all(&refs)
}
/// The shapes of `inside` clipped to `outline` (filled under `rule`), added as one.
fn push_clipped(&mut self, inside: Shapes, outline: &PathData, rule: FillRule) {
if inside.too_detailed || !self.room(inside.anchors) {
self.too_detailed = true;
return;
}
let clipped = vectorcraft_pathops::boolean(&inside.united(), FillRule::NonZero, outline, rule, BoolOp::Intersect);
if !clipped.subpaths.is_empty() {
self.push((clipped, FillRule::NonZero));
}
}
}
/// The shapes the shown paths and compound paths in `n` (baked art) paint: their filled areas and
/// their strokes' outlines, its groups' members included (baking has already made a group's own
/// fills and strokes art among its members), and the areas its gradient meshes cover. A clip
/// group's clipped art is clipped to
/// its clipping path, whose own fill and stroke paint unclipped, as on the canvas. Hidden or fully
/// transparent art is left out.
fn painted(d: &mut Document, brushes: &[vectorcraft_brush::Brush], n: &Node, out: &mut Shapes) {
if !n.visible || n.opacity <= 0.0 || out.too_detailed {
return;
}
match &n.kind {
NodeKind::Path { guide: true, .. } => {}
NodeKind::Path { .. } | NodeKind::Compound { .. } => {
fills(n, out);
// The outline holds a copy of the path for each fill too, which adds nothing new.
if let Some(o) = super::pathops::outline_strokes(d, brushes, n) {
fills_in(&o, out);
}
}
NodeKind::Group { children, clip: true } => {
let Some((clip, rest)) = children.split_first() else { return };
painted(d, brushes, clip, out);
// Nothing to clip by hides the clipped art, as on the canvas.
let Some((outline, rule)) = vectorcraft_render::effects::clip_outline(clip) else { return };
let mut inside = Shapes { anchors: out.anchors, ..Default::default() };
rest.iter().for_each(|c| painted(d, brushes, c, &mut inside));
inside.anchors -= out.anchors;
out.push_clipped(inside, &PathData::from_bezpath(&outline), rule);
}
NodeKind::Group { children, .. } | NodeKind::Layer { children, .. } => children.iter().for_each(|c| painted(d, brushes, c, out)),
NodeKind::Mesh(_) => mesh_area(n, out),
_ => {}
}
}
/// The areas the shown paths and gradient meshes in `n` (an outlined stroke) fill, clipped by its
/// clip groups, whose clipping paths' own fills paint unclipped. A stroke with a gradient along or
/// across it is outlined as gradient meshes clipped to the stroke's outline.
fn fills_in(n: &Node, out: &mut Shapes) {
if !n.visible || n.opacity <= 0.0 || out.too_detailed {
return;
}
match &n.kind {
NodeKind::Mesh(_) => mesh_area(n, out),
NodeKind::Group { children, clip: true } => {
let Some((clip, rest)) = children.split_first() else { return };
fills_in(clip, out);
let Some((outline, rule)) = vectorcraft_render::effects::clip_outline(clip) else { return };
let mut inside = Shapes { anchors: out.anchors, ..Default::default() };
rest.iter().for_each(|c| fills_in(c, &mut inside));
inside.anchors -= out.anchors;
out.push_clipped(inside, &PathData::from_bezpath(&outline), rule);
}
_ => match n.children() {
Some(children) => children.iter().for_each(|c| fills_in(c, out)),
None => fills(n, out),
},
}
}
/// The anchors [`mesh_area`] makes at most of mesh `m`: its outline's, one a patch along each side,
/// when no point of it is fully transparent, else four by four quads of four anchors a patch.
fn mesh_anchors(m: &vectorcraft_doc::GradientMesh) -> usize {
let (rows, cols) = (m.rows as usize, m.cols as usize);
if opaque(m) { rows.saturating_add(cols).saturating_mul(2) } else { rows.saturating_mul(cols).saturating_mul(64) }
}
fn opaque(m: &vectorcraft_doc::GradientMesh) -> bool {
m.points.iter().all(|p| p.opacity > 0.0)
}
/// The area gradient mesh `n` paints: its outline when no point of it is fully transparent, else
/// the pieces of it that are not fully transparent (four by four quads a patch). A mesh whose
/// pieces do not fit in `out` makes the shapes too detailed.
fn mesh_area(n: &Node, out: &mut Shapes) {
let NodeKind::Mesh(m) = &n.kind else { return };
if !out.room(mesh_anchors(m)) {
return;
}
if opaque(m) {
let outline = m.outline();
if !outline.subpaths.is_empty() {
out.push((outline, FillRule::NonZero));
}
return;
}
for q in m.quads(4) {
if q.opacity > 0.0 || q.corners.iter().any(|(_, o)| *o > 0.0) {
out.push((PathData::single(SubPath::polyline(&q.pts, true)), FillRule::NonZero));
}
}
}
/// The area path `n` fills, when a shown fill paints it.
fn fills(n: &Node, out: &mut Shapes) {
let paints = |i: &AppearanceItem| matches!(i, AppearanceItem::Fill(f) if f.visible && f.opacity > 0.0 && !f.paint.is_none());
if n.appearance.items.iter().any(paints)
&& let Some(shape) = super::pathops::node_path(n)
{
out.push(shape);
}
}
/// The document a missing arrowheads file starts as: one example head, a solid triangle.
pub fn starter() -> Document {
let mut doc = Document::new(612.0, 792.0);
doc.title = "Arrowheads".into();
let name = "Example Arrowhead";
let tri = SubPath::polyline(&[Point::new(-10.0, -8.0), Point::new(10.0, 0.0), Point::new(-10.0, 8.0)], true);
let art = Node::path(NodeId(0), PathData::single(tri), Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 1.0));
let art = doc.reid(&art);
doc.symbols.push(Symbol { name: name.into(), art: Arc::new(art), dynamic: false });
// The tip is the right edge's middle.
doc.set_symbol_registration(name, SymbolRegistration(5));
let id = doc.alloc_id();
let instance = Node::new(id, NodeKind::SymbolInstance { symbol: name.into(), xf: Affine::translate((306.0, 396.0)) });
let layer = doc.layers.first().map(|l| l.id);
let _ = doc.insert(layer, 0, instance);
doc
}
impl Session {
/// Keep the custom arrowheads in the file at `path` and read the heads it holds (none while it
/// is missing; an unreadable file is reported by `stroke.arrowheads.list`).
pub fn attach_arrowheads_file(&mut self, path: String) {
self.arrowheads.path = Some(path);
self.reload_arrowheads();
}
/// Read the arrowheads file again.
fn reload_arrowheads(&mut self) {
let Some(path) = self.arrowheads.path.clone() else { return };
match read(&path) {
Ok(Some(doc)) => self.arrowheads.set(heads_of(&doc)),
Ok(None) => self.arrowheads.set(Read::default()),
Err(e) => {
self.arrowheads.set(Read::default());
self.arrowheads.error = Some(e);
}
}
}
/// Document `doc` was saved to `path`: when that is the arrowheads file, its heads are the
/// ones listed now.
pub(crate) fn arrowheads_saved(&mut self, path: &str, doc: &Document) {
if self.arrowheads.path.as_deref().is_some_and(|a| same_file(a, path)) {
self.arrowheads.set(heads_of(doc));
}
}
}
fn same_file(a: &str, b: &str) -> bool {
let canon = |p: &str| std::fs::canonicalize(p).unwrap_or_else(|_| std::path::PathBuf::from(p));
std::path::Path::new(a) == std::path::Path::new(b) || canon(a) == canon(b)
}
/// The document in the arrowheads file at `path`; `None` when there is no file.
fn read(path: &str) -> std::result::Result<Option<Document>, String> {
let size = match std::fs::metadata(path) {
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(e) => return Err(format!("{path}: {e}")),
Ok(m) if !m.is_file() => return Err(format!("{path}: not a file")),
Ok(m) => m.len(),
};
if size > MAX_FILE_BYTES {
return Err(format!("{path}: larger than {} MB", MAX_FILE_BYTES >> 20));
}
let bytes = crate::file_access::unconfined(|| super::fileio::read_file(path)).map_err(|e| e.to_string())?;
let parse = || super::fileio::native_file(&bytes).map(|(doc, ..)| doc).map_err(|e| e.to_string());
crate::guard::catch_panic(parse).map_err(|_| format!("{path}: could not be read"))?.map(Some)
}
fn has_file(s: &Session) -> std::result::Result<(), String> {
if s.arrowheads.path.is_some() { Ok(()) } else { Err("no arrowheads file here".into()) }
}
fn skipped(s: &Session) -> Vec<Value> {
s.arrowheads.skipped.iter().map(|(name, reason)| json!({"name": name, "reason": reason})).collect()
}
fn list(s: &mut Session, _: &Value) -> Result<Value> {
let heads: Vec<Value> = s.arrowheads.heads.iter().map(|h| json!({"name": h.name, "reach": h.reach(), "halfWidth": h.half_width()})).collect();
Ok(json!({"arrowheads": heads, "file": s.arrowheads.path, "skipped": skipped(s), "error": s.arrowheads.error}))
}
fn reload(s: &mut Session, _: &Value) -> Result<Value> {
const C: &str = "stroke.arrowheads.reload";
s.reload_arrowheads();
if let Some(e) = &s.arrowheads.error {
return Err(bad(C, e.clone()));
}
Ok(json!({"count": s.arrowheads.heads.len(), "skipped": skipped(s)}))
}
fn edit(s: &mut Session, _: &Value) -> Result<Value> {
const C: &str = "stroke.arrowheads.edit";
let path = s.arrowheads.path.clone().ok_or_else(|| bad(C, "no arrowheads file here"))?;
let created = !std::path::Path::new(&path).exists();
if created {
if let Some(dir) = std::path::Path::new(&path).parent() {
std::fs::create_dir_all(dir).map_err(|e| bad(C, format!("{}: {e}", dir.display())))?;
}
let bytes = vectorcraft_format::save_file(&starter());
crate::file_access::unconfined(|| super::fileio::write_file(&path, &bytes)).map_err(|e| bad(C, e.to_string()))?;
s.reload_arrowheads();
}
// Already open: that document comes to the front.
let open = s.documents().iter().position(|st| st.path.as_deref().is_some_and(|p| same_file(p, &path)));
match open {
Some(i) => {
s.set_active(i);
}
None => {
crate::file_access::unconfined(|| s.execute("document.open", &json!({ "path": path })))?;
}
}
Ok(json!({"path": path, "created": created}))
}
+1 -1
View File
@@ -568,7 +568,7 @@ fn appearance(o: &DimensionOptions, mode: ColorMode, ext: bool, arrows: bool) ->
ArrowStyle::Tick => Some(Arrowhead::Slash),
ArrowStyle::None => None,
};
st.start_arrow = head;
st.start_arrow = head.clone();
st.end_arrow = head;
st.arrow_scale = (o.arrow_scale, o.arrow_scale);
st.arrow_align = vectorcraft_doc::appearance::ArrowAlign::Tip;
+5
View File
@@ -1005,6 +1005,8 @@ impl SaveJob {
}
// The other open documents that place this one show the new version.
let written = self.plan.path.clone().filter(|_| matches!(self.plan.format.id, "vectorcraft" | "template"));
// A save of the arrowheads file lists its heads in the arrowhead menus.
let native = (self.plan.format.id == "vectorcraft").then(|| self.snapshot.clone());
if let Some(st) = s.document_mut(uid) {
self.finish(st);
}
@@ -1012,6 +1014,9 @@ impl SaveJob {
crate::cmd::recovery::forget(s, uid);
}
if let Some(path) = written {
if let Some(doc) = native {
s.arrowheads_saved(&path, &doc);
}
crate::cmd::links::refresh_placed(s, uid, &path);
}
}
@@ -675,5 +675,6 @@ pub(super) const DENIED: &[&str] = &[
"object.blend.options",
"object.liquify",
"object.pathfinder.options",
"stroke.arrowheads.edit",
"type.compositeFonts.set",
];
+25 -6
View File
@@ -4,8 +4,10 @@
use serde_json::{Value, json};
use vectorcraft_color::{Color, Paint};
use vectorcraft_doc::{
ArrowAlign, Arrowhead, CharStyle, Dash, LineCap, LineJoin, Node, NodeKind, SavedProfile, Scaling, StrokeAlign, StrokeLayer, Unit, WidthProfile,
ArrowAlign, Arrowhead, CharStyle, CustomArrowhead, Dash, LineCap, LineJoin, Node, NodeKind, SavedProfile, Scaling, StrokeAlign, StrokeLayer,
Unit, WidthProfile,
};
use vectorcraft_geom::PathData;
use super::appearance::{ItemTarget, edit_items, item_target};
use super::paint::painted;
@@ -20,7 +22,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|LineArrow|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), widthPoints?, gradientMode? (as document.inspect reports them in strokeOptions, so those read back unchanged; ignored here: a custom profile is set with the Width tool, gradientMode with paint.editGradient), 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?|id?, target?: \"object\"|\"contents\", aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection)} Without a stroke item, type takes weight, cap, join, miterLimit, align (around its glyph outlines; the type stays live) 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. With nothing selected (and no ids) they set up the next object drawn (appearance.newArt; weight always does) → {objects: how many objects took the stroke options}",
"{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|LineArrow|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|{custom: name} (a custom arrowhead of the arrowheads file, stroke.arrowheads.list)|{Custom: {name, path}} (a custom head with its art, as document.inspect reports one) (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), widthPoints?, gradientMode? (as document.inspect reports them in strokeOptions, so those read back unchanged; ignored here: a custom profile is set with the Width tool, gradientMode with paint.editGradient), 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?|id?, target?: \"object\"|\"contents\", aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection)} Without a stroke item, type takes weight, cap, join, miterLimit, align (around its glyph outlines; the type stays live) 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. With nothing selected (and no ids) they set up the next object drawn (appearance.newArt; weight always does) → {objects: how many objects took the stroke options}",
has_doc,
stroke_set
),
@@ -222,6 +224,23 @@ fn stroke_set(s: &mut Session, p: &Value) -> Result<Value> {
None => Ok(None),
Some(Value::Null) => Ok(Some(None)),
Some(Value::String(n)) if n == "none" => Ok(Some(None)),
// A custom head by name, from the arrowheads file (stroke.arrowheads.list).
Some(Value::Object(o)) if o.len() == 1 && o.contains_key("custom") => {
let Some(Value::String(name)) = o.get("custom") else { return Err(bad(C, format!("{k}: custom must be a name"))) };
let head = s.arrowheads.get(name).ok_or_else(|| bad(C, format!("no custom arrowhead {name} (see stroke.arrowheads.list)")))?;
Ok(Some(Some(Arrowhead::Custom(head))))
}
// A custom head with its art: an outline the head would drop is refused, not drawn empty.
Some(Value::Object(o)) if o.contains_key("Custom") => {
let raw = o.get("Custom").and_then(|c| c.get("path")).cloned().unwrap_or(Value::Null);
let path: PathData = serde_json::from_value(raw).map_err(|e| bad(C, format!("{k}: bad custom arrowhead path: {e}")))?;
if path.subpaths.iter().all(|sp| sp.anchors.is_empty()) || !CustomArrowhead::fits(&path) {
let (most, far) = (CustomArrowhead::MAX_ANCHORS, CustomArrowhead::MAX_UNITS);
return Err(bad(C, format!("{k}: a custom arrowhead's path needs 1 to {most} anchors, each within {far} head units of its tip")));
}
let head = p.get(k).cloned().unwrap_or(Value::Null);
serde_json::from_value::<Arrowhead>(head).map(|a| Some(Some(a))).map_err(|e| bad(C, format!("{k}: bad custom arrowhead: {e}")))
}
Some(v) => serde_json::from_value::<Arrowhead>(v.clone()).map(|a| Some(Some(a))).map_err(|_| bad(C, format!("unknown arrowhead {v}"))),
}
};
@@ -238,11 +257,11 @@ fn stroke_set(s: &mut Session, p: &Value) -> Result<Value> {
};
let set = |st: &mut StrokeLayer| {
change.apply(st);
if let Some(a) = sa {
st.start_arrow = a;
if let Some(a) = &sa {
st.start_arrow = a.clone();
}
if let Some(a) = ea {
st.end_arrow = a;
if let Some(a) = &ea {
st.end_arrow = a.clone();
}
if let Some(a) = arrow_align {
st.arrow_align = a;
+2
View File
@@ -151,6 +151,8 @@ pub struct Session {
pub symbol_libraries: cmd::symbollib::Libraries,
/// User Defined and loaded brush libraries (Window → Brush Libraries); not saved.
pub brush_libraries: cmd::brushlib::Libraries,
/// The custom arrowheads of the arrowheads file (`stroke.arrowheads.*`); none without one.
pub arrowheads: cmd::arrowheads::Arrowheads,
/// The Libraries panel's libraries of graphics, colours and text styles (`library.*`).
pub libraries: cmd::library::Libraries,
/// Where the saved PDF presets are kept, one file each; `None`: only for the session
+1
View File
@@ -80,6 +80,7 @@ impl Session {
style_libraries: Default::default(),
symbol_libraries: Default::default(),
brush_libraries: Default::default(),
arrowheads: Default::default(),
libraries: Default::default(),
pdf_preset_dir: None,
dictionary_file: None,
+1
View File
@@ -9,6 +9,7 @@ pub(super) mod adjust;
mod ai_open_path;
mod appearance;
mod areafit;
mod arrowheads;
mod assets;
mod assets_3d;
mod attributes;
+252
View File
@@ -0,0 +1,252 @@
//! Custom arrowheads (`cmd/arrowheads.rs`): heads read from the arrowheads file's symbols, listed,
//! set with `stroke.set`, reloaded when the file is saved, and kept with the documents that use
//! them.
use std::sync::Arc;
use serde_json::json;
use vectorcraft_color::{Color, Paint};
use vectorcraft_doc::{Appearance, Arrowhead, Document, Node, StrokeLayer, Symbol, SymbolRegistration};
use vectorcraft_geom::{PathData, Point, SubPath};
use crate::cmd::arrowheads::{self, heads_of};
use crate::*;
fn polygon(pts: &[(f64, f64)], ap: Appearance) -> Node {
let pts: Vec<Point> = pts.iter().map(|&(x, y)| Point::new(x, y)).collect();
Node::path(NodeId(0), PathData::single(SubPath::polyline(&pts, true)), ap)
}
fn add_symbol(d: &mut Document, name: &str, art: Node) {
let art = d.reid(&art);
d.symbols.push(Symbol { name: name.into(), art: Arc::new(art), dynamic: false });
}
fn filled() -> Appearance {
Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 1.0)
}
/// A folder of its own for test `name`, emptied.
fn folder(name: &str) -> std::path::PathBuf {
let dir = std::env::temp_dir().join(format!("vectorcraft-arrowheads-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
dir
}
fn session_with_line() -> (Session, NodeId) {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 200, "height": 200})).unwrap();
let r = s.execute("shape.line", &json!({"x1": 10, "y1": 100, "x2": 150, "y2": 100})).unwrap();
(s, NodeId(r["id"].as_u64().unwrap()))
}
fn stroke(s: &Session, id: NodeId) -> StrokeLayer {
s.doc().unwrap().doc.node(id).unwrap().appearance.stroke().unwrap().clone()
}
#[test]
fn each_symbol_is_a_head_pointing_right_with_its_tip_at_the_registration_point() {
let mut d = Document::new(100.0, 100.0);
// 20 high, so a unit is 5 pt: tip on the right edge's middle.
add_symbol(&mut d, "Wedge", polygon(&[(0.0, -10.0), (20.0, 0.0), (0.0, 10.0)], filled()));
d.set_symbol_registration("Wedge", SymbolRegistration(5));
// A stroke alone paints its outline: a 10 × 4 band, tip at its centre (the default).
let mut line = Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 4.0);
line.stroke_mut().unwrap().cap = vectorcraft_doc::LineCap::Butt;
let band = Node::path(NodeId(0), PathData::single(SubPath::polyline(&[Point::new(0.0, 0.0), Point::new(10.0, 0.0)], false)), line);
add_symbol(&mut d, "Band", band);
// Neither filled nor stroked: no head.
add_symbol(&mut d, "Ghost", polygon(&[(0.0, 0.0), (5.0, 0.0), (5.0, 5.0)], Appearance::default()));
// Far longer than wide.
add_symbol(&mut d, "Needle", polygon(&[(0.0, 0.0), (1000.0, 0.5), (0.0, 1.0)], filled()));
// A fill at 0 % opacity paints nothing.
let mut clear = filled();
if let Some(vectorcraft_doc::AppearanceItem::Fill(f)) = clear.items.first_mut() {
f.opacity = 0.0;
}
add_symbol(&mut d, "Clear", polygon(&[(0.0, 0.0), (5.0, 0.0), (5.0, 5.0)], clear));
// More anchors than a symbol's art may have: skipped before its shapes are united.
let dense: Vec<(f64, f64)> = (0..=arrowheads::MAX_ART_ANCHORS).map(|i| ((i as f64).cos(), (i as f64).sin())).collect();
add_symbol(&mut d, "Dense", polygon(&dense, filled()));
// A 20 × 4 bar clipped to a 4 × 4 square: a 4 × 4 head.
let mut mask = polygon(&[(0.0, 0.0), (4.0, 0.0), (4.0, 4.0), (0.0, 4.0)], Appearance::default());
if let NodeKind::Path { clipping, .. } = &mut mask.kind {
*clipping = true;
}
let bar = polygon(&[(0.0, 0.0), (20.0, 0.0), (20.0, 4.0), (0.0, 4.0)], filled());
add_symbol(&mut d, "Clipped", Node::new(NodeId(0), NodeKind::Group { children: vec![Arc::new(mask), Arc::new(bar)], clip: true }));
// An 8 × 4 bar grown by 2 pt on every side (Offset Path): 12 × 8, so 6 units long.
let mut grown = filled();
grown.effects.push(vectorcraft_doc::Effect { id: "path.offsetPath".into(), params: json!({"offset": 2}), visible: true });
add_symbol(&mut d, "Grown", polygon(&[(0.0, 0.0), (8.0, 0.0), (8.0, 4.0), (0.0, 4.0)], grown));
// The same effect on the fill alone.
let mut fill_grown = filled();
fill_grown.items[0].effects_mut().push(vectorcraft_doc::Effect { id: "path.offsetPath".into(), params: json!({"offset": 2}), visible: true });
add_symbol(&mut d, "Fill Grown", polygon(&[(0.0, 0.0), (8.0, 0.0), (8.0, 4.0), (0.0, 4.0)], fill_grown));
// And on a group holding the bar.
let mut group = Node::group(NodeId(0), vec![Arc::new(polygon(&[(0.0, 0.0), (8.0, 0.0), (8.0, 4.0), (0.0, 4.0)], filled()))]);
group.appearance.effects.push(vectorcraft_doc::Effect { id: "path.offsetPath".into(), params: json!({"offset": 2}), visible: true });
add_symbol(&mut d, "Group Grown", group);
// A clipping path stroked 2 pt: its stroke paints unclipped and does not widen the clip.
let mut frame = polygon(&[(0.0, 0.0), (4.0, 0.0), (4.0, 4.0), (0.0, 4.0)], Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 2.0));
if let NodeKind::Path { clipping, .. } = &mut frame.kind {
*clipping = true;
}
let bar = polygon(&[(0.0, 0.0), (20.0, 0.0), (20.0, 4.0), (0.0, 4.0)], filled());
add_symbol(&mut d, "Framed", Node::new(NodeId(0), NodeKind::Group { children: vec![Arc::new(frame), Arc::new(bar)], clip: true }));
// A group's own fill paints its unpainted 8 × 4 member.
let mut painted_group = Node::group(NodeId(0), vec![Arc::new(polygon(&[(0.0, 0.0), (8.0, 0.0), (8.0, 4.0), (0.0, 4.0)], Appearance::default()))]);
painted_group.appearance = filled().into();
add_symbol(&mut d, "Group Fill", painted_group);
// A 10 × 4 band stroked with a gradient along it.
let gradient = Paint::Gradient(Box::new(vectorcraft_color::GradientPaint::new(vectorcraft_color::Gradient::default())));
let mut along = Appearance::basic(Paint::None, gradient, 4.0);
if let Some(st) = along.stroke_mut() {
(st.cap, st.gradient_mode) = (vectorcraft_doc::LineCap::Butt, vectorcraft_doc::StrokeGradientMode::Along);
}
let band = Node::path(NodeId(0), PathData::single(SubPath::polyline(&[Point::new(0.0, 0.0), Point::new(10.0, 0.0)], false)), along);
add_symbol(&mut d, "Gradient Band", band);
// An 8 × 4 mesh of two patches whose right one is fully transparent: a 4 × 4 head.
let corners = vectorcraft_doc::live::grid_points(vectorcraft_geom::Rect::new(0.0, 0.0, 8.0, 4.0), 1, 2);
let points = corners
.iter()
.map(|&p| vectorcraft_doc::MeshPoint { p, color: Color::BLACK, opacity: if p.x > 1.0 { 0.0 } else { 1.0 }, handles: Default::default() })
.collect();
let mesh = vectorcraft_doc::GradientMesh { rows: 1, cols: 2, points };
add_symbol(&mut d, "Half Mesh", Node::new(NodeId(0), NodeKind::Mesh(mesh)));
// An effect on the file's layer is not part of any symbol.
Arc::make_mut(&mut d.layers[0]).appearance.effects.push(vectorcraft_doc::Effect {
id: "path.offsetPath".into(),
params: json!({"offset": 5}),
visible: true,
});
let read = heads_of(&d);
let names: Vec<&str> = read.heads.iter().map(|h| h.name.as_str()).collect();
assert_eq!(names, ["Wedge", "Band", "Clipped", "Grown", "Fill Grown", "Group Grown", "Framed", "Group Fill", "Gradient Band", "Half Mesh"]);
let b = read.heads[9].path.bounds().unwrap();
assert!((b.width() - 4.0).abs() < 1e-3 && (b.height() - 4.0).abs() < 1e-3, "the mesh's painted patch: {b:?}");
let b = read.heads[7].path.bounds().unwrap();
assert!((b.width() - 8.0).abs() < 1e-3 && (b.height() - 4.0).abs() < 1e-3, "the group's fill: {b:?}");
let (g, plain) = (read.heads[8].path.bounds().unwrap(), read.heads[1].path.bounds().unwrap());
let near = |a: f64, b: f64| (a - b).abs() < 1e-3;
assert!(near(g.x0, plain.x0) && near(g.x1, plain.x1) && near(g.y0, plain.y0) && near(g.y1, plain.y1), "the gradient stroke's outline: {g:?}");
let framed = read.heads[6].path.to_bezpath();
let b = vectorcraft_geom::Shape::bounding_box(&framed);
assert!((b.width() - 4.0).abs() < 1e-3 && (b.height() - 4.0).abs() < 1e-3, "6 × 6 with the stroke: {b:?}");
assert_ne!(vectorcraft_geom::Shape::winding(&framed, Point::new(0.1, 0.1)), 0, "the clipped bar fills the middle");
for h in &read.heads[3..6] {
let b = h.path.bounds().unwrap();
assert!((b.width() - 6.0).abs() < 1e-3 && (b.height() - 4.0).abs() < 1e-3, "{}: the effect's outline: {b:?}", h.name);
}
let skipped: Vec<&str> = read.skipped.iter().map(|(n, _)| n.as_str()).collect();
assert_eq!(skipped, ["Ghost", "Needle", "Clear", "Dense"], "{:?}", read.skipped);
let b = read.heads[2].path.bounds().unwrap();
assert!((b.width() - 4.0).abs() < 1e-3 && (b.height() - 4.0).abs() < 1e-3, "clipped to the square: {b:?}");
let b = read.heads[0].path.bounds().unwrap();
assert!(b.x0.abs() < 1e-6 && (b.x1 - 4.0).abs() < 1e-6 && (b.y0 + 2.0).abs() < 1e-6 && (b.y1 - 2.0).abs() < 1e-6, "{b:?}");
assert!((read.heads[0].reach() - 4.0f64.hypot(2.0)).abs() < 1e-6);
let b = read.heads[1].path.bounds().unwrap();
assert!((b.x0 + 5.0).abs() < 1e-3 && (b.x1 - 5.0).abs() < 1e-3 && (b.y0 + 2.0).abs() < 1e-3 && (b.y1 - 2.0).abs() < 1e-3, "{b:?}");
}
#[test]
fn the_arrowheads_file_is_made_opened_and_reloaded_when_saved() {
let dir = folder("edit");
let file = dir.join(arrowheads::FILE_NAME).to_string_lossy().to_string();
let (mut s, id) = session_with_line();
s.attach_arrowheads_file(file.clone());
let list = s.execute("stroke.arrowheads.list", &json!({})).unwrap();
assert_eq!((list["arrowheads"].as_array().unwrap().len(), list["file"].as_str()), (0, Some(file.as_str())));
assert!(s.execute("stroke.set", &json!({"endArrow": {"custom": "Example Arrowhead"}})).is_err(), "no such head yet");
let line_doc = s.doc().unwrap().uid;
let r = s.execute("stroke.arrowheads.edit", &json!({})).unwrap();
assert_eq!(r["created"], true);
assert_eq!(s.doc().unwrap().path.as_deref(), Some(file.as_str()), "the file is the active document");
let list = s.execute("stroke.arrowheads.list", &json!({})).unwrap();
assert_eq!(list["arrowheads"][0]["name"], "Example Arrowhead");
// A second head drawn in the file is listed once the file is saved.
let st = s.doc_mut().unwrap();
let d = Arc::make_mut(&mut st.doc);
add_symbol(d, "Square", polygon(&[(0.0, 0.0), (4.0, 0.0), (4.0, 4.0), (0.0, 4.0)], filled()));
assert_eq!(s.execute("stroke.arrowheads.list", &json!({})).unwrap()["arrowheads"].as_array().unwrap().len(), 1);
s.execute("document.save", &json!({})).unwrap();
let list = s.execute("stroke.arrowheads.list", &json!({})).unwrap();
let names: Vec<&str> = list["arrowheads"].as_array().unwrap().iter().filter_map(|h| h["name"].as_str()).collect();
assert_eq!(names, ["Example Arrowhead", "Square"]);
// Editing again from the line's document brings the open file to the front, without making
// it anew or opening it twice.
s.execute("document.activate", &json!({"index": 0})).unwrap();
assert_eq!(s.doc().unwrap().uid, line_doc);
assert_eq!(s.execute("stroke.arrowheads.edit", &json!({})).unwrap()["created"], false);
assert_eq!((s.documents().len(), s.doc().unwrap().path.as_deref()), (2, Some(file.as_str())));
// The line takes the head with its art.
s.execute("document.activate", &json!({"index": 0})).unwrap();
s.execute("stroke.set", &json!({"ids": [id.0], "endArrow": {"custom": "Square"}})).unwrap();
let Some(Arrowhead::Custom(c)) = stroke(&s, id).end_arrow else { panic!("a custom head") };
assert_eq!(c.name, "Square");
assert!(s.execute("stroke.set", &json!({"endArrow": {"custom": 3}})).is_err());
assert!(s.execute("stroke.set", &json!({"endArrow": {"custom": "Circle"}})).is_err());
// A head given with its art is refused when the art could not be drawn.
let mut far = serde_json::to_value(Arrowhead::Custom(c.clone())).unwrap();
far["Custom"]["path"] = serde_json::to_value(PathData::single(SubPath::polyline(&[Point::new(0.0, 0.0), Point::new(1e6, 1.0)], true))).unwrap();
assert!(s.execute("stroke.set", &json!({"ids": [id.0], "endArrow": far})).is_err());
let mut empty = serde_json::to_value(Arrowhead::Custom(c)).unwrap();
empty["Custom"]["path"] = json!({"subpaths": []});
assert!(s.execute("stroke.set", &json!({"ids": [id.0], "endArrow": empty})).is_err());
assert!(matches!(stroke(&s, id).end_arrow, Some(Arrowhead::Custom(h)) if h.name == "Square"), "the head stays as it was");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn a_document_keeps_its_custom_heads_without_the_arrowheads_file() {
let (mut s, id) = session_with_line();
let mut d = Document::new(100.0, 100.0);
add_symbol(&mut d, "Wedge", polygon(&[(0.0, -10.0), (20.0, 0.0), (0.0, 10.0)], filled()));
let head = Arrowhead::Custom(Arc::new(heads_of(&d).heads.remove(0)));
// As document.inspect reports it, so it reads back unchanged.
s.execute("stroke.set", &json!({"startArrow": head, "endArrow": "Triangle"})).unwrap();
assert_eq!(stroke(&s, id).start_arrow, Some(head.clone()));
let inspect = s.execute("document.inspect", &json!({})).unwrap().to_string();
assert!(inspect.contains("\"Wedge\""), "inspect names the head");
let ink = |s: &Session| {
vectorcraft_render::Renderer::new().render(&s.doc().unwrap().doc, 200, 200, vectorcraft_geom::Affine::IDENTITY, &Default::default())
};
let with_head = ink(&s);
// Saved and opened in a session that has no arrowheads file: the head is drawn the same.
let saved = s.execute("document.export", &json!({"format": "vectorcraft"})).unwrap()["dataBase64"].clone();
let mut o = Session::new();
o.execute("document.open", &json!({"name": "a.vectorcraft", "dataBase64": saved})).unwrap();
assert_eq!(stroke(&o, id).start_arrow, Some(head));
assert_eq!(ink(&o).pixels, with_head.pixels);
// SVG and PDF write it as they write the built-in heads.
let svg = |s: &mut Session| s.execute("document.export", &json!({"format": "svg"})).unwrap()["dataBase64"].as_str().unwrap().len();
let headed = svg(&mut o);
o.execute("stroke.set", &json!({"ids": [id.0], "startArrow": null})).unwrap();
assert!(headed > svg(&mut o), "the head is in the SVG");
// Undo puts it back.
o.execute("edit.undo", &json!({})).unwrap();
assert!(matches!(stroke(&o, id).start_arrow, Some(Arrowhead::Custom(_))));
}
#[test]
fn an_unreadable_arrowheads_file_lists_no_heads_and_says_why() {
let dir = folder("bad");
let file = dir.join(arrowheads::FILE_NAME);
std::fs::write(&file, b"not a drawing").unwrap();
let mut s = Session::new();
s.attach_arrowheads_file(file.to_string_lossy().to_string());
let list = s.execute("stroke.arrowheads.list", &json!({})).unwrap();
assert!(list["arrowheads"].as_array().unwrap().is_empty());
assert!(list["error"].is_string(), "{list}");
assert!(s.execute("stroke.arrowheads.reload", &json!({})).is_err());
// A folder in its place is not read either.
std::fs::remove_file(&file).unwrap();
std::fs::create_dir(&file).unwrap();
assert!(s.execute("stroke.arrowheads.reload", &json!({})).is_err());
let _ = std::fs::remove_dir_all(&dir);
// Without a file (the web, runs without preferences) there is nothing to edit.
let mut bare = Session::new();
assert!(bare.execute("stroke.arrowheads.edit", &json!({})).is_err());
assert_eq!(bare.execute("stroke.arrowheads.list", &json!({})).unwrap()["file"], serde_json::Value::Null);
}
+1 -1
View File
@@ -2109,7 +2109,7 @@ fn empty_artboard_checks_match_the_visual_bounds_on_varied_strokes() {
st.cap = [LineCap::Butt, LineCap::Round, LineCap::Square][next(3) as usize];
st.align = [StrokeAlign::Center, StrokeAlign::Inside, StrokeAlign::Outside][next(3) as usize];
st.miter_limit = 1.0 + next(20) as f64;
let mut head = || (next(3) == 0).then(|| Arrowhead::ALL[next(Arrowhead::ALL.len() as u64) as usize]);
let mut head = || (next(3) == 0).then(|| Arrowhead::ALL[next(Arrowhead::ALL.len() as u64) as usize].clone());
(st.start_arrow, st.end_arrow) = (head(), head());
st.arrow_scale = (50.0 + next(250) as f64, 50.0 + next(250) as f64);
if next(3) == 0 {
+1 -1
View File
@@ -51,7 +51,7 @@ fn profiles_come_from_the_preset_catalogue() {
assert!(spec.params.contains("arrowAlign"));
// …and every arrowhead name `startArrow`/`endArrow` accept.
for a in vectorcraft_doc::Arrowhead::ALL {
let name = serde_json::to_value(a).unwrap();
let name = serde_json::to_value(&a).unwrap();
assert!(spec.params.contains(name.as_str().unwrap()), "{name}");
s.execute("stroke.set", &json!({"endArrow": name})).unwrap();
assert_eq!(stroke(&s, id).end_arrow, Some(a));
+1 -1
View File
@@ -52,7 +52,7 @@ pub(crate) fn painted_boxes(d: &Document) -> (Vec<Rect>, Vec<Rect>) {
fn arrowheads_and_the_trimmed_line_match_the_shared_geometry() {
for align in [ArrowAlign::Extend, ArrowAlign::Tip] {
for kind in Arrowhead::ALL {
let (d, n) = line_doc(arrow_stroke(kind, align, 1.0));
let (d, n) = line_doc(arrow_stroke(kind.clone(), align, 1.0));
let bp = n.path_data().unwrap().to_bezpath();
let st = n.appearance.stroke().unwrap();
let pieces = stroke_pieces(&bp, st);
+1 -1
View File
@@ -300,7 +300,7 @@ fn arrowheads_add_ink_at_the_ends() {
// Every arrowhead kind renders.
for a in Arrowhead::ALL {
let mut b = DocBuilder::new(100.0, 100.0);
b.path(line(), stroke_only(2.0, |s| s.end_arrow = Some(a)), |_| {});
b.path(line(), stroke_only(2.0, |s| s.end_arrow = Some(a.clone())), |_| {});
assert!(render(&b.build()).ink() > plain.ink(), "{a:?}");
}
}
+1 -1
View File
@@ -114,7 +114,7 @@ fn a_hollow_circle_head_is_empty_inside() {
fn heads_are_drawn_where_the_shared_geometry_puts_them_and_never_past_the_tip() {
for align in [ArrowAlign::Extend, ArrowAlign::Tip] {
for kind in Arrowhead::ALL {
let d = arrow_doc(kind, align, 1.0, |s| s.cap = LineCap::Round);
let d = arrow_doc(kind.clone(), align, 1.0, |s| s.cap = LineCap::Round);
let h = head_of(&d);
if align == ArrowAlign::Tip {
assert_eq!(h.tip, Point::new(80.0, 50.0));
+4
View File
@@ -2759,6 +2759,10 @@ digits Malayalam Малаялам
Search Preferences Търсене в настройките
No settings found Няма намерени настройки
Reset Profiles Нулиране на профилите
Arrowheads Върхове на стрелки
Edit Arrowheads Редактиране на върхове на стрелки
Edit Arrowheads… Редактиране на върхове на стрелки…
Reload Arrowheads Презареждане на върхове на стрелки
Reset Rotate View Нулиране на изгледа на завъртане
Rotate View to Selection Завъртане на изгледа спрямо селекцията
Reset the rows to the artwork's colors Нулиране на редовете към цветовете на илюстрацията
Can't render this file because it contains an unexpected character in line 2 and column 73.
+4
View File
@@ -3819,6 +3819,10 @@ theme Appearance Izgled
Add to Profiles Dodaj u profile
Delete Profile Izbriši profil
Reset Profiles Vrati profile na početno
Arrowheads Vrhovi strelica
Edit Arrowheads Uredi vrhove strelica
Edit Arrowheads… Uredi vrhove strelica…
Reload Arrowheads Ponovo učitaj vrhove strelica
Stroke Options Opcije poteza
Width Profiles Profili širine
Apply Graphic Style Primijeni grafički stil
Can't render this file because it contains an unexpected character in line 2 and column 73.
+4
View File
@@ -3507,6 +3507,10 @@ digits Malayalam Malaiàlam
No settings found No s'ha trobat cap paràmetre
Reset Printing and Proofing Restableix impressió i prova
Reset Profiles Restableix els perfils
Arrowheads Puntes de fletxa
Edit Arrowheads Edita les puntes de fletxa
Edit Arrowheads… Edita les puntes de fletxa…
Reload Arrowheads Torna a carregar les puntes de fletxa
Reset Rotate View Restableix la rotació de la vista
Reset Ruler Origin Restableix l’origen de les regles
Reset the rows to the artwork's colors Restableix les files als colors de l’obra
Can't render this file because it contains an unexpected character in line 2 and column 73.
+4
View File
@@ -3870,6 +3870,10 @@ theme Appearance Motiv
Add to Profiles Přidat do profilů
Delete Profile Odstranit profil
Reset Profiles Obnovit profily
Arrowheads Šipky
Edit Arrowheads Upravit šipky
Edit Arrowheads… Upravit šipky…
Reload Arrowheads Znovu načíst šipky
Stroke Options Možnosti tahu
Width Profiles Profily šířky
Apply Graphic Style Použít grafický styl
Can't render this file because it contains an unexpected character in line 2 and column 73.
+4
View File
@@ -3594,6 +3594,10 @@ theme Appearance Udseende
Add to Profiles Føj til profiler
Delete Profile Slet profil
Reset Profiles Nulstil profiler
Arrowheads Pilespidser
Edit Arrowheads Redigér pilespidser
Edit Arrowheads… Redigér pilespidser…
Reload Arrowheads Genindlæs pilespidser
Stroke Options Konturindstillinger
Width Profiles Breddeprofiler
Apply Graphic Style Anvend grafikstil
Can't render this file because it contains an unexpected character in line 2 and column 73.
+4
View File
@@ -2791,6 +2791,10 @@ digits Malayalam Malayalam
Search Preferences Voreinstellungen durchsuchen
No settings found Keine Einstellungen gefunden
Reset Profiles Profile zurücksetzen
Arrowheads Pfeilspitzen
Edit Arrowheads Pfeilspitzen bearbeiten
Edit Arrowheads… Pfeilspitzen bearbeiten…
Reload Arrowheads Pfeilspitzen neu laden
Rotate View Ansicht drehen
Reset Rotate View Ansichtsdrehung zurücksetzen
Rotate View to Selection Ansicht nach Auswahl drehen
Can't render this file because it contains an unexpected character in line 2 and column 73.
+4
View File
@@ -3423,6 +3423,10 @@ digits Malayalam Μαλαγιάλαμ
Reset OpenType Features Επαναφορά χαρακτηριστικών OpenType
Reset Panel Επαναφορά πίνακα
Reset Profiles Επαναφορά προφίλ
Arrowheads Αιχμές βελών
Edit Arrowheads Επεξεργασία αιχμών βελών
Edit Arrowheads… Επεξεργασία αιχμών βελών…
Reload Arrowheads Επαναφόρτωση αιχμών βελών
Reset Rotate View Επαναφορά περιστροφής προβολής
Rotate View to Selection Περιστροφή προβολής ως προς την επιλογή
Reset the rows to the artwork's colors Επαναφορά γραμμών στα χρώματα του σχεδίου
Can't render this file because it is too large.
+4
View File
@@ -2833,6 +2833,10 @@ digits Malayalam Malayálam
Search Preferences Buscar en preferencias
No settings found No se encontraron ajustes
Reset Profiles Restablecer perfiles
Arrowheads Puntas de flecha
Edit Arrowheads Editar puntas de flecha
Edit Arrowheads… Editar puntas de flecha…
Reload Arrowheads Volver a cargar puntas de flecha
Reset Rotate View Restablecer rotación de vista
Rotate View to Selection Rotar vista según la selección
Reset the rows to the artwork's colors Restablecer las filas a los colores de la ilustración
Can't render this file because it contains an unexpected character in line 2 and column 73.
+4
View File
@@ -3794,6 +3794,10 @@ theme Appearance Ulkoasu
Add to Profiles Lisää profiileihin
Delete Profile Poista profiili
Reset Profiles Palauta profiilit
Arrowheads Nuolenkärjet
Edit Arrowheads Muokkaa nuolenkärkiä
Edit Arrowheads… Muokkaa nuolenkärkiä…
Reload Arrowheads Lataa nuolenkärjet uudelleen
Stroke Options Viivan asetukset
Width Profiles Leveysprofiilit
Apply Graphic Style Käytä grafiikkatyyliä
Can't render this file because it contains an unexpected character in line 2 and column 73.
+4
View File
@@ -2825,6 +2825,10 @@ digits Malayalam Malayalam
Search Preferences Rechercher dans les préférences
No settings found Aucun réglage trouvé
Reset Profiles Réinitialiser les profils
Arrowheads Pointes de flèche
Edit Arrowheads Modifier les pointes de flèche
Edit Arrowheads… Modifier les pointes de flèche…
Reload Arrowheads Recharger les pointes de flèche
Reset Rotate View Réinitialiser la rotation de l’affichage
Rotate View to Selection Faire pivoter l’affichage selon la sélection
Reset the rows to the artwork's colors Réinitialiser les lignes avec les couleurs de l’illustration
Can't render this file because it contains an unexpected character in line 2 and column 73.
+4
View File
@@ -3811,6 +3811,10 @@ antiAlias Sharp शार्प
No settings found कोई सेटिंग नहीं मिली
Reset Printing and Proofing मुद्रण और प्रूफिंग रीसेट करें
Reset Profiles प्रोफ़ाइल रीसेट करें
Arrowheads एरोहेड्स
Edit Arrowheads एरोहेड्स संपादित करें
Edit Arrowheads… एरोहेड्स संपादित करें…
Reload Arrowheads एरोहेड्स फिर से लोड करें
Reset Rotate View व्यू घुमाएँ रीसेट करें
Reset Ruler Origin शासकीय उत्पत्ति रीसेट करें
Reset Symbol Instance सिंबल इंस्टेंस रीसेट करें
Can't render this file because it is too large.
+4
View File
@@ -3377,6 +3377,10 @@ mark-style Japanese Japán
No settings found Nincs ilyen beállítás
Reset Printing and Proofing Printing and Proofing visszaállítása
Reset Profiles Profilok visszaállítása
Arrowheads Nyílhegyek
Edit Arrowheads Nyílhegyek szerkesztése
Edit Arrowheads… Nyílhegyek szerkesztése…
Reload Arrowheads Nyílhegyek újratöltése
Reset Rotate View Nézetforgatás visszaállítása
Rotate View to Selection Nézet elforgatása a kijelöléshez
Reset the rows to the artwork's colors Sorok visszaállítása a grafika színeire
Can't render this file because it contains an unexpected character in line 2 and column 73.
+4
View File
@@ -3594,6 +3594,10 @@ theme Appearance Tampilan
Add to Profiles Tambahkan ke profil
Delete Profile Hapus profil
Reset Profiles Atur ulang profil
Arrowheads Mata panah
Edit Arrowheads Edit mata panah
Edit Arrowheads… Edit mata panah…
Reload Arrowheads Muat ulang mata panah
Stroke Options Opsi goresan
Width Profiles Profil lebar
Apply Graphic Style Terapkan gaya grafis
Can't render this file because it contains an unexpected character in line 2328 and column 5.
+4
View File
@@ -2867,6 +2867,10 @@ digits Malayalam Malayalam
Search Preferences Cerca nelle preferenze
No settings found Nessuna impostazione trovata
Reset Profiles Reimposta profili
Arrowheads Punte di freccia
Edit Arrowheads Modifica punte di freccia
Edit Arrowheads… Modifica punte di freccia…
Reload Arrowheads Ricarica punte di freccia
Rotate View Ruota vista
Reset Rotate View Reimposta rotazione vista
Rotate View to Selection Ruota vista in base alla selezione
Can't render this file because it contains an unexpected character in line 2 and column 73.
+4
View File
@@ -2864,6 +2864,10 @@ mark-style Japanese 日本式
Search Preferences 環境設定を検索
No settings found 設定が見つかりません
Reset Profiles プロファイルをリセット
Arrowheads 矢印
Edit Arrowheads 矢印を編集
Edit Arrowheads… 矢印を編集…
Reload Arrowheads 矢印を再読み込み
Reset the rows to the artwork's colors 行をアートワークのカラーにリセット
Reset to Default 初期設定に戻す
Reset to White, Black 白、黒にリセット
Can't render this file because it contains an unexpected character in line 2 and column 73.
+4
View File
@@ -3433,6 +3433,10 @@ theme Appearance იერსახე
Add to Profiles პროფილებში დამატება
Delete Profile პროფილის წაშლა
Reset Profiles პროფილების ჩამოყრა
Arrowheads ისრის წვერები
Edit Arrowheads ისრის წვერების რედაქტირება
Edit Arrowheads… ისრის წვერების რედაქტირება…
Reload Arrowheads ისრის წვერების ხელახლა ჩატვირთვა
Stroke Options კონტურის პარამეტრები
Width Profiles სიგანის პროფილები
Apply Graphic Style გრაფიკული სტილის გამოყენება
Can't render this file because it is too large.
+4
View File
@@ -2853,6 +2853,10 @@ mark-style Japanese 일본식
Search Preferences 환경 설정 검색
No settings found 설정을 찾을 수 없음
Reset Profiles 프로필 재설정
Arrowheads 화살촉
Edit Arrowheads 화살촉 편집
Edit Arrowheads… 화살촉 편집…
Reload Arrowheads 화살촉 다시 불러오기
Reset the rows to the artwork's colors 행을 아트웍 색상으로 재설정
Reset to Default 기본값으로 재설정
Reset to White, Black 흰색, 검정으로 재설정
Can't render this file because it contains an unexpected character in line 2 and column 73.
+4
View File
@@ -3608,6 +3608,10 @@ theme Appearance Vormgeving
Add to Profiles Aan profielen toevoegen
Delete Profile Profiel verwijderen
Reset Profiles Profielen opnieuw instellen
Arrowheads Pijlpunten
Edit Arrowheads Pijlpunten bewerken
Edit Arrowheads… Pijlpunten bewerken…
Reload Arrowheads Pijlpunten opnieuw laden
Stroke Options Lijnopties
Width Profiles Breedteprofielen
Apply Graphic Style Grafische stijl toepassen
Can't render this file because it contains an unexpected character in line 2 and column 73.
+4
View File
@@ -3733,6 +3733,10 @@ theme Appearance Wygląd
Add to Profiles Dodaj do profili
Delete Profile Usuń profil
Reset Profiles Resetuj profile
Arrowheads Groty strzałek
Edit Arrowheads Edytuj groty strzałek
Edit Arrowheads… Edytuj groty strzałek…
Reload Arrowheads Wczytaj ponownie groty strzałek
Stroke Options Opcje obrysu
Width Profiles Profile szerokości
Apply Graphic Style Zastosuj styl graficzny
Can't render this file because it contains an unexpected character in line 2 and column 73.
+4
View File
@@ -2370,6 +2370,10 @@ digits Malayalam Malaiala
Search Preferences Pesquisar nas Preferências
No settings found Nenhuma configuração encontrada
Reset Profiles Redefinir Perfis
Arrowheads Pontas de Seta
Edit Arrowheads Editar Pontas de Seta
Edit Arrowheads… Editar Pontas de Seta…
Reload Arrowheads Recarregar Pontas de Seta
Rotate View Girar Visualização
Reset Rotate View Redefinir Visualização Girada
Rotate View to Selection Girar Visualização para a Seleção
Can't render this file because it contains an unexpected character in line 6 and column 47.
+4
View File
@@ -2862,6 +2862,10 @@ digits Malayalam Малаялам
Search Preferences Поиск в настройках
No settings found Настройки не найдены
Reset Profiles Сбросить профили
Arrowheads Стрелки
Edit Arrowheads Редактировать стрелки
Edit Arrowheads… Редактировать стрелки…
Reload Arrowheads Перезагрузить стрелки
Rotate View Повернуть вид
Reset Rotate View Сбросить поворот вида
Rotate View to Selection Повернуть вид по выделению
Can't render this file because it is too large.
+4
View File
@@ -2769,6 +2769,10 @@ digits Malayalam Malayalam
Search Preferences Sök i inställningar
No settings found Inga inställningar hittades
Reset Profiles Återställ profiler
Arrowheads Pilspetsar
Edit Arrowheads Redigera pilspetsar
Edit Arrowheads… Redigera pilspetsar…
Reload Arrowheads Läs in pilspetsar igen
Reset Rotate View Återställ Rotera vy
Rotate View to Selection Rotera vyn efter markeringen
Reset the rows to the artwork's colors Återställ raderna till teckningens färger
Can't render this file because it contains an unexpected character in line 5 and column 25.
+4
View File
@@ -2638,6 +2638,10 @@ digits Malayalam Malayalam
Search Preferences Tercihlerde Ara
No settings found Ayar bulunamadı
Reset Profiles Profilleri Sıfırla
Arrowheads Ok Uçları
Edit Arrowheads Ok Uçlarını Düzenle
Edit Arrowheads… Ok Uçlarını Düzenle…
Reload Arrowheads Ok Uçlarını Yeniden Yükle
Reset Rotate View Döndürme Görünümünü Sıfırla
Rotate View to Selection Görünümü Seçime Döndür
Reset the rows to the artwork's colors Satırları resmin renklerine göre sıfırlayın
Can't render this file because it contains an unexpected character in line 5 and column 25.
+4
View File
@@ -2859,6 +2859,10 @@ digits Malayalam Малаялам
Search Preferences Пошук у налаштуваннях
No settings found Налаштування не знайдено
Reset Profiles Скинути профілі
Arrowheads Стрілки
Edit Arrowheads Редагувати стрілки
Edit Arrowheads… Редагувати стрілки…
Reload Arrowheads Перезавантажити стрілки
Rotate View Повернути вид
Reset Rotate View Скинути поворот виду
Rotate View to Selection Повернути вид за виділенням
Can't render this file because it contains an unexpected character in line 182 and column 5.
+4
View File
@@ -3140,6 +3140,10 @@ mark-style Japanese Kiểu Nhật
Search Preferences Tìm trong tùy chọn
No settings found Không tìm thấy cài đặt
Reset Profiles Đặt lại hồ sơ màu
Arrowheads Đầu mũi tên
Edit Arrowheads Chỉnh sửa đầu mũi tên
Edit Arrowheads… Chỉnh sửa đầu mũi tên…
Reload Arrowheads Tải lại đầu mũi tên
Rotate View Xoay chế độ xem
Reset Rotate View Đặt lại xoay chế độ xem
Rotate View to Selection Xoay chế độ xem theo vùng chọn
Can't render this file because it contains an unexpected character in line 204 and column 5.
+4
View File
@@ -2733,6 +2733,10 @@ digits Malayalam 马拉雅拉姆数字
Search Preferences 搜索首选项
No settings found 未找到设置
Reset Profiles 重置配置文件
Arrowheads 箭头
Edit Arrowheads 编辑箭头
Edit Arrowheads… 编辑箭头…
Reload Arrowheads 重新载入箭头
Reset Rotate View 重置旋转视图
Rotate View to Selection 按所选对象旋转视图
Reset the rows to the artwork's colors 将各行重置为图稿的颜色
Can't render this file because it contains an unexpected character in line 2 and column 73.
+4
View File
@@ -3811,6 +3811,10 @@ digits Malayalam 馬來亞拉姆數字
Search Preferences 搜尋偏好設定
No settings found 找不到設定
Reset Profiles 重設描述檔
Arrowheads 箭頭
Edit Arrowheads 編輯箭頭
Edit Arrowheads… 編輯箭頭…
Reload Arrowheads 重新載入箭頭
Rotate View 旋轉檢視
Reset Rotate View 重設旋轉檢視
Rotate View to Selection 依選取範圍旋轉檢視
Can't render this file because it contains an unexpected character in line 6 and column 47.
+51 -14
View File
@@ -100,10 +100,37 @@ pub(crate) fn edited_dash_pattern(fields: &[Option<f64>; 6], previous: &[f64]) -
out
}
fn arrow_label(a: Option<Arrowhead>) -> &'static str {
fn arrow_label(a: Option<&Arrowhead>) -> &'static str {
a.map_or("None", Arrowhead::label)
}
/// The name an arrowhead menu shows for `a`: a built-in head's in the interface language, a
/// custom head's own.
fn arrow_name(a: Option<&Arrowhead>) -> String {
match a {
Some(Arrowhead::Custom(c)) => c.name.clone(),
_ => tl!(arrow_label(a)).to_string(),
}
}
/// The preview cache key of `a`: its name, and for a custom head its art.
fn arrow_key(a: Option<&Arrowhead>) -> String {
match a {
Some(Arrowhead::Custom(c)) => {
use std::hash::{Hash, Hasher};
let mut h = std::collections::hash_map::DefaultHasher::new();
for s in &c.path.subpaths {
(s.anchors.len(), s.closed).hash(&mut h);
for p in s.anchors.iter().flat_map(|a| [a.p, a.h_in, a.h_out]) {
(p.x.to_bits(), p.y.to_bits()).hash(&mut h);
}
}
format!("custom:{}:{:x}", c.name, h.finish())
}
_ => format!("{a:?}"),
}
}
/// Apply Stroke panel options (`stroke.set` params): while the Type tool edits text, to the
/// selected characters' stroke.
fn set(app: &mut VectorcraftApp, p: Value) {
@@ -272,14 +299,17 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
});
widgets::divider(ui);
// Arrowheads.
let (sa, ea) = (st.as_ref().and_then(|s| s.start_arrow), st.as_ref().and_then(|s| s.end_arrow));
let (sa, ea) = (st.as_ref().and_then(|s| s.start_arrow.clone()), st.as_ref().and_then(|s| s.end_arrow.clone()));
// The custom heads of the arrowheads file follow the built-in ones.
let custom: Vec<Arrowhead> = app.session.arrowheads.heads().iter().cloned().map(Arrowhead::Custom).collect();
ui.horizontal(|ui| {
row_label(ui, tl!("Arrowheads:"));
if let Some(a) = arrow_dropdown(ui, "arrow-start", sa, true) {
set(app, json!({"startArrow": a.map(|a| format!("{a:?}"))}));
// A custom head goes with its art, as document.inspect reports it.
if let Some(a) = arrow_dropdown(ui, "arrow-start", sa.as_ref(), &custom, true) {
set(app, json!({ "startArrow": a }));
}
if let Some(a) = arrow_dropdown(ui, "arrow-end", ea, false) {
set(app, json!({"endArrow": a.map(|a| format!("{a:?}"))}));
if let Some(a) = arrow_dropdown(ui, "arrow-end", ea.as_ref(), &custom, false) {
set(app, json!({ "endArrow": a }));
}
if widgets::icon_button_enabled(ui, "arrow-left-right", tl!("Swap start and end arrowheads"), false, st.is_some(), 22.0).clicked() {
app.run("stroke.setAdvanced", json!({"swapArrows": true})).ok();
@@ -360,14 +390,15 @@ pub(super) fn arrow_doc(a: Option<Arrowhead>, start: bool, w: f64, h: f64) -> Op
/// 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) {
if let Some(tex) = widgets::doc_preview(ui, &format!("arrow:{a:?}:{start}"), r.size(), |w, h| arrow_doc(a, start, w, h)) {
fn paint_arrow(ui: &Ui, r: Rect, a: Option<&Arrowhead>, start: bool, color: Color32) {
if let Some(tex) = widgets::doc_preview(ui, &format!("arrow:{}:{start}", arrow_key(a)), r.size(), |w, h| arrow_doc(a.cloned(), 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).
fn arrow_dropdown(ui: &mut Ui, id: &str, cur: Option<Arrowhead>, start: bool) -> Option<Option<Arrowhead>> {
/// Dropdown whose button and items show drawn arrowhead previews: None, the built-in heads, then
/// the `custom` ones. Returns Some(choice).
fn arrow_dropdown(ui: &mut Ui, id: &str, cur: Option<&Arrowhead>, custom: &[Arrowhead], start: bool) -> Option<Option<Arrowhead>> {
let t = Tokens::get(ui.ctx());
let (r, resp) = ui.allocate_exact_size(vec2(64.0, 24.0), Sense::click());
ui.painter().rect_filled(r, 2, t.input);
@@ -380,7 +411,8 @@ fn arrow_dropdown(ui: &mut Ui, id: &str, cur: Option<Arrowhead>, start: bool) ->
egui::Popup::menu(&resp).id(egui::Id::unique(("arrow-pop", id))).show(|ui| {
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 built_in = Arrowhead::ALL;
for a in std::iter::once(None).chain(built_in.iter().map(Some)).chain(custom.iter().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);
@@ -391,12 +423,12 @@ fn arrow_dropdown(ui: &mut Ui, id: &str, cur: Option<Arrowhead>, start: bool) ->
ui.painter().text(
row.left_center() + vec2(66.0, 0.0),
egui::Align2::LEFT_CENTER,
tl!(arrow_label(a)),
arrow_name(a),
egui::FontId::proportional(11.5),
t.text,
);
if rr.clicked() {
out = Some(a);
out = Some(a.cloned());
ui.close();
}
}
@@ -500,6 +532,11 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) {
if menu_item(ui, tl!("Reset Profiles"), crate::menus::enabled(app, "stroke.widthProfile.reset"), false) {
app.run("stroke.widthProfile.reset", json!({})).ok();
}
ui.separator();
// Opens the arrowheads file, whose symbols are the custom heads; saving it reloads them.
if menu_item(ui, tl!("Edit Arrowheads…"), crate::menus::enabled(app, "stroke.arrowheads.edit"), false) {
app.run("stroke.arrowheads.edit", json!({})).ok();
}
}
#[cfg(test)]
@@ -647,7 +684,7 @@ mod tests {
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)");
assert_eq!(arrow_label(Some(&Arrowhead::CircleOpen)), "Circle (open)");
}
#[test]
@@ -100,9 +100,15 @@ fn every_arrowhead_preview_draws_its_head() {
};
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");
assert!(ink(Some(a.clone()), false) > plain.0 + 20, "{a:?} at the end");
assert!(ink(Some(a.clone()), true) > plain.1 + 20, "{a:?} at the start");
}
// So does a custom head: a 4 × 4 square behind its tip.
let pts = [(0.0, -2.0), (4.0, -2.0), (4.0, 2.0), (0.0, 2.0)].map(|(x, y)| vectorcraft_geom::Point::new(x, y));
let square = vectorcraft_geom::PathData::single(vectorcraft_geom::SubPath::polyline(&pts, true));
let custom = Arrowhead::Custom(std::sync::Arc::new(vectorcraft_doc::CustomArrowhead::new("Square".into(), square)));
assert!(ink(Some(custom.clone()), false) > plain.0 + 20);
assert!(ink(Some(custom), true) > plain.1 + 20);
}
#[test]