mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-30 20:58:00 -06:00
M3.20: arrowheads: hollow outlines, tip/extend alignment, one opacity for line and head
Hollow heads draw as rings (the open arrow as a chevron) and closed paths get
no heads. The stroke stops under each head, so it never shows through a hollow
head or past the tip; stroke.set {arrowAlign: "extend"|"tip"} chooses whether
the tip extends past the end point (default) or sits on it with the stroke
shortened. Line and heads share one paint box (a gradient runs on into the
head) and take the stroke and object opacity once, as one layer on the canvas
and one group in PDF. StrokeLayer.arrow_align is a new defaulted field, so
older files load unchanged. The Stroke panel's Align buttons get two generated
icons and are disabled when the stroke has no head.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
23f19a861b
commit
5ae7bfb8ca
@@ -2,7 +2,7 @@
|
||||
|
||||
use serde_json::Value;
|
||||
use vectorcraft_color::{Color, Paint};
|
||||
use vectorcraft_doc::{Arrowhead, Dash, LineCap, LineJoin, StrokeAlign, StrokeLayer, WidthProfile};
|
||||
use vectorcraft_doc::{ArrowAlign, Arrowhead, Dash, LineCap, LineJoin, StrokeAlign, StrokeLayer, WidthProfile};
|
||||
|
||||
use super::*;
|
||||
|
||||
@@ -13,7 +13,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Stroke Options",
|
||||
["Window", "Stroke"],
|
||||
None,
|
||||
"{weight?, 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), dashOffset?, alignDashes?, startArrow?, endArrow?: Arrow|ArrowOpen|Triangle|TriangleOpen|Circle|CircleOpen|Square|SquareOpen|Diamond|Bar|null, profile?: \"uniform\"|\"lens\"|\"taperStart\"|\"taperEnd\", ids?}",
|
||||
"{weight?, 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), dashOffset?, alignDashes?, startArrow?, endArrow?: Arrow|ArrowOpen|Triangle|TriangleOpen|Circle|CircleOpen|Square|SquareOpen|Diamond|Bar|null, arrowAlign?: \"extend\" (tip past the end point, default)|\"tip\" (tip on the end point; the stroke is shortened), profile?: \"uniform\"|\"lens\"|\"taperStart\"|\"taperEnd\", ids?}",
|
||||
has_doc,
|
||||
stroke_set
|
||||
),
|
||||
@@ -48,6 +48,12 @@ fn stroke_set(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
};
|
||||
let (sa, ea) = (arrow("startArrow")?, arrow("endArrow")?);
|
||||
let arrow_align = match str_param(p, "arrowAlign") {
|
||||
None => None,
|
||||
Some("extend") => Some(ArrowAlign::Extend),
|
||||
Some("tip") => Some(ArrowAlign::Tip),
|
||||
Some(o) => return Err(bad("stroke.set", format!("arrowAlign must be extend|tip, got {o}"))),
|
||||
};
|
||||
let profile = match str_param(p, "profile") {
|
||||
None => None,
|
||||
Some("uniform") => Some(None),
|
||||
@@ -102,6 +108,9 @@ fn stroke_set(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if let Some(a) = ea {
|
||||
st.end_arrow = a;
|
||||
}
|
||||
if let Some(a) = arrow_align {
|
||||
st.arrow_align = a;
|
||||
}
|
||||
if let Some(pr) = &profile {
|
||||
st.profile = pr.clone();
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
//! Stroke geometry from the Stroke panel's commands (`cmd/stroke.rs`): the width-profile presets.
|
||||
//! Stroke geometry from the Stroke panel's commands (`cmd/stroke.rs`): arrowhead alignment and
|
||||
//! the width-profile presets.
|
||||
|
||||
use serde_json::json;
|
||||
use vectorcraft_doc::{StrokeLayer, WidthProfile};
|
||||
use vectorcraft_doc::{ArrowAlign, StrokeLayer, WidthProfile};
|
||||
|
||||
use super::*;
|
||||
|
||||
@@ -16,6 +17,20 @@ fn stroke(s: &Session, id: NodeId) -> StrokeLayer {
|
||||
s.doc().unwrap().doc.node(id).unwrap().appearance.stroke().unwrap().clone()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arrow_align_is_set_per_stroke_and_validated() {
|
||||
let (mut s, id) = session_with_line();
|
||||
assert_eq!(stroke(&s, id).arrow_align, ArrowAlign::Extend);
|
||||
s.execute("stroke.set", &json!({"endArrow": "TriangleOpen", "arrowAlign": "tip"})).unwrap();
|
||||
let st = stroke(&s, id);
|
||||
assert_eq!((st.end_arrow, st.arrow_align), (Some(vectorcraft_doc::Arrowhead::TriangleOpen), ArrowAlign::Tip));
|
||||
s.execute("stroke.set", &json!({"arrowAlign": "extend", "ids": [id.0]})).unwrap();
|
||||
assert_eq!(stroke(&s, id).arrow_align, ArrowAlign::Extend);
|
||||
assert!(s.execute("stroke.set", &json!({"arrowAlign": "middle"})).is_err());
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(stroke(&s, id).arrow_align, ArrowAlign::Tip);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn profiles_come_from_the_preset_catalogue() {
|
||||
let (mut s, id) = session_with_line();
|
||||
@@ -28,20 +43,27 @@ fn profiles_come_from_the_preset_catalogue() {
|
||||
s.execute("stroke.set", &json!({"profile": "uniform"})).unwrap();
|
||||
assert!(stroke(&s, id).profile.is_none(), "uniform is the plain stroke");
|
||||
assert!(s.execute("stroke.set", &json!({"profile": "zigzag"})).is_err());
|
||||
// The params doc (what agents read) lists every preset.
|
||||
// The params doc (what agents read) lists every preset and the arrow alignment.
|
||||
let spec = command_specs().iter().find(|c| c.id == "stroke.set").unwrap();
|
||||
for p in WidthProfile::PRESETS {
|
||||
assert!(spec.params.contains(&format!("\"{}\"", p.id)), "{}", p.id);
|
||||
}
|
||||
assert!(spec.params.contains("arrowAlign"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn profiles_survive_the_native_format() {
|
||||
fn arrow_align_and_profiles_survive_the_native_format() {
|
||||
let (mut s, id) = session_with_line();
|
||||
s.execute("stroke.set", &json!({"startArrow": "CircleOpen", "endArrow": "Arrow", "profile": "taperEnd"})).unwrap();
|
||||
s.execute(
|
||||
"stroke.set",
|
||||
&json!({"startArrow": "CircleOpen", "endArrow": "Arrow", "arrowAlign": "tip", "profile": "lens", "dash": [0, 6], "cap": "round"}),
|
||||
)
|
||||
.unwrap();
|
||||
let doc = &s.doc().unwrap().doc;
|
||||
let back = vectorcraft_format::load(&vectorcraft_format::save(doc, false)).unwrap();
|
||||
let st = back.node(id).unwrap().appearance.stroke().unwrap().clone();
|
||||
assert_eq!(st, stroke(&s, id));
|
||||
assert_eq!(WidthProfile::id_of(st.profile.as_ref()), "taperEnd");
|
||||
assert_eq!(back.node(id).unwrap().appearance.stroke().unwrap(), &stroke(&s, id));
|
||||
// A stroke with the default alignment writes nothing new, so older builds read it unchanged.
|
||||
s.execute("stroke.set", &json!({"arrowAlign": "extend"})).unwrap();
|
||||
let text = String::from_utf8(vectorcraft_format::save(&s.doc().unwrap().doc, true)).unwrap();
|
||||
assert!(!text.contains("arrow_align"));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user