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 0000000000..1f1cb1bdf3 Binary files /dev/null and b/tests/Avalonia.RenderTests/Assets/Ramp64.png differ diff --git a/tests/Avalonia.RenderTests/CrossTests/Brushes/CrossRelativeTransformBrushTests.cs b/tests/Avalonia.RenderTests/CrossTests/Brushes/CrossRelativeTransformBrushTests.cs new file mode 100644 index 0000000000..f939af0f1f --- /dev/null +++ b/tests/Avalonia.RenderTests/CrossTests/Brushes/CrossRelativeTransformBrushTests.cs @@ -0,0 +1,186 @@ +using System; +using System.Collections.Generic; +using System.IO; +using Avalonia.Media; +using CrossUI; +using Xunit; + +#if AVALONIA_SKIA +namespace Avalonia.Skia.RenderTests; +#else +namespace Avalonia.RenderTests.WpfCompare; +#endif + +public class CrossRelativeTransformBrushTests : CrossTestBase +{ + public CrossRelativeTransformBrushTests() : base("Media/RelativeTransformBrush") + { + } + + // A rotation about an off-centre point of the unit square: bounds-dependent once conjugated + // into target space, and asymmetric enough to show on a centre-symmetric radial gradient, + // where a rotation about the centre would be invisible. + private static readonly Matrix s_unitRotation = + Matrix.CreateTranslation(-0.3, -0.7) + * Matrix.CreateRotation(Matrix.ToRadians(30)) + * Matrix.CreateTranslation(0.3, 0.7); + + [CrossFact] + public void Linear_Gradient_Relative_Transform_Should_Work_As_Expected() + { + RenderAndCompare(Scene(ctx => + 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 0000000000..5efb7b695a Binary files /dev/null and b/tests/TestFiles/CrossTests/Media/RelativeTransformBrush/Drawing_Brush_Relative_Transform_Should_Work_As_Expected.wpf.png differ diff --git a/tests/TestFiles/CrossTests/Media/RelativeTransformBrush/Image_Brush_Relative_Transform_Should_Work_As_Expected.wpf.png b/tests/TestFiles/CrossTests/Media/RelativeTransformBrush/Image_Brush_Relative_Transform_Should_Work_As_Expected.wpf.png new file mode 100644 index 0000000000..b12b06940c Binary files /dev/null and b/tests/TestFiles/CrossTests/Media/RelativeTransformBrush/Image_Brush_Relative_Transform_Should_Work_As_Expected.wpf.png differ 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 0000000000..5d7729c9fa Binary files /dev/null and b/tests/TestFiles/CrossTests/Media/RelativeTransformBrush/Linear_Gradient_Relative_Transform_Should_Work_As_Expected.wpf.png differ 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 0000000000..5d7729c9fa Binary files /dev/null and b/tests/TestFiles/CrossTests/Media/RelativeTransformBrush/Radial_Gradient_Relative_Transform_Should_Work_As_Expected.wpf.png differ 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 0000000000..5d7729c9fa Binary files /dev/null and b/tests/TestFiles/CrossTests/Media/RelativeTransformBrush/Relative_Transform_Should_Apply_Before_Transform.wpf.png differ 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 0000000000..8d090da10a Binary files /dev/null and b/tests/TestFiles/CrossTests/Media/RelativeTransformBrush/Relative_Transform_Should_Follow_The_Bounds_Of_Each_Fill.wpf.png differ 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 0000000000..3e9525ad7d Binary files /dev/null and b/tests/TestFiles/CrossTests/Media/RelativeTransformBrush/Solid_Color_Relative_Transform_Should_Be_Ignored.wpf.png differ 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 0000000000..9135672259 Binary files /dev/null and b/tests/TestFiles/CrossTests/Media/RelativeTransformBrush/Tiled_Drawing_Brush_Relative_Transform_Should_Work_As_Expected.wpf.png differ diff --git a/tests/TestFiles/CrossTests/Media/RelativeTransformBrush/Tiled_Image_Brush_Relative_And_Absolute_Transform_Should_Work_As_Expected.wpf.png b/tests/TestFiles/CrossTests/Media/RelativeTransformBrush/Tiled_Image_Brush_Relative_And_Absolute_Transform_Should_Work_As_Expected.wpf.png new file mode 100644 index 0000000000..015d65dd65 Binary files /dev/null and b/tests/TestFiles/CrossTests/Media/RelativeTransformBrush/Tiled_Image_Brush_Relative_And_Absolute_Transform_Should_Work_As_Expected.wpf.png differ diff --git a/tests/TestFiles/CrossTests/Media/TileBrushes/Should_Render_Drawing_Brush_With_Transform_On_An_Offset_Fill.wpf.png b/tests/TestFiles/CrossTests/Media/TileBrushes/Should_Render_Drawing_Brush_With_Transform_On_An_Offset_Fill.wpf.png new file mode 100644 index 0000000000..e8434b0c8b Binary files /dev/null and b/tests/TestFiles/CrossTests/Media/TileBrushes/Should_Render_Drawing_Brush_With_Transform_On_An_Offset_Fill.wpf.png differ diff --git a/tests/TestFiles/CrossTests/Media/TileBrushes/Should_Render_Image_Brush_With_Transform_On_An_Offset_Fill.wpf.png b/tests/TestFiles/CrossTests/Media/TileBrushes/Should_Render_Image_Brush_With_Transform_On_An_Offset_Fill.wpf.png new file mode 100644 index 0000000000..dfacb306f4 Binary files /dev/null and b/tests/TestFiles/CrossTests/Media/TileBrushes/Should_Render_Image_Brush_With_Transform_On_An_Offset_Fill.wpf.png differ 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 0000000000..1b231f2c2f Binary files /dev/null and b/tests/TestFiles/Skia/Media/ConicGradientBrush/ConicGradientBrush_Transform_Applies_After_Angle.expected.png differ diff --git a/tests/TestFiles/Skia/Media/RelativeTransformBrush/Drawing_Brush_Rotated_In_Unit_Space.expected.png b/tests/TestFiles/Skia/Media/RelativeTransformBrush/Drawing_Brush_Rotated_In_Unit_Space.expected.png new file mode 100644 index 0000000000..fec2e003ae Binary files /dev/null and b/tests/TestFiles/Skia/Media/RelativeTransformBrush/Drawing_Brush_Rotated_In_Unit_Space.expected.png differ 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 0000000000..349242248f Binary files /dev/null and b/tests/TestFiles/Skia/Media/RelativeTransformBrush/Linear_Gradient_Rotated_In_Unit_Space.expected.png differ 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 0000000000..84527b2ed1 Binary files /dev/null and b/tests/TestFiles/Skia/Media/RelativeTransformBrush/Radial_Gradient_Rotated_In_Unit_Space.expected.png differ diff --git a/tests/TestFiles/Skia/Media/RelativeTransformBrush/Relative_Transform_Applies_Before_Absolute.expected.png b/tests/TestFiles/Skia/Media/RelativeTransformBrush/Relative_Transform_Applies_Before_Absolute.expected.png new file mode 100644 index 0000000000..6d09818b1d Binary files /dev/null and b/tests/TestFiles/Skia/Media/RelativeTransformBrush/Relative_Transform_Applies_Before_Absolute.expected.png differ 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 0000000000..80e98a1164 Binary files /dev/null and b/tests/TestFiles/Skia/Media/RelativeTransformBrush/Shared_Brush_On_Two_Different_Bounds.expected.png differ