diff --git a/samples/ControlCatalog/Pages/CarouselPage.xaml b/samples/ControlCatalog/Pages/CarouselPage.xaml index 4a53c9026f..1c2d768966 100644 --- a/samples/ControlCatalog/Pages/CarouselPage.xaml +++ b/samples/ControlCatalog/Pages/CarouselPage.xaml @@ -29,6 +29,7 @@ None Slide Crossfade + 3D Rotation diff --git a/samples/ControlCatalog/Pages/CarouselPage.xaml.cs b/samples/ControlCatalog/Pages/CarouselPage.xaml.cs index 66180d4ccb..6b7707be13 100644 --- a/samples/ControlCatalog/Pages/CarouselPage.xaml.cs +++ b/samples/ControlCatalog/Pages/CarouselPage.xaml.cs @@ -45,6 +45,9 @@ namespace ControlCatalog.Pages case 2: _carousel.PageTransition = new CrossFade(TimeSpan.FromSeconds(0.25)); break; + case 3: + _carousel.PageTransition = new Rotate3DTransition(TimeSpan.FromSeconds(0.5), _orientation.SelectedIndex == 0 ? PageSlide.SlideAxis.Horizontal : PageSlide.SlideAxis.Vertical); + break; } } } diff --git a/samples/RenderDemo/MainWindow.xaml b/samples/RenderDemo/MainWindow.xaml index 429c4776d5..e6e62f86ed 100644 --- a/samples/RenderDemo/MainWindow.xaml +++ b/samples/RenderDemo/MainWindow.xaml @@ -72,5 +72,8 @@ + + + diff --git a/samples/RenderDemo/Pages/Transform3DPage.axaml b/samples/RenderDemo/Pages/Transform3DPage.axaml new file mode 100644 index 0000000000..30ed35bbac --- /dev/null +++ b/samples/RenderDemo/Pages/Transform3DPage.axaml @@ -0,0 +1,210 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/samples/RenderDemo/Pages/Transform3DPage.axaml.cs b/samples/RenderDemo/Pages/Transform3DPage.axaml.cs new file mode 100644 index 0000000000..5083189c4c --- /dev/null +++ b/samples/RenderDemo/Pages/Transform3DPage.axaml.cs @@ -0,0 +1,21 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Markup.Xaml; +using RenderDemo.ViewModels; + +namespace RenderDemo.Pages; + +public class Transform3DPage : UserControl +{ + public Transform3DPage() + { + InitializeComponent(); + this.DataContext = new Transform3DPageViewModel(); + } + + private void InitializeComponent() + { + AvaloniaXamlLoader.Load(this); + } +} + diff --git a/samples/RenderDemo/ViewModels/Transform3DPageViewModel.cs b/samples/RenderDemo/ViewModels/Transform3DPageViewModel.cs new file mode 100644 index 0000000000..c8d1d40e3a --- /dev/null +++ b/samples/RenderDemo/ViewModels/Transform3DPageViewModel.cs @@ -0,0 +1,55 @@ +using System; +using MiniMvvm; +using Avalonia.Animation; + +namespace RenderDemo.ViewModels +{ + public class Transform3DPageViewModel : ViewModelBase + { + private double _depth = 200; + + private double _centerX = 0; + private double _centerY = 0; + private double _centerZ = 0; + private double _angleX = 0; + private double _angleY = 0; + private double _angleZ = 0; + + public double Depth + { + get => _depth; + set => RaiseAndSetIfChanged(ref _depth, value); + } + + public double CenterX + { + get => _centerX; + set => RaiseAndSetIfChanged(ref _centerX, value); + } + public double CenterY + { + get => _centerY; + set => RaiseAndSetIfChanged(ref _centerY, value); + } + public double CenterZ + { + get => _centerZ; + set => RaiseAndSetIfChanged(ref _centerZ, value); + } + public double AngleX + { + get => _angleX; + set => RaiseAndSetIfChanged(ref _angleX, value); + } + public double AngleY + { + get => _angleY; + set => RaiseAndSetIfChanged(ref _angleY, value); + } + public double AngleZ + { + get => _angleZ; + set => RaiseAndSetIfChanged(ref _angleZ, value); + } + } +} diff --git a/src/Avalonia.Base/Animation/Animators/TransformAnimator.cs b/src/Avalonia.Base/Animation/Animators/TransformAnimator.cs index 34ec8ac503..e12ca722f9 100644 --- a/src/Avalonia.Base/Animation/Animators/TransformAnimator.cs +++ b/src/Avalonia.Base/Animation/Animators/TransformAnimator.cs @@ -43,6 +43,7 @@ namespace Avalonia.Animation.Animators normalTransform.Children.Add(new SkewTransform()); normalTransform.Children.Add(new RotateTransform()); normalTransform.Children.Add(new TranslateTransform()); + normalTransform.Children.Add(new Rotate3DTransform()); ctrl.RenderTransform = normalTransform; } diff --git a/src/Avalonia.Base/Animation/PageSlide.cs b/src/Avalonia.Base/Animation/PageSlide.cs index 6f5d12d824..b22bc8b243 100644 --- a/src/Avalonia.Base/Animation/PageSlide.cs +++ b/src/Avalonia.Base/Animation/PageSlide.cs @@ -10,7 +10,7 @@ using Avalonia.VisualTree; namespace Avalonia.Animation { /// - /// Transitions between two pages by sliding them horizontally. + /// Transitions between two pages by sliding them horizontally or vertically. /// public class PageSlide : IPageTransition { @@ -62,7 +62,7 @@ namespace Avalonia.Animation public Easing SlideOutEasing { get; set; } = new LinearEasing(); /// - public async Task Start(Visual? from, Visual? to, bool forward, CancellationToken cancellationToken) + public virtual async Task Start(Visual? from, Visual? to, bool forward, CancellationToken cancellationToken) { if (cancellationToken.IsCancellationRequested) { @@ -157,7 +157,7 @@ namespace Avalonia.Animation /// /// Any one of the parameters may be null, but not both. /// - private static IVisual GetVisualParent(IVisual? from, IVisual? to) + protected static IVisual GetVisualParent(IVisual? from, IVisual? to) { var p1 = (from ?? to)!.VisualParent; var p2 = (to ?? from)!.VisualParent; diff --git a/src/Avalonia.Base/Animation/Transitions/Rotate3DTransition.cs b/src/Avalonia.Base/Animation/Transitions/Rotate3DTransition.cs new file mode 100644 index 0000000000..60c7a97ced --- /dev/null +++ b/src/Avalonia.Base/Animation/Transitions/Rotate3DTransition.cs @@ -0,0 +1,120 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Avalonia.Media; +using Avalonia.Styling; + +namespace Avalonia.Animation; + +public class Rotate3DTransition: PageSlide +{ + + /// + /// Creates a new instance of the + /// + /// How long the rotation should take place + /// The orientation of the rotation + public Rotate3DTransition(TimeSpan duration, SlideAxis orientation = SlideAxis.Horizontal, double? depth = null) + : base(duration, orientation) + { + Depth = depth; + } + + /// + /// Defines the depth of the 3D Effect. If null, depth will be calculated automatically from the width or height + /// of the common parent of the visual being rotated. + /// + public double? Depth { get; set; } + + /// + /// Creates a new instance of the + /// + public Rotate3DTransition() { } + + /// + public override async Task Start(Visual? @from, Visual? to, bool forward, CancellationToken cancellationToken) + { + if (cancellationToken.IsCancellationRequested) + { + return; + } + + var tasks = new Task[from != null && to != null ? 2 : 1]; + var parent = GetVisualParent(from, to); + var (rotateProperty, center) = Orientation switch + { + SlideAxis.Vertical => (Rotate3DTransform.AngleXProperty, parent.Bounds.Height), + SlideAxis.Horizontal => (Rotate3DTransform.AngleYProperty, parent.Bounds.Width), + _ => throw new ArgumentOutOfRangeException() + }; + + var depthSetter = new Setter {Property = Rotate3DTransform.DepthProperty, Value = Depth ?? center}; + var centerZSetter = new Setter {Property = Rotate3DTransform.CenterZProperty, Value = -center / 2}; + + KeyFrame CreateKeyFrame(double cue, double rotation, int zIndex, bool isVisible = true) => + new() { + Setters = + { + new Setter { Property = rotateProperty, Value = rotation }, + new Setter { Property = Visual.ZIndexProperty, Value = zIndex }, + new Setter { Property = Visual.IsVisibleProperty, Value = isVisible }, + centerZSetter, + depthSetter + }, + Cue = new Cue(cue) + }; + + if (from != null) + { + var animation = new Animation + { + Easing = SlideOutEasing, + Duration = Duration, + FillMode = FillMode.Forward, + Children = + { + CreateKeyFrame(0d, 0d, 2), + CreateKeyFrame(0.5d, 45d * (forward ? -1 : 1), 1), + CreateKeyFrame(1d, 90d * (forward ? -1 : 1), 1, isVisible: false) + } + }; + + tasks[0] = animation.RunAsync(from, null, cancellationToken); + } + + if (to != null) + { + to.IsVisible = true; + var animation = new Animation + { + Easing = SlideInEasing, + Duration = Duration, + FillMode = FillMode.Forward, + Children = + { + CreateKeyFrame(0d, 90d * (forward ? 1 : -1), 1), + CreateKeyFrame(0.5d, 45d * (forward ? 1 : -1), 1), + CreateKeyFrame(1d, 0d, 2) + } + }; + + tasks[from != null ? 1 : 0] = animation.RunAsync(to, null, cancellationToken); + } + + await Task.WhenAll(tasks); + + if (!cancellationToken.IsCancellationRequested) + { + if (to != null) + { + to.ZIndex = 2; + } + + if (from != null) + { + from.IsVisible = false; + from.ZIndex = 1; + } + } + } +} diff --git a/src/Avalonia.Base/Matrix.cs b/src/Avalonia.Base/Matrix.cs index b08a0eb98a..5bbc657385 100644 --- a/src/Avalonia.Base/Matrix.cs +++ b/src/Avalonia.Base/Matrix.cs @@ -1,12 +1,22 @@ using System; using System.Globalization; +using System.Linq; +using System.Numerics; using Avalonia.Utilities; namespace Avalonia { /// - /// A 2x3 matrix. + /// A 3x3 matrix. /// + /// Matrix layout: + /// | 1st col | 2nd col | 3r col | + /// 1st row | scaleX | skrewY | persX | + /// 2nd row | skrewX | scaleY | persY | + /// 3rd row | transX | transY | persZ | + /// + /// Note: Skia.SkMatrix uses a transposed layout (where for example skrewX/skrewY and perspp0/tranX are swapped). + /// #if !BUILDTASK public #endif @@ -14,40 +24,76 @@ namespace Avalonia { private readonly double _m11; private readonly double _m12; + private readonly double _m13; private readonly double _m21; private readonly double _m22; + private readonly double _m23; private readonly double _m31; private readonly double _m32; + private readonly double _m33; + + /// + /// Initializes a new instance of the struct (equivalent to a 2x3 Matrix without perspective). + /// + /// The first element of the first row. + /// The second element of the first row. + /// The first element of the second row. + /// The second element of the second row. + /// The first element of the third row. + /// The second element of the third row. + public Matrix( + double scaleX, + double skrewY, + double skrewX, + double scaleY, + double offsetX, + double offsetY) : this( scaleX, skrewY, 0, skrewX, scaleY, 0, offsetX, offsetY, 1) + { + } + + /// /// Initializes a new instance of the struct. /// - /// The first element of the first row. - /// The second element of the first row. - /// The first element of the second row. - /// The second element of the second row. + /// The first element of the first row. + /// The second element of the first row. + /// The third element of the first row. + /// The first element of the second row. + /// The second element of the second row. + /// The third element of the second row. /// The first element of the third row. /// The second element of the third row. + /// The third element of the third row. public Matrix( - double m11, - double m12, - double m21, - double m22, + double scaleX, + double skrewY, + double persX, + double skrewX, + double scaleY, + double persY, double offsetX, - double offsetY) + double offsetY, + double persZ) { - _m11 = m11; - _m12 = m12; - _m21 = m21; - _m22 = m22; + _m11 = scaleX; + _m12 = skrewY; + _m13 = persX; + _m21 = skrewX; + _m22 = scaleY; + _m23 = persY; _m31 = offsetX; _m32 = offsetY; + _m33 = persZ; } /// /// Returns the multiplicative identity matrix. /// - public static Matrix Identity { get; } = new Matrix(1.0, 0.0, 0.0, 1.0, 0.0, 0.0); + public static Matrix Identity { get; } = new Matrix( + 1.0, 0.0, 0.0, + 0.0, 1.0, 0.0, + 0.0, 0.0, 1.0); /// /// Returns whether the matrix is the identity matrix. @@ -60,35 +106,50 @@ namespace Avalonia public bool HasInverse => !MathUtilities.IsZero(GetDeterminant()); /// - /// The first element of the first row + /// The first element of the first row (scaleX). /// public double M11 => _m11; /// - /// The second element of the first row + /// The second element of the first row (skrewY). /// public double M12 => _m12; /// - /// The first element of the second row + /// The third element of the first row (persX: input x-axis perspective factor). + /// + public double M13 => _m13; + + /// + /// The first element of the second row (skrewX). /// public double M21 => _m21; /// - /// The second element of the second row + /// The second element of the second row (scaleY). /// public double M22 => _m22; /// - /// The first element of the third row + /// The third element of the second row (persY: input y-axis perspective factor). + /// + public double M23 => _m23; + + /// + /// The first element of the third row (offsetX/translateX). /// public double M31 => _m31; /// - /// The second element of the third row + /// The second element of the third row (offsetY/translateY). /// public double M32 => _m32; + /// + /// The third element of the third row (persZ: perspective scale factor). + /// + public double M33 => _m33; + /// /// Multiplies two matrices together and returns the resulting matrix. /// @@ -98,12 +159,15 @@ namespace Avalonia public static Matrix operator *(Matrix value1, Matrix value2) { return new Matrix( - (value1.M11 * value2.M11) + (value1.M12 * value2.M21), - (value1.M11 * value2.M12) + (value1.M12 * value2.M22), - (value1.M21 * value2.M11) + (value1.M22 * value2.M21), - (value1.M21 * value2.M12) + (value1.M22 * value2.M22), - (value1._m31 * value2.M11) + (value1._m32 * value2.M21) + value2._m31, - (value1._m31 * value2.M12) + (value1._m32 * value2.M22) + value2._m32); + (value1.M11 * value2.M11) + (value1.M12 * value2.M21) + (value1.M13 * value2.M31), + (value1.M11 * value2.M12) + (value1.M12 * value2.M22) + (value1.M13 * value2.M32), + (value1.M11 * value2.M13) + (value1.M12 * value2.M23) + (value1.M13 * value2.M33), + (value1.M21 * value2.M11) + (value1.M22 * value2.M21) + (value1.M23 * value2.M31), + (value1.M21 * value2.M12) + (value1.M22 * value2.M22) + (value1.M23 * value2.M32), + (value1.M21 * value2.M13) + (value1.M22 * value2.M23) + (value1.M23 * value2.M33), + (value1.M31 * value2.M11) + (value1.M32 * value2.M21) + (value1.M33 * value2.M31), + (value1.M31 * value2.M12) + (value1.M32 * value2.M22) + (value1.M33 * value2.M32), + (value1.M31 * value2.M13) + (value1.M32 * value2.M23) + (value1.M33 * value2.M33)); } /// @@ -171,7 +235,7 @@ namespace Avalonia /// A scaling matrix. public static Matrix CreateScale(double xScale, double yScale) { - return CreateScale(new Vector(xScale, yScale)); + return new Matrix(xScale, 0, 0, yScale, 0, 0); } /// @@ -181,7 +245,7 @@ namespace Avalonia /// A scaling matrix. public static Matrix CreateScale(Vector scales) { - return new Matrix(scales.X, 0, 0, scales.Y, 0, 0); + return CreateScale(scales.X, scales.Y); } /// @@ -214,7 +278,7 @@ namespace Avalonia { return angle * 0.0174532925; } - + /// /// Appends another matrix as post-multiplication operation. /// Equivalent to this * value; @@ -227,7 +291,7 @@ namespace Avalonia } /// - /// Prpends another matrix as pre-multiplication operation. + /// Prepends another matrix as pre-multiplication operation. /// Equivalent to value * this; /// /// A matrix. @@ -247,7 +311,49 @@ namespace Avalonia /// public double GetDeterminant() { - return (_m11 * _m22) - (_m12 * _m21); + // implemented using "Laplace expansion": + return _m11 * (_m22 * _m33 - _m23 * _m32) + - _m12 * (_m21 * _m33 - _m23 * _m31) + + _m13 * (_m21 * _m32 - _m22 * _m31); + } + + /// + /// Transforms the point with the matrix + /// + /// The point to be transformed + /// The transformed point + public Point Transform(Point p) + { + Point transformedResult; + + // If this matrix contains a non-affine transform with need to extend + // the point to a 3D vector and flatten it back for 2d display + // by multiplying X and Y with the inverse of the Z axis. + // The code below also works with affine transformations, but for performance (and compatibility) + // reasons we will use the more complex calculation only if necessary + if (ContainsPerspective()) + { + var m44 = new Matrix4x4( + (float)M11, (float)M12, (float)M13, 0, + (float)M21, (float)M22, (float)M23, 0, + (float)M31, (float)M32, (float)M33, 0, + 0, 0, 0, 1 + ); + + var vector = new Vector3((float)p.X, (float)p.Y, 1); + var transformedVector = Vector3.Transform(vector, m44); + var z = 1 / transformedVector.Z; + + transformedResult = new Point(transformedVector.X * z, transformedVector.Y * z); + } + else + { + return new Point( + (p.X * M11) + (p.Y * M21) + M31, + (p.X * M12) + (p.Y * M22) + M32); + } + + return transformedResult; } /// @@ -260,10 +366,13 @@ namespace Avalonia // ReSharper disable CompareOfFloatsByEqualityOperator return _m11 == other.M11 && _m12 == other.M12 && + _m13 == other.M13 && _m21 == other.M21 && _m22 == other.M22 && + _m23 == other.M23 && _m31 == other.M31 && - _m32 == other.M32; + _m32 == other.M32 && + _m33 == other.M33; // ReSharper restore CompareOfFloatsByEqualityOperator } @@ -280,9 +389,18 @@ namespace Avalonia /// The hash code. public override int GetHashCode() { - return M11.GetHashCode() + M12.GetHashCode() + - M21.GetHashCode() + M22.GetHashCode() + - M31.GetHashCode() + M32.GetHashCode(); + return (_m11, _m12, _m13, _m21, _m22, _m23, _m31, _m32, _m33).GetHashCode(); + } + + /// + /// Determines if the current matrix contains perspective (non-affine) transforms (true) or only (affine) transforms that could be mapped into an 2x3 matrix (false). + /// + public bool ContainsPerspective() + { + + // ReSharper disable CompareOfFloatsByEqualityOperator + return _m13 != 0 || _m23 != 0 || _m33 != 1; + // ReSharper restore CompareOfFloatsByEqualityOperator } /// @@ -292,15 +410,25 @@ namespace Avalonia public override string ToString() { CultureInfo ci = CultureInfo.CurrentCulture; + + string msg; + double[] values; + + if (ContainsPerspective()) + { + msg = "{{ {{M11:{0} M12:{1} M13:{2}}} {{M21:{3} M22:{4} M23:{5}}} {{M31:{6} M32:{7} M33:{8}}} }}"; + values = new[] { M11, M12, M13, M21, M22, M23, M31, M32, M33 }; + } + else + { + msg = "{{ {{M11:{0} M12:{1}}} {{M21:{2} M22:{3}}} {{M31:{4} M32:{5}}} }}"; + values = new[] { M11, M12, M21, M22, M31, M32 }; + } + return string.Format( ci, - "{{ {{M11:{0} M12:{1}}} {{M21:{2} M22:{3}}} {{M31:{4} M32:{5}}} }}", - M11.ToString(ci), - M12.ToString(ci), - M21.ToString(ci), - M22.ToString(ci), - M31.ToString(ci), - M32.ToString(ci)); + msg, + values.Select((v) => v.ToString(ci)).ToArray()); } /// @@ -318,14 +446,20 @@ namespace Avalonia return false; } + var invdet = 1 / d; + inverted = new Matrix( - _m22 / d, - -_m12 / d, - -_m21 / d, - _m11 / d, - ((_m21 * _m32) - (_m22 * _m31)) / d, - ((_m12 * _m31) - (_m11 * _m32)) / d); - + (_m22 * _m33 - _m32 * _m23) * invdet, + (_m13 * _m31 - _m12 * _m33) * invdet, + (_m12 * _m23 - _m13 * _m22) * invdet, + (_m23 * _m31 - _m21 * _m33) * invdet, + (_m11 * _m33 - _m13 * _m31) * invdet, + (_m21 * _m13 - _m11 * _m23) * invdet, + (_m21 * _m32 - _m31 * _m22) * invdet, + (_m21 * _m12 - _m11 * _m32) * invdet, + (_m11 * _m22 - _m21 * _m12) * invdet + ); + return true; } @@ -336,7 +470,7 @@ namespace Avalonia /// The inverted matrix. public Matrix Invert() { - if (!TryInvert(out Matrix inverted)) + if (!TryInvert(out var inverted)) { throw new InvalidOperationException("Transform is not invertible."); } @@ -347,20 +481,30 @@ namespace Avalonia /// /// Parses a string. /// - /// Six comma-delimited double values (m11, m12, m21, m22, offsetX, offsetY) that describe the new + /// Six or nine comma-delimited double values (m11, m12, m21, m22, offsetX, offsetY[, persX, persY, persZ]) that describe the new /// The . public static Matrix Parse(string s) { + // initialize to satisfy compiler - only used when retrieved from string. + double v8 = 0; + double v9 = 0; + using (var tokenizer = new StringTokenizer(s, CultureInfo.InvariantCulture, exceptionMessage: "Invalid Matrix.")) { - return new Matrix( - tokenizer.ReadDouble(), - tokenizer.ReadDouble(), - tokenizer.ReadDouble(), - tokenizer.ReadDouble(), - tokenizer.ReadDouble(), - tokenizer.ReadDouble() - ); + var v1 = tokenizer.ReadDouble(); + var v2 = tokenizer.ReadDouble(); + var v3 = tokenizer.ReadDouble(); + var v4 = tokenizer.ReadDouble(); + var v5 = tokenizer.ReadDouble(); + var v6 = tokenizer.ReadDouble(); + var pers = tokenizer.TryReadDouble(out var v7); + pers = pers && tokenizer.TryReadDouble(out v8); + pers = pers && tokenizer.TryReadDouble(out v9); + + if (pers) + return new Matrix(v1, v2, v7, v3, v4, v8, v5, v6, v9); + else + return new Matrix(v1, v2, v3, v4, v5, v6); } } @@ -369,14 +513,14 @@ namespace Avalonia /// /// Matrix to decompose. /// Decomposed matrix. - /// The status of the operation. + /// The status of the operation. public static bool TryDecomposeTransform(Matrix matrix, out Decomposed decomposed) { decomposed = default; var determinant = matrix.GetDeterminant(); - if (MathUtilities.IsZero(determinant)) + if (MathUtilities.IsZero(determinant) || matrix.ContainsPerspective()) { return false; } diff --git a/src/Avalonia.Base/Point.cs b/src/Avalonia.Base/Point.cs index 67e7d71fbc..fbdf0db800 100644 --- a/src/Avalonia.Base/Point.cs +++ b/src/Avalonia.Base/Point.cs @@ -1,5 +1,6 @@ using System; using System.Globalization; +using System.Numerics; #if !BUILDTASK using Avalonia.Animation.Animators; #endif @@ -168,12 +169,7 @@ namespace Avalonia /// The point. /// The matrix. /// The resulting point. - public static Point operator *(Point point, Matrix matrix) - { - return new Point( - (point.X * matrix.M11) + (point.Y * matrix.M21) + matrix.M31, - (point.X * matrix.M12) + (point.Y * matrix.M22) + matrix.M32); - } + public static Point operator *(Point point, Matrix matrix) => matrix.Transform(point); /// /// Parses a string. @@ -242,18 +238,7 @@ namespace Avalonia /// /// The transform. /// The transformed point. - public Point Transform(Matrix transform) - { - var x = X; - var y = Y; - var xadd = y * transform.M21 + transform.M31; - var yadd = x * transform.M12 + transform.M32; - x *= transform.M11; - x += xadd; - y *= transform.M22; - y += yadd; - return new Point(x, y); - } + public Point Transform(Matrix transform) => transform.Transform(this); /// /// Returns a new point with the specified X coordinate. diff --git a/src/Avalonia.Base/Rotate3DTransform.cs b/src/Avalonia.Base/Rotate3DTransform.cs new file mode 100644 index 0000000000..2c4e515861 --- /dev/null +++ b/src/Avalonia.Base/Rotate3DTransform.cs @@ -0,0 +1,210 @@ +using System; +using System.Numerics; +using Avalonia.Animation.Animators; + +namespace Avalonia.Media; + +/// +/// Non-Affine 3D transformation for rotating a visual around a definable axis +/// +public class Rotate3DTransform : Transform +{ + private readonly bool _isInitializing; + + /// + /// Defines the property. + /// + public static readonly StyledProperty AngleXProperty = + AvaloniaProperty.Register(nameof(AngleX)); + + /// + /// Defines the property. + /// + public static readonly StyledProperty AngleYProperty = + AvaloniaProperty.Register(nameof(AngleY)); + + /// + /// Defines the property. + /// + public static readonly StyledProperty AngleZProperty = + AvaloniaProperty.Register(nameof(AngleZ)); + + + /// + /// Defines the property. + /// + public static readonly StyledProperty CenterXProperty = + AvaloniaProperty.Register(nameof(CenterX)); + + + /// + /// Defines the property. + /// + public static readonly StyledProperty CenterYProperty = + AvaloniaProperty.Register(nameof(CenterY)); + + + /// + /// Defines the property. + /// + public static readonly StyledProperty CenterZProperty = + AvaloniaProperty.Register(nameof(CenterZ)); + + /// + /// Defines the property. + /// + public static readonly StyledProperty DepthProperty = + AvaloniaProperty.Register(nameof(Depth)); + + /// + /// Initializes a new instance of the class. + /// + public Rotate3DTransform() { } + + /// + /// Initializes a new instance of the class. + /// + /// The rotation around the X-Axis + /// The rotation around the Y-Axis + /// The rotation around the Z-Axis + /// The origin of the X-Axis + /// The origin of the Y-Axis + /// The origin of the Z-Axis + /// The depth of the 3D effect + public Rotate3DTransform( + double angleX, + double angleY, + double angleZ, + double centerX, + double centerY, + double centerZ, + double depth) : this() + { + _isInitializing = true; + AngleX = angleX; + AngleY = angleY; + AngleZ = angleZ; + CenterX = centerX; + CenterY = centerY; + CenterZ = centerZ; + Depth = depth; + _isInitializing = false; + } + + /// + /// Sets the rotation around the X-Axis + /// + public double AngleX + { + get => GetValue(AngleXProperty); + set => SetValue(AngleXProperty, value); + } + + /// + /// Sets the rotation around the Y-Axis + /// + public double AngleY + { + get => GetValue(AngleYProperty); + set => SetValue(AngleYProperty, value); + } + + /// + /// Sets the rotation around the Z-Axis + /// + public double AngleZ + { + get => GetValue(AngleZProperty); + set => SetValue(AngleZProperty, value); + } + + /// + /// Moves the origin the X-Axis rotates around + /// + public double CenterX + { + get => GetValue(CenterXProperty); + set => SetValue(CenterXProperty, value); + } + + /// + /// Moves the origin the Y-Axis rotates around + /// + public double CenterY + { + get => GetValue(CenterYProperty); + set => SetValue(CenterYProperty, value); + } + + /// + /// Moves the origin the Z-Axis rotates around + /// + public double CenterZ + { + get => GetValue(CenterZProperty); + set => SetValue(CenterZProperty, value); + } + + /// + /// Affects the depth of the rotation effect + /// + public double Depth + { + get => GetValue(DepthProperty); + set => SetValue(DepthProperty, value); + } + + /// + /// Gets the transform's . + /// + public override Matrix Value + { + get + { + var matrix44 = Matrix4x4.Identity; + //Copy values first, because it's not guaranteed, that values will not change during calculation + var (copyCenterX, + copyCenterY, + copyCenterZ, + copyAngleX, + copyAngleY, + copyAngleZ, + copyDepth) = (CenterX, CenterY, CenterZ, AngleX, AngleY, AngleZ, Depth); + + var centerSum = copyCenterX + copyCenterY + copyCenterZ; + + if (Math.Abs(centerSum) > double.Epsilon) matrix44 *= Matrix4x4.CreateTranslation(-(float)copyCenterX, -(float)copyCenterY, -(float)copyCenterZ); + + if (copyAngleX != 0) matrix44 *= Matrix4x4.CreateRotationX((float)Matrix.ToRadians(copyAngleX)); + if (copyAngleY != 0) matrix44 *= Matrix4x4.CreateRotationY((float)Matrix.ToRadians(copyAngleY)); + if (copyAngleZ != 0) matrix44 *= Matrix4x4.CreateRotationZ((float)Matrix.ToRadians(copyAngleZ)); + + if (Math.Abs(centerSum) > double.Epsilon) matrix44 *= Matrix4x4.CreateTranslation((float)copyCenterX, (float)copyCenterY, (float)copyCenterZ); + + if (copyDepth != 0) + { + var perspectiveMatrix = Matrix4x4.Identity; + perspectiveMatrix.M34 = -1 / (float)copyDepth; + matrix44 *= perspectiveMatrix; + } + + var matrix = new Matrix( + matrix44.M11, + matrix44.M12, + matrix44.M14, + matrix44.M21, + matrix44.M22, + matrix44.M24, + matrix44.M41, + matrix44.M42, + matrix44.M44); + + return matrix; + } + } + + protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) + { + if (!_isInitializing) RaiseChanged(); + } +} diff --git a/src/Avalonia.Build.Tasks/Avalonia.Build.Tasks.csproj b/src/Avalonia.Build.Tasks/Avalonia.Build.Tasks.csproj index e9b99c9aa8..a801d338c3 100644 --- a/src/Avalonia.Build.Tasks/Avalonia.Build.Tasks.csproj +++ b/src/Avalonia.Build.Tasks/Avalonia.Build.Tasks.csproj @@ -103,5 +103,6 @@ + diff --git a/src/Skia/Avalonia.Skia/SkiaSharpExtensions.cs b/src/Skia/Avalonia.Skia/SkiaSharpExtensions.cs index 166f2e8f98..d584216f17 100644 --- a/src/Skia/Avalonia.Skia/SkiaSharpExtensions.cs +++ b/src/Skia/Avalonia.Skia/SkiaSharpExtensions.cs @@ -103,9 +103,9 @@ namespace Avalonia.Skia SkewY = (float)m.M12, ScaleY = (float)m.M22, TransY = (float)m.M32, - Persp0 = 0, - Persp1 = 0, - Persp2 = 1 + Persp0 = (float)m.M13, + Persp1 = (float)m.M23, + Persp2 = (float)m.M33 }; return sm; diff --git a/tests/Avalonia.Base.UnitTests/MatrixTests.cs b/tests/Avalonia.Base.UnitTests/MatrixTests.cs new file mode 100644 index 0000000000..99fe4685d7 --- /dev/null +++ b/tests/Avalonia.Base.UnitTests/MatrixTests.cs @@ -0,0 +1,92 @@ +using System; +using System.Numerics; +using System.Runtime.InteropServices; +using Avalonia.Media; +using Xunit; + +namespace Avalonia.Visuals.UnitTests; + +/// +/// These tests use the "official" Matrix4x4 and Matrix3x2 from the System.Numerics namespace, to validate +/// that Avalonias own implementation of a 3x3 Matrix works correctly. +/// +public class MatrixTests +{ + /// + /// Because Avalonia is working internally with doubles, but System.Numerics Vector and Matrix implementations + /// only make use of floats, we need to reduce precision, comparing them. It should be sufficient to compare + /// 5 fractional digits to ensure, that the result is correct. + /// + /// The expected vector + /// The actual transformed point + private void AssertCoordinatesEqualWithReducedPrecision(Vector2 expected, Point actual) + { + double ReducePrecision(double input) => Math.Truncate(input * 10000); + + var expectedX = ReducePrecision(expected.X); + var expectedY = ReducePrecision(expected.Y); + + var actualX = ReducePrecision(actual.X); + var actualY = ReducePrecision(actual.Y); + + Assert.Equal(expectedX, actualX); + Assert.Equal(expectedY, actualY); + } + + [Fact] + public void Transform_Point_Should_Return_Correct_Value_For_Translated_Matrix() + { + var vector2 = Vector2.Transform( + new Vector2(1, 1), + Matrix3x2.CreateTranslation(2, 2)); + var expected = new Point(vector2.X, vector2.Y); + + var matrix = Matrix.CreateTranslation(2, 2); + var point = new Point(1, 1); + var transformedPoint = matrix.Transform(point); + + Assert.Equal(expected, transformedPoint); + } + + [Fact] + public void Transform_Point_Should_Return_Correct_Value_For_Rotated_Matrix() + { + var expected = Vector2.Transform( + new Vector2(0, 10), + Matrix3x2.CreateRotation((float)Matrix.ToRadians(45))); + + var matrix = Matrix.CreateRotation(Matrix.ToRadians(45)); + var point = new Point(0, 10); + var actual = matrix.Transform(point); + + AssertCoordinatesEqualWithReducedPrecision(expected, actual); + } + + [Fact] + public void Transform_Point_Should_Return_Correct_Value_For_Scaled_Matrix() + { + var vector2 = Vector2.Transform( + new Vector2(1, 1), + Matrix3x2.CreateScale(2, 2)); + var expected = new Point(vector2.X, vector2.Y); + var matrix = Matrix.CreateScale(2, 2); + var point = new Point(1, 1); + var actual = matrix.Transform(point); + + Assert.Equal(expected, actual); + } + + [Fact] + public void Transform_Point_Should_Return_Correct_Value_For_Skewed_Matrix() + { + var expected = Vector2.Transform( + new Vector2(1, 1), + Matrix3x2.CreateSkew(30, 20)); + + var matrix = Matrix.CreateSkew(30, 20); + var point = new Point(1, 1); + var actual = matrix.Transform(point); + + AssertCoordinatesEqualWithReducedPrecision(expected, actual); + } +}