//! 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::{Arrowhead, Dash, LineCap, LineJoin, StrokeAlign, StrokeLayer}; use super::*; pub fn specs() -> Vec { 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, dashOffset?, alignDashes?, startArrow?, endArrow?: name|null, profile?: \"uniform\"|\"lens\"|\"taperStart\"|\"taperEnd\", ids?}", has_doc, stroke_set ), cmd!( "stroke.setAdvanced", "Stroke Options", [], None, "{arrowScale?: [start %, end %], swapArrows?: bool, flipProfile?: \"along\"|\"across\", brush?: name|null, ids?} Stroke panel extras", has_doc, stroke_advanced ), ] } fn stroke_set(s: &mut Session, p: &Value) -> Result { if let Some(w) = p.get("weight").and_then(Value::as_f64) { s.paint.stroke_width = w.max(0.0); } let ids = paint_targets(s, p)?; if ids.is_empty() { return ok(); } let arrow = |k: &str| -> Result>> { 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::(v.clone()).map(|a| Some(Some(a))).map_err(|_| bad("stroke.set", format!("unknown arrowhead {v}"))) } } }; let (sa, ea) = (arrow("startArrow")?, arrow("endArrow")?); s.edit("Stroke", |d, _| { for id in &ids { let Some(n) = d.node_mut(*id) else { continue }; if n.appearance.stroke().is_none() { n.appearance.set_stroke(Paint::solid(Color::BLACK)); } let st: &mut StrokeLayer = n.appearance.stroke_mut().unwrap(); 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 = a.iter().filter_map(Value::as_f64).collect(); st.dash = if pattern.is_empty() { None } else { Some(Dash { pattern, offset: f64_or(p, "dashOffset", 0.0), align_corners: bool_or(p, "alignDashes", false) }) }; } _ => {} } if let Some(a) = sa { st.start_arrow = a; } if let Some(a) = ea { st.end_arrow = a; } match str_param(p, "profile") { Some("uniform") => st.profile = None, Some("lens") => st.profile = Some(vectorcraft_doc::WidthProfile::lens()), Some("taperStart") => st.profile = Some(vectorcraft_doc::WidthProfile::taper_start()), Some("taperEnd") => st.profile = Some(vectorcraft_doc::WidthProfile::taper_end()), _ => {} } } 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: &vectorcraft_doc::WidthProfile, along: bool) -> vectorcraft_doc::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)); vectorcraft_doc::WidthProfile { points: pts } } fn stroke_advanced(s: &mut Session, p: &Value) -> Result { 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 ids = paint_targets(s, p)?; if ids.is_empty() { return ok(); } s.edit("Stroke", |d, _| { for id in &ids { let Some(n) = d.node_mut(*id) else { continue }; let Some(st) = n.appearance.stroke_mut() else { continue }; 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() }