Skip to content

Commit 0b3a340

Browse files
authored
Ring shape primitive (#21446)
# Objective This introduces a generalised 2d `Ring` shape for any underlying primitive (i.e. what an `Annulus` is to a `Circle`). This allows us to have "hollow" shapes or "outlines". `Ring` is also extrudable. It is assumed that the inner and outer meshes have the same number of vertices. ```rs let capsule_ring = Ring::new(Capsule2d::new(50.0, 100.0), Capsule2d::new(45.0, 100.0)); let hexagon_ring = Ring::new(RegularPolygon::new(50.0, 6), RegularPolygon::new(45.0, 6)); // note vertex count must match ``` ## Solution There is a new generic primitive `Ring`, which takes as input any `Primitive2d`, with two instances of that shape: the outer and the inner (or hollow). The mesh for a `RingMeshBuilder` is constructed by concatenating the vertices of the outer and inner meshes, then walking the perimeter to join corresponding vertices like so: <img width="513" height="509" alt="image" src="https://github.com/user-attachments/assets/2cecb458-3b59-44fb-858b-1beffecd1e57" /> ``` # outer vertices, then inner vertices positions = [ 0 1 2 3 4 0' 1' 2' 3' 4' ] # pairs of triangles indices = [ 0 1 0' 0' 1 1' 1 2 1' 1' 2 2' 2 3 2' 2' 3 3' 3 4 3' 3' 4 4' 4 0 4' 4' 0 0' ] ``` Examples of generated meshes: <img width="398" height="351" alt="image" src="https://github.com/user-attachments/assets/348bbd91-9f4e-4040-bfa5-d508a4308c10" /> <img width="472" height="376" alt="image" src="https://github.com/user-attachments/assets/dbaf894e-6f7f-4b79-af3e-69516da85898" /> <img width="388" height="357" alt="image" src="https://github.com/user-attachments/assets/cb9881e5-4518-4743-b8de-5816b632f36f" /> <img width="449" height="402" alt="image" src="https://github.com/user-attachments/assets/7d2022c9-b8cf-4b4b-bb09-cbe4fe49fb89" /> ## Testing I've tested these changes by updating the `2d_shapes`, `3d_shapes` and `custom_primitives` examples. It could potentially benefit from unit tests. --- ## Showcase <img width="1282" height="752" alt="image" src="https://github.com/user-attachments/assets/edab9dbf-1093-43c7-9804-8e5c8a830573" /> _Rings of 2d primitives (bottom row)_ <img width="1282" height="752" alt="image" src="https://github.com/user-attachments/assets/fbeed7f9-42bb-432c-bce9-cfeca87d70af" /> _Extrusions of rings of extrudable primitives (back row)_ --- ## Follow-up work I've only realised this from looking at Extrudable, but because I used the mesh positions but it does assume the positions are well-ordered around the perimeter. Extrudable instead uses the notion of a perimeter (via indices so it doesn't matter what order the mesh positions are in), a follow-up may be to do something similar for Ring. An alternative idea may be to compute the perimeter first as directly a list of Vec2 positions (maybe a Perimeter trait), then construct any needed meshes from that. This potentially makes `Annulus` redundant as it is equivalent to a `Ring<Circle>`. One thing of note is that `Extrusion<Annulus>` is textured differently from `Extrusion<Ring<Circle>>`. Another idea is to have a way to construct `PrimitiveTopology::LineList` meshes from Primitive shapes (which may have a similar effect as creating a Ring).
1 parent b2759ed commit 0b3a340

File tree

11 files changed

+915
-61
lines changed

11 files changed

+915
-61
lines changed

crates/bevy_math/src/bounding/bounded2d/primitive_impls.rs

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ use crate::{
55
ops,
66
primitives::{
77
Annulus, Arc2d, Capsule2d, Circle, CircularSector, CircularSegment, Ellipse, Line2d,
8-
Plane2d, Rectangle, RegularPolygon, Rhombus, Segment2d, Triangle2d,
8+
Plane2d, Primitive2d, Rectangle, RegularPolygon, Rhombus, Ring, Segment2d, Triangle2d,
99
},
1010
Dir2, Isometry2d, Mat2, Rot2, Vec2,
1111
};
@@ -428,6 +428,16 @@ impl Bounded2d for Capsule2d {
428428
}
429429
}
430430

431+
impl<P: Bounded2d + Primitive2d> Bounded2d for Ring<P> {
432+
fn aabb_2d(&self, isometry: impl Into<Isometry2d>) -> Aabb2d {
433+
self.outer_shape.aabb_2d(isometry)
434+
}
435+
436+
fn bounding_circle(&self, isometry: impl Into<Isometry2d>) -> BoundingCircle {
437+
self.outer_shape.bounding_circle(isometry)
438+
}
439+
}
440+
431441
#[cfg(test)]
432442
#[expect(clippy::print_stdout, reason = "Allowed in tests.")]
433443
mod tests {

crates/bevy_math/src/bounding/bounded3d/extrusion.rs

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ use crate::{
77
ops,
88
primitives::{
99
Capsule2d, Cuboid, Cylinder, Ellipse, Extrusion, Line2d, Primitive2d, Rectangle,
10-
RegularPolygon, Segment2d, Triangle2d,
10+
RegularPolygon, Ring, Segment2d, Triangle2d,
1111
},
1212
Isometry2d, Isometry3d, Quat, Rot2,
1313
};
@@ -165,6 +165,21 @@ impl BoundedExtrusion for Capsule2d {
165165
}
166166
}
167167

168+
impl<T: BoundedExtrusion> BoundedExtrusion for Ring<T> {
169+
fn extrusion_aabb_3d(&self, half_depth: f32, isometry: impl Into<Isometry3d>) -> Aabb3d {
170+
self.outer_shape.extrusion_aabb_3d(half_depth, isometry)
171+
}
172+
173+
fn extrusion_bounding_sphere(
174+
&self,
175+
half_depth: f32,
176+
isometry: impl Into<Isometry3d>,
177+
) -> BoundingSphere {
178+
self.outer_shape
179+
.extrusion_bounding_sphere(half_depth, isometry)
180+
}
181+
}
182+
168183
impl<T: BoundedExtrusion> Bounded3d for Extrusion<T> {
169184
fn aabb_3d(&self, isometry: impl Into<Isometry3d>) -> Aabb3d {
170185
self.base_shape.extrusion_aabb_3d(self.half_depth, isometry)

crates/bevy_math/src/primitives/dim2.rs

Lines changed: 72 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ use thiserror::Error;
66
use super::{Measured2d, Primitive2d, WindingOrder};
77
use crate::{
88
ops::{self, FloatPow},
9+
primitives::Inset,
910
Dir2, InvalidDirectionError, Isometry2d, Ray2d, Rot2, Vec2,
1011
};
1112

@@ -468,11 +469,11 @@ impl Measured2d for CircularSegment {
468469
}
469470

470471
impl CircularSegment {
471-
/// Create a new [`CircularSegment`] from a `radius`, and an `angle`
472+
/// Create a new [`CircularSegment`] from a `radius`, and a `half_angle` in radians.
472473
#[inline]
473-
pub const fn new(radius: f32, angle: f32) -> Self {
474+
pub const fn new(radius: f32, half_angle: f32) -> Self {
474475
Self {
475-
arc: Arc2d::new(radius, angle),
476+
arc: Arc2d::new(radius, half_angle),
476477
}
477478
}
478479

@@ -793,6 +794,9 @@ mod arc_tests {
793794
}
794795

795796
/// An ellipse primitive, which is like a circle, but the width and height can be different
797+
///
798+
/// Ellipse does not implement [`Inset`] as concentric ellipses do not have parallel curves:
799+
/// if the ellipse is not a circle, the inset shape is not actually an ellipse (although it may look like one) but can also be a lens-like shape.
796800
#[derive(Clone, Copy, Debug, PartialEq)]
797801
#[cfg_attr(feature = "serialize", derive(serde::Serialize, serde::Deserialize))]
798802
#[cfg_attr(
@@ -2234,6 +2238,71 @@ impl Measured2d for Capsule2d {
22342238
}
22352239
}
22362240

2241+
/// A 2D shape representing the ring version of a base shape.
2242+
///
2243+
/// The `inner_shape` forms the "hollow" of the `outer_shape`.
2244+
///
2245+
/// The resulting shapes are rings or hollow shapes.
2246+
/// For example, a circle becomes an annulus.
2247+
///
2248+
/// # Warning
2249+
///
2250+
/// The `outer_shape` must contain the `inner_shape` for the generated meshes to be accurate.
2251+
///
2252+
/// If there are vertices in the `inner_shape` that escape the `outer_shape`
2253+
/// (for example, if the `inner_shape` is in fact larger),
2254+
/// it may result in incorrect geometries.
2255+
#[derive(Clone, Copy, Debug, PartialEq)]
2256+
#[cfg_attr(feature = "serialize", derive(serde::Serialize, serde::Deserialize))]
2257+
pub struct Ring<P: Primitive2d> {
2258+
/// The outer shape
2259+
pub outer_shape: P,
2260+
/// The inner shape (the same shape of a different size)
2261+
pub inner_shape: P,
2262+
}
2263+
2264+
impl<P: Primitive2d> Ring<P> {
2265+
/// Create a new `Ring` from a given `outer_shape` and `inner_shape`.
2266+
///
2267+
/// If the primitive implements [`Inset`] and you would like a uniform thickness, consider using [`ToRing::to_ring`]
2268+
pub const fn new(outer_shape: P, inner_shape: P) -> Self {
2269+
Self {
2270+
outer_shape,
2271+
inner_shape,
2272+
}
2273+
}
2274+
}
2275+
2276+
impl<T: Primitive2d> Primitive2d for Ring<T> {}
2277+
2278+
impl<P: Primitive2d + Clone + Inset> Ring<P> {
2279+
/// Generate a `Ring` from a given `primitive` and a `thickness`.
2280+
pub fn from_primitive_and_thickness(primitive: P, thickness: f32) -> Self {
2281+
let hollow = primitive.clone().inset(thickness);
2282+
Ring::new(primitive, hollow)
2283+
}
2284+
}
2285+
2286+
/// Provides a convenience method for converting a primitive to a [`Ring`], with a given thickness.
2287+
///
2288+
/// The primitive must implement [`Inset`].
2289+
pub trait ToRing: Primitive2d + Inset
2290+
where
2291+
Self: Sized,
2292+
{
2293+
/// Construct a `Ring`
2294+
fn to_ring(self, thickness: f32) -> Ring<Self>;
2295+
}
2296+
2297+
impl<P> ToRing for P
2298+
where
2299+
P: Primitive2d + Clone + Inset,
2300+
{
2301+
fn to_ring(self, thickness: f32) -> Ring<Self> {
2302+
Ring::from_primitive_and_thickness(self, thickness)
2303+
}
2304+
}
2305+
22372306
#[cfg(test)]
22382307
mod tests {
22392308
// Reference values were computed by hand and/or with external tools
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
use crate::{
2+
ops,
3+
primitives::{
4+
Capsule2d, Circle, CircularSegment, Primitive2d, Rectangle, RegularPolygon, Rhombus,
5+
Triangle2d,
6+
},
7+
Vec2,
8+
};
9+
10+
/// A primitive that can be resized uniformly.
11+
///
12+
/// See documentation on [`Inset::inset`].
13+
///
14+
/// See also [`ToRing`](crate::primitives::ToRing).
15+
pub trait Inset: Primitive2d {
16+
/// Create a new version of this primitive that is resized uniformly.
17+
/// That is, it resizes the shape inwards such that for the lines between vertices,
18+
/// it creates new parallel lines that are `distance` inwards from the original lines.
19+
///
20+
/// This is useful for creating smaller shapes or making outlines of `distance` thickness with [`Ring`](crate::primitives::Ring).
21+
///
22+
/// See also [`ToRing::to_ring`](crate::primitives::ToRing::to_ring)
23+
fn inset(self, distance: f32) -> Self;
24+
}
25+
26+
impl Inset for Circle {
27+
fn inset(mut self, distance: f32) -> Self {
28+
self.radius -= distance;
29+
self
30+
}
31+
}
32+
33+
impl Inset for Triangle2d {
34+
fn inset(self, distance: f32) -> Self {
35+
fn find_inset_point(a: Vec2, b: Vec2, c: Vec2, distance: f32) -> Vec2 {
36+
let unit_vector_ab = (b - a).normalize();
37+
let unit_vector_ac = (c - a).normalize();
38+
let half_angle_bac = unit_vector_ab.angle_to(unit_vector_ac) / 2.0;
39+
let mean = (unit_vector_ab + unit_vector_ac) / 2.0;
40+
let direction = mean.normalize();
41+
let magnitude = distance / ops::sin(half_angle_bac);
42+
a + direction * magnitude
43+
}
44+
45+
let [a, b, c] = self.vertices;
46+
47+
let new_a = find_inset_point(a, b, c, distance);
48+
let new_b = find_inset_point(b, c, a, distance);
49+
let new_c = find_inset_point(c, a, b, distance);
50+
51+
Self::new(new_a, new_b, new_c)
52+
}
53+
}
54+
55+
impl Inset for Rhombus {
56+
fn inset(mut self, distance: f32) -> Self {
57+
let [half_width, half_height] = self.half_diagonals.into();
58+
let angle = ops::atan(half_height / half_width);
59+
let x_offset = distance / ops::sin(angle);
60+
let y_offset = distance / ops::cos(angle);
61+
self.half_diagonals -= Vec2::new(x_offset, y_offset);
62+
self
63+
}
64+
}
65+
66+
impl Inset for Capsule2d {
67+
fn inset(mut self, distance: f32) -> Self {
68+
self.radius -= distance;
69+
self
70+
}
71+
}
72+
73+
impl Inset for Rectangle {
74+
fn inset(mut self, distance: f32) -> Self {
75+
self.half_size -= Vec2::splat(distance);
76+
self
77+
}
78+
}
79+
80+
impl Inset for CircularSegment {
81+
fn inset(self, distance: f32) -> Self {
82+
let old_arc = self.arc;
83+
let radius = old_arc.radius - distance;
84+
let apothem = old_arc.apothem() + distance;
85+
// https://en.wikipedia.org/wiki/Circular_segment
86+
let half_angle = ops::acos(apothem / radius);
87+
Self::new(radius, half_angle)
88+
}
89+
}
90+
91+
impl Inset for RegularPolygon {
92+
fn inset(mut self, distance: f32) -> Self {
93+
let half_angle = self.internal_angle_radians() / 2.0;
94+
let offset = distance / ops::sin(half_angle);
95+
self.circumcircle.radius -= offset;
96+
self
97+
}
98+
}

crates/bevy_math/src/primitives/mod.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ mod dim2;
66
pub use dim2::*;
77
mod dim3;
88
pub use dim3::*;
9+
mod inset;
10+
pub use inset::*;
911
mod polygon;
1012

1113
/// A marker trait for 2D primitives

0 commit comments

Comments
 (0)