mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-26 18:56:55 -06:00
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 <noreply@anthropic.com>
101 lines
3.6 KiB
Rust
101 lines
3.6 KiB
Rust
//! 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));
|
|
}
|
|
}
|