From 45e5d25ccfe4c99bc4968963a576e1d805348e34 Mon Sep 17 00:00:00 2001 From: Steven Kirk Date: Wed, 30 Aug 2023 14:25:48 +0200 Subject: [PATCH] Implemented Geometry.GetWidenedGeometry. --- src/Avalonia.Base/Media/Geometry.cs | 24 ++++++++- src/Avalonia.Base/Media/ImmutableGeometry.cs | 19 +++++++ src/Avalonia.Base/Platform/IGeometryImpl.cs | 8 +++ .../HeadlessPlatformRenderInterface.cs | 2 + src/Skia/Avalonia.Skia/DrawingContextImpl.cs | 22 ++------ src/Skia/Avalonia.Skia/GeometryImpl.cs | 21 ++++++++ .../Helpers/DrawingContextHelper.cs | 28 +++++++++- .../Avalonia.Skia/Helpers/SKPathHelper.cs | 32 +++++++++++ .../Avalonia.Direct2D1/Media/GeometryImpl.cs | 17 ++++++ .../Avalonia.RenderTests/Shapes/PathTests.cs | 50 ++++++++++++++++++ .../GetWidenedPathGeometry_Line.expected.png | Bin 0 -> 1440 bytes ...WidenedPathGeometry_Line_Dash.expected.png | Bin 0 -> 2443 bytes .../GetWidenedPathGeometry_Line.expected.png | Bin 0 -> 1370 bytes ...WidenedPathGeometry_Line_Dash.expected.png | Bin 0 -> 2443 bytes 14 files changed, 201 insertions(+), 22 deletions(-) create mode 100644 src/Avalonia.Base/Media/ImmutableGeometry.cs create mode 100644 src/Skia/Avalonia.Skia/Helpers/SKPathHelper.cs create mode 100644 tests/TestFiles/Direct2D1/Shapes/Path/GetWidenedPathGeometry_Line.expected.png create mode 100644 tests/TestFiles/Direct2D1/Shapes/Path/GetWidenedPathGeometry_Line_Dash.expected.png create mode 100644 tests/TestFiles/Skia/Shapes/Path/GetWidenedPathGeometry_Line.expected.png create mode 100644 tests/TestFiles/Skia/Shapes/Path/GetWidenedPathGeometry_Line_Dash.expected.png diff --git a/src/Avalonia.Base/Media/Geometry.cs b/src/Avalonia.Base/Media/Geometry.cs index a66cd616a3..d8fa3bb07f 100644 --- a/src/Avalonia.Base/Media/Geometry.cs +++ b/src/Avalonia.Base/Media/Geometry.cs @@ -21,6 +21,7 @@ namespace Avalonia.Media AvaloniaProperty.Register(nameof(Transform)); private bool _isDirty = true; + private bool _canInvaldate = true; private IGeometryImpl? _platformImpl; static Geometry() @@ -30,9 +31,14 @@ namespace Avalonia.Media internal Geometry() { - } - + + private protected Geometry(IGeometryImpl? platformImpl) + { + _platformImpl = platformImpl; + _isDirty = _canInvaldate = false; + } + /// /// Raised when the geometry changes. /// @@ -118,6 +124,17 @@ namespace Avalonia.Media return PlatformImpl?.StrokeContains(pen, point) == true; } + /// + /// Gets a that is the shape defined by the stroke on the Geometry + /// produced by the specified Pen. + /// + /// The pen to use. + /// The outlined geometry. + public Geometry GetWidenedGeometry(IPen pen) + { + return new ImmutableGeometry(PlatformImpl?.GetWidenedGeometry(pen)); + } + /// /// Marks a property as affecting the geometry's . /// @@ -146,6 +163,9 @@ namespace Avalonia.Media /// protected void InvalidateGeometry() { + if (!_canInvaldate) + return; + _isDirty = true; _platformImpl = null; Changed?.Invoke(this, EventArgs.Empty); diff --git a/src/Avalonia.Base/Media/ImmutableGeometry.cs b/src/Avalonia.Base/Media/ImmutableGeometry.cs new file mode 100644 index 0000000000..ba16329abb --- /dev/null +++ b/src/Avalonia.Base/Media/ImmutableGeometry.cs @@ -0,0 +1,19 @@ +using System; +using Avalonia.Platform; + +namespace Avalonia.Media; + +internal class ImmutableGeometry : Geometry +{ + public ImmutableGeometry(IGeometryImpl? platformImpl) + : base(platformImpl) + { + } + + public override Geometry Clone() => new ImmutableGeometry(PlatformImpl); + + private protected override IGeometryImpl? CreateDefiningGeometry() + { + return PlatformImpl; + } +} diff --git a/src/Avalonia.Base/Platform/IGeometryImpl.cs b/src/Avalonia.Base/Platform/IGeometryImpl.cs index d1964bf07e..0d1e7b972c 100644 --- a/src/Avalonia.Base/Platform/IGeometryImpl.cs +++ b/src/Avalonia.Base/Platform/IGeometryImpl.cs @@ -28,6 +28,14 @@ namespace Avalonia.Platform /// The bounding rectangle. Rect GetRenderBounds(IPen? pen); + /// + /// Gets a geometry that is the shape defined by the stroke on the geometry + /// produced by the specified Pen. + /// + /// The pen to use. + /// The outlined geometry. + IGeometryImpl GetWidenedGeometry(IPen pen); + /// /// Indicates whether the geometry's fill contains the specified point. /// diff --git a/src/Headless/Avalonia.Headless/HeadlessPlatformRenderInterface.cs b/src/Headless/Avalonia.Headless/HeadlessPlatformRenderInterface.cs index 7293874671..a7a2b486b4 100644 --- a/src/Headless/Avalonia.Headless/HeadlessPlatformRenderInterface.cs +++ b/src/Headless/Avalonia.Headless/HeadlessPlatformRenderInterface.cs @@ -182,6 +182,8 @@ namespace Avalonia.Headless return Bounds.Inflate(pen.Thickness / 2); } + public IGeometryImpl GetWidenedGeometry(IPen pen) => this; + public bool StrokeContains(IPen? pen, Point point) { return false; diff --git a/src/Skia/Avalonia.Skia/DrawingContextImpl.cs b/src/Skia/Avalonia.Skia/DrawingContextImpl.cs index 76d236e18a..9260e102ee 100644 --- a/src/Skia/Avalonia.Skia/DrawingContextImpl.cs +++ b/src/Skia/Avalonia.Skia/DrawingContextImpl.cs @@ -6,6 +6,7 @@ using System.Threading; using Avalonia.Media; using Avalonia.Platform; using Avalonia.Rendering.Utilities; +using Avalonia.Skia.Helpers; using Avalonia.Utilities; using SkiaSharp; using ISceneBrush = Avalonia.Media.ISceneBrush; @@ -1252,25 +1253,10 @@ namespace Avalonia.Skia paint.StrokeMiter = (float) pen.MiterLimit; - if (pen.DashStyle?.Dashes != null && pen.DashStyle.Dashes.Count > 0) + if (DrawingContextHelper.TryCreateDashEffect(pen, out var dashEffect)) { - var srcDashes = pen.DashStyle.Dashes; - - var count = srcDashes.Count % 2 == 0 ? srcDashes.Count : srcDashes.Count * 2; - - var dashesArray = new float[count]; - - for (var i = 0; i < count; ++i) - { - dashesArray[i] = (float) srcDashes[i % srcDashes.Count] * paint.StrokeWidth; - } - - var offset = (float)(pen.DashStyle.Offset * pen.Thickness); - - var pe = SKPathEffect.CreateDash(dashesArray, offset); - - paint.PathEffect = pe; - rv.AddDisposable(pe); + paint.PathEffect = dashEffect; + rv.AddDisposable(dashEffect); } return rv; diff --git a/src/Skia/Avalonia.Skia/GeometryImpl.cs b/src/Skia/Avalonia.Skia/GeometryImpl.cs index c1ce4a661f..0cee36204d 100644 --- a/src/Skia/Avalonia.Skia/GeometryImpl.cs +++ b/src/Skia/Avalonia.Skia/GeometryImpl.cs @@ -2,6 +2,7 @@ using System; using System.Diagnostics.CodeAnalysis; using Avalonia.Media; using Avalonia.Platform; +using Avalonia.Skia.Helpers; using SkiaSharp; namespace Avalonia.Skia @@ -75,6 +76,22 @@ namespace Avalonia.Skia return _pathCache.RenderBounds; } + public IGeometryImpl GetWidenedGeometry(IPen pen) + { + var cache = new PathCache(); + cache.UpdateIfNeeded(StrokePath, pen); + + if (cache.ExpandedPath is { } path) + { + // The path returned to us by skia here does not have closed figures. + // Fix that by calling CreateClosedPath. + var closed = SKPathHelper.CreateClosedPath(path); + return new StreamGeometryImpl(closed, closed); + } + + return new StreamGeometryImpl(new SKPath(), null); + } + /// public ITransformedGeometryImpl WithTransform(Matrix transform) { @@ -191,6 +208,10 @@ namespace Avalonia.Skia paint.StrokeCap = cap.ToSKStrokeCap(); paint.StrokeJoin = join.ToSKStrokeJoin(); paint.StrokeMiter = (float)miterLimit; + + if (DrawingContextHelper.TryCreateDashEffect(pen, out var dashEffect)) + paint.PathEffect = dashEffect; + _path = new SKPath(); paint.GetFillPath(strokePath, _path); diff --git a/src/Skia/Avalonia.Skia/Helpers/DrawingContextHelper.cs b/src/Skia/Avalonia.Skia/Helpers/DrawingContextHelper.cs index 6a726dc9dc..44caa8ae5a 100644 --- a/src/Skia/Avalonia.Skia/Helpers/DrawingContextHelper.cs +++ b/src/Skia/Avalonia.Skia/Helpers/DrawingContextHelper.cs @@ -1,5 +1,6 @@ -using Avalonia.Platform; -using Avalonia.Rendering; +using System.Diagnostics.CodeAnalysis; +using Avalonia.Media; +using Avalonia.Platform; using SkiaSharp; namespace Avalonia.Skia.Helpers @@ -26,5 +27,28 @@ namespace Avalonia.Skia.Helpers return new DrawingContextImpl(createInfo); } + public static bool TryCreateDashEffect(IPen? pen, [NotNullWhen(true)] out SKPathEffect? effect) + { + if (pen?.DashStyle?.Dashes != null && pen.DashStyle.Dashes.Count > 0) + { + var srcDashes = pen.DashStyle.Dashes; + + var count = srcDashes.Count % 2 == 0 ? srcDashes.Count : srcDashes.Count * 2; + + var dashesArray = new float[count]; + + for (var i = 0; i < count; ++i) + { + dashesArray[i] = (float)srcDashes[i % srcDashes.Count] * (float)pen.Thickness; + } + + var offset = (float)(pen.DashStyle.Offset * pen.Thickness); + effect = SKPathEffect.CreateDash(dashesArray, offset); + return true; + } + + effect = null; + return false; + } } } diff --git a/src/Skia/Avalonia.Skia/Helpers/SKPathHelper.cs b/src/Skia/Avalonia.Skia/Helpers/SKPathHelper.cs new file mode 100644 index 0000000000..3bbb80e305 --- /dev/null +++ b/src/Skia/Avalonia.Skia/Helpers/SKPathHelper.cs @@ -0,0 +1,32 @@ +using SkiaSharp; + +namespace Avalonia.Skia.Helpers; + +internal static class SKPathHelper +{ + public static SKPath CreateClosedPath(SKPath path) + { + using var iter = path.CreateIterator(true); + SKPathVerb verb; + var points = new SKPoint[4]; + var rv = new SKPath(); + while ((verb = iter.Next(points)) != SKPathVerb.Done) + { + if (verb == SKPathVerb.Move) + rv.MoveTo(points[0]); + else if (verb == SKPathVerb.Line) + rv.LineTo(points[1]); + else if (verb == SKPathVerb.Close) + rv.Close(); + else if (verb == SKPathVerb.Quad) + rv.QuadTo(points[1], points[2]); + else if (verb == SKPathVerb.Cubic) + rv.CubicTo(points[1], points[2], points[3]); + else if (verb == SKPathVerb.Conic) + rv.ConicTo(points[1], points[2], iter.ConicWeight()); + + } + + return rv; + } +} diff --git a/src/Windows/Avalonia.Direct2D1/Media/GeometryImpl.cs b/src/Windows/Avalonia.Direct2D1/Media/GeometryImpl.cs index e1c08e0814..fec9b37aac 100644 --- a/src/Windows/Avalonia.Direct2D1/Media/GeometryImpl.cs +++ b/src/Windows/Avalonia.Direct2D1/Media/GeometryImpl.cs @@ -47,6 +47,23 @@ namespace Avalonia.Direct2D1.Media } } + public IGeometryImpl GetWidenedGeometry(IPen pen) + { + var result = new PathGeometry(Direct2D1Platform.Direct2D1Factory); + + using (var sink = result.Open()) + { + Geometry.Widen( + (float)pen.Thickness, + pen.ToDirect2DStrokeStyle(Direct2D1Platform.Direct2D1Factory), + 0.25f, + sink); + sink.Close(); + } + + return new StreamGeometryImpl(result); + } + /// public bool FillContains(Point point) { diff --git a/tests/Avalonia.RenderTests/Shapes/PathTests.cs b/tests/Avalonia.RenderTests/Shapes/PathTests.cs index bf375121de..4f1412990b 100644 --- a/tests/Avalonia.RenderTests/Shapes/PathTests.cs +++ b/tests/Avalonia.RenderTests/Shapes/PathTests.cs @@ -434,5 +434,55 @@ namespace Avalonia.Direct2D1.RenderTests.Shapes await RenderToFile(target); CompareImages(); } + + [Fact] + public async Task GetWidenedPathGeometry_Line() + { + var pen = new Pen(Brushes.Black, 10); + var geometry = StreamGeometry.Parse("M 0,0 L 180,180").GetWidenedGeometry(pen); + + Decorator target = new Decorator + { + Width = 200, + Height = 200, + Child = new Path + { + Stroke = Brushes.Red, + StrokeThickness = 1, + Fill = Brushes.Green, + HorizontalAlignment = HorizontalAlignment.Center, + VerticalAlignment = VerticalAlignment.Center, + Data = geometry, + } + }; + + await RenderToFile(target); + CompareImages(); + } + + [Fact] + public async Task GetWidenedPathGeometry_Line_Dash() + { + var pen = new Pen(Brushes.Black, 10, DashStyle.Dash); + var geometry = StreamGeometry.Parse("M 0,0 L 180,180").GetWidenedGeometry(pen); + + Decorator target = new Decorator + { + Width = 200, + Height = 200, + Child = new Path + { + Stroke = Brushes.Red, + StrokeThickness = 1, + Fill = Brushes.Green, + HorizontalAlignment = HorizontalAlignment.Center, + VerticalAlignment = VerticalAlignment.Center, + Data = geometry, + } + }; + + await RenderToFile(target); + CompareImages(); + } } } diff --git a/tests/TestFiles/Direct2D1/Shapes/Path/GetWidenedPathGeometry_Line.expected.png b/tests/TestFiles/Direct2D1/Shapes/Path/GetWidenedPathGeometry_Line.expected.png new file mode 100644 index 0000000000000000000000000000000000000000..8399b9e790d6031461f36fded22720beeba4933e GIT binary patch literal 1440 zcmWlZeKb^g9LC3VWhT_PlQgsFHm;3gE0$)+F~i{An3|moqBrM^mz5)_)eB`uYNfXI zQ;WrB*|sDv6V4Wn>ZFZPN1Bwhy&Ph9ZEuIPyO^@y-yiq6&;8!#^L>8j{&A0~*N558 z6VKytIJPXK)WE$PU%a`X^)U@+;AXK~6Bfj2=ye=}2O>$KQgAqzzOy#RTXHy->sX~C za^Ke1gV#>Rg)F>2#C9()_$f4UE$6PmlC#psV~`bYu86}=G2Eq&6~!O zuLZl-c5M8(Z!&FDfNaHGC3i=;^i9s5{WCRny-N!64n2w8cDX=$=Baz&+W!nsvdp~) z#!XWzf61OzwO;wRIyG`D5IIF!)t&6(LH}phTe%SokaCy8NDtz$Jo3Yt%lVx+z zW>)=coUZv@vV!O(Nq*pgE2{@_jZgDj zC&7;$ z;XPP`AA?G@e`5{71=1)7C`dn%1_eXvEx2H58!o77vjJV&W(#_-4S%)rJxidnou^aL zsV;>j`FYX&5qrr{0(UToPUY#WkbuaTgQ&s)rD=`chAKM5*HLJarK|*O4v_&igbEX| zfkXz4CKc2&0egrmLrjLSo2M))k%vl(DhMwL8gVH^$7lzAgq+^b(@7*o1tlce8NzEd zYEe<%0=Av-5~3EC@+R3V!pj@AgizK3)}Lq+pcVyXO|l<|rd6n4McE734no66{VZi~ z#~vg!BGey3*^_Jlp>aq33d&w+oFFtZ)^ zBruK>CO&cuqnzxFLxf3$9AQct)45}(nWJ`0m$Io+?zgO{oKK)9a>^V`|IW{w%^&g8 zrypS&NZvsHNDeoC1u}b7enB9GORU}F2c4JvW6>V5YW~@R{gCQ+Y69Y|5yeAZ7pQg^O>oYeQ7aD}Pr2Y;{?GCVC?0k%#iX*D}OUfAgr?A+-b92j|Xaw==|P37gD zy%#mlVpr~T6_jO0(Bsb>0*j9iM2)_auZfocH7LdA;AC&*SsQ=kt8L-f!knSEtRYx~d=$ zXfv5aa0l{B`GP6~TH(#P4YHk7fl43{>}N8;{=^l( z*^#8+;W+)yIWM+zbO`74wTIQMgr_>!zdb0w^)BcYUm zoA(r`=F$7!52|a+oLb&pru?n(fkR@I{_>^s_8zk|Atgk5&-W59zi@f=YipfY+7!n_ z{)K#_@|*ZjW5!SG2j;(0o8t;2fJXokRE{2BD*=`AY-7!rvCXC~D~6yBk@xH1j{*Y^ z>84T=CK(~Hs(zCg?~r}k-$)DNU${|ck@hIJw_VXKbY<9)`gU;f&cf7o^UXQjW`Da0 z22BvGL#nU^6X=+-9i&I{qPTQBY2f?GrRb|+8{2!AoB3E@J+UxFXk*a-HnfmB>pJ{G z7KnNus{G~K{`jR8iuqs{yko#z!c#MCz-RI|BzaVw)VhWVR4gnm>gf4Sjo_D_sXk_z z*qt7)&8PS8@LbGCDhE}5^e8{ff~&C015r;rF#?4v+n9})!Yhl0u#z(jkGtKWQ9OUA z_2bDgYUq6{kSNfF8j|Pg2@~}qWdy7;`a2O2iel<6Udp0UBC3}iLFw&L7rYVFjX-lA zNtFP;Vy|}``Dc*$F5iZZ(jt(b+F)bp?Q=a!+rCtopG1zc-W^gY z6IqPtE5$$BN$a~|gvX@b;HU`ylL=y>R2y`EF!8%!NgSFMpG%Z|u%3l&VN`dYyqQEa zns;nyVeXHAP)7ST*e@w*?8%X#^Xqshf$ldIijrD}ggPI)<>gu$Fm$;Mt+jBZ-%Xei z)^P9D)IyJAmrKNAieaQHJ$#ysx=w z&T3#$SVO#a*|ue3{)O?W_iZ+HKGOd3vbF0-@CQ>ZEUD;j_Snm5pM!HMjq5FJgV?0h z?YK=`k7BS@Algtp#(4~EwFnd~)ueN^T|)21qR#q`^9FX)QOLl2UWjNru&(g+ZCgh*ePtei7 zh?u84*`LL;kbgO^wt!iAzgHMZmW9pVK`IM1NsV%s)nN{0Sw=xpAwZ*%pmZp|G4T{4cevu{)1(1;RC_@aguCHBI9TR^+n-g3@i`NNnnv z8U2DNh8JVq(jB!(%Z&#N{I@se`EJ1y1Cl_zdgXKeSv>9^4TbMKleeDCJ|x8{9i8>`#GRHLAG9!($@_U_UZt)o4)XBmC!Lo%t|P4QYd`JxZ8ZrPCPSZ<+~P)~`< zzb71iLOD3~jr{~TPRGX=9m5DT^&vu|I=MS*#uot0&0v5wvZEdz!(Ea29#3Ew3?Z!IvY z{+$jD17XD|(=bpL7fHixVsQ~P44lQ)pkYpwv1WLNjY|(bRZKt3nsgfD@_nQ#O6-@O zpi{7m#Hwt=^z^c+0m?XH?`lE9l|^ZALp2@aJCH39<*q*d!jG7|$zwN-nRMLPs5oJi zwKaGvEUND?YS%HWmI!npO|W!r;2V5sC;RH*>>OO_*R>+*si=w-pslqDH4~_R&Cm`D z(T~2yubM4SKRxZRq@e&Y%goz1&Py?hM5cYJaJh#(*b(MROE}TV5+o?hhx>6~i*mR3 zGM$<@E_?UkCXFdF3tEx!q6msH)B@Z8*(%UXO~63tPqr%mbclbf8Meg<)x)Z5P9<0lNq zpS)Q811^fKC!&H+>Y@$d<<3GQKu@s&;ug_zsKJ$W9~nSUA+h~XN~ z0&F<*Zi>P(I{@Z;IKe>=zj+%l4ZjB}(Ns8D%I(KN;8nJ&DGk-bm27}n8;EX8T?jFc z-W^bMyNW!T zCJWbu20cQs;LxDQLvk(2lWRyL>!O)tm(ACbwMZA=b_cUT)wfg4F^gTX9yb*2q>L#v zT;Pdi{T}HT*Rk=;riPLsd(Z&Dp1F2#xW=|=)yrk#PF<(V8Xd_=!)HM=&>_a^jwn*P z4~wg-l4ZM%ytuj6f8c=wHHG6*oS&qc6>g6?k9(j5unJ(AsZZ|tKPfq`VDgSf401jA&7;2CGwzj?XkM z3LYCiRwNPy8z8d;&n|LF{PDeM#mOUh5^qZ}WJ~baC1E*{$WLZq7H03Ld+NDVeutU3 zzkm8cJ?lErF=4XY20d;R_a%*#tzQ zOlNZRqc5%I+?~<}_X1C!dT1o+v? z_W6WAZX^c2a}a~a z-Kz;mF7qcKE{cHf(g^6>NPxAGfaWFwde0d!*wDR>0B!ASQ1}{T(TJhX)~~?0VdZt5 z+@9HTpGbKoF>cQ@DR6smsfpn9VgjN}q&+V?0t0vBJluAetQb!@#*?;w7M50(EGE0% zG$#~G&D|`U7_N9BQU$y@=Gz#Vg zE$~#sUJ3!`hSWBWs+cPgl=hn$%FlRVGjQ&KgKCt<2}a=L;9v}Lb3#0DA~>i*X}ll> z&T8mZz=zC|DqXm;!YsZau{%^~KgX7U`~$FAjr@6bKFD{#X1vO?R*+u@d(=qEv0C6Q zg*_@%&aoEYJpillD$gbYuLD-$Rerz>ymc@>294ncc7d=`cqIneICg<8NKcPQSRGYk zRPK{^%?4rB(5P_z%9!hPAzd<{;-OR~8I^TC5_c?OhG*EGhuL3cD3c&P8X^hc8D(9< zP8#$J_86I@@<2GRt1kQriaX-IPNhQDMNF03wp3+NGRgV~6^{11*b04;D7T&B@^CEk z&cLzZE*8q3VE5D)c{nzDv3u%x4;C5=h|4d-u{NK$&K@k(7GO7%nP!)iuKs{yl;cb# zG^js+7+a=>IT#%bC(-#1j$a+d1=Ddgj!oetPMgH*8uGfJI7N|$_jovqlH0C1nsDK= zXC{t96Sgk*QWbqK9xTi`RIz+z+a*oQ^x;pQwQBA?^c|{wbv`5N=s*5vH$5|+ZcYEs zr`p~+P`0$dFD3LF{mj^MMp-nFO>cXEU$9=9YBomly2sa!+3O+#iVBVuZfocH7LdA;AC&*SsQ=kt8L-f!knSEtRYx~d=$ zXfv5aa0l{B`GP6~TH(#P4YHk7fl43{>}N8;{=^l( z*^#8+;W+)yIWM+zbO`74wTIQMgr_>!zdb0w^)BcYUm zoA(r`=F$7!52|a+oLb&pru?n(fkR@I{_>^s_8zk|Atgk5&-W59zi@f=YipfY+7!n_ z{)K#_@|*ZjW5!SG2j;(0o8t;2fJXokRE{2BD*=`AY-7!rvCXC~D~6yBk@xH1j{*Y^ z>84T=CK(~Hs(zCg?~r}k-$)DNU${|ck@hIJw_VXKbY<9)`gU;f&cf7o^UXQjW`Da0 z22BvGL#nU^6X=+-9i&I{qPTQBY2f?GrRb|+8{2!AoB3E@J+UxFXk*a-HnfmB>pJ{G z7KnNus{G~K{`jR8iuqs{yko#z!c#MCz-RI|BzaVw)VhWVR4gnm>gf4Sjo_D_sXk_z z*qt7)&8PS8@LbGCDhE}5^e8{ff~&C015r;rF#?4v+n9})!Yhl0u#z(jkGtKWQ9OUA z_2bDgYUq6{kSNfF8j|Pg2@~}qWdy7;`a2O2iel<6Udp0UBC3}iLFw&L7rYVFjX-lA zNtFP;Vy|}``Dc*$F5iZZ(jt(b+F)bp?Q=a!+rCtopG1zc-W^gY z6IqPtE5$$BN$a~|gvX@b;HU`ylL=y>R2y`EF!8%!NgSFMpG%Z|u%3l&VN`dYyqQEa zns;nyVeXHAP)7ST*e@w*?8%X#^Xqshf$ldIijrD}ggPI)<>gu$Fm$;Mt+jBZ-%Xei z)^P9D)IyJAmrKNAieaQHJ$#ysx=w z&T3#$SVO#a*|ue3{)O?W_iZ+HKGOd3vbF0-@CQ>ZEUD;j_Snm5pM!HMjq5FJgV?0h z?YK=`k7BS@Algtp#(4~EwFnd~)ueN^T|)21qR#q`^9FX)QOLl2UWjNru&(g+ZCgh*ePtei7 zh?u84*`LL;kbgO^wt!iAzgHMZmW9pVK`IM1NsV%s)nN{0Sw=xpAwZ*%pmZp|G4T{4cevu{)1(1;RC_@aguCHBI9TR^+n-g3@i`NNnnv z8U2DNh8JVq(jB!(%Z&#N{I@se`EJ1y1Cl_zdgXKeSv>9^4TbMKleeDCJ|x8{9i8>`#GRHLAG9!($@_U_UZt)o4)XBmC!Lo%t|P4QYd`JxZ8ZrPCPSZ<+~P)~`< zzb71iLOD3~jr{~TPRGX=9m5DT^&vu|I=MS*#uot0&0v5wvZEdz!(Ea29#3Ew3?Z!IvY z{+$jD17XD|(=bpL7fHixVsQ~P44lQ)pkYpwv1WLNjY|(bRZKt3nsgfD@_nQ#O6-@O zpi{7m#Hwt=^z^c+0m?XH?`lE9l|^ZALp2@aJCH39<*q*d!jG7|$zwN-nRMLPs5oJi zwKaGvEUND?YS%HWmI!npO|W!r;2V5sC;RH*>>OO_*R>+*si=w-pslqDH4~_R&Cm`D z(T~2yubM4SKRxZRq@e&Y%goz1&Py?hM5cYJaJh#(*b(MROE}TV5+o?hhx>6~i*mR3 zGM$<@E_?UkCXFdF3tEx!q6msH)B@Z8*(%UXO~63tPqr%mbclbf8Meg<)x)Z5P9<0lNq zpS)Q811^fKC!&H+>Y@$d<<3GQKu@s&;ug_zsKJ$W9~nSUA+h~XN~ z0&F<*Zi>P(I{@Z;IKe>=zj+%l4ZjB}(Ns8D%I(KN;8nJ&DGk-bm27}n8;EX8T?jFc z-W^bMyNW!T zCJWbu20cQs;LxDQLvk(2lWRyL>!O)tm(ACbwMZA=b_cUT)wfg4F^gTX9yb*2q>L#v zT;Pdi{T}HT*Rk=;riPLsd(Z&Dp1F2#xW=|=)yrk#PF<(V8Xd_=!)HM=&>_a^jwn*P z4~wg-l4ZM%ytuj6f8c=wHHG6*oS&qc6>g6?k9(j5unJ(AsZZ|tKPfq`VD