M3.18: shared stroke-geometry module for arrowheads, width profiles and the profile preset catalogue

Arrowheads, width-profile outlines and dashed line outlines now come from
vectorcraft_effects::stroke (stroke_pieces, line_outline, width_outline), used by
the canvas and PDF export alike; the renderer caches profile outlines too. Width
profiles are a doc-level catalogue (WidthProfile::PRESETS, preset, preset_id,
id_of) shared by stroke.set and the Stroke panel; stroke.set rejects unknown
profile ids and its params doc lists the arrowhead names.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
bflatastic
2026-10-04 17:16:17 +07:00
co-authored by Claude Opus 5.5
parent 8f9d6ef2bd
commit 09d7aaa7f4
17 changed files with 526 additions and 289 deletions
+11 -10
View File
@@ -2,7 +2,7 @@
use serde_json::Value;
use vectorcraft_color::{Color, Paint};
use vectorcraft_doc::{Arrowhead, Dash, LineCap, LineJoin, StrokeAlign, StrokeLayer};
use vectorcraft_doc::{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, dashOffset?, alignDashes?, startArrow?, endArrow?: name|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, dashOffset?, alignDashes?, startArrow?, endArrow?: Arrow|ArrowOpen|Triangle|TriangleOpen|Circle|CircleOpen|Square|SquareOpen|Diamond|Bar|null, profile?: \"uniform\"|\"lens\"|\"taperStart\"|\"taperEnd\", ids?}",
has_doc,
stroke_set
),
@@ -48,6 +48,11 @@ fn stroke_set(s: &mut Session, p: &Value) -> Result<Value> {
}
};
let (sa, ea) = (arrow("startArrow")?, arrow("endArrow")?);
let profile = match str_param(p, "profile") {
None => None,
Some("uniform") => Some(None),
Some(id) => Some(Some(WidthProfile::preset(id).ok_or_else(|| bad("stroke.set", format!("unknown profile {id}")))?)),
};
s.edit("Stroke", |d, _| {
for id in &ids {
let Some(n) = d.node_mut(*id) else { continue };
@@ -97,12 +102,8 @@ fn stroke_set(s: &mut Session, p: &Value) -> Result<Value> {
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()),
_ => {}
if let Some(pr) = &profile {
st.profile = pr.clone();
}
}
Ok(())
@@ -111,11 +112,11 @@ fn stroke_set(s: &mut Session, p: &Value) -> Result<Value> {
}
/// 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 {
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));
vectorcraft_doc::WidthProfile { points: pts }
WidthProfile { points: pts }
}
fn stroke_advanced(s: &mut Session, p: &Value) -> Result<Value> {