Skip to content

Commit b7ff2ce

Browse files
curve: add ParamCurveTangent for pointwise tangents
Add ParamCurveTangent as an additive companion to ParamCurveDeriv for code that needs a tangent vector at a parameter value without requiring a derivative curve object. Place the new trait alongside the other curve traits in param_curve.rs, implement it for Line, ConstPoint, QuadBez, CubicBez, Arc, and PathSeg, and use it in internal call sites that were spelling pointwise tangent queries via deriv().eval(t).to_vec2(). Add tests that check the new tangent API against the existing derivative-curve behavior, cover degenerate normalization cases, and validate Arc's tangent formula and reversed-sweep behavior.
1 parent 5610501 commit b7ff2ce

8 files changed

Lines changed: 186 additions & 33 deletions

File tree

kurbo/src/arc.rs

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33

44
//! An ellipse arc.
55
6-
use crate::{Affine, Ellipse, ParamCurve, PathEl, Point, Rect, Shape, Vec2};
6+
use crate::{Affine, Ellipse, ParamCurve, ParamCurveTangent, PathEl, Point, Rect, Shape, Vec2};
77
use core::{
88
f64::consts::{FRAC_PI_2, PI},
99
iter,
@@ -193,6 +193,12 @@ impl ParamCurve for Arc {
193193
}
194194
}
195195

