Skip to content

Specialized UI transform #16615

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 77 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
77 commits
Select commit Hold shift + click to select a range
76c0480
* Added a `transform: Transform` field to `Node`.
ickshonpe Dec 2, 2024
6847c51
Fixed overflow_debug example
ickshonpe Dec 2, 2024
ff39bd4
Use logical units for `Node`'s translation
ickshonpe Dec 2, 2024
96465d1
Merge branch 'main' into node-global-transform
ickshonpe Dec 2, 2024
f6a3dc8
fix for ambiguity detection
ickshonpe Dec 2, 2024
447cea6
Merge branch 'node-global-transform' of https://github.com/ickshonpe/…
ickshonpe Dec 2, 2024
3dc5c2a
Merge branch 'main' into node-global-transform
ickshonpe Feb 17, 2025
67e8315
Fixes for merge
ickshonpe Feb 17, 2025
10833bf
Changed the `Val::resolve` method to always return always return a va…
ickshonpe Mar 5, 2025
e5a59f1
Merge branch 'main' into node-global-transform
ickshonpe Apr 22, 2025
78ddbf3
Merge branch 'main' into node-global-transform
ickshonpe Apr 22, 2025
78a9972
Split up the `Transform` field into separate fields for translation, …
ickshonpe Apr 24, 2025
81d9433
Made new `Node` fields `pub`.
ickshonpe Apr 24, 2025
abeda2b
Updated layout to use new transform fields.
ickshonpe Apr 24, 2025
2c98ce5
Fixed examples
ickshonpe Apr 24, 2025
54fb3ac
negate rotation before applying it in layout updates
ickshonpe Apr 24, 2025
8825583
Added `TransformedNode` type
ickshonpe Apr 24, 2025
40c51b1
Merge branch 'node-global-transform' into node-global-transform-trans…
ickshonpe Apr 24, 2025
085316f
Addeded `transform: Affine2` field to `ComputedNode`.
ickshonpe Apr 24, 2025
0566eeb
Updated remaining rendering modules
ickshonpe Apr 24, 2025
3dfcdfc
Fixed update module
ickshonpe Apr 24, 2025
6c74428
Fixed accessibility module
ickshonpe Apr 24, 2025
1441201
fixed picking
ickshonpe Apr 24, 2025
7243867
Updated doc comments
ickshonpe Apr 24, 2025
96ddbc8
Added contains_point helper functions to ComputedNode
ickshonpe Apr 24, 2025
8112c79
Removed unused
ickshonpe Apr 24, 2025
430b657
updated ComputedNode::transform doc comment
ickshonpe Apr 24, 2025
1f3d3af
Fixed `focus` module.
ickshonpe Apr 24, 2025
bcb8757
Changed picking_backend to check mouse over recursively
ickshonpe Apr 24, 2025
bd59b49
Removed comments.
ickshonpe Apr 25, 2025
09879ec
Updated `ui_root_node_should_act_like_position_absolute` test
ickshonpe Apr 25, 2025
af637c4
Removed unused
ickshonpe Apr 25, 2025
7f5f998
Added `UiVec`, `UiTransform`, `UiGlobalTransform` types.
ickshonpe Apr 25, 2025
ebeaed2
Update `UiGlobalTransform` in ui_layout_system
ickshonpe Apr 25, 2025
8def248
Update everything to use new UI transform components
ickshonpe Apr 25, 2025
59492df
Merge branch 'val-physical-resolve' into node-ui-transform
ickshonpe Apr 25, 2025
0c4ef87
Added resolve method to `UiVec`
ickshonpe Apr 25, 2025
129c279
Use resolve function in ui_layout_system
ickshonpe Apr 25, 2025
27c0718
Fixed spelling mistake
ickshonpe Apr 25, 2025
4c6f9c6
Fixed UI debug overlay extraction
ickshonpe Apr 25, 2025
a42e295
Fixed `UiVec` attributes
ickshonpe Apr 25, 2025
fa613c3
Use object-centered coordinates for uinodes instead of inconsistantly…
ickshonpe Apr 25, 2025
14fe83a
Changed std import for core
ickshonpe Apr 25, 2025
60ebe6d
Fixed redundant explicit link target
ickshonpe Apr 25, 2025
8fa6db5
Renamed `mouse_over` to `cursor_over`
ickshonpe Apr 25, 2025
5ff2495
Added draft release note
ickshonpe Apr 25, 2025
25cdfaa
Fix for md lint
ickshonpe Apr 25, 2025
1069293
md fix
ickshonpe Apr 25, 2025
dc73857
Added `resolve_clip_rect` helper method to `ComputedNode.
ickshonpe Apr 25, 2025
447844e
Moved `clip_check_recursive` to `picking_backend` module.
ickshonpe Apr 25, 2025
a289ca2
Clean up picking_backend
ickshonpe Apr 25, 2025
c1e9a6b
Removed unneeded variable
ickshonpe Apr 25, 2025
04933ec
Removed unused import
ickshonpe Apr 25, 2025
4f28bc1
Fixed `resolve_clip_rect`, Rect::from_center_size takes a size, not a…
ickshonpe Apr 25, 2025
8318286
Made `UiGlobalTransform`'s inner value private.
ickshonpe Apr 25, 2025
e32cdb1
Fixed accessor in debug_overlay
ickshonpe Apr 25, 2025
b9440e2
Added migration guide
ickshonpe Apr 25, 2025
2ac47f5
Added trailing line to migration guide.
ickshonpe Apr 25, 2025
e91c901
updated migration guide
ickshonpe Apr 26, 2025
c6726fa
Fix migration-guide
ickshonpe Apr 26, 2025
836d8c6
Merge branch 'main' into node-global-transform
ickshonpe Apr 26, 2025
b71f014
Merge branch 'main' into node-global-transform
ickshonpe Apr 27, 2025
fb9fc0f
Added `ui_transform` example
ickshonpe Apr 27, 2025
ff42405
update example
ickshonpe Apr 27, 2025
6f06089
use children! in example
ickshonpe Apr 27, 2025
9495d21
rotate by positive angle
ickshonpe Apr 27, 2025
36b8953
rotate text in example
ickshonpe Apr 27, 2025
a532f32
Merge branch 'node-global-transform' of https://github.com/ickshonpe/…
ickshonpe Apr 27, 2025
3a0a499
Merge branch 'main' into node-global-transform
ickshonpe Apr 27, 2025
306d6f1
moved ui_transform to label's parents in example
ickshonpe Apr 28, 2025
7449a32
apply node + UiTransform's translation together
ickshonpe Apr 28, 2025
6674cc7
Merge branch 'node-global-transform' of https://github.com/ickshonpe/…
ickshonpe Apr 28, 2025
93f3ce8
Renamed `UiVec` to `Val2`, added doc comment.
ickshonpe Apr 28, 2025
94714e6
Fixed unresolved doc links
ickshonpe Apr 28, 2025
5882696
Removed default on unit struct
ickshonpe Apr 28, 2025
84b1663
Merge branch 'main' into node-global-transform
ickshonpe Apr 28, 2025
c5afa14
Merge branch 'main' into node-global-transform
ickshonpe Apr 28, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -3488,6 +3488,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"
Expand Down
7 changes: 3 additions & 4 deletions crates/bevy_ui/src/accessibility.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
use crate::{
experimental::UiChildren,
prelude::{Button, Label},
ui_transform::UiGlobalTransform,
widget::{ImageNode, TextUiReader},
ComputedNode,
};
Expand All @@ -13,9 +14,7 @@ use bevy_ecs::{
system::{Commands, Query},
world::Ref,
};
use bevy_math::Vec3Swizzles;
use bevy_render::camera::CameraUpdateSystem;
use bevy_transform::prelude::GlobalTransform;

use accesskit::{Node, Rect, Role};

Expand All @@ -40,12 +39,12 @@ fn calc_bounds(
mut nodes: Query<(
&mut AccessibilityNode,
Ref<ComputedNode>,
Ref<GlobalTransform>,
Ref<UiGlobalTransform>,
)>,
) {
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;
Expand Down
91 changes: 28 additions & 63 deletions crates/bevy_ui/src/focus.rs
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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(
Expand All @@ -81,18 +84,17 @@ 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<Vec2>,
}

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
}
}

Expand Down Expand Up @@ -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,
}
Expand All @@ -154,6 +155,8 @@ pub fn ui_focus_system(
touches_input: Res<Touches>,
ui_stack: Res<UiStack>,
mut node_query: Query<NodeQuery>,
clipping_query: Query<(&ComputedNode, &UiGlobalTransform, &Node)>,
child_of_query: Query<&ChildOf>,
) {
let primary_window = primary_window.iter().next();

Expand Down Expand Up @@ -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
{
Expand All @@ -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);
}
Expand Down Expand Up @@ -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.
}
58 changes: 37 additions & 21 deletions crates/bevy_ui/src/geometry.rs
Original file line number Diff line number Diff line change
Expand Up @@ -255,15 +255,19 @@ pub enum ValArithmeticError {
}

impl Val {
/// Resolves a [`Val`] from the given context values and returns this as an [`f32`].
/// The [`Val::Px`] value (if present), `parent_size` and `viewport_size` should all be in the same coordinate space.
/// Returns a [`ValArithmeticError::NonEvaluable`] if the [`Val`] is impossible to resolve into a concrete value.
/// Resolves this [`Val`] to a value in physical pixels from the given `scale_factor`, `parent_size`,
/// and `viewport_size`.
///
/// **Note:** If a [`Val::Px`] is resolved, its inner value is returned unchanged.
pub fn resolve(self, parent_size: f32, viewport_size: Vec2) -> Result<f32, ValArithmeticError> {
/// Returns a [`ValArithmeticError::NonEvaluable`] if the [`Val`] is impossible to resolve into a concrete value.
pub fn resolve(
self,
scale_factor: f32,
parent_size: f32,
viewport_size: Vec2,
) -> Result<f32, ValArithmeticError> {
match self {
Val::Percent(value) => Ok(parent_size * value / 100.0),
Val::Px(value) => Ok(value),
Val::Px(value) => Ok(value * scale_factor),
Val::Vw(value) => Ok(viewport_size.x * value / 100.0),
Val::Vh(value) => Ok(viewport_size.y * value / 100.0),
Val::VMin(value) => Ok(viewport_size.min_element() * value / 100.0),
Expand Down Expand Up @@ -687,7 +691,7 @@ mod tests {
fn val_evaluate() {
let size = 250.;
let viewport_size = vec2(1000., 500.);
let result = Val::Percent(80.).resolve(size, viewport_size).unwrap();
let result = Val::Percent(80.).resolve(1., size, viewport_size).unwrap();

assert_eq!(result, size * 0.8);
}
Expand All @@ -696,7 +700,7 @@ mod tests {
fn val_resolve_px() {
let size = 250.;
let viewport_size = vec2(1000., 500.);
let result = Val::Px(10.).resolve(size, viewport_size).unwrap();
let result = Val::Px(10.).resolve(1., size, viewport_size).unwrap();

assert_eq!(result, 10.);
}
Expand All @@ -709,33 +713,45 @@ mod tests {
for value in (-10..10).map(|value| value as f32) {
// for a square viewport there should be no difference between `Vw` and `Vh` and between `Vmin` and `Vmax`.
assert_eq!(
Val::Vw(value).resolve(size, viewport_size),
Val::Vh(value).resolve(size, viewport_size)
Val::Vw(value).resolve(1., size, viewport_size),
Val::Vh(value).resolve(1., size, viewport_size)
);
assert_eq!(
Val::VMin(value).resolve(size, viewport_size),
Val::VMax(value).resolve(size, viewport_size)
Val::VMin(value).resolve(1., size, viewport_size),
Val::VMax(value).resolve(1., size, viewport_size)
);
assert_eq!(
Val::VMin(value).resolve(size, viewport_size),
Val::Vw(value).resolve(size, viewport_size)
Val::VMin(value).resolve(1., size, viewport_size),
Val::Vw(value).resolve(1., size, viewport_size)
);
}

let viewport_size = vec2(1000., 500.);
assert_eq!(Val::Vw(100.).resolve(size, viewport_size).unwrap(), 1000.);
assert_eq!(Val::Vh(100.).resolve(size, viewport_size).unwrap(), 500.);
assert_eq!(Val::Vw(60.).resolve(size, viewport_size).unwrap(), 600.);
assert_eq!(Val::Vh(40.).resolve(size, viewport_size).unwrap(), 200.);
assert_eq!(Val::VMin(50.).resolve(size, viewport_size).unwrap(), 250.);
assert_eq!(Val::VMax(75.).resolve(size, viewport_size).unwrap(), 750.);
assert_eq!(
Val::Vw(100.).resolve(1., size, viewport_size).unwrap(),
1000.
);
assert_eq!(
Val::Vh(100.).resolve(1., size, viewport_size).unwrap(),
500.
);
assert_eq!(Val::Vw(60.).resolve(1., size, viewport_size).unwrap(), 600.);
assert_eq!(Val::Vh(40.).resolve(1., size, viewport_size).unwrap(), 200.);
assert_eq!(
Val::VMin(50.).resolve(1., size, viewport_size).unwrap(),
250.
);
assert_eq!(
Val::VMax(75.).resolve(1., size, viewport_size).unwrap(),
750.
);
}

#[test]
fn val_auto_is_non_evaluable() {
let size = 250.;
let viewport_size = vec2(1000., 500.);
let resolve_auto = Val::Auto.resolve(size, viewport_size);
let resolve_auto = Val::Auto.resolve(1., size, viewport_size);

assert_eq!(resolve_auto, Err(ValArithmeticError::NonEvaluable));
}
Expand Down
6 changes: 4 additions & 2 deletions crates/bevy_ui/src/layout/convert.rs
Original file line number Diff line number Diff line change
Expand Up @@ -448,6 +448,8 @@ impl RepeatedGridTrack {

#[cfg(test)]
mod tests {
use bevy_math::Vec2;

use super::*;

#[test]
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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)),
Expand Down
Loading