Merge pull request #47 from storytold/pr/m3-stroke-depth
M3: width profile library, thirty more arrowheads, whole-stroke bounds and hit-testing (M3.61–M3.63)
This commit is contained in:
commit
2b07aa2ac9
|
|
@ -48,6 +48,7 @@ impl Dash {
|
|||
}
|
||||
}
|
||||
|
||||
/// An arrowhead shape. Saved by variant name: new shapes are appended, never renamed or reordered.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub enum Arrowhead {
|
||||
Triangle,
|
||||
|
|
@ -60,21 +61,130 @@ pub enum Arrowhead {
|
|||
Diamond,
|
||||
Arrow,
|
||||
ArrowOpen,
|
||||
Barbed,
|
||||
/// One barb, on the left of the direction the head points.
|
||||
HalfArrowLeft,
|
||||
/// One barb, on the right of the direction the head points.
|
||||
HalfArrowRight,
|
||||
Concave,
|
||||
DoubleBar,
|
||||
Feather,
|
||||
DotOnBar,
|
||||
Chevron,
|
||||
DoubleArrow,
|
||||
Target,
|
||||
Star,
|
||||
Cross,
|
||||
Plus,
|
||||
Hexagon,
|
||||
HexagonOpen,
|
||||
Tag,
|
||||
TagOpen,
|
||||
HalfCircle,
|
||||
Drop,
|
||||
Slash,
|
||||
DoubleSlash,
|
||||
DiamondOpen,
|
||||
TriangleReverse,
|
||||
Swallowtail,
|
||||
Bracket,
|
||||
Fork,
|
||||
Leaf,
|
||||
Kite,
|
||||
TriangleBar,
|
||||
Oval,
|
||||
}
|
||||
|
||||
impl Arrowhead {
|
||||
pub const ALL: [Arrowhead; 10] = [
|
||||
/// Every arrowhead, in menu order (arrows, then outlined shapes, then bars and marks).
|
||||
pub const ALL: [Arrowhead; 40] = [
|
||||
Arrowhead::Arrow,
|
||||
Arrowhead::ArrowOpen,
|
||||
Arrowhead::Barbed,
|
||||
Arrowhead::Concave,
|
||||
Arrowhead::DoubleArrow,
|
||||
Arrowhead::HalfArrowLeft,
|
||||
Arrowhead::HalfArrowRight,
|
||||
Arrowhead::Chevron,
|
||||
Arrowhead::Feather,
|
||||
Arrowhead::Swallowtail,
|
||||
Arrowhead::Triangle,
|
||||
Arrowhead::TriangleOpen,
|
||||
Arrowhead::TriangleReverse,
|
||||
Arrowhead::TriangleBar,
|
||||
Arrowhead::Kite,
|
||||
Arrowhead::Leaf,
|
||||
Arrowhead::Drop,
|
||||
Arrowhead::Circle,
|
||||
Arrowhead::CircleOpen,
|
||||
Arrowhead::HalfCircle,
|
||||
Arrowhead::Oval,
|
||||
Arrowhead::Target,
|
||||
Arrowhead::Square,
|
||||
Arrowhead::SquareOpen,
|
||||
Arrowhead::Tag,
|
||||
Arrowhead::TagOpen,
|
||||
Arrowhead::Diamond,
|
||||
Arrowhead::DiamondOpen,
|
||||
Arrowhead::Hexagon,
|
||||
Arrowhead::HexagonOpen,
|
||||
Arrowhead::Star,
|
||||
Arrowhead::Cross,
|
||||
Arrowhead::Plus,
|
||||
Arrowhead::Bar,
|
||||
Arrowhead::DoubleBar,
|
||||
Arrowhead::DotOnBar,
|
||||
Arrowhead::Slash,
|
||||
Arrowhead::DoubleSlash,
|
||||
Arrowhead::Bracket,
|
||||
Arrowhead::Fork,
|
||||
];
|
||||
|
||||
/// The name the arrowhead menus show.
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
Arrowhead::Triangle => "Triangle",
|
||||
Arrowhead::TriangleOpen => "Triangle (open)",
|
||||
Arrowhead::Circle => "Circle",
|
||||
Arrowhead::CircleOpen => "Circle (open)",
|
||||
Arrowhead::Square => "Square",
|
||||
Arrowhead::SquareOpen => "Square (open)",
|
||||
Arrowhead::Bar => "Bar",
|
||||
Arrowhead::Diamond => "Diamond",
|
||||
Arrowhead::Arrow => "Arrow",
|
||||
Arrowhead::ArrowOpen => "Arrow (open)",
|
||||
Arrowhead::Barbed => "Barbed",
|
||||
Arrowhead::HalfArrowLeft => "Half Arrow (left)",
|
||||
Arrowhead::HalfArrowRight => "Half Arrow (right)",
|
||||
Arrowhead::Concave => "Concave",
|
||||
Arrowhead::DoubleBar => "Double Bar",
|
||||
Arrowhead::Feather => "Feather",
|
||||
Arrowhead::DotOnBar => "Dot on Bar",
|
||||
Arrowhead::Chevron => "Chevron",
|
||||
Arrowhead::DoubleArrow => "Double Arrow",
|
||||
Arrowhead::Target => "Target",
|
||||
Arrowhead::Star => "Star",
|
||||
Arrowhead::Cross => "Cross",
|
||||
Arrowhead::Plus => "Plus",
|
||||
Arrowhead::Hexagon => "Hexagon",
|
||||
Arrowhead::HexagonOpen => "Hexagon (open)",
|
||||
Arrowhead::Tag => "Tag",
|
||||
Arrowhead::TagOpen => "Tag (open)",
|
||||
Arrowhead::HalfCircle => "Half Circle",
|
||||
Arrowhead::Drop => "Drop",
|
||||
Arrowhead::Slash => "Slash",
|
||||
Arrowhead::DoubleSlash => "Double Slash",
|
||||
Arrowhead::DiamondOpen => "Diamond (open)",
|
||||
Arrowhead::TriangleReverse => "Reverse Triangle",
|
||||
Arrowhead::Swallowtail => "Swallowtail",
|
||||
Arrowhead::Bracket => "Bracket",
|
||||
Arrowhead::Fork => "Fork",
|
||||
Arrowhead::Leaf => "Leaf",
|
||||
Arrowhead::Kite => "Kite",
|
||||
Arrowhead::TriangleBar => "Triangle to Bar",
|
||||
Arrowhead::Oval => "Oval",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Where an arrowhead sits relative to the end of its path. In both modes the stroke stops under
|
||||
|
|
@ -106,9 +216,12 @@ pub struct ProfilePreset {
|
|||
}
|
||||
|
||||
impl WidthProfile {
|
||||
/// Width factor at `t` (average of both sides), linear between points.
|
||||
/// (left, right) width factors at `t`, linear between points.
|
||||
pub fn at(&self, t: f64) -> (f64, f64) {
|
||||
let p = &self.points;
|
||||
Self::at_points(&self.points, t)
|
||||
}
|
||||
/// [`Self::at`] of a profile's points (a preset's or a saved profile's).
|
||||
pub fn at_points(p: &[(f64, f64, f64)], t: f64) -> (f64, f64) {
|
||||
if p.is_empty() {
|
||||
return (1.0, 1.0);
|
||||
}
|
||||
|
|
@ -125,11 +238,17 @@ impl WidthProfile {
|
|||
(l.1, l.2)
|
||||
}
|
||||
/// The built-in profiles, in menu order. "uniform" is the plain stroke (no profile).
|
||||
pub const PRESETS: [ProfilePreset; 4] = [
|
||||
pub const PRESETS: [ProfilePreset; 7] = [
|
||||
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)] },
|
||||
// Full width at both ends, pinched to a quarter in the middle.
|
||||
ProfilePreset { id: "pinch", label: "Pinch", points: &[(0.0, 1.0, 1.0), (0.5, 0.25, 0.25), (1.0, 1.0, 1.0)] },
|
||||
// A round head a fifth of the way along, then a long taper to the end.
|
||||
ProfilePreset { id: "teardrop", label: "Teardrop", points: &[(0.0, 0.0, 0.0), (0.2, 1.0, 1.0), (1.0, 0.0, 0.0)] },
|
||||
// Two swells between narrow necks.
|
||||
ProfilePreset { id: "wave", label: "Wave", points: &[(0.0, 0.3, 0.3), (0.25, 1.0, 1.0), (0.5, 0.3, 0.3), (0.75, 1.0, 1.0), (1.0, 0.3, 0.3)] },
|
||||
];
|
||||
/// The built-in profile with this id.
|
||||
pub fn preset(id: &str) -> Option<Self> {
|
||||
|
|
@ -155,6 +274,14 @@ impl WidthProfile {
|
|||
}
|
||||
}
|
||||
|
||||
/// A width profile saved to the Profile list under a name (Add to Profiles), kept with the
|
||||
/// preferences.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct SavedProfile {
|
||||
pub name: String,
|
||||
pub profile: WidthProfile,
|
||||
}
|
||||
|
||||
/// A live effect in an appearance stack. Parameters are interpreted by `vectorcraft-effects`.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Effect {
|
||||
|
|
@ -239,6 +366,10 @@ pub struct StrokeLayer {
|
|||
pub overprint: bool,
|
||||
}
|
||||
|
||||
/// How far any arrowhead reaches from its tip, in units of its weight (the open arrow's arms are
|
||||
/// cut square a little behind its 4 × 4 box).
|
||||
pub(crate) const HEAD_REACH: f64 = 4.6;
|
||||
|
||||
fn ten() -> f64 {
|
||||
10.0
|
||||
}
|
||||
|
|
@ -276,16 +407,50 @@ impl StrokeLayer {
|
|||
}
|
||||
}
|
||||
/// Weight of the start (`end == false`) or end arrowhead: stroke weight × its scale, at least
|
||||
/// a quarter point. A head of weight `hw` is `4·hw` long and wide.
|
||||
/// a quarter point. A head of weight `hw` fits a box about `4·hw` long and wide.
|
||||
pub fn arrow_weight(&self, end: bool) -> f64 {
|
||||
let pct = if end { self.arrow_scale.1 } else { self.arrow_scale.0 };
|
||||
(self.width * pct / 100.0).max(0.25)
|
||||
}
|
||||
/// How far the arrowheads can reach from the path's end points (0 without heads): the
|
||||
/// head's diagonal, or with [`ArrowAlign::Extend`] its length plus the cap past the end.
|
||||
/// How far the start (`end == false`) or end arrowhead can reach from its end point (0
|
||||
/// without one): the head's diagonal, or with [`ArrowAlign::Extend`] its length, plus the cap.
|
||||
pub fn head_reach(&self, end: bool) -> f64 {
|
||||
let head = if end { self.end_arrow } else { self.start_arrow };
|
||||
head.map_or(0.0, |_| HEAD_REACH * self.arrow_weight(end) + self.width / 2.0)
|
||||
}
|
||||
/// How far the arrowheads can reach from the path's end points (0 without heads).
|
||||
pub fn arrow_reach(&self) -> f64 {
|
||||
let reach = |head: Option<Arrowhead>, end: bool| head.map_or(0.0, |_| 4.5 * self.arrow_weight(end) + self.width / 2.0);
|
||||
reach(self.start_arrow, false).max(reach(self.end_arrow, true))
|
||||
self.head_reach(false).max(self.head_reach(true))
|
||||
}
|
||||
/// The widest the width profile makes the stroke, as a factor of its weight (1 without one).
|
||||
pub fn profile_max(&self) -> f64 {
|
||||
self.profile.as_ref().and_then(|p| p.points.iter().map(|&(_, l, r)| l.max(r)).reduce(f64::max)).unwrap_or(1.0).max(0.0)
|
||||
}
|
||||
/// The stroke's body across a path that is `closed` or open: how far it reaches from the path
|
||||
/// at full width (half the weight centred; the whole weight on one side when aligned inside or
|
||||
/// outside a closed path, which open paths ignore), and for aligned strokes which side it
|
||||
/// paints (`Some(true)`: inside).
|
||||
pub fn body(&self, closed: bool) -> (f64, Option<bool>) {
|
||||
match self.align {
|
||||
StrokeAlign::Outside if closed => (self.width, Some(false)),
|
||||
StrokeAlign::Inside if closed => (self.width, Some(true)),
|
||||
_ => (self.width / 2.0, None),
|
||||
}
|
||||
}
|
||||
/// How far the body paints outside a path that is `closed` or open, at the profile's widest.
|
||||
pub fn side_reach(&self, closed: bool) -> f64 {
|
||||
match self.body(closed) {
|
||||
(_, Some(true)) => 0.0,
|
||||
(half, _) => half * self.profile_max(),
|
||||
}
|
||||
}
|
||||
/// The farthest this stroke can paint from any path: its body, miter spikes up to the miter
|
||||
/// limit, projecting caps' corners and the arrowheads ([`Appearance::outset`]).
|
||||
pub fn reach(&self) -> f64 {
|
||||
let side = self.side_reach(true).max(self.side_reach(false));
|
||||
let miter = if self.join == LineJoin::Miter { self.miter_limit.max(1.0) } else { 1.0 };
|
||||
let cap = if self.cap == LineCap::Square { std::f64::consts::SQRT_2 } else { 1.0 };
|
||||
(side * miter.max(cap)).max(self.arrow_reach())
|
||||
}
|
||||
/// The box an unplaced gradient on this stroke fits to (see [`stroke_paint_bounds`]).
|
||||
pub fn paint_bounds(&self, geometric: vectorcraft_geom::Rect) -> vectorcraft_geom::Rect {
|
||||
|
|
@ -529,22 +694,17 @@ impl Appearance {
|
|||
Some(i) => self.items.get_mut(i).map(AppearanceItem::effects_mut),
|
||||
}
|
||||
}
|
||||
/// Largest distance the painted area extends beyond the geometry (for visual bounds).
|
||||
/// The visible strokes that paint something.
|
||||
pub fn painted_strokes(&self) -> impl Iterator<Item = &StrokeLayer> {
|
||||
self.items.iter().filter_map(|i| match i {
|
||||
AppearanceItem::Stroke(s) if s.visible && !s.paint.is_none() => Some(s),
|
||||
_ => None,
|
||||
})
|
||||
}
|
||||
/// Largest distance the painted area can extend beyond any geometry (see
|
||||
/// [`StrokeLayer::reach`]); paths measure their own with [`Self::stroked_bounds`].
|
||||
pub fn outset(&self) -> f64 {
|
||||
self.items
|
||||
.iter()
|
||||
.filter_map(|i| match i {
|
||||
AppearanceItem::Stroke(s) if s.visible && !s.paint.is_none() => Some(
|
||||
match s.align {
|
||||
StrokeAlign::Center => s.width / 2.0 * if s.join == LineJoin::Miter { s.miter_limit.min(4.0) } else { 1.0 },
|
||||
StrokeAlign::Outside => s.width,
|
||||
StrokeAlign::Inside => 0.0,
|
||||
}
|
||||
.max(s.arrow_reach()),
|
||||
),
|
||||
_ => None,
|
||||
})
|
||||
.fold(0.0, f64::max)
|
||||
self.painted_strokes().map(StrokeLayer::reach).fold(0.0, f64::max)
|
||||
}
|
||||
/// Scale stroke weights (Scale Strokes & Effects).
|
||||
pub fn scale_strokes(&mut self, s: f64) {
|
||||
|
|
@ -863,6 +1023,60 @@ 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 arrowhead_names_are_stable_and_every_one_is_listed_once() {
|
||||
// Saved files store these names: they may only ever be appended to.
|
||||
const SAVED: [&str; 40] = [
|
||||
"Triangle",
|
||||
"TriangleOpen",
|
||||
"Circle",
|
||||
"CircleOpen",
|
||||
"Square",
|
||||
"SquareOpen",
|
||||
"Bar",
|
||||
"Diamond",
|
||||
"Arrow",
|
||||
"ArrowOpen",
|
||||
"Barbed",
|
||||
"HalfArrowLeft",
|
||||
"HalfArrowRight",
|
||||
"Concave",
|
||||
"DoubleBar",
|
||||
"Feather",
|
||||
"DotOnBar",
|
||||
"Chevron",
|
||||
"DoubleArrow",
|
||||
"Target",
|
||||
"Star",
|
||||
"Cross",
|
||||
"Plus",
|
||||
"Hexagon",
|
||||
"HexagonOpen",
|
||||
"Tag",
|
||||
"TagOpen",
|
||||
"HalfCircle",
|
||||
"Drop",
|
||||
"Slash",
|
||||
"DoubleSlash",
|
||||
"DiamondOpen",
|
||||
"TriangleReverse",
|
||||
"Swallowtail",
|
||||
"Bracket",
|
||||
"Fork",
|
||||
"Leaf",
|
||||
"Kite",
|
||||
"TriangleBar",
|
||||
"Oval",
|
||||
];
|
||||
for name in SAVED {
|
||||
let a: Arrowhead = serde_json::from_value(serde_json::json!(name)).unwrap();
|
||||
assert_eq!(serde_json::to_value(a).unwrap(), name);
|
||||
assert_eq!(Arrowhead::ALL.iter().filter(|x| **x == a).count(), 1, "{name} in the menu once");
|
||||
}
|
||||
let labels: std::collections::HashSet<&str> = Arrowhead::ALL.iter().map(|a| a.label()).collect();
|
||||
assert_eq!(labels.len(), Arrowhead::ALL.len(), "labels are distinct");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arrow_align_defaults_to_extend_and_round_trips() {
|
||||
let mut st = StrokeLayer::new(Paint::solid(Color::BLACK), 2.0);
|
||||
|
|
@ -886,8 +1100,9 @@ mod tests {
|
|||
st.arrow_scale = (100.0, 200.0);
|
||||
assert_eq!(st.arrow_weight(false), 4.0);
|
||||
assert_eq!(st.arrow_weight(true), 8.0);
|
||||
assert_eq!((st.head_reach(false), st.head_reach(true)), (0.0, HEAD_REACH * 8.0 + 2.0));
|
||||
// A 32 pt head whose tip sits up to its length (plus the cap) past the end point.
|
||||
assert_eq!(a.outset(), 4.5 * 8.0 + 2.0);
|
||||
assert_eq!(a.outset(), HEAD_REACH * 8.0 + 2.0);
|
||||
a.stroke_mut().unwrap().width = 0.01;
|
||||
assert_eq!(a.stroke().unwrap().arrow_weight(true), 0.25, "heads keep a minimum size");
|
||||
}
|
||||
|
|
@ -899,8 +1114,28 @@ mod tests {
|
|||
assert_eq!(a.outset(), 5.0);
|
||||
a.stroke_mut().unwrap().align = StrokeAlign::Outside;
|
||||
assert_eq!(a.outset(), 10.0);
|
||||
// Open paths stroke inside-aligned strokes centred.
|
||||
a.stroke_mut().unwrap().align = StrokeAlign::Inside;
|
||||
assert_eq!(a.outset(), 0.0);
|
||||
assert_eq!(a.outset(), 5.0);
|
||||
assert_eq!((a.stroke().unwrap().side_reach(true), a.stroke().unwrap().body(true)), (0.0, (10.0, Some(true))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn outset_covers_miter_spikes_projecting_caps_and_profile_maxima() {
|
||||
let mut a = Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 2.0);
|
||||
// Miter joins can spike out to the limit × half the weight.
|
||||
assert_eq!(a.outset(), 10.0);
|
||||
let st = a.stroke_mut().unwrap();
|
||||
st.join = LineJoin::Bevel;
|
||||
st.cap = LineCap::Square;
|
||||
assert!((a.outset() - std::f64::consts::SQRT_2).abs() < 1e-12, "a projecting cap's corner");
|
||||
let st = a.stroke_mut().unwrap();
|
||||
st.cap = LineCap::Round;
|
||||
st.profile = Some(WidthProfile { points: vec![(0.0, 0.5, 2.5), (1.0, 1.0, 1.0)] });
|
||||
assert_eq!(st.profile_max(), 2.5);
|
||||
assert_eq!(a.outset(), 2.5);
|
||||
a.stroke_mut().unwrap().paint = Paint::None;
|
||||
assert_eq!(a.outset(), 0.0, "an unpainted stroke");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
|
|
@ -99,7 +99,7 @@ fn hit_children(parent: &Node, p: Point, opt: HitOptions, chain: &mut Vec<NodeId
|
|||
if !c.visible || c.locked {
|
||||
continue;
|
||||
}
|
||||
if let Some(b) = c.visual_bounds()
|
||||
if let Some(b) = c.reach_bounds()
|
||||
&& !b.inflate(opt.tol, opt.tol).contains(p)
|
||||
{
|
||||
continue;
|
||||
|
|
@ -117,13 +117,22 @@ fn hit_children(parent: &Node, p: Point, opt: HitOptions, chain: &mut Vec<NodeId
|
|||
None
|
||||
}
|
||||
|
||||
/// A click on the strokes of a path or compound path `n` along `bp`: what any visible stroke
|
||||
/// paints there (arrowheads, alignment, width profile, projecting caps, miter spikes), else its
|
||||
/// outline within the tolerance. Outline mode hits the outline only.
|
||||
fn hit_stroke(n: &Node, bp: &vectorcraft_geom::BezPath, rule: vectorcraft_geom::FillRule, p: Point, opt: HitOptions) -> Option<HitKind> {
|
||||
if !opt.outline && n.appearance.stroke_hit(bp, rule, p, opt.tol) {
|
||||
return Some(HitKind::Stroke);
|
||||
}
|
||||
stroke_contains(bp, 0.0, opt.tol, p).then_some(HitKind::Outline)
|
||||
}
|
||||
|
||||
fn hit_leaf(n: &Node, p: Point, opt: HitOptions) -> Option<HitKind> {
|
||||
match &n.kind {
|
||||
NodeKind::Path { path, rule, .. } => {
|
||||
let bp = path.to_bezpath();
|
||||
let sw = n.appearance.stroke_width();
|
||||
if stroke_contains(&bp, if opt.outline { 0.0 } else { sw }, opt.tol, p) {
|
||||
return Some(if opt.outline || sw == 0.0 { HitKind::Outline } else { HitKind::Stroke });
|
||||
if let Some(k) = hit_stroke(n, &bp, *rule, p, opt) {
|
||||
return Some(k);
|
||||
}
|
||||
let filled = !n.appearance.fill_paint().is_none();
|
||||
if !opt.outline && !opt.path_only && filled && fill_contains(&bp, *rule, p) {
|
||||
|
|
@ -131,15 +140,10 @@ fn hit_leaf(n: &Node, p: Point, opt: HitOptions) -> Option<HitKind> {
|
|||
}
|
||||
None
|
||||
}
|
||||
NodeKind::Compound { children, rule } => {
|
||||
let mut bp = vectorcraft_geom::BezPath::new();
|
||||
for c in children {
|
||||
if let Some(pd) = c.path_data() {
|
||||
bp.extend(pd.to_bezpath());
|
||||
}
|
||||
}
|
||||
if stroke_contains(&bp, n.appearance.stroke_width(), opt.tol, p) {
|
||||
return Some(HitKind::Stroke);
|
||||
NodeKind::Compound { rule, .. } => {
|
||||
let bp = n.stroke_path()?;
|
||||
if let Some(k) = hit_stroke(n, &bp, *rule, p, opt) {
|
||||
return Some(k);
|
||||
}
|
||||
(!opt.outline && fill_contains(&bp, *rule, p)).then_some(HitKind::Fill)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ pub mod node;
|
|||
pub mod overprint;
|
||||
pub mod pattern;
|
||||
mod pixels;
|
||||
mod reach;
|
||||
pub mod selection;
|
||||
pub mod swatches;
|
||||
pub mod text;
|
||||
|
|
@ -34,8 +35,8 @@ pub(crate) mod skip {
|
|||
}
|
||||
|
||||
pub use appearance::{
|
||||
Appearance, AppearanceItem, ArrowAlign, Arrowhead, Dash, Effect, FillLayer, LineCap, LineJoin, ProfilePreset, StrokeAlign, StrokeLayer,
|
||||
WidthProfile,
|
||||
Appearance, AppearanceItem, ArrowAlign, Arrowhead, Dash, Effect, FillLayer, LineCap, LineJoin, ProfilePreset, SavedProfile, StrokeAlign,
|
||||
StrokeLayer, WidthProfile,
|
||||
};
|
||||
pub use graph::{GraphKind, GraphSpec};
|
||||
pub use hit::{Hit, HitKind};
|
||||
|
|
|
|||
|
|
@ -426,6 +426,30 @@ impl Node {
|
|||
_ => None,
|
||||
}
|
||||
}
|
||||
/// The path a path's or compound path's own strokes follow (a compound's members' together).
|
||||
pub fn stroke_path(&self) -> Option<vectorcraft_geom::BezPath> {
|
||||
match &self.kind {
|
||||
NodeKind::Path { path, .. } => Some(path.to_bezpath()),
|
||||
NodeKind::Compound { children, .. } => {
|
||||
let mut bp = vectorcraft_geom::BezPath::new();
|
||||
children.iter().filter_map(|c| c.path_data()).for_each(|pd| bp.extend(pd.to_bezpath()));
|
||||
Some(bp)
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
/// A quick box round everything the object can paint, for culling clicks: paths and compound
|
||||
/// paths grow their geometric bounds by the farthest their strokes can reach from any path
|
||||
/// ([`Appearance::outset`]) instead of measuring along their shape.
|
||||
pub fn reach_bounds(&self) -> Option<Rect> {
|
||||
match &self.kind {
|
||||
NodeKind::Path { .. } | NodeKind::Compound { .. } => {
|
||||
let o = self.appearance.outset();
|
||||
self.geometric_bounds().map(|b| b.inflate(o, o))
|
||||
}
|
||||
_ => self.visual_bounds(),
|
||||
}
|
||||
}
|
||||
/// Geometric bounds (no stroke), recursively. Clip groups are bounded by their clip path.
|
||||
pub fn geometric_bounds(&self) -> Option<Rect> {
|
||||
match &self.kind {
|
||||
|
|
@ -449,7 +473,8 @@ impl Node {
|
|||
NodeKind::Repeat(r) => r.bounds(),
|
||||
}
|
||||
}
|
||||
/// Visual bounds (includes stroke outset).
|
||||
/// Visual bounds: what the object paints, its strokes included (paths and compound paths
|
||||
/// measure theirs along their own shape, see [`Appearance::stroked_bounds`]).
|
||||
pub fn visual_bounds(&self) -> Option<Rect> {
|
||||
match &self.kind {
|
||||
NodeKind::Group { children, clip: true } | NodeKind::Layer { children, clip: true, .. } => {
|
||||
|
|
@ -469,6 +494,10 @@ impl Node {
|
|||
let o = crate::live::max_outset(content);
|
||||
self.geometric_bounds().map(|b| b.inflate(o, o))
|
||||
}
|
||||
NodeKind::Path { .. } | NodeKind::Compound { .. } => {
|
||||
let b = self.geometric_bounds()?;
|
||||
Some(self.appearance.stroked_bounds(b, || self.stroke_path().unwrap_or_default()))
|
||||
}
|
||||
_ => {
|
||||
let o = self.appearance.outset();
|
||||
self.geometric_bounds().map(|b| b.inflate(o, o))
|
||||
|
|
|
|||
|
|
@ -0,0 +1,431 @@
|
|||
//! Where a path's strokes paint, from the path's geometry without building their outlines: the
|
||||
//! visual bounds and the clicks of a stroke take in its alignment, its width profile, miter spikes
|
||||
//! up to the miter limit, projecting caps and arrowheads.
|
||||
|
||||
use vectorcraft_geom::bez::{CORNER_COS, kink, segments, subpaths, tangent, unit};
|
||||
use vectorcraft_geom::hit::fill_contains;
|
||||
use vectorcraft_geom::kurbo::{ParamCurveArclen, ParamCurveNearest};
|
||||
use vectorcraft_geom::{BezPath, FillRule, ParamCurve, PathSeg, Point, Rect, Shape, Vec2};
|
||||
|
||||
use crate::appearance::HEAD_REACH;
|
||||
use crate::{Appearance, ArrowAlign, Dash, LineCap, LineJoin, StrokeAlign, StrokeLayer, WidthProfile};
|
||||
|
||||
/// Arc-length accuracy for placing a point along a profile (document points).
|
||||
const ARCLEN_ACCURACY: f64 = 1e-3;
|
||||
|
||||
/// The segments of a subpath that have length (zero-length ones, all of whose points coincide,
|
||||
/// have no direction).
|
||||
fn drawn(els: &[vectorcraft_geom::PathEl]) -> Vec<PathSeg> {
|
||||
let moves = |s: &PathSeg| match s {
|
||||
PathSeg::Line(l) => l.p1 != l.p0,
|
||||
PathSeg::Quad(q) => q.p1 != q.p0 || q.p2 != q.p0,
|
||||
PathSeg::Cubic(c) => c.p1 != c.p0 || c.p2 != c.p0 || c.p3 != c.p0,
|
||||
};
|
||||
segments(els).into_iter().filter(moves).collect()
|
||||
}
|
||||
|
||||
/// The open subpath ends of `segs`: (point, unit direction out of the path) at its start and end.
|
||||
fn ends(segs: &[PathSeg]) -> Option<[(Point, Vec2); 2]> {
|
||||
let (first, last) = (segs.first()?, segs.last()?);
|
||||
Some([(first.start(), -tangent(first, 0.0)), (last.end(), tangent(last, 1.0))])
|
||||
}
|
||||
|
||||
/// The joints between consecutive segments (and, `closed`, where the subpath closes): the point
|
||||
/// and the unit tangents in and out.
|
||||
fn joints(segs: &[PathSeg], closed: bool) -> impl Iterator<Item = (Point, Vec2, Vec2)> + '_ {
|
||||
let n = segs.len();
|
||||
let pairs = if closed && n > 1 { n } else { n.saturating_sub(1) };
|
||||
(0..pairs).map(move |i| {
|
||||
let (a, b) = (&segs[i], &segs[(i + 1) % n]);
|
||||
(a.end(), tangent(a, 1.0), tangent(b, 0.0))
|
||||
})
|
||||
}
|
||||
|
||||
/// The miter spike at a corner with tangents `t_in`, `t_out` of a stroke reaching `side` from its
|
||||
/// path: its tip and the corner's outer offset points, or `None` when the path doesn't turn there
|
||||
/// or the miter limit bevels it.
|
||||
fn miter(at: Point, t_in: Vec2, t_out: Vec2, side: f64, limit: f64) -> Option<[Point; 3]> {
|
||||
let cos = t_in.dot(t_out);
|
||||
// cos(half the turn) is sin(half the angle between the segments): the miter is side / it.
|
||||
let half = ((1.0 + cos) / 2.0).max(0.0).sqrt();
|
||||
if cos >= CORNER_COS || half * limit < 1.0 {
|
||||
return None;
|
||||
}
|
||||
let out = unit(t_in - t_out);
|
||||
// The offset of each segment on the outer side of the turn.
|
||||
let outer = |t: Vec2| {
|
||||
let n = Vec2::new(-t.y, t.x);
|
||||
if n.dot(out) >= 0.0 { n } else { -n }
|
||||
};
|
||||
Some([at + outer(t_in) * side, at + out * (side / half), at + outer(t_out) * side])
|
||||
}
|
||||
|
||||
/// The heads' ends of `bp`: the start of its first subpath and the end of its last, when open (as
|
||||
/// the stroke geometry places them), each with `true` for the end head.
|
||||
fn head_ends(st: &StrokeLayer, bp: &BezPath) -> Vec<(Point, Vec2, bool)> {
|
||||
if st.start_arrow.is_none() && st.end_arrow.is_none() {
|
||||
return vec![];
|
||||
}
|
||||
let subs = subpaths(bp);
|
||||
let open_ends = |sub: Option<&(std::ops::Range<usize>, bool)>| sub.filter(|s| !s.1).and_then(|s| ends(&drawn(&bp.elements()[s.0.clone()])));
|
||||
let mut out = vec![];
|
||||
if st.start_arrow.is_some()
|
||||
&& let Some([(at, dir), _]) = open_ends(subs.first())
|
||||
{
|
||||
out.push((at, dir, false));
|
||||
}
|
||||
if st.end_arrow.is_some()
|
||||
&& let Some([_, (at, dir)]) = open_ends(subs.last())
|
||||
{
|
||||
out.push((at, dir, true));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// (left, right) width factors where `p`'s nearest point on the subpath `segs` sits (segment `i`
|
||||
/// at `t`): the profile at that fraction of the subpath's length, or wider where an end of the
|
||||
/// segment is a corner. The stroke draws the sides there straight to the corner's join and inner
|
||||
/// meeting point, which can lie outside the exact width all along the segment.
|
||||
fn profile_at(profile: &WidthProfile, segs: &[PathSeg], closed: bool, i: usize, t: f64) -> (f64, f64) {
|
||||
let n = segs.len();
|
||||
let lens: Vec<f64> = segs.iter().map(|s| s.arclen(ARCLEN_ACCURACY)).collect();
|
||||
let total: f64 = lens.iter().sum::<f64>().max(1e-12);
|
||||
let before = lens[..i].iter().sum::<f64>();
|
||||
let mut w = profile.at((before + segs[i].subsegment(0.0..t).arclen(ARCLEN_ACCURACY)) / total);
|
||||
let corner = |a: usize, b: usize| (closed || a < b) && kink(&segs[a], &segs[b]);
|
||||
let starts = n > 1 && corner((i + n - 1) % n, i);
|
||||
let ends = n > 1 && corner(i, (i + 1) % n);
|
||||
// A closed subpath's last segment ends where it starts.
|
||||
let end = if i + 1 == n { 0.0 } else { before + lens[i] };
|
||||
for (is, at) in [(starts, before), (ends, end)] {
|
||||
if is {
|
||||
let (l, r) = profile.at(at / total);
|
||||
w = (w.0.max(l), w.1.max(r));
|
||||
}
|
||||
}
|
||||
w
|
||||
}
|
||||
|
||||
/// Is `p` inside the convex polygon `pts` (either winding), or within `tol` of it?
|
||||
fn in_convex(pts: &[Point], p: Point, tol: f64) -> bool {
|
||||
let n = pts.len();
|
||||
// Signed distances from each edge's line.
|
||||
let (lo, hi) = (0..n).fold((f64::INFINITY, f64::NEG_INFINITY), |(lo, hi), i| {
|
||||
let e = pts[(i + 1) % n] - pts[i];
|
||||
let d = e.cross(p - pts[i]) / e.hypot().max(1e-12);
|
||||
(lo.min(d), hi.max(d))
|
||||
});
|
||||
lo >= -tol || hi <= tol
|
||||
}
|
||||
|
||||
impl StrokeLayer {
|
||||
/// Does bounding this stroke need its path's shape: miter spikes, projecting caps, arrowheads,
|
||||
/// or an alignment that depends on whether the path is closed? Else it reaches evenly round it.
|
||||
fn needs_shape(&self) -> bool {
|
||||
self.join == LineJoin::Miter
|
||||
|| self.cap == LineCap::Square
|
||||
|| self.start_arrow.is_some()
|
||||
|| self.end_arrow.is_some()
|
||||
|| self.align != StrokeAlign::Center
|
||||
}
|
||||
|
||||
/// The box this stroke paints along `bp`, whose geometric bounds are `bounds`: the bounds grown
|
||||
/// by the body's reach, with the tips of the miter spikes the limit keeps, the corners of
|
||||
/// projecting caps and the reach of the arrowheads round their end points.
|
||||
pub fn bounds_on(&self, bp: &BezPath, bounds: Rect) -> Rect {
|
||||
let els = bp.elements();
|
||||
let mut out = bounds;
|
||||
let mut side_max: f64 = 0.0;
|
||||
// Dashes put a cap on every dash: projecting ones reach a corner's diagonal anywhere.
|
||||
let capped_dashes = self.cap == LineCap::Square && self.dash.as_ref().is_some_and(Dash::is_dashed);
|
||||
for (r, closed) in subpaths(bp) {
|
||||
let side = self.side_reach(closed);
|
||||
side_max = side_max.max(if capped_dashes { side * std::f64::consts::SQRT_2 } else { side });
|
||||
if side <= 0.0 {
|
||||
continue;
|
||||
}
|
||||
let segs = drawn(&els[r]);
|
||||
if self.join == LineJoin::Miter {
|
||||
for (at, t_in, t_out) in joints(&segs, closed) {
|
||||
if let Some([_, tip, _]) = miter(at, t_in, t_out, side, self.miter_limit) {
|
||||
out = out.union_pt(tip);
|
||||
}
|
||||
}
|
||||
}
|
||||
if self.cap == LineCap::Square && !closed {
|
||||
for (at, dir) in ends(&segs).into_iter().flatten() {
|
||||
let n = Vec2::new(-dir.y, dir.x) * side;
|
||||
out = out.union_pt(at + dir * side + n).union_pt(at + dir * side - n);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (at, _, end) in head_ends(self, bp) {
|
||||
let r = self.head_reach(end);
|
||||
out = out.union(Rect::from_center_size(at, (2.0 * r, 2.0 * r)));
|
||||
}
|
||||
out.union(bounds.inflate(side_max, side_max))
|
||||
}
|
||||
|
||||
/// Does this stroke along `bp` (filled with `rule`) paint within `tol` of `p`? Inside and
|
||||
/// outside strokes of closed paths count on their own side only (and within `tol` of the
|
||||
/// path); a width profile counts at its width where `p` is nearest.
|
||||
pub fn hits(&self, bp: &BezPath, rule: FillRule, p: Point, tol: f64) -> bool {
|
||||
let reach = self.reach() + tol;
|
||||
if self.width.is_nan() || self.width <= 0.0 || !bp.bounding_box().inflate(reach, reach).contains(p) {
|
||||
return false;
|
||||
}
|
||||
if head_ends(self, bp).into_iter().any(|(at, out, end)| self.head_contains(at, out, end, p, tol)) {
|
||||
return true;
|
||||
}
|
||||
let els = bp.elements();
|
||||
// Aligned strokes paint on one side of the path only.
|
||||
let on_side = |region: Option<bool>, d: f64| region.is_none_or(|inside| d <= tol || fill_contains(bp, rule, p) == inside);
|
||||
// Every dash gets the caps, as wide as the stroke where the dash ends: round ones reach as
|
||||
// far as the widest point, projecting ones a corner's diagonal.
|
||||
let dash_caps = match self.cap {
|
||||
LineCap::Butt => None,
|
||||
_ if !self.dash.as_ref().is_some_and(Dash::is_dashed) => None,
|
||||
LineCap::Round => Some(1.0),
|
||||
LineCap::Square => Some(std::f64::consts::SQRT_2),
|
||||
};
|
||||
for (r, closed) in subpaths(bp) {
|
||||
let segs = drawn(&els[r]);
|
||||
let (half, region) = self.body(closed);
|
||||
let full = half * self.profile_max() * dash_caps.unwrap_or(1.0);
|
||||
// The nearest point on this subpath.
|
||||
let near = segs.iter().enumerate().map(|(i, s)| (i, s.nearest(p, 1e-9))).min_by(|a, b| a.1.distance_sq.total_cmp(&b.1.distance_sq));
|
||||
if let Some((i, nr)) = near
|
||||
&& nr.distance_sq.sqrt() <= full + tol
|
||||
{
|
||||
let d = nr.distance_sq.sqrt();
|
||||
let factor = if dash_caps.is_some() {
|
||||
full / half.max(1e-12)
|
||||
} else {
|
||||
self.profile.as_ref().map_or(1.0, |pr| {
|
||||
let (l, rt) = profile_at(pr, &segs, closed, i, nr.t);
|
||||
// Nearest to a segment's end, `p` is beside a join or past a cap, which
|
||||
// spans the widths of both sides.
|
||||
let at_end = nr.t <= 1e-9 || nr.t >= 1.0 - 1e-9;
|
||||
// Left of the direction of travel (y down) reads the left width.
|
||||
let tn = tangent(&segs[i], nr.t);
|
||||
let left = (p - segs[i].eval(nr.t)).dot(Vec2::new(tn.y, -tn.x)) >= 0.0;
|
||||
if at_end {
|
||||
l.max(rt)
|
||||
} else if left {
|
||||
l
|
||||
} else {
|
||||
rt
|
||||
}
|
||||
})
|
||||
};
|
||||
if d <= half * factor.max(0.0) + tol && on_side(region, d) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
if full <= 0.0 {
|
||||
continue;
|
||||
}
|
||||
if self.join == LineJoin::Miter
|
||||
&& joints(&segs, closed).any(|(at, t_in, t_out)| {
|
||||
miter(at, t_in, t_out, full, self.miter_limit).is_some_and(|[a, tip, b]| in_convex(&[at, a, tip, b], p, tol))
|
||||
})
|
||||
&& on_side(region, f64::INFINITY)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
if self.cap == LineCap::Square
|
||||
&& !closed
|
||||
&& ends(&segs).into_iter().flatten().any(|(at, dir)| {
|
||||
let n = Vec2::new(-dir.y, dir.x) * full;
|
||||
in_convex(&[at + n, at + n + dir * full, at - n + dir * full, at - n], p, tol)
|
||||
})
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Is `p` within `tol` of where the start (`end == false`) or end arrowhead at `at` (the path
|
||||
/// leaving along `out`) can paint? Heads are at most about 4.6 weights long and 4 wide. With
|
||||
/// [`ArrowAlign::Extend`] a head points along `out` with its tip up to its length (or the cap)
|
||||
/// past the end; with [`ArrowAlign::Tip`] its tip is on the end and it lies back along the
|
||||
/// chord the path bends through under it, so anywhere within its length of the end.
|
||||
fn head_contains(&self, at: Point, out: Vec2, end: bool, p: Point, tol: f64) -> bool {
|
||||
let hw = self.arrow_weight(end);
|
||||
let v = p - at;
|
||||
match self.arrow_align {
|
||||
ArrowAlign::Tip => v.hypot() <= HEAD_REACH * hw + tol,
|
||||
ArrowAlign::Extend => {
|
||||
let along = v.dot(out);
|
||||
v.cross(out).abs() <= 2.0 * hw + tol && along <= 4.0 * hw + self.width / 2.0 + tol && along >= -HEAD_REACH * hw - tol
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Appearance {
|
||||
/// The visual bounds of a path with geometric bounds `bounds` painted with this appearance:
|
||||
/// the bounds with what each visible stroke paints round it ([`StrokeLayer::bounds_on`]).
|
||||
/// `bp` (the path) is only built when a stroke needs its shape.
|
||||
pub fn stroked_bounds(&self, bounds: Rect, bp: impl FnOnce() -> BezPath) -> Rect {
|
||||
let mut path = Some(bp);
|
||||
let mut shape = None;
|
||||
self.painted_strokes().fold(bounds, |acc, s| {
|
||||
if !s.needs_shape() {
|
||||
let o = s.side_reach(false);
|
||||
return acc.union(bounds.inflate(o, o));
|
||||
}
|
||||
let bp: &BezPath = shape.get_or_insert_with(|| path.take().map(|f| f()).unwrap_or_default());
|
||||
acc.union(s.bounds_on(bp, bounds))
|
||||
})
|
||||
}
|
||||
|
||||
/// Does a visible stroke of this appearance along `bp` (filled with `rule`) paint within
|
||||
/// `tol` of `p` ([`StrokeLayer::hits`])?
|
||||
pub fn stroke_hit(&self, bp: &BezPath, rule: FillRule, p: Point, tol: f64) -> bool {
|
||||
self.painted_strokes().any(|s| s.hits(bp, rule, p, tol))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use vectorcraft_color::{Color, Paint};
|
||||
use vectorcraft_geom::{PathData, shapes};
|
||||
|
||||
use super::*;
|
||||
use crate::{Arrowhead, Document, Node, StrokeAlign, hit};
|
||||
|
||||
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 line() -> BezPath {
|
||||
shapes::line(Point::new(0.0, 0.0), Point::new(100.0, 0.0)).to_bezpath()
|
||||
}
|
||||
|
||||
/// A thin triangle whose 22.6° apex at (100, 20) miters about 5 times half the weight out.
|
||||
fn triangle() -> BezPath {
|
||||
let mut bp = BezPath::new();
|
||||
bp.move_to((0.0, 0.0));
|
||||
bp.line_to((100.0, 20.0));
|
||||
bp.line_to((0.0, 40.0));
|
||||
bp.close_path();
|
||||
bp
|
||||
}
|
||||
|
||||
fn square() -> BezPath {
|
||||
shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)).to_bezpath()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_bigger_arrowhead_grows_the_bounds() {
|
||||
let l = line();
|
||||
let b = l.bounding_box();
|
||||
let at = |scale: f64| {
|
||||
stroke(2.0, |s| {
|
||||
s.join = LineJoin::Round;
|
||||
s.end_arrow = Some(Arrowhead::Triangle);
|
||||
s.arrow_scale = (100.0, scale);
|
||||
})
|
||||
};
|
||||
let (small, big) = (at(100.0).bounds_on(&l, b), at(300.0).bounds_on(&l, b));
|
||||
assert!(big.x1 > small.x1 + 15.0 && big.y1 > small.y1 + 7.0, "{small:?} → {big:?}");
|
||||
// A 300% head on a 2 pt stroke is 24 pt long and wide: its tip, past the end, is inside.
|
||||
assert!(big.x1 >= 100.0 + 24.0 && big.y1 >= 12.0 && big.x0 == -1.0, "{big:?}");
|
||||
// The start has no head.
|
||||
assert_eq!(at(300.0).head_reach(false), 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rectangles_keep_exact_bounds_and_sharp_corners_spike_up_to_the_limit() {
|
||||
let sq = square();
|
||||
let b = sq.bounding_box();
|
||||
let st = stroke(4.0, |_| {});
|
||||
assert_eq!(st.bounds_on(&sq, b), b.inflate(2.0, 2.0), "right-angle miters stay in the box");
|
||||
// A sharp spike: a thin triangle's apex miters far out unless the limit bevels it.
|
||||
let tri = triangle();
|
||||
let tb = tri.bounding_box();
|
||||
let spiked = st.bounds_on(&tri, tb);
|
||||
assert!(spiked.x1 > 100.0 + 10.0, "{spiked:?}");
|
||||
let mut low = st.clone();
|
||||
low.miter_limit = 2.0;
|
||||
assert!(low.bounds_on(&tri, tb).x1 <= 100.0 + 2.0 + 1e-9, "beveled");
|
||||
// Projecting caps reach past the ends; inside strokes stay in.
|
||||
let l = line();
|
||||
let cap = stroke(4.0, |s| s.cap = LineCap::Square).bounds_on(&l, l.bounding_box());
|
||||
assert_eq!((cap.x0, cap.x1, cap.y1), (-2.0, 102.0, 2.0));
|
||||
assert_eq!(stroke(4.0, |s| s.align = StrokeAlign::Inside).bounds_on(&sq, b), b);
|
||||
assert_eq!(stroke(4.0, |s| s.align = StrokeAlign::Outside).bounds_on(&sq, b), b.inflate(4.0, 4.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clicks_hit_outside_strokes_on_their_side_only() {
|
||||
let sq = square();
|
||||
let w = 10.0;
|
||||
let st = stroke(w, |s| s.align = StrokeAlign::Outside);
|
||||
assert!(st.hits(&sq, FillRule::NonZero, Point::new(-0.8 * w, 50.0), 0.5), "0.8·w outside");
|
||||
assert!(!st.hits(&sq, FillRule::NonZero, Point::new(0.8 * w, 50.0), 0.5), "nothing painted inside");
|
||||
assert!(st.hits(&sq, FillRule::NonZero, Point::new(0.3, 50.0), 0.5), "on the path");
|
||||
let inside = stroke(w, |s| s.align = StrokeAlign::Inside);
|
||||
assert!(inside.hits(&sq, FillRule::NonZero, Point::new(0.8 * w, 50.0), 0.5));
|
||||
assert!(!inside.hits(&sq, FillRule::NonZero, Point::new(-0.8 * w, 50.0), 0.5));
|
||||
// A centred stroke reaches half its weight each side.
|
||||
let centre = stroke(w, |_| {});
|
||||
assert!(centre.hits(&sq, FillRule::NonZero, Point::new(-4.0, 50.0), 0.5));
|
||||
assert!(!centre.hits(&sq, FillRule::NonZero, Point::new(-8.0, 50.0), 0.5));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clicks_follow_the_width_profile() {
|
||||
let l = line();
|
||||
let lens = stroke(10.0, |s| s.profile = Some(WidthProfile::lens()));
|
||||
assert!(lens.hits(&l, FillRule::NonZero, Point::new(50.0, 4.8), 0.1), "the lens's widest point");
|
||||
assert!(!lens.hits(&l, FillRule::NonZero, Point::new(5.0, 4.8), 0.1), "its thin end");
|
||||
// A profile wider than the weight on one side.
|
||||
let wide = stroke(10.0, |s| s.profile = Some(WidthProfile { points: vec![(0.0, 3.0, 1.0), (1.0, 3.0, 1.0)] }));
|
||||
// Left of travel (+x, y down) is −y.
|
||||
assert!(wide.hits(&l, FillRule::NonZero, Point::new(50.0, -14.0), 0.1));
|
||||
assert!(!wide.hits(&l, FillRule::NonZero, Point::new(50.0, 14.0), 0.1));
|
||||
assert!(wide.bounds_on(&l, l.bounding_box()).y0 <= -15.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clicks_hit_heads_caps_and_spikes() {
|
||||
let l = line();
|
||||
let head = stroke(2.0, |s| {
|
||||
s.end_arrow = Some(Arrowhead::Triangle);
|
||||
s.arrow_scale = (100.0, 300.0);
|
||||
});
|
||||
// The 24 pt head sits past the end in extend mode, 12 pt either side of the line.
|
||||
assert!(head.hits(&l, FillRule::NonZero, Point::new(110.0, 6.0), 0.1));
|
||||
assert!(!head.hits(&l, FillRule::NonZero, Point::new(50.0, 6.0), 0.1));
|
||||
let mut tip = head.clone();
|
||||
tip.arrow_align = ArrowAlign::Tip;
|
||||
assert!(tip.hits(&l, FillRule::NonZero, Point::new(90.0, 6.0), 0.1) && !tip.hits(&l, FillRule::NonZero, Point::new(130.0, 0.0), 0.1));
|
||||
let cap = stroke(10.0, |s| s.cap = LineCap::Square);
|
||||
assert!(cap.hits(&l, FillRule::NonZero, Point::new(104.0, 4.5), 0.1), "the cap's corner");
|
||||
assert!(!stroke(10.0, |_| {}).hits(&l, FillRule::NonZero, Point::new(104.0, 4.5), 0.1), "a butt end");
|
||||
let tri = triangle();
|
||||
let spike = stroke(4.0, |_| {});
|
||||
assert!(spike.hits(&tri, FillRule::NonZero, Point::new(106.0, 20.0), 0.1), "in the miter spike");
|
||||
assert!(!spike.hits(&tri, FillRule::NonZero, Point::new(106.0, 23.0), 0.1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn document_hit_testing_and_visual_bounds_use_the_whole_stroke() {
|
||||
let mut d = Document::new(300.0, 300.0);
|
||||
let (l, id) = (d.layers[0].id, d.alloc_id());
|
||||
let mut ap = Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 10.0);
|
||||
ap.stroke_mut().unwrap().align = StrokeAlign::Outside;
|
||||
let sq = PathData::from_bezpath(&shapes::rectangle(Rect::new(100.0, 100.0, 200.0, 200.0)).to_bezpath());
|
||||
d.insert(Some(l), 0, Node::path(id, sq, ap)).unwrap();
|
||||
let opt = hit::HitOptions { tol: 1.0, ..Default::default() };
|
||||
let h = hit::hit_test(&d, Point::new(92.0, 150.0), opt).expect("0.8·w outside the path");
|
||||
assert_eq!((h.leaf, h.kind), (id, hit::HitKind::Stroke));
|
||||
assert!(hit::hit_test(&d, Point::new(108.0, 150.0), opt).is_none(), "an unfilled interior");
|
||||
assert_eq!(d.node(id).unwrap().visual_bounds(), Some(Rect::new(90.0, 90.0, 210.0, 210.0)));
|
||||
}
|
||||
}
|
||||
|
|
@ -17,8 +17,9 @@ mod width;
|
|||
|
||||
use std::borrow::Cow;
|
||||
|
||||
use kurbo::{BezPath, ParamCurve, ParamCurveDeriv, PathEl, PathSeg, Vec2};
|
||||
use kurbo::{BezPath, PathEl, PathSeg};
|
||||
use vectorcraft_doc::{LineCap, LineJoin, StrokeAlign, StrokeLayer};
|
||||
pub(crate) use vectorcraft_geom::bez::{kink, segments, subpaths, tangent, unit};
|
||||
|
||||
pub use arrow::Arrow;
|
||||
pub use dash::{Dashed, Dot, dash, dot_outline};
|
||||
|
|
@ -27,9 +28,6 @@ pub use width::width_outline;
|
|||
|
||||
/// Arc-length accuracy for dashing and trimming (document points).
|
||||
const ARCLEN_ACCURACY: f64 = 1e-6;
|
||||
/// Tangents meeting at more than about 1° (this cosine) make a corner: fitted dashes centre on it
|
||||
/// and variable-width strokes give it the stroke's join.
|
||||
const CORNER_COS: f64 = 0.9998;
|
||||
|
||||
/// The geometry one stroke paints, in the coordinate space of its path.
|
||||
#[derive(Clone, Debug)]
|
||||
|
|
@ -119,40 +117,6 @@ pub fn line_outline(line: &BezPath, st: &StrokeLayer, width: f64, tol: f64) -> B
|
|||
}
|
||||
}
|
||||
|
||||
/// Do segments `a` and `b` (`b` following `a`) meet at a corner?
|
||||
pub(crate) fn kink(a: &PathSeg, b: &PathSeg) -> bool {
|
||||
tangent(a, 1.0).dot(tangent(b, 0.0)) < CORNER_COS
|
||||
}
|
||||
|
||||
/// 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) }
|
||||
}
|
||||
|
||||
/// Append `seg` to `out` (which must already have a current point at its start).
|
||||
pub(crate) fn push_seg(out: &mut BezPath, seg: &PathSeg) {
|
||||
match seg {
|
||||
|
|
@ -162,38 +126,11 @@ pub(crate) fn push_seg(out: &mut BezPath, seg: &PathSeg) {
|
|||
}
|
||||
}
|
||||
|
||||
/// Element ranges of the subpaths of `bp` (each with whether it is closed).
|
||||
pub(crate) fn subpaths(bp: &BezPath) -> Vec<(std::ops::Range<usize>, bool)> {
|
||||
let els = bp.elements();
|
||||
let mut out = vec![];
|
||||
let mut start = 0;
|
||||
for (i, el) in els.iter().enumerate() {
|
||||
match el {
|
||||
PathEl::MoveTo(_) if i > start => {
|
||||
out.push((start..i, false));
|
||||
start = i;
|
||||
}
|
||||
PathEl::ClosePath => {
|
||||
out.push((start..i + 1, true));
|
||||
start = i + 1;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if start < els.len() {
|
||||
out.push((start..els.len(), false));
|
||||
}
|
||||
// A lone MoveTo (or a MoveTo straight after a ClosePath) draws nothing.
|
||||
out.retain(|(r, _)| r.len() > 1);
|
||||
out
|
||||
}
|
||||
|
||||
/// The segments of one subpath (`els` starts with its MoveTo; a ClosePath adds the closing line).
|
||||
pub(crate) fn segments(els: &[PathEl]) -> Vec<PathSeg> {
|
||||
kurbo::segments(els.iter().copied()).collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
#[cfg(test)]
|
||||
mod tests_arrows;
|
||||
#[cfg(test)]
|
||||
mod tests_fit;
|
||||
#[cfg(test)]
|
||||
mod tests_reach;
|
||||
|
|
|
|||
|
|
@ -1,14 +1,16 @@
|
|||
//! 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
|
||||
//! Arrowheads. A head of weight `hw` (stroke weight × scale) fits a box `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.
|
||||
//! past the end point. Heads added after the first ten are drawn in local units ([`local`]).
|
||||
|
||||
use std::borrow::Cow;
|
||||
|
||||
use kurbo::{BezPath, ParamCurve, ParamCurveArclen, PathSeg, Point, Shape, Vec2};
|
||||
use std::f64::consts::{FRAC_PI_2, PI};
|
||||
|
||||
use kurbo::{Affine, BezPath, ParamCurve, ParamCurveArclen, PathSeg, Point, Shape, Vec2};
|
||||
use vectorcraft_doc::{ArrowAlign, Arrowhead, StrokeLayer};
|
||||
|
||||
use super::{ARCLEN_ACCURACY, StrokePieces, cap_extent, push_seg, segments, subpaths, tangent, unit};
|
||||
|
|
@ -185,6 +187,10 @@ fn inset(kind: Arrowhead, hw: f64, cap: f64) -> f64 {
|
|||
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),
|
||||
_ => match local_stop(kind) {
|
||||
Stop::At(u) => (u * hw).max(cap),
|
||||
Stop::Wall(back) => hollow(back * hw),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -224,6 +230,150 @@ fn shape(kind: Arrowhead, tip: Point, dir: Vec2, hw: f64) -> BezPath {
|
|||
let t = bar(hw);
|
||||
polygon(&mut p, &[tip + n * half, tip + n * half - dir * t, tip - n * half - dir * t, tip - n * half]);
|
||||
}
|
||||
_ => {
|
||||
// Local x runs back from the tip, y across to the right of `dir`; a unit is `hw`.
|
||||
p = local(kind);
|
||||
p.apply_affine(Affine::new([-dir.x * hw, -dir.y * hw, n.x * hw, n.y * hw, tip.x, tip.y]));
|
||||
}
|
||||
}
|
||||
p
|
||||
}
|
||||
|
||||
/// Where the stroke ends under a head drawn in local units.
|
||||
enum Stop {
|
||||
/// This many units back from the tip (or as far as the cap needs).
|
||||
At(f64),
|
||||
/// In the back wall of a ring whose back is this many units from the tip.
|
||||
Wall(f64),
|
||||
}
|
||||
|
||||
/// Flat-to-flat length of the hexagons (their corners touch the sides of the 4-unit box).
|
||||
const HEX: f64 = 2.0 * 1.732_050_807_568_877_2;
|
||||
/// Flattening tolerance of the curved local heads, in units.
|
||||
const LOCAL_TOL: f64 = 1e-3;
|
||||
|
||||
fn local_stop(kind: Arrowhead) -> Stop {
|
||||
match kind {
|
||||
Arrowhead::Target | Arrowhead::TagOpen | Arrowhead::DiamondOpen => Stop::Wall(4.0),
|
||||
Arrowhead::HexagonOpen => Stop::Wall(HEX),
|
||||
Arrowhead::DoubleBar | Arrowhead::DotOnBar => Stop::At(0.375),
|
||||
Arrowhead::Bracket => Stop::At(0.3),
|
||||
Arrowhead::Fork => Stop::At(2.7),
|
||||
Arrowhead::HalfArrowLeft | Arrowhead::HalfArrowRight | Arrowhead::Feather => Stop::At(1.3),
|
||||
Arrowhead::Swallowtail => Stop::At(1.4),
|
||||
Arrowhead::Chevron => Stop::At(1.0),
|
||||
Arrowhead::Slash => Stop::At(1.2),
|
||||
Arrowhead::DoubleSlash => Stop::At(1.05),
|
||||
Arrowhead::DoubleArrow => Stop::At(1.7),
|
||||
Arrowhead::HalfCircle => Stop::At(1.0),
|
||||
Arrowhead::Concave => Stop::At(2.6),
|
||||
Arrowhead::Hexagon => Stop::At(HEX / 2.0),
|
||||
_ => Stop::At(2.0),
|
||||
}
|
||||
}
|
||||
|
||||
/// The outline of a head added after the first ten, in local units: x from the tip (0) back
|
||||
/// along the line (to at most 4), y across it (−2 to 2, positive to the right of the direction
|
||||
/// the head points). Every shape is generated here.
|
||||
fn local(kind: Arrowhead) -> BezPath {
|
||||
let mut p = BezPath::new();
|
||||
let poly = |p: &mut BezPath, pts: &[(f64, f64)]| polygon(p, &pts.iter().map(|&q| Point::from(q)).collect::<Vec<_>>());
|
||||
let ring_poly = |p: &mut BezPath, pts: &[(f64, f64)]| ring_polygon(p, &pts.iter().map(|&q| Point::from(q)).collect::<Vec<_>>(), ring(1.0));
|
||||
let circle = |p: &mut BezPath, c: (f64, f64), r: f64| p.extend(kurbo::Circle::new(c, r).path_elements(LOCAL_TOL));
|
||||
// A regular polygon of `n` corners around `c`, the first at angle `a0`, alternating radii.
|
||||
let star = |c: (f64, f64), radii: &[f64], n: usize, a0: f64| -> Vec<(f64, f64)> {
|
||||
(0..n).map(|i| (a0 + i as f64 * 2.0 * PI / n as f64, radii[i % radii.len()])).map(|(a, r)| (c.0 + r * a.cos(), c.1 + r * a.sin())).collect()
|
||||
};
|
||||
// A plus of arms `h` long and `k` thick round (2, 0), turned by `turn`.
|
||||
let plus = |h: f64, k: f64, turn: f64| -> Vec<(f64, f64)> {
|
||||
let arm = [(k, h), (k, k), (h, k)];
|
||||
let (sin, cos) = turn.sin_cos();
|
||||
(0..4)
|
||||
.flat_map(|q| arm.iter().map(move |&(x, y)| [(x, y), (y, -x), (-x, -y), (-y, x)][q]))
|
||||
.map(|(x, y)| (2.0 + x * cos - y * sin, x * sin + y * cos))
|
||||
.collect()
|
||||
};
|
||||
let hexagon = star((HEX / 2.0, 0.0), &[2.0], 6, PI / 6.0);
|
||||
match kind {
|
||||
Arrowhead::Barbed => poly(&mut p, &[(0.0, 0.0), (3.6, 2.0), (2.6, 0.55), (4.0, 0.55), (4.0, -0.55), (2.6, -0.55), (3.6, -2.0)]),
|
||||
Arrowhead::HalfArrowLeft => poly(&mut p, &[(0.0, 0.0), (4.0, -2.0), (3.0, -0.5), (3.0, 0.5), (1.0, 0.5)]),
|
||||
Arrowhead::HalfArrowRight => poly(&mut p, &[(0.0, 0.0), (4.0, 2.0), (3.0, 0.5), (3.0, -0.5), (1.0, -0.5)]),
|
||||
Arrowhead::Concave => {
|
||||
p.move_to((0.0, 0.0));
|
||||
p.quad_to((2.4, 0.4), (4.0, 2.0));
|
||||
p.line_to((4.0, -2.0));
|
||||
p.quad_to((2.4, -0.4), (0.0, 0.0));
|
||||
p.close_path();
|
||||
}
|
||||
Arrowhead::DoubleBar => {
|
||||
poly(&mut p, &[(0.0, -2.0), (0.75, -2.0), (0.75, 2.0), (0.0, 2.0)]);
|
||||
poly(&mut p, &[(1.5, -2.0), (2.25, -2.0), (2.25, 2.0), (1.5, 2.0)]);
|
||||
}
|
||||
Arrowhead::Feather => poly(&mut p, &[(0.0, 0.5), (1.4, 2.0), (4.0, 2.0), (2.6, 0.5), (2.6, -0.5), (4.0, -2.0), (1.4, -2.0), (0.0, -0.5)]),
|
||||
Arrowhead::DotOnBar => {
|
||||
poly(&mut p, &[(0.0, -2.0), (0.75, -2.0), (0.75, 2.0), (0.0, 2.0)]);
|
||||
circle(&mut p, (2.5, 0.0), 1.25);
|
||||
}
|
||||
Arrowhead::Chevron => poly(&mut p, &[(0.0, 0.0), (2.0, 2.0), (4.0, 2.0), (2.0, 0.0), (4.0, -2.0), (2.0, -2.0)]),
|
||||
Arrowhead::DoubleArrow => poly(&mut p, &[(0.0, 0.0), (2.2, 1.8), (1.9, 0.5), (4.0, 2.0), (3.4, 0.0), (4.0, -2.0), (1.9, -0.5), (2.2, -1.8)]),
|
||||
Arrowhead::Target => {
|
||||
circle(&mut p, (2.0, 0.0), 2.0);
|
||||
p.extend(kurbo::Circle::new((2.0, 0.0), 2.0 - ring(1.0)).to_path(LOCAL_TOL).reverse_subpaths());
|
||||
circle(&mut p, (2.0, 0.0), 0.75);
|
||||
}
|
||||
Arrowhead::Star => poly(&mut p, &star((2.0, 0.0), &[2.0, 0.85], 10, PI)),
|
||||
Arrowhead::Cross => poly(&mut p, &plus(2.2, 0.4, PI / 4.0)),
|
||||
Arrowhead::Plus => poly(&mut p, &plus(1.9, 0.4, 0.0)),
|
||||
Arrowhead::Hexagon => poly(&mut p, &hexagon),
|
||||
Arrowhead::HexagonOpen => ring_poly(&mut p, &hexagon),
|
||||
Arrowhead::Tag => poly(&mut p, &[(0.0, 0.0), (1.6, 2.0), (4.0, 2.0), (4.0, -2.0), (1.6, -2.0)]),
|
||||
Arrowhead::TagOpen => ring_poly(&mut p, &[(0.0, 0.0), (1.6, 2.0), (4.0, 2.0), (4.0, -2.0), (1.6, -2.0)]),
|
||||
Arrowhead::HalfCircle => {
|
||||
p.move_to((2.0, 2.0));
|
||||
p.extend(kurbo::Arc::new((2.0, 0.0), (2.0, 2.0), FRAC_PI_2, PI, 0.0).append_iter(LOCAL_TOL));
|
||||
p.close_path();
|
||||
}
|
||||
Arrowhead::Drop => {
|
||||
// Straight sides from the tip, tangent to a round back.
|
||||
let (c, r) = (Point::new(2.6, 0.0), 1.4);
|
||||
let a = PI - (r / c.x).acos();
|
||||
p.move_to((0.0, 0.0));
|
||||
p.line_to(c + Vec2::from_angle(a) * r);
|
||||
p.extend(kurbo::Arc::new(c, (r, r), a, -2.0 * a, 0.0).append_iter(LOCAL_TOL));
|
||||
p.close_path();
|
||||
}
|
||||
Arrowhead::Slash => poly(&mut p, &[(0.0, 2.0), (0.8, 2.0), (2.4, -2.0), (1.6, -2.0)]),
|
||||
Arrowhead::DoubleSlash => {
|
||||
poly(&mut p, &[(0.0, 2.0), (0.7, 2.0), (2.1, -2.0), (1.4, -2.0)]);
|
||||
poly(&mut p, &[(1.6, 2.0), (2.3, 2.0), (3.7, -2.0), (3.0, -2.0)]);
|
||||
}
|
||||
Arrowhead::DiamondOpen => ring_poly(&mut p, &[(0.0, 0.0), (2.0, 2.0), (4.0, 0.0), (2.0, -2.0)]),
|
||||
Arrowhead::TriangleReverse => poly(&mut p, &[(0.0, 2.0), (4.0, 0.0), (0.0, -2.0)]),
|
||||
Arrowhead::Swallowtail => poly(&mut p, &[(0.0, 2.0), (4.0, 2.0), (2.8, 0.0), (4.0, -2.0), (0.0, -2.0)]),
|
||||
Arrowhead::Bracket => poly(&mut p, &[(0.0, 2.0), (2.0, 2.0), (2.0, 1.4), (0.6, 1.4), (0.6, -1.4), (2.0, -1.4), (2.0, -2.0), (0.0, -2.0)]),
|
||||
Arrowhead::Fork => poly(&mut p, &[(0.0, 2.0), (3.0, 2.0), (3.0, -2.0), (0.0, -2.0), (0.0, -1.4), (2.4, -1.4), (2.4, 1.4), (0.0, 1.4)]),
|
||||
Arrowhead::Leaf => {
|
||||
p.move_to((0.0, 0.0));
|
||||
p.quad_to((2.0, 3.2), (4.0, 0.0));
|
||||
p.quad_to((2.0, -3.2), (0.0, 0.0));
|
||||
p.close_path();
|
||||
}
|
||||
Arrowhead::Kite => poly(&mut p, &[(0.0, 0.0), (1.3, 1.7), (4.0, 0.0), (1.3, -1.7)]),
|
||||
Arrowhead::TriangleBar => {
|
||||
poly(&mut p, &[(0.0, 2.0), (0.6, 2.0), (0.6, 0.25), (4.0, 1.8), (4.0, -1.8), (0.6, -0.25), (0.6, -2.0), (0.0, -2.0)])
|
||||
}
|
||||
Arrowhead::Oval => p.extend(kurbo::Ellipse::new((2.0, 0.0), (2.0, 1.2), 0.0).path_elements(LOCAL_TOL)),
|
||||
// The first ten are drawn by `shape`.
|
||||
Arrowhead::Triangle
|
||||
| Arrowhead::TriangleOpen
|
||||
| Arrowhead::Circle
|
||||
| Arrowhead::CircleOpen
|
||||
| Arrowhead::Square
|
||||
| Arrowhead::SquareOpen
|
||||
| Arrowhead::Bar
|
||||
| Arrowhead::Diamond
|
||||
| Arrowhead::Arrow
|
||||
| Arrowhead::ArrowOpen => {}
|
||||
}
|
||||
p
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,83 @@
|
|||
//! Every arrowhead's outline: drawn, inside its box (4 × 4 weights; the open arrow's square-cut
|
||||
//! arms reach a little further back), without self-intersections, and covering the end of the
|
||||
//! stroke under it.
|
||||
|
||||
use kurbo::{BezPath, PathEl, Point, Shape};
|
||||
use vectorcraft_color::{Color, Paint};
|
||||
use vectorcraft_doc::{ArrowAlign, Arrowhead, StrokeLayer};
|
||||
|
||||
use super::stroke_pieces;
|
||||
|
||||
/// A 2 pt line along +x ending at (100, 0) with `kind` at its end, tip on the end point: heads
|
||||
/// of weight 2, so 8 long and 8 wide.
|
||||
fn head(kind: Arrowhead) -> super::Arrow {
|
||||
let mut st = StrokeLayer::new(Paint::solid(Color::BLACK), 2.0);
|
||||
st.end_arrow = Some(kind);
|
||||
st.arrow_align = ArrowAlign::Tip;
|
||||
let mut l = BezPath::new();
|
||||
l.move_to((0.0, 0.0));
|
||||
l.line_to((100.0, 0.0));
|
||||
stroke_pieces(&l, &st).heads.remove(0)
|
||||
}
|
||||
|
||||
/// The outline's closed contours as polygons (curves flattened).
|
||||
fn contours(bp: &BezPath) -> Vec<Vec<Point>> {
|
||||
let mut out: Vec<Vec<Point>> = vec![];
|
||||
kurbo::flatten(bp.iter(), 1e-3, |el| match el {
|
||||
PathEl::MoveTo(p) => out.push(vec![p]),
|
||||
PathEl::LineTo(p) => out.last_mut().unwrap().push(p),
|
||||
PathEl::ClosePath => {
|
||||
let c = out.last_mut().unwrap();
|
||||
if c.len() > 1 && c[0].distance(*c.last().unwrap()) < 1e-9 {
|
||||
c.pop();
|
||||
}
|
||||
}
|
||||
_ => unreachable!("flattened"),
|
||||
});
|
||||
out
|
||||
}
|
||||
|
||||
/// Do segments `a` and `b` cross at a point inside both (touching ends don't count)?
|
||||
fn cross(a: (Point, Point), b: (Point, Point)) -> bool {
|
||||
let orient = |p: Point, q: Point, r: Point| (q - p).cross(r - p);
|
||||
let (d1, d2) = (orient(b.0, b.1, a.0), orient(b.0, b.1, a.1));
|
||||
let (d3, d4) = (orient(a.0, a.1, b.0), orient(a.0, a.1, b.1));
|
||||
d1 * d2 < -1e-12 && d3 * d4 < -1e-12
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_outline_is_drawn_inside_its_box_without_self_intersections() {
|
||||
let (o, d) = (Point::ORIGIN, Point::new(2.0, 2.0));
|
||||
assert!(cross((o, d), (Point::new(0.0, 2.0), Point::new(2.0, 0.0))) && !cross((o, d), (d, Point::new(4.0, 0.0))), "the check itself");
|
||||
for kind in Arrowhead::ALL {
|
||||
let h = head(kind);
|
||||
assert!(h.outline.area().abs() > 4.0, "{kind:?} is drawn");
|
||||
let b = h.outline.bounding_box();
|
||||
assert!(b.x0 >= 91.0 - 1e-6 && b.x1 <= 100.0 + 1e-6 && b.y0 >= -4.0 - 1e-6 && b.y1 <= 4.0 + 1e-6, "{kind:?} leaves its box: {b:?}");
|
||||
let edges: Vec<(Point, Point)> = contours(&h.outline).iter().flat_map(|c| (0..c.len()).map(|i| (c[i], c[(i + 1) % c.len()]))).collect();
|
||||
assert!(edges.len() >= 3, "{kind:?}");
|
||||
for (i, a) in edges.iter().enumerate() {
|
||||
for b in &edges[i + 1..] {
|
||||
assert!(!cross(*a, *b), "{kind:?}: {a:?} crosses {b:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_stroke_ends_inside_every_head() {
|
||||
for kind in Arrowhead::ALL {
|
||||
let h = head(kind);
|
||||
// Just in front of where the line stops, on its centre line.
|
||||
let end = h.tip - h.dir * (h.inset - 0.1);
|
||||
assert_ne!(h.outline.winding(end), 0, "{kind:?}: the line ends outside the head (inset {})", h.inset);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn half_arrows_barb_on_their_own_side() {
|
||||
// Pointing along +x on a y-down page, left is −y.
|
||||
assert!(head(Arrowhead::HalfArrowLeft).outline.bounding_box().y0 < -3.0);
|
||||
assert!(head(Arrowhead::HalfArrowLeft).outline.bounding_box().y1 < 1.5);
|
||||
assert!(head(Arrowhead::HalfArrowRight).outline.bounding_box().y1 > 3.0);
|
||||
}
|
||||
|
|
@ -0,0 +1,92 @@
|
|||
//! The document's stroke bounds and clicks (`vectorcraft_doc` reach) against the geometry the
|
||||
//! stroke really paints: every painted point is inside the visual bounds and hits.
|
||||
|
||||
use kurbo::{BezPath, Point, Rect, Shape};
|
||||
use vectorcraft_color::{Color, Paint};
|
||||
use vectorcraft_doc::{ArrowAlign, Arrowhead, Dash, LineCap, LineJoin, StrokeAlign, StrokeLayer, WidthProfile};
|
||||
use vectorcraft_geom::hit::fill_contains;
|
||||
use vectorcraft_geom::{FillRule, PathData};
|
||||
|
||||
use super::outline_region;
|
||||
|
||||
fn paths() -> Vec<BezPath> {
|
||||
let mut curve = BezPath::new();
|
||||
curve.move_to((10.0, 60.0));
|
||||
curve.curve_to((30.0, 0.0), (70.0, 120.0), (90.0, 40.0));
|
||||
let mut zigzag = BezPath::new();
|
||||
zigzag.move_to((10.0, 20.0));
|
||||
zigzag.line_to((40.0, 70.0));
|
||||
zigzag.line_to((55.0, 15.0));
|
||||
zigzag.line_to((90.0, 60.0));
|
||||
let mut tri = BezPath::new();
|
||||
tri.move_to((10.0, 10.0));
|
||||
tri.line_to((90.0, 30.0));
|
||||
tri.line_to((10.0, 50.0));
|
||||
tri.close_path();
|
||||
vec![curve, zigzag, tri, Rect::new(20.0, 20.0, 80.0, 70.0).to_path(0.1)]
|
||||
}
|
||||
|
||||
/// Check `st` along `bp`: the painted region's box is inside the bounds, and points of the
|
||||
/// region (on a 1.5 pt grid) hit within a quarter point, as the painted outlines are flattened.
|
||||
/// Variable widths are drawn straight between their offsets at the path's samples and inner
|
||||
/// corners, which beside sharp corners can paint up to about a point past the exact width there.
|
||||
fn check(bp: &BezPath, st: &StrokeLayer, what: &str) {
|
||||
let region = outline_region(&PathData::from_bezpath(bp), FillRule::NonZero, st).to_bezpath();
|
||||
if region.elements().is_empty() {
|
||||
return;
|
||||
}
|
||||
let painted = region.bounding_box();
|
||||
let bounds = st.bounds_on(bp, bp.bounding_box());
|
||||
assert!(bounds.inflate(0.05, 0.05).contains_rect(painted), "{what}: paints {painted:?} outside {bounds:?}");
|
||||
let tol = if st.profile.is_some() { 1.0 } else { 0.25 };
|
||||
// Off the outline's own coordinates, so no sample sits level with a vertex.
|
||||
let (mut x, mut checked) = (painted.x0 + 0.371, 0);
|
||||
while x <= painted.x1 {
|
||||
let mut y = painted.y0 + 0.293;
|
||||
while y <= painted.y1 {
|
||||
let p = Point::new(x, y);
|
||||
if fill_contains(®ion, FillRule::EvenOdd, p) {
|
||||
assert!(st.hits(bp, FillRule::NonZero, p, tol), "{what}: painted {p:?} doesn't hit");
|
||||
checked += 1;
|
||||
}
|
||||
y += 1.5;
|
||||
}
|
||||
x += 1.5;
|
||||
}
|
||||
assert!(checked > 0, "{what}: no samples");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bounds_and_clicks_cover_what_every_stroke_paints() {
|
||||
let profiles = [None, Some(WidthProfile::lens()), Some(WidthProfile { points: vec![(0.0, 2.0, 0.5), (0.6, 0.8, 1.6), (1.0, 1.2, 0.3)] })];
|
||||
for (pi, bp) in paths().iter().enumerate() {
|
||||
for align in [StrokeAlign::Center, StrokeAlign::Inside, StrokeAlign::Outside] {
|
||||
for cap in [LineCap::Butt, LineCap::Round, LineCap::Square] {
|
||||
for (join, limit) in [(LineJoin::Miter, 10.0), (LineJoin::Miter, 2.0), (LineJoin::Round, 10.0), (LineJoin::Bevel, 10.0)] {
|
||||
for (fi, profile) in profiles.iter().enumerate() {
|
||||
for dash in [None, Some(Dash { pattern: vec![7.0, 4.0], offset: 0.0, align_corners: false })] {
|
||||
let mut st = StrokeLayer::new(Paint::solid(Color::BLACK), 6.0);
|
||||
(st.align, st.cap, st.join, st.miter_limit, st.profile, st.dash) =
|
||||
(align, cap, join, limit, profile.clone(), dash.clone());
|
||||
check(bp, &st, &format!("path {pi} {align:?} {cap:?} {join:?} {limit} profile {fi} dashed {}", dash.is_some()));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bounds_and_clicks_cover_every_arrowhead() {
|
||||
let bp = &paths()[0];
|
||||
for kind in Arrowhead::ALL {
|
||||
for align in [ArrowAlign::Extend, ArrowAlign::Tip] {
|
||||
for (scale, cap) in [(50.0, LineCap::Round), (100.0, LineCap::Butt), (300.0, LineCap::Square)] {
|
||||
let mut st = StrokeLayer::new(Paint::solid(Color::BLACK), 3.0);
|
||||
(st.start_arrow, st.end_arrow, st.arrow_align, st.arrow_scale, st.cap) = (Some(kind), Some(kind), align, (scale, scale), cap);
|
||||
check(bp, &st, &format!("{kind:?} {align:?} {scale}%"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,9 +1,9 @@
|
|||
//! The Stroke panel: weight, cap, join, alignment, dashes, arrowheads, width profiles and brushes.
|
||||
//! With type selected it edits the characters' stroke (weight, cap, join, miter limit, dashes).
|
||||
|
||||
use serde_json::Value;
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_color::{Color, Paint};
|
||||
use vectorcraft_doc::{ArrowAlign, Arrowhead, CharStyle, Dash, LineCap, LineJoin, Node, NodeKind, StrokeLayer, Unit, WidthProfile};
|
||||
use vectorcraft_doc::{ArrowAlign, Arrowhead, CharStyle, Dash, LineCap, LineJoin, Node, NodeKind, SavedProfile, StrokeLayer, Unit, WidthProfile};
|
||||
|
||||
use super::appearance::{ItemTarget, edit_items, item_target};
|
||||
use super::paint::painted;
|
||||
|
|
@ -18,7 +18,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
"Stroke Options",
|
||||
["Window", "Stroke"],
|
||||
None,
|
||||
"{weight?: pt, 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; a new pattern keeps the current offset and alignment), dashOffset? (exact dashes only), alignDashes?: bool (true: dashes fitted to corners and path ends, every run between them holding whole periods with a dash centred on each corner and end; false, the default for a new pattern: exact lengths), 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\", item?: stroke item index|null (omitted: the Appearance panel's active item if it is a stroke, else the top stroke, created when missing), ids?} Without a stroke item, type takes weight, cap, join, miterLimit and the dash options as its characters' stroke (every run; text.setRangeStyle `strokeOptions` styles a range), and images and symbol instances (also in groups) are left alone",
|
||||
"{weight?: pt, 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; a new pattern keeps the current offset and alignment), dashOffset? (exact dashes only), alignDashes?: bool (true: dashes fitted to corners and path ends, every run between them holding whole periods with a dash centred on each corner and end; false, the default for a new pattern: exact lengths), startArrow?, endArrow?: Arrow|ArrowOpen|Barbed|Concave|DoubleArrow|HalfArrowLeft|HalfArrowRight|Chevron|Feather|Swallowtail|Triangle|TriangleOpen|TriangleReverse|TriangleBar|Kite|Leaf|Drop|Circle|CircleOpen|HalfCircle|Oval|Target|Square|SquareOpen|Tag|TagOpen|Diamond|DiamondOpen|Hexagon|HexagonOpen|Star|Cross|Plus|Bar|DoubleBar|DotOnBar|Slash|DoubleSlash|Bracket|Fork|null (HalfArrowLeft/Right: one barb, left or right of the direction the head points), arrowAlign?: \"extend\" (tip past the end point, default)|\"tip\" (tip on the end point; the stroke is shortened), profile?: \"uniform\"|\"lens\"|\"taperStart\"|\"taperEnd\"|\"pinch\"|\"teardrop\"|\"wave\"|a saved profile's name (stroke.widthProfile.list), item?: stroke item index|null (omitted: the Appearance panel's active item if it is a stroke, else the top stroke, created when missing), ids?} Without a stroke item, type takes weight, cap, join, miterLimit and the dash options as its characters' stroke (every run; text.setRangeStyle `strokeOptions` styles a range), and images and symbol instances (also in groups) are left alone",
|
||||
has_doc,
|
||||
stroke_set
|
||||
),
|
||||
|
|
@ -31,6 +31,42 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
has_doc,
|
||||
stroke_advanced
|
||||
),
|
||||
cmd!(
|
||||
"stroke.widthProfile.add",
|
||||
"Add to Profiles",
|
||||
[],
|
||||
None,
|
||||
"{name?} save the selected stroke's variable width to the Profile list (kept with the preferences) under `name` (default \"Width Profile N\"; unique, not a built-in's) → {name}",
|
||||
can_add_profile,
|
||||
profile_add
|
||||
),
|
||||
cmd!(
|
||||
"stroke.widthProfile.delete",
|
||||
"Delete Profile",
|
||||
[],
|
||||
None,
|
||||
"{name?} remove a saved profile from the Profile list (default: the selected stroke's); built-in profiles can't be deleted, and strokes keep their widths → {deleted}",
|
||||
has_saved_profiles,
|
||||
profile_delete
|
||||
),
|
||||
cmd!(
|
||||
"stroke.widthProfile.reset",
|
||||
"Reset Profiles",
|
||||
[],
|
||||
None,
|
||||
"{} remove every saved profile, leaving the built-ins → {removed: count}",
|
||||
has_saved_profiles,
|
||||
profile_reset
|
||||
),
|
||||
cmd!(
|
||||
query "stroke.widthProfile.list",
|
||||
"Width Profiles",
|
||||
[],
|
||||
None,
|
||||
"{} → {profiles: [{id (stroke.set `profile`), label, builtIn, points: [[t, left, right]…]}], current: the selected stroke's profile id or name, \"custom\" when it isn't listed, null without a stroke}",
|
||||
always,
|
||||
profile_list
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
|
|
@ -154,8 +190,7 @@ fn stroke_set(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
};
|
||||
let profile = match str_param(p, "profile") {
|
||||
None => None,
|
||||
Some("uniform") => Some(None),
|
||||
Some(id) => Some(Some(WidthProfile::preset(id).ok_or_else(|| bad(C, format!("unknown profile {id}")))?)),
|
||||
Some(id) => Some(s.resolve_profile(id).ok_or_else(|| bad(C, format!("unknown profile {id} (see stroke.widthProfile.list)")))?),
|
||||
};
|
||||
edit_items(s, &ids, item, C, "Stroke", false, |n, index| {
|
||||
if index.is_none()
|
||||
|
|
@ -314,3 +349,118 @@ fn stroke_advanced(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
})?;
|
||||
ok()
|
||||
}
|
||||
|
||||
/// One row of the Profile list: a built-in profile or a saved one.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct ProfileEntry<'a> {
|
||||
/// What `stroke.set {profile}` takes: a built-in's id, a saved profile's name.
|
||||
pub id: &'a str,
|
||||
pub label: &'a str,
|
||||
pub points: &'a [(f64, f64, f64)],
|
||||
pub built_in: bool,
|
||||
}
|
||||
|
||||
impl ProfileEntry<'_> {
|
||||
/// Does this row describe `p` (`None`: the plain stroke, the Uniform row)?
|
||||
pub fn matches(&self, p: Option<&WidthProfile>) -> bool {
|
||||
match p {
|
||||
None => self.built_in && self.id == "uniform",
|
||||
Some(p) => p.points == self.points,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Session {
|
||||
/// The Profile list: the built-in profiles, then the saved ones.
|
||||
pub fn profile_entries(&self) -> impl Iterator<Item = ProfileEntry<'_>> {
|
||||
let built_in = WidthProfile::PRESETS.iter().map(|p| ProfileEntry { id: p.id, label: p.label, points: p.points, built_in: true });
|
||||
let saved =
|
||||
self.prefs.width_profiles.iter().map(|p| ProfileEntry { id: &p.name, label: &p.name, points: &p.profile.points, built_in: false });
|
||||
built_in.chain(saved)
|
||||
}
|
||||
|
||||
/// The Profile list row showing `p` (`None`: the plain stroke), if it is listed.
|
||||
pub fn profile_entry(&self, p: Option<&WidthProfile>) -> Option<ProfileEntry<'_>> {
|
||||
self.profile_entries().find(|e| e.matches(p))
|
||||
}
|
||||
|
||||
/// The profile `stroke.set {profile: key}` applies: `Some(None)` for "uniform" (the plain
|
||||
/// stroke), else a built-in's id or a saved profile's name; `None` when nothing is called `key`.
|
||||
pub fn resolve_profile(&self, key: &str) -> Option<Option<WidthProfile>> {
|
||||
let e = self.profile_entries().find(|e| e.id == key)?;
|
||||
Some((!e.matches(None)).then(|| WidthProfile { points: e.points.to_vec() }))
|
||||
}
|
||||
|
||||
/// The name Add to Profiles suggests: the first free "Width Profile N".
|
||||
pub fn next_profile_name(&self) -> String {
|
||||
(1..).map(|n| format!("Width Profile {n}")).find(|n| !self.profile_name_taken(n)).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Does a listed profile have `name` as its id or label (ignoring case)?
|
||||
fn profile_name_taken(&self, name: &str) -> bool {
|
||||
self.profile_entries().any(|e| e.id.eq_ignore_ascii_case(name) || e.label.eq_ignore_ascii_case(name))
|
||||
}
|
||||
|
||||
/// The variable width Add to Profiles saves: the shown stroke's profile, when it isn't listed.
|
||||
fn profile_to_add(&self) -> std::result::Result<WidthProfile, String> {
|
||||
let p = self.shown_stroke().and_then(|s| s.profile).ok_or("select a stroke with a variable width")?;
|
||||
match self.profile_entry(Some(&p)) {
|
||||
Some(e) => Err(format!("the selected stroke's profile is already listed as {}", e.label)),
|
||||
None => Ok(p),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn can_add_profile(s: &Session) -> std::result::Result<(), String> {
|
||||
has_doc(s)?;
|
||||
s.profile_to_add().map(|_| ())
|
||||
}
|
||||
|
||||
fn has_saved_profiles(s: &Session) -> std::result::Result<(), String> {
|
||||
if s.prefs.width_profiles.is_empty() { Err("no saved profiles".into()) } else { Ok(()) }
|
||||
}
|
||||
|
||||
fn profile_add(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "stroke.widthProfile.add";
|
||||
let profile = s.profile_to_add().map_err(|e| bad(C, e))?;
|
||||
let name = match str_param(p, "name").map(str::trim) {
|
||||
None => s.next_profile_name(),
|
||||
Some("") => return Err(bad(C, "the name is empty")),
|
||||
Some(n) if s.profile_name_taken(n) => return Err(bad(C, format!("a profile is already called {n}"))),
|
||||
Some(n) => n.to_string(),
|
||||
};
|
||||
s.prefs.width_profiles.push(SavedProfile { name: name.clone(), profile });
|
||||
Ok(json!({ "name": name }))
|
||||
}
|
||||
|
||||
fn profile_delete(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "stroke.widthProfile.delete";
|
||||
let name = match str_param(p, "name") {
|
||||
Some(n) => n.to_string(),
|
||||
None => {
|
||||
let shown = s.shown_stroke().and_then(|st| st.profile);
|
||||
let entry = shown.as_ref().and_then(|p| s.profile_entry(Some(p)));
|
||||
entry.map(|e| e.id.to_string()).ok_or_else(|| bad(C, "give a name, or select a stroke with a saved profile"))?
|
||||
}
|
||||
};
|
||||
if WidthProfile::PRESETS.iter().any(|b| b.id == name || b.label == name) {
|
||||
return Err(bad(C, format!("{name} is built in and can't be deleted")));
|
||||
}
|
||||
let i = s.prefs.width_profiles.iter().position(|sp| sp.name == name).ok_or_else(|| bad(C, format!("no saved profile called {name}")))?;
|
||||
s.prefs.width_profiles.remove(i);
|
||||
Ok(json!({ "deleted": name }))
|
||||
}
|
||||
|
||||
fn profile_reset(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
let removed = std::mem::take(&mut s.prefs.width_profiles).len();
|
||||
Ok(json!({ "removed": removed }))
|
||||
}
|
||||
|
||||
fn profile_list(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
let profiles: Vec<Value> = s
|
||||
.profile_entries()
|
||||
.map(|e| json!({"id": e.id, "label": e.label, "builtIn": e.built_in, "points": e.points.iter().map(|&(t, l, r)| [t, l, r]).collect::<Vec<_>>()}))
|
||||
.collect();
|
||||
let current = s.shown_stroke().map(|st| s.profile_entry(st.profile.as_ref()).map_or("custom", |e| e.id).to_string());
|
||||
Ok(json!({ "profiles": profiles, "current": current }))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -322,6 +322,10 @@ pub struct Prefs {
|
|||
/// edit it.
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
pub flattener_presets: Vec<cmd::FlattenerPreset>,
|
||||
/// Width profiles saved to the Stroke panel's Profile list (`stroke.widthProfile.*`). Not a
|
||||
/// Preferences dialog field, so it has no [`cmd::prefscmds::PREF_SPECS`] row.
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
pub width_profiles: Vec<vectorcraft_doc::SavedProfile>,
|
||||
}
|
||||
|
||||
impl Default for Prefs {
|
||||
|
|
@ -441,6 +445,7 @@ impl Default for Prefs {
|
|||
},
|
||||
eyedropper: Default::default(),
|
||||
flattener_presets: vec![],
|
||||
width_profiles: vec![],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -841,6 +846,8 @@ mod tests_proxyitems;
|
|||
#[cfg(test)]
|
||||
mod tests_strokegeom;
|
||||
#[cfg(test)]
|
||||
mod tests_strokereach;
|
||||
#[cfg(test)]
|
||||
mod tests_strokeux;
|
||||
#[cfg(test)]
|
||||
mod tests_stylepanel;
|
||||
|
|
@ -861,4 +868,6 @@ mod tests_tileedge;
|
|||
#[cfg(test)]
|
||||
mod tests_tints;
|
||||
#[cfg(test)]
|
||||
mod tests_widthprofiles;
|
||||
#[cfg(test)]
|
||||
mod tests_xform;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,76 @@
|
|||
//! Visual bounds and clicks take in the whole stroke: arrowheads, outside alignment, width
|
||||
//! profiles (through the Selection tool and the commands that use visual bounds).
|
||||
|
||||
use serde_json::json;
|
||||
use vectorcraft_tools::{PointerEvent, PointerKind};
|
||||
|
||||
use super::*;
|
||||
|
||||
fn session() -> Session {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
fn id_of(v: &serde_json::Value) -> NodeId {
|
||||
NodeId(v["id"].as_u64().unwrap())
|
||||
}
|
||||
|
||||
/// Click with the Selection tool at (x, y) on a 100% view: what is selected afterwards.
|
||||
fn click(s: &mut Session, x: f64, y: f64) -> Vec<NodeId> {
|
||||
let v = ViewInfo::default();
|
||||
s.select_tool("selection", v).unwrap();
|
||||
for k in [PointerKind::Down, PointerKind::Up] {
|
||||
s.pointer(&PointerEvent::new(k, x, y), v).unwrap();
|
||||
}
|
||||
s.active().unwrap().selection.objects.clone()
|
||||
}
|
||||
|
||||
/// The artboard after Fit to Selected Art (which fits the selection's visual bounds).
|
||||
fn fitted(s: &mut Session) -> vectorcraft_geom::Rect {
|
||||
s.execute("artboard.fitToSelection", &json!({})).unwrap();
|
||||
s.doc().unwrap().doc.artboards[0].rect
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_300_percent_arrowhead_grows_the_visual_bounds() {
|
||||
let mut s = session();
|
||||
s.execute("shape.line", &json!({"x1": 100, "y1": 200, "x2": 300, "y2": 200})).unwrap();
|
||||
s.execute("stroke.set", &json!({"weight": 2, "join": "round", "endArrow": "Triangle"})).unwrap();
|
||||
let plain = fitted(&mut s);
|
||||
s.execute("stroke.setAdvanced", &json!({"arrowScale": [100, 300]})).unwrap();
|
||||
let big = fitted(&mut s);
|
||||
// The 24 pt head reaches past the end and 12 pt to each side of the line.
|
||||
assert!(big.x1 > plain.x1 + 15.0 && big.y1 >= 212.0 && big.y0 <= 188.0, "{plain:?} → {big:?}");
|
||||
assert_eq!(big.x0, plain.x0, "the start has no head");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_click_outside_an_outside_stroke_selects_it() {
|
||||
let mut s = session();
|
||||
let r = id_of(&s.execute("shape.rectangle", &json!({"x": 100, "y": 100, "width": 200, "height": 200})).unwrap());
|
||||
s.execute("paint.setFill", &json!({"none": true})).unwrap();
|
||||
s.execute("stroke.set", &json!({"weight": 20, "align": "outside"})).unwrap();
|
||||
s.execute("select.none", &json!({})).unwrap();
|
||||
// 0.8 × the weight outside the path: past where a centred stroke (plus the tolerance) reaches.
|
||||
assert_eq!(click(&mut s, 84.0, 200.0), [r]);
|
||||
s.execute("select.none", &json!({})).unwrap();
|
||||
assert!(click(&mut s, 116.0, 200.0).is_empty(), "nothing paints inside");
|
||||
s.execute("stroke.set", &json!({"ids": [r.0], "align": "center"})).unwrap();
|
||||
s.execute("select.none", &json!({})).unwrap();
|
||||
assert!(click(&mut s, 84.0, 200.0).is_empty(), "a centred stroke doesn't reach");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_click_at_the_lens_maximum_selects_its_stroke() {
|
||||
let mut s = session();
|
||||
let l = id_of(&s.execute("shape.line", &json!({"x1": 100, "y1": 200, "x2": 300, "y2": 200})).unwrap());
|
||||
s.execute("stroke.set", &json!({"weight": 20, "profile": "lens"})).unwrap();
|
||||
s.execute("select.none", &json!({})).unwrap();
|
||||
assert_eq!(click(&mut s, 200.0, 209.0), [l], "the widest point");
|
||||
s.execute("select.none", &json!({})).unwrap();
|
||||
assert!(click(&mut s, 110.0, 209.0).is_empty(), "the thin end");
|
||||
// Fitted bounds take the profile in too.
|
||||
s.execute("select.set", &json!({"ids": [l.0]})).unwrap();
|
||||
assert_eq!(fitted(&mut s).y1, 210.0);
|
||||
}
|
||||
|
|
@ -0,0 +1,140 @@
|
|||
//! The width profile library (`stroke.widthProfile.*`): saved profiles kept with the preferences
|
||||
//! next to the built-in ones, applied by name with `stroke.set {profile}`.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::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 profile(s: &Session, id: NodeId) -> Option<WidthProfile> {
|
||||
s.doc().unwrap().doc.node(id).unwrap().appearance.stroke().unwrap().profile.clone()
|
||||
}
|
||||
|
||||
fn custom() -> WidthProfile {
|
||||
WidthProfile { points: vec![(0.0, 0.2, 0.2), (0.3, 1.6, 0.8), (1.0, 0.5, 0.5)] }
|
||||
}
|
||||
|
||||
/// Give the line `p` as its variable width (as the Width tool would).
|
||||
fn set_profile(s: &mut Session, id: NodeId, p: WidthProfile) {
|
||||
s.edit("Width", |d, _| {
|
||||
d.node_mut(id).unwrap().appearance.stroke_mut().unwrap().profile = Some(p);
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
fn ids(list: &Value) -> Vec<&str> {
|
||||
list["profiles"].as_array().unwrap().iter().map(|p| p["id"].as_str().unwrap()).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn add_list_apply_delete_reset() {
|
||||
let (mut s, id) = session_with_line();
|
||||
let built_in: Vec<&str> = WidthProfile::PRESETS.iter().map(|p| p.id).collect();
|
||||
let list = s.execute("stroke.widthProfile.list", &json!({})).unwrap();
|
||||
assert_eq!(ids(&list), built_in);
|
||||
assert_eq!(list["current"], "uniform");
|
||||
// Nothing to save from a plain stroke, or from a built-in profile.
|
||||
assert!(s.execute("stroke.widthProfile.add", &json!({})).is_err());
|
||||
s.execute("stroke.set", &json!({"profile": "lens"})).unwrap();
|
||||
assert!(s.execute("stroke.widthProfile.add", &json!({})).is_err());
|
||||
|
||||
set_profile(&mut s, id, custom());
|
||||
assert_eq!(s.execute("stroke.widthProfile.list", &json!({})).unwrap()["current"], "custom");
|
||||
let undo_depth = s.doc().unwrap().history.undo.len();
|
||||
assert_eq!(s.execute("stroke.widthProfile.add", &json!({})).unwrap()["name"], "Width Profile 1");
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), undo_depth, "the library is not part of the document");
|
||||
// Listed after the built-ins, and now the stroke's profile.
|
||||
let list = s.execute("stroke.widthProfile.list", &json!({})).unwrap();
|
||||
assert_eq!(ids(&list).last(), Some(&"Width Profile 1"));
|
||||
assert_eq!(list["current"], "Width Profile 1");
|
||||
let row = list["profiles"].as_array().unwrap().last().unwrap().clone();
|
||||
assert_eq!((row["builtIn"].clone(), row["points"][1].clone()), (json!(false), json!([0.3, 1.6, 0.8])));
|
||||
// The same profile can't be saved twice.
|
||||
assert!(s.execute("stroke.widthProfile.add", &json!({"name": "Again"})).is_err());
|
||||
|
||||
// Apply it by name to another stroke.
|
||||
let other = NodeId(s.execute("shape.line", &json!({"x1": 10, "y1": 150, "x2": 150, "y2": 150})).unwrap()["id"].as_u64().unwrap());
|
||||
assert!(profile(&s, other).is_none());
|
||||
s.execute("stroke.set", &json!({"profile": "Width Profile 1"})).unwrap();
|
||||
assert_eq!(profile(&s, other), Some(custom()));
|
||||
assert!(s.execute("stroke.set", &json!({"profile": "Nope"})).is_err());
|
||||
|
||||
// Delete it (the selected stroke's): strokes keep their widths.
|
||||
assert_eq!(s.execute("stroke.widthProfile.delete", &json!({})).unwrap()["deleted"], "Width Profile 1");
|
||||
assert_eq!(ids(&s.execute("stroke.widthProfile.list", &json!({})).unwrap()), built_in);
|
||||
assert_eq!(profile(&s, other), Some(custom()));
|
||||
assert!(s.execute("stroke.set", &json!({"profile": "Width Profile 1"})).is_err());
|
||||
|
||||
// Reset removes every saved profile.
|
||||
s.execute("stroke.widthProfile.add", &json!({"name": " Ribbon "})).unwrap();
|
||||
set_profile(&mut s, other, WidthProfile { points: vec![(0.0, 1.0, 0.0), (1.0, 0.0, 1.0)] });
|
||||
s.execute("stroke.widthProfile.add", &json!({"name": "Twist"})).unwrap();
|
||||
assert_eq!(s.prefs.width_profiles.iter().map(|p| p.name.as_str()).collect::<Vec<_>>(), ["Ribbon", "Twist"]);
|
||||
assert_eq!(s.execute("stroke.widthProfile.reset", &json!({})).unwrap()["removed"], 2);
|
||||
assert!(s.prefs.width_profiles.is_empty());
|
||||
assert!(s.execute("stroke.widthProfile.reset", &json!({})).is_err(), "nothing left to reset");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn built_ins_cant_be_deleted_and_names_stay_unique() {
|
||||
let (mut s, id) = session_with_line();
|
||||
set_profile(&mut s, id, custom());
|
||||
for taken in ["lens", "Taper Start", "UNIFORM"] {
|
||||
assert!(s.execute("stroke.widthProfile.add", &json!({"name": taken})).is_err(), "{taken}");
|
||||
}
|
||||
assert!(s.execute("stroke.widthProfile.add", &json!({"name": " "})).is_err());
|
||||
s.execute("stroke.widthProfile.add", &json!({"name": "Mine"})).unwrap();
|
||||
for b in ["lens", "Lens", "uniform", "wave"] {
|
||||
let e = s.execute("stroke.widthProfile.delete", &json!({"name": b})).unwrap_err().to_string();
|
||||
assert!(e.contains("built in"), "{b}: {e}");
|
||||
}
|
||||
// With a built-in profile selected, Delete has nothing to delete.
|
||||
s.execute("stroke.set", &json!({"profile": "pinch"})).unwrap();
|
||||
assert!(s.execute("stroke.widthProfile.delete", &json!({})).is_err());
|
||||
assert!(s.execute("stroke.widthProfile.delete", &json!({"name": "Other"})).is_err());
|
||||
assert_eq!(s.next_profile_name(), "Width Profile 1");
|
||||
assert_eq!(s.execute("stroke.widthProfile.delete", &json!({"name": "Mine"})).unwrap()["deleted"], "Mine");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn saved_profiles_round_trip_with_the_preferences() {
|
||||
let (mut s, id) = session_with_line();
|
||||
set_profile(&mut s, id, custom());
|
||||
s.execute("stroke.widthProfile.add", &json!({"name": "Mine"})).unwrap();
|
||||
let json = s.prefs.to_json();
|
||||
assert_eq!(json["widthProfiles"][0]["name"], "Mine");
|
||||
let back: Prefs = serde_json::from_value(json).unwrap();
|
||||
assert_eq!(back, s.prefs);
|
||||
// Without saved profiles nothing is written, and older preference files load with none.
|
||||
assert!(Prefs::default().to_json().get("widthProfiles").is_none());
|
||||
let old: Prefs = serde_json::from_value(json!({"keyboardIncrement": 2})).unwrap();
|
||||
assert!(old.width_profiles.is_empty());
|
||||
// Setting and resetting other preferences keeps them.
|
||||
s.execute("prefs.set", &json!({"key": "keyboardIncrement", "value": 3})).unwrap();
|
||||
s.execute("prefs.reset", &json!({})).unwrap();
|
||||
assert_eq!(s.prefs.width_profiles.len(), 1);
|
||||
// A restarted session (preferences applied from the saved file) lists them again.
|
||||
let mut fresh = Session::new();
|
||||
fresh.apply_prefs(back);
|
||||
assert!(fresh.resolve_profile("Mine").is_some_and(|p| p == Some(custom())));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_built_in_applies_and_the_params_doc_names_them() {
|
||||
let (mut s, id) = session_with_line();
|
||||
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);
|
||||
s.execute("stroke.set", &json!({"profile": p.id})).unwrap();
|
||||
assert_eq!(s.execute("stroke.widthProfile.list", &json!({})).unwrap()["current"], p.id);
|
||||
assert_eq!(profile(&s, id).map(|w| w.points), (p.id != "uniform").then(|| p.points.to_vec()));
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,100 @@
|
|||
//! Walking a [`BezPath`] as strokes see it: its subpaths (open or closed), their segments, unit
|
||||
//! tangents and corners. Shared by the stroke geometry (`vectorcraft-effects`) and the visual
|
||||
//! bounds and hit testing of strokes (`vectorcraft-doc`).
|
||||
|
||||
use kurbo::{BezPath, ParamCurve, ParamCurveDeriv, PathEl, PathSeg, Vec2};
|
||||
|
||||
/// Tangents meeting at more than about 1° (this cosine) make a corner: fitted dashes centre on it,
|
||||
/// variable-width strokes give it the stroke's join and miter spikes grow from it.
|
||||
pub const CORNER_COS: f64 = 0.9998;
|
||||
|
||||
/// Element ranges of the subpaths of `bp` (each with whether it is closed).
|
||||
pub fn subpaths(bp: &BezPath) -> Vec<(std::ops::Range<usize>, bool)> {
|
||||
let els = bp.elements();
|
||||
let mut out = vec![];
|
||||
let mut start = 0;
|
||||
for (i, el) in els.iter().enumerate() {
|
||||
match el {
|
||||
PathEl::MoveTo(_) if i > start => {
|
||||
out.push((start..i, false));
|
||||
start = i;
|
||||
}
|
||||
PathEl::ClosePath => {
|
||||
out.push((start..i + 1, true));
|
||||
start = i + 1;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if start < els.len() {
|
||||
out.push((start..els.len(), false));
|
||||
}
|
||||
// A lone MoveTo (or a MoveTo straight after a ClosePath) draws nothing.
|
||||
out.retain(|(r, _)| r.len() > 1);
|
||||
out
|
||||
}
|
||||
|
||||
/// The segments of one subpath (`els` starts with its MoveTo; a ClosePath adds the closing line).
|
||||
pub fn segments(els: &[PathEl]) -> Vec<PathSeg> {
|
||||
kurbo::segments(els.iter().copied()).collect()
|
||||
}
|
||||
|
||||
/// Unit tangent of `s` at `t`, robust at ends where control points coincide with the end point.
|
||||
pub 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 fn unit(v: Vec2) -> Vec2 {
|
||||
let h = v.hypot();
|
||||
if h > 1e-12 { v / h } else { Vec2::new(1.0, 0.0) }
|
||||
}
|
||||
|
||||
/// Do segments `a` and `b` (`b` following `a`) meet at a corner?
|
||||
pub fn kink(a: &PathSeg, b: &PathSeg) -> bool {
|
||||
tangent(a, 1.0).dot(tangent(b, 0.0)) < CORNER_COS
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn subpaths_and_tangents() {
|
||||
let mut bp = BezPath::new();
|
||||
bp.move_to((0.0, 0.0));
|
||||
bp.line_to((10.0, 0.0));
|
||||
bp.line_to((10.0, 10.0));
|
||||
bp.close_path();
|
||||
bp.move_to((20.0, 0.0));
|
||||
bp.curve_to((20.0, 0.0), (30.0, 0.0), (30.0, 10.0));
|
||||
let subs = subpaths(&bp);
|
||||
assert_eq!(subs, vec![(0..4, true), (4..6, false)]);
|
||||
let segs = segments(&bp.elements()[subs[0].0.clone()]);
|
||||
assert_eq!(segs.len(), 3, "the close adds a segment");
|
||||
assert!(kink(&segs[0], &segs[1]));
|
||||
let c = segments(&bp.elements()[subs[1].0.clone()]);
|
||||
// A handle on its anchor: the direction comes from the next control point.
|
||||
assert_eq!(tangent(&c[0], 0.0), Vec2::new(1.0, 0.0));
|
||||
assert_eq!(tangent(&c[0], 1.0), Vec2::new(0.0, 1.0));
|
||||
assert_eq!(unit(Vec2::ZERO), Vec2::new(1.0, 0.0));
|
||||
}
|
||||
}
|
||||
|
|
@ -7,6 +7,7 @@
|
|||
//! Coordinates are document points (1/72 in), y pointing down.
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
pub mod bez;
|
||||
pub mod hit;
|
||||
pub mod path;
|
||||
pub mod recognize;
|
||||
|
|
|
|||
|
|
@ -440,9 +440,9 @@ fn options_decimals_minify_responsive_artboard() {
|
|||
assert!(s.contains("M10.12 20"), "{s}");
|
||||
let s = export(&d, &ExportOptions::default());
|
||||
assert!(s.contains("width=\"200\" height=\"100\"") && s.contains("M10.123 20"), "{s}");
|
||||
// All art bounds.
|
||||
// All art bounds: the rectangle and half its 1 pt stroke (right-angle miters stay inside).
|
||||
let s = export(&d, &ExportOptions { artboard: None, ..Default::default() });
|
||||
assert!(s.contains("viewBox=\"0 0 43.877 34\""), "{s}");
|
||||
assert!(s.contains("viewBox=\"0 0 40.877 31\""), "{s}");
|
||||
let s = export(&d, &ExportOptions { object_ids: false, ..Default::default() });
|
||||
assert!(!s.contains("id=\"Layer_1\""));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -127,8 +127,7 @@ pub fn control_bar(app: &mut VectorcraftApp, ui: &mut Ui) {
|
|||
let link = ui.link(egui::RichText::new("Stroke:").size(12.0).color(t.text).underline()).on_hover_text("Stroke options");
|
||||
stroke_panel::popover(app, &link);
|
||||
stroke_panel::weight_field(app, ui, "cb-stroke", weight, 100.0);
|
||||
let profile = vectorcraft_doc::WidthProfile::id_of(shown_stroke.as_ref().and_then(|s| s.profile.as_ref()));
|
||||
if let Some(id) = stroke_panel::profile_dropdown(ui, profile) {
|
||||
if let Some(id) = stroke_panel::profile_dropdown(app, ui, shown_stroke.as_ref().and_then(|s| s.profile.as_ref())) {
|
||||
app.run("stroke.set", json!({"profile": id})).ok();
|
||||
}
|
||||
ui.add_space(4.0);
|
||||
|
|
|
|||
|
|
@ -599,7 +599,7 @@ fn stroke_notes(ui: &Ui, r: Rect, st: &vectorcraft_doc::StrokeLayer) {
|
|||
if let Some(p) = st.profile.as_ref().filter(|p| p.preset_id() != Some("uniform")) {
|
||||
let pr = Rect::from_min_size(pos2(x, r.center().y - 5.0), vec2(28.0, 10.0));
|
||||
if pr.right() <= r.right() {
|
||||
paint_profile(ui, pr, Some(p), t.text);
|
||||
paint_profile(ui, pr, Some(&p.points), t.text);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
//! Brushes panel: the document's brush library with rendered stroke previews. Clicking a brush
|
||||
//! applies it to the selected paths (and makes it the Paintbrush's current brush).
|
||||
|
||||
use std::cell::RefCell;
|
||||
use std::collections::HashMap;
|
||||
|
||||
use egui::{Sense, Ui, vec2};
|
||||
|
|
@ -26,43 +25,25 @@ pub fn brushes(app: &mut VectorcraftApp) -> (Vec<(String, String)>, Option<Strin
|
|||
(list, v["current"].as_str().map(str::to_string))
|
||||
}
|
||||
|
||||
/// A stroke preview for brush definition `def`, rendered at `size` pixels and cached by the
|
||||
/// definition's JSON.
|
||||
/// A stroke preview for brush definition `def`, rendered at `size` and cached by the definition's
|
||||
/// JSON.
|
||||
fn preview(ui: &Ui, def: &Value, size: egui::Vec2) -> Option<egui::TextureHandle> {
|
||||
thread_local! {
|
||||
static RENDERER: RefCell<vectorcraft_render::Renderer> = RefCell::new(vectorcraft_render::Renderer::new());
|
||||
static CACHE: RefCell<HashMap<String, egui::TextureHandle>> = RefCell::new(HashMap::new());
|
||||
}
|
||||
let ppp = ui.ctx().pixels_per_point() as f64;
|
||||
let (w, h) = (size.x as f64, size.y as f64);
|
||||
let key = format!("{w}x{h}@{ppp}:{def}");
|
||||
if let Some(t) = CACHE.with(|c| c.borrow().get(&key).cloned()) {
|
||||
return Some(t);
|
||||
}
|
||||
let mut doc = Document::new(w, h);
|
||||
doc.unknown.insert("brushes".into(), json!([def]));
|
||||
let name = def["name"].as_str()?.to_string();
|
||||
let mut ap = Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 1.0);
|
||||
ap.stroke_mut()?.brush = Some(name);
|
||||
// A gentle S-curve across the swatch.
|
||||
let mut bp = vectorcraft_geom::BezPath::new();
|
||||
let (x0, x1) = (h * 0.5, w - h * 0.5);
|
||||
bp.move_to((x0, h * 0.5));
|
||||
bp.curve_to((x0 + (x1 - x0) * 0.35, h * 0.1), (x0 + (x1 - x0) * 0.65, h * 0.9), (x1, h * 0.5));
|
||||
let id = doc.alloc_id();
|
||||
let l = doc.layers[0].id;
|
||||
doc.insert(Some(l), 0, Node::path(id, vectorcraft_geom::PathData::from_bezpath(&bp), ap)).ok()?;
|
||||
let img = RENDERER.with(|r| r.borrow_mut().render_region(&doc, vectorcraft_geom::Rect::new(0.0, 0.0, w, h), ppp, false));
|
||||
let color = egui::ColorImage::from_rgba_premultiplied([img.width as usize, img.height as usize], &img.pixels);
|
||||
let tex = ui.ctx().load_texture(format!("brush-{key}"), color, egui::TextureOptions::LINEAR);
|
||||
CACHE.with(|c| {
|
||||
let mut c = c.borrow_mut();
|
||||
if c.len() > 256 {
|
||||
c.clear();
|
||||
}
|
||||
c.insert(key, tex.clone());
|
||||
});
|
||||
Some(tex)
|
||||
widgets::doc_preview(ui, &format!("brush:{def}"), size, |w, h| {
|
||||
let mut doc = Document::new(w, h);
|
||||
doc.unknown.insert("brushes".into(), json!([def]));
|
||||
let name = def["name"].as_str()?.to_string();
|
||||
let mut ap = Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 1.0);
|
||||
ap.stroke_mut()?.brush = Some(name);
|
||||
// A gentle S-curve across the swatch.
|
||||
let mut bp = vectorcraft_geom::BezPath::new();
|
||||
let (x0, x1) = (h * 0.5, w - h * 0.5);
|
||||
bp.move_to((x0, h * 0.5));
|
||||
bp.curve_to((x0 + (x1 - x0) * 0.35, h * 0.1), (x0 + (x1 - x0) * 0.65, h * 0.9), (x1, h * 0.5));
|
||||
let id = doc.alloc_id();
|
||||
let l = doc.layers[0].id;
|
||||
doc.insert(Some(l), 0, Node::path(id, vectorcraft_geom::PathData::from_bezpath(&bp), ap)).ok()?;
|
||||
Some(doc)
|
||||
})
|
||||
}
|
||||
|
||||
fn selected_brush(app: &VectorcraftApp) -> Option<String> {
|
||||
|
|
|
|||
|
|
@ -441,4 +441,6 @@ mod tests_freeform;
|
|||
#[cfg(test)]
|
||||
mod tests_stroke;
|
||||
#[cfg(test)]
|
||||
mod tests_strokedepth;
|
||||
#[cfg(test)]
|
||||
mod tests_strokeux;
|
||||
|
|
|
|||
|
|
@ -1,9 +1,10 @@
|
|||
//! Stroke panel: weight spinner + presets, cap / corner / align toggles, miter limit, dashed line
|
||||
//! with three dash/gap pairs, arrowheads with drawn previews, arrow scale, width profiles.
|
||||
//! with three dash/gap pairs, arrowheads with rendered previews, arrow scale, width profiles.
|
||||
|
||||
use egui::{Color32, Pos2, Rect, Sense, Stroke, StrokeKind, Ui, pos2, vec2};
|
||||
use egui::{Color32, Rect, Sense, Stroke, StrokeKind, Ui, pos2, vec2};
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::{ArrowAlign, Arrowhead, Dash, LineCap, LineJoin, ProfilePreset, StrokeAlign, StrokeLayer, WidthProfile};
|
||||
use vectorcraft_color::{Color, Paint};
|
||||
use vectorcraft_doc::{Appearance, ArrowAlign, Arrowhead, Dash, Document, LineCap, LineJoin, Node, StrokeAlign, StrokeLayer, WidthProfile};
|
||||
use vectorcraft_engine::inspect::StrokeMixed;
|
||||
|
||||
use super::{character, current_stroke, pstate, set_pstate, stroke_mixed};
|
||||
|
|
@ -14,9 +15,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];
|
||||
|
||||
/// The silhouette drawn for profile `id` (None draws the plain uniform bar).
|
||||
fn profile_of(id: &str) -> Option<WidthProfile> {
|
||||
WidthProfile::preset(id).filter(|_| id != "uniform")
|
||||
/// A profile's points as its silhouette: none (the plain bar) for the uniform stroke.
|
||||
fn silhouette(points: &[(f64, f64, f64)]) -> Option<&[(f64, f64, f64)]> {
|
||||
(points != WidthProfile::PRESETS[0].points).then_some(points)
|
||||
}
|
||||
|
||||
/// The Arrowheads Align buttons: (alignment, `stroke.set` value, icon, tooltip).
|
||||
|
|
@ -64,14 +65,8 @@ pub fn dash_pattern(fields: &[Option<f64>; 6]) -> Vec<f64> {
|
|||
out
|
||||
}
|
||||
|
||||
fn arrow_label(a: Option<Arrowhead>) -> String {
|
||||
match a {
|
||||
None => "None".into(),
|
||||
Some(a) => {
|
||||
let s = format!("{a:?}");
|
||||
s.replace("Open", " (open)")
|
||||
}
|
||||
}
|
||||
fn arrow_label(a: Option<Arrowhead>) -> &'static str {
|
||||
a.map_or("None", Arrowhead::label)
|
||||
}
|
||||
|
||||
/// Apply Stroke panel options (`stroke.set` params): while the Type tool edits text, to the
|
||||
|
|
@ -269,10 +264,9 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
|
|||
});
|
||||
widgets::divider(ui);
|
||||
// Profile.
|
||||
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) {
|
||||
if let Some(id) = profile_dropdown(app, ui, st.as_ref().and_then(|s| s.profile.as_ref())) {
|
||||
set(app, json!({"profile": id}));
|
||||
}
|
||||
let can_flip = st.as_ref().is_some_and(|s| s.profile.is_some());
|
||||
|
|
@ -285,31 +279,34 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
|
|||
});
|
||||
}
|
||||
|
||||
/// Draw an arrowhead preview: a line with the head at the left (`start`) or right end.
|
||||
/// The document an arrowhead preview renders: a `w`×`h` pt line with head `a` at its left
|
||||
/// (`start`) or right end, its tip on the end point, drawn white (the preview is tinted).
|
||||
pub(super) fn arrow_doc(a: Option<Arrowhead>, start: bool, w: f64, h: f64) -> Option<Document> {
|
||||
let mut doc = Document::new(w, h);
|
||||
let mut ap = Appearance::basic(Paint::None, Paint::solid(Color::WHITE), 1.5);
|
||||
let st = ap.stroke_mut()?;
|
||||
// Heads of weight 2.5 pt: 10 pt long and wide.
|
||||
st.arrow_scale = (500.0 / 3.0, 500.0 / 3.0);
|
||||
st.arrow_align = ArrowAlign::Tip;
|
||||
if start {
|
||||
st.start_arrow = a
|
||||
} else {
|
||||
st.end_arrow = a
|
||||
}
|
||||
let mut bp = vectorcraft_geom::BezPath::new();
|
||||
bp.move_to((3.0, h / 2.0));
|
||||
bp.line_to((w - 3.0, h / 2.0));
|
||||
let (id, l) = (doc.alloc_id(), doc.layers[0].id);
|
||||
doc.insert(Some(l), 0, Node::path(id, vectorcraft_geom::PathData::from_bezpath(&bp), ap)).ok()?;
|
||||
Some(doc)
|
||||
}
|
||||
|
||||
/// Draw an arrowhead preview (the head as the canvas draws it) in `r`: a line with the head at the
|
||||
/// left (`start`) or right end.
|
||||
fn paint_arrow(ui: &Ui, r: Rect, a: Option<Arrowhead>, start: bool, color: Color32) {
|
||||
let y = r.center().y;
|
||||
let (tip_x, back) = if start { (r.left() + 4.0, 1.0) } else { (r.right() - 4.0, -1.0) };
|
||||
let s = Stroke::new(1.5, color);
|
||||
let line_from = if a.is_some() { tip_x + back * 8.0 } else { tip_x };
|
||||
let other = if start { r.right() - 4.0 } else { r.left() + 4.0 };
|
||||
ui.painter().line_segment([pos2(line_from, y), pos2(other, y)], s);
|
||||
let Some(a) = a else { return };
|
||||
let p = |dx: f32, dy: f32| -> Pos2 { pos2(tip_x + back * dx, y + dy) };
|
||||
let solid = |pts: Vec<Pos2>| egui::Shape::convex_polygon(pts, color, Stroke::NONE);
|
||||
let open = |pts: Vec<Pos2>| egui::Shape::closed_line(pts, s);
|
||||
let shape = match a {
|
||||
Arrowhead::Triangle => solid(vec![p(0.0, 0.0), p(9.0, -4.5), p(9.0, 4.5)]),
|
||||
Arrowhead::TriangleOpen => open(vec![p(0.0, 0.0), p(9.0, -4.5), p(9.0, 4.5)]),
|
||||
Arrowhead::Arrow => solid(vec![p(0.0, 0.0), p(10.0, -5.0), p(7.0, 0.0), p(10.0, 5.0)]),
|
||||
Arrowhead::ArrowOpen => egui::Shape::line(vec![p(9.0, -5.0), p(0.0, 0.0), p(9.0, 5.0)], s),
|
||||
Arrowhead::Circle => egui::Shape::circle_filled(p(4.5, 0.0), 4.5, color),
|
||||
Arrowhead::CircleOpen => egui::Shape::circle_stroke(p(4.5, 0.0), 4.0, s),
|
||||
Arrowhead::Square => egui::Shape::rect_filled(Rect::from_center_size(p(4.5, 0.0), vec2(8.0, 8.0)), 0.0, color),
|
||||
Arrowhead::SquareOpen => egui::Shape::rect_stroke(Rect::from_center_size(p(4.5, 0.0), vec2(8.0, 8.0)), 0.0, s, StrokeKind::Middle),
|
||||
Arrowhead::Diamond => solid(vec![p(0.0, 0.0), p(5.0, -5.0), p(10.0, 0.0), p(5.0, 5.0)]),
|
||||
Arrowhead::Bar => egui::Shape::line_segment([p(0.0, -6.0), p(0.0, 6.0)], Stroke::new(2.0, color)),
|
||||
};
|
||||
ui.painter().add(shape);
|
||||
if let Some(tex) = widgets::doc_preview(ui, &format!("arrow:{a:?}:{start}"), r.size(), |w, h| arrow_doc(a, start, w, h)) {
|
||||
ui.painter().image(tex.id(), r, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), color);
|
||||
}
|
||||
}
|
||||
|
||||
/// Dropdown whose button and items show drawn arrowhead previews. Returns Some(choice).
|
||||
|
|
@ -324,41 +321,42 @@ fn arrow_dropdown(ui: &mut Ui, id: &str, cur: Option<Arrowhead>, start: bool) ->
|
|||
let resp = resp.on_hover_text(if start { "Start arrowhead" } else { "End arrowhead" });
|
||||
let mut out = None;
|
||||
egui::Popup::menu(&resp).id(egui::Id::new(("arrow-pop", id))).show(|ui| {
|
||||
ui.set_min_width(140.0);
|
||||
let opts: Vec<Option<Arrowhead>> = std::iter::once(None).chain(Arrowhead::ALL.iter().copied().map(Some)).collect();
|
||||
for a in opts {
|
||||
let (row, rr) = ui.allocate_exact_size(vec2(140.0, 22.0), Sense::click());
|
||||
if a == cur {
|
||||
ui.painter().rect_filled(row, 0.0, t.row_selected);
|
||||
} else if rr.hovered() {
|
||||
ui.painter().rect_filled(row, 0.0, t.hover);
|
||||
ui.set_min_width(170.0);
|
||||
egui::ScrollArea::vertical().max_height(360.0).show(ui, |ui| {
|
||||
for a in std::iter::once(None).chain(Arrowhead::ALL.map(Some)) {
|
||||
let (row, rr) = ui.allocate_exact_size(vec2(170.0, 22.0), Sense::click());
|
||||
if a == cur {
|
||||
ui.painter().rect_filled(row, 0.0, t.row_selected);
|
||||
} else if rr.hovered() {
|
||||
ui.painter().rect_filled(row, 0.0, t.hover);
|
||||
}
|
||||
paint_arrow(ui, Rect::from_min_size(row.min + vec2(4.0, 0.0), vec2(56.0, 22.0)), a, start, t.text_strong);
|
||||
ui.painter().text(
|
||||
row.left_center() + vec2(66.0, 0.0),
|
||||
egui::Align2::LEFT_CENTER,
|
||||
arrow_label(a),
|
||||
egui::FontId::proportional(11.5),
|
||||
t.text,
|
||||
);
|
||||
if rr.clicked() {
|
||||
out = Some(a);
|
||||
ui.close();
|
||||
}
|
||||
}
|
||||
paint_arrow(ui, Rect::from_min_size(row.min + vec2(4.0, 0.0), vec2(56.0, 22.0)), a, start, t.text_strong);
|
||||
ui.painter().text(
|
||||
row.left_center() + vec2(66.0, 0.0),
|
||||
egui::Align2::LEFT_CENTER,
|
||||
arrow_label(a),
|
||||
egui::FontId::proportional(11.5),
|
||||
t.text,
|
||||
);
|
||||
if rr.clicked() {
|
||||
out = Some(a);
|
||||
ui.close();
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
out
|
||||
}
|
||||
|
||||
/// Draw a width profile's silhouette into `r`.
|
||||
pub fn paint_profile(ui: &Ui, r: Rect, prof: Option<&WidthProfile>, color: Color32) {
|
||||
/// Draw a width profile's silhouette (its points; None: the plain bar) into `r`.
|
||||
pub fn paint_profile(ui: &Ui, r: Rect, points: Option<&[(f64, f64, f64)]>, color: Color32) {
|
||||
let n = 32;
|
||||
let half = r.height() / 2.0 - 1.0;
|
||||
let mut top = vec![];
|
||||
let mut bot = vec![];
|
||||
for i in 0..=n {
|
||||
let tt = i as f64 / n as f64;
|
||||
let (l, rr) = prof.map(|p| p.at(tt)).unwrap_or((0.35, 0.35));
|
||||
let (l, rr) = points.map_or((0.35, 0.35), |p| WidthProfile::at_points(p, tt));
|
||||
let x = r.left() + tt as f32 * r.width();
|
||||
top.push(pos2(x, r.center().y - l as f32 * half));
|
||||
bot.push(pos2(x, r.center().y + rr as f32 * half));
|
||||
|
|
@ -376,31 +374,41 @@ pub fn paint_profile(ui: &Ui, r: Rect, prof: Option<&WidthProfile>, color: Color
|
|||
ui.painter().add(egui::Shape::mesh(mesh));
|
||||
}
|
||||
|
||||
pub(crate) fn profile_dropdown(ui: &mut Ui, cur: &str) -> Option<&'static str> {
|
||||
/// The Profile dropdown (Stroke panel, Control bar): the stroke's own silhouette on the button
|
||||
/// (a custom profile too), the built-in profiles and then the saved ones in the list. Returns the
|
||||
/// id or name picked.
|
||||
pub(crate) fn profile_dropdown(app: &VectorcraftApp, ui: &mut Ui, cur: Option<&WidthProfile>) -> Option<String> {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(100.0, 24.0), Sense::click());
|
||||
ui.painter().rect_filled(r, 2, t.input);
|
||||
ui.painter().rect_stroke(r, 2, Stroke::new(1.0, if resp.hovered() { t.text } else { t.input_border }), StrokeKind::Inside);
|
||||
let body = Rect::from_min_max(r.min + vec2(6.0, 5.0), pos2(r.right() - 20.0, r.bottom() - 5.0));
|
||||
paint_profile(ui, body, profile_of(cur).as_ref(), t.text_strong);
|
||||
paint_profile(ui, body, cur.and_then(|p| silhouette(&p.points)), t.text_strong);
|
||||
icons::paint(ui, "chevron-down", Rect::from_center_size(pos2(r.right() - 9.0, r.center().y), vec2(10.0, 10.0)), t.icon);
|
||||
let resp = resp.on_hover_text("Variable Width Profile");
|
||||
let mut out = None;
|
||||
egui::Popup::menu(&resp).show(|ui| {
|
||||
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);
|
||||
} else if rr.hovered() {
|
||||
ui.painter().rect_filled(row, 0.0, t.hover);
|
||||
egui::ScrollArea::vertical().max_height(360.0).show(ui, |ui| {
|
||||
// A divider between the built-in profiles and the saved ones.
|
||||
let mut divide = true;
|
||||
for e in app.session.profile_entries() {
|
||||
if !e.built_in && std::mem::take(&mut divide) {
|
||||
ui.separator();
|
||||
}
|
||||
let (row, rr) = ui.allocate_exact_size(vec2(170.0, 26.0), Sense::click());
|
||||
if e.matches(cur) {
|
||||
ui.painter().rect_filled(row, 0.0, t.row_selected);
|
||||
} else if rr.hovered() {
|
||||
ui.painter().rect_filled(row, 0.0, t.hover);
|
||||
}
|
||||
paint_profile(ui, Rect::from_min_size(row.min + vec2(6.0, 6.0), vec2(70.0, 14.0)), silhouette(e.points), t.text_strong);
|
||||
ui.painter().text(row.left_center() + vec2(84.0, 0.0), egui::Align2::LEFT_CENTER, e.label, egui::FontId::proportional(11.5), t.text);
|
||||
if rr.clicked() {
|
||||
out = Some(e.id.to_string());
|
||||
ui.close();
|
||||
}
|
||||
}
|
||||
paint_profile(ui, Rect::from_min_size(row.min + vec2(6.0, 6.0), vec2(70.0, 14.0)), profile_of(id).as_ref(), t.text_strong);
|
||||
ui.painter().text(row.left_center() + vec2(84.0, 0.0), egui::Align2::LEFT_CENTER, label, egui::FontId::proportional(11.5), t.text);
|
||||
if rr.clicked() {
|
||||
out = Some(id);
|
||||
ui.close();
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
out
|
||||
}
|
||||
|
|
@ -410,10 +418,20 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) {
|
|||
if menu_item(ui, if hidden { "Show Options" } else { "Hide Options" }, true, false) {
|
||||
set_pstate(ui.ctx(), "stroke-hide-options", !hidden);
|
||||
}
|
||||
menu_item(ui, "Add to Profiles", false, false);
|
||||
menu_item(ui, "Delete Profile", false, false);
|
||||
if menu_item(ui, "Reset Profile", current_stroke(app).is_some_and(|s| s.profile.is_some()), false) {
|
||||
set(app, json!({"profile": "uniform"}));
|
||||
ui.separator();
|
||||
if menu_item(ui, "Add to Profiles…", crate::menus::enabled(app, "stroke.widthProfile.add"), false) {
|
||||
let name = app.session.next_profile_name();
|
||||
let p = json!({"command": "stroke.widthProfile.add", "label": "Variable Width Profile", "params": {"name": name}});
|
||||
app.run("ui.paramDialog", p).ok();
|
||||
}
|
||||
// Deletes the selected stroke's saved profile.
|
||||
let shown = current_stroke(app).and_then(|s| s.profile);
|
||||
let saved = shown.as_ref().and_then(|p| app.session.profile_entry(Some(p))).is_some_and(|e| !e.built_in);
|
||||
if menu_item(ui, "Delete Profile", saved, false) {
|
||||
app.run("stroke.widthProfile.delete", json!({})).ok();
|
||||
}
|
||||
if menu_item(ui, "Reset Profiles", crate::menus::enabled(app, "stroke.widthProfile.reset"), false) {
|
||||
app.run("stroke.widthProfile.reset", json!({})).ok();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -437,9 +455,9 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
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!(silhouette(WidthProfile::PRESETS[0].points).is_none(), "uniform draws the plain bar");
|
||||
let lens = WidthProfile::lens();
|
||||
assert_eq!(silhouette(&lens.points), Some(lens.points.as_slice()));
|
||||
assert_eq!(arrow_label(Some(Arrowhead::CircleOpen)), "Circle (open)");
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,120 @@
|
|||
//! Headless frames of the Stroke panel's width profile library: the ≡ menu's Add to Profiles…,
|
||||
//! Delete Profile and Reset Profiles, and the Profile dropdown listing saved profiles.
|
||||
|
||||
use serde_json::json;
|
||||
use vectorcraft_doc::WidthProfile;
|
||||
|
||||
use super::tests_appearance::{click, frame_events, run, text_rect};
|
||||
use super::*;
|
||||
|
||||
fn app_with_line() -> VectorcraftApp {
|
||||
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default());
|
||||
run(&mut app, "file.new", json!({"width": 200, "height": 200}));
|
||||
run(&mut app, "shape.line", json!({"x1": 10, "y1": 100, "x2": 150, "y2": 100}));
|
||||
app
|
||||
}
|
||||
|
||||
fn custom() -> WidthProfile {
|
||||
WidthProfile { points: vec![(0.0, 0.3, 0.3), (0.6, 1.4, 1.4), (1.0, 0.0, 0.0)] }
|
||||
}
|
||||
|
||||
/// Give the selected line `p` as its variable width.
|
||||
fn set_profile(app: &mut VectorcraftApp, p: WidthProfile) {
|
||||
app.session
|
||||
.edit("Width", |d, sel| {
|
||||
let id = sel.objects[0];
|
||||
d.node_mut(id).unwrap().appearance.stroke_mut().unwrap().profile = Some(p);
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
fn panel_and_menu(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
stroke::show(app, ui);
|
||||
stroke::menu(app, ui);
|
||||
}
|
||||
|
||||
fn profile_points(app: &VectorcraftApp) -> Option<Vec<(f64, f64, f64)>> {
|
||||
current_stroke(app).and_then(|s| s.profile).map(|p| p.points)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn menu_saves_deletes_and_resets_profiles() {
|
||||
let ctx = egui::Context::default();
|
||||
let mut app = app_with_line();
|
||||
set_profile(&mut app, custom());
|
||||
let t = frame_events(&ctx, &mut app, vec![], panel_and_menu);
|
||||
click(&ctx, &mut app, text_rect(&t, " Add to Profiles…").center(), panel_and_menu);
|
||||
// A name dialog (OK runs stroke.widthProfile.add).
|
||||
let d = app.ui.dialog.clone().expect("name dialog");
|
||||
assert_eq!((d.str("__command"), d.str("name")), ("stroke.widthProfile.add".to_string(), "Width Profile 1".to_string()));
|
||||
app.ui.dialog.as_mut().unwrap().fields.insert("name".into(), json!("Mine"));
|
||||
crate::dialogs::confirm(&mut app).unwrap();
|
||||
assert_eq!(app.session.prefs.width_profiles[0].name, "Mine");
|
||||
// Saved profiles don't trip the Preferences dialog, which only edits preferences.
|
||||
crate::prefs_dialog::open(&mut app, None);
|
||||
crate::prefs_dialog::confirm(&mut app).unwrap();
|
||||
assert_eq!(app.session.prefs.width_profiles.len(), 1);
|
||||
|
||||
// Delete Profile removes the selected stroke's saved profile; the stroke keeps its widths.
|
||||
let t = frame_events(&ctx, &mut app, vec![], panel_and_menu);
|
||||
click(&ctx, &mut app, text_rect(&t, " Delete Profile").center(), panel_and_menu);
|
||||
assert!(app.session.prefs.width_profiles.is_empty());
|
||||
assert_eq!(profile_points(&app), Some(custom().points));
|
||||
|
||||
run(&mut app, "stroke.widthProfile.add", json!({"name": "A"}));
|
||||
let t = frame_events(&ctx, &mut app, vec![], panel_and_menu);
|
||||
click(&ctx, &mut app, text_rect(&t, " Reset Profiles").center(), panel_and_menu);
|
||||
assert!(app.session.prefs.width_profiles.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dropdown_lists_saved_profiles_and_applies_them() {
|
||||
let ctx = egui::Context::default();
|
||||
let mut app = app_with_line();
|
||||
set_profile(&mut app, custom());
|
||||
run(&mut app, "stroke.widthProfile.add", json!({"name": "Mine"}));
|
||||
run(&mut app, "stroke.set", json!({"profile": "uniform"}));
|
||||
let t = frame_events(&ctx, &mut app, vec![], stroke::show);
|
||||
// The dropdown sits right of the "Profile:" label.
|
||||
let label = text_rect(&t, "Profile:");
|
||||
let button = egui::pos2(label.right() + ctx.global_style().spacing.item_spacing.x + 50.0, label.center().y);
|
||||
click(&ctx, &mut app, button, stroke::show);
|
||||
let t = frame_events(&ctx, &mut app, vec![], stroke::show);
|
||||
for p in WidthProfile::PRESETS {
|
||||
text_rect(&t, p.label);
|
||||
}
|
||||
click(&ctx, &mut app, text_rect(&t, "Mine").center(), stroke::show);
|
||||
assert_eq!(profile_points(&app), Some(custom().points));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_arrowhead_preview_draws_its_head() {
|
||||
use vectorcraft_doc::Arrowhead;
|
||||
// Rendered on transparent: the head adds ink to its half of the preview.
|
||||
let ink = |a: Option<Arrowhead>, start: bool| {
|
||||
let d = stroke::arrow_doc(a, start, 56.0, 22.0).unwrap();
|
||||
let img = vectorcraft_render::Renderer::new().render_region(&d, vectorcraft_geom::Rect::new(0.0, 0.0, 56.0, 22.0), 2.0, false);
|
||||
let xs = if start { 0..56 } else { 56..112 };
|
||||
xs.flat_map(|x| (0..44).map(move |y| (x, y))).filter(|&(x, y)| img.pixel(x, y)[3] > 64).count()
|
||||
};
|
||||
let plain = (ink(None, false), ink(None, true));
|
||||
for a in Arrowhead::ALL {
|
||||
assert!(ink(Some(a), false) > plain.0 + 20, "{a:?} at the end");
|
||||
assert!(ink(Some(a), true) > plain.1 + 20, "{a:?} at the start");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arrowhead_dropdown_offers_the_new_heads() {
|
||||
let ctx = egui::Context::default();
|
||||
let mut app = app_with_line();
|
||||
let t = frame_events(&ctx, &mut app, vec![], stroke::show);
|
||||
// The end-arrowhead dropdown is the second 64 pt button right of the "Arrowheads:" label.
|
||||
let label = text_rect(&t, "Arrowheads:");
|
||||
let sp = ctx.global_style().spacing.item_spacing.x;
|
||||
click(&ctx, &mut app, egui::pos2(label.right() + 2.0 * sp + 64.0 + 32.0, label.center().y), stroke::show);
|
||||
let t = frame_events(&ctx, &mut app, vec![], stroke::show);
|
||||
click(&ctx, &mut app, text_rect(&t, "Barbed").center(), stroke::show);
|
||||
assert_eq!(current_stroke(&app).and_then(|s| s.end_arrow), Some(vectorcraft_doc::Arrowhead::Barbed));
|
||||
}
|
||||
|
|
@ -60,7 +60,7 @@ fn library_submenus_are_disabled_placeholders() {
|
|||
#[test]
|
||||
fn stroke_profiles_have_descriptive_names() {
|
||||
let labels: Vec<&str> = vectorcraft_doc::WidthProfile::PRESETS.iter().map(|p| p.label).collect();
|
||||
assert_eq!(labels, ["Uniform", "Lens", "Taper Start", "Taper End"]);
|
||||
assert_eq!(labels, ["Uniform", "Lens", "Taper Start", "Taper End", "Pinch", "Teardrop", "Wave"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
|
|
@ -1027,3 +1027,33 @@ pub fn drag_source(ui: &Ui, resp: &Response, drag: impl FnOnce() -> PanelDrag) {
|
|||
egui::DragAndDrop::set_payload(ui.ctx(), drag());
|
||||
}
|
||||
}
|
||||
|
||||
/// A small document rendered to a texture at the screen's pixel density (stroke previews: brushes,
|
||||
/// arrowheads), transparent where nothing paints. `build` makes the `w`×`h` pt document only when
|
||||
/// the texture for `key` at this size isn't cached yet.
|
||||
pub fn doc_preview(ui: &Ui, key: &str, size: Vec2, build: impl FnOnce(f64, f64) -> Option<vectorcraft_doc::Document>) -> Option<egui::TextureHandle> {
|
||||
use std::cell::RefCell;
|
||||
use std::collections::HashMap;
|
||||
thread_local! {
|
||||
static RENDERER: RefCell<vectorcraft_render::Renderer> = RefCell::new(vectorcraft_render::Renderer::new());
|
||||
static CACHE: RefCell<HashMap<String, egui::TextureHandle>> = RefCell::new(HashMap::new());
|
||||
}
|
||||
let ppp = ui.ctx().pixels_per_point() as f64;
|
||||
let (w, h) = (size.x as f64, size.y as f64);
|
||||
let key = format!("{w}x{h}@{ppp}:{key}");
|
||||
if let Some(t) = CACHE.with(|c| c.borrow().get(&key).cloned()) {
|
||||
return Some(t);
|
||||
}
|
||||
let doc = build(w, h)?;
|
||||
let img = RENDERER.with(|r| r.borrow_mut().render_region(&doc, vectorcraft_geom::Rect::new(0.0, 0.0, w, h), ppp, false));
|
||||
let color = egui::ColorImage::from_rgba_premultiplied([img.width as usize, img.height as usize], &img.pixels);
|
||||
let tex = ui.ctx().load_texture(format!("preview-{key}"), color, egui::TextureOptions::LINEAR);
|
||||
CACHE.with(|c| {
|
||||
let mut c = c.borrow_mut();
|
||||
if c.len() > 256 {
|
||||
c.clear();
|
||||
}
|
||||
c.insert(key, tex.clone());
|
||||
});
|
||||
Some(tex)
|
||||
}
|
||||
|
|
|
|||
29
docs/mcp.md
29
docs/mcp.md
|
|
@ -484,4 +484,33 @@ and flat-colour regions, plus `regions` (bounds, and `id` for objects) for the `
|
|||
|
||||
```json
|
||||
{"name":"run_command","arguments":{"command":"flattener.preview","params":{"highlight":"allRasterized","preset":"low"}}}
|
||||
|
||||
## Width profiles
|
||||
|
||||
The Profile list holds the built-in variable-width profiles (`uniform`, `lens`, `taperStart`, `taperEnd`, `pinch`,
|
||||
`teardrop`, `wave`) and profiles saved from strokes; saved ones are kept with the preferences, not in the document
|
||||
(no undo step). `stroke.widthProfile.list` returns every row (`id`, `label`, `builtIn`, `points` as `[t, left,
|
||||
right]`) and `current`, the selected stroke's profile (`"custom"` when it isn't listed).
|
||||
`stroke.widthProfile.add {name?}` saves the selected stroke's variable width (default name "Width Profile N"),
|
||||
`stroke.widthProfile.delete {name?}` removes a saved one (default: the selected stroke's; built-ins can't be deleted)
|
||||
and `stroke.widthProfile.reset` removes every saved one. `stroke.set {profile}` takes a built-in id or a saved name.
|
||||
|
||||
```json
|
||||
{"name":"run_command","arguments":{"command":"stroke.widthProfile.add","params":{"name":"Ribbon"}}}
|
||||
{"name":"run_command","arguments":{"command":"stroke.set","params":{"ids":[9],"profile":"Ribbon"}}}
|
||||
```
|
||||
|
||||
## Arrowheads
|
||||
|
||||
`stroke.set {startArrow, endArrow}` takes any of 40 generated heads by name (the params doc lists them; `null` for
|
||||
none): arrows (`Arrow`, `Barbed`, `Concave`, `DoubleArrow`, `HalfArrowLeft`/`Right`, `Chevron`, `Feather`,
|
||||
`Swallowtail`…), filled and open shapes (`Triangle`, `Circle`, `Oval`, `Target`, `Tag`, `Diamond`, `Hexagon`, `Star`,
|
||||
the `…Open` rings…) and marks (`Bar`, `DoubleBar`, `DotOnBar`, `Slash`, `DoubleSlash`, `Bracket`, `Fork`, `Cross`,
|
||||
`Plus`). Each head fits a box about four times its weight (stroke weight × `arrowScale`) long and wide.
|
||||
|
||||
## Stroke bounds and clicks
|
||||
|
||||
Visual bounds (Fit to Selected Art, Rasterize, exporting all art) and clicks on the canvas take
|
||||
in the whole stroke as drawn: arrowheads, inside/outside alignment (an outside stroke is hit outside the path, an
|
||||
inside one inside it), the width profile's width where you click, projecting caps and miter spikes up to the miter
|
||||
limit. A rectangle's right-angle miters stay within half the weight of its edges.
|
||||
|
|
|
|||
Loading…
Reference in New Issue