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
+21 -56
View File
@@ -12,22 +12,21 @@ mod live;
mod paint;
mod pattern;
pub mod proof;
mod width;
use std::collections::HashMap;
use std::sync::Arc;
use vectorcraft_doc::{AppearanceItem, Document, LineCap, LineJoin, Node, NodeId, NodeKind, StrokeAlign, StrokeLayer, TextObject};
use vectorcraft_doc::{AppearanceItem, Document, Node, NodeId, NodeKind, StrokeAlign, StrokeLayer, TextObject};
use vectorcraft_geom::{Affine, BezPath, FillRule, Rect, Shape};
use vello_cpu::kurbo;
use vello_cpu::peniko::{self, BlendMode, Compose, Mix};
use vello_cpu::{Pixmap, RenderContext, Resources};
pub use effects::stroke::width_outline;
pub use live::expand_live;
pub use pattern::render_pattern_swatch;
pub use vectorcraft_effects as effects;
pub use vello_cpu;
pub use width::width_outline;
/// Rendering options.
#[derive(Clone, Debug)]
@@ -653,36 +652,10 @@ impl Renderer {
ctx.push_layer(None, Some(blend_mode(st.blend)), Some(st.opacity), None, None);
}
ctx.set_transform(f.view);
let closed = bp.elements().last().is_some_and(|e| matches!(e, kurbo::PathEl::ClosePath));
let width = match st.align {
StrokeAlign::Center => st.width,
_ if closed => st.width * 2.0,
_ => st.width,
};
// Keep hairlines visible when zoomed far out (Illustrator shows at least ~1 device pixel).
let width = width.max(f.px * 0.5);
let mut stroke = kurbo::Stroke::new(width)
.with_join(match st.join {
LineJoin::Miter => kurbo::Join::Miter,
LineJoin::Round => kurbo::Join::Round,
LineJoin::Bevel => kurbo::Join::Bevel,
})
.with_caps(match st.cap {
LineCap::Butt => kurbo::Cap::Butt,
LineCap::Round => kurbo::Cap::Round,
LineCap::Square => kurbo::Cap::Square,
})
.with_miter_limit(st.miter_limit);
if let Some(d) = &st.dash
&& d.pattern.iter().any(|v| *v > 0.0)
{
let mut pat = d.pattern.clone();
if pat.len() % 2 == 1 {
pat.extend(pat.clone());
}
stroke = stroke.with_dashes(d.offset, pat);
}
ctx.set_stroke(stroke.clone());
let pieces = effects::stroke::stroke_pieces(bp, st);
let closed = effects::stroke::is_closed(bp);
// Keep hairlines visible when zoomed far out (at least ~1 device pixel).
let width = effects::stroke::aligned_width(st, closed).max(f.px * 0.5);
let inside = st.align == StrokeAlign::Inside && closed;
if inside {
ctx.set_fill_rule(fill_rule(rule));
@@ -690,14 +663,10 @@ impl Renderer {
}
if paint::set_paint(ctx, &st.paint, bounds.inflate(st.width / 2.0, st.width / 2.0), f.doc) {
self.fold_alpha(ctx, &st.paint);
if let Some(o) = width::outline_for(bp, st, f.px * 0.25) {
ctx.set_fill_rule(peniko::Fill::NonZero);
ctx.fill_path(&o);
} else if let Some(o) = self.cached_stroke(f, bp, st, &stroke) {
ctx.set_fill_rule(peniko::Fill::NonZero);
ctx.fill_path(&o);
} else {
ctx.stroke_path(bp);
ctx.set_fill_rule(peniko::Fill::NonZero);
match self.cached_stroke(f, &pieces.line, st, width) {
Some(o) => ctx.fill_path(&o),
None => ctx.fill_path(&effects::stroke::line_outline(&pieces.line, st, width, f.px * 0.25)),
}
}
if inside {
@@ -711,14 +680,10 @@ impl Renderer {
ctx.fill_path(bp);
ctx.set_blend_mode(BlendMode::default());
}
// Arrowheads.
if st.start_arrow.is_some() || st.end_arrow.is_some() {
for (arrow, head) in paint::arrowheads(bp, st) {
let _ = arrow;
if paint::set_paint(ctx, &st.paint, bounds, f.doc) {
ctx.set_fill_rule(peniko::Fill::NonZero);
ctx.fill_path(&head);
}
if !pieces.heads.is_empty() && paint::set_paint(ctx, &st.paint, bounds, f.doc) {
ctx.set_fill_rule(peniko::Fill::NonZero);
for head in &pieces.heads {
ctx.fill_path(&head.outline);
}
}
if layered {
@@ -726,21 +691,21 @@ impl Renderer {
}
}
/// The stroke of the fast-path node `self.cur` expanded to a fill outline in document space,
/// cached across frames. The tolerance (vello's 0.25 device px) is bucketed to powers of two,
/// never coarser than needed, so zooming reuses outlines until the level changes.
fn cached_stroke(&mut self, f: &Frame, bp: &BezPath, st: &StrokeLayer, stroke: &kurbo::Stroke) -> Option<Arc<BezPath>> {
/// The line part of a stroke of the fast-path node `self.cur` (see
/// [`effects::stroke::line_outline`]), cached across frames. The tolerance (vello's 0.25
/// device px) is bucketed to powers of two, never coarser than needed, so zooming reuses
/// outlines until the level changes.
fn cached_stroke(&mut self, f: &Frame, line: &BezPath, st: &StrokeLayer, width: f64) -> Option<Arc<BezPath>> {
let node = self.cur.clone()?;
let level = (f.px * 0.25).log2().floor() as i32;
let key = (st as *const StrokeLayer as usize, stroke.width.to_bits(), level);
let key = (st as *const StrokeLayer as usize, width.to_bits(), level);
if let Some(e) = self.strokes.get_mut(&key)
&& Arc::ptr_eq(&e.node, &node)
{
e.stamp = self.stamp;
return Some(e.outline.clone());
}
let tolerance = 2f64.powi(level);
let outline = Arc::new(kurbo::stroke(bp.iter(), stroke, &kurbo::StrokeOpts::default(), tolerance));
let outline = Arc::new(effects::stroke::line_outline(line, st, width, 2f64.powi(level)));
self.strokes.insert(key, StrokeEntry { node, outline: outline.clone(), stamp: self.stamp });
Some(outline)
}
+3 -84
View File
@@ -1,10 +1,9 @@
//! Paint conversion (solid, gradients, patterns), arrowheads and image decoding.
//! Paint conversion (solid, gradients, patterns) and image decoding.
use vectorcraft_color::{GradientKind, Paint};
use vectorcraft_doc::{Arrowhead, Document, StrokeLayer};
use vectorcraft_geom::{Affine, BezPath, Point, Rect, Shape, Vec2};
use vectorcraft_doc::Document;
use vectorcraft_geom::{Affine, Rect, Vec2};
use vello_cpu::RenderContext;
use vello_cpu::kurbo::{self, ParamCurve, ParamCurveDeriv};
use vello_cpu::peniko::{self, ColorStop};
fn color(c: &vectorcraft_color::Color, alpha: f32) -> peniko::Color {
@@ -54,86 +53,6 @@ pub fn set_paint(ctx: &mut RenderContext, p: &Paint, bounds: Rect, doc: &Documen
}
}
/// Arrowhead geometry for a stroke: (which, filled outline) in document coordinates.
pub fn arrowheads(bp: &BezPath, st: &StrokeLayer) -> Vec<(Arrowhead, BezPath)> {
let segs: Vec<kurbo::PathSeg> = bp.segments().collect();
let mut out = Vec::new();
let (Some(first), Some(last)) = (segs.first(), segs.last()) else { return out };
if let Some(a) = st.start_arrow {
let p = first.eval(0.0);
let d = tangent(first, 0.0) * -1.0;
out.push((a, head(a, p, d, st.width * st.arrow_scale.0 / 100.0)));
}
if let Some(a) = st.end_arrow {
let p = last.eval(1.0);
let d = tangent(last, 1.0);
out.push((a, head(a, p, d, st.width * st.arrow_scale.1 / 100.0)));
}
out
}
fn tangent(s: &kurbo::PathSeg, t: f64) -> Vec2 {
let d = match s {
kurbo::PathSeg::Line(l) => l.p1 - l.p0,
kurbo::PathSeg::Quad(q) => q.deriv().eval(t).to_vec2(),
kurbo::PathSeg::Cubic(c) => {
let v = c.deriv().eval(t).to_vec2();
if v.hypot() < 1e-9 { c.p3 - c.p0 } else { v }
}
};
let l = d.hypot();
if l < 1e-12 { Vec2::new(1.0, 0.0) } else { d / l }
}
/// Arrowhead outline pointing along `dir` with its tip at `tip`, sized for stroke weight `w`.
fn head(kind: Arrowhead, tip: Point, dir: Vec2, w: f64) -> BezPath {
let w = w.max(0.25);
let n = Vec2::new(-dir.y, dir.x);
let len = 4.0 * w;
let half = 2.0 * w;
let base = tip - dir * len;
let mut p = BezPath::new();
match kind {
Arrowhead::Triangle | Arrowhead::TriangleOpen | Arrowhead::Arrow | Arrowhead::ArrowOpen => {
p.move_to(tip);
p.line_to(base + n * half);
if matches!(kind, Arrowhead::Arrow | Arrowhead::ArrowOpen) {
p.line_to(tip - dir * (len * 0.7));
}
p.line_to(base - n * half);
p.close_path();
}
Arrowhead::Circle | Arrowhead::CircleOpen => {
p = kurbo::Circle::new(tip - dir * half, half).to_path(0.01);
}
Arrowhead::Square | Arrowhead::SquareOpen => {
let c = tip - dir * half;
p.move_to(c + dir * half + n * half);
p.line_to(c - dir * half + n * half);
p.line_to(c - dir * half - n * half);
p.line_to(c + dir * half - n * half);
p.close_path();
}
Arrowhead::Diamond => {
let c = tip - dir * half;
p.move_to(tip);
p.line_to(c + n * half);
p.line_to(c - dir * half);
p.line_to(c - n * half);
p.close_path();
}
Arrowhead::Bar => {
let t = w * 0.75;
p.move_to(tip + n * half);
p.line_to(tip + n * half - dir * t);
p.line_to(tip - n * half - dir * t);
p.line_to(tip - n * half);
p.close_path();
}
}
p
}
/// Decode encoded image bytes into a premultiplied pixmap.
pub fn decode_pixmap(bytes: &[u8]) -> Option<vello_cpu::Pixmap> {
let img = image::load_from_memory(bytes).ok()?.to_rgba8();
-261
View File
@@ -1,261 +0,0 @@
//! Variable-width strokes (Width tool / width profiles).
//!
//! A stroke with a [`WidthProfile`] is drawn as a filled outline: each subpath is flattened, and
//! at every sample the left and right offsets `width/2 · factor(t)` (t = fraction of the
//! subpath's length) are placed along the (mitred) normal. Open subpaths get butt, round or
//! projecting caps (the round cap blends the two side widths); closed subpaths become two loops
//! of opposite orientation filled with the non-zero rule.
use vectorcraft_doc::{LineCap, StrokeAlign, StrokeLayer, WidthProfile};
use vectorcraft_geom::kurbo::{self, BezPath, PathEl, Point, Vec2};
/// The outline to fill instead of stroking, when `st` has a width profile (and no dashes).
pub(crate) fn outline_for(bp: &BezPath, st: &StrokeLayer, tol: f64) -> Option<BezPath> {
let profile = st.profile.as_ref()?;
if st.dash.as_ref().is_some_and(|d| d.pattern.iter().any(|v| *v > 0.0)) {
return None;
}
let closed = bp.elements().last().is_some_and(|e| matches!(e, PathEl::ClosePath));
let width = match st.align {
StrokeAlign::Center => st.width,
_ if closed => st.width * 2.0,
_ => st.width,
};
let o = width_outline(bp, width, profile, st.cap, tol);
(!o.elements().is_empty()).then_some(o)
}
/// Filled outline of `bp` stroked with `width` scaled by `profile`, flattened to `tol`.
pub fn width_outline(bp: &BezPath, width: f64, profile: &WidthProfile, cap: LineCap, tol: f64) -> BezPath {
let mut out = BezPath::new();
for (pts, closed) in subpaths(bp, tol.max(1e-4)) {
outline_subpath(&mut out, &pts, closed, width, profile, cap);
}
out
}
/// Flattened subpaths (consecutive duplicates removed; closed ones don't repeat the start).
fn subpaths(bp: &BezPath, tol: f64) -> Vec<(Vec<Point>, bool)> {
let mut out: Vec<(Vec<Point>, bool)> = vec![];
let mut cur: Vec<Point> = vec![];
let push = |cur: &mut Vec<Point>, p: Point| {
if cur.last().is_none_or(|l| l.distance(p) > 1e-9) {
cur.push(p);
}
};
kurbo::flatten(bp.iter(), tol, |el| match el {
PathEl::MoveTo(p) => {
if cur.len() > 1 {
out.push((std::mem::take(&mut cur), false));
}
cur.clear();
cur.push(p);
}
PathEl::LineTo(p) => push(&mut cur, p),
PathEl::ClosePath => {
if cur.len() > 1 && cur.first().unwrap().distance(*cur.last().unwrap()) <= 1e-9 {
cur.pop();
}
if cur.len() > 1 {
let start = cur[0];
out.push((std::mem::take(&mut cur), true));
cur.push(start);
} else {
cur.clear();
}
}
_ => {}
});
if cur.len() > 1 {
out.push((cur, false));
}
out
}
fn unit(v: Vec2) -> Vec2 {
let h = v.hypot();
if h > 1e-12 { v / h } else { Vec2::new(1.0, 0.0) }
}
/// Left normal in y-down space (left of the direction of travel).
fn left(t: Vec2) -> Vec2 {
Vec2::new(t.y, -t.x)
}
/// Insert samples where the profile has width points (so the piecewise-linear profile is exact
/// along straight runs).
fn densify(pts: &[Point], closed: bool, profile: &WidthProfile) -> Vec<Point> {
let n = pts.len();
let seg_count = if closed { n } else { n - 1 };
let total: f64 = (0..seg_count).map(|i| pts[i].distance(pts[(i + 1) % n])).sum();
if total <= 1e-12 {
return pts.to_vec();
}
let mut out = Vec::with_capacity(n + profile.points.len());
let mut acc = 0.0;
for i in 0..seg_count {
let (a, b) = (pts[i], pts[(i + 1) % n]);
let len = a.distance(b);
out.push(a);
for (t, _, _) in &profile.points {
let d = t * total;
if d > acc + 1e-9 && d < acc + len - 1e-9 {
out.push(a.lerp(b, (d - acc) / len));
}
}
acc += len;
}
if !closed {
out.push(pts[n - 1]);
}
out
}
fn outline_subpath(out: &mut BezPath, pts: &[Point], closed: bool, width: f64, profile: &WidthProfile, cap: LineCap) {
let pts = &densify(pts, closed, profile)[..];
let n = pts.len();
let seg_count = if closed { n } else { n - 1 };
let seg_dir: Vec<Vec2> = (0..seg_count).map(|i| unit(pts[(i + 1) % n] - pts[i])).collect();
let mut cum = vec![0.0; n + 1];
for i in 0..seg_count {
cum[i + 1] = cum[i] + pts[i].distance(pts[(i + 1) % n]);
}
let total = cum[seg_count];
if total <= 1e-12 {
return;
}
// Vertex normals (mitred, limited to 4× so sharp corners don't spike).
let normal_at = |i: usize| -> (Vec2, f64) {
let (a, b) = if closed {
(seg_dir[(i + seg_count - 1) % seg_count], seg_dir[i % seg_count])
} else if i == 0 {
(seg_dir[0], seg_dir[0])
} else if i == n - 1 {
(seg_dir[seg_count - 1], seg_dir[seg_count - 1])
} else {
(seg_dir[i - 1], seg_dir[i])
};
let (na, nb) = (left(a), left(b));
let nm = unit(na + nb);
let c = nm.dot(nb).max(0.25);
(nm, 1.0 / c)
};
let half = width / 2.0;
let mut lpts = Vec::with_capacity(n);
let mut rpts = Vec::with_capacity(n);
for (i, p) in pts.iter().enumerate() {
let (nm, miter) = normal_at(i);
let (fl, fr) = profile.at(cum[i] / total);
lpts.push(*p + nm * (miter * half * fl.max(0.0)));
rpts.push(*p - nm * (miter * half * fr.max(0.0)));
}
if closed {
polygon(out, lpts.iter().copied());
polygon(out, rpts.iter().rev().copied());
return;
}
let (t0, t1) = (seg_dir[0], seg_dir[seg_count - 1]);
let (fl0, fr0) = profile.at(0.0);
let (fl1, fr1) = profile.at(1.0);
let (wl0, wr0, wl1, wr1) = (half * fl0.max(0.0), half * fr0.max(0.0), half * fl1.max(0.0), half * fr1.max(0.0));
let mut ring: Vec<Point> = lpts.clone();
// End cap: from the left side around the end to the right side.
cap_points(&mut ring, pts[n - 1], left(t1), t1, wl1, wr1, cap);
ring.extend(rpts.iter().rev());
// Start cap: from the right side around the start back to the left side.
cap_points(&mut ring, pts[0], -left(t0), -t0, wr0, wl0, cap);
polygon(out, ring.into_iter());
}
/// Cap points between `c + u·r0` and `c − u·r1`, bulging towards `v`.
fn cap_points(ring: &mut Vec<Point>, c: Point, u: Vec2, v: Vec2, r0: f64, r1: f64, cap: LineCap) {
match cap {
LineCap::Butt => {}
LineCap::Square => {
let h = (r0 + r1) / 2.0;
ring.push(c + u * r0 + v * h);
ring.push(c - u * r1 + v * h);
}
LineCap::Round => {
let steps = 16;
for k in 1..steps {
let a = std::f64::consts::PI * k as f64 / steps as f64;
let r = r0 + (r1 - r0) * (k as f64 / steps as f64);
ring.push(c + u * (a.cos() * r) + v * (a.sin() * r));
}
}
}
}
fn polygon(out: &mut BezPath, mut pts: impl Iterator<Item = Point>) {
let Some(first) = pts.next() else { return };
out.move_to(first);
for p in pts {
out.line_to(p);
}
out.close_path();
}
#[cfg(test)]
mod tests {
use super::*;
use kurbo::Shape;
fn line() -> BezPath {
let mut b = BezPath::new();
b.move_to((0.0, 0.0));
b.line_to((100.0, 0.0));
b
}
fn area(b: &BezPath) -> f64 {
b.area().abs()
}
#[test]
fn uniform_profile_matches_a_plain_stroke() {
let p = WidthProfile { points: vec![(0.0, 1.0, 1.0), (1.0, 1.0, 1.0)] };
assert!((area(&width_outline(&line(), 10.0, &p, LineCap::Butt, 0.01)) - 1000.0).abs() < 1e-6);
let round = area(&width_outline(&line(), 10.0, &p, LineCap::Round, 0.01));
let want = 1000.0 + std::f64::consts::PI * 25.0;
assert!((round - want).abs() / want < 0.01, "{round} vs {want}");
assert!((area(&width_outline(&line(), 10.0, &p, LineCap::Square, 0.01)) - 1100.0).abs() < 1e-6);
}
#[test]
fn lens_profile_is_a_diamond_and_one_sided_profiles_stay_on_their_side() {
let a = area(&width_outline(&line(), 10.0, &WidthProfile::lens(), LineCap::Butt, 0.01));
assert!((a - 500.0).abs() < 1e-6, "{a}");
let one = WidthProfile { points: vec![(0.0, 1.0, 0.0), (1.0, 1.0, 0.0)] };
let o = width_outline(&line(), 10.0, &one, LineCap::Butt, 0.01);
assert!((area(&o) - 500.0).abs() < 1e-6);
let bb = o.bounding_box();
// Travelling +x, the left side is up (negative y).
assert!(bb.y0 < -4.99 && bb.y1 <= 1e-9, "{bb:?}");
}
#[test]
fn closed_paths_make_a_ring() {
let mut sq = BezPath::new();
sq.move_to((0.0, 0.0));
sq.line_to((100.0, 0.0));
sq.line_to((100.0, 100.0));
sq.line_to((0.0, 100.0));
sq.close_path();
let p = WidthProfile { points: vec![(0.0, 1.0, 1.0), (1.0, 1.0, 1.0)] };
let a = area(&width_outline(&sq, 10.0, &p, LineCap::Butt, 0.01));
assert!((a - (110.0 * 110.0 - 90.0 * 90.0)).abs() < 1e-6, "{a}");
}
#[test]
fn curves_are_offset_by_the_profile() {
// A circle of radius 50 with a uniform 10 pt stroke: ring area 2π·50·10.
let c = kurbo::Circle::new((0.0, 0.0), 50.0).to_path(1e-4);
let p = WidthProfile { points: vec![(0.0, 1.0, 1.0), (1.0, 1.0, 1.0)] };
let a = area(&width_outline(&c, 10.0, &p, LineCap::Butt, 0.001));
let want = 2.0 * std::f64::consts::PI * 50.0 * 10.0;
assert!((a - want).abs() / want < 0.01, "{a} vs {want}");
let st = StrokeLayer { profile: None, ..StrokeLayer::new(vectorcraft_color::Paint::solid(vectorcraft_color::Color::BLACK), 4.0) };
assert!(outline_for(&c, &st, 0.1).is_none());
}
}