mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-14 19:26:56 -04:00
M3.63: visual bounds and clicks take in the whole stroke
Paths and compound paths now measure their visual bounds along their shape: the body (inside/outside alignment, the width profile's widest point), the tips of the miter spikes the limit keeps, projecting caps' corners and the arrowheads' reach. A rectangle's right-angle miters stay within half the weight (Fit to Selected Art, Rasterize and all-art SVG export get tighter, exact boxes); Appearance::outset is the conservative reach for anything else. Clicks hit what the strokes paint: outside strokes outside the path (0.8 x the weight out), inside ones inside, the profile's width where clicked, caps, spikes and heads. Click culling keeps a cheap box (hit test 0.34 ms per click on the 50k-path budget). The subpath/tangent helpers move from the stroke geometry into vectorcraft_geom::bez so both share them; a cross-check test holds every painted point of the real stroke geometry inside the bounds and hittable. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
fdad11a99c
commit
c1dc860f1d
13 files changed
+839
-107
No files matched your search
@@ -366,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
|
||||
}
|
||||
@@ -403,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 {
|
||||
@@ -656,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) {
|
||||
@@ -1067,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");
|
||||
}
|
||||
@@ -1080,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]
|
||||
|
||||
+17
-13
@@ -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;
|
||||
|
||||
+30
-1
@@ -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,40 +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;
|
||||
@@ -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}%"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -846,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;
|
||||
|
||||
@@ -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,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\""));
|
||||
}
|
||||
|
||||
@@ -507,3 +507,10 @@ none): arrows (`Arrow`, `Barbed`, `Concave`, `DoubleArrow`, `HalfArrowLeft`/`Rig
|
||||
`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.
|
||||
Reference in new issue
Block a user