mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-13 09:39:52 +00:00
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:
@@ -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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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}%"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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}))
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
];
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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:?}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user