From 7d843e0c0891545ec6cc0131398b0db6364a7a88 Mon Sep 17 00:00:00 2001 From: Salvador Carvalhinho Date: Sun, 26 May 2024 16:27:57 +0100 Subject: [PATCH] Implement Rhombus 2D primitive. (#13501) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # 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 --- crates/bevy_gizmos/src/primitives/dim2.rs | 34 +++- .../src/bounding/bounded2d/primitive_impls.rs | 58 +++++- crates/bevy_math/src/primitives/dim2.rs | 167 ++++++++++++++++++ .../src/impls/math/primitives2d.rs | 8 + .../bevy_render/src/mesh/primitives/dim2.rs | 34 +++- examples/2d/2d_shapes.rs | 3 +- 6 files changed, 298 insertions(+), 6 deletions(-) diff --git a/crates/bevy_gizmos/src/primitives/dim2.rs b/crates/bevy_gizmos/src/primitives/dim2.rs index 74028903b1df9..495e1cb687dcf 100644 --- a/crates/bevy_gizmos/src/primitives/dim2.rs +++ b/crates/bevy_gizmos/src/primitives/dim2.rs @@ -7,7 +7,7 @@ use super::helpers::*; use bevy_color::Color; use bevy_math::primitives::{ 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}; @@ -138,6 +138,38 @@ where } } +// rhombus 2d + +impl<'w, 's, Config, Clear> GizmoPrimitive2d 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, + ) -> 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 impl<'w, 's, Config, Clear> GizmoPrimitive2d for Gizmos<'w, 's, Config, Clear> diff --git a/crates/bevy_math/src/bounding/bounded2d/primitive_impls.rs b/crates/bevy_math/src/bounding/bounded2d/primitive_impls.rs index 01d3d3a70e7c7..1012322baa6b9 100644 --- a/crates/bevy_math/src/bounding/bounded2d/primitive_impls.rs +++ b/crates/bevy_math/src/bounding/bounded2d/primitive_impls.rs @@ -3,8 +3,8 @@ use crate::{ primitives::{ Arc2d, BoxedPolygon, BoxedPolyline2d, Capsule2d, Circle, CircularSector, CircularSegment, - Ellipse, Line2d, Plane2d, Polygon, Polyline2d, Rectangle, RegularPolygon, Segment2d, - Triangle2d, + Ellipse, Line2d, Plane2d, Polygon, Polyline2d, Rectangle, RegularPolygon, Rhombus, + Segment2d, Triangle2d, }, 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) -> 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, + ) -> BoundingCircle { + BoundingCircle::new(translation, self.circumradius()) + } +} + impl Bounded2d for Plane2d { fn aabb_2d(&self, translation: Vec2, rotation: impl Into) -> Aabb2d { let rotation: Rotation2d = rotation.into(); @@ -448,7 +475,7 @@ mod tests { bounding::Bounded2d, primitives::{ Arc2d, Capsule2d, Circle, CircularSector, CircularSegment, Ellipse, Line2d, Plane2d, - Polygon, Polyline2d, Rectangle, RegularPolygon, Segment2d, Triangle2d, + Polygon, Polyline2d, Rectangle, RegularPolygon, Rhombus, Segment2d, Triangle2d, }, Dir2, }; @@ -769,6 +796,31 @@ mod tests { 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] fn plane() { let translation = Vec2::new(2.0, 1.0); diff --git a/crates/bevy_math/src/primitives/dim2.rs b/crates/bevy_math/src/primitives/dim2.rs index b331a37114a22..e945d52b8535f 100644 --- a/crates/bevy_math/src/primitives/dim2.rs +++ b/crates/bevy_math/src/primitives/dim2.rs @@ -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, /// stretching infinitely far #[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] fn circle_math() { let circle = Circle { radius: 3.0 }; @@ -1618,6 +1763,28 @@ mod tests { 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] fn ellipse_math() { let ellipse = Ellipse::new(3.0, 1.0); diff --git a/crates/bevy_reflect/src/impls/math/primitives2d.rs b/crates/bevy_reflect/src/impls/math/primitives2d.rs index c9d9a5b50dc09..2fde8883c06c6 100644 --- a/crates/bevy_reflect/src/impls/math/primitives2d.rs +++ b/crates/bevy_reflect/src/impls/math/primitives2d.rs @@ -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!( #[reflect(Debug, PartialEq, Serialize, Deserialize)] #[type_path = "bevy_math::primitives"] diff --git a/crates/bevy_render/src/mesh/primitives/dim2.rs b/crates/bevy_render/src/mesh/primitives/dim2.rs index 899286657ee51..2cfa3acce28bf 100644 --- a/crates/bevy_render/src/mesh/primitives/dim2.rs +++ b/crates/bevy_render/src/mesh/primitives/dim2.rs @@ -10,7 +10,7 @@ use super::{MeshBuilder, Meshable}; use bevy_math::{ primitives::{ Annulus, Capsule2d, Circle, CircularSector, CircularSegment, Ellipse, Rectangle, - RegularPolygon, Triangle2d, Triangle3d, WindingOrder, + RegularPolygon, Rhombus, Triangle2d, Triangle3d, WindingOrder, }, FloatExt, Vec2, }; @@ -583,6 +583,38 @@ impl From 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 for Mesh { + fn from(rhombus: Rhombus) -> Self { + rhombus.mesh() + } +} + impl Meshable for Triangle2d { type Output = Mesh; diff --git a/examples/2d/2d_shapes.rs b/examples/2d/2d_shapes.rs index e76cc5a428a28..dbd4392c1aadf 100644 --- a/examples/2d/2d_shapes.rs +++ b/examples/2d/2d_shapes.rs @@ -12,7 +12,7 @@ fn main() { .run(); } -const X_EXTENT: f32 = 800.; +const X_EXTENT: f32 = 900.; fn setup( mut commands: Commands, @@ -28,6 +28,7 @@ fn setup( Mesh2dHandle(meshes.add(Ellipse::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(Rhombus::new(75.0, 100.0))), Mesh2dHandle(meshes.add(Rectangle::new(50.0, 100.0))), Mesh2dHandle(meshes.add(RegularPolygon::new(50.0, 6))), Mesh2dHandle(meshes.add(Triangle2d::new(