196+
impl ParamCurveTangent for Arc {
197+
fn tangent(&self, t: f64) -> Vec2 {
198+
self.sweep_angle * sample_ellipse(self.radii, self.x_rotation, self.angle_at(t) + FRAC_PI_2)
199+
}
200+
}
201+
196202
impl Shape for Arc {
197203
type PathElementsIter<'iter> = iter::Chain<iter::Once<PathEl>, ArcAppendIter>;
198204

@@ -248,7 +254,7 @@ impl Mul<Arc> for Affine {
248254
mod tests {
249255
use core::f64::consts::{FRAC_PI_4, FRAC_PI_6};
250256

251-
use crate::ParamCurve;
257+
use crate::{ParamCurve, ParamCurveTangent};
252258

253259
use super::*;
254260

@@ -321,6 +327,20 @@ mod tests {
321327
assert_point_near(reversed.eval(0.5), arc.eval(0.5));
322328
}
323329

330+
#[test]
331+
fn tangent_matches_shifted_sample() {
332+
let arc = Arc::new((2.0, -1.0), (4.0, 1.5), FRAC_PI_6, PI / 2.0, FRAC_PI_4);
333+
for t in [0.0, 0.25, 0.5, 1.0] {
334+
let expected = arc.sweep_angle
335+
* sample_ellipse(arc.radii, arc.x_rotation, arc.angle_at(t) + FRAC_PI_2);
336+
assert!((arc.tangent(t) - expected).hypot() <= 1e-12);
337+
}
338+
339+
let reversed = arc.reversed();
340+
assert!((reversed.tangent(0.0) + arc.tangent(1.0)).hypot() <= 1e-12);
341+
assert!((reversed.tangent(1.0) + arc.tangent(0.0)).hypot() <= 1e-12);
342+
}
343+
324344
#[test]
325345
fn subsegment_matches_original() {
326346
let arc = Arc::new((1.0, -4.0), (3.0, 2.0), -FRAC_PI_4, PI, FRAC_PI_6);

kurbo/src/bezpath.rs

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,8 @@ use crate::common::{solve_cubic, solve_quadratic};
1717
use crate::MAX_EXTREMA;
1818
use crate::{
1919
Affine, CubicBez, Line, Nearest, ParamCurve, ParamCurveArclen, ParamCurveArea,
20-
ParamCurveExtrema, ParamCurveNearest, Point, QuadBez, Rect, Shape, TranslateScale, Vec2,
20+
ParamCurveExtrema, ParamCurveNearest, ParamCurveTangent, Point, QuadBez, Rect, Shape,
21+
TranslateScale, Vec2,
2122
};
2223

2324
#[cfg(not(feature = "std"))]
@@ -913,6 +914,16 @@ impl ParamCurve for PathSeg {
913914
}
914915
}
915916

917+
impl ParamCurveTangent for PathSeg {
918+
fn tangent(&self, t: f64) -> Vec2 {
919+
match *self {
920+
PathSeg::Line(line) => line.tangent(t),
921+
PathSeg::Quad(quad) => quad.tangent(t),
922+
PathSeg::Cubic(cubic) => cubic.tangent(t),
923+
}
924+
}
925+
}
926+
916927
impl ParamCurveArclen for PathSeg {
917928
fn arclen(&self, accuracy: f64) -> f64 {
918929
match *self {

kurbo/src/cubicbez.rs

Lines changed: 23 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,8 @@ use crate::common::{
1717
};
1818
use crate::{
1919
Affine, Nearest, ParamCurve, ParamCurveArclen, ParamCurveArea, ParamCurveCurvature,
20-
ParamCurveDeriv, ParamCurveExtrema, ParamCurveNearest, PathEl, Point, QuadBez, Rect, Shape,
20+
ParamCurveDeriv, ParamCurveExtrema, ParamCurveNearest, ParamCurveTangent, PathEl, Point,
21+
QuadBez, Rect, Shape,
2122
};
2223

2324
#[cfg(not(feature = "std"))]
@@ -441,9 +442,11 @@ impl CubicBez {
441442
let nearest = q.nearest(Point::ORIGIN, 1e-9);
442443
// detect whether curvature at minimum derivative exceeds 1/dimension,
443444
// without division.
444-
let d = q.eval(nearest.t);
445-
let d2 = q.deriv().eval(nearest.t);
446-
let cross = d.to_vec2().cross(d2.to_vec2());
445+
let d = q.eval(nearest.t).to_vec2();
446+
// `q` is the first-derivative curve of `self`, so `q.tangent(t)`
447+
// is the second derivative of `self` at `t`.
448+
let d2 = q.tangent(nearest.t);
449+
let cross = d.cross(d2);
447450
if nearest.distance_sq.powi(3) <= (cross * dimension).powi(2) {
448451
let a = 3. * det_012 + det_023 - 2. * det_013;
449452
let b = -3. * det_012 + det_013;
@@ -560,10 +563,9 @@ impl ParamCurve for CubicBez {
560563
let (t0, t1) = (range.start, range.end);
561564
let p0 = self.eval(t0);
562565
let p3 = self.eval(t1);
563-
let d = self.deriv();
564566
let scale = (t1 - t0) * (1.0 / 3.0);
565-
let p1 = p0 + scale * d.eval(t0).to_vec2();
566-
let p2 = p3 - scale * d.eval(t1).to_vec2();
567+
let p1 = p0 + scale * self.tangent(t0);
568+
let p2 = p3 - scale * self.tangent(t1);
567569
CubicBez { p0, p1, p2, p3 }
568570
}
569571

@@ -613,6 +615,17 @@ impl ParamCurveDeriv for CubicBez {
613615
}
614616
}
615617

618+
impl ParamCurveTangent for CubicBez {
619+
#[inline]
620+
fn tangent(&self, t: f64) -> Vec2 {
621+
let mt = 1.0 - t;
622+
let d01 = self.p1 - self.p0;
623+
let d12 = self.p2 - self.p1;
624+
let d23 = self.p3 - self.p2;
625+
3.0 * (d01 * (mt * mt) + d12 * (2.0 * mt * t) + d23 * (t * t))
626+
}
627+
}
628+
616629
fn arclen_quadrature_core(coeffs: &[(f64, f64)], dm: Vec2, dm1: Vec2, dm2: Vec2) -> f64 {
617630
coeffs
618631
.iter()
@@ -832,8 +845,8 @@ pub fn cubics_to_quadratic_splines(curves: &[CubicBez], accuracy: f64) -> Option
832845
mod tests {
833846
use crate::{
834847
cubics_to_quadratic_splines, Affine, CubicBez, Nearest, ParamCurve, ParamCurveArclen,
835-
ParamCurveArea, ParamCurveDeriv, ParamCurveExtrema, ParamCurveNearest, Point, QuadBez,
836-
QuadSpline,
848+
ParamCurveArea, ParamCurveDeriv, ParamCurveExtrema, ParamCurveNearest, ParamCurveTangent,
849+
Point, QuadBez, QuadSpline,
837850
};
838851

839852
#[test]
@@ -856,6 +869,7 @@ mod tests {
856869
let d_approx = (p1 - p) * delta.recip();
857870
let d = deriv.eval(t).to_vec2();
858871
assert!((d - d_approx).hypot() < delta * 2.0);
872+
assert!((c.tangent(t) - d).hypot() < 1e-12);
859873
}
860874
}
861875

kurbo/src/lib.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -168,7 +168,7 @@ pub use crate::line::{ConstPoint, Line, LinePathIter};
168168
pub use crate::moments::{Moments, ParamCurveMoments};
169169
pub use crate::param_curve::{
170170
Nearest, ParamCurve, ParamCurveArclen, ParamCurveArea, ParamCurveCurvature, ParamCurveDeriv,
171-
ParamCurveExtrema, ParamCurveNearest, DEFAULT_ACCURACY, MAX_EXTREMA,
171+
ParamCurveExtrema, ParamCurveNearest, ParamCurveTangent, DEFAULT_ACCURACY, MAX_EXTREMA,
172172
};
173173
pub use crate::point::Point;
174174
pub use crate::quadbez::{QuadBez, QuadBezIter};

kurbo/src/line.rs

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,8 @@ use arrayvec::ArrayVec;
99

1010
use crate::{
1111
Affine, Nearest, ParamCurve, ParamCurveArclen, ParamCurveArea, ParamCurveCurvature,
12-
ParamCurveDeriv, ParamCurveExtrema, ParamCurveNearest, PathEl, Point, Rect, Shape, Vec2,
13-
DEFAULT_ACCURACY, MAX_EXTREMA,
12+
ParamCurveDeriv, ParamCurveExtrema, ParamCurveNearest, ParamCurveTangent, PathEl, Point, Rect,
13+
Shape, Vec2, DEFAULT_ACCURACY, MAX_EXTREMA,
1414
};
1515

1616
/// A single line.
@@ -138,6 +138,13 @@ impl ParamCurveDeriv for Line {
138138
}
139139
}
140140

141+
impl ParamCurveTangent for Line {
142+
#[inline]
143+
fn tangent(&self, _t: f64) -> Vec2 {
144+
self.p1 - self.p0
145+
}
146+
}
147+
141148
impl ParamCurveArclen for Line {
142149
#[inline]
143150
fn arclen(&self, _accuracy: f64) -> f64 {
@@ -244,6 +251,13 @@ impl ParamCurveDeriv for ConstPoint {
244251
}
245252
}
246253

254+
impl ParamCurveTangent for ConstPoint {
255+
#[inline]
256+
fn tangent(&self, _t: f64) -> Vec2 {
257+
Vec2::ZERO
258+
}
259+
}
260+
247261
impl ParamCurveArclen for ConstPoint {
248262
#[inline(always)]
249263
fn arclen(&self, _accuracy: f64) -> f64 {

kurbo/src/param_curve.rs

Lines changed: 89 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ use core::ops::Range;
77

88
use arrayvec::ArrayVec;
99

10-
use crate::{common, Point, Rect};
10+
use crate::{common, Point, Rect, Vec2};
1111

1212
#[cfg(not(feature = "std"))]
1313
use crate::common::FloatFuncs;
@@ -78,6 +78,29 @@ pub trait ParamCurveDeriv {
7878
}
7979
}
8080

81+
/// A parameterized curve with a tangent vector.
82+
///
83+
/// This trait complements [`ParamCurveDeriv`] by exposing the most common
84+
/// derivative query directly as a vector-valued operation.
85+
pub trait ParamCurveTangent: ParamCurve {
86+
/// Evaluate the tangent vector at parameter `t`.
87+
///
88+
/// Generally `t` is in the range `[0, 1]`.
89+
///
90+
/// Degenerate curves may return the zero vector.
91+
fn tangent(&self, t: f64) -> Vec2;
92+
93+
/// Evaluate the tangent vector at parameter `t` and normalize it.
94+
///
95+
/// Returns `None` when the tangent has zero length and therefore does
96+
/// not admit a unit-length normalization.
97+
fn unit_tangent(&self, t: f64) -> Option<Vec2> {
98+
let tangent = self.tangent(t);
99+
let norm = tangent.hypot();
100+
(norm > 0.0).then_some(tangent / norm)
101+
}
102+
}
103+
81104
/// A parameterized curve that can have its arc length measured.
82105
pub trait ParamCurveArclen: ParamCurve {
83106
/// The arc length of the curve.
@@ -219,3 +242,68 @@ pub trait ParamCurveExtrema: ParamCurve {
219242
bbox
220243
}
221244
}
245+
246+
#[cfg(test)]
247+
mod tangent_tests {
248+
use crate::{CubicBez, Line, ParamCurve, ParamCurveDeriv, PathSeg, QuadBez, Vec2};
249+
250+
use super::ParamCurveTangent;
251+
252+
fn assert_vec_near(actual: Vec2, expected: Vec2) {
253+
let epsilon = 1e-12;
254+
assert!(
255+
(actual - expected).hypot() <= epsilon,
256+
"expected {expected:?}, got {actual:?}"
257+
);
258+
}
259+
260+
#[test]
261+
fn line_tangent_matches_displacement() {
262+
let line = Line::new((1.0, 2.0), (4.0, 6.0));
263+
let expected = Vec2::new(3.0, 4.0);
264+
assert_vec_near(line.tangent(0.0), expected);
265+
assert_vec_near(line.tangent(0.5), expected);
266+
assert_vec_near(line.tangent(1.0), expected);
267+
assert_vec_near(line.tangent(0.25), expected);
268+
assert_vec_near(line.unit_tangent(0.25).unwrap(), expected.normalize());
269+
}
270+
271+
#[test]
272+
fn degenerate_line_has_no_unit_tangent() {
273+
let line = Line::new((1.0, 2.0), (1.0, 2.0));
274+
assert_eq!(line.tangent(0.5), Vec2::ZERO);
275+
assert_eq!(line.unit_tangent(0.5), None);
276+
}
277+
278+
#[test]
279+
fn const_point_has_no_unit_tangent() {
280+
let point = Line::new((2.0, -3.0), (5.0, 1.0)).deriv();
281+
assert_eq!(point.tangent(0.5), Vec2::ZERO);
282+
assert_eq!(point.unit_tangent(0.5), None);
283+
}
284+
285+
#[test]
286+
fn quad_tangent_matches_derivative_curve() {
287+
let quad = QuadBez::new((0.0, 0.0), (2.0, 3.0), (4.0, 0.0));
288+
for t in [0.0, 0.25, 0.5, 1.0] {
289+
assert_vec_near(quad.tangent(t), quad.deriv().eval(t).to_vec2());
290+
}
291+
}
292+
293+
#[test]
294+
fn cubic_tangent_matches_derivative_curve() {
295+
let cubic = CubicBez::new((0.0, 0.0), (1.0, 2.0), (3.0, 2.0), (4.0, 0.0));
296+
for t in [0.0, 0.25, 0.5, 1.0] {
297+
assert_vec_near(cubic.tangent(t), cubic.deriv().eval(t).to_vec2());
298+
}
299+
}
300+
301+
#[test]
302+
fn pathseg_tangent_dispatches() {
303+
let quad = QuadBez::new((0.0, 0.0), (2.0, 3.0), (4.0, 0.0));
304+
let segment = PathSeg::Quad(quad);
305+
for t in [0.0, 0.25, 0.5, 1.0] {
306+
assert_vec_near(segment.tangent(t), quad.tangent(t));
307+
}
308+
}
309+
}

kurbo/src/quadbez.rs

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,8 @@ use crate::common::{solve_cubic, solve_quadratic};
1111
use crate::MAX_EXTREMA;
1212
use crate::{
1313
Affine, CubicBez, Line, Nearest, ParamCurve, ParamCurveArclen, ParamCurveArea,
14-
ParamCurveCurvature, ParamCurveDeriv, ParamCurveExtrema, ParamCurveNearest, PathEl, Point,
15-
Rect, Shape,
14+
ParamCurveCurvature, ParamCurveDeriv, ParamCurveExtrema, ParamCurveNearest, ParamCurveTangent,
15+
PathEl, Point, Rect, Shape, Vec2,
1616
};
1717

1818
#[cfg(not(feature = "std"))]
@@ -262,6 +262,14 @@ impl ParamCurveDeriv for QuadBez {
262262
}
263263
}
264264

265+
impl ParamCurveTangent for QuadBez {
266+
#[inline]
267+
fn tangent(&self, t: f64) -> Vec2 {
268+
let mt = 1.0 - t;
269+
2.0 * (((self.p1 - self.p0) * mt) + ((self.p2 - self.p1) * t))
270+
}
271+
}
272+
265273
impl ParamCurveArclen for QuadBez {
266274
/// Arclength of a quadratic Bézier segment.
267275
///
@@ -426,7 +434,7 @@ impl Mul<QuadBez> for Affine {
426434
mod tests {
427435
use crate::{
428436
Affine, Nearest, ParamCurve, ParamCurveArclen, ParamCurveArea, ParamCurveDeriv,
429-
ParamCurveExtrema, ParamCurveNearest, Point, QuadBez,
437+
ParamCurveExtrema, ParamCurveNearest, ParamCurveTangent, Point, QuadBez,
430438
};
431439

432440
fn assert_near(p0: Point, p1: Point, epsilon: f64) {
@@ -447,6 +455,7 @@ mod tests {
447455
let d_approx = (p1 - p) * delta.recip();
448456
let d = deriv.eval(t).to_vec2();
449457
assert!((d - d_approx).hypot() < delta * 2.0);
458+
assert!((q.tangent(t) - d).hypot() < 1e-12);
450459
}
451460
}
452461

0 commit comments

Comments
 (0)