mirror of
https://github.com/emilk/egui.git
synced 2026-09-01 22:30:03 -04:00
Allow rotation of rectangles and ellipses (#7682)
<!-- Please read the "Making a PR" section of [`CONTRIBUTING.md`](https://github.com/emilk/egui/blob/main/CONTRIBUTING.md) before opening a Pull Request! * Keep your PR:s small and focused. * The PR title is what ends up in the changelog, so make it descriptive! * If applicable, add a screenshot or gif. * If it is a non-trivial addition, consider adding a demo for it to `egui_demo_lib`, or a new example. * Do NOT open PR:s from your `master` branch, as that makes it hard for maintainers to test and add commits to your PR. * Remember to run `cargo fmt` and `cargo clippy`. * Open the PR as a draft until you have self-reviewed it and run `./scripts/check.sh`. * When you have addressed a PR comment, mark it as resolved. Please be patient! I will review your PR, but my time is limited! --> Added the ability to rotate rectangles and ellipses. Similar to the existing text implementation * [x ] I have followed the instructions in the PR template
This commit is contained in:
@@ -297,6 +297,7 @@ fn rect_shape_ui(ui: &mut egui::Ui, shape: &mut RectShape) {
|
|||||||
blur_width,
|
blur_width,
|
||||||
round_to_pixels,
|
round_to_pixels,
|
||||||
brush: _,
|
brush: _,
|
||||||
|
angle: _,
|
||||||
} = shape;
|
} = shape;
|
||||||
|
|
||||||
let round_to_pixels = round_to_pixels.get_or_insert(true);
|
let round_to_pixels = round_to_pixels.get_or_insert(true);
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ pub fn adjust_colors(
|
|||||||
radius: _,
|
radius: _,
|
||||||
fill,
|
fill,
|
||||||
stroke,
|
stroke,
|
||||||
|
angle: _,
|
||||||
})
|
})
|
||||||
| Shape::Rect(RectShape {
|
| Shape::Rect(RectShape {
|
||||||
rect: _,
|
rect: _,
|
||||||
@@ -67,6 +68,7 @@ pub fn adjust_colors(
|
|||||||
round_to_pixels: _,
|
round_to_pixels: _,
|
||||||
blur_width: _,
|
blur_width: _,
|
||||||
brush: _,
|
brush: _,
|
||||||
|
angle: _,
|
||||||
}) => {
|
}) => {
|
||||||
adjust_color(fill);
|
adjust_color(fill);
|
||||||
adjust_color(&mut stroke.color);
|
adjust_color(&mut stroke.color);
|
||||||
|
|||||||
@@ -10,6 +10,9 @@ pub struct EllipseShape {
|
|||||||
pub radius: Vec2,
|
pub radius: Vec2,
|
||||||
pub fill: Color32,
|
pub fill: Color32,
|
||||||
pub stroke: Stroke,
|
pub stroke: Stroke,
|
||||||
|
|
||||||
|
/// Rotate ellipse by this many radians clockwise around its center.
|
||||||
|
pub angle: f32,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl EllipseShape {
|
impl EllipseShape {
|
||||||
@@ -20,6 +23,7 @@ impl EllipseShape {
|
|||||||
radius,
|
radius,
|
||||||
fill: fill_color.into(),
|
fill: fill_color.into(),
|
||||||
stroke: Default::default(),
|
stroke: Default::default(),
|
||||||
|
angle: 0.0,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -30,18 +34,38 @@ impl EllipseShape {
|
|||||||
radius,
|
radius,
|
||||||
fill: Default::default(),
|
fill: Default::default(),
|
||||||
stroke: stroke.into(),
|
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)
|
/// The visual bounding rectangle (includes stroke width)
|
||||||
pub fn visual_bounding_rect(&self) -> Rect {
|
pub fn visual_bounding_rect(&self) -> Rect {
|
||||||
if self.fill == Color32::TRANSPARENT && self.stroke.is_empty() {
|
if self.fill == Color32::TRANSPARENT && self.stroke.is_empty() {
|
||||||
Rect::NOTHING
|
Rect::NOTHING
|
||||||
} else {
|
} else {
|
||||||
Rect::from_center_size(
|
let rect = Rect::from_center_size(
|
||||||
self.center,
|
Pos2::ZERO,
|
||||||
self.radius * 2.0 + Vec2::splat(self.stroke.width),
|
self.radius * 2.0 + Vec2::splat(self.stroke.width),
|
||||||
)
|
);
|
||||||
|
rect.rotate_bb(emath::Rot2::from_angle(self.angle))
|
||||||
|
.translate(self.center.to_vec2())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -54,13 +54,16 @@ pub struct RectShape {
|
|||||||
/// Since most rectangles do not have a texture, this is optional and in an `Arc`,
|
/// Since most rectangles do not have a texture, this is optional and in an `Arc`,
|
||||||
/// so that [`RectShape`] is kept small..
|
/// so that [`RectShape`] is kept small..
|
||||||
pub brush: Option<Arc<Brush>>,
|
pub brush: Option<Arc<Brush>>,
|
||||||
|
|
||||||
|
/// Rotate rectangle by this many radians clockwise around its center.
|
||||||
|
pub angle: f32,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn rect_shape_size() {
|
fn rect_shape_size() {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
std::mem::size_of::<RectShape>(),
|
std::mem::size_of::<RectShape>(),
|
||||||
48,
|
56,
|
||||||
"RectShape changed size! If it shrank - good! Update this test. If it grew - bad! Try to find a way to avoid it."
|
"RectShape changed size! If it shrank - good! Update this test. If it grew - bad! Try to find a way to avoid it."
|
||||||
);
|
);
|
||||||
assert!(
|
assert!(
|
||||||
@@ -88,6 +91,7 @@ impl RectShape {
|
|||||||
round_to_pixels: None,
|
round_to_pixels: None,
|
||||||
blur_width: 0.0,
|
blur_width: 0.0,
|
||||||
brush: Default::default(),
|
brush: Default::default(),
|
||||||
|
angle: 0.0,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -157,6 +161,25 @@ impl RectShape {
|
|||||||
self
|
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)
|
/// The visual bounding rectangle (includes stroke width)
|
||||||
#[inline]
|
#[inline]
|
||||||
pub fn visual_bounding_rect(&self) -> Rect {
|
pub fn visual_bounding_rect(&self) -> Rect {
|
||||||
@@ -168,7 +191,17 @@ impl RectShape {
|
|||||||
StrokeKind::Middle => self.stroke.width / 2.0,
|
StrokeKind::Middle => self.stroke.width / 2.0,
|
||||||
StrokeKind::Outside => self.stroke.width,
|
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())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1546,6 +1546,7 @@ impl Tessellator {
|
|||||||
radius,
|
radius,
|
||||||
fill,
|
fill,
|
||||||
stroke,
|
stroke,
|
||||||
|
angle,
|
||||||
} = shape;
|
} = shape;
|
||||||
|
|
||||||
if radius.x <= 0.0 || radius.y <= 0.0 {
|
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.push(center + Vec2::new(0.0, -radius.y));
|
||||||
points.extend(quarter.iter().rev().map(|p| center + Vec2::new(p.x, -p.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();
|
let path_stroke = PathStroke::from(stroke).outside();
|
||||||
self.scratchpad_path.clear();
|
self.scratchpad_path.clear();
|
||||||
self.scratchpad_path.add_line_loop(&points);
|
self.scratchpad_path.add_line_loop(&points);
|
||||||
@@ -1773,6 +1782,7 @@ impl Tessellator {
|
|||||||
round_to_pixels,
|
round_to_pixels,
|
||||||
mut blur_width,
|
mut blur_width,
|
||||||
brush: _, // brush is extracted on its own, because it is not Copy
|
brush: _, // brush is extracted on its own, because it is not Copy
|
||||||
|
angle,
|
||||||
} = *rect_shape;
|
} = *rect_shape;
|
||||||
|
|
||||||
let mut corner_radius = CornerRadiusF32::from(corner_radius);
|
let mut corner_radius = CornerRadiusF32::from(corner_radius);
|
||||||
@@ -1940,6 +1950,16 @@ impl Tessellator {
|
|||||||
let path = &mut self.scratchpad_path;
|
let path = &mut self.scratchpad_path;
|
||||||
path.clear();
|
path.clear();
|
||||||
path::rounded_rectangle(&mut self.scratchpad_points, rect, corner_radius);
|
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);
|
path.add_line_loop(&self.scratchpad_points);
|
||||||
|
|
||||||
let path_stroke = PathStroke::from(stroke).with_kind(stroke_kind);
|
let path_stroke = PathStroke::from(stroke).with_kind(stroke_kind);
|
||||||
|
|||||||
3
tests/egui_tests/tests/snapshots/rotated_ellipse.png
Normal file
3
tests/egui_tests/tests/snapshots/rotated_ellipse.png
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:e8f222733524b21969834a9ccc15aa9b0a4deb1d41e1086c80750f7cdd9711c8
|
||||||
|
size 17324
|
||||||
3
tests/egui_tests/tests/snapshots/rotated_rect.png
Normal file
3
tests/egui_tests/tests/snapshots/rotated_rect.png
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:fb4f1d10aa664e04da4b2e38c52cb6516a4c43a98884c9223e15266ea28ccd3d
|
||||||
|
size 14191
|
||||||
117
tests/egui_tests/tests/test_rotation.rs
Normal file
117
tests/egui_tests/tests/test_rotation.rs
Normal file
@@ -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<F>(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);
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user