Skip to content

sunsided/path-traits

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

15 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

path-traits

Crates.io Documentation License: EUPL-1.2 Rust Edition Rust 1.85+ unsafe forbidden

Tower-like generic traits for parametric paths, segments, and geometric queries.

What is this?

path-traits is a small, dependency-free trait crate that provides a unified interface for parametric curves. It decouples geometric queries (sampling by arc-length, tangents, curvature, projection, composition) from curve representation (line segments, arcs, Béziers, B-splines, NURBS, polylines, and more).

The crate is no_std by default, has zero mandatory dependencies, is #![forbid(unsafe_code)], and is generic over a Scalar type so it works with f32, f64, or custom scalar types via the optional num-traits feature.

Who is it for?

  • Path planning / motion planning / robotics libraries that need a shared vocabulary for curve queries.
  • Graphics and CAD code that consumes curves generically, regardless of underlying representation.
  • Numerical libraries that want arc-length-based sampling, tangents, and curvature without tying to a specific curve type.
  • Anyone who wants to write fn foo<P: Path>(p: &P) once and have it work for every curve type in the ecosystem.

Installation

# Default: no_std, zero deps
cargo add path-traits

# With num-traits for the Float/FloatCore Scalar backend
cargo add path-traits --features num-traits

# With std integrations
cargo add path-traits --features std

# Both
cargo add path-traits --features "std num-traits"

The trait hierarchy at a glance

Using the traits (consumer guide)

Writing a generic function

use path_traits::{Path, Scalar};

fn midpoint<P: Path>(p: &P) -> Result<P::Point, P::Error> {
    let half = p.length() / P::Scalar::from_usize(2);
    p.sample_at(half)
}

Combining traits for richer queries

use path_traits::{Path, Tangent, Curved};

fn path_info<P: Path + Tangent + Curved>(p: &P, s: P::Scalar)
    -> Result<String, P::Error>
{
    let tangent = p.tangent_at(s)?;
    let curvature = p.curvature_at(s)?;
    Ok(format!("T=({:?}), k={:?}", tangent, curvature))
}

Sampling with helper functions

use path_traits::{Path, equidistant, n_samples, uniform_t};

fn sample_demo<P: Path>(p: &P) {
    // Every 1.0 units of arc-length
    let _: Vec<_> = equidistant(p, 1.0).collect();

    // Exactly 10 points
    let _: Vec<_> = n_samples(p, 10).collect();
}

// uniform_t requires ParametricPath
use path_traits::ParametricPath;
fn uniform_demo<P: ParametricPath>(p: &P) {
    let _: Vec<_> = uniform_t(p, 5).collect();
}

Path composition with PathExt

use path_traits::{Path, PathExt, Heading, Tangent};

fn compose_demo<P: Path + Clone>(a: P, b: P)
where
    P: Path<Scalar = f64, Point = P::Point>,
{
    // Reverse direction
    let _reversed = a.clone().reverse();

    // Join end-to-end
    let _concat = a.concat(b);

    // offset() requires Tangent + Heading bounds
    // let _offset = a.clone().offset(0.5);
}

Closest-point projection

use path_traits::{Path, Project};

fn nearest<P: Path + Project>(p: &P, query: P::Point) -> Result<P::Point, P::Error> {
    p.closest_point(query)
}

Implementing the traits (implementer guide)

This section describes what you must implement for each trait, what you get for free, and the invariants your implementation must uphold.

Scalar

The Scalar trait is the numeric backbone of the crate. You typically do not need to implement it — blanket implementations exist for f32 / f64 in all feature configurations. Only implement Scalar manually for exotic numeric types.

Feature-dependent supertraits:

Features Supertrait
(none) Add + Sub + Mul + Div + Neg + PartialOrd + Debug + Copy + 'static
num-traits FloatCore + Debug + Copy + 'static
num-traits + std Float + Debug + Copy + 'static

Required methods: zero(), one(), from_usize(n).

Vector

Vector represents a displacement or derivative in Euclidean space.

Required: zero(), dot(self, rhs), norm(self).

Required operator bounds: Add<Output=Self>, Sub<Output=Self>, Mul<Scalar, Output=Self>.

Invariant: norm() >= 0 and (v * 0).norm() == 0.

Point

Point represents a position in an affine space.

Required: displacement(self, other) -> Vector, translate(self, v) -> Point.

Provided: distance(self, other) (delegates to displacement().norm()).

Invariant: a.translate(a.displacement(b)) == b.

Path

Path is the core trait for arc-length-parameterized curves.

Required associated types:

  • type Scalar: Scalar
  • type Point: Point<Scalar = Self::Scalar>
  • type Error: From<PathError<Self::Scalar>>

Required methods:

  • length(&self) -> Self::Scalar — total arc-length.
  • sample_at(&self, s: Self::Scalar) -> Result<Self::Point, Self::Error> — sample at arc-length s ∈ [0, length].

Provided: start(), end(), domain().

Invariants:

  • sample_at(0) == start() and sample_at(length()) == end().
  • Return PathError::OutOfDomain { param, domain } when s ∉ [0, length] — use the PathError::out_of_domain(s, self.domain()) helper. Use PathError::degenerate(reason) for zero-length paths and PathError::not_differentiable(s, reason) for cusps.
  • sample_at should be arc-length-parameterized (constant speed). If it is not, also implement ParametricPath and override t_to_s / s_to_t.

Error context: PathError<S> carries the offending parameter and valid domain so consumers can produce precise diagnostics:

use path_traits::{Path, PathError};

fn handle_error<P: Path>(path: &P, result: Result<P::Point, P::Error>) {
    if let Err(err) = result {
        // Convert to PathError to inspect the payload
        if let PathError::OutOfDomain { param, domain } = err.into() {
            eprintln!("parameter {:?} is outside [{:?}, {:?}]", param, domain.start(), domain.end());
        }
    }
}

ParametricPath

ParametricPath extends Path with normalized-parameter sampling.

Required:

  • sample_t(&self, t: Self::Scalar) -> Result<Self::Point, Self::Error> — sample at t ∈ [0, 1].

Provided (default linear conversion):

  • t_to_s(&self, t) -> Self::Scalar — default: t * length().
  • s_to_t(&self, s) -> Self::Scalar — default: s / length().

Invariants: sample_t(0) == start(), sample_t(1) == end().

Override t_to_s / s_to_t if your path is not constant-speed.

PathSegment

PathSegment is a marker trait for primitive, non-subdivided curves (a single line segment, a Bézier curve, etc.). Implement it on any type that already implements Path.

impl PathSegment for MyCurve {}

SegmentedPath

SegmentedPath is for paths composed of multiple segments (polylines, chains).

Required associated type:

  • type Segment: PathSegment<Scalar = Self::Scalar, Point = Self::Point, Error = Self::Error>

Note the same-type constraint: Scalar, Point, and Error must all match the parent path.

Required methods:

  • segment_count(&self) -> usize
  • segments(&self) -> impl Iterator<Item = &Self::Segment> + '_
  • locate(&self, s: Self::Scalar) -> Result<(usize, Self::Scalar), Self::Error>

Provided: segment(&self, i) (by index, returns Option).

Invariants:

  • Segment lengths sum to length().
  • locate(s) returns local_s ∈ [0, segment_length].

Tangent

Tangent provides the unit tangent vector at any arc-length.

Required:

  • tangent_at(&self, s) -> Result<Vector, Self::Error>

The returned vector must be unit-length and point in the direction of increasing s. Use PathError::degenerate(reason) for zero-length paths and PathError::not_differentiable(s, reason) for cusps.

Heading

Heading provides the planar heading angle (2D only).

Required:

  • heading_at(&self, s) -> Result<Self::Scalar, Self::Error>

Returns radians, using the atan2(y, x) convention (counter-clockwise from the positive x-axis).

Curved

Curved provides curvature at any arc-length.

Required associated type:

  • type Curvature — scalar in 2D, vector in 3D.

Required method:

  • curvature_at(&self, s) -> Result<Self::Curvature, Self::Error>

Sign convention (2D): positive for left turns (CCW), negative for right turns.

FrenetFrame (advanced)

FrenetFrame provides the full Frenet-Serret frame. This is the most complex differential trait and is optional for basic implementations.

Bounds: Tangent + Curved.

Required associated type:

  • type Frame — e.g. (Tangent, Normal) in 2D or (T, N, B) in 3D.

Required method:

  • frame_at(&self, s) -> Result<Self::Frame, Self::Error>

BishopFrame (advanced, 3D)

BishopFrame provides a rotation-minimizing frame stream. Unlike FrenetFrame, which is a local, pointwise query, Bishop frames are path-dependent: they require an explicit seed frame and produce an ordered sequence of frames at monotonically non-decreasing arc-length samples.

Bounds: Tangent.

Required associated types:

  • type Frame — e.g. (T, M1, M2) in 3D.
  • type Seed — the initial frame or "up" hint that disambiguates the family of rotation-minimizing frames.

