Skip to content

Commit 34501f9

Browse files
authored
feat: ImageElement can rotate the image (#3039)
1 parent bdb9a4a commit 34501f9

File tree

20 files changed

+363
-18
lines changed

20 files changed

+363
-18
lines changed

sources/editor/Stride.Assets.Presentation/AssetEditors/UIEditor/Views/ThicknessEditor.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -110,7 +110,7 @@ protected override void UpdateComponentsFromValue(Thickness? value)
110110
}
111111

112112
/// <inheritdoc/>
113-
protected override Thickness? UpateValueFromFloat(float value)
113+
protected override Thickness? UpdateValueFromFloat(float value)
114114
{
115115
return Thickness.UniformCuboid(value);
116116
}

sources/editor/Stride.Assets.Presentation/View/UIPropertyTemplates.xaml

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,21 @@
11
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
2+
xmlns:s="clr-namespace:System;assembly=mscorlib"
23
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
34
xmlns:ui="clr-namespace:Stride.UI;assembly=Stride.UI"
45
xmlns:views="clr-namespace:Stride.Assets.Presentation.AssetEditors.UIEditor.Views"
6+
xmlns:tp="clr-namespace:Stride.Core.Assets.Editor.View.TemplateProviders;assembly=Stride.Core.Assets.Editor"
57
xmlns:sd="http://schemas.stride3d.net/xaml/presentation">
68
<ResourceDictionary.MergedDictionaries>
79
<ResourceDictionary Source="/Stride.Core.Assets.Editor;component/View/DefaultPropertyTemplateProviders.xaml"/>
810
<ResourceDictionary Source="ImageDictionary.xaml"/>
911
</ResourceDictionary.MergedDictionaries>
1012

