Files
vectorcraft/crates/doc/src/reach.rs
T
BFlat e0a8fdae5b Merge pull request #6958 from dotCooCoo/fix/bevel-join-visual-bounds
Transform: a bevel stops at the corners of the segments it joins with Use Preview Bounds
2026-11-13 05:23:09 +07:00

1002 lines
53 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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. Butt caps end at the path's end points,
//! and a bevel ends at the corners of the two segments it joins.
use vectorcraft_geom::bez::{CORNER_COS, has_length, kink, segments, subpaths, tangent, unit};
use vectorcraft_geom::hit::fill_contains;
use vectorcraft_geom::kurbo::{ParamCurveArclen, ParamCurveExtrema, ParamCurveNearest};
use vectorcraft_geom::{BezPath, FillRule, ParamCurve, PathSeg, Point, Rect, Shape, Vec2};
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> {
segments(els).into_iter().filter(has_length).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]> {
// cos(half the turn) is sin(half the angle between the segments): the miter is side / it.
let half = ((1.0 + t_in.dot(t_out)) / 2.0).max(0.0).sqrt();
if half * limit < 1.0 {
return None;
}
let ([a, b], out) = bevel(at, t_in, t_out, side)?;
Some([a, at + out * (side / half), b])
}
/// The bevel at a corner with tangents `t_in`, `t_out` of a stroke reaching `side` from its path:
/// the outer corners of the two segments' strokes and the unit direction out of the turn, or
/// `None` when the path doesn't turn there.
fn bevel(at: Point, t_in: Vec2, t_out: Vec2, side: f64) -> Option<([Point; 2], Vec2)> {
if t_in.dot(t_out) >= CORNER_COS {
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 + outer(t_out) * side], out))
}
/// 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], lens: &[f64], total: f64, closed: bool, i: usize, t: f64) -> (f64, f64) {
let n = segs.len();
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
}
/// Does the curvature of `seg` stay below one over `side` all along it? A curve's first derivative
/// is a mix of its three control differences, and its second a mix of their two differences, which
/// bound the curvature from above. Then the edges of a stroke reaching `side` from it do not fold
/// back, and those edges reach farthest at the ends of `seg` or where its tangent is square to an
/// axis.
fn gentle(seg: &PathSeg, side: f64) -> bool {
let c = seg.to_cubic();
let d = [c.p1 - c.p0, c.p2 - c.p1, c.p3 - c.p2].map(|v| v * 3.0);
let along = unit(d[0] + d[1] + d[2]);
let slowest = d.iter().map(|v| v.dot(along)).fold(f64::INFINITY, f64::min);
let bend = 2.0 * (d[1] - d[0]).hypot().max((d[2] - d[1]).hypot());
slowest > 0.0 && side * bend < slowest * slowest
}
/// The box a stroke reaching `side` from `seg` paints across it, from the directions its tangent
/// can take: the tangent is a mix of the nonzero control differences, so when their directions all
/// lie within a quarter turn of the directions' sum, every point across the stroke is in `seg`'s
/// box moved `side` along a normal of one of those directions. `None` when they do not. A handle on
/// its end point adds no direction, so a straight curve with one measures as a line.
fn turning_box(seg: &PathSeg, side: f64) -> Option<Rect> {
let c = seg.to_cubic();
let d = [c.p1 - c.p0, c.p2 - c.p1, c.p3 - c.p2];
let moving = || d.iter().map(|v| (*v, v.hypot())).filter(|(_, h)| *h > 0.0).map(|(v, h)| v / h);
let along = unit(moving().fold(Vec2::ZERO, |s, v| s + v));
if moving().any(|v| v.dot(along) <= 0.0) {
return None;
}
let angle = |v: &Vec2| along.cross(*v).atan2(along.dot(*v));
let lo = moving().min_by(|a, b| angle(a).total_cmp(&angle(b)))?;
let hi = moving().max_by(|a, b| angle(a).total_cmp(&angle(b)))?;
let n = |t: Vec2| (Vec2::new(t.y, -t.x) * side).to_point();
let one_side = round_join_box(Point::ZERO, lo, hi, side).unwrap_or_else(|| Rect::from_points(n(lo), n(hi)));
let reach = one_side.union(Rect::new(-one_side.x1, -one_side.y1, -one_side.x0, -one_side.y0));
let b = ParamCurveExtrema::bounding_box(seg);
Some(Rect::new(b.x0 + reach.x0, b.y0 + reach.y0, b.x1 + reach.x1, b.y1 + reach.y1))
}
/// The box a stroke reaching `side` from `seg` paints when it stops square at both ends of `seg`:
/// the corners at the ends and the points across the stroke where the tangent is square to an axis.
/// A curve that bends tighter than `side` is halved up to `depth` times, and a piece still bending
/// that much is measured by [`turning_box`], or grown by `side` on every side when its tangent
/// turns too far.
fn swept_box(seg: &PathSeg, side: f64, depth: u32) -> Rect {
if !matches!(seg, PathSeg::Line(_)) && !gentle(seg, side) {
if depth == 0 {
return turning_box(seg, side).unwrap_or_else(|| ParamCurveExtrema::bounding_box(seg).inflate(side, side));
}
return swept_box(&seg.subsegment(0.0..0.5), side, depth - 1).union(swept_box(&seg.subsegment(0.5..1.0), side, depth - 1));
}
let across = |t: f64| {
let (p, tn) = (seg.eval(t), tangent(seg, t));
let n = Vec2::new(-tn.y, tn.x) * side;
[p + n, p - n]
};
let [a, b] = across(0.0);
seg.extrema().into_iter().chain([1.0]).flat_map(across).fold(Rect::from_points(a, b), |r, p| r.union_pt(p))
}
/// The box of the arc a round join paints `side` from `at`, where the path turns from tangent
/// `t_in` to `t_out`: the arc runs on the outer side of the turn, and its box comes from its ends
/// and the points on it square to an axis. A joint where the path turns back on itself is given
/// the whole disc's box, and one where it does not turn paints no arc (`None`).
fn round_join_box(at: Point, t_in: Vec2, t_out: Vec2, side: f64) -> Option<Rect> {
let (t_in, t_out) = (unit(t_in), unit(t_out));
let turn = t_in.cross(t_out);
if turn.abs() < 1e-12 {
return (t_in.dot(t_out) < 0.0).then(|| Rect::from_center_size(at, (2.0 * side, 2.0 * side)));
}
let s = turn.signum();
let outer = |t: Vec2| Vec2::new(t.y, -t.x) * s;
let (a, b) = (outer(t_in), outer(t_out));
let axes = [Vec2::new(1.0, 0.0), Vec2::new(-1.0, 0.0), Vec2::new(0.0, 1.0), Vec2::new(0.0, -1.0)];
let arc = Rect::from_points(at + a * side, at + b * side);
Some(axes.into_iter().filter(|d| a.cross(*d) * s >= 0.0 && d.cross(b) * s >= 0.0).fold(arc, |r, d| r.union_pt(at + d * side)))
}
/// 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, bevels, projecting caps,
/// arrowheads, an alignment that depends on whether the path is closed, or butt caps, which
/// stop at the ends of an open path? Else it reaches evenly round it.
fn needs_shape(&self) -> bool {
self.join != LineJoin::Round
|| self.cap != LineCap::Round
|| self.start_arrow.is_some()
|| self.end_arrow.is_some()
|| self.align != StrokeAlign::Center
}
/// Does this stroke stop at the corners of its segments along a subpath that is `closed` or
/// open, where its caps are butt caps on an open subpath or its joins are bevels or miters?
/// Dashes with round or projecting caps can end at a corner, so with them only butt caps stop.
fn stops_at_corners(&self, closed: bool) -> bool {
let capped_dashes = self.cap != LineCap::Butt && self.dash.as_ref().is_some_and(Dash::is_dashed);
(self.cap == LineCap::Butt && !closed) || (self.join != LineJoin::Round && !capped_dashes)
}
/// Does this stroke reach past a subpath that is `closed` or open by a margin that follows the
/// direction of its segments at their corners: where it stops at them
/// ([`Self::stops_at_corners`]) or paints miter spikes, which dashes with round or projecting
/// caps also paint?
fn reaches_by_corners(&self, closed: bool) -> bool {
self.stops_at_corners(closed) || self.join == LineJoin::Miter
}
/// The box this stroke's body paints along `segs`, the segments with length of a subpath, short
/// of its caps, joined with `within`: each segment's [`swept_box`] and, with round joins on an
/// open subpath, the arc each joint between segments paints ([`round_join_box`]), a shallow one
/// included. A bevel stays inside the boxes of the segments it joins. `None` when `segs` is
/// empty. Butt dashes stop inside the same box. The caps other than butt ones, the heads and
/// miter tips are measured apart. A segment in the middle is measured only when its own box
/// grown by `side`, which holds everything it paints, is not inside the box so far, which
/// starts as `within` and the first and last segments' boxes.
fn body_along(&self, segs: &[PathSeg], side: f64, within: Rect) -> Option<Rect> {
let (first, last) = (segs.first()?, segs.last()?);
let ends = swept_box(first, side, 3).union(swept_box(last, side, 3)).union(within);
let body = segs.iter().skip(1).take(segs.len().saturating_sub(2)).fold(ends, |b, s| {
if b.contains_rect(ParamCurveExtrema::bounding_box(s).inflate(side, side)) { b } else { b.union(swept_box(s, side, 3)) }
});
if self.join != LineJoin::Round {
return Some(body);
}
Some(joints(segs, false).fold(body, |b, (at, t_in, t_out)| round_join_box(at, t_in, t_out, side).map_or(b, |j| b.union(j))))
}
/// The box this stroke paints along `bp` (geometric bounds `bounds`): an open subpath with butt
/// caps, and every subpath when the joins are bevels or miters, by [`Self::body_along`] with its
/// caps, every other subpath by the box of its segments grown by its reach, with `tips` and,
/// when `measure_tips`, the miter spikes the limit keeps, the corners of projecting caps and the
/// reach of the arrowheads round their end points.
fn reach_along(&self, bp: &BezPath, bounds: Rect, tips: Option<Rect>, measure_tips: bool) -> Rect {
let els = bp.elements();
let subs = subpaths(bp);
let mut out = tips.map_or(bounds, |t| bounds.union(t));
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);
// A butt cap stops at an open subpath's end, and a bevel, or a miter the limit bevels, at
// the two segments' corners, so such a subpath is measured along its segments. Every
// subpath reaches evenly unless one is measured that way; only then is each subpath's own
// box needed.
let even_only = !subs.iter().any(|s| self.stops_at_corners(s.1));
let mut even = even_only.then_some(bounds);
for (r, closed) in subs {
let side = self.side_reach(closed);
let butt = self.cap == LineCap::Butt && !closed;
let along = self.stops_at_corners(closed);
if !along {
side_max = side_max.max(if capped_dashes { side * std::f64::consts::SQRT_2 } else { side });
}
if side <= 0.0 {
continue;
}
let square = self.cap == LineCap::Square && !closed;
let miters = measure_tips && self.join == LineJoin::Miter;
if !(along || square || miters || !even_only) {
continue;
}
let segs = drawn(&els[r.clone()]);
if miters {
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 along {
// The path's bounds are in the box already, so only the segments that can reach
// past them are measured.
match self.body_along(&segs, side, out) {
Some(body) => out = out.union(body),
// A subpath with no length paints at most its caps' dot.
None if !butt => {
let dot = segments(&els[r]).iter().map(ParamCurveExtrema::bounding_box).reduce(|a, b| a.union(b));
out = dot.map_or(out, |d| out.union(d.inflate(side, side)));
}
None => {}
}
if self.cap == LineCap::Round && !closed {
for (at, _) in ends(&segs).into_iter().flatten() {
out = out.union(Rect::from_center_size(at, (2.0 * side, 2.0 * side)));
}
}
} else if !even_only {
even = vectorcraft_geom::union_opt(even, segs.iter().map(ParamCurveExtrema::bounding_box).reduce(|a, b| a.union(b)));
}
if square {
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)));
}
even.map_or(out, |e| out.union(e.inflate(side_max, side_max)))
}
/// 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. An open subpath
/// with butt caps does not reach past its ends, and a bevel does not reach round its corner
/// ([`Self::body_along`]).
pub fn bounds_on(&self, bp: &BezPath, bounds: Rect) -> Rect {
self.reach_along(bp, bounds, None, true)
}
/// The tip of the miter spike this stroke paints where segment `b` follows segment `a` in a
/// subpath of its path (`closed`: a closed one), both with length ([`has_length`]), or `None`
/// when the join is not a miter, the path does not turn there or the miter limit bevels it.
pub fn miter_tip(&self, closed: bool, a: &PathSeg, b: &PathSeg) -> Option<Point> {
let side = self.side_reach(closed);
if self.join != LineJoin::Miter || side <= 0.0 {
return None;
}
miter(a.end(), tangent(a, 1.0), tangent(b, 0.0), side, self.miter_limit).map(|[_, tip, _]| tip)
}
/// [`Self::bounds_on`] with the miter spikes' tips taken from `tips`, the box of the tips of
/// every joint of `bp` ([`Self::miter_tip`]) or `None` when there are none, instead of measured
/// along `bp`. It measures the projecting caps and arrowheads as [`Self::bounds_on`] does.
pub fn bounds_with_tips(&self, bp: &BezPath, bounds: Rect, tips: Option<Rect>) -> Rect {
self.reach_along(bp, bounds, tips, false)
}
/// 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 the width of every segment that can reach `p`, as the
/// wider side of a farther one can paint past the 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);
// Past a butt cap or a bevel the stroke stops at the segments' corners. A width profile
// keeps the round reach at the ends.
let squared = self.profile.is_none() && self.stops_at_corners(closed);
// Every segment near enough to reach `p`: with a width profile the wider side of a
// farther segment can paint past the nearest one, so the nearest alone is not enough.
let near = segs.iter().enumerate().filter_map(|(i, s)| {
let nr = s.nearest(p, 1e-9);
(nr.distance_sq.sqrt() <= full + tol).then_some((i, nr))
});
// The lengths are only needed when a profiled candidate reaches `p`; compute them
// once for the whole subpath rather than once per candidate.
let mut lengths: Option<(Vec<f64>, f64)> = None;
for (i, nr) in near {
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 (lens, total) = lengths.get_or_insert_with(|| {
let lens: Vec<f64> = segs.iter().map(|s| s.arclen(ARCLEN_ACCURACY)).collect();
let total = lens.iter().sum::<f64>().max(1e-12);
(lens, total)
});
let (l, rt) = profile_at(pr, &segs, lens, *total, 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) && (!squared || self.paints_at_end(&segs, closed, (i, nr.t), p, half, tol))
{
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
}
/// Does this stroke, where it stops at the corners of its segments ([`Self::stops_at_corners`]),
/// paint `p`, which is within `side` (and `tol`) of segment `i` of the subpath `segs`
/// (`closed`: a closed one) and nearest to it at `t`? It does when `p` is not past the end of
/// the segment that `t` is at, by more than `tol`, and past it when a round cap or a round join
/// paints there, when the path does not turn at that end, or when `p` is within `tol` of the
/// bevel's triangle. Projecting caps and
/// miter spikes are checked apart. A curve that bends more tightly than `side` ([`gentle`])
/// counts as painting round its ends.
fn paints_at_end(&self, segs: &[PathSeg], closed: bool, (i, t): (usize, f64), p: Point, side: f64, tol: f64) -> bool {
let (n, at_start) = (segs.len(), t <= 1e-9);
let Some(seg) = segs.get(i) else { return true };
if (!at_start && t < 1.0 - 1e-9) || !(matches!(seg, PathSeg::Line(_)) || gentle(seg, side)) {
return true;
}
let (at, out) = if at_start { (seg.start(), -tangent(seg, 0.0)) } else { (seg.end(), tangent(seg, 1.0)) };
if (p - at).dot(out) <= tol {
return true;
}
// The segment on the other side of this end, `None` at the end of an open subpath.
let other = if at_start { (i > 0 || closed).then(|| (i + n - 1) % n) } else { (i + 1 < n || closed).then(|| (i + 1) % n) };
let Some(other) = other.and_then(|o| segs.get(o)) else { return self.cap == LineCap::Round };
if self.join == LineJoin::Round {
return true;
}
let (t_in, t_out) = if at_start { (tangent(other, 1.0), tangent(seg, 0.0)) } else { (tangent(seg, 1.0), tangent(other, 0.0)) };
// A joint where the path does not turn has no bevel: the stroke runs on through it.
bevel(at, t_in, t_out, side).is_none_or(|([a, b], _)| in_convex(&[at, a, b], p, tol))
}
/// Is `p` within `tol` of where the start (`end == false`) or end arrowhead at `at` (the path
/// leaving along `out`) can paint? A head reaches [`crate::Arrowhead::reach`] weights from its tip and
/// [`crate::Arrowhead::half_width`] either side of its line (4.6 and 2 for most). 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 Some(kind) = (if end { &self.end_arrow } else { &self.start_arrow }) else { return false };
let (hw, v) = (self.arrow_weight(end), p - at);
match self.arrow_align {
ArrowAlign::Tip => v.hypot() <= kind.reach() * hw + tol,
ArrowAlign::Extend => {
let along = v.dot(out);
v.cross(out).abs() <= kind.half_width() * hw + tol
&& along <= kind.length() * hw + self.width / 2.0 + tol
&& along >= -kind.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.stroked_bounds_by(bounds, |s| {
let bp: &BezPath = shape.get_or_insert_with(|| path.take().map(|f| f()).unwrap_or_default());
s.bounds_on(bp, bounds)
})
}
/// [`Self::stroked_bounds`] with the box of each visible stroke that needs the path's shape
/// (miter spikes, bevels, projecting or butt caps, arrowheads or an alignment) given by `shaped`.
pub fn stroked_bounds_by(&self, bounds: Rect, mut shaped: impl FnMut(&StrokeLayer) -> Rect) -> Rect {
self.painted_strokes().fold(bounds, |acc, s| {
if !s.needs_shape() {
let o = s.side_reach(false);
return acc.union(bounds.inflate(o, o));
}
acc.union(shaped(s))
})
}
/// Does this appearance have a visible stroke that stops at the corners of its segments on an
/// open subpath or a closed one, or paints miter spikes: butt caps, or bevel and miter joins
/// ([`StrokeLayer::reaches_by_corners`])?
pub fn has_cornered_stroke(&self) -> bool {
self.painted_strokes().any(|s| s.reaches_by_corners(false))
}
/// Does a visible stroke of this appearance that stops at the corners of its segments (butt
/// caps on an open subpath, bevel and miter joins) or paints miter spikes, dashed ones with
/// round or projecting caps included, run along a subpath of `bp` where it does so, with a
/// segment that starts or ends at a slant, not square to the axes, once placed by `a`? The
/// visual box's margin at those corners, and at miter tips, depends on their direction, which a
/// scale out of proportion changes.
pub fn has_slanted_stroke_corners(&self, bp: &BezPath, a: vectorcraft_geom::Affine) -> bool {
if !self.has_cornered_stroke() {
return false;
}
let slanted = |v: Vec2| {
let d = unit(a * v.to_point() - a * Point::ZERO);
(d.x * d.y).abs() > 1e-9
};
let els = bp.elements();
let subs = subpaths(bp);
let cornered = |closed: bool| self.painted_strokes().any(|s| s.reaches_by_corners(closed));
subs.into_iter()
.filter(|(_, closed)| cornered(*closed))
.any(|(r, _)| drawn(&els[r]).iter().any(|s| slanted(tangent(s, 0.0)) || slanted(tangent(s, 1.0))))
}
/// 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))
}
}
/// How many symbol instances [`crate::Node::has_slanted_stroke_corners`] looks into for one object
/// before it answers yes without looking further.
pub const SLANT_INSTANCES: usize = 4096;
impl crate::Node {
/// Does a visible stroke of this object, of an object in it or of an object in a symbol
/// instance's art from `symbols`, placed by `a`, stop at the corners of a segment at a slant
/// ([`Appearance::has_slanted_stroke_corners`])? Past [`SLANT_INSTANCES`] instances it answers
/// yes.
pub fn has_slanted_stroke_corners(&self, symbols: &crate::SymbolBoxes, a: vectorcraft_geom::Affine) -> bool {
self.slanted_corners(symbols, a, &std::cell::Cell::new(SLANT_INSTANCES))
}
/// [`Self::has_slanted_stroke_corners`] with `left` instances still to look into.
fn slanted_corners(&self, symbols: &crate::SymbolBoxes, a: vectorcraft_geom::Affine, left: &std::cell::Cell<usize>) -> bool {
if let crate::NodeKind::SymbolInstance { symbol, xf } = &self.kind {
let Some(l) = left.get().checked_sub(1) else { return true };
left.set(l);
return symbols.measure_art(symbol, |art| art.slanted_corners(symbols, a * *xf, left)).unwrap_or(false);
}
(self.appearance.has_cornered_stroke() && self.stroke_path().is_some_and(|bp| self.appearance.has_slanted_stroke_corners(&bp, a)))
|| self.children().is_some_and(|c| c.iter().any(|c| c.slanted_corners(symbols, a, left)))
}
}
#[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 == 0.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 a_bevel_stops_at_the_corners_of_the_segments_it_joins() {
// A 40 × 100 pt triangle stroked 20 pt: a miter limit of 2 or a bevel join bevels its
// 22.6° tip at (20, 0), so the stroke reaches 10 · 20 / √10400 pt above it, not 10 pt.
let mut tri = BezPath::new();
tri.move_to((0.0, 100.0));
tri.line_to((20.0, 0.0));
tri.line_to((40.0, 100.0));
tri.close_path();
let tb = tri.bounding_box();
let (top, side) = (-200.0 / 10400.0_f64.sqrt(), 1000.0 / 10400.0_f64.sqrt());
let limited = stroke(20.0, |s| s.miter_limit = 2.0);
let bevel = stroke(20.0, |s| {
s.join = LineJoin::Bevel;
s.cap = LineCap::Round;
});
let b = limited.bounds_on(&tri, tb);
assert!((b.y0 - top).abs() < 1e-9, "{b:?}");
// The limit keeps the base corners' miters, whose tips reach about 12.2 pt left and right.
assert!(b.x0 < -12.0 && b.x1 > 52.0, "{b:?}");
let mut ap = Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 20.0);
*ap.stroke_mut().unwrap() = bevel.clone();
let b = ap.stroked_bounds(tb, || tri.clone());
for (got, want) in [(b.x0, -side), (b.y0, top), (b.x1, 40.0 + side), (b.y1, 110.0)] {
assert!((got - want).abs() < 1e-9, "{b:?}");
}
// Open, the same corner is beveled and the round caps reach the half weight round the ends.
let chevron = polyline(&[(0.0, 100.0), (20.0, 0.0), (40.0, 100.0)]);
let b = bevel.bounds_on(&chevron, chevron.bounding_box());
for (got, want) in [(b.x0, -10.0), (b.y0, top), (b.x1, 50.0), (b.y1, 110.0)] {
assert!((got - want).abs() < 1e-9, "{b:?}");
}
// A round join, and the round caps of dashes, which can end at the tip, reach round it.
let round = stroke(20.0, |s| s.join = LineJoin::Round);
assert!((round.bounds_on(&tri, tb).y0 + 10.0).abs() < 1e-9);
let mut dashed = bevel.clone();
dashed.dash = Some(Dash { pattern: vec![7.0, 4.0], offset: 0.0, align_corners: false });
assert!((dashed.bounds_on(&chevron, chevron.bounding_box()).y0 + 10.0).abs() < 1e-9);
}
#[test]
fn a_click_past_a_bevel_or_a_butt_end_misses() {
// 5 pt above the triangle's tip is within the half weight of it, but past the bevel, which
// reaches about 1.96 pt above it. A round join paints there.
let mut tri = polyline(&[(0.0, 100.0), (20.0, 0.0), (40.0, 100.0)]);
tri.close_path();
let (above, on) = (Point::new(20.0, -5.0), Point::new(20.0, -1.5));
for join in [LineJoin::Bevel, LineJoin::Miter] {
let st = stroke(20.0, |s| (s.join, s.miter_limit) = (join, 2.0));
assert!(!st.hits(&tri, FillRule::NonZero, above, 0.5), "{join:?}");
assert!(st.hits(&tri, FillRule::NonZero, on, 0.0), "{join:?}");
}
assert!(stroke(20.0, |s| s.join = LineJoin::Round).hits(&tri, FillRule::NonZero, above, 0.5));
// 1.5 pt past a butt end of a 4 pt line misses, and a quarter point within it hits; a round
// cap paints past it.
let line = polyline(&[(0.0, 0.0), (0.0, 50.0)]);
let butt = stroke(4.0, |s| s.cap = LineCap::Butt);
assert!(!butt.hits(&line, FillRule::NonZero, Point::new(1.0, -1.5), 0.25));
assert!(butt.hits(&line, FillRule::NonZero, Point::new(1.0, -0.2), 0.25));
assert!(stroke(4.0, |s| s.cap = LineCap::Round).hits(&line, FillRule::NonZero, Point::new(1.0, -1.5), 0.25));
}
#[test]
fn a_joint_too_shallow_to_bevel_paints_past_both_segments_ends() {
// The path turns by about 0.57° at (100, -0.5), too little to bevel, so the stroke runs on
// through the joint: 3.5 pt above it is past the end of both segments and still painted.
let kink = polyline(&[(0.0, 0.0), (100.0, -0.5), (200.0, 0.0)]);
let above = Point::new(100.0, -4.0);
for join in [LineJoin::Bevel, LineJoin::Miter, LineJoin::Round] {
assert!(stroke(10.0, |s| s.join = join).hits(&kink, FillRule::NonZero, above, 0.0), "{join:?}");
}
}
/// An open path through `points`.
fn polyline(points: &[(f64, f64)]) -> BezPath {
let mut bp = BezPath::new();
for (i, p) in points.iter().enumerate() {
if i == 0 { bp.move_to(*p) } else { bp.line_to(*p) }
}
bp
}
#[test]
fn butt_caps_stop_at_the_ends_of_an_open_path() {
let butt = |f: fn(&mut StrokeLayer)| {
stroke(2.0, |s| {
s.cap = LineCap::Butt;
f(s);
})
};
// A vertical line reaches sideways only.
let v = polyline(&[(50.0, 10.0), (50.0, 60.0)]);
assert_eq!(butt(|_| {}).bounds_on(&v, v.bounding_box()), Rect::new(49.0, 10.0, 51.0, 60.0));
// A 3-4-5 diagonal: its corners reach 0.8 pt across x and 0.6 pt across y.
let d = polyline(&[(0.0, 0.0), (30.0, 40.0)]);
let b = butt(|_| {}).bounds_on(&d, d.bounding_box());
for (got, want) in [(b.x0, -0.8), (b.y0, -0.6), (b.x1, 30.8), (b.y1, 40.6)] {
assert!((got - want).abs() < 1e-9, "{b:?}");
}
// The dashes of a butt stroke stop inside the line as well, and an open path ignores the
// alignment.
let dashed = butt(|s| s.dash = Some(Dash { pattern: vec![4.0, 2.0], offset: 0.0, align_corners: false }));
assert_eq!(dashed.bounds_on(&v, v.bounding_box()), Rect::new(49.0, 10.0, 51.0, 60.0));
assert_eq!(butt(|s| s.align = StrokeAlign::Inside).bounds_on(&v, v.bounding_box()), Rect::new(49.0, 10.0, 51.0, 60.0));
// A round cap reaches half the weight past each end, a square one the corners.
let round = butt(|s| s.cap = LineCap::Round).bounds_on(&v, v.bounding_box());
assert_eq!(round, Rect::new(49.0, 9.0, 51.0, 61.0));
let square = butt(|s| s.cap = LineCap::Square).bounds_on(&v, v.bounding_box());
assert_eq!(square, Rect::new(49.0, 9.0, 51.0, 61.0));
// Two strokes union.
let mut ap = Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 2.0);
ap.stroke_mut().unwrap().cap = LineCap::Butt;
let wide = stroke(10.0, |s| s.cap = LineCap::Round);
ap.items.push(crate::AppearanceItem::Stroke(wide));
let vb = v.bounding_box();
assert_eq!(ap.stroked_bounds(vb, || v.clone()), Rect::new(45.0, 5.0, 55.0, 65.0));
}
#[test]
fn a_butt_paths_joins_and_curves_stay_inside_the_box() {
// A corner in the middle: the round join reaches a full half weight straight down, outside
// the turn, the ends do not, and a miter reaches its tip.
let zig = polyline(&[(0.0, 0.0), (20.0, 20.0), (40.0, 0.0)]);
let round = stroke(4.0, |s| {
s.cap = LineCap::Butt;
s.join = LineJoin::Round;
})
.bounds_on(&zig, zig.bounding_box());
assert!((round.y1 - 22.0).abs() < 1e-9, "the round join's reach: {round:?}");
assert!((round.x0 + 2.0_f64.sqrt()).abs() < 1e-9 && (round.x1 - 40.0 - 2.0_f64.sqrt()).abs() < 1e-9, "the ends' corners: {round:?}");
// A round join paints its arc on the outer side of a turn too shallow to count as a
// corner, and nothing where the path goes straight on.
let round_butt = stroke(4.0, |s| {
s.cap = LineCap::Butt;
s.join = LineJoin::Round;
});
let shallow = polyline(&[(0.0, 0.25), (50.0, 0.0), (100.0, 0.25)]);
let b = round_butt.bounds_on(&shallow, shallow.bounding_box());
assert!((b.y0 + 2.0).abs() < 1e-12, "{b:?}");
let straight = polyline(&[(0.0, 0.0), (0.1, 0.0), (100.0, 0.0)]);
assert_eq!(round_butt.bounds_on(&straight, straight.bounding_box()), Rect::new(0.0, -2.0, 100.0, 2.0));
let miter = stroke(4.0, |s| s.cap = LineCap::Butt).bounds_on(&zig, zig.bounding_box());
assert!((miter.y1 - (20.0 + 2.0 * 2.0_f64.sqrt())).abs() < 1e-9, "the miter tip: {miter:?}");
// An arc over the top whose ends leave the page straight down: the top reaches half the
// weight past the path, and the bottom stops at the ends.
let mut arc = BezPath::new();
arc.move_to((0.0, 50.0));
arc.curve_to((0.0, 22.4), (22.4, 0.0), (50.0, 0.0));
arc.curve_to((77.6, 0.0), (100.0, 22.4), (100.0, 50.0));
let b = stroke(4.0, |s| s.cap = LineCap::Butt).bounds_on(&arc, arc.bounding_box());
assert!((b.y0 + 2.0).abs() < 1e-9 && (b.x0 + 2.0).abs() < 1e-9 && (b.x1 - 102.0).abs() < 1e-9, "{b:?}");
assert!((b.y1 - 50.0).abs() < 1e-9, "{b:?}");
}
#[test]
fn a_butt_curve_with_a_handle_on_its_end_point_stops_near_that_end() {
let butt = stroke(2.0, |s| s.cap = LineCap::Butt);
// A straight curve whose first or last handle sits on its end point measures like a line.
let mut first = BezPath::new();
first.move_to((50.0, 10.0));
first.curve_to((50.0, 10.0), (50.0, 40.0), (50.0, 60.0));
let mut last = BezPath::new();
last.move_to((50.0, 10.0));
last.curve_to((50.0, 20.0), (50.0, 60.0), (50.0, 60.0));
for bp in [first, last] {
assert_eq!(butt.bounds_on(&bp, bp.bounding_box()), Rect::new(49.0, 10.0, 51.0, 60.0));
}
// A bend that leaves its start straight up from a handle on the start point: the stroke
// reaches only a little below the start, not half the weight.
let mut bend = BezPath::new();
bend.move_to((0.0, 50.0));
bend.curve_to((0.0, 50.0), (0.0, 0.0), (50.0, 0.0));
let b = stroke(4.0, |s| s.cap = LineCap::Butt).bounds_on(&bend, bend.bounding_box());
assert!(b.y1 >= 50.0 && b.y1 < 50.2, "{b:?}");
assert!((b.x0 + 2.0).abs() < 1e-9 && (b.y0 + 2.0).abs() < 1e-9 && (b.x1 - 50.0).abs() < 1e-9, "{b:?}");
// Nearly a cusp: the curve turns back at its lowest point, where its tangent is level and
// the stroke reaches the full half weight below it.
let (a0, a1, a2) = (1e5 * 81.0 / 256.0, -1e5 * 9.0 / 8.0, 1e5);
let x = |i: f64| 2e-9 * i / 3.0;
let mut cusp = BezPath::new();
cusp.move_to((x(0.0), a0));
cusp.curve_to((x(1.0), a0 + a1 / 3.0), (x(2.0), a0 + 2.0 * a1 / 3.0 + a2 / 3.0), (x(3.0), a0 + a1 + a2));
let b = butt.bounds_on(&cusp, cusp.bounding_box());
assert!(b.y0 <= -1.0 + 1e-6, "{b:?}");
// A curve far shorter than the weight still reaches half the weight above where its
// tangent is level.
let mut tiny = BezPath::new();
tiny.move_to((0.0, 0.9e-12));
tiny.curve_to((0.3e-12, -0.5e-12), (1.7e-12, -0.2e-12), (2e-12, 1e-12));
let b = butt.bounds_on(&tiny, tiny.bounding_box());
assert!(b.y0 <= -1.0 + 1e-9, "{b:?}");
}
#[test]
fn a_butt_stroke_is_slanted_where_a_segment_starts_or_ends_at_a_slant() {
let mut ap = Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 2.0);
ap.stroke_mut().unwrap().cap = LineCap::Butt;
let id = vectorcraft_geom::Affine::IDENTITY;
assert!(!ap.has_slanted_stroke_corners(&polyline(&[(0.0, 0.0), (0.0, 50.0), (30.0, 50.0)]), id));
// Square ends, slanted segments in the middle.
let apex = polyline(&[(0.0, 50.0), (0.0, 40.0), (10.0, 0.0), (20.0, 40.0), (20.0, 50.0)]);
assert!(ap.has_slanted_stroke_corners(&apex, id));
// A rotation slants a vertical line.
let v = polyline(&[(0.0, 0.0), (0.0, 50.0)]);
assert!(ap.has_slanted_stroke_corners(&v, vectorcraft_geom::Affine::rotate(0.3)));
// With round caps, the miter join still stops at the corners of the slanted segments, on a
// closed path too; round caps and round joins never do.
ap.stroke_mut().unwrap().cap = LineCap::Round;
assert!(ap.has_slanted_stroke_corners(&apex, id));
let mut tri = polyline(&[(0.0, 100.0), (20.0, 0.0), (40.0, 100.0)]);
tri.close_path();
ap.stroke_mut().unwrap().join = LineJoin::Bevel;
assert!(ap.has_slanted_stroke_corners(&tri, id));
assert!(!ap.has_slanted_stroke_corners(&square(), id));
ap.stroke_mut().unwrap().join = LineJoin::Round;
assert!(!ap.has_slanted_stroke_corners(&apex, id));
assert!(!ap.has_slanted_stroke_corners(&tri, id));
}
#[test]
fn the_slant_check_looks_into_at_most_4096_symbol_instances() {
// S0 is a vertical butt line. Each of S1 to S12 holds two instances of the symbol before
// it, so an instance of S12 holds 8,191 instances and none of them is slanted. Past 4,096
// the check answers yes.
let mut d = Document::new(400.0, 400.0);
let mut ap = Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 2.0);
ap.stroke_mut().unwrap().cap = LineCap::Butt;
let line = Node::path(d.alloc_id(), PathData::from_bezpath(&polyline(&[(0.0, 0.0), (0.0, 10.0)])), ap);
d.symbols.push(crate::Symbol { dynamic: false, name: "S0".into(), art: std::sync::Arc::new(line) });
for k in 1..=12 {
let mut instance = |dx: f64| {
let xf = vectorcraft_geom::Affine::translate((dx, 0.0));
std::sync::Arc::new(Node::new(d.alloc_id(), crate::NodeKind::SymbolInstance { symbol: format!("S{}", k - 1), xf }))
};
let pair = vec![instance(0.0), instance(20.0)];
let art = Node::group(d.alloc_id(), pair);
d.symbols.push(crate::Symbol { dynamic: false, name: format!("S{k}"), art: std::sync::Arc::new(art) });
}
let top = Node::new(d.alloc_id(), crate::NodeKind::SymbolInstance { symbol: "S12".into(), xf: vectorcraft_geom::Affine::IDENTITY });
let symbols = d.symbol_boxes();
assert!(top.has_slanted_stroke_corners(&symbols, vectorcraft_geom::Affine::IDENTITY));
assert!(symbols.art_measured() <= SLANT_INSTANCES, "{}", symbols.art_measured());
// One instance of S1 holds three instances, all looked into.
let one = Node::new(d.alloc_id(), crate::NodeKind::SymbolInstance { symbol: "S1".into(), xf: vectorcraft_geom::Affine::IDENTITY });
assert!(!one.has_slanted_stroke_corners(&d.symbol_boxes(), vectorcraft_geom::Affine::IDENTITY));
}
#[test]
fn a_compound_path_measures_each_of_its_subpaths_by_its_own_ends() {
// A closed square at the top and an open line below it: only the square reaches past its
// own edges.
let mut bp = BezPath::new();
bp.extend(square().elements().iter().copied());
bp.move_to((50.0, 150.0));
bp.line_to((50.0, 200.0));
let st = stroke(4.0, |s| s.cap = LineCap::Butt);
assert_eq!(st.bounds_on(&bp, bp.bounding_box()), Rect::new(-2.0, -2.0, 102.0, 200.0));
}
#[test]
fn a_middle_segment_can_set_the_box_of_a_butt_path() {
// The flat middle segment reaches a full half weight above the path, and the ends' corners
// reach 10/51 of it below.
let hat = polyline(&[(0.0, 50.0), (10.0, 0.0), (20.0, 0.0), (30.0, 50.0)]);
let b = stroke(2.0, |s| {
s.cap = LineCap::Butt;
s.join = LineJoin::Bevel;
})
.bounds_on(&hat, hat.bounding_box());
let corner = 10.0 / (10.0_f64 * 10.0 + 50.0 * 50.0).sqrt();
assert!((b.y0 + 1.0).abs() < 1e-9 && (b.y1 - 50.0 - corner).abs() < 1e-9, "{b:?}");
}
#[test]
fn bounds_with_tips_measures_a_butt_line_as_bounds_on_does() {
let v = polyline(&[(50.0, 10.0), (50.0, 60.0)]);
let st = stroke(2.0, |s| s.cap = LineCap::Butt);
assert_eq!(st.bounds_with_tips(&v, v.bounding_box(), None), Rect::new(49.0, 10.0, 51.0, 60.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)));
}
}