Required method:

  • bishop_frames(&self, seed, samples) -> impl Iterator<Item = Result<Self::Frame, Self::Error>>

Key differences from Frenet frames:

  • Defined wherever the tangent exists — no breakdown at zero curvature.
  • No discontinuous frame flip at inflection points.
  • Path-dependent: the same s yields different frames under different seeds.
  • Requires monotonic sample ordering for efficient integration.

In 2D, a rotation-minimizing frame collapses to (T, N) and adds nothing beyond Tangent. This trait is primarily useful for 3D paths.

Project

Project provides closest-point projection onto a path.

Required:

  • project(&self, p: Self::Point) -> Result<Self::Scalar, Self::Error> — returns the arc-length s of the closest point.

Provided: closest_point(&self, p) -> Result<Self::Point, Self::Error> (calls project then sample_at).

Invariant: the returned s minimizes path.sample_at(s).distance(p) over [0, length].

Minimal implementation example

Here is a complete, minimal implementation for a 2D line segment:

use path_traits::*;

// Vector
#[derive(Debug, Clone, Copy, PartialEq)]
struct Vec2(f64, f64);

impl core::ops::Add for Vec2 {
    type Output = Self;
    fn add(self, rhs: Self) -> Self { Vec2(self.0 + rhs.0, self.1 + rhs.1) }
}

impl core::ops::Sub for Vec2 {
    type Output = Self;
    fn sub(self, rhs: Self) -> Self { Vec2(self.0 - rhs.0, self.1 - rhs.1) }
}

impl core::ops::Mul<f64> for Vec2 {
    type Output = Self;
    fn mul(self, rhs: f64) -> Self { Vec2(self.0 * rhs, self.1 * rhs) }
}

impl Vector for Vec2 {
    type Scalar = f64;
    fn zero() -> Self { Vec2(0.0, 0.0) }
    fn dot(self, rhs: Self) -> f64 { self.0 * rhs.0 + self.1 * rhs.1 }
    fn norm(self) -> f64 { (self.0 * self.0 + self.1 * self.1).sqrt() }
}

// Point
#[derive(Debug, Clone, Copy, PartialEq)]
struct Pt2(f64, f64);

impl Point for Pt2 {
    type Scalar = f64;
    type Vector = Vec2;
    fn displacement(self, other: Self) -> Vec2 {
        Vec2(other.0 - self.0, other.1 - self.1)
    }
    fn translate(self, v: Vec2) -> Self { Pt2(self.0 + v.0, self.1 + v.1) }
}

// Path
struct LineSegment2 { a: Pt2, b: Pt2, len: f64 }

impl LineSegment2 {
    fn new(a: Pt2, b: Pt2) -> Self {
        Self { a, b, len: a.distance(b) }
    }
}

impl Path for LineSegment2 {
    type Scalar = f64;
    type Point = Pt2;
    type Error = PathError<f64>;

    fn length(&self) -> f64 { self.len }

    fn sample_at(&self, s: f64) -> Result<Pt2, PathError<f64>> {
        if s < 0.0 || s > self.len { return Err(PathError::out_of_domain(s, 0.0..=self.len)); }
        if self.len == 0.0 { return Ok(self.a); }
        let t = s / self.len;
        Ok(Pt2(
            self.a.0 + t * (self.b.0 - self.a.0),
            self.a.1 + t * (self.b.1 - self.a.1),
        ))
    }
}

Adapter bounds note

The .offset() method on PathExt requires Self: Tangent + Heading. If your type does not implement these traits, the compiler will reject .offset() calls. This is intentional — offsetting requires knowledge of the tangent direction and heading to compute the displaced curve.

Feature flags

Feature Description
(default) no_std, zero deps. f32/f64 work via manual Scalar impls.
num-traits Uses num-traits as the Scalar backend. Without std, bounded by FloatCore; with std, bounded by Float.
std Enables std-specific integrations. When combined with num-traits, forwards std to that crate.

The core traits work in all three configurations. f32 and f64 are always available as Scalar types.

MSRV / Edition

Rust 2024 edition, MSRV 1.85+.

License

Licensed under any of:

  • EUPL-1.2
  • MIT
  • Apache-2.0

Choose whichever suits your needs.

Repository

Source code, issues, and pull requests: https://github.com/sunsided/path-traits

About

Tower-like generic traits for parametric paths, segments, and geometric queries

Topics

Resources

Stars

Watchers

Forks

Contributors

Languages