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:
parent
8f9d6ef2bd
commit
09d7aaa7f4
|
|
@ -74,6 +74,17 @@ pub struct WidthProfile {
|
|||
pub points: Vec<(f64, f64, f64)>,
|
||||
}
|
||||
|
||||
/// A built-in width profile (the Stroke panel's Profile list).
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct ProfilePreset {
|
||||
/// Stable id used by `stroke.set {profile}`.
|
||||
pub id: &'static str,
|
||||
/// Menu label.
|
||||
pub label: &'static str,
|
||||
/// (t, left, right) width points.
|
||||
pub points: &'static [(f64, f64, f64)],
|
||||
}
|
||||
|
||||
impl WidthProfile {
|
||||
/// Width factor at `t` (average of both sides), linear between points.
|
||||
pub fn at(&self, t: f64) -> (f64, f64) {
|
||||
|
|
@ -93,15 +104,34 @@ impl WidthProfile {
|
|||
let l = p.last().unwrap();
|
||||
(l.1, l.2)
|
||||
}
|
||||
/// Illustrator's "Width Profile 1" (lens shape) analogue.
|
||||
/// The built-in profiles, in menu order. "uniform" is the plain stroke (no profile).
|
||||
pub const PRESETS: [ProfilePreset; 4] = [
|
||||
ProfilePreset { id: "uniform", label: "Uniform", points: &[(0.0, 1.0, 1.0), (1.0, 1.0, 1.0)] },
|
||||
ProfilePreset { id: "lens", label: "Lens", points: &[(0.0, 0.0, 0.0), (0.5, 1.0, 1.0), (1.0, 0.0, 0.0)] },
|
||||
ProfilePreset { id: "taperStart", label: "Taper Start", points: &[(0.0, 0.0, 0.0), (1.0, 1.0, 1.0)] },
|
||||
ProfilePreset { id: "taperEnd", label: "Taper End", points: &[(0.0, 1.0, 1.0), (1.0, 0.0, 0.0)] },
|
||||
];
|
||||
/// The built-in profile with this id.
|
||||
pub fn preset(id: &str) -> Option<Self> {
|
||||
Self::PRESETS.iter().find(|p| p.id == id).map(|p| Self { points: p.points.to_vec() })
|
||||
}
|
||||
/// The id of the built-in profile these points match, if any.
|
||||
pub fn preset_id(&self) -> Option<&'static str> {
|
||||
Self::PRESETS.iter().find(|p| p.points == self.points.as_slice()).map(|p| p.id)
|
||||
}
|
||||
/// The id of a stroke's profile: "uniform" without one, "custom" when it matches no preset.
|
||||
pub fn id_of(p: Option<&Self>) -> &'static str {
|
||||
p.map_or(Some("uniform"), Self::preset_id).unwrap_or("custom")
|
||||
}
|
||||
/// The lens profile (thin ends, full width in the middle).
|
||||
pub fn lens() -> Self {
|
||||
Self { points: vec![(0.0, 0.0, 0.0), (0.5, 1.0, 1.0), (1.0, 0.0, 0.0)] }
|
||||
Self::preset("lens").expect("built-in")
|
||||
}
|
||||
pub fn taper_end() -> Self {
|
||||
Self { points: vec![(0.0, 1.0, 1.0), (1.0, 0.0, 0.0)] }
|
||||
Self::preset("taperEnd").expect("built-in")
|
||||
}
|
||||
pub fn taper_start() -> Self {
|
||||
Self { points: vec![(0.0, 0.0, 0.0), (1.0, 1.0, 1.0)] }
|
||||
Self::preset("taperStart").expect("built-in")
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -347,6 +377,19 @@ mod tests {
|
|||
assert_eq!(p.at(2.0), (0.0, 0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn profile_presets_round_trip_their_ids() {
|
||||
for p in WidthProfile::PRESETS {
|
||||
let prof = WidthProfile::preset(p.id).unwrap();
|
||||
assert_eq!(prof.preset_id(), Some(p.id));
|
||||
assert_eq!(WidthProfile::id_of(Some(&prof)), p.id);
|
||||
}
|
||||
assert_eq!(WidthProfile::id_of(None), "uniform");
|
||||
assert_eq!(WidthProfile::id_of(Some(&WidthProfile { points: vec![(0.0, 0.3, 0.3)] })), "custom");
|
||||
assert!(WidthProfile::preset("nope").is_none());
|
||||
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 outset_depends_on_align() {
|
||||
let mut a = Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 10.0);
|
||||
|
|
|
|||
|
|
@ -30,7 +30,9 @@ pub(crate) mod skip {
|
|||
}
|
||||
}
|
||||
|
||||
pub use appearance::{Appearance, AppearanceItem, Arrowhead, Dash, Effect, FillLayer, LineCap, LineJoin, StrokeAlign, StrokeLayer, WidthProfile};
|
||||
pub use appearance::{
|
||||
Appearance, AppearanceItem, Arrowhead, Dash, Effect, FillLayer, LineCap, LineJoin, ProfilePreset, StrokeAlign, StrokeLayer, WidthProfile,
|
||||
};
|
||||
pub use graph::{GraphKind, GraphSpec};
|
||||
pub use hit::{Hit, HitKind};
|
||||
pub use live::{BlendOrientation, BlendSpacing, BlendSpec, EnvelopeKind, GradientMesh, MeshPoint};
|
||||
|
|
|
|||
|
|
@ -8,6 +8,8 @@
|
|||
//! - **Raster effects** (Drop Shadow, Inner/Outer Glow, Feather, Gaussian Blur) are described by
|
||||
//! [`raster_effects`] and painted by the renderer; [`outset`] says how far they reach beyond
|
||||
//! the geometry.
|
||||
//! - **Stroke geometry** ([`stroke`]): arrowheads, dash patterns and width profiles, shared by the
|
||||
//! renderer, the exporters and Outline Stroke.
|
||||
//! - [`effect_catalog`] lists every effect with its menu path, parameter documentation and the
|
||||
//! defaults of Illustrator's dialogs. Missing parameters always fall back to those defaults.
|
||||
//!
|
||||
|
|
@ -19,6 +21,7 @@ mod bake;
|
|||
mod distort;
|
||||
mod group;
|
||||
mod raster;
|
||||
pub mod stroke;
|
||||
mod stylize;
|
||||
mod util;
|
||||
mod warp;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,126 @@
|
|||
//! 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;
|
||||
//! - [`width_outline`]: variable-width (profile) strokes;
|
||||
//! - [`line_outline`]: the line part of a stroke as one filled outline (profile or dashes, caps
|
||||
//! and joins).
|
||||
|
||||
mod arrow;
|
||||
mod width;
|
||||
|
||||
use std::borrow::Cow;
|
||||
|
||||
use kurbo::{BezPath, ParamCurve, ParamCurveDeriv, PathEl, PathSeg, Vec2};
|
||||
use vectorcraft_doc::{LineCap, LineJoin, StrokeAlign, StrokeLayer};
|
||||
|
||||
pub use arrow::Arrow;
|
||||
pub use width::width_outline;
|
||||
|
||||
/// The geometry one stroke paints, in the coordinate space of its path.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct StrokePieces<'a> {
|
||||
/// The centre line to stroke.
|
||||
pub line: Cow<'a, BezPath>,
|
||||
/// The arrowheads, start first.
|
||||
pub heads: Vec<Arrow>,
|
||||
}
|
||||
|
||||
/// The centre line and arrowheads of stroke `st` along `bp`.
|
||||
pub fn stroke_pieces<'a>(bp: &'a BezPath, st: &StrokeLayer) -> StrokePieces<'a> {
|
||||
if st.start_arrow.is_none() && st.end_arrow.is_none() {
|
||||
return StrokePieces { line: Cow::Borrowed(bp), heads: vec![] };
|
||||
}
|
||||
arrow::pieces(bp, st)
|
||||
}
|
||||
|
||||
/// Does `bp` end with a `ClosePath`?
|
||||
pub fn is_closed(bp: &BezPath) -> bool {
|
||||
bp.elements().last().is_some_and(|e| matches!(e, PathEl::ClosePath))
|
||||
}
|
||||
|
||||
/// The width actually stroked: inside/outside strokes of closed paths are drawn twice as wide
|
||||
/// and clipped to one side; open paths always stroke centred.
|
||||
pub fn aligned_width(st: &StrokeLayer, closed: bool) -> f64 {
|
||||
match st.align {
|
||||
StrokeAlign::Inside | StrokeAlign::Outside if closed => st.width * 2.0,
|
||||
_ => st.width,
|
||||
}
|
||||
}
|
||||
|
||||
/// 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)
|
||||
.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)
|
||||
}
|
||||
|
||||
/// Does the dash pattern of `st` draw anything other than a solid line?
|
||||
pub fn is_dashed(st: &StrokeLayer) -> bool {
|
||||
st.dash.as_ref().is_some_and(|d| d.pattern.iter().any(|v| *v > 0.0))
|
||||
}
|
||||
|
||||
/// The filled outline of the line part of `st` along `line` (usually [`StrokePieces::line`]) at
|
||||
/// `width` (see [`aligned_width`]), flattened/fitted to `tol`: the width profile, or the dash
|
||||
/// pattern, with the stroke's caps and joins. Fill it with the non-zero rule. Arrowheads are not
|
||||
/// included.
|
||||
pub fn line_outline(line: &BezPath, st: &StrokeLayer, width: f64, tol: f64) -> BezPath {
|
||||
if line.elements().is_empty() || width <= 0.0 || !width.is_finite() {
|
||||
return BezPath::new();
|
||||
}
|
||||
let tol = tol.max(1e-4);
|
||||
if let Some(profile) = st.profile.as_ref().filter(|_| !is_dashed(st)) {
|
||||
return width_outline(line, width, profile, st.cap, tol);
|
||||
}
|
||||
let mut style = style(st, width);
|
||||
if let Some(d) = st.dash.as_ref().filter(|_| is_dashed(st)) {
|
||||
// An odd pattern repeats with dashes and gaps swapped (as in SVG and PDF).
|
||||
let mut pat = d.pattern.clone();
|
||||
if pat.len() % 2 == 1 {
|
||||
pat.extend_from_within(..);
|
||||
}
|
||||
style = style.with_dashes(d.offset, pat);
|
||||
}
|
||||
kurbo::stroke(line.iter(), &style, &kurbo::StrokeOpts::default(), tol)
|
||||
}
|
||||
|
||||
/// Unit tangent of `s` at `t`, robust at ends where control points coincide with the end point.
|
||||
pub(crate) fn tangent(s: &PathSeg, t: f64) -> Vec2 {
|
||||
let d = match s {
|
||||
PathSeg::Line(l) => l.p1 - l.p0,
|
||||
PathSeg::Quad(q) => {
|
||||
let v = q.deriv().eval(t).to_vec2();
|
||||
if v.hypot() > 1e-9 { v } else { q.p2 - q.p0 }
|
||||
}
|
||||
PathSeg::Cubic(c) => {
|
||||
let v = c.deriv().eval(t).to_vec2();
|
||||
if v.hypot() > 1e-9 {
|
||||
v
|
||||
} else if t < 0.5 {
|
||||
// The first control point that differs from the start gives the direction.
|
||||
[c.p2, c.p3].into_iter().map(|p| p - c.p0).find(|v| v.hypot() > 1e-9).unwrap_or_default()
|
||||
} else {
|
||||
[c.p1, c.p0].into_iter().map(|p| c.p3 - p).find(|v| v.hypot() > 1e-9).unwrap_or_default()
|
||||
}
|
||||
}
|
||||
};
|
||||
unit(d)
|
||||
}
|
||||
|
||||
/// `v` normalised, or +x when it has no length.
|
||||
pub(crate) fn unit(v: Vec2) -> Vec2 {
|
||||
let h = v.hypot();
|
||||
if h > 1e-12 { v / h } else { Vec2::new(1.0, 0.0) }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
|
@ -0,0 +1,69 @@
|
|||
//! 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.
|
||||
|
||||
use std::borrow::Cow;
|
||||
|
||||
use kurbo::{BezPath, ParamCurve, PathSeg, Point, Shape, Vec2};
|
||||
use vectorcraft_doc::{Arrowhead, StrokeLayer};
|
||||
|
||||
use super::{StrokePieces, tangent};
|
||||
|
||||
/// One arrowhead.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Arrow {
|
||||
pub kind: Arrowhead,
|
||||
/// The filled outline (non-zero rule).
|
||||
pub outline: BezPath,
|
||||
pub tip: Point,
|
||||
/// Unit direction the head points in.
|
||||
pub dir: Vec2,
|
||||
}
|
||||
|
||||
/// [`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 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 });
|
||||
}
|
||||
}
|
||||
StrokePieces { line: Cow::Borrowed(bp), heads }
|
||||
}
|
||||
|
||||
/// 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 mut p = BezPath::new();
|
||||
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::Diamond => polygon(&mut p, &[tip, centre + n * half, base, centre - n * half]),
|
||||
Arrowhead::Bar => {
|
||||
let t = 0.75 * hw;
|
||||
polygon(&mut p, &[tip + n * half, tip + n * half - dir * t, tip - n * half - dir * t, tip - n * half]);
|
||||
}
|
||||
}
|
||||
p
|
||||
}
|
||||
|
||||
fn polygon(p: &mut BezPath, pts: &[Point]) {
|
||||
let Some((first, rest)) = pts.split_first() else { return };
|
||||
p.move_to(*first);
|
||||
for q in rest {
|
||||
p.line_to(*q);
|
||||
}
|
||||
p.close_path();
|
||||
}
|
||||
|
|
@ -0,0 +1,82 @@
|
|||
use kurbo::{BezPath, Point, Shape};
|
||||
use vectorcraft_color::{Color, Paint};
|
||||
use vectorcraft_doc::{Arrowhead, Dash, StrokeLayer, WidthProfile};
|
||||
|
||||
use super::*;
|
||||
|
||||
fn line(x0: f64, x1: f64) -> BezPath {
|
||||
let mut b = BezPath::new();
|
||||
b.move_to((x0, 0.0));
|
||||
b.line_to((x1, 0.0));
|
||||
b
|
||||
}
|
||||
|
||||
fn stroke(width: f64, f: impl FnOnce(&mut StrokeLayer)) -> StrokeLayer {
|
||||
let mut st = StrokeLayer::new(Paint::solid(Color::BLACK), width);
|
||||
f(&mut st);
|
||||
st
|
||||
}
|
||||
|
||||
fn dashed(pattern: &[f64], offset: f64) -> Dash {
|
||||
Dash { pattern: pattern.to_vec(), offset, align_corners: false }
|
||||
}
|
||||
|
||||
fn near(a: f64, b: f64, tol: f64) -> bool {
|
||||
(a - b).abs() <= tol
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn line_outline_uses_the_profile_dashes_or_a_plain_stroke() {
|
||||
let l = line(0.0, 100.0);
|
||||
let plain = line_outline(&l, &stroke(10.0, |_| {}), 10.0, 1e-3);
|
||||
assert!(near(plain.area().abs(), 1000.0, 1e-6));
|
||||
let lens = line_outline(&l, &stroke(10.0, |s| s.profile = Some(WidthProfile::lens())), 10.0, 1e-3);
|
||||
assert!(near(lens.area().abs(), 500.0, 1e-6));
|
||||
// Dashes take precedence over a profile (as before).
|
||||
let st = stroke(10.0, |s| {
|
||||
s.profile = Some(WidthProfile::lens());
|
||||
s.dash = Some(dashed(&[25.0, 25.0], 0.0));
|
||||
});
|
||||
assert!(near(line_outline(&l, &st, 10.0, 1e-3).area().abs(), 500.0, 1e-6));
|
||||
assert!(line_outline(&BezPath::new(), &st, 10.0, 1e-3).elements().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn aligned_width_doubles_only_closed_inside_and_outside_strokes() {
|
||||
let mut st = stroke(3.0, |s| s.align = vectorcraft_doc::StrokeAlign::Inside);
|
||||
assert_eq!(aligned_width(&st, true), 6.0);
|
||||
assert_eq!(aligned_width(&st, false), 3.0);
|
||||
st.align = vectorcraft_doc::StrokeAlign::Center;
|
||||
assert_eq!(aligned_width(&st, true), 3.0);
|
||||
}
|
||||
|
||||
#[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());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn heads_sit_on_the_end_points_and_point_out_of_the_path() {
|
||||
for kind in Arrowhead::ALL {
|
||||
let st = stroke(2.0, |s| {
|
||||
s.start_arrow = Some(kind);
|
||||
s.end_arrow = Some(kind);
|
||||
});
|
||||
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:?}");
|
||||
}
|
||||
// The start head of a curve points back along its first tangent.
|
||||
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)));
|
||||
assert!(p.heads[0].dir.x < 0.0 && p.heads[0].dir.y > 0.0, "{:?}", p.heads[0].dir);
|
||||
}
|
||||
|
|
@ -6,24 +6,10 @@
|
|||
//! 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};
|
||||
use kurbo::{BezPath, PathEl, Point, Vec2};
|
||||
use vectorcraft_doc::{LineCap, WidthProfile};
|
||||
|
||||
/// 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)
|
||||
}
|
||||
use super::unit;
|
||||
|
||||
/// 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 {
|
||||
|
|
@ -72,11 +58,6 @@ fn subpaths(bp: &BezPath, tol: f64) -> Vec<(Vec<Point>, bool)> {
|
|||
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)
|
||||
|
|
@ -255,7 +236,5 @@ mod tests {
|
|||
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());
|
||||
}
|
||||
}
|
||||
|
|
@ -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> {
|
||||
|
|
|
|||
|
|
@ -733,6 +733,8 @@ mod tests_pattern;
|
|||
#[cfg(test)]
|
||||
mod tests_prefs;
|
||||
#[cfg(test)]
|
||||
mod tests_strokegeom;
|
||||
#[cfg(test)]
|
||||
mod tests_textedit;
|
||||
#[cfg(test)]
|
||||
mod tests_xform;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,47 @@
|
|||
//! Stroke geometry from the Stroke panel's commands (`cmd/stroke.rs`): the width-profile presets.
|
||||
|
||||
use serde_json::json;
|
||||
use vectorcraft_doc::{StrokeLayer, WidthProfile};
|
||||
|
||||
use super::*;
|
||||
|
||||
fn session_with_line() -> (Session, NodeId) {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 200, "height": 200})).unwrap();
|
||||
let r = s.execute("shape.line", &json!({"x1": 10, "y1": 100, "x2": 150, "y2": 100})).unwrap();
|
||||
(s, NodeId(r["id"].as_u64().unwrap()))
|
||||
}
|
||||
|
||||
fn stroke(s: &Session, id: NodeId) -> StrokeLayer {
|
||||
s.doc().unwrap().doc.node(id).unwrap().appearance.stroke().unwrap().clone()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn profiles_come_from_the_preset_catalogue() {
|
||||
let (mut s, id) = session_with_line();
|
||||
for p in WidthProfile::PRESETS {
|
||||
s.execute("stroke.set", &json!({"profile": p.id})).unwrap();
|
||||
let st = stroke(&s, id);
|
||||
assert_eq!(WidthProfile::id_of(st.profile.as_ref()), p.id);
|
||||
}
|
||||
assert!(stroke(&s, id).profile.is_some());
|
||||
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.
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn profiles_survive_the_native_format() {
|
||||
let (mut s, id) = session_with_line();
|
||||
s.execute("stroke.set", &json!({"startArrow": "CircleOpen", "endArrow": "Arrow", "profile": "taperEnd"})).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");
|
||||
}
|
||||
|
|
@ -11,9 +11,9 @@ use krilla::num::NormalizedF32;
|
|||
use krilla::page::PageSettings;
|
||||
use krilla::paint::{Fill, LinearGradient, RadialGradient, SpreadMethod, Stop, Stroke, StrokeDash};
|
||||
use krilla::surface::Surface;
|
||||
use kurbo::{ParamCurve, ParamCurveDeriv, PathEl, Shape, Vec2};
|
||||
use kurbo::{PathEl, Shape, Vec2};
|
||||
use vectorcraft_color::{BlendMode, Color, GradientKind, Paint};
|
||||
use vectorcraft_doc::{AppearanceItem, Arrowhead, Document, LineCap, LineJoin, Node, NodeKind, StrokeAlign, StrokeLayer, TextObject};
|
||||
use vectorcraft_doc::{AppearanceItem, Document, LineCap, LineJoin, Node, NodeKind, StrokeAlign, StrokeLayer, TextObject};
|
||||
use vectorcraft_geom::{Affine, BezPath, FillRule, Rect};
|
||||
|
||||
use crate::{Compatibility, ExportReport, PdfError, PdfOptions};
|
||||
|
|
@ -577,10 +577,11 @@ impl Exporter<'_> {
|
|||
s.pop();
|
||||
pushes -= 1;
|
||||
}
|
||||
if st.start_arrow.is_some() || st.end_arrow.is_some() {
|
||||
for head in arrowheads(bp, st) {
|
||||
if let Some(p) = to_path(&head) {
|
||||
s.set_fill(Some(Fill { paint: paint.clone(), opacity: norm(st.opacity), rule: krilla::paint::FillRule::NonZero }));
|
||||
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 let Some(p) = to_path(&head.outline) {
|
||||
s.draw_path(&p);
|
||||
}
|
||||
}
|
||||
|
|
@ -679,78 +680,3 @@ impl Exporter<'_> {
|
|||
s.pop();
|
||||
}
|
||||
}
|
||||
|
||||
/// Arrowhead outlines for a stroke (same geometry as the renderer).
|
||||
fn arrowheads(bp: &BezPath, st: &StrokeLayer) -> Vec<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 {
|
||||
out.push(head(a, first.eval(0.0), tangent(first, 0.0) * -1.0, st.width * st.arrow_scale.0 / 100.0));
|
||||
}
|
||||
if let Some(a) = st.end_arrow {
|
||||
out.push(head(a, last.eval(1.0), tangent(last, 1.0), 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 }
|
||||
}
|
||||
|
||||
fn head(kind: Arrowhead, tip: kurbo::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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -105,3 +105,5 @@ pub enum PdfError {
|
|||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
#[cfg(test)]
|
||||
mod tests_stroke;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,62 @@
|
|||
//! Stroke geometry in PDF export: arrowheads come from the shared `vectorcraft_effects::stroke`
|
||||
//! geometry, the same the canvas draws.
|
||||
|
||||
use vectorcraft_color::{Color, Paint};
|
||||
use vectorcraft_doc::{Appearance, AppearanceItem, Arrowhead, Document, Node, NodeId, NodeKind, StrokeLayer};
|
||||
use vectorcraft_effects::stroke::stroke_pieces;
|
||||
use vectorcraft_geom::{Point, Rect, Shape, shapes};
|
||||
|
||||
use crate::*;
|
||||
|
||||
fn line_doc(st: StrokeLayer) -> (Document, Node) {
|
||||
let mut d = Document::new(100.0, 100.0);
|
||||
let mut n = Node::path(
|
||||
NodeId(0),
|
||||
shapes::line(Point::new(20.0, 50.0), Point::new(80.0, 50.0)),
|
||||
Appearance { items: vec![AppearanceItem::Stroke(st)], effects: vec![] },
|
||||
);
|
||||
n.id = d.alloc_id();
|
||||
let layer = d.default_layer().unwrap();
|
||||
d.insert(Some(layer), 0, n.clone()).unwrap();
|
||||
(d, n)
|
||||
}
|
||||
|
||||
fn arrow_stroke(kind: Arrowhead) -> StrokeLayer {
|
||||
let mut st = StrokeLayer::new(Paint::solid(Color::BLACK), 4.0);
|
||||
st.end_arrow = Some(kind);
|
||||
st
|
||||
}
|
||||
|
||||
fn close(a: Rect, b: Rect) -> bool {
|
||||
[(a.x0, b.x0), (a.y0, b.y0), (a.x1, b.x1), (a.y1, b.y1)].iter().all(|(p, q)| (p - q).abs() < 0.01)
|
||||
}
|
||||
|
||||
/// Bounding boxes of the filled and the stroked paths of a PDF read back.
|
||||
fn painted_boxes(d: &Document) -> (Vec<Rect>, Vec<Rect>) {
|
||||
let back = import(&export(d, &PdfOptions::default()).unwrap()).unwrap();
|
||||
let (mut fills, mut strokes) = (vec![], vec![]);
|
||||
back.walk(|m| {
|
||||
let Some(bb) = m.geometric_bounds().filter(|_| matches!(m.kind, NodeKind::Path { .. } | NodeKind::Compound { .. })) else { return };
|
||||
match m.appearance.items.first() {
|
||||
Some(AppearanceItem::Fill(_)) => fills.push(bb),
|
||||
Some(AppearanceItem::Stroke(_)) => strokes.push(bb),
|
||||
None => {}
|
||||
}
|
||||
});
|
||||
(fills, strokes)
|
||||
}
|
||||
|
||||
#[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]);
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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, StrokeAlign, StrokeLayer, Unit, WidthProfile};
|
||||
use vectorcraft_doc::{Arrowhead, LineCap, LineJoin, ProfilePreset, StrokeAlign, StrokeLayer, Unit, WidthProfile};
|
||||
|
||||
use super::{first_selected, pstate, set_pstate};
|
||||
use crate::theme::Tokens;
|
||||
|
|
@ -13,27 +13,9 @@ use crate::{VectorcraftApp, icons};
|
|||
pub const WEIGHT_PRESETS: [f64; 22] =
|
||||
[0.25, 0.5, 0.75, 1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0, 20.0, 30.0, 40.0, 50.0, 60.0, 70.0, 80.0, 90.0, 100.0];
|
||||
|
||||
/// Width profile presets: (id for `stroke.set`, label).
|
||||
pub const PROFILES: [(&str, &str); 4] = [("uniform", "Uniform"), ("lens", "Lens"), ("taperStart", "Taper Start"), ("taperEnd", "Taper End")];
|
||||
|
||||
/// Which preset a stroke's profile is (None profile = uniform).
|
||||
pub fn profile_id(p: Option<&WidthProfile>) -> &'static str {
|
||||
let Some(p) = p else { return "uniform" };
|
||||
for (id, prof) in [("lens", WidthProfile::lens()), ("taperStart", WidthProfile::taper_start()), ("taperEnd", WidthProfile::taper_end())] {
|
||||
if prof.points == p.points {
|
||||
return id;
|
||||
}
|
||||
}
|
||||
"custom"
|
||||
}
|
||||
|
||||
/// The silhouette drawn for profile `id` (None draws the plain uniform bar).
|
||||
fn profile_of(id: &str) -> Option<WidthProfile> {
|
||||
match id {
|
||||
"lens" => Some(WidthProfile::lens()),
|
||||
"taperStart" => Some(WidthProfile::taper_start()),
|
||||
"taperEnd" => Some(WidthProfile::taper_end()),
|
||||
_ => None,
|
||||
}
|
||||
WidthProfile::preset(id).filter(|_| id != "uniform")
|
||||
}
|
||||
|
||||
/// Dash pattern → the panel's six dash/gap fields (None = empty field).
|
||||
|
|
@ -237,7 +219,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
|
|||
});
|
||||
widgets::divider(ui);
|
||||
// Profile.
|
||||
let pid = profile_id(st.as_ref().and_then(|s| s.profile.as_ref()));
|
||||
let pid = WidthProfile::id_of(st.as_ref().and_then(|s| s.profile.as_ref()));
|
||||
ui.horizontal(|ui| {
|
||||
row_label(ui, "Profile:");
|
||||
if let Some(id) = profile_dropdown(ui, pid) {
|
||||
|
|
@ -355,7 +337,7 @@ fn profile_dropdown(ui: &mut Ui, cur: &str) -> Option<&'static str> {
|
|||
let resp = resp.on_hover_text("Variable Width Profile");
|
||||
let mut out = None;
|
||||
egui::Popup::menu(&resp).show(|ui| {
|
||||
for (id, label) in PROFILES {
|
||||
for ProfilePreset { id, label, .. } in WidthProfile::PRESETS {
|
||||
let (row, rr) = ui.allocate_exact_size(vec2(170.0, 26.0), Sense::click());
|
||||
if id == cur {
|
||||
ui.painter().rect_filled(row, 0.0, t.row_selected);
|
||||
|
|
@ -404,11 +386,38 @@ mod tests {
|
|||
}
|
||||
|
||||
#[test]
|
||||
fn profile_ids() {
|
||||
assert_eq!(profile_id(None), "uniform");
|
||||
assert_eq!(profile_id(Some(&WidthProfile::lens())), "lens");
|
||||
assert_eq!(profile_id(Some(&WidthProfile::taper_end())), "taperEnd");
|
||||
assert_eq!(profile_id(Some(&WidthProfile { points: vec![(0.0, 0.3, 0.3)] })), "custom");
|
||||
fn profile_silhouettes_and_arrow_labels() {
|
||||
assert!(profile_of("uniform").is_none());
|
||||
assert_eq!(profile_of("lens"), Some(WidthProfile::lens()));
|
||||
assert!(profile_of("custom").is_none());
|
||||
assert_eq!(arrow_label(Some(Arrowhead::CircleOpen)), "Circle (open)");
|
||||
}
|
||||
|
||||
/// Run the panel and its ≡ menu for one headless frame.
|
||||
fn frame(app: &mut VectorcraftApp) {
|
||||
let ctx = egui::Context::default();
|
||||
let mut out = ctx.run_ui(egui::RawInput::default(), |ui| {
|
||||
show(app, ui);
|
||||
menu(app, ui);
|
||||
});
|
||||
out.textures_delta.clear();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn panel_draws_for_lines_with_and_without_heads() {
|
||||
use vectorcraft_engine::Session;
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
let r = |app: &mut VectorcraftApp, id: &str, p: Value| app.session.execute(id, &p).unwrap();
|
||||
r(&mut app, "file.new", json!({"width": 100, "height": 100}));
|
||||
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"})] {
|
||||
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!(WidthProfile::id_of(st.profile.as_ref()), "lens");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -56,7 +56,7 @@ fn library_submenus_are_disabled_placeholders() {
|
|||
|
||||
#[test]
|
||||
fn stroke_profiles_have_descriptive_names() {
|
||||
let labels: Vec<&str> = crate::panels::stroke::PROFILES.iter().map(|p| p.1).collect();
|
||||
let labels: Vec<&str> = vectorcraft_doc::WidthProfile::PRESETS.iter().map(|p| p.label).collect();
|
||||
assert_eq!(labels, ["Uniform", "Lens", "Taper Start", "Taper End"]);
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue