diff --git a/crates/egui_demo_lib/src/demo/tests/tessellation_test.rs b/crates/egui_demo_lib/src/demo/tests/tessellation_test.rs index 3bb814889..abe3280eb 100644 --- a/crates/egui_demo_lib/src/demo/tests/tessellation_test.rs +++ b/crates/egui_demo_lib/src/demo/tests/tessellation_test.rs @@ -297,6 +297,7 @@ fn rect_shape_ui(ui: &mut egui::Ui, shape: &mut RectShape) { blur_width, round_to_pixels, brush: _, + angle: _, } = shape; let round_to_pixels = round_to_pixels.get_or_insert(true); diff --git a/crates/epaint/src/shape_transform.rs b/crates/epaint/src/shape_transform.rs index 8fce01f64..71cc1332e 100644 --- a/crates/epaint/src/shape_transform.rs +++ b/crates/epaint/src/shape_transform.rs @@ -57,6 +57,7 @@ pub fn adjust_colors( radius: _, fill, stroke, + angle: _, }) | Shape::Rect(RectShape { rect: _, @@ -67,6 +68,7 @@ pub fn adjust_colors( round_to_pixels: _, blur_width: _, brush: _, + angle: _, }) => { adjust_color(fill); adjust_color(&mut stroke.color); diff --git a/crates/epaint/src/shapes/ellipse_shape.rs b/crates/epaint/src/shapes/ellipse_shape.rs index 310638d0f..b436eb841 100644 --- a/crates/epaint/src/shapes/ellipse_shape.rs +++ b/crates/epaint/src/shapes/ellipse_shape.rs @@ -10,6 +10,9 @@ pub struct EllipseShape { pub radius: Vec2, pub fill: Color32, pub stroke: Stroke, + + /// Rotate ellipse by this many radians clockwise around its center. + pub angle: f32, } impl EllipseShape { @@ -20,6 +23,7 @@ impl EllipseShape { radius, fill: fill_color.into(), stroke: Default::default(), + angle: 0.0, } } @@ -30,18 +34,38 @@ impl EllipseShape { radius, fill: Default::default(), stroke: stroke.into(), + angle: 0.0, } } + /// Set the rotation of the ellipse (in radians, clockwise). + /// The ellipse rotates around its center. + #[inline] + pub fn with_angle(mut self, angle: f32) -> Self { + self.angle = angle; + self + } + + /// Set the rotation of the ellipse (in radians, clockwise) around a custom pivot point. + #[inline] + pub fn with_angle_and_pivot(mut self, angle: f32, pivot: Pos2) -> Self { + self.angle = angle; + let rot = emath::Rot2::from_angle(angle); + self.center = pivot + rot * (self.center - pivot); + self + } + /// The visual bounding rectangle (includes stroke width) pub fn visual_bounding_rect(&self) -> Rect { if self.fill == Color32::TRANSPARENT && self.stroke.is_empty() { Rect::NOTHING } else { - Rect::from_center_size( - self.center, + let rect = Rect::from_center_size( + Pos2::ZERO, self.radius * 2.0 + Vec2::splat(self.stroke.width), - ) + ); + rect.rotate_bb(emath::Rot2::from_angle(self.angle)) + .translate(self.center.to_vec2()) } } } diff --git a/crates/epaint/src/shapes/rect_shape.rs b/crates/epaint/src/shapes/rect_shape.rs index 2e855d369..e0c528377 100644 --- a/crates/epaint/src/shapes/rect_shape.rs +++ b/crates/epaint/src/shapes/rect_shape.rs @@ -54,13 +54,16 @@ pub struct RectShape { /// Since most rectangles do not have a texture, this is optional and in an `Arc`, /// so that [`RectShape`] is kept small.. pub brush: Option>, + + /// Rotate rectangle by this many radians clockwise around its center. + pub angle: f32, } #[test] fn rect_shape_size() { assert_eq!( std::mem::size_of::(), - 48, + 56, "RectShape changed size! If it shrank - good! Update this test. If it grew - bad! Try to find a way to avoid it." ); assert!( @@ -88,6 +91,7 @@ impl RectShape { round_to_pixels: None, blur_width: 0.0, brush: Default::default(), + angle: 0.0, } } @@ -157,6 +161,25 @@ impl RectShape { self } + /// Set the rotation of the rectangle (in radians, clockwise). + /// The rectangle rotates around its center. + #[inline] + pub fn with_angle(mut self, angle: f32) -> Self { + self.angle = angle; + self + } + + /// Set the rotation of the rectangle (in radians, clockwise) around a custom pivot point. + #[inline] + pub fn with_angle_and_pivot(mut self, angle: f32, pivot: Pos2) -> Self { + self.angle = angle; + let rot = emath::Rot2::from_angle(angle); + let center = self.rect.center(); + let new_center = pivot + rot * (center - pivot); + self.rect = self.rect.translate(new_center - center); + self + } + /// The visual bounding rectangle (includes stroke width) #[inline] pub fn visual_bounding_rect(&self) -> Rect { @@ -168,7 +191,17 @@ impl RectShape { StrokeKind::Middle => self.stroke.width / 2.0, StrokeKind::Outside => self.stroke.width, }; - self.rect.expand(expand + self.blur_width / 2.0) + let expanded = self.rect.expand(expand + self.blur_width / 2.0); + if self.angle == 0.0 { + expanded + } else { + // Rotate around the rectangle's center and compute bounding box + let center = self.rect.center(); + let rect_relative = Rect::from_center_size(Pos2::ZERO, expanded.size()); + rect_relative + .rotate_bb(emath::Rot2::from_angle(self.angle)) + .translate(center.to_vec2()) + } } } diff --git a/crates/epaint/src/tessellator.rs b/crates/epaint/src/tessellator.rs index 9529765ac..9256ae16e 100644 --- a/crates/epaint/src/tessellator.rs +++ b/crates/epaint/src/tessellator.rs @@ -1546,6 +1546,7 @@ impl Tessellator { radius, fill, stroke, + angle, } = shape; if radius.x <= 0.0 || radius.y <= 0.0 { @@ -1596,6 +1597,14 @@ impl Tessellator { points.push(center + Vec2::new(0.0, -radius.y)); points.extend(quarter.iter().rev().map(|p| center + Vec2::new(p.x, -p.y))); + // Apply rotation if angle is non-zero + if angle != 0.0 { + let rot = emath::Rot2::from_angle(angle); + for point in &mut points { + *point = center + rot * (*point - center); + } + } + let path_stroke = PathStroke::from(stroke).outside(); self.scratchpad_path.clear(); self.scratchpad_path.add_line_loop(&points); @@ -1773,6 +1782,7 @@ impl Tessellator { round_to_pixels, mut blur_width, brush: _, // brush is extracted on its own, because it is not Copy + angle, } = *rect_shape; let mut corner_radius = CornerRadiusF32::from(corner_radius); @@ -1940,6 +1950,16 @@ impl Tessellator { let path = &mut self.scratchpad_path; path.clear(); path::rounded_rectangle(&mut self.scratchpad_points, rect, corner_radius); + + // Apply rotation if angle is non-zero + if angle != 0.0 { + let rot = emath::Rot2::from_angle(angle); + let center = rect.center(); + for point in &mut self.scratchpad_points { + *point = center + rot * (*point - center); + } + } + path.add_line_loop(&self.scratchpad_points); let path_stroke = PathStroke::from(stroke).with_kind(stroke_kind); diff --git a/tests/egui_tests/tests/snapshots/rotated_ellipse.png b/tests/egui_tests/tests/snapshots/rotated_ellipse.png new file mode 100644 index 000000000..e32f7864c --- /dev/null +++ b/tests/egui_tests/tests/snapshots/rotated_ellipse.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e8f222733524b21969834a9ccc15aa9b0a4deb1d41e1086c80750f7cdd9711c8 +size 17324 diff --git a/tests/egui_tests/tests/snapshots/rotated_rect.png b/tests/egui_tests/tests/snapshots/rotated_rect.png new file mode 100644 index 000000000..52255aa7f --- /dev/null +++ b/tests/egui_tests/tests/snapshots/rotated_rect.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:fb4f1d10aa664e04da4b2e38c52cb6516a4c43a98884c9223e15266ea28ccd3d +size 14191 diff --git a/tests/egui_tests/tests/test_rotation.rs b/tests/egui_tests/tests/test_rotation.rs new file mode 100644 index 000000000..03c9a8664 --- /dev/null +++ b/tests/egui_tests/tests/test_rotation.rs @@ -0,0 +1,117 @@ +use egui::epaint::{EllipseShape, RectShape, StrokeKind}; +use egui::{Color32, Grid, Pos2, Rect, Shape, Stroke, Vec2}; +use egui_kittest::Harness; + +const SHAPE_COLOR: Color32 = Color32::from_rgb(255, 165, 0); +const GHOST_COLOR: Color32 = Color32::from_rgb(0, 255, 255); +const PIVOT_COLOR: Color32 = Color32::from_rgb(255, 0, 255); +const CELL_SIZE: Vec2 = Vec2::new(180.0, 180.0); + +#[test] +fn rotated_rect() { + let shape_stroke = Stroke::new(2.0, Color32::BLACK); + let ghost_stroke = Stroke::new(1.0, GHOST_COLOR); + + let mut harness = Harness::new_ui(|ui| { + ui.ctx().set_pixels_per_point(1.0); + + let rect_size = Vec2::new(100.0, 60.0); + let cell_center = Pos2::new(90.0, 90.0); + let cell_rect = Rect::from_center_size(cell_center, rect_size); + + Grid::new("rotated_rect_grid") + .spacing(Vec2::new(30.0, 30.0)) + .show(ui, |ui| { + for (label, angle, pivot) in [ + ("0°", 0.0, None), + ("Center 45°", 45.0f32.to_radians(), None), + ( + "Top-Left 45°", + 45.0f32.to_radians(), + Some(cell_rect.left_top()), + ), + ] { + paint_case(ui, label, |offset| { + let rect = cell_rect.translate(offset); + let pivot = pivot.map(|p| p + offset); + let pivot_pos = pivot.unwrap_or_else(|| rect.center()); + + let ghost = RectShape::stroke(rect, 0.0, ghost_stroke, StrokeKind::Outside); + let shape = RectShape::new( + rect, + 0.0, + SHAPE_COLOR, + shape_stroke, + StrokeKind::Outside, + ) + .with_angle_and_pivot(angle, pivot_pos); + + (ghost.into(), shape.into(), pivot_pos) + }); + } + }); + }); + + harness.fit_contents(); + harness.try_snapshot("rotated_rect").unwrap(); +} + +#[test] +fn rotated_ellipse() { + let shape_stroke = Stroke::new(2.0, Color32::BLACK); + let ghost_stroke = Stroke::new(1.0, GHOST_COLOR); + + let mut harness = Harness::new_ui(|ui| { + ui.ctx().set_pixels_per_point(1.0); + + let rect_size = Vec2::new(100.0, 60.0); + let cell_center = Pos2::new(90.0, 90.0); + let radius = rect_size / 2.0; + + Grid::new("rotated_ellipse_grid") + .spacing(Vec2::new(30.0, 30.0)) + .show(ui, |ui| { + for (label, angle, pivot) in [ + ("0°", 0.0, None), + ("Center 45°", 45.0f32.to_radians(), None), + ( + "Top-Left 45°", + 45.0f32.to_radians(), + Some(cell_center - radius), + ), + ] { + paint_case(ui, label, |offset| { + let center = cell_center + offset; + let pivot = pivot.map(|p| p + offset); + let pivot_pos = pivot.unwrap_or(center); + + let ghost = EllipseShape::stroke(center, radius, ghost_stroke); + let mut shape = EllipseShape::filled(center, radius, SHAPE_COLOR); + shape.stroke = shape_stroke; + let shape = shape.with_angle_and_pivot(angle, pivot_pos); + + (ghost.into(), shape.into(), pivot_pos) + }); + } + }); + }); + + harness.fit_contents(); + harness.try_snapshot("rotated_ellipse").unwrap(); +} + +fn paint_case(ui: &mut egui::Ui, label: &str, make_shapes: F) +where + F: FnOnce(Vec2) -> (Shape, Shape, Pos2), +{ + ui.vertical(|ui| { + ui.label(label); + let (response, painter) = ui.allocate_painter(CELL_SIZE, egui::Sense::hover()); + let offset = response.rect.min.to_vec2(); + + let (ghost, shape, pivot) = make_shapes(offset); + painter.add(ghost); + painter.add(shape); + painter.circle_filled(pivot, 3.0, PIVOT_COLOR); + }); +}