diff --git a/crates/bevy_ui/src/focus.rs b/crates/bevy_ui/src/focus.rs index 7bd0dfc95e..b0fe6fb2c0 100644 --- a/crates/bevy_ui/src/focus.rs +++ b/crates/bevy_ui/src/focus.rs @@ -1,4 +1,7 @@ -use crate::{CalculatedClip, DefaultUiCamera, Node, TargetCamera, UiScale, UiStack}; +use crate::{ + picking_backend::pick_rounded_rect, CalculatedClip, DefaultUiCamera, Node, TargetCamera, + UiScale, UiStack, +}; use bevy_ecs::{ change_detection::DetectChangesMut, entity::Entity, @@ -249,19 +252,18 @@ pub fn ui_focus_system( .map(|clip| node_rect.intersect(clip.clip)) .unwrap_or(node_rect); + let cursor_position = camera_cursor_positions.get(&camera_entity); + // The mouse position relative to the node // (0., 0.) is the top-left corner, (1., 1.) is the bottom-right corner // Coordinates are relative to the entire node, not just the visible region. - let relative_cursor_position = - camera_cursor_positions - .get(&camera_entity) - .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()) - }); + let relative_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()) + }); // If the current cursor position is within the bounds of the node's visible area, consider it for // clicking @@ -270,7 +272,16 @@ pub fn ui_focus_system( normalized: relative_cursor_position, }; - let contains_cursor = relative_cursor_position_component.mouse_over(); + let contains_cursor = relative_cursor_position_component.mouse_over() + && cursor_position + .map(|point| { + pick_rounded_rect( + *point - node_rect.center(), + node_rect.size(), + node.node.border_radius, + ) + }) + .unwrap_or(false); // Save the relative cursor position to the correct component if let Some(mut node_relative_cursor_position_component) = node.relative_cursor_position diff --git a/crates/bevy_ui/src/layout/mod.rs b/crates/bevy_ui/src/layout/mod.rs index afc5662157..b529df6f30 100644 --- a/crates/bevy_ui/src/layout/mod.rs +++ b/crates/bevy_ui/src/layout/mod.rs @@ -1,4 +1,6 @@ -use crate::{ContentSize, DefaultUiCamera, Node, Outline, Style, TargetCamera, UiScale}; +use crate::{ + BorderRadius, ContentSize, DefaultUiCamera, Node, Outline, Style, TargetCamera, UiScale, +}; use bevy_ecs::{ change_detection::{DetectChanges, DetectChangesMut}, entity::{Entity, EntityHashMap, EntityHashSet}, @@ -110,7 +112,12 @@ pub fn ui_layout_system( children_query: Query<(Entity, Ref), With>, just_children_query: Query<&Children>, mut removed_components: UiLayoutSystemRemovedComponentParam, - mut node_transform_query: Query<(&mut Node, &mut Transform, Option<&Outline>)>, + mut node_transform_query: Query<( + &mut Node, + &mut Transform, + Option<&BorderRadius>, + Option<&Outline>, + )>, #[cfg(feature = "bevy_text")] mut buffer_query: Query<&mut CosmicBuffer>, #[cfg(feature = "bevy_text")] mut text_pipeline: ResMut, ) { @@ -280,13 +287,20 @@ pub fn ui_layout_system( entity: Entity, ui_surface: &UiSurface, root_size: Option, - node_transform_query: &mut Query<(&mut Node, &mut Transform, Option<&Outline>)>, + node_transform_query: &mut Query<( + &mut Node, + &mut Transform, + Option<&BorderRadius>, + Option<&Outline>, + )>, children_query: &Query<&Children>, inverse_target_scale_factor: f32, parent_size: Vec2, mut absolute_location: Vec2, ) { - if let Ok((mut node, mut transform, outline)) = node_transform_query.get_mut(entity) { + if let Ok((mut node, mut transform, maybe_border_radius, maybe_outline)) = + node_transform_query.get_mut(entity) + { let Ok(layout) = ui_surface.get_layout(entity) else { return; }; @@ -311,7 +325,13 @@ pub fn ui_layout_system( let viewport_size = root_size.unwrap_or(node.calculated_size); - if let Some(outline) = outline { + 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(node.calculated_size, viewport_size); + } + + if let Some(outline) = maybe_outline { // don't trigger change detection when only outlines are changed let node = node.bypass_change_detection(); node.outline_width = outline diff --git a/crates/bevy_ui/src/picking_backend.rs b/crates/bevy_ui/src/picking_backend.rs index 5cb31e2098..433e293e7f 100644 --- a/crates/bevy_ui/src/picking_backend.rs +++ b/crates/bevy_ui/src/picking_backend.rs @@ -163,6 +163,11 @@ pub fn ui_picking( 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, + ) { hit_nodes .entry((camera_entity, *pointer_id)) @@ -212,3 +217,26 @@ pub fn ui_picking( output.send(PointerHits::new(*pointer, picks, order)); } } + +// 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 s = point.signum(); + let r = (border_radius.top_left * (1. - s.x) * (1. - s.y) + + border_radius.top_right * (1. + s.x) * (1. - s.y) + + border_radius.bottom_right * (1. + s.x) * (1. + s.y) + + border_radius.bottom_left * (1. - s.x) * (1. + s.y)) + / 4.; + + 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/render/mod.rs b/crates/bevy_ui/src/render/mod.rs index 266620bf11..59c4933b9b 100644 --- a/crates/bevy_ui/src/render/mod.rs +++ b/crates/bevy_ui/src/render/mod.rs @@ -24,8 +24,8 @@ use ui_texture_slice_pipeline::UiTextureSlicerPlugin; use crate::graph::{NodeUi, SubGraphUi}; use crate::{ - BackgroundColor, BorderColor, BorderRadius, CalculatedClip, ContentSize, DefaultUiCamera, Node, - Outline, Style, TargetCamera, UiImage, UiScale, Val, + BackgroundColor, BorderColor, CalculatedClip, ContentSize, DefaultUiCamera, Node, Outline, + Style, TargetCamera, UiImage, UiScale, Val, }; use bevy_app::prelude::*; @@ -202,7 +202,6 @@ pub fn extract_uinode_background_colors( Option<&CalculatedClip>, Option<&TargetCamera>, &BackgroundColor, - Option<&BorderRadius>, &Style, Option<&Parent>, )>, @@ -217,7 +216,6 @@ pub fn extract_uinode_background_colors( clip, camera, background_color, - border_radius, style, parent, ) in &uinode_query @@ -258,16 +256,13 @@ pub fn extract_uinode_background_colors( let border = [left, top, right, bottom]; - let border_radius = if let Some(border_radius) = border_radius { - resolve_border_radius( - border_radius, - uinode.size(), - ui_logical_viewport_size, - ui_scale.0, - ) - } else { - [0.; 4] - }; + let border_radius = [ + uinode.border_radius.top_left, + uinode.border_radius.top_right, + uinode.border_radius.bottom_right, + uinode.border_radius.bottom_left, + ] + .map(|r| r * ui_scale.0); extracted_uinodes.uinodes.insert( entity, @@ -311,7 +306,6 @@ pub fn extract_uinode_images( Option<&TargetCamera>, &UiImage, Option<&TextureAtlas>, - Option<&BorderRadius>, Option<&Parent>, &Style, ), @@ -320,18 +314,8 @@ pub fn extract_uinode_images( >, node_query: Extract>, ) { - for ( - uinode, - transform, - view_visibility, - clip, - camera, - image, - atlas, - border_radius, - parent, - style, - ) in &uinode_query + for (uinode, transform, view_visibility, clip, camera, image, atlas, parent, style) in + &uinode_query { let Some(camera_entity) = camera.map(TargetCamera::entity).or(default_ui_camera.get()) else { @@ -393,16 +377,13 @@ pub fn extract_uinode_images( let border = [left, top, right, bottom]; - let border_radius = if let Some(border_radius) = border_radius { - resolve_border_radius( - border_radius, - uinode.size(), - ui_logical_viewport_size, - ui_scale.0, - ) - } else { - [0.; 4] - }; + let border_radius = [ + uinode.border_radius.top_left, + uinode.border_radius.top_right, + uinode.border_radius.bottom_right, + uinode.border_radius.bottom_left, + ] + .map(|r| r * ui_scale.0); extracted_uinodes.uinodes.insert( commands.spawn_empty().id(), @@ -437,33 +418,6 @@ pub(crate) fn resolve_border_thickness(value: Val, parent_width: f32, viewport_s } } -pub(crate) fn resolve_border_radius( - &values: &BorderRadius, - node_size: Vec2, - viewport_size: Vec2, - ui_scale: f32, -) -> [f32; 4] { - let max_radius = 0.5 * node_size.min_element() * ui_scale; - [ - values.top_left, - values.top_right, - values.bottom_right, - values.bottom_left, - ] - .map(|value| { - match value { - Val::Auto => 0., - Val::Px(px) => ui_scale * px, - Val::Percent(percent) => node_size.min_element() * percent / 100., - Val::Vw(percent) => viewport_size.x * percent / 100., - Val::Vh(percent) => viewport_size.y * percent / 100., - Val::VMin(percent) => viewport_size.min_element() * percent / 100., - Val::VMax(percent) => viewport_size.max_element() * percent / 100., - } - .clamp(0., max_radius) - }) -} - #[inline] fn clamp_corner(r: f32, size: Vec2, offset: Vec2) -> f32 { let s = 0.5 * size + offset; @@ -503,7 +457,6 @@ pub fn extract_uinode_borders( Option<&Parent>, &Style, &BorderColor, - Option<&BorderRadius>, ), Without, >, @@ -512,17 +465,8 @@ pub fn extract_uinode_borders( ) { let image = AssetId::::default(); - for ( - node, - global_transform, - view_visibility, - clip, - camera, - parent, - style, - border_color, - maybe_border_radius, - ) in &uinode_query + for (uinode, global_transform, view_visibility, clip, camera, parent, style, border_color) in + &uinode_query { let Some(camera_entity) = camera.map(TargetCamera::entity).or(default_ui_camera.get()) else { @@ -532,8 +476,8 @@ pub fn extract_uinode_borders( // Skip invisible borders if !view_visibility.get() || border_color.0.is_fully_transparent() - || node.size().x <= 0. - || node.size().y <= 0. + || uinode.size().x <= 0. + || uinode.size().y <= 0. { continue; } @@ -569,28 +513,26 @@ pub fn extract_uinode_borders( continue; } - let border_radius = if let Some(border_radius) = maybe_border_radius { - let resolved_radius = resolve_border_radius( - border_radius, - node.size(), - ui_logical_viewport_size, - ui_scale.0, - ); - clamp_radius(resolved_radius, node.size(), border.into()) - } else { - [0.; 4] - }; + let border_radius = [ + uinode.border_radius.top_left, + uinode.border_radius.top_right, + uinode.border_radius.bottom_right, + uinode.border_radius.bottom_left, + ] + .map(|r| r * ui_scale.0); + + let border_radius = clamp_radius(border_radius, uinode.size(), border.into()); let transform = global_transform.compute_matrix(); extracted_uinodes.uinodes.insert( commands.spawn_empty().id(), ExtractedUiNode { - stack_index: node.stack_index, + stack_index: uinode.stack_index, // This translates the uinode's transform to the center of the current border rectangle transform, color: border_color.0.into(), rect: Rect { - max: node.size(), + max: uinode.size(), ..Default::default() }, image, diff --git a/crates/bevy_ui/src/ui_node.rs b/crates/bevy_ui/src/ui_node.rs index 549eb796ce..46dafe8517 100644 --- a/crates/bevy_ui/src/ui_node.rs +++ b/crates/bevy_ui/src/ui_node.rs @@ -48,6 +48,11 @@ pub struct Node { /// /// Automatically calculated by [`super::layout::ui_layout_system`]. pub(crate) unrounded_size: Vec2, + /// Resolved border radius values in logical pixels. + /// Border radius updates bypass change detection. + /// + /// Automatically calculated by [`super::layout::ui_layout_system`]. + pub(crate) border_radius: ResolvedBorderRadius, } impl Node { @@ -122,6 +127,7 @@ impl Node { outline_width: 0., outline_offset: 0., unrounded_size: Vec2::ZERO, + border_radius: ResolvedBorderRadius::ZERO, }; } @@ -2188,6 +2194,49 @@ impl BorderRadius { self.bottom_right = radius; self } + + /// Compute the logical border radius for a single corner from the given values + pub fn resolve_single_corner(radius: Val, node_size: Vec2, viewport_size: Vec2) -> f32 { + match radius { + Val::Auto => 0., + Val::Px(px) => px, + Val::Percent(percent) => node_size.min_element() * percent / 100., + Val::Vw(percent) => viewport_size.x * percent / 100., + Val::Vh(percent) => viewport_size.y * percent / 100., + Val::VMin(percent) => viewport_size.min_element() * percent / 100., + Val::VMax(percent) => viewport_size.max_element() * percent / 100., + } + .clamp(0., 0.5 * node_size.min_element()) + } + + pub fn resolve(&self, node_size: Vec2, viewport_size: Vec2) -> ResolvedBorderRadius { + ResolvedBorderRadius { + top_left: Self::resolve_single_corner(self.top_left, node_size, viewport_size), + top_right: Self::resolve_single_corner(self.top_right, node_size, viewport_size), + bottom_left: Self::resolve_single_corner(self.bottom_left, node_size, viewport_size), + bottom_right: Self::resolve_single_corner(self.bottom_right, node_size, viewport_size), + } + } +} + +/// Represents the resolved border radius values for a UI node. +/// +/// The values are in logical pixels. +#[derive(Copy, Clone, Debug, PartialEq, Reflect)] +pub struct ResolvedBorderRadius { + pub top_left: f32, + pub top_right: f32, + pub bottom_left: f32, + pub bottom_right: f32, +} + +impl ResolvedBorderRadius { + pub const ZERO: Self = Self { + top_left: 0., + top_right: 0., + bottom_left: 0., + bottom_right: 0., + }; } #[cfg(test)]