Browse Source

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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
pull/22200/head
Benedikt Stebner 2 weeks ago
committed by GitHub
parent
commit
0dbce40347
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 12
      api/Avalonia.nupkg.xml
  2. 9
      src/Avalonia.Base/Animation/Animators/GradientBrushAnimator.cs
  3. 30
      src/Avalonia.Base/Media/Brush.cs
  4. 9
      src/Avalonia.Base/Media/IBrush.cs
  5. 28
      src/Avalonia.Base/Media/Immutable/ImmutableConicGradientBrush.cs
  6. 29
      src/Avalonia.Base/Media/Immutable/ImmutableGradientBrush.cs
  7. 8
      src/Avalonia.Base/Media/Immutable/ImmutableImageBrush.cs
  8. 28
      src/Avalonia.Base/Media/Immutable/ImmutableLinearGradientBrush.cs
  9. 35
      src/Avalonia.Base/Media/Immutable/ImmutableRadialGradientBrush.cs
  10. 31
      src/Avalonia.Base/Media/Immutable/ImmutableSolidColorBrush.cs
  11. 12
      src/Avalonia.Base/Media/Immutable/ImmutableTileBrush.cs
  12. 1
      src/Avalonia.Base/Rendering/Composition/Brushes/ServerCompositionBrush.cs
  13. 1
      src/Avalonia.Base/Rendering/Composition/Brushes/ServerSimpleCompositionBrush.cs
  14. 1
      src/Avalonia.Base/Rendering/Composition/Drawing/CompositionRenderDataSceneBrushContent.cs
  15. 1
      src/Avalonia.Base/Rendering/Composition/Drawing/ImmediateRenderDataSceneBrushContent.cs
  16. 2
      src/Avalonia.Base/composition-schema.xml
  17. 98
      src/Skia/Avalonia.Skia/DrawingContextImpl.cs
  18. 123
      tests/Avalonia.Base.UnitTests/Composition/RelativeTransformBrushTests.cs
  19. 88
      tests/Avalonia.Base.UnitTests/Media/RelativeTransformBrushTests.cs
  20. 4
      tests/Avalonia.Base.UnitTests/Rendering/SceneGraph/DrawOperationTests.cs
  21. 8
      tests/Avalonia.RenderTests.WpfCompare/CrossUI.Wpf.cs
  22. BIN
      tests/Avalonia.RenderTests/Assets/Ramp64.png
  23. 186
      tests/Avalonia.RenderTests/CrossTests/Brushes/CrossRelativeTransformBrushTests.cs
  24. 49
      tests/Avalonia.RenderTests/CrossTests/Brushes/CrossTileBrushTests.cs
  25. 12
      tests/Avalonia.RenderTests/CrossUI/CrossUI.Avalonia.cs
  26. 11
      tests/Avalonia.RenderTests/CrossUI/CrossUI.cs
  27. 35
      tests/Avalonia.RenderTests/Media/ConicGradientBrushTests.cs
  28. 146
      tests/Avalonia.RenderTests/Media/RelativeTransformBrushTests.cs
  29. BIN
      tests/TestFiles/CrossTests/Media/RelativeTransformBrush/Drawing_Brush_Relative_Transform_Should_Work_As_Expected.wpf.png
  30. BIN
      tests/TestFiles/CrossTests/Media/RelativeTransformBrush/Image_Brush_Relative_Transform_Should_Work_As_Expected.wpf.png
  31. BIN
      tests/TestFiles/CrossTests/Media/RelativeTransformBrush/Linear_Gradient_Relative_Transform_Should_Work_As_Expected.wpf.png
  32. BIN
      tests/TestFiles/CrossTests/Media/RelativeTransformBrush/Radial_Gradient_Relative_Transform_Should_Work_As_Expected.wpf.png
  33. BIN
      tests/TestFiles/CrossTests/Media/RelativeTransformBrush/Relative_Transform_Should_Apply_Before_Transform.wpf.png
  34. BIN
      tests/TestFiles/CrossTests/Media/RelativeTransformBrush/Relative_Transform_Should_Follow_The_Bounds_Of_Each_Fill.wpf.png
  35. BIN
      tests/TestFiles/CrossTests/Media/RelativeTransformBrush/Solid_Color_Relative_Transform_Should_Be_Ignored.wpf.png
  36. BIN
      tests/TestFiles/CrossTests/Media/RelativeTransformBrush/Tiled_Drawing_Brush_Relative_Transform_Should_Work_As_Expected.wpf.png
  37. BIN
      tests/TestFiles/CrossTests/Media/RelativeTransformBrush/Tiled_Image_Brush_Relative_And_Absolute_Transform_Should_Work_As_Expected.wpf.png
  38. BIN
      tests/TestFiles/CrossTests/Media/TileBrushes/Should_Render_Drawing_Brush_With_Transform_On_An_Offset_Fill.wpf.png
  39. BIN
      tests/TestFiles/CrossTests/Media/TileBrushes/Should_Render_Image_Brush_With_Transform_On_An_Offset_Fill.wpf.png
  40. BIN
      tests/TestFiles/Skia/Media/ConicGradientBrush/ConicGradientBrush_Transform_Applies_After_Angle.expected.png
  41. BIN
      tests/TestFiles/Skia/Media/RelativeTransformBrush/Drawing_Brush_Rotated_In_Unit_Space.expected.png
  42. BIN
      tests/TestFiles/Skia/Media/RelativeTransformBrush/Linear_Gradient_Rotated_In_Unit_Space.expected.png
  43. BIN
      tests/TestFiles/Skia/Media/RelativeTransformBrush/Radial_Gradient_Rotated_In_Unit_Space.expected.png
  44. BIN
      tests/TestFiles/Skia/Media/RelativeTransformBrush/Relative_Transform_Applies_Before_Absolute.expected.png
  45. BIN
      tests/TestFiles/Skia/Media/RelativeTransformBrush/Shared_Brush_On_Two_Different_Bounds.expected.png

12
api/Avalonia.nupkg.xml

@ -241,6 +241,12 @@
<Left>baseline/Avalonia/lib/net10.0/Avalonia.Base.dll</Left>
<Right>current/Avalonia/lib/net10.0/Avalonia.Base.dll</Right>
</Suppression>
<Suppression>
<DiagnosticId>CP0006</DiagnosticId>
<Target>P:Avalonia.Media.IBrush.RelativeTransform</Target>
<Left>baseline/Avalonia/lib/net10.0/Avalonia.Base.dll</Left>
<Right>current/Avalonia/lib/net10.0/Avalonia.Base.dll</Right>
</Suppression>
<Suppression>
<DiagnosticId>CP0006</DiagnosticId>
<Target>P:Avalonia.Media.IImageBrushSource.Bitmap</Target>
@ -301,6 +307,12 @@
<Left>baseline/Avalonia/lib/net8.0/Avalonia.Base.dll</Left>
<Right>current/Avalonia/lib/net8.0/Avalonia.Base.dll</Right>
</Suppression>
<Suppression>
<DiagnosticId>CP0006</DiagnosticId>
<Target>P:Avalonia.Media.IBrush.RelativeTransform</Target>
<Left>baseline/Avalonia/lib/net8.0/Avalonia.Base.dll</Left>
<Right>current/Avalonia/lib/net8.0/Avalonia.Base.dll</Right>
</Suppression>
<Suppression>
<DiagnosticId>CP0006</DiagnosticId>
<Target>P:Avalonia.Media.IImageBrushSource.Bitmap</Target>

9
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;

30
src/Avalonia.Base/Media/Brush.cs

@ -33,6 +33,12 @@ namespace Avalonia.Media
public static readonly StyledProperty<RelativePoint> TransformOriginProperty =
AvaloniaProperty.Register<Brush, RelativePoint>(nameof(TransformOrigin));
/// <summary>
/// Defines the <see cref="RelativeTransform"/> property.
/// </summary>
public static readonly StyledProperty<ITransform?> RelativeTransformProperty =
AvaloniaProperty.Register<Brush, ITransform?>(nameof(RelativeTransform));
/// <summary>
/// Gets or sets the opacity of the brush.
/// </summary>
@ -60,6 +66,13 @@ namespace Avalonia.Media
set => SetValue(TransformOriginProperty, value);
}
/// <inheritdoc cref="IBrush.RelativeTransform"/>
public ITransform? RelativeTransform
{
get => GetValue(RelativeTransformProperty);
set => SetValue(RelativeTransformProperty, value);
}
/// <summary>
/// Parses a brush string.
/// </summary>
@ -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<ITransform> resource)
resource.AddRefOnCompositor(c);
if (Transform is ICompositionRenderResource<ITransform> transform)
transform.AddRefOnCompositor(c);
if (RelativeTransform is ICompositionRenderResource<ITransform> 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<ITransform> resource)
resource.ReleaseOnCompositor(c);
if (Transform is ICompositionRenderResource<ITransform> transform)
transform.ReleaseOnCompositor(c);
if (RelativeTransform is ICompositionRenderResource<ITransform> 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);

9
src/Avalonia.Base/Media/IBrush.cs

@ -24,5 +24,14 @@ namespace Avalonia.Media
/// Gets the origin of the brushes <see cref="Transform"/>
/// </summary>
RelativePoint TransformOrigin { get; }
/// <summary>
/// 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 <see cref="Transform"/> 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.
/// </summary>
ITransform? RelativeTransform { get; }
}
}

28
src/Avalonia.Base/Media/Immutable/ImmutableConicGradientBrush.cs

@ -17,6 +17,7 @@ namespace Avalonia.Media.Immutable
/// <param name="spreadMethod">The spread method.</param>
/// <param name="center">The center point for the gradient.</param>
/// <param name="angle">The starting angle for the gradient.</param>
// TODO13: remove, folding relativeTransform into the overload below as an optional parameter.
public ImmutableConicGradientBrush(
IReadOnlyList<ImmutableGradientStop> 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)
{
}
/// <summary>
/// Initializes a new instance of the <see cref="ImmutableConicGradientBrush"/> class.
/// </summary>
/// <param name="gradientStops">The gradient stops.</param>
/// <param name="opacity">The opacity of the brush.</param>
/// <param name="transform">The transform of the brush.</param>
/// <param name="transformOrigin">The transform origin of the brush</param>
/// <param name="spreadMethod">The spread method.</param>
/// <param name="center">The center point for the gradient.</param>
/// <param name="angle">The starting angle for the gradient.</param>
/// <param name="relativeTransform">The transform of the brush in the relative coordinate
/// space of the area being painted, applied before <paramref name="transform"/>.</param>
public ImmutableConicGradientBrush(
IReadOnlyList<ImmutableGradientStop> 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;

29
src/Avalonia.Base/Media/Immutable/ImmutableGradientBrush.cs

@ -15,18 +15,41 @@ namespace Avalonia.Media.Immutable
/// <param name="transform">The transform of the brush.</param>
/// <param name="transformOrigin">The transform origin of the brush</param>
/// <param name="spreadMethod">The spread method.</param>
// TODO13: remove, folding relativeTransform into the overload below as an optional parameter.
protected ImmutableGradientBrush(
IReadOnlyList<ImmutableGradientStop> gradientStops,
double opacity,
ImmutableTransform? transform,
RelativePoint? transformOrigin,
GradientSpreadMethod spreadMethod)
: this(gradientStops, opacity, transform, transformOrigin, spreadMethod, null)
{
}
/// <summary>
/// Initializes a new instance of the <see cref="ImmutableGradientBrush"/> class.
/// </summary>
/// <param name="gradientStops">The gradient stops.</param>
/// <param name="opacity">The opacity of the brush.</param>
/// <param name="transform">The transform of the brush.</param>
/// <param name="transformOrigin">The transform origin of the brush</param>
/// <param name="spreadMethod">The spread method.</param>
/// <param name="relativeTransform">The transform of the brush in the relative coordinate
/// space of the area being painted, applied before <paramref name="transform"/>.</param>
protected ImmutableGradientBrush(
IReadOnlyList<ImmutableGradientStop> 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;
}
/// <summary>
@ -35,9 +58,8 @@ namespace Avalonia.Media.Immutable
/// <param name="source">The brush from which this brush's properties should be copied.</param>
protected ImmutableGradientBrush(GradientBrush source)
: this(source.GradientStops.ToImmutable(), source.Opacity, source.Transform?.ToImmutable(),
source.TransformOrigin, source.SpreadMethod)
source.TransformOrigin, source.SpreadMethod, source.RelativeTransform?.ToImmutable())
{
}
/// <inheritdoc/>
@ -58,5 +80,8 @@ namespace Avalonia.Media.Immutable
/// <inheritdoc/>
public GradientSpreadMethod SpreadMethod { get; }
/// <inheritdoc/>
public ITransform? RelativeTransform { get; }
}
}

8
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.
/// </param>
/// <param name="tileMode">The tile mode.</param>
/// <param name="relativeTransform">The transform of the brush in the relative coordinate
/// space of the area being painted, applied before <paramref name="transform"/>.</param>
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;
}

28
src/Avalonia.Base/Media/Immutable/ImmutableLinearGradientBrush.cs

@ -17,6 +17,7 @@ namespace Avalonia.Media.Immutable
/// <param name="spreadMethod">The spread method.</param>
/// <param name="startPoint">The start point for the gradient.</param>
/// <param name="endPoint">The end point for the gradient.</param>
// TODO13: remove, folding relativeTransform into the overload below as an optional parameter.
public ImmutableLinearGradientBrush(
IReadOnlyList<ImmutableGradientStop> 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)
{
}
/// <summary>
/// Initializes a new instance of the <see cref="ImmutableLinearGradientBrush"/> class.
/// </summary>
/// <param name="gradientStops">The gradient stops.</param>
/// <param name="opacity">The opacity of the brush.</param>
/// <param name="transform">The transform of the brush.</param>
/// <param name="transformOrigin">The transform origin of the brush</param>
/// <param name="spreadMethod">The spread method.</param>
/// <param name="startPoint">The start point for the gradient.</param>
/// <param name="endPoint">The end point for the gradient.</param>
/// <param name="relativeTransform">The transform of the brush in the relative coordinate
/// space of the area being painted, applied before <paramref name="transform"/>.</param>
public ImmutableLinearGradientBrush(
IReadOnlyList<ImmutableGradientStop> 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;

35
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<ImmutableGradientStop> 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)
{
}
/// <summary>
/// Initializes a new instance of the <see cref="ImmutableRadialGradientBrush"/> class.
/// </summary>
/// <param name="gradientStops">The gradient stops.</param>
/// <param name="opacity">The opacity of the brush.</param>
/// <param name="transform">The transform of the brush.</param>
/// <param name="transformOrigin">The transform origin of the brush</param>
/// <param name="spreadMethod">The spread method.</param>
/// <param name="center">The start point for the gradient.</param>
/// <param name="gradientOrigin">
/// The location of the two-dimensional focal point that defines the beginning of the gradient.
/// </param>
/// <param name="radiusX">The horizontal radius of the outermost circle.</param>
/// <param name="radiusY">The vertical radius of the outermost circle.</param>
/// <param name="relativeTransform">The transform of the brush in the relative coordinate
/// space of the area being painted, applied before <paramref name="transform"/>.</param>
public ImmutableRadialGradientBrush(
IReadOnlyList<ImmutableGradientStop> 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;

31
src/Avalonia.Base/Media/Immutable/ImmutableSolidColorBrush.cs

@ -13,11 +13,30 @@ namespace Avalonia.Media.Immutable
/// <param name="color">The color to use.</param>
/// <param name="opacity">The opacity of the brush.</param>
/// <param name="transform">The transform of the brush.</param>
// 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)
{
}
/// <summary>
/// Initializes a new instance of the <see cref="ImmutableSolidColorBrush"/> class.
/// </summary>
/// <param name="color">The color to use.</param>
/// <param name="opacity">The opacity of the brush.</param>
/// <param name="transform">The transform of the brush.</param>
/// <param name="relativeTransform">The transform of the brush in the relative coordinate
/// space of the area being painted, applied before <paramref name="transform"/>.</param>
public ImmutableSolidColorBrush(
Color color,
double opacity,
ImmutableTransform? transform,
ImmutableTransform? relativeTransform)
{
Color = color;
Opacity = opacity;
Transform = transform;
RelativeTransform = relativeTransform;
}
/// <summary>
@ -34,7 +53,8 @@ namespace Avalonia.Media.Immutable
/// </summary>
/// <param name="source">The brush from which this brush's properties should be copied.</param>
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
/// </summary>
public RelativePoint TransformOrigin { get; }
/// <inheritdoc/>
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());
}
}

12
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.
/// </param>
/// <param name="tileMode">The tile mode.</param>
/// <param name="relativeTransform">The transform of the brush in the relative coordinate
/// space of the area being painted, applied before <paramref name="transform"/>.</param>
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;
}
/// <summary>
@ -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
/// <inheritdoc/>
public TileMode TileMode { get; }
/// <inheritdoc/>
public ITransform? RelativeTransform { get; }
}
}

1
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

1
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

1
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()
{

1
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()
{

2
src/Avalonia.Base/composition-schema.xml

@ -91,6 +91,7 @@
<Property Name="Opacity" Type="double" />
<Property Name="TransformOrigin" Type="RelativePoint" />
<Property Name="Transform" Type="Avalonia.Media.ITransform?" />
<Property Name="RelativeTransform" Type="Avalonia.Media.ITransform?" />
</Object>
<Object Name="CompositionSimpleSolidColorBrush" Internal="true" ServerOnly="true" ServerBase="ServerCompositionSimpleBrush">
<Property Name="Color" Type="Avalonia.Media.Color" />
@ -129,6 +130,7 @@
<Property Name="Opacity" Type="double" Animated="true" DefaultValue="1"/>
<Property Name="TransformOrigin" Type="RelativePoint" />
<Property Name="Transform" Type="Avalonia.Media.ITransform?" />
<Property Name="RelativeTransform" Type="Avalonia.Media.ITransform?" />
</Object>
<Object Name="CompositionSolidColorBrush" Inherits="CompositionBrush" ServerBase="ServerCompositionBrush">
<Property Name="Color" Type="Avalonia.Media.Color" Animated="true"/>

98
src/Skia/Avalonia.Skia/DrawingContextImpl.cs

@ -901,6 +901,30 @@ namespace Avalonia.Skia
return null;
}
/// <summary>
/// The relative transform of a brush conjugated into target space: the
/// unit square maps onto <paramref name="targetRect"/>, the matrix acts
/// inside that space, before the absolute brush transform.
/// </summary>
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);
}
/// <summary>
/// Configure paint wrapper for using gradient brush.
/// </summary>
@ -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);

123
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;
/// <summary>
/// <see cref="IBrush.RelativeTransform"/> 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.
/// </summary>
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<Compositor, CompositionBrush> 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<IBrush>)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);
}
}
}

88
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;
/// <summary>
/// <see cref="IBrush.RelativeTransform"/> survives the snapshot every mutable brush takes of itself
/// when it is handed to a consumer that needs an immutable brush.
/// </summary>
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<IPlatformRenderInterface>()));
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);
}
}

4
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;

8
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()
{

BIN
tests/Avalonia.RenderTests/Assets/Ramp64.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 189 B

186
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<ICrossDrawingContext> 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<CrossDrawing>
{
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
};
}

49
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()
{

12
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()

11
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; }

35
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()
{

146
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;
/// <summary>
/// <see cref="IBrush.RelativeTransform"/> applies in the unit
/// space of the painted bounds, before the absolute <see cref="IBrush.Transform"/>,
/// so one brush instance serves consumers of different sizes.
/// </summary>
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();
}
}

BIN
tests/TestFiles/CrossTests/Media/RelativeTransformBrush/Drawing_Brush_Relative_Transform_Should_Work_As_Expected.wpf.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

BIN
tests/TestFiles/CrossTests/Media/RelativeTransformBrush/Image_Brush_Relative_Transform_Should_Work_As_Expected.wpf.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

BIN
tests/TestFiles/CrossTests/Media/RelativeTransformBrush/Linear_Gradient_Relative_Transform_Should_Work_As_Expected.wpf.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 657 B

BIN
tests/TestFiles/CrossTests/Media/RelativeTransformBrush/Radial_Gradient_Relative_Transform_Should_Work_As_Expected.wpf.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 657 B

BIN
tests/TestFiles/CrossTests/Media/RelativeTransformBrush/Relative_Transform_Should_Apply_Before_Transform.wpf.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 657 B

BIN
tests/TestFiles/CrossTests/Media/RelativeTransformBrush/Relative_Transform_Should_Follow_The_Bounds_Of_Each_Fill.wpf.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 670 B

BIN
tests/TestFiles/CrossTests/Media/RelativeTransformBrush/Solid_Color_Relative_Transform_Should_Be_Ignored.wpf.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 670 B

BIN
tests/TestFiles/CrossTests/Media/RelativeTransformBrush/Tiled_Drawing_Brush_Relative_Transform_Should_Work_As_Expected.wpf.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

BIN
tests/TestFiles/CrossTests/Media/RelativeTransformBrush/Tiled_Image_Brush_Relative_And_Absolute_Transform_Should_Work_As_Expected.wpf.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

BIN
tests/TestFiles/CrossTests/Media/TileBrushes/Should_Render_Drawing_Brush_With_Transform_On_An_Offset_Fill.wpf.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 666 B

BIN
tests/TestFiles/CrossTests/Media/TileBrushes/Should_Render_Image_Brush_With_Transform_On_An_Offset_Fill.wpf.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 861 B

BIN
tests/TestFiles/Skia/Media/ConicGradientBrush/ConicGradientBrush_Transform_Applies_After_Angle.expected.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

BIN
tests/TestFiles/Skia/Media/RelativeTransformBrush/Drawing_Brush_Rotated_In_Unit_Space.expected.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

BIN
tests/TestFiles/Skia/Media/RelativeTransformBrush/Linear_Gradient_Rotated_In_Unit_Space.expected.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

BIN
tests/TestFiles/Skia/Media/RelativeTransformBrush/Radial_Gradient_Rotated_In_Unit_Space.expected.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

BIN
tests/TestFiles/Skia/Media/RelativeTransformBrush/Relative_Transform_Applies_Before_Absolute.expected.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

BIN
tests/TestFiles/Skia/Media/RelativeTransformBrush/Shared_Brush_On_Two_Different_Bounds.expected.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Loading…
Cancel
Save