bevy icon indicating copy to clipboard operation
bevy copied to clipboard

`Curve` implementation for cubic curves

Open mweatherley opened this issue 2 months ago • 0 comments

This is an in-progress prototype implementation of Curve for the geometric curves created in bevy_math. Everything here is subject to heavy renaming; I just wanted to get something functional up and running. Notably, this design presently excludes NURBS curves, which is a whole new bag of worms.

Objective

Add support for returning data in the form of a Curve (in the sense of the curve-trait RFC) to the bevy_math cubic curves.

The output data of these curves is somewhat stratified. For example, the output of the CubicBezier builder has very few guarantees on its output compared to CubicBSpline; in the first case, it doesn't make too much sense to ask about derivatives globally, in general (since there is no reason for them to match up between segments), whereas in the latter, one always has globally valid first and second derivatives. The aim is for the API to reflect this in some way without getting in the user's way excessively.

Solution

Technical changes

This particular prototype represents, essentially, the most "overengineered" part of the solution space, since it provides explicit type-level safeguards that prevent users from, for instance, accessing a Curve holding both the position and velocity for curve constructions which are not globally C1 (although this limitation can be bypassed). It consists of a number of moving parts to accomplish this:

  • Firstly, CubicCurve now has a second parameter which serves as a marker. This is limited to a set of marker types which satisfy a marker trait, Smoothness, and the set of all of them consists of NoGuarantees, C0, C1, and C2.
  • Secondly, there are a few new data types which play the role of T in Curve<T>; these combine position, velocity, and acceleration data into convenient packages to be used together; e.g.:
    pub struct C0Data<P: VectorSpace> {
        position: P,
    }
    
    pub struct C1Data<P: VectorSpace> {
        position: P,
        velocity: P,
    }
    
    pub struct C2Data<P: VectorSpace> {
        position: P,
        velocity: P,
        acceleration: P,
    }
    
  • Next, each of these has a corresponding trait which dictates whether a curve with that output can be created from the given data; e.g.:
    /// A trait for a type that can be turned into a curve which is continuous and also has
    /// continuous derivatives.
    pub trait ToC1Curve<P: VectorSpace> {
    /// The type of the curve.
    type CurveType: Curve<C1Data<P>>;
    
    /// Yield a C1 curve.
    fn to_curve_c1(&self) -> Self::CurveType;
    }
    
  • These traits are implemented using wrapper structs; the point is that, for example, ToC1Curve is implemented by any CubicCurve with a marker value at least C1 (so, C1 and C2 in that case):
    /// A wrapper struct which actually implements the [`Curve`] trait associated to its level.
    /// Here, the `Smoothness` parameter is actually precise; a `CubicCurveWrapper` with a
    /// `Smoothness` of `C2` does not access the `C0` data, for example.
    pub struct CubicCurveWrapper<L, P>
    where
        L: Smoothness,
        P: VectorSpace,
    {
        inner: CubicCurve<L, P>,
    }
    
    // ...
    
    impl<L, P> ToC1Curve<P> for CubicCurve<L, P>
    where
        L: AtLeastC1,
        P: VectorSpace,
    {
        type CurveType = CubicCurveWrapper<C1, P>;
    
        fn to_curve_c1(&self) -> Self::CurveType {
            CubicCurveWrapper {
                inner: self.clone().transmute_smoothness(),
            }
        }
    }
    
  • Furthermore, the Smoothness parameter can be raised explicitly by using bless functions which otherwise have no effect. This allows, for example, C1 data to be accessed on a curve whose continuity of derivatives cannot be guaranteed at the type level (either for hacky reasons or because the type jsut fails to capture enough knowledge); these have signatures that end up looking like this (although they are actually implemented using traits):
    /// Grants access to C1-level curve data that cannot be guaranteed to be valid by the
    /// invariants from this curve's construction.
    fn bless_c1(self) -> CubicCurve<C1, P> { //... }
    

Usage

What this actually ends up looking like for users is this; let's suppose, for example, that we want to extract a Curve from a cubic B-spline:

// ... definition of `control_points` ...
// Use the B-spline construction to get a `CubicCurve<C2, P>`
let my_curve = CubicBSpline::new(control_points).to_curve();
// Now we can get something which is actually a `Curve<C1Data<P>>` by using one of the trait methods:
let position_and_velocity_curve = my_curve.to_curve_c1();

On the other hand, suppose that we have something like a Hermite interpolation:

// ... definitions of `control_points`, `tangents`, ...
// Use the data to get a curve from a Hermite spline (`CubicCurve<C1, P>`):
let my_curve = CubicHermite::new(control_points, tangents).to_curve();
// It's only C1, but I want the acceleration anyway!!! HAHAHA!!!
// This is a `Curve<C2Data<P>>`; the resulting acceleration might be discontinuous.
let c2_data_curve = my_curve.bless_c2().to_curve_c2();

(As you can see, the terminology is presently quite suboptimal.)


Changelog

Migration Guide

Any external code explicitly using the CubicCurve type (e.g. as part of a Component) will need to be demarcated with a marker type depending on how it is used.

Discussion

As I wrote above, this feels slightly overengineered to me, and the places where that actually comes through negatively, in my opinion, are:

  • When users have to actually interact with the marker types it's weird
  • The C0Data, C1Data, etc. bundling doesn't feel necessarily natural from a user perspective (especially with names like to_curve_c1, but perhaps that can be remedied somehow).

Another direction I could see going with this is just not bundling the position/velocity/acceleration data together, and instead just having separate methods splitting out Curve<P>s for the position, velocity, and acceleration separately. This has the advantage that it requires potentially less interfacing with technical jargon, but users would often need to zip the data together to use it in other processes (e.g. basically anything that requires the velocity would probably require position as well). Of course, this could still be gated behind the "smoothness" bounds in essentially the same way.

mweatherley avatar Apr 22 '24 20:04 mweatherley