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:
bflatastic
2026-10-04 17:20:40 +07:00
co-authored by Claude Opus 5.5
parent 23f19a861b
commit 5ae7bfb8ca
17 changed files with 625 additions and 101 deletions
+16 -10
View File
@@ -646,13 +646,17 @@ impl Renderer {
}
fn draw_stroke(&mut self, ctx: &mut RenderContext, f: &Frame, bp: &BezPath, rule: FillRule, st: &StrokeLayer, bounds: Rect) {
let layered = st.opacity < 1.0 || st.blend != vectorcraft_color::BlendMode::Normal || st.align == StrokeAlign::Outside;
let pieces = effects::stroke::stroke_pieces(bp, st);
// The line and its heads overlap: they take the opacity once, in one layer, instead of
// each folding it into its paint.
let folded = if pieces.heads.is_empty() { 1.0 } else { std::mem::replace(&mut self.alpha, 1.0) };
let opacity = st.opacity * folded;
let layered = opacity < 1.0 || st.blend != vectorcraft_color::BlendMode::Normal || st.align == StrokeAlign::Outside;
if layered {
ctx.set_transform(Affine::IDENTITY);
ctx.push_layer(None, Some(blend_mode(st.blend)), Some(st.opacity), None, None);
ctx.push_layer(None, Some(blend_mode(st.blend)), Some(opacity), None, None);
}
ctx.set_transform(f.view);
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);
@@ -661,13 +665,18 @@ impl Renderer {
ctx.set_fill_rule(fill_rule(rule));
ctx.push_clip_layer(bp);
}
if paint::set_paint(ctx, &st.paint, bounds.inflate(st.width / 2.0, st.width / 2.0), f.doc) {
// One paint box for the line and the heads, so a gradient runs on into the heads.
let paint_bounds = bounds.inflate(st.width / 2.0, st.width / 2.0);
if paint::set_paint(ctx, &st.paint, paint_bounds, f.doc) {
self.fold_alpha(ctx, &st.paint);
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)),
}
for head in &pieces.heads {
ctx.fill_path(&head.outline);
}
}
if inside {
ctx.pop_layer();
@@ -680,15 +689,12 @@ impl Renderer {
ctx.fill_path(bp);
ctx.set_blend_mode(BlendMode::default());
}
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 {
ctx.pop_layer();
}
if !pieces.heads.is_empty() {
self.alpha = folded;
}
}
/// The line part of a stroke of the fast-path node `self.cur` (see
+3 -2
View File
@@ -189,8 +189,9 @@ fn arrowheads_render() {
let l = d.layers[0].id;
d.insert(Some(l), 0, n).unwrap();
let r = render(&d);
// Arrowhead is wider than the 2 pt line.
assert!(r.pixel(73, 47)[0] < 128);
// The arrowhead (past the end point by default: tip at 84) is wider than the 2 pt line.
assert!(r.pixel(78, 48)[0] < 128);
assert!(r.pixel(85, 50)[0] > 200, "nothing past the tip");
}
use vectorcraft_doc::Arrowhead;