Implement Rhombus 2D primitive. (#13501)

# Objective

- Create a new 2D primitive, Rhombus, also knows as "Diamond Shape"
- Simplify the creation and handling of isometric projections
- Extend Bevy's arsenal of 2D primitives

## Testing

- New unit tests created in bevy_math/ primitives and bev_math/ bounding
- Tested translations, rotations, wireframe, bounding sphere, aabb and
creation parameters

---------

Co-authored-by: Luís Figueiredo <luispcfigueiredo@tecnico.ulisboa.pt>
This commit is contained in:
Salvador Carvalhinho 2024-05-26 16:27:57 +01:00 committed by GitHub
parent 037f37e4d6
commit 7d843e0c08
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 298 additions and 6 deletions

View File

@ -7,7 +7,7 @@ use super::helpers::*;
use bevy_color::Color; use bevy_color::Color;
use bevy_math::primitives::{ use bevy_math::primitives::{
Annulus, BoxedPolygon, BoxedPolyline2d, Capsule2d, Circle, Ellipse, Line2d, Plane2d, Polygon, Annulus, BoxedPolygon, BoxedPolyline2d, Capsule2d, Circle, Ellipse, Line2d, Plane2d, Polygon,
Polyline2d, Primitive2d, Rectangle, RegularPolygon, Segment2d, Triangle2d, Polyline2d, Primitive2d, Rectangle, RegularPolygon, Rhombus, Segment2d, Triangle2d,
}; };
use bevy_math::{Dir2, Mat2, Vec2}; use bevy_math::{Dir2, Mat2, Vec2};
@ -138,6 +138,38 @@ where
} }
} }
// rhombus 2d
impl<'w, 's, Config, Clear> GizmoPrimitive2d<Rhombus> for Gizmos<'w, 's, Config, Clear>
where
Config: GizmoConfigGroup,
Clear: 'static + Send + Sync,
{
type Output<'a> = () where Self: 'a;
fn primitive_2d(
&mut self,
primitive: Rhombus,
position: Vec2,
angle: f32,
color: impl Into<Color>,
) -> Self::Output<'_> {
if !self.enabled {
return;
}
let [a, b, c, d] =
[(1.0, 0.0), (0.0, 1.0), (-1.0, 0.0), (0.0, -1.0)].map(|(sign_x, sign_y)| {
Vec2::new(
primitive.half_diagonals.x * sign_x,
primitive.half_diagonals.y * sign_y,
)
});
let positions = [a, b, c, d, a].map(rotate_then_translate_2d(angle, position));
self.linestrip_2d(positions, color);
}
}
// capsule 2d // capsule 2d
impl<'w, 's, Config, Clear> GizmoPrimitive2d<Capsule2d> for Gizmos<'w, 's, Config, Clear> impl<'w, 's, Config, Clear> GizmoPrimitive2d<Capsule2d> for Gizmos<'w, 's, Config, Clear>

View File

