From d99a26456e7cf31fe3d1977ab87926f38fb5dc47 Mon Sep 17 00:00:00 2001 From: Johan Klokkhammer Helsing Date: Sun, 19 Oct 2025 16:25:47 +0200 Subject: [PATCH 1/9] feat: Use bounds for easily resizable shapes --- assets/shapes/annulus.wgsl | 10 +- assets/shapes/capsule.wgsl | 10 +- assets/shapes/circle.wgsl | 7 +- assets/shapes/circular_sector.wgsl | 13 +-- assets/shapes/ellipse.wgsl | 9 +- assets/shapes/rectangle.wgsl | 6 +- assets/shapes/regular_polygon.wgsl | 11 ++- assets/shapes/rhombus.wgsl | 7 +- examples/bevy_primitives.rs | 123 ++++++++++++++---------- src/bevy_primitives.rs | 145 +++++++++++++++++++++++------ src/components.rs | 2 +- src/sdf.rs | 68 +++++++++++++- 12 files changed, 299 insertions(+), 112 deletions(-) diff --git a/assets/shapes/annulus.wgsl b/assets/shapes/annulus.wgsl index a20147c..d0fb596 100644 --- a/assets/shapes/annulus.wgsl +++ b/assets/shapes/annulus.wgsl @@ -2,9 +2,11 @@ #import smud -// Parametrized annulus (ring) SDF -// params.x contains the outer radius -// params.y contains the inner radius +// Annulus (ring) SDF using bounds for outer radius +// Outer radius is computed as min(bounds.x, bounds.y) +// params.x contains the inner radius fn sdf(input: smud::SdfInput) -> f32 { - return smud::sd_annulus(input.pos, input.params.x, input.params.y); + let outer_radius = min(input.bounds.x, input.bounds.y); + let inner_radius = input.params.x; + return smud::sd_annulus(input.pos, outer_radius, inner_radius); } diff --git a/assets/shapes/capsule.wgsl b/assets/shapes/capsule.wgsl index 77fa928..f4b526e 100644 --- a/assets/shapes/capsule.wgsl +++ b/assets/shapes/capsule.wgsl @@ -2,9 +2,11 @@ #import smud -// Parametrized capsule SDF -// params.x contains the radius -// params.y contains the half_length +// Capsule SDF using bounds +// Radius is min(bounds.x, bounds.y) to ensure half_length remains positive +// Half-length of the line segment is bounds.y - radius fn sdf(input: smud::SdfInput) -> f32 { - return smud::sd_capsule(input.pos, input.params.x, input.params.y); + let radius = min(input.bounds.x, input.bounds.y); + let half_length = input.bounds.y - radius; + return smud::sd_capsule(input.pos, radius, half_length); } diff --git a/assets/shapes/circle.wgsl b/assets/shapes/circle.wgsl index 7c0b39c..e9f5ade 100644 --- a/assets/shapes/circle.wgsl +++ b/assets/shapes/circle.wgsl @@ -2,8 +2,9 @@ #import smud -// Parametrized circle SDF -// params.x contains the radius +// Circle SDF using bounds +// Radius is computed as min(bounds.x, bounds.y) fn sdf(input: smud::SdfInput) -> f32 { - return smud::sd_circle(input.pos, input.params.x); + let radius = min(input.bounds.x, input.bounds.y); + return smud::sd_circle(input.pos, radius); } diff --git a/assets/shapes/circular_sector.wgsl b/assets/shapes/circular_sector.wgsl index 3910f7b..a382ffd 100644 --- a/assets/shapes/circular_sector.wgsl +++ b/assets/shapes/circular_sector.wgsl @@ -2,11 +2,12 @@ #import smud -// Parametrized circular sector (pie slice) SDF -// params.x contains the radius -// params.y contains sin(half_angle) -// params.z contains cos(half_angle) +// Circular sector (pie slice) SDF using bounds for radius +// Radius is computed as min(bounds.x, bounds.y) +// params.x contains sin(half_angle) +// params.y contains cos(half_angle) fn sdf(input: smud::SdfInput) -> f32 { - let c = vec2(input.params.y, input.params.z); - return smud::sd_pie(input.pos, c, input.params.x); + let radius = min(input.bounds.x, input.bounds.y); + let c = vec2(input.params.x, input.params.y); + return smud::sd_pie(input.pos, c, radius); } diff --git a/assets/shapes/ellipse.wgsl b/assets/shapes/ellipse.wgsl index cdd3f60..fbcbbef 100644 --- a/assets/shapes/ellipse.wgsl +++ b/assets/shapes/ellipse.wgsl @@ -2,13 +2,12 @@ #import smud -// Parametrized ellipse SDF -// params.x contains the semi-major axis (half_size.x) -// params.y contains the semi-minor axis (half_size.y) +// Ellipse SDF using bounds +// Uses input.bounds for the semi-major and semi-minor axes (half_size.x, half_size.y) // If the axes are equal (or nearly equal), delegates to circle SDF since sd_ellipse doesn't handle that case fn sdf(input: smud::SdfInput) -> f32 { - let a = input.params.x; - let b = input.params.y; + let a = input.bounds.x; + let b = input.bounds.y; // Use circle SDF when radii are equal (sd_ellipse doesn't handle this case well) if (abs(a - b) < 1e-6) { diff --git a/assets/shapes/rectangle.wgsl b/assets/shapes/rectangle.wgsl index d338b79..4948dc0 100644 --- a/assets/shapes/rectangle.wgsl +++ b/assets/shapes/rectangle.wgsl @@ -2,8 +2,8 @@ #import smud -// Parametrized rectangle SDF -// params.xy contains the half-size of the rectangle +// Rectangle SDF using bounds +// Uses input.bounds for the half-size of the rectangle fn sdf(input: smud::SdfInput) -> f32 { - return smud::sd_box(input.pos, input.params.xy); + return smud::sd_box(input.pos, input.bounds); } diff --git a/assets/shapes/regular_polygon.wgsl b/assets/shapes/regular_polygon.wgsl index d84933b..795be97 100644 --- a/assets/shapes/regular_polygon.wgsl +++ b/assets/shapes/regular_polygon.wgsl @@ -2,10 +2,11 @@ #import smud -// Parametrized regular polygon SDF -// params.x contains the radius (circumradius) -// params.y contains the number of sides (as float, should be cast to int) +// Regular polygon SDF using bounds for radius +// Radius is computed as min(bounds.x, bounds.y) +// params.x contains the number of sides (as float, should be cast to int) fn sdf(input: smud::SdfInput) -> f32 { - let sides = i32(input.params.y); - return smud::sd_regular_polygon(input.pos, input.params.x, sides); + let radius = min(input.bounds.x, input.bounds.y); + let sides = i32(input.params.x); + return smud::sd_regular_polygon(input.pos, radius, sides); } diff --git a/assets/shapes/rhombus.wgsl b/assets/shapes/rhombus.wgsl index 1522cc9..ebc878d 100644 --- a/assets/shapes/rhombus.wgsl +++ b/assets/shapes/rhombus.wgsl @@ -2,9 +2,8 @@ #import smud -// Parametrized rhombus SDF -// params.x contains half of the horizontal diagonal -// params.y contains half of the vertical diagonal +// Rhombus SDF using bounds +// Uses input.bounds for the half-diagonals fn sdf(input: smud::SdfInput) -> f32 { - return smud::sd_rhombus(input.pos, vec2(input.params.x, input.params.y)); + return smud::sd_rhombus(input.pos, input.bounds); } diff --git a/examples/bevy_primitives.rs b/examples/bevy_primitives.rs index 73f2e18..efe869e 100644 --- a/examples/bevy_primitives.rs +++ b/examples/bevy_primitives.rs @@ -11,98 +11,127 @@ fn main() { } fn setup(mut commands: Commands) { - // Spawn a camera commands.spawn((Camera2d, Msaa::Off)); + // Row 1: Default bounds (each shape uses its natural size) commands.spawn(( - Transform::from_translation(Vec3::new(-200., 100., 0.)), - SmudShape::from(Rectangle::new(100., 50.)).with_color(css::TOMATO), + Transform::from_translation(Vec3::new(-420., 150., 0.)), + SmudShape::from(Rectangle::new(50., 50.)).with_color(css::TOMATO), OriginalColor(css::TOMATO.into()), )); commands.spawn(( - Transform::from_translation(Vec3::new(100., 100., 0.)), - SmudShape::from(Circle::new(40.)).with_color(css::CORNFLOWER_BLUE), + Transform::from_translation(Vec3::new(-300., 150., 0.)), + SmudShape::from(Circle::new(25.)).with_color(css::CORNFLOWER_BLUE), OriginalColor(css::CORNFLOWER_BLUE.into()), )); - // Using struct initialization with spread operator commands.spawn(( - Transform::from_translation(Vec3::new(-200., -100., 0.)), - SmudShape { - color: css::LIMEGREEN.into(), - ..SmudShape::from(Rectangle::new(120., 40.)) - }, + Transform::from_translation(Vec3::new(-180., 150., 0.)), + SmudShape::from(Ellipse::new(30., 30.)).with_color(css::VIOLET), + OriginalColor(css::VIOLET.into()), )); - // Rotated rectangle commands.spawn(( - Transform::from_translation(Vec3::new(100., -100., 0.)) - .with_rotation(Quat::from_rotation_z(0.5)), - SmudShape::from(Rectangle::new(100., 50.)).with_color(css::ORANGE), + Transform::from_translation(Vec3::new(-60., 150., 0.)), + SmudShape::from(Annulus::new(15., 30.)).with_color(css::MAGENTA), + OriginalColor(css::MAGENTA.into()), )); - // Scaled rectangle commands.spawn(( - Transform::from_translation(Vec3::new(0., 0., 0.)).with_scale(Vec3::splat(1.5)), - SmudShape::from(Rectangle::new(60., 60.)).with_color(css::HOT_PINK), + Transform::from_translation(Vec3::new(60., 150., 0.)), + SmudShape::from(Capsule2d::new(10., 20.)).with_color(css::LIME), + OriginalColor(css::LIME.into()), )); commands.spawn(( - Transform::from_translation(Vec3::new(250., 0., 0.)), - SmudShape::from(Rectangle::new(50., 100.)) - .with_color(css::YELLOW) - .with_fill(SIMPLE_FILL_HANDLE), - OriginalColor(css::YELLOW.into()), + Transform::from_translation(Vec3::new(180., 150., 0.)), + SmudShape::from(Rhombus::new(30., 30.)).with_color(css::GOLD), + OriginalColor(css::GOLD.into()), )); - // Ellipse commands.spawn(( - Transform::from_translation(Vec3::new(-250., 0., 0.)), - SmudShape::from(Ellipse::new(70., 40.)).with_color(css::VIOLET), - OriginalColor(css::VIOLET.into()), + Transform::from_translation(Vec3::new(300., 150., 0.)), + SmudShape::from(CircularSector::from_turns(35., 0.25)).with_color(css::ORANGE_RED), + OriginalColor(css::ORANGE_RED.into()), )); - // Rotated ellipse commands.spawn(( - Transform::from_translation(Vec3::new(0., 150., 0.)) - .with_rotation(Quat::from_rotation_z(0.8)), - SmudShape::from(Ellipse::new(80., 30.)).with_color(css::TURQUOISE), - OriginalColor(css::TURQUOISE.into()), + Transform::from_translation(Vec3::new(420., 150., 0.)), + SmudShape::from(RegularPolygon::new(30., 6)).with_color(css::AQUA), + OriginalColor(css::AQUA.into()), )); - // Annulus (ring) + // Row 2: Rectangular bounds (showing how Rectangle and Ellipse adapt to different bounds) commands.spawn(( - Transform::from_translation(Vec3::new(0., -150., 0.)), - SmudShape::from(Annulus::new(20., 50.)).with_color(css::MAGENTA), + Transform::from_translation(Vec3::new(-420., -150., 0.)), + SmudShape { + bounds: Rectangle::new(70., 40.), + ..SmudShape::from(Rectangle::new(50., 50.)).with_color(css::TOMATO) + }, + OriginalColor(css::TOMATO.into()), + )); + + commands.spawn(( + Transform::from_translation(Vec3::new(-300., -150., 0.)), + SmudShape { + bounds: Rectangle::new(70., 40.), + ..SmudShape::from(Circle::new(25.)).with_color(css::CORNFLOWER_BLUE) + }, + OriginalColor(css::CORNFLOWER_BLUE.into()), + )); + + commands.spawn(( + Transform::from_translation(Vec3::new(-180., -150., 0.)), + SmudShape { + bounds: Rectangle::new(70., 40.), + ..SmudShape::from(Ellipse::new(30., 30.)).with_color(css::VIOLET) + }, + OriginalColor(css::VIOLET.into()), + )); + + commands.spawn(( + Transform::from_translation(Vec3::new(-60., -150., 0.)), + SmudShape { + bounds: Rectangle::new(70., 40.), + ..SmudShape::from(Annulus::new(15., 30.)).with_color(css::MAGENTA) + }, OriginalColor(css::MAGENTA.into()), )); - // Capsule (pill shape) commands.spawn(( - Transform::from_translation(Vec3::new(-350., 0., 0.)), - SmudShape::from(Capsule2d::new(15., 40.)).with_color(css::LIME), + Transform::from_translation(Vec3::new(60., -150., 0.)), + SmudShape { + bounds: Rectangle::new(70., 40.), + ..SmudShape::from(Capsule2d::new(10., 20.)).with_color(css::LIME) + }, OriginalColor(css::LIME.into()), )); - // Rhombus (diamond) commands.spawn(( - Transform::from_translation(Vec3::new(350., 0., 0.)), - SmudShape::from(Rhombus::new(50., 30.)).with_color(css::GOLD), + Transform::from_translation(Vec3::new(180., -150., 0.)), + SmudShape { + bounds: Rectangle::new(70., 40.), + ..SmudShape::from(Rhombus::new(30., 30.)).with_color(css::GOLD) + }, OriginalColor(css::GOLD.into()), )); - // Circular sector (pie slice) - 90 degrees (quarter turn) commands.spawn(( - Transform::from_translation(Vec3::new(0., 200., 0.)), - SmudShape::from(CircularSector::from_turns(60., 0.25)).with_color(css::ORANGE_RED), + Transform::from_translation(Vec3::new(300., -150., 0.)), + SmudShape { + bounds: Rectangle::new(70., 40.), + ..SmudShape::from(CircularSector::from_turns(35., 0.25)).with_color(css::ORANGE_RED) + }, OriginalColor(css::ORANGE_RED.into()), )); - // Regular polygon - hexagon (6 sides) commands.spawn(( - Transform::from_translation(Vec3::new(250., -150., 0.)), - SmudShape::from(RegularPolygon::new(50., 6)).with_color(css::AQUA), + Transform::from_translation(Vec3::new(420., -150., 0.)), + SmudShape { + bounds: Rectangle::new(70., 40.), + ..SmudShape::from(RegularPolygon::new(30., 6)).with_color(css::AQUA) + }, OriginalColor(css::AQUA.into()), )); } diff --git a/src/bevy_primitives.rs b/src/bevy_primitives.rs index 03f2c4c..39f3d57 100644 --- a/src/bevy_primitives.rs +++ b/src/bevy_primitives.rs @@ -60,20 +60,17 @@ trait SmudPrimitive: Sized + Bounded2d { /// The shader handle for this primitive's SDF fn sdf_shader() -> Handle; - /// Extract bounds for rendering (including padding for anti-aliasing) + /// Extract bounds for rendering /// - /// Default implementation uses the `Bounded2d` trait to compute an AABB - /// and adds padding for anti-aliasing. + /// Default implementation uses the `Bounded2d` trait to compute an AABB. + /// Padding for anti-aliasing is handled by `SmudShape::extra_bounds`. fn bounds(&self) -> Rectangle { - const PADDING: f32 = 4.0; let aabb = Bounded2d::aabb_2d(self, Vec2::ZERO); // For asymmetric shapes, we need the maximum extent from origin in each direction let half_size = aabb.min.abs().max(aabb.max.abs()); - Rectangle { - half_size: half_size + Vec2::splat(PADDING), - } + Rectangle { half_size } } /// Extract shader parameters (stored in SmudShape.params) @@ -194,13 +191,13 @@ impl SmudPrimitive for Rectangle { } fn params(&self) -> Vec4 { - Vec4::new(self.half_size.x, self.half_size.y, 0.0, 0.0) + Vec4::ZERO } fn try_from_shape(shape: &SmudShape) -> Option { if shape.sdf.id() == RECTANGLE_SDF_HANDLE.id() { Some(Rectangle { - half_size: shape.params.xy(), + half_size: shape.bounds.half_size, }) } else { None @@ -220,14 +217,13 @@ impl SmudPrimitive for Circle { } fn params(&self) -> Vec4 { - Vec4::new(self.radius, 0.0, 0.0, 0.0) + Vec4::ZERO } fn try_from_shape(shape: &SmudShape) -> Option { if shape.sdf.id() == CIRCLE_SDF_HANDLE.id() { - Some(Circle { - radius: shape.params.x, - }) + let radius = shape.bounds.half_size.x.min(shape.bounds.half_size.y); + Some(Circle { radius }) } else { None } @@ -246,13 +242,13 @@ impl SmudPrimitive for Ellipse { } fn params(&self) -> Vec4 { - Vec4::new(self.half_size.x, self.half_size.y, 0.0, 0.0) + Vec4::ZERO } fn try_from_shape(shape: &SmudShape) -> Option { if shape.sdf.id() == ELLIPSE_SDF_HANDLE.id() { Some(Ellipse { - half_size: shape.params.xy(), + half_size: shape.bounds.half_size, }) } else { None @@ -280,12 +276,14 @@ impl SmudPrimitive for Annulus { } fn params(&self) -> Vec4 { - Vec4::new(self.outer_circle.radius, self.inner_circle.radius, 0.0, 0.0) + Vec4::new(self.inner_circle.radius, 0.0, 0.0, 0.0) } fn try_from_shape(shape: &SmudShape) -> Option { if shape.sdf.id() == ANNULUS_SDF_HANDLE.id() { - Some(Annulus::new(shape.params.y, shape.params.x)) + let outer_radius = shape.bounds.half_size.x.min(shape.bounds.half_size.y); + let inner_radius = shape.params.x; + Some(Annulus::new(inner_radius, outer_radius)) } else { None } @@ -305,12 +303,16 @@ impl SmudPrimitive for Capsule2d { } fn params(&self) -> Vec4 { - Vec4::new(self.radius, self.half_length, 0.0, 0.0) + Vec4::ZERO } fn try_from_shape(shape: &SmudShape) -> Option { if shape.sdf.id() == CAPSULE_SDF_HANDLE.id() { - Some(Capsule2d::new(shape.params.x, shape.params.y)) + // Must match shader logic: radius = min(bounds.x, bounds.y) + let radius = shape.bounds.half_size.x.min(shape.bounds.half_size.y); + let half_length_from_bounds = shape.bounds.half_size.y - radius; + // Note: Capsule2d::new takes (radius, full_length), not (radius, half_length)! + Some(Capsule2d::new(radius, half_length_from_bounds * 2.0)) } else { None } @@ -318,8 +320,14 @@ impl SmudPrimitive for Capsule2d { #[cfg(feature = "bevy_picking")] fn picking_fn(&self) -> Box f32 + Send + Sync> { - let radius = self.radius; - let half_length = self.half_length; + // The shader computes from bounds: radius = min(bounds.x, bounds.y), half_length = bounds.y - radius + // We need to reconstruct bounds from Capsule2d and apply the same logic + // bounds.x = self.radius, bounds.y = self.half_length + self.radius + let bounds_x = self.radius; + let bounds_y = self.half_length + self.radius; + // Now apply shader logic + let radius = bounds_x.min(bounds_y); + let half_length = bounds_y - radius; Box::new(move |p| sdf::capsule(p, radius, half_length)) } } @@ -330,13 +338,13 @@ impl SmudPrimitive for Rhombus { } fn params(&self) -> Vec4 { - Vec4::new(self.half_diagonals.x, self.half_diagonals.y, 0.0, 0.0) + Vec4::ZERO } fn try_from_shape(shape: &SmudShape) -> Option { if shape.sdf.id() == RHOMBUS_SDF_HANDLE.id() { Some(Rhombus { - half_diagonals: shape.params.xy(), + half_diagonals: shape.bounds.half_size, }) } else { None @@ -357,13 +365,13 @@ impl SmudPrimitive for CircularSector { fn params(&self) -> Vec4 { let (sin, cos) = self.arc.half_angle.sin_cos(); - Vec4::new(self.arc.radius, sin, cos, 0.0) + Vec4::new(sin, cos, 0.0, 0.0) } fn try_from_shape(shape: &SmudShape) -> Option { if shape.sdf.id() == CIRCULAR_SECTOR_SDF_HANDLE.id() { - let radius = shape.params.x; - let half_angle = shape.params.y.atan2(shape.params.z); + let radius = shape.bounds.half_size.x.min(shape.bounds.half_size.y); + let half_angle = shape.params.x.atan2(shape.params.y); Some(CircularSector::new(radius, half_angle)) } else { None @@ -385,13 +393,13 @@ impl SmudPrimitive for RegularPolygon { } fn params(&self) -> Vec4 { - Vec4::new(self.circumcircle.radius, self.sides as f32, 0.0, 0.0) + Vec4::new(self.sides as f32, 0.0, 0.0, 0.0) } fn try_from_shape(shape: &SmudShape) -> Option { if shape.sdf.id() == REGULAR_POLYGON_SDF_HANDLE.id() { - let radius = shape.params.x; - let sides = shape.params.y as u32; + let radius = shape.bounds.half_size.x.min(shape.bounds.half_size.y); + let sides = shape.params.x as u32; Some(RegularPolygon::new(radius, sides)) } else { None @@ -448,3 +456,82 @@ fn auto_add_picking_shape( } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_rectangle_round_trip() { + let original = Rectangle::new(100.0, 50.0); + let shape = SmudShape::from(original); + let reconstructed = + Rectangle::try_from_shape(&shape).expect("Failed to reconstruct rectangle"); + + assert_eq!( + original.half_size, reconstructed.half_size, + "Rectangle half_size should match after round-trip conversion" + ); + } + + #[test] + fn test_capsule2d_round_trip() { + let original = Capsule2d::new(10.0, 20.0); + let shape = SmudShape::from(original); + + println!( + "Original: radius={}, half_length={}", + original.radius, original.half_length + ); + println!("Original bounds: {:?}", original.bounds()); + println!("Shape bounds: {:?}", shape.bounds.half_size); + + let reconstructed = + Capsule2d::try_from_shape(&shape).expect("Failed to reconstruct capsule"); + + println!( + "Reconstructed: radius={}, half_length={}", + reconstructed.radius, reconstructed.half_length + ); + + assert_eq!( + original.radius, reconstructed.radius, + "Capsule2d radius should match after round-trip conversion" + ); + assert_eq!( + original.half_length, reconstructed.half_length, + "Capsule2d half_length should match after round-trip conversion" + ); + } + + #[test] + fn test_capsule2d_with_wide_bounds() { + // Test a capsule with bounds wider than tall (70x40) + let original = Capsule2d::new(10.0, 20.0); + let mut shape = SmudShape::from(original); + shape.bounds = Rectangle::new(70.0, 40.0); + + let reconstructed = + Capsule2d::try_from_shape(&shape).expect("Failed to reconstruct wide capsule"); + + // With bounds (70, 40): + // - radius = min(70, 40) = 40 + // - half_length_from_bounds = 40 - 40 = 0 + // - Capsule2d::new(40, 0*2) creates a capsule with radius=20, half_length=0 + // (because Capsule2d::new divides both parameters by 2 internally) + assert_eq!( + reconstructed.radius, 20.0, + "Radius should match shader: min(bounds.x, bounds.y) / 2" + ); + assert_eq!( + reconstructed.half_length, 0.0, + "Half_length should be 0 for wide bounds" + ); + + // Ensure it's not negative + assert!( + reconstructed.half_length >= 0.0, + "Half_length must not be negative" + ); + } +} diff --git a/src/components.rs b/src/components.rs index 4243960..4572f55 100644 --- a/src/components.rs +++ b/src/components.rs @@ -53,7 +53,7 @@ impl Default for SmudShape { params: default(), fill: DEFAULT_FILL_HANDLE, blend_mode: BlendMode::default(), - extra_bounds: 0.0, + extra_bounds: 5.0, } } } diff --git a/src/sdf.rs b/src/sdf.rs index 5273cc7..ae4d7de 100644 --- a/src/sdf.rs +++ b/src/sdf.rs @@ -53,6 +53,10 @@ pub fn annulus(p: Vec2, outer_radius: f32, inner_radius: f32) -> f32 { /// Signed distance to a capsule (pill shape) pub fn capsule(p: Vec2, radius: f32, half_length: f32) -> f32 { + // Special case: when half_length is 0, the capsule is just a circle + if half_length == 0.0 { + return circle(p, radius); + } let a = Vec2::new(0.0, -half_length); let b = Vec2::new(0.0, half_length); segment(p, a, b) - radius @@ -83,7 +87,14 @@ pub fn oriented_box(p: Vec2, a: Vec2, b: Vec2, th: f32) -> f32 { pub fn segment(p: Vec2, a: Vec2, b: Vec2) -> f32 { let pa = p - a; let ba = b - a; - let h = clamp(pa.dot(ba) / ba.dot(ba), 0.0, 1.0); + let ba_len_sq = ba.dot(ba); + + // Degenerate case: when a == b, the segment is just a point + if ba_len_sq == 0.0 { + return pa.length(); + } + + let h = clamp(pa.dot(ba) / ba_len_sq, 0.0, 1.0); (pa - ba * h).length() } @@ -673,4 +684,59 @@ mod tests { // Point outside circle should be positive assert!(circle(Vec2::new(2.0, 0.0), 1.0) > 0.0); } + + #[test] + fn test_segment_degenerate_case() { + // When a == b, segment is just a point, should return distance to that point + let a = Vec2::new(5.0, 5.0); + let b = Vec2::new(5.0, 5.0); + + // Distance from origin to the point (5, 5) + let result = segment(Vec2::ZERO, a, b); + assert!( + result.is_finite(), + "segment should not return NaN for a == b" + ); + let expected = (Vec2::new(5.0, 5.0) - Vec2::ZERO).length(); + assert!((result - expected).abs() < 0.001); + + // Distance from a point to itself should be 0 + let result = segment(Vec2::new(5.0, 5.0), a, b); + assert!(result.is_finite()); + assert!(result.abs() < f32::EPSILON); + } + + #[test] + fn test_capsule_with_zero_half_length() { + // When half_length is 0, capsule should behave like a circle + let radius = 20.0; + let half_length = 0.0; + + // Point at center should be -radius + let result = capsule(Vec2::ZERO, radius, half_length); + assert!(result.is_finite(), "capsule should not return NaN"); + assert_eq!(result, -radius); + + // Point on the circle edge + let result = capsule(Vec2::new(radius, 0.0), radius, half_length); + assert!(result.is_finite(), "capsule should not return NaN"); + assert!(result.abs() < 0.001); + + // Point outside + let result = capsule(Vec2::new(30.0, 0.0), radius, half_length); + assert!(result.is_finite(), "capsule should not return NaN"); + assert!(result > 0.0); + } + + #[test] + fn test_capsule_with_normal_half_length() { + // Normal capsule with half_length > 0 + let radius = 10.0; + let half_length = 10.0; + + // Point at center + let result = capsule(Vec2::ZERO, radius, half_length); + assert!(result.is_finite()); + assert_eq!(result, -radius); + } } From 33d02891ea4d130b7857fcfd129ce562ab2081a9 Mon Sep 17 00:00:00 2001 From: Johan Klokkhammer Helsing Date: Sun, 19 Oct 2025 18:44:46 +0200 Subject: [PATCH 2/9] docs: Use field initializers in example --- examples/bevy_primitives.rs | 24 ++++++++++++++++-------- 1 file changed, 16 insertions(+), 8 deletions(-) diff --git a/examples/bevy_primitives.rs b/examples/bevy_primitives.rs index efe869e..f655c31 100644 --- a/examples/bevy_primitives.rs +++ b/examples/bevy_primitives.rs @@ -67,7 +67,8 @@ fn setup(mut commands: Commands) { Transform::from_translation(Vec3::new(-420., -150., 0.)), SmudShape { bounds: Rectangle::new(70., 40.), - ..SmudShape::from(Rectangle::new(50., 50.)).with_color(css::TOMATO) + color: css::TOMATO.into(), + ..SmudShape::from(Rectangle::new(50., 50.)) }, OriginalColor(css::TOMATO.into()), )); @@ -76,7 +77,8 @@ fn setup(mut commands: Commands) { Transform::from_translation(Vec3::new(-300., -150., 0.)), SmudShape { bounds: Rectangle::new(70., 40.), - ..SmudShape::from(Circle::new(25.)).with_color(css::CORNFLOWER_BLUE) + color: css::CORNFLOWER_BLUE.into(), + ..SmudShape::from(Circle::new(25.)) }, OriginalColor(css::CORNFLOWER_BLUE.into()), )); @@ -85,7 +87,8 @@ fn setup(mut commands: Commands) { Transform::from_translation(Vec3::new(-180., -150., 0.)), SmudShape { bounds: Rectangle::new(70., 40.), - ..SmudShape::from(Ellipse::new(30., 30.)).with_color(css::VIOLET) + color: css::VIOLET.into(), + ..SmudShape::from(Ellipse::new(30., 30.)) }, OriginalColor(css::VIOLET.into()), )); @@ -94,7 +97,8 @@ fn setup(mut commands: Commands) { Transform::from_translation(Vec3::new(-60., -150., 0.)), SmudShape { bounds: Rectangle::new(70., 40.), - ..SmudShape::from(Annulus::new(15., 30.)).with_color(css::MAGENTA) + color: css::MAGENTA.into(), + ..SmudShape::from(Annulus::new(15., 30.)) }, OriginalColor(css::MAGENTA.into()), )); @@ -103,7 +107,8 @@ fn setup(mut commands: Commands) { Transform::from_translation(Vec3::new(60., -150., 0.)), SmudShape { bounds: Rectangle::new(70., 40.), - ..SmudShape::from(Capsule2d::new(10., 20.)).with_color(css::LIME) + color: css::LIME.into(), + ..SmudShape::from(Capsule2d::new(10., 20.)) }, OriginalColor(css::LIME.into()), )); @@ -112,7 +117,8 @@ fn setup(mut commands: Commands) { Transform::from_translation(Vec3::new(180., -150., 0.)), SmudShape { bounds: Rectangle::new(70., 40.), - ..SmudShape::from(Rhombus::new(30., 30.)).with_color(css::GOLD) + color: css::GOLD.into(), + ..SmudShape::from(Rhombus::new(30., 30.)) }, OriginalColor(css::GOLD.into()), )); @@ -121,7 +127,8 @@ fn setup(mut commands: Commands) { Transform::from_translation(Vec3::new(300., -150., 0.)), SmudShape { bounds: Rectangle::new(70., 40.), - ..SmudShape::from(CircularSector::from_turns(35., 0.25)).with_color(css::ORANGE_RED) + color: css::ORANGE_RED.into(), + ..SmudShape::from(CircularSector::from_turns(35., 0.25)) }, OriginalColor(css::ORANGE_RED.into()), )); @@ -130,7 +137,8 @@ fn setup(mut commands: Commands) { Transform::from_translation(Vec3::new(420., -150., 0.)), SmudShape { bounds: Rectangle::new(70., 40.), - ..SmudShape::from(RegularPolygon::new(30., 6)).with_color(css::AQUA) + color: css::AQUA.into(), + ..SmudShape::from(RegularPolygon::new(30., 6)) }, OriginalColor(css::AQUA.into()), )); From c65a6fc18ae036cb8636020ef36db8086689eac8 Mon Sep 17 00:00:00 2001 From: Johan Klokkhammer Helsing Date: Sun, 19 Oct 2025 18:45:03 +0200 Subject: [PATCH 3/9] docs: Animate bounds in bevy_primitives example --- examples/bevy_primitives.rs | 31 +++++++++++++++++++++++++++++-- 1 file changed, 29 insertions(+), 2 deletions(-) diff --git a/examples/bevy_primitives.rs b/examples/bevy_primitives.rs index f655c31..eeb0ef5 100644 --- a/examples/bevy_primitives.rs +++ b/examples/bevy_primitives.rs @@ -6,7 +6,7 @@ fn main() { App::new() .add_plugins((DefaultPlugins, SmudPlugin, SmudPickingPlugin)) .add_systems(Startup, setup) - .add_systems(Update, update_colors_on_hover) + .add_systems(Update, (animate_bounds, update_colors_on_hover)) .run(); } @@ -62,7 +62,7 @@ fn setup(mut commands: Commands) { OriginalColor(css::AQUA.into()), )); - // Row 2: Rectangular bounds (showing how Rectangle and Ellipse adapt to different bounds) + // Row 2: Animated bounds (shapes smoothly transition between tall and wide) commands.spawn(( Transform::from_translation(Vec3::new(-420., -150., 0.)), SmudShape { @@ -71,6 +71,7 @@ fn setup(mut commands: Commands) { ..SmudShape::from(Rectangle::new(50., 50.)) }, OriginalColor(css::TOMATO.into()), + AnimatedBounds, )); commands.spawn(( @@ -81,6 +82,7 @@ fn setup(mut commands: Commands) { ..SmudShape::from(Circle::new(25.)) }, OriginalColor(css::CORNFLOWER_BLUE.into()), + AnimatedBounds, )); commands.spawn(( @@ -91,6 +93,7 @@ fn setup(mut commands: Commands) { ..SmudShape::from(Ellipse::new(30., 30.)) }, OriginalColor(css::VIOLET.into()), + AnimatedBounds, )); commands.spawn(( @@ -101,6 +104,7 @@ fn setup(mut commands: Commands) { ..SmudShape::from(Annulus::new(15., 30.)) }, OriginalColor(css::MAGENTA.into()), + AnimatedBounds, )); commands.spawn(( @@ -111,6 +115,7 @@ fn setup(mut commands: Commands) { ..SmudShape::from(Capsule2d::new(10., 20.)) }, OriginalColor(css::LIME.into()), + AnimatedBounds, )); commands.spawn(( @@ -121,6 +126,7 @@ fn setup(mut commands: Commands) { ..SmudShape::from(Rhombus::new(30., 30.)) }, OriginalColor(css::GOLD.into()), + AnimatedBounds, )); commands.spawn(( @@ -131,6 +137,7 @@ fn setup(mut commands: Commands) { ..SmudShape::from(CircularSector::from_turns(35., 0.25)) }, OriginalColor(css::ORANGE_RED.into()), + AnimatedBounds, )); commands.spawn(( @@ -141,6 +148,7 @@ fn setup(mut commands: Commands) { ..SmudShape::from(RegularPolygon::new(30., 6)) }, OriginalColor(css::AQUA.into()), + AnimatedBounds, )); } @@ -148,6 +156,25 @@ fn setup(mut commands: Commands) { #[derive(Component)] struct OriginalColor(Color); +// Marker component for shapes with animated bounds +#[derive(Component)] +struct AnimatedBounds; + +// System to animate bounds based on time +fn animate_bounds(time: Res