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:
bflatasticandClaude Opus 5.5 committed 2026-10-05 02:55:23 +07:00
1 parent fdad11a99c
commit c1dc860f1d
13 files changed
+839 -107

No files matched your search

+76 -22
View File
@@ -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
View File
@@ -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)
}
+1
View File
@@ -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
View File
@@ -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))
+431
View File
@@ -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)));
}
}
+4 -69
View File
@@ -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;
+92
View File
@@ -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(&region, 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}%"));
}
}
}
}
+2
View File
@@ -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;
+76
View File
@@ -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);
}
+100
View File
@@ -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));
}
}
+1
View File
@@ -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;
+2 -2
View File
@@ -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\""));
}
+7
View File
@@ -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.