diff --git a/crates/doc/src/appearance.rs b/crates/doc/src/appearance.rs index 775fa1b1..ec556018 100644 --- a/crates/doc/src/appearance.rs +++ b/crates/doc/src/appearance.rs @@ -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, 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) { + 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 { + 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] diff --git a/crates/doc/src/hit.rs b/crates/doc/src/hit.rs index 84e46d0d..bacce0bb 100644 --- a/crates/doc/src/hit.rs +++ b/crates/doc/src/hit.rs @@ -99,7 +99,7 @@ fn hit_children(parent: &Node, p: Point, opt: HitOptions, chain: &mut Vec Option { + 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 { 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 { } 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) } diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 01190778..6059ad79 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -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; diff --git a/crates/doc/src/node.rs b/crates/doc/src/node.rs index fe28a65f..4761eecc 100644 --- a/crates/doc/src/node.rs +++ b/crates/doc/src/node.rs @@ -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 { + 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 { + 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 { 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 { 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)) diff --git a/crates/doc/src/reach.rs b/crates/doc/src/reach.rs new file mode 100644 index 00000000..12965821 --- /dev/null +++ b/crates/doc/src/reach.rs @@ -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 { + 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 + '_ { + 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, 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 = segs.iter().map(|s| s.arclen(ARCLEN_ACCURACY)).collect(); + let total: f64 = lens.iter().sum::().max(1e-12); + let before = lens[..i].iter().sum::(); + 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, 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))); + } +} diff --git a/crates/effects/src/stroke.rs b/crates/effects/src/stroke.rs index 7dc1362b..22c9187c 100644 --- a/crates/effects/src/stroke.rs +++ b/crates/effects/src/stroke.rs @@ -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, 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 { - 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; diff --git a/crates/effects/src/stroke/tests_reach.rs b/crates/effects/src/stroke/tests_reach.rs new file mode 100644 index 00000000..859d8b1c --- /dev/null +++ b/crates/effects/src/stroke/tests_reach.rs @@ -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 { + 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}%")); + } + } + } +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index c23d2172..f5ad1530 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -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; diff --git a/crates/engine/src/tests_strokereach.rs b/crates/engine/src/tests_strokereach.rs new file mode 100644 index 00000000..9db74845 --- /dev/null +++ b/crates/engine/src/tests_strokereach.rs @@ -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 { + 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); +} diff --git a/crates/geom/src/bez.rs b/crates/geom/src/bez.rs new file mode 100644 index 00000000..408f3abd --- /dev/null +++ b/crates/geom/src/bez.rs @@ -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, 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 { + 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)); + } +} diff --git a/crates/geom/src/lib.rs b/crates/geom/src/lib.rs index bc828e71..79ed3e2f 100644 --- a/crates/geom/src/lib.rs +++ b/crates/geom/src/lib.rs @@ -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; diff --git a/crates/svg/tests/roundtrip.rs b/crates/svg/tests/roundtrip.rs index 15b89d4f..1a3339d5 100644 --- a/crates/svg/tests/roundtrip.rs +++ b/crates/svg/tests/roundtrip.rs @@ -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\"")); } diff --git a/docs/mcp.md b/docs/mcp.md index 6df362c0..ff037cce 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -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.