Add Capsule2d primitive (#11585)

# Objective

Currently, the `Capsule` primitive is technically dimension-agnostic in
that it implements both `Primitive2d` and `Primitive3d`. This seems good
on paper, but it can often be useful to have separate 2D and 3D versions
of primitives.

For example, one might want a two-dimensional capsule mesh. We can't
really implement both 2D and 3D meshing for the same type using the
upcoming `Meshable` trait (see #11431). We also currently don't
implement `Bounded2d` for `Capsule`, see
https://github.com/bevyengine/bevy/pull/11336#issuecomment-1890797788.

Having 2D and 3D separate at a type level is more explicit, and also
more consistent with the existing primitives, as there are no other
types that implement both `Primitive2d` and `Primitive3d` at the same
time.

## Solution

Rename `Capsule` to `Capsule3d` and add `Capsule2d`. `Capsule2d`
implements `Bounded2d`.

For now, I went for `Capsule2d` for the sake of consistency and clarity.
Mathematically the more accurate term would be `Stadium` or `Pill` (see
[Wikipedia](https://en.wikipedia.org/wiki/Stadium_(geometry))), but
those might be less obvious to game devs. For reference, Godot has
[`CapsuleShape2D`](https://docs.godotengine.org/en/stable/classes/class_capsuleshape2d.html).
I can rename it if others think the geometrically correct name is better
though.

---

## Changelog

- Renamed `Capsule` to `Capsule3d`
- Added `Capsule2d` with `Bounded2d` implemented

---------

Co-authored-by: Alice Cecile <alice.i.cecile@gmail.com>
This commit is contained in:
Joona Aalto 2024-01-29 19:52:04 +02:00 committed by GitHub
parent 1bc293f33a
commit a9f061e909
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 89 additions and 18 deletions

View File

@ -3,8 +3,8 @@
use glam::{Mat2, Vec2};
use crate::primitives::{
BoxedPolygon, BoxedPolyline2d, Circle, Ellipse, Line2d, Plane2d, Polygon, Polyline2d,
Rectangle, RegularPolygon, Segment2d, Triangle2d,
BoxedPolygon, BoxedPolyline2d, Capsule2d, Circle, Direction2d, Ellipse, Line2d, Plane2d,
Polygon, Polyline2d, Rectangle, RegularPolygon, Segment2d, Triangle2d,
};
use super::{Aabb2d, Bounded2d, BoundingCircle};
@ -230,6 +230,31 @@ impl Bounded2d for RegularPolygon {
}
}
impl Bounded2d for Capsule2d {
fn aabb_2d(&self, translation: Vec2, rotation: f32) -> Aabb2d {
// Get the line segment between the hemicircles of the rotated capsule
let segment = Segment2d {
// Multiplying a normalized vector (Vec2::Y) with a rotation returns a normalized vector.
direction: Direction2d::new_unchecked(Mat2::from_angle(rotation) * Vec2::Y),
half_length: self.half_length,
};
let (a, b) = (segment.point1(), segment.point2());
// Expand the line segment by the capsule radius to get the capsule half-extents
let min = a.min(b) - Vec2::splat(self.radius);
let max = a.max(b) + Vec2::splat(self.radius);
Aabb2d {
min: min + translation,
max: max + translation,
}
}
fn bounding_circle(&self, translation: Vec2, _rotation: f32) -> BoundingCircle {
BoundingCircle::new(translation, self.radius + self.half_length)
}
}
#[cfg(test)]
mod tests {
use glam::Vec2;
@ -237,8 +262,8 @@ mod tests {
use crate::{
bounding::Bounded2d,
primitives::{
Circle, Direction2d, Ellipse, Line2d, Plane2d, Polygon, Polyline2d, Rectangle,
RegularPolygon, Segment2d, Triangle2d,
Capsule2d, Circle, Direction2d, Ellipse, Line2d, Plane2d, Polygon, Polyline2d,
Rectangle, RegularPolygon, Segment2d, Triangle2d,
},
};
@ -440,4 +465,18 @@ mod tests {
assert_eq!(bounding_circle.center, translation);
assert_eq!(bounding_circle.radius(), 1.0);
}
#[test]
fn capsule() {
let capsule = Capsule2d::new(0.5, 2.0);
let translation = Vec2::new(2.0, 1.0);
let aabb = capsule.aabb_2d(translation, 0.0);
assert_eq!(aabb.min, translation - Vec2::new(0.5, 1.5));
assert_eq!(aabb.max, translation + Vec2::new(0.5, 1.5));
let bounding_circle = capsule.bounding_circle(translation, 0.0);
assert_eq!(bounding_circle.center, translation);
assert_eq!(bounding_circle.radius(), 1.5);
}
}

View File

@ -5,7 +5,7 @@ use glam::{Mat3, Quat, Vec2, Vec3};
use crate::{
bounding::{Bounded2d, BoundingCircle},
primitives::{
BoxedPolyline3d, Capsule, Cone, ConicalFrustum, Cuboid, Cylinder, Direction3d, Line3d,
BoxedPolyline3d, Capsule3d, Cone, ConicalFrustum, Cuboid, Cylinder, Direction3d, Line3d,
Plane3d, Polyline3d, Segment3d, Sphere, Torus, Triangle2d,
},
};
@ -146,7 +146,7 @@ impl Bounded3d for Cylinder {
}
}
impl Bounded3d for Capsule {
impl Bounded3d for Capsule3d {
fn aabb_3d(&self, translation: Vec3, rotation: Quat) -> Aabb3d {
// Get the line segment between the hemispheres of the rotated capsule
let segment = Segment3d {
@ -311,7 +311,7 @@ mod tests {
use crate::{
bounding::Bounded3d,
primitives::{
Capsule, Cone, ConicalFrustum, Cuboid, Cylinder, Direction3d, Line3d, Plane3d,
Capsule3d, Cone, ConicalFrustum, Cuboid, Cylinder, Direction3d, Line3d, Plane3d,
Polyline3d, Segment3d, Sphere, Torus,
},
};
@ -463,7 +463,7 @@ mod tests {
#[test]
fn capsule() {
let capsule = Capsule::new(0.5, 2.0);
let capsule = Capsule3d::new(0.5, 2.0);
let translation = Vec3::new(2.0, 1.0, 0.0);
let aabb = capsule.aabb_3d(translation, Quat::IDENTITY);

View File

@ -471,6 +471,7 @@ pub struct Rectangle {
/// Half of the width and height of the rectangle
pub half_size: Vec2,
}
impl Primitive2d for Rectangle {}
impl Default for Rectangle {
/// Returns the default [`Rectangle`] with a half-width and half-height of `0.5`.
@ -721,6 +722,30 @@ impl RegularPolygon {
}
}
/// A 2D capsule primitive, also known as a stadium or pill shape.
///
/// A two-dimensional capsule is defined as a neighborhood of points at a distance (radius) from a line
#[derive(Clone, Copy, Debug, PartialEq)]
#[cfg_attr(feature = "serialize", derive(serde::Serialize, serde::Deserialize))]
#[doc(alias = "stadium", alias = "pill")]
pub struct Capsule2d {
/// The radius of the capsule
pub radius: f32,
/// Half the height of the capsule, excluding the hemicircles
pub half_length: f32,
}
impl Primitive2d for Capsule2d {}
impl Capsule2d {
/// Create a new `Capsule2d` from a radius and length
pub fn new(radius: f32, length: f32) -> Self {
Self {
radius,
half_length: length / 2.0,
}
}
}
#[cfg(test)]
mod tests {
// Reference values were computed by hand and/or with external tools

View File

@ -423,22 +423,20 @@ impl Cylinder {
}
}
/// A capsule primitive.
/// A capsule is defined as a surface at a distance (radius) from a line
/// A 3D capsule primitive.
/// A three-dimensional capsule is defined as a surface at a distance (radius) from a line
#[derive(Clone, Copy, Debug, PartialEq)]
#[cfg_attr(feature = "serialize", derive(serde::Serialize, serde::Deserialize))]
pub struct Capsule {
pub struct Capsule3d {
/// The radius of the capsule
pub radius: f32,
/// Half the height of the capsule, excluding the hemispheres
pub half_length: f32,
}
impl super::Primitive2d for Capsule {}
impl Primitive3d for Capsule {}
impl Primitive3d for Capsule3d {}
impl Capsule {
/// Create a new `Capsule` from a radius and length
#[inline(always)]
impl Capsule3d {
/// Create a new `Capsule3d` from a radius and length
pub fn new(radius: f32, length: f32) -> Self {
Self {
radius,
@ -704,7 +702,7 @@ mod tests {
#[test]
fn capsule_math() {
let capsule = Capsule::new(2.0, 9.0);
let capsule = Capsule3d::new(2.0, 9.0);
assert_eq!(
capsule.to_cylinder(),
Cylinder::new(2.0, 9.0),

View File

@ -91,3 +91,12 @@ impl_reflect_struct!(
sides: usize,
}
);
impl_reflect_struct!(
#[reflect(Debug, PartialEq, Serialize, Deserialize)]
#[type_path = "bevy_math::primitives"]
struct Capsule2d {
radius: f32,
half_length: f32,
}
);

View File

@ -71,7 +71,7 @@ impl_reflect_struct!(
impl_reflect_struct!(
#[reflect(Debug, PartialEq, Serialize, Deserialize)]
#[type_path = "bevy_math::primitives"]
struct Capsule {
struct Capsule3d {
radius: f32,
half_length: f32,
}