From 07bc733847892e229e884801af45ff0934bfeb8a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wies=C5=82aw=20=C5=A0olt=C3=A9s?= Date: Thu, 23 Apr 2026 00:52:17 +0200 Subject: [PATCH 1/5] Add vector-effect scene metadata --- src/ShimSkiaSharp/SKPaint.cs | 17 +++++++++++ .../Compatibility/SvgElementFactory.cs | 1 + src/Svg.Custom/Painting/SvgVectorEffect.cs | 28 +++++++++++++++++++ src/Svg.SceneGraph/SvgSceneCompiler.cs | 1 + src/Svg.SceneGraph/SvgSceneDocument.cs | 1 + src/Svg.SceneGraph/SvgSceneNode.cs | 3 ++ .../SvgSceneNodeBoundsService.cs | 10 +++++-- src/Svg.SceneGraph/SvgScenePaintingService.cs | 1 + .../ShimSkiaSharp.UnitTests/CloneCoreTests.cs | 1 + .../ShimSkiaSharp.UnitTests/CloneTestData.cs | 1 + 10 files changed, 61 insertions(+), 3 deletions(-) create mode 100644 src/Svg.Custom/Painting/SvgVectorEffect.cs diff --git a/src/ShimSkiaSharp/SKPaint.cs b/src/ShimSkiaSharp/SKPaint.cs index 0abf5e7923..32596bce1d 100644 --- a/src/ShimSkiaSharp/SKPaint.cs +++ b/src/ShimSkiaSharp/SKPaint.cs @@ -13,6 +13,7 @@ public sealed class SKPaint : ICloneable, IDeepCloneable private SKStrokeCap _strokeCap = SKStrokeCap.Butt; private SKStrokeJoin _strokeJoin = SKStrokeJoin.Miter; private float _strokeMiter = 4; + private bool _isStrokeNonScaling; private SKTypeface? _typeface; private float _textSize = 12; private SKTextAlign _textAlign = SKTextAlign.Left; @@ -135,6 +136,21 @@ public float StrokeMiter } } + public bool IsStrokeNonScaling + { + get => _isStrokeNonScaling; + set + { + if (_isStrokeNonScaling == value) + { + return; + } + + _isStrokeNonScaling = value; + _version++; + } + } + public SKTypeface? Typeface { get => _typeface; @@ -353,6 +369,7 @@ internal SKPaint DeepClone(CloneContext context) clone.StrokeCap = StrokeCap; clone.StrokeJoin = StrokeJoin; clone.StrokeMiter = StrokeMiter; + clone.IsStrokeNonScaling = IsStrokeNonScaling; clone.Typeface = Typeface?.DeepClone(context); clone.TextSize = TextSize; clone.TextAlign = TextAlign; diff --git a/src/Svg.Custom/Compatibility/SvgElementFactory.cs b/src/Svg.Custom/Compatibility/SvgElementFactory.cs index b539f64bf1..3fbe2cf447 100644 --- a/src/Svg.Custom/Compatibility/SvgElementFactory.cs +++ b/src/Svg.Custom/Compatibility/SvgElementFactory.cs @@ -234,6 +234,7 @@ private static bool IsStyleAttribute(string name) case "text-rendering": case "text-transform": case "unicode-bidi": + case "vector-effect": case "visibility": case "word-spacing": case "writing-mode": diff --git a/src/Svg.Custom/Painting/SvgVectorEffect.cs b/src/Svg.Custom/Painting/SvgVectorEffect.cs new file mode 100644 index 0000000000..b04be8bd5e --- /dev/null +++ b/src/Svg.Custom/Painting/SvgVectorEffect.cs @@ -0,0 +1,28 @@ +using System.ComponentModel; + +namespace Svg +{ + [TypeConverter(typeof(SvgVectorEffectConverter))] + public enum SvgVectorEffect + { + None, + NonScalingStroke + } + + public sealed class SvgVectorEffectConverter : EnumBaseConverter + { + public SvgVectorEffectConverter() : base(CaseHandling.KebabCase) + { + } + } + + public abstract partial class SvgVisualElement + { + [SvgAttribute("vector-effect")] + public virtual SvgVectorEffect VectorEffect + { + get { return GetAttribute("vector-effect", false, SvgVectorEffect.None); } + set { Attributes["vector-effect"] = value; } + } + } +} diff --git a/src/Svg.SceneGraph/SvgSceneCompiler.cs b/src/Svg.SceneGraph/SvgSceneCompiler.cs index c3998bee40..0e69eea95d 100644 --- a/src/Svg.SceneGraph/SvgSceneCompiler.cs +++ b/src/Svg.SceneGraph/SvgSceneCompiler.cs @@ -1184,6 +1184,7 @@ private static bool TryCompileDirectElementNode( node.HitTestPath = path; node.SupportsFillHitTest = SvgScenePaintingService.IsValidFill(visualElement); node.SupportsStrokeHitTest = SvgScenePaintingService.IsValidStroke(visualElement, node.GeometryBounds); + node.IsStrokeNonScaling = visualElement.VectorEffect == SvgVectorEffect.NonScalingStroke; node.HitTestTargetElement = GetDefaultHitTestTargetElement(node, element); AssignRetainedVisualState(node, element); AssignRetainedResourceKeys(node, element, compileContext.GetElementAddressKey); diff --git a/src/Svg.SceneGraph/SvgSceneDocument.cs b/src/Svg.SceneGraph/SvgSceneDocument.cs index a976385656..8f53d02b8b 100644 --- a/src/Svg.SceneGraph/SvgSceneDocument.cs +++ b/src/Svg.SceneGraph/SvgSceneDocument.cs @@ -735,6 +735,7 @@ private void ResolveRuntimePayload(SvgSceneNode node, bool refreshRetainedMetada ? SvgScenePaintingService.GetStrokePaint(visualElement, node.GeometryBounds, AssetLoader, IgnoreAttributes) : null; node.StrokeWidth = hasOwnPaintPayload ? node.Stroke?.StrokeWidth ?? 0f : 0f; + node.IsStrokeNonScaling = hasOwnPaintPayload && visualElement.VectorEffect == SvgVectorEffect.NonScalingStroke; node.SetMask(null); node.MaskPaint = null; node.MaskDstIn = null; diff --git a/src/Svg.SceneGraph/SvgSceneNode.cs b/src/Svg.SceneGraph/SvgSceneNode.cs index 53c2af01bb..6ea8edeff4 100644 --- a/src/Svg.SceneGraph/SvgSceneNode.cs +++ b/src/Svg.SceneGraph/SvgSceneNode.cs @@ -116,6 +116,8 @@ internal SvgSceneNode( public float StrokeWidth { get; internal set; } + public bool IsStrokeNonScaling { get; internal set; } + public bool IsRenderable { get; internal set; } public bool IsAntialias { get; internal set; } @@ -188,6 +190,7 @@ internal void ReplaceWith(SvgSceneNode replacement) SupportsFillHitTest = replacement.SupportsFillHitTest; SupportsStrokeHitTest = replacement.SupportsStrokeHitTest; StrokeWidth = replacement.StrokeWidth; + IsStrokeNonScaling = replacement.IsStrokeNonScaling; IsRenderable = replacement.IsRenderable; IsAntialias = replacement.IsAntialias; SuppressSubtreeRendering = replacement.SuppressSubtreeRendering; diff --git a/src/Svg.SceneGraph/SvgSceneNodeBoundsService.cs b/src/Svg.SceneGraph/SvgSceneNodeBoundsService.cs index 9cd239b57d..82cadfb096 100644 --- a/src/Svg.SceneGraph/SvgSceneNodeBoundsService.cs +++ b/src/Svg.SceneGraph/SvgSceneNodeBoundsService.cs @@ -67,9 +67,13 @@ public static SKRect GetInflatedBounds(SvgSceneNode node, SKRect bounds) return bounds; } - var scaleX = Math.Sqrt((node.TotalTransform.ScaleX * node.TotalTransform.ScaleX) + (node.TotalTransform.SkewY * node.TotalTransform.SkewY)); - var scaleY = Math.Sqrt((node.TotalTransform.SkewX * node.TotalTransform.SkewX) + (node.TotalTransform.ScaleY * node.TotalTransform.ScaleY)); - var inflation = (float)(Math.Max(scaleX, scaleY) * node.StrokeWidth / 2f); + var inflation = node.StrokeWidth / 2f; + if (!node.IsStrokeNonScaling) + { + var scaleX = Math.Sqrt((node.TotalTransform.ScaleX * node.TotalTransform.ScaleX) + (node.TotalTransform.SkewY * node.TotalTransform.SkewY)); + var scaleY = Math.Sqrt((node.TotalTransform.SkewX * node.TotalTransform.SkewX) + (node.TotalTransform.ScaleY * node.TotalTransform.ScaleY)); + inflation = (float)(Math.Max(scaleX, scaleY) * inflation); + } if (inflation <= 0f) { return bounds; diff --git a/src/Svg.SceneGraph/SvgScenePaintingService.cs b/src/Svg.SceneGraph/SvgScenePaintingService.cs index 968735ac53..8439978b08 100644 --- a/src/Svg.SceneGraph/SvgScenePaintingService.cs +++ b/src/Svg.SceneGraph/SvgScenePaintingService.cs @@ -146,6 +146,7 @@ internal static SKPaint CreateSolidFillPaint(SolidFillPaintCacheKey key) skPaint.StrokeMiter = svgVisualElement.StrokeMiterLimit; skPaint.StrokeWidth = svgVisualElement.StrokeWidth.ToDeviceValue(UnitRenderingType.Other, svgVisualElement, skBounds); + skPaint.IsStrokeNonScaling = svgVisualElement.VectorEffect == SvgVectorEffect.NonScalingStroke; if (svgVisualElement.StrokeDashArray is { }) { diff --git a/tests/ShimSkiaSharp.UnitTests/CloneCoreTests.cs b/tests/ShimSkiaSharp.UnitTests/CloneCoreTests.cs index bcbcf0b7ac..8e945cf876 100644 --- a/tests/ShimSkiaSharp.UnitTests/CloneCoreTests.cs +++ b/tests/ShimSkiaSharp.UnitTests/CloneCoreTests.cs @@ -173,6 +173,7 @@ private static void AssertPaintClone(SKPaint original, SKPaint clone) Assert.Equal(original.StrokeCap, clone.StrokeCap); Assert.Equal(original.StrokeJoin, clone.StrokeJoin); Assert.Equal(original.StrokeMiter, clone.StrokeMiter); + Assert.Equal(original.IsStrokeNonScaling, clone.IsStrokeNonScaling); Assert.Equal(original.TextSize, clone.TextSize); Assert.Equal(original.TextAlign, clone.TextAlign); Assert.Equal(original.LcdRenderText, clone.LcdRenderText); diff --git a/tests/ShimSkiaSharp.UnitTests/CloneTestData.cs b/tests/ShimSkiaSharp.UnitTests/CloneTestData.cs index eab157a4dc..50ff44df49 100644 --- a/tests/ShimSkiaSharp.UnitTests/CloneTestData.cs +++ b/tests/ShimSkiaSharp.UnitTests/CloneTestData.cs @@ -16,6 +16,7 @@ public static SKPaint CreatePaint() StrokeCap = SKStrokeCap.Round, StrokeJoin = SKStrokeJoin.Bevel, StrokeMiter = 3, + IsStrokeNonScaling = true, Typeface = SKTypeface.FromFamilyName("Test", SKFontStyleWeight.Bold, SKFontStyleWidth.Condensed, SKFontStyleSlant.Italic), TextSize = 14, TextAlign = SKTextAlign.Center, From 693598dbd9e76ee3f45c5eaff2bea0727722ff38 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wies=C5=82aw=20=C5=A0olt=C3=A9s?= Date: Thu, 23 Apr 2026 00:52:21 +0200 Subject: [PATCH 2/5] Render non-scaling strokes under transforms --- .../SkiaCSharpModelExtensions.cs | 24 ++++- src/Svg.Skia/SKSvg.Model.cs | 95 ++++++++++++++++++- src/Svg.Skia/SkiaModel.cs | 40 +++++++- 3 files changed, 152 insertions(+), 7 deletions(-) diff --git a/src/Svg.CodeGen.Skia/SkiaCSharpModelExtensions.cs b/src/Svg.CodeGen.Skia/SkiaCSharpModelExtensions.cs index ce06c5532d..e013ccff14 100644 --- a/src/Svg.CodeGen.Skia/SkiaCSharpModelExtensions.cs +++ b/src/Svg.CodeGen.Skia/SkiaCSharpModelExtensions.cs @@ -1890,7 +1890,29 @@ public static void ToSKPicture(this SKPicture? picture, SkiaCSharpCodeGenCounter drawPathCanvasCommand.Path.ToSKPath(counter, sb, indent); var counterPaint = ++counter.Paint; drawPathCanvasCommand.Paint.ToSKPaint(counter, sb, indent); - sb.AppendLine($"{indent}{counter.CanvasVarName}{counterCanvas}.DrawPath({counter.PathVarName}{counterPath}, {counter.PaintVarName}{counterPaint});"); + if (drawPathCanvasCommand.Paint.IsStrokeNonScaling && + drawPathCanvasCommand.Paint.Style == SKPaintStyle.Stroke) + { + var counterNonScalingPath = ++counter.Path; + sb.AppendLine($"{indent}var matrix{counterNonScalingPath} = {counter.CanvasVarName}{counterCanvas}.TotalMatrix;"); + sb.AppendLine($"{indent}if (matrix{counterNonScalingPath}.IsIdentity)"); + sb.AppendLine($"{indent}{{"); + sb.AppendLine($"{indent} {counter.CanvasVarName}{counterCanvas}.DrawPath({counter.PathVarName}{counterPath}, {counter.PaintVarName}{counterPaint});"); + sb.AppendLine($"{indent}}}"); + sb.AppendLine($"{indent}else"); + sb.AppendLine($"{indent}{{"); + sb.AppendLine($"{indent} using var {counter.PathVarName}{counterNonScalingPath} = new SKPath({counter.PathVarName}{counterPath});"); + sb.AppendLine($"{indent} {counter.PathVarName}{counterNonScalingPath}.Transform(matrix{counterNonScalingPath});"); + sb.AppendLine($"{indent} {counter.CanvasVarName}{counterCanvas}.Save();"); + sb.AppendLine($"{indent} {counter.CanvasVarName}{counterCanvas}.ResetMatrix();"); + sb.AppendLine($"{indent} {counter.CanvasVarName}{counterCanvas}.DrawPath({counter.PathVarName}{counterNonScalingPath}, {counter.PaintVarName}{counterPaint});"); + sb.AppendLine($"{indent} {counter.CanvasVarName}{counterCanvas}.Restore();"); + sb.AppendLine($"{indent}}}"); + } + else + { + sb.AppendLine($"{indent}{counter.CanvasVarName}{counterCanvas}.DrawPath({counter.PathVarName}{counterPath}, {counter.PaintVarName}{counterPaint});"); + } // NOTE: Do not dispose created SKTypeface by font manager. #if USE_DISPOSE_TYPEFACE diff --git a/src/Svg.Skia/SKSvg.Model.cs b/src/Svg.Skia/SKSvg.Model.cs index 51f10d1766..e6287c7c56 100644 --- a/src/Svg.Skia/SKSvg.Model.cs +++ b/src/Svg.Skia/SKSvg.Model.cs @@ -618,6 +618,18 @@ public bool FlushPendingAnimationFrame() public bool Save(System.IO.Stream stream, SkiaSharp.SKColor background, SkiaSharp.SKEncodedImageFormat format = SkiaSharp.SKEncodedImageFormat.Png, int quality = 100, float scaleX = 1f, float scaleY = 1f) { + SKPicture? model; + lock (Sync) + { + model = Model; + } + + if (ContainsNonScalingStroke(model) && + TrySaveModelImage(model, stream, background, format, quality, scaleX, scaleY)) + { + return true; + } + if (Picture is { }) { if (Picture.ToImage(stream, background, format, quality, scaleX, scaleY, SkiaSharp.SKColorType.Rgba8888, SkiaSharp.SKAlphaType.Premul, Settings.Srgb)) @@ -677,6 +689,63 @@ private bool TrySaveBlankModelImage(System.IO.Stream stream, SkiaSharp.SKColor b return true; } + private bool TrySaveModelImage(SKPicture? model, System.IO.Stream stream, SkiaSharp.SKColor background, SkiaSharp.SKEncodedImageFormat format, int quality, float scaleX, float scaleY) + { + if (model is null) + { + return false; + } + + var width = model.CullRect.Width * scaleX; + var height = model.CullRect.Height * scaleY; + if (!(width > 0) || !(height > 0)) + { + return false; + } + + var imageInfo = new SkiaSharp.SKImageInfo((int)width, (int)height, SkiaSharp.SKColorType.Rgba8888, SkiaSharp.SKAlphaType.Premul, Settings.Srgb); + using var bitmap = new SkiaSharp.SKBitmap(imageInfo); + using var canvas = new SkiaSharp.SKCanvas(bitmap); + canvas.Clear(background); + canvas.Save(); + canvas.Scale(scaleX, scaleY); + SkiaModel.Draw(model, canvas); + canvas.Restore(); + + using var image = SkiaSharp.SKImage.FromBitmap(bitmap); + using var data = image.Encode(format, quality); + if (data is null) + { + return false; + } + + data.SaveTo(stream); + return true; + } + + private static bool ContainsNonScalingStroke(SKPicture? model) + { + if (model?.Commands is not { } commands) + { + return false; + } + + for (var i = 0; i < commands.Count; i++) + { + switch (commands[i]) + { + case DrawPathCanvasCommand { Paint: { IsStrokeNonScaling: true, Style: SKPaintStyle.Stroke } }: + return true; + + case DrawPictureCanvasCommand { Picture: { } picture } + when ContainsNonScalingStroke(picture): + return true; + } + } + + return false; + } + public void Draw(SkiaSharp.SKCanvas canvas) { BeginDraw(); @@ -710,14 +779,30 @@ public void Draw(SkiaSharp.SKCanvas canvas) } else if (!TryDrawAnimationLayers(canvas)) { - var picture = Picture; - if (picture is null) + SKPicture? model; + lock (Sync) { - canvas.Restore(); - return; + model = Model; } - canvas.DrawPicture(picture); + if (ContainsNonScalingStroke(model)) + { + if (model is not null) + { + SkiaModel.Draw(model, canvas); + } + } + else + { + var picture = Picture; + if (picture is null) + { + canvas.Restore(); + return; + } + + canvas.DrawPicture(picture); + } } canvas.Restore(); } diff --git a/src/Svg.Skia/SkiaModel.cs b/src/Svg.Skia/SkiaModel.cs index c2a39a23da..c492fc61ee 100644 --- a/src/Svg.Skia/SkiaModel.cs +++ b/src/Svg.Skia/SkiaModel.cs @@ -1794,10 +1794,15 @@ public void Draw(CanvasCommand canvasCommand, SkiaSharp.SKCanvas skCanvas, bool if (drawPathCanvasCommand.Path is { } && drawPathCanvasCommand.Paint is { }) { var path = GetRenderPath(drawPathCanvasCommand.Path); + if (path is null) + { + break; + } + var paint = wireframe ? ToWireframePaint(drawPathCanvasCommand.Paint) : GetRenderPaint(drawPathCanvasCommand.Paint); - skCanvas.DrawPath(path, paint); + DrawPath(skCanvas, path, paint, drawPathCanvasCommand.Paint); } break; } @@ -1876,6 +1881,39 @@ public void Draw(SKPicture picture, SkiaSharp.SKCanvas skCanvas, bool wireframe } } + private static void DrawPath( + SkiaSharp.SKCanvas skCanvas, + SkiaSharp.SKPath path, + SkiaSharp.SKPaint? paint, + SKPaint sourcePaint) + { + if (paint is null) + { + return; + } + + if (!sourcePaint.IsStrokeNonScaling || sourcePaint.Style != SKPaintStyle.Stroke) + { + skCanvas.DrawPath(path, paint); + return; + } + + var currentMatrix = skCanvas.TotalMatrix; + if (currentMatrix.IsIdentity) + { + skCanvas.DrawPath(path, paint); + return; + } + + using var transformedPath = new SkiaSharp.SKPath(path); + transformedPath.Transform(currentMatrix); + + skCanvas.Save(); + skCanvas.ResetMatrix(); + skCanvas.DrawPath(transformedPath, paint); + skCanvas.Restore(); + } + private SkiaSharp.SKPaint ToWireframePaint(SKPaint? paint) { var strokeCap = paint is null ? SkiaSharp.SKStrokeCap.Butt : ToSKStrokeCap(paint.StrokeCap); From 55ce64b41c18a6e5bc4a2665f352171df8cc1fbe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wies=C5=82aw=20=C5=A0olt=C3=A9s?= Date: Thu, 23 Apr 2026 00:52:25 +0200 Subject: [PATCH 3/5] Add non-scaling stroke regression tests --- tests/Svg.Skia.UnitTests/SKSvgTests.cs | 74 ++++++++++++++++++++++++++ 1 file changed, 74 insertions(+) diff --git a/tests/Svg.Skia.UnitTests/SKSvgTests.cs b/tests/Svg.Skia.UnitTests/SKSvgTests.cs index 5ef8be8b52..d1753fc797 100644 --- a/tests/Svg.Skia.UnitTests/SKSvgTests.cs +++ b/tests/Svg.Skia.UnitTests/SKSvgTests.cs @@ -126,6 +126,80 @@ public void Save_StandaloneDocumentWithExplicitPercentSize_UsesConfiguredStandal Assert.Equal(360, image.Height); } + [Theory] + [InlineData("", 1, 3)] + [InlineData("vector-effect=\"non-scaling-stroke\"", 3, 6)] + [InlineData("style=\"vector-effect: non-scaling-stroke\"", 3, 6)] + public void Save_DownScaledVectorEffectNonScalingStroke_PreservesStrokeWidth(string vectorEffect, int minOpaquePixels, int maxOpaquePixels) + { + var svg = new SKSvg(); + using var input = new MemoryStream(Encoding.UTF8.GetBytes(CreateNonScalingStrokeSvgMarkup(vectorEffect))); + using var _ = svg.Load(input); + using var output = new MemoryStream(); + + Assert.True(svg.Save(output, SkiaSharp.SKColors.Transparent, SkiaSharp.SKEncodedImageFormat.Png, 100, 0.5f, 0.5f)); + + output.Position = 0; + using var image = Image.Load(output); + Assert.Equal(50, image.Width); + Assert.Equal(20, image.Height); + + var opaquePixels = CountOpaquePixelsInColumn(image, 25); + Assert.InRange(opaquePixels, minOpaquePixels, maxOpaquePixels); + } + + [Theory] + [InlineData("", 1, 3)] + [InlineData("vector-effect=\"non-scaling-stroke\"", 3, 6)] + [InlineData("style=\"vector-effect: non-scaling-stroke\"", 3, 6)] + public void Draw_DownScaledVectorEffectNonScalingStroke_PreservesStrokeWidthAndRaisesOnDraw(string vectorEffect, int minOpaquePixels, int maxOpaquePixels) + { + var svg = new SKSvg(); + using var input = new MemoryStream(Encoding.UTF8.GetBytes(CreateNonScalingStrokeSvgMarkup(vectorEffect))); + using var _ = svg.Load(input); + using var bitmap = new SkiaSharp.SKBitmap(new SkiaSharp.SKImageInfo(50, 20, SkiaSharp.SKColorType.Rgba8888, SkiaSharp.SKAlphaType.Premul)); + using var canvas = new SkiaSharp.SKCanvas(bitmap); + var drawCount = 0; + svg.OnDraw += (_, _) => drawCount++; + + canvas.Clear(SkiaSharp.SKColors.Transparent); + canvas.Scale(0.5f, 0.5f); + svg.Draw(canvas); + + using var skImage = SkiaSharp.SKImage.FromBitmap(bitmap); + using var data = skImage.Encode(SkiaSharp.SKEncodedImageFormat.Png, 100); + Assert.NotNull(data); + + using var output = new MemoryStream(data.ToArray()); + using var image = Image.Load(output); + var opaquePixels = CountOpaquePixelsInColumn(image, 25); + Assert.InRange(opaquePixels, minOpaquePixels, maxOpaquePixels); + Assert.Equal(1, drawCount); + } + + private static string CreateNonScalingStrokeSvgMarkup(string vectorEffect) + { + return $$""" + + + + """; + } + + private static int CountOpaquePixelsInColumn(Image image, int x) + { + var count = 0; + for (var y = 0; y < image.Height; y++) + { + if (image[x, y].A > 200) + { + count++; + } + } + + return count; + } + [Fact] public void Save_InheritedCurrentColor_UsesConsumingElementsColor() { From 2fdf1a51314335b4c7496d218443a8d4a9661054 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wies=C5=82aw=20=C5=A0olt=C3=A9s?= Date: Thu, 23 Apr 2026 07:07:47 +0200 Subject: [PATCH 4/5] Fix non-scaling stroke hit testing --- .../Services/GeometryHitTestService.cs | 38 +++++++++++++++++++ src/Svg.SceneGraph/SvgSceneHitTestService.cs | 9 ++++- tests/Svg.Skia.UnitTests/HitTestTests.cs | 30 +++++++++++++++ 3 files changed, 75 insertions(+), 2 deletions(-) diff --git a/src/Svg.Model/Services/GeometryHitTestService.cs b/src/Svg.Model/Services/GeometryHitTestService.cs index 5dbbb5e35e..6a17d312e0 100644 --- a/src/Svg.Model/Services/GeometryHitTestService.cs +++ b/src/Svg.Model/Services/GeometryHitTestService.cs @@ -48,6 +48,24 @@ public static bool ContainsStroke(SKPath path, SKPoint point, SKMatrix transform return ContainsStrokeLocal(path, localPoint, Math.Max(strokeWidth / 2f, MinStrokeTolerance)); } + public static bool ContainsStroke(SKPath path, SKPoint point, SKMatrix transform, float strokeWidth, bool isStrokeNonScaling) + { + if (!isStrokeNonScaling) + { + return ContainsStroke(path, point, transform, strokeWidth); + } + + if (strokeWidth <= 0f) + { + return false; + } + + var tolerance = Math.Max(strokeWidth / 2f, MinStrokeTolerance); + return transform.IsIdentity + ? ContainsStrokeLocal(path, point, tolerance) + : ContainsTransformedStroke(path, point, transform, tolerance); + } + public static bool Contains(ClipPath clipPath, SKPoint point) { return ContainsClipPath(clipPath, point); @@ -164,6 +182,26 @@ private static bool ContainsFillLocal(SKPath path, SKPoint point) private static bool ContainsStrokeLocal(SKPath path, SKPoint point, float tolerance) { var contours = FlattenPath(path); + return ContainsStroke(contours, point, tolerance); + } + + private static bool ContainsTransformedStroke(SKPath path, SKPoint point, SKMatrix transform, float tolerance) + { + var contours = FlattenPath(path); + for (var i = 0; i < contours.Count; i++) + { + var points = contours[i].Points; + for (var j = 0; j < points.Count; j++) + { + points[j] = transform.MapPoint(points[j]); + } + } + + return ContainsStroke(contours, point, tolerance); + } + + private static bool ContainsStroke(List contours, SKPoint point, float tolerance) + { if (contours.Count == 0) { return false; diff --git a/src/Svg.SceneGraph/SvgSceneHitTestService.cs b/src/Svg.SceneGraph/SvgSceneHitTestService.cs index b2d9011a87..7d0e165fed 100644 --- a/src/Svg.SceneGraph/SvgSceneHitTestService.cs +++ b/src/Svg.SceneGraph/SvgSceneHitTestService.cs @@ -190,7 +190,12 @@ private static bool HitTestStrokeCore(SvgSceneNode node, SKPoint point) { if (node.HitTestPath is { } hitTestPath) { - return GeometryHitTestService.ContainsStroke(hitTestPath, point, node.TotalTransform, node.StrokeWidth); + return GeometryHitTestService.ContainsStroke( + hitTestPath, + point, + node.TotalTransform, + node.StrokeWidth, + node.IsStrokeNonScaling); } return node.StrokeWidth > 0f && GetDirectStrokeBounds(node).Contains(point); @@ -334,7 +339,7 @@ private static bool UsesStructuralBounds(SvgSceneNode node) private static SKRect GetRectHitBounds(SvgSceneNode node) { var bounds = UsesStructuralBounds(node) - ? GetStructuralBounds(node) + ? SvgSceneNodeBoundsService.GetRenderablePaintBounds(node) : GetDirectFillBounds(node); return SvgSceneNodeBoundsService.GetInflatedBounds(node, bounds); diff --git a/tests/Svg.Skia.UnitTests/HitTestTests.cs b/tests/Svg.Skia.UnitTests/HitTestTests.cs index 37d7a0fb3c..129b6bb017 100644 --- a/tests/Svg.Skia.UnitTests/HitTestTests.cs +++ b/tests/Svg.Skia.UnitTests/HitTestTests.cs @@ -114,6 +114,22 @@ public class HitTestTests : SvgUnitTest """; + private const string NonScalingStrokeHitTestSvg = """ + + + + + + """; + private static string GetSvgPath(string name) => Path.Combine("..", "..", "..", "..", "Tests", name); @@ -329,6 +345,20 @@ public void HitTest_Point_InvalidFilterSubtree_IsNotInteractable() Assert.Equal("back", topmostNode!.ElementId); } + [Fact] + public void HitTest_Point_NonScalingStroke_UsesDeviceSpaceStrokeWidth() + { + using var svg = new SKSvg(); + svg.FromSvg(NonScalingStrokeHitTestSvg); + + var strokeElement = svg.HitTestTopmostElement(new SKPoint(50, 54)); + var outsideElement = svg.HitTestTopmostElement(new SKPoint(50, 56)); + + Assert.NotNull(strokeElement); + Assert.Equal("target", strokeElement!.ID); + Assert.Null(outsideElement); + } + private static bool IntersectsWith(SKRect a, SKRect b) { return a.Left < b.Right && a.Right > b.Left && From 180a5c191d83a6a86923b960e436b56cf5d94d76 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wies=C5=82aw=20=C5=A0olt=C3=A9s?= Date: Thu, 23 Apr 2026 07:07:53 +0200 Subject: [PATCH 5/5] Render non-scaling animation layers live --- src/Svg.Skia/SKSvg.AnimationLayers.cs | 32 +++++++++++++++++---- tests/Svg.Skia.UnitTests/SKSvgTests.cs | 39 ++++++++++++++++++++++++++ 2 files changed, 65 insertions(+), 6 deletions(-) diff --git a/src/Svg.Skia/SKSvg.AnimationLayers.cs b/src/Svg.Skia/SKSvg.AnimationLayers.cs index 91a213039b..c39e32c01e 100644 --- a/src/Svg.Skia/SKSvg.AnimationLayers.cs +++ b/src/Svg.Skia/SKSvg.AnimationLayers.cs @@ -308,30 +308,50 @@ private bool TryDrawAnimationLayers(SkiaSharp.SKCanvas canvas) return false; } + SKPicture? staticLayerModel; + SKPicture? dynamicLayerModel; SkiaSharp.SKPicture? staticLayerPicture; SkiaSharp.SKPicture? dynamicLayerPicture; lock (Sync) { + staticLayerModel = _staticAnimationLayerModel; + dynamicLayerModel = _dynamicAnimationLayerModel; staticLayerPicture = _staticAnimationLayerPicture; dynamicLayerPicture = _dynamicAnimationLayerPicture; } - if (staticLayerPicture is null && dynamicLayerPicture is null) + if (staticLayerModel is null && + dynamicLayerModel is null && + staticLayerPicture is null && + dynamicLayerPicture is null) { return false; } - if (staticLayerPicture is { }) + DrawAnimationLayer(staticLayerModel, staticLayerPicture, canvas); + DrawAnimationLayer(dynamicLayerModel, dynamicLayerPicture, canvas); + + return true; + } + + private void DrawAnimationLayer(SKPicture? model, SkiaSharp.SKPicture? picture, SkiaSharp.SKCanvas canvas) + { + if (model is { } && ContainsNonScalingStroke(model)) { - canvas.DrawPicture(staticLayerPicture); + SkiaModel.Draw(model, canvas); + return; } - if (dynamicLayerPicture is { }) + if (picture is { }) { - canvas.DrawPicture(dynamicLayerPicture); + canvas.DrawPicture(picture); + return; } - return true; + if (model is { }) + { + SkiaModel.Draw(model, canvas); + } } private bool TryRefreshAnimationLayerEntries( diff --git a/tests/Svg.Skia.UnitTests/SKSvgTests.cs b/tests/Svg.Skia.UnitTests/SKSvgTests.cs index d1753fc797..5a4c249999 100644 --- a/tests/Svg.Skia.UnitTests/SKSvgTests.cs +++ b/tests/Svg.Skia.UnitTests/SKSvgTests.cs @@ -177,6 +177,31 @@ public void Draw_DownScaledVectorEffectNonScalingStroke_PreservesStrokeWidthAndR Assert.Equal(1, drawCount); } + [Fact] + public void Draw_AnimationLayerCachingWithNonScalingStroke_PreservesStrokeWidth() + { + var svg = new SKSvg(); + using var input = new MemoryStream(Encoding.UTF8.GetBytes(CreateAnimationLayerNonScalingStrokeSvgMarkup())); + using var _ = svg.Load(input); + using var bitmap = new SkiaSharp.SKBitmap(new SkiaSharp.SKImageInfo(50, 20, SkiaSharp.SKColorType.Rgba8888, SkiaSharp.SKAlphaType.Premul)); + using var canvas = new SkiaSharp.SKCanvas(bitmap); + + Assert.True(svg.UsesAnimationLayerCaching); + + canvas.Clear(SkiaSharp.SKColors.Transparent); + canvas.Scale(0.5f, 0.5f); + svg.Draw(canvas); + + using var skImage = SkiaSharp.SKImage.FromBitmap(bitmap); + using var data = skImage.Encode(SkiaSharp.SKEncodedImageFormat.Png, 100); + Assert.NotNull(data); + + using var output = new MemoryStream(data.ToArray()); + using var image = Image.Load(output); + var opaquePixels = CountOpaquePixelsInColumn(image, 25); + Assert.InRange(opaquePixels, 3, 6); + } + private static string CreateNonScalingStrokeSvgMarkup(string vectorEffect) { return $$""" @@ -186,6 +211,20 @@ private static string CreateNonScalingStrokeSvgMarkup(string vectorEffect) """; } + private static string CreateAnimationLayerNonScalingStrokeSvgMarkup() + { + return """ + + + + + + + + + """; + } + private static int CountOpaquePixelsInColumn(Image image, int x) { var count = 0;