Files
vectorcraft/crates/geom/src/bez.rs
T
bflatasticandClaude Opus 5.5 c1dc860f1d 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 <noreply@anthropic.com>
2026-10-05 02:55:23 +07:00

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));
}
}