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

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

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

Part of #5421.
This commit is contained in:
dotCooCoo
2026-11-11 21:52:46 -08:00
parent 7f3a046b18
commit c1f7e2eeaf
59 changed files with 1143 additions and 59 deletions
+2
View File
@@ -4,6 +4,7 @@ pub mod actionset;
mod addons;
mod apiref;
pub(crate) mod appearance;
pub mod arrowheads;
mod assets;
mod attributes;
pub mod brushlib;
@@ -297,6 +298,7 @@ pub fn command_specs() -> &'static [CommandSpec] {
// Split out of `paint` (M3.8); kept next to it so lists in registry order (the command
// palette, `engine.commands`) show them where they always were.
v.extend(stroke::specs());
v.extend(arrowheads::specs());
v.extend(appearance::specs());
v.extend(style::specs());
v.extend(swatch::specs());
+423
View File
@@ -0,0 +1,423 @@
//! Custom arrowheads (Stroke panel menu › Edit Arrowheads…). The arrowheads file is a native
//! document kept next to the preferences ([`Session::attach_arrowheads_file`]). Each of its symbols
//! is one arrowhead, listed by the symbol's name after the built-in heads in the Stroke panel's
//! arrowhead menus. A head points right (+x): the symbol's registration point is its tip, and the
//! art's height (its extent across the line) is drawn as wide as the built-in heads, four times
//! the head's weight. A head covers what its art paints: the visible fills, and the visible
//! strokes outlined, of the paths, compound paths and groups in it, and its gradient meshes, with
//! their geometry effects applied and clip groups clipped. A head is one solid shape in the stroke's paint: art at 0 %
//! opacity is left out, and other opacities, blend modes and opacity masks do not change the
//! shape. Saving the file reloads the heads. A stroke that uses a custom head carries a copy of its art
//! ([`vectorcraft_doc::CustomArrowhead`]), so its document draws it anywhere.
use std::sync::Arc;
use serde_json::{Value, json};
use vectorcraft_color::{Color, Paint};
use vectorcraft_doc::{Appearance, AppearanceItem, CustomArrowhead, Document, Node, NodeId, NodeKind, Symbol, SymbolRegistration};
use vectorcraft_geom::{Affine, FillRule, PathData, Point, SubPath};
use vectorcraft_pathops::BoolOp;
use super::{CommandSpec, Result, Session, always, bad, cmd};
/// The arrowheads file's name, in the folder of the preferences.
pub const FILE_NAME: &str = "Arrowheads.vectorcraft";
/// The largest arrowheads file read.
pub const MAX_FILE_BYTES: u64 = 64 << 20;
/// The most heads read from the file; symbols past it are skipped.
pub const MAX_HEADS: usize = 256;
/// The most anchors a symbol's art, and the shapes it paints, may have; a symbol past it is skipped.
pub const MAX_ART_ANCHORS: usize = 16 * CustomArrowhead::MAX_ANCHORS;
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(
query "stroke.arrowheads.list",
"Arrowheads",
[],
None,
"{} → {arrowheads: [{name (stroke.set `startArrow`/`endArrow` as {custom: name}), reach, halfWidth (in head weights)}], file: the arrowheads file's path or null (no file here, as on the web), skipped: [{name, reason}] symbols of the file that are not heads, error: why the file could not be read, or null} the custom arrowheads, listed after the built-in ones in the Stroke panel's arrowhead menus",
always,
list
),
cmd!(
"stroke.arrowheads.edit",
"Edit Arrowheads",
[],
None,
"{} open the arrowheads file (a native document next to the preferences; created first with one example head when it is missing). Each symbol in it is one custom arrowhead named after the symbol: it points right, its registration point is the tip, and its height is drawn as wide as the built-in heads; its visible fills and outlined strokes make the head. Saving the file reloads the heads → {path, created: whether the file was made now}",
has_file,
edit
),
cmd!(
"stroke.arrowheads.reload",
"Reload Arrowheads",
[],
None,
"{} read the arrowheads file again (a save of it from VectorCraft does this already) → {count, skipped: [{name, reason}]}",
has_file,
reload
),
]
}
/// The custom arrowheads read from the arrowheads file.
#[derive(Default)]
pub struct Arrowheads {
path: Option<String>,
heads: Vec<Arc<CustomArrowhead>>,
skipped: Vec<(String, String)>,
error: Option<String>,
}
impl Arrowheads {
/// The arrowheads file's path; `None` without one (the web, runs without preferences).
pub fn path(&self) -> Option<&str> {
self.path.as_deref()
}
/// The heads, in the order of the file's symbols.
pub fn heads(&self) -> &[Arc<CustomArrowhead>] {
&self.heads
}
/// Head `name`.
pub fn get(&self, name: &str) -> Option<Arc<CustomArrowhead>> {
self.heads.iter().find(|h| h.name == name).cloned()
}
fn set(&mut self, read: Read) {
(self.heads, self.skipped) = (read.heads.into_iter().map(Arc::new).collect(), read.skipped);
self.error = None;
}
}
/// The heads a document holds, and the symbols that are not heads with the reason.
#[derive(Debug, Default)]
pub struct Read {
pub heads: Vec<CustomArrowhead>,
pub skipped: Vec<(String, String)>,
}
/// The heads of document `doc`: one per symbol, up to [`MAX_HEADS`] (see the module docs).
pub fn heads_of(doc: &Document) -> Read {
let mut d = doc.clone();
let brushes = vectorcraft_brush::library(&d);
let mut out = Read::default();
for (i, sym) in doc.symbols.iter().enumerate() {
if i >= MAX_HEADS {
out.skipped.push((sym.name.clone(), format!("only the first {MAX_HEADS} symbols are read")));
continue;
}
if out.heads.iter().any(|h| h.name == sym.name) {
out.skipped.push((sym.name.clone(), "another symbol has this name".into()));
continue;
}
match head(&mut d, &brushes, sym) {
Ok(h) => out.heads.push(h),
Err(why) => out.skipped.push((sym.name.clone(), why.into())),
}
}
out
}
/// Symbol `sym` of `d` as a head.
fn head(d: &mut Document, brushes: &[vectorcraft_brush::Brush], sym: &Symbol) -> std::result::Result<CustomArrowhead, &'static str> {
const TOO_DETAILED: &str = "its art is too long or too detailed for a head";
if art_anchors(&sym.art) > MAX_ART_ANCHORS {
return Err(TOO_DETAILED);
}
let art = baked_art(d, sym);
let mut shapes = Shapes::default();
painted(d, brushes, &art, &mut shapes);
if shapes.too_detailed {
return Err(TOO_DETAILED);
}
let area = shapes.united();
let b = area.bounds().filter(|b| b.height() > 1e-9 && b.is_finite()).ok_or("its art paints nothing")?;
let tip = b.center() + d.symbol_registration(&sym.name).offset_from_center(b.width(), b.height());
// Head units: x back from the tip, y across to the right of +x, 4 units to the art's height.
let k = 4.0 / b.height();
let local = Affine::new([-k, 0.0, 0.0, k, k * tip.x, -k * tip.y]);
let h = CustomArrowhead::new(sym.name.clone(), area.transformed(local));
if h.path.subpaths.is_empty() {
return Err(TOO_DETAILED);
}
Ok(h)
}
/// The anchors of the paths in `n` and of the pieces of its gradient meshes ([`mesh_anchors`]), its
/// members' included.
fn art_anchors(n: &Node) -> usize {
let own = match &n.kind {
NodeKind::Mesh(m) => mesh_anchors(m),
_ => n.path_data().map_or(0, PathData::anchor_count),
};
n.children().map_or(own, |c| c.iter().fold(own, |sum, c| sum.saturating_add(art_anchors(c))))
}
/// The art of symbol `sym` of `d` with every geometry effect in it applied (its objects', fills',
/// strokes' and groups'), as the canvas draws it: the art baked as for export, on a copy of `d`.
fn baked_art(d: &Document, sym: &Symbol) -> Node {
// A plain new layer, so the file's own layers' effects stay out of the art.
let mut tmp = d.clone();
tmp.layers = Document::new(1.0, 1.0).layers;
let Some(children) = tmp.layers.first_mut().and_then(|l| Arc::make_mut(l).children_mut()) else { return (*sym.art).clone() };
*children = vec![sym.art.clone()];
let baked = vectorcraft_render::effects::bake_document(&tmp);
baked.as_ref().and_then(|b| b.layers.first()?.children()?.first().map(|c| (**c).clone())).unwrap_or_else(|| (*sym.art).clone())
}
/// The shapes a symbol's art paints, up to [`MAX_ART_ANCHORS`] anchors in all.
#[derive(Default)]
struct Shapes {
list: Vec<(PathData, FillRule)>,
anchors: usize,
/// More anchors were asked for than fit; the shapes past the limit were not made.
too_detailed: bool,
}
impl Shapes {
/// Whether `anchors` more fit; when they do not, the shapes are too detailed.
fn room(&mut self, anchors: usize) -> bool {
self.too_detailed |= self.anchors.saturating_add(anchors) > MAX_ART_ANCHORS;
!self.too_detailed
}
fn push(&mut self, shape: (PathData, FillRule)) {
if self.room(shape.0.anchor_count()) {
self.anchors += shape.0.anchor_count();
self.list.push(shape);
}
}
fn united(&self) -> PathData {
let refs: Vec<(&PathData, FillRule)> = self.list.iter().map(|(p, r)| (p, *r)).collect();
vectorcraft_pathops::unite_all(&refs)
}
/// The shapes of `inside` clipped to `outline` (filled under `rule`), added as one.
fn push_clipped(&mut self, inside: Shapes, outline: &PathData, rule: FillRule) {
if inside.too_detailed || !self.room(inside.anchors) {
self.too_detailed = true;
return;
}
let clipped = vectorcraft_pathops::boolean(&inside.united(), FillRule::NonZero, outline, rule, BoolOp::Intersect);
if !clipped.subpaths.is_empty() {
self.push((clipped, FillRule::NonZero));
}
}
}
/// The shapes the shown paths and compound paths in `n` (baked art) paint: their filled areas and
/// their strokes' outlines, its groups' members included (baking has already made a group's own
/// fills and strokes art among its members), and the areas its gradient meshes cover. A clip
/// group's clipped art is clipped to
/// its clipping path, whose own fill and stroke paint unclipped, as on the canvas. Hidden or fully
/// transparent art is left out.
fn painted(d: &mut Document, brushes: &[vectorcraft_brush::Brush], n: &Node, out: &mut Shapes) {
if !n.visible || n.opacity <= 0.0 || out.too_detailed {
return;
}
match &n.kind {
NodeKind::Path { guide: true, .. } => {}
NodeKind::Path { .. } | NodeKind::Compound { .. } => {
fills(n, out);
// The outline holds a copy of the path for each fill too, which adds nothing new.
if let Some(o) = super::pathops::outline_strokes(d, brushes, n) {
fills_in(&o, out);
}
}
NodeKind::Group { children, clip: true } => {
let Some((clip, rest)) = children.split_first() else { return };
painted(d, brushes, clip, out);
// Nothing to clip by hides the clipped art, as on the canvas.
let Some((outline, rule)) = vectorcraft_render::effects::clip_outline(clip) else { return };
let mut inside = Shapes { anchors: out.anchors, ..Default::default() };
rest.iter().for_each(|c| painted(d, brushes, c, &mut inside));
inside.anchors -= out.anchors;
out.push_clipped(inside, &PathData::from_bezpath(&outline), rule);
}
NodeKind::Group { children, .. } | NodeKind::Layer { children, .. } => children.iter().for_each(|c| painted(d, brushes, c, out)),
NodeKind::Mesh(_) => mesh_area(n, out),
_ => {}
}
}
/// The areas the shown paths and gradient meshes in `n` (an outlined stroke) fill, clipped by its
/// clip groups, whose clipping paths' own fills paint unclipped. A stroke with a gradient along or
/// across it is outlined as gradient meshes clipped to the stroke's outline.
fn fills_in(n: &Node, out: &mut Shapes) {
if !n.visible || n.opacity <= 0.0 || out.too_detailed {
return;
}
match &n.kind {
NodeKind::Mesh(_) => mesh_area(n, out),
NodeKind::Group { children, clip: true } => {
let Some((clip, rest)) = children.split_first() else { return };
fills_in(clip, out);
let Some((outline, rule)) = vectorcraft_render::effects::clip_outline(clip) else { return };
let mut inside = Shapes { anchors: out.anchors, ..Default::default() };
rest.iter().for_each(|c| fills_in(c, &mut inside));
inside.anchors -= out.anchors;
out.push_clipped(inside, &PathData::from_bezpath(&outline), rule);
}
_ => match n.children() {
Some(children) => children.iter().for_each(|c| fills_in(c, out)),
None => fills(n, out),
},
}
}
/// The anchors [`mesh_area`] makes at most of mesh `m`: its outline's, one a patch along each side,
/// when no point of it is fully transparent, else four by four quads of four anchors a patch.
fn mesh_anchors(m: &vectorcraft_doc::GradientMesh) -> usize {
let (rows, cols) = (m.rows as usize, m.cols as usize);
if opaque(m) { rows.saturating_add(cols).saturating_mul(2) } else { rows.saturating_mul(cols).saturating_mul(64) }
}
fn opaque(m: &vectorcraft_doc::GradientMesh) -> bool {
m.points.iter().all(|p| p.opacity > 0.0)
}
/// The area gradient mesh `n` paints: its outline when no point of it is fully transparent, else
/// the pieces of it that are not fully transparent (four by four quads a patch). A mesh whose
/// pieces do not fit in `out` makes the shapes too detailed.
fn mesh_area(n: &Node, out: &mut Shapes) {
let NodeKind::Mesh(m) = &n.kind else { return };
if !out.room(mesh_anchors(m)) {
return;
}
if opaque(m) {
let outline = m.outline();
if !outline.subpaths.is_empty() {
out.push((outline, FillRule::NonZero));
}
return;
}
for q in m.quads(4) {
if q.opacity > 0.0 || q.corners.iter().any(|(_, o)| *o > 0.0) {
out.push((PathData::single(SubPath::polyline(&q.pts, true)), FillRule::NonZero));
}
}
}
/// The area path `n` fills, when a shown fill paints it.
fn fills(n: &Node, out: &mut Shapes) {
let paints = |i: &AppearanceItem| matches!(i, AppearanceItem::Fill(f) if f.visible && f.opacity > 0.0 && !f.paint.is_none());
if n.appearance.items.iter().any(paints)
&& let Some(shape) = super::pathops::node_path(n)
{
out.push(shape);
}
}
/// The document a missing arrowheads file starts as: one example head, a solid triangle.
pub fn starter() -> Document {
let mut doc = Document::new(612.0, 792.0);
doc.title = "Arrowheads".into();
let name = "Example Arrowhead";
let tri = SubPath::polyline(&[Point::new(-10.0, -8.0), Point::new(10.0, 0.0), Point::new(-10.0, 8.0)], true);
let art = Node::path(NodeId(0), PathData::single(tri), Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 1.0));
let art = doc.reid(&art);
doc.symbols.push(Symbol { name: name.into(), art: Arc::new(art), dynamic: false });
// The tip is the right edge's middle.
doc.set_symbol_registration(name, SymbolRegistration(5));
let id = doc.alloc_id();
let instance = Node::new(id, NodeKind::SymbolInstance { symbol: name.into(), xf: Affine::translate((306.0, 396.0)) });
let layer = doc.layers.first().map(|l| l.id);
let _ = doc.insert(layer, 0, instance);
doc
}
impl Session {
/// Keep the custom arrowheads in the file at `path` and read the heads it holds (none while it
/// is missing; an unreadable file is reported by `stroke.arrowheads.list`).
pub fn attach_arrowheads_file(&mut self, path: String) {
self.arrowheads.path = Some(path);
self.reload_arrowheads();
}
/// Read the arrowheads file again.
fn reload_arrowheads(&mut self) {
let Some(path) = self.arrowheads.path.clone() else { return };
match read(&path) {
Ok(Some(doc)) => self.arrowheads.set(heads_of(&doc)),
Ok(None) => self.arrowheads.set(Read::default()),
Err(e) => {
self.arrowheads.set(Read::default());
self.arrowheads.error = Some(e);
}
}
}
/// Document `doc` was saved to `path`: when that is the arrowheads file, its heads are the
/// ones listed now.
pub(crate) fn arrowheads_saved(&mut self, path: &str, doc: &Document) {
if self.arrowheads.path.as_deref().is_some_and(|a| same_file(a, path)) {
self.arrowheads.set(heads_of(doc));
}
}
}
fn same_file(a: &str, b: &str) -> bool {
let canon = |p: &str| std::fs::canonicalize(p).unwrap_or_else(|_| std::path::PathBuf::from(p));
std::path::Path::new(a) == std::path::Path::new(b) || canon(a) == canon(b)
}
/// The document in the arrowheads file at `path`; `None` when there is no file.
fn read(path: &str) -> std::result::Result<Option<Document>, String> {
let size = match std::fs::metadata(path) {
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(e) => return Err(format!("{path}: {e}")),
Ok(m) if !m.is_file() => return Err(format!("{path}: not a file")),
Ok(m) => m.len(),
};
if size > MAX_FILE_BYTES {
return Err(format!("{path}: larger than {} MB", MAX_FILE_BYTES >> 20));
}
let bytes = crate::file_access::unconfined(|| super::fileio::read_file(path)).map_err(|e| e.to_string())?;
let parse = || super::fileio::native_file(&bytes).map(|(doc, ..)| doc).map_err(|e| e.to_string());
crate::guard::catch_panic(parse).map_err(|_| format!("{path}: could not be read"))?.map(Some)
}
fn has_file(s: &Session) -> std::result::Result<(), String> {
if s.arrowheads.path.is_some() { Ok(()) } else { Err("no arrowheads file here".into()) }
}
fn skipped(s: &Session) -> Vec<Value> {
s.arrowheads.skipped.iter().map(|(name, reason)| json!({"name": name, "reason": reason})).collect()
}
fn list(s: &mut Session, _: &Value) -> Result<Value> {
let heads: Vec<Value> = s.arrowheads.heads.iter().map(|h| json!({"name": h.name, "reach": h.reach(), "halfWidth": h.half_width()})).collect();
Ok(json!({"arrowheads": heads, "file": s.arrowheads.path, "skipped": skipped(s), "error": s.arrowheads.error}))
}
fn reload(s: &mut Session, _: &Value) -> Result<Value> {
const C: &str = "stroke.arrowheads.reload";
s.reload_arrowheads();
if let Some(e) = &s.arrowheads.error {
return Err(bad(C, e.clone()));
}
Ok(json!({"count": s.arrowheads.heads.len(), "skipped": skipped(s)}))
}
fn edit(s: &mut Session, _: &Value) -> Result<Value> {
const C: &str = "stroke.arrowheads.edit";
let path = s.arrowheads.path.clone().ok_or_else(|| bad(C, "no arrowheads file here"))?;
let created = !std::path::Path::new(&path).exists();
if created {
if let Some(dir) = std::path::Path::new(&path).parent() {
std::fs::create_dir_all(dir).map_err(|e| bad(C, format!("{}: {e}", dir.display())))?;
}
let bytes = vectorcraft_format::save_file(&starter());
crate::file_access::unconfined(|| super::fileio::write_file(&path, &bytes)).map_err(|e| bad(C, e.to_string()))?;
s.reload_arrowheads();
}
// Already open: that document comes to the front.
let open = s.documents().iter().position(|st| st.path.as_deref().is_some_and(|p| same_file(p, &path)));
match open {
Some(i) => {
s.set_active(i);
}
None => {
crate::file_access::unconfined(|| s.execute("document.open", &json!({ "path": path })))?;
}
}
Ok(json!({"path": path, "created": created}))
}
+1 -1
View File
@@ -568,7 +568,7 @@ fn appearance(o: &DimensionOptions, mode: ColorMode, ext: bool, arrows: bool) ->
ArrowStyle::Tick => Some(Arrowhead::Slash),
ArrowStyle::None => None,
};
st.start_arrow = head;
st.start_arrow = head.clone();
st.end_arrow = head;
st.arrow_scale = (o.arrow_scale, o.arrow_scale);
st.arrow_align = vectorcraft_doc::appearance::ArrowAlign::Tip;
+5
View File
@@ -1005,6 +1005,8 @@ impl SaveJob {
}
// The other open documents that place this one show the new version.
let written = self.plan.path.clone().filter(|_| matches!(self.plan.format.id, "vectorcraft" | "template"));
// A save of the arrowheads file lists its heads in the arrowhead menus.
let native = (self.plan.format.id == "vectorcraft").then(|| self.snapshot.clone());
if let Some(st) = s.document_mut(uid) {
self.finish(st);
}
@@ -1012,6 +1014,9 @@ impl SaveJob {
crate::cmd::recovery::forget(s, uid);
}
if let Some(path) = written {
if let Some(doc) = native {
s.arrowheads_saved(&path, &doc);
}
crate::cmd::links::refresh_placed(s, uid, &path);
}
}
@@ -675,5 +675,6 @@ pub(super) const DENIED: &[&str] = &[
"object.blend.options",
"object.liquify",
"object.pathfinder.options",
"stroke.arrowheads.edit",
"type.compositeFonts.set",
];
+25 -6
View File
@@ -4,8 +4,10 @@
use serde_json::{Value, json};
use vectorcraft_color::{Color, Paint};
use vectorcraft_doc::{
ArrowAlign, Arrowhead, CharStyle, Dash, LineCap, LineJoin, Node, NodeKind, SavedProfile, Scaling, StrokeAlign, StrokeLayer, Unit, WidthProfile,
ArrowAlign, Arrowhead, CharStyle, CustomArrowhead, Dash, LineCap, LineJoin, Node, NodeKind, SavedProfile, Scaling, StrokeAlign, StrokeLayer,
Unit, WidthProfile,
};
use vectorcraft_geom::PathData;
use super::appearance::{ItemTarget, edit_items, item_target};
use super::paint::painted;
@@ -20,7 +22,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Stroke Options",
["Window", "Stroke"],
None,
"{weight?: pt, cap?: butt|round|square, join?: miter|round|bevel, miterLimit?, align?: center|inside|outside, dash?: [d,g,…]|null (a 0 dash with a round or projecting cap draws dots or squares; a new pattern keeps the current offset and alignment), dashOffset? (exact dashes only), alignDashes?: bool (true: dashes fitted to corners and path ends, every run between them holding whole periods with a dash centred on each corner and end; false, the default for a new pattern: exact lengths), startArrow?, endArrow?: Arrow|ArrowOpen|LineArrow|Barbed|Concave|DoubleArrow|HalfArrowLeft|HalfArrowRight|Chevron|Feather|Swallowtail|Triangle|TriangleOpen|TriangleReverse|TriangleBar|Kite|Leaf|Drop|Circle|CircleOpen|HalfCircle|Oval|Target|Square|SquareOpen|Tag|TagOpen|Diamond|DiamondOpen|Hexagon|HexagonOpen|Star|Cross|Plus|Bar|DoubleBar|DotOnBar|Slash|DoubleSlash|Bracket|Fork|null (HalfArrowLeft/Right: one barb, left or right of the direction the head points), arrowAlign?: \"extend\" (tip past the end point, default)|\"tip\" (tip on the end point; the stroke is shortened), profile?: \"uniform\"|\"lens\"|\"taperStart\"|\"taperEnd\"|\"pinch\"|\"teardrop\"|\"wave\"|a saved profile's name (stroke.widthProfile.list), widthPoints?, gradientMode? (as document.inspect reports them in strokeOptions, so those read back unchanged; ignored here: a custom profile is set with the Width tool, gradientMode with paint.editGradient), item?: stroke item index|null (omitted: the Appearance panel's active item if it is a stroke, else the top stroke, created when missing), ids?|id?, target?: \"object\"|\"contents\", aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection)} Without a stroke item, type takes weight, cap, join, miterLimit, align (around its glyph outlines; the type stays live) and the dash options as its characters' stroke (every run; text.setRangeStyle `strokeOptions` styles a range), and images and symbol instances (also in groups) are left alone. With nothing selected (and no ids) they set up the next object drawn (appearance.newArt; weight always does) → {objects: how many objects took the stroke options}",
"{weight?: pt, cap?: butt|round|square, join?: miter|round|bevel, miterLimit?, align?: center|inside|outside, dash?: [d,g,…]|null (a 0 dash with a round or projecting cap draws dots or squares; a new pattern keeps the current offset and alignment), dashOffset? (exact dashes only), alignDashes?: bool (true: dashes fitted to corners and path ends, every run between them holding whole periods with a dash centred on each corner and end; false, the default for a new pattern: exact lengths), startArrow?, endArrow?: Arrow|ArrowOpen|LineArrow|Barbed|Concave|DoubleArrow|HalfArrowLeft|HalfArrowRight|Chevron|Feather|Swallowtail|Triangle|TriangleOpen|TriangleReverse|TriangleBar|Kite|Leaf|Drop|Circle|CircleOpen|HalfCircle|Oval|Target|Square|SquareOpen|Tag|TagOpen|Diamond|DiamondOpen|Hexagon|HexagonOpen|Star|Cross|Plus|Bar|DoubleBar|DotOnBar|Slash|DoubleSlash|Bracket|Fork|null|{custom: name} (a custom arrowhead of the arrowheads file, stroke.arrowheads.list)|{Custom: {name, path}} (a custom head with its art, as document.inspect reports one) (HalfArrowLeft/Right: one barb, left or right of the direction the head points), arrowAlign?: \"extend\" (tip past the end point, default)|\"tip\" (tip on the end point; the stroke is shortened), profile?: \"uniform\"|\"lens\"|\"taperStart\"|\"taperEnd\"|\"pinch\"|\"teardrop\"|\"wave\"|a saved profile's name (stroke.widthProfile.list), widthPoints?, gradientMode? (as document.inspect reports them in strokeOptions, so those read back unchanged; ignored here: a custom profile is set with the Width tool, gradientMode with paint.editGradient), item?: stroke item index|null (omitted: the Appearance panel's active item if it is a stroke, else the top stroke, created when missing), ids?|id?, target?: \"object\"|\"contents\", aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection)} Without a stroke item, type takes weight, cap, join, miterLimit, align (around its glyph outlines; the type stays live) and the dash options as its characters' stroke (every run; text.setRangeStyle `strokeOptions` styles a range), and images and symbol instances (also in groups) are left alone. With nothing selected (and no ids) they set up the next object drawn (appearance.newArt; weight always does) → {objects: how many objects took the stroke options}",
has_doc,
stroke_set
),
@@ -222,6 +224,23 @@ fn stroke_set(s: &mut Session, p: &Value) -> Result<Value> {
None => Ok(None),
Some(Value::Null) => Ok(Some(None)),
Some(Value::String(n)) if n == "none" => Ok(Some(None)),
// A custom head by name, from the arrowheads file (stroke.arrowheads.list).
Some(Value::Object(o)) if o.len() == 1 && o.contains_key("custom") => {
let Some(Value::String(name)) = o.get("custom") else { return Err(bad(C, format!("{k}: custom must be a name"))) };
let head = s.arrowheads.get(name).ok_or_else(|| bad(C, format!("no custom arrowhead {name} (see stroke.arrowheads.list)")))?;
Ok(Some(Some(Arrowhead::Custom(head))))
}
// A custom head with its art: an outline the head would drop is refused, not drawn empty.
Some(Value::Object(o)) if o.contains_key("Custom") => {
let raw = o.get("Custom").and_then(|c| c.get("path")).cloned().unwrap_or(Value::Null);
let path: PathData = serde_json::from_value(raw).map_err(|e| bad(C, format!("{k}: bad custom arrowhead path: {e}")))?;
if path.subpaths.iter().all(|sp| sp.anchors.is_empty()) || !CustomArrowhead::fits(&path) {
let (most, far) = (CustomArrowhead::MAX_ANCHORS, CustomArrowhead::MAX_UNITS);
return Err(bad(C, format!("{k}: a custom arrowhead's path needs 1 to {most} anchors, each within {far} head units of its tip")));
}
let head = p.get(k).cloned().unwrap_or(Value::Null);
serde_json::from_value::<Arrowhead>(head).map(|a| Some(Some(a))).map_err(|e| bad(C, format!("{k}: bad custom arrowhead: {e}")))
}
Some(v) => serde_json::from_value::<Arrowhead>(v.clone()).map(|a| Some(Some(a))).map_err(|_| bad(C, format!("unknown arrowhead {v}"))),
}
};
@@ -238,11 +257,11 @@ fn stroke_set(s: &mut Session, p: &Value) -> Result<Value> {
};
let set = |st: &mut StrokeLayer| {
change.apply(st);
if let Some(a) = sa {
st.start_arrow = a;
if let Some(a) = &sa {
st.start_arrow = a.clone();
}
if let Some(a) = ea {
st.end_arrow = a;
if let Some(a) = &ea {
st.end_arrow = a.clone();
}
if let Some(a) = arrow_align {
st.arrow_align = a;
+2
View File
@@ -151,6 +151,8 @@ pub struct Session {
pub symbol_libraries: cmd::symbollib::Libraries,
/// User Defined and loaded brush libraries (Window → Brush Libraries); not saved.
pub brush_libraries: cmd::brushlib::Libraries,
/// The custom arrowheads of the arrowheads file (`stroke.arrowheads.*`); none without one.
pub arrowheads: cmd::arrowheads::Arrowheads,
/// The Libraries panel's libraries of graphics, colours and text styles (`library.*`).
pub libraries: cmd::library::Libraries,
/// Where the saved PDF presets are kept, one file each; `None`: only for the session
+1
View File
@@ -80,6 +80,7 @@ impl Session {
style_libraries: Default::default(),
symbol_libraries: Default::default(),
brush_libraries: Default::default(),
arrowheads: Default::default(),
libraries: Default::default(),
pdf_preset_dir: None,
dictionary_file: None,
+1
View File
@@ -9,6 +9,7 @@ pub(super) mod adjust;
mod ai_open_path;
mod appearance;
mod areafit;
mod arrowheads;
mod assets;
mod assets_3d;
mod attributes;
+252
View File
@@ -0,0 +1,252 @@
//! Custom arrowheads (`cmd/arrowheads.rs`): heads read from the arrowheads file's symbols, listed,
//! set with `stroke.set`, reloaded when the file is saved, and kept with the documents that use
//! them.
use std::sync::Arc;
use serde_json::json;
use vectorcraft_color::{Color, Paint};
use vectorcraft_doc::{Appearance, Arrowhead, Document, Node, StrokeLayer, Symbol, SymbolRegistration};
use vectorcraft_geom::{PathData, Point, SubPath};
use crate::cmd::arrowheads::{self, heads_of};
use crate::*;
fn polygon(pts: &[(f64, f64)], ap: Appearance) -> Node {
let pts: Vec<Point> = pts.iter().map(|&(x, y)| Point::new(x, y)).collect();
Node::path(NodeId(0), PathData::single(SubPath::polyline(&pts, true)), ap)
}
fn add_symbol(d: &mut Document, name: &str, art: Node) {
let art = d.reid(&art);
d.symbols.push(Symbol { name: name.into(), art: Arc::new(art), dynamic: false });
}
fn filled() -> Appearance {
Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 1.0)
}
/// A folder of its own for test `name`, emptied.
fn folder(name: &str) -> std::path::PathBuf {
let dir = std::env::temp_dir().join(format!("vectorcraft-arrowheads-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
dir
}
fn session_with_line() -> (Session, NodeId) {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 200, "height": 200})).unwrap();
let r = s.execute("shape.line", &json!({"x1": 10, "y1": 100, "x2": 150, "y2": 100})).unwrap();
(s, NodeId(r["id"].as_u64().unwrap()))
}
fn stroke(s: &Session, id: NodeId) -> StrokeLayer {
s.doc().unwrap().doc.node(id).unwrap().appearance.stroke().unwrap().clone()
}
#[test]
fn each_symbol_is_a_head_pointing_right_with_its_tip_at_the_registration_point() {
let mut d = Document::new(100.0, 100.0);
// 20 high, so a unit is 5 pt: tip on the right edge's middle.
add_symbol(&mut d, "Wedge", polygon(&[(0.0, -10.0), (20.0, 0.0), (0.0, 10.0)], filled()));
d.set_symbol_registration("Wedge", SymbolRegistration(5));
// A stroke alone paints its outline: a 10 × 4 band, tip at its centre (the default).
let mut line = Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 4.0);
line.stroke_mut().unwrap().cap = vectorcraft_doc::LineCap::Butt;
let band = Node::path(NodeId(0), PathData::single(SubPath::polyline(&[Point::new(0.0, 0.0), Point::new(10.0, 0.0)], false)), line);
add_symbol(&mut d, "Band", band);
// Neither filled nor stroked: no head.
add_symbol(&mut d, "Ghost", polygon(&[(0.0, 0.0), (5.0, 0.0), (5.0, 5.0)], Appearance::default()));
// Far longer than wide.
add_symbol(&mut d, "Needle", polygon(&[(0.0, 0.0), (1000.0, 0.5), (0.0, 1.0)], filled()));
// A fill at 0 % opacity paints nothing.
let mut clear = filled();
if let Some(vectorcraft_doc::AppearanceItem::Fill(f)) = clear.items.first_mut() {
f.opacity = 0.0;
}
add_symbol(&mut d, "Clear", polygon(&[(0.0, 0.0), (5.0, 0.0), (5.0, 5.0)], clear));
// More anchors than a symbol's art may have: skipped before its shapes are united.
let dense: Vec<(f64, f64)> = (0..=arrowheads::MAX_ART_ANCHORS).map(|i| ((i as f64).cos(), (i as f64).sin())).collect();
add_symbol(&mut d, "Dense", polygon(&dense, filled()));
// A 20 × 4 bar clipped to a 4 × 4 square: a 4 × 4 head.
let mut mask = polygon(&[(0.0, 0.0), (4.0, 0.0), (4.0, 4.0), (0.0, 4.0)], Appearance::default());
if let NodeKind::Path { clipping, .. } = &mut mask.kind {
*clipping = true;
}
let bar = polygon(&[(0.0, 0.0), (20.0, 0.0), (20.0, 4.0), (0.0, 4.0)], filled());
add_symbol(&mut d, "Clipped", Node::new(NodeId(0), NodeKind::Group { children: vec![Arc::new(mask), Arc::new(bar)], clip: true }));
// An 8 × 4 bar grown by 2 pt on every side (Offset Path): 12 × 8, so 6 units long.
let mut grown = filled();
grown.effects.push(vectorcraft_doc::Effect { id: "path.offsetPath".into(), params: json!({"offset": 2}), visible: true });
add_symbol(&mut d, "Grown", polygon(&[(0.0, 0.0), (8.0, 0.0), (8.0, 4.0), (0.0, 4.0)], grown));
// The same effect on the fill alone.
let mut fill_grown = filled();
fill_grown.items[0].effects_mut().push(vectorcraft_doc::Effect { id: "path.offsetPath".into(), params: json!({"offset": 2}), visible: true });
add_symbol(&mut d, "Fill Grown", polygon(&[(0.0, 0.0), (8.0, 0.0), (8.0, 4.0), (0.0, 4.0)], fill_grown));
// And on a group holding the bar.
let mut group = Node::group(NodeId(0), vec![Arc::new(polygon(&[(0.0, 0.0), (8.0, 0.0), (8.0, 4.0), (0.0, 4.0)], filled()))]);
group.appearance.effects.push(vectorcraft_doc::Effect { id: "path.offsetPath".into(), params: json!({"offset": 2}), visible: true });
add_symbol(&mut d, "Group Grown", group);
// A clipping path stroked 2 pt: its stroke paints unclipped and does not widen the clip.
let mut frame = polygon(&[(0.0, 0.0), (4.0, 0.0), (4.0, 4.0), (0.0, 4.0)], Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 2.0));
if let NodeKind::Path { clipping, .. } = &mut frame.kind {
*clipping = true;
}
let bar = polygon(&[(0.0, 0.0), (20.0, 0.0), (20.0, 4.0), (0.0, 4.0)], filled());
add_symbol(&mut d, "Framed", Node::new(NodeId(0), NodeKind::Group { children: vec![Arc::new(frame), Arc::new(bar)], clip: true }));
// A group's own fill paints its unpainted 8 × 4 member.
let mut painted_group = Node::group(NodeId(0), vec![Arc::new(polygon(&[(0.0, 0.0), (8.0, 0.0), (8.0, 4.0), (0.0, 4.0)], Appearance::default()))]);
painted_group.appearance = filled().into();
add_symbol(&mut d, "Group Fill", painted_group);
// A 10 × 4 band stroked with a gradient along it.
let gradient = Paint::Gradient(Box::new(vectorcraft_color::GradientPaint::new(vectorcraft_color::Gradient::default())));
let mut along = Appearance::basic(Paint::None, gradient, 4.0);
if let Some(st) = along.stroke_mut() {
(st.cap, st.gradient_mode) = (vectorcraft_doc::LineCap::Butt, vectorcraft_doc::StrokeGradientMode::Along);
}
let band = Node::path(NodeId(0), PathData::single(SubPath::polyline(&[Point::new(0.0, 0.0), Point::new(10.0, 0.0)], false)), along);
add_symbol(&mut d, "Gradient Band", band);
// An 8 × 4 mesh of two patches whose right one is fully transparent: a 4 × 4 head.
let corners = vectorcraft_doc::live::grid_points(vectorcraft_geom::Rect::new(0.0, 0.0, 8.0, 4.0), 1, 2);
let points = corners
.iter()
.map(|&p| vectorcraft_doc::MeshPoint { p, color: Color::BLACK, opacity: if p.x > 1.0 { 0.0 } else { 1.0 }, handles: Default::default() })
.collect();
let mesh = vectorcraft_doc::GradientMesh { rows: 1, cols: 2, points };
add_symbol(&mut d, "Half Mesh", Node::new(NodeId(0), NodeKind::Mesh(mesh)));
// An effect on the file's layer is not part of any symbol.
Arc::make_mut(&mut d.layers[0]).appearance.effects.push(vectorcraft_doc::Effect {
id: "path.offsetPath".into(),
params: json!({"offset": 5}),
visible: true,
});
let read = heads_of(&d);
let names: Vec<&str> = read.heads.iter().map(|h| h.name.as_str()).collect();
assert_eq!(names, ["Wedge", "Band", "Clipped", "Grown", "Fill Grown", "Group Grown", "Framed", "Group Fill", "Gradient Band", "Half Mesh"]);
let b = read.heads[9].path.bounds().unwrap();
assert!((b.width() - 4.0).abs() < 1e-3 && (b.height() - 4.0).abs() < 1e-3, "the mesh's painted patch: {b:?}");
let b = read.heads[7].path.bounds().unwrap();
assert!((b.width() - 8.0).abs() < 1e-3 && (b.height() - 4.0).abs() < 1e-3, "the group's fill: {b:?}");
let (g, plain) = (read.heads[8].path.bounds().unwrap(), read.heads[1].path.bounds().unwrap());
let near = |a: f64, b: f64| (a - b).abs() < 1e-3;
assert!(near(g.x0, plain.x0) && near(g.x1, plain.x1) && near(g.y0, plain.y0) && near(g.y1, plain.y1), "the gradient stroke's outline: {g:?}");
let framed = read.heads[6].path.to_bezpath();
let b = vectorcraft_geom::Shape::bounding_box(&framed);
assert!((b.width() - 4.0).abs() < 1e-3 && (b.height() - 4.0).abs() < 1e-3, "6 × 6 with the stroke: {b:?}");
assert_ne!(vectorcraft_geom::Shape::winding(&framed, Point::new(0.1, 0.1)), 0, "the clipped bar fills the middle");
for h in &read.heads[3..6] {
let b = h.path.bounds().unwrap();
assert!((b.width() - 6.0).abs() < 1e-3 && (b.height() - 4.0).abs() < 1e-3, "{}: the effect's outline: {b:?}", h.name);
}
let skipped: Vec<&str> = read.skipped.iter().map(|(n, _)| n.as_str()).collect();
assert_eq!(skipped, ["Ghost", "Needle", "Clear", "Dense"], "{:?}", read.skipped);
let b = read.heads[2].path.bounds().unwrap();
assert!((b.width() - 4.0).abs() < 1e-3 && (b.height() - 4.0).abs() < 1e-3, "clipped to the square: {b:?}");
let b = read.heads[0].path.bounds().unwrap();
assert!(b.x0.abs() < 1e-6 && (b.x1 - 4.0).abs() < 1e-6 && (b.y0 + 2.0).abs() < 1e-6 && (b.y1 - 2.0).abs() < 1e-6, "{b:?}");
assert!((read.heads[0].reach() - 4.0f64.hypot(2.0)).abs() < 1e-6);
let b = read.heads[1].path.bounds().unwrap();
assert!((b.x0 + 5.0).abs() < 1e-3 && (b.x1 - 5.0).abs() < 1e-3 && (b.y0 + 2.0).abs() < 1e-3 && (b.y1 - 2.0).abs() < 1e-3, "{b:?}");
}
#[test]
fn the_arrowheads_file_is_made_opened_and_reloaded_when_saved() {
let dir = folder("edit");
let file = dir.join(arrowheads::FILE_NAME).to_string_lossy().to_string();
let (mut s, id) = session_with_line();
s.attach_arrowheads_file(file.clone());
let list = s.execute("stroke.arrowheads.list", &json!({})).unwrap();
assert_eq!((list["arrowheads"].as_array().unwrap().len(), list["file"].as_str()), (0, Some(file.as_str())));
assert!(s.execute("stroke.set", &json!({"endArrow": {"custom": "Example Arrowhead"}})).is_err(), "no such head yet");
let line_doc = s.doc().unwrap().uid;
let r = s.execute("stroke.arrowheads.edit", &json!({})).unwrap();
assert_eq!(r["created"], true);
assert_eq!(s.doc().unwrap().path.as_deref(), Some(file.as_str()), "the file is the active document");
let list = s.execute("stroke.arrowheads.list", &json!({})).unwrap();
assert_eq!(list["arrowheads"][0]["name"], "Example Arrowhead");
// A second head drawn in the file is listed once the file is saved.
let st = s.doc_mut().unwrap();
let d = Arc::make_mut(&mut st.doc);
add_symbol(d, "Square", polygon(&[(0.0, 0.0), (4.0, 0.0), (4.0, 4.0), (0.0, 4.0)], filled()));
assert_eq!(s.execute("stroke.arrowheads.list", &json!({})).unwrap()["arrowheads"].as_array().unwrap().len(), 1);
s.execute("document.save", &json!({})).unwrap();
let list = s.execute("stroke.arrowheads.list", &json!({})).unwrap();
let names: Vec<&str> = list["arrowheads"].as_array().unwrap().iter().filter_map(|h| h["name"].as_str()).collect();
assert_eq!(names, ["Example Arrowhead", "Square"]);
// Editing again from the line's document brings the open file to the front, without making
// it anew or opening it twice.
s.execute("document.activate", &json!({"index": 0})).unwrap();
assert_eq!(s.doc().unwrap().uid, line_doc);
assert_eq!(s.execute("stroke.arrowheads.edit", &json!({})).unwrap()["created"], false);
assert_eq!((s.documents().len(), s.doc().unwrap().path.as_deref()), (2, Some(file.as_str())));
// The line takes the head with its art.
s.execute("document.activate", &json!({"index": 0})).unwrap();
s.execute("stroke.set", &json!({"ids": [id.0], "endArrow": {"custom": "Square"}})).unwrap();
let Some(Arrowhead::Custom(c)) = stroke(&s, id).end_arrow else { panic!("a custom head") };
assert_eq!(c.name, "Square");
assert!(s.execute("stroke.set", &json!({"endArrow": {"custom": 3}})).is_err());
assert!(s.execute("stroke.set", &json!({"endArrow": {"custom": "Circle"}})).is_err());
// A head given with its art is refused when the art could not be drawn.
let mut far = serde_json::to_value(Arrowhead::Custom(c.clone())).unwrap();
far["Custom"]["path"] = serde_json::to_value(PathData::single(SubPath::polyline(&[Point::new(0.0, 0.0), Point::new(1e6, 1.0)], true))).unwrap();
assert!(s.execute("stroke.set", &json!({"ids": [id.0], "endArrow": far})).is_err());
let mut empty = serde_json::to_value(Arrowhead::Custom(c)).unwrap();
empty["Custom"]["path"] = json!({"subpaths": []});
assert!(s.execute("stroke.set", &json!({"ids": [id.0], "endArrow": empty})).is_err());
assert!(matches!(stroke(&s, id).end_arrow, Some(Arrowhead::Custom(h)) if h.name == "Square"), "the head stays as it was");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn a_document_keeps_its_custom_heads_without_the_arrowheads_file() {
let (mut s, id) = session_with_line();
let mut d = Document::new(100.0, 100.0);
add_symbol(&mut d, "Wedge", polygon(&[(0.0, -10.0), (20.0, 0.0), (0.0, 10.0)], filled()));
let head = Arrowhead::Custom(Arc::new(heads_of(&d).heads.remove(0)));
// As document.inspect reports it, so it reads back unchanged.
s.execute("stroke.set", &json!({"startArrow": head, "endArrow": "Triangle"})).unwrap();
assert_eq!(stroke(&s, id).start_arrow, Some(head.clone()));
let inspect = s.execute("document.inspect", &json!({})).unwrap().to_string();
assert!(inspect.contains("\"Wedge\""), "inspect names the head");
let ink = |s: &Session| {
vectorcraft_render::Renderer::new().render(&s.doc().unwrap().doc, 200, 200, vectorcraft_geom::Affine::IDENTITY, &Default::default())
};
let with_head = ink(&s);
// Saved and opened in a session that has no arrowheads file: the head is drawn the same.
let saved = s.execute("document.export", &json!({"format": "vectorcraft"})).unwrap()["dataBase64"].clone();
let mut o = Session::new();
o.execute("document.open", &json!({"name": "a.vectorcraft", "dataBase64": saved})).unwrap();
assert_eq!(stroke(&o, id).start_arrow, Some(head));
assert_eq!(ink(&o).pixels, with_head.pixels);
// SVG and PDF write it as they write the built-in heads.
let svg = |s: &mut Session| s.execute("document.export", &json!({"format": "svg"})).unwrap()["dataBase64"].as_str().unwrap().len();
let headed = svg(&mut o);
o.execute("stroke.set", &json!({"ids": [id.0], "startArrow": null})).unwrap();
assert!(headed > svg(&mut o), "the head is in the SVG");
// Undo puts it back.
o.execute("edit.undo", &json!({})).unwrap();
assert!(matches!(stroke(&o, id).start_arrow, Some(Arrowhead::Custom(_))));
}
#[test]
fn an_unreadable_arrowheads_file_lists_no_heads_and_says_why() {
let dir = folder("bad");
let file = dir.join(arrowheads::FILE_NAME);
std::fs::write(&file, b"not a drawing").unwrap();
let mut s = Session::new();
s.attach_arrowheads_file(file.to_string_lossy().to_string());
let list = s.execute("stroke.arrowheads.list", &json!({})).unwrap();
assert!(list["arrowheads"].as_array().unwrap().is_empty());
assert!(list["error"].is_string(), "{list}");
assert!(s.execute("stroke.arrowheads.reload", &json!({})).is_err());
// A folder in its place is not read either.
std::fs::remove_file(&file).unwrap();
std::fs::create_dir(&file).unwrap();
assert!(s.execute("stroke.arrowheads.reload", &json!({})).is_err());
let _ = std::fs::remove_dir_all(&dir);
// Without a file (the web, runs without preferences) there is nothing to edit.
let mut bare = Session::new();
assert!(bare.execute("stroke.arrowheads.edit", &json!({})).is_err());
assert_eq!(bare.execute("stroke.arrowheads.list", &json!({})).unwrap()["file"], serde_json::Value::Null);
}
+1 -1
View File
@@ -2109,7 +2109,7 @@ fn empty_artboard_checks_match_the_visual_bounds_on_varied_strokes() {
st.cap = [LineCap::Butt, LineCap::Round, LineCap::Square][next(3) as usize];
st.align = [StrokeAlign::Center, StrokeAlign::Inside, StrokeAlign::Outside][next(3) as usize];
st.miter_limit = 1.0 + next(20) as f64;
let mut head = || (next(3) == 0).then(|| Arrowhead::ALL[next(Arrowhead::ALL.len() as u64) as usize]);
let mut head = || (next(3) == 0).then(|| Arrowhead::ALL[next(Arrowhead::ALL.len() as u64) as usize].clone());
(st.start_arrow, st.end_arrow) = (head(), head());
st.arrow_scale = (50.0 + next(250) as f64, 50.0 + next(250) as f64);
if next(3) == 0 {
+1 -1
View File
@@ -51,7 +51,7 @@ fn profiles_come_from_the_preset_catalogue() {
assert!(spec.params.contains("arrowAlign"));
// …and every arrowhead name `startArrow`/`endArrow` accept.
for a in vectorcraft_doc::Arrowhead::ALL {
let name = serde_json::to_value(a).unwrap();
let name = serde_json::to_value(&a).unwrap();
assert!(spec.params.contains(name.as_str().unwrap()), "{name}");
s.execute("stroke.set", &json!({"endArrow": name})).unwrap();
assert_eq!(stroke(&s, id).end_arrow, Some(a));