@ -3,8 +3,8 @@
use crate::{ use crate::{
primitives::{ primitives::{
Arc2d, BoxedPolygon, BoxedPolyline2d, Capsule2d, Circle, CircularSector, CircularSegment, Arc2d, BoxedPolygon, BoxedPolyline2d, Capsule2d, Circle, CircularSector, CircularSegment,
Ellipse, Line2d, Plane2d, Polygon, Polyline2d, Rectangle, RegularPolygon, Segment2d, Ellipse, Line2d, Plane2d, Polygon, Polyline2d, Rectangle, RegularPolygon, Rhombus,
Triangle2d, Segment2d, Triangle2d,
}, },
Dir2, Mat2, Rotation2d, Vec2, Dir2, Mat2, Rotation2d, Vec2,
}; };
@ -183,6 +183,33 @@ impl Bounded2d for Ellipse {
} }
} }
impl Bounded2d for Rhombus {
fn aabb_2d(&self, translation: Vec2, rotation: impl Into<Rotation2d>) -> Aabb2d {
let rotation_mat = rotation.into();
let [rotated_x_half_diagonal, rotated_y_half_diagonal] = [
rotation_mat * Vec2::new(self.half_diagonals.x, 0.0),
rotation_mat * Vec2::new(0.0, self.half_diagonals.y),
];
let aabb_half_extent = rotated_x_half_diagonal
.abs()
.max(rotated_y_half_diagonal.abs());
Aabb2d {
min: -aabb_half_extent + translation,
max: aabb_half_extent + translation,
}
}
fn bounding_circle(
&self,
translation: Vec2,
_rotation: impl Into<Rotation2d>,
) -> BoundingCircle {
BoundingCircle::new(translation, self.circumradius())
}
}
impl Bounded2d for Plane2d { impl Bounded2d for Plane2d {
fn aabb_2d(&self, translation: Vec2, rotation: impl Into<Rotation2d>) -> Aabb2d { fn aabb_2d(&self, translation: Vec2, rotation: impl Into<Rotation2d>) -> Aabb2d {
let rotation: Rotation2d = rotation.into(); let rotation: Rotation2d = rotation.into();
@ -448,7 +475,7 @@ mod tests {
bounding::Bounded2d, bounding::Bounded2d,
primitives::{ primitives::{
Arc2d, Capsule2d, Circle, CircularSector, CircularSegment, Ellipse, Line2d, Plane2d, Arc2d, Capsule2d, Circle, CircularSector, CircularSegment, Ellipse, Line2d, Plane2d,
Polygon, Polyline2d, Rectangle, RegularPolygon, Segment2d, Triangle2d, Polygon, Polyline2d, Rectangle, RegularPolygon, Rhombus, Segment2d, Triangle2d,
}, },
Dir2, Dir2,
}; };
@ -769,6 +796,31 @@ mod tests {
assert_eq!(bounding_circle.radius(), 1.0); assert_eq!(bounding_circle.radius(), 1.0);
} }
#[test]
fn rhombus() {
let rhombus = Rhombus::new(2.0, 1.0);
let translation = Vec2::new(2.0, 1.0);
let aabb = rhombus.aabb_2d(translation, std::f32::consts::FRAC_PI_4);
assert_eq!(aabb.min, Vec2::new(1.2928932, 0.29289323));
assert_eq!(aabb.max, Vec2::new(2.7071068, 1.7071068));
let bounding_circle = rhombus.bounding_circle(translation, std::f32::consts::FRAC_PI_4);
assert_eq!(bounding_circle.center, translation);
assert_eq!(bounding_circle.radius(), 1.0);
let rhombus = Rhombus::new(0.0, 0.0);
let translation = Vec2::new(0.0, 0.0);
let aabb = rhombus.aabb_2d(translation, std::f32::consts::FRAC_PI_4);
assert_eq!(aabb.min, Vec2::new(0.0, 0.0));
assert_eq!(aabb.max, Vec2::new(0.0, 0.0));
let bounding_circle = rhombus.bounding_circle(translation, std::f32::consts::FRAC_PI_4);
assert_eq!(bounding_circle.center, translation);
assert_eq!(bounding_circle.radius(), 0.0);
}
#[test] #[test]
fn plane() { fn plane() {
let translation = Vec2::new(2.0, 1.0); let translation = Vec2::new(2.0, 1.0);

View File

@ -929,6 +929,132 @@ impl Measured2d for Annulus {
} }
} }
/// A rhombus primitive, also known as a diamond shape.
#[derive(Clone, Copy, Debug, PartialEq)]
#[cfg_attr(feature = "serialize", derive(serde::Serialize, serde::Deserialize))]
#[doc(alias = "Diamond")]
pub struct Rhombus {
/// Size of the horizontal and vertical diagonals of the rhombus
pub half_diagonals: Vec2,
}
impl Primitive2d for Rhombus {}
impl Default for Rhombus {
/// Returns the default [`Rhombus`] with a half-horizontal and half-vertical diagonal of `0.5`.
fn default() -> Self {
Self {
half_diagonals: Vec2::splat(0.5),
}
}
}
impl Rhombus {
/// Create a new `Rhombus` from a vertical and horizontal diagonal sizes.
#[inline(always)]
pub fn new(horizontal_diagonal: f32, vertical_diagonal: f32) -> Self {
Self {
half_diagonals: Vec2::new(horizontal_diagonal / 2.0, vertical_diagonal / 2.0),
}
}
/// Create a new `Rhombus` from a side length with all inner angles equal.
#[inline(always)]
pub fn from_side(side: f32) -> Self {
Self {
half_diagonals: Vec2::splat(side.hypot(side) / 2.0),
}
}
/// Create a new `Rhombus` from a given inradius with all inner angles equal.
#[inline(always)]
pub fn from_inradius(inradius: f32) -> Self {
let half_diagonal = inradius * 2.0 / std::f32::consts::SQRT_2;
Self {
half_diagonals: Vec2::new(half_diagonal, half_diagonal),
}
}
/// Get the length of each side of the rhombus
#[inline(always)]
pub fn side(&self) -> f32 {
self.half_diagonals.length()
}
/// Get the radius of the circumcircle on which all vertices
/// of the rhombus lie
#[inline(always)]
pub fn circumradius(&self) -> f32 {
self.half_diagonals.x.max(self.half_diagonals.y)
}
/// Get the radius of the largest circle that can
/// be drawn within the rhombus
#[inline(always)]
#[doc(alias = "apothem")]
pub fn inradius(&self) -> f32 {
let side = self.side();
if side == 0.0 {
0.0
} else {
(self.half_diagonals.x * self.half_diagonals.y) / side
}
}
/// Finds the point on the rhombus that is closest to the given `point`.
///
/// If the point is outside the rhombus, the returned point will be on the perimeter of the rhombus.
/// Otherwise, it will be inside the rhombus and returned as is.
#[inline(always)]
pub fn closest_point(&self, point: Vec2) -> Vec2 {
// Fold the problem into the positive quadrant
let point_abs = point.abs();
let half_diagonals = self.half_diagonals.abs(); // to ensure correct sign
// The unnormalised normal vector perpendicular to the side of the rhombus
let normal = Vec2::new(half_diagonals.y, half_diagonals.x);
let normal_magnitude_squared = normal.length_squared();
if normal_magnitude_squared == 0.0 {
return Vec2::ZERO; // A null Rhombus has only one point anyway.
}
// The last term corresponds to normal.dot(rhombus_vertex)
let distance_unnormalised = normal.dot(point_abs) - half_diagonals.x * half_diagonals.y;
// The point is already inside so we simply return it.
if distance_unnormalised <= 0.0 {
return point;
}
// Clamp the point to the edge
let mut result = point_abs - normal * distance_unnormalised / normal_magnitude_squared;
// Clamp the point back to the positive quadrant
// if it's outside, it needs to be clamped to either vertex
if result.x <= 0.0 {
result = Vec2::new(0.0, half_diagonals.y);
} else if result.y <= 0.0 {
result = Vec2::new(half_diagonals.x, 0.0);
}
// Finally, we restore the signs of the original vector
result.copysign(point)
}
}
impl Measured2d for Rhombus {
/// Get the area of the rhombus
#[inline(always)]
fn area(&self) -> f32 {
2.0 * self.half_diagonals.x * self.half_diagonals.y
}
/// Get the perimeter of the rhombus
#[inline(always)]
fn perimeter(&self) -> f32 {
4.0 * self.side()
}
}
/// An unbounded plane in 2D space. It forms a separating surface through the origin, /// An unbounded plane in 2D space. It forms a separating surface through the origin,
/// stretching infinitely far /// stretching infinitely far
#[derive(Clone, Copy, Debug, PartialEq)] #[derive(Clone, Copy, Debug, PartialEq)]
@ -1601,6 +1727,25 @@ mod tests {
); );
} }
#[test]
fn rhombus_closest_point() {
let rhombus = Rhombus::new(2.0, 1.0);
assert_eq!(rhombus.closest_point(Vec2::X * 10.0), Vec2::X);
assert_eq!(
rhombus.closest_point(Vec2::NEG_ONE * 0.2),
Vec2::NEG_ONE * 0.2
);
assert_eq!(
rhombus.closest_point(Vec2::new(-0.55, 0.35)),
Vec2::new(-0.5, 0.25)
);
let rhombus = Rhombus::new(0.0, 0.0);
assert_eq!(rhombus.closest_point(Vec2::X * 10.0), Vec2::ZERO);
assert_eq!(rhombus.closest_point(Vec2::NEG_ONE * 0.2), Vec2::ZERO);
assert_eq!(rhombus.closest_point(Vec2::new(-0.55, 0.35)), Vec2::ZERO);
}
#[test] #[test]
fn circle_math() { fn circle_math() {
let circle = Circle { radius: 3.0 }; let circle = Circle { radius: 3.0 };
@ -1618,6 +1763,28 @@ mod tests {
assert_eq!(annulus.perimeter(), 37.699112, "incorrect perimeter"); assert_eq!(annulus.perimeter(), 37.699112, "incorrect perimeter");
} }
#[test]
fn rhombus_math() {
let rhombus = Rhombus::new(3.0, 4.0);
assert_eq!(rhombus.area(), 6.0, "incorrect area");
assert_eq!(rhombus.perimeter(), 10.0, "incorrect perimeter");
assert_eq!(rhombus.side(), 2.5, "incorrect side");
assert_eq!(rhombus.inradius(), 1.2, "incorrect inradius");
assert_eq!(rhombus.circumradius(), 2.0, "incorrect circumradius");
let rhombus = Rhombus::new(0.0, 0.0);
assert_eq!(rhombus.area(), 0.0, "incorrect area");
assert_eq!(rhombus.perimeter(), 0.0, "incorrect perimeter");
assert_eq!(rhombus.side(), 0.0, "incorrect side");
assert_eq!(rhombus.inradius(), 0.0, "incorrect inradius");
assert_eq!(rhombus.circumradius(), 0.0, "incorrect circumradius");
let rhombus = Rhombus::from_side(std::f32::consts::SQRT_2);
assert_eq!(rhombus, Rhombus::new(2.0, 2.0));
assert_eq!(
rhombus,
Rhombus::from_inradius(std::f32::consts::FRAC_1_SQRT_2)
);
}
#[test] #[test]
fn ellipse_math() { fn ellipse_math() {
let ellipse = Ellipse::new(3.0, 1.0); let ellipse = Ellipse::new(3.0, 1.0);

View File

@ -28,6 +28,14 @@ impl_reflect!(
} }
); );
impl_reflect!(
#[reflect(Debug, PartialEq, Serialize, Deserialize)]
#[type_path = "bevy_math::primitives"]
struct Rhombus {
half_diagonals: Vec2,
}
);
impl_reflect!( impl_reflect!(
#[reflect(Debug, PartialEq, Serialize, Deserialize)] #[reflect(Debug, PartialEq, Serialize, Deserialize)]
#[type_path = "bevy_math::primitives"] #[type_path = "bevy_math::primitives"]

View File

@ -10,7 +10,7 @@ use super::{MeshBuilder, Meshable};
use bevy_math::{ use bevy_math::{
primitives::{ primitives::{
Annulus, Capsule2d, Circle, CircularSector, CircularSegment, Ellipse, Rectangle, Annulus, Capsule2d, Circle, CircularSector, CircularSegment, Ellipse, Rectangle,
RegularPolygon, Triangle2d, Triangle3d, WindingOrder, RegularPolygon, Rhombus, Triangle2d, Triangle3d, WindingOrder,
}, },
FloatExt, Vec2, FloatExt, Vec2,
}; };
@ -583,6 +583,38 @@ impl From<Annulus> for Mesh {
} }
} }
impl Meshable for Rhombus {
type Output = Mesh;
fn mesh(&self) -> Self::Output {
let [hhd, vhd] = [self.half_diagonals.x, self.half_diagonals.y];
let positions = vec![
[hhd, 0.0, 0.0],
[-hhd, 0.0, 0.0],
[0.0, vhd, 0.0],
[0.0, -vhd, 0.0],
];
let normals = vec![[0.0, 0.0, 1.0]; 4];
let uvs = vec![[1.0, 0.5], [0.0, 0.5], [0.5, 0.0], [0.5, 1.0]];
let indices = Indices::U32(vec![1, 0, 2, 1, 3, 0]);
Mesh::new(
PrimitiveTopology::TriangleList,
RenderAssetUsages::default(),
)
.with_inserted_indices(indices)
.with_inserted_attribute(Mesh::ATTRIBUTE_POSITION, positions)
.with_inserted_attribute(Mesh::ATTRIBUTE_NORMAL, normals)
.with_inserted_attribute(Mesh::ATTRIBUTE_UV_0, uvs)
}
}
impl From<Rhombus> for Mesh {
fn from(rhombus: Rhombus) -> Self {
rhombus.mesh()
}
}
impl Meshable for Triangle2d { impl Meshable for Triangle2d {
type Output = Mesh; type Output = Mesh;

View File

@ -12,7 +12,7 @@ fn main() {
.run(); .run();
} }
const X_EXTENT: f32 = 800.; const X_EXTENT: f32 = 900.;
fn setup( fn setup(
mut commands: Commands, mut commands: Commands,
@ -28,6 +28,7 @@ fn setup(
Mesh2dHandle(meshes.add(Ellipse::new(25.0, 50.0))), Mesh2dHandle(meshes.add(Ellipse::new(25.0, 50.0))),
Mesh2dHandle(meshes.add(Annulus::new(25.0, 50.0))), Mesh2dHandle(meshes.add(Annulus::new(25.0, 50.0))),
Mesh2dHandle(meshes.add(Capsule2d::new(25.0, 50.0))), Mesh2dHandle(meshes.add(Capsule2d::new(25.0, 50.0))),
Mesh2dHandle(meshes.add(Rhombus::new(75.0, 100.0))),
Mesh2dHandle(meshes.add(Rectangle::new(50.0, 100.0))), Mesh2dHandle(meshes.add(Rectangle::new(50.0, 100.0))),
Mesh2dHandle(meshes.add(RegularPolygon::new(50.0, 6))), Mesh2dHandle(meshes.add(RegularPolygon::new(50.0, 6))),
Mesh2dHandle(meshes.add(Triangle2d::new( Mesh2dHandle(meshes.add(Triangle2d::new(