From 0dbce40347f7ccc327763d5deaf8d71404de62b5 Mon Sep 17 00:00:00 2001 From: Benedikt Stebner Date: Wed, 9 Sep 2026 08:39:06 +0000 Subject: [PATCH] Add RelativeTransform to brushes (#21881) * Apply tile brush transforms once the tile sits in target space Brush.Transform was concatenated into the shader matrix before the translation that moves the tile onto the painted area, so it acted in the tile's own space instead of the target's. A brush transform on a fill away from the origin - the common case, since a relative viewport resolves against the fill's bounds - came out displaced, and WPF, whose behaviour these brushes follow, disagrees. Both tile paths now place the tile first and let the transform act on the result. Where the placement is identity, which is what the existing goldens cover, the matrices are unchanged. The cross suite gains an image brush so the non-scalable path is covered too, plus a smooth ramp fixture: the star line drawing that was the only image asset survives resampling as sparse speckle, which the comparison metric cannot tell apart from a geometry error. Co-Authored-By: Claude Opus 5 * Apply the conic gradient transform after its angle The sweep shader carried Angle as its local matrix and pre-concatenated Brush.Transform onto it, so the transform acted on the raw sweep and the angle then turned the result. Every other brush bakes its intrinsic geometry in first and lets the brush transform act on the finished pattern. The visible effect was that a translation moved the gradient in a direction rotated by Angle - 90 instead of the direction asked for. A sweep gradient is fully determined by its centre and angle, so the golden here is checked against the brush with its centre moved by the same offset and no transform: the two renders are pixel-identical, while the previous order is 0.169 rmse away against a 0.022 tolerance. Co-Authored-By: Claude Opus 5 * Add RelativeTransform to every brush A brush transform expressed in the unit space of the painted bounds, applied before the absolute Transform - WPF's Brush.RelativeTransform. It lets one brush express a bounds-dependent transform, an SVG gradientTransform in objectBoundingBox units for instance, without baking any one consumer's bounds into a matrix, which is what makes a single brush per gradient definition possible. The property sits on IBrush and Brush, so every brush kind carries it and a consumer reads it without a type test. Each immutable brush takes it through a second constructor, leaving the existing signatures alone and marking them for collapse in v13. The gradient brush animator interpolates it alongside Transform, and the composition schema gains it on both brush bases so the value reaches the render thread as a live resource rather than a snapshot. IBrush is NotClientImplementable, so the added member is suppressed for API validation the way earlier additions to those interfaces are. Nothing consumes it yet. Co-Authored-By: Claude Opus 5 * Consume RelativeTransform in the Skia backend The relative matrix is conjugated into target space at draw time - the unit square maps onto the painted rect, whose origin translation and scale wrap the matrix - and composes before the absolute brush transform. All three gradient kinds and both tile brush paths read it; a solid colour brush has nothing for it to act on, matching WPF. With no relative transform every path reduces to the previous matrices, so existing goldens stay byte-identical. The cross suite compares the result against WPF for the linear and radial gradients, a drawing brush tiled and untiled, an image brush, the composition order against Transform, one brush shared by two differently sized fills, and the solid colour no-op. Goldens under Skia/Media additionally cover the immediate renderer and the GPU backends, which the cross suite does not exercise; each was measured against its WPF counterpart before being committed. Co-Authored-By: Claude Opus 5 --------- Co-authored-by: Claude Opus 5 --- api/Avalonia.nupkg.xml | 12 ++ .../Animators/GradientBrushAnimator.cs | 9 +- src/Avalonia.Base/Media/Brush.cs | 30 ++- src/Avalonia.Base/Media/IBrush.cs | 9 + .../Immutable/ImmutableConicGradientBrush.cs | 28 ++- .../Media/Immutable/ImmutableGradientBrush.cs | 29 ++- .../Media/Immutable/ImmutableImageBrush.cs | 8 +- .../Immutable/ImmutableLinearGradientBrush.cs | 28 ++- .../Immutable/ImmutableRadialGradientBrush.cs | 35 +++- .../Immutable/ImmutableSolidColorBrush.cs | 31 ++- .../Media/Immutable/ImmutableTileBrush.cs | 12 +- .../Brushes/ServerCompositionBrush.cs | 1 + .../Brushes/ServerSimpleCompositionBrush.cs | 1 + .../CompositionRenderDataSceneBrushContent.cs | 1 + .../ImmediateRenderDataSceneBrushContent.cs | 1 + src/Avalonia.Base/composition-schema.xml | 2 + src/Skia/Avalonia.Skia/DrawingContextImpl.cs | 98 +++++---- .../RelativeTransformBrushTests.cs | 123 ++++++++++++ .../Media/RelativeTransformBrushTests.cs | 88 +++++++++ .../SceneGraph/DrawOperationTests.cs | 4 +- .../CrossUI.Wpf.cs | 8 + tests/Avalonia.RenderTests/Assets/Ramp64.png | Bin 0 -> 189 bytes .../CrossRelativeTransformBrushTests.cs | 186 ++++++++++++++++++ .../CrossTests/Brushes/CrossTileBrushTests.cs | 49 +++++ .../CrossUI/CrossUI.Avalonia.cs | 12 +- tests/Avalonia.RenderTests/CrossUI/CrossUI.cs | 11 ++ .../Media/ConicGradientBrushTests.cs | 35 ++++ .../Media/RelativeTransformBrushTests.cs | 146 ++++++++++++++ ..._Transform_Should_Work_As_Expected.wpf.png | Bin 0 -> 2941 bytes ..._Transform_Should_Work_As_Expected.wpf.png | Bin 0 -> 4163 bytes ..._Transform_Should_Work_As_Expected.wpf.png | Bin 0 -> 657 bytes ..._Transform_Should_Work_As_Expected.wpf.png | Bin 0 -> 657 bytes ...form_Should_Apply_Before_Transform.wpf.png | Bin 0 -> 657 bytes ...uld_Follow_The_Bounds_Of_Each_Fill.wpf.png | Bin 0 -> 670 bytes ...lative_Transform_Should_Be_Ignored.wpf.png | Bin 0 -> 670 bytes ..._Transform_Should_Work_As_Expected.wpf.png | Bin 0 -> 3901 bytes ..._Transform_Should_Work_As_Expected.wpf.png | Bin 0 -> 5241 bytes ...h_With_Transform_On_An_Offset_Fill.wpf.png | Bin 0 -> 666 bytes ...h_With_Transform_On_An_Offset_Fill.wpf.png | Bin 0 -> 861 bytes ...Transform_Applies_After_Angle.expected.png | Bin 0 -> 12312 bytes ...g_Brush_Rotated_In_Unit_Space.expected.png | Bin 0 -> 1223 bytes ...radient_Rotated_In_Unit_Space.expected.png | Bin 0 -> 1223 bytes ...radient_Rotated_In_Unit_Space.expected.png | Bin 0 -> 2498 bytes ...sform_Applies_Before_Absolute.expected.png | Bin 0 -> 1045 bytes ...Brush_On_Two_Different_Bounds.expected.png | Bin 0 -> 1710 bytes 45 files changed, 940 insertions(+), 57 deletions(-) create mode 100644 tests/Avalonia.Base.UnitTests/Composition/RelativeTransformBrushTests.cs create mode 100644 tests/Avalonia.Base.UnitTests/Media/RelativeTransformBrushTests.cs create mode 100644 tests/Avalonia.RenderTests/Assets/Ramp64.png create mode 100644 tests/Avalonia.RenderTests/CrossTests/Brushes/CrossRelativeTransformBrushTests.cs create mode 100644 tests/Avalonia.RenderTests/Media/RelativeTransformBrushTests.cs create mode 100644 tests/TestFiles/CrossTests/Media/RelativeTransformBrush/Drawing_Brush_Relative_Transform_Should_Work_As_Expected.wpf.png create mode 100644 tests/TestFiles/CrossTests/Media/RelativeTransformBrush/Image_Brush_Relative_Transform_Should_Work_As_Expected.wpf.png create mode 100644 tests/TestFiles/CrossTests/Media/RelativeTransformBrush/Linear_Gradient_Relative_Transform_Should_Work_As_Expected.wpf.png create mode 100644 tests/TestFiles/CrossTests/Media/RelativeTransformBrush/Radial_Gradient_Relative_Transform_Should_Work_As_Expected.wpf.png create mode 100644 tests/TestFiles/CrossTests/Media/RelativeTransformBrush/Relative_Transform_Should_Apply_Before_Transform.wpf.png create mode 100644 tests/TestFiles/CrossTests/Media/RelativeTransformBrush/Relative_Transform_Should_Follow_The_Bounds_Of_Each_Fill.wpf.png create mode 100644 tests/TestFiles/CrossTests/Media/RelativeTransformBrush/Solid_Color_Relative_Transform_Should_Be_Ignored.wpf.png create mode 100644 tests/TestFiles/CrossTests/Media/RelativeTransformBrush/Tiled_Drawing_Brush_Relative_Transform_Should_Work_As_Expected.wpf.png create mode 100644 tests/TestFiles/CrossTests/Media/RelativeTransformBrush/Tiled_Image_Brush_Relative_And_Absolute_Transform_Should_Work_As_Expected.wpf.png create mode 100644 tests/TestFiles/CrossTests/Media/TileBrushes/Should_Render_Drawing_Brush_With_Transform_On_An_Offset_Fill.wpf.png create mode 100644 tests/TestFiles/CrossTests/Media/TileBrushes/Should_Render_Image_Brush_With_Transform_On_An_Offset_Fill.wpf.png create mode 100644 tests/TestFiles/Skia/Media/ConicGradientBrush/ConicGradientBrush_Transform_Applies_After_Angle.expected.png create mode 100644 tests/TestFiles/Skia/Media/RelativeTransformBrush/Drawing_Brush_Rotated_In_Unit_Space.expected.png create mode 100644 tests/TestFiles/Skia/Media/RelativeTransformBrush/Linear_Gradient_Rotated_In_Unit_Space.expected.png create mode 100644 tests/TestFiles/Skia/Media/RelativeTransformBrush/Radial_Gradient_Rotated_In_Unit_Space.expected.png create mode 100644 tests/TestFiles/Skia/Media/RelativeTransformBrush/Relative_Transform_Applies_Before_Absolute.expected.png create mode 100644 tests/TestFiles/Skia/Media/RelativeTransformBrush/Shared_Brush_On_Two_Different_Bounds.expected.png diff --git a/api/Avalonia.nupkg.xml b/api/Avalonia.nupkg.xml index 6653e52d49..48fb468baa 100644 --- a/api/Avalonia.nupkg.xml +++ b/api/Avalonia.nupkg.xml @@ -241,6 +241,12 @@ baseline/Avalonia/lib/net10.0/Avalonia.Base.dll current/Avalonia/lib/net10.0/Avalonia.Base.dll + + CP0006 + P:Avalonia.Media.IBrush.RelativeTransform + baseline/Avalonia/lib/net10.0/Avalonia.Base.dll + current/Avalonia/lib/net10.0/Avalonia.Base.dll + CP0006 P:Avalonia.Media.IImageBrushSource.Bitmap @@ -301,6 +307,12 @@ baseline/Avalonia/lib/net8.0/Avalonia.Base.dll current/Avalonia/lib/net8.0/Avalonia.Base.dll + + CP0006 + P:Avalonia.Media.IBrush.RelativeTransform + baseline/Avalonia/lib/net8.0/Avalonia.Base.dll + current/Avalonia/lib/net8.0/Avalonia.Base.dll + CP0006 P:Avalonia.Media.IImageBrushSource.Bitmap diff --git a/src/Avalonia.Base/Animation/Animators/GradientBrushAnimator.cs b/src/Avalonia.Base/Animation/Animators/GradientBrushAnimator.cs index cb64e11919..7fc05d8e4f 100644 --- a/src/Avalonia.Base/Animation/Animators/GradientBrushAnimator.cs +++ b/src/Avalonia.Base/Animation/Animators/GradientBrushAnimator.cs @@ -38,7 +38,8 @@ namespace Avalonia.Animation.Animators s_relativePointAnimator.Interpolate(progress, oldRadial.Center, newRadial.Center), s_relativePointAnimator.Interpolate(progress, oldRadial.GradientOrigin, newRadial.GradientOrigin), s_relativeScalarAnimator.Interpolate(progress, oldRadial.RadiusX, newRadial.RadiusX), - s_relativeScalarAnimator.Interpolate(progress, oldRadial.RadiusY, newRadial.RadiusY) + s_relativeScalarAnimator.Interpolate(progress, oldRadial.RadiusY, newRadial.RadiusY), + InterpolateTransform(progress, oldValue.RelativeTransform, newValue.RelativeTransform) ); case IConicGradientBrush oldConic when newValue is IConicGradientBrush newConic: @@ -49,7 +50,8 @@ namespace Avalonia.Animation.Animators s_relativePointAnimator.Interpolate(progress, oldValue.TransformOrigin, newValue.TransformOrigin), oldValue.SpreadMethod, s_relativePointAnimator.Interpolate(progress, oldConic.Center, newConic.Center), - s_doubleAnimator.Interpolate(progress, oldConic.Angle, newConic.Angle)); + s_doubleAnimator.Interpolate(progress, oldConic.Angle, newConic.Angle), + InterpolateTransform(progress, oldValue.RelativeTransform, newValue.RelativeTransform)); case ILinearGradientBrush oldLinear when newValue is ILinearGradientBrush newLinear: return new ImmutableLinearGradientBrush( @@ -59,7 +61,8 @@ namespace Avalonia.Animation.Animators s_relativePointAnimator.Interpolate(progress, oldValue.TransformOrigin, newValue.TransformOrigin), oldValue.SpreadMethod, s_relativePointAnimator.Interpolate(progress, oldLinear.StartPoint, newLinear.StartPoint), - s_relativePointAnimator.Interpolate(progress, oldLinear.EndPoint, newLinear.EndPoint)); + s_relativePointAnimator.Interpolate(progress, oldLinear.EndPoint, newLinear.EndPoint), + InterpolateTransform(progress, oldValue.RelativeTransform, newValue.RelativeTransform)); default: return progress >= 0.5 ? newValue : oldValue; diff --git a/src/Avalonia.Base/Media/Brush.cs b/src/Avalonia.Base/Media/Brush.cs index fb9dca26c9..dfebb87216 100644 --- a/src/Avalonia.Base/Media/Brush.cs +++ b/src/Avalonia.Base/Media/Brush.cs @@ -33,6 +33,12 @@ namespace Avalonia.Media public static readonly StyledProperty TransformOriginProperty = AvaloniaProperty.Register(nameof(TransformOrigin)); + /// + /// Defines the property. + /// + public static readonly StyledProperty RelativeTransformProperty = + AvaloniaProperty.Register(nameof(RelativeTransform)); + /// /// Gets or sets the opacity of the brush. /// @@ -60,6 +66,13 @@ namespace Avalonia.Media set => SetValue(TransformOriginProperty, value); } + /// + public ITransform? RelativeTransform + { + get => GetValue(RelativeTransformProperty); + set => SetValue(RelativeTransformProperty, value); + } + /// /// Parses a brush string. /// @@ -90,7 +103,7 @@ namespace Avalonia.Media protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) { - if (change.Property == TransformProperty) + if (change.Property == TransformProperty || change.Property == RelativeTransformProperty) _resource.ProcessPropertyChangeNotification(change); RegisterForSerialization(); @@ -117,8 +130,10 @@ namespace Avalonia.Media private protected virtual void OnReferencedFromCompositor(Compositor c) { - if (Transform is ICompositionRenderResource resource) - resource.AddRefOnCompositor(c); + if (Transform is ICompositionRenderResource transform) + transform.AddRefOnCompositor(c); + if (RelativeTransform is ICompositionRenderResource relativeTransform) + relativeTransform.AddRefOnCompositor(c); } void ICompositionRenderResource.ReleaseOnCompositor(Compositor c) @@ -129,15 +144,18 @@ namespace Avalonia.Media protected virtual void OnUnreferencedFromCompositor(Compositor c) { - if (Transform is ICompositionRenderResource resource) - resource.ReleaseOnCompositor(c); + if (Transform is ICompositionRenderResource transform) + transform.ReleaseOnCompositor(c); + if (RelativeTransform is ICompositionRenderResource relativeTransform) + relativeTransform.ReleaseOnCompositor(c); } SimpleServerObject? ICompositorSerializable.TryGetServer(Compositor c) => _resource.TryGetForCompositor(c); private protected virtual void SerializeChanges(Compositor c, BatchStreamWriter writer) { - ServerCompositionSimpleBrush.SerializeAllChanges(writer, Opacity, TransformOrigin, Transform.GetServer(c)); + ServerCompositionSimpleBrush.SerializeAllChanges(writer, Opacity, TransformOrigin, Transform.GetServer(c), + RelativeTransform.GetServer(c)); } void ICompositorSerializable.SerializeChanges(Compositor c, BatchStreamWriter writer) => SerializeChanges(c, writer); diff --git a/src/Avalonia.Base/Media/IBrush.cs b/src/Avalonia.Base/Media/IBrush.cs index e32894f67c..260edacf71 100644 --- a/src/Avalonia.Base/Media/IBrush.cs +++ b/src/Avalonia.Base/Media/IBrush.cs @@ -24,5 +24,14 @@ namespace Avalonia.Media /// Gets the origin of the brushes /// RelativePoint TransformOrigin { get; } + + /// + /// Gets the transform applied in the relative coordinate space of the area being painted: + /// the unit square (0,0)-(1,1) maps onto the painted bounds, this transform applies inside + /// that space, and follows in target space. It lets a single brush + /// express a bounds-dependent transform - an SVG gradientTransform in objectBoundingBox + /// units, for example - without baking any one consumer's bounds into a matrix. + /// + ITransform? RelativeTransform { get; } } } diff --git a/src/Avalonia.Base/Media/Immutable/ImmutableConicGradientBrush.cs b/src/Avalonia.Base/Media/Immutable/ImmutableConicGradientBrush.cs index 70232f0a63..7308142078 100644 --- a/src/Avalonia.Base/Media/Immutable/ImmutableConicGradientBrush.cs +++ b/src/Avalonia.Base/Media/Immutable/ImmutableConicGradientBrush.cs @@ -17,6 +17,7 @@ namespace Avalonia.Media.Immutable /// The spread method. /// The center point for the gradient. /// The starting angle for the gradient. + // TODO13: remove, folding relativeTransform into the overload below as an optional parameter. public ImmutableConicGradientBrush( IReadOnlyList gradientStops, double opacity = 1, @@ -25,7 +26,32 @@ namespace Avalonia.Media.Immutable GradientSpreadMethod spreadMethod = GradientSpreadMethod.Pad, RelativePoint? center = null, double angle = 0) - : base(gradientStops, opacity, transform, transformOrigin, spreadMethod) + : this(gradientStops, opacity, transform, transformOrigin, spreadMethod, center, angle, null) + { + } + + /// + /// Initializes a new instance of the class. + /// + /// The gradient stops. + /// The opacity of the brush. + /// The transform of the brush. + /// The transform origin of the brush + /// The spread method. + /// The center point for the gradient. + /// The starting angle for the gradient. + /// The transform of the brush in the relative coordinate + /// space of the area being painted, applied before . + public ImmutableConicGradientBrush( + IReadOnlyList gradientStops, + double opacity, + ImmutableTransform? transform, + RelativePoint? transformOrigin, + GradientSpreadMethod spreadMethod, + RelativePoint? center, + double angle, + ImmutableTransform? relativeTransform) + : base(gradientStops, opacity, transform, transformOrigin, spreadMethod, relativeTransform) { Center = center ?? RelativePoint.Center; Angle = angle; diff --git a/src/Avalonia.Base/Media/Immutable/ImmutableGradientBrush.cs b/src/Avalonia.Base/Media/Immutable/ImmutableGradientBrush.cs index c86d86d20a..69d8ba74a2 100644 --- a/src/Avalonia.Base/Media/Immutable/ImmutableGradientBrush.cs +++ b/src/Avalonia.Base/Media/Immutable/ImmutableGradientBrush.cs @@ -15,18 +15,41 @@ namespace Avalonia.Media.Immutable /// The transform of the brush. /// The transform origin of the brush /// The spread method. + // TODO13: remove, folding relativeTransform into the overload below as an optional parameter. protected ImmutableGradientBrush( IReadOnlyList gradientStops, double opacity, ImmutableTransform? transform, RelativePoint? transformOrigin, GradientSpreadMethod spreadMethod) + : this(gradientStops, opacity, transform, transformOrigin, spreadMethod, null) + { + } + + /// + /// Initializes a new instance of the class. + /// + /// The gradient stops. + /// The opacity of the brush. + /// The transform of the brush. + /// The transform origin of the brush + /// The spread method. + /// The transform of the brush in the relative coordinate + /// space of the area being painted, applied before . + protected ImmutableGradientBrush( + IReadOnlyList gradientStops, + double opacity, + ImmutableTransform? transform, + RelativePoint? transformOrigin, + GradientSpreadMethod spreadMethod, + ImmutableTransform? relativeTransform) { GradientStops = gradientStops; Opacity = opacity; Transform = transform; TransformOrigin = transformOrigin.HasValue ? transformOrigin.Value : RelativePoint.TopLeft; SpreadMethod = spreadMethod; + RelativeTransform = relativeTransform; } /// @@ -35,9 +58,8 @@ namespace Avalonia.Media.Immutable /// The brush from which this brush's properties should be copied. protected ImmutableGradientBrush(GradientBrush source) : this(source.GradientStops.ToImmutable(), source.Opacity, source.Transform?.ToImmutable(), - source.TransformOrigin, source.SpreadMethod) + source.TransformOrigin, source.SpreadMethod, source.RelativeTransform?.ToImmutable()) { - } /// @@ -58,5 +80,8 @@ namespace Avalonia.Media.Immutable /// public GradientSpreadMethod SpreadMethod { get; } + + /// + public ITransform? RelativeTransform { get; } } } diff --git a/src/Avalonia.Base/Media/Immutable/ImmutableImageBrush.cs b/src/Avalonia.Base/Media/Immutable/ImmutableImageBrush.cs index ce4ba5d140..19bac465bf 100644 --- a/src/Avalonia.Base/Media/Immutable/ImmutableImageBrush.cs +++ b/src/Avalonia.Base/Media/Immutable/ImmutableImageBrush.cs @@ -22,6 +22,8 @@ namespace Avalonia.Media.Immutable /// How the source rectangle will be stretched to fill the destination rect. /// /// The tile mode. + /// The transform of the brush in the relative coordinate + /// space of the area being painted, applied before . public ImmutableImageBrush( Bitmap? source, AlignmentX alignmentX = AlignmentX.Center, @@ -32,7 +34,8 @@ namespace Avalonia.Media.Immutable RelativePoint transformOrigin = default, RelativeRect? sourceRect = null, Stretch stretch = Stretch.Uniform, - TileMode tileMode = TileMode.None) + TileMode tileMode = TileMode.None, + ImmutableTransform? relativeTransform = null) : base( alignmentX, alignmentY, @@ -42,7 +45,8 @@ namespace Avalonia.Media.Immutable transformOrigin, sourceRect ?? RelativeRect.Fill, stretch, - tileMode) + tileMode, + relativeTransform) { Source = source; } diff --git a/src/Avalonia.Base/Media/Immutable/ImmutableLinearGradientBrush.cs b/src/Avalonia.Base/Media/Immutable/ImmutableLinearGradientBrush.cs index 3c26b5c009..17b2df93e1 100644 --- a/src/Avalonia.Base/Media/Immutable/ImmutableLinearGradientBrush.cs +++ b/src/Avalonia.Base/Media/Immutable/ImmutableLinearGradientBrush.cs @@ -17,6 +17,7 @@ namespace Avalonia.Media.Immutable /// The spread method. /// The start point for the gradient. /// The end point for the gradient. + // TODO13: remove, folding relativeTransform into the overload below as an optional parameter. public ImmutableLinearGradientBrush( IReadOnlyList gradientStops, double opacity = 1, @@ -25,7 +26,32 @@ namespace Avalonia.Media.Immutable GradientSpreadMethod spreadMethod = GradientSpreadMethod.Pad, RelativePoint? startPoint = null, RelativePoint? endPoint = null) - : base(gradientStops, opacity, transform, transformOrigin, spreadMethod) + : this(gradientStops, opacity, transform, transformOrigin, spreadMethod, startPoint, endPoint, null) + { + } + + /// + /// Initializes a new instance of the class. + /// + /// The gradient stops. + /// The opacity of the brush. + /// The transform of the brush. + /// The transform origin of the brush + /// The spread method. + /// The start point for the gradient. + /// The end point for the gradient. + /// The transform of the brush in the relative coordinate + /// space of the area being painted, applied before . + public ImmutableLinearGradientBrush( + IReadOnlyList gradientStops, + double opacity, + ImmutableTransform? transform, + RelativePoint? transformOrigin, + GradientSpreadMethod spreadMethod, + RelativePoint? startPoint, + RelativePoint? endPoint, + ImmutableTransform? relativeTransform) + : base(gradientStops, opacity, transform, transformOrigin, spreadMethod, relativeTransform) { StartPoint = startPoint ?? RelativePoint.TopLeft; EndPoint = endPoint ?? RelativePoint.BottomRight; diff --git a/src/Avalonia.Base/Media/Immutable/ImmutableRadialGradientBrush.cs b/src/Avalonia.Base/Media/Immutable/ImmutableRadialGradientBrush.cs index 3b210874d9..dd77d9d2f7 100644 --- a/src/Avalonia.Base/Media/Immutable/ImmutableRadialGradientBrush.cs +++ b/src/Avalonia.Base/Media/Immutable/ImmutableRadialGradientBrush.cs @@ -41,6 +41,7 @@ namespace Avalonia.Media.Immutable } + // TODO13: remove, folding relativeTransform into the overload below as an optional parameter. public ImmutableRadialGradientBrush( IReadOnlyList gradientStops, double opacity = 1, @@ -52,7 +53,39 @@ namespace Avalonia.Media.Immutable RelativeScalar? radiusX = null, RelativeScalar? radiusY = null ) - : base(gradientStops, opacity, transform, transformOrigin, spreadMethod) + : this(gradientStops, opacity, transform, transformOrigin, spreadMethod, center, gradientOrigin, + radiusX, radiusY, null) + { + } + + /// + /// Initializes a new instance of the class. + /// + /// The gradient stops. + /// The opacity of the brush. + /// The transform of the brush. + /// The transform origin of the brush + /// The spread method. + /// The start point for the gradient. + /// + /// The location of the two-dimensional focal point that defines the beginning of the gradient. + /// + /// The horizontal radius of the outermost circle. + /// The vertical radius of the outermost circle. + /// The transform of the brush in the relative coordinate + /// space of the area being painted, applied before . + public ImmutableRadialGradientBrush( + IReadOnlyList gradientStops, + double opacity, + ImmutableTransform? transform, + RelativePoint? transformOrigin, + GradientSpreadMethod spreadMethod, + RelativePoint? center, + RelativePoint? gradientOrigin, + RelativeScalar? radiusX, + RelativeScalar? radiusY, + ImmutableTransform? relativeTransform) + : base(gradientStops, opacity, transform, transformOrigin, spreadMethod, relativeTransform) { Center = center ?? RelativePoint.Center; GradientOrigin = gradientOrigin ?? RelativePoint.Center; diff --git a/src/Avalonia.Base/Media/Immutable/ImmutableSolidColorBrush.cs b/src/Avalonia.Base/Media/Immutable/ImmutableSolidColorBrush.cs index c88aea7e9e..d074963f0f 100644 --- a/src/Avalonia.Base/Media/Immutable/ImmutableSolidColorBrush.cs +++ b/src/Avalonia.Base/Media/Immutable/ImmutableSolidColorBrush.cs @@ -13,11 +13,30 @@ namespace Avalonia.Media.Immutable /// The color to use. /// The opacity of the brush. /// The transform of the brush. + // TODO13: remove, folding relativeTransform into the overload below as an optional parameter. public ImmutableSolidColorBrush(Color color, double opacity = 1, ImmutableTransform? transform = null) + : this(color, opacity, transform, null) + { + } + + /// + /// Initializes a new instance of the class. + /// + /// The color to use. + /// The opacity of the brush. + /// The transform of the brush. + /// The transform of the brush in the relative coordinate + /// space of the area being painted, applied before . + public ImmutableSolidColorBrush( + Color color, + double opacity, + ImmutableTransform? transform, + ImmutableTransform? relativeTransform) { Color = color; Opacity = opacity; Transform = transform; + RelativeTransform = relativeTransform; } /// @@ -34,7 +53,8 @@ namespace Avalonia.Media.Immutable /// /// The brush from which this brush's properties should be copied. public ImmutableSolidColorBrush(ISolidColorBrush source) - : this(source.Color, source.Opacity, source.Transform?.ToImmutable()) + : this(source.Color, source.Opacity, source.Transform?.ToImmutable(), + source.RelativeTransform?.ToImmutable()) { } @@ -58,11 +78,15 @@ namespace Avalonia.Media.Immutable /// public RelativePoint TransformOrigin { get; } + /// + public ITransform? RelativeTransform { get; } + public bool Equals(ImmutableSolidColorBrush? other) { if (ReferenceEquals(null, other)) return false; if (ReferenceEquals(this, other)) return true; - return Color.Equals(other.Color) && Opacity.Equals(other.Opacity) && (Transform == null && other.Transform == null ? true : (Transform != null && Transform.Equals(other.Transform))); + return Color.Equals(other.Color) && Opacity.Equals(other.Opacity) + && Equals(Transform, other.Transform) && Equals(RelativeTransform, other.RelativeTransform); } public override bool Equals(object? obj) @@ -74,7 +98,8 @@ namespace Avalonia.Media.Immutable { unchecked { - return (Color.GetHashCode() * 397) ^ Opacity.GetHashCode() ^ (Transform is null ? 0 : Transform.GetHashCode()); + return (Color.GetHashCode() * 397) ^ Opacity.GetHashCode() ^ (Transform is null ? 0 : Transform.GetHashCode()) + ^ (RelativeTransform is null ? 0 : RelativeTransform.GetHashCode()); } } diff --git a/src/Avalonia.Base/Media/Immutable/ImmutableTileBrush.cs b/src/Avalonia.Base/Media/Immutable/ImmutableTileBrush.cs index df6d14ecf9..5bf317db08 100644 --- a/src/Avalonia.Base/Media/Immutable/ImmutableTileBrush.cs +++ b/src/Avalonia.Base/Media/Immutable/ImmutableTileBrush.cs @@ -21,6 +21,8 @@ namespace Avalonia.Media.Immutable /// How the source rectangle will be stretched to fill the destination rect. /// /// The tile mode. + /// The transform of the brush in the relative coordinate + /// space of the area being painted, applied before . private protected ImmutableTileBrush( AlignmentX alignmentX, AlignmentY alignmentY, @@ -30,7 +32,8 @@ namespace Avalonia.Media.Immutable RelativePoint transformOrigin, RelativeRect sourceRect, Stretch stretch, - TileMode tileMode) + TileMode tileMode, + ImmutableTransform? relativeTransform) { AlignmentX = alignmentX; AlignmentY = alignmentY; @@ -41,6 +44,7 @@ namespace Avalonia.Media.Immutable SourceRect = sourceRect; Stretch = stretch; TileMode = tileMode; + RelativeTransform = relativeTransform; } /// @@ -57,7 +61,8 @@ namespace Avalonia.Media.Immutable source.TransformOrigin, source.SourceRect, source.Stretch, - source.TileMode) + source.TileMode, + source.RelativeTransform?.ToImmutable()) { } @@ -91,5 +96,8 @@ namespace Avalonia.Media.Immutable /// public TileMode TileMode { get; } + + /// + public ITransform? RelativeTransform { get; } } } diff --git a/src/Avalonia.Base/Rendering/Composition/Brushes/ServerCompositionBrush.cs b/src/Avalonia.Base/Rendering/Composition/Brushes/ServerCompositionBrush.cs index e537db319a..1588bd3a36 100644 --- a/src/Avalonia.Base/Rendering/Composition/Brushes/ServerCompositionBrush.cs +++ b/src/Avalonia.Base/Rendering/Composition/Brushes/ServerCompositionBrush.cs @@ -9,6 +9,7 @@ namespace Avalonia.Rendering.Composition.Server; internal partial class ServerCompositionBrush : IBrush { ITransform? IBrush.Transform => Transform; + ITransform? IBrush.RelativeTransform => RelativeTransform; } internal partial class ServerCompositionGradientBrush : ServerCompositionBrush, IGradientBrush diff --git a/src/Avalonia.Base/Rendering/Composition/Brushes/ServerSimpleCompositionBrush.cs b/src/Avalonia.Base/Rendering/Composition/Brushes/ServerSimpleCompositionBrush.cs index d15b76cca5..c4cd282dc4 100644 --- a/src/Avalonia.Base/Rendering/Composition/Brushes/ServerSimpleCompositionBrush.cs +++ b/src/Avalonia.Base/Rendering/Composition/Brushes/ServerSimpleCompositionBrush.cs @@ -12,6 +12,7 @@ namespace Avalonia.Rendering.Composition.Server internal partial class ServerCompositionSimpleBrush : IBrush { ITransform? IBrush.Transform => Transform; + ITransform? IBrush.RelativeTransform => RelativeTransform; } internal class ServerCompositionSimpleGradientBrush : ServerCompositionSimpleBrush, IGradientBrush diff --git a/src/Avalonia.Base/Rendering/Composition/Drawing/CompositionRenderDataSceneBrushContent.cs b/src/Avalonia.Base/Rendering/Composition/Drawing/CompositionRenderDataSceneBrushContent.cs index a8b9da743a..ea27912359 100644 --- a/src/Avalonia.Base/Rendering/Composition/Drawing/CompositionRenderDataSceneBrushContent.cs +++ b/src/Avalonia.Base/Rendering/Composition/Drawing/CompositionRenderDataSceneBrushContent.cs @@ -25,6 +25,7 @@ internal class CompositionRenderDataSceneBrushContent : ISceneBrushContent public double Opacity => Brush.Opacity; public ITransform? Transform => Brush.Transform; public RelativePoint TransformOrigin => Brush.TransformOrigin; + public ITransform? RelativeTransform => Brush.RelativeTransform; public void Dispose() { diff --git a/src/Avalonia.Base/Rendering/Composition/Drawing/ImmediateRenderDataSceneBrushContent.cs b/src/Avalonia.Base/Rendering/Composition/Drawing/ImmediateRenderDataSceneBrushContent.cs index e3b43655bc..e84083783f 100644 --- a/src/Avalonia.Base/Rendering/Composition/Drawing/ImmediateRenderDataSceneBrushContent.cs +++ b/src/Avalonia.Base/Rendering/Composition/Drawing/ImmediateRenderDataSceneBrushContent.cs @@ -22,6 +22,7 @@ internal class ImmediateRenderDataSceneBrushContent : ISceneBrushContent public double Opacity => Brush.Opacity; public ITransform? Transform => Brush.Transform; public RelativePoint TransformOrigin => Brush.TransformOrigin; + public ITransform? RelativeTransform => Brush.RelativeTransform; public void Dispose() { diff --git a/src/Avalonia.Base/composition-schema.xml b/src/Avalonia.Base/composition-schema.xml index 1256355fa4..aaf958efdf 100644 --- a/src/Avalonia.Base/composition-schema.xml +++ b/src/Avalonia.Base/composition-schema.xml @@ -91,6 +91,7 @@ + @@ -129,6 +130,7 @@ + diff --git a/src/Skia/Avalonia.Skia/DrawingContextImpl.cs b/src/Skia/Avalonia.Skia/DrawingContextImpl.cs index 03fa3d7686..eb95fb56b2 100644 --- a/src/Skia/Avalonia.Skia/DrawingContextImpl.cs +++ b/src/Skia/Avalonia.Skia/DrawingContextImpl.cs @@ -901,6 +901,30 @@ namespace Avalonia.Skia return null; } + /// + /// The relative transform of a brush conjugated into target space: the + /// unit square maps onto , the matrix acts + /// inside that space, before the absolute brush transform. + /// + private static Matrix? GetRelativeTransform(IBrush brush, Rect targetRect) + { + if (brush.RelativeTransform is not { } relativeTransform + || targetRect.Width <= 0 || targetRect.Height <= 0) + { + return null; + } + + var matrix = relativeTransform.Value; + if (matrix.IsIdentity) + return null; + + return Matrix.CreateTranslation(-targetRect.X, -targetRect.Y) + * Matrix.CreateScale(1 / targetRect.Width, 1 / targetRect.Height) + * matrix + * Matrix.CreateScale(targetRect.Width, targetRect.Height) + * Matrix.CreateTranslation(targetRect.X, targetRect.Y); + } + /// /// Configure paint wrapper for using gradient brush. /// @@ -920,26 +944,22 @@ namespace Avalonia.Skia var start = linearGradient.StartPoint.ToPixels(targetRect).ToSKPoint(); var end = linearGradient.EndPoint.ToPixels(targetRect).ToSKPoint(); - // would be nice to cache these shaders possibly? - if (linearGradient.Transform is null) - { - using (var shader = - SKShader.CreateLinearGradient(start, end, stopColors, stopOffsets, tileMode)) - { - paintWrapper.Paint.Shader = shader; - } - } - else + var transform = GetRelativeTransform(linearGradient, targetRect); + if (linearGradient.Transform is { } absoluteTransform) { var transformOrigin = linearGradient.TransformOrigin.ToPixels(targetRect); var offset = Matrix.CreateTranslation(transformOrigin); - var transform = (-offset) * linearGradient.Transform.Value * (offset); + var absolute = (-offset) * absoluteTransform.Value * (offset); + transform = transform.HasValue ? transform.Value * absolute : absolute; + } - using (var shader = - SKShader.CreateLinearGradient(start, end, stopColors, stopOffsets, tileMode, transform.ToSKMatrix())) - { - paintWrapper.Paint.Shader = shader; - } + // would be nice to cache these shaders possibly? + using (var shader = transform.HasValue + ? SKShader.CreateLinearGradient(start, end, stopColors, stopOffsets, tileMode, + transform.Value.ToSKMatrix()) + : SKShader.CreateLinearGradient(start, end, stopColors, stopOffsets, tileMode)) + { + paintWrapper.Paint.Shader = shader; } break; @@ -962,6 +982,8 @@ namespace Avalonia.Skia * Matrix.CreateScale(1, radiusY / radiusX) * Matrix.CreateTranslation(centerPoint); + if (GetRelativeTransform(radialGradient, targetRect) is { } relative) + transform = transform.HasValue ? transform * relative : relative; if (radialGradient.Transform != null) { @@ -1060,16 +1082,18 @@ namespace Avalonia.Skia var angle = (float)(conicGradient.Angle - 90); var rotation = SKMatrix.CreateRotationDegrees(angle, center.X, center.Y); - if (conicGradient.Transform is { }) + var transform = GetRelativeTransform(conicGradient, targetRect); + if (conicGradient.Transform is { } absoluteTransform) { - var transformOrigin = conicGradient.TransformOrigin.ToPixels(targetRect); var offset = Matrix.CreateTranslation(transformOrigin); - var transform = (-offset) * conicGradient.Transform.Value * (offset); - - rotation = rotation.PreConcat(transform.ToSKMatrix()); + var absolute = (-offset) * absoluteTransform.Value * (offset); + transform = transform.HasValue ? transform.Value * absolute : absolute; } + if (transform.HasValue) + rotation = rotation.PostConcat(transform.Value.ToSKMatrix()); + using (var shader = SKShader.CreateSweepGradient(center, stopColors, stopOffsets, rotation)) { @@ -1147,19 +1171,24 @@ namespace Avalonia.Skia tileTransform, SKMatrix.CreateScale((float)(96.0 / _intermediateSurfaceDpi.X), (float)(96.0 / _intermediateSurfaceDpi.Y))); + if (tileBrush.DestinationRect.Unit == RelativeUnit.Relative) + paintTransform = + paintTransform.PreConcat(SKMatrix.CreateTranslation((float)targetBox.X, (float)targetBox.Y)); + + // Both brush transforms act on the tile once it sits in target space, the relative one + // first. + if (GetRelativeTransform(tileBrush, targetBox) is { } relativeTransform) + paintTransform = paintTransform.PostConcat(relativeTransform.ToSKMatrix()); + if (tileBrush.Transform is { }) { var origin = tileBrush.TransformOrigin.ToPixels(targetBox); var offset = Matrix.CreateTranslation(origin); var transform = (-offset) * tileBrush.Transform.Value * (offset); - paintTransform = paintTransform.PreConcat(transform.ToSKMatrix()); + paintTransform = paintTransform.PostConcat(transform.ToSKMatrix()); } - if (tileBrush.DestinationRect.Unit == RelativeUnit.Relative) - paintTransform = - paintTransform.PreConcat(SKMatrix.CreateTranslation((float)targetBox.X, (float)targetBox.Y)); - using (var shader = image.ToShader(tileX, tileY, paintTransform)) { paintWrapper.Paint.Shader = shader; @@ -1268,19 +1297,22 @@ namespace Avalonia.Skia // If there is no BrushTransform and destinationRect is at (0,0) we don't need any transforms Matrix shaderTransform = Matrix.Identity; - - // Apply Brush.Transform to SKShader + + // Apply destinationRect position + if (destinationRect.Position != default) + shaderTransform = Matrix.CreateTranslation(destinationRect.X, destinationRect.Y); + + // Apply Brush.RelativeTransform and Brush.Transform to SKShader, in that order + if (GetRelativeTransform(content, targetRect) is { } relativeTransform) + shaderTransform *= relativeTransform; + if (content.Transform != null) { var transformOrigin = content.TransformOrigin.ToPixels(targetRect); var offset = Matrix.CreateTranslation(transformOrigin); - shaderTransform = (-offset) * content.Transform.Value * (offset); + shaderTransform *= (-offset) * content.Transform.Value * (offset); } - - // Apply destinationRect position - if (destinationRect.Position != default) - shaderTransform *= Matrix.CreateTranslation(destinationRect.X, destinationRect.Y); // Create shader var (tileX, tileY) = GetTileModes(content.Brush.TileMode); diff --git a/tests/Avalonia.Base.UnitTests/Composition/RelativeTransformBrushTests.cs b/tests/Avalonia.Base.UnitTests/Composition/RelativeTransformBrushTests.cs new file mode 100644 index 0000000000..9405bdc6f2 --- /dev/null +++ b/tests/Avalonia.Base.UnitTests/Composition/RelativeTransformBrushTests.cs @@ -0,0 +1,123 @@ +using System; +using Avalonia.Media; +using Avalonia.Media.Immutable; +using Avalonia.Rendering.Composition; +using Avalonia.Rendering.Composition.Drawing; +using Avalonia.Base.UnitTests.Media; +using Avalonia.UnitTests; +using Xunit; + +namespace Avalonia.Base.UnitTests.Composition; + +/// +/// transport: the value set on the client brush must reach +/// the server-side counterpart the backend reads at draw time, for every brush kind, both through +/// the mutable Media brushes and through the composition brushes. +/// +public class RelativeTransformBrushTests : ScopedTestBase +{ + private static readonly Matrix s_matrix = Matrix.CreateRotation(0.5) * Matrix.CreateTranslation(0.25, 0.5); + + [Fact] + public void Composition_Solid_Color_Brush_Relative_Transform_Should_Reach_The_Server() + => AssertCompositionBrushReachesServer(c => c.CreateSolidColorBrush(Colors.Red)); + + [Fact] + public void Composition_Linear_Gradient_Brush_Relative_Transform_Should_Reach_The_Server() + => AssertCompositionBrushReachesServer(c => c.CreateLinearGradientBrush()); + + [Fact] + public void Composition_Radial_Gradient_Brush_Relative_Transform_Should_Reach_The_Server() + => AssertCompositionBrushReachesServer(c => c.CreateRadialGradientBrush()); + + [Fact] + public void Composition_Conic_Gradient_Brush_Relative_Transform_Should_Reach_The_Server() + => AssertCompositionBrushReachesServer(c => c.CreateConicGradientBrush()); + + [Fact] + public void Solid_Color_Brush_Relative_Transform_Should_Reach_The_Server() + => AssertBrushReachesServer(new SolidColorBrush(Colors.Red)); + + [Fact] + public void Linear_Gradient_Brush_Relative_Transform_Should_Reach_The_Server() + => AssertBrushReachesServer(new LinearGradientBrush { GradientStops = { new GradientStop(Colors.Red, 0) } }); + + [Fact] + public void Radial_Gradient_Brush_Relative_Transform_Should_Reach_The_Server() + => AssertBrushReachesServer(new RadialGradientBrush { GradientStops = { new GradientStop(Colors.Red, 0) } }); + + [Fact] + public void Conic_Gradient_Brush_Relative_Transform_Should_Reach_The_Server() + => AssertBrushReachesServer(new ConicGradientBrush { GradientStops = { new GradientStop(Colors.Red, 0) } }); + + [Fact] + public void Image_Brush_Relative_Transform_Should_Reach_The_Server() + => AssertBrushReachesServer(new ImageBrush()); + + [Fact] + public void Drawing_Brush_Relative_Transform_Should_Reach_The_Server() + => AssertBrushReachesServer(new DrawingBrush()); + + [Fact] + public void Changing_Relative_Transform_Raises_Invalidated() + { + var target = new SolidColorBrush(); + + RenderResourceTestHelper.AssertResourceInvalidation( + target, () => target.RelativeTransform = new ImmutableTransform(s_matrix)); + } + + [Fact] + public void Relative_Transform_Is_Referenced_On_The_Compositor() + { + using var services = new CompositorTestServices(); + + var transform = new RotateTransform(45); + var brush = new SolidColorBrush(Colors.Red) { RelativeTransform = transform }; + + ((ICompositionRenderResource)brush).AddRefOnCompositor(services.Compositor); + try + { + services.RunJobs(); + Assert.NotNull(((ICompositorSerializable)transform).TryGetServer(services.Compositor)); + } + finally + { + ((ICompositionRenderResource)brush).ReleaseOnCompositor(services.Compositor); + } + + Assert.Null(((ICompositorSerializable)transform).TryGetServer(services.Compositor)); + } + + private static void AssertCompositionBrushReachesServer(Func factory) + { + using var services = new CompositorTestServices(); + var brush = factory(services.Compositor); + + brush.RelativeTransform = new ImmutableTransform(s_matrix); + services.RunJobs(); + + Assert.Equal(s_matrix, ((IBrush)brush.Server).RelativeTransform!.Value); + } + + private static void AssertBrushReachesServer(Brush brush) + { + using var services = new CompositorTestServices(); + + brush.RelativeTransform = new ImmutableTransform(s_matrix); + + var resource = (ICompositionRenderResource)brush; + ((ICompositionRenderResource)brush).AddRefOnCompositor(services.Compositor); + try + { + services.RunJobs(); + + var server = resource.GetForCompositor(services.Compositor); + Assert.Equal(s_matrix, server.RelativeTransform!.Value); + } + finally + { + ((ICompositionRenderResource)brush).ReleaseOnCompositor(services.Compositor); + } + } +} diff --git a/tests/Avalonia.Base.UnitTests/Media/RelativeTransformBrushTests.cs b/tests/Avalonia.Base.UnitTests/Media/RelativeTransformBrushTests.cs new file mode 100644 index 0000000000..c144325c0e --- /dev/null +++ b/tests/Avalonia.Base.UnitTests/Media/RelativeTransformBrushTests.cs @@ -0,0 +1,88 @@ +using Avalonia.Animation.Animators; +using Avalonia.Media; +using Avalonia.Media.Immutable; +using Avalonia.Platform; +using Avalonia.UnitTests; +using Moq; +using Xunit; + +namespace Avalonia.Base.UnitTests.Media; + +/// +/// survives the snapshot every mutable brush takes of itself +/// when it is handed to a consumer that needs an immutable brush. +/// +public class RelativeTransformBrushTests +{ + private static readonly Matrix s_matrix = Matrix.CreateRotation(0.5) * Matrix.CreateTranslation(0.25, 0.5); + + [Fact] + public void Solid_Color_Brush_ToImmutable_Carries_Relative_Transform() + => AssertCarriedToImmutable(new SolidColorBrush(Colors.Red)); + + [Fact] + public void Linear_Gradient_Brush_ToImmutable_Carries_Relative_Transform() + => AssertCarriedToImmutable(new LinearGradientBrush()); + + [Fact] + public void Radial_Gradient_Brush_ToImmutable_Carries_Relative_Transform() + => AssertCarriedToImmutable(new RadialGradientBrush()); + + [Fact] + public void Conic_Gradient_Brush_ToImmutable_Carries_Relative_Transform() + => AssertCarriedToImmutable(new ConicGradientBrush()); + + [Fact] + public void Image_Brush_ToImmutable_Carries_Relative_Transform() + => AssertCarriedToImmutable(new ImageBrush()); + + [Fact] + public void Scene_Brush_Content_Carries_Relative_Transform() + { + using var app = UnitTestApplication.Start(new TestServices(renderInterface: Mock.Of())); + + var brush = new DrawingBrush(new GeometryDrawing + { + Geometry = new RectangleGeometry(new Rect(0, 0, 10, 10)), + Brush = Brushes.Red, + }) + { + RelativeTransform = new ImmutableTransform(s_matrix), + }; + + using var content = ((ISceneBrush)brush).CreateContent()!; + + Assert.Equal(s_matrix, content.RelativeTransform!.Value); + } + + [Fact] + public void Immutable_Solid_Color_Brushes_Differing_Only_In_Relative_Transform_Are_Not_Equal() + { + var first = new ImmutableSolidColorBrush(Colors.Red); + var second = new ImmutableSolidColorBrush(Colors.Red, 1, null, new ImmutableTransform(s_matrix)); + + Assert.NotEqual(first, second); + } + + [Fact] + public void Animating_A_Gradient_Brush_Keeps_The_Relative_Transform() + { + var animator = new GradientBrushAnimator(); + var from = new ImmutableLinearGradientBrush( + [], 1, null, null, GradientSpreadMethod.Pad, null, null, new ImmutableTransform(s_matrix)); + var to = new ImmutableLinearGradientBrush([]); + + var interpolated = animator.Interpolate(0.5, from, to); + + Assert.Equal(s_matrix, interpolated!.RelativeTransform!.Value); + } + + private static void AssertCarriedToImmutable(Brush brush) + { + brush.RelativeTransform = new ImmutableTransform(s_matrix); + + var immutable = brush.ToImmutable(); + + Assert.Equal(s_matrix, immutable.RelativeTransform!.Value); + } +} diff --git a/tests/Avalonia.Base.UnitTests/Rendering/SceneGraph/DrawOperationTests.cs b/tests/Avalonia.Base.UnitTests/Rendering/SceneGraph/DrawOperationTests.cs index bcd4fdebd6..611880942b 100644 --- a/tests/Avalonia.Base.UnitTests/Rendering/SceneGraph/DrawOperationTests.cs +++ b/tests/Avalonia.Base.UnitTests/Rendering/SceneGraph/DrawOperationTests.cs @@ -1,4 +1,4 @@ -using Avalonia.Media; +using Avalonia.Media; using Avalonia.Platform; using Avalonia.Rendering.SceneGraph; using Avalonia.Utilities; @@ -481,6 +481,7 @@ namespace Avalonia.Base.UnitTests.Rendering.SceneGraph public double Opacity => 1; public ITransform? Transform => null; public RelativePoint TransformOrigin => default; + public ITransform? RelativeTransform => null; public void AddRefOnCompositor(Compositor c) => AddRefCount++; public void ReleaseOnCompositor(Compositor c) => ReleaseCount++; public IBrush GetForCompositor(Compositor c) => this; @@ -507,6 +508,7 @@ namespace Avalonia.Base.UnitTests.Rendering.SceneGraph public double Opacity => 1; public ITransform? Transform => null; public RelativePoint TransformOrigin => default; + public ITransform? RelativeTransform => null; public void AddRefOnCompositor(Compositor c) => AddRefCount++; public void ReleaseOnCompositor(Compositor c) { } public IBrush GetForCompositor(Compositor c) => this; diff --git a/tests/Avalonia.RenderTests.WpfCompare/CrossUI.Wpf.cs b/tests/Avalonia.RenderTests.WpfCompare/CrossUI.Wpf.cs index d6e6897d7e..73ba76e3db 100644 --- a/tests/Avalonia.RenderTests.WpfCompare/CrossUI.Wpf.cs +++ b/tests/Avalonia.RenderTests.WpfCompare/CrossUI.Wpf.cs @@ -260,6 +260,14 @@ namespace Avalonia.RenderTests.WpfCompare return Sync(new SolidColorBrush(br.Color.ToWpf()), brush); if (brush is CrossDrawingBrush db) return SyncTile(new DrawingBrush(ConvertDrawing(db.Drawing)), db); + if (brush is CrossImageBrush ib) + return SyncTile(new ImageBrush(new BitmapImage(new Uri(ib.Path, UriKind.Absolute))), ib); + if (brush is CrossLinearGradientBrush linear) + return SyncGradient(new LinearGradientBrush() + { + StartPoint = linear.StartPoint.ToWpf(), + EndPoint = linear.EndPoint.ToWpf() + }, linear); if (brush is CrossRadialGradientBrush radial) return SyncGradient(new RadialGradientBrush() { diff --git a/tests/Avalonia.RenderTests/Assets/Ramp64.png b/tests/Avalonia.RenderTests/Assets/Ramp64.png new file mode 100644 index 0000000000000000000000000000000000000000..1f1cb1bdf397e33af64a54058cf0947acc282c78 GIT binary patch literal 189 zcmeAS@N?(olHy`uVBq!ia0vp^4j|0I1SD0tpLGJM7Ec$)kcv5PuOH+*V8C<0L43+y z>#Fh$?n4WdC|^Hw_RQ;IyEFex&aH9dKCz;qhYOv!5&z&!)*Jl;xa401>n}D} nSj=Rkg-#^bHzvnFoX!5Yg}LVT0 + ctx.DrawRectangle(Linear(relative: s_unitRotation), null, new Rect(40, 70, 120, 60)))); + } + + [CrossFact] + public void Radial_Gradient_Relative_Transform_Should_Work_As_Expected() + { + RenderAndCompare(Scene(ctx => + ctx.DrawRectangle(Radial(relative: s_unitRotation), null, new Rect(40, 70, 120, 60)))); + } + + [CrossFact] + public void Relative_Transform_Should_Apply_Before_Transform() + { + // A scale rather than a translation: it does not commute with the relative rotation, so the + // two orders are far enough apart to be told apart by the comparison. + RenderAndCompare(Scene(ctx => + ctx.DrawRectangle( + Linear(relative: s_unitRotation, absolute: Matrix.CreateScale(0.4, 0.4)), + null, + new Rect(40, 70, 120, 60)))); + } + + [CrossFact] + public void Relative_Transform_Should_Follow_The_Bounds_Of_Each_Fill() + { + // One brush instance, two differently sized rects: the unit space is each rect's own, so + // the two fills must differ in shape. + RenderAndCompare(Scene(ctx => + { + var shared = Linear(relative: s_unitRotation); + ctx.DrawRectangle(shared, null, new Rect(40, 30, 120, 60)); + ctx.DrawRectangle(shared, null, new Rect(30, 110, 60, 90)); + })); + } + + [CrossFact] + public void Solid_Color_Relative_Transform_Should_Be_Ignored() + { + RenderAndCompare(Scene(ctx => + ctx.DrawRectangle( + new CrossSolidColorBrush(Colors.Crimson) { RelativeTransform = s_unitRotation }, + null, + new Rect(40, 70, 120, 60)))); + } + + [CrossFact] + public void Drawing_Brush_Relative_Transform_Should_Work_As_Expected() + { + RenderAndCompare(Scene(ctx => + ctx.DrawRectangle(Checkerboard(relative: s_unitRotation), null, new Rect(40, 70, 120, 60)))); + } + + [CrossFact] + public void Tiled_Drawing_Brush_Relative_Transform_Should_Work_As_Expected() + { + var brush = Checkerboard(relative: s_unitRotation); + brush.TileMode = TileMode.Tile; + brush.Viewport = new Rect(0, 0, 30, 30); + brush.ViewportUnits = BrushMappingMode.Absolute; + + RenderAndCompare(Scene(ctx => ctx.DrawRectangle(brush, null, new Rect(40, 70, 120, 60)))); + } + + [CrossFact] + public void Image_Brush_Relative_Transform_Should_Work_As_Expected() + { + RenderAndCompare(Scene(ctx => + ctx.DrawRectangle( + new CrossImageBrush { Path = RampImagePath, RelativeTransform = s_unitRotation }, + null, + new Rect(40, 70, 120, 60)))); + } + + // A smooth two-axis ramp rather than a line drawing: every pixel carries position, so a + // misplaced transform shows up as a colour shift over the whole fill instead of hiding in the + // resampling noise the two backends produce differently along hard edges. + [CrossFact] + public void Tiled_Image_Brush_Relative_And_Absolute_Transform_Should_Work_As_Expected() + { + // Tiling and an absolute viewport put the tile offset and the destination translate into + // the shader matrix, so this is where the slot the two brush transforms occupy matters. + RenderAndCompare(Scene(ctx => ctx.DrawRectangle( + new CrossImageBrush + { + Path = RampImagePath, + TileMode = TileMode.Tile, + Viewport = new Rect(0, 0, 40, 40), + ViewportUnits = BrushMappingMode.Absolute, + RelativeTransform = s_unitRotation, + Transform = Matrix.CreateScale(0.6, 0.6) + }, + null, + new Rect(40, 70, 120, 60)))); + } + + private static string RampImagePath => Path.Join( + Path.GetDirectoryName(typeof(CrossRelativeTransformBrushTests).Assembly.Location), + "Assets", + "Ramp64.png"); + + private static CrossControl Scene(Action render) => + new CrossFuncControl(render) + { + Width = 200, + Height = 200, + Background = new CrossSolidColorBrush(Colors.White) + }; + + private static CrossLinearGradientBrush Linear(Matrix? relative = null, Matrix? absolute = null) => + new() + { + GradientStops = { new GradientStop(Colors.Red, 0), new GradientStop(Colors.Blue, 1) }, + StartPoint = new Point(0, 0), + EndPoint = new Point(1, 0), + RelativeTransform = relative, + Transform = absolute + }; + + private static CrossRadialGradientBrush Radial(Matrix? relative = null, Matrix? absolute = null) => + new() + { + GradientStops = { new GradientStop(Colors.Red, 0), new GradientStop(Colors.Blue, 1) }, + Center = new Point(0.5, 0.5), + GradientOrigin = new Point(0.5, 0.5), + RadiusX = 0.5, + RadiusY = 0.5, + RelativeTransform = relative, + Transform = absolute + }; + + private static CrossDrawingBrush Checkerboard(Matrix? relative = null, Matrix? absolute = null) => + new() + { + Drawing = new CrossDrawingGroup + { + Children = new List + { + new CrossGeometryDrawing(new CrossRectangleGeometry(new Rect(0, 0, 20, 20))) + { + Brush = new CrossSolidColorBrush(Colors.Crimson) + }, + new CrossGeometryDrawing(new CrossRectangleGeometry(new Rect(0, 0, 10, 10))) + { + Brush = new CrossSolidColorBrush(Colors.MidnightBlue) + }, + new CrossGeometryDrawing(new CrossRectangleGeometry(new Rect(10, 10, 10, 10))) + { + Brush = new CrossSolidColorBrush(Colors.MidnightBlue) + } + } + }, + RelativeTransform = relative, + Transform = absolute + }; +} diff --git a/tests/Avalonia.RenderTests/CrossTests/Brushes/CrossTileBrushTests.cs b/tests/Avalonia.RenderTests/CrossTests/Brushes/CrossTileBrushTests.cs index 599ef948e9..401589d4b5 100644 --- a/tests/Avalonia.RenderTests/CrossTests/Brushes/CrossTileBrushTests.cs +++ b/tests/Avalonia.RenderTests/CrossTests/Brushes/CrossTileBrushTests.cs @@ -1,4 +1,6 @@ +using System; using System.Collections.Generic; +using System.IO; using Avalonia.Media; using CrossUI; using Xunit; @@ -138,6 +140,53 @@ public class CrossTileBrushTests : CrossTestBase } + // Scaling about the centre of the 200x200 canvas rather than about its origin, so the + // transformed tile stays inside the fill and the output shows where it landed. + private static readonly Matrix s_halveAboutCanvasCentre = + Matrix.CreateTranslation(-100, -100) + * Matrix.CreateScale(0.5, 0.5) + * Matrix.CreateTranslation(100, 100); + + [CrossFact] + public void Should_Render_Drawing_Brush_With_Transform_On_An_Offset_Fill() + { + // A relative viewport lays the tile over the fill, and the transform then acts on it in + // target space, so the crimson rect ends up centred in the fill at half its size. + // Transforming the tile before it is positioned would push it towards the bottom edge. + var brush = new CrossDrawingBrush + { + TileMode = TileMode.None, + Transform = s_halveAboutCanvasCentre, + Drawing = new CrossGeometryDrawing(new CrossRectangleGeometry(new Rect(0, 0, 100, 100))) + { + Brush = new CrossSolidColorBrush(Colors.Crimson) + } + }; + + RenderAndCompare(new CrossFuncControl(ctx => ctx.DrawRectangle(brush, null, new Rect(40, 70, 120, 60))) + { + Width = 200, + Height = 200, + Background = new CrossSolidColorBrush(Colors.White) + }); + } + + [CrossFact] + public void Should_Render_Image_Brush_With_Transform_On_An_Offset_Fill() + { + var path = Path.Join( + Path.GetDirectoryName(typeof(CrossTileBrushTests).Assembly.Location), "Assets", "Ramp64.png"); + + var brush = new CrossImageBrush { Path = path, Transform = s_halveAboutCanvasCentre }; + + RenderAndCompare(new CrossFuncControl(ctx => ctx.DrawRectangle(brush, null, new Rect(40, 70, 120, 60))) + { + Width = 200, + Height = 200, + Background = new CrossSolidColorBrush(Colors.White) + }); + } + [CrossFact] public void Should_Render_With_Transform() { diff --git a/tests/Avalonia.RenderTests/CrossUI/CrossUI.Avalonia.cs b/tests/Avalonia.RenderTests/CrossUI/CrossUI.Avalonia.cs index 2bc308a270..d0d402915e 100644 --- a/tests/Avalonia.RenderTests/CrossUI/CrossUI.Avalonia.cs +++ b/tests/Avalonia.RenderTests/CrossUI/CrossUI.Avalonia.cs @@ -244,8 +244,7 @@ namespace Avalonia.Skia.RenderTests.CrossUI dst.Opacity = src.Opacity; dst.Transform = ConvertTransform(src.Transform); dst.TransformOrigin = new RelativePoint(default, RelativeUnit.Absolute); - if (src.RelativeTransform != null) - throw new PlatformNotSupportedException(); + dst.RelativeTransform = ConvertTransform(src.RelativeTransform); return dst; } @@ -272,6 +271,15 @@ namespace Avalonia.Skia.RenderTests.CrossUI return Sync(new SolidColorBrush(br.Color), brush); if (brush is CrossDrawingBrush db) return SyncTile(new DrawingBrush(ConvertDrawing(db.Drawing)), db); + if (brush is CrossImageBrush ib) + return SyncTile(new ImageBrush(new Bitmap(ib.Path)), ib); + if (brush is CrossLinearGradientBrush linear) + return SyncGradient( + new LinearGradientBrush() + { + StartPoint = ConvertPoint(linear.StartPoint, linear.MappingMode), + EndPoint = ConvertPoint(linear.EndPoint, linear.MappingMode) + }, linear); if (brush is CrossRadialGradientBrush radial) return SyncGradient( new RadialGradientBrush() diff --git a/tests/Avalonia.RenderTests/CrossUI/CrossUI.cs b/tests/Avalonia.RenderTests/CrossUI/CrossUI.cs index fbf55036ed..ab7b42151b 100644 --- a/tests/Avalonia.RenderTests/CrossUI/CrossUI.cs +++ b/tests/Avalonia.RenderTests/CrossUI/CrossUI.cs @@ -43,6 +43,12 @@ public class CrossGradientBrush : CrossBrush public BrushMappingMode MappingMode; } +public class CrossLinearGradientBrush : CrossGradientBrush +{ + public Avalonia.Point StartPoint = new(0, 0); + public Avalonia.Point EndPoint = new(1, 1); +} + public class CrossRadialGradientBrush : CrossGradientBrush { public Avalonia.Point Center; @@ -168,6 +174,11 @@ public class CrossDrawingBrush : CrossTileBrush public required CrossDrawing Drawing { get; set; } } +public class CrossImageBrush : CrossTileBrush +{ + public required string Path { get; set; } +} + public class CrossPen { public required CrossBrush Brush { get; set; } diff --git a/tests/Avalonia.RenderTests/Media/ConicGradientBrushTests.cs b/tests/Avalonia.RenderTests/Media/ConicGradientBrushTests.cs index a649f9cc84..9567896939 100644 --- a/tests/Avalonia.RenderTests/Media/ConicGradientBrushTests.cs +++ b/tests/Avalonia.RenderTests/Media/ConicGradientBrushTests.cs @@ -172,6 +172,41 @@ namespace Avalonia.Skia.RenderTests CompareImages(); } + [Fact] + public async Task ConicGradientBrush_Transform_Applies_After_Angle() + { + // The angle belongs to the gradient itself, so the brush transform acts on the swept + // pattern rather than the other way round: the output is the same gradient centred + // 30px right and 20px down, and not one whose translation has been turned by the angle. + Decorator target = new Decorator + { + Width = 200, + Height = 200, + Child = new Border + { + Background = new ConicGradientBrush + { + GradientStops = + { + new GradientStop { Color = Colors.Red, Offset = 0 }, + new GradientStop { Color = Colors.Yellow, Offset = 0.1667 }, + new GradientStop { Color = Colors.Lime, Offset = 0.3333 }, + new GradientStop { Color = Colors.Aqua, Offset = 0.5000 }, + new GradientStop { Color = Colors.Blue, Offset = 0.6667 }, + new GradientStop { Color = Colors.Magenta, Offset = 0.8333 }, + new GradientStop { Color = Colors.Red, Offset = 1 }, + }, + Center = new RelativePoint(100, 100, RelativeUnit.Absolute), + Angle = 45, + Transform = new TranslateTransform(30, 20) + } + } + }; + + await RenderToFile(target); + CompareImages(); + } + [Fact] public async Task ConicGradientBrush_DrawingContext() { diff --git a/tests/Avalonia.RenderTests/Media/RelativeTransformBrushTests.cs b/tests/Avalonia.RenderTests/Media/RelativeTransformBrushTests.cs new file mode 100644 index 0000000000..c5c5717e7d --- /dev/null +++ b/tests/Avalonia.RenderTests/Media/RelativeTransformBrushTests.cs @@ -0,0 +1,146 @@ +using System.Threading.Tasks; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Media; +using Avalonia.Media.Immutable; +using Xunit; + +namespace Avalonia.Skia.RenderTests; + +/// +/// applies in the unit +/// space of the painted bounds, before the absolute , +/// so one brush instance serves consumers of different sizes. +/// +public class RelativeTransformBrushTests : TestBase +{ + private static readonly ImmutableGradientStop[] s_stops = + { + new(0, Colors.Red), + new(1, Colors.Blue), + }; + + // A rotation about an off-center unit-space point: bounds-dependent once + // conjugated, and asymmetric enough that even a center-symmetric radial + // gradient shows it (a rotation about the center would be invisible there). + private static readonly Matrix s_unitMatrix = + Matrix.CreateTranslation(-0.3, -0.7) + * Matrix.CreateRotation(Matrix.ToRadians(30)) + * Matrix.CreateTranslation(0.3, 0.7); + + public RelativeTransformBrushTests() + : base(@"Media\RelativeTransformBrush") + { + } + + private static Canvas Scene(params Control[] children) + { + var canvas = new Canvas + { + Width = 200, + Height = 200, + Background = Brushes.White, + }; + + foreach (var child in children) + canvas.Children.Add(child); + + return canvas; + } + + private static Border Filled(IBrush brush, double left, double top, double width, double height) => + new() + { + Background = brush, + Width = width, + Height = height, + [Canvas.LeftProperty] = left, + [Canvas.TopProperty] = top, + }; + + [Fact] + public async Task Linear_Gradient_Rotated_In_Unit_Space() + { + var brush = new ImmutableLinearGradientBrush( + s_stops, 1, null, null, GradientSpreadMethod.Pad, null, null, + new ImmutableTransform(s_unitMatrix)); + + await RenderToFile(Scene(Filled(brush, 40, 70, 120, 60))); + CompareImages(); + } + + [Fact] + public async Task Radial_Gradient_Rotated_In_Unit_Space() + { + var brush = new ImmutableRadialGradientBrush( + s_stops, 1, null, null, GradientSpreadMethod.Pad, null, null, null, null, + new ImmutableTransform(s_unitMatrix)); + + await RenderToFile(Scene(Filled(brush, 40, 70, 120, 60))); + CompareImages(); + } + + [Fact] + public async Task Shared_Brush_On_Two_Different_Bounds() + { + // The gradient must follow each rect's own bounds, so the two fills + // differ in shape while sharing one brush instance. + var shared = new ImmutableLinearGradientBrush( + s_stops, 1, null, null, GradientSpreadMethod.Pad, null, null, + new ImmutableTransform(s_unitMatrix)); + + await RenderToFile( + Scene( + Filled(shared, 40, 30, 120, 60), + Filled(shared, 30, 110, 60, 90))); + CompareImages(); + } + + [Fact] + public async Task Drawing_Brush_Rotated_In_Unit_Space() + { + var brush = new DrawingBrush(new DrawingGroup + { + Children = + { + new GeometryDrawing + { + Geometry = new RectangleGeometry(new Rect(0, 0, 20, 20)), + Brush = Brushes.Crimson, + }, + new GeometryDrawing + { + Geometry = new RectangleGeometry(new Rect(0, 0, 10, 10)), + Brush = Brushes.MidnightBlue, + }, + new GeometryDrawing + { + Geometry = new RectangleGeometry(new Rect(10, 10, 10, 10)), + Brush = Brushes.MidnightBlue, + }, + } + }) + { + Stretch = Stretch.Fill, + RelativeTransform = new ImmutableTransform(s_unitMatrix), + }; + + await RenderToFile(Scene(Filled(brush, 40, 70, 120, 60))); + CompareImages(); + } + + [Fact] + public async Task Relative_Transform_Applies_Before_Absolute() + { + // A scale, not a translation: it does not commute with the relative + // rotation, so applying the two the other way round moves the image far + // enough to fail this golden. A translation stayed inside the tolerance. + var brush = new ImmutableLinearGradientBrush( + s_stops, 1, new ImmutableTransform(Matrix.CreateScale(0.4, 0.4)), null, + GradientSpreadMethod.Pad, null, null, + new ImmutableTransform(s_unitMatrix)); + + await RenderToFile(Scene(Filled(brush, 40, 70, 120, 60))); + CompareImages(); + } +} diff --git a/tests/TestFiles/CrossTests/Media/RelativeTransformBrush/Drawing_Brush_Relative_Transform_Should_Work_As_Expected.wpf.png b/tests/TestFiles/CrossTests/Media/RelativeTransformBrush/Drawing_Brush_Relative_Transform_Should_Work_As_Expected.wpf.png new file mode 100644 index 0000000000000000000000000000000000000000..5efb7b695ab0cd7312cbe38033bf80b8fef159cd GIT binary patch literal 2941 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 NUk9m8*L?Ky{{b0Yl(+x@ literal 0 HcmV?d00001 diff --git a/tests/TestFiles/CrossTests/Media/RelativeTransformBrush/Linear_Gradient_Relative_Transform_Should_Work_As_Expected.wpf.png b/tests/TestFiles/CrossTests/Media/RelativeTransformBrush/Linear_Gradient_Relative_Transform_Should_Work_As_Expected.wpf.png new file mode 100644 index 0000000000000000000000000000000000000000..5d7729c9fa8cf3d719e829c9cc1e21c6ef1cc195 GIT binary patch literal 657 zcmeAS@N?(olHy`uVBq!ia0vp^CqS5k4M?tyST_$yF%}28J29*~C-V}>VM%xNb!1@J z*w6hZkrl}2EbxddW?j>c2TPNK!jK^5LxXI0h4ue*2wZ j#b?K%5t`sA-@}lr$ltUji0v6L88LXe`njxgN@xNA550`v literal 0 HcmV?d00001 diff --git a/tests/TestFiles/CrossTests/Media/RelativeTransformBrush/Radial_Gradient_Relative_Transform_Should_Work_As_Expected.wpf.png b/tests/TestFiles/CrossTests/Media/RelativeTransformBrush/Radial_Gradient_Relative_Transform_Should_Work_As_Expected.wpf.png new file mode 100644 index 0000000000000000000000000000000000000000..5d7729c9fa8cf3d719e829c9cc1e21c6ef1cc195 GIT binary patch literal 657 zcmeAS@N?(olHy`uVBq!ia0vp^CqS5k4M?tyST_$yF%}28J29*~C-V}>VM%xNb!1@J z*w6hZkrl}2EbxddW?j>c2TPNK!jK^5LxXI0h4ue*2wZ j#b?K%5t`sA-@}lr$ltUji0v6L88LXe`njxgN@xNA550`v literal 0 HcmV?d00001 diff --git a/tests/TestFiles/CrossTests/Media/RelativeTransformBrush/Relative_Transform_Should_Apply_Before_Transform.wpf.png b/tests/TestFiles/CrossTests/Media/RelativeTransformBrush/Relative_Transform_Should_Apply_Before_Transform.wpf.png new file mode 100644 index 0000000000000000000000000000000000000000..5d7729c9fa8cf3d719e829c9cc1e21c6ef1cc195 GIT binary patch literal 657 zcmeAS@N?(olHy`uVBq!ia0vp^CqS5k4M?tyST_$yF%}28J29*~C-V}>VM%xNb!1@J z*w6hZkrl}2EbxddW?j>c2TPNK!jK^5LxXI0h4ue*2wZ j#b?K%5t`sA-@}lr$ltUji0v6L88LXe`njxgN@xNA550`v literal 0 HcmV?d00001 diff --git a/tests/TestFiles/CrossTests/Media/RelativeTransformBrush/Relative_Transform_Should_Follow_The_Bounds_Of_Each_Fill.wpf.png b/tests/TestFiles/CrossTests/Media/RelativeTransformBrush/Relative_Transform_Should_Follow_The_Bounds_Of_Each_Fill.wpf.png new file mode 100644 index 0000000000000000000000000000000000000000..8d090da10a0f04ddf3139678c10b9cd164850235 GIT binary patch literal 670 zcmeAS@N?(olHy`uVBq!ia0vp^CqS5k4M?tyST_$yF%}28J29*~C-V}>VM%xNb!1@J z*w6hZkrl}2EbxddW?X0?z8ezMPTarckh^gysG4D;BRyg;9#MYXxaJe{X665E!I1~z1{4nz{%3&pujPR zh%@irS+d;A5#V5IR3M(HubymI?Dy`i{qi&GN!3e|MFX!`pG`uv_U+^ecY$e&!PC{x JWt~$(69B+ul1cyo literal 0 HcmV?d00001 diff --git a/tests/TestFiles/CrossTests/Media/RelativeTransformBrush/Solid_Color_Relative_Transform_Should_Be_Ignored.wpf.png b/tests/TestFiles/CrossTests/Media/RelativeTransformBrush/Solid_Color_Relative_Transform_Should_Be_Ignored.wpf.png new file mode 100644 index 0000000000000000000000000000000000000000..3e9525ad7d499886c3eb51bfd6a2315577bf0440 GIT binary patch literal 670 zcmeAS@N?(olHy`uVBq!ia0vp^CqS5k4M?tyST_$yF%}28J29*~C-V}>VM%xNb!1@J z*w6hZkrl}2EbxddW?s|8e2m_lmZo>W|9UOMJ{BfN1%V+!Tsw2dZr;hl>NmpmPySWCyS%bO ufP<+~VQ>-IX=(C}{QN^EP+6EB^8WA^Xgd6%xDc487(8A5T-G@yGywpx4|2x< literal 0 HcmV?d00001 diff --git a/tests/TestFiles/CrossTests/Media/RelativeTransformBrush/Tiled_Drawing_Brush_Relative_Transform_Should_Work_As_Expected.wpf.png b/tests/TestFiles/CrossTests/Media/RelativeTransformBrush/Tiled_Drawing_Brush_Relative_Transform_Should_Work_As_Expected.wpf.png new file mode 100644 index 0000000000000000000000000000000000000000..9135672259d1fcf80abddc9a4bb221e08cc2cd86 GIT binary patch literal 3901 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 F6994z4Y>dS literal 0 HcmV?d00001 diff --git a/tests/TestFiles/Skia/Media/ConicGradientBrush/ConicGradientBrush_Transform_Applies_After_Angle.expected.png b/tests/TestFiles/Skia/Media/ConicGradientBrush/ConicGradientBrush_Transform_Applies_After_Angle.expected.png new file mode 100644 index 0000000000000000000000000000000000000000..1b231f2c2f29e7b984a9e78d8689cde47c670f00 GIT binary patch literal 12312 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 literal 0 HcmV?d00001 diff --git a/tests/TestFiles/Skia/Media/RelativeTransformBrush/Linear_Gradient_Rotated_In_Unit_Space.expected.png b/tests/TestFiles/Skia/Media/RelativeTransformBrush/Linear_Gradient_Rotated_In_Unit_Space.expected.png new file mode 100644 index 0000000000000000000000000000000000000000..349242248f38853fea3531613feef6c16a1ae66a GIT binary patch literal 1223 zcmeAS@N?(olHy`uVBq!ia0vp^CqS5k4M?tyST_$yu@pObhHwBu4M$1`kXz^J;uumf z=k1-qNmA|tu7MNwhUTBD-g?Vk%}J4;Kdo(9eAxWYs&!iIC#+Qk8`s_5mMi|i*}m@| zs+15>bVYbGqx23pWA1fr#``Ynckf(k#AW( z%?_;(-hM7ieZv1Wn+kfQ+QRMem->A%o%g$z%?{Wx|(Eh(HZ|>E1 zCXF@fJP+TPufD6vWLd?z?EJ0IZ_2#cFKuF3eXig7oJ zCFEoKTXyrUtL#P9fx0p8b<9sR{@eM-u#WSb)oI>OZ1Q&%E>AnK=6Cw`)%;J4%{?v) zb$r=*{Ov2|%A>qRUxYZG|9f1f%#<0~#mRl$>@@GD&GG-cOiMY>?5Z!@{ma3wy4ggx z(L>%oFI@h_>bwb?b?$6=esP`MgxlR#m7G%PTW$KwzBcaplzh)v=gyMIyb0nJ&)72K zn?$Eot_Ql9#i*9EZ{F2EHbCcndSicS_Vfj%VTV6w01bO@e&UGg471mB&o7RxRe1kv zM)|9om(#Zb9r;q<+)E4QrXPdWd- zdCiW3%V{7z_huVra)w1dvrXG7EqLOKYKHah%rKz))oSEZu13DE0Be84dua#D>a$f( zK?ZyN_t;+W8DZhE&Cwu}Qcit$Z27zu6hJ>u@m|`-qJ6IFD%2{Ww<7xl^ literal 0 HcmV?d00001 diff --git a/tests/TestFiles/Skia/Media/RelativeTransformBrush/Radial_Gradient_Rotated_In_Unit_Space.expected.png b/tests/TestFiles/Skia/Media/RelativeTransformBrush/Radial_Gradient_Rotated_In_Unit_Space.expected.png new file mode 100644 index 0000000000000000000000000000000000000000..84527b2ed194be39da1d9bc7f6bf94fa57871c72 GIT binary patch literal 2498 zcmds3`#Teg8`q38!dWtn$kL2DZn-4)+l(ECp@f9YCDM*LBV?XLF6|giX>$3VNr>sV zu9I+FayI2M&D+R{PD6cLrB_*YZ zc0>9|JnOrHWF?kyp`l#jfC)}$KafOjfN(idQgX>?q@&;U(zTg+!oqW9mw8!}4e?*5 zb6*!>Kd;m<1KZ?!>YolkY;-La!A4VryMI;U_(K#;O^V6)Wo zsU5)PP1B2AkQo951OFd9#AyX5tS7(3)rsqEwl_lbw?#*h5nZHJEumO(7qrA4s7-F0 zroe_0h1+&m4Lg;mknBH=1E&XvD9q>+1)5{Wk@(6Bud{s8U-K6EK(_3{JLN$!2zQIn zRBSLsak>(BJ_{TilYA;ar3?5!z{!X2#N1F2ZiKDcJ~38iwHAkgoyz9}vA&P;`?5#! z=&iwPUw#_iQUJlbUFW6$5z^2LM$G#7+WH((H>ERCp@weJg^+nhJllq&Ms- z5=8rEIjsK-CB4dQBHqPm|3gWG?#FG6#;h*o^n4zkYZnR}=*E6}`|(Dq=4cd8+hB6K zougSdyA+N2%>(Kx_r6m4WJsrxus9Dc?-HE2g+M17_!NnvGsv=|4>f{%1W_RrilCwR zl9rmAWnmgL_4a59f`O(RGW8pku*@V;#2Am=c!t~*a;%H`&1y=MdtAVj1U*Dd%CyU@ z7Vv>;G>w>!MjdkV%rS~r+lw)?od*t{ZJA+QMh#oDWIjRO#JCRl-?u}jlX+=kb#qCH&~QI`iF<{>%PCNS57ac-u3tSk5MfX4G}!~Nq2;n!-i$W%yH=}Wnip+EZ|dCtNDZIlA`b@ z&^GU``nVkM^cVd;zDN${;1uWbC z5X+p@)Gna58&yo=T}G!vj`CPlWk2?36C_K0cA!NMCCz$ayjh;jBYoJBumVo*QTNFr z^V7Q`y%s%v`zxx}wj@)0t?JOE|LW{M-llkC!TY9ucNb)9KveZl>QPeqA^XDBaW$q8 zzYtuf`(RBLpK^|A9cT$4)eh`Ym5f&A5L9D`s=1!77i!6yI|An>w-Je7Dv)aSoqc{gnTu# zcGz~e`E?{-g*^`lNT84FZkHbrU_^J0?Jtgp6%DG}$I!2B-S?)4a=B`9ZZ2a7V-vFv z;?{CIU8P9cxcr?DB#%Dvv)qsyu3FA63(jnysj~Oxi-_`7NvFVd4eYVp&Lk;L$??$? z$hoi@hk-i$W^yfc#!I*ot z8SH-3_FmxSZ$T9?@?_aRT){NsK;K?3C1P&X;}28bkoVD$+*MRLv< zC8Euz4TkiJi;rY2al_`Xzq8C#v+zG0Dl2_&J%@$H5L$T0IKW1TIJCFKJ8&L%D|cVW zS31HXh#ybkW#^Af6QSe6wW;$^y8BXN*zRESc)3EFZg}c0r9p-tHIy1I2=a5ZQsLI7ALn zdnMQ@zuvtq^UF2QS4|$GS3T9b;=iJ7Wy<3%TO$cL-5ed>afUm?fQpOLbN@_jPpC)( zei=EalWJ*hV%Zc+;mBPsYh^9NS6=?^l-qM%L&%NNy16&!%+72~{9Y^(8YDS6jCZZY z!KAJu{fmNXJK-!0Ja=#QVL{pbZKQ&+_dNW_wWB(dj4kQyWcxM z?qB$N*UPW-!|W$m|B-gPe(=t#ef@Q7*B*Y6w8|lld-16RInx)5(+~Z)Z_-#Ie`fLf z-Fp7+`&(7F@-O+F5VoU#`&QnRwu3gm?CxFHn;^+n{Qh^|`5!yX8cSr(WZC~WSXZNv zzxQRj+?R{Lcf|kPzgPbB^-YJvZ7-GX{BIxmb8V_Zx-?L#<)g)N&Lxi*^v~mb{P7lR zP@%#0J@1v{y(MnXR0A4X`{hj3iEmM?Mz0t2-&61TyM;65n4`R{@Uef(_?Em{&~K;Q z^LPtK$~3Up$0+tosTbVm?~LNsTwJ4Ye|ml{``>xSS(@@El*4_$$$Yt(z9Ms%Lm$XV zD}W9VWv~6xBL}oJ7pQ6B^iS_^1g=)Q{@Lp6Tg_bDu^ByJSKCC zCFra0qi;SE#hHR0NBui)+lwEY6U82+DDyk0SXnOMlwGZhbn}fzj<4vF~T-oJC|u=)j7da{wKv& zt6g*YvEo`|&oMg=64ZN^Erd3tDnm{r-UW|_mXv9 literal 0 HcmV?d00001 diff --git a/tests/TestFiles/Skia/Media/RelativeTransformBrush/Shared_Brush_On_Two_Different_Bounds.expected.png b/tests/TestFiles/Skia/Media/RelativeTransformBrush/Shared_Brush_On_Two_Different_Bounds.expected.png new file mode 100644 index 0000000000000000000000000000000000000000..80e98a1164069f39bf6dbfc9d5a437c2cfb178a0 GIT binary patch literal 1710 zcmbtVX;c$u6b)g3fl4y6xK+%6G^l_?I3k;nut-u6B&Hw=0Trc3$_^?7Ob3@hp%4WH zBrLTkP+0^)mJ&pP3WA~*jD}6tAZukYB?xp9Py4&4opWZsAMd?;@4I*AOu``-M^%+A zDi{n#l}5Fvqi3u11{Kj~$&u0=^Z>$bX$%m(VnDxm3`X%3&7Q(I&ztUb3)69g9A4TE z8=g<-PZNwACJAkb2li}GR%MK_GQ}~PHs09o>`U&X^kK7Pg?GmY-8PuKB=Ywd1S5x) z!va_U1V9LcU^4*i+vBf97SF8J1YMPjAEw;y+ik$?B zrGQ@ilIV`dZJ2xYgD%d}AZk?shetI;w`5AY$}-Ol~e3(xCeqUC+wYq#C| zoV1Wn1?|Y;`m>ekbsvxj%Mcd<5jyU}re))#eZMKFU4FUbbEcc$^`r90brR@5z??V$ z&||?~u`ntyvP9m^9Pk!LKdI`fT4%poy>Tgg6{)y6%C+ zB-sl`5%HP)8y4;4UfiQ*eg>@2g+qpr@E)gZ2Y80lxG3DrDoVwVNN$C_ix8iP(~#6& zcteOQV(p=*#q6W>;^bI;4G%1q$j_aj+jivql>P2{gjP!;bx|AU^`vxr0=L3;Y{l_} zOlR)1hK81~{lHY@vT$NV-rSOshLgM6&n|Yc=AJvq-Kq3^I;v1cWkJBU@EJKA6t+Jq z>)c}`T$rdlrpB+Uvxi_42P8WsJ7PCMG<>l3iud=1&igIRA*&Vl z)Rzj*4mf4Uz=Dn4OHhP)hvi4Q9wGjzm{K?M&5Bi5o6=k#5NZh|vrg^DN6sNrpRB1* z!A=uh8vv4%La7bE)ghh>7N05>b3?_;#63dK^3 zrHUGtX3-w*Wa2~&s8Lo@Aw}|Pz}uR2%G@zgx1WY0dMCXFbpQ47%i0;08`X8+-mi`- z*k)GEM=`>JoHB0_k!oe`! z>#ft!`Hvq)-jq$?pQ1TYU9pc3hb=~J$n`X8kOUVQa61>ZLqqPL@MExcsrqHQUN#B9 ze(=+zjzzck7`)NuPIc(_UvcN#ca#ftK_7}^)=`=qMtaL~LO_DUls&4o}IQ+U&MMnYl>H<+yT7FmThWM2#=PM;Vyl60A)HpJp*i#=vcFg-^ zcw?RcUt86cL0g^igELXx?te21feO|^*rcrwr93X!l5=2S>?^3(#Dd$0c6p$bQM*VE zk^q^6j?e4a8*{#sf;=(=uH7R-K4EO7s8ilrwv|`(g64@WpzCOGIZvt#{dQ1gu(36F zYHxj-OfItU+E@ym*)(Pv9H~)OI+)N?Pm_DBj*uLn9c$}`tTe&=hp&+-raF~9iWoG} z8Z|PTbhsVphWc3A(WcaA0~DsGI+)WE*q@IXEJD}o(e?eI;{+PSP4YcZm#u0fn__D)z5(o#9^vK%9S)vY+@SIeHf!%~atIHCRRt z>ITibbGYSK3PP)+lmY*wFJw(SQH7?;89MjhC|;{2a%-Q;dyXAOsL`n^(sIb_LRst= zn+GU2mKeTa3sRArWFr4X2DE{(lH{T6fb&~ov(m{So#WDXq}gZ59}*QcYP>J=mg+Tj zih|oi)S$Ml71F$cYKZ#yCOL+-rZqBEXL0FURo68v=E%q$576EN{Q+TU4leekw!U$H E0m)YMz5oCK literal 0 HcmV?d00001