Cyclic splines (#14106)

# Objective

Fill a gap in the functionality of our curve constructions by allowing
users to easily build cyclic curves from control data.

## Solution

Here I opted for something lightweight and discoverable. There is a new
`CyclicCubicGenerator` trait with a method `to_curve_cyclic` which uses
splines' control data to create curves that are cyclic. For now, its
signature is exactly like that of `CubicGenerator` — `to_curve_cyclic`
just yields a `CubicCurve`:
```rust
/// Implement this on cubic splines that can generate a cyclic cubic curve from their spline parameters.
///
/// This makes sense only when the control data can be interpreted cyclically.
pub trait CyclicCubicGenerator<P: VectorSpace> {
    /// Build a cyclic [`CubicCurve`] by computing the interpolation coefficients for each curve segment.
    fn to_curve_cyclic(&self) -> CubicCurve<P>;
}
```

This trait has been implemented for `CubicHermite`,
`CubicCardinalSpline`, `CubicBSpline`, and `LinearSpline`:

<img width="753" alt="Screenshot 2024-07-01 at 8 58 27 PM"
src="https://github.com/bevyengine/bevy/assets/2975848/69ae0802-3b78-4fb9-b73a-6f842cf3b33c">
<img width="628" alt="Screenshot 2024-07-01 at 9 00 14 PM"
src="https://github.com/bevyengine/bevy/assets/2975848/2992175a-a96c-40fc-b1a1-5206c3572cde">
<img width="606" alt="Screenshot 2024-07-01 at 8 59 36 PM"
src="https://github.com/bevyengine/bevy/assets/2975848/9e99eb3a-dbe6-42da-886c-3d3e00410d03">
<img width="603" alt="Screenshot 2024-07-01 at 8 59 01 PM"
src="https://github.com/bevyengine/bevy/assets/2975848/d037bc0c-396a-43af-ab5c-fad9a29417ef">

(Each type pictured respectively with the control points rendered as
green spheres; tangents not pictured in the case of the Hermite spline.)

These curves are all parametrized so that the output of `to_curve` and
the output of `to_curve_cyclic` are similar. For instance, in
`CubicCardinalSpline`, the first output segment is a curve segment
joining the first and second control points in each, although it is
constructed differently. In the other cases, the segments from
`to_curve` are a subset of those in `to_curve_cyclic`, with the new
segments appearing at the end.

## Testing

I rendered cyclic splines from control data and made sure they looked
reasonable. Existing tests are intact for splines where previous code
was modified. (Note that the coefficient computation for cyclic spline
segments is almost verbatim identical to that of their non-cyclic
counterparts.)

The Bezier benchmarks also look fine.

---

## Changelog

- Added `CyclicCubicGenerator` trait to `bevy_math::cubic_splines` for
creating cyclic curves from control data.
- Implemented `CyclicCubicGenerator` for `CubicHermite`,
`CubicCardinalSpline`, `CubicBSpline`, and `LinearSpline`.
- `bevy_math` now depends on `itertools`.

---

## Discussion

### Design decisions

The biggest thing here is just the approach taken in the first place:
namely, the cyclic constructions use new methods on the same old
structs. This choice was made to reduce friction and increase
discoverability but also because creating new ones just seemed
unnecessary: the underlying data would have been the same, so creating
something like "`CyclicCubicBSpline`" whose internally-held control data
is regarded as cyclic in nature doesn't really accomplish much — the end
result for the user is basically the same either way.

Similarly, I don't presently see a pressing need for `to_curve_cyclic`
to output something other than a `CubicCurve`, although changing this in
the future may be useful. See below.

A notable omission here is that `CyclicCubicGenerator` is not
implemented for `CubicBezier`. This is not a gap waiting to be filled —
`CubicBezier` just doesn't have enough data to join its start with its
end without just making up the requisite control points wholesale. In
all the cases where `CyclicCubicGenerator` has been implemented here,
the fashion in which the ends are connected is quite natural and follows
the semantics of the associated spline construction.

### Future direction

There are two main things here:
1. We should investigate whether we should do something similar for
NURBS. I just don't know that much about NURBS at the moment, so I
regarded this as out of scope for the PR.
2. We may eventually want to change the output type of
`CyclicCubicGenerator::to_curve_cyclic` to a type which reifies the
cyclic nature of the curve output. This wasn't done in this PR because
I'm unsure how much value a type-level guarantee of cyclicity actually
has, but if some useful features make sense only in the case of cyclic
curves, this might be worth pursuing.
This commit is contained in:
Matty 2024-07-17 09:02:31 -04:00 committed by GitHub
parent 72e7a4fed2
commit 3484bd916f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 654 additions and 36 deletions

View file

@ -3037,6 +3037,17 @@ description = "Demonstrates creating and using custom Ui materials"
category = "UI (User Interface)"
wasm = true
[[example]]
name = "cubic_splines"
path = "examples/math/cubic_splines.rs"
doc-scrape-examples = true
[package.metadata.example.cubic_splines]
name = "Cubic Splines"
description = "Exhibits different modes of constructing cubic curves using splines"
category = "Math"
wasm = true
[[example]]
name = "render_primitives"
path = "examples/math/render_primitives.rs"

View file

@ -12,6 +12,7 @@ rust-version = "1.68.2"
[dependencies]
glam = { version = "0.27", features = ["bytemuck"] }
thiserror = "1.0"
itertools = "0.13.0"
serde = { version = "1", features = ["derive"], optional = true }
libm = { version = "0.2", optional = true }
approx = { version = "0.5", optional = true }

View file

@ -4,6 +4,7 @@ use std::{fmt::Debug, iter::once};
use crate::{Vec2, VectorSpace};
use itertools::Itertools;
use thiserror::Error;
#[cfg(feature = "bevy_reflect")]
@ -46,7 +47,7 @@ use bevy_reflect::{std_traits::ReflectDefault, Reflect};
#[derive(Clone, Debug)]
#[cfg_attr(feature = "bevy_reflect", derive(Reflect), reflect(Debug))]
pub struct CubicBezier<P: VectorSpace> {
/// The control points of the Bezier curve
/// The control points of the Bezier curve.
pub control_points: Vec<[P; 4]>,
}
@ -95,8 +96,13 @@ impl<P: VectorSpace> CubicGenerator<P> for CubicBezier<P> {
/// Tangents are explicitly defined at each control point.
///
/// ### Continuity
/// The curve is at minimum C0 continuous, meaning it has no holes or jumps. It is also C1, meaning the
/// tangent vector has no sudden jumps.
/// The curve is at minimum C1 continuous, meaning that it has no holes or jumps and the tangent vector also
/// has no sudden jumps.
///
/// ### Parametrization
/// The first segment of the curve connects the first two control points, the second connects the second and
/// third, and so on. This remains true when a cyclic curve is formed with [`to_curve_cyclic`], in which case
/// the final curve segment connects the last control point to the first.
///
/// ### Usage
///
@ -117,10 +123,12 @@ impl<P: VectorSpace> CubicGenerator<P> for CubicBezier<P> {
/// let hermite = CubicHermite::new(points, tangents).to_curve();
/// let positions: Vec<_> = hermite.iter_positions(100).collect();
/// ```
///
/// [`to_curve_cyclic`]: CyclicCubicGenerator::to_curve_cyclic
#[derive(Clone, Debug)]
#[cfg_attr(feature = "bevy_reflect", derive(Reflect), reflect(Debug))]
pub struct CubicHermite<P: VectorSpace> {
/// The control points of the Hermite curve
/// The control points of the Hermite curve.
pub control_points: Vec<(P, P)>,
}
impl<P: VectorSpace> CubicHermite<P> {
@ -133,23 +141,47 @@ impl<P: VectorSpace> CubicHermite<P> {
control_points: control_points.into_iter().zip(tangents).collect(),
}
}
}
impl<P: VectorSpace> CubicGenerator<P> for CubicHermite<P> {
/// The characteristic matrix for this spline construction.
///
/// Each row of this matrix expresses the coefficients of a [`CubicSegment`] as a linear
/// combination of `p_i`, `v_i`, `p_{i+1}`, and `v_{i+1}`, where `(p_i, v_i)` and
/// `(p_{i+1}, v_{i+1})` are consecutive control points with tangents.
#[inline]
fn to_curve(&self) -> CubicCurve<P> {
let char_matrix = [
fn char_matrix(&self) -> [[f32; 4]; 4] {
[
[1., 0., 0., 0.],
[0., 1., 0., 0.],
[-3., -2., 3., -1.],
[2., 1., -2., 1.],
];
]
}
}
impl<P: VectorSpace> CubicGenerator<P> for CubicHermite<P> {
#[inline]
fn to_curve(&self) -> CubicCurve<P> {
let segments = self
.control_points
.windows(2)
.map(|p| {
let (p0, v0, p1, v1) = (p[0].0, p[0].1, p[1].0, p[1].1);
CubicSegment::coefficients([p0, v0, p1, v1], char_matrix)
CubicSegment::coefficients([p0, v0, p1, v1], self.char_matrix())
})
.collect();
CubicCurve { segments }
}
}
impl<P: VectorSpace> CyclicCubicGenerator<P> for CubicHermite<P> {
#[inline]
fn to_curve_cyclic(&self) -> CubicCurve<P> {
let segments = self
.control_points
.iter()
.circular_tuple_windows()
.map(|(&j0, &j1)| {
let (p0, v0, p1, v1) = (j0.0, j0.1, j1.0, j1.1);
CubicSegment::coefficients([p0, v0, p1, v1], self.char_matrix())
})
.collect();
@ -173,6 +205,11 @@ impl<P: VectorSpace> CubicGenerator<P> for CubicHermite<P> {
/// The curve is at minimum C1, meaning that it is continuous (it has no holes or jumps), and its tangent
/// vector is also well-defined everywhere, without sudden jumps.
///
/// ### Parametrization
/// The first segment of the curve connects the first two control points, the second connects the second and
/// third, and so on. This remains true when a cyclic curve is formed with [`to_curve_cyclic`], in which case
/// the final curve segment connects the last control point to the first.
///
/// ### Usage
///
/// ```
@ -186,6 +223,8 @@ impl<P: VectorSpace> CubicGenerator<P> for CubicHermite<P> {
/// let cardinal = CubicCardinalSpline::new(0.3, points).to_curve();
/// let positions: Vec<_> = cardinal.iter_positions(100).collect();
/// ```
///
/// [`to_curve_cyclic`]: CyclicCubicGenerator::to_curve_cyclic
#[derive(Clone, Debug)]
#[cfg_attr(feature = "bevy_reflect", derive(Reflect), reflect(Debug))]
pub struct CubicCardinalSpline<P: VectorSpace> {
@ -211,18 +250,25 @@ impl<P: VectorSpace> CubicCardinalSpline<P> {
control_points: control_points.into(),
}
}
}
impl<P: VectorSpace> CubicGenerator<P> for CubicCardinalSpline<P> {
/// The characteristic matrix for this spline construction.
///
/// Each row of this matrix expresses the coefficients of a [`CubicSegment`] as a linear
/// combination of four consecutive control points.
#[inline]
fn to_curve(&self) -> CubicCurve<P> {
fn char_matrix(&self) -> [[f32; 4]; 4] {
let s = self.tension;
let char_matrix = [
[
[0., 1., 0., 0.],
[-s, 0., s, 0.],
[2. * s, s - 3., 3. - 2. * s, -s],
[-s, 2. - s, s - 2., s],
];
]
}
}
impl<P: VectorSpace> CubicGenerator<P> for CubicCardinalSpline<P> {
#[inline]
fn to_curve(&self) -> CubicCurve<P> {
let length = self.control_points.len();
// Early return to avoid accessing an invalid index
@ -239,30 +285,73 @@ impl<P: VectorSpace> CubicGenerator<P> for CubicCardinalSpline<P> {
let mirrored_last = self.control_points[length - 1] * 2. - self.control_points[length - 2];
let extended_control_points = once(&mirrored_first)
.chain(self.control_points.iter())
.chain(once(&mirrored_last))
.collect::<Vec<_>>();
.chain(once(&mirrored_last));
let segments = extended_control_points
.windows(4)
.map(|p| CubicSegment::coefficients([*p[0], *p[1], *p[2], *p[3]], char_matrix))
.tuple_windows()
.map(|(&p0, &p1, &p2, &p3)| {
CubicSegment::coefficients([p0, p1, p2, p3], self.char_matrix())
})
.collect();
CubicCurve { segments }
}
}
impl<P: VectorSpace> CyclicCubicGenerator<P> for CubicCardinalSpline<P> {
#[inline]
fn to_curve_cyclic(&self) -> CubicCurve<P> {
let len = self.control_points.len();
if len == 0 {
return CubicCurve { segments: vec![] };
}
// This would ordinarily be the last segment, but we pick it out so that we can make it first
// in order to get a desirable parametrization where the first segment connects the first two
// control points instead of the second and third.
let first_segment = {
// We take the indices mod `len` in case `len` is very small.
let p0 = self.control_points[len - 1];
let p1 = self.control_points[0];
let p2 = self.control_points[1 % len];
let p3 = self.control_points[2 % len];
CubicSegment::coefficients([p0, p1, p2, p3], self.char_matrix())
};
let later_segments = self
.control_points
.iter()
.circular_tuple_windows()
.map(|(&p0, &p1, &p2, &p3)| {
CubicSegment::coefficients([p0, p1, p2, p3], self.char_matrix())
})
.take(len - 1);
let mut segments = Vec::with_capacity(len);
segments.push(first_segment);
segments.extend(later_segments);
CubicCurve { segments }
}
}
/// A spline interpolated continuously across the nearest four control points. The curve does not
/// pass through any of the control points.
/// necessarily pass through any of the control points.
///
/// ### Interpolation
/// The curve does not pass through control points.
/// The curve does not necessarily pass through its control points.
///
/// ### Tangency
/// Tangents are automatically computed based on the position of control points.
/// Tangents are automatically computed based on the positions of control points.
///
/// ### Continuity
/// The curve is C2 continuous, meaning it has no holes or jumps, and the tangent vector changes smoothly along
/// the entire curve length. The acceleration continuity of this spline makes it useful for camera paths.
/// The curve is C2 continuous, meaning it has no holes or jumps, the tangent vector changes smoothly along
/// the entire curve, and the acceleration also varies continuously. The acceleration continuity of this
/// spline makes it useful for camera paths.
///
/// ### Parametrization
/// Each curve segment is defined by a window of four control points taken in sequence. When [`to_curve_cyclic`]
/// is used to form a cyclic curve, the three additional segments used to close the curve come last.
///
/// ### Usage
///
@ -277,6 +366,8 @@ impl<P: VectorSpace> CubicGenerator<P> for CubicCardinalSpline<P> {
/// let b_spline = CubicBSpline::new(points).to_curve();
/// let positions: Vec<_> = b_spline.iter_positions(100).collect();
/// ```
///
/// [`to_curve_cyclic`]: CyclicCubicGenerator::to_curve_cyclic
#[derive(Clone, Debug)]
#[cfg_attr(feature = "bevy_reflect", derive(Reflect), reflect(Debug))]
pub struct CubicBSpline<P: VectorSpace> {
@ -290,10 +381,13 @@ impl<P: VectorSpace> CubicBSpline<P> {
control_points: control_points.into(),
}
}
}
impl<P: VectorSpace> CubicGenerator<P> for CubicBSpline<P> {
/// The characteristic matrix for this spline construction.
///
/// Each row of this matrix expresses the coefficients of a [`CubicSegment`] as a linear
/// combination of four consecutive control points.
#[inline]
fn to_curve(&self) -> CubicCurve<P> {
fn char_matrix(&self) -> [[f32; 4]; 4] {
// A derivation for this matrix can be found in "General Matrix Representations for B-splines" by Kaihuai Qin.
// <https://xiaoxingchen.github.io/2020/03/02/bspline_in_so3/general_matrix_representation_for_bsplines.pdf>
// See section 4.1 and equations 7 and 8.
@ -308,16 +402,41 @@ impl<P: VectorSpace> CubicGenerator<P> for CubicBSpline<P> {
.iter_mut()
.for_each(|r| r.iter_mut().for_each(|c| *c /= 6.0));
char_matrix
}
}
impl<P: VectorSpace> CubicGenerator<P> for CubicBSpline<P> {
#[inline]
fn to_curve(&self) -> CubicCurve<P> {
let segments = self
.control_points
.windows(4)
.map(|p| CubicSegment::coefficients([p[0], p[1], p[2], p[3]], char_matrix))
.map(|p| CubicSegment::coefficients([p[0], p[1], p[2], p[3]], self.char_matrix()))
.collect();
CubicCurve { segments }
}
}
impl<P: VectorSpace> CyclicCubicGenerator<P> for CubicBSpline<P> {
#[inline]
fn to_curve_cyclic(&self) -> CubicCurve<P> {
let segments = self
.control_points
.iter()
.circular_tuple_windows()
.map(|(&a, &b, &c, &d)| CubicSegment::coefficients([a, b, c, d], self.char_matrix()))
.collect();
// Note that the parametrization is consistent with the one for `to_curve` but with
// the extra curve segments all tacked on at the end. This might be slightly counter-intuitive,
// since it means the first segment doesn't go "between" the first two control points, but
// between the second and third instead.
CubicCurve { segments }
}
}
/// Error during construction of [`CubicNurbs`]
#[derive(Clone, Debug, Error)]
pub enum CubicNurbsError {
@ -377,7 +496,7 @@ pub enum CubicNurbsError {
/// When there is no knot multiplicity, the curve is C2 continuous, meaning it has no holes or jumps and the
/// tangent vector changes smoothly along the entire curve length. Like the [`CubicBSpline`], the acceleration
/// continuity makes it useful for camera paths. Knot multiplicity of 2 in intermediate knots reduces the
/// continuity to C2, and knot multiplicity of 3 reduces the continuity to C0. The curve is always at least
/// continuity to C1, and knot multiplicity of 3 reduces the continuity to C0. The curve is always at least
/// C0, meaning it has no jumps or holes.
///
/// ### Usage
@ -606,14 +725,20 @@ impl<P: VectorSpace> RationalGenerator<P> for CubicNurbs<P> {
///
/// ### Continuity
/// The curve is C0 continuous, meaning it has no holes or jumps.
///
/// ### Parametrization
/// Each curve segment connects two adjacent control points in sequence. When a cyclic curve is
/// formed with [`to_curve_cyclic`], the final segment connects the last control point with the first.
///
/// [`to_curve_cyclic`]: CyclicCubicGenerator::to_curve_cyclic
#[derive(Clone, Debug)]
#[cfg_attr(feature = "bevy_reflect", derive(Reflect), reflect(Debug))]
pub struct LinearSpline<P: VectorSpace> {
/// The control points of the NURBS
/// The control points of the linear spline.
pub points: Vec<P>,
}
impl<P: VectorSpace> LinearSpline<P> {
/// Create a new linear spline
/// Create a new linear spline from a list of points to be interpolated.
pub fn new(points: impl Into<Vec<P>>) -> Self {
Self {
points: points.into(),
@ -637,6 +762,20 @@ impl<P: VectorSpace> CubicGenerator<P> for LinearSpline<P> {
CubicCurve { segments }
}
}
impl<P: VectorSpace> CyclicCubicGenerator<P> for LinearSpline<P> {
#[inline]
fn to_curve_cyclic(&self) -> CubicCurve<P> {
let segments = self
.points
.iter()
.circular_tuple_windows()
.map(|(&a, &b)| CubicSegment {
coeff: [a, b - a, P::default(), P::default()],
})
.collect();
CubicCurve { segments }
}
}
/// Implement this on cubic splines that can generate a cubic curve from their spline parameters.
pub trait CubicGenerator<P: VectorSpace> {
@ -644,6 +783,15 @@ pub trait CubicGenerator<P: VectorSpace> {
fn to_curve(&self) -> CubicCurve<P>;
}
/// Implement this on cubic splines that can generate a cyclic cubic curve from their spline parameters.
///
/// This makes sense only when the control data can be interpreted cyclically.
pub trait CyclicCubicGenerator<P: VectorSpace> {
/// Build a cyclic [`CubicCurve`] by computing the interpolation coefficients for each curve segment,
/// treating the control data as cyclic so that the result is a closed curve.
fn to_curve_cyclic(&self) -> CubicCurve<P>;
}
/// A segment of a cubic curve, used to hold precomputed coefficients for fast interpolation.
/// Can be evaluated as a parametric curve over the domain `[0, 1)`.
///
@ -651,7 +799,7 @@ pub trait CubicGenerator<P: VectorSpace> {
#[derive(Copy, Clone, Debug, Default, PartialEq)]
#[cfg_attr(feature = "bevy_reflect", derive(Reflect), reflect(Debug, Default))]
pub struct CubicSegment<P: VectorSpace> {
/// Coefficients of the segment
/// Polynomial coefficients for the segment.
pub coeff: [P; 4],
}

View file

@ -49,8 +49,8 @@ pub mod prelude {
pub use crate::{
cubic_splines::{
CubicBSpline, CubicBezier, CubicCardinalSpline, CubicCurve, CubicGenerator,
CubicHermite, CubicNurbs, CubicNurbsError, CubicSegment, RationalCurve,
RationalGenerator, RationalSegment,
CubicHermite, CubicNurbs, CubicNurbsError, CubicSegment, CyclicCubicGenerator,
RationalCurve, RationalGenerator, RationalSegment,
},
direction::{Dir2, Dir3, Dir3A},
primitives::*,

View file

@ -335,6 +335,7 @@ Example | Description
Example | Description
--- | ---
[Cubic Splines](../examples/math/cubic_splines.rs) | Exhibits different modes of constructing cubic curves using splines
[Custom Primitives](../examples/math/custom_primitives.rs) | Demonstrates how to add custom primitives and useful traits for them.
[Random Sampling](../examples/math/random_sampling.rs) | Demonstrates how to sample random points from mathematical primitives
[Rendering Primitives](../examples/math/render_primitives.rs) | Shows off rendering for all math primitives as both Meshes and Gizmos

View file

@ -0,0 +1,457 @@
//! This example exhibits different available modes of constructing cubic Bezier curves.
use bevy::{
app::{App, Startup, Update},
color::*,
ecs::system::Commands,
gizmos::gizmos::Gizmos,
input::{mouse::MouseButtonInput, ButtonState},
math::{cubic_splines::*, vec2},
prelude::*,
};
fn main() {
App::new()
.add_plugins(DefaultPlugins)
.add_systems(Startup, setup)
.add_systems(
Update,
(
handle_keypress,
handle_mouse_move,
handle_mouse_press,
draw_edit_move,
update_curve,
update_spline_mode_text,
update_cycling_mode_text,
draw_curve,
draw_control_points,
)
.chain(),
)
.run();
}
fn setup(mut commands: Commands) {
// Initialize the modes with their defaults:
let spline_mode = SplineMode::default();
commands.insert_resource(spline_mode);
let cycling_mode = CyclingMode::default();
commands.insert_resource(cycling_mode);
// Starting data for [`ControlPoints`]:
let default_points = vec![
vec2(-500., -200.),
vec2(-250., 250.),
vec2(250., 250.),
vec2(500., -200.),
];
let default_tangents = vec![
vec2(0., 200.),
vec2(200., 0.),
vec2(0., -200.),
vec2(-200., 0.),
];
let default_control_data = ControlPoints {
points_and_tangents: default_points.into_iter().zip(default_tangents).collect(),
};
let curve = form_curve(&default_control_data, spline_mode, cycling_mode);
commands.insert_resource(curve);
commands.insert_resource(default_control_data);
// Mouse tracking information:
commands.insert_resource(MousePosition::default());
commands.insert_resource(MouseEditMove::default());
commands.spawn(Camera2dBundle::default());
// The instructions and modes are rendered on the left-hand side in a column.
let instructions_text = "Click and drag to add control points and their tangents\n\
R: Remove the last control point\n\
S: Cycle the spline construction being used\n\
C: Toggle cyclic curve construction";
let spline_mode_text = format!("Spline: {}", spline_mode);
let cycling_mode_text = format!("{}", cycling_mode);
let style = TextStyle::default();
commands
.spawn(NodeBundle {
style: Style {
position_type: PositionType::Absolute,
top: Val::Px(12.0),
left: Val::Px(12.0),
flex_direction: FlexDirection::Column,
row_gap: Val::Px(20.0),
..default()
},
..default()
})
.with_children(|parent| {
parent.spawn(TextBundle::from_section(instructions_text, style.clone()));
parent.spawn((
SplineModeText,
TextBundle::from_section(spline_mode_text, style.clone()),
));
parent.spawn((
CyclingModeText,
TextBundle::from_section(cycling_mode_text, style.clone()),
));
});
}
// -----------------------------------
// Curve-related Resources and Systems
// -----------------------------------
/// The current spline mode, which determines the spline method used in conjunction with the
/// control points.
#[derive(Clone, Copy, Resource, Default)]
enum SplineMode {
#[default]
Hermite,
Cardinal,
B,
}
impl std::fmt::Display for SplineMode {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
SplineMode::Hermite => f.write_str("Hermite"),
SplineMode::Cardinal => f.write_str("Cardinal"),
SplineMode::B => f.write_str("B"),
}
}
}
/// The current cycling mode, which determines whether the control points should be interpolated
/// cylically (to make a loop).
#[derive(Clone, Copy, Resource, Default)]
enum CyclingMode {
#[default]
NotCyclic,
Cyclic,
}
impl std::fmt::Display for CyclingMode {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
CyclingMode::NotCyclic => f.write_str("Not Cyclic"),
CyclingMode::Cyclic => f.write_str("Cyclic"),
}
}
}
/// The curve presently being displayed. This is optional because there may not be enough control
/// points to actually generate a curve.
#[derive(Clone, Default, Resource)]
struct Curve {
inner: Option<CubicCurve<Vec2>>,
}
impl From<CubicCurve<Vec2>> for Curve {
fn from(value: CubicCurve<Vec2>) -> Self {
Self { inner: Some(value) }
}
}
/// The control points used to generate a curve. The tangent components are only used in the case of
/// Hermite interpolation.
#[derive(Clone, Resource)]
struct ControlPoints {
points_and_tangents: Vec<(Vec2, Vec2)>,
}
/// This system is responsible for updating the [`Curve`] when the [control points] or active modes
/// change.
///
/// [control points]: ControlPoints
fn update_curve(
control_points: Res<ControlPoints>,
spline_mode: Res<SplineMode>,
cycling_mode: Res<CyclingMode>,
mut curve: ResMut<Curve>,
) {
if !control_points.is_changed() && !spline_mode.is_changed() && !cycling_mode.is_changed() {
return;
}
*curve = form_curve(&control_points, *spline_mode, *cycling_mode);
}
/// This system uses gizmos to draw the current [`Curve`] by breaking it up into a large number
/// of line segments.
fn draw_curve(curve: Res<Curve>, mut gizmos: Gizmos) {
let Some(ref curve) = curve.inner else {
return;
};
// Scale resolution with curve length so it doesn't degrade as the length increases.
let resolution = 100 * curve.segments.len();
gizmos.linestrip(
curve.iter_positions(resolution).map(|pt| pt.extend(0.0)),
Color::srgb(1.0, 1.0, 1.0),
);
}
/// This system uses gizmos to draw the current [control points] as circles, displaying their
/// tangent vectors as arrows in the case of a Hermite spline.
///
/// [control points]: ControlPoints
fn draw_control_points(
control_points: Res<ControlPoints>,
spline_mode: Res<SplineMode>,
mut gizmos: Gizmos,
) {
for &(point, tangent) in &control_points.points_and_tangents {
gizmos.circle_2d(point, 10.0, Color::srgb(0.0, 1.0, 0.0));
if matches!(*spline_mode, SplineMode::Hermite) {
gizmos.arrow_2d(point, point + tangent, Color::srgb(1.0, 0.0, 0.0));
}
}
}
/// Helper function for generating a [`Curve`] from [control points] and selected modes.
///
/// [control points]: ControlPoints
fn form_curve(
control_points: &ControlPoints,
spline_mode: SplineMode,
cycling_mode: CyclingMode,
) -> Curve {
let (points, tangents): (Vec<_>, Vec<_>) =
control_points.points_and_tangents.iter().copied().unzip();
match spline_mode {
SplineMode::Hermite => {
if points.len() < 2 {
Curve::default()
} else {
let spline = CubicHermite::new(points, tangents);
Curve::from(match cycling_mode {
CyclingMode::NotCyclic => spline.to_curve(),
CyclingMode::Cyclic => spline.to_curve_cyclic(),
})
}
}
SplineMode::Cardinal => {
if points.len() < 2 {
Curve::default()
} else {
let spline = CubicCardinalSpline::new_catmull_rom(points);
Curve::from(match cycling_mode {
CyclingMode::NotCyclic => spline.to_curve(),
CyclingMode::Cyclic => spline.to_curve_cyclic(),
})
}
}
SplineMode::B => {
if matches!(cycling_mode, CyclingMode::NotCyclic) && points.len() < 4
|| matches!(cycling_mode, CyclingMode::Cyclic) && points.len() < 2
{
Curve::default()
} else {
let spline = CubicBSpline::new(points);
Curve::from(match cycling_mode {
CyclingMode::NotCyclic => spline.to_curve(),
CyclingMode::Cyclic => spline.to_curve_cyclic(),
})
}
}
}
}
// --------------------
// Text-related Components and Systems
// --------------------
/// Marker component for the text node that displays the current [`SplineMode`].
#[derive(Component)]
struct SplineModeText;
/// Marker component for the text node that displays the current [`CyclingMode`].
#[derive(Component)]
struct CyclingModeText;
fn update_spline_mode_text(
spline_mode: Res<SplineMode>,
mut spline_mode_text: Query<&mut Text, With<SplineModeText>>,
) {
if !spline_mode.is_changed() {
return;
}
let new_text = format!("Spline: {}", *spline_mode);
for mut spline_mode_text in spline_mode_text.iter_mut() {
if let Some(section) = spline_mode_text.sections.first_mut() {
section.value.clone_from(&new_text);
}
}
}
fn update_cycling_mode_text(
cycling_mode: Res<CyclingMode>,
mut cycling_mode_text: Query<&mut Text, With<CyclingModeText>>,
) {
if !cycling_mode.is_changed() {
return;
}
let new_text = format!("{}", *cycling_mode);
for mut cycling_mode_text in cycling_mode_text.iter_mut() {
if let Some(section) = cycling_mode_text.sections.first_mut() {
section.value.clone_from(&new_text);
}
}
}
// -----------------------------------
// Input-related Resources and Systems
// -----------------------------------
/// A small state machine which tracks a click-and-drag motion used to create new control points.
/// When the user is not doing a click-and-drag motion, the `start` field is `None`. When the user
/// presses the left mouse button, the location of that press is temporarily stored in the field.
#[derive(Clone, Default, Resource)]
struct MouseEditMove {
start: Option<Vec2>,
}
/// The current mouse position, if known.
#[derive(Clone, Default, Resource)]
struct MousePosition(Option<Vec2>);
/// Update the current cursor position and track it in the [`MousePosition`] resource.
fn handle_mouse_move(
mut cursor_events: EventReader<CursorMoved>,
mut mouse_position: ResMut<MousePosition>,
) {
if let Some(cursor_event) = cursor_events.read().last() {
mouse_position.0 = Some(cursor_event.position);
}
}
/// This system handles updating the [`MouseEditMove`] resource, orchestrating the logical part
/// of the click-and-drag motion which actually creates new control points.
fn handle_mouse_press(
mut button_events: EventReader<MouseButtonInput>,
mouse_position: Res<MousePosition>,
mut edit_move: ResMut<MouseEditMove>,
mut control_points: ResMut<ControlPoints>,
camera: Query<(&Camera, &GlobalTransform)>,
) {
let Some(mouse_pos) = mouse_position.0 else {
return;
};
// Handle click and drag behavior
for button_event in button_events.read() {
if button_event.button != MouseButton::Left {
continue;
}
match button_event.state {
ButtonState::Pressed => {
if edit_move.start.is_some() {
// If the edit move already has a start, press event should do nothing.
continue;
}
// This press represents the start of the edit move.
edit_move.start = Some(mouse_pos);
}
ButtonState::Released => {
// Release is only meaningful if we started an edit move.
let Some(start) = edit_move.start else {
continue;
};
let Ok((camera, camera_transform)) = camera.get_single() else {
continue;
};
// Convert the starting point and end point (current mouse pos) into world coords:
let Some(point) = camera.viewport_to_world_2d(camera_transform, start) else {
continue;
};
let Some(end_point) = camera.viewport_to_world_2d(camera_transform, mouse_pos)
else {
continue;
};
let tangent = end_point - point;
// The start of the click-and-drag motion represents the point to add,
// while the difference with the current position represents the tangent.
control_points.points_and_tangents.push((point, tangent));
// Reset the edit move since we've consumed it.
edit_move.start = None;
}
}
}
}
/// This system handles drawing the "preview" control point based on the state of [`MouseEditMove`].
fn draw_edit_move(
edit_move: Res<MouseEditMove>,
mouse_position: Res<MousePosition>,
mut gizmos: Gizmos,
camera: Query<(&Camera, &GlobalTransform)>,
) {
let Some(start) = edit_move.start else {
return;
};
let Some(mouse_pos) = mouse_position.0 else {
return;
};
let Ok((camera, camera_transform)) = camera.get_single() else {
return;
};
// Resources store data in viewport coordinates, so we need to convert to world coordinates
// to display them:
let Some(start) = camera.viewport_to_world_2d(camera_transform, start) else {
return;
};
let Some(end) = camera.viewport_to_world_2d(camera_transform, mouse_pos) else {
return;
};
gizmos.circle_2d(start, 10.0, Color::srgb(0.0, 1.0, 0.7));
gizmos.circle_2d(start, 7.0, Color::srgb(0.0, 1.0, 0.7));
gizmos.arrow_2d(start, end, Color::srgb(1.0, 0.0, 0.7));
}
/// This system handles all keyboard commands.
fn handle_keypress(
keyboard: Res<ButtonInput<KeyCode>>,
mut spline_mode: ResMut<SplineMode>,
mut cycling_mode: ResMut<CyclingMode>,
mut control_points: ResMut<ControlPoints>,
) {
// S => change spline mode
if keyboard.just_pressed(KeyCode::KeyS) {
*spline_mode = match *spline_mode {
SplineMode::Hermite => SplineMode::Cardinal,
SplineMode::Cardinal => SplineMode::B,
SplineMode::B => SplineMode::Hermite,
}
}
// C => change cycling mode
if keyboard.just_pressed(KeyCode::KeyC) {
*cycling_mode = match *cycling_mode {
CyclingMode::NotCyclic => CyclingMode::Cyclic,
CyclingMode::Cyclic => CyclingMode::NotCyclic,
}
}
// R => remove last control point
if keyboard.just_pressed(KeyCode::KeyR) {
control_points.points_and_tangents.pop();
}
}