Skip to main content

path_traits/
lib.rs

1//! Composable traits for working with parametric curves and geometric queries.
2//!
3//! This crate provides a unified interface for sampling paths by arc-length,
4//! querying differential properties (tangents, curvature, heading), projecting
5//! points onto curves, and composing paths through reversal, concatenation, and
6//! offsetting. The trait design is inspired by Tower's layered approach, but the
7//! focus here is on making geometric queries ergonomic and reusable across any
8//! curve representation.
9//!
10//! # Quick example
11//!
12//! ```
13//! use path_traits::{Path, Scalar};
14//!
15//! fn print_length<P: Path>(path: &P) {
16//!     println!("length = {:?}", path.length());
17//! }
18//! ```
19//!
20//! # Crate features
21//!
22//! - `std` - enables `std`-specific integrations. The core traits work without
23//!   `std`; `core::error::Error` is available via Rust edition 2024. When
24//!   combined with `num-traits`, forwards `std` to that crate for the full
25//!   `Float` trait (instead of `FloatCore`).
26//! - `num-traits` - uses `num-traits` as the [`Scalar`] backend. Without `std`,
27//!   [`Scalar`] is bounded by `FloatCore`; with `std`, it is bounded by `Float`.
28//!
29//! The default build is `no_std` with zero external dependencies, providing
30//! manual [`Scalar`] implementations for `f32` and `f64` using only `core`.
31//!
32//! # What's available
33//!
34//! - [`Scalar`], [`Point`], [`Vector`] - numeric and geometric primitives
35//! - [`Path`], [`ParametricPath`] - sample curves by arc-length or normalized parameter
36//! - [`PathSegment`], [`SegmentedPath`] - work with multi-segment paths like polylines
37//! - [`Tangent`], [`Heading`], [`Curved`], [`FrenetFrame`] - differential geometry queries
38//! - [`Project`] - closest-point projection onto a path
39//! - [`PathExt`] - chainable adapters: [`Reverse`], [`Concat`], [`Offset`]
40
41#![cfg_attr(not(feature = "std"), no_std)]
42#![deny(missing_docs)]
43
44mod differential;
45mod error;
46mod path;
47mod point;
48mod project;
49mod sampler;
50mod scalar;
51mod segment;
52mod transform;
53
54pub use {
55    differential::*, error::*, path::*, point::*, project::*, sampler::*, scalar::*, segment::*,
56    transform::*,
57};
58
59#[cfg(all(test, feature = "std"))]
60mod tests {
61    use super::*;
62
63    /// 2D vector implementation for testing.
64    #[derive(Debug, Clone, Copy, PartialEq)]
65    struct Vec2(f64, f64);
66
67    impl core::ops::Add for Vec2 {
68        type Output = Self;
69        fn add(self, rhs: Self) -> Self {
70            Vec2(self.0 + rhs.0, self.1 + rhs.1)
71        }
72    }
73
74    impl core::ops::Sub for Vec2 {
75        type Output = Self;
76        fn sub(self, rhs: Self) -> Self {
77            Vec2(self.0 - rhs.0, self.1 - rhs.1)
78        }
79    }
80
81    impl core::ops::Mul<f64> for Vec2 {
82        type Output = Self;
83        fn mul(self, rhs: f64) -> Self {
84            Vec2(self.0 * rhs, self.1 * rhs)
85        }
86    }
87
88    impl Vector for Vec2 {
89        type Scalar = f64;
90        fn zero() -> Self {
91            Vec2(0.0, 0.0)
92        }
93        fn dot(self, rhs: Self) -> Self::Scalar {
94            self.0 * rhs.0 + self.1 * rhs.1
95        }
96        fn norm(self) -> Self::Scalar {
97            (self.0 * self.0 + self.1 * self.1).sqrt()
98        }
99    }
100
101    /// 2D point implementation for testing.
102    #[derive(Debug, Clone, Copy, PartialEq)]
103    struct Pt2(f64, f64);
104
105    impl Point for Pt2 {
106        type Scalar = f64;
107        type Vector = Vec2;
108        fn displacement(self, other: Self) -> Self::Vector {
109            Vec2(other.0 - self.0, other.1 - self.1)
110        }
111        fn translate(self, v: Self::Vector) -> Self {
112            Pt2(self.0 + v.0, self.1 + v.1)
113        }
114    }
115
116    /// A 2D line segment used as a test implementation of `Path`.
117    #[derive(Debug, Clone)]
118    struct LineSegment2 {
119        /// Start point of the segment.
120        a: Pt2,
121        /// End point of the segment.
122        b: Pt2,
123        /// Precomputed Euclidean length.
124        len: f64,
125    }
126
127    impl LineSegment2 {
128        /// Construct a line segment between two points.
129        fn new(a: Pt2, b: Pt2) -> Self {
130            let len = a.distance(b);
131            Self { a, b, len }
132        }
133    }
134
135    impl Path for LineSegment2 {
136        type Scalar = f64;
137        type Point = Pt2;
138        type Error = PathError<f64>;
139
140        fn length(&self) -> Self::Scalar {
141            self.len
142        }
143
144        fn sample_at(&self, s: Self::Scalar) -> Result<Self::Point, Self::Error> {
145            if s < 0.0 || s > self.len {
146                return Err(PathError::out_of_domain(s, self.domain()));
147            }
148            if self.len == 0.0 {
149                return Ok(self.a);
150            }
151            let t = s / self.len;
152            Ok(Pt2(
153                self.a.0 + t * (self.b.0 - self.a.0),
154                self.a.1 + t * (self.b.1 - self.a.1),
155            ))
156        }
157    }
158
159    impl PathSegment for LineSegment2 {}
160
161    impl ParametricPath for LineSegment2 {
162        fn sample_t(&self, t: Self::Scalar) -> Result<Self::Point, Self::Error> {
163            if !(0.0..=1.0).contains(&t) {
164                return Err(PathError::out_of_domain(t, 0.0..=1.0));
165            }
166            Ok(Pt2(
167                self.a.0 + t * (self.b.0 - self.a.0),
168                self.a.1 + t * (self.b.1 - self.a.1),
169            ))
170        }
171    }
172
173    impl Tangent for LineSegment2 {
174        fn tangent_at(
175            &self,
176            s: Self::Scalar,
177        ) -> Result<<Self::Point as Point>::Vector, Self::Error> {
178            if s < 0.0 || s > self.len {
179                return Err(PathError::out_of_domain(s, self.domain()));
180            }
181            if self.len == 0.0 {
182                return Err(PathError::degenerate("zero-length segment"));
183            }
184            let dir = self.a.displacement(self.b);
185            Ok(dir * (1.0 / self.len))
186        }
187    }
188
189    impl Heading for LineSegment2 {
190        fn heading_at(&self, s: Self::Scalar) -> Result<Self::Scalar, Self::Error> {
191            if s < 0.0 || s > self.len {
192                return Err(PathError::out_of_domain(s, self.domain()));
193            }
194            if self.len == 0.0 {
195                return Err(PathError::degenerate("zero-length segment"));
196            }
197            let dir = self.a.displacement(self.b);
198            Ok(dir.1.atan2(dir.0))
199        }
200    }
201
202    impl Curved for LineSegment2 {
203        type Curvature = f64;
204        fn curvature_at(&self, s: Self::Scalar) -> Result<Self::Curvature, Self::Error> {
205            if s < 0.0 || s > self.len {
206                return Err(PathError::out_of_domain(s, self.domain()));
207            }
208            Ok(0.0)
209        }
210    }
211
212    /// A 2D polyline composed of multiple `LineSegment2` segments.
213    #[derive(Debug, Clone)]
214    struct Polyline2 {
215        #[allow(dead_code)]
216        /// Original control points (kept for debugging).
217        points: Vec<Pt2>,
218        /// Individual line segments.
219        segments: Vec<LineSegment2>,
220        /// Precomputed length of each segment.
221        seg_lengths: Vec<f64>,
222        /// Total arc-length of the polyline.
223        total_len: f64,
224    }
225
226    impl Polyline2 {
227        /// Construct a polyline from an ordered list of points.
228        fn new(points: Vec<Pt2>) -> Self {
229            assert!(points.len() >= 2);
230            let segments: Vec<_> = points
231                .windows(2)
232                .map(|w| LineSegment2::new(w[0], w[1]))
233                .collect();
234            let seg_lengths: Vec<_> = segments.iter().map(|s| s.len).collect();
235            let total_len = seg_lengths.iter().sum();
236            Self {
237                points,
238                segments,
239                seg_lengths,
240                total_len,
241            }
242        }
243    }
244
245    impl Path for Polyline2 {
246        type Scalar = f64;
247        type Point = Pt2;
248        type Error = PathError<f64>;
249
250        fn length(&self) -> Self::Scalar {
251            self.total_len
252        }
253
254        fn sample_at(&self, s: Self::Scalar) -> Result<Self::Point, Self::Error> {
255            if s < 0.0 || s > self.total_len {
256                return Err(PathError::out_of_domain(s, self.domain()));
257            }
258            let (idx, local) = self.locate(s)?;
259            self.segments[idx].sample_at(local)
260        }
261    }
262
263    impl SegmentedPath for Polyline2 {
264        type Segment = LineSegment2;
265
266        fn segment_count(&self) -> usize {
267            self.segments.len()
268        }
269
270        fn segments(&self) -> impl Iterator<Item = &Self::Segment> + '_ {
271            self.segments.iter()
272        }
273
274        fn locate(&self, s: Self::Scalar) -> Result<(usize, Self::Scalar), Self::Error> {
275            if s < 0.0 || s > self.total_len {
276                return Err(PathError::out_of_domain(s, self.domain()));
277            }
278            let mut remaining = s;
279            for (i, &seg_len) in self.seg_lengths.iter().enumerate() {
280                if remaining <= seg_len {
281                    return Ok((i, remaining));
282                }
283                remaining -= seg_len;
284            }
285            Ok((
286                self.segments.len() - 1,
287                self.seg_lengths.last().copied().unwrap_or(0.0),
288            ))
289        }
290    }
291
292    impl Project for LineSegment2 {
293        fn project(&self, p: Self::Point) -> Result<Self::Scalar, Self::Error> {
294            if self.len == 0.0 {
295                return Ok(0.0);
296            }
297            let dir = self.a.displacement(self.b);
298            let to_p = self.a.displacement(p);
299            let t = (to_p.dot(dir)) / (dir.dot(dir));
300            let t_clamped = t.clamp(0.0, 1.0);
301            Ok(t_clamped * self.len)
302        }
303    }
304
305    #[test]
306    fn line_segment_length() {
307        let seg = LineSegment2::new(Pt2(0.0, 0.0), Pt2(3.0, 4.0));
308        assert!((seg.length() - 5.0).abs() < 1e-10);
309    }
310
311    #[test]
312    fn line_segment_start_end() {
313        let seg = LineSegment2::new(Pt2(1.0, 2.0), Pt2(4.0, 6.0));
314        let start = seg.start().unwrap();
315        let end = seg.end().unwrap();
316        assert_eq!(start, Pt2(1.0, 2.0));
317        assert_eq!(end, Pt2(4.0, 6.0));
318    }
319
320    #[test]
321    fn line_segment_sample_mid() {
322        let seg = LineSegment2::new(Pt2(0.0, 0.0), Pt2(2.0, 0.0));
323        let mid = seg.sample_at(1.0).unwrap();
324        assert!((mid.0 - 1.0).abs() < 1e-10);
325        assert!((mid.1 - 0.0).abs() < 1e-10);
326    }
327
328    #[test]
329    fn line_segment_out_of_domain() {
330        let seg = LineSegment2::new(Pt2(0.0, 0.0), Pt2(1.0, 0.0));
331        let err = seg.sample_at(-0.1).unwrap_err();
332        assert!(
333            matches!(err, PathError::OutOfDomain { param, domain } if (param - -0.1).abs() < 1e-10 && *domain.start() == 0.0 && (*domain.end() - 1.0).abs() < 1e-10)
334        );
335        let err = seg.sample_at(1.1).unwrap_err();
336        assert!(
337            matches!(err, PathError::OutOfDomain { param, domain } if (param - 1.1).abs() < 1e-10 && *domain.start() == 0.0 && (*domain.end() - 1.0).abs() < 1e-10)
338        );
339    }
340
341    #[test]
342    fn polyline_segment_count() {
343        let pts = vec![Pt2(0.0, 0.0), Pt2(1.0, 0.0), Pt2(1.0, 1.0)];
344        let poly = Polyline2::new(pts);
345        assert_eq!(poly.segment_count(), 2);
346        assert_eq!(poly.segments().count(), 2);
347    }
348
349    #[test]
350    fn polyline_total_length() {
351        let pts = vec![Pt2(0.0, 0.0), Pt2(3.0, 0.0), Pt2(3.0, 4.0)];
352        let poly = Polyline2::new(pts);
353        assert!((poly.length() - 7.0).abs() < 1e-10);
354    }
355
356    #[test]
357    fn polyline_locate_roundtrip() {
358        let pts = vec![Pt2(0.0, 0.0), Pt2(2.0, 0.0), Pt2(2.0, 3.0)];
359        let poly = Polyline2::new(pts);
360        let (idx, local) = poly.locate(1.0).unwrap();
361        assert_eq!(idx, 0);
362        assert!((local - 1.0).abs() < 1e-10);
363        let (idx, local) = poly.locate(3.0).unwrap();
364        assert_eq!(idx, 1);
365        assert!((local - 1.0).abs() < 1e-10);
366    }
367
368    #[test]
369    fn line_segment_tangent() {
370        let seg = LineSegment2::new(Pt2(0.0, 0.0), Pt2(3.0, 4.0));
371        let t = seg.tangent_at(2.5).unwrap();
372        let expected = Vec2(0.6, 0.8);
373        assert!((t.0 - expected.0).abs() < 1e-10);
374        assert!((t.1 - expected.1).abs() < 1e-10);
375    }
376
377    #[test]
378    fn line_segment_heading() {
379        let seg = LineSegment2::new(Pt2(0.0, 0.0), Pt2(1.0, 1.0));
380        let h = seg.heading_at(0.5).unwrap();
381        assert!((h - std::f64::consts::FRAC_PI_4).abs() < 1e-10);
382    }
383
384    #[test]
385    fn line_segment_curvature_zero() {
386        let seg = LineSegment2::new(Pt2(0.0, 0.0), Pt2(5.0, 0.0));
387        assert_eq!(seg.curvature_at(2.5).unwrap(), 0.0);
388    }
389
390    #[test]
391    fn equidistant_samples() {
392        let seg = LineSegment2::new(Pt2(0.0, 0.0), Pt2(4.0, 0.0));
393        let samples: Vec<_> = equidistant(&seg, 1.0).collect();
394        assert_eq!(samples.len(), 5);
395        for r in &samples {
396            assert!(r.is_ok());
397        }
398    }
399
400    #[test]
401    fn n_samples_count() {
402        let seg = LineSegment2::new(Pt2(0.0, 0.0), Pt2(10.0, 0.0));
403        let samples: Vec<_> = n_samples(&seg, 3).collect();
404        assert_eq!(samples.len(), 3);
405    }
406
407    #[test]
408    fn uniform_t_samples() {
409        let seg = LineSegment2::new(Pt2(0.0, 0.0), Pt2(10.0, 0.0));
410        let samples: Vec<_> = uniform_t(&seg, 5).collect();
411        assert_eq!(samples.len(), 5);
412    }
413
414    #[test]
415    fn reverse_double_equals_original() {
416        let seg = LineSegment2::new(Pt2(0.0, 0.0), Pt2(5.0, 0.0));
417        let rev = seg.clone().reverse();
418        let rev2 = rev.reverse();
419        for s in [0.0, 1.25, 2.5, 3.75, 5.0] {
420            let orig = seg.sample_at(s).unwrap();
421            let double_rev = rev2.sample_at(s).unwrap();
422            assert!((orig.0 - double_rev.0).abs() < 1e-10);
423            assert!((orig.1 - double_rev.1).abs() < 1e-10);
424        }
425    }
426
427    #[test]
428    fn concat_total_length() {
429        let a = LineSegment2::new(Pt2(0.0, 0.0), Pt2(3.0, 0.0));
430        let b = LineSegment2::new(Pt2(3.0, 0.0), Pt2(3.0, 4.0));
431        let c = a.concat(b);
432        assert!((c.length() - 7.0).abs() < 1e-10);
433    }
434
435    #[test]
436    fn domain_matches_length() {
437        let seg = LineSegment2::new(Pt2(0.0, 0.0), Pt2(3.0, 4.0));
438        let domain = seg.domain();
439        assert!(*domain.start() == 0.0);
440        assert!((*domain.end() - 5.0).abs() < 1e-10);
441    }
442
443    #[test]
444    fn project_onto_segment() {
445        let seg = LineSegment2::new(Pt2(0.0, 0.0), Pt2(4.0, 0.0));
446        let s = seg.project(Pt2(2.0, 3.0)).unwrap();
447        assert!((s - 2.0).abs() < 1e-10);
448    }
449
450    #[test]
451    fn project_clamped_to_end() {
452        let seg = LineSegment2::new(Pt2(0.0, 0.0), Pt2(3.0, 0.0));
453        let s = seg.project(Pt2(5.0, 1.0)).unwrap();
454        assert!((s - 3.0).abs() < 1e-10);
455    }
456
457    #[test]
458    fn project_clamped_to_start() {
459        let seg = LineSegment2::new(Pt2(2.0, 0.0), Pt2(5.0, 0.0));
460        let s = seg.project(Pt2(0.0, 1.0)).unwrap();
461        assert!(s.abs() < 1e-10);
462    }
463
464    #[test]
465    fn closest_point_on_segment() {
466        let seg = LineSegment2::new(Pt2(0.0, 0.0), Pt2(4.0, 0.0));
467        let cp = seg.closest_point(Pt2(1.0, 5.0)).unwrap();
468        assert!((cp.0 - 1.0).abs() < 1e-10);
469        assert!((cp.1 - 0.0).abs() < 1e-10);
470    }
471
472    #[test]
473    fn concat_sample_across_boundary() {
474        let a = LineSegment2::new(Pt2(0.0, 0.0), Pt2(3.0, 0.0));
475        let b = LineSegment2::new(Pt2(3.0, 0.0), Pt2(3.0, 4.0));
476        let c = a.concat(b);
477        let junction = c.sample_at(3.0).unwrap();
478        assert!((junction.0 - 3.0).abs() < 1e-10);
479        assert!((junction.1 - 0.0).abs() < 1e-10);
480        let into_b = c.sample_at(5.0).unwrap();
481        assert!((into_b.0 - 3.0).abs() < 1e-10);
482        assert!((into_b.1 - 2.0).abs() < 1e-10);
483    }
484
485    #[test]
486    fn reverse_tangent_negated() {
487        let seg = LineSegment2::new(Pt2(0.0, 0.0), Pt2(3.0, 4.0));
488        let rev = seg.clone().reverse();
489        let orig_t = seg.tangent_at(2.0).unwrap();
490        let rev_t = rev.tangent_at(seg.length() - 2.0).unwrap();
491        assert!((rev_t.0 - (-orig_t.0)).abs() < 1e-10);
492        assert!((rev_t.1 - (-orig_t.1)).abs() < 1e-10);
493    }
494
495    #[test]
496    fn reverse_curvature_negated() {
497        let seg = LineSegment2::new(Pt2(0.0, 0.0), Pt2(5.0, 0.0));
498        let rev = seg.clone().reverse();
499        let orig_k = seg.curvature_at(2.0).unwrap();
500        let rev_k = rev.curvature_at(3.0).unwrap();
501        assert!((rev_k - (-orig_k)).abs() < 1e-10);
502    }
503
504    #[test]
505    fn reverse_start_is_original_end() {
506        let seg = LineSegment2::new(Pt2(1.0, 2.0), Pt2(4.0, 6.0));
507        let rev = seg.clone().reverse();
508        let rev_start = rev.start().unwrap();
509        let orig_end = seg.end().unwrap();
510        assert_eq!(rev_start, orig_end);
511    }
512}