diff --git a/Avalonia.Desktop.slnf b/Avalonia.Desktop.slnf index ffff140b5d..40a3802a59 100644 --- a/Avalonia.Desktop.slnf +++ b/Avalonia.Desktop.slnf @@ -62,6 +62,7 @@ "tests\\Avalonia.Markup.UnitTests\\Avalonia.Markup.UnitTests.csproj", "tests\\Avalonia.Markup.Xaml.UnitTests\\Avalonia.Markup.Xaml.UnitTests.csproj", "tests\\Avalonia.ReactiveUI.UnitTests\\Avalonia.ReactiveUI.UnitTests.csproj", + "tests\\Avalonia.RenderTests.WpfCompare\\Avalonia.RenderTests.WpfCompare.csproj", "tests\\Avalonia.Skia.RenderTests\\Avalonia.Skia.RenderTests.csproj", "tests\\Avalonia.Skia.UnitTests\\Avalonia.Skia.UnitTests.csproj", "tests\\Avalonia.UnitTests\\Avalonia.UnitTests.csproj", diff --git a/Avalonia.sln b/Avalonia.sln index 5dbd99adaf..1ac6b77e4e 100644 --- a/Avalonia.sln +++ b/Avalonia.sln @@ -291,6 +291,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "BuildTasks", "BuildTasks", EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PInvoke", "tests\TestFiles\BuildTasks\PInvoke\PInvoke.csproj", "{0A948D71-99C5-43E9-BACB-B0BA59EA25B4}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Avalonia.RenderTests.WpfCompare", "tests\Avalonia.RenderTests.WpfCompare\Avalonia.RenderTests.WpfCompare.csproj", "{9AE1B827-21AC-4063-AB22-C8804B7F931E}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -675,6 +677,10 @@ Global {0A948D71-99C5-43E9-BACB-B0BA59EA25B4}.Debug|Any CPU.Build.0 = Debug|Any CPU {0A948D71-99C5-43E9-BACB-B0BA59EA25B4}.Release|Any CPU.ActiveCfg = Release|Any CPU {0A948D71-99C5-43E9-BACB-B0BA59EA25B4}.Release|Any CPU.Build.0 = Release|Any CPU + {9AE1B827-21AC-4063-AB22-C8804B7F931E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {9AE1B827-21AC-4063-AB22-C8804B7F931E}.Debug|Any CPU.Build.0 = Debug|Any CPU + {9AE1B827-21AC-4063-AB22-C8804B7F931E}.Release|Any CPU.ActiveCfg = Release|Any CPU + {9AE1B827-21AC-4063-AB22-C8804B7F931E}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -758,6 +764,7 @@ Global {9D6AEF22-221F-4F4B-B335-A4BA510F002C} = {C5A00AC3-B34C-4564-9BDD-2DA473EF4D8B} {5BF0C3B8-E595-4940-AB30-2DA206C2F085} = {9D6AEF22-221F-4F4B-B335-A4BA510F002C} {0A948D71-99C5-43E9-BACB-B0BA59EA25B4} = {5BF0C3B8-E595-4940-AB30-2DA206C2F085} + {9AE1B827-21AC-4063-AB22-C8804B7F931E} = {C5A00AC3-B34C-4564-9BDD-2DA473EF4D8B} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {87366D66-1391-4D90-8999-95A620AD786A} diff --git a/tests/Avalonia.Direct2D1.RenderTests/Avalonia.Direct2D1.RenderTests.csproj b/tests/Avalonia.Direct2D1.RenderTests/Avalonia.Direct2D1.RenderTests.csproj index febe87630e..e6cf735b2b 100644 --- a/tests/Avalonia.Direct2D1.RenderTests/Avalonia.Direct2D1.RenderTests.csproj +++ b/tests/Avalonia.Direct2D1.RenderTests/Avalonia.Direct2D1.RenderTests.csproj @@ -1,10 +1,12 @@  $(AvsCurrentTargetFramework) + $(DefineConstants);AVALONIA_D2D true + diff --git a/tests/Avalonia.RenderTests.WpfCompare/Avalonia.RenderTests.WpfCompare.csproj b/tests/Avalonia.RenderTests.WpfCompare/Avalonia.RenderTests.WpfCompare.csproj new file mode 100644 index 0000000000..d20f1b35d3 --- /dev/null +++ b/tests/Avalonia.RenderTests.WpfCompare/Avalonia.RenderTests.WpfCompare.csproj @@ -0,0 +1,23 @@ + + + $(AvsCurrentWindowsTargetFramework) + true + enable + true + + + + + + + + + + + + + + + + + diff --git a/tests/Avalonia.RenderTests.WpfCompare/CrossFact.cs b/tests/Avalonia.RenderTests.WpfCompare/CrossFact.cs new file mode 100644 index 0000000000..8b002eb52e --- /dev/null +++ b/tests/Avalonia.RenderTests.WpfCompare/CrossFact.cs @@ -0,0 +1,13 @@ +using Xunit; + +namespace Avalonia.RenderTests.WpfCompare; + +public class CrossFactAttribute : StaFactAttribute +{ + +} + +public class CrossTheoryAttribute : StaTheoryAttribute +{ + +} diff --git a/tests/Avalonia.RenderTests.WpfCompare/CrossTestBase.cs b/tests/Avalonia.RenderTests.WpfCompare/CrossTestBase.cs new file mode 100644 index 0000000000..e7d6b27213 --- /dev/null +++ b/tests/Avalonia.RenderTests.WpfCompare/CrossTestBase.cs @@ -0,0 +1,53 @@ +using System; +using System.IO; +using System.Runtime.CompilerServices; +using System.Windows.Media; +using System.Windows.Media.Imaging; +using CrossUI; + +namespace Avalonia.RenderTests.WpfCompare; + +public class CrossTestBase +{ + private readonly string _groupName; + public CrossTestBase(string groupName) + { + _groupName = groupName; + } + + protected void RenderAndCompare(CrossControl root, [CallerMemberName] string? testName = null, double dpi = 96) + { + var dir = Path.Combine(GetTestsDirectory(), "TestFiles", "CrossTests", _groupName); + if (!Directory.Exists(dir)) + Directory.CreateDirectory(dir); + var path = Path.Combine(dir, testName + ".wpf.png"); + + var w = root.Width; + var h = root.Height; + var pw = (int)Math.Ceiling(w * dpi / 96); + var ph = (int)Math.Ceiling(h * dpi / 96); + + var control = new WpfCrossControl(root); + control.Measure(new System.Windows.Size(w, h)); + control.Arrange(new System.Windows.Rect(0, 0, w, h)); + var bmp = new RenderTargetBitmap(pw, ph, dpi, dpi, PixelFormats.Default); + bmp.Render(control); + var encoder = new PngBitmapEncoder(); + encoder.Frames.Add(BitmapFrame.Create(bmp)); + using (var f = File.Create(path)) + encoder.Save(f); + } + + static string GetTestsDirectory() + { + var path = Directory.GetCurrentDirectory(); + + while (path.Length > 0 && Path.GetFileName(path) != "tests") + { + path = Path.GetDirectoryName(path); + } + + return path; + } + +} diff --git a/tests/Avalonia.RenderTests.WpfCompare/CrossUI.Wpf.cs b/tests/Avalonia.RenderTests.WpfCompare/CrossUI.Wpf.cs new file mode 100644 index 0000000000..b450663248 --- /dev/null +++ b/tests/Avalonia.RenderTests.WpfCompare/CrossUI.Wpf.cs @@ -0,0 +1,188 @@ +using System; +using System.Windows.Controls; +using CrossUI; +using System.Collections.Generic; +using System.Linq; +using System.Windows.Media; +using System.Windows.Media.Imaging; +using AlignmentX = System.Windows.Media.AlignmentX; +using AlignmentY = System.Windows.Media.AlignmentY; +using Brush = System.Windows.Media.Brush; +using BrushMappingMode = System.Windows.Media.BrushMappingMode; +using Color = Avalonia.Media.Color; +using Drawing = System.Windows.Media.Drawing; +using DrawingBrush = System.Windows.Media.DrawingBrush; +using DrawingCollection = System.Windows.Media.DrawingCollection; +using DrawingContext = System.Windows.Media.DrawingContext; +using DrawingGroup = System.Windows.Media.DrawingGroup; +using DrawingImage = System.Windows.Media.DrawingImage; +using Geometry = System.Windows.Media.Geometry; +using GeometryDrawing = System.Windows.Media.GeometryDrawing; +using MatrixTransform = System.Windows.Media.MatrixTransform; +using Pen = System.Windows.Media.Pen; +using RectangleGeometry = System.Windows.Media.RectangleGeometry; +using SolidColorBrush = System.Windows.Media.SolidColorBrush; +using Stretch = System.Windows.Media.Stretch; +using TileBrush = System.Windows.Media.TileBrush; +using TileMode = System.Windows.Media.TileMode; +using Transform = System.Windows.Media.Transform; +using WPoint = System.Windows.Point; +using WSize = System.Windows.Size; +using WRect = System.Windows.Rect; +using WColor = System.Windows.Media.Color; +using WMatrix = System.Windows.Media.Matrix; +namespace Avalonia.RenderTests.WpfCompare; + +internal static class WpfConvertExtensions +{ + public static WPoint ToWpf(this Point pt) => new(pt.X, pt.Y); + public static WSize ToWpf(this Size size) => new(size.Width, size.Height); + public static WRect ToWpf(this Rect rect) => new(rect.Left, rect.Top, rect.Width, rect.Height); + public static WColor ToWpf(this Color color) => WColor.FromArgb(color.A, color.R, color.G, color.B); + public static WMatrix ToWpf(this Matrix m) => new WMatrix(m.M11, m.M12, m.M21, m.M22, m.M31, m.M32); +} + +internal class WpfCrossControl : Panel +{ + private readonly CrossControl _src; + private readonly Dictionary _children; + + public WpfCrossControl(CrossControl src) + { + _src = src; + _children = src.Children.ToDictionary(x => x, x => new WpfCrossControl(x)); + Width = src.Bounds.Width; + Height = src.Bounds.Height; + RenderTransform = new MatrixTransform(src.RenderTransform.ToWpf()); + foreach (var ch in src.Children) + { + var c = _children[ch]; + this.Children.Add(c); + } + } + + protected override WSize MeasureOverride(WSize availableSize) + { + foreach (var ch in _children) + ch.Value.Measure(ch.Key.Bounds.Size.ToWpf()); + return _src.Bounds.Size.ToWpf(); + } + + protected override WSize ArrangeOverride(WSize finalSize) + { + foreach (var ch in _children) + ch.Value.Arrange(ch.Key.Bounds.ToWpf()); + return base.ArrangeOverride(finalSize); + } + + protected override void OnRender(DrawingContext context) + { + _src.Render(new WpfCrossDrawingContext(context)); + } +} + +internal class WpfCrossDrawingContext : ICrossDrawingContext +{ + private readonly DrawingContext _ctx; + + public WpfCrossDrawingContext(DrawingContext ctx) + { + _ctx = ctx; + } + + private static Transform? ConvertTransform(Matrix? m) => m == null ? null : new MatrixTransform(m.Value.ToWpf()); + + private static Geometry ConvertGeometry(CrossGeometry g) + { + if (g is CrossRectangleGeometry rg) + return new RectangleGeometry(rg.Rect.ToWpf()); + else if (g is CrossSvgGeometry svg) + return Geometry.Parse(svg.Path); + else if (g is CrossEllipseGeometry ellipse) + return new EllipseGeometry(ellipse.Rect.ToWpf()); + throw new NotSupportedException(); + } + + private static Drawing ConvertDrawing(CrossDrawing src) + { + if (src is CrossDrawingGroup g) + return new DrawingGroup() { Children = new DrawingCollection(g.Children.Select(ConvertDrawing)) }; + if (src is CrossGeometryDrawing geo) + return new GeometryDrawing() + { + Geometry = ConvertGeometry(geo.Geometry), Brush = ConvertBrush(geo.Brush), Pen = ConvertPen(geo.Pen) + }; + throw new NotSupportedException(); + } + + private static Brush? ConvertBrush(CrossBrush? brush) + { + if (brush == null) + return null; + static Brush Sync(Brush dst, CrossBrush src) + { + dst.Opacity = src.Opacity; + dst.Transform = ConvertTransform(src.Transform); + dst.RelativeTransform = ConvertTransform(src.RelativeTransform); + return dst; + } + + static Brush SyncTile(TileBrush dst, CrossTileBrush src) + { + dst.Stretch = (Stretch)src.Stretch; + dst.AlignmentX = (AlignmentX)src.AlignmentX; + dst.AlignmentY = (AlignmentY)src.AlignmentY; + dst.TileMode = (TileMode)src.TileMode; + dst.Viewbox = src.Viewbox.ToWpf(); + dst.ViewboxUnits = (BrushMappingMode)src.ViewboxUnits; + dst.Viewport = src.Viewport.ToWpf(); + dst.ViewportUnits = (BrushMappingMode)src.ViewportUnits; + return Sync(dst, src); + } + + static Brush SyncGradient(GradientBrush dst, CrossGradientBrush src) + { + dst.MappingMode = (BrushMappingMode)src.MappingMode; + dst.SpreadMethod = (GradientSpreadMethod)src.SpreadMethod; + dst.GradientStops = + new GradientStopCollection(src.GradientStops.Select(s => new GradientStop(s.Color.ToWpf(), s.Offset))); + return Sync(dst, src); + } + + if (brush is CrossSolidColorBrush br) + return Sync(new SolidColorBrush(br.Color.ToWpf()), brush); + if (brush is CrossDrawingBrush db) + return SyncTile(new DrawingBrush(ConvertDrawing(db.Drawing)), db); + if (brush is CrossRadialGradientBrush radial) + return SyncGradient(new RadialGradientBrush() + { + RadiusX = radial.RadiusX, + RadiusY = radial.RadiusY, + Center = radial.Center.ToWpf(), + GradientOrigin = radial.GradientOrigin.ToWpf() + }, radial); + throw new NotSupportedException(); + } + + private static Pen? ConvertPen(CrossPen? pen) + { + if (pen == null) + return null; + return new Pen(ConvertBrush(pen.Brush), pen.Thickness); + } + + private static ImageSource ConvertImage(CrossImage image) + { + if (image is CrossBitmapImage bi) + return new BitmapImage(new Uri(bi.Path, UriKind.Absolute)); + if (image is CrossDrawingImage di) + return new DrawingImage(ConvertDrawing(di.Drawing)); + throw new NotSupportedException(); + } + + public void DrawRectangle(CrossBrush? brush, CrossPen? pen, Rect rc) => _ctx.DrawRectangle(ConvertBrush(brush), ConvertPen(pen), rc.ToWpf()); + public void DrawGeometry(CrossBrush? brush, CrossPen? pen, CrossGeometry geo) => + _ctx.DrawGeometry(ConvertBrush(brush), ConvertPen(pen), ConvertGeometry(geo)); + + public void DrawImage(CrossImage image, Rect rc) => _ctx.DrawImage(ConvertImage(image), rc.ToWpf()); +} diff --git a/tests/Avalonia.RenderTests.WpfCompare/Properties/AssemblyInfo.cs b/tests/Avalonia.RenderTests.WpfCompare/Properties/AssemblyInfo.cs new file mode 100644 index 0000000000..a4bcec543f --- /dev/null +++ b/tests/Avalonia.RenderTests.WpfCompare/Properties/AssemblyInfo.cs @@ -0,0 +1,3 @@ +using Xunit; + +[assembly: CollectionBehavior(DisableTestParallelization = true)] diff --git a/tests/Avalonia.RenderTests/CrossTests/Brushes/CrossTileBrushTests.cs b/tests/Avalonia.RenderTests/CrossTests/Brushes/CrossTileBrushTests.cs new file mode 100644 index 0000000000..d7283cf9d9 --- /dev/null +++ b/tests/Avalonia.RenderTests/CrossTests/Brushes/CrossTileBrushTests.cs @@ -0,0 +1,54 @@ +using Avalonia.Media; +using CrossUI; +using Xunit; + +#if AVALONIA_SKIA +namespace Avalonia.Skia.RenderTests; +#elif AVALONIA_D2D +namespace Avalonia.Direct2D1.RenderTests; +#else +namespace Avalonia.RenderTests.WpfCompare; +#endif + + +public class CrossTileBrushTests : CrossTestBase +{ + public CrossTileBrushTests() : base("Media/TileBrushes") + { + } + + [CrossFact] + public void Simple_Checkboard_Pattern_Is_Rendered_Identically() + { + RenderAndCompare(new CrossControl() + { + Width = 100, + Height = 100, + Background = new CrossDrawingBrush() + { + Drawing = new CrossDrawingGroup() + { + Children = + { + new CrossGeometryDrawing(new CrossRectangleGeometry(new(0, 0, 20, 20))) + { + Brush = new CrossSolidColorBrush(Colors.White) + }, + new CrossGeometryDrawing(new CrossRectangleGeometry(new(0, 0, 10, 10))) + { + Brush = new CrossSolidColorBrush(Colors.Black) + }, + new CrossGeometryDrawing(new CrossRectangleGeometry(new(10, 10, 10, 10))) + { + Brush = new CrossSolidColorBrush(Colors.Black) + }, + } + }, + Viewport = new Rect(0, 0, 10, 10), + ViewportUnits = BrushMappingMode.Absolute, + TileMode = TileMode.Tile + } + }); + + } +} diff --git a/tests/Avalonia.RenderTests/CrossTests/Brushes/RadialGradientBrushTests.cs b/tests/Avalonia.RenderTests/CrossTests/Brushes/RadialGradientBrushTests.cs new file mode 100644 index 0000000000..98529dcbee --- /dev/null +++ b/tests/Avalonia.RenderTests/CrossTests/Brushes/RadialGradientBrushTests.cs @@ -0,0 +1,63 @@ +using Avalonia.Media; +using CrossUI; +using Xunit; + +#if AVALONIA_SKIA +namespace Avalonia.Skia.RenderTests; +#elif AVALONIA_D2D +namespace Avalonia.Direct2D1.RenderTests; +#else +namespace Avalonia.RenderTests.WpfCompare; +#endif + + +public class CrossRadialGradientBrushTests : CrossTestBase +{ + public CrossRadialGradientBrushTests() : base("Media/RadialGradientBrush") + { + } + + [CrossFact] + public void Transform_Should_Work_As_Expected() + { + RenderAndCompare( + new CrossControl() + { + Children = + { + new CrossFuncControl(ctx => + { + var geo = new CrossEllipseGeometry(new Rect(3.430200000000003, 29.019099999999998, 42.7692, + 19.6732)); + ctx.DrawGeometry(new CrossSolidColorBrush(Colors.Magenta), null, geo); + ctx.DrawGeometry( + new CrossRadialGradientBrush() + { + RadiusX = 12.289, + RadiusY = 12.289, + GradientOrigin = new Point(15.116, 63.965), + Center = new Point(15.116, 63.965), + MappingMode = BrushMappingMode.Absolute, + SpreadMethod = GradientSpreadMethod.Pad, + GradientStops = + { + new GradientStop(Colors.Black, 0), new GradientStop(Colors.Transparent, 1) + }, + Transform = new Matrix(1.664, 0, + 0, 0.75621371, + -0.06567275, -10.272) + }, null, geo); + }) + { + Width = 48, + Height = 48, + RenderTransform = Matrix.CreateScale(4, 4) + } + }, + Width = 256, + Height = 256, + Background = new CrossSolidColorBrush(Colors.White) + }); + + } +} diff --git a/tests/Avalonia.RenderTests/CrossUI/CrossUI.Avalonia.cs b/tests/Avalonia.RenderTests/CrossUI/CrossUI.Avalonia.cs new file mode 100644 index 0000000000..29a772b7ae --- /dev/null +++ b/tests/Avalonia.RenderTests/CrossUI/CrossUI.Avalonia.cs @@ -0,0 +1,172 @@ +#nullable enable +using System; +using System.Collections.Generic; +using System.Linq; +using Avalonia.Controls; +using Avalonia.Media; +using Avalonia.Media.Imaging; +using Avalonia.Media.Immutable; +using CrossUI; + +#if AVALONIA_SKIA +namespace Avalonia.Skia.RenderTests.CrossUI; +#else +namespace Avalonia.Direct2D1.RenderTests.CrossUI; +#endif + +class AvaloniaCrossControl : Control +{ + private readonly CrossControl _src; + private readonly Dictionary _children; + + public AvaloniaCrossControl(CrossControl src) + { + _src = src; + _children = src.Children.ToDictionary(x => x, x => new AvaloniaCrossControl(x)); + Width = src.Bounds.Width; + Height = src.Bounds.Height; + RenderTransform = new MatrixTransform(src.RenderTransform); + RenderTransformOrigin = new RelativePoint(default, RelativeUnit.Relative); + foreach (var ch in src.Children) + { + var c = _children[ch]; + VisualChildren.Add(c); + LogicalChildren.Add(c); + } + } + + protected override Size MeasureOverride(Size availableSize) + { + foreach (var ch in _children) + ch.Value.Measure(ch.Key.Bounds.Size); + return _src.Bounds.Size; + } + + protected override Size ArrangeOverride(Size finalSize) + { + foreach (var ch in _children) + ch.Value.Arrange(ch.Key.Bounds); + return finalSize; + } + + public override void Render(DrawingContext context) + { + _src.Render(new AvaloniaCrossDrawingContext(context)); + } +} + +class AvaloniaCrossDrawingContext : ICrossDrawingContext +{ + private readonly DrawingContext _ctx; + + public AvaloniaCrossDrawingContext(DrawingContext ctx) + { + _ctx = ctx; + } + + static Transform? ConvertTransform(Matrix? m) => m == null ? null : new MatrixTransform(m.Value); + + static RelativeRect ConvertRect(Rect rc, BrushMappingMode mode) + => new RelativeRect(rc, + mode == BrushMappingMode.RelativeToBoundingBox ? RelativeUnit.Relative : RelativeUnit.Absolute); + + static RelativePoint ConvertPoint(Point pt, BrushMappingMode mode) + => new(pt, mode == BrushMappingMode.RelativeToBoundingBox ? RelativeUnit.Relative : RelativeUnit.Absolute); + + static RelativeScalar ConvertScalar(double scalar, BrushMappingMode mode) + => new(scalar, mode == BrushMappingMode.RelativeToBoundingBox ? RelativeUnit.Relative : RelativeUnit.Absolute); + + static Geometry ConvertGeometry(CrossGeometry g) + { + if (g is CrossRectangleGeometry rg) + return new RectangleGeometry(rg.Rect); + else if (g is CrossSvgGeometry svg) + return PathGeometry.Parse(svg.Path); + else if (g is CrossEllipseGeometry ellipse) + return new EllipseGeometry(ellipse.Rect); + throw new NotSupportedException(); + } + + static Drawing ConvertDrawing(CrossDrawing src) + { + if (src is CrossDrawingGroup g) + return new DrawingGroup() { Children = new DrawingCollection(g.Children.Select(ConvertDrawing)) }; + if (src is CrossGeometryDrawing geo) + return new GeometryDrawing() + { + Geometry = ConvertGeometry(geo.Geometry), Brush = ConvertBrush(geo.Brush), Pen = ConvertPen(geo.Pen) + }; + throw new NotSupportedException(); + } + + static IBrush? ConvertBrush(CrossBrush? brush) + { + if (brush == null) + return null; + static Brush Sync(Brush dst, CrossBrush src) + { + dst.Opacity = src.Opacity; + dst.Transform = ConvertTransform(src.Transform); + dst.TransformOrigin = new RelativePoint(default, RelativeUnit.Absolute); + if (src.RelativeTransform != null) + throw new PlatformNotSupportedException(); + return dst; + } + + static Brush SyncTile(TileBrush dst, CrossTileBrush src) + { + dst.Stretch = src.Stretch; + dst.AlignmentX = src.AlignmentX; + dst.AlignmentY = src.AlignmentY; + dst.TileMode = src.TileMode; + dst.SourceRect = ConvertRect(src.Viewbox, src.ViewboxUnits); + dst.DestinationRect = ConvertRect(src.Viewport, src.ViewportUnits); + return Sync(dst, src); + } + + static Brush SyncGradient(GradientBrush dst, CrossGradientBrush src) + { + dst.GradientStops = new GradientStops(); + dst.GradientStops.AddRange(src.GradientStops); + dst.SpreadMethod = src.SpreadMethod; + return Sync(dst, src); + } + + if (brush is CrossSolidColorBrush br) + return Sync(new SolidColorBrush(br.Color), brush); + if (brush is CrossDrawingBrush db) + return SyncTile(new DrawingBrush(ConvertDrawing(db.Drawing)), db); + if (brush is CrossRadialGradientBrush radial) + return SyncGradient( + new RadialGradientBrush() + { + Center = ConvertPoint(radial.Center, radial.MappingMode), + GradientOrigin = ConvertPoint(radial.GradientOrigin, radial.MappingMode), + RadiusX = ConvertScalar(radial.RadiusX, radial.MappingMode), + RadiusY = ConvertScalar(radial.RadiusY, radial.MappingMode) + }, radial); + throw new NotSupportedException(); + } + + static IPen? ConvertPen(CrossPen? pen) + { + if (pen == null) + return null; + return new Pen(ConvertBrush(pen.Brush), pen.Thickness); + } + + static IImage ConvertImage(CrossImage image) + { + if (image is CrossBitmapImage bi) + return new Bitmap(bi.Path); + if (image is CrossDrawingImage di) + return new DrawingImage(ConvertDrawing(di.Drawing)); + throw new NotSupportedException(); + } + + public void DrawRectangle(CrossBrush? brush, CrossPen? pen, Rect rc) => _ctx.DrawRectangle(ConvertBrush(brush), ConvertPen(pen), rc); + public void DrawGeometry(CrossBrush? brush, CrossPen? pen, CrossGeometry geometry) => + _ctx.DrawGeometry(ConvertBrush(brush), ConvertPen(pen), ConvertGeometry(geometry)); + + public void DrawImage(CrossImage image, Rect rc) => _ctx.DrawImage(ConvertImage(image), rc); +} diff --git a/tests/Avalonia.RenderTests/CrossUI/CrossUI.cs b/tests/Avalonia.RenderTests/CrossUI/CrossUI.cs new file mode 100644 index 0000000000..449f75b0c6 --- /dev/null +++ b/tests/Avalonia.RenderTests/CrossUI/CrossUI.cs @@ -0,0 +1,203 @@ +// ReSharper disable RedundantNameQualifier + +#nullable enable +using System; +using System.Collections.Generic; +using Avalonia.Media; +using Avalonia; + +namespace CrossUI; + +public class CrossBrush +{ + public double Opacity = 1; + public Avalonia.Matrix? Transform; + public Avalonia.Matrix? RelativeTransform; +} + +public class CrossSolidColorBrush : CrossBrush +{ + public Avalonia.Media.Color Color = Avalonia.Media.Colors.Black; + + public CrossSolidColorBrush() + { + + } + + public CrossSolidColorBrush(Avalonia.Media.Color color) + { + Color = color; + } +} + +public class CrossGradientBrush : CrossBrush +{ + public List GradientStops = new(); + public Avalonia.Media.GradientSpreadMethod SpreadMethod; + public BrushMappingMode MappingMode; +} + +public class CrossRadialGradientBrush : CrossGradientBrush +{ + public Avalonia.Point Center; + public Avalonia.Point GradientOrigin; + public double RadiusX, RadiusY; +} + +public class CrossTileBrush : CrossBrush +{ + public Avalonia.Media.AlignmentX AlignmentX = AlignmentX.Center; + public Avalonia.Media.AlignmentY AlignmentY = AlignmentY.Center; + public Avalonia.Media.Stretch Stretch = Stretch.Fill; + public Avalonia.Media.TileMode TileMode = TileMode.None; + public Rect Viewbox = new Rect(0, 0, 1, 1); + public Avalonia.Media.BrushMappingMode ViewboxUnits = BrushMappingMode.RelativeToBoundingBox; + public Rect Viewport = new Rect(0, 0, 1, 1); + public Avalonia.Media.BrushMappingMode ViewportUnits = BrushMappingMode.RelativeToBoundingBox; +} + + +public abstract class CrossDrawing +{ +} + + +public class CrossGeometryDrawing : CrossDrawing +{ + public CrossGeometry Geometry; + public CrossBrush? Brush; + public CrossPen? Pen; + public CrossGeometryDrawing(CrossGeometry geometry) + { + Geometry = geometry; + } +} + +public class CrossDrawingGroup : CrossDrawing +{ + public List Children = new(); +} + +public abstract class CrossGeometry +{ + +} + +public class CrossSvgGeometry : CrossGeometry +{ + public string Path; + + public CrossSvgGeometry(string path) + { + Path = path; + } +} + +public class CrossEllipseGeometry : CrossGeometry +{ + public CrossEllipseGeometry(Rect rect) + { + Rect = rect; + } + + public CrossEllipseGeometry() + { + + } + + public Rect Rect { get; set; } +} + +public class CrossRectangleGeometry : CrossGeometry +{ + public Rect Rect; + + public CrossRectangleGeometry(Rect rect) + { + Rect = rect; + } +} + +public class CrossDrawingBrush : CrossTileBrush +{ + public CrossDrawing Drawing; +} + +public class CrossPen +{ + public CrossBrush Brush; + public double Thickness = 1; +} + +public interface ICrossDrawingContext +{ + void DrawRectangle(CrossBrush? brush, CrossPen? pen, Rect rc); + void DrawGeometry(CrossBrush? brush, CrossPen? pen, CrossGeometry geometry); + void DrawImage(CrossImage image, Rect rc); +} + +public abstract class CrossImage +{ + +} + +public class CrossBitmapImage : CrossImage +{ + public string Path; + public CrossBitmapImage(string path) + { + Path = path; + } +} + +public class CrossDrawingImage : CrossImage +{ + public CrossDrawing Drawing; +} + + +public class CrossControl +{ + public Rect Bounds => new Rect(Left, Top, Width, Height); + public double Left, Top, Width, Height; + public CrossBrush? Background; + public CrossPen? Outline; + public List Children = new(); + public Matrix RenderTransform = Matrix.Identity; + + public virtual void Render(ICrossDrawingContext ctx) + { + var rc = new Rect(Bounds.Size); + if (Background != null || Outline != null) + ctx.DrawRectangle(Background, Outline, rc); + } +} + +public class CrossFuncControl : CrossControl +{ + private readonly Action _render; + + public CrossFuncControl(Action render) + { + _render = render; + } + + public override void Render(ICrossDrawingContext ctx) + { + base.Render(ctx); + _render(ctx); + } +} + +public class CrossImageControl : CrossControl +{ + public CrossImage Image; + public override void Render(ICrossDrawingContext ctx) + { + base.Render(ctx); + var rc = new Rect(Bounds.Size); + ctx.DrawImage(Image, rc); + } +} + + diff --git a/tests/Avalonia.RenderTests/TestBase.cs b/tests/Avalonia.RenderTests/TestBase.cs index 0d9f526a4b..1bec00649a 100644 --- a/tests/Avalonia.RenderTests/TestBase.cs +++ b/tests/Avalonia.RenderTests/TestBase.cs @@ -42,27 +42,6 @@ namespace Avalonia.Direct2D1.RenderTests #endif public static FontFamily TestFontFamily = new FontFamily(s_fontUri); - private static readonly TestDispatcherImpl threadingInterface = - new TestDispatcherImpl(); - - private static readonly IAssetLoader assetLoader = new StandardAssetLoader(); - - static TestBase() - { -#if AVALONIA_SKIA - SkiaPlatform.Initialize(); -#else - Direct2D1Platform.Initialize(); -#endif - AvaloniaLocator.CurrentMutable - .Bind() - .ToConstant(threadingInterface); - - AvaloniaLocator.CurrentMutable - .Bind() - .ToConstant(assetLoader); - } - public TestBase(string outputPath) { outputPath = outputPath.Replace('\\', Path.DirectorySeparatorChar); @@ -75,7 +54,7 @@ namespace Avalonia.Direct2D1.RenderTests #endif OutputPath = Path.Combine(testFiles, platform, outputPath); - threadingInterface.MainThread = Thread.CurrentThread; + TestRenderHelper.BeginTest(); } public string OutputPath @@ -83,64 +62,17 @@ namespace Avalonia.Direct2D1.RenderTests get; } - protected Task RenderToFile(Control target, [CallerMemberName] string testName = "", double dpi = 96) + protected async Task RenderToFile(Control target, [CallerMemberName] string testName = "", double dpi = 96) { if (!Directory.Exists(OutputPath)) { Directory.CreateDirectory(OutputPath); } - + var immediatePath = Path.Combine(OutputPath, testName + ".immediate.out.png"); var compositedPath = Path.Combine(OutputPath, testName + ".composited.out.png"); - var factory = AvaloniaLocator.Current.GetRequiredService(); - var pixelSize = new PixelSize((int)target.Width, (int)target.Height); - var size = new Size(target.Width, target.Height); - var dpiVector = new Vector(dpi, dpi); - - using (RenderTargetBitmap bitmap = new RenderTargetBitmap(pixelSize, dpiVector)) - { - target.Measure(size); - target.Arrange(new Rect(size)); - bitmap.Render(target); - bitmap.Save(immediatePath); - } - - var timer = new ManualRenderTimer(); - - var compositor = new Compositor(new RenderLoop(timer), null, true, - new DispatcherCompositorScheduler(), true, Dispatcher.UIThread); - using (var writableBitmap = factory.CreateWriteableBitmap(pixelSize, dpiVector, factory.DefaultPixelFormat, factory.DefaultAlphaFormat)) - { - var root = new TestRenderRoot(dpiVector.X / 96, null!); - using (var renderer = new CompositingRenderer(root, compositor, () => new[] - { - new BitmapFramebufferSurface(writableBitmap) - })) - { - root.Initialize(renderer, target); - renderer.Start(); - Dispatcher.UIThread.RunJobs(); - renderer.Paint(new Rect(root.Bounds.Size), false); - } - writableBitmap.Save(compositedPath); - } - - return Task.CompletedTask; - } - - class BitmapFramebufferSurface : IFramebufferPlatformSurface - { - private readonly IWriteableBitmapImpl _bitmap; - - public BitmapFramebufferSurface(IWriteableBitmapImpl bitmap) - { - _bitmap = bitmap; - } - - public IFramebufferRenderTarget CreateFramebufferRenderTarget() - { - return new FuncFramebufferRenderTarget(() => _bitmap.Lock()); - } + await TestRenderHelper.RenderToFile(target, immediatePath, true, dpi); + await TestRenderHelper.RenderToFile(target, compositedPath, false, dpi); } protected void CompareImages([CallerMemberName] string testName = "", @@ -156,7 +88,7 @@ namespace Avalonia.Direct2D1.RenderTests { if (!skipImmediate) { - var immediateError = CompareImages(immediate!, expected); + var immediateError = TestRenderHelper.CompareImages(immediate!, expected); if (immediateError > 0.022) { Assert.True(false, immediatePath + ": Error = " + immediateError); @@ -165,7 +97,7 @@ namespace Avalonia.Direct2D1.RenderTests if (!skipCompositor) { - var compositedError = CompareImages(composited!, expected); + var compositedError = TestRenderHelper.CompareImages(composited!, expected); if (compositedError > 0.022) { Assert.True(false, compositedPath + ": Error = " + compositedError); @@ -178,108 +110,11 @@ namespace Avalonia.Direct2D1.RenderTests { var expectedPath = Path.Combine(OutputPath, (expectedName ?? testName) + ".expected.png"); var actualPath = Path.Combine(OutputPath, testName + ".out.png"); - - using (var expected = Image.Load(expectedPath)) - using (var actual = Image.Load(actualPath)) - { - double immediateError = CompareImages(actual, expected); - - if (immediateError > 0.022) - { - Assert.True(false, actualPath + ": Error = " + immediateError); - } - } + TestRenderHelper.AssertCompareImages(actualPath, expectedPath); } - /// - /// Calculates root mean square error for given two images. - /// Based roughly on ImageMagick implementation to ensure consistency. - /// - private static double CompareImages(Image actual, Image expected) - { - if (actual.Width != expected.Width || actual.Height != expected.Height) - { - throw new ArgumentException("Images have different resolutions"); - } - - var quantity = actual.Width * actual.Height; - double squaresError = 0; - - const double scale = 1 / 255d; - - for (var x = 0; x < actual.Width; x++) - { - double localError = 0; - - for (var y = 0; y < actual.Height; y++) - { - var expectedAlpha = expected[x, y].A * scale; - var actualAlpha = actual[x, y].A * scale; - - var r = scale * (expectedAlpha * expected[x, y].R - actualAlpha * actual[x, y].R); - var g = scale * (expectedAlpha * expected[x, y].G - actualAlpha * actual[x, y].G); - var b = scale * (expectedAlpha * expected[x, y].B - actualAlpha * actual[x, y].B); - var a = expectedAlpha - actualAlpha; - - var error = r * r + g * g + b * b + a * a; - - localError += error; - } - - squaresError += localError; - } - - var meanSquaresError = squaresError / quantity; - - const int channelCount = 4; - - meanSquaresError = meanSquaresError / channelCount; - - return Math.Sqrt(meanSquaresError); - } - - private static string GetTestsDirectory() - { - var path = Directory.GetCurrentDirectory(); + private static string GetTestsDirectory() => TestRenderHelper.GetTestsDirectory(); - while (path.Length > 0 && Path.GetFileName(path) != "tests") - { - path = Path.GetDirectoryName(path); - } - - return path; - } - - private class TestDispatcherImpl : IDispatcherImpl - { - public bool CurrentThreadIsLoopThread => MainThread.ManagedThreadId == Thread.CurrentThread.ManagedThreadId; - - public Thread MainThread { get; set; } - -#pragma warning disable 67 - public event Action Signaled; - public event Action Timer; -#pragma warning restore 67 - - public void Signal() - { - // No-op - } - - public long Now => 0; - - public void UpdateTimer(long? dueTimeInMs) - { - // No-op - } - } - - public void Dispose() - { - if (Dispatcher.UIThread.CheckAccess()) - { - Dispatcher.UIThread.RunJobs(); - } - } + public void Dispose() => TestRenderHelper.EndTest(); } } diff --git a/tests/Avalonia.RenderTests/TestRenderHelper.cs b/tests/Avalonia.RenderTests/TestRenderHelper.cs new file mode 100644 index 0000000000..81e225f83d --- /dev/null +++ b/tests/Avalonia.RenderTests/TestRenderHelper.cs @@ -0,0 +1,227 @@ +using System.IO; +using System.Runtime.CompilerServices; +using Avalonia.Controls; +using Avalonia.Media.Imaging; +using Avalonia.Rendering; +using SixLabors.ImageSharp; +using Xunit; +using Avalonia.Platform; +using System.Threading.Tasks; +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Linq; +using System.Reactive.Disposables; +using System.Threading; +using Avalonia.Controls.Platform.Surfaces; +using Avalonia.Media; +using Avalonia.Rendering.Composition; +using Avalonia.Threading; +using Avalonia.UnitTests; +using Avalonia.Utilities; +using SixLabors.ImageSharp.PixelFormats; +using Image = SixLabors.ImageSharp.Image; +#if AVALONIA_SKIA +using Avalonia.Skia; +#else +using Avalonia.Direct2D1; +#endif + +#if AVALONIA_SKIA +namespace Avalonia.Skia.RenderTests; +#else +namespace Avalonia.Direct2D1.RenderTests; +#endif + +static class TestRenderHelper +{ + private static readonly TestDispatcherImpl s_dispatcherImpl = + new TestDispatcherImpl(); + + static TestRenderHelper() + { +#if AVALONIA_SKIA + SkiaPlatform.Initialize(); +#else + Direct2D1Platform.Initialize(); +#endif + AvaloniaLocator.CurrentMutable + .Bind() + .ToConstant(s_dispatcherImpl); + + AvaloniaLocator.CurrentMutable.Bind().ToConstant(new StandardAssetLoader()); + } + + + public static Task RenderToFile(Control target, string path, bool immediate, double dpi = 96) + { + var dir = Path.GetDirectoryName(path); + if (!Directory.Exists(dir)) + Directory.CreateDirectory(dir); + + var factory = AvaloniaLocator.Current.GetRequiredService(); + var pixelSize = new PixelSize((int)target.Width, (int)target.Height); + var size = new Size(target.Width, target.Height); + var dpiVector = new Vector(dpi, dpi); + + if (immediate) + { + using (RenderTargetBitmap bitmap = new RenderTargetBitmap(pixelSize, dpiVector)) + { + target.Measure(size); + target.Arrange(new Rect(size)); + bitmap.Render(target); + bitmap.Save(path); + } + } + else + { + var timer = new ManualRenderTimer(); + + var compositor = new Compositor(new RenderLoop(timer), null, true, + new DispatcherCompositorScheduler(), true, Dispatcher.UIThread); + using (var writableBitmap = factory.CreateWriteableBitmap(pixelSize, dpiVector, factory.DefaultPixelFormat, + factory.DefaultAlphaFormat)) + { + var root = new TestRenderRoot(dpiVector.X / 96, null!); + using (var renderer = new CompositingRenderer(root, compositor, + () => new[] { new BitmapFramebufferSurface(writableBitmap) })) + { + root.Initialize(renderer, target); + renderer.Start(); + Dispatcher.UIThread.RunJobs(); + renderer.Paint(new Rect(root.Bounds.Size), false); + } + + writableBitmap.Save(path); + } + } + + return Task.CompletedTask; + } + + class BitmapFramebufferSurface : IFramebufferPlatformSurface + { + private readonly IWriteableBitmapImpl _bitmap; + + public BitmapFramebufferSurface(IWriteableBitmapImpl bitmap) + { + _bitmap = bitmap; + } + + public IFramebufferRenderTarget CreateFramebufferRenderTarget() + { + return new FuncFramebufferRenderTarget(() => _bitmap.Lock()); + } + } + + + public static void BeginTest() + { + s_dispatcherImpl.MainThread = Thread.CurrentThread; + } + + public static void EndTest() + { + if (Dispatcher.UIThread.CheckAccess()) + Dispatcher.UIThread.RunJobs(); + } + + public static string GetTestsDirectory() + { + var path = Directory.GetCurrentDirectory(); + + while (path.Length > 0 && Path.GetFileName(path) != "tests") + { + path = Path.GetDirectoryName(path); + } + + return path; + } + + private class TestDispatcherImpl : IDispatcherImpl + { + public bool CurrentThreadIsLoopThread => MainThread.ManagedThreadId == Thread.CurrentThread.ManagedThreadId; + + public Thread MainThread { get; set; } + +#pragma warning disable 67 + public event Action Signaled; + public event Action Timer; +#pragma warning restore 67 + + public void Signal() + { + // No-op + } + + public long Now => 0; + + public void UpdateTimer(long? dueTimeInMs) + { + // No-op + } + } + + public static void AssertCompareImages(string actualPath, string expectedPath) + { + using (var expected = Image.Load(expectedPath)) + using (var actual = Image.Load(actualPath)) + { + double immediateError = TestRenderHelper.CompareImages(actual, expected); + + if (immediateError > 0.022) + { + Assert.True(false, actualPath + ": Error = " + immediateError); + } + } + } + + /// + /// Calculates root mean square error for given two images. + /// Based roughly on ImageMagick implementation to ensure consistency. + /// + public static double CompareImages(Image actual, Image expected) + { + if (actual.Width != expected.Width || actual.Height != expected.Height) + { + throw new ArgumentException("Images have different resolutions"); + } + + var quantity = actual.Width * actual.Height; + double squaresError = 0; + + const double scale = 1 / 255d; + + for (var x = 0; x < actual.Width; x++) + { + double localError = 0; + + for (var y = 0; y < actual.Height; y++) + { + var expectedAlpha = expected[x, y].A * scale; + var actualAlpha = actual[x, y].A * scale; + + var r = scale * (expectedAlpha * expected[x, y].R - actualAlpha * actual[x, y].R); + var g = scale * (expectedAlpha * expected[x, y].G - actualAlpha * actual[x, y].G); + var b = scale * (expectedAlpha * expected[x, y].B - actualAlpha * actual[x, y].B); + var a = expectedAlpha - actualAlpha; + + var error = r * r + g * g + b * b + a * a; + + localError += error; + } + + squaresError += localError; + } + + var meanSquaresError = squaresError / quantity; + + const int channelCount = 4; + + meanSquaresError = meanSquaresError / channelCount; + + return Math.Sqrt(meanSquaresError); + } + +} diff --git a/tests/Avalonia.RenderTests/WpfCompareTestBase.cs b/tests/Avalonia.RenderTests/WpfCompareTestBase.cs new file mode 100644 index 0000000000..36db8b3da5 --- /dev/null +++ b/tests/Avalonia.RenderTests/WpfCompareTestBase.cs @@ -0,0 +1,9 @@ +#if AVALONIA_SKIA +namespace Avalonia.Skia.RenderTests; +#else +namespace Avalonia.Direct2D1.RenderTests; +#endif +class WpfCompareTestBase +{ + +} diff --git a/tests/Avalonia.Skia.RenderTests/CrossTestBase.cs b/tests/Avalonia.Skia.RenderTests/CrossTestBase.cs new file mode 100644 index 0000000000..215d5b4826 --- /dev/null +++ b/tests/Avalonia.Skia.RenderTests/CrossTestBase.cs @@ -0,0 +1,60 @@ +using System; +using System.IO; +using System.Runtime.CompilerServices; +using Avalonia.Skia.RenderTests; +using Avalonia.Skia.RenderTests.CrossUI; +using CrossUI; +using SixLabors.ImageSharp; +using SixLabors.ImageSharp.PixelFormats; +using Xunit; + +#if AVALONIA_SKIA +namespace Avalonia.Skia.RenderTests; +#else +namespace Avalonia.Direct2D1.RenderTests; +#endif + +class CrossFactAttribute : FactAttribute +{ + +} + +class CrossThreoryAttribute : TheoryAttribute +{ + +} + +public class CrossTestBase : IDisposable +{ + private readonly string _groupName; + public CrossTestBase(string groupName) + { + TestRenderHelper.BeginTest(); + _groupName = groupName; + } + + protected void RenderAndCompare(CrossControl root, [CallerMemberName] string? testName = null, double dpi = 96) + { + var dir = Path.Combine(TestRenderHelper.GetTestsDirectory(), "TestFiles", "CrossTests", _groupName); + if (!Directory.Exists(dir)) + Directory.CreateDirectory(dir); + var flavor = +#if AVALONIA_SKIA + "skia"; +#else + "d2d"; +#endif + var pathBase = Path.Combine(dir, testName); + var renderPath = pathBase + "." + flavor + ".out.png"; + var compareWith = pathBase + ".wpf.png"; + var control = new AvaloniaCrossControl(root); + TestRenderHelper.RenderToFile(control, renderPath, false, dpi); + + TestRenderHelper.AssertCompareImages(renderPath, compareWith); + } + + public void Dispose() + { + TestRenderHelper.EndTest(); + } +} diff --git a/tests/TestFiles/CrossTests/Media/RadialGradientBrush/Transform_Should_Work_As_Expected.wpf.png b/tests/TestFiles/CrossTests/Media/RadialGradientBrush/Transform_Should_Work_As_Expected.wpf.png new file mode 100644 index 0000000000..0c2d533896 Binary files /dev/null and b/tests/TestFiles/CrossTests/Media/RadialGradientBrush/Transform_Should_Work_As_Expected.wpf.png differ diff --git a/tests/TestFiles/CrossTests/Media/TileBrushes/Simple_Checkboard_Pattern_Is_Rendered_Identically.wpf.png b/tests/TestFiles/CrossTests/Media/TileBrushes/Simple_Checkboard_Pattern_Is_Rendered_Identically.wpf.png new file mode 100644 index 0000000000..1ad3c6ec09 Binary files /dev/null and b/tests/TestFiles/CrossTests/Media/TileBrushes/Simple_Checkboard_Pattern_Is_Rendered_Identically.wpf.png differ