diff --git a/sources/editor/Stride.Assets.Presentation/AssetEditors/UIEditor/Views/ThicknessEditor.cs b/sources/editor/Stride.Assets.Presentation/AssetEditors/UIEditor/Views/ThicknessEditor.cs index 61a0ca8bc8..3ea910770d 100644 --- a/sources/editor/Stride.Assets.Presentation/AssetEditors/UIEditor/Views/ThicknessEditor.cs +++ b/sources/editor/Stride.Assets.Presentation/AssetEditors/UIEditor/Views/ThicknessEditor.cs @@ -110,7 +110,7 @@ protected override void UpdateComponentsFromValue(Thickness? value) } /// - protected override Thickness? UpateValueFromFloat(float value) + protected override Thickness? UpdateValueFromFloat(float value) { return Thickness.UniformCuboid(value); } diff --git a/sources/editor/Stride.Assets.Presentation/View/UIPropertyTemplates.xaml b/sources/editor/Stride.Assets.Presentation/View/UIPropertyTemplates.xaml index 1eef046c4b..b2928eb1f7 100644 --- a/sources/editor/Stride.Assets.Presentation/View/UIPropertyTemplates.xaml +++ b/sources/editor/Stride.Assets.Presentation/View/UIPropertyTemplates.xaml @@ -1,13 +1,21 @@ + + + + + + @@ -83,8 +91,4 @@ - - - - diff --git a/sources/editor/Stride.Core.Assets.Editor/View/TemplateProviders/TypeAndPropertyNameMatchTemplateProvider.cs b/sources/editor/Stride.Core.Assets.Editor/View/TemplateProviders/TypeAndPropertyNameMatchTemplateProvider.cs new file mode 100644 index 0000000000..5d3334cf4e --- /dev/null +++ b/sources/editor/Stride.Core.Assets.Editor/View/TemplateProviders/TypeAndPropertyNameMatchTemplateProvider.cs @@ -0,0 +1,25 @@ +// Copyright (c) .NET Foundation and Contributors (https://dotnetfoundation.org/ & https://stride3d.net) +// Distributed under the MIT license. See the LICENSE.md file in the project root for more information. +using Stride.Core.Presentation.Quantum.ViewModels; + +namespace Stride.Core.Assets.Editor.View.TemplateProviders +{ + /// + /// A template provider that matches nodes based on both their type and property name. + /// + public class TypeAndPropertyNameMatchTemplateProvider : TypeMatchTemplateProvider + { + /// + public override string Name => $"{base.Name}_{PropertyName}"; + + /// + /// Gets or sets the name of the property to match. + /// + public string PropertyName { get; set; } + + public override bool MatchNode(NodeViewModel node) + { + return base.MatchNode(node) && node.Name == PropertyName; + } + } +} diff --git a/sources/engine/Stride.UI.Tests/Layering/ImageElementRotationTests.cs b/sources/engine/Stride.UI.Tests/Layering/ImageElementRotationTests.cs new file mode 100644 index 0000000000..da3ce6b215 --- /dev/null +++ b/sources/engine/Stride.UI.Tests/Layering/ImageElementRotationTests.cs @@ -0,0 +1,188 @@ +// Copyright (c) .NET Foundation and Contributors (https://dotnetfoundation.org/ & https://stride3d.net) +// Distributed under the MIT license. See the LICENSE.md file in the project root for more information. + +using Stride.Core.Mathematics; +using Stride.Graphics; +using Stride.UI.Controls; +using Xunit; + +namespace Stride.UI.Tests.Layering +{ + /// + /// Tests for the property. + /// + [System.ComponentModel.Description("Tests for ImageElement rotation functionality")] + public class ImageElementRotationTests + { + [Fact] + [System.ComponentModel.Description("Test that the default rotation value is 0")] + public void TestDefaultRotation() + { + var image = new ImageElement(); + Assert.Equal(0f, image.Rotation); + } + + [Fact] + [System.ComponentModel.Description("Test that the default LocalMatrix is Identity when rotation is 0")] + public void TestDefaultLocalMatrix() + { + var image = new ImageElement(); + Assert.Equal(Matrix.Identity, image.LocalMatrix); + } + + [Fact] + [System.ComponentModel.Description("Test setting rotation to a positive value")] + public void TestSetPositiveRotation() + { + var image = new ImageElement(); + var angle = MathUtil.PiOverFour; // 45 degrees + + image.Rotation = angle; + + Assert.Equal(angle, image.Rotation); + } + + [Fact] + [System.ComponentModel.Description("Test setting rotation to a negative value (counter-clockwise)")] + public void TestSetNegativeRotation() + { + var image = new ImageElement(); + var angle = -MathUtil.PiOverFour; // -45 degrees + + image.Rotation = angle; + + Assert.Equal(angle, image.Rotation); + } + + [Fact] + [System.ComponentModel.Description("Test that LocalMatrix is updated when rotation changes")] + public void TestLocalMatrixUpdatesOnRotationChange() + { + var image = new ImageElement(); + var angle = MathUtil.PiOverTwo; // 90 degrees + + image.Rotation = angle; + + var expectedMatrix = Matrix.RotationZ(angle); + AssertMatrixEqual(expectedMatrix, image.LocalMatrix); + } + + [Fact] + [System.ComponentModel.Description("Test that setting rotation to 0 resets LocalMatrix to Identity")] + public void TestRotationZeroResetsToIdentity() + { + var image = new ImageElement(); + + // First set a non-zero rotation + image.Rotation = MathUtil.PiOverFour; + Assert.NotEqual(Matrix.Identity, image.LocalMatrix); + + // Then reset to zero + image.Rotation = 0f; + Assert.Equal(Matrix.Identity, image.LocalMatrix); + } + + [Fact] + [System.ComponentModel.Description("Test that very small rotation values (near zero) set LocalMatrix to Identity")] + public void TestVerySmallRotationSetsIdentity() + { + var image = new ImageElement(); + + // Set a value smaller than float.Epsilon + image.Rotation = float.Epsilon / 2f; + + // Should be treated as zero + Assert.Equal(Matrix.Identity, image.LocalMatrix); + } + + [Fact] + [System.ComponentModel.Description("Test multiple rotation changes")] + public void TestMultipleRotationChanges() + { + var image = new ImageElement(); + + // First rotation + image.Rotation = MathUtil.PiOverFour; + AssertMatrixEqual(Matrix.RotationZ(MathUtil.PiOverFour), image.LocalMatrix); + + // Second rotation + image.Rotation = MathUtil.PiOverTwo; + AssertMatrixEqual(Matrix.RotationZ(MathUtil.PiOverTwo), image.LocalMatrix); + + // Third rotation (negative) + image.Rotation = -MathUtil.PiOverFour; + AssertMatrixEqual(Matrix.RotationZ(-MathUtil.PiOverFour), image.LocalMatrix); + } + + [Fact] + [System.ComponentModel.Description("Test that setting the same rotation value doesn't trigger unnecessary updates")] + public void TestSetSameRotationValue() + { + var image = new ImageElement(); + var angle = MathUtil.PiOverFour; + + image.Rotation = angle; + var firstMatrix = image.LocalMatrix; + + // Set the same value again + image.Rotation = angle; + var secondMatrix = image.LocalMatrix; + + // Matrix should be the same + Assert.Equal(firstMatrix, secondMatrix); + } + + [Fact] + [System.ComponentModel.Description("Test that rotation doesn't affect the image size or measurement")] + public void TestRotationDoesNotAffectMeasurement() + { + var sprite = new Sprite() + { + Region = new Rectangle(0, 0, 100, 50) + }; + var image = new ImageElement() + { + Source = (Rendering.Sprites.SpriteFromTexture)sprite, + StretchType = StretchType.None + }; + + // Measure without rotation + image.Measure(new Vector3(200, 200, 0)); + var sizeWithoutRotation = image.DesiredSizeWithMargins; + + // Apply rotation and measure again + image.Rotation = MathUtil.PiOverFour; + image.Measure(new Vector3(200, 200, 0)); + var sizeWithRotation = image.DesiredSizeWithMargins; + + // Rotation should not change the measured size + Assert.Equal(sizeWithoutRotation, sizeWithRotation); + } + + /// + /// Helper method to assert that two matrices are approximately equal within a tolerance. + /// + private static void AssertMatrixEqual(Matrix expected, Matrix actual, int precision = 5) + { + Assert.Equal(expected.M11, actual.M11, precision); + Assert.Equal(expected.M12, actual.M12, precision); + Assert.Equal(expected.M13, actual.M13, precision); + Assert.Equal(expected.M14, actual.M14, precision); + + Assert.Equal(expected.M21, actual.M21, precision); + Assert.Equal(expected.M22, actual.M22, precision); + Assert.Equal(expected.M23, actual.M23, precision); + Assert.Equal(expected.M24, actual.M24, precision); + + Assert.Equal(expected.M31, actual.M31, precision); + Assert.Equal(expected.M32, actual.M32, precision); + Assert.Equal(expected.M33, actual.M33, precision); + Assert.Equal(expected.M34, actual.M34, precision); + + Assert.Equal(expected.M41, actual.M41, precision); + Assert.Equal(expected.M42, actual.M42, precision); + Assert.Equal(expected.M43, actual.M43, precision); + Assert.Equal(expected.M44, actual.M44, precision); + } + } +} diff --git a/sources/engine/Stride.UI.Tests/Layering/ImageElementTests.cs b/sources/engine/Stride.UI.Tests/Layering/ImageElementTests.cs index 39aae1d235..ccce15679f 100644 --- a/sources/engine/Stride.UI.Tests/Layering/ImageElementTests.cs +++ b/sources/engine/Stride.UI.Tests/Layering/ImageElementTests.cs @@ -40,6 +40,7 @@ public void TestBasicInvalidations() UIElementLayeringTests.TestNoInvalidation(this, () => source.Region = new Rectangle(8, 9, 3, 4)); // if the size of the region does not change we avoid re-measuring UIElementLayeringTests.TestNoInvalidation(this, () => source.Orientation = ImageOrientation.Rotated90); // no changes UIElementLayeringTests.TestNoInvalidation(this, () => source.Borders = Vector4.One); // no changes + UIElementLayeringTests.TestNoInvalidation(this, () => Rotation = MathUtil.PiOverFour); // rotation does not affect layout/measurement // ReSharper restore ImplicitlyCapturedClosure } diff --git a/sources/engine/Stride.UI.Tests/Stride.UI.Tests.Windows.csproj b/sources/engine/Stride.UI.Tests/Stride.UI.Tests.Windows.csproj index 6f554a3615..c9a8397a79 100644 --- a/sources/engine/Stride.UI.Tests/Stride.UI.Tests.Windows.csproj +++ b/sources/engine/Stride.UI.Tests/Stride.UI.Tests.Windows.csproj @@ -42,6 +42,7 @@ + diff --git a/sources/engine/Stride.UI/Controls/ImageElement.cs b/sources/engine/Stride.UI/Controls/ImageElement.cs index d8a5270dfc..9f840e3863 100644 --- a/sources/engine/Stride.UI/Controls/ImageElement.cs +++ b/sources/engine/Stride.UI/Controls/ImageElement.cs @@ -32,7 +32,7 @@ public class ImageElement : UIElement [DefaultValue(null)] public ISpriteProvider Source { - get { return source;} + get { return source; } set { if (source == value) @@ -86,6 +86,29 @@ public StretchDirection StretchDirection } } + /// + /// Gets or sets the rotation angle in radians (clockwise around Z-axis). + /// + /// The rotation is applied around the center of the image. Positive values rotate clockwise. + /// The rotation angle in radians. Positive values rotate clockwise. + [DataMember] + [Display(category: LayoutCategory)] + [DefaultValue(0f)] + public float Rotation + { + get { return field; } + set + { + if (Math.Abs(field - value) <= MathUtil.ZeroTolerance) + { + return; + } + + field = value; + UpdateLocalMatrix(); + } + } + protected override Vector3 ArrangeOverride(Vector3 finalSizeWithoutMargins) { return ImageSizeHelper.CalculateImageSizeFromAvailable(sprite, finalSizeWithoutMargins, StretchType, StretchDirection, false); @@ -125,5 +148,20 @@ private void OnSpriteChanged(Sprite currentSprite) sprite.BorderChanged += InvalidateMeasure; } } + + /// + /// Updates the local transformation matrix based on the current rotation angle. + /// + private void UpdateLocalMatrix() + { + if (Rotation == 0) + { + LocalMatrix = Matrix.Identity; + } + else + { + LocalMatrix = Matrix.RotationZ(Rotation); + } + } } } diff --git a/sources/presentation/Stride.Core.Presentation.Wpf/Controls/AngleEditor.cs b/sources/presentation/Stride.Core.Presentation.Wpf/Controls/AngleEditor.cs new file mode 100644 index 0000000000..b6cde23649 --- /dev/null +++ b/sources/presentation/Stride.Core.Presentation.Wpf/Controls/AngleEditor.cs @@ -0,0 +1,73 @@ +// Copyright (c) .NET Foundation and Contributors (https://dotnetfoundation.org/ & https://stride3d.net) +// Distributed under the MIT license. See the LICENSE.md file in the project root for more information. +using System; +using System.Windows; + +using Stride.Core.Mathematics; + +namespace Stride.Core.Presentation.Controls +{ + /// + /// Represents a control that allows to edit an angle value stored in radians but displayed in degrees. + /// + public class AngleEditor : VectorEditorBase + { + /// + /// Identifies the dependency property. + /// + public static readonly DependencyProperty DegreesProperty = DependencyProperty.Register( + nameof(Degrees), + typeof(float), + typeof(AngleEditor), + new FrameworkPropertyMetadata(0f, FrameworkPropertyMetadataOptions.BindsTwoWayByDefault, OnComponentPropertyChanged, CoerceComponentValue)); + + /// + /// Gets or sets the angle in degrees. + /// + public float Degrees + { + get => (float)GetValue(DegreesProperty); + set => SetValue(DegreesProperty, value); + } + + /// + public override void ResetValue() + { + Value = DefaultValue; + } + + /// + protected override void UpdateComponentsFromValue(float value) + { + var degrees = GetDisplayValue(value); + SetCurrentValue(DegreesProperty, degrees); + } + + /// + protected override float UpdateValueFromComponent(DependencyProperty property) + { + if (property == DegreesProperty) + { + return MathUtil.DegreesToRadians(Degrees); + } + + throw new ArgumentException("Property unsupported by method UpdateValueFromComponent."); + } + + /// + protected override float UpdateValueFromFloat(float value) + { + return MathUtil.DegreesToRadians(value); + } + + /// + /// Converts radians to degrees for display. + /// + /// The angle in radians. + /// The angle in degrees. + private static float GetDisplayValue(float angleRadians) + { + return MathF.Round(MathUtil.RadiansToDegrees(angleRadians), 4); + } + } +} diff --git a/sources/presentation/Stride.Core.Presentation.Wpf/Controls/Int2Editor.cs b/sources/presentation/Stride.Core.Presentation.Wpf/Controls/Int2Editor.cs index 6b28703071..f56dbe8891 100644 --- a/sources/presentation/Stride.Core.Presentation.Wpf/Controls/Int2Editor.cs +++ b/sources/presentation/Stride.Core.Presentation.Wpf/Controls/Int2Editor.cs @@ -51,7 +51,7 @@ protected override void UpdateComponentsFromValue(Int2? value) } /// - protected override Int2? UpateValueFromFloat(float value) + protected override Int2? UpdateValueFromFloat(float value) { return new Int2((int)Math.Round(value, MidpointRounding.AwayFromZero)); } diff --git a/sources/presentation/Stride.Core.Presentation.Wpf/Controls/Int3Editor.cs b/sources/presentation/Stride.Core.Presentation.Wpf/Controls/Int3Editor.cs index 81e876594a..4a1fad5b49 100644 --- a/sources/presentation/Stride.Core.Presentation.Wpf/Controls/Int3Editor.cs +++ b/sources/presentation/Stride.Core.Presentation.Wpf/Controls/Int3Editor.cs @@ -64,7 +64,7 @@ protected override void UpdateComponentsFromValue(Int3? value) } /// - protected override Int3? UpateValueFromFloat(float value) + protected override Int3? UpdateValueFromFloat(float value) { return new Int3((int)Math.Round(value, MidpointRounding.AwayFromZero)); } diff --git a/sources/presentation/Stride.Core.Presentation.Wpf/Controls/Int4Editor.cs b/sources/presentation/Stride.Core.Presentation.Wpf/Controls/Int4Editor.cs index c71d56d7d7..11e1943554 100644 --- a/sources/presentation/Stride.Core.Presentation.Wpf/Controls/Int4Editor.cs +++ b/sources/presentation/Stride.Core.Presentation.Wpf/Controls/Int4Editor.cs @@ -77,7 +77,7 @@ protected override void UpdateComponentsFromValue(Int4? value) } /// - protected override Int4? UpateValueFromFloat(float value) + protected override Int4? UpdateValueFromFloat(float value) { return new Int4((int)Math.Round(value, MidpointRounding.AwayFromZero)); } diff --git a/sources/presentation/Stride.Core.Presentation.Wpf/Controls/MatrixEditor.cs b/sources/presentation/Stride.Core.Presentation.Wpf/Controls/MatrixEditor.cs index 2af4942043..2b77b3c893 100644 --- a/sources/presentation/Stride.Core.Presentation.Wpf/Controls/MatrixEditor.cs +++ b/sources/presentation/Stride.Core.Presentation.Wpf/Controls/MatrixEditor.cs @@ -207,7 +207,7 @@ protected override void UpdateComponentsFromValue(Matrix? value) return new Matrix(array); } - protected override Matrix? UpateValueFromFloat(float value) + protected override Matrix? UpdateValueFromFloat(float value) { return new Matrix(value); } diff --git a/sources/presentation/Stride.Core.Presentation.Wpf/Controls/RectangleEditor.cs b/sources/presentation/Stride.Core.Presentation.Wpf/Controls/RectangleEditor.cs index 4555267aa6..c4bdce0355 100644 --- a/sources/presentation/Stride.Core.Presentation.Wpf/Controls/RectangleEditor.cs +++ b/sources/presentation/Stride.Core.Presentation.Wpf/Controls/RectangleEditor.cs @@ -77,7 +77,7 @@ protected override void UpdateComponentsFromValue(Rectangle? value) } /// - protected override Rectangle? UpateValueFromFloat(float value) + protected override Rectangle? UpdateValueFromFloat(float value) { var intValue = (int)Math.Round(value, MidpointRounding.AwayFromZero); return new Rectangle(0, 0, intValue, intValue); diff --git a/sources/presentation/Stride.Core.Presentation.Wpf/Controls/RectangleFEditor.cs b/sources/presentation/Stride.Core.Presentation.Wpf/Controls/RectangleFEditor.cs index 4c540cc116..b81ae8e59d 100644 --- a/sources/presentation/Stride.Core.Presentation.Wpf/Controls/RectangleFEditor.cs +++ b/sources/presentation/Stride.Core.Presentation.Wpf/Controls/RectangleFEditor.cs @@ -77,7 +77,7 @@ protected override void UpdateComponentsFromValue(RectangleF? value) } /// - protected override RectangleF? UpateValueFromFloat(float value) + protected override RectangleF? UpdateValueFromFloat(float value) { return new RectangleF(0.0f, 0.0f, value, value); } diff --git a/sources/presentation/Stride.Core.Presentation.Wpf/Controls/RotationEditor.cs b/sources/presentation/Stride.Core.Presentation.Wpf/Controls/RotationEditor.cs index 6877e5e433..6dd5bc45ec 100644 --- a/sources/presentation/Stride.Core.Presentation.Wpf/Controls/RotationEditor.cs +++ b/sources/presentation/Stride.Core.Presentation.Wpf/Controls/RotationEditor.cs @@ -86,7 +86,7 @@ protected override void UpdateComponentsFromValue(Quaternion? value) } /// - protected override Quaternion? UpateValueFromFloat(float value) + protected override Quaternion? UpdateValueFromFloat(float value) { var radian = MathUtil.DegreesToRadians(value); decomposedRotation = new Vector3(radian); diff --git a/sources/presentation/Stride.Core.Presentation.Wpf/Controls/Vector2Editor.cs b/sources/presentation/Stride.Core.Presentation.Wpf/Controls/Vector2Editor.cs index c418f63ae7..b7ed329e1d 100644 --- a/sources/presentation/Stride.Core.Presentation.Wpf/Controls/Vector2Editor.cs +++ b/sources/presentation/Stride.Core.Presentation.Wpf/Controls/Vector2Editor.cs @@ -87,7 +87,7 @@ protected override void UpdateComponentsFromValue(Vector2? value) } /// - protected override Vector2? UpateValueFromFloat(float value) + protected override Vector2? UpdateValueFromFloat(float value) { return new Vector2(value); } diff --git a/sources/presentation/Stride.Core.Presentation.Wpf/Controls/Vector3Editor.cs b/sources/presentation/Stride.Core.Presentation.Wpf/Controls/Vector3Editor.cs index 1d7089dc7a..cc1d7247ce 100644 --- a/sources/presentation/Stride.Core.Presentation.Wpf/Controls/Vector3Editor.cs +++ b/sources/presentation/Stride.Core.Presentation.Wpf/Controls/Vector3Editor.cs @@ -102,7 +102,7 @@ protected override void UpdateComponentsFromValue(Vector3? value) } /// - protected override Vector3? UpateValueFromFloat(float value) + protected override Vector3? UpdateValueFromFloat(float value) { return new Vector3(value); } diff --git a/sources/presentation/Stride.Core.Presentation.Wpf/Controls/Vector4Editor.cs b/sources/presentation/Stride.Core.Presentation.Wpf/Controls/Vector4Editor.cs index 92ea0094ae..f4b2c88c69 100644 --- a/sources/presentation/Stride.Core.Presentation.Wpf/Controls/Vector4Editor.cs +++ b/sources/presentation/Stride.Core.Presentation.Wpf/Controls/Vector4Editor.cs @@ -117,7 +117,7 @@ protected override void UpdateComponentsFromValue(Vector4? value) } /// - protected override Vector4? UpateValueFromFloat(float value) + protected override Vector4? UpdateValueFromFloat(float value) { return new Vector4(value); } diff --git a/sources/presentation/Stride.Core.Presentation.Wpf/Controls/VectorEditorBase.cs b/sources/presentation/Stride.Core.Presentation.Wpf/Controls/VectorEditorBase.cs index a67290bdfc..9dd2059a77 100644 --- a/sources/presentation/Stride.Core.Presentation.Wpf/Controls/VectorEditorBase.cs +++ b/sources/presentation/Stride.Core.Presentation.Wpf/Controls/VectorEditorBase.cs @@ -105,7 +105,7 @@ public override void OnApplyTemplate() /// public override void SetVectorFromValue(float value) { - Value = UpateValueFromFloat(value); + Value = UpdateValueFromFloat(value); } /// @@ -130,7 +130,7 @@ public override void ResetValue() /// Updates the property from a single float. /// /// The value to use to generate a vector. - protected abstract T UpateValueFromFloat(float value); + protected abstract T UpdateValueFromFloat(float value); /// /// Raised when the property is modified. diff --git a/sources/presentation/Stride.Core.Presentation.Wpf/Themes/ThemeSelector.xaml b/sources/presentation/Stride.Core.Presentation.Wpf/Themes/ThemeSelector.xaml index 1d5a6b0432..ce8c5a2658 100644 --- a/sources/presentation/Stride.Core.Presentation.Wpf/Themes/ThemeSelector.xaml +++ b/sources/presentation/Stride.Core.Presentation.Wpf/Themes/ThemeSelector.xaml @@ -4828,4 +4828,19 @@ + +