bevy/examples/math/cubic_splines.rs
Hamir Mahal ec728c31c1
style: simplify string formatting for readability (#15033)
# Objective

The goal of this change is to improve code readability and
maintainability.
2024-09-03 23:35:49 +00:00

446 lines
14 KiB
Rust

//! 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, Isometry2d},
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(Option<CubicCurve<Vec2>>);
/// 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.0 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(
Isometry2d::from_translation(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 => {
let spline = CubicHermite::new(points, tangents);
Curve(match cycling_mode {
CyclingMode::NotCyclic => spline.to_curve().ok(),
CyclingMode::Cyclic => spline.to_curve_cyclic().ok(),
})
}
SplineMode::Cardinal => {
let spline = CubicCardinalSpline::new_catmull_rom(points);
Curve(match cycling_mode {
CyclingMode::NotCyclic => spline.to_curve().ok(),
CyclingMode::Cyclic => spline.to_curve_cyclic().ok(),
})
}
SplineMode::B => {
let spline = CubicBSpline::new(points);
Curve(match cycling_mode {
CyclingMode::NotCyclic => spline.to_curve().ok(),
CyclingMode::Cyclic => spline.to_curve_cyclic().ok(),
})
}
}
}
// --------------------
// 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 Ok(point) = camera.viewport_to_world_2d(camera_transform, start) else {
continue;
};
let Ok(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 Ok(start) = camera.viewport_to_world_2d(camera_transform, start) else {
return;
};
let Ok(end) = camera.viewport_to_world_2d(camera_transform, mouse_pos) else {
return;
};
gizmos.circle_2d(
Isometry2d::from_translation(start),
10.0,
Color::srgb(0.0, 1.0, 0.7),
);
gizmos.circle_2d(
Isometry2d::from_translation(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();
}
}