13+
<tp:TypeAndPropertyNameMatchTemplateProvider x:Key="RotationTemplateProvider" Type="{x:Type s:Single}" PropertyName="Rotation" sd:PropertyViewHelper.TemplateCategory="PropertyEditor">
14+
<DataTemplate>
15+
<sd:AngleEditor Value="{Binding NodeValue}" />
16+
</DataTemplate>
17+
</tp:TypeAndPropertyNameMatchTemplateProvider>
18+
1119
<sd:TypeMatchTemplateProvider x:Key="StripDefinitionTemplateProvider" Type="{x:Type ui:StripDefinition}" sd:PropertyViewHelper.TemplateCategory="PropertyEditor">
1220
<DataTemplate>
1321
<UniformGrid Rows="1">
@@ -83,8 +91,4 @@
8391
<views:ThicknessEditor Value="{Binding NodeValue}" DecimalPlaces="3" />
8492
</DataTemplate>
8593
</sd:TypeMatchTemplateProvider>
86-
8794
</ResourceDictionary>
88-
89-
90-
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
// Copyright (c) .NET Foundation and Contributors (https://dotnetfoundation.org/ & https://stride3d.net)
2+
// Distributed under the MIT license. See the LICENSE.md file in the project root for more information.
3+
using Stride.Core.Presentation.Quantum.ViewModels;
4+
5+
namespace Stride.Core.Assets.Editor.View.TemplateProviders
6+
{
7+
/// <summary>
8+
/// A template provider that matches nodes based on both their type and property name.
9+
/// </summary>
10+
public class TypeAndPropertyNameMatchTemplateProvider : TypeMatchTemplateProvider
11+
{
12+
/// <inheritdoc/>
13+
public override string Name => $"{base.Name}_{PropertyName}";
14+
15+
/// <summary>
16+
/// Gets or sets the name of the property to match.
17+
/// </summary>
18+
public string PropertyName { get; set; }
19+
20+
public override bool MatchNode(NodeViewModel node)
21+
{
22+
return base.MatchNode(node) && node.Name == PropertyName;
23+
}
24+
}
25+
}
Lines changed: 188 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,188 @@
1+
// Copyright (c) .NET Foundation and Contributors (https://dotnetfoundation.org/ & https://stride3d.net)
2+
// Distributed under the MIT license. See the LICENSE.md file in the project root for more information.
3+
4+
using Stride.Core.Mathematics;
5+
using Stride.Graphics;
6+
using Stride.UI.Controls;
7+
using Xunit;
8+
9+
namespace Stride.UI.Tests.Layering
10+
{
11+
/// <summary>
12+
/// Tests for the <see cref="ImageElement.Rotation"/> property.
13+
/// </summary>
14+
[System.ComponentModel.Description("Tests for ImageElement rotation functionality")]
15+
public class ImageElementRotationTests
16+
{
17+
[Fact]
18+
[System.ComponentModel.Description("Test that the default rotation value is 0")]
19+
public void TestDefaultRotation()
20+
{
21+
var image = new ImageElement();
22+
Assert.Equal(0f, image.Rotation);
23+
}
24+
25+
[Fact]
26+
[System.ComponentModel.Description("Test that the default LocalMatrix is Identity when rotation is 0")]
27+
public void TestDefaultLocalMatrix()
28+
{
29+
var image = new ImageElement();
30+
Assert.Equal(Matrix.Identity, image.LocalMatrix);
31+
}
32+
33+
[Fact]
34+
[System.ComponentModel.Description("Test setting rotation to a positive value")]
35+
public void TestSetPositiveRotation()
36+
{
37+
var image = new ImageElement();
38+
var angle = MathUtil.PiOverFour; // 45 degrees
39+
40+
image.Rotation = angle;
41+
42+
Assert.Equal(angle, image.Rotation);
43+
}
44+
45+
[Fact]
46+
[System.ComponentModel.Description("Test setting rotation to a negative value (counter-clockwise)")]
47+
public void TestSetNegativeRotation()
48+
{
49+
var image = new ImageElement();
50+
var angle = -MathUtil.PiOverFour; // -45 degrees
51+
52+
image.Rotation = angle;
53+
54+
Assert.Equal(angle, image.Rotation);
55+
}
56+
57+
[Fact]
58+
[System.ComponentModel.Description("Test that LocalMatrix is updated when rotation changes")]
59+
public void TestLocalMatrixUpdatesOnRotationChange()
60+
{
61+
var image = new ImageElement();
62+
var angle = MathUtil.PiOverTwo; // 90 degrees
63+
64+
image.Rotation = angle;
65+
66+
var expectedMatrix = Matrix.RotationZ(angle);
67+
AssertMatrixEqual(expectedMatrix, image.LocalMatrix);
68+
}
69+
70+
[Fact]
71+
[System.ComponentModel.Description("Test that setting rotation to 0 resets LocalMatrix to Identity")]
72+
public void TestRotationZeroResetsToIdentity()
73+
{
74+
var image = new ImageElement();
75+
76+
// First set a non-zero rotation
77+
image.Rotation = MathUtil.PiOverFour;
78+
Assert.NotEqual(Matrix.Identity, image.LocalMatrix);
79+
80+
// Then reset to zero
81+
image.Rotation = 0f;
82+
Assert.Equal(Matrix.Identity, image.LocalMatrix);
83+
}
84+
85+
[Fact]
86+
[System.ComponentModel.Description("Test that very small rotation values (near zero) set LocalMatrix to Identity")]
87+
public void TestVerySmallRotationSetsIdentity()
88+
{
89+
var image = new ImageElement();
90+
91+
// Set a value smaller than float.Epsilon
92+
image.Rotation = float.Epsilon / 2f;
93+
94+
// Should be treated as zero
95+
Assert.Equal(Matrix.Identity, image.LocalMatrix);
96+
}
97+
98+
[Fact]
99+
[System.ComponentModel.Description("Test multiple rotation changes")]
100+
public void TestMultipleRotationChanges()
101+
{
102+
var image = new ImageElement();
103+
104+
// First rotation
105+
image.Rotation = MathUtil.PiOverFour;
106+
AssertMatrixEqual(Matrix.RotationZ(MathUtil.PiOverFour), image.LocalMatrix);
107+
108+
// Second rotation
109+
image.Rotation = MathUtil.PiOverTwo;
110+
AssertMatrixEqual(Matrix.RotationZ(MathUtil.PiOverTwo), image.LocalMatrix);
111+
112+
// Third rotation (negative)
113+
image.Rotation = -MathUtil.PiOverFour;
114+
AssertMatrixEqual(Matrix.RotationZ(-MathUtil.PiOverFour), image.LocalMatrix);
115+
}
116+
117+
[Fact]
118+
[System.ComponentModel.Description("Test that setting the same rotation value doesn't trigger unnecessary updates")]
119+
public void TestSetSameRotationValue()
120+
{
121+
var image = new ImageElement();
122+
var angle = MathUtil.PiOverFour;
123+
124+
image.Rotation = angle;
125+
var firstMatrix = image.LocalMatrix;
126+
127+
// Set the same value again
128+
image.Rotation = angle;
129+
var secondMatrix = image.LocalMatrix;
130+
131+
// Matrix should be the same
132+
Assert.Equal(firstMatrix, secondMatrix);
133+
}
134+
135+
[Fact]
136+
[System.ComponentModel.Description("Test that rotation doesn't affect the image size or measurement")]
137+
public void TestRotationDoesNotAffectMeasurement()
138+
{
139+
var sprite = new Sprite()
140+
{
141+
Region = new Rectangle(0, 0, 100, 50)
142+
};
143+
var image = new ImageElement()
144+
{
145+
Source = (Rendering.Sprites.SpriteFromTexture)sprite,
146+
StretchType = StretchType.None
147+
};
148+
149+
// Measure without rotation
150+
image.Measure(new Vector3(200, 200, 0));
151+
var sizeWithoutRotation = image.DesiredSizeWithMargins;
152+
153+
// Apply rotation and measure again
154+
image.Rotation = MathUtil.PiOverFour;
155+
image.Measure(new Vector3(200, 200, 0));
156+
var sizeWithRotation = image.DesiredSizeWithMargins;
157+
158+
// Rotation should not change the measured size
159+
Assert.Equal(sizeWithoutRotation, sizeWithRotation);
160+
}
161+
162+
/// <summary>
163+
/// Helper method to assert that two matrices are approximately equal within a tolerance.
164+
/// </summary>
165+
private static void AssertMatrixEqual(Matrix expected, Matrix actual, int precision = 5)
166+
{
167+
Assert.Equal(expected.M11, actual.M11, precision);
168+
Assert.Equal(expected.M12, actual.M12, precision);
169+
Assert.Equal(expected.M13, actual.M13, precision);
170+
Assert.Equal(expected.M14, actual.M14, precision);
171+
172+
Assert.Equal(expected.M21, actual.M21, precision);
173+
Assert.Equal(expected.M22, actual.M22, precision);
174+
Assert.Equal(expected.M23, actual.M23, precision);
175+
Assert.Equal(expected.M24, actual.M24, precision);
176+
177+
Assert.Equal(expected.M31, actual.M31, precision);
178+
Assert.Equal(expected.M32, actual.M32, precision);
179+
Assert.Equal(expected.M33, actual.M33, precision);
180+
Assert.Equal(expected.M34, actual.M34, precision);
181+
182+
Assert.Equal(expected.M41, actual.M41, precision);
183+
Assert.Equal(expected.M42, actual.M42, precision);
184+
Assert.Equal(expected.M43, actual.M43, precision);
185+
Assert.Equal(expected.M44, actual.M44, precision);
186+
}
187+
}
188+
}

sources/engine/Stride.UI.Tests/Layering/ImageElementTests.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ public void TestBasicInvalidations()
4040
UIElementLayeringTests.TestNoInvalidation(this, () => source.Region = new Rectangle(8, 9, 3, 4)); // if the size of the region does not change we avoid re-measuring
4141
UIElementLayeringTests.TestNoInvalidation(this, () => source.Orientation = ImageOrientation.Rotated90); // no changes
4242
UIElementLayeringTests.TestNoInvalidation(this, () => source.Borders = Vector4.One); // no changes
43+
UIElementLayeringTests.TestNoInvalidation(this, () => Rotation = MathUtil.PiOverFour); // rotation does not affect layout/measurement
4344

4445
// ReSharper restore ImplicitlyCapturedClosure
4546
}

sources/engine/Stride.UI.Tests/Stride.UI.Tests.Windows.csproj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@
4242
<Compile Include="Layering\GridTests.cs" />
4343
<Compile Include="Layering\ImageButtonTests.cs" />
4444
<Compile Include="Layering\ImageElementTests.cs" />
45+
<Compile Include="Layering\ImageElementRotationTests.cs" />
4546
<Compile Include="Layering\MeasureArrangeValidator.cs" />
4647
<Compile Include="Layering\MeasureReflector.cs" />
4748
<Compile Include="Layering\MeasureValidator.cs" />

sources/engine/Stride.UI/Controls/ImageElement.cs

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ public class ImageElement : UIElement
3232
[DefaultValue(null)]
3333
public ISpriteProvider Source
3434
{
35-
get { return source;}
35+
get { return source; }
3636
set
3737
{
3838
if (source == value)
@@ -86,6 +86,29 @@ public StretchDirection StretchDirection
8686
}
8787
}
8888

89+
/// <summary>
90+
/// Gets or sets the rotation angle in radians (clockwise around Z-axis).
91+
/// </summary>
92+
/// <remarks>The rotation is applied around the center of the image. Positive values rotate clockwise.</remarks>
93+
/// <userdoc>The rotation angle in radians. Positive values rotate clockwise.</userdoc>
94+
[DataMember]
95+
[Display(category: LayoutCategory)]
96+
[DefaultValue(0f)]
97+
public float Rotation
98+
{
99+
get { return field; }
100+
set
101+
{
102+
if (Math.Abs(field - value) <= MathUtil.ZeroTolerance)
103+
{
104+
return;
105+
}
106+
107+
field = value;
108+
UpdateLocalMatrix();
109+
}
110+
}
111+
89112
protected override Vector3 ArrangeOverride(Vector3 finalSizeWithoutMargins)
90113
{
91114
return ImageSizeHelper.CalculateImageSizeFromAvailable(sprite, finalSizeWithoutMargins, StretchType, StretchDirection, false);
@@ -125,5 +148,20 @@ private void OnSpriteChanged(Sprite currentSprite)
125148
sprite.BorderChanged += InvalidateMeasure;
126149
}
127150
}
151+
152+
/// <summary>
153+
/// Updates the local transformation matrix based on the current rotation angle.
154+
/// </summary>
155+
private void UpdateLocalMatrix()
156+
{
157+
if (Rotation == 0)
158+
{
159+
LocalMatrix = Matrix.Identity;
160+
}
161+
else
162+
{
163+
LocalMatrix = Matrix.RotationZ(Rotation);
164+
}
165+
}
128166
}
129167
}

0 commit comments

Comments
 (0)