From 4836c7868ca36f3976c6b6fa9b33d90d04c8ccf7 Mon Sep 17 00:00:00 2001 From: ickshonpe Date: Mon, 9 Jun 2025 20:05:49 +0100 Subject: [PATCH] Specialized UI transform (#16615) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Objective Add specialized UI transform `Component`s and fix some related problems: * Animating UI elements by modifying the `Transform` component of UI nodes doesn't work very well because `ui_layout_system` overwrites the translations each frame. The `overflow_debug` example uses a horrible hack where it copies the transform into the position that'll likely cause a panic if any users naively copy it. * Picking ignores rotation and scaling and assumes UI nodes are always axis aligned. * The clipping geometry stored in `CalculatedClip` is wrong for rotated and scaled elements. * Transform propagation is unnecessary for the UI, the transforms can be updated during layout updates. * The UI internals use both object-centered and top-left-corner-based coordinates systems for UI nodes. Depending on the context you have to add or subtract the half-size sometimes before transforming between coordinate spaces. We should just use one system consistantly so that the transform can always be directly applied. * `Transform` doesn't support responsive coordinates. ## Solution * Unrequire `Transform` from `Node`. * New components `UiTransform`, `UiGlobalTransform`: - `Node` requires `UiTransform`, `UiTransform` requires `UiGlobalTransform` - `UiTransform` is a 2d-only equivalent of `Transform` with a translation in `Val`s. - `UiGlobalTransform` newtypes `Affine2` and is updated in `ui_layout_system`. * New helper functions on `ComputedNode` for mapping between viewport and local node space. * The cursor position is transformed to local node space during picking so that it respects rotations and scalings. * To check if the cursor hovers a node recursively walk up the tree to the root checking if any of the ancestor nodes clip the point at the cursor. If the point is clipped the interaction is ignored. * Use object-centered coordinates for UI nodes. * `RelativeCursorPosition`'s coordinates are now object-centered with (0,0) at the the center of the node and the corners at (±0.5, ±0.5). * Replaced the `normalized_visible_node_rect: Rect` field of `RelativeCursorPosition` with `cursor_over: bool`, which is set to true when the cursor is over an unclipped point on the node. The visible area of the node is not necessarily a rectangle, so the previous implementation didn't work. This should fix all the logical bugs with non-axis aligned interactions and clipping. Rendering still needs changes but they are far outside the scope of this PR. Tried and abandoned two other approaches: * New `transform` field on `Node`, require `GlobalTransform` on `Node`, and unrequire `Transform` on `Node`. Unrequiring `Transform` opts out of transform propagation so there is then no conflict with updating the `GlobalTransform` in `ui_layout_system`. This was a nice change in its simplicity but potentially confusing for users I think, all the `GlobalTransform` docs mention `Transform` and having special rules for how it's updated just for the UI is unpleasently surprising. * New `transform` field on `Node`. Unrequire `Transform` on `Node`. New `transform: Affine2` field on `ComputedNode`. This was okay but I think most users want a separate specialized UI transform components. The fat `ComputedNode` doesn't work well with change detection. Fixes #18929, #18930 ## Testing There is an example you can look at: ``` cargo run --example ui_transform ``` Sometimes in the example if you press the rotate button couple of times the first glyph from the top label disappears , I'm not sure what's causing it yet but I don't think it's related to this PR. ## Migration Guide New specialized 2D UI transform components `UiTransform` and `UiGlobalTransform`. `UiTransform` is a 2d-only equivalent of `Transform` with a translation in `Val`s. `UiGlobalTransform` newtypes `Affine2` and is updated in `ui_layout_system`. `Node` now requires `UiTransform` instead of `Transform`. `UiTransform` requires `UiGlobalTransform`. In previous versions of Bevy `ui_layout_system` would overwrite UI node's `Transform::translation` each frame. `UiTransform`s aren't overwritten and there is no longer any need for systems that cache and rewrite the transform for translated UI elements. `RelativeCursorPosition`'s coordinates are now object-centered with (0,0) at the the center of the node and the corners at (±0.5, ±0.5). Its `normalized_visible_node_rect` field has been removed and replaced with a new `cursor_over: bool` field which is set to true when the cursor is hovering an unclipped area of the UI node. --------- Co-authored-by: Alice Cecile --- Cargo.toml | 11 + crates/bevy_ui/src/accessibility.rs | 9 +- crates/bevy_ui/src/focus.rs | 91 ++---- crates/bevy_ui/src/layout/convert.rs | 6 +- crates/bevy_ui/src/layout/mod.rs | 66 ++-- crates/bevy_ui/src/lib.rs | 3 + crates/bevy_ui/src/picking_backend.rs | 72 +++-- crates/bevy_ui/src/render/box_shadow.rs | 24 +- crates/bevy_ui/src/render/debug_overlay.rs | 8 +- crates/bevy_ui/src/render/gradient.rs | 30 +- crates/bevy_ui/src/render/mod.rs | 100 +++--- .../src/render/ui_material_pipeline.rs | 29 +- .../src/render/ui_texture_slice_pipeline.rs | 19 +- crates/bevy_ui/src/ui_node.rs | 75 ++++- crates/bevy_ui/src/ui_transform.rs | 191 +++++++++++ crates/bevy_ui/src/update.rs | 14 +- examples/README.md | 1 + examples/testbed/full_ui.rs | 8 +- examples/ui/overflow_debug.rs | 30 +- examples/ui/relative_cursor_position.rs | 2 +- examples/ui/ui_transform.rs | 302 ++++++++++++++++++ ...tive_cursor_position_is_object_centered.md | 6 + .../specialized_ui_transform.md | 32 ++ .../release-notes/specialized_ui_transform.md | 7 + 24 files changed, 869 insertions(+), 267 deletions(-) create mode 100644 crates/bevy_ui/src/ui_transform.rs create mode 100644 examples/ui/ui_transform.rs create mode 100644 release-content/migration-guides/relative_cursor_position_is_object_centered.md create mode 100644 release-content/migration-guides/specialized_ui_transform.md create mode 100644 release-content/release-notes/specialized_ui_transform.md diff --git a/Cargo.toml b/Cargo.toml index 000bebe134..703877983c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3543,6 +3543,17 @@ description = "Illustrates how to use 9 Slicing for TextureAtlases in UI" category = "UI (User Interface)" wasm = true +[[example]] +name = "ui_transform" +path = "examples/ui/ui_transform.rs" +doc-scrape-examples = true + +[package.metadata.example.ui_transform] +name = "UI Transform" +description = "An example demonstrating how to translate, rotate and scale UI elements." +category = "UI (User Interface)" +wasm = true + [[example]] name = "viewport_debug" path = "examples/ui/viewport_debug.rs" diff --git a/crates/bevy_ui/src/accessibility.rs b/crates/bevy_ui/src/accessibility.rs index 637ed943f2..78e80717a2 100644 --- a/crates/bevy_ui/src/accessibility.rs +++ b/crates/bevy_ui/src/accessibility.rs @@ -1,6 +1,7 @@ use crate::{ experimental::UiChildren, prelude::{Button, Label}, + ui_transform::UiGlobalTransform, widget::{ImageNode, TextUiReader}, ComputedNode, }; @@ -13,11 +14,9 @@ use bevy_ecs::{ system::{Commands, Query}, world::Ref, }; -use bevy_math::Vec3Swizzles; -use bevy_render::camera::CameraUpdateSystems; -use bevy_transform::prelude::GlobalTransform; use accesskit::{Node, Rect, Role}; +use bevy_render::camera::CameraUpdateSystems; fn calc_label( text_reader: &mut TextUiReader, @@ -40,12 +39,12 @@ fn calc_bounds( mut nodes: Query<( &mut AccessibilityNode, Ref, - Ref, + Ref, )>, ) { for (mut accessible, node, transform) in &mut nodes { if node.is_changed() || transform.is_changed() { - let center = transform.translation().xy(); + let center = transform.translation; let half_size = 0.5 * node.size; let min = center - half_size; let max = center + half_size; diff --git a/crates/bevy_ui/src/focus.rs b/crates/bevy_ui/src/focus.rs index 9242bf1380..f55cbb92b8 100644 --- a/crates/bevy_ui/src/focus.rs +++ b/crates/bevy_ui/src/focus.rs @@ -1,18 +1,21 @@ -use crate::{CalculatedClip, ComputedNode, ComputedNodeTarget, ResolvedBorderRadius, UiStack}; +use crate::{ + picking_backend::clip_check_recursive, ui_transform::UiGlobalTransform, ComputedNode, + ComputedNodeTarget, Node, UiStack, +}; use bevy_ecs::{ change_detection::DetectChangesMut, entity::{ContainsEntity, Entity}, + hierarchy::ChildOf, prelude::{Component, With}, query::QueryData, reflect::ReflectComponent, system::{Local, Query, Res}, }; use bevy_input::{mouse::MouseButton, touch::Touches, ButtonInput}; -use bevy_math::{Rect, Vec2}; +use bevy_math::Vec2; use bevy_platform::collections::HashMap; use bevy_reflect::{std_traits::ReflectDefault, Reflect}; use bevy_render::{camera::NormalizedRenderTarget, prelude::Camera, view::InheritedVisibility}; -use bevy_transform::components::GlobalTransform; use bevy_window::{PrimaryWindow, Window}; use smallvec::SmallVec; @@ -67,12 +70,12 @@ impl Default for Interaction { } } -/// A component storing the position of the mouse relative to the node, (0., 0.) being the top-left corner and (1., 1.) being the bottom-right -/// If the mouse is not over the node, the value will go beyond the range of (0., 0.) to (1., 1.) +/// A component storing the position of the mouse relative to the node, (0., 0.) being the center and (0.5, 0.5) being the bottom-right +/// If the mouse is not over the node, the value will go beyond the range of (-0.5, -0.5) to (0.5, 0.5) /// /// It can be used alongside [`Interaction`] to get the position of the press. /// -/// The component is updated when it is in the same entity with [`Node`](crate::Node). +/// The component is updated when it is in the same entity with [`Node`]. #[derive(Component, Copy, Clone, Default, PartialEq, Debug, Reflect)] #[reflect(Component, Default, PartialEq, Debug, Clone)] #[cfg_attr( @@ -81,8 +84,8 @@ impl Default for Interaction { reflect(Serialize, Deserialize) )] pub struct RelativeCursorPosition { - /// Visible area of the Node relative to the size of the entire Node. - pub normalized_visible_node_rect: Rect, + /// True if the cursor position is over an unclipped area of the Node. + pub cursor_over: bool, /// Cursor position relative to the size and position of the Node. /// A None value indicates that the cursor position is unknown. pub normalized: Option, @@ -90,9 +93,8 @@ pub struct RelativeCursorPosition { impl RelativeCursorPosition { /// A helper function to check if the mouse is over the node - pub fn mouse_over(&self) -> bool { - self.normalized - .is_some_and(|position| self.normalized_visible_node_rect.contains(position)) + pub fn cursor_over(&self) -> bool { + self.cursor_over } } @@ -133,11 +135,10 @@ pub struct State { pub struct NodeQuery { entity: Entity, node: &'static ComputedNode, - global_transform: &'static GlobalTransform, + transform: &'static UiGlobalTransform, interaction: Option<&'static mut Interaction>, relative_cursor_position: Option<&'static mut RelativeCursorPosition>, focus_policy: Option<&'static FocusPolicy>, - calculated_clip: Option<&'static CalculatedClip>, inherited_visibility: Option<&'static InheritedVisibility>, target_camera: &'static ComputedNodeTarget, } @@ -154,6 +155,8 @@ pub fn ui_focus_system( touches_input: Res, ui_stack: Res, mut node_query: Query, + clipping_query: Query<(&ComputedNode, &UiGlobalTransform, &Node)>, + child_of_query: Query<&ChildOf>, ) { let primary_window = primary_window.iter().next(); @@ -234,46 +237,30 @@ pub fn ui_focus_system( } let camera_entity = node.target_camera.camera()?; - let node_rect = Rect::from_center_size( - node.global_transform.translation().truncate(), - node.node.size(), - ); - - // Intersect with the calculated clip rect to find the bounds of the visible region of the node - let visible_rect = node - .calculated_clip - .map(|clip| node_rect.intersect(clip.clip)) - .unwrap_or(node_rect); - let cursor_position = camera_cursor_positions.get(&camera_entity); + let contains_cursor = cursor_position.is_some_and(|point| { + node.node.contains_point(*node.transform, *point) + && clip_check_recursive(*point, *entity, &clipping_query, &child_of_query) + }); + // The mouse position relative to the node - // (0., 0.) is the top-left corner, (1., 1.) is the bottom-right corner + // (-0.5, -0.5) is the top-left corner, (0.5, 0.5) is the bottom-right corner // Coordinates are relative to the entire node, not just the visible region. - let relative_cursor_position = cursor_position.and_then(|cursor_position| { + let normalized_cursor_position = cursor_position.and_then(|cursor_position| { // ensure node size is non-zero in all dimensions, otherwise relative position will be // +/-inf. if the node is hidden, the visible rect min/max will also be -inf leading to // false positives for mouse_over (#12395) - (node_rect.size().cmpgt(Vec2::ZERO).all()) - .then_some((*cursor_position - node_rect.min) / node_rect.size()) + node.node.normalize_point(*node.transform, *cursor_position) }); // If the current cursor position is within the bounds of the node's visible area, consider it for // clicking let relative_cursor_position_component = RelativeCursorPosition { - normalized_visible_node_rect: visible_rect.normalize(node_rect), - normalized: relative_cursor_position, + cursor_over: contains_cursor, + normalized: normalized_cursor_position, }; - let contains_cursor = relative_cursor_position_component.mouse_over() - && cursor_position.is_some_and(|point| { - pick_rounded_rect( - *point - node_rect.center(), - node_rect.size(), - node.node.border_radius, - ) - }); - // Save the relative cursor position to the correct component if let Some(mut node_relative_cursor_position_component) = node.relative_cursor_position { @@ -284,7 +271,8 @@ pub fn ui_focus_system( Some(*entity) } else { if let Some(mut interaction) = node.interaction { - if *interaction == Interaction::Hovered || (relative_cursor_position.is_none()) + if *interaction == Interaction::Hovered + || (normalized_cursor_position.is_none()) { interaction.set_if_neq(Interaction::None); } @@ -334,26 +322,3 @@ pub fn ui_focus_system( } } } - -// Returns true if `point` (relative to the rectangle's center) is within the bounds of a rounded rectangle with -// the given size and border radius. -// -// Matches the sdf function in `ui.wgsl` that is used by the UI renderer to draw rounded rectangles. -pub(crate) fn pick_rounded_rect( - point: Vec2, - size: Vec2, - border_radius: ResolvedBorderRadius, -) -> bool { - let [top, bottom] = if point.x < 0. { - [border_radius.top_left, border_radius.bottom_left] - } else { - [border_radius.top_right, border_radius.bottom_right] - }; - let r = if point.y < 0. { top } else { bottom }; - - let corner_to_point = point.abs() - 0.5 * size; - let q = corner_to_point + r; - let l = q.max(Vec2::ZERO).length(); - let m = q.max_element().min(0.); - l + m - r < 0. -} diff --git a/crates/bevy_ui/src/layout/convert.rs b/crates/bevy_ui/src/layout/convert.rs index 079e73bb49..53c03113b9 100644 --- a/crates/bevy_ui/src/layout/convert.rs +++ b/crates/bevy_ui/src/layout/convert.rs @@ -448,6 +448,8 @@ impl RepeatedGridTrack { #[cfg(test)] mod tests { + use bevy_math::Vec2; + use super::*; #[test] @@ -523,7 +525,7 @@ mod tests { grid_column: GridPlacement::start(4), grid_row: GridPlacement::span(3), }; - let viewport_values = LayoutContext::new(1.0, bevy_math::Vec2::new(800., 600.)); + let viewport_values = LayoutContext::new(1.0, Vec2::new(800., 600.)); let taffy_style = from_node(&node, &viewport_values, false); assert_eq!(taffy_style.display, taffy::style::Display::Flex); assert_eq!(taffy_style.box_sizing, taffy::style::BoxSizing::ContentBox); @@ -661,7 +663,7 @@ mod tests { #[test] fn test_into_length_percentage() { use taffy::style::LengthPercentage; - let context = LayoutContext::new(2.0, bevy_math::Vec2::new(800., 600.)); + let context = LayoutContext::new(2.0, Vec2::new(800., 600.)); let cases = [ (Val::Auto, LengthPercentage::Length(0.)), (Val::Percent(1.), LengthPercentage::Percent(0.01)), diff --git a/crates/bevy_ui/src/layout/mod.rs b/crates/bevy_ui/src/layout/mod.rs index b38241a95a..a5e53d981e 100644 --- a/crates/bevy_ui/src/layout/mod.rs +++ b/crates/bevy_ui/src/layout/mod.rs @@ -1,5 +1,6 @@ use crate::{ experimental::{UiChildren, UiRootNodes}, + ui_transform::{UiGlobalTransform, UiTransform}, BorderRadius, ComputedNode, ComputedNodeTarget, ContentSize, Display, LayoutConfig, Node, Outline, OverflowAxis, ScrollPosition, }; @@ -12,9 +13,9 @@ use bevy_ecs::{ system::{Commands, Query, ResMut}, world::Ref, }; -use bevy_math::Vec2; + +use bevy_math::{Affine2, Vec2}; use bevy_sprite::BorderRect; -use bevy_transform::components::Transform; use thiserror::Error; use tracing::warn; use ui_surface::UiSurface; @@ -81,9 +82,10 @@ pub fn ui_layout_system( )>, computed_node_query: Query<(Entity, Option>), With>, ui_children: UiChildren, - mut node_transform_query: Query<( + mut node_update_query: Query<( &mut ComputedNode, - &mut Transform, + &UiTransform, + &mut UiGlobalTransform, &Node, Option<&LayoutConfig>, Option<&BorderRadius>, @@ -175,7 +177,8 @@ with UI components as a child of an entity without UI components, your UI layout &mut ui_surface, true, computed_target.physical_size().as_vec2(), - &mut node_transform_query, + Affine2::IDENTITY, + &mut node_update_query, &ui_children, computed_target.scale_factor.recip(), Vec2::ZERO, @@ -190,9 +193,11 @@ with UI components as a child of an entity without UI components, your UI layout ui_surface: &mut UiSurface, inherited_use_rounding: bool, target_size: Vec2, - node_transform_query: &mut Query<( + mut inherited_transform: Affine2, + node_update_query: &mut Query<( &mut ComputedNode, - &mut Transform, + &UiTransform, + &mut UiGlobalTransform, &Node, Option<&LayoutConfig>, Option<&BorderRadius>, @@ -206,13 +211,14 @@ with UI components as a child of an entity without UI components, your UI layout ) { if let Ok(( mut node, - mut transform, + transform, + mut global_transform, style, maybe_layout_config, maybe_border_radius, maybe_outline, maybe_scroll_position, - )) = node_transform_query.get_mut(entity) + )) = node_update_query.get_mut(entity) { let use_rounding = maybe_layout_config .map(|layout_config| layout_config.use_rounding) @@ -224,10 +230,11 @@ with UI components as a child of an entity without UI components, your UI layout let layout_size = Vec2::new(layout.size.width, layout.size.height); + // Taffy layout position of the top-left corner of the node, relative to its parent. let layout_location = Vec2::new(layout.location.x, layout.location.y); - // The position of the center of the node, stored in the node's transform - let node_center = + // The position of the center of the node relative to its top-left corner. + let local_center = layout_location - parent_scroll_position + 0.5 * (layout_size - parent_size); // only trigger change detection when the new values are different @@ -253,6 +260,16 @@ with UI components as a child of an entity without UI components, your UI layout node.bypass_change_detection().border = taffy_rect_to_border_rect(layout.border); node.bypass_change_detection().padding = taffy_rect_to_border_rect(layout.padding); + // Computer the node's new global transform + let mut local_transform = + transform.compute_affine(inverse_target_scale_factor, layout_size, target_size); + local_transform.translation += local_center; + inherited_transform *= local_transform; + + if inherited_transform != **global_transform { + *global_transform = inherited_transform.into(); + } + if let Some(border_radius) = maybe_border_radius { // We don't trigger change detection for changes to border radius node.bypass_change_detection().border_radius = border_radius.resolve( @@ -290,10 +307,6 @@ with UI components as a child of an entity without UI components, your UI layout .max(0.); } - if transform.translation.truncate() != node_center { - transform.translation = node_center.extend(0.); - } - let scroll_position: Vec2 = maybe_scroll_position .map(|scroll_pos| { Vec2::new( @@ -333,7 +346,8 @@ with UI components as a child of an entity without UI components, your UI layout ui_surface, use_rounding, target_size, - node_transform_query, + inherited_transform, + node_update_query, ui_children, inverse_target_scale_factor, layout_size, @@ -356,10 +370,7 @@ mod tests { use bevy_platform::collections::HashMap; use bevy_render::{camera::ManualTextureViews, prelude::Camera}; use bevy_transform::systems::mark_dirty_trees; - use bevy_transform::{ - prelude::GlobalTransform, - systems::{propagate_parent_transforms, sync_simple_transforms}, - }; + use bevy_transform::systems::{propagate_parent_transforms, sync_simple_transforms}; use bevy_utils::prelude::default; use bevy_window::{ PrimaryWindow, Window, WindowCreated, WindowResized, WindowResolution, @@ -684,23 +695,20 @@ mod tests { ui_schedule.run(&mut world); let overlap_check = world - .query_filtered::<(Entity, &ComputedNode, &GlobalTransform), Without>() + .query_filtered::<(Entity, &ComputedNode, &UiGlobalTransform), Without>() .iter(&world) .fold( Option::<(Rect, bool)>::None, - |option_rect, (entity, node, global_transform)| { - let current_rect = Rect::from_center_size( - global_transform.translation().truncate(), - node.size(), - ); + |option_rect, (entity, node, transform)| { + let current_rect = Rect::from_center_size(transform.translation, node.size()); assert!( current_rect.height().abs() + current_rect.width().abs() > 0., "root ui node {entity} doesn't have a logical size" ); assert_ne!( - global_transform.affine(), - GlobalTransform::default().affine(), - "root ui node {entity} global transform is not populated" + *transform, + UiGlobalTransform::default(), + "root ui node {entity} transform is not populated" ); let Some((rect, is_overlapping)) = option_rect else { return Some((current_rect, false)); diff --git a/crates/bevy_ui/src/lib.rs b/crates/bevy_ui/src/lib.rs index 4ce6359206..be0649c038 100644 --- a/crates/bevy_ui/src/lib.rs +++ b/crates/bevy_ui/src/lib.rs @@ -18,6 +18,7 @@ pub mod widget; pub mod gradients; #[cfg(feature = "bevy_ui_picking_backend")] pub mod picking_backend; +pub mod ui_transform; use bevy_derive::{Deref, DerefMut}; #[cfg(feature = "bevy_ui_picking_backend")] @@ -42,6 +43,7 @@ pub use measurement::*; pub use render::*; pub use ui_material::*; pub use ui_node::*; +pub use ui_transform::*; use widget::{ImageNode, ImageNodeSize, ViewportNode}; @@ -64,6 +66,7 @@ pub mod prelude { gradients::*, ui_material::*, ui_node::*, + ui_transform::*, widget::{Button, ImageNode, Label, NodeImageMode, ViewportNode}, Interaction, MaterialNode, UiMaterialPlugin, UiScale, }, diff --git a/crates/bevy_ui/src/picking_backend.rs b/crates/bevy_ui/src/picking_backend.rs index 26b84c6005..5647baee12 100644 --- a/crates/bevy_ui/src/picking_backend.rs +++ b/crates/bevy_ui/src/picking_backend.rs @@ -24,14 +24,13 @@ #![deny(missing_docs)] -use crate::{focus::pick_rounded_rect, prelude::*, UiStack}; +use crate::{prelude::*, ui_transform::UiGlobalTransform, UiStack}; use bevy_app::prelude::*; use bevy_ecs::{prelude::*, query::QueryData}; -use bevy_math::{Rect, Vec2}; +use bevy_math::Vec2; use bevy_platform::collections::HashMap; use bevy_reflect::{std_traits::ReflectDefault, Reflect}; use bevy_render::prelude::*; -use bevy_transform::prelude::*; use bevy_window::PrimaryWindow; use bevy_picking::backend::prelude::*; @@ -91,9 +90,8 @@ impl Plugin for UiPickingPlugin { pub struct NodeQuery { entity: Entity, node: &'static ComputedNode, - global_transform: &'static GlobalTransform, + transform: &'static UiGlobalTransform, pickable: Option<&'static Pickable>, - calculated_clip: Option<&'static CalculatedClip>, inherited_visibility: Option<&'static InheritedVisibility>, target_camera: &'static ComputedNodeTarget, } @@ -110,6 +108,8 @@ pub fn ui_picking( ui_stack: Res, node_query: Query, mut output: EventWriter, + clipping_query: Query<(&ComputedNode, &UiGlobalTransform, &Node)>, + child_of_query: Query<&ChildOf>, ) { // For each camera, the pointer and its position let mut pointer_pos_by_camera = HashMap::>::default(); @@ -181,43 +181,33 @@ pub fn ui_picking( continue; }; - let node_rect = Rect::from_center_size( - node.global_transform.translation().truncate(), - node.node.size(), - ); - // Nodes with Display::None have a (0., 0.) logical rect and can be ignored - if node_rect.size() == Vec2::ZERO { + if node.node.size() == Vec2::ZERO { continue; } - // Intersect with the calculated clip rect to find the bounds of the visible region of the node - let visible_rect = node - .calculated_clip - .map(|clip| node_rect.intersect(clip.clip)) - .unwrap_or(node_rect); - let pointers_on_this_cam = pointer_pos_by_camera.get(&camera_entity); - // The mouse position relative to the node - // (0., 0.) is the top-left corner, (1., 1.) is the bottom-right corner + // Find the normalized cursor position relative to the node. + // (±0., 0.) is the center with the corners at points (±0.5, ±0.5). // Coordinates are relative to the entire node, not just the visible region. for (pointer_id, cursor_position) in pointers_on_this_cam.iter().flat_map(|h| h.iter()) { - let relative_cursor_position = (*cursor_position - node_rect.min) / node_rect.size(); - - if visible_rect - .normalize(node_rect) - .contains(relative_cursor_position) - && pick_rounded_rect( - *cursor_position - node_rect.center(), - node_rect.size(), - node.node.border_radius, + if node.node.contains_point(*node.transform, *cursor_position) + && clip_check_recursive( + *cursor_position, + *node_entity, + &clipping_query, + &child_of_query, ) { hit_nodes .entry((camera_entity, *pointer_id)) .or_default() - .push((*node_entity, relative_cursor_position)); + .push(( + *node_entity, + node.transform.inverse().transform_point2(*cursor_position) + / node.node.size(), + )); } } } @@ -262,3 +252,27 @@ pub fn ui_picking( output.write(PointerHits::new(*pointer, picks, order)); } } + +/// Walk up the tree child-to-parent checking that `point` is not clipped by any ancestor node. +pub fn clip_check_recursive( + point: Vec2, + entity: Entity, + clipping_query: &Query<'_, '_, (&ComputedNode, &UiGlobalTransform, &Node)>, + child_of_query: &Query<&ChildOf>, +) -> bool { + if let Ok(child_of) = child_of_query.get(entity) { + let parent = child_of.0; + if let Ok((computed_node, transform, node)) = clipping_query.get(parent) { + if !computed_node + .resolve_clip_rect(node.overflow, node.overflow_clip_margin) + .contains(transform.inverse().transform_point2(point)) + { + // The point is clipped and should be ignored by picking + return false; + } + } + return clip_check_recursive(point, parent, clipping_query, child_of_query); + } + // Reached root, point unclipped by all ancestors + true +} diff --git a/crates/bevy_ui/src/render/box_shadow.rs b/crates/bevy_ui/src/render/box_shadow.rs index 1c2b2c7d0a..b6f3f3501e 100644 --- a/crates/bevy_ui/src/render/box_shadow.rs +++ b/crates/bevy_ui/src/render/box_shadow.rs @@ -2,6 +2,7 @@ use core::{hash::Hash, ops::Range}; +use crate::prelude::UiGlobalTransform; use crate::{ BoxShadow, BoxShadowSamples, CalculatedClip, ComputedNode, ComputedNodeTarget, RenderUiSystems, ResolvedBorderRadius, TransparentUi, Val, @@ -18,7 +19,7 @@ use bevy_ecs::{ }, }; use bevy_image::BevyDefault as _; -use bevy_math::{vec2, FloatOrd, Mat4, Rect, Vec2, Vec3Swizzles, Vec4Swizzles}; +use bevy_math::{vec2, Affine2, FloatOrd, Rect, Vec2}; use bevy_render::sync_world::MainEntity; use bevy_render::RenderApp; use bevy_render::{ @@ -29,7 +30,6 @@ use bevy_render::{ view::*, Extract, ExtractSchedule, Render, RenderSystems, }; -use bevy_transform::prelude::GlobalTransform; use bytemuck::{Pod, Zeroable}; use super::{stack_z_offsets, UiCameraMap, UiCameraView, QUAD_INDICES, QUAD_VERTEX_POSITIONS}; @@ -211,7 +211,7 @@ impl SpecializedRenderPipeline for BoxShadowPipeline { /// Description of a shadow to be sorted and queued for rendering pub struct ExtractedBoxShadow { pub stack_index: u32, - pub transform: Mat4, + pub transform: Affine2, pub bounds: Vec2, pub clip: Option, pub extracted_camera_entity: Entity, @@ -236,7 +236,7 @@ pub fn extract_shadows( Query<( Entity, &ComputedNode, - &GlobalTransform, + &UiGlobalTransform, &InheritedVisibility, &BoxShadow, Option<&CalculatedClip>, @@ -302,7 +302,7 @@ pub fn extract_shadows( extracted_box_shadows.box_shadows.push(ExtractedBoxShadow { render_entity: commands.spawn(TemporaryRenderEntity).id(), stack_index: uinode.stack_index, - transform: transform.compute_matrix() * Mat4::from_translation(offset.extend(0.)), + transform: Affine2::from(transform) * Affine2::from_translation(offset), color: drop_shadow.color.into(), bounds: shadow_size + 6. * blur_radius, clip: clip.map(|clip| clip.clip), @@ -405,11 +405,15 @@ pub fn prepare_shadows( .get(item.index) .filter(|n| item.entity() == n.render_entity) { - let rect_size = box_shadow.bounds.extend(1.0); + let rect_size = box_shadow.bounds; // Specify the corners of the node - let positions = QUAD_VERTEX_POSITIONS - .map(|pos| (box_shadow.transform * (pos * rect_size).extend(1.)).xyz()); + let positions = QUAD_VERTEX_POSITIONS.map(|pos| { + box_shadow + .transform + .transform_point2(pos * rect_size) + .extend(0.) + }); // Calculate the effect of clipping // Note: this won't work with rotation/scaling, but that's much more complex (may need more that 2 quads) @@ -443,7 +447,7 @@ pub fn prepare_shadows( positions[3] + positions_diff[3].extend(0.), ]; - let transformed_rect_size = box_shadow.transform.transform_vector3(rect_size); + let transformed_rect_size = box_shadow.transform.transform_vector2(rect_size); // Don't try to cull nodes that have a rotation // In a rotation around the Z-axis, this value is 0.0 for an angle of 0.0 or π @@ -492,7 +496,7 @@ pub fn prepare_shadows( size: box_shadow.size.into(), radius, blur: box_shadow.blur_radius, - bounds: rect_size.xy().into(), + bounds: rect_size.into(), }); } diff --git a/crates/bevy_ui/src/render/debug_overlay.rs b/crates/bevy_ui/src/render/debug_overlay.rs index aa9440b8d8..c3ada22c2e 100644 --- a/crates/bevy_ui/src/render/debug_overlay.rs +++ b/crates/bevy_ui/src/render/debug_overlay.rs @@ -1,5 +1,6 @@ use crate::shader_flags; use crate::ui_node::ComputedNodeTarget; +use crate::ui_transform::UiGlobalTransform; use crate::CalculatedClip; use crate::ComputedNode; use bevy_asset::AssetId; @@ -16,7 +17,6 @@ use bevy_render::sync_world::TemporaryRenderEntity; use bevy_render::view::InheritedVisibility; use bevy_render::Extract; use bevy_sprite::BorderRect; -use bevy_transform::components::GlobalTransform; use super::ExtractedUiItem; use super::ExtractedUiNode; @@ -62,9 +62,9 @@ pub fn extract_debug_overlay( Query<( Entity, &ComputedNode, + &UiGlobalTransform, &InheritedVisibility, Option<&CalculatedClip>, - &GlobalTransform, &ComputedNodeTarget, )>, >, @@ -76,7 +76,7 @@ pub fn extract_debug_overlay( let mut camera_mapper = camera_map.get_mapper(); - for (entity, uinode, visibility, maybe_clip, transform, computed_target) in &uinode_query { + for (entity, uinode, transform, visibility, maybe_clip, computed_target) in &uinode_query { if !debug_options.show_hidden && !visibility.get() { continue; } @@ -102,7 +102,7 @@ pub fn extract_debug_overlay( extracted_camera_entity, item: ExtractedUiItem::Node { atlas_scaling: None, - transform: transform.compute_matrix(), + transform: transform.into(), flip_x: false, flip_y: false, border: BorderRect::all(debug_options.line_width / uinode.inverse_scale_factor()), diff --git a/crates/bevy_ui/src/render/gradient.rs b/crates/bevy_ui/src/render/gradient.rs index 31369899f7..bd818c7d5b 100644 --- a/crates/bevy_ui/src/render/gradient.rs +++ b/crates/bevy_ui/src/render/gradient.rs @@ -17,8 +17,9 @@ use bevy_ecs::{ use bevy_image::prelude::*; use bevy_math::{ ops::{cos, sin}, - FloatOrd, Mat4, Rect, Vec2, Vec3Swizzles, Vec4Swizzles, + FloatOrd, Rect, Vec2, }; +use bevy_math::{Affine2, Vec2Swizzles}; use bevy_render::sync_world::MainEntity; use bevy_render::{ render_phase::*, @@ -29,7 +30,6 @@ use bevy_render::{ Extract, ExtractSchedule, Render, RenderSystems, }; use bevy_sprite::BorderRect; -use bevy_transform::prelude::GlobalTransform; use bytemuck::{Pod, Zeroable}; use super::shader_flags::BORDER_ALL; @@ -238,7 +238,7 @@ pub enum ResolvedGradient { pub struct ExtractedGradient { pub stack_index: u32, - pub transform: Mat4, + pub transform: Affine2, pub rect: Rect, pub clip: Option, pub extracted_camera_entity: Entity, @@ -354,7 +354,7 @@ pub fn extract_gradients( Entity, &ComputedNode, &ComputedNodeTarget, - &GlobalTransform, + &UiGlobalTransform, &InheritedVisibility, Option<&CalculatedClip>, AnyOf<(&BackgroundGradient, &BorderGradient)>, @@ -414,7 +414,7 @@ pub fn extract_gradients( border_radius: uinode.border_radius, border: uinode.border, node_type, - transform: transform.compute_matrix(), + transform: transform.into(), }, main_entity: entity.into(), render_entity: commands.spawn(TemporaryRenderEntity).id(), @@ -439,7 +439,7 @@ pub fn extract_gradients( extracted_gradients.items.push(ExtractedGradient { render_entity: commands.spawn(TemporaryRenderEntity).id(), stack_index: uinode.stack_index, - transform: transform.compute_matrix(), + transform: transform.into(), stops_range: range_start..extracted_color_stops.0.len(), rect: Rect { min: Vec2::ZERO, @@ -487,7 +487,7 @@ pub fn extract_gradients( extracted_gradients.items.push(ExtractedGradient { render_entity: commands.spawn(TemporaryRenderEntity).id(), stack_index: uinode.stack_index, - transform: transform.compute_matrix(), + transform: transform.into(), stops_range: range_start..extracted_color_stops.0.len(), rect: Rect { min: Vec2::ZERO, @@ -541,7 +541,7 @@ pub fn extract_gradients( extracted_gradients.items.push(ExtractedGradient { render_entity: commands.spawn(TemporaryRenderEntity).id(), stack_index: uinode.stack_index, - transform: transform.compute_matrix(), + transform: transform.into(), stops_range: range_start..extracted_color_stops.0.len(), rect: Rect { min: Vec2::ZERO, @@ -675,12 +675,16 @@ pub fn prepare_gradient( *item.batch_range_mut() = item_index as u32..item_index as u32 + 1; let uinode_rect = gradient.rect; - let rect_size = uinode_rect.size().extend(1.0); + let rect_size = uinode_rect.size(); // Specify the corners of the node - let positions = QUAD_VERTEX_POSITIONS - .map(|pos| (gradient.transform * (pos * rect_size).extend(1.)).xyz()); - let corner_points = QUAD_VERTEX_POSITIONS.map(|pos| pos.xy() * rect_size.xy()); + let positions = QUAD_VERTEX_POSITIONS.map(|pos| { + gradient + .transform + .transform_point2(pos * rect_size) + .extend(0.) + }); + let corner_points = QUAD_VERTEX_POSITIONS.map(|pos| pos * rect_size); // Calculate the effect of clipping // Note: this won't work with rotation/scaling, but that's much more complex (may need more that 2 quads) @@ -721,7 +725,7 @@ pub fn prepare_gradient( corner_points[3] + positions_diff[3], ]; - let transformed_rect_size = gradient.transform.transform_vector3(rect_size); + let transformed_rect_size = gradient.transform.transform_vector2(rect_size); // Don't try to cull nodes that have a rotation // In a rotation around the Z-axis, this value is 0.0 for an angle of 0.0 or π diff --git a/crates/bevy_ui/src/render/mod.rs b/crates/bevy_ui/src/render/mod.rs index 83140d0f3b..61319eda9b 100644 --- a/crates/bevy_ui/src/render/mod.rs +++ b/crates/bevy_ui/src/render/mod.rs @@ -8,7 +8,9 @@ pub mod ui_texture_slice_pipeline; mod debug_overlay; mod gradient; +use crate::prelude::UiGlobalTransform; use crate::widget::{ImageNode, ViewportNode}; + use crate::{ BackgroundColor, BorderColor, BoxShadowSamples, CalculatedClip, ComputedNode, ComputedNodeTarget, Outline, ResolvedBorderRadius, TextShadow, UiAntiAlias, @@ -22,7 +24,7 @@ use bevy_core_pipeline::{core_2d::Camera2d, core_3d::Camera3d}; use bevy_ecs::prelude::*; use bevy_ecs::system::SystemParam; use bevy_image::prelude::*; -use bevy_math::{FloatOrd, Mat4, Rect, UVec4, Vec2, Vec3, Vec3Swizzles, Vec4Swizzles}; +use bevy_math::{Affine2, FloatOrd, Mat4, Rect, UVec4, Vec2}; use bevy_render::load_shader_library; use bevy_render::render_graph::{NodeRunError, RenderGraphContext}; use bevy_render::render_phase::ViewSortedRenderPhases; @@ -243,7 +245,7 @@ pub enum ExtractedUiItem { /// Ordering: left, top, right, bottom. border: BorderRect, node_type: NodeType, - transform: Mat4, + transform: Affine2, }, /// A contiguous sequence of text glyphs from the same section Glyphs { @@ -253,7 +255,7 @@ pub enum ExtractedUiItem { } pub struct ExtractedGlyph { - pub transform: Mat4, + pub transform: Affine2, pub rect: Rect, } @@ -344,7 +346,7 @@ pub fn extract_uinode_background_colors( Query<( Entity, &ComputedNode, - &GlobalTransform, + &UiGlobalTransform, &InheritedVisibility, Option<&CalculatedClip>, &ComputedNodeTarget, @@ -383,7 +385,7 @@ pub fn extract_uinode_background_colors( extracted_camera_entity, item: ExtractedUiItem::Node { atlas_scaling: None, - transform: transform.compute_matrix(), + transform: transform.into(), flip_x: false, flip_y: false, border: uinode.border(), @@ -403,7 +405,7 @@ pub fn extract_uinode_images( Query<( Entity, &ComputedNode, - &GlobalTransform, + &UiGlobalTransform, &InheritedVisibility, Option<&CalculatedClip>, &ComputedNodeTarget, @@ -467,7 +469,7 @@ pub fn extract_uinode_images( extracted_camera_entity, item: ExtractedUiItem::Node { atlas_scaling, - transform: transform.compute_matrix(), + transform: transform.into(), flip_x: image.flip_x, flip_y: image.flip_y, border: uinode.border, @@ -487,7 +489,7 @@ pub fn extract_uinode_borders( Entity, &Node, &ComputedNode, - &GlobalTransform, + &UiGlobalTransform, &InheritedVisibility, Option<&CalculatedClip>, &ComputedNodeTarget, @@ -503,7 +505,7 @@ pub fn extract_uinode_borders( entity, node, computed_node, - global_transform, + transform, inherited_visibility, maybe_clip, camera, @@ -567,7 +569,7 @@ pub fn extract_uinode_borders( extracted_camera_entity, item: ExtractedUiItem::Node { atlas_scaling: None, - transform: global_transform.compute_matrix(), + transform: transform.into(), flip_x: false, flip_y: false, border: computed_node.border(), @@ -600,7 +602,7 @@ pub fn extract_uinode_borders( clip: maybe_clip.map(|clip| clip.clip), extracted_camera_entity, item: ExtractedUiItem::Node { - transform: global_transform.compute_matrix(), + transform: transform.into(), atlas_scaling: None, flip_x: false, flip_y: false, @@ -749,7 +751,7 @@ pub fn extract_viewport_nodes( Query<( Entity, &ComputedNode, - &GlobalTransform, + &UiGlobalTransform, &InheritedVisibility, Option<&CalculatedClip>, &ComputedNodeTarget, @@ -792,7 +794,7 @@ pub fn extract_viewport_nodes( extracted_camera_entity, item: ExtractedUiItem::Node { atlas_scaling: None, - transform: transform.compute_matrix(), + transform: transform.into(), flip_x: false, flip_y: false, border: uinode.border(), @@ -812,7 +814,7 @@ pub fn extract_text_sections( Query<( Entity, &ComputedNode, - &GlobalTransform, + &UiGlobalTransform, &InheritedVisibility, Option<&CalculatedClip>, &ComputedNodeTarget, @@ -830,7 +832,7 @@ pub fn extract_text_sections( for ( entity, uinode, - global_transform, + transform, inherited_visibility, clip, camera, @@ -847,8 +849,7 @@ pub fn extract_text_sections( continue; }; - let transform = global_transform.affine() - * bevy_math::Affine3A::from_translation((-0.5 * uinode.size()).extend(0.)); + let transform = Affine2::from(*transform) * Affine2::from_translation(-0.5 * uinode.size()); for ( i, @@ -866,7 +867,7 @@ pub fn extract_text_sections( .textures[atlas_info.location.glyph_index] .as_rect(); extracted_uinodes.glyphs.push(ExtractedGlyph { - transform: transform * Mat4::from_translation(position.extend(0.)), + transform: transform * Affine2::from_translation(*position), rect, }); @@ -910,8 +911,8 @@ pub fn extract_text_shadows( Query<( Entity, &ComputedNode, + &UiGlobalTransform, &ComputedNodeTarget, - &GlobalTransform, &InheritedVisibility, Option<&CalculatedClip>, &TextLayoutInfo, @@ -924,16 +925,8 @@ pub fn extract_text_shadows( let mut end = start + 1; let mut camera_mapper = camera_map.get_mapper(); - for ( - entity, - uinode, - target, - global_transform, - inherited_visibility, - clip, - text_layout_info, - shadow, - ) in &uinode_query + for (entity, uinode, transform, target, inherited_visibility, clip, text_layout_info, shadow) in + &uinode_query { // Skip if not visible or if size is set to zero (e.g. when a parent is set to `Display::None`) if !inherited_visibility.get() || uinode.is_empty() { @@ -944,9 +937,9 @@ pub fn extract_text_shadows( continue; }; - let transform = global_transform.affine() - * Mat4::from_translation( - (-0.5 * uinode.size() + shadow.offset / uinode.inverse_scale_factor()).extend(0.), + let node_transform = Affine2::from(*transform) + * Affine2::from_translation( + -0.5 * uinode.size() + shadow.offset / uinode.inverse_scale_factor(), ); for ( @@ -965,7 +958,7 @@ pub fn extract_text_shadows( .textures[atlas_info.location.glyph_index] .as_rect(); extracted_uinodes.glyphs.push(ExtractedGlyph { - transform: transform * Mat4::from_translation(position.extend(0.)), + transform: node_transform * Affine2::from_translation(*position), rect, }); @@ -998,7 +991,7 @@ pub fn extract_text_background_colors( Query<( Entity, &ComputedNode, - &GlobalTransform, + &UiGlobalTransform, &InheritedVisibility, Option<&CalculatedClip>, &ComputedNodeTarget, @@ -1021,8 +1014,8 @@ pub fn extract_text_background_colors( continue; }; - let transform = global_transform.affine() - * bevy_math::Affine3A::from_translation(-0.5 * uinode.size().extend(0.)); + let transform = + Affine2::from(global_transform) * Affine2::from_translation(-0.5 * uinode.size()); for &(section_entity, rect) in text_layout_info.section_rects.iter() { let Ok(text_background_color) = text_background_colors_query.get(section_entity) else { @@ -1042,7 +1035,7 @@ pub fn extract_text_background_colors( extracted_camera_entity, item: ExtractedUiItem::Node { atlas_scaling: None, - transform: transform * Mat4::from_translation(rect.center().extend(0.)), + transform: transform * Affine2::from_translation(rect.center()), flip_x: false, flip_y: false, border: uinode.border(), @@ -1093,11 +1086,11 @@ impl Default for UiMeta { } } -pub(crate) const QUAD_VERTEX_POSITIONS: [Vec3; 4] = [ - Vec3::new(-0.5, -0.5, 0.0), - Vec3::new(0.5, -0.5, 0.0), - Vec3::new(0.5, 0.5, 0.0), - Vec3::new(-0.5, 0.5, 0.0), +pub(crate) const QUAD_VERTEX_POSITIONS: [Vec2; 4] = [ + Vec2::new(-0.5, -0.5), + Vec2::new(0.5, -0.5), + Vec2::new(0.5, 0.5), + Vec2::new(-0.5, 0.5), ]; pub(crate) const QUAD_INDICES: [usize; 6] = [0, 2, 3, 0, 1, 2]; @@ -1321,12 +1314,12 @@ pub fn prepare_uinodes( let mut uinode_rect = extracted_uinode.rect; - let rect_size = uinode_rect.size().extend(1.0); + let rect_size = uinode_rect.size(); // Specify the corners of the node let positions = QUAD_VERTEX_POSITIONS - .map(|pos| (*transform * (pos * rect_size).extend(1.)).xyz()); - let points = QUAD_VERTEX_POSITIONS.map(|pos| pos.xy() * rect_size.xy()); + .map(|pos| transform.transform_point2(pos * rect_size).extend(0.)); + let points = QUAD_VERTEX_POSITIONS.map(|pos| pos * rect_size); // Calculate the effect of clipping // Note: this won't work with rotation/scaling, but that's much more complex (may need more that 2 quads) @@ -1367,7 +1360,7 @@ pub fn prepare_uinodes( points[3] + positions_diff[3], ]; - let transformed_rect_size = transform.transform_vector3(rect_size); + let transformed_rect_size = transform.transform_vector2(rect_size); // Don't try to cull nodes that have a rotation // In a rotation around the Z-axis, this value is 0.0 for an angle of 0.0 or π @@ -1448,7 +1441,7 @@ pub fn prepare_uinodes( border_radius.bottom_left, ], border: [border.left, border.top, border.right, border.bottom], - size: rect_size.xy().into(), + size: rect_size.into(), point: points[i].into(), }); } @@ -1470,13 +1463,14 @@ pub fn prepare_uinodes( let color = extracted_uinode.color.to_f32_array(); for glyph in &extracted_uinodes.glyphs[range.clone()] { let glyph_rect = glyph.rect; - let size = glyph.rect.size(); - - let rect_size = glyph_rect.size().extend(1.0); + let rect_size = glyph_rect.size(); // Specify the corners of the glyph let positions = QUAD_VERTEX_POSITIONS.map(|pos| { - (glyph.transform * (pos * rect_size).extend(1.)).xyz() + glyph + .transform + .transform_point2(pos * glyph_rect.size()) + .extend(0.) }); let positions_diff = if let Some(clip) = extracted_uinode.clip { @@ -1511,7 +1505,7 @@ pub fn prepare_uinodes( // cull nodes that are completely clipped let transformed_rect_size = - glyph.transform.transform_vector3(rect_size); + glyph.transform.transform_vector2(rect_size); if positions_diff[0].x - positions_diff[1].x >= transformed_rect_size.x.abs() || positions_diff[1].y - positions_diff[2].y @@ -1548,7 +1542,7 @@ pub fn prepare_uinodes( flags: shader_flags::TEXTURED | shader_flags::CORNERS[i], radius: [0.0; 4], border: [0.0; 4], - size: size.into(), + size: rect_size.into(), point: [0.0; 2], }); } diff --git a/crates/bevy_ui/src/render/ui_material_pipeline.rs b/crates/bevy_ui/src/render/ui_material_pipeline.rs index 02fab4fdee..ebdeacccf9 100644 --- a/crates/bevy_ui/src/render/ui_material_pipeline.rs +++ b/crates/bevy_ui/src/render/ui_material_pipeline.rs @@ -1,9 +1,7 @@ -use core::{hash::Hash, marker::PhantomData, ops::Range}; - use crate::*; use bevy_asset::*; use bevy_ecs::{ - prelude::Component, + prelude::{Component, With}, query::ROQueryItem, system::{ lifetimeless::{Read, SRes}, @@ -11,24 +9,22 @@ use bevy_ecs::{ }, }; use bevy_image::BevyDefault as _; -use bevy_math::{FloatOrd, Mat4, Rect, Vec2, Vec4Swizzles}; +use bevy_math::{Affine2, FloatOrd, Rect, Vec2}; use bevy_render::{ extract_component::ExtractComponentPlugin, globals::{GlobalsBuffer, GlobalsUniform}, + load_shader_library, render_asset::{PrepareAssetError, RenderAsset, RenderAssetPlugin, RenderAssets}, render_phase::*, render_resource::{binding_types::uniform_buffer, *}, renderer::{RenderDevice, RenderQueue}, + sync_world::{MainEntity, TemporaryRenderEntity}, view::*, Extract, ExtractSchedule, Render, RenderSystems, }; -use bevy_render::{ - load_shader_library, - sync_world::{MainEntity, TemporaryRenderEntity}, -}; use bevy_sprite::BorderRect; -use bevy_transform::prelude::GlobalTransform; use bytemuck::{Pod, Zeroable}; +use core::{hash::Hash, marker::PhantomData, ops::Range}; /// Adds the necessary ECS resources and render logic to enable rendering entities using the given /// [`UiMaterial`] asset type (which includes [`UiMaterial`] types). @@ -321,7 +317,7 @@ impl RenderCommand

for DrawUiMaterialNode { pub struct ExtractedUiMaterialNode { pub stack_index: u32, - pub transform: Mat4, + pub transform: Affine2, pub rect: Rect, pub border: BorderRect, pub border_radius: ResolvedBorderRadius, @@ -356,7 +352,7 @@ pub fn extract_ui_material_nodes( Query<( Entity, &ComputedNode, - &GlobalTransform, + &UiGlobalTransform, &MaterialNode, &InheritedVisibility, Option<&CalculatedClip>, @@ -387,7 +383,7 @@ pub fn extract_ui_material_nodes( extracted_uinodes.uinodes.push(ExtractedUiMaterialNode { render_entity: commands.spawn(TemporaryRenderEntity).id(), stack_index: computed_node.stack_index, - transform: transform.compute_matrix(), + transform: transform.into(), material: handle.id(), rect: Rect { min: Vec2::ZERO, @@ -459,10 +455,13 @@ pub fn prepare_uimaterial_nodes( let uinode_rect = extracted_uinode.rect; - let rect_size = uinode_rect.size().extend(1.0); + let rect_size = uinode_rect.size(); let positions = QUAD_VERTEX_POSITIONS.map(|pos| { - (extracted_uinode.transform * (pos * rect_size).extend(1.0)).xyz() + extracted_uinode + .transform + .transform_point2(pos * rect_size) + .extend(1.0) }); let positions_diff = if let Some(clip) = extracted_uinode.clip { @@ -496,7 +495,7 @@ pub fn prepare_uimaterial_nodes( ]; let transformed_rect_size = - extracted_uinode.transform.transform_vector3(rect_size); + extracted_uinode.transform.transform_vector2(rect_size); // Don't try to cull nodes that have a rotation // In a rotation around the Z-axis, this value is 0.0 for an angle of 0.0 or π diff --git a/crates/bevy_ui/src/render/ui_texture_slice_pipeline.rs b/crates/bevy_ui/src/render/ui_texture_slice_pipeline.rs index 80a55bbcd4..0e232ab1cc 100644 --- a/crates/bevy_ui/src/render/ui_texture_slice_pipeline.rs +++ b/crates/bevy_ui/src/render/ui_texture_slice_pipeline.rs @@ -1,5 +1,6 @@ use core::{hash::Hash, ops::Range}; +use crate::prelude::UiGlobalTransform; use crate::*; use bevy_asset::*; use bevy_color::{Alpha, ColorToComponents, LinearRgba}; @@ -11,7 +12,7 @@ use bevy_ecs::{ }, }; use bevy_image::prelude::*; -use bevy_math::{FloatOrd, Mat4, Rect, Vec2, Vec4Swizzles}; +use bevy_math::{Affine2, FloatOrd, Rect, Vec2}; use bevy_platform::collections::HashMap; use bevy_render::sync_world::MainEntity; use bevy_render::{ @@ -25,7 +26,6 @@ use bevy_render::{ Extract, ExtractSchedule, Render, RenderSystems, }; use bevy_sprite::{SliceScaleMode, SpriteAssetEvents, SpriteImageMode, TextureSlicer}; -use bevy_transform::prelude::GlobalTransform; use binding_types::{sampler, texture_2d}; use bytemuck::{Pod, Zeroable}; use widget::ImageNode; @@ -218,7 +218,7 @@ impl SpecializedRenderPipeline for UiTextureSlicePipeline { pub struct ExtractedUiTextureSlice { pub stack_index: u32, - pub transform: Mat4, + pub transform: Affine2, pub rect: Rect, pub atlas_rect: Option, pub image: AssetId, @@ -246,7 +246,7 @@ pub fn extract_ui_texture_slices( Query<( Entity, &ComputedNode, - &GlobalTransform, + &UiGlobalTransform, &InheritedVisibility, Option<&CalculatedClip>, &ComputedNodeTarget, @@ -306,7 +306,7 @@ pub fn extract_ui_texture_slices( extracted_ui_slicers.slices.push(ExtractedUiTextureSlice { render_entity: commands.spawn(TemporaryRenderEntity).id(), stack_index: uinode.stack_index, - transform: transform.compute_matrix(), + transform: transform.into(), color: image.color.into(), rect: Rect { min: Vec2::ZERO, @@ -497,11 +497,12 @@ pub fn prepare_ui_slices( let uinode_rect = texture_slices.rect; - let rect_size = uinode_rect.size().extend(1.0); + let rect_size = uinode_rect.size(); // Specify the corners of the node - let positions = QUAD_VERTEX_POSITIONS - .map(|pos| (texture_slices.transform * (pos * rect_size).extend(1.)).xyz()); + let positions = QUAD_VERTEX_POSITIONS.map(|pos| { + (texture_slices.transform.transform_point2(pos * rect_size)).extend(0.) + }); // Calculate the effect of clipping // Note: this won't work with rotation/scaling, but that's much more complex (may need more that 2 quads) @@ -536,7 +537,7 @@ pub fn prepare_ui_slices( ]; let transformed_rect_size = - texture_slices.transform.transform_vector3(rect_size); + texture_slices.transform.transform_vector2(rect_size); // Don't try to cull nodes that have a rotation // In a rotation around the Z-axis, this value is 0.0 for an angle of 0.0 or π diff --git a/crates/bevy_ui/src/ui_node.rs b/crates/bevy_ui/src/ui_node.rs index f5f914bdc0..343d8af3cd 100644 --- a/crates/bevy_ui/src/ui_node.rs +++ b/crates/bevy_ui/src/ui_node.rs @@ -1,4 +1,7 @@ -use crate::{FocusPolicy, UiRect, Val}; +use crate::{ + ui_transform::{UiGlobalTransform, UiTransform}, + FocusPolicy, UiRect, Val, +}; use bevy_color::{Alpha, Color}; use bevy_derive::{Deref, DerefMut}; use bevy_ecs::{prelude::*, system::SystemParam}; @@ -9,7 +12,6 @@ use bevy_render::{ view::Visibility, }; use bevy_sprite::BorderRect; -use bevy_transform::components::Transform; use bevy_utils::once; use bevy_window::{PrimaryWindow, WindowRef}; use core::{f32, num::NonZero}; @@ -229,6 +231,73 @@ impl ComputedNode { pub const fn inverse_scale_factor(&self) -> f32 { self.inverse_scale_factor } + + // Returns true if `point` within the node. + // + // Matches the sdf function in `ui.wgsl` that is used by the UI renderer to draw rounded rectangles. + pub fn contains_point(&self, transform: UiGlobalTransform, point: Vec2) -> bool { + let Some(local_point) = transform + .try_inverse() + .map(|transform| transform.transform_point2(point)) + else { + return false; + }; + let [top, bottom] = if local_point.x < 0. { + [self.border_radius.top_left, self.border_radius.bottom_left] + } else { + [ + self.border_radius.top_right, + self.border_radius.bottom_right, + ] + }; + let r = if local_point.y < 0. { top } else { bottom }; + let corner_to_point = local_point.abs() - 0.5 * self.size; + let q = corner_to_point + r; + let l = q.max(Vec2::ZERO).length(); + let m = q.max_element().min(0.); + l + m - r < 0. + } + + /// Transform a point to normalized node space with the center of the node at the origin and the corners at [+/-0.5, +/-0.5] + pub fn normalize_point(&self, transform: UiGlobalTransform, point: Vec2) -> Option { + self.size + .cmpgt(Vec2::ZERO) + .all() + .then(|| transform.try_inverse()) + .flatten() + .map(|transform| transform.transform_point2(point) / self.size) + } + + /// Resolve the node's clipping rect in local space + pub fn resolve_clip_rect( + &self, + overflow: Overflow, + overflow_clip_margin: OverflowClipMargin, + ) -> Rect { + let mut clip_rect = Rect::from_center_size(Vec2::ZERO, self.size); + + let clip_inset = match overflow_clip_margin.visual_box { + OverflowClipBox::BorderBox => BorderRect::ZERO, + OverflowClipBox::ContentBox => self.content_inset(), + OverflowClipBox::PaddingBox => self.border(), + }; + + clip_rect.min.x += clip_inset.left; + clip_rect.min.y += clip_inset.top; + clip_rect.max.x -= clip_inset.right; + clip_rect.max.y -= clip_inset.bottom; + + if overflow.x == OverflowAxis::Visible { + clip_rect.min.x = -f32::INFINITY; + clip_rect.max.x = f32::INFINITY; + } + if overflow.y == OverflowAxis::Visible { + clip_rect.min.y = -f32::INFINITY; + clip_rect.max.y = f32::INFINITY; + } + + clip_rect + } } impl ComputedNode { @@ -323,12 +392,12 @@ impl From for ScrollPosition { #[require( ComputedNode, ComputedNodeTarget, + UiTransform, BackgroundColor, BorderColor, BorderRadius, FocusPolicy, ScrollPosition, - Transform, Visibility, ZIndex )] diff --git a/crates/bevy_ui/src/ui_transform.rs b/crates/bevy_ui/src/ui_transform.rs new file mode 100644 index 0000000000..47f8484e54 --- /dev/null +++ b/crates/bevy_ui/src/ui_transform.rs @@ -0,0 +1,191 @@ +use crate::Val; +use bevy_derive::Deref; +use bevy_ecs::component::Component; +use bevy_ecs::prelude::ReflectComponent; +use bevy_math::Affine2; +use bevy_math::Rot2; +use bevy_math::Vec2; +use bevy_reflect::prelude::*; + +/// A pair of [`Val`]s used to represent a 2-dimensional size or offset. +#[derive(Debug, PartialEq, Clone, Copy, Reflect)] +#[reflect(Default, PartialEq, Debug, Clone)] +#[cfg_attr( + feature = "serialize", + derive(serde::Serialize, serde::Deserialize), + reflect(Serialize, Deserialize) +)] +pub struct Val2 { + /// Translate the node along the x-axis. + /// `Val::Percent` values are resolved based on the computed width of the Ui Node. + /// `Val::Auto` is resolved to `0.`. + pub x: Val, + /// Translate the node along the y-axis. + /// `Val::Percent` values are resolved based on the computed height of the UI Node. + /// `Val::Auto` is resolved to `0.`. + pub y: Val, +} + +impl Val2 { + pub const ZERO: Self = Self { + x: Val::ZERO, + y: Val::ZERO, + }; + + /// Creates a new [`Val2`] where both components are in logical pixels + pub const fn px(x: f32, y: f32) -> Self { + Self { + x: Val::Px(x), + y: Val::Px(y), + } + } + + /// Creates a new [`Val2`] where both components are percentage values + pub const fn percent(x: f32, y: f32) -> Self { + Self { + x: Val::Percent(x), + y: Val::Percent(y), + } + } + + /// Creates a new [`Val2`] + pub const fn new(x: Val, y: Val) -> Self { + Self { x, y } + } + + /// Resolves this [`Val2`] from the given `scale_factor`, `parent_size`, + /// and `viewport_size`. + /// + /// Component values of [`Val::Auto`] are resolved to 0. + pub fn resolve(&self, scale_factor: f32, base_size: Vec2, viewport_size: Vec2) -> Vec2 { + Vec2::new( + self.x + .resolve(scale_factor, base_size.x, viewport_size) + .unwrap_or(0.), + self.y + .resolve(scale_factor, base_size.y, viewport_size) + .unwrap_or(0.), + ) + } +} + +impl Default for Val2 { + fn default() -> Self { + Self::ZERO + } +} + +/// Relative 2D transform for UI nodes +/// +/// [`UiGlobalTransform`] is automatically inserted whenever [`UiTransform`] is inserted. +#[derive(Component, Debug, PartialEq, Clone, Copy, Reflect)] +#[reflect(Component, Default, PartialEq, Debug, Clone)] +#[cfg_attr( + feature = "serialize", + derive(serde::Serialize, serde::Deserialize), + reflect(Serialize, Deserialize) +)] +#[require(UiGlobalTransform)] +pub struct UiTransform { + /// Translate the node. + pub translation: Val2, + /// Scale the node. A negative value reflects the node in that axis. + pub scale: Vec2, + /// Rotate the node clockwise. + pub rotation: Rot2, +} + +impl UiTransform { + pub const IDENTITY: Self = Self { + translation: Val2::ZERO, + scale: Vec2::ONE, + rotation: Rot2::IDENTITY, + }; + + /// Creates a UI transform representing a rotation. + pub fn from_rotation(rotation: Rot2) -> Self { + Self { + rotation, + ..Self::IDENTITY + } + } + + /// Creates a UI transform representing a responsive translation. + pub fn from_translation(translation: Val2) -> Self { + Self { + translation, + ..Self::IDENTITY + } + } + + /// Creates a UI transform representing a scaling. + pub fn from_scale(scale: Vec2) -> Self { + Self { + scale, + ..Self::IDENTITY + } + } + + /// Resolves the translation from the given `scale_factor`, `base_value`, and `target_size` + /// and returns a 2d affine transform from the resolved translation, and the `UiTransform`'s rotation, and scale. + pub fn compute_affine(&self, scale_factor: f32, base_size: Vec2, target_size: Vec2) -> Affine2 { + Affine2::from_scale_angle_translation( + self.scale, + self.rotation.as_radians(), + self.translation + .resolve(scale_factor, base_size, target_size), + ) + } +} + +impl Default for UiTransform { + fn default() -> Self { + Self::IDENTITY + } +} + +/// Absolute 2D transform for UI nodes +/// +/// [`UiGlobalTransform`]s are updated from [`UiTransform`] and [`Node`](crate::ui_node::Node) +/// in [`ui_layout_system`](crate::layout::ui_layout_system) +#[derive(Component, Debug, PartialEq, Clone, Copy, Reflect, Deref)] +#[reflect(Component, Default, PartialEq, Debug, Clone)] +#[cfg_attr( + feature = "serialize", + derive(serde::Serialize, serde::Deserialize), + reflect(Serialize, Deserialize) +)] +pub struct UiGlobalTransform(Affine2); + +impl Default for UiGlobalTransform { + fn default() -> Self { + Self(Affine2::IDENTITY) + } +} + +impl UiGlobalTransform { + /// If the transform is invertible returns its inverse. + /// Otherwise returns `None`. + #[inline] + pub fn try_inverse(&self) -> Option { + (self.matrix2.determinant() != 0.).then_some(self.inverse()) + } +} + +impl From for UiGlobalTransform { + fn from(value: Affine2) -> Self { + Self(value) + } +} + +impl From for Affine2 { + fn from(value: UiGlobalTransform) -> Self { + value.0 + } +} + +impl From<&UiGlobalTransform> for Affine2 { + fn from(value: &UiGlobalTransform) -> Self { + value.0 + } +} diff --git a/crates/bevy_ui/src/update.rs b/crates/bevy_ui/src/update.rs index 7e27c4abdd..c0e9d09d7b 100644 --- a/crates/bevy_ui/src/update.rs +++ b/crates/bevy_ui/src/update.rs @@ -2,6 +2,7 @@ use crate::{ experimental::{UiChildren, UiRootNodes}, + ui_transform::UiGlobalTransform, CalculatedClip, ComputedNodeTarget, DefaultUiCamera, Display, Node, OverflowAxis, UiScale, UiTargetCamera, }; @@ -17,7 +18,6 @@ use bevy_ecs::{ use bevy_math::{Rect, UVec2}; use bevy_render::camera::Camera; use bevy_sprite::BorderRect; -use bevy_transform::components::GlobalTransform; /// Updates clipping for all nodes pub fn update_clipping_system( @@ -26,7 +26,7 @@ pub fn update_clipping_system( mut node_query: Query<( &Node, &ComputedNode, - &GlobalTransform, + &UiGlobalTransform, Option<&mut CalculatedClip>, )>, ui_children: UiChildren, @@ -48,14 +48,13 @@ fn update_clipping( node_query: &mut Query<( &Node, &ComputedNode, - &GlobalTransform, + &UiGlobalTransform, Option<&mut CalculatedClip>, )>, entity: Entity, mut maybe_inherited_clip: Option, ) { - let Ok((node, computed_node, global_transform, maybe_calculated_clip)) = - node_query.get_mut(entity) + let Ok((node, computed_node, transform, maybe_calculated_clip)) = node_query.get_mut(entity) else { return; }; @@ -91,10 +90,7 @@ fn update_clipping( maybe_inherited_clip } else { // Find the current node's clipping rect and intersect it with the inherited clipping rect, if one exists - let mut clip_rect = Rect::from_center_size( - global_transform.translation().truncate(), - computed_node.size(), - ); + let mut clip_rect = Rect::from_center_size(transform.translation, computed_node.size()); // Content isn't clipped at the edges of the node but at the edges of the region specified by [`Node::overflow_clip_margin`]. // diff --git a/examples/README.md b/examples/README.md index 37c1ae4621..dce7c114e8 100644 --- a/examples/README.md +++ b/examples/README.md @@ -571,6 +571,7 @@ Example | Description [UI Texture Atlas Slice](../examples/ui/ui_texture_atlas_slice.rs) | Illustrates how to use 9 Slicing for TextureAtlases in UI [UI Texture Slice](../examples/ui/ui_texture_slice.rs) | Illustrates how to use 9 Slicing in UI [UI Texture Slice Flipping and Tiling](../examples/ui/ui_texture_slice_flip_and_tile.rs) | Illustrates how to flip and tile images with 9 Slicing in UI +[UI Transform](../examples/ui/ui_transform.rs) | An example demonstrating how to translate, rotate and scale UI elements. [UI Z-Index](../examples/ui/z_index.rs) | Demonstrates how to control the relative depth (z-position) of UI elements [Viewport Debug](../examples/ui/viewport_debug.rs) | An example for debugging viewport coordinates [Viewport Node](../examples/ui/viewport_node.rs) | Demonstrates how to create a viewport node with picking support diff --git a/examples/testbed/full_ui.rs b/examples/testbed/full_ui.rs index 02f69ce955..634945f057 100644 --- a/examples/testbed/full_ui.rs +++ b/examples/testbed/full_ui.rs @@ -228,7 +228,13 @@ fn setup(mut commands: Commands, asset_server: Res) { parent.spawn(( ImageNode::new(asset_server.load("branding/bevy_logo_light.png")), // Uses the transform to rotate the logo image by 45 degrees - Transform::from_rotation(Quat::from_rotation_z(0.25 * PI)), + Node { + ..Default::default() + }, + UiTransform { + rotation: Rot2::radians(0.25 * PI), + ..Default::default() + }, BorderRadius::all(Val::Px(10.)), Outline { width: Val::Px(2.), diff --git a/examples/ui/overflow_debug.rs b/examples/ui/overflow_debug.rs index 400c257166..7f77cb98c6 100644 --- a/examples/ui/overflow_debug.rs +++ b/examples/ui/overflow_debug.rs @@ -4,7 +4,6 @@ use bevy::{input::common_conditions::input_just_pressed, prelude::*, ui::widget: use std::f32::consts::{FRAC_PI_2, PI, TAU}; const CONTAINER_SIZE: f32 = 150.0; -const HALF_CONTAINER_SIZE: f32 = CONTAINER_SIZE / 2.0; const LOOP_LENGTH: f32 = 4.0; fn main() { @@ -41,16 +40,16 @@ struct AnimationState { struct Container(u8); trait UpdateTransform { - fn update(&self, t: f32, transform: &mut Transform); + fn update(&self, t: f32, transform: &mut UiTransform); } #[derive(Component)] struct Move; impl UpdateTransform for Move { - fn update(&self, t: f32, transform: &mut Transform) { - transform.translation.x = ops::sin(t * TAU - FRAC_PI_2) * HALF_CONTAINER_SIZE; - transform.translation.y = -ops::cos(t * TAU - FRAC_PI_2) * HALF_CONTAINER_SIZE; + fn update(&self, t: f32, transform: &mut UiTransform) { + transform.translation.x = Val::Percent(ops::sin(t * TAU - FRAC_PI_2) * 50.); + transform.translation.y = Val::Percent(-ops::cos(t * TAU - FRAC_PI_2) * 50.); } } @@ -58,7 +57,7 @@ impl UpdateTransform for Move { struct Scale; impl UpdateTransform for Scale { - fn update(&self, t: f32, transform: &mut Transform) { + fn update(&self, t: f32, transform: &mut UiTransform) { transform.scale.x = 1.0 + 0.5 * ops::cos(t * TAU).max(0.0); transform.scale.y = 1.0 + 0.5 * ops::cos(t * TAU + PI).max(0.0); } @@ -68,9 +67,8 @@ impl UpdateTransform for Scale { struct Rotate; impl UpdateTransform for Rotate { - fn update(&self, t: f32, transform: &mut Transform) { - transform.rotation = - Quat::from_axis_angle(Vec3::Z, (ops::cos(t * TAU) * 45.0).to_radians()); + fn update(&self, t: f32, transform: &mut UiTransform) { + transform.rotation = Rot2::radians(ops::cos(t * TAU) * 45.0); } } @@ -175,10 +173,6 @@ fn spawn_container( update_transform: impl UpdateTransform + Component, spawn_children: impl FnOnce(&mut ChildSpawnerCommands), ) { - let mut transform = Transform::default(); - - update_transform.update(0.0, &mut transform); - parent .spawn(( Node { @@ -198,11 +192,8 @@ fn spawn_container( Node { align_items: AlignItems::Center, justify_content: JustifyContent::Center, - top: Val::Px(transform.translation.x), - left: Val::Px(transform.translation.y), ..default() }, - transform, update_transform, )) .with_children(spawn_children); @@ -233,13 +224,10 @@ fn update_animation( fn update_transform( animation: Res, - mut containers: Query<(&mut Transform, &mut Node, &ComputedNode, &T)>, + mut containers: Query<(&mut UiTransform, &T)>, ) { - for (mut transform, mut node, computed_node, update_transform) in &mut containers { + for (mut transform, update_transform) in &mut containers { update_transform.update(animation.t, &mut transform); - - node.left = Val::Px(transform.translation.x * computed_node.inverse_scale_factor()); - node.top = Val::Px(transform.translation.y * computed_node.inverse_scale_factor()); } } diff --git a/examples/ui/relative_cursor_position.rs b/examples/ui/relative_cursor_position.rs index 796e810895..5346918257 100644 --- a/examples/ui/relative_cursor_position.rs +++ b/examples/ui/relative_cursor_position.rs @@ -78,7 +78,7 @@ fn relative_cursor_position_system( "unknown".to_string() }; - text_color.0 = if relative_cursor_position.mouse_over() { + text_color.0 = if relative_cursor_position.cursor_over() { Color::srgb(0.1, 0.9, 0.1) } else { Color::srgb(0.9, 0.1, 0.1) diff --git a/examples/ui/ui_transform.rs b/examples/ui/ui_transform.rs new file mode 100644 index 0000000000..68abd0ba36 --- /dev/null +++ b/examples/ui/ui_transform.rs @@ -0,0 +1,302 @@ +//! An example demonstrating how to translate, rotate and scale UI elements. +use bevy::color::palettes::css::DARK_GRAY; +use bevy::color::palettes::css::RED; +use bevy::color::palettes::css::YELLOW; +use bevy::prelude::*; +use core::f32::consts::FRAC_PI_8; + +fn main() { + App::new() + .add_plugins(DefaultPlugins) + .add_systems(Startup, setup) + .add_systems(Update, button_system) + .add_systems(Update, translation_system) + .run(); +} + +const NORMAL_BUTTON: Color = Color::WHITE; +const HOVERED_BUTTON: Color = Color::Srgba(YELLOW); +const PRESSED_BUTTON: Color = Color::Srgba(RED); + +/// A button that rotates the target node +#[derive(Component)] +pub struct RotateButton(pub Rot2); + +/// A button that scales the target node +#[derive(Component)] +pub struct ScaleButton(pub f32); + +/// Marker component so the systems know which entities to translate, rotate and scale +#[derive(Component)] +pub struct TargetNode; + +/// Handles button interactions +fn button_system( + mut interaction_query: Query< + ( + &Interaction, + &mut BackgroundColor, + Option<&RotateButton>, + Option<&ScaleButton>, + ), + (Changed, With