Files
vectorcraft/crates/engine/src/cmd/stroke.rs
T
bflatasticandClaude Opus 5.5 42ad4540b7 M3.38: dashes fitted to corners and path ends, a flag of their own that keeps the offset
With alignDashes every run between corners and path ends holds a whole number of
periods, scaled to fit, with a dash (or dot) centred on each corner and on both
ends of an open path. It lives in the shared dash generator, so the canvas, SVG
and PDF export (as outlines) and Outline Stroke all draw it.

stroke.set: alignDashes and dashOffset edit the current pattern on their own, and
a new dash array keeps the offset and alignment it replaces. The Stroke panel
turns dashes on fitted to corners (or as last turned off), and its Exact/Fit
buttons send only the flag.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 23:37:58 +07:00

178 lines
7.6 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! The Stroke panel: weight, cap, join, alignment, dashes, arrowheads, width profiles and brushes.
use serde_json::Value;
use vectorcraft_color::{Color, Paint};
use vectorcraft_doc::{ArrowAlign, Arrowhead, Dash, LineCap, LineJoin, StrokeAlign, WidthProfile};
use super::appearance::{edit_items, item_target};
use super::*;
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(
"stroke.set",
"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; a new pattern keeps the current offset and alignment), dashOffset? (exact dashes only), alignDashes?: bool (true: dashes fitted to corners and path ends, every run between them holding whole periods with a dash centred on each corner and end; false, the default for a new pattern: exact lengths), startArrow?, endArrow?: Arrow|ArrowOpen|Triangle|TriangleOpen|Circle|CircleOpen|Square|SquareOpen|Diamond|Bar|null, arrowAlign?: \"extend\" (tip past the end point, default)|\"tip\" (tip on the end point; the stroke is shortened), profile?: \"uniform\"|\"lens\"|\"taperStart\"|\"taperEnd\", 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?}",
has_doc,
stroke_set
),
cmd!(
"stroke.setAdvanced",
"Stroke Options",
[],
None,
"{arrowScale?: [start %, end %], swapArrows?: bool, flipProfile?: \"along\"|\"across\", brush?: name|null, item?: stroke item index|null, ids?} Stroke panel extras",
has_doc,
stroke_advanced
),
]
}
fn stroke_set(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "stroke.set";
if let Some(w) = p.get("weight").and_then(Value::as_f64) {
s.paint.stroke_width = w.max(0.0);
}
let item = item_target(s, p, C)?.of_kind(s, false);
let ids = item.targets(s, p)?;
let arrow = |k: &str| -> Result<Option<Option<Arrowhead>>> {
match p.get(k) {
None => Ok(None),
Some(Value::Null) => Ok(Some(None)),
Some(Value::String(n)) if n == "none" => Ok(Some(None)),
Some(v) => serde_json::from_value::<Arrowhead>(v.clone()).map(|a| Some(Some(a))).map_err(|_| bad(C, format!("unknown arrowhead {v}"))),
}
};
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(C, format!("arrowAlign must be extend|tip, got {o}"))),
};
let profile = match str_param(p, "profile") {
None => None,
Some("uniform") => Some(None),
Some(id) => Some(Some(WidthProfile::preset(id).ok_or_else(|| bad(C, format!("unknown profile {id}")))?)),
};
edit_items(s, &ids, item, C, "Stroke", false, |n, index| {
if index.is_none() && n.appearance.stroke().is_none() {
n.appearance.set_stroke(Paint::solid(Color::BLACK));
}
let Some(st) = n.appearance.stroke_at_mut(index) else { return Ok(()) };
if let Some(w) = p.get("weight").and_then(Value::as_f64) {
st.width = w.max(0.0);
}
match str_param(p, "cap") {
Some("round") => st.cap = LineCap::Round,
Some("square") | Some("projecting") => st.cap = LineCap::Square,
Some("butt") => st.cap = LineCap::Butt,
_ => {}
}
match str_param(p, "join") {
Some("round") => st.join = LineJoin::Round,
Some("bevel") => st.join = LineJoin::Bevel,
Some("miter") => st.join = LineJoin::Miter,
_ => {}
}
if let Some(m) = p.get("miterLimit").and_then(Value::as_f64) {
st.miter_limit = m.clamp(1.0, 500.0);
}
match str_param(p, "align") {
Some("inside") => st.align = StrokeAlign::Inside,
Some("outside") => st.align = StrokeAlign::Outside,
Some("center") => st.align = StrokeAlign::Center,
_ => {}
}
match p.get("dash") {
Some(Value::Null) => st.dash = None,
Some(Value::Array(a)) => {
let pattern: Vec<f64> = a.iter().filter_map(Value::as_f64).collect();
// A new pattern keeps the offset and alignment of the one it replaces.
st.dash = (!pattern.is_empty()).then(|| Dash { pattern, ..st.dash.take().unwrap_or_default() });
}
_ => {}
}
if let Some(d) = st.dash.as_mut() {
if let Some(o) = p.get("dashOffset").and_then(Value::as_f64) {
d.offset = o;
}
if let Some(a) = p.get("alignDashes").and_then(Value::as_bool) {
d.align_corners = a;
}
}
if let Some(a) = sa {
st.start_arrow = a;
}
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();
}
Ok(())
})?;
ok()
}
/// Mirror a width profile along the path (t → 1 − t) or across it (swap left/right widths).
pub(crate) fn flip_profile(p: &WidthProfile, along: bool) -> WidthProfile {
let mut pts: Vec<(f64, f64, f64)> =
if along { p.points.iter().map(|&(t, l, r)| (1.0 - t, l, r)).collect() } else { p.points.iter().map(|&(t, l, r)| (t, r, l)).collect() };
pts.sort_by(|a, b| a.0.total_cmp(&b.0));
WidthProfile { points: pts }
}
fn stroke_advanced(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "stroke.setAdvanced";
let scale = match p.get("arrowScale") {
None => None,
Some(v) => {
let a = v.as_array().filter(|a| a.len() == 2).ok_or_else(|| bad(C, "arrowScale must be [start, end]"))?;
let f = |i: usize| a[i].as_f64().map(|x| x.clamp(1.0, 1000.0));
Some((f(0).ok_or_else(|| bad(C, "bad arrowScale"))?, f(1).ok_or_else(|| bad(C, "bad arrowScale"))?))
}
};
let flip = match str_param(p, "flipProfile") {
None => None,
Some("along") => Some(true),
Some("across") => Some(false),
Some(o) => return Err(bad(C, format!("flipProfile must be along|across, got {o}"))),
};
let swap = bool_or(p, "swapArrows", false);
let brush = match p.get("brush") {
None => None,
Some(Value::Null) => Some(None),
Some(Value::String(b)) => Some(Some(b.clone())),
Some(_) => return Err(bad(C, "brush must be a name or null")),
};
if scale.is_none() && flip.is_none() && !swap && brush.is_none() {
return Err(bad(C, "nothing to change"));
}
let item = item_target(s, p, C)?.of_kind(s, false);
let ids = item.targets(s, p)?;
edit_items(s, &ids, item, C, "Stroke", false, |n, index| {
let Some(st) = n.appearance.stroke_at_mut(index) else { return Ok(()) };
if let Some(sc) = scale {
st.arrow_scale = sc;
}
if swap {
std::mem::swap(&mut st.start_arrow, &mut st.end_arrow);
st.arrow_scale = (st.arrow_scale.1, st.arrow_scale.0);
}
if let (Some(along), Some(pr)) = (flip, &st.profile) {
st.profile = Some(flip_profile(pr, along));
}
if let Some(b) = &brush {
st.brush = b.clone();
}
Ok(())
})?;
ok()
}