mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-27 19:27:21 -06:00
A corner is now any anchor that isn't smooth where two sides meet at an angle, straight or curved, as in Illustrator. The cut meets each side at its setback measured along the curve, leaves both sides along their tangents there, and each curved side is trimmed to what the cuts at its ends leave of it. The largest radius is still the one taking the cut halfway along the shorter side, measured along the curve. Corners between straight sides draw exactly as before. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
423 lines
19 KiB
Rust
423 lines
19 KiB
Rust
//! Live Corners on any path: the anchors where two sides, straight or curved, meet at an angle,
|
|
//! and the path with those corners cut round, inverted round or chamfered (a curved side trimmed
|
|
//! to what the cut leaves of it). Rectangles cut theirs the same way.
|
|
|
|
use kurbo::{CubicBez, ParamCurve, ParamCurveArclen, ParamCurveDeriv, Point, Vec2};
|
|
|
|
use crate::path::{Anchor, AnchorKind, PathData, SubPath};
|
|
use crate::shapes::{CornerKind, KAPPA};
|
|
|
|
/// Lengths and radii at or below this are none at all.
|
|
const EPS: f64 = 1e-9;
|
|
/// Sides closer than this to straight on (or folded back), as the sine of their angle, make no corner.
|
|
const MIN_SINE: f64 = 1e-6;
|
|
|
|
/// How closely arc lengths along curved sides are measured (points).
|
|
const ARCLEN_ACCURACY: f64 = 1e-6;
|
|
|
|
/// A side of a corner: the segment from the corner's anchor to its neighbour, as a cubic leaving
|
|
/// the corner (a straight side has its handles at its ends).
|
|
#[derive(Clone, Copy, Debug, PartialEq)]
|
|
pub struct Side {
|
|
pub curve: CubicBez,
|
|
}
|
|
|
|
impl Side {
|
|
/// Is the side straight, its handles on its ends?
|
|
fn straight(&self) -> bool {
|
|
self.curve.p1.distance(self.curve.p0) <= EPS && self.curve.p2.distance(self.curve.p3) <= EPS
|
|
}
|
|
|
|
/// The unit direction the side leaves the corner in: towards its first control point not on
|
|
/// the corner. `None` when the side has no length.
|
|
fn direction(&self) -> Option<Vec2> {
|
|
let c = self.curve;
|
|
let d = [c.p1, c.p2, c.p3].into_iter().map(|q| q - c.p0).find(|d| d.hypot() > EPS)?;
|
|
let d = d / d.hypot();
|
|
d.x.is_finite().then_some(d)
|
|
}
|
|
|
|
/// The side's length.
|
|
fn length(&self) -> f64 {
|
|
if self.straight() { self.curve.p0.distance(self.curve.p3) } else { self.curve.arclen(ARCLEN_ACCURACY) }
|
|
}
|
|
|
|
/// The parameter `s` along the side from the corner, the point there and the direction the
|
|
/// side runs on in (away from the corner).
|
|
fn at_length(&self, s: f64, u: Vec2) -> (f64, Point, Vec2) {
|
|
let c = self.curve;
|
|
if self.straight() {
|
|
let len = c.p0.distance(c.p3);
|
|
let t = if len > EPS { (s / len).clamp(0.0, 1.0) } else { 0.0 };
|
|
return (t, c.p0 + u * s, u);
|
|
}
|
|
let t = c.inv_arclen(s, ARCLEN_ACCURACY).clamp(0.0, 1.0);
|
|
let d = c.deriv().eval(t).to_vec2();
|
|
let dir = if d.hypot() > EPS { d / d.hypot() } else { u };
|
|
(t, c.eval(t), dir)
|
|
}
|
|
}
|
|
|
|
/// A corner Live Corners can cut: an anchor that isn't smooth, where two sides (straight or
|
|
/// curved) meet at an angle (#1144).
|
|
#[derive(Clone, Copy, Debug, PartialEq)]
|
|
pub struct Corner {
|
|
/// The anchor's index in its path, counting the anchors of every subpath in order.
|
|
pub index: usize,
|
|
/// Where the sides meet.
|
|
pub at: Point,
|
|
/// Unit directions the sides leave the corner in: towards the previous anchor, then towards
|
|
/// the next one.
|
|
pub u: Vec2,
|
|
pub v: Vec2,
|
|
/// The sides' lengths (along curves), in the same order.
|
|
pub lu: f64,
|
|
pub lv: f64,
|
|
/// The sides themselves, leaving the corner, in the same order.
|
|
pub side_u: Side,
|
|
pub side_v: Side,
|
|
}
|
|
|
|
impl Corner {
|
|
/// The corner of anchor `i` of `sp`, whose index in the path is `index`; `None` when the
|
|
/// anchor is an end of an open subpath or is smooth, when a side has no length, or when the
|
|
/// sides leave it straight on.
|
|
fn of(sp: &SubPath, i: usize, index: usize) -> Option<Self> {
|
|
let n = sp.anchors.len();
|
|
let (prev, next) = if sp.closed { ((i + n).checked_sub(1)? % n, (i + 1) % n) } else { (i.checked_sub(1)?, i + 1) };
|
|
let (a, prev, next) = (sp.anchors.get(i)?, sp.anchors.get(prev)?, sp.anchors.get(next)?);
|
|
if a.kind == AnchorKind::Smooth {
|
|
return None;
|
|
}
|
|
let side_u = Side { curve: CubicBez::new(a.p, a.h_in, prev.h_out, prev.p) };
|
|
let side_v = Side { curve: CubicBez::new(a.p, a.h_out, next.h_in, next.p) };
|
|
let (u, v) = (side_u.direction()?, side_v.direction()?);
|
|
let (lu, lv) = (side_u.length(), side_v.length());
|
|
if !(lu > EPS && lv > EPS && lu.is_finite() && lv.is_finite()) {
|
|
return None;
|
|
}
|
|
let c = Self { index, at: a.p, u, v, lu, lv, side_u, side_v };
|
|
(c.sin() > MIN_SINE).then_some(c)
|
|
}
|
|
|
|
/// The cosine of the angle between the sides.
|
|
pub fn cos(&self) -> f64 {
|
|
self.u.dot(self.v)
|
|
}
|
|
|
|
/// The sine of the angle between the sides.
|
|
pub fn sin(&self) -> f64 {
|
|
self.u.cross(self.v).abs()
|
|
}
|
|
|
|
/// The angle between the sides, in degrees (90 at a rectangle's corners).
|
|
pub fn angle_deg(&self) -> f64 {
|
|
self.sin().atan2(self.cos()).to_degrees()
|
|
}
|
|
|
|
/// How far from the corner a round cut of radius `r` meets each side: r / tan(angle / 2).
|
|
pub fn setback(&self, r: f64) -> f64 {
|
|
r * (1.0 + self.cos()) / self.sin()
|
|
}
|
|
|
|
/// The largest radius the corner draws: its cut then reaches halfway along the shorter side,
|
|
/// so neighbouring corners at their largest meet without overlapping (half the shorter side
|
|
/// at a rectangle's corners).
|
|
pub fn max_radius(&self) -> f64 {
|
|
self.lu.min(self.lv) / 2.0 * self.sin() / (1.0 + self.cos())
|
|
}
|
|
|
|
/// The radius the corner is drawn with: `radius`, no larger than [`Corner::max_radius`], 0 when
|
|
/// negative or not a number.
|
|
pub fn fitted(&self, radius: f64) -> f64 {
|
|
radius.max(0.0).min(self.max_radius())
|
|
}
|
|
|
|
/// The point `k` along the bisector, measured in (u + v): the centre of a round cut of radius
|
|
/// `r` is at `k = r / sin`.
|
|
pub fn on_bisector(&self, k: f64) -> Point {
|
|
self.at + (self.u + self.v) * k
|
|
}
|
|
|
|
/// How much the radius grows when the centre of the round cut moves by `d`: `d`'s part along
|
|
/// the bisector, in radius units.
|
|
pub fn radius_change(&self, d: Vec2) -> f64 {
|
|
d.dot(self.u + self.v) * self.sin() / (2.0 + 2.0 * self.cos())
|
|
}
|
|
|
|
/// The cut of radius `radius` (> 0, fitted): where it meets each side, `setback` along it,
|
|
/// and the cut's own curve between them, its handles leaving the sides smoothly.
|
|
fn cut(&self, radius: f64, kind: CornerKind) -> Cut {
|
|
let s = self.setback(radius);
|
|
let (tu, start, du) = self.side_u.at_length(s, self.u);
|
|
let (tv, end, dv) = self.side_v.at_length(s, self.v);
|
|
let cos = du.dot(dv);
|
|
let (h_out, h_in) = match kind {
|
|
// An arc tangent to both sides, turning by 180° less the angle between them there.
|
|
CornerKind::Round => {
|
|
let h = radius * arc_handle(-cos);
|
|
(start - du * h, end - dv * h)
|
|
}
|
|
// An arc centred on the corner through both ends, spanning the angle.
|
|
CornerKind::InvertedRound => {
|
|
let h = s * arc_handle(cos);
|
|
(start + square_to(du, dv) * h, end + square_to(dv, du) * h)
|
|
}
|
|
CornerKind::Chamfer => (start, end),
|
|
};
|
|
Cut { tu, tv, start, h_out, h_in, end }
|
|
}
|
|
}
|
|
|
|
/// A corner cut: the parameters where it meets its sides (from the corner), its two new anchors
|
|
/// and the handles between them.
|
|
#[derive(Clone, Copy, Debug)]
|
|
struct Cut {
|
|
tu: f64,
|
|
tv: f64,
|
|
start: Point,
|
|
h_out: Point,
|
|
h_in: Point,
|
|
end: Point,
|
|
}
|
|
|
|
/// The unit direction square to unit `a`, on `b`'s side.
|
|
fn square_to(a: Vec2, b: Vec2) -> Vec2 {
|
|
let w = b - a * a.dot(b);
|
|
let l = w.hypot();
|
|
if l > EPS { w / l } else { Vec2::ZERO }
|
|
}
|
|
|
|
/// The handle length, per unit of radius, of a cubic drawing a circular arc that spans an angle
|
|
/// whose cosine is `cos`: 4/3 · tan(angle / 4) ([`KAPPA`] for a quarter circle).
|
|
fn arc_handle(cos: f64) -> f64 {
|
|
if cos.abs() < 1e-12 {
|
|
return KAPPA;
|
|
}
|
|
let cos = cos.clamp(-1.0, 1.0);
|
|
let (sin_half, cos_half) = (((1.0 - cos) / 2.0).max(0.0).sqrt(), ((1.0 + cos) / 2.0).max(0.0).sqrt());
|
|
4.0 / 3.0 * sin_half / (1.0 + cos_half)
|
|
}
|
|
|
|
/// The corners of `path` Live Corners can cut, in anchor order.
|
|
pub fn path_corners(path: &PathData) -> Vec<Corner> {
|
|
let mut out = vec![];
|
|
let mut first = 0;
|
|
for sp in &path.subpaths {
|
|
out.extend((0..sp.anchors.len()).filter_map(|i| Corner::of(sp, i, first + i)));
|
|
first += sp.anchors.len();
|
|
}
|
|
out
|
|
}
|
|
|
|
/// `path` with each of its corners ([`path_corners`]) cut by the radius in `radii` and the kind in
|
|
/// `kinds` at its anchor index (missing: 0 and round), each radius no larger than fits
|
|
/// ([`Corner::fitted`]). A cut corner becomes two anchors, and a curved side between corners is
|
|
/// trimmed to what is left of it; a closed subpath whose first corner is cut starts where that
|
|
/// cut ends, so the cut closes it. Also returns, per subpath, the anchor index in `path` each
|
|
/// anchor comes from.
|
|
pub fn cut_corners(path: &PathData, radii: &[f64], kinds: &[CornerKind]) -> (PathData, Vec<Vec<usize>>) {
|
|
let corners = path_corners(path);
|
|
let mut corners = corners.iter().peekable();
|
|
let (mut subpaths, mut sources) = (Vec::with_capacity(path.subpaths.len()), Vec::with_capacity(path.subpaths.len()));
|
|
let mut first = 0;
|
|
for sp in &path.subpaths {
|
|
let n = sp.anchors.len();
|
|
// The cut of each anchor, if any.
|
|
let cuts: Vec<Option<Cut>> = (0..n)
|
|
.map(|i| {
|
|
let index = first + i;
|
|
let c = corners.next_if(|c| c.index == index)?;
|
|
let radius = c.fitted(radii.get(index).copied().unwrap_or(0.0));
|
|
(radius > EPS).then(|| c.cut(radius, kinds.get(index).copied().unwrap_or_default()))
|
|
})
|
|
.collect();
|
|
// Each segment from anchor j, trimmed to the part the cuts at its ends leave: its handles
|
|
// (out of its first anchor, into its last), `None` when it is untouched.
|
|
let trimmed: Vec<Option<(Point, Point)>> = (0..n)
|
|
.map(|j| {
|
|
let k = (j + 1) % n;
|
|
let (a, b) = (sp.anchors.get(j)?, sp.anchors.get(k)?);
|
|
let (from, to) = (cuts.get(j).copied().flatten(), cuts.get(k).copied().flatten());
|
|
if (from.is_none() && to.is_none()) || (k == 0 && !sp.closed) {
|
|
return None;
|
|
}
|
|
// A straight segment stays straight, its handles on its ends.
|
|
if !a.has_out() && !b.has_in() {
|
|
return Some((from.map_or(a.p, |c| c.end), to.map_or(b.p, |c| c.start)));
|
|
}
|
|
let (s0, s1) = (from.map_or(0.0, |c| c.tv), to.map_or(1.0, |c| 1.0 - c.tu));
|
|
let sub = CubicBez::new(a.p, a.h_out, b.h_in, b.p).subsegment(s0..s1.max(s0));
|
|
Some((sub.p1, sub.p2))
|
|
})
|
|
.collect();
|
|
let (mut anchors, mut from) = (Vec::with_capacity(n), Vec::with_capacity(n));
|
|
let mut closing = None;
|
|
for (i, a) in sp.anchors.iter().enumerate() {
|
|
let index = first + i;
|
|
let h_in = trimmed.get((i + n - 1) % n).copied().flatten().map_or(a.h_in, |(_, h)| h);
|
|
let h_out = trimmed.get(i).copied().flatten().map_or(a.h_out, |(h, _)| h);
|
|
match cuts.get(i).copied().flatten() {
|
|
Some(c) => {
|
|
let start = Anchor { p: c.start, h_in, h_out: c.h_out, kind: AnchorKind::Corner };
|
|
let end = Anchor { p: c.end, h_in: c.h_in, h_out, kind: AnchorKind::Corner };
|
|
if i == 0 && sp.closed {
|
|
closing = Some(start);
|
|
} else {
|
|
anchors.push(start);
|
|
from.push(index);
|
|
}
|
|
anchors.push(end);
|
|
from.push(index);
|
|
}
|
|
None => {
|
|
anchors.push(Anchor { h_in, h_out, ..*a });
|
|
from.push(index);
|
|
}
|
|
}
|
|
}
|
|
if let Some(a) = closing {
|
|
anchors.push(a);
|
|
from.push(first);
|
|
}
|
|
first += n;
|
|
subpaths.push(SubPath::new(anchors, sp.closed));
|
|
sources.push(from);
|
|
}
|
|
(PathData::new(subpaths), sources)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::shapes;
|
|
use kurbo::Rect;
|
|
|
|
fn near(a: Point, b: Point) -> bool {
|
|
a.distance(b) < 1e-9
|
|
}
|
|
|
|
/// A five-pointed star's tips are acute corners, its inner corners obtuse: all ten cut, each
|
|
/// a circle of the radius tangent to both sides.
|
|
#[test]
|
|
fn a_star_has_ten_corners_cut_as_circles() {
|
|
let star = shapes::star(Point::new(100.0, 100.0), 50.0, 25.0, 5, 0.0);
|
|
let corners = path_corners(&star);
|
|
assert_eq!(corners.len(), 10);
|
|
let angles: Vec<_> = corners.iter().map(|c| c.angle_deg()).collect();
|
|
assert!(angles.iter().step_by(2).all(|a| *a < 90.0), "tips are acute: {angles:?}");
|
|
assert!(angles.iter().skip(1).step_by(2).all(|a| *a > 90.0), "inner corners are obtuse: {angles:?}");
|
|
let (cut, from) = cut_corners(&star, &[4.0; 10], &[]);
|
|
assert_eq!(cut.anchor_count(), 20);
|
|
assert_eq!(from, [vec![0, 1, 1, 2, 2, 3, 3, 4, 4, 5, 5, 6, 6, 7, 7, 8, 8, 9, 9, 0]]);
|
|
for c in &corners {
|
|
// Both ends of the cut lie `setback` along the sides, as far from the arc's centre as
|
|
// the radius, and that centre is `radius` from either side.
|
|
let t = c.setback(4.0);
|
|
let o = c.on_bisector(4.0 / c.sin());
|
|
for p in [c.at + c.u * t, c.at + c.v * t] {
|
|
assert!((p.distance(o) - 4.0).abs() < 1e-9);
|
|
assert!(cut.anchors().any(|(_, _, a)| near(a.p, p)), "{p:?} is an anchor");
|
|
}
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn radii_stop_at_half_the_shorter_side_per_corner() {
|
|
let tri = PathData::single(SubPath::polyline(&[Point::new(0.0, 0.0), Point::new(100.0, 0.0), Point::new(0.0, 40.0)], true));
|
|
let corners = path_corners(&tri);
|
|
assert_eq!(corners.len(), 3);
|
|
for c in &corners {
|
|
let max = c.max_radius();
|
|
assert!((c.setback(max) - c.lu.min(c.lv) / 2.0).abs() < 1e-9);
|
|
assert_eq!(c.fitted(1e6), max);
|
|
assert_eq!(c.fitted(-3.0), 0.0);
|
|
assert_eq!(c.fitted(f64::NAN), 0.0);
|
|
}
|
|
// The right angle at (0, 0) takes up to 20, half its shorter side.
|
|
assert!((corners[0].max_radius() - 20.0).abs() < 1e-9);
|
|
}
|
|
|
|
#[test]
|
|
fn ends_curves_smooth_and_straight_anchors_are_no_corners() {
|
|
// An open zig-zag: its ends aren't corners, nor is the anchor its sides run straight on through.
|
|
let open = PathData::single(SubPath::polyline(
|
|
&[Point::new(0.0, 0.0), Point::new(50.0, 0.0), Point::new(100.0, 0.0), Point::new(100.0, 50.0), Point::new(150.0, 80.0)],
|
|
false,
|
|
));
|
|
assert_eq!(path_corners(&open).iter().map(|c| c.index).collect::<Vec<_>>(), [2, 3]);
|
|
// Smooth anchors aren't corners.
|
|
assert!(path_corners(&shapes::ellipse(Rect::new(0.0, 0.0, 10.0, 10.0))).is_empty());
|
|
// A second subpath counts on from the first one's anchors.
|
|
let two = PathData::new(vec![
|
|
SubPath::polyline(&[Point::new(0.0, 0.0), Point::new(10.0, 0.0), Point::new(0.0, 10.0)], true),
|
|
SubPath::polyline(&[Point::new(50.0, 0.0), Point::new(60.0, 0.0), Point::new(50.0, 10.0)], true),
|
|
]);
|
|
let (cut, from) = cut_corners(&two, &[0.0, 0.0, 0.0, 1.0], &[]);
|
|
assert_eq!(from, [vec![0, 1, 2], vec![3, 4, 5, 3]]);
|
|
assert_eq!(cut.anchor_count(), 7);
|
|
}
|
|
|
|
/// The square `0..100` with its right side bulging out as a curve (#1144).
|
|
fn bulging_square() -> PathData {
|
|
let mut sp = SubPath::polyline(&[Point::new(0.0, 0.0), Point::new(100.0, 0.0), Point::new(100.0, 100.0), Point::new(0.0, 100.0)], true);
|
|
sp.anchors[1].h_out = Point::new(140.0, 30.0);
|
|
sp.anchors[2].h_in = Point::new(140.0, 70.0);
|
|
PathData::single(sp)
|
|
}
|
|
|
|
/// Corners where a curve meets a line are cut too: the cut ends on the path, leaves both
|
|
/// sides smoothly, and what is left of the curve still follows it (#1144).
|
|
#[test]
|
|
fn corners_next_to_curves_are_cut_along_them() {
|
|
let path = bulging_square();
|
|
let corners = path_corners(&path);
|
|
assert_eq!(corners.iter().map(|c| c.index).collect::<Vec<_>>(), [0, 1, 2, 3]);
|
|
let side = kurbo::CubicBez::new(Point::new(100.0, 0.0), Point::new(140.0, 30.0), Point::new(140.0, 70.0), Point::new(100.0, 100.0));
|
|
// The curved side is longer than its chord, and limits its corners by its length.
|
|
assert!(corners[1].lv > 100.0 + 1.0);
|
|
let (cut, from) = cut_corners(&path, &[10.0; 4], &[]);
|
|
assert_eq!(from, [vec![0, 1, 1, 2, 2, 3, 3, 0]]);
|
|
let sp = &cut.subpaths[0];
|
|
let on_side = |p: Point| (0..=1000).map(|i| side.eval(f64::from(i) / 1000.0).distance(p)).fold(f64::MAX, f64::min);
|
|
// Corner 1's cut ends on the curve and corner 2's starts on it, and the trimmed curve
|
|
// between them stays on the original one.
|
|
let (a, b) = (sp.anchors[2], sp.anchors[3]);
|
|
assert!(on_side(a.p) < 0.1 && on_side(b.p) < 0.1);
|
|
let trimmed = kurbo::CubicBez::new(a.p, a.h_out, b.h_in, b.p);
|
|
for t in [0.25, 0.5, 0.75] {
|
|
assert!(on_side(trimmed.eval(t)) < 0.1);
|
|
}
|
|
// Every cut leaves its sides smoothly: each anchor's handles are in line.
|
|
for (i, x) in sp.anchors.iter().enumerate() {
|
|
let (into, out) = if x.has_in() && x.has_out() {
|
|
(x.p - x.h_in, x.h_out - x.p)
|
|
} else if x.has_out() {
|
|
(x.p - sp.anchors[(i + sp.anchors.len() - 1) % sp.anchors.len()].p, x.h_out - x.p)
|
|
} else {
|
|
(x.h_in - x.p, x.p - sp.anchors[(i + 1) % sp.anchors.len()].p)
|
|
};
|
|
assert!(into.normalize().cross(out.normalize()).abs() < 1e-6 && into.dot(out) > 0.0, "anchor {i}");
|
|
}
|
|
}
|
|
|
|
/// Inverted round and chamfer cuts end where the round one does, at any angle.
|
|
#[test]
|
|
fn kinds_share_their_ends() {
|
|
let star = shapes::star(Point::new(0.0, 0.0), 50.0, 25.0, 5, 0.0);
|
|
let ends = |k: CornerKind| -> Vec<Point> { cut_corners(&star, &[3.0; 10], &[k; 10]).0.anchors().map(|(_, _, a)| a.p).collect() };
|
|
let round = ends(CornerKind::Round);
|
|
assert_eq!(ends(CornerKind::InvertedRound), round);
|
|
assert_eq!(ends(CornerKind::Chamfer), round);
|
|
// The inverted arc stays on the circle about the corner.
|
|
let c = path_corners(&star)[0];
|
|
let cut = c.cut(3.0, CornerKind::InvertedRound);
|
|
let bez = kurbo::CubicBez::new(cut.start, cut.h_out, cut.h_in, cut.end);
|
|
let r = c.setback(3.0);
|
|
for t in [0.25, 0.5, 0.75] {
|
|
use kurbo::ParamCurve;
|
|
assert!((bez.eval(t).distance(c.at) - r).abs() < r * 1e-3);
|
|
}
|
|
}
|
|
}
|