From ec01c2dc4562adb979cf5497a48b53efa8250901 Mon Sep 17 00:00:00 2001 From: Ben Harper Date: Fri, 24 May 2024 02:12:46 +1000 Subject: [PATCH] New circular primitives: `Arc2d`, `CircularSector`, `CircularSegment` (#13482) # Objective Adopted #11748 ## Solution I've rebased on main to fix the merge conflicts. ~~Not quite ready to merge yet~~ * Clippy is happy and the tests are passing, but... * ~~The new shapes in `examples/2d/2d_shapes.rs` don't look right at all~~ Never mind, looks like radians and degrees just got mixed up at some point? * I have updated one doc comment based on a review in the original PR. --------- Co-authored-by: Alexis "spectria" Horizon Co-authored-by: Alexis "spectria" Horizon <118812919+spectria-limina@users.noreply.github.com> Co-authored-by: Joona Aalto Co-authored-by: Alice Cecile Co-authored-by: Ben Harper --- Cargo.toml | 11 + crates/bevy_math/Cargo.toml | 3 + .../src/bounding/bounded2d/primitive_impls.rs | 417 +++++++++++- crates/bevy_math/src/direction.rs | 6 +- crates/bevy_math/src/primitives/dim2.rs | 635 +++++++++++++++++- crates/bevy_math/src/rotation2d.rs | 6 +- .../bevy_render/src/mesh/primitives/dim2.rs | 292 +++++++- crates/bevy_render/src/mesh/primitives/mod.rs | 2 +- examples/2d/2d_shapes.rs | 4 +- examples/2d/mesh2d_arcs.rs | 124 ++++ examples/README.md | 1 + 11 files changed, 1485 insertions(+), 16 deletions(-) create mode 100644 examples/2d/mesh2d_arcs.rs diff --git a/Cargo.toml b/Cargo.toml index a67a80df9a7c9..947f4875278e9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -423,6 +423,17 @@ description = "Renders a 2d mesh" category = "2D Rendering" wasm = true +[[example]] +name = "mesh2d_arcs" +path = "examples/2d/mesh2d_arcs.rs" +doc-scrape-examples = true + +[package.metadata.example.mesh2d_arcs] +name = "Arc 2D Meshes" +description = "Demonstrates UV-mapping of the circular segment and sector primitives" +category = "2D Rendering" +wasm = true + [[example]] name = "mesh2d_manual" path = "examples/2d/mesh2d_manual.rs" diff --git a/crates/bevy_math/Cargo.toml b/crates/bevy_math/Cargo.toml index a1e362b56f574..b3cca0d1dc7e0 100644 --- a/crates/bevy_math/Cargo.toml +++ b/crates/bevy_math/Cargo.toml @@ -18,6 +18,7 @@ approx = { version = "0.5", optional = true } rand = { version = "0.8", features = [ "alloc", ], default-features = false, optional = true } +smallvec = { version = "1.11" } [dev-dependencies] approx = "0.5" @@ -26,6 +27,8 @@ rand = "0.8" rand_chacha = "0.3" # Enable the approx feature when testing. bevy_math = { path = ".", version = "0.14.0-dev", features = ["approx"] } +glam = { version = "0.27", features = ["approx"] } + [features] default = ["rand"] diff --git a/crates/bevy_math/src/bounding/bounded2d/primitive_impls.rs b/crates/bevy_math/src/bounding/bounded2d/primitive_impls.rs index 4cfa6c008004e..01d3d3a70e7c7 100644 --- a/crates/bevy_math/src/bounding/bounded2d/primitive_impls.rs +++ b/crates/bevy_math/src/bounding/bounded2d/primitive_impls.rs @@ -2,11 +2,15 @@ use crate::{ primitives::{ - BoxedPolygon, BoxedPolyline2d, Capsule2d, Circle, Ellipse, Line2d, Plane2d, Polygon, - Polyline2d, Rectangle, RegularPolygon, Segment2d, Triangle2d, + Arc2d, BoxedPolygon, BoxedPolyline2d, Capsule2d, Circle, CircularSector, CircularSegment, + Ellipse, Line2d, Plane2d, Polygon, Polyline2d, Rectangle, RegularPolygon, Segment2d, + Triangle2d, }, Dir2, Mat2, Rotation2d, Vec2, }; +use std::f32::consts::{FRAC_PI_2, PI, TAU}; + +use smallvec::SmallVec; use super::{Aabb2d, Bounded2d, BoundingCircle}; @@ -24,6 +28,120 @@ impl Bounded2d for Circle { } } +// Compute the axis-aligned bounding points of a rotated arc, used for computing the AABB of arcs and derived shapes. +// The return type has room for 7 points so that the CircularSector code can add an additional point. +#[inline] +fn arc_bounding_points(arc: Arc2d, rotation: impl Into) -> SmallVec<[Vec2; 7]> { + // Otherwise, the extreme points will always be either the endpoints or the axis-aligned extrema of the arc's circle. + // We need to compute which axis-aligned extrema are actually contained within the rotated arc. + let mut bounds = SmallVec::<[Vec2; 7]>::new(); + let rotation = rotation.into(); + bounds.push(rotation * arc.left_endpoint()); + bounds.push(rotation * arc.right_endpoint()); + + // The half-angles are measured from a starting point of π/2, being the angle of Vec2::Y. + // Compute the normalized angles of the endpoints with the rotation taken into account, and then + // check if we are looking for an angle that is between or outside them. + let left_angle = (FRAC_PI_2 + arc.half_angle + rotation.as_radians()).rem_euclid(TAU); + let right_angle = (FRAC_PI_2 - arc.half_angle + rotation.as_radians()).rem_euclid(TAU); + let inverted = left_angle < right_angle; + for extremum in [Vec2::X, Vec2::Y, Vec2::NEG_X, Vec2::NEG_Y] { + let angle = extremum.to_angle().rem_euclid(TAU); + // If inverted = true, then right_angle > left_angle, so we are looking for an angle that is not between them. + // There's a chance that this condition fails due to rounding error, if the endpoint angle is juuuust shy of the axis. + // But in that case, the endpoint itself is within rounding error of the axis and will define the bounds just fine. + #[allow(clippy::nonminimal_bool)] + if !inverted && angle >= right_angle && angle <= left_angle + || inverted && (angle >= right_angle || angle <= left_angle) + { + bounds.push(extremum * arc.radius); + } + } + bounds +} + +impl Bounded2d for Arc2d { + fn aabb_2d(&self, translation: Vec2, rotation: impl Into) -> Aabb2d { + // If our arc covers more than a circle, just return the bounding box of the circle. + if self.half_angle >= PI { + return Circle::new(self.radius).aabb_2d(translation, rotation); + } + + Aabb2d::from_point_cloud(translation, 0.0, &arc_bounding_points(*self, rotation)) + } + + fn bounding_circle( + &self, + translation: Vec2, + rotation: impl Into, + ) -> BoundingCircle { + // There are two possibilities for the bounding circle. + if self.is_major() { + // If the arc is major, then the widest distance between two points is a diameter of the arc's circle; + // therefore, that circle is the bounding radius. + BoundingCircle::new(translation, self.radius) + } else { + // Otherwise, the widest distance between two points is the chord, + // so a circle of that diameter around the midpoint will contain the entire arc. + let center = rotation.into() * self.chord_midpoint(); + BoundingCircle::new(center + translation, self.half_chord_length()) + } + } +} + +impl Bounded2d for CircularSector { + fn aabb_2d(&self, translation: Vec2, rotation: impl Into) -> Aabb2d { + // If our sector covers more than a circle, just return the bounding box of the circle. + if self.half_angle() >= PI { + return Circle::new(self.radius()).aabb_2d(translation, rotation); + } + + // Otherwise, we use the same logic as for Arc2d, above, just with the circle's center as an additional possibility. + let mut bounds = arc_bounding_points(self.arc, rotation); + bounds.push(Vec2::ZERO); + + Aabb2d::from_point_cloud(translation, 0.0, &bounds) + } + + fn bounding_circle( + &self, + translation: Vec2, + rotation: impl Into, + ) -> BoundingCircle { + if self.arc.is_major() { + // If the arc is major, that is, greater than a semicircle, + // then bounding circle is just the circle defining the sector. + BoundingCircle::new(translation, self.arc.radius) + } else { + // However, when the arc is minor, + // we need our bounding circle to include both endpoints of the arc as well as the circle center. + // This means we need the circumcircle of those three points. + // The circumcircle will always have a greater curvature than the circle itself, so it will contain + // the entire circular sector. + Triangle2d::new( + Vec2::ZERO, + self.arc.left_endpoint(), + self.arc.right_endpoint(), + ) + .bounding_circle(translation, rotation) + } + } +} + +impl Bounded2d for CircularSegment { + fn aabb_2d(&self, translation: Vec2, rotation: impl Into) -> Aabb2d { + self.arc.aabb_2d(translation, rotation) + } + + fn bounding_circle( + &self, + translation: Vec2, + rotation: impl Into, + ) -> BoundingCircle { + self.arc.bounding_circle(translation, rotation) + } +} + impl Bounded2d for Ellipse { fn aabb_2d(&self, translation: Vec2, rotation: impl Into) -> Aabb2d { let rotation: Rotation2d = rotation.into(); @@ -321,13 +439,16 @@ impl Bounded2d for Capsule2d { #[cfg(test)] mod tests { + use std::f32::consts::{FRAC_PI_2, FRAC_PI_3, FRAC_PI_4, FRAC_PI_6, TAU}; + + use approx::assert_abs_diff_eq; use glam::Vec2; use crate::{ bounding::Bounded2d, primitives::{ - Capsule2d, Circle, Ellipse, Line2d, Plane2d, Polygon, Polyline2d, Rectangle, - RegularPolygon, Segment2d, Triangle2d, + Arc2d, Capsule2d, Circle, CircularSector, CircularSegment, Ellipse, Line2d, Plane2d, + Polygon, Polyline2d, Rectangle, RegularPolygon, Segment2d, Triangle2d, }, Dir2, }; @@ -346,6 +467,294 @@ mod tests { assert_eq!(bounding_circle.radius(), 1.0); } + #[test] + // Arcs and circular segments have the same bounding shapes so they share test cases. + fn arc_and_segment() { + struct TestCase { + name: &'static str, + arc: Arc2d, + translation: Vec2, + rotation: f32, + aabb_min: Vec2, + aabb_max: Vec2, + bounding_circle_center: Vec2, + bounding_circle_radius: f32, + } + + // The apothem of an arc covering 1/6th of a circle. + let apothem = f32::sqrt(3.0) / 2.0; + let tests = [ + // Test case: a basic minor arc + TestCase { + name: "1/6th circle untransformed", + arc: Arc2d::from_radians(1.0, FRAC_PI_3), + translation: Vec2::ZERO, + rotation: 0.0, + aabb_min: Vec2::new(-0.5, apothem), + aabb_max: Vec2::new(0.5, 1.0), + bounding_circle_center: Vec2::new(0.0, apothem), + bounding_circle_radius: 0.5, + }, + // Test case: a smaller arc, verifying that radius scaling works + TestCase { + name: "1/6th circle with radius 0.5", + arc: Arc2d::from_radians(0.5, FRAC_PI_3), + translation: Vec2::ZERO, + rotation: 0.0, + aabb_min: Vec2::new(-0.25, apothem / 2.0), + aabb_max: Vec2::new(0.25, 0.5), + bounding_circle_center: Vec2::new(0.0, apothem / 2.0), + bounding_circle_radius: 0.25, + }, + // Test case: a larger arc, verifying that radius scaling works + TestCase { + name: "1/6th circle with radius 2.0", + arc: Arc2d::from_radians(2.0, FRAC_PI_3), + translation: Vec2::ZERO, + rotation: 0.0, + aabb_min: Vec2::new(-1.0, 2.0 * apothem), + aabb_max: Vec2::new(1.0, 2.0), + bounding_circle_center: Vec2::new(0.0, 2.0 * apothem), + bounding_circle_radius: 1.0, + }, + // Test case: translation of a minor arc + TestCase { + name: "1/6th circle translated", + arc: Arc2d::from_radians(1.0, FRAC_PI_3), + translation: Vec2::new(2.0, 3.0), + rotation: 0.0, + aabb_min: Vec2::new(1.5, 3.0 + apothem), + aabb_max: Vec2::new(2.5, 4.0), + bounding_circle_center: Vec2::new(2.0, 3.0 + apothem), + bounding_circle_radius: 0.5, + }, + // Test case: rotation of a minor arc + TestCase { + name: "1/6th circle rotated", + arc: Arc2d::from_radians(1.0, FRAC_PI_3), + translation: Vec2::ZERO, + // Rotate left by 1/12 of a circle, so the right endpoint is on the y-axis. + rotation: FRAC_PI_6, + aabb_min: Vec2::new(-apothem, 0.5), + aabb_max: Vec2::new(0.0, 1.0), + // The exact coordinates here are not obvious, but can be computed by constructing + // an altitude from the midpoint of the chord to the y-axis and using the right triangle + // similarity theorem. + bounding_circle_center: Vec2::new(-apothem / 2.0, apothem.powi(2)), + bounding_circle_radius: 0.5, + }, + // Test case: handling of axis-aligned extrema + TestCase { + name: "1/4er circle rotated to be axis-aligned", + arc: Arc2d::from_radians(1.0, FRAC_PI_2), + translation: Vec2::ZERO, + // Rotate right by 1/8 of a circle, so the right endpoint is on the x-axis and the left endpoint is on the y-axis. + rotation: -FRAC_PI_4, + aabb_min: Vec2::ZERO, + aabb_max: Vec2::splat(1.0), + bounding_circle_center: Vec2::splat(0.5), + bounding_circle_radius: f32::sqrt(2.0) / 2.0, + }, + // Test case: a basic major arc + TestCase { + name: "5/6th circle untransformed", + arc: Arc2d::from_radians(1.0, 5.0 * FRAC_PI_3), + translation: Vec2::ZERO, + rotation: 0.0, + aabb_min: Vec2::new(-1.0, -apothem), + aabb_max: Vec2::new(1.0, 1.0), + bounding_circle_center: Vec2::ZERO, + bounding_circle_radius: 1.0, + }, + // Test case: a translated major arc + TestCase { + name: "5/6th circle translated", + arc: Arc2d::from_radians(1.0, 5.0 * FRAC_PI_3), + translation: Vec2::new(2.0, 3.0), + rotation: 0.0, + aabb_min: Vec2::new(1.0, 3.0 - apothem), + aabb_max: Vec2::new(3.0, 4.0), + bounding_circle_center: Vec2::new(2.0, 3.0), + bounding_circle_radius: 1.0, + }, + // Test case: a rotated major arc, with inverted left/right angles + TestCase { + name: "5/6th circle rotated", + arc: Arc2d::from_radians(1.0, 5.0 * FRAC_PI_3), + translation: Vec2::ZERO, + // Rotate left by 1/12 of a circle, so the left endpoint is on the y-axis. + rotation: FRAC_PI_6, + aabb_min: Vec2::new(-1.0, -1.0), + aabb_max: Vec2::new(1.0, 1.0), + bounding_circle_center: Vec2::ZERO, + bounding_circle_radius: 1.0, + }, + ]; + + for test in tests { + println!("subtest case: {}", test.name); + let segment: CircularSegment = test.arc.into(); + + let arc_aabb = test.arc.aabb_2d(test.translation, test.rotation); + assert_abs_diff_eq!(test.aabb_min, arc_aabb.min); + assert_abs_diff_eq!(test.aabb_max, arc_aabb.max); + let segment_aabb = segment.aabb_2d(test.translation, test.rotation); + assert_abs_diff_eq!(test.aabb_min, segment_aabb.min); + assert_abs_diff_eq!(test.aabb_max, segment_aabb.max); + + let arc_bounding_circle = test.arc.bounding_circle(test.translation, test.rotation); + assert_abs_diff_eq!(test.bounding_circle_center, arc_bounding_circle.center); + assert_abs_diff_eq!(test.bounding_circle_radius, arc_bounding_circle.radius()); + let segment_bounding_circle = segment.bounding_circle(test.translation, test.rotation); + assert_abs_diff_eq!(test.bounding_circle_center, segment_bounding_circle.center); + assert_abs_diff_eq!( + test.bounding_circle_radius, + segment_bounding_circle.radius() + ); + } + } + + #[test] + fn circular_sector() { + struct TestCase { + name: &'static str, + arc: Arc2d, + translation: Vec2, + rotation: f32, + aabb_min: Vec2, + aabb_max: Vec2, + bounding_circle_center: Vec2, + bounding_circle_radius: f32, + } + + // The apothem of an arc covering 1/6th of a circle. + let apothem = f32::sqrt(3.0) / 2.0; + let inv_sqrt_3 = f32::sqrt(3.0).recip(); + let tests = [ + // Test case: An sector whose arc is minor, but whose bounding circle is not the circumcircle of the endpoints and center + TestCase { + name: "1/3rd circle", + arc: Arc2d::from_radians(1.0, TAU / 3.0), + translation: Vec2::ZERO, + rotation: 0.0, + aabb_min: Vec2::new(-apothem, 0.0), + aabb_max: Vec2::new(apothem, 1.0), + bounding_circle_center: Vec2::new(0.0, 0.5), + bounding_circle_radius: apothem, + }, + // The remaining test cases are selected as for arc_and_segment. + TestCase { + name: "1/6th circle untransformed", + arc: Arc2d::from_radians(1.0, FRAC_PI_3), + translation: Vec2::ZERO, + rotation: 0.0, + aabb_min: Vec2::new(-0.5, 0.0), + aabb_max: Vec2::new(0.5, 1.0), + // The bounding circle is a circumcircle of an equilateral triangle with side length 1. + // The distance from the corner to the center of such a triangle is 1/sqrt(3). + bounding_circle_center: Vec2::new(0.0, inv_sqrt_3), + bounding_circle_radius: inv_sqrt_3, + }, + TestCase { + name: "1/6th circle with radius 0.5", + arc: Arc2d::from_radians(0.5, FRAC_PI_3), + translation: Vec2::ZERO, + rotation: 0.0, + aabb_min: Vec2::new(-0.25, 0.0), + aabb_max: Vec2::new(0.25, 0.5), + bounding_circle_center: Vec2::new(0.0, inv_sqrt_3 / 2.0), + bounding_circle_radius: inv_sqrt_3 / 2.0, + }, + TestCase { + name: "1/6th circle with radius 2.0", + arc: Arc2d::from_radians(2.0, FRAC_PI_3), + translation: Vec2::ZERO, + rotation: 0.0, + aabb_min: Vec2::new(-1.0, 0.0), + aabb_max: Vec2::new(1.0, 2.0), + bounding_circle_center: Vec2::new(0.0, 2.0 * inv_sqrt_3), + bounding_circle_radius: 2.0 * inv_sqrt_3, + }, + TestCase { + name: "1/6th circle translated", + arc: Arc2d::from_radians(1.0, FRAC_PI_3), + translation: Vec2::new(2.0, 3.0), + rotation: 0.0, + aabb_min: Vec2::new(1.5, 3.0), + aabb_max: Vec2::new(2.5, 4.0), + bounding_circle_center: Vec2::new(2.0, 3.0 + inv_sqrt_3), + bounding_circle_radius: inv_sqrt_3, + }, + TestCase { + name: "1/6th circle rotated", + arc: Arc2d::from_radians(1.0, FRAC_PI_3), + translation: Vec2::ZERO, + // Rotate left by 1/12 of a circle, so the right endpoint is on the y-axis. + rotation: FRAC_PI_6, + aabb_min: Vec2::new(-apothem, 0.0), + aabb_max: Vec2::new(0.0, 1.0), + // The x-coordinate is now the inradius of the equilateral triangle, which is sqrt(3)/2. + bounding_circle_center: Vec2::new(-inv_sqrt_3 / 2.0, 0.5), + bounding_circle_radius: inv_sqrt_3, + }, + TestCase { + name: "1/4er circle rotated to be axis-aligned", + arc: Arc2d::from_radians(1.0, FRAC_PI_2), + translation: Vec2::ZERO, + // Rotate right by 1/8 of a circle, so the right endpoint is on the x-axis and the left endpoint is on the y-axis. + rotation: -FRAC_PI_4, + aabb_min: Vec2::ZERO, + aabb_max: Vec2::splat(1.0), + bounding_circle_center: Vec2::splat(0.5), + bounding_circle_radius: f32::sqrt(2.0) / 2.0, + }, + TestCase { + name: "5/6th circle untransformed", + arc: Arc2d::from_radians(1.0, 5.0 * FRAC_PI_3), + translation: Vec2::ZERO, + rotation: 0.0, + aabb_min: Vec2::new(-1.0, -apothem), + aabb_max: Vec2::new(1.0, 1.0), + bounding_circle_center: Vec2::ZERO, + bounding_circle_radius: 1.0, + }, + TestCase { + name: "5/6th circle translated", + arc: Arc2d::from_radians(1.0, 5.0 * FRAC_PI_3), + translation: Vec2::new(2.0, 3.0), + rotation: 0.0, + aabb_min: Vec2::new(1.0, 3.0 - apothem), + aabb_max: Vec2::new(3.0, 4.0), + bounding_circle_center: Vec2::new(2.0, 3.0), + bounding_circle_radius: 1.0, + }, + TestCase { + name: "5/6th circle rotated", + arc: Arc2d::from_radians(1.0, 5.0 * FRAC_PI_3), + translation: Vec2::ZERO, + // Rotate left by 1/12 of a circle, so the left endpoint is on the y-axis. + rotation: FRAC_PI_6, + aabb_min: Vec2::new(-1.0, -1.0), + aabb_max: Vec2::new(1.0, 1.0), + bounding_circle_center: Vec2::ZERO, + bounding_circle_radius: 1.0, + }, + ]; + + for test in tests { + println!("subtest case: {}", test.name); + let sector: CircularSector = test.arc.into(); + + let aabb = sector.aabb_2d(test.translation, test.rotation); + assert_abs_diff_eq!(test.aabb_min, aabb.min); + assert_abs_diff_eq!(test.aabb_max, aabb.max); + + let bounding_circle = sector.bounding_circle(test.translation, test.rotation); + assert_abs_diff_eq!(test.bounding_circle_center, bounding_circle.center); + assert_abs_diff_eq!(test.bounding_circle_radius, bounding_circle.radius()); + } + } + #[test] fn ellipse() { let ellipse = Ellipse::new(1.0, 0.5); diff --git a/crates/bevy_math/src/direction.rs b/crates/bevy_math/src/direction.rs index 5deeee2a835fd..a5af98f8f4649 100644 --- a/crates/bevy_math/src/direction.rs +++ b/crates/bevy_math/src/direction.rs @@ -234,7 +234,7 @@ impl std::ops::Mul for Rotation2d { } } -#[cfg(feature = "approx")] +#[cfg(any(feature = "approx", test))] impl approx::AbsDiffEq for Dir2 { type Epsilon = f32; fn default_epsilon() -> f32 { @@ -245,7 +245,7 @@ impl approx::AbsDiffEq for Dir2 { } } -#[cfg(feature = "approx")] +#[cfg(any(feature = "approx", test))] impl approx::RelativeEq for Dir2 { fn default_max_relative() -> f32 { f32::EPSILON @@ -256,7 +256,7 @@ impl approx::RelativeEq for Dir2 { } } -#[cfg(feature = "approx")] +#[cfg(any(feature = "approx", test))] impl approx::UlpsEq for Dir2 { fn default_max_ulps() -> u32 { 4 diff --git a/crates/bevy_math/src/primitives/dim2.rs b/crates/bevy_math/src/primitives/dim2.rs index 61725ab1b94e3..b331a37114a22 100644 --- a/crates/bevy_math/src/primitives/dim2.rs +++ b/crates/bevy_math/src/primitives/dim2.rs @@ -1,4 +1,4 @@ -use std::f32::consts::PI; +use std::f32::consts::{FRAC_PI_2, FRAC_PI_3, PI}; use super::{Measured2d, Primitive2d, WindingOrder}; use crate::{Dir2, Vec2}; @@ -67,6 +67,639 @@ impl Measured2d for Circle { } } +/// A primitive representing an arc between two points on a circle. +/// +/// An arc has no area. +/// If you want to include the portion of a circle's area swept out by the arc, +/// use the pie-shaped [`CircularSector`]. +/// If you want to include only the space inside the convex hull of the arc, +/// use the bowl-shaped [`CircularSegment`]. +/// +/// The arc is drawn starting from [`Vec2::Y`], extending by `half_angle` radians on +/// either side. The center of the circle is the origin [`Vec2::ZERO`]. Note that this +/// means that the origin may not be within the `Arc2d`'s convex hull. +/// +/// **Warning:** Arcs with negative angle or radius, or with angle greater than an entire circle, are not officially supported. +/// It is recommended to normalize arcs to have an angle in [0, 2π]. +#[derive(Clone, Copy, Debug, PartialEq)] +#[doc(alias("CircularArc", "CircleArc"))] +#[cfg_attr(feature = "serialize", derive(serde::Serialize, serde::Deserialize))] +pub struct Arc2d { + /// The radius of the circle + pub radius: f32, + /// Half the angle defining the arc + pub half_angle: f32, +} +impl Primitive2d for Arc2d {} + +impl Default for Arc2d { + /// Returns the default [`Arc2d`] with radius `0.5`, covering one third of a circle + fn default() -> Self { + Self { + radius: 0.5, + half_angle: 2.0 * FRAC_PI_3, + } + } +} + +impl Arc2d { + /// Create a new [`Arc2d`] from a `radius` and a `half_angle` + #[inline(always)] + pub fn new(radius: f32, half_angle: f32) -> Self { + Self { radius, half_angle } + } + + /// Create a new [`Arc2d`] from a `radius` and an `angle` in radians + #[inline(always)] + pub fn from_radians(radius: f32, angle: f32) -> Self { + Self { + radius, + half_angle: angle / 2.0, + } + } + + /// Create a new [`Arc2d`] from a `radius` and an `angle` in degrees. + #[inline(always)] + pub fn from_degrees(radius: f32, angle: f32) -> Self { + Self { + radius, + half_angle: angle.to_radians() / 2.0, + } + } + + /// Create a new [`Arc2d`] from a `radius` and a `fraction` of a single turn. + /// + /// For instance, `0.5` turns is a semicircle. + #[inline(always)] + pub fn from_turns(radius: f32, fraction: f32) -> Self { + Self { + radius, + half_angle: fraction * PI, + } + } + + /// Get the angle of the arc + #[inline(always)] + pub fn angle(&self) -> f32 { + self.half_angle * 2.0 + } + + /// Get the length of the arc + #[inline(always)] + pub fn length(&self) -> f32 { + self.angle() * self.radius + } + + /// Get the right-hand end point of the arc + #[inline(always)] + pub fn right_endpoint(&self) -> Vec2 { + self.radius * Vec2::from_angle(FRAC_PI_2 - self.half_angle) + } + + /// Get the left-hand end point of the arc + #[inline(always)] + pub fn left_endpoint(&self) -> Vec2 { + self.radius * Vec2::from_angle(FRAC_PI_2 + self.half_angle) + } + + /// Get the endpoints of the arc + #[inline(always)] + pub fn endpoints(&self) -> [Vec2; 2] { + [self.left_endpoint(), self.right_endpoint()] + } + + /// Get the midpoint of the arc + #[inline] + pub fn midpoint(&self) -> Vec2 { + self.radius * Vec2::Y + } + + /// Get half the distance between the endpoints (half the length of the chord) + #[inline(always)] + pub fn half_chord_length(&self) -> f32 { + self.radius * f32::sin(self.half_angle) + } + + /// Get the distance between the endpoints (the length of the chord) + #[inline(always)] + pub fn chord_length(&self) -> f32 { + 2.0 * self.half_chord_length() + } + + /// Get the midpoint of the two endpoints (the midpoint of the chord) + #[inline(always)] + pub fn chord_midpoint(&self) -> Vec2 { + self.apothem() * Vec2::Y + } + + /// Get the length of the apothem of this arc, that is, + /// the distance from the center of the circle to the midpoint of the chord, in the direction of the midpoint of the arc. + /// Equivalently, the [`radius`](Self::radius) minus the [`sagitta`](Self::sagitta). + /// + /// Note that for a [`major`](Self::is_major) arc, the apothem will be negative. + #[inline(always)] + // Naming note: Various sources are inconsistent as to whether the apothem is the segment between the center and the + // midpoint of a chord, or the length of that segment. Given this confusion, we've opted for the definition + // used by Wolfram MathWorld, which is the distance rather than the segment. + pub fn apothem(&self) -> f32 { + let sign = if self.is_minor() { 1.0 } else { -1.0 }; + sign * f32::sqrt(self.radius.powi(2) - self.half_chord_length().powi(2)) + } + + /// Get the length of the sagitta of this arc, that is, + /// the length of the line between the midpoints of the arc and its chord. + /// Equivalently, the height of the triangle whose base is the chord and whose apex is the midpoint of the arc. + /// + /// The sagitta is also the sum of the [`radius`](Self::radius) and the [`apothem`](Self::apothem). + pub fn sagitta(&self) -> f32 { + self.radius - self.apothem() + } + + /// Produces true if the arc is at most half a circle. + /// + /// **Note:** This is not the negation of [`is_major`](Self::is_major): an exact semicircle is both major and minor. + #[inline(always)] + pub fn is_minor(&self) -> bool { + self.half_angle <= FRAC_PI_2 + } + + /// Produces true if the arc is at least half a circle. + /// + /// **Note:** This is not the negation of [`is_minor`](Self::is_minor): an exact semicircle is both major and minor. + #[inline(always)] + pub fn is_major(&self) -> bool { + self.half_angle >= FRAC_PI_2 + } +} + +/// A primitive representing a circular sector: a pie slice of a circle. +/// +/// The segment is positioned so that it always includes [`Vec2::Y`] and is vertically symmetrical. +/// To orient the sector differently, apply a rotation. +/// The sector is drawn with the center of its circle at the origin [`Vec2::ZERO`]. +/// +/// **Warning:** Circular sectors with negative angle or radius, or with angle greater than an entire circle, are not officially supported. +/// We recommend normalizing circular sectors to have an angle in [0, 2π]. +#[derive(Clone, Copy, Debug, PartialEq)] +#[cfg_attr(feature = "serialize", derive(serde::Serialize, serde::Deserialize))] +pub struct CircularSector { + /// The arc defining the sector + #[cfg_attr(feature = "serialize", serde(flatten))] + pub arc: Arc2d, +} +impl Primitive2d for CircularSector {} + +impl Default for CircularSector { + /// Returns the default [`CircularSector`] with radius `0.5` and covering a third of a circle + fn default() -> Self { + Self::from(Arc2d::default()) + } +} + +impl From for CircularSector { + fn from(arc: Arc2d) -> Self { + Self { arc } + } +} + +impl CircularSector { + /// Create a new [`CircularSector`] from a `radius` and an `angle` + #[inline(always)] + pub fn new(radius: f32, angle: f32) -> Self { + Self::from(Arc2d::new(radius, angle)) + } + + /// Create a new [`CircularSector`] from a `radius` and an `angle` in radians. + #[inline(always)] + pub fn from_radians(radius: f32, angle: f32) -> Self { + Self::from(Arc2d::from_radians(radius, angle)) + } + + /// Create a new [`CircularSector`] from a `radius` and an `angle` in degrees. + #[inline(always)] + pub fn from_degrees(radius: f32, angle: f32) -> Self { + Self::from(Arc2d::from_degrees(radius, angle)) + } + + /// Create a new [`CircularSector`] from a `radius` and a number of `turns` of a circle. + /// + /// For instance, `0.5` turns is a semicircle. + #[inline(always)] + pub fn from_turns(radius: f32, fraction: f32) -> Self { + Self::from(Arc2d::from_turns(radius, fraction)) + } + + /// Get half the angle of the sector + #[inline(always)] + pub fn half_angle(&self) -> f32 { + self.arc.half_angle + } + + /// Get the angle of the sector + #[inline(always)] + pub fn angle(&self) -> f32 { + self.arc.angle() + } + + /// Get the radius of the sector + #[inline(always)] + pub fn radius(&self) -> f32 { + self.arc.radius + } + + /// Get the length of the arc defining the sector + #[inline(always)] + pub fn arc_length(&self) -> f32 { + self.arc.length() + } + + /// Get half the length of the chord defined by the sector + /// + /// See [`Arc2d::half_chord_length`] + #[inline(always)] + pub fn half_chord_length(&self) -> f32 { + self.arc.half_chord_length() + } + + /// Get the length of the chord defined by the sector + /// + /// See [`Arc2d::chord_length`] + #[inline(always)] + pub fn chord_length(&self) -> f32 { + self.arc.chord_length() + } + + /// Get the midpoint of the chord defined by the sector + /// + /// See [`Arc2d::chord_midpoint`] + #[inline(always)] + pub fn chord_midpoint(&self) -> Vec2 { + self.arc.chord_midpoint() + } + + /// Get the length of the apothem of this sector + /// + /// See [`Arc2d::apothem`] + #[inline(always)] + pub fn apothem(&self) -> f32 { + self.arc.apothem() + } + + /// Get the length of the sagitta of this sector + /// + /// See [`Arc2d::sagitta`] + #[inline(always)] + pub fn sagitta(&self) -> f32 { + self.arc.sagitta() + } + + /// Returns the area of this sector + #[inline(always)] + pub fn area(&self) -> f32 { + self.arc.radius.powi(2) * self.arc.half_angle + } +} + +/// A primitive representing a circular segment: +/// the area enclosed by the arc of a circle and its chord (the line between its endpoints). +/// +/// The segment is drawn starting from [`Vec2::Y`], extending equally on either side. +/// To orient the segment differently, apply a rotation. +/// The segment is drawn with the center of its circle at the origin [`Vec2::ZERO`]. +/// When positioning a segment, the [`apothem`](Self::apothem) function may be particularly useful. +/// +/// **Warning:** Circular segments with negative angle or radius, or with angle greater than an entire circle, are not officially supported. +/// We recommend normalizing circular segments to have an angle in [0, 2π]. +#[derive(Clone, Copy, Debug, PartialEq)] +#[cfg_attr(feature = "serialize", derive(serde::Serialize, serde::Deserialize))] +pub struct CircularSegment { + /// The arc defining the segment + #[cfg_attr(feature = "serialize", serde(flatten))] + pub arc: Arc2d, +} +impl Primitive2d for CircularSegment {} + +impl Default for CircularSegment { + /// Returns the default [`CircularSegment`] with radius `0.5` and covering a third of a circle + fn default() -> Self { + Self::from(Arc2d::default()) + } +} + +impl From for CircularSegment { + fn from(arc: Arc2d) -> Self { + Self { arc } + } +} + +impl CircularSegment { + /// Create a new [`CircularSegment`] from a `radius`, and an `angle` + #[inline(always)] + pub fn new(radius: f32, angle: f32) -> Self { + Self::from(Arc2d::new(radius, angle)) + } + + /// Create a new [`CircularSegment`] from a `radius` and an `angle` in radians. + #[inline(always)] + pub fn from_radians(radius: f32, angle: f32) -> Self { + Self::from(Arc2d::from_radians(radius, angle)) + } + + /// Create a new [`CircularSegment`] from a `radius` and an `angle` in degrees. + #[inline(always)] + pub fn from_degrees(radius: f32, angle: f32) -> Self { + Self::from(Arc2d::from_degrees(radius, angle)) + } + + /// Create a new [`CircularSegment`] from a `radius` and a number of `turns` of a circle. + /// + /// For instance, `0.5` turns is a semicircle. + #[inline(always)] + pub fn from_turns(radius: f32, fraction: f32) -> Self { + Self::from(Arc2d::from_turns(radius, fraction)) + } + + /// Get the half-angle of the segment + #[inline(always)] + pub fn half_angle(&self) -> f32 { + self.arc.half_angle + } + + /// Get the angle of the segment + #[inline(always)] + pub fn angle(&self) -> f32 { + self.arc.angle() + } + + /// Get the radius of the segment + #[inline(always)] + pub fn radius(&self) -> f32 { + self.arc.radius + } + + /// Get the length of the arc defining the segment + #[inline(always)] + pub fn arc_length(&self) -> f32 { + self.arc.length() + } + + /// Get half the length of the segment's base, also known as its chord + #[inline(always)] + #[doc(alias = "half_base_length")] + pub fn half_chord_length(&self) -> f32 { + self.arc.half_chord_length() + } + + /// Get the length of the segment's base, also known as its chord + #[inline(always)] + #[doc(alias = "base_length")] + #[doc(alias = "base")] + pub fn chord_length(&self) -> f32 { + self.arc.chord_length() + } + + /// Get the midpoint of the segment's base, also known as its chord + #[inline(always)] + #[doc(alias = "base_midpoint")] + pub fn chord_midpoint(&self) -> Vec2 { + self.arc.chord_midpoint() + } + + /// Get the length of the apothem of this segment, + /// which is the signed distance between the segment and the center of its circle + /// + /// See [`Arc2d::apothem`] + #[inline(always)] + pub fn apothem(&self) -> f32 { + self.arc.apothem() + } + + /// Get the length of the sagitta of this segment, also known as its height + /// + /// See [`Arc2d::sagitta`] + #[inline(always)] + #[doc(alias = "height")] + pub fn sagitta(&self) -> f32 { + self.arc.sagitta() + } + + /// Returns the area of this segment + #[inline(always)] + pub fn area(&self) -> f32 { + 0.5 * self.arc.radius.powi(2) * (self.arc.angle() - self.arc.angle().sin()) + } +} + +#[cfg(test)] +mod arc_tests { + use std::f32::consts::FRAC_PI_4; + + use approx::assert_abs_diff_eq; + + use super::*; + + struct ArcTestCase { + radius: f32, + half_angle: f32, + angle: f32, + length: f32, + right_endpoint: Vec2, + left_endpoint: Vec2, + endpoints: [Vec2; 2], + midpoint: Vec2, + half_chord_length: f32, + chord_length: f32, + chord_midpoint: Vec2, + apothem: f32, + sagitta: f32, + is_minor: bool, + is_major: bool, + sector_area: f32, + segment_area: f32, + } + + impl ArcTestCase { + fn check_arc(&self, arc: Arc2d) { + assert_abs_diff_eq!(self.radius, arc.radius); + assert_abs_diff_eq!(self.half_angle, arc.half_angle); + assert_abs_diff_eq!(self.angle, arc.angle()); + assert_abs_diff_eq!(self.length, arc.length()); + assert_abs_diff_eq!(self.right_endpoint, arc.right_endpoint()); + assert_abs_diff_eq!(self.left_endpoint, arc.left_endpoint()); + assert_abs_diff_eq!(self.endpoints[0], arc.endpoints()[0]); + assert_abs_diff_eq!(self.endpoints[1], arc.endpoints()[1]); + assert_abs_diff_eq!(self.midpoint, arc.midpoint()); + assert_abs_diff_eq!(self.half_chord_length, arc.half_chord_length()); + assert_abs_diff_eq!(self.chord_length, arc.chord_length(), epsilon = 0.00001); + assert_abs_diff_eq!(self.chord_midpoint, arc.chord_midpoint()); + assert_abs_diff_eq!(self.apothem, arc.apothem()); + assert_abs_diff_eq!(self.sagitta, arc.sagitta()); + assert_eq!(self.is_minor, arc.is_minor()); + assert_eq!(self.is_major, arc.is_major()); + } + + fn check_sector(&self, sector: CircularSector) { + assert_abs_diff_eq!(self.radius, sector.radius()); + assert_abs_diff_eq!(self.half_angle, sector.half_angle()); + assert_abs_diff_eq!(self.angle, sector.angle()); + assert_abs_diff_eq!(self.half_chord_length, sector.half_chord_length()); + assert_abs_diff_eq!(self.chord_length, sector.chord_length(), epsilon = 0.00001); + assert_abs_diff_eq!(self.chord_midpoint, sector.chord_midpoint()); + assert_abs_diff_eq!(self.apothem, sector.apothem()); + assert_abs_diff_eq!(self.sagitta, sector.sagitta()); + assert_abs_diff_eq!(self.sector_area, sector.area()); + } + + fn check_segment(&self, segment: CircularSegment) { + assert_abs_diff_eq!(self.radius, segment.radius()); + assert_abs_diff_eq!(self.half_angle, segment.half_angle()); + assert_abs_diff_eq!(self.angle, segment.angle()); + assert_abs_diff_eq!(self.half_chord_length, segment.half_chord_length()); + assert_abs_diff_eq!(self.chord_length, segment.chord_length(), epsilon = 0.00001); + assert_abs_diff_eq!(self.chord_midpoint, segment.chord_midpoint()); + assert_abs_diff_eq!(self.apothem, segment.apothem()); + assert_abs_diff_eq!(self.sagitta, segment.sagitta()); + assert_abs_diff_eq!(self.segment_area, segment.area()); + } + } + + #[test] + fn zero_angle() { + let tests = ArcTestCase { + radius: 1.0, + half_angle: 0.0, + angle: 0.0, + length: 0.0, + left_endpoint: Vec2::Y, + right_endpoint: Vec2::Y, + endpoints: [Vec2::Y, Vec2::Y], + midpoint: Vec2::Y, + half_chord_length: 0.0, + chord_length: 0.0, + chord_midpoint: Vec2::Y, + apothem: 1.0, + sagitta: 0.0, + is_minor: true, + is_major: false, + sector_area: 0.0, + segment_area: 0.0, + }; + + tests.check_arc(Arc2d::new(1.0, 0.0)); + tests.check_sector(CircularSector::new(1.0, 0.0)); + tests.check_segment(CircularSegment::new(1.0, 0.0)); + } + + #[test] + fn zero_radius() { + let tests = ArcTestCase { + radius: 0.0, + half_angle: FRAC_PI_4, + angle: FRAC_PI_2, + length: 0.0, + left_endpoint: Vec2::ZERO, + right_endpoint: Vec2::ZERO, + endpoints: [Vec2::ZERO, Vec2::ZERO], + midpoint: Vec2::ZERO, + half_chord_length: 0.0, + chord_length: 0.0, + chord_midpoint: Vec2::ZERO, + apothem: 0.0, + sagitta: 0.0, + is_minor: true, + is_major: false, + sector_area: 0.0, + segment_area: 0.0, + }; + + tests.check_arc(Arc2d::new(0.0, FRAC_PI_4)); + tests.check_sector(CircularSector::new(0.0, FRAC_PI_4)); + tests.check_segment(CircularSegment::new(0.0, FRAC_PI_4)); + } + + #[test] + fn quarter_circle() { + let sqrt_half: f32 = f32::sqrt(0.5); + let tests = ArcTestCase { + radius: 1.0, + half_angle: FRAC_PI_4, + angle: FRAC_PI_2, + length: FRAC_PI_2, + left_endpoint: Vec2::new(-sqrt_half, sqrt_half), + right_endpoint: Vec2::splat(sqrt_half), + endpoints: [Vec2::new(-sqrt_half, sqrt_half), Vec2::splat(sqrt_half)], + midpoint: Vec2::Y, + half_chord_length: sqrt_half, + chord_length: f32::sqrt(2.0), + chord_midpoint: Vec2::new(0.0, sqrt_half), + apothem: sqrt_half, + sagitta: 1.0 - sqrt_half, + is_minor: true, + is_major: false, + sector_area: FRAC_PI_4, + segment_area: FRAC_PI_4 - 0.5, + }; + + tests.check_arc(Arc2d::from_turns(1.0, 0.25)); + tests.check_sector(CircularSector::from_turns(1.0, 0.25)); + tests.check_segment(CircularSegment::from_turns(1.0, 0.25)); + } + + #[test] + fn half_circle() { + let tests = ArcTestCase { + radius: 1.0, + half_angle: FRAC_PI_2, + angle: PI, + length: PI, + left_endpoint: Vec2::NEG_X, + right_endpoint: Vec2::X, + endpoints: [Vec2::NEG_X, Vec2::X], + midpoint: Vec2::Y, + half_chord_length: 1.0, + chord_length: 2.0, + chord_midpoint: Vec2::ZERO, + apothem: 0.0, + sagitta: 1.0, + is_minor: true, + is_major: true, + sector_area: FRAC_PI_2, + segment_area: FRAC_PI_2, + }; + + tests.check_arc(Arc2d::from_radians(1.0, PI)); + tests.check_sector(CircularSector::from_radians(1.0, PI)); + tests.check_segment(CircularSegment::from_radians(1.0, PI)); + } + + #[test] + fn full_circle() { + let tests = ArcTestCase { + radius: 1.0, + half_angle: PI, + angle: 2.0 * PI, + length: 2.0 * PI, + left_endpoint: Vec2::NEG_Y, + right_endpoint: Vec2::NEG_Y, + endpoints: [Vec2::NEG_Y, Vec2::NEG_Y], + midpoint: Vec2::Y, + half_chord_length: 0.0, + chord_length: 0.0, + chord_midpoint: Vec2::NEG_Y, + apothem: -1.0, + sagitta: 2.0, + is_minor: false, + is_major: true, + sector_area: PI, + segment_area: PI, + }; + + tests.check_arc(Arc2d::from_degrees(1.0, 360.0)); + tests.check_sector(CircularSector::from_degrees(1.0, 360.0)); + tests.check_segment(CircularSegment::from_degrees(1.0, 360.0)); + } +} + /// An ellipse primitive #[derive(Clone, Copy, Debug, PartialEq)] #[cfg_attr(feature = "serialize", derive(serde::Serialize, serde::Deserialize))] diff --git a/crates/bevy_math/src/rotation2d.rs b/crates/bevy_math/src/rotation2d.rs index 7291e57c19233..bdeb5620a2c4c 100644 --- a/crates/bevy_math/src/rotation2d.rs +++ b/crates/bevy_math/src/rotation2d.rs @@ -386,7 +386,7 @@ impl std::ops::Mul for Rotation2d { } } -#[cfg(feature = "approx")] +#[cfg(any(feature = "approx", test))] impl approx::AbsDiffEq for Rotation2d { type Epsilon = f32; fn default_epsilon() -> f32 { @@ -397,7 +397,7 @@ impl approx::AbsDiffEq for Rotation2d { } } -#[cfg(feature = "approx")] +#[cfg(any(feature = "approx", test))] impl approx::RelativeEq for Rotation2d { fn default_max_relative() -> f32 { f32::EPSILON @@ -408,7 +408,7 @@ impl approx::RelativeEq for Rotation2d { } } -#[cfg(feature = "approx")] +#[cfg(any(feature = "approx", test))] impl approx::UlpsEq for Rotation2d { fn default_max_ulps() -> u32 { 4 diff --git a/crates/bevy_render/src/mesh/primitives/dim2.rs b/crates/bevy_render/src/mesh/primitives/dim2.rs index 37a91c7f229ac..899286657ee51 100644 --- a/crates/bevy_render/src/mesh/primitives/dim2.rs +++ b/crates/bevy_render/src/mesh/primitives/dim2.rs @@ -1,3 +1,5 @@ +use std::f32::consts::FRAC_PI_2; + use crate::{ mesh::primitives::dim3::triangle3d, mesh::{Indices, Mesh}, @@ -5,9 +7,12 @@ use crate::{ }; use super::{MeshBuilder, Meshable}; -use bevy_math::primitives::{ - Annulus, Capsule2d, Circle, Ellipse, Rectangle, RegularPolygon, Triangle2d, Triangle3d, - WindingOrder, +use bevy_math::{ + primitives::{ + Annulus, Capsule2d, Circle, CircularSector, CircularSegment, Ellipse, Rectangle, + RegularPolygon, Triangle2d, Triangle3d, WindingOrder, + }, + FloatExt, Vec2, }; use wgpu::PrimitiveTopology; @@ -73,6 +78,287 @@ impl From for Mesh { } } +/// Specifies how to generate UV-mappings for the [`CircularSector`] and [`CircularSegment`] shapes. +/// +/// Currently the only variant is `Mask`, which is good for showing a portion of a texture that includes +/// the entire circle, particularly the same texture will be displayed with different fractions of a +/// complete circle. +/// +/// It's expected that more will be added in the future, such as a variant that causes the texture to be +/// scaled to fit the bounding box of the shape, which would be good for packed textures only including the +/// portion of the circle that is needed to display. +#[derive(Copy, Clone, Debug, PartialEq)] +#[non_exhaustive] +pub enum CircularMeshUvMode { + /// Treats the shape as a mask over a circle of equal size and radius, + /// with the center of the circle at the center of the texture. + Mask { + /// Angle by which to rotate the shape when generating the UV map. + angle: f32, + }, +} + +impl Default for CircularMeshUvMode { + fn default() -> Self { + CircularMeshUvMode::Mask { angle: 0.0 } + } +} + +/// A builder used for creating a [`Mesh`] with a [`CircularSector`] shape. +/// +/// The resulting mesh will have a UV-map such that the center of the circle is +/// at the center of the texture. +#[derive(Clone, Debug)] +pub struct CircularSectorMeshBuilder { + /// The sector shape. + pub sector: CircularSector, + /// The number of vertices used for the arc portion of the sector mesh. + /// The default is `32`. + #[doc(alias = "vertices")] + pub resolution: usize, + /// The UV mapping mode + pub uv_mode: CircularMeshUvMode, +} + +impl Default for CircularSectorMeshBuilder { + fn default() -> Self { + Self { + sector: CircularSector::default(), + resolution: 32, + uv_mode: CircularMeshUvMode::default(), + } + } +} + +impl CircularSectorMeshBuilder { + /// Creates a new [`CircularSectorMeshBuilder`] from a given sector + #[inline] + pub fn new(sector: CircularSector) -> Self { + Self { + sector, + ..Self::default() + } + } + + /// Sets the number of vertices used for the sector mesh. + #[inline] + #[doc(alias = "vertices")] + pub const fn resolution(mut self, resolution: usize) -> Self { + self.resolution = resolution; + self + } + + /// Sets the uv mode used for the sector mesh + #[inline] + pub const fn uv_mode(mut self, uv_mode: CircularMeshUvMode) -> Self { + self.uv_mode = uv_mode; + self + } + + /// Builds a [`Mesh`] based on the configuration in `self`. + pub fn build(&self) -> Mesh { + let mut indices = Vec::with_capacity((self.resolution - 1) * 3); + let mut positions = Vec::with_capacity(self.resolution + 1); + let normals = vec![[0.0, 0.0, 1.0]; self.resolution + 1]; + let mut uvs = Vec::with_capacity(self.resolution + 1); + + let CircularMeshUvMode::Mask { angle: uv_angle } = self.uv_mode; + + // Push the center of the circle. + positions.push([0.0; 3]); + uvs.push([0.5; 2]); + + let first_angle = FRAC_PI_2 - self.sector.half_angle(); + let last_angle = FRAC_PI_2 + self.sector.half_angle(); + let last_i = (self.resolution - 1) as f32; + for i in 0..self.resolution { + let angle = f32::lerp(first_angle, last_angle, i as f32 / last_i); + + // Compute the vertex + let vertex = self.sector.radius() * Vec2::from_angle(angle); + // Compute the UV coordinate by taking the modified angle's unit vector, negating the Y axis, and rescaling and centering it at (0.5, 0.5). + // We accomplish the Y axis flip by negating the angle. + let uv = + Vec2::from_angle(-(angle + uv_angle)).mul_add(Vec2::splat(0.5), Vec2::splat(0.5)); + + positions.push([vertex.x, vertex.y, 0.0]); + uvs.push([uv.x, uv.y]); + } + + for i in 1..(self.resolution as u32) { + // Index 0 is the center. + indices.extend_from_slice(&[0, i, i + 1]); + } + + Mesh::new( + PrimitiveTopology::TriangleList, + RenderAssetUsages::default(), + ) + .with_inserted_attribute(Mesh::ATTRIBUTE_POSITION, positions) + .with_inserted_attribute(Mesh::ATTRIBUTE_NORMAL, normals) + .with_inserted_attribute(Mesh::ATTRIBUTE_UV_0, uvs) + .with_inserted_indices(Indices::U32(indices)) + } +} + +impl Meshable for CircularSector { + type Output = CircularSectorMeshBuilder; + + fn mesh(&self) -> Self::Output { + CircularSectorMeshBuilder { + sector: *self, + ..Default::default() + } + } +} + +impl From for Mesh { + /// Converts this sector into a [`Mesh`] using a default [`CircularSectorMeshBuilder`]. + /// + /// See the documentation of [`CircularSectorMeshBuilder`] for more details. + fn from(sector: CircularSector) -> Self { + sector.mesh().build() + } +} + +impl From for Mesh { + fn from(sector: CircularSectorMeshBuilder) -> Self { + sector.build() + } +} + +/// A builder used for creating a [`Mesh`] with a [`CircularSegment`] shape. +/// +/// The resulting mesh will have a UV-map such that the center of the circle is +/// at the center of the texture. +#[derive(Clone, Copy, Debug)] +pub struct CircularSegmentMeshBuilder { + /// The segment shape. + pub segment: CircularSegment, + /// The number of vertices used for the arc portion of the segment mesh. + /// The default is `32`. + #[doc(alias = "vertices")] + pub resolution: usize, + /// The UV mapping mode + pub uv_mode: CircularMeshUvMode, +} + +impl Default for CircularSegmentMeshBuilder { + fn default() -> Self { + Self { + segment: CircularSegment::default(), + resolution: 32, + uv_mode: CircularMeshUvMode::default(), + } + } +} + +impl CircularSegmentMeshBuilder { + /// Creates a new [`CircularSegmentMeshBuilder`] from a given segment + #[inline] + pub fn new(segment: CircularSegment) -> Self { + Self { + segment, + ..Self::default() + } + } + + /// Sets the number of vertices used for the segment mesh. + #[inline] + #[doc(alias = "vertices")] + pub const fn resolution(mut self, resolution: usize) -> Self { + self.resolution = resolution; + self + } + + /// Sets the uv mode used for the segment mesh + #[inline] + pub const fn uv_mode(mut self, uv_mode: CircularMeshUvMode) -> Self { + self.uv_mode = uv_mode; + self + } + + /// Builds a [`Mesh`] based on the configuration in `self`. + pub fn build(&self) -> Mesh { + let mut indices = Vec::with_capacity((self.resolution - 1) * 3); + let mut positions = Vec::with_capacity(self.resolution + 1); + let normals = vec![[0.0, 0.0, 1.0]; self.resolution + 1]; + let mut uvs = Vec::with_capacity(self.resolution + 1); + + let CircularMeshUvMode::Mask { angle: uv_angle } = self.uv_mode; + + // Push the center of the chord. + let midpoint_vertex = self.segment.chord_midpoint(); + positions.push([midpoint_vertex.x, midpoint_vertex.y, 0.0]); + // Compute the UV coordinate of the midpoint vertex. + // This is similar to the computation inside the loop for the arc vertices, + // but the vertex angle is PI/2, and we must scale by the ratio of the apothem to the radius + // to correctly position the vertex. + let midpoint_uv = Vec2::from_angle(-uv_angle - FRAC_PI_2).mul_add( + Vec2::splat(0.5 * (self.segment.apothem() / self.segment.radius())), + Vec2::splat(0.5), + ); + uvs.push([midpoint_uv.x, midpoint_uv.y]); + + let first_angle = FRAC_PI_2 - self.segment.half_angle(); + let last_angle = FRAC_PI_2 + self.segment.half_angle(); + let last_i = (self.resolution - 1) as f32; + for i in 0..self.resolution { + let angle = f32::lerp(first_angle, last_angle, i as f32 / last_i); + + // Compute the vertex + let vertex = self.segment.radius() * Vec2::from_angle(angle); + // Compute the UV coordinate by taking the modified angle's unit vector, negating the Y axis, and rescaling and centering it at (0.5, 0.5). + // We accomplish the Y axis flip by negating the angle. + let uv = + Vec2::from_angle(-(angle + uv_angle)).mul_add(Vec2::splat(0.5), Vec2::splat(0.5)); + + positions.push([vertex.x, vertex.y, 0.0]); + uvs.push([uv.x, uv.y]); + } + + for i in 1..(self.resolution as u32) { + // Index 0 is the midpoint of the chord. + indices.extend_from_slice(&[0, i, i + 1]); + } + + Mesh::new( + PrimitiveTopology::TriangleList, + RenderAssetUsages::default(), + ) + .with_inserted_attribute(Mesh::ATTRIBUTE_POSITION, positions) + .with_inserted_attribute(Mesh::ATTRIBUTE_NORMAL, normals) + .with_inserted_attribute(Mesh::ATTRIBUTE_UV_0, uvs) + .with_inserted_indices(Indices::U32(indices)) + } +} + +impl Meshable for CircularSegment { + type Output = CircularSegmentMeshBuilder; + + fn mesh(&self) -> Self::Output { + CircularSegmentMeshBuilder { + segment: *self, + ..Default::default() + } + } +} + +impl From for Mesh { + /// Converts this sector into a [`Mesh`] using a default [`CircularSegmentMeshBuilder`]. + /// + /// See the documentation of [`CircularSegmentMeshBuilder`] for more details. + fn from(segment: CircularSegment) -> Self { + segment.mesh().build() + } +} + +impl From for Mesh { + fn from(sector: CircularSegmentMeshBuilder) -> Self { + sector.build() + } +} + impl Meshable for RegularPolygon { type Output = Mesh; diff --git a/crates/bevy_render/src/mesh/primitives/mod.rs b/crates/bevy_render/src/mesh/primitives/mod.rs index 48ede8e66382a..c179f5b982594 100644 --- a/crates/bevy_render/src/mesh/primitives/mod.rs +++ b/crates/bevy_render/src/mesh/primitives/mod.rs @@ -20,7 +20,7 @@ //! ``` mod dim2; -pub use dim2::{CircleMeshBuilder, EllipseMeshBuilder}; +pub use dim2::*; mod dim3; pub use dim3::*; diff --git a/examples/2d/2d_shapes.rs b/examples/2d/2d_shapes.rs index efbf070dcfe9d..e76cc5a428a28 100644 --- a/examples/2d/2d_shapes.rs +++ b/examples/2d/2d_shapes.rs @@ -12,7 +12,7 @@ fn main() { .run(); } -const X_EXTENT: f32 = 600.; +const X_EXTENT: f32 = 800.; fn setup( mut commands: Commands, @@ -23,6 +23,8 @@ fn setup( let shapes = [ Mesh2dHandle(meshes.add(Circle { radius: 50.0 })), + Mesh2dHandle(meshes.add(CircularSector::new(50.0, 1.0))), + Mesh2dHandle(meshes.add(CircularSegment::new(50.0, 1.25))), 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))), diff --git a/examples/2d/mesh2d_arcs.rs b/examples/2d/mesh2d_arcs.rs new file mode 100644 index 0000000000000..f15421d449fc2 --- /dev/null +++ b/examples/2d/mesh2d_arcs.rs @@ -0,0 +1,124 @@ +//! Demonstrates UV mappings of the [`CircularSector`] and [`CircularSegment`] primitives. +//! +//! Also draws the bounding boxes and circles of the primitives. +use std::f32::consts::FRAC_PI_2; + +use bevy::{ + color::palettes::css::{BLUE, DARK_SLATE_GREY, RED}, + math::bounding::{Bounded2d, BoundingVolume}, + prelude::*, + render::mesh::{CircularMeshUvMode, CircularSectorMeshBuilder, CircularSegmentMeshBuilder}, + sprite::MaterialMesh2dBundle, +}; + +fn main() { + App::new() + .add_plugins(DefaultPlugins) + .add_systems(Startup, setup) + .add_systems( + Update, + ( + draw_bounds::, + draw_bounds::, + ), + ) + .run(); +} + +#[derive(Component, Debug)] +struct DrawBounds(Shape); + +fn setup( + mut commands: Commands, + asset_server: Res, + mut meshes: ResMut>, + mut materials: ResMut>, +) { + let material = materials.add(asset_server.load("branding/icon.png")); + + commands.spawn(Camera2dBundle { + camera: Camera { + clear_color: ClearColorConfig::Custom(DARK_SLATE_GREY.into()), + ..default() + }, + ..default() + }); + + const UPPER_Y: f32 = 50.0; + const LOWER_Y: f32 = -50.0; + const FIRST_X: f32 = -450.0; + const OFFSET: f32 = 100.0; + const NUM_SLICES: i32 = 8; + + // This draws NUM_SLICES copies of the Bevy logo as circular sectors and segments, + // with successively larger angles up to a complete circle. + for i in 0..NUM_SLICES { + let fraction = (i + 1) as f32 / NUM_SLICES as f32; + + let sector = CircularSector::from_turns(40.0, fraction); + // We want to rotate the circular sector so that the sectors appear clockwise from north. + // We must rotate it both in the Transform and in the mesh's UV mappings. + let sector_angle = -sector.half_angle(); + let sector_mesh = + CircularSectorMeshBuilder::new(sector).uv_mode(CircularMeshUvMode::Mask { + angle: sector_angle, + }); + commands.spawn(( + MaterialMesh2dBundle { + mesh: meshes.add(sector_mesh).into(), + material: material.clone(), + transform: Transform { + translation: Vec3::new(FIRST_X + OFFSET * i as f32, 2.0 * UPPER_Y, 0.0), + rotation: Quat::from_rotation_z(sector_angle), + ..default() + }, + ..default() + }, + DrawBounds(sector), + )); + + let segment = CircularSegment::from_turns(40.0, fraction); + // For the circular segment, we will draw Bevy charging forward, which requires rotating the + // shape and texture by 90 degrees. + // + // Note that this may be unintuitive; it may feel like we should rotate the texture by the + // opposite angle to preserve the orientation of Bevy. But the angle is not the angle of the + // texture itself, rather it is the angle at which the vertices are mapped onto the texture. + // so it is the negative of what you might otherwise expect. + let segment_angle = -FRAC_PI_2; + let segment_mesh = + CircularSegmentMeshBuilder::new(segment).uv_mode(CircularMeshUvMode::Mask { + angle: -segment_angle, + }); + commands.spawn(( + MaterialMesh2dBundle { + mesh: meshes.add(segment_mesh).into(), + material: material.clone(), + transform: Transform { + translation: Vec3::new(FIRST_X + OFFSET * i as f32, LOWER_Y, 0.0), + rotation: Quat::from_rotation_z(segment_angle), + ..default() + }, + ..default() + }, + DrawBounds(segment), + )); + } +} + +fn draw_bounds( + q: Query<(&DrawBounds, &GlobalTransform)>, + mut gizmos: Gizmos, +) { + for (shape, transform) in &q { + let (_, rotation, translation) = transform.to_scale_rotation_translation(); + let translation = translation.truncate(); + let rotation = rotation.to_euler(EulerRot::XYZ).2; + + let aabb = shape.0.aabb_2d(translation, rotation); + gizmos.rect_2d(aabb.center(), 0.0, aabb.half_size() * 2.0, RED); + + let bounding_circle = shape.0.bounding_circle(translation, rotation); + gizmos.circle_2d(bounding_circle.center, bounding_circle.radius(), BLUE); + } +} diff --git a/examples/README.md b/examples/README.md index c30f0f2333754..4bc8aadf1305e 100644 --- a/examples/README.md +++ b/examples/README.md @@ -102,6 +102,7 @@ Example | Description [2D Shapes](../examples/2d/2d_shapes.rs) | Renders simple 2D primitive shapes like circles and polygons [2D Viewport To World](../examples/2d/2d_viewport_to_world.rs) | Demonstrates how to use the `Camera::viewport_to_world_2d` method [2D Wireframe](../examples/2d/wireframe_2d.rs) | Showcases wireframes for 2d meshes +[Arc 2D Meshes](../examples/2d/mesh2d_arcs.rs) | Demonstrates UV-mapping of the circular segment and sector primitives [Custom glTF vertex attribute 2D](../examples/2d/custom_gltf_vertex_attribute.rs) | Renders a glTF mesh in 2D with a custom vertex attribute [Manual Mesh 2D](../examples/2d/mesh2d_manual.rs) | Renders a custom mesh "manually" with "mid-level" renderer apis [Mesh 2D](../examples/2d/mesh2d.rs) | Renders a 2d mesh