mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-14 19:26:56 -04:00
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 <[email protected]>
This commit is contained in:
1 parent
23f19a861b
commit
5ae7bfb8ca
17 files changed
+625
-101
No files matched your search
@@ -89,6 +89,8 @@ Generated-in-code art is original and has no file to list. This covers the defau
|
||||
| `assets/icons/dc-appearance.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
|
||||
| `assets/icons/dc-arc.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
|
||||
| `assets/icons/dc-arrow-down.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
|
||||
| `assets/icons/dc-arrow-extend.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
|
||||
| `assets/icons/dc-arrow-tip.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
|
||||
| `assets/icons/dc-arrow-up.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
|
||||
| `assets/icons/dc-artboard-options.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
|
||||
| `assets/icons/dc-artboards.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="butt" stroke-linejoin="miter"><path d="M2 12H14"/><path d="M13 7.5L21.5 12L13 16.5Z" fill="currentColor" stroke="none"/><path d="M14 4V20" stroke-width="1" stroke-dasharray="2 1.5"/></svg>
|
||||
|
After Width: | Height: | Size: 341 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="butt" stroke-linejoin="miter"><path d="M2 12H10"/><path d="M9.5 7.5L18 12L9.5 16.5Z" fill="currentColor" stroke="none"/><path d="M18 4V20" stroke-width="1" stroke-dasharray="2 1.5"/></svg>
|
||||
|
After Width: | Height: | Size: 341 B |
@@ -68,6 +68,17 @@ impl Arrowhead {
|
||||
];
|
||||
}
|
||||
|
||||
/// Where an arrowhead sits relative to the end of its path. In both modes the stroke stops under
|
||||
/// the head, so the line never shows through a hollow head or past its tip.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub enum ArrowAlign {
|
||||
/// The tip extends past the end point (the path keeps its length).
|
||||
#[default]
|
||||
Extend,
|
||||
/// The tip sits on the end point (the stroke is shortened by the head).
|
||||
Tip,
|
||||
}
|
||||
|
||||
/// Variable-width profile: (position 0..1 along the path, left width factor, right width factor).
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
pub struct WidthProfile {
|
||||
@@ -207,6 +218,9 @@ pub struct StrokeLayer {
|
||||
pub visible: bool,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub effects: Vec<Effect>,
|
||||
/// Arrowhead placement at both ends.
|
||||
#[serde(default, skip_serializing_if = "crate::skip::is_default")]
|
||||
pub arrow_align: ArrowAlign,
|
||||
}
|
||||
|
||||
fn ten() -> f64 {
|
||||
@@ -241,6 +255,7 @@ impl StrokeLayer {
|
||||
blend: BlendMode::Normal,
|
||||
visible: true,
|
||||
effects: vec![],
|
||||
arrow_align: ArrowAlign::Extend,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -390,6 +405,19 @@ mod tests {
|
||||
assert_eq!(WidthProfile::lens().points, vec![(0.0, 0.0, 0.0), (0.5, 1.0, 1.0), (1.0, 0.0, 0.0)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arrow_align_defaults_to_extend_and_round_trips() {
|
||||
let mut st = StrokeLayer::new(Paint::solid(Color::BLACK), 2.0);
|
||||
assert_eq!(st.arrow_align, ArrowAlign::Extend);
|
||||
// The default is not written, so older readers see the same JSON as before.
|
||||
assert!(!serde_json::to_string(&st).unwrap().contains("arrow_align"));
|
||||
let old: StrokeLayer = serde_json::from_str(r#"{"paint":{"type":"none"},"width":2.0}"#).unwrap();
|
||||
assert_eq!(old.arrow_align, ArrowAlign::Extend);
|
||||
st.arrow_align = ArrowAlign::Tip;
|
||||
let back: StrokeLayer = serde_json::from_str(&serde_json::to_string(&st).unwrap()).unwrap();
|
||||
assert_eq!(back, st);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn outset_depends_on_align() {
|
||||
let mut a = Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 10.0);
|
||||
|
||||
@@ -31,7 +31,8 @@ pub(crate) mod skip {
|
||||
}
|
||||
|
||||
pub use appearance::{
|
||||
Appearance, AppearanceItem, Arrowhead, Dash, Effect, FillLayer, LineCap, LineJoin, ProfilePreset, StrokeAlign, StrokeLayer, WidthProfile,
|
||||
Appearance, AppearanceItem, ArrowAlign, Arrowhead, Dash, Effect, FillLayer, LineCap, LineJoin, ProfilePreset, StrokeAlign, StrokeLayer,
|
||||
WidthProfile,
|
||||
};
|
||||
pub use graph::{GraphKind, GraphSpec};
|
||||
pub use hit::{Hit, HitKind};
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
//! Stroke geometry shared by the canvas, the PDF/SVG exporters and Outline Stroke, so every
|
||||
//! consumer paints the same shapes:
|
||||
//!
|
||||
//! - [`stroke_pieces`]: the centre line and the arrowheads as filled outlines;
|
||||
//! - [`stroke_pieces`]: the centre line (trimmed under the arrowheads) and the arrowheads as
|
||||
//! filled outlines (hollow kinds are rings), placed by [`ArrowAlign`](vectorcraft_doc::ArrowAlign);
|
||||
//! - [`dash`]: the dash pattern; zero-length dashes become [`Dot`]s that a round or projecting cap
|
||||
//! turns into discs or squares ([`dot_outline`]);
|
||||
//! - [`width_outline`]: variable-width (profile) strokes;
|
||||
@@ -27,9 +28,10 @@ const ARCLEN_ACCURACY: f64 = 1e-6;
|
||||
/// The geometry one stroke paints, in the coordinate space of its path.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct StrokePieces<'a> {
|
||||
/// The centre line to stroke.
|
||||
/// The centre line to stroke: the path with its ends trimmed under the arrowheads (borrowed
|
||||
/// when nothing is trimmed). Empty when the heads cover the whole path.
|
||||
pub line: Cow<'a, BezPath>,
|
||||
/// The arrowheads, start first.
|
||||
/// The arrowheads, start first. Closed ends get none.
|
||||
pub heads: Vec<Arrow>,
|
||||
}
|
||||
|
||||
@@ -55,6 +57,14 @@ pub fn aligned_width(st: &StrokeLayer, closed: bool) -> f64 {
|
||||
}
|
||||
}
|
||||
|
||||
/// How far a cap reaches past the end of a stroke of `width`.
|
||||
pub fn cap_extent(cap: LineCap, width: f64) -> f64 {
|
||||
match cap {
|
||||
LineCap::Butt => 0.0,
|
||||
LineCap::Round | LineCap::Square => width / 2.0,
|
||||
}
|
||||
}
|
||||
|
||||
/// The stroke style (cap, join, miter limit) of `st` at `width`, without dashes.
|
||||
pub fn style(st: &StrokeLayer, width: f64) -> kurbo::Stroke {
|
||||
kurbo::Stroke::new(width)
|
||||
|
||||
@@ -1,58 +1,227 @@
|
||||
//! Arrowheads. A head of weight `hw` (stroke weight × scale) is `4·hw` long and `4·hw` wide,
|
||||
//! with its tip on the end point of the path.
|
||||
//! Arrowheads. A head of weight `hw` (stroke weight × scale) is `4·hw` long and `4·hw` wide.
|
||||
//! Hollow kinds are rings (`hw/2` walls) or, for the open arrow, a chevron with `hw` arms. The
|
||||
//! stroke ends under the head at the head's *inset* from the tip: halfway into solid heads, in
|
||||
//! the back wall of hollow ones (with the cap kept out of the hole) and at the chevron's inner
|
||||
//! corner. With [`ArrowAlign::Tip`] the tip sits on the end point and the stroke is shortened by
|
||||
//! the inset; with [`ArrowAlign::Extend`] the path keeps its length and the tip sits the inset
|
||||
//! past the end point.
|
||||
|
||||
use std::borrow::Cow;
|
||||
|
||||
use kurbo::{BezPath, ParamCurve, PathSeg, Point, Shape, Vec2};
|
||||
use vectorcraft_doc::{Arrowhead, StrokeLayer};
|
||||
use kurbo::{BezPath, ParamCurve, ParamCurveArclen, PathSeg, Point, Shape, Vec2};
|
||||
use vectorcraft_doc::{ArrowAlign, Arrowhead, StrokeLayer};
|
||||
|
||||
use super::{StrokePieces, tangent};
|
||||
use super::{ARCLEN_ACCURACY, StrokePieces, cap_extent, push_seg, segments, subpaths, tangent, unit};
|
||||
|
||||
/// One arrowhead.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Arrow {
|
||||
pub kind: Arrowhead,
|
||||
/// The filled outline (non-zero rule).
|
||||
/// The filled outline (non-zero rule; hollow kinds are rings).
|
||||
pub outline: BezPath,
|
||||
pub tip: Point,
|
||||
/// Unit direction the head points in.
|
||||
pub dir: Vec2,
|
||||
/// Distance from the tip back to where the stroke ends under the head.
|
||||
pub inset: f64,
|
||||
}
|
||||
|
||||
/// [`super::stroke_pieces`] for a stroke with at least one arrowhead.
|
||||
pub(super) fn pieces<'a>(bp: &'a BezPath, st: &StrokeLayer) -> StrokePieces<'a> {
|
||||
let segs: Vec<PathSeg> = bp.segments().collect();
|
||||
let subs = subpaths(bp);
|
||||
let els = bp.elements();
|
||||
// Heads go on the start of the first subpath and the end of the last one, when open.
|
||||
let first = subs.first().filter(|s| !s.1).map(|s| s.0.clone());
|
||||
let last = subs.last().filter(|s| !s.1).map(|s| s.0.clone());
|
||||
let cap = cap_extent(st.cap, st.width);
|
||||
let weight = |pct: f64| (st.width * pct / 100.0).max(0.25);
|
||||
let start = st.start_arrow.zip(first).map(|(k, r)| (k, r, inset(k, weight(st.arrow_scale.0), cap)));
|
||||
let end = st.end_arrow.zip(last).map(|(k, r)| (k, r, inset(k, weight(st.arrow_scale.1), cap)));
|
||||
if start.is_none() && end.is_none() {
|
||||
return StrokePieces { line: Cow::Borrowed(bp), heads: vec![] };
|
||||
}
|
||||
let tip_mode = st.arrow_align == ArrowAlign::Tip;
|
||||
let mut heads = vec![];
|
||||
if let (Some(first), Some(last)) = (segs.first(), segs.last()) {
|
||||
let weight = |pct: f64| (st.width * pct / 100.0).max(0.25);
|
||||
if let Some(kind) = st.start_arrow {
|
||||
let (tip, dir) = (first.start(), -tangent(first, 0.0));
|
||||
heads.push(Arrow { kind, outline: shape(kind, tip, dir, weight(st.arrow_scale.0)), tip, dir });
|
||||
}
|
||||
if let Some(kind) = st.end_arrow {
|
||||
let (tip, dir) = (last.end(), tangent(last, 1.0));
|
||||
heads.push(Arrow { kind, outline: shape(kind, tip, dir, weight(st.arrow_scale.1)), tip, dir });
|
||||
// Trims per subpath index (start, end), applied in tip mode only.
|
||||
let mut trims: Vec<(f64, f64)> = vec![(0.0, 0.0); subs.len()];
|
||||
let mut place = |kind: Arrowhead, range: std::ops::Range<usize>, at_start: bool, inset: f64, pct: f64| {
|
||||
let segs = segments(&els[range]);
|
||||
let (end_pt, outward) = end_of(&segs, at_start);
|
||||
let (tip, dir) = if tip_mode {
|
||||
// Point along the chord from where the stroke now ends to the end point.
|
||||
let dir = cut_point(&segs, at_start, inset).map(|p| end_pt - p).filter(|v| v.hypot() > 1e-9).map(unit).unwrap_or(outward);
|
||||
(end_pt, dir)
|
||||
} else {
|
||||
(end_pt + outward * inset, outward)
|
||||
};
|
||||
heads.push(Arrow { kind, outline: shape(kind, tip, dir, weight(pct)), tip, dir, inset });
|
||||
};
|
||||
if let Some((k, r, i)) = start {
|
||||
place(k, r, true, i, st.arrow_scale.0);
|
||||
if tip_mode {
|
||||
trims[0].0 = i;
|
||||
}
|
||||
}
|
||||
StrokePieces { line: Cow::Borrowed(bp), heads }
|
||||
if let Some((k, r, i)) = end {
|
||||
place(k, r, false, i, st.arrow_scale.1);
|
||||
if tip_mode {
|
||||
trims[subs.len() - 1].1 = i;
|
||||
}
|
||||
}
|
||||
if !tip_mode {
|
||||
return StrokePieces { line: Cow::Borrowed(bp), heads };
|
||||
}
|
||||
let mut line = BezPath::new();
|
||||
for ((range, _), &(s0, s1)) in subs.iter().zip(&trims) {
|
||||
if s0 == 0.0 && s1 == 0.0 {
|
||||
line.extend(els[range.clone()].iter().copied());
|
||||
continue;
|
||||
}
|
||||
// Only open subpaths get heads, so a trimmed subpath stays open.
|
||||
let segs = trim(&segments(&els[range.clone()]), s0, s1);
|
||||
if let Some(first) = segs.first() {
|
||||
line.move_to(first.start());
|
||||
for s in &segs {
|
||||
push_seg(&mut line, s);
|
||||
}
|
||||
}
|
||||
}
|
||||
StrokePieces { line: Cow::Owned(line), heads }
|
||||
}
|
||||
|
||||
/// The end point of a subpath and the unit direction pointing out of it there.
|
||||
fn end_of(segs: &[PathSeg], at_start: bool) -> (Point, Vec2) {
|
||||
let long = |s: &&PathSeg| s.arclen(ARCLEN_ACCURACY) > 1e-9;
|
||||
if at_start {
|
||||
let p = segs.first().map_or(Point::ORIGIN, |s| s.start());
|
||||
let d = segs.iter().find(long).map_or(Vec2::new(-1.0, 0.0), |s| -tangent(s, 0.0));
|
||||
(p, d)
|
||||
} else {
|
||||
let p = segs.last().map_or(Point::ORIGIN, |s| s.end());
|
||||
let d = segs.iter().rev().find(long).map_or(Vec2::new(1.0, 0.0), |s| tangent(s, 1.0));
|
||||
(p, d)
|
||||
}
|
||||
}
|
||||
|
||||
/// The point `dist` along the subpath from its start (or back from its end), if it is that long.
|
||||
fn cut_point(segs: &[PathSeg], from_start: bool, dist: f64) -> Option<Point> {
|
||||
let (i, t) = if from_start { cut_from_start(segs, dist)? } else { cut_from_end(segs, dist)? };
|
||||
Some(segs[i].eval(t))
|
||||
}
|
||||
|
||||
/// (segment, parameter) at arc length `dist` from the start.
|
||||
fn cut_from_start(segs: &[PathSeg], dist: f64) -> Option<(usize, f64)> {
|
||||
let mut acc = 0.0;
|
||||
for (i, s) in segs.iter().enumerate() {
|
||||
let len = s.arclen(ARCLEN_ACCURACY);
|
||||
if acc + len >= dist {
|
||||
return Some((i, if len > 0.0 { s.inv_arclen(dist - acc, ARCLEN_ACCURACY) } else { 0.0 }));
|
||||
}
|
||||
acc += len;
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// (segment, parameter) at arc length `dist` back from the end.
|
||||
fn cut_from_end(segs: &[PathSeg], dist: f64) -> Option<(usize, f64)> {
|
||||
let mut acc = 0.0;
|
||||
for (i, s) in segs.iter().enumerate().rev() {
|
||||
let len = s.arclen(ARCLEN_ACCURACY);
|
||||
if acc + len >= dist {
|
||||
return Some((i, if len > 0.0 { s.inv_arclen(len - (dist - acc), ARCLEN_ACCURACY) } else { 1.0 }));
|
||||
}
|
||||
acc += len;
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// The segments with `s0` of arc length removed from the start and `s1` from the end (empty when
|
||||
/// nothing is left).
|
||||
fn trim(segs: &[PathSeg], s0: f64, s1: f64) -> Vec<PathSeg> {
|
||||
let (Some((i0, t0)), Some((i1, t1))) = (cut_from_start(segs, s0), cut_from_end(segs, s1)) else { return vec![] };
|
||||
if (i0, t0) >= (i1, t1) {
|
||||
return vec![];
|
||||
}
|
||||
if i0 == i1 {
|
||||
return vec![segs[i0].subsegment(t0..t1)];
|
||||
}
|
||||
let mut out = Vec::with_capacity(i1 - i0 + 1);
|
||||
out.push(segs[i0].subsegment(t0..1.0));
|
||||
out.extend_from_slice(&segs[i0 + 1..i1]);
|
||||
out.push(segs[i1].subsegment(0.0..t1));
|
||||
out
|
||||
}
|
||||
|
||||
/// Head length (and width) for weight `hw`.
|
||||
fn len(hw: f64) -> f64 {
|
||||
4.0 * hw
|
||||
}
|
||||
/// Wall of the hollow ring heads.
|
||||
fn ring(hw: f64) -> f64 {
|
||||
0.5 * hw
|
||||
}
|
||||
/// Arm thickness of the open arrow.
|
||||
fn arm(hw: f64) -> f64 {
|
||||
hw
|
||||
}
|
||||
/// Thickness of the bar.
|
||||
fn bar(hw: f64) -> f64 {
|
||||
0.75 * hw
|
||||
}
|
||||
/// Where the notch of the (open) arrow sits, as a fraction of the length.
|
||||
const NOTCH: f64 = 0.7;
|
||||
|
||||
/// Distance from the tip where the stroke ends, for a stroke whose cap reaches `cap` past its end.
|
||||
fn inset(kind: Arrowhead, hw: f64, cap: f64) -> f64 {
|
||||
let l = len(hw);
|
||||
// Solid heads: halfway in, far enough that the cap stays behind the tip.
|
||||
let solid = |back: f64| (back / 2.0).max(cap);
|
||||
// Hollow heads: in the back wall, with the cap kept out of the hole.
|
||||
let hollow = |back: f64| (back - ring(hw) / 2.0).max(back - ring(hw) + cap);
|
||||
match kind {
|
||||
Arrowhead::Triangle | Arrowhead::Circle | Arrowhead::Square | Arrowhead::Diamond => solid(l),
|
||||
Arrowhead::Arrow => solid(NOTCH * l),
|
||||
Arrowhead::Bar => solid(bar(hw)),
|
||||
Arrowhead::TriangleOpen | Arrowhead::CircleOpen | Arrowhead::SquareOpen => hollow(l),
|
||||
// The inner corner of the chevron: arm / sin(half angle).
|
||||
Arrowhead::ArrowOpen => (arm(hw) * (l * l + l * l / 4.0).sqrt() / (l / 2.0)).max(cap),
|
||||
}
|
||||
}
|
||||
|
||||
/// Outline of a `kind` head of weight `hw` with its tip at `tip`, pointing along `dir`.
|
||||
fn shape(kind: Arrowhead, tip: Point, dir: Vec2, hw: f64) -> BezPath {
|
||||
let n = Vec2::new(-dir.y, dir.x);
|
||||
let len = 4.0 * hw;
|
||||
let half = len / 2.0;
|
||||
let base = tip - dir * len;
|
||||
let centre = tip - dir * half;
|
||||
let l = len(hw);
|
||||
let half = l / 2.0;
|
||||
let base = tip - dir * l;
|
||||
let mut p = BezPath::new();
|
||||
let triangle = [tip, base + n * half, base - n * half];
|
||||
let square = [tip + n * half, base + n * half, base - n * half, tip - n * half];
|
||||
let centre = tip - dir * half;
|
||||
match kind {
|
||||
Arrowhead::Triangle | Arrowhead::TriangleOpen => polygon(&mut p, &[tip, base + n * half, base - n * half]),
|
||||
Arrowhead::Arrow | Arrowhead::ArrowOpen => polygon(&mut p, &[tip, base + n * half, tip - dir * (len * 0.7), base - n * half]),
|
||||
Arrowhead::Circle | Arrowhead::CircleOpen => p.extend(kurbo::Circle::new(centre, half).path_elements(0.01)),
|
||||
Arrowhead::Square | Arrowhead::SquareOpen => polygon(&mut p, &[tip + n * half, base + n * half, base - n * half, tip - n * half]),
|
||||
Arrowhead::Triangle => polygon(&mut p, &triangle),
|
||||
Arrowhead::TriangleOpen => ring_polygon(&mut p, &triangle, ring(hw)),
|
||||
Arrowhead::Arrow => polygon(&mut p, &[tip, base + n * half, tip - dir * (NOTCH * l), base - n * half]),
|
||||
Arrowhead::ArrowOpen => {
|
||||
// Arms of thickness `arm` from the tip to the base corners, cut square at their ends.
|
||||
let t = arm(hw);
|
||||
let (a, b) = (base + n * half, base - n * half);
|
||||
let (ua, ub) = (unit(a - tip), unit(b - tip));
|
||||
// Inward (towards the axis) normals of the two arms.
|
||||
let (na, nb) = (Vec2::new(-ua.y, ua.x), Vec2::new(ub.y, -ub.x));
|
||||
let inner = tip - dir * (t * (a - tip).hypot() / half);
|
||||
polygon(&mut p, &[tip, a, a + na * t, inner, b + nb * t, b]);
|
||||
}
|
||||
Arrowhead::Circle => p.extend(kurbo::Circle::new(centre, half).path_elements(0.01)),
|
||||
Arrowhead::CircleOpen => {
|
||||
p.extend(kurbo::Circle::new(centre, half).path_elements(0.01));
|
||||
p.extend(kurbo::Circle::new(centre, half - ring(hw)).to_path(0.01).reverse_subpaths());
|
||||
}
|
||||
Arrowhead::Square => polygon(&mut p, &square),
|
||||
Arrowhead::SquareOpen => ring_polygon(&mut p, &square, ring(hw)),
|
||||
Arrowhead::Diamond => polygon(&mut p, &[tip, centre + n * half, base, centre - n * half]),
|
||||
Arrowhead::Bar => {
|
||||
let t = 0.75 * hw;
|
||||
let t = bar(hw);
|
||||
polygon(&mut p, &[tip + n * half, tip + n * half - dir * t, tip - n * half - dir * t, tip - n * half]);
|
||||
}
|
||||
}
|
||||
@@ -67,3 +236,22 @@ fn polygon(p: &mut BezPath, pts: &[Point]) {
|
||||
}
|
||||
p.close_path();
|
||||
}
|
||||
|
||||
/// A convex polygon with a hole: the polygon inset by `wall`, wound the other way.
|
||||
fn ring_polygon(p: &mut BezPath, pts: &[Point], wall: f64) {
|
||||
polygon(p, pts);
|
||||
let n = pts.len();
|
||||
// Signed area: which side of each edge is inside.
|
||||
let area: f64 = (0..n).map(|i| pts[i].to_vec2().cross(pts[(i + 1) % n].to_vec2())).sum();
|
||||
let inward = |e: Vec2| if area > 0.0 { Vec2::new(-e.y, e.x) } else { Vec2::new(e.y, -e.x) };
|
||||
let inner: Vec<Point> = (0..n)
|
||||
.map(|i| {
|
||||
let (prev, cur, next) = (pts[(i + n - 1) % n], pts[i], pts[(i + 1) % n]);
|
||||
let (n1, n2) = (inward(unit(cur - prev)), inward(unit(next - cur)));
|
||||
// The corner where both edges, moved in by `wall`, meet.
|
||||
cur + (n1 + n2) * (wall / (1.0 + n1.dot(n2)))
|
||||
})
|
||||
.rev()
|
||||
.collect();
|
||||
polygon(p, &inner);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
use kurbo::{BezPath, PathEl, Point, Rect, Shape, Vec2};
|
||||
use vectorcraft_color::{Color, Paint};
|
||||
use vectorcraft_doc::{Arrowhead, Dash, LineCap, StrokeLayer, WidthProfile};
|
||||
use vectorcraft_doc::{ArrowAlign, Arrowhead, Dash, LineCap, StrokeLayer, WidthProfile};
|
||||
|
||||
use super::*;
|
||||
|
||||
@@ -137,33 +137,119 @@ fn aligned_width_doubles_only_closed_inside_and_outside_strokes() {
|
||||
|
||||
// ---------------------------------------------------------------- arrowheads
|
||||
|
||||
#[test]
|
||||
fn no_heads_borrows_the_path() {
|
||||
let l = line(0.0, 50.0);
|
||||
let p = stroke_pieces(&l, &stroke(2.0, |_| {}));
|
||||
assert!(matches!(p.line, Cow::Borrowed(_)) && p.heads.is_empty());
|
||||
fn arrows(kind: Arrowhead, align: ArrowAlign, cap: LineCap) -> StrokeLayer {
|
||||
stroke(2.0, |s| {
|
||||
s.end_arrow = Some(kind);
|
||||
s.arrow_align = align;
|
||||
s.cap = cap;
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn heads_sit_on_the_end_points_and_point_out_of_the_path() {
|
||||
fn no_heads_borrows_the_path_and_closed_paths_get_none() {
|
||||
let l = line(0.0, 50.0);
|
||||
assert!(matches!(stroke_pieces(&l, &stroke(2.0, |_| {})).line, Cow::Borrowed(_)));
|
||||
let st = stroke(2.0, |s| {
|
||||
s.start_arrow = Some(Arrowhead::Triangle);
|
||||
s.end_arrow = Some(Arrowhead::Triangle);
|
||||
s.arrow_align = ArrowAlign::Tip;
|
||||
});
|
||||
let sq = square(50.0);
|
||||
let p = stroke_pieces(&sq, &st);
|
||||
assert!(p.heads.is_empty());
|
||||
assert_eq!(p.line.as_ref(), &sq);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tip_mode_shortens_the_stroke_by_the_head_and_puts_the_tip_on_the_end_point() {
|
||||
for kind in Arrowhead::ALL {
|
||||
let st = stroke(2.0, |s| {
|
||||
s.start_arrow = Some(kind);
|
||||
s.end_arrow = Some(kind);
|
||||
});
|
||||
let st = arrows(kind, ArrowAlign::Tip, LineCap::Butt);
|
||||
let l = line(0.0, 100.0);
|
||||
let p = stroke_pieces(&l, &st);
|
||||
let [a, b] = &p.heads[..] else { panic!("two heads") };
|
||||
assert_eq!((a.tip, b.tip), (Point::new(0.0, 0.0), Point::new(100.0, 0.0)), "{kind:?}");
|
||||
assert!(near(a.dir.x, -1.0, 1e-9) && near(b.dir.x, 1.0, 1e-9), "{kind:?}");
|
||||
let (ba, bb) = (a.outline.bounding_box(), b.outline.bounding_box());
|
||||
assert!(ba.x0 >= -0.01 && bb.x1 <= 100.01, "{kind:?}: nothing past the tips");
|
||||
assert!(a.outline.area().abs() > 0.5, "{kind:?}");
|
||||
let [h] = &p.heads[..] else { panic!("one head") };
|
||||
assert_eq!(h.tip, Point::new(100.0, 0.0), "{kind:?}");
|
||||
assert!(near(h.dir.x, 1.0, 1e-9), "{kind:?}");
|
||||
assert!(h.inset > 0.0 && h.inset <= 8.0, "{kind:?} inset {}", h.inset);
|
||||
assert!(near(length(&p.line), 100.0 - h.inset, 1e-6), "{kind:?}");
|
||||
// Nothing reaches past the tip.
|
||||
assert!(h.outline.bounding_box().x1 <= 100.0 + 0.01, "{kind:?}");
|
||||
assert!(h.outline.area().abs() > 0.5, "{kind:?}");
|
||||
}
|
||||
// The start head of a curve points back along its first tangent.
|
||||
// Both ends, on a curve: the start head points back along the path.
|
||||
let mut c = BezPath::new();
|
||||
c.move_to((0.0, 0.0));
|
||||
c.curve_to((30.0, -40.0), (70.0, -40.0), (100.0, 0.0));
|
||||
let p = stroke_pieces(&c, &stroke(2.0, |s| s.start_arrow = Some(Arrowhead::Triangle)));
|
||||
let st = stroke(2.0, |s| {
|
||||
s.start_arrow = Some(Arrowhead::Triangle);
|
||||
s.end_arrow = Some(Arrowhead::Arrow);
|
||||
s.arrow_align = ArrowAlign::Tip;
|
||||
});
|
||||
let p = stroke_pieces(&c, &st);
|
||||
assert_eq!(p.heads.len(), 2);
|
||||
assert_eq!(p.heads[0].tip, Point::new(0.0, 0.0));
|
||||
assert!(p.heads[0].dir.x < 0.0 && p.heads[0].dir.y > 0.0, "{:?}", p.heads[0].dir);
|
||||
let total = length(&c);
|
||||
assert!(near(length(&p.line), total - p.heads[0].inset - p.heads[1].inset, 1e-5));
|
||||
// A path shorter than its heads leaves no line.
|
||||
let short = line(0.0, 3.0);
|
||||
let p = stroke_pieces(&short, &arrows(Arrowhead::Triangle, ArrowAlign::Tip, LineCap::Butt));
|
||||
assert!(p.line.elements().is_empty());
|
||||
assert_eq!(p.heads.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extend_mode_keeps_the_path_and_overhangs_by_the_inset() {
|
||||
let l = line(0.0, 100.0);
|
||||
let st = arrows(Arrowhead::Triangle, ArrowAlign::Extend, LineCap::Butt);
|
||||
let p = stroke_pieces(&l, &st);
|
||||
assert!(matches!(p.line, Cow::Borrowed(_)));
|
||||
let h = &p.heads[0];
|
||||
assert!(near(h.tip.x, 100.0 + h.inset, 1e-9));
|
||||
// The same head as in tip mode, moved by the overhang.
|
||||
let tip = stroke_pieces(&l, &arrows(Arrowhead::Triangle, ArrowAlign::Tip, LineCap::Butt));
|
||||
let mut moved = tip.heads[0].outline.clone();
|
||||
moved.apply_affine(kurbo::Affine::translate((h.inset, 0.0)));
|
||||
let pts = |b: &BezPath| b.elements().iter().filter_map(|e| e.end_point()).collect::<Vec<_>>();
|
||||
let (a, b) = (pts(&moved), pts(&h.outline));
|
||||
assert_eq!(a.len(), b.len());
|
||||
assert!(a.iter().zip(&b).all(|(p, q)| p.distance(*q) < 1e-9));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hollow_heads_are_rings_and_the_stroke_stays_out_of_the_hole() {
|
||||
for (kind, cap) in [(Arrowhead::CircleOpen, LineCap::Round), (Arrowhead::SquareOpen, LineCap::Square), (Arrowhead::TriangleOpen, LineCap::Butt)] {
|
||||
let st = arrows(kind, ArrowAlign::Tip, cap);
|
||||
let l = line(0.0, 100.0);
|
||||
let p = stroke_pieces(&l, &st);
|
||||
let h = &p.heads[0];
|
||||
let line_o = line_outline(&p.line, &st, st.width, 1e-3);
|
||||
// A point inside the hole is neither head nor line.
|
||||
let hole = Point::new(100.0 - 8.0 * 0.45, 0.0);
|
||||
assert_eq!(h.outline.winding(hole), 0, "{kind:?} hole");
|
||||
assert_eq!(line_o.winding(hole), 0, "{kind:?} line in the hole");
|
||||
// The line reaches the back wall, so they overlap (no seam).
|
||||
let back_wall = Point::new(100.0 - 8.0 + 0.25, 0.0);
|
||||
assert_ne!(h.outline.winding(back_wall), 0, "{kind:?} wall");
|
||||
assert_ne!(line_o.winding(back_wall - Vec2::new(0.2, 0.0)), 0, "{kind:?} line reaches the wall");
|
||||
}
|
||||
// The open arrow is a chevron: open between its arms.
|
||||
let l = line(0.0, 100.0);
|
||||
let p = stroke_pieces(&l, &arrows(Arrowhead::ArrowOpen, ArrowAlign::Tip, LineCap::Butt));
|
||||
let h = &p.heads[0];
|
||||
assert_eq!(h.outline.winding(Point::new(93.0, 0.0)), 0);
|
||||
assert_ne!(h.outline.winding(Point::new(99.0, 0.0)), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn round_caps_never_poke_past_the_tip() {
|
||||
for kind in Arrowhead::ALL {
|
||||
for scale in [10.0, 100.0, 300.0] {
|
||||
let mut st = arrows(kind, ArrowAlign::Tip, LineCap::Round);
|
||||
st.arrow_scale = (scale, scale);
|
||||
let l = line(0.0, 100.0);
|
||||
let p = stroke_pieces(&l, &st);
|
||||
let o = line_outline(&p.line, &st, st.width, 1e-3);
|
||||
assert!(o.bounding_box().x1 <= 100.0 + 1e-3, "{kind:?} at {scale}%: {}", o.bounding_box().x1);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
use serde_json::Value;
|
||||
use vectorcraft_color::{Color, Paint};
|
||||
use vectorcraft_doc::{Arrowhead, Dash, LineCap, LineJoin, StrokeAlign, StrokeLayer, WidthProfile};
|
||||
use vectorcraft_doc::{ArrowAlign, 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 (a 0 dash with a round or projecting cap draws dots or squares), dashOffset?, alignDashes?, startArrow?, endArrow?: Arrow|ArrowOpen|Triangle|TriangleOpen|Circle|CircleOpen|Square|SquareOpen|Diamond|Bar|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 (a 0 dash with a round or projecting cap draws dots or squares), dashOffset?, alignDashes?, 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\", ids?}",
|
||||
has_doc,
|
||||
stroke_set
|
||||
),
|
||||
@@ -48,6 +48,12 @@ fn stroke_set(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
};
|
||||
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("stroke.set", format!("arrowAlign must be extend|tip, got {o}"))),
|
||||
};
|
||||
let profile = match str_param(p, "profile") {
|
||||
None => None,
|
||||
Some("uniform") => Some(None),
|
||||
@@ -102,6 +108,9 @@ fn stroke_set(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
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();
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
//! Stroke geometry from the Stroke panel's commands (`cmd/stroke.rs`): the width-profile presets.
|
||||
//! Stroke geometry from the Stroke panel's commands (`cmd/stroke.rs`): arrowhead alignment and
|
||||
//! the width-profile presets.
|
||||
|
||||
use serde_json::json;
|
||||
use vectorcraft_doc::{StrokeLayer, WidthProfile};
|
||||
use vectorcraft_doc::{ArrowAlign, StrokeLayer, WidthProfile};
|
||||
|
||||
use super::*;
|
||||
|
||||
@@ -16,6 +17,20 @@ fn stroke(s: &Session, id: NodeId) -> StrokeLayer {
|
||||
s.doc().unwrap().doc.node(id).unwrap().appearance.stroke().unwrap().clone()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arrow_align_is_set_per_stroke_and_validated() {
|
||||
let (mut s, id) = session_with_line();
|
||||
assert_eq!(stroke(&s, id).arrow_align, ArrowAlign::Extend);
|
||||
s.execute("stroke.set", &json!({"endArrow": "TriangleOpen", "arrowAlign": "tip"})).unwrap();
|
||||
let st = stroke(&s, id);
|
||||
assert_eq!((st.end_arrow, st.arrow_align), (Some(vectorcraft_doc::Arrowhead::TriangleOpen), ArrowAlign::Tip));
|
||||
s.execute("stroke.set", &json!({"arrowAlign": "extend", "ids": [id.0]})).unwrap();
|
||||
assert_eq!(stroke(&s, id).arrow_align, ArrowAlign::Extend);
|
||||
assert!(s.execute("stroke.set", &json!({"arrowAlign": "middle"})).is_err());
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(stroke(&s, id).arrow_align, ArrowAlign::Tip);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn profiles_come_from_the_preset_catalogue() {
|
||||
let (mut s, id) = session_with_line();
|
||||
@@ -28,20 +43,27 @@ fn profiles_come_from_the_preset_catalogue() {
|
||||
s.execute("stroke.set", &json!({"profile": "uniform"})).unwrap();
|
||||
assert!(stroke(&s, id).profile.is_none(), "uniform is the plain stroke");
|
||||
assert!(s.execute("stroke.set", &json!({"profile": "zigzag"})).is_err());
|
||||
// The params doc (what agents read) lists every preset.
|
||||
// The params doc (what agents read) lists every preset and the arrow alignment.
|
||||
let spec = command_specs().iter().find(|c| c.id == "stroke.set").unwrap();
|
||||
for p in WidthProfile::PRESETS {
|
||||
assert!(spec.params.contains(&format!("\"{}\"", p.id)), "{}", p.id);
|
||||
}
|
||||
assert!(spec.params.contains("arrowAlign"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn profiles_survive_the_native_format() {
|
||||
fn arrow_align_and_profiles_survive_the_native_format() {
|
||||
let (mut s, id) = session_with_line();
|
||||
s.execute("stroke.set", &json!({"startArrow": "CircleOpen", "endArrow": "Arrow", "profile": "taperEnd"})).unwrap();
|
||||
s.execute(
|
||||
"stroke.set",
|
||||
&json!({"startArrow": "CircleOpen", "endArrow": "Arrow", "arrowAlign": "tip", "profile": "lens", "dash": [0, 6], "cap": "round"}),
|
||||
)
|
||||
.unwrap();
|
||||
let doc = &s.doc().unwrap().doc;
|
||||
let back = vectorcraft_format::load(&vectorcraft_format::save(doc, false)).unwrap();
|
||||
let st = back.node(id).unwrap().appearance.stroke().unwrap().clone();
|
||||
assert_eq!(st, stroke(&s, id));
|
||||
assert_eq!(WidthProfile::id_of(st.profile.as_ref()), "taperEnd");
|
||||
assert_eq!(back.node(id).unwrap().appearance.stroke().unwrap(), &stroke(&s, id));
|
||||
// A stroke with the default alignment writes nothing new, so older builds read it unchanged.
|
||||
s.execute("stroke.set", &json!({"arrowAlign": "extend"})).unwrap();
|
||||
let text = String::from_utf8(vectorcraft_format::save(&s.doc().unwrap().doc, true)).unwrap();
|
||||
assert!(!text.contains("arrow_align"));
|
||||
}
|
||||
@@ -523,6 +523,18 @@ impl Exporter<'_> {
|
||||
s.push_blend_mode(blend(st.blend));
|
||||
pushes += 1;
|
||||
}
|
||||
// The line ends under its arrowheads; they overlap, so both take the opacity once, as a group.
|
||||
let pieces = vectorcraft_effects::stroke::stroke_pieces(bp, st);
|
||||
let grouped = !pieces.heads.is_empty() && st.opacity < 1.0;
|
||||
if grouped {
|
||||
s.push_opacity(norm(st.opacity));
|
||||
pushes += 1;
|
||||
}
|
||||
let opacity = if grouped { NormalizedF32::ONE } else { norm(st.opacity) };
|
||||
let trimmed = match &pieces.line {
|
||||
std::borrow::Cow::Owned(line) => Some(to_path(line)),
|
||||
std::borrow::Cow::Borrowed(_) => None,
|
||||
};
|
||||
let width = match st.align {
|
||||
StrokeAlign::Center => st.width,
|
||||
_ if closed => st.width * 2.0,
|
||||
@@ -567,20 +579,22 @@ impl Exporter<'_> {
|
||||
LineJoin::Round => krilla::paint::LineJoin::Round,
|
||||
LineJoin::Bevel => krilla::paint::LineJoin::Bevel,
|
||||
},
|
||||
opacity: norm(st.opacity),
|
||||
opacity,
|
||||
dash,
|
||||
}));
|
||||
s.draw_path(path);
|
||||
// A line fully covered by its heads (Tip alignment) has nothing left to stroke.
|
||||
if let Some(line) = trimmed.as_ref().map_or(Some(path), Option::as_ref) {
|
||||
s.draw_path(line);
|
||||
}
|
||||
s.set_stroke(None);
|
||||
// Pop the alignment clip before drawing arrowheads.
|
||||
if matches!(st.align, StrokeAlign::Inside | StrokeAlign::Outside) && closed && pushes > 0 {
|
||||
s.pop();
|
||||
pushes -= 1;
|
||||
}
|
||||
let heads = vectorcraft_effects::stroke::stroke_pieces(bp, st).heads;
|
||||
if !heads.is_empty() {
|
||||
s.set_fill(Some(Fill { paint, opacity: norm(st.opacity), rule: krilla::paint::FillRule::NonZero }));
|
||||
for head in &heads {
|
||||
if !pieces.heads.is_empty() {
|
||||
s.set_fill(Some(Fill { paint, opacity, rule: krilla::paint::FillRule::NonZero }));
|
||||
for head in &pieces.heads {
|
||||
if let Some(p) = to_path(&head.outline) {
|
||||
s.draw_path(&p);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
//! Stroke geometry in PDF export: arrowheads come from the shared `vectorcraft_effects::stroke`
|
||||
//! geometry, the same the canvas draws.
|
||||
//! Stroke geometry in PDF export: arrowheads and the trimmed line come from the shared
|
||||
//! `vectorcraft_effects::stroke` geometry, and they take the stroke opacity once.
|
||||
|
||||
use vectorcraft_color::{Color, Paint};
|
||||
use vectorcraft_doc::{Appearance, AppearanceItem, Arrowhead, Document, Node, NodeId, NodeKind, StrokeLayer};
|
||||
use vectorcraft_doc::{Appearance, AppearanceItem, ArrowAlign, Arrowhead, Document, Node, NodeId, NodeKind, StrokeLayer};
|
||||
use vectorcraft_effects::stroke::stroke_pieces;
|
||||
use vectorcraft_geom::{Point, Rect, Shape, shapes};
|
||||
|
||||
@@ -21,9 +21,11 @@ fn line_doc(st: StrokeLayer) -> (Document, Node) {
|
||||
(d, n)
|
||||
}
|
||||
|
||||
fn arrow_stroke(kind: Arrowhead) -> StrokeLayer {
|
||||
fn arrow_stroke(kind: Arrowhead, align: ArrowAlign, opacity: f32) -> StrokeLayer {
|
||||
let mut st = StrokeLayer::new(Paint::solid(Color::BLACK), 4.0);
|
||||
st.end_arrow = Some(kind);
|
||||
st.arrow_align = align;
|
||||
st.opacity = opacity;
|
||||
st
|
||||
}
|
||||
|
||||
@@ -47,16 +49,44 @@ fn painted_boxes(d: &Document) -> (Vec<Rect>, Vec<Rect>) {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arrowheads_match_the_shared_geometry() {
|
||||
for kind in Arrowhead::ALL {
|
||||
let (d, n) = line_doc(arrow_stroke(kind));
|
||||
let bp = n.path_data().unwrap().to_bezpath();
|
||||
let pieces = stroke_pieces(&bp, n.appearance.stroke().unwrap());
|
||||
let (fills, strokes) = painted_boxes(&d);
|
||||
let head = pieces.heads[0].outline.bounding_box();
|
||||
assert_eq!(fills.len(), 1, "{kind:?}: one head");
|
||||
assert!(close(fills[0], head), "{kind:?}: {:?} vs {head:?}", fills[0]);
|
||||
assert_eq!(strokes.len(), 1, "{kind:?}: one line");
|
||||
assert!(close(strokes[0], bp.bounding_box()), "{kind:?}: {:?}", strokes[0]);
|
||||
fn arrowheads_and_the_trimmed_line_match_the_shared_geometry() {
|
||||
for align in [ArrowAlign::Extend, ArrowAlign::Tip] {
|
||||
for kind in Arrowhead::ALL {
|
||||
let (d, n) = line_doc(arrow_stroke(kind, align, 1.0));
|
||||
let bp = n.path_data().unwrap().to_bezpath();
|
||||
let pieces = stroke_pieces(&bp, n.appearance.stroke().unwrap());
|
||||
let (fills, strokes) = painted_boxes(&d);
|
||||
let head = pieces.heads[0].outline.bounding_box();
|
||||
assert_eq!(fills.len(), 1, "{kind:?} {align:?}: one head");
|
||||
assert!(close(fills[0], head), "{kind:?} {align:?}: {:?} vs {head:?}", fills[0]);
|
||||
assert_eq!(strokes.len(), 1, "{kind:?} {align:?}: one line");
|
||||
assert!(close(strokes[0], pieces.line.bounding_box()), "{kind:?} {align:?}: {:?}", strokes[0]);
|
||||
if align == ArrowAlign::Tip {
|
||||
assert!((strokes[0].x1 - (80.0 - pieces.heads[0].inset)).abs() < 0.01, "{kind:?}: the stroke stops under the head");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn line_and_head_share_one_opacity_group() {
|
||||
let (d, _) = line_doc(arrow_stroke(Arrowhead::Triangle, ArrowAlign::Tip, 0.5));
|
||||
let back = import(&export(&d, &PdfOptions::default()).unwrap()).unwrap();
|
||||
let mut group_opacity = None;
|
||||
let mut leaf_opacities = vec![];
|
||||
back.walk(|m| match &m.kind {
|
||||
NodeKind::Group { .. } if m.opacity < 1.0 => group_opacity = Some(m.opacity),
|
||||
NodeKind::Path { .. } => {
|
||||
leaf_opacities.push(m.opacity);
|
||||
for i in &m.appearance.items {
|
||||
leaf_opacities.push(match i {
|
||||
AppearanceItem::Fill(f) => f.opacity,
|
||||
AppearanceItem::Stroke(s) => s.opacity,
|
||||
});
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
});
|
||||
assert!(group_opacity.is_some_and(|o| (o - 0.5).abs() < 0.01), "{group_opacity:?}");
|
||||
assert!(leaf_opacities.iter().all(|o| (o - 1.0).abs() < 1e-3), "{leaf_opacities:?}");
|
||||
}
|
||||
+16
-10
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
//! Pixel tests for the shared stroke geometry (`vectorcraft_effects::stroke`) on the canvas:
|
||||
//! dotted lines (zero-length dashes).
|
||||
//! dotted lines (zero-length dashes) and arrowheads (hollow kinds, alignment, compositing).
|
||||
|
||||
use vectorcraft_color::{Color, Paint};
|
||||
use vectorcraft_doc::{Appearance, AppearanceItem, Dash, Document, LineCap, StrokeLayer};
|
||||
use vectorcraft_geom::{Point, Rect, shapes};
|
||||
use vectorcraft_color::{Color, Gradient, GradientKind, GradientPaint, GradientStop, Paint};
|
||||
use vectorcraft_doc::{Appearance, AppearanceItem, ArrowAlign, Arrowhead, Dash, Document, LineCap, StrokeLayer};
|
||||
use vectorcraft_geom::{Point, Rect, Shape, shapes};
|
||||
use vectorcraft_render::{Renderer, effects};
|
||||
use vectorcraft_testkit::fixtures::DocBuilder;
|
||||
use vectorcraft_testkit::raster::{Image, assert_similar, render_region};
|
||||
use vectorcraft_testkit::svg;
|
||||
@@ -75,3 +76,97 @@ fn dotted_lines_survive_svg_export_and_render_the_same() {
|
||||
assert_similar(&render(&d), &render(&back), 0.05, 0.001);
|
||||
}
|
||||
}
|
||||
|
||||
fn arrow_doc(kind: Arrowhead, align: ArrowAlign, opacity: f32, f: impl FnOnce(&mut StrokeLayer)) -> Document {
|
||||
line_doc(
|
||||
20.0,
|
||||
80.0,
|
||||
stroked(4.0, |s| {
|
||||
s.end_arrow = Some(kind);
|
||||
s.arrow_align = align;
|
||||
f(s);
|
||||
}),
|
||||
opacity,
|
||||
)
|
||||
}
|
||||
|
||||
/// The arrowhead the shared geometry computes for the line of `d`.
|
||||
fn head_of(d: &Document) -> effects::stroke::Arrow {
|
||||
let n = d.layers[0].children().unwrap()[0].clone();
|
||||
let bp = n.path_data().unwrap().to_bezpath();
|
||||
effects::stroke::stroke_pieces(&bp, n.appearance.stroke().unwrap()).heads.remove(0)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_hollow_circle_head_is_empty_inside() {
|
||||
let d = arrow_doc(Arrowhead::CircleOpen, ArrowAlign::Extend, 1.0, |_| {});
|
||||
let h = head_of(&d);
|
||||
let centre = h.tip - h.dir * 8.0;
|
||||
let img = render(&d);
|
||||
assert!(!dark(&img, centre.x, centre.y), "the centre of an open circle is empty");
|
||||
assert!(dark(&img, h.tip.x - 1.0, 50.0), "its wall is painted");
|
||||
// The filled circle is solid.
|
||||
let solid = render(&arrow_doc(Arrowhead::Circle, ArrowAlign::Extend, 1.0, |_| {}));
|
||||
assert!(dark(&solid, centre.x, centre.y));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn heads_are_drawn_where_the_shared_geometry_puts_them_and_never_past_the_tip() {
|
||||
for align in [ArrowAlign::Extend, ArrowAlign::Tip] {
|
||||
for kind in Arrowhead::ALL {
|
||||
let d = arrow_doc(kind, align, 1.0, |s| s.cap = LineCap::Round);
|
||||
let h = head_of(&d);
|
||||
if align == ArrowAlign::Tip {
|
||||
assert_eq!(h.tip, Point::new(80.0, 50.0));
|
||||
} else {
|
||||
assert!((h.tip.x - (80.0 + h.inset)).abs() < 1e-9);
|
||||
}
|
||||
let img = render(&d);
|
||||
// The rightmost ink is the tip (allowing for antialiasing).
|
||||
let right = (0..img.width).rev().find(|&x| (0..img.height).any(|y| img.over_white(x, y)[0] < 250)).unwrap();
|
||||
let bb = h.outline.bounding_box();
|
||||
assert!((right as f64 / S - bb.x1).abs() <= 0.5, "{kind:?} {align:?}: ink ends at {} vs head {}", right as f64 / S, bb.x1);
|
||||
assert!(!dark(&img, h.tip.x + 0.5, 50.0), "{kind:?} {align:?}: nothing past the tip");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Straight-alpha pixel at document point (x, y), rendered on transparent.
|
||||
fn alpha_at(d: &Document, x: f64, y: f64) -> u8 {
|
||||
let r = Renderer::new().render_region(d, Rect::new(0.0, 0.0, 100.0, 100.0), S, false);
|
||||
r.pixel((x * S) as u32, (y * S) as u32)[3]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn line_and_head_take_the_opacity_once() {
|
||||
for (node, stroke) in [(0.5, 1.0), (1.0, 0.5)] {
|
||||
let d = arrow_doc(Arrowhead::Triangle, ArrowAlign::Extend, node, |s| s.opacity = stroke);
|
||||
let h = head_of(&d);
|
||||
let line = alpha_at(&d, 40.0, 50.0);
|
||||
assert!((120..=135).contains(&line), "half-opaque line: {line}");
|
||||
// Where the line runs under the head, and in the head beside it.
|
||||
let overlap = alpha_at(&d, h.tip.x - h.inset - 1.0, 50.0);
|
||||
let beside = alpha_at(&d, h.tip.x - 12.0, 50.0 + 4.5);
|
||||
assert_eq!(overlap, line, "no double darkening where line and head overlap");
|
||||
assert_eq!(beside, line, "the head has the line's opacity");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_gradient_runs_on_into_the_head() {
|
||||
let stops = vec![
|
||||
GradientStop { offset: 0.0, color: Color::rgb(1.0, 0.0, 0.0), opacity: 1.0, midpoint: 0.5 },
|
||||
GradientStop { offset: 1.0, color: Color::rgb(0.0, 0.0, 1.0), opacity: 1.0, midpoint: 0.5 },
|
||||
];
|
||||
let paint = Paint::Gradient(Box::new(GradientPaint::new(Gradient { kind: GradientKind::Linear, stops })));
|
||||
let d = arrow_doc(Arrowhead::Square, ArrowAlign::Tip, 1.0, |s| s.paint = paint);
|
||||
let h = head_of(&d);
|
||||
let img = render(&d);
|
||||
// Just behind the head's back edge (line) and just inside it (head): nearly the same colour.
|
||||
let back = h.tip.x - 16.0;
|
||||
let (a, b) = (img.over_white(((back - 0.5) * S) as u32, (50.0 * S) as u32), img.over_white(((back + 0.5) * S) as u32, (50.0 * S) as u32));
|
||||
assert!(a[0].abs_diff(b[0]) < 12 && a[2].abs_diff(b[2]) < 12, "{a:?} vs {b:?}");
|
||||
// The head is part of the same gradient: bluer than the line's start.
|
||||
let start = img.over_white((22.0 * S) as u32, (50.0 * S) as u32);
|
||||
assert!(b[2] > start[2] + 100, "{b:?} vs {start:?}");
|
||||
}
|
||||
@@ -44,6 +44,8 @@ pub static ICONS: &[(&str, &[u8])] = &[
|
||||
("dc-appearance", include_bytes!("../../../assets/icons/dc-appearance.svg")),
|
||||
("dc-arc", include_bytes!("../../../assets/icons/dc-arc.svg")),
|
||||
("dc-arrow-down", include_bytes!("../../../assets/icons/dc-arrow-down.svg")),
|
||||
("dc-arrow-extend", include_bytes!("../../../assets/icons/dc-arrow-extend.svg")),
|
||||
("dc-arrow-tip", include_bytes!("../../../assets/icons/dc-arrow-tip.svg")),
|
||||
("dc-arrow-up", include_bytes!("../../../assets/icons/dc-arrow-up.svg")),
|
||||
("dc-artboard-options", include_bytes!("../../../assets/icons/dc-artboard-options.svg")),
|
||||
("dc-artboards", include_bytes!("../../../assets/icons/dc-artboards.svg")),
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
use egui::{Color32, Pos2, Rect, Sense, Stroke, StrokeKind, Ui, pos2, vec2};
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::{Arrowhead, LineCap, LineJoin, ProfilePreset, StrokeAlign, StrokeLayer, Unit, WidthProfile};
|
||||
use vectorcraft_doc::{ArrowAlign, Arrowhead, LineCap, LineJoin, ProfilePreset, StrokeAlign, StrokeLayer, Unit, WidthProfile};
|
||||
|
||||
use super::{first_selected, pstate, set_pstate};
|
||||
use crate::theme::Tokens;
|
||||
@@ -18,6 +18,17 @@ fn profile_of(id: &str) -> Option<WidthProfile> {
|
||||
WidthProfile::preset(id).filter(|_| id != "uniform")
|
||||
}
|
||||
|
||||
/// The Arrowheads Align buttons: (alignment, `stroke.set` value, icon, tooltip).
|
||||
const ARROW_ALIGN: [(ArrowAlign, &str, &str, &str); 2] = [
|
||||
(ArrowAlign::Extend, "extend", "dc-arrow-extend", "Tip extends past the end point"),
|
||||
(ArrowAlign::Tip, "tip", "dc-arrow-tip", "Tip on the end point"),
|
||||
];
|
||||
|
||||
/// Which Align button is on, and whether they are usable (only a stroke with a head can be aligned).
|
||||
fn arrow_align_state(st: Option<&StrokeLayer>) -> (ArrowAlign, bool) {
|
||||
st.map_or((ArrowAlign::default(), false), |s| (s.arrow_align, s.start_arrow.is_some() || s.end_arrow.is_some()))
|
||||
}
|
||||
|
||||
/// Dash pattern → the panel's six dash/gap fields (None = empty field).
|
||||
pub fn dash_fields(pattern: &[f64]) -> [Option<f64>; 6] {
|
||||
let mut out = [None; 6];
|
||||
@@ -212,10 +223,14 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
app.run("stroke.setAdvanced", json!({"arrowScale": [a, b]})).ok();
|
||||
}
|
||||
});
|
||||
let (arrow_align, can_align) = arrow_align_state(st.as_ref());
|
||||
ui.horizontal(|ui| {
|
||||
row_label(ui, "Align:");
|
||||
widgets::icon_button_enabled(ui, "dc-cap-square", "Tip extends past the end (on the roadmap)", true, false, 22.0);
|
||||
widgets::icon_button_enabled(ui, "dc-cap-butt", "Tip on the end point (on the roadmap)", false, false, 22.0);
|
||||
for (v, name, icon, tip) in ARROW_ALIGN {
|
||||
if widgets::icon_button_enabled(ui, icon, tip, arrow_align == v, can_align, 22.0).clicked() {
|
||||
set(app, json!({"arrowAlign": name}));
|
||||
}
|
||||
}
|
||||
});
|
||||
widgets::divider(ui);
|
||||
// Profile.
|
||||
@@ -393,6 +408,19 @@ mod tests {
|
||||
assert_eq!(arrow_label(Some(Arrowhead::CircleOpen)), "Circle (open)");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arrow_align_buttons_need_a_head() {
|
||||
let mut st = StrokeLayer::new(vectorcraft_color::Paint::None, 1.0);
|
||||
assert_eq!(arrow_align_state(None), (ArrowAlign::Extend, false));
|
||||
assert_eq!(arrow_align_state(Some(&st)), (ArrowAlign::Extend, false));
|
||||
st.start_arrow = Some(Arrowhead::Bar);
|
||||
st.arrow_align = ArrowAlign::Tip;
|
||||
assert_eq!(arrow_align_state(Some(&st)), (ArrowAlign::Tip, true));
|
||||
for (_, _, icon, _) in ARROW_ALIGN {
|
||||
assert!(icons::exists(icon), "{icon}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Run the panel and its ≡ menu for one headless frame.
|
||||
fn frame(app: &mut VectorcraftApp) {
|
||||
let ctx = egui::Context::default();
|
||||
@@ -412,12 +440,12 @@ mod tests {
|
||||
frame(&mut app);
|
||||
r(&mut app, "shape.line", json!({"x1": 10, "y1": 50, "x2": 90, "y2": 50}));
|
||||
frame(&mut app);
|
||||
for p in [json!({"endArrow": "ArrowOpen"}), json!({"profile": "lens"}), json!({"dash": [0, 6], "cap": "round"})] {
|
||||
for p in [json!({"endArrow": "ArrowOpen", "arrowAlign": "tip"}), json!({"profile": "lens"}), json!({"dash": [0, 6], "cap": "round"})] {
|
||||
r(&mut app, "stroke.set", p);
|
||||
frame(&mut app);
|
||||
}
|
||||
let st = stroke_now(&app).unwrap();
|
||||
assert_eq!(st.end_arrow, Some(Arrowhead::ArrowOpen));
|
||||
assert_eq!((st.end_arrow, st.arrow_align), (Some(Arrowhead::ArrowOpen), ArrowAlign::Tip));
|
||||
assert_eq!(WidthProfile::id_of(st.profile.as_ref()), "lens");
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user