diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c438f96..ec245bc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -8,6 +8,7 @@ on: - 'ACadSharp.Image.Tests/**' - 'Directory.Packages.props' - 'Directory.Build.props' + - 'sixlabors.lic' - '*.sln' - '.github/workflows/ci.yml' push: @@ -19,6 +20,7 @@ on: - 'ACadSharp.Image.Tests/**' - 'Directory.Packages.props' - 'Directory.Build.props' + - 'sixlabors.lic' - '*.sln' - '.github/workflows/ci.yml' diff --git a/ACadSharp.Image.Tests/Baselines/6-57-1119.model.01.png b/ACadSharp.Image.Tests/Baselines/6-57-1119.model.01.png index 391bb12..3528ef1 100644 Binary files a/ACadSharp.Image.Tests/Baselines/6-57-1119.model.01.png and b/ACadSharp.Image.Tests/Baselines/6-57-1119.model.01.png differ diff --git a/ACadSharp.Image.Tests/Baselines/HSK80AHCP16190M_BMG.model.01.png b/ACadSharp.Image.Tests/Baselines/HSK80AHCP16190M_BMG.model.01.png index 4b1aa3c..209c4b9 100644 Binary files a/ACadSharp.Image.Tests/Baselines/HSK80AHCP16190M_BMG.model.01.png and b/ACadSharp.Image.Tests/Baselines/HSK80AHCP16190M_BMG.model.01.png differ diff --git a/ACadSharp.Image.Tests/Baselines/Subaru-Logo-Vector-Free-Wrap.model.01.png b/ACadSharp.Image.Tests/Baselines/Subaru-Logo-Vector-Free-Wrap.model.01.png index 0dbb6e6..f59c544 100644 Binary files a/ACadSharp.Image.Tests/Baselines/Subaru-Logo-Vector-Free-Wrap.model.01.png and b/ACadSharp.Image.Tests/Baselines/Subaru-Logo-Vector-Free-Wrap.model.01.png differ diff --git a/ACadSharp.Image.Tests/Baselines/entities.model.01.png b/ACadSharp.Image.Tests/Baselines/entities.model.01.png index 261fb3c..bd05f11 100644 Binary files a/ACadSharp.Image.Tests/Baselines/entities.model.01.png and b/ACadSharp.Image.Tests/Baselines/entities.model.01.png differ diff --git a/ACadSharp.Image.Tests/Baselines/features.model.01.png b/ACadSharp.Image.Tests/Baselines/features.model.01.png index 0ccbf27..9bb4424 100644 Binary files a/ACadSharp.Image.Tests/Baselines/features.model.01.png and b/ACadSharp.Image.Tests/Baselines/features.model.01.png differ diff --git a/ACadSharp.Image.Tests/Baselines/features.model.01.svg b/ACadSharp.Image.Tests/Baselines/features.model.01.svg index 55bc979..29840f8 100644 --- a/ACadSharp.Image.Tests/Baselines/features.model.01.svg +++ b/ACadSharp.Image.Tests/Baselines/features.model.01.svg @@ -4,11 +4,11 @@ - + - + - + diff --git a/ACadSharp.Image.Tests/Baselines/fidelity.model.01.png b/ACadSharp.Image.Tests/Baselines/fidelity.model.01.png index 8bad877..3633602 100644 Binary files a/ACadSharp.Image.Tests/Baselines/fidelity.model.01.png and b/ACadSharp.Image.Tests/Baselines/fidelity.model.01.png differ diff --git a/ACadSharp.Image.Tests/Baselines/viewport-sheet.paper.01.png b/ACadSharp.Image.Tests/Baselines/viewport-sheet.paper.01.png index d0dc229..52aab31 100644 Binary files a/ACadSharp.Image.Tests/Baselines/viewport-sheet.paper.01.png and b/ACadSharp.Image.Tests/Baselines/viewport-sheet.paper.01.png differ diff --git a/ACadSharp.Image.Tests/ColorResolutionTests.cs b/ACadSharp.Image.Tests/ColorResolutionTests.cs index e738d5e..246dce8 100644 --- a/ACadSharp.Image.Tests/ColorResolutionTests.cs +++ b/ACadSharp.Image.Tests/ColorResolutionTests.cs @@ -1,5 +1,6 @@ using ACadSharp.Image.Extensions; using SixLabors.ImageSharp; +using SixLabors.ImageSharp.PixelFormats; namespace ACadSharp.Image.Tests; @@ -11,7 +12,7 @@ public sealed class ColorResolutionTests public void Index7IsBlackOnLightAndWhiteOnDarkBackgrounds() { ImageConfiguration light = new(); - ImageConfiguration dark = new() { BackgroundColor = Color.FromRgb(20, 20, 40) }; + ImageConfiguration dark = new() { BackgroundColor = Color.FromPixel(new Rgba32(20, 20, 40)) }; ImageConfiguration transparent = new() { BackgroundColor = Color.Transparent }; Assert.Equal(Color.Black, light.ResolveForegroundColor()); @@ -26,6 +27,6 @@ public void ExplicitForegroundWins() Assert.Equal(Color.Yellow, configuration.ResolveForegroundColor()); Assert.Equal(Color.Yellow, new ACadSharp.Color(7).ToImageColor(configuration.ResolveForegroundColor())); - Assert.Equal(Color.FromRgb(255, 0, 0), new ACadSharp.Color(1).ToImageColor(configuration.ResolveForegroundColor())); + Assert.Equal(Color.FromPixel(new Rgba32(255, 0, 0)), new ACadSharp.Color(1).ToImageColor(configuration.ResolveForegroundColor())); } } diff --git a/ACadSharp.Image.Tests/EntityRenderDispatcherTests.cs b/ACadSharp.Image.Tests/EntityRenderDispatcherTests.cs index 2fe755a..70d3e9b 100644 --- a/ACadSharp.Image.Tests/EntityRenderDispatcherTests.cs +++ b/ACadSharp.Image.Tests/EntityRenderDispatcherTests.cs @@ -8,6 +8,7 @@ using ACadSharp.Objects; using ACadSharp.Tables; using CSMath; +using SixLabors.ImageSharp.PixelFormats; namespace ACadSharp.Image.Tests; @@ -19,7 +20,7 @@ internal static ImageRenderContext CreateContext(IDrawingSurface surface, ImageC return new ImageRenderContext(surface, configuration, layout, 100, 100, 0, 0, 1d, 0, 0, singlePrecision: false, lineTypeScale: 1d); } - // ACadSharp.CadObject.Handle has an internal setter in ACadSharp 3.7.1, so tests + // ACadSharp.CadObject.Handle has an internal setter in ACadSharp 3.8.0, so tests // that need a deterministic handle assign it via reflection instead. private static T WithHandle(T entity, ulong handle) where T : CadObject @@ -66,10 +67,10 @@ public void NestedEntityOnLayerZeroInheritsInsertLayer() Assert.Equal(0UL, surface.Entities[1].Handle); Assert.Equal("Hardware", surface.Entities[2].LayerName); Assert.Equal(0, surface.Depth); - Assert.Equal(SixLabors.ImageSharp.Color.FromRgb(255, 0, 0), surface.Layers[1].Color); + Assert.Equal(SixLabors.ImageSharp.Color.FromPixel(new Rgba32(255, 0, 0)), surface.Layers[1].Color); // The layer-0 line is ByLayer, so it is drawn with the effective (insert) layer's colour, not layer 0's. - Assert.Equal(SixLabors.ImageSharp.Color.FromRgb(255, 0, 0), surface.Styles[0].StrokeColor); + Assert.Equal(SixLabors.ImageSharp.Color.FromPixel(new Rgba32(255, 0, 0)), surface.Styles[0].StrokeColor); } [Fact] @@ -107,19 +108,19 @@ public void NestedEntitiesResolveByLayerAgainstTheEffectiveLayerAndByBlockAgains // Styles are recorded per drawing call: (a), (b), (c); the insert itself draws nothing. Assert.Equal(3, surface.Styles.Count); ImageStyle layerZero = surface.Styles[0]; - Assert.Equal(SixLabors.ImageSharp.Color.FromRgb(255, 0, 0), layerZero.StrokeColor); + Assert.Equal(SixLabors.ImageSharp.Color.FromPixel(new Rgba32(255, 0, 0)), layerZero.StrokeColor); Assert.Equal(configuration.GetLineWeightPixels(LineWeightType.W50), layerZero.StrokeWidth); // LTSCALE 2 (header, reached through the insert since the clone has no document) x CELTSCALE 1.5 (insert) x 1. Assert.NotNull(layerZero.DashPattern); Assert.Equal([3f, 3f], layerZero.DashPattern); ImageStyle byBlock = surface.Styles[1]; - Assert.Equal(SixLabors.ImageSharp.Color.FromRgb(0, 255, 0), byBlock.StrokeColor); + Assert.Equal(SixLabors.ImageSharp.Color.FromPixel(new Rgba32(0, 255, 0)), byBlock.StrokeColor); Assert.Equal(configuration.GetLineWeightPixels(LineWeightType.W200), byBlock.StrokeWidth); Assert.Null(byBlock.DashPattern); ImageStyle own = surface.Styles[2]; - Assert.Equal(SixLabors.ImageSharp.Color.FromRgb(0, 0, 255), own.StrokeColor); + Assert.Equal(SixLabors.ImageSharp.Color.FromPixel(new Rgba32(0, 0, 255)), own.StrokeColor); Assert.Equal(configuration.GetLineWeightPixels(LineWeightType.W100), own.StrokeWidth); } @@ -162,7 +163,7 @@ public void LayerInfoCarriesLayerColourAndWidth() LayerRenderInfo info = Assert.Single(surface.Layers); Assert.Equal("Red", info.LayerName); - Assert.Equal(SixLabors.ImageSharp.Color.FromRgb(255, 0, 0), info.Color); + Assert.Equal(SixLabors.ImageSharp.Color.FromPixel(new Rgba32(255, 0, 0)), info.Color); Assert.Equal(configuration.GetLineWeightPixels(LineWeightType.W50), info.StrokeWidth); } @@ -170,7 +171,7 @@ public void LayerInfoCarriesLayerColourAndWidth() public void LayerColourIndexSevenFollowsTheBackground() { RecordingDrawingSurface surface = new(); - ImageConfiguration configuration = new() { BackgroundColor = SixLabors.ImageSharp.Color.FromRgb(20, 20, 40) }; + ImageConfiguration configuration = new() { BackgroundColor = SixLabors.ImageSharp.Color.FromPixel(new Rgba32(20, 20, 40)) }; EntityRenderDispatcher dispatcher = new(configuration); Line line = new(new XYZ(0, 0, 0), new XYZ(10, 0, 0)) { Layer = new Layer("Ink") { Color = new ACadSharp.Color(7) } }; @@ -964,7 +965,7 @@ public void ConstantAttributeDefinitionsAreStillDrawn() BlockRecord block = new("CONST"); block.Entities.Add(new AttributeDefinition { Tag = "MAKER", Value = "ACME", InsertPoint = new XYZ(1, 1, 0), Height = 2, Flags = AttributeFlags.Constant }); Insert insert = new(block) { InsertPoint = new XYZ(10, 0, 0) }; - // Insert has no parameterless constructor and Block has no public setter in ACadSharp 3.7.1 (verified by + // Insert has no parameterless constructor and Block has no public setter in ACadSharp 3.8.0 (verified by // probe), so an insert with no ATTRIB is built via Insert(BlockRecord) and then Attributes.Clear(), reproducing // a file where a constant attribute was never persisted as an ATTRIB. Without the Clear(), Insert(BlockRecord) // would already have created an AttributeEntity for the constant ATTDEF, and the assertion below would pass @@ -1258,8 +1259,9 @@ public void LeaderArrowInsideAScaledInsertScalesWithIt() Assert.Contains(arrow, p => Math.Abs(p.X - 11) < 1e-9 && Math.Abs(p.Y - 96) < 1e-9); Assert.Equal([new SurfacePoint(5, 95), new SurfacePoint(25, 95)], Assert.Single(surface.Polylines)); - // Insert.Explode()'s clone shares the leader's vertex list (a Leader.Clone() quirk like MLine.Clone()'s), so - // ApplyTransform would otherwise leave the block's own LEADER holding world coordinates after this call. + // Insert.Explode()'s clone shares the leader's vertex list (a Leader.Clone() quirk; MLine.Clone() shares its + // vertices' segment lists instead), so ApplyTransform would otherwise leave the block's own LEADER holding + // world coordinates after this call. Assert.Equal([new XYZ(0, 0, 0), new XYZ(10, 0, 0)], leader.Vertices); } @@ -1360,6 +1362,12 @@ private static MLine.Vertex VertexAt(double x, double y, params double[][] param return vertex; } + private static void AssertMLineSegmentsIntact(MLine mline) + { + Assert.Equal(2, mline.Vertices.Count); + Assert.All(mline.Vertices, vertex => Assert.Equal(2, vertex.Segments.Count)); + } + [Fact] public void MLineDrawsOnePolylinePerStyleElementAtTheStoredOffsets() { @@ -1576,7 +1584,7 @@ public void AnMLineWithAreaFillCutsNotifiesThatFillCutsAreNotDrawn() } [Fact] - public void MLineInsideABlockIsDrawnThroughTheInsertAndKeepsItsVertices() + public void MLineInsideABlockIsDrawnThroughTheInsertAndKeepsItsVerticesAndSegments() { RecordingDrawingSurface surface = new(); ImageConfiguration configuration = new(); @@ -1587,13 +1595,13 @@ public void MLineInsideABlockIsDrawnThroughTheInsertAndKeepsItsVertices() new EntityRenderDispatcher(configuration).Draw(CreateContext(surface, configuration), insert); - Assert.Equal(2, mline.Vertices.Count); + AssertMLineSegmentsIntact(mline); Assert.Equal(2, surface.Polylines.Count); Assert.Equal([new SurfacePoint(5, 79.5), new SurfacePoint(15, 79.5)], surface.Polylines[0]); } [Fact] - public void MLineNestedTwoBlocksDeepIsDrawnThroughTheComposedInsertsAndKeepsItsVertices() + public void MLineNestedTwoBlocksDeepIsDrawnThroughTheComposedInsertsAndKeepsItsVerticesAndSegments() { RecordingDrawingSurface surface = new(); ImageConfiguration configuration = new(); @@ -1607,10 +1615,10 @@ public void MLineNestedTwoBlocksDeepIsDrawnThroughTheComposedInsertsAndKeepsItsV new EntityRenderDispatcher(configuration).Draw(CreateContext(surface, configuration), outerInsert); - // ACadSharp 3.7.1's Insert.Clone() deep-clones its block, so exploding outerInsert clones (and empties the - // shared vertex list of) an MLINE that is not even a direct child of OUTER's block; without healing every - // MLINE reachable through the block tree, the original loses its vertices here too. - Assert.Equal(2, mline.Vertices.Count); + // ACadSharp 3.8.0's Insert.Clone() deep-clones its block, so exploding outerInsert clones (and empties the + // shared segment lists of) an MLINE that is not even a direct child of OUTER's block; without healing every + // MLINE reachable through the block tree, the original loses its segments here too. + AssertMLineSegmentsIntact(mline); Assert.Equal(2, surface.Polylines.Count); // Composed translation (5,20) + (2,3) = (7,23), both inserts translation-only. Element 0 (offset +0.5): // world y 0.5 -> composed 23.5 -> surface 100-23.5 = 76.5. Element 1 (offset -0.5): world y -0.5 -> composed @@ -1648,7 +1656,6 @@ public void RectangularWipeoutFillsTheBackgroundColourOpaquely() RecordingDrawingSurface surface = new(); ImageConfiguration configuration = new() { BackgroundColor = SixLabors.ImageSharp.Color.White }; Wipeout wipeout = UnitWipeout(); - wipeout.ClipType = ClipType.Rectangular; wipeout.ClipBoundaryVertices.AddRange([new XY(-0.5, -0.5), new XY(0.5, 0.5)]); wipeout.Transparency = new Transparency(50); @@ -1669,7 +1676,6 @@ public void PolygonalWipeoutUsesItsVertices() RecordingDrawingSurface surface = new(); ImageConfiguration configuration = new(); Wipeout wipeout = UnitWipeout(); - wipeout.ClipType = ClipType.Polygonal; wipeout.ClipBoundaryVertices.AddRange([new XY(-0.5, -0.5), new XY(0.5, -0.5), new XY(0, 0.5)]); new EntityRenderDispatcher(configuration).Draw(CreateContext(surface, configuration), wipeout); @@ -1767,7 +1773,7 @@ public void AWipeoutInsideAnInsertIsMappedFromTheOriginalSoItsUAndVStayDirection IReadOnlyList polygon = Assert.Single(surface.Polygons); // UnitWipeout covers x in [10,15]; the insert translates the insertion point by (50,0,0) to [60,65]. A U - // vector contaminated by that translation (as ACadSharp 3.7.1's Wipeout.ApplyTransform would produce) would + // vector contaminated by that translation (as ACadSharp 3.8.0's Wipeout.ApplyTransform would produce) would // stretch it to [60,115] instead. Assert.Equal(60d, polygon.Min(p => p.X), 6); Assert.Equal(65d, polygon.Max(p => p.X), 6); @@ -1900,7 +1906,7 @@ public void MLineWithOneVertexInsideABlockWarnsThatItHasNoVertices() public void WipeoutOnATranslucentBackgroundIsSkippedWithAWarning() { RecordingDrawingSurface surface = new(); - ImageConfiguration configuration = new() { BackgroundColor = SixLabors.ImageSharp.Color.FromRgba(255, 255, 255, 128) }; + ImageConfiguration configuration = new() { BackgroundColor = SixLabors.ImageSharp.Color.FromPixel(new Rgba32(255, 255, 255, 128)) }; List notifications = new(); configuration.OnNotification += (_, e) => notifications.Add(e); @@ -2012,7 +2018,7 @@ public void ABlockThatReferencesItselfIsSkippedWithAWarningInsteadOfOverflowing( document.BlockRecords.Add(inner); outer.Entities.Add(new Line(new XYZ(0, 0, 0), new XYZ(1, 0, 0))); // Constructing an Insert of a block overflows the stack once that block's graph already contains a cycle - // (ACadSharp 3.7.1's Insert(BlockRecord) constructor itself recurses through the block). So the insert under + // (ACadSharp 3.8.0's Insert(BlockRecord) constructor itself recurses through the block). So the insert under // test is built, and added to the document, while outer's graph is still acyclic; the second half of the // cycle (inner's own Insert(outer)) is wired up afterwards, closing the cycle only in the two blocks' // Entities collections, never inside another Insert constructor call. @@ -2455,7 +2461,7 @@ public void DrawingAnArrowBlockLeavesAnMLineInsideItIntact() arrow.Entities.Add(mline); document.BlockRecords.Add(arrow); BlockRecord note = new("NOTE"); - // ACadSharp 3.7.1's Insert(BlockRecord) constructor clones a document-owned block's entities, and cloning a + // ACadSharp 3.8.0's Insert(BlockRecord) constructor clones a document-owned block's entities, and cloning a // LEADER clones its dimension style and with it that style's arrowhead block, so building the insert after // the leader would empty this MLINE before the renderer ever saw the drawing. The insert is therefore built // while NOTE is still empty, the same construction-order workaround the cycle tests use. @@ -2468,13 +2474,13 @@ public void DrawingAnArrowBlockLeavesAnMLineInsideItIntact() }); document.BlockRecords.Add(note); document.Entities.Add(insert); - Assert.Equal(2, mline.Vertices.Count); + AssertMLineSegmentsIntact(mline); new EntityRenderDispatcher(configuration).Draw(CreateContext(surface, configuration), insert); // The caller's own MLINE must survive the render, and it must have been drawn from its two vertices: the // leader's path is one polyline and the MLINE's two style elements are the other two. - Assert.Equal(2, mline.Vertices.Count); + AssertMLineSegmentsIntact(mline); Assert.Equal(3, surface.Polylines.Count); } @@ -2499,14 +2505,14 @@ public void DrawingATopLevelLeadersArrowBlockLeavesAnMLineInsideItIntact() Style = new DimensionStyle("A") { ArrowSize = 2, ScaleFactor = 1, LeaderArrow = arrow }, }; document.Entities.Add(leader); - Assert.Equal(2, mline.Vertices.Count); + AssertMLineSegmentsIntact(mline); new EntityRenderDispatcher(configuration).Draw(CreateContext(surface, configuration), leader); // Nothing exploded this leader, so the only thing standing between ACadSharp's Insert(BlockRecord) // constructor and the caller's MLINE is the snapshot DrawArrowBlock takes before building its transient // insert. - Assert.Equal(2, mline.Vertices.Count); + AssertMLineSegmentsIntact(mline); Assert.Equal(3, surface.Polylines.Count); } @@ -2536,14 +2542,14 @@ public void DrawingABlockWhoseLeaderStyleCarriesADimArrow1LeavesAnMLineInsideItI }); document.BlockRecords.Add(note); document.Entities.Add(insert); - Assert.Equal(2, mline.Vertices.Count); + AssertMLineSegmentsIntact(mline); new EntityRenderDispatcher(configuration).Draw(CreateContext(surface, configuration), insert); // DIMBLK1 is never drawn by this renderer, but DimensionStyle.Clone() deep-clones all four of its // block-valued properties, so exploding the insert reaches this MLINE through the leader's style exactly as // it does through LeaderArrow. The snapshot has to follow every one of those edges, not just the drawn one. - Assert.Equal(2, mline.Vertices.Count); + AssertMLineSegmentsIntact(mline); } [Fact] @@ -2570,13 +2576,13 @@ public void DrawingABlockWhoseDimensionStyleCarriesAnArrowBlockLeavesAnMLineInsi }); document.BlockRecords.Add(note); document.Entities.Add(insert); - Assert.Equal(2, mline.Vertices.Count); + AssertMLineSegmentsIntact(mline); new EntityRenderDispatcher(configuration).Draw(CreateContext(surface, configuration), insert); // A DIMENSION carries a DimensionStyle exactly as a LEADER does, and cloning it clones the style's arrow // blocks the same way. A block holding only a dimension must therefore still be scanned and snapshotted. - Assert.Equal(2, mline.Vertices.Count); + AssertMLineSegmentsIntact(mline); } [Fact] @@ -2604,14 +2610,14 @@ public void DrawingABlockWhoseDimensionPictureHoldsAnMLineLeavesItIntact() }); document.BlockRecords.Add(note); document.Entities.Add(insert); - Assert.Equal(2, mline.Vertices.Count); + AssertMLineSegmentsIntact(mline); new EntityRenderDispatcher(configuration).Draw(CreateContext(surface, configuration), insert); // Dimension.Clone() deep-clones the picture block that holds the dimension's own generated geometry, not // just the arrowhead blocks its style points at, so that block is a fifth edge the snapshot has to follow. // Unlike the arrowheads it is on the ordinary render path: DrawDimension draws through it. - Assert.Equal(2, mline.Vertices.Count); + AssertMLineSegmentsIntact(mline); } [Fact] @@ -2670,14 +2676,14 @@ public void DrawingATopLevelDimensionLeavesAnMLineInsideItsArrowBlockIntact() Style = new DimensionStyle("A") { ArrowSize = 2, ScaleFactor = 1, SeparateArrowBlocks = true, DimArrow1 = arrow, DimArrow2 = arrow }, }; document.Entities.Add(dimension); - Assert.Equal(2, mline.Vertices.Count); + AssertMLineSegmentsIntact(mline); new EntityRenderDispatcher(configuration).Draw(CreateContext(surface, configuration), dimension); // Nothing exploded this dimension: DrawDimension calls UpdateBlock() to generate the picture, and that // builds Inserts of the caller's own DimArrow1/DimArrow2 blocks, whose constructor empties an MLINE inside // them. The snapshot around that call is the only thing standing between it and the caller's document. - Assert.Equal(2, mline.Vertices.Count); + AssertMLineSegmentsIntact(mline); } [Fact] @@ -2733,13 +2739,13 @@ public void RenderingADimensionThroughTheExporterLeavesAnMLineInsideItsArrowBloc document.Entities.Add(dimension); ImageExporter exporter = new(); exporter.AddModelSpace(document); - Assert.Equal(2, mline.Vertices.Count); + AssertMLineSegmentsIntact(mline); foreach (RenderedPage page in exporter.Render()) { page.Dispose(); } - Assert.Equal(2, mline.Vertices.Count); + AssertMLineSegmentsIntact(mline); } } diff --git a/ACadSharp.Image.Tests/FeatureGoldenTests.cs b/ACadSharp.Image.Tests/FeatureGoldenTests.cs index 3cf1c04..7e1d793 100644 --- a/ACadSharp.Image.Tests/FeatureGoldenTests.cs +++ b/ACadSharp.Image.Tests/FeatureGoldenTests.cs @@ -41,7 +41,9 @@ public void FeatureSvgMatchesGoldenAndContainsEveryPrimitive() exporter.Configuration.OnNotification += (_, e) => notifications.Add(e); RenderedSvgPage page = Assert.IsType(Assert.Single(exporter.Render(ImageExportFormat.Svg))); - GoldenAssert.Svg("features.model.01", page.Content); + // ACadSharp's pattern-hatch intersections can land on either side of a two-decimal half-way value depending on + // platform floating point, so allow one printed unit of drift for this golden. + GoldenAssert.Svg("features.model.01", page.Content, 0.011); Assert.DoesNotContain(notifications, n => n.NotificationType is NotificationType.Warning or NotificationType.NotImplemented); XDocument document = XDocument.Parse(page.Content); diff --git a/ACadSharp.Image.Tests/GoldenAssert.cs b/ACadSharp.Image.Tests/GoldenAssert.cs index 2ab4169..a7f4fd5 100644 --- a/ACadSharp.Image.Tests/GoldenAssert.cs +++ b/ACadSharp.Image.Tests/GoldenAssert.cs @@ -1,3 +1,5 @@ +using System.Globalization; +using System.Text.RegularExpressions; using ACadSharp.Image.Rendering; using SixLabors.ImageSharp; using SixLabors.ImageSharp.Formats.Png; @@ -13,6 +15,8 @@ internal static class GoldenAssert { public static bool Updating => Environment.GetEnvironmentVariable("ACADSHARP_IMAGE_UPDATE_BASELINES") == "1"; + private static readonly Regex SvgNumber = new(@"[-+]?(?:\d+\.\d+|\d+)", RegexOptions.CultureInvariant); + private static string BaselineDirectory { get @@ -37,7 +41,13 @@ public static void Png(string baseName, Image actual) SampleParityTests.AssertPixelsEqual(expected, actual, path); } - public static void Svg(string baseName, string actual) + /// Baseline file name without the .svg extension. + /// The rendered SVG. + /// + /// Absolute tolerance for numeric tokens. Zero keeps the comparison exact; a small value absorbs platform floating-point + /// drift in values that are otherwise printed at the same precision. + /// + public static void Svg(string baseName, string actual, double numberTolerance = 0d) { string path = Path.Combine(BaselineDirectory, baseName + ".svg"); string normalized = actual.Replace("\r\n", "\n"); @@ -50,7 +60,59 @@ public static void Svg(string baseName, string actual) } Assert.True(File.Exists(path), $"Missing golden {path}. Run with ACADSHARP_IMAGE_UPDATE_BASELINES=1 to create it."); - Assert.Equal(File.ReadAllText(path).Replace("\r\n", "\n"), normalized); + string expected = File.ReadAllText(path).Replace("\r\n", "\n"); + if (numberTolerance <= 0d) + { + Assert.Equal(expected, normalized); + return; + } + + AssertSvgNumbersEqual(expected, normalized, path, numberTolerance); + } + + private static void AssertSvgNumbersEqual(string expected, string actual, string path, double tolerance) + { + List<(bool IsNumber, string Text)> expectedTokens = TokenizeSvg(expected); + List<(bool IsNumber, string Text)> actualTokens = TokenizeSvg(actual); + Assert.Equal(expectedTokens.Count, actualTokens.Count); + for (int i = 0; i < expectedTokens.Count; i++) + { + Assert.Equal(expectedTokens[i].IsNumber, actualTokens[i].IsNumber); + if (!expectedTokens[i].IsNumber) + { + Assert.Equal(expectedTokens[i].Text, actualTokens[i].Text); + continue; + } + + double expectedNumber = double.Parse(expectedTokens[i].Text, CultureInfo.InvariantCulture); + double actualNumber = double.Parse(actualTokens[i].Text, CultureInfo.InvariantCulture); + Assert.True( + Math.Abs(expectedNumber - actualNumber) <= tolerance, + $"SVG number differs in {path} at token {i}: expected {expectedNumber.ToString(CultureInfo.InvariantCulture)}, actual {actualNumber.ToString(CultureInfo.InvariantCulture)}."); + } + } + + private static List<(bool IsNumber, string Text)> TokenizeSvg(string svg) + { + List<(bool IsNumber, string Text)> tokens = []; + int position = 0; + foreach (Match match in SvgNumber.Matches(svg)) + { + if (match.Index > position) + { + tokens.Add((false, svg[position..match.Index])); + } + + tokens.Add((true, match.Value)); + position = match.Index + match.Length; + } + + if (position < svg.Length) + { + tokens.Add((false, svg[position..])); + } + + return tokens; } /// diff --git a/ACadSharp.Image.Tests/ImageExporterTests.cs b/ACadSharp.Image.Tests/ImageExporterTests.cs index 8f81587..5c1d45b 100644 --- a/ACadSharp.Image.Tests/ImageExporterTests.cs +++ b/ACadSharp.Image.Tests/ImageExporterTests.cs @@ -118,7 +118,7 @@ public void RenderSplineBlockDoesNotReportNotImplemented() Assert.NotNull(page.Canvas); Assert.DoesNotContain(notifications, n => n.NotificationType == NotificationType.NotImplemented && n.Message.Contains("Spline", StringComparison.OrdinalIgnoreCase)); - // Silence is not enough: the spline must actually reach the surface. ACadSharp 3.7.1's UpdateFromFitPoints + // Silence is not enough: the spline must actually reach the surface. ACadSharp 3.8.0's UpdateFromFitPoints // produces no control points, so this one is drawn through its fit points, with a warning saying so. RecordingDrawingSurface surface = new(); new ImagePageRenderer(exporter.Configuration).RenderTo(surface, exporter.Pages[0]); @@ -129,7 +129,7 @@ public void RenderSplineBlockDoesNotReportNotImplemented() [Fact] public void RenderClosedPeriodicSplineDoesNotDrawSpokeToOrigin() { - using Image canvas = new(100, 100, SixLabors.ImageSharp.Color.White); + using Image canvas = new(100, 100, SixLabors.ImageSharp.Color.White.ToPixel()); ImageConfiguration configuration = new() { Width = 100, diff --git a/ACadSharp.Image.Tests/ImagePageRendererTests.cs b/ACadSharp.Image.Tests/ImagePageRendererTests.cs index e982d08..9f3500d 100644 --- a/ACadSharp.Image.Tests/ImagePageRendererTests.cs +++ b/ACadSharp.Image.Tests/ImagePageRendererTests.cs @@ -243,7 +243,6 @@ public void ModelSpaceWipeoutMappedRegionDecidesViewportInclusionNotTheRawVertex VVector = new XYZ(-5, 0, 0), Size = new XY(1, 1), ClippingState = true, - ClipType = ClipType.Rectangular, }; wipeout.ClipBoundaryVertices.AddRange([new XY(-0.5, -0.5), new XY(0.5, 0.5)]); document.Entities.Add(wipeout); @@ -309,7 +308,6 @@ public void InvertedClipModelSpaceWipeoutInAViewportMasksTheSameAsThePageLevelDr VVector = new XYZ(0, 5, 0), Size = new XY(1, 1), ClippingState = true, - ClipType = ClipType.Rectangular, ClipMode = ClipMode.Inside, }; // Strictly inside the frame, not equal to it (this wipeout's own default frame is (-0.5,-0.5)..(0.5,0.5)), @@ -461,6 +459,7 @@ public void AddingAnMLineToAPreviouslyCleanBlockIsPickedUpByTheNextRenderOnTheSa renderer.RenderTo(surface, page); Assert.Equal(2, mline.Vertices.Count); + Assert.All(mline.Vertices, vertex => Assert.Equal(2, vertex.Segments.Count)); Assert.Equal(2, surface.Polylines.Count); } @@ -470,11 +469,11 @@ public void AddingALeaderToAPreviouslyCleanBlockIsPickedUpByTheNextRenderOnTheSa // Same staleness hazard as the MLINE case above, for a LEADER: Insert.Clone() shares a LEADER's vertex list // with its source too (see EntityRenderDispatcher remarks on DrawBlockContents), so it must be found by the // same cached subtree scan and must not be missed once it is added after the block was first seen clean. - // The insert carries a non-zero placement deliberately: a LEADER's shared list is not emptied by Clone() the - // way an MLINE's is, it is overwritten in place by Explode()'s ApplyTransform, so under an identity - // placement that in-place write puts back the very same coordinates and the assertions below would hold - // whether or not the stale-cache bug is present. A real translation makes the un-healed path write world - // coordinates into the shared list instead, so both assertions genuinely discriminate. + // The insert carries a non-zero placement deliberately: a LEADER's shared vertex list is not emptied by + // Clone() the way an MLINE's segment lists are; it is overwritten in place by Explode()'s ApplyTransform, so + // under an identity placement that in-place write puts back the very same coordinates and the assertions + // below would hold whether or not the stale-cache bug is present. A real translation makes the un-healed path + // write world coordinates into the shared list instead, so both assertions genuinely discriminate. BlockRecord wall = new("WALLL"); wall.Entities.Add(new Line(new XYZ(0, 0, 0), new XYZ(10, 0, 0))); BlockRecord plan = new("PLANL"); diff --git a/ACadSharp.Image.Tests/ImagePageTests.cs b/ACadSharp.Image.Tests/ImagePageTests.cs index 7d266b8..9d75970 100644 --- a/ACadSharp.Image.Tests/ImagePageTests.cs +++ b/ACadSharp.Image.Tests/ImagePageTests.cs @@ -88,7 +88,6 @@ public void FrameUsesTheMappedWipeoutRegionNotTheRawPixelVertices() VVector = new XYZ(-5, 0, 0), Size = new XY(1, 1), ClippingState = true, - ClipType = ClipType.Rectangular, }; wipeout.ClipBoundaryVertices.AddRange([new XY(-0.5, -0.5), new XY(0.5, 0.5)]); ImagePage page = new(); @@ -165,7 +164,6 @@ public void TheFrameOfAnInvertedWipeoutIsItsWholeImageFootprint() VVector = new XYZ(0, 10, 0), Size = new XY(1, 1), ClippingState = true, - ClipType = ClipType.Rectangular, ClipMode = ClipMode.Inside, }; wipeout.ClipBoundaryVertices.Add(new XY(-0.25, -0.25)); diff --git a/ACadSharp.Image.Tests/RasterDrawingSurfaceTests.cs b/ACadSharp.Image.Tests/RasterDrawingSurfaceTests.cs index c2946c6..58facfa 100644 --- a/ACadSharp.Image.Tests/RasterDrawingSurfaceTests.cs +++ b/ACadSharp.Image.Tests/RasterDrawingSurfaceTests.cs @@ -15,7 +15,7 @@ public sealed class RasterDrawingSurfaceTests [Fact] public void DrawLinePaintsPixelsAlongTheLine() { - using Image canvas = new(20, 20, ImageColor.White); + using Image canvas = new(20, 20, White); using RasterDrawingSurface surface = new(canvas, new ImageConfiguration(), ownsCanvas: false); surface.DrawLine(new ImageStyle(ImageColor.Black, 2f), new SurfacePoint(2, 10), new SurfacePoint(18, 10)); @@ -27,7 +27,7 @@ public void DrawLinePaintsPixelsAlongTheLine() [Fact] public void DrawPolylineClosedConnectsLastPointToFirst() { - using Image canvas = new(20, 20, ImageColor.White); + using Image canvas = new(20, 20, White); using RasterDrawingSurface surface = new(canvas, new ImageConfiguration(), ownsCanvas: false); SurfacePoint[] points = [new(2, 2), new(18, 2), new(18, 18)]; @@ -40,7 +40,7 @@ public void DrawPolylineClosedConnectsLastPointToFirst() [Fact] public void FillPathUsesEvenOddRuleForHoles() { - using Image canvas = new(40, 40, ImageColor.White); + using Image canvas = new(40, 40, White); using RasterDrawingSurface surface = new(canvas, new ImageConfiguration(), ownsCanvas: false); SurfacePoint[] outer = [new(2, 2), new(38, 2), new(38, 38), new(2, 38)]; SurfacePoint[] hole = [new(15, 15), new(25, 15), new(25, 25), new(15, 25)]; @@ -54,7 +54,7 @@ public void FillPathUsesEvenOddRuleForHoles() [Fact] public void OpacityBlendsWithBackground() { - using Image canvas = new(10, 10, ImageColor.White); + using Image canvas = new(10, 10, White); using RasterDrawingSurface surface = new(canvas, new ImageConfiguration(), ownsCanvas: false); surface.FillPolygon(new ImageStyle(ImageColor.Black, 1f, null, 0.5f), [new(0, 0), new(10, 0), new(10, 10), new(0, 10)]); @@ -68,7 +68,7 @@ public void OpacityBlendsWithBackground() [Fact] public void DashPatternLeavesGaps() { - using Image canvas = new(60, 10, ImageColor.White); + using Image canvas = new(60, 10, White); using RasterDrawingSurface surface = new(canvas, new ImageConfiguration(), ownsCanvas: false); // 10 px dash, 10 px gap. @@ -82,7 +82,7 @@ public void DashPatternLeavesGaps() [Fact] public void ViewportDrawsIntoChildAndCompositesAtBounds() { - using Image canvas = new(40, 40, ImageColor.White); + using Image canvas = new(40, 40, White); using RasterDrawingSurface surface = new(canvas, new ImageConfiguration(), ownsCanvas: false); ViewportSurface viewport = surface.BeginViewport(new SurfaceRect(20, 20, 10, 10)); @@ -141,7 +141,7 @@ public void PositiveBulgeBendsTowardPositiveYInSurfaceSpace() [Fact] public void ViewportFlipOriginIsTheExactHeightNotTheRoundedImageHeight() { - using Image canvas = new(20, 20, ImageColor.White); + using Image canvas = new(20, 20, White); using RasterDrawingSurface surface = new(canvas, new ImageConfiguration(), ownsCanvas: false); // A viewport 9.3 px tall gets a 10-row image; its content must still be placed against 9.3, not 10. @@ -160,7 +160,7 @@ public void ViewportFlipOriginIsTheExactHeightNotTheRoundedImageHeight() [Fact] public void ViewportFractionalPositionIsCarriedIntoTheChildOffsets() { - using Image canvas = new(20, 20, ImageColor.White); + using Image canvas = new(20, 20, White); using RasterDrawingSurface surface = new(canvas, new ImageConfiguration(), ownsCanvas: false); // Viewport at (3.6, 2.4): the image is pasted at (3, 2) and the child draws 0.6 / 0.4 px further in. @@ -240,7 +240,7 @@ public void RotatedTextAnchorsItsFirstLineAlongItsOwnUpAxis() /// Draws one text run of height 10 at the canvas centre and returns the canvas. private static Image DrawnText(string value, SurfaceTextBaseline baseline, double lineSpacingFactor, double rotation, float dpi = 96f, double widthScale = 1d) { - Image canvas = new(200, 200, ImageColor.White); + Image canvas = new(200, 200, White); using RasterDrawingSurface surface = new(canvas, new ImageConfiguration { Dpi = dpi }, ownsCanvas: false); surface.DrawText( new ImageStyle(ImageColor.Black, 1f), @@ -299,7 +299,7 @@ private static int[] InkColumnStarts(Image canvas) [Fact] public void FillsDropNonFiniteGeometryInsteadOfThrowing() { - using Image canvas = new(20, 20, ImageColor.White); + using Image canvas = new(20, 20, White); using RasterDrawingSurface surface = new(canvas, new ImageConfiguration(), ownsCanvas: false); ImageStyle style = new(ImageColor.Black, 1f); SurfacePoint[] ring = [new(2, 2), new(18, 2), new(double.PositiveInfinity, 18)]; @@ -440,7 +440,7 @@ public void DrawTextRejectsANonFiniteOrNonPositiveWidthScale(double widthScale) // Matches SvgDrawingSurface.DrawText's guard: Matrix3x2.CreateScale(0f, 1f, pivot) would otherwise collapse // the run to nothing silently instead of being rejected the same way a direct caller cannot reach through // TextRenderer (both Place overloads guard length >= 1e-12). - using Image canvas = new(20, 20, ImageColor.White); + using Image canvas = new(20, 20, White); ImageConfiguration configuration = new(); List notifications = new(); configuration.OnNotification += (_, e) => notifications.Add(e); diff --git a/ACadSharp.Image.Tests/RenderedPageTests.cs b/ACadSharp.Image.Tests/RenderedPageTests.cs index 0cb85d8..0d75dae 100644 --- a/ACadSharp.Image.Tests/RenderedPageTests.cs +++ b/ACadSharp.Image.Tests/RenderedPageTests.cs @@ -19,7 +19,7 @@ public sealed class RenderedPageTests [InlineData(ImageExportFormat.Webp, new byte[] { 0x52, 0x49, 0x46, 0x46 }, "WEBP")] public void RasterPagesEncodeInTheirFormat(ImageExportFormat format, byte[] signature, string? fourcc) { - using Image canvas = new(8, 8, SixLabors.ImageSharp.Color.White); + using Image canvas = new(8, 8, SixLabors.ImageSharp.Color.White.ToPixel()); using RenderedImagePage page = new("p", canvas, format, 80); using MemoryStream stream = new(); diff --git a/ACadSharp.Image.Tests/SplineRendererTests.cs b/ACadSharp.Image.Tests/SplineRendererTests.cs index 43251cf..2a1d5ba 100644 --- a/ACadSharp.Image.Tests/SplineRendererTests.cs +++ b/ACadSharp.Image.Tests/SplineRendererTests.cs @@ -129,7 +129,7 @@ public void InconsistentKnotSplineFallsBackToAcadSharpTessellation() dispatcher.Draw(Context(surface, configuration), spline); - // ACadSharp 3.7.1's TryPolygonalVertexes tessellates this malformed spline anyway (via its own + // ACadSharp 3.8.0's TryPolygonalVertexes tessellates this malformed spline anyway (via its own // fallback), so the renderer's last-resort path succeeds and draws a polyline instead of warning. Assert.Single(surface.Polylines); Assert.DoesNotContain(notifications, n => n.NotificationType == NotificationType.Warning); diff --git a/ACadSharp.Image.Tests/StyleResolutionTests.cs b/ACadSharp.Image.Tests/StyleResolutionTests.cs index 24121da..8ef8b97 100644 --- a/ACadSharp.Image.Tests/StyleResolutionTests.cs +++ b/ACadSharp.Image.Tests/StyleResolutionTests.cs @@ -12,7 +12,7 @@ public sealed class StyleResolutionTests [Fact] public void OpacityMapping() { - Assert.Equal(1f, ImageStyleResolver.ResolveOpacity(new Line(), 1f)); // ByLayer default -> opaque (Layer has no transparency in ACadSharp 3.7.1) + Assert.Equal(1f, ImageStyleResolver.ResolveOpacity(new Line(), 1f)); // ByLayer default -> opaque (Layer has no transparency in ACadSharp 3.8.0) Assert.Equal(0.3f, ImageStyleResolver.ResolveOpacity(new Line { Transparency = new Transparency(70) }, 1f), 3); Assert.Equal(0.5f, ImageStyleResolver.ResolveOpacity(new Line { Transparency = Transparency.ByBlock }, 0.5f)); Assert.Equal(1f, ImageStyleResolver.ResolveOpacity(new Line { Transparency = Transparency.ByBlock }, 1f)); diff --git a/ACadSharp.Image.Tests/SvgDrawingSurfaceTests.cs b/ACadSharp.Image.Tests/SvgDrawingSurfaceTests.cs index 1bf71c6..d12949d 100644 --- a/ACadSharp.Image.Tests/SvgDrawingSurfaceTests.cs +++ b/ACadSharp.Image.Tests/SvgDrawingSurfaceTests.cs @@ -1,6 +1,7 @@ using System.Xml.Linq; using ACadSharp.Image.Rendering; using ACadSharp.Image.Rendering.Svg; +using SixLabors.ImageSharp.PixelFormats; namespace ACadSharp.Image.Tests; @@ -20,7 +21,7 @@ private static SvgDrawingSurface CreateSurface(Action? confi private static EntityRenderInfo Entity(string layer, string type = "LINE", ulong handle = 0x10, ulong? parent = null, string? block = null) => new(layer, type, handle, parent, block); - private static LayerRenderInfo Layer(string name) => new(name, Color.FromRgb(255, 0, 0), 1f); + private static LayerRenderInfo Layer(string name) => new(name, Color.FromPixel(new Rgba32(255, 0, 0)), 1f); [Theory] [InlineData(null, 3)] @@ -81,7 +82,7 @@ public void BackgroundRectOnlyWhenOpaque() public void EntitiesAreGroupedByEffectiveLayerInOrderOfFirstAppearance() { using SvgDrawingSurface surface = CreateSurface(c => c.Svg.IdPrefix = "p-"); - ImageStyle style = new(Color.FromRgb(255, 0, 0), 1f); + ImageStyle style = new(Color.FromPixel(new Rgba32(255, 0, 0)), 1f); surface.BeginEntity(Entity("Walls", handle: 0x1F3), Layer("Walls")); surface.DrawLine(style, new SurfacePoint(0, 0), new SurfacePoint(10, 0)); @@ -115,7 +116,7 @@ public void EntitiesAreGroupedByEffectiveLayerInOrderOfFirstAppearance() public void NestedEntityCarriesParentAndBlock() { using SvgDrawingSurface surface = CreateSurface(); - ImageStyle style = new(Color.FromRgb(255, 0, 0), 1f); + ImageStyle style = new(Color.FromPixel(new Rgba32(255, 0, 0)), 1f); surface.BeginEntity(Entity("Doors", "INSERT", 0xA0), Layer("Doors")); surface.BeginEntity(Entity("Doors", "LINE", 0xA1, 0xA0, "DOOR"), Layer("Doors")); @@ -133,9 +134,9 @@ public void ZeroHandleOmitsDataHandle() { using SvgDrawingSurface surface = CreateSurface(); - // Exploded block contents are clones with handle 0 (ACadSharp 3.7.1); no data-handle is written for them. + // Exploded block contents are clones with handle 0 (ACadSharp 3.8.0); no data-handle is written for them. surface.BeginEntity(Entity("Doors", "LINE", 0, 0xA0, "DOOR"), Layer("Doors")); - surface.DrawLine(new ImageStyle(Color.FromRgb(255, 0, 0), 1f), new SurfacePoint(0, 0), new SurfacePoint(1, 1)); + surface.DrawLine(new ImageStyle(Color.FromPixel(new Rgba32(255, 0, 0)), 1f), new SurfacePoint(0, 0), new SurfacePoint(1, 1)); surface.EndEntity(); XElement line = Assert.Single(surface.ToDocument().Descendants(Ns + "line")); @@ -150,7 +151,7 @@ public void StyleOverridesAreWrittenOnlyWhenTheyDiffer() using SvgDrawingSurface surface = CreateSurface(c => c.Svg.EmitEntityAttributes = false); surface.BeginEntity(Entity("L"), Layer("L")); - surface.DrawLine(new ImageStyle(Color.FromRgb(0, 0, 255), 2.5f, [4f, 2f], 0.5f), new SurfacePoint(0, 0), new SurfacePoint(1, 1)); + surface.DrawLine(new ImageStyle(Color.FromPixel(new Rgba32(0, 0, 255)), 2.5f, [4f, 2f], 0.5f), new SurfacePoint(0, 0), new SurfacePoint(1, 1)); surface.EndEntity(); XElement line = Assert.Single(surface.ToDocument().Descendants(Ns + "line")); @@ -496,7 +497,7 @@ public void XmlTextCleanKeepsLegalWhitespaceAndSurrogatePairs() [Fact] public void TranslucentBackgroundKeepsItsAlphaAsFillOpacity() { - using SvgDrawingSurface surface = CreateSurface(c => c.BackgroundColor = Color.FromRgba(0, 0, 0, 128)); + using SvgDrawingSurface surface = CreateSurface(c => c.BackgroundColor = Color.FromPixel(new Rgba32(0, 0, 0, 128))); XElement rect = Assert.Single(surface.ToDocument().Descendants(Ns + "rect")); Assert.Equal("#000000", (string?)rect.Attribute("fill")); diff --git a/ACadSharp.Image.Tests/SvgTextLayoutTests.cs b/ACadSharp.Image.Tests/SvgTextLayoutTests.cs index 048a01a..1ae79f6 100644 --- a/ACadSharp.Image.Tests/SvgTextLayoutTests.cs +++ b/ACadSharp.Image.Tests/SvgTextLayoutTests.cs @@ -100,9 +100,7 @@ public void LineCountMatchesImageSharpLayout(string text, double width) } /// - /// Counts the lines ImageSharp lays the text out on, independently of the wrapper: consecutive baselines are one - /// em apart at line spacing 1, and no glyph's top strays a whole em from its own line's top, so the distinct - /// floor(top / em) values are the lines. + /// Counts the lines ImageSharp lays the text out on, independently of the wrapper. /// private static int ImageSharpLineCount(string text, double width, double emSize) { @@ -113,13 +111,6 @@ private static int ImageSharpLineCount(string text, double width, double emSize) WrappingLength = (float)(width * ReferenceSize / emSize), }; - Assert.True(TextMeasurer.TryMeasureCharacterBounds(text, options, out ReadOnlySpan glyphs)); - HashSet rows = new(); - foreach (GlyphBounds glyph in glyphs) - { - rows.Add((int)Math.Floor(glyph.Bounds.Y / ReferenceSize)); - } - - return rows.Count; + return TextMeasurer.CountLines(text, options); } } diff --git a/ACadSharp.Image.Tests/SyntheticSamples.cs b/ACadSharp.Image.Tests/SyntheticSamples.cs index ea94ba4..56b8c4a 100644 --- a/ACadSharp.Image.Tests/SyntheticSamples.cs +++ b/ACadSharp.Image.Tests/SyntheticSamples.cs @@ -40,7 +40,7 @@ public static CadDocument ViewportSheet() document.Entities.Add(new Line(new XYZ(0, 30, 0), new XYZ(100, 30, 0)) { Layer = grid }); document.Entities.Add(new Circle { Center = new XYZ(50, 30, 0), Radius = 20, Layer = walls }); - // The default "Layout1" is left in the document: ACadSharp 3.7.1's LayoutCollection.Remove hard-codes + // The default "Layout1" is left in the document: ACadSharp 3.8.0's LayoutCollection.Remove hard-codes // Layout.PaperLayoutName ("Layout1") as non-removable and throws ArgumentException for it. It is harmless // here because the tests add only the "Sheet" layout to the exporter, never document.Layouts wholesale. Layout sheet = new("Sheet") { PaperWidth = 297, PaperHeight = 210 }; @@ -315,7 +315,6 @@ public static BlockRecord FidelityBlock() VVector = new XYZ(0, 20, 0), Size = new XY(1, 1), ClippingState = true, - ClipType = ClipType.Rectangular, ClipMode = ClipMode.Inside, Layer = coverLayer, }, 0x15); @@ -362,7 +361,7 @@ private static MLine.Vertex MLineVertex(XYZ position, XYZ direction, XYZ miter, /// /// Assigns a deterministic handle via reflection: has an internal setter in - /// ACadSharp 3.7.1, so tests that need a specific handle (to pin draw order, for instance) cannot set it directly. + /// ACadSharp 3.8.0, so tests that need a specific handle (to pin draw order, for instance) cannot set it directly. /// Shared by ImagePageTests and EntityRenderDispatcherTests as well as this class. /// internal static T WithHandle(T entity, ulong handle) diff --git a/ACadSharp.Image/ACadSharp.Image.csproj b/ACadSharp.Image/ACadSharp.Image.csproj index 2e64dde..4c4386a 100644 --- a/ACadSharp.Image/ACadSharp.Image.csproj +++ b/ACadSharp.Image/ACadSharp.Image.csproj @@ -14,6 +14,19 @@ true + + + + + + + + + diff --git a/ACadSharp.Image/Extensions/ColorExtensions.cs b/ACadSharp.Image/Extensions/ColorExtensions.cs index 0ecd75c..460b41d 100644 --- a/ACadSharp.Image/Extensions/ColorExtensions.cs +++ b/ACadSharp.Image/Extensions/ColorExtensions.cs @@ -1,5 +1,6 @@ using CadColor = ACadSharp.Color; using ImageColor = SixLabors.ImageSharp.Color; +using Rgba32 = SixLabors.ImageSharp.PixelFormats.Rgba32; namespace ACadSharp.Image.Extensions; @@ -27,6 +28,6 @@ public static ImageColor ToImageColor(this CadColor color, ImageColor foreground return foreground; } - return ImageColor.FromRgb(color.R, color.G, color.B); + return ImageColor.FromPixel(new Rgba32(color.R, color.G, color.B)); } } diff --git a/ACadSharp.Image/ImageExporter.cs b/ACadSharp.Image/ImageExporter.cs index b514bf6..b1b0006 100644 --- a/ACadSharp.Image/ImageExporter.cs +++ b/ACadSharp.Image/ImageExporter.cs @@ -162,10 +162,11 @@ public void Add(BlockRecord block) /// Output format the pages will be saved as. Defaults to PNG. /// Rendered pages; dispose each when finished. /// - /// Rendering temporarily mutates block MLINEs and LEADERs while working around ACadSharp 3.7.1's destructive - /// MLine.Clone() and Leader.Clone() (both share their vertex list with their source instead of - /// copying it) and restores them before returning; a must not be rendered concurrently - /// by two exporters, and Insert.Explode() itself is not safe for concurrent use either. + /// Rendering temporarily mutates block MLINEs and LEADERs while working around ACadSharp 3.8.0's destructive + /// MLine.Clone() (which empties the segment lists its vertices share with the source) and + /// Leader.Clone() (which shares its vertex list with the source instead of copying it) and restores them + /// before returning; a must not be rendered concurrently by two exporters, and + /// Insert.Explode() itself is not safe for concurrent use either. /// public IReadOnlyList Render(ImageExportFormat format = ImageExportFormat.Png) { diff --git a/ACadSharp.Image/Rendering/CsMathExtensions.cs b/ACadSharp.Image/Rendering/CsMathExtensions.cs new file mode 100644 index 0000000..65c3671 --- /dev/null +++ b/ACadSharp.Image/Rendering/CsMathExtensions.cs @@ -0,0 +1,19 @@ +using CSMath; + +namespace ACadSharp.Image.Rendering; + +/// +/// Vector helpers that CSMath 1.x exposed as instance methods and CSMath 2.x removed. +/// +internal static class CsMathExtensions +{ + /// Euclidean length of a 2D vector. + public static double GetLength(this XY vector) => Math.Sqrt((vector.X * vector.X) + (vector.Y * vector.Y)); + + /// Euclidean length of a 3D vector. + public static double GetLength(this XYZ vector) => + Math.Sqrt((vector.X * vector.X) + (vector.Y * vector.Y) + (vector.Z * vector.Z)); + + /// Whether both components of a 2D vector are exactly zero. + public static bool IsZero(this XY vector) => vector.X == 0d && vector.Y == 0d; +} diff --git a/ACadSharp.Image/Rendering/EntityRenderDispatcher.cs b/ACadSharp.Image/Rendering/EntityRenderDispatcher.cs index f697b1f..91061a1 100644 --- a/ACadSharp.Image/Rendering/EntityRenderDispatcher.cs +++ b/ACadSharp.Image/Rendering/EntityRenderDispatcher.cs @@ -43,7 +43,7 @@ internal sealed class EntityRenderDispatcher /// /// Keyed on identity, so this only pays off for repeated top-level inserts of the /// same block within one page: a nested reached while exploding an outer one holds a - /// deep-cloned block record (per in ACadSharp 3.7.1), a different key every time, so + /// deep-cloned block record (per in ACadSharp 3.8.0), a different key every time, so /// it misses the cache on every call regardless of how many times the same source block appears nested. A /// would still write an entry for every one of those one-shot clones as /// walks them, pinning the whole cloned block graph of a page in memory until @@ -52,6 +52,24 @@ internal sealed class EntityRenderDispatcher /// private readonly ConditionalWeakTable> _blocksNeedingHeal = new(); + /// + /// Snapshot of an MLINE's vertex list and of each vertex's segment list. ACadSharp's clone gets its own vertex + /// objects but shares each vertex's segment-list object with the source, and cloning empties those lists in + /// place, so both levels have to be captured and restored. + /// + private sealed class MLineSnapshot + { + public MLineSnapshot(List vertices, List> segments) + { + this.Vertices = vertices; + this.Segments = segments; + } + + public List Vertices { get; } + + public List> Segments { get; } + } + public EntityRenderDispatcher(ImageConfiguration configuration) { this._configuration = configuration; @@ -99,12 +117,11 @@ public void Draw(ImageRenderContext context, Entity entity) // source is the original block entity a clone came from, whose geometry is used instead of the clone's, and // placement is the transform of the insert that placed it. UsesOriginalGeometry's doc is the canonical list of // the types drawn from their original, and it is not repeated here. - // Both are null outside a block reference, but they do not always travel together inside - // one: an MLINE clone is always drawn with placement set and source null (UsesOriginalGeometry never recognises - // an MLine original, since the heal already restores the clone's own vertices to local coordinates), and so is a - // LEADER clone whose ordinal pairing with the block's original entities failed. A HATCH or WIPEOUT clone has no - // such fallback: when its pairing fails it is drawn with neither source nor placement, from its own - // un-normalised clone geometry (see the count-mismatch Warning in DrawBlockContents). + // Both are null outside a block reference, but they do not always travel together inside one: a LEADER clone + // whose ordinal pairing with the block's original entities failed is drawn with placement set and source null, + // and an MLINE clone in the same situation is drawn from its own (already transformed) points with both null. + // A HATCH or WIPEOUT clone has no such fallback: when its pairing fails it is drawn with neither source nor + // placement, from its own un-normalised clone geometry (see the count-mismatch Warning in DrawBlockContents). private void Draw(ImageRenderContext context, Entity entity, Layer? parentLayer, ulong? parentHandle, string? blockName, ResolvedStyle? parent, Entity? source = null, Transform? placement = null) { // Visibility comes first: a hidden entity must not warn about geometry nobody is going to draw. @@ -138,19 +155,19 @@ private void Draw(ImageRenderContext context, Entity entity, Layer? parentLayer, DrawArc(context, style, arc); break; case Arc arc: - DrawPolyline(context, style, arc.PolygonalVertexes(this._configuration.ArcPrecision).Select(v => v.Convert()), false); + DrawPolyline(context, style, arc.PolygonalVertexes(this._configuration.ArcPrecision).Select(v => (XY)v), false); break; case Circle circle when context.Surface.SupportsCurves && IsWorldPlane(circle.Normal): context.Surface.DrawEllipse(style, context.ToSurfacePoint(circle.Center), context.ToSurfaceLength(circle.Radius), context.ToSurfaceLength(circle.Radius), 0d); break; case Circle circle: - DrawPolyline(context, style, circle.PolygonalVertexes(this._configuration.ArcPrecision).Select(v => v.Convert()), true); + DrawPolyline(context, style, circle.PolygonalVertexes(this._configuration.ArcPrecision).Select(v => (XY)v), true); break; case Ellipse ellipse when context.Surface.SupportsCurves && IsWorldPlane(ellipse.Normal): DrawEllipse(context, style, ellipse); break; case Ellipse ellipse: - DrawPolyline(context, style, ellipse.PolygonalVertexes(this._configuration.ArcPrecision).Select(v => v.Convert()), true); + DrawPolyline(context, style, ellipse.PolygonalVertexes(this._configuration.ArcPrecision).Select(v => (XY)v), true); break; case Line line: context.Surface.DrawLine(style, context.ToSurfacePoint(line.StartPoint), context.ToSurfacePoint(line.EndPoint)); @@ -198,7 +215,7 @@ private void Draw(ImageRenderContext context, Entity entity, Layer? parentLayer, this.DrawBlockContents(context, insert, layer, resolved); break; case MLine mline: - this.DrawMLine(context, style, resolved, mline, placement); + this.DrawMLine(context, style, resolved, source as MLine ?? mline, placement); break; case Wipeout wipeout: this.DrawWipeout(context, style, source as Wipeout ?? wipeout, placement); @@ -266,10 +283,10 @@ private void DrawPoint(ImageRenderContext context, ImageStyle style, ACadSharp.E /// /// UpdateBlock() is the second place in the renderer, after , that makes /// ACadSharp construct an Insert of a block the caller owns: for a linear or aligned dimension it builds - /// one of each of the style's arrow blocks. ACadSharp 3.7.1's Insert(BlockRecord) constructor clones a - /// document-owned block's entities, so that call empties the vertex list of any MLINE inside one of them — - /// measured at two vertices to none with no renderer involved. This is the top-level dimension path, where - /// nothing else takes a snapshot: a dimension reached through a block reference is covered by + /// one of each of the style's arrow blocks. ACadSharp 3.8.0's Insert(BlockRecord) constructor clones a + /// document-owned block's entities, so that call empties the segment lists of any MLINE inside one of them — + /// measured at two segments per vertex to none with no renderer involved. This is the top-level dimension + /// path, where nothing else takes a snapshot: a dimension reached through a block reference is covered by /// 's own snapshot and its finally heal, but the entity-type switch in /// Draw routes a top-level dimension straight here. The heal is in a finally for the same reason /// it is there, so a throw while generating the picture cannot leave the caller's document broken. Page framing @@ -288,7 +305,7 @@ private void DrawDimension(ImageRenderContext context, Dimension dimension, Laye BlockRecord? block = dimension.Block; if (block == null) { - Dictionary> mlineVertices = new(); + Dictionary mlineVertices = new(); Dictionary> leaderVertices = new(); HashSet collected = new(); foreach (BlockRecord referenced in ReferencedBlocks(dimension)) @@ -437,7 +454,7 @@ private IEnumerable PolylinePoints(IPolyline polyline) IEnumerable points = polyline.GetPoints(this._configuration.ArcPrecision); if (IsWorldPlane(polyline.Normal)) { - return points.Select(v => v.Convert()); + return points.Select(v => (XY)v); } OcsTransform toWorld = OcsTransform.For(polyline.Normal); @@ -511,7 +528,7 @@ private static void DrawArc(ImageRenderContext context, ImageStyle style, Arc ar /// Emits an ellipse or elliptical arc natively. /// /// - /// Ellipse.MajorAxis and Ellipse.MinorAxis are full axis lengths in ACadSharp 3.7.1 + /// Ellipse.MajorAxis and Ellipse.MinorAxis are full axis lengths in ACadSharp 3.8.0 /// (MajorAxis is twice the length of MajorAxisEndPoint), so they are halved into surface radii. /// private static void DrawEllipse(ImageRenderContext context, ImageStyle style, Ellipse ellipse) @@ -605,8 +622,8 @@ private void DrawLeader(ImageRenderContext context, ImageStyle style, ResolvedSt } double size = leader.Style.ArrowSize * (leader.Style.ScaleFactor > 0d ? leader.Style.ScaleFactor : 1d); - XY tip = leader.Vertices[0].Convert(); - XY direction = tip - leader.Vertices[1].Convert(); + XY tip = (XY)leader.Vertices[0]; + XY direction = tip - (XY)leader.Vertices[1]; double length = direction.GetLength(); // Every comparison with NaN is false, so the size has to be tested for finiteness explicitly. The degenerate @@ -655,7 +672,7 @@ private void DrawLeader(ImageRenderContext context, ImageStyle style, ResolvedSt /// by walking its entities with a transform: most entity types are drawn from their own stored points and ignore /// a placement, so only Insert.Explode() transforms an arbitrary block's contents correctly. /// - /// Two ACadSharp 3.7.1 behaviours shape the construction. An Insert cannot represent shear, so a composed + /// Two ACadSharp 3.8.0 behaviours shape the construction. An Insert cannot represent shear, so a composed /// transform that is not a planar similarity has no equivalent insert and the caller falls back. And /// Insert.GetTransform() computes R * S * p + (InsertPoint - BasePoint), where AutoCAD specifies /// InsertPoint + R * S * (p - BasePoint); the two agree only when the rotation and scale are identity, so @@ -710,11 +727,11 @@ XYZ Arrow(XYZ p) return false; } - // ACadSharp 3.7.1's Insert(BlockRecord) constructor clones a document-owned block's entities, so merely - // building the transient insert empties the vertex list of any MLINE in the arrow block (and of any MLINE + // ACadSharp 3.8.0's Insert(BlockRecord) constructor clones a document-owned block's entities, so merely + // building the transient insert empties the segment lists of any MLINE in the arrow block (and of any MLINE // in a further arrowhead block below it). The snapshot therefore has to be taken before the constructor // runs, not inside DrawBlockContents, which only gets to look once the insert already exists. - Dictionary> mlineVertices = new(); + Dictionary mlineVertices = new(); Dictionary> leaderVertices = new(); if (this.BlockSubtreeNeedsHeal(arrow, new HashSet())) { @@ -1101,7 +1118,7 @@ private void DrawWipeout(ImageRenderContext context, ImageStyle style, Wipeout w /// Zero, one or two rings of world points. /// /// The insertion point is mapped as a point and the U and V vectors as directions, from the original entity: - /// ACadSharp 3.7.1's Wipeout.ApplyTransform maps U and V as points, so a translated clone's vectors carry + /// ACadSharp 3.8.0's Wipeout.ApplyTransform maps U and V as points, so a translated clone's vectors carry /// the translation and the mask is stretched across the drawing. /// internal static IReadOnlyList> WipeoutWorldRings(Wipeout wipeout, Transform? placement) @@ -1162,20 +1179,19 @@ internal static XYZ WipeoutPixelToWorld(CadWipeoutBase image, XY pixel, Transfor /// placed through the insert's transform, instead of the clone's own points. This doc is the canonical list of /// the types that are drawn that way; the block-content path and Draw point here rather than repeat it. /// They are: a TEXT or MTEXT (their alignment point and, for MTEXT, X axis are never transformed by - /// Explode()), an ATTRIB or ATTDEF (an - /// AttributeBase is a TextEntity, so the TEXT arm covers it; in practice this is the constant - /// ATTDEF draws — a non-constant one is a template and is skipped there — and a - /// multi-line one is then drawn from the original's embedded MTEXT through the insert transform rather than - /// block-local), a LEADER (once healed, the clone - /// shares the same local vertex list as the original, so either would draw identically; the original is used - /// for consistency with TEXT, MTEXT and SOLID, not because it carries anything the clone lacks), a SOLID whose - /// normal is not the world Z axis (its OCS corners must be brought into world space before the insert - /// transform, not after), a HATCH (its boundary and pattern are OCS data too, and Hatch.ApplyTransform - /// maps the raw OCS boundary as if it were world data and never folds in Elevation, so the clone can - /// never be trusted; only the original, drawn through its own OCS frame and then the placement, is correct), or - /// a WIPEOUT (Wipeout.ApplyTransform maps its U and V vectors as points, so a translated clone's vectors - /// carry the translation; only the original, mapped through , keeps them - /// as directions). + /// Explode()); an ATTRIB or ATTDEF (an AttributeBase is a TextEntity, so the TEXT arm + /// covers it; in practice this is the constant ATTDEF draws — a non-constant one + /// is a template and is skipped there — and a multi-line one is then drawn from the original's embedded MTEXT + /// through the insert transform rather than block-local); an MLINE (ACadSharp 3.8's Explode() transforms + /// the clone's vertices but empties the shared segment lists that carry MLEDIT cuts, so only the original + /// retains the complete local geometry); a LEADER (the original is used for consistency with TEXT, MTEXT and + /// SOLID; the heal also restores its local vertex list); a SOLID whose normal is not the world Z axis (its OCS + /// corners must be brought into world space before the insert transform, not after); a HATCH (its boundary and + /// pattern are OCS data too, and Hatch.ApplyTransform maps the raw OCS boundary as if it were world data + /// and never folds in Elevation, so the clone can never be trusted; only the original, drawn through its + /// own OCS frame and then the placement, is correct); or a WIPEOUT (Wipeout.ApplyTransform maps its U and + /// V vectors as points, so a translated clone's vectors carry the translation; only the original, mapped through + /// , keeps them as directions). /// The pairing requires to be the block entity at the clone's own index and of the /// same runtime type, since a mismatched index (an ATTDEF the clone stream skipped, for example) would pair the /// wrong entity. @@ -1190,7 +1206,7 @@ private static bool UsesOriginalGeometry(Entity? original, Entity clone) return false; } - if (original is TextEntity or MText or Leader or Hatch or Wipeout) + if (original is TextEntity or MText or MLine or Leader or Hatch or Wipeout) { return true; } @@ -1199,10 +1215,10 @@ private static bool UsesOriginalGeometry(Entity? original, Entity clone) } /// - /// Draws the contents of a block reference by exploding it, healing the vertex lists ACadSharp 3.7.1's clones - /// share with their sources, and drawing every clone — some of them from the original block entity through the - /// insert's transform rather than from the clone's own points. 's doc is the - /// canonical list of which types those are and why; it is not repeated here. + /// Draws the contents of a block reference by exploding it, healing the vertex and segment lists ACadSharp's + /// clones share with their sources, and drawing every clone — some of them from the original block entity + /// through the insert's transform rather than from the clone's own points. 's + /// doc is the canonical list of which types those are and why; it is not repeated here. /// /// The context that maps drawing units onto the surface. /// The block reference to draw the contents of. @@ -1229,32 +1245,33 @@ private void DrawBlockContents(ImageRenderContext context, Insert insert, Layer? // The exploded clones carry the block entities' own attributes but no owner or document; ByBlock and // layer-0 inheritance, and the header's LTSCALE, come from the insert's resolved style and effective layer. - // ACadSharp 3.7.1's Explode() yields one clone per block entity, in order. Text geometry comes from the + // ACadSharp 3.8.0's Explode() yields one clone per block entity, in order. Text geometry comes from the // original entity placed through the insert's transform, because the clones' alignment points and MTEXT // X axes are never transformed and mirrored inserts hand back world points with a flipped normal. Transform transform = insert.GetTransform(); IReadOnlyList originals = insert.Block.Entities.ToList(); - // ACadSharp 3.7.1's MLine.Clone() empties the vertex list an MLine shares with its source (by - // MemberwiseClone), and Leader.Clone() shares its vertex list the same way but Explode()'s ApplyTransform - // overwrites that shared list's contents (world coordinates) in place instead of emptying it; either way the - // source document is left corrupted once Explode() runs, because the clone and its source are the very same - // List object. Insert.Clone() deep-clones its entire block subtree, so exploding this insert destroys every - // MLINE reachable through it, including ones nested inside a block placed inside this one, several levels - // below anything Explode() itself returns, because cloning the nested Insert empties that MLINE's list the - // moment it is cloned along the way; a nested LEADER's list, by contrast, is overwritten only when the - // insert that directly contains it is the one exploded, so a deeply nested LEADER survives an ancestor's - // Explode() unharmed and its snapshot below is a defensive backstop, not a load-bearing fix. + // ACadSharp 3.8.0's MLine.Clone() gives the clone its own vertex objects but shares each vertex's segment + // list with the source and empties those lists in place; Leader.Clone() shares its vertex list with the + // source, but Explode()'s ApplyTransform overwrites that shared list's contents (world coordinates) in place + // instead of emptying it. Either way the source document is left corrupted once Explode() runs, because + // the clone and its source share the affected list object. Insert.Clone() deep-clones its entire block + // subtree, so exploding this insert destroys every MLINE reachable through it, including ones nested inside + // a block placed inside this one, several levels below anything Explode() itself returns, because cloning + // the nested Insert empties that MLINE's list the moment it is cloned along the way; a nested LEADER's list, + // by contrast, is overwritten only when the insert that directly contains it is the one exploded, so a + // deeply nested LEADER survives an ancestor's Explode() unharmed and its snapshot below is a defensive + // backstop, not a load-bearing fix. // CollectSharedVertexLists walks the whole subtree, following every edge ReferencedBlocks reports — nested // Insert.Block references, a DIMENSION's own picture block, and the four arrowhead blocks of a LEADER's or a // DIMENSION's style, none of them cloned at this point — to snapshot every MLINE and LEADER before - // Explode() runs, and Heal repairs them - // immediately after and again in `finally`. The repair is always in place (Clear + AddRange into the - // *existing* list, never a reassignment): because a clone shares the very same list object as its source at - // every depth, one in-place heal fixes the original and every clone below it at once; reassigning would - // leave an outer level's shared list broken. The insert's transform still has to be applied manually to a - // healed MLINE's or LEADER's points, because Explode()'s own ApplyTransform ran against the pre-heal list. - Dictionary> mlineVertices = new(); + // Explode() runs, and Heal repairs them immediately after and again in `finally`. The repair is always in + // place (Clear + AddRange into the *existing* list, never a reassignment): because the lists a clone shares + // with its source are shared at every depth, one in-place heal fixes the original and every clone below it at + // once; reassigning would leave an outer level's shared list broken. The insert's transform still has to be + // applied manually to the local points used for a healed MLINE or LEADER, because Explode()'s own + // ApplyTransform only reached the clone. + Dictionary mlineVertices = new(); Dictionary> leaderVertices = new(); // Walking the whole subtree just to find out there is nothing to snapshot is wasted work on every insert of // an MLINE/LEADER-free block; BlockSubtreeNeedsHeal answers that cheaply (memoised per block), so the actual @@ -1300,7 +1317,14 @@ private void DrawBlockContents(ImageRenderContext context, Insert insert, Layer? source = original; entityPlacement = transform; } - else if (entity is MLine or Leader) + else if (entity is MLine) + { + // ACadSharp 3.8's Explode() transforms MLINE clones to world coordinates itself. A clone whose + // ordinal pairing with the block's originals failed has no local geometry left to place, so + // applying the insert transform again would move it twice. + entityPlacement = null; + } + else if (entity is Leader) { entityPlacement = transform; } @@ -1324,18 +1348,28 @@ private void DrawBlockContents(ImageRenderContext context, Insert insert, Layer? } /// - /// Restores every snapshotted MLINE and LEADER vertex list in place (Clear + AddRange, never a reassignment): - /// a clone shares the very same list object as its source at every depth, so one in-place repair fixes the - /// original and every clone below it at once, where reassigning would leave an outer level's list broken. + /// Restores every snapshotted MLINE vertex and segment list, and every LEADER vertex list, in place + /// (Clear + AddRange, never a reassignment): the list objects a clone shares with its source are shared at every + /// depth, so one in-place repair fixes the original and every clone below it at once, where reassigning would + /// leave an outer level's list broken. /// - /// The MLINE vertex lists captured before cloning. + /// The MLINE vertex and segment lists captured before cloning. /// The LEADER vertex lists captured before cloning. - private static void Heal(Dictionary> mlineSnapshot, Dictionary> leaderSnapshot) + private static void Heal(Dictionary mlineSnapshot, Dictionary> leaderSnapshot) { - foreach (KeyValuePair> pair in mlineSnapshot) + foreach (KeyValuePair pair in mlineSnapshot) { pair.Key.Vertices.Clear(); - pair.Key.Vertices.AddRange(pair.Value); + pair.Key.Vertices.AddRange(pair.Value.Vertices); + // The clone shares each vertex's segment list with its source, and cloning empties those lists in place. + // Restoring the source's lists therefore repairs the clone too. + int count = Math.Min(pair.Key.Vertices.Count, pair.Value.Segments.Count); + for (int i = 0; i < count; i++) + { + List segments = pair.Key.Vertices[i].Segments; + segments.Clear(); + segments.AddRange(pair.Value.Segments[i]); + } } foreach (KeyValuePair> pair in leaderSnapshot) @@ -1352,7 +1386,7 @@ private static void Heal(Dictionary> mlineSnapshot, Di /// a block reference's own ; /// a DIMENSION's , the anonymous block holding the picture ACadSharp /// generates for it — its lines, arrowheads and measurement text. Dimension.Clone() deep-clones it - /// (probed on 3.7.1: an MLINE inside a picture block goes from two vertices to none across a single + /// (probed on 3.8.0: an MLINE inside a picture block goes from two segments per vertex to none across a single /// Clone(), and the clone's block is a different instance), so it corrupts a source document exactly as /// the arrowhead edges do. It differs from them in being on the ordinary render path — DrawDimension /// draws through it — rather than reachable only from an exotic file, and in being generated geometry rather @@ -1360,7 +1394,7 @@ private static void Heal(Dictionary> mlineSnapshot, Di /// and there is nothing to clone or to walk; /// every non-null block-valued property of a LEADER's or a DIMENSION's dimension style — /// ArrowBlock (DIMBLK), DimArrow1 (DIMBLK1), DimArrow2 (DIMBLK2) and LeaderArrow - /// (DIMLDRBLK). ACadSharp 3.7.1's DimensionStyle.Clone() deep-clones all four, and Leader and + /// (DIMLDRBLK). ACadSharp 3.8.0's DimensionStyle.Clone() deep-clones all four, and Leader and /// Dimension both clone their style, so an MLINE inside any of them is emptied by a clone that never /// names it — whether or not the renderer ever draws that particular arrowhead, since only LeaderArrow /// is drawn. @@ -1431,25 +1465,27 @@ private static IEnumerable ReferencedBlocks(Entity entity) } /// - /// Snapshots every MLINE's and LEADER's vertex list reachable from , following every - /// edge reports: nested references, and all four - /// arrowhead blocks of every LEADER's and DIMENSION's dimension style on the way. - /// deep-clones its entire block subtree, so an MLINE nested several blocks deep is - /// corrupted by an ancestor insert's own explode even though it is never that ancestor's direct child, because - /// its list is emptied the moment it is cloned; a nested LEADER's list, by contrast, is only overwritten when - /// the insert that directly contains it is the one exploded, so snapshotting it here is a defensive backstop - /// rather than the fix MLINE needs. Cloning a LEADER or a DIMENSION also clones its dimension style, and that - /// clones all four of the style's arrowhead blocks, and cloning a DIMENSION clones its picture block too, which - /// is how an MLINE inside a custom arrowhead — or inside a dimension's own generated geometry — is reached by a - /// clone that never names it. This has to run, and capture the whole subtree, before the clone that - /// corrupts those lists — the explode itself, or, for a document-owned block, the Insert(BlockRecord) - /// constructor. + /// Snapshots every MLINE's vertex and segment lists and every LEADER's vertex list reachable from + /// , following every edge reports: nested + /// references, and all four arrowhead blocks of every LEADER's and DIMENSION's + /// dimension style on the way. deep-clones its entire block subtree, so an MLINE + /// nested several blocks deep is corrupted by an ancestor insert's own explode even though it is never that + /// ancestor's direct child, because its vertex and segment lists are emptied the moment it is cloned; a nested + /// LEADER's list, by contrast, is only overwritten when the insert that directly contains it is the one + /// exploded, so snapshotting it here is a defensive backstop rather than the fix MLINE needs. Cloning a LEADER + /// or a DIMENSION also clones its dimension style, which clones all four of the style's arrowhead blocks, and + /// cloning a DIMENSION clones its picture block too, which is how an MLINE inside a custom arrowhead — or inside + /// a dimension's own generated geometry — is reached by a clone that never names it. This has to run, and capture + /// the whole subtree, before the clone that corrupts those lists — the explode itself, or, for a document-owned + /// block, the Insert(BlockRecord) constructor. /// - /// The block whose entities, nested blocks, dimension pictures and dimension-style arrowhead blocks are searched. - /// Receives one entry per MLINE found, keyed by the MLINE itself. + /// The block whose entities, nested blocks, dimension pictures and dimension-style arrowhead + /// blocks are searched. + /// Receives one entry per MLINE found, keyed by the MLINE itself and holding its + /// vertex and segment lists. /// Receives one entry per LEADER found, keyed by the LEADER itself. /// Blocks already walked, so a circular or diamond hierarchy is walked once. - private static void CollectSharedVertexLists(BlockRecord? block, Dictionary> mlineSnapshot, Dictionary> leaderSnapshot, HashSet visited) + private static void CollectSharedVertexLists(BlockRecord? block, Dictionary mlineSnapshot, Dictionary> leaderSnapshot, HashSet visited) { if (block == null || !visited.Add(block)) { @@ -1461,7 +1497,9 @@ private static void CollectSharedVertexLists(BlockRecord? block, Dictionary(mline.Vertices)); + mlineSnapshot.Add(mline, new MLineSnapshot( + new List(mline.Vertices), + mline.Vertices.Select(v => new List(v.Segments)).ToList())); break; case Leader leader when !leaderSnapshot.ContainsKey(leader): leaderSnapshot.Add(leader, new List(leader.Vertices)); @@ -1683,7 +1721,7 @@ private bool IsAttributeVisible(AttributeBase attribute, Insert insert, Resolved private void DrawHatch(ImageRenderContext context, ImageStyle style, Hatch hatch, Transform? placement) { // Boundary paths and exploded pattern lines are OCS data; the OCS frame and the entity's own elevation are - // applied here and the insert transform after them, because ACadSharp 3.7.1's Hatch.ApplyTransform maps the + // applied here and the insert transform after them, because ACadSharp 3.8.0's Hatch.ApplyTransform maps the // raw OCS boundary as if it were world data and never folds the elevation in, so a clone from a block cannot // be trusted for a hatch on a tilted plane. OcsTransform? toWorld = IsWorldPlane(hatch.Normal) ? null : OcsTransform.For(hatch.Normal); @@ -1753,7 +1791,7 @@ private void DrawHatch(ImageRenderContext context, ImageStyle style, Hatch hatch /// /// Number of pattern scan lines Hatch.ExplodePattern() would sweep across the hatch's bounding box, using its - /// own arithmetic (ACadSharp 3.7.1). Each scan line is clipped against every boundary edge and may emit several + /// own arithmetic (ACadSharp 3.8.0). Each scan line is clipped against every boundary edge and may emit several /// dashes, so this is the work the expansion costs, not the number of lines it draws. /// /// The pattern hatch. diff --git a/ACadSharp.Image/Rendering/ImagePageRenderer.cs b/ACadSharp.Image/Rendering/ImagePageRenderer.cs index 8a993f6..663dbf5 100644 --- a/ACadSharp.Image/Rendering/ImagePageRenderer.cs +++ b/ACadSharp.Image/Rendering/ImagePageRenderer.cs @@ -61,7 +61,7 @@ public RenderedPage Render(ImagePage page, ImageExportFormat format) return this.RenderSvg(page); } - Image image = new(this._configuration.Width, this._configuration.Height, this._configuration.BackgroundColor); + Image image = RasterCanvasCompat.CreateImage(this._configuration.Width, this._configuration.Height, this._configuration.BackgroundColor); try { using RasterDrawingSurface surface = new(image, this._configuration, ownsCanvas: false); @@ -198,7 +198,7 @@ private RenderedSvgPage RenderSvg(ImagePage page) /// /// The branch is on the raw DXF value of $PSLTSCALE (0 = model-space lengths, 1 = paper-space lengths, and the /// AutoCAD default) rather than on the name of the member, because the ACadSharp - /// 3.7.1 names are swapped with respect to those semantics: SpaceLineTypeScaling.Viewport is 0 and + /// 3.8.0 names are swapped with respect to those semantics: SpaceLineTypeScaling.Viewport is 0 and /// SpaceLineTypeScaling.Normal is 1. /// internal static double ResolveViewportLineTypeScale(CadHeader? header, double pageLineTypeScale, double viewportScaleFactor) diff --git a/ACadSharp.Image/Rendering/ImageRenderContext.cs b/ACadSharp.Image/Rendering/ImageRenderContext.cs index 7f313e4..a6a14ee 100644 --- a/ACadSharp.Image/Rendering/ImageRenderContext.cs +++ b/ACadSharp.Image/Rendering/ImageRenderContext.cs @@ -333,7 +333,7 @@ public SurfacePoint ToSurfacePoint(XY point) /// The point in surface coordinates. public SurfacePoint ToSurfacePoint(XYZ point) { - return this.ToSurfacePoint(point.Convert()); + return this.ToSurfacePoint((XY)point); } /// diff --git a/ACadSharp.Image/Rendering/ImageStyleResolver.cs b/ACadSharp.Image/Rendering/ImageStyleResolver.cs index 2570700..a9bf934 100644 --- a/ACadSharp.Image/Rendering/ImageStyleResolver.cs +++ b/ACadSharp.Image/Rendering/ImageStyleResolver.cs @@ -109,7 +109,7 @@ public ImageStyle ToImageStyle(ResolvedStyle resolved, ImageRenderContext contex } /// - /// Maps CAD transparency to opacity. ByLayer is opaque (ACadSharp 3.7.1 layers carry no transparency); + /// Maps CAD transparency to opacity. ByLayer is opaque (ACadSharp 3.8.0 layers carry no transparency); /// ByBlock inherits the parent's opacity; explicit values 0..90 mean that percentage transparent. /// internal static float ResolveOpacity(Entity entity, float parentOpacity) diff --git a/ACadSharp.Image/Rendering/InsertPlacement.cs b/ACadSharp.Image/Rendering/InsertPlacement.cs index fa9d882..8072575 100644 --- a/ACadSharp.Image/Rendering/InsertPlacement.cs +++ b/ACadSharp.Image/Rendering/InsertPlacement.cs @@ -8,7 +8,7 @@ namespace ACadSharp.Image.Rendering; /// /// /// Points and vectors map differently: a translation moves a point but must not change a direction, so a vector is -/// mapped by transforming its head and tail and subtracting. ACadSharp 3.7.1 gets this wrong in places of its own +/// mapped by transforming its head and tail and subtracting. ACadSharp 3.8.0 gets this wrong in places of its own /// (Wipeout.ApplyTransform transforms its U and V vectors as points), which is why the renderer maps from the /// original entity through these helpers instead of trusting a transformed clone. /// diff --git a/ACadSharp.Image/Rendering/OcsTransform.cs b/ACadSharp.Image/Rendering/OcsTransform.cs index c7775d9..2de6365 100644 --- a/ACadSharp.Image/Rendering/OcsTransform.cs +++ b/ACadSharp.Image/Rendering/OcsTransform.cs @@ -8,7 +8,7 @@ namespace ACadSharp.Image.Rendering; /// /// /// Polylines, hatches and solids store their vertices in the plane defined by their extrusion Normal (the OCS) and -/// ACadSharp 3.7.1 returns them raw: neither IPolyline.GetPoints nor Hatch.BoundaryPath.GetPoints nor +/// ACadSharp 3.8.0 returns them raw: neither IPolyline.GetPoints nor Hatch.BoundaryPath.GetPoints nor /// Hatch.ExplodePattern applies the normal. The renderer projects world XY onto the page, so those points must /// be brought into world space first. /// diff --git a/ACadSharp.Image/Rendering/RasterCanvasCompat.cs b/ACadSharp.Image/Rendering/RasterCanvasCompat.cs new file mode 100644 index 0000000..08e9fcf --- /dev/null +++ b/ACadSharp.Image/Rendering/RasterCanvasCompat.cs @@ -0,0 +1,144 @@ +using System.Numerics; +using SixLabors.Fonts; +using SixLabors.ImageSharp; +using SixLabors.ImageSharp.Drawing; +using SixLabors.ImageSharp.Drawing.Processing; +using SixLabors.ImageSharp.Processing; +#if !NET6_0 +using SixLabors.ImageSharp.Drawing.Text; +#endif +using ImageColor = SixLabors.ImageSharp.Color; +using ImagePoint = SixLabors.ImageSharp.Point; +using Rgba32 = SixLabors.ImageSharp.PixelFormats.Rgba32; + +namespace ACadSharp.Image.Rendering; + +/// +/// Bridges the ImageSharp.Drawing 2.x API used on net6.0 and the 3.x API used on net8.0+. +/// +/// +/// Six Labors 4.x (ImageSharp) and 3.x (Drawing, Fonts) require .NET 8 or later, so net6.0 stays on +/// ImageSharp 3.2.0/Drawing 2.2.0. The drawing surface calls through this class to keep the rendering +/// code single-sourced. +/// +internal static class RasterCanvasCompat +{ + public static Image CreateImage(int width, int height, ImageColor background) + { + return new Image(width, height, background.ToPixel()); + } + + public static void DrawLine(Image canvas, Pen pen, PointF[] points) + { +#if NET6_0 + canvas.Mutate(x => x.DrawLine(pen, points)); +#else + canvas.Mutate(x => x.Paint(c => c.DrawLine(pen, points))); +#endif + } + + public static void DrawPath(Image canvas, Pen pen, IPath path) + { +#if NET6_0 + canvas.Mutate(x => x.Draw(pen, path)); +#else + canvas.Mutate(x => x.Paint(c => c.Draw(pen, path))); +#endif + } + + public static void FillPolygon(Image canvas, ImageColor color, PointF[] points) + { +#if NET6_0 + canvas.Mutate(x => x.FillPolygon(color, points)); +#else + canvas.Mutate(x => x.Paint(c => c.Fill(new SolidBrush(color), new Polygon(new LinearLineSegment(points))))); +#endif + } + + public static DrawingOptions CreateDrawingOptions(Matrix3x2? transform = null, bool evenOdd = false) + { + DrawingOptions options = new(); +#if NET6_0 + if (evenOdd) + { + options.ShapeOptions.IntersectionRule = IntersectionRule.EvenOdd; + } + + if (transform.HasValue) + { + options.Transform = transform.Value; + } +#else + if (evenOdd) + { + options.IntersectionRule = IntersectionRule.EvenOdd; + } + + if (transform.HasValue) + { + options.Transform = ToMatrix4x4(transform.Value); + } +#endif + return options; + } + + public static void FillPath(Image canvas, DrawingOptions options, ImageColor color, IPath path) + { +#if NET6_0 + canvas.Mutate(x => x.Fill(options, color, path)); +#else + canvas.Mutate(x => x.Paint(options, c => c.Fill(new SolidBrush(color), path))); +#endif + } + + public static void FillGlyphs(Image canvas, DrawingOptions options, ImageColor color, IPathCollection glyphs) + { +#if NET6_0 + canvas.Mutate(x => x.Fill(options, color, glyphs)); +#else + canvas.Mutate(x => x.Paint(options, c => c.Fill(new SolidBrush(color), glyphs))); +#endif + } + + public static void FillEllipse(Image canvas, ImageColor color, PointF center, float radius) + { +#if NET6_0 + canvas.Mutate(x => x.Fill(color, new EllipsePolygon(center.X, center.Y, radius))); +#else + canvas.Mutate(x => x.Paint(c => c.FillEllipse(new SolidBrush(color), center, new SizeF(radius * 2f, radius * 2f)))); +#endif + } + + public static IPathCollection GenerateTextPaths(string text, TextOptions options) + { +#if NET6_0 + return TextBuilder.GenerateGlyphs(text, options); +#else + return TextBuilder.GeneratePaths(text, options); +#endif + } + + public static void DrawImage(Image canvas, Image image, ImagePoint location) + { +#if NET6_0 + canvas.Mutate(x => x.DrawImage(image, location, 1f)); +#else + canvas.Mutate(x => x.Paint(c => c.DrawImage( + image, + new Rectangle(0, 0, image.Width, image.Height), + new RectangleF(location.X, location.Y, image.Width, image.Height), + KnownResamplers.NearestNeighbor))); +#endif + } + +#if !NET6_0 + private static Matrix4x4 ToMatrix4x4(Matrix3x2 matrix) + { + return new Matrix4x4( + matrix.M11, matrix.M12, 0f, 0f, + matrix.M21, matrix.M22, 0f, 0f, + 0f, 0f, 1f, 0f, + matrix.M31, matrix.M32, 0f, 1f); + } +#endif +} diff --git a/ACadSharp.Image/Rendering/RasterDrawingSurface.cs b/ACadSharp.Image/Rendering/RasterDrawingSurface.cs index dd670a8..c18e959 100644 --- a/ACadSharp.Image/Rendering/RasterDrawingSurface.cs +++ b/ACadSharp.Image/Rendering/RasterDrawingSurface.cs @@ -46,7 +46,7 @@ public void EndEntity() public void DrawLine(ImageStyle style, SurfacePoint start, SurfacePoint end) { Pen pen = CreatePen(style); - this.Canvas.Mutate(x => x.DrawLine(pen, ToPointF(start), ToPointF(end))); + RasterCanvasCompat.DrawLine(this.Canvas, pen, [ToPointF(start), ToPointF(end)]); } public void DrawPolyline(ImageStyle style, IReadOnlyList points, bool closed) @@ -68,7 +68,7 @@ public void DrawPolyline(ImageStyle style, IReadOnlyList points, b } Pen pen = CreatePen(style); - this.Canvas.Mutate(x => x.DrawLine(pen, pixels)); + RasterCanvasCompat.DrawLine(this.Canvas, pen, pixels); } public void DrawArc(ImageStyle style, SurfacePoint center, double radiusX, double radiusY, double rotation, double startAngle, double sweepAngle) @@ -101,7 +101,7 @@ public void DrawCubicBezier(ImageStyle style, IReadOnlyList contro IPath path = builder.Build(); Pen pen = CreatePen(style); - this.Canvas.Mutate(x => x.Draw(pen, path)); + RasterCanvasCompat.DrawPath(this.Canvas, pen, path); } public void DrawBulgePolyline(ImageStyle style, IReadOnlyList points, IReadOnlyList bulges, bool closed) @@ -144,7 +144,7 @@ public void FillPolygon(ImageStyle style, IReadOnlyList points) PointF[] pixels = finite.Select(ToPointF).ToArray(); ImageColor color = style.EffectiveColor; - this.Canvas.Mutate(x => x.FillPolygon(color, pixels)); + RasterCanvasCompat.FillPolygon(this.Canvas, color, pixels); } public void FillPath(ImageStyle style, IReadOnlyList> rings) @@ -161,11 +161,8 @@ public void FillPath(ImageStyle style, IReadOnlyList IPath shape = polygons.Length == 1 ? polygons[0] : new ComplexPolygon(polygons); ImageColor color = style.EffectiveColor; - DrawingOptions options = new() - { - ShapeOptions = { IntersectionRule = IntersectionRule.EvenOdd }, - }; - this.Canvas.Mutate(x => x.Fill(options, color, shape)); + DrawingOptions options = RasterCanvasCompat.CreateDrawingOptions(evenOdd: true); + RasterCanvasCompat.FillPath(this.Canvas, options, color, shape); } public void FillCircle(ImageStyle style, SurfacePoint center, double radius) @@ -178,7 +175,7 @@ public void FillCircle(ImageStyle style, SurfacePoint center, double radius) PointF pixel = ToPointF(center); ImageColor color = style.EffectiveColor; - this.Canvas.Mutate(x => x.Fill(color, new EllipsePolygon(pixel.X, pixel.Y, (float)radius))); + RasterCanvasCompat.FillEllipse(this.Canvas, color, pixel, (float)radius); } public void DrawText(ImageStyle style, SurfaceText text) @@ -224,7 +221,7 @@ public void DrawText(ImageStyle style, SurfaceText text) TextOptions options = new(font) { Dpi = 72f, - Origin = new PointF(origin.X, origin.Y + (float)leadingOffset), + Origin = new Vector2(origin.X, origin.Y + (float)leadingOffset), HorizontalAlignment = text.Anchor switch { SurfaceTextAnchor.Middle => HorizontalAlignment.Center, @@ -241,8 +238,8 @@ public void DrawText(ImageStyle style, SurfaceText text) LineSpacing = lineSpacing, }; - IPathCollection glyphs = TextBuilder.GenerateGlyphs(text.Text, options); - DrawingOptions drawingOptions = new(); + IPathCollection glyphs = RasterCanvasCompat.GenerateTextPaths(text.Text, options); + Matrix3x2? transform = null; bool stretched = Math.Abs(text.WidthScale - 1d) > 1e-9; bool rotated = Math.Abs(text.Rotation) > double.Epsilon; if (stretched || rotated) @@ -251,17 +248,18 @@ public void DrawText(ImageStyle style, SurfaceText text) // origin first widens the run along its own reading axis, and the rotation that follows carries the // widened run to its final orientation, so the stretch travels with the text instead of the canvas. Vector2 pivot = new(origin.X, origin.Y); - Matrix3x2 transform = stretched ? Matrix3x2.CreateScale((float)text.WidthScale, 1f, pivot) : Matrix3x2.Identity; + Matrix3x2 value = stretched ? Matrix3x2.CreateScale((float)text.WidthScale, 1f, pivot) : Matrix3x2.Identity; if (rotated) { - transform *= Matrix3x2.CreateRotation((float)-text.Rotation, pivot); + value *= Matrix3x2.CreateRotation((float)-text.Rotation, pivot); } - drawingOptions.Transform = transform; + transform = value; } + DrawingOptions drawingOptions = RasterCanvasCompat.CreateDrawingOptions(transform); ImageColor color = style.EffectiveColor; - this.Canvas.Mutate(x => x.Fill(drawingOptions, color, glyphs)); + RasterCanvasCompat.FillGlyphs(this.Canvas, drawingOptions, color, glyphs); } public ViewportSurface BeginViewport(SurfaceRect bounds) @@ -275,7 +273,7 @@ public ViewportSurface BeginViewport(SurfaceRect bounds) double fractionY = bounds.Y - top; int width = Math.Max(1, (int)Math.Ceiling(fractionX + bounds.Width)); int height = Math.Max(1, (int)Math.Ceiling(fractionY + bounds.Height)); - Image image = new(width, height, ImageColor.Transparent); + Image image = RasterCanvasCompat.CreateImage(width, height, ImageColor.Transparent); RasterDrawingSurface child = new(image, this._configuration, ownsCanvas: true); ViewportSurface viewport = new(child, fractionX, fractionY + bounds.Height); this._viewports[viewport] = (image, new SurfaceRect(left, top, width, height)); @@ -291,7 +289,7 @@ public void EndViewport(ViewportSurface viewport) // Bounds were floored to whole pixels in BeginViewport. ImagePoint destination = new((int)entry.Bounds.X, (int)entry.Bounds.Y); - this.Canvas.Mutate(x => x.DrawImage(entry.Image, destination, 1f)); + RasterCanvasCompat.DrawImage(this.Canvas, entry.Image, destination); viewport.Surface.Dispose(); } diff --git a/ACadSharp.Image/Rendering/ResolvedStyle.cs b/ACadSharp.Image/Rendering/ResolvedStyle.cs index f14b6e5..a5da684 100644 --- a/ACadSharp.Image/Rendering/ResolvedStyle.cs +++ b/ACadSharp.Image/Rendering/ResolvedStyle.cs @@ -11,7 +11,7 @@ namespace ACadSharp.Image.Rendering; /// /// The record doubles as the inheritance source for the entity's children: block contents and dimension geometry /// resolve their ByBlock attributes against the placing entity's resolved values, and inherit its document header, -/// since the clones Insert.Explode() produces in ACadSharp 3.7.1 belong to no document. +/// since the clones Insert.Explode() produces in ACadSharp 3.8.0 belong to no document. /// /// Resolved colour; never ByLayer or ByBlock. /// Resolved line weight; never ByLayer or ByBlock. diff --git a/ACadSharp.Image/Rendering/SplineRenderer.cs b/ACadSharp.Image/Rendering/SplineRenderer.cs index aa34a3d..866397a 100644 --- a/ACadSharp.Image/Rendering/SplineRenderer.cs +++ b/ACadSharp.Image/Rendering/SplineRenderer.cs @@ -53,14 +53,14 @@ public bool Draw(ImageRenderContext context, ImageStyle style, Spline spline) SurfacePoint[] points = new SurfacePoint[polygonalPoints.Count]; for (int i = 0; i < polygonalPoints.Count; i++) { - points[i] = context.ToSurfacePoint(polygonalPoints[i].Convert()); + points[i] = context.ToSurfacePoint((XY)polygonalPoints[i]); } context.Surface.DrawPolyline(style, points, ShouldClosePoints(points, spline.IsClosed || spline.IsPeriodic)); return true; } - // ACadSharp 3.7.1's UpdateFromFitPoints fills the knot vector but no control points, and DXF allows a spline + // ACadSharp 3.8.0's UpdateFromFitPoints fills the knot vector but no control points, and DXF allows a spline // defined by fit points alone; joining the fit points is a coarse but honest stand-in for the curve. if (spline.ControlPoints.Count == 0 && spline.FitPoints.Count > 1) { diff --git a/ACadSharp.Image/Rendering/TextRenderer.cs b/ACadSharp.Image/Rendering/TextRenderer.cs index b84885e..1d88fd0 100644 --- a/ACadSharp.Image/Rendering/TextRenderer.cs +++ b/ACadSharp.Image/Rendering/TextRenderer.cs @@ -22,7 +22,7 @@ internal sealed class TextRenderer /// The transform of the insert that placed the entity, or null at top level. /// /// An MTEXT insertion point and X axis are WCS in DXF. The insert transform is applied here, by the renderer, - /// because ACadSharp 3.7.1's Insert.Explode() moves the insertion point but leaves the X axis untouched, + /// because ACadSharp 3.8.0's Insert.Explode() moves the insertion point but leaves the X axis untouched, /// so an MTEXT inside a rotated insert would otherwise be drawn unrotated. /// public void Draw(ImageRenderContext context, ImageStyle style, MText mtext, Transform? placement) @@ -76,7 +76,7 @@ public void Draw(ImageRenderContext context, ImageStyle style, MText mtext, Tran /// The multi-line attribute or attribute definition. /// The transform of the insert that placed the entity, or null at top level. /// - /// When the embedded object is missing the single-line value is drawn instead, with a warning: ACadSharp 3.7.1's + /// When the embedded object is missing the single-line value is drawn instead, with a warning: ACadSharp 3.8.0's /// DWG reader only reads the attribute type for R2018 and later files, so an older drawing reports every /// attribute as single-line and never populates the embedded object. /// @@ -103,7 +103,7 @@ public void DrawAttribute(ImageRenderContext context, ImageStyle style, Attribut /// The transform of the insert that placed the entity, or null at top level. /// /// A TEXT entity's points and rotation live in its own OCS; the insert transform is applied after the OCS, by the - /// renderer, because ACadSharp 3.7.1's Insert.Explode() transforms the insertion point but not the + /// renderer, because ACadSharp 3.8.0's Insert.Explode() transforms the insertion point but not the /// alignment point, and mixes world points with a mirrored normal. /// public void Draw(ImageRenderContext context, ImageStyle style, TextEntity textEntity, Transform? placement) @@ -293,7 +293,8 @@ internal static string PlainTextOf(MText mtext) { string value = mtext.Value ?? string.Empty; string decoded = UnicodeEscape.Replace(value, m => ((char)Convert.ToInt32(m.Groups[1].Value, 16)).ToString()); - return decoded == value ? mtext.PlainText : new MText { Value = decoded }.PlainText; + string plain = decoded == value ? mtext.PlainText : new MText { Value = decoded }.PlainText; + return NormalizeLineEndings(plain); } /// @@ -309,7 +310,7 @@ internal static string NormalizeText(string? value) } string text = UnicodeEscape.Replace(value, m => ((char)Convert.ToInt32(m.Groups[1].Value, 16)).ToString()); - return text + return NormalizeLineEndings(text .Replace("%%%", "\u0001", StringComparison.Ordinal) .Replace("%%C", "Ø", StringComparison.OrdinalIgnoreCase) .Replace("%%D", "°", StringComparison.OrdinalIgnoreCase) @@ -317,6 +318,15 @@ internal static string NormalizeText(string? value) .Replace("%%U", string.Empty, StringComparison.OrdinalIgnoreCase) .Replace("%%O", string.Empty, StringComparison.OrdinalIgnoreCase) .Replace("\u0001", "%", StringComparison.Ordinal) - .Replace("\\P", "\n", StringComparison.OrdinalIgnoreCase); + .Replace("\\P", "\n", StringComparison.OrdinalIgnoreCase)); + } + + /// + /// Collapses the Windows line endings ACadSharp 3.8 emits for MTEXT paragraphs to the single \n the + /// layout backends expect. A stray \r is treated as a line break as well. + /// + private static string NormalizeLineEndings(string value) + { + return value.Replace("\r\n", "\n", StringComparison.Ordinal).Replace('\r', '\n'); } } diff --git a/Directory.Build.props b/Directory.Build.props index 5625beb..b0bbf64 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -10,5 +10,12 @@ true git https://github.com/slaveoftime/ACadSharp.Image.git + + + $(MSBuildThisFileDirectory)sixlabors.lic diff --git a/Directory.Packages.props b/Directory.Packages.props index c621af1..19beba3 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -4,13 +4,26 @@ - - - - - - + + + - + + + + + + + + + + + + + + diff --git a/README.md b/README.md index 14e1eb1..11b5a55 100644 --- a/README.md +++ b/README.md @@ -223,7 +223,7 @@ exporter.Configuration.HideLayer("A-DOOR"); // then remove on exporter.AddModelSpace(document); ``` -Filtering happens when rendering, so it also applies to block contents, dimension geometry and paper-space viewport contents. Entities on layer `0` inside a block take the layer of the insert that placed them, including its colour, line weight and linetype when theirs are ByLayer; ByBlock attributes resolve to the placing insert's own (colour 7 and defaults at top level). Text inside block references is placed through the insert's transform from the original entity, because ACadSharp 3.7.1 leaves TEXT alignment points and MTEXT directions untransformed when exploding. Rendering never modifies the pages, so changing filters between renders is safe. +Filtering happens when rendering, so it also applies to block contents, dimension geometry and paper-space viewport contents. Entities on layer `0` inside a block take the layer of the insert that placed them, including its colour, line weight and linetype when theirs are ByLayer; ByBlock attributes resolve to the placing insert's own (colour 7 and defaults at top level). Text inside block references is placed through the insert's transform from the original entity, because ACadSharp 3.8.0 leaves TEXT alignment points and MTEXT directions untransformed when exploding. Rendering never modifies the pages, so changing filters between renders is safe. ### Layer visibility @@ -253,7 +253,7 @@ The SVG has a drawing-unit `viewBox`, no `width`/`height` unless `Svg.EmitSize` SVG and PNG are built from the same geometry and never disagree on it, but they intentionally differ in fidelity: SVG keeps native arcs, Beziers and ``, while raster output tessellates curves and outlines glyphs. SVG text is sized and wrapped to match the PNG output; glyph shapes still depend on the viewer's fonts. Repeated spaces inside text are preserved in both outputs. `ImageConfiguration.Dpi` affects only line weights; text is sized from the drawing on both backends. Entities with a non-world extrusion normal (an OCS other than the default) are brought into world coordinates first: arcs, circles and ellipses through ACadSharp's own tessellation, polylines, hatches and solids through the renderer's OCS transform. Single-line TEXT on another plane is placed on the mirrored extent with readable glyphs; AutoCAD would draw the glyphs themselves mirrored, so this is a deliberate readability choice, not a parity guarantee. Text height follows the transformed up axis and width the transformed reading axis of whatever places the text, block reference or OCS plane. MTEXT and dimension geometry are already world coordinates in DXF; MTEXT is placed through the same projection (its height likewise follows the projected up-axis length) and dimensions need no transform. The available `Svg` options are `NonScalingStroke`, `EmitEntityAttributes`, `EmitSize`, `IdPrefix`, and `Precision`. -**Thread safety:** Rendering temporarily mutates block MLINEs and LEADERs while working around ACadSharp 3.7.1's destructive `MLine.Clone()` and `Leader.Clone()` and restores them before returning; a `CadDocument` must not be rendered concurrently by two exporters, and `Insert.Explode()` itself is not safe for concurrent use either. +**Thread safety:** Rendering temporarily mutates block MLINEs and LEADERs while working around ACadSharp 3.8.0's destructive `MLine.Clone()` and `Leader.Clone()` and restores them before returning; a `CadDocument` must not be rendered concurrently by two exporters, and `Insert.Explode()` itself is not safe for concurrent use either. ### Custom Line Weights @@ -358,6 +358,8 @@ Contributions are welcome! Please feel free to submit a Pull Request. This project is released under the [MIT License](LICENSE). +The raster backend depends on Six Labors ImageSharp, Fonts, and ImageSharp.Drawing. Their build-time license check reads the repository-level [`sixlabors.lic`](sixlabors.lic) community license, which is separate from this project's MIT license. + --- ## 🌟 Support diff --git a/sixlabors.lic b/sixlabors.lic new file mode 100644 index 0000000..f885c3d --- /dev/null +++ b/sixlabors.lic @@ -0,0 +1 @@ +Id=ctm_01m5dwn86nk2hw9avk9hyf7a7t;Kind=Community;ExpiryDateUtc=2028-01-19T00:09:28.6607550Z;Key=Assembly:VKfdK13p5Tvwgo9HpkHH4S42NUy64/bPgvhMBW/rjLlxlMah7v3UeseNAx2pGKoShqzDmIic5KFQdL241NnArA==|5qrK4Zgg0D7x2710KtAY6Zd0V8j40C2qsla6U3TIdeOfjQcG1pN8+nNKH3nOnGtwoGpDKAQqHGpd0fHG28QxJg== \ No newline at end of file