diff --git a/.gitattributes b/.gitattributes index 1ff0c42..46537f3 100644 --- a/.gitattributes +++ b/.gitattributes @@ -41,7 +41,7 @@ # image files are treated as binary by default. ############################################################################### #*.jpg binary -#*.png binary +*.png binary #*.gif binary ############################################################################### diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e2255a6..954b490 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,6 +6,9 @@ on: - 'ACadSharp.Image/**' - 'ACadSharp.Image.Cli/**' - 'ACadSharp.Image.Tests/**' + - 'Directory.Packages.props' + - 'Directory.Build.props' + - '*.sln' - '.github/workflows/ci.yml' push: branches: @@ -13,6 +16,10 @@ on: paths: - 'ACadSharp.Image/**' - 'ACadSharp.Image.Cli/**' + - 'ACadSharp.Image.Tests/**' + - 'Directory.Packages.props' + - 'Directory.Build.props' + - '*.sln' - '.github/workflows/ci.yml' jobs: @@ -26,12 +33,17 @@ jobs: uses: actions/setup-dotnet@v4 with: dotnet-version: | - 6.0.x 8.0.x 10.0.x - name: Restore run: dotnet restore ACadSharp.Image.sln + - name: Install parity font + run: sudo apt-get update && sudo apt-get install -y fonts-dejavu-core + + - name: Build + run: dotnet build ACadSharp.Image.sln --configuration Release --no-restore -warnaserror + - name: Test - run: dotnet test ACadSharp.Image.sln --configuration Release --no-restore + run: dotnet test ACadSharp.Image.sln --configuration Release --no-build diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4078fbc..37a6608 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -18,7 +18,6 @@ jobs: uses: actions/setup-dotnet@v4 with: dotnet-version: | - 6.0.x 8.0.x 10.0.x diff --git a/.gitignore b/.gitignore index 8656284..d5f96c4 100644 --- a/.gitignore +++ b/.gitignore @@ -361,4 +361,6 @@ MigrationBackup/ .ionide/ # Fody - auto-generated XML schema -FodyWeavers.xsd \ No newline at end of file +FodyWeavers.xsd +# CodeGraph index +.codegraph/ diff --git a/ACadSharp.Image.Cli/ACadSharp.Image.Cli.csproj b/ACadSharp.Image.Cli/ACadSharp.Image.Cli.csproj index 818d9c8..7143203 100644 --- a/ACadSharp.Image.Cli/ACadSharp.Image.Cli.csproj +++ b/ACadSharp.Image.Cli/ACadSharp.Image.Cli.csproj @@ -20,4 +20,8 @@ + + + + diff --git a/ACadSharp.Image.Cli/CliOptions.cs b/ACadSharp.Image.Cli/CliOptions.cs index f5692c0..58890ec 100644 --- a/ACadSharp.Image.Cli/CliOptions.cs +++ b/ACadSharp.Image.Cli/CliOptions.cs @@ -13,5 +13,13 @@ internal sealed record CliOptions( string BackgroundColor, int Quality, bool ExportPaperLayouts, - IReadOnlyList HideLayers + IReadOnlyList HideLayers, + bool SvgNoScalingStroke, + bool SvgNoEntityAttributes, + bool SvgEmitSize, + string SvgIdPrefix, + int? SvgPrecision, + LayerVisibilityMode? LayerVisibility, + IReadOnlyList OnlyLayers, + bool ListLayers ); diff --git a/ACadSharp.Image.Cli/Program.cs b/ACadSharp.Image.Cli/Program.cs index 9762238..3c52a64 100644 --- a/ACadSharp.Image.Cli/Program.cs +++ b/ACadSharp.Image.Cli/Program.cs @@ -5,17 +5,27 @@ namespace ACadSharp.Image.Cli; internal static class Program { - public static int Main(string[] args) + /// Entry point: runs the tool against the console. + public static int Main(string[] args) => Run(args, Console.Out, Console.Error); + + /// + /// Runs the tool with explicit writers so the output can be captured; passes the console. + /// + /// Command-line arguments. + /// Receives help, the layer table and the success line. + /// Receives reader and renderer notifications and the error line. + /// 0 on success, 1 on any handled error. + internal static int Run(string[] args, TextWriter output, TextWriter error) { try { if (args.Length == 0 || args.Any(IsHelpArgument)) { - WriteHelp(); + WriteHelp(output); return 0; } - var stopwatch = System.Diagnostics.Stopwatch.StartNew(); + System.Diagnostics.Stopwatch stopwatch = System.Diagnostics.Stopwatch.StartNew(); CliOptions options = ParseArgs(args); string inputPath = Path.GetFullPath(options.InputPath); @@ -24,14 +34,23 @@ public static int Main(string[] args) throw new FileNotFoundException("Input file was not found.", inputPath); } + // The format is resolved before the document is read, so a bad --format fails fast instead of + // after a long DWG parse. ImageExportFormat format = ResolveFormat(options); + + CadDocument document = LoadDocument(inputPath, error); + if (options.ListLayers) + { + WriteLayerTable(document, output); + return 0; + } + string outputPath = ResolveOutputPath(options, inputPath, format); ImageExporter exporter = new(); Configure(exporter.Configuration, options); - exporter.Configuration.OnNotification += OnExporterNotification; + exporter.Configuration.OnNotification += (_, e) => error.WriteLine($"render: {e.Message}"); - CadDocument document = LoadDocument(inputPath); if (options.ExportPaperLayouts) { exporter.AddPaperLayouts(document); @@ -43,15 +62,15 @@ public static int Main(string[] args) exporter.Save(outputPath, format); - Console.WriteLine($"Generated {Path.GetFullPath(outputPath)} in {stopwatch.ElapsedMilliseconds}ms"); + output.WriteLine($"Generated {Path.GetFullPath(outputPath)} in {stopwatch.ElapsedMilliseconds}ms"); return 0; } catch (Exception ex) when (!IsFatalException(ex)) { - Console.Error.WriteLine($"Error: {ex.Message}"); + error.WriteLine($"Error: {ex.Message}"); #if DEBUG - Console.Error.WriteLine(ex.StackTrace); + error.WriteLine(ex.StackTrace); #endif return 1; } @@ -80,18 +99,63 @@ private static void Configure(ImageConfiguration configuration, CliOptions optio { configuration.HideLayer(layer); } + + configuration.Svg.NonScalingStroke = !options.SvgNoScalingStroke; + configuration.Svg.EmitEntityAttributes = !options.SvgNoEntityAttributes; + configuration.Svg.EmitSize = options.SvgEmitSize; + configuration.Svg.IdPrefix = options.SvgIdPrefix; + configuration.Svg.Precision = options.SvgPrecision; + + foreach (string layer in options.OnlyLayers) + { + configuration.IncludeLayer(layer); + } + + if (options.LayerVisibility is not null) + { + configuration.LayerVisibility = options.LayerVisibility.Value; + } } - private static CadDocument LoadDocument(string inputPath) + private static CadDocument LoadDocument(string inputPath, TextWriter error) { return Path.GetExtension(inputPath).ToLowerInvariant() switch { - ".dxf" => DxfReader.Read(inputPath, OnReaderNotification), - ".dwg" => DwgReader.Read(inputPath, OnReaderNotification), + ".dxf" => DxfReader.Read(inputPath, (_, e) => OnReaderNotification(e, error)), + ".dwg" => DwgReader.Read(inputPath, (_, e) => OnReaderNotification(e, error)), _ => throw new InvalidOperationException("Unsupported input format. Use a .dxf or .dwg file."), }; } + /// Writes a human-readable layer table for to . + internal static void WriteLayerTable(CadDocument document, TextWriter writer) + { + // ACadSharp always keeps layer "0", so the table is never empty. + List layers = document.Layers.OrderBy(l => l.Name, StringComparer.OrdinalIgnoreCase).ToList(); + + Dictionary counts = new(StringComparer.OrdinalIgnoreCase); + foreach (ACadSharp.Entities.Entity entity in document.ModelSpace.Entities) + { + string name = entity.Layer?.Name ?? "0"; + counts[name] = counts.TryGetValue(name, out int count) ? count + 1 : 1; + } + + int nameWidth = Math.Max(5, layers.Max(l => l.Name.Length)); + int lineTypeWidth = Math.Max(8, layers.Max(l => (l.LineType?.Name ?? "-").Length)); + int weightWidth = Math.Max(6, layers.Max(l => l.LineWeight.ToString().Length)); + + writer.WriteLine($"{"Layer".PadRight(nameWidth)} On Frozen Plot Color {"Weight".PadRight(weightWidth)} {"Linetype".PadRight(lineTypeWidth)} Entities"); + foreach (ACadSharp.Tables.Layer layer in layers) + { + string color = layer.Color.IsTrueColor + ? $"#{layer.Color.R:x2}{layer.Color.G:x2}{layer.Color.B:x2}" + : layer.Color.Index.ToString(CultureInfo.InvariantCulture); + counts.TryGetValue(layer.Name, out int count); + writer.WriteLine( + $"{layer.Name.PadRight(nameWidth)} {(layer.IsOn ? "yes" : "no ")} {(layer.Flags.HasFlag(ACadSharp.Tables.LayerFlags.Frozen) ? "yes " : "no ")} {(layer.PlotFlag ? "yes " : "no ")} {color.PadRight(11)} {layer.LineWeight.ToString().PadRight(weightWidth)} {(layer.LineType?.Name ?? "-").PadRight(lineTypeWidth)} {count}"); + } + } + private static SixLabors.ImageSharp.Color ParseColor(string value) { try @@ -104,7 +168,8 @@ private static SixLabors.ImageSharp.Color ParseColor(string value) } } - private static ImageExportFormat ResolveFormat(CliOptions options) + /// Resolves the export format from an explicit --format, else the output path's extension, else . + internal static ImageExportFormat ResolveFormat(CliOptions options) { if (!string.IsNullOrWhiteSpace(options.Format)) { @@ -124,7 +189,8 @@ private static ImageExportFormat ResolveFormat(CliOptions options) return ImageExportFormat.Png; } - private static string ResolveOutputPath(CliOptions options, string inputPath, ImageExportFormat format) + /// Resolves the output path from an explicit --output, else the input path with the format's extension. + internal static string ResolveOutputPath(CliOptions options, string inputPath, ImageExportFormat format) { if (!string.IsNullOrWhiteSpace(options.OutputPath)) { @@ -134,7 +200,8 @@ private static string ResolveOutputPath(CliOptions options, string inputPath, Im return Path.ChangeExtension(inputPath, format.GetFileExtension()); } - private static CliOptions ParseArgs(IReadOnlyList args) + /// Parses command-line arguments into ; throws for unknown or invalid arguments. + internal static CliOptions ParseArgs(IReadOnlyList args) { string? inputPath = null; string? outputPath = null; @@ -149,13 +216,26 @@ private static CliOptions ParseArgs(IReadOnlyList args) int quality = 90; bool exportPaperLayouts = false; List hideLayers = new(); + bool svgNoScalingStroke = false; + bool svgNoEntityAttributes = false; + bool svgEmitSize = false; + string svgIdPrefix = string.Empty; + int? svgPrecision = null; + LayerVisibilityMode? layerVisibility = null; + List onlyLayers = new(); + bool listLayers = false; for (int i = 0; i < args.Count; i++) { string current = args[i]; if (!current.StartsWith('-')) { - inputPath ??= current; + if (inputPath != null) + { + throw new InvalidOperationException($"Unexpected argument '{current}'."); + } + + inputPath = current; continue; } @@ -195,6 +275,30 @@ private static CliOptions ParseArgs(IReadOnlyList args) case "--hide-layer": hideLayers.Add(GetRequiredValue(args, ref i, current)); break; + case "--svg-no-scaling-stroke": + svgNoScalingStroke = true; + break; + case "--svg-no-entity-attributes": + svgNoEntityAttributes = true; + break; + case "--svg-size": + svgEmitSize = true; + break; + case "--svg-id-prefix": + svgIdPrefix = GetRequiredValue(args, ref i, current); + break; + case "--svg-precision": + svgPrecision = ParseRange(GetRequiredValue(args, ref i, current), current, 0, 8); + break; + case "--layer-visibility": + layerVisibility = ParseLayerVisibility(GetRequiredValue(args, ref i, current)); + break; + case "--only-layer": + onlyLayers.Add(GetRequiredValue(args, ref i, current)); + break; + case "--list-layers": + listLayers = true; + break; default: throw new InvalidOperationException($"Unknown argument '{current}'."); } @@ -205,7 +309,32 @@ private static CliOptions ParseArgs(IReadOnlyList args) throw new InvalidOperationException("An input .dxf or .dwg file is required."); } - return new CliOptions(inputPath, outputPath, format, width, height, paddingLeft, paddingTop, paddingRight, paddingBottom, backgroundColor, quality, exportPaperLayouts, hideLayers); + return new CliOptions(inputPath, outputPath, format, width, height, paddingLeft, paddingTop, paddingRight, paddingBottom, backgroundColor, quality, exportPaperLayouts, hideLayers, svgNoScalingStroke, svgNoEntityAttributes, svgEmitSize, svgIdPrefix, svgPrecision, layerVisibility, onlyLayers, listLayers); + } + + private static LayerVisibilityMode ParseLayerVisibility(string? value) + { + switch (value?.Trim().ToLowerInvariant()) + { + case "all": + return LayerVisibilityMode.All; + case "screen": + return LayerVisibilityMode.Screen; + case "plot": + return LayerVisibilityMode.Plot; + default: + throw new InvalidOperationException($"Invalid --layer-visibility '{value}'. Use all, screen or plot."); + } + } + + private static int ParseRange(string value, string argumentName, int min, int max) + { + if (int.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out int parsed) && parsed >= min && parsed <= max) + { + return parsed; + } + + throw new InvalidOperationException($"Argument {argumentName} must be between {min} and {max}."); } private static int ParsePositiveInt(string value, string argumentName) @@ -267,19 +396,14 @@ private static string GetRequiredValue(IReadOnlyList args, ref int index return args[index]; } - private static void OnReaderNotification(object? sender, NotificationEventArgs e) + private static void OnReaderNotification(NotificationEventArgs e, TextWriter error) { if (e.NotificationType is NotificationType.None or NotificationType.Warning or NotificationType.NotImplemented) { return; } - Console.Error.WriteLine($"reader: {e.Message}"); - } - - private static void OnExporterNotification(object? sender, NotificationEventArgs e) - { - Console.Error.WriteLine($"render: {e.Message}"); + error.WriteLine($"reader: {e.Message}"); } private static bool IsHelpArgument(string value) => @@ -287,15 +411,15 @@ private static bool IsHelpArgument(string value) => value.Equals("--help", StringComparison.OrdinalIgnoreCase) || value.Equals("-?", StringComparison.OrdinalIgnoreCase); - private static void WriteHelp() + private static void WriteHelp(TextWriter output) { - Console.WriteLine(""" + output.WriteLine(""" Usage: cad-to-image [options] Options: -o, --output Output file or directory path. - -f, --format png, bmp, jpg, jpeg, gif, webp. + -f, --format png, bmp, jpg, jpeg, gif, webp, svg. -w, --width Output width in pixels. Default: 1600. -H, --height Output height in pixels. Default: 900. -p, --padding Padding in pixels: , , or . @@ -303,6 +427,15 @@ private static void WriteHelp() -q, --quality <1-100> Output quality for lossy formats. Default: 90. --paper-layouts Export paper layouts instead of model space. --hide-layer Hide entities on the specified layer. Can be used multiple times. + --only-layer Render only the specified layer(s). Can be used multiple times. + --layer-visibility all (default), screen (honour off/frozen), or plot (also honour non-plottable). + --list-layers Print the drawing's layers and exit without rendering. + --svg-no-scaling-stroke Write SVG stroke widths in drawing units instead of constant pixels. + --svg-no-entity-attributes + Omit data-handle/data-type/data-parent/data-block attributes from SVG. + --svg-size Emit width/height on the SVG root from --width/--height. + --svg-id-prefix Prefix for SVG ids so several drawings can share one page. + --svg-precision <0-8> Decimal places for SVG coordinates. Default: adaptive. --help, -h, -? Show this help text. """); } diff --git a/ACadSharp.Image.Tests/ACadSharp.Image.Tests.csproj b/ACadSharp.Image.Tests/ACadSharp.Image.Tests.csproj index 5662a60..7343795 100644 --- a/ACadSharp.Image.Tests/ACadSharp.Image.Tests.csproj +++ b/ACadSharp.Image.Tests/ACadSharp.Image.Tests.csproj @@ -25,6 +25,7 @@ + diff --git a/ACadSharp.Image.Tests/Baselines/6-57-1119.model.01.png b/ACadSharp.Image.Tests/Baselines/6-57-1119.model.01.png new file mode 100644 index 0000000..391bb12 Binary files /dev/null and b/ACadSharp.Image.Tests/Baselines/6-57-1119.model.01.png differ diff --git a/ACadSharp.Image.Tests/Baselines/6-57-1119.model.01.svg b/ACadSharp.Image.Tests/Baselines/6-57-1119.model.01.svg new file mode 100644 index 0000000..10f8f2e --- /dev/null +++ b/ACadSharp.Image.Tests/Baselines/6-57-1119.model.01.svg @@ -0,0 +1,502 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 30° + + + + + 2Z + + + + + + + + + + + + + 57 + + + + + + + + + + + 30 + + + + + + + + + + + 2.5 + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/ACadSharp.Image.Tests/Baselines/HSK80AHCP16190M_BMG.model.01.png b/ACadSharp.Image.Tests/Baselines/HSK80AHCP16190M_BMG.model.01.png new file mode 100644 index 0000000..4b1aa3c Binary files /dev/null and b/ACadSharp.Image.Tests/Baselines/HSK80AHCP16190M_BMG.model.01.png differ diff --git a/ACadSharp.Image.Tests/Baselines/HSK80AHCP16190M_BMG.model.01.svg b/ACadSharp.Image.Tests/Baselines/HSK80AHCP16190M_BMG.model.01.svg new file mode 100644 index 0000000..48f0fe3 --- /dev/null +++ b/ACadSharp.Image.Tests/Baselines/HSK80AHCP16190M_BMG.model.01.svg @@ -0,0 +1,807 @@ + + + + + + + + + + + Ø16 + + + + + + Ø37.54 + + + + + + + 49 + Max Stop length + + + + + + + 190 + + + + + + 125 + + + + + + + Ø49.85 + + + + + + + + HSK A-80 + + + + HYDRAULIC CHUCK + HSK80AHCP16190M + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/ACadSharp.Image.Tests/Baselines/HSK80AHCP16190M_BMG.paper.01.png b/ACadSharp.Image.Tests/Baselines/HSK80AHCP16190M_BMG.paper.01.png new file mode 100644 index 0000000..1ce18b3 Binary files /dev/null and b/ACadSharp.Image.Tests/Baselines/HSK80AHCP16190M_BMG.paper.01.png differ diff --git a/ACadSharp.Image.Tests/Baselines/HSK80AHCP16190M_BMG.paper.01.svg b/ACadSharp.Image.Tests/Baselines/HSK80AHCP16190M_BMG.paper.01.svg new file mode 100644 index 0000000..7452236 --- /dev/null +++ b/ACadSharp.Image.Tests/Baselines/HSK80AHCP16190M_BMG.paper.01.svg @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file 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 new file mode 100644 index 0000000..0dbb6e6 Binary files /dev/null and b/ACadSharp.Image.Tests/Baselines/Subaru-Logo-Vector-Free-Wrap.model.01.png differ diff --git a/ACadSharp.Image.Tests/Baselines/Subaru-Logo-Vector-Free-Wrap.model.01.svg b/ACadSharp.Image.Tests/Baselines/Subaru-Logo-Vector-Free-Wrap.model.01.svg new file mode 100644 index 0000000..bd6c2af --- /dev/null +++ b/ACadSharp.Image.Tests/Baselines/Subaru-Logo-Vector-Free-Wrap.model.01.svg @@ -0,0 +1,29 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/ACadSharp.Image.Tests/Baselines/entities.model.01.png b/ACadSharp.Image.Tests/Baselines/entities.model.01.png new file mode 100644 index 0000000..261fb3c Binary files /dev/null and b/ACadSharp.Image.Tests/Baselines/entities.model.01.png differ diff --git a/ACadSharp.Image.Tests/Baselines/entities.model.01.svg b/ACadSharp.Image.Tests/Baselines/entities.model.01.svg new file mode 100644 index 0000000..70fef76 --- /dev/null +++ b/ACadSharp.Image.Tests/Baselines/entities.model.01.svg @@ -0,0 +1,30 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + A-101 + + + + \ No newline at end of file diff --git a/ACadSharp.Image.Tests/Baselines/features.model.01.png b/ACadSharp.Image.Tests/Baselines/features.model.01.png new file mode 100644 index 0000000..0ccbf27 Binary files /dev/null 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 new file mode 100644 index 0000000..55bc979 --- /dev/null +++ b/ACadSharp.Image.Tests/Baselines/features.model.01.svg @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Line1 + Line2 + + FIT + + + + \ No newline at end of file diff --git a/ACadSharp.Image.Tests/Baselines/fidelity.model.01.png b/ACadSharp.Image.Tests/Baselines/fidelity.model.01.png new file mode 100644 index 0000000..8bad877 Binary files /dev/null and b/ACadSharp.Image.Tests/Baselines/fidelity.model.01.png differ diff --git a/ACadSharp.Image.Tests/Baselines/fidelity.model.01.svg b/ACadSharp.Image.Tests/Baselines/fidelity.model.01.svg new file mode 100644 index 0000000..768a3b4 --- /dev/null +++ b/ACadSharp.Image.Tests/Baselines/fidelity.model.01.svg @@ -0,0 +1,33 @@ + + + + + + + Room 1 + Level 2 + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/ACadSharp.Image.Tests/Baselines/viewport-sheet.paper.01.png b/ACadSharp.Image.Tests/Baselines/viewport-sheet.paper.01.png new file mode 100644 index 0000000..d0dc229 Binary files /dev/null and b/ACadSharp.Image.Tests/Baselines/viewport-sheet.paper.01.png differ diff --git a/ACadSharp.Image.Tests/Baselines/viewport-sheet.paper.01.svg b/ACadSharp.Image.Tests/Baselines/viewport-sheet.paper.01.svg new file mode 100644 index 0000000..e542109 --- /dev/null +++ b/ACadSharp.Image.Tests/Baselines/viewport-sheet.paper.01.svg @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + SHEET 1 + + + + \ No newline at end of file diff --git a/ACadSharp.Image.Tests/CliTests.cs b/ACadSharp.Image.Tests/CliTests.cs new file mode 100644 index 0000000..9035d5f --- /dev/null +++ b/ACadSharp.Image.Tests/CliTests.cs @@ -0,0 +1,231 @@ +using ACadSharp.Entities; +using ACadSharp.Image.Cli; +using ACadSharp.Tables; +using CSMath; + +namespace ACadSharp.Image.Tests; + +/// Tests the CLI's argument parsing, format resolution, layer table and entry point through captured writers. +public sealed class CliTests +{ + [Fact] + public void ParseArgsAppliesDefaults() + { + CliOptions options = Program.ParseArgs(["plan.dxf"]); + + Assert.Equal("plan.dxf", options.InputPath); + Assert.Null(options.OutputPath); + Assert.Null(options.Format); + Assert.Equal(ImageConfiguration.DefaultWidth, options.Width); + Assert.Equal(ImageConfiguration.DefaultHeight, options.Height); + Assert.Equal((0, 0, 0, 0), (options.PaddingLeft, options.PaddingTop, options.PaddingRight, options.PaddingBottom)); + Assert.Equal("white", options.BackgroundColor); + Assert.Equal(90, options.Quality); + Assert.False(options.ExportPaperLayouts); + Assert.Empty(options.HideLayers); + Assert.Empty(options.OnlyLayers); + Assert.False(options.SvgNoScalingStroke); + Assert.False(options.SvgNoEntityAttributes); + Assert.False(options.SvgEmitSize); + Assert.Equal(string.Empty, options.SvgIdPrefix); + Assert.Null(options.SvgPrecision); + Assert.Null(options.LayerVisibility); + Assert.False(options.ListLayers); + } + + [Fact] + public void ParseArgsReadsEveryOption() + { + CliOptions options = Program.ParseArgs([ + "plan.dwg", "-o", "out/plan.svg", "-f", "svg", "-w", "640", "-H", "480", "-p", "1,2,3,4", "-b", "#202020", "-q", "75", + "--paper-layouts", "--hide-layer", "A-DOOR", "--hide-layer", "A-GLAZ", "--only-layer", "A-WALL", "--only-layer", "A-DOOR", + "--layer-visibility", "Plot", "--list-layers", "--svg-no-scaling-stroke", "--svg-no-entity-attributes", "--svg-size", + "--svg-id-prefix", "p1-", "--svg-precision", "3", + ]); + + Assert.Equal("plan.dwg", options.InputPath); + Assert.Equal("out/plan.svg", options.OutputPath); + Assert.Equal("svg", options.Format); + Assert.Equal(640, options.Width); + Assert.Equal(480, options.Height); + Assert.Equal((1, 2, 3, 4), (options.PaddingLeft, options.PaddingTop, options.PaddingRight, options.PaddingBottom)); + Assert.Equal("#202020", options.BackgroundColor); + Assert.Equal(75, options.Quality); + Assert.True(options.ExportPaperLayouts); + Assert.Equal(["A-DOOR", "A-GLAZ"], options.HideLayers); + Assert.Equal(["A-WALL", "A-DOOR"], options.OnlyLayers); + Assert.Equal(LayerVisibilityMode.Plot, options.LayerVisibility); + Assert.True(options.ListLayers); + Assert.True(options.SvgNoScalingStroke); + Assert.True(options.SvgNoEntityAttributes); + Assert.True(options.SvgEmitSize); + Assert.Equal("p1-", options.SvgIdPrefix); + Assert.Equal(3, options.SvgPrecision); + } + + [Theory] + [InlineData("8", 8, 8, 8, 8)] + [InlineData("4,6", 4, 6, 4, 6)] + [InlineData("1,2,3,4", 1, 2, 3, 4)] + public void ParseArgsAcceptsThePaddingForms(string value, int left, int top, int right, int bottom) + { + CliOptions options = Program.ParseArgs(["a.dxf", "--padding", value]); + + Assert.Equal((left, top, right, bottom), (options.PaddingLeft, options.PaddingTop, options.PaddingRight, options.PaddingBottom)); + } + + [Theory] + [InlineData("--bogus")] + [InlineData("--width", "0")] + [InlineData("--width", "abc")] + [InlineData("--quality", "101")] + [InlineData("--padding", "1,2,3")] + [InlineData("--padding", "-1")] + [InlineData("--svg-precision", "9")] + [InlineData("--layer-visibility", "hidden")] + [InlineData("--layer-visibility", "1")] + [InlineData("--output")] + [InlineData("b.dxf")] + public void ParseArgsRejectsInvalidArguments(params string[] tail) + { + List args = ["a.dxf", .. tail]; + + Assert.Throws(() => Program.ParseArgs(args)); + } + + [Fact] + public void ParseArgsRequiresAnInputFile() + { + InvalidOperationException ex = Assert.Throws(() => Program.ParseArgs(["--width", "10"])); + + Assert.Contains("input", ex.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void LayerVisibilityIsCaseInsensitiveButNotNumeric() + { + Assert.Equal(LayerVisibilityMode.Screen, Program.ParseArgs(["a.dxf", "--layer-visibility", "SCREEN"]).LayerVisibility); + Assert.Equal(LayerVisibilityMode.All, Program.ParseArgs(["a.dxf", "--layer-visibility", " all "]).LayerVisibility); + Assert.Throws(() => Program.ParseArgs(["a.dxf", "--layer-visibility", "2"])); + } + + [Fact] + public void ResolveFormatPrefersExplicitThenExtensionThenPng() + { + Assert.Equal(ImageExportFormat.Svg, Program.ResolveFormat(Program.ParseArgs(["a.dxf", "-f", "svg", "-o", "x.png"]))); + Assert.Equal(ImageExportFormat.Jpeg, Program.ResolveFormat(Program.ParseArgs(["a.dxf", "-o", "x.jpg"]))); + Assert.Equal(ImageExportFormat.Svg, Program.ResolveFormat(Program.ParseArgs(["a.dxf", "-o", "x.SVG"]))); + Assert.Equal(ImageExportFormat.Png, Program.ResolveFormat(Program.ParseArgs(["a.dxf"]))); + Assert.Equal(ImageExportFormat.Png, Program.ResolveFormat(Program.ParseArgs(["a.dxf", "-o", "outdir"]))); + InvalidOperationException ex = Assert.Throws(() => Program.ResolveFormat(Program.ParseArgs(["a.dxf", "-f", "tiff"]))); + Assert.Contains("tiff", ex.Message, StringComparison.Ordinal); + } + + [Fact] + public void ResolveOutputPathUsesTheFormatExtensionWhenNoOutputIsGiven() + { + string input = Path.Combine(Path.GetTempPath(), "drawing.dxf"); + + Assert.Equal(Path.ChangeExtension(input, ".svg"), Program.ResolveOutputPath(Program.ParseArgs([input]), input, ImageExportFormat.Svg)); + Assert.Equal(Path.GetFullPath("out.png"), Program.ResolveOutputPath(Program.ParseArgs([input, "-o", "out.png"]), input, ImageExportFormat.Png)); + } + + [Fact] + public void WriteLayerTableAlignsColumnsAndCountsModelSpaceEntities() + { + CadDocument document = new(); + Layer walls = new("A-WALL-INTERIOR") { Color = new ACadSharp.Color(1), LineWeight = LineWeightType.W50 }; + Layer notes = new("N") { Color = new ACadSharp.Color(0x10, 0x20, 0x30), IsOn = false, PlotFlag = false }; + document.Layers.Add(walls); + document.Layers.Add(notes); + document.Entities.Add(new Line(new XYZ(0, 0, 0), new XYZ(1, 0, 0)) { Layer = walls }); + document.Entities.Add(new Line(new XYZ(0, 0, 0), new XYZ(0, 1, 0)) { Layer = walls }); + + StringWriter writer = new(); + Program.WriteLayerTable(document, writer); + string[] lines = writer.ToString().Split('\n', StringSplitOptions.RemoveEmptyEntries).Select(l => l.TrimEnd('\r')).ToArray(); + + // Header, then "0", "A-WALL-INTERIOR" and "N" sorted case-insensitively. + Assert.Equal(4, lines.Length); + Assert.StartsWith("Layer On Frozen Plot Color Weight Linetype Entities", lines[0]); + Assert.StartsWith("0 yes no yes 7", lines[1]); + Assert.StartsWith("A-WALL-INTERIOR yes no yes 1 W50", lines[2]); + Assert.EndsWith(" 2", lines[2]); + Assert.StartsWith("N no no no #102030", lines[3]); + Assert.EndsWith(" 0", lines[3]); + // Every column except the trailing entity count is padded to a fixed width, and every count here ("7", + // "2", "0") is a single digit, so all three data rows come out the same length. + Assert.Single(lines.Skip(1).Select(l => l.Length).Distinct()); + } + + [Fact] + public void RunReturnsOneAndReportsAMissingInputFileOnTheErrorWriter() + { + string missing = Path.Combine(Path.GetTempPath(), $"missing-{Guid.NewGuid():N}.dxf"); + StringWriter output = new(); + StringWriter error = new(); + + int exitCode = Program.Run([missing], output, error); + + Assert.Equal(1, exitCode); + Assert.Equal(string.Empty, output.ToString()); + Assert.StartsWith("Error: Input file was not found.", error.ToString(), StringComparison.Ordinal); + } + + [Fact] + public void RunRejectsAnExtraPositionalArgument() + { + // The input need not exist: ParseArgs runs, and fails, before Run checks File.Exists. + StringWriter output = new(); + StringWriter error = new(); + + int exitCode = Program.Run(["a.dxf", "b.dxf"], output, error); + + Assert.Equal(1, exitCode); + Assert.Contains("Unexpected argument 'b.dxf'", error.ToString(), StringComparison.Ordinal); + } + + [Fact] + public void RunWritesHelpToTheOutputWriterAndReturnsZero() + { + StringWriter output = new(); + StringWriter error = new(); + + Assert.Equal(0, Program.Run([], output, error)); + Assert.Equal(0, Program.Run(["--help"], output, error)); + + Assert.Contains("Usage:", output.ToString(), StringComparison.Ordinal); + Assert.Contains("--list-layers", output.ToString(), StringComparison.Ordinal); + Assert.Equal(string.Empty, error.ToString()); + } + + [Fact] + public void RunRejectsAnUnknownFormatBeforeReadingTheDocument() + { + string repoRoot = SampleParityTests.FindRepoRoot(); + string sample = Path.Combine(repoRoot, "Samples", "6-57-1119.dxf"); + StringWriter output = new(); + StringWriter error = new(); + + int exitCode = Program.Run([sample, "-f", "tiff"], output, error); + + Assert.Equal(1, exitCode); + Assert.Contains("Unsupported output format 'tiff'", error.ToString(), StringComparison.Ordinal); + } + + [Fact] + public void RunListsLayersOnTheOutputWriterWithoutRendering() + { + string repoRoot = SampleParityTests.FindRepoRoot(); + string sample = Path.Combine(repoRoot, "Samples", "6-57-1119.dxf"); + string outputPath = Path.Combine(Path.GetTempPath(), $"not-written-{Guid.NewGuid():N}.png"); + StringWriter output = new(); + StringWriter error = new(); + + int exitCode = Program.Run([sample, "--list-layers", "-o", outputPath], output, error); + + Assert.Equal(0, exitCode); + Assert.StartsWith("Layer", output.ToString(), StringComparison.Ordinal); + Assert.False(File.Exists(outputPath)); + } +} diff --git a/ACadSharp.Image.Tests/ColorResolutionTests.cs b/ACadSharp.Image.Tests/ColorResolutionTests.cs new file mode 100644 index 0000000..e738d5e --- /dev/null +++ b/ACadSharp.Image.Tests/ColorResolutionTests.cs @@ -0,0 +1,31 @@ +using ACadSharp.Image.Extensions; +using SixLabors.ImageSharp; + +namespace ACadSharp.Image.Tests; + +using Color = SixLabors.ImageSharp.Color; + +public sealed class ColorResolutionTests +{ + [Fact] + public void Index7IsBlackOnLightAndWhiteOnDarkBackgrounds() + { + ImageConfiguration light = new(); + ImageConfiguration dark = new() { BackgroundColor = Color.FromRgb(20, 20, 40) }; + ImageConfiguration transparent = new() { BackgroundColor = Color.Transparent }; + + Assert.Equal(Color.Black, light.ResolveForegroundColor()); + Assert.Equal(Color.White, dark.ResolveForegroundColor()); + Assert.Equal(Color.Black, transparent.ResolveForegroundColor()); + } + + [Fact] + public void ExplicitForegroundWins() + { + ImageConfiguration configuration = new() { BackgroundColor = Color.Black, ForegroundColor = Color.Yellow }; + + 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())); + } +} diff --git a/ACadSharp.Image.Tests/EntityGoldenTests.cs b/ACadSharp.Image.Tests/EntityGoldenTests.cs new file mode 100644 index 0000000..52876b3 --- /dev/null +++ b/ACadSharp.Image.Tests/EntityGoldenTests.cs @@ -0,0 +1,107 @@ +using System.Xml.Linq; +using ACadSharp.Image.Rendering; +using ACadSharp.Image.Rendering.Svg; +using ACadSharp.IO; +using CSMath; +using SixLabors.Fonts; +using SixLabors.ImageSharp.PixelFormats; + +namespace ACadSharp.Image.Tests; + +/// +/// Renders (the entities added after the feature goldens were written: +/// 3DFACE, LEADER, MLINE, WIPEOUT and a block ATTRIB) through both real backends and compares the results with +/// their baselines. Mirrors . +/// +public sealed class EntityGoldenTests +{ + private const string FontFamily = "DejaVu Sans"; + private static readonly XNamespace Ns = SvgDrawingSurface.Ns; + + private static ImageExporter EntityExporter() + { + ImageExporter exporter = new(); + exporter.Configuration.Width = 800; + exporter.Configuration.Height = 500; + exporter.Configuration.SetPadding(10); + exporter.Configuration.FontFamilyName = FontFamily; + exporter.Add(SyntheticSamples.EntityBlock()); + return exporter; + } + + [Fact] + public void EntityPngMatchesBaseline() + { + Assert.True(SystemFonts.TryGet(FontFamily, out _), $"Font '{FontFamily}' must be installed for parity tests."); + ImageExporter exporter = EntityExporter(); + + using RenderedImagePage page = Assert.IsType(Assert.Single(exporter.Render())); + + GoldenAssert.Png("entities.model.01", page.Canvas); + + // Raster occlusion: the WIPEOUT (world x in [70,90], y in [25,35]) masks the "Under" line (y=30, x in + // [60,100]) where the two overlap, and leaves it visible outside that range. This is the one assertion the + // SVG cannot make: SVG groups content by layer, so the Line/Wipeout paint order there is the layer-group + // order, not the entities' own draw order, but the raster canvas paints them in the page's true draw order. + // CreatePageContext(surface, ImagePage, …) resolves to PageFrame.Of(page), which is also what + // ImagePageRenderer.ResolveFrame returns as long as HasActiveFilters() is false; EntityExporter() sets no + // included/hidden layers and leaves LayerVisibility at its default, so the two fits coincide here. A filter + // added to the exporter later would desync this reconstructed fit from the one the render actually used. + ImageRenderContext context = ImageRenderContext.CreatePageContext(new RecordingDrawingSurface(), exporter.Pages[0], exporter.Configuration); + SurfacePoint covered = context.ToSurfacePoint(new XY(80, 30)); + SurfacePoint exposed = context.ToSurfacePoint(new XY(65, 30)); + + Rgba32 white = SixLabors.ImageSharp.Color.White.ToPixel(); + Assert.Equal(white, GoldenAssert.DarkestPixelNear(page.Canvas, covered)); + Assert.NotEqual(white, GoldenAssert.DarkestPixelNear(page.Canvas, exposed)); + } + + [Fact] + public void EntitySvgMatchesGoldenAndContainsEveryEntity() + { + ImageExporter exporter = EntityExporter(); + List notifications = new(); + exporter.Configuration.OnNotification += (_, e) => notifications.Add(e); + + RenderedSvgPage page = Assert.IsType(Assert.Single(exporter.Render(ImageExportFormat.Svg))); + GoldenAssert.Svg("entities.model.01", page.Content); + + Assert.DoesNotContain(notifications, n => n.NotificationType is NotificationType.Warning or NotificationType.NotImplemented); + XDocument document = XDocument.Parse(page.Content); + + // 3DFACE: one hidden edge (Third) leaves one open run of the other three edges, so it draws as a single + // 4-point (open) polyline. Scoped to its own layer so the leaders' and mline's own polylines cannot count. + XElement faceGroup = Assert.Single(document.Descendants(Ns + "g"), g => (string?)g.Attribute("data-layer") == "Face"); + XElement facePolyline = Assert.Single(faceGroup.Elements(Ns + "polyline")); + // Points are serialised as "x y x y ...", two tokens per point: 4 points is exactly 8 tokens. + Assert.Equal(8, ((string?)facePolyline.Attribute("points"))!.Split(' ', StringSplitOptions.RemoveEmptyEntries).Length); + + // LEADER: both leaders have ArrowHeadEnabled, so each draws one arrowhead polygon; the splined leader's own + // path is a cubic Bezier chain (a "C" command), the straight leader's a polyline. + List leaderPolygons = document.Descendants(Ns + "polygon").Where(p => (string?)p.Attribute("data-type") == "LEADER").ToList(); + Assert.Equal(2, leaderPolygons.Count); + Assert.Contains(document.Descendants(Ns + "polyline"), l => (string?)l.Attribute("data-type") == "LEADER"); + List leaderPaths = document.Descendants(Ns + "path").Where(p => (string?)p.Attribute("data-type") == "LEADER").ToList(); + Assert.Single(leaderPaths, p => ((string?)p.Attribute("d"))!.Contains('C')); + + // MLINE: a fill-on style rings the band between its two outer elements (colour 3 = green) and draws each + // element as its own polyline (colours 1 = red, 5 = blue), scoped to the mline's own layer. + XElement wallGroup = Assert.Single(document.Descendants(Ns + "g"), g => (string?)g.Attribute("data-layer") == "Wall"); + XElement mlineFill = Assert.Single(wallGroup.Elements(Ns + "polygon")); + Assert.Equal("#00ff00", (string?)mlineFill.Attribute("fill")); + List mlineLines = wallGroup.Elements(Ns + "polyline").ToList(); + Assert.Equal(2, mlineLines.Count); + Assert.Contains(mlineLines, l => (string?)l.Attribute("stroke") == "#ff0000"); + Assert.Contains(mlineLines, l => (string?)l.Attribute("stroke") == "#0000ff"); + + // WIPEOUT: an opaque fill of the (white) background colour, on the wipeout's own layer. + XElement coverGroup = Assert.Single(document.Descendants(Ns + "g"), g => (string?)g.Attribute("data-layer") == "Cover"); + XElement wipeoutFill = Assert.Single(coverGroup.Elements(Ns + "polygon")); + Assert.Equal("#ffffff", (string?)wipeoutFill.Attribute("fill")); + + // ATTRIB: the constant-attribute path is exercised by EntityRenderDispatcherTests; here the value carried by + // the INSERT's own ATTRIB, tagged with the insert's handle as its parent. + XElement attributeText = Assert.Single(document.Descendants(Ns + "text"), t => t.Value == "A-101"); + Assert.NotNull(attributeText.Attribute("data-parent")); + } +} diff --git a/ACadSharp.Image.Tests/EntityRenderDispatcherTests.cs b/ACadSharp.Image.Tests/EntityRenderDispatcherTests.cs new file mode 100644 index 0000000..2fe755a --- /dev/null +++ b/ACadSharp.Image.Tests/EntityRenderDispatcherTests.cs @@ -0,0 +1,2745 @@ +using System.Reflection; +using System.Xml.Linq; +using ACadSharp.Entities; +using ACadSharp.Header; +using ACadSharp.Image.Rendering; +using ACadSharp.Image.Rendering.Svg; +using ACadSharp.IO; +using ACadSharp.Objects; +using ACadSharp.Tables; +using CSMath; + +namespace ACadSharp.Image.Tests; + +public sealed class EntityRenderDispatcherTests +{ + internal static ImageRenderContext CreateContext(IDrawingSurface surface, ImageConfiguration configuration) + { + Layout layout = new("test") { PaperWidth = 100, PaperHeight = 100 }; + 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 + // that need a deterministic handle assign it via reflection instead. + private static T WithHandle(T entity, ulong handle) + where T : CadObject + => SyntheticSamples.WithHandle(entity, handle); + + [Fact] + public void DrawWrapsEntityInBeginAndEnd() + { + RecordingDrawingSurface surface = new(); + ImageConfiguration configuration = new(); + EntityRenderDispatcher dispatcher = new(configuration); + Line line = WithHandle(new Line(new XYZ(0, 0, 0), new XYZ(10, 0, 0)) { Layer = new Layer("Walls") }, 0x1F3); + + dispatcher.Draw(CreateContext(surface, configuration), line); + + Assert.Equal(3, surface.Calls.Count); + Assert.StartsWith("BeginEntity LINE layer=Walls parent=- block=-", surface.Calls[0]); + Assert.StartsWith("DrawLine", surface.Calls[1]); + Assert.Equal("EndEntity", surface.Calls[2]); + Assert.Equal(0, surface.Depth); + Assert.Equal(0x1F3UL, surface.Entities[0].Handle); + } + + [Fact] + public void NestedEntityOnLayerZeroInheritsInsertLayer() + { + RecordingDrawingSurface surface = new(); + ImageConfiguration configuration = new(); + EntityRenderDispatcher dispatcher = new(configuration); + + BlockRecord block = new("DOOR"); + block.Entities.Add(new Line(new XYZ(0, 0, 0), new XYZ(1, 0, 0)) { Layer = new Layer(Layer.DefaultName) }); + block.Entities.Add(new Line(new XYZ(0, 0, 0), new XYZ(0, 1, 0)) { Layer = new Layer("Hardware") }); + Insert insert = WithHandle(new Insert(block) { Layer = new Layer("Doors") { Color = new ACadSharp.Color(1) } }, 0xAB); + + dispatcher.Draw(CreateContext(surface, configuration), insert); + + // Outer insert, then two nested entities. + Assert.Equal(3, surface.Entities.Count); + Assert.Equal("Doors", surface.Entities[0].LayerName); + Assert.Equal("Doors", surface.Entities[1].LayerName); + Assert.Equal(0xABUL, surface.Entities[1].ParentHandle); + Assert.Equal("DOOR", surface.Entities[1].BlockName); + 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); + + // 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); + } + + [Fact] + public void NestedEntitiesResolveByLayerAgainstTheEffectiveLayerAndByBlockAgainstTheInsert() + { + RecordingDrawingSurface surface = new(); + ImageConfiguration configuration = new(); + EntityRenderDispatcher dispatcher = new(configuration); + + CadDocument document = new(); + document.Header.LineTypeScale = 2d; + LineType dashed = new("DASHED"); + dashed.AddSegment(new LineType.Segment { Length = 1 }); + dashed.AddSegment(new LineType.Segment { Length = -1 }); + document.LineTypes.Add(dashed); + Layer doors = new("Doors") { Color = new ACadSharp.Color(1), LineWeight = LineWeightType.W50, LineType = dashed }; + document.Layers.Add(doors); + Layer hardware = new("Hardware") { Color = new ACadSharp.Color(5), LineWeight = LineWeightType.W100 }; + document.Layers.Add(hardware); + + BlockRecord block = new("DOOR"); + document.BlockRecords.Add(block); + // (a) layer 0, everything ByLayer: takes the insert's layer, including its dashed linetype and weight. + block.Entities.Add(new Line(new XYZ(0, 0, 0), new XYZ(10, 0, 0)) { Layer = document.Layers[Layer.DefaultName] }); + // (b) everything ByBlock: takes the insert's own resolved attributes. + block.Entities.Add(new Line(new XYZ(0, 0, 0), new XYZ(0, 10, 0)) { Color = ACadSharp.Color.ByBlock, LineWeight = LineWeightType.ByBlock, LineType = document.LineTypes.ByBlock }); + // (c) an explicit layer keeps its own attributes. + block.Entities.Add(new Line(new XYZ(0, 0, 0), new XYZ(10, 10, 0)) { Layer = hardware }); + + Insert insert = new(block) { Layer = doors, Color = new ACadSharp.Color(3), LineWeight = LineWeightType.W200, LineType = document.LineTypes.Continuous, LineTypeScale = 1.5 }; + document.Entities.Add(insert); + + dispatcher.Draw(CreateContext(surface, configuration), insert); + + // 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(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(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(configuration.GetLineWeightPixels(LineWeightType.W100), own.StrokeWidth); + } + + [Fact] + public void TopLevelByBlockDrawsAsColourSeven() + { + RecordingDrawingSurface surface = new(); + ImageConfiguration configuration = new() { BackgroundColor = SixLabors.ImageSharp.Color.Black }; + EntityRenderDispatcher dispatcher = new(configuration); + + dispatcher.Draw(CreateContext(surface, configuration), new Line(new XYZ(0, 0, 0), new XYZ(1, 0, 0)) { Color = ACadSharp.Color.ByBlock, LineWeight = LineWeightType.ByBlock }); + + ImageStyle style = Assert.Single(surface.Styles); + Assert.Equal(SixLabors.ImageSharp.Color.White, style.StrokeColor); + Assert.Equal(configuration.GetLineWeightPixels(LineWeightType.Default), style.StrokeWidth); + } + + [Fact] + public void EffectiveLayerReturnsParentLayerObjectForLayerZero() + { + Layer parent = new("Doors") { IsOn = false }; + Line onZero = new() { Layer = new Layer(Layer.DefaultName) }; + Line onOwn = new() { Layer = new Layer("Own") }; + + Assert.Same(parent, EntityRenderDispatcher.GetEffectiveLayer(onZero, parent)); + Assert.Equal("Own", EntityRenderDispatcher.GetEffectiveLayer(onOwn, parent)!.Name); + Assert.Equal(Layer.DefaultName, EntityRenderDispatcher.GetEffectiveLayer(onZero, null)!.Name); + } + + [Fact] + public void LayerInfoCarriesLayerColourAndWidth() + { + RecordingDrawingSurface surface = new(); + ImageConfiguration configuration = new(); + EntityRenderDispatcher dispatcher = new(configuration); + Layer layer = new("Red") { Color = new ACadSharp.Color(1), LineWeight = LineWeightType.W50 }; + Line line = new(new XYZ(0, 0, 0), new XYZ(10, 0, 0)) { Layer = layer }; + + dispatcher.Draw(CreateContext(surface, configuration), line); + + LayerRenderInfo info = Assert.Single(surface.Layers); + Assert.Equal("Red", info.LayerName); + Assert.Equal(SixLabors.ImageSharp.Color.FromRgb(255, 0, 0), info.Color); + Assert.Equal(configuration.GetLineWeightPixels(LineWeightType.W50), info.StrokeWidth); + } + + [Fact] + public void LayerColourIndexSevenFollowsTheBackground() + { + RecordingDrawingSurface surface = new(); + ImageConfiguration configuration = new() { BackgroundColor = SixLabors.ImageSharp.Color.FromRgb(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) } }; + + dispatcher.Draw(CreateContext(surface, configuration), line); + + // Colour index 7 is "ByBackground": on a dark sheet the layer group is white, not black. + Assert.Equal(SixLabors.ImageSharp.Color.White, Assert.Single(surface.Layers).Color); + } + + [Fact] + public void CurveCapableSurfaceReceivesNativeArcsCirclesAndBulges() + { + RecordingDrawingSurface surface = new() { SupportsCurves = true }; + ImageConfiguration configuration = new(); + EntityRenderDispatcher dispatcher = new(configuration); + ImageRenderContext context = CreateContext(surface, configuration); + + dispatcher.Draw(context, new Arc { Center = new XYZ(10, 10, 0), Radius = 5, StartAngle = 0, EndAngle = Math.PI / 2 }); + dispatcher.Draw(context, new Circle { Center = new XYZ(0, 0, 0), Radius = 2 }); + LwPolyline polyline = new(); + polyline.Vertices.Add(new LwPolyline.Vertex(new XY(0, 0)) { Bulge = 1 }); + polyline.Vertices.Add(new LwPolyline.Vertex(new XY(10, 0))); + dispatcher.Draw(context, polyline); + + Assert.Contains(surface.Calls, c => c.StartsWith("DrawArc", StringComparison.Ordinal) && c.Contains("sweep=-1.57", StringComparison.Ordinal)); + Assert.Contains(surface.Calls, c => c.StartsWith("DrawEllipse", StringComparison.Ordinal) && c.Contains("rx=2", StringComparison.Ordinal)); + Assert.Contains(surface.Calls, c => c.StartsWith("DrawBulgePolyline n=2", StringComparison.Ordinal) && c.Contains("bulges=1,0", StringComparison.Ordinal)); + Assert.DoesNotContain(surface.Calls, c => c.StartsWith("DrawPolyline", StringComparison.Ordinal)); + } + + [Fact] + public void RasterStyleSurfaceStillReceivesTessellatedPolylines() + { + RecordingDrawingSurface surface = new() { SupportsCurves = false }; + ImageConfiguration configuration = new(); + EntityRenderDispatcher dispatcher = new(configuration); + + dispatcher.Draw(CreateContext(surface, configuration), new Circle { Center = new XYZ(0, 0, 0), Radius = 2 }); + + Assert.Contains(surface.Calls, c => c.StartsWith("DrawPolyline", StringComparison.Ordinal)); + Assert.DoesNotContain(surface.Calls, c => c.StartsWith("DrawEllipse", StringComparison.Ordinal)); + } + + [Fact] + public void CurveCapableSurfaceReceivesEllipseSemiAxes() + { + RecordingDrawingSurface surface = new() { SupportsCurves = true }; + ImageConfiguration configuration = new(); + EntityRenderDispatcher dispatcher = new(configuration); + + // ACadSharp reports MajorAxis/MinorAxis as full axis lengths; the surface takes semi-axes. + dispatcher.Draw(CreateContext(surface, configuration), new Ellipse { Center = new XYZ(0, 0, 0), MajorAxisEndPoint = new XYZ(4, 0, 0), RadiusRatio = 0.5 }); + + Assert.Contains(surface.Calls, c => c.StartsWith("DrawEllipse", StringComparison.Ordinal) && c.Contains("rx=4 ry=2", StringComparison.Ordinal)); + } + + [Fact] + public void EllipseRotationAndPartialSweepAreNegatedForTheSurface() + { + RecordingDrawingSurface surface = new() { SupportsCurves = true }; + ImageConfiguration configuration = new(); + EntityRenderDispatcher dispatcher = new(configuration); + ImageRenderContext context = CreateContext(surface, configuration); + + // Major axis along +Y, so the drawing rotation is +PI/2 and the surface rotation is -PI/2. + dispatcher.Draw(context, new Ellipse { Center = new XYZ(0, 0, 0), MajorAxisEndPoint = new XYZ(0, 4, 0), RadiusRatio = 0.5 }); + dispatcher.Draw(context, new Ellipse + { + Center = new XYZ(0, 0, 0), + MajorAxisEndPoint = new XYZ(2, 0, 0), + RadiusRatio = 0.5, + StartParameter = 0, + EndParameter = Math.PI / 2, + }); + + Assert.Contains(surface.Calls, c => c.StartsWith("DrawEllipse", StringComparison.Ordinal) && c.Contains("rot=-1.57", StringComparison.Ordinal)); + Assert.Contains(surface.Calls, c => c.StartsWith("DrawArc", StringComparison.Ordinal) + && c.Contains("rx=2 ry=1", StringComparison.Ordinal) + && c.Contains("sweep=-1.57", StringComparison.Ordinal)); + } + + [Fact] + public void NonWorldNormalFallsBackToTessellation() + { + RecordingDrawingSurface surface = new() { SupportsCurves = true }; + ImageConfiguration configuration = new(); + EntityRenderDispatcher dispatcher = new(configuration); + + // A (0,0,-1) extrusion mirrors X in the OCS; only PolygonalVertexes applies it, so the native path must be skipped. + dispatcher.Draw(CreateContext(surface, configuration), new Circle { Center = new XYZ(10, 0, 0), Radius = 1, Normal = new XYZ(0, 0, -1) }); + + Assert.Contains(surface.Calls, c => c.StartsWith("DrawPolyline", StringComparison.Ordinal)); + Assert.DoesNotContain(surface.Calls, c => c.StartsWith("DrawEllipse", StringComparison.Ordinal)); + } + + // Both angles are finite, so the entity passes the non-finite gate, but their difference overflows to infinity. + // Without the sweep normalization the tessellation would step over an unbounded range and hang. + private static Arc HugeSweepArc() => new() + { + Center = new XYZ(10, 10, 0), + Radius = 5, + StartAngle = -double.MaxValue, + EndAngle = double.MaxValue, + }; + + private static Arc NonFiniteArc() => new() + { + Center = new XYZ(10, 10, 0), + Radius = double.PositiveInfinity, + StartAngle = double.NaN, + EndAngle = double.NaN, + }; + + [Fact] + public void HugeArcSweepNormalizesToAFullTurnInsteadOfHanging() + { + RecordingDrawingSurface surface = new() { SupportsCurves = true }; + ImageConfiguration configuration = new(); + EntityRenderDispatcher dispatcher = new(configuration); + + dispatcher.Draw(CreateContext(surface, configuration), HugeSweepArc()); + + Assert.Contains(surface.Calls, c => c.StartsWith("DrawArc", StringComparison.Ordinal) && c.Contains("sweep=-6.28", StringComparison.Ordinal)); + + // A full turn reaches the SVG surface as a closed ellipse (a circle here), never an arc path. + using SvgDrawingSurface svg = new(configuration, new SurfaceRect(0, 0, 100, 100), null, null); + dispatcher.Draw(CreateContext(svg, configuration), HugeSweepArc()); + + XDocument document = svg.ToDocument(); + Assert.Single(document.Descendants(SvgDrawingSurface.Ns + "circle")); + Assert.Empty(document.Descendants(SvgDrawingSurface.Ns + "path")); + + // The other branch of the same guard: a huge but finite sweep is folded into one turn with a modulo + // instead of being handed to the surface raw, which would step a tessellation over 1e9 radians. + surface.Calls.Clear(); + dispatcher.Draw(CreateContext(surface, configuration), new Arc { Center = new XYZ(10, 10, 0), Radius = 5, StartAngle = 0, EndAngle = 1e9 }); + string call = Assert.Single(surface.Calls, c => c.StartsWith("DrawArc", StringComparison.Ordinal)); + Assert.Contains("sweep=-0.577", call, StringComparison.Ordinal); + } + + [Fact] + public void NonFiniteArcIsSkippedWithWarning() + { + RecordingDrawingSurface surface = new() { SupportsCurves = true }; + ImageConfiguration configuration = new(); + List notifications = new(); + configuration.OnNotification += (_, e) => notifications.Add(e); + EntityRenderDispatcher dispatcher = new(configuration); + + dispatcher.Draw(CreateContext(surface, configuration), WithHandle(NonFiniteArc(), 0x1FA)); + + Assert.Empty(surface.Calls); + NotificationEventArgs notification = Assert.Single(notifications); + Assert.Equal(NotificationType.Warning, notification.NotificationType); + Assert.Contains("non-finite", notification.Message, StringComparison.Ordinal); + + // Nothing of the sort may reach the markup: rx="Infinity" is not valid SVG. + using SvgDrawingSurface svg = new(configuration, new SurfaceRect(0, 0, 100, 100), null, null); + dispatcher.Draw(CreateContext(svg, configuration), WithHandle(NonFiniteArc(), 0x1FA)); + + XDocument document = svg.ToDocument(); + Assert.Empty(document.Descendants(SvgDrawingSurface.Ns + "ellipse")); + Assert.Empty(document.Descendants(SvgDrawingSurface.Ns + "circle")); + Assert.Empty(document.Descendants(SvgDrawingSurface.Ns + "path")); + } + + private static Hatch SquareHatch(bool solid) + { + Hatch hatch = new(); + Hatch.BoundaryPath path = new(); + Hatch.BoundaryPath.Polyline polyline = new() { IsClosed = true }; + polyline.Vertices.AddRange([new XYZ(0, 0, 0), new XYZ(10, 0, 0), new XYZ(10, 10, 0), new XYZ(0, 10, 0)]); + path.Edges.Add(polyline); + hatch.Paths.Add(path); + if (solid) + { + hatch.IsSolid = true; + hatch.PatternType = HatchPatternType.SolidFill; + hatch.Pattern = HatchPattern.Solid; + } + else + { + hatch.IsSolid = false; + hatch.PatternType = HatchPatternType.PatternFill; + hatch.Pattern = new HatchPattern("ANSI31"); + hatch.Pattern.Lines.Add(new HatchPattern.Line { Angle = Math.PI / 4, BasePoint = XY.Zero, Offset = new XY(0, 3.175) }); + hatch.PatternScale = 1; + } + + return hatch; + } + + [Fact] + public void SolidHatchFillsBoundaryRings() + { + RecordingDrawingSurface surface = new(); + ImageConfiguration configuration = new(); + EntityRenderDispatcher dispatcher = new(configuration); + + dispatcher.Draw(CreateContext(surface, configuration), SquareHatch(solid: true)); + + Assert.Contains("FillPath rings=1", surface.Calls); + Assert.DoesNotContain(surface.Calls, c => c.StartsWith("DrawLine", StringComparison.Ordinal)); + } + + [Fact] + public void PatternHatchDrawsClippedLines() + { + RecordingDrawingSurface surface = new(); + ImageConfiguration configuration = new(); + EntityRenderDispatcher dispatcher = new(configuration); + + dispatcher.Draw(CreateContext(surface, configuration), SquareHatch(solid: false)); + + int lines = surface.Calls.Count(c => c.StartsWith("DrawLine", StringComparison.Ordinal)); + Assert.InRange(lines, 5, 9); // 45-degree lines 3.175 apart across a 10x10 square + Assert.All(surface.Styles, s => Assert.Null(s.DashPattern)); + } + + [Fact] + public void PatternHatchIsCappedWithWarning() + { + // A dashed pattern emits several segments per scan line, so the segment cap trips even though the scan-line + // preflight (about a dozen scan lines for this square) lets the expansion run. + Hatch hatch = SquareHatch(solid: false); + hatch.Pattern!.Lines[0].DashLengths.AddRange([1d, -1d]); + int cap = (int)Math.Ceiling(EntityRenderDispatcher.EstimateScanLines(hatch)); + + RecordingDrawingSurface surface = new(); + ImageConfiguration configuration = new() { MaxHatchLines = cap }; + List notifications = new(); + configuration.OnNotification += (_, e) => notifications.Add(e); + EntityRenderDispatcher dispatcher = new(configuration); + + dispatcher.Draw(CreateContext(surface, configuration), hatch); + + Assert.Equal(cap, surface.Calls.Count(c => c.StartsWith("DrawLine", StringComparison.Ordinal))); + Assert.Contains(notifications, n => n.NotificationType == NotificationType.Warning && n.Message.Contains("remaining lines were skipped", StringComparison.Ordinal)); + } + + [Fact] + public void ArbitraryAxisFrameMapsOcsPointsIntoWorld() + { + // The classic case: a (0,0,-1) extrusion mirrors X. + OcsTransform flipped = OcsTransform.For(new XYZ(0, 0, -1)); + XY mirrored = flipped.ToWorldXY(1, 2, 0); + Assert.Equal(-1d, mirrored.X, 9); + Assert.Equal(2d, mirrored.Y, 9); + + // A vertical plane: OCS Y becomes world Z, so the elevation lands in world Y. + OcsTransform vertical = OcsTransform.For(new XYZ(0, 1, 0)); + XY lifted = vertical.ToWorldXY(1, 2, 3); + Assert.Equal(-1d, lifted.X, 9); + Assert.Equal(3d, lifted.Y, 9); + + // Whatever the tilt, the frame is orthonormal and its Z axis is the unit normal. + OcsTransform tilted = OcsTransform.For(new XYZ(2, 2, 2)); + double unit = 1d / Math.Sqrt(3); + XYZ mappedZ = tilted.ToWorld(0, 0, 1); + Assert.Equal(unit, mappedZ.X, 9); + Assert.Equal(unit, mappedZ.Y, 9); + Assert.Equal(unit, mappedZ.Z, 9); + Assert.Equal(1d, Length(tilted.AxisX), 9); + Assert.Equal(1d, Length(tilted.AxisY), 9); + Assert.Equal(0d, Dot(tilted.AxisX, tilted.AxisY), 9); + Assert.Equal(0d, Dot(tilted.AxisX, tilted.Normal), 9); + Assert.Equal(0d, Dot(tilted.AxisY, tilted.Normal), 9); + + Assert.True(OcsTransform.IsWorldPlane(XYZ.AxisZ)); + Assert.False(OcsTransform.IsWorldPlane(tilted.Normal)); + + static double Length(XYZ v) => Math.Sqrt(Dot(v, v)); + static double Dot(XYZ a, XYZ b) => (a.X * b.X) + (a.Y * b.Y) + (a.Z * b.Z); + } + + [Fact] + public void TextInsideAMirroredInsertStaysWithItsGeometry() + { + RecordingDrawingSurface surface = new(); + ImageConfiguration configuration = new(); + EntityRenderDispatcher dispatcher = new(configuration); + BlockRecord block = new("LABEL"); + block.Entities.Add(new Line(new XYZ(1, 2, 0), new XYZ(3, 2, 0))); + block.Entities.Add(new TextEntity { Value = "T", InsertPoint = new XYZ(1, 2, 0), Height = 1 }); + Insert insert = new(block) { InsertPoint = new XYZ(10, 0, 0), XScale = -1 }; + + dispatcher.Draw(CreateContext(surface, configuration), insert); + + // The line starts at world x = 9; the text shares that point, and its glyphs read the mirrored extent from its end. + (SurfacePoint start, _) = Assert.Single(surface.Lines); + SurfaceText run = Assert.Single(surface.Texts); + Assert.Equal(9d, start.X, 6); + Assert.Equal(start.X, run.Origin.X, 6); + Assert.Equal(start.Y, run.Origin.Y, 6); + Assert.Equal(SurfaceTextAnchor.End, run.Anchor); + Assert.Equal(1d, Math.Cos(run.Rotation), 6); + } + + [Fact] + public void AlignedTextInsideAnInsertIsTranslatedWithIt() + { + RecordingDrawingSurface surface = new(); + ImageConfiguration configuration = new(); + EntityRenderDispatcher dispatcher = new(configuration); + BlockRecord block = new("TAG"); + block.Entities.Add(new TextEntity { Value = "R", InsertPoint = new XYZ(1, 2, 0), AlignmentPoint = new XYZ(4, 2, 0), HorizontalAlignment = TextHorizontalAlignment.Right, Height = 1 }); + Insert insert = new(block) { InsertPoint = new XYZ(10, 0, 0) }; + + dispatcher.Draw(CreateContext(surface, configuration), insert); + + // ACadSharp leaves the clone's alignment point at (4,2); the renderer places the original through the insert. + SurfaceText run = Assert.Single(surface.Texts); + Assert.Equal(14d, run.Origin.X, 6); + Assert.Equal(100d - 2d, run.Origin.Y, 6); + Assert.Equal(SurfaceTextAnchor.End, run.Anchor); + } + + [Fact] + public void TextInsideANestedInsertIsPlacedThroughBothTransforms() + { + RecordingDrawingSurface surface = new(); + ImageConfiguration configuration = new(); + EntityRenderDispatcher dispatcher = new(configuration); + BlockRecord inner = new("INNER"); + inner.Entities.Add(new TextEntity { Value = "N", InsertPoint = new XYZ(1, 2, 0), AlignmentPoint = new XYZ(3, 2, 0), HorizontalAlignment = TextHorizontalAlignment.Right, Height = 1 }); + BlockRecord outer = new("OUTER"); + outer.Entities.Add(new Insert(inner) { InsertPoint = new XYZ(5, 0, 0) }); + Insert insert = new(outer) { InsertPoint = new XYZ(10, 0, 0) }; + + dispatcher.Draw(CreateContext(surface, configuration), insert); + + // (3,2) + (5,0) + (10,0): the alignment point travels through both inserts. + SurfaceText run = Assert.Single(surface.Texts); + Assert.Equal(18d, run.Origin.X, 6); + Assert.Equal(100d - 2d, run.Origin.Y, 6); + Assert.Equal(SurfaceTextAnchor.End, run.Anchor); + } + + [Fact] + public void TextAndMTextInsideARotatedScaledInsertFollowIt() + { + RecordingDrawingSurface surface = new(); + ImageConfiguration configuration = new(); + EntityRenderDispatcher dispatcher = new(configuration); + BlockRecord block = new("ROT"); + block.Entities.Add(new TextEntity { Value = "T", InsertPoint = new XYZ(1, 2, 0), Height = 1 }); + block.Entities.Add(new MText { Value = "M", InsertPoint = new XYZ(1, 5, 0), Height = 1, AlignmentPoint = new XYZ(1, 0, 0) }); + Insert insert = new(block) { InsertPoint = new XYZ(10, 0, 0), Rotation = Math.PI / 2, XScale = 2, YScale = 2 }; + + dispatcher.Draw(CreateContext(surface, configuration), insert); + + Assert.Equal(2, surface.Texts.Count); + SurfaceText text = surface.Texts[0]; + // (1,2) scaled by 2 and rotated a quarter turn about the origin, then moved to (10,0): (10 - 4, 2) = (6, 2). + Assert.Equal(6d, text.Origin.X, 6); + Assert.Equal(100d - 2d, text.Origin.Y, 6); + Assert.Equal(0d, Math.Cos(text.Rotation), 6); + Assert.Equal(1d, Math.Sin(text.Rotation), 6); + Assert.Equal(2d, text.Height, 6); + Assert.Equal(SurfaceTextAnchor.Start, text.Anchor); + // A uniform insert scale (XScale == YScale) must leave WidthScale at 1: the reading-axis and up-axis + // lengths the insert transform produces are equal, so p.WidthScale / p.Scale reduces to 1. + Assert.Equal(1d, text.WidthScale, 9); + + SurfaceText mtext = surface.Texts[1]; + Assert.Equal(0d, Math.Cos(mtext.Rotation), 6); + Assert.Equal(1d, Math.Sin(mtext.Rotation), 6); + Assert.Equal(2d, mtext.Height, 6); + Assert.Equal(1d, mtext.WidthScale, 9); + } + + [Fact] + public void HatchInsideAMirroredInsertStaysWithItsGeometry() + { + RecordingDrawingSurface surface = new(); + ImageConfiguration configuration = new(); + EntityRenderDispatcher dispatcher = new(configuration); + BlockRecord block = new("HATCHED"); + Hatch hatch = SquareHatch(solid: true); + block.Entities.Add(hatch); + Insert insert = new(block) { InsertPoint = new XYZ(10, 0, 0), XScale = -1 }; + + dispatcher.Draw(CreateContext(surface, configuration), insert); + + // This hatch's normal is the world Z axis, so it is drawn from the original's own boundary points (already + // world) mapped straight through the insert's placement: the 0..10 square mirrored about x = 10 spans 0..10 + // again. The expectation is invariant across both the exploded-clone path and the current original-entity + // path, which is why this test alone would not have caught the pattern-angle mirroring bug the clone path + // had — see APatternHatchInsideAMirroredInsertMirrorsItsPatternAngle below for that. + IReadOnlyList ring = Assert.Single(Assert.Single(surface.FillPaths)); + Assert.Equal(0d, ring.Min(p => p.X), 6); + Assert.Equal(10d, ring.Max(p => p.X), 6); + } + + [Fact] + public void APatternHatchInsideAMirroredInsertMirrorsItsPatternAngle() + { + RecordingDrawingSurface surface = new(); + ImageConfiguration configuration = new(); + BlockRecord block = new("PATTERN"); + Hatch hatch = SquareHatch(solid: false); + block.Entities.Add(hatch); + Insert insert = new(block) { XScale = -1 }; + + new EntityRenderDispatcher(configuration).Draw(CreateContext(surface, configuration), insert); + + // SquareHatch's pattern line runs at Angle = Math.PI/4 (45 degrees) in the hatch's own space, direction + // (cos45, sin45) = (a, a). Drawing from the original means ExplodePattern() sees that unmirrored angle and + // the mirror (XScale = -1) is applied afterwards, through the placement, negating only X: (-a, a), a + // world-space slope of a / -a = -1 (the old exploded-clone path left Pattern.Angle unmirrored at 0.7854 and + // so drew the unmirrored +1 slope instead — the bug this task fixes for pattern hatches). The renderer's Y + // flip (ImageRenderContext.ToSurfacePoint: surfaceY = SurfaceHeight - worldY) negates the Y delta again + // without touching X, so on the surface — what surface.Lines records — every drawn segment has slope +1. + Assert.NotEmpty(surface.Lines); + Assert.All(surface.Lines, l => + { + double dx = l.End.X - l.Start.X; + double dy = l.End.Y - l.Start.Y; + Assert.True(Math.Abs(dx) > 1e-6, "pattern line unexpectedly vertical in surface space"); + Assert.Equal(1d, dy / dx, 6); + }); + } + + [Fact] + public void NonWorldPolylineIsTessellatedAndBroughtIntoWorld() + { + RecordingDrawingSurface surface = new() { SupportsCurves = true }; + ImageConfiguration configuration = new(); + EntityRenderDispatcher dispatcher = new(configuration); + LwPolyline polyline = new(); + polyline.Vertices.Add(new LwPolyline.Vertex(new XY(1, 0)) { Bulge = 1 }); + polyline.Vertices.Add(new LwPolyline.Vertex(new XY(3, 0))); + polyline.Normal = new XYZ(0, 0, -1); + + dispatcher.Draw(CreateContext(surface, configuration), polyline); + + // Bulges only describe circular arcs on the world plane; a mirrored polyline is tessellated, then mirrored. + Assert.DoesNotContain(surface.Calls, c => c.StartsWith("DrawBulgePolyline", StringComparison.Ordinal)); + IReadOnlyList points = Assert.Single(surface.Polylines); + Assert.True(points.Count > 2); + Assert.Equal(-1d, points[0].X, 3); + Assert.Equal(-3d, points[^1].X, 3); + Assert.All(points, p => Assert.InRange(p.X, -3.01, -0.99)); + + // On the world plane nothing changes: the bulge reaches a curve-capable surface intact. + surface.Calls.Clear(); + polyline.Normal = XYZ.AxisZ; + dispatcher.Draw(CreateContext(surface, configuration), polyline); + Assert.Contains(surface.Calls, c => c.StartsWith("DrawBulgePolyline", StringComparison.Ordinal)); + } + + [Fact] + public void NonWorldHatchIsBroughtIntoWorld() + { + RecordingDrawingSurface surface = new(); + ImageConfiguration configuration = new(); + EntityRenderDispatcher dispatcher = new(configuration); + + Hatch solid = SquareHatch(solid: true); + solid.Normal = new XYZ(0, 0, -1); + dispatcher.Draw(CreateContext(surface, configuration), solid); + + IReadOnlyList ring = Assert.Single(Assert.Single(surface.FillPaths)); + Assert.Equal(-10d, ring.Min(p => p.X), 6); + Assert.Equal(0d, ring.Max(p => p.X), 6); + + Hatch pattern = SquareHatch(solid: false); + pattern.Normal = new XYZ(0, 0, -1); + dispatcher.Draw(CreateContext(surface, configuration), pattern); + + Assert.NotEmpty(surface.Lines); + Assert.All(surface.Lines, l => + { + Assert.InRange(l.Start.X, -10.001, 0.001); + Assert.InRange(l.End.X, -10.001, 0.001); + }); + } + + [Fact] + public void ATiltedHatchInsideAnInsertIsMappedThroughItsOwnOcsThenTheInsertTransform() + { + RecordingDrawingSurface surface = new(); + ImageConfiguration configuration = new(); + BlockRecord block = new("TILT"); + Hatch hatch = new() { IsSolid = true, Normal = new XYZ(0, 0, -1), Elevation = 0d }; + hatch.Paths.Add(SyntheticSamples.SquarePath(0, 0, 10, 10)); + block.Entities.Add(hatch); + Insert insert = new(block) { InsertPoint = new XYZ(20, 0, 0) }; + + new EntityRenderDispatcher(configuration).Draw(CreateContext(surface, configuration), insert); + + // Normal (0,0,-1) mirrors X going OCS to world, so the square spans x in [-10,0]; the insert then adds 20. + IReadOnlyList ring = Assert.Single(Assert.Single(surface.FillPaths)); + Assert.Equal(10d, ring.Min(p => p.X), 6); + Assert.Equal(20d, ring.Max(p => p.X), 6); + } + + [Fact] + public void ATiltedHatchAtTopLevelIsUnchanged() + { + RecordingDrawingSurface surface = new(); + ImageConfiguration configuration = new(); + Hatch hatch = new() { IsSolid = true, Normal = new XYZ(0, 0, -1), Elevation = 0d }; + hatch.Paths.Add(SyntheticSamples.SquarePath(0, 0, 10, 10)); + + new EntityRenderDispatcher(configuration).Draw(CreateContext(surface, configuration), hatch); + + IReadOnlyList ring = Assert.Single(Assert.Single(surface.FillPaths)); + Assert.Equal(-10d, ring.Min(p => p.X), 6); + Assert.Equal(0d, ring.Max(p => p.X), 6); + } + + [Fact] + public void AHatchInsideAMirroredInsertKeepsItsExtent() + { + RecordingDrawingSurface surface = new(); + ImageConfiguration configuration = new(); + BlockRecord block = new("MIRROR"); + Hatch hatch = new() { IsSolid = true, Normal = XYZ.AxisZ, Elevation = 0d }; + hatch.Paths.Add(SyntheticSamples.SquarePath(0, 0, 10, 10)); + block.Entities.Add(hatch); + Insert insert = new(block) { InsertPoint = new XYZ(50, 0, 0), XScale = -1 }; + + new EntityRenderDispatcher(configuration).Draw(CreateContext(surface, configuration), insert); + + IReadOnlyList ring = Assert.Single(Assert.Single(surface.FillPaths)); + Assert.Equal(40d, ring.Min(p => p.X), 6); + Assert.Equal(50d, ring.Max(p => p.X), 6); + } + + [Fact] + public void ATiltedHatchWithAnElevationIsPlacedAlongItsOwnNormal() + { + RecordingDrawingSurface surface = new(); + ImageConfiguration configuration = new(); + Hatch hatch = new() { IsSolid = true, Normal = new XYZ(0, 1, 0), Elevation = 5d }; + hatch.Paths.Add(SyntheticSamples.SquarePath(0, 0, 10, 10)); + + new EntityRenderDispatcher(configuration).Draw(CreateContext(surface, configuration), hatch); + + // Normal (0,1,0): the OCS X axis is world -X and the OCS Y axis is world +Z, so the square's Y collapses to a + // constant world Y = +5 (the elevation along the normal); after the surface Y flip (SurfaceHeight - worldY) + // that lands at 95, so the elevation reaches the output rather than being dropped as it was for a clone. + IReadOnlyList ring = Assert.Single(Assert.Single(surface.FillPaths)); + Assert.All(ring, p => Assert.Equal(95d, p.Y, 6)); + } + + [Fact] + public void DensePatternHatchIsSkippedBeforeExpansion() + { + Hatch hatch = SquareHatch(solid: false); + double scanLines = EntityRenderDispatcher.EstimateScanLines(hatch); + Assert.InRange(scanLines, 6, 40); + + RecordingDrawingSurface surface = new(); + ImageConfiguration configuration = new() { MaxHatchLines = 5 }; + List notifications = new(); + configuration.OnNotification += (_, e) => notifications.Add(e); + EntityRenderDispatcher dispatcher = new(configuration); + + dispatcher.Draw(CreateContext(surface, configuration), hatch); + + Assert.Empty(surface.Lines); + NotificationEventArgs notification = Assert.Single(notifications); + Assert.Equal(NotificationType.Warning, notification.NotificationType); + Assert.Contains("hatch skipped", notification.Message, StringComparison.Ordinal); + + // Above the estimate the pattern is expanded and drawn as before. + configuration.MaxHatchLines = 50; + dispatcher.Draw(CreateContext(surface, configuration), hatch); + Assert.NotEmpty(surface.Lines); + Assert.Single(notifications); + } + + [Fact] + public void NonWorldSolidIsBroughtIntoWorld() + { + RecordingDrawingSurface surface = new(); + ImageConfiguration configuration = new(); + EntityRenderDispatcher dispatcher = new(configuration); + Solid solid = new() + { + FirstCorner = new XYZ(1, 0, 0), + SecondCorner = new XYZ(3, 0, 0), + ThirdCorner = new XYZ(1, 2, 0), + FourthCorner = new XYZ(3, 2, 0), + Normal = new XYZ(0, 0, -1), + }; + + dispatcher.Draw(CreateContext(surface, configuration), solid); + + // A (0,0,-1) extrusion mirrors X: the solid must land on x in [-3, -1], not [1, 3]. + IReadOnlyList points = Assert.Single(surface.Polygons); + Assert.Equal(4, points.Count); + Assert.Equal(-1d, points.Max(p => p.X), 9); + Assert.Equal(-3d, points.Min(p => p.X), 9); + + // The default normal leaves the corners untouched. + solid.Normal = XYZ.AxisZ; + dispatcher.Draw(CreateContext(surface, configuration), solid); + Assert.Equal(1d, surface.Polygons[1].Min(p => p.X), 9); + Assert.Equal(3d, surface.Polygons[1].Max(p => p.X), 9); + } + + [Fact] + public void SolidCornersAreFilledInDxfOrder() + { + RecordingDrawingSurface surface = new(); + ImageConfiguration configuration = new(); + EntityRenderDispatcher dispatcher = new(configuration); + // Corners in the DXF Z pattern: bottom-left, bottom-right, top-left, top-right. + Solid solid = new() + { + FirstCorner = new XYZ(0, 0, 0), + SecondCorner = new XYZ(10, 0, 0), + ThirdCorner = new XYZ(0, 5, 0), + FourthCorner = new XYZ(10, 5, 0), + }; + + dispatcher.Draw(CreateContext(surface, configuration), solid); + + // Filled as 1-2-4-3, the outline is a rectangle; as 1-2-3-4 it would be a bow-tie. + IReadOnlyList points = Assert.Single(surface.Polygons); + Assert.Equal([0d, 10d, 10d, 0d], points.Select(p => p.X).ToArray()); + Assert.Equal([100d, 100d, 95d, 95d], points.Select(p => p.Y).ToArray()); + } + + [Fact] + public void OcsSolidInsideAnInsertAppliesTheNormalBeforeTheInsertTransform() + { + RecordingDrawingSurface surface = new(); + ImageConfiguration configuration = new(); + BlockRecord block = new("PLATE"); + block.Entities.Add(new Solid { FirstCorner = new XYZ(0, 0, 0), SecondCorner = new XYZ(10, 0, 0), ThirdCorner = new XYZ(0, 5, 0), FourthCorner = new XYZ(10, 5, 0), Normal = new XYZ(0, 0, -1) }); + Insert insert = new(block) { InsertPoint = new XYZ(20, 0, 0) }; + + new EntityRenderDispatcher(configuration).Draw(CreateContext(surface, configuration), insert); + + // Normal (0,0,-1) mirrors X in OCS→world: corners x in [-10,0]; then the insert moves them by +20: x in [10,20]. + IReadOnlyList polygon = Assert.Single(surface.Polygons); + Assert.Equal(new HashSet { new(20, 100), new(10, 100), new(10, 95), new(20, 95) }, polygon.ToHashSet()); + } + + [Fact] + public void MalformedPolylineIsSkippedWithWarningAndSubsequentEntitiesStillDraw() + { + // Two coincident vertices joined by a bulge make ACadSharp's tessellating GetPoints throw. + RecordingDrawingSurface surface = new() { SupportsCurves = false }; + ImageConfiguration configuration = new(); + List notifications = new(); + configuration.OnNotification += (_, e) => notifications.Add(e); + EntityRenderDispatcher dispatcher = new(configuration); + + LwPolyline malformed = new(); + malformed.Vertices.Add(new LwPolyline.Vertex(new XY(5, 5)) { Bulge = 1 }); + malformed.Vertices.Add(new LwPolyline.Vertex(new XY(5, 5))); + malformed.Vertices.Add(new LwPolyline.Vertex(new XY(8, 5))); + + dispatcher.Draw(CreateContext(surface, configuration), WithHandle(malformed, 0x2A)); + + Assert.Equal(0, surface.Depth); + NotificationEventArgs notification = Assert.Single(notifications); + Assert.Equal(NotificationType.Warning, notification.NotificationType); + Assert.Contains("entity skipped", notification.Message, StringComparison.Ordinal); + + Line line = WithHandle(new Line(new XYZ(0, 0, 0), new XYZ(10, 0, 0)), 0x2B); + dispatcher.Draw(CreateContext(surface, configuration), line); + + Assert.Contains(surface.Calls, c => c.StartsWith("DrawLine", StringComparison.Ordinal)); + } + + [Fact] + public void Face3DWithAllEdgesVisibleIsOneClosedPolyline() + { + RecordingDrawingSurface surface = new(); + ImageConfiguration configuration = new(); + Face3D face = new() + { + FirstCorner = new XYZ(0, 0, 0), + SecondCorner = new XYZ(10, 0, 0), + ThirdCorner = new XYZ(10, 10, 0), + FourthCorner = new XYZ(0, 10, 0), + }; + + new EntityRenderDispatcher(configuration).Draw(CreateContext(surface, configuration), face); + + Assert.Equal(["DrawPolyline n=4 closed=True"], surface.Calls.Where(c => c.StartsWith("Draw", StringComparison.Ordinal))); + Assert.Equal(new SurfacePoint(0, 100), surface.Polylines[0][0]); + Assert.Equal(new SurfacePoint(0, 90), surface.Polylines[0][3]); + Assert.Empty(surface.Polygons); + } + + [Fact] + public void Face3DSkipsInvisibleEdgesAndKeepsTheVisibleRunsJoined() + { + RecordingDrawingSurface surface = new(); + ImageConfiguration configuration = new(); + // Edges 2 (10,0)->(10,10) and 4 (0,10)->(0,0) hidden: two separate open edges remain. + Face3D face = new() + { + FirstCorner = new XYZ(0, 0, 0), + SecondCorner = new XYZ(10, 0, 0), + ThirdCorner = new XYZ(10, 10, 0), + FourthCorner = new XYZ(0, 10, 0), + Flags = InvisibleEdgeFlags.Second | InvisibleEdgeFlags.Fourth, + }; + + new EntityRenderDispatcher(configuration).Draw(CreateContext(surface, configuration), face); + + Assert.Equal(2, surface.Polylines.Count); + Assert.All(surface.Calls.Where(c => c.StartsWith("DrawPolyline", StringComparison.Ordinal)), c => Assert.EndsWith("closed=False", c)); + Assert.Contains(surface.Polylines, p => p.SequenceEqual([new SurfacePoint(10, 90), new SurfacePoint(0, 90)])); + Assert.Contains(surface.Polylines, p => p.SequenceEqual([new SurfacePoint(0, 100), new SurfacePoint(10, 100)])); + } + + [Fact] + public void Face3DWithOneHiddenEdgeIsOneOpenRunOfThreeEdges() + { + RecordingDrawingSurface surface = new(); + ImageConfiguration configuration = new(); + Face3D face = new() + { + FirstCorner = new XYZ(0, 0, 0), + SecondCorner = new XYZ(10, 0, 0), + ThirdCorner = new XYZ(10, 10, 0), + FourthCorner = new XYZ(0, 10, 0), + Flags = InvisibleEdgeFlags.Third, + }; + + new EntityRenderDispatcher(configuration).Draw(CreateContext(surface, configuration), face); + + IReadOnlyList run = Assert.Single(surface.Polylines); + // Starts after the hidden edge: 4 -> 1 -> 2 -> 3. + Assert.Equal([new SurfacePoint(0, 90), new SurfacePoint(0, 100), new SurfacePoint(10, 100), new SurfacePoint(10, 90)], run); + } + + [Fact] + public void TriangularFace3DDropsTheDegenerateEdge() + { + RecordingDrawingSurface surface = new(); + ImageConfiguration configuration = new(); + Face3D face = new() + { + FirstCorner = new XYZ(0, 0, 0), + SecondCorner = new XYZ(10, 0, 0), + ThirdCorner = new XYZ(10, 10, 0), + FourthCorner = new XYZ(10, 10, 0), + }; + + new EntityRenderDispatcher(configuration).Draw(CreateContext(surface, configuration), face); + + Assert.Equal(["DrawPolyline n=3 closed=True"], surface.Calls.Where(c => c.StartsWith("Draw", StringComparison.Ordinal))); + } + + private static Insert InsertWithAttribute(string value, AttributeFlags flags, out BlockRecord block) + { + block = new BlockRecord("TAGGED"); + block.Entities.Add(new AttributeDefinition { Tag = "ROOM", Value = "DEFAULT", InsertPoint = new XYZ(1, 1, 0), Height = 2, Flags = flags }); + // Insert(BlockRecord) creates one AttributeEntity per ATTDEF at the identity transform; place it explicitly. + Insert insert = new(block) { InsertPoint = new XYZ(10, 0, 0) }; + AttributeEntity attribute = Assert.Single(insert.Attributes); + attribute.Value = value; + attribute.InsertPoint = new XYZ(15, 5, 0); + attribute.Flags = flags; + return insert; + } + + [Fact] + public void InsertDrawsItsAttributesAndNotTheDefinitionDefaults() + { + RecordingDrawingSurface surface = new(); + ImageConfiguration configuration = new(); + Insert insert = WithHandle(InsertWithAttribute("A-101", AttributeFlags.None, out _), 0xAB); + + new EntityRenderDispatcher(configuration).Draw(CreateContext(surface, configuration), insert); + + SurfaceText text = Assert.Single(surface.Texts); + Assert.Equal("A-101", text.Text); + Assert.Equal(new SurfacePoint(15, 95), text.Origin); + Assert.DoesNotContain(surface.Texts, t => t.Text == "DEFAULT"); + EntityRenderInfo info = surface.Entities.Single(e => e.EntityType == insert.Attributes.First().ObjectName); + Assert.Equal(0xABUL, info.ParentHandle); + Assert.Equal("TAGGED", info.BlockName); + } + + [Fact] + public void ConstantAttributeDefinitionsAreStillDrawn() + { + RecordingDrawingSurface surface = new(); + ImageConfiguration configuration = new(); + 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 + // 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 + // even if the explode-time fallback that reads the value from the ATTDEF itself were broken. + insert.Attributes.Clear(); + Assert.Empty(insert.Attributes); + + new EntityRenderDispatcher(configuration).Draw(CreateContext(surface, configuration), insert); + + Assert.Single(surface.Texts, t => t.Text == "ACME"); + } + + [Theory] + [InlineData(LayerVisibilityMode.All, AttributeVisibilityMode.Normal, true)] + [InlineData(LayerVisibilityMode.Screen, AttributeVisibilityMode.Normal, false)] + [InlineData(LayerVisibilityMode.Screen, AttributeVisibilityMode.All, true)] + [InlineData(LayerVisibilityMode.Screen, AttributeVisibilityMode.None, false)] + public void HiddenAttributesFollowAttmodeUnlessEverythingIsShown(LayerVisibilityMode layerMode, AttributeVisibilityMode attmode, bool drawn) + { + RecordingDrawingSurface surface = new(); + ImageConfiguration configuration = new() { LayerVisibility = layerMode }; + Insert insert = InsertWithAttribute("SECRET", AttributeFlags.Hidden, out BlockRecord block); + CadDocument document = new(); + document.Header.AttributeVisibility = attmode; + document.BlockRecords.Add(block); + document.Entities.Add(insert); + + new EntityRenderDispatcher(configuration).Draw(CreateContext(surface, configuration), insert); + + Assert.Equal(drawn, surface.Texts.Any(t => t.Text == "SECRET")); + } + + [Fact] + public void VisibleAttributeIsDrawnUnderNormalAttmode() + { + RecordingDrawingSurface surface = new(); + ImageConfiguration configuration = new() { LayerVisibility = LayerVisibilityMode.Screen }; + Insert insert = InsertWithAttribute("SHOWN", AttributeFlags.None, out BlockRecord block); + CadDocument document = new(); + document.BlockRecords.Add(block); + document.Entities.Add(insert); + + new EntityRenderDispatcher(configuration).Draw(CreateContext(surface, configuration), insert); + + Assert.Contains(surface.Texts, t => t.Text == "SHOWN"); + } + + [Fact] + public void NestedInsertAttributesFollowTheOuterInsertsDocumentAttmode() + { + // A nested Insert exploded out of an outer block's contents carries no Document of its own; its ATTMODE + // must still come from the outer insert's document, not fall back to Normal. + RecordingDrawingSurface surface = new(); + ImageConfiguration configuration = new() { LayerVisibility = LayerVisibilityMode.Screen }; + + BlockRecord innerBlock = new("INNER_BLK"); + innerBlock.Entities.Add(new AttributeDefinition { Tag = "X", Value = "DEFAULT_INNER", InsertPoint = new XYZ(0, 0, 0), Height = 2, Flags = AttributeFlags.None }); + Insert innerInsert = new(innerBlock) { InsertPoint = new XYZ(0, 0, 0) }; + AttributeEntity innerAttribute = Assert.Single(innerInsert.Attributes); + innerAttribute.Value = "INNER"; + innerAttribute.InsertPoint = new XYZ(0, 0, 0); + innerAttribute.Flags = AttributeFlags.None; + + BlockRecord outerBlock = new("OUTER_BLK"); + outerBlock.Entities.Add(innerInsert); + Insert outerInsert = new(outerBlock) { InsertPoint = new XYZ(10, 0, 0) }; + + CadDocument document = new(); + document.Header.AttributeVisibility = AttributeVisibilityMode.None; + document.BlockRecords.Add(innerBlock); + document.BlockRecords.Add(outerBlock); + document.Entities.Add(outerInsert); + + new EntityRenderDispatcher(configuration).Draw(CreateContext(surface, configuration), outerInsert); + + Assert.DoesNotContain(surface.Texts, t => t.Text == "INNER"); + } + + [Fact] + public void ConstantAttributeDefinitionFollowsAttmodeWhenDrawnFromTheExplodePath() + { + RecordingDrawingSurface surface = new(); + ImageConfiguration configuration = new() { LayerVisibility = LayerVisibilityMode.Screen }; + BlockRecord block = new("CONST3"); + block.Entities.Add(new AttributeDefinition { Tag = "MAKER", Value = "ACME3", InsertPoint = new XYZ(1, 1, 0), Height = 2, Flags = AttributeFlags.Constant }); + Insert insert = new(block) { InsertPoint = new XYZ(10, 0, 0) }; + insert.Attributes.Clear(); + + CadDocument document = new(); + document.Header.AttributeVisibility = AttributeVisibilityMode.None; + document.BlockRecords.Add(block); + document.Entities.Add(insert); + + new EntityRenderDispatcher(configuration).Draw(CreateContext(surface, configuration), insert); + + Assert.DoesNotContain(surface.Texts, t => t.Text == "ACME3"); + } + + [Fact] + public void HiddenConstantAttributeDefinitionIsNotDrawnUnderNormalAttmode() + { + RecordingDrawingSurface surface = new(); + ImageConfiguration configuration = new() { LayerVisibility = LayerVisibilityMode.Screen }; + BlockRecord block = new("CONST4"); + block.Entities.Add(new AttributeDefinition { Tag = "MAKER", Value = "ACME4", InsertPoint = new XYZ(1, 1, 0), Height = 2, Flags = AttributeFlags.Constant | AttributeFlags.Hidden }); + Insert insert = new(block) { InsertPoint = new XYZ(10, 0, 0) }; + insert.Attributes.Clear(); + + CadDocument document = new(); + document.Header.AttributeVisibility = AttributeVisibilityMode.Normal; + document.BlockRecords.Add(block); + document.Entities.Add(insert); + + new EntityRenderDispatcher(configuration).Draw(CreateContext(surface, configuration), insert); + + Assert.DoesNotContain(surface.Texts, t => t.Text == "ACME4"); + } + + [Fact] + public void ConstantAttributeTagMatchIsCaseInsensitive() + { + RecordingDrawingSurface surface = new(); + ImageConfiguration configuration = new(); + BlockRecord block = new("CONST5"); + block.Entities.Add(new AttributeDefinition { Tag = "Maker", Value = "ACME5", InsertPoint = new XYZ(1, 1, 0), Height = 2, Flags = AttributeFlags.Constant }); + Insert insert = new(block) { InsertPoint = new XYZ(10, 0, 0) }; + AttributeEntity attribute = Assert.Single(insert.Attributes); + // DXF attribute tags are case-insensitive identifiers; the ATTDEF's own tag casing differs from the ATTRIB's. + attribute.Tag = "MAKER"; + + new EntityRenderDispatcher(configuration).Draw(CreateContext(surface, configuration), insert); + + Assert.Single(surface.Texts, t => t.Text == "ACME5"); + } + + [Fact] + public void ConstantMultiLineAttributeDefinitionIsPlacedThroughTheInsertTransform() + { + // Constant multi-line ATTDEFs reach EntityRenderDispatcher.Draw through the block-explode path in + // DrawBlockContents, not through DrawAttributes: UsesOriginalGeometry already treats AttributeDefinition + // as a TextEntity subclass and hands it the insert's transform as placement, the same as a block TEXT, + // MTEXT or Leader. This pins that the new multi-line arm honours that placement instead of only working + // at top level (where DrawAttributes always passes null). + RecordingDrawingSurface surface = new(); + ImageConfiguration configuration = new(); + BlockRecord block = new("CONSTML"); + block.Entities.Add(new AttributeDefinition + { + Tag = "ROOM", + Value = "WRONG", + AttributeType = AttributeType.ConstantMultiLine, + Flags = AttributeFlags.Constant, + InsertPoint = new XYZ(1, 1, 0), + Height = 2, + MText = new MText { Value = "Line1\\PLine2", InsertPoint = new XYZ(2, 3, 0), Height = 4 }, + }); + Insert insert = new(block) { InsertPoint = new XYZ(20, 10, 0), XScale = 2, YScale = 2, ZScale = 2 }; + // Insert(BlockRecord) auto-creates a matching ATTRIB for the constant ATTDEF, which would suppress the + // definition in the explode loop (see ConstantAttributeDefinitionsAreStillDrawn); clearing it reproduces + // a file where the constant attribute was never persisted as its own ATTRIB. + insert.Attributes.Clear(); + + new EntityRenderDispatcher(configuration).Draw(CreateContext(surface, configuration), insert); + + SurfaceText run = Assert.Single(surface.Texts); + Assert.Contains("Line1", run.Text); + Assert.Contains("Line2", run.Text); + Assert.DoesNotContain("WRONG", run.Text); + // The embedded MText's own insertion point (2,3) is scaled by the insert's uniform XScale/YScale (2) and + // translated by its InsertPoint (20,10): world = (2*2+20, 3*2+10) = (24, 16); CreateContext's 100-unit + // paper flips Y, so the surface origin is (24, 100-16) = (24, 84). + Assert.Equal(24d, run.Origin.X, 9); + Assert.Equal(84d, run.Origin.Y, 9); + // The MText's own up axis (0,1,0) is likewise scaled by 2, so its height in surface units doubles: 4*2=8. + Assert.Equal(8d, run.Height, 9); + } + + [Fact] + public void StraightLeaderIsOneOpenPolylineWithoutArrow() + { + RecordingDrawingSurface surface = new(); + ImageConfiguration configuration = new(); + Leader leader = new() { Vertices = { new XYZ(0, 0, 0), new XYZ(10, 10, 0), new XYZ(20, 10, 0) } }; + + new EntityRenderDispatcher(configuration).Draw(CreateContext(surface, configuration), leader); + + Assert.Equal(["DrawPolyline n=3 closed=False"], surface.Calls.Where(c => c.StartsWith("Draw", StringComparison.Ordinal) || c.StartsWith("Fill", StringComparison.Ordinal))); + Assert.Equal([new SurfacePoint(0, 100), new SurfacePoint(10, 90), new SurfacePoint(20, 90)], surface.Polylines[0]); + } + + [Fact] + public void LeaderArrowheadIsAFilledTriangleAtTheFirstVertex() + { + RecordingDrawingSurface surface = new(); + ImageConfiguration configuration = new(); + // A fresh style: DimensionStyle.Default may be shared, and tests run in parallel. + Leader leader = new() { ArrowHeadEnabled = true, Vertices = { new XYZ(0, 0, 0), new XYZ(30, 0, 0) }, Style = new DimensionStyle("ARROW") { ArrowSize = 6, ScaleFactor = 2 } }; + + new EntityRenderDispatcher(configuration).Draw(CreateContext(surface, configuration), leader); + + IReadOnlyList arrow = Assert.Single(surface.Polygons); + Assert.Equal(3, arrow.Count); + Assert.Equal(new SurfacePoint(0, 100), arrow[0]); + // Length 6 * 2 = 12 along +X (away from the second vertex), half-width 12 / 6 = 2. + Assert.Contains(arrow, p => Math.Abs(p.X - 12) < 1e-9 && Math.Abs(p.Y - 98) < 1e-9); + Assert.Contains(arrow, p => Math.Abs(p.X - 12) < 1e-9 && Math.Abs(p.Y - 102) < 1e-9); + } + + [Fact] + public void SplinedLeaderIsACubicBezierChainThroughItsVertices() + { + RecordingDrawingSurface surface = new(); + ImageConfiguration configuration = new(); + Leader leader = new() { PathType = LeaderPathType.Spline, Vertices = { new XYZ(0, 0, 0), new XYZ(10, 10, 0), new XYZ(20, 0, 0), new XYZ(30, 10, 0) } }; + + new EntityRenderDispatcher(configuration).Draw(CreateContext(surface, configuration), leader); + + Assert.Equal(["DrawCubicBezier n=10 closed=False"], surface.Calls.Where(c => c.StartsWith("Draw", StringComparison.Ordinal))); + } + + [Fact] + public void CatmullRomControlPointsInterpolateTheInputPoints() + { + SurfacePoint[] points = [new(0, 0), new(10, 10), new(20, 0)]; + + SurfacePoint[] controls = EntityRenderDispatcher.CatmullRomToBezier(points); + + Assert.Equal(7, controls.Length); + Assert.Equal(points[0], controls[0]); + Assert.Equal(points[1], controls[3]); + Assert.Equal(points[2], controls[6]); + // Interior tangent at (10,10) is (P2 - P0) / 6 = (20, 0) / 6. + Assert.Equal(new SurfacePoint(10 - 20d / 6d, 10), controls[2]); + Assert.Equal(new SurfacePoint(10 + 20d / 6d, 10), controls[4]); + } + + [Fact] + public void LeaderWithCustomArrowBlockFallsBackToTheDefaultArrowWithANotification() + { + RecordingDrawingSurface surface = new(); + ImageConfiguration configuration = new(); + List notifications = new(); + configuration.OnNotification += (_, e) => notifications.Add(e); + Leader leader = new() { ArrowHeadEnabled = true, Vertices = { new XYZ(0, 0, 0), new XYZ(30, 0, 0) }, Style = new DimensionStyle("DOTTED") { LeaderArrow = new BlockRecord("_DOT") } }; + + new EntityRenderDispatcher(configuration).Draw(CreateContext(surface, configuration), leader); + + Assert.Single(surface.Polygons); + Assert.Contains(notifications, n => n.NotificationType == NotificationType.Warning && n.Message.Contains("_DOT", StringComparison.Ordinal)); + } + + [Fact] + public void LeaderWithANonFiniteArrowSizeDrawsNoArrowheadAndSaysNothingAboutIt() + { + RecordingDrawingSurface surface = new(); + ImageConfiguration configuration = new(); + List notifications = new(); + configuration.OnNotification += (_, e) => notifications.Add(e); + Leader leader = new() + { + ArrowHeadEnabled = true, + Vertices = { new XYZ(0, 0, 0), new XYZ(30, 0, 0) }, + Style = new DimensionStyle("NAN") { ArrowSize = double.NaN, LeaderArrow = new BlockRecord("_DOT") }, + }; + + new EntityRenderDispatcher(configuration).Draw(CreateContext(surface, configuration), leader); + + // Every comparison with NaN is false, so an unguarded size <= 0 test lets a triangle with NaN corners through. + // The leader line itself is still drawn, and the fallback notification must not claim an arrow nobody drew. + Assert.Single(surface.Polylines); + Assert.Empty(surface.Polygons); + Assert.Empty(notifications); + } + + [Fact] + public void LeaderArrowInsideAScaledInsertScalesWithIt() + { + RecordingDrawingSurface surface = new(); + ImageConfiguration configuration = new(); + BlockRecord block = new("NOTE"); + Leader leader = new() { ArrowHeadEnabled = true, Vertices = { new XYZ(0, 0, 0), new XYZ(10, 0, 0) }, Style = new DimensionStyle("ARROW") { ArrowSize = 3, ScaleFactor = 1 } }; + block.Entities.Add(leader); + Insert insert = new(block) { InsertPoint = new XYZ(5, 5, 0), XScale = 2, YScale = 2 }; + + new EntityRenderDispatcher(configuration).Draw(CreateContext(surface, configuration), insert); + + IReadOnlyList arrow = Assert.Single(surface.Polygons); + Assert.Equal(new SurfacePoint(5, 95), arrow[0]); + // Source-space base at x=3 with half-width 0.5, scaled by 2 and moved by (5,5): x=11, y=5±1. + Assert.Contains(arrow, p => Math.Abs(p.X - 11) < 1e-9 && Math.Abs(p.Y - 94) < 1e-9); + 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. + Assert.Equal([new XYZ(0, 0, 0), new XYZ(10, 0, 0)], leader.Vertices); + } + + [Fact] + public void LeaderArrowTipKeepsVertexZUnderANonWorldInsertNormal() + { + RecordingDrawingSurface surface = new(); + ImageConfiguration configuration = new(); + BlockRecord block = new("ARROWZ"); + Leader leader = new() { ArrowHeadEnabled = true, Vertices = { new XYZ(0, 0, 7), new XYZ(10, 0, 7) }, Style = new DimensionStyle("ARROWZ") { ArrowSize = 3, ScaleFactor = 1 } }; + block.Entities.Add(leader); + // Normal (0,1,0) couples Z into X/Y through the insert's transform; an arrow anchored with Z forced to 0 + // would land at a different point than the path's own first vertex, detaching the arrow from the line. + Insert insert = new(block) { Normal = new XYZ(0, 1, 0) }; + + new EntityRenderDispatcher(configuration).Draw(CreateContext(surface, configuration), insert); + + IReadOnlyList arrow = Assert.Single(surface.Polygons); + IReadOnlyList path = Assert.Single(surface.Polylines); + Assert.Equal(path[0], arrow[0]); + } + + [Fact] + public void OcsSolidInsideAMirroredInsertComposesBothTransforms() + { + RecordingDrawingSurface surface = new(); + ImageConfiguration configuration = new(); + BlockRecord block = new("PLATEM"); + block.Entities.Add(new Solid { FirstCorner = new XYZ(0, 0, 0), SecondCorner = new XYZ(10, 0, 0), ThirdCorner = new XYZ(0, 5, 0), FourthCorner = new XYZ(10, 5, 0), Normal = new XYZ(0, 0, -1) }); + // The OCS normal mirrors X (world x in [-10,0]), and the insert's own XScale mirrors X again: the two + // mirrors compose to identity in X, offset by InsertPoint, not a double mirror away from it. + Insert insert = new(block) { InsertPoint = new XYZ(20, 0, 0), XScale = -1 }; + + new EntityRenderDispatcher(configuration).Draw(CreateContext(surface, configuration), insert); + + IReadOnlyList polygon = Assert.Single(surface.Polygons); + Assert.Equal(new HashSet { new(20, 100), new(30, 100), new(30, 95), new(20, 95) }, polygon.ToHashSet()); + } + + [Fact] + public void SplinedLeaderInsideAScaledInsertMapsBezierEndpointsThroughThePlacement() + { + RecordingDrawingSurface surface = new(); + ImageConfiguration configuration = new(); + BlockRecord block = new("SPLINENOTE"); + block.Entities.Add(new Leader { PathType = LeaderPathType.Spline, Vertices = { new XYZ(0, 0, 0), new XYZ(5, 5, 0), new XYZ(10, 0, 0) } }); + Insert insert = new(block) { InsertPoint = new XYZ(5, 5, 0), XScale = 2, YScale = 2 }; + + new EntityRenderDispatcher(configuration).Draw(CreateContext(surface, configuration), insert); + + // Catmull-Rom control points always start and end exactly on the input points, mapped or not, so the chain's + // first and last control point must equal the first and last vertex mapped through the insert's transform. + IReadOnlyList controls = Assert.Single(surface.Beziers); + Assert.Equal(new SurfacePoint(5, 95), controls[0]); + Assert.Equal(new SurfacePoint(25, 95), controls[^1]); + } + + [Fact] + public void LeaderNestedTwoBlocksDeepIsDrawnThroughTheComposedInsertsAndKeepsItsVertices() + { + RecordingDrawingSurface surface = new(); + ImageConfiguration configuration = new(); + Leader leader = new() { Vertices = { new XYZ(0, 0, 0), new XYZ(10, 0, 0) } }; + BlockRecord inner = new("INNERL"); + inner.Entities.Add(leader); + Insert nestedInsert = new(inner) { InsertPoint = new XYZ(2, 3, 0) }; + BlockRecord outer = new("OUTERL"); + outer.Entities.Add(nestedInsert); + Insert outerInsert = new(outer) { InsertPoint = new XYZ(5, 20, 0) }; + + new EntityRenderDispatcher(configuration).Draw(CreateContext(surface, configuration), outerInsert); + + // Neither Insert.Clone() (which deep-clones INNERL, including a Leader that shares LEADER's own vertex + // list) nor either level's Explode() call is allowed to leave the deep original mutated. + Assert.Equal([new XYZ(0, 0, 0), new XYZ(10, 0, 0)], leader.Vertices); + // Composed translation (5,20) + (2,3) = (7,23), both inserts translation-only. + Assert.Equal([new SurfacePoint(7, 77), new SurfacePoint(17, 77)], Assert.Single(surface.Polylines)); + } + + private static MLineStyle TwoElementStyle(double outer, MLineStyleFlags flags = MLineStyleFlags.None) + { + MLineStyle style = new("PLAN") { Flags = flags, FillColor = new ACadSharp.Color(3) }; + style.AddElement(new MLineStyle.Element { Offset = outer, Color = new ACadSharp.Color(1) }); + style.AddElement(new MLineStyle.Element { Offset = -outer, Color = ACadSharp.Color.ByLayer }); + return style; + } + + private static MLine.Vertex VertexAt(double x, double y, params double[][] parameters) + { + MLine.Vertex vertex = new() { Position = new XYZ(x, y, 0), Direction = new XYZ(1, 0, 0), Miter = new XYZ(0, 1, 0) }; + foreach (double[] segment in parameters) + { + MLine.Vertex.Segment element = new(); + element.Parameters.AddRange(segment); + vertex.Segments.Add(element); + } + + return vertex; + } + + [Fact] + public void MLineDrawsOnePolylinePerStyleElementAtTheStoredOffsets() + { + RecordingDrawingSurface surface = new(); + ImageConfiguration configuration = new(); + MLine mline = new() { Style = TwoElementStyle(0.5), Vertices = { VertexAt(0, 10, [0.5, 0], [-0.5, 0]), VertexAt(20, 10, [0.5, 0], [-0.5, 0]) } }; + + new EntityRenderDispatcher(configuration).Draw(CreateContext(surface, configuration), mline); + + Assert.Equal(2, surface.Polylines.Count); + Assert.Equal([new SurfacePoint(0, 89.5), new SurfacePoint(20, 89.5)], surface.Polylines[0]); + Assert.Equal([new SurfacePoint(0, 90.5), new SurfacePoint(20, 90.5)], surface.Polylines[1]); + Assert.All(surface.Calls.Where(c => c.StartsWith("DrawPolyline", StringComparison.Ordinal)), c => Assert.EndsWith("closed=False", c)); + // Element colour 1 (red) is used for the first element; ByLayer falls back to the entity's resolved colour. + Assert.Equal(SixLabors.ImageSharp.Color.Red.ToPixel(), surface.Styles[0].StrokeColor.ToPixel()); + } + + [Fact] + public void MLineWithoutVertexParametersFallsBackToStyleOffsetsAndJustification() + { + RecordingDrawingSurface surface = new(); + ImageConfiguration configuration = new(); + List notifications = new(); + configuration.OnNotification += (_, e) => notifications.Add(e); + MLine mline = new() { Style = TwoElementStyle(0.5), ScaleFactor = 2, Justification = MLineJustification.Top, Vertices = { VertexAt(0, 10), VertexAt(20, 10) } }; + + new EntityRenderDispatcher(configuration).Draw(CreateContext(surface, configuration), mline); + + // Top justification puts the +0.5 element on the vertex line; the other lies 2 * 1.0 below it. + Assert.Equal([new SurfacePoint(0, 90), new SurfacePoint(20, 90)], surface.Polylines[0]); + Assert.Equal([new SurfacePoint(0, 92), new SurfacePoint(20, 92)], surface.Polylines[1]); + Assert.Contains(notifications, n => n.NotificationType == NotificationType.Warning); + } + + [Fact] + public void ClosedMLineClosesEveryElementAndFillsBetweenTheOuterOnes() + { + RecordingDrawingSurface surface = new(); + ImageConfiguration configuration = new(); + // The third vertex's miter is not the shared (0,1,0) of the other two: a degenerate miter there would make + // the wall closing the ring back to the first vertex zero-width, hiding whether the fill actually covers it. + MLine.Vertex third = VertexAt(20, 20, [1, 0], [-1, 0]); + third.Miter = new XYZ(-1, 0, 0); + MLine mline = new() + { + Style = TwoElementStyle(1, MLineStyleFlags.FillOn), + Flags = MLineFlags.Closed, + Vertices = { VertexAt(0, 0, [1, 0], [-1, 0]), VertexAt(20, 0, [1, 0], [-1, 0]), third }, + }; + + new EntityRenderDispatcher(configuration).Draw(CreateContext(surface, configuration), mline); + + Assert.All(surface.Calls.Where(c => c.StartsWith("DrawPolyline", StringComparison.Ordinal)), c => Assert.EndsWith("closed=True", c)); + IReadOnlyList fill = Assert.Single(surface.Polygons); + // Keyhole fill: outer ring (3), a bridge back to the outer and inner starts (2), inner ring reversed (3). + // The third vertex's (-1,0,0) miter puts its outer point at x 19 and its inner point at x 21. + Assert.Equal( + [ + new SurfacePoint(0, 99), new SurfacePoint(20, 99), new SurfacePoint(19, 80), + new SurfacePoint(0, 99), new SurfacePoint(0, 101), + new SurfacePoint(21, 80), new SurfacePoint(20, 101), new SurfacePoint(0, 101), + ], + fill); + Assert.Equal("FillPolygon n=8", surface.Calls.First(c => c.StartsWith("Fill", StringComparison.Ordinal) || c.StartsWith("DrawPolyline", StringComparison.Ordinal))); + } + + [Theory] + // No cut values: one run covering the whole element. + [InlineData(new double[] { 0.5, 0 }, 10d, new double[] { 0, 10 })] + // A single break at the end is not a cut. + [InlineData(new double[] { 0.5, 0, 10 }, 10d, new double[] { 0, 10 })] + // Break at 4, resume at 6. + [InlineData(new double[] { 0.5, 0, 4, 6 }, 10d, new double[] { 0, 4, 6, 10 })] + // Odd count: the element ends hidden after the last value. + [InlineData(new double[] { 0.5, 0, 4 }, 10d, new double[] { 0, 4 })] + // Two cuts. + [InlineData(new double[] { 0.5, 0, 2, 3, 7, 8 }, 10d, new double[] { 0, 2, 3, 7, 8, 10 })] + // Values past the end are clamped. + [InlineData(new double[] { 0.5, 0, 4, 99 }, 10d, new double[] { 0, 4 })] + public void VisibleRunsFollowTheAbsoluteCutPositions(double[] parameters, double length, double[] expected) + { + IReadOnlyList<(double Start, double End)> runs = EntityRenderDispatcher.VisibleRuns(parameters, length); + + Assert.Equal(expected.Length / 2, runs.Count); + for (int i = 0; i < runs.Count; i++) + { + Assert.Equal(expected[2 * i], runs[i].Start, 9); + Assert.Equal(expected[(2 * i) + 1], runs[i].End, 9); + } + } + + [Fact] + public void VisibleRunsStopAtANonFiniteOrDecreasingValue() + { + Assert.Equal([(0d, 4d)], EntityRenderDispatcher.VisibleRuns([0.5, 0, 4, double.NaN, 8], 10d)); + Assert.Equal([(0d, 4d)], EntityRenderDispatcher.VisibleRuns([0.5, 0, 4, 3], 10d)); + } + + [Fact] + public void AnMLineWithACutDrawsTwoRunsForThatElement() + { + RecordingDrawingSurface surface = new(); + ImageConfiguration configuration = new(); + MLineStyle style = new("CUT"); + style.AddElement(new MLineStyle.Element { Offset = 0 }); + MLine mline = new() + { + Style = style, + Vertices = + { + VertexAt(0, 10, [0, 0, 4, 6]), + VertexAt(20, 10, [0, 0, 4, 6]), + }, + }; + + new EntityRenderDispatcher(configuration).Draw(CreateContext(surface, configuration), mline); + + // A cut element leaves the single-polyline path entirely: its runs are drawn as separate lines. + Assert.Empty(surface.Polylines); + Assert.Equal(2, surface.Lines.Count); + Assert.Equal((new SurfacePoint(0, 90), new SurfacePoint(4, 90)), surface.Lines[0]); + Assert.Equal((new SurfacePoint(6, 90), new SurfacePoint(20, 90)), surface.Lines[1]); + } + + [Fact] + public void AnMLineWhoseOnlyCutValueSitsAtTheSegmentEndStillDrawsOnePolylinePerElement() + { + RecordingDrawingSurface surface = new(); + ImageConfiguration configuration = new(); + // The shape the one real-world sample carries: a third parameter equal to the segment length, which is a + // break at the very end and so no cut at all. Such an element must stay on the single-polyline path, or + // every dashed multiline in the repository would restart its linetype phase at each vertex. + MLine mline = new() + { + Style = TwoElementStyle(0.5), + Vertices = { VertexAt(0, 10, [0.5, 0, 20], [-0.5, 0, 20]), VertexAt(20, 10, [0.5, 0, 20], [-0.5, 0, 20]) }, + }; + + new EntityRenderDispatcher(configuration).Draw(CreateContext(surface, configuration), mline); + + Assert.Equal(2, surface.Polylines.Count); + Assert.Empty(surface.Lines); + Assert.Equal([new SurfacePoint(0, 89.5), new SurfacePoint(20, 89.5)], surface.Polylines[0]); + Assert.Equal([new SurfacePoint(0, 90.5), new SurfacePoint(20, 90.5)], surface.Polylines[1]); + } + + [Fact] + public void AClosedMLineCutsItsClosingSegmentToo() + { + RecordingDrawingSurface surface = new(); + ImageConfiguration configuration = new(); + MLineStyle style = new("CUT"); + style.AddElement(new MLineStyle.Element { Offset = 0 }); + // A 20-15-25 triangle, so the closing segment (the third vertex back to the first) has an exact length of + // 25 and its runs land on whole surface coordinates. The cut is stored on that last vertex. + MLine mline = new() + { + Style = style, + Flags = MLineFlags.Closed, + Vertices = { VertexAt(0, 10, [0, 0]), VertexAt(20, 10, [0, 0]), VertexAt(20, 25, [0, 0, 5, 20]) }, + }; + + new EntityRenderDispatcher(configuration).Draw(CreateContext(surface, configuration), mline); + + // A cut anywhere puts the whole element on the per-run path, so all three segments are drawn as lines: the + // first two whole, the closing one broken at 5 and resumed at 20 of its 25 units, i.e. at fractions 0.2 and + // 0.8 of the surface segment from (20,75) to (0,90). + Assert.Empty(surface.Polylines); + Assert.Equal(4, surface.Lines.Count); + Assert.Equal((new SurfacePoint(0, 90), new SurfacePoint(20, 90)), surface.Lines[0]); + Assert.Equal((new SurfacePoint(20, 90), new SurfacePoint(20, 75)), surface.Lines[1]); + Assert.Equal((new SurfacePoint(20, 75), new SurfacePoint(16, 78)), surface.Lines[2]); + Assert.Equal((new SurfacePoint(4, 87), new SurfacePoint(0, 90)), surface.Lines[3]); + } + + [Fact] + public void ACutMLineInsideAScaledInsertScalesItsRuns() + { + RecordingDrawingSurface surface = new(); + ImageConfiguration configuration = new(); + MLineStyle style = new("CUT"); + style.AddElement(new MLineStyle.Element { Offset = 0 }); + BlockRecord block = new("WALL"); + block.Entities.Add(new MLine + { + Style = style, + Vertices = { VertexAt(0, 0, [0, 0, 4, 6]), VertexAt(20, 0, [0, 0, 4, 6]) }, + }); + Insert insert = new(block) { InsertPoint = new XYZ(0, 10, 0), XScale = 2, YScale = 2, ZScale = 2 }; + + new EntityRenderDispatcher(configuration).Draw(CreateContext(surface, configuration), insert); + + // The cut positions are stored in the multiline's own units, so a 2x insert puts the 4..6 gap at 8..12. + Assert.Empty(surface.Polylines); + Assert.Equal(2, surface.Lines.Count); + Assert.Equal((new SurfacePoint(0, 90), new SurfacePoint(8, 90)), surface.Lines[0]); + Assert.Equal((new SurfacePoint(12, 90), new SurfacePoint(40, 90)), surface.Lines[1]); + } + + [Fact] + public void AnMLineWithAreaFillCutsNotifiesThatFillCutsAreNotDrawn() + { + RecordingDrawingSurface surface = new(); + ImageConfiguration configuration = new(); + List notifications = new(); + configuration.OnNotification += (_, e) => notifications.Add(e); + MLine mline = new() { Style = TwoElementStyle(0.5), Vertices = { VertexAt(0, 10, [0.5, 0], [-0.5, 0]), VertexAt(20, 10, [0.5, 0], [-0.5, 0]) } }; + mline.Vertices[0].Segments[0].AreaFillParameters.Add(2); + mline.Vertices[0].Segments[0].AreaFillParameters.Add(5); + + new EntityRenderDispatcher(configuration).Draw(CreateContext(surface, configuration), mline); + + Assert.Contains(notifications, n => n.NotificationType == NotificationType.NotImplemented && n.Message.Contains("fill cuts", StringComparison.OrdinalIgnoreCase)); + } + + [Fact] + public void MLineInsideABlockIsDrawnThroughTheInsertAndKeepsItsVertices() + { + RecordingDrawingSurface surface = new(); + ImageConfiguration configuration = new(); + MLine mline = new() { Style = TwoElementStyle(0.5), Vertices = { VertexAt(0, 0, [0.5, 0], [-0.5, 0]), VertexAt(10, 0, [0.5, 0], [-0.5, 0]) } }; + BlockRecord block = new("WALL"); + block.Entities.Add(mline); + Insert insert = new(block) { InsertPoint = new XYZ(5, 20, 0) }; + + new EntityRenderDispatcher(configuration).Draw(CreateContext(surface, configuration), insert); + + Assert.Equal(2, mline.Vertices.Count); + 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() + { + RecordingDrawingSurface surface = new(); + ImageConfiguration configuration = new(); + MLine mline = new() { Style = TwoElementStyle(0.5), Vertices = { VertexAt(0, 0, [0.5, 0], [-0.5, 0]), VertexAt(10, 0, [0.5, 0], [-0.5, 0]) } }; + BlockRecord inner = new("INNER"); + inner.Entities.Add(mline); + Insert nestedInsert = new(inner) { InsertPoint = new XYZ(2, 3, 0) }; + BlockRecord outer = new("OUTER"); + outer.Entities.Add(nestedInsert); + Insert outerInsert = new(outer) { InsertPoint = new XYZ(5, 20, 0) }; + + 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); + 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 + // 22.5 -> surface 77.5. X shifts by 7 for both vertices (0 and 10). + Assert.Equal([new SurfacePoint(7, 76.5), new SurfacePoint(17, 76.5)], surface.Polylines[0]); + Assert.Equal([new SurfacePoint(7, 77.5), new SurfacePoint(17, 77.5)], surface.Polylines[1]); + } + + private static Wipeout UnitWipeout() + { + // Insert (10,10), one-pixel image whose pixel spans 5 x 5 drawing units. + return new Wipeout + { + InsertPoint = new XYZ(10, 10, 0), + UVector = new XYZ(5, 0, 0), + VVector = new XYZ(0, 5, 0), + Size = new XY(1, 1), + Flags = ImageDisplayFlags.ShowImage | ImageDisplayFlags.UseClippingBoundary, + ClippingState = true, + }; + } + + [Fact] + public void WipeoutPixelMappingFlipsYAndCentresPixels() + { + Wipeout wipeout = UnitWipeout(); + + Assert.Equal(new XYZ(10, 15, 0), EntityRenderDispatcher.WipeoutPixelToWorld(wipeout, new XY(-0.5, -0.5), null)); + Assert.Equal(new XYZ(15, 10, 0), EntityRenderDispatcher.WipeoutPixelToWorld(wipeout, new XY(0.5, 0.5), null)); + } + + [Fact] + 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); + + new EntityRenderDispatcher(configuration).Draw(CreateContext(surface, configuration), wipeout); + + IReadOnlyList polygon = Assert.Single(surface.Polygons); + Assert.Equal(4, polygon.Count); + Assert.Equal(new HashSet { new(10, 90), new(15, 90), new(15, 85), new(10, 85) }, polygon.ToHashSet()); + ImageStyle style = Assert.Single(surface.Styles); + Assert.Equal(SixLabors.ImageSharp.Color.White.ToPixel(), style.StrokeColor.ToPixel()); + Assert.Equal(1f, style.Opacity); + Assert.Empty(surface.Polylines); + } + + [Fact] + 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); + + Assert.Equal([new SurfacePoint(10, 85), new SurfacePoint(15, 85), new SurfacePoint(12.5, 90)], Assert.Single(surface.Polygons)); + } + + [Fact] + public void WipeoutWithoutClippingFillsTheWholeImageFrame() + { + RecordingDrawingSurface surface = new(); + ImageConfiguration configuration = new(); + Wipeout wipeout = UnitWipeout(); + wipeout.ClippingState = false; + + new EntityRenderDispatcher(configuration).Draw(CreateContext(surface, configuration), wipeout); + + Assert.Equal(new HashSet { new(10, 90), new(15, 90), new(15, 85), new(10, 85) }, Assert.Single(surface.Polygons).ToHashSet()); + } + + [Fact] + public void WipeoutOnTransparentBackgroundIsSkippedWithAWarning() + { + RecordingDrawingSurface surface = new(); + ImageConfiguration configuration = new() { BackgroundColor = SixLabors.ImageSharp.Color.Transparent }; + List notifications = new(); + configuration.OnNotification += (_, e) => notifications.Add(e); + + new EntityRenderDispatcher(configuration).Draw(CreateContext(surface, configuration), UnitWipeout()); + + Assert.Empty(surface.Polygons); + Assert.Contains(notifications, n => n.NotificationType == NotificationType.Warning); + } + + [Fact] + public void AnInvertedWipeoutMasksTheFrameMinusItsBoundary() + { + RecordingDrawingSurface surface = new(); + ImageConfiguration configuration = new(); + Wipeout wipeout = UnitWipeout(); + // A boundary strictly inside the frame (not equal to it, as UnitWipeout's own (-0.5,-0.5)..(0.5,0.5) frame + // would be): the two rings must be genuinely different point sets, or an implementation that returns the + // frame twice (or the boundary twice) would satisfy a count-only assertion unnoticed. + wipeout.ClipBoundaryVertices.AddRange([new XY(-0.25, -0.25), new XY(0.25, 0.25)]); + wipeout.ClipMode = ClipMode.Inside; + + new EntityRenderDispatcher(configuration).Draw(CreateContext(surface, configuration), wipeout); + + IReadOnlyList> rings = Assert.Single(surface.FillPaths); + Assert.Equal(2, rings.Count); + // Ring 0 is the whole image frame; ring 1 is the boundary, strictly inside it. + Assert.Equal(new HashSet { new(10, 85), new(15, 85), new(15, 90), new(10, 90) }, rings[0].ToHashSet()); + Assert.Equal(new HashSet { new(11.25, 86.25), new(13.75, 86.25), new(13.75, 88.75), new(11.25, 88.75) }, rings[1].ToHashSet()); + Assert.Empty(surface.Polygons); + } + + [Fact] + public void AWipeoutWithClippingOffFillsTheWholeFrameEvenWhenItsModeIsInverted() + { + RecordingDrawingSurface surface = new(); + ImageConfiguration configuration = new(); + Wipeout wipeout = UnitWipeout(); + wipeout.ClipMode = ClipMode.Inside; + wipeout.ClippingState = false; + + new EntityRenderDispatcher(configuration).Draw(CreateContext(surface, configuration), wipeout); + + // Pins the single ring to the whole image frame, not merely its count, so a wrong ring cannot pass unnoticed. + Assert.Equal(new HashSet { new(10, 85), new(15, 85), new(15, 90), new(10, 90) }, Assert.Single(surface.Polygons).ToHashSet()); + Assert.Empty(surface.FillPaths); + } + + [Fact] + public void AnOrdinaryWipeoutStillFillsOnePolygon() + { + RecordingDrawingSurface surface = new(); + ImageConfiguration configuration = new(); + + new EntityRenderDispatcher(configuration).Draw(CreateContext(surface, configuration), UnitWipeout()); + + Assert.Single(surface.Polygons); + Assert.Empty(surface.FillPaths); + } + + [Fact] + public void AWipeoutInsideAnInsertIsMappedFromTheOriginalSoItsUAndVStayDirections() + { + RecordingDrawingSurface surface = new(); + ImageConfiguration configuration = new(); + BlockRecord block = new("MASK"); + block.Entities.Add(UnitWipeout()); + Insert insert = new(block) { InsertPoint = new XYZ(50, 0, 0) }; + + new EntityRenderDispatcher(configuration).Draw(CreateContext(surface, configuration), insert); + + 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 + // stretch it to [60,115] instead. + Assert.Equal(60d, polygon.Min(p => p.X), 6); + Assert.Equal(65d, polygon.Max(p => p.X), 6); + } + + [Fact] + public void AHiddenWipeoutDrawsNothing() + { + RecordingDrawingSurface surface = new(); + ImageConfiguration configuration = new(); + Wipeout wipeout = UnitWipeout(); + wipeout.Flags = 0; + + new EntityRenderDispatcher(configuration).Draw(CreateContext(surface, configuration), wipeout); + + Assert.Empty(surface.Polygons); + Assert.Empty(surface.FillPaths); + } + + [Fact] + public void MLineWithANonFiniteStyleOffsetIsSkippedWithAWarning() + { + RecordingDrawingSurface surface = new(); + ImageConfiguration configuration = new(); + List notifications = new(); + configuration.OnNotification += (_, e) => notifications.Add(e); + MLineStyle style = new("BROKEN") { Flags = MLineStyleFlags.FillOn, FillColor = new ACadSharp.Color(3) }; + style.AddElement(new MLineStyle.Element { Offset = 0.5, Color = ACadSharp.Color.ByLayer }); + style.AddElement(new MLineStyle.Element { Offset = double.NaN, Color = ACadSharp.Color.ByLayer }); + // The vertices carry no parameters, so the style offsets (and the NaN with them) reach the geometry. + MLine mline = new() { Style = style, Vertices = { VertexAt(0, 10), VertexAt(20, 10) } }; + + new EntityRenderDispatcher(configuration).Draw(CreateContext(surface, configuration), mline); + + // A NaN scaled offset used to slip past Enumerable.Min (which, unlike Max, does not skip NaN), silently + // dropping the fill ring while still stroking both elements; it is now caught before any drawing and the + // whole entity is skipped with a warning instead. + Assert.Empty(surface.Polylines); + Assert.Empty(surface.Polygons); + Assert.Contains(notifications, n => n.NotificationType == NotificationType.Warning && n.Message.Contains("non-finite", StringComparison.OrdinalIgnoreCase)); + } + + [Fact] + public void MLineFallbackUnderANegativeScaleAnchorsTheGeometricTopElement() + { + RecordingDrawingSurface surface = new(); + ImageConfiguration configuration = new(); + MLine mline = new() { Style = TwoElementStyle(0.5), ScaleFactor = -2, Justification = MLineJustification.Top, Vertices = { VertexAt(0, 10), VertexAt(20, 10) } }; + + new EntityRenderDispatcher(configuration).Draw(CreateContext(surface, configuration), mline); + + // Scaled offsets are -1 (element 0) and +1 (element 1); Top puts the +1 element on the vertex line and element 0 two units below it. + Assert.Equal([new SurfacePoint(0, 92), new SurfacePoint(20, 92)], surface.Polylines[0]); + Assert.Equal([new SurfacePoint(0, 90), new SurfacePoint(20, 90)], surface.Polylines[1]); + } + + [Fact] + public void MLineElementWithByLayerLinetypeInheritsTheEntityDashes() + { + RecordingDrawingSurface surface = new(); + ImageConfiguration configuration = new(); + MLineStyle style = new("DASHED"); + style.AddElement(new MLineStyle.Element { Offset = 0.5, LineType = new LineType(LineType.ByLayerName) }); + style.AddElement(new MLineStyle.Element { Offset = -0.5 }); + LineType dashed = new("DASHED2"); + dashed.AddSegment(new LineType.Segment { Length = 2 }); + dashed.AddSegment(new LineType.Segment { Length = -1 }); + // A third element carries its own genuinely named linetype, distinct from both the ByLayer placeholder and + // the entity's own "DASHED2": it must still resolve through LineTypeDashResolver instead of falling into + // the ByLayer/ByBlock inheritance path, so a regression that made every element inherit the entity's dashes + // would leave this element's pattern indistinguishable from the other two and be caught here. + // Lengths chosen well above ImageConfiguration.MinimumDashPixels' default (2), so the pattern resolves to + // an actual dash array rather than collapsing to solid (null) for being too short to render. + LineType dotted = new("DOTTED2"); + dotted.AddSegment(new LineType.Segment { Length = 3 }); + dotted.AddSegment(new LineType.Segment { Length = -2 }); + style.AddElement(new MLineStyle.Element { Offset = 0, LineType = dotted }); + MLine mline = new() + { + Style = style, + LineType = dashed, + Vertices = + { + VertexAt(0, 10, [0.5, 0], [-0.5, 0], [0, 0]), + VertexAt(20, 10, [0.5, 0], [-0.5, 0], [0, 0]), + }, + }; + + new EntityRenderDispatcher(configuration).Draw(CreateContext(surface, configuration), mline); + + Assert.NotNull(surface.Styles[0].DashPattern); + Assert.Equal(surface.Styles[1].DashPattern, surface.Styles[0].DashPattern); + Assert.NotNull(surface.Styles[2].DashPattern); + Assert.NotEqual(surface.Styles[0].DashPattern, surface.Styles[2].DashPattern); + } + + [Fact] + public void MLineWithANonFiniteScaleIsSkippedWithAWarning() + { + RecordingDrawingSurface surface = new(); + ImageConfiguration configuration = new(); + List notifications = new(); + configuration.OnNotification += (_, e) => notifications.Add(e); + MLine mline = new() { Style = TwoElementStyle(0.5), ScaleFactor = double.NaN, Vertices = { VertexAt(0, 10), VertexAt(20, 10) } }; + + new EntityRenderDispatcher(configuration).Draw(CreateContext(surface, configuration), mline); + + Assert.Empty(surface.Polylines); + Assert.Contains(notifications, n => n.NotificationType == NotificationType.Warning && n.Message.Contains("non-finite", StringComparison.OrdinalIgnoreCase)); + } + + [Fact] + public void MLineWithOneVertexInsideABlockWarnsThatItHasNoVertices() + { + RecordingDrawingSurface surface = new(); + ImageConfiguration configuration = new(); + List notifications = new(); + configuration.OnNotification += (_, e) => notifications.Add(e); + MLine mline = new() { Style = TwoElementStyle(0.5), Vertices = { VertexAt(0, 0, [0.5, 0], [-0.5, 0]) } }; + BlockRecord block = new("STUB"); + block.Entities.Add(mline); + + new EntityRenderDispatcher(configuration).Draw(CreateContext(surface, configuration), new Insert(block)); + + Assert.Empty(surface.Polylines); + Assert.Contains(notifications, n => n.NotificationType == NotificationType.Warning && n.Message.Contains("no vertices", StringComparison.Ordinal)); + } + + [Fact] + public void WipeoutOnATranslucentBackgroundIsSkippedWithAWarning() + { + RecordingDrawingSurface surface = new(); + ImageConfiguration configuration = new() { BackgroundColor = SixLabors.ImageSharp.Color.FromRgba(255, 255, 255, 128) }; + List notifications = new(); + configuration.OnNotification += (_, e) => notifications.Add(e); + + new EntityRenderDispatcher(configuration).Draw(CreateContext(surface, configuration), UnitWipeout()); + + // A partly transparent fill blends on the raster backend and masks fully in SVG (Hex drops alpha), so a + // wipeout that cannot mask is skipped rather than drawn differently by the two backends. + Assert.Empty(surface.Polygons); + Assert.Contains(notifications, n => n.NotificationType == NotificationType.Warning && n.Message.Contains("an opaque background", StringComparison.Ordinal)); + } + + /// + /// Renders to PNG through the public exporter alongside one ordinary line, and returns + /// the warnings raised. Non-finite geometry must cost its own entity and nothing more. + /// + /// The entity whose geometry carries NaN. + /// The warnings raised during the export. + private static List RenderWithNonFiniteEntity(Entity entity) + { + BlockRecord block = new("non-finite"); + block.Entities.Add(new Line(new XYZ(0, 0, 0), new XYZ(100, 50, 0))); + block.Entities.Add(entity); + ImageExporter exporter = new(); + List notifications = new(); + exporter.Configuration.OnNotification += (_, e) => notifications.Add(e); + exporter.Add(block); + + using RenderedImagePage page = Assert.IsType(Assert.Single(exporter.Render())); + + Assert.NotNull(page.Canvas); + return notifications.Where(n => n.NotificationType == NotificationType.Warning).ToList(); + } + + [Fact] + public void FilledMLineWithANonFiniteVertexIsSkippedWithoutKillingTheExport() + { + MLine mline = new() + { + Style = TwoElementStyle(0.5, MLineStyleFlags.FillOn), + Vertices = { VertexAt(0, 0, [0.5, 0], [-0.5, 0]), VertexAt(20, 0, [0.5, 0], [-0.5, 0]) }, + }; + mline.Vertices[1].Position = new XYZ(double.NaN, 0, 0); + + // ImageSharp's fill throws ArithmeticException on a NaN vertex, which is neither an ArgumentException nor an + // InvalidOperationException: unguarded, one malformed multiline takes the whole page down. A non-finite + // vertex position fails HasFiniteGeometry before drawing is attempted at all, so the message is the + // dispatcher's own, not the raster backend's ArithmeticException backstop (which must never be reached: it + // would mean the dispatcher's own check let a NaN vertex through to ImageSharp's fill). + List warnings = RenderWithNonFiniteEntity(mline); + Assert.Contains("geometry contains non-finite values; entity skipped", Assert.Single(warnings).Message, StringComparison.Ordinal); + Assert.DoesNotContain(warnings, w => w.Message.Contains("Raster:", StringComparison.Ordinal)); + } + + [Fact] + public void MLineWithANonFiniteCutPositionIsSkippedWithoutKillingTheExport() + { + // A cut position places the end of a run, so it reaches the surface exactly as a vertex position does and + // has to be validated with them: an MLINE carrying NaN anywhere in its parameters is skipped whole. + MLine mline = new() + { + Style = TwoElementStyle(0.5), + Vertices = { VertexAt(0, 0, [0.5, 0, double.NaN], [-0.5, 0]), VertexAt(20, 0, [0.5, 0], [-0.5, 0]) }, + }; + + List warnings = RenderWithNonFiniteEntity(mline); + Assert.Contains("geometry contains non-finite values; entity skipped", Assert.Single(warnings).Message, StringComparison.Ordinal); + } + + [Fact] + public void WipeoutWithANonFiniteVectorIsSkippedWithoutKillingTheExport() + { + Wipeout wipeout = UnitWipeout(); + wipeout.UVector = new XYZ(double.NaN, 0, 0); + + // As above: a non-finite UVector fails HasFiniteGeometry before drawing is attempted, so the message is the + // dispatcher's own and the raster backend's non-finite backstop must never fire. + List warnings = RenderWithNonFiniteEntity(wipeout); + Assert.Contains("geometry contains non-finite values; entity skipped", Assert.Single(warnings).Message, StringComparison.Ordinal); + Assert.DoesNotContain(warnings, w => w.Message.Contains("Raster:", StringComparison.Ordinal)); + } + + [Fact] + public void InsertWithoutABlockIsSkippedWithAWarning() + { + RecordingDrawingSurface surface = new(); + ImageConfiguration configuration = new(); + List notifications = new(); + configuration.OnNotification += (_, e) => notifications.Add(e); + Insert insert = new(new BlockRecord("GONE")); + typeof(Insert).GetProperty(nameof(Insert.Block))!.SetValue(insert, null); + + new EntityRenderDispatcher(configuration).Draw(CreateContext(surface, configuration), insert); + + Assert.DoesNotContain(surface.Calls, c => c.StartsWith("Draw", StringComparison.Ordinal)); + Assert.Contains(notifications, n => n.NotificationType == NotificationType.Warning && n.Message.Contains("no block", StringComparison.OrdinalIgnoreCase)); + } + + [Fact] + public void ABlockThatReferencesItselfIsSkippedWithAWarningInsteadOfOverflowing() + { + RecordingDrawingSurface surface = new(); + ImageConfiguration configuration = new(); + List notifications = new(); + configuration.OnNotification += (_, e) => notifications.Add(e); + CadDocument document = new(); + BlockRecord outer = new("OUTER"); + BlockRecord inner = new("INNER"); + document.BlockRecords.Add(outer); + 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 + // 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. + Insert insert = new(outer); + document.Entities.Add(insert); + outer.Entities.Add(new Insert(inner)); + inner.Entities.Add(new Insert(outer)); + + new EntityRenderDispatcher(configuration).Draw(CreateContext(surface, configuration), insert); + + Assert.Contains(notifications, n => n.NotificationType == NotificationType.Warning && n.Message.Contains("references itself", StringComparison.OrdinalIgnoreCase)); + Assert.Empty(surface.Lines); + } + + [Fact] + public void ACircularBlockGraphDoesNotKillTheExporterWhileFramingThePage() + { + // Framing runs before rendering and recurses through the same graph, so this is the call that dies first if + // only the draw path is guarded. A stack overflow cannot be caught, so a regression here takes the whole + // test process down rather than failing this test: run it on its own when it is new. + CadDocument document = new(); + BlockRecord outer = new("OUTER"); + BlockRecord inner = new("INNER"); + document.BlockRecords.Add(outer); + document.BlockRecords.Add(inner); + outer.Entities.Add(new Line(new XYZ(0, 0, 0), new XYZ(1, 0, 0))); + // See the comment above: the insert is built and added to the document before the cycle is closed, because + // constructing an Insert of an already-cyclic block overflows the stack inside ACadSharp's own constructor. + Insert insert = new(outer); + document.Entities.Add(insert); + outer.Entities.Add(new Insert(inner)); + inner.Entities.Add(new Insert(outer)); + ImageExporter exporter = new(); + + exporter.Add(document.ModelSpace); + + Assert.NotNull(exporter.Pages); + } + + [Fact] + public void AnOrdinaryNestedBlockStillDraws() + { + RecordingDrawingSurface surface = new(); + ImageConfiguration configuration = new(); + BlockRecord outer = new("OUTER"); + BlockRecord inner = new("INNER"); + inner.Entities.Add(new Line(new XYZ(0, 0, 0), new XYZ(10, 0, 0))); + outer.Entities.Add(new Insert(inner) { InsertPoint = new XYZ(0, 5, 0) }); + Insert insert = new(outer) { InsertPoint = new XYZ(2, 3, 0) }; + + new EntityRenderDispatcher(configuration).Draw(CreateContext(surface, configuration), insert); + + Assert.Single(surface.Lines); + } + + [Fact] + public void ABlockCycleHiddenBehindAnMLineIsStillDetected() + { + // ScanBlockSubtree (used for the MLINE/LEADER heal scan) stops at the first MLINE or LEADER it finds and + // never looks past it. BlockGraphIsCircular must not share that shortcut: the cycle-closing Insert here sits + // behind an MLine as the block's first entity, so a regression that delegated cycle detection back to + // ScanBlockSubtree-style logic would never reach it and would pass every other cycle test in this file. + RecordingDrawingSurface surface = new(); + ImageConfiguration configuration = new(); + List notifications = new(); + configuration.OnNotification += (_, e) => notifications.Add(e); + CadDocument document = new(); + BlockRecord outer = new("OUTER"); + BlockRecord inner = new("INNER"); + document.BlockRecords.Add(outer); + document.BlockRecords.Add(inner); + outer.Entities.Add(new MLine()); + // See the construction-order comments on the other cycle tests above: the insert under test is built while + // outer's graph is still acyclic (only the MLine is there), and the cycle is closed afterward. + Insert insert = new(outer); + document.Entities.Add(insert); + outer.Entities.Add(new Insert(inner)); + inner.Entities.Add(new Insert(outer)); + + new EntityRenderDispatcher(configuration).Draw(CreateContext(surface, configuration), insert); + + Assert.Contains(notifications, n => n.NotificationType == NotificationType.Warning && n.Message.Contains("references itself", StringComparison.OrdinalIgnoreCase)); + Assert.DoesNotContain(surface.Calls, c => c.StartsWith("Draw", StringComparison.Ordinal)); + } + + [Fact] + public void ADirectSelfReferencingBlockIsDetected() + { + RecordingDrawingSurface surface = new(); + ImageConfiguration configuration = new(); + List notifications = new(); + configuration.OnNotification += (_, e) => notifications.Add(e); + BlockRecord self = new("SELF"); + // Constructed while `self` is still empty, so ACadSharp's own Insert(BlockRecord) constructor - which + // itself recurses through the block, the same recursion Explode() performs - does not yet see a cycle. The + // self-reference is closed afterward purely through List.Add, and this very same Insert instance + // (not a freshly constructed one) is what gets drawn, so no later Insert(self) call ever runs against an + // already-cyclic block. + Insert insert = new(self); + self.Entities.Add(insert); + + new EntityRenderDispatcher(configuration).Draw(CreateContext(surface, configuration), insert); + + Assert.Contains(notifications, n => n.NotificationType == NotificationType.Warning && n.Message.Contains("references itself", StringComparison.OrdinalIgnoreCase)); + Assert.DoesNotContain(surface.Calls, c => c.StartsWith("Draw", StringComparison.Ordinal)); + } + + [Fact] + public void ADiamondSharedBlockIsNotMistakenForACycle() + { + // Two different paths (A -> B -> D and A -> C -> D) reach the same block D. Path-scoped cycle tracking must + // tell this apart from a real cycle: only a globally shared "visited" set would wrongly flag D the second + // time it is reached, which would silently refuse to draw any drawing that reuses a block from two places. + RecordingDrawingSurface surface = new(); + ImageConfiguration configuration = new(); + List notifications = new(); + configuration.OnNotification += (_, e) => notifications.Add(e); + BlockRecord a = new("A"); + BlockRecord b = new("B"); + BlockRecord c = new("C"); + BlockRecord d = new("D"); + d.Entities.Add(new Line(new XYZ(0, 0, 0), new XYZ(1, 0, 0))); + b.Entities.Add(new Insert(d)); + c.Entities.Add(new Insert(d)); + a.Entities.Add(new Insert(b)); + a.Entities.Add(new Insert(c)); + Insert insert = new(a); + + new EntityRenderDispatcher(configuration).Draw(CreateContext(surface, configuration), insert); + + Assert.DoesNotContain(notifications, n => n.NotificationType == NotificationType.Warning && n.Message.Contains("references itself", StringComparison.OrdinalIgnoreCase)); + Assert.Equal(2, surface.Lines.Count); + } + + /// Whether two surface points agree to within a rounding tolerance. + private static bool Close(SurfacePoint actual, SurfacePoint expected) + => Math.Abs(actual.X - expected.X) < 1e-9 && Math.Abs(actual.Y - expected.Y) < 1e-9; + + private static BlockRecord ArrowBlock(string name = "ARROW", double baseX = 0, double baseY = 0) + { + BlockRecord block = new(name); + block.BlockEntity.BasePoint = new XYZ(baseX, baseY, 0); + // A unit arrow: the tip sits at the base point and the body runs back along local -X. + block.Entities.Add(new Line(new XYZ(baseX - 1, baseY, 0), new XYZ(baseX, baseY, 0))); + block.Entities.Add(new Solid + { + FirstCorner = new XYZ(baseX - 1, baseY - 0.25, 0), + SecondCorner = new XYZ(baseX, baseY, 0), + ThirdCorner = new XYZ(baseX - 1, baseY + 0.25, 0), + FourthCorner = new XYZ(baseX, baseY, 0), + }); + return block; + } + + [Fact] + public void ALeaderWithACustomArrowBlockDrawsTheBlockAndNotifiesNothing() + { + RecordingDrawingSurface surface = new(); + ImageConfiguration configuration = new(); + List notifications = new(); + configuration.OnNotification += (_, e) => notifications.Add(e); + CadDocument document = new(); + BlockRecord arrow = ArrowBlock(); + document.BlockRecords.Add(arrow); + Leader leader = WithHandle(new Leader + { + ArrowHeadEnabled = true, + Vertices = { new XYZ(10, 10, 0), new XYZ(30, 10, 0) }, + Style = new DimensionStyle("A") { ArrowSize = 2, ScaleFactor = 1, LeaderArrow = arrow }, + }, 0x5A); + document.Entities.Add(leader); + + new EntityRenderDispatcher(configuration).Draw(CreateContext(surface, configuration), leader); + + Assert.DoesNotContain(notifications, n => n.NotificationType == NotificationType.NotImplemented); + // The block's own line: its local +X points outward, away from the leader running off to (30,10), so its + // body runs from the tip at (10,10) back towards the leader, twice as long as the block's own unit. + Assert.Contains(surface.Lines, l => l.Start == new SurfacePoint(12, 90) && l.End == new SurfacePoint(10, 90)); + // The block's solid, not the built-in triangle. + Assert.Single(surface.Polygons); + // The arrow's parts belong to the leader, not to the transient insert that placed them. + Assert.Equal(new ulong?[] { null, 0x5AUL, 0x5AUL }, surface.Entities.Select(e => e.ParentHandle).ToArray()); + } + + [Fact] + public void ACustomArrowRotatesToTheOutwardLeaderDirection() + { + RecordingDrawingSurface surface = new(); + ImageConfiguration configuration = new(); + CadDocument document = new(); + BlockRecord arrow = ArrowBlock(); + document.BlockRecords.Add(arrow); + // The leader runs downward from the tip, so the arrow's local +X must point up. + Leader leader = new() + { + ArrowHeadEnabled = true, + Vertices = { new XYZ(10, 50, 0), new XYZ(10, 20, 0) }, + Style = new DimensionStyle("A") { ArrowSize = 2, ScaleFactor = 1, LeaderArrow = arrow }, + }; + document.Entities.Add(leader); + + new EntityRenderDispatcher(configuration).Draw(CreateContext(surface, configuration), leader); + + Assert.Contains(surface.Lines, l => l.Start == new SurfacePoint(10, 52) && l.End == new SurfacePoint(10, 50)); + } + + [Fact] + public void ACustomArrowHonoursANonZeroBlockBasePoint() + { + RecordingDrawingSurface surface = new(); + ImageConfiguration configuration = new(); + CadDocument document = new(); + BlockRecord arrow = ArrowBlock("ARROWB", baseX: 5, baseY: 7); + document.BlockRecords.Add(arrow); + Leader leader = new() + { + ArrowHeadEnabled = true, + Vertices = { new XYZ(10, 10, 0), new XYZ(30, 10, 0) }, + Style = new DimensionStyle("A") { ArrowSize = 2, ScaleFactor = 1, LeaderArrow = arrow }, + }; + document.Entities.Add(leader); + + new EntityRenderDispatcher(configuration).Draw(CreateContext(surface, configuration), leader); + + // The base point is the arrow's tip, so it must land on the leader's first vertex exactly as in the + // zero-base-point case: the block's line still runs from (12,10) to (10,10) in world. The compensation for + // the base point goes through the insert's rotation, so the coordinates carry a few ulps of rounding. + Assert.Contains(surface.Lines, l => Close(l.Start, new SurfacePoint(12, 90)) && Close(l.End, new SurfacePoint(10, 90))); + } + + [Fact] + public void ACustomArrowInsideAScaledInsertScalesWithIt() + { + RecordingDrawingSurface surface = new(); + ImageConfiguration configuration = new(); + CadDocument document = new(); + BlockRecord arrow = ArrowBlock(); + document.BlockRecords.Add(arrow); + BlockRecord note = new("NOTE"); + document.BlockRecords.Add(note); + note.Entities.Add(new Leader + { + ArrowHeadEnabled = true, + Vertices = { new XYZ(0, 0, 0), new XYZ(10, 0, 0) }, + Style = new DimensionStyle("A") { ArrowSize = 2, ScaleFactor = 1, LeaderArrow = arrow }, + }); + Insert insert = new(note) { InsertPoint = new XYZ(10, 10, 0), XScale = 3, YScale = 3, ZScale = 3 }; + document.Entities.Add(insert); + + new EntityRenderDispatcher(configuration).Draw(CreateContext(surface, configuration), insert); + + // Arrow size 2 scaled by 3 is 6: the block's line runs from (16,10) to (10,10) in world. + Assert.Contains(surface.Lines, l => l.Start == new SurfacePoint(16, 90) && l.End == new SurfacePoint(10, 90)); + } + + [Fact] + public void ACustomArrowUnderANonUniformInsertFallsBackToTheDefaultTriangleWithAWarning() + { + RecordingDrawingSurface surface = new(); + ImageConfiguration configuration = new(); + List notifications = new(); + configuration.OnNotification += (_, e) => notifications.Add(e); + CadDocument document = new(); + BlockRecord arrow = ArrowBlock(); + document.BlockRecords.Add(arrow); + BlockRecord note = new("NOTE"); + document.BlockRecords.Add(note); + note.Entities.Add(new Leader + { + ArrowHeadEnabled = true, + Vertices = { new XYZ(0, 0, 0), new XYZ(10, 0, 0) }, + Style = new DimensionStyle("A") { ArrowSize = 2, ScaleFactor = 1, LeaderArrow = arrow }, + }); + Insert insert = new(note) { InsertPoint = new XYZ(10, 10, 0), XScale = 3, YScale = 1, ZScale = 1, Rotation = Math.PI / 4 }; + document.Entities.Add(insert); + + new EntityRenderDispatcher(configuration).Draw(CreateContext(surface, configuration), insert); + + Assert.Contains(notifications, n => n.Message.Contains("cannot be placed", StringComparison.OrdinalIgnoreCase)); + Assert.Single(surface.Polygons); + } + + [Fact] + public void ACustomArrowUnderARotatedNonUniformInsertFallsBackOnAxisLength() + { + // A rotated non-uniform insert, unlike the unrotated sibling above: an Insert's transform is a rotation + // times per-axis scales, so it maps the unit axes to lengths 3 and 1 at right angles however far it is + // turned, and this is rejected on length like the sibling rather than on orthogonality. No placement the + // renderer builds reaches the orthogonality branch of the gate: a tilted insert does not either, because + // ACadSharp's arbitrary-axis X always lies in the world XY plane and leaves the two projected axes at right + // angles with unequal lengths, and a nested insert is re-expressed by Explode() as an Insert, which cannot + // carry a shear in the first place. InsertPlacementTests.AShearedPlacementWithEqualLengthAxesIsNotASimilarity + // drives that branch directly with a hand-built shear instead. + RecordingDrawingSurface surface = new(); + ImageConfiguration configuration = new(); + List notifications = new(); + configuration.OnNotification += (_, e) => notifications.Add(e); + CadDocument document = new(); + BlockRecord arrow = ArrowBlock(); + document.BlockRecords.Add(arrow); + BlockRecord note = new("NOTE"); + document.BlockRecords.Add(note); + note.Entities.Add(new Leader + { + ArrowHeadEnabled = true, + Vertices = { new XYZ(0, 0, 0), new XYZ(10, 10, 0) }, + Style = new DimensionStyle("A") { ArrowSize = 2, ScaleFactor = 1, LeaderArrow = arrow }, + }); + Insert insert = new(note) { InsertPoint = new XYZ(10, 10, 0), XScale = 3, YScale = 1, ZScale = 1, Rotation = Math.PI / 4 }; + document.Entities.Add(insert); + + new EntityRenderDispatcher(configuration).Draw(CreateContext(surface, configuration), insert); + + Assert.Contains(notifications, n => n.Message.Contains("cannot be placed", StringComparison.OrdinalIgnoreCase)); + Assert.Single(surface.Polygons); + } + + [Fact] + public void ARecursiveArrowBlockFallsBackToTheDefaultTriangle() + { + RecordingDrawingSurface surface = new(); + ImageConfiguration configuration = new(); + List notifications = new(); + configuration.OnNotification += (_, e) => notifications.Add(e); + CadDocument document = new(); + BlockRecord arrow = ArrowBlock(); + document.BlockRecords.Add(arrow); + arrow.Entities.Add(new Insert(arrow)); + Leader leader = new() + { + ArrowHeadEnabled = true, + Vertices = { new XYZ(10, 10, 0), new XYZ(30, 10, 0) }, + Style = new DimensionStyle("A") { ArrowSize = 2, ScaleFactor = 1, LeaderArrow = arrow }, + }; + document.Entities.Add(leader); + + new EntityRenderDispatcher(configuration).Draw(CreateContext(surface, configuration), leader); + + Assert.Contains(notifications, n => n.Message.Contains("references itself", StringComparison.OrdinalIgnoreCase)); + Assert.Single(surface.Polygons); + } + + [Fact] + public void AnEmptyArrowBlockDrawsNothingExtraAndWarnsOnce() + { + RecordingDrawingSurface surface = new(); + ImageConfiguration configuration = new(); + List notifications = new(); + configuration.OnNotification += (_, e) => notifications.Add(e); + CadDocument document = new(); + BlockRecord arrow = new("EMPTY"); + document.BlockRecords.Add(arrow); + Leader leader = new() + { + ArrowHeadEnabled = true, + Vertices = { new XYZ(10, 10, 0), new XYZ(30, 10, 0) }, + Style = new DimensionStyle("A") { ArrowSize = 2, ScaleFactor = 1, LeaderArrow = arrow }, + }; + document.Entities.Add(leader); + + new EntityRenderDispatcher(configuration).Draw(CreateContext(surface, configuration), leader); + + Assert.Single(notifications); + Assert.Contains(notifications, n => n.Message.Contains("is empty", StringComparison.OrdinalIgnoreCase)); + Assert.Single(surface.Polygons); + } + + [Fact] + public void AnArrowBlockWhoseOwnLeaderUsesItAgainFallsBackToTheDefaultTriangle() + { + // Leader.Clone() deep-clones its dimension style and with it that style's arrowhead block, so an arrow + // block holding a leader that points back at it exhausts the stack inside Explode(), uncatchably; the + // cycle walk follows the leader-arrow edge for exactly that reason. + RecordingDrawingSurface surface = new(); + ImageConfiguration configuration = new(); + List notifications = new(); + configuration.OnNotification += (_, e) => notifications.Add(e); + CadDocument document = new(); + BlockRecord arrow = ArrowBlock(); + document.BlockRecords.Add(arrow); + arrow.Entities.Add(new Leader + { + ArrowHeadEnabled = true, + Vertices = { new XYZ(-0.5, 0, 0), new XYZ(-1, 0, 0) }, + Style = new DimensionStyle("A") { ArrowSize = 0.2, ScaleFactor = 1, LeaderArrow = arrow }, + }); + Leader leader = new() + { + ArrowHeadEnabled = true, + Vertices = { new XYZ(10, 10, 0), new XYZ(30, 10, 0) }, + Style = new DimensionStyle("A") { ArrowSize = 2, ScaleFactor = 1, LeaderArrow = arrow }, + }; + document.Entities.Add(leader); + + new EntityRenderDispatcher(configuration).Draw(CreateContext(surface, configuration), leader); + + Assert.Contains(notifications, n => n.Message.Contains("references itself", StringComparison.OrdinalIgnoreCase)); + Assert.Single(surface.Polygons); + } + + [Fact] + public void ACustomArrowInsideAMirroredInsertIsReflectedWithIt() + { + RecordingDrawingSurface surface = new(); + ImageConfiguration configuration = new(); + CadDocument document = new(); + BlockRecord arrow = ArrowBlock(); + document.BlockRecords.Add(arrow); + BlockRecord note = new("NOTE"); + document.BlockRecords.Add(note); + note.Entities.Add(new Leader + { + ArrowHeadEnabled = true, + Vertices = { new XYZ(0, 0, 0), new XYZ(10, 0, 0) }, + Style = new DimensionStyle("A") { ArrowSize = 2, ScaleFactor = 1, LeaderArrow = arrow }, + }); + Insert insert = new(note) { InsertPoint = new XYZ(10, 10, 0), XScale = -1, YScale = 1, ZScale = 1 }; + document.Entities.Add(insert); + + new EntityRenderDispatcher(configuration).Draw(CreateContext(surface, configuration), insert); + + // The mirror turns the leader round to run towards world (0,10), so the arrow body still runs from the tip + // at (10,10) towards the leader, now along -X. A mirrored placement is expressed as a negative X scale on + // the transient insert, so an inverted reflection branch would put the body at (12,10) instead. + Assert.Contains(surface.Lines, l => Close(l.Start, new SurfacePoint(8, 90)) && Close(l.End, new SurfacePoint(10, 90))); + } + + [Fact] + public void DrawingAnArrowBlockLeavesAnMLineInsideItIntact() + { + RecordingDrawingSurface surface = new(); + ImageConfiguration configuration = new(); + CadDocument document = new(); + BlockRecord arrow = ArrowBlock(); + MLine mline = new() + { + Style = TwoElementStyle(0.5), + Vertices = { VertexAt(-1, 0, [0.5, 0], [-0.5, 0]), VertexAt(0, 0, [0.5, 0], [-0.5, 0]) }, + }; + 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 + // 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. + Insert insert = new(note) { InsertPoint = new XYZ(10, 10, 0) }; + note.Entities.Add(new Leader + { + ArrowHeadEnabled = true, + Vertices = { new XYZ(0, 0, 0), new XYZ(10, 0, 0) }, + Style = new DimensionStyle("A") { ArrowSize = 2, ScaleFactor = 1, LeaderArrow = arrow }, + }); + document.BlockRecords.Add(note); + document.Entities.Add(insert); + Assert.Equal(2, mline.Vertices.Count); + + 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); + Assert.Equal(3, surface.Polylines.Count); + } + + [Fact] + public void DrawingATopLevelLeadersArrowBlockLeavesAnMLineInsideItIntact() + { + RecordingDrawingSurface surface = new(); + ImageConfiguration configuration = new(); + CadDocument document = new(); + BlockRecord arrow = ArrowBlock(); + MLine mline = new() + { + Style = TwoElementStyle(0.5), + Vertices = { VertexAt(-1, 0, [0.5, 0], [-0.5, 0]), VertexAt(0, 0, [0.5, 0], [-0.5, 0]) }, + }; + arrow.Entities.Add(mline); + document.BlockRecords.Add(arrow); + Leader leader = new() + { + ArrowHeadEnabled = true, + Vertices = { new XYZ(10, 10, 0), new XYZ(30, 10, 0) }, + Style = new DimensionStyle("A") { ArrowSize = 2, ScaleFactor = 1, LeaderArrow = arrow }, + }; + document.Entities.Add(leader); + Assert.Equal(2, mline.Vertices.Count); + + 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); + Assert.Equal(3, surface.Polylines.Count); + } + + [Fact] + public void DrawingABlockWhoseLeaderStyleCarriesADimArrow1LeavesAnMLineInsideItIntact() + { + RecordingDrawingSurface surface = new(); + ImageConfiguration configuration = new(); + CadDocument document = new(); + BlockRecord arrow = ArrowBlock(); + MLine mline = new() + { + Style = TwoElementStyle(0.5), + Vertices = { VertexAt(-1, 0, [0.5, 0], [-0.5, 0]), VertexAt(0, 0, [0.5, 0], [-0.5, 0]) }, + }; + arrow.Entities.Add(mline); + document.BlockRecords.Add(arrow); + BlockRecord note = new("NOTE"); + // Built while NOTE is still empty, so ACadSharp's Insert(BlockRecord) constructor has nothing to clone yet; + // the same construction-order workaround the LeaderArrow tests above use. + Insert insert = new(note) { InsertPoint = new XYZ(10, 10, 0) }; + note.Entities.Add(new Leader + { + ArrowHeadEnabled = true, + Vertices = { new XYZ(0, 0, 0), new XYZ(10, 0, 0) }, + Style = new DimensionStyle("A") { ArrowSize = 2, ScaleFactor = 1, DimArrow1 = arrow }, + }); + document.BlockRecords.Add(note); + document.Entities.Add(insert); + Assert.Equal(2, mline.Vertices.Count); + + 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); + } + + [Fact] + public void DrawingABlockWhoseDimensionStyleCarriesAnArrowBlockLeavesAnMLineInsideItIntact() + { + RecordingDrawingSurface surface = new(); + ImageConfiguration configuration = new(); + CadDocument document = new(); + BlockRecord arrow = ArrowBlock(); + MLine mline = new() + { + Style = TwoElementStyle(0.5), + Vertices = { VertexAt(-1, 0, [0.5, 0], [-0.5, 0]), VertexAt(0, 0, [0.5, 0], [-0.5, 0]) }, + }; + arrow.Entities.Add(mline); + document.BlockRecords.Add(arrow); + BlockRecord note = new("NOTE"); + Insert insert = new(note) { InsertPoint = new XYZ(10, 10, 0) }; + note.Entities.Add(new DimensionLinear + { + FirstPoint = new XYZ(0, 0, 0), + SecondPoint = new XYZ(10, 0, 0), + Style = new DimensionStyle("A") { ArrowSize = 2, ScaleFactor = 1, ArrowBlock = arrow }, + }); + document.BlockRecords.Add(note); + document.Entities.Add(insert); + Assert.Equal(2, mline.Vertices.Count); + + 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); + } + + [Fact] + public void DrawingABlockWhoseDimensionPictureHoldsAnMLineLeavesItIntact() + { + RecordingDrawingSurface surface = new(); + ImageConfiguration configuration = new(); + CadDocument document = new(); + BlockRecord picture = new("*D1"); + MLine mline = new() + { + Style = TwoElementStyle(0.5), + Vertices = { VertexAt(0, 0, [0.5, 0], [-0.5, 0]), VertexAt(10, 0, [0.5, 0], [-0.5, 0]) }, + }; + picture.Entities.Add(mline); + document.BlockRecords.Add(picture); + BlockRecord note = new("NOTE"); + Insert insert = new(note) { InsertPoint = new XYZ(10, 10, 0) }; + note.Entities.Add(new DimensionLinear + { + FirstPoint = new XYZ(0, 0, 0), + SecondPoint = new XYZ(10, 0, 0), + Style = new DimensionStyle("A") { ArrowSize = 2, ScaleFactor = 1 }, + Block = picture, + }); + document.BlockRecords.Add(note); + document.Entities.Add(insert); + Assert.Equal(2, mline.Vertices.Count); + + 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); + } + + [Fact] + public void ABlockWhoseDimensionPicturePlacesItAgainIsSkippedWithAWarning() + { + // Dimension.Clone() deep-clones the picture block, so a picture that places the block holding the dimension + // makes Explode() recurse until the stack dies, uncatchably. The cycle walk therefore follows the picture + // edge as well. Refusing here cannot cost a legitimate drawing: a picture block is geometry ACadSharp + // generates from the dimension's own definition points and never places the dimension's container in it. + RecordingDrawingSurface surface = new(); + ImageConfiguration configuration = new(); + List notifications = new(); + configuration.OnNotification += (_, e) => notifications.Add(e); + CadDocument document = new(); + BlockRecord note = new("NOTE"); + // Both inserts are built while NOTE is still empty, so ACadSharp's Insert(BlockRecord) constructor has + // nothing to clone yet and the cycle is assembled without tripping it during construction. + Insert outer = new(note) { InsertPoint = new XYZ(10, 10, 0) }; + BlockRecord picture = new("*D1"); + picture.Entities.Add(new Insert(note)); + // Adding the picture registers NOTE with the document too, through the insert it holds, so NOTE is never + // added by name a second time. + document.BlockRecords.Add(picture); + note.Entities.Add(new DimensionLinear + { + FirstPoint = new XYZ(0, 0, 0), + SecondPoint = new XYZ(10, 0, 0), + Style = new DimensionStyle("A") { ArrowSize = 2, ScaleFactor = 1 }, + Block = picture, + }); + document.Entities.Add(outer); + + new EntityRenderDispatcher(configuration).Draw(CreateContext(surface, configuration), outer); + + Assert.Contains(notifications, n => n.Message.Contains("references itself", StringComparison.OrdinalIgnoreCase)); + } + + [Fact] + public void DrawingATopLevelDimensionLeavesAnMLineInsideItsArrowBlockIntact() + { + RecordingDrawingSurface surface = new(); + ImageConfiguration configuration = new(); + CadDocument document = new(); + BlockRecord arrow = ArrowBlock(); + MLine mline = new() + { + Style = TwoElementStyle(0.5), + Vertices = { VertexAt(-1, 0, [0.5, 0], [-0.5, 0]), VertexAt(0, 0, [0.5, 0], [-0.5, 0]) }, + }; + arrow.Entities.Add(mline); + document.BlockRecords.Add(arrow); + DimensionLinear dimension = new() + { + FirstPoint = new XYZ(10, 10, 0), + SecondPoint = new XYZ(30, 10, 0), + Style = new DimensionStyle("A") { ArrowSize = 2, ScaleFactor = 1, SeparateArrowBlocks = true, DimArrow1 = arrow, DimArrow2 = arrow }, + }; + document.Entities.Add(dimension); + Assert.Equal(2, mline.Vertices.Count); + + 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); + } + + [Fact] + public void ATopLevelDimensionWhoseArrowBlockPlacesItselfIsSkippedWithAWarning() + { + // UpdateBlock() builds an Insert of each of the style's arrow blocks, and that constructor performs the same + // deep clone Explode() does, so an arrow block reachable from itself exhausts the stack inside ACadSharp + // before UpdateBlock() returns. The pre-check in DrawDimension is what stops that. + RecordingDrawingSurface surface = new(); + ImageConfiguration configuration = new(); + List notifications = new(); + configuration.OnNotification += (_, e) => notifications.Add(e); + CadDocument document = new(); + BlockRecord arrow = ArrowBlock(); + // Built before the cycle is closed, so the constructor's own clone terminates. + arrow.Entities.Add(new Insert(arrow)); + document.BlockRecords.Add(arrow); + DimensionLinear dimension = new() + { + FirstPoint = new XYZ(10, 10, 0), + SecondPoint = new XYZ(30, 10, 0), + Style = new DimensionStyle("A") { ArrowSize = 2, ScaleFactor = 1, SeparateArrowBlocks = true, DimArrow1 = arrow }, + }; + document.Entities.Add(dimension); + + new EntityRenderDispatcher(configuration).Draw(CreateContext(surface, configuration), dimension); + + Assert.Contains(notifications, n => n.Message.Contains("references itself", StringComparison.OrdinalIgnoreCase)); + } + + [Fact] + public void RenderingADimensionThroughTheExporterLeavesAnMLineInsideItsArrowBlockIntact() + { + // The dispatcher-level test above covers DrawDimension. This one covers the whole public path, which runs + // EntityBounds.TryGet over every entity for page framing before anything is drawn: if ACadSharp's + // Dimension.GetBoundingBox() generated the picture itself, the arrow blocks would be cloned before + // DrawDimension's snapshot could be taken and the heal would arrive too late. + CadDocument document = new(); + BlockRecord arrow = ArrowBlock(); + MLine mline = new() + { + Style = TwoElementStyle(0.5), + Vertices = { VertexAt(-1, 0, [0.5, 0], [-0.5, 0]), VertexAt(0, 0, [0.5, 0], [-0.5, 0]) }, + }; + arrow.Entities.Add(mline); + document.BlockRecords.Add(arrow); + DimensionLinear dimension = new() + { + FirstPoint = new XYZ(10, 10, 0), + SecondPoint = new XYZ(30, 10, 0), + Style = new DimensionStyle("A") { ArrowSize = 2, ScaleFactor = 1, SeparateArrowBlocks = true, DimArrow1 = arrow, DimArrow2 = arrow }, + }; + document.Entities.Add(dimension); + ImageExporter exporter = new(); + exporter.AddModelSpace(document); + Assert.Equal(2, mline.Vertices.Count); + + foreach (RenderedPage page in exporter.Render()) + { + page.Dispose(); + } + + Assert.Equal(2, mline.Vertices.Count); + } +} diff --git a/ACadSharp.Image.Tests/FeatureGoldenTests.cs b/ACadSharp.Image.Tests/FeatureGoldenTests.cs new file mode 100644 index 0000000..3cf1c04 --- /dev/null +++ b/ACadSharp.Image.Tests/FeatureGoldenTests.cs @@ -0,0 +1,81 @@ +using System.Xml.Linq; +using ACadSharp.Image.Rendering.Svg; +using ACadSharp.IO; +using SixLabors.Fonts; + +namespace ACadSharp.Image.Tests; + +/// Renders a block containing every primitive the sample goldens lack and compares it with its baseline and golden. +public sealed class FeatureGoldenTests +{ + private const string FontFamily = "DejaVu Sans"; + private static readonly XNamespace Ns = SvgDrawingSurface.Ns; + + private static ImageExporter FeatureExporter() + { + ImageExporter exporter = new(); + exporter.Configuration.Width = 800; + exporter.Configuration.Height = 500; + exporter.Configuration.SetPadding(10); + exporter.Configuration.FontFamilyName = FontFamily; + exporter.Add(SyntheticSamples.FeatureBlock()); + return exporter; + } + + [Fact] + public void FeaturePngMatchesBaseline() + { + Assert.True(SystemFonts.TryGet(FontFamily, out _), $"Font '{FontFamily}' must be installed for parity tests."); + ImageExporter exporter = FeatureExporter(); + + using RenderedImagePage page = Assert.IsType(Assert.Single(exporter.Render())); + + GoldenAssert.Png("features.model.01", page.Canvas); + } + + [Fact] + public void FeatureSvgMatchesGoldenAndContainsEveryPrimitive() + { + ImageExporter exporter = FeatureExporter(); + List notifications = new(); + exporter.Configuration.OnNotification += (_, e) => notifications.Add(e); + + RenderedSvgPage page = Assert.IsType(Assert.Single(exporter.Render(ImageExportFormat.Svg))); + GoldenAssert.Svg("features.model.01", page.Content); + + Assert.DoesNotContain(notifications, n => n.NotificationType is NotificationType.Warning or NotificationType.NotImplemented); + XDocument document = XDocument.Parse(page.Content); + List paths = document.Descendants(Ns + "path").ToList(); + + // Solid hatch: even-odd filled path. Pattern hatch: several plain lines on layer Hatch. + Assert.Single(paths, p => (string?)p.Attribute("fill-rule") == "evenodd"); + XElement hatchGroup = Assert.Single(document.Descendants(Ns + "g"), g => (string?)g.Attribute("data-layer") == "Hatch"); + Assert.True(hatchGroup.Elements(Ns + "line").Count() >= 5); + + // Full ellipse and elliptical arc (an A command with rx 10 ry 5). + Assert.Single(document.Descendants(Ns + "ellipse")); + Assert.Contains(paths, p => ((string?)p.Attribute("d") ?? string.Empty).Contains("A10 5", StringComparison.Ordinal)); + + // Translucent line. + XElement translucent = Assert.Single(document.Descendants(Ns + "line"), l => (string?)l.Parent!.Attribute("data-layer") == "Trans"); + Assert.Equal("0.5", (string?)translucent.Attribute("opacity")); + + // Bulged closed polyline: a path with an arc command that closes. + Assert.Contains(paths, p => ((string?)p.Attribute("d") ?? string.Empty) is string d && d.Contains('A') && d.EndsWith('Z') && !d.Contains("A10 5", StringComparison.Ordinal)); + + // Insert: two nested lines tagged with the block, in the Doors group, the ByBlock one in the insert's colour (5 = blue). + List doorLines = document.Descendants(Ns + "line").Where(l => (string?)l.Attribute("data-block") == "DOOR").ToList(); + Assert.Equal(2, doorLines.Count); + Assert.All(doorLines, l => Assert.Equal("Doors", (string?)l.Parent!.Attribute("data-layer"))); + Assert.Contains(doorLines, l => (string?)l.Attribute("stroke") == "#0000ff"); + Assert.All(doorLines, l => Assert.Null(l.Attribute("data-handle"))); + + // Text: MText as two tspans, Fit text with textLength. + List texts = document.Descendants(Ns + "text").ToList(); + Assert.Equal(2, texts.Count); + Assert.Contains(texts, t => t.Elements(Ns + "tspan").Select(s => s.Value).SequenceEqual(["Line1", "Line2"])); + XElement fit = Assert.Single(texts, t => t.Value == "FIT"); + Assert.Equal("40", (string?)fit.Attribute("textLength")); + Assert.Equal("middle", (string?)fit.Attribute("text-anchor")); + } +} diff --git a/ACadSharp.Image.Tests/FidelityGoldenTests.cs b/ACadSharp.Image.Tests/FidelityGoldenTests.cs new file mode 100644 index 0000000..56c47a8 --- /dev/null +++ b/ACadSharp.Image.Tests/FidelityGoldenTests.cs @@ -0,0 +1,158 @@ +using System.Globalization; +using System.Text.RegularExpressions; +using System.Xml.Linq; +using ACadSharp.Image.Rendering; +using ACadSharp.Image.Rendering.Svg; +using ACadSharp.IO; +using CSMath; +using SixLabors.Fonts; +using SixLabors.ImageSharp.PixelFormats; + +namespace ACadSharp.Image.Tests; + +/// +/// Renders — a multi-line attribute, a hatch on a tilted plane inside a +/// block, a leader with a custom arrowhead block, an inverted wipeout over a line, and an MLINE with a cut in both +/// elements — through both real backends and compares the results with their baselines. Mirrors +/// . +/// +public sealed class FidelityGoldenTests +{ + private const string FontFamily = "DejaVu Sans"; + private static readonly XNamespace Ns = SvgDrawingSurface.Ns; + private static readonly Regex PathCommand = new(@"[ML](-?[0-9]*\.?[0-9]+) (-?[0-9]*\.?[0-9]+)", RegexOptions.Compiled); + + /// + /// The exporter both goldens render, with subscribed before the page is added + /// so anything raised during page construction is covered by the warning-free guard too, not just what the + /// render itself raises. + /// + private static ImageExporter FidelityExporter(List notifications) + { + ImageExporter exporter = new(); + exporter.Configuration.Width = 800; + exporter.Configuration.Height = 500; + exporter.Configuration.SetPadding(10); + exporter.Configuration.FontFamilyName = FontFamily; + exporter.Configuration.OnNotification += (_, e) => notifications.Add(e); + exporter.Add(SyntheticSamples.FidelityBlock()); + return exporter; + } + + /// + /// This fixture deliberately walks four arrowhead fallback paths (an empty or self-referencing arrow block, a + /// non-uniform placement, a degenerate size); a silent fallback to the default triangle would still satisfy the + /// geometry assertions, so this is the one check that proves every feature actually took its intended path + /// rather than falling back unnoticed. It is asserted on both backends, since they are different surfaces. + /// + private static void AssertNothingFellBack(IReadOnlyList notifications) + { + Assert.DoesNotContain(notifications, n => n.NotificationType is NotificationType.Warning or NotificationType.NotImplemented); + } + + [Fact] + public void FidelityPngMatchesBaseline() + { + Assert.True(SystemFonts.TryGet(FontFamily, out _), $"Font '{FontFamily}' must be installed for parity tests."); + List notifications = new(); + ImageExporter exporter = FidelityExporter(notifications); + + using RenderedImagePage page = Assert.IsType(Assert.Single(exporter.Render())); + + GoldenAssert.Png("fidelity.model.01", page.Canvas); + AssertNothingFellBack(notifications); + + // The inverted wipeout masks the whole wipeout frame (world x in [60,110], y in [10,30] — derived from its + // own InsertPoint/UVector/VVector/Size) EXCEPT its boundary (world x in [75,95], y in [14,26] — derived the + // same way from ClipBoundaryVertices), so the "Under" line at y = 20 survives only inside the boundary and + // is masked outside it (but still inside the frame). This is the assertion the SVG cannot make: SVG groups + // by layer, so the line and the mask are not in draw order there. + // CreatePageContext(surface, ImagePage, …) resolves to PageFrame.Of(page), which is also what + // ImagePageRenderer.ResolveFrame returns as long as HasActiveFilters() is false; FidelityExporter() sets no + // included/hidden layers and leaves LayerVisibility at its default, so the two fits coincide here. A filter + // added to the exporter later would desync this reconstructed fit from the one the render actually used. + ImageRenderContext context = ImageRenderContext.CreatePageContext(new RecordingDrawingSurface(), exporter.Pages[0], exporter.Configuration); + SurfacePoint inside = context.ToSurfacePoint(new XY(85, 20)); + SurfacePoint outside = context.ToSurfacePoint(new XY(65, 20)); + Rgba32 white = new(255, 255, 255, 255); + Assert.NotEqual(white, GoldenAssert.DarkestPixelNear(page.Canvas, inside)); + Assert.Equal(white, GoldenAssert.DarkestPixelNear(page.Canvas, outside)); + } + + [Fact] + public void FidelitySvgMatchesGoldenAndContainsEveryFeature() + { + List notifications = new(); + ImageExporter exporter = FidelityExporter(notifications); + + RenderedSvgPage page = Assert.IsType(Assert.Single(exporter.Render(ImageExportFormat.Svg))); + + GoldenAssert.Svg("fidelity.model.01", page.Content); + AssertNothingFellBack(notifications); + + XDocument document = XDocument.Parse(page.Content); + XElement InLayer(string layer) => document.Descendants(Ns + "g").Single(g => (string?)g.Attribute("data-layer") == layer); + + // Multi-line attribute: two lines from the embedded MText, and the single-line value nowhere in the file. + // The ATTDEF template in the "LABEL" block is not constant, so the explode loop skips it outright; only the + // insert's own multi-line ATTRIB (drawn from its MText, never from AttributeEntity.Value) reaches the SVG. + XElement text = Assert.Single(InLayer("Rooms").Descendants(Ns + "text")); + Assert.Equal("ATTRIB", (string?)text.Attribute("data-type")); + Assert.Equal(["Room 1", "Level 2"], text.Descendants(Ns + "tspan").Select(s => s.Value).ToArray()); + Assert.DoesNotContain("FLAT", page.Content, StringComparison.Ordinal); + + // Tilted hatch inside a block: normal (0,0,-1) mirrors the OCS X axis to world (-1,0,0) (Y is unaffected), + // so the local square x in [0,20] becomes world x in [-20,0]; the insert at (80,70,0) then shifts it to + // world x in [60,80]. The page frame then translates every coordinate by -minX before it reaches the SVG + // (ImagePage.ComputeFrame's Translation = -limits.Min, and CreateSvgPageContext keeps drawing units 1:1), + // and the block's own leftmost content — the MLINE's and LEADER's vertices at world x = 10, and the + // multi-line attribute at world x = 10 — puts minX at 10, so the emitted path spans x in [50,70]. + XElement hatch = Assert.Single(InLayer("Tilted").Descendants(Ns + "path")); + double[] xs = PointsOf(hatch).Select(p => p.X).ToArray(); + Assert.Equal(50d, xs.Min(), 3); + Assert.Equal(70d, xs.Max(), 3); + + // Custom arrowhead: the block's own filled solid, not the built-in triangle. + Assert.Contains(InLayer("Leader").Descendants(Ns + "polygon"), p => (string?)p.Attribute("data-type") == "SOLID"); + + // Inverted wipeout: one even-odd path with two rings, filled with the page background. + XElement mask = Assert.Single(InLayer("Cover").Descendants(Ns + "path")); + Assert.Equal("#ffffff", (string?)mask.Attribute("fill")); + Assert.Equal("evenodd", (string?)mask.Attribute("fill-rule")); + Assert.Equal(2, RingCountOf(mask)); + + // Cut MLINE: two elements, each broken into two runs (a cut between 20 and 30 along a 50-unit element), so + // four separate lines total; the style has no fill and no square caps, so nothing else adds to the count. + Assert.Equal(4, InLayer("Wall").Descendants().Count(e => e.Name == Ns + "line" || e.Name == Ns + "polyline")); + } + + /// + /// Parses an SVG path's d attribute (its M/L commands) or a polygon's/ + /// polyline's points attribute into surface points. + /// + private static IReadOnlyList PointsOf(XElement element) + { + if (element.Name == Ns + "path") + { + return PathCommand.Matches((string?)element.Attribute("d") ?? string.Empty) + .Select(m => new SurfacePoint( + double.Parse(m.Groups[1].Value, CultureInfo.InvariantCulture), + double.Parse(m.Groups[2].Value, CultureInfo.InvariantCulture))) + .ToArray(); + } + + string[] tokens = ((string?)element.Attribute("points") ?? string.Empty).Split(' ', StringSplitOptions.RemoveEmptyEntries); + List points = new(tokens.Length / 2); + for (int i = 0; i + 1 < tokens.Length; i += 2) + { + points.Add(new SurfacePoint( + double.Parse(tokens[i], CultureInfo.InvariantCulture), + double.Parse(tokens[i + 1], CultureInfo.InvariantCulture))); + } + + return points; + } + + /// The number of subpaths (M commands) in a path element's d attribute. + private static int RingCountOf(XElement path) => ((string?)path.Attribute("d") ?? string.Empty).Count(c => c == 'M'); +} diff --git a/ACadSharp.Image.Tests/FontResolverTests.cs b/ACadSharp.Image.Tests/FontResolverTests.cs new file mode 100644 index 0000000..76cc872 --- /dev/null +++ b/ACadSharp.Image.Tests/FontResolverTests.cs @@ -0,0 +1,53 @@ +using ACadSharp.Image.Rendering; +using SixLabors.Fonts; + +namespace ACadSharp.Image.Tests; + +/// Checks that missing font families fall back along the documented chain rather than to an arbitrary face. +public sealed class FontResolverTests +{ + [Fact] + public void InstalledFamilyIsUsedAsIs() + { + Assert.True(SystemFonts.TryGet("DejaVu Sans", out _), "DejaVu Sans must be installed for this test."); + + Assert.Equal("DejaVu Sans", FontResolver.Resolve("DejaVu Sans").Name); + Assert.Equal(12f, FontResolver.Create("DejaVu Sans", 12f).Size); + } + + [Fact] + public void MissingFamilyFallsBackAlongTheChain() + { + FontFamily family = FontResolver.Resolve("No Such Family 4711"); + + IReadOnlyList chain = FontResolver.Fallbacks; + string? firstInstalled = chain.FirstOrDefault(name => SystemFonts.TryGet(name, out _)); + if (firstInstalled != null) + { + Assert.Equal(firstInstalled, family.Name); + } + else + { + Assert.Equal(SystemFonts.Families.First().Name, family.Name); + } + } + + [Fact] + public void TryResolveAgreesWithResolveWhileFontsAreInstalled() + { + // The false branch needs a machine without a single installed family, which SystemFonts cannot be made to + // report here, so only the resolving side is covered. + Assert.True(FontResolver.TryResolve("DejaVu Sans", out FontFamily configured)); + Assert.Equal("DejaVu Sans", configured.Name); + + Assert.True(FontResolver.TryResolve("No Such Family 4711", out FontFamily fallback)); + Assert.Equal(FontResolver.Resolve("No Such Family 4711").Name, fallback.Name); + } + + [Fact] + public void NullOrBlankFamilyUsesTheChain() + { + Assert.Equal(FontResolver.Resolve("No Such Family 4711").Name, FontResolver.Resolve(null).Name); + Assert.Equal(FontResolver.Resolve("No Such Family 4711").Name, FontResolver.Resolve(" ").Name); + } +} diff --git a/ACadSharp.Image.Tests/GoldenAssert.cs b/ACadSharp.Image.Tests/GoldenAssert.cs new file mode 100644 index 0000000..2ab4169 --- /dev/null +++ b/ACadSharp.Image.Tests/GoldenAssert.cs @@ -0,0 +1,83 @@ +using ACadSharp.Image.Rendering; +using SixLabors.ImageSharp; +using SixLabors.ImageSharp.Formats.Png; +using SixLabors.ImageSharp.PixelFormats; + +namespace ACadSharp.Image.Tests; + +/// +/// Compares rendered output with the files under Baselines/: PNGs pixel-for-pixel and SVGs as text. With the +/// environment variable ACADSHARP_IMAGE_UPDATE_BASELINES=1 the files are rewritten instead of compared. +/// +internal static class GoldenAssert +{ + public static bool Updating => Environment.GetEnvironmentVariable("ACADSHARP_IMAGE_UPDATE_BASELINES") == "1"; + + private static string BaselineDirectory + { + get + { + string directory = Path.Combine(SampleParityTests.FindRepoRoot(), "ACadSharp.Image.Tests", "Baselines"); + Directory.CreateDirectory(directory); + return directory; + } + } + + public static void Png(string baseName, Image actual) + { + string path = Path.Combine(BaselineDirectory, baseName + ".png"); + if (Updating) + { + actual.Save(path, new PngEncoder()); + return; + } + + Assert.True(File.Exists(path), $"Missing baseline {path}. Run with ACADSHARP_IMAGE_UPDATE_BASELINES=1 to create it."); + using Image expected = SixLabors.ImageSharp.Image.Load(path); + SampleParityTests.AssertPixelsEqual(expected, actual, path); + } + + public static void Svg(string baseName, string actual) + { + string path = Path.Combine(BaselineDirectory, baseName + ".svg"); + string normalized = actual.Replace("\r\n", "\n"); + Assert.DoesNotContain("Infinity", normalized, StringComparison.Ordinal); + Assert.DoesNotContain("NaN", normalized, StringComparison.Ordinal); + if (Updating) + { + File.WriteAllText(path, normalized); + return; + } + + 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); + } + + /// + /// The darkest (lowest R+G+B) pixel in a small window around , so an occlusion assertion + /// survives anti-aliasing and rounding of the fitted coordinates without depending on one exact pixel. Shared by + /// EntityGoldenTests and FidelityGoldenTests. + /// + internal static Rgba32 DarkestPixelNear(Image canvas, SurfacePoint point, int radius = 2) + { + int centerX = (int)Math.Round(point.X); + int centerY = (int)Math.Round(point.Y); + Rgba32 darkest = SixLabors.ImageSharp.Color.White.ToPixel(); + int darkestLuma = int.MaxValue; + for (int y = Math.Max(0, centerY - radius); y <= Math.Min(canvas.Height - 1, centerY + radius); y++) + { + for (int x = Math.Max(0, centerX - radius); x <= Math.Min(canvas.Width - 1, centerX + radius); x++) + { + Rgba32 pixel = canvas[x, y]; + int luma = pixel.R + pixel.G + pixel.B; + if (luma < darkestLuma) + { + darkestLuma = luma; + darkest = pixel; + } + } + } + + return darkest; + } +} diff --git a/ACadSharp.Image.Tests/ImageConfigurationTests.cs b/ACadSharp.Image.Tests/ImageConfigurationTests.cs index ebbff3d..8415b9f 100644 --- a/ACadSharp.Image.Tests/ImageConfigurationTests.cs +++ b/ACadSharp.Image.Tests/ImageConfigurationTests.cs @@ -39,4 +39,95 @@ public void LineWeightOverridesAreManagedThroughMethods() Assert.Empty(configuration.LineWeightValues); } + + [Fact] + public void LayerVisibilityDefaultsToAll() + { + Assert.Equal(LayerVisibilityMode.All, new ImageConfiguration().LayerVisibility); + } + + [Fact] + public void IncludedLayersAreManagedThroughMethods() + { + ImageConfiguration configuration = new(); + + configuration.IncludeLayer("Walls"); + configuration.IncludeLayers(["doors", "Windows"]); + + Assert.Equal(3, configuration.IncludedLayers.Count); + Assert.Contains("WALLS", configuration.IncludedLayers); + Assert.True(configuration.ExcludeLayer("DOORS")); + Assert.False(configuration.ExcludeLayer("nope")); + Assert.Throws(() => configuration.IncludeLayer(" ")); + + configuration.ClearIncludedLayers(); + + Assert.Empty(configuration.IncludedLayers); + } + + [Fact] + public void NewNumericSettingsAreValidated() + { + ImageConfiguration configuration = new(); + + Assert.Null(configuration.ForegroundColor); + Assert.Equal(2f, configuration.MinimumDashPixels); + Assert.Equal(20000, configuration.MaxHatchLines); + Assert.Throws(() => configuration.MinimumDashPixels = -1f); + Assert.Throws(() => configuration.MaxHatchLines = 0); + } + + [Fact] + public void HideLayersAddsSeveralNamesCaseInsensitively() + { + ImageConfiguration configuration = new(); + + configuration.HideLayers(["A-DOOR", "a-door", "A-GLAZ"]); + + Assert.Equal(2, configuration.HiddenLayers.Count); + Assert.Contains("A-DOOR", configuration.HiddenLayers); + Assert.Contains("a-glaz", configuration.HiddenLayers); + Assert.True(configuration.ShowLayer("A-Door")); + Assert.False(configuration.ShowLayer("A-Door")); + Assert.Throws(() => configuration.HideLayers([" "])); + } + + [Fact] + public void IncludedLayersBehavesAsAReadOnlySet() + { + ImageConfiguration configuration = new(); + configuration.IncludeLayers(["Walls", "Doors"]); + IReadOnlySet included = configuration.IncludedLayers; + + Assert.Equal(2, included.Count); + Assert.True(included.Contains("walls")); + Assert.True(included.IsSubsetOf(["WALLS", "DOORS", "Glazing"])); + Assert.True(included.IsProperSubsetOf(["WALLS", "DOORS", "Glazing"])); + Assert.True(included.IsSupersetOf(["doors"])); + Assert.True(included.IsProperSupersetOf(["doors"])); + Assert.True(included.Overlaps(["Doors", "Roof"])); + Assert.True(included.SetEquals(["DOORS", "WALLS"])); + Assert.Equal(2, included.Count()); + Assert.True(configuration.ExcludeLayer("WALLS")); + Assert.False(included.Contains("Walls")); + } + + [Fact] + public void LineWeightOverridesValidateAndFallBackToDefaults() + { + ImageConfiguration configuration = new(); + double defaultW50 = configuration.GetLineWeightMillimeters(ACadSharp.LineWeightType.W50); + + configuration.SetLineWeight(ACadSharp.LineWeightType.W50, 1.25); + Assert.Equal(1.25, configuration.GetLineWeightMillimeters(ACadSharp.LineWeightType.W50)); + Assert.Throws(() => configuration.SetLineWeight(ACadSharp.LineWeightType.W50, -0.1)); + Assert.True(configuration.RemoveLineWeight(ACadSharp.LineWeightType.W50)); + Assert.False(configuration.RemoveLineWeight(ACadSharp.LineWeightType.W50)); + Assert.Equal(defaultW50, configuration.GetLineWeightMillimeters(ACadSharp.LineWeightType.W50)); + Assert.Equal(0d, configuration.GetLineWeightMillimeters(ACadSharp.LineWeightType.ByLayer)); + configuration.SetLineWeight(ACadSharp.LineWeightType.Default, 0d); + Assert.Equal(Math.Max(1f, configuration.LineWeightScale), configuration.GetLineWeightPixels(ACadSharp.LineWeightType.Default)); + configuration.ClearLineWeights(); + Assert.Equal(defaultW50, configuration.GetLineWeightMillimeters(ACadSharp.LineWeightType.W50)); + } } diff --git a/ACadSharp.Image.Tests/ImageExportFormatExtensionsTests.cs b/ACadSharp.Image.Tests/ImageExportFormatExtensionsTests.cs index f050305..3b930ea 100644 --- a/ACadSharp.Image.Tests/ImageExportFormatExtensionsTests.cs +++ b/ACadSharp.Image.Tests/ImageExportFormatExtensionsTests.cs @@ -9,6 +9,7 @@ public sealed class ImageExportFormatExtensionsTests [InlineData("jpeg", ImageExportFormat.Jpeg)] [InlineData("gif", ImageExportFormat.Gif)] [InlineData("webp", ImageExportFormat.Webp)] + [InlineData("svg", ImageExportFormat.Svg)] public void TryParseRecognizesSupportedFormats(string value, ImageExportFormat expected) { bool parsed = ImageExportFormatExtensions.TryParse(value, out ImageExportFormat actual); @@ -21,6 +22,7 @@ public void TryParseRecognizesSupportedFormats(string value, ImageExportFormat e [InlineData(".png", ImageExportFormat.Png)] [InlineData(".jpg", ImageExportFormat.Jpeg)] [InlineData(".webp", ImageExportFormat.Webp)] + [InlineData(".svg", ImageExportFormat.Svg)] public void TryParseFileExtensionRecognizesSupportedExtensions(string extension, ImageExportFormat expected) { bool parsed = ImageExportFormatExtensions.TryParseFileExtension(extension, out ImageExportFormat actual); @@ -28,4 +30,10 @@ public void TryParseFileExtensionRecognizesSupportedExtensions(string extension, Assert.True(parsed); Assert.Equal(expected, actual); } + + [Fact] + public void SvgFileExtensionIsDotSvg() + { + Assert.Equal(".svg", ImageExportFormat.Svg.GetFileExtension()); + } } diff --git a/ACadSharp.Image.Tests/ImageExporterTests.cs b/ACadSharp.Image.Tests/ImageExporterTests.cs index f522648..8f81587 100644 --- a/ACadSharp.Image.Tests/ImageExporterTests.cs +++ b/ACadSharp.Image.Tests/ImageExporterTests.cs @@ -43,7 +43,7 @@ public void RenderUsesConfiguredCanvasSize() exporter.Configuration.Height = 600; exporter.Add(block); - using RenderedImagePage page = Assert.Single(exporter.Render()); + using RenderedImagePage page = Assert.IsType(Assert.Single(exporter.Render())); Assert.Equal(800, page.Canvas.Width); Assert.Equal(600, page.Canvas.Height); @@ -69,11 +69,12 @@ public void PageContextUsesConfiguredPadding() }; using Image canvas = new(configuration.Width, configuration.Height); - ImageRenderContext context = ImageRenderContext.CreatePageContext(canvas, page, configuration); + using RasterDrawingSurface surface = new(canvas, configuration, ownsCanvas: false); + ImageRenderContext context = ImageRenderContext.CreatePageContext(surface, page, configuration); - Assert.Equal(5f, context.PixelsPerUnit); - Assert.Equal(10f, context.OffsetX); - Assert.Equal(20f, context.OffsetY); + Assert.Equal(5d, context.Scale); + Assert.Equal(10d, context.OffsetX); + Assert.Equal(20d, context.OffsetY); } [Fact] @@ -112,10 +113,17 @@ public void RenderSplineBlockDoesNotReportNotImplemented() exporter.Add(block); - using RenderedImagePage page = Assert.Single(exporter.Render()); + using RenderedImagePage page = Assert.IsType(Assert.Single(exporter.Render())); 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 + // 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]); + Assert.Contains(surface.Calls, c => c.StartsWith("DrawPolyline n=3", StringComparison.Ordinal)); + Assert.Contains(notifications, n => n.NotificationType == NotificationType.Warning && n.Message.Contains("fit points", StringComparison.Ordinal)); } [Fact] @@ -135,7 +143,8 @@ public void RenderClosedPeriodicSplineDoesNotDrawSpokeToOrigin() PaperHeight = 10, }, }; - ImageRenderContext context = new(canvas, configuration, page.Layout, 100, 100, -5, -5, 10f); + using RasterDrawingSurface surface = new(canvas, configuration, ownsCanvas: false); + ImageRenderContext context = new(surface, configuration, page.Layout, 100, 100, -5, -5, 10f, 0, 0, singlePrecision: true, lineTypeScale: 10f); EntityRenderDispatcher dispatcher = new(configuration); Spline spline = new() { @@ -187,7 +196,7 @@ public void RenderInsertDrawsBlockContentsWithoutReportingNotImplemented() exporter.Configuration.OnNotification += (_, args) => notifications.Add(args); exporter.Add(pageBlock); - using RenderedImagePage page = Assert.Single(exporter.Render()); + using RenderedImagePage page = Assert.IsType(Assert.Single(exporter.Render())); Rgba32 white = SixLabors.ImageSharp.Color.White.ToPixel(); Assert.DoesNotContain(notifications, n => n.NotificationType == NotificationType.NotImplemented && n.Message.Contains("Insert", StringComparison.OrdinalIgnoreCase)); @@ -198,20 +207,27 @@ public void RenderInsertDrawsBlockContentsWithoutReportingNotImplemented() [Fact] public void RenderHandlesEntitiesWithNaNBoundingBox() { - // Create a block with normal lines + // Two ordinary lines plus an arc whose bounding box is non-finite (Samples/6-57-1119.dxf has one like it). BlockRecord block = new("nan-bbox-block"); block.Entities.Add(new Line(new XYZ(0, 0, 0), new XYZ(100, 50, 0))); block.Entities.Add(new Line(new XYZ(100, 50, 0), new XYZ(200, 0, 0))); + block.Entities.Add(new Arc { Center = new XYZ(10, 10, 0), Radius = double.PositiveInfinity, StartAngle = double.NaN, EndAngle = double.NaN }); ImageExporter exporter = new(); exporter.Add(block); + ImagePage framed = Assert.Single(exporter.Pages); + + // The non-finite entity must not poison the auto-sized frame... + Assert.Equal(200d, framed.Layout!.PaperWidth); + Assert.Equal(50d, framed.Layout.PaperHeight); - // Should render successfully without NaN propagation issues - using RenderedImagePage page = Assert.Single(exporter.Render()); + // ...nor the render. + using RenderedImagePage page = Assert.IsType(Assert.Single(exporter.Render())); Assert.NotNull(page.Canvas); Assert.Equal(ImageConfiguration.DefaultWidth, page.Canvas.Width); Assert.Equal(ImageConfiguration.DefaultHeight, page.Canvas.Height); + Assert.Contains(Enumerable.Range(0, page.Canvas.Width).SelectMany(x => Enumerable.Range(0, page.Canvas.Height).Select(y => page.Canvas[x, y])), p => p != new Rgba32(255, 255, 255, 255)); } [Fact] @@ -238,9 +254,8 @@ public void HiddenLayersFiltersOutEntitiesOnSpecifiedLayers() exporter.Add(block); - // Verify filtering before rendering - ImagePage page = exporter.Pages[0]; - Assert.Equal(2, page.Entities.Count); // Only Layer1 and Layer3 entities + Assert.Equal(3, exporter.Pages[0].Entities.Count); // pages keep every entity; filtering happens at render time + Assert.Equal(2, CountDrawnLines(exporter)); } [Fact] @@ -257,8 +272,8 @@ public void HiddenLayersIsCaseInsensitive() exporter.Add(block); - ImagePage page = exporter.Pages[0]; - Assert.Empty(page.Entities); // All entities filtered out + Assert.Single(exporter.Pages[0].Entities); // pages keep every entity; filtering happens at render time + Assert.Equal(0, CountDrawnLines(exporter)); } [Fact] @@ -285,7 +300,163 @@ public void MultipleHiddenLayersCanBeConfigured() exporter.Add(block); - ImagePage page = exporter.Pages[0]; - Assert.Single(page.Entities); // Only Layer2 entity + Assert.Equal(3, exporter.Pages[0].Entities.Count); // pages keep every entity; filtering happens at render time + Assert.Equal(1, CountDrawnLines(exporter)); + } + + [Fact] + public void RenderReturnsRasterPagesCarryingTheRequestedFormat() + { + BlockRecord block = new("format-block"); + block.Entities.Add(new Line(new XYZ(0, 0, 0), new XYZ(10, 10, 0))); + + ImageExporter exporter = new(); + exporter.Add(block); + + using RenderedPage page = Assert.Single(exporter.Render(ImageExportFormat.Jpeg)); + + RenderedImagePage raster = Assert.IsType(page); + Assert.Equal(ImageExportFormat.Jpeg, raster.Format); + Assert.Equal("format-block", raster.Name); + } + + [Fact] + public void RenderedPageSavesToStreamInItsFormat() + { + BlockRecord block = new("stream-block"); + block.Entities.Add(new Line(new XYZ(0, 0, 0), new XYZ(10, 10, 0))); + + ImageExporter exporter = new(); + exporter.Configuration.Width = 32; + exporter.Configuration.Height = 32; + exporter.Add(block); + + using RenderedPage page = Assert.Single(exporter.Render(ImageExportFormat.Png)); + using MemoryStream stream = new(); + page.Save(stream); + + byte[] bytes = stream.ToArray(); + Assert.Equal(0x89, bytes[0]); + Assert.Equal((byte)'P', bytes[1]); + Assert.Equal((byte)'N', bytes[2]); + Assert.Equal((byte)'G', bytes[3]); + } + + private static int CountDrawnLines(ImageExporter exporter) + { + RecordingDrawingSurface surface = new(); + ImagePageRenderer renderer = new(exporter.Configuration); + renderer.RenderTo(surface, exporter.Pages[0]); + return surface.Calls.Count(c => c.StartsWith("DrawLine", StringComparison.Ordinal)); + } + + [Fact] + public void ChangingHiddenLayersAfterAddTakesEffect() + { + BlockRecord block = new("late-hide"); + block.Entities.Add(new Line(new XYZ(0, 0, 0), new XYZ(1, 1, 0)) { Layer = new Layer("Late") }); + ImageExporter exporter = new(); + exporter.Add(block); + + Assert.Equal(1, CountDrawnLines(exporter)); + exporter.Configuration.HideLayer("Late"); + Assert.Equal(0, CountDrawnLines(exporter)); + } + + [Fact] + public void HiddenEntitiesDoNotAffectAutoSizedFraming() + { + static ImageExporter Build(bool withFarHiddenLine) + { + BlockRecord block = new("framing"); + block.Entities.Add(new Line(new XYZ(0, 0, 0), new XYZ(10, 10, 0)) { Layer = new Layer("Visible") }); + if (withFarHiddenLine) + { + block.Entities.Add(new Line(new XYZ(1000, 1000, 0), new XYZ(1010, 1010, 0)) { Layer = new Layer("Far") }); + } + + ImageExporter exporter = new(); + exporter.Configuration.Width = 200; + exporter.Configuration.Height = 200; + exporter.Configuration.HideLayer("Far"); + exporter.Add(block); + return exporter; + } + + static string FirstLineCall(ImageExporter exporter) + { + RecordingDrawingSurface surface = new(); + new ImagePageRenderer(exporter.Configuration).RenderTo(surface, exporter.Pages[0]); + return surface.Calls.Single(c => c.StartsWith("DrawLine", StringComparison.Ordinal)); + } + + Assert.Equal(FirstLineCall(Build(withFarHiddenLine: false)), FirstLineCall(Build(withFarHiddenLine: true))); + } + + [Fact] + public void FilteredRenderingLeavesThePageFrameUntouchedAndClearingFiltersRestoresTheFullFrame() + { + BlockRecord block = new("framing-roundtrip"); + block.Entities.Add(new Line(new XYZ(0, 0, 0), new XYZ(10, 10, 0)) { Layer = new Layer("Near") }); + block.Entities.Add(new Line(new XYZ(1000, 1000, 0), new XYZ(1010, 1010, 0)) { Layer = new Layer("Far") }); + ImageExporter exporter = new(); + exporter.Configuration.Width = 200; + exporter.Configuration.Height = 200; + exporter.Add(block); + ImagePage page = Assert.Single(exporter.Pages); + double fullWidth = page.Layout!.PaperWidth; + XY fullTranslation = page.Translation; + Assert.Equal(1010d, fullWidth); + + static (SurfacePoint Start, SurfacePoint End)[] Render(ImageExporter exporter) + { + RecordingDrawingSurface surface = new(); + new ImagePageRenderer(exporter.Configuration).RenderTo(surface, exporter.Pages[0]); + return surface.Lines.ToArray(); + } + + // Hidden far line: the near line fills the frame, and the page is not written to. + exporter.Configuration.HideLayer("Far"); + (SurfacePoint Start, SurfacePoint End)[] filtered = Render(exporter); + Assert.Single(filtered); + Assert.Equal(200d, filtered[0].End.X - filtered[0].Start.X, 3); + Assert.Equal(fullWidth, page.Layout.PaperWidth); + Assert.Equal(fullTranslation, page.Translation); + + // Filters cleared: both lines are drawn in the full frame the caller built, not the 10-unit one. + exporter.Configuration.ClearHiddenLayers(); + (SurfacePoint Start, SurfacePoint End)[] unfiltered = Render(exporter); + Assert.Equal(2, unfiltered.Length); + Assert.All(unfiltered, l => Assert.InRange(l.End.X, 0, 200)); + Assert.Equal(200d / 1010d * 10d, unfiltered[0].End.X - unfiltered[0].Start.X, 3); + } + + [Fact] + public void MalformedPolylineDoesNotAbortTheExport() + { + // Two coincident vertices joined by a bulge make ACadSharp throw from GetBoundingBox and GetPoints. + LwPolyline malformed = new(); + malformed.Vertices.Add(new LwPolyline.Vertex(new XY(5, 5)) { Bulge = 1 }); + malformed.Vertices.Add(new LwPolyline.Vertex(new XY(5, 5))); + malformed.Vertices.Add(new LwPolyline.Vertex(new XY(8, 5))); + BlockRecord block = new("malformed"); + block.Entities.Add(new Line(new XYZ(0, 0, 0), new XYZ(100, 50, 0))); + block.Entities.Add(malformed); + + ImageExporter exporter = new(); + List notifications = new(); + exporter.Configuration.OnNotification += (_, e) => notifications.Add(e); + exporter.Add(block); + + // The frame comes from the entities whose bounds can be computed. + ImagePage page = Assert.Single(exporter.Pages); + Assert.Equal(100d, page.Layout!.PaperWidth); + Assert.Equal(50d, page.Layout.PaperHeight); + + using RenderedImagePage png = Assert.IsType(Assert.Single(exporter.Render())); + RenderedSvgPage svg = Assert.IsType(Assert.Single(exporter.Render(ImageExportFormat.Svg))); + + Assert.Contains(" n.NotificationType == NotificationType.Warning && n.Message.Contains("entity skipped", StringComparison.Ordinal)); } } diff --git a/ACadSharp.Image.Tests/ImagePageRendererTests.cs b/ACadSharp.Image.Tests/ImagePageRendererTests.cs new file mode 100644 index 0000000..e982d08 --- /dev/null +++ b/ACadSharp.Image.Tests/ImagePageRendererTests.cs @@ -0,0 +1,525 @@ +using ACadSharp.Entities; +using ACadSharp.Header; +using ACadSharp.Image.Rendering; +using ACadSharp.IO; +using ACadSharp.Objects; +using ACadSharp.Tables; +using CSMath; + +namespace ACadSharp.Image.Tests; + +public sealed class ImagePageRendererTests +{ + [Fact] + public void ViewportLineTypeScaleDefaultsToPaperSpaceWithoutHeader() + { + Assert.Equal(8d, ImagePageRenderer.ResolveViewportLineTypeScale(null, 8d, 0.5d)); + } + + [Fact] + public void ViewportLineTypeScaleKeepsPageScaleWhenPsltscaleIsOne() + { + // Raw $PSLTSCALE 1: dashes are scaled to paper space, so the page value is used unchanged. + CadHeader header = new() { PaperSpaceLineTypeScaling = (SpaceLineTypeScaling)1 }; + + Assert.Equal(8d, ImagePageRenderer.ResolveViewportLineTypeScale(header, 8d, 0.5d)); + } + + [Fact] + public void ViewportLineTypeScaleFollowsViewportWhenPsltscaleIsZero() + { + // Raw $PSLTSCALE 0: dashes keep their model-space length and shrink with the viewport. + CadHeader header = new() { PaperSpaceLineTypeScaling = (SpaceLineTypeScaling)0 }; + + Assert.Equal(4d, ImagePageRenderer.ResolveViewportLineTypeScale(header, 8d, 0.5d)); + } + + [Fact] + public void LayoutPagesKeepTheirPaperSize() + { + // A layout carries its own paper size, so the frame must survive rendering even with a hidden + // entity far outside the sheet. + Layout layout = new("sheet") { PaperWidth = 210d, PaperHeight = 297d }; + layout.AssociatedBlock.Entities.Add(new Line(new XYZ(5000, 5000, 0), new XYZ(6000, 6000, 0)) { Layer = new Layer("Far") }); + + ImageExporter exporter = new(); + exporter.Configuration.HideLayer("Far"); + exporter.Add(layout); + ImagePage page = Assert.Single(exporter.Pages); + + new ImagePageRenderer(exporter.Configuration).RenderTo(new RecordingDrawingSurface(), page); + + Assert.Equal(210d, page.Layout!.PaperWidth); + Assert.Equal(297d, page.Layout.PaperHeight); + } + + [Fact] + public void UnfilteredBlockPagesAreNotReframed() + { + // Nothing can be hidden, so the page must be handed to the surface exactly as it was built. + BlockRecord block = new("PLAN"); + block.Entities.Add(new Line(new XYZ(100, 50, 0), new XYZ(200, 150, 0))); + + ImageExporter exporter = new(); + exporter.Add(block); + ImagePage page = Assert.Single(exporter.Pages); + XY translation = page.Translation; + double paperWidth = page.Layout!.PaperWidth; + + new ImagePageRenderer(exporter.Configuration).RenderTo(new RecordingDrawingSurface(), page); + + Assert.Equal(translation, page.Translation); + Assert.Equal(paperWidth, page.Layout.PaperWidth); + } + + /// Renders the exporter's first page onto the surface, the way does. + private static void RenderThrough(ImageExporter exporter, RecordingDrawingSurface surface) + { + new ImagePageRenderer(exporter.Configuration).RenderTo(surface, exporter.Pages[0]); + } + + [Fact] + public void PaperEntitiesAddedBeforeAViewportAreDrawnBeforeIt() + { + // A page built by ImageExporter from a layout whose title line sorts before the viewport must draw the line first. + CadDocument document = new(); + document.Entities.Add(new Line(new XYZ(0, 0, 0), new XYZ(10, 0, 0))); + Layout layout = new("Sheet") { PaperWidth = 200, PaperHeight = 100 }; + document.Layouts.Add(layout); + layout.AssociatedBlock.Entities.Add(new Line(new XYZ(5, 5, 0), new XYZ(50, 5, 0))); + layout.AssociatedBlock.Entities.Add(new Viewport { Center = new XYZ(100, 50, 0), Width = 50, Height = 50, ViewCenter = new XY(5, 0), ViewHeight = 20 }); + // A second paper line after the viewport pins the renderer to the sequence in both directions: drawing all + // entities and then all viewports (the mirror of the old bug) would put this one before the viewport too. + layout.AssociatedBlock.Entities.Add(new Line(new XYZ(5, 90, 0), new XYZ(50, 90, 0))); + + RecordingDrawingSurface surface = new(); + ImageExporter exporter = new(); + exporter.Add(layout); + RenderThrough(exporter, surface); + + int line1 = surface.Calls.FindIndex(c => c.StartsWith("DrawLine", StringComparison.Ordinal)); + int viewport = surface.Calls.FindIndex(c => c.StartsWith("BeginViewport", StringComparison.Ordinal)); + // The model line the viewport shows is a DrawLine too, so the second paper line is located past EndViewport + // rather than with FindLastIndex, which would match that one under the mirror bug. + int endViewport = surface.Calls.FindIndex(c => string.Equals(c, "EndViewport", StringComparison.Ordinal)); + int line2 = endViewport < 0 ? -1 : surface.Calls.FindIndex(endViewport, c => c.StartsWith("DrawLine", StringComparison.Ordinal)); + Assert.True( + line1 >= 0 && viewport >= 0 && endViewport >= 0 && line1 < viewport && endViewport < line2, + $"expected the first title line, the viewport and the second title line in that order, got line1 at {line1}, viewport at {viewport}, EndViewport at {endViewport}, line2 at {line2}."); + } + + [Fact] + public void AViewportAddedAsAPageEntityIsNotDrawnAsAViewport() + { + // ImagePage.Add(BlockRecord) has no viewport filter, so a layout block's paper viewport can reach the page + // through AddEntity. Such a viewport is an ordinary page entity, not a window onto model space. + CadDocument document = new(); + document.Entities.Add(new Line(new XYZ(0, 0, 0), new XYZ(10, 0, 0))); + Layout layout = new("Sheet") { PaperWidth = 200, PaperHeight = 100 }; + document.Layouts.Add(layout); + Viewport viewport = new() { Center = new XYZ(100, 50, 0), Width = 50, Height = 50, ViewCenter = new XY(5, 0), ViewHeight = 20 }; + layout.AssociatedBlock.Entities.Add(viewport); + + ImageConfiguration configuration = new(); + List notifications = new(); + configuration.OnNotification += (_, e) => notifications.Add(e); + + ImagePage asEntity = new() { Layout = layout, Document = document }; + asEntity.AddEntity(viewport); + RecordingDrawingSurface entitySurface = new(); + new ImagePageRenderer(configuration).RenderTo(entitySurface, asEntity); + + Assert.DoesNotContain(entitySurface.Calls, c => c.StartsWith("BeginViewport", StringComparison.Ordinal)); + Assert.Contains(notifications, n => n.NotificationType == NotificationType.NotImplemented); + + ImagePage asWindow = new() { Layout = layout, Document = document }; + asWindow.AddViewport(viewport); + RecordingDrawingSurface windowSurface = new(); + new ImagePageRenderer(configuration).RenderTo(windowSurface, asWindow); + + Assert.Single(windowSurface.Calls, c => c.StartsWith("BeginViewport", StringComparison.Ordinal)); + } + + [Fact] + public void MalformedModelEntityDoesNotAbortViewportRendering() + { + CadDocument document = new(); + document.Entities.Add(new Line(new XYZ(0, 0, 0), new XYZ(10, 0, 0))); + LwPolyline malformed = new(); + malformed.Vertices.Add(new LwPolyline.Vertex(new XY(5, 5)) { Bulge = 1 }); + malformed.Vertices.Add(new LwPolyline.Vertex(new XY(5, 5))); + malformed.Vertices.Add(new LwPolyline.Vertex(new XY(9, 5))); + document.Entities.Add(malformed); + Layout layout = new("Sheet") { PaperWidth = 200, PaperHeight = 100 }; + document.Layouts.Add(layout); + layout.AssociatedBlock.Entities.Add(new Viewport { Center = new XYZ(100, 50, 0), Width = 50, Height = 50, ViewCenter = new XY(5, 2), ViewHeight = 20 }); + + RecordingDrawingSurface surface = new(); + ImageExporter exporter = new(); + List notifications = new(); + exporter.Configuration.OnNotification += (_, e) => notifications.Add(e); + exporter.Add(layout); + + RenderThrough(exporter, surface); + + Assert.Contains(surface.Calls, c => c.StartsWith("DrawLine", StringComparison.Ordinal)); + Assert.Contains(notifications, n => n.NotificationType == NotificationType.Warning && n.Message.Contains("bounds", StringComparison.OrdinalIgnoreCase)); + } + + [Fact] + public void ModelEntityWithAnUnresolvedBlockDoesNotAbortViewportRendering() + { + // SelectViewportEntities culls model space by GetBoundingBox(); an Insert whose Block reference could not be + // resolved makes ACadSharp's Insert.GetBoundingBox() throw NullReferenceException unguarded, which must not + // abort the rest of the viewport, the way the malformed-geometry case above does not. + CadDocument document = new(); + document.Entities.Add(new Line(new XYZ(0, 0, 0), new XYZ(10, 0, 0))); + Insert orphan = new(new BlockRecord("GONE")) { InsertPoint = new XYZ(5, 5, 0) }; + typeof(Insert).GetProperty(nameof(Insert.Block))!.SetValue(orphan, null); + document.Entities.Add(orphan); + Layout layout = new("Sheet") { PaperWidth = 200, PaperHeight = 100 }; + document.Layouts.Add(layout); + layout.AssociatedBlock.Entities.Add(new Viewport { Center = new XYZ(100, 50, 0), Width = 50, Height = 50, ViewCenter = new XY(5, 2), ViewHeight = 20 }); + + RecordingDrawingSurface surface = new(); + ImageExporter exporter = new(); + List notifications = new(); + exporter.Configuration.OnNotification += (_, e) => notifications.Add(e); + exporter.Add(layout); + + RenderThrough(exporter, surface); + + Assert.Contains(surface.Calls, c => c.StartsWith("DrawLine", StringComparison.Ordinal)); + Assert.Contains(notifications, n => n.NotificationType == NotificationType.Warning && n.Message.Contains("no block", StringComparison.OrdinalIgnoreCase)); + } + + [Fact] + public void ModelEntityWithANestedUnresolvedBlockReferenceDoesNotAbortViewportRendering() + { + // A model-space Insert whose own Block resolves fine, but which references a block holding a further Insert + // with a null Block, makes ACadSharp's Insert.GetBoundingBox() throw NullReferenceException one level down, + // out of BlockRecord.GetBoundingBox(). EntityBounds.TryGet must catch that too, not just the top-level + // Block == null case ModelEntityWithAnUnresolvedBlockDoesNotAbortViewportRendering above covers. + CadDocument document = new(); + document.Entities.Add(new Line(new XYZ(0, 0, 0), new XYZ(10, 0, 0))); + Insert nestedOrphan = new(new BlockRecord("GONE")) { InsertPoint = new XYZ(0, 0, 0) }; + typeof(Insert).GetProperty(nameof(Insert.Block))!.SetValue(nestedOrphan, null); + BlockRecord middle = new("MIDDLE"); + middle.Entities.Add(nestedOrphan); + Insert outer = new(middle) { InsertPoint = new XYZ(5, 5, 0) }; + document.Entities.Add(outer); + Layout layout = new("Sheet") { PaperWidth = 200, PaperHeight = 100 }; + document.Layouts.Add(layout); + layout.AssociatedBlock.Entities.Add(new Viewport { Center = new XYZ(100, 50, 0), Width = 50, Height = 50, ViewCenter = new XY(5, 2), ViewHeight = 20 }); + + RecordingDrawingSurface surface = new(); + ImageExporter exporter = new(); + List notifications = new(); + exporter.Configuration.OnNotification += (_, e) => notifications.Add(e); + exporter.Add(layout); + + RenderThrough(exporter, surface); + + Assert.Contains(surface.Calls, c => c.StartsWith("DrawLine", StringComparison.Ordinal)); + Assert.Contains(notifications, n => n.NotificationType == NotificationType.Warning && n.Message.Contains("bounds could not be computed", StringComparison.OrdinalIgnoreCase)); + } + + [Fact] + public void ModelSpaceWipeoutMappedRegionDecidesViewportInclusionNotTheRawVertexBox() + { + // Mirrors ImagePageTests.FrameUsesTheMappedWipeoutRegionNotTheRawPixelVertices: pixel space is rotated 90 + // degrees, so ACadSharp's own GetBoundingBox() returns the raw ClipBoundaryVertices' box, x/y in + // [9.5,10.5], while the region the renderer actually fills (mapped through UVector/VVector) spans x in + // [5,10], y in [10,15]. Viewport.SelectEntities-style culling (ImagePageRenderer.cs) checks entity-box + // corners against the viewport's model window, not general rectangle overlap, so the window below is + // placed on the mapped region's (5,10) corner: none of the raw box's four corners fall inside it (the raw + // box would be fully excluded), but the mapped region's corner does (partial inclusion), so only bounds + // that follow the renderer's own wipeout mapping select this entity into the viewport. + CadDocument document = new(); + Wipeout wipeout = new() + { + InsertPoint = new XYZ(10, 10, 0), + UVector = new XYZ(0, 5, 0), + 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); + Layout layout = new("Sheet") { PaperWidth = 200, PaperHeight = 100 }; + document.Layouts.Add(layout); + // Model window x in [4,6], y in [9,11]: none of the raw box's corners ([9.5,10.5]x[9.5,10.5]) fall inside + // it, but the mapped region's (5,10) corner does. + layout.AssociatedBlock.Entities.Add(new Viewport { Center = new XYZ(100, 50, 0), Width = 50, Height = 50, ViewCenter = new XY(5, 10), ViewHeight = 2 }); + + RecordingDrawingSurface surface = new(); + ImageExporter exporter = new(); + exporter.Add(layout); + + RenderThrough(exporter, surface); + + Assert.Contains(surface.Calls, c => c.StartsWith("FillPolygon", StringComparison.Ordinal)); + } + + [Fact] + public void HiddenModelSpaceWipeoutInAViewportRaisesNoWarning() + { + // EntityBounds.TryGet returns false with a null error for a wipeout that would draw nothing (here, + // ImageDisplayFlags.ShowImage left unset) rather than one whose bounds could not be computed: it must be + // skipped from the viewport silently, not with a "bounds could not be computed" Warning that would be + // false (its bounds are well-defined; it simply draws nothing). + CadDocument document = new(); + Wipeout hidden = new() + { + InsertPoint = new XYZ(10, 10, 0), + UVector = new XYZ(5, 0, 0), + VVector = new XYZ(0, 5, 0), + Size = new XY(1, 1), + Flags = ImageDisplayFlags.ShowNotAlignedImage | ImageDisplayFlags.UseClippingBoundary, // ShowImage off + }; + document.Entities.Add(hidden); + Layout layout = new("Sheet") { PaperWidth = 200, PaperHeight = 100 }; + document.Layouts.Add(layout); + layout.AssociatedBlock.Entities.Add(new Viewport { Center = new XYZ(100, 50, 0), Width = 50, Height = 50, ViewCenter = new XY(10, 10), ViewHeight = 20 }); + + RecordingDrawingSurface surface = new(); + ImageExporter exporter = new(); + List notifications = new(); + exporter.Configuration.OnNotification += (_, e) => notifications.Add(e); + exporter.Add(layout); + + RenderThrough(exporter, surface); + + Assert.DoesNotContain(surface.Calls, c => c.StartsWith("FillPolygon", StringComparison.Ordinal)); + Assert.DoesNotContain(notifications, n => n.NotificationType == NotificationType.Warning); + } + + [Fact] + public void InvertedClipModelSpaceWipeoutInAViewportMasksTheSameAsThePageLevelDrawWipeout() + { + // An inverted wipeout now masks the frame minus its boundary instead of being skipped: its bounds + // (EntityBounds.TryGet, via WipeoutWorldRings) are the whole footprint, so SelectViewportEntities selects it + // like any other entity and Draw fills it with an even-odd FillPath the same way the page level does. + CadDocument document = new(); + Wipeout inverted = new() + { + InsertPoint = new XYZ(10, 10, 0), + UVector = new XYZ(5, 0, 0), + 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)), + // so the two rings the fill relies on are genuinely different point sets. + inverted.ClipBoundaryVertices.AddRange([new XY(-0.25, -0.25), new XY(0.25, 0.25)]); + document.Entities.Add(inverted); + Layout layout = new("Sheet") { PaperWidth = 200, PaperHeight = 100 }; + document.Layouts.Add(layout); + layout.AssociatedBlock.Entities.Add(new Viewport { Center = new XYZ(100, 50, 0), Width = 50, Height = 50, ViewCenter = new XY(10, 10), ViewHeight = 20 }); + + RecordingDrawingSurface surface = new(); + ImageExporter exporter = new(); + List notifications = new(); + exporter.Configuration.OnNotification += (_, e) => notifications.Add(e); + exporter.Add(layout); + + RenderThrough(exporter, surface); + + Assert.DoesNotContain(surface.Calls, c => c.StartsWith("FillPolygon", StringComparison.Ordinal)); + Assert.Contains("FillPath rings=2", surface.Calls); + Assert.DoesNotContain(notifications, n => n.NotificationType == NotificationType.NotImplemented); + } + + [Fact] + public void NonFiniteModelEntityBoundsInAViewportRaiseAWarningInsteadOfVanishingSilently() + { + // OverlapsInPlane compares bounds with <=/>=, and every IEEE 754 comparison against NaN is false, so a NaN + // bound culls the entity the same way a strictly-outside one is, but silently: EntityBounds.TryGet succeeds + // (the bounds are computed, just NaN), so the "bounds could not be computed" branch never fires. (Infinity is + // not this case: +/-Infinity compares correctly against a finite window, so an infinite-bounds line would + // still be selected and reach Draw's own HasFiniteGeometry check, which is why this test uses NaN, not + // PositiveInfinity, to discriminate the fix from the pre-fix behaviour.) The page-level Draw would have + // warned "geometry contains non-finite values"; the viewport path must too. + CadDocument document = new(); + document.Entities.Add(new Line(new XYZ(0, 0, 0), new XYZ(double.NaN, 0, 0))); + Layout layout = new("Sheet") { PaperWidth = 200, PaperHeight = 100 }; + document.Layouts.Add(layout); + layout.AssociatedBlock.Entities.Add(new Viewport { Center = new XYZ(100, 50, 0), Width = 50, Height = 50, ViewCenter = new XY(0, 0), ViewHeight = 10 }); + + RecordingDrawingSurface surface = new(); + ImageExporter exporter = new(); + List notifications = new(); + exporter.Configuration.OnNotification += (_, e) => notifications.Add(e); + exporter.Add(layout); + + RenderThrough(exporter, surface); + + Assert.DoesNotContain(surface.Calls, c => c.StartsWith("DrawLine", StringComparison.Ordinal)); + Assert.Contains(notifications, n => n.NotificationType == NotificationType.Warning && n.Message.Contains("non-finite", StringComparison.OrdinalIgnoreCase)); + } + + [Fact] + public void ALineCrossingTheViewportWithBothEndpointsOutsideItIsDrawn() + { + // BoundingBox.IsIn (what Viewport.SelectEntities itself uses) only keeps an entity when one of its bounds' + // own corners lies inside the window; a line's degenerate bounding box has no corner inside a window it + // merely passes through, so IsIn/partial both come back false for a line like this one even though it is + // squarely visible in the viewport. The XY overlap test used instead must still select it. + CadDocument document = new(); + document.Entities.Add(new Line(new XYZ(-10, 0, 0), new XYZ(10, 0, 0))); + Layout layout = new("Sheet") { PaperWidth = 200, PaperHeight = 100 }; + document.Layouts.Add(layout); + // Model window x in [-5,5], y in [-5,5] (ViewCenter +/- ViewHeight/2 on a square viewport). + layout.AssociatedBlock.Entities.Add(new Viewport { Center = new XYZ(100, 50, 0), Width = 50, Height = 50, ViewCenter = new XY(0, 0), ViewHeight = 10 }); + + RecordingDrawingSurface surface = new(); + ImageExporter exporter = new(); + exporter.Add(layout); + + RenderThrough(exporter, surface); + + Assert.Contains(surface.Calls, c => c.StartsWith("DrawLine", StringComparison.Ordinal)); + } + + [Fact] + public void AnEntityWhoseBoundsEncloseTheViewportIsDrawn() + { + // Same IsIn limitation as above, the other way round: none of a large solid's four corners lie inside a + // small window it entirely encloses, so IsIn/partial both come back false even though the solid covers the + // whole viewport. The XY overlap test must still select it. + CadDocument document = new(); + document.Entities.Add(new Solid { FirstCorner = new XYZ(-20, -20, 0), SecondCorner = new XYZ(20, -20, 0), ThirdCorner = new XYZ(-20, 20, 0), FourthCorner = new XYZ(20, 20, 0) }); + Layout layout = new("Sheet") { PaperWidth = 200, PaperHeight = 100 }; + document.Layouts.Add(layout); + layout.AssociatedBlock.Entities.Add(new Viewport { Center = new XYZ(100, 50, 0), Width = 50, Height = 50, ViewCenter = new XY(0, 0), ViewHeight = 10 }); + + RecordingDrawingSurface surface = new(); + ImageExporter exporter = new(); + exporter.Add(layout); + + RenderThrough(exporter, surface); + + Assert.Contains(surface.Calls, c => c.StartsWith("FillPolygon", StringComparison.Ordinal)); + } + + [Fact] + public void AnEntityStrictlyOutsideTheViewportIsStillCulled() + { + // The XY overlap test must not turn into "draw everything": an entity whose bounds do not overlap the + // window at all on either axis stays excluded. + CadDocument document = new(); + document.Entities.Add(new Line(new XYZ(100, 100, 0), new XYZ(110, 110, 0))); + Layout layout = new("Sheet") { PaperWidth = 200, PaperHeight = 100 }; + document.Layouts.Add(layout); + layout.AssociatedBlock.Entities.Add(new Viewport { Center = new XYZ(100, 50, 0), Width = 50, Height = 50, ViewCenter = new XY(0, 0), ViewHeight = 10 }); + + RecordingDrawingSurface surface = new(); + ImageExporter exporter = new(); + exporter.Add(layout); + + RenderThrough(exporter, surface); + + Assert.DoesNotContain(surface.Calls, c => c.StartsWith("DrawLine", StringComparison.Ordinal)); + } + + [Fact] + public void AddingAnMLineToAPreviouslyCleanBlockIsPickedUpByTheNextRenderOnTheSameRenderer() + { + // The dispatcher caches, per block, whether its subtree holds anything that needs MLINE/LEADER healing + // before Explode() runs (see EntityRenderDispatcher.BlockSubtreeNeedsHeal). The renderer outlives a single + // render, so a block found clean on one render must not stay cached as clean once an MLINE is added to it. + BlockRecord wall = new("WALL"); + wall.Entities.Add(new Line(new XYZ(0, 0, 0), new XYZ(10, 0, 0))); + BlockRecord plan = new("PLAN"); + plan.Entities.Add(new Insert(wall)); + + ImageExporter exporter = new(); + exporter.Add(plan); + ImagePage page = Assert.Single(exporter.Pages); + ImagePageRenderer renderer = new(exporter.Configuration); + + renderer.RenderTo(new RecordingDrawingSurface(), page); + + MLineStyle style = new("PLAN"); + style.AddElement(new MLineStyle.Element { Offset = 0.5 }); + style.AddElement(new MLineStyle.Element { Offset = -0.5 }); + MLine mline = new() + { + Style = style, + Vertices = + { + MLineVertexAt(0, 0, [0.5, 0], [-0.5, 0]), + MLineVertexAt(10, 0, [0.5, 0], [-0.5, 0]), + }, + }; + wall.Entities.Add(mline); + + RecordingDrawingSurface surface = new(); + renderer.RenderTo(surface, page); + + Assert.Equal(2, mline.Vertices.Count); + Assert.Equal(2, surface.Polylines.Count); + } + + [Fact] + public void AddingALeaderToAPreviouslyCleanBlockIsPickedUpByTheNextRenderOnTheSameRenderer() + { + // 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. + BlockRecord wall = new("WALLL"); + wall.Entities.Add(new Line(new XYZ(0, 0, 0), new XYZ(10, 0, 0))); + BlockRecord plan = new("PLANL"); + plan.Entities.Add(new Insert(wall) { InsertPoint = new XYZ(5, 20, 0) }); + + ImageExporter exporter = new(); + exporter.Add(plan); + ImagePage page = Assert.Single(exporter.Pages); + ImagePageRenderer renderer = new(exporter.Configuration); + + renderer.RenderTo(new RecordingDrawingSurface(), page); + + Leader leader = new() { Vertices = { new XYZ(0, 0, 0), new XYZ(10, 0, 0) } }; + wall.Entities.Add(leader); + + RecordingDrawingSurface surface = new(); + renderer.RenderTo(surface, page); + + // The original LEADER must still hold its own local coordinates: an un-healed render would have the + // in-place ApplyTransform bake world coordinates into the shared list instead, and leave them there because + // no snapshot was taken to restore from. + Assert.Equal([new XYZ(0, 0, 0), new XYZ(10, 0, 0)], leader.Vertices); + + // The drawn polyline must carry the insert's translation exactly once, the way a healthy (never-stale) + // render of the very same final page state would draw it — comparing against a fresh renderer's output + // sidesteps hand-computing the page's auto-sized frame while still catching the double-transform an + // un-healed draw would otherwise produce (Draw() re-applies the insert's transform on top of vertices + // ApplyTransform already moved into world space in place). + RecordingDrawingSurface reference = new(); + new ImagePageRenderer(exporter.Configuration).RenderTo(reference, page); + IReadOnlyList expected = Assert.Single(reference.Polylines); + IReadOnlyList actual = Assert.Single(surface.Polylines); + Assert.Equal(expected, actual); + } + + private static MLine.Vertex MLineVertexAt(double x, double y, params double[][] parameters) + { + MLine.Vertex vertex = new() { Position = new XYZ(x, y, 0), Direction = new XYZ(1, 0, 0), Miter = new XYZ(0, 1, 0) }; + foreach (double[] segment in parameters) + { + MLine.Vertex.Segment element = new(); + element.Parameters.AddRange(segment); + vertex.Segments.Add(element); + } + + return vertex; + } +} diff --git a/ACadSharp.Image.Tests/ImagePageTests.cs b/ACadSharp.Image.Tests/ImagePageTests.cs new file mode 100644 index 0000000..7d266b8 --- /dev/null +++ b/ACadSharp.Image.Tests/ImagePageTests.cs @@ -0,0 +1,180 @@ +using System.Reflection; +using ACadSharp; +using ACadSharp.Entities; +using ACadSharp.Image.Rendering; +using ACadSharp.Objects; +using ACadSharp.Tables; +using CSMath; + +namespace ACadSharp.Image.Tests; + +public sealed class ImagePageTests +{ + private static T WithHandle(T entity, ulong handle) + where T : CadObject + => SyntheticSamples.WithHandle(entity, handle); + + [Fact] + public void AddOrdersEntitiesByHandleNotByInsertionOrder() + { + BlockRecord block = new("ORDER"); + Line later = WithHandle(new Line(new XYZ(0, 0, 0), new XYZ(1, 0, 0)), 0x20); + Line earlier = WithHandle(new Line(new XYZ(0, 0, 0), new XYZ(0, 1, 0)), 0x10); + block.Entities.Add(later); + block.Entities.Add(earlier); + + ImagePage page = new(); + page.Add(block, resizeLayout: false); + + Assert.Equal([0x10UL, 0x20UL], page.Entities.Select(e => e.Handle)); + } + + [Fact] + public void AddHonoursTheDrawOrderTable() + { + BlockRecord block = new("ORDER"); + Line low = WithHandle(new Line(new XYZ(0, 0, 0), new XYZ(1, 0, 0)), 0x10); + Line high = WithHandle(new Line(new XYZ(0, 0, 0), new XYZ(0, 1, 0)), 0x20); + block.Entities.Add(low); + block.Entities.Add(high); + block.CreateSortEntitiesTable(); + block.SortEntitiesTable!.Add(low, 0x30); // the low-handle entity is sorted last + + ImagePage page = new(); + page.Add(block, resizeLayout: false); + + Assert.Equal([0x20UL, 0x10UL], page.Entities.Select(e => e.Handle)); + } + + [Fact] + public void AddWithFilterKeepsTheSortedOrder() + { + BlockRecord block = new("ORDER"); + block.Entities.Add(WithHandle(new Line(new XYZ(0, 0, 0), new XYZ(1, 0, 0)), 0x30)); + block.Entities.Add(WithHandle(new Circle { Center = new XYZ(0, 0, 0), Radius = 1 }, 0x20)); + block.Entities.Add(WithHandle(new Line(new XYZ(0, 0, 0), new XYZ(0, 1, 0)), 0x10)); + + ImagePage page = new(); + page.Add(block, e => e is Line, resizeLayout: false); + + Assert.Equal([0x10UL, 0x30UL], page.Entities.Select(e => e.Handle)); + } + + [Fact] + public void DrawSequenceKeepsViewportsAndEntitiesInInsertionOrder() + { + ImagePage page = new(); + Line first = new(new XYZ(0, 0, 0), new XYZ(1, 0, 0)); + Viewport viewport = new() { Center = new XYZ(50, 50, 0), Width = 10, Height = 10 }; + Line last = new(new XYZ(0, 0, 0), new XYZ(0, 1, 0)); + + page.AddEntity(first); + page.AddViewport(viewport); + page.AddEntity(last); + + Assert.Equal([first, viewport, last], page.DrawSequence); + Assert.Equal([first, last], page.Entities); + Assert.Equal([viewport], page.Viewports); + } + + [Fact] + public void FrameUsesTheMappedWipeoutRegionNotTheRawPixelVertices() + { + // Pixel space rotated 90 degrees: U up, V left. Raw vertices span 1 unit; the mapped region spans 5. + Wipeout wipeout = new() + { + InsertPoint = new XYZ(10, 10, 0), + UVector = new XYZ(0, 5, 0), + 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(); + page.AddEntity(wipeout); + + PageFrame frame = Assert.NotNull(page.ComputeFrame(null)); + + // Mapped corners: (10,10)+(x+0.5)U+(1-y-0.5)V for the four corners → x in [5,10], y in [10,15]. + Assert.Equal(5d, frame.PaperWidth, 6); + Assert.Equal(5d, frame.PaperHeight, 6); + } + + [Fact] + public void FrameMirrorsAnOcsSolidLikeTheRenderer() + { + Solid solid = new() { FirstCorner = new XYZ(0, 0, 0), SecondCorner = new XYZ(10, 0, 0), ThirdCorner = new XYZ(0, 5, 0), FourthCorner = new XYZ(10, 5, 0), Normal = new XYZ(0, 0, -1) }; + ImagePage page = new(); + page.AddEntity(solid); + + PageFrame frame = Assert.NotNull(page.ComputeFrame(null)); + + // A (0,0,-1) normal mirrors X: the solid spans x in [-10, 0]. Translation is -min (PageFrame.Of / ComputeFrame), + // so the mirrored solid's translation is 10. + Assert.Equal(10d, frame.Translation.X, 6); + Assert.Equal(10d, frame.PaperWidth, 6); + Assert.Equal(5d, frame.PaperHeight, 6); + } + + [Fact] + public void FrameSkipsAnInsertWithoutABlock() + { + Insert insert = new(new BlockRecord("GONE")) { InsertPoint = new XYZ(1000, 1000, 0) }; + typeof(Insert).GetProperty(nameof(Insert.Block))!.SetValue(insert, null); + ImagePage page = new(); + page.AddEntity(new Line(new XYZ(0, 0, 0), new XYZ(10, 0, 0))); + page.AddEntity(insert); + + PageFrame frame = Assert.NotNull(page.ComputeFrame(null)); + + Assert.Equal(10d, frame.PaperWidth, 6); + } + + [Fact] + public void FrameSkipsAnInsertWhoseNestedBlockReferenceIsUnresolved() + { + // A block reference nested one level inside a resolvable block, itself referencing a null Block, makes + // ACadSharp's Insert.GetBoundingBox() throw NullReferenceException from inside BlockRecord.GetBoundingBox(), + // past EntityBounds.TryGet's exception filter, unless that filter also catches NullReferenceException. This + // is not the page-level case FrameSkipsAnInsertWithoutABlock covers (Block == null on the entity itself); + // here the outer insert's own Block resolves fine, and the unresolved reference is one level down. + Insert nestedOrphan = new(new BlockRecord("GONE")) { InsertPoint = new XYZ(0, 0, 0) }; + typeof(Insert).GetProperty(nameof(Insert.Block))!.SetValue(nestedOrphan, null); + BlockRecord middle = new("MIDDLE"); + middle.Entities.Add(nestedOrphan); + Insert outer = new(middle) { InsertPoint = new XYZ(1000, 1000, 0) }; + + ImagePage page = new(); + page.AddEntity(new Line(new XYZ(0, 0, 0), new XYZ(10, 0, 0))); + page.AddEntity(outer); + + PageFrame frame = Assert.NotNull(page.ComputeFrame(null)); + + Assert.Equal(10d, frame.PaperWidth, 6); + } + + [Fact] + public void TheFrameOfAnInvertedWipeoutIsItsWholeImageFootprint() + { + ImagePage page = new(); + Wipeout wipeout = new() + { + InsertPoint = new XYZ(0, 0, 0), + UVector = new XYZ(20, 0, 0), + 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)); + wipeout.ClipBoundaryVertices.Add(new XY(0.25, 0.25)); + page.AddEntity(wipeout); + + PageFrame frame = Assert.NotNull(page.ComputeFrame(null)); + + Assert.Equal(20d, frame.PaperWidth, 6); + Assert.Equal(10d, frame.PaperHeight, 6); + } +} diff --git a/ACadSharp.Image.Tests/InsertPlacementTests.cs b/ACadSharp.Image.Tests/InsertPlacementTests.cs new file mode 100644 index 0000000..11d7977 --- /dev/null +++ b/ACadSharp.Image.Tests/InsertPlacementTests.cs @@ -0,0 +1,160 @@ +using ACadSharp.Entities; +using ACadSharp.Image.Rendering; +using ACadSharp.Tables; +using CSMath; +using Xunit; + +namespace ACadSharp.Image.Tests; + +public sealed class InsertPlacementTests +{ + [Fact] + public void MapPointWithoutAPlacementReturnsThePointUnchanged() + { + Assert.Equal(new XYZ(3, 4, 5), InsertPlacement.MapPoint(null, new XYZ(3, 4, 5))); + } + + [Fact] + public void MapVectorDropsTheTranslation() + { + Transform placement = Transform.CreateTranslation(new XYZ(100, 200, 300)); + + Assert.Equal(new XYZ(1, 0, 0), InsertPlacement.MapVector(placement, new XYZ(1, 0, 0))); + } + + [Fact] + public void MapVectorKeepsTheLinearPart() + { + Transform placement = PlacementOf(new XYZ(100, 200, 0), 0d, 2, 3); + + XYZ mapped = InsertPlacement.MapVector(placement, new XYZ(1, 1, 0)); + + Assert.Equal(2d, mapped.X, 9); + Assert.Equal(3d, mapped.Y, 9); + } + + /// + /// A placement built the way production builds one: from a real block reference. Constructing a + /// Transform directly would depend on an argument order these tests should not be pinning. + /// + private static Transform PlacementOf(XYZ insertPoint, double rotation, double xScale, double yScale) + => new Insert(new BlockRecord("PLACEMENT")) + { + InsertPoint = insertPoint, + Rotation = rotation, + XScale = xScale, + YScale = yScale, + ZScale = Math.Abs(xScale), + }.GetTransform(); + + [Fact] + public void MapOcsPointAppliesTheOcsBeforeThePlacement() + { + // Normal (0,0,-1) mirrors X going from OCS to world: (4,1) becomes (-4,1); the placement then adds (10,0). + Transform placement = Transform.CreateTranslation(new XYZ(10, 0, 0)); + + XYZ mapped = InsertPlacement.MapOcsPoint(placement, OcsTransform.For(new XYZ(0, 0, -1)), 0d, new XYZ(4, 1, 0)); + + Assert.Equal(6d, mapped.X, 9); + Assert.Equal(1d, mapped.Y, 9); + } + + [Fact] + public void MapOcsPointUsesTheElevationForTheOutOfPlaneOffset() + { + XYZ mapped = InsertPlacement.MapOcsPoint(null, OcsTransform.For(new XYZ(0, 0, -1)), 7d, new XYZ(1, 2, 0)); + + Assert.Equal(-7d, mapped.Z, 9); + } + + [Fact] + public void MapOcsPointWithoutAnOcsIsAPlainPointMap() + { + Assert.Equal(new XYZ(1, 2, 0), InsertPlacement.MapOcsPoint(null, null, 0d, new XYZ(1, 2, 0))); + } + + [Fact] + public void ANullPlacementIsAUnitSimilarity() + { + Assert.True(InsertPlacement.TryGetPlanarSimilarity(null, out double scale, out double rotation, out bool mirrored)); + Assert.Equal(1d, scale, 9); + Assert.Equal(0d, rotation, 9); + Assert.False(mirrored); + } + + [Fact] + public void AUniformlyScaledRotationIsASimilarity() + { + Transform placement = PlacementOf(new XYZ(5, 5, 0), Math.PI / 2, 3, 3); + + Assert.True(InsertPlacement.TryGetPlanarSimilarity(placement, out double scale, out double rotation, out bool mirrored)); + Assert.Equal(3d, scale, 9); + Assert.Equal(Math.PI / 2, rotation, 9); + Assert.False(mirrored); + } + + [Fact] + public void AMirroredPlacementIsASimilarityAndSaysSo() + { + Transform placement = PlacementOf(XYZ.Zero, 0d, -2, 2); + + Assert.True(InsertPlacement.TryGetPlanarSimilarity(placement, out double scale, out double rotation, out bool mirrored)); + Assert.Equal(2d, scale, 9); + Assert.True(mirrored); + } + + [Fact] + public void ANonUniformScaleIsNotASimilarity() + { + Transform placement = PlacementOf(XYZ.Zero, 0d, 2, 5); + + Assert.False(InsertPlacement.TryGetPlanarSimilarity(placement, out _, out _, out _)); + } + + [Fact] + public void ANonUniformScaleUnderRotationIsRejectedOnAxisLength() + { + // Insert.GetTransform() scales in local axes and then applies a pure rotation, which always keeps two + // originally-orthogonal axes orthogonal: turning a 3:1 scale 45 degrees gives mapped axis lengths 3 and 1 + // (unequal), not equal-length-but-skewed axes. This is caught by the length check, not the orthogonality + // check; see AShearedPlacementWithEqualLengthAxesIsNotASimilarity for a case that reaches the latter. + Transform placement = PlacementOf(XYZ.Zero, Math.PI / 4, 3, 1); + + Assert.False(InsertPlacement.TryGetPlanarSimilarity(placement, out _, out _, out _)); + } + + [Fact] + public void AShearedPlacementWithEqualLengthAxesIsNotASimilarity() + { + // A placement built from Insert.GetTransform() can never produce equal-length, non-orthogonal mapped axes + // (see ANonUniformScaleUnderRotationIsRejectedOnAxisLength), so the orthogonality branch of + // TryGetPlanarSimilarity is unreached by any placement the renderer builds today; it exists for the + // arrow-block task, which composes placements, and a composition of two rotate+scale maps can shear. This + // test drives that branch directly with a hand-built shear: X maps to (1,0,0) and Y maps to (cos 60, sin + // 60, 0), both unit length with a 60 degree angle between them, so the rejection can only come from the + // orthogonality term. + Matrix4 matrix = new( + 1d, Math.Cos(Math.PI / 3d), 0d, 0d, + 0d, Math.Sin(Math.PI / 3d), 0d, 0d, + 0d, 0d, 1d, 0d, + 0d, 0d, 0d, 1d); + Transform placement = new(matrix); + + // Guard the test's own premise so it can never silently degrade into exercising the length check instead. + XYZ ex = InsertPlacement.MapVector(placement, XYZ.AxisX); + XYZ ey = InsertPlacement.MapVector(placement, XYZ.AxisY); + Assert.Equal(new XY(ex.X, ex.Y).GetLength(), new XY(ey.X, ey.Y).GetLength(), 9); + Assert.NotEqual(0d, (ex.X * ey.X) + (ex.Y * ey.Y), 6); + + Assert.False(InsertPlacement.TryGetPlanarSimilarity(placement, out _, out _, out _)); + } + + [Fact] + public void APlacementSeenEdgeOnIsNotASimilarity() + { + // Rotating a quarter turn about X flattens the Y axis onto Z, so nothing is left in the drawing plane. + Transform placement = Transform.CreateRotation(XYZ.AxisX, Math.PI / 2); + + Assert.False(InsertPlacement.TryGetPlanarSimilarity(placement, out _, out _, out _)); + } +} diff --git a/ACadSharp.Image.Tests/LayerFilteringTests.cs b/ACadSharp.Image.Tests/LayerFilteringTests.cs new file mode 100644 index 0000000..7dff1cf --- /dev/null +++ b/ACadSharp.Image.Tests/LayerFilteringTests.cs @@ -0,0 +1,179 @@ +using ACadSharp.Entities; +using ACadSharp.Image.Rendering; +using ACadSharp.Objects; +using ACadSharp.Tables; +using CSMath; + +namespace ACadSharp.Image.Tests; + +public sealed class LayerFilteringTests +{ + private static (RecordingDrawingSurface Surface, EntityRenderDispatcher Dispatcher, ImageRenderContext Context) Setup(Action? configure = null, Viewport? viewport = null) + { + ImageConfiguration configuration = new(); + configure?.Invoke(configuration); + RecordingDrawingSurface surface = new(); + Layout layout = new("test") { PaperWidth = 100, PaperHeight = 100 }; + ImageRenderContext context = new(surface, configuration, layout, 100, 100, 0, 0, 1d, 0, 0, singlePrecision: false, lineTypeScale: 1d, viewport: viewport); + return (surface, new EntityRenderDispatcher(configuration), context); + } + + private static Line LineOn(Layer layer) => new(new XYZ(0, 0, 0), new XYZ(10, 0, 0)) { Layer = layer }; + + private static int Drawn(RecordingDrawingSurface surface) => surface.Calls.Count(c => c.StartsWith("DrawLine", StringComparison.Ordinal)); + + [Fact] + public void AllModeDrawsOffAndFrozenLayers() + { + (RecordingDrawingSurface surface, EntityRenderDispatcher dispatcher, ImageRenderContext context) = Setup(); + + dispatcher.Draw(context, LineOn(new Layer("Off") { IsOn = false })); + dispatcher.Draw(context, LineOn(new Layer("Frozen") { Flags = LayerFlags.Frozen })); + dispatcher.Draw(context, LineOn(new Layer("NoPlot") { PlotFlag = false })); + + Assert.Equal(3, Drawn(surface)); + } + + [Fact] + public void ScreenModeHidesOffFrozenAndInvisibleButNotNonPlottable() + { + (RecordingDrawingSurface surface, EntityRenderDispatcher dispatcher, ImageRenderContext context) = Setup(c => c.LayerVisibility = LayerVisibilityMode.Screen); + + dispatcher.Draw(context, LineOn(new Layer("Off") { IsOn = false })); + dispatcher.Draw(context, LineOn(new Layer("Frozen") { Flags = LayerFlags.Frozen })); + Line invisible = LineOn(new Layer("Vis")); + invisible.IsInvisible = true; + dispatcher.Draw(context, invisible); + dispatcher.Draw(context, LineOn(new Layer("NoPlot") { PlotFlag = false })); + + Assert.Equal(1, Drawn(surface)); + Assert.Equal("NoPlot", Assert.Single(surface.Entities).LayerName); + } + + [Fact] + public void PlotModeAlsoHidesNonPlottable() + { + (RecordingDrawingSurface surface, EntityRenderDispatcher dispatcher, ImageRenderContext context) = Setup(c => c.LayerVisibility = LayerVisibilityMode.Plot); + + dispatcher.Draw(context, LineOn(new Layer("NoPlot") { PlotFlag = false })); + dispatcher.Draw(context, LineOn(new Layer("Plot"))); + + Assert.Equal(1, Drawn(surface)); + } + + [Fact] + public void ViewportFrozenLayersHideOnlyInsideThatViewport() + { + Layer frozenHere = new("Site"); + Viewport viewport = new(); + viewport.FrozenLayers.Add(frozenHere); + (RecordingDrawingSurface inside, EntityRenderDispatcher dispatcher, ImageRenderContext viewportContext) = Setup(c => c.LayerVisibility = LayerVisibilityMode.Screen, viewport); + (RecordingDrawingSurface outside, EntityRenderDispatcher dispatcher2, ImageRenderContext pageContext) = Setup(c => c.LayerVisibility = LayerVisibilityMode.Screen); + + dispatcher.Draw(viewportContext, LineOn(new Layer("Site"))); + dispatcher2.Draw(pageContext, LineOn(new Layer("Site"))); + + Assert.Equal(0, Drawn(inside)); + Assert.Equal(1, Drawn(outside)); + } + + [Fact] + public void IncludeListRestrictsThenHideListRemoves() + { + (RecordingDrawingSurface surface, EntityRenderDispatcher dispatcher, ImageRenderContext context) = Setup(c => + { + c.IncludeLayers(["A", "B"]); + c.HideLayer("b"); + }); + + dispatcher.Draw(context, LineOn(new Layer("A"))); + dispatcher.Draw(context, LineOn(new Layer("B"))); + dispatcher.Draw(context, LineOn(new Layer("C"))); + + Assert.Equal(1, Drawn(surface)); + Assert.Equal("A", Assert.Single(surface.Entities).LayerName); + } + + [Fact] + public void IncludedLayerStillObeysVisibilityMode() + { + (RecordingDrawingSurface surface, EntityRenderDispatcher dispatcher, ImageRenderContext context) = Setup(c => + { + c.IncludeLayer("A"); + c.LayerVisibility = LayerVisibilityMode.Screen; + }); + + dispatcher.Draw(context, LineOn(new Layer("A") { IsOn = false })); + + Assert.Equal(0, Drawn(surface)); + } + + [Fact] + public void NestedEntitiesAreFilteredByTheirOwnLayerWithLayerZeroInheritance() + { + (RecordingDrawingSurface surface, EntityRenderDispatcher dispatcher, ImageRenderContext context) = Setup(c => c.HideLayer("Hardware")); + BlockRecord block = new("DOOR"); + block.Entities.Add(new Line(new XYZ(0, 0, 0), new XYZ(1, 0, 0)) { Layer = new Layer(Layer.DefaultName) }); + block.Entities.Add(new Line(new XYZ(0, 0, 0), new XYZ(0, 1, 0)) { Layer = new Layer("Hardware") }); + Insert insert = new(block) { Layer = new Layer("Doors") }; + + dispatcher.Draw(context, insert); + + Assert.Equal(1, Drawn(surface)); + Assert.Equal("Doors", surface.Entities.Last().LayerName); + } + + [Fact] + public void HidingTheInsertLayerHidesTheWholeBlock() + { + (RecordingDrawingSurface surface, EntityRenderDispatcher dispatcher, ImageRenderContext context) = Setup(c => c.HideLayer("Doors")); + BlockRecord block = new("DOOR"); + block.Entities.Add(new Line(new XYZ(0, 0, 0), new XYZ(1, 0, 0)) { Layer = new Layer("Hardware") }); + Insert insert = new(block) { Layer = new Layer("Doors") }; + + dispatcher.Draw(context, insert); + + Assert.Equal(0, Drawn(surface)); + Assert.Empty(surface.Entities); + } + + [Fact] + public void LayerZeroSubEntitiesInheritVisibleInsertLayer() + { + (RecordingDrawingSurface surface, EntityRenderDispatcher dispatcher, ImageRenderContext context) = Setup(c => c.LayerVisibility = LayerVisibilityMode.Screen); + BlockRecord block = new("SYM"); + block.Entities.Add(new Line(new XYZ(0, 0, 0), new XYZ(1, 0, 0)) { Layer = new Layer(Layer.DefaultName) }); + + // Visible insert layer: the layer-0 sub-entity inherits it and is drawn. + dispatcher.Draw(context, new Insert(block) { Layer = new Layer("Symbols") }); + + Assert.Equal(1, Drawn(surface)); + } + + [Fact] + public void FrozenInsertLayerHidesItsContentsAndVisibleInsertShowsLayerZeroContents() + { + Layer frozen = new("Doors") { Flags = LayerFlags.Frozen }; + Layer visible = new("Windows"); + Layer frozenOwn = new("Hardware") { Flags = LayerFlags.Frozen }; + + static BlockRecord Symbol(Layer own) + { + BlockRecord block = new(Guid.NewGuid().ToString("N")); + block.Entities.Add(new Line(new XYZ(0, 0, 0), new XYZ(1, 0, 0)) { Layer = new Layer(Layer.DefaultName) }); + block.Entities.Add(new Line(new XYZ(0, 0, 0), new XYZ(0, 1, 0)) { Layer = own }); + return block; + } + + (RecordingDrawingSurface surface, EntityRenderDispatcher dispatcher, ImageRenderContext context) = Setup(c => c.LayerVisibility = LayerVisibilityMode.Screen); + + // Insert on a frozen layer: nothing inside is drawn, not even the entity on its own visible layer. + dispatcher.Draw(context, new Insert(Symbol(visible)) { Layer = frozen }); + Assert.Equal(0, Drawn(surface)); + + // Insert on a visible layer: the layer-0 line inherits that layer and is drawn; the line on its own frozen layer is not. + dispatcher.Draw(context, new Insert(Symbol(frozenOwn)) { Layer = visible }); + Assert.Equal(1, Drawn(surface)); + Assert.Equal("Windows", surface.Entities.Last(e => e.EntityType == "LINE").LayerName); + } +} diff --git a/ACadSharp.Image.Tests/LineTypeDashResolverTests.cs b/ACadSharp.Image.Tests/LineTypeDashResolverTests.cs new file mode 100644 index 0000000..b7512e6 --- /dev/null +++ b/ACadSharp.Image.Tests/LineTypeDashResolverTests.cs @@ -0,0 +1,130 @@ +using ACadSharp.Entities; +using ACadSharp.Header; +using ACadSharp.Image.Rendering; +using ACadSharp.Objects; +using ACadSharp.Tables; + +namespace ACadSharp.Image.Tests; + +public sealed class LineTypeDashResolverTests +{ + private static LineType Dashed(params double[] lengths) + { + LineType lineType = new("DASHED"); + foreach (double length in lengths) + { + lineType.AddSegment(new LineType.Segment { Length = length }); + } + + return lineType; + } + + private static ImageRenderContext Context(double scale, double? unitsPerMillimeter = null, float minimumDash = 2f) + { + ImageConfiguration configuration = new() { MinimumDashPixels = minimumDash }; + Layout layout = new("t") { PaperWidth = 10, PaperHeight = 10 }; + return new ImageRenderContext(new RecordingDrawingSurface(), configuration, layout, 10, 10, 0, 0, scale, 0, 0, singlePrecision: false, lineTypeScale: scale, strokeUnitsPerMillimeter: unitsPerMillimeter); + } + + [Fact] + public void ContinuousIsSolid() + { + Assert.Null(LineTypeDashResolver.BuildPattern(LineType.Continuous, 1d, 1f)); + } + + [Fact] + public void AllDashPatternIsSolid() + { + Assert.Null(LineTypeDashResolver.BuildPattern(Dashed(1, 2), 1d, 1f)); + } + + [Fact] + public void DashGapPatternScales() + { + float[]? pattern = LineTypeDashResolver.BuildPattern(Dashed(0.5, -0.25), 4d, 1f); + + Assert.NotNull(pattern); + Assert.Equal([2f, 1f], pattern); + } + + [Fact] + public void DotsBecomeStrokeWidthDashesAndGapsMerge() + { + LineType lineType = Dashed(0.5, -0.25, 0, -0.25); + + float[]? pattern = LineTypeDashResolver.BuildPattern(lineType, 2d, 1.5f); + + // dash 1, gap 0.5, dot -> 1.5, gap 0.5 + Assert.NotNull(pattern); + Assert.Equal([1f, 0.5f, 1.5f, 0.5f], pattern); + } + + [Fact] + public void ShapeSegmentsAreGaps() + { + LineType lineType = new("GAS"); + lineType.AddSegment(new LineType.Segment { Length = 0.5 }); + lineType.AddSegment(new LineType.Segment { Length = -0.2 }); + lineType.AddSegment(new LineType.Segment { Length = 0.3, IsText = true, Text = "GAS" }); + lineType.AddSegment(new LineType.Segment { Length = -0.2 }); + + float[]? pattern = LineTypeDashResolver.BuildPattern(lineType, 10d, 1f); + + Assert.NotNull(pattern); + Assert.Equal([5f, 7f], pattern); // gaps 2 + 3 + 2 merged + } + + [Fact] + public void PatternStartingWithGapGetsZeroDash() + { + float[]? pattern = LineTypeDashResolver.BuildPattern(Dashed(-0.5, 0.5), 1d, 1f); + + Assert.NotNull(pattern); + Assert.Equal([0f, 0.5f, 0.5f, 0f], pattern); + } + + [Fact] + public void TinyPatternsAreSolidInPixelMode() + { + Line line = new() { LineType = Dashed(0.1, -0.1) }; + + Assert.Null(Resolve(line, Context(1d))); + Assert.NotNull(Resolve(line, Context(20d))); + Assert.NotNull(Resolve(line, Context(1d, unitsPerMillimeter: 1d))); + } + + [Fact] + public void NonFinitePatternsAreSolid() + { + // A huge CELTSCALE overflows the pattern lengths to infinity, which no surface can dash with. + Line line = new() { LineType = Dashed(1, -1), LineTypeScale = double.MaxValue }; + + Assert.Null(Resolve(line, Context(1d))); + } + + [Fact] + public void EntityLineTypeScaleMultiplies() + { + Line line = new() { LineType = Dashed(1, -1), LineTypeScale = 3 }; + + float[]? pattern = Resolve(line, Context(1d)); + + Assert.NotNull(pattern); + Assert.Equal([3f, 3f], pattern); + } + + [Fact] + public void HeaderLineTypeScaleMultiplies() + { + CadHeader header = new() { LineTypeScale = 2d }; + Line line = new() { LineType = Dashed(1, -1), LineTypeScale = 3 }; + + float[]? pattern = LineTypeDashResolver.Resolve(line.LineType, header, line.LineTypeScale, Context(1d), 1f); + + Assert.NotNull(pattern); + Assert.Equal([6f, 6f], pattern); + } + + private static float[]? Resolve(Line line, ImageRenderContext context) => + LineTypeDashResolver.Resolve(line.LineType, line.Document?.Header, line.LineTypeScale, context, 1f); +} diff --git a/ACadSharp.Image.Tests/RasterDrawingSurfaceTests.cs b/ACadSharp.Image.Tests/RasterDrawingSurfaceTests.cs new file mode 100644 index 0000000..c2946c6 --- /dev/null +++ b/ACadSharp.Image.Tests/RasterDrawingSurfaceTests.cs @@ -0,0 +1,455 @@ +using ACadSharp.Image.Rendering; +using ACadSharp.IO; +using SixLabors.ImageSharp; +using SixLabors.ImageSharp.PixelFormats; +using ImageColor = SixLabors.ImageSharp.Color; + +namespace ACadSharp.Image.Tests; + +public sealed class RasterDrawingSurfaceTests +{ + private static readonly Rgba32 White = ImageColor.White.ToPixel(); + + private static readonly Rgba32 Black = ImageColor.Black.ToPixel(); + + [Fact] + public void DrawLinePaintsPixelsAlongTheLine() + { + using Image canvas = new(20, 20, ImageColor.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)); + + Assert.Equal(Black, canvas[10, 10]); + Assert.Equal(White, canvas[10, 2]); + } + + [Fact] + public void DrawPolylineClosedConnectsLastPointToFirst() + { + using Image canvas = new(20, 20, ImageColor.White); + using RasterDrawingSurface surface = new(canvas, new ImageConfiguration(), ownsCanvas: false); + SurfacePoint[] points = [new(2, 2), new(18, 2), new(18, 18)]; + + surface.DrawPolyline(new ImageStyle(ImageColor.Black, 2f), points, closed: true); + + // Closing edge runs from (18,18) back to (2,2): the midpoint (10,10) must be painted. + Assert.Equal(Black, canvas[10, 10]); + } + + [Fact] + public void FillPathUsesEvenOddRuleForHoles() + { + using Image canvas = new(40, 40, ImageColor.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)]; + + surface.FillPath(new ImageStyle(ImageColor.Black, 1f), [outer, hole]); + + Assert.Equal(Black, canvas[5, 5]); + Assert.Equal(White, canvas[20, 20]); + } + + [Fact] + public void OpacityBlendsWithBackground() + { + using Image canvas = new(10, 10, ImageColor.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)]); + + Rgba32 pixel = canvas[5, 5]; + Assert.InRange(pixel.R, 120, 135); + Assert.Equal(pixel.R, pixel.G); + Assert.Equal(255, pixel.A); + } + + [Fact] + public void DashPatternLeavesGaps() + { + using Image canvas = new(60, 10, ImageColor.White); + using RasterDrawingSurface surface = new(canvas, new ImageConfiguration(), ownsCanvas: false); + + // 10 px dash, 10 px gap. + surface.DrawLine(new ImageStyle(ImageColor.Black, 2f, [10f, 10f], 1f), new SurfacePoint(0, 5), new SurfacePoint(60, 5)); + + Assert.Equal(Black, canvas[5, 5]); + Assert.Equal(White, canvas[15, 5]); + Assert.Equal(Black, canvas[25, 5]); + } + + [Fact] + public void ViewportDrawsIntoChildAndCompositesAtBounds() + { + using Image canvas = new(40, 40, ImageColor.White); + using RasterDrawingSurface surface = new(canvas, new ImageConfiguration(), ownsCanvas: false); + + ViewportSurface viewport = surface.BeginViewport(new SurfaceRect(20, 20, 10, 10)); + Assert.Equal(0d, viewport.OffsetX); + Assert.Equal(10d, viewport.BottomY); + + // Fill the whole child; only the 10x10 region at (20,20) may change on the page. + viewport.Surface.FillPolygon(new ImageStyle(ImageColor.Black, 1f), [new(0, 0), new(10, 0), new(10, 10), new(0, 10)]); + surface.EndViewport(viewport); + + Assert.Equal(Black, canvas[25, 25]); + Assert.Equal(White, canvas[15, 15]); + Assert.Equal(White, canvas[35, 35]); + } + + [Fact] + public void ArcPointsStartAndEndOnTheArc() + { + IReadOnlyList points = CurveTessellation.ArcPoints(new SurfacePoint(0, 0), 10, 10, 0, 0, Math.PI / 2, 8); + + Assert.Equal(9, points.Count); + Assert.Equal(10, points[0].X, 6); + Assert.Equal(0, points[0].Y, 6); + Assert.Equal(0, points[^1].X, 6); + Assert.Equal(10, points[^1].Y, 6); + } + + [Fact] + public void BulgeArcOfOneIsASemicircle() + { + CurveTessellation.BulgeArc(new SurfacePoint(0, 0), new SurfacePoint(10, 0), 1d, out SurfacePoint center, out double radius, out double startAngle, out double sweep); + + Assert.Equal(5, center.X, 6); + Assert.Equal(0, center.Y, 6); + Assert.Equal(5, radius, 6); + Assert.Equal(-Math.PI, sweep, 6); + Assert.Equal(Math.PI, Math.Abs(startAngle), 6); + } + + [Fact] + public void PositiveBulgeBendsTowardPositiveYInSurfaceSpace() + { + // Drawing-space CCW arc from (0,0) to (10,0) passes below the chord; below is +Y on a Y-down surface. + CurveTessellation.BulgeArc(new SurfacePoint(0, 0), new SurfacePoint(10, 0), 0.5d, out SurfacePoint center, out double radius, out double startAngle, out double sweep); + + Assert.Equal(5, center.X, 6); + Assert.Equal(-3.75, center.Y, 6); + Assert.Equal(6.25, radius, 6); + Assert.True(sweep < 0); + + IReadOnlyList points = CurveTessellation.ArcPoints(center, radius, radius, 0, startAngle, sweep, 2); + Assert.Equal(5, points[1].X, 6); + Assert.Equal(2.5, points[1].Y, 6); + } + + [Fact] + public void ViewportFlipOriginIsTheExactHeightNotTheRoundedImageHeight() + { + using Image canvas = new(20, 20, ImageColor.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. + ViewportSurface viewport = surface.BeginViewport(new SurfaceRect(0, 0, 10, 9.3)); + + Assert.Equal(9.3, viewport.BottomY, 9); + Assert.Equal(0d, viewport.OffsetX); + + // A one-pixel line drawn on the viewport's bottom edge (surface y = BottomY) must reach the page. + viewport.Surface.DrawLine(new ImageStyle(ImageColor.Red, 1f), new SurfacePoint(0, viewport.BottomY), new SurfacePoint(10, viewport.BottomY)); + surface.EndViewport(viewport); + + Assert.Contains(Enumerable.Range(0, 10).Select(x => canvas[x, 9]), p => p.R > p.G); + } + + [Fact] + public void ViewportFractionalPositionIsCarriedIntoTheChildOffsets() + { + using Image canvas = new(20, 20, ImageColor.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. + ViewportSurface viewport = surface.BeginViewport(new SurfaceRect(3.6, 2.4, 10, 9.3)); + + Assert.Equal(0.6, viewport.OffsetX, 9); + Assert.Equal(2.4 - 2 + 9.3, viewport.BottomY, 9); + + // A vertical line on the child's own X offset must land in page column 3 (covering x 3.1..4.1), not column 4 alone. + viewport.Surface.DrawLine(new ImageStyle(ImageColor.Red, 1f), new SurfacePoint(viewport.OffsetX, 0), new SurfacePoint(viewport.OffsetX, viewport.BottomY)); + surface.EndViewport(viewport); + + Assert.True(canvas[3, 6].R > canvas[3, 6].G, $"column 3 should carry most of the line, got {canvas[3, 6]}"); + Assert.Equal(new Rgba32(255, 255, 255, 255), canvas[2, 6]); + Assert.Equal(new Rgba32(255, 255, 255, 255), canvas[6, 6]); + } + + [Fact] + public void TextLinesAreSpacedAtFiveThirdsOfTheTextHeight() + { + // Capitals only, so each line inks exactly one band and the band starts track the baselines. + using Image singleLine = DrawnText("H", SurfaceTextBaseline.Hanging, 1d, 0d); + using Image twoLines = DrawnText("H\nH", SurfaceTextBaseline.Hanging, 1d, 0d); + int[] one = InkBandStarts(singleLine); + int[] two = InkBandStarts(twoLines); + + int first = Assert.Single(one); + Assert.Equal(2, two.Length); + + // The added leading must go between the lines, not above the first one: line 1 stays exactly where a + // single-line run puts it. + Assert.Equal(first, two[0]); + + double distance = two[1] - two[0]; + Assert.True(Math.Abs(distance - 50d / 3d) <= 1d, $"expected the lines about {50d / 3d:F1} px apart (5/3 of the text height), got {distance}."); + } + + [Fact] + public void AlphabeticTextAnchorsItsLastLine() + { + // Alphabetic stands the block on the anchor, so the closing line must not move when a line is added above it. + using Image singleLine = DrawnText("H", SurfaceTextBaseline.Alphabetic, 1d, 0d); + using Image twoLines = DrawnText("H\nH", SurfaceTextBaseline.Alphabetic, 1d, 0d); + + int[] one = InkBandStarts(singleLine); + int[] two = InkBandStarts(twoLines); + + Assert.Single(one); + Assert.Equal(2, two.Length); + Assert.Equal(one[0], two[^1]); + + double distance = two[1] - two[0]; + Assert.True(Math.Abs(distance - 50d / 3d) <= 1d, $"expected the lines about {50d / 3d:F1} px apart, got {distance}."); + } + + [Fact] + public void RotatedTextAnchorsItsFirstLineAlongItsOwnUpAxis() + { + // A quarter turn puts the text's up axis along the page's x axis: the spacing correction must travel with it, + // so the first line of a rotated block still starts where a rotated single line does. + using Image singleLine = DrawnText("H", SurfaceTextBaseline.Hanging, 1d, Math.PI / 2d); + using Image twoLines = DrawnText("H\nH", SurfaceTextBaseline.Hanging, 1d, Math.PI / 2d); + + int[] one = InkColumnStarts(singleLine); + int[] two = InkColumnStarts(twoLines); + + Assert.Single(one); + Assert.Equal(2, two.Length); + + // The transform rotates by -90 degrees, which sends the text's downward line advance towards +x, so the block + // grows rightwards and its first line is the leftmost band. + Assert.Equal(one[0], two[0]); + double distance = two[1] - two[0]; + Assert.True(Math.Abs(distance - 50d / 3d) <= 1d, $"expected the rotated lines about {50d / 3d:F1} px apart, got {distance}."); + } + + /// 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); + using RasterDrawingSurface surface = new(canvas, new ImageConfiguration { Dpi = dpi }, ownsCanvas: false); + surface.DrawText( + new ImageStyle(ImageColor.Black, 1f), + new SurfaceText(value, new SurfacePoint(100, 100), 10, rotation, SurfaceTextAnchor.Start, baseline, 0, lineSpacingFactor, 0, widthScale)); + return canvas; + } + + /// The first row of every run of inked rows, a row counting as inked when it holds a pixel darker than mid grey. + private static int[] InkBandStarts(Image canvas) + { + List starts = new(); + bool previousInked = false; + for (int y = 0; y < canvas.Height; y++) + { + bool inked = false; + for (int x = 0; x < canvas.Width && !inked; x++) + { + inked = canvas[x, y].R < 128; + } + + if (inked && !previousInked) + { + starts.Add(y); + } + + previousInked = inked; + } + + return starts.ToArray(); + } + + /// The first column of every run of inked columns, the transpose of . + private static int[] InkColumnStarts(Image canvas) + { + List starts = new(); + bool previousInked = false; + for (int x = 0; x < canvas.Width; x++) + { + bool inked = false; + for (int y = 0; y < canvas.Height && !inked; y++) + { + inked = canvas[x, y].R < 128; + } + + if (inked && !previousInked) + { + starts.Add(x); + } + + previousInked = inked; + } + + return starts.ToArray(); + } + + [Fact] + public void FillsDropNonFiniteGeometryInsteadOfThrowing() + { + using Image canvas = new(20, 20, ImageColor.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)]; + + // ImageSharp's scan-line fill throws ArithmeticException on a non-finite vertex, which the dispatcher's + // catch filter would not have caught: the surface drops those points the way the SVG backend does. + surface.FillPolygon(style, [new(2, 2), new(double.NaN, 2), new(18, 18), new(2, 18)]); + surface.FillPath(style, [ring]); + surface.FillCircle(style, new SurfacePoint(10, double.NaN), 5); + + // The polygon keeps its three finite corners and is still filled; the two-point ring and the circle vanish. + Assert.Equal(Black, canvas[5, 15]); + Assert.Equal(White, canvas[10, 3]); + } + + [Fact] + public void TextSizeDoesNotDependOnTheConfiguredDpi() + { + using Image rendered96 = DrawnText("Hg", SurfaceTextBaseline.Alphabetic, 1d, 0d, dpi: 96f); + using Image rendered300 = DrawnText("Hg", SurfaceTextBaseline.Alphabetic, 1d, 0d, dpi: 300f); + int[] at96 = InkColumnBounds(rendered96); + int[] at300 = InkColumnBounds(rendered300); + + Assert.True(Math.Abs(at96[0] - at300[0]) <= 1 && Math.Abs(at96[1] - at300[1]) <= 1, $"ink columns {at96[0]}..{at96[1]} at 96 dpi but {at300[0]}..{at300[1]} at 300 dpi."); + } + + [Fact] + public void HangingTextStaysOnItsAnchorForAnyLineSpacingFactor() + { + using Image single = DrawnText("H", SurfaceTextBaseline.Hanging, 1d, 0d); + using Image spaced = DrawnText("H\nH", SurfaceTextBaseline.Hanging, 2d, 0d); + + int[] one = InkBandStarts(single); + int[] two = InkBandStarts(spaced); + + Assert.Equal(Assert.Single(one), two[0]); + double distance = two[1] - two[0]; + Assert.True(Math.Abs(distance - 100d / 3d) <= 1d, $"expected the lines about {100d / 3d:F1} px apart (2 x 5/3 of the text height), got {distance}."); + } + + /// First and last canvas column holding a pixel darker than mid grey. + private static int[] InkColumnBounds(Image canvas) + { + int first = -1; + int last = -1; + for (int x = 0; x < canvas.Width; x++) + { + bool inked = false; + for (int y = 0; y < canvas.Height && !inked; y++) + { + inked = canvas[x, y].R < 128; + } + + if (inked) + { + if (first < 0) + { + first = x; + } + + last = x; + } + } + + return [first, last]; + } + + /// First and last canvas row holding a pixel darker than mid grey. + private static int[] InkRowBounds(Image canvas) + { + int first = -1; + int last = -1; + for (int y = 0; y < canvas.Height; y++) + { + bool inked = false; + for (int x = 0; x < canvas.Width && !inked; x++) + { + inked = canvas[x, y].R < 128; + } + + if (inked) + { + if (first < 0) + { + first = y; + } + + last = y; + } + } + + return [first, last]; + } + + [Fact] + public void NonUniformWidthScaleWidensGlyphsAlongTheBaselineOnly() + { + using Image natural = DrawnText("H", SurfaceTextBaseline.Alphabetic, 1d, 0d); + using Image stretched = DrawnText("H", SurfaceTextBaseline.Alphabetic, 1d, 0d, widthScale: 2d); + + int[] naturalColumns = InkColumnBounds(natural); + int[] stretchedColumns = InkColumnBounds(stretched); + int[] naturalRows = InkRowBounds(natural); + int[] stretchedRows = InkRowBounds(stretched); + + int naturalColumnSpan = naturalColumns[1] - naturalColumns[0]; + int stretchedColumnSpan = stretchedColumns[1] - stretchedColumns[0]; + Assert.True(Math.Abs(stretchedColumnSpan - (2 * naturalColumnSpan)) <= 2, $"expected the stretched column span (~{2 * naturalColumnSpan}) to be about double the natural span ({naturalColumnSpan}), got {stretchedColumnSpan}."); + + int naturalRowSpan = naturalRows[1] - naturalRows[0]; + int stretchedRowSpan = stretchedRows[1] - stretchedRows[0]; + Assert.True(Math.Abs(stretchedRowSpan - naturalRowSpan) <= 1, $"expected the row span to stay about {naturalRowSpan}, got {stretchedRowSpan}."); + } + + [Fact] + public void NonUniformWidthScaleWidensARotatedGlyphAlongItsOwnBaseline() + { + // A quarter turn puts the reading axis along the page's y axis, so a stretch along the reading axis must + // widen the ROW span, not the column span, once the glyph is rotated into place. + using Image natural = DrawnText("H", SurfaceTextBaseline.Alphabetic, 1d, Math.PI / 2d); + using Image stretched = DrawnText("H", SurfaceTextBaseline.Alphabetic, 1d, Math.PI / 2d, widthScale: 2d); + + int[] naturalRows = InkRowBounds(natural); + int[] stretchedRows = InkRowBounds(stretched); + + int naturalRowSpan = naturalRows[1] - naturalRows[0]; + int stretchedRowSpan = stretchedRows[1] - stretchedRows[0]; + Assert.True(Math.Abs(stretchedRowSpan - (2 * naturalRowSpan)) <= 2, $"expected the stretched row span (~{2 * naturalRowSpan}) to be about double the natural span ({naturalRowSpan}), got {stretchedRowSpan}."); + } + + [Theory] + [InlineData(0d)] + [InlineData(-1d)] + [InlineData(double.NaN)] + [InlineData(double.PositiveInfinity)] + 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); + ImageConfiguration configuration = new(); + List notifications = new(); + configuration.OnNotification += (_, e) => notifications.Add(e); + using RasterDrawingSurface surface = new(canvas, configuration, ownsCanvas: false); + SurfaceText run = new("H", new SurfacePoint(10, 10), 4, 0, SurfaceTextAnchor.Start, SurfaceTextBaseline.Alphabetic, -1, 1, -1, WidthScale: widthScale); + + surface.DrawText(new ImageStyle(ImageColor.Black, 1f), run); + + Assert.Equal(White, canvas[10, 10]); + Assert.Contains(notifications, n => n.NotificationType == NotificationType.Warning && n.Message.Contains("non-finite", StringComparison.OrdinalIgnoreCase)); + } +} diff --git a/ACadSharp.Image.Tests/RecordingDrawingSurface.cs b/ACadSharp.Image.Tests/RecordingDrawingSurface.cs new file mode 100644 index 0000000..ac53b17 --- /dev/null +++ b/ACadSharp.Image.Tests/RecordingDrawingSurface.cs @@ -0,0 +1,132 @@ +using ACadSharp.Image.Rendering; + +namespace ACadSharp.Image.Tests; + +/// +/// Test double that records surface calls as strings and entity boundaries as infos. +/// +internal sealed class RecordingDrawingSurface : IDrawingSurface +{ + public List Calls { get; } = new(); + + public List Entities { get; } = new(); + + public List Layers { get; } = new(); + + public List Styles { get; } = new(); + + public int Depth { get; private set; } + + public bool SupportsCurves { get; init; } + + /// Start and end of every DrawLine call, in order. + public List<(SurfacePoint Start, SurfacePoint End)> Lines { get; } = new(); + + /// Points of every DrawPolyline and DrawBulgePolyline call, in order. + public List> Polylines { get; } = new(); + + /// Rings of every FillPath call, in order. + public List>> FillPaths { get; } = new(); + + /// Points of every FillPolygon call, in order. + public List> Polygons { get; } = new(); + + /// Control points of every DrawCubicBezier call, in order. + public List> Beziers { get; } = new(); + + /// Every text run handed to DrawText, in order. + public List Texts { get; } = new(); + + public void BeginEntity(EntityRenderInfo info, LayerRenderInfo layer) + { + this.Depth++; + this.Entities.Add(info); + this.Layers.Add(layer); + this.Calls.Add($"BeginEntity {info.EntityType} layer={info.LayerName} parent={info.ParentHandle?.ToString("X") ?? "-"} block={info.BlockName ?? "-"}"); + } + + public void EndEntity() + { + this.Depth--; + this.Calls.Add("EndEntity"); + } + + public void DrawLine(ImageStyle style, SurfacePoint start, SurfacePoint end) + { + this.Styles.Add(style); + this.Calls.Add($"DrawLine {start} {end} w={style.StrokeWidth}"); + this.Lines.Add((start, end)); + } + + public void DrawPolyline(ImageStyle style, IReadOnlyList points, bool closed) + { + this.Styles.Add(style); + this.Calls.Add($"DrawPolyline n={points.Count} closed={closed}"); + this.Polylines.Add(points.ToArray()); + } + + public void DrawArc(ImageStyle style, SurfacePoint center, double radiusX, double radiusY, double rotation, double startAngle, double sweepAngle) + { + this.Styles.Add(style); + this.Calls.Add($"DrawArc {center} rx={radiusX} ry={radiusY} rot={rotation} start={startAngle} sweep={sweepAngle}"); + } + + public void DrawEllipse(ImageStyle style, SurfacePoint center, double radiusX, double radiusY, double rotation) + { + this.Styles.Add(style); + this.Calls.Add($"DrawEllipse {center} rx={radiusX} ry={radiusY} rot={rotation}"); + } + + public void DrawCubicBezier(ImageStyle style, IReadOnlyList controlPoints, bool closed) + { + this.Styles.Add(style); + this.Calls.Add($"DrawCubicBezier n={controlPoints.Count} closed={closed}"); + this.Beziers.Add(controlPoints.ToArray()); + } + + public void DrawBulgePolyline(ImageStyle style, IReadOnlyList points, IReadOnlyList bulges, bool closed) + { + this.Styles.Add(style); + this.Calls.Add($"DrawBulgePolyline n={points.Count} closed={closed} bulges={string.Join(",", bulges)}"); + this.Polylines.Add(points.ToArray()); + } + + public void FillPolygon(ImageStyle style, IReadOnlyList points) + { + this.Styles.Add(style); + this.Calls.Add($"FillPolygon n={points.Count}"); + this.Polygons.Add(points.ToArray()); + } + + public void FillPath(ImageStyle style, IReadOnlyList> rings) + { + this.Styles.Add(style); + this.Calls.Add($"FillPath rings={rings.Count}"); + this.FillPaths.Add(rings.Select(r => (IReadOnlyList)r.ToArray()).ToArray()); + } + + public void FillCircle(ImageStyle style, SurfacePoint center, double radius) + { + this.Styles.Add(style); + this.Calls.Add($"FillCircle {center} r={radius}"); + } + + public void DrawText(ImageStyle style, SurfaceText text) + { + this.Styles.Add(style); + this.Calls.Add($"DrawText '{text.Text}' anchor={text.Anchor} baseline={text.Baseline}"); + this.Texts.Add(text); + } + + public ViewportSurface BeginViewport(SurfaceRect bounds) + { + this.Calls.Add($"BeginViewport {bounds}"); + return new ViewportSurface(this, bounds.X, bounds.Y + bounds.Height); + } + + public void EndViewport(ViewportSurface viewport) => this.Calls.Add("EndViewport"); + + public void Dispose() + { + } +} diff --git a/ACadSharp.Image.Tests/RenderedPageTests.cs b/ACadSharp.Image.Tests/RenderedPageTests.cs new file mode 100644 index 0000000..0cb85d8 --- /dev/null +++ b/ACadSharp.Image.Tests/RenderedPageTests.cs @@ -0,0 +1,123 @@ +using ACadSharp.Entities; +using ACadSharp.Image.Rendering; +using ACadSharp.Tables; +using ACadSharp.Types.Units; +using CSMath; +using SixLabors.ImageSharp; +using SixLabors.ImageSharp.PixelFormats; + +namespace ACadSharp.Image.Tests; + +/// Covers the rendered page encoders, format helpers, unit conversion and page entity filters. +public sealed class RenderedPageTests +{ + [Theory] + [InlineData(ImageExportFormat.Png, new byte[] { 0x89, 0x50, 0x4E, 0x47 }, null)] + [InlineData(ImageExportFormat.Bmp, new byte[] { 0x42, 0x4D }, null)] + [InlineData(ImageExportFormat.Jpeg, new byte[] { 0xFF, 0xD8, 0xFF }, null)] + [InlineData(ImageExportFormat.Gif, new byte[] { 0x47, 0x49, 0x46, 0x38 }, null)] + [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 RenderedImagePage page = new("p", canvas, format, 80); + using MemoryStream stream = new(); + + page.Save(stream); + + byte[] bytes = stream.ToArray(); + Assert.True(bytes.Length > signature.Length); + Assert.Equal(signature, bytes.Take(signature.Length).ToArray()); + if (fourcc is not null) + { + Assert.Equal(fourcc, System.Text.Encoding.ASCII.GetString(bytes, 8, 4)); + } + } + + [Fact] + public void SaveToPathCreatesTheDirectory() + { + string directory = Path.Combine(Path.GetTempPath(), $"acad-image-{Guid.NewGuid():N}", "nested"); + string path = Path.Combine(directory, "page.svg"); + try + { + using RenderedSvgPage page = new("p", ""); + + page.Save(path); + + Assert.True(File.Exists(path)); + byte[] bytes = File.ReadAllBytes(path); + Assert.False(bytes.Length >= 3 && bytes[0] == 0xEF && bytes[1] == 0xBB && bytes[2] == 0xBF, "SVG files must be written without a BOM."); + Assert.StartsWith(" e.Layer.Name == "Keep"); + + Assert.Single(page.Entities); + Assert.Equal("Keep", page.Entities[0].Layer.Name); + Assert.True(page.AutoSized); + Assert.Equal(1d, page.Layout!.PaperWidth); // extents 1 x 0 are clamped to at least 1 unit + Assert.Equal(1d, page.Layout.PaperHeight); + } +} diff --git a/ACadSharp.Image.Tests/SampleParityTests.cs b/ACadSharp.Image.Tests/SampleParityTests.cs new file mode 100644 index 0000000..8ca3da5 --- /dev/null +++ b/ACadSharp.Image.Tests/SampleParityTests.cs @@ -0,0 +1,193 @@ +using ACadSharp.IO; +using SixLabors.Fonts; +using SixLabors.ImageSharp; +using SixLabors.ImageSharp.Formats.Png; +using SixLabors.ImageSharp.PixelFormats; + +namespace ACadSharp.Image.Tests; + +/// +/// Renders the files in Samples/ with a fixed configuration and compares the result with the baselines in +/// Baselines/: the PNGs pixel-for-pixel and the SVG goldens as text. Set the environment variable +/// ACADSHARP_IMAGE_UPDATE_BASELINES=1 to rewrite both instead of comparing. +/// +public sealed class SampleParityTests +{ + private const string FontFamily = "DejaVu Sans"; + + public static TheoryData Samples => new() + { + { "6-57-1119.dxf", false }, + { "HSK80AHCP16190M_BMG.dwg", false }, + { "HSK80AHCP16190M_BMG.dwg", true }, + { "Subaru Logo Vector Free Wrap.dxf", false }, + }; + + [Theory] + [MemberData(nameof(Samples))] + public void SampleRendersMatchBaselines(string fileName, bool paperLayouts) + { + Assert.True(SystemFonts.TryGet(FontFamily, out _), $"Font '{FontFamily}' must be installed for parity tests."); + + string repoRoot = FindRepoRoot(); + string samplePath = Path.Combine(repoRoot, "Samples", fileName); + string baselineDirectory = Path.Combine(repoRoot, "ACadSharp.Image.Tests", "Baselines"); + Directory.CreateDirectory(baselineDirectory); + + bool update = Environment.GetEnvironmentVariable("ACADSHARP_IMAGE_UPDATE_BASELINES") == "1"; + string baseName = Path.GetFileNameWithoutExtension(fileName).Replace(' ', '-') + (paperLayouts ? ".paper" : ".model"); + + IReadOnlyList> rendered = RenderSample(samplePath, paperLayouts); + try + { + if (!update) + { + int baselineCount = Directory.GetFiles(baselineDirectory, $"{baseName}.*.png").Length; + Assert.True(baselineCount == rendered.Count, $"Expected {baselineCount} baseline pages for {baseName}, renderer produced {rendered.Count}."); + } + + for (int i = 0; i < rendered.Count; i++) + { + string baselinePath = Path.Combine(baselineDirectory, $"{baseName}.{i + 1:D2}.png"); + if (update) + { + rendered[i].Save(baselinePath, new PngEncoder()); + continue; + } + + Assert.True(File.Exists(baselinePath), $"Missing baseline {baselinePath}. Run with ACADSHARP_IMAGE_UPDATE_BASELINES=1 to create it."); + using Image baseline = SixLabors.ImageSharp.Image.Load(baselinePath); + AssertPixelsEqual(baseline, rendered[i], baselinePath); + } + } + finally + { + foreach (Image image in rendered) + { + image.Dispose(); + } + } + } + + [Theory] + [MemberData(nameof(Samples))] + public void SampleSvgsMatchGoldens(string fileName, bool paperLayouts) + { + string repoRoot = FindRepoRoot(); + string samplePath = Path.Combine(repoRoot, "Samples", fileName); + string baselineDirectory = Path.Combine(repoRoot, "ACadSharp.Image.Tests", "Baselines"); + bool update = Environment.GetEnvironmentVariable("ACADSHARP_IMAGE_UPDATE_BASELINES") == "1"; + string baseName = Path.GetFileNameWithoutExtension(fileName).Replace(' ', '-') + (paperLayouts ? ".paper" : ".model"); + + CadDocument document = Path.GetExtension(samplePath).ToLowerInvariant() == ".dwg" ? DwgReader.Read(samplePath) : DxfReader.Read(samplePath); + ImageExporter exporter = new(); + exporter.Configuration.Width = 800; + exporter.Configuration.Height = 500; + exporter.Configuration.SetPadding(10); + exporter.Configuration.FontFamilyName = FontFamily; + if (paperLayouts) + { + exporter.AddPaperLayouts(document); + } + else + { + exporter.AddModelSpace(document); + } + + IReadOnlyList pages = exporter.Render(ImageExportFormat.Svg); + if (!update) + { + int baselineCount = Directory.GetFiles(baselineDirectory, $"{baseName}.*.svg").Length; + Assert.True(baselineCount == pages.Count, $"Expected {baselineCount} golden SVG pages for {baseName}, renderer produced {pages.Count}."); + } + + for (int i = 0; i < pages.Count; i++) + { + string goldenPath = Path.Combine(baselineDirectory, $"{baseName}.{i + 1:D2}.svg"); + string actual = Assert.IsType(pages[i]).Content.Replace("\r\n", "\n"); + + // Non-finite numbers make the SVG invalid. The ordinal comparison is deliberate: "dominant-baseline" + // contains "nan" in lower case only. + Assert.DoesNotContain("Infinity", actual, StringComparison.Ordinal); + Assert.DoesNotContain("NaN", actual, StringComparison.Ordinal); + if (update) + { + File.WriteAllText(goldenPath, actual); + continue; + } + + Assert.True(File.Exists(goldenPath), $"Missing golden {goldenPath}. Run with ACADSHARP_IMAGE_UPDATE_BASELINES=1 to create it."); + string expected = File.ReadAllText(goldenPath).Replace("\r\n", "\n"); + Assert.Equal(expected, actual); + } + } + + internal static IReadOnlyList> RenderSample(string samplePath, bool paperLayouts) + { + CadDocument document = Path.GetExtension(samplePath).ToLowerInvariant() == ".dwg" + ? DwgReader.Read(samplePath) + : DxfReader.Read(samplePath); + + ImageExporter exporter = new(); + exporter.Configuration.Width = 800; + exporter.Configuration.Height = 500; + exporter.Configuration.SetPadding(10); + exporter.Configuration.FontFamilyName = FontFamily; + + if (paperLayouts) + { + exporter.AddPaperLayouts(document); + } + else + { + exporter.AddModelSpace(document); + } + + List> images = new(); + foreach (RenderedPage page in exporter.Render()) + { + images.Add(Assert.IsType(page).Canvas); + } + + return images; + } + + internal static void AssertPixelsEqual(Image expected, Image actual, string label) + { + Assert.Equal(expected.Width, actual.Width); + Assert.Equal(expected.Height, actual.Height); + + Rgba32[] expectedPixels = new Rgba32[expected.Width * expected.Height]; + Rgba32[] actualPixels = new Rgba32[actual.Width * actual.Height]; + expected.CopyPixelDataTo(expectedPixels); + actual.CopyPixelDataTo(actualPixels); + + int firstDifference = -1; + for (int i = 0; i < expectedPixels.Length; i++) + { + if (expectedPixels[i] != actualPixels[i]) + { + firstDifference = i; + break; + } + } + + Assert.True(firstDifference < 0, $"{label}: first differing pixel at index {firstDifference} (x={firstDifference % expected.Width}, y={firstDifference / expected.Width}); expected {expectedPixels[Math.Max(0, firstDifference)]} actual {actualPixels[Math.Max(0, firstDifference)]}."); + } + + internal static string FindRepoRoot() + { + string? directory = AppContext.BaseDirectory; + while (directory != null) + { + if (File.Exists(Path.Combine(directory, "ACadSharp.Image.sln"))) + { + return directory; + } + + directory = Path.GetDirectoryName(directory); + } + + throw new InvalidOperationException("Could not locate the repository root (ACadSharp.Image.sln)."); + } +} diff --git a/ACadSharp.Image.Tests/SplineBezierConverterTests.cs b/ACadSharp.Image.Tests/SplineBezierConverterTests.cs new file mode 100644 index 0000000..274f519 --- /dev/null +++ b/ACadSharp.Image.Tests/SplineBezierConverterTests.cs @@ -0,0 +1,89 @@ +using ACadSharp.Entities; +using ACadSharp.Image.Rendering; +using CSMath; + +namespace ACadSharp.Image.Tests; + +public sealed class SplineBezierConverterTests +{ + private static Spline ClampedUniformCubic() + { + Spline spline = new() { Degree = 3 }; + spline.Knots.AddRange([0d, 0d, 0d, 0d, 1d, 2d, 3d, 3d, 3d, 3d]); + spline.ControlPoints.AddRange([ + new XYZ(0, 0, 0), new XYZ(1, 3, 0), new XYZ(3, 4, 0), new XYZ(5, 1, 0), new XYZ(7, 3, 0), new XYZ(9, 0, 0), + ]); + return spline; + } + + [Fact] + public void ConvertsClampedCubicIntoBezierChain() + { + Spline spline = ClampedUniformCubic(); + + Assert.True(SplineBezierConverter.TryConvert(spline, out List bezier)); + + // 3 knot spans -> 3 segments -> 10 control points. + Assert.Equal(10, bezier.Count); + Assert.Equal(spline.ControlPoints[0], bezier[0]); + Assert.Equal(spline.ControlPoints[^1], bezier[^1]); + } + + [Theory] + [InlineData(0.25)] + [InlineData(1.0)] + [InlineData(1.7)] + [InlineData(2.9)] + public void BezierChainMatchesDeBoorEvaluation(double t) + { + Spline spline = ClampedUniformCubic(); + Assert.True(SplineBezierConverter.TryConvert(spline, out List bezier)); + + XY expected = SplineRenderer.EvaluateSplinePoint(3, spline.Knots, spline.ControlPoints, spline.Weights, t); + + // Segment index and local parameter for uniform interior knots 0,1,2,3. + int segment = Math.Min(2, (int)Math.Floor(t)); + double u = t - segment; + XYZ p0 = bezier[segment * 3]; + XYZ p1 = bezier[(segment * 3) + 1]; + XYZ p2 = bezier[(segment * 3) + 2]; + XYZ p3 = bezier[(segment * 3) + 3]; + double v = 1 - u; + double x = (v * v * v * p0.X) + (3 * v * v * u * p1.X) + (3 * v * u * u * p2.X) + (u * u * u * p3.X); + double y = (v * v * v * p0.Y) + (3 * v * v * u * p1.Y) + (3 * v * u * u * p2.Y) + (u * u * u * p3.Y); + + Assert.Equal(expected.X, x, 9); + Assert.Equal(expected.Y, y, 9); + } + + [Fact] + public void RejectsRationalUnclampedOrNonCubic() + { + Spline rational = ClampedUniformCubic(); + rational.Weights.AddRange(Enumerable.Repeat(2d, 6)); + Assert.False(SplineBezierConverter.TryConvert(rational, out _)); + + Spline quadratic = new() { Degree = 2 }; + quadratic.Knots.AddRange([0d, 0d, 0d, 1d, 1d, 1d]); + quadratic.ControlPoints.AddRange([new XYZ(0, 0, 0), new XYZ(1, 1, 0), new XYZ(2, 0, 0)]); + Assert.False(SplineBezierConverter.TryConvert(quadratic, out _)); + + Spline unclamped = ClampedUniformCubic(); + unclamped.Knots[0] = -1d; + Assert.False(SplineBezierConverter.TryConvert(unclamped, out _)); + } + + [Fact] + public void RejectsInteriorKnotMultiplicityAboveDegree() + { + // A multiplicity-4 interior knot breaks the curve into two independent splines; it is not a Bezier chain. + Spline spline = new() { Degree = 3 }; + spline.Knots.AddRange([0d, 0d, 0d, 0d, 1d, 1d, 1d, 1d, 2d, 2d, 2d, 2d]); + spline.ControlPoints.AddRange([ + new XYZ(0, 0, 0), new XYZ(1, 1, 0), new XYZ(2, 1, 0), new XYZ(3, 0, 0), + new XYZ(4, 0, 0), new XYZ(5, -1, 0), new XYZ(6, -1, 0), new XYZ(7, 0, 0), + ]); + + Assert.False(SplineBezierConverter.TryConvert(spline, out _)); + } +} diff --git a/ACadSharp.Image.Tests/SplineRendererTests.cs b/ACadSharp.Image.Tests/SplineRendererTests.cs new file mode 100644 index 0000000..43251cf --- /dev/null +++ b/ACadSharp.Image.Tests/SplineRendererTests.cs @@ -0,0 +1,137 @@ +using ACadSharp.Entities; +using ACadSharp.Image.Rendering; +using ACadSharp.IO; +using ACadSharp.Objects; +using CSMath; + +namespace ACadSharp.Image.Tests; + +/// Drives splines through the dispatcher and checks which surface primitive they reach. +public sealed class SplineRendererTests +{ + private static ImageRenderContext Context(IDrawingSurface surface, ImageConfiguration configuration) + { + Layout layout = new("t") { PaperWidth = 100, PaperHeight = 100 }; + return new ImageRenderContext(surface, configuration, layout, 100, 100, 0, 0, 1d, 0, 0, singlePrecision: false, lineTypeScale: 1d); + } + + // Degree 3, 6 control points, clamped uniform knots: not Bezier-form (interior knots have multiplicity 1). + private static Spline ClampedUniformCubic() + { + Spline spline = new() { Degree = 3 }; + spline.Knots.AddRange([0d, 0d, 0d, 0d, 1d, 2d, 3d, 3d, 3d, 3d]); + spline.ControlPoints.AddRange([new XYZ(0, 0, 0), new XYZ(1, 3, 0), new XYZ(3, 4, 0), new XYZ(5, 1, 0), new XYZ(7, 3, 0), new XYZ(9, 0, 0)]); + return spline; + } + + [Fact] + public void NonBezierSplineIsSampledOnSurfacesWithoutCurves() + { + RecordingDrawingSurface surface = new(); + ImageConfiguration configuration = new() { ArcPrecision = 16 }; + EntityRenderDispatcher dispatcher = new(configuration); + Spline spline = ClampedUniformCubic(); + + dispatcher.Draw(Context(surface, configuration), spline); + + // 3 knot spans x 16 = 48 steps -> 49 points (ArcPrecision 16 is below that floor). + IReadOnlyList points = Assert.Single(surface.Polylines); + Assert.Equal(49, points.Count); + Assert.DoesNotContain(surface.Calls, c => c.StartsWith("DrawCubicBezier", StringComparison.Ordinal)); + + // Endpoints are the clamped control points; the midpoint is the de Boor evaluation at t = 1.5 (Y flipped by the context). + Assert.Equal(0d, points[0].X, 9); + Assert.Equal(100d, points[0].Y, 9); + Assert.Equal(9d, points[^1].X, 9); + XY mid = SplineRenderer.EvaluateSplinePoint(3, spline.Knots, spline.ControlPoints, spline.Weights, 1.5); + Assert.Equal(mid.X, points[24].X, 9); + Assert.Equal(100d - mid.Y, points[24].Y, 9); + } + + [Fact] + public void RationalSplineIsSampledEvenOnCurveSurfaces() + { + RecordingDrawingSurface surface = new() { SupportsCurves = true }; + ImageConfiguration configuration = new(); + EntityRenderDispatcher dispatcher = new(configuration); + Spline spline = ClampedUniformCubic(); + spline.Weights.AddRange([1d, 2d, 1d, 2d, 1d, 1d]); + + dispatcher.Draw(Context(surface, configuration), spline); + + // Bezier conversion refuses rational splines, so the curve-capable surface still receives a polyline. + Assert.Single(surface.Polylines); + Assert.DoesNotContain(surface.Calls, c => c.StartsWith("DrawCubicBezier", StringComparison.Ordinal)); + // Weighting pulls the curve toward the heavier control points: the midpoint moves compared with the unweighted spline. + XY weighted = SplineRenderer.EvaluateSplinePoint(3, spline.Knots, spline.ControlPoints, spline.Weights, 1.5); + XY unweighted = SplineRenderer.EvaluateSplinePoint(3, spline.Knots, spline.ControlPoints, [], 1.5); + Assert.NotEqual(unweighted.Y, weighted.Y); + } + + [Fact] + public void QuadraticSplineIsSampled() + { + RecordingDrawingSurface surface = new() { SupportsCurves = true }; + ImageConfiguration configuration = new() { ArcPrecision = 8 }; + EntityRenderDispatcher dispatcher = new(configuration); + Spline spline = new() { Degree = 2 }; + spline.Knots.AddRange([0d, 0d, 0d, 1d, 2d, 2d, 2d]); + spline.ControlPoints.AddRange([new XYZ(0, 0, 0), new XYZ(2, 4, 0), new XYZ(4, 0, 0), new XYZ(6, 4, 0)]); + + dispatcher.Draw(Context(surface, configuration), spline); + + IReadOnlyList points = Assert.Single(surface.Polylines); + Assert.Equal(33, points.Count); // 2 spans x 16 = 32 steps + Assert.Equal(6d, points[^1].X, 9); + } + + [Fact] + public void ClampedCubicIsConvertedToBezierOnCurveSurfaces() + { + RecordingDrawingSurface surface = new() { SupportsCurves = true }; + ImageConfiguration configuration = new(); + EntityRenderDispatcher dispatcher = new(configuration); + + dispatcher.Draw(Context(surface, configuration), ClampedUniformCubic()); + + // Knot insertion turns the clamped cubic into 3 Bezier segments: 10 control points. + Assert.Contains(surface.Calls, c => c.StartsWith("DrawCubicBezier n=10", StringComparison.Ordinal)); + Assert.Empty(surface.Polylines); + } + + [Fact] + public void EmptySplineWarnsAndDrawsNothing() + { + RecordingDrawingSurface surface = new(); + ImageConfiguration configuration = new(); + List notifications = new(); + configuration.OnNotification += (_, e) => notifications.Add(e); + EntityRenderDispatcher dispatcher = new(configuration); + Spline spline = new() { Degree = 3 }; // no knots, no control points: every fallback in SplineRenderer.Draw fails. + + dispatcher.Draw(Context(surface, configuration), spline); + + Assert.Empty(surface.Polylines); + Assert.Contains(notifications, n => n.NotificationType == NotificationType.Warning && n.Message.Contains("spline", StringComparison.OrdinalIgnoreCase)); + } + + [Fact] + public void InconsistentKnotSplineFallsBackToAcadSharpTessellation() + { + RecordingDrawingSurface surface = new(); + ImageConfiguration configuration = new(); + List notifications = new(); + configuration.OnNotification += (_, e) => notifications.Add(e); + EntityRenderDispatcher dispatcher = new(configuration); + Spline spline = new() { Degree = 3 }; + spline.Knots.AddRange([0d, 0d, 1d, 1d]); // wrong knot count for 4 control points of degree 3 + spline.ControlPoints.AddRange([new XYZ(0, 0, 0), new XYZ(1, 1, 0), new XYZ(2, 0, 0), new XYZ(3, 1, 0)]); + + dispatcher.Draw(Context(surface, configuration), spline); + + // ACadSharp 3.7.1'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 new file mode 100644 index 0000000..24121da --- /dev/null +++ b/ACadSharp.Image.Tests/StyleResolutionTests.cs @@ -0,0 +1,37 @@ +using ACadSharp; +using ACadSharp.Entities; +using ACadSharp.Image.Rendering; +using ACadSharp.Objects; +using ACadSharp.Tables; +using CSMath; + +namespace ACadSharp.Image.Tests; + +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(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)); + } + + [Fact] + public void NestedEntitiesInheritByBlockOpacity() + { + ImageConfiguration configuration = new(); + RecordingDrawingSurface surface = new(); + Layout layout = new("t") { PaperWidth = 10, PaperHeight = 10 }; + ImageRenderContext context = new(surface, configuration, layout, 10, 10, 0, 0, 1d, 0, 0, singlePrecision: false, lineTypeScale: 1d); + EntityRenderDispatcher dispatcher = new(configuration); + BlockRecord block = new("B"); + block.Entities.Add(new Line(new XYZ(0, 0, 0), new XYZ(1, 0, 0)) { Transparency = Transparency.ByBlock }); + Insert insert = new(block) { Transparency = new Transparency(50) }; + + dispatcher.Draw(context, insert); + + Assert.Equal(0.5f, Assert.Single(surface.Styles).Opacity, 3); + } +} diff --git a/ACadSharp.Image.Tests/SvgDrawingSurfaceTests.cs b/ACadSharp.Image.Tests/SvgDrawingSurfaceTests.cs new file mode 100644 index 0000000..1bf71c6 --- /dev/null +++ b/ACadSharp.Image.Tests/SvgDrawingSurfaceTests.cs @@ -0,0 +1,645 @@ +using System.Xml.Linq; +using ACadSharp.Image.Rendering; +using ACadSharp.Image.Rendering.Svg; + +namespace ACadSharp.Image.Tests; + +using Color = SixLabors.ImageSharp.Color; + +public sealed class SvgDrawingSurfaceTests +{ + private static readonly XNamespace Ns = SvgDrawingSurface.Ns; + + private static SvgDrawingSurface CreateSurface(Action? configure = null) + { + ImageConfiguration configuration = new(); + configure?.Invoke(configuration); + return new SvgDrawingSurface(configuration, new SurfaceRect(0, 0, 100, 50), null, null); + } + + 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); + + [Theory] + [InlineData(null, 3)] + [InlineData(1d, 3)] + [InlineData(0.001d, 6)] + [InlineData(1e-6d, 8)] + [InlineData(1d / 25.4d, 5)] + public void StyleDecimalsFollowTheStrokeUnit(double? strokeUnitsPerMillimeter, int expected) + { + using SvgDrawingSurface surface = new(new ImageConfiguration(), new SurfaceRect(0, 0, 100, 50), null, null, strokeUnitsPerMillimeter); + + Assert.Equal(expected, surface.StyleDecimals); + } + + [Fact] + public void RootHasViewBoxAndNoSizeByDefault() + { + using SvgDrawingSurface surface = CreateSurface(); + + XElement root = surface.ToDocument().Root!; + + Assert.Equal(Ns + "svg", root.Name); + Assert.Equal("0 0 100 50", (string?)root.Attribute("viewBox")); + Assert.Null(root.Attribute("width")); + Assert.Null(root.Attribute("height")); + XElement cadRoot = Assert.Single(root.Elements(Ns + "g")); + Assert.Equal("cad-root", (string?)cadRoot.Attribute("class")); + Assert.DoesNotContain(cadRoot.Attributes(), a => a.Name != "class"); + XElement defaults = Assert.Single(cadRoot.Elements(Ns + "g")); + Assert.Equal("none", (string?)defaults.Attribute("fill")); + Assert.Contains("Arial", (string?)defaults.Attribute("font-family")); + Assert.DoesNotContain(" c.BackgroundColor = Color.Transparent); + + Assert.Single(opaque.ToDocument().Descendants(Ns + "rect"), r => (string?)r.Attribute("class") == "cad-background"); + Assert.Empty(transparent.ToDocument().Descendants(Ns + "rect")); + } + + [Fact] + public void EntitiesAreGroupedByEffectiveLayerInOrderOfFirstAppearance() + { + using SvgDrawingSurface surface = CreateSurface(c => c.Svg.IdPrefix = "p-"); + ImageStyle style = new(Color.FromRgb(255, 0, 0), 1f); + + surface.BeginEntity(Entity("Walls", handle: 0x1F3), Layer("Walls")); + surface.DrawLine(style, new SurfacePoint(0, 0), new SurfacePoint(10, 0)); + surface.EndEntity(); + surface.BeginEntity(Entity("Doors", handle: 0x1F4), Layer("Doors")); + surface.DrawLine(style, new SurfacePoint(0, 0), new SurfacePoint(0, 10)); + surface.EndEntity(); + surface.BeginEntity(Entity("Walls", handle: 0x1F5), Layer("Walls")); + surface.DrawLine(style, new SurfacePoint(5, 5), new SurfacePoint(6, 6)); + surface.EndEntity(); + + XElement defaults = surface.ToDocument().Root!.Element(Ns + "g")!.Element(Ns + "g")!; + List layers = defaults.Elements(Ns + "g").ToList(); + + Assert.Equal(2, layers.Count); + Assert.Equal("Walls", (string?)layers[0].Attribute("data-layer")); + Assert.Equal("p-layer-walls", (string?)layers[0].Attribute("id")); + Assert.Equal("cad-layer", (string?)layers[0].Attribute("class")); + Assert.Equal("#ff0000", (string?)layers[0].Attribute("stroke")); + Assert.Equal(2, layers[0].Elements(Ns + "line").Count()); + Assert.Equal("Doors", (string?)layers[1].Attribute("data-layer")); + + XElement first = layers[0].Elements(Ns + "line").First(); + Assert.Equal("1F3", (string?)first.Attribute("data-handle")); + Assert.Equal("LINE", (string?)first.Attribute("data-type")); + Assert.Null(first.Attribute("stroke")); // same as the layer group + Assert.Equal("non-scaling-stroke", (string?)first.Attribute("vector-effect")); + } + + [Fact] + public void NestedEntityCarriesParentAndBlock() + { + using SvgDrawingSurface surface = CreateSurface(); + ImageStyle style = new(Color.FromRgb(255, 0, 0), 1f); + + surface.BeginEntity(Entity("Doors", "INSERT", 0xA0), Layer("Doors")); + surface.BeginEntity(Entity("Doors", "LINE", 0xA1, 0xA0, "DOOR"), Layer("Doors")); + surface.DrawLine(style, new SurfacePoint(0, 0), new SurfacePoint(1, 1)); + surface.EndEntity(); + surface.EndEntity(); + + XElement line = Assert.Single(surface.ToDocument().Descendants(Ns + "line")); + Assert.Equal("A0", (string?)line.Attribute("data-parent")); + Assert.Equal("DOOR", (string?)line.Attribute("data-block")); + } + + [Fact] + 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. + 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.EndEntity(); + + XElement line = Assert.Single(surface.ToDocument().Descendants(Ns + "line")); + Assert.Null(line.Attribute("data-handle")); + Assert.Equal("LINE", (string?)line.Attribute("data-type")); + Assert.Equal("A0", (string?)line.Attribute("data-parent")); + } + + [Fact] + 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.EndEntity(); + + XElement line = Assert.Single(surface.ToDocument().Descendants(Ns + "line")); + Assert.Equal("#0000ff", (string?)line.Attribute("stroke")); + Assert.Equal("2.5", (string?)line.Attribute("stroke-width")); + Assert.Equal("4 2", (string?)line.Attribute("stroke-dasharray")); + Assert.Equal("0.5", (string?)line.Attribute("opacity")); + Assert.Null(line.Attribute("data-handle")); + } + + [Fact] + public void PolylineAndPolygonAndFills() + { + using SvgDrawingSurface surface = CreateSurface(); + ImageStyle style = new(Color.Black, 1f); + SurfacePoint[] tri = [new(0, 0), new(10, 0), new(10, 10)]; + + surface.BeginEntity(Entity("L"), Layer("L")); + surface.DrawPolyline(style, tri, closed: false); + surface.DrawPolyline(style, tri, closed: true); + surface.FillPolygon(style, tri); + surface.FillPath(style, [tri, [new(2, 2), new(4, 2), new(4, 4)]]); + surface.FillCircle(style, new SurfacePoint(5, 5), 2); + surface.EndEntity(); + + XDocument document = surface.ToDocument(); + Assert.Equal("0 0 10 0 10 10", (string?)Assert.Single(document.Descendants(Ns + "polyline")).Attribute("points")); + List polygons = document.Descendants(Ns + "polygon").ToList(); + Assert.Equal(2, polygons.Count); + Assert.Null(polygons[0].Attribute("fill")); + Assert.Equal("#000000", (string?)polygons[1].Attribute("fill")); + Assert.Equal("none", (string?)polygons[1].Attribute("stroke")); + XElement path = Assert.Single(document.Descendants(Ns + "path")); + Assert.Equal("evenodd", (string?)path.Attribute("fill-rule")); + Assert.Equal("M0 0L10 0L10 10ZM2 2L4 2L4 4Z", (string?)path.Attribute("d")); + XElement circle = Assert.Single(document.Descendants(Ns + "circle")); + Assert.Equal("2", (string?)circle.Attribute("r")); + Assert.Equal("#000000", (string?)circle.Attribute("fill")); + } + + [Fact] + public void DrawingUnitStrokesOmitVectorEffect() + { + using SvgDrawingSurface surface = CreateSurface(c => c.Svg.NonScalingStroke = false); + + surface.BeginEntity(Entity("L"), Layer("L")); + surface.DrawLine(new ImageStyle(Color.Black, 0.25f), new SurfacePoint(0, 0), new SurfacePoint(1, 1)); + surface.EndEntity(); + + XElement line = Assert.Single(surface.ToDocument().Descendants(Ns + "line")); + Assert.Null(line.Attribute("vector-effect")); + } + + [Fact] + public void StyleScalarsUseFixedPrecisionRegardlessOfCoordinatePrecision() + { + ImageConfiguration configuration = new(); + using SvgDrawingSurface surface = new(configuration, new SurfaceRect(0, 0, 20000, 10000), null, null); + ImageStyle style = new(Color.Black, 0.25f, [0.5f, 0.25f], 0.5f); + + surface.BeginEntity(Entity("L"), Layer("L")); + surface.DrawLine(style, new SurfacePoint(0, 0), new SurfacePoint(12345.678, 0)); + surface.EndEntity(); + + XElement line = Assert.Single(surface.ToDocument().Descendants(Ns + "line")); + Assert.Equal("0.25", (string?)line.Attribute("stroke-width")); + Assert.Equal("0.5 0.25", (string?)line.Attribute("stroke-dasharray")); + Assert.Equal("0.5", (string?)line.Attribute("opacity")); + Assert.Equal("12346", (string?)line.Attribute("x2")); + } + + [Fact] + public void ArcIsWrittenAsPathWithFlags() + { + using SvgDrawingSurface surface = CreateSurface(); + + // Quarter turn clockwise on screen (positive surface sweep) from angle 0. + surface.DrawArc(new ImageStyle(Color.Black, 1f), new SurfacePoint(50, 25), 10, 10, 0, 0, Math.PI / 2); + + XElement path = Assert.Single(surface.ToDocument().Descendants(Ns + "path")); + Assert.Equal("M60 25A10 10 0 0 1 50 35", (string?)path.Attribute("d")); + } + + [Fact] + public void CounterClockwiseArcHasSweepFlagZeroAndLargeArcWhenOverHalfTurn() + { + using SvgDrawingSurface surface = CreateSurface(); + + surface.DrawArc(new ImageStyle(Color.Black, 1f), new SurfacePoint(50, 25), 10, 10, 0, 0, -1.5 * Math.PI); + + XElement path = Assert.Single(surface.ToDocument().Descendants(Ns + "path")); + Assert.Equal("M60 25A10 10 0 1 0 50 35", (string?)path.Attribute("d")); + } + + [Fact] + public void FullSweepBecomesEllipseAndCirclesUseCircle() + { + using SvgDrawingSurface surface = CreateSurface(); + + surface.DrawArc(new ImageStyle(Color.Black, 1f), new SurfacePoint(50, 25), 10, 5, Math.PI / 4, 0, 2 * Math.PI); + surface.DrawEllipse(new ImageStyle(Color.Black, 1f), new SurfacePoint(10, 10), 3, 3, 0); + + XDocument document = surface.ToDocument(); + XElement ellipse = Assert.Single(document.Descendants(Ns + "ellipse")); + Assert.Equal("10", (string?)ellipse.Attribute("rx")); + Assert.Equal("5", (string?)ellipse.Attribute("ry")); + Assert.Equal("rotate(45 50 25)", (string?)ellipse.Attribute("transform")); + XElement circle = Assert.Single(document.Descendants(Ns + "circle")); + Assert.Equal("3", (string?)circle.Attribute("r")); + Assert.Null(circle.Attribute("fill")); + } + + [Fact] + public void BulgePolylineWritesArcCommands() + { + using SvgDrawingSurface surface = CreateSurface(); + + surface.DrawBulgePolyline(new ImageStyle(Color.Black, 1f), [new(0, 0), new(10, 0), new(10, 10)], [1d, 0d], closed: false); + + XElement path = Assert.Single(surface.ToDocument().Descendants(Ns + "path")); + Assert.Equal("M0 0A5 5 0 0 0 10 0L10 10", (string?)path.Attribute("d")); + } + + [Fact] + public void BulgePolylineDropsTheBulgeOfADroppedVertex() + { + using SvgDrawingSurface surface = CreateSurface(); + + // The non-finite vertex goes and its bulge with it, so the arc stays on the (10,0) vertex that owns it. + surface.DrawBulgePolyline( + new ImageStyle(Color.Black, 1f), + [new(0, 0), new(double.NaN, double.NaN), new(10, 0), new(20, 0)], + [0d, 0d, 1d, 0d], + closed: false); + + XElement path = Assert.Single(surface.ToDocument().Descendants(Ns + "path")); + string d = (string?)path.Attribute("d") ?? string.Empty; + Assert.Equal(1, d.Count(c => c == 'A')); + Assert.DoesNotContain("NaN", d, StringComparison.Ordinal); + } + + [Fact] + public void CubicBezierWritesCCommands() + { + using SvgDrawingSurface surface = CreateSurface(); + + surface.DrawCubicBezier(new ImageStyle(Color.Black, 1f), [new(0, 0), new(1, 2), new(3, 2), new(4, 0)], closed: true); + + XElement path = Assert.Single(surface.ToDocument().Descendants(Ns + "path")); + Assert.Equal("M0 0C1 2 3 2 4 0Z", (string?)path.Attribute("d")); + } + + [Fact] + public void TextIsWrittenAsTextElement() + { + using SvgDrawingSurface surface = CreateSurface(); + SurfaceText run = new("Hello", new SurfacePoint(10, 20), 2.5, Math.PI / 6, SurfaceTextAnchor.Middle, SurfaceTextBaseline.Central, -1, 1, 12); + + surface.BeginEntity(Entity("Anno", "TEXT"), Layer("Anno")); + surface.DrawText(new ImageStyle(Color.Black, 1f), run); + surface.EndEntity(); + + XElement text = Assert.Single(surface.ToDocument().Descendants(Ns + "text")); + Assert.Equal("Hello", text.Value); + Assert.Equal("10", (string?)text.Attribute("x")); + Assert.Equal("20", (string?)text.Attribute("y")); + Assert.Equal("3.33", (string?)text.Attribute("font-size")); // 2.5 × 4/3 + Assert.Equal("middle", (string?)text.Attribute("text-anchor")); + Assert.Equal("central", (string?)text.Attribute("dominant-baseline")); + Assert.Equal("rotate(-30 10 20)", (string?)text.Attribute("transform")); + Assert.Equal("12", (string?)text.Attribute("textLength")); + Assert.Equal("spacingAndGlyphs", (string?)text.Attribute("lengthAdjust")); + Assert.Equal("#000000", (string?)text.Attribute("fill")); + Assert.Equal("none", (string?)text.Attribute("stroke")); + } + + [Fact] + public void NonUniformWidthScaleAddsAScaleTransform() + { + using SvgDrawingSurface surface = CreateSurface(); + SurfaceText run = new("AB", new SurfacePoint(10, 20), 4, 0, SurfaceTextAnchor.Start, SurfaceTextBaseline.Alphabetic, -1, 1, -1, WidthScale: 2); + + surface.DrawText(new ImageStyle(Color.Black, 1f), run); + + XElement text = Assert.Single(surface.ToDocument().Descendants(Ns + "text")); + Assert.Equal("translate(10 20) scale(2 1) translate(-10 -20)", (string?)text.Attribute("transform")); + } + + [Fact] + public void NonUniformWidthScaleWithRotationAppendsScaleAfterRotate() + { + using SvgDrawingSurface surface = CreateSurface(); + SurfaceText run = new("AB", new SurfacePoint(10, 20), 4, Math.PI / 2, SurfaceTextAnchor.Start, SurfaceTextBaseline.Alphabetic, -1, 1, -1, WidthScale: 2); + + surface.DrawText(new ImageStyle(Color.Black, 1f), run); + + XElement text = Assert.Single(surface.ToDocument().Descendants(Ns + "text")); + string? transform = (string?)text.Attribute("transform"); + Assert.NotNull(transform); + Assert.StartsWith("rotate(-90 10 20) ", transform, StringComparison.Ordinal); + Assert.EndsWith("translate(10 20) scale(2 1) translate(-10 -20)", transform, StringComparison.Ordinal); + } + + [Fact] + public void UnitWidthScaleAddsNoTransformAttribute() + { + using SvgDrawingSurface surface = CreateSurface(); + SurfaceText run = new("AB", new SurfacePoint(10, 20), 4, 0, SurfaceTextAnchor.Start, SurfaceTextBaseline.Alphabetic, -1, 1, -1, WidthScale: 1); + + surface.DrawText(new ImageStyle(Color.Black, 1f), run); + + XElement text = Assert.Single(surface.ToDocument().Descendants(Ns + "text")); + Assert.Null(text.Attribute("transform")); + } + + [Fact] + public void WidthScaleUsesStylePrecisionNotCoordinatePrecision() + { + // A drawing whose viewBox drives the adaptive coordinate formatter to 0 decimals (or an explicit + // Svg.Precision of 0) must not round a fractional WidthScale away to an integer, or to 0, which would + // erase the text: the stretch factor is a dimensionless ratio, formatted at style precision instead. + using SvgDrawingSurface surface = CreateSurface(c => c.Svg.Precision = 0); + SurfaceText run = new("AB", new SurfacePoint(10, 20), 4, 0, SurfaceTextAnchor.Start, SurfaceTextBaseline.Alphabetic, -1, 1, -1, WidthScale: 1.5); + + surface.DrawText(new ImageStyle(Color.Black, 1f), run); + + XElement text = Assert.Single(surface.ToDocument().Descendants(Ns + "text")); + string? transform = (string?)text.Attribute("transform"); + Assert.NotNull(transform); + Assert.Contains("scale(1.5 1)", transform, StringComparison.Ordinal); + } + + [Fact] + public void MultiLineTextUsesTspans() + { + using SvgDrawingSurface surface = CreateSurface(); + SurfaceText run = new("A\nB\nC", new SurfacePoint(0, 0), 2, 0, SurfaceTextAnchor.Start, SurfaceTextBaseline.Alphabetic, -1, 1, -1); + + surface.DrawText(new ImageStyle(Color.Black, 1f), run); + + XElement text = Assert.Single(surface.ToDocument().Descendants(Ns + "text")); + List spans = text.Elements(Ns + "tspan").ToList(); + Assert.Equal(3, spans.Count); + Assert.Null(spans[0].Attribute("dy")); + Assert.Equal("3.33", (string?)spans[1].Attribute("dy")); + Assert.Equal("0", (string?)spans[1].Attribute("x")); + Assert.Null(text.Attribute("dominant-baseline")); + Assert.Null(text.Attribute("transform")); + } + + [Fact] + public void LayerNamesThatSanitiseAlikeGetUniqueIds() + { + using SvgDrawingSurface surface = CreateSurface(); + ImageStyle style = new(Color.Black, 1f); + + surface.BeginEntity(Entity("A WALL"), Layer("A WALL")); + surface.DrawLine(style, new SurfacePoint(0, 0), new SurfacePoint(1, 1)); + surface.EndEntity(); + surface.BeginEntity(Entity("A-WALL"), Layer("A-WALL")); + surface.DrawLine(style, new SurfacePoint(1, 1), new SurfacePoint(2, 2)); + surface.EndEntity(); + + XDocument document = surface.ToDocument(); + List ids = document.Descendants().Select(e => (string?)e.Attribute("id")).OfType().ToList(); + + // Both names sanitise to "layer-a-wall"; duplicate ids are invalid markup, so the second one is suffixed. + Assert.Equal(new[] { "layer-a-wall", "layer-a-wall-2" }, ids); + Assert.Equal(ids.Count, ids.Distinct(StringComparer.Ordinal).Count()); + } + + [Fact] + public void ViewportWrapsContentsInClippedGroupWithOwnLayerGroups() + { + using SvgDrawingSurface surface = CreateSurface(c => c.Svg.IdPrefix = "x-"); + ImageStyle style = new(Color.Black, 1f); + + surface.BeginEntity(Entity("Title"), Layer("Title")); + surface.DrawLine(style, new SurfacePoint(0, 0), new SurfacePoint(1, 1)); + surface.EndEntity(); + + ViewportSurface viewport = surface.BeginViewport(new SurfaceRect(10, 5, 40, 30)); + Assert.Same(surface, viewport.Surface); + Assert.Equal(10d, viewport.OffsetX); + Assert.Equal(35d, viewport.BottomY); + surface.BeginEntity(Entity("Title"), Layer("Title")); + surface.DrawLine(style, new SurfacePoint(12, 6), new SurfacePoint(20, 20)); + surface.EndEntity(); + surface.EndViewport(viewport); + + XDocument document = surface.ToDocument(); + XElement clipPath = Assert.Single(document.Descendants(Ns + "clipPath")); + Assert.Equal("x-clip-1", (string?)clipPath.Attribute("id")); + Assert.Equal("userSpaceOnUse", (string?)clipPath.Attribute("clipPathUnits")); + XElement rect = Assert.Single(clipPath.Elements(Ns + "rect")); + Assert.Equal("10", (string?)rect.Attribute("x")); + Assert.Equal("30", (string?)rect.Attribute("height")); + + XElement group = Assert.Single(document.Descendants(Ns + "g"), g => (string?)g.Attribute("clip-path") == "url(#x-clip-1)"); + Assert.Equal("cad-viewport", (string?)group.Attribute("class")); + // The viewport has its own "Title" layer group, separate from the page-level one, with a distinct id. + List titleGroups = document.Descendants(Ns + "g").Where(g => (string?)g.Attribute("data-layer") == "Title").ToList(); + Assert.Equal(2, titleGroups.Count); + Assert.Equal("x-layer-title", (string?)titleGroups[0].Attribute("id")); + Assert.Equal("x-clip-1-layer-title", (string?)titleGroups[1].Attribute("id")); + Assert.Single(group.Descendants(Ns + "line")); + Assert.True(document.Descendants(Ns + "defs").Single().ElementsBeforeSelf().Count() == 0); + + List ids = document.Descendants().Select(e => (string?)e.Attribute("id")).Where(id => id != null).ToList()!; + Assert.Equal(ids.Count, ids.Distinct(StringComparer.Ordinal).Count()); + } + + [Fact] + public void ForbiddenXmlCharactersAreStrippedFromTextLayerAndBlockNames() + { + string bad = "A" + (char)1 + "B"; + using SvgDrawingSurface surface = CreateSurface(); + SurfaceText run = new(bad + "\nC" + (char)0x1F + "D", new SurfacePoint(0, 0), 1, 0, SurfaceTextAnchor.Start, SurfaceTextBaseline.Alphabetic, -1, 1, 0); + + surface.BeginEntity(Entity(bad, "TEXT", block: bad), Layer(bad)); + surface.DrawText(new ImageStyle(Color.Black, 1f), run); + surface.EndEntity(); + + // Serialising is what throws on U+0001; the whole point is that it no longer does. + string markup = surface.ToSvgString(); + XDocument document = XDocument.Parse(markup); + XElement group = Assert.Single(document.Descendants(Ns + "g"), g => (string?)g.Attribute("class") == "cad-layer"); + Assert.Equal("AB", (string?)group.Attribute("data-layer")); + XElement text = Assert.Single(document.Descendants(Ns + "text")); + Assert.Equal("AB", (string?)text.Attribute("data-block")); + Assert.Equal(["AB", "CD"], text.Elements(Ns + "tspan").Select(t => t.Value).ToArray()); + } + + [Fact] + public void XmlTextCleanKeepsLegalWhitespaceAndSurrogatePairs() + { + string legal = "tab\t nl\n cr\r emoji\U0001F600"; + Assert.Same(legal, SvgXmlText.Clean(legal)); + Assert.Equal("ab", SvgXmlText.Clean("a" + (char)0xFFFE + "b")); + Assert.Equal("ab", SvgXmlText.Clean("a\uD83Db")); + } + + [Fact] + public void TranslucentBackgroundKeepsItsAlphaAsFillOpacity() + { + using SvgDrawingSurface surface = CreateSurface(c => c.BackgroundColor = Color.FromRgba(0, 0, 0, 128)); + + XElement rect = Assert.Single(surface.ToDocument().Descendants(Ns + "rect")); + Assert.Equal("#000000", (string?)rect.Attribute("fill")); + Assert.Equal("0.502", (string?)rect.Attribute("fill-opacity")); + + using SvgDrawingSurface opaque = CreateSurface(); + Assert.Null(Assert.Single(opaque.ToDocument().Descendants(Ns + "rect")).Attribute("fill-opacity")); + } + + [Fact] + public void IdPrefixIsRestrictedToIdSafeCharacters() + { + using SvgDrawingSurface surface = CreateSurface(c => c.Svg.IdPrefix = "drawing one/\"2\" "); + ViewportSurface viewport = surface.BeginViewport(new SurfaceRect(0, 0, 10, 10)); + surface.BeginEntity(Entity("Walls"), Layer("Walls")); + surface.DrawLine(new ImageStyle(Color.Black, 1f), new SurfacePoint(0, 0), new SurfacePoint(1, 1)); + surface.EndEntity(); + surface.EndViewport(viewport); + + XDocument document = surface.ToDocument(); + XElement clip = Assert.Single(document.Descendants(Ns + "clipPath")); + Assert.Equal("drawing-one-2-clip-1", (string?)clip.Attribute("id")); + XElement group = Assert.Single(document.Descendants(Ns + "g"), g => (string?)g.Attribute("class") == "cad-viewport"); + Assert.Equal("url(#drawing-one-2-clip-1)", (string?)group.Attribute("clip-path")); + Assert.Equal("drawing-one-2-clip-1-layer-walls", (string?)Assert.Single(document.Descendants(Ns + "g"), g => (string?)g.Attribute("class") == "cad-layer").Attribute("id")); + + Assert.Equal("Plan_1-", SvgIdSanitizer.SanitizePrefix("Plan_1-")); + Assert.Equal(string.Empty, SvgIdSanitizer.SanitizePrefix("")); + } + + [Fact] + public void FontSizeIsTheEmForTheCadCapHeight() + { + using SvgDrawingSurface surface = CreateSurface(); + surface.BeginEntity(Entity("Anno", "TEXT"), Layer("Anno")); + surface.DrawText(new ImageStyle(Color.Black, 1f), new SurfaceText("H", new SurfacePoint(0, 0), 3, 0, SurfaceTextAnchor.Start, SurfaceTextBaseline.Alphabetic, -1, 1, 0)); + surface.EndEntity(); + + XElement text = Assert.Single(surface.ToDocument().Descendants(Ns + "text")); + Assert.Equal("4", (string?)text.Attribute("font-size")); // 3 × 4/3 + } + + [Fact] + public void MultiLineBlocksAreAnchoredByTheirBaseline() + { + using SvgDrawingSurface surface = CreateSurface(); + ImageStyle style = new(Color.Black, 1f); + surface.BeginEntity(Entity("Anno", "MTEXT"), Layer("Anno")); + surface.DrawText(style, new SurfaceText("a\nb\nc", new SurfacePoint(10, 50), 3, 0, SurfaceTextAnchor.Start, SurfaceTextBaseline.Central, -1, 1, 0)); + surface.DrawText(style, new SurfaceText("a\nb", new SurfacePoint(10, 50), 3, 0, SurfaceTextAnchor.Start, SurfaceTextBaseline.Alphabetic, -1, 1, 0)); + surface.DrawText(style, new SurfaceText("a\nb", new SurfacePoint(10, 50), 3, 0, SurfaceTextAnchor.Start, SurfaceTextBaseline.Hanging, -1, 1, 0)); + surface.EndEntity(); + + List texts = surface.ToDocument().Descendants(Ns + "text").ToList(); + // Line height is 5/3 of the cap height: 5. Central: three lines, first line one line height above the origin. + Assert.Equal("45", (string?)texts[0].Attribute("y")); + // Alphabetic (bottom): two lines, first line one line height above. + Assert.Equal("45", (string?)texts[1].Attribute("y")); + // Hanging (top): first line at the origin. + Assert.Equal("50", (string?)texts[2].Attribute("y")); + Assert.Equal(["a", "b", "c"], texts[0].Elements(Ns + "tspan").Select(t => t.Value).ToArray()); + Assert.Equal("5", (string?)texts[0].Elements(Ns + "tspan").ElementAt(1).Attribute("dy")); + } + + [Fact] + public void TextIsWrappedAtTheWrappingWidth() + { + using SvgDrawingSurface surface = CreateSurface(c => c.FontFamilyName = "DejaVu Sans"); + surface.BeginEntity(Entity("Anno", "MTEXT"), Layer("Anno")); + // Width 14 at cap height 3 (em 4) fits roughly five to six characters of DejaVu Sans per line. + surface.DrawText(new ImageStyle(Color.Black, 1f), new SurfaceText("alpha beta gamma delta", new SurfacePoint(0, 0), 3, 0, SurfaceTextAnchor.Start, SurfaceTextBaseline.Hanging, 14, 1, 0)); + surface.EndEntity(); + + XElement text = Assert.Single(surface.ToDocument().Descendants(Ns + "text")); + string[] lines = text.Elements(Ns + "tspan").Select(t => t.Value).ToArray(); + Assert.Equal(["alpha", "beta", "gamma", "delta"], lines); + } + + [Fact] + public void TextElementsPreserveRepeatedWhitespace() + { + // Asserted against the serialized string, not ToDocument()'s in-memory tree: XDocument.Save's + // pretty-printing is what can turn xml:space="preserve" into drawn indentation (see the multi-line + // test below), and only re-parsing the actual output can catch that. + using SvgDrawingSurface surface = CreateSurface(); + surface.BeginEntity(Entity("Anno", "TEXT"), Layer("Anno")); + surface.DrawText(new ImageStyle(Color.Black, 1f), new SurfaceText("A B", new SurfacePoint(0, 0), 3, 0, SurfaceTextAnchor.Start, SurfaceTextBaseline.Alphabetic, -1, 1, 0)); + surface.EndEntity(); + + XDocument document = XDocument.Parse(surface.ToSvgString(), LoadOptions.PreserveWhitespace); + XElement text = Assert.Single(document.Descendants(Ns + "text")); + Assert.Equal("preserve", (string?)text.Attribute(XNamespace.Xml + "space")); + Assert.Equal("A B", text.Value); + } + + [Fact] + public void MultiLineTextCarriesNoXmlSpaceOfItsOwnAndEachTspanPreservesItsRun() + { + // XDocument.Save's default pretty-printing indents each with a newline plus spaces; those + // indentation characters end up as direct-child text nodes of regardless of any attribute (an + // XmlWriter formatting fact, verified against the serialized string below), so they cannot be asserted + // away without disabling indentation for the whole document. What actually matters is which xml:space + // value governs them: xml:space is inherited, so if carried "preserve" those indentation nodes + // would inherit it and be drawn (SVG 1.1 assigns the whitespace after a to the *preceding* text + // chunk, visibly shifting a middle-anchored line — this was the bug). must therefore carry no + // xml:space of its own here, leaving its direct-child whitespace nodes under the ordinary default + // (collapsing) rule, while each carries its own explicit xml:space="preserve" so the repeated + // spaces *inside* its line survive. + using SvgDrawingSurface surface = CreateSurface(); + ImageStyle style = new(Color.Black, 1f); + surface.BeginEntity(Entity("Anno", "MTEXT"), Layer("Anno")); + surface.DrawText(style, new SurfaceText("a b\nc d", new SurfacePoint(10, 50), 3, 0, SurfaceTextAnchor.Middle, SurfaceTextBaseline.Alphabetic, -1, 1, 0)); + surface.EndEntity(); + + XDocument document = XDocument.Parse(surface.ToSvgString(), LoadOptions.PreserveWhitespace); + XElement text = Assert.Single(document.Descendants(Ns + "text")); + + Assert.Null(text.Attribute(XNamespace.Xml + "space")); + // Any direct text child of here is pretty-print indentation, never drawable glyph content: the + // wrapper never hands DrawText a paragraph containing only whitespace. + Assert.All(text.Nodes().OfType(), node => Assert.True(string.IsNullOrWhiteSpace(node.Value))); + + List tspans = text.Elements(Ns + "tspan").ToList(); + Assert.Equal(2, tspans.Count); + foreach (XElement tspan in tspans) + { + Assert.Equal("preserve", (string?)tspan.Attribute(XNamespace.Xml + "space")); + Assert.Empty(tspan.Elements()); + } + + Assert.Equal("a b", tspans[0].Value); + Assert.Equal("c d", tspans[1].Value); + } + + [Fact] + public void WrapKeepsExplicitBreaksAndLongWords() + { + IReadOnlyList lines = SvgTextLayout.Wrap("one two\nthree fourfivesixseven", 8, 4, "DejaVu Sans"); + + Assert.Equal("one", lines[0]); + Assert.Equal("two", lines[1]); + Assert.Equal("three", lines[2]); + Assert.Equal("fourfivesixseven", lines[3]); // a single word wider than the width stays on its own line + Assert.Equal(["x"], SvgTextLayout.Wrap("x", -1, 4, "DejaVu Sans")); + } +} diff --git a/ACadSharp.Image.Tests/SvgExportTests.cs b/ACadSharp.Image.Tests/SvgExportTests.cs new file mode 100644 index 0000000..f07a6e7 --- /dev/null +++ b/ACadSharp.Image.Tests/SvgExportTests.cs @@ -0,0 +1,200 @@ +using System.Xml.Linq; +using ACadSharp.Entities; +using ACadSharp.Image.Rendering.Svg; +using ACadSharp.Tables; +using ACadSharp.Types.Units; +using CSMath; +using SixLabors.ImageSharp.PixelFormats; + +namespace ACadSharp.Image.Tests; + +public sealed class SvgExportTests +{ + private static readonly XNamespace Ns = SvgDrawingSurface.Ns; + + private static BlockRecord SimpleBlock() + { + BlockRecord block = new("svg-block"); + block.Entities.Add(new Line(new XYZ(0, 0, 0), new XYZ(100, 0, 0)) { Layer = new Layer("Walls") }); + block.Entities.Add(new Circle { Center = new XYZ(50, 25, 0), Radius = 10, Layer = new Layer("Doors") }); + block.Entities.Add(new TextEntity { Value = "Room", InsertPoint = new XYZ(10, 40, 0), Height = 5, Layer = new Layer("Anno") }); + return block; + } + + [Fact] + public void RenderSvgProducesSvgPage() + { + ImageExporter exporter = new(); + exporter.Add(SimpleBlock()); + + using RenderedPage page = Assert.Single(exporter.Render(ImageExportFormat.Svg)); + RenderedSvgPage svg = Assert.IsType(page); + XDocument document = XDocument.Parse(svg.Content); + XElement root = document.Root!; + + Assert.Equal(Ns + "svg", root.Name); + Assert.Equal(ImageExportFormat.Svg, svg.Format); + // Extents 100 x 50 (the text bounding box may enlarge the height slightly). + string[] viewBox = ((string)root.Attribute("viewBox")!).Split(' '); + Assert.Equal("0", viewBox[0]); + Assert.Equal("0", viewBox[1]); + Assert.Equal("100", viewBox[2]); + Assert.Null(root.Attribute("width")); + Assert.Equal(3, document.Descendants(Ns + "g").Count(g => g.Attribute("data-layer") != null)); + Assert.Single(document.Descendants(Ns + "circle")); + Assert.Equal("Room", Assert.Single(document.Descendants(Ns + "text")).Value); + } + + [Fact] + public void PointRadiusIsConvertedFromPixelsIntoDrawingUnits() + { + BlockRecord block = new("points"); + block.Entities.Add(new Line(new XYZ(0, 0, 0), new XYZ(100, 50, 0))); + block.Entities.Add(new Point { Location = new XYZ(50, 25, 0) }); + ImageExporter exporter = new(); + exporter.Configuration.Width = 1000; + exporter.Configuration.Height = 500; + exporter.Configuration.SetPadding(0); + exporter.Add(block); + + using RenderedPage page = Assert.Single(exporter.Render(ImageExportFormat.Svg)); + XDocument document = XDocument.Parse(((RenderedSvgPage)page).Content); + XElement circle = Assert.Single(document.Descendants(Ns + "circle")); + + // The dot is DotSizePixels / 2 = 2 pixels; the page fits 100 x 50 drawing units into 1000 x 500 pixels, + // so a pixel is a tenth of a drawing unit and the radius is 0.2 units, not 2. + Assert.Equal("0.2", (string?)circle.Attribute("r")); + } + + [Fact] + public void YAxisIsFlipped() + { + BlockRecord block = new("flip"); + block.Entities.Add(new Line(new XYZ(0, 0, 0), new XYZ(10, 5, 0))); + ImageExporter exporter = new(); + exporter.Add(block); + + using RenderedPage page = Assert.Single(exporter.Render(ImageExportFormat.Svg)); + XDocument document = XDocument.Parse(((RenderedSvgPage)page).Content); + XElement line = Assert.Single(document.Descendants(Ns + "line")); + + // The page is 10 x 5. Drawing (0,0) is the bottom-left, so it lands at SVG y = 5; drawing (10,5) lands at y = 0. + // The asymmetric page distinguishes a true Y flip from a transpose. + Assert.Equal("0", (string?)line.Attribute("x1")); + Assert.Equal("5", (string?)line.Attribute("y1")); + Assert.Equal("10", (string?)line.Attribute("x2")); + Assert.Equal("0", (string?)line.Attribute("y2")); + } + + [Fact] + public void PaddingExpandsViewBoxAndSizeIsOptional() + { + BlockRecord block = new("padded"); + block.Entities.Add(new Line(new XYZ(0, 0, 0), new XYZ(100, 50, 0))); + ImageExporter exporter = new(); + exporter.Configuration.Width = 1000; + exporter.Configuration.Height = 600; + exporter.Configuration.SetPadding(100, 50, 100, 50); + exporter.Configuration.Svg.EmitSize = true; + exporter.Add(block); + + using RenderedPage page = Assert.Single(exporter.Render(ImageExportFormat.Svg)); + XElement root = XDocument.Parse(((RenderedSvgPage)page).Content).Root!; + + // Drawable 800x500 for a 100x50 page -> fit 8 px/unit -> padding 12.5 units horizontally, 6.25 vertically. + Assert.Equal("-12.5 -6.25 125 62.5", (string?)root.Attribute("viewBox")); + Assert.Equal("1000", (string?)root.Attribute("width")); + Assert.Equal("600", (string?)root.Attribute("height")); + } + + [Fact] + public void StrokeWidthsArePixelsByDefaultAndDrawingUnitsWhenScaling() + { + BlockRecord block = new("weights"); + block.Entities.Add(new Line(new XYZ(0, 0, 0), new XYZ(100, 0, 0)) { LineWeight = ACadSharp.LineWeightType.W50 }); + + ImageExporter pixels = new(); + pixels.Add(block); + using RenderedPage pixelPage = Assert.Single(pixels.Render(ImageExportFormat.Svg)); + XElement pixelLine = Assert.Single(XDocument.Parse(((RenderedSvgPage)pixelPage).Content).Descendants(Ns + "line")); + Assert.Equal("non-scaling-stroke", (string?)pixelLine.Attribute("vector-effect")); + // 0.50 mm at 96 dpi = 1.8897 px, written by the fixed 3-decimal style formatter. + Assert.Equal("1.89", (string?)pixelLine.Attribute("stroke-width")); + + ImageExporter units = new(); + units.Configuration.Svg.NonScalingStroke = false; + units.Add(block); + using RenderedPage unitPage = Assert.Single(units.Render(ImageExportFormat.Svg)); + XElement unitLine = Assert.Single(XDocument.Parse(((RenderedSvgPage)unitPage).Content).Descendants(Ns + "line")); + Assert.Null(unitLine.Attribute("vector-effect")); + Assert.Equal("0.5", (string?)unitLine.Attribute("stroke-width")); // 0.50 mm, unitless drawing treated as millimetres + } + + [Fact] + public void SaveWritesSvgFile() + { + string path = Path.Combine(Path.GetTempPath(), $"acadsharp-{Guid.NewGuid():N}.svg"); + try + { + ImageExporter exporter = new(); + exporter.Add(SimpleBlock()); + exporter.Save(path, ImageExportFormat.Svg); + + string content = File.ReadAllText(path); + Assert.Contains(" g.Attribute("data-layer") != null); + Assert.Equal("0.00025", (string?)layer.Attribute("stroke-width")); + } + + [Fact] + public void MillimetreUnitsKeepThreeDecimalStyleScalars() + { + CadDocument document = new(); + document.Header.InsUnits = UnitsType.Millimeters; + document.ModelSpace.Entities.Add(new Line(new XYZ(0, 0, 0), new XYZ(100, 0, 0)) { LineWeight = ACadSharp.LineWeightType.W50 }); + + ImageExporter exporter = new(); + exporter.Configuration.Svg.NonScalingStroke = false; + exporter.AddModelSpace(document); + + using RenderedPage page = Assert.Single(exporter.Render(ImageExportFormat.Svg)); + XElement line = Assert.Single(XDocument.Parse(((RenderedSvgPage)page).Content).Descendants(Ns + "line")); + + Assert.Equal("0.5", (string?)line.Attribute("stroke-width")); + } + + [Fact] + public void RenderedImagePageRejectsTheSvgFormat() + { + using RenderedImagePage page = new("x", new SixLabors.ImageSharp.Image(1, 1), ImageExportFormat.Svg); + + Assert.Throws(() => page.Save(new MemoryStream())); + } +} diff --git a/ACadSharp.Image.Tests/SvgFormattingTests.cs b/ACadSharp.Image.Tests/SvgFormattingTests.cs new file mode 100644 index 0000000..20f5161 --- /dev/null +++ b/ACadSharp.Image.Tests/SvgFormattingTests.cs @@ -0,0 +1,40 @@ +using ACadSharp.Image.Rendering.Svg; + +namespace ACadSharp.Image.Tests; + +public sealed class SvgFormattingTests +{ + [Theory] + [InlineData(3, 1.23456, "1.235")] + [InlineData(3, 10.0, "10")] + [InlineData(3, -0.0004, "0")] + [InlineData(0, 2.5, "3")] + [InlineData(2, 1234567.891, "1234567.89")] + public void FormatRoundsAndTrimsTrailingZeros(int decimals, double value, string expected) + { + SvgNumberFormatter formatter = new(decimals); + + Assert.Equal(expected, formatter.Format(value)); + } + + [Theory] + [InlineData(50000, 30000, 0)] // 50 m site plan in mm: 1e-4 * 50000 = 5 -> whole units + [InlineData(420, 297, 2)] // A3 sheet in mm: 0.042 -> 2 decimals + [InlineData(20, 10, 3)] // 20 mm part: 0.002 -> 3 decimals + [InlineData(0.5, 0.5, 5)] + [InlineData(1e-9, 1e-9, 8)] + public void AdaptiveDecimalsTargetsOneTenThousandthOfTheLargerSide(double width, double height, int expected) + { + Assert.Equal(expected, SvgNumberFormatter.AdaptiveDecimals(width, height)); + } + + [Theory] + [InlineData("", "layer", "Walls", "layer-walls")] + [InlineData("plan1-", "layer", "A-WALL Exterior (new)", "plan1-layer-a-wall-exterior-new")] + [InlineData("", "layer", "0", "layer-0")] + [InlineData("", "clip", "", "clip-")] + public void SanitizeProducesSafeIds(string prefix, string kind, string name, string expected) + { + Assert.Equal(expected, SvgIdSanitizer.Sanitize(prefix, kind, name)); + } +} diff --git a/ACadSharp.Image.Tests/SvgOptionsTests.cs b/ACadSharp.Image.Tests/SvgOptionsTests.cs new file mode 100644 index 0000000..88e0559 --- /dev/null +++ b/ACadSharp.Image.Tests/SvgOptionsTests.cs @@ -0,0 +1,38 @@ +namespace ACadSharp.Image.Tests; + +public sealed class SvgOptionsTests +{ + [Fact] + public void DefaultsMatchSpec() + { + SvgOptions options = new ImageConfiguration().Svg; + + Assert.True(options.NonScalingStroke); + Assert.True(options.EmitEntityAttributes); + Assert.False(options.EmitSize); + Assert.Equal(string.Empty, options.IdPrefix); + Assert.Null(options.Precision); + } + + [Fact] + public void PrecisionIsValidated() + { + SvgOptions options = new(); + + options.Precision = 3; + Assert.Equal(3, options.Precision); + Assert.Throws(() => options.Precision = 9); + Assert.Throws(() => options.Precision = -1); + } + + [Fact] + public void LineWeightMillimetersUsesOverridesThenDefaults() + { + ImageConfiguration configuration = new(); + + Assert.Equal(0.25d, configuration.GetLineWeightMillimeters(ACadSharp.LineWeightType.W25)); + configuration.SetLineWeight(ACadSharp.LineWeightType.W25, 0.4d); + Assert.Equal(0.4d, configuration.GetLineWeightMillimeters(ACadSharp.LineWeightType.W25)); + Assert.Equal(0d, configuration.GetLineWeightMillimeters(ACadSharp.LineWeightType.ByLayer)); + } +} diff --git a/ACadSharp.Image.Tests/SvgTextLayoutTests.cs b/ACadSharp.Image.Tests/SvgTextLayoutTests.cs new file mode 100644 index 0000000..048a01a --- /dev/null +++ b/ACadSharp.Image.Tests/SvgTextLayoutTests.cs @@ -0,0 +1,125 @@ +using ACadSharp.Image.Rendering.Svg; +using SixLabors.Fonts; + +namespace ACadSharp.Image.Tests; + +/// +/// Checks that the SVG line breaking is scale-invariant, breaks at the raster's opportunities and lays out the same +/// number of lines as ImageSharp does for the same text and width. +/// +public sealed class SvgTextLayoutTests +{ + private const string Family = "DejaVu Sans"; + + /// Em size the parity measurements use; the layout is linear in it, so any fixed size will do. + private const float ReferenceSize = 100f; + + [Fact] + public void WrappingIsScaleInvariant() + { + IReadOnlyList large = SvgTextLayout.Wrap("alpha beta gamma delta", 14, 4, Family); + IReadOnlyList small = SvgTextLayout.Wrap("alpha beta gamma delta", 1.4, 0.4, Family); + + Assert.Equal(["alpha", "beta", "gamma", "delta"], large); + Assert.Equal(large, small); + } + + [Fact] + public void HyphenatedPartsBreakAfterTheHyphen() + { + // "left-" advances 7.92 units at em 4, the other parts more, so only one part fits per line at width 8. + IReadOnlyList lines = SvgTextLayout.Wrap("left-hand-side-rail", 8, 4, Family); + + Assert.Equal(["left-", "hand-", "side-", "rail"], lines); + } + + [Fact] + public void RepeatedSpacesArePreserved() + { + Assert.Equal(["A B"], SvgTextLayout.Wrap("A B", 100, 4, Family)); + } + + [Fact] + public void SlashesBreakAfterTheSlash() + { + Assert.Equal(["a/", "b/", "c/", "d"], SvgTextLayout.Wrap("a/b/c/d", 4, 4, Family)); + } + + [Fact] + public void TokenWiderThanTheWidthStaysAloneOnItsLine() + { + Assert.Equal(["supercalifragilistic"], SvgTextLayout.Wrap("supercalifragilistic", 2, 4, Family)); + } + + [Fact] + public void NoBreakSpaceNeverBreaks() + { + Assert.Equal(["A\u00A0B"], SvgTextLayout.Wrap("A\u00A0B", 1, 4, Family)); + } + + [Fact] + public void CombiningMarksDoNotBreakTheWrapper() + { + // Combining marks below the baseline (U+0332 COMBINING LOW LINE) and their precomposed equivalents. + Assert.Equal(["\u05D5\u0332\u05D5\u0332 abc"], SvgTextLayout.Wrap("\u05D5\u0332\u05D5\u0332 abc", 100, 4, Family)); + Assert.Equal(["\u1E3B abc"], SvgTextLayout.Wrap("\u1E3B abc", 100, 4, Family)); + Assert.Equal(["l\u0332l\u0332 abc"], SvgTextLayout.Wrap("l\u0332l\u0332 abc", 100, 4, Family)); + } + + [Fact] + public void RightToLeftTextWrapsInLogicalOrder() + { + // Three Hebrew words; the lines must be logical-order slices, not visual-order ones. + IReadOnlyList lines = SvgTextLayout.Wrap("\u05D0\u05D1\u05D2 \u05D3\u05D4\u05D5 \u05D6\u05D7\u05D8", 6, 4, Family); + + Assert.Equal(["\u05D0\u05D1\u05D2", "\u05D3\u05D4\u05D5", "\u05D6\u05D7\u05D8"], lines); + } + + [Theory] + [InlineData("alpha beta gamma delta", 6)] + [InlineData("alpha beta gamma delta", 9)] + [InlineData("alpha beta gamma delta", 14)] + [InlineData("alpha beta gamma delta", 30)] + [InlineData("left-hand-side-rail", 6)] + [InlineData("left-hand-side-rail", 9)] + [InlineData("left-hand-side-rail", 14)] + [InlineData("left-hand-side-rail", 30)] + [InlineData("A B C", 6)] + [InlineData("A B C", 9)] + [InlineData("A B C", 14)] + [InlineData("A B C", 30)] + [InlineData("A\u00A0B", 1)] + [InlineData("keep\u00A0together x", 12)] + public void LineCountMatchesImageSharpLayout(string text, double width) + { + int expected = ImageSharpLineCount(text, width, 4d); + + IReadOnlyList lines = SvgTextLayout.Wrap(text, width, 4, Family); + + Assert.Equal(expected, lines.Count); + } + + /// + /// 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. + /// + private static int ImageSharpLineCount(string text, double width, double emSize) + { + Assert.True(SystemFonts.TryGet(Family, out FontFamily family), $"Font '{Family}' must be installed."); + TextOptions options = new(family.CreateFont(ReferenceSize)) + { + Dpi = 72f, + 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; + } +} diff --git a/ACadSharp.Image.Tests/SyntheticSamples.cs b/ACadSharp.Image.Tests/SyntheticSamples.cs new file mode 100644 index 0000000..ea94ba4 --- /dev/null +++ b/ACadSharp.Image.Tests/SyntheticSamples.cs @@ -0,0 +1,374 @@ +using ACadSharp.Entities; +using ACadSharp.IO; +using ACadSharp.Objects; +using ACadSharp.Tables; +using CSMath; + +namespace ACadSharp.Image.Tests; + +/// +/// Drawings built in code for the golden tests, so the goldens cover the feature list rather than whatever the sample +/// files happen to contain. is round-tripped through the DXF writer and reader so the +/// document arrives the way a file would (owners, handles and table references wired by the reader). +/// +internal static class SyntheticSamples +{ + /// + /// Model space with lines on layers Walls, Hidden and Grid (dashed) and a circle, plus a layout "Sheet" + /// (297 x 210) holding a viewport at scale 2 that freezes layer Hidden, a frame line and a title. + /// + public static CadDocument ViewportSheet() + { + CadDocument document = new(); + document.Header.LineTypeScale = 1d; + + LineType dashed = new("DASHED"); + dashed.AddSegment(new LineType.Segment { Length = 5 }); + dashed.AddSegment(new LineType.Segment { Length = -2.5 }); + document.LineTypes.Add(dashed); + + Layer walls = new("Walls") { Color = new Color(1) }; + Layer hidden = new("Hidden") { Color = new Color(5) }; + Layer grid = new("Grid") { Color = new Color(3), LineType = dashed }; + document.Layers.Add(walls); + document.Layers.Add(hidden); + document.Layers.Add(grid); + + document.Entities.Add(new Line(new XYZ(0, 0, 0), new XYZ(100, 0, 0)) { Layer = walls }); + document.Entities.Add(new Line(new XYZ(0, 0, 0), new XYZ(0, 60, 0)) { Layer = walls }); + document.Entities.Add(new Line(new XYZ(0, 60, 0), new XYZ(100, 60, 0)) { Layer = hidden }); + 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 + // 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 }; + document.Layouts.Add(sheet); + + Viewport viewport = new() + { + Center = new XYZ(148.5, 105, 0), + Width = 200, + Height = 120, + ViewCenter = new XY(50, 30), + ViewHeight = 60, + }; + viewport.FrozenLayers.Add(hidden); + sheet.AssociatedBlock.Entities.Add(viewport); + sheet.AssociatedBlock.Entities.Add(new Line(new XYZ(10, 10, 0), new XYZ(287, 10, 0)) { Layer = walls }); + sheet.AssociatedBlock.Entities.Add(new TextEntity { Value = "SHEET 1", InsertPoint = new XYZ(10, 190, 0), Height = 8, Layer = walls }); + + // DxfWriter.Dispose() closes the underlying stream, so a MemoryStream cannot be read back afterwards; write + // to a temporary file instead. + string path = Path.Combine(Path.GetTempPath(), $"viewport-{Guid.NewGuid():N}.dxf"); + try + { + using (DxfWriter writer = new(path, document, binary: false)) + { + writer.Write(); + } + + return DxfReader.Read(path); + } + finally + { + File.Delete(path); + } + } + + /// + /// One block exercising every primitive the goldens from the sample files do not contain: a solid and a pattern + /// hatch, a full ellipse and an elliptical arc, a translucent line, an insert with layer-0 and ByBlock contents, + /// a bulged closed polyline, a two-line MText and a Fit-aligned text. + /// + public static BlockRecord FeatureBlock() + { + BlockRecord block = new("features"); + Layer hatchLayer = new("Hatch") { Color = new Color(1) }; + Layer curves = new("Curves") { Color = new Color(4) }; + Layer trans = new("Trans") { Color = new Color(6) }; + Layer doors = new("Doors") { Color = new Color(3) }; + Layer anno = new("Anno") { Color = new Color(7) }; + + Hatch solid = new() { IsSolid = true, PatternType = HatchPatternType.SolidFill, Pattern = HatchPattern.Solid, Layer = hatchLayer }; + solid.Paths.Add(SquarePath(0, 0, 20)); + block.Entities.Add(solid); + + Hatch pattern = new() { IsSolid = false, PatternType = HatchPatternType.PatternFill, Pattern = new HatchPattern("ANSI31"), Layer = hatchLayer }; + pattern.Pattern.Lines.Add(new HatchPattern.Line { Angle = Math.PI / 4, BasePoint = XY.Zero, Offset = new XY(0, 3.175) }); + pattern.PatternScale = 1; + pattern.Paths.Add(SquarePath(30, 0, 20)); + block.Entities.Add(pattern); + + block.Entities.Add(new Ellipse { Center = new XYZ(70, 10, 0), MajorAxisEndPoint = new XYZ(10, 0, 0), RadiusRatio = 0.5, Layer = curves }); + block.Entities.Add(new Ellipse { Center = new XYZ(100, 10, 0), MajorAxisEndPoint = new XYZ(10, 0, 0), RadiusRatio = 0.5, StartParameter = 0, EndParameter = Math.PI, Layer = curves }); + + block.Entities.Add(new Line(new XYZ(0, 30, 0), new XYZ(120, 30, 0)) { Layer = trans, Transparency = new Transparency(50), LineWeight = LineWeightType.W100 }); + + LwPolyline bulged = new() { IsClosed = true, Layer = curves }; + bulged.Vertices.Add(new LwPolyline.Vertex(new XY(0, 40)) { Bulge = 1 }); + bulged.Vertices.Add(new LwPolyline.Vertex(new XY(20, 40))); + bulged.Vertices.Add(new LwPolyline.Vertex(new XY(20, 55))); + block.Entities.Add(bulged); + + BlockRecord door = new("DOOR"); + door.Entities.Add(new Line(new XYZ(0, 0, 0), new XYZ(10, 0, 0)) { Layer = new Layer(Layer.DefaultName) }); + door.Entities.Add(new Line(new XYZ(0, 0, 0), new XYZ(0, 10, 0)) { Color = Color.ByBlock, LineWeight = LineWeightType.ByBlock }); + block.Entities.Add(new Insert(door) { InsertPoint = new XYZ(40, 40, 0), Layer = doors, Color = new Color(5), LineWeight = LineWeightType.W70 }); + + block.Entities.Add(new MText { Value = "Line1\\PLine2", InsertPoint = new XYZ(70, 48, 0), Height = 4, Layer = anno }); + block.Entities.Add(new TextEntity { Value = "FIT", InsertPoint = new XYZ(70, 55, 0), AlignmentPoint = new XYZ(110, 55, 0), HorizontalAlignment = TextHorizontalAlignment.Fit, Height = 4, Layer = anno }); + + return block; + } + + private static Hatch.BoundaryPath SquarePath(double x, double y, double size) + { + Hatch.BoundaryPath path = new(); + Hatch.BoundaryPath.Polyline polyline = new() { IsClosed = true }; + polyline.Vertices.AddRange([new XYZ(x, y, 0), new XYZ(x + size, y, 0), new XYZ(x + size, y + size, 0), new XYZ(x, y + size, 0)]); + path.Edges.Add(polyline); + return path; + } + + /// + /// A rectangular hatch boundary path built from four line edges (rather than a single closed polyline edge), + /// spanning .. and ... + /// Shared by EntityRenderDispatcherTests and this class. + /// + internal static Hatch.BoundaryPath SquarePath(double x0, double y0, double x1, double y1) + { + Hatch.BoundaryPath path = new(); + path.Edges.Add(new Hatch.BoundaryPath.Line { Start = new XY(x0, y0), End = new XY(x1, y0) }); + path.Edges.Add(new Hatch.BoundaryPath.Line { Start = new XY(x1, y0), End = new XY(x1, y1) }); + path.Edges.Add(new Hatch.BoundaryPath.Line { Start = new XY(x1, y1), End = new XY(x0, y1) }); + path.Edges.Add(new Hatch.BoundaryPath.Line { Start = new XY(x0, y1), End = new XY(x0, y0) }); + return path; + } + + /// + /// One block exercising the entities added after the feature goldens were written: a 3DFACE with one hidden edge, + /// a straight and a splined LEADER (both with arrowheads), a filled two-element MLINE turning a right-angle corner, + /// an opaque WIPEOUT masking part of a line, and an INSERT whose ATTRIB carries a room number. Handles are + /// assigned explicitly so the draw order (and so the wipeout's occlusion of the line beneath it) does not depend + /// on 's tie-breaking for entities that would otherwise all share handle 0. + /// + public static BlockRecord EntityBlock() + { + BlockRecord block = new("entities"); + Layer faceLayer = new("Face") { Color = new Color(2) }; + Layer leaderLayer = new("Leader") { Color = new Color(4) }; + Layer wallLayer = new("Wall") { Color = new Color(6) }; + Layer underLayer = new("Under") { Color = new Color(1) }; + Layer coverLayer = new("Cover") { Color = new Color(8) }; + Layer roomsLayer = new("Rooms") { Color = new Color(9) }; + + Face3D face = WithHandle(new Face3D + { + FirstCorner = new XYZ(0, 0, 0), + SecondCorner = new XYZ(20, 0, 0), + ThirdCorner = new XYZ(20, 15, 0), + FourthCorner = new XYZ(0, 15, 0), + Flags = InvisibleEdgeFlags.Third, + Layer = faceLayer, + }, 0x10); + block.Entities.Add(face); + + DimensionStyle leaderStyle = new("ENTITIES") { ArrowSize = 2 }; + Leader straight = WithHandle(new Leader + { + ArrowHeadEnabled = true, + Style = leaderStyle, + Layer = leaderLayer, + Vertices = { new XYZ(30, 0, 0), new XYZ(45, 10, 0), new XYZ(60, 10, 0) }, + }, 0x11); + block.Entities.Add(straight); + + Leader spline = WithHandle(new Leader + { + ArrowHeadEnabled = true, + PathType = LeaderPathType.Spline, + Style = leaderStyle, + Layer = leaderLayer, + Vertices = { new XYZ(70, 0, 0), new XYZ(80, 10, 0), new XYZ(90, 0, 0), new XYZ(100, 10, 0) }, + }, 0x12); + block.Entities.Add(spline); + + MLineStyle mlineStyle = new("ENTITIES") { Flags = MLineStyleFlags.FillOn, FillColor = new Color(3) }; + mlineStyle.AddElement(new MLineStyle.Element { Offset = 1, Color = new Color(1) }); + mlineStyle.AddElement(new MLineStyle.Element { Offset = -1, Color = new Color(5) }); + + // The corner vertex's miter bisects the right-angle turn from +X to +Y; at offset +-1 the element points lie + // sqrt(2) along it, not 1 (Position + Miter * along, so a non-unit "along" is what carries the offset across + // the corner without narrowing the wall). + double diagonal = Math.Sqrt(2); + MLine mline = WithHandle(new MLine + { + Style = mlineStyle, + Layer = wallLayer, + Vertices = + { + MLineVertex(new XYZ(0, 30, 0), new XYZ(1, 0, 0), new XYZ(0, 1, 0), [1, 0], [-1, 0]), + MLineVertex(new XYZ(40, 30, 0), new XYZ(0, 1, 0), new XYZ(-1, 1, 0) / diagonal, [diagonal, 0], [-diagonal, 0]), + MLineVertex(new XYZ(40, 50, 0), new XYZ(0, 1, 0), new XYZ(-1, 0, 0), [1, 0], [-1, 0]), + }, + }, 0x13); + block.Entities.Add(mline); + + Line under = WithHandle(new Line(new XYZ(60, 30, 0), new XYZ(100, 30, 0)) { Layer = underLayer }, 0x14); + block.Entities.Add(under); + + Wipeout wipeout = WithHandle(new Wipeout + { + InsertPoint = new XYZ(70, 25, 0), + UVector = new XYZ(20, 0, 0), + VVector = new XYZ(0, 10, 0), + Size = new XY(1, 1), + ClippingState = true, + Layer = coverLayer, + }, 0x15); + wipeout.ClipBoundaryVertices.AddRange([new XY(-0.5, -0.5), new XY(0.5, 0.5)]); + block.Entities.Add(wipeout); + + BlockRecord room = new("ROOM"); + room.Entities.Add(new AttributeDefinition { Tag = "ROOM", Value = "DEFAULT", InsertPoint = XYZ.Zero, Height = 3 }); + // Insert(BlockRecord) creates one AttributeEntity per ATTDEF at the identity transform; place it explicitly. + Insert insert = WithHandle(new Insert(room) { InsertPoint = new XYZ(60, 45, 0), Layer = roomsLayer }, 0x16); + AttributeEntity attribute = insert.Attributes.Single(); + attribute.Value = "A-101"; + attribute.InsertPoint = new XYZ(60, 45, 0); + attribute.Height = 3; + block.Entities.Add(insert); + + return block; + } + + /// + /// A block exercising every feature the remaining-limitations work added: a multi-line attribute, a hatch on a + /// tilted plane inside a block, a leader with a custom arrowhead block, an inverted wipeout over a line, and an + /// MLINE with a cut in both of its elements. Handles increase in draw order, so the wipeout follows the line it + /// masks. + /// + /// The block, with every entity on its own named layer. + public static BlockRecord FidelityBlock() + { + BlockRecord block = new("fidelity"); + Layer roomsLayer = new("Rooms") { Color = new Color(9) }; + Layer hatchLayer = new("Tilted") { Color = new Color(3) }; + Layer leaderLayer = new("Leader") { Color = new Color(4) }; + Layer underLayer = new("Under") { Color = new Color(1) }; + Layer coverLayer = new("Cover") { Color = new Color(8) }; + Layer wallLayer = new("Wall") { Color = new Color(6) }; + + // Multi-line attribute: the single-line value must never appear in the output. + BlockRecord label = new("LABEL"); + label.Entities.Add(new AttributeDefinition { Tag = "ROOM", Value = "FLAT", Layer = roomsLayer }); + Insert labelInsert = WithHandle(new Insert(label) { InsertPoint = new XYZ(10, 80, 0), Layer = roomsLayer }, 0x10); + labelInsert.Attributes.Clear(); + labelInsert.Attributes.Add(WithHandle(new AttributeEntity + { + Tag = "ROOM", + Value = "FLAT", + AttributeType = AttributeType.MultiLine, + InsertPoint = new XYZ(10, 80, 0), + Height = 4, + Layer = roomsLayer, + MText = new MText { Value = "Room 1\\PLevel 2", InsertPoint = new XYZ(10, 80, 0), Height = 4, RectangleWidth = 40 }, + }, 0x11)); + block.Entities.Add(labelInsert); + + // Tilted hatch inside a block: normal (0,0,-1) mirrors X on the way to world. + BlockRecord tilted = new("TILTED"); + Hatch hatch = new() { IsSolid = true, Normal = new XYZ(0, 0, -1), Elevation = 0d, Layer = hatchLayer }; + hatch.Paths.Add(SquarePath(0, 0, 20, 15)); + tilted.Entities.Add(hatch); + block.Entities.Add(WithHandle(new Insert(tilted) { InsertPoint = new XYZ(80, 70, 0), Layer = hatchLayer }, 0x12)); + + // Custom arrowhead block: tip at the base point, body back along local -X. + BlockRecord arrow = new("FIDELITY_ARROW"); + arrow.Entities.Add(new Line(new XYZ(-1, 0, 0), new XYZ(0, 0, 0))); + arrow.Entities.Add(new Solid + { + FirstCorner = new XYZ(-1, -0.25, 0), + SecondCorner = new XYZ(0, 0, 0), + ThirdCorner = new XYZ(-1, 0.25, 0), + FourthCorner = new XYZ(0, 0, 0), + }); + block.Entities.Add(WithHandle(new Leader + { + ArrowHeadEnabled = true, + Style = new DimensionStyle("FIDELITY") { ArrowSize = 4, ScaleFactor = 1, LeaderArrow = arrow }, + Layer = leaderLayer, + Vertices = { new XYZ(10, 40, 0), new XYZ(35, 55, 0), new XYZ(55, 55, 0) }, + }, 0x13)); + + // Inverted wipeout over a line: only the middle band of the line survives. The line runs past the + // wipeout's own frame (world x in [60,110]) on both sides, so no endpoint shares a fractional pixel with + // the frame's edge; the boundary is inset in both axes (not just x), so a regression that clamped only one + // component of the clip boundary to the frame would still be caught. + block.Entities.Add(WithHandle(new Line(new XYZ(55, 20, 0), new XYZ(115, 20, 0)) { Layer = underLayer }, 0x14)); + Wipeout wipeout = WithHandle(new Wipeout + { + InsertPoint = new XYZ(60, 10, 0), + UVector = new XYZ(50, 0, 0), + VVector = new XYZ(0, 20, 0), + Size = new XY(1, 1), + ClippingState = true, + ClipType = ClipType.Rectangular, + ClipMode = ClipMode.Inside, + Layer = coverLayer, + }, 0x15); + wipeout.ClipBoundaryVertices.Add(new XY(-0.2, -0.3)); + wipeout.ClipBoundaryVertices.Add(new XY(0.2, 0.3)); + block.Entities.Add(wipeout); + + // Cut MLINE: both elements break between 20 and 30 along their own length. + MLineStyle wallStyle = new("FIDELITY_WALL"); + wallStyle.AddElement(new MLineStyle.Element { Offset = 1 }); + wallStyle.AddElement(new MLineStyle.Element { Offset = -1 }); + block.Entities.Add(WithHandle(new MLine + { + Style = wallStyle, + Layer = wallLayer, + Vertices = + { + MLineVertex(new XYZ(10, 10, 0), new XYZ(1, 0, 0), new XYZ(0, 1, 0), [1, 0, 20, 30], [-1, 0, 20, 30]), + MLineVertex(new XYZ(60, 10, 0), new XYZ(1, 0, 0), new XYZ(0, 1, 0), [1, 0, 20, 30], [-1, 0, 20, 30]), + }, + }, 0x16)); + + return block; + } + + /// + /// Builds one MLINE vertex: is the segment direction leaving this vertex (or, at + /// the last vertex, the direction of the segment arriving at it) and is the vector each + /// element's offset (Position + Miter * Parameters[0]) is carried along; + /// supplies one segment (with its offset as Parameters[0]) per style element, in element order. + /// + private static MLine.Vertex MLineVertex(XYZ position, XYZ direction, XYZ miter, params double[][] parameters) + { + MLine.Vertex vertex = new() { Position = position, Direction = direction, Miter = miter }; + foreach (double[] segment in parameters) + { + MLine.Vertex.Segment element = new(); + element.Parameters.AddRange(segment); + vertex.Segments.Add(element); + } + + return vertex; + } + + /// + /// 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. + /// Shared by ImagePageTests and EntityRenderDispatcherTests as well as this class. + /// + internal static T WithHandle(T entity, ulong handle) + where T : CadObject + { + typeof(CadObject).GetProperty(nameof(CadObject.Handle))!.SetValue(entity, handle); + return entity; + } +} diff --git a/ACadSharp.Image.Tests/TextRendererTests.cs b/ACadSharp.Image.Tests/TextRendererTests.cs new file mode 100644 index 0000000..8015d72 --- /dev/null +++ b/ACadSharp.Image.Tests/TextRendererTests.cs @@ -0,0 +1,356 @@ +using ACadSharp.Entities; +using ACadSharp.Image.Rendering; +using ACadSharp.IO; +using ACadSharp.Objects; +using ACadSharp.Tables; +using CSMath; + +namespace ACadSharp.Image.Tests; + +/// Checks how text alignment and attachment map onto surface text runs. +public sealed class TextRendererTests +{ + private static (RecordingDrawingSurface Surface, ImageRenderContext Context, EntityRenderDispatcher Dispatcher) Setup(double scale = 1d) + { + RecordingDrawingSurface surface = new(); + ImageConfiguration configuration = new(); + Layout layout = new("t") { PaperWidth = 100, PaperHeight = 100 }; + ImageRenderContext context = new(surface, configuration, layout, 100, 100, 0, 0, scale, 0, 0, singlePrecision: false, lineTypeScale: scale); + return (surface, context, new EntityRenderDispatcher(configuration)); + } + + [Fact] + public void FitTextIsCentredBetweenInsertAndAlignmentPointsWithAFixedLength() + { + (RecordingDrawingSurface surface, ImageRenderContext context, EntityRenderDispatcher dispatcher) = Setup(scale: 2d); + TextEntity text = new() { Value = "FIT", InsertPoint = new XYZ(10, 20, 0), AlignmentPoint = new XYZ(40, 20, 0), HorizontalAlignment = TextHorizontalAlignment.Fit, Height = 5 }; + + dispatcher.Draw(context, text); + + SurfaceText run = Assert.Single(surface.Texts); + Assert.Equal(SurfaceTextAnchor.Middle, run.Anchor); + Assert.Equal(SurfaceTextBaseline.Alphabetic, run.Baseline); + Assert.Equal(60d, run.FixedLength, 9); // 30 drawing units x scale 2 + Assert.Equal(80d, run.Origin.X, 9); // origin is the alignment point for anything but Left/Baseline + Assert.Equal(100d - 40d, run.Origin.Y, 9); + Assert.Equal(10d, run.Height, 9); + } + + [Fact] + public void AlignedTextWithCoincidentPointsHasNoFixedLength() + { + (RecordingDrawingSurface surface, ImageRenderContext context, EntityRenderDispatcher dispatcher) = Setup(); + TextEntity text = new() { Value = "A", InsertPoint = new XYZ(1, 1, 0), AlignmentPoint = new XYZ(1, 1, 0), HorizontalAlignment = TextHorizontalAlignment.Aligned, Height = 2 }; + + dispatcher.Draw(context, text); + + Assert.Equal(-1d, Assert.Single(surface.Texts).FixedLength); + } + + [Fact] + public void NonUniformInsertScaleStretchesTextHorizontally() + { + RecordingDrawingSurface surface = new(); + ImageConfiguration configuration = new(); + BlockRecord block = new("LABEL"); + block.Entities.Add(new MText { Value = "Wide", InsertPoint = new XYZ(0, 0, 0), Height = 4, RectangleWidth = 30 }); + Insert insert = new(block) { InsertPoint = new XYZ(10, 10, 0), XScale = 2, YScale = 1 }; + + new EntityRenderDispatcher(configuration).Draw(EntityRenderDispatcherTests.CreateContext(surface, configuration), insert); + + SurfaceText run = Assert.Single(surface.Texts); + Assert.Equal(4d, run.Height, 9); + Assert.Equal(2d, run.WidthScale, 9); + Assert.Equal(60d, run.WrappingWidth, 9); + } + + [Theory] + [InlineData(TextHorizontalAlignment.Left, TextVerticalAlignmentType.Baseline, SurfaceTextAnchor.Start, SurfaceTextBaseline.Alphabetic, 1d)] + [InlineData(TextHorizontalAlignment.Center, TextVerticalAlignmentType.Baseline, SurfaceTextAnchor.Middle, SurfaceTextBaseline.Alphabetic, 9d)] + [InlineData(TextHorizontalAlignment.Right, TextVerticalAlignmentType.Top, SurfaceTextAnchor.End, SurfaceTextBaseline.Hanging, 9d)] + [InlineData(TextHorizontalAlignment.Middle, TextVerticalAlignmentType.Middle, SurfaceTextAnchor.Middle, SurfaceTextBaseline.Central, 9d)] + [InlineData(TextHorizontalAlignment.Left, TextVerticalAlignmentType.Bottom, SurfaceTextAnchor.Start, SurfaceTextBaseline.Alphabetic, 9d)] + // SurfaceTextAnchor and SurfaceTextBaseline are internal, and a public [Theory] method cannot + // declare a parameter less accessible than itself (CS0051), so they travel through InlineData + // boxed as object and are cast back inside the method body. + public void TextAlignmentMapsToAnchorBaselineAndOrigin(TextHorizontalAlignment horizontal, TextVerticalAlignmentType vertical, object anchor, object baseline, double expectedOriginX) + { + (RecordingDrawingSurface surface, ImageRenderContext context, EntityRenderDispatcher dispatcher) = Setup(); + TextEntity text = new() { Value = "T", InsertPoint = new XYZ(1, 0, 0), AlignmentPoint = new XYZ(9, 0, 0), HorizontalAlignment = horizontal, VerticalAlignment = vertical, Height = 2 }; + + dispatcher.Draw(context, text); + + SurfaceText run = Assert.Single(surface.Texts); + Assert.Equal((SurfaceTextAnchor)anchor, run.Anchor); + Assert.Equal((SurfaceTextBaseline)baseline, run.Baseline); + Assert.Equal(expectedOriginX, run.Origin.X, 9); + } + + [Theory] + [InlineData(AttachmentPointType.TopLeft, SurfaceTextAnchor.Start, SurfaceTextBaseline.Hanging)] + [InlineData(AttachmentPointType.TopCenter, SurfaceTextAnchor.Middle, SurfaceTextBaseline.Hanging)] + [InlineData(AttachmentPointType.MiddleRight, SurfaceTextAnchor.End, SurfaceTextBaseline.Central)] + [InlineData(AttachmentPointType.BottomCenter, SurfaceTextAnchor.Middle, SurfaceTextBaseline.Alphabetic)] + [InlineData(AttachmentPointType.BottomLeft, SurfaceTextAnchor.Start, SurfaceTextBaseline.Alphabetic)] + // See the comment on TextAlignmentMapsToAnchorBaselineAndOrigin: the internal enum parameters + // travel through InlineData as object to satisfy CS0051 on this public [Theory] method. + public void MTextAttachmentMapsToAnchorAndBaseline(AttachmentPointType attachment, object anchor, object baseline) + { + (RecordingDrawingSurface surface, ImageRenderContext context, EntityRenderDispatcher dispatcher) = Setup(); + MText text = new() { Value = "M", InsertPoint = new XYZ(5, 5, 0), Height = 3, AttachmentPoint = attachment, RectangleWidth = 40, LineSpacing = 1.5 }; + + dispatcher.Draw(context, text); + + SurfaceText run = Assert.Single(surface.Texts); + Assert.Equal((SurfaceTextAnchor)anchor, run.Anchor); + Assert.Equal((SurfaceTextBaseline)baseline, run.Baseline); + Assert.Equal(40d, run.WrappingWidth, 9); + Assert.Equal(1.5d, run.LineSpacingFactor, 9); + Assert.Equal(-1d, run.FixedLength); + } + + [Fact] + public void MTextWithoutRectangleWidthDoesNotWrap() + { + (RecordingDrawingSurface surface, ImageRenderContext context, EntityRenderDispatcher dispatcher) = Setup(); + + dispatcher.Draw(context, new MText { Value = "M", InsertPoint = new XYZ(5, 5, 0), Height = 3, RectangleWidth = 0 }); + + Assert.Equal(-1d, Assert.Single(surface.Texts).WrappingWidth); + } + + [Fact] + public void ControlCodesAreExpandedAndParagraphsBecomeLines() + { + (RecordingDrawingSurface surface, ImageRenderContext context, EntityRenderDispatcher dispatcher) = Setup(); + + dispatcher.Draw(context, new TextEntity { Value = "%%c20 %%d %%p1", InsertPoint = new XYZ(0, 0, 0), Height = 2 }); + dispatcher.Draw(context, new MText { Value = "First\\PSecond", InsertPoint = new XYZ(0, 0, 0), Height = 2 }); + + Assert.Equal(2, surface.Texts.Count); + Assert.Equal("Ø20 ° ±1", surface.Texts[0].Text); + Assert.Equal("First\nSecond", surface.Texts[1].Text); + } + + [Fact] + public void BlankTextDrawsNothing() + { + (RecordingDrawingSurface surface, ImageRenderContext context, EntityRenderDispatcher dispatcher) = Setup(); + + dispatcher.Draw(context, new TextEntity { Value = " ", InsertPoint = new XYZ(0, 0, 0), Height = 2 }); + dispatcher.Draw(context, new MText { Value = string.Empty, InsertPoint = new XYZ(0, 0, 0), Height = 2 }); + + Assert.Empty(surface.Texts); + Assert.Equal(4, surface.Calls.Count); // two Begin/End pairs, no DrawText + } + + [Fact] + public void MirroredPlaneTextKeepsReadableGlyphsAndOccupiesTheMirroredExtent() + { + (RecordingDrawingSurface surface, ImageRenderContext context, EntityRenderDispatcher dispatcher) = Setup(); + TextEntity text = new() { Value = "M", InsertPoint = new XYZ(10, 20, 0), Height = 2, Normal = new XYZ(0, 0, -1) }; + + dispatcher.Draw(context, text); + + SurfaceText run = Assert.Single(surface.Texts); + // (0,0,-1) mirrors X: the origin lands at x = -10, and the run is anchored at its end so it extends toward -x. + Assert.Equal(-10d, run.Origin.X, 9); + Assert.Equal(100d - 20d, run.Origin.Y, 9); + Assert.Equal(SurfaceTextAnchor.End, run.Anchor); + Assert.Equal(1d, Math.Cos(run.Rotation), 9); // upright: readable glyphs on the mirrored extent + Assert.Equal(0d, Math.Sin(run.Rotation), 9); + } + + [Fact] + public void MirroredPlaneRotationIsNegatedAndRightAlignmentBecomesStart() + { + (RecordingDrawingSurface surface, ImageRenderContext context, EntityRenderDispatcher dispatcher) = Setup(); + TextEntity text = new() { Value = "R", InsertPoint = new XYZ(1, 1, 0), AlignmentPoint = new XYZ(4, 1, 0), HorizontalAlignment = TextHorizontalAlignment.Right, Rotation = 0.5, Height = 2, Normal = new XYZ(0, 0, -1) }; + + dispatcher.Draw(context, text); + + SurfaceText run = Assert.Single(surface.Texts); + Assert.Equal(-4d, run.Origin.X, 9); + Assert.Equal(SurfaceTextAnchor.Start, run.Anchor); + Assert.Equal(Math.Cos(0.5), Math.Cos(run.Rotation), 9); + Assert.Equal(-Math.Sin(0.5), Math.Sin(run.Rotation), 9); + } + + [Fact] + public void FrontFacingTiltedPlaneProjectsOriginAndDirection() + { + (RecordingDrawingSurface surface, ImageRenderContext context, EntityRenderDispatcher dispatcher) = Setup(); + // Normal tilted toward +Y: the arbitrary axis algorithm makes the OCS X axis point along world -X. + TextEntity text = new() { Value = "T", InsertPoint = new XYZ(1, 0, 0), Height = 2, Normal = new XYZ(0, 0.6, 0.8) }; + + dispatcher.Draw(context, text); + + SurfaceText run = Assert.Single(surface.Texts); + Assert.Equal(-1d, run.Origin.X, 9); + Assert.Equal(SurfaceTextAnchor.Start, run.Anchor); // seen from the front: anchor unchanged + Assert.Equal(-1d, Math.Cos(run.Rotation), 9); // direction (1,0) in OCS is world -X + Assert.Equal(0d, Math.Sin(run.Rotation), 9); + } + + [Fact] + public void MiddleAnchorAndFixedLengthSurviveMirroring() + { + (RecordingDrawingSurface surface, ImageRenderContext context, EntityRenderDispatcher dispatcher) = Setup(scale: 2d); + TextEntity text = new() { Value = "F", InsertPoint = new XYZ(0, 0, 0), AlignmentPoint = new XYZ(30, 0, 0), HorizontalAlignment = TextHorizontalAlignment.Fit, Height = 5, Normal = new XYZ(0, 0, -1) }; + + dispatcher.Draw(context, text); + + SurfaceText run = Assert.Single(surface.Texts); + Assert.Equal(SurfaceTextAnchor.Middle, run.Anchor); + Assert.Equal(60d, run.FixedLength, 9); + Assert.Equal(-60d, run.Origin.X, 9); + } + + [Fact] + public void OrientLeavesFrontFacingPlacementsAlone() + { + TextRenderer.Placement front = new(new XY(0, 0), new XY(Math.Cos(0.7), Math.Sin(0.7)), Mirrored: false, Scale: 1d, WidthScale: 1d); + (double rotation, SurfaceTextAnchor anchor) = TextRenderer.Orient(front, SurfaceTextAnchor.End); + + Assert.Equal(0.7, rotation, 9); + Assert.Equal(SurfaceTextAnchor.End, anchor); + } + + [Fact] + public void PlaceDetectsMirroringAndEdgeOnPlanes() + { + TextRenderer.Placement? mirrored = TextRenderer.Place(null, new XYZ(0, 0, 0), new XYZ(-1, 0, 0), new XYZ(0, 1, 0)); + + Assert.True(mirrored!.Value.Mirrored); + Assert.Null(TextRenderer.Place(null, new XYZ(0, 0, 0), new XYZ(0, 0, 1), new XYZ(0, 1, 0))); + } + + [Fact] + public void EdgeOnPlaneTextIsSkippedWithWarning() + { + (RecordingDrawingSurface surface, ImageRenderContext context, EntityRenderDispatcher dispatcher) = Setup(); + List notifications = new(); + context.Configuration.OnNotification += (_, e) => notifications.Add(e); + TextEntity text = new() { Value = "T", InsertPoint = new XYZ(0, 0, 0), Height = 2, Normal = new XYZ(0, 1, 0), Rotation = 0 }; + + dispatcher.Draw(context, text); + + Assert.Empty(surface.Texts); + NotificationEventArgs notification = Assert.Single(notifications); + Assert.Equal(NotificationType.Warning, notification.NotificationType); + Assert.Contains("edge-on", notification.Message, StringComparison.Ordinal); + } + + [Fact] + public void UnicodeEscapesAndPercentCodesAreDecoded() + { + Assert.Equal("Ø 50 ´", TextRenderer.NormalizeText("\\U+00D8 50 \\u+00b4")); + Assert.Equal("Ø ° ± % under", TextRenderer.NormalizeText("%%c %%d %%p %%% %%uunder%%u")); + Assert.Equal("A\nB", TextRenderer.NormalizeText("A\\PB")); + Assert.Equal("U+12", TextRenderer.NormalizeText("U+12")); // not an escape without the backslash + Assert.Equal("\\U+12G4", TextRenderer.NormalizeText("\\U+12G4")); // not four hex digits: left alone + } + + [Fact] + public void MTextEscapesAreDecodedBeforeFormattingIsStripped() + { + MText mtext = new() { Value = "\\C10;\\fArial|b0|i0|;\\H1.5;A\\P\\U+00B4" }; + + Assert.Equal("A\n´", TextRenderer.PlainTextOf(mtext)); + + (RecordingDrawingSurface surface, ImageRenderContext context, EntityRenderDispatcher dispatcher) = Setup(); + dispatcher.Draw(context, new MText { Value = "\\U+00D8\\P\\U+2205", InsertPoint = new XYZ(0, 0, 0), Height = 2 }); + Assert.Equal("Ø\n∅", Assert.Single(surface.Texts).Text); + } + + [Fact] + public void AMultiLineAttributeIsDrawnFromItsEmbeddedMTextNotItsSingleLineValue() + { + RecordingDrawingSurface surface = new(); + ImageConfiguration configuration = new(); + AttributeEntity attribute = new() + { + Tag = "ROOM", + Value = "WRONG", + AttributeType = AttributeType.MultiLine, + InsertPoint = new XYZ(0, 0, 0), + Height = 3, + MText = new MText { Value = "Line1\\PLine2", InsertPoint = new XYZ(10, 20, 0), Height = 4, RectangleWidth = 30 }, + }; + + new EntityRenderDispatcher(configuration).Draw(EntityRenderDispatcherTests.CreateContext(surface, configuration), attribute); + + SurfaceText run = Assert.Single(surface.Texts); + Assert.Contains("Line1", run.Text); + Assert.Contains("Line2", run.Text); + Assert.DoesNotContain("WRONG", run.Text); + Assert.Equal(4d, run.Height, 9); + Assert.Equal(30d, run.WrappingWidth, 9); + Assert.Equal(new SurfacePoint(10, 80), run.Origin); + } + + [Fact] + public void AMultiLineAttributeKeepsTheAttributeAsTheObservableEntity() + { + // Guards the dispatcher's BeginEntity structure, not this task's switch arm specifically: BeginEntity is + // called with the outer entity parameter before the switch runs, for every arm, so this assertion would + // pass even against the pre-fix code that drew the single-line value. It stays as documentation that the + // observable entity must never change no matter which arm draws the geometry. + RecordingDrawingSurface surface = new(); + ImageConfiguration configuration = new(); + AttributeEntity attribute = new() + { + Tag = "ROOM", + Value = "WRONG", + AttributeType = AttributeType.MultiLine, + MText = new MText { Value = "A", InsertPoint = new XYZ(1, 1, 0), Height = 2 }, + }; + + new EntityRenderDispatcher(configuration).Draw(EntityRenderDispatcherTests.CreateContext(surface, configuration), attribute); + + Assert.Equal("ATTRIB", Assert.Single(surface.Entities).EntityType); + } + + [Fact] + public void AMultiLineAttributeWithoutAnEmbeddedMTextFallsBackToItsValueWithAWarning() + { + RecordingDrawingSurface surface = new(); + ImageConfiguration configuration = new(); + List notifications = new(); + configuration.OnNotification += (_, e) => notifications.Add(e); + AttributeEntity attribute = new() + { + Tag = "ROOM", + Value = "FALLBACK", + AttributeType = AttributeType.MultiLine, + InsertPoint = new XYZ(5, 5, 0), + Height = 2, + }; + + new EntityRenderDispatcher(configuration).Draw(EntityRenderDispatcherTests.CreateContext(surface, configuration), attribute); + + Assert.Equal("FALLBACK", Assert.Single(surface.Texts).Text); + Assert.Contains(notifications, n => n.NotificationType == NotificationType.Warning && n.Message.Contains("multi-line layout", StringComparison.OrdinalIgnoreCase)); + } + + [Fact] + public void ASingleLineAttributeStillTakesTheTextPath() + { + RecordingDrawingSurface surface = new(); + ImageConfiguration configuration = new(); + AttributeEntity attribute = new() + { + Tag = "ROOM", + Value = "A-101", + InsertPoint = new XYZ(2, 2, 0), + Height = 2, + MText = new MText { Value = "IGNORED", InsertPoint = new XYZ(50, 50, 0), Height = 9 }, + }; + + new EntityRenderDispatcher(configuration).Draw(EntityRenderDispatcherTests.CreateContext(surface, configuration), attribute); + + SurfaceText run = Assert.Single(surface.Texts); + Assert.Equal("A-101", run.Text); + Assert.Equal(2d, run.Height, 9); + } +} diff --git a/ACadSharp.Image.Tests/ViewportParityTests.cs b/ACadSharp.Image.Tests/ViewportParityTests.cs new file mode 100644 index 0000000..027177b --- /dev/null +++ b/ACadSharp.Image.Tests/ViewportParityTests.cs @@ -0,0 +1,119 @@ +using System.Xml.Linq; +using ACadSharp.Image.Rendering.Svg; +using ACadSharp.Objects; +using SixLabors.Fonts; +using SixLabors.ImageSharp.PixelFormats; + +namespace ACadSharp.Image.Tests; + +/// Renders a code-built sheet with one viewport and compares it with its PNG baseline and SVG golden. +public sealed class ViewportParityTests +{ + private const string FontFamily = "DejaVu Sans"; + private static readonly XNamespace Ns = SvgDrawingSurface.Ns; + + private static ImageExporter SheetExporter(out CadDocument document) + { + document = SyntheticSamples.ViewportSheet(); + ImageExporter exporter = new(); + exporter.Configuration.Width = 800; + exporter.Configuration.Height = 500; + exporter.Configuration.SetPadding(10); + exporter.Configuration.FontFamilyName = FontFamily; + // Viewport-scoped layer freezing is opt-in (LayerVisibilityMode.All is the default and draws frozen layers + // too); Screen is the mode that also honours Viewport.FrozenLayers per EntityVisibilityFilter.IsVisible. + exporter.Configuration.LayerVisibility = LayerVisibilityMode.Screen; + Layout sheet = document.Layouts.First(l => l.Name == "Sheet"); + exporter.Add(sheet); + return exporter; + } + + [Fact] + public void SheetRoundTripKeepsTheViewport() + { + ImageExporter exporter = SheetExporter(out CadDocument document); + ImagePage page = Assert.Single(exporter.Pages); + + Assert.Equal(2, page.Entities.Count); // frame line and title; the viewport itself is not an entity of the page + Assert.Single(page.Viewports); + Assert.Equal(2d, page.Viewports[0].ScaleFactor, 9); + Assert.Contains(page.Viewports[0].FrozenLayers, l => l.Name == "Hidden"); + Assert.Equal(1, (int)document.Header.PaperSpaceLineTypeScaling); // PSLTSCALE default: dashes at page scale + } + + [Fact] + public void SheetPngMatchesBaseline() + { + Assert.True(SystemFonts.TryGet(FontFamily, out _), $"Font '{FontFamily}' must be installed for parity tests."); + ImageExporter exporter = SheetExporter(out _); + + using RenderedImagePage page = Assert.IsType(Assert.Single(exporter.Render())); + + GoldenAssert.Png("viewport-sheet.paper.01", page.Canvas); + + // PIXEL PROBES, measured against the generated baseline (see the task report for the derivation): + // - x in [300,500], y in [112,116]: the viewport's top edge is at canvas y≈113 (paper y=45 -> 10 + 45*2.2857); + // model y=60 maps to viewport-local y≈0.7, so an un-frozen "Hidden" line would land on rows 113-114. The + // window scan confirms every pixel in that band is background, i.e. that Hidden stays out of the raster + // (the grid line sits at y≈250 and the circle spans y≈159-342, so the window is otherwise clean too). + // - (171, 200): a point on the left wall (model x=0, Walls layer, red), well clear of its corners. + // The model bottom wall (my=0) coincides with the viewport's lower edge; before the flip-origin fix the + // rounded flip origin shifted content down and pushed it out of the raster image, so it could not serve + // as a probe. With BottomY now the exact height, the bottom wall renders and is probed below. + Rgba32 white = new(255, 255, 255, 255); + for (int y = 112; y <= 116; y++) + { + for (int x = 300; x <= 500; x++) + { + Assert.Equal(white, page.Canvas[x, y]); + } + } + + Assert.NotEqual(white, page.Canvas[171, 200]); + + // The bottom wall lies exactly on the view's lower edge; before the flip-origin fix it fell outside the viewport image. + Rgba32 bottomWall = page.Canvas[400, 387]; + Assert.True(bottomWall.R > 200 && bottomWall.G < 100 && bottomWall.B < 100, $"expected a red pixel on the bottom wall, got {bottomWall}"); + } + + [Fact] + public void SheetSvgMatchesGoldenAndClipsTheViewport() + { + ImageExporter exporter = SheetExporter(out _); + + RenderedSvgPage page = Assert.IsType(Assert.Single(exporter.Render(ImageExportFormat.Svg))); + GoldenAssert.Svg("viewport-sheet.paper.01", page.Content); + + XDocument document = XDocument.Parse(page.Content); + XElement clip = Assert.Single(document.Descendants(Ns + "clipPath")); + Assert.Equal("clip-1", (string?)clip.Attribute("id")); + XElement viewportGroup = Assert.Single(document.Descendants(Ns + "g"), g => (string?)g.Attribute("class") == "cad-viewport"); + Assert.Equal("url(#clip-1)", (string?)viewportGroup.Attribute("clip-path")); + + // Layer groups inside the viewport carry the clip-scoped ids; the frozen layer is absent altogether. + List layerIds = viewportGroup.Descendants(Ns + "g").Where(g => (string?)g.Attribute("class") == "cad-layer").Select(g => (string)g.Attribute("id")!).ToList(); + Assert.Contains("clip-1-layer-walls", layerIds); + Assert.Contains("clip-1-layer-grid", layerIds); + Assert.DoesNotContain(layerIds, id => id.Contains("hidden", StringComparison.Ordinal)); + Assert.DoesNotContain(document.Descendants(), e => (string?)e.Attribute("data-layer") == "Hidden"); + + // The grid line is dashed, at page scale (PSLTSCALE 1): 5 and 2.5 drawing units times the fit scale, in pixels. + XElement gridLine = Assert.Single(viewportGroup.Descendants(Ns + "line"), l => (string?)l.Parent!.Attribute("data-layer") == "Grid"); + string[] dashes = ((string)gridLine.Attribute("stroke-dasharray")!).Split(' '); + Assert.Equal(2, dashes.Length); + Assert.Equal(2d, double.Parse(dashes[0], System.Globalization.CultureInfo.InvariantCulture) / double.Parse(dashes[1], System.Globalization.CultureInfo.InvariantCulture), 3); + // Naming the fit scale directly catches a model-scale regression that a byte-identical golden alone would + // only report as an opaque diff. Mirrors ImageRenderContext.ComputeSvgFitScale: the smaller of the + // width-constrained and height-constrained fits (800x500 canvas, 10px padding on every side, 297x210 paper) - + // the 210-unit paper height is the binding constraint here, not the 297-unit width. + double widthFit = (800d - (2 * 10)) / 297d; + double heightFit = (500d - (2 * 10)) / 210d; + double fitScale = Math.Min(widthFit, heightFit); + Assert.Equal(5d * fitScale, double.Parse(dashes[0], System.Globalization.CultureInfo.InvariantCulture), 3); + + // The circle keeps its native form inside the viewport, and the page-level frame line and title sit outside it. + Assert.Single(viewportGroup.Descendants(Ns + "circle")); + Assert.Single(document.Descendants(Ns + "text")); + Assert.DoesNotContain(viewportGroup.Descendants(Ns + "text"), _ => true); + } +} diff --git a/ACadSharp.Image/ACadSharp.Image.csproj b/ACadSharp.Image/ACadSharp.Image.csproj index ff60a2a..6b4c84c 100644 --- a/ACadSharp.Image/ACadSharp.Image.csproj +++ b/ACadSharp.Image/ACadSharp.Image.csproj @@ -1,14 +1,14 @@  - net6.0;net8.0;net10.0 + net8.0;net10.0 net10.0 obj\publish-aot\ true ACadSharp.Image ACadSharp.Image - Raster image exporter for ACadSharp DXF and DWG documents using SixLabors.ImageSharp. - acadsharp;cad;dxf;dwg;imagesharp;rendering + Raster and SVG exporter for ACadSharp DXF and DWG documents. + acadsharp;cad;dxf;dwg;imagesharp;svg;rendering README.md true diff --git a/ACadSharp.Image/Extensions/ColorExtensions.cs b/ACadSharp.Image/Extensions/ColorExtensions.cs index e0b27ac..0ecd75c 100644 --- a/ACadSharp.Image/Extensions/ColorExtensions.cs +++ b/ACadSharp.Image/Extensions/ColorExtensions.cs @@ -10,20 +10,21 @@ internal static class ColorExtensions { /// /// AutoCAD color index 7 represents "ByBackground" (white on dark backgrounds, black on light backgrounds). - /// We default to black for rendering purposes. /// private const short ByBackgroundIndex = 7; /// - /// Converts an AutoCAD to an ImageSharp . + /// Converts an AutoCAD to an ImageSharp , resolving color + /// index 7 ("ByBackground") to the given color. /// /// The AutoCAD color to convert. + /// The color to use for index 7 ("ByBackground"). /// The corresponding ImageSharp color. - public static ImageColor ToImageColor(this CadColor color) + public static ImageColor ToImageColor(this CadColor color, ImageColor foreground) { if (color.Index == ByBackgroundIndex) { - return ImageColor.Black; + return foreground; } return ImageColor.FromRgb(color.R, color.G, color.B); diff --git a/ACadSharp.Image/ImageConfiguration.cs b/ACadSharp.Image/ImageConfiguration.cs index 9a12c70..9ce9662 100644 --- a/ACadSharp.Image/ImageConfiguration.cs +++ b/ACadSharp.Image/ImageConfiguration.cs @@ -105,18 +105,27 @@ public sealed class ImageConfiguration private readonly HashSet _hiddenLayers = new(StringComparer.OrdinalIgnoreCase); + private readonly HashSet _includedLayers = new(StringComparer.OrdinalIgnoreCase); + private readonly Dictionary _lineWeightValues = new(); private readonly IReadOnlySet _readOnlyHiddenLayers; + private readonly IReadOnlySet _readOnlyIncludedLayers; + private readonly ReadOnlyDictionary _readOnlyLineWeightValues; + private float _minimumDashPixels = 2f; + + private int _maxHatchLines = 20000; + /// /// Initializes a new instance of the class. /// public ImageConfiguration() { this._readOnlyHiddenLayers = new ReadOnlySet(this._hiddenLayers); + this._readOnlyIncludedLayers = new ReadOnlySet(this._includedLayers); this._readOnlyLineWeightValues = new ReadOnlyDictionary(this._lineWeightValues); } @@ -133,7 +142,7 @@ public ImageConfiguration() /// Gets or sets the dots-per-inch resolution used when converting drawing units to pixels. /// /// - /// This value affects line weight calculations and text sizing. + /// This value affects line weight calculations only; text is sized from the drawing on both backends. /// Default is 96 DPI. /// public float Dpi { get; set; } = 96f; @@ -204,18 +213,62 @@ public int PaddingLeft /// Gets or sets the font family name used for rendering text entities. /// /// - /// The font must be available on the system. If the specified font is not found, - /// the system's default font family is used as a fallback. + /// If the family is not installed, the first installed family of the fallback chain FontResolver.Fallbacks + /// (Liberation Sans, DejaVu Sans, Arial, Helvetica, Noto Sans, Segoe UI) is used, then the first installed + /// family; when no font is installed, raster text is skipped with a warning and SVG text is emitted unwrapped. /// Default is "Arial". /// public string FontFamilyName { get; set; } = "Arial"; + /// + /// Gets the settings that only affect SVG output. + /// + public SvgOptions Svg { get; } = new(); + /// /// Gets the set of layer names that should be hidden during export. /// Layer names are case-insensitive. /// public IReadOnlySet HiddenLayers => this._readOnlyHiddenLayers; + /// + /// Gets or sets how layer state (on/off, frozen, plottable) affects rendering. Default . + /// + public LayerVisibilityMode LayerVisibility { get; set; } = LayerVisibilityMode.All; + + /// + /// Gets the layers to render when the set is not empty; all other layers are skipped. Applied before . Case-insensitive. + /// + public IReadOnlySet IncludedLayers => this._readOnlyIncludedLayers; + + /// + /// Gets or sets the colour used for AutoCAD colour index 7 ("white/black by background"). Null (default) picks black or white from the luminance of . + /// + public ImageColor? ForegroundColor { get; set; } + + /// + /// Gets or sets the pattern length in pixels below which dashed linetypes are drawn solid. Default 2. + /// + public float MinimumDashPixels + { + get => this._minimumDashPixels; + set => this._minimumDashPixels = value >= 0f ? value : throw new ArgumentOutOfRangeException(nameof(value), "Minimum dash length must be zero or greater."); + } + + /// + /// Gets or sets the maximum number of pattern lines drawn per hatch; beyond it a warning is raised and the remainder is skipped. Default 20000. + /// + /// + /// The same limit is applied before the pattern is expanded: ACadSharp builds every pattern line of a hatch up + /// front, so a hatch whose pattern would need more scan lines than this is skipped entirely, with a warning, + /// instead of being expanded and then truncated. + /// + public int MaxHatchLines + { + get => this._maxHatchLines; + set => this._maxHatchLines = value > 0 ? value : throw new ArgumentOutOfRangeException(nameof(value), "Maximum hatch lines must be greater than zero."); + } + /// /// Gets or sets the JPEG output quality as a percentage. /// @@ -298,11 +351,7 @@ public int Height /// public float GetLineWeightPixels(LineWeightType lineWeight) { - double millimeters = this._lineWeightValues.TryGetValue(lineWeight, out double configured) - ? configured - : LineWeightDefaultValues.TryGetValue(lineWeight, out double fallback) - ? fallback - : 0d; + double millimeters = this.GetLineWeightMillimeters(lineWeight); if (millimeters <= 0d) { @@ -313,6 +362,18 @@ public float GetLineWeightPixels(LineWeightType lineWeight) return Math.Max(1f, pixels * this.LineWeightScale); } + /// + /// Gets the configured millimetre value for a line weight (overrides first, then , else 0). + /// + public double GetLineWeightMillimeters(LineWeightType lineWeight) + { + return this._lineWeightValues.TryGetValue(lineWeight, out double configured) + ? configured + : LineWeightDefaultValues.TryGetValue(lineWeight, out double fallback) + ? fallback + : 0d; + } + /// /// Applies the same padding to all four sides of the output canvas. /// @@ -390,6 +451,49 @@ public void ClearHiddenLayers() this._hiddenLayers.Clear(); } + /// + /// Adds the specified layer to the include list. + /// + /// The layer name to include. + public void IncludeLayer(string layerName) + { + ThrowIfNullOrWhiteSpace(layerName); + this._includedLayers.Add(layerName); + } + + /// + /// Adds the specified layers to the include list. + /// + /// The layer names to include. + public void IncludeLayers(IEnumerable layerNames) + { + ArgumentNullException.ThrowIfNull(layerNames); + + foreach (string layerName in layerNames) + { + this.IncludeLayer(layerName); + } + } + + /// + /// Removes the specified layer from the include list. + /// + /// The layer name to exclude. + /// if the layer was removed; otherwise, . + public bool ExcludeLayer(string layerName) + { + ThrowIfNullOrWhiteSpace(layerName); + return this._includedLayers.Remove(layerName); + } + + /// + /// Clears the include list. + /// + public void ClearIncludedLayers() + { + this._includedLayers.Clear(); + } + /// /// Sets a custom line weight override in millimeters. /// @@ -428,6 +532,27 @@ internal void Notify(string message, NotificationType notificationType, Exceptio this.OnNotification?.Invoke(this, new NotificationEventArgs(message, notificationType, ex)); } + /// + /// Colour used for AutoCAD colour index 7: when set, else black on light or transparent + /// backgrounds and white on dark ones. + /// + internal ImageColor ResolveForegroundColor() + { + if (this.ForegroundColor is ImageColor explicitColor) + { + return explicitColor; + } + + SixLabors.ImageSharp.PixelFormats.Rgba32 background = this.BackgroundColor.ToPixel(); + if (background.A == 0) + { + return ImageColor.Black; + } + + double luminance = (0.299d * background.R) + (0.587d * background.G) + (0.114d * background.B); + return luminance < 128d ? ImageColor.White : ImageColor.Black; + } + private static int ValidateNonNegative(int value, string propertyName) { return value >= 0 diff --git a/ACadSharp.Image/ImageExportFormat.cs b/ACadSharp.Image/ImageExportFormat.cs index 8c8fda4..2d1e002 100644 --- a/ACadSharp.Image/ImageExportFormat.cs +++ b/ACadSharp.Image/ImageExportFormat.cs @@ -31,5 +31,10 @@ public enum ImageExportFormat /// WebP format. Modern format supporting lossy and lossless compression, /// transparency, and excellent compression ratios. /// - Webp + Webp, + + /// + /// Scalable Vector Graphics. Resolution independent, keeps layers as groups and text as text. + /// + Svg, } diff --git a/ACadSharp.Image/ImageExportFormatExtensions.cs b/ACadSharp.Image/ImageExportFormatExtensions.cs index 694fe68..96afabe 100644 --- a/ACadSharp.Image/ImageExportFormatExtensions.cs +++ b/ACadSharp.Image/ImageExportFormatExtensions.cs @@ -29,6 +29,7 @@ public static string GetFileExtension(this ImageExportFormat format) ImageExportFormat.Jpeg => ".jpg", ImageExportFormat.Gif => ".gif", ImageExportFormat.Webp => ".webp", + ImageExportFormat.Svg => ".svg", _ => ".png", }; } @@ -74,6 +75,7 @@ public static bool TryParse(string? value, out ImageExportFormat format) "jpg" or "jpeg" => Success(ImageExportFormat.Jpeg, out format), "gif" => Success(ImageExportFormat.Gif, out format), "webp" => Success(ImageExportFormat.Webp, out format), + "svg" => Success(ImageExportFormat.Svg, out format), _ => Failure(out format), }; } diff --git a/ACadSharp.Image/ImageExporter.cs b/ACadSharp.Image/ImageExporter.cs index 9630203..b514bf6 100644 --- a/ACadSharp.Image/ImageExporter.cs +++ b/ACadSharp.Image/ImageExporter.cs @@ -4,17 +4,11 @@ using ACadSharp.Objects; using ACadSharp.Tables; using ACadSharp.Image.Rendering; -using SixLabors.ImageSharp; -using SixLabors.ImageSharp.Formats.Bmp; -using SixLabors.ImageSharp.Formats.Gif; -using SixLabors.ImageSharp.Formats.Jpeg; -using SixLabors.ImageSharp.Formats.Png; -using SixLabors.ImageSharp.Formats.Webp; namespace ACadSharp.Image; /// -/// Exports CAD drawings to raster images in various formats. +/// Exports CAD drawings to raster images or SVG. /// /// /// The is the main entry point for exporting CAD content to images. @@ -99,6 +93,11 @@ public void Add(IEnumerable layouts) /// Adds a single layout to the exporter. /// /// The layout to add. + /// + /// Layer filters and visibility settings are applied when rendering, so all entities are kept on the page. + /// Paper entities and viewports are taken from one sorted pass over the layout's block, so the page keeps + /// the drawing's draw order between them. + /// public void Add(Layout layout) { ArgumentNullException.ThrowIfNull(layout); @@ -107,24 +106,23 @@ public void Add(Layout layout) { Layout = layout, Name = SanitizeFileName(layout.Name), + Document = layout.Document, }; - foreach (Entity entity in layout.AssociatedBlock.Entities) + foreach (Entity entity in layout.AssociatedBlock.GetSortedEntities()) { - if (this.ShouldIncludeEntity(entity)) + if (entity is Viewport viewport) { - page.AddEntity(entity); - } - } + // The paper viewport is the sheet itself, not a window onto model space. + if (!viewport.RepresentsPaper) + { + page.AddViewport(viewport); + } - foreach (Viewport viewport in layout.Viewports) - { - if (viewport.RepresentsPaper) - { continue; } - page.AddViewport(viewport); + page.AddEntity(entity); } this._pages.Add(page); @@ -134,6 +132,9 @@ public void Add(Layout layout) /// Adds a block record to the exporter as a single page. /// /// The block record to add. + /// + /// Layer filters and visibility settings are applied when rendering, so all entities are kept on the page. + /// public void Add(BlockRecord block) { ArgumentNullException.ThrowIfNull(block); @@ -141,53 +142,38 @@ public void Add(BlockRecord block) ImagePage page = new() { Name = SanitizeFileName(block.Name), + Document = block.Document, }; - page.Add(block, this.ShouldIncludeEntity); + page.Add(block, ShouldIncludeEntity); this._pages.Add(page); } - private bool ShouldIncludeEntity(Entity entity) - { - if (entity is Viewport) - { - return false; - } - - return !this.IsHiddenLayer(entity); - } - - private bool IsHiddenLayer(Entity entity) - { - if (this.Configuration.HiddenLayers.Count == 0) - { - return false; - } - - string? layerName = entity.Layer?.Name; - if (string.IsNullOrEmpty(layerName)) - { - return false; - } - - return this.Configuration.HiddenLayers.Contains(layerName); - } + /// + /// Viewports are added through , never as page entities. + /// + /// The entity being considered. + /// True when the entity belongs on the page. + private static bool ShouldIncludeEntity(Entity entity) => entity is not Viewport; /// - /// Renders all added pages to image format without saving to disk. + /// Renders all added pages without saving to disk. /// - /// A list of rendered image pages. + /// Output format the pages will be saved as. Defaults to PNG. + /// Rendered pages; dispose each when finished. /// - /// The returned pages must be disposed after use to free resources. - /// This method is useful for custom processing or testing without file I/O. + /// 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. /// - public IReadOnlyList Render() + public IReadOnlyList Render(ImageExportFormat format = ImageExportFormat.Png) { ImagePageRenderer renderer = new(this.Configuration); - RenderedImagePage[] pages = new RenderedImagePage[this._pages.Count]; + RenderedPage[] pages = new RenderedPage[this._pages.Count]; for (int i = 0; i < this._pages.Count; i++) { - pages[i] = renderer.Render(this._pages[i]); + pages[i] = renderer.Render(this._pages[i], format); } return pages; @@ -196,16 +182,11 @@ public IReadOnlyList Render() /// /// Renders all added pages and saves the output to the specified path. /// - /// The file path to save the image to. - /// The image format to use. Defaults to PNG. + /// A file path when there is one page, or a directory when there are several. + /// The output format. Defaults to PNG. public void Save(string outputPath, ImageExportFormat format = ImageExportFormat.Png) { - this.SaveInternal(outputPath, format); - } - - private void SaveInternal(string outputPath, ImageExportFormat format) - { - IReadOnlyList pages = this.Render(); + IReadOnlyList pages = this.Render(format); try { @@ -219,8 +200,7 @@ private void SaveInternal(string outputPath, ImageExportFormat format) if (pages.Count == 1 && !string.IsNullOrWhiteSpace(extension)) { - Directory.CreateDirectory(Path.GetDirectoryName(fullPath)!); - this.SavePage(pages[0], fullPath, format); + pages[0].Save(fullPath); return; } @@ -232,46 +212,20 @@ private void SaveInternal(string outputPath, ImageExportFormat format) ? "page" : Path.GetFileNameWithoutExtension(fullPath); - Directory.CreateDirectory(directory); - for (int i = 0; i < pages.Count; i++) { - string pagePath = Path.Combine(directory, $"{prefix}-{i + 1:D2}-{pages[i].Name}{format.GetFileExtension()}"); - this.SavePage(pages[i], pagePath, format); + pages[i].Save(Path.Combine(directory, $"{prefix}-{i + 1:D2}-{pages[i].Name}{format.GetFileExtension()}")); } } finally { - foreach (RenderedImagePage page in pages) + foreach (RenderedPage page in pages) { page.Dispose(); } } } - private void SavePage(RenderedImagePage page, string path, ImageExportFormat format) - { - switch (format) - { - case ImageExportFormat.Bmp: - page.Canvas.Save(path, new BmpEncoder()); - break; - case ImageExportFormat.Jpeg: - page.Canvas.Save(path, new JpegEncoder { Quality = this.Configuration.OutputQuality }); - break; - case ImageExportFormat.Gif: - page.Canvas.Save(path, new GifEncoder()); - break; - case ImageExportFormat.Webp: - page.Canvas.Save(path, new WebpEncoder { Quality = this.Configuration.OutputQuality }); - break; - case ImageExportFormat.Png: - default: - page.Canvas.Save(path, new PngEncoder()); - break; - } - } - private static string SanitizeFileName(string? value) { if (string.IsNullOrWhiteSpace(value)) diff --git a/ACadSharp.Image/ImagePage.cs b/ACadSharp.Image/ImagePage.cs index 3d66b00..dee33b2 100644 --- a/ACadSharp.Image/ImagePage.cs +++ b/ACadSharp.Image/ImagePage.cs @@ -1,5 +1,6 @@ using System.Collections.ObjectModel; using ACadSharp.Entities; +using ACadSharp.Image.Rendering; using ACadSharp.Objects; using ACadSharp.Tables; using CSMath; @@ -15,6 +16,8 @@ public sealed class ImagePage private readonly List _viewports = []; + private readonly List _drawSequence = []; + private readonly ReadOnlyCollection _readOnlyEntities; private readonly ReadOnlyCollection _readOnlyViewports; @@ -39,6 +42,16 @@ public sealed class ImagePage /// public IReadOnlyList Viewports => this._readOnlyViewports; + /// + /// Gets the entities and viewports in the order they were added, which is the order they are drawn in. + /// + internal IReadOnlyList DrawSequence => this._drawSequence; + + /// + /// Gets or sets the document the page content came from, when known. Used for header settings such as units and linetype scale. + /// + public CadDocument? Document { get; set; } + /// /// Gets or sets the translation offset applied to the page content. /// @@ -49,6 +62,12 @@ public sealed class ImagePage /// internal PlotPaperUnits PaperUnits => PlotPaperUnits.Pixels; + /// + /// Gets a value indicating whether the page size came from rather than from a layout's paper size. + /// Auto-sized pages are re-framed on the visible entities at render time. + /// + internal bool AutoSized { get; private set; } + /// /// Initializes a new instance of the class. /// @@ -74,13 +93,20 @@ public void Add(BlockRecord block, bool resizeLayout = true) /// The block record to add entities from. /// Optional predicate to filter entities. Return true to include the entity. /// Whether to automatically calculate layout bounds. Defaults to true. + /// + /// Entities are added in the drawing's draw order (handle order, overridden by the block's DRAWORDER table), + /// so later entities paint over earlier ones (in SVG, within each layer group; layer grouping comes first). + /// The order is the page's own: the contents of a block reference are drawn in the block's stored order at the + /// first nesting level; deeper levels come back in handle order (ACadSharp's block clone does not preserve the + /// stored order below the first level). + /// public void Add(BlockRecord block, Func? entityFilter, bool resizeLayout = true) { ArgumentNullException.ThrowIfNull(block); if (entityFilter != null) { - foreach (Entity entity in block.Entities) + foreach (Entity entity in block.GetSortedEntities()) { if (entityFilter(entity)) { @@ -90,7 +116,7 @@ public void Add(BlockRecord block, Func? entityFilter, bool resize } else { - foreach (Entity entity in block.Entities) + foreach (Entity entity in block.GetSortedEntities()) { this.AddEntity(entity); } @@ -110,6 +136,7 @@ public void AddEntity(Entity entity) { ArgumentNullException.ThrowIfNull(entity); this._entities.Add(entity); + this._drawSequence.Add(entity); } /// @@ -120,16 +147,37 @@ public void AddViewport(Viewport viewport) { ArgumentNullException.ThrowIfNull(viewport); this._viewports.Add(viewport); + this._drawSequence.Add(viewport); } /// /// Updates the layout size based on the bounding box of all entities on this page. /// + /// + /// When no entity has finite bounds, and the layout size are left unchanged. + /// public void UpdateLayoutSize() + { + if (this.ComputeFrame(null) is PageFrame frame) + { + this.Translation = frame.Translation; + this.Layout ??= frame.Layout; + this.Layout.PaperWidth = frame.PaperWidth; + this.Layout.PaperHeight = frame.PaperHeight; + this.AutoSized = true; + } + } + + /// + /// Computes the frame that fits the entities the predicate accepts, without changing the page. + /// + /// Predicate selecting the entities to frame, or null to frame every entity. + /// The fitted frame, or null when no selected entity has finite bounds. + internal PageFrame? ComputeFrame(Func? include) { if (this._entities.Count == 0) { - return; + return null; } bool hasValidBounds = false; @@ -142,9 +190,20 @@ public void UpdateLayoutSize() foreach (Entity entity in this._entities) { - BoundingBox boundingBox = entity.GetBoundingBox(); - if (double.IsNaN(boundingBox.Min.X) || double.IsNaN(boundingBox.Min.Y) || - double.IsNaN(boundingBox.Max.X) || double.IsNaN(boundingBox.Max.Y)) + if (include != null && !include(entity)) + { + continue; + } + + if (!EntityBounds.TryGet(entity, out BoundingBox boundingBox)) + { + continue; + } + + // NaN and infinity both occur in the wild (Samples/6-57-1119.dxf has an ARC with an infinite radius) + // and either would poison the page size. + if (!double.IsFinite(boundingBox.Min.X) || !double.IsFinite(boundingBox.Min.Y) || !double.IsFinite(boundingBox.Min.Z) || + !double.IsFinite(boundingBox.Max.X) || !double.IsFinite(boundingBox.Max.Y) || !double.IsFinite(boundingBox.Max.Z)) { continue; } @@ -171,15 +230,17 @@ public void UpdateLayoutSize() if (!hasValidBounds) { - return; + return null; } BoundingBox limits = new(minX, minY, minZ, maxX, maxY, maxZ); - this.Translation = -(XY)limits.Min; + XY translation = -(XY)limits.Min; limits = limits.Move(-limits.Min); - this.Layout ??= new Layout("default_page"); - this.Layout.PaperWidth = Math.Max(1d, limits.Max.X); - this.Layout.PaperHeight = Math.Max(1d, limits.Max.Y); + return new PageFrame( + this.Layout ?? new Layout("default_page"), + translation, + Math.Max(1d, limits.Max.X), + Math.Max(1d, limits.Max.Y)); } } diff --git a/ACadSharp.Image/LayerVisibilityMode.cs b/ACadSharp.Image/LayerVisibilityMode.cs new file mode 100644 index 0000000..27d6e6c --- /dev/null +++ b/ACadSharp.Image/LayerVisibilityMode.cs @@ -0,0 +1,16 @@ +namespace ACadSharp.Image; + +/// +/// Controls how layer and entity state in the drawing affects what is rendered. +/// +public enum LayerVisibilityMode +{ + /// Render everything regardless of layer state. This is the default and matches earlier versions. + All, + + /// Hide entities on layers that are off or frozen, entities flagged invisible, and layers frozen in the current viewport. + Screen, + + /// plus hide entities on non-plottable layers. + Plot, +} diff --git a/ACadSharp.Image/RenderedImagePage.cs b/ACadSharp.Image/RenderedImagePage.cs index 65bf9cc..a5ded1d 100644 --- a/ACadSharp.Image/RenderedImagePage.cs +++ b/ACadSharp.Image/RenderedImagePage.cs @@ -1,55 +1,69 @@ +using SixLabors.ImageSharp.Formats.Bmp; +using SixLabors.ImageSharp.Formats.Gif; +using SixLabors.ImageSharp.Formats.Jpeg; +using SixLabors.ImageSharp.Formats.Png; +using SixLabors.ImageSharp.Formats.Webp; using Rgba32 = SixLabors.ImageSharp.PixelFormats.Rgba32; namespace ACadSharp.Image; /// -/// Represents a single rendered page (layout or model space view) as an image. +/// A page rendered to a raster canvas. /// /// -/// -/// Instances of this class are produced by and wrap -/// a canvas along with a descriptive name. -/// -/// -/// This class implements and owns the underlying image buffer. -/// Callers must dispose of the instance when finished to release unmanaged resources. -/// +/// Owns the underlying ; dispose the page to release it. /// -public sealed class RenderedImagePage : IDisposable +public sealed class RenderedImagePage : RenderedPage { - /// - /// Gets the name of this page (e.g., layout name or "Model"). - /// - public string Name { get; } - - /// - /// Gets the rendered image canvas. - /// - /// - /// The canvas is a 32-bit RGBA image. It should not be modified after - /// the page has been rendered. - /// - public SixLabors.ImageSharp.Image Canvas { get; } + private readonly int _quality; /// /// Initializes a new instance of the class. /// - /// The name of the page. - /// The rendered image canvas. - public RenderedImagePage(string name, SixLabors.ImageSharp.Image canvas) + /// Page name. + /// Rendered canvas; ownership transfers to the page. + /// Raster format used by . Must not be . + /// Quality 1..100 for lossy formats. + public RenderedImagePage(string name, SixLabors.ImageSharp.Image canvas, ImageExportFormat format = ImageExportFormat.Png, int quality = 90) + : base(name, format) { - this.Name = name; this.Canvas = canvas; + this._quality = quality; } /// - /// Releases the unmanaged resources used by the underlying image canvas. + /// Gets the rendered image canvas (32-bit RGBA). /// - /// - /// After calling this method, the property should no longer - /// be accessed. The method is safe to call multiple times. - /// - public void Dispose() + public SixLabors.ImageSharp.Image Canvas { get; } + + /// + public override void Save(Stream stream) + { + ArgumentNullException.ThrowIfNull(stream); + switch (this.Format) + { + case ImageExportFormat.Bmp: + this.Canvas.Save(stream, new BmpEncoder()); + break; + case ImageExportFormat.Jpeg: + this.Canvas.Save(stream, new JpegEncoder { Quality = this._quality }); + break; + case ImageExportFormat.Gif: + this.Canvas.Save(stream, new GifEncoder()); + break; + case ImageExportFormat.Webp: + this.Canvas.Save(stream, new WebpEncoder { Quality = this._quality }); + break; + case ImageExportFormat.Svg: + throw new NotSupportedException("RenderedImagePage holds a raster canvas; render with ImageExportFormat.Svg to obtain a RenderedSvgPage."); + default: + this.Canvas.Save(stream, new PngEncoder()); + break; + } + } + + /// + public override void Dispose() { this.Canvas.Dispose(); } diff --git a/ACadSharp.Image/RenderedPage.cs b/ACadSharp.Image/RenderedPage.cs new file mode 100644 index 0000000..a9c22f0 --- /dev/null +++ b/ACadSharp.Image/RenderedPage.cs @@ -0,0 +1,48 @@ +namespace ACadSharp.Image; + +/// +/// A rendered page produced by , ready to be saved in its . +/// +public abstract class RenderedPage : IDisposable +{ + /// + /// Initializes a new instance of the class. + /// + /// The name of the page. + /// The format this page will be saved as. + protected RenderedPage(string name, ImageExportFormat format) + { + this.Name = name; + this.Format = format; + } + + /// + /// Gets the name of this page (layout name or block name). + /// + public string Name { get; } + + /// + /// Gets the format this page will be saved as. + /// + public ImageExportFormat Format { get; } + + /// + /// Saves the page to a file, creating the directory if needed. + /// + public void Save(string path) + { + ArgumentException.ThrowIfNullOrWhiteSpace(path); + string fullPath = Path.GetFullPath(path); + Directory.CreateDirectory(Path.GetDirectoryName(fullPath)!); + using FileStream stream = new(fullPath, FileMode.Create, FileAccess.Write, FileShare.None); + this.Save(stream); + } + + /// + /// Writes the page to a stream in its . + /// + public abstract void Save(Stream stream); + + /// + public abstract void Dispose(); +} diff --git a/ACadSharp.Image/RenderedSvgPage.cs b/ACadSharp.Image/RenderedSvgPage.cs new file mode 100644 index 0000000..423a793 --- /dev/null +++ b/ACadSharp.Image/RenderedSvgPage.cs @@ -0,0 +1,38 @@ +using System.Text; + +namespace ACadSharp.Image; + +/// +/// A page rendered to SVG markup. +/// +public sealed class RenderedSvgPage : RenderedPage +{ + /// + /// Initializes a new instance of the class. + /// + /// Page name. + /// SVG markup for the page. + public RenderedSvgPage(string name, string content) + : base(name, ImageExportFormat.Svg) + { + this.Content = content; + } + + /// + /// Gets the SVG markup as text. It carries no XML declaration so it can be inlined in HTML; writes UTF-8 without a BOM. + /// + public string Content { get; } + + /// + public override void Save(Stream stream) + { + ArgumentNullException.ThrowIfNull(stream); + byte[] bytes = new UTF8Encoding(false).GetBytes(this.Content); + stream.Write(bytes, 0, bytes.Length); + } + + /// + public override void Dispose() + { + } +} diff --git a/ACadSharp.Image/Rendering/CurveTessellation.cs b/ACadSharp.Image/Rendering/CurveTessellation.cs new file mode 100644 index 0000000..62e75b2 --- /dev/null +++ b/ACadSharp.Image/Rendering/CurveTessellation.cs @@ -0,0 +1,80 @@ +namespace ACadSharp.Image.Rendering; + +/// +/// Geometry helpers shared by backends that need arcs as points or bulges as arcs. +/// +internal static class CurveTessellation +{ + /// + /// Samples an elliptical arc into + 1 points. + /// + /// Centre in surface units. + /// Semi-axis along the rotated X axis. + /// Semi-axis along the rotated Y axis. + /// Rotation of the X axis in radians (surface space). + /// Start parameter in radians (surface space). + /// Signed sweep in radians (surface space). + /// Number of straight segments, at least 1. + public static IReadOnlyList ArcPoints(SurfacePoint center, double radiusX, double radiusY, double rotation, double startAngle, double sweepAngle, int segments) + { + segments = Math.Max(1, segments); + double cos = Math.Cos(rotation); + double sin = Math.Sin(rotation); + SurfacePoint[] points = new SurfacePoint[segments + 1]; + for (int i = 0; i <= segments; i++) + { + double angle = startAngle + (sweepAngle * i / segments); + double x = radiusX * Math.Cos(angle); + double y = radiusY * Math.Sin(angle); + points[i] = new SurfacePoint( + center.X + (x * cos) - (y * sin), + center.Y + (x * sin) + (y * cos)); + } + + return points; + } + + /// + /// Converts a polyline bulge into arc parameters in surface space. + /// + /// + /// + /// Bulge is tan(theta/4) where theta is the included angle. A positive bulge is a counter-clockwise arc in the drawing + /// and still looks counter-clockwise on screen after the Y flip; but in surface coordinates (Y down) a visually + /// counter-clockwise turn is a decreasing angle, so a positive bulge yields a negative sweep here. + /// + /// + /// Callers must guard against being 0 and against coincident / + /// points; either condition drives the chord length to 0 and yields NaN. + /// + /// + public static void BulgeArc(SurfacePoint start, SurfacePoint end, double bulge, out SurfacePoint center, out double radius, out double startAngle, out double sweepAngle) + { + double chordX = end.X - start.X; + double chordY = end.Y - start.Y; + double chord = Math.Sqrt((chordX * chordX) + (chordY * chordY)); + double theta = 4d * Math.Atan(Math.Abs(bulge)); + radius = chord / (2d * Math.Sin(theta / 2d)); + + // Distance from the chord midpoint to the centre, along the chord normal. + double apothem = radius * Math.Cos(theta / 2d); + double midX = (start.X + end.X) / 2d; + double midY = (start.Y + end.Y) / 2d; + double normalX = -chordY / chord; + double normalY = chordX / chord; + + // The arc bulges toward +normal for a positive bulge, so the centre sits on the -normal side. + double side = bulge > 0 ? -1d : 1d; + center = new SurfacePoint(midX + (side * apothem * normalX), midY + (side * apothem * normalY)); + startAngle = Math.Atan2(start.Y - center.Y, start.X - center.X); + sweepAngle = bulge > 0 ? -theta : theta; + } + + /// + /// Number of straight segments to use for an arc of radians when a full turn uses . + /// + public static int SegmentsForSweep(double sweepAngle, int fullCircleSegments) + { + return Math.Max(1, (int)Math.Ceiling(Math.Abs(sweepAngle) / (2d * Math.PI) * Math.Max(4, fullCircleSegments))); + } +} diff --git a/ACadSharp.Image/Rendering/EntityBounds.cs b/ACadSharp.Image/Rendering/EntityBounds.cs new file mode 100644 index 0000000..50adacb --- /dev/null +++ b/ACadSharp.Image/Rendering/EntityBounds.cs @@ -0,0 +1,100 @@ +using ACadSharp.Entities; +using CSMath; + +namespace ACadSharp.Image.Rendering; + +/// +/// Bounds the renderer would actually draw, for page framing and for culling a viewport's model-space contents to +/// its view box. ACadSharp's GetBoundingBox ignores a wipeout's pixel vectors and a solid's extrusion +/// normal, and throws for some malformed geometry; this helper applies the renderer's own mapping for those and +/// reports failure instead of throwing. +/// +internal static class EntityBounds +{ + /// + /// Computes the bounds an entity would occupy as the renderer draws it. + /// + /// The entity to bound. + /// The bounds, or when the entity cannot contribute. + /// True when is valid. + public static bool TryGet(Entity entity, out BoundingBox bounds) => TryGet(entity, out bounds, out _); + + /// + /// Computes the bounds an entity would occupy as the renderer draws it, and the exception that made it fail, if + /// any, so a caller can report it. + /// + /// The entity to bound. + /// The bounds, or when the entity cannot contribute. + /// The exception that made the attempt fail — either the one ACadSharp raised, or one this + /// method constructed itself to carry a reason of its own (a self-referencing block, which is refused before + /// ACadSharp is asked and so never throws); null when the entity has no bounds for another reason (an unresolved + /// block reference, or a wipeout/solid that would occupy no area). + /// True when is valid. + public static bool TryGet(Entity entity, out BoundingBox bounds, out Exception? error) + { + bounds = default; + error = null; + switch (entity) + { + case Insert insert when insert.Block == null: + return false; + case Insert insert when EntityRenderDispatcher.BlockGraphIsCircular(insert.Block): + error = new InvalidOperationException($"block '{insert.Block!.Name}' references itself"); + return false; + case Wipeout wipeout: + return TryFromPoints(EntityRenderDispatcher.WipeoutWorldRings(wipeout, null).SelectMany(ring => ring).ToList(), out bounds); + case Solid solid when !OcsTransform.IsWorldPlane(solid.Normal): + OcsTransform toWorld = OcsTransform.For(solid.Normal); + return TryFromPoints([ToWorld(toWorld, solid.FirstCorner), ToWorld(toWorld, solid.SecondCorner), ToWorld(toWorld, solid.ThirdCorner), ToWorld(toWorld, solid.FourthCorner)], out bounds); + } + + try + { + bounds = entity.GetBoundingBox(); + return true; + } + catch (Exception ex) when (ex is ArgumentException or InvalidOperationException or NotSupportedException or ArithmeticException or NullReferenceException) + { + // ACadSharp's GetBoundingBox() throws for some malformed geometry (e.g. a bulge between coincident + // vertices). NullReferenceException is also caught here: a block reference nested inside this entity's + // own block (reached recursively through BlockRecord.GetBoundingBox()) can itself have an unresolved + // Block, which Insert.GetBoundingBox() dereferences without a null check. The top-level Block == null + // case is handled above without reaching this method's own GetBoundingBox() call; this is the same + // failure one level (or more) down, where only ACadSharp's own recursive call sees it. + error = ex; + return false; + } + } + + /// + /// Maps a solid corner through its OCS-to-world frame. + /// + /// The OCS-to-world frame. + /// The corner, in OCS coordinates. + /// The world point. + private static XYZ ToWorld(OcsTransform toWorld, XYZ corner) => toWorld.ToWorld(corner.X, corner.Y, corner.Z); + + /// + /// Builds the axis-aligned bounds enclosing a set of world points. + /// + /// The points to enclose. + /// The enclosing bounds, or when is empty. + /// True when is non-empty. + private static bool TryFromPoints(IReadOnlyList points, out BoundingBox bounds) + { + bounds = default; + if (points.Count == 0) + { + return false; + } + + double minX = points.Min(p => p.X); + double minY = points.Min(p => p.Y); + double minZ = points.Min(p => p.Z); + double maxX = points.Max(p => p.X); + double maxY = points.Max(p => p.Y); + double maxZ = points.Max(p => p.Z); + bounds = new BoundingBox(new XYZ(minX, minY, minZ), new XYZ(maxX, maxY, maxZ)); + return true; + } +} diff --git a/ACadSharp.Image/Rendering/EntityRenderDispatcher.cs b/ACadSharp.Image/Rendering/EntityRenderDispatcher.cs index 5bcd762..1f275c8 100644 --- a/ACadSharp.Image/Rendering/EntityRenderDispatcher.cs +++ b/ACadSharp.Image/Rendering/EntityRenderDispatcher.cs @@ -1,12 +1,15 @@ -using ACadSharp.Entities; +using System.Globalization; +using System.Runtime.CompilerServices; +using ACadSharp.Entities; using ACadSharp.Extensions; +using ACadSharp.Header; +using ACadSharp.Image.Extensions; using ACadSharp.IO; +using ACadSharp.Objects; using ACadSharp.Tables; using CSMath; -using SixLabors.ImageSharp; -using SixLabors.ImageSharp.Drawing; -using SixLabors.ImageSharp.Drawing.Processing; -using SixLabors.ImageSharp.Processing; +using ImageColor = SixLabors.ImageSharp.Color; +using Rgba32 = SixLabors.ImageSharp.PixelFormats.Rgba32; namespace ACadSharp.Image.Rendering; @@ -31,24 +34,57 @@ internal sealed class EntityRenderDispatcher private readonly SplineRenderer _splineRenderer; private readonly ImageStyleResolver _styleResolver; private readonly TextRenderer _textRenderer; + private readonly EntityVisibilityFilter _visibilityFilter; + + /// + /// Per-block cache of , so repeated inserts of the same block scan its + /// subtree for MLINEs and LEADERs at most once per page. Cleared by . + /// + /// + /// 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 + /// 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 + /// for no benefit; a gives the same + /// lookup semantics without retaining a clone past the call that produced it. + /// + private readonly ConditionalWeakTable> _blocksNeedingHeal = new(); public EntityRenderDispatcher(ImageConfiguration configuration) { this._configuration = configuration; this._splineRenderer = new SplineRenderer(configuration); - this._styleResolver = new ImageStyleResolver(configuration); - this._textRenderer = new TextRenderer(configuration); + this._styleResolver = new ImageStyleResolver(); + this._textRenderer = new TextRenderer(); + this._visibilityFilter = new EntityVisibilityFilter(configuration); + } + + /// + /// Clears the per-block MLINE/LEADER subtree cache used by . The dispatcher + /// belongs to an , which can render several pages (and the same document can be + /// edited between them), so a cached result from an earlier page must not be trusted for a later one; call this + /// once at the start of every page render. + /// + internal void BeginPage() + { + this._blocksNeedingHeal.Clear(); } /// - /// Draws a single CAD entity onto the rendering canvas. + /// Draws a single CAD entity onto the drawing surface. /// - /// The rendering context containing the canvas and coordinate transforms. + /// The rendering context containing the surface and coordinate transforms. /// The entity to draw. /// /// - /// The entity's color and line weight are resolved automatically from the entity - /// properties (ByLayer, ByBlock, or explicit values) using . + /// The entity's colour, line weight, linetype dashes and opacity are resolved automatically from the + /// entity properties (ByLayer, ByBlock, or explicit values) using . + /// + /// + /// The entity may not be drawn at all: it is skipped without output when the visibility filter hides its + /// layer, and skipped with a warning when its defining geometry carries NaN or infinity. /// /// /// If the entity type is not supported, a warning notification is raised but no @@ -57,69 +93,227 @@ public EntityRenderDispatcher(ImageConfiguration configuration) /// public void Draw(ImageRenderContext context, Entity entity) { - ImageStyle style = this._styleResolver.Resolve(entity); + this.Draw(context, entity, parentLayer: null, parentHandle: null, blockName: null, parent: null); + } - switch (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). + 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. + Layer? layer = GetEffectiveLayer(entity, parentLayer); + string layerName = layer?.Name ?? Layer.DefaultName; + if (!this._visibilityFilter.IsVisible(entity, layer, layerName, context.Viewport)) { - case Arc arc: - this.DrawPolyline(context, style, arc.PolygonalVertexes(this._configuration.ArcPrecision).Select(v => v.Convert()), false); - break; - case Circle circle: - this.DrawPolyline(context, style, circle.PolygonalVertexes(this._configuration.ArcPrecision).Select(v => v.Convert()), true); - break; - case Ellipse ellipse: - this.DrawPolyline(context, style, ellipse.PolygonalVertexes(this._configuration.ArcPrecision).Select(v => v.Convert()), true); - break; - case Line line: - context.Canvas.Mutate(x => x.DrawLine(style.StrokeColor, style.StrokeWidth, context.ToPixelPoint(line.StartPoint), context.ToPixelPoint(line.EndPoint))); - break; - case Dimension dimension: - this.DrawDimension(context, dimension); - break; - case Solid solid: - this.DrawSolid(context, style, solid); - break; - case ACadSharp.Entities.Point point: - this.DrawPoint(context, style, point); - break; - case IPolyline polyline: - this.DrawPolyline(context, style, polyline.GetPoints(this._configuration.ArcPrecision).Select(v => v.Convert()), polyline.IsClosed); - break; - case Spline spline: - this._splineRenderer.Draw(context, style, spline); - break; - case MText mtext: - this._textRenderer.Draw(context, style, mtext); - break; - case TextEntity textEntity: - this._textRenderer.Draw(context, style, textEntity); - break; - case IText text: - this._configuration.Notify($"[{entity.SubclassMarker}] Text rendering is not implemented yet.", NotificationType.NotImplemented); - break; - case Insert insert: - this.DrawBlockContents(context, insert); - break; - default: - this._configuration.Notify($"[{entity.SubclassMarker}] Drawing not implemented.", NotificationType.NotImplemented); - break; + return; + } + + if (!HasFiniteGeometry(entity)) + { + this._configuration.Notify( + $"[{entity.SubclassMarker}] Handle {entity.Handle.ToString("X", CultureInfo.InvariantCulture)}: geometry contains non-finite values; entity skipped.", + NotificationType.Warning); + return; + } + + ImageColor foreground = context.Configuration.ResolveForegroundColor(); + ResolvedStyle resolved = this._styleResolver.ResolveAttributes(entity, layer, parent); + ImageStyle style = this._styleResolver.ToImageStyle(resolved, context, foreground); + EntityRenderInfo info = new(layerName, entity.ObjectName, entity.Handle, parentHandle, blockName); + LayerRenderInfo layerInfo = CreateLayerInfo(layer, layerName, context, foreground); + + context.Surface.BeginEntity(info, layerInfo); + try + { + switch (entity) + { + case Arc arc when context.Surface.SupportsCurves && IsWorldPlane(arc.Normal): + DrawArc(context, style, arc); + break; + case Arc arc: + DrawPolyline(context, style, arc.PolygonalVertexes(this._configuration.ArcPrecision).Select(v => v.Convert()), 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); + 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); + break; + case Line line: + context.Surface.DrawLine(style, context.ToSurfacePoint(line.StartPoint), context.ToSurfacePoint(line.EndPoint)); + break; + case Dimension dimension: + this.DrawDimension(context, dimension, layer, resolved); + break; + case Leader leader: + this.DrawLeader(context, style, resolved, layer, source as Leader ?? leader, placement); + break; + case Solid solid: + DrawSolid(context, style, source as Solid ?? solid, placement); + break; + case Face3D face: + DrawFace3D(context, style, face); + break; + case ACadSharp.Entities.Point point: + this.DrawPoint(context, style, point); + break; + case IPolyline polyline when context.Surface.SupportsCurves && IsWorldPlane(polyline.Normal): + DrawBulgePolyline(context, style, polyline); + break; + case IPolyline polyline: + DrawPolyline(context, style, this.PolylinePoints(polyline), polyline.IsClosed); + break; + case Spline spline: + this._splineRenderer.Draw(context, style, spline); + break; + case AttributeBase attribute when attribute.AttributeType is AttributeType.MultiLine or AttributeType.ConstantMultiLine: + this._textRenderer.DrawAttribute(context, style, source as AttributeBase ?? attribute, placement); + break; + case MText mtext: + this._textRenderer.Draw(context, style, source as MText ?? mtext, placement); + break; + case TextEntity textEntity: + this._textRenderer.Draw(context, style, source as TextEntity ?? textEntity, placement); + break; + case IText text: + this._configuration.Notify($"[{entity.SubclassMarker}] Text rendering is not implemented yet.", NotificationType.NotImplemented); + break; + case Hatch hatch: + this.DrawHatch(context, style, source as Hatch ?? hatch, placement); + break; + case Insert insert: + this.DrawBlockContents(context, insert, layer, resolved); + break; + case MLine mline: + this.DrawMLine(context, style, resolved, mline, placement); + break; + case Wipeout wipeout: + this.DrawWipeout(context, style, source as Wipeout ?? wipeout, placement); + break; + default: + this._configuration.Notify($"[{entity.SubclassMarker}] Drawing not implemented.", NotificationType.NotImplemented); + break; + } + } + catch (Exception ex) when (ex is ArgumentException or InvalidOperationException or NotSupportedException or ArithmeticException) + { + // A malformed entity (ACadSharp throws for a bulge between coincident vertices, for example) must not take the page down with it. + // ArithmeticException is ImageSharp's: its scan-line fill rejects a non-finite vertex that slipped past HasFiniteGeometry. + this._configuration.Notify( + $"[{entity.SubclassMarker}] Handle {entity.Handle.ToString("X", CultureInfo.InvariantCulture)}: geometry could not be computed ({ex.Message}); entity skipped.", + NotificationType.Warning, + ex); + } + finally + { + context.Surface.EndEntity(); + } + } + + /// + /// Entities on layer "0" inside a block take the layer of the insert that placed them. + /// + internal static Layer? GetEffectiveLayer(Entity entity, Layer? parentLayer) + { + Layer? own = entity.Layer; + if (own == null || string.IsNullOrEmpty(own.Name)) + { + return parentLayer ?? own; + } + + if (parentLayer != null && string.Equals(own.Name, Layer.DefaultName, StringComparison.Ordinal)) + { + return parentLayer; + } + + return own; + } + + private static LayerRenderInfo CreateLayerInfo(Layer? layer, string layerName, ImageRenderContext context, ImageColor foreground) + { + if (layer == null) + { + return new LayerRenderInfo(layerName, foreground, context.ToStrokeWidth(LineWeightType.Default)); } + + return new LayerRenderInfo(layerName, layer.Color.ToImageColor(foreground), context.ToStrokeWidth(layer.LineWeight)); } private void DrawPoint(ImageRenderContext context, ImageStyle style, ACadSharp.Entities.Point point) { - PointF center = context.ToPixelPoint(point.Location); + // DotSizePixels is a pixel size; SVG surface units are drawing units, so it has to be converted. float radius = Math.Max(1f, this._configuration.DotSizePixels / 2f); - - context.Canvas.Mutate(x => x.Fill(style.StrokeColor, new EllipsePolygon(center.X, center.Y, radius))); + context.Surface.FillCircle(style, context.ToSurfacePoint(point.Location), context.ToSurfacePixels(radius)); } - private void DrawDimension(ImageRenderContext context, Dimension dimension) + /// + /// Draws a dimension from the picture block ACadSharp generates for it, generating that block first when the + /// drawing did not store one. + /// + /// + /// 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 + /// '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 + /// runs ahead of every draw and does not get there first: Dimension.GetBoundingBox() was probed to leave + /// Block null, so never reaches the constructor. + /// + /// The cycle pre-check is not symmetry: the clone that constructor performs is the same deep clone + /// Explode() performs, so an arrow block reachable from itself exhausts the stack inside ACadSharp before + /// UpdateBlock() returns, and a StackOverflowException cannot be caught. The dimension's own + /// picture block is not among the blocks checked, because this branch only runs when there is not one yet. + /// + /// + private void DrawDimension(ImageRenderContext context, Dimension dimension, Layer? layer, ResolvedStyle parent) { + string handle = dimension.Handle.ToString("X", CultureInfo.InvariantCulture); BlockRecord? block = dimension.Block; if (block == null) { - dimension.UpdateBlock(); + Dictionary> mlineVertices = new(); + Dictionary> leaderVertices = new(); + HashSet collected = new(); + foreach (BlockRecord referenced in ReferencedBlocks(dimension)) + { + if (BlockGraphIsCircular(referenced)) + { + this._configuration.Notify($"[{dimension.SubclassMarker}] Handle {handle}: block '{referenced.Name}' references itself; dimension skipped.", NotificationType.Warning); + return; + } + + if (this.BlockSubtreeNeedsHeal(referenced, new HashSet())) + { + CollectSharedVertexLists(referenced, mlineVertices, leaderVertices, collected); + } + } + + try + { + dimension.UpdateBlock(); + } + finally + { + Heal(mlineVertices, leaderVertices); + } + block = dimension.Block; } @@ -136,84 +330,1489 @@ private void DrawDimension(ImageRenderContext context, Dimension dimension) continue; } - this.Draw(context, entity); + this.Draw(context, entity, layer, dimension.Handle, blockName: null, parent); } } - private void DrawSolid(ImageRenderContext context, ImageStyle style, Solid solid) + /// + /// Fills a solid's four corners. The corners are OCS coordinates (ACadSharp leaves the normal to the caller), so a + /// non-world normal is applied first, with each corner's Z as its elevation, and only then the insert transform + /// that placed it (null at top level, since Explode() already transformed a world-plane clone). DXF SOLID + /// stores corners in a Z pattern (first edge 1-2, opposite edge 3-4), so they are filled in order 1-2-4-3, not + /// 1-2-3-4. + /// + private static void DrawSolid(ImageRenderContext context, ImageStyle style, Solid solid, Transform? placement) { - PointF[] points = + OcsTransform? toWorld = IsWorldPlane(solid.Normal) ? null : OcsTransform.For(solid.Normal); + SurfacePoint ToSurface(XYZ corner) + { + XYZ world = toWorld != null ? toWorld.ToWorld(corner.X, corner.Y, corner.Z) : corner; + return context.ToSurfacePoint(InsertPlacement.MapPoint(placement, world)); + } + + SurfacePoint[] points = [ - context.ToPixelPoint(solid.FirstCorner), - context.ToPixelPoint(solid.SecondCorner), - context.ToPixelPoint(solid.ThirdCorner), - context.ToPixelPoint(solid.FourthCorner), + ToSurface(solid.FirstCorner), + ToSurface(solid.SecondCorner), + ToSurface(solid.FourthCorner), + ToSurface(solid.ThirdCorner), ]; - context.Canvas.Mutate(x => x.FillPolygon(style.StrokeColor, points)); + context.Surface.FillPolygon(style, points); } - private void DrawPolyline(ImageRenderContext context, ImageStyle style, IEnumerable vertices, bool close) + /// + /// A 3DFACE is stroked edge by edge in plan view: edge n joins corner n to corner n+1 and edge 4 closes the ring; + /// a triangle repeats its third corner, which makes edge 3 degenerate. Hidden edges (the invisible-edge flags) + /// split the ring into open runs. Corners are world coordinates, so there is no OCS step. + /// + private static void DrawFace3D(ImageRenderContext context, ImageStyle style, Face3D face) { - PointF[] points = vertices.Select(context.ToPixelPoint).ToArray(); - if (points.Length < 2) + bool triangle = face.FourthCorner.Equals(face.ThirdCorner); + XYZ[] corners = triangle + ? [face.FirstCorner, face.SecondCorner, face.ThirdCorner] + : [face.FirstCorner, face.SecondCorner, face.ThirdCorner, face.FourthCorner]; + bool[] hidden = triangle + ? [face.Flags.HasFlag(InvisibleEdgeFlags.First), face.Flags.HasFlag(InvisibleEdgeFlags.Second), face.Flags.HasFlag(InvisibleEdgeFlags.Fourth)] + : [face.Flags.HasFlag(InvisibleEdgeFlags.First), face.Flags.HasFlag(InvisibleEdgeFlags.Second), face.Flags.HasFlag(InvisibleEdgeFlags.Third), face.Flags.HasFlag(InvisibleEdgeFlags.Fourth)]; + + int count = corners.Length; + int firstHidden = Array.IndexOf(hidden, true); + if (firstHidden < 0) + { + context.Surface.DrawPolyline(style, corners.Select(context.ToSurfacePoint).ToArray(), true); + return; + } + + // Start just after a hidden edge so no visible run wraps around the ring. + List run = new(count + 1); + for (int step = 1; step <= count; step++) + { + int edge = (firstHidden + step) % count; + if (hidden[edge]) + { + Flush(); + continue; + } + + if (run.Count == 0) + { + run.Add(context.ToSurfacePoint(corners[edge])); + } + + run.Add(context.ToSurfacePoint(corners[(edge + 1) % count])); + } + + Flush(); + + void Flush() + { + if (run.Count >= 2) + { + context.Surface.DrawPolyline(style, run.ToArray(), false); + } + + run.Clear(); + } + } + + /// + /// True when an entity's extrusion is the world Z axis, so its OCS coordinates are already world coordinates. + /// + /// + /// Native curve output uses the raw centre, radii and angles; ACadSharp applies the OCS transform only inside + /// PolygonalVertexes. Anything but the default normal (a (0,0,-1) extrusion mirrors X, for example) + /// therefore has to fall back to the tessellating path. Polylines, hatches and solids are never transformed by ACadSharp + /// at all, so their points go through instead. + /// + private static bool IsWorldPlane(XYZ normal) => OcsTransform.IsWorldPlane(normal); + + /// + /// Tessellated polyline points in world XY. A polyline on the world plane keeps ACadSharp's points untouched (the + /// raster output depends on that exact sequence); any other normal is brought into world space first, since + /// GetPoints returns raw OCS vertices. + /// + private IEnumerable PolylinePoints(IPolyline polyline) + { + IEnumerable points = polyline.GetPoints(this._configuration.ArcPrecision); + if (IsWorldPlane(polyline.Normal)) + { + return points.Select(v => v.Convert()); + } + + OcsTransform toWorld = OcsTransform.For(polyline.Normal); + double elevation = polyline.Elevation; + return points.Select(p => toWorld.ToWorldXY(p.X, p.Y, elevation)); + } + + /// + /// Brings a drawing sweep into (0, 2*PI]. An exact zero (equal start and end angles) becomes a full turn, + /// and non-finite input degrades to a full turn rather than looping. + /// + private static double NormalizeSweep(double sweep) + { + double full = 2d * Math.PI; + if (double.IsNaN(sweep) || double.IsInfinity(sweep)) + { + return full; + } + + sweep %= full; + if (sweep <= 0d) + { + sweep += full; + } + + return sweep; + } + + /// + /// False when an entity's defining geometry carries NaN or infinity, as some DXF files do + /// (Samples/6-57-1119.dxf handle 1FA is an ARC with radius Infinity and NaN angles). + /// + /// The entity to inspect. + /// True when the geometry can be drawn. + internal static bool HasFiniteGeometry(Entity entity) => entity switch + { + // Arc derives from Circle: this case must stay first. + Arc arc => IsFinite(arc.Center) && IsFinitePositive(arc.Radius) && double.IsFinite(arc.StartAngle) && double.IsFinite(arc.EndAngle), + Circle circle => IsFinite(circle.Center) && IsFinitePositive(circle.Radius), + Ellipse ellipse => IsFinite(ellipse.Center) && IsFinite(ellipse.MajorAxisEndPoint) && double.IsFinite(ellipse.RadiusRatio) && double.IsFinite(ellipse.StartParameter) && double.IsFinite(ellipse.EndParameter), + Line line => IsFinite(line.StartPoint) && IsFinite(line.EndPoint), + Face3D face => IsFinite(face.FirstCorner) && IsFinite(face.SecondCorner) && IsFinite(face.ThirdCorner) && IsFinite(face.FourthCorner), + Leader leader => leader.Vertices.All(IsFinite), + // Every value that reaches a fill point has to be covered, not just the positions: Parameters[0] is the + // element offset along the miter, the values after it are cut positions that place the ends of a run, and + // the clip vertices are mapped through WipeoutPixelToWorld. + MLine mline => mline.Vertices.All(v => IsFinite(v.Position) && IsFinite(v.Miter) + && v.Segments.All(s => s.Parameters.All(double.IsFinite))), + Wipeout wipeout => IsFinite(wipeout.InsertPoint) && IsFinite(wipeout.UVector) && IsFinite(wipeout.VVector) + && double.IsFinite(wipeout.Size.X) && double.IsFinite(wipeout.Size.Y) + && wipeout.ClipBoundaryVertices.All(p => double.IsFinite(p.X) && double.IsFinite(p.Y)), + _ => true, + }; + + private static bool IsFinite(XYZ p) => double.IsFinite(p.X) && double.IsFinite(p.Y) && double.IsFinite(p.Z); + + private static bool IsFinitePositive(double value) => double.IsFinite(value) && value > 0d; + + /// + /// Emits an arc natively. Drawing angles turn counter-clockwise; the surface Y axis points down, so both the start + /// angle and the sweep change sign. + /// + private static void DrawArc(ImageRenderContext context, ImageStyle style, Arc arc) + { + double sweep = NormalizeSweep(arc.EndAngle - arc.StartAngle); + double radius = context.ToSurfaceLength(arc.Radius); + context.Surface.DrawArc(style, context.ToSurfacePoint(arc.Center), radius, radius, 0d, -arc.StartAngle, -sweep); + } + + /// + /// Emits an ellipse or elliptical arc natively. + /// + /// + /// Ellipse.MajorAxis and Ellipse.MinorAxis are full axis lengths in ACadSharp 3.7.1 + /// (MajorAxis is twice the length of MajorAxisEndPoint), so they are halved into surface radii. + /// + private static void DrawEllipse(ImageRenderContext context, ImageStyle style, Ellipse ellipse) + { + double radiusX = context.ToSurfaceLength(ellipse.MajorAxis / 2d); + double radiusY = context.ToSurfaceLength(ellipse.MinorAxis / 2d); + SurfacePoint center = context.ToSurfacePoint(ellipse.Center); + if (ellipse.IsFullEllipse) + { + context.Surface.DrawEllipse(style, center, radiusX, radiusY, -ellipse.Rotation); + return; + } + + double sweep = NormalizeSweep(ellipse.EndParameter - ellipse.StartParameter); + context.Surface.DrawArc(style, center, radiusX, radiusY, -ellipse.Rotation, -ellipse.StartParameter, -sweep); + } + + /// + /// Emits a polyline with its bulges intact instead of tessellating the arc segments. + /// + private static void DrawBulgePolyline(ImageRenderContext context, ImageStyle style, IPolyline polyline) + { + List points = new(); + List bulges = new(); + foreach (IVertex vertex in polyline.Vertices) + { + // IVertex.Location is a CSMath.IVector; it only exposes an indexer. + points.Add(context.ToSurfacePoint(new XY(vertex.Location[0], vertex.Location[1]))); + bulges.Add(vertex.Bulge); + } + + if (points.Count < 2) { return; } - if (close && this.ShouldClose(points)) + context.Surface.DrawBulgePolyline(style, points, bulges, polyline.IsClosed); + } + + private static void DrawPolyline(ImageRenderContext context, ImageStyle style, IEnumerable vertices, bool close) + { + SurfacePoint[] points = vertices.Select(context.ToSurfacePoint).ToArray(); + if (points.Length < 2) { - PointF[] closedPoints = new PointF[points.Length + 1]; - Array.Copy(points, closedPoints, points.Length); - closedPoints[^1] = points[0]; - points = closedPoints; + return; } - context.Canvas.Mutate(x => x.DrawLine(style.StrokeColor, style.StrokeWidth, points)); + context.Surface.DrawPolyline(style, points, SplineRenderer.ShouldClosePoints(points, close)); } - private void DrawBlockContents(ImageRenderContext context, Insert insert) + /// + /// A leader is its stored path (the hookline is already the last vertex; the annotation is a separate entity) + /// plus, when enabled, AutoCAD's default closed filled arrowhead at the first vertex: an isosceles triangle + /// DIMASZ x DIMSCALE long and a third of that wide. A splined leader runs a Catmull-Rom curve through its + /// vertices. A custom arrowhead block is drawn in the triangle's place by , which + /// falls back to the triangle when the block cannot be placed. Path and arrowhead are built in the leader's own + /// coordinates and mapped through (null at top level) last, so a leader inside a + /// scaled or rotated insert scales and rotates with it. + /// + /// The context that maps drawing units onto the surface. + /// The leader's stroke and fill style. + /// The leader's resolved style, which a custom arrowhead's ByBlock entities inherit. + /// The leader's effective layer, which a custom arrowhead's layer-0 entities inherit. + /// The leader to draw. + /// The transform of the insert that placed the leader, or null at top level. + private void DrawLeader(ImageRenderContext context, ImageStyle style, ResolvedStyle resolved, Layer? layer, Leader leader, Transform? placement) { - foreach (Entity entity in insert.Explode()) + if (leader.Vertices.Count < 2) + { + return; + } + + SurfacePoint Map(XYZ p) => context.ToSurfacePoint(InsertPlacement.MapPoint(placement, p)); + + SurfacePoint[] points = leader.Vertices.Select(Map).ToArray(); + if (leader.PathType == LeaderPathType.Spline && points.Length > 2) + { + // Catmull-Rom control points are affine combinations of the input points, so mapping the vertices first + // and building the curve from the mapped points gives the same result as building it in source space and + // mapping every control point afterward. + context.Surface.DrawCubicBezier(style, CatmullRomToBezier(points), false); + } + else + { + context.Surface.DrawPolyline(style, points, false); + } + + if (!leader.ArrowHeadEnabled) { - Draw(context, entity); + return; + } + + 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(); + double length = direction.GetLength(); + + // Every comparison with NaN is false, so the size has to be tested for finiteness explicitly. The degenerate + // cases return before the custom-arrow notification, which would otherwise claim a substitute nobody drew. + if (!double.IsFinite(size) || size <= 0d || length <= 0d) + { + return; + } + + direction /= length; + double tipZ = leader.Vertices[0].Z; + if (leader.Style.LeaderArrow != null + && this.DrawArrowBlock(context, layer, resolved, leader, leader.Style.LeaderArrow, tip, direction, size, tipZ, placement)) + { + return; } + + XY baseCenter = tip - (direction * size); + XY half = new XY(-direction.Y, direction.X) * (size / 6d); + XY baseLeft = baseCenter + half; + XY baseRight = baseCenter - half; + // The triangle is built flat (in the leader's own XY plane, ignoring any Z on the second vertex), but its + // anchor must carry the first vertex's own Z so it maps to the same point as the path's own first vertex; + // dropping it here would detach the arrow from the line under a placement whose normal couples Z into X/Y. + context.Surface.FillPolygon(style, [Map(new XYZ(tip.X, tip.Y, tipZ)), Map(new XYZ(baseLeft.X, baseLeft.Y, tipZ)), Map(new XYZ(baseRight.X, baseRight.Y, tipZ))]); } /// - /// Determines whether a polyline should be closed based on a heuristic. + /// Draws a custom arrowhead block at a leader's tip: the block's base point goes to the tip, its local +X axis + /// turns to point outward along , and it is scaled by , all + /// composed with the placement of the block reference that placed the leader. /// + /// The context that maps drawing units onto the surface. + /// The leader's effective layer, which the arrow's layer-0 entities inherit. + /// The leader's resolved style, which the arrow's ByBlock entities inherit. + /// The leader the arrow belongs to, for notifications. + /// The arrow block. + /// The leader's first vertex, in the leader's own coordinates. + /// The outward unit direction at the tip, in the leader's own coordinates. + /// The arrow size, already multiplied by the dimension style's overall scale. + /// The tip's own Z, so a leader off the world plane keeps its arrow attached to its line. + /// The transform of the insert that placed the leader, or null at top level. + /// True when the block was drawn; false when the caller should fall back to the default triangle. /// - /// The heuristic compares the distance between the last and first points (closing length) - /// to the average segment length. If the closing length is within 3x the average segment - /// length, the polyline is considered closeable. This handles cases where polylines are - /// nearly closed but have small gaps due to precision or modeling errors. + /// The block is drawn by handing a transient Insert of it to the ordinary block-content path, rather than + /// 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 + /// 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 + /// the insertion point below is compensated to produce AutoCAD's placement. A package upgrade that corrects this + /// will break ACustomArrowHonoursANonZeroBlockBasePoint, which is the intended tripwire. + /// /// - private bool ShouldClose(IReadOnlyList points) + private bool DrawArrowBlock(ImageRenderContext context, Layer? layer, ResolvedStyle parent, Leader leader, BlockRecord arrow, XY tip, XY direction, double size, double z, Transform? placement) { - if (points.Count < 3) + string handle = leader.Handle.ToString("X", CultureInfo.InvariantCulture); + if (arrow.Entities.Count == 0) + { + this._configuration.Notify($"[{leader.SubclassMarker}] Handle {handle}: arrowhead block '{arrow.Name}' is empty; the default closed arrow is drawn instead.", NotificationType.Warning); + return false; + } + + if (BlockGraphIsCircular(arrow)) + { + this._configuration.Notify($"[{leader.SubclassMarker}] Handle {handle}: arrowhead block '{arrow.Name}' references itself; the default closed arrow is drawn instead.", NotificationType.Warning); + return false; + } + + // The map the arrow block's own coordinates must go through: base point to the tip, local +X onto the + // outward direction, scaled by the arrow size, and then the outer placement. + XYZ basePoint = arrow.BlockEntity.BasePoint; + XY across = new(-direction.Y, direction.X); + XYZ Arrow(XYZ p) + { + XY local = new(p.X - basePoint.X, p.Y - basePoint.Y); + XY placed = tip + (direction * (local.X * size)) + (across * (local.Y * size)); + return InsertPlacement.MapPoint(placement, new XYZ(placed.X, placed.Y, z + ((p.Z - basePoint.Z) * size))); + } + + // The arrow's own map is a rotation and one uniform scale, so the composition is a similarity exactly when + // the outer placement is one. Testing the outer placement directly also catches the case a length-only check + // misses: a non-uniform scale turned 45 degrees leaves both axes the same length but not at right angles. + if (!InsertPlacement.TryGetPlanarSimilarity(placement, out double outerScale, out _, out _)) { + this._configuration.Notify($"[{leader.SubclassMarker}] Handle {handle}: arrowhead block '{arrow.Name}' cannot be placed under a non-uniform transform; the default closed arrow is drawn instead.", NotificationType.Warning); return false; } - float totalLength = 0f; - for (int i = 1; i < points.Count; i++) + XYZ origin = Arrow(basePoint); + XYZ ex = Arrow(basePoint + XYZ.AxisX) - origin; + XYZ ey = Arrow(basePoint + XYZ.AxisY) - origin; + double scale = size * outerScale; + bool mirrored = (ex.X * ey.Y) - (ex.Y * ey.X) < 0d; + double rotation = Math.Atan2(ex.Y, ex.X); + if (!double.IsFinite(scale) || scale < 1e-12 || !double.IsFinite(rotation)) { - totalLength += Distance(points[i - 1], points[i]); + this._configuration.Notify($"[{leader.SubclassMarker}] Handle {handle}: arrowhead block '{arrow.Name}' has a degenerate size; the default closed arrow is drawn instead.", NotificationType.Warning); + 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 + // 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> leaderVertices = new(); + if (this.BlockSubtreeNeedsHeal(arrow, new HashSet())) + { + CollectSharedVertexLists(arrow, mlineVertices, leaderVertices, new HashSet()); } - float averageSegmentLength = totalLength / (points.Count - 1); - float closingLength = Distance(points[^1], points[0]); + try + { + // A reflection is expressed as a negative X scale, which turns the mapped X axis around, so the rotation + // is taken half a turn further to bring it back. + Insert transient = new(arrow) + { + Rotation = mirrored ? rotation + Math.PI : rotation, + XScale = mirrored ? -scale : scale, + YScale = scale, + ZScale = scale, + InsertPoint = origin, + }; + transient.Attributes.Clear(); + + // Repaired straight away, so DrawBlockContents takes its own snapshot from intact lists. + Heal(mlineVertices, leaderVertices); + + // Where the block's base point actually lands under the insert as built, corrected by the difference. + // Both formulas differ from the wanted placement by a translation that moves one for one with the + // insertion point — their derivative with respect to it is the identity — so a single correction lands + // the base point on the tip whichever one the package uses, which keeps this right if a later ACadSharp + // fixes its own divergence from AutoCAD's documented insert semantics. + XYZ landed = transient.GetTransform().ApplyTransform(basePoint); + transient.InsertPoint = origin + (origin - landed); + this.DrawBlockContents(context, transient, layer, parent, leader.Handle); + } + finally + { + Heal(mlineVertices, leaderVertices); + } - // 3x multiplier provides tolerance for small gaps in nearly-closed polylines - return closingLength <= averageSegmentLength * 3f; + return true; } - private static float Distance(PointF a, PointF b) + /// + /// Control points (1 + 3n) of the cubic Bézier chain equivalent to a uniform Catmull-Rom spline through + /// , with the end tangents clamped by repeating the end points. + /// + internal static SurfacePoint[] CatmullRomToBezier(IReadOnlyList points) { - float dx = a.X - b.X; - float dy = a.Y - b.Y; - return MathF.Sqrt(dx * dx + dy * dy); + int segments = points.Count - 1; + SurfacePoint[] controls = new SurfacePoint[(segments * 3) + 1]; + controls[0] = points[0]; + for (int i = 0; i < segments; i++) + { + SurfacePoint previous = points[Math.Max(i - 1, 0)]; + SurfacePoint start = points[i]; + SurfacePoint end = points[i + 1]; + SurfacePoint next = points[Math.Min(i + 2, points.Count - 1)]; + controls[(3 * i) + 1] = new SurfacePoint(start.X + ((end.X - previous.X) / 6d), start.Y + ((end.Y - previous.Y) / 6d)); + controls[(3 * i) + 2] = new SurfacePoint(end.X - ((next.X - start.X) / 6d), end.Y - ((next.Y - start.Y) / 6d)); + controls[(3 * i) + 3] = end; + } + + return controls; } + /// + /// The geometry stored in an MLINE's vertices is final: element j passes through + /// Position + Segments[j].Parameters[0] * Miter at every vertex (DXF group 41), with justification and + /// scale already applied by the writer. Vertices without parameters fall back to the style offsets with the + /// justification shift, with a warning. Cuts made by MLEDIT (further group-41 values) break an element into the + /// visible runs computes, each drawn as its own line; an element with no usable cut + /// position stays a single polyline so its linetype phase is unbroken. Fill cuts (group 42) are notified, not + /// drawn. Each element takes the style element's colour and linetype, falling + /// back to the entity's own; a fill-on style fills the ring between the two outermost elements first. Square + /// caps join the outermost elements at an open end unless the entity suppresses them; round and inner-arc + /// caps and joints are not drawn. + /// + private void DrawMLine(ImageRenderContext context, ImageStyle style, ResolvedStyle resolved, MLine mline, Transform? placement) + { + IReadOnlyList vertices = mline.Vertices; + MLineStyle.Element[] elements = mline.Style.Elements.ToArray(); + if (vertices.Count < 2 || elements.Length == 0) + { + // A genuinely degenerate MLINE returns silently, but a non-null placement means this is a block clone; + // if it has no vertices here, snapshot/heal pairing failed to reach it, which would otherwise vanish + // with no explanation. + if (vertices.Count < 2 && placement != null) + { + this._configuration.Notify($"[{mline.SubclassMarker}] Handle {mline.Handle.ToString("X", CultureInfo.InvariantCulture)}: multiline has no vertices; skipped.", NotificationType.Warning); + } + + return; + } + + bool closed = mline.Flags.HasFlag(MLineFlags.Closed); + double scale = mline.ScaleFactor == 0d ? 1d : mline.ScaleFactor; + // Offsets are scaled before the extrema are taken: under a negative ScaleFactor, scaling flips which element + // is geometrically outermost, so choosing extrema from the raw (unscaled) offsets would anchor Top/Bottom + // justification (and pick the fill ring) at the wrong element. + double[] scaled = elements.Select(e => e.Offset * scale).ToArray(); + string handle = mline.Handle.ToString("X", CultureInfo.InvariantCulture); + if (!double.IsFinite(scale) || scaled.Any(v => !double.IsFinite(v))) + { + this._configuration.Notify($"[{mline.SubclassMarker}] Handle {handle}: multiline style has non-finite offsets or scale; entity skipped.", NotificationType.Warning); + return; + } + + double maxOffset = scaled.Max(); + double minOffset = scaled.Min(); + double shift = mline.Justification switch + { + MLineJustification.Top => -maxOffset, + MLineJustification.Bottom => -minOffset, + _ => 0d, + }; + + bool fallback = false; + SurfacePoint[][] lines = new SurfacePoint[elements.Length][]; + // The same points before any placement or projection: cut positions are distances in the multiline's own + // drawing units, so they can only be measured against a segment length taken in those units. Measuring the + // placed points instead would leave a cut at its stored distance while the geometry around it scaled. + XYZ[][] local = new XYZ[elements.Length][]; + for (int j = 0; j < elements.Length; j++) + { + lines[j] = new SurfacePoint[vertices.Count]; + local[j] = new XYZ[vertices.Count]; + for (int i = 0; i < vertices.Count; i++) + { + MLine.Vertex vertex = vertices[i]; + double along; + if (j < vertex.Segments.Count && vertex.Segments[j].Parameters.Count > 0) + { + along = vertex.Segments[j].Parameters[0]; + } + else + { + along = scaled[j] + shift; + fallback = true; + } + + XYZ point = vertex.Position + (vertex.Miter * along); + local[j][i] = point; + lines[j][i] = context.ToSurfacePoint(InsertPlacement.MapPoint(placement, point)); + } + } + + if (fallback) + { + this._configuration.Notify($"[{mline.SubclassMarker}] Handle {handle}: vertex parameters are missing; element offsets were computed from the style.", NotificationType.Warning); + } + + if (vertices.Any(v => v.Segments.Any(s => s.AreaFillParameters.Count > 0))) + { + this._configuration.Notify($"[{mline.SubclassMarker}] Handle {handle}: fill cuts are not drawn; the filled band is continuous.", NotificationType.NotImplemented); + } + + ImageColor foreground = context.Configuration.ResolveForegroundColor(); + // scaled is already known finite (the non-finite check above returned early otherwise), so maxOffset and + // minOffset, both drawn from it, are always found here; outer/inner are picked from the scaled offsets so + // they name the geometrically outermost/innermost element even under a negative ScaleFactor. + int outer = Array.FindIndex(scaled, v => v == maxOffset); + int inner = Array.FindIndex(scaled, v => v == minOffset); + bool hasRing = outer >= 0 && inner >= 0 && outer != inner; + if (mline.Style.Flags.HasFlag(MLineStyleFlags.FillOn) && hasRing) + { + ImageStyle fill = style with { StrokeColor = ElementColor(mline.Style.FillColor), DashPattern = null }; + // An open MLINE's fill is the band between the two outer elements. A closed one needs the full annulus: + // the outer and inner rings alone (as for the open case) leave the closing wall between the last and + // first vertices outside the path, so a bridge back to the outer ring's start point turns it into a + // keyhole that covers that wall too; the inner ring's reversal gives it the opposite winding, so both + // nonzero and even-odd fill rules produce the ring, not its complement. + SurfacePoint[] fillPoints = closed + ? [.. lines[outer], lines[outer][0], lines[inner][0], .. Enumerable.Reverse(lines[inner])] + : [.. lines[outer], .. Enumerable.Reverse(lines[inner])]; + context.Surface.FillPolygon(fill, fillPoints); + } + + for (int j = 0; j < elements.Length; j++) + { + // An element linetype named ByLayer/ByBlock is not itself a drawable pattern: it means the element + // inherits the entity's own resolved dashes, same as a null element linetype, rather than being handed + // to the resolver, which would otherwise treat the placeholder name as an unknown (solid) linetype. + LineType? elementType = elements[j].LineType; + float[]? dashes = elementType == null + || ImageStyleResolver.IsNamed(elementType, LineType.ByLayerName) + || ImageStyleResolver.IsNamed(elementType, LineType.ByBlockName) + ? style.DashPattern + : LineTypeDashResolver.Resolve(elementType, resolved.Header, resolved.LineTypeScale, context, style.StrokeWidth); + ImageStyle elementStyle = style with { StrokeColor = ElementColor(elements[j].Color), DashPattern = dashes }; + + // An uncut element stays one polyline: drawing it as a chain of separate lines would restart a dashed + // linetype's phase at every vertex and would move every existing golden. + if (!HasCut(j)) + { + context.Surface.DrawPolyline(elementStyle, lines[j], closed); + continue; + } + + int lastVertex = closed ? vertices.Count : vertices.Count - 1; + for (int i = 0; i < lastVertex; i++) + { + int next = (i + 1) % vertices.Count; + SurfacePoint from = lines[j][i]; + SurfacePoint to = lines[j][next]; + foreach ((double t0, double t1) in RunFractions(j, i, next)) + { + SurfacePoint a = new(from.X + ((to.X - from.X) * t0), from.Y + ((to.Y - from.Y) * t0)); + SurfacePoint b = new(from.X + ((to.X - from.X) * t1), from.Y + ((to.Y - from.Y) * t1)); + context.Surface.DrawLine(elementStyle, a, b); + } + } + } + + if (!closed && hasRing) + { + if (mline.Style.Flags.HasFlag(MLineStyleFlags.StartSquareCap) && !mline.Flags.HasFlag(MLineFlags.NoStartCaps)) + { + context.Surface.DrawLine(style, lines[outer][0], lines[inner][0]); + } + + if (mline.Style.Flags.HasFlag(MLineStyleFlags.EndSquareCap) && !mline.Flags.HasFlag(MLineFlags.NoEndCaps)) + { + context.Surface.DrawLine(style, lines[outer][^1], lines[inner][^1]); + } + } + + ImageColor ElementColor(ACadSharp.Color color) => color.IsByLayer || color.IsByBlock ? style.StrokeColor : color.ToImageColor(foreground); + + // The visible runs of one segment, as fractions of its length. The stored cut positions are distances in + // the multiline's own drawing units, so the segment they are measured against has to be the unplaced one; + // the fractions are then applied to the already-placed and projected surface points, which is exact because + // both steps are affine. + IReadOnlyList<(double Start, double End)> RunFractions(int element, int from, int to) + { + double segmentLength = (local[element][to] - local[element][from]).GetLength(); + if (segmentLength <= 0d || !double.IsFinite(segmentLength)) + { + // A zero-length segment (coincident vertices) has nothing to cut: report one full run so the element + // is not pushed onto the per-run path, where it would lose its linetype phase for no reason. + return [(0d, 1d)]; + } + + IReadOnlyList parameters = element < vertices[from].Segments.Count ? vertices[from].Segments[element].Parameters : []; + return VisibleRuns(parameters, segmentLength).Select(run => (run.Start / segmentLength, run.End / segmentLength)).ToList(); + } + + // Whether any segment of this element is broken, i.e. yields anything other than one run covering the whole + // segment. An unbroken element keeps its single polyline. + bool HasCut(int element) + { + int lastVertex = closed ? vertices.Count : vertices.Count - 1; + for (int i = 0; i < lastVertex; i++) + { + IReadOnlyList<(double Start, double End)> runs = RunFractions(element, i, (i + 1) % vertices.Count); + if (runs.Count != 1 || runs[0].Start > 1e-12 || runs[0].End < 1d - 1e-12) + { + return true; + } + } + + return false; + } + } + + /// + /// The visible runs of one MLINE element, as distances from the element's own start. DXF group 41 stores, after + /// the miter offset and the element's start offset, the positions at which the element breaks and resumes, + /// alternating; an odd count leaves the element hidden to its end. Values are clamped to the element's length, + /// and the list is cut short at the first value that is not finite or not greater than the one before it. + /// + /// The element's stored parameters, starting with the miter offset. + /// The element's length between this vertex and the next. + /// The visible runs, in order; a single full-length run when there are no usable cut positions. + /// + /// Reading these as absolute positions is the literal sense of the DXF reference. ezdxf's model comments read the + /// same array as relative dash and gap lengths, and neither ezdxf nor LibreDWG draws cuts at all, so no + /// implementation settles it; the two readings agree only on a single cut. This is the interpretation the + /// renderer implements and the README records it as unconfirmed. + /// + /// p[1], the offset from the miter intersection to the element's actual start, is not applied: runs are + /// measured from the intersection, which is where the renderer already starts every element. Real values are a + /// small fraction of a unit, so applying it would move existing output for no visible gain; it is recorded here + /// so a later change is a deliberate one. + /// + /// + /// The non-finite guard on a parameter value is unreachable through Draw, which skips a multiline with any + /// non-finite parameter outright (HasFiniteGeometry); it is kept as a backstop for direct callers of this + /// method, which is why the two policies differ — skipping the entity there, truncating the cut list here. + /// + /// + internal static IReadOnlyList<(double Start, double End)> VisibleRuns(IReadOnlyList parameters, double length) + { + if (!double.IsFinite(length) || length <= 0d) + { + return []; + } + + List breaks = new(); + double previous = 0d; + for (int i = 2; i < parameters.Count; i++) + { + double value = parameters[i]; + if (!double.IsFinite(value) || value <= previous) + { + break; + } + + if (value >= length) + { + break; + } + + breaks.Add(value); + previous = value; + } + + if (breaks.Count == 0) + { + return [(0d, length)]; + } + + List<(double Start, double End)> runs = new(); + double start = 0d; + for (int i = 0; i < breaks.Count; i += 2) + { + runs.Add((start, breaks[i])); + start = i + 1 < breaks.Count ? breaks[i + 1] : double.NaN; + if (double.IsNaN(start)) + { + return runs; + } + } + + runs.Add((start, length)); + return runs; + } + + /// + /// A wipeout masks whatever was drawn before it: its clip boundary (or the whole image frame when clipping is + /// off) is filled with the page background at full opacity, so the page must be drawn in the drawing's order. + /// The frame is never stroked. An inverted clip masks the frame minus the boundary as a single even-odd path. A + /// background that is anything short of opaque cannot be honoured and is skipped with a notification. + /// + private void DrawWipeout(ImageRenderContext context, ImageStyle style, Wipeout wipeout, Transform? placement) + { + if (!wipeout.Flags.HasFlag(ImageDisplayFlags.ShowImage)) + { + return; + } + + string handle = wipeout.Handle.ToString("X", CultureInfo.InvariantCulture); + ImageColor background = this._configuration.BackgroundColor; + if (background.ToPixel().A < 255) + { + // A translucent fill blends over what is underneath on the raster backend, while the SVG backend's Hex + // drops the alpha and masks fully, so anything short of opaque is skipped rather than drawn two ways. + this._configuration.Notify($"[{wipeout.SubclassMarker}] Handle {handle}: a wipeout needs an opaque background to mask; skipped.", NotificationType.Warning); + return; + } + + IReadOnlyList> rings = WipeoutWorldRings(wipeout, placement); + if (rings.Count == 0) + { + return; + } + + ImageStyle maskStyle = style with { StrokeColor = background, Opacity = 1f, DashPattern = null }; + if (rings.Count == 1) + { + context.Surface.FillPolygon(maskStyle, rings[0].Select(context.ToSurfacePoint).ToArray()); + return; + } + + // An inverted clip masks everything except the boundary, which is the frame with the boundary as a hole: an + // even-odd fill over both rings. + context.Surface.FillPath(maskStyle, rings.Select(ring => (IReadOnlyList)ring.Select(context.ToSurfacePoint).ToArray()).ToList()); + } + + /// + /// The world rings a wipeout masks: none when the image is hidden, one when it masks a single region, and two — + /// the whole image frame and the boundary inside it — for an inverted clip, which masks everything except the + /// boundary. Clipping that is switched off masks the whole frame whatever the clip mode says. + /// + /// The wipeout entity. + /// The transform of the insert that placed it, or null at top level. + /// 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 + /// the translation and the mask is stretched across the drawing. + /// + internal static IReadOnlyList> WipeoutWorldRings(Wipeout wipeout, Transform? placement) + { + if (!wipeout.Flags.HasFlag(ImageDisplayFlags.ShowImage)) + { + return []; + } + + List frame = + [ + new XY(-0.5, -0.5), + new XY(wipeout.Size.X - 0.5, -0.5), + new XY(wipeout.Size.X - 0.5, wipeout.Size.Y - 0.5), + new XY(-0.5, wipeout.Size.Y - 0.5), + ]; + + if (!wipeout.ClippingState || wipeout.ClipBoundaryVertices.Count < 2) + { + return [Map(frame)]; + } + + List boundary; + if (wipeout.ClipType == ClipType.Rectangular || wipeout.ClipBoundaryVertices.Count == 2) + { + XY a = wipeout.ClipBoundaryVertices[0]; + XY b = wipeout.ClipBoundaryVertices[1]; + boundary = [a, new XY(b.X, a.Y), b, new XY(a.X, b.Y)]; + } + else + { + boundary = wipeout.ClipBoundaryVertices.ToList(); + } + + return wipeout.ClipMode == ClipMode.Inside + ? [Map(frame), Map(boundary)] + : [Map(boundary)]; + + IReadOnlyList Map(IEnumerable pixels) => pixels.Select(p => WipeoutPixelToWorld(wipeout, p, placement)).ToList(); + } + + /// + /// Maps an image-space boundary vertex to world coordinates. Pixel (0,0) is the top-left pixel and Y grows + /// downwards; UVector runs along the visual bottom and VVector up the visual left side, each one + /// pixel long. The documented default boundary (-0.5,-0.5)..(Size-0.5) therefore covers exactly the image. The + /// insertion point is mapped as a point and the two vectors as directions. + /// + internal static XYZ WipeoutPixelToWorld(CadWipeoutBase image, XY pixel, Transform? placement) + { + XYZ insertPoint = InsertPlacement.MapPoint(placement, image.InsertPoint); + XYZ u = InsertPlacement.MapVector(placement, image.UVector); + XYZ v = InsertPlacement.MapVector(placement, image.VVector); + return insertPoint + (u * (pixel.X + 0.5)) + (v * (image.Size.Y - pixel.Y - 0.5)); + } + + /// + /// True when an exploded should be drawn from 's geometry, + /// 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). + /// 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. + /// + /// The block entity at the same index as , or null past the end of the block's own entities. + /// The entity Explode() produced. + /// True when should be drawn from instead. + private static bool UsesOriginalGeometry(Entity? original, Entity clone) + { + if (original == null || original.GetType() != clone.GetType()) + { + return false; + } + + if (original is TextEntity or MText or Leader or Hatch or Wipeout) + { + return true; + } + + return original is Solid solid && !IsWorldPlane(solid.Normal); + } + + /// + /// 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. + /// + /// The context that maps drawing units onto the surface. + /// The block reference to draw the contents of. + /// The insert's effective layer, which its layer-0 contents inherit. + /// The insert's resolved style, which its ByBlock contents inherit. + /// + /// The handle to record as the contents' parent instead of the insert's own. Passed for the transient insert + /// builds, whose handle is zero and belongs to no entity in the drawing, so an + /// arrowhead's parts point at the leader they belong to. + /// + private void DrawBlockContents(ImageRenderContext context, Insert insert, Layer? layer, ResolvedStyle parent, ulong? parentHandleOverride = null) + { + if (insert.Block == null) + { + this._configuration.Notify($"[{insert.SubclassMarker}] Handle {insert.Handle.ToString("X", CultureInfo.InvariantCulture)}: block reference has no block; skipped.", NotificationType.Warning); + return; + } + + if (BlockGraphIsCircular(insert.Block)) + { + this._configuration.Notify($"[{insert.SubclassMarker}] Handle {insert.Handle.ToString("X", CultureInfo.InvariantCulture)}: block '{insert.Block.Name}' references itself; skipped.", NotificationType.Warning); + return; + } + + // 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 + // 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. + // 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(); + 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 + // walk only runs when it can find something. + if (this.BlockSubtreeNeedsHeal(insert.Block, new HashSet())) + { + CollectSharedVertexLists(insert.Block, mlineVertices, leaderVertices, new HashSet()); + } + + int index = 0; + try + { + // Explode() is a lazy iterator and the heal must not be interleaved with the Clone() calls it makes, so + // the clones are materialised (and held alive at once) only when there is something to heal. + bool needsHeal = mlineVertices.Count > 0 || leaderVertices.Count > 0; + IEnumerable clones = needsHeal ? insert.Explode().ToList() : insert.Explode(); + if (needsHeal) + { + Heal(mlineVertices, leaderVertices); + } + + foreach (Entity entity in clones) + { + Entity? original = index < originals.Count ? originals[index] : null; + index++; + if (entity is AttributeDefinition definition) + { + // A non-constant ATTDEF is a template shown through its ATTRIB. A constant one is skipped too when + // an ATTRIB with its tag already exists (ACadSharp's Insert(BlockRecord) constructor emits one even + // for constant definitions, so the value would otherwise be drawn twice) or when ATTMODE/Hidden + // would hide it; DXF attribute tags are case-insensitive, so the tag comparison ignores case. + bool hasMatchingAttrib = insert.Attributes.Any(a => string.Equals(a.Tag, definition.Tag, StringComparison.OrdinalIgnoreCase)); + if (!definition.Flags.HasFlag(AttributeFlags.Constant) || hasMatchingAttrib || !this.IsAttributeVisible(definition, insert, parent)) + { + continue; + } + } + + Entity? source = null; + Transform? entityPlacement = null; + if (UsesOriginalGeometry(original, entity)) + { + source = original; + entityPlacement = transform; + } + else if (entity is MLine or Leader) + { + entityPlacement = transform; + } + + this.Draw(context, entity, layer, parentHandleOverride ?? insert.Handle, insert.Block.Name, parent, source, entityPlacement); + } + } + finally + { + Heal(mlineVertices, leaderVertices); + } + + if (index != originals.Count) + { + this._configuration.Notify( + $"[{insert.SubclassMarker}] Handle {insert.Handle.ToString("X", CultureInfo.InvariantCulture)}: block '{insert.Block.Name}' exploded into {index} entities but holds {originals.Count}; geometry drawn from originals inside it may be misplaced.", + NotificationType.Warning); + } + + this.DrawAttributes(context, insert, layer, parent); + } + + /// + /// 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. + /// + /// The MLINE vertex lists captured before cloning. + /// The LEADER vertex lists captured before cloning. + private static void Heal(Dictionary> mlineSnapshot, Dictionary> leaderSnapshot) + { + foreach (KeyValuePair> pair in mlineSnapshot) + { + pair.Key.Vertices.Clear(); + pair.Key.Vertices.AddRange(pair.Value); + } + + foreach (KeyValuePair> pair in leaderSnapshot) + { + pair.Key.Vertices.Clear(); + pair.Key.Vertices.AddRange(pair.Value); + } + } + + /// + /// The blocks reaches when it is cloned, and the only edges the block-graph walks + /// below follow: + /// + /// 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 + /// 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 + /// than something the drawing's author named. When it is still null the dimension has not been generated yet + /// 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 + /// 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. + /// + /// Every one of these edges is also one a cycle can run through, which is why the cycle walk consumes this + /// enumerator too: each is followed by a deep clone that recurses, and a cycle through any of them exhausts the + /// stack inside ACadSharp uncatchably. One block can be reached twice (the same record set as two arrowheads, or + /// as both a nested insert and a dimension picture); de-duplication is left to the callers, which all track the + /// blocks they have already walked. + /// + /// The entity whose outgoing block references are wanted. + /// Each referenced block, possibly yielding the same block more than once. + private static IEnumerable ReferencedBlocks(Entity entity) + { + if (entity is Insert insert) + { + if (insert.Block != null) + { + yield return insert.Block; + } + + yield break; + } + + DimensionStyle? style; + if (entity is Dimension dimension) + { + if (dimension.Block != null) + { + yield return dimension.Block; + } + + style = dimension.Style; + } + else if (entity is Leader leader) + { + style = leader.Style; + } + else + { + yield break; + } + + if (style == null) + { + yield break; + } + + if (style.ArrowBlock != null) + { + yield return style.ArrowBlock; + } + + if (style.DimArrow1 != null) + { + yield return style.DimArrow1; + } + + if (style.DimArrow2 != null) + { + yield return style.DimArrow2; + } + + if (style.LeaderArrow != null) + { + yield return style.LeaderArrow; + } + } + + /// + /// 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. + /// + /// 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. + /// 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) + { + if (block == null || !visited.Add(block)) + { + return; + } + + foreach (Entity entity in block.Entities) + { + switch (entity) + { + case MLine mline when !mlineSnapshot.ContainsKey(mline): + mlineSnapshot.Add(mline, new List(mline.Vertices)); + break; + case Leader leader when !leaderSnapshot.ContainsKey(leader): + leaderSnapshot.Add(leader, new List(leader.Vertices)); + break; + } + + foreach (BlockRecord referenced in ReferencedBlocks(entity)) + { + CollectSharedVertexLists(referenced, mlineSnapshot, leaderSnapshot, visited); + } + } + } + + /// + /// True when , or any block reachable from it through the edges + /// reports, contains an MLINE, a LEADER or a DIMENSION. The first two are the + /// entities exists to snapshot; a DIMENSION carries none of its own but + /// reaches its picture block and its style's arrowhead blocks, either of which may hold one, so it has to answer + /// yes here or that walk would never be run. + /// A LEADER answers yes for the same reason as well as for its own vertices. Over-approximating costs one + /// wasted subtree walk and cannot lose a snapshot. + /// Answers are memoised per block in , so an insert of a block already proven + /// clean (or already proven to need healing) elsewhere on the page costs a dictionary lookup instead of a walk. + /// + /// The block to check, or null. + /// Blocks already walked in this call, so a circular or diamond hierarchy is walked once. + /// True when the subtree contains an MLINE, a LEADER or a DIMENSION. + private bool BlockSubtreeNeedsHeal(BlockRecord? block, HashSet visited) + { + return this.ScanBlockSubtree(block, visited).NeedsHeal; + } + + /// + /// The recursive core of . Besides the answer, it reports whether the walk + /// was cut short by a cycle: a truncated walk saw only part of the subtree, so its "clean" verdict must not be + /// cached under — doing so would poison every future insert of this block with an + /// answer taken from an incomplete scan. A "needs healing" verdict is always safe to cache, truncated or not: + /// finding one MLINE/LEADER is a fact no missed branch can undo. + /// + /// The block to check, or null. + /// Blocks already walked in this call, so a circular or diamond hierarchy is walked once. + /// Whether the subtree contains an MLINE, a LEADER or a DIMENSION, and whether a cycle cut the walk short. + private (bool NeedsHeal, bool Truncated) ScanBlockSubtree(BlockRecord? block, HashSet visited) + { + if (block == null) + { + return (false, false); + } + + if (this._blocksNeedingHeal.TryGetValue(block, out StrongBox? cached)) + { + return (cached.Value, false); + } + + if (!visited.Add(block)) + { + // A block reachable from itself: treat the cyclic branch as clean rather than recurse forever, and tell + // the caller this branch was truncated so it knows not to trust — or cache — a "clean" verdict built on + // top of it. + return (false, true); + } + + bool needsHeal = false; + bool truncated = false; + foreach (Entity entity in block.Entities) + { + // A DIMENSION is included even though it holds no vertex list of its own: it reaches its own picture + // block and its style's arrowhead blocks, either of which may hold an MLINE, and a "clean" answer here + // means no snapshot is ever taken. + if (entity is MLine or Leader or Dimension) + { + needsHeal = true; + break; + } + + foreach (BlockRecord referenced in ReferencedBlocks(entity)) + { + (bool nestedNeedsHeal, bool nestedTruncated) = this.ScanBlockSubtree(referenced, visited); + truncated |= nestedTruncated; + if (nestedNeedsHeal) + { + needsHeal = true; + break; + } + } + + if (needsHeal) + { + break; + } + } + + if (needsHeal || !truncated) + { + this._blocksNeedingHeal.AddOrUpdate(block, new StrongBox(needsHeal)); + } + + return (needsHeal, truncated); + } + + /// + /// Whether a block's own graph contains a cycle, so that a reference to it cannot be exploded. + /// + /// The block a reference points at. + /// True when walking the block's nested references reaches a block already on the walk. + /// + /// This walks the whole graph without stopping early, unlike the heal scan: a cycle can hide behind any branch, + /// and an answer that stopped at the first interesting entity would miss it. Cycle detection itself is done with + /// a set of the blocks on the *current path*, not a global one, so a diamond — two references to the same block + /// from different places — is not mistaken for a cycle. A second set records the blocks already proven acyclic + /// anywhere in this walk, which is what keeps a heavily shared DAG (each block holding two references to the + /// next) from costing exponential time; it is sound because a block that reaches no cycle and no on-path + /// ancestor from one path cannot reach one from another — if it could, that ancestor would be reachable from it + /// and the first walk would already have come back to the block itself. It is scoped to the one call rather than + /// held in a field, because the caller's document may change between renders. + /// The edges followed are the ones reports: a nested Insert, a DIMENSION's + /// own picture block, and all four arrowhead blocks of a LEADER's or a DIMENSION's dimension style. Cloning + /// either entity deep-clones its style and with it those blocks, and cloning a DIMENSION deep-clones its + /// picture, so a leader inside its own arrowhead block — or a dimension whose picture places the block that + /// holds the dimension — exhausts the stack in exactly the same way a self-referencing insert does. Refusing on + /// the picture edge cannot cost a legitimate drawing: a picture block is geometry ACadSharp generates from the + /// dimension's own definition points and never places the dimension's container in it, so a cycle there means a + /// file that would otherwise take the process down. A picture block shared by two dimensions is a diamond, not + /// a cycle, and the on-path set already tells the two apart. + /// + /// It has to be answered before Insert.Explode() is called, not while drawing: exploding deep-clones the + /// whole block graph, so a cycle exhausts the stack inside ACadSharp before the renderer sees a single entity, + /// and a StackOverflowException cannot be caught in .NET — the process dies. A draw-time guard keyed on + /// the block record could not recognise a nested level anyway, because the inserts reached down there hold + /// deep-cloned records with a different identity at every level. + /// + /// + internal static bool BlockGraphIsCircular(BlockRecord? block) + { + return block != null && Walk(block, new HashSet(), new HashSet()); + + static bool Walk(BlockRecord block, HashSet onPath, HashSet acyclic) + { + if (acyclic.Contains(block)) + { + return false; + } + + if (!onPath.Add(block)) + { + return true; + } + + try + { + foreach (Entity entity in block.Entities) + { + foreach (BlockRecord reached in ReferencedBlocks(entity)) + { + if (Walk(reached, onPath, acyclic)) + { + return true; + } + } + } + + acyclic.Add(block); + return false; + } + finally + { + onPath.Remove(block); + } + } + } + + /// + /// ATTRIB entities store absolute coordinates in their own OCS (the insert's transform is already applied by + /// the writer), so they go through the TEXT (or, for a multi-line attribute, MTEXT) pipeline with no placement. + /// + private void DrawAttributes(ImageRenderContext context, Insert insert, Layer? layer, ResolvedStyle parent) + { + foreach (AttributeEntity attribute in insert.Attributes) + { + if (this.IsAttributeVisible(attribute, insert, parent)) + { + this.Draw(context, attribute, layer, insert.Handle, insert.Block?.Name, parent); + } + } + } + + /// + /// ATTMODE and the attribute's Hidden flag are drawing-visibility state, ignored under + /// like entity invisibility; otherwise None hides every attribute, + /// Normal hides the ones flagged Hidden and All shows them all. + /// + private bool IsAttributeVisible(AttributeBase attribute, Insert insert, ResolvedStyle parent) + { + if (this._configuration.LayerVisibility == LayerVisibilityMode.All) + { + return true; + } + + // A nested insert exploded out of an outer block carries no Document of its own; its ATTMODE comes from + // the outermost placing insert's header instead, via the resolved style that is threaded down for LTSCALE. + CadHeader? header = insert.Document?.Header ?? parent.Header; + AttributeVisibilityMode mode = header?.AttributeVisibility ?? AttributeVisibilityMode.Normal; + return mode switch + { + AttributeVisibilityMode.None => false, + AttributeVisibilityMode.All => true, + _ => !attribute.Flags.HasFlag(AttributeFlags.Hidden), + }; + } + + /// + /// A solid hatch fills its boundary loops (path.GetPoints) with the even-odd rule; a pattern hatch draws + /// each line ExplodePattern() yields, capped at . Boundary + /// and pattern points are drawn from the original block entity in its own OCS (its normal and elevation), then + /// mapped through (null at top level), never from an exploded clone — except when + /// ordinal pairing fails, in which case is the clone itself and + /// is null (see and the count-mismatch Warning in DrawBlockContents). + /// + 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 + // 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); + SurfacePoint ToSurface(XYZ point) => context.ToSurfacePoint(InsertPlacement.MapOcsPoint(placement, toWorld, hatch.Elevation, point)); + + if (hatch.IsSolid || hatch.PatternType == HatchPatternType.SolidFill) + { + List> rings = new(); + foreach (Hatch.BoundaryPath path in hatch.Paths) + { + List ring = new(); + foreach (XYZ point in path.GetPoints(this._configuration.ArcPrecision)) + { + ring.Add(ToSurface(point)); + } + + if (ring.Count >= 3) + { + rings.Add(ring); + } + } + + if (rings.Count > 0) + { + context.Surface.FillPath(style, rings); + } + + return; + } + + if (hatch.Pattern == null) + { + this._configuration.Notify($"[{hatch.SubclassMarker}] Hatch pattern is not available.", NotificationType.Warning); + return; + } + + // ExplodePattern builds every line up front, so the cap has to be applied before calling it: a fine pattern + // over a large boundary would otherwise allocate millions of entities before the first one is drawn. + double scanLines = EstimateScanLines(hatch); + if (scanLines > this._configuration.MaxHatchLines) + { + this._configuration.Notify( + $"[{hatch.SubclassMarker}] Hatch pattern needs about {scanLines.ToString("F0", CultureInfo.InvariantCulture)} scan lines, more than MaxHatchLines ({this._configuration.MaxHatchLines.ToString(CultureInfo.InvariantCulture)}); hatch skipped.", + NotificationType.Warning); + return; + } + + ImageStyle lineStyle = style with { DashPattern = null }; + int drawn = 0; + foreach (Entity segment in hatch.ExplodePattern()) + { + if (segment is not Line line) + { + continue; + } + + if (drawn >= this._configuration.MaxHatchLines) + { + this._configuration.Notify($"[{hatch.SubclassMarker}] Hatch pattern exceeds {this._configuration.MaxHatchLines} lines; remaining lines were skipped.", NotificationType.Warning); + return; + } + + context.Surface.DrawLine(lineStyle, ToSurface(line.StartPoint), ToSurface(line.EndPoint)); + drawn++; + } + } + + /// + /// 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 + /// dashes, so this is the work the expansion costs, not the number of lines it draws. + /// + /// The pattern hatch. + /// The scan line count, or 0 when the pattern would not expand to anything. + internal static double EstimateScanLines(Hatch hatch) + { + if (hatch.Pattern == null || hatch.Pattern.Lines.Count == 0 || hatch.Paths.Count == 0) + { + return 0d; + } + + BoundingBox box = hatch.GetBoundingBox(); + if (!IsFinite(box.Min) || !IsFinite(box.Max)) + { + return 0d; + } + + XY[] corners = + [ + new XY(box.Min.X, box.Min.Y), + new XY(box.Min.X, box.Max.Y), + new XY(box.Max.X, box.Min.Y), + new XY(box.Max.X, box.Max.Y), + ]; + + double total = 0d; + foreach (HatchPattern.Line patternLine in hatch.Pattern.Lines) + { + XY direction = patternLine.Direction; + if (direction.IsZero()) + { + continue; + } + + XY normal = new(-direction.Y, direction.X); + double minProjection = double.PositiveInfinity; + double maxProjection = double.NegativeInfinity; + foreach (XY corner in corners) + { + double projection = (corner.X * normal.X) + (corner.Y * normal.Y); + minProjection = Math.Min(minProjection, projection); + maxProjection = Math.Max(maxProjection, projection); + } + + double offset = patternLine.LineOffset; + if (Math.Abs(offset) <= MathHelper.Epsilon) + { + total += 1d; + continue; + } + + double origin = (patternLine.BasePoint.X * normal.X) + (patternLine.BasePoint.Y * normal.Y); + double k1 = (minProjection - origin) / offset; + double k2 = (maxProjection - origin) / offset; + double first = Math.Floor(Math.Min(k1, k2)) - 1d; + double last = Math.Ceiling(Math.Max(k1, k2)) + 1d; + total += last - first + 1d; + } + + return total; + } } diff --git a/ACadSharp.Image/Rendering/EntityRenderInfo.cs b/ACadSharp.Image/Rendering/EntityRenderInfo.cs new file mode 100644 index 0000000..6d6c5cb --- /dev/null +++ b/ACadSharp.Image/Rendering/EntityRenderInfo.cs @@ -0,0 +1,18 @@ +using ImageColor = SixLabors.ImageSharp.Color; + +namespace ACadSharp.Image.Rendering; + +/// +/// Identifies the entity currently being drawn so structured backends can group and tag their output. +/// +/// Effective layer name (entities on layer "0" inside a block inherit the insert's layer). +/// DXF object name, e.g. LINE. +/// Entity handle. 0 when the entity is a transient clone produced by Insert.Explode() (block contents); real document entities carry their handle. +/// Handle of the owning Insert or Dimension when drawing nested content. +/// Block name when drawing nested content of an Insert. +internal sealed record EntityRenderInfo(string LayerName, string EntityType, ulong Handle, ulong? ParentHandle, string? BlockName); + +/// +/// Layer defaults a structured backend may hoist onto a group element. +/// +internal sealed record LayerRenderInfo(string LayerName, ImageColor Color, float StrokeWidth); diff --git a/ACadSharp.Image/Rendering/EntityVisibilityFilter.cs b/ACadSharp.Image/Rendering/EntityVisibilityFilter.cs new file mode 100644 index 0000000..c25afc1 --- /dev/null +++ b/ACadSharp.Image/Rendering/EntityVisibilityFilter.cs @@ -0,0 +1,69 @@ +using ACadSharp.Entities; +using ACadSharp.Tables; + +namespace ACadSharp.Image.Rendering; + +/// +/// Decides whether an entity is drawn, combining the include list, the hide list and . +/// +internal sealed class EntityVisibilityFilter +{ + private readonly ImageConfiguration _configuration; + + public EntityVisibilityFilter(ImageConfiguration configuration) + { + this._configuration = configuration; + } + + /// + /// True when the entity should be drawn. + /// + /// The entity being considered. + /// The layer the entity renders with, which for a layer "0" block member is the insert's layer. + /// The name of , or when there is none. + /// The viewport being drawn into, or null for page-level content. + /// True when the entity is visible under the current configuration. + public bool IsVisible(Entity entity, Layer? effectiveLayer, string effectiveLayerName, Viewport? viewport) + { + if (this._configuration.IncludedLayers.Count > 0 && !this._configuration.IncludedLayers.Contains(effectiveLayerName)) + { + return false; + } + + if (this._configuration.HiddenLayers.Count > 0 && this._configuration.HiddenLayers.Contains(effectiveLayerName)) + { + return false; + } + + LayerVisibilityMode mode = this._configuration.LayerVisibility; + if (mode == LayerVisibilityMode.All) + { + return true; + } + + if (entity.IsInvisible) + { + return false; + } + + if (effectiveLayer != null) + { + if (!effectiveLayer.IsOn || effectiveLayer.Flags.HasFlag(LayerFlags.Frozen)) + { + return false; + } + + if (viewport != null && viewport.FrozenLayers.Any(frozen => string.Equals(frozen.Name, effectiveLayerName, StringComparison.OrdinalIgnoreCase))) + { + return false; + } + + if (mode == LayerVisibilityMode.Plot && !effectiveLayer.PlotFlag) + { + return false; + } + } + + return true; + } +} diff --git a/ACadSharp.Image/Rendering/FontResolver.cs b/ACadSharp.Image/Rendering/FontResolver.cs new file mode 100644 index 0000000..8bcb160 --- /dev/null +++ b/ACadSharp.Image/Rendering/FontResolver.cs @@ -0,0 +1,81 @@ +using SixLabors.Fonts; + +namespace ACadSharp.Image.Rendering; + +/// +/// Resolves the configured font family to an installed one. When the configured family is missing, the fallback chain +/// mirrors the SVG font stack (Arial, Helvetica, sans-serif): metric-compatible Liberation Sans first, then the +/// common Linux and Windows sans faces, and only then the first installed family. +/// +internal static class FontResolver +{ + /// Families tried, in order, when the configured one is not installed. + public static readonly IReadOnlyList Fallbacks = + Array.AsReadOnly(new[] { "Liberation Sans", "DejaVu Sans", "Arial", "Helvetica", "Noto Sans", "Segoe UI" }); + + /// + /// Tries to find the installed family for a configured name. + /// + /// The configured family, or null/blank for the fallback chain. + /// + /// The configured family when installed, otherwise the first installed fallback, otherwise the first installed + /// family; the default value when no font family is installed at all. + /// + /// false when the machine has no installed font family, so nothing can be resolved. + public static bool TryResolve(string? familyName, out FontFamily family) + { + if (!string.IsNullOrWhiteSpace(familyName) && SystemFonts.TryGet(familyName, out family)) + { + return true; + } + + foreach (string fallback in Fallbacks) + { + if (SystemFonts.TryGet(fallback, out family)) + { + return true; + } + } + + foreach (FontFamily installed in SystemFonts.Families) + { + family = installed; + return true; + } + + family = default; + return false; + } + + /// + /// Finds the installed family for a configured name. + /// + /// The configured family, or null/blank for the fallback chain. + /// The configured family when installed, otherwise the first installed fallback, otherwise the first installed family. + /// No font family is installed. + public static FontFamily Resolve(string? familyName) + { + if (!TryResolve(familyName, out FontFamily family)) + { + throw new InvalidOperationException("No font families are installed; text cannot be rendered."); + } + + return family; + } + + /// + /// Creates a font of the given size from the resolved family. + /// + /// The configured family. + /// Font size in points; clamped to at least 1, which the raster backend needs to rasterise. + /// The font. + /// + /// Because of the clamp the returned font's size is not the requested one below 1 point, so callers that measure in + /// drawing units must not rely on it: measure at a fixed reference size and scale the result instead, the way + /// SvgTextLayout.Wrap does. + /// + public static Font Create(string? familyName, float size) + { + return Resolve(familyName).CreateFont(Math.Max(1f, size)); + } +} diff --git a/ACadSharp.Image/Rendering/IDrawingSurface.cs b/ACadSharp.Image/Rendering/IDrawingSurface.cs new file mode 100644 index 0000000..ea81300 --- /dev/null +++ b/ACadSharp.Image/Rendering/IDrawingSurface.cs @@ -0,0 +1,78 @@ +namespace ACadSharp.Image.Rendering; + +/// +/// Result of opening a viewport: the surface to draw into and where its origin sits relative to the parent. +/// +/// Surface that receives the viewport contents. +/// X of the viewport's left edge in units. +/// Y of the viewport's bottom edge in units. +internal readonly record struct ViewportSurface(IDrawingSurface Surface, double OffsetX, double BottomY); + +/// +/// Backend-neutral drawing primitives. Coordinates are surface units with Y growing downward. +/// +internal interface IDrawingSurface : IDisposable +{ + /// + /// True when the backend draws arcs, ellipses and bulges natively; false when it wants tessellated polylines. + /// + bool SupportsCurves { get; } + + /// + /// Opens a scope for the entity about to be drawn. + /// + /// + /// Scopes nest: an Insert or Dimension opens a scope, draws nothing itself, and each nested entity + /// opens its own scope inside it. Every is matched by an , even + /// when the entity type is unsupported. + /// + void BeginEntity(EntityRenderInfo info, LayerRenderInfo layer); + + /// + /// Closes the scope opened by the matching . + /// + void EndEntity(); + + void DrawLine(ImageStyle style, SurfacePoint start, SurfacePoint end); + + void DrawPolyline(ImageStyle style, IReadOnlyList points, bool closed); + + /// + /// Draws an elliptical arc. Angles are radians in surface space (already sign-adjusted for the Y flip); a positive sweep turns clockwise on screen. + /// + void DrawArc(ImageStyle style, SurfacePoint center, double radiusX, double radiusY, double rotation, double startAngle, double sweepAngle); + + void DrawEllipse(ImageStyle style, SurfacePoint center, double radiusX, double radiusY, double rotation); + + /// + /// Draws a chain of cubic Bezier segments given 3n+1 control points. + /// + /// + /// The raster backend ignores (the chain ends where it starts for closed splines); + /// structured backends may close the path. + /// + void DrawCubicBezier(ImageStyle style, IReadOnlyList controlPoints, bool closed); + + /// + /// Draws a polyline whose segments may be circular arcs. [i] applies to the segment from points[i] to points[i+1]; 0 is straight. + /// + void DrawBulgePolyline(ImageStyle style, IReadOnlyList points, IReadOnlyList bulges, bool closed); + + void FillPolygon(ImageStyle style, IReadOnlyList points); + + /// + /// Fills several rings with the even-odd rule. + /// + void FillPath(ImageStyle style, IReadOnlyList> rings); + + void FillCircle(ImageStyle style, SurfacePoint center, double radius); + + void DrawText(ImageStyle style, SurfaceText text); + + /// + /// Opens a clipped viewport region. is the viewport rectangle in this surface's units. + /// + ViewportSurface BeginViewport(SurfaceRect bounds); + + void EndViewport(ViewportSurface viewport); +} diff --git a/ACadSharp.Image/Rendering/ImagePageRenderer.cs b/ACadSharp.Image/Rendering/ImagePageRenderer.cs index a5e090e..8a993f6 100644 --- a/ACadSharp.Image/Rendering/ImagePageRenderer.cs +++ b/ACadSharp.Image/Rendering/ImagePageRenderer.cs @@ -1,10 +1,12 @@ +using System.Globalization; using ACadSharp.Entities; -using ACadSharp.Image.Extensions; +using ACadSharp.Header; +using ACadSharp.Image.Rendering.Svg; +using ACadSharp.IO; +using ACadSharp.Tables; +using ACadSharp.Types.Units; using CSMath; using SixLabors.ImageSharp; -using SixLabors.ImageSharp.Processing; -using ImageColor = SixLabors.ImageSharp.Color; -using ImagePoint = SixLabors.ImageSharp.Point; using Rgba32 = SixLabors.ImageSharp.PixelFormats.Rgba32; namespace ACadSharp.Image.Rendering; @@ -27,18 +29,21 @@ internal sealed class ImagePageRenderer { private readonly ImageConfiguration _configuration; private readonly EntityRenderDispatcher _dispatcher; + private readonly EntityVisibilityFilter _visibilityFilter; public ImagePageRenderer(ImageConfiguration configuration) { this._configuration = configuration; this._dispatcher = new EntityRenderDispatcher(configuration); + this._visibilityFilter = new EntityVisibilityFilter(configuration); } /// - /// Renders the specified page into a . + /// Renders the specified page into a . /// /// The page to render. - /// A containing the rendered canvas. + /// The output format the rendered page will be saved as. + /// A containing the rendered canvas. /// /// /// The rendering process follows these steps: @@ -49,45 +54,276 @@ public ImagePageRenderer(ImageConfiguration configuration) /// /// /// - public RenderedImagePage Render(ImagePage page) + public RenderedPage Render(ImagePage page, ImageExportFormat format) { - var image = new Image(this._configuration.Width, this._configuration.Height, this._configuration.BackgroundColor); - ImageRenderContext context = ImageRenderContext.CreatePageContext(image, page, this._configuration); + if (format == ImageExportFormat.Svg) + { + return this.RenderSvg(page); + } + + Image image = new(this._configuration.Width, this._configuration.Height, this._configuration.BackgroundColor); + try + { + using RasterDrawingSurface surface = new(image, this._configuration, ownsCanvas: false); + this.RenderTo(surface, page); + } + catch + { + image.Dispose(); + throw; + } - foreach (Viewport viewport in page.Viewports) + return new RenderedImagePage(page.Name, image, format, this._configuration.OutputQuality); + } + + /// + /// Renders the page onto the raster page context of its visible frame (see and + /// ). + /// + /// The surface receiving the page content. + /// The page to render. + internal void RenderTo(IDrawingSurface surface, ImagePage page) + { + this.RenderTo(ImageRenderContext.CreatePageContext(surface, this.ResolveFrame(page), this._configuration), page); + } + + /// + /// The frame to render a page in: for a page whose size was derived from its content and a configuration that can + /// hide entities, the frame fitted to the visible entities, so hidden ones do not stretch it; otherwise the page's + /// own frame. + /// + /// The page about to be rendered. + /// The frame to map onto the surface. + /// + /// The page itself is never modified, so a later render with different filters starts from the frame the caller + /// built. Only top-level page entities take part; entities shown through a viewport are framed by the viewport + /// itself, and pages that carry a layout's paper size keep it. When every visible entity has non-finite bounds + /// the page's own frame is used. + /// + private PageFrame ResolveFrame(ImagePage page) + { + if (!page.AutoSized || !this.HasActiveFilters()) { - this.DrawViewport(context, viewport); + return PageFrame.Of(page); } - foreach (Entity entity in page.Entities) + return page.ComputeFrame(entity => { - this._dispatcher.Draw(context, entity); + Layer? layer = EntityRenderDispatcher.GetEffectiveLayer(entity, null); + return this._visibilityFilter.IsVisible(entity, layer, layer?.Name ?? Layer.DefaultName, null); + }) ?? PageFrame.Of(page); + } + + /// + /// True when the configuration can hide an entity, so the visible extents may differ from the page extents. + /// + /// True when an include list, a hide list or a visibility mode other than is set. + private bool HasActiveFilters() => + this._configuration.IncludedLayers.Count > 0 + || this._configuration.HiddenLayers.Count > 0 + || this._configuration.LayerVisibility != LayerVisibilityMode.All; + + /// + /// Renders the page's entities and viewports through the given page context, in the order they were added. + /// + /// The page-level context. + /// The page to render. + /// + /// Only a viewport added through is drawn as a window onto model + /// space. One that reached the page through is an ordinary page entity + /// and goes to the dispatcher, which reports it as not implemented. + /// + private void RenderTo(ImageRenderContext context, ImagePage page) + { + // The dispatcher outlives a single page render (this renderer can render several pages, see the class + // remarks), so its per-block MLINE/LEADER subtree cache must not carry a result computed for a different + // page — or an earlier render of this same page, whose document may have been edited since — into this one. + this._dispatcher.BeginPage(); + + // Viewport does not override Equals, so the default comparer is reference equality: the set answers + // "did this very viewport come through AddViewport?", not "is there an equal-looking one". + HashSet windows = new(page.Viewports); + foreach (Entity item in page.DrawSequence) + { + if (item is Viewport viewport && windows.Contains(viewport)) + { + this.DrawViewport(context, viewport); + } + else + { + this._dispatcher.Draw(context, item); + } } + } + + /// + /// Renders the page into SVG markup. + /// + /// The page to render. + /// The rendered SVG page. + private RenderedSvgPage RenderSvg(ImagePage page) + { + PageFrame frame = this.ResolveFrame(page); + SurfaceRect viewBox = ImageRenderContext.ComputeSvgViewBox(frame, this._configuration); + SvgOptions options = this._configuration.Svg; + double? strokeUnits = options.NonScalingStroke + ? null + : ImageRenderContext.UnitsPerMillimeter(page.Document?.Header.InsUnits ?? UnitsType.Unitless); + + using SvgDrawingSurface surface = new( + this._configuration, + viewBox, + options.EmitSize ? this._configuration.Width : null, + options.EmitSize ? this._configuration.Height : null, + strokeUnits); + + ImageRenderContext context = ImageRenderContext.CreateSvgPageContext(surface, frame, this._configuration, strokeUnits); + + this.RenderTo(context, page); - return new RenderedImagePage(page.Name, image); + return new RenderedSvgPage(page.Name, surface.ToSvgString()); } + /// + /// Surface units per linetype unit inside a viewport, decided by the PSLTSCALE header variable. + /// + /// Header of the document being rendered, or null when there is none. + /// Surface units per linetype unit on the page around the viewport. + /// Paper units per model unit shown by the viewport. + /// + /// when linetypes are scaled to paper space, so dashes are the same length + /// everywhere on the sheet; otherwise it times , so dashes keep their model-space + /// length and shrink with the viewport. + /// + /// + /// 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 + /// SpaceLineTypeScaling.Normal is 1. + /// + internal static double ResolveViewportLineTypeScale(CadHeader? header, double pageLineTypeScale, double viewportScaleFactor) + { + int psltscale = header == null ? 1 : (int)header.PaperSpaceLineTypeScaling; + return psltscale == 1 ? pageLineTypeScale : pageLineTypeScale * viewportScaleFactor; + } + + /// + /// Draws one paper-space viewport: its window on the page surface, and the model-space entities it shows. + /// + /// The page-level context the viewport sits on. + /// The viewport to draw. private void DrawViewport(ImageRenderContext pageContext, Viewport viewport) { BoundingBox viewportBounds = viewport.GetBoundingBox(); - int viewportWidth = Math.Max(1, (int)Math.Ceiling(pageContext.ToPixelLength(viewportBounds.LengthX))); - int viewportHeight = Math.Max(1, (int)Math.Ceiling(pageContext.ToPixelLength(viewportBounds.LengthY))); + // Exact size for both backends; the raster surface rounds its own image up to whole pixels. + double viewportWidth = pageContext.ToSurfaceLength(viewportBounds.LengthX); + double viewportHeight = pageContext.ToSurfaceLength(viewportBounds.LengthY); BoundingBox modelBounds = viewport.GetModelBoundingBox(); - using var viewportImage = new Image(viewportWidth, viewportHeight, ImageColor.Transparent); - ImageRenderContext viewportContext = ImageRenderContext.CreateViewportContext( - viewportImage, - pageContext.Layout, - this._configuration, - modelBounds, - pageContext.PixelsPerUnit * (float)viewport.ScaleFactor); + SurfacePoint topLeft = pageContext.ToSurfacePoint(new XY(viewportBounds.Min.X, viewportBounds.Max.Y)); + ViewportSurface viewportSurface = pageContext.Surface.BeginViewport(new SurfaceRect(topLeft.X, topLeft.Y, viewportWidth, viewportHeight)); - foreach (Entity entity in viewport.SelectEntities()) + double scale = pageContext.SinglePrecision + ? (float)pageContext.Scale * (float)viewport.ScaleFactor + : pageContext.Scale * viewport.ScaleFactor; + double lineTypeScale = ResolveViewportLineTypeScale(viewport.Document?.Header, pageContext.LineTypeScale, viewport.ScaleFactor); + ImageRenderContext viewportContext = ImageRenderContext.CreateViewportContext(pageContext, viewport, viewportSurface, viewportWidth, modelBounds, scale, lineTypeScale); + + foreach (Entity entity in this.SelectViewportEntities(viewport)) { this._dispatcher.Draw(viewportContext, entity); } - PointF destination = pageContext.ToPixelPoint(new XY(viewportBounds.Min.X, viewportBounds.Max.Y)); - pageContext.Canvas.Mutate(x => x.DrawImage(viewportImage, new ImagePoint((int)MathF.Round(destination.X), (int)MathF.Round(destination.Y)), 1f)); + pageContext.Surface.EndViewport(viewportSurface); + } + + /// + /// The model-space entities a viewport shows, in the drawing's draw order: those whose bounds + /// (, the same bounds the page framer uses, so a wipeout or an OCS solid is culled by + /// the region it actually draws, not ACadSharp's raw GetBoundingBox) overlap or touch the view box in the + /// XY plane (), including an entity that encloses the view box or crosses it + /// without either endpoint inside it. This does not mirror Viewport.SelectEntities, whose corner-based + /// BoundingBox.IsIn check culls both of those cases; an entity cannot bound at + /// all is skipped with a warning instead of aborting the page. + /// + /// The viewport to select the contents of. + /// The model-space entities to draw inside the viewport. + internal IEnumerable SelectViewportEntities(Viewport viewport) + { + if (viewport.Document == null) + { + this._configuration.Notify($"[{viewport.SubclassMarker}] Handle {viewport.Handle.ToString("X", CultureInfo.InvariantCulture)}: viewport has no document; contents skipped.", NotificationType.Warning); + yield break; + } + + BoundingBox box = viewport.GetModelBoundingBox(); + foreach (Entity entity in viewport.Document.ModelSpace.GetSortedEntities()) + { + if (entity is Insert { Block: null }) + { + // Called out ahead of EntityBounds.TryGet so the warning names the actual cause: an unresolved + // block reference, not "bounds could not be computed". + this._configuration.Notify($"[{entity.SubclassMarker}] Handle {entity.Handle.ToString("X", CultureInfo.InvariantCulture)}: block reference has no block; skipped in viewport.", NotificationType.Warning); + continue; + } + + if (!EntityBounds.TryGet(entity, out BoundingBox bounds, out Exception? error)) + { + // error is null when the entity has no bounds for a reason that is not a computation failure (a + // wipeout that would draw nothing because ShowImage is off): nothing is wrong with it, so it is + // skipped without a Warning. + if (error != null) + { + this._configuration.Notify($"[{entity.SubclassMarker}] Handle {entity.Handle.ToString("X", CultureInfo.InvariantCulture)}: bounds could not be computed ({error.Message}); entity skipped in viewport.", NotificationType.Warning, error); + } + + continue; + } + + if (HasNaNBound(bounds)) + { + // OverlapsInPlane's <=/>= comparisons are all false against a NaN bound (IEEE 754: every comparison + // with NaN is false), so the entity below is culled the same way a strictly-outside one is, but + // silently; notify the same way Draw does for the same condition instead of letting it vanish. An + // infinite bound is not this case: +/-Infinity compares correctly against a finite window (an + // unbounded entity genuinely overlaps it), so it is left to OverlapsInPlane and, if selected, to + // Draw's own HasFiniteGeometry/entity-type handling — narrowing this check to NaN only keeps it from + // pre-empting that with a "non-finite" message that would be wrong for a legitimately unbounded + // entity (an XLine or Ray, say). + this._configuration.Notify($"[{entity.SubclassMarker}] Handle {entity.Handle.ToString("X", CultureInfo.InvariantCulture)}: geometry contains non-finite values; entity skipped in viewport.", NotificationType.Warning); + continue; + } + + if (OverlapsInPlane(box, bounds)) + { + yield return entity; + } + } } + + /// + /// True when two bounds overlap or touch in the XY plane (Z ignored), by axis-aligned interval overlap on X and + /// Y independently. Unlike BoundingBox.IsIn, this also keeps an entity whose bounds enclose + /// entirely, or cross it without either bound's own corner lying inside the other. + /// + /// The viewport's model-space view box. + /// The candidate entity's bounds. + /// True when the two bounds overlap or touch on both axes. + private static bool OverlapsInPlane(BoundingBox window, BoundingBox bounds) + { + return bounds.Min.X <= window.Max.X && bounds.Max.X >= window.Min.X + && bounds.Min.Y <= window.Max.Y && bounds.Max.Y >= window.Min.Y; + } + + /// + /// True when any of the four X/Y components reads (Z is ignored there, so a NaN Z + /// is never silently culled) is NaN. Every IEEE 754 comparison against NaN is false, so a NaN bound makes + /// return false regardless of the others; an infinite bound is not this case + /// (+/-Infinity compares correctly against a finite window), so it is deliberately not checked here. + /// + /// The bounds to check. + /// True when has a NaN X or Y component. + private static bool HasNaNBound(BoundingBox bounds) => + double.IsNaN(bounds.Min.X) || double.IsNaN(bounds.Min.Y) || + double.IsNaN(bounds.Max.X) || double.IsNaN(bounds.Max.Y); } diff --git a/ACadSharp.Image/Rendering/ImageRenderContext.cs b/ACadSharp.Image/Rendering/ImageRenderContext.cs index 9109ddb..7f313e4 100644 --- a/ACadSharp.Image/Rendering/ImageRenderContext.cs +++ b/ACadSharp.Image/Rendering/ImageRenderContext.cs @@ -1,61 +1,139 @@ +using ACadSharp.Entities; using ACadSharp.Objects; -using ACadSharp.Image.Extensions; +using ACadSharp.Types.Units; using CSMath; -using SixLabors.ImageSharp; -using Rgba32 = SixLabors.ImageSharp.PixelFormats.Rgba32; namespace ACadSharp.Image.Rendering; +/// +/// Maps drawing coordinates onto an . +/// +/// +/// +/// x = OffsetX + (p.X - OriginX) * Scale and y = SurfaceHeight - OffsetY - (p.Y - OriginY) * Scale. +/// +/// +/// When is true the arithmetic is performed in in the same order +/// the original raster renderer used, so raster output stays pixel-identical. +/// +/// internal sealed class ImageRenderContext { - public SixLabors.ImageSharp.Image Canvas { get; } + public ImageRenderContext( + IDrawingSurface surface, + ImageConfiguration configuration, + Layout layout, + double surfaceWidth, + double surfaceHeight, + double originX, + double originY, + double scale, + double offsetX, + double offsetY, + bool singlePrecision, + double lineTypeScale, + Viewport? viewport = null, + ImageRenderContext? parent = null, + double? strokeUnitsPerMillimeter = null, + double pixelsPerSurfaceUnit = 1d) + { + this.Surface = surface; + this.Configuration = configuration; + this.Layout = layout; + this.SurfaceWidth = surfaceWidth; + this.SurfaceHeight = surfaceHeight; + this.OriginX = originX; + this.OriginY = originY; + this.Scale = scale; + this.OffsetX = offsetX; + this.OffsetY = offsetY; + this.SinglePrecision = singlePrecision; + this.LineTypeScale = lineTypeScale; + this.Viewport = viewport; + this.Parent = parent; + this.StrokeUnitsPerMillimeter = strokeUnitsPerMillimeter; + this.PixelsPerSurfaceUnit = pixelsPerSurfaceUnit; + } + /// Surface that receives the primitives produced from this context. + public IDrawingSurface Surface { get; } + + /// Configuration driving the export. public ImageConfiguration Configuration { get; } + /// Layout the content belongs to. public Layout Layout { get; } - public int PixelWidth { get; } + /// Surface width in surface units. + public double SurfaceWidth { get; } - public int PixelHeight { get; } + /// Surface height in surface units; used to flip the Y axis. + public double SurfaceHeight { get; } + /// Drawing X coordinate that maps onto . public double OriginX { get; } + /// Drawing Y coordinate that maps onto SurfaceHeight - OffsetY. public double OriginY { get; } - public float PixelsPerUnit { get; } + /// Surface units per drawing unit. + public double Scale { get; } - public float OffsetX { get; } + /// Surface X of the mapped . + public double OffsetX { get; } - public float OffsetY { get; } + /// Surface offset of the mapped from the surface bottom. + public double OffsetY { get; } - public ImageRenderContext( - SixLabors.ImageSharp.Image canvas, - ImageConfiguration configuration, - Layout layout, - int pixelWidth, - int pixelHeight, - double originX, - double originY, - float pixelsPerUnit, - float offsetX = 0f, - float offsetY = 0f) + /// True for the raster backend: reproduces the original float arithmetic exactly. + public bool SinglePrecision { get; } + + /// + /// Surface units per linetype unit. Equal to for the raster backend and for SVG in + /// drawing-unit stroke mode; the SVG non-scaling-stroke page context uses the raster fit scale instead, because the + /// browser computes dash patterns in pixel space there. Inside a viewport it is the page value when PSLTSCALE + /// scales linetypes to paper space, and the page value times the viewport scale factor when it does not. + /// + public double LineTypeScale { get; } + + /// Viewport whose contents are being drawn, or null for page-level content. + public Viewport? Viewport { get; } + + /// Context that opened this one, or null for the page context. + public ImageRenderContext? Parent { get; } + + /// + /// Drawing units per millimetre used to express stroke widths, or null when stroke widths are pixels. + /// The raster backend and the SVG non-scaling-stroke mode leave this null; SVG drawing-unit mode sets it. + /// + public double? StrokeUnitsPerMillimeter { get; } + + /// + /// Pixels per surface unit. One for the raster backend, whose surface units are already pixels; the page fit scale + /// for SVG, whose surface units are drawing units. Sizes expressed in pixels are divided by it. + /// + public double PixelsPerSurfaceUnit { get; } + + /// + /// Creates the page-level context that maps paper space onto the full surface. + /// + /// Surface receiving the page content. + /// Page being rendered. + /// Configuration driving the export. + /// A context centred on the drawable area left by the configured padding. + public static ImageRenderContext CreatePageContext(IDrawingSurface surface, ImagePage page, ImageConfiguration configuration) { - this.Canvas = canvas; - this.Configuration = configuration; - this.Layout = layout; - this.PixelWidth = pixelWidth; - this.PixelHeight = pixelHeight; - this.OriginX = originX; - this.OriginY = originY; - this.PixelsPerUnit = pixelsPerUnit; - this.OffsetX = offsetX; - this.OffsetY = offsetY; + return CreatePageContext(surface, PageFrame.Of(page), configuration); } - public static ImageRenderContext CreatePageContext( - SixLabors.ImageSharp.Image canvas, - ImagePage page, - ImageConfiguration configuration) + /// + /// Creates the page-level context that maps a page frame onto the full surface. + /// + /// Surface receiving the page content. + /// Frame being rendered; the page's own, or one fitted to its visible entities. + /// Configuration driving the export. + /// A context centred on the drawable area left by the configured padding. + public static ImageRenderContext CreatePageContext(IDrawingSurface surface, PageFrame frame, ImageConfiguration configuration) { int drawableWidth = configuration.Width - configuration.PaddingLeft - configuration.PaddingRight; int drawableHeight = configuration.Height - configuration.PaddingTop - configuration.PaddingBottom; @@ -64,9 +142,9 @@ public static ImageRenderContext CreatePageContext( throw new InvalidOperationException("Padding must leave at least one drawable pixel in both dimensions."); } - Layout layout = page.Layout ?? new Layout("default_page"); - double pageWidth = Math.Max(1d, layout.PaperWidth); - double pageHeight = Math.Max(1d, layout.PaperHeight); + Layout layout = frame.Layout; + double pageWidth = Math.Max(1d, frame.PaperWidth); + double pageHeight = Math.Max(1d, frame.PaperHeight); float pixelsPerUnit = Math.Min( drawableWidth / (float)pageWidth, drawableHeight / (float)pageHeight); @@ -76,11 +154,11 @@ public static ImageRenderContext CreatePageContext( float offsetX = configuration.PaddingLeft + ((drawableWidth - scaledWidth) / 2f); float offsetY = configuration.PaddingBottom + ((drawableHeight - scaledHeight) / 2f); - double originX = -page.Translation.X - layout.UnprintableMargin.Left; - double originY = -page.Translation.Y - layout.UnprintableMargin.Bottom; + double originX = -frame.Translation.X - layout.UnprintableMargin.Left; + double originY = -frame.Translation.Y - layout.UnprintableMargin.Bottom; return new ImageRenderContext( - canvas, + surface, configuration, layout, configuration.Width, @@ -89,41 +167,213 @@ public static ImageRenderContext CreatePageContext( originY, pixelsPerUnit, offsetX, - offsetY); + offsetY, + singlePrecision: true, + lineTypeScale: pixelsPerUnit, + pixelsPerSurfaceUnit: 1d); } - public static ImageRenderContext CreateViewportContext( - SixLabors.ImageSharp.Image canvas, - Layout layout, - ImageConfiguration configuration, - BoundingBox modelBounds, - float pixelsPerUnit) + /// + /// Creates the context that maps a viewport's model space onto the viewport surface. + /// + /// Context that opened the viewport. + /// Viewport being drawn. + /// Surface returned by . + /// Width of the viewport in surface units. + /// Model-space bounds shown by the viewport. + /// Surface units per model unit. + /// Surface units per linetype unit, decided by the PSLTSCALE header variable. + /// A context whose origin is the bottom-left corner of . + public static ImageRenderContext CreateViewportContext(ImageRenderContext parent, Viewport viewport, ViewportSurface surface, double surfaceWidth, BoundingBox modelBounds, double scale, double lineTypeScale) + { + return new ImageRenderContext( + surface.Surface, + parent.Configuration, + parent.Layout, + surfaceWidth: surfaceWidth, + surfaceHeight: surface.BottomY, + originX: modelBounds.Min.X, + originY: modelBounds.Min.Y, + scale: scale, + offsetX: surface.OffsetX, + offsetY: 0d, + singlePrecision: parent.SinglePrecision, + lineTypeScale: lineTypeScale, + viewport: viewport, + parent: parent, + strokeUnitsPerMillimeter: parent.StrokeUnitsPerMillimeter, + pixelsPerSurfaceUnit: parent.PixelsPerSurfaceUnit); + } + + /// + /// Computes the SVG viewBox for a page frame: the frame rectangle in drawing units grown by the configured + /// padding, converted to drawing units with the same fit scale the raster backend would use. + /// + /// Frame being rendered. + /// Configuration driving the export. + /// The viewBox rectangle in drawing units. + public static SurfaceRect ComputeSvgViewBox(PageFrame frame, ImageConfiguration configuration) { + int drawableWidth = configuration.Width - configuration.PaddingLeft - configuration.PaddingRight; + int drawableHeight = configuration.Height - configuration.PaddingTop - configuration.PaddingBottom; + if (drawableWidth <= 0 || drawableHeight <= 0) + { + throw new InvalidOperationException("Padding must leave at least one drawable pixel in both dimensions."); + } + + double pageWidth = Math.Max(1d, frame.PaperWidth); + double pageHeight = Math.Max(1d, frame.PaperHeight); + double fit = Math.Min(drawableWidth / pageWidth, drawableHeight / pageHeight); + + double left = configuration.PaddingLeft / fit; + double top = configuration.PaddingTop / fit; + double right = configuration.PaddingRight / fit; + double bottom = configuration.PaddingBottom / fit; + return new SurfaceRect(-left, -top, pageWidth + left + right, pageHeight + top + bottom); + } + + /// + /// Pixels per drawing unit the raster fit would use for a page frame; SVG uses it to convert padding and, + /// in non-scaling-stroke mode, dash lengths into pixels. + /// + /// Frame being rendered. + /// Configuration driving the export. + /// The fit scale in pixels per drawing unit. + public static double ComputeSvgFitScale(PageFrame frame, ImageConfiguration configuration) + { + int drawableWidth = configuration.Width - configuration.PaddingLeft - configuration.PaddingRight; + int drawableHeight = configuration.Height - configuration.PaddingTop - configuration.PaddingBottom; + return Math.Min(drawableWidth / Math.Max(1d, frame.PaperWidth), drawableHeight / Math.Max(1d, frame.PaperHeight)); + } + + /// + /// Creates the page-level context for the SVG backend from a page frame: drawing units one-to-one, the padding + /// living in the viewBox margin rather than in an offset. + /// + /// Surface receiving the page content. + /// Frame being rendered; the page's own, or one fitted to its visible entities. + /// Configuration driving the export. + /// Drawing units per millimetre for stroke widths, or null to keep pixel widths. + /// A double-precision context whose surface units are drawing units. + public static ImageRenderContext CreateSvgPageContext(IDrawingSurface surface, PageFrame frame, ImageConfiguration configuration, double? strokeUnitsPerMillimeter) + { + Layout layout = frame.Layout; + double pageWidth = Math.Max(1d, frame.PaperWidth); + double pageHeight = Math.Max(1d, frame.PaperHeight); + double originX = -frame.Translation.X - layout.UnprintableMargin.Left; + double originY = -frame.Translation.Y - layout.UnprintableMargin.Bottom; + + // With vector-effect="non-scaling-stroke" the browser computes the dash pattern in pixel space like the width, + // so dash lengths must be pixels too. In drawing-unit mode they are drawing units (scale 1). + double lineTypeScale = strokeUnitsPerMillimeter == null ? ComputeSvgFitScale(frame, configuration) : 1d; + return new ImageRenderContext( - canvas, + surface, configuration, layout, - canvas.Width, - canvas.Height, - modelBounds.Min.X, - modelBounds.Min.Y, - pixelsPerUnit); + pageWidth, + pageHeight, + originX, + originY, + scale: 1d, + offsetX: 0d, + offsetY: 0d, + singlePrecision: false, + lineTypeScale: lineTypeScale, + strokeUnitsPerMillimeter: strokeUnitsPerMillimeter, + pixelsPerSurfaceUnit: ComputeSvgFitScale(frame, configuration)); + } + + /// + /// Drawing units per millimetre for a document unit setting. Unitless and unknown units are treated as millimetres. + /// + /// The document insertion units. + /// The number of drawing units in one millimetre. + internal static double UnitsPerMillimeter(UnitsType units) + { + return units switch + { + UnitsType.Millimeters => 1d, + UnitsType.Centimeters => 0.1d, + UnitsType.Meters => 0.001d, + UnitsType.Kilometers => 0.000001d, + UnitsType.Inches => 1d / 25.4d, + UnitsType.Feet => 1d / 304.8d, + UnitsType.Yards => 1d / 914.4d, + UnitsType.Miles => 1d / 1609344d, + UnitsType.Microns => 1000d, + UnitsType.Decimeters => 0.01d, + _ => 1d, + }; + } + + /// + /// Projects a drawing point onto the surface. + /// + /// Point in drawing coordinates. + /// The point in surface coordinates. + public SurfacePoint ToSurfacePoint(XY point) + { + if (this.SinglePrecision) + { + float x = (float)this.OffsetX + (float)((point.X - this.OriginX) * (float)this.Scale); + float y = (float)this.SurfaceHeight - (float)this.OffsetY - (float)((point.Y - this.OriginY) * (float)this.Scale); + return new SurfacePoint(x, y); + } + + return new SurfacePoint( + this.OffsetX + ((point.X - this.OriginX) * this.Scale), + this.SurfaceHeight - this.OffsetY - ((point.Y - this.OriginY) * this.Scale)); } - public PointF ToPixelPoint(XY point) + /// + /// Projects a drawing point onto the surface, discarding the Z coordinate. + /// + /// Point in drawing coordinates. + /// The point in surface coordinates. + public SurfacePoint ToSurfacePoint(XYZ point) { - float x = this.OffsetX + (float)((point.X - this.OriginX) * this.PixelsPerUnit); - float y = this.PixelHeight - this.OffsetY - (float)((point.Y - this.OriginY) * this.PixelsPerUnit); - return new PointF(x, y); + return this.ToSurfacePoint(point.Convert()); } - public PointF ToPixelPoint(XYZ point) + /// + /// Converts a size expressed in pixels into surface units. + /// + /// Size in pixels. + /// The size in surface units. + public double ToSurfacePixels(double pixels) => pixels / this.PixelsPerSurfaceUnit; + + /// + /// Converts a drawing length into surface units. + /// + /// Length in drawing units. + /// The length in surface units. + public double ToSurfaceLength(double value) { - return this.ToPixelPoint(point.Convert()); + return this.SinglePrecision + ? (float)value * (float)this.Scale + : value * this.Scale; } - public float ToPixelLength(double value) + /// + /// Stroke width for a line weight. Pixels from the configuration table unless + /// is set, in which case drawing units. + /// + /// Line weight to convert. + /// The stroke width in pixels or drawing units. + public float ToStrokeWidth(LineWeightType lineWeight) { - return (float)value * this.PixelsPerUnit; + if (this.StrokeUnitsPerMillimeter is not double unitsPerMillimeter) + { + return this.Configuration.GetLineWeightPixels(lineWeight); + } + + double millimeters = this.Configuration.GetLineWeightMillimeters(lineWeight); + if (millimeters <= 0d) + { + millimeters = 0.25d; + } + + return (float)(millimeters * unitsPerMillimeter * this.Configuration.LineWeightScale); } } diff --git a/ACadSharp.Image/Rendering/ImageStyle.cs b/ACadSharp.Image/Rendering/ImageStyle.cs index f7e0b43..8fa84ee 100644 --- a/ACadSharp.Image/Rendering/ImageStyle.cs +++ b/ACadSharp.Image/Rendering/ImageStyle.cs @@ -3,11 +3,25 @@ namespace ACadSharp.Image.Rendering; /// -/// Represents the visual styling applied when rendering a single CAD entity. +/// Resolved visual style for one entity. Widths and dash lengths are in surface units. /// /// -/// This immutable record bundles stroke color and width together, avoiding -/// repeated lookups during entity rendering. It is resolved from entity -/// properties (color, line weight) by . +/// new ImageStyle() / has 0 and is therefore invisible; use one +/// of the constructors instead. /// -internal readonly record struct ImageStyle(ImageColor StrokeColor, float StrokeWidth); +/// Stroke and fill colour. +/// Stroke width in surface units. +/// Alternating dash and gap lengths in surface units, or for a solid stroke. +/// Opacity from 0 (invisible) to 1 (opaque). +internal readonly record struct ImageStyle(ImageColor StrokeColor, float StrokeWidth, float[]? DashPattern, float Opacity) +{ + public ImageStyle(ImageColor strokeColor, float strokeWidth) + : this(strokeColor, strokeWidth, null, 1f) + { + } + + /// + /// Gets the stroke colour with applied to its alpha channel. + /// + public ImageColor EffectiveColor => this.Opacity >= 1f ? this.StrokeColor : this.StrokeColor.WithAlpha(this.Opacity); +} diff --git a/ACadSharp.Image/Rendering/ImageStyleResolver.cs b/ACadSharp.Image/Rendering/ImageStyleResolver.cs index b27b7f9..2570700 100644 --- a/ACadSharp.Image/Rendering/ImageStyleResolver.cs +++ b/ACadSharp.Image/Rendering/ImageStyleResolver.cs @@ -1,5 +1,9 @@ using ACadSharp.Entities; +using ACadSharp.Header; using ACadSharp.Image.Extensions; +using ACadSharp.Tables; +using CadColor = ACadSharp.Color; +using ImageColor = SixLabors.ImageSharp.Color; namespace ACadSharp.Image.Rendering; @@ -7,31 +11,132 @@ namespace ACadSharp.Image.Rendering; /// Resolves values from CAD entity properties. /// /// -/// This class reads color and line weight information from an -/// and converts it into pixel-based rendering values using the configured -/// . +/// +/// Resolution happens in two steps. substitutes ByLayer and ByBlock: ByLayer reads the +/// entity's effective layer (an entity on layer "0" inside a block takes the layer of the insert that placed +/// it, see ), ByBlock reads the placing entity's resolved +/// attributes, and at top level ByBlock falls back to the defaults AutoCAD draws it with (colour 7, default weight, +/// continuous). then converts the result into surface units for a render context. +/// +/// +/// ACadSharp's own GetActiveColor family cannot do this: it resolves ByLayer against the entity's stored layer +/// and ByBlock against the block record's owner, and the block-content clones it hands out have neither owner nor +/// document. +/// /// internal sealed class ImageStyleResolver { - private readonly ImageConfiguration _configuration; + /// AutoCAD colour index 7, the colour ByBlock resolves to when there is no block. + private static readonly CadColor ByBackground = new(7); - public ImageStyleResolver(ImageConfiguration configuration) + /// + /// Resolves an entity's colour, line weight, linetype, linetype scale and opacity. + /// + /// The entity whose attributes should be resolved. + /// The layer the entity is drawn on after layer-0 inheritance; null when it has none. + /// The resolved attributes of the insert or dimension that placed the entity, or null at top level. + /// The resolved attributes, in CAD terms. + public ResolvedStyle ResolveAttributes(Entity entity, Layer? effectiveLayer, ResolvedStyle? parent) { - this._configuration = configuration; + CadHeader? header = entity.Document?.Header ?? parent?.Header; + + CadColor color = entity.Color; + if (color.IsByLayer) + { + color = effectiveLayer?.Color ?? ByBackground; + } + else if (color.IsByBlock) + { + color = parent?.Color ?? ByBackground; + } + + if (color.IsByLayer || color.IsByBlock) + { + // A layer table entry itself set to ByLayer/ByBlock is malformed; draw it as the default colour. + color = ByBackground; + } + + LineWeightType lineWeight = entity.LineWeight switch + { + LineWeightType.ByLayer => effectiveLayer?.LineWeight ?? LineWeightType.Default, + LineWeightType.ByBlock => parent?.LineWeight ?? LineWeightType.Default, + _ => entity.LineWeight, + }; + if (lineWeight is LineWeightType.ByLayer or LineWeightType.ByBlock) + { + lineWeight = LineWeightType.Default; + } + + LineType? lineType = entity.LineType; + if (IsNamed(lineType, LineType.ByLayerName)) + { + lineType = effectiveLayer?.LineType; + } + else if (IsNamed(lineType, LineType.ByBlockName)) + { + lineType = parent?.LineType; + } + + if (IsNamed(lineType, LineType.ByLayerName) || IsNamed(lineType, LineType.ByBlockName)) + { + lineType = null; + } + + double lineTypeScale = (entity.LineTypeScale > 0d ? entity.LineTypeScale : 1d) * (parent?.LineTypeScale ?? 1d); + float opacity = ResolveOpacity(entity, parent?.Opacity ?? 1f); + + return new ResolvedStyle(color, lineWeight, lineType, lineTypeScale, opacity, header); } /// - /// Resolves the visual style for a CAD entity. + /// Converts resolved attributes into the surface-unit style a context draws with. /// - /// The entity whose style should be resolved. + /// Attributes from . + /// The context that maps drawing units onto the surface. + /// The colour to use for AutoCAD colour index 7 ("ByBackground"). /// - /// An containing the stroke color (in RGBA) - /// and stroke width (in pixels) for the entity. + /// An containing the stroke color (in RGBA), stroke width and dash pattern (in surface + /// units), and opacity. /// - public ImageStyle Resolve(Entity entity) + public ImageStyle ToImageStyle(ResolvedStyle resolved, ImageRenderContext context, ImageColor foreground) { + float width = context.ToStrokeWidth(resolved.LineWeight); return new ImageStyle( - entity.GetActiveColor().ToImageColor(), - this._configuration.GetLineWeightPixels(entity.GetActiveLineWeightType())); + resolved.Color.ToImageColor(foreground), + width, + LineTypeDashResolver.Resolve(resolved.LineType, resolved.Header, resolved.LineTypeScale, context, width), + resolved.Opacity); + } + + /// + /// Maps CAD transparency to opacity. ByLayer is opaque (ACadSharp 3.7.1 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) + { + Transparency transparency = entity.Transparency; + if (transparency.IsByLayer) + { + return 1f; + } + + if (transparency.IsByBlock) + { + return parentOpacity; + } + + return Math.Clamp(1f - (transparency.Value / 100f), 0f, 1f); + } + + /// + /// True when is not null and its name matches case-insensitively, + /// as used to recognise the synthetic "ByLayer"/"ByBlock" linetypes. + /// + /// The linetype to check, or null. + /// The name to compare against, typically or . + /// True when is named . + internal static bool IsNamed(LineType? lineType, string name) + { + return lineType != null && string.Equals(lineType.Name, name, StringComparison.OrdinalIgnoreCase); } } diff --git a/ACadSharp.Image/Rendering/InsertPlacement.cs b/ACadSharp.Image/Rendering/InsertPlacement.cs new file mode 100644 index 0000000..fa9d882 --- /dev/null +++ b/ACadSharp.Image/Rendering/InsertPlacement.cs @@ -0,0 +1,90 @@ +using CSMath; + +namespace ACadSharp.Image.Rendering; + +/// +/// Maps geometry through the transform of the block reference that placed it. A placement of null means the +/// entity is at top level and every map is the identity. +/// +/// +/// 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 +/// (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. +/// +internal static class InsertPlacement +{ + /// Maps a world point through the placement. + /// The transform of the insert that placed the entity, or null at top level. + /// The world point. + /// The placed world point. + internal static XYZ MapPoint(Transform? placement, XYZ point) => placement == null ? point : placement.ApplyTransform(point); + + /// Maps a world direction through the placement, keeping the linear part and dropping the translation. + /// The transform of the insert that placed the entity, or null at top level. + /// The world direction. + /// The placed direction, scaled and rotated but not translated. + internal static XYZ MapVector(Transform? placement, XYZ vector) + { + if (placement == null) + { + return vector; + } + + return placement.ApplyTransform(vector) - placement.ApplyTransform(XYZ.Zero); + } + + /// + /// Maps a point stored in an entity's own object coordinate system: the OCS frame first (with the entity's + /// elevation as the out-of-plane offset), then the placement. + /// + /// The transform of the insert that placed the entity, or null at top level. + /// The entity's OCS frame, or null when it lies in the world plane. + /// The entity's elevation along its own normal. + /// The point in the entity's OCS. + /// The placed world point. + internal static XYZ MapOcsPoint(Transform? placement, OcsTransform? toWorld, double elevation, XYZ ocsPoint) + { + XYZ world = toWorld != null ? toWorld.ToWorld(ocsPoint.X, ocsPoint.Y, elevation) : ocsPoint; + return MapPoint(placement, world); + } + + /// + /// Whether the placement acts on the drawing plane as a similarity: one uniform scale and a rotation, optionally + /// with a reflection. Geometry that has to be handed back to ACadSharp as an Insert can only be expressed + /// when this holds, because an Insert has no way to represent the shear a non-uniform scale composed with + /// a rotation produces. + /// + /// The transform to test, or null at top level. + /// Receives the uniform scale. + /// Receives the rotation of the mapped X axis, in radians. + /// Receives whether the mapped Y axis lies clockwise from the mapped X axis. + /// True when the placement is a planar similarity. + internal static bool TryGetPlanarSimilarity(Transform? placement, out double scale, out double rotation, out bool mirrored) + { + XYZ ex = MapVector(placement, XYZ.AxisX); + XYZ ey = MapVector(placement, XYZ.AxisY); + XY x = new(ex.X, ex.Y); + XY y = new(ey.X, ey.Y); + double lx = x.GetLength(); + double ly = y.GetLength(); + scale = lx; + rotation = 0d; + mirrored = false; + if (lx < 1e-12 || ly < 1e-12 || !double.IsFinite(lx) || !double.IsFinite(ly)) + { + return false; + } + + // A similarity keeps both axes the same length and at right angles; the tolerances are relative so a drawing + // in millimetres and one in metres are judged the same way. + if (Math.Abs(lx - ly) > 1e-9 * lx || Math.Abs((x.X * y.X) + (x.Y * y.Y)) > 1e-9 * lx * ly) + { + return false; + } + + rotation = Math.Atan2(x.Y, x.X); + mirrored = (x.X * y.Y) - (x.Y * y.X) < 0d; + return true; + } +} diff --git a/ACadSharp.Image/Rendering/LineTypeDashResolver.cs b/ACadSharp.Image/Rendering/LineTypeDashResolver.cs new file mode 100644 index 0000000..3fd6898 --- /dev/null +++ b/ACadSharp.Image/Rendering/LineTypeDashResolver.cs @@ -0,0 +1,128 @@ +using ACadSharp.Entities; +using ACadSharp.Header; +using ACadSharp.Tables; + +namespace ACadSharp.Image.Rendering; + +/// +/// Turns a CAD linetype into an alternating dash/gap array in surface units. +/// +internal static class LineTypeDashResolver +{ + /// + /// Resolves the dash pattern of a resolved linetype. + /// + /// The linetype after ByLayer/ByBlock substitution, or null for a solid stroke. + /// Header supplying LTSCALE, or null when the entity belongs to no document (scale 1). + /// The entity's effective CELTSCALE, including every enclosing insert's. + /// The context that maps linetype units onto the surface. + /// The stroke width in surface units; dots are drawn as a dash this long. + /// + /// Alternating dash and gap lengths in surface units, or for a solid stroke. + /// + public static float[]? Resolve(LineType? lineType, CadHeader? header, double lineTypeScale, ImageRenderContext context, float strokeWidth) + { + if (lineType == null) + { + return null; + } + + double ltscale = header != null && header.LineTypeScale > 0d ? header.LineTypeScale : 1d; + double celtscale = lineTypeScale > 0d ? lineTypeScale : 1d; + float[]? pattern = BuildPattern(lineType, ltscale * celtscale * context.LineTypeScale, strokeWidth); + if (pattern == null) + { + return null; + } + + // A huge LTSCALE or CELTSCALE overflows the pattern to infinity, which no surface can dash with. + if (pattern.Any(v => !float.IsFinite(v))) + { + return null; + } + + if (EnforcesMinimumDash(context) && pattern.Sum() < context.Configuration.MinimumDashPixels) + { + return null; + } + + return pattern; + } + + /// + /// Indicates whether applies in a context. + /// + /// The context the entity is drawn in. + /// True when the surface expresses stroke sizes in pixels, so the minimum is meaningful. + public static bool EnforcesMinimumDash(ImageRenderContext context) + { + return context.StrokeUnitsPerMillimeter == null; + } + + /// + /// Builds the dash pattern of a linetype, scaled into surface units. + /// + /// The linetype to convert. + /// Surface units per linetype unit, including LTSCALE and CELTSCALE. + /// The stroke width in surface units; dots are drawn as a dash this long. + /// + /// Alternating dash and gap lengths in surface units, or when the linetype has no gaps + /// and is therefore solid. + /// + public static float[]? BuildPattern(LineType lineType, double scale, float strokeWidth) + { + List<(bool On, float Length)> entries = new(); + foreach (LineType.Segment segment in lineType.Segments) + { + double length = segment.Length * scale; + bool on; + float value; + if (segment.IsShape || segment.IsText) + { + on = false; + value = (float)Math.Abs(length); + } + else if (length > 0d) + { + on = true; + value = (float)length; + } + else if (length < 0d) + { + on = false; + value = (float)-length; + } + else + { + on = true; + value = strokeWidth; + } + + if (entries.Count > 0 && entries[^1].On == on) + { + entries[^1] = (on, entries[^1].Length + value); + } + else + { + entries.Add((on, value)); + } + } + + if (entries.Count == 0 || !entries.Any(e => !e.On)) + { + return null; + } + + if (!entries[0].On) + { + entries.Insert(0, (true, 0f)); + } + + if (entries.Count % 2 == 1) + { + entries.Add((false, 0f)); + } + + return entries.Select(e => e.Length).ToArray(); + } +} diff --git a/ACadSharp.Image/Rendering/OcsTransform.cs b/ACadSharp.Image/Rendering/OcsTransform.cs new file mode 100644 index 0000000..c7775d9 --- /dev/null +++ b/ACadSharp.Image/Rendering/OcsTransform.cs @@ -0,0 +1,112 @@ +using CSMath; + +namespace ACadSharp.Image.Rendering; + +/// +/// Maps object coordinate system (OCS) points of a planar entity into world coordinates. +/// +/// +/// +/// 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 +/// Hatch.ExplodePattern applies the normal. The renderer projects world XY onto the page, so those points must +/// be brought into world space first. +/// +/// +/// The frame follows the AutoCAD arbitrary axis algorithm (DXF reference, "Arbitrary Axis Algorithm"): when the normal +/// is within 1/64 of the world Z axis the OCS X axis is Wy x N, otherwise Wz x N; the OCS Y axis is +/// N x Ax. It is written out here rather than taken from CSMath.Matrix3.ArbitraryAxis, whose result is +/// not orthonormal for tilted normals in the pinned version. A (0,0,-1) normal, the common case, mirrors X. +/// +/// +internal sealed class OcsTransform +{ + private const double AxisThreshold = 1d / 64d; + + private OcsTransform(XYZ axisX, XYZ axisY, XYZ normal) + { + this.AxisX = axisX; + this.AxisY = axisY; + this.Normal = normal; + } + + /// OCS X axis in world coordinates. + public XYZ AxisX { get; } + + /// OCS Y axis in world coordinates. + public XYZ AxisY { get; } + + /// OCS Z axis (the unit normal) in world coordinates. + public XYZ Normal { get; } + + /// + /// True when an entity's extrusion is the world Z axis, so its OCS coordinates are already world coordinates. + /// + /// The entity's extrusion direction. + /// True for the default normal. + public static bool IsWorldPlane(XYZ normal) + { + return Math.Abs(normal.X) < 1e-9 && Math.Abs(normal.Y) < 1e-9 && Math.Abs(normal.Z - 1d) < 1e-9; + } + + /// + /// Builds the OCS-to-world frame for an extrusion normal. + /// + /// The entity's extrusion direction; it need not be unit length. + /// The frame, or the identity frame when the normal is degenerate (zero or non-finite). + public static OcsTransform For(XYZ normal) + { + double length = Math.Sqrt((normal.X * normal.X) + (normal.Y * normal.Y) + (normal.Z * normal.Z)); + if (!double.IsFinite(length) || length < 1e-12) + { + return new OcsTransform(XYZ.AxisX, XYZ.AxisY, XYZ.AxisZ); + } + + XYZ n = new(normal.X / length, normal.Y / length, normal.Z / length); + XYZ axisX = Math.Abs(n.X) < AxisThreshold && Math.Abs(n.Y) < AxisThreshold + ? Normalize(new XYZ(n.Z, 0d, -n.X)) // Wy x N + : Normalize(new XYZ(-n.Y, n.X, 0d)); // Wz x N + XYZ axisY = Cross(n, axisX); + return new OcsTransform(axisX, axisY, n); + } + + /// + /// Transforms an OCS point into world coordinates and drops Z, which the page projection ignores. + /// + /// OCS X. + /// OCS Y. + /// OCS Z; the entity's Elevation. + /// The world XY of the point. + public XY ToWorldXY(double x, double y, double elevation) + { + return new XY( + (x * this.AxisX.X) + (y * this.AxisY.X) + (elevation * this.Normal.X), + (x * this.AxisX.Y) + (y * this.AxisY.Y) + (elevation * this.Normal.Y)); + } + + /// + /// Transforms an OCS point into world coordinates. + /// + /// OCS X. + /// OCS Y. + /// OCS Z; the entity's Elevation. + /// The world point. + public XYZ ToWorld(double x, double y, double elevation) + { + return new XYZ( + (x * this.AxisX.X) + (y * this.AxisY.X) + (elevation * this.Normal.X), + (x * this.AxisX.Y) + (y * this.AxisY.Y) + (elevation * this.Normal.Y), + (x * this.AxisX.Z) + (y * this.AxisY.Z) + (elevation * this.Normal.Z)); + } + + private static XYZ Normalize(XYZ v) + { + double length = Math.Sqrt((v.X * v.X) + (v.Y * v.Y) + (v.Z * v.Z)); + return new XYZ(v.X / length, v.Y / length, v.Z / length); + } + + private static XYZ Cross(XYZ a, XYZ b) + { + return new XYZ((a.Y * b.Z) - (a.Z * b.Y), (a.Z * b.X) - (a.X * b.Z), (a.X * b.Y) - (a.Y * b.X)); + } +} diff --git a/ACadSharp.Image/Rendering/PageFrame.cs b/ACadSharp.Image/Rendering/PageFrame.cs new file mode 100644 index 0000000..7c33010 --- /dev/null +++ b/ACadSharp.Image/Rendering/PageFrame.cs @@ -0,0 +1,30 @@ +using ACadSharp.Objects; +using CSMath; + +namespace ACadSharp.Image.Rendering; + +/// +/// The rectangle of drawing space a page maps onto the output: the paper size and the translation that moves the +/// content's minimum corner to the origin, plus the layout that supplies the unprintable margins. +/// +/// +/// A frame is a value taken from an (or computed from a subset of its entities when layers are +/// filtered) so that rendering can fit the visible content without writing anything back to the page. +/// +/// Layout whose margins apply; the page's own or a default one. +/// Offset applied to drawing coordinates before fitting. +/// Width of the framed area in drawing units. +/// Height of the framed area in drawing units. +internal readonly record struct PageFrame(Layout Layout, XY Translation, double PaperWidth, double PaperHeight) +{ + /// + /// The frame a page currently carries. + /// + /// The page. + /// Its translation and paper size. + public static PageFrame Of(ImagePage page) + { + Layout layout = page.Layout ?? new Layout("default_page"); + return new PageFrame(layout, page.Translation, layout.PaperWidth, layout.PaperHeight); + } +} diff --git a/ACadSharp.Image/Rendering/RasterDrawingSurface.cs b/ACadSharp.Image/Rendering/RasterDrawingSurface.cs new file mode 100644 index 0000000..dd670a8 --- /dev/null +++ b/ACadSharp.Image/Rendering/RasterDrawingSurface.cs @@ -0,0 +1,372 @@ +using System.Numerics; +using ACadSharp.IO; +using SixLabors.Fonts; +using SixLabors.ImageSharp; +using SixLabors.ImageSharp.Drawing; +using SixLabors.ImageSharp.Drawing.Processing; +using SixLabors.ImageSharp.Processing; +using ImageColor = SixLabors.ImageSharp.Color; +using ImagePoint = SixLabors.ImageSharp.Point; +using Rgba32 = SixLabors.ImageSharp.PixelFormats.Rgba32; + +namespace ACadSharp.Image.Rendering; + +/// +/// backed by an ImageSharp . +/// +/// +/// Primitives map onto the same ImageSharp.Drawing calls the pre-abstraction renderer used; callers keep the closing heuristic and curve tessellation, so routed output is pixel-identical. +/// Curves are not supported natively; callers tessellate them ( is false). +/// +internal sealed class RasterDrawingSurface : IDrawingSurface +{ + private readonly ImageConfiguration _configuration; + private readonly bool _ownsCanvas; + private readonly Dictionary Image, SurfaceRect Bounds)> _viewports = new(); + + public RasterDrawingSurface(Image canvas, ImageConfiguration configuration, bool ownsCanvas) + { + this.Canvas = canvas; + this._configuration = configuration; + this._ownsCanvas = ownsCanvas; + } + + public Image Canvas { get; } + + public bool SupportsCurves => false; + + public void BeginEntity(EntityRenderInfo info, LayerRenderInfo layer) + { + } + + 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))); + } + + public void DrawPolyline(ImageStyle style, IReadOnlyList points, bool closed) + { + if (points.Count < 2) + { + return; + } + + PointF[] pixels = new PointF[closed ? points.Count + 1 : points.Count]; + for (int i = 0; i < points.Count; i++) + { + pixels[i] = ToPointF(points[i]); + } + + if (closed) + { + pixels[^1] = pixels[0]; + } + + Pen pen = CreatePen(style); + this.Canvas.Mutate(x => x.DrawLine(pen, pixels)); + } + + public void DrawArc(ImageStyle style, SurfacePoint center, double radiusX, double radiusY, double rotation, double startAngle, double sweepAngle) + { + int segments = CurveTessellation.SegmentsForSweep(sweepAngle, this._configuration.ArcPrecision); + this.DrawPolyline(style, CurveTessellation.ArcPoints(center, radiusX, radiusY, rotation, startAngle, sweepAngle, segments), closed: false); + } + + public void DrawEllipse(ImageStyle style, SurfacePoint center, double radiusX, double radiusY, double rotation) + { + this.DrawPolyline(style, CurveTessellation.ArcPoints(center, radiusX, radiusY, rotation, 0d, 2d * Math.PI, this._configuration.ArcPrecision), closed: true); + } + + public void DrawCubicBezier(ImageStyle style, IReadOnlyList controlPoints, bool closed) + { + if (controlPoints.Count < 4) + { + return; + } + + PathBuilder builder = new(); + for (int index = 0; index + 3 < controlPoints.Count; index += 3) + { + builder.AddCubicBezier( + ToPointF(controlPoints[index]), + ToPointF(controlPoints[index + 1]), + ToPointF(controlPoints[index + 2]), + ToPointF(controlPoints[index + 3])); + } + + IPath path = builder.Build(); + Pen pen = CreatePen(style); + this.Canvas.Mutate(x => x.Draw(pen, path)); + } + + public void DrawBulgePolyline(ImageStyle style, IReadOnlyList points, IReadOnlyList bulges, bool closed) + { + if (points.Count < 2) + { + return; + } + + List flattened = new(points.Count * 4) { points[0] }; + int segmentCount = closed ? points.Count : points.Count - 1; + for (int i = 0; i < segmentCount; i++) + { + SurfacePoint start = points[i]; + SurfacePoint end = points[(i + 1) % points.Count]; + double bulge = i < bulges.Count ? bulges[i] : 0d; + if (Math.Abs(bulge) < 1e-12 || start == end) + { + flattened.Add(end); + continue; + } + + CurveTessellation.BulgeArc(start, end, bulge, out SurfacePoint center, out double radius, out double startAngle, out double sweep); + IReadOnlyList arc = CurveTessellation.ArcPoints(center, radius, radius, 0d, startAngle, sweep, CurveTessellation.SegmentsForSweep(sweep, this._configuration.ArcPrecision)); + for (int j = 1; j < arc.Count; j++) + { + flattened.Add(arc[j]); + } + } + + this.DrawPolyline(style, flattened, closed: false); + } + + public void FillPolygon(ImageStyle style, IReadOnlyList points) + { + if (this.FinitePoints(points, 3) is not List finite) + { + return; + } + + PointF[] pixels = finite.Select(ToPointF).ToArray(); + ImageColor color = style.EffectiveColor; + this.Canvas.Mutate(x => x.FillPolygon(color, pixels)); + } + + public void FillPath(ImageStyle style, IReadOnlyList> rings) + { + IPath[] polygons = rings + .Select(ring => this.FinitePoints(ring, 3)) + .Where(ring => ring != null) + .Select(ring => (IPath)new Polygon(new LinearLineSegment(ring!.Select(ToPointF).ToArray()))) + .ToArray(); + if (polygons.Length == 0) + { + return; + } + + 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)); + } + + public void FillCircle(ImageStyle style, SurfacePoint center, double radius) + { + if (!IsFinite(center) || !double.IsFinite(radius)) + { + this.NotifyNonFinite(); + return; + } + + PointF pixel = ToPointF(center); + ImageColor color = style.EffectiveColor; + this.Canvas.Mutate(x => x.Fill(color, new EllipsePolygon(pixel.X, pixel.Y, (float)radius))); + } + + public void DrawText(ImageStyle style, SurfaceText text) + { + if (string.IsNullOrWhiteSpace(text.Text)) + { + return; + } + + if (!double.IsFinite(text.WidthScale) || text.WidthScale <= 0) + { + // Matches SvgDrawingSurface.DrawText's guard: Matrix3x2.CreateScale(0f, 1f, pivot) below would collapse + // the run to nothing silently instead of throwing, and a negative or non-finite scale is just as wrong. + // TextRenderer never produces one of these (both Place overloads guard length >= 1e-12), so this only + // matters to a caller driving SurfaceText directly. + this.NotifyNonFinite(); + return; + } + + PointF origin = ToPointF(text.Origin); + + // The font size is the em, 4/3 of the CAD text height, laid out at 72 dpi so one point is one pixel: text + // then scales with the page like the geometry does and not with ImageConfiguration.Dpi, which only sizes + // line weights. The SVG backend uses the same em through TextMetrics.EmSize. + Font font = this.CreateFont(TextMetrics.EmSize(text.Height)); + + // ImageSharp advances the baseline by one em per line; AutoCAD and the SVG backend space lines at 5/3 of + // the text height, that is 5/4 em, so the spacing factor carries the 5/4. ImageSharp then splits the extra + // (LineSpacing - 1) em of leading evenly above and below every line, which would displace even a single + // line, so the origin is pulled back by that half-leading on whichever end the alignment anchors: up for + // Hanging, which anchors the top, down for Alphabetic, which anchors the bottom, and not at all for + // Central. The offset rides on the layout origin, so the rotation below turns it with the glyphs. + double factor = text.LineSpacingFactor <= 0d ? 1d : text.LineSpacingFactor; + float lineSpacing = (float)factor * 5f / 4f; + double halfLeading = font.Size * (lineSpacing - 1d) / 2d; + double leadingOffset = text.Baseline switch + { + SurfaceTextBaseline.Hanging => -halfLeading, + SurfaceTextBaseline.Alphabetic => halfLeading, + _ => 0d, + }; + + TextOptions options = new(font) + { + Dpi = 72f, + Origin = new PointF(origin.X, origin.Y + (float)leadingOffset), + HorizontalAlignment = text.Anchor switch + { + SurfaceTextAnchor.Middle => HorizontalAlignment.Center, + SurfaceTextAnchor.End => HorizontalAlignment.Right, + _ => HorizontalAlignment.Left, + }, + VerticalAlignment = text.Baseline switch + { + SurfaceTextBaseline.Hanging => VerticalAlignment.Top, + SurfaceTextBaseline.Central => VerticalAlignment.Center, + _ => VerticalAlignment.Bottom, + }, + WrappingLength = text.WrappingWidth > 0 ? (float)(text.WrappingWidth / text.WidthScale) : -1, + LineSpacing = lineSpacing, + }; + + IPathCollection glyphs = TextBuilder.GenerateGlyphs(text.Text, options); + DrawingOptions drawingOptions = new(); + bool stretched = Math.Abs(text.WidthScale - 1d) > 1e-9; + bool rotated = Math.Abs(text.Rotation) > double.Epsilon; + if (stretched || rotated) + { + // Glyphs are laid out along the page's own x axis before any transform; scaling that layout about the + // 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; + if (rotated) + { + transform *= Matrix3x2.CreateRotation((float)-text.Rotation, pivot); + } + + drawingOptions.Transform = transform; + } + + ImageColor color = style.EffectiveColor; + this.Canvas.Mutate(x => x.Fill(drawingOptions, color, glyphs)); + } + + public ViewportSurface BeginViewport(SurfaceRect bounds) + { + // The child image can only be pasted at whole pixels. Its position is floored, the fractional remainder moves + // into the child's own offsets (so content keeps its exact page position), and the image grows to hold that + // remainder. The flip origin is the exact height: rounding it used to shift content and drop boundary geometry. + double left = Math.Floor(bounds.X); + double top = Math.Floor(bounds.Y); + double fractionX = bounds.X - left; + 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); + 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)); + return viewport; + } + + public void EndViewport(ViewportSurface viewport) + { + if (!this._viewports.Remove(viewport, out (Image Image, SurfaceRect Bounds) entry)) + { + throw new InvalidOperationException("EndViewport was called for a viewport this surface did not begin."); + } + + // 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)); + viewport.Surface.Dispose(); + } + + public void Dispose() + { + foreach ((Image image, _) in this._viewports.Values) + { + image.Dispose(); + } + + this._viewports.Clear(); + if (this._ownsCanvas) + { + this.Canvas.Dispose(); + } + } + + /// Font at the given em size in points; at 72 dpi one point is one pixel. + private Font CreateFont(double emSize) + { + return FontResolver.Create(this._configuration.FontFamilyName, (float)emSize); + } + + private static Pen CreatePen(ImageStyle style) + { + ImageColor color = style.EffectiveColor; + if (style.DashPattern is not { Length: > 0 }) + { + return new SolidPen(color, style.StrokeWidth); + } + + // ImageSharp.Drawing pattern values are multiples of the stroke width. + float width = Math.Max(0.01f, style.StrokeWidth); + float[] pattern = new float[style.DashPattern.Length]; + for (int i = 0; i < pattern.Length; i++) + { + pattern[i] = Math.Max(0.001f, style.DashPattern[i] / width); + } + + return new PatternPen(color, width, pattern); + } + + private static PointF ToPointF(SurfacePoint point) + { + return new PointF((float)point.X, (float)point.Y); + } + + private static bool IsFinite(SurfacePoint p) => double.IsFinite(p.X) && double.IsFinite(p.Y); + + /// + /// Copies the points that carry no NaN or infinity, notifying when any is dropped. ImageSharp's scan-line fill + /// throws on a non-finite vertex, which would abort the whole export; the + /// dispatcher filters the entities it knows about, and this is the backstop, matching the SVG backend. + /// + /// Points to filter. + /// Number of points the shape needs. + /// The surviving points, or null when fewer than remain. + private List? FinitePoints(IReadOnlyList points, int minimum) + { + List finite = new(points.Count); + for (int i = 0; i < points.Count; i++) + { + if (IsFinite(points[i])) + { + finite.Add(points[i]); + } + } + + if (finite.Count != points.Count) + { + this.NotifyNonFinite(); + } + + return finite.Count >= minimum ? finite : null; + } + + private void NotifyNonFinite() => this._configuration.Notify("Raster: non-finite geometry skipped.", NotificationType.Warning); +} diff --git a/ACadSharp.Image/Rendering/ResolvedStyle.cs b/ACadSharp.Image/Rendering/ResolvedStyle.cs new file mode 100644 index 0000000..f14b6e5 --- /dev/null +++ b/ACadSharp.Image/Rendering/ResolvedStyle.cs @@ -0,0 +1,22 @@ +using ACadSharp.Header; +using ACadSharp.Tables; +using CadColor = ACadSharp.Color; + +namespace ACadSharp.Image.Rendering; + +/// +/// An entity's drawing attributes with ByLayer and ByBlock already substituted, in CAD terms (before any conversion to +/// surface units). +/// +/// +/// 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. +/// +/// Resolved colour; never ByLayer or ByBlock. +/// Resolved line weight; never ByLayer or ByBlock. +/// Resolved linetype, or null for a solid stroke. +/// Effective CELTSCALE: the entity's own times every enclosing insert's. +/// Resolved opacity, 0..1. +/// Header of the document the entity (or its outermost placing entity) belongs to, for LTSCALE. +internal sealed record ResolvedStyle(CadColor Color, LineWeightType LineWeight, LineType? LineType, double LineTypeScale, float Opacity, CadHeader? Header); diff --git a/ACadSharp.Image/Rendering/SplineBezierConverter.cs b/ACadSharp.Image/Rendering/SplineBezierConverter.cs new file mode 100644 index 0000000..e54d45c --- /dev/null +++ b/ACadSharp.Image/Rendering/SplineBezierConverter.cs @@ -0,0 +1,134 @@ +using ACadSharp.Entities; +using CSMath; + +namespace ACadSharp.Image.Rendering; + +/// +/// Converts clamped, non-rational cubic B-splines into a chain of cubic Bezier segments by Boehm knot insertion. +/// +internal static class SplineBezierConverter +{ + private const double KnotTolerance = 1e-9; + + public static bool TryConvert(Spline spline, out List bezierControlPoints) + { + bezierControlPoints = new List(); + const int degree = 3; + IReadOnlyList knots = spline.Knots; + IReadOnlyList controlPoints = spline.ControlPoints; + + if (spline.Degree != degree || + spline.Weights.Any(w => Math.Abs(w - 1d) > 1e-12) || + controlPoints.Count < degree + 1 || + knots.Count != controlPoints.Count + degree + 1 || + !HasMultiplicity(knots, 0, degree + 1) || + !HasMultiplicity(knots, knots.Count - (degree + 1), degree + 1)) + { + return false; + } + + List k = new(knots); + List p = new(controlPoints); + + int index = degree + 1; + while (index < k.Count - (degree + 1)) + { + double u = k[index]; + int multiplicity = 1; + while (index + multiplicity < k.Count && Math.Abs(k[index + multiplicity] - u) <= KnotTolerance) + { + multiplicity++; + } + + if (multiplicity > degree) + { + // A knot repeated more than degree times splits the curve; it is not a single Bezier chain. + return false; + } + + for (int m = multiplicity; m < degree; m++) + { + InsertKnot(k, p, u, degree); + } + + index += degree; + } + + if ((p.Count - 1) % degree != 0) + { + return false; + } + + bezierControlPoints = p; + return true; + } + + /// + /// Boehm's algorithm: inserts once, updating knots and control points in place. + /// + public static void InsertKnot(List knots, List points, double u, int degree) + { + int span = FindSpan(knots, points.Count, degree, u); + List updated = new(points.Count + 1); + for (int i = 0; i <= span - degree; i++) + { + updated.Add(points[i]); + } + + for (int i = span - degree + 1; i <= span; i++) + { + double denominator = knots[i + degree] - knots[i]; + double alpha = denominator <= KnotTolerance ? 0d : (u - knots[i]) / denominator; + XYZ a = points[i - 1]; + XYZ b = points[i]; + updated.Add(new XYZ( + ((1d - alpha) * a.X) + (alpha * b.X), + ((1d - alpha) * a.Y) + (alpha * b.Y), + ((1d - alpha) * a.Z) + (alpha * b.Z))); + } + + for (int i = span; i < points.Count; i++) + { + updated.Add(points[i]); + } + + points.Clear(); + points.AddRange(updated); + knots.Insert(span + 1, u); + } + + private static int FindSpan(List knots, int pointCount, int degree, double u) + { + int last = pointCount - 1; + if (u >= knots[pointCount]) + { + return last; + } + + int span = degree; + while (span < last && u >= knots[span + 1]) + { + span++; + } + + return span; + } + + private static bool HasMultiplicity(IReadOnlyList knots, int start, int count) + { + if (start < 0 || start + count > knots.Count) + { + return false; + } + + for (int i = 1; i < count; i++) + { + if (Math.Abs(knots[start + i] - knots[start]) > KnotTolerance) + { + return false; + } + } + + return true; + } +} diff --git a/ACadSharp.Image/Rendering/SplineRenderer.cs b/ACadSharp.Image/Rendering/SplineRenderer.cs index f4206b1..aa34a3d 100644 --- a/ACadSharp.Image/Rendering/SplineRenderer.cs +++ b/ACadSharp.Image/Rendering/SplineRenderer.cs @@ -2,10 +2,6 @@ using ACadSharp.Extensions; using ACadSharp.IO; using CSMath; -using SixLabors.ImageSharp; -using SixLabors.ImageSharp.Drawing; -using SixLabors.ImageSharp.Drawing.Processing; -using SixLabors.ImageSharp.Processing; namespace ACadSharp.Image.Rendering; @@ -13,6 +9,13 @@ internal sealed class SplineRenderer(ImageConfiguration configuration) { private readonly ImageConfiguration _configuration = configuration; + /// + /// Draws a spline, preferring exact Bezier segments and falling back to sampled vertexes. + /// + /// The context that maps drawing units onto the surface. + /// The resolved style for the entity. + /// The spline to draw. + /// when geometry was produced; otherwise . public bool Draw(ImageRenderContext context, ImageStyle style, Spline spline) { if (this.DrawBezierSpline(context, style, spline)) @@ -20,30 +23,55 @@ public bool Draw(ImageRenderContext context, ImageStyle style, Spline spline) return true; } + if (context.Surface.SupportsCurves && SplineBezierConverter.TryConvert(spline, out List bezier)) + { + SurfacePoint[] points = new SurfacePoint[bezier.Count]; + for (int i = 0; i < bezier.Count; i++) + { + points[i] = context.ToSurfacePoint(bezier[i]); + } + + context.Surface.DrawCubicBezier(style, points, spline.IsClosed || spline.IsPeriodic); + return true; + } + XY[] sampledVertices = this.SampleSpline(spline); if (sampledVertices.Length > 1) { - PointF[] points = new PointF[sampledVertices.Length]; + SurfacePoint[] points = new SurfacePoint[sampledVertices.Length]; for (int i = 0; i < sampledVertices.Length; i++) { - points[i] = context.ToPixelPoint(sampledVertices[i]); + points[i] = context.ToSurfacePoint(sampledVertices[i]); } - points = ClosePoints(points, spline.IsClosed || spline.IsPeriodic); - context.Canvas.Mutate(x => x.DrawLine(style.StrokeColor, style.StrokeWidth, points)); + context.Surface.DrawPolyline(style, points, ShouldClosePoints(points, spline.IsClosed || spline.IsPeriodic)); return true; } if (spline.TryPolygonalVertexes(this._configuration.ArcPrecision, out List? polygonalPoints) && polygonalPoints.Count > 1) { - PointF[] points = new PointF[polygonalPoints.Count]; + SurfacePoint[] points = new SurfacePoint[polygonalPoints.Count]; for (int i = 0; i < polygonalPoints.Count; i++) { - points[i] = context.ToPixelPoint(polygonalPoints[i].Convert()); + points[i] = context.ToSurfacePoint(polygonalPoints[i].Convert()); + } + + 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 + // 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) + { + SurfacePoint[] points = new SurfacePoint[spline.FitPoints.Count]; + for (int i = 0; i < points.Length; i++) + { + points[i] = context.ToSurfacePoint(spline.FitPoints[i]); } - points = ClosePoints(points, spline.IsClosed || spline.IsPeriodic); - context.Canvas.Mutate(x => x.DrawLine(style.StrokeColor, style.StrokeWidth, points)); + context.Surface.DrawPolyline(style, points, ShouldClosePoints(points, spline.IsClosed || spline.IsPeriodic)); + this._configuration.Notify($"[{spline.SubclassMarker}] Spline has fit points but no control points; drawn as a polyline through its fit points.", NotificationType.Warning); return true; } @@ -58,24 +86,18 @@ private bool DrawBezierSpline(ImageRenderContext context, ImageStyle style, Spli return false; } - PathBuilder builder = new(); IReadOnlyList controlPoints = spline.ControlPoints; - for (int segment = 0; segment < segmentCount; segment++) + SurfacePoint[] points = new SurfacePoint[(segmentCount * 3) + 1]; + for (int i = 0; i < points.Length; i++) { - int index = segment * 3; - builder.AddCubicBezier( - context.ToPixelPoint(controlPoints[index]), - context.ToPixelPoint(controlPoints[index + 1]), - context.ToPixelPoint(controlPoints[index + 2]), - context.ToPixelPoint(controlPoints[index + 3])); + points[i] = context.ToSurfacePoint(controlPoints[i]); } - IPath path = builder.Build(); - context.Canvas.Mutate(x => x.Draw(style.StrokeColor, style.StrokeWidth, path)); + context.Surface.DrawCubicBezier(style, points, spline.IsClosed || spline.IsPeriodic); return true; } - private static bool TryGetBezierSegments(Spline spline, out int segmentCount) + internal static bool TryGetBezierSegments(Spline spline, out int segmentCount) { segmentCount = 0; if (spline.Degree != 3 || @@ -173,7 +195,7 @@ private XY[] SampleSpline(Spline spline) return vertices.ToArray(); } - private static XY EvaluateSplinePoint( + internal static XY EvaluateSplinePoint( int degree, IReadOnlyList knots, IReadOnlyList controlPoints, @@ -236,22 +258,21 @@ private static int FindKnotSpan(int degree, IReadOnlyList knots, int con return span; } - private static PointF[] ClosePoints(PointF[] points, bool close) - { - if (!close || !ShouldClose(points)) - { - return points; - } - - PointF[] closedPoints = new PointF[points.Length + 1]; - Array.Copy(points, closedPoints, points.Length); - closedPoints[^1] = points[0]; - return closedPoints; - } - - private static bool ShouldClose(IReadOnlyList points) + /// + /// Determines whether a polyline should be closed based on a heuristic. + /// + /// The polyline points in surface coordinates. + /// Whether the source geometry asks for a closed shape. + /// when the closing segment should be drawn. + /// + /// The heuristic compares the distance between the last and first points (closing length) + /// to the average segment length. If the closing length is within 3x the average segment + /// length, the polyline is considered closeable. This handles cases where polylines are + /// nearly closed but have small gaps due to precision or modeling errors. + /// + internal static bool ShouldClosePoints(IReadOnlyList points, bool close) { - if (points.Count < 3) + if (!close || points.Count < 3) { return false; } @@ -267,11 +288,11 @@ private static bool ShouldClose(IReadOnlyList points) return closingLength <= averageSegmentLength * 3f; } - private static float Distance(PointF a, PointF b) + private static float Distance(SurfacePoint a, SurfacePoint b) { - float dx = a.X - b.X; - float dy = a.Y - b.Y; - return MathF.Sqrt(dx * dx + dy * dy); + float dx = (float)a.X - (float)b.X; + float dy = (float)a.Y - (float)b.Y; + return MathF.Sqrt((dx * dx) + (dy * dy)); } private readonly record struct SplinePoint(double X, double Y, double Z, double Weight) diff --git a/ACadSharp.Image/Rendering/SurfacePoint.cs b/ACadSharp.Image/Rendering/SurfacePoint.cs new file mode 100644 index 0000000..287ceea --- /dev/null +++ b/ACadSharp.Image/Rendering/SurfacePoint.cs @@ -0,0 +1,11 @@ +namespace ACadSharp.Image.Rendering; + +/// +/// A point in surface coordinates: pixels for the raster backend, drawing units for SVG. Y grows downward. +/// +internal readonly record struct SurfacePoint(double X, double Y); + +/// +/// An axis-aligned rectangle in surface coordinates. is the top edge. +/// +internal readonly record struct SurfaceRect(double X, double Y, double Width, double Height); diff --git a/ACadSharp.Image/Rendering/SurfaceText.cs b/ACadSharp.Image/Rendering/SurfaceText.cs new file mode 100644 index 0000000..d84a4df --- /dev/null +++ b/ACadSharp.Image/Rendering/SurfaceText.cs @@ -0,0 +1,40 @@ +namespace ACadSharp.Image.Rendering; + +internal enum SurfaceTextAnchor +{ + Start, + Middle, + End, +} + +internal enum SurfaceTextBaseline +{ + Alphabetic, + Central, + Hanging, +} + +/// +/// Everything a backend needs to place a text run. +/// +/// Text with CAD control codes already expanded; may contain newlines. +/// Anchor point in surface units. +/// Text height (font size) in surface units. +/// Rotation in radians, counter-clockwise in drawing space. Backends negate it because surface Y points down. +/// Horizontal anchoring relative to . +/// Vertical anchoring relative to . +/// Wrap width in surface units; zero or negative disables wrapping. +/// Line spacing multiplier; 1.0 is single spacing. +/// Total advance the text must occupy in surface units; zero or negative means natural width. +/// Factor glyph advances are stretched by along the baseline relative to ; 1 is natural width. and are expressed in surface units of the stretched run. +internal sealed record SurfaceText( + string Text, + SurfacePoint Origin, + double Height, + double Rotation, + SurfaceTextAnchor Anchor, + SurfaceTextBaseline Baseline, + double WrappingWidth, + double LineSpacingFactor, + double FixedLength, + double WidthScale = 1d); diff --git a/ACadSharp.Image/Rendering/Svg/SvgDrawingSurface.cs b/ACadSharp.Image/Rendering/Svg/SvgDrawingSurface.cs new file mode 100644 index 0000000..f030767 --- /dev/null +++ b/ACadSharp.Image/Rendering/Svg/SvgDrawingSurface.cs @@ -0,0 +1,735 @@ +using System.Globalization; +using System.Text; +using System.Xml; +using System.Xml.Linq; +using ACadSharp.IO; +using ImageColor = SixLabors.ImageSharp.Color; +using Rgba32 = SixLabors.ImageSharp.PixelFormats.Rgba32; + +namespace ACadSharp.Image.Rendering.Svg; + +/// +/// that builds an SVG document with one <g> per effective layer. +/// +/// +/// Coordinates handed to this surface are already in SVG user units (drawing units, Y down). +/// See the design spec section 5 for the document structure. +/// +internal sealed class SvgDrawingSurface : IDrawingSurface +{ + public static readonly XNamespace Ns = "http://www.w3.org/2000/svg"; + + private readonly ImageConfiguration _configuration; + private readonly SvgOptions _options; + private readonly string _idPrefix; + private readonly SvgNumberFormatter _numbers; + private readonly SvgNumberFormatter _styleNumbers; + private readonly SvgNumberFormatter _angleNumbers; + private readonly XElement _root; + private readonly XElement _defs; + private readonly XElement _defaults; + private readonly Stack _containers = new(); + private readonly Stack<(EntityRenderInfo Info, LayerRenderInfo Layer)> _entities = new(); + private readonly HashSet _issuedIds = new(StringComparer.Ordinal); + private XDocument? _document; + private int _clipCounter; + + public SvgDrawingSurface(ImageConfiguration configuration, SurfaceRect viewBox, double? sizeWidth, double? sizeHeight, double? strokeUnitsPerMillimeter = null) + { + this._configuration = configuration; + this._options = configuration.Svg; + this._idPrefix = SvgIdSanitizer.SanitizePrefix(this._options.IdPrefix); + this._numbers = new SvgNumberFormatter(this._options.Precision ?? SvgNumberFormatter.AdaptiveDecimals(viewBox.Width, viewBox.Height)); + this._styleNumbers = new SvgNumberFormatter(StyleDecimalsFor(strokeUnitsPerMillimeter)); + // Rotations are degrees whatever the drawing units are, so they get their own fixed precision. + this._angleNumbers = new SvgNumberFormatter(4); + + this._root = new XElement(Ns + "svg", + new XAttribute("viewBox", $"{this.N(viewBox.X)} {this.N(viewBox.Y)} {this.N(viewBox.Width)} {this.N(viewBox.Height)}")); + if (sizeWidth is > 0 && sizeHeight is > 0) + { + this._root.Add(new XAttribute("width", this.N(sizeWidth.Value)), new XAttribute("height", this.N(sizeHeight.Value))); + } + + this._defs = new XElement(Ns + "defs"); + XElement cadRoot = new(Ns + "g", new XAttribute("class", "cad-root")); + this._defaults = new XElement(Ns + "g", + new XAttribute("fill", "none"), + new XAttribute("stroke-linecap", "round"), + new XAttribute("stroke-linejoin", "round"), + new XAttribute("font-family", BuildFontStack(SvgXmlText.Clean(configuration.FontFamilyName)))); + + Rgba32 background = configuration.BackgroundColor.ToPixel(); + if (background.A > 0) + { + XElement rect = new(Ns + "rect", + new XAttribute("class", "cad-background"), + new XAttribute("x", this.N(viewBox.X)), + new XAttribute("y", this.N(viewBox.Y)), + new XAttribute("width", this.N(viewBox.Width)), + new XAttribute("height", this.N(viewBox.Height)), + new XAttribute("fill", Hex(configuration.BackgroundColor))); + if (background.A < byte.MaxValue) + { + // Hex() drops the alpha channel; a translucent background keeps it as fill-opacity, like the raster canvas would. + rect.Add(new XAttribute("fill-opacity", this.S(background.A / 255d))); + } + + rect.Add(new XAttribute("stroke", "none")); + this._defaults.Add(rect); + } + + cadRoot.Add(this._defaults); + this._root.Add(cadRoot); + this._containers.Push(new Container(this._defaults, "layer")); + } + + public bool SupportsCurves => true; + + /// + /// Decimals used for style scalars such as stroke widths and dash lengths. + /// + internal int StyleDecimals => this._styleNumbers.Decimals; + + /// + /// Style scalars are three decimals for pixels and millimetre-scale drawing units. When a drawing unit is much + /// larger than a millimetre (metres, kilometres) a 0.25 mm stroke is a tiny number of drawing units, so the + /// precision grows with the unit to keep the width from rounding away to zero. + /// + /// Drawing units per millimetre for stroke widths, or null for pixel widths. + /// The number of decimals for style scalars, 3..8. + private static int StyleDecimalsFor(double? strokeUnitsPerMillimeter) + { + return strokeUnitsPerMillimeter is double unitsPerMillimeter && unitsPerMillimeter > 0d + ? Math.Clamp(3 - (int)Math.Floor(Math.Log10(unitsPerMillimeter)), 3, 8) + : 3; + } + + /// + /// The document being built. The same instance is returned on every call and reflects the drawing done so far. + /// + /// The SVG document. + public XDocument ToDocument() + { + if (this._defs.HasElements && this._defs.Parent == null) + { + this._root.AddFirst(this._defs); + } + + return this._document ??= new XDocument(this._root); + } + + public string ToSvgString() + { + StringBuilder builder = new(); + // No XML declaration: XmlWriter over a StringBuilder would declare utf-16, which contradicts the UTF-8 bytes RenderedSvgPage writes, + // and inline SVG in HTML must not carry a declaration anyway. + XmlWriterSettings settings = new() { Indent = true, OmitXmlDeclaration = true, NewLineChars = "\n" }; + using (XmlWriter writer = XmlWriter.Create(builder, settings)) + { + this.ToDocument().Save(writer); + } + + return builder.ToString(); + } + + public void BeginEntity(EntityRenderInfo info, LayerRenderInfo layer) + { + this._entities.Push((info, layer)); + } + + public void EndEntity() + { + if (this._entities.Count > 0) + { + this._entities.Pop(); + } + } + + public void DrawLine(ImageStyle style, SurfacePoint start, SurfacePoint end) + { + if (!IsFinite(start) || !IsFinite(end)) + { + this.NotifyNonFinite(); + return; + } + + this.Append(this.Stroked(new XElement(Ns + "line", + new XAttribute("x1", this.N(start.X)), new XAttribute("y1", this.N(start.Y)), + new XAttribute("x2", this.N(end.X)), new XAttribute("y2", this.N(end.Y))), style)); + } + + public void DrawPolyline(ImageStyle style, IReadOnlyList points, bool closed) + { + if (this.FinitePoints(points, 2) is not List finite) + { + return; + } + + XElement element = new(Ns + (closed ? "polygon" : "polyline"), new XAttribute("points", this.Points(finite))); + this.Append(this.Stroked(element, style)); + } + + public void DrawArc(ImageStyle style, SurfacePoint center, double radiusX, double radiusY, double rotation, double startAngle, double sweepAngle) + { + if (!IsFinite(center) || !IsFinite(radiusX) || !IsFinite(radiusY) || !IsFinite(rotation) || !IsFinite(startAngle) || !IsFinite(sweepAngle)) + { + this.NotifyNonFinite(); + return; + } + + if (Math.Abs(sweepAngle) >= (2d * Math.PI) - 1e-9) + { + this.DrawEllipse(style, center, radiusX, radiusY, rotation); + return; + } + + SurfacePoint start = PointOnEllipse(center, radiusX, radiusY, rotation, startAngle); + SurfacePoint end = PointOnEllipse(center, radiusX, radiusY, rotation, startAngle + sweepAngle); + int largeArc = Math.Abs(sweepAngle) > Math.PI ? 1 : 0; + int sweepFlag = sweepAngle > 0 ? 1 : 0; + string d = $"M{this.N(start.X)} {this.N(start.Y)}A{this.N(Math.Abs(radiusX))} {this.N(Math.Abs(radiusY))} {this.A(rotation * 180d / Math.PI)} {largeArc} {sweepFlag} {this.N(end.X)} {this.N(end.Y)}"; + this.Append(this.Stroked(new XElement(Ns + "path", new XAttribute("d", d)), style)); + } + + public void DrawEllipse(ImageStyle style, SurfacePoint center, double radiusX, double radiusY, double rotation) + { + if (!IsFinite(center) || !IsFinite(radiusX) || !IsFinite(radiusY) || !IsFinite(rotation)) + { + this.NotifyNonFinite(); + return; + } + + // A NaN radius compares false against everything, so the finiteness is part of the discriminator. + if (double.IsFinite(radiusX) && double.IsFinite(radiusY) && Math.Abs(radiusX - radiusY) < 1e-9) + { + this.Append(this.Stroked(new XElement(Ns + "circle", + new XAttribute("cx", this.N(center.X)), new XAttribute("cy", this.N(center.Y)), new XAttribute("r", this.N(Math.Abs(radiusX)))), style)); + return; + } + + XElement ellipse = new(Ns + "ellipse", + new XAttribute("cx", this.N(center.X)), new XAttribute("cy", this.N(center.Y)), + new XAttribute("rx", this.N(Math.Abs(radiusX))), new XAttribute("ry", this.N(Math.Abs(radiusY)))); + if (Math.Abs(rotation) > 1e-12) + { + ellipse.Add(new XAttribute("transform", $"rotate({this.A(rotation * 180d / Math.PI)} {this.N(center.X)} {this.N(center.Y)})")); + } + + this.Append(this.Stroked(ellipse, style)); + } + + public void DrawCubicBezier(ImageStyle style, IReadOnlyList controlPoints, bool closed) + { + if (this.FinitePoints(controlPoints, 4) is not List finite) + { + return; + } + + StringBuilder d = new(); + d.Append('M').Append(this.N(finite[0].X)).Append(' ').Append(this.N(finite[0].Y)); + for (int i = 1; i + 2 < finite.Count; i += 3) + { + d.Append('C'); + for (int j = 0; j < 3; j++) + { + if (j > 0) + { + d.Append(' '); + } + + d.Append(this.N(finite[i + j].X)).Append(' ').Append(this.N(finite[i + j].Y)); + } + } + + if (closed) + { + d.Append('Z'); + } + + this.Append(this.Stroked(new XElement(Ns + "path", new XAttribute("d", d.ToString())), style)); + } + + public void DrawBulgePolyline(ImageStyle style, IReadOnlyList points, IReadOnlyList bulges, bool closed) + { + // The bulges are indexed by vertex, so dropping a non-finite point has to drop its bulge with it. + List vertices = new(points.Count); + List vertexBulges = new(points.Count); + for (int i = 0; i < points.Count; i++) + { + if (!IsFinite(points[i])) + { + continue; + } + + vertices.Add(points[i]); + vertexBulges.Add(i < bulges.Count ? bulges[i] : 0d); + } + + if (vertices.Count != points.Count) + { + this.NotifyNonFinite(); + } + + if (vertices.Count < 2) + { + return; + } + + StringBuilder d = new(); + d.Append('M').Append(this.N(vertices[0].X)).Append(' ').Append(this.N(vertices[0].Y)); + int segmentCount = closed ? vertices.Count : vertices.Count - 1; + for (int i = 0; i < segmentCount; i++) + { + SurfacePoint start = vertices[i]; + SurfacePoint end = vertices[(i + 1) % vertices.Count]; + double bulge = vertexBulges[i]; + if (!IsFinite(bulge) || Math.Abs(bulge) < 1e-12 || start == end) + { + d.Append('L').Append(this.N(end.X)).Append(' ').Append(this.N(end.Y)); + continue; + } + + CurveTessellation.BulgeArc(start, end, bulge, out _, out double radius, out _, out double sweep); + if (!IsFinite(radius) || !IsFinite(sweep)) + { + this.NotifyNonFinite(); + d.Append('L').Append(this.N(end.X)).Append(' ').Append(this.N(end.Y)); + continue; + } + + int largeArc = Math.Abs(bulge) > 1d ? 1 : 0; + int sweepFlag = sweep > 0 ? 1 : 0; + d.Append('A').Append(this.N(radius)).Append(' ').Append(this.N(radius)).Append(" 0 ").Append(largeArc).Append(' ').Append(sweepFlag).Append(' ') + .Append(this.N(end.X)).Append(' ').Append(this.N(end.Y)); + } + + if (closed) + { + d.Append('Z'); + } + + this.Append(this.Stroked(new XElement(Ns + "path", new XAttribute("d", d.ToString())), style)); + } + + private static SurfacePoint PointOnEllipse(SurfacePoint center, double radiusX, double radiusY, double rotation, double angle) + { + double x = radiusX * Math.Cos(angle); + double y = radiusY * Math.Sin(angle); + double cos = Math.Cos(rotation); + double sin = Math.Sin(rotation); + return new SurfacePoint(center.X + (x * cos) - (y * sin), center.Y + (x * sin) + (y * cos)); + } + + public void FillPolygon(ImageStyle style, IReadOnlyList points) + { + if (this.FinitePoints(points, 3) is not List finite) + { + return; + } + + this.Append(this.Filled(new XElement(Ns + "polygon", new XAttribute("points", this.Points(finite))), style)); + } + + public void FillPath(ImageStyle style, IReadOnlyList> rings) + { + StringBuilder d = new(); + foreach (IReadOnlyList ring in rings) + { + if (this.FinitePoints(ring, 3) is not List finite) + { + continue; + } + + d.Append('M').Append(this.N(finite[0].X)).Append(' ').Append(this.N(finite[0].Y)); + for (int i = 1; i < finite.Count; i++) + { + d.Append('L').Append(this.N(finite[i].X)).Append(' ').Append(this.N(finite[i].Y)); + } + + d.Append('Z'); + } + + if (d.Length == 0) + { + return; + } + + XElement path = new(Ns + "path", new XAttribute("fill-rule", "evenodd"), new XAttribute("d", d.ToString())); + this.Append(this.Filled(path, style)); + } + + public void FillCircle(ImageStyle style, SurfacePoint center, double radius) + { + if (!IsFinite(center) || !IsFinite(radius)) + { + this.NotifyNonFinite(); + return; + } + + this.Append(this.Filled(new XElement(Ns + "circle", + new XAttribute("cx", this.N(center.X)), new XAttribute("cy", this.N(center.Y)), new XAttribute("r", this.N(Math.Abs(radius)))), style)); + } + + public void DrawText(ImageStyle style, SurfaceText text) + { + if (string.IsNullOrWhiteSpace(text.Text)) + { + return; + } + + if (!IsFinite(text.Origin) || !IsFinite(text.Height) || !IsFinite(text.Rotation) || !IsFinite(text.FixedLength) || !IsFinite(text.LineSpacingFactor) || !IsFinite(text.WidthScale) || text.WidthScale <= 0) + { + this.NotifyNonFinite(); + return; + } + + double emSize = SvgTextLayout.EmSize(text.Height); + IReadOnlyList lines = SvgTextLayout.Wrap(SvgXmlText.Clean(text.Text), text.WrappingWidth / text.WidthScale, emSize, this._configuration.FontFamilyName); + double lineHeight = SvgTextLayout.LineHeight(text.Height, text.LineSpacingFactor); + double firstLineY = text.Origin.Y + SvgTextLayout.BlockOffset(lines.Count, lineHeight, text.Baseline); + + XElement element = new(Ns + "text", + new XAttribute("x", this.N(text.Origin.X)), + new XAttribute("y", this.N(firstLineY)), + new XAttribute("font-size", this.N(emSize))); + + if (lines.Count == 1) + { + // Repeated spaces are meaningful CAD content (alignment, tabular labels); SVG collapses runs of + // whitespace by default, so xml:space keeps what the wrapper already preserved in the source text. + // A single-line element's only child is the direct text node below, so this is safe here. + // The multi-line case below instead puts xml:space on each : putting it on too would + // also preserve the newline and indentation XDocument.Save's pretty-printing adds between + // elements, which would then be drawn as extra space at the end of the preceding line. + element.Add(new XAttribute(XNamespace.Xml + "space", "preserve")); + } + + if (text.Anchor != SurfaceTextAnchor.Start) + { + element.Add(new XAttribute("text-anchor", text.Anchor == SurfaceTextAnchor.Middle ? "middle" : "end")); + } + + if (text.Baseline != SurfaceTextBaseline.Alphabetic) + { + element.Add(new XAttribute("dominant-baseline", text.Baseline == SurfaceTextBaseline.Central ? "central" : "hanging")); + } + + // The rotation carries the reading axis into place; a non-uniform insert scale then stretches along that axis by + // pivoting a scale about the anchor point, expressed as translate/scale/translate because SVG has no bare "scale + // about a point" primitive. + string? rotate = Math.Abs(text.Rotation) > 1e-12 + ? $"rotate({this.A(-text.Rotation * 180d / Math.PI)} {this.N(text.Origin.X)} {this.N(text.Origin.Y)})" + : null; + // WidthScale is a dimensionless ratio, not a coordinate, so it is formatted at style precision (at least 3 + // decimals): the adaptive coordinate formatter drops to 0 decimals on a large-viewBox drawing, which would + // round an ordinary stretch to an integer or, worse, to a 0 that erases the text. + string? stretch = Math.Abs(text.WidthScale - 1d) > 1e-9 + ? $"translate({this.N(text.Origin.X)} {this.N(text.Origin.Y)}) scale({this.S(text.WidthScale)} 1) translate({this.N(-text.Origin.X)} {this.N(-text.Origin.Y)})" + : null; + if (rotate != null || stretch != null) + { + element.Add(new XAttribute("transform", string.Join(' ', new string?[] { rotate, stretch }.Where(part => part != null)))); + } + + if (text.FixedLength > 0) + { + element.Add(new XAttribute("textLength", this.N(text.FixedLength / text.WidthScale)), new XAttribute("lengthAdjust", "spacingAndGlyphs")); + } + + if (lines.Count == 1) + { + element.Add(lines[0]); + } + else + { + for (int i = 0; i < lines.Count; i++) + { + XElement span = new(Ns + "tspan", + new XAttribute("x", this.N(text.Origin.X)), + // A holding only a direct text node is not itself indented by XDocument.Save, so + // preserving whitespace here keeps runs inside the line without pulling in the inter-tspan + // indentation (see the single-line comment above). + new XAttribute(XNamespace.Xml + "space", "preserve"), + lines[i]); + if (i > 0) + { + span.Add(new XAttribute("dy", this.N(lineHeight))); + } + + element.Add(span); + } + } + + this.Append(this.Filled(element, style)); + } + + public ViewportSurface BeginViewport(SurfaceRect bounds) + { + this._clipCounter++; + string clipId = this.UniqueId(SvgIdSanitizer.Sanitize(this._idPrefix, "clip", this._clipCounter.ToString(CultureInfo.InvariantCulture))); + this._defs.Add(new XElement(Ns + "clipPath", + new XAttribute("id", clipId), + new XAttribute("clipPathUnits", "userSpaceOnUse"), + new XElement(Ns + "rect", + new XAttribute("x", this.N(bounds.X)), new XAttribute("y", this.N(bounds.Y)), + new XAttribute("width", this.N(bounds.Width)), new XAttribute("height", this.N(bounds.Height))))); + + XElement group = new(Ns + "g", new XAttribute("class", "cad-viewport"), new XAttribute("clip-path", $"url(#{clipId})")); + this._containers.Peek().Element.Add(group); + this._containers.Push(new Container(group, $"clip-{this._clipCounter.ToString(CultureInfo.InvariantCulture)}-layer")); + return new ViewportSurface(this, bounds.X, bounds.Y + bounds.Height); + } + + public void EndViewport(ViewportSurface viewport) + { + if (!ReferenceEquals(viewport.Surface, this)) + { + throw new InvalidOperationException("EndViewport was called with a viewport from another surface."); + } + + if (this._containers.Count <= 1) + { + throw new InvalidOperationException("EndViewport was called without a matching BeginViewport."); + } + + this._containers.Pop(); + } + + public void Dispose() + { + } + + // ---- element helpers ------------------------------------------------- + + private XElement Stroked(XElement element, ImageStyle style) + { + (LayerRenderInfo? layer, _) = this.CurrentLayer(); + string stroke = Hex(style.StrokeColor); + if (layer == null || !string.Equals(stroke, Hex(layer.Color), StringComparison.Ordinal)) + { + element.Add(new XAttribute("stroke", stroke)); + } + + if (layer == null || Math.Abs(style.StrokeWidth - layer.StrokeWidth) > 1e-6f) + { + element.Add(new XAttribute("stroke-width", this.S(style.StrokeWidth))); + } + + if (style.DashPattern is { Length: > 0 }) + { + element.Add(new XAttribute("stroke-dasharray", string.Join(" ", style.DashPattern.Select(v => this.S(v))))); + } + + if (this._options.NonScalingStroke) + { + element.Add(new XAttribute("vector-effect", "non-scaling-stroke")); + } + + this.AddCommon(element, style); + return element; + } + + private XElement Filled(XElement element, ImageStyle style) + { + element.Add(new XAttribute("fill", Hex(style.StrokeColor)), new XAttribute("stroke", "none")); + this.AddCommon(element, style); + return element; + } + + private void AddCommon(XElement element, ImageStyle style) + { + if (style.Opacity < 1f) + { + element.Add(new XAttribute("opacity", this.S(Math.Clamp(style.Opacity, 0f, 1f)))); + } + + if (this._options.EmitEntityAttributes && this._entities.Count > 0) + { + (EntityRenderInfo info, _) = this._entities.Peek(); + if (info.Handle != 0) + { + // Exploded block contents are transient clones with handle 0; a "0" handle would be meaningless to consumers. + element.Add(new XAttribute("data-handle", info.Handle.ToString("X", CultureInfo.InvariantCulture))); + } + + element.Add(new XAttribute("data-type", info.EntityType)); + if (info.ParentHandle is ulong parent) + { + element.Add(new XAttribute("data-parent", parent.ToString("X", CultureInfo.InvariantCulture))); + } + + if (!string.IsNullOrEmpty(info.BlockName)) + { + element.Add(new XAttribute("data-block", SvgXmlText.Clean(info.BlockName))); + } + } + } + + private void Append(XElement element) + { + this.CurrentLayerGroup().Add(element); + } + + private (LayerRenderInfo? Layer, string Name) CurrentLayer() + { + if (this._entities.Count == 0) + { + return (null, "0"); + } + + (EntityRenderInfo info, LayerRenderInfo layer) = this._entities.Peek(); + return (layer, info.LayerName); + } + + private XElement CurrentLayerGroup() + { + (LayerRenderInfo? layer, string name) = this.CurrentLayer(); + Container container = this._containers.Peek(); + if (container.Layers.TryGetValue(name, out XElement? group)) + { + return group; + } + + group = new XElement(Ns + "g", + new XAttribute("id", this.UniqueId(SvgIdSanitizer.Sanitize(this._idPrefix, container.IdKind, name))), + new XAttribute("class", "cad-layer"), + new XAttribute("data-layer", SvgXmlText.Clean(name))); + if (layer != null) + { + group.Add(new XAttribute("stroke", Hex(layer.Color)), new XAttribute("stroke-width", this.S(layer.StrokeWidth))); + } + + container.Element.Add(group); + container.Layers[name] = group; + return group; + } + + private string N(double value) => this._numbers.Format(value); + + private string S(double value) => this._styleNumbers.Format(value); + + private string A(double degrees) => this._angleNumbers.Format(degrees); + + private static bool IsFinite(SurfacePoint p) => double.IsFinite(p.X) && double.IsFinite(p.Y); + + private static bool IsFinite(double v) => double.IsFinite(v); + + /// + /// Reports geometry dropped because it carried NaN or infinity. The dispatcher filters the known cases; this is the + /// backstop that keeps such values out of the markup, where they would make the SVG invalid. + /// + private void NotifyNonFinite() + { + string handle = this._entities.Count > 0 + ? this._entities.Peek().Info.Handle.ToString("X", CultureInfo.InvariantCulture) + : "?"; + this._configuration.Notify($"SVG: non-finite geometry skipped for entity {handle}", NotificationType.Warning); + } + + /// + /// Copies the points that carry no NaN or infinity, notifying when any is dropped. + /// + /// Points to filter. + /// Number of points the shape needs. + /// The surviving points, or null when fewer than remain. + private List? FinitePoints(IReadOnlyList points, int minimum) + { + List finite = new(points.Count); + for (int i = 0; i < points.Count; i++) + { + if (IsFinite(points[i])) + { + finite.Add(points[i]); + } + } + + if (finite.Count != points.Count) + { + this.NotifyNonFinite(); + } + + return finite.Count >= minimum ? finite : null; + } + + /// + /// Makes an id unique within the document by appending -2, -3, ... Two different names can sanitise + /// to the same id ("A WALL" and "A-WALL" both become "layer-a-wall") and duplicate ids are invalid markup. + /// + /// The sanitised id to issue. + /// An id no other element in this document carries. + private string UniqueId(string candidate) + { + if (this._issuedIds.Add(candidate)) + { + return candidate; + } + + int suffix = 2; + string id; + do + { + id = $"{candidate}-{suffix.ToString(CultureInfo.InvariantCulture)}"; + suffix++; + } + while (!this._issuedIds.Add(id)); + + return id; + } + + private string Points(IReadOnlyList points) + { + StringBuilder builder = new(points.Count * 12); + for (int i = 0; i < points.Count; i++) + { + if (i > 0) + { + builder.Append(' '); + } + + builder.Append(this.N(points[i].X)).Append(' ').Append(this.N(points[i].Y)); + } + + return builder.ToString(); + } + + internal static string Hex(ImageColor color) + { + Rgba32 pixel = color.ToPixel(); + return $"#{pixel.R:x2}{pixel.G:x2}{pixel.B:x2}"; + } + + private static string BuildFontStack(string fontFamilyName) + { + List families = new(); + string[] candidates = { fontFamilyName, "Arial", "Helvetica", "sans-serif" }; + foreach (string candidate in candidates) + { + if (!string.IsNullOrWhiteSpace(candidate) && !families.Contains(candidate, StringComparer.OrdinalIgnoreCase)) + { + families.Add(candidate); + } + } + + return string.Join(", ", families.Select(f => f.Contains(' ') ? $"'{f}'" : f)); + } + + /// + /// A page or viewport group that owns its own set of layer groups. keeps layer ids unique + /// across containers ("layer" at page level, "clip-N-layer" inside viewport N). + /// + private sealed class Container + { + public Container(XElement element, string idKind) + { + this.Element = element; + this.IdKind = idKind; + } + + public XElement Element { get; } + + public string IdKind { get; } + + public Dictionary Layers { get; } = new(StringComparer.OrdinalIgnoreCase); + } +} diff --git a/ACadSharp.Image/Rendering/Svg/SvgIdSanitizer.cs b/ACadSharp.Image/Rendering/Svg/SvgIdSanitizer.cs new file mode 100644 index 0000000..4014068 --- /dev/null +++ b/ACadSharp.Image/Rendering/Svg/SvgIdSanitizer.cs @@ -0,0 +1,81 @@ +using System.Text; + +namespace ACadSharp.Image.Rendering.Svg; + +/// +/// Builds HTML-safe ids: {prefix}{kind}-{name} with the name lower-cased and every run of characters outside +/// [a-z0-9_-] collapsed into a single dash. +/// +internal static class SvgIdSanitizer +{ + /// + /// Restricts a caller-supplied id prefix to [A-Za-z0-9_-], keeping its case and collapsing every run of other + /// characters into a single dash. An id containing a space or a quote would break the url(#id) references + /// that clip paths rely on. + /// + /// The configured . + /// The prefix with only id-safe characters. + public static string SanitizePrefix(string prefix) + { + if (string.IsNullOrEmpty(prefix)) + { + return string.Empty; + } + + StringBuilder builder = new(prefix.Length); + bool pendingDash = false; + foreach (char c in prefix) + { + bool safe = (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '_' || c == '-'; + if (safe) + { + if (pendingDash && builder.Length > 0 && builder[^1] != '-') + { + builder.Append('-'); + } + + pendingDash = false; + builder.Append(c); + } + else + { + pendingDash = true; + } + } + + if (pendingDash && builder.Length > 0 && builder[^1] != '-') + { + builder.Append('-'); + } + + return builder.ToString(); + } + + public static string Sanitize(string prefix, string kind, string name) + { + StringBuilder builder = new(prefix.Length + kind.Length + name.Length + 1); + builder.Append(prefix).Append(kind).Append('-'); + + bool pendingDash = false; + foreach (char c in name.ToLowerInvariant()) + { + bool safe = (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '_' || c == '-'; + if (safe) + { + if (pendingDash && builder[^1] != '-') + { + builder.Append('-'); + } + + pendingDash = false; + builder.Append(c); + } + else + { + pendingDash = true; + } + } + + return builder.ToString(); + } +} diff --git a/ACadSharp.Image/Rendering/Svg/SvgNumberFormatter.cs b/ACadSharp.Image/Rendering/Svg/SvgNumberFormatter.cs new file mode 100644 index 0000000..d2231f8 --- /dev/null +++ b/ACadSharp.Image/Rendering/Svg/SvgNumberFormatter.cs @@ -0,0 +1,50 @@ +using System.Globalization; + +namespace ACadSharp.Image.Rendering.Svg; + +/// +/// Formats numbers for SVG attributes: invariant culture, fixed decimals, trailing zeros trimmed, no negative zero. +/// +internal sealed class SvgNumberFormatter +{ + private readonly int _decimals; + + public SvgNumberFormatter(int decimals) + { + this._decimals = Math.Clamp(decimals, 0, 8); + } + + public int Decimals => this._decimals; + + public string Format(double value) + { + double rounded = Math.Round(value, this._decimals, MidpointRounding.AwayFromZero); + if (rounded == 0d) + { + return "0"; + } + + string text = rounded.ToString("F" + this._decimals.ToString(CultureInfo.InvariantCulture), CultureInfo.InvariantCulture); + if (this._decimals > 0) + { + text = text.TrimEnd('0').TrimEnd('.'); + } + + return text; + } + + /// + /// Decimals such that the resolution is one ten-thousandth of the larger viewBox side, clamped to 0..8. + /// + public static int AdaptiveDecimals(double width, double height) + { + double size = Math.Max(Math.Abs(width), Math.Abs(height)); + if (size <= 0d || double.IsNaN(size) || double.IsInfinity(size)) + { + return 3; + } + + int decimals = 4 - (int)Math.Floor(Math.Log10(size)); + return Math.Clamp(decimals, 0, 8); + } +} diff --git a/ACadSharp.Image/Rendering/Svg/SvgTextLayout.cs b/ACadSharp.Image/Rendering/Svg/SvgTextLayout.cs new file mode 100644 index 0000000..30b1fec --- /dev/null +++ b/ACadSharp.Image/Rendering/Svg/SvgTextLayout.cs @@ -0,0 +1,161 @@ +using SixLabors.Fonts; + +namespace ACadSharp.Image.Rendering.Svg; + +/// +/// Text metrics for the SVG backend, chosen to match the raster backend and the CAD intent. +/// +/// +/// The em size and the reason for the 4/3 factor live in , shared with the raster backend. +/// +internal static class SvgTextLayout +{ + /// Font size (em) for a CAD text height. + public static double EmSize(double capHeight) => TextMetrics.EmSize(capHeight); + + /// Distance between consecutive baselines. + public static double LineHeight(double capHeight, double lineSpacingFactor) => + capHeight * (lineSpacingFactor <= 0 ? 1d : lineSpacingFactor) * 5d / 3d; + + /// + /// Offset of the first line's baseline from the anchor, in surface units (negative is up), so the whole block hangs + /// from, is centred on, or stands on the anchor the way the CAD attachment point says. + /// + public static double BlockOffset(int lineCount, double lineHeight, SurfaceTextBaseline baseline) => baseline switch + { + SurfaceTextBaseline.Central => -(lineCount - 1) * lineHeight / 2d, + SurfaceTextBaseline.Alphabetic => -(lineCount - 1) * lineHeight, + _ => 0d, + }; + + /// + /// Em size the wrapping measurements use. SixLabors applies no hinting, so advances are linear in the size: a + /// fixed reference size with a proportionally scaled width wraps sub-unit CAD text as accurately as large text, + /// and sidesteps the one-point clamp in . + /// + private const float ReferenceSize = 100f; + + /// + /// Splits text into lines: explicit line breaks always break; when is positive, + /// the words are fitted greedily, measured with SixLabors.Fonts, at the raster's break opportunities, so both + /// backends break the same labels at the same break opportunities. A token wider than the width stays alone on its + /// line. + /// + /// Text with \n for explicit breaks. + /// Available width in surface units, or a non-positive value for no wrapping. + /// Font size in surface units. + /// Configured family, resolved through for measuring. + /// The lines, never empty; the paragraphs unwrapped when there is no font, or no way, to measure with. + public static IReadOnlyList Wrap(string text, double wrappingWidth, double emSize, string? fontFamily) + { + string[] paragraphs = text.Replace("\r\n", "\n").Split('\n'); + if (wrappingWidth <= 0d || emSize <= 0d || !FontResolver.TryResolve(fontFamily, out FontFamily family)) + { + return paragraphs; + } + + // Points at 72 dpi are surface units, so the scaled limit is directly comparable with the measured advances. + TextOptions options = new(family.CreateFont(ReferenceSize)) { Dpi = 72f }; + double limit = wrappingWidth * ReferenceSize / emSize; + + try + { + List lines = new(); + foreach (string paragraph in paragraphs) + { + AppendLines(paragraph, options, limit, lines); + } + + return lines; + } + catch (Exception exception) when (exception is ArgumentException or InvalidOperationException) + { + // Shaping can reject text this font cannot lay out. Unwrapped text beats no text and beats a throw on a + // drawing that merely contains an awkward label. + return paragraphs; + } + } + + /// + /// Appends the lines of one paragraph, fitting tokens greedily: a token joins the current line while the line's + /// advance still fits, and otherwise opens the next one. Trailing whitespace is trimmed off the candidate before + /// measuring, the way the raster ignores the spaces that fall at a break, and off every line that a break closes; + /// whitespace inside a line survives as typed. + /// + /// One paragraph, without line breaks. + /// Measuring options carrying the reference-size font. + /// Available width, scaled to the reference size. + /// Receives the fitted lines. + private static void AppendLines(string paragraph, TextOptions options, double limit, List lines) + { + string current = string.Empty; + foreach (string token in Tokenize(paragraph)) + { + if (current.Length == 0) + { + current = token; + continue; + } + + string candidate = current + token; + if (TextMeasurer.MeasureAdvance(candidate.TrimEnd(' ', '\t'), options).Width <= limit) + { + current = candidate; + } + else + { + lines.Add(current.TrimEnd(' ', '\t')); + current = token; + } + } + + lines.Add(current); + } + + /// + /// Cuts a paragraph at the raster's break opportunities: after a run of breaking whitespace, which stays attached + /// to the token it follows, and after a hyphen-minus or a slash that is not itself followed by whitespace. Working + /// on the string in logical order keeps the tokens meaningful for right-to-left text and for combining marks, + /// which a pass over laid-out glyphs cannot promise. + /// + /// One paragraph, without line breaks. + /// The tokens, in logical order; concatenated they are the paragraph. + private static IEnumerable Tokenize(string paragraph) + { + int start = 0; + int index = 0; + while (index < paragraph.Length) + { + char current = paragraph[index]; + if (IsBreakingSpace(current)) + { + while (index < paragraph.Length && IsBreakingSpace(paragraph[index])) + { + index++; + } + } + else if ((current == '-' || current == '/') && index + 1 < paragraph.Length && !IsBreakingSpace(paragraph[index + 1])) + { + index++; + } + else + { + index++; + continue; + } + + yield return paragraph[start..index]; + start = index; + } + + if (start < paragraph.Length) + { + yield return paragraph[start..]; + } + } + + /// Whether the character ends a run that a line may break after. + /// The character to classify. + /// true for a space or a tab; a no-break space (U+00A0) never breaks, as in UAX #14. + private static bool IsBreakingSpace(char value) => value is ' ' or '\t'; +} diff --git a/ACadSharp.Image/Rendering/Svg/SvgXmlText.cs b/ACadSharp.Image/Rendering/Svg/SvgXmlText.cs new file mode 100644 index 0000000..3eada6a --- /dev/null +++ b/ACadSharp.Image/Rendering/Svg/SvgXmlText.cs @@ -0,0 +1,67 @@ +using System.Text; +using System.Xml; + +namespace ACadSharp.Image.Rendering.Svg; + +/// +/// Removes characters that XML 1.0 forbids from strings that come out of a drawing (text, layer names, block names, +/// font names). LINQ to XML escapes markup characters itself, but a control character such as U+0001 makes the +/// serialiser throw, and a drawing must never take the whole export down with it. +/// +internal static class SvgXmlText +{ + /// + /// Returns with every character that is not a legal XML character removed. Tab, line feed and + /// carriage return are legal and kept; valid surrogate pairs are kept; lone surrogates are dropped. + /// + /// The string to clean. + /// The same instance when nothing had to be removed, otherwise a cleaned copy. + public static string Clean(string value) + { + int first = FirstInvalidIndex(value); + if (first < 0) + { + return value; + } + + StringBuilder builder = new(value.Length); + builder.Append(value, 0, first); + for (int i = first; i < value.Length; i++) + { + char c = value[i]; + if (XmlConvert.IsXmlChar(c)) + { + builder.Append(c); + } + else if (i + 1 < value.Length && XmlConvert.IsXmlSurrogatePair(value[i + 1], c)) + { + builder.Append(c).Append(value[i + 1]); + i++; + } + } + + return builder.ToString(); + } + + private static int FirstInvalidIndex(string value) + { + for (int i = 0; i < value.Length; i++) + { + char c = value[i]; + if (XmlConvert.IsXmlChar(c)) + { + continue; + } + + if (i + 1 < value.Length && XmlConvert.IsXmlSurrogatePair(value[i + 1], c)) + { + i++; + continue; + } + + return i; + } + + return -1; + } +} diff --git a/ACadSharp.Image/Rendering/TextMetrics.cs b/ACadSharp.Image/Rendering/TextMetrics.cs new file mode 100644 index 0000000..70999d6 --- /dev/null +++ b/ACadSharp.Image/Rendering/TextMetrics.cs @@ -0,0 +1,20 @@ +namespace ACadSharp.Image.Rendering; + +/// +/// Text sizing shared by the raster and SVG backends, chosen to match the CAD intent. +/// +/// +/// SurfaceText.Height is the CAD text height, which is the cap height, and common sans faces have a cap height +/// of about 0.72 em; 4/3 of the cap height is therefore the em that draws the glyphs at the height the drawing asks +/// for. Both backends lay that em out at 72 dpi (one point is one pixel on the raster canvas, one user unit in SVG), +/// so text scales with the page like the geometry does and the two outputs agree. Line spacing follows AutoCAD: 5/3 of +/// the text height per line at spacing factor 1. +/// +internal static class TextMetrics +{ + /// Em size per unit of cap height. + public const double CapHeightToEm = 4d / 3d; + + /// Font size (em) for a CAD text height. + public static double EmSize(double capHeight) => capHeight * CapHeightToEm; +} diff --git a/ACadSharp.Image/Rendering/TextRenderer.cs b/ACadSharp.Image/Rendering/TextRenderer.cs index 53ed741..b84885e 100644 --- a/ACadSharp.Image/Rendering/TextRenderer.cs +++ b/ACadSharp.Image/Rendering/TextRenderer.cs @@ -1,45 +1,112 @@ -using System.Numerics; +using System.Globalization; +using System.Text.RegularExpressions; using ACadSharp.Entities; +using ACadSharp.IO; using CSMath; -using SixLabors.Fonts; -using SixLabors.ImageSharp; -using SixLabors.ImageSharp.Drawing; -using SixLabors.ImageSharp.Drawing.Processing; -using SixLabors.ImageSharp.Processing; namespace ACadSharp.Image.Rendering; -internal sealed class TextRenderer(ImageConfiguration configuration) +/// +/// Converts and into runs and hands them to the surface. +/// +internal sealed class TextRenderer { - private readonly ImageConfiguration _configuration = configuration; + private static readonly Regex UnicodeEscape = new(@"\\[Uu]\+([0-9A-Fa-f]{4})", RegexOptions.Compiled); - public void Draw(ImageRenderContext context, ImageStyle style, MText mtext) + /// + /// Draws a multiline text entity, optionally placed by a block reference. + /// + /// The context that maps drawing units onto the surface. + /// The resolved style for the entity. + /// The entity to draw. + /// 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, + /// so an MTEXT inside a rotated insert would otherwise be drawn unrotated. + /// + public void Draw(ImageRenderContext context, ImageStyle style, MText mtext, Transform? placement) { - string text = NormalizeText(mtext.PlainText); + string text = NormalizeText(PlainTextOf(mtext)); if (string.IsNullOrWhiteSpace(text)) { return; } - PointF origin = context.ToPixelPoint(mtext.InsertPoint); - Font font = this.CreateFont(context, mtext.Height); - TextOptions options = new(font) + XYZ xAxis = mtext.AlignmentPoint; + if (xAxis.GetLength() < 1e-12) { - Dpi = context.Configuration.Dpi, - Origin = origin, - HorizontalAlignment = GetHorizontalAlignment(mtext.AttachmentPoint), - VerticalAlignment = GetVerticalAlignment(mtext.AttachmentPoint), - WrappingLength = mtext.RectangleWidth > 0 ? context.ToPixelLength(mtext.RectangleWidth) : -1, - LineSpacing = (float)mtext.LineSpacing, - }; + xAxis = new XYZ(Math.Cos(mtext.Rotation), Math.Sin(mtext.Rotation), 0d); + } + + xAxis /= xAxis.GetLength(); + XYZ yAxis = new(-xAxis.Y, xAxis.X, 0d); + Placement? placed = Place(placement, mtext.InsertPoint, xAxis, yAxis); + if (placed is not Placement p) + { + context.Configuration.Notify($"[{mtext.SubclassMarker}] Text plane is seen edge-on; text skipped.", NotificationType.Warning); + return; + } + + (double rotation, SurfaceTextAnchor anchor) = Orient(p, GetAnchor(mtext.AttachmentPoint)); + SurfaceText run = new( + text, + context.ToSurfacePoint(p.Origin), + context.ToSurfaceLength(mtext.Height * p.Scale), + rotation, + anchor, + GetBaseline(mtext.AttachmentPoint), + mtext.RectangleWidth > 0 ? context.ToSurfaceLength(mtext.RectangleWidth * p.WidthScale) : -1d, + mtext.LineSpacing, + FixedLength: -1d, + WidthScale: p.WidthScale / p.Scale); + + context.Surface.DrawText(style, run); + } - IPathCollection glyphs = TextBuilder.GenerateGlyphs(text, options); - DrawingOptions drawingOptions = CreateDrawingOptions(origin, mtext.Rotation); + /// + /// Draws an attribute whose layout comes from an embedded . AutoCAD stores a multi-line + /// attribute's real layout there, and leaves the single-line Value as a flattened copy, so the embedded + /// object is the authority for everything geometric: the text, its rectangle width, height, rotation and + /// attachment point. The attribute itself stays the observable entity, so layer, colour, handle and parent + /// metadata are unchanged. + /// + /// The context that maps drawing units onto the surface. + /// The resolved style for the attribute. + /// 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 + /// 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. + /// + public void DrawAttribute(ImageRenderContext context, ImageStyle style, AttributeBase attribute, Transform? placement) + { + if (attribute.MText == null) + { + context.Configuration.Notify( + $"[{attribute.SubclassMarker}] Handle {attribute.Handle.ToString("X", CultureInfo.InvariantCulture)}: multi-line layout is not available; the single-line value was drawn.", + NotificationType.Warning); + this.Draw(context, style, (TextEntity)attribute, placement); + return; + } - context.Canvas.Mutate(x => x.Fill(drawingOptions, style.StrokeColor, glyphs)); + this.Draw(context, style, attribute.MText, placement); } - public void Draw(ImageRenderContext context, ImageStyle style, TextEntity textEntity) + /// + /// Draws a single-line text entity, optionally placed by a block reference. + /// + /// The context that maps drawing units onto the surface. + /// The resolved style for the entity. + /// The entity to draw. + /// 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 + /// alignment point, and mixes world points with a mirrored normal. + /// + public void Draw(ImageRenderContext context, ImageStyle style, TextEntity textEntity, Transform? placement) { string text = NormalizeText(textEntity.Value); if (string.IsNullOrWhiteSpace(text)) @@ -47,42 +114,128 @@ public void Draw(ImageRenderContext context, ImageStyle style, TextEntity textEn return; } - PointF origin = context.ToPixelPoint(GetTextOrigin(textEntity)); - Font font = this.CreateFont(context, textEntity.Height); - TextOptions options = new(font) + // TEXT stores its points and rotation in its own OCS (MTEXT does not: its insertion point and X axis are WCS). + OcsTransform? toWorld = OcsTransform.IsWorldPlane(textEntity.Normal) ? null : OcsTransform.For(textEntity.Normal); + XYZ origin = ToWorld(toWorld, GetTextOrigin(textEntity)); + XYZ xAxis = Direction(toWorld, textEntity.Rotation); + XYZ yAxis = Direction(toWorld, textEntity.Rotation + (Math.PI / 2d)); + Placement? placed = Place(placement, origin, xAxis, yAxis); + if (placed is not Placement p) { - Dpi = context.Configuration.Dpi, - Origin = origin, - HorizontalAlignment = GetHorizontalAlignment(textEntity.HorizontalAlignment), - VerticalAlignment = GetVerticalAlignment(textEntity.VerticalAlignment), - }; + context.Configuration.Notify($"[{textEntity.SubclassMarker}] Text plane is seen edge-on; text skipped.", NotificationType.Warning); + return; + } - IPathCollection glyphs = TextBuilder.GenerateGlyphs(text, options); - DrawingOptions drawingOptions = CreateDrawingOptions(origin, textEntity.Rotation); + (double rotation, SurfaceTextAnchor anchor) = Orient(p, GetAnchor(textEntity.HorizontalAlignment)); + SurfaceText run = new( + text, + context.ToSurfacePoint(p.Origin), + context.ToSurfaceLength(textEntity.Height * p.Scale), + rotation, + anchor, + GetBaseline(textEntity.VerticalAlignment), + WrappingWidth: -1d, + LineSpacingFactor: 1d, + GetFixedLength(context, textEntity, toWorld, placement), + WidthScale: p.WidthScale / p.Scale); - context.Canvas.Mutate(x => x.Fill(drawingOptions, style.StrokeColor, glyphs)); + context.Surface.DrawText(style, run); } - private Font CreateFont(ImageRenderContext context, double height) + /// A text run placed in world XY: where it starts, the direction it reads along and its up direction. + /// World origin of the run. + /// Unit world direction the baseline reads along. + /// True when the up direction lies to the right of the reading direction, i.e. the plane is seen from behind. + /// Factor the text height is multiplied by (the length of the transformed up vector). + /// Length of the transformed unit reading direction, before it is normalised into . + internal readonly record struct Placement(XY Origin, XY Direction, bool Mirrored, double Scale, double WidthScale); + + /// + /// Builds a placement by mapping the origin and the tips of its unit X and Y axes through the optional insert + /// transform and projecting onto world XY. + /// + /// The transform of the insert that placed the entity, or null at top level. + /// World origin of the run. + /// Unit world direction the baseline reads along, before the insert transform. + /// Unit world up direction, before the insert transform. + /// The placement, or null when the plane is seen edge-on (either axis projects to nothing). + internal static Placement? Place(Transform? placement, XYZ origin, XYZ xAxis, XYZ yAxis) + { + XYZ o = InsertPlacement.MapPoint(placement, origin); + XYZ dx = InsertPlacement.MapPoint(placement, origin + xAxis) - o; + XYZ dy = InsertPlacement.MapPoint(placement, origin + yAxis) - o; + XY direction = new(dx.X, dx.Y); + XY up = new(dy.X, dy.Y); + double length = direction.GetLength(); + double scale = up.GetLength(); + if (length < 1e-12 || scale < 1e-12) + { + return null; + } + + bool mirrored = (direction.X * up.Y) - (direction.Y * up.X) < 0d; + return new Placement(new XY(o.X, o.Y), direction / length, mirrored, scale, length); + } + + /// + /// Rotation and anchor for a placement. + /// + /// The placement to orient. + /// Anchor derived from the horizontal alignment or attachment point. + /// The rotation to draw with (radians, drawing convention) and the anchor to use. + /// + /// A plane seen from behind (what MIRROR writes) would show mirrored glyphs; the renderer keeps them readable and + /// lets the run occupy the mirrored extent instead, which is the same baseline read from the other end: half a + /// turn added to the direction, and and + /// swapped. AutoCAD draws the glyphs themselves mirrored; this is a deliberate readability choice. + /// + internal static (double Rotation, SurfaceTextAnchor Anchor) Orient(Placement placement, SurfaceTextAnchor anchor) { - float size = Math.Max(1f, context.ToPixelLength(height)); - if (SystemFonts.TryGet(this._configuration.FontFamilyName, out FontFamily family)) + double angle = Math.Atan2(placement.Direction.Y, placement.Direction.X); + if (!placement.Mirrored) { - return family.CreateFont(size); + return (angle, anchor); } - return SystemFonts.Families.First().CreateFont(size); + SurfaceTextAnchor flipped = anchor switch + { + SurfaceTextAnchor.Start => SurfaceTextAnchor.End, + SurfaceTextAnchor.End => SurfaceTextAnchor.Start, + _ => anchor, + }; + double turned = angle + Math.PI; + return (Math.Atan2(Math.Sin(turned), Math.Cos(turned)), flipped); + } + + private static XYZ ToWorld(OcsTransform? toWorld, XYZ point) => toWorld == null ? point : toWorld.ToWorld(point.X, point.Y, point.Z); + + private static XYZ Direction(OcsTransform? toWorld, double angle) + { + XYZ ocs = new(Math.Cos(angle), Math.Sin(angle), 0d); + return toWorld == null ? ocs : toWorld.ToWorld(ocs.X, ocs.Y, 0d); } - private static DrawingOptions CreateDrawingOptions(PointF origin, double rotation) + /// + /// Measures the Fit/Aligned advance between a TEXT entity's insertion and alignment points. + /// + /// The context that maps drawing units onto the surface. + /// The entity to measure. + /// The OCS frame, or null for the world plane. + /// The transform of the insert that placed the entity, or null at top level. + /// The fixed advance in surface units, or -1 when the alignment does not apply. + private static double GetFixedLength(ImageRenderContext context, TextEntity textEntity, OcsTransform? toWorld, Transform? placement) { - DrawingOptions options = new(); - if (Math.Abs(rotation) > double.Epsilon) + if (textEntity.HorizontalAlignment is not (TextHorizontalAlignment.Aligned or TextHorizontalAlignment.Fit)) { - options.Transform = Matrix3x2.CreateRotation((float)-rotation, new Vector2(origin.X, origin.Y)); + return -1d; } - return options; + XYZ insert = InsertPlacement.MapPoint(placement, ToWorld(toWorld, textEntity.InsertPoint)); + XYZ alignment = InsertPlacement.MapPoint(placement, ToWorld(toWorld, textEntity.AlignmentPoint)); + double dx = alignment.X - insert.X; + double dy = alignment.Y - insert.Y; + double length = Math.Sqrt((dx * dx) + (dy * dy)); + return length > 0 ? context.ToSurfaceLength(length) : -1d; } private static XYZ GetTextOrigin(TextEntity textEntity) @@ -92,57 +245,78 @@ private static XYZ GetTextOrigin(TextEntity textEntity) : textEntity.AlignmentPoint; } - private static HorizontalAlignment GetHorizontalAlignment(AttachmentPointType attachment) + private static SurfaceTextAnchor GetAnchor(AttachmentPointType attachment) { return attachment switch { - AttachmentPointType.TopCenter or AttachmentPointType.MiddleCenter or AttachmentPointType.BottomCenter => HorizontalAlignment.Center, - AttachmentPointType.TopRight or AttachmentPointType.MiddleRight or AttachmentPointType.BottomRight => HorizontalAlignment.Right, - _ => HorizontalAlignment.Left, + AttachmentPointType.TopCenter or AttachmentPointType.MiddleCenter or AttachmentPointType.BottomCenter => SurfaceTextAnchor.Middle, + AttachmentPointType.TopRight or AttachmentPointType.MiddleRight or AttachmentPointType.BottomRight => SurfaceTextAnchor.End, + _ => SurfaceTextAnchor.Start, }; } - private static VerticalAlignment GetVerticalAlignment(AttachmentPointType attachment) + private static SurfaceTextBaseline GetBaseline(AttachmentPointType attachment) { return attachment switch { - AttachmentPointType.TopLeft or AttachmentPointType.TopCenter or AttachmentPointType.TopRight => VerticalAlignment.Top, - AttachmentPointType.MiddleLeft or AttachmentPointType.MiddleCenter or AttachmentPointType.MiddleRight => VerticalAlignment.Center, - _ => VerticalAlignment.Bottom, + AttachmentPointType.TopLeft or AttachmentPointType.TopCenter or AttachmentPointType.TopRight => SurfaceTextBaseline.Hanging, + AttachmentPointType.MiddleLeft or AttachmentPointType.MiddleCenter or AttachmentPointType.MiddleRight => SurfaceTextBaseline.Central, + _ => SurfaceTextBaseline.Alphabetic, }; } - private static HorizontalAlignment GetHorizontalAlignment(TextHorizontalAlignment alignment) + private static SurfaceTextAnchor GetAnchor(TextHorizontalAlignment alignment) { return alignment switch { - TextHorizontalAlignment.Center or TextHorizontalAlignment.Aligned or TextHorizontalAlignment.Middle or TextHorizontalAlignment.Fit => HorizontalAlignment.Center, - TextHorizontalAlignment.Right => HorizontalAlignment.Right, - _ => HorizontalAlignment.Left, + TextHorizontalAlignment.Center or TextHorizontalAlignment.Aligned or TextHorizontalAlignment.Middle or TextHorizontalAlignment.Fit => SurfaceTextAnchor.Middle, + TextHorizontalAlignment.Right => SurfaceTextAnchor.End, + _ => SurfaceTextAnchor.Start, }; } - private static VerticalAlignment GetVerticalAlignment(TextVerticalAlignmentType alignment) + private static SurfaceTextBaseline GetBaseline(TextVerticalAlignmentType alignment) { return alignment switch { - TextVerticalAlignmentType.Middle => VerticalAlignment.Center, - TextVerticalAlignmentType.Top => VerticalAlignment.Top, - _ => VerticalAlignment.Bottom, + TextVerticalAlignmentType.Middle => SurfaceTextBaseline.Central, + TextVerticalAlignmentType.Top => SurfaceTextBaseline.Hanging, + _ => SurfaceTextBaseline.Alphabetic, }; } - private static string NormalizeText(string? value) + /// + /// The MTEXT's text with formatting stripped. Unicode escapes are decoded before ACadSharp strips the formatting, + /// because its PlainText drops the backslash of \U+XXXX and would leave the literal code behind. + /// + 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; + } + + /// + /// Applies the DXF text codes: \U+XXXX code points, %%C diameter, %%D degree, %%P + /// plus-minus, %%% percent, the %%U/%%O underline and overline toggles (dropped), and + /// \P paragraph breaks. + /// + internal static string NormalizeText(string? value) { if (string.IsNullOrEmpty(value)) { return string.Empty; } - return value + string text = UnicodeEscape.Replace(value, m => ((char)Convert.ToInt32(m.Groups[1].Value, 16)).ToString()); + return text + .Replace("%%%", "\u0001", StringComparison.Ordinal) .Replace("%%C", "Ø", StringComparison.OrdinalIgnoreCase) .Replace("%%D", "°", StringComparison.OrdinalIgnoreCase) .Replace("%%P", "±", StringComparison.OrdinalIgnoreCase) + .Replace("%%U", string.Empty, StringComparison.OrdinalIgnoreCase) + .Replace("%%O", string.Empty, StringComparison.OrdinalIgnoreCase) + .Replace("\u0001", "%", StringComparison.Ordinal) .Replace("\\P", "\n", StringComparison.OrdinalIgnoreCase); } } diff --git a/ACadSharp.Image/SvgOptions.cs b/ACadSharp.Image/SvgOptions.cs new file mode 100644 index 0000000..aad6fe8 --- /dev/null +++ b/ACadSharp.Image/SvgOptions.cs @@ -0,0 +1,43 @@ +namespace ACadSharp.Image; + +/// +/// Settings that only affect output. +/// +public sealed class SvgOptions +{ + private int? _precision; + + /// + /// Gets or sets whether strokes keep a constant on-screen width when the SVG is zoomed. + /// When true (default) stroke widths are written in pixels with vector-effect="non-scaling-stroke"; + /// when false they are written in drawing units and scale with the drawing. + /// + public bool NonScalingStroke { get; set; } = true; + + /// + /// Gets or sets whether each element carries data-handle, data-type, data-parent and data-block attributes. Default true. + /// + public bool EmitEntityAttributes { get; set; } = true; + + /// + /// Gets or sets whether the root element gets width/height attributes from and . Default false (responsive). + /// + public bool EmitSize { get; set; } + + /// + /// Gets or sets a prefix for every id so several drawings can be inlined in one HTML document. Default empty. + /// + public string IdPrefix { get; set; } = string.Empty; + + /// + /// Gets or sets the number of decimals for coordinates (0..8). Null (default) picks the precision from the viewBox size + /// so the resolution is one ten-thousandth of the larger side. + /// + public int? Precision + { + get => this._precision; + set => this._precision = value is null or (>= 0 and <= 8) + ? value + : throw new ArgumentOutOfRangeException(nameof(value), "Precision must be between 0 and 8."); + } +} diff --git a/Directory.Packages.props b/Directory.Packages.props index ab552c6..c621af1 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -4,7 +4,7 @@ - + diff --git a/README.md b/README.md index f056497..c892c31 100644 --- a/README.md +++ b/README.md @@ -2,12 +2,12 @@ [![NuGet downloads](https://img.shields.io/nuget/dt/ACadSharp.Image?logo=nuget&label=downloads)](https://www.nuget.org/packages/ACadSharp.Image) [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE) -[![.NET](https://img.shields.io/badge/.NET-6.0%20%7C%208.0%20%7C%2010.0-512bd4)](https://dotnet.microsoft.com/download) +[![.NET](https://img.shields.io/badge/.NET-8.0%20%7C%2010.0-512bd4)](https://dotnet.microsoft.com/download) [![CI](https://github.com/slaveoftime/ACadSharp.Image/actions/workflows/ci.yml/badge.svg)](https://github.com/slaveoftime/ACadSharp.Image/actions) **High-performance DXF/DWG to image renderer for .NET**, built on [ACadSharp](https://github.com/DomCR/ACadSharp) and [ImageSharp](https://github.com/SixLabors/ImageSharp). -Transform CAD drawings into raster images for **previews**, **CI/CD pipelines**, **web applications**, **documentation**, and **automated workflows** — with zero AutoCAD dependency. +Transform CAD drawings into raster images or SVG for **previews**, **CI/CD pipelines**, **web applications**, **documentation**, and **automated workflows** — with zero AutoCAD dependency. ![Rendered sample](Samples/HSK80AHCP16190M_BMG.webp) @@ -15,11 +15,14 @@ Transform CAD drawings into raster images for **previews**, **CI/CD pipelines**, ## ✨ Features -- 🎨 **Multi-format export** — PNG, BMP, JPEG, GIF, and WebP support +- 🎨 **Multi-format export** — PNG, BMP, JPEG, GIF, WebP, and SVG support - 📐 **Full CAD support** — Render DXF and DWG files with ACadSharp - 🖼️ **Customizable output** — Control width, height, padding, background color, and quality - 📊 **Space support** — Model space, paper layouts, and viewports -- 🎭 **Layer filtering** — Hide specific layers with `--hide-layer` option +- 🖋️ **SVG output** — One `` per layer, `data-*` attributes and real ``, ready for React pan/zoom viewers +- 👁️ **Layer visibility modes** — `screen` and `plot` honour off, frozen, non-plottable and viewport-frozen layers +- 🎭 **Layer filtering** — Include and exclude layer lists, with `--hide-layer` and `--only-layer` CLI options +- 〰️ **Linetypes, transparency and hatches** — Dashed linetypes, entity transparency and hatch fills are rendered - ⚡ **CLI tool** — Cross-platform command-line interface for automation - 🔧 **Library API** — Full .NET integration with intuitive fluent-style configuration - 🚀 **Native AOT** - Publish as standalone native binaries with zero .NET runtime requirement @@ -64,7 +67,7 @@ using SixLabors.ImageSharp; var document = DwgReader.Read("part.dwg"); // Configure and export -var exporter = new ImageExporter("output.webp"); +var exporter = new ImageExporter(); exporter.Configuration.Width = 2000; exporter.Configuration.Height = 1400; exporter.Configuration.SetPadding(24, 12); @@ -121,6 +124,18 @@ cad-to-image "complex.dxf" --hide-layer "DIMENSIONS" --hide-layer "ANNOTATIONS" cad-to-image "multi-sheet.dwg" --paper-layouts --output ./sheets/ ``` +**Render to SVG:** + +```bash +cad-to-image "drawing.dxf" --format svg --layer-visibility plot --only-layer "A-WALL" --only-layer "A-DOOR" +``` + +**List a drawing's layers:** + +```bash +cad-to-image "drawing.dxf" --list-layers +``` + --- ## 📖 CLI Reference @@ -131,7 +146,7 @@ Usage: Options: -o, --output Output file or directory path. - -f, --format png, bmp, jpg, jpeg, gif, webp. + -f, --format png, bmp, jpg, jpeg, gif, webp, svg. -w, --width Output width in pixels. Default: 1600. -H, --height Output height in pixels. Default: 900. -p, --padding Padding in pixels: , , or . @@ -139,6 +154,15 @@ Options: -q, --quality <1-100> Output quality for lossy formats. Default: 90. --paper-layouts Export paper layouts instead of model space. --hide-layer Hide entities on the specified layer. Can be used multiple times. + --only-layer Render only the specified layer(s). Can be used multiple times. + --layer-visibility all (default), screen (honour off/frozen), or plot (also honour non-plottable). + --list-layers Print the drawing's layers and exit without rendering. + --svg-no-scaling-stroke Write SVG stroke widths in drawing units instead of constant pixels. + --svg-no-entity-attributes + Omit data-handle/data-type/data-parent/data-block attributes from SVG. + --svg-size Emit width/height on the SVG root from --width/--height. + --svg-id-prefix Prefix for SVG ids so several drawings can share one page. + --svg-precision <0-8> Decimal places for SVG coordinates. Default: adaptive. --help, -h, -? Show this help text. ``` @@ -148,44 +172,89 @@ Options: ``` ACadSharp.Image/ -├── ImageExporter.cs # Main public API -├── ImageConfiguration.cs # Fluent configuration -├── ImagePage.cs # Page representation -├── RenderedImagePage.cs # Rendered output +├── ImageExporter.cs # Main public API +├── ImageConfiguration.cs # Configuration (layers, colours, SVG options) +├── ImagePage.cs # Page representation +├── RenderedPage.cs # Abstract rendered output (Save to path/stream) +├── RenderedImagePage.cs # Raster output (ImageSharp) +├── RenderedSvgPage.cs # SVG output +├── SvgOptions.cs # SVG-only settings +├── LayerVisibilityMode.cs # All / Screen / Plot +├── ImageExportFormat.cs # Png, Bmp, Jpeg, Gif, Webp, Svg +├── ImageExportFormatExtensions.cs # Format <-> file extension helpers └── Rendering/ - ├── ImagePageRenderer.cs # Page-level rendering - ├── EntityRenderDispatcher.cs # Entity routing & primitive drawing - ├── SplineRenderer.cs # Spline path generation and sampling - ├── TextRenderer.cs # Text and MText rendering - ├── ImageRenderContext.cs # Coordinate transforms - └── ImageStyleResolver.cs # Color & line weight resolution + ├── IDrawingSurface.cs # Backend-neutral primitives + ├── RasterDrawingSurface.cs # ImageSharp backend + ├── Svg/ + │ ├── SvgDrawingSurface.cs # SVG backend + │ ├── SvgIdSanitizer.cs # HTML-safe id generation + │ └── SvgNumberFormatter.cs # Coordinate formatting/precision + ├── ImagePageRenderer.cs # Page-level rendering and viewports + ├── EntityRenderDispatcher.cs # Entity routing, layer filtering, hatches + ├── EntityRenderInfo.cs # Handle/type/parent/block identity for a drawn entity + ├── EntityVisibilityFilter.cs # Include/hide lists and layer state + ├── ImageStyle.cs # Resolved colour, width, dashes, opacity for one entity + ├── ImageStyleResolver.cs # Colour, width, dashes, opacity + ├── LineTypeDashResolver.cs # Linetype to dash array + ├── SplineRenderer.cs / SplineBezierConverter.cs + ├── CurveTessellation.cs # Arc/circle/ellipse sampling for raster and off-plane entities + ├── TextRenderer.cs # Text to SurfaceText + ├── SurfacePoint.cs # Surface-space point/rect primitives + ├── SurfaceText.cs # Backend-neutral text placement + └── ImageRenderContext.cs # Coordinate transforms ``` The library follows a clean architecture pattern: - **ImageExporter** - Public API for adding CAD content - **ImagePage** - Represents individual renderable pages -- **Rendering pipeline** - Transforms CAD entities to pixel coordinates and draws them +- **Rendering pipeline** - Transforms CAD entities to surface coordinates and draws them through a backend-neutral `IDrawingSurface`, implemented by a raster (ImageSharp) and an SVG surface - **Configuration** - Fluent, extensible settings for customization --- ## 💡 Advanced Usage -### Layer Filtering - -Control visibility of specific layers programmatically: +### Layer selection ```csharp var exporter = new ImageExporter(); +exporter.Configuration.IncludeLayers(["A-WALL", "A-DOOR"]); // render only these (optional) +exporter.Configuration.HideLayer("A-DOOR"); // then remove one of them +exporter.AddModelSpace(document); +``` -// Hide multiple layers (case-insensitive) -exporter.Configuration.HideLayer("0"); -exporter.Configuration.HideLayer("DEFPOINTS"); -exporter.Configuration.HideLayer("ANNO_TEXT"); +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. -exporter.AddModelSpace(document); +### Layer visibility + +```csharp +exporter.Configuration.LayerVisibility = LayerVisibilityMode.Plot; // All (default), Screen, Plot ``` +`Screen` hides off and frozen layers, invisible entities and layers frozen per viewport. `Plot` also hides non-plottable layers such as `DEFPOINTS`. Hidden block attributes and the drawing's ATTMODE are honoured in the same two modes. + +### Supported entities + +Lines, arcs, circles, ellipses, polylines (2D, 3D, lightweight, with bulges), splines, points, solids, 3D faces (edges, honouring invisible-edge flags), hatches (solid and pattern), TEXT, MTEXT, dimensions, block references, block attributes (ATTRIB; hidden ones follow ATTMODE under `Screen`/`Plot`; a multi-line attribute is laid out from its embedded MTEXT; an attribute belonging to an insert nested inside another block is laid out in that block's own coordinates; only top-level attributes are placed), leaders (straight and splined, with the default arrowhead or a custom arrowhead block placed at the tip; a custom arrowhead falls back to the default triangle, with a warning, when its block is empty, references itself, is degenerately sized, or sits inside a non-uniformly scaled block reference), multilines (element offsets, fill, square caps; cut segments are drawn from DXF group 41 read as absolute positions — the DXF reference reads that way, but ezdxf reads the same values as relative dash and gap lengths and no implementation settles it, so a drawing with more than one cut per element may differ from AutoCAD; fill cuts are not drawn), wipeouts (masked with the background colour; an inverted clip masks the frame minus the boundary; needs an opaque `BackgroundColor`; in SVG the mask stays within layer-group order) and paper-space viewports. Entities, including paper-space viewports, are drawn in the drawing's draw order (handle order overridden by DRAWORDER), so later entities paint over earlier ones (in SVG, within each layer group; layer grouping comes first). Draw order applies to the page's own entities; the contents of a block reference are drawn in the block's stored order at the first nesting level, and in handle order (not DRAWORDER) below that. A block reference whose block is missing is skipped with a warning, and so is one whose block graph references itself (directly, through a nested block, through a dimension style's arrowhead block, or through the picture block of a dimension inside it). A paper-space viewport's contents are the model-space entities whose bounds overlap or touch its view box in the XY plane, including an entity that encloses the view box or crosses it without a corner inside it. + +### Linetypes, transparency and colour 7 + +Dashed linetypes are rendered using `LTSCALE`, the entity linetype scale and `PSLTSCALE` (honoured from the raw `$PSLTSCALE` header value) in paper space; patterns shorter than `MinimumDashPixels` are drawn solid (pixel-width modes only; not applied in SVG drawing-unit mode), and embedded shapes and text in a linetype render as gaps. Entity transparency becomes opacity (ByLayer is treated as opaque because the ACadSharp layer table carries no transparency). Colour index 7 resolves to black or white from the background luminance, or to `ForegroundColor` when set. + +### SVG output + +```csharp +exporter.Configuration.Svg.NonScalingStroke = true; // constant on-screen stroke width when zooming (default) +exporter.Configuration.Svg.IdPrefix = "plan1-"; // when inlining several drawings in one page +exporter.Save("plan.svg", ImageExportFormat.Svg); +``` + +The SVG has a drawing-unit `viewBox`, no `width`/`height` unless `Svg.EmitSize` is set, an attribute-free `` for your pan/zoom transform, and one `` per layer. Every element carries `data-handle` and `data-type` (plus `data-parent`/`data-block` for block contents); `data-handle` is omitted for exploded block contents, since they are transient clones with no handle of their own. In React, prefer injecting the markup at runtime or configure SVGO to keep ids; `data-*` attributes survive the default SVGR pipeline. Toggle a layer with CSS `display: none` on its group. + +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. + ### Custom Line Weights Override default line weight values: @@ -210,7 +279,7 @@ exporter.Configuration.ArcPrecision = 512; // Higher = smoother arcs ### Prerequisites -- [.NET 6.0 SDK](https://dotnet.microsoft.com/download) or later +- [.NET 8.0 SDK](https://dotnet.microsoft.com/download) or later - Any IDE with C# support (VS 2022, VS Code, Rider) ### Build & Test @@ -244,6 +313,8 @@ dotnet run --project ./ACadSharp.Image.Cli/ACadSharp.Image.Cli.csproj -- "./Samp dotnet run --project ./ACadSharp.Image.Cli/ACadSharp.Image.Cli.csproj -- "./Samples/HSK80AHCP16190M_BMG.dwg" --format webp --width 1200 --height 760 dotnet run --project ./ACadSharp.Image.Cli/ACadSharp.Image.Cli.csproj -- "./Samples/Subaru Logo Vector Free Wrap.dxf" --format webp --width 1200 --height 700 --background "#a0a7ae" + +dotnet run --project ./ACadSharp.Image.Cli/ACadSharp.Image.Cli.csproj -- "./Samples/6-57-1119.dxf" --format svg --layer-visibility plot ``` ### Build NuGet Package @@ -283,6 +354,19 @@ Recent modernization work includes intentional API tightening: These changes preserve the rendering behavior while making mutation points explicit and easier to maintain. +Changes on the way to the next major release: + +- `ImageExporter.Render()` now takes an optional `ImageExportFormat` and returns `IReadOnlyList`; cast items to `RenderedImagePage` for the raster canvas or `RenderedSvgPage` (its `Content` holds the markup), or call `Save(path)`/`Save(stream)` on the page. +- `RenderedImagePage` derives from the new abstract `RenderedPage` and its constructor takes the format and quality it will save with. +- The library targets net8.0 and net10.0; net6.0 is no longer supported. +- ACadSharp 3.7.1 is required. +- `ImagePage.Entities` now keeps every added entity; `ImageConfiguration.HiddenLayers` and `LayerVisibility` are applied at render time instead of at `Add`, so changing them afterwards takes effect, and the framing of auto-sized pages follows the currently visible entities. +- New public members: `ImageConfiguration.GetLineWeightMillimeters` and `ImagePage.Document`. +- `RenderedImagePage.Save` throws `NotSupportedException` when its format is `ImageExportFormat.Svg`; use a `RenderedSvgPage` for SVG output instead. +- `ImagePage.Entities` is now ordered by the drawing's draw order (handle order, overridden by DRAWORDER) instead of file order, so later entities paint over earlier ones; block contents keep their stored order. +- `ImageConfiguration.Dpi` no longer scales text; it affects only line weights. Raster text is laid out at a fixed 72 dpi from the same em size the SVG backend uses, so PNG text at the default 96 dpi is unchanged. +- Release this work under a major version tag (for example `v2.0.0`); the version is derived from the tag by the release workflow. + --- ## 🤝 Contributing diff --git a/docs/research/layers-and-svg-support.md b/docs/research/layers-and-svg-support.md new file mode 100644 index 0000000..38ed315 --- /dev/null +++ b/docs/research/layers-and-svg-support.md @@ -0,0 +1,184 @@ +# Layers, layer selection, and SVG output in ACadSharp.Image + +- Date: 2026-09-02 +- Branch: `mubeda/svg-support` +- HEAD: `178e4c2f3d9e3721f7335ba44698d9e1ed1e3cb3` (identical to `main`; see [SVG section](#3-svg-image-generation)) +- Method: repository source read directly (line numbers below are from the working tree at HEAD), pinned NuGet package XML docs restored to `~/.nuget/packages`, and upstream source/docs at the pinned versions where the behaviour lives outside this repo. + +## Summary + +| Capability | Status | Where | +| --- | --- | --- | +| Reading layer info during rendering: ByLayer colour and ByLayer line weight inheritance | Supported | `ACadSharp.Image/Rendering/ImageStyleResolver.cs:31-36` (delegates to ACadSharp `Entity.GetActiveColor()` / `GetActiveLineWeightType()`) | +| Honouring layer state: off (`Layer.IsOn`), frozen (`LayerFlags.Frozen`), non-plottable (`Layer.PlotFlag`), per-viewport frozen layers (`Viewport.FrozenLayers`), `Entity.IsInvisible` | Not supported | No reference anywhere in `ACadSharp.Image/` or `ACadSharp.Image.Cli/` (grep detailed in [1.2](#12-layer-attributes-not-honoured)) | +| Layer linetype (`Layer.LineType`, dash patterns) and transparency | Not supported | Same grep; all strokes are solid `DrawLine`/`Draw` calls (`EntityRenderDispatcher.cs`, `SplineRenderer.cs`) | +| Selecting layers (library API): hide by name, exclude-list | Supported (exclude-only) | `ACadSharp.Image/ImageConfiguration.cs:106,217,354-391`; applied in `ACadSharp.Image/ImageExporter.cs:150-174` | +| Selecting layers (CLI) | Supported (exclude-only) | `ACadSharp.Image.Cli/Program.cs:79-82,195-197,305` | +| Include-only / whitelist / wildcard layer selection | Not supported | No such API; only `HideLayer*`/`ShowLayer` exist (`ImageConfiguration.cs:354-391`) | +| Hidden-layer filter applied to viewport contents, exploded `Insert`s, dimension blocks | Not supported (bypass) | `ACadSharp.Image/Rendering/ImagePageRenderer.cs:85`, `EntityRenderDispatcher.cs:132,177` | +| Raster output: PNG, BMP, JPEG, GIF, WebP | Supported | `ACadSharp.Image/ImageExportFormat.cs:6-35`, `ACadSharp.Image/ImageExporter.cs:252-273` | +| SVG output | Not supported | No `svg` token in any source file; `ImageExportFormat` has no `Svg` member; branch diff vs `main` is empty | +| SVG work in progress on this branch | None | `git log --oneline main..HEAD` and `git diff main --stat` are both empty; sibling worktree clean | + +## 1. Layers — reading layer info and using it during rendering + +### 1.1 What is honoured + +**Layer name (for filtering).** `ImageExporter.IsHiddenLayer` reads `entity.Layer?.Name` and checks it against the configured hidden set (`ACadSharp.Image/ImageExporter.cs:160-174`). Details in [section 2](#2-selection-of-layers). + +**ByLayer / ByBlock colour and line weight.** The only place style is resolved is `ImageStyleResolver.Resolve`: + +```csharp +// ACadSharp.Image/Rendering/ImageStyleResolver.cs:31-36 +public ImageStyle Resolve(Entity entity) +{ + return new ImageStyle( + entity.GetActiveColor().ToImageColor(), + this._configuration.GetLineWeightPixels(entity.GetActiveLineWeightType())); +} +``` + +`GetActiveColor()` and `GetActiveLineWeightType()` are ACadSharp members, not this repo's code. The pinned package is ACadSharp 3.4.24 (`Directory.Packages.props:7`). Its XML docs describe them as "Get the active color for the entity, process the colors like `Color.ByBlock` and `Color.ByLayer`" and "Get the active line weight for the entity, process the line weights like `LineWeightType.ByBlock` and `LineWeightType.ByLayer`" (`~/.nuget/packages/acadsharp/3.4.24/lib/net48/ACadSharp.xml`, members `M:ACadSharp.Entities.IEntity.GetActiveColor` and `M:ACadSharp.Entities.IEntity.GetActiveLineWeightType`). The source at the pinned tag (https://github.com/DomCR/ACadSharp/blob/v3.4.24/src/ACadSharp/Entities/Entity.cs) resolves: + +- `GetActiveColor()`: `Color.IsByLayer` -> `this.Layer.Color`; `Color.IsByBlock` with `Owner is BlockRecord record` -> `record.BlockEntity.Color`; otherwise the entity's own `Color`. +- `GetActiveLineWeightType()`: `LineWeightType.ByLayer` -> `this.Layer.LineWeight`; `ByBlock` with a `BlockRecord` owner -> `record.BlockEntity.GetActiveLineWeightType()`; otherwise the entity's own `LineWeight`. + +So layer colour and layer line weight are inherited when the entity says ByLayer. The resolved `LineWeightType` is turned into pixels by `ImageConfiguration.GetLineWeightPixels` (`ImageConfiguration.cs:299-314`) using the mm table (`:52-80`), `Dpi` (`:139`) and `LineWeightScale` (`:156`), with per-type overrides via `SetLineWeight` (`:398-406`). + +The style is resolved once per entity at the top of the dispatcher (`ACadSharp.Image/Rendering/EntityRenderDispatcher.cs:58-60`) and passed to every drawing primitive, including the spline and text renderers (`SplineRenderer.cs:16`, `TextRenderer.cs:16,42`). + +**Colour conversion caveat.** `ColorExtensions.ToImageColor` maps ACI index 7 to black unconditionally, regardless of `ImageConfiguration.BackgroundColor` (`ACadSharp.Image/Extensions/ColorExtensions.cs:15-26`). A layer whose colour is index 7 therefore renders black even on a dark background. + +### 1.2 Layer attributes not honoured + +ACadSharp 3.4.24 exposes `Layer.IsOn` ("Indicates if the Layer is visible in the model"), `Layer.Flags` (`LayerFlags.Frozen`, `FrozenNewViewports`, `Locked`, ...), `Layer.PlotFlag` ("Specifies if the layer is plottable"), `Layer.LineType`, `Viewport.FrozenLayers` ("Frozen layer object ID/handle"), `Entity.IsInvisible`, `Entity.LineType`, `Entity.LineTypeScale` and `Entity.Transparency` (all from the pinned `ACadSharp.xml`, members `P:ACadSharp.Tables.Layer.*`, `F:ACadSharp.Tables.LayerFlags.*`, `P:ACadSharp.Entities.Viewport.FrozenLayers`, `P:ACadSharp.Entities.Entity.*`). + +None of these are read by this repository. Search performed: + +``` +grep -rn -i "IsOn\|Frozen\|Plottable\|PlotFlag\|\.Layer\b\|Layer\.\|LineType\|Linetype\|Transparency\|IsInvisible\|Invisible" ACadSharp.Image/ ACadSharp.Image.Cli/ --include="*.cs" +``` + +The only layer-related hit in library code is `entity.Layer?.Name` at `ACadSharp.Image/ImageExporter.cs:167`; the remaining hits are the word "transparency" in format XML comments (`ImageExportFormat.cs:9,20,25,32`, `ImageConfiguration.cs:199`), `OrdinalIgnoreCase` string comparisons, and the CLI help text. Consequences: + +- Entities on layers that are **off** or **frozen** in the drawing are rendered. +- Entities on **non-plottable** layers (e.g. `DEFPOINTS`) are rendered unless the caller hides them by name (the README's own example hides `DEFPOINTS` manually, `README.md:183`). +- **Per-viewport frozen layers** are ignored: `DrawViewport` draws everything returned by `viewport.SelectEntities()` (`ImagePageRenderer.cs:85`). +- **Linetype** is never consulted (ACadSharp provides `Entity.GetActiveLineType()` but nothing in this repo calls it); every stroke is a solid `DrawLine`/`Draw` (`EntityRenderDispatcher.cs`, `SplineRenderer.cs:33,46,74`). +- **Transparency** and `IsInvisible` are ignored. + +## 2. Selection of layers + +### 2.1 Library API + +All mutation goes through `ImageConfiguration` (`ACadSharp.Image/ImageConfiguration.cs`): + +| Member | Signature | Lines | +| --- | --- | --- | +| Backing store | `private readonly HashSet _hiddenLayers = new(StringComparer.OrdinalIgnoreCase);` | 106 | +| Read view | `public IReadOnlySet HiddenLayers { get; }` | 217 | +| Hide one | `public void HideLayer(string layerName)` — throws `ArgumentException` on null/whitespace | 354-358 | +| Hide many | `public void HideLayers(IEnumerable layerNames)` | 364-372 | +| Un-hide | `public bool ShowLayer(string layerName)` — returns whether it was removed | 379-383 | +| Reset | `public void ClearHiddenLayers()` | 388-391 | + +Names are compared case-insensitively (`:106`, `:215`). There is no include-list, whitelist, wildcard, regex, or "only these layers" API — the model is exclude-only. + +### 2.2 Where the filter is applied + +The filter runs at **add time**, in `ImageExporter`, not in the render loop: + +- `ShouldIncludeEntity(Entity)` (`ACadSharp.Image/ImageExporter.cs:150-158`) drops `Viewport` entities and anything for which `IsHiddenLayer` is true. +- `IsHiddenLayer(Entity)` (`:160-174`) short-circuits when `HiddenLayers.Count == 0`, reads `entity.Layer?.Name`, returns `false` for a null/empty name, otherwise `HiddenLayers.Contains(layerName)`. +- `Add(Layout)` applies it to `layout.AssociatedBlock.Entities` only (`:112-118`); viewports are added unfiltered (`:120-128`). +- `Add(BlockRecord)` passes it as the `entityFilter` predicate to `ImagePage.Add(BlockRecord, Func?, bool)` (`:146`; `ACadSharp.Image/ImagePage.cs:77-93`). +- `AddModelSpace(CadDocument)` and `AddPaperLayouts(CadDocument)` are thin wrappers over the two `Add` overloads (`:62-76`). + +The render loop itself (`ImagePageRenderer.Render`, `ACadSharp.Image/Rendering/ImagePageRenderer.cs:52-68`) iterates `page.Viewports` and `page.Entities` with no layer check, and `EntityRenderDispatcher.Draw` (`EntityRenderDispatcher.cs:58`) has none either. + +Consequences of add-time filtering: + +1. **`--paper-layouts --hide-layer X` does not hide model-space content.** Paper-space viewport contents come from `viewport.SelectEntities()` inside `DrawViewport` (`ImagePageRenderer.cs:85`), which is never passed through `ShouldIncludeEntity`. Only paper-space entities (title block, annotations) on layer X are removed. +2. **Nested entities bypass the filter.** `Insert` is drawn via `insert.Explode()` (`EntityRenderDispatcher.cs:175-181`, loop at `:177`) and `Dimension` via its block's entities (`:117-141`, loop at `:132`); sub-entities on a hidden layer are still drawn. Only the top-level `Insert`/`Dimension` entity's own layer is tested. +3. **Public `ImagePage` mutators skip the filter.** `ImagePage.Add(BlockRecord, bool)` (`ImagePage.cs:66`), `ImagePage.AddEntity` (`:109`) and `ImagePage.AddViewport` (`:119`) do not know about `HiddenLayers`. +4. **Changing `HiddenLayers` after `Add(...)` has no effect** on pages already built, since `page.Entities` is materialised at add time. + +### 2.3 CLI + +`ACadSharp.Image.Cli`: + +- Option: `--hide-layer `, repeatable, no short alias (`ACadSharp.Image.Cli/Program.cs:195-197`; help text `:305`). Stored as `IReadOnlyList HideLayers` on the options record (`ACadSharp.Image.Cli/CliOptions.cs:16`). +- Applied by `Configure`, which calls `configuration.HideLayer(layer)` for each value (`Program.cs:79-82`). +- No `--show-layer`, `--only-layer`, `--layers` include list, or layer-listing command exists (the full option switch is `Program.cs:162-201`). + +README documents the same surface (`README.md:22,74-76,112-116,141,174-187,281`). + +### 2.4 Test coverage + +- `ACadSharp.Image.Tests/ImageConfigurationTests.cs:8-24` — `HiddenLayersAreManagedThroughMethods`: `HideLayer`, case-insensitive `Contains`, `ShowLayer` returns `true`, `ClearHiddenLayers`. +- `ACadSharp.Image.Tests/ImageExporterTests.cs:217-244` — `HiddenLayersFiltersOutEntitiesOnSpecifiedLayers`: three lines on `Layer1/2/3`, hide `Layer2`, `Add(BlockRecord)`, asserts `page.Entities.Count == 2` (`:243`). +- `ImageExporterTests.cs:246-262` — `HiddenLayersIsCaseInsensitive`: hide `mylayer`, entity on `MyLayer`, asserts `Assert.Empty(page.Entities)` (`:261`). +- `ImageExporterTests.cs:264-290` — `MultipleHiddenLayersCanBeConfigured`: hide `Layer1`,`Layer3`, asserts `Assert.Single(page.Entities)` (`:289`). + +All three exporter tests go through `Add(BlockRecord)` and assert on `page.Entities` before rendering. Not covered: the `Add(Layout)` path, the viewport bypass, nested `Insert`/`Dimension` bypass, the CLI `--hide-layer` parsing, and any pixel-level check that hidden content is absent from the output image. (CodeGraph's blast-radius note flags `IsHiddenLayer` as having "no covering tests"; that is a direct-caller heuristic — the three exporter tests do exercise it indirectly via `Add(BlockRecord)`.) + +## 3. SVG image generation + +### 3.1 Output formats and backend as of HEAD + +- Formats: `ImageExportFormat { Png, Bmp, Jpeg, Gif, Webp }` (`ACadSharp.Image/ImageExportFormat.cs:6-35`). Extension mapping and parsing accept only those five plus the `jpg` alias (`ACadSharp.Image/ImageExportFormatExtensions.cs:24-33,62-78,107-110`). CLI help lists `png, bmp, jpg, jpeg, gif, webp` (`Program.cs:298`); `ResolveFormat` falls back to PNG (`:107-125`). +- Encoding: `ImageExporter.SavePage` switches on the enum and calls `page.Canvas.Save(path, new {Bmp,Jpeg,Gif,Webp,Png}Encoder())` (`ACadSharp.Image/ImageExporter.cs:252-273`). +- Backend: SixLabors.ImageSharp 3.1.12, SixLabors.ImageSharp.Drawing 2.1.7, SixLabors.Fonts 2.1.3 (`Directory.Packages.props:10-12`; referenced at `ACadSharp.Image/ACadSharp.Image.csproj:18-21`). The package describes itself as a "Raster image exporter ... using SixLabors.ImageSharp" (`ACadSharp.Image.csproj:10`). The canvas type is `SixLabors.ImageSharp.Image` in both `ImageRenderContext.Canvas` (`ACadSharp.Image/Rendering/ImageRenderContext.cs:11`) and `RenderedImagePage.Canvas` (`ACadSharp.Image/RenderedImagePage.cs:32`), and every primitive is an ImageSharp.Drawing `Mutate(...)` call (`EntityRenderDispatcher.cs`, `SplineRenderer.cs:33,46,74`, `TextRenderer.cs:39,63`). +- No SkiaSharp or System.Drawing reference exists (`Directory.Packages.props:6-15` is the full package list). + +### 3.2 SVG is absent + +Searches performed: + +``` +grep -rniE "svg" --include="*.cs" --include="*.csproj" --include="*.props" --include="*.md" --include="*.sln" --include="*.json" . +``` + +Only hits: `README.md:4` and `README.md:6` — the `License-MIT-blue.svg` and `ci.yml/badge.svg` badge image URLs. No `Svg` enum member, encoder, writer, test, or CLI value exists. + +History: `git log --all --oneline -S svg -i` returns only `6ae39c2 Update README`, whose `svg` matches are the same two badge URLs. + +### 3.3 What the `svg-support` branch changed vs `main` + +Nothing. + +- `git log --oneline main..HEAD` — empty. +- `git diff main --stat` — empty. +- `git log --oneline HEAD..main` — empty (branch is not behind either). +- `git branch -a` shows a local `svg-support` and this `mubeda/svg-support`, both at `178e4c2`, and only `origin/main` as a remote branch (no pushed SVG branch). +- `git worktree list` shows the sibling worktree `/work/github/ACadSharp.Image` on `svg-support` at the same SHA; `git -C /work/github/ACadSharp.Image status --porcelain` is clean and `git stash list` is empty, so there is no uncommitted SVG work either. + +The branch name is, at HEAD, an intention only. + +### 3.4 Backend capability: ImageSharp cannot emit SVG + +- ImageSharp's format page lists 13 raster codecs (ANI, BMP, CUR, EXR, GIF, ICO, JPEG, PBM, PNG, QOI, TGA, TIFF, WebP) and states "ImageSharp works with raster images. Vector artwork, document formats, and application-native design files are outside the built-in codec set." — https://docs.sixlabors.com/articles/imagesharp/imageformats.html +- The pinned 3.1.12 assembly's encoder types (`T:SixLabors.ImageSharp.Formats.*Encoder` in `~/.nuget/packages/sixlabors.imagesharp/3.1.12/lib/net6.0/SixLabors.ImageSharp.xml`) are Bmp, Gif, Jpeg, Pbm, Png, Qoi, Tga, Tiff, Webp — no SVG. +- The only `svg` in ImageSharp.Drawing 2.1.7 is `Path.TryParseSvgPath(string, out IPath)` (`~/.nuget/packages/sixlabors.imagesharp.drawing/2.1.7/lib/net6.0/SixLabors.ImageSharp.Drawing.xml`, member `M:SixLabors.ImageSharp.Drawing.Path.TryParseSvgPath`), which parses SVG path *input* into geometry; it does not write SVG. + +### 3.5 What adding SVG would take (factual options, no recommendation) + +**Option A — delegate to ACadSharp's own SVG writer.** The already-referenced ACadSharp 3.4.24 ships `ACadSharp.IO.SvgWriter` ("Writer to support the creation of SVG from `BlockRecord` and `Layout`") and `ACadSharp.IO.SvgConfiguration` (pinned `ACadSharp.xml`, members `T:ACadSharp.IO.SvgWriter`, `T:ACadSharp.IO.SvgConfiguration`). + +- Constructors documented in the XML: `SvgWriter(Stream)`, `SvgWriter(string filename)`, `SvgWriter(string filename, CadDocument document)`. The source at the tag also has `SvgWriter(Stream, CadDocument)` (https://github.com/DomCR/ACadSharp/blob/v3.4.24/src/ACadSharp/IO/SvgWriter.cs). +- Methods documented in the XML: `Write()` ("will draw all the content in the model space"; requires a non-null `CadDocument`), `Write(Layout)`, `Dispose()`. The tagged source additionally has `Write(BlockRecord)` (https://github.com/DomCR/ACadSharp/blob/v3.4.24/src/ACadSharp/IO/SvgWriter.cs). +- `SvgConfiguration` documented in the XML: `LineWeightRatio` (default 100), `DefaultLineWeight` (mm), `PointRadius`, `GetLineWeightValue(LineWeightType, UnitsType)`. The tagged source additionally has an undocumented `ArcPoints` property (https://github.com/DomCR/ACadSharp/blob/v3.4.24/src/ACadSharp/IO/SVG/SvgConfiguration.cs). +- The drawing code `SvgXmlWriter` derives `stroke` from `entity.GetActiveColor()` and `stroke-width` from `entity.GetActiveLineWeightType()` via `GetLineWeightValue`, so ByLayer inheritance matches this repo's raster path. Per the fetched source at v3.4.24 (https://github.com/DomCR/ACadSharp/blob/v3.4.24/src/ACadSharp/IO/SVG/SvgXmlWriter.cs) it contains no references to `IsOn`, `Frozen`, `PlotFlag` or `IsInvisible`, its `writeSpline` is commented out, and it has writers for Arc, Circle, Dimension, Ellipse, Hatch, Insert, Line, Point, Polyline, Solid, Text. +- Implications for this repo: `SvgWriter` has no hidden-layer concept, so `HiddenLayers` would have to be applied by pre-building a filtered `BlockRecord`/`Layout` before calling `Write`; raster-only settings (`Width`, `Height`, padding, `BackgroundColor`, `OutputQuality`, `Dpi`, `FontFamilyName`, `ArcPrecision`) do not map onto `SvgConfiguration`; splines would be dropped; and `RenderedImagePage.Canvas` (`Image`) has no SVG counterpart, so `Render()`/`Save()` would need a separate code path. + +**Option B — introduce a vector-capable drawing surface.** Because `Image` is baked into `ImageRenderContext.Canvas` (`ImageRenderContext.cs:11`), `RenderedImagePage.Canvas` (`RenderedImagePage.cs:32`) and every `Canvas.Mutate(...)` call in `EntityRenderDispatcher`, `SplineRenderer` and `TextRenderer`, an SVG target would require abstracting those primitives behind an interface and adding an SVG implementation. One such implementation is SkiaSharp's `SKSvgCanvas` — "A specialized SKCanvas which generates SVG commands from its draw calls", with `Create(SKRect, Stream)` and `Create(SKRect, SKWStream)` (https://learn.microsoft.com/en-us/dotnet/api/skiasharp.sksvgcanvas) — which would add a native-dependency package the project currently does not have (`Directory.Packages.props:6-15`). Either route also needs a new `ImageExportFormat.Svg` member, extension/parse entries (`ImageExportFormatExtensions.cs:24-33,62-78`), a `SavePage` branch (`ImageExporter.cs:252-273`), and the CLI format list (`Program.cs:298`). + +## 4. Gaps / open questions + +1. **Layer state is ignored.** Off, frozen, non-plottable, and viewport-frozen layers all render; `Entity.IsInvisible` renders. A "respect drawing visibility" mode would need reads of `Layer.IsOn`, `Layer.Flags`, `Layer.PlotFlag`, `Viewport.FrozenLayers` (all available in ACadSharp 3.4.24), and the decision of whether it should be default-on or opt-in. +2. **Hidden-layer filter does not reach viewport contents or nested block/dimension entities** (`ImagePageRenderer.cs:85`, `EntityRenderDispatcher.cs:132,177`). Moving the check into `EntityRenderDispatcher.Draw` (or `DrawViewport`) would close this, at the cost of also changing what `ImagePage.Entities` contains. +3. **Exclude-only selection.** No include-list; a caller wanting "only layer X" must enumerate every other layer from `CadDocument.Layers` themselves. +4. **Linetype is never rendered** (dash patterns from `Layer.LineType`/`Entity.LineType` are dropped); ACI 7 is hard-coded to black (`ColorExtensions.cs:15-26`). +5. **SVG does not exist and the branch has no work on it.** Whether the intended route is ACadSharp's `SvgWriter` (already a dependency, no hidden-layer support, no splines) or a new drawing-surface abstraction is undecided; both are described in [3.5](#35-what-adding-svg-would-take-factual-options-no-recommendation). +6. **Test gaps.** No tests for `Add(Layout)` + `HideLayer`, viewport/nested bypass, CLI `--hide-layer` parsing, or output-pixel assertions for hidden layers. +7. **Not verified here.** The `SvgXmlWriter` "no layer checks" statement rests on a fetch of the tagged source summarised for those identifiers, not on a local compile; the local `ACadSharp.xml` confirms the public surface but not method bodies. No sample DXF/DWG in `Samples/` was rendered to confirm the off/frozen-layer behaviour empirically — the conclusion is from the absence of any code reading those properties. diff --git a/docs/research/remaining-limitations-design-options.md b/docs/research/remaining-limitations-design-options.md new file mode 100644 index 0000000..6862cd7 --- /dev/null +++ b/docs/research/remaining-limitations-design-options.md @@ -0,0 +1,422 @@ +# Design options for the remaining rendering limitations + +> Consultation note produced by OpenAI Codex on 2026-09-04 at the maintainers' request, lightly edited (repository-relative links). It follows up on [remaining-rendering-limitations.md](remaining-rendering-limitations.md) and proposes how each remaining limitation could be implemented against ACadSharp 3.7.1. Line references point at the branch state on that date and may drift. + +## Summary + +- Keep `IDrawingSurface` unchanged for MLINE cuts, arrow blocks, multi-line attributes, and tilted hatches; existing primitives are sufficient. +- Introduce one internal, cumulative placement module that maps WCS points, OCS points, and vectors without mutating ACadSharp entities. +- Implement tilted hatches from the original hatch: OCS → WCS → cumulative insert placement. +- Positional original/clone pairing is the only reliable identity in ACadSharp 3.7.1; retain it short-term with type guards. +- Long-term, replace `Insert.Explode()` with original-entity traversal plus type-specific primitive extraction. +- Render custom leader arrows as placed block contents; ACadSharp already follows this model for dimension arrow blocks. +- Render inverted opaque wipeouts as an even-odd full-frame-minus-boundary path. +- Transparent wipeouts require an explicit erase/composite primitive and incompatible changes to SVG’s one-group-per-layer structure. +- Route multi-line attributes through their embedded `MText`, retaining ATTRIB style, visibility, and metadata. +- Method: primary-source research plus a codebase-design review focused on seam depth, locality, and observable rendering behavior. + +## 1. MLEDIT cut segments + +### Semantics + +For every MLINE vertex and style element, ACadSharp exposes DXF group 41 as `MLine.Vertex.Segment.Parameters`; group 42 is `AreaFillParameters`. The types are present but carry no interpretation helper in [MLine.Vertex.cs](https://github.com/DomCR/ACadSharp/blob/v3.7.1/src/ACadSharp/Entities/MLine.Vertex.cs#L9-L63). + +The [Autodesk MLINE reference](https://help.autodesk.com/cloudhelp/2025/ENU/AutoCAD-DXF/files/GUID-590E8AE3-C6D9-4641-8485-D7B3693E432C.htm) defines: + +- `p[0]`: distance from the reference vertex along `Miter` to the element-path intersection. +- `p[1]`: distance along the element path from that intersection to the element’s actual start. +- `p[2]`: distance from the actual start to the first break. +- Further values “continue to list the start and stop points” of the element. + +The last phrase is genuinely ambiguous. The literal reading makes `p[2..]` monotonically increasing positions from the actual element start, alternating cut-start/cut-stop. The existing synthetic values `[offset, 0, 4, 6]` naturally mean a cut from distance 4 to 6. + +ezdxf documents the same array as `[miter-offset, line-start-offset, dash, gap, dash, …]`, suggesting relative alternating lengths, but also states that it does not create line-break features; its current renderer uses only `offset[0]` and draws continuous elements. See [ezdxf’s model comments](https://github.com/mozman/ezdxf/blob/master/src/ezdxf/entities/mline.py#L140-L185) and [renderer](https://github.com/mozman/ezdxf/blob/master/src/ezdxf/render/mline.py#L177-L205). LibreDWG only preserves the raw `segparms`/`areafillparms` arrays and therefore does not resolve the ambiguity ([schema](https://github.com/LibreDWG/libredwg/blob/master/src/dwg.spec#L2967-L3004)). I found no public ODA passage that settles it. + +### Options + +1. **Absolute cut positions, matching the literal DXF prose.** + + For segment `i → i+1`, element `j`: + + - `A = vertex[i].Position + p[0] × vertex[i].Miter` + - `D = normalize(vertex[i].Direction)` + - `S = A + p[1] × D` + - `E` is the next vertex’s element-path intersection. + - Interpret `(p[2], p[3]), (p[4], p[5]), …` as cut-start/cut-stop distances from `S`. + - Clamp and sort valid pairs to the signed span `S → E`, merge overlaps, and emit the complementary visible intervals. + - Construct endpoints in the original block coordinate system, then apply `placement` to each endpoint. This correctly handles non-uniform and mirrored inserts without trying to scale the stored distances separately. + - Coalesce adjacent visible intervals across vertices where possible so linetype phase is not unnecessarily restarted. + + Both backends receive `DrawLine`/`DrawPolyline`; SVG emits `` or ``, raster uses ImageSharp strokes. Fill remains before strokes. Transparency, per-layer grouping, viewport compositing, and entity draw order are unchanged. + +2. **Relative dash/gap lengths, matching ezdxf’s comments.** + + Begin at `S`, alternately consume visible and hidden lengths from `p[2..]`. The primitive output is identical to option 1, but drawings whose parameters are absolute positions render differently as soon as there is more than one cut. + +3. **Extend the interval engine to group 42 fill cuts.** + + Group 42 uses analogous parameterization. A complete MLEDIT implementation would split the filled band into per-segment polygons and subtract the group-42 gaps before stroking. This is materially larger than the stated group-41 limitation; inferring fill gaps from stroke gaps is incorrect. + +A mask-based solution is inappropriate: painting a background-colored cut after the MLINE would also hide unrelated entities drawn earlier. + +### Recommendation + +Use option 1, but make an AutoCAD-authored fixture the acceptance oracle before freezing the interpretation. Effort **M** including malformed-data handling; **S** once the absolute interpretation is confirmed. + +Also extend [`HasFiniteGeometry`](../../ACadSharp.Image/Rendering/EntityRenderDispatcher.cs:385): it currently validates only `Parameters[0]` and does not validate `Direction`; the new path consumes every parameter. + +Tests: + +- Replace `MLineCutParametersAreIgnoredWithAWarning` with `[0.5, 0, 4, 6]` expecting visible runs `0–4` and `6–end`. +- Multiple and overlapping cuts, odd parameter count, descending/non-finite values, closed final-to-first segment. +- A rotated, mirrored, non-uniformly scaled block MLINE proving endpoints go through `placement`. +- Dashed element around a cut, documenting whether dash phase restarts. +- Separate group-42 warning/test until fill cuts are implemented. + +Existing no-cut MLINEs should remain primitive-for-primitive identical, so PNG/SVG baseline risk is **low**. New feature goldens will intentionally contain multiple SVG strokes. + +No upstream change is required to read the cuts. The useful upstream changes are an authoritative `MLine` interval/virtual-entity helper and correct deep cloning of both `MLine.Vertices` and every vertex’s `Segments`/parameter lists. + +## 2. Custom arrowhead blocks + +### Semantics + +AutoCAD treats a custom arrowhead as a block inserted at the normal arrow location. Its X/Y scale is the arrow size multiplied by the overall dimension scale; its block insertion point affects placement. For a horizontal dimension, Autodesk documents zero rotation at the right end and 180° at the left end. Annotative blocks are not valid arrowheads. See [About Customizing Arrowheads](https://help.autodesk.com/cloudhelp/2019/ENU/AutoCAD-Core/files/GUID-5D1F8D41-86EC-481F-ACA0-B169F0B91D00.htm) and [DIMLDRBLK](https://help.autodesk.com/cloudhelp/2019/ENU/AutoCAD-LT/files/GUID-B4374832-C2B4-4555-900C-693625AC58DE.htm). + +ACadSharp exposes the resolved block as `DimensionStyle.LeaderArrow`, DXF handle 341, and exposes `ArrowSize`/`ScaleFactor` in [DimensionStyle.cs](https://github.com/DomCR/ACadSharp/blob/v3.7.1/src/ACadSharp/Tables/DimensionStyle.cs#L138-L176). `Leader.Style` and its WCS `Vertices` are in [Leader.cs](https://github.com/DomCR/ACadSharp/blob/v3.7.1/src/ACadSharp/Entities/Leader.cs#L102-L158). + +The transform convention is corroborated by ACadSharp’s own dimension implementation: `dimensionArrow` maps the block base point to the tip, uses `ArrowSize × ScaleFactor`, and rotates local +X to the supplied arrow direction ([Dimension.cs](https://github.com/DomCR/ACadSharp/blob/v3.7.1/src/ACadSharp/Entities/Dimension.cs#L702-L730)). + +Consequently, dimensions do not have the same dispatcher-level gap when their anonymous picture block exists or is generated: the picture contains a correctly scaled/rotated `Insert`, and [`DrawDimension`](../../ACadSharp.Image/Rendering/EntityRenderDispatcher.cs:225) recursively renders it. `DIMBLK`/`DIMBLK1`/`DIMBLK2` need separate work only if ACadSharp fails to generate the anonymous dimension block or ignores a particular override. + +For a straight leader, the outward arrow direction is `tip - vertices[1]`. For a spline leader, AutoCAD visually aligns the arrow to the endpoint tangent; using the first chord is only an approximation. With the current Catmull–Rom conversion, derive the tangent from the first Bézier control pair. + +### Options + +1. **Render the arrow `BlockRecord` through a placement-aware block helper.** + + Build: + + - `tip = leader.Vertices[0]` + - `direction = normalized outward endpoint tangent` + - `scale = Style.ArrowSize × effective Style.ScaleFactor` + - `arrowPlacement(local) = tip + R(direction) × scale × (local - block.BasePoint)` + + Compose that with any outer leader `placement`, rather than decomposing it back into an ACadSharp `Insert`. Traverse the arrow block’s stored entities in order, with layer-0 and ByBlock properties inherited from the leader. Draw the leader line first and arrow contents second, so filled arrow geometry covers the line endpoint. + + Both backends use existing primitives. SVG arrow children retain vector paths but enter their effective per-layer `` groups; raster draws them directly. Transparent backgrounds have no special meaning—ordinary entity opacity applies. + +2. **Construct a synthetic ACadSharp `Insert` and call `DrawBlockContents`.** + + This is smaller initially and matches `Dimension.dimensionArrow`, but imports every existing `Explode()` problem: destructive MLINE cloning, transformed text defects, hatch normalization, nested `BlockRecord.Clone()` reordering, and possible attribute creation from the `Insert(BlockRecord)` constructor. + +3. **Compile blocks into reusable drawing commands/SVG symbols.** + + Cache a backend-neutral primitive display list. SVG can emit a ``/`` or replay the commands; raster replays them. This helps drawings with thousands of identical arrows but complicates ByBlock styling, per-layer grouping, entity metadata, nested inserts, and recursion detection. + +### Recommendation + +Use option 1. It is a useful deep module rather than arrow-specific recursion, and it becomes groundwork for option 5(c). Effort **M**. + +Add a recursion guard keyed by active `BlockRecord` references plus a configurable depth cap. A block containing a leader that points back to the same arrow block must warn and fall back to the default arrow rather than recurse forever. + +Tests: + +- Custom block containing a line and a filled circle; assert that no fallback triangle or NotImplemented notification remains. +- Non-zero block base point, four leader directions, and `ArrowSize × ScaleFactor`. +- Layer 0/ByBlock inheritance and a nonzero child layer. +- Custom arrow on a leader inside rotated, mirrored, and non-uniformly scaled inserts. +- Spline leader endpoint tangent. +- Recursive arrow block and malformed/empty block. +- PNG and SVG synthetic goldens. + +Default leaders remain unchanged. Existing drawings with custom arrows intentionally change, so overall baseline risk is **low**, but their new goldens are substantial. + +No upstream change is necessary. Helpful upstream additions would be a public arrow-block placement helper equivalent to protected `Dimension.dimensionArrow`, and a `Leader.GetActiveDimensionStyle()` equivalent if leader DSTYLE overrides are not already folded into `Leader.Style`. + +## 3. Inverted wipeout clips + +### Semantics + +WIPEOUT uses the raster-image geometry model: WCS insertion point, WCS single-pixel U/V vectors, pixel-space boundary vertices, image size, clipping state, and rectangular or polygonal clip type. Autodesk documents the default pixel boundary as `(-0.5,-0.5)` to `(size.x-0.5,size.y-0.5)` in the [WIPEOUT DXF reference](https://help.autodesk.com/cloudhelp/2020/ENU/AutoCAD-DXF/files/GUID-2229F9C4-3C80-4C67-9EDA-45ED684808DC.htm). + +The published WIPEOUT table does not document group 290, so the serialization reference itself is incomplete here. AutoCAD’s [IMAGECLIP command documentation](https://help.autodesk.com/cloudhelp/2020/ENU/AutoCAD-Core/files/GUID-9D652E1A-29F8-49BC-ABCC-37B9F1C7A1D0.htm) resolves the display semantics: normal clipping hides the outside; inverted clipping hides the inside. Turning clipping off displays the full image. + +ACadSharp maps group 290 to `CadWipeoutBase.ClipMode`, with `Outside` and `Inside`, and exposes the remaining fields in [CadWipeoutBase.cs](https://github.com/DomCR/ACadSharp/blob/v3.7.1/src/ACadSharp/Entities/CadWipeoutBase.cs#L45-L53). `Inside` means the inside is clipped away, so the wipeout paints the full image frame minus the active boundary. + +Two related issues matter: + +- Clip mode must be ignored when `ClippingState == false`; the current early return in [`DrawWipeout`](../../ACadSharp.Image/Rendering/EntityRenderDispatcher.cs:674) skips an inverted wipeout even when clipping is off. +- ACadSharp’s `ApplyTransform` applies point transforms to `UVector` and `VVector`, so translations contaminate both vectors ([CadWipeoutBase.cs](https://github.com/DomCR/ACadSharp/blob/v3.7.1/src/ACadSharp/Entities/CadWipeoutBase.cs#L215-L220)). Wipeouts in block references should therefore be mapped from the original entity and then passed through outer placement. + +### Options + +1. **Opaque-background even-odd fill using the existing interface.** + + When clipping is active: + + - Build the complete four-corner image footprint. + - Build the active rectangular/polygonal boundary. + - For normal mode, `FillPolygon(boundary)`. + - For inverted mode, `FillPath([fullFrame, boundary])`. + + When clipping is off, fill the full frame regardless of `ClipMode`. + + [`FillPath`](../../ACadSharp.Image/Rendering/IDrawingSurface.cs:66) already gives the needed even-odd rule: SVG emits one ``; raster uses an ImageSharp `ComplexPolygon` with `IntersectionRule.EvenOdd`. Autodesk requires clipping vertices to remain within the image, so the inner-ring assumption is valid for conforming files. + + Use the current opaque background color. On transparent/translucent backgrounds, retain the explicit warning and skip: a color fill cannot mean “reveal what was beneath the CAD drawing.” + +2. **Add `IDrawingSurface.ErasePath(rings)` with even-odd semantics.** + + This models a wipeout directly as removal of earlier paint: + + - Raster: rasterize the region into an antialiased mask and replace those destination pixels with the surface’s base value—configured background at page level, transparent in a viewport child, transparent for a transparent page. + - SVG: leave the background as an immutable bottom sibling; wrap all earlier paint in the current container in a ``, use a user-space mask to punch out the region, then begin a new paint segment for later entities. + + This handles transparent output and nested viewports correctly. It also supports both normal and inverted wipeouts, future IMAGE/XCLIP work, and MTEXT background masks. + + The price is structural: after every erase, later entities require new layer groups. A single `` per logical layer and strict global painter order cannot both be preserved. + +3. **Retain every primitive in a display list and resolve compositing at finalization.** + + This permits exact chronological layers, masks, and clips, but converts both adapters into retained-mode renderers. It is an architectural **L** change with broad memory and golden consequences. + +`BeginClip`/`EndClip` is not the right interface: a clip constrains future paint; a wipeout removes prior paint. + +### Recommendation + +For the stated limitation, use option 1: effort **S**, low risk, no `IDrawingSurface` change. Treat transparent wipeouts as a separate explicit capability; if required, choose option 2 rather than a misleading `WipeoutColor`. + +Update [`WipeoutWorldBoundary`](../../ACadSharp.Image/Rendering/EntityRenderDispatcher.cs:707) and [`EntityBounds.TryGet`](../../ACadSharp.Image/Rendering/EntityBounds.cs:19) together. An inverted wipeout’s bounds are its full image footprint, not an empty set or the inner polygon; otherwise page framing and viewport culling can discard it before rendering. + +Tests: + +- Inverted polygon produces two even-odd rings. +- Inverted rectangular pair expands correctly. +- `ClippingState=false` fills the full frame even with `ClipMode.Inside`. +- A line before the wipeout is hidden; a line after it remains visible. +- Rotated/skewed U/V vectors and an outer block placement. +- Active viewport compositing. +- Existing transparent-background warning. +- If `ErasePath` is later added: transparent page, nested viewport, multiple successive wipeouts, and cross-layer chronological behavior. + +The current SVG design gives layer grouping precedence over painter order. Under option 1, a wipeout can still cover a later entity appended to an older layer group. Exact AutoCAD behavior requires option 2 or 3 and repeated chronological layer groups. That should be documented, not hidden in the wipeout helper. + +Upstream should fix `CadWipeoutBase.ApplyTransform` to transform U/V as vectors, transform or retain the pixel boundary consistently, expose the correct full-frame world polygon, and correct `GetBoundingBox()`. + +## 4. Multi-line attributes + +### Semantics + +The [ATTRIB DXF reference](https://help.autodesk.com/cloudhelp/2019/ENU/AutoCAD-DXF/files/GUID-7DD8B495-C3F8-48CD-A766-14F9D7D0DD9B.htm) includes an `AcDbXrecord`/`AcDbMText` representation. Its MText flag distinguishes multiline attributes and constant multiline definitions; the embedded section contains the text chunks, text style, WCS X-axis, width, height, and rotation. + +ObjectARX describes the embedded `AcDbMText` as the actual representation used by a multiline attribute ([`getMTextAttribute`](https://help.autodesk.com/cloudhelp/2027/ENU/OARX-RefGuide/files/OARX-RefGuide-__MEMBERTYPE_Methods_AcDbAttribute.html)). Consequently, the single-line `AttributeBase.Value` is not authoritative for layout. + +ACadSharp models this as `AttributeBase.AttributeType` and `AttributeBase.MText` in [AttributeBase.cs](https://github.com/DomCR/ACadSharp/blob/v3.7.1/src/ACadSharp/Entities/AttributeBase.cs#L9-L30). `AttributeEntity` still derives through `TextEntity`, which explains why the current switch reaches the single-line TEXT path. + +A top-level ATTRIB’s TEXT/MTEXT coordinates already include its own insert’s placement. It must not receive `insert.GetTransform()` again. For an ATTRIB owned by an insert nested inside another block, however, the outer cumulative placement still applies. That distinction should be represented explicitly rather than by a nullable transform whose meaning changes by call site. + +### Options + +1. **Route the embedded `MText` while retaining ATTRIB ownership.** + + Before the generic `TextEntity` case, detect `AttributeBase` with `MultiLine` or `ConstantMultiLine`: + + - Resolve color, layer, transparency, visibility, `EntityRenderInfo`, and parent handle from the ATTRIB/ATTDEF. + - Pass `attribute.MText` as layout geometry to `TextRenderer.Draw(MText, placement)`. + - For a top-level `Insert.Attributes` entry, placement is identity. + - For a nested insert’s attribute, placement is the transform of outer block references only. + - For a constant multiline ATTDEF in block-local coordinates, use the full block placement. + + SVG automatically emits ``/``; raster uses the existing multiline/wrapping glyph path. Draw order remains the current order: attributes are drawn after block contents and in `Insert.Attributes` order. Transparent backgrounds need no special behavior. + +2. **Add a `TextRenderer.DrawAttribute` adapter.** + + It accepts the owner attribute, embedded MText, and placement, hiding the ownership/layout split. This slightly deepens `TextRenderer` and prevents the dispatcher from knowing which MText fields are authoritative. + +3. **Synthesize MText when `MText == null`.** + + Copy `Value`, point, height, rotation, and style into a temporary MText. This loses the true rectangle width, attachment point, direction, and embedded formatting. It is acceptable only as a warning-producing fallback. + +### Recommendation + +Use option 2 internally, implemented with option 1’s semantics. Effort **S**, low risk. + +The observable entity remains ATTRIB—important for SVG `data-type`, `data-handle`, parent insert metadata, and layer grouping—while the embedded MText supplies only layout geometry. + +Tests: + +- `AttributeType.MultiLine`, `Value="WRONG"`, embedded `MText.Value="Line1\\PLine2"`; assert two lines and prove `Value` is ignored. +- Rectangle width/wrapping, attachment point, line spacing, rotation, and text style. +- Top-level inserted attribute proving no double placement. +- Nested insert proving exactly the outer placement is applied. +- Mirrored outer insert and a constant multiline ATTDEF. +- Hidden/ATTMODE filtering remains unchanged. +- Missing embedded MText warns and uses the documented fallback. +- SVG `` and raster golden. + +Existing single-line attributes remain byte-identical. Only drawings already containing multiline attributes change. + +For complete upstream safety, ACadSharp needs to deep-clone `AttributeBase.MText`, transform it with the owning attribute, and correct `MText.ApplyTransform`. `AttributeDefinition` also inherits the broken `TextEntity.ApplyTransform`; its embedded MText needs explicit treatment. + +## 5. Tilted hatches in blocks and the explode pairing + +### Semantics + +HATCH elevation and normal define an OCS plane, and its boundary vertices are OCS data. Autodesk states this explicitly in the [HATCH entity reference](https://help.autodesk.com/cloudhelp/2023/ENU/AutoCAD-DXF/files/GUID-C6C71CED-CE0F-4184-82A5-07AD6241F15B.htm) and for every boundary edge type in [Boundary Path Data](https://help.autodesk.com/cloudhelp/2024/ENU/AutoCAD-DXF/files/GUID-DC5215D6-E73F-4DFF-8BE9-01CA9610FAEE.htm). The OCS-to-WCS frame follows AutoCAD’s arbitrary-axis algorithm ([OCS overview](https://help.autodesk.com/cloudhelp/2024/ENU/AutoCAD-DXF/files/GUID-D99F1509-E4E4-47A3-8691-92EA07DC88F5.htm)). + +ACadSharp exposes `Hatch.Elevation`, `Normal`, `Paths`, and `Pattern` in [Hatch.cs](https://github.com/DomCR/ACadSharp/blob/v3.7.1/src/ACadSharp/Entities/Hatch.cs#L18-L68). Its `ApplyTransform` transforms raw boundary edges directly, separately transforms the normal, never incorporates the original OCS elevation into each boundary point, and reduces the transformed pattern to one angle and scale ([Hatch.cs](https://github.com/DomCR/ACadSharp/blob/v3.7.1/src/ACadSharp/Entities/Hatch.cs#L145-L170), [BoundaryPath.cs](https://github.com/DomCR/ACadSharp/blob/v3.7.1/src/ACadSharp/Entities/Hatch.BoundaryPath.cs#L90-L109)). That representation cannot preserve a general affine transform of a tilted pattern. + +Therefore [`NormalizeExplodedClone`](../../ACadSharp.Image/Rendering/EntityRenderDispatcher.cs:917) is correct only for a world-plane hatch whose insert changes the normal to `-Z`, notably a mirror. For an originally tilted hatch, resetting the clone to `+Z` hides the missing original OCS transformation. + +`Insert.Explode()` is structurally one-to-one and ordered in 3.7.1 ([Insert.cs](https://github.com/DomCR/ACadSharp/blob/v3.7.1/src/ACadSharp/Entities/Insert.cs#L302-L340)). Clones cannot carry identity: `CadObject.Clone()` clears the handle, document, and owner. Thus ordinal position is the only identity available after `Explode()`; geometry/type matching is unsafe because duplicates are legal and Circle becomes Ellipse. + +One correction to the stated premise: the current working tree’s [`DrawBlockContents`](../../ACadSharp.Image/Rendering/EntityRenderDispatcher.cs:744) uses the original only for exact-type `TextEntity`/`MText`; it gives `placement` to a healed MLine, but `Solid` and `Leader` still render from exploded clones. Designs that assume all five types already use original geometry would miss current defects in non-world SOLID and scaled leader arrowheads. + +### Options (a) original+transform, (b) clone normalisation, (c) Explode()-free composition; ApplyTransform trust table + +#### (a) Original hatch + OCS + insert transform + +Pair the hatch clone to its original by ordinal, but ignore the clone’s geometry: + +1. Generate original boundary points or original `ExplodePattern()` segments in hatch OCS. +2. Map each endpoint with `OcsTransform.For(original.Normal)` and `original.Elevation`. +3. Apply the cumulative insert `placement`. +4. Project through `ImageRenderContext`. +5. Emit `FillPath` for solid hatches or `DrawLine` for pattern segments. + +Pattern expansion occurs before placement, so non-uniform scaling, mirroring, and tilted projection are represented by transformed endpoints rather than forced back into one pattern angle/scale. Both backends receive the same primitives; SVG remains vector. Entity/layer metadata and draw order come from the exploded clone/source pair exactly as today. + +This is the smallest correct change. It still relies on the 3.7.1 ordinal guarantee. + +#### (b) Repair the clone in `NormalizeExplodedClone` + +A safe narrow normalization is possible only when the **original** hatch normal was already `+Z`: after a mirror, treat its transformed boundary coordinates as world data and reset the clone normal. + +A genuinely tilted hatch cannot be repaired from the clone alone. The information required to distinguish “raw original OCS, then insert-transformed” from “fully transformed WCS” has already been conflated. Rebuilding its boundary and pattern from the original would merely reimplement option (a) while adding mutable transient state. + +Recommendation within this option: make normalization conditional on the original normal and warn/fall back for any source/clone mismatch. Do not retain the current unconditional “any non-world normal becomes +Z” rule. + +#### (c) `Explode()`-free composition + +Traverse `insert.Block.Entities` directly in stored order and carry a cumulative placement: + +`worldPoint = outerPlacement(innerInsert.GetTransform()(sourcePoint))` + +The internal placement module should expose only a few operations: + +- map WCS point; +- map vector by transforming `origin + vector` and subtracting transformed origin; +- map OCS point using original normal/elevation, then placement; +- compose an inner insert. + +That interface hides matrix order and all point-versus-vector traps. + +Two implementation variants are viable: + +- **All-source rendering:** every dispatcher helper extracts source geometry and applies placement before producing surface primitives. +- **Hybrid transform-one:** clone and call `ApplyTransform` only for types proven safe for the renderer; special-case all others. + +To retain Circle→Ellipse under non-uniform scale without `Explode()`: + +- Map the circle center and its two orthogonal radius axes through OCS and placement. +- Form the projected 2×2 axis matrix. +- Use its singular values and left singular vectors as ellipse radii and rotation. +- Emit `DrawEllipse` for a full circle. SVG keeps a native ``; raster tessellates because `SupportsCurves` is false. +- Tessellate partial arcs initially, or derive the SVD-induced phase and reflected sweep separately. + +Do not create an `Ellipse` and call ACadSharp’s `Ellipse.ApplyTransform`; that method itself is not reliable. + +An Explode-free traversal also removes: + +- positional pairing; +- the MLINE snapshot/heal mutation; +- `Insert.Clone()`’s deep block clone; +- nested `BlockRecord.Clone()` reordering through `GetSortedEntities()` ([BlockRecord.cs](https://github.com/DomCR/ACadSharp/blob/v3.7.1/src/ACadSharp/Tables/BlockRecord.cs#L363-L385)); +- loss of source handles/documents/owners. + +It may nevertheless change SVG output: using original entities would expose their handles where exploded clones currently omit `data-handle`. Preserve the present metadata contract deliberately, or introduce `data-source-handle` as a separately reviewed change. + +#### ApplyTransform trust table + +Here, “safe” means safe for a copied entity under an arbitrary nested-insert affine transform and for the primitives this renderer emits—not merely that the method changes some fields. + +| Entity | Trust | Assessment | +|---|---:|---| +| [`Line`](https://github.com/DomCR/ACadSharp/blob/v3.7.1/src/ACadSharp/Entities/Line.cs#L71-L76) | Yes | Maps both WCS endpoints and normal. | +| [`Arc`](https://github.com/DomCR/ACadSharp/blob/v3.7.1/src/ACadSharp/Entities/Arc.cs#L168-L201) | Similarity only | Retains `Arc`; non-uniform scale requires an elliptical arc. | +| [`Circle`](https://github.com/DomCR/ACadSharp/blob/v3.7.1/src/ACadSharp/Entities/Circle.cs#L68-L80) | Similarity only | Retains one radius. `Insert.Explode()` avoids this with Circle→Ellipse. | +| [`Ellipse`](https://github.com/DomCR/ACadSharp/blob/v3.7.1/src/ACadSharp/Entities/Ellipse.cs#L122-L146) | No | Transforms a perpendicular direction with the point transform and does not remap partial-ellipse parameters/reflection. | +| [`LwPolyline`](https://github.com/DomCR/ACadSharp/blob/v3.7.1/src/ACadSharp/Entities/LwPolyLine.cs#L109-L124) | No, generally | Re-expresses vertices but leaves bulges, widths, thickness, and elevation semantics incomplete; non-uniform scale turns circular bulges into ellipses. | +| [`Polyline2D`](https://github.com/DomCR/ACadSharp/blob/v3.7.1/src/ACadSharp/Entities/PolyLine.cs#L121-L144) | No, generally | Same base transform problem as above; straight centerlines under planar similarity transforms are usable. | +| [`Polyline3D`](https://github.com/DomCR/ACadSharp/blob/v3.7.1/src/ACadSharp/Entities/PolyLine.cs#L121-L144) | Conditional | Inherits the same OCS re-expression despite 3D vertex semantics. Common +Z insert transforms work; direct mapping of sampled WCS vertices is safer. | +| [`Spline`](https://github.com/DomCR/ACadSharp/blob/v3.7.1/src/ACadSharp/Entities/Spline.cs#L190-L205) | Geometry-only | Control/fit points are affine-mapped, so this renderer’s locus is usable. Start/end tangents are transformed as points and are not trustworthy. | +| [`Hatch`](https://github.com/DomCR/ACadSharp/blob/v3.7.1/src/ACadSharp/Entities/Hatch.cs#L145-L170) | No | Raw OCS paths are transformed without the original OCS/elevation; pattern state cannot represent general affine scaling. | +| [`Solid`](https://github.com/DomCR/ACadSharp/blob/v3.7.1/src/ACadSharp/Entities/Solid.cs#L93-L99) | World-plane only | Corners are raw OCS values but are transformed as WCS; normal is not updated. | +| [`Face3D`](https://github.com/DomCR/ACadSharp/blob/v3.7.1/src/ACadSharp/Entities/Face3D.cs#L63-L69) | Yes | All four WCS corners are mapped. | +| [`Point`](https://github.com/DomCR/ACadSharp/blob/v3.7.1/src/ACadSharp/Entities/Point.cs#L61-L65) | Dot only | Location/normal are sufficient for the current dot primitive; point-display rotation is not transformed. | +| [`Dimension`](https://github.com/DomCR/ACadSharp/blob/v3.7.1/src/ACadSharp/Entities/Dimension.cs#L242-L264) | No | Maps only the main definition point and sometimes text midpoint; subtype points and anonymous picture geometry are not generally transformed. | +| [`Insert`](https://github.com/DomCR/ACadSharp/blob/v3.7.1/src/ACadSharp/Entities/Insert.cs#L241-L291) | Conditional | Attempts to decompose the result into normal/rotation/scales. Nested non-uniform scales plus rotations can introduce shear, which `Insert` cannot represent; attributes inherit text defects. | +| [`MLine`](https://github.com/DomCR/ACadSharp/blob/v3.7.1/src/ACadSharp/Entities/MLine.cs#L95-L121) | No | Vertex direction/miter/parameters and scale are unsafe under general placement; cloning is destructive in 3.7.1. | +| [`Leader`](https://github.com/DomCR/ACadSharp/blob/v3.7.1/src/ACadSharp/Entities/Leader.cs#L149-L165) | Line geometry only | Vertices and horizontal direction are mapped, but offsets and generated arrow size/shape do not inherit the affine placement. | +| [`Wipeout`](https://github.com/DomCR/ACadSharp/blob/v3.7.1/src/ACadSharp/Entities/CadWipeoutBase.cs#L215-L220) | No | U/V vectors are transformed as points, adding translation. | +| [`TextEntity`](https://github.com/DomCR/ACadSharp/blob/v3.7.1/src/ACadSharp/Entities/TextEntity.cs#L149-L268) | No | `AlignmentPoint` is untouched. | +| [`MText`](https://github.com/DomCR/ACadSharp/blob/v3.7.1/src/ACadSharp/Entities/MText.cs#L217-L306) | No | Computes then discards rotation; `AlignmentPoint` remains unchanged; non-uniform scaling is intentionally unsupported. | +| [`AttributeDefinition`](https://github.com/DomCR/ACadSharp/blob/v3.7.1/src/ACadSharp/Entities/AttributeDefinition.cs#L9-L44) | No | Inherits `TextEntity.ApplyTransform`; the embedded MText also needs independent clone/placement handling. | + +### Recommendation + +Implement option (a) now. Effort **M**, baseline risk **low**: top-level hatches and the existing mirrored world-plane regression should remain unchanged; only tilted hatches in blocks intentionally move. + +Harden ordinal pairing at the same time: + +- Pairing by `Explode()` position is already the only handle-free scheme available in 3.7.1. +- Check a per-type compatibility relation before using original geometry: exact type for text/hatch, plus explicitly allowed conversions such as Circle→Ellipse. +- On mismatch, warn immediately and avoid applying the wrong original—not merely report a count mismatch after everything has drawn. +- Keep the final count warning as a package-upgrade tripwire. + +Tests: + +- Solid and patterned hatch with a genuinely tilted normal, non-zero elevation, and a translated/rotated insert. +- Same under mirror and non-uniform scale. +- Two nested inserts proving transform composition. +- Existing mirrored +Z hatch remains unchanged. +- Pattern endpoints are generated in original OCS and placed afterward. +- Source hatch and block remain unchanged after drawing. +- Duplicate adjacent entities and a Circle→Ellipse between source-dependent types, proving pairing is ordinal rather than geometric. + +Treat option (c), preferably hybrid transform-one, as a separate **L** migration. It is architecturally cleaner but has high SVG-golden and moderate PNG-baseline risk because it changes clone metadata, nested order, and curve representation. + +## Sequencing and shared infrastructure + +1. **Multi-line attributes first**: isolated **S**, immediate coverage, no surface changes. +2. **Placement module + tilted hatch**: establish the cumulative point/vector/OCS seam and harden ordinal pairing. +3. **Custom leader arrows**: reuse placement and placed-block traversal; also correct arrow sizing inside outer inserts. +4. **Inverted wipeout with existing `FillPath`**: small once original-wipeout placement and bounds are available. +5. **MLEDIT cuts last**: first obtain one AutoCAD-authored multi-cut fixture to settle absolute versus relative interpretation. +6. **Separate architectural work:** Explode-free traversal and, independently, transparent `ErasePath` compositing. + +The highest-leverage shared module is placement, not clipping. It serves items 1, 2, 3, 4, and 5 while leaving the backend interface small. `ErasePath` earns a real surface seam only if transparent wipeouts or future XCLIP/IMAGE masking are in scope. + +Current-code complications to account for: + +- SVG’s single layer group means exact cross-layer painter order is already impossible. +- `WipeoutWorldBoundary` is also used for bounds/culling. +- MLINE finiteness checks currently cover too little data. +- `DrawAttributes` occurs after all block contents. +- `BlockRecord.Clone()` materializes sorted rather than stored entity order. +- Current `DrawBlockContents` does not actually use originals for Solid or Leader. +- A spline leader’s arrow direction should come from its endpoint tangent. +- An Explode-free traversal must apply only outer placement to nested `Insert.Attributes`; applying the nested insert’s own transform again is a double transform. + +## Upstream (ACadSharp) changes that would unlock more + +- Correct `Hatch.ApplyTransform`, or expose `GetWorldBoundaryPoints()` and `ExplodePatternWorld()` that explicitly consume the original OCS/elevation. +- Add `Insert.ExplodeWithSources()` returning `(Source, Result)` pairs, or a public one-entity transform operation that preserves source identity. +- Deep-clone `MLine.Vertices`, vertex `Segments`, and both parameter lists without mutating the source. +- Fix `TextEntity.AlignmentPoint`, `MText.AlignmentPoint`/rotation, and embedded `AttributeBase.MText` cloning/transformation. +- Transform wipeout U/V as vectors and provide correct world bounds. +- Make Circle/Arc/Ellipse affine transforms representation-aware, including Circle→Ellipse and reflected partial-ellipse parameters. +- Transform spline tangents as vectors. +- Preserve stored block entity order in `BlockRecord.Clone()` while cloning the sort table separately. +- Expose the dimension arrow-block placement helper for leaders, and expose effective leader style overrides directly. diff --git a/docs/research/remaining-rendering-limitations.md b/docs/research/remaining-rendering-limitations.md new file mode 100644 index 0000000..4904d4d --- /dev/null +++ b/docs/research/remaining-rendering-limitations.md @@ -0,0 +1,339 @@ +# Remaining rendering limitations: 3DFACE, MLINE, WIPEOUT, LEADER, insert attributes, explode pairing, raster text sizing + +- Date: 2026-09-03 +- Branch: `mubeda/svg-support` +- HEAD: `8ec289411faa195d0ee4a5c9b2376c2764b17c3c` +- Method: repository source read directly (line numbers are from the working tree at HEAD); ACadSharp 3.7.1 public surface obtained by **reflecting the pinned assembly** `~/.nuget/packages/acadsharp/3.7.1/lib/net10.0/ACadSharp.dll` and by reading its XML docs (`.../ACadSharp.xml`) — claims from that route are marked *(verified in package)*; method bodies read from the tagged GitHub source `DomCR/ACadSharp` at `v3.7.1` — marked *(v3.7.1 source)*; unreleased fixes read at `master` — marked *(main)*; behaviour confirmed by running throwaway probe programs against the pinned package — marked *(probe)*. DXF semantics from Autodesk's published *AutoCAD 2012 DXF Reference* PDF. SixLabors.Fonts behaviour from the pinned package XML docs, the tagged `v2.1.3` source, and measurements made against the pinned assembly. + +## Summary + +| Topic | Verdict | Effort | Touches a baseline/golden? | +| --- | --- | --- | --- | +| 3DFACE (`Face3D`) | Fully supportable today; corners are WCS, four edges with per-edge invisibility | **S** | No (no sample contains one) | +| MLINE (`MLine`) | Supportable from the stored per-vertex parameters; `MLineStyle` **is** resolved by both readers | **M** | No | +| WIPEOUT (`Wipeout`) | Supportable as an opaque polygon; boundary needs a pixel→WCS mapping the library does not provide | **M** | No | +| LEADER (`Leader`) | Supportable as polyline/spline + arrowhead; hookline is already in `Vertices` | **M** | No | +| INSERT attributes (`Insert.Attributes`) | Not drawn at all today; they are ordinary `TextEntity` geometry in absolute coordinates | **S** | No sample has one; the `features` golden would change only if the synthetic sample gains attributes | +| Original↔clone pairing after `Insert.Explode()` | Order **is** structurally guaranteed in 3.7.1; handles are not usable; no newer ACadSharp exists | **S** (hardening only) | No | +| Raster text size depends on `Dpi` | Real; fix is `TextOptions.Dpi = 72` with size in ems | **S** | Arithmetically a no-op at the default `Dpi = 96`; float rounding differs — **run the golden suite** | +| Single-line raster text shift at `LineSpacing ≠ 1` | Compensation is exact only at factor 1; wrong for MTEXT with a non-unit line-spacing factor | **S** | No golden uses a non-unit factor | +| *(incidental)* ATTDEF default values are drawn inside every `Insert` | Existing bug | **S** | No sample has an ATTDEF | +| *(incidental)* `MLine.Clone()` destroys the source MLINE in 3.7.1 | Upstream bug, fixed on `main`, still broken for segments | — | No | + +--- + +## 1. Unimplemented entity types + +All four fall through `EntityRenderDispatcher.Draw`'s `default:` arm and raise a `NotImplemented` notification (`ACadSharp.Image/Rendering/EntityRenderDispatcher.cs:153-155`). None of them appears in any drawing under `Samples/`: a probe that read all three sample files and grouped every block's entities by type found only `Line`, `Arc`, `TextEntity`, `DimensionAngular3Pt` (`Samples/6-57-1119.dxf`), `Line`, `Arc`, `Point`, `Solid`, `MText`, `DimensionLinear`, `Circle`, `Viewport` (`Samples/HSK80AHCP16190M_BMG.dwg`), and `Spline` (`Samples/Subaru Logo Vector Free Wrap.dxf`) *(probe)*. So none of the four sections below can move an existing PNG baseline or SVG golden. + +### 1.1 3DFACE (`AcDbFace`) + +**Status (2026-09-03):** implemented in plan 08 (docs/superpowers/plans/2026-09-03-08-remaining-entities.md). + +**What ACadSharp 3.7.1 exposes.** `ACadSharp.Entities.Face3D : Entity`, with `XYZ FirstCorner/SecondCorner/ThirdCorner/FourthCorner`, `InvisibleEdgeFlags Flags`, `ApplyTransform`, `GetBoundingBox` *(verified in package)*. `InvisibleEdgeFlags` is `[Flags]`-shaped with `None = 0, First = 1, Second = 2, Third = 4, Fourth = 8` *(verified in package)*. It does **not** implement `IOrientable` and has no `Normal` property — its corners are world coordinates and need no OCS step, unlike `Solid` (contrast `EntityRenderDispatcher.DrawSolid`, `:239-255`, which does apply `OcsTransform`). `ApplyTransform` simply maps all four corners *(v3.7.1 source, `Entities/Face3D.cs:69-75`)*. + +**DXF semantics.** Group 10/11/12/13 are the first…fourth corner, each *"(in WCS)"*; *"If only three corners are entered, this [the fourth] is the same as the third corner"*; group 70 is *"Invisible edge flags (optional; default = 0): 1 = First edge is invisible / 2 = Second edge is invisible / 4 = Third edge is invisible / 8 = Fourth edge is invisible"* (DXF Reference, *3dface group codes*). Edge *n* runs from corner *n* to corner *n+1*, with edge 4 closing corner 4 back to corner 1. + +**Rendering approach (both backends).** Project the four corners with `context.ToSurfacePoint(...)` and emit the visible edges only. Because per-edge visibility breaks the ring, this cannot go through `IDrawingSurface.DrawPolyline` as one call in the general case: + +- Build the four edges `(1→2, 2→3, 3→4, 4→1)`, drop each one whose flag bit is set, and drop the degenerate `3→4` edge when `FourthCorner == ThirdCorner` (the documented triangle encoding). +- Emit maximal runs of consecutive kept edges as `DrawPolyline(..., closed: false)`, and a single `DrawPolyline(..., closed: true)` in the common `Flags == None` case so the SVG gets one `` rather than four ``s (`SvgDrawingSurface.DrawPolyline` picks `polygon`/`polyline` from the `closed` argument, `ACadSharp.Image/Rendering/Svg/SvgDrawingSurface.cs`). +- Do **not** `FillPolygon`: a 3DFACE plots as edges in a 2D/wireframe view, which is the only view this renderer produces, and per-edge invisibility only makes sense for a wireframe. (Not primary-sourced; see [section 7](#7-not-verified).) Nothing else is backend-specific; both surfaces already implement `DrawPolyline`. + +**Gaps.** None. 3DFACE is the cheapest of the four. + +**Recommendation — effort S, no baseline impact.** Add a `case Face3D face:` above the `default:` arm and a `DrawFace3D` helper next to `DrawSolid`. Cover it with a `RecordingDrawingSurface` unit test per flag combination, plus one entity in the synthetic `features` sample if a visual golden is wanted (that *would* rewrite `features.model.01.png/.svg`). + +### 1.2 MLINE (`AcDbMline`) + +**Status (2026-09-03):** implemented in plan 08 (docs/superpowers/plans/2026-09-03-08-remaining-entities.md); deviation: MLEDIT cuts (the group-41 values after the first) are ignored, like ezdxf's renderer — the elements stay continuous and a Warning is raised when any are present. + +**What ACadSharp 3.7.1 exposes.** `ACadSharp.Entities.MLine : Entity, IOrientable` with `MLineFlags Flags` (`Has = 1, Closed = 2, NoStartCaps = 4, NoEndCaps = 8`), `MLineJustification Justification` (`Top = 0, Zero = 1, Bottom = 2`), `XYZ Normal`, `double ScaleFactor`, `XYZ StartPoint`, `MLineStyle Style`, `List Vertices` *(verified in package)*. `MLine.Vertex` has `XYZ Position`, `XYZ Direction` ("Direction vector of segment starting at this vertex"), `XYZ Miter` ("Direction vector of miter at this vertex") and `List Segments` ("Segments in MLineStyle definition"); `Vertex.Segment` has `List Parameters` ("Element parameters") and `List AreaFillParameters` *(verified in package; XML docs)*. `ACadSharp.Objects.MLineStyle : NonGraphicalObject` has `IEnumerable Elements`, `Color FillColor`, `MLineStyleFlags Flags` (`FillOn = 1, DisplayJoints = 2, StartSquareCap = 16, StartInnerArcsCap = 32, StartRoundCap = 64, EndSquareCap = 256, EndInnerArcsCap = 512, EndRoundCap = 1024`), `StartAngle`/`EndAngle`, and `MLineStyle.Element` has `double Offset`, `Color Color`, `LineType LineType` *(verified in package)*. `MLine.Style` can never be null: the setter throws on null and the field is initialised to `MLineStyle.Default` *(v3.7.1 source, `Entities/MLine.cs:63-93`)*; `MLineStyle.Default` is named `Standard` and carries 2 elements *(probe)*. + +**Is `MLineStyle` resolved?** Yes — this is *not* a gap. A probe authored an `MLine` with a custom three-element `MLineStyle` (offsets `0.5 / 0 / -0.5`), wrote it to DXF and to DWG with ACadSharp's own writers, read both back, and in both cases got `style='PROBE3' elements=3 offsets=[0.5,0,-0.5] scale=2 just=Zero verts=3` *(probe)*. The 340 handle reference is declared on the property (`[DxfCodeValue(DxfReferenceType.Handle | DxfReferenceType.Name, 340)]`, *v3.7.1 source*), which is what the DXF Reference says to use: *"The correct fields to modify are as follows: Mline The 340 group in the same object, which indicates the proper MLINESTYLE object"*. + +**DXF semantics — the important part.** The offsets are **already baked into each vertex**, so justification and scale do not have to be re-derived. From the DXF Reference (*Mline group codes*, prose after the table): + +> The group code 41 parameterization is a list of real values, one real per group code 41. … The first group code 41 value is the distance from the segment vertex along the miter vector to the point where the line element's path intersects the miter vector. The next group code 41 value is the distance along the line element's path from the point defined by the first group 41 to the actual start of the line element. The next is the distance from the start of the line element to the first break (or cut) in the line element. The successive group code 41 values continue to list the start and stop points of the line element in this segment of the mline. Linetypes do not affect group 41 lists. + +Group 41 is ACadSharp's `Vertex.Segments[j].Parameters`, and `Segments[j]` corresponds to element *j* of the style (group 73 is *"Number of elements in MLINESTYLE definition"*). Group 42 (`AreaFillParameters`) describes the fill-area boundary the same way. Group 71 bit 2 is `Closed`, bits 4/8 suppress the start/end caps. Group 70 justification and group 40 scale are the authoring inputs that produced those parameters; re-applying them would double-count. + +**Rendering approach (both backends).** + +- For element *j*, walk the vertices; at vertex *i* the element's line passes through `Vertices[i].Position + Segments[j].Parameters[0] * Vertices[i].Miter`. Join consecutive vertices with `DrawPolyline` (closed when `Flags.HasFlag(MLineFlags.Closed)`), one polyline per style element. This is a plain polyline on both surfaces. +- The remaining group-41 values are **breaks**, not decoration: per the quoted prose, `Parameters[1]` is the distance along the element's path to where the line actually starts, and the values after it alternate stop/start of the cut segments (what `MLEDIT` writes when one mline crosses another). Ignoring them draws a solid line straight through every gap. A first version can honour `Parameters[0]` only and `Notify` when `Parameters.Count > 2`; a complete one walks the pairs and emits one polyline per surviving run. +- Style each element from `MLineStyle.Element.Color` and `.LineType` rather than the entity's resolved style: build `ImageStyle` from the element colour (`ColorExtensions.ToImageColor(foreground)`) and, for dashes, the existing `LineTypeDashResolver` (`ACadSharp.Image/Rendering/LineTypeDashResolver.cs`). Keep the entity's resolved stroke width and opacity. +- Fill: when `Style.Flags.HasFlag(MLineStyleFlags.FillOn)`, fill the ring formed by the outermost two elements with `Style.FillColor` via `FillPolygon`, before stroking, so the strokes stay on top. +- Caps and joints (`StartRoundCap`, `DisplayJoints`, …) are cosmetic; a first version can skip them and add a straight cap segment between the outermost elements when the corresponding suppress flag is absent. +- **Fallback**: when `Vertices[i].Segments` is empty (some writers omit group 74/41), fall back to computing offsets from the style: element offset `o_j`, scale `ScaleFactor`, and a justification shift of `-max(o)` for `Top`, `0` for `Zero`, `-min(o)` for `Bottom`, applied along `Miter`. Say so in a `Notify` warning so the approximation is visible. + +**Gaps to be aware of.** `MLine.Clone()` in 3.7.1 is destructive: it calls `base.Clone()` (a `MemberwiseClone`, so `clone.Vertices` is the *same* `List` instance) and then `clone.Vertices.Clear()` *(v3.7.1 source, `Entities/MLine.cs:108-121`)*. A probe confirms it: a source MLINE with one vertex has **zero** vertices after `Clone()`, and so does the clone *(probe)*. This fires whenever an MLINE lives inside a block, because `Insert.Explode()` calls `CloneTyped()` on every block entity (see [section 3](#3-originaltoclone-pairing-after-insertexplode)) — so exploding an insert silently empties the block's MLINE. On `main` the entity-level bug is fixed (`clone.Vertices = new List()`), but `MLine.Vertex.cs` is byte-identical to v3.7.1 and still does `clone.Segments.Clear()` on a memberwise-shared list, so the per-vertex `Segments` are still destroyed *(main)*. **Practical consequence:** render MLINEs from the top-level entity, and for MLINEs inside blocks either skip them with a warning or read them from `insert.Block.Entities` *before* enumerating `Explode()`. + +**Recommendation — effort M, no baseline impact.** Implement the parameter-driven path plus the style-offset fallback; guard against the clone bug. Unit-test with `RecordingDrawingSurface` against a hand-built `MLine` (offsets, closed flag, fill on/off). + +### 1.3 WIPEOUT (`AcDbWipeout`) + +**Status (2026-09-03):** implemented in plan 08 (docs/superpowers/plans/2026-09-03-08-remaining-entities.md); deviation: the pixel→WCS mapping is Y-flipped relative to the derivation below (`world = InsertPoint + (p.X + 0.5) * U + (Size.Y - p.Y - 0.5) * V`, matching ezdxf's `boundary_path_wcs`, which also writes wipeouts this way) — the flip is what makes the documented default boundary cover exactly the image extent with the top-left pixel at the top. + +**What ACadSharp 3.7.1 exposes.** `ACadSharp.Entities.Wipeout : CadWipeoutBase : Entity` — `CadWipeoutBase` is documented as the *"Common base class for `RasterImage` and `Wipeout`"* and carries `List ClipBoundaryVertices`, `ClipType ClipType` (`Rectangular = 1, Polygonal = 2`), `bool ClippingState`, `ClipMode ClipMode` (`Outside = 0, Inside = 1`), `XYZ InsertPoint`, `XYZ UVector`, `XYZ VVector`, `XY Size`, `ImageDisplayFlags Flags` (`ShowImage = 1, ShowNotAlignedImage = 2, UseClippingBoundary = 4, TransparencyIsOn = 8`), `bool ShowImage`, `byte Brightness/Contrast/Fade`, `ImageDefinition Definition` *(verified in package)*. + +**Is it read?** Yes, on both formats. The DXF reader has `case DxfFileToken.EntityWipeout: return this.readEntityCodes(new CadWipeoutBaseTemplate(new Wipeout()), this.readWipeoutBase);` and the sibling `EntityImage → RasterImage` *(v3.7.1 source, `IO/DXF/DxfStreamReader/DxfSectionReaderBase.cs:231-234`, boundary vertices appended at `:1531`)*; the DWG reader has `case "WIPEOUT": template = this.readCadImage(new Wipeout());` next to `case "IMAGE": … new RasterImage()` *(v3.7.1 source, `IO/DWG/DwgStreamReaders/DwgObjectReader.cs:5608,5655`, shared body at `:1217-1233`)*. A write→read round trip could **not** be used to confirm this end-to-end: a `Wipeout` authored in memory did not survive ACadSharp's own DXF or DWG writers *(probe)*, i.e. the *write* path drops it. That does not affect rendering, which only reads. + +**DXF semantics.** WIPEOUT's subclass marker is `AcDbRasterImage` and its group codes are the IMAGE codes (DXF Reference, *Wipeout group codes* and *Image group codes*): + +- 10 = *"Insertion point (in WCS)"*. +- 11 = *"U-vector of a single pixel (points along the visual bottom of the image, starting at the insertion point) (in WCS)"*. +- 12 = *"V-vector of a single pixel (points along the visual left side of the image, starting at the insertion point) (in WCS)"*. +- 13/23 = *"Image size in pixels"*. +- 71 = *"Clipping boundary type. 1 = Rectangular; 2 = Polygonal"*; 91 = vertex count; 14/24 = *"Clip boundary vertex (in OCS)"*, with the note *"1) For rectangular clip boundary type, two opposite corners must be specified. Default is (-0.5,-0.5), (size.x-0.5, size.y-0.5). 2) For polygonal clip boundary type, three or more vertices must be specified. Polygonal vertices must be listed sequentially"*. +- 70 = display flags (`1 = Show image`, `4 = Use clipping boundary`); 280 = clipping state; 290 = clip mode (Outside/Inside). + +So the boundary vertices are in **pixel space**, not drawing units, and the mapping to world is `world(p) = InsertPoint + (p.X + 0.5) · UVector + (p.Y + 0.5) · VVector`. The `+0.5` follows from the documented default rectangular boundary: `(-0.5, -0.5)` then maps to `InsertPoint` and `(size.x-0.5, size.y-0.5)` to `InsertPoint + size.x·U + size.y·V`, i.e. exactly the image extent. + +**ACadSharp gap.** `CadWipeoutBase.GetBoundingBox()` does **not** perform this mapping — it takes the min/max of the raw boundary vertices and adds `InsertPoint`, ignoring `UVector`, `VVector` and the half-pixel offset *(v3.7.1 source, `Entities/CadWipeoutBase.cs:230-244`; unchanged on `main`)*. It is therefore only correct when the pixel vectors happen to be the unit axes. The renderer must do the mapping itself; it must **not** rely on `GetBoundingBox()` for wipeout extents. + +**Rendering approach (both backends).** + +- **Visibility.** `Flags` decides whether anything is painted: no `ImageDisplayFlags.ShowImage` (group 70 bit 1) means draw nothing. `ClippingState` (group 280) does **not** hide the entity — it selects *which* region is painted. +- **Region.** When `ClippingState` is true, use `ClipBoundaryVertices`, expanding a `Rectangular` pair of opposite corners into four pixel-space corners first; a `Polygonal` boundary is used as listed. When `ClippingState` is false, use the full frame `(-0.5, -0.5) … (Size.X - 0.5, Size.Y - 0.5)`, which is the documented default boundary. +- **`ClipMode`.** `ClipMode.Inside` (group 290 = 1) inverts the region — everything *outside* the boundary is painted. That needs an even-odd fill of the frame minus the boundary, or a clip; the honest first version raises a `Notify` and skips, rather than filling the wrong half. +- Map every vertex with the formula above, project through `context.ToSurfacePoint`, and call `IDrawingSurface.FillPolygon` with a style whose colour is the page background and whose opacity is forced to 1: `style with { StrokeColor = configuration.BackgroundColor, Opacity = 1f }`. The `Opacity` reset matters — `ImageStyle.EffectiveColor` applies the entity's resolved transparency to the alpha channel (`ACadSharp.Image/Rendering/ImageStyle.cs:16-26`), and a translucent wipeout would not occlude. `FillPolygon` uses `style.EffectiveColor` on both surfaces (`RasterDrawingSurface.cs:137-147` and the SVG `FillPolygon`). +- **Draw order matters, and this repo's order is not ACadSharp's default.** `ImagePage` materialises entities in `block.Entities` order (`ACadSharp.Image/ImagePage.cs:95,105`; layouts via `ImageExporter.cs:110`), i.e. DXF file order. ACadSharp's own `BlockRecord.GetSortedEntities()` returns `this.Entities.OrderBy(e => e.Handle)` when there is no sort table and applies the `SortEntitiesTable` (DRAWORDER) when there is one *(v3.7.1 source, `Tables/BlockRecord.cs:243-251,470-486`)*. File order and handle order usually coincide, but nothing guarantees it, and DRAWORDER is ignored entirely today. Since a wipeout is only correct if everything it should hide is drawn *before* it, the WIPEOUT work should switch `ImagePage`'s two `block.Entities` loops to `block.GetSortedEntities()` — one change that fixes both the default ordering and DRAWORDER. That is the part of this item that **can** move existing baselines, so measure it separately. +- **Transparent backgrounds break the effect.** `SvgDrawingSurface` emits its `cad-background` `` only when `configuration.BackgroundColor` has non-zero alpha (`SvgDrawingSurface.cs:62-79`), and a fill with alpha 0 paints nothing on either backend. With `BackgroundColor = Transparent` a wipeout therefore cannot occlude. Options: paint it with `ResolveForegroundColor()`'s complement, or add an explicit `WipeoutColor` configuration knob defaulting to `BackgroundColor`. Document whichever is chosen. +- **Frame.** AutoCAD's WIPEOUTFRAME system variable controls whether the boundary is stroked. ACadSharp 3.7.1's `CadHeader` exposes no `WipeoutFrame`/`WIPEOUTFRAME` member *(verified in package — no such member in the reflected `CadHeader` surface or the XML docs)*, and the DXF Reference documents the frame setting on the `WIPEOUTVARIABLES` object, not on the entity. Simplest correct default: **do not** draw the frame (matching `WIPEOUTFRAME = 0`, AutoCAD's plot behaviour), and expose an opt-in flag if a frame is ever wanted. + +**Recommendation — effort M, no baseline impact for the entity itself; the draw-order change is separate and *does* risk moving baselines.** Implement the wipeout as an opaque background-coloured `FillPolygon` with the pixel→WCS mapping written locally, no frame, and an explicit decision about transparent backgrounds; unit-test the mapping against the documented default boundary. Land the `GetSortedEntities()` switch as its own commit and re-run the golden suite for it. + +### 1.4 LEADER (`AcDbLeader`) + +**Status (2026-09-03):** implemented in plan 08 (docs/superpowers/plans/2026-09-03-08-remaining-entities.md). + +**What ACadSharp 3.7.1 exposes.** `ACadSharp.Entities.Leader : Entity, IOrientable` with `List Vertices`, `bool ArrowHeadEnabled`, `LeaderPathType PathType` (`StraightLineSegments = 0, Spline = 1`), `bool HasHookline` *(get-only)*, `HookLineDirection HookLineDirection` (`Opposite = 0, Same = 1`), `XYZ HorizontalDirection`, `XYZ Normal`, `DimensionStyle Style`, `Entity AssociatedAnnotation`, `LeaderCreationType CreationType` (`CreatedWithTextAnnotation = 0, CreatedWithToleranceAnnotation = 1, CreatedWithBlockReferenceAnnotation = 2, CreatedWithoutAnnotation = 3`), `XYZ AnnotationOffset`, `XYZ BlockOffset`, `double TextHeight`, `double TextWidth`, plus `SetDimensionOverride`/`SetStyleOverrideMap` *(verified in package)*. `Style` defaults to `DimensionStyle.Default` and cannot be set to null *(v3.7.1 source, `Entities/Leader.cs:113-128`)*; `DimensionStyle.Default.ArrowSize` is `0.18`, `ScaleFactor` is `1`, `LeaderArrow` is `null` *(probe)*. `DimensionStyle.ArrowSize` is documented as *"Controls the size of dimension line and leader line arrowheads. Also controls the size of hook lines (see DIMASZ System Variable)"* and `DimensionStyle.LeaderArrow` as *"Specifies the arrow type for leaders (see DIMLDRBLK System Variable). A [BlockRecord] that makes up an arrowhead or null if the default, closed-filled arrowhead is to be displayed"* *(XML docs)*. + +**DXF semantics.** From the DXF Reference (*Leader group codes*): 3 = dimension style name; 71 = *"Arrowhead flag: 0 = Disabled; 1 = Enabled"*; 72 = *"Leader path type: 0 = Straight line segments; 1 = Spline"*; 73 = creation flag; 74 = hookline direction; 75 = *"Hookline flag: 0 = No hookline; 1 = Has a hookline"*; 40/41 = text annotation height/width; 76 = vertex count; 10/20/30 = *"Vertex coordinates (one entry for each vertex)"* with no OCS qualifier, i.e. WCS; 340 = *"Hard reference to associated annotation (mtext, tolerance, or insert entity)"*; 211 = the "horizontal" direction; 212/213 = offsets of the last vertex from the block/annotation placement point. + +Two consequences: **the hookline is already one of the stored vertices** (it is the last, horizontal, segment — no extra geometry to synthesise), and **the annotation is a separate entity** that the page already draws in its own right, so a LEADER renderer must not draw `AssociatedAnnotation` again. + +**ACadSharp gap.** `Leader.HasHookline` is a computed getter — it returns whether the vector from the second-to-last to the last vertex is parallel to `HorizontalDirection` — with no setter, even though it carries `[DxfCodeValue(75)]` *(v3.7.1 source, `Entities/Leader.cs:61-72`)*. The file's stored group-75 flag is therefore discarded and re-derived. Harmless for rendering (the geometry is in `Vertices` either way), but do not treat `HasHookline` as file data. + +**Rendering approach (both backends).** + +- `PathType.StraightLineSegments`: project `Vertices` and call `DrawPolyline(..., closed: false)`. Nothing backend-specific. +- `PathType.Spline`: the vertices are the fit points of a splined leader. Build a transient `Spline { FitPoints = Vertices, Degree = 3 }` and hand it to the existing `SplineRenderer`, which already has a fit-points path — it falls back to *"Spline has fit points but no control points; drawn as a polyline through its fit points"* (`ACadSharp.Image/Rendering/SplineRenderer.cs:64-77`), because ACadSharp 3.7.1's `UpdateFromFitPoints` fills the knot vector but no control points. That is a coarse but honest rendering; alternatively emit a Catmull-Rom→cubic-Bézier chain through the fit points and call `DrawCubicBezier` on surfaces where `SupportsCurves` is true (SVG), which gives the SVG a real `` and costs little. +- Arrowhead: only when `ArrowHeadEnabled`. Size `= Style.ArrowSize * (Style.ScaleFactor > 0 ? Style.ScaleFactor : 1)` in drawing units, converted with `context.ToSurfaceLength`. Direction is `Vertices[0] - Vertices[1]` (the arrow sits at the first vertex, pointing at the thing being annotated). When `Style.LeaderArrow` is null, draw AutoCAD's default *closed filled* arrowhead: an isosceles triangle of length `size` and half-width `size/6` (AutoCAD's built-in ratio), filled with `FillPolygon` using the entity's resolved style. When `LeaderArrow` is a `BlockRecord`, either render the block scaled to `ArrowSize` (an `Insert`-like path) or fall back to the default triangle with a `Notify` — the latter is the cheaper first version. +- `HookLineDirection` and `AnnotationOffset`/`BlockOffset` need no geometry of their own; they only describe where the (separately drawn) annotation sits. + +**Recommendation — effort M, no baseline impact.** Straight-line path plus default filled arrowhead first (that covers the overwhelming majority of LEADERs); spline path via `SplineRenderer`; custom arrow blocks deferred behind a notification. + +--- + +## 2. INSERT attributes (ATTRIB) + +**Status (2026-09-03):** implemented in plan 08 (docs/superpowers/plans/2026-09-03-08-remaining-entities.md). + +**Does ACadSharp expose them?** Yes. `Insert.Attributes` is `SeqendCollection` with a public getter, documented as *"Attributes from the block reference. If an attribute should be added in this collection a definition will be added into the block reference as well"*; `Insert.HasAttributes` is *"True if the insert has attribute entities in it"* *(verified in package; XML docs)*. The type chain is `AttributeEntity : AttributeBase : TextEntity : Entity`, and `AttributeEntity` implements `IText` *(verified in package)*. `AttributeBase` adds `AttributeType AttributeType` (`SingleLine = 1, MultiLine = 2, ConstantMultiLine = 4`), `AttributeFlags Flags` (`None = 0, Hidden = 1, Constant = 2, Verify = 4, Preset = 8`), `bool IsLocked`, `MText MText`, `string Tag`, `byte Version`, and an **`override`** of `TextEntity.VerticalAlignment` *(verified in package; `override`, not `new`, confirmed at `Entities/AttributeBase.cs:63-64` against `virtual` at `Entities/TextEntity.cs:135` — v3.7.1 source)*. That last point matters: reading `VerticalAlignment` through a `TextEntity` reference yields the ATTRIB's own value, so `TextRenderer.Draw(ImageRenderContext, ImageStyle, TextEntity, Transform?)` works unchanged on an `AttributeEntity`. + +**Are they in world coordinates?** Yes — they need **no** insert transform. The DXF Reference's *Attrib group codes* give ATTRIB the subclass chain `AcDbText` then `AcDbAttribute`, with 10 = *"Text start point (in OCS)"*, 11 = *"Alignment point (in OCS) (optional) … Present only if 72 or 74 group is present and nonzero"*, 40 = text height, 50 = rotation, 72/74 = horizontal/vertical justification *"See TEXT … group codes"*, 210 = extrusion. Those are absolute coordinates in the ATTRIB's own OCS, exactly like a TEXT entity — the insert's translation, rotation and scale are already applied by whatever wrote the file. So: pass `placement: null` to `TextRenderer.Draw`. The existing OCS handling in `TextRenderer.Draw` (`ACadSharp.Image/Rendering/TextRenderer.cs:86-89`) is exactly what ATTRIB needs. + +**Which are invisible?** + +- **`AttributeFlags.Hidden`** (group 70 bit 1, *"Attribute is invisible (does not appear)"*): skip the attribute. +- **ATTMODE.** `CadHeader.AttributeVisibility` is documented as *"Controls display of attributes. System variable ATTMODE"* and is of type `ACadSharp.Header.AttributeVisibilityMode` with `None = 0, Normal = 1, All = 2` *(verified in package)*, matching the DXF Reference's HEADER entry: *"`$ATTMODE` 70 Attribute visibility: 0 = None; 1 = Normal; 2 = All"* (DXF Reference, *Header Variables*). `None` hides every attribute; `Normal` honours the per-attribute `Hidden` flag; `All` shows every attribute including hidden ones. This is a per-document setting, so it belongs alongside `ImageConfiguration.LayerVisibility` — under `LayerVisibilityMode.All` the renderer already ignores drawing visibility (`ACadSharp.Image/Rendering/EntityVisibilityFilter.cs:38-42`), and ATTMODE should be treated the same way. +- `AttributeFlags.Constant` does **not** hide anything; constant attributes have no ATTRIB at all — they stay as ATTDEFs in the block (see the incidental finding in [section 5](#5-incidental-findings)). + +**Rendering approach.** In `EntityRenderDispatcher.DrawBlockContents` (`:393-413`), after the `Explode()` loop, iterate `insert.Attributes` and call `this.Draw(context, attribute, layer, insert.Handle, insert.Block?.Name, parent)` — no `textSource`, no `placement`. The `case TextEntity textEntity:` arm (`:141-143`) then routes it to `TextRenderer`, and `BeginEntity`/`EndEntity` give the SVG the same per-entity group structure as any other text. Filter on `Flags.HasFlag(AttributeFlags.Hidden)` plus the document's ATTMODE. `AttributeBase.MText` is populated for multi-line attributes (`AttributeType.MultiLine`); prefer it over `Value` when non-null so a multi-line attribute goes through the MTEXT path. + +**Gotcha when writing the test fixture.** `Insert(BlockRecord block)` already creates one `AttributeEntity` per `block.AttributeDefinitions` and calls `att.ApplyTransform(this.GetTransform())` inside the constructor *(v3.7.1 source, `Entities/Insert.cs:225-246`)*. With C# object-initializer syntax the constructor runs **before** `InsertPoint`/`Rotation`/`XScale` are assigned, so those attributes are transformed by the identity; a later `UpdateAttributes()` sees the tags already present and does nothing (`Insert.cs:411-436`). Set the placement properties first, or position the attributes explicitly, when building a synthetic insert-with-attributes for a test. + +**Recommendation — effort S, changes no existing baseline.** No `Samples/` drawing contains an `Insert` at all, let alone one with attributes *(probe)*; the `features` synthetic sample has one `Insert` (`ACadSharp.Image.Tests/SyntheticSamples.cs:118`) with no attributes, so `features.model.01.png/.svg` stay byte-identical unless the sample is extended (which would be a deliberate golden rewrite). + +--- + +## 3. Original-to-clone pairing after `Insert.Explode()` + +**Status (2026-09-03):** implemented in plan 08 (docs/superpowers/plans/2026-09-03-08-remaining-entities.md). + +**Is the order guaranteed in 3.7.1?** Yes, structurally — the pairing in `EntityRenderDispatcher.DrawBlockContents` (`:402-412`) is sound. `Explode()` is a single `foreach` over `this.Block.Entities` that yields exactly one entity per source entity, in order, with no filtering and no fan-out: + +```csharp +// v3.7.1 source, src/ACadSharp/Entities/Insert.cs:320-359 +public IEnumerable Explode() +{ + Transform transform = this.GetTransform(); + foreach (var e in this.Block.Entities) + { + Entity c; + switch (e) + { + case Arc arc: /* … builds a new Arc from transformed end vertices … */ yield return a; continue; + case Circle circle: c = new Ellipse() { … }; c.MatchProperties(e); break; + default: c = e.CloneTyped(); break; + } + c.ApplyTransform(transform); + yield return c; + } +} +``` + +A probe over a five-entity block (`Line`, `Circle`, `Arc`, `TextEntity`, `AttributeDefinition`) returned exactly five clones in the same positions, with `Circle → Ellipse` being the only type change *(probe)*. `BlockRecord.Entities` is a `CadObjectCollection` — an insertion-ordered collection, and the *same* collection object is enumerated by both `insert.Block.Entities.ToList()` and `Explode()`, so index *i* is the same entity on both sides by construction. + +**Is a handle-independent pairing possible?** No, not on the clones. `CadObject.Clone()` is `MemberwiseClone()` followed by `clone.Handle = 0; clone.Document = null; clone.Owner = null;` *(v3.7.1 source, `CadObject.cs:140-155`)*, and the probe confirms every exploded clone has `handle=0` *(probe)*. Handle-based pairing is therefore impossible. + +**Is there a more robust approach?** Yes, and it is strictly better than pairing: **stop using `Explode()` for the entity types the renderer already special-cases, and drive them from the originals.** The renderer only needs the clones' *properties* (which come from `MatchProperties`/`CloneTyped`) plus the transform; it already ignores the clones' geometry for TEXT and MTEXT and re-derives it from the original plus `insert.GetTransform()` (`TextRenderer.Draw`, `:23-25` and `:72-75`). Two safe refactors: + +1. **Iterate `insert.Block.Entities` directly** and, for each entity, either draw it from the original + `GetTransform()` (TEXT, MTEXT — already the case) or draw a `CloneTyped()` + `ApplyTransform(transform)` of just that one entity. This removes the index bookkeeping entirely and removes the dependency on `Explode()`'s internals; it also lets the renderer skip the destructive `MLine.Clone()` (section 1.2) and skip ATTDEFs (section 5). Cost: it re-implements four lines of `Explode()`, and loses the `Arc`/`Circle` conversions — which this renderer does not need, since it tessellates arcs and ellipses itself. +2. **Keep `Explode()` but assert the count.** `originals.Count` vs the number of yielded clones; `Notify` a warning and fall back to un-paired drawing if they ever diverge. Two lines, and it turns a silent misplacement into a visible warning after a package upgrade. + +**Does a newer ACadSharp fix `ApplyTransform`?** No, and there is nothing to upgrade to. + +- **v3.7.1 (published 2026-08-18) is the newest release** — the GitHub releases feed lists `v3.7.1`, `v3.6.51`, `v3.6.35`, `v3.6.29`, `v3.6.12`, `v3.5.7`, `v3.4.29`, … with no 3.8.x and no 4.x. So "upgrading" today means moving to unreleased `master`. +- `TextEntity.ApplyTransform` assigns `InsertPoint`, `Normal`, `Rotation`, `Height`, `WidthFactor`, `ObliqueAngle` and **never touches `AlignmentPoint`** — identical at `v3.7.1` and at `master` *(v3.7.1 source `Entities/TextEntity.cs:279-284`; main, same file, same assignments)*. Since `TextRenderer.GetTextOrigin` uses `AlignmentPoint` for any non-`Left`/non-`Baseline` text (`TextRenderer.cs:209-214`), the workaround is still required. A probe confirms it: a TEXT with `AlignmentPoint = (5,5,0)` inside an insert at `(10,0,0)` with rotation π/2 and scale 2 comes back with `Insert=(10,0,0)` transformed but `Alignment=(5,5,0)` unchanged *(probe)*. +- `MText.ApplyTransform` computes a `newRotation` local and then **discards it**, assigning only `InsertPoint`, `Normal`, `Height` and `RectangleWidth` — again identical at `v3.7.1` and `master`. `MText.Rotation` is a **get-only** property derived from `AlignmentPoint` (`return new XY(this.AlignmentPoint.X, this.AlignmentPoint.Y).GetAngle();`, *v3.7.1 source `Entities/MText.cs:169-175*), and `AlignmentPoint` is never transformed. A probe confirms: an MTEXT with `AlignmentPoint = (1,0,0)` keeps `rotation = 0.0000` after `ApplyTransform` with a π/2 rotation *(probe)*. +- The one relevant `master` change is `MLine.Clone()` (section 1.2), which does not affect text. + +**Cost of upgrading.** Nothing to upgrade to. Moving to `master` would buy only the `MLine.Clone()` vertex fix (not the `Vertex.Segments` fix), would still leave both text-transform gaps, and would put the project on an unreleased commit. Recommendation: **stay on 3.7.1** and keep the renderer-side transform, which is correct regardless of what upstream does. + +**Recommendation — effort S, no baseline impact.** Add the count assertion (option 2) now; consider the `Block.Entities`-driven refactor (option 1) when MLINE support lands, since it needs the same change to dodge the destructive clone. Neither alters output for any current sample. + +--- + +## 4. Raster text sizing + +### 4.1 Text size scales with `ImageConfiguration.Dpi`; geometry does not + +**Status (2026-09-03):** implemented in plan 08 (docs/superpowers/plans/2026-09-03-08-remaining-entities.md). + +**Confirmation of the problem.** Geometry scale comes from the page fit, not from `Dpi`: `ImageRenderContext.ToSurfaceLength` multiplies by `Scale` (`ACadSharp.Image/Rendering/ImageRenderContext.cs:387-392`), which is computed from the requested width/height. `Dpi` (default `96f`, `ACadSharp.Image/ImageConfiguration.cs:148`) is used in exactly two places: line weights, where it is *intended* (`GetLineWeightPixels`: `millimeters * Dpi / 25.4`, `:360`) because line weights are physical millimetres; and text, where it is not — `RasterDrawingSurface.DrawText` sets `TextOptions.Dpi = this._configuration.Dpi` while passing the CAD text height straight in as a font size (`RasterDrawingSurface.cs:184,205` via `CreateFont`, `:279-282`). The SVG backend has no `Dpi` at all: it converts the same height with a fixed factor, `SvgTextLayout.EmSize(h) = h * 4/3` (`ACadSharp.Image/Rendering/Svg/SvgTextLayout.cs:17-20`), and measures at a pinned `Dpi = 72f` (`:64`). + +**Why.** `Font.Size` is documented as *"the size of the font in PT units"*; `TextOptions.Dpi` is *"the DPI (Dots Per Inch) to render/measure the text at. Defaults to 72"*; and `FontMetrics.ScaleFactor` is *"the scale factor that is applied to all glyphs in this face. Calculated as `72 * UnitsPerEm` so that 1pt = 1px"* *(SixLabors.Fonts 2.1.3 XML docs, `~/.nuget/packages/sixlabors.fonts/2.1.3/lib/net6.0/SixLabors.Fonts.xml`)*. In the layout code the whole box is computed in inches (`Vector2 boxLocation = options.Origin / options.Dpi;`, `wrappingLength = options.WrappingLength / options.Dpi`) and one line box is `float lineHeight = metric.UnitsPerEm * scaleY;` with `scaleY = pointSize / metric.ScaleFactor.Y` *(v2.1.3 source, `src/SixLabors.Fonts/TextLayout.cs:196,933,1138,1140`)*. Substituting `ScaleFactor = 72 · UnitsPerEm` gives `lineHeight = pointSize / 72` inches, i.e. **em size in pixels = `Font.Size × Dpi / 72`** — so glyphs grow linearly with `Dpi` while the geometry around them does not. + +Measured against the pinned assembly *(probe, Liberation Sans)*: at `Font.Size = 10`, `MeasureBounds("Hg")` returns `11.32 × 8.96` px at `Dpi = 72`, `15.09 × 11.94` at `96`, `23.58 × 18.66` at `150`, `47.16 × 37.31` at `300`. + +**The correct fix.** Set `TextOptions.Dpi = 72f` and pass the size in **ems**: `size = height * TextMetrics.CapHeightToEm` (that is `height * 4/3`). Because `Font.Size × Dpi / 72` is the only thing that matters, `(size = h, Dpi = 96)` and `(size = 4h/3, Dpi = 72)` are the same rendering — verified exactly: `MeasureBounds("Hg")` at `12pt @ 96dpi` and at `16pt @ 72dpi` both return `bounds=(1.3125, 2.2773) 18.1094 × 14.3281` *(probe)*. Under the new scheme the measurement is `Dpi`-invariant: `height = 10` gives `15.09 × 11.94` px at every `Dpi` in `{72, 96, 150, 300}` *(probe)*. + +Three knock-on edits in `RasterDrawingSurface.DrawText`: + +- `CreateFont` should take the em size, not the cap height, so it and `SvgTextLayout.EmSize` share one definition of the conversion. +- `halfLeading` becomes `factor * font.Size / 8d` (the `* Dpi / 72d` factor collapses to 1) — numerically the same value as today at `Dpi = 96`. +- `FontResolver.Create`'s 1-point clamp (`ACadSharp.Image/Rendering/FontResolver.cs:77-80`) now bites at `height < 0.75` surface units instead of `height < 1`, i.e. the clamp gets slightly *less* aggressive. Sub-pixel text is degenerate either way. + +**Baseline impact.** Arithmetically this is a no-op at the default `Dpi = 96`, but it is **not** bit-identical: `Math.Max(1f, (float)h) * 96f/72f` and `Math.Max(1f, (float)(h * 4d/3d))` differ by one ULP for ~37% of heights `h ≥ 1` (max relative difference `1.34e-7`), and end-to-end `MeasureBounds` differs for most heights by at most `6.1e-4` px *(probe)*. That is three orders of magnitude below a pixel, so the rendered PNGs should be unchanged — but `GoldenAssert.Png` compares pixel-for-pixel (`ACadSharp.Image.Tests/GoldenAssert.cs:26-36`), so **run `FeatureGoldenTests`, `SampleParityTests` and `ViewportParityTests` and be ready to regenerate every PNG baseline that contains text — `features.model.01.png`, `viewport-sheet.paper.01.png` (its `"SHEET 1"` label, `ACadSharp.Image.Tests/SyntheticSamples.cs:60`), `6-57-1119.model.01.png` and `HSK80AHCP16190M_BMG.model.01.png`/`.paper.01.png` — with `ACADSHARP_IMAGE_UPDATE_BASELINES=1`. `Subaru-Logo-Vector-Free-Wrap.model.01.png` is splines only and cannot move.** SVG goldens cannot move: the SVG backend does not read `Dpi`. No test sets a non-default `Dpi` today (the only `Dpi =` in the test tree is `Dpi = 72f` inside a `SixLabors` measuring option, `ACadSharp.Image.Tests/SvgTextLayoutTests.cs:112`), so nothing currently asserts the buggy behaviour. + +**Recommendation — effort S.** Make the change and add a regression test that renders the same text at `Dpi = 96` and `Dpi = 300` and asserts the glyph bounding box in pixels is unchanged. + +### 4.2 Single-line vertical shift when `LineSpacing ≠ 1` + +**Status (2026-09-03):** implemented in plan 08 (docs/superpowers/plans/2026-09-03-08-remaining-entities.md). + +**How SixLabors distributes the extra leading.** In `LayoutLineHorizontal` *(v2.1.3 source, `src/SixLabors.Fonts/TextLayout.cs:316-348`)*: + +```csharp +float lineHeight = textLine.ScaledMaxLineHeight; +float advanceY = lineHeight * options.LineSpacing; +float offsetY = (advanceY - lineHeight) * .5F; // half the extra leading +float yLineAdvance = advanceY - offsetY; +if (isFirstLine) { + case VerticalAlignment.Center: for (…) offsetY -= …ScaledMaxLineHeight * options.LineSpacing * .5F; break; + case VerticalAlignment.Bottom: for (…) offsetY -= …ScaledMaxLineHeight * options.LineSpacing; break; +} +penLocation.Y += offsetY; +``` + +So the extra leading `lineHeight × (LineSpacing − 1)` is split **half above and half below** every line — which is why even a single line moves. Measured at em `= 13.3333` px *(probe, `MeasureBounds("Hg")` y-origin)*: + +| `LineSpacing` | `VerticalAlignment.Top` | `.Bottom` | `.Center` | +| --- | --- | --- | --- | +| 1.0 | 1.8978 | −11.4355 | −4.7689 | +| 1.25 | 3.5644 (`+em/8`) | −13.1022 (`−em/8`) | −4.7689 (unchanged) | +| 2.5 | 11.8978 (`+0.75·em`) | −21.4355 (`−0.75·em`) | −4.7689 (unchanged) | +| 0.5 | −1.4355 (`−0.25·em`) | −8.1022 (`+0.25·em`) | −4.7689 (unchanged) | + +Top shifts **down** by half the extra leading, Bottom shifts **up** by half, Center is unaffected. That is exactly the shape of the compensation in `RasterDrawingSurface.DrawText` (`:194-201`): `-halfLeading` for `Hanging`, `+halfLeading` for `Alphabetic`, `0` for `Central`. + +**The bug.** The magnitude is only right at spacing factor 1. The code computes `halfLeading = factor * em / 8` (`:195`, with `LineSpacing = factor * 5/4` at `:220`), but the shift SixLabors actually applies is `em × (LineSpacing − 1) / 2 = em × (5·factor − 4) / 8`. The two agree **only** when `factor = 1` (both `em/8`). For an MTEXT whose `LineSpacing` factor is, say, 2, SixLabors shifts by `0.75·em` while the renderer compensates `0.25·em`, leaving a `0.5·em` residual displacement of the whole block. `MText.LineSpacing` is a real DXF value (group 44) that this repo passes straight through (`TextRenderer.cs:59`), so any drawing with non-default MTEXT line spacing is misplaced today. + +**Cleanest fix, smallest diff.** Correct the formula: + +```csharp +double halfLeading = ((factor * 5d / 4d) - 1d) * emPx / 8d * 4d; // = emPx * (5*factor - 4) / 8 +``` + +or, written directly from the mechanism, `halfLeading = emPx * (lineSpacing - 1d) / 2d` where `lineSpacing` is the value handed to `TextOptions`. At `factor = 1` this is `emPx/8` — byte-identical to today. + +**The structurally cleaner alternative** (and the one that removes the whole class of problem) is to stop asking SixLabors to lay out multiple lines at all: reuse `SvgTextLayout.Wrap` to split and wrap the text (it already exists, is already used by the SVG backend, and is already tested), then draw **each line separately** with `LineSpacing = 1` at an origin advanced by `SvgTextLayout.LineHeight(height, factor)`. That makes the two backends share one line-breaking and one line-advance implementation, removes the `5/4` fudge factor and the half-leading correction entirely, and removes the divergence risk between raster and SVG wrapping. Cost: `DrawText` becomes a loop, and the per-line vertical anchoring has to be derived from `SvgTextLayout.BlockOffset` instead of `TextOptions.VerticalAlignment`. + +**Baseline impact.** The formula fix changes nothing at `factor = 1`, and no golden uses a non-unit factor — the synthetic `features` sample's only multi-line text is `new MText { Value = "Line1\\PLine2", … }` with the default spacing (`ACadSharp.Image.Tests/SyntheticSamples.cs:120`), and the sample drawings contain 2 multi-line MTEXTs out of 8 (`Samples/HSK80AHCP16190M_BMG.dwg`) *(probe)*, again at the default factor. The per-line-drawing refactor **would** move multi-line PNG baselines (`features.model.01.png` and `HSK80AHCP16190M_BMG.model.01.png`), because ImageSharp's block layout and a manual per-line advance will not agree to the pixel. + +**Recommendation.** Formula fix: **effort S, no baseline change** — do it. Per-line refactor: **effort M, moves multi-line PNG baselines** — worth doing only alongside the section 4.1 change, so both text baselines are regenerated once. + +--- + +## 5. Incidental findings + +Two things surfaced while verifying the above; neither was asked for, both are cheap to fix and adjacent to the work. + +1. **ATTDEF default values are drawn inside every `Insert`.** **Status (2026-09-03):** implemented in plan 08 (docs/superpowers/plans/2026-09-03-08-remaining-entities.md). `BlockRecord.AttributeDefinitions` is `this.Entities.OfType()` *(v3.7.1 source, `Tables/BlockRecord.cs:72-76`)* — ATTDEFs live in `Block.Entities`, so `Insert.Explode()` yields them, and a probe confirms an `AttributeDefinition` clone comes back among the exploded entities *(probe)*. `AttributeDefinition : AttributeBase : TextEntity`, so `EntityRenderDispatcher`'s `case TextEntity textEntity:` (`:141-143`) draws its `Value` (the ATTDEF's *default* string, DXF group 1) at the ATTDEF's position, for every insert of that block. AutoCAD does not: a non-`Constant` ATTDEF is replaced by the insert's ATTRIB and is not displayed. Fix: skip `AttributeDefinition` in the explode loop (adding `case AttributeDefinition:` before `case TextEntity:` with a `continue`), and draw `Constant` ATTDEFs only. This pairs naturally with [section 2](#2-insert-attributes-attrib). Effort S; no sample or golden contains an ATTDEF *(probe)*, so no baseline moves. +2. **`MLine.Clone()` destroys the source in 3.7.1.** Detailed in [section 1.2](#12-mline-acdbmline). Verified by probe. Relevant even before MLINE rendering exists, because `Insert.Explode()` clones every block entity — an MLINE inside a block is silently emptied for the rest of the process's lifetime. + +--- + +## 6. Sources + +**This repository (working tree at `8ec2894`)** + +- `ACadSharp.Image/Rendering/EntityRenderDispatcher.cs` — type routing and the `default:` NotImplemented arm (`:153-155`), `DrawSolid` OCS handling (`:239-255`), `DrawBlockContents` explode pairing (`:393-413`), `case TextEntity` (`:141-143`). +- `ACadSharp.Image/Rendering/RasterDrawingSurface.cs` — `DrawText` (`:176-232`), `TextOptions.Dpi` (`:205`), `halfLeading` (`:194-201`), `LineSpacing` (`:220`), `CreateFont` (`:279-282`), `FillPolygon` (`:137-147`). +- `ACadSharp.Image/Rendering/Svg/SvgDrawingSurface.cs` — background `` gating (`:62-79`), `DrawText` (`:374-436`), `BeginViewport` clip (`:438-453`). +- `ACadSharp.Image/Rendering/TextMetrics.cs` — `CapHeightToEm`/`EmSize` (moved there by plan 08; `SvgTextLayout.EmSize` forwards). +- `ACadSharp.Image/Rendering/Svg/SvgTextLayout.cs` — `LineHeight` (`:23-24`), `BlockOffset` (`:30-35`), `Wrap` (`:55-83`), measuring `Dpi = 72f` (`:64`). +- `ACadSharp.Image/Rendering/TextRenderer.cs` — MTEXT/TEXT transform rationale (`:22-26`, `:72-76`), `GetTextOrigin` (`:209-214`), `LineSpacing` pass-through (`:59`). +- `ACadSharp.Image/Rendering/FontResolver.cs` — the 1-point clamp (`:77-80`). +- `ACadSharp.Image/Rendering/SplineRenderer.cs` — fit-points fallback (`:64-77`). +- `ACadSharp.Image/Rendering/EntityVisibilityFilter.cs` — visibility precedence (`:26-68`). +- `ACadSharp.Image/Rendering/ImageStyle.cs` — record struct, `EffectiveColor` (`:16-26`). +- `ACadSharp.Image/Rendering/ImageRenderContext.cs` — `ToSurfaceLength`/`Scale` (`:387-392`). +- `ACadSharp.Image/ImageConfiguration.cs` — `Dpi` default (`:148`), `GetLineWeightPixels` (`:360`), `BackgroundColor` (`:210`), `ResolveForegroundColor` (`:538-545`). +- `ACadSharp.Image/ImagePage.cs:95,105` and `ACadSharp.Image/ImageExporter.cs:110` — entity draw order is `block.Entities` order. +- `ACadSharp.Image.Tests/GoldenAssert.cs:26-53` — pixel-exact PNG comparison and text-exact SVG comparison; `ACADSHARP_IMAGE_UPDATE_BASELINES=1` regenerates. +- `ACadSharp.Image.Tests/SyntheticSamples.cs:118,120,121` — the `features` sample's `Insert`, two-line `MText` and Fit-aligned `TextEntity`. +- `ACadSharp.Image.Tests/Baselines/` — the twelve baseline/golden files listed in section 4.1. + +**ACadSharp 3.7.1 — pinned package** + +- `~/.nuget/packages/acadsharp/3.7.1/lib/net10.0/ACadSharp.dll` (reflected public surface) and `.../ACadSharp.xml` (XML docs) for: `T:ACadSharp.Entities.Face3D`, `T:ACadSharp.Entities.InvisibleEdgeFlags`, `T:ACadSharp.Entities.MLine` and its nested `Vertex`/`Vertex.Segment`, `T:ACadSharp.Objects.MLineStyle` and `MLineStyle.Element`, `T:ACadSharp.Entities.MLineJustification`, `T:ACadSharp.Entities.MLineFlags`, `T:ACadSharp.Objects.MLineStyleFlags`, `T:ACadSharp.Entities.Wipeout`, `T:ACadSharp.Entities.CadWipeoutBase`, `T:ACadSharp.Entities.ClipType`, `T:ACadSharp.Entities.ClipMode`, `T:ACadSharp.Entities.ImageDisplayFlags`, `T:ACadSharp.Entities.Leader`, `T:ACadSharp.Entities.LeaderPathType`, `T:ACadSharp.Entities.LeaderCreationType`, `T:ACadSharp.Entities.HookLineDirection`, `P:ACadSharp.Entities.Insert.Attributes`, `M:ACadSharp.Entities.Insert.Explode`, `M:ACadSharp.Entities.Insert.GetTransform`, `T:ACadSharp.Entities.AttributeEntity`, `T:ACadSharp.Entities.AttributeBase`, `T:ACadSharp.Entities.AttributeDefinition`, `T:ACadSharp.Entities.AttributeFlags`, `T:ACadSharp.Entities.AttributeType`, `P:ACadSharp.Header.CadHeader.AttributeVisibility`, `T:ACadSharp.Header.AttributeVisibilityMode`, `P:ACadSharp.Tables.DimensionStyle.ArrowSize`, `P:ACadSharp.Tables.DimensionStyle.LeaderArrow`, `P:ACadSharp.Tables.DimensionStyle.ScaleFactor`. + +**ACadSharp source at tag `v3.7.1`** (https://github.com/DomCR/ACadSharp/blob/v3.7.1/…) + +- `src/ACadSharp/Entities/Insert.cs:225-246` (ctor creates and transforms attributes), `:320-359` (`Explode`), `:380-…` (`GetTransform`), `:411-436` (`UpdateAttributes`). +- `src/ACadSharp/Entities/TextEntity.cs:135` (`virtual VerticalAlignment`), `:159-285` (`ApplyTransform`, no `AlignmentPoint`). +- `src/ACadSharp/Entities/MText.cs:169-175` (get-only `Rotation` derived from `AlignmentPoint`), `:233-325` (`ApplyTransform`, discards `newRotation`). +- `src/ACadSharp/Entities/AttributeBase.cs:63-64` (`override VerticalAlignment`). +- `src/ACadSharp/Entities/Face3D.cs:27-75`. +- `src/ACadSharp/Entities/MLine.cs:63-93` (`Style`), `:108-121` (destructive `Clone`). +- `src/ACadSharp/Entities/MLine.Vertex.cs:9-63` (`Position`/`Direction`/`Miter`/`Segments`, destructive `Clone`). +- `src/ACadSharp/Entities/CadWipeoutBase.cs:45-53` (`ClipBoundaryVertices` + doc), `:174-185` (`UVector`/`VVector`/`Size`), `:215-220` (`ApplyTransform`), `:230-244` (`GetBoundingBox`, ignores U/V). +- `src/ACadSharp/Entities/Leader.cs:61-72` (computed `HasHookline`), `:113-128` (`Style`), `:159-169` (`ApplyTransform`). +- `src/ACadSharp/CadObject.cs:140-155` (`Clone` zeroes `Handle`). +- `src/ACadSharp/Tables/BlockRecord.cs:72-76` (`AttributeDefinitions`), `:113-118` (`Entities`), `:243-251`, `:470-486` (`SortEntitiesTable`, `GetSortedEntities`). +- `src/ACadSharp/IO/DXF/DxfStreamReader/DxfSectionReaderBase.cs:231-234`, `:1515-1536` (WIPEOUT/IMAGE read path). +- `src/ACadSharp/IO/DWG/DwgStreamReaders/DwgObjectReader.cs:1217-1233`, `:5608`, `:5655` (WIPEOUT/IMAGE read path). + +**ACadSharp `master` (unreleased, read 2026-09-03)** — `src/ACadSharp/Entities/TextEntity.cs`, `MText.cs`, `MLine.cs`, `MLine.Vertex.cs`, `CadWipeoutBase.cs`; `MLine.Clone` fixed, everything else unchanged. Release list from https://api.github.com/repos/DomCR/ACadSharp/releases (latest `v3.7.1`, 2026-08-18). + +**Autodesk** — *AutoCAD 2012 DXF Reference*, https://images.autodesk.com/adsk/files/autocad_2012_pdf_dxf-reference_enu.pdf: *3dface group codes* (p. 63-64), *Mline group codes* (p. 104-106), *Wipeout group codes* (p. 155) and *Image group codes* (p. 95-96), *Leader group codes* (p. 98-99), *Attrib group codes* (p. 72-73). + +**SixLabors.Fonts 2.1.3** + +- `~/.nuget/packages/sixlabors.fonts/2.1.3/lib/net6.0/SixLabors.Fonts.xml` — `P:SixLabors.Fonts.Font.Size`, `P:SixLabors.Fonts.FontMetrics.ScaleFactor`, `P:SixLabors.Fonts.TextOptions.Dpi`, `P:SixLabors.Fonts.TextOptions.LineSpacing`, `P:SixLabors.Fonts.TextOptions.WrappingLength`. +- https://github.com/SixLabors/Fonts/blob/v2.1.3/src/SixLabors.Fonts/TextLayout.cs — `:196` (`Origin / Dpi`), `:316-348` (leading split, `offsetY`), `:933` (`WrappingLength / Dpi`), `:1138-1148` (`lineHeight = UnitsPerEm * pointSize / ScaleFactor`). + +**Probes** — throwaway .NET 10 console projects in the session scratchpad, referencing the pinned `ACadSharp 3.7.1` and `SixLabors.Fonts 2.1.3`: (a) entity-type census of the three `Samples/` drawings; (b) `MLine.Clone()` destructiveness; (c) `Insert.Explode()` ordering, clone handles, TEXT `AlignmentPoint` and MTEXT `Rotation` after `ApplyTransform`; (d) DXF and DWG write→read round trip of `MLine`/`Face3D`/`Leader`/`Wipeout`/`Insert`+ATTRIB; (e) `TextMeasurer.MeasureBounds` sweeps for the `Dpi`/em-size equivalence, the `LineSpacing` offsets and the float-rounding comparison. + +## 7. Not verified + +1. **The `+0.5` half-pixel offset in the wipeout boundary mapping** is *derived* from the DXF Reference's documented default boundary `(-0.5,-0.5) … (size.x-0.5, size.y-0.5)`, not stated outright by Autodesk. Likewise the **sign of `VVector`** (the reference says it "points along the visual left side of the image, starting at the insertion point", which reads as upward from a lower-left insertion point) matters only for asymmetric polygonal boundaries. Both should be checked against a real drawing containing a rotated or polygonal WIPEOUT before the mapping is trusted; no such drawing exists under `Samples/`. +2. **The WIPEOUT read path was not exercised end to end.** ACadSharp 3.7.1's writers drop `Wipeout`, so the round-trip probe could not produce one; the conclusion that both readers support it rests on the reader source cited above, not on a parsed file. +3. **Whether the section 4.1 change leaves the PNG baselines byte-identical.** The measured end-to-end divergence is ≤ `6.1e-4` px, which should not flip an antialiased pixel, but `GoldenAssert.Png` is exact and the only way to know is to run `FeatureGoldenTests` and `SampleParityTests`. No test run was performed for this note. +4. **AutoCAD's exact default arrowhead proportions** (the `size/6` half-width used in section 1.4) come from the conventional closed-filled arrowhead geometry, not from a cited Autodesk statement; the DXF Reference documents `DIMASZ` as the arrow *size* only. +5. **That a 3DFACE plots unfilled.** The DXF Reference documents the corners and the invisible-edge flags but says nothing about fill; "edges only in a 2D/wireframe view" is standard AutoCAD behaviour and is implied by the per-edge invisibility flags, but no Autodesk statement was located. +6. **MLINE cap and joint rendering** (`StartRoundCap`, `EndInnerArcsCap`, `DisplayJoints`, `StartAngle`/`EndAngle`) is described from the flag names and the DXF Reference's group-code list; no primary source was found that specifies the exact cap geometry. diff --git a/docs/superpowers/plans/2026-09-02-01-drawing-surface-foundation.md b/docs/superpowers/plans/2026-09-02-01-drawing-surface-foundation.md new file mode 100644 index 0000000..9d565f9 --- /dev/null +++ b/docs/superpowers/plans/2026-09-02-01-drawing-surface-foundation.md @@ -0,0 +1,2276 @@ +# Drawing Surface Foundation Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Put a backend-neutral drawing-surface interface between entity decomposition and ImageSharp, with pixel-identical raster output, an abstract rendered-page API, ACadSharp 3.7.1, and net6.0 dropped. + +**Architecture:** `EntityRenderDispatcher`, `SplineRenderer` and `TextRenderer` stop calling ImageSharp and instead call `IDrawingSurface` primitives through a backend-neutral `ImageRenderContext`. `RasterDrawingSurface` reproduces today's ImageSharp calls exactly (same float rounding sequence) so committed baseline PNGs stay byte-identical. `Render()` returns abstract `RenderedPage` objects that know how to save themselves. + +**Tech Stack:** .NET 8/10, C# latest, ACadSharp 3.7.1, SixLabors.ImageSharp 3.1.12, SixLabors.ImageSharp.Drawing 2.1.7, SixLabors.Fonts 2.1.3, xUnit 2.9. + +**Spec:** `docs/superpowers/specs/2026-09-02-layers-and-svg-design.md` (sections 3, 6, 9). Read the interface appendix (section 9) before starting; every name below comes from it. + +## Global Constraints + +- ACadSharp `3.7.1`; library targets `net8.0;net10.0`; CLI and tests `net10.0`; no new NuGet packages. +- Raster output after this plan must be byte-identical to the baselines committed in Task 1 for the default configuration. +- Repo style: `this.` on members, explicit types except lambdas, XML docs on public members, `sealed` classes, file-scoped namespaces, rendering internals are `internal` (tests see them through `InternalsVisibleTo`). +- Run all commands from `/work/workspaces/orca/ACadSharp.Image/svg-support`. Never `cd` elsewhere. +- Commit after every task. Commit message style in this repo is a short imperative line (`Add Insert entity support`). Every commit ends with: + +``` +Co-Authored-By: Claude Fable 5.1 +Claude-Session: https://claude.ai/code/session_016RGgSinQSUz4d89FRLxhMz +``` + +- Do not commit `.codegraph/`. Commit `docs/` (research note, spec, plans) with Task 1. +- Test command: `dotnet test ACadSharp.Image.sln -c Release --nologo -v q`. Filter one test with `--filter "FullyQualifiedName~"`. + +## File Structure + +| File | Responsibility | Task | +| --- | --- | --- | +| `ACadSharp.Image.Tests/SampleParityTests.cs` (create) | Renders `Samples/` with a fixed configuration and compares against committed PNG baselines byte-for-byte | 1 | +| `ACadSharp.Image.Tests/Baselines/*.png` (create) | Baselines generated **before** the refactor | 1 | +| `ACadSharp.Image.Tests/ACadSharp.Image.Tests.csproj` (modify) | Copy baselines to output | 1 | +| `Directory.Packages.props`, `ACadSharp.Image/ACadSharp.Image.csproj`, `.github/workflows/ci.yml`, `.github/workflows/release.yml` (modify) | ACadSharp 3.7.1, drop net6.0, package description | 2 | +| `ACadSharp.Image/Rendering/SurfacePoint.cs` (create) | `SurfacePoint`, `SurfaceRect` | 3 | +| `ACadSharp.Image/Rendering/ImageStyle.cs` (modify) | Full style record with dash pattern and opacity | 3 | +| `ACadSharp.Image/Rendering/SurfaceText.cs` (create) | `SurfaceText`, `SurfaceTextAnchor`, `SurfaceTextBaseline` | 3 | +| `ACadSharp.Image/Rendering/EntityRenderInfo.cs` (create) | `EntityRenderInfo`, `LayerRenderInfo` | 3 | +| `ACadSharp.Image/Rendering/IDrawingSurface.cs` (create) | The interface plus `ViewportSurface` | 3 | +| `ACadSharp.Image/Rendering/CurveTessellation.cs` (create) | Arc and bulge tessellation helpers shared by backends | 3 | +| `ACadSharp.Image/Rendering/RasterDrawingSurface.cs` (create) | ImageSharp implementation | 3 | +| `ACadSharp.Image.Tests/RasterDrawingSurfaceTests.cs` (create) | Pixel tests for the raster surface, tessellation tests | 3 | +| `ACadSharp.Image/Rendering/ImageRenderContext.cs` (modify) | Backend-neutral transform; single-precision mode for raster parity | 4 | +| `ACadSharp.Image/Rendering/ImageStyleResolver.cs` (modify) | Takes the context to resolve stroke width | 4 | +| `ACadSharp.Image/Rendering/EntityRenderDispatcher.cs`, `SplineRenderer.cs`, `TextRenderer.cs`, `ImagePageRenderer.cs` (modify) | Draw through the surface | 4 | +| `ACadSharp.Image.Tests/ImageExporterTests.cs` (modify) | Adapt the two tests that build a context by hand | 4 | +| `ACadSharp.Image/RenderedPage.cs` (create), `RenderedImagePage.cs` (modify), `ImageExporter.cs` (modify) | Abstract rendered page, `Render(format)`, `Save` via page | 5 | +| `ACadSharp.Image/Rendering/EntityRenderDispatcher.cs` (modify), `ACadSharp.Image.Tests/RecordingDrawingSurface.cs` (create), `ACadSharp.Image.Tests/EntityRenderDispatcherTests.cs` (create) | `BeginEntity`/`EndEntity` with effective layer | 6 | + +--- + +### Task 1: Parity baselines from the pre-refactor renderer + +**Files:** +- Create: `ACadSharp.Image.Tests/SampleParityTests.cs` +- Create: `ACadSharp.Image.Tests/Baselines/` (generated PNGs) +- Modify: `ACadSharp.Image.Tests/ACadSharp.Image.Tests.csproj` + +**Interfaces:** +- Consumes: today's `ImageExporter.Render()` returning `IReadOnlyList` with `Canvas`. +- Produces: `SampleParityTests.RenderSample(string fileName, bool paperLayouts)` helper and the baseline files later tasks must keep green. + +- [ ] **Step 1: Check the pinned font exists** + +Run: `fc-list : family | grep -i "DejaVu Sans"` +Expected: at least one line containing `DejaVu Sans`. If absent, install `dejavu-sans-fonts` (Fedora) before continuing; the baselines depend on it. + +- [ ] **Step 2: Write the parity test** + +Create `ACadSharp.Image.Tests/SampleParityTests.cs`: + +```csharp +using ACadSharp.IO; +using SixLabors.Fonts; +using SixLabors.ImageSharp; +using SixLabors.ImageSharp.Formats.Png; +using SixLabors.ImageSharp.PixelFormats; + +namespace ACadSharp.Image.Tests; + +/// +/// Renders the files in Samples/ with a fixed configuration and compares the result +/// byte-for-byte with the PNGs in Baselines/. Set the environment variable +/// ACADSHARP_IMAGE_UPDATE_BASELINES=1 to rewrite the baselines instead of comparing. +/// +public sealed class SampleParityTests +{ + private const string FontFamily = "DejaVu Sans"; + + public static TheoryData Samples => new() + { + { "6-57-1119.dxf", false }, + { "HSK80AHCP16190M_BMG.dwg", false }, + { "HSK80AHCP16190M_BMG.dwg", true }, + { "Subaru Logo Vector Free Wrap.dxf", false }, + }; + + [Theory] + [MemberData(nameof(Samples))] + public void SampleRendersMatchBaselines(string fileName, bool paperLayouts) + { + Assert.True(SystemFonts.TryGet(FontFamily, out _), $"Font '{FontFamily}' must be installed for parity tests."); + + string repoRoot = FindRepoRoot(); + string samplePath = Path.Combine(repoRoot, "Samples", fileName); + string baselineDirectory = Path.Combine(repoRoot, "ACadSharp.Image.Tests", "Baselines"); + Directory.CreateDirectory(baselineDirectory); + + bool update = Environment.GetEnvironmentVariable("ACADSHARP_IMAGE_UPDATE_BASELINES") == "1"; + string baseName = Path.GetFileNameWithoutExtension(fileName).Replace(' ', '-') + (paperLayouts ? ".paper" : ".model"); + + IReadOnlyList> rendered = RenderSample(samplePath, paperLayouts); + try + { + for (int i = 0; i < rendered.Count; i++) + { + string baselinePath = Path.Combine(baselineDirectory, $"{baseName}.{i + 1:D2}.png"); + if (update) + { + rendered[i].Save(baselinePath, new PngEncoder()); + continue; + } + + Assert.True(File.Exists(baselinePath), $"Missing baseline {baselinePath}. Run with ACADSHARP_IMAGE_UPDATE_BASELINES=1 to create it."); + using Image baseline = Image.Load(baselinePath); + AssertPixelsEqual(baseline, rendered[i], baselinePath); + } + } + finally + { + foreach (Image image in rendered) + { + image.Dispose(); + } + } + } + + internal static IReadOnlyList> RenderSample(string samplePath, bool paperLayouts) + { + CadDocument document = Path.GetExtension(samplePath).ToLowerInvariant() == ".dwg" + ? DwgReader.Read(samplePath) + : DxfReader.Read(samplePath); + + ImageExporter exporter = new(); + exporter.Configuration.Width = 800; + exporter.Configuration.Height = 500; + exporter.Configuration.SetPadding(10); + exporter.Configuration.FontFamilyName = FontFamily; + + if (paperLayouts) + { + exporter.AddPaperLayouts(document); + } + else + { + exporter.AddModelSpace(document); + } + + List> images = new(); + foreach (RenderedImagePage page in exporter.Render()) + { + images.Add(page.Canvas); + } + + return images; + } + + internal static void AssertPixelsEqual(Image expected, Image actual, string label) + { + Assert.Equal(expected.Width, actual.Width); + Assert.Equal(expected.Height, actual.Height); + + Rgba32[] expectedPixels = new Rgba32[expected.Width * expected.Height]; + Rgba32[] actualPixels = new Rgba32[actual.Width * actual.Height]; + expected.CopyPixelDataTo(expectedPixels); + actual.CopyPixelDataTo(actualPixels); + + int firstDifference = -1; + for (int i = 0; i < expectedPixels.Length; i++) + { + if (expectedPixels[i] != actualPixels[i]) + { + firstDifference = i; + break; + } + } + + Assert.True(firstDifference < 0, $"{label}: first differing pixel at index {firstDifference} (x={firstDifference % expected.Width}, y={firstDifference / expected.Width}); expected {expectedPixels[Math.Max(0, firstDifference)]} actual {actualPixels[Math.Max(0, firstDifference)]}."); + } + + internal static string FindRepoRoot() + { + string? directory = AppContext.BaseDirectory; + while (directory != null) + { + if (File.Exists(Path.Combine(directory, "ACadSharp.Image.sln"))) + { + return directory; + } + + directory = Path.GetDirectoryName(directory); + } + + throw new InvalidOperationException("Could not locate the repository root (ACadSharp.Image.sln)."); + } +} +``` + +Note: the `RenderSample` loop iterates `RenderedImagePage`; Task 5 changes this to `Assert.IsType(page)` over `RenderedPage`. + +- [ ] **Step 3: Generate the baselines with the current renderer** + +Run: `ACADSHARP_IMAGE_UPDATE_BASELINES=1 dotnet test ACadSharp.Image.sln -c Release --nologo -v q --filter "FullyQualifiedName~SampleParityTests"` +Expected: PASS, and `ls ACadSharp.Image.Tests/Baselines` shows at least `6-57-1119.model.01.png`, `HSK80AHCP16190M_BMG.model.01.png`, `Subaru-Logo-Vector-Free-Wrap.model.01.png`. The `.paper.` files exist only if the DWG has paper layouts with content; zero pages is acceptable. + +- [ ] **Step 4: Verify the comparison passes without the update flag** + +Run: `dotnet test ACadSharp.Image.sln -c Release --nologo -v q --filter "FullyQualifiedName~SampleParityTests"` +Expected: PASS (4 test cases). + +- [ ] **Step 5: Verify the test really compares** + +Temporarily change `exporter.Configuration.Width = 800;` to `801` in `RenderSample`, run the same command, expect FAIL on the width assertion, then revert to `800`. + +- [ ] **Step 6: Commit** + +```bash +git add ACadSharp.Image.Tests/SampleParityTests.cs ACadSharp.Image.Tests/Baselines docs +git commit -m "Add sample parity baselines and design docs + +Co-Authored-By: Claude Fable 5.1 +Claude-Session: https://claude.ai/code/session_016RGgSinQSUz4d89FRLxhMz" +``` + +--- + +### Task 2: ACadSharp 3.7.1, drop net6.0, workflows + +**Files:** +- Modify: `Directory.Packages.props:7` +- Modify: `ACadSharp.Image/ACadSharp.Image.csproj:4,10,11` +- Modify: `.github/workflows/ci.yml`, `.github/workflows/release.yml` (the `dotnet-version` lists) + +- [ ] **Step 1: Bump ACadSharp** + +In `Directory.Packages.props` change `` to `Version="3.7.1"`. + +- [ ] **Step 2: Drop net6.0 and update package metadata** + +In `ACadSharp.Image/ACadSharp.Image.csproj`: +- `net6.0;net8.0;net10.0` becomes `net8.0;net10.0`. +- `` becomes `Raster and SVG exporter for ACadSharp DXF and DWG documents.` +- `` becomes `acadsharp;cad;dxf;dwg;imagesharp;svg;rendering`. + +- [ ] **Step 3: Remove 6.0.x from both workflows** + +In `.github/workflows/ci.yml` and in the `publish-packages` job of `.github/workflows/release.yml`, delete the line ` 6.0.x` so each `dotnet-version` block lists only `8.0.x` and `10.0.x` (the `publish-native-cli` job already lists only `10.0.x`). + +- [ ] **Step 4: Build and test** + +Run: `dotnet build ACadSharp.Image.sln -c Release --nologo -v q 2>&1 | grep -E "error|Warn|warn" ; dotnet test ACadSharp.Image.sln -c Release --nologo -v q` +Expected: no `CodePages ... net6.0` warning anymore, `0 Error(s)`, all tests pass including the parity theory. + +- [ ] **Step 5: Commit** + +```bash +git add Directory.Packages.props ACadSharp.Image/ACadSharp.Image.csproj .github/workflows/ci.yml .github/workflows/release.yml +git commit -m "Update ACadSharp to 3.7.1 and drop net6.0 + +Co-Authored-By: Claude Fable 5.1 +Claude-Session: https://claude.ai/code/session_016RGgSinQSUz4d89FRLxhMz" +``` + +--- + +### Task 3: Surface types, interface, tessellation helper, raster surface + +**Files:** +- Create: `ACadSharp.Image/Rendering/SurfacePoint.cs` +- Modify: `ACadSharp.Image/Rendering/ImageStyle.cs` +- Create: `ACadSharp.Image/Rendering/SurfaceText.cs` +- Create: `ACadSharp.Image/Rendering/EntityRenderInfo.cs` +- Create: `ACadSharp.Image/Rendering/IDrawingSurface.cs` +- Create: `ACadSharp.Image/Rendering/CurveTessellation.cs` +- Create: `ACadSharp.Image/Rendering/RasterDrawingSurface.cs` +- Test: `ACadSharp.Image.Tests/RasterDrawingSurfaceTests.cs` + +**Interfaces:** +- Produces (verbatim from spec section 9): `SurfacePoint`, `SurfaceRect`, `ImageStyle(StrokeColor, StrokeWidth, DashPattern, Opacity)`, `SurfaceText`, `SurfaceTextAnchor`, `SurfaceTextBaseline`, `EntityRenderInfo`, `LayerRenderInfo`, `ViewportSurface`, `IDrawingSurface`, `CurveTessellation.ArcPoints(...)`, `CurveTessellation.BulgeArc(...)`, `RasterDrawingSurface(Image canvas, ImageConfiguration configuration, bool ownsCanvas)`. + +- [ ] **Step 1: Write the failing surface tests** + +Create `ACadSharp.Image.Tests/RasterDrawingSurfaceTests.cs`: + +```csharp +using ACadSharp.Image.Rendering; +using SixLabors.ImageSharp; +using SixLabors.ImageSharp.PixelFormats; + +namespace ACadSharp.Image.Tests; + +public sealed class RasterDrawingSurfaceTests +{ + private static readonly Rgba32 White = Color.White.ToPixel(); + + private static readonly Rgba32 Black = Color.Black.ToPixel(); + + [Fact] + public void DrawLinePaintsPixelsAlongTheLine() + { + using Image canvas = new(20, 20, Color.White); + using RasterDrawingSurface surface = new(canvas, new ImageConfiguration(), ownsCanvas: false); + + surface.DrawLine(new ImageStyle(Color.Black, 2f), new SurfacePoint(2, 10), new SurfacePoint(18, 10)); + + Assert.Equal(Black, canvas[10, 10]); + Assert.Equal(White, canvas[10, 2]); + } + + [Fact] + public void DrawPolylineClosedConnectsLastPointToFirst() + { + using Image canvas = new(20, 20, Color.White); + using RasterDrawingSurface surface = new(canvas, new ImageConfiguration(), ownsCanvas: false); + SurfacePoint[] points = [new(2, 2), new(18, 2), new(18, 18)]; + + surface.DrawPolyline(new ImageStyle(Color.Black, 2f), points, closed: true); + + // Closing edge runs from (18,18) back to (2,2): the midpoint (10,10) must be painted. + Assert.Equal(Black, canvas[10, 10]); + } + + [Fact] + public void FillPathUsesEvenOddRuleForHoles() + { + using Image canvas = new(40, 40, Color.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)]; + + surface.FillPath(new ImageStyle(Color.Black, 1f), [outer, hole]); + + Assert.Equal(Black, canvas[5, 5]); + Assert.Equal(White, canvas[20, 20]); + } + + [Fact] + public void OpacityBlendsWithBackground() + { + using Image canvas = new(10, 10, Color.White); + using RasterDrawingSurface surface = new(canvas, new ImageConfiguration(), ownsCanvas: false); + + surface.FillPolygon(new ImageStyle(Color.Black, 1f, null, 0.5f), [new(0, 0), new(10, 0), new(10, 10), new(0, 10)]); + + Rgba32 pixel = canvas[5, 5]; + Assert.InRange(pixel.R, 120, 135); + Assert.Equal(pixel.R, pixel.G); + Assert.Equal(255, pixel.A); + } + + [Fact] + public void DashPatternLeavesGaps() + { + using Image canvas = new(60, 10, Color.White); + using RasterDrawingSurface surface = new(canvas, new ImageConfiguration(), ownsCanvas: false); + + // 10 px dash, 10 px gap. + surface.DrawLine(new ImageStyle(Color.Black, 2f, [10f, 10f], 1f), new SurfacePoint(0, 5), new SurfacePoint(60, 5)); + + Assert.Equal(Black, canvas[5, 5]); + Assert.Equal(White, canvas[15, 5]); + Assert.Equal(Black, canvas[25, 5]); + } + + [Fact] + public void ViewportDrawsIntoChildAndCompositesAtBounds() + { + using Image canvas = new(40, 40, Color.White); + using RasterDrawingSurface surface = new(canvas, new ImageConfiguration(), ownsCanvas: false); + + ViewportSurface viewport = surface.BeginViewport(new SurfaceRect(20, 20, 10, 10)); + Assert.Equal(0d, viewport.OffsetX); + Assert.Equal(10d, viewport.BottomY); + + // Fill the whole child; only the 10x10 region at (20,20) may change on the page. + viewport.Surface.FillPolygon(new ImageStyle(Color.Black, 1f), [new(0, 0), new(10, 0), new(10, 10), new(0, 10)]); + surface.EndViewport(viewport); + + Assert.Equal(Black, canvas[25, 25]); + Assert.Equal(White, canvas[15, 15]); + Assert.Equal(White, canvas[35, 35]); + } + + [Fact] + public void ArcPointsStartAndEndOnTheArc() + { + IReadOnlyList points = CurveTessellation.ArcPoints(new SurfacePoint(0, 0), 10, 10, 0, 0, Math.PI / 2, 8); + + Assert.Equal(9, points.Count); + Assert.Equal(10, points[0].X, 6); + Assert.Equal(0, points[0].Y, 6); + Assert.Equal(0, points[^1].X, 6); + Assert.Equal(10, points[^1].Y, 6); + } + + [Fact] + public void BulgeArcOfOneIsASemicircle() + { + CurveTessellation.BulgeArc(new SurfacePoint(0, 0), new SurfacePoint(10, 0), 1d, out SurfacePoint center, out double radius, out double startAngle, out double sweep); + + Assert.Equal(5, center.X, 6); + Assert.Equal(0, center.Y, 6); + Assert.Equal(5, radius, 6); + Assert.Equal(-Math.PI, sweep, 6); + Assert.Equal(Math.PI, Math.Abs(startAngle), 6); + } + + [Fact] + public void PositiveBulgeBendsTowardPositiveYInSurfaceSpace() + { + // Drawing-space CCW arc from (0,0) to (10,0) passes below the chord; below is +Y on a Y-down surface. + CurveTessellation.BulgeArc(new SurfacePoint(0, 0), new SurfacePoint(10, 0), 0.5d, out SurfacePoint center, out double radius, out double startAngle, out double sweep); + + Assert.Equal(5, center.X, 6); + Assert.Equal(-3.75, center.Y, 6); + Assert.Equal(6.25, radius, 6); + Assert.True(sweep < 0); + + IReadOnlyList points = CurveTessellation.ArcPoints(center, radius, radius, 0, startAngle, sweep, 2); + Assert.Equal(5, points[1].X, 6); + Assert.Equal(2.5, points[1].Y, 6); + } +} +``` + +- [ ] **Step 2: Run the tests to verify they fail to compile** + +Run: `dotnet test ACadSharp.Image.sln -c Release --nologo -v q --filter "FullyQualifiedName~RasterDrawingSurfaceTests"` +Expected: build errors (`RasterDrawingSurface`, `SurfacePoint` not found). + +- [ ] **Step 3: Add the value types** + +Create `ACadSharp.Image/Rendering/SurfacePoint.cs`: + +```csharp +namespace ACadSharp.Image.Rendering; + +/// +/// A point in surface coordinates: pixels for the raster backend, drawing units for SVG. Y grows downward. +/// +internal readonly record struct SurfacePoint(double X, double Y); + +/// +/// An axis-aligned rectangle in surface coordinates. is the top edge. +/// +internal readonly record struct SurfaceRect(double X, double Y, double Width, double Height); +``` + +Replace `ACadSharp.Image/Rendering/ImageStyle.cs` with: + +```csharp +using ImageColor = SixLabors.ImageSharp.Color; + +namespace ACadSharp.Image.Rendering; + +/// +/// Resolved visual style for one entity. Widths and dash lengths are in surface units. +/// +/// Stroke and fill colour. +/// Stroke width in surface units. +/// Alternating dash and gap lengths in surface units, or for a solid stroke. +/// Opacity from 0 (invisible) to 1 (opaque). +internal readonly record struct ImageStyle(ImageColor StrokeColor, float StrokeWidth, float[]? DashPattern, float Opacity) +{ + public ImageStyle(ImageColor strokeColor, float strokeWidth) + : this(strokeColor, strokeWidth, null, 1f) + { + } + + /// + /// Gets the stroke colour with applied to its alpha channel. + /// + public ImageColor EffectiveColor => this.Opacity >= 1f ? this.StrokeColor : this.StrokeColor.WithAlpha(this.Opacity); +} +``` + +Create `ACadSharp.Image/Rendering/SurfaceText.cs`: + +```csharp +namespace ACadSharp.Image.Rendering; + +internal enum SurfaceTextAnchor +{ + Start, + Middle, + End, +} + +internal enum SurfaceTextBaseline +{ + Alphabetic, + Central, + Hanging, +} + +/// +/// Everything a backend needs to place a text run. +/// +/// Text with CAD control codes already expanded; may contain newlines. +/// Anchor point in surface units. +/// Text height (font size) in surface units. +/// Rotation in radians, counter-clockwise in drawing space. Backends negate it because surface Y points down. +/// Horizontal anchoring relative to . +/// Vertical anchoring relative to . +/// Wrap width in surface units; zero or negative disables wrapping. +/// Line spacing multiplier; 1.0 is single spacing. +/// Total advance the text must occupy in surface units; zero or negative means natural width. +internal sealed record SurfaceText( + string Text, + SurfacePoint Origin, + double Height, + double Rotation, + SurfaceTextAnchor Anchor, + SurfaceTextBaseline Baseline, + double WrappingWidth, + double LineSpacingFactor, + double FixedLength); +``` + +Create `ACadSharp.Image/Rendering/EntityRenderInfo.cs`: + +```csharp +using ImageColor = SixLabors.ImageSharp.Color; + +namespace ACadSharp.Image.Rendering; + +/// +/// Identifies the entity currently being drawn so structured backends can group and tag their output. +/// +/// Effective layer name (entities on layer "0" inside a block inherit the insert's layer). +/// DXF object name, e.g. LINE. +/// Entity handle. +/// Handle of the owning Insert or Dimension when drawing nested content. +/// Block name when drawing nested content of an Insert. +internal sealed record EntityRenderInfo(string LayerName, string EntityType, ulong Handle, ulong? ParentHandle, string? BlockName); + +/// +/// Layer defaults a structured backend may hoist onto a group element. +/// +internal sealed record LayerRenderInfo(string LayerName, ImageColor Color, float StrokeWidth); +``` + +Create `ACadSharp.Image/Rendering/IDrawingSurface.cs`: + +```csharp +namespace ACadSharp.Image.Rendering; + +/// +/// Result of opening a viewport: the surface to draw into and where its origin sits relative to the parent. +/// +/// Surface that receives the viewport contents. +/// X of the viewport's left edge in units. +/// Y of the viewport's bottom edge in units. +internal readonly record struct ViewportSurface(IDrawingSurface Surface, double OffsetX, double BottomY); + +/// +/// Backend-neutral drawing primitives. Coordinates are surface units with Y growing downward. +/// +internal interface IDrawingSurface : IDisposable +{ + /// + /// True when the backend draws arcs, ellipses and bulges natively; false when it wants tessellated polylines. + /// + bool SupportsCurves { get; } + + void BeginEntity(EntityRenderInfo info, LayerRenderInfo layer); + + void EndEntity(); + + void DrawLine(ImageStyle style, SurfacePoint start, SurfacePoint end); + + void DrawPolyline(ImageStyle style, IReadOnlyList points, bool closed); + + /// + /// Draws an elliptical arc. Angles are radians in surface space (already sign-adjusted for the Y flip); a positive sweep turns clockwise on screen. + /// + void DrawArc(ImageStyle style, SurfacePoint center, double radiusX, double radiusY, double rotation, double startAngle, double sweepAngle); + + void DrawEllipse(ImageStyle style, SurfacePoint center, double radiusX, double radiusY, double rotation); + + /// + /// Draws a chain of cubic Bezier segments given 3n+1 control points. + /// + void DrawCubicBezier(ImageStyle style, IReadOnlyList controlPoints, bool closed); + + /// + /// Draws a polyline whose segments may be circular arcs. [i] applies to the segment from points[i] to points[i+1]; 0 is straight. + /// + void DrawBulgePolyline(ImageStyle style, IReadOnlyList points, IReadOnlyList bulges, bool closed); + + void FillPolygon(ImageStyle style, IReadOnlyList points); + + /// + /// Fills several rings with the even-odd rule. + /// + void FillPath(ImageStyle style, IReadOnlyList> rings); + + void FillCircle(ImageStyle style, SurfacePoint center, double radius); + + void DrawText(ImageStyle style, SurfaceText text); + + /// + /// Opens a clipped viewport region. is the viewport rectangle in this surface's units. + /// + ViewportSurface BeginViewport(SurfaceRect bounds); + + void EndViewport(ViewportSurface viewport); +} +``` + +- [ ] **Step 4: Add the tessellation helper** + +Create `ACadSharp.Image/Rendering/CurveTessellation.cs`: + +```csharp +namespace ACadSharp.Image.Rendering; + +/// +/// Geometry helpers shared by backends that need arcs as points or bulges as arcs. +/// +internal static class CurveTessellation +{ + /// + /// Samples an elliptical arc into + 1 points. + /// + /// Centre in surface units. + /// Semi-axis along the rotated X axis. + /// Semi-axis along the rotated Y axis. + /// Rotation of the X axis in radians (surface space). + /// Start parameter in radians (surface space). + /// Signed sweep in radians (surface space). + /// Number of straight segments, at least 1. + public static IReadOnlyList ArcPoints(SurfacePoint center, double radiusX, double radiusY, double rotation, double startAngle, double sweepAngle, int segments) + { + segments = Math.Max(1, segments); + double cos = Math.Cos(rotation); + double sin = Math.Sin(rotation); + SurfacePoint[] points = new SurfacePoint[segments + 1]; + for (int i = 0; i <= segments; i++) + { + double angle = startAngle + (sweepAngle * i / segments); + double x = radiusX * Math.Cos(angle); + double y = radiusY * Math.Sin(angle); + points[i] = new SurfacePoint( + center.X + (x * cos) - (y * sin), + center.Y + (x * sin) + (y * cos)); + } + + return points; + } + + /// + /// Converts a polyline bulge into arc parameters in surface space. + /// + /// + /// Bulge is tan(theta/4) where theta is the included angle. A positive bulge is a counter-clockwise arc in the drawing + /// and still looks counter-clockwise on screen after the Y flip; but in surface coordinates (Y down) a visually + /// counter-clockwise turn is a decreasing angle, so a positive bulge yields a negative sweep here. + /// + public static void BulgeArc(SurfacePoint start, SurfacePoint end, double bulge, out SurfacePoint center, out double radius, out double startAngle, out double sweepAngle) + { + double chordX = end.X - start.X; + double chordY = end.Y - start.Y; + double chord = Math.Sqrt((chordX * chordX) + (chordY * chordY)); + double theta = 4d * Math.Atan(Math.Abs(bulge)); + radius = chord / (2d * Math.Sin(theta / 2d)); + + // Distance from the chord midpoint to the centre, along the chord normal. + double sagitta = radius * Math.Cos(theta / 2d); + double midX = (start.X + end.X) / 2d; + double midY = (start.Y + end.Y) / 2d; + double normalX = -chordY / chord; + double normalY = chordX / chord; + + // The arc bulges toward +normal for a positive bulge, so the centre sits on the -normal side. + double side = bulge > 0 ? -1d : 1d; + center = new SurfacePoint(midX + (side * sagitta * normalX), midY + (side * sagitta * normalY)); + startAngle = Math.Atan2(start.Y - center.Y, start.X - center.X); + sweepAngle = bulge > 0 ? -theta : theta; + } + + /// + /// Number of straight segments to use for an arc of radians when a full turn uses . + /// + public static int SegmentsForSweep(double sweepAngle, int fullCircleSegments) + { + return Math.Max(1, (int)Math.Ceiling(Math.Abs(sweepAngle) / (2d * Math.PI) * Math.Max(4, fullCircleSegments))); + } +} +``` + +- [ ] **Step 5: Add the raster surface** + +Create `ACadSharp.Image/Rendering/RasterDrawingSurface.cs`. The text code is moved verbatim from today's `TextRenderer` (font creation, options, rotation transform) so glyph output stays identical. + +```csharp +using System.Numerics; +using SixLabors.Fonts; +using SixLabors.ImageSharp; +using SixLabors.ImageSharp.Drawing; +using SixLabors.ImageSharp.Drawing.Processing; +using SixLabors.ImageSharp.Processing; +using ImageColor = SixLabors.ImageSharp.Color; +using ImagePoint = SixLabors.ImageSharp.Point; +using Rgba32 = SixLabors.ImageSharp.PixelFormats.Rgba32; + +namespace ACadSharp.Image.Rendering; + +/// +/// backed by an ImageSharp . +/// +/// +/// Every primitive maps onto the same ImageSharp.Drawing call the pre-abstraction renderer used, so output is pixel-identical. +/// Curves are not supported natively; callers tessellate them ( is false). +/// +internal sealed class RasterDrawingSurface : IDrawingSurface +{ + private readonly ImageConfiguration _configuration; + private readonly bool _ownsCanvas; + private readonly Dictionary Image, SurfaceRect Bounds)> _viewports = new(); + + public RasterDrawingSurface(Image canvas, ImageConfiguration configuration, bool ownsCanvas) + { + this.Canvas = canvas; + this._configuration = configuration; + this._ownsCanvas = ownsCanvas; + } + + public Image Canvas { get; } + + public bool SupportsCurves => false; + + public void BeginEntity(EntityRenderInfo info, LayerRenderInfo layer) + { + } + + 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))); + } + + public void DrawPolyline(ImageStyle style, IReadOnlyList points, bool closed) + { + if (points.Count < 2) + { + return; + } + + PointF[] pixels = new PointF[closed ? points.Count + 1 : points.Count]; + for (int i = 0; i < points.Count; i++) + { + pixels[i] = ToPointF(points[i]); + } + + if (closed) + { + pixels[^1] = pixels[0]; + } + + Pen pen = CreatePen(style); + this.Canvas.Mutate(x => x.DrawLine(pen, pixels)); + } + + public void DrawArc(ImageStyle style, SurfacePoint center, double radiusX, double radiusY, double rotation, double startAngle, double sweepAngle) + { + int segments = CurveTessellation.SegmentsForSweep(sweepAngle, this._configuration.ArcPrecision); + this.DrawPolyline(style, CurveTessellation.ArcPoints(center, radiusX, radiusY, rotation, startAngle, sweepAngle, segments), closed: false); + } + + public void DrawEllipse(ImageStyle style, SurfacePoint center, double radiusX, double radiusY, double rotation) + { + this.DrawPolyline(style, CurveTessellation.ArcPoints(center, radiusX, radiusY, rotation, 0d, 2d * Math.PI, this._configuration.ArcPrecision), closed: true); + } + + public void DrawCubicBezier(ImageStyle style, IReadOnlyList controlPoints, bool closed) + { + if (controlPoints.Count < 4) + { + return; + } + + PathBuilder builder = new(); + for (int index = 0; index + 3 < controlPoints.Count; index += 3) + { + builder.AddCubicBezier( + ToPointF(controlPoints[index]), + ToPointF(controlPoints[index + 1]), + ToPointF(controlPoints[index + 2]), + ToPointF(controlPoints[index + 3])); + } + + IPath path = builder.Build(); + Pen pen = CreatePen(style); + this.Canvas.Mutate(x => x.Draw(pen, path)); + } + + public void DrawBulgePolyline(ImageStyle style, IReadOnlyList points, IReadOnlyList bulges, bool closed) + { + if (points.Count < 2) + { + return; + } + + List flattened = new(points.Count * 4) { points[0] }; + int segmentCount = closed ? points.Count : points.Count - 1; + for (int i = 0; i < segmentCount; i++) + { + SurfacePoint start = points[i]; + SurfacePoint end = points[(i + 1) % points.Count]; + double bulge = i < bulges.Count ? bulges[i] : 0d; + if (Math.Abs(bulge) < 1e-12 || start == end) + { + flattened.Add(end); + continue; + } + + CurveTessellation.BulgeArc(start, end, bulge, out SurfacePoint center, out double radius, out double startAngle, out double sweep); + IReadOnlyList arc = CurveTessellation.ArcPoints(center, radius, radius, 0d, startAngle, sweep, CurveTessellation.SegmentsForSweep(sweep, this._configuration.ArcPrecision)); + for (int j = 1; j < arc.Count; j++) + { + flattened.Add(arc[j]); + } + } + + this.DrawPolyline(style, flattened, closed: false); + } + + public void FillPolygon(ImageStyle style, IReadOnlyList points) + { + if (points.Count < 3) + { + return; + } + + PointF[] pixels = points.Select(ToPointF).ToArray(); + ImageColor color = style.EffectiveColor; + this.Canvas.Mutate(x => x.FillPolygon(color, pixels)); + } + + public void FillPath(ImageStyle style, IReadOnlyList> rings) + { + IPath[] polygons = rings + .Where(ring => ring.Count >= 3) + .Select(ring => (IPath)new Polygon(new LinearLineSegment(ring.Select(ToPointF).ToArray()))) + .ToArray(); + if (polygons.Length == 0) + { + return; + } + + 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)); + } + + 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))); + } + + public void DrawText(ImageStyle style, SurfaceText text) + { + if (string.IsNullOrWhiteSpace(text.Text)) + { + return; + } + + PointF origin = ToPointF(text.Origin); + Font font = this.CreateFont(text.Height); + TextOptions options = new(font) + { + Dpi = this._configuration.Dpi, + Origin = origin, + HorizontalAlignment = text.Anchor switch + { + SurfaceTextAnchor.Middle => HorizontalAlignment.Center, + SurfaceTextAnchor.End => HorizontalAlignment.Right, + _ => HorizontalAlignment.Left, + }, + VerticalAlignment = text.Baseline switch + { + SurfaceTextBaseline.Hanging => VerticalAlignment.Top, + SurfaceTextBaseline.Central => VerticalAlignment.Center, + _ => VerticalAlignment.Bottom, + }, + WrappingLength = text.WrappingWidth > 0 ? (float)text.WrappingWidth : -1, + LineSpacing = (float)text.LineSpacingFactor, + }; + + IPathCollection glyphs = TextBuilder.GenerateGlyphs(text.Text, options); + DrawingOptions drawingOptions = new(); + if (Math.Abs(text.Rotation) > double.Epsilon) + { + drawingOptions.Transform = Matrix3x2.CreateRotation((float)-text.Rotation, new Vector2(origin.X, origin.Y)); + } + + ImageColor color = style.EffectiveColor; + this.Canvas.Mutate(x => x.Fill(drawingOptions, color, glyphs)); + } + + public ViewportSurface BeginViewport(SurfaceRect bounds) + { + int width = Math.Max(1, (int)Math.Ceiling(bounds.Width)); + int height = Math.Max(1, (int)Math.Ceiling(bounds.Height)); + Image image = new(width, height, ImageColor.Transparent); + RasterDrawingSurface child = new(image, this._configuration, ownsCanvas: true); + ViewportSurface viewport = new(child, 0d, height); + this._viewports[viewport] = (image, bounds); + return viewport; + } + + public void EndViewport(ViewportSurface viewport) + { + if (!this._viewports.Remove(viewport, out (Image Image, SurfaceRect Bounds) entry)) + { + throw new InvalidOperationException("EndViewport was called for a viewport this surface did not begin."); + } + + ImagePoint destination = new((int)MathF.Round((float)entry.Bounds.X), (int)MathF.Round((float)entry.Bounds.Y)); + this.Canvas.Mutate(x => x.DrawImage(entry.Image, destination, 1f)); + viewport.Surface.Dispose(); + } + + public void Dispose() + { + foreach ((Image image, _) in this._viewports.Values) + { + image.Dispose(); + } + + this._viewports.Clear(); + if (this._ownsCanvas) + { + this.Canvas.Dispose(); + } + } + + private Font CreateFont(double height) + { + float size = Math.Max(1f, (float)height); + if (SystemFonts.TryGet(this._configuration.FontFamilyName, out FontFamily family)) + { + return family.CreateFont(size); + } + + return SystemFonts.Families.First().CreateFont(size); + } + + private static Pen CreatePen(ImageStyle style) + { + ImageColor color = style.EffectiveColor; + if (style.DashPattern is not { Length: > 0 }) + { + return new SolidPen(color, style.StrokeWidth); + } + + // ImageSharp.Drawing pattern values are multiples of the stroke width. + float width = Math.Max(0.01f, style.StrokeWidth); + float[] pattern = new float[style.DashPattern.Length]; + for (int i = 0; i < pattern.Length; i++) + { + pattern[i] = Math.Max(0.001f, style.DashPattern[i] / width); + } + + return new PatternPen(color, style.StrokeWidth, pattern); + } + + private static PointF ToPointF(SurfacePoint point) + { + return new PointF((float)point.X, (float)point.Y); + } +} +``` + +Parity notes for whoever touches this later: `DrawLine(Color, float, PointF[])` in ImageSharp.Drawing is a wrapper over `DrawLine(new SolidPen(color, width), points)`, so building the pen ourselves is pixel-identical. `EffectiveColor` returns the colour unchanged when opacity is 1. + +- [ ] **Step 6: Run the surface tests** + +Run: `dotnet test ACadSharp.Image.sln -c Release --nologo -v q --filter "FullyQualifiedName~RasterDrawingSurfaceTests"` +Expected: PASS (9 tests). If `OpacityBlendsWithBackground` lands outside 120..135, print the pixel and widen by at most 5 either side; ImageSharp blends 50% black over white to about 127 or 128. + +- [ ] **Step 7: Run the whole suite** + +Run: `dotnet test ACadSharp.Image.sln -c Release --nologo -v q` +Expected: PASS. Nothing in the render pipeline uses the surface yet, so parity is unaffected. + +- [ ] **Step 8: Commit** + +```bash +git add ACadSharp.Image/Rendering ACadSharp.Image.Tests/RasterDrawingSurfaceTests.cs +git commit -m "Add drawing surface abstraction and ImageSharp implementation + +Co-Authored-By: Claude Fable 5.1 +Claude-Session: https://claude.ai/code/session_016RGgSinQSUz4d89FRLxhMz" +``` + +--- + +### Task 4: Route the render pipeline through the surface (pixel parity) + +**Files:** +- Modify: `ACadSharp.Image/Rendering/ImageRenderContext.cs` (rewrite) +- Modify: `ACadSharp.Image/Rendering/ImageStyleResolver.cs` +- Modify: `ACadSharp.Image/Rendering/EntityRenderDispatcher.cs` +- Modify: `ACadSharp.Image/Rendering/SplineRenderer.cs` +- Modify: `ACadSharp.Image/Rendering/TextRenderer.cs` +- Modify: `ACadSharp.Image/Rendering/ImagePageRenderer.cs` +- Modify: `ACadSharp.Image.Tests/ImageExporterTests.cs:60-84,130-176` (the two tests that build a context) + +**Interfaces:** +- Consumes: `IDrawingSurface`, `RasterDrawingSurface`, `SurfacePoint`, `ImageStyle`, `SurfaceText` from Task 3. +- Produces: `ImageRenderContext` with `Surface`, `Scale`, `SurfaceWidth`, `SurfaceHeight`, `OffsetX`, `OffsetY`, `OriginX`, `OriginY`, `LineTypeScale`, `Viewport`, `Parent`, `SinglePrecision`, `ToSurfacePoint(XY)`, `ToSurfacePoint(XYZ)`, `ToSurfaceLength(double)`, `ToStrokeWidth(LineWeightType)`, static `CreatePageContext(IDrawingSurface, ImagePage, ImageConfiguration)` and `CreateViewportContext(ImageRenderContext parent, Viewport viewport, ViewportSurface surface, BoundingBox modelBounds, double scale)`. `ImageStyleResolver.Resolve(Entity, ImageRenderContext)`. `ImagePageRenderer.Render(ImagePage)` still returns `RenderedImagePage` until Task 5. + +**Why single precision matters:** today's `ToPixelPoint` computes `float x = OffsetX + (float)((point.X - OriginX) * PixelsPerUnit)` with `OffsetX` and `PixelsPerUnit` as `float`. The refactored context stores doubles but, when `SinglePrecision` is true, performs the exact same float operations in the same order so the raster backend receives bit-identical coordinates. The SVG backend (plan 2) uses `SinglePrecision = false`. + +- [ ] **Step 1: Rewrite `ImageRenderContext`** + +Replace `ACadSharp.Image/Rendering/ImageRenderContext.cs` with: + +```csharp +using ACadSharp.Entities; +using ACadSharp.Objects; +using CSMath; + +namespace ACadSharp.Image.Rendering; + +/// +/// Maps drawing coordinates onto an . +/// +/// +/// +/// x = OffsetX + (p.X - OriginX) * Scale and y = SurfaceHeight - OffsetY - (p.Y - OriginY) * Scale. +/// +/// +/// When is true the arithmetic is performed in in the same order +/// the original raster renderer used, so raster output stays pixel-identical. +/// +/// +internal sealed class ImageRenderContext +{ + public ImageRenderContext( + IDrawingSurface surface, + ImageConfiguration configuration, + Layout layout, + double surfaceWidth, + double surfaceHeight, + double originX, + double originY, + double scale, + double offsetX, + double offsetY, + bool singlePrecision, + double lineTypeScale, + Viewport? viewport = null, + ImageRenderContext? parent = null) + { + this.Surface = surface; + this.Configuration = configuration; + this.Layout = layout; + this.SurfaceWidth = surfaceWidth; + this.SurfaceHeight = surfaceHeight; + this.OriginX = originX; + this.OriginY = originY; + this.Scale = scale; + this.OffsetX = offsetX; + this.OffsetY = offsetY; + this.SinglePrecision = singlePrecision; + this.LineTypeScale = lineTypeScale; + this.Viewport = viewport; + this.Parent = parent; + } + + public IDrawingSurface Surface { get; } + + public ImageConfiguration Configuration { get; } + + public Layout Layout { get; } + + public double SurfaceWidth { get; } + + public double SurfaceHeight { get; } + + public double OriginX { get; } + + public double OriginY { get; } + + /// Surface units per drawing unit. + public double Scale { get; } + + public double OffsetX { get; } + + public double OffsetY { get; } + + /// True for the raster backend: reproduces the original float arithmetic exactly. + public bool SinglePrecision { get; } + + /// Surface units per linetype unit; differs from inside viewports with paper-space linetype scaling. + public double LineTypeScale { get; } + + /// Viewport whose contents are being drawn, or null for page-level content. + public Viewport? Viewport { get; } + + public ImageRenderContext? Parent { get; } + + public static ImageRenderContext CreatePageContext(IDrawingSurface surface, ImagePage page, ImageConfiguration configuration) + { + int drawableWidth = configuration.Width - configuration.PaddingLeft - configuration.PaddingRight; + int drawableHeight = configuration.Height - configuration.PaddingTop - configuration.PaddingBottom; + if (drawableWidth <= 0 || drawableHeight <= 0) + { + throw new InvalidOperationException("Padding must leave at least one drawable pixel in both dimensions."); + } + + Layout layout = page.Layout ?? new Layout("default_page"); + double pageWidth = Math.Max(1d, layout.PaperWidth); + double pageHeight = Math.Max(1d, layout.PaperHeight); + float pixelsPerUnit = Math.Min( + drawableWidth / (float)pageWidth, + drawableHeight / (float)pageHeight); + + float scaledWidth = (float)pageWidth * pixelsPerUnit; + float scaledHeight = (float)pageHeight * pixelsPerUnit; + float offsetX = configuration.PaddingLeft + ((drawableWidth - scaledWidth) / 2f); + float offsetY = configuration.PaddingBottom + ((drawableHeight - scaledHeight) / 2f); + + double originX = -page.Translation.X - layout.UnprintableMargin.Left; + double originY = -page.Translation.Y - layout.UnprintableMargin.Bottom; + + return new ImageRenderContext( + surface, + configuration, + layout, + configuration.Width, + configuration.Height, + originX, + originY, + pixelsPerUnit, + offsetX, + offsetY, + singlePrecision: true, + lineTypeScale: pixelsPerUnit); + } + + public static ImageRenderContext CreateViewportContext(ImageRenderContext parent, Viewport viewport, ViewportSurface surface, BoundingBox modelBounds, double scale) + { + return new ImageRenderContext( + surface.Surface, + parent.Configuration, + parent.Layout, + surfaceWidth: 0d, + surfaceHeight: surface.BottomY, + originX: modelBounds.Min.X, + originY: modelBounds.Min.Y, + scale: scale, + offsetX: surface.OffsetX, + offsetY: 0d, + singlePrecision: parent.SinglePrecision, + lineTypeScale: scale, + viewport: viewport, + parent: parent); + } + + public SurfacePoint ToSurfacePoint(XY point) + { + if (this.SinglePrecision) + { + float x = (float)this.OffsetX + (float)((point.X - this.OriginX) * (float)this.Scale); + float y = (float)this.SurfaceHeight - (float)this.OffsetY - (float)((point.Y - this.OriginY) * (float)this.Scale); + return new SurfacePoint(x, y); + } + + return new SurfacePoint( + this.OffsetX + ((point.X - this.OriginX) * this.Scale), + this.SurfaceHeight - this.OffsetY - ((point.Y - this.OriginY) * this.Scale)); + } + + public SurfacePoint ToSurfacePoint(XYZ point) + { + return this.ToSurfacePoint(point.Convert()); + } + + public double ToSurfaceLength(double value) + { + return this.SinglePrecision + ? (float)value * (float)this.Scale + : value * this.Scale; + } + + /// + /// Stroke width in surface units for a line weight. Raster: pixels from the configuration table. + /// + public float ToStrokeWidth(LineWeightType lineWeight) + { + return this.Configuration.GetLineWeightPixels(lineWeight); + } +} +``` + +`(float)((point.X - this.OriginX) * (float)this.Scale)` is the same expression as before: `double * float` promotes the float to double, exactly as `(point.X - OriginX) * PixelsPerUnit` did, and `Scale` holds the float value widened (it was constructed from `pixelsPerUnit`, a float). + +- [ ] **Step 2: Update `ImageStyleResolver`** + +Replace the `Resolve` method in `ACadSharp.Image/Rendering/ImageStyleResolver.cs`: + +```csharp + /// + /// Resolves the visual style for a CAD entity in the given context. + /// + public ImageStyle Resolve(Entity entity, ImageRenderContext context) + { + return new ImageStyle( + entity.GetActiveColor().ToImageColor(), + context.ToStrokeWidth(entity.GetActiveLineWeightType())); + } +``` + +Remove the now-unused `_configuration` field and constructor parameter only if nothing else uses them; otherwise leave them. + +- [ ] **Step 3: Rewrite `TextRenderer` as a `SurfaceText` builder** + +Replace `ACadSharp.Image/Rendering/TextRenderer.cs` with: + +```csharp +using ACadSharp.Entities; +using CSMath; + +namespace ACadSharp.Image.Rendering; + +/// +/// Converts and into runs and hands them to the surface. +/// +internal sealed class TextRenderer +{ + public void Draw(ImageRenderContext context, ImageStyle style, MText mtext) + { + string text = NormalizeText(mtext.PlainText); + if (string.IsNullOrWhiteSpace(text)) + { + return; + } + + SurfaceText run = new( + text, + context.ToSurfacePoint(mtext.InsertPoint), + context.ToSurfaceLength(mtext.Height), + mtext.Rotation, + GetAnchor(mtext.AttachmentPoint), + GetBaseline(mtext.AttachmentPoint), + mtext.RectangleWidth > 0 ? context.ToSurfaceLength(mtext.RectangleWidth) : -1d, + mtext.LineSpacing, + FixedLength: -1d); + + context.Surface.DrawText(style, run); + } + + public void Draw(ImageRenderContext context, ImageStyle style, TextEntity textEntity) + { + string text = NormalizeText(textEntity.Value); + if (string.IsNullOrWhiteSpace(text)) + { + return; + } + + SurfaceText run = new( + text, + context.ToSurfacePoint(GetTextOrigin(textEntity)), + context.ToSurfaceLength(textEntity.Height), + textEntity.Rotation, + GetAnchor(textEntity.HorizontalAlignment), + GetBaseline(textEntity.VerticalAlignment), + WrappingWidth: -1d, + LineSpacingFactor: 1d, + GetFixedLength(context, textEntity)); + + context.Surface.DrawText(style, run); + } + + private static double GetFixedLength(ImageRenderContext context, TextEntity textEntity) + { + if (textEntity.HorizontalAlignment is not (TextHorizontalAlignment.Aligned or TextHorizontalAlignment.Fit)) + { + return -1d; + } + + double dx = textEntity.AlignmentPoint.X - textEntity.InsertPoint.X; + double dy = textEntity.AlignmentPoint.Y - textEntity.InsertPoint.Y; + double length = Math.Sqrt((dx * dx) + (dy * dy)); + return length > 0 ? context.ToSurfaceLength(length) : -1d; + } + + private static XYZ GetTextOrigin(TextEntity textEntity) + { + return textEntity.HorizontalAlignment == TextHorizontalAlignment.Left && textEntity.VerticalAlignment == TextVerticalAlignmentType.Baseline + ? textEntity.InsertPoint + : textEntity.AlignmentPoint; + } + + private static SurfaceTextAnchor GetAnchor(AttachmentPointType attachment) + { + return attachment switch + { + AttachmentPointType.TopCenter or AttachmentPointType.MiddleCenter or AttachmentPointType.BottomCenter => SurfaceTextAnchor.Middle, + AttachmentPointType.TopRight or AttachmentPointType.MiddleRight or AttachmentPointType.BottomRight => SurfaceTextAnchor.End, + _ => SurfaceTextAnchor.Start, + }; + } + + private static SurfaceTextBaseline GetBaseline(AttachmentPointType attachment) + { + return attachment switch + { + AttachmentPointType.TopLeft or AttachmentPointType.TopCenter or AttachmentPointType.TopRight => SurfaceTextBaseline.Hanging, + AttachmentPointType.MiddleLeft or AttachmentPointType.MiddleCenter or AttachmentPointType.MiddleRight => SurfaceTextBaseline.Central, + _ => SurfaceTextBaseline.Alphabetic, + }; + } + + private static SurfaceTextAnchor GetAnchor(TextHorizontalAlignment alignment) + { + return alignment switch + { + TextHorizontalAlignment.Center or TextHorizontalAlignment.Aligned or TextHorizontalAlignment.Middle or TextHorizontalAlignment.Fit => SurfaceTextAnchor.Middle, + TextHorizontalAlignment.Right => SurfaceTextAnchor.End, + _ => SurfaceTextAnchor.Start, + }; + } + + private static SurfaceTextBaseline GetBaseline(TextVerticalAlignmentType alignment) + { + return alignment switch + { + TextVerticalAlignmentType.Middle => SurfaceTextBaseline.Central, + TextVerticalAlignmentType.Top => SurfaceTextBaseline.Hanging, + _ => SurfaceTextBaseline.Alphabetic, + }; + } + + private static string NormalizeText(string? value) + { + if (string.IsNullOrEmpty(value)) + { + return string.Empty; + } + + return value + .Replace("%%C", "Ø", StringComparison.OrdinalIgnoreCase) + .Replace("%%D", "°", StringComparison.OrdinalIgnoreCase) + .Replace("%%P", "±", StringComparison.OrdinalIgnoreCase) + .Replace("\\P", "\n", StringComparison.OrdinalIgnoreCase); + } +} +``` + +The mapping tables are the same as before; the raster surface maps `Start/Middle/End` back to `Left/Center/Right` and `Hanging/Central/Alphabetic` back to `Top/Center/Bottom`, so the `TextOptions` the surface builds are identical to the old ones. (CSMath's `XYZ` exposes no distance helper in 3.7.1, hence the explicit arithmetic.) + +- [ ] **Step 4: Update `SplineRenderer` to draw through the surface** + +In `ACadSharp.Image/Rendering/SplineRenderer.cs`: + +Change the class header to `internal sealed class SplineRenderer(ImageConfiguration configuration)` (unchanged) and remove the ImageSharp `using` lines (`SixLabors.ImageSharp`, `SixLabors.ImageSharp.Drawing`, `SixLabors.ImageSharp.Drawing.Processing`, `SixLabors.ImageSharp.Processing`). + +Replace the body of `Draw`: + +```csharp + public bool Draw(ImageRenderContext context, ImageStyle style, Spline spline) + { + if (this.DrawBezierSpline(context, style, spline)) + { + return true; + } + + XY[] sampledVertices = this.SampleSpline(spline); + if (sampledVertices.Length > 1) + { + SurfacePoint[] points = new SurfacePoint[sampledVertices.Length]; + for (int i = 0; i < sampledVertices.Length; i++) + { + points[i] = context.ToSurfacePoint(sampledVertices[i]); + } + + context.Surface.DrawPolyline(style, points, ShouldClosePoints(points, spline.IsClosed || spline.IsPeriodic)); + return true; + } + + if (spline.TryPolygonalVertexes(this._configuration.ArcPrecision, out List? polygonalPoints) && polygonalPoints.Count > 1) + { + SurfacePoint[] points = new SurfacePoint[polygonalPoints.Count]; + for (int i = 0; i < polygonalPoints.Count; i++) + { + points[i] = context.ToSurfacePoint(polygonalPoints[i].Convert()); + } + + context.Surface.DrawPolyline(style, points, ShouldClosePoints(points, spline.IsClosed || spline.IsPeriodic)); + return true; + } + + this._configuration.Notify($"[{spline.SubclassMarker}] Could not approximate spline geometry.", NotificationType.Warning); + return false; + } +``` + +Replace `DrawBezierSpline`: + +```csharp + private bool DrawBezierSpline(ImageRenderContext context, ImageStyle style, Spline spline) + { + if (!TryGetBezierSegments(spline, out int segmentCount)) + { + return false; + } + + IReadOnlyList controlPoints = spline.ControlPoints; + SurfacePoint[] points = new SurfacePoint[(segmentCount * 3) + 1]; + for (int i = 0; i < points.Length; i++) + { + points[i] = context.ToSurfacePoint(controlPoints[i]); + } + + context.Surface.DrawCubicBezier(style, points, spline.IsClosed || spline.IsPeriodic); + return true; + } +``` + +Make `TryGetBezierSegments` `internal static` (plan 2 reuses it). Replace `ClosePoints`, `ShouldClose(IReadOnlyList)` and `Distance(PointF, PointF)` with: + +```csharp + internal static bool ShouldClosePoints(IReadOnlyList points, bool close) + { + if (!close || points.Count < 3) + { + return false; + } + + float totalLength = 0f; + for (int i = 1; i < points.Count; i++) + { + totalLength += Distance(points[i - 1], points[i]); + } + + float averageSegmentLength = totalLength / (points.Count - 1); + float closingLength = Distance(points[^1], points[0]); + return closingLength <= averageSegmentLength * 3f; + } + + private static float Distance(SurfacePoint a, SurfacePoint b) + { + float dx = (float)a.X - (float)b.X; + float dy = (float)a.Y - (float)b.Y; + return MathF.Sqrt((dx * dx) + (dy * dy)); + } +``` + +The float arithmetic mirrors the old `PointF` version, so the closing decision is unchanged. + +- [ ] **Step 5: Update `EntityRenderDispatcher`** + +In `ACadSharp.Image/Rendering/EntityRenderDispatcher.cs` remove the four `SixLabors.*` usings and rewrite the drawing methods. Full replacement of the class body below the constructor (keep the class doc comment and constructor): + +```csharp + public void Draw(ImageRenderContext context, Entity entity) + { + ImageStyle style = this._styleResolver.Resolve(entity, context); + + switch (entity) + { + case Arc arc: + this.DrawPolyline(context, style, arc.PolygonalVertexes(this._configuration.ArcPrecision).Select(v => v.Convert()), false); + break; + case Circle circle: + this.DrawPolyline(context, style, circle.PolygonalVertexes(this._configuration.ArcPrecision).Select(v => v.Convert()), true); + break; + case Ellipse ellipse: + this.DrawPolyline(context, style, ellipse.PolygonalVertexes(this._configuration.ArcPrecision).Select(v => v.Convert()), true); + break; + case Line line: + context.Surface.DrawLine(style, context.ToSurfacePoint(line.StartPoint), context.ToSurfacePoint(line.EndPoint)); + break; + case Dimension dimension: + this.DrawDimension(context, dimension); + break; + case Solid solid: + this.DrawSolid(context, style, solid); + break; + case ACadSharp.Entities.Point point: + this.DrawPoint(context, style, point); + break; + case IPolyline polyline: + this.DrawPolyline(context, style, polyline.GetPoints(this._configuration.ArcPrecision).Select(v => v.Convert()), polyline.IsClosed); + break; + case Spline spline: + this._splineRenderer.Draw(context, style, spline); + break; + case MText mtext: + this._textRenderer.Draw(context, style, mtext); + break; + case TextEntity textEntity: + this._textRenderer.Draw(context, style, textEntity); + break; + case IText text: + this._configuration.Notify($"[{entity.SubclassMarker}] Text rendering is not implemented yet.", NotificationType.NotImplemented); + break; + case Insert insert: + this.DrawBlockContents(context, insert); + break; + default: + this._configuration.Notify($"[{entity.SubclassMarker}] Drawing not implemented.", NotificationType.NotImplemented); + break; + } + } + + private void DrawPoint(ImageRenderContext context, ImageStyle style, ACadSharp.Entities.Point point) + { + float radius = Math.Max(1f, this._configuration.DotSizePixels / 2f); + context.Surface.FillCircle(style, context.ToSurfacePoint(point.Location), radius); + } + + private void DrawDimension(ImageRenderContext context, Dimension dimension) + { + BlockRecord? block = dimension.Block; + if (block == null) + { + dimension.UpdateBlock(); + block = dimension.Block; + } + + if (block == null) + { + this._configuration.Notify($"[{dimension.SubclassMarker}] Dimension block is not available.", NotificationType.Warning); + return; + } + + foreach (Entity entity in block.Entities) + { + if (entity is ACadSharp.Entities.Point) + { + continue; + } + + this.Draw(context, entity); + } + } + + private static void DrawSolid(ImageRenderContext context, ImageStyle style, Solid solid) + { + SurfacePoint[] points = + [ + context.ToSurfacePoint(solid.FirstCorner), + context.ToSurfacePoint(solid.SecondCorner), + context.ToSurfacePoint(solid.ThirdCorner), + context.ToSurfacePoint(solid.FourthCorner), + ]; + + context.Surface.FillPolygon(style, points); + } + + private void DrawPolyline(ImageRenderContext context, ImageStyle style, IEnumerable vertices, bool close) + { + SurfacePoint[] points = vertices.Select(context.ToSurfacePoint).ToArray(); + if (points.Length < 2) + { + return; + } + + context.Surface.DrawPolyline(style, points, SplineRenderer.ShouldClosePoints(points, close)); + } + + private void DrawBlockContents(ImageRenderContext context, Insert insert) + { + foreach (Entity entity in insert.Explode()) + { + this.Draw(context, entity); + } + } +``` + +Delete the old `ShouldClose` and `Distance` helpers from the dispatcher (they now live in `SplineRenderer`). `DrawSolid` becomes `static` because it no longer touches instance state; change the call site to `DrawSolid(context, style, solid)`. The `DrawPoint` radius is the same `Math.Max(1f, DotSizePixels / 2f)` as before; note `FillCircle` takes a double and casts back to float inside the raster surface, which is lossless. + +- [ ] **Step 6: Update `ImagePageRenderer`** + +Replace the `Render` and `DrawViewport` methods in `ACadSharp.Image/Rendering/ImagePageRenderer.cs` (keep the `using` for `SixLabors.ImageSharp` and `Rgba32`; remove `SixLabors.ImageSharp.Processing`, `ImageColor` and `ImagePoint` aliases if unused): + +```csharp + public RenderedImagePage Render(ImagePage page) + { + Image image = new(this._configuration.Width, this._configuration.Height, this._configuration.BackgroundColor); + using RasterDrawingSurface surface = new(image, this._configuration, ownsCanvas: false); + this.RenderTo(surface, page); + return new RenderedImagePage(page.Name, image); + } + + internal void RenderTo(IDrawingSurface surface, ImagePage page) + { + ImageRenderContext context = ImageRenderContext.CreatePageContext(surface, page, this._configuration); + + foreach (Viewport viewport in page.Viewports) + { + this.DrawViewport(context, viewport); + } + + foreach (Entity entity in page.Entities) + { + this._dispatcher.Draw(context, entity); + } + } + + private void DrawViewport(ImageRenderContext pageContext, Viewport viewport) + { + BoundingBox viewportBounds = viewport.GetBoundingBox(); + double viewportWidth = Math.Max(1, (int)Math.Ceiling(pageContext.ToSurfaceLength(viewportBounds.LengthX))); + double viewportHeight = Math.Max(1, (int)Math.Ceiling(pageContext.ToSurfaceLength(viewportBounds.LengthY))); + BoundingBox modelBounds = viewport.GetModelBoundingBox(); + + SurfacePoint topLeft = pageContext.ToSurfacePoint(new XY(viewportBounds.Min.X, viewportBounds.Max.Y)); + ViewportSurface viewportSurface = pageContext.Surface.BeginViewport(new SurfaceRect(topLeft.X, topLeft.Y, viewportWidth, viewportHeight)); + + double scale = pageContext.SinglePrecision + ? (float)pageContext.Scale * (float)viewport.ScaleFactor + : pageContext.Scale * viewport.ScaleFactor; + ImageRenderContext viewportContext = ImageRenderContext.CreateViewportContext(pageContext, viewport, viewportSurface, modelBounds, scale); + + foreach (Entity entity in viewport.SelectEntities()) + { + this._dispatcher.Draw(viewportContext, entity); + } + + pageContext.Surface.EndViewport(viewportSurface); + } +``` + +`ToSurfaceLength` in single-precision mode returns `(float)value * (float)Scale`, the same as the old `ToPixelLength`, and the ceiling/int conversion is unchanged. The viewport image size in `RasterDrawingSurface.BeginViewport` is `Ceiling(bounds.Width)` of an already-integral value, so it is the same integer. + +- [ ] **Step 7: Fix the two tests that construct a context directly** + +In `ACadSharp.Image.Tests/ImageExporterTests.cs`, `PageContextUsesConfiguredPadding` becomes: + +```csharp + using Image canvas = new(configuration.Width, configuration.Height); + using RasterDrawingSurface surface = new(canvas, configuration, ownsCanvas: false); + ImageRenderContext context = ImageRenderContext.CreatePageContext(surface, page, configuration); + + Assert.Equal(5d, context.Scale); + Assert.Equal(10d, context.OffsetX); + Assert.Equal(20d, context.OffsetY); +``` + +and in `RenderClosedPeriodicSplineDoesNotDrawSpokeToOrigin` replace the context construction with: + +```csharp + using RasterDrawingSurface surface = new(canvas, configuration, ownsCanvas: false); + ImageRenderContext context = new(surface, configuration, page.Layout, 100, 100, -5, -5, 10f, 0, 0, singlePrecision: true, lineTypeScale: 10f); +``` + +- [ ] **Step 8: Build, then run the full suite including parity** + +Run: `dotnet build ACadSharp.Image.sln -c Release --nologo -v q 2>&1 | grep -E " error " ; dotnet test ACadSharp.Image.sln -c Release --nologo -v q` +Expected: 0 errors, all tests PASS, including the 4 `SampleParityTests` cases. + +If a parity case fails, the message names the first differing pixel. Diagnose in this order: (1) a `float`/`double` ordering difference in `ToSurfacePoint` or `ToSurfaceLength`; (2) `DrawPolyline` closing decision (`ShouldClosePoints` must receive the original `close` request and the un-closed points); (3) text options (compare each `TextOptions` property to the old `TextRenderer`); (4) viewport destination rounding (`MathF.Round((float)bounds.X)`). Do not update the baselines to make the test pass. + +- [ ] **Step 9: Commit** + +```bash +git add ACadSharp.Image/Rendering ACadSharp.Image.Tests/ImageExporterTests.cs +git commit -m "Render entities through the drawing surface + +Co-Authored-By: Claude Fable 5.1 +Claude-Session: https://claude.ai/code/session_016RGgSinQSUz4d89FRLxhMz" +``` + +--- + +### Task 5: `RenderedPage` abstraction and `Render(format)` + +**Files:** +- Create: `ACadSharp.Image/RenderedPage.cs` +- Modify: `ACadSharp.Image/RenderedImagePage.cs` +- Modify: `ACadSharp.Image/ImageExporter.cs:144-272` (`Render`, `Save`, `SaveInternal`, `SavePage`) +- Modify: `ACadSharp.Image/Rendering/ImagePageRenderer.cs` (`Render` signature) +- Modify: `ACadSharp.Image.Tests/ImageExporterTests.cs`, `ACadSharp.Image.Tests/SampleParityTests.cs` (call sites of `Render()`) + +**Interfaces:** +- Produces: `public abstract class RenderedPage : IDisposable { string Name; ImageExportFormat Format; abstract void Save(string path); abstract void Save(Stream stream); }`, `RenderedImagePage(string name, Image canvas, ImageExportFormat format, int quality)`, `ImageExporter.Render(ImageExportFormat format = ImageExportFormat.Png) : IReadOnlyList`, `ImagePageRenderer.Render(ImagePage page, ImageExportFormat format) : RenderedPage`. + +- [ ] **Step 1: Write the failing tests** + +Append to `ACadSharp.Image.Tests/ImageExporterTests.cs`: + +```csharp + [Fact] + public void RenderReturnsRasterPagesCarryingTheRequestedFormat() + { + BlockRecord block = new("format-block"); + block.Entities.Add(new Line(new XYZ(0, 0, 0), new XYZ(10, 10, 0))); + + ImageExporter exporter = new(); + exporter.Add(block); + + using RenderedPage page = Assert.Single(exporter.Render(ImageExportFormat.Jpeg)); + + RenderedImagePage raster = Assert.IsType(page); + Assert.Equal(ImageExportFormat.Jpeg, raster.Format); + Assert.Equal("format-block", raster.Name); + } + + [Fact] + public void RenderedPageSavesToStreamInItsFormat() + { + BlockRecord block = new("stream-block"); + block.Entities.Add(new Line(new XYZ(0, 0, 0), new XYZ(10, 10, 0))); + + ImageExporter exporter = new(); + exporter.Configuration.Width = 32; + exporter.Configuration.Height = 32; + exporter.Add(block); + + using RenderedPage page = Assert.Single(exporter.Render(ImageExportFormat.Png)); + using MemoryStream stream = new(); + page.Save(stream); + + byte[] bytes = stream.ToArray(); + Assert.Equal(0x89, bytes[0]); + Assert.Equal((byte)'P', bytes[1]); + Assert.Equal((byte)'N', bytes[2]); + Assert.Equal((byte)'G', bytes[3]); + } +``` + +Also update the existing call sites in the same file: every `using RenderedImagePage page = Assert.Single(exporter.Render());` becomes `using RenderedImagePage page = Assert.IsType(Assert.Single(exporter.Render()));`. In `SampleParityTests.RenderSample` change the loop to: + +```csharp + foreach (RenderedPage page in exporter.Render()) + { + images.Add(Assert.IsType(page).Canvas); + } +``` + +- [ ] **Step 2: Run to confirm compile failure** + +Run: `dotnet test ACadSharp.Image.sln -c Release --nologo -v q --filter "FullyQualifiedName~ImageExporterTests"` +Expected: build error, `RenderedPage` not found. + +- [ ] **Step 3: Add `RenderedPage`** + +Create `ACadSharp.Image/RenderedPage.cs`: + +```csharp +namespace ACadSharp.Image; + +/// +/// A rendered page produced by , ready to be saved in its . +/// +public abstract class RenderedPage : IDisposable +{ + protected RenderedPage(string name, ImageExportFormat format) + { + this.Name = name; + this.Format = format; + } + + /// + /// Gets the name of this page (layout name or block name). + /// + public string Name { get; } + + /// + /// Gets the format this page will be saved as. + /// + public ImageExportFormat Format { get; } + + /// + /// Saves the page to a file, creating the directory if needed. + /// + public void Save(string path) + { + ArgumentException.ThrowIfNullOrWhiteSpace(path); + string fullPath = Path.GetFullPath(path); + Directory.CreateDirectory(Path.GetDirectoryName(fullPath)!); + using FileStream stream = new(fullPath, FileMode.Create, FileAccess.Write, FileShare.None); + this.Save(stream); + } + + /// + /// Writes the page to a stream in its . + /// + public abstract void Save(Stream stream); + + /// + public abstract void Dispose(); +} +``` + +- [ ] **Step 4: Make `RenderedImagePage` a `RenderedPage`** + +Replace `ACadSharp.Image/RenderedImagePage.cs` with: + +```csharp +using SixLabors.ImageSharp.Formats.Bmp; +using SixLabors.ImageSharp.Formats.Gif; +using SixLabors.ImageSharp.Formats.Jpeg; +using SixLabors.ImageSharp.Formats.Png; +using SixLabors.ImageSharp.Formats.Webp; +using Rgba32 = SixLabors.ImageSharp.PixelFormats.Rgba32; + +namespace ACadSharp.Image; + +/// +/// A page rendered to a raster canvas. +/// +/// +/// Owns the underlying ; dispose the page to release it. +/// +public sealed class RenderedImagePage : RenderedPage +{ + private readonly int _quality; + + /// + /// Initializes a new instance of the class. + /// + /// Page name. + /// Rendered canvas; ownership transfers to the page. + /// Raster format used by . Must not be . + /// Quality 1..100 for lossy formats. + public RenderedImagePage(string name, SixLabors.ImageSharp.Image canvas, ImageExportFormat format = ImageExportFormat.Png, int quality = 90) + : base(name, format) + { + this.Canvas = canvas; + this._quality = quality; + } + + /// + /// Gets the rendered image canvas (32-bit RGBA). + /// + public SixLabors.ImageSharp.Image Canvas { get; } + + /// + public override void Save(Stream stream) + { + ArgumentNullException.ThrowIfNull(stream); + switch (this.Format) + { + case ImageExportFormat.Bmp: + this.Canvas.Save(stream, new BmpEncoder()); + break; + case ImageExportFormat.Jpeg: + this.Canvas.Save(stream, new JpegEncoder { Quality = this._quality }); + break; + case ImageExportFormat.Gif: + this.Canvas.Save(stream, new GifEncoder()); + break; + case ImageExportFormat.Webp: + this.Canvas.Save(stream, new WebpEncoder { Quality = this._quality }); + break; + default: + this.Canvas.Save(stream, new PngEncoder()); + break; + } + } + + /// + public override void Dispose() + { + this.Canvas.Dispose(); + } +} +``` + +(The `` remark about `Svg` becomes true in plan 2; leave it.) + +- [ ] **Step 5: Update `ImagePageRenderer.Render`** + +```csharp + public RenderedPage Render(ImagePage page, ImageExportFormat format) + { + Image image = new(this._configuration.Width, this._configuration.Height, this._configuration.BackgroundColor); + using RasterDrawingSurface surface = new(image, this._configuration, ownsCanvas: false); + this.RenderTo(surface, page); + return new RenderedImagePage(page.Name, image, format, this._configuration.OutputQuality); + } +``` + +- [ ] **Step 6: Update `ImageExporter`** + +Replace `Render`, `Save`, `SaveInternal` and delete `SavePage` and the five `SixLabors.ImageSharp.Formats.*` usings plus `using SixLabors.ImageSharp;` in `ACadSharp.Image/ImageExporter.cs`: + +```csharp + /// + /// Renders all added pages without saving to disk. + /// + /// Output format the pages will be saved as. Defaults to PNG. + /// Rendered pages; dispose each when finished. + public IReadOnlyList Render(ImageExportFormat format = ImageExportFormat.Png) + { + ImagePageRenderer renderer = new(this.Configuration); + RenderedPage[] pages = new RenderedPage[this._pages.Count]; + for (int i = 0; i < this._pages.Count; i++) + { + pages[i] = renderer.Render(this._pages[i], format); + } + + return pages; + } + + /// + /// Renders all added pages and saves the output to the specified path. + /// + /// A file path when there is one page, or a directory when there are several. + /// The output format. Defaults to PNG. + public void Save(string outputPath, ImageExportFormat format = ImageExportFormat.Png) + { + IReadOnlyList pages = this.Render(format); + + try + { + if (pages.Count == 0) + { + throw new InvalidOperationException("There are no pages to export."); + } + + string fullPath = Path.GetFullPath(outputPath); + string? extension = Path.GetExtension(fullPath); + + if (pages.Count == 1 && !string.IsNullOrWhiteSpace(extension)) + { + pages[0].Save(fullPath); + return; + } + + string directory = string.IsNullOrWhiteSpace(extension) + ? fullPath + : Path.GetDirectoryName(fullPath)!; + + string prefix = string.IsNullOrWhiteSpace(extension) + ? "page" + : Path.GetFileNameWithoutExtension(fullPath); + + for (int i = 0; i < pages.Count; i++) + { + pages[i].Save(Path.Combine(directory, $"{prefix}-{i + 1:D2}-{pages[i].Name}{format.GetFileExtension()}")); + } + } + finally + { + foreach (RenderedPage page in pages) + { + page.Dispose(); + } + } + } +``` + +Update the class XML summary from "Exports CAD drawings to raster images in various formats." to "Exports CAD drawings to raster images or SVG." and the `` reference `Save(string, ImageExportFormat)` stays valid. + +- [ ] **Step 7: Run the whole suite** + +Run: `dotnet test ACadSharp.Image.sln -c Release --nologo -v q` +Expected: PASS, including `SaveMultiplePagesUsesIndexedOutputNames`, the two new tests, and parity. + +- [ ] **Step 8: Commit** + +```bash +git add ACadSharp.Image ACadSharp.Image.Tests +git commit -m "Add RenderedPage abstraction and format-aware Render + +Co-Authored-By: Claude Fable 5.1 +Claude-Session: https://claude.ai/code/session_016RGgSinQSUz4d89FRLxhMz" +``` + +--- + +### Task 6: Entity boundaries with effective layer + +**Files:** +- Modify: `ACadSharp.Image/Rendering/EntityRenderDispatcher.cs` +- Create: `ACadSharp.Image.Tests/RecordingDrawingSurface.cs` +- Create: `ACadSharp.Image.Tests/EntityRenderDispatcherTests.cs` + +**Interfaces:** +- Consumes: `IDrawingSurface.BeginEntity(EntityRenderInfo, LayerRenderInfo)`, `EndEntity()`. +- Produces: `EntityRenderDispatcher.Draw(ImageRenderContext context, Entity entity)` (unchanged public shape) now wraps every drawn entity in `BeginEntity`/`EndEntity` and passes nested-entity context; `internal static string GetEffectiveLayerName(Entity entity, string? parentLayerName)`. Plans 2 and 3 rely on `EntityRenderInfo.LayerName` being the effective layer. + +- [ ] **Step 1: Write the recording surface and the failing tests** + +Create `ACadSharp.Image.Tests/RecordingDrawingSurface.cs`: + +```csharp +using ACadSharp.Image.Rendering; + +namespace ACadSharp.Image.Tests; + +/// +/// Test double that records surface calls as strings and entity boundaries as infos. +/// +internal sealed class RecordingDrawingSurface : IDrawingSurface +{ + public List Calls { get; } = new(); + + public List Entities { get; } = new(); + + public List Layers { get; } = new(); + + public int Depth { get; private set; } + + public bool SupportsCurves { get; init; } + + public void BeginEntity(EntityRenderInfo info, LayerRenderInfo layer) + { + this.Depth++; + this.Entities.Add(info); + this.Layers.Add(layer); + this.Calls.Add($"BeginEntity {info.EntityType} layer={info.LayerName} parent={info.ParentHandle?.ToString("X") ?? "-"} block={info.BlockName ?? "-"}"); + } + + public void EndEntity() + { + this.Depth--; + this.Calls.Add("EndEntity"); + } + + public void DrawLine(ImageStyle style, SurfacePoint start, SurfacePoint end) => this.Calls.Add($"DrawLine {start} {end} w={style.StrokeWidth}"); + + public void DrawPolyline(ImageStyle style, IReadOnlyList points, bool closed) => this.Calls.Add($"DrawPolyline n={points.Count} closed={closed}"); + + public void DrawArc(ImageStyle style, SurfacePoint center, double radiusX, double radiusY, double rotation, double startAngle, double sweepAngle) => this.Calls.Add($"DrawArc {center} rx={radiusX} ry={radiusY} start={startAngle} sweep={sweepAngle}"); + + public void DrawEllipse(ImageStyle style, SurfacePoint center, double radiusX, double radiusY, double rotation) => this.Calls.Add($"DrawEllipse {center} rx={radiusX} ry={radiusY}"); + + public void DrawCubicBezier(ImageStyle style, IReadOnlyList controlPoints, bool closed) => this.Calls.Add($"DrawCubicBezier n={controlPoints.Count} closed={closed}"); + + public void DrawBulgePolyline(ImageStyle style, IReadOnlyList points, IReadOnlyList bulges, bool closed) => this.Calls.Add($"DrawBulgePolyline n={points.Count} closed={closed}"); + + public void FillPolygon(ImageStyle style, IReadOnlyList points) => this.Calls.Add($"FillPolygon n={points.Count}"); + + public void FillPath(ImageStyle style, IReadOnlyList> rings) => this.Calls.Add($"FillPath rings={rings.Count}"); + + public void FillCircle(ImageStyle style, SurfacePoint center, double radius) => this.Calls.Add($"FillCircle {center} r={radius}"); + + public void DrawText(ImageStyle style, SurfaceText text) => this.Calls.Add($"DrawText '{text.Text}' anchor={text.Anchor} baseline={text.Baseline}"); + + public ViewportSurface BeginViewport(SurfaceRect bounds) + { + this.Calls.Add($"BeginViewport {bounds}"); + return new ViewportSurface(this, bounds.X, bounds.Y + bounds.Height); + } + + public void EndViewport(ViewportSurface viewport) => this.Calls.Add("EndViewport"); + + public void Dispose() + { + } +} +``` + +Create `ACadSharp.Image.Tests/EntityRenderDispatcherTests.cs`: + +```csharp +using ACadSharp.Entities; +using ACadSharp.Image.Rendering; +using ACadSharp.Objects; +using ACadSharp.Tables; +using CSMath; + +namespace ACadSharp.Image.Tests; + +public sealed class EntityRenderDispatcherTests +{ + private static ImageRenderContext CreateContext(RecordingDrawingSurface surface, ImageConfiguration configuration) + { + Layout layout = new("test") { PaperWidth = 100, PaperHeight = 100 }; + return new ImageRenderContext(surface, configuration, layout, 100, 100, 0, 0, 1d, 0, 0, singlePrecision: false, lineTypeScale: 1d); + } + + [Fact] + public void DrawWrapsEntityInBeginAndEnd() + { + RecordingDrawingSurface surface = new(); + ImageConfiguration configuration = new(); + EntityRenderDispatcher dispatcher = new(configuration); + Line line = new(new XYZ(0, 0, 0), new XYZ(10, 0, 0)) { Layer = new Layer("Walls"), Handle = 0x1F3 }; + + dispatcher.Draw(CreateContext(surface, configuration), line); + + Assert.Equal(3, surface.Calls.Count); + Assert.StartsWith("BeginEntity LINE layer=Walls parent=- block=-", surface.Calls[0]); + Assert.StartsWith("DrawLine", surface.Calls[1]); + Assert.Equal("EndEntity", surface.Calls[2]); + Assert.Equal(0, surface.Depth); + Assert.Equal(0x1F3UL, surface.Entities[0].Handle); + } + + [Fact] + public void NestedEntityOnLayerZeroInheritsInsertLayer() + { + RecordingDrawingSurface surface = new(); + ImageConfiguration configuration = new(); + EntityRenderDispatcher dispatcher = new(configuration); + + BlockRecord block = new("DOOR"); + block.Entities.Add(new Line(new XYZ(0, 0, 0), new XYZ(1, 0, 0)) { Layer = new Layer(Layer.DefaultName) }); + block.Entities.Add(new Line(new XYZ(0, 0, 0), new XYZ(0, 1, 0)) { Layer = new Layer("Hardware") }); + Insert insert = new(block) { Layer = new Layer("Doors"), Handle = 0xAB }; + + dispatcher.Draw(CreateContext(surface, configuration), insert); + + // Outer insert, then two nested entities. + Assert.Equal(3, surface.Entities.Count); + Assert.Equal("Doors", surface.Entities[0].LayerName); + Assert.Equal("Doors", surface.Entities[1].LayerName); + Assert.Equal(0xABUL, surface.Entities[1].ParentHandle); + Assert.Equal("DOOR", surface.Entities[1].BlockName); + Assert.Equal("Hardware", surface.Entities[2].LayerName); + Assert.Equal(0, surface.Depth); + } + + [Fact] + public void LayerInfoCarriesLayerColourAndWidth() + { + RecordingDrawingSurface surface = new(); + ImageConfiguration configuration = new(); + EntityRenderDispatcher dispatcher = new(configuration); + Layer layer = new("Red") { Color = new ACadSharp.Color(1), LineWeight = LineWeightType.W50 }; + Line line = new(new XYZ(0, 0, 0), new XYZ(10, 0, 0)) { Layer = layer }; + + dispatcher.Draw(CreateContext(surface, configuration), line); + + LayerRenderInfo info = Assert.Single(surface.Layers); + Assert.Equal("Red", info.LayerName); + Assert.Equal(SixLabors.ImageSharp.Color.FromRgb(255, 0, 0), info.Color); + Assert.Equal(configuration.GetLineWeightPixels(LineWeightType.W50), info.StrokeWidth); + } +} +``` + +- [ ] **Step 2: Run to see them fail** + +Run: `dotnet test ACadSharp.Image.sln -c Release --nologo -v q --filter "FullyQualifiedName~EntityRenderDispatcherTests"` +Expected: FAIL (`Calls.Count` is 1, no `BeginEntity`). + +- [ ] **Step 3: Implement entity boundaries in the dispatcher** + +In `ACadSharp.Image/Rendering/EntityRenderDispatcher.cs` add `using ACadSharp.Image.Extensions;` (for `ToImageColor`) and change `Draw` into a thin public entry that calls a private recursive method carrying the parent state: + +```csharp + public void Draw(ImageRenderContext context, Entity entity) + { + this.Draw(context, entity, parentLayerName: null, parentHandle: null, blockName: null); + } + + private void Draw(ImageRenderContext context, Entity entity, string? parentLayerName, ulong? parentHandle, string? blockName) + { + ImageStyle style = this._styleResolver.Resolve(entity, context); + string layerName = GetEffectiveLayerName(entity, parentLayerName); + EntityRenderInfo info = new(layerName, entity.ObjectName, entity.Handle, parentHandle, blockName); + LayerRenderInfo layerInfo = CreateLayerInfo(entity.Layer, layerName, context); + + context.Surface.BeginEntity(info, layerInfo); + try + { + switch (entity) + { + // ... every existing case unchanged, except the two recursive ones: + case Dimension dimension: + this.DrawDimension(context, dimension, layerName); + break; + case Insert insert: + this.DrawBlockContents(context, insert, layerName); + break; + // ... + } + } + finally + { + context.Surface.EndEntity(); + } + } + + /// + /// Entities on layer "0" inside a block take the layer of the insert that placed them. + /// + internal static string GetEffectiveLayerName(Entity entity, string? parentLayerName) + { + string? own = entity.Layer?.Name; + if (string.IsNullOrEmpty(own)) + { + return parentLayerName ?? Layer.DefaultName; + } + + if (parentLayerName != null && string.Equals(own, Layer.DefaultName, StringComparison.Ordinal)) + { + return parentLayerName; + } + + return own; + } + + private static LayerRenderInfo CreateLayerInfo(Layer? layer, string layerName, ImageRenderContext context) + { + if (layer == null) + { + return new LayerRenderInfo(layerName, SixLabors.ImageSharp.Color.Black, context.ToStrokeWidth(LineWeightType.Default)); + } + + return new LayerRenderInfo(layerName, layer.Color.ToImageColor(), context.ToStrokeWidth(layer.LineWeight)); + } +``` + +Then update the two recursive helpers so nested entities receive the parent information: + +```csharp + private void DrawDimension(ImageRenderContext context, Dimension dimension, string layerName) + { + // ... block lookup unchanged ... + foreach (Entity entity in block.Entities) + { + if (entity is ACadSharp.Entities.Point) + { + continue; + } + + this.Draw(context, entity, layerName, dimension.Handle, blockName: null); + } + } + + private void DrawBlockContents(ImageRenderContext context, Insert insert, string layerName) + { + foreach (Entity entity in insert.Explode()) + { + this.Draw(context, entity, layerName, insert.Handle, insert.Block?.Name); + } + } +``` + +- [ ] **Step 4: Run the dispatcher tests, then the whole suite** + +Run: `dotnet test ACadSharp.Image.sln -c Release --nologo -v q` +Expected: PASS. Raster is unaffected because `RasterDrawingSurface.BeginEntity/EndEntity` are no-ops, so parity must still be green. + +- [ ] **Step 5: Commit** + +```bash +git add ACadSharp.Image/Rendering/EntityRenderDispatcher.cs ACadSharp.Image.Tests/RecordingDrawingSurface.cs ACadSharp.Image.Tests/EntityRenderDispatcherTests.cs +git commit -m "Emit entity boundaries with effective layer to the surface + +Co-Authored-By: Claude Fable 5.1 +Claude-Session: https://claude.ai/code/session_016RGgSinQSUz4d89FRLxhMz" +``` + +--- + +## Self-review checklist (run before handing over) + +- Spec coverage for this plan: section 3 (ACadSharp 3.7.1, net6.0 drop, no new deps, parity) → Tasks 1, 2, 4; section 6 (`RenderedPage`, `Render(format)`) → Task 5; section 9 types → Task 3; effective layer rule (section 4.2) → Task 6. +- Every type and member name used in Tasks 4 to 6 (`SurfacePoint`, `ImageStyle.EffectiveColor`, `IDrawingSurface.*`, `ViewportSurface`, `ImageRenderContext.ToSurfacePoint/ToSurfaceLength/ToStrokeWidth/Scale/SinglePrecision`, `SplineRenderer.ShouldClosePoints`, `SplineRenderer.TryGetBezierSegments`, `ImagePageRenderer.RenderTo`, `RenderedPage`, `RenderedImagePage(name, canvas, format, quality)`) is defined in an earlier task of this plan or in spec section 9. +- Parity is checked by `SampleParityTests` after Tasks 2, 3, 4, 5 and 6. diff --git a/docs/superpowers/plans/2026-09-02-02-svg-backend.md b/docs/superpowers/plans/2026-09-02-02-svg-backend.md new file mode 100644 index 0000000..9327448 --- /dev/null +++ b/docs/superpowers/plans/2026-09-02-02-svg-backend.md @@ -0,0 +1,2409 @@ +# SVG Backend Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add `ImageExportFormat.Svg` backed by a hand-written `SvgDrawingSurface` that produces React-friendly SVG (drawing-unit viewBox, per-layer groups with `data-*` attributes, real ``, native arcs and Beziers, non-scaling strokes), wired through the exporter and the CLI. + +**Architecture:** `SvgDrawingSurface` implements `IDrawingSurface` (plan 1) on top of `System.Xml.Linq`, keeping one `` per effective layer inside the current container (page or viewport). A double-precision `ImageRenderContext` maps drawing units 1:1 with a Y flip. The dispatcher emits true curves when `Surface.SupportsCurves` is true, and degree-3 splines are converted to Bezier chains by knot insertion. `RenderedSvgPage` carries the document text. + +**Tech Stack:** .NET 8/10, `System.Xml.Linq`, ACadSharp 3.7.1, xUnit. No new packages. + +**Spec:** `docs/superpowers/specs/2026-09-02-layers-and-svg-design.md` (sections 5, 6, 7, 9). Requires plan `2026-09-02-01-drawing-surface-foundation.md` to be complete. + +## Global Constraints + +- Same as plan 1: no new NuGet packages, repo coding style, run from the worktree root, commit per task with the two trailer lines: + +``` +Co-Authored-By: Claude Fable 5.1 +Claude-Session: https://claude.ai/code/session_016RGgSinQSUz4d89FRLxhMz +``` + +- Raster parity (`SampleParityTests`) must stay green after every task. The raster path never sees `SupportsCurves == true`, so dispatcher changes for curves must be behind that flag. +- All SVG numbers are written with `CultureInfo.InvariantCulture`. +- SVG namespace: `http://www.w3.org/2000/svg`. Create elements with `XName` from `SvgDrawingSurface.Ns` so the root carries `xmlns` once. + +## File Structure + +| File | Responsibility | Task | +| --- | --- | --- | +| `ACadSharp.Image/SvgOptions.cs` (create) | Public SVG options | 1 | +| `ACadSharp.Image/ImageConfiguration.cs` (modify) | `Svg` property, `GetLineWeightMillimeters` | 1 | +| `ACadSharp.Image/ImageExportFormat.cs`, `ImageExportFormatExtensions.cs` (modify) | `Svg` member, `.svg` | 1 | +| `ACadSharp.Image.Tests/ImageExportFormatExtensionsTests.cs`, `SvgOptionsTests.cs` (modify/create) | | 1 | +| `ACadSharp.Image/Rendering/Svg/SvgNumberFormatter.cs`, `SvgIdSanitizer.cs` (create) | Number and id formatting | 2 | +| `ACadSharp.Image.Tests/SvgFormattingTests.cs` (create) | | 2 | +| `ACadSharp.Image/Rendering/Svg/SvgDrawingSurface.cs` (create) | The backend | 3, 4, 5, 6 | +| `ACadSharp.Image.Tests/SvgDrawingSurfaceTests.cs` (create) | Structural tests | 3, 4, 5, 6 | +| `ACadSharp.Image/Rendering/SplineBezierConverter.cs` (create), `SplineRenderer.cs`, `EntityRenderDispatcher.cs` (modify) | Curves when the surface supports them | 4 | +| `ACadSharp.Image.Tests/SplineBezierConverterTests.cs`, `EntityRenderDispatcherTests.cs` (create/modify) | | 4 | +| `ACadSharp.Image/RenderedSvgPage.cs` (create), `ImagePage.cs`, `ImageExporter.cs`, `Rendering/ImageRenderContext.cs`, `Rendering/ImagePageRenderer.cs` (modify) | SVG page context, stroke-width units, wiring | 7 | +| `ACadSharp.Image.Tests/SvgExportTests.cs` (create) | End-to-end structure tests | 7 | +| `ACadSharp.Image.Cli/Program.cs`, `CliOptions.cs` (modify) | `--format svg`, `--svg-*` | 8 | +| `ACadSharp.Image.Tests/SampleParityTests.cs` (modify), `Baselines/*.svg` (create) | Golden SVGs | 9 | + +--- + +### Task 1: `SvgOptions`, `ImageExportFormat.Svg`, millimetre lookup + +**Files:** +- Create: `ACadSharp.Image/SvgOptions.cs` +- Modify: `ACadSharp.Image/ImageConfiguration.cs` (add `Svg` property near `FontFamilyName`, add `GetLineWeightMillimeters` next to `GetLineWeightPixels`) +- Modify: `ACadSharp.Image/ImageExportFormat.cs`, `ACadSharp.Image/ImageExportFormatExtensions.cs` +- Test: `ACadSharp.Image.Tests/ImageExportFormatExtensionsTests.cs`, create `ACadSharp.Image.Tests/SvgOptionsTests.cs` + +**Interfaces:** +- Produces: `public sealed class SvgOptions { bool NonScalingStroke = true; bool EmitEntityAttributes = true; bool EmitSize = false; string IdPrefix = ""; int? Precision (0..8) }`, `ImageConfiguration.Svg`, `ImageConfiguration.GetLineWeightMillimeters(LineWeightType) : double`, `ImageExportFormat.Svg`, `".svg"`. + +- [ ] **Step 1: Failing tests** + +In `ACadSharp.Image.Tests/ImageExportFormatExtensionsTests.cs` add `[InlineData("svg", ImageExportFormat.Svg)]` to `TryParseRecognizesSupportedFormats` and `[InlineData(".svg", ImageExportFormat.Svg)]` to `TryParseFileExtensionRecognizesSupportedExtensions`, plus: + +```csharp + [Fact] + public void SvgFileExtensionIsDotSvg() + { + Assert.Equal(".svg", ImageExportFormat.Svg.GetFileExtension()); + } +``` + +Create `ACadSharp.Image.Tests/SvgOptionsTests.cs`: + +```csharp +namespace ACadSharp.Image.Tests; + +public sealed class SvgOptionsTests +{ + [Fact] + public void DefaultsMatchSpec() + { + SvgOptions options = new ImageConfiguration().Svg; + + Assert.True(options.NonScalingStroke); + Assert.True(options.EmitEntityAttributes); + Assert.False(options.EmitSize); + Assert.Equal(string.Empty, options.IdPrefix); + Assert.Null(options.Precision); + } + + [Fact] + public void PrecisionIsValidated() + { + SvgOptions options = new(); + + options.Precision = 3; + Assert.Equal(3, options.Precision); + Assert.Throws(() => options.Precision = 9); + Assert.Throws(() => options.Precision = -1); + } + + [Fact] + public void LineWeightMillimetersUsesOverridesThenDefaults() + { + ImageConfiguration configuration = new(); + + Assert.Equal(0.25d, configuration.GetLineWeightMillimeters(ACadSharp.LineWeightType.W25)); + configuration.SetLineWeight(ACadSharp.LineWeightType.W25, 0.4d); + Assert.Equal(0.4d, configuration.GetLineWeightMillimeters(ACadSharp.LineWeightType.W25)); + Assert.Equal(0d, configuration.GetLineWeightMillimeters(ACadSharp.LineWeightType.ByLayer)); + } +} +``` + +- [ ] **Step 2: Run, expect compile failure** + +Run: `dotnet test ACadSharp.Image.sln -c Release --nologo -v q --filter "FullyQualifiedName~SvgOptionsTests|FullyQualifiedName~ImageExportFormatExtensionsTests"` + +- [ ] **Step 3: Implement** + +Create `ACadSharp.Image/SvgOptions.cs`: + +```csharp +namespace ACadSharp.Image; + +/// +/// Settings that only affect output. +/// +public sealed class SvgOptions +{ + private int? _precision; + + /// + /// Gets or sets whether strokes keep a constant on-screen width when the SVG is zoomed. + /// When true (default) stroke widths are written in pixels with vector-effect="non-scaling-stroke"; + /// when false they are written in drawing units and scale with the drawing. + /// + public bool NonScalingStroke { get; set; } = true; + + /// + /// Gets or sets whether each element carries data-handle, data-type, data-parent and data-block attributes. Default true. + /// + public bool EmitEntityAttributes { get; set; } = true; + + /// + /// Gets or sets whether the root element gets width/height attributes from and . Default false (responsive). + /// + public bool EmitSize { get; set; } + + /// + /// Gets or sets a prefix for every id so several drawings can be inlined in one HTML document. Default empty. + /// + public string IdPrefix { get; set; } = string.Empty; + + /// + /// Gets or sets the number of decimals for coordinates (0..8). Null (default) picks the precision from the viewBox size + /// so the resolution is one ten-thousandth of the larger side. + /// + public int? Precision + { + get => this._precision; + set => this._precision = value is null or (>= 0 and <= 8) + ? value + : throw new ArgumentOutOfRangeException(nameof(value), "Precision must be between 0 and 8."); + } +} +``` + +In `ImageConfiguration` add after `FontFamilyName`: + +```csharp + /// + /// Gets the settings that only affect SVG output. + /// + public SvgOptions Svg { get; } = new(); +``` + +and after `GetLineWeightPixels`: + +```csharp + /// + /// Gets the configured millimetre value for a line weight (overrides first, then , else 0). + /// + public double GetLineWeightMillimeters(LineWeightType lineWeight) + { + return this._lineWeightValues.TryGetValue(lineWeight, out double configured) + ? configured + : LineWeightDefaultValues.TryGetValue(lineWeight, out double fallback) + ? fallback + : 0d; + } +``` + +Refactor `GetLineWeightPixels` to call it: `double millimeters = this.GetLineWeightMillimeters(lineWeight);` (behaviour identical). + +In `ImageExportFormat` append: + +```csharp + /// + /// Scalable Vector Graphics. Resolution independent, keeps layers as groups and text as text. + /// + Svg, +``` + +In `ImageExportFormatExtensions`: `ImageExportFormat.Svg => ".svg",` in `GetFileExtension`; `"svg" => Success(ImageExportFormat.Svg, out format),` in `TryParse`. + +- [ ] **Step 4: Run tests, then whole suite** + +Run: `dotnet test ACadSharp.Image.sln -c Release --nologo -v q` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add ACadSharp.Image ACadSharp.Image.Tests +git commit -m "Add SvgOptions and the Svg export format + +Co-Authored-By: Claude Fable 5.1 +Claude-Session: https://claude.ai/code/session_016RGgSinQSUz4d89FRLxhMz" +``` + +--- + +### Task 2: Number formatting and id sanitising + +**Files:** +- Create: `ACadSharp.Image/Rendering/Svg/SvgNumberFormatter.cs` +- Create: `ACadSharp.Image/Rendering/Svg/SvgIdSanitizer.cs` +- Test: `ACadSharp.Image.Tests/SvgFormattingTests.cs` + +**Interfaces:** +- Produces: `internal sealed class SvgNumberFormatter(int decimals) { string Format(double); static int AdaptiveDecimals(double width, double height); }`, `internal static class SvgIdSanitizer { static string Sanitize(string prefix, string kind, string name); }`. + +- [ ] **Step 1: Failing tests** + +Create `ACadSharp.Image.Tests/SvgFormattingTests.cs`: + +```csharp +using ACadSharp.Image.Rendering.Svg; + +namespace ACadSharp.Image.Tests; + +public sealed class SvgFormattingTests +{ + [Theory] + [InlineData(3, 1.23456, "1.235")] + [InlineData(3, 10.0, "10")] + [InlineData(3, -0.0004, "0")] + [InlineData(0, 2.5, "3")] + [InlineData(2, 1234567.891, "1234567.89")] + public void FormatRoundsAndTrimsTrailingZeros(int decimals, double value, string expected) + { + SvgNumberFormatter formatter = new(decimals); + + Assert.Equal(expected, formatter.Format(value)); + } + + [Theory] + [InlineData(50000, 30000, 0)] // 50 m site plan in mm: 1e-4 * 50000 = 5 -> whole units + [InlineData(420, 297, 2)] // A3 sheet in mm: 0.042 -> 2 decimals + [InlineData(20, 10, 3)] // 20 mm part: 0.002 -> 3 decimals + [InlineData(0.5, 0.5, 5)] + [InlineData(1e-9, 1e-9, 8)] + public void AdaptiveDecimalsTargetsOneTenThousandthOfTheLargerSide(double width, double height, int expected) + { + Assert.Equal(expected, SvgNumberFormatter.AdaptiveDecimals(width, height)); + } + + [Theory] + [InlineData("", "layer", "Walls", "layer-walls")] + [InlineData("plan1-", "layer", "A-WALL Exterior (new)", "plan1-layer-a-wall-exterior-new")] + [InlineData("", "layer", "0", "layer-0")] + [InlineData("", "clip", "", "clip-")] + public void SanitizeProducesSafeIds(string prefix, string kind, string name, string expected) + { + Assert.Equal(expected, SvgIdSanitizer.Sanitize(prefix, kind, name)); + } +} +``` + +- [ ] **Step 2: Run, expect compile failure** + +- [ ] **Step 3: Implement** + +Create `ACadSharp.Image/Rendering/Svg/SvgNumberFormatter.cs`: + +```csharp +using System.Globalization; + +namespace ACadSharp.Image.Rendering.Svg; + +/// +/// Formats numbers for SVG attributes: invariant culture, fixed decimals, trailing zeros trimmed, no negative zero. +/// +internal sealed class SvgNumberFormatter +{ + private readonly int _decimals; + + public SvgNumberFormatter(int decimals) + { + this._decimals = Math.Clamp(decimals, 0, 8); + } + + public int Decimals => this._decimals; + + public string Format(double value) + { + double rounded = Math.Round(value, this._decimals, MidpointRounding.AwayFromZero); + if (rounded == 0d) + { + return "0"; + } + + string text = rounded.ToString("F" + this._decimals.ToString(CultureInfo.InvariantCulture), CultureInfo.InvariantCulture); + if (this._decimals > 0) + { + text = text.TrimEnd('0').TrimEnd('.'); + } + + return text; + } + + /// + /// Decimals such that the resolution is one ten-thousandth of the larger viewBox side, clamped to 0..8. + /// + public static int AdaptiveDecimals(double width, double height) + { + double size = Math.Max(Math.Abs(width), Math.Abs(height)); + if (size <= 0d || double.IsNaN(size) || double.IsInfinity(size)) + { + return 3; + } + + int decimals = 4 - (int)Math.Floor(Math.Log10(size)); + return Math.Clamp(decimals, 0, 8); + } +} +``` + +Check the theory values against the formula: 50000 → log10 = 4.69 → floor 4 → 0; 420 → 2.62 → floor 2 → 2; 20 → 1.3 → 1 → 3; 0.5 → -0.3 → floor -1 → 5; 1e-9 → -9 → 13 → clamped 8. + +Create `ACadSharp.Image/Rendering/Svg/SvgIdSanitizer.cs`: + +```csharp +using System.Text; + +namespace ACadSharp.Image.Rendering.Svg; + +/// +/// Builds HTML-safe ids: {prefix}{kind}-{name} with the name lower-cased and every run of characters outside +/// [a-z0-9_-] collapsed into a single dash. +/// +internal static class SvgIdSanitizer +{ + public static string Sanitize(string prefix, string kind, string name) + { + StringBuilder builder = new(prefix.Length + kind.Length + name.Length + 1); + builder.Append(prefix).Append(kind).Append('-'); + + bool pendingDash = false; + foreach (char c in name.ToLowerInvariant()) + { + bool safe = (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '_' || c == '-'; + if (safe) + { + if (pendingDash && builder[^1] != '-') + { + builder.Append('-'); + } + + pendingDash = false; + builder.Append(c); + } + else + { + pendingDash = true; + } + } + + return builder.ToString(); + } +} +``` + +`"A-WALL Exterior (new)"` → `a-wall-exterior-new` (the trailing `)` sets `pendingDash` but nothing follows, so no trailing dash). + +- [ ] **Step 4: Run tests** + +Run: `dotnet test ACadSharp.Image.sln -c Release --nologo -v q --filter "FullyQualifiedName~SvgFormattingTests"` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add ACadSharp.Image/Rendering/Svg ACadSharp.Image.Tests/SvgFormattingTests.cs +git commit -m "Add SVG number and id formatting helpers + +Co-Authored-By: Claude Fable 5.1 +Claude-Session: https://claude.ai/code/session_016RGgSinQSUz4d89FRLxhMz" +``` + +--- + +### Task 3: `SvgDrawingSurface` core: document, layer groups, straight primitives + +**Files:** +- Create: `ACadSharp.Image/Rendering/Svg/SvgDrawingSurface.cs` +- Test: `ACadSharp.Image.Tests/SvgDrawingSurfaceTests.cs` + +**Interfaces:** +- Consumes: `IDrawingSurface`, `ImageStyle`, `EntityRenderInfo`, `LayerRenderInfo`, `SurfacePoint`, `SvgNumberFormatter`, `SvgIdSanitizer`, `SvgOptions`. +- Produces: `internal sealed class SvgDrawingSurface : IDrawingSurface` with constructor `SvgDrawingSurface(ImageConfiguration configuration, SurfaceRect viewBox, double? sizeWidth, double? sizeHeight)`, `XDocument ToDocument()`, `string ToSvgString()`, `static readonly XNamespace Ns`. Curves, text and viewports are added in Tasks 4 to 6 (they throw `NotImplementedException` until then, which is fine because nothing calls them yet). + +Key behaviours: +- Elements are appended to the layer group of the **current entity** (top of the entity stack). If a primitive is drawn with no entity open (tests), a group named `0` is used. +- The container stack starts with the defaults group; Task 6 pushes viewport groups. Each container has its own `Dictionary` of layer groups. +- Attributes on elements: `stroke` when different from the group's stroke; `stroke-width` when different from the group's; `stroke-dasharray` when `DashPattern` is set; `opacity` when `< 1`; `vector-effect="non-scaling-stroke"` on stroked elements when `Svg.NonScalingStroke`; `data-*` when `EmitEntityAttributes`. +- Fills: `fill=colour`, `stroke="none"`. +- Colour: `#rrggbb` from `Color.ToPixel()`; alpha is carried by `opacity`, not by the colour. + +- [ ] **Step 1: Failing tests** + +Create `ACadSharp.Image.Tests/SvgDrawingSurfaceTests.cs`: + +```csharp +using System.Xml.Linq; +using ACadSharp.Image.Rendering; +using ACadSharp.Image.Rendering.Svg; +using SixLabors.ImageSharp; + +namespace ACadSharp.Image.Tests; + +public sealed class SvgDrawingSurfaceTests +{ + private static readonly XNamespace Ns = SvgDrawingSurface.Ns; + + private static SvgDrawingSurface CreateSurface(Action? configure = null) + { + ImageConfiguration configuration = new(); + configure?.Invoke(configuration); + return new SvgDrawingSurface(configuration, new SurfaceRect(0, 0, 100, 50), null, null); + } + + 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); + + [Fact] + public void RootHasViewBoxAndNoSizeByDefault() + { + using SvgDrawingSurface surface = CreateSurface(); + + XElement root = surface.ToDocument().Root!; + + Assert.Equal(Ns + "svg", root.Name); + Assert.Equal("0 0 100 50", (string?)root.Attribute("viewBox")); + Assert.Null(root.Attribute("width")); + Assert.Null(root.Attribute("height")); + XElement cadRoot = Assert.Single(root.Elements(Ns + "g")); + Assert.Equal("cad-root", (string?)cadRoot.Attribute("class")); + Assert.Empty(cadRoot.Attributes().Where(a => a.Name != "class")); + XElement defaults = Assert.Single(cadRoot.Elements(Ns + "g")); + Assert.Equal("none", (string?)defaults.Attribute("fill")); + Assert.Contains("Arial", (string?)defaults.Attribute("font-family")); + } + + [Fact] + public void SizeIsEmittedWhenRequested() + { + ImageConfiguration configuration = new(); + using SvgDrawingSurface surface = new(configuration, new SurfaceRect(0, 0, 100, 50), 800, 400); + + XElement root = surface.ToDocument().Root!; + + Assert.Equal("800", (string?)root.Attribute("width")); + Assert.Equal("400", (string?)root.Attribute("height")); + } + + [Fact] + public void BackgroundRectOnlyWhenOpaque() + { + using SvgDrawingSurface opaque = CreateSurface(); + using SvgDrawingSurface transparent = CreateSurface(c => c.BackgroundColor = Color.Transparent); + + Assert.Single(opaque.ToDocument().Descendants(Ns + "rect").Where(r => (string?)r.Attribute("class") == "cad-background")); + Assert.Empty(transparent.ToDocument().Descendants(Ns + "rect")); + } + + [Fact] + public void EntitiesAreGroupedByEffectiveLayerInOrderOfFirstAppearance() + { + using SvgDrawingSurface surface = CreateSurface(c => c.Svg.IdPrefix = "p-"); + ImageStyle style = new(Color.FromRgb(255, 0, 0), 1f); + + surface.BeginEntity(Entity("Walls", handle: 0x1F3), Layer("Walls")); + surface.DrawLine(style, new SurfacePoint(0, 0), new SurfacePoint(10, 0)); + surface.EndEntity(); + surface.BeginEntity(Entity("Doors", handle: 0x1F4), Layer("Doors")); + surface.DrawLine(style, new SurfacePoint(0, 0), new SurfacePoint(0, 10)); + surface.EndEntity(); + surface.BeginEntity(Entity("Walls", handle: 0x1F5), Layer("Walls")); + surface.DrawLine(style, new SurfacePoint(5, 5), new SurfacePoint(6, 6)); + surface.EndEntity(); + + XElement defaults = surface.ToDocument().Root!.Element(Ns + "g")!.Element(Ns + "g")!; + List layers = defaults.Elements(Ns + "g").ToList(); + + Assert.Equal(2, layers.Count); + Assert.Equal("Walls", (string?)layers[0].Attribute("data-layer")); + Assert.Equal("p-layer-walls", (string?)layers[0].Attribute("id")); + Assert.Equal("cad-layer", (string?)layers[0].Attribute("class")); + Assert.Equal("#ff0000", (string?)layers[0].Attribute("stroke")); + Assert.Equal(2, layers[0].Elements(Ns + "line").Count()); + Assert.Equal("Doors", (string?)layers[1].Attribute("data-layer")); + + XElement first = layers[0].Elements(Ns + "line").First(); + Assert.Equal("1F3", (string?)first.Attribute("data-handle")); + Assert.Equal("LINE", (string?)first.Attribute("data-type")); + Assert.Null(first.Attribute("stroke")); // same as the layer group + Assert.Equal("non-scaling-stroke", (string?)first.Attribute("vector-effect")); + } + + [Fact] + public void NestedEntityCarriesParentAndBlock() + { + using SvgDrawingSurface surface = CreateSurface(); + ImageStyle style = new(Color.FromRgb(255, 0, 0), 1f); + + surface.BeginEntity(Entity("Doors", "INSERT", 0xA0), Layer("Doors")); + surface.BeginEntity(Entity("Doors", "LINE", 0xA1, 0xA0, "DOOR"), Layer("Doors")); + surface.DrawLine(style, new SurfacePoint(0, 0), new SurfacePoint(1, 1)); + surface.EndEntity(); + surface.EndEntity(); + + XElement line = Assert.Single(surface.ToDocument().Descendants(Ns + "line")); + Assert.Equal("A0", (string?)line.Attribute("data-parent")); + Assert.Equal("DOOR", (string?)line.Attribute("data-block")); + } + + [Fact] + 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. + 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.EndEntity(); + + XElement line = Assert.Single(surface.ToDocument().Descendants(Ns + "line")); + Assert.Null(line.Attribute("data-handle")); + Assert.Equal("LINE", (string?)line.Attribute("data-type")); + Assert.Equal("A0", (string?)line.Attribute("data-parent")); + } + + [Fact] + 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.EndEntity(); + + XElement line = Assert.Single(surface.ToDocument().Descendants(Ns + "line")); + Assert.Equal("#0000ff", (string?)line.Attribute("stroke")); + Assert.Equal("2.5", (string?)line.Attribute("stroke-width")); + Assert.Equal("4 2", (string?)line.Attribute("stroke-dasharray")); + Assert.Equal("0.5", (string?)line.Attribute("opacity")); + Assert.Null(line.Attribute("data-handle")); + } + + [Fact] + public void PolylineAndPolygonAndFills() + { + using SvgDrawingSurface surface = CreateSurface(); + ImageStyle style = new(Color.Black, 1f); + SurfacePoint[] tri = [new(0, 0), new(10, 0), new(10, 10)]; + + surface.BeginEntity(Entity("L"), Layer("L")); + surface.DrawPolyline(style, tri, closed: false); + surface.DrawPolyline(style, tri, closed: true); + surface.FillPolygon(style, tri); + surface.FillPath(style, [tri, [new(2, 2), new(4, 2), new(4, 4)]]); + surface.FillCircle(style, new SurfacePoint(5, 5), 2); + surface.EndEntity(); + + XDocument document = surface.ToDocument(); + Assert.Equal("0 0 10 0 10 10", (string?)Assert.Single(document.Descendants(Ns + "polyline")).Attribute("points")); + List polygons = document.Descendants(Ns + "polygon").ToList(); + Assert.Equal(2, polygons.Count); + Assert.Null(polygons[0].Attribute("fill")); + Assert.Equal("#000000", (string?)polygons[1].Attribute("fill")); + Assert.Equal("none", (string?)polygons[1].Attribute("stroke")); + XElement path = Assert.Single(document.Descendants(Ns + "path")); + Assert.Equal("evenodd", (string?)path.Attribute("fill-rule")); + Assert.Equal("M0 0L10 0L10 10ZM2 2L4 2L4 4Z", (string?)path.Attribute("d")); + XElement circle = Assert.Single(document.Descendants(Ns + "circle")); + Assert.Equal("2", (string?)circle.Attribute("r")); + Assert.Equal("#000000", (string?)circle.Attribute("fill")); + } + + [Fact] + public void DrawingUnitStrokesOmitVectorEffect() + { + using SvgDrawingSurface surface = CreateSurface(c => c.Svg.NonScalingStroke = false); + + surface.BeginEntity(Entity("L"), Layer("L")); + surface.DrawLine(new ImageStyle(Color.Black, 0.25f), new SurfacePoint(0, 0), new SurfacePoint(1, 1)); + surface.EndEntity(); + + XElement line = Assert.Single(surface.ToDocument().Descendants(Ns + "line")); + Assert.Null(line.Attribute("vector-effect")); + } +} +``` + +- [ ] **Step 2: Run, expect compile failure** + +- [ ] **Step 3: Implement the surface core** + +Create `ACadSharp.Image/Rendering/Svg/SvgDrawingSurface.cs`: + +```csharp +using System.Globalization; +using System.Text; +using System.Xml; +using System.Xml.Linq; +using ImageColor = SixLabors.ImageSharp.Color; +using Rgba32 = SixLabors.ImageSharp.PixelFormats.Rgba32; + +namespace ACadSharp.Image.Rendering.Svg; + +/// +/// that builds an SVG document with one <g> per effective layer. +/// +/// +/// Coordinates handed to this surface are already in SVG user units (drawing units, Y down). +/// See the design spec section 5 for the document structure. +/// +internal sealed class SvgDrawingSurface : IDrawingSurface +{ + public static readonly XNamespace Ns = "http://www.w3.org/2000/svg"; + + private readonly ImageConfiguration _configuration; + private readonly SvgOptions _options; + private readonly SvgNumberFormatter _numbers; + private readonly XElement _root; + private readonly XElement _defs; + private readonly XElement _defaults; + private readonly Stack _containers = new(); + private readonly Stack<(EntityRenderInfo Info, LayerRenderInfo Layer)> _entities = new(); + private int _clipCounter; + + public SvgDrawingSurface(ImageConfiguration configuration, SurfaceRect viewBox, double? sizeWidth, double? sizeHeight) + { + this._configuration = configuration; + this._options = configuration.Svg; + this._numbers = new SvgNumberFormatter(this._options.Precision ?? SvgNumberFormatter.AdaptiveDecimals(viewBox.Width, viewBox.Height)); + + this._root = new XElement(Ns + "svg", + new XAttribute("viewBox", $"{this.N(viewBox.X)} {this.N(viewBox.Y)} {this.N(viewBox.Width)} {this.N(viewBox.Height)}")); + if (sizeWidth is > 0 && sizeHeight is > 0) + { + this._root.Add(new XAttribute("width", this.N(sizeWidth.Value)), new XAttribute("height", this.N(sizeHeight.Value))); + } + + this._defs = new XElement(Ns + "defs"); + XElement cadRoot = new(Ns + "g", new XAttribute("class", "cad-root")); + this._defaults = new XElement(Ns + "g", + new XAttribute("fill", "none"), + new XAttribute("stroke-linecap", "round"), + new XAttribute("stroke-linejoin", "round"), + new XAttribute("font-family", BuildFontStack(configuration.FontFamilyName))); + + Rgba32 background = configuration.BackgroundColor.ToPixel(); + if (background.A > 0) + { + this._defaults.Add(new XElement(Ns + "rect", + new XAttribute("class", "cad-background"), + new XAttribute("x", this.N(viewBox.X)), + new XAttribute("y", this.N(viewBox.Y)), + new XAttribute("width", this.N(viewBox.Width)), + new XAttribute("height", this.N(viewBox.Height)), + new XAttribute("fill", Hex(configuration.BackgroundColor)), + new XAttribute("stroke", "none"))); + } + + cadRoot.Add(this._defaults); + this._root.Add(cadRoot); + this._containers.Push(new Container(this._defaults, "layer")); + } + + public bool SupportsCurves => true; + + public XDocument ToDocument() + { + XElement clone = new(this._root); + if (this._defs.HasElements) + { + clone.AddFirst(new XElement(this._defs)); + } + + return new XDocument(new XDeclaration("1.0", "utf-8", null), clone); + } + + public string ToSvgString() + { + StringBuilder builder = new(); + // No XML declaration: XmlWriter over a StringBuilder would declare utf-16, which contradicts the UTF-8 bytes RenderedSvgPage writes, + // and inline SVG in HTML must not carry a declaration anyway. + XmlWriterSettings settings = new() { Indent = true, OmitXmlDeclaration = true, NewLineChars = "\n" }; + using (XmlWriter writer = XmlWriter.Create(builder, settings)) + { + this.ToDocument().Save(writer); + } + + return builder.ToString(); + } + + public void BeginEntity(EntityRenderInfo info, LayerRenderInfo layer) + { + this._entities.Push((info, layer)); + } + + public void EndEntity() + { + if (this._entities.Count > 0) + { + this._entities.Pop(); + } + } + + public void DrawLine(ImageStyle style, SurfacePoint start, SurfacePoint end) + { + this.Append(this.Stroked(new XElement(Ns + "line", + new XAttribute("x1", this.N(start.X)), new XAttribute("y1", this.N(start.Y)), + new XAttribute("x2", this.N(end.X)), new XAttribute("y2", this.N(end.Y))), style)); + } + + public void DrawPolyline(ImageStyle style, IReadOnlyList points, bool closed) + { + if (points.Count < 2) + { + return; + } + + XElement element = new(Ns + (closed ? "polygon" : "polyline"), new XAttribute("points", this.Points(points))); + this.Append(this.Stroked(element, style)); + } + + public void DrawArc(ImageStyle style, SurfacePoint center, double radiusX, double radiusY, double rotation, double startAngle, double sweepAngle) + { + throw new NotImplementedException("Task 4"); + } + + public void DrawEllipse(ImageStyle style, SurfacePoint center, double radiusX, double radiusY, double rotation) + { + throw new NotImplementedException("Task 4"); + } + + public void DrawCubicBezier(ImageStyle style, IReadOnlyList controlPoints, bool closed) + { + throw new NotImplementedException("Task 4"); + } + + public void DrawBulgePolyline(ImageStyle style, IReadOnlyList points, IReadOnlyList bulges, bool closed) + { + throw new NotImplementedException("Task 4"); + } + + public void FillPolygon(ImageStyle style, IReadOnlyList points) + { + if (points.Count < 3) + { + return; + } + + this.Append(this.Filled(new XElement(Ns + "polygon", new XAttribute("points", this.Points(points))), style)); + } + + public void FillPath(ImageStyle style, IReadOnlyList> rings) + { + StringBuilder d = new(); + foreach (IReadOnlyList ring in rings) + { + if (ring.Count < 3) + { + continue; + } + + d.Append('M').Append(this.N(ring[0].X)).Append(' ').Append(this.N(ring[0].Y)); + for (int i = 1; i < ring.Count; i++) + { + d.Append('L').Append(this.N(ring[i].X)).Append(' ').Append(this.N(ring[i].Y)); + } + + d.Append('Z'); + } + + if (d.Length == 0) + { + return; + } + + XElement path = new(Ns + "path", new XAttribute("fill-rule", "evenodd"), new XAttribute("d", d.ToString())); + this.Append(this.Filled(path, style)); + } + + public void FillCircle(ImageStyle style, SurfacePoint center, double radius) + { + this.Append(this.Filled(new XElement(Ns + "circle", + new XAttribute("cx", this.N(center.X)), new XAttribute("cy", this.N(center.Y)), new XAttribute("r", this.N(radius))), style)); + } + + public void DrawText(ImageStyle style, SurfaceText text) + { + throw new NotImplementedException("Task 5"); + } + + public ViewportSurface BeginViewport(SurfaceRect bounds) + { + throw new NotImplementedException("Task 6"); + } + + public void EndViewport(ViewportSurface viewport) + { + throw new NotImplementedException("Task 6"); + } + + public void Dispose() + { + } + + // ---- element helpers ------------------------------------------------- + + private XElement Stroked(XElement element, ImageStyle style) + { + (LayerRenderInfo? layer, _) = this.CurrentLayer(); + string stroke = Hex(style.StrokeColor); + if (layer == null || !string.Equals(stroke, Hex(layer.Color), StringComparison.Ordinal)) + { + element.Add(new XAttribute("stroke", stroke)); + } + + if (layer == null || Math.Abs(style.StrokeWidth - layer.StrokeWidth) > 1e-6f) + { + element.Add(new XAttribute("stroke-width", this.N(style.StrokeWidth))); + } + + if (style.DashPattern is { Length: > 0 }) + { + element.Add(new XAttribute("stroke-dasharray", string.Join(" ", style.DashPattern.Select(v => this.N(v))))); + } + + if (this._options.NonScalingStroke) + { + element.Add(new XAttribute("vector-effect", "non-scaling-stroke")); + } + + this.AddCommon(element, style); + return element; + } + + private XElement Filled(XElement element, ImageStyle style) + { + element.Add(new XAttribute("fill", Hex(style.StrokeColor)), new XAttribute("stroke", "none")); + this.AddCommon(element, style); + return element; + } + + private void AddCommon(XElement element, ImageStyle style) + { + if (style.Opacity < 1f) + { + element.Add(new XAttribute("opacity", this.N(Math.Clamp(style.Opacity, 0f, 1f)))); + } + + if (this._options.EmitEntityAttributes && this._entities.Count > 0) + { + (EntityRenderInfo info, _) = this._entities.Peek(); + if (info.Handle != 0) + { + // Exploded block contents are transient clones with handle 0; a "0" handle would be meaningless to consumers. + element.Add(new XAttribute("data-handle", info.Handle.ToString("X", CultureInfo.InvariantCulture))); + } + + element.Add(new XAttribute("data-type", info.EntityType)); + if (info.ParentHandle is ulong parent) + { + element.Add(new XAttribute("data-parent", parent.ToString("X", CultureInfo.InvariantCulture))); + } + + if (!string.IsNullOrEmpty(info.BlockName)) + { + element.Add(new XAttribute("data-block", info.BlockName)); + } + } + } + + private void Append(XElement element) + { + this.CurrentLayerGroup().Add(element); + } + + private (LayerRenderInfo? Layer, string Name) CurrentLayer() + { + if (this._entities.Count == 0) + { + return (null, "0"); + } + + (EntityRenderInfo info, LayerRenderInfo layer) = this._entities.Peek(); + return (layer, info.LayerName); + } + + private XElement CurrentLayerGroup() + { + (LayerRenderInfo? layer, string name) = this.CurrentLayer(); + Container container = this._containers.Peek(); + if (container.Layers.TryGetValue(name, out XElement? group)) + { + return group; + } + + group = new XElement(Ns + "g", + new XAttribute("id", SvgIdSanitizer.Sanitize(this._options.IdPrefix, container.IdKind, name)), + new XAttribute("class", "cad-layer"), + new XAttribute("data-layer", name)); + if (layer != null) + { + group.Add(new XAttribute("stroke", Hex(layer.Color)), new XAttribute("stroke-width", this.N(layer.StrokeWidth))); + } + + container.Element.Add(group); + container.Layers[name] = group; + return group; + } + + private string N(double value) => this._numbers.Format(value); + + private string Points(IReadOnlyList points) + { + StringBuilder builder = new(points.Count * 12); + for (int i = 0; i < points.Count; i++) + { + if (i > 0) + { + builder.Append(' '); + } + + builder.Append(this.N(points[i].X)).Append(' ').Append(this.N(points[i].Y)); + } + + return builder.ToString(); + } + + internal static string Hex(ImageColor color) + { + Rgba32 pixel = color.ToPixel(); + return $"#{pixel.R:x2}{pixel.G:x2}{pixel.B:x2}"; + } + + private static string BuildFontStack(string fontFamilyName) + { + List families = new(); + foreach (string candidate in new[] { fontFamilyName, "Arial", "Helvetica", "sans-serif" }) + { + if (!string.IsNullOrWhiteSpace(candidate) && !families.Contains(candidate, StringComparer.OrdinalIgnoreCase)) + { + families.Add(candidate); + } + } + + return string.Join(", ", families.Select(f => f.Contains(' ') ? $"'{f}'" : f)); + } + + /// + /// A page or viewport group that owns its own set of layer groups. keeps layer ids unique + /// across containers ("layer" at page level, "clip-N-layer" inside viewport N). + /// + private sealed class Container + { + public Container(XElement element, string idKind) + { + this.Element = element; + this.IdKind = idKind; + } + + public XElement Element { get; } + + public string IdKind { get; } + + public Dictionary Layers { get; } = new(StringComparer.OrdinalIgnoreCase); + } +} +``` + +Note on `ToDocument`: cloning lets tests call it repeatedly while drawing continues; `` is inserted first only when it has children (clip paths from Task 6). + +- [ ] **Step 4: Run tests** + +Run: `dotnet test ACadSharp.Image.sln -c Release --nologo -v q --filter "FullyQualifiedName~SvgDrawingSurfaceTests"` +Expected: PASS (9 tests). The adaptive precision for a 100x50 viewBox is `4 - 2 = 2` decimals, so `2.5` and `0.5` format as expected. + +- [ ] **Step 5: Commit** + +```bash +git add ACadSharp.Image/Rendering/Svg/SvgDrawingSurface.cs ACadSharp.Image.Tests/SvgDrawingSurfaceTests.cs +git commit -m "Add SvgDrawingSurface with layer groups and straight primitives + +Co-Authored-By: Claude Fable 5.1 +Claude-Session: https://claude.ai/code/session_016RGgSinQSUz4d89FRLxhMz" +``` + +--- + +### Task 4: Curves: arcs, ellipses, bulges, Beziers (surface + dispatcher) + +**Files:** +- Modify: `ACadSharp.Image/Rendering/Svg/SvgDrawingSurface.cs` (the four `NotImplementedException` curve methods) +- Create: `ACadSharp.Image/Rendering/SplineBezierConverter.cs` +- Modify: `ACadSharp.Image/Rendering/SplineRenderer.cs` (use the converter when `SupportsCurves`) +- Modify: `ACadSharp.Image/Rendering/EntityRenderDispatcher.cs` (curve cases when `SupportsCurves`) +- Test: `ACadSharp.Image.Tests/SvgDrawingSurfaceTests.cs`, `ACadSharp.Image.Tests/SplineBezierConverterTests.cs`, `ACadSharp.Image.Tests/EntityRenderDispatcherTests.cs` + +**Interfaces:** +- Produces: `internal static class SplineBezierConverter { static bool TryConvert(Spline spline, out List bezierControlPoints); static void InsertKnot(List knots, List points, double u, int degree); }`. Dispatcher emits `DrawArc`, `DrawEllipse`, `DrawBulgePolyline`, `DrawCubicBezier` only when `context.Surface.SupportsCurves`. + +Geometry conventions (surface space, Y down): +- Drawing angle `a` becomes surface angle `-a`; drawing sweep `s` becomes `-s`. Ellipse rotation `r` becomes `-r`. +- SVG arc flags: `sweep-flag = surfaceSweep > 0 ? 1 : 0`; `large-arc-flag = |surfaceSweep| > PI ? 1 : 0`. `|surfaceSweep| >= 2PI - 1e-9` is drawn as a full ellipse. +- Point on an elliptical arc at parameter `t`: `center + (rx cos t) * (cos r, sin r) + (ry sin t) * (-sin r, cos r)` (same formula as `CurveTessellation.ArcPoints`). +- Bulge `b` on a segment: `CurveTessellation.BulgeArc` gives centre, radius and signed sweep; SVG `A r r 0 largeArc sweepFlag x y` with `largeArc = |b| > 1 ? 1 : 0`, `sweepFlag = sweep > 0 ? 1 : 0` (so a positive bulge gives `0`). + +- [ ] **Step 1: Failing surface tests** + +Append to `SvgDrawingSurfaceTests`: + +```csharp + [Fact] + public void ArcIsWrittenAsPathWithFlags() + { + using SvgDrawingSurface surface = CreateSurface(); + + // Quarter turn clockwise on screen (positive surface sweep) from angle 0. + surface.DrawArc(new ImageStyle(Color.Black, 1f), new SurfacePoint(50, 25), 10, 10, 0, 0, Math.PI / 2); + + XElement path = Assert.Single(surface.ToDocument().Descendants(Ns + "path")); + Assert.Equal("M60 25A10 10 0 0 1 50 35", (string?)path.Attribute("d")); + } + + [Fact] + public void CounterClockwiseArcHasSweepFlagZeroAndLargeArcWhenOverHalfTurn() + { + using SvgDrawingSurface surface = CreateSurface(); + + surface.DrawArc(new ImageStyle(Color.Black, 1f), new SurfacePoint(50, 25), 10, 10, 0, 0, -1.5 * Math.PI); + + XElement path = Assert.Single(surface.ToDocument().Descendants(Ns + "path")); + Assert.Equal("M60 25A10 10 0 1 0 50 35", (string?)path.Attribute("d")); + } + + [Fact] + public void FullSweepBecomesEllipseAndCirclesUseCircle() + { + using SvgDrawingSurface surface = CreateSurface(); + + surface.DrawArc(new ImageStyle(Color.Black, 1f), new SurfacePoint(50, 25), 10, 5, Math.PI / 4, 0, 2 * Math.PI); + surface.DrawEllipse(new ImageStyle(Color.Black, 1f), new SurfacePoint(10, 10), 3, 3, 0); + + XDocument document = surface.ToDocument(); + XElement ellipse = Assert.Single(document.Descendants(Ns + "ellipse")); + Assert.Equal("10", (string?)ellipse.Attribute("rx")); + Assert.Equal("5", (string?)ellipse.Attribute("ry")); + Assert.Equal("rotate(45 50 25)", (string?)ellipse.Attribute("transform")); + XElement circle = Assert.Single(document.Descendants(Ns + "circle")); + Assert.Equal("3", (string?)circle.Attribute("r")); + Assert.Null(circle.Attribute("fill")); + } + + [Fact] + public void BulgePolylineWritesArcCommands() + { + using SvgDrawingSurface surface = CreateSurface(); + + surface.DrawBulgePolyline(new ImageStyle(Color.Black, 1f), [new(0, 0), new(10, 0), new(10, 10)], [1d, 0d], closed: false); + + XElement path = Assert.Single(surface.ToDocument().Descendants(Ns + "path")); + Assert.Equal("M0 0A5 5 0 0 0 10 0L10 10", (string?)path.Attribute("d")); + } + + [Fact] + public void CubicBezierWritesCCommands() + { + using SvgDrawingSurface surface = CreateSurface(); + + surface.DrawCubicBezier(new ImageStyle(Color.Black, 1f), [new(0, 0), new(1, 2), new(3, 2), new(4, 0)], closed: true); + + XElement path = Assert.Single(surface.ToDocument().Descendants(Ns + "path")); + Assert.Equal("M0 0C1 2 3 2 4 0Z", (string?)path.Attribute("d")); + } +``` + +Create `ACadSharp.Image.Tests/SplineBezierConverterTests.cs`: + +```csharp +using ACadSharp.Entities; +using ACadSharp.Image.Rendering; +using CSMath; + +namespace ACadSharp.Image.Tests; + +public sealed class SplineBezierConverterTests +{ + private static Spline ClampedUniformCubic() + { + Spline spline = new() { Degree = 3 }; + spline.Knots.AddRange([0d, 0d, 0d, 0d, 1d, 2d, 3d, 3d, 3d, 3d]); + spline.ControlPoints.AddRange([ + new XYZ(0, 0, 0), new XYZ(1, 3, 0), new XYZ(3, 4, 0), new XYZ(5, 1, 0), new XYZ(7, 3, 0), new XYZ(9, 0, 0), + ]); + return spline; + } + + [Fact] + public void ConvertsClampedCubicIntoBezierChain() + { + Spline spline = ClampedUniformCubic(); + + Assert.True(SplineBezierConverter.TryConvert(spline, out List bezier)); + + // 3 knot spans -> 3 segments -> 10 control points. + Assert.Equal(10, bezier.Count); + Assert.Equal(spline.ControlPoints[0], bezier[0]); + Assert.Equal(spline.ControlPoints[^1], bezier[^1]); + } + + [Theory] + [InlineData(0.25)] + [InlineData(1.0)] + [InlineData(1.7)] + [InlineData(2.9)] + public void BezierChainMatchesDeBoorEvaluation(double t) + { + Spline spline = ClampedUniformCubic(); + Assert.True(SplineBezierConverter.TryConvert(spline, out List bezier)); + + XY expected = SplineRenderer.EvaluateSplinePoint(3, spline.Knots, spline.ControlPoints, spline.Weights, t); + + // Segment index and local parameter for uniform interior knots 0,1,2,3. + int segment = Math.Min(2, (int)Math.Floor(t)); + double u = t - segment; + XYZ p0 = bezier[segment * 3]; + XYZ p1 = bezier[(segment * 3) + 1]; + XYZ p2 = bezier[(segment * 3) + 2]; + XYZ p3 = bezier[(segment * 3) + 3]; + double v = 1 - u; + double x = (v * v * v * p0.X) + (3 * v * v * u * p1.X) + (3 * v * u * u * p2.X) + (u * u * u * p3.X); + double y = (v * v * v * p0.Y) + (3 * v * v * u * p1.Y) + (3 * v * u * u * p2.Y) + (u * u * u * p3.Y); + + Assert.Equal(expected.X, x, 9); + Assert.Equal(expected.Y, y, 9); + } + + [Fact] + public void RejectsRationalUnclampedOrNonCubic() + { + Spline rational = ClampedUniformCubic(); + rational.Weights.AddRange(Enumerable.Repeat(2d, 6)); + Assert.False(SplineBezierConverter.TryConvert(rational, out _)); + + Spline quadratic = new() { Degree = 2 }; + quadratic.Knots.AddRange([0d, 0d, 0d, 1d, 1d, 1d]); + quadratic.ControlPoints.AddRange([new XYZ(0, 0, 0), new XYZ(1, 1, 0), new XYZ(2, 0, 0)]); + Assert.False(SplineBezierConverter.TryConvert(quadratic, out _)); + + Spline unclamped = ClampedUniformCubic(); + unclamped.Knots[0] = -1d; + Assert.False(SplineBezierConverter.TryConvert(unclamped, out _)); + } +} +``` + +Append to `EntityRenderDispatcherTests`: + +```csharp + [Fact] + public void CurveCapableSurfaceReceivesNativeArcsCirclesAndBulges() + { + RecordingDrawingSurface surface = new() { SupportsCurves = true }; + ImageConfiguration configuration = new(); + EntityRenderDispatcher dispatcher = new(configuration); + ImageRenderContext context = CreateContext(surface, configuration); + + dispatcher.Draw(context, new Arc { Center = new XYZ(10, 10, 0), Radius = 5, StartAngle = 0, EndAngle = Math.PI / 2 }); + dispatcher.Draw(context, new Circle { Center = new XYZ(0, 0, 0), Radius = 2 }); + LwPolyline polyline = new(); + polyline.Vertices.Add(new LwPolyline.Vertex(new XY(0, 0)) { Bulge = 1 }); + polyline.Vertices.Add(new LwPolyline.Vertex(new XY(10, 0))); + dispatcher.Draw(context, polyline); + + Assert.Contains(surface.Calls, c => c.StartsWith("DrawArc", StringComparison.Ordinal) && c.Contains("sweep=-1.57", StringComparison.Ordinal)); + Assert.Contains(surface.Calls, c => c.StartsWith("DrawEllipse", StringComparison.Ordinal) && c.Contains("rx=2", StringComparison.Ordinal)); + Assert.Contains(surface.Calls, c => c.StartsWith("DrawBulgePolyline n=2", StringComparison.Ordinal)); + Assert.DoesNotContain(surface.Calls, c => c.StartsWith("DrawPolyline", StringComparison.Ordinal)); + } + + [Fact] + public void RasterStyleSurfaceStillReceivesTessellatedPolylines() + { + RecordingDrawingSurface surface = new() { SupportsCurves = false }; + ImageConfiguration configuration = new(); + EntityRenderDispatcher dispatcher = new(configuration); + + dispatcher.Draw(CreateContext(surface, configuration), new Circle { Center = new XYZ(0, 0, 0), Radius = 2 }); + + Assert.Contains(surface.Calls, c => c.StartsWith("DrawPolyline", StringComparison.Ordinal)); + Assert.DoesNotContain(surface.Calls, c => c.StartsWith("DrawEllipse", StringComparison.Ordinal)); + } +``` + +`Arc` with `StartAngle 0, EndAngle PI/2` sweeps `+PI/2` in the drawing, so the surface sweep is `-PI/2` (the recording surface prints `sweep=-1.5707963267948966`; the substring check `sweep=-1.57` matches). + +- [ ] **Step 2: Run, expect compile failures** + +- [ ] **Step 3: Implement the surface curve methods** + +Replace the four curve stubs in `SvgDrawingSurface`: + +```csharp + public void DrawArc(ImageStyle style, SurfacePoint center, double radiusX, double radiusY, double rotation, double startAngle, double sweepAngle) + { + if (Math.Abs(sweepAngle) >= (2d * Math.PI) - 1e-9) + { + this.DrawEllipse(style, center, radiusX, radiusY, rotation); + return; + } + + SurfacePoint start = PointOnEllipse(center, radiusX, radiusY, rotation, startAngle); + SurfacePoint end = PointOnEllipse(center, radiusX, radiusY, rotation, startAngle + sweepAngle); + int largeArc = Math.Abs(sweepAngle) > Math.PI ? 1 : 0; + int sweepFlag = sweepAngle > 0 ? 1 : 0; + string d = $"M{this.N(start.X)} {this.N(start.Y)}A{this.N(radiusX)} {this.N(radiusY)} {this.N(rotation * 180d / Math.PI)} {largeArc} {sweepFlag} {this.N(end.X)} {this.N(end.Y)}"; + this.Append(this.Stroked(new XElement(Ns + "path", new XAttribute("d", d)), style)); + } + + public void DrawEllipse(ImageStyle style, SurfacePoint center, double radiusX, double radiusY, double rotation) + { + if (Math.Abs(radiusX - radiusY) < 1e-9) + { + this.Append(this.Stroked(new XElement(Ns + "circle", + new XAttribute("cx", this.N(center.X)), new XAttribute("cy", this.N(center.Y)), new XAttribute("r", this.N(radiusX))), style)); + return; + } + + XElement ellipse = new(Ns + "ellipse", + new XAttribute("cx", this.N(center.X)), new XAttribute("cy", this.N(center.Y)), + new XAttribute("rx", this.N(radiusX)), new XAttribute("ry", this.N(radiusY))); + if (Math.Abs(rotation) > 1e-12) + { + ellipse.Add(new XAttribute("transform", $"rotate({this.N(rotation * 180d / Math.PI)} {this.N(center.X)} {this.N(center.Y)})")); + } + + this.Append(this.Stroked(ellipse, style)); + } + + public void DrawCubicBezier(ImageStyle style, IReadOnlyList controlPoints, bool closed) + { + if (controlPoints.Count < 4) + { + return; + } + + StringBuilder d = new(); + d.Append('M').Append(this.N(controlPoints[0].X)).Append(' ').Append(this.N(controlPoints[0].Y)); + for (int i = 1; i + 2 < controlPoints.Count; i += 3) + { + d.Append('C'); + for (int j = 0; j < 3; j++) + { + if (j > 0) + { + d.Append(' '); + } + + d.Append(this.N(controlPoints[i + j].X)).Append(' ').Append(this.N(controlPoints[i + j].Y)); + } + } + + if (closed) + { + d.Append('Z'); + } + + this.Append(this.Stroked(new XElement(Ns + "path", new XAttribute("d", d.ToString())), style)); + } + + public void DrawBulgePolyline(ImageStyle style, IReadOnlyList points, IReadOnlyList bulges, bool closed) + { + if (points.Count < 2) + { + return; + } + + StringBuilder d = new(); + d.Append('M').Append(this.N(points[0].X)).Append(' ').Append(this.N(points[0].Y)); + int segmentCount = closed ? points.Count : points.Count - 1; + for (int i = 0; i < segmentCount; i++) + { + SurfacePoint start = points[i]; + SurfacePoint end = points[(i + 1) % points.Count]; + double bulge = i < bulges.Count ? bulges[i] : 0d; + if (Math.Abs(bulge) < 1e-12 || start == end) + { + d.Append('L').Append(this.N(end.X)).Append(' ').Append(this.N(end.Y)); + continue; + } + + CurveTessellation.BulgeArc(start, end, bulge, out _, out double radius, out _, out double sweep); + int largeArc = Math.Abs(bulge) > 1d ? 1 : 0; + int sweepFlag = sweep > 0 ? 1 : 0; + d.Append('A').Append(this.N(radius)).Append(' ').Append(this.N(radius)).Append(" 0 ").Append(largeArc).Append(' ').Append(sweepFlag).Append(' ') + .Append(this.N(end.X)).Append(' ').Append(this.N(end.Y)); + } + + if (closed) + { + d.Append('Z'); + } + + this.Append(this.Stroked(new XElement(Ns + "path", new XAttribute("d", d.ToString())), style)); + } + + private static SurfacePoint PointOnEllipse(SurfacePoint center, double radiusX, double radiusY, double rotation, double angle) + { + double x = radiusX * Math.Cos(angle); + double y = radiusY * Math.Sin(angle); + double cos = Math.Cos(rotation); + double sin = Math.Sin(rotation); + return new SurfacePoint(center.X + (x * cos) - (y * sin), center.Y + (x * sin) + (y * cos)); + } +``` + +The `M0 0A5 5 0 0 0 10 0L10 10` expectation: bulge 1 → theta PI, radius 5, `largeArc = 0` (|b| is not > 1), positive bulge → negative sweep → `sweepFlag 0`. + +- [ ] **Step 4: Implement the Bezier converter** + +Create `ACadSharp.Image/Rendering/SplineBezierConverter.cs`: + +```csharp +using ACadSharp.Entities; +using CSMath; + +namespace ACadSharp.Image.Rendering; + +/// +/// Converts clamped, non-rational cubic B-splines into a chain of cubic Bezier segments by Boehm knot insertion. +/// +internal static class SplineBezierConverter +{ + private const double KnotTolerance = 1e-9; + + public static bool TryConvert(Spline spline, out List bezierControlPoints) + { + bezierControlPoints = new List(); + const int degree = 3; + IReadOnlyList knots = spline.Knots; + IReadOnlyList controlPoints = spline.ControlPoints; + + if (spline.Degree != degree || + spline.Weights.Any(w => Math.Abs(w - 1d) > 1e-12) || + controlPoints.Count < degree + 1 || + knots.Count != controlPoints.Count + degree + 1 || + !HasMultiplicity(knots, 0, degree + 1) || + !HasMultiplicity(knots, knots.Count - (degree + 1), degree + 1)) + { + return false; + } + + List k = new(knots); + List p = new(controlPoints); + + int index = degree + 1; + while (index < k.Count - (degree + 1)) + { + double u = k[index]; + int multiplicity = 1; + while (index + multiplicity < k.Count && Math.Abs(k[index + multiplicity] - u) <= KnotTolerance) + { + multiplicity++; + } + + for (int m = multiplicity; m < degree; m++) + { + InsertKnot(k, p, u, degree); + } + + index += degree; + } + + if ((p.Count - 1) % degree != 0) + { + return false; + } + + bezierControlPoints = p; + return true; + } + + /// + /// Boehm's algorithm: inserts once, updating knots and control points in place. + /// + public static void InsertKnot(List knots, List points, double u, int degree) + { + int span = FindSpan(knots, points.Count, degree, u); + List updated = new(points.Count + 1); + for (int i = 0; i <= span - degree; i++) + { + updated.Add(points[i]); + } + + for (int i = span - degree + 1; i <= span; i++) + { + double denominator = knots[i + degree] - knots[i]; + double alpha = denominator <= KnotTolerance ? 0d : (u - knots[i]) / denominator; + XYZ a = points[i - 1]; + XYZ b = points[i]; + updated.Add(new XYZ( + ((1d - alpha) * a.X) + (alpha * b.X), + ((1d - alpha) * a.Y) + (alpha * b.Y), + ((1d - alpha) * a.Z) + (alpha * b.Z))); + } + + for (int i = span; i < points.Count; i++) + { + updated.Add(points[i]); + } + + points.Clear(); + points.AddRange(updated); + knots.Insert(span + 1, u); + } + + private static int FindSpan(List knots, int pointCount, int degree, double u) + { + int last = pointCount - 1; + if (u >= knots[pointCount]) + { + return last; + } + + int span = degree; + while (span < last && u >= knots[span + 1]) + { + span++; + } + + return span; + } + + private static bool HasMultiplicity(IReadOnlyList knots, int start, int count) + { + if (start < 0 || start + count > knots.Count) + { + return false; + } + + for (int i = 1; i < count; i++) + { + if (Math.Abs(knots[start + i] - knots[start]) > KnotTolerance) + { + return false; + } + } + + return true; + } +} +``` + +In `SplineRenderer` make `EvaluateSplinePoint` `internal static` (the test calls it) and change `Draw` so that after `DrawBezierSpline` fails it tries the converter when the surface supports curves: + +```csharp + if (context.Surface.SupportsCurves && SplineBezierConverter.TryConvert(spline, out List bezier)) + { + SurfacePoint[] points = new SurfacePoint[bezier.Count]; + for (int i = 0; i < bezier.Count; i++) + { + points[i] = context.ToSurfacePoint(bezier[i]); + } + + context.Surface.DrawCubicBezier(style, points, spline.IsClosed || spline.IsPeriodic); + return true; + } +``` + +Insert this block immediately after the `if (this.DrawBezierSpline(...)) return true;` check. + +- [ ] **Step 5: Dispatcher curve cases** + +In `EntityRenderDispatcher.Draw` replace the `Arc`, `Circle`, `Ellipse` and `IPolyline` cases: + +```csharp + case Arc arc when context.Surface.SupportsCurves: + this.DrawArc(context, style, arc); + break; + case Arc arc: + this.DrawPolyline(context, style, arc.PolygonalVertexes(this._configuration.ArcPrecision).Select(v => v.Convert()), false); + break; + case Circle circle when context.Surface.SupportsCurves: + context.Surface.DrawEllipse(style, context.ToSurfacePoint(circle.Center), context.ToSurfaceLength(circle.Radius), context.ToSurfaceLength(circle.Radius), 0d); + break; + case Circle circle: + this.DrawPolyline(context, style, circle.PolygonalVertexes(this._configuration.ArcPrecision).Select(v => v.Convert()), true); + break; + case Ellipse ellipse when context.Surface.SupportsCurves: + this.DrawEllipse(context, style, ellipse); + break; + case Ellipse ellipse: + this.DrawPolyline(context, style, ellipse.PolygonalVertexes(this._configuration.ArcPrecision).Select(v => v.Convert()), true); + break; + case IPolyline polyline when context.Surface.SupportsCurves: + this.DrawBulgePolyline(context, style, polyline); + break; + case IPolyline polyline: + this.DrawPolyline(context, style, polyline.GetPoints(this._configuration.ArcPrecision).Select(v => v.Convert()), polyline.IsClosed); + break; +``` + +Add the helpers: + +```csharp + private static void DrawArc(ImageRenderContext context, ImageStyle style, Arc arc) + { + double sweep = arc.EndAngle - arc.StartAngle; + while (sweep <= 0d) + { + sweep += 2d * Math.PI; + } + + double radius = context.ToSurfaceLength(arc.Radius); + context.Surface.DrawArc(style, context.ToSurfacePoint(arc.Center), radius, radius, 0d, -arc.StartAngle, -sweep); + } + + private static void DrawEllipse(ImageRenderContext context, ImageStyle style, Ellipse ellipse) + { + double radiusX = context.ToSurfaceLength(ellipse.MajorAxis); + double radiusY = context.ToSurfaceLength(ellipse.MinorAxis); + SurfacePoint center = context.ToSurfacePoint(ellipse.Center); + if (ellipse.IsFullEllipse) + { + context.Surface.DrawEllipse(style, center, radiusX, radiusY, -ellipse.Rotation); + return; + } + + double sweep = ellipse.EndParameter - ellipse.StartParameter; + while (sweep <= 0d) + { + sweep += 2d * Math.PI; + } + + context.Surface.DrawArc(style, center, radiusX, radiusY, -ellipse.Rotation, -ellipse.StartParameter, -sweep); + } + + private void DrawBulgePolyline(ImageRenderContext context, ImageStyle style, IPolyline polyline) + { + List points = new(); + List bulges = new(); + foreach (IVertex vertex in polyline.Vertices) + { + // IVertex.Location is a CSMath.IVector; it only exposes an indexer. + points.Add(context.ToSurfacePoint(new XY(vertex.Location[0], vertex.Location[1]))); + bulges.Add(vertex.Bulge); + } + + if (points.Count < 2) + { + return; + } + + context.Surface.DrawBulgePolyline(style, points, bulges, polyline.IsClosed); + } +``` + +`ACadSharp.Entities.Ellipse.MajorAxis` and `MinorAxis` are half-lengths in 3.7.1 (`MajorAxis` is the length of `MajorAxisEndPoint`); `Rotation` is the angle of the major axis. + +- [ ] **Step 6: Run everything** + +Run: `dotnet test ACadSharp.Image.sln -c Release --nologo -v q` +Expected: PASS including `SampleParityTests` (raster surface reports `SupportsCurves == false`, so the raster path is untouched). + +- [ ] **Step 7: Commit** + +```bash +git add ACadSharp.Image ACadSharp.Image.Tests +git commit -m "Emit native arcs, bulges and Bezier splines for curve-capable surfaces + +Co-Authored-By: Claude Fable 5.1 +Claude-Session: https://claude.ai/code/session_016RGgSinQSUz4d89FRLxhMz" +``` + +--- + +### Task 5: Text as `` + +**Files:** +- Modify: `ACadSharp.Image/Rendering/Svg/SvgDrawingSurface.cs` (`DrawText`) +- Test: `ACadSharp.Image.Tests/SvgDrawingSurfaceTests.cs` + +- [ ] **Step 1: Failing tests** + +```csharp + [Fact] + public void TextIsWrittenAsTextElement() + { + using SvgDrawingSurface surface = CreateSurface(); + SurfaceText run = new("Hello", new SurfacePoint(10, 20), 2.5, Math.PI / 6, SurfaceTextAnchor.Middle, SurfaceTextBaseline.Central, -1, 1, 12); + + surface.BeginEntity(Entity("Anno", "TEXT"), Layer("Anno")); + surface.DrawText(new ImageStyle(Color.Black, 1f), run); + surface.EndEntity(); + + XElement text = Assert.Single(surface.ToDocument().Descendants(Ns + "text")); + Assert.Equal("Hello", text.Value); + Assert.Equal("10", (string?)text.Attribute("x")); + Assert.Equal("20", (string?)text.Attribute("y")); + Assert.Equal("2.5", (string?)text.Attribute("font-size")); + Assert.Equal("middle", (string?)text.Attribute("text-anchor")); + Assert.Equal("central", (string?)text.Attribute("dominant-baseline")); + Assert.Equal("rotate(-30 10 20)", (string?)text.Attribute("transform")); + Assert.Equal("12", (string?)text.Attribute("textLength")); + Assert.Equal("spacingAndGlyphs", (string?)text.Attribute("lengthAdjust")); + Assert.Equal("#000000", (string?)text.Attribute("fill")); + Assert.Equal("none", (string?)text.Attribute("stroke")); + } + + [Fact] + public void MultiLineTextUsesTspans() + { + using SvgDrawingSurface surface = CreateSurface(); + SurfaceText run = new("A\nB\nC", new SurfacePoint(0, 0), 2, 0, SurfaceTextAnchor.Start, SurfaceTextBaseline.Alphabetic, -1, 1, -1); + + surface.DrawText(new ImageStyle(Color.Black, 1f), run); + + XElement text = Assert.Single(surface.ToDocument().Descendants(Ns + "text")); + List spans = text.Elements(Ns + "tspan").ToList(); + Assert.Equal(3, spans.Count); + Assert.Null(spans[0].Attribute("dy")); + Assert.Equal("3.33", (string?)spans[1].Attribute("dy")); + Assert.Equal("0", (string?)spans[1].Attribute("x")); + Assert.Null(text.Attribute("dominant-baseline")); + Assert.Null(text.Attribute("transform")); + } +``` + +- [ ] **Step 2: Run, expect failure (`NotImplementedException`)** + +- [ ] **Step 3: Implement** + +```csharp + public void DrawText(ImageStyle style, SurfaceText text) + { + if (string.IsNullOrWhiteSpace(text.Text)) + { + return; + } + + XElement element = new(Ns + "text", + new XAttribute("x", this.N(text.Origin.X)), + new XAttribute("y", this.N(text.Origin.Y)), + new XAttribute("font-size", this.N(text.Height))); + + if (text.Anchor != SurfaceTextAnchor.Start) + { + element.Add(new XAttribute("text-anchor", text.Anchor == SurfaceTextAnchor.Middle ? "middle" : "end")); + } + + if (text.Baseline != SurfaceTextBaseline.Alphabetic) + { + element.Add(new XAttribute("dominant-baseline", text.Baseline == SurfaceTextBaseline.Central ? "central" : "hanging")); + } + + if (Math.Abs(text.Rotation) > 1e-12) + { + element.Add(new XAttribute("transform", $"rotate({this.N(-text.Rotation * 180d / Math.PI)} {this.N(text.Origin.X)} {this.N(text.Origin.Y)})")); + } + + if (text.FixedLength > 0) + { + element.Add(new XAttribute("textLength", this.N(text.FixedLength)), new XAttribute("lengthAdjust", "spacingAndGlyphs")); + } + + string[] lines = text.Text.Replace("\r\n", "\n").Split('\n'); + if (lines.Length == 1) + { + element.Add(lines[0]); + } + else + { + double lineHeight = text.Height * (text.LineSpacingFactor <= 0 ? 1d : text.LineSpacingFactor) * 5d / 3d; + for (int i = 0; i < lines.Length; i++) + { + XElement span = new(Ns + "tspan", new XAttribute("x", this.N(text.Origin.X)), lines[i]); + if (i > 0) + { + span.Add(new XAttribute("dy", this.N(lineHeight))); + } + + element.Add(span); + } + } + + this.Append(this.Filled(element, style)); + } +``` + +With a 100x50 viewBox (2 decimals) the line height `2 * 5/3 = 3.333...` formats as `3.33`. + +- [ ] **Step 4: Run tests, then whole suite** + +- [ ] **Step 5: Commit** + +```bash +git add ACadSharp.Image/Rendering/Svg/SvgDrawingSurface.cs ACadSharp.Image.Tests/SvgDrawingSurfaceTests.cs +git commit -m "Write CAD text as SVG text elements + +Co-Authored-By: Claude Fable 5.1 +Claude-Session: https://claude.ai/code/session_016RGgSinQSUz4d89FRLxhMz" +``` + +--- + +### Task 6: Viewports with `` + +**Files:** +- Modify: `ACadSharp.Image/Rendering/Svg/SvgDrawingSurface.cs` (`BeginViewport`, `EndViewport`) +- Test: `ACadSharp.Image.Tests/SvgDrawingSurfaceTests.cs` + +- [ ] **Step 1: Failing test** + +```csharp + [Fact] + public void ViewportWrapsContentsInClippedGroupWithOwnLayerGroups() + { + using SvgDrawingSurface surface = CreateSurface(c => c.Svg.IdPrefix = "x-"); + ImageStyle style = new(Color.Black, 1f); + + surface.BeginEntity(Entity("Title"), Layer("Title")); + surface.DrawLine(style, new SurfacePoint(0, 0), new SurfacePoint(1, 1)); + surface.EndEntity(); + + ViewportSurface viewport = surface.BeginViewport(new SurfaceRect(10, 5, 40, 30)); + Assert.Same(surface, viewport.Surface); + Assert.Equal(10d, viewport.OffsetX); + Assert.Equal(35d, viewport.BottomY); + surface.BeginEntity(Entity("Title"), Layer("Title")); + surface.DrawLine(style, new SurfacePoint(12, 6), new SurfacePoint(20, 20)); + surface.EndEntity(); + surface.EndViewport(viewport); + + XDocument document = surface.ToDocument(); + XElement clipPath = Assert.Single(document.Descendants(Ns + "clipPath")); + Assert.Equal("x-clip-1", (string?)clipPath.Attribute("id")); + Assert.Equal("userSpaceOnUse", (string?)clipPath.Attribute("clipPathUnits")); + XElement rect = Assert.Single(clipPath.Elements(Ns + "rect")); + Assert.Equal("10", (string?)rect.Attribute("x")); + Assert.Equal("30", (string?)rect.Attribute("height")); + + XElement group = Assert.Single(document.Descendants(Ns + "g").Where(g => (string?)g.Attribute("clip-path") == "url(#x-clip-1)")); + Assert.Equal("cad-viewport", (string?)group.Attribute("class")); + // The viewport has its own "Title" layer group, separate from the page-level one, with a distinct id. + List titleGroups = document.Descendants(Ns + "g").Where(g => (string?)g.Attribute("data-layer") == "Title").ToList(); + Assert.Equal(2, titleGroups.Count); + Assert.Equal("x-layer-title", (string?)titleGroups[0].Attribute("id")); + Assert.Equal("x-clip-1-layer-title", (string?)titleGroups[1].Attribute("id")); + Assert.Single(group.Descendants(Ns + "line")); + Assert.True(document.Descendants(Ns + "defs").Single().ElementsBeforeSelf().Count() == 0); + + List ids = document.Descendants().Select(e => (string?)e.Attribute("id")).Where(id => id != null).ToList()!; + Assert.Equal(ids.Count, ids.Distinct(StringComparer.Ordinal).Count()); + } +``` + +- [ ] **Step 2: Run, expect failure** + +- [ ] **Step 3: Implement** + +```csharp + public ViewportSurface BeginViewport(SurfaceRect bounds) + { + this._clipCounter++; + string clipId = SvgIdSanitizer.Sanitize(this._options.IdPrefix, "clip", this._clipCounter.ToString(CultureInfo.InvariantCulture)); + this._defs.Add(new XElement(Ns + "clipPath", + new XAttribute("id", clipId), + new XAttribute("clipPathUnits", "userSpaceOnUse"), + new XElement(Ns + "rect", + new XAttribute("x", this.N(bounds.X)), new XAttribute("y", this.N(bounds.Y)), + new XAttribute("width", this.N(bounds.Width)), new XAttribute("height", this.N(bounds.Height))))); + + XElement group = new(Ns + "g", new XAttribute("class", "cad-viewport"), new XAttribute("clip-path", $"url(#{clipId})")); + this._containers.Peek().Element.Add(group); + this._containers.Push(new Container(group, $"clip-{this._clipCounter.ToString(CultureInfo.InvariantCulture)}-layer")); + return new ViewportSurface(this, bounds.X, bounds.Y + bounds.Height); + } + + public void EndViewport(ViewportSurface viewport) + { + if (this._containers.Count <= 1) + { + throw new InvalidOperationException("EndViewport was called without a matching BeginViewport."); + } + + this._containers.Pop(); + } +``` + +- [ ] **Step 4: Run tests and suite** + +- [ ] **Step 5: Commit** + +```bash +git add ACadSharp.Image/Rendering/Svg/SvgDrawingSurface.cs ACadSharp.Image.Tests/SvgDrawingSurfaceTests.cs +git commit -m "Clip SVG viewport contents with clipPath groups + +Co-Authored-By: Claude Fable 5.1 +Claude-Session: https://claude.ai/code/session_016RGgSinQSUz4d89FRLxhMz" +``` + +--- + +### Task 7: SVG page context, stroke-width units, `RenderedSvgPage`, exporter wiring + +**Files:** +- Create: `ACadSharp.Image/RenderedSvgPage.cs` +- Modify: `ACadSharp.Image/ImagePage.cs` (add `internal CadDocument? Document { get; set; }`) +- Modify: `ACadSharp.Image/ImageExporter.cs` (`Add(Layout)`, `Add(BlockRecord)` set `Document`) +- Modify: `ACadSharp.Image/Rendering/ImageRenderContext.cs` (`StrokeUnitsPerMillimeter`, `CreateSvgPageContext`, `ToStrokeWidth`) +- Modify: `ACadSharp.Image/Rendering/ImagePageRenderer.cs` (`Render` branches on format) +- Test: `ACadSharp.Image.Tests/SvgExportTests.cs` + +**Interfaces:** +- Produces: `public sealed class RenderedSvgPage : RenderedPage { string Content; }`; `ImageRenderContext.StrokeUnitsPerMillimeter : double?` (null = pixel widths); `ImageRenderContext.CreateSvgPageContext(SvgDrawingSurface surface, ImagePage page, ImageConfiguration configuration, double? strokeUnitsPerMillimeter)`; `static SurfaceRect ImageRenderContext.ComputeSvgViewBox(ImagePage page, ImageConfiguration configuration)`; `internal static double UnitsPerMillimeter(UnitsType units)`. + +ViewBox with padding (spec 5.1): `fit = min(drawableWidth / W, drawableHeight / H)` in px per unit; `padL_u = PaddingLeft / fit` etc.; viewBox `x = -padL_u`, `y = -padT_u`, `width = W + padL_u + padR_u`, `height = H + padT_u + padB_u`. Context: `Scale = 1`, `OffsetX = 0`, `OffsetY = 0`, `SurfaceHeight = H`, origins as raster. This gives `y = H - (p.Y - originY)`, so the page occupies `0..W` by `0..H` and the padding lives in the negative/overflow margin of the viewBox. + +- [ ] **Step 1: Failing end-to-end tests** + +Create `ACadSharp.Image.Tests/SvgExportTests.cs`: + +```csharp +using System.Xml.Linq; +using ACadSharp.Entities; +using ACadSharp.Image.Rendering.Svg; +using ACadSharp.Tables; +using CSMath; + +namespace ACadSharp.Image.Tests; + +public sealed class SvgExportTests +{ + private static readonly XNamespace Ns = SvgDrawingSurface.Ns; + + private static BlockRecord SimpleBlock() + { + BlockRecord block = new("svg-block"); + block.Entities.Add(new Line(new XYZ(0, 0, 0), new XYZ(100, 0, 0)) { Layer = new Layer("Walls") }); + block.Entities.Add(new Circle { Center = new XYZ(50, 25, 0), Radius = 10, Layer = new Layer("Doors") }); + block.Entities.Add(new TextEntity { Value = "Room", InsertPoint = new XYZ(10, 40, 0), Height = 5, Layer = new Layer("Anno") }); + return block; + } + + [Fact] + public void RenderSvgProducesSvgPage() + { + ImageExporter exporter = new(); + exporter.Add(SimpleBlock()); + + using RenderedPage page = Assert.Single(exporter.Render(ImageExportFormat.Svg)); + RenderedSvgPage svg = Assert.IsType(page); + XDocument document = XDocument.Parse(svg.Content); + XElement root = document.Root!; + + Assert.Equal(Ns + "svg", root.Name); + Assert.Equal(ImageExportFormat.Svg, svg.Format); + // Extents 100 x 50 (the text bounding box may enlarge the height slightly). + string[] viewBox = ((string)root.Attribute("viewBox")!).Split(' '); + Assert.Equal("0", viewBox[0]); + Assert.Equal("0", viewBox[1]); + Assert.Equal("100", viewBox[2]); + Assert.Null(root.Attribute("width")); + Assert.Equal(3, document.Descendants(Ns + "g").Count(g => g.Attribute("data-layer") != null)); + Assert.Single(document.Descendants(Ns + "circle")); + Assert.Equal("Room", Assert.Single(document.Descendants(Ns + "text")).Value); + } + + [Fact] + public void YAxisIsFlipped() + { + BlockRecord block = new("flip"); + block.Entities.Add(new Line(new XYZ(0, 0, 0), new XYZ(10, 10, 0))); + ImageExporter exporter = new(); + exporter.Add(block); + + using RenderedPage page = Assert.Single(exporter.Render(ImageExportFormat.Svg)); + XDocument document = XDocument.Parse(((RenderedSvgPage)page).Content); + XElement line = Assert.Single(document.Descendants(Ns + "line")); + + // Drawing (0,0) is the bottom-left, so it lands at SVG y = 10; drawing (10,10) lands at y = 0. + Assert.Equal("0", (string?)line.Attribute("x1")); + Assert.Equal("10", (string?)line.Attribute("y1")); + Assert.Equal("10", (string?)line.Attribute("x2")); + Assert.Equal("0", (string?)line.Attribute("y2")); + } + + [Fact] + public void PaddingExpandsViewBoxAndSizeIsOptional() + { + BlockRecord block = new("padded"); + block.Entities.Add(new Line(new XYZ(0, 0, 0), new XYZ(100, 50, 0))); + ImageExporter exporter = new(); + exporter.Configuration.Width = 1000; + exporter.Configuration.Height = 600; + exporter.Configuration.SetPadding(100, 50, 100, 50); + exporter.Configuration.Svg.EmitSize = true; + exporter.Add(block); + + using RenderedPage page = Assert.Single(exporter.Render(ImageExportFormat.Svg)); + XElement root = XDocument.Parse(((RenderedSvgPage)page).Content).Root!; + + // Drawable 800x500 for a 100x50 page -> fit 8 px/unit -> padding 12.5 units horizontally, 6.25 vertically. + Assert.Equal("-12.5 -6.25 125 62.5", (string?)root.Attribute("viewBox")); + Assert.Equal("1000", (string?)root.Attribute("width")); + Assert.Equal("600", (string?)root.Attribute("height")); + } + + [Fact] + public void StrokeWidthsArePixelsByDefaultAndDrawingUnitsWhenScaling() + { + BlockRecord block = new("weights"); + block.Entities.Add(new Line(new XYZ(0, 0, 0), new XYZ(100, 0, 0)) { LineWeight = ACadSharp.LineWeightType.W50 }); + + ImageExporter pixels = new(); + pixels.Add(block); + using RenderedPage pixelPage = Assert.Single(pixels.Render(ImageExportFormat.Svg)); + XElement pixelLine = Assert.Single(XDocument.Parse(((RenderedSvgPage)pixelPage).Content).Descendants(Ns + "line")); + Assert.Equal("non-scaling-stroke", (string?)pixelLine.Attribute("vector-effect")); + // 0.50 mm at 96 dpi = 1.8897 px, written with the viewBox's adaptive precision (2 decimals for a ~100 unit page). + Assert.Equal("1.89", (string?)pixelLine.Attribute("stroke-width")); + + ImageExporter units = new(); + units.Configuration.Svg.NonScalingStroke = false; + units.Add(block); + using RenderedPage unitPage = Assert.Single(units.Render(ImageExportFormat.Svg)); + XElement unitLine = Assert.Single(XDocument.Parse(((RenderedSvgPage)unitPage).Content).Descendants(Ns + "line")); + Assert.Null(unitLine.Attribute("vector-effect")); + Assert.Equal("0.5", (string?)unitLine.Attribute("stroke-width")); // 0.50 mm, unitless drawing treated as millimetres + } + + [Fact] + public void SaveWritesSvgFile() + { + string path = Path.Combine(Path.GetTempPath(), $"acadsharp-{Guid.NewGuid():N}.svg"); + try + { + ImageExporter exporter = new(); + exporter.Add(SimpleBlock()); + exporter.Save(path, ImageExportFormat.Svg); + + string content = File.ReadAllText(path); + Assert.Contains("Svg` can become `` again now that the member exists, and `Save(Stream)` gets an explicit arm before `default:`: + +```csharp + case ImageExportFormat.Svg: + throw new NotSupportedException("RenderedImagePage holds a raster canvas; render with ImageExportFormat.Svg to obtain a RenderedSvgPage."); +``` + +Add a test to `SvgExportTests`: construct `new RenderedImagePage("x", new SixLabors.ImageSharp.Image(1, 1), ImageExportFormat.Svg)` and assert `Save(new MemoryStream())` throws `NotSupportedException`. + +- [ ] **Step 3: `RenderedSvgPage`** + +Create `ACadSharp.Image/RenderedSvgPage.cs`: + +```csharp +using System.Text; + +namespace ACadSharp.Image; + +/// +/// A page rendered to SVG markup. +/// +public sealed class RenderedSvgPage : RenderedPage +{ + /// + /// Initializes a new instance of the class. + /// + public RenderedSvgPage(string name, string content) + : base(name, ImageExportFormat.Svg) + { + this.Content = content; + } + + /// + /// Gets the SVG markup as text. It carries no XML declaration so it can be inlined in HTML; writes UTF-8 without a BOM. + /// + public string Content { get; } + + /// + public override void Save(Stream stream) + { + ArgumentNullException.ThrowIfNull(stream); + byte[] bytes = new UTF8Encoding(false).GetBytes(this.Content); + stream.Write(bytes, 0, bytes.Length); + } + + /// + public override void Dispose() + { + } +} +``` + +- [ ] **Step 4: Document reference on pages** + +In `ImagePage` add: + +```csharp + /// + /// Gets or sets the document the page content came from, when known. Used for header settings such as units and linetype scale. + /// + public CadDocument? Document { get; set; } +``` + +In `ImageExporter.Add(Layout)` set `Document = layout.Document` in the object initializer; in `Add(BlockRecord)` set `Document = block.Document`. + +- [ ] **Step 5: Context changes** + +In `ImageRenderContext` add a constructor parameter `double? strokeUnitsPerMillimeter = null` (after `parent`) stored in `public double? StrokeUnitsPerMillimeter { get; }`, thread it through `CreateViewportContext` (`strokeUnitsPerMillimeter: parent.StrokeUnitsPerMillimeter`), and replace `ToStrokeWidth`: + +```csharp + public float ToStrokeWidth(LineWeightType lineWeight) + { + if (this.StrokeUnitsPerMillimeter is not double unitsPerMillimeter) + { + return this.Configuration.GetLineWeightPixels(lineWeight); + } + + double millimeters = this.Configuration.GetLineWeightMillimeters(lineWeight); + if (millimeters <= 0d) + { + millimeters = 0.25d; + } + + return (float)(millimeters * unitsPerMillimeter * this.Configuration.LineWeightScale); + } +``` + +Add the SVG factory and helpers: + +```csharp + public static SurfaceRect ComputeSvgViewBox(ImagePage page, ImageConfiguration configuration) + { + int drawableWidth = configuration.Width - configuration.PaddingLeft - configuration.PaddingRight; + int drawableHeight = configuration.Height - configuration.PaddingTop - configuration.PaddingBottom; + if (drawableWidth <= 0 || drawableHeight <= 0) + { + throw new InvalidOperationException("Padding must leave at least one drawable pixel in both dimensions."); + } + + Layout layout = page.Layout ?? new Layout("default_page"); + double pageWidth = Math.Max(1d, layout.PaperWidth); + double pageHeight = Math.Max(1d, layout.PaperHeight); + double fit = Math.Min(drawableWidth / pageWidth, drawableHeight / pageHeight); + + double left = configuration.PaddingLeft / fit; + double top = configuration.PaddingTop / fit; + double right = configuration.PaddingRight / fit; + double bottom = configuration.PaddingBottom / fit; + return new SurfaceRect(-left, -top, pageWidth + left + right, pageHeight + top + bottom); + } + + /// + /// Pixels per drawing unit the raster fit would use for this page; SVG uses it to convert padding and, + /// in non-scaling-stroke mode, dash lengths into pixels. + /// + public static double ComputeSvgFitScale(ImagePage page, ImageConfiguration configuration) + { + int drawableWidth = configuration.Width - configuration.PaddingLeft - configuration.PaddingRight; + int drawableHeight = configuration.Height - configuration.PaddingTop - configuration.PaddingBottom; + Layout layout = page.Layout ?? new Layout("default_page"); + return Math.Min(drawableWidth / Math.Max(1d, layout.PaperWidth), drawableHeight / Math.Max(1d, layout.PaperHeight)); + } + + public static ImageRenderContext CreateSvgPageContext(IDrawingSurface surface, ImagePage page, ImageConfiguration configuration, double? strokeUnitsPerMillimeter) + { + Layout layout = page.Layout ?? new Layout("default_page"); + double pageWidth = Math.Max(1d, layout.PaperWidth); + double pageHeight = Math.Max(1d, layout.PaperHeight); + double originX = -page.Translation.X - layout.UnprintableMargin.Left; + double originY = -page.Translation.Y - layout.UnprintableMargin.Bottom; + + // With vector-effect="non-scaling-stroke" the browser computes the dash pattern in pixel space like the width, + // so dash lengths must be pixels too. In drawing-unit mode they are drawing units (scale 1). + double lineTypeScale = strokeUnitsPerMillimeter == null ? ComputeSvgFitScale(page, configuration) : 1d; + + return new ImageRenderContext( + surface, + configuration, + layout, + pageWidth, + pageHeight, + originX, + originY, + scale: 1d, + offsetX: 0d, + offsetY: 0d, + singlePrecision: false, + lineTypeScale: lineTypeScale, + strokeUnitsPerMillimeter: strokeUnitsPerMillimeter); + } + + /// + /// Drawing units per millimetre for a document unit setting. Unitless and unknown units are treated as millimetres. + /// + internal static double UnitsPerMillimeter(UnitsType units) + { + return units switch + { + UnitsType.Millimeters => 1d, + UnitsType.Centimeters => 0.1d, + UnitsType.Meters => 0.001d, + UnitsType.Kilometers => 0.000001d, + UnitsType.Inches => 1d / 25.4d, + UnitsType.Feet => 1d / 304.8d, + UnitsType.Yards => 1d / 914.4d, + UnitsType.Miles => 1d / 1609344d, + UnitsType.Microns => 1000d, + UnitsType.Decimeters => 0.01d, + _ => 1d, + }; + } +``` + +`UnitsType` lives in the `ACadSharp` namespace. If a member name in the switch does not exist in 3.7.1, delete that arm; the default arm covers it. + +- [ ] **Step 6: `ImagePageRenderer.Render` branches on format** + +```csharp + public RenderedPage Render(ImagePage page, ImageExportFormat format) + { + if (format == ImageExportFormat.Svg) + { + return this.RenderSvg(page); + } + + Image image = new(this._configuration.Width, this._configuration.Height, this._configuration.BackgroundColor); + using RasterDrawingSurface surface = new(image, this._configuration, ownsCanvas: false); + this.RenderTo(surface, page); + return new RenderedImagePage(page.Name, image, format, this._configuration.OutputQuality); + } + + private RenderedSvgPage RenderSvg(ImagePage page) + { + SurfaceRect viewBox = ImageRenderContext.ComputeSvgViewBox(page, this._configuration); + SvgOptions options = this._configuration.Svg; + using SvgDrawingSurface surface = new( + this._configuration, + viewBox, + options.EmitSize ? this._configuration.Width : null, + options.EmitSize ? this._configuration.Height : null); + + double? strokeUnits = options.NonScalingStroke + ? null + : ImageRenderContext.UnitsPerMillimeter(page.Document?.Header.InsUnits ?? UnitsType.Unitless); + ImageRenderContext context = ImageRenderContext.CreateSvgPageContext(surface, page, this._configuration, strokeUnits); + + foreach (Viewport viewport in page.Viewports) + { + this.DrawViewport(context, viewport); + } + + foreach (Entity entity in page.Entities) + { + this._dispatcher.Draw(context, entity); + } + + return new RenderedSvgPage(page.Name, surface.ToSvgString()); + } +``` + +Refactor so both paths share the two loops: add `private void RenderTo(ImageRenderContext context, ImagePage page)` containing the viewport and entity loops, and **keep** `internal void RenderTo(IDrawingSurface surface, ImagePage page)` as a thin wrapper that calls `ImageRenderContext.CreatePageContext(surface, page, this._configuration)` and then the private overload (plan 3's tests call the surface-taking overload). `RenderSvg` calls the private overload with the SVG context. Add `using ACadSharp.Image.Rendering.Svg;` and `using ACadSharp;` for `UnitsType`. + +`DrawViewport` already works for SVG: `pageContext.ToSurfacePoint` gives the viewport top-left in page units, `ToSurfaceLength` gives its size, `BeginViewport` returns `(this, bounds.X, bounds.Y + bounds.Height)` and `CreateViewportContext` uses `OffsetX = bounds.X`, `SurfaceHeight = bottom`, so model points land inside the viewport rectangle in page units. The `(int)Math.Ceiling` on viewport width/height in `DrawViewport` is a raster leftover: change those two lines to + +```csharp + double viewportWidth = pageContext.SinglePrecision + ? Math.Max(1, (int)Math.Ceiling(pageContext.ToSurfaceLength(viewportBounds.LengthX))) + : pageContext.ToSurfaceLength(viewportBounds.LengthX); + double viewportHeight = pageContext.SinglePrecision + ? Math.Max(1, (int)Math.Ceiling(pageContext.ToSurfaceLength(viewportBounds.LengthY))) + : pageContext.ToSurfaceLength(viewportBounds.LengthY); +``` + +so the raster path is unchanged and SVG keeps exact sizes. + +- [ ] **Step 7: Run everything** + +Run: `dotnet test ACadSharp.Image.sln -c Release --nologo -v q` +Expected: PASS, parity included. If `RenderSvgProducesSvgPage` fails on the viewBox height because the text bounding box extends past 50, relax that assertion to `Assert.True(double.Parse(viewBox[3], CultureInfo.InvariantCulture) >= 50)`. + +- [ ] **Step 8: Commit** + +```bash +git add ACadSharp.Image ACadSharp.Image.Tests +git commit -m "Render pages to SVG through the exporter + +Co-Authored-By: Claude Fable 5.1 +Claude-Session: https://claude.ai/code/session_016RGgSinQSUz4d89FRLxhMz" +``` + +--- + +### Task 8: CLI: `--format svg` and `--svg-*` flags + +**Files:** +- Modify: `ACadSharp.Image.Cli/CliOptions.cs` +- Modify: `ACadSharp.Image.Cli/Program.cs` (`Configure`, `ParseArgs`, `WriteHelp`) + +- [ ] **Step 1: Extend `CliOptions`** + +Add these positional parameters at the end of the record: `bool SvgScalingStroke, bool SvgNoEntityAttributes, bool SvgEmitSize, string SvgIdPrefix, int? SvgPrecision`. + +- [ ] **Step 2: Parse and apply** + +In `ParseArgs` add locals `bool svgScalingStroke = false; bool svgNoEntityAttributes = false; bool svgEmitSize = false; string svgIdPrefix = string.Empty; int? svgPrecision = null;` and cases: + +```csharp + case "--svg-no-scaling-stroke": + svgScalingStroke = true; + break; + case "--svg-no-entity-attributes": + svgNoEntityAttributes = true; + break; + case "--svg-size": + svgEmitSize = true; + break; + case "--svg-id-prefix": + svgIdPrefix = GetRequiredValue(args, ref i, current); + break; + case "--svg-precision": + svgPrecision = ParseRange(GetRequiredValue(args, ref i, current), current, 0, 8); + break; +``` + +with + +```csharp + private static int ParseRange(string value, string argumentName, int min, int max) + { + if (int.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out int parsed) && parsed >= min && parsed <= max) + { + return parsed; + } + + throw new InvalidOperationException($"Argument {argumentName} must be between {min} and {max}."); + } +``` + +Pass the five new values into the `CliOptions` constructor call. In `Configure` append: + +```csharp + configuration.Svg.NonScalingStroke = !options.SvgScalingStroke; + configuration.Svg.EmitEntityAttributes = !options.SvgNoEntityAttributes; + configuration.Svg.EmitSize = options.SvgEmitSize; + configuration.Svg.IdPrefix = options.SvgIdPrefix; + configuration.Svg.Precision = options.SvgPrecision; +``` + +In `WriteHelp` change the format line to `png, bmp, jpg, jpeg, gif, webp, svg.` and add after `--hide-layer`: + +``` + --svg-no-scaling-stroke Write SVG stroke widths in drawing units instead of constant pixels. + --svg-no-entity-attributes + Omit data-handle/data-type/data-parent/data-block attributes from SVG. + --svg-size Emit width/height on the SVG root from --width/--height. + --svg-id-prefix Prefix for SVG ids so several drawings can share one page. + --svg-precision <0-8> Decimal places for SVG coordinates. Default: adaptive. +``` + +- [ ] **Step 3: Verify manually** + +Run: + +```bash +dotnet run --project ./ACadSharp.Image.Cli/ACadSharp.Image.Cli.csproj -c Release -- "./Samples/6-57-1119.dxf" --format svg --output /tmp/claude-1000/-work-workspaces-orca-ACadSharp-Image-svg-support/f63cfd24-08c1-4c72-b680-352d15a25a0a/scratchpad/cli-test.svg --svg-id-prefix demo- && head -c 400 /tmp/claude-1000/-work-workspaces-orca-ACadSharp-Image-svg-support/f63cfd24-08c1-4c72-b680-352d15a25a0a/scratchpad/cli-test.svg +``` + +Expected: output starts with ` +Claude-Session: https://claude.ai/code/session_016RGgSinQSUz4d89FRLxhMz" +``` + +--- + +### Task 9: Golden SVGs for the samples + +**Files:** +- Modify: `ACadSharp.Image.Tests/SampleParityTests.cs` +- Create: `ACadSharp.Image.Tests/Baselines/*.svg` + +- [ ] **Step 1: Add the golden theory** + +Append to `SampleParityTests`: + +```csharp + [Theory] + [MemberData(nameof(Samples))] + public void SampleSvgsMatchGoldens(string fileName, bool paperLayouts) + { + string repoRoot = FindRepoRoot(); + string samplePath = Path.Combine(repoRoot, "Samples", fileName); + string baselineDirectory = Path.Combine(repoRoot, "ACadSharp.Image.Tests", "Baselines"); + bool update = Environment.GetEnvironmentVariable("ACADSHARP_IMAGE_UPDATE_BASELINES") == "1"; + string baseName = Path.GetFileNameWithoutExtension(fileName).Replace(' ', '-') + (paperLayouts ? ".paper" : ".model"); + + CadDocument document = Path.GetExtension(samplePath).ToLowerInvariant() == ".dwg" ? DwgReader.Read(samplePath) : DxfReader.Read(samplePath); + ImageExporter exporter = new(); + exporter.Configuration.Width = 800; + exporter.Configuration.Height = 500; + exporter.Configuration.SetPadding(10); + exporter.Configuration.FontFamilyName = FontFamily; + if (paperLayouts) + { + exporter.AddPaperLayouts(document); + } + else + { + exporter.AddModelSpace(document); + } + + IReadOnlyList pages = exporter.Render(ImageExportFormat.Svg); + for (int i = 0; i < pages.Count; i++) + { + string goldenPath = Path.Combine(baselineDirectory, $"{baseName}.{i + 1:D2}.svg"); + string actual = Assert.IsType(pages[i]).Content.Replace("\r\n", "\n"); + if (update) + { + File.WriteAllText(goldenPath, actual); + continue; + } + + Assert.True(File.Exists(goldenPath), $"Missing golden {goldenPath}. Run with ACADSHARP_IMAGE_UPDATE_BASELINES=1 to create it."); + string expected = File.ReadAllText(goldenPath).Replace("\r\n", "\n"); + Assert.Equal(expected, actual); + } + } +``` + +- [ ] **Step 2: Generate, then verify** + +Run: `ACADSHARP_IMAGE_UPDATE_BASELINES=1 dotnet test ACadSharp.Image.sln -c Release --nologo -v q --filter "FullyQualifiedName~SampleSvgsMatchGoldens"` then the same command without the variable. +Expected: both PASS; `ls ACadSharp.Image.Tests/Baselines/*.svg` lists the model-space goldens (and paper goldens if the DWG has layouts). + +- [ ] **Step 3: Eyeball one golden** + +Run: `grep -c " +Claude-Session: https://claude.ai/code/session_016RGgSinQSUz4d89FRLxhMz" +``` + +--- + +## Self-review checklist + +- Spec 5.1 (viewBox, size, padding) → Task 7. 5.2 (structure, ids, data attributes, omission of hidden content) → Task 3 (omission follows from plan 3's render-loop filtering: filtered entities never reach the surface). 5.3 (primitives) → Tasks 3, 4, 5, 6. 5.4 (precision) → Task 2. 5.5 (stroke widths) → Tasks 3, 7. 5.6 (`SvgOptions`) → Task 1. Section 6 (`Svg` format, `RenderedSvgPage`) → Tasks 1, 7. Section 7 CLI SVG flags → Task 8. Golden tests → Task 9. +- Names used across tasks: `SvgDrawingSurface.Ns`, `ToDocument`, `ToSvgString`, `SvgNumberFormatter.AdaptiveDecimals`, `SvgIdSanitizer.Sanitize(prefix, kind, name)`, `SplineBezierConverter.TryConvert`, `SplineRenderer.EvaluateSplinePoint` (made internal), `ImageRenderContext.ComputeSvgViewBox`, `CreateSvgPageContext`, `UnitsPerMillimeter`, `StrokeUnitsPerMillimeter`, `RenderedSvgPage.Content`, `ImagePage.Document`. +- Deviation recorded: general degree-3 splines are converted by knot insertion (Task 4); periodic, unclamped, rational or non-cubic splines fall back to tessellation. diff --git a/docs/superpowers/plans/2026-09-02-03-layer-attributes.md b/docs/superpowers/plans/2026-09-02-03-layer-attributes.md new file mode 100644 index 0000000..36bdea2 --- /dev/null +++ b/docs/superpowers/plans/2026-09-02-03-layer-attributes.md @@ -0,0 +1,1463 @@ +# Layer Attributes and Selection Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Honour layer state (off, frozen, plot, viewport-frozen, invisible) behind an opt-in mode, add an include list, move all layer filtering into the render loop, render linetypes, transparency, ACI 7 by background, and hatches in both backends, and expose everything in the CLI and README. + +**Architecture:** `EntityVisibilityFilter` decides per drawn entity (with its effective layer and current viewport) and runs inside `EntityRenderDispatcher.Draw` before `BeginEntity`. `ImageStyleResolver` grows opacity and dash resolution (`LineTypeDashResolver`). Hatch decomposition uses ACadSharp's `ExplodePattern()`. Both backends already consume `ImageStyle.DashPattern` and `Opacity` (plans 1 and 2), so this plan mostly feeds them real values. + +**Tech Stack:** .NET 8/10, ACadSharp 3.7.1, ImageSharp.Drawing `PatternPen`, xUnit. + +**Spec:** `docs/superpowers/specs/2026-09-02-layers-and-svg-design.md` (sections 4, 6, 7). Requires plans 01 and 02 to be complete. + +## Global Constraints + +- Same as plans 1 and 2 (no new packages, style, worktree root, commit trailers): + +``` +Co-Authored-By: Claude Fable 5.1 +Claude-Session: https://claude.ai/code/session_016RGgSinQSUz4d89FRLxhMz +``` + +- Default configuration output must not change: `LayerVisibility` defaults to `All`, `ForegroundColor` null resolves to black on the default white background, entities default to ByLayer transparency (opaque), and the sample files' `Continuous` linetypes stay solid. **If a parity baseline changes because a sample actually uses a dashed linetype or a non-opaque entity, that is expected**: regenerate that baseline once with `ACADSHARP_IMAGE_UPDATE_BASELINES=1`, inspect the PNG visually, and mention it in the commit message. Do the same for the SVG goldens. +- Recorded deviation (spec 4.3): ByLayer transparency resolves to opaque because ACadSharp 3.7.1's `Layer` has no transparency. + +## File Structure + +| File | Responsibility | Task | +| --- | --- | --- | +| `ACadSharp.Image/LayerVisibilityMode.cs` (create), `ImageConfiguration.cs` (modify) | Public options | 1 | +| `ACadSharp.Image.Tests/ImageConfigurationTests.cs` (modify) | | 1 | +| `ACadSharp.Image/Rendering/EntityVisibilityFilter.cs` (create), `EntityRenderDispatcher.cs`, `ImageExporter.cs` (modify) | Render-loop filtering, effective layer object | 2 | +| `ACadSharp.Image.Tests/RecordingDrawingSurface.cs`, `EntityRenderDispatcherTests.cs`, `ImageExporterTests.cs`, `LayerFilteringTests.cs` (modify/create) | | 2 | +| `ACadSharp.Image/Extensions/ColorExtensions.cs`, `ImageConfiguration.cs`, `Rendering/ImageStyleResolver.cs`, `EntityRenderDispatcher.cs` (modify) | ACI 7 and `ForegroundColor` | 3 | +| `ACadSharp.Image/Rendering/ImageStyleResolver.cs`, `EntityRenderDispatcher.cs` (modify) | Transparency to opacity | 4 | +| `ACadSharp.Image/Rendering/LineTypeDashResolver.cs` (create), `ImageStyleResolver.cs`, `ImagePageRenderer.cs`, `ImageRenderContext.cs` (modify) | Linetypes | 5 | +| `ACadSharp.Image/Rendering/EntityRenderDispatcher.cs` (modify) | Hatch | 6 | +| `ACadSharp.Image.Cli/Program.cs`, `CliOptions.cs` (modify) | `--layer-visibility`, `--only-layer`, `--list-layers` | 7 | +| `README.md` (modify) | Docs and migration notes | 8 | + +--- + +### Task 1: Configuration surface + +**Files:** +- Create: `ACadSharp.Image/LayerVisibilityMode.cs` +- Modify: `ACadSharp.Image/ImageConfiguration.cs` +- Test: `ACadSharp.Image.Tests/ImageConfigurationTests.cs` + +**Interfaces:** +- Produces: `public enum LayerVisibilityMode { All, Screen, Plot }`; on `ImageConfiguration`: `LayerVisibilityMode LayerVisibility { get; set; } = All`, `IReadOnlySet IncludedLayers`, `void IncludeLayer(string)`, `void IncludeLayers(IEnumerable)`, `bool ExcludeLayer(string)`, `void ClearIncludedLayers()`, `SixLabors.ImageSharp.Color? ForegroundColor { get; set; }`, `float MinimumDashPixels { get; set; } = 2f` (>= 0), `int MaxHatchLines { get; set; } = 20000` (> 0). + +- [ ] **Step 1: Failing tests** + +Append to `ImageConfigurationTests`: + +```csharp + [Fact] + public void LayerVisibilityDefaultsToAll() + { + Assert.Equal(LayerVisibilityMode.All, new ImageConfiguration().LayerVisibility); + } + + [Fact] + public void IncludedLayersAreManagedThroughMethods() + { + ImageConfiguration configuration = new(); + + configuration.IncludeLayer("Walls"); + configuration.IncludeLayers(["doors", "Windows"]); + + Assert.Equal(3, configuration.IncludedLayers.Count); + Assert.Contains("WALLS", configuration.IncludedLayers); + Assert.True(configuration.ExcludeLayer("DOORS")); + Assert.False(configuration.ExcludeLayer("nope")); + Assert.Throws(() => configuration.IncludeLayer(" ")); + + configuration.ClearIncludedLayers(); + + Assert.Empty(configuration.IncludedLayers); + } + + [Fact] + public void NewNumericSettingsAreValidated() + { + ImageConfiguration configuration = new(); + + Assert.Null(configuration.ForegroundColor); + Assert.Equal(2f, configuration.MinimumDashPixels); + Assert.Equal(20000, configuration.MaxHatchLines); + Assert.Throws(() => configuration.MinimumDashPixels = -1f); + Assert.Throws(() => configuration.MaxHatchLines = 0); + } +``` + +- [ ] **Step 2: Run, expect compile failure** + +- [ ] **Step 3: Implement** + +Create `ACadSharp.Image/LayerVisibilityMode.cs`: + +```csharp +namespace ACadSharp.Image; + +/// +/// Controls how layer and entity state in the drawing affects what is rendered. +/// +public enum LayerVisibilityMode +{ + /// Render everything regardless of layer state. This is the default and matches earlier versions. + All, + + /// Hide entities on layers that are off or frozen, entities flagged invisible, and layers frozen in the current viewport. + Screen, + + /// plus hide entities on non-plottable layers. + Plot, +} +``` + +In `ImageConfiguration`: +- Fields: `private readonly HashSet _includedLayers = new(StringComparer.OrdinalIgnoreCase);`, `private readonly IReadOnlySet _readOnlyIncludedLayers;` (initialise in the constructor with `new ReadOnlySet(this._includedLayers)`), `private float _minimumDashPixels = 2f;`, `private int _maxHatchLines = 20000;`. +- Properties (with XML docs in the style of the existing ones): + +```csharp + public LayerVisibilityMode LayerVisibility { get; set; } = LayerVisibilityMode.All; + + public IReadOnlySet IncludedLayers => this._readOnlyIncludedLayers; + + public ImageColor? ForegroundColor { get; set; } + + public float MinimumDashPixels + { + get => this._minimumDashPixels; + set => this._minimumDashPixels = value >= 0f ? value : throw new ArgumentOutOfRangeException(nameof(value), "Minimum dash length must be zero or greater."); + } + + public int MaxHatchLines + { + get => this._maxHatchLines; + set => this._maxHatchLines = value > 0 ? value : throw new ArgumentOutOfRangeException(nameof(value), "Maximum hatch lines must be greater than zero."); + } +``` + +Doc text: `LayerVisibility` "Gets or sets how layer state (on/off, frozen, plottable) affects rendering. Default ." `IncludedLayers` "Gets the layers to render when the set is not empty; all other layers are skipped. Applied before . Case-insensitive." `ForegroundColor` "Gets or sets the colour used for AutoCAD colour index 7 (\"white/black by background\"). Null (default) picks black or white from the luminance of ." `MinimumDashPixels` "Gets or sets the pattern length in pixels below which dashed linetypes are drawn solid. Default 2." `MaxHatchLines` "Gets or sets the maximum number of pattern lines drawn per hatch; beyond it a warning is raised and the remainder is skipped. Default 20000." + +- Methods next to the hidden-layer ones: + +```csharp + public void IncludeLayer(string layerName) + { + ThrowIfNullOrWhiteSpace(layerName); + this._includedLayers.Add(layerName); + } + + public void IncludeLayers(IEnumerable layerNames) + { + ArgumentNullException.ThrowIfNull(layerNames); + foreach (string layerName in layerNames) + { + this.IncludeLayer(layerName); + } + } + + public bool ExcludeLayer(string layerName) + { + ThrowIfNullOrWhiteSpace(layerName); + return this._includedLayers.Remove(layerName); + } + + public void ClearIncludedLayers() + { + this._includedLayers.Clear(); + } +``` + +- [ ] **Step 4: Run tests, commit** + +```bash +dotnet test ACadSharp.Image.sln -c Release --nologo -v q +git add ACadSharp.Image/LayerVisibilityMode.cs ACadSharp.Image/ImageConfiguration.cs ACadSharp.Image.Tests/ImageConfigurationTests.cs +git commit -m "Add layer visibility, include list and related configuration + +Co-Authored-By: Claude Fable 5.1 +Claude-Session: https://claude.ai/code/session_016RGgSinQSUz4d89FRLxhMz" +``` + +--- + +### Task 2: Render-loop filtering + +**Files:** +- Create: `ACadSharp.Image/Rendering/EntityVisibilityFilter.cs` +- Modify: `ACadSharp.Image/Rendering/EntityRenderDispatcher.cs` +- Modify: `ACadSharp.Image/ImageExporter.cs` (remove layer filtering from `Add`) +- Modify: `ACadSharp.Image.Tests/RecordingDrawingSurface.cs` (record styles), `EntityRenderDispatcherTests.cs` (effective layer test), `ImageExporterTests.cs` (rewrite the three hidden-layer tests) +- Create: `ACadSharp.Image.Tests/LayerFilteringTests.cs` + +**Interfaces:** +- Produces: `internal sealed class EntityVisibilityFilter(ImageConfiguration configuration) { bool IsVisible(Entity entity, Layer? effectiveLayer, string effectiveLayerName, Viewport? viewport); }`; dispatcher's `internal static Layer? GetEffectiveLayer(Entity entity, Layer? parentLayer)` replacing `GetEffectiveLayerName(Entity, string?)` (name is `effectiveLayer?.Name ?? Layer.DefaultName`); `RecordingDrawingSurface.Styles : List`. + +Rules (spec 4.1, 4.2), evaluated in order, first match hides: +1. `IncludedLayers.Count > 0 && !IncludedLayers.Contains(name)` +2. `HiddenLayers.Contains(name)` +3. mode `All` → visible. Otherwise: `layer != null && !layer.IsOn`; `layer != null && layer.Flags.HasFlag(LayerFlags.Frozen)`; `entity.IsInvisible`; `viewport != null && layer != null && viewport.FrozenLayers.Any(f => string.Equals(f.Name, name, OrdinalIgnoreCase))`. +4. mode `Plot`: `layer != null && !layer.PlotFlag`. + +- [ ] **Step 1: Failing tests** + +In `RecordingDrawingSurface` add `public List Styles { get; } = new();` and `this.Styles.Add(style);` as the first line of every `Draw*`/`Fill*` method. + +Create `ACadSharp.Image.Tests/LayerFilteringTests.cs`: + +```csharp +using ACadSharp.Entities; +using ACadSharp.Image.Rendering; +using ACadSharp.Objects; +using ACadSharp.Tables; +using CSMath; + +namespace ACadSharp.Image.Tests; + +public sealed class LayerFilteringTests +{ + private static (RecordingDrawingSurface Surface, EntityRenderDispatcher Dispatcher, ImageRenderContext Context) Setup(Action? configure = null, Viewport? viewport = null) + { + ImageConfiguration configuration = new(); + configure?.Invoke(configuration); + RecordingDrawingSurface surface = new(); + Layout layout = new("test") { PaperWidth = 100, PaperHeight = 100 }; + ImageRenderContext context = new(surface, configuration, layout, 100, 100, 0, 0, 1d, 0, 0, singlePrecision: false, lineTypeScale: 1d, viewport: viewport); + return (surface, new EntityRenderDispatcher(configuration), context); + } + + private static Line LineOn(Layer layer) => new(new XYZ(0, 0, 0), new XYZ(10, 0, 0)) { Layer = layer }; + + private static int Drawn(RecordingDrawingSurface surface) => surface.Calls.Count(c => c.StartsWith("DrawLine", StringComparison.Ordinal)); + + [Fact] + public void AllModeDrawsOffAndFrozenLayers() + { + (RecordingDrawingSurface surface, EntityRenderDispatcher dispatcher, ImageRenderContext context) = Setup(); + + dispatcher.Draw(context, LineOn(new Layer("Off") { IsOn = false })); + dispatcher.Draw(context, LineOn(new Layer("Frozen") { Flags = LayerFlags.Frozen })); + dispatcher.Draw(context, LineOn(new Layer("NoPlot") { PlotFlag = false })); + + Assert.Equal(3, Drawn(surface)); + } + + [Fact] + public void ScreenModeHidesOffFrozenAndInvisibleButNotNonPlottable() + { + (RecordingDrawingSurface surface, EntityRenderDispatcher dispatcher, ImageRenderContext context) = Setup(c => c.LayerVisibility = LayerVisibilityMode.Screen); + + dispatcher.Draw(context, LineOn(new Layer("Off") { IsOn = false })); + dispatcher.Draw(context, LineOn(new Layer("Frozen") { Flags = LayerFlags.Frozen })); + Line invisible = LineOn(new Layer("Vis")); + invisible.IsInvisible = true; + dispatcher.Draw(context, invisible); + dispatcher.Draw(context, LineOn(new Layer("NoPlot") { PlotFlag = false })); + + Assert.Equal(1, Drawn(surface)); + Assert.Equal("NoPlot", Assert.Single(surface.Entities).LayerName); + } + + [Fact] + public void PlotModeAlsoHidesNonPlottable() + { + (RecordingDrawingSurface surface, EntityRenderDispatcher dispatcher, ImageRenderContext context) = Setup(c => c.LayerVisibility = LayerVisibilityMode.Plot); + + dispatcher.Draw(context, LineOn(new Layer("NoPlot") { PlotFlag = false })); + dispatcher.Draw(context, LineOn(new Layer("Plot"))); + + Assert.Equal(1, Drawn(surface)); + } + + [Fact] + public void ViewportFrozenLayersHideOnlyInsideThatViewport() + { + Layer frozenHere = new("Site"); + Viewport viewport = new(); + viewport.FrozenLayers.Add(frozenHere); + (RecordingDrawingSurface inside, EntityRenderDispatcher dispatcher, ImageRenderContext viewportContext) = Setup(c => c.LayerVisibility = LayerVisibilityMode.Screen, viewport); + (RecordingDrawingSurface outside, EntityRenderDispatcher dispatcher2, ImageRenderContext pageContext) = Setup(c => c.LayerVisibility = LayerVisibilityMode.Screen); + + dispatcher.Draw(viewportContext, LineOn(new Layer("Site"))); + dispatcher2.Draw(pageContext, LineOn(new Layer("Site"))); + + Assert.Equal(0, Drawn(inside)); + Assert.Equal(1, Drawn(outside)); + } + + [Fact] + public void IncludeListRestrictsThenHideListRemoves() + { + (RecordingDrawingSurface surface, EntityRenderDispatcher dispatcher, ImageRenderContext context) = Setup(c => + { + c.IncludeLayers(["A", "B"]); + c.HideLayer("b"); + }); + + dispatcher.Draw(context, LineOn(new Layer("A"))); + dispatcher.Draw(context, LineOn(new Layer("B"))); + dispatcher.Draw(context, LineOn(new Layer("C"))); + + Assert.Equal(1, Drawn(surface)); + Assert.Equal("A", Assert.Single(surface.Entities).LayerName); + } + + [Fact] + public void IncludedLayerStillObeysVisibilityMode() + { + (RecordingDrawingSurface surface, EntityRenderDispatcher dispatcher, ImageRenderContext context) = Setup(c => + { + c.IncludeLayer("A"); + c.LayerVisibility = LayerVisibilityMode.Screen; + }); + + dispatcher.Draw(context, LineOn(new Layer("A") { IsOn = false })); + + Assert.Equal(0, Drawn(surface)); + } + + [Fact] + public void NestedEntitiesAreFilteredByTheirOwnLayerWithLayerZeroInheritance() + { + (RecordingDrawingSurface surface, EntityRenderDispatcher dispatcher, ImageRenderContext context) = Setup(c => c.HideLayer("Hardware")); + BlockRecord block = new("DOOR"); + block.Entities.Add(new Line(new XYZ(0, 0, 0), new XYZ(1, 0, 0)) { Layer = new Layer(Layer.DefaultName) }); + block.Entities.Add(new Line(new XYZ(0, 0, 0), new XYZ(0, 1, 0)) { Layer = new Layer("Hardware") }); + Insert insert = new(block) { Layer = new Layer("Doors") }; + + dispatcher.Draw(context, insert); + + Assert.Equal(1, Drawn(surface)); + Assert.Equal("Doors", surface.Entities.Last().LayerName); + } + + [Fact] + public void HidingTheInsertLayerHidesTheWholeBlock() + { + (RecordingDrawingSurface surface, EntityRenderDispatcher dispatcher, ImageRenderContext context) = Setup(c => c.HideLayer("Doors")); + BlockRecord block = new("DOOR"); + block.Entities.Add(new Line(new XYZ(0, 0, 0), new XYZ(1, 0, 0)) { Layer = new Layer("Hardware") }); + Insert insert = new(block) { Layer = new Layer("Doors") }; + + dispatcher.Draw(context, insert); + + Assert.Equal(0, Drawn(surface)); + Assert.Empty(surface.Entities); + } + + [Fact] + public void LayerZeroSubEntitiesFollowTheInsertLayerState() + { + (RecordingDrawingSurface visibleSurface, EntityRenderDispatcher dispatcher, ImageRenderContext context) = Setup(c => c.LayerVisibility = LayerVisibilityMode.Screen); + BlockRecord block = new("SYM"); + block.Entities.Add(new Line(new XYZ(0, 0, 0), new XYZ(1, 0, 0)) { Layer = new Layer(Layer.DefaultName) }); + + // Visible insert layer: the layer-0 sub-entity is drawn. + dispatcher.Draw(context, new Insert(block) { Layer = new Layer("Symbols") }); + Assert.Equal(1, Drawn(visibleSurface)); + + // Frozen insert layer: the same sub-entity inherits the frozen layer and is hidden. + (RecordingDrawingSurface frozenSurface, EntityRenderDispatcher dispatcher2, ImageRenderContext context2) = Setup(c => c.LayerVisibility = LayerVisibilityMode.Screen); + dispatcher2.Draw(context2, new Insert(block) { Layer = new Layer("Symbols") { Flags = LayerFlags.Frozen } }); + Assert.Equal(0, Drawn(frozenSurface)); + } +} +``` + +Rewrite the three hidden-layer tests in `ImageExporterTests` (`HiddenLayersFiltersOutEntitiesOnSpecifiedLayers`, `HiddenLayersIsCaseInsensitive`, `MultipleHiddenLayersCanBeConfigured`) to assert on rendered output instead of `page.Entities`: + +```csharp + private static int CountDrawnLines(ImageExporter exporter) + { + RecordingDrawingSurface surface = new(); + ImagePageRenderer renderer = new(exporter.Configuration); + renderer.RenderTo(surface, exporter.Pages[0]); + return surface.Calls.Count(c => c.StartsWith("DrawLine", StringComparison.Ordinal)); + } +``` + +and, for example, `HiddenLayersFiltersOutEntitiesOnSpecifiedLayers` ends with: + +```csharp + Assert.Equal(3, exporter.Pages[0].Entities.Count); // pages keep every entity; filtering happens at render time + Assert.Equal(2, CountDrawnLines(exporter)); +``` + +`HiddenLayersIsCaseInsensitive` asserts `Assert.Equal(0, CountDrawnLines(exporter));` and `MultipleHiddenLayersCanBeConfigured` asserts `Assert.Equal(1, CountDrawnLines(exporter));`. `ImagePageRenderer.RenderTo(IDrawingSurface, ImagePage)` must exist as `internal` (plan 2 Task 7 changed the private overload to take a context; keep a public-to-tests `internal void RenderTo(IDrawingSurface surface, ImagePage page)` that builds the raster-style page context with `CreatePageContext`). Add this test too: + +```csharp + [Fact] + public void ChangingHiddenLayersAfterAddTakesEffect() + { + BlockRecord block = new("late-hide"); + block.Entities.Add(new Line(new XYZ(0, 0, 0), new XYZ(1, 1, 0)) { Layer = new Layer("Late") }); + ImageExporter exporter = new(); + exporter.Add(block); + + Assert.Equal(1, CountDrawnLines(exporter)); + exporter.Configuration.HideLayer("Late"); + Assert.Equal(0, CountDrawnLines(exporter)); + } +``` + +In `EntityRenderDispatcherTests.NestedEntityOnLayerZeroInheritsInsertLayer` nothing changes (it asserts on names). Add: + +```csharp + [Fact] + public void EffectiveLayerReturnsParentLayerObjectForLayerZero() + { + Layer parent = new("Doors") { IsOn = false }; + Line onZero = new() { Layer = new Layer(Layer.DefaultName) }; + Line onOwn = new() { Layer = new Layer("Own") }; + + Assert.Same(parent, EntityRenderDispatcher.GetEffectiveLayer(onZero, parent)); + Assert.Equal("Own", EntityRenderDispatcher.GetEffectiveLayer(onOwn, parent)!.Name); + Assert.Equal(Layer.DefaultName, EntityRenderDispatcher.GetEffectiveLayer(onZero, null)!.Name); + } +``` + +- [ ] **Step 2: Run, expect failures/compile errors** + +- [ ] **Step 3: Implement the filter** + +Create `ACadSharp.Image/Rendering/EntityVisibilityFilter.cs`: + +```csharp +using ACadSharp.Entities; +using ACadSharp.Tables; + +namespace ACadSharp.Image.Rendering; + +/// +/// Decides whether an entity is drawn, combining the include list, the hide list and . +/// +internal sealed class EntityVisibilityFilter +{ + private readonly ImageConfiguration _configuration; + + public EntityVisibilityFilter(ImageConfiguration configuration) + { + this._configuration = configuration; + } + + public bool IsVisible(Entity entity, Layer? effectiveLayer, string effectiveLayerName, Viewport? viewport) + { + if (this._configuration.IncludedLayers.Count > 0 && !this._configuration.IncludedLayers.Contains(effectiveLayerName)) + { + return false; + } + + if (this._configuration.HiddenLayers.Count > 0 && this._configuration.HiddenLayers.Contains(effectiveLayerName)) + { + return false; + } + + LayerVisibilityMode mode = this._configuration.LayerVisibility; + if (mode == LayerVisibilityMode.All) + { + return true; + } + + if (entity.IsInvisible) + { + return false; + } + + if (effectiveLayer != null) + { + if (!effectiveLayer.IsOn || effectiveLayer.Flags.HasFlag(LayerFlags.Frozen)) + { + return false; + } + + if (viewport != null && viewport.FrozenLayers.Any(frozen => string.Equals(frozen.Name, effectiveLayerName, StringComparison.OrdinalIgnoreCase))) + { + return false; + } + + if (mode == LayerVisibilityMode.Plot && !effectiveLayer.PlotFlag) + { + return false; + } + } + + return true; + } +} +``` + +- [ ] **Step 4: Wire the dispatcher** + +In `EntityRenderDispatcher`: +- Add field `private readonly EntityVisibilityFilter _visibilityFilter;` initialised in the constructor. +- Replace `GetEffectiveLayerName(Entity, string?)` with: + +```csharp + /// + /// Entities on layer "0" inside a block take the layer of the insert that placed them. + /// + internal static Layer? GetEffectiveLayer(Entity entity, Layer? parentLayer) + { + Layer? own = entity.Layer; + if (own == null || string.IsNullOrEmpty(own.Name)) + { + return parentLayer ?? own ?? Layer.Default; + } + + if (parentLayer != null && string.Equals(own.Name, Layer.DefaultName, StringComparison.Ordinal)) + { + return parentLayer; + } + + return own; + } +``` + +- Change the private recursive `Draw` signature to `Draw(ImageRenderContext context, Entity entity, Layer? parentLayer, ulong? parentHandle, string? blockName)` and its start to: + +```csharp + Layer? layer = GetEffectiveLayer(entity, parentLayer); + string layerName = layer?.Name ?? Layer.DefaultName; + if (!this._visibilityFilter.IsVisible(entity, layer, layerName, context.Viewport)) + { + return; + } + + ImageStyle style = this._styleResolver.Resolve(entity, context); + EntityRenderInfo info = new(layerName, entity.ObjectName, entity.Handle, parentHandle, blockName); + LayerRenderInfo layerInfo = CreateLayerInfo(layer, layerName, context); +``` + +- `DrawDimension` and `DrawBlockContents` take `Layer? layer` instead of `string layerName` and pass it down. The public `Draw(context, entity)` passes `parentLayer: null`. + +If `Layer.Default` is not a static property in 3.7.1 (plan research shows `Layer.Default` and `Layer.DefaultName` exist), fall back to `new Layer(Layer.DefaultName)`. + +- [ ] **Step 5: Remove add-time layer filtering from `ImageExporter`** + +In `ImageExporter`: `ShouldIncludeEntity` becomes `entity is not Viewport` (delete `IsHiddenLayer`). Update its XML remarks on `Add(Layout)`/`Add(BlockRecord)`: "Layer filters and visibility settings are applied when rendering, so all entities are kept on the page." + +- [ ] **Step 5b: Keep auto-sized pages framed on visible content** + +Before this change, hidden entities were excluded before `ImagePage.UpdateLayoutSize` computed the block page's extents, so hiding a far-away layer tightened the frame. Hidden entities now stay on the page, so the frame must be recomputed from visible entities at render time. + +Failing test (append to `ImageExporterTests`): + +```csharp + [Fact] + public void HiddenEntitiesDoNotAffectAutoSizedFraming() + { + static ImageExporter Build(bool withFarHiddenLine) + { + BlockRecord block = new("framing"); + block.Entities.Add(new Line(new XYZ(0, 0, 0), new XYZ(10, 10, 0)) { Layer = new Layer("Visible") }); + if (withFarHiddenLine) + { + block.Entities.Add(new Line(new XYZ(1000, 1000, 0), new XYZ(1010, 1010, 0)) { Layer = new Layer("Far") }); + } + + ImageExporter exporter = new(); + exporter.Configuration.Width = 200; + exporter.Configuration.Height = 200; + exporter.Configuration.HideLayer("Far"); + exporter.Add(block); + return exporter; + } + + static string FirstLineCall(ImageExporter exporter) + { + RecordingDrawingSurface surface = new(); + new ImagePageRenderer(exporter.Configuration).RenderTo(surface, exporter.Pages[0]); + return surface.Calls.Single(c => c.StartsWith("DrawLine", StringComparison.Ordinal)); + } + + Assert.Equal(FirstLineCall(Build(withFarHiddenLine: false)), FirstLineCall(Build(withFarHiddenLine: true))); + } +``` + +Implementation: +- `ImagePage`: add `internal bool AutoSized { get; private set; }`, set to `true` at the end of `UpdateLayoutSize()` when it assigned the paper size. Add an overload `public void UpdateLayoutSize(Func? include)` that computes the bounding box only over entities for which `include` returns true (null includes all); the existing parameterless method calls it with `null`. When the filtered set is empty, leave `Translation` and the layout size unchanged. +- `ImagePageRenderer`: in both `RenderTo(IDrawingSurface, ImagePage)` (before `CreatePageContext`) and `RenderSvg` (before `ComputeSvgViewBox`), when `page.AutoSized` call + +```csharp + page.UpdateLayoutSize(entity => + { + Layer? layer = EntityRenderDispatcher.GetEffectiveLayer(entity, null); + return this._visibilityFilter.IsVisible(entity, layer, layer?.Name ?? Layer.DefaultName, null); + }); +``` + +with `private readonly EntityVisibilityFilter _visibilityFilter = new(configuration);` added to the renderer. Layout pages (`Add(Layout)`) keep their paper size and are never auto-sized. + +Run the test: the two `DrawLine` strings must be identical. Parity holds because the samples render with no hidden layers, so the filtered bounding box equals the unfiltered one. + +- [ ] **Step 6: Run everything** + +Run: `dotnet test ACadSharp.Image.sln -c Release --nologo -v q` +Expected: PASS including parity (default `All` mode and empty lists draw exactly what add-time filtering drew, since the samples are rendered with no hidden layers). + +- [ ] **Step 7: Commit** + +```bash +git add ACadSharp.Image ACadSharp.Image.Tests +git commit -m "Apply layer visibility and selection in the render loop + +Co-Authored-By: Claude Fable 5.1 +Claude-Session: https://claude.ai/code/session_016RGgSinQSUz4d89FRLxhMz" +``` + +--- + +### Task 3: ACI 7 by background and `ForegroundColor` + +**Files:** +- Modify: `ACadSharp.Image/Extensions/ColorExtensions.cs` +- Modify: `ACadSharp.Image/ImageConfiguration.cs` (`ResolveForegroundColor`) +- Modify: `ACadSharp.Image/Rendering/ImageStyleResolver.cs`, `EntityRenderDispatcher.CreateLayerInfo` +- Test: `ACadSharp.Image.Tests/ColorResolutionTests.cs` (create) + +**Interfaces:** +- Produces: `ColorExtensions.ToImageColor(this CadColor color, ImageColor foreground)`; `internal ImageColor ImageConfiguration.ResolveForegroundColor()`. + +- [ ] **Step 1: Failing tests** + +```csharp +using ACadSharp.Image.Extensions; +using SixLabors.ImageSharp; + +namespace ACadSharp.Image.Tests; + +public sealed class ColorResolutionTests +{ + [Fact] + public void Index7IsBlackOnLightAndWhiteOnDarkBackgrounds() + { + ImageConfiguration light = new(); + ImageConfiguration dark = new() { BackgroundColor = Color.FromRgb(20, 20, 40) }; + ImageConfiguration transparent = new() { BackgroundColor = Color.Transparent }; + + Assert.Equal(Color.Black, light.ResolveForegroundColor()); + Assert.Equal(Color.White, dark.ResolveForegroundColor()); + Assert.Equal(Color.Black, transparent.ResolveForegroundColor()); + } + + [Fact] + public void ExplicitForegroundWins() + { + ImageConfiguration configuration = new() { BackgroundColor = Color.Black, ForegroundColor = Color.Yellow }; + + 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())); + } +} +``` + +- [ ] **Step 2: Run, expect compile failure** + +- [ ] **Step 3: Implement** + +`ColorExtensions`: + +```csharp + public static ImageColor ToImageColor(this CadColor color, ImageColor foreground) + { + if (color.Index == ByBackgroundIndex) + { + return foreground; + } + + return ImageColor.FromRgb(color.R, color.G, color.B); + } + + public static ImageColor ToImageColor(this CadColor color) => color.ToImageColor(ImageColor.Black); +``` + +`ImageConfiguration`: + +```csharp + /// + /// Colour used for AutoCAD colour index 7: when set, else black on light or transparent + /// backgrounds and white on dark ones. + /// + internal ImageColor ResolveForegroundColor() + { + if (this.ForegroundColor is ImageColor explicitColor) + { + return explicitColor; + } + + SixLabors.ImageSharp.PixelFormats.Rgba32 background = this.BackgroundColor.ToPixel(); + if (background.A == 0) + { + return ImageColor.Black; + } + + double luminance = (0.299d * background.R) + (0.587d * background.G) + (0.114d * background.B); + return luminance < 128d ? ImageColor.White : ImageColor.Black; + } +``` + +`ImageStyleResolver.Resolve`: `entity.GetActiveColor().ToImageColor(context.Configuration.ResolveForegroundColor())`. `EntityRenderDispatcher.CreateLayerInfo`: `layer.Color.ToImageColor(context.Configuration.ResolveForegroundColor())`, and the null-layer branch uses `context.Configuration.ResolveForegroundColor()` instead of `Black`. + +- [ ] **Step 4: Run everything (parity must hold: white background still gives black), commit** + +```bash +git add ACadSharp.Image ACadSharp.Image.Tests/ColorResolutionTests.cs +git commit -m "Resolve colour index 7 from the background or ForegroundColor + +Co-Authored-By: Claude Fable 5.1 +Claude-Session: https://claude.ai/code/session_016RGgSinQSUz4d89FRLxhMz" +``` + +--- + +### Task 4: Transparency to opacity + +**Files:** +- Modify: `ACadSharp.Image/Rendering/ImageStyleResolver.cs`, `EntityRenderDispatcher.cs` +- Test: `ACadSharp.Image.Tests/LayerFilteringTests.cs` (append) or a new `StyleResolutionTests.cs` + +**Interfaces:** +- Produces: `ImageStyleResolver.Resolve(Entity entity, ImageRenderContext context, float parentOpacity)`; `internal static float ImageStyleResolver.ResolveOpacity(Entity entity, float parentOpacity)`. Dispatcher threads `parentOpacity` through nested draws (top level 1). + +- [ ] **Step 1: Failing tests** + +Create `ACadSharp.Image.Tests/StyleResolutionTests.cs`: + +```csharp +using ACadSharp; +using ACadSharp.Entities; +using ACadSharp.Image.Rendering; +using ACadSharp.Objects; +using ACadSharp.Tables; +using CSMath; + +namespace ACadSharp.Image.Tests; + +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(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)); + } + + [Fact] + public void NestedEntitiesInheritByBlockOpacity() + { + ImageConfiguration configuration = new(); + RecordingDrawingSurface surface = new(); + Layout layout = new("t") { PaperWidth = 10, PaperHeight = 10 }; + ImageRenderContext context = new(surface, configuration, layout, 10, 10, 0, 0, 1d, 0, 0, singlePrecision: false, lineTypeScale: 1d); + EntityRenderDispatcher dispatcher = new(configuration); + BlockRecord block = new("B"); + block.Entities.Add(new Line(new XYZ(0, 0, 0), new XYZ(1, 0, 0)) { Transparency = Transparency.ByBlock }); + Insert insert = new(block) { Transparency = new Transparency(50) }; + + dispatcher.Draw(context, insert); + + Assert.Equal(0.5f, Assert.Single(surface.Styles).Opacity, 3); + } +} +``` + +- [ ] **Step 2: Run, expect failure** + +- [ ] **Step 3: Implement** + +`ImageStyleResolver`: + +```csharp + public ImageStyle Resolve(Entity entity, ImageRenderContext context, float parentOpacity) + { + return new ImageStyle( + entity.GetActiveColor().ToImageColor(context.Configuration.ResolveForegroundColor()), + context.ToStrokeWidth(entity.GetActiveLineWeightType()), + null, + ResolveOpacity(entity, parentOpacity)); + } + + /// + /// Maps CAD transparency to opacity. ByLayer is opaque (ACadSharp 3.7.1 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) + { + Transparency transparency = entity.Transparency; + if (transparency.IsByLayer) + { + return 1f; + } + + if (transparency.IsByBlock) + { + return parentOpacity; + } + + return Math.Clamp(1f - (transparency.Value / 100f), 0f, 1f); + } +``` + +Dispatcher: the private `Draw` gains `float parentOpacity`; the public entry passes `1f`; `DrawDimension`/`DrawBlockContents` receive the resolved `style.Opacity` and pass it down as `parentOpacity`. + +- [ ] **Step 4: Run everything (parity: sample entities default to ByLayer, so opaque), commit** + +```bash +git add ACadSharp.Image ACadSharp.Image.Tests/StyleResolutionTests.cs +git commit -m "Map entity transparency to opacity + +Co-Authored-By: Claude Fable 5.1 +Claude-Session: https://claude.ai/code/session_016RGgSinQSUz4d89FRLxhMz" +``` + +--- + +### Task 5: Linetypes as dash patterns + +**Files:** +- Create: `ACadSharp.Image/Rendering/LineTypeDashResolver.cs` +- Modify: `ACadSharp.Image/Rendering/ImageStyleResolver.cs` (fill `DashPattern`) +- Modify: `ACadSharp.Image/Rendering/ImageRenderContext.cs` (`CreateViewportContext` takes `lineTypeScale`), `ImagePageRenderer.DrawViewport` (PSLTSCALE) +- Test: `ACadSharp.Image.Tests/LineTypeDashResolverTests.cs` (create) + +**Interfaces:** +- Produces: `internal static class LineTypeDashResolver { static float[]? Resolve(Entity entity, ImageRenderContext context, float strokeWidth); static float[]? BuildPattern(LineType lineType, double scale, float strokeWidth); static bool EnforcesMinimumDash(ImageRenderContext context); }`. + +Rules (spec 4.4): `scale = ltscale * celtscale * context.LineTypeScale` with `ltscale = header.LineTypeScale > 0 ? header.LineTypeScale : 1`, `celtscale = entity.LineTypeScale > 0 ? entity.LineTypeScale : 1`. Segment kinds: dash (`Length > 0`), gap (`Length < 0`, or `IsShape`/`IsText`), dot (`Length == 0` → dash of `strokeWidth`). Adjacent same-kind entries are merged; the pattern starts with a dash (prepend a `0` dash when it starts with a gap); odd counts are doubled so the array is even. Minimum dash rule applies when `context.StrokeUnitsPerMillimeter == null` (pixel widths): total length `< MinimumDashPixels` → null (solid). + +- [ ] **Step 1: Failing tests** + +```csharp +using ACadSharp.Entities; +using ACadSharp.Image.Rendering; +using ACadSharp.Objects; +using ACadSharp.Tables; + +namespace ACadSharp.Image.Tests; + +public sealed class LineTypeDashResolverTests +{ + private static LineType Dashed(params double[] lengths) + { + LineType lineType = new("DASHED"); + foreach (double length in lengths) + { + lineType.AddSegment(new LineType.Segment { Length = length }); + } + + return lineType; + } + + private static ImageRenderContext Context(double scale, double? unitsPerMillimeter = null, float minimumDash = 2f) + { + ImageConfiguration configuration = new() { MinimumDashPixels = minimumDash }; + Layout layout = new("t") { PaperWidth = 10, PaperHeight = 10 }; + return new ImageRenderContext(new RecordingDrawingSurface(), configuration, layout, 10, 10, 0, 0, scale, 0, 0, singlePrecision: false, lineTypeScale: scale, strokeUnitsPerMillimeter: unitsPerMillimeter); + } + + [Fact] + public void ContinuousIsSolid() + { + Assert.Null(LineTypeDashResolver.BuildPattern(LineType.Continuous, 1d, 1f)); + } + + [Fact] + public void DashGapPatternScales() + { + float[]? pattern = LineTypeDashResolver.BuildPattern(Dashed(0.5, -0.25), 4d, 1f); + + Assert.Equal([2f, 1f], pattern); + } + + [Fact] + public void DotsBecomeStrokeWidthDashesAndGapsMerge() + { + LineType lineType = Dashed(0.5, -0.25, 0, -0.25); + + float[]? pattern = LineTypeDashResolver.BuildPattern(lineType, 2d, 1.5f); + + // dash 1, gap 0.5, dot -> 1.5, gap 0.5 + Assert.Equal([1f, 0.5f, 1.5f, 0.5f], pattern); + } + + [Fact] + public void ShapeSegmentsAreGaps() + { + LineType lineType = new("GAS"); + lineType.AddSegment(new LineType.Segment { Length = 0.5 }); + lineType.AddSegment(new LineType.Segment { Length = -0.2 }); + lineType.AddSegment(new LineType.Segment { Length = 0.3, IsText = true, Text = "GAS" }); + lineType.AddSegment(new LineType.Segment { Length = -0.2 }); + + float[]? pattern = LineTypeDashResolver.BuildPattern(lineType, 10d, 1f); + + Assert.Equal([5f, 7f], pattern); // gaps 2 + 3 + 2 merged + } + + [Fact] + public void PatternStartingWithGapGetsZeroDash() + { + float[]? pattern = LineTypeDashResolver.BuildPattern(Dashed(-0.5, 0.5), 1d, 1f); + + Assert.Equal([0f, 0.5f, 0.5f, 0f], pattern); + } + + [Fact] + public void TinyPatternsAreSolidInPixelMode() + { + Line line = new() { LineType = Dashed(0.1, -0.1) }; + + Assert.Null(LineTypeDashResolver.Resolve(line, Context(1d), 1f)); + Assert.NotNull(LineTypeDashResolver.Resolve(line, Context(20d), 1f)); + Assert.NotNull(LineTypeDashResolver.Resolve(line, Context(1d, unitsPerMillimeter: 1d), 1f)); + } + + [Fact] + public void EntityLineTypeScaleMultiplies() + { + Line line = new() { LineType = Dashed(1, -1), LineTypeScale = 3 }; + + Assert.Equal([3f, 3f], LineTypeDashResolver.Resolve(line, Context(1d), 1f)); + } +} +``` + +`LineType.Segment` in 3.7.1 has a public parameterless constructor and settable `Length`, `IsText`, `IsShape`, `Text` (from the API dump). If `Continuous` cannot be referenced as `LineType.Continuous`, use `new LineType("Continuous")` (no segments). + +- [ ] **Step 2: Run, expect compile failure** + +- [ ] **Step 3: Implement** + +```csharp +using ACadSharp.Entities; +using ACadSharp.Header; +using ACadSharp.Tables; + +namespace ACadSharp.Image.Rendering; + +/// +/// Turns a CAD linetype into an alternating dash/gap array in surface units. +/// +internal static class LineTypeDashResolver +{ + public static float[]? Resolve(Entity entity, ImageRenderContext context, float strokeWidth) + { + LineType? lineType = entity.GetActiveLineType(); + if (lineType == null) + { + return null; + } + + CadHeader? header = entity.Document?.Header; + double ltscale = header != null && header.LineTypeScale > 0d ? header.LineTypeScale : 1d; + double celtscale = entity.LineTypeScale > 0d ? entity.LineTypeScale : 1d; + float[]? pattern = BuildPattern(lineType, ltscale * celtscale * context.LineTypeScale, strokeWidth); + if (pattern == null) + { + return null; + } + + if (EnforcesMinimumDash(context) && pattern.Sum() < context.Configuration.MinimumDashPixels) + { + return null; + } + + return pattern; + } + + public static bool EnforcesMinimumDash(ImageRenderContext context) + { + return context.StrokeUnitsPerMillimeter == null; + } + + public static float[]? BuildPattern(LineType lineType, double scale, float strokeWidth) + { + List<(bool On, float Length)> entries = new(); + foreach (LineType.Segment segment in lineType.Segments) + { + double length = segment.Length * scale; + bool on; + float value; + if (segment.IsShape || segment.IsText) + { + on = false; + value = (float)Math.Abs(length); + } + else if (length > 0d) + { + on = true; + value = (float)length; + } + else if (length < 0d) + { + on = false; + value = (float)-length; + } + else + { + on = true; + value = strokeWidth; + } + + if (entries.Count > 0 && entries[^1].On == on) + { + entries[^1] = (on, entries[^1].Length + value); + } + else + { + entries.Add((on, value)); + } + } + + if (entries.Count == 0 || !entries.Any(e => !e.On)) + { + return null; + } + + if (!entries[0].On) + { + entries.Insert(0, (true, 0f)); + } + + if (entries.Count % 2 == 1) + { + entries.Add((false, 0f)); + } + + return entries.Select(e => e.Length).ToArray(); + } +} +``` + +Check `PatternStartingWithGapGetsZeroDash`: entries gap 0.5, dash 0.5 → prepend dash 0 → [0 on, 0.5 off, 0.5 on] → odd → append 0 off → `[0, 0.5, 0.5, 0]`. Check `DotsBecomeStrokeWidthDashesAndGapsMerge`: 0.5·2=1 on, 0.5 off, dot 1.5 on, 0.5 off → `[1, 0.5, 1.5, 0.5]`. Check `ShapeSegmentsAreGaps`: 5 on, 2 off, 3 off (merged → 5), 2 off (merged → 7) → `[5, 7]`. + +`ImageStyleResolver.Resolve` builds the style in two steps so the dash resolver gets the width: + +```csharp + float width = context.ToStrokeWidth(entity.GetActiveLineWeightType()); + return new ImageStyle( + entity.GetActiveColor().ToImageColor(context.Configuration.ResolveForegroundColor()), + width, + LineTypeDashResolver.Resolve(entity, context, width), + ResolveOpacity(entity, parentOpacity)); +``` + +PSLTSCALE: `ImageRenderContext.CreateViewportContext` gets a new parameter `double lineTypeScale` used instead of `scale`; in `ImagePageRenderer.DrawViewport`: + +```csharp + bool paperSpaceLineTypeScaling = (viewport.Document?.Header.PaperSpaceLineTypeScaling ?? SpaceLineTypeScaling.Viewport) == SpaceLineTypeScaling.Viewport; + double lineTypeScale = paperSpaceLineTypeScaling + ? pageContext.LineTypeScale + : pageContext.LineTypeScale * viewport.ScaleFactor; + ImageRenderContext viewportContext = ImageRenderContext.CreateViewportContext(pageContext, viewport, viewportSurface, modelBounds, scale, lineTypeScale); +``` + +(`SpaceLineTypeScaling` is in `ACadSharp.Header`.) Deriving from the page's `LineTypeScale` rather than its `Scale` keeps the units right for every backend: raster pages have `LineTypeScale == Scale` (pixels per unit), SVG pages in non-scaling-stroke mode have `LineTypeScale == fit` (pixels per unit, matching the pixel stroke widths the browser also computes in pixel space) and in drawing-unit mode `LineTypeScale == 1`. Raster surface `CreatePen` already builds a `PatternPen` from `DashPattern`; the SVG surface already writes `stroke-dasharray`. + +- [ ] **Step 4: Run everything** + +Run: `dotnet test ACadSharp.Image.sln -c Release --nologo -v q` +Expected: PASS. If a `SampleParityTests` case or SVG golden now differs, confirm the sample really contains a non-continuous linetype (`grep -c "^DASHED\|^HIDDEN\|^CENTER" Samples/6-57-1119.dxf` or inspect the golden diff for `stroke-dasharray`), regenerate that baseline with the update flag, look at the PNG, and state it in the commit message. + +- [ ] **Step 5: Commit** + +```bash +git add ACadSharp.Image ACadSharp.Image.Tests +git commit -m "Render linetypes as dash patterns with LTSCALE and PSLTSCALE + +Co-Authored-By: Claude Fable 5.1 +Claude-Session: https://claude.ai/code/session_016RGgSinQSUz4d89FRLxhMz" +``` + +--- + +### Task 6: Hatch + +**Files:** +- Modify: `ACadSharp.Image/Rendering/EntityRenderDispatcher.cs` +- Test: `ACadSharp.Image.Tests/EntityRenderDispatcherTests.cs` + +- [ ] **Step 1: Failing tests** + +```csharp + private static Hatch SquareHatch(bool solid) + { + Hatch hatch = new(); + Hatch.BoundaryPath path = new(); + Hatch.BoundaryPath.Polyline polyline = new() { IsClosed = true }; + polyline.Vertices.AddRange([new XYZ(0, 0, 0), new XYZ(10, 0, 0), new XYZ(10, 10, 0), new XYZ(0, 10, 0)]); + path.Edges.Add(polyline); + hatch.Paths.Add(path); + if (solid) + { + hatch.IsSolid = true; + hatch.PatternType = HatchPatternType.SolidFill; + hatch.Pattern = HatchPattern.Solid; + } + else + { + hatch.IsSolid = false; + hatch.PatternType = HatchPatternType.PatternFill; + hatch.Pattern = new HatchPattern("ANSI31"); + hatch.Pattern.Lines.Add(new HatchPattern.Line { Angle = Math.PI / 4, BasePoint = XY.Zero, Offset = new XY(0, 3.175) }); + hatch.PatternScale = 1; + } + + return hatch; + } + + [Fact] + public void SolidHatchFillsBoundaryRings() + { + RecordingDrawingSurface surface = new(); + ImageConfiguration configuration = new(); + EntityRenderDispatcher dispatcher = new(configuration); + + dispatcher.Draw(CreateContext(surface, configuration), SquareHatch(solid: true)); + + Assert.Contains("FillPath rings=1", surface.Calls); + Assert.DoesNotContain(surface.Calls, c => c.StartsWith("DrawLine", StringComparison.Ordinal)); + } + + [Fact] + public void PatternHatchDrawsClippedLines() + { + RecordingDrawingSurface surface = new(); + ImageConfiguration configuration = new(); + EntityRenderDispatcher dispatcher = new(configuration); + + dispatcher.Draw(CreateContext(surface, configuration), SquareHatch(solid: false)); + + int lines = surface.Calls.Count(c => c.StartsWith("DrawLine", StringComparison.Ordinal)); + Assert.InRange(lines, 5, 9); // 45-degree lines 3.175 apart across a 10x10 square + Assert.All(surface.Styles, s => Assert.Null(s.DashPattern)); + } + + [Fact] + public void PatternHatchIsCappedWithWarning() + { + RecordingDrawingSurface surface = new(); + ImageConfiguration configuration = new() { MaxHatchLines = 3 }; + List notifications = new(); + configuration.OnNotification += (_, e) => notifications.Add(e); + EntityRenderDispatcher dispatcher = new(configuration); + + dispatcher.Draw(CreateContext(surface, configuration), SquareHatch(solid: false)); + + Assert.Equal(3, surface.Calls.Count(c => c.StartsWith("DrawLine", StringComparison.Ordinal))); + Assert.Contains(notifications, n => n.NotificationType == NotificationType.Warning && n.Message.Contains("hatch", StringComparison.OrdinalIgnoreCase)); + } +``` + +(`NotificationEventArgs`/`NotificationType` are in `ACadSharp.IO`; add the using.) The probe against 3.7.1 produced 7 lines for exactly this hatch. + +- [ ] **Step 2: Run, expect failure (`Drawing not implemented` notification, no calls)** + +- [ ] **Step 3: Implement** + +Add before the `Insert` case in `Draw`: + +```csharp + case Hatch hatch: + this.DrawHatch(context, style, hatch); + break; +``` + +and: + +```csharp + private void DrawHatch(ImageRenderContext context, ImageStyle style, Hatch hatch) + { + if (hatch.IsSolid || hatch.PatternType == HatchPatternType.SolidFill) + { + List> rings = new(); + foreach (Hatch.BoundaryPath path in hatch.Paths) + { + List ring = new(); + foreach (XYZ point in path.GetPoints(this._configuration.ArcPrecision)) + { + ring.Add(context.ToSurfacePoint(point)); + } + + if (ring.Count >= 3) + { + rings.Add(ring); + } + } + + if (rings.Count > 0) + { + context.Surface.FillPath(style, rings); + } + + return; + } + + ImageStyle lineStyle = style with { DashPattern = null }; + int drawn = 0; + foreach (Entity segment in hatch.ExplodePattern()) + { + if (segment is not Line line) + { + continue; + } + + if (drawn >= this._configuration.MaxHatchLines) + { + this._configuration.Notify($"[{hatch.SubclassMarker}] Hatch pattern exceeds {this._configuration.MaxHatchLines} lines; remaining lines were skipped.", NotificationType.Warning); + return; + } + + context.Surface.DrawLine(lineStyle, context.ToSurfacePoint(line.StartPoint), context.ToSurfacePoint(line.EndPoint)); + drawn++; + } + } +``` + +`BoundaryPath.GetPoints` returns `IEnumerable` in ACadSharp 3.7.1 (verified by reflection). If `ExplodePattern` throws for a hatch without a pattern (`Pattern == null`), guard with `if (hatch.Pattern == null) return;` before the loop and add a `Warning` notification. + +- [ ] **Step 4: Run everything, commit** + +```bash +dotnet test ACadSharp.Image.sln -c Release --nologo -v q +git add ACadSharp.Image/Rendering/EntityRenderDispatcher.cs ACadSharp.Image.Tests/EntityRenderDispatcherTests.cs +git commit -m "Render solid and pattern hatches + +Co-Authored-By: Claude Fable 5.1 +Claude-Session: https://claude.ai/code/session_016RGgSinQSUz4d89FRLxhMz" +``` + +If a sample contains hatches, the parity PNG and SVG golden for it will change; regenerate as described in Global Constraints and say so in the commit message. + +--- + +### Task 7: CLI layer options and `--list-layers` + +**Files:** +- Modify: `ACadSharp.Image.Cli/CliOptions.cs`, `ACadSharp.Image.Cli/Program.cs` + +- [ ] **Step 1: Options** + +Add to `CliOptions`: `string? LayerVisibility, IReadOnlyList OnlyLayers, bool ListLayers`. In `ParseArgs`: + +```csharp + case "--layer-visibility": + layerVisibility = GetRequiredValue(args, ref i, current); + break; + case "--only-layer": + onlyLayers.Add(GetRequiredValue(args, ref i, current)); + break; + case "--list-layers": + listLayers = true; + break; +``` + +In `Configure`: + +```csharp + foreach (string layer in options.OnlyLayers) + { + configuration.IncludeLayer(layer); + } + + if (!string.IsNullOrWhiteSpace(options.LayerVisibility)) + { + configuration.LayerVisibility = Enum.TryParse(options.LayerVisibility, ignoreCase: true, out LayerVisibilityMode mode) + ? mode + : throw new InvalidOperationException($"Invalid --layer-visibility '{options.LayerVisibility}'. Use all, screen or plot."); + } +``` + +- [ ] **Step 2: `--list-layers`** + +In `Main`, after `CadDocument document = LoadDocument(inputPath);` and before adding content: + +```csharp + if (options.ListLayers) + { + WriteLayerTable(document); + return 0; + } +``` + +Move `ResolveFormat`/`ResolveOutputPath` after this block so listing never requires an output path. Add: + +```csharp + private static void WriteLayerTable(CadDocument document) + { + Dictionary counts = new(StringComparer.OrdinalIgnoreCase); + foreach (ACadSharp.Entities.Entity entity in document.ModelSpace.Entities) + { + string name = entity.Layer?.Name ?? "0"; + counts[name] = counts.TryGetValue(name, out int count) ? count + 1 : 1; + } + + List layers = document.Layers.OrderBy(l => l.Name, StringComparer.OrdinalIgnoreCase).ToList(); + int nameWidth = Math.Max(5, layers.Max(l => l.Name.Length)); + int lineTypeWidth = Math.Max(8, layers.Max(l => (l.LineType?.Name ?? "-").Length)); + + Console.WriteLine($"{"Layer".PadRight(nameWidth)} On Frozen Plot Color Weight {"Linetype".PadRight(lineTypeWidth)} Entities"); + foreach (ACadSharp.Tables.Layer layer in layers) + { + string color = layer.Color.IsTrueColor + ? $"#{layer.Color.R:x2}{layer.Color.G:x2}{layer.Color.B:x2}" + : layer.Color.Index.ToString(CultureInfo.InvariantCulture); + counts.TryGetValue(layer.Name, out int count); + Console.WriteLine( + $"{layer.Name.PadRight(nameWidth)} {(layer.IsOn ? "yes" : "no ")} {(layer.Flags.HasFlag(ACadSharp.Tables.LayerFlags.Frozen) ? "yes " : "no ")} {(layer.PlotFlag ? "yes " : "no ")} {color.PadRight(11)} {layer.LineWeight.ToString().PadRight(6)} {(layer.LineType?.Name ?? "-").PadRight(lineTypeWidth)} {count}"); + } + } +``` + +Help text additions: + +``` + --only-layer Render only the specified layer(s). Can be used multiple times. + --layer-visibility all (default), screen (honour off/frozen), or plot (also honour non-plottable). + --list-layers Print the drawing's layers and exit without rendering. +``` + +- [ ] **Step 3: Verify manually** + +Run: `dotnet run --project ./ACadSharp.Image.Cli/ACadSharp.Image.Cli.csproj -c Release -- ./Samples/6-57-1119.dxf --list-layers` +Expected: a table with a header row and one row per layer including `OPTIONAL_DIMENSIONS`. Then: + +```bash +dotnet run --project ./ACadSharp.Image.Cli/ACadSharp.Image.Cli.csproj -c Release -- ./Samples/6-57-1119.dxf --only-layer OPTIONAL_DIMENSIONS --format svg -o /tmp/claude-1000/-work-workspaces-orca-ACadSharp-Image-svg-support/f63cfd24-08c1-4c72-b680-352d15a25a0a/scratchpad/only.svg && grep -o 'data-layer="[^"]*"' /tmp/claude-1000/-work-workspaces-orca-ACadSharp-Image-svg-support/f63cfd24-08c1-4c72-b680-352d15a25a0a/scratchpad/only.svg | sort -u +``` + +Expected: exactly one `data-layer="OPTIONAL_DIMENSIONS"`. + +- [ ] **Step 4: Tests and commit** + +```bash +dotnet test ACadSharp.Image.sln -c Release --nologo -v q +git add ACadSharp.Image.Cli +git commit -m "Add layer visibility, include list and --list-layers to the CLI + +Co-Authored-By: Claude Fable 5.1 +Claude-Session: https://claude.ai/code/session_016RGgSinQSUz4d89FRLxhMz" +``` + +--- + +### Task 8: README + +**Files:** +- Modify: `README.md` + +- [ ] **Step 1: Features list (line 16 onwards)** + +Add bullets: "SVG output with one `` per layer, `data-*` attributes and real ``, ready for React pan/zoom viewers", "Layer visibility modes (`screen`, `plot`) honouring off, frozen, non-plottable and viewport-frozen layers", "Include and exclude layer lists", "Linetypes, transparency and hatches". + +- [ ] **Step 2: CLI reference (line 126 onwards)** + +Replace the option table/list so it matches `WriteHelp` exactly, including `svg` in `--format`, `--only-layer`, `--layer-visibility`, `--list-layers` and the five `--svg-*` flags. + +- [ ] **Step 3: Architecture tree (line 147)** + +Update to: + +``` +ACadSharp.Image/ +├── ImageExporter.cs # Main public API +├── ImageConfiguration.cs # Configuration (layers, colours, SVG options) +├── ImagePage.cs # Page representation +├── RenderedPage.cs # Abstract rendered output (Save to path/stream) +├── RenderedImagePage.cs # Raster output (ImageSharp) +├── RenderedSvgPage.cs # SVG output +└── Rendering/ + ├── IDrawingSurface.cs # Backend-neutral primitives + ├── RasterDrawingSurface.cs # ImageSharp backend + ├── Svg/SvgDrawingSurface.cs # SVG backend + ├── ImagePageRenderer.cs # Page-level rendering and viewports + ├── EntityRenderDispatcher.cs # Entity routing, layer filtering, hatches + ├── EntityVisibilityFilter.cs # Include/hide lists and layer state + ├── ImageStyleResolver.cs # Colour, width, dashes, opacity + ├── LineTypeDashResolver.cs # Linetype to dash array + ├── SplineRenderer.cs / SplineBezierConverter.cs + ├── TextRenderer.cs # Text to SurfaceText + └── ImageRenderContext.cs # Coordinate transforms +``` + +- [ ] **Step 4: Advanced usage (line 172 onwards)** + +Replace "Layer Filtering" with three subsections and add an SVG one: + +````markdown +### Layer selection + +```csharp +var exporter = new ImageExporter(); +exporter.Configuration.IncludeLayers(["A-WALL", "A-DOOR"]); // render only these (optional) +exporter.Configuration.HideLayer("DEFPOINTS"); // then remove these +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. + +### Layer visibility + +```csharp +exporter.Configuration.LayerVisibility = LayerVisibilityMode.Plot; // All (default), Screen, Plot +``` + +`Screen` hides off and frozen layers, invisible entities and layers frozen per viewport. `Plot` also hides non-plottable layers such as `DEFPOINTS`. + +### Linetypes, transparency and colour 7 + +Dashed linetypes are rendered using `LTSCALE`, the entity linetype scale and `PSLTSCALE` in paper space; patterns shorter than `MinimumDashPixels` are drawn solid. Entity transparency becomes opacity (ByLayer is treated as opaque because the ACadSharp layer table carries no transparency). Colour index 7 resolves to black or white from the background luminance, or to `ForegroundColor` when set. + +### SVG output + +```csharp +exporter.Configuration.Svg.NonScalingStroke = true; // constant on-screen stroke width when zooming (default) +exporter.Configuration.Svg.IdPrefix = "plan1-"; // when inlining several drawings in one page +exporter.Save("plan.svg", ImageExportFormat.Svg); +``` + +The SVG has a drawing-unit `viewBox`, no `width`/`height` unless `Svg.EmitSize` is set, an attribute-free `` for your pan/zoom transform, and one `` per layer. Every element carries `data-handle` and `data-type` (plus `data-parent`/`data-block` for block contents). In React, prefer injecting the markup at runtime or configure SVGO to keep ids; `data-*` attributes survive the default SVGR pipeline. Toggle a layer with CSS `display: none` on its group. +```` + +- [ ] **Step 5: Migration notes (line 274)** + +Add a new list under a heading `### 2.0` (or the next major version): + +- `ImageExporter.Render()` now takes an optional `ImageExportFormat` and returns `IReadOnlyList`; cast to `RenderedImagePage` for the canvas or `RenderedSvgPage` for the markup, or call `Save`. +- `RenderedImagePage` derives from `RenderedPage` and knows its format and quality. +- `ImagePage.Entities` keeps every added entity; hidden layers are applied at render time, so changing `HiddenLayers` after `Add` takes effect. +- net6.0 is no longer targeted; ACadSharp 3.7.1 is required. +- Release with a major version tag (`v2.0.0`). + +- [ ] **Step 6: Run examples section (line 239)** + +Add: `dotnet run --project ./ACadSharp.Image.Cli/ACadSharp.Image.Cli.csproj -- "./Samples/6-57-1119.dxf" --format svg --layer-visibility plot`. + +- [ ] **Step 7: Final full verification and commit** + +```bash +dotnet build ACadSharp.Image.sln -c Release --nologo -v q 2>&1 | grep -E "warn|error" ; dotnet test ACadSharp.Image.sln -c Release --nologo -v q +git add README.md +git commit -m "Document layer visibility, selection and SVG output + +Co-Authored-By: Claude Fable 5.1 +Claude-Session: https://claude.ai/code/session_016RGgSinQSUz4d89FRLxhMz" +``` + +Expected: 0 warnings introduced by this work (XML doc warnings count), all tests green. + +--- + +## Self-review checklist + +- Spec 4.1 → Tasks 1, 2. 4.2 (include list, render-loop filtering, layer-0 rule) → Tasks 1, 2. 4.3 (ACI 7, transparency, linetype, deviations) → Tasks 3, 4, 5. 4.4 (LTSCALE, PSLTSCALE, dots, shapes, minimum dash) → Task 5. 4.5 (hatch, cap) → Task 6. Section 7 (CLI layer flags, `--list-layers`) → Task 7. README and migration → Task 8. +- Names: `EntityVisibilityFilter.IsVisible(Entity, Layer?, string, Viewport?)`, `EntityRenderDispatcher.GetEffectiveLayer(Entity, Layer?)`, `ImageStyleResolver.Resolve(Entity, ImageRenderContext, float)`, `ImageStyleResolver.ResolveOpacity`, `LineTypeDashResolver.Resolve/BuildPattern/EnforcesMinimumDash`, `ImageConfiguration.ResolveForegroundColor`, `ColorExtensions.ToImageColor(CadColor, ImageColor)`, `ImageRenderContext.CreateViewportContext(..., double scale, double lineTypeScale)`, `RecordingDrawingSurface.Styles`, `ImagePageRenderer.RenderTo(IDrawingSurface, ImagePage)`. +- Parity: default settings keep raster output; any sample-driven baseline change is inspected and called out in the commit. diff --git a/docs/superpowers/plans/2026-09-03-04-test-hardening.md b/docs/superpowers/plans/2026-09-03-04-test-hardening.md new file mode 100644 index 0000000..b826a84 --- /dev/null +++ b/docs/superpowers/plans/2026-09-03-04-test-hardening.md @@ -0,0 +1,1257 @@ +# Test Hardening Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Close the coverage gaps the 2026-09-03 Codex review and coverlet run exposed: paper-space viewports end to end, the CLI, feature goldens that contain hatches/ellipses/opacity/inserts/text, generic spline sampling, text placement, and the small public API surface. + +**Architecture:** Tests only, plus two testability changes in the CLI (internal entry points, a `TextWriter` parameter) and one addition to the recording test double (`Texts`). Synthetic drawings are built in code (`SyntheticSamples`), not committed as files; their PNG baselines and SVG goldens live next to the sample baselines and use the same `ACADSHARP_IMAGE_UPDATE_BASELINES=1` switch through a shared `GoldenAssert` helper. Nothing in the library's rendering code changes; if a test exposes a defect, stop and record it in the ledger for a ruling rather than fixing it inside the test task. + +**Tech Stack:** .NET 10 test project (xUnit, coverlet.collector), ACadSharp 3.7.1 (`DxfWriter`/`DxfReader` for the in-memory round trip), SixLabors.ImageSharp for pixel assertions, System.Xml.Linq for SVG assertions. + +**Spec:** `docs/superpowers/specs/2026-09-02-layers-and-svg-design.md` (binding). This plan adds tests for requirements already implemented; it changes no behaviour described there. + +## Global Constraints + +- ACadSharp `3.7.1`; SixLabors.ImageSharp `3.1.12`; no new NuGet dependencies (spec section 3). +- Target frameworks unchanged: library `net8.0;net10.0`, CLI and tests `net10.0`. +- Coding conventions: `this.` prefix on instance members, explicit types except LINQ lambdas, XML docs on public members, `sealed` classes, file-scoped namespaces, `internal` for rendering internals with `InternalsVisibleTo` for the test project (spec section 3). New files are UTF-8 without BOM, LF line endings, four-space indent (the repository's de facto convention; `.editorconfig` is not followed by the existing files). +- Existing PNG baselines and SVG goldens under `ACadSharp.Image.Tests/Baselines` must not change. New baselines are created once with `ACADSHARP_IMAGE_UPDATE_BASELINES=1` and committed; the commit message states what they cover. +- Parity and golden tests require the font `DejaVu Sans` (`FontFamilyName = "DejaVu Sans"`), which CI installs via `fonts-dejavu-core`. +- Build must stay warning-free: `dotnet build ACadSharp.Image.sln -warnaserror`. +- No library rendering code is modified by this plan. A test that fails against the current renderer is a finding, not a reason to loosen the test or patch the renderer inside the task. +- Never use bare `git stash` / `git stash pop`. Commit messages end with the two trailer lines the repository uses (see any commit on this branch). + +## File Structure + +- Create `ACadSharp.Image.Tests/GoldenAssert.cs`: shared PNG-baseline and SVG-golden comparison with the update switch (extracted pattern from `SampleParityTests`, which stays as is). +- Create `ACadSharp.Image.Tests/SyntheticSamples.cs`: builders for the in-memory drawings (`ViewportSheet()`, `FeatureBlock()`). +- Create `ACadSharp.Image.Tests/CliTests.cs`, `ViewportParityTests.cs`, `FeatureGoldenTests.cs`, `SplineRendererTests.cs`, `TextRendererTests.cs`, `RenderedPageTests.cs`. +- Modify `ACadSharp.Image.Cli/Program.cs` (visibility of four methods, `TextWriter` parameter), `ACadSharp.Image.Cli/ACadSharp.Image.Cli.csproj` (`InternalsVisibleTo`), `ACadSharp.Image.Tests/ACadSharp.Image.Tests.csproj` (project reference), `ACadSharp.Image.Tests/RecordingDrawingSurface.cs` (`Texts`), `ACadSharp.Image.Tests/ImageConfigurationTests.cs`. +- New baselines: `ACadSharp.Image.Tests/Baselines/viewport-sheet.paper.01.png`, `viewport-sheet.paper.01.svg`, `features.model.01.png`, `features.model.01.svg`. + +--- + +### Task 1: CLI parser, format resolution and layer table tests + +**Files:** +- Modify: `ACadSharp.Image.Cli/ACadSharp.Image.Cli.csproj` +- Modify: `ACadSharp.Image.Cli/Program.cs` (methods `WriteLayerTable`, `ResolveFormat`, `ResolveOutputPath`, `ParseArgs`, and the `Main` call site of `WriteLayerTable`) +- Modify: `ACadSharp.Image.Tests/ACadSharp.Image.Tests.csproj` +- Create: `ACadSharp.Image.Tests/CliTests.cs` + +**Interfaces:** +- Consumes: `CliOptions` record (`ACadSharp.Image.Cli/CliOptions.cs`), `ImageExportFormatExtensions`. +- Produces: `internal static CliOptions Program.ParseArgs(IReadOnlyList args)`, `internal static ImageExportFormat Program.ResolveFormat(CliOptions options)`, `internal static string Program.ResolveOutputPath(CliOptions options, string inputPath, ImageExportFormat format)`, `internal static void Program.WriteLayerTable(CadDocument document, TextWriter writer)`. + +- [ ] **Step 1: Expose the CLI internals to the test project** + +In `ACadSharp.Image.Cli/ACadSharp.Image.Cli.csproj` add, inside the `` element after the existing ``: + +```xml + + + +``` + +In `ACadSharp.Image.Tests/ACadSharp.Image.Tests.csproj` change the project reference group to: + +```xml + + + + +``` + +- [ ] **Step 2: Make the four entry points internal and give the layer table a writer** + +In `ACadSharp.Image.Cli/Program.cs`: + +- `private static void WriteLayerTable(CadDocument document)` becomes `internal static void WriteLayerTable(CadDocument document, TextWriter writer)`. Inside it, replace every `Console.WriteLine(` with `writer.WriteLine(` (three occurrences: the "No layers." line, the header line, the per-layer line). +- In `Main`, change `WriteLayerTable(document);` to `WriteLayerTable(document, Console.Out);`. +- `private static ImageExportFormat ResolveFormat(CliOptions options)` becomes `internal static ...`. +- `private static string ResolveOutputPath(CliOptions options, string inputPath, ImageExportFormat format)` becomes `internal static ...`. +- `private static CliOptions ParseArgs(IReadOnlyList args)` becomes `internal static ...`. + +Add `/// ` XML docs to the four now-internal methods in one line each, e.g. `/// Parses command-line arguments into ; throws for unknown or invalid arguments.`. + +Run: `dotnet build ACadSharp.Image.sln -warnaserror --nologo -v q` +Expected: 0 warnings, 0 errors. + +- [ ] **Step 3: Write the failing CLI tests** + +Create `ACadSharp.Image.Tests/CliTests.cs`: + +```csharp +using ACadSharp.Entities; +using ACadSharp.Image.Cli; +using ACadSharp.Tables; +using CSMath; + +namespace ACadSharp.Image.Tests; + +public sealed class CliTests +{ + [Fact] + public void ParseArgsAppliesDefaults() + { + CliOptions options = Program.ParseArgs(["plan.dxf"]); + + Assert.Equal("plan.dxf", options.InputPath); + Assert.Null(options.OutputPath); + Assert.Null(options.Format); + Assert.Equal(ImageConfiguration.DefaultWidth, options.Width); + Assert.Equal(ImageConfiguration.DefaultHeight, options.Height); + Assert.Equal((0, 0, 0, 0), (options.PaddingLeft, options.PaddingTop, options.PaddingRight, options.PaddingBottom)); + Assert.Equal("white", options.BackgroundColor); + Assert.Equal(90, options.Quality); + Assert.False(options.ExportPaperLayouts); + Assert.Empty(options.HideLayers); + Assert.Empty(options.OnlyLayers); + Assert.False(options.SvgNoScalingStroke); + Assert.False(options.SvgNoEntityAttributes); + Assert.False(options.SvgEmitSize); + Assert.Equal(string.Empty, options.SvgIdPrefix); + Assert.Null(options.SvgPrecision); + Assert.Null(options.LayerVisibility); + Assert.False(options.ListLayers); + } + + [Fact] + public void ParseArgsReadsEveryOption() + { + CliOptions options = Program.ParseArgs([ + "plan.dwg", "-o", "out/plan.svg", "-f", "svg", "-w", "640", "-H", "480", "-p", "1,2,3,4", "-b", "#202020", "-q", "75", + "--paper-layouts", "--hide-layer", "A-DOOR", "--hide-layer", "A-GLAZ", "--only-layer", "A-WALL", "--only-layer", "A-DOOR", + "--layer-visibility", "Plot", "--list-layers", "--svg-no-scaling-stroke", "--svg-no-entity-attributes", "--svg-size", + "--svg-id-prefix", "p1-", "--svg-precision", "3", + ]); + + Assert.Equal("plan.dwg", options.InputPath); + Assert.Equal("out/plan.svg", options.OutputPath); + Assert.Equal("svg", options.Format); + Assert.Equal(640, options.Width); + Assert.Equal(480, options.Height); + Assert.Equal((1, 2, 3, 4), (options.PaddingLeft, options.PaddingTop, options.PaddingRight, options.PaddingBottom)); + Assert.Equal("#202020", options.BackgroundColor); + Assert.Equal(75, options.Quality); + Assert.True(options.ExportPaperLayouts); + Assert.Equal(["A-DOOR", "A-GLAZ"], options.HideLayers); + Assert.Equal(["A-WALL", "A-DOOR"], options.OnlyLayers); + Assert.Equal(LayerVisibilityMode.Plot, options.LayerVisibility); + Assert.True(options.ListLayers); + Assert.True(options.SvgNoScalingStroke); + Assert.True(options.SvgNoEntityAttributes); + Assert.True(options.SvgEmitSize); + Assert.Equal("p1-", options.SvgIdPrefix); + Assert.Equal(3, options.SvgPrecision); + } + + [Theory] + [InlineData("8", 8, 8, 8, 8)] + [InlineData("4,6", 4, 6, 4, 6)] + [InlineData("1,2,3,4", 1, 2, 3, 4)] + public void ParseArgsAcceptsThePaddingForms(string value, int left, int top, int right, int bottom) + { + CliOptions options = Program.ParseArgs(["a.dxf", "--padding", value]); + + Assert.Equal((left, top, right, bottom), (options.PaddingLeft, options.PaddingTop, options.PaddingRight, options.PaddingBottom)); + } + + [Theory] + [InlineData("--bogus")] + [InlineData("--width", "0")] + [InlineData("--width", "abc")] + [InlineData("--quality", "101")] + [InlineData("--padding", "1,2,3")] + [InlineData("--padding", "-1")] + [InlineData("--svg-precision", "9")] + [InlineData("--layer-visibility", "hidden")] + [InlineData("--layer-visibility", "1")] + [InlineData("--output")] + public void ParseArgsRejectsInvalidArguments(params string[] tail) + { + List args = ["a.dxf", .. tail]; + + Assert.Throws(() => Program.ParseArgs(args)); + } + + [Fact] + public void ParseArgsRequiresAnInputFile() + { + InvalidOperationException ex = Assert.Throws(() => Program.ParseArgs(["--width", "10"])); + + Assert.Contains("input", ex.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void LayerVisibilityIsCaseInsensitiveButNotNumeric() + { + Assert.Equal(LayerVisibilityMode.Screen, Program.ParseArgs(["a.dxf", "--layer-visibility", "SCREEN"]).LayerVisibility); + Assert.Equal(LayerVisibilityMode.All, Program.ParseArgs(["a.dxf", "--layer-visibility", " all "]).LayerVisibility); + Assert.Throws(() => Program.ParseArgs(["a.dxf", "--layer-visibility", "2"])); + } + + [Fact] + public void ResolveFormatPrefersExplicitThenExtensionThenPng() + { + Assert.Equal(ImageExportFormat.Svg, Program.ResolveFormat(Program.ParseArgs(["a.dxf", "-f", "svg", "-o", "x.png"]))); + Assert.Equal(ImageExportFormat.Jpeg, Program.ResolveFormat(Program.ParseArgs(["a.dxf", "-o", "x.jpg"]))); + Assert.Equal(ImageExportFormat.Svg, Program.ResolveFormat(Program.ParseArgs(["a.dxf", "-o", "x.SVG"]))); + Assert.Equal(ImageExportFormat.Png, Program.ResolveFormat(Program.ParseArgs(["a.dxf"]))); + Assert.Equal(ImageExportFormat.Png, Program.ResolveFormat(Program.ParseArgs(["a.dxf", "-o", "outdir"]))); + InvalidOperationException ex = Assert.Throws(() => Program.ResolveFormat(Program.ParseArgs(["a.dxf", "-f", "tiff"]))); + Assert.Contains("tiff", ex.Message, StringComparison.Ordinal); + } + + [Fact] + public void ResolveOutputPathUsesTheFormatExtensionWhenNoOutputIsGiven() + { + string input = Path.Combine(Path.GetTempPath(), "drawing.dxf"); + + Assert.Equal(Path.ChangeExtension(input, ".svg"), Program.ResolveOutputPath(Program.ParseArgs([input]), input, ImageExportFormat.Svg)); + Assert.Equal(Path.GetFullPath("out.png"), Program.ResolveOutputPath(Program.ParseArgs([input, "-o", "out.png"]), input, ImageExportFormat.Png)); + } + + [Fact] + public void WriteLayerTableAlignsColumnsAndCountsModelSpaceEntities() + { + CadDocument document = new(); + Layer walls = new("A-WALL-INTERIOR") { Color = new ACadSharp.Color(1), LineWeight = LineWeightType.W50 }; + Layer notes = new("N") { Color = ACadSharp.Color.FromTrueColor(0x10, 0x20, 0x30), IsOn = false, PlotFlag = false }; + document.Layers.Add(walls); + document.Layers.Add(notes); + document.Entities.Add(new Line(new XYZ(0, 0, 0), new XYZ(1, 0, 0)) { Layer = walls }); + document.Entities.Add(new Line(new XYZ(0, 0, 0), new XYZ(0, 1, 0)) { Layer = walls }); + + StringWriter writer = new(); + Program.WriteLayerTable(document, writer); + string[] lines = writer.ToString().Split('\n', StringSplitOptions.RemoveEmptyEntries).Select(l => l.TrimEnd('\r')).ToArray(); + + // Header, then "0", "A-WALL-INTERIOR" and "N" sorted case-insensitively. + Assert.Equal(4, lines.Length); + Assert.StartsWith("Layer On Frozen Plot Color Weight Linetype Entities", lines[0]); + Assert.StartsWith("0 yes no yes 7", lines[1]); + Assert.StartsWith("A-WALL-INTERIOR yes no yes 1 W50", lines[2]); + Assert.EndsWith(" 2", lines[2]); + Assert.StartsWith("N no no no #102030", lines[3]); + Assert.EndsWith(" 0", lines[3]); + // The weight column is as wide as its widest value ("Default" is 7 characters), so every row has the same length. + Assert.Single(lines.Select(l => l.Length).Distinct()); + } + + [Fact] + public void WriteLayerTableReportsAnEmptyLayerTable() + { + CadDocument document = new(); + document.Layers.Remove(Layer.DefaultName); + StringWriter writer = new(); + + Program.WriteLayerTable(document, writer); + + Assert.Equal("No layers.", writer.ToString().Trim()); + } + + [Fact] + public void MainReturnsOneForAMissingInputFile() + { + string missing = Path.Combine(Path.GetTempPath(), $"missing-{Guid.NewGuid():N}.dxf"); + + Assert.Equal(1, Program.Main([missing])); + } + + [Fact] + public void MainReturnsZeroForHelp() + { + Assert.Equal(0, Program.Main([])); + Assert.Equal(0, Program.Main(["--help"])); + } +} +``` + +Notes for the implementer: `Layer.DefaultName` is `"0"`. If `document.Layers.Remove(Layer.DefaultName)` returns false or throws in ACadSharp 3.7.1 (layer 0 may be protected), replace the `WriteLayerTableReportsAnEmptyLayerTable` body with a document whose only layer is `0` and assert the row count is 2 instead; record the substitution in the report. The header string in `WriteLayerTableAlignsColumnsAndCountsModelSpaceEntities` follows the code exactly: name column padded to the longest name (15), `Weight` padded to the longest weight text (`Default` = 7), `Linetype` padded to 8. Run the test first, and if the expected header differs only in spacing, fix the test string to match the actual output and say so in the report; if it differs in content (missing column, wrong count), that is a finding. + +- [ ] **Step 4: Run the tests to verify they fail before the CLI changes are wired** + +Run: `dotnet test ACadSharp.Image.Tests --nologo --filter "FullyQualifiedName~CliTests"` +Expected: compilation succeeds only if Steps 1 and 2 are done; with them done, all tests should PASS except any whose expected string does not match (see notes). If a test fails for a reason other than spacing, stop and report. + +- [ ] **Step 5: Run the whole suite and commit** + +Run: `dotnet build ACadSharp.Image.sln -warnaserror --nologo -v q && dotnet test ACadSharp.Image.sln --nologo -v q` +Expected: 0 warnings; all tests pass (167 + the new CLI tests). + +```bash +git add ACadSharp.Image.Cli ACadSharp.Image.Tests/ACadSharp.Image.Tests.csproj ACadSharp.Image.Tests/CliTests.cs +git commit -m "Test the CLI parser, format resolution and layer table" +``` + +--- + +### Task 2: Paper-space viewport fixture with PNG baseline and SVG golden + +**Files:** +- Create: `ACadSharp.Image.Tests/GoldenAssert.cs` +- Create: `ACadSharp.Image.Tests/SyntheticSamples.cs` +- Create: `ACadSharp.Image.Tests/ViewportParityTests.cs` +- Create (generated): `ACadSharp.Image.Tests/Baselines/viewport-sheet.paper.01.png`, `ACadSharp.Image.Tests/Baselines/viewport-sheet.paper.01.svg` + +**Interfaces:** +- Consumes: `SampleParityTests.AssertPixelsEqual(Image, Image, string)` and `SampleParityTests.FindRepoRoot()` (both `internal static`), `ImageExporter.Add(Layout)`, `SvgDrawingSurface.Ns`. +- Produces: `internal static class GoldenAssert { static void Png(string baseName, Image actual); static void Svg(string baseName, string actual); static bool Updating { get; } }` and `internal static class SyntheticSamples { static CadDocument ViewportSheet(); }`. Task 3 adds `FeatureBlock()` to `SyntheticSamples`. + +- [ ] **Step 1: Write the golden helper** + +Create `ACadSharp.Image.Tests/GoldenAssert.cs`: + +```csharp +using SixLabors.ImageSharp; +using SixLabors.ImageSharp.Formats.Png; +using SixLabors.ImageSharp.PixelFormats; + +namespace ACadSharp.Image.Tests; + +/// +/// Compares rendered output with the files under Baselines/: PNGs pixel-for-pixel and SVGs as text. With the +/// environment variable ACADSHARP_IMAGE_UPDATE_BASELINES=1 the files are rewritten instead of compared. +/// +internal static class GoldenAssert +{ + public static bool Updating => Environment.GetEnvironmentVariable("ACADSHARP_IMAGE_UPDATE_BASELINES") == "1"; + + private static string BaselineDirectory + { + get + { + string directory = Path.Combine(SampleParityTests.FindRepoRoot(), "ACadSharp.Image.Tests", "Baselines"); + Directory.CreateDirectory(directory); + return directory; + } + } + + public static void Png(string baseName, Image actual) + { + string path = Path.Combine(BaselineDirectory, baseName + ".png"); + if (Updating) + { + actual.Save(path, new PngEncoder()); + return; + } + + Assert.True(File.Exists(path), $"Missing baseline {path}. Run with ACADSHARP_IMAGE_UPDATE_BASELINES=1 to create it."); + using Image expected = SixLabors.ImageSharp.Image.Load(path); + SampleParityTests.AssertPixelsEqual(expected, actual, path); + } + + public static void Svg(string baseName, string actual) + { + string path = Path.Combine(BaselineDirectory, baseName + ".svg"); + string normalized = actual.Replace("\r\n", "\n"); + Assert.DoesNotContain("Infinity", normalized, StringComparison.Ordinal); + Assert.DoesNotContain("NaN", normalized, StringComparison.Ordinal); + if (Updating) + { + File.WriteAllText(path, normalized); + return; + } + + 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); + } +} +``` + +- [ ] **Step 2: Write the synthetic viewport sheet** + +Create `ACadSharp.Image.Tests/SyntheticSamples.cs`: + +```csharp +using ACadSharp.Entities; +using ACadSharp.IO; +using ACadSharp.Objects; +using ACadSharp.Tables; +using CSMath; + +namespace ACadSharp.Image.Tests; + +/// +/// Drawings built in code for the golden tests, so the goldens cover the feature list rather than whatever the sample +/// files happen to contain. is round-tripped through the DXF writer and reader so the +/// document arrives the way a file would (owners, handles and table references wired by the reader). +/// +internal static class SyntheticSamples +{ + /// + /// Model space with lines on layers Walls, Hidden and Grid (dashed) and a circle, plus a layout "Sheet" + /// (297 x 210) holding a viewport at scale 2 that freezes layer Hidden, a frame line and a title. + /// + public static CadDocument ViewportSheet() + { + CadDocument document = new(); + document.Header.LineTypeScale = 1d; + + LineType dashed = new("DASHED"); + dashed.AddSegment(new LineType.Segment { Length = 5 }); + dashed.AddSegment(new LineType.Segment { Length = -2.5 }); + document.LineTypes.Add(dashed); + + Layer walls = new("Walls") { Color = new Color(1) }; + Layer hidden = new("Hidden") { Color = new Color(5) }; + Layer grid = new("Grid") { Color = new Color(3), LineType = dashed }; + document.Layers.Add(walls); + document.Layers.Add(hidden); + document.Layers.Add(grid); + + document.Entities.Add(new Line(new XYZ(0, 0, 0), new XYZ(100, 0, 0)) { Layer = walls }); + document.Entities.Add(new Line(new XYZ(0, 0, 0), new XYZ(0, 60, 0)) { Layer = walls }); + document.Entities.Add(new Line(new XYZ(0, 60, 0), new XYZ(100, 60, 0)) { Layer = hidden }); + 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" would render as a second, empty page. + document.Layouts.Remove("Layout1"); + Layout sheet = new("Sheet") { PaperWidth = 297, PaperHeight = 210 }; + document.Layouts.Add(sheet); + + Viewport viewport = new() + { + Center = new XYZ(148.5, 105, 0), + Width = 200, + Height = 120, + ViewCenter = new XY(50, 30), + ViewHeight = 60, + }; + viewport.FrozenLayers.Add(hidden); + sheet.AssociatedBlock.Entities.Add(viewport); + sheet.AssociatedBlock.Entities.Add(new Line(new XYZ(10, 10, 0), new XYZ(287, 10, 0)) { Layer = walls }); + sheet.AssociatedBlock.Entities.Add(new TextEntity { Value = "SHEET 1", InsertPoint = new XYZ(10, 190, 0), Height = 8, Layer = walls }); + + using MemoryStream stream = new(); + using (DxfWriter writer = new(stream, document, binary: false)) + { + writer.Write(); + } + + stream.Position = 0; + return DxfReader.Read(stream, null); + } +} +``` + +If `DxfWriter` disposing closes the stream before it can be read, write to a temporary file instead (`Path.Combine(Path.GetTempPath(), $"viewport-{Guid.NewGuid():N}.dxf")`, `new DxfWriter(path, document, false)`, then `DxfReader.Read(path)` and `File.Delete(path)`); both constructor and reader overloads exist in ACadSharp 3.7.1. Record which one was used. + +- [ ] **Step 3: Write the failing viewport tests** + +Create `ACadSharp.Image.Tests/ViewportParityTests.cs`: + +```csharp +using System.Xml.Linq; +using ACadSharp.Image.Rendering.Svg; +using ACadSharp.Objects; +using SixLabors.Fonts; +using SixLabors.ImageSharp.PixelFormats; + +namespace ACadSharp.Image.Tests; + +public sealed class ViewportParityTests +{ + private const string FontFamily = "DejaVu Sans"; + private static readonly XNamespace Ns = SvgDrawingSurface.Ns; + + private static ImageExporter SheetExporter(out CadDocument document) + { + document = SyntheticSamples.ViewportSheet(); + ImageExporter exporter = new(); + exporter.Configuration.Width = 800; + exporter.Configuration.Height = 500; + exporter.Configuration.SetPadding(10); + exporter.Configuration.FontFamilyName = FontFamily; + Layout sheet = document.Layouts.First(l => l.Name == "Sheet"); + exporter.Add(sheet); + return exporter; + } + + [Fact] + public void SheetRoundTripKeepsTheViewport() + { + ImageExporter exporter = SheetExporter(out CadDocument document); + ImagePage page = Assert.Single(exporter.Pages); + + Assert.Equal(2, page.Entities.Count); // frame line and title; the viewport itself is not an entity of the page + Assert.Single(page.Viewports); + Assert.Equal(2d, page.Viewports[0].ScaleFactor, 9); + Assert.Contains(page.Viewports[0].FrozenLayers, l => l.Name == "Hidden"); + Assert.Equal(1, (int)document.Header.PaperSpaceLineTypeScaling); // PSLTSCALE default: dashes at page scale + } + + [Fact] + public void SheetPngMatchesBaseline() + { + Assert.True(SystemFonts.TryGet(FontFamily, out _), $"Font '{FontFamily}' must be installed for parity tests."); + ImageExporter exporter = SheetExporter(out _); + + using RenderedImagePage page = Assert.IsType(Assert.Single(exporter.Render())); + + GoldenAssert.Png("viewport-sheet.paper.01", page.Canvas); + + // PIXEL PROBES: replace these two lines with concrete coordinates after inspecting the baseline (see notes below). + Rgba32 white = new(255, 255, 255, 255); + Assert.NotEqual(white, page.Canvas[page.Canvas.Width / 2, page.Canvas.Height / 2]); // placeholder: something is drawn near the centre + } + + [Fact] + public void SheetSvgMatchesGoldenAndClipsTheViewport() + { + ImageExporter exporter = SheetExporter(out _); + + RenderedSvgPage page = Assert.IsType(Assert.Single(exporter.Render(ImageExportFormat.Svg))); + GoldenAssert.Svg("viewport-sheet.paper.01", page.Content); + + XDocument document = XDocument.Parse(page.Content); + XElement clip = Assert.Single(document.Descendants(Ns + "clipPath")); + Assert.Equal("clip-1", (string?)clip.Attribute("id")); + XElement viewportGroup = Assert.Single(document.Descendants(Ns + "g"), g => (string?)g.Attribute("class") == "cad-viewport"); + Assert.Equal("url(#clip-1)", (string?)viewportGroup.Attribute("clip-path")); + + // Layer groups inside the viewport carry the clip-scoped ids; the frozen layer is absent altogether. + List layerIds = viewportGroup.Descendants(Ns + "g").Where(g => (string?)g.Attribute("class") == "cad-layer").Select(g => (string)g.Attribute("id")!).ToList(); + Assert.Contains("clip-1-layer-walls", layerIds); + Assert.Contains("clip-1-layer-grid", layerIds); + Assert.DoesNotContain(layerIds, id => id.Contains("hidden", StringComparison.Ordinal)); + Assert.DoesNotContain(document.Descendants(), e => (string?)e.Attribute("data-layer") == "Hidden"); + + // The grid line is dashed, at page scale (PSLTSCALE 1): 5 and 2.5 drawing units times the fit scale, in pixels. + XElement gridLine = Assert.Single(viewportGroup.Descendants(Ns + "line"), l => (string?)l.Parent!.Attribute("data-layer") == "Grid"); + string[] dashes = ((string)gridLine.Attribute("stroke-dasharray")!).Split(' '); + Assert.Equal(2, dashes.Length); + Assert.Equal(2d, double.Parse(dashes[0], System.Globalization.CultureInfo.InvariantCulture) / double.Parse(dashes[1], System.Globalization.CultureInfo.InvariantCulture), 3); + + // The circle keeps its native form inside the viewport, and the page-level frame line and title sit outside it. + Assert.Single(viewportGroup.Descendants(Ns + "circle")); + Assert.Single(document.Descendants(Ns + "text")); + Assert.DoesNotContain(viewportGroup.Descendants(Ns + "text"), _ => true); + } +} +``` + +Notes for the implementer: the pixel probe in `SheetPngMatchesBaseline` is a placeholder. Before committing, open the generated baseline PNG (`Read` tool on the file) and replace the placeholder with two concrete pixel checks: one background pixel just inside the viewport's top edge (where the frozen "Hidden" line would have been drawn, i.e. a few pixels below the top of the red rectangle's side walls) and one non-background pixel on the red bottom wall line. Geometry to compute them: fit = min(780/297, 480/210) = 2.2857 px/unit; the sheet is 678.9 x 480 px, starting at x = 10 + (780 - 678.9)/2 = 60.5 and y = 10; paper (px, py) maps to canvas (60.5 + px * 2.2857, 10 + (210 - py) * 2.2857); model (mx, my) maps to paper (148.5 + (mx - 50) * 2, 105 + (my - 30) * 2). So model (50, 0) (bottom wall) is paper (148.5, 45) = canvas (400, 387); model (50, 58) (just below the frozen top wall) is paper (148.5, 161) = canvas (400, 122). Verify against the image before using them and state the coordinates in the report. + +- [ ] **Step 4: Run the tests and create the baselines** + +Run: `dotnet test ACadSharp.Image.Tests --nologo --filter "FullyQualifiedName~ViewportParityTests"` +Expected: `SheetRoundTripKeepsTheViewport` PASS; the two golden tests FAIL with "Missing baseline/golden". + +Run: `ACADSHARP_IMAGE_UPDATE_BASELINES=1 dotnet test ACadSharp.Image.Tests --nologo --filter "FullyQualifiedName~ViewportParityTests"` +Expected: PASS, and two new files under `ACadSharp.Image.Tests/Baselines/`. + +Inspect `viewport-sheet.paper.01.svg` (it is text) and confirm: a ``, a `cad-viewport` group, layer groups `clip-1-layer-walls` and `clip-1-layer-grid`, no `Hidden`, one `stroke-dasharray`, one ``, one ``. Inspect the PNG visually with the Read tool: a red rectangle outline missing its top edge, a green dashed line across the middle, a red circle, a frame line near the bottom and a title near the top. If anything is missing, do not commit the baseline: report it as a finding. + +Run: `dotnet test ACadSharp.Image.Tests --nologo --filter "FullyQualifiedName~ViewportParityTests"` (without the variable) +Expected: PASS. + +- [ ] **Step 5: Run the whole suite and commit** + +Run: `dotnet build ACadSharp.Image.sln -warnaserror --nologo -v q && dotnet test ACadSharp.Image.sln --nologo -v q` +Expected: 0 warnings; all pass; `git status` shows only the new files. + +```bash +git add ACadSharp.Image.Tests/GoldenAssert.cs ACadSharp.Image.Tests/SyntheticSamples.cs ACadSharp.Image.Tests/ViewportParityTests.cs ACadSharp.Image.Tests/Baselines/viewport-sheet.paper.01.png ACadSharp.Image.Tests/Baselines/viewport-sheet.paper.01.svg +git commit -m "Add a paper-space viewport fixture with PNG baseline and SVG golden" +``` + +--- + +### Task 3: Feature goldens for hatch, ellipse, opacity, insert, bulge and text + +**Files:** +- Modify: `ACadSharp.Image.Tests/SyntheticSamples.cs` (add `FeatureBlock()`) +- Create: `ACadSharp.Image.Tests/FeatureGoldenTests.cs` +- Create (generated): `ACadSharp.Image.Tests/Baselines/features.model.01.png`, `ACadSharp.Image.Tests/Baselines/features.model.01.svg` + +**Interfaces:** +- Consumes: `GoldenAssert.Png/Svg` (Task 2), `SvgDrawingSurface.Ns`. +- Produces: `SyntheticSamples.FeatureBlock()` returning a `BlockRecord`. + +- [ ] **Step 1: Add the feature block builder** + +Append to `SyntheticSamples` (inside the class, after `ViewportSheet`): + +```csharp + /// + /// One block exercising every primitive the goldens from the sample files do not contain: a solid and a pattern + /// hatch, a full ellipse and an elliptical arc, a translucent line, an insert with layer-0 and ByBlock contents, + /// a bulged closed polyline, a two-line MText and a Fit-aligned text. + /// + public static BlockRecord FeatureBlock() + { + BlockRecord block = new("features"); + Layer hatchLayer = new("Hatch") { Color = new Color(1) }; + Layer curves = new("Curves") { Color = new Color(4) }; + Layer trans = new("Trans") { Color = new Color(6) }; + Layer doors = new("Doors") { Color = new Color(3) }; + Layer anno = new("Anno") { Color = new Color(7) }; + + Hatch solid = new() { IsSolid = true, PatternType = HatchPatternType.SolidFill, Pattern = HatchPattern.Solid, Layer = hatchLayer }; + solid.Paths.Add(SquarePath(0, 0, 20)); + block.Entities.Add(solid); + + Hatch pattern = new() { IsSolid = false, PatternType = HatchPatternType.PatternFill, Pattern = new HatchPattern("ANSI31"), Layer = hatchLayer }; + pattern.Pattern.Lines.Add(new HatchPattern.Line { Angle = Math.PI / 4, BasePoint = XY.Zero, Offset = new XY(0, 3.175) }); + pattern.PatternScale = 1; + pattern.Paths.Add(SquarePath(30, 0, 20)); + block.Entities.Add(pattern); + + block.Entities.Add(new Ellipse { Center = new XYZ(70, 10, 0), MajorAxisEndPoint = new XYZ(10, 0, 0), RadiusRatio = 0.5, Layer = curves }); + block.Entities.Add(new Ellipse { Center = new XYZ(100, 10, 0), MajorAxisEndPoint = new XYZ(10, 0, 0), RadiusRatio = 0.5, StartParameter = 0, EndParameter = Math.PI, Layer = curves }); + + block.Entities.Add(new Line(new XYZ(0, 30, 0), new XYZ(120, 30, 0)) { Layer = trans, Transparency = new Transparency(50), LineWeight = LineWeightType.W100 }); + + LwPolyline bulged = new() { IsClosed = true, Layer = curves }; + bulged.Vertices.Add(new LwPolyline.Vertex(new XY(0, 40)) { Bulge = 1 }); + bulged.Vertices.Add(new LwPolyline.Vertex(new XY(20, 40))); + bulged.Vertices.Add(new LwPolyline.Vertex(new XY(20, 55))); + block.Entities.Add(bulged); + + BlockRecord door = new("DOOR"); + door.Entities.Add(new Line(new XYZ(0, 0, 0), new XYZ(10, 0, 0)) { Layer = new Layer(Layer.DefaultName) }); + door.Entities.Add(new Line(new XYZ(0, 0, 0), new XYZ(0, 10, 0)) { Color = Color.ByBlock, LineWeight = LineWeightType.ByBlock }); + block.Entities.Add(new Insert(door) { InsertPoint = new XYZ(40, 40, 0), Layer = doors, Color = new Color(5), LineWeight = LineWeightType.W70 }); + + block.Entities.Add(new MText { Value = "Line1\\PLine2", InsertPoint = new XYZ(70, 48, 0), Height = 4, Layer = anno }); + block.Entities.Add(new TextEntity { Value = "FIT", InsertPoint = new XYZ(70, 55, 0), AlignmentPoint = new XYZ(110, 55, 0), HorizontalAlignment = TextHorizontalAlignment.Fit, Height = 4, Layer = anno }); + + return block; + } + + private static Hatch.BoundaryPath SquarePath(double x, double y, double size) + { + Hatch.BoundaryPath path = new(); + Hatch.BoundaryPath.Polyline polyline = new() { IsClosed = true }; + polyline.Vertices.AddRange([new XYZ(x, y, 0), new XYZ(x + size, y, 0), new XYZ(x + size, y + size, 0), new XYZ(x, y + size, 0)]); + path.Edges.Add(polyline); + return path; + } +``` + +- [ ] **Step 2: Write the failing feature golden tests** + +Create `ACadSharp.Image.Tests/FeatureGoldenTests.cs`: + +```csharp +using System.Xml.Linq; +using ACadSharp.Image.Rendering.Svg; +using SixLabors.Fonts; + +namespace ACadSharp.Image.Tests; + +public sealed class FeatureGoldenTests +{ + private const string FontFamily = "DejaVu Sans"; + private static readonly XNamespace Ns = SvgDrawingSurface.Ns; + + private static ImageExporter FeatureExporter() + { + ImageExporter exporter = new(); + exporter.Configuration.Width = 800; + exporter.Configuration.Height = 500; + exporter.Configuration.SetPadding(10); + exporter.Configuration.FontFamilyName = FontFamily; + exporter.Add(SyntheticSamples.FeatureBlock()); + return exporter; + } + + [Fact] + public void FeaturePngMatchesBaseline() + { + Assert.True(SystemFonts.TryGet(FontFamily, out _), $"Font '{FontFamily}' must be installed for parity tests."); + ImageExporter exporter = FeatureExporter(); + + using RenderedImagePage page = Assert.IsType(Assert.Single(exporter.Render())); + + GoldenAssert.Png("features.model.01", page.Canvas); + } + + [Fact] + public void FeatureSvgMatchesGoldenAndContainsEveryPrimitive() + { + ImageExporter exporter = FeatureExporter(); + List notifications = new(); + exporter.Configuration.OnNotification += (_, e) => notifications.Add(e); + + RenderedSvgPage page = Assert.IsType(Assert.Single(exporter.Render(ImageExportFormat.Svg))); + GoldenAssert.Svg("features.model.01", page.Content); + + Assert.DoesNotContain(notifications, n => n.NotificationType is NotificationType.Warning or NotificationType.NotImplemented); + XDocument document = XDocument.Parse(page.Content); + List paths = document.Descendants(Ns + "path").ToList(); + + // Solid hatch: even-odd filled path. Pattern hatch: several plain lines on layer Hatch. + Assert.Single(paths, p => (string?)p.Attribute("fill-rule") == "evenodd"); + XElement hatchGroup = Assert.Single(document.Descendants(Ns + "g"), g => (string?)g.Attribute("data-layer") == "Hatch"); + Assert.True(hatchGroup.Elements(Ns + "line").Count() >= 5); + + // Full ellipse and elliptical arc (an A command with rx 10 ry 5). + Assert.Single(document.Descendants(Ns + "ellipse")); + Assert.Contains(paths, p => ((string?)p.Attribute("d") ?? string.Empty).Contains("A10 5", StringComparison.Ordinal)); + + // Translucent line. + XElement translucent = Assert.Single(document.Descendants(Ns + "line"), l => (string?)l.Parent!.Attribute("data-layer") == "Trans"); + Assert.Equal("0.5", (string?)translucent.Attribute("opacity")); + + // Bulged closed polyline: a path with an arc command that closes. + Assert.Contains(paths, p => ((string?)p.Attribute("d") ?? string.Empty) is string d && d.Contains('A') && d.EndsWith('Z') && !d.Contains("A10 5", StringComparison.Ordinal)); + + // Insert: two nested lines tagged with the block, in the Doors group, the ByBlock one in the insert's colour (5 = blue). + List doorLines = document.Descendants(Ns + "line").Where(l => (string?)l.Attribute("data-block") == "DOOR").ToList(); + Assert.Equal(2, doorLines.Count); + Assert.All(doorLines, l => Assert.Equal("Doors", (string?)l.Parent!.Attribute("data-layer"))); + Assert.Contains(doorLines, l => (string?)l.Attribute("stroke") == "#0000ff"); + Assert.All(doorLines, l => Assert.Null(l.Attribute("data-handle"))); + + // Text: MText as two tspans, Fit text with textLength. + List texts = document.Descendants(Ns + "text").ToList(); + Assert.Equal(2, texts.Count); + Assert.Contains(texts, t => t.Elements(Ns + "tspan").Select(s => s.Value).SequenceEqual(["Line1", "Line2"])); + XElement fit = Assert.Single(texts, t => t.Value == "FIT"); + Assert.Equal("40", (string?)fit.Attribute("textLength")); + Assert.Equal("middle", (string?)fit.Attribute("text-anchor")); + } +} +``` + +Notes for the implementer: the SVG `d` strings are formatted with adaptive precision; if `"A10 5"` does not match because of the exact formatting (e.g. `A10 5 0 0 1`), read the golden, find the elliptical arc path and adjust the substring to the smallest distinctive form (`"A10 5"` should hold since rx = 10 and ry = 5 are integers). If the notification assertion fails because the pattern hatch or the Fit text raises a warning, report the warning text: that is a finding, not a test bug. + +- [ ] **Step 3: Run, create the baselines, inspect, rerun** + +Run: `dotnet test ACadSharp.Image.Tests --nologo --filter "FullyQualifiedName~FeatureGoldenTests"` +Expected: both FAIL with "Missing baseline/golden". + +Run: `ACADSHARP_IMAGE_UPDATE_BASELINES=1 dotnet test ACadSharp.Image.Tests --nologo --filter "FullyQualifiedName~FeatureGoldenTests"` +Expected: PASS, two new files. + +Inspect the PNG with the Read tool: a red filled square and a red diagonally hatched square along the bottom, a cyan ellipse and half-ellipse to their right, a translucent magenta horizontal line, a cyan D-shaped closed polyline, a green/blue L-shaped door symbol, two lines of text and the word FIT stretched. Confirm the SVG assertions pass without the update variable: + +Run: `dotnet test ACadSharp.Image.Tests --nologo --filter "FullyQualifiedName~FeatureGoldenTests"` +Expected: PASS. + +- [ ] **Step 4: Run the whole suite and commit** + +Run: `dotnet build ACadSharp.Image.sln -warnaserror --nologo -v q && dotnet test ACadSharp.Image.sln --nologo -v q` +Expected: 0 warnings; all pass. + +```bash +git add ACadSharp.Image.Tests/SyntheticSamples.cs ACadSharp.Image.Tests/FeatureGoldenTests.cs ACadSharp.Image.Tests/Baselines/features.model.01.png ACadSharp.Image.Tests/Baselines/features.model.01.svg +git commit -m "Add feature goldens covering hatches, ellipses, opacity, inserts and text" +``` + +--- + +### Task 4: Spline sampling and text placement unit tests + +**Files:** +- Modify: `ACadSharp.Image.Tests/RecordingDrawingSurface.cs` (add `Texts`) +- Create: `ACadSharp.Image.Tests/SplineRendererTests.cs` +- Create: `ACadSharp.Image.Tests/TextRendererTests.cs` + +**Interfaces:** +- Consumes: `SplineRenderer.EvaluateSplinePoint(int, IReadOnlyList, IReadOnlyList, IReadOnlyList, double)` (internal static), `EntityRenderDispatcher.Draw(ImageRenderContext, Entity)`, `RecordingDrawingSurface.Polylines`, `SurfaceText`. +- Produces: `RecordingDrawingSurface.Texts` (`List`). + +- [ ] **Step 1: Record text runs in the test double** + +In `ACadSharp.Image.Tests/RecordingDrawingSurface.cs`, after the `FillPaths` property add: + +```csharp + /// Every text run handed to DrawText, in order. + public List Texts { get; } = new(); +``` + +and in `DrawText`, after the `Calls.Add(...)` line, add `this.Texts.Add(text);`. + +- [ ] **Step 2: Write the failing spline tests** + +Create `ACadSharp.Image.Tests/SplineRendererTests.cs`: + +```csharp +using ACadSharp.Entities; +using ACadSharp.Image.Rendering; +using ACadSharp.Objects; +using CSMath; + +namespace ACadSharp.Image.Tests; + +public sealed class SplineRendererTests +{ + private static ImageRenderContext Context(IDrawingSurface surface, ImageConfiguration configuration) + { + Layout layout = new("t") { PaperWidth = 100, PaperHeight = 100 }; + return new ImageRenderContext(surface, configuration, layout, 100, 100, 0, 0, 1d, 0, 0, singlePrecision: false, lineTypeScale: 1d); + } + + // Degree 3, 6 control points, clamped uniform knots: not Bezier-form (interior knots have multiplicity 1). + private static Spline ClampedUniformCubic() + { + Spline spline = new() { Degree = 3 }; + spline.Knots.AddRange([0d, 0d, 0d, 0d, 1d, 2d, 3d, 3d, 3d, 3d]); + spline.ControlPoints.AddRange([new XYZ(0, 0, 0), new XYZ(1, 3, 0), new XYZ(3, 4, 0), new XYZ(5, 1, 0), new XYZ(7, 3, 0), new XYZ(9, 0, 0)]); + return spline; + } + + [Fact] + public void NonBezierSplineIsSampledOnSurfacesWithoutCurves() + { + RecordingDrawingSurface surface = new(); + ImageConfiguration configuration = new() { ArcPrecision = 16 }; + EntityRenderDispatcher dispatcher = new(configuration); + Spline spline = ClampedUniformCubic(); + + dispatcher.Draw(Context(surface, configuration), spline); + + // 3 knot spans x 16 = 48 steps -> 49 points (ArcPrecision 16 is below that floor). + IReadOnlyList points = Assert.Single(surface.Polylines); + Assert.Equal(49, points.Count); + Assert.DoesNotContain(surface.Calls, c => c.StartsWith("DrawCubicBezier", StringComparison.Ordinal)); + + // Endpoints are the clamped control points; the midpoint is the de Boor evaluation at t = 1.5 (Y flipped by the context). + Assert.Equal(0d, points[0].X, 9); + Assert.Equal(100d, points[0].Y, 9); + Assert.Equal(9d, points[^1].X, 9); + XY mid = SplineRenderer.EvaluateSplinePoint(3, spline.Knots, spline.ControlPoints, spline.Weights, 1.5); + Assert.Equal(mid.X, points[24].X, 9); + Assert.Equal(100d - mid.Y, points[24].Y, 9); + } + + [Fact] + public void RationalSplineIsSampledEvenOnCurveSurfaces() + { + RecordingDrawingSurface surface = new() { SupportsCurves = true }; + ImageConfiguration configuration = new(); + EntityRenderDispatcher dispatcher = new(configuration); + Spline spline = ClampedUniformCubic(); + spline.Weights.AddRange([1d, 2d, 1d, 2d, 1d, 1d]); + + dispatcher.Draw(Context(surface, configuration), spline); + + // Bezier conversion refuses rational splines, so the curve-capable surface still receives a polyline. + Assert.Single(surface.Polylines); + Assert.DoesNotContain(surface.Calls, c => c.StartsWith("DrawCubicBezier", StringComparison.Ordinal)); + // Weighting pulls the curve toward the heavier control points: the midpoint moves compared with the unweighted spline. + XY weighted = SplineRenderer.EvaluateSplinePoint(3, spline.Knots, spline.ControlPoints, spline.Weights, 1.5); + XY unweighted = SplineRenderer.EvaluateSplinePoint(3, spline.Knots, spline.ControlPoints, [], 1.5); + Assert.NotEqual(unweighted.Y, weighted.Y); + } + + [Fact] + public void QuadraticSplineIsSampled() + { + RecordingDrawingSurface surface = new() { SupportsCurves = true }; + ImageConfiguration configuration = new() { ArcPrecision = 8 }; + EntityRenderDispatcher dispatcher = new(configuration); + Spline spline = new() { Degree = 2 }; + spline.Knots.AddRange([0d, 0d, 0d, 1d, 2d, 2d, 2d]); + spline.ControlPoints.AddRange([new XYZ(0, 0, 0), new XYZ(2, 4, 0), new XYZ(4, 0, 0), new XYZ(6, 4, 0)]); + + dispatcher.Draw(Context(surface, configuration), spline); + + IReadOnlyList points = Assert.Single(surface.Polylines); + Assert.Equal(33, points.Count); // 2 spans x 16 = 32 steps + Assert.Equal(6d, points[^1].X, 9); + } + + [Fact] + public void BezierFormSplineStaysNativeOnCurveSurfaces() + { + RecordingDrawingSurface surface = new() { SupportsCurves = true }; + ImageConfiguration configuration = new(); + EntityRenderDispatcher dispatcher = new(configuration); + + dispatcher.Draw(Context(surface, configuration), ClampedUniformCubic()); + + // Knot insertion turns the clamped cubic into 3 Bezier segments: 10 control points. + Assert.Contains(surface.Calls, c => c.StartsWith("DrawCubicBezier n=10", StringComparison.Ordinal)); + Assert.Empty(surface.Polylines); + } + + [Fact] + public void InconsistentSplineWarnsAndDrawsNothing() + { + RecordingDrawingSurface surface = new(); + ImageConfiguration configuration = new(); + List notifications = new(); + configuration.OnNotification += (_, e) => notifications.Add(e); + EntityRenderDispatcher dispatcher = new(configuration); + Spline spline = new() { Degree = 3 }; + spline.Knots.AddRange([0d, 0d, 1d, 1d]); // wrong knot count for 4 control points of degree 3 + spline.ControlPoints.AddRange([new XYZ(0, 0, 0), new XYZ(1, 1, 0), new XYZ(2, 0, 0), new XYZ(3, 1, 0)]); + + dispatcher.Draw(Context(surface, configuration), spline); + + Assert.Empty(surface.Polylines); + Assert.Contains(notifications, n => n.NotificationType == NotificationType.Warning && n.Message.Contains("spline", StringComparison.OrdinalIgnoreCase)); + } +} +``` + +Notes: `InconsistentSplineWarnsAndDrawsNothing` reaches the renderer's last resort, ACadSharp's `TryPolygonalVertexes`. If ACadSharp manages to produce points for that malformed spline and the renderer draws a polyline, replace the two assertions with `Assert.True(surface.Polylines.Count <= 1)` plus a comment naming the ACadSharp behaviour, and report it. + +- [ ] **Step 3: Write the failing text placement tests** + +Create `ACadSharp.Image.Tests/TextRendererTests.cs`: + +```csharp +using ACadSharp.Entities; +using ACadSharp.Image.Rendering; +using ACadSharp.Objects; +using CSMath; + +namespace ACadSharp.Image.Tests; + +public sealed class TextRendererTests +{ + private static (RecordingDrawingSurface Surface, ImageRenderContext Context, EntityRenderDispatcher Dispatcher) Setup(double scale = 1d) + { + RecordingDrawingSurface surface = new(); + ImageConfiguration configuration = new(); + Layout layout = new("t") { PaperWidth = 100, PaperHeight = 100 }; + ImageRenderContext context = new(surface, configuration, layout, 100, 100, 0, 0, scale, 0, 0, singlePrecision: false, lineTypeScale: scale); + return (surface, context, new EntityRenderDispatcher(configuration)); + } + + [Fact] + public void FitTextIsCentredBetweenInsertAndAlignmentPointsWithAFixedLength() + { + (RecordingDrawingSurface surface, ImageRenderContext context, EntityRenderDispatcher dispatcher) = Setup(scale: 2d); + TextEntity text = new() { Value = "FIT", InsertPoint = new XYZ(10, 20, 0), AlignmentPoint = new XYZ(40, 20, 0), HorizontalAlignment = TextHorizontalAlignment.Fit, Height = 5 }; + + dispatcher.Draw(context, text); + + SurfaceText run = Assert.Single(surface.Texts); + Assert.Equal(SurfaceTextAnchor.Middle, run.Anchor); + Assert.Equal(SurfaceTextBaseline.Alphabetic, run.Baseline); + Assert.Equal(60d, run.FixedLength, 9); // 30 drawing units x scale 2 + Assert.Equal(80d, run.Origin.X, 9); // origin is the alignment point for anything but Left/Baseline + Assert.Equal(100d - 40d, run.Origin.Y, 9); + Assert.Equal(10d, run.Height, 9); + } + + [Fact] + public void AlignedTextWithCoincidentPointsHasNoFixedLength() + { + (RecordingDrawingSurface surface, ImageRenderContext context, EntityRenderDispatcher dispatcher) = Setup(); + TextEntity text = new() { Value = "A", InsertPoint = new XYZ(1, 1, 0), AlignmentPoint = new XYZ(1, 1, 0), HorizontalAlignment = TextHorizontalAlignment.Aligned, Height = 2 }; + + dispatcher.Draw(context, text); + + Assert.Equal(-1d, Assert.Single(surface.Texts).FixedLength); + } + + [Theory] + [InlineData(TextHorizontalAlignment.Left, TextVerticalAlignmentType.Baseline, SurfaceTextAnchor.Start, SurfaceTextBaseline.Alphabetic, 1d)] + [InlineData(TextHorizontalAlignment.Center, TextVerticalAlignmentType.Baseline, SurfaceTextAnchor.Middle, SurfaceTextBaseline.Alphabetic, 9d)] + [InlineData(TextHorizontalAlignment.Right, TextVerticalAlignmentType.Top, SurfaceTextAnchor.End, SurfaceTextBaseline.Hanging, 9d)] + [InlineData(TextHorizontalAlignment.Middle, TextVerticalAlignmentType.Middle, SurfaceTextAnchor.Middle, SurfaceTextBaseline.Central, 9d)] + [InlineData(TextHorizontalAlignment.Left, TextVerticalAlignmentType.Bottom, SurfaceTextAnchor.Start, SurfaceTextBaseline.Alphabetic, 9d)] + public void TextAlignmentMapsToAnchorBaselineAndOrigin(TextHorizontalAlignment horizontal, TextVerticalAlignmentType vertical, SurfaceTextAnchor anchor, SurfaceTextBaseline baseline, double expectedOriginX) + { + (RecordingDrawingSurface surface, ImageRenderContext context, EntityRenderDispatcher dispatcher) = Setup(); + TextEntity text = new() { Value = "T", InsertPoint = new XYZ(1, 0, 0), AlignmentPoint = new XYZ(9, 0, 0), HorizontalAlignment = horizontal, VerticalAlignment = vertical, Height = 2 }; + + dispatcher.Draw(context, text); + + SurfaceText run = Assert.Single(surface.Texts); + Assert.Equal(anchor, run.Anchor); + Assert.Equal(baseline, run.Baseline); + Assert.Equal(expectedOriginX, run.Origin.X, 9); + } + + [Theory] + [InlineData(AttachmentPointType.TopLeft, SurfaceTextAnchor.Start, SurfaceTextBaseline.Hanging)] + [InlineData(AttachmentPointType.TopCenter, SurfaceTextAnchor.Middle, SurfaceTextBaseline.Hanging)] + [InlineData(AttachmentPointType.MiddleRight, SurfaceTextAnchor.End, SurfaceTextBaseline.Central)] + [InlineData(AttachmentPointType.BottomCenter, SurfaceTextAnchor.Middle, SurfaceTextBaseline.Alphabetic)] + [InlineData(AttachmentPointType.BottomLeft, SurfaceTextAnchor.Start, SurfaceTextBaseline.Alphabetic)] + public void MTextAttachmentMapsToAnchorAndBaseline(AttachmentPointType attachment, SurfaceTextAnchor anchor, SurfaceTextBaseline baseline) + { + (RecordingDrawingSurface surface, ImageRenderContext context, EntityRenderDispatcher dispatcher) = Setup(); + MText text = new() { Value = "M", InsertPoint = new XYZ(5, 5, 0), Height = 3, AttachmentPoint = attachment, RectangleWidth = 40, LineSpacing = 1.5 }; + + dispatcher.Draw(context, text); + + SurfaceText run = Assert.Single(surface.Texts); + Assert.Equal(anchor, run.Anchor); + Assert.Equal(baseline, run.Baseline); + Assert.Equal(40d, run.WrappingWidth, 9); + Assert.Equal(1.5d, run.LineSpacingFactor, 9); + Assert.Equal(-1d, run.FixedLength); + } + + [Fact] + public void MTextWithoutRectangleWidthDoesNotWrap() + { + (RecordingDrawingSurface surface, ImageRenderContext context, EntityRenderDispatcher dispatcher) = Setup(); + + dispatcher.Draw(context, new MText { Value = "M", InsertPoint = new XYZ(5, 5, 0), Height = 3, RectangleWidth = 0 }); + + Assert.Equal(-1d, Assert.Single(surface.Texts).WrappingWidth); + } + + [Fact] + public void ControlCodesAreExpandedAndParagraphsBecomeLines() + { + (RecordingDrawingSurface surface, ImageRenderContext context, EntityRenderDispatcher dispatcher) = Setup(); + + dispatcher.Draw(context, new TextEntity { Value = "%%c20 %%d %%p1", InsertPoint = new XYZ(0, 0, 0), Height = 2 }); + dispatcher.Draw(context, new MText { Value = "First\\PSecond", InsertPoint = new XYZ(0, 0, 0), Height = 2 }); + + Assert.Equal(2, surface.Texts.Count); + Assert.Equal("Ø20 ° ±1", surface.Texts[0].Text); + Assert.Equal("First\nSecond", surface.Texts[1].Text); + } + + [Fact] + public void BlankTextDrawsNothing() + { + (RecordingDrawingSurface surface, ImageRenderContext context, EntityRenderDispatcher dispatcher) = Setup(); + + dispatcher.Draw(context, new TextEntity { Value = " ", InsertPoint = new XYZ(0, 0, 0), Height = 2 }); + dispatcher.Draw(context, new MText { Value = string.Empty, InsertPoint = new XYZ(0, 0, 0), Height = 2 }); + + Assert.Empty(surface.Texts); + Assert.Equal(4, surface.Calls.Count); // two Begin/End pairs, no DrawText + } +} +``` + +Notes: `MText.PlainText` in ACadSharp 3.7.1 already turns `\P` into a newline, and `NormalizeText` leaves newlines alone, so `"First\nSecond"` is the expected value. In the alignment theory the `Left/Bottom` row expects the alignment point (X 9) because only `Left` + `Baseline` uses the insert point. If ACadSharp's `TextEntity` setter for `HorizontalAlignment` also moves the origin or the enum lacks a member named exactly as written, report it rather than guessing. + +- [ ] **Step 4: Run the new tests** + +Run: `dotnet test ACadSharp.Image.Tests --nologo --filter "FullyQualifiedName~SplineRendererTests|FullyQualifiedName~TextRendererTests"` +Expected: all PASS (these test existing behaviour; a failure is a finding to report, with the actual values). + +- [ ] **Step 5: Run the whole suite and commit** + +Run: `dotnet build ACadSharp.Image.sln -warnaserror --nologo -v q && dotnet test ACadSharp.Image.sln --nologo -v q` +Expected: 0 warnings; all pass. + +```bash +git add ACadSharp.Image.Tests/RecordingDrawingSurface.cs ACadSharp.Image.Tests/SplineRendererTests.cs ACadSharp.Image.Tests/TextRendererTests.cs +git commit -m "Test spline sampling and text placement through the dispatcher" +``` + +--- + +### Task 5: Small public API and unit conversion tests + +**Files:** +- Modify: `ACadSharp.Image.Tests/ImageConfigurationTests.cs` (append tests) +- Create: `ACadSharp.Image.Tests/RenderedPageTests.cs` + +**Interfaces:** +- Consumes: `ImageConfiguration.HideLayers/IncludeLayers/SetLineWeight/RemoveLineWeight/ClearLineWeights/GetLineWeightMillimeters`, `RenderedImagePage(string name, Image canvas, ImageExportFormat format, int quality)` (check the constructor signature in `RenderedImagePage.cs` before writing; adjust the call if it differs and report), `RenderedPage.Save(string)`, `ImageRenderContext.UnitsPerMillimeter(UnitsType)` (internal static), `ImageExportFormatExtensions.GetFileExtension/TryParse/TryParseFileExtension`, `ImagePage.Add(BlockRecord, Func?, bool)`. + +- [ ] **Step 1: Append configuration tests** + +Append to the class in `ACadSharp.Image.Tests/ImageConfigurationTests.cs` (before its closing brace): + +```csharp + [Fact] + public void HideLayersAddsSeveralNamesCaseInsensitively() + { + ImageConfiguration configuration = new(); + + configuration.HideLayers(["A-DOOR", "a-door", "A-GLAZ"]); + + Assert.Equal(2, configuration.HiddenLayers.Count); + Assert.Contains("A-DOOR", configuration.HiddenLayers); + Assert.Contains("a-glaz", configuration.HiddenLayers); + Assert.True(configuration.ShowLayer("A-Door")); + Assert.False(configuration.ShowLayer("A-Door")); + Assert.Throws(() => configuration.HideLayers([" "])); + } + + [Fact] + public void IncludedLayersBehavesAsAReadOnlySet() + { + ImageConfiguration configuration = new(); + configuration.IncludeLayers(["Walls", "Doors"]); + IReadOnlySet included = configuration.IncludedLayers; + + Assert.Equal(2, included.Count); + Assert.True(included.Contains("walls")); + Assert.True(included.IsSubsetOf(["WALLS", "DOORS", "Glazing"])); + Assert.True(included.IsProperSubsetOf(["WALLS", "DOORS", "Glazing"])); + Assert.True(included.IsSupersetOf(["doors"])); + Assert.True(included.IsProperSupersetOf(["doors"])); + Assert.True(included.Overlaps(["Doors", "Roof"])); + Assert.True(included.SetEquals(["DOORS", "WALLS"])); + Assert.Equal(2, included.Count()); + Assert.True(configuration.ExcludeLayer("WALLS")); + Assert.False(included.Contains("Walls")); + } + + [Fact] + public void LineWeightOverridesValidateAndFallBackToDefaults() + { + ImageConfiguration configuration = new(); + double defaultW50 = configuration.GetLineWeightMillimeters(ACadSharp.LineWeightType.W50); + + configuration.SetLineWeight(ACadSharp.LineWeightType.W50, 1.25); + Assert.Equal(1.25, configuration.GetLineWeightMillimeters(ACadSharp.LineWeightType.W50)); + Assert.Throws(() => configuration.SetLineWeight(ACadSharp.LineWeightType.W50, -0.1)); + Assert.True(configuration.RemoveLineWeight(ACadSharp.LineWeightType.W50)); + Assert.False(configuration.RemoveLineWeight(ACadSharp.LineWeightType.W50)); + Assert.Equal(defaultW50, configuration.GetLineWeightMillimeters(ACadSharp.LineWeightType.W50)); + Assert.Equal(0d, configuration.GetLineWeightMillimeters(ACadSharp.LineWeightType.ByLayer)); + configuration.SetLineWeight(ACadSharp.LineWeightType.Default, 0d); + Assert.Equal(Math.Max(1f, configuration.LineWeightScale), configuration.GetLineWeightPixels(ACadSharp.LineWeightType.Default)); + configuration.ClearLineWeights(); + Assert.Equal(defaultW50, configuration.GetLineWeightMillimeters(ACadSharp.LineWeightType.W50)); + } +``` + +If `HideLayers([" "])` does not throw `ArgumentException` in the current implementation (check `HideLayer` in `ImageConfiguration.cs`), replace that line with whatever the code actually does for blank names, and state it in the report. + +- [ ] **Step 2: Write the rendered-page, format and unit tests** + +Create `ACadSharp.Image.Tests/RenderedPageTests.cs`: + +```csharp +using ACadSharp.Entities; +using ACadSharp.Image.Rendering; +using ACadSharp.Tables; +using ACadSharp.Types.Units; +using CSMath; +using SixLabors.ImageSharp; +using SixLabors.ImageSharp.PixelFormats; + +namespace ACadSharp.Image.Tests; + +public sealed class RenderedPageTests +{ + [Theory] + [InlineData(ImageExportFormat.Png, new byte[] { 0x89, 0x50, 0x4E, 0x47 })] + [InlineData(ImageExportFormat.Bmp, new byte[] { 0x42, 0x4D })] + [InlineData(ImageExportFormat.Jpeg, new byte[] { 0xFF, 0xD8, 0xFF })] + [InlineData(ImageExportFormat.Gif, new byte[] { 0x47, 0x49, 0x46, 0x38 })] + [InlineData(ImageExportFormat.Webp, new byte[] { 0x52, 0x49, 0x46, 0x46 })] + public void RasterPagesEncodeInTheirFormat(ImageExportFormat format, byte[] signature) + { + using Image canvas = new(8, 8, Color.White); + using RenderedImagePage page = new("p", canvas, format, 80); + using MemoryStream stream = new(); + + page.Save(stream); + + byte[] bytes = stream.ToArray(); + Assert.True(bytes.Length > signature.Length); + Assert.Equal(signature, bytes.Take(signature.Length).ToArray()); + } + + [Fact] + public void SaveToPathCreatesTheDirectory() + { + string directory = Path.Combine(Path.GetTempPath(), $"acad-image-{Guid.NewGuid():N}", "nested"); + string path = Path.Combine(directory, "page.svg"); + try + { + using RenderedSvgPage page = new("p", ""); + + page.Save(path); + + Assert.True(File.Exists(path)); + byte[] bytes = File.ReadAllBytes(path); + Assert.False(bytes.Length >= 3 && bytes[0] == 0xEF && bytes[1] == 0xBB && bytes[2] == 0xBF, "SVG files must be written without a BOM."); + Assert.StartsWith(" e.Layer.Name == "Keep"); + + Assert.Single(page.Entities); + Assert.Equal("Keep", page.Entities[0].Layer.Name); + Assert.True(page.AutoSized); + Assert.Equal(1d, page.Layout!.PaperWidth); // extents 1 x 0 are clamped to at least 1 unit + Assert.Equal(1d, page.Layout.PaperHeight); + } +} +``` + +Notes: if `UnitsType.Parsecs` does not exist in ACadSharp 3.7.1, use any member of `UnitsType` that is not in the switch (`UnitsType.Angstroms` or `UnitsType.Nanometers`), and if the `RenderedImagePage` constructor takes different parameters, adapt the call. Report both substitutions. + +- [ ] **Step 3: Run the new tests** + +Run: `dotnet test ACadSharp.Image.Tests --nologo --filter "FullyQualifiedName~RenderedPageTests|FullyQualifiedName~ImageConfigurationTests"` +Expected: PASS. + +- [ ] **Step 4: Run the whole suite, check coverage, commit** + +Run: `dotnet build ACadSharp.Image.sln -warnaserror --nologo -v q && dotnet test ACadSharp.Image.sln --nologo -v q` +Expected: 0 warnings; all pass. + +Run: `dotnet test ACadSharp.Image.Tests --nologo -v q --collect:"XPlat Code Coverage" --results-directory /tmp/claude-1000/-work-workspaces-orca-ACadSharp-Image-svg-support/f63cfd24-08c1-4c72-b680-352d15a25a0a/scratchpad/coverage-after` +Expected: a `coverage.cobertura.xml`; report its `line-rate` and `branch-rate` attributes from the root element (before this plan: 85.8% lines, 76.4% branches). + +```bash +git add ACadSharp.Image.Tests/ImageConfigurationTests.cs ACadSharp.Image.Tests/RenderedPageTests.cs +git commit -m "Test the configuration set API, rendered page encoders, formats and units" +``` diff --git a/docs/superpowers/plans/2026-09-03-05-follow-ups.md b/docs/superpowers/plans/2026-09-03-05-follow-ups.md new file mode 100644 index 0000000..21ddb5e --- /dev/null +++ b/docs/superpowers/plans/2026-09-03-05-follow-ups.md @@ -0,0 +1,434 @@ +# Post-Review Follow-Ups Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Close the three follow-ups left open by the 2026-09-03 reviews: the raster viewport sub-pixel offset that drops boundary geometry, the missing OCS transform for `Solid`, and the CLI's direct console writes that make its tests noisy. + +**Architecture:** Two small renderer changes (one line of arithmetic in the viewport path, one transform in `DrawSolid`), each with a test that fails before and passes after, plus a deliberate regeneration of the two raster baselines the viewport change moves. One CLI refactor introducing `Run(args, output, error)` so tests capture output through `StringWriter`. The design spec records the viewport parity exception. + +**Tech Stack:** .NET 10, ACadSharp 3.7.1, SixLabors.ImageSharp 3.1.12, xUnit. + +**Spec:** `docs/superpowers/specs/2026-09-02-layers-and-svg-design.md` (binding; section 3 parity rule amended by Task 1, section 5.3 OCS rule extended by Task 2). + +## Global Constraints + +- ACadSharp `3.7.1`; SixLabors.ImageSharp `3.1.12`; no new NuGet dependencies; target frameworks unchanged. +- Coding conventions: `this.` prefix on instance members, explicit types except LINQ lambdas, XML docs on public and internal members, `sealed` classes, file-scoped namespaces, four-space indent, UTF-8 without BOM, LF line endings. +- Baselines: only Task 1 may regenerate baselines, and only `HSK80AHCP16190M_BMG.paper.01.png` and `viewport-sheet.paper.01.png`. Every other file under `ACadSharp.Image.Tests/Baselines` must be byte-identical before and after each task (`git status` must show no other baseline). Regeneration is done with `ACADSHARP_IMAGE_UPDATE_BASELINES=1` scoped to the two tests that own those files, never on the whole suite. +- Parity tests need the font `DejaVu Sans` (installed). +- `dotnet build ACadSharp.Image.sln -warnaserror` must stay warning-free; the full suite must pass before every commit. +- Never use bare `git stash` / `git stash pop`. Commit messages end with the repository's two trailer lines (see any commit on this branch). + +## File Structure + +- Modify `ACadSharp.Image/Rendering/ImagePageRenderer.cs` (`DrawViewport`: exact viewport size), `ACadSharp.Image/Rendering/RasterDrawingSurface.cs` (`BeginViewport`: exact flip origin), `ACadSharp.Image/Rendering/EntityRenderDispatcher.cs` (`DrawSolid`: OCS transform). +- Modify `ACadSharp.Image.Tests/ViewportParityTests.cs` (bottom-wall probe returns), `ACadSharp.Image.Tests/EntityRenderDispatcherTests.cs` (solid OCS test), `ACadSharp.Image.Tests/RasterDrawingSurfaceTests.cs` (viewport flip test). +- Modify `ACadSharp.Image.Cli/Program.cs` (`Run` with writers), `ACadSharp.Image.Tests/CliTests.cs` (captured output), the six new test classes (class-level XML docs). +- Modify `docs/superpowers/specs/2026-09-02-layers-and-svg-design.md` (sections 3 and 5.3), `README.md` (one sentence on Solid OCS). +- Regenerate `ACadSharp.Image.Tests/Baselines/HSK80AHCP16190M_BMG.paper.01.png` and `ACadSharp.Image.Tests/Baselines/viewport-sheet.paper.01.png`. + +--- + +### Task 1: Raster viewports keep geometry on their lower boundary + +**Files:** +- Modify: `ACadSharp.Image/Rendering/ImagePageRenderer.cs` (`DrawViewport`, lines ~193-222) +- Modify: `ACadSharp.Image/Rendering/RasterDrawingSurface.cs` (`BeginViewport`, lines ~216-224) +- Modify: `ACadSharp.Image.Tests/RasterDrawingSurfaceTests.cs` (append one test) +- Modify: `ACadSharp.Image.Tests/ViewportParityTests.cs` (`SheetPngMatchesBaseline`) +- Modify: `docs/superpowers/specs/2026-09-02-layers-and-svg-design.md` (section 3, the pixel-identical bullet) +- Regenerate: `ACadSharp.Image.Tests/Baselines/HSK80AHCP16190M_BMG.paper.01.png`, `ACadSharp.Image.Tests/Baselines/viewport-sheet.paper.01.png` + +**Interfaces:** +- Consumes: `ViewportSurface(IDrawingSurface Surface, double OffsetX, double BottomY)`; `ImageRenderContext.CreateViewportContext(parent, viewport, surface, surfaceWidth, modelBounds, scale, lineTypeScale)` uses `surface.BottomY` as the Y-flip origin. +- Produces: no signature changes. Behaviour: the raster viewport's child image is still `ceil(width) x ceil(height)` pixels, but `BottomY` is the exact height, so model geometry maps to the same sub-pixel position it has on the page. + +Background: today `DrawViewport` rounds the viewport size up to whole pixels for the raster path and `RasterDrawingSurface.BeginViewport` returns that integer as `BottomY`. A viewport 274.29 px tall gets a 275-row image and a flip origin of 275, so every point is shifted down 0.71 px and a model line on the view's lower edge lands on row 275, outside the image. The pre-branch renderer did the same, which is why the HSK paper baseline encodes the shift. + +- [ ] **Step 1: Write the failing surface test** + +Append to the class in `ACadSharp.Image.Tests/RasterDrawingSurfaceTests.cs` (check its existing usings; it already uses `SixLabors.ImageSharp` and `Rgba32`): + +```csharp + [Fact] + public void ViewportFlipOriginIsTheExactHeightNotTheRoundedImageHeight() + { + using Image canvas = new(20, 20, ImageColor.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. + ViewportSurface viewport = surface.BeginViewport(new SurfaceRect(0, 0, 10, 9.3)); + + Assert.Equal(9.3, viewport.BottomY, 9); + Assert.Equal(0d, viewport.OffsetX); + + // A one-pixel line drawn on the viewport's bottom edge (surface y = BottomY) must reach the page. + viewport.Surface.DrawLine(new ImageStyle(ImageColor.Red, 1f), new SurfacePoint(0, viewport.BottomY), new SurfacePoint(10, viewport.BottomY)); + surface.EndViewport(viewport); + + Assert.Contains(Enumerable.Range(0, 10).Select(x => canvas[x, 9]), p => p.R > p.G); + } +``` + +Check the constructor `RasterDrawingSurface(Image canvas, ImageConfiguration configuration, bool ownsCanvas)` against the file before using it; adapt the call if the parameter order differs and say so. + +- [ ] **Step 2: Run it and confirm it fails** + +Run: `dotnet test ACadSharp.Image.Tests --nologo --filter "FullyQualifiedName~ViewportFlipOriginIsTheExactHeight"` +Expected: FAIL on `Assert.Equal(9.3, viewport.BottomY, 9)` (actual 10). + +- [ ] **Step 3: Use the exact size for the flip origin** + +In `ACadSharp.Image/Rendering/RasterDrawingSurface.cs`, `BeginViewport`: + +```csharp + public ViewportSurface BeginViewport(SurfaceRect bounds) + { + // The child image needs whole pixels, but the content is placed against the viewport's exact height: + // rounding the flip origin up used to shift every point down by the fraction and push geometry on the + // view's lower edge out of the image. + int width = Math.Max(1, (int)Math.Ceiling(bounds.Width)); + int height = Math.Max(1, (int)Math.Ceiling(bounds.Height)); + Image image = new(width, height, ImageColor.Transparent); + RasterDrawingSurface child = new(image, this._configuration, ownsCanvas: true); + ViewportSurface viewport = new(child, 0d, bounds.Height); + this._viewports[viewport] = (image, bounds); + return viewport; + } +``` + +In `ACadSharp.Image/Rendering/ImagePageRenderer.cs`, `DrawViewport`, replace the two rounded size computations with the exact lengths for both backends: + +```csharp + BoundingBox viewportBounds = viewport.GetBoundingBox(); + // Exact size for both backends; the raster surface rounds its own image up to whole pixels. + double viewportWidth = pageContext.ToSurfaceLength(viewportBounds.LengthX); + double viewportHeight = pageContext.ToSurfaceLength(viewportBounds.LengthY); +``` + +Leave the `scale` computation (`(float)pageContext.Scale * (float)viewport.ScaleFactor` under `SinglePrecision`) exactly as it is. + +- [ ] **Step 4: Run the surface test and the full suite** + +Run: `dotnet test ACadSharp.Image.Tests --nologo --filter "FullyQualifiedName~ViewportFlipOriginIsTheExactHeight"` +Expected: PASS. + +Run: `dotnet test ACadSharp.Image.sln --nologo -v q` +Expected: exactly two failures, both PNG comparisons: `SampleParityTests.SampleRendersMatchBaselines(fileName: "HSK80AHCP16190M_BMG.dwg", paperLayouts: True)` and `ViewportParityTests.SheetPngMatchesBaseline`. Any other failure (an SVG golden, a model-space PNG, a unit test) means the change did more than intended: stop and report. + +- [ ] **Step 5: Regenerate the two baselines, then inspect them** + +Run: +```bash +ACADSHARP_IMAGE_UPDATE_BASELINES=1 dotnet test ACadSharp.Image.Tests --nologo --filter "FullyQualifiedName~SampleParityTests.SampleRendersMatchBaselines|FullyQualifiedName~ViewportParityTests.SheetPngMatchesBaseline" +``` +(The sample theory regenerates all four sample PNGs, but the three model-space ones must come out byte-identical; verify with `git status --short ACadSharp.Image.Tests/Baselines`, which must list exactly `HSK80AHCP16190M_BMG.paper.01.png` and `viewport-sheet.paper.01.png`. If a model-space PNG shows as modified, the change is wrong: `git checkout -- ACadSharp.Image.Tests/Baselines` and report.) + +Quantify the change for the commit message with this script (run from the repo root, `python3` with Pillow is available; if not, use a tiny C# check through a throwaway test and delete it): + +```bash +git show HEAD:ACadSharp.Image.Tests/Baselines/HSK80AHCP16190M_BMG.paper.01.png > /tmp/claude-1000/-work-workspaces-orca-ACadSharp-Image-svg-support/f63cfd24-08c1-4c72-b680-352d15a25a0a/scratchpad/hsk-paper-before.png +python3 - <<'PY' +from PIL import Image, ImageChops +a = Image.open('/tmp/claude-1000/-work-workspaces-orca-ACadSharp-Image-svg-support/f63cfd24-08c1-4c72-b680-352d15a25a0a/scratchpad/hsk-paper-before.png').convert('RGBA') +b = Image.open('ACadSharp.Image.Tests/Baselines/HSK80AHCP16190M_BMG.paper.01.png').convert('RGBA') +diff = ImageChops.difference(a, b) +bbox = diff.getbbox() +changed = sum(1 for p in diff.getdata() if p != (0, 0, 0, 0)) +print('changed pixels', changed, 'of', a.width * a.height, 'bbox', bbox) +PY +``` + +Open both new PNGs with the Read tool. In `viewport-sheet.paper.01.png` the red rectangle must now show its bottom wall (a horizontal red line joining the left wall's lower end) in addition to everything it showed before; in the HSK paper page the content should look the same, shifted up by less than a pixel. + +- [ ] **Step 6: Bring the bottom-wall probe back** + +In `ACadSharp.Image.Tests/ViewportParityTests.cs`, `SheetPngMatchesBaseline`, after the existing red left-wall probe add a bottom-wall probe. Model (50, 0) is paper (148.5, 45); with the sheet placed at x = 60.5 and y = 10 at 2.2857 px/unit that is canvas (400, 387). Read the row in the regenerated baseline and pick the row with the strongest red between y = 385 and y = 389, then assert: + +```csharp + // The bottom wall lies exactly on the view's lower edge; before the flip-origin fix it fell outside the viewport image. + Rgba32 bottomWall = page.Canvas[400, ]; + Assert.True(bottomWall.R > 200 && bottomWall.G < 100 && bottomWall.B < 100, $"expected a red pixel on the bottom wall, got {bottomWall}"); +``` + +Update the comment above the white-window scan if it still says the bottom wall does not render. + +- [ ] **Step 7: Amend the spec's parity rule** + +In `docs/superpowers/specs/2026-09-02-layers-and-svg-design.md`, section 3, after the bullet beginning "Existing raster output must stay **pixel-identical**" add: + +```markdown +- **Amended 2026-09-03 (follow-up after review):** paper-space pages are the one deliberate exception. The pre-refactor renderer rounded a raster viewport's height up to whole pixels and used the rounded value as the Y-flip origin, shifting the viewport's content down by the fraction and dropping geometry on the view's lower edge. `RasterDrawingSurface.BeginViewport` now keeps the exact height as the flip origin (the child image is still whole pixels), so `HSK80AHCP16190M_BMG.paper.01.png` and `viewport-sheet.paper.01.png` were regenerated once with that cause. Model-space baselines are unchanged. +``` + +- [ ] **Step 8: Full suite, build, commit** + +Run: `dotnet build ACadSharp.Image.sln -warnaserror --nologo -v q && dotnet test ACadSharp.Image.sln --nologo -v q` +Expected: 0 warnings; all pass (249 tests). + +```bash +git add ACadSharp.Image/Rendering/ImagePageRenderer.cs ACadSharp.Image/Rendering/RasterDrawingSurface.cs ACadSharp.Image.Tests/RasterDrawingSurfaceTests.cs ACadSharp.Image.Tests/ViewportParityTests.cs ACadSharp.Image.Tests/Baselines/HSK80AHCP16190M_BMG.paper.01.png ACadSharp.Image.Tests/Baselines/viewport-sheet.paper.01.png docs/superpowers/specs/2026-09-02-layers-and-svg-design.md +git commit -m "Place raster viewport content against the exact viewport height" +``` + +The commit body must state the changed-pixel count from Step 5 and that the two paper baselines were regenerated for that reason. + +--- + +### Task 2: Solids honour their extrusion normal + +**Files:** +- Modify: `ACadSharp.Image/Rendering/EntityRenderDispatcher.cs` (`DrawSolid`) +- Modify: `ACadSharp.Image.Tests/EntityRenderDispatcherTests.cs` (append one test) +- Modify: `README.md` (the sentence about non-world extrusion normals, ~line 250) +- Modify: `docs/superpowers/specs/2026-09-02-layers-and-svg-design.md` (section 5.3 OCS bullet) + +**Interfaces:** +- Consumes: `OcsTransform.IsWorldPlane(XYZ)`, `OcsTransform.For(XYZ)`, `OcsTransform.ToWorldXY(double x, double y, double elevation)`; `RecordingDrawingSurface.Calls` records `FillPolygon n=4`; `RecordingDrawingSurface` has no polygon point capture, so the test adds one (see Step 1). +- Produces: no signature changes. + +Background: `Solid` stores its four corners in its OCS (`Solid.Normal`, default `(0,0,1)`); ACadSharp does not transform them. `DrawSolid` currently projects the raw corners, so a solid with a `(0,0,-1)` normal is drawn un-mirrored, unlike polylines and hatches since the review fixes. + +- [ ] **Step 1: Capture polygon points in the test double and write the failing test** + +In `ACadSharp.Image.Tests/RecordingDrawingSurface.cs`, after the `FillPaths` property add: + +```csharp + /// Points of every FillPolygon call, in order. + public List> Polygons { get; } = new(); +``` + +and in `FillPolygon`, after the `Calls.Add(...)` line, add `this.Polygons.Add(points.ToArray());`. + +Append to `ACadSharp.Image.Tests/EntityRenderDispatcherTests.cs`: + +```csharp + [Fact] + public void NonWorldSolidIsBroughtIntoWorld() + { + RecordingDrawingSurface surface = new(); + ImageConfiguration configuration = new(); + EntityRenderDispatcher dispatcher = new(configuration); + Solid solid = new() + { + FirstCorner = new XYZ(1, 0, 0), + SecondCorner = new XYZ(3, 0, 0), + ThirdCorner = new XYZ(1, 2, 0), + FourthCorner = new XYZ(3, 2, 0), + Normal = new XYZ(0, 0, -1), + }; + + dispatcher.Draw(CreateContext(surface, configuration), solid); + + // A (0,0,-1) extrusion mirrors X: the solid must land on x in [-3, -1], not [1, 3]. + IReadOnlyList points = Assert.Single(surface.Polygons); + Assert.Equal(4, points.Count); + Assert.Equal(-1d, points.Max(p => p.X), 9); + Assert.Equal(-3d, points.Min(p => p.X), 9); + + // The default normal leaves the corners untouched. + solid.Normal = XYZ.AxisZ; + dispatcher.Draw(CreateContext(surface, configuration), solid); + Assert.Equal(1d, surface.Polygons[1].Min(p => p.X), 9); + Assert.Equal(3d, surface.Polygons[1].Max(p => p.X), 9); + } +``` + +- [ ] **Step 2: Run it and confirm it fails** + +Run: `dotnet test ACadSharp.Image.Tests --nologo --filter "FullyQualifiedName~NonWorldSolidIsBroughtIntoWorld"` +Expected: FAIL on `Assert.Equal(-1d, points.Max(...))` (actual 3). + +- [ ] **Step 3: Transform the corners** + +Replace `DrawSolid` in `ACadSharp.Image/Rendering/EntityRenderDispatcher.cs`: + +```csharp + /// + /// Fills a solid's four corners. The corners are OCS coordinates (ACadSharp leaves the normal to the caller), so a + /// non-world normal is applied first, with each corner's Z as its elevation. + /// + private static void DrawSolid(ImageRenderContext context, ImageStyle style, Solid solid) + { + OcsTransform? toWorld = IsWorldPlane(solid.Normal) ? null : OcsTransform.For(solid.Normal); + SurfacePoint ToSurface(XYZ corner) => toWorld != null + ? context.ToSurfacePoint(toWorld.ToWorldXY(corner.X, corner.Y, corner.Z)) + : context.ToSurfacePoint(corner); + + SurfacePoint[] points = + [ + ToSurface(solid.FirstCorner), + ToSurface(solid.SecondCorner), + ToSurface(solid.ThirdCorner), + ToSurface(solid.FourthCorner), + ]; + + context.Surface.FillPolygon(style, points); + } +``` + +- [ ] **Step 4: Run the test, then the full suite** + +Run: `dotnet test ACadSharp.Image.Tests --nologo --filter "FullyQualifiedName~NonWorldSolidIsBroughtIntoWorld"` +Expected: PASS. + +Run: `dotnet test ACadSharp.Image.sln --nologo -v q` +Expected: all pass; no baseline changes (`git status --short ACadSharp.Image.Tests/Baselines` empty). A baseline change would mean a sample contains a non-world solid: stop and report rather than regenerate. + +- [ ] **Step 5: Document** + +In `README.md`, replace the sentence + +``` +Entities with a non-world extrusion normal (an OCS other than the default) are brought into world coordinates first and, in SVG, tessellated, since their curve parameters no longer describe an ellipse in output space. +``` + +with + +``` +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. Text entities are not transformed (their normal is ignored), which is a known limitation. +``` + +In the spec, section 5.3, extend the amended OCS bullet's first sentence from "Polylines and hatches store OCS coordinates" to "Polylines, hatches and solids store OCS coordinates" and append: "`Solid` corners are transformed the same way (follow-up 2026-09-03). `TextEntity`/`MText` normals remain ignored." + +- [ ] **Step 6: Build, full suite, commit** + +Run: `dotnet build ACadSharp.Image.sln -warnaserror --nologo -v q && dotnet test ACadSharp.Image.sln --nologo -v q` +Expected: 0 warnings; all pass (250 tests). + +```bash +git add ACadSharp.Image/Rendering/EntityRenderDispatcher.cs ACadSharp.Image.Tests/RecordingDrawingSurface.cs ACadSharp.Image.Tests/EntityRenderDispatcherTests.cs README.md docs/superpowers/specs/2026-09-02-layers-and-svg-design.md +git commit -m "Bring OCS solids into world space" +``` + +--- + +### Task 3: CLI output through writers, dead branch removed, test class docs + +**Files:** +- Modify: `ACadSharp.Image.Cli/Program.cs` +- Modify: `ACadSharp.Image.Tests/CliTests.cs` +- Modify: `ACadSharp.Image.Tests/ViewportParityTests.cs`, `FeatureGoldenTests.cs`, `SplineRendererTests.cs`, `TextRendererTests.cs`, `RenderedPageTests.cs`, `CliTests.cs` (class-level XML docs) + +**Interfaces:** +- Produces: `internal static int Program.Run(string[] args, TextWriter output, TextWriter error)`; `public static int Main(string[] args)` becomes `=> Run(args, Console.Out, Console.Error);`; `WriteHelp(TextWriter output)`. + +- [ ] **Step 1: Write the failing tests** + +In `ACadSharp.Image.Tests/CliTests.cs`, replace the two `Main*` tests with: + +```csharp + [Fact] + public void RunReturnsOneAndReportsAMissingInputFileOnTheErrorWriter() + { + string missing = Path.Combine(Path.GetTempPath(), $"missing-{Guid.NewGuid():N}.dxf"); + StringWriter output = new(); + StringWriter error = new(); + + int exitCode = Program.Run([missing], output, error); + + Assert.Equal(1, exitCode); + Assert.Equal(string.Empty, output.ToString()); + Assert.StartsWith("Error: Input file was not found.", error.ToString(), StringComparison.Ordinal); + } + + [Fact] + public void RunWritesHelpToTheOutputWriterAndReturnsZero() + { + StringWriter output = new(); + StringWriter error = new(); + + Assert.Equal(0, Program.Run([], output, error)); + Assert.Equal(0, Program.Run(["--help"], output, error)); + + Assert.Contains("Usage:", output.ToString(), StringComparison.Ordinal); + Assert.Contains("--list-layers", output.ToString(), StringComparison.Ordinal); + Assert.Equal(string.Empty, error.ToString()); + } + + [Fact] + public void RunRejectsAnUnknownFormatBeforeReadingTheDocument() + { + string repoRoot = SampleParityTests.FindRepoRoot(); + string sample = Path.Combine(repoRoot, "Samples", "6-57-1119.dxf"); + StringWriter output = new(); + StringWriter error = new(); + + int exitCode = Program.Run([sample, "-f", "tiff"], output, error); + + Assert.Equal(1, exitCode); + Assert.Contains("Unsupported output format 'tiff'", error.ToString(), StringComparison.Ordinal); + } + + [Fact] + public void RunListsLayersOnTheOutputWriterWithoutRendering() + { + string repoRoot = SampleParityTests.FindRepoRoot(); + string sample = Path.Combine(repoRoot, "Samples", "6-57-1119.dxf"); + string outputPath = Path.Combine(Path.GetTempPath(), $"not-written-{Guid.NewGuid():N}.png"); + StringWriter output = new(); + StringWriter error = new(); + + int exitCode = Program.Run([sample, "--list-layers", "-o", outputPath], output, error); + + Assert.Equal(0, exitCode); + Assert.StartsWith("Layer", output.ToString(), StringComparison.Ordinal); + Assert.False(File.Exists(outputPath)); + } +``` + +Also delete `WriteLayerTableRendersOnlyTheDefaultLayer` (it exercised a fallback for a branch this task removes) if it only asserts the default-layer row; keep it if it asserts anything else and say so. + +- [ ] **Step 2: Run them and confirm they fail to compile** + +Run: `dotnet build ACadSharp.Image.Tests --nologo -v q` +Expected: CS0117 `Program` does not contain a definition for `Run`. + +- [ ] **Step 3: Refactor Program** + +In `ACadSharp.Image.Cli/Program.cs`: + +- Replace `public static int Main(string[] args)` and its body with: + +```csharp + public static int Main(string[] args) => Run(args, Console.Out, Console.Error); + + /// + /// Runs the tool with explicit writers so the output can be captured; passes the console. + /// + /// Command-line arguments. + /// Receives help, the layer table and the success line. + /// Receives reader and renderer notifications and the error line. + /// 0 on success, 1 on any handled error. + internal static int Run(string[] args, TextWriter output, TextWriter error) + { + try + { + if (args.Length == 0 || args.Any(IsHelpArgument)) + { + WriteHelp(output); + return 0; + } + ... (the existing body, with these substitutions) +``` + +Substitutions inside the body: `WriteLayerTable(document, Console.Out)` → `WriteLayerTable(document, output)`; `Console.WriteLine($"Generated ...")` → `output.WriteLine(...)`; `Console.Error.WriteLine($"Error: {ex.Message}")` and the `#if DEBUG` stack trace line → `error.WriteLine(...)`; `DxfReader.Read(inputPath, OnReaderNotification)` and `DwgReader.Read(inputPath, OnReaderNotification)` in `LoadDocument` → give `LoadDocument` a `TextWriter error` parameter and pass a lambda `(_, e) => OnReaderNotification(e, error)`; `exporter.Configuration.OnNotification += OnExporterNotification;` → `+= (_, e) => error.WriteLine($"render: {e.Message}");`. + +- Change `OnReaderNotification(object? sender, NotificationEventArgs e)` to `OnReaderNotification(NotificationEventArgs e, TextWriter error)` writing `error.WriteLine($"reader: {e.Message}")`; delete `OnExporterNotification`. +- Change `WriteHelp()` to `WriteHelp(TextWriter output)` and `Console.WriteLine("""...""")` to `output.WriteLine("""...""")`. +- In `WriteLayerTable`, delete the unreachable `if (layers.Count == 0) { writer.WriteLine("No layers."); return; }` block and add a one-line comment above the `layers` declaration: `// ACadSharp always keeps layer "0", so the table is never empty.` +- No other behaviour changes. `grep -n "Console\." ACadSharp.Image.Cli/Program.cs` must afterwards show only the `Main` line. + +- [ ] **Step 4: Class-level XML docs** + +Add a one-sentence `/// ` above each of these classes: `CliTests` ("Tests the CLI's argument parsing, format resolution, layer table and entry point through captured writers."), `ViewportParityTests` ("Renders a code-built sheet with one viewport and compares it with its PNG baseline and SVG golden."), `FeatureGoldenTests` ("Renders a block containing every primitive the sample goldens lack and compares it with its baseline and golden."), `SplineRendererTests` ("Drives splines through the dispatcher and checks which surface primitive they reach."), `TextRendererTests` ("Checks how text alignment and attachment map onto surface text runs."), `RenderedPageTests` ("Covers the rendered page encoders, format helpers, unit conversion and page entity filters."). + +- [ ] **Step 5: Build, focused tests, full suite, commit** + +Run: `dotnet build ACadSharp.Image.sln -warnaserror --nologo -v q && dotnet test ACadSharp.Image.Tests --nologo --filter "FullyQualifiedName~CliTests" && dotnet test ACadSharp.Image.sln --nologo -v q` +Expected: 0 warnings; CLI tests pass; full suite passes with no stray output lines from the CLI tests (the test run output should not contain "Usage:" or "Error:"). + +```bash +git add ACadSharp.Image.Cli/Program.cs ACadSharp.Image.Tests +git commit -m "Route CLI output through writers and document the new test classes" +``` diff --git a/docs/superpowers/plans/2026-09-03-06-remaining-fixes.md b/docs/superpowers/plans/2026-09-03-06-remaining-fixes.md new file mode 100644 index 0000000..26da068 --- /dev/null +++ b/docs/superpowers/plans/2026-09-03-06-remaining-fixes.md @@ -0,0 +1,403 @@ +# Remaining Fixes Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Clear the last known fixable items on `mubeda/svg-support`: the half-pixel rounding of raster viewport composites, single-line TEXT entities on non-default OCS planes, an untested frozen-insert case, and two CLI nits. + +**Architecture:** Two contained renderer changes (viewport composite fraction carried into the child context; TEXT origin and direction mapped through `OcsTransform` with MIRRTEXT=0 semantics for planes seen from behind), each test-first, plus tests and doc touch-ups. Only the synthetic viewport baseline may move. + +**Tech Stack:** .NET 10, ACadSharp 3.7.1, SixLabors.ImageSharp 3.1.12, xUnit. + +**Spec:** `docs/superpowers/specs/2026-09-02-layers-and-svg-design.md` (binding; Task 2 extends section 5.3's OCS bullet). + +## Global Constraints + +- ACadSharp `3.7.1`; SixLabors.ImageSharp `3.1.12`; no new NuGet dependencies; target frameworks unchanged. +- Coding conventions: `this.` prefix on instance members, explicit types except LINQ lambdas, XML docs on public and internal members, `sealed` classes, file-scoped namespaces, four-space indent, UTF-8 without BOM, LF line endings. +- Baselines: only Task 1 may regenerate a baseline, and only `ACadSharp.Image.Tests/Baselines/viewport-sheet.paper.01.png`, with the scoped command in the task. Every other baseline and golden stays byte-identical (`git status --short ACadSharp.Image.Tests/Baselines` must list only that file in Task 1 and nothing in Tasks 2 and 3). +- Parity tests need the font `DejaVu Sans` (installed). +- `dotnet build ACadSharp.Image.sln -warnaserror` warning-free; full suite green before each commit. +- Never use bare `git stash` / `git stash pop`. Commit messages end with the repository's two trailer lines (see any commit on this branch). + +## File Structure + +- Modify `ACadSharp.Image/Rendering/RasterDrawingSurface.cs` (`BeginViewport`, `EndViewport`), `ACadSharp.Image/Rendering/TextRenderer.cs`, `ACadSharp.Image.Cli/Program.cs`. +- Modify tests: `RasterDrawingSurfaceTests.cs`, `ViewportParityTests.cs` (probes only if the shifted baseline requires), `TextRendererTests.cs`, `LayerFilteringTests.cs`. +- Modify `README.md`, `docs/superpowers/specs/2026-09-02-layers-and-svg-design.md` (section 5.3). + +--- + +### Task 1: Raster viewport composites keep their sub-pixel position + +**Files:** +- Modify: `ACadSharp.Image/Rendering/RasterDrawingSurface.cs` (`BeginViewport` ~216-227, `EndViewport` ~229-239) +- Modify: `ACadSharp.Image.Tests/RasterDrawingSurfaceTests.cs` (append one test) +- Modify (only if a probe fails after regeneration): `ACadSharp.Image.Tests/ViewportParityTests.cs` +- Regenerate: `ACadSharp.Image.Tests/Baselines/viewport-sheet.paper.01.png` + +**Interfaces:** +- Consumes: `ViewportSurface(IDrawingSurface Surface, double OffsetX, double BottomY)`; `ImageRenderContext.CreateViewportContext` uses `surface.OffsetX` as `offsetX` and `surface.BottomY` as `surfaceHeight`, so `x = OffsetX + (p.X - OriginX) * Scale` and `y = BottomY - (p.Y - OriginY) * Scale`. +- Produces: no signature change. Behaviour: the child image is pasted at `floor(bounds.X), floor(bounds.Y)` and the fractional parts move into `OffsetX` / `BottomY`, so content keeps its exact page position; the image grows by up to one pixel to hold the fraction. + +Background: `EndViewport` pastes the child image at `MathF.Round(bounds.X/Y)`, so every raster viewport sits up to half a pixel from its exact position. The SVG surface is unaffected (it clips in place). + +- [ ] **Step 1: Write the failing test** + +Append to `ACadSharp.Image.Tests/RasterDrawingSurfaceTests.cs` (the file uses the alias `ImageColor` for `SixLabors.ImageSharp.Color`): + +```csharp + [Fact] + public void ViewportFractionalPositionIsCarriedIntoTheChildOffsets() + { + using Image canvas = new(20, 20, ImageColor.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. + ViewportSurface viewport = surface.BeginViewport(new SurfaceRect(3.6, 2.4, 10, 9.3)); + + Assert.Equal(0.6, viewport.OffsetX, 9); + Assert.Equal(2.4 - 2 + 9.3, viewport.BottomY, 9); + + // A vertical line on the child's own X offset must land in page column 3 (covering x 3.1..4.1), not column 4 alone. + viewport.Surface.DrawLine(new ImageStyle(ImageColor.Red, 1f), new SurfacePoint(viewport.OffsetX, 0), new SurfacePoint(viewport.OffsetX, viewport.BottomY)); + surface.EndViewport(viewport); + + Assert.True(canvas[3, 6].R > canvas[3, 6].G, $"column 3 should carry most of the line, got {canvas[3, 6]}"); + Assert.Equal(new Rgba32(255, 255, 255, 255), canvas[2, 6]); + Assert.Equal(new Rgba32(255, 255, 255, 255), canvas[6, 6]); + } +``` + +- [ ] **Step 2: Run it and confirm it fails** + +Run: `dotnet test ACadSharp.Image.Tests --nologo --filter "FullyQualifiedName~ViewportFractionalPositionIsCarried"` +Expected: FAIL on `Assert.Equal(0.6, viewport.OffsetX, 9)` (actual 0). + +- [ ] **Step 3: Carry the fraction** + +Replace `BeginViewport` and the destination computation in `EndViewport` in `ACadSharp.Image/Rendering/RasterDrawingSurface.cs`: + +```csharp + public ViewportSurface BeginViewport(SurfaceRect bounds) + { + // The child image can only be pasted at whole pixels. Its position is floored, the fractional remainder moves + // into the child's own offsets (so content keeps its exact page position), and the image grows to hold that + // remainder. The flip origin is the exact height: rounding it used to shift content and drop boundary geometry. + double left = Math.Floor(bounds.X); + double top = Math.Floor(bounds.Y); + double fractionX = bounds.X - left; + 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); + 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)); + return viewport; + } +``` + +and in `EndViewport` replace + +```csharp + ImagePoint destination = new((int)MathF.Round((float)entry.Bounds.X), (int)MathF.Round((float)entry.Bounds.Y)); +``` + +with + +```csharp + // Bounds were floored to whole pixels in BeginViewport. + ImagePoint destination = new((int)entry.Bounds.X, (int)entry.Bounds.Y); +``` + +If `_viewports` stores the bounds under a different tuple shape, keep its shape and store the floored rectangle. + +- [ ] **Step 4: Run the surface tests, then the full suite** + +Run: `dotnet test ACadSharp.Image.Tests --nologo --filter "FullyQualifiedName~RasterDrawingSurfaceTests"` +Expected: PASS (including the existing `ViewportFlipOriginIsTheExactHeightNotTheRoundedImageHeight`, whose bounds start at 0 so nothing changes for it). + +Run: `dotnet test ACadSharp.Image.sln --nologo -v q` +Expected: exactly one failure, `ViewportParityTests.SheetPngMatchesBaseline` (its sheet starts at x = 60.5, so the composite moves half a pixel). Any other failure means the change did more than intended: stop and report. + +- [ ] **Step 5: Regenerate the sheet baseline and check the probes** + +Run: `ACADSHARP_IMAGE_UPDATE_BASELINES=1 dotnet test ACadSharp.Image.Tests --nologo --filter "FullyQualifiedName~ViewportParityTests.SheetPngMatchesBaseline"` +Then: `git status --short ACadSharp.Image.Tests/Baselines` must list only `viewport-sheet.paper.01.png`. + +Quantify with Pillow (python3 available) against `git show HEAD:ACadSharp.Image.Tests/Baselines/viewport-sheet.paper.01.png` as in the previous plan: print changed-pixel count and bounding box. + +Run the parity test without the variable: `dotnet test ACadSharp.Image.Tests --nologo --filter "FullyQualifiedName~ViewportParityTests"`. If a pixel probe now fails (the left wall may have moved from column 171 to 172, and the bottom wall row may shift), read the pixels around the old probe in the new baseline, move the probe to the column/row that is now pure red, and explain the move in the report. Do not weaken the assertions (a probe must still demand a red pixel or a white window). + +Open the new PNG with the Read tool: same picture as before (closed red rectangle, green dashed line, red circle, title, frame line), shifted by less than a pixel. + +- [ ] **Step 6: Build, full suite, commit** + +Run: `dotnet build ACadSharp.Image.sln -warnaserror --nologo -v q && dotnet test ACadSharp.Image.sln --nologo -v q` +Expected: 0 warnings; all pass (253 tests). + +```bash +git add ACadSharp.Image/Rendering/RasterDrawingSurface.cs ACadSharp.Image.Tests/RasterDrawingSurfaceTests.cs ACadSharp.Image.Tests/ViewportParityTests.cs ACadSharp.Image.Tests/Baselines/viewport-sheet.paper.01.png +git commit -m "Keep the sub-pixel position of raster viewport composites" +``` +The commit body states the changed-pixel count and that only the synthetic sheet baseline moved. + +--- + +### Task 2: Single-line TEXT on non-default OCS planes + +**Files:** +- Modify: `ACadSharp.Image/Rendering/TextRenderer.cs` +- Modify: `ACadSharp.Image.Tests/TextRendererTests.cs` (append tests) +- Modify: `README.md` (~line 249), `docs/superpowers/specs/2026-09-02-layers-and-svg-design.md` (section 5.3 OCS bullet) + +**Interfaces:** +- Consumes: `OcsTransform.IsWorldPlane(XYZ)`, `OcsTransform.For(XYZ)`, `.ToWorldXY(x, y, z)`, `.ToWorld(x, y, z)` (returns `XYZ`), `.Normal`; `RecordingDrawingSurface.Texts`. +- Produces: `internal static (double Rotation, SurfaceTextAnchor Anchor) TextRenderer.ResolvePlacement(double rotation, SurfaceTextAnchor anchor, OcsTransform? toWorld)`. + +Background: DXF stores TEXT's insertion and alignment points in the entity's OCS and its rotation in that plane; MTEXT stores its insertion point and X-axis direction in WCS (ACadSharp derives `MText.Rotation` from that direction), so only `TextEntity` needs a transform. When a plane is seen from behind (normal Z below zero, the usual `(0,0,-1)` produced by MIRROR), AutoCAD with `MIRRTEXT = 0` (the default) keeps the glyphs readable and lets the run occupy the mirrored extent: the same baseline, read from the other end. That is what `ResolvePlacement` implements: the projected direction angle plus half a turn, with `Start` and `End` anchors swapped. Planes seen from the front keep their projected angle and anchor. + +- [ ] **Step 1: Write the failing tests** + +Append to `ACadSharp.Image.Tests/TextRendererTests.cs` (it has `Setup(scale)` returning `(Surface, Context, Dispatcher)` and uses the context mapping `(x, y) -> (x * scale, 100 - y * scale)`): + +```csharp + [Fact] + public void MirroredPlaneTextKeepsReadableGlyphsAndOccupiesTheMirroredExtent() + { + (RecordingDrawingSurface surface, ImageRenderContext context, EntityRenderDispatcher dispatcher) = Setup(); + TextEntity text = new() { Value = "M", InsertPoint = new XYZ(10, 20, 0), Height = 2, Normal = new XYZ(0, 0, -1) }; + + dispatcher.Draw(context, text); + + SurfaceText run = Assert.Single(surface.Texts); + // (0,0,-1) mirrors X: the origin lands at x = -10, and the run is anchored at its end so it extends toward -x. + Assert.Equal(-10d, run.Origin.X, 9); + Assert.Equal(100d - 20d, run.Origin.Y, 9); + Assert.Equal(SurfaceTextAnchor.End, run.Anchor); + Assert.Equal(1d, Math.Cos(run.Rotation), 9); // upright, MIRRTEXT = 0 semantics + Assert.Equal(0d, Math.Sin(run.Rotation), 9); + } + + [Fact] + public void MirroredPlaneRotationIsNegatedAndRightAlignmentBecomesStart() + { + (RecordingDrawingSurface surface, ImageRenderContext context, EntityRenderDispatcher dispatcher) = Setup(); + TextEntity text = new() { Value = "R", InsertPoint = new XYZ(1, 1, 0), AlignmentPoint = new XYZ(4, 1, 0), HorizontalAlignment = TextHorizontalAlignment.Right, Rotation = 0.5, Height = 2, Normal = new XYZ(0, 0, -1) }; + + dispatcher.Draw(context, text); + + SurfaceText run = Assert.Single(surface.Texts); + Assert.Equal(-4d, run.Origin.X, 9); + Assert.Equal(SurfaceTextAnchor.Start, run.Anchor); + Assert.Equal(Math.Cos(0.5), Math.Cos(run.Rotation), 9); + Assert.Equal(-Math.Sin(0.5), Math.Sin(run.Rotation), 9); + } + + [Fact] + public void FrontFacingTiltedPlaneProjectsOriginAndDirection() + { + (RecordingDrawingSurface surface, ImageRenderContext context, EntityRenderDispatcher dispatcher) = Setup(); + // Normal tilted toward +Y: the arbitrary axis algorithm makes the OCS X axis point along world -X. + TextEntity text = new() { Value = "T", InsertPoint = new XYZ(1, 0, 0), Height = 2, Normal = new XYZ(0, 0.6, 0.8) }; + + dispatcher.Draw(context, text); + + SurfaceText run = Assert.Single(surface.Texts); + Assert.Equal(-1d, run.Origin.X, 9); + Assert.Equal(SurfaceTextAnchor.Start, run.Anchor); // seen from the front: anchor unchanged + Assert.Equal(-1d, Math.Cos(run.Rotation), 9); // direction (1,0) in OCS is world -X + Assert.Equal(0d, Math.Sin(run.Rotation), 9); + } + + [Fact] + public void MiddleAnchorAndFixedLengthSurviveMirroring() + { + (RecordingDrawingSurface surface, ImageRenderContext context, EntityRenderDispatcher dispatcher) = Setup(scale: 2d); + TextEntity text = new() { Value = "F", InsertPoint = new XYZ(0, 0, 0), AlignmentPoint = new XYZ(30, 0, 0), HorizontalAlignment = TextHorizontalAlignment.Fit, Height = 5, Normal = new XYZ(0, 0, -1) }; + + dispatcher.Draw(context, text); + + SurfaceText run = Assert.Single(surface.Texts); + Assert.Equal(SurfaceTextAnchor.Middle, run.Anchor); + Assert.Equal(60d, run.FixedLength, 9); + Assert.Equal(-60d, run.Origin.X, 9); + } + + [Fact] + public void ResolvePlacementLeavesWorldPlaneTextAlone() + { + (double rotation, SurfaceTextAnchor anchor) = TextRenderer.ResolvePlacement(0.7, SurfaceTextAnchor.End, null); + + Assert.Equal(0.7, rotation); + Assert.Equal(SurfaceTextAnchor.End, anchor); + } +``` + +- [ ] **Step 2: Run them and confirm they fail** + +Run: `dotnet test ACadSharp.Image.Tests --nologo --filter "FullyQualifiedName~TextRendererTests"` +Expected: `ResolvePlacementLeavesWorldPlaneTextAlone` fails to compile (no `ResolvePlacement`); comment it out temporarily if needed to see the other four FAIL (origin X 10 instead of -10, etc.), then restore it. + +- [ ] **Step 3: Implement** + +In `ACadSharp.Image/Rendering/TextRenderer.cs`, change the `TextEntity` overload of `Draw` to: + +```csharp + public void Draw(ImageRenderContext context, ImageStyle style, TextEntity textEntity) + { + string text = NormalizeText(textEntity.Value); + if (string.IsNullOrWhiteSpace(text)) + { + return; + } + + // TEXT stores its points and rotation in its own OCS (MTEXT does not: its insertion point and X axis are WCS). + OcsTransform? toWorld = OcsTransform.IsWorldPlane(textEntity.Normal) ? null : OcsTransform.For(textEntity.Normal); + XYZ origin = GetTextOrigin(textEntity); + SurfacePoint surfaceOrigin = toWorld == null + ? context.ToSurfacePoint(origin) + : context.ToSurfacePoint(toWorld.ToWorldXY(origin.X, origin.Y, origin.Z)); + (double rotation, SurfaceTextAnchor anchor) = ResolvePlacement(textEntity.Rotation, GetAnchor(textEntity.HorizontalAlignment), toWorld); + + SurfaceText run = new( + text, + surfaceOrigin, + context.ToSurfaceLength(textEntity.Height), + rotation, + anchor, + GetBaseline(textEntity.VerticalAlignment), + WrappingWidth: -1d, + LineSpacingFactor: 1d, + GetFixedLength(context, textEntity)); + context.Surface.DrawText(style, run); + } + + /// + /// Maps a TEXT entity's in-plane rotation and anchor onto the page. + /// + /// Rotation in the entity's OCS, radians. + /// Anchor derived from the horizontal alignment. + /// The OCS frame, or null for the world plane. + /// The rotation to draw with (radians, drawing convention) and the anchor to use. + /// + /// The OCS X direction rotated by is projected onto world XY. A plane seen from the + /// front keeps that direction. A plane seen from behind (normal Z below zero, what MIRROR produces) would show the + /// glyphs mirrored; AutoCAD with MIRRTEXT = 0, its default, keeps them readable and lets the run occupy the + /// mirrored extent instead, which is the same baseline read from the other end: half a turn added to the projected + /// direction, and and swapped. + /// + internal static (double Rotation, SurfaceTextAnchor Anchor) ResolvePlacement(double rotation, SurfaceTextAnchor anchor, OcsTransform? toWorld) + { + if (toWorld == null) + { + return (rotation, anchor); + } + + XYZ direction = toWorld.ToWorld(Math.Cos(rotation), Math.Sin(rotation), 0d); + double projected = Math.Atan2(direction.Y, direction.X); + if (toWorld.Normal.Z >= 0d) + { + return (projected, anchor); + } + + SurfaceTextAnchor flipped = anchor switch + { + SurfaceTextAnchor.Start => SurfaceTextAnchor.End, + SurfaceTextAnchor.End => SurfaceTextAnchor.Start, + _ => anchor, + }; + return (projected + Math.PI, flipped); + } +``` + +`GetTextOrigin` stays as it is (it picks the OCS point; the transform is applied afterwards). Add `using CSMath;` if the file lacks it (it already uses `XYZ`). + +- [ ] **Step 4: Run the text tests and the full suite** + +Run: `dotnet test ACadSharp.Image.Tests --nologo --filter "FullyQualifiedName~TextRendererTests"` +Expected: PASS (all, including the pre-existing ones, which use the default normal). + +Run: `dotnet test ACadSharp.Image.sln --nologo -v q` +Expected: all pass; no baseline changes. A baseline change would mean a sample contains TEXT with a non-default normal: stop and report rather than regenerate. + +- [ ] **Step 5: Document** + +`README.md`: replace the sentence "Text and dimension entities are not transformed (their normals are ignored), which is a known limitation." with "Single-line TEXT on another plane is placed the way AutoCAD shows it with `MIRRTEXT = 0`: readable glyphs occupying the mirrored extent. MTEXT and dimension geometry are already world coordinates in DXF and need no transform." + +Spec section 5.3, at the end of the amended OCS bullet, replace "`TextEntity`/`MText` normals remain ignored." with "`TextEntity` points are OCS too (remaining fix, 2026-09-03): the origin goes through `OcsTransform`, and `TextRenderer.ResolvePlacement` projects the rotation, adding half a turn and swapping Start/End anchors for planes seen from behind (`MIRRTEXT = 0` semantics). `MText` insertion point and X axis are WCS in DXF and are used as stored." + +- [ ] **Step 6: Build, full suite, commit** + +Run: `dotnet build ACadSharp.Image.sln -warnaserror --nologo -v q && dotnet test ACadSharp.Image.sln --nologo -v q` +Expected: 0 warnings; all pass (258 tests). + +```bash +git add ACadSharp.Image/Rendering/TextRenderer.cs ACadSharp.Image.Tests/TextRendererTests.cs README.md docs/superpowers/specs/2026-09-02-layers-and-svg-design.md +git commit -m "Place single-line TEXT on non-default OCS planes" +``` + +--- + +### Task 3: Frozen-insert inheritance test and CLI nits + +**Files:** +- Modify: `ACadSharp.Image.Tests/LayerFilteringTests.cs` (append one test) +- Modify: `ACadSharp.Image.Cli/Program.cs` (`Main` XML doc, `var stopwatch`) + +**Interfaces:** +- Consumes: `LayerFilteringTests.Setup(Action?, Viewport?)` returning `(Surface, Dispatcher, Context)`, `Drawn(surface)` counting `DrawLine` calls, `RecordingDrawingSurface.Entities`. + +- [ ] **Step 1: Write the test** + +Append to `ACadSharp.Image.Tests/LayerFilteringTests.cs`: + +```csharp + [Fact] + public void FrozenInsertLayerHidesItsContentsAndVisibleInsertShowsLayerZeroContents() + { + Layer frozen = new("Doors") { Flags = LayerFlags.Frozen }; + Layer visible = new("Windows"); + Layer frozenOwn = new("Hardware") { Flags = LayerFlags.Frozen }; + + static BlockRecord Symbol(Layer own) + { + BlockRecord block = new(Guid.NewGuid().ToString("N")); + block.Entities.Add(new Line(new XYZ(0, 0, 0), new XYZ(1, 0, 0)) { Layer = new Layer(Layer.DefaultName) }); + block.Entities.Add(new Line(new XYZ(0, 0, 0), new XYZ(0, 1, 0)) { Layer = own }); + return block; + } + + (RecordingDrawingSurface surface, EntityRenderDispatcher dispatcher, ImageRenderContext context) = Setup(c => c.LayerVisibility = LayerVisibilityMode.Screen); + + // Insert on a frozen layer: nothing inside is drawn, not even the entity on its own visible layer. + dispatcher.Draw(context, new Insert(Symbol(visible)) { Layer = frozen }); + Assert.Equal(0, Drawn(surface)); + + // Insert on a visible layer: the layer-0 line inherits that layer and is drawn; the line on its own frozen layer is not. + dispatcher.Draw(context, new Insert(Symbol(frozenOwn)) { Layer = visible }); + Assert.Equal(1, Drawn(surface)); + Assert.Equal("Windows", surface.Entities.Last(e => e.EntityType == "LINE").LayerName); + } +``` + +If `LayerFlags` needs a `using ACadSharp.Tables;` it is already imported (the file uses `Layer`); if `Flags` is not settable, use `IsOn = false` on the frozen layers instead and say so in the report (Screen mode honours both). + +- [ ] **Step 2: Run it** + +Run: `dotnet test ACadSharp.Image.Tests --nologo --filter "FullyQualifiedName~FrozenInsertLayerHidesItsContents"` +Expected: PASS (this describes existing behaviour). If it fails, report the actual counts; do not change the renderer. + +- [ ] **Step 3: CLI nits** + +In `ACadSharp.Image.Cli/Program.cs`: add `/// Entry point: runs the tool against the console.` above `Main`, and change `var stopwatch = System.Diagnostics.Stopwatch.StartNew();` to `System.Diagnostics.Stopwatch stopwatch = System.Diagnostics.Stopwatch.StartNew();`. + +- [ ] **Step 4: Build, full suite, commit** + +Run: `dotnet build ACadSharp.Image.sln -warnaserror --nologo -v q && dotnet test ACadSharp.Image.sln --nologo -v q` +Expected: 0 warnings; all pass (259 tests); no baseline changes. + +```bash +git add ACadSharp.Image.Tests/LayerFilteringTests.cs ACadSharp.Image.Cli/Program.cs +git commit -m "Test frozen inserts with layer-0 contents and tidy the CLI entry point" +``` diff --git a/docs/superpowers/plans/2026-09-03-07-text-fidelity.md b/docs/superpowers/plans/2026-09-03-07-text-fidelity.md new file mode 100644 index 0000000..b9e26c2 --- /dev/null +++ b/docs/superpowers/plans/2026-09-03-07-text-fidelity.md @@ -0,0 +1,511 @@ +# Text Fidelity Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make SVG text match the raster (and the CAD intent) on real drawings: decode `\U+XXXX` escapes, resolve a sensible fallback font, size SVG text like the raster, anchor multi-line MTEXT by its block, and wrap MTEXT at its rectangle width. + +**Architecture:** Three small tasks. Text normalisation gains unicode-escape decoding (both backends). A shared `FontResolver` gives the raster a deterministic fallback chain and lets the SVG surface measure text with the same font family. `SvgDrawingSurface.DrawText` converts the CAD height to an em size with the factor the raster already applies implicitly, offsets multi-line blocks for the Central and Alphabetic baselines, and wraps at `WrappingWidth` with measured advances. Raster output is unchanged except the fallback font on machines without the configured family. + +**Tech Stack:** .NET 10, ACadSharp 3.7.1, SixLabors.ImageSharp 3.1.12 / Drawing 2.1.7 / Fonts 2.1.3 (`TextMeasurer`), xUnit. + +**Spec:** `docs/superpowers/specs/2026-09-02-layers-and-svg-design.md` (binding; section 5.3 "Text" bullet amended by Task 3). + +## Global Constraints + +- ACadSharp `3.7.1`; SixLabors packages as pinned; no new NuGet dependencies; target frameworks unchanged. +- Coding conventions: `this.` prefix on instance members, explicit types except LINQ lambdas, XML docs on public and internal members, `sealed` classes, file-scoped namespaces, four-space indent, UTF-8 without BOM, LF line endings. +- PNG baselines must stay byte-identical in every task (the parity tests pin `DejaVu Sans`, which is installed, so the fallback chain never engages there). SVG goldens: Tasks 1 and 2 must not change any; Task 3 regenerates exactly the goldens that contain ` + /// The MTEXT's text with formatting stripped. Unicode escapes are decoded before ACadSharp strips the formatting, + /// because its PlainText drops the backslash of \U+XXXX and would leave the literal code behind. + /// + 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 ReferenceEquals(decoded, value) || decoded == value ? mtext.PlainText : new MText { Value = decoded }.PlainText; + } + + /// + /// Applies the DXF text codes: \U+XXXX code points, %%C diameter, %%D degree, %%P + /// plus-minus, %%% percent, the %%U/%%O underline and overline toggles (dropped), and + /// \P paragraph breaks. + /// + internal static string NormalizeText(string? value) + { + if (string.IsNullOrEmpty(value)) + { + return string.Empty; + } + + string text = UnicodeEscape.Replace(value, m => ((char)Convert.ToInt32(m.Groups[1].Value, 16)).ToString()); + return text + .Replace("%%%", "\u0001", StringComparison.Ordinal) + .Replace("%%C", "Ø", StringComparison.OrdinalIgnoreCase) + .Replace("%%D", "°", StringComparison.OrdinalIgnoreCase) + .Replace("%%P", "±", StringComparison.OrdinalIgnoreCase) + .Replace("%%U", string.Empty, StringComparison.OrdinalIgnoreCase) + .Replace("%%O", string.Empty, StringComparison.OrdinalIgnoreCase) + .Replace("\u0001", "%", StringComparison.Ordinal) + .Replace("\\P", "\n", StringComparison.OrdinalIgnoreCase); + } +``` + +Add `using System.Text.RegularExpressions;`. In the MTEXT `Draw` overload replace `NormalizeText(mtext.PlainText)` with `NormalizeText(PlainTextOf(mtext))`. (The `\u0001` placeholder never survives: it is replaced back before returning; `SvgXmlText.Clean` would drop it otherwise.) + +- [ ] **Step 4: Run tests, full suite, commit** + +Run: `dotnet test ACadSharp.Image.Tests --nologo --filter "FullyQualifiedName~TextRendererTests"` → PASS. +Run: `dotnet build ACadSharp.Image.sln -warnaserror --nologo -v q && dotnet test ACadSharp.Image.sln --nologo -v q` → all pass; `git status --short ACadSharp.Image.Tests/Baselines` empty (if a golden changes because a sample contains such an escape, stop and report which; do not regenerate in this task). + +```bash +git add ACadSharp.Image/Rendering/TextRenderer.cs ACadSharp.Image.Tests/TextRendererTests.cs +git commit -m "Decode Unicode escapes and percent codes in text" +``` + +--- + +### Task 2: Deterministic fallback font + +**Files:** +- Create: `ACadSharp.Image/Rendering/FontResolver.cs` +- Modify: `ACadSharp.Image/Rendering/RasterDrawingSurface.cs` (`CreateFont`) +- Create: `ACadSharp.Image.Tests/FontResolverTests.cs` + +**Interfaces:** +- Produces: `internal static class FontResolver { static FontFamily Resolve(string? familyName); static Font Create(string? familyName, float size); static readonly string[] Fallbacks; }`. + +Background: when the configured family (default `Arial`) is not installed, the raster takes `SystemFonts.Families.First()`, whatever sorts first on the machine (a decorative face on this one). The SVG font stack is `Arial, Helvetica, sans-serif`, which fontconfig maps to Liberation Sans; the raster should follow the same intent. + +- [ ] **Step 1: Write the failing test** + +Create `ACadSharp.Image.Tests/FontResolverTests.cs`: + +```csharp +using ACadSharp.Image.Rendering; +using SixLabors.Fonts; + +namespace ACadSharp.Image.Tests; + +/// Checks that missing font families fall back along the documented chain rather than to an arbitrary face. +public sealed class FontResolverTests +{ + [Fact] + public void InstalledFamilyIsUsedAsIs() + { + Assert.True(SystemFonts.TryGet("DejaVu Sans", out _), "DejaVu Sans must be installed for this test."); + + Assert.Equal("DejaVu Sans", FontResolver.Resolve("DejaVu Sans").Name); + Assert.Equal(12f, FontResolver.Create("DejaVu Sans", 12f).Size); + } + + [Fact] + public void MissingFamilyFallsBackAlongTheChain() + { + FontFamily family = FontResolver.Resolve("No Such Family 4711"); + + string[] chain = FontResolver.Fallbacks; + string? firstInstalled = chain.FirstOrDefault(name => SystemFonts.TryGet(name, out _)); + if (firstInstalled != null) + { + Assert.Equal(firstInstalled, family.Name); + } + else + { + Assert.Equal(SystemFonts.Families.First().Name, family.Name); + } + } + + [Fact] + public void NullOrBlankFamilyUsesTheChain() + { + Assert.Equal(FontResolver.Resolve("No Such Family 4711").Name, FontResolver.Resolve(null).Name); + Assert.Equal(FontResolver.Resolve("No Such Family 4711").Name, FontResolver.Resolve(" ").Name); + } +} +``` + +- [ ] **Step 2: Run and confirm it fails to compile** + +Run: `dotnet build ACadSharp.Image.Tests --nologo -v q` → CS0103/CS0246 for `FontResolver`. + +- [ ] **Step 3: Implement** + +Create `ACadSharp.Image/Rendering/FontResolver.cs`: + +```csharp +using SixLabors.Fonts; + +namespace ACadSharp.Image.Rendering; + +/// +/// Resolves the configured font family to an installed one. When the configured family is missing, the fallback chain +/// mirrors the SVG font stack (Arial, Helvetica, sans-serif): metric-compatible Liberation Sans first, then the +/// common Linux and Windows sans faces, and only then the first installed family. +/// +internal static class FontResolver +{ + /// Families tried, in order, when the configured one is not installed. + public static readonly string[] Fallbacks = ["Liberation Sans", "DejaVu Sans", "Arial", "Helvetica", "Noto Sans", "Segoe UI"]; + + /// + /// Finds the installed family for a configured name. + /// + /// The configured family, or null/blank for the fallback chain. + /// The configured family when installed, otherwise the first installed fallback, otherwise the first installed family. + public static FontFamily Resolve(string? familyName) + { + if (!string.IsNullOrWhiteSpace(familyName) && SystemFonts.TryGet(familyName, out FontFamily configured)) + { + return configured; + } + + foreach (string fallback in Fallbacks) + { + if (SystemFonts.TryGet(fallback, out FontFamily family)) + { + return family; + } + } + + return SystemFonts.Families.First(); + } + + /// + /// Creates a font of the given size from the resolved family. + /// + /// The configured family. + /// Font size in points. + /// The font. + public static Font Create(string? familyName, float size) + { + return Resolve(familyName).CreateFont(Math.Max(1f, size)); + } +} +``` + +In `RasterDrawingSurface.CreateFont` replace the body with `return FontResolver.Create(this._configuration.FontFamilyName, (float)height);` and keep the method (or inline it at its call sites and delete it; either is fine, say which). + +- [ ] **Step 4: Run tests, full suite, commit** + +Run: `dotnet test ACadSharp.Image.Tests --nologo --filter "FullyQualifiedName~FontResolverTests"` → PASS. +Run: `dotnet build ACadSharp.Image.sln -warnaserror --nologo -v q && dotnet test ACadSharp.Image.sln --nologo -v q` → all pass; no baseline change (the parity tests pin DejaVu Sans; the feature and viewport tests too). + +```bash +git add ACadSharp.Image/Rendering/FontResolver.cs ACadSharp.Image/Rendering/RasterDrawingSurface.cs ACadSharp.Image.Tests/FontResolverTests.cs +git commit -m "Resolve missing font families along a deterministic fallback chain" +``` + +--- + +### Task 3: SVG text sized, anchored and wrapped like the raster + +**Files:** +- Create: `ACadSharp.Image/Rendering/Svg/SvgTextLayout.cs` +- Modify: `ACadSharp.Image/Rendering/Svg/SvgDrawingSurface.cs` (`DrawText`) +- Modify: `ACadSharp.Image.Tests/SvgDrawingSurfaceTests.cs`, `ACadSharp.Image.Tests/SvgExportTests.cs` (one assertion each, see below) +- Modify: `docs/superpowers/specs/2026-09-02-layers-and-svg-design.md` (5.3 Text bullet), `README.md` +- Regenerate: the four SVG goldens that contain text (see Global Constraints) + +**Interfaces:** +- Consumes: `FontResolver.Create(string?, float)` (Task 2), `SixLabors.Fonts.TextMeasurer.MeasureAdvance(string, TextOptions)`. +- Produces: `internal static class SvgTextLayout { const double CapHeightToEm = 4d / 3d; static double EmSize(double capHeight); static double LineHeight(double capHeight, double lineSpacingFactor); static double BlockOffset(int lineCount, double lineHeight, SurfaceTextBaseline baseline); static IReadOnlyList Wrap(string text, double wrappingWidth, double emSize, string? fontFamily); }`. + +Background (measured): `SurfaceText.Height` is the CAD text height, which is the cap height. The raster creates a font of that size in points and renders at `ImageConfiguration.Dpi` (96 by default), so its em size is `Height × 96/72`, giving a cap height close to the CAD height. The SVG wrote `font-size = Height`, so its glyphs were 25% smaller than the raster's. Multi-line MTEXT: the raster (like AutoCAD) positions the whole block by the attachment point; the SVG put the first line's baseline there. Wrapping: the raster wraps at `WrappingWidth`; SVG has no automatic wrapping, so wrapped labels came out on one line. + +- [ ] **Step 1: Write the failing tests** + +Append to `ACadSharp.Image.Tests/SvgDrawingSurfaceTests.cs`: + +```csharp + [Fact] + public void FontSizeIsTheEmForTheCadCapHeight() + { + using SvgDrawingSurface surface = CreateSurface(); + surface.BeginEntity(Entity("Anno", "TEXT"), Layer("Anno")); + surface.DrawText(new ImageStyle(Color.Black, 1f), new SurfaceText("H", new SurfacePoint(0, 0), 3, 0, SurfaceTextAnchor.Start, SurfaceTextBaseline.Alphabetic, -1, 1, 0)); + surface.EndEntity(); + + XElement text = Assert.Single(surface.ToDocument().Descendants(Ns + "text")); + Assert.Equal("4", (string?)text.Attribute("font-size")); // 3 × 4/3 + } + + [Fact] + public void MultiLineBlocksAreAnchoredByTheirBaseline() + { + using SvgDrawingSurface surface = CreateSurface(); + ImageStyle style = new(Color.Black, 1f); + surface.BeginEntity(Entity("Anno", "MTEXT"), Layer("Anno")); + surface.DrawText(style, new SurfaceText("a\nb\nc", new SurfacePoint(10, 50), 3, 0, SurfaceTextAnchor.Start, SurfaceTextBaseline.Central, -1, 1, 0)); + surface.DrawText(style, new SurfaceText("a\nb", new SurfacePoint(10, 50), 3, 0, SurfaceTextAnchor.Start, SurfaceTextBaseline.Alphabetic, -1, 1, 0)); + surface.DrawText(style, new SurfaceText("a\nb", new SurfacePoint(10, 50), 3, 0, SurfaceTextAnchor.Start, SurfaceTextBaseline.Hanging, -1, 1, 0)); + surface.EndEntity(); + + List texts = surface.ToDocument().Descendants(Ns + "text").ToList(); + // Line height is 5/3 of the cap height: 5. Central: three lines, first line one line height above the origin. + Assert.Equal("45", (string?)texts[0].Attribute("y")); + // Alphabetic (bottom): two lines, first line one line height above. + Assert.Equal("45", (string?)texts[1].Attribute("y")); + // Hanging (top): first line at the origin. + Assert.Equal("50", (string?)texts[2].Attribute("y")); + Assert.Equal(["a", "b", "c"], texts[0].Elements(Ns + "tspan").Select(t => t.Value).ToArray()); + Assert.Equal("5", (string?)texts[0].Elements(Ns + "tspan").ElementAt(1).Attribute("dy")); + } + + [Fact] + public void TextIsWrappedAtTheWrappingWidth() + { + using SvgDrawingSurface surface = CreateSurface(c => c.FontFamilyName = "DejaVu Sans"); + surface.BeginEntity(Entity("Anno", "MTEXT"), Layer("Anno")); + // Width 14 at cap height 3 (em 4) fits roughly five to six characters of DejaVu Sans per line. + surface.DrawText(new ImageStyle(Color.Black, 1f), new SurfaceText("alpha beta gamma delta", new SurfacePoint(0, 0), 3, 0, SurfaceTextAnchor.Start, SurfaceTextBaseline.Hanging, 14, 1, 0)); + surface.EndEntity(); + + XElement text = Assert.Single(surface.ToDocument().Descendants(Ns + "text")); + string[] lines = text.Elements(Ns + "tspan").Select(t => t.Value).ToArray(); + Assert.Equal(["alpha", "beta", "gamma", "delta"], lines); + } + + [Fact] + public void WrapKeepsExplicitBreaksAndLongWords() + { + IReadOnlyList lines = SvgTextLayout.Wrap("one two\nthree fourfivesixseven", 8, 4, "DejaVu Sans"); + + Assert.Equal("one", lines[0]); + Assert.Equal("two", lines[1]); + Assert.Equal("three", lines[2]); + Assert.Equal("fourfivesixseven", lines[3]); // a single word wider than the width stays on its own line + Assert.Equal(["x"], SvgTextLayout.Wrap("x", -1, 4, "DejaVu Sans")); + } +``` + +In `ACadSharp.Image.Tests/SvgExportTests.cs`, find the assertion on `font-size` if one exists (search for `font-size`) and update it to the new value (height × 4/3); if none exists, add nothing. + +- [ ] **Step 2: Run and confirm they fail** + +Run: `dotnet test ACadSharp.Image.Tests --nologo --filter "FullyQualifiedName~SvgDrawingSurfaceTests"` → the new tests fail (compile error for `SvgTextLayout`, then font-size "3", y "50", one tspan). + +- [ ] **Step 3: Implement the layout helper** + +Create `ACadSharp.Image/Rendering/Svg/SvgTextLayout.cs`: + +```csharp +using SixLabors.Fonts; + +namespace ACadSharp.Image.Rendering.Svg; + +/// +/// Text metrics for the SVG backend, chosen to match the raster backend and the CAD intent. +/// +/// +/// SurfaceText.Height is the CAD text height, which is the cap height. The raster backend creates a font of that +/// size in points and renders at 96 dpi, so its em size is 4/3 of the cap height, and common sans faces have a cap +/// height of about 0.72 em; the SVG uses the same factor so both outputs agree. Line spacing follows AutoCAD: 5/3 of +/// the text height per line at spacing factor 1. +/// +internal static class SvgTextLayout +{ + /// Em size per unit of cap height. + public const double CapHeightToEm = 4d / 3d; + + /// Font size (em) for a CAD text height. + public static double EmSize(double capHeight) => capHeight * CapHeightToEm; + + /// Distance between consecutive baselines. + public static double LineHeight(double capHeight, double lineSpacingFactor) => + capHeight * (lineSpacingFactor <= 0 ? 1d : lineSpacingFactor) * 5d / 3d; + + /// + /// Offset of the first line's baseline from the anchor, in surface units (negative is up), so the whole block hangs + /// from, is centred on, or stands on the anchor the way the CAD attachment point says. + /// + public static double BlockOffset(int lineCount, double lineHeight, SurfaceTextBaseline baseline) => baseline switch + { + SurfaceTextBaseline.Central => -(lineCount - 1) * lineHeight / 2d, + SurfaceTextBaseline.Alphabetic => -(lineCount - 1) * lineHeight, + _ => 0d, + }; + + /// + /// Splits text into lines: explicit line breaks always break; when is positive, + /// words are added greedily while the measured advance fits. A single word wider than the width stays alone. + /// + /// Text with \n for explicit breaks. + /// Available width in surface units, or a non-positive value for no wrapping. + /// Font size in surface units. + /// Configured family, resolved through for measuring. + /// The lines, never empty. + public static IReadOnlyList Wrap(string text, double wrappingWidth, double emSize, string? fontFamily) + { + string[] paragraphs = text.Replace("\r\n", "\n").Split('\n'); + if (wrappingWidth <= 0d || emSize <= 0d) + { + return paragraphs; + } + + // Points at 72 dpi are surface units, so the measured advance is directly comparable with the width. + TextOptions options = new(FontResolver.Create(fontFamily, (float)emSize)) { Dpi = 72f }; + List lines = new(); + foreach (string paragraph in paragraphs) + { + string[] words = paragraph.Split(' ', StringSplitOptions.RemoveEmptyEntries); + if (words.Length == 0) + { + lines.Add(string.Empty); + continue; + } + + string current = words[0]; + for (int i = 1; i < words.Length; i++) + { + string candidate = current + " " + words[i]; + if (TextMeasurer.MeasureAdvance(candidate, options).Width <= wrappingWidth) + { + current = candidate; + } + else + { + lines.Add(current); + current = words[i]; + } + } + + lines.Add(current); + } + + return lines; + } +} +``` + +`FontResolver` lives in `ACadSharp.Image.Rendering`; add `using ACadSharp.Image.Rendering;` if the namespace differs. + +- [ ] **Step 4: Use it in `SvgDrawingSurface.DrawText`** + +Replace the element construction and the lines block: + +```csharp + double emSize = SvgTextLayout.EmSize(text.Height); + IReadOnlyList lines = SvgTextLayout.Wrap(SvgXmlText.Clean(text.Text), text.WrappingWidth, emSize, this._configuration.FontFamilyName); + double lineHeight = SvgTextLayout.LineHeight(text.Height, text.LineSpacingFactor); + double firstLineY = text.Origin.Y + SvgTextLayout.BlockOffset(lines.Count, lineHeight, text.Baseline); + + XElement element = new(Ns + "text", + new XAttribute("x", this.N(text.Origin.X)), + new XAttribute("y", this.N(firstLineY)), + new XAttribute("font-size", this.N(emSize))); +``` + +Keep the anchor, baseline, rotation (still about `text.Origin`, so a shifted block rotates around its anchor) and `textLength` attributes as they are. Then: + +```csharp + if (lines.Count == 1) + { + element.Add(lines[0]); + } + else + { + for (int i = 0; i < lines.Count; i++) + { + XElement span = new(Ns + "tspan", new XAttribute("x", this.N(text.Origin.X)), lines[i]); + if (i > 0) + { + span.Add(new XAttribute("dy", this.N(lineHeight))); + } + + element.Add(span); + } + } +``` + +- [ ] **Step 5: Run the surface tests, then the suite; regenerate the text goldens** + +Run: `dotnet test ACadSharp.Image.Tests --nologo --filter "FullyQualifiedName~SvgDrawingSurfaceTests"` → PASS (fix any pre-existing assertion in that file that hard-coded the old `font-size` or `y`, and say so). +Run: `dotnet test ACadSharp.Image.sln --nologo -v q` → expected failures: exactly the SVG golden comparisons for `6-57-1119.dxf`, `HSK80AHCP16190M_BMG.dwg` (model), `FeatureGoldenTests.FeatureSvgMatchesGoldenAndContainsEveryPrimitive`, `ViewportParityTests.SheetSvgMatchesGoldenAndClipsTheViewport`, plus possibly `SvgExportTests` assertions on `font-size`. No PNG may fail. Anything else: stop and report. + +Regenerate only the text goldens: +```bash +ACADSHARP_IMAGE_UPDATE_BASELINES=1 dotnet test ACadSharp.Image.Tests --nologo --filter "FullyQualifiedName~SampleParityTests.SampleSvgsMatchGoldens|FullyQualifiedName~FeatureGoldenTests.FeatureSvgMatchesGolden|FullyQualifiedName~ViewportParityTests.SheetSvgMatchesGolden" +git status --short ACadSharp.Image.Tests/Baselines +``` +The status must list only the four text goldens (the Subaru golden has no text and must come out identical; if it changes, stop and report). Inspect `git diff --stat` of the goldens and confirm the changes are `font-size` values, `y` values on multi-line texts, and added `tspan`s, nothing else. Run the suite again without the variable → all pass. + +- [ ] **Step 6: Document** + +Spec section 5.3, Text bullet: append "**Amended 2026-09-03 (text fidelity):** `font-size` is 4/3 of the CAD text height (the cap height), matching the raster backend, which renders points at 96 dpi; multi-line blocks are offset so the whole block hangs from, is centred on, or stands on the anchor (Hanging/Central/Alphabetic); MTEXT with a rectangle width is wrapped greedily with advances measured by SixLabors.Fonts through `FontResolver`, so lines break where the raster breaks them. `\U+XXXX` escapes and `%%` codes are decoded for both backends." +README, after the SVG fidelity sentence: "SVG text is sized and wrapped to match the PNG output; glyph shapes still depend on the viewer's fonts." + +- [ ] **Step 7: Build, full suite, commit** + +Run: `dotnet build ACadSharp.Image.sln -warnaserror --nologo -v q && dotnet test ACadSharp.Image.sln --nologo -v q` → 0 warnings; all pass. + +```bash +git add ACadSharp.Image/Rendering/Svg/SvgTextLayout.cs ACadSharp.Image/Rendering/Svg/SvgDrawingSurface.cs ACadSharp.Image.Tests/SvgDrawingSurfaceTests.cs ACadSharp.Image.Tests/SvgExportTests.cs ACadSharp.Image.Tests/Baselines/*.svg docs/superpowers/specs/2026-09-02-layers-and-svg-design.md README.md +git commit -m "Size, anchor and wrap SVG text like the raster backend" +``` +The commit body lists the four regenerated goldens and the cause. diff --git a/docs/superpowers/plans/2026-09-03-08-remaining-entities.md b/docs/superpowers/plans/2026-09-03-08-remaining-entities.md new file mode 100644 index 0000000..d915bd8 --- /dev/null +++ b/docs/superpowers/plans/2026-09-03-08-remaining-entities.md @@ -0,0 +1,1377 @@ +# Remaining Entities and Raster Text Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Close the limitations recorded in `docs/research/remaining-rendering-limitations.md`: draw 3DFACE, LEADER, MLINE and WIPEOUT entities and block attributes on both backends, respect the drawing's draw order, make the explode pairing observable, and make raster text independent of `ImageConfiguration.Dpi` with a correct line-spacing compensation. + +**Architecture:** Every new entity type becomes one `case` in `EntityRenderDispatcher.Draw` plus one private `Draw` helper that projects CAD geometry to `SurfacePoint`s and calls the existing backend-neutral `IDrawingSurface` primitives (`DrawPolyline`, `DrawCubicBezier`, `FillPolygon`, `DrawLine`), so both PNG and SVG gain each entity at once. Block attributes reuse the TEXT pipeline. The raster surface sizes text in ems at a fixed 72 dpi like the SVG backend already does. + +**Tech Stack:** .NET 8/10, ACadSharp 3.7.1, SixLabors.ImageSharp 3.1.12 / Drawing 2.1.7 / Fonts 2.1.3, xUnit. + +**Spec:** `docs/superpowers/specs/2026-09-02-layers-and-svg-design.md` (binding). This plan adds section "4.6 Additional entities (2026-09-03)" and amends 5.3 "Text"; the research note `docs/research/remaining-rendering-limitations.md` is the argument, not the authority. Where the research note and this plan disagree (the WIPEOUT mapping and the MLINE cut parameters), this plan wins; the reasons are given in the tasks. + +## Global Constraints + +- ACadSharp `3.7.1`; SixLabors packages as pinned; no new NuGet dependencies; target frameworks unchanged. +- Coding conventions: `this.` prefix on instance members, explicit types except LINQ lambdas, XML docs on public and internal members (private helpers get a `` when their name does not say it all), `sealed` classes, file-scoped namespaces, four-space indent, UTF-8 without BOM, LF line endings. +- PNG baselines and SVG goldens in `ACadSharp.Image.Tests/Baselines/` must stay byte-identical in Tasks 2 to 6 (no sample or synthetic drawing contains the entity types they add). Task 1 and Task 7 may move baselines only as described in those tasks, regenerated with the scoped commands given there, with the cause in the commit body. +- `dotnet build ACadSharp.Image.sln -warnaserror` warning-free; full suite green before each commit (`dotnet test ACadSharp.Image.sln --configuration Release -warnaserror`). +- No reference to any drawing outside `Samples/` in code, tests, comments or commit messages. +- Never use bare `git stash` / `git stash pop`. Commit messages end with the repository's two trailer lines (see any commit on this branch). +- New notifications use the existing `ImageConfiguration.Notify(message, NotificationType[, exception])` and the message shape `[{entity.SubclassMarker}] ...`. +- Tests that need a fixed handle use the reflection helper pattern from `EntityRenderDispatcherTests.WithHandle` (`CadObject.Handle` has an internal setter in 3.7.1). +- In `EntityRenderDispatcherTests.CreateContext` the surface is 100x100 with scale 1 and no offset, so a CAD point `(x, y)` lands at `SurfacePoint(x, 100 - y)`. + +## File Structure + +- Modify `ACadSharp.Image/ImagePage.cs` (`Add`), `ACadSharp.Image/ImageExporter.cs` (layout page loop): draw order. +- Modify `ACadSharp.Image/Rendering/EntityRenderDispatcher.cs`: new `case` arms and helpers `DrawFace3D`, `DrawLeader`, `DrawMLine`, `DrawWipeout`, `DrawAttributes`, `IsAttributeVisible`; `DrawBlockContents` changes. +- Modify `ACadSharp.Image/Rendering/RasterDrawingSurface.cs` (`DrawText`, `CreateFont`). +- Tests: new `ACadSharp.Image.Tests/ImagePageTests.cs`; extend `EntityRenderDispatcherTests.cs`, `RasterDrawingSurfaceTests.cs`. +- Docs: spec new section 4.6 and 5.3 amendment; `README.md` gets a "Supported entities" section; `docs/research/remaining-rendering-limitations.md` gets a status line per topic. + +--- + +### Task 1: Draw pages in the drawing's sort order + +**Files:** +- Modify: `ACadSharp.Image/ImagePage.cs:89-110` +- Modify: `ACadSharp.Image/ImageExporter.cs:110` +- Create: `ACadSharp.Image.Tests/ImagePageTests.cs` +- Modify: `README.md`, spec section 4.6 (new) + +**Interfaces:** +- Consumes: `BlockRecord.GetSortedEntities()` (ACadSharp 3.7.1: `IEnumerable`, entities ordered by handle, then by the block's `SortEntitiesTable` when present). +- Produces: nothing new; `ImagePage.Entities` order changes. + +Background: `ImagePage.Add` and the layout loop in `ImageExporter` enumerate `block.Entities` (file order). AutoCAD draws by handle order overridden by the DRAWORDER table; ACadSharp exposes exactly that as `GetSortedEntities()`. Later entities paint over earlier ones, which is what a WIPEOUT (Task 6) relies on. + +- [ ] **Step 1: Write the failing test** + +Create `ACadSharp.Image.Tests/ImagePageTests.cs`: + +```csharp +using System.Reflection; +using ACadSharp; +using ACadSharp.Entities; +using ACadSharp.Tables; +using CSMath; + +namespace ACadSharp.Image.Tests; + +public sealed class ImagePageTests +{ + private static T WithHandle(T entity, ulong handle) + where T : CadObject + { + typeof(CadObject).GetProperty(nameof(CadObject.Handle))!.SetValue(entity, handle); + return entity; + } + + [Fact] + public void AddOrdersEntitiesByHandleNotByInsertionOrder() + { + BlockRecord block = new("ORDER"); + Line later = WithHandle(new Line(new XYZ(0, 0, 0), new XYZ(1, 0, 0)), 0x20); + Line earlier = WithHandle(new Line(new XYZ(0, 0, 0), new XYZ(0, 1, 0)), 0x10); + block.Entities.Add(later); + block.Entities.Add(earlier); + + ImagePage page = new(); + page.Add(block, resizeLayout: false); + + Assert.Equal([0x10UL, 0x20UL], page.Entities.Select(e => e.Handle)); + } + + [Fact] + public void AddWithFilterKeepsTheSortedOrder() + { + BlockRecord block = new("ORDER"); + block.Entities.Add(WithHandle(new Line(new XYZ(0, 0, 0), new XYZ(1, 0, 0)), 0x30)); + block.Entities.Add(WithHandle(new Circle { Center = new XYZ(0, 0, 0), Radius = 1 }, 0x20)); + block.Entities.Add(WithHandle(new Line(new XYZ(0, 0, 0), new XYZ(0, 1, 0)), 0x10)); + + ImagePage page = new(); + page.Add(block, e => e is Line, resizeLayout: false); + + Assert.Equal([0x10UL, 0x30UL], page.Entities.Select(e => e.Handle)); + } +} +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `dotnet test ACadSharp.Image.Tests --filter "FullyQualifiedName~ImagePageTests"` +Expected: both FAIL (order is 0x20, 0x10 and 0x30, 0x10). + +- [ ] **Step 3: Switch the two loops and the exporter to the sorted enumeration** + +In `ImagePage.Add(BlockRecord, Func?, bool)` replace both `foreach (Entity entity in block.Entities)` with `foreach (Entity entity in block.GetSortedEntities())` and add to the method's ``: "Entities are added in the drawing's draw order (handle order, overridden by the block's DRAWORDER table), so later entities paint over earlier ones on both backends." + +In `ImageExporter.cs:110` replace `layout.AssociatedBlock.Entities` with `layout.AssociatedBlock.GetSortedEntities()`. + +- [ ] **Step 4: Run the new tests, then the whole suite** + +Run: `dotnet test ACadSharp.Image.Tests --filter "FullyQualifiedName~ImagePageTests"` → PASS. +Run: `dotnet test ACadSharp.Image.sln --configuration Release -warnaserror`. + +If a PNG baseline or SVG golden fails, the sample's file order differs from its handle order. Then: (a) for each failing golden, diff the SVG text (`git diff --no-index` against a regenerated copy) and confirm the only change is element order inside `` groups (SVG) or overlap pixels (PNG); (b) regenerate exactly the failing baselines with the scoped command, for example `ACADSHARP_IMAGE_UPDATE_BASELINES=1 dotnet test ACadSharp.Image.Tests --filter "FullyQualifiedName~SampleParityTests.SampleSvgsMatchGoldens"`; (c) list each regenerated file with the cause in the commit body. If a change is not explainable by ordering, stop and report. + +- [ ] **Step 5: Docs** + +Spec: append a new section after 4.5: + +```markdown +### 4.6 Additional entities (2026-09-03) + +- Draw order: pages enumerate `BlockRecord.GetSortedEntities()` (handle order, then the DRAWORDER `SortEntitiesTable`), not file order, on both backends. +``` + +README: after the "Layer visibility" paragraphs (around line 234) add a section: + +```markdown +### Supported entities + +Lines, arcs, circles, ellipses, polylines (2D, 3D, lightweight, with bulges), splines, points, solids, hatches (solid and pattern), TEXT, MTEXT, dimensions, block references and paper-space viewports. Entities are drawn in the drawing's draw order (handle order overridden by DRAWORDER), so later entities paint over earlier ones. +``` + +- [ ] **Step 6: Commit** + +```bash +git add ACadSharp.Image/ImagePage.cs ACadSharp.Image/ImageExporter.cs ACadSharp.Image.Tests/ImagePageTests.cs README.md docs/superpowers/specs/2026-09-02-layers-and-svg-design.md +git commit -m "Draw page entities in the drawing's sort order" +``` + +--- + +### Task 2: Draw 3DFACE edges + +**Files:** +- Modify: `ACadSharp.Image/Rendering/EntityRenderDispatcher.cs` (switch in `Draw`, new `DrawFace3D` next to `DrawSolid`) +- Modify: `ACadSharp.Image.Tests/EntityRenderDispatcherTests.cs` +- Modify: `README.md` ("Supported entities"), spec 4.6 + +**Interfaces:** +- Consumes: `ACadSharp.Entities.Face3D` (`XYZ FirstCorner/SecondCorner/ThirdCorner/FourthCorner`, `InvisibleEdgeFlags Flags` with `None=0, First=1, Second=2, Third=4, Fourth=8`). Corners are WCS; no OCS step. +- Produces: `private static void DrawFace3D(ImageRenderContext context, ImageStyle style, Face3D face)`. + +Background: edge n joins corner n to corner n+1; edge 4 closes corner 4 back to corner 1. A triangle repeats the third corner as the fourth (DXF reference), so its closing edge is edge 4 (flag `Fourth`) and edge 3 is degenerate. A 3DFACE is a wireframe primitive in a plan view: it is stroked, never filled. + +- [ ] **Step 1: Write the failing tests** + +Append to `EntityRenderDispatcherTests`: + +```csharp + [Fact] + public void Face3DWithAllEdgesVisibleIsOneClosedPolyline() + { + RecordingDrawingSurface surface = new(); + ImageConfiguration configuration = new(); + Face3D face = new() + { + FirstCorner = new XYZ(0, 0, 0), + SecondCorner = new XYZ(10, 0, 0), + ThirdCorner = new XYZ(10, 10, 0), + FourthCorner = new XYZ(0, 10, 0), + }; + + new EntityRenderDispatcher(configuration).Draw(CreateContext(surface, configuration), face); + + Assert.Equal(["DrawPolyline n=4 closed=True"], surface.Calls.Where(c => c.StartsWith("Draw", StringComparison.Ordinal))); + Assert.Equal(new SurfacePoint(0, 100), surface.Polylines[0][0]); + Assert.Equal(new SurfacePoint(0, 90), surface.Polylines[0][3]); + Assert.Empty(surface.Polygons); + } + + [Fact] + public void Face3DSkipsInvisibleEdgesAndKeepsTheVisibleRunsJoined() + { + RecordingDrawingSurface surface = new(); + ImageConfiguration configuration = new(); + // Edges 2 (10,0)->(10,10) and 4 (0,10)->(0,0) hidden: two separate open edges remain. + Face3D face = new() + { + FirstCorner = new XYZ(0, 0, 0), + SecondCorner = new XYZ(10, 0, 0), + ThirdCorner = new XYZ(10, 10, 0), + FourthCorner = new XYZ(0, 10, 0), + Flags = InvisibleEdgeFlags.Second | InvisibleEdgeFlags.Fourth, + }; + + new EntityRenderDispatcher(configuration).Draw(CreateContext(surface, configuration), face); + + Assert.Equal(2, surface.Polylines.Count); + Assert.All(surface.Calls.Where(c => c.StartsWith("DrawPolyline", StringComparison.Ordinal)), c => Assert.EndsWith("closed=False", c)); + Assert.Contains(surface.Polylines, p => p.SequenceEqual([new SurfacePoint(10, 90), new SurfacePoint(0, 90)])); + Assert.Contains(surface.Polylines, p => p.SequenceEqual([new SurfacePoint(0, 100), new SurfacePoint(10, 100)])); + } + + [Fact] + public void Face3DWithOneHiddenEdgeIsOneOpenRunOfThreeEdges() + { + RecordingDrawingSurface surface = new(); + ImageConfiguration configuration = new(); + Face3D face = new() + { + FirstCorner = new XYZ(0, 0, 0), + SecondCorner = new XYZ(10, 0, 0), + ThirdCorner = new XYZ(10, 10, 0), + FourthCorner = new XYZ(0, 10, 0), + Flags = InvisibleEdgeFlags.Third, + }; + + new EntityRenderDispatcher(configuration).Draw(CreateContext(surface, configuration), face); + + IReadOnlyList run = Assert.Single(surface.Polylines); + // Starts after the hidden edge: 4 -> 1 -> 2 -> 3. + Assert.Equal([new SurfacePoint(0, 90), new SurfacePoint(0, 100), new SurfacePoint(10, 100), new SurfacePoint(10, 90)], run); + } + + [Fact] + public void TriangularFace3DDropsTheDegenerateEdge() + { + RecordingDrawingSurface surface = new(); + ImageConfiguration configuration = new(); + Face3D face = new() + { + FirstCorner = new XYZ(0, 0, 0), + SecondCorner = new XYZ(10, 0, 0), + ThirdCorner = new XYZ(10, 10, 0), + FourthCorner = new XYZ(10, 10, 0), + }; + + new EntityRenderDispatcher(configuration).Draw(CreateContext(surface, configuration), face); + + Assert.Equal(["DrawPolyline n=3 closed=True"], surface.Calls.Where(c => c.StartsWith("Draw", StringComparison.Ordinal))); + } +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `dotnet test ACadSharp.Image.Tests --filter "FullyQualifiedName~Face3D"` +Expected: FAIL (no `DrawPolyline` call; a `NotImplemented` notification is raised instead). + +- [ ] **Step 3: Implement** + +Add `case Face3D face: DrawFace3D(context, style, face); break;` immediately after the `case Solid solid:` arm, and this helper after `DrawSolid`: + +```csharp + /// + /// A 3DFACE is stroked edge by edge in plan view: edge n joins corner n to corner n+1 and edge 4 closes the ring; + /// a triangle repeats its third corner, which makes edge 3 degenerate. Hidden edges (the invisible-edge flags) + /// split the ring into open runs. Corners are world coordinates, so there is no OCS step. + /// + private static void DrawFace3D(ImageRenderContext context, ImageStyle style, Face3D face) + { + bool triangle = face.FourthCorner.Equals(face.ThirdCorner); + XYZ[] corners = triangle + ? [face.FirstCorner, face.SecondCorner, face.ThirdCorner] + : [face.FirstCorner, face.SecondCorner, face.ThirdCorner, face.FourthCorner]; + bool[] hidden = triangle + ? [face.Flags.HasFlag(InvisibleEdgeFlags.First), face.Flags.HasFlag(InvisibleEdgeFlags.Second), face.Flags.HasFlag(InvisibleEdgeFlags.Fourth)] + : [face.Flags.HasFlag(InvisibleEdgeFlags.First), face.Flags.HasFlag(InvisibleEdgeFlags.Second), face.Flags.HasFlag(InvisibleEdgeFlags.Third), face.Flags.HasFlag(InvisibleEdgeFlags.Fourth)]; + + int count = corners.Length; + int firstHidden = Array.IndexOf(hidden, true); + if (firstHidden < 0) + { + context.Surface.DrawPolyline(style, corners.Select(context.ToSurfacePoint).ToArray(), true); + return; + } + + // Start just after a hidden edge so no visible run wraps around the ring. + List run = new(count + 1); + for (int step = 1; step <= count; step++) + { + int edge = (firstHidden + step) % count; + if (hidden[edge]) + { + Flush(); + continue; + } + + if (run.Count == 0) + { + run.Add(context.ToSurfacePoint(corners[edge])); + } + + run.Add(context.ToSurfacePoint(corners[(edge + 1) % count])); + } + + Flush(); + + void Flush() + { + if (run.Count >= 2) + { + context.Surface.DrawPolyline(style, run.ToArray(), false); + } + + run.Clear(); + } + } +``` + +Also extend `HasFiniteGeometry` with `Face3D face => IsFinite(face.FirstCorner) && IsFinite(face.SecondCorner) && IsFinite(face.ThirdCorner) && IsFinite(face.FourthCorner),` before the `_ => true` arm. + +- [ ] **Step 4: Run the tests and the suite** + +Run: `dotnet test ACadSharp.Image.Tests --filter "FullyQualifiedName~Face3D"` → PASS. +Run: `dotnet test ACadSharp.Image.sln --configuration Release -warnaserror` → green, `git status --short ACadSharp.Image.Tests/Baselines` empty. + +- [ ] **Step 5: Docs** + +Spec 4.6, add bullet: "3DFACE (`Face3D`): visible edges stroked as polylines (one closed polygon when no edge is hidden; a triangle repeats its third corner and drops the degenerate edge). Never filled. Corners are WCS." README "Supported entities": add "3D faces (edges, honouring invisible-edge flags)". + +- [ ] **Step 6: Commit** + +```bash +git add ACadSharp.Image/Rendering/EntityRenderDispatcher.cs ACadSharp.Image.Tests/EntityRenderDispatcherTests.cs README.md docs/superpowers/specs/2026-09-02-layers-and-svg-design.md +git commit -m "Draw 3DFACE edges on both backends" +``` + +--- + +### Task 3: Block attributes, ATTDEF suppression and an explode-count check + +**Files:** +- Modify: `ACadSharp.Image/Rendering/EntityRenderDispatcher.cs` (`DrawBlockContents`, new `DrawAttributes`, `IsAttributeVisible`) +- Modify: `ACadSharp.Image.Tests/EntityRenderDispatcherTests.cs` +- Modify: `README.md`, spec 4.6 + +**Interfaces:** +- Consumes: `Insert.Attributes` (`SeqendCollection`), `AttributeEntity : AttributeBase : TextEntity` with `AttributeFlags Flags` (`Hidden=1, Constant=2`), `AttributeDefinition : AttributeBase`, `CadHeader.AttributeVisibility` (`AttributeVisibilityMode.None/Normal/All`), `ImageConfiguration.LayerVisibility`. +- Produces: `private void DrawAttributes(ImageRenderContext context, Insert insert, Layer? layer, ResolvedStyle parent)`, `private bool IsAttributeVisible(AttributeEntity attribute, Insert insert)`. + +Background: ATTRIB points are absolute coordinates in the attribute's own OCS (DXF: `AcDbText` then `AcDbAttribute`), so the TEXT pipeline renders them with `placement: null`. `Insert.Explode()` also yields the block's ATTDEFs, whose default value is currently drawn for every insert; AutoCAD shows only `Constant` ATTDEFs. The clone/original pairing relies on `Explode()` yielding one clone per block entity; a count mismatch after a package upgrade must become a warning, not silent misplacement. Rule for ATTMODE: like entity invisibility, it is ignored under `LayerVisibilityMode.All`; under `Screen`/`Plot`, `None` hides every attribute, `Normal` hides attributes flagged `Hidden`, `All` shows them all. Multi-line attributes are drawn through the TEXT path from their `Value` (recorded limitation). + +- [ ] **Step 1: Write the failing tests** + +Append to `EntityRenderDispatcherTests`: + +```csharp + private static Insert InsertWithAttribute(string value, AttributeFlags flags, out BlockRecord block) + { + block = new BlockRecord("TAGGED"); + block.Entities.Add(new AttributeDefinition { Tag = "ROOM", Value = "DEFAULT", InsertPoint = new XYZ(1, 1, 0), Height = 2, Flags = flags }); + // Insert(BlockRecord) creates one AttributeEntity per ATTDEF at the identity transform; place it explicitly. + Insert insert = new(block) { InsertPoint = new XYZ(10, 0, 0) }; + AttributeEntity attribute = Assert.Single(insert.Attributes); + attribute.Value = value; + attribute.InsertPoint = new XYZ(15, 5, 0); + attribute.Flags = flags; + return insert; + } + + [Fact] + public void InsertDrawsItsAttributesAndNotTheDefinitionDefaults() + { + RecordingDrawingSurface surface = new(); + ImageConfiguration configuration = new(); + Insert insert = WithHandle(InsertWithAttribute("A-101", AttributeFlags.None, out _), 0xAB); + + new EntityRenderDispatcher(configuration).Draw(CreateContext(surface, configuration), insert); + + SurfaceText text = Assert.Single(surface.Texts); + Assert.Equal("A-101", text.Text); + Assert.Equal(new SurfacePoint(15, 95), text.Origin); + Assert.DoesNotContain(surface.Texts, t => t.Text == "DEFAULT"); + EntityRenderInfo info = surface.Entities.Single(e => e.EntityType == insert.Attributes.First().ObjectName); + Assert.Equal(0xABUL, info.ParentHandle); + Assert.Equal("TAGGED", info.BlockName); + } + + [Fact] + public void ConstantAttributeDefinitionsAreStillDrawn() + { + RecordingDrawingSurface surface = new(); + ImageConfiguration configuration = new(); + 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) }; + + new EntityRenderDispatcher(configuration).Draw(CreateContext(surface, configuration), insert); + + Assert.Contains(surface.Texts, t => t.Text == "ACME"); + } + + [Theory] + [InlineData(LayerVisibilityMode.All, AttributeVisibilityMode.Normal, true)] + [InlineData(LayerVisibilityMode.Screen, AttributeVisibilityMode.Normal, false)] + [InlineData(LayerVisibilityMode.Screen, AttributeVisibilityMode.All, true)] + [InlineData(LayerVisibilityMode.Screen, AttributeVisibilityMode.None, false)] + public void HiddenAttributesFollowAttmodeUnlessEverythingIsShown(LayerVisibilityMode layerMode, AttributeVisibilityMode attmode, bool drawn) + { + RecordingDrawingSurface surface = new(); + ImageConfiguration configuration = new() { LayerVisibility = layerMode }; + Insert insert = InsertWithAttribute("SECRET", AttributeFlags.Hidden, out BlockRecord block); + CadDocument document = new(); + document.Header.AttributeVisibility = attmode; + document.BlockRecords.Add(block); + document.Entities.Add(insert); + + new EntityRenderDispatcher(configuration).Draw(CreateContext(surface, configuration), insert); + + Assert.Equal(drawn, surface.Texts.Any(t => t.Text == "SECRET")); + } + + [Fact] + public void VisibleAttributeIsDrawnUnderNormalAttmode() + { + RecordingDrawingSurface surface = new(); + ImageConfiguration configuration = new() { LayerVisibility = LayerVisibilityMode.Screen }; + Insert insert = InsertWithAttribute("SHOWN", AttributeFlags.None, out BlockRecord block); + CadDocument document = new(); + document.BlockRecords.Add(block); + document.Entities.Add(insert); + + new EntityRenderDispatcher(configuration).Draw(CreateContext(surface, configuration), insert); + + Assert.Contains(surface.Texts, t => t.Text == "SHOWN"); + } +``` + +If `document.BlockRecords.Add(block)` throws because `document.Entities.Add(insert)` registers the block itself, drop that line and note it in the report. + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `dotnet test ACadSharp.Image.Tests --filter "FullyQualifiedName~Attribute"` +Expected: the first test FAILS because "DEFAULT" is drawn and "A-101" is not; the theory FAILS for the `true` rows; `ConstantAttributeDefinitionsAreStillDrawn` may already pass. + +- [ ] **Step 3: Implement** + +Replace `DrawBlockContents` with: + +```csharp + private void DrawBlockContents(ImageRenderContext context, Insert insert, Layer? layer, ResolvedStyle parent) + { + // 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 + // 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() ?? (IReadOnlyList)Array.Empty(); + int index = 0; + foreach (Entity entity in insert.Explode()) + { + Entity? original = index < originals.Count ? originals[index] : null; + index++; + if (entity is AttributeDefinition definition && !definition.Flags.HasFlag(AttributeFlags.Constant)) + { + // A non-constant ATTDEF is a template: the insert's ATTRIB carries the value that is actually shown. + continue; + } + + NormalizeExplodedClone(entity); + bool placeText = original is TextEntity or MText && original.GetType() == entity.GetType(); + this.Draw(context, entity, layer, insert.Handle, insert.Block?.Name, parent, placeText ? original : null, placeText ? transform : null); + } + + if (index != originals.Count) + { + this._configuration.Notify( + $"[{insert.SubclassMarker}] Handle {insert.Handle.ToString("X", CultureInfo.InvariantCulture)}: block '{insert.Block?.Name}' exploded into {index} entities but holds {originals.Count}; text inside it may be misplaced.", + NotificationType.Warning); + } + + this.DrawAttributes(context, insert, layer, parent); + } + + /// + /// ATTRIB entities store absolute coordinates in their own OCS (the insert's transform is already applied by + /// the writer), so they go through the TEXT pipeline with no placement. Multi-line attributes are drawn from + /// their single-line value. + /// + private void DrawAttributes(ImageRenderContext context, Insert insert, Layer? layer, ResolvedStyle parent) + { + foreach (AttributeEntity attribute in insert.Attributes) + { + if (this.IsAttributeVisible(attribute, insert)) + { + this.Draw(context, attribute, layer, insert.Handle, insert.Block?.Name, parent); + } + } + } + + /// + /// ATTMODE and the attribute's Hidden flag are drawing-visibility state, ignored under + /// like entity invisibility; otherwise None hides every attribute, + /// Normal hides the ones flagged Hidden and All shows them all. + /// + private bool IsAttributeVisible(AttributeEntity attribute, Insert insert) + { + if (this._configuration.LayerVisibility == LayerVisibilityMode.All) + { + return true; + } + + AttributeVisibilityMode mode = insert.Document?.Header.AttributeVisibility ?? AttributeVisibilityMode.Normal; + return mode switch + { + AttributeVisibilityMode.None => false, + AttributeVisibilityMode.All => true, + _ => !attribute.Flags.HasFlag(AttributeFlags.Hidden), + }; + } +``` + +Add `using ACadSharp.Header;` for `AttributeVisibilityMode`. + +- [ ] **Step 4: Run the tests and the suite** + +Run: `dotnet test ACadSharp.Image.Tests --filter "FullyQualifiedName~Attribute"` → PASS. +Run: `dotnet test ACadSharp.Image.sln --configuration Release -warnaserror` → green; baselines unchanged (the synthetic `features` insert has no attributes and no ATTDEF). + +- [ ] **Step 5: Docs** + +Spec 4.6 bullets: "Block attributes: `Insert.Attributes` are drawn through the TEXT pipeline with no placement (ATTRIB coordinates are absolute); non-constant ATTDEFs yielded by `Explode()` are skipped, constant ones drawn. ATTMODE (`CadHeader.AttributeVisibility`) and the Hidden flag are ignored under `LayerVisibilityMode.All`, otherwise None hides all, Normal hides Hidden, All shows all. Multi-line attributes are drawn from their single-line value (limitation)." and "Explode pairing: when `Explode()` yields a different number of entities than the block holds, a Warning is raised." README "Supported entities": add "block attributes (ATTRIB; hidden ones follow ATTMODE under `Screen`/`Plot`)". README layer visibility paragraph (line ~234): append "Hidden block attributes and the drawing's ATTMODE are honoured in the same two modes." + +- [ ] **Step 6: Commit** + +```bash +git add ACadSharp.Image/Rendering/EntityRenderDispatcher.cs ACadSharp.Image.Tests/EntityRenderDispatcherTests.cs README.md docs/superpowers/specs/2026-09-02-layers-and-svg-design.md +git commit -m "Draw block attributes and skip attribute definition templates" +``` + +--- + +### Task 4: Draw LEADER paths and arrowheads + +**Files:** +- Modify: `ACadSharp.Image/Rendering/EntityRenderDispatcher.cs` (switch, new `DrawLeader`, `CatmullRomToBezier`) +- Modify: `ACadSharp.Image.Tests/EntityRenderDispatcherTests.cs` +- Modify: `README.md`, spec 4.6 + +**Interfaces:** +- Consumes: `Leader` (`List Vertices` in WCS, `bool ArrowHeadEnabled`, `LeaderPathType PathType` (`StraightLineSegments=0, Spline=1`), `DimensionStyle Style` never null with `double ArrowSize` (default 0.18), `double ScaleFactor` (default 1), `BlockRecord? LeaderArrow`), `IDrawingSurface.DrawCubicBezier(style, controlPoints, closed)` where `controlPoints` is `1 + 3n` points (both surfaces implement it; the raster tessellates). +- Produces: `private void DrawLeader(ImageRenderContext context, ImageStyle style, Leader leader)`, `internal static SurfacePoint[] CatmullRomToBezier(IReadOnlyList points)`. + +Background: the hookline is already the last stored vertex, and the annotation is a separate entity drawn on its own, so a leader is just its path plus an optional arrowhead at the first vertex pointing away from the second. AutoCAD's default closed filled arrowhead is an isosceles triangle of length DIMASZ×DIMSCALE and base width one third of that. Splined leaders use their vertices as fit points; a uniform Catmull-Rom spline through them, converted to cubic Béziers, gives the SVG a real `` and the raster a smooth tessellation without a warning. + +- [ ] **Step 1: Write the failing tests** + +Append to `EntityRenderDispatcherTests`: + +```csharp + [Fact] + public void StraightLeaderIsOneOpenPolylineWithoutArrow() + { + RecordingDrawingSurface surface = new(); + ImageConfiguration configuration = new(); + Leader leader = new() { Vertices = { new XYZ(0, 0, 0), new XYZ(10, 10, 0), new XYZ(20, 10, 0) } }; + + new EntityRenderDispatcher(configuration).Draw(CreateContext(surface, configuration), leader); + + Assert.Equal(["DrawPolyline n=3 closed=False"], surface.Calls.Where(c => c.StartsWith("Draw", StringComparison.Ordinal) || c.StartsWith("Fill", StringComparison.Ordinal))); + Assert.Equal([new SurfacePoint(0, 100), new SurfacePoint(10, 90), new SurfacePoint(20, 90)], surface.Polylines[0]); + } + + [Fact] + public void LeaderArrowheadIsAFilledTriangleAtTheFirstVertex() + { + RecordingDrawingSurface surface = new(); + ImageConfiguration configuration = new(); + // A fresh style: DimensionStyle.Default may be shared, and tests run in parallel. + Leader leader = new() { ArrowHeadEnabled = true, Vertices = { new XYZ(0, 0, 0), new XYZ(30, 0, 0) }, Style = new DimensionStyle("ARROW") { ArrowSize = 6, ScaleFactor = 2 } }; + + new EntityRenderDispatcher(configuration).Draw(CreateContext(surface, configuration), leader); + + IReadOnlyList arrow = Assert.Single(surface.Polygons); + Assert.Equal(3, arrow.Count); + Assert.Equal(new SurfacePoint(0, 100), arrow[0]); + // Length 6 * 2 = 12 along +X (away from the second vertex), half-width 12 / 6 = 2. + Assert.Contains(arrow, p => Math.Abs(p.X - 12) < 1e-9 && Math.Abs(p.Y - 98) < 1e-9); + Assert.Contains(arrow, p => Math.Abs(p.X - 12) < 1e-9 && Math.Abs(p.Y - 102) < 1e-9); + } + + [Fact] + public void SplinedLeaderIsACubicBezierChainThroughItsVertices() + { + RecordingDrawingSurface surface = new(); + ImageConfiguration configuration = new(); + Leader leader = new() { PathType = LeaderPathType.Spline, Vertices = { new XYZ(0, 0, 0), new XYZ(10, 10, 0), new XYZ(20, 0, 0), new XYZ(30, 10, 0) } }; + + new EntityRenderDispatcher(configuration).Draw(CreateContext(surface, configuration), leader); + + Assert.Equal(["DrawCubicBezier n=10 closed=False"], surface.Calls.Where(c => c.StartsWith("Draw", StringComparison.Ordinal))); + } + + [Fact] + public void CatmullRomControlPointsInterpolateTheInputPoints() + { + SurfacePoint[] points = [new(0, 0), new(10, 10), new(20, 0)]; + + SurfacePoint[] controls = EntityRenderDispatcher.CatmullRomToBezier(points); + + Assert.Equal(7, controls.Length); + Assert.Equal(points[0], controls[0]); + Assert.Equal(points[1], controls[3]); + Assert.Equal(points[2], controls[6]); + // Interior tangent at (10,10) is (P2 - P0) / 6 = (20, 0) / 6. + Assert.Equal(new SurfacePoint(10 - 20d / 6d, 10), controls[2]); + Assert.Equal(new SurfacePoint(10 + 20d / 6d, 10), controls[4]); + } + + [Fact] + public void LeaderWithCustomArrowBlockFallsBackToTheDefaultArrowWithANotification() + { + RecordingDrawingSurface surface = new(); + ImageConfiguration configuration = new(); + List notifications = new(); + configuration.OnNotification += (_, e) => notifications.Add(e); + Leader leader = new() { ArrowHeadEnabled = true, Vertices = { new XYZ(0, 0, 0), new XYZ(30, 0, 0) }, Style = new DimensionStyle("DOTTED") { LeaderArrow = new BlockRecord("_DOT") } }; + + new EntityRenderDispatcher(configuration).Draw(CreateContext(surface, configuration), leader); + + Assert.Single(surface.Polygons); + Assert.Contains(notifications, n => n.NotificationType == NotificationType.NotImplemented && n.Message.Contains("_DOT", StringComparison.Ordinal)); + } +``` + +Check the property names on `NotificationEventArgs` (`Message`, `NotificationType`) against `ACadSharp.Image/NotificationEventArgs.cs` and adjust the assertion if they differ. + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `dotnet test ACadSharp.Image.Tests --filter "FullyQualifiedName~Leader|FullyQualifiedName~CatmullRom"` +Expected: FAIL (compile error for `CatmullRomToBezier`, then no draw calls). + +- [ ] **Step 3: Implement** + +Add `case Leader leader: this.DrawLeader(context, style, leader); break;` after the `Dimension` arm, and: + +```csharp + /// + /// A leader is its stored path (the hookline is already the last vertex; the annotation is a separate entity) + /// plus, when enabled, AutoCAD's default closed filled arrowhead at the first vertex: an isosceles triangle + /// DIMASZ x DIMSCALE long and a third of that wide. A splined leader runs a Catmull-Rom curve through its + /// vertices. Custom arrowhead blocks fall back to the default triangle with a notification. + /// + private void DrawLeader(ImageRenderContext context, ImageStyle style, Leader leader) + { + if (leader.Vertices.Count < 2) + { + return; + } + + SurfacePoint[] points = leader.Vertices.Select(context.ToSurfacePoint).ToArray(); + if (leader.PathType == LeaderPathType.Spline && points.Length > 2) + { + context.Surface.DrawCubicBezier(style, CatmullRomToBezier(points), false); + } + else + { + context.Surface.DrawPolyline(style, points, false); + } + + if (!leader.ArrowHeadEnabled) + { + return; + } + + if (leader.Style.LeaderArrow != null) + { + this._configuration.Notify($"[{leader.SubclassMarker}] Arrowhead block '{leader.Style.LeaderArrow.Name}' is not rendered; the default closed arrow is drawn instead.", NotificationType.NotImplemented); + } + + 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(); + double length = direction.GetLength(); + if (size <= 0d || length <= 0d) + { + return; + } + + direction /= length; + XY baseCenter = tip - (direction * size); + XY half = new XY(-direction.Y, direction.X) * (size / 6d); + context.Surface.FillPolygon(style, [context.ToSurfacePoint(tip), context.ToSurfacePoint(baseCenter + half), context.ToSurfacePoint(baseCenter - half)]); + } + + /// + /// Control points (1 + 3n) of the cubic Bézier chain equivalent to a uniform Catmull-Rom spline through + /// , with the end tangents clamped by repeating the end points. + /// + internal static SurfacePoint[] CatmullRomToBezier(IReadOnlyList points) + { + int segments = points.Count - 1; + SurfacePoint[] controls = new SurfacePoint[(segments * 3) + 1]; + controls[0] = points[0]; + for (int i = 0; i < segments; i++) + { + SurfacePoint previous = points[Math.Max(i - 1, 0)]; + SurfacePoint start = points[i]; + SurfacePoint end = points[i + 1]; + SurfacePoint next = points[Math.Min(i + 2, points.Count - 1)]; + controls[(3 * i) + 1] = new SurfacePoint(start.X + ((end.X - previous.X) / 6d), start.Y + ((end.Y - previous.Y) / 6d)); + controls[(3 * i) + 2] = new SurfacePoint(end.X - ((next.X - start.X) / 6d), end.Y - ((next.Y - start.Y) / 6d)); + controls[(3 * i) + 3] = end; + } + + return controls; + } +``` + +If `XY` lacks an operator used above (`-`, `*`, `/`, `GetLength`), use the equivalent CSMath method (`XY.Subtract`, `Multiply`, `Normalize`) and say so in the report. Extend `HasFiniteGeometry` with `Leader leader => leader.Vertices.All(IsFinite),`. + +- [ ] **Step 4: Run the tests and the suite** + +Run: `dotnet test ACadSharp.Image.Tests --filter "FullyQualifiedName~Leader|FullyQualifiedName~CatmullRom"` → PASS. +Run: `dotnet test ACadSharp.Image.sln --configuration Release -warnaserror` → green; baselines unchanged. + +- [ ] **Step 5: Docs** + +Spec 4.6 bullet: "LEADER (`Leader`): polyline through `Vertices` (WCS; the hookline is the last vertex), or a Catmull-Rom cubic Bézier chain for `PathType.Spline`; default closed filled arrowhead (length `ArrowSize x ScaleFactor`, base width one third) at the first vertex when `ArrowHeadEnabled`; custom arrow blocks fall back to it with a NotImplemented notification; the associated annotation is never drawn by the leader." README: add "leaders (straight and splined, with the default arrowhead)". + +- [ ] **Step 6: Commit** + +```bash +git add ACadSharp.Image/Rendering/EntityRenderDispatcher.cs ACadSharp.Image.Tests/EntityRenderDispatcherTests.cs README.md docs/superpowers/specs/2026-09-02-layers-and-svg-design.md +git commit -m "Draw leaders with their default arrowhead" +``` + +--- + +### Task 5: Draw MLINE elements and survive ACadSharp's destructive MLINE clone + +**Files:** +- Modify: `ACadSharp.Image/Rendering/EntityRenderDispatcher.cs` (switch, new `DrawMLine`, `DrawBlockContents` vertex preservation) +- Modify: `ACadSharp.Image.Tests/EntityRenderDispatcherTests.cs` +- Modify: `README.md`, spec 4.6 + +**Interfaces:** +- Consumes: `MLine` (`List Vertices`, `MLineFlags Flags` with `Closed=2, NoStartCaps=4, NoEndCaps=8`, `MLineJustification Justification` (`Top=0, Zero=1, Bottom=2`), `double ScaleFactor`, `MLineStyle Style` never null); `MLine.Vertex` (`XYZ Position`, `XYZ Miter`, `List Segments`); `Vertex.Segment.Parameters` (`List`); `MLineStyle` (`IEnumerable Elements`, `Color FillColor`, `MLineStyleFlags Flags` with `FillOn=1, StartSquareCap=16, EndSquareCap=256`, `AddElement(Element)`); `MLineStyle.Element` (`double Offset`, `Color Color`, `LineType? LineType`). `LineTypeDashResolver.Resolve(LineType?, CadHeader?, double, ImageRenderContext, float)`, `ColorExtensions.ToImageColor(CadColor, ImageColor)`, `ResolvedStyle.Header`, `ResolvedStyle.LineTypeScale`. The private `Draw(...)` already has a `Transform? placement` parameter. +- Produces: `private void DrawMLine(ImageRenderContext context, ImageStyle style, ResolvedStyle resolved, MLine mline, Transform? placement)`. + +Background: the offsets are baked into each vertex: element j's line passes through `Position + Parameters[0] * Miter` of `Segments[j]` (DXF group 41; ezdxf's renderer also uses only that first value and treats the stored geometry as final, so justification and scale must not be re-applied). The further group-41 values describe cuts made by MLEDIT; this plan ignores them (the elements stay continuous) and raises one Warning per entity when any are present. Ruling against the research note, which suggested honouring them: the DXF reference's wording is ambiguous about whether they are cumulative, the reference implementation ignores them, and a wrong gap is worse than a missing one. When a vertex has no parameters for an element, the offset is computed from the style (`Offset * ScaleFactor` plus the justification shift `-max(Offset)` for Top, `0` for Zero, `-min(Offset)` for Bottom, all times `ScaleFactor`) with one Warning. Vertices are WCS. In 3.7.1 `MLine.Clone()` empties the source's shared vertex list, so `Insert.Explode()` destroys every MLINE inside a block: `DrawBlockContents` snapshots the vertex lists first, hands the snapshot to the clone (drawn through the insert transform) and restores the originals afterwards. + +- [ ] **Step 1: Write the failing tests** + +Append to `EntityRenderDispatcherTests`: + +```csharp + private static MLineStyle TwoElementStyle(double outer, MLineStyleFlags flags = MLineStyleFlags.None) + { + MLineStyle style = new("PLAN") { Flags = flags, FillColor = new ACadSharp.Color(3) }; + style.AddElement(new MLineStyle.Element { Offset = outer, Color = new ACadSharp.Color(1) }); + style.AddElement(new MLineStyle.Element { Offset = -outer, Color = ACadSharp.Color.ByLayer }); + return style; + } + + private static MLine.Vertex VertexAt(double x, double y, params double[][] parameters) + { + MLine.Vertex vertex = new() { Position = new XYZ(x, y, 0), Direction = new XYZ(1, 0, 0), Miter = new XYZ(0, 1, 0) }; + foreach (double[] segment in parameters) + { + MLine.Vertex.Segment element = new(); + element.Parameters.AddRange(segment); + vertex.Segments.Add(element); + } + + return vertex; + } + + [Fact] + public void MLineDrawsOnePolylinePerStyleElementAtTheStoredOffsets() + { + RecordingDrawingSurface surface = new(); + ImageConfiguration configuration = new(); + MLine mline = new() { Style = TwoElementStyle(0.5), Vertices = { VertexAt(0, 10, [0.5, 0], [-0.5, 0]), VertexAt(20, 10, [0.5, 0], [-0.5, 0]) } }; + + new EntityRenderDispatcher(configuration).Draw(CreateContext(surface, configuration), mline); + + Assert.Equal(2, surface.Polylines.Count); + Assert.Equal([new SurfacePoint(0, 89.5), new SurfacePoint(20, 89.5)], surface.Polylines[0]); + Assert.Equal([new SurfacePoint(0, 90.5), new SurfacePoint(20, 90.5)], surface.Polylines[1]); + Assert.All(surface.Calls.Where(c => c.StartsWith("DrawPolyline", StringComparison.Ordinal)), c => Assert.EndsWith("closed=False", c)); + // Element colour 1 (red) is used for the first element; ByLayer falls back to the entity's resolved colour. + Assert.Equal(SixLabors.ImageSharp.Color.Red.ToPixel(), surface.Styles[0].StrokeColor.ToPixel()); + } + + [Fact] + public void MLineWithoutVertexParametersFallsBackToStyleOffsetsAndJustification() + { + RecordingDrawingSurface surface = new(); + ImageConfiguration configuration = new(); + List notifications = new(); + configuration.OnNotification += (_, e) => notifications.Add(e); + MLine mline = new() { Style = TwoElementStyle(0.5), ScaleFactor = 2, Justification = MLineJustification.Top, Vertices = { VertexAt(0, 10), VertexAt(20, 10) } }; + + new EntityRenderDispatcher(configuration).Draw(CreateContext(surface, configuration), mline); + + // Top justification puts the +0.5 element on the vertex line; the other lies 2 * 1.0 below it. + Assert.Equal([new SurfacePoint(0, 90), new SurfacePoint(20, 90)], surface.Polylines[0]); + Assert.Equal([new SurfacePoint(0, 92), new SurfacePoint(20, 92)], surface.Polylines[1]); + Assert.Contains(notifications, n => n.NotificationType == NotificationType.Warning); + } + + [Fact] + public void ClosedMLineClosesEveryElementAndFillsBetweenTheOuterOnes() + { + RecordingDrawingSurface surface = new(); + ImageConfiguration configuration = new(); + MLine mline = new() + { + Style = TwoElementStyle(1, MLineStyleFlags.FillOn), + Flags = MLineFlags.Closed, + Vertices = { VertexAt(0, 0, [1, 0], [-1, 0]), VertexAt(20, 0, [1, 0], [-1, 0]), VertexAt(20, 20, [1, 0], [-1, 0]) }, + }; + + new EntityRenderDispatcher(configuration).Draw(CreateContext(surface, configuration), mline); + + Assert.All(surface.Calls.Where(c => c.StartsWith("DrawPolyline", StringComparison.Ordinal)), c => Assert.EndsWith("closed=True", c)); + IReadOnlyList fill = Assert.Single(surface.Polygons); + Assert.Equal(6, fill.Count); + Assert.Equal("FillPolygon n=6", surface.Calls.First(c => c.StartsWith("Fill", StringComparison.Ordinal) || c.StartsWith("DrawPolyline", StringComparison.Ordinal))); + } + + [Fact] + public void MLineCutParametersAreIgnoredWithAWarning() + { + RecordingDrawingSurface surface = new(); + ImageConfiguration configuration = new(); + List notifications = new(); + configuration.OnNotification += (_, e) => notifications.Add(e); + MLine mline = new() { Style = TwoElementStyle(0.5), Vertices = { VertexAt(0, 10, [0.5, 0, 4, 6], [-0.5, 0]), VertexAt(20, 10, [0.5, 0], [-0.5, 0]) } }; + + new EntityRenderDispatcher(configuration).Draw(CreateContext(surface, configuration), mline); + + Assert.Equal(2, surface.Polylines.Count); + Assert.Single(notifications, n => n.NotificationType == NotificationType.Warning && n.Message.Contains("cut", StringComparison.OrdinalIgnoreCase)); + } + + [Fact] + public void MLineInsideABlockIsDrawnThroughTheInsertAndKeepsItsVertices() + { + RecordingDrawingSurface surface = new(); + ImageConfiguration configuration = new(); + MLine mline = new() { Style = TwoElementStyle(0.5), Vertices = { VertexAt(0, 0, [0.5, 0], [-0.5, 0]), VertexAt(10, 0, [0.5, 0], [-0.5, 0]) } }; + BlockRecord block = new("WALL"); + block.Entities.Add(mline); + Insert insert = new(block) { InsertPoint = new XYZ(5, 20, 0) }; + + new EntityRenderDispatcher(configuration).Draw(CreateContext(surface, configuration), insert); + + Assert.Equal(2, mline.Vertices.Count); + Assert.Equal(2, surface.Polylines.Count); + Assert.Equal([new SurfacePoint(5, 79.5), new SurfacePoint(15, 79.5)], surface.Polylines[0]); + } +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `dotnet test ACadSharp.Image.Tests --filter "FullyQualifiedName~MLine"` +Expected: FAIL (no polylines; the block test also finds `mline.Vertices.Count == 0` after the explode). + +- [ ] **Step 3: Implement** + +Add `case MLine mline: this.DrawMLine(context, style, resolved, mline, placement); break;` before the `default:` arm, and: + +```csharp + /// + /// The geometry stored in an MLINE's vertices is final: element j passes through + /// Position + Segments[j].Parameters[0] * Miter at every vertex (DXF group 41), with justification and + /// scale already applied by the writer. Vertices without parameters fall back to the style offsets with the + /// justification shift, with a warning. Cuts made by MLEDIT (further group-41 values) are ignored with a + /// warning; the elements stay continuous. Each element takes the style element's colour and linetype, falling + /// back to the entity's own; a fill-on style fills the ring between the two outermost elements first. Square + /// caps join the outermost elements at an open end unless the entity suppresses them; round and inner-arc + /// caps and joints are not drawn. + /// + private void DrawMLine(ImageRenderContext context, ImageStyle style, ResolvedStyle resolved, MLine mline, Transform? placement) + { + IReadOnlyList vertices = mline.Vertices; + MLineStyle.Element[] elements = mline.Style.Elements.ToArray(); + if (vertices.Count < 2 || elements.Length == 0) + { + return; + } + + bool closed = mline.Flags.HasFlag(MLineFlags.Closed); + double scale = mline.ScaleFactor == 0d ? 1d : mline.ScaleFactor; + double maxOffset = elements.Max(e => e.Offset); + double minOffset = elements.Min(e => e.Offset); + double shift = mline.Justification switch + { + MLineJustification.Top => -maxOffset * scale, + MLineJustification.Bottom => -minOffset * scale, + _ => 0d, + }; + + bool fallback = false; + bool cuts = false; + SurfacePoint[][] lines = new SurfacePoint[elements.Length][]; + for (int j = 0; j < elements.Length; j++) + { + lines[j] = new SurfacePoint[vertices.Count]; + for (int i = 0; i < vertices.Count; i++) + { + MLine.Vertex vertex = vertices[i]; + double along; + if (j < vertex.Segments.Count && vertex.Segments[j].Parameters.Count > 0) + { + along = vertex.Segments[j].Parameters[0]; + cuts |= vertex.Segments[j].Parameters.Count > 2; + } + else + { + along = (elements[j].Offset * scale) + shift; + fallback = true; + } + + XYZ world = vertex.Position + (vertex.Miter * along); + lines[j][i] = context.ToSurfacePoint(placement == null ? world : placement.ApplyTransform(world)); + } + } + + string handle = mline.Handle.ToString("X", CultureInfo.InvariantCulture); + if (fallback) + { + this._configuration.Notify($"[{mline.SubclassMarker}] Handle {handle}: vertex parameters are missing; element offsets were computed from the style.", NotificationType.Warning); + } + + if (cuts) + { + this._configuration.Notify($"[{mline.SubclassMarker}] Handle {handle}: cut segments are not rendered; elements are drawn continuous.", NotificationType.Warning); + } + + ImageColor foreground = context.Configuration.ResolveForegroundColor(); + int outer = Array.FindIndex(elements, e => e.Offset == maxOffset); + int inner = Array.FindIndex(elements, e => e.Offset == minOffset); + if (mline.Style.Flags.HasFlag(MLineStyleFlags.FillOn) && outer != inner) + { + ImageStyle fill = style with { StrokeColor = ElementColor(mline.Style.FillColor), DashPattern = null }; + context.Surface.FillPolygon(fill, [.. lines[outer], .. lines[inner].Reverse()]); + } + + for (int j = 0; j < elements.Length; j++) + { + float[]? dashes = elements[j].LineType == null + ? style.DashPattern + : LineTypeDashResolver.Resolve(elements[j].LineType, resolved.Header, resolved.LineTypeScale, context, style.StrokeWidth); + ImageStyle elementStyle = style with { StrokeColor = ElementColor(elements[j].Color), DashPattern = dashes }; + context.Surface.DrawPolyline(elementStyle, lines[j], closed); + } + + if (!closed && outer != inner) + { + if (mline.Style.Flags.HasFlag(MLineStyleFlags.StartSquareCap) && !mline.Flags.HasFlag(MLineFlags.NoStartCaps)) + { + context.Surface.DrawLine(style, lines[outer][0], lines[inner][0]); + } + + if (mline.Style.Flags.HasFlag(MLineStyleFlags.EndSquareCap) && !mline.Flags.HasFlag(MLineFlags.NoEndCaps)) + { + context.Surface.DrawLine(style, lines[outer][^1], lines[inner][^1]); + } + } + + ImageColor ElementColor(ACadSharp.Color color) => color.IsByLayer || color.IsByBlock ? style.StrokeColor : color.ToImageColor(foreground); + } +``` + +Then in `DrawBlockContents` (from Task 3), wrap the explode loop: + +```csharp + // ACadSharp 3.7.1's MLine.Clone() clears the vertex list it shares with its source, so Explode() would + // empty every MLINE in the block for the rest of the document's life. The lists are captured first, lent to + // the clone (drawn through the insert transform, since the empty list was what ApplyTransform saw) and + // restored afterwards. + Dictionary> mlineVertices = originals.OfType().ToDictionary(m => m, m => new List(m.Vertices)); + int index = 0; + try + { + foreach (Entity entity in insert.Explode()) + { + Entity? original = index < originals.Count ? originals[index] : null; + index++; + if (entity is AttributeDefinition definition && !definition.Flags.HasFlag(AttributeFlags.Constant)) + { + continue; + } + + NormalizeExplodedClone(entity); + Transform? entityPlacement = null; + Entity? source = null; + if (original is TextEntity or MText && original.GetType() == entity.GetType()) + { + source = original; + entityPlacement = transform; + } + else if (entity is MLine clone && original is MLine sourceMLine && mlineVertices.TryGetValue(sourceMLine, out List? vertices)) + { + clone.Vertices = vertices; + entityPlacement = transform; + } + + this.Draw(context, entity, layer, insert.Handle, insert.Block?.Name, parent, source, entityPlacement); + } + } + finally + { + foreach (KeyValuePair> pair in mlineVertices) + { + pair.Key.Vertices = pair.Value; + } + } +``` + +The explode-count warning and the `this.DrawAttributes(...)` call from Task 3 stay in the method, after the `finally` block. Update the comment on the private `Draw` so `placement` reads: "placement is the transform of the insert that placed a block TEXT, MTEXT or MLINE; null outside a block reference." If `XYZ` lacks `+`/`*` operators, use `XYZ.Add`/`Multiply` equivalents and report it. + +- [ ] **Step 4: Run the tests and the suite** + +Run: `dotnet test ACadSharp.Image.Tests --filter "FullyQualifiedName~MLine"` → PASS. +Run: `dotnet test ACadSharp.Image.sln --configuration Release -warnaserror` → green; baselines unchanged. + +- [ ] **Step 5: Docs** + +Spec 4.6 bullet: "MLINE (`MLine`): one polyline per style element through `Position + Parameters[0] x Miter` (stored geometry is final; justification and scale are not re-applied), style-element colour and linetype falling back to the entity's, fill between the outermost elements when the style has FillOn, square caps only; vertices without parameters fall back to style offsets with a Warning; MLEDIT cuts are ignored with a Warning. Inside blocks the vertex lists are captured before `Explode()` and restored after it because `MLine.Clone()` in 3.7.1 empties the source." README: add "multilines (element offsets, fill, square caps; cuts are not rendered)". + +- [ ] **Step 6: Commit** + +```bash +git add ACadSharp.Image/Rendering/EntityRenderDispatcher.cs ACadSharp.Image.Tests/EntityRenderDispatcherTests.cs README.md docs/superpowers/specs/2026-09-02-layers-and-svg-design.md +git commit -m "Draw multilines and shield block MLINEs from the destructive clone" +``` + +--- + +### Task 6: Draw WIPEOUT as an opaque background polygon + +**Files:** +- Modify: `ACadSharp.Image/Rendering/EntityRenderDispatcher.cs` (switch, new `DrawWipeout`, `internal static XYZ WipeoutPixelToWorld(...)`) +- Modify: `ACadSharp.Image.Tests/EntityRenderDispatcherTests.cs` +- Modify: `README.md`, spec 4.6 + +**Interfaces:** +- Consumes: `Wipeout : CadWipeoutBase` (`List ClipBoundaryVertices`, `ClipType ClipType` (`Rectangular=1, Polygonal=2`), `bool ClippingState`, `ClipMode ClipMode` (`Outside=0, Inside=1`), `XYZ InsertPoint`, `XYZ UVector`, `XYZ VVector`, `XY Size`, `ImageDisplayFlags Flags` with `ShowImage=1`), `ImageConfiguration.BackgroundColor`. +- Produces: `private void DrawWipeout(ImageRenderContext context, ImageStyle style, Wipeout wipeout)`, `internal static XYZ WipeoutPixelToWorld(CadWipeoutBase image, XY pixel)`. + +Background: the boundary is in pixel space with its origin at the top-left corner of the image and Y pointing down; U runs along the visual bottom, V along the visual left side, both one pixel long. The mapping (as implemented by ezdxf's `boundary_path_wcs`, which also writes wipeouts this way) is `world = InsertPoint + (p.X + 0.5) * U + (Size.Y - p.Y - 0.5) * V`. Ruling against the research note, which had no Y flip: the flip is what makes the documented default boundary `(-0.5,-0.5) .. (Size.X-0.5, Size.Y-0.5)` cover exactly the image extent with the top-left pixel at the top. On the raster backend a wipeout paints the background colour at full opacity over everything drawn before it (Task 1 makes that order the drawing's). In SVG every entity sits inside its layer's ``, so a wipeout masks only content in its own layer group and in groups written earlier; layer grouping takes precedence over draw order by design (the same holds for hatches and solids) and is not to be changed. A transparent background cannot occlude, so the wipeout is skipped with a Warning; `ClipMode.Inside` (everything outside the boundary is masked) is skipped with a NotImplemented notification. The frame is never drawn (AutoCAD's WIPEOUTFRAME=0 plot behaviour; 3.7.1 exposes no header variable for it). + +- [ ] **Step 1: Write the failing tests** + +Append to `EntityRenderDispatcherTests`: + +```csharp + private static Wipeout UnitWipeout() + { + // Insert (10,10), one-pixel image whose pixel spans 5 x 5 drawing units. + return new Wipeout + { + InsertPoint = new XYZ(10, 10, 0), + UVector = new XYZ(5, 0, 0), + VVector = new XYZ(0, 5, 0), + Size = new XY(1, 1), + Flags = ImageDisplayFlags.ShowImage | ImageDisplayFlags.UseClippingBoundary, + ClippingState = true, + }; + } + + [Fact] + public void WipeoutPixelMappingFlipsYAndCentresPixels() + { + Wipeout wipeout = UnitWipeout(); + + Assert.Equal(new XYZ(10, 15, 0), EntityRenderDispatcher.WipeoutPixelToWorld(wipeout, new XY(-0.5, -0.5))); + Assert.Equal(new XYZ(15, 10, 0), EntityRenderDispatcher.WipeoutPixelToWorld(wipeout, new XY(0.5, 0.5))); + } + + [Fact] + 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); + + new EntityRenderDispatcher(configuration).Draw(CreateContext(surface, configuration), wipeout); + + IReadOnlyList polygon = Assert.Single(surface.Polygons); + Assert.Equal(4, polygon.Count); + Assert.Equal(new HashSet { new(10, 90), new(15, 90), new(15, 85), new(10, 85) }, polygon.ToHashSet()); + ImageStyle style = Assert.Single(surface.Styles); + Assert.Equal(SixLabors.ImageSharp.Color.White.ToPixel(), style.StrokeColor.ToPixel()); + Assert.Equal(1f, style.Opacity); + Assert.Empty(surface.Polylines); + } + + [Fact] + 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); + + Assert.Equal([new SurfacePoint(10, 85), new SurfacePoint(15, 85), new SurfacePoint(12.5, 90)], Assert.Single(surface.Polygons)); + } + + [Fact] + public void WipeoutWithoutClippingFillsTheWholeImageFrame() + { + RecordingDrawingSurface surface = new(); + ImageConfiguration configuration = new(); + Wipeout wipeout = UnitWipeout(); + wipeout.ClippingState = false; + + new EntityRenderDispatcher(configuration).Draw(CreateContext(surface, configuration), wipeout); + + Assert.Equal(new HashSet { new(10, 90), new(15, 90), new(15, 85), new(10, 85) }, Assert.Single(surface.Polygons).ToHashSet()); + } + + [Fact] + public void WipeoutOnTransparentBackgroundIsSkippedWithAWarning() + { + RecordingDrawingSurface surface = new(); + ImageConfiguration configuration = new() { BackgroundColor = SixLabors.ImageSharp.Color.Transparent }; + List notifications = new(); + configuration.OnNotification += (_, e) => notifications.Add(e); + + new EntityRenderDispatcher(configuration).Draw(CreateContext(surface, configuration), UnitWipeout()); + + Assert.Empty(surface.Polygons); + Assert.Contains(notifications, n => n.NotificationType == NotificationType.Warning); + } + + [Fact] + public void InvertedAndHiddenWipeoutsDrawNothing() + { + RecordingDrawingSurface surface = new(); + ImageConfiguration configuration = new(); + List notifications = new(); + configuration.OnNotification += (_, e) => notifications.Add(e); + Wipeout inverted = UnitWipeout(); + inverted.ClipMode = ClipMode.Inside; + Wipeout hidden = UnitWipeout(); + hidden.Flags = ImageDisplayFlags.None; + EntityRenderDispatcher dispatcher = new(configuration); + + dispatcher.Draw(CreateContext(surface, configuration), inverted); + dispatcher.Draw(CreateContext(surface, configuration), hidden); + + Assert.Empty(surface.Polygons); + Assert.Single(notifications, n => n.NotificationType == NotificationType.NotImplemented); + } +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `dotnet test ACadSharp.Image.Tests --filter "FullyQualifiedName~Wipeout"` +Expected: FAIL (compile error for `WipeoutPixelToWorld`, then no polygons). + +- [ ] **Step 3: Implement** + +Add `case Wipeout wipeout: this.DrawWipeout(context, style, wipeout); break;` before the `default:` arm, and: + +```csharp + /// + /// A wipeout masks whatever was drawn before it: its clip boundary (or the whole image frame when clipping is + /// off) is filled with the page background at full opacity, so the page must be drawn in the drawing's order. + /// The frame is never stroked. An inverted clip (everything outside the boundary masked) and a transparent + /// background cannot be honoured and are skipped with a notification. + /// + private void DrawWipeout(ImageRenderContext context, ImageStyle style, Wipeout wipeout) + { + if (!wipeout.Flags.HasFlag(ImageDisplayFlags.ShowImage)) + { + return; + } + + string handle = wipeout.Handle.ToString("X", CultureInfo.InvariantCulture); + if (wipeout.ClipMode == ClipMode.Inside) + { + this._configuration.Notify($"[{wipeout.SubclassMarker}] Handle {handle}: inverted clip boundaries are not rendered.", NotificationType.NotImplemented); + return; + } + + ImageColor background = this._configuration.BackgroundColor; + if (background.ToPixel().A == 0) + { + this._configuration.Notify($"[{wipeout.SubclassMarker}] Handle {handle}: a wipeout cannot mask on a transparent background; skipped.", NotificationType.Warning); + return; + } + + List pixels; + if (wipeout.ClippingState && wipeout.ClipBoundaryVertices.Count >= 2) + { + if (wipeout.ClipType == ClipType.Rectangular || wipeout.ClipBoundaryVertices.Count == 2) + { + XY a = wipeout.ClipBoundaryVertices[0]; + XY b = wipeout.ClipBoundaryVertices[1]; + pixels = [a, new XY(b.X, a.Y), b, new XY(a.X, b.Y)]; + } + else + { + pixels = wipeout.ClipBoundaryVertices.ToList(); + } + } + else + { + pixels = [new XY(-0.5, -0.5), new XY(wipeout.Size.X - 0.5, -0.5), new XY(wipeout.Size.X - 0.5, wipeout.Size.Y - 0.5), new XY(-0.5, wipeout.Size.Y - 0.5)]; + } + + SurfacePoint[] points = pixels.Select(p => context.ToSurfacePoint(WipeoutPixelToWorld(wipeout, p))).ToArray(); + context.Surface.FillPolygon(style with { StrokeColor = background, Opacity = 1f, DashPattern = null }, points); + } + + /// + /// Maps an image-space boundary vertex to world coordinates. Pixel (0,0) is the top-left pixel and Y grows + /// downwards; UVector runs along the visual bottom and VVector up the visual left side, each one + /// pixel long. The documented default boundary (-0.5,-0.5)..(Size-0.5) therefore covers exactly the image. + /// + internal static XYZ WipeoutPixelToWorld(CadWipeoutBase image, XY pixel) + => image.InsertPoint + (image.UVector * (pixel.X + 0.5)) + (image.VVector * (image.Size.Y - pixel.Y - 0.5)); +``` + +Add `using SixLabors.ImageSharp.PixelFormats;` if `Rgba32` is not already in scope. + +- [ ] **Step 4: Run the tests and the suite** + +Run: `dotnet test ACadSharp.Image.Tests --filter "FullyQualifiedName~Wipeout"` → PASS. +Run: `dotnet test ACadSharp.Image.sln --configuration Release -warnaserror` → green; baselines unchanged. + +- [ ] **Step 5: Docs** + +Spec 4.6 bullet: "WIPEOUT (`Wipeout`): the clip boundary (rectangular pair expanded to four corners; polygonal as listed; the full frame when `ClippingState` is off) is mapped from pixel space with `InsertPoint + (x+0.5)U + (Size.Y-y-0.5)V` and filled with `BackgroundColor` at opacity 1; no frame; `ClipMode.Inside` is NotImplemented; a transparent background skips the wipeout with a Warning. In SVG the mask covers only its own layer group and earlier groups, because layer grouping takes precedence over draw order." README: add "wipeouts (masked with the background colour; needs an opaque `BackgroundColor`; in SVG the mask stays within layer-group order)". + +- [ ] **Step 6: Commit** + +```bash +git add ACadSharp.Image/Rendering/EntityRenderDispatcher.cs ACadSharp.Image.Tests/EntityRenderDispatcherTests.cs README.md docs/superpowers/specs/2026-09-02-layers-and-svg-design.md +git commit -m "Mask wipeouts with the background colour" +``` + +--- + +### Task 7: Size raster text in ems at 72 dpi and fix the line-spacing compensation + +**Files:** +- Create: `ACadSharp.Image/Rendering/TextMetrics.cs` +- Modify: `ACadSharp.Image/Rendering/RasterDrawingSurface.cs:176-232, 279-282`, `ACadSharp.Image/Rendering/Svg/SvgTextLayout.cs:17-20` +- Modify: `ACadSharp.Image.Tests/RasterDrawingSurfaceTests.cs` +- Modify: `README.md`, spec 5.3, `docs/research/remaining-rendering-limitations.md` + +**Interfaces:** +- Consumes: `FontResolver.Create(string?, float size)`, `RasterDrawingSurfaceTests.DrawnText(value, baseline, lineSpacingFactor, rotation)` and `InkBandStarts(image)` helpers. +- Produces: `internal static class TextMetrics` in `ACadSharp.Image/Rendering/TextMetrics.cs` with `public const double CapHeightToEm = 4d / 3d;` and `public static double EmSize(double capHeight) => capHeight * CapHeightToEm;`. `SvgTextLayout.CapHeightToEm` and `SvgTextLayout.EmSize` become forwarders to it (`public const double CapHeightToEm = TextMetrics.CapHeightToEm;`, `public static double EmSize(double capHeight) => TextMetrics.EmSize(capHeight);`) so the SVG code and its tests do not change. The raster must not reference the `Svg` namespace. + +Background: SixLabors draws glyphs at `Font.Size x Dpi / 72` pixels, so passing the CAD height with `Dpi = configuration.Dpi` makes text grow with `Dpi` while geometry does not. `(size = h, Dpi = 96)` and `(size = 4h/3, Dpi = 72)` render identically, so the fix is `Dpi = 72f` with the size in ems, shared with the SVG backend through the new `TextMetrics.EmSize`. Separately, SixLabors splits the extra leading `em x (LineSpacing - 1)` half above and half below each line; the current compensation `factor x em / 8` equals that only when `factor = 1`. The correct value is `em x (LineSpacing - 1) / 2`. + +- [ ] **Step 1: Write the failing tests** + +Append to `RasterDrawingSurfaceTests`: + +```csharp + [Fact] + public void TextSizeDoesNotDependOnTheConfiguredDpi() + { + int[] at96 = InkColumnBounds(DrawnText("Hg", SurfaceTextBaseline.Alphabetic, 1d, 0d, dpi: 96f)); + int[] at300 = InkColumnBounds(DrawnText("Hg", SurfaceTextBaseline.Alphabetic, 1d, 0d, dpi: 300f)); + + Assert.True(Math.Abs(at96[0] - at300[0]) <= 1 && Math.Abs(at96[1] - at300[1]) <= 1, $"ink columns {at96[0]}..{at96[1]} at 96 dpi but {at300[0]}..{at300[1]} at 300 dpi."); + } + + [Fact] + public void HangingTextStaysOnItsAnchorForAnyLineSpacingFactor() + { + using Image single = DrawnText("H", SurfaceTextBaseline.Hanging, 1d, 0d); + using Image spaced = DrawnText("H\nH", SurfaceTextBaseline.Hanging, 2d, 0d); + + int[] one = InkBandStarts(single); + int[] two = InkBandStarts(spaced); + + Assert.Equal(Assert.Single(one), two[0]); + double distance = two[1] - two[0]; + Assert.True(Math.Abs(distance - 100d / 3d) <= 1d, $"expected the lines about {100d / 3d:F1} px apart (2 x 5/3 of the text height), got {distance}."); + } + + /// First and last canvas column holding a pixel darker than mid grey. + private static int[] InkColumnBounds(Image canvas) + { + int first = -1; + int last = -1; + for (int x = 0; x < canvas.Width; x++) + { + bool inked = false; + for (int y = 0; y < canvas.Height && !inked; y++) + { + inked = canvas[x, y].R < 128; + } + + if (inked) + { + if (first < 0) + { + first = x; + } + + last = x; + } + } + + return [first, last]; + } +``` + +Change the existing `DrawnText` helper to take `float dpi = 96f` as a fifth optional parameter and pass `new ImageConfiguration { Dpi = dpi }` to the surface. + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `dotnet test ACadSharp.Image.Tests --filter "FullyQualifiedName~RasterDrawingSurfaceTests"` +Expected: `TextSizeDoesNotDependOnTheConfiguredDpi` FAILS (about three times wider at 300 dpi); `HangingTextStaysOnItsAnchorForAnyLineSpacingFactor` FAILS (first band moves by about 6 px). Record both RED values in the report. + +- [ ] **Step 3: Implement** + +In `RasterDrawingSurface.DrawText` replace the font creation, comment, `halfLeading` and `TextOptions.Dpi`: + +```csharp + // The font size is the em, 4/3 of the CAD text height, laid out at 72 dpi so one point is one pixel: text + // then scales with the page like the geometry does and not with ImageConfiguration.Dpi, which only sizes + // line weights. The SVG backend uses the same em through TextMetrics.EmSize. + Font font = this.CreateFont(TextMetrics.EmSize(text.Height)); + + // ImageSharp advances the baseline by one em per line; AutoCAD and the SVG backend space lines at 5/3 of + // the text height, that is 5/4 em, so the spacing factor carries the 5/4. ImageSharp then splits the extra + // (LineSpacing - 1) em of leading evenly above and below every line, which would displace even a single + // line, so the origin is pulled back by that half-leading on whichever end the alignment anchors: up for + // Hanging, which anchors the top, down for Alphabetic, which anchors the bottom, and not at all for + // Central. The offset rides on the layout origin, so the rotation below turns it with the glyphs. + double factor = text.LineSpacingFactor <= 0d ? 1d : text.LineSpacingFactor; + float lineSpacing = (float)factor * 5f / 4f; + double halfLeading = font.Size * (lineSpacing - 1d) / 2d; +``` + +Set `Dpi = 72f,` and `LineSpacing = lineSpacing,` in the `TextOptions` initializer. Rename `CreateFont(double height)` to `CreateFont(double emSize)` with a ``: "Font at the given em size in points; at 72 dpi one point is one pixel." + +- [ ] **Step 4: Run the tests and the suite; handle baselines** + +Run: `dotnet test ACadSharp.Image.Tests --filter "FullyQualifiedName~RasterDrawingSurfaceTests"` → PASS (all, including the existing spacing and anchor tests). +Run: `dotnet test ACadSharp.Image.sln --configuration Release -warnaserror`. + +The change is arithmetically a no-op at 96 dpi but not bit-exact (one-ULP float differences). If a PNG baseline fails: with Pillow (or an xUnit scratch assertion) count the differing pixels and the maximum channel delta between the baseline and the new render; the diff must be confined to text pixels, at most 0.5% of the canvas and small in magnitude. Then regenerate exactly the failing PNGs with the scoped commands (`ACADSHARP_IMAGE_UPDATE_BASELINES=1 dotnet test ACadSharp.Image.Tests --filter "FullyQualifiedName~FeatureGoldenTests.FeaturePngMatchesBaseline"`, `...~SampleParityTests.SampleRendersMatchBaselines`, `...~ViewportParityTests`), list them with pixel counts in the commit body, and confirm no `.svg` golden changed (`git status --short ACadSharp.Image.Tests/Baselines/*.svg` empty). If a non-text pixel changes, stop and report. + +- [ ] **Step 5: Docs** + +Spec 5.3 "Text" bullet: append "**Amended 2026-09-03 (remaining fixes):** the raster backend lays text out at a fixed 72 dpi with the em size (`TextMetrics.EmSize`, shared with the SVG backend), so text no longer scales with `ImageConfiguration.Dpi`; the single-line compensation is `em x (LineSpacing - 1) / 2`, exact for every line-spacing factor." README line ~250: replace "a non-default `Dpi` scales raster text but not SVG text" (or the equivalent sentence) with "`ImageConfiguration.Dpi` affects only line weights; text is sized from the drawing on both backends." In `docs/research/remaining-rendering-limitations.md`, add under each topic heading (1.1 to 1.4, 2, 3, 4.1, 4.2 and the incidental ATTDEF finding) one line `**Status (2026-09-03):** implemented in plan 08 (docs/superpowers/plans/2026-09-03-08-remaining-entities.md)`, with the deviations noted for 1.2 (cuts ignored, like ezdxf) and 1.3 (Y-flipped mapping). + +- [ ] **Step 6: Commit** + +```bash +git add ACadSharp.Image/Rendering/TextMetrics.cs ACadSharp.Image/Rendering/RasterDrawingSurface.cs ACadSharp.Image/Rendering/Svg/SvgTextLayout.cs ACadSharp.Image.Tests/RasterDrawingSurfaceTests.cs README.md docs/superpowers/specs/2026-09-02-layers-and-svg-design.md docs/research/remaining-rendering-limitations.md ACadSharp.Image.Tests/Baselines +git commit -m "Size raster text in ems at 72 dpi and fix the leading compensation" +``` + +--- + +## Self-review + +- Coverage against the research note: 1.1 → Task 2; 1.2 → Task 5 (cuts ignored, documented deviation); 1.3 → Task 6 (Y-flipped mapping, documented deviation) and Task 1 (draw order); 1.4 → Task 4; 2 → Task 3; 3 → Task 3 (count warning; the `Block.Entities` refactor is not taken because `Explode()`'s Circle→Ellipse conversion under non-uniform scale is needed, verified by probe); 4.1 and 4.2 → Task 7; incidental ATTDEF → Task 3; incidental MLINE clone → Task 5. +- Type consistency: `Draw(..., Entity? textSource, Transform? placement)` keeps its signature; Task 5 reuses `placement`. `CatmullRomToBezier` and `WipeoutPixelToWorld` are `internal static` so the tests reach them through the existing `InternalsVisibleTo`. +- Baselines: Tasks 2 to 6 cannot move any; Task 1 and Task 7 have explicit measure-then-regenerate steps. diff --git a/docs/superpowers/plans/2026-09-04-09-codex-review-fixes.md b/docs/superpowers/plans/2026-09-04-09-codex-review-fixes.md new file mode 100644 index 0000000..cf45349 --- /dev/null +++ b/docs/superpowers/plans/2026-09-04-09-codex-review-fixes.md @@ -0,0 +1,747 @@ +# Codex Review Fixes Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Fix the verified findings of the second independent Codex review (range `fc99ba8..0131f28`): viewport draw order and crash-safety, renderer-aware page framing, block-reference composition for OCS solids and leader arrows, non-uniformly scaled block text, MLINE fallback defects, SVG whitespace, stale docs, CLI argument validation, dead code, CI path filters, and the test gaps the review named. + +**Architecture:** Each fix is local. `ImagePage` gains an insertion-ordered draw sequence that interleaves viewports and paper entities; `ImagePageRenderer` selects viewport contents itself, in sorted order, with per-entity guards. A small `EntityBounds` helper gives `ComputeFrame` the same wipeout mapping and OCS handling the renderer uses. `DrawBlockContents` extends its "draw from the original through the insert transform" path (already used for TEXT/MTEXT/MLINE) to non-world SOLIDs and LEADERs. `TextRenderer.Placement` and `SurfaceText` carry a horizontal `WidthScale` so both surfaces can stretch glyphs for non-uniform insert scales. + +**Tech Stack:** .NET 8/10, ACadSharp 3.7.1, SixLabors.ImageSharp 3.1.12 / Fonts 2.1.3, xUnit. + +**Spec:** `docs/superpowers/specs/2026-09-02-layers-and-svg-design.md` (binding; sections 4.6 and 5.3 amended here). Review source: the Codex report archived in the session scratchpad; each task names its finding. + +**Findings declined or deferred (recorded for the reviewer):** warning aggregation by block (the library reports per entity by design; consumers aggregate); a split of `EntityRenderDispatcher` into components (behaviour-preserving refactor, deferred to its own plan); avoiding `Insert.Explode()` altogether (the Circle→Ellipse conversion under non-uniform scale is needed); frame computation that ignores filtered block children (a design choice: the frame follows the page, not the filter). + +## Global Constraints + +- ACadSharp `3.7.1`; SixLabors packages as pinned; no new NuGet dependencies; target frameworks unchanged. +- Coding conventions: `this.` prefix on instance members, explicit types except LINQ lambdas, XML docs on public and internal members and a `` on private helpers, `sealed` classes, file-scoped namespaces, four-space indent, UTF-8 without BOM (never add or remove a BOM), LF line endings. +- PNG baselines and SVG goldens in `ACadSharp.Image.Tests/Baselines/` are byte-identical except where a task says otherwise: Task 1 may move `viewport-sheet.paper.01.*` only if the paper draw order actually changes (measure and explain); Task 6 regenerates exactly the four SVG goldens that contain ` ImagePage.DrawSequence` (entities and viewports, `Viewport : Entity`, in the order they were added); `internal IEnumerable ImagePageRenderer.SelectViewportEntities(Viewport viewport)`. +- Public `ImagePage.Entities` and `ImagePage.Viewports` keep their contents and order. + +- [ ] **Step 1: Write the failing tests** + +Append to `ImagePageTests`: + +```csharp + [Fact] + public void DrawSequenceKeepsViewportsAndEntitiesInInsertionOrder() + { + ImagePage page = new(); + Line first = new(new XYZ(0, 0, 0), new XYZ(1, 0, 0)); + Viewport viewport = new() { Center = new XYZ(50, 50, 0), Width = 10, Height = 10 }; + Line last = new(new XYZ(0, 0, 0), new XYZ(0, 1, 0)); + + page.AddEntity(first); + page.AddViewport(viewport); + page.AddEntity(last); + + Assert.Equal([first, viewport, last], page.DrawSequence); + Assert.Equal([first, last], page.Entities); + Assert.Equal([viewport], page.Viewports); + } +``` + +Append to `ImagePageRendererTests`. The file already renders pages with `new ImagePageRenderer(exporter.Configuration).RenderTo(new RecordingDrawingSurface(), page)` (see `LayoutPagesKeepTheirPaperSize`); `RenderThrough(exporter, surface)` below is exactly `new ImagePageRenderer(exporter.Configuration).RenderTo(surface, exporter.Pages[0])` — write it as a private static helper in the test class: + +```csharp + [Fact] + public void PaperEntitiesAddedBeforeAViewportAreDrawnBeforeIt() + { + // A page built by ImageExporter from a layout whose title line sorts before the viewport must draw the line first. + CadDocument document = new(); + document.Entities.Add(new Line(new XYZ(0, 0, 0), new XYZ(10, 0, 0))); + Layout layout = new("Sheet") { PaperWidth = 200, PaperHeight = 100 }; + document.Layouts.Add(layout); + layout.AssociatedBlock.Entities.Add(new Line(new XYZ(5, 5, 0), new XYZ(50, 5, 0))); + layout.AssociatedBlock.Entities.Add(new Viewport { Center = new XYZ(100, 50, 0), Width = 50, Height = 50, ViewCenter = new XY(5, 0), ViewHeight = 20 }); + + RecordingDrawingSurface surface = new(); + ImageExporter exporter = new(); + exporter.Add(layout); + RenderThrough(exporter, surface); // helper: render exporter.Pages[0] onto `surface` via ImagePageRenderer + + int line = surface.Calls.FindIndex(c => c.StartsWith("DrawLine", StringComparison.Ordinal)); + int viewport = surface.Calls.FindIndex(c => c.StartsWith("BeginViewport", StringComparison.Ordinal)); + Assert.True(line >= 0 && viewport >= 0 && line < viewport, $"expected the title line before the viewport, got line at {line}, viewport at {viewport}."); + } + + [Fact] + public void MalformedModelEntityDoesNotAbortViewportRendering() + { + CadDocument document = new(); + document.Entities.Add(new Line(new XYZ(0, 0, 0), new XYZ(10, 0, 0))); + LwPolyline malformed = new(); + malformed.Vertices.Add(new LwPolyline.Vertex(new XY(5, 5)) { Bulge = 1 }); + malformed.Vertices.Add(new LwPolyline.Vertex(new XY(5, 5))); + malformed.Vertices.Add(new LwPolyline.Vertex(new XY(9, 5))); + document.Entities.Add(malformed); + Layout layout = new("Sheet") { PaperWidth = 200, PaperHeight = 100 }; + document.Layouts.Add(layout); + layout.AssociatedBlock.Entities.Add(new Viewport { Center = new XYZ(100, 50, 0), Width = 50, Height = 50, ViewCenter = new XY(5, 2), ViewHeight = 20 }); + + RecordingDrawingSurface surface = new(); + ImageExporter exporter = new(); + List notifications = new(); + exporter.Configuration.OnNotification += (_, e) => notifications.Add(e); + exporter.Add(layout); + + RenderThrough(exporter, surface); + + Assert.Contains(surface.Calls, c => c.StartsWith("DrawLine", StringComparison.Ordinal)); + Assert.Contains(notifications, n => n.NotificationType == NotificationType.Warning && n.Message.Contains("bounds", StringComparison.OrdinalIgnoreCase)); + } +``` + +If `Layout.AssociatedBlock` is not populated for a code-built layout the way these tests assume, build the layout the way `SyntheticSamples.ViewportSheet()` does and say so in the report. The malformed polyline's `GetBoundingBox()` throws `ArgumentOutOfRangeException` on 3.7.1 (verified). + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `dotnet test ACadSharp.Image.Tests --filter "FullyQualifiedName~DrawSequence|FullyQualifiedName~PaperEntitiesAddedBefore|FullyQualifiedName~MalformedModelEntity"` +Expected: FAIL (no `DrawSequence`; viewport drawn first; the malformed polyline throws out of `SelectEntities`). + +- [ ] **Step 3: Implement** + +`ImagePage`: add `private readonly List _drawSequence = [];` appended to by both `AddEntity` and `AddViewport` (`Viewport` derives from `Entity`), exposed as `internal IReadOnlyList DrawSequence => this._drawSequence;` with an XML summary ("Entities and viewports in the order they were added, which is the order they are drawn in."). + +`ImageExporter.Add(Layout)`: replace the two loops with one over `layout.AssociatedBlock.GetSortedEntities()`: a `Viewport` that `RepresentsPaper` is skipped, any other `Viewport` goes to `page.AddViewport`, everything else to `page.AddEntity`. Remove `ShouldIncludeEntity` if it becomes unused (check `Add(BlockRecord)` still filters viewports out; keep the filter there). + +`ImagePageRenderer.RenderTo`: + +```csharp + foreach (Entity item in page.DrawSequence) + { + if (item is Viewport viewport) + { + this.DrawViewport(context, viewport); + } + else + { + this._dispatcher.Draw(context, item); + } + } +``` + +`DrawViewport`: replace `viewport.SelectEntities()` with `this.SelectViewportEntities(viewport)`: + +```csharp + /// + /// The model-space entities a viewport shows, in the drawing's draw order: those whose bounding box lies in or + /// crosses the view box (what Viewport.SelectEntities does) minus the ones whose bounds ACadSharp cannot + /// compute, which are skipped with a warning instead of aborting the page. + /// + internal IEnumerable SelectViewportEntities(Viewport viewport) + { + if (viewport.Document == null) + { + this._configuration.Notify($"[{viewport.SubclassMarker}] Handle {viewport.Handle.ToString("X", CultureInfo.InvariantCulture)}: viewport has no document; skipped.", NotificationType.Warning); + yield break; + } + + BoundingBox box = viewport.GetModelBoundingBox(); + foreach (Entity entity in viewport.Document.ModelSpace.GetSortedEntities()) + { + BoundingBox bounds; + try + { + bounds = entity.GetBoundingBox(); + } + catch (Exception ex) when (ex is ArgumentException or InvalidOperationException) + { + this._configuration.Notify($"[{entity.SubclassMarker}] Handle {entity.Handle.ToString("X", CultureInfo.InvariantCulture)}: bounds could not be computed ({ex.Message}); entity skipped in viewport.", NotificationType.Warning, ex); + continue; + } + + if (box.IsIn(bounds, out bool partial) || partial) + { + yield return entity; + } + } + } +``` + +Add `using System.Globalization;` if missing. Keep `DrawViewport`'s own `viewport.GetBoundingBox()` (it cannot throw for a viewport). + +- [ ] **Step 4: Run the tests and the suite; measure baselines** + +Run the three new tests → PASS. Run the full suite. If `viewport-sheet.paper.01.png`/`.svg` change, confirm with a diff of the SVG that only element order moved (the frame line and title now precede or follow the viewport as the handles dictate), regenerate them with `ACADSHARP_IMAGE_UPDATE_BASELINES=1 dotnet test ACadSharp.Image.Tests --filter "FullyQualifiedName~ViewportParityTests"`, and list them with the cause in the commit body. Any other baseline change: stop and report. + +- [ ] **Step 5: Docs** + +Spec 4.6 draw-order bullet: append "Paper-space viewports are interleaved with paper entities in the same sorted order; a viewport's contents are the sorted model-space entities whose bounds lie in or cross its view box, and an entity whose bounds cannot be computed is skipped with a Warning." README "Supported entities" draw-order sentence: add "including paper-space viewports". + +- [ ] **Step 6: Commit** + +```bash +git add ACadSharp.Image/ImagePage.cs ACadSharp.Image/ImageExporter.cs ACadSharp.Image/Rendering/ImagePageRenderer.cs ACadSharp.Image.Tests/ImagePageTests.cs ACadSharp.Image.Tests/ImagePageRendererTests.cs README.md docs/superpowers/specs/2026-09-02-layers-and-svg-design.md ACadSharp.Image.Tests/Baselines +git commit -m "Draw viewports in page order and survive malformed model entities in them" +``` + +--- + +### Task 2: Frame pages from renderer-consistent bounds and tolerate unresolved block references + +**Findings:** Important 2 (frames use `Entity.GetBoundingBox()`, which ignores the wipeout U/V mapping and OCS solid normals) and Important 7 (an `Insert` whose `Block` is null makes `Explode()` and `GetBoundingBox()` throw `NullReferenceException`, outside every catch filter). + +**Files:** +- Create: `ACadSharp.Image/Rendering/EntityBounds.cs` +- Modify: `ACadSharp.Image/ImagePage.cs` (`ComputeFrame`), `ACadSharp.Image/Rendering/EntityRenderDispatcher.cs` (`DrawWipeout` region extraction, `DrawBlockContents` guard, `HasFiniteGeometry`) +- Test: `ACadSharp.Image.Tests/ImagePageTests.cs`, `ACadSharp.Image.Tests/EntityRenderDispatcherTests.cs` + +**Interfaces:** +- Produces: `internal static class EntityBounds { public static bool TryGet(Entity entity, out BoundingBox bounds); }`; `internal static IReadOnlyList EntityRenderDispatcher.WipeoutWorldBoundary(Wipeout wipeout)` (the mapped region the renderer fills, or empty when nothing would be drawn). + +- [ ] **Step 1: Write the failing tests** + +Append to `ImagePageTests`: + +```csharp + [Fact] + public void FrameUsesTheMappedWipeoutRegionNotTheRawPixelVertices() + { + // Pixel space rotated 90 degrees: U up, V left. Raw vertices span 1 unit; the mapped region spans 5. + Wipeout wipeout = new() + { + InsertPoint = new XYZ(10, 10, 0), + UVector = new XYZ(0, 5, 0), + 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(); + page.AddEntity(wipeout); + + PageFrame frame = Assert.NotNull(page.ComputeFrame(null)).Value; + + // Mapped corners: (10,10)+(x+0.5)U+(1-y-0.5)V for the four corners → x in [5,10], y in [10,15]. + Assert.Equal(5d, frame.PaperWidth, 6); + Assert.Equal(5d, frame.PaperHeight, 6); + } + + [Fact] + public void FrameMirrorsAnOcsSolidLikeTheRenderer() + { + Solid solid = new() { FirstCorner = new XYZ(0, 0, 0), SecondCorner = new XYZ(10, 0, 0), ThirdCorner = new XYZ(0, 5, 0), FourthCorner = new XYZ(10, 5, 0), Normal = new XYZ(0, 0, -1) }; + ImagePage page = new(); + page.AddEntity(solid); + + PageFrame frame = Assert.NotNull(page.ComputeFrame(null)).Value; + + // A (0,0,-1) normal mirrors X: the solid spans x in [-10, 0]. + Assert.Equal(-10d, frame.Translation.X * -1d, 6); // adjust to the sign convention PageFrame.Translation uses; see PageFrame.Of + Assert.Equal(10d, frame.PaperWidth, 6); + Assert.Equal(5d, frame.PaperHeight, 6); + } + + [Fact] + public void FrameSkipsAnInsertWithoutABlock() + { + Insert insert = new(new BlockRecord("GONE")) { InsertPoint = new XYZ(1000, 1000, 0) }; + typeof(Insert).GetProperty(nameof(Insert.Block))!.SetValue(insert, null); + ImagePage page = new(); + page.AddEntity(new Line(new XYZ(0, 0, 0), new XYZ(10, 0, 0))); + page.AddEntity(insert); + + PageFrame frame = Assert.NotNull(page.ComputeFrame(null)).Value; + + Assert.Equal(10d, frame.PaperWidth, 6); + } +``` + +Read `PageFrame.Of`/`ComputeFrame` to express the translation assertion correctly before running (the comment marks the line to adjust); the width/height assertions are the substance. + +Append to `EntityRenderDispatcherTests`: + +```csharp + [Fact] + public void InsertWithoutABlockIsSkippedWithAWarning() + { + RecordingDrawingSurface surface = new(); + ImageConfiguration configuration = new(); + List notifications = new(); + configuration.OnNotification += (_, e) => notifications.Add(e); + Insert insert = new(new BlockRecord("GONE")); + typeof(Insert).GetProperty(nameof(Insert.Block))!.SetValue(insert, null); + + new EntityRenderDispatcher(configuration).Draw(CreateContext(surface, configuration), insert); + + Assert.DoesNotContain(surface.Calls, c => c.StartsWith("Draw", StringComparison.Ordinal)); + Assert.Contains(notifications, n => n.NotificationType == NotificationType.Warning && n.Message.Contains("no block", StringComparison.OrdinalIgnoreCase)); + } +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `dotnet test ACadSharp.Image.Tests --filter "FullyQualifiedName~Frame|FullyQualifiedName~InsertWithoutABlock"` +Expected: FAIL (raw vertex bounds; OCS ignored; `NullReferenceException`). + +- [ ] **Step 3: Implement** + +In `EntityRenderDispatcher`, extract the region computation of `DrawWipeout` into + +```csharp + /// + /// The world polygon a wipeout masks: its clip boundary (a rectangular pair expanded to four corners) or the whole + /// image frame when clipping is off, mapped through . Empty when the wipeout + /// would draw nothing (image hidden or an inverted clip). + /// + internal static IReadOnlyList WipeoutWorldBoundary(Wipeout wipeout) +``` + +and make `DrawWipeout` use it (the notifications for `ClipMode.Inside` and transparent backgrounds stay in `DrawWipeout`). + +Create `EntityBounds`: + +```csharp +/// +/// Bounds the renderer would actually draw, for page framing. ACadSharp's GetBoundingBox ignores a wipeout's +/// pixel vectors and a solid's extrusion normal, and throws for some malformed geometry; this helper applies the +/// renderer's own mapping for those and reports failure instead of throwing. +/// +internal static class EntityBounds +{ + public static bool TryGet(Entity entity, out BoundingBox bounds) + { + bounds = default; + switch (entity) + { + case Insert insert when insert.Block == null: + return false; + case Wipeout wipeout: + return TryFromPoints(EntityRenderDispatcher.WipeoutWorldBoundary(wipeout), out bounds); + case Solid solid when !OcsTransform.IsWorldPlane(solid.Normal): + OcsTransform toWorld = OcsTransform.For(solid.Normal); + return TryFromPoints([ToWorld(toWorld, solid.FirstCorner), ToWorld(toWorld, solid.SecondCorner), ToWorld(toWorld, solid.ThirdCorner), ToWorld(toWorld, solid.FourthCorner)], out bounds); + } + + try + { + bounds = entity.GetBoundingBox(); + return true; + } + catch (Exception ex) when (ex is ArgumentException or InvalidOperationException) + { + // ACadSharp throws for some malformed geometry (e.g. a bulge between coincident vertices). + return false; + } + } + + private static XYZ ToWorld(OcsTransform toWorld, XYZ corner) => toWorld.ToWorld(corner.X, corner.Y, corner.Z); + + private static bool TryFromPoints(IReadOnlyList points, out BoundingBox bounds) + { + bounds = default; + if (points.Count == 0) + { + return false; + } + + double minX = points.Min(p => p.X), minY = points.Min(p => p.Y), minZ = points.Min(p => p.Z); + double maxX = points.Max(p => p.X), maxY = points.Max(p => p.Y), maxZ = points.Max(p => p.Z); + bounds = new BoundingBox(new XYZ(minX, minY, minZ), new XYZ(maxX, maxY, maxZ)); + return true; + } +} +``` + +(Split the `double` declarations onto separate lines per the conventions; check `OcsTransform.ToWorld(x, y, elevation)` returns `XYZ` — it does.) `ImagePage.ComputeFrame` replaces its `try { entity.GetBoundingBox() } catch` block with `if (!EntityBounds.TryGet(entity, out BoundingBox boundingBox)) { continue; }` and keeps the non-finite check after it. + +`DrawBlockContents`: first statement + +```csharp + if (insert.Block == null) + { + this._configuration.Notify($"[{insert.SubclassMarker}] Handle {insert.Handle.ToString("X", CultureInfo.InvariantCulture)}: block reference has no block; skipped.", NotificationType.Warning); + return; + } +``` + +and simplify the later `insert.Block?.` uses accordingly. + +- [ ] **Step 4: Run the tests and the suite** + +New tests → PASS. Full suite green; `git status --short ACadSharp.Image.Tests/Baselines` empty (no sample contains a wipeout or an OCS solid at page level; the synthetic features solid is on the world plane — if `features.model.01.*` moves, stop and report). + +- [ ] **Step 5: Docs** + +Spec 4.6: add bullet "Framing: auto-sized pages are framed with `EntityBounds`, which applies the wipeout pixel mapping and solid OCS normals the renderer uses and skips entities whose bounds ACadSharp cannot compute; an `Insert` without a block is skipped with a Warning everywhere." README: in the "Supported entities" paragraph add "A block reference whose block is missing is skipped with a warning." + +- [ ] **Step 6: Commit** + +```bash +git add ACadSharp.Image/Rendering/EntityBounds.cs ACadSharp.Image/ImagePage.cs ACadSharp.Image/Rendering/EntityRenderDispatcher.cs ACadSharp.Image.Tests/ImagePageTests.cs ACadSharp.Image.Tests/EntityRenderDispatcherTests.cs README.md docs/superpowers/specs/2026-09-02-layers-and-svg-design.md +git commit -m "Frame pages from the geometry the renderer draws and skip block references without a block" +``` + +--- + +### Task 3: Compose OCS solids and leader arrows with the insert transform; document nested order and thread safety + +**Findings:** Important 4 (a block SOLID with a non-world normal gets the insert transform applied to raw OCS corners and the normal applied afterwards, i.e. in the wrong order; a LEADER arrow inside a scaled insert keeps its unscaled size), Important 5 (block interiors below the first nesting level come back from ACadSharp's block clone in handle order, so "stored order" holds only at the first level), Important 6 (the MLINE heal mutates shared lists; concurrent rendering of one document is unsafe, and was already unsafe through `Explode()` itself). + +**Files:** +- Modify: `ACadSharp.Image/Rendering/EntityRenderDispatcher.cs` (`Draw` signature comment, switch arms for `Solid` and `Leader`, `DrawSolid`, `DrawLeader`, `DrawBlockContents` pairing) +- Modify: `ACadSharp.Image/ImageExporter.cs` (`Render` remarks), `README.md`, spec 4.6 +- Test: `ACadSharp.Image.Tests/EntityRenderDispatcherTests.cs` + +**Interfaces:** +- The private `Draw(..., Entity? source = null, Transform? placement = null)` parameter is renamed from `textSource` to `source` and now carries the original for TEXT, MTEXT, non-world SOLID and LEADER. +- `DrawSolid(ImageRenderContext, ImageStyle, Solid, Transform? placement)` and `DrawLeader(ImageRenderContext, ImageStyle, Leader, Transform? placement)` draw the given entity's geometry in its own coordinates and map every point through `placement` (null at top level). + +- [ ] **Step 1: Write the failing tests** + +```csharp + [Fact] + public void OcsSolidInsideAnInsertAppliesTheNormalBeforeTheInsertTransform() + { + RecordingDrawingSurface surface = new(); + ImageConfiguration configuration = new(); + BlockRecord block = new("PLATE"); + block.Entities.Add(new Solid { FirstCorner = new XYZ(0, 0, 0), SecondCorner = new XYZ(10, 0, 0), ThirdCorner = new XYZ(0, 5, 0), FourthCorner = new XYZ(10, 5, 0), Normal = new XYZ(0, 0, -1) }); + Insert insert = new(block) { InsertPoint = new XYZ(20, 0, 0) }; + + new EntityRenderDispatcher(configuration).Draw(CreateContext(surface, configuration), insert); + + // Normal (0,0,-1) mirrors X in OCS→world: corners x in [-10,0]; then the insert moves them by +20: x in [10,20]. + IReadOnlyList polygon = Assert.Single(surface.Polygons); + Assert.Equal(new HashSet { new(20, 100), new(10, 100), new(10, 95), new(20, 95) }, polygon.ToHashSet()); + } + + [Fact] + public void LeaderArrowInsideAScaledInsertScalesWithIt() + { + RecordingDrawingSurface surface = new(); + ImageConfiguration configuration = new(); + BlockRecord block = new("NOTE"); + block.Entities.Add(new Leader { ArrowHeadEnabled = true, Vertices = { new XYZ(0, 0, 0), new XYZ(10, 0, 0) }, Style = new DimensionStyle("ARROW") { ArrowSize = 3, ScaleFactor = 1 } }); + Insert insert = new(block) { InsertPoint = new XYZ(5, 5, 0), XScale = 2, YScale = 2 }; + + new EntityRenderDispatcher(configuration).Draw(CreateContext(surface, configuration), insert); + + IReadOnlyList arrow = Assert.Single(surface.Polygons); + Assert.Equal(new SurfacePoint(5, 95), arrow[0]); + // Source-space base at x=3 with half-width 0.5, scaled by 2 and moved by (5,5): x=11, y=5±1. + Assert.Contains(arrow, p => Math.Abs(p.X - 11) < 1e-9 && Math.Abs(p.Y - 94) < 1e-9); + 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)); + } +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Expected: FAIL (mirrored corners land at x in [20,30] or the OCS is applied after the move; the arrow base sits at x = 8). + +- [ ] **Step 3: Implement** + +In `DrawBlockContents`, extend the pairing: + +```csharp + if ((original is TextEntity or MText || original is Solid { } s && !IsWorldPlane(s.Normal) || original is Leader) && original.GetType() == entity.GetType()) + { + source = original; + entityPlacement = transform; + } +``` + +(write it as readable nested conditions rather than one expression if clearer). Switch arms: `case Solid solid: DrawSolid(context, style, source as Solid ?? solid, placement);` and `case Leader leader: this.DrawLeader(context, style, source as Leader ?? leader, placement);`. Rename `textSource` to `source` in the signature and the two text arms, and update the comment above `Draw` to: "source is the original block entity a TEXT, MTEXT, non-world SOLID or LEADER clone came from, whose geometry is used instead of the clone's, and placement is the transform of the insert that placed it; both are null outside a block reference." + +`DrawSolid` maps each corner OCS → world → placement: `XYZ world = toWorld != null ? toWorld.ToWorld(corner.X, corner.Y, corner.Z) : corner; return context.ToSurfacePoint(placement == null ? world : placement.ApplyTransform(world));`. For a world-plane solid clone `placement` is null (the clone was transformed by `Explode()`), so existing output is unchanged. + +`DrawLeader` computes the path and the arrow in the leader's own coordinates and maps every point with a local `SurfacePoint Map(XYZ p) => context.ToSurfacePoint(placement == null ? p : placement.ApplyTransform(p));`: path points `leader.Vertices.Select(Map)`, Catmull-Rom on the mapped points (affine maps commute with the Catmull-Rom construction), arrow corners built in source space (`tip`, `baseCenter ± half` as today) then mapped. Since a top-level leader has `placement == null`, its output is unchanged. + +Docs: spec 4.6 draw-order bullet, replace the stored-order clause with "The contents of a block reference are drawn in the block's stored order at the first nesting level; deeper levels come back from ACadSharp's block clone in handle order (`BlockRecord.Clone()` enumerates `GetSortedEntities()`), so DRAWORDER inside nested blocks is honoured only there." README "Supported entities": same sentence, shorter. Thread safety: `ImageExporter.Render` ``: "Rendering temporarily mutates block MLINEs while working around ACadSharp 3.7.1's destructive `MLine.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." README: add the same sentence under the SVG/PNG paragraph or a new "Thread safety" note. + +- [ ] **Step 4: Run the tests and the suite** + +New tests → PASS; full suite green; baselines unchanged (the features block's solid is world-plane; no sample has a block leader). + +- [ ] **Step 5: Commit** + +```bash +git add ACadSharp.Image/Rendering/EntityRenderDispatcher.cs ACadSharp.Image/ImageExporter.cs ACadSharp.Image.Tests/EntityRenderDispatcherTests.cs README.md docs/superpowers/specs/2026-09-02-layers-and-svg-design.md +git commit -m "Place block solids and leaders from their originals through the insert transform" +``` + +--- + +### Task 4: Carry the horizontal scale of non-uniformly scaled block text + +**Finding:** Important 3. `TextRenderer.Place` normalises the transformed reading direction and keeps only the up-vector length, so an insert with `XScale = 2, YScale = 1` draws its text at natural width and wraps MTEXT at the unscaled rectangle width. + +**Files:** +- Modify: `ACadSharp.Image/Rendering/TextRenderer.cs` (`Placement`, `Place`, both `Draw`s), `ACadSharp.Image/Rendering/SurfaceText.cs`, `ACadSharp.Image/Rendering/Svg/SvgDrawingSurface.cs` (`DrawText`), `ACadSharp.Image/Rendering/RasterDrawingSurface.cs` (`DrawText`) +- Test: `ACadSharp.Image.Tests/TextRendererTests.cs`, `ACadSharp.Image.Tests/SvgDrawingSurfaceTests.cs`, `ACadSharp.Image.Tests/RasterDrawingSurfaceTests.cs` + +**Interfaces:** +- `Placement(XY Origin, XY Direction, bool Mirrored, double Scale, double WidthScale)` where `WidthScale` is the length of the transformed unit reading direction. +- `SurfaceText` gains a trailing positional parameter `double WidthScale = 1d`: the factor glyph advances are stretched by along the baseline relative to `Height` (1 = natural). `WrappingWidth` and `FixedLength` are expressed in surface units of the stretched run. + +- [ ] **Step 1: Write the failing tests** + +`TextRendererTests` (use that file's existing helpers for drawing an entity inside an insert onto a `RecordingDrawingSurface`; the earlier uniform-scale test is the model): + +```csharp + [Fact] + public void NonUniformInsertScaleStretchesTextHorizontally() + { + RecordingDrawingSurface surface = new(); + ImageConfiguration configuration = new(); + BlockRecord block = new("LABEL"); + block.Entities.Add(new MText { Value = "Wide", InsertPoint = new XYZ(0, 0, 0), Height = 4, RectangleWidth = 30 }); + Insert insert = new(block) { InsertPoint = new XYZ(10, 10, 0), XScale = 2, YScale = 1 }; + + new EntityRenderDispatcher(configuration).Draw(EntityRenderDispatcherTests.CreateContext(surface, configuration), insert); + + SurfaceText run = Assert.Single(surface.Texts); + Assert.Equal(4d, run.Height, 9); + Assert.Equal(2d, run.WidthScale, 9); + Assert.Equal(60d, run.WrappingWidth, 9); + } +``` + +(make `CreateContext` internal static if it is private.) `SvgDrawingSurfaceTests`: draw a `SurfaceText("AB", origin (10, 20), height 4, rotation 0, Start, Alphabetic, -1, 1, -1, WidthScale: 2)` and assert the `` element's `transform` attribute equals `translate(10 20) scale(2 1) translate(-10 -20)`; with rotation π/2 as well assert it starts with `rotate(-90 10 20) ` followed by the same scale triple; with `WidthScale` 1 assert there is no `transform` attribute for rotation 0 (golden safety). `RasterDrawingSurfaceTests`: `DrawnText` gains an optional `double widthScale = 1d`; assert the ink column span of `"H"` at `widthScale: 2` is about twice the span at 1 (within 2 px) while the ink row span is unchanged (within 1 px). + +- [ ] **Step 2: Run the tests to verify they fail** + +Expected: compile failures for `WidthScale`, then FAIL. + +- [ ] **Step 3: Implement** + +`Place` returns `new Placement(new XY(o.X, o.Y), direction / length, mirrored, scale, length)`. Both `Draw`s pass `WidthScale: p.WidthScale / p.Scale` to `SurfaceText`, and the MTEXT wrapping width becomes `context.ToSurfaceLength(mtext.RectangleWidth * p.WidthScale)`. `GetFixedLength` already measures transformed points; leave it. + +`SvgDrawingSurface.DrawText`: wrap at `text.WrappingWidth / text.WidthScale` (the stretch is applied by the transform), set `textLength` to `text.FixedLength / text.WidthScale`, and build the transform list: rotation part as today, then when `Math.Abs(text.WidthScale - 1d) > 1e-9` append `translate({x} {y}) scale({sx} 1) translate({-x} {-y})` (space-separated, numbers through `this.N`/`this.S`). Validate `WidthScale` in the finiteness guard. + +`RasterDrawingSurface.DrawText`: `WrappingLength` divided by `text.WidthScale` (the raster does not use `FixedLength` at all, verified by grep, so nothing else scales); `drawingOptions.Transform = Matrix3x2.CreateScale((float)text.WidthScale, 1f, new Vector2(origin.X, origin.Y)) * Matrix3x2.CreateRotation((float)-text.Rotation, new Vector2(origin.X, origin.Y))` when either differs from identity (scale first, then rotation; check SixLabors' `Matrix3x2` multiplication order gives "scale then rotate" and adjust if the row-vector convention reverses it: the rotated `"H"` at `widthScale: 2` must widen along its own baseline, not along the canvas X axis — add that as a third raster assertion with rotation π/2: the ink ROW span doubles). + +- [ ] **Step 4: Run the tests and the suite** + +New tests → PASS. Full suite green; all baselines byte-identical (`WidthScale` is 1 everywhere in the samples: uniform scales only). + +- [ ] **Step 5: Docs** + +Spec 5.3 text bullet: append "**Amended 2026-09-04:** block text under a non-uniform insert scale carries `SurfaceText.WidthScale` (transformed reading-axis length over up-axis length); the SVG emits `translate scale translate` after the rotation and the raster composes the same matrix; wrapping width and fixed length follow the reading axis." README fidelity paragraph: replace the height sentence with "Text height follows the transformed up axis and width the transformed reading axis of the block reference that placed it." + +- [ ] **Step 6: Commit** + +```bash +git add ACadSharp.Image/Rendering/TextRenderer.cs ACadSharp.Image/Rendering/SurfaceText.cs ACadSharp.Image/Rendering/Svg/SvgDrawingSurface.cs ACadSharp.Image/Rendering/RasterDrawingSurface.cs ACadSharp.Image.Tests/TextRendererTests.cs ACadSharp.Image.Tests/SvgDrawingSurfaceTests.cs ACadSharp.Image.Tests/RasterDrawingSurfaceTests.cs ACadSharp.Image.Tests/EntityRenderDispatcherTests.cs README.md docs/superpowers/specs/2026-09-02-layers-and-svg-design.md +git commit -m "Stretch block text along the reading axis under non-uniform insert scales" +``` + +--- + +### Task 5: MLINE fallback under signed scale, inherited element linetypes, finite validation, and a block-subtree cache + +**Findings:** Minor 2 (extrema chosen before applying a negative `ScaleFactor`, so Top/Bottom anchor the wrong element; an element `LineType` named ByLayer/ByBlock is handed to `LineTypeDashResolver` and comes back solid instead of inheriting; non-finite style offsets or scale reach the fallback), Minor 3 (every insert walks its whole block subtree looking for MLINEs). + +**Files:** +- Modify: `ACadSharp.Image/Rendering/EntityRenderDispatcher.cs` (`DrawMLine`, `DrawBlockContents`, new `_blocksWithMLines` cache), `ACadSharp.Image/Rendering/ImageStyleResolver.cs` (`IsNamed` made `internal static`) +- Test: `ACadSharp.Image.Tests/EntityRenderDispatcherTests.cs` + +- [ ] **Step 1: Write the failing tests** + +```csharp + [Fact] + public void MLineFallbackUnderANegativeScaleAnchorsTheGeometricTopElement() + { + RecordingDrawingSurface surface = new(); + ImageConfiguration configuration = new(); + MLine mline = new() { Style = TwoElementStyle(0.5), ScaleFactor = -2, Justification = MLineJustification.Top, Vertices = { VertexAt(0, 10), VertexAt(20, 10) } }; + + new EntityRenderDispatcher(configuration).Draw(CreateContext(surface, configuration), mline); + + // Scaled offsets are -1 (element 0) and +1 (element 1); Top puts the +1 element on the vertex line and element 0 two units below it. + Assert.Equal([new SurfacePoint(0, 92), new SurfacePoint(20, 92)], surface.Polylines[0]); + Assert.Equal([new SurfacePoint(0, 90), new SurfacePoint(20, 90)], surface.Polylines[1]); + } + + [Fact] + public void MLineElementWithByLayerLinetypeInheritsTheEntityDashes() + { + RecordingDrawingSurface surface = new(); + ImageConfiguration configuration = new(); + MLineStyle style = new("DASHED"); + style.AddElement(new MLineStyle.Element { Offset = 0.5, LineType = new LineType(LineType.ByLayerName) }); + style.AddElement(new MLineStyle.Element { Offset = -0.5 }); + LineType dashed = new("DASHED2"); + dashed.AddSegment(new LineType.Segment { Length = 2 }); + dashed.AddSegment(new LineType.Segment { Length = -1 }); + MLine mline = new() { Style = style, LineType = dashed, Vertices = { VertexAt(0, 10, [0.5, 0], [-0.5, 0]), VertexAt(20, 10, [0.5, 0], [-0.5, 0]) } }; + + new EntityRenderDispatcher(configuration).Draw(CreateContext(surface, configuration), mline); + + Assert.NotNull(surface.Styles[0].DashPattern); + Assert.Equal(surface.Styles[1].DashPattern, surface.Styles[0].DashPattern); + } + + [Fact] + public void MLineWithANonFiniteScaleIsSkippedWithAWarning() + { + RecordingDrawingSurface surface = new(); + ImageConfiguration configuration = new(); + List notifications = new(); + configuration.OnNotification += (_, e) => notifications.Add(e); + MLine mline = new() { Style = TwoElementStyle(0.5), ScaleFactor = double.NaN, Vertices = { VertexAt(0, 10), VertexAt(20, 10) } }; + + new EntityRenderDispatcher(configuration).Draw(CreateContext(surface, configuration), mline); + + Assert.Empty(surface.Polylines); + Assert.Contains(notifications, n => n.NotificationType == NotificationType.Warning && n.Message.Contains("non-finite", StringComparison.OrdinalIgnoreCase)); + } +``` + +Check `LineType.Segment`/`AddSegment` names against 3.7.1 (`LineTypeDashResolverTests` builds linetypes already; copy its construction) and adjust. + +- [ ] **Step 2: Run the tests to verify they fail** + +Expected: FAIL (element 0 on the vertex line; solid element; NaN offsets drawn or exception). + +- [ ] **Step 3: Implement** + +`DrawMLine`: compute `double[] scaled = elements.Select(e => e.Offset * scale).ToArray();` and take `maxOffset`/`minOffset` and the `outer`/`inner` indices from `scaled`; shift becomes `-max(scaled)` / `-min(scaled)`. Before any drawing, when the fallback is needed for any vertex (or unconditionally, it is cheap): if `!double.IsFinite(scale) || scaled.Any(v => !double.IsFinite(v))` → Warning `"[...] Handle X: multiline style has non-finite offsets or scale; entity skipped."` and return. Element linetype: `LineType? elementType = elements[j].LineType; float[]? dashes = elementType == null || ImageStyleResolver.IsNamed(elementType, LineType.ByLayerName) || ImageStyleResolver.IsNamed(elementType, LineType.ByBlockName) ? style.DashPattern : LineTypeDashResolver.Resolve(...)`. Make `IsNamed` `internal static` with a ``. + +Cache: `private readonly Dictionary _blocksWithMLines = new();` on the dispatcher; `private bool BlockSubtreeHasMLines(BlockRecord block, HashSet visited)` memoised per block (an MLINE directly in the block, or any nested `Insert.Block` subtree with one). `DrawBlockContents` calls `CollectMLines` only when `BlockSubtreeHasMLines(insert.Block, new HashSet())` is true; otherwise `mlineVertices` stays empty and the streaming `Explode()` path is taken. The dispatcher belongs to an `ImagePageRenderer`, which can render several pages and outlives a single `RenderTo` (`ImagePageRenderer.cs:32-36`), so a stale `false` would let `Explode()` empty an MLINE added to a block between two renders. Add `internal void BeginPage()` on the dispatcher that clears the cache, call it at the start of `ImagePageRenderer.RenderTo`, and add this test to `ImagePageRendererTests`: render a block page holding an insert of an MLINE-free block through one renderer, then add an `MLine` (two vertices, parameters `[0.5,0]/[-0.5,0]`) to that block and render the same page again with the same renderer; assert the MLINE still has two vertices and that two polylines were drawn in the second render. + +- [ ] **Step 4: Run the tests and the suite** + +New tests → PASS; the existing MLINE tests (including the nested-block one) stay green; full suite green; baselines unchanged. + +- [ ] **Step 5: Docs** + +Spec 4.6 MLINE bullet: add "justification fallback uses the signed scaled offsets; element linetypes named ByLayer/ByBlock inherit the entity's dashes; a style with non-finite offsets or scale skips the entity with a Warning; block subtrees are scanned for MLINEs once per render and cached." + +- [ ] **Step 6: Commit** + +```bash +git add ACadSharp.Image/Rendering/EntityRenderDispatcher.cs ACadSharp.Image/Rendering/ImageStyleResolver.cs ACadSharp.Image.Tests/EntityRenderDispatcherTests.cs docs/superpowers/specs/2026-09-02-layers-and-svg-design.md +git commit -m "Fix MLINE fallback offsets and inherited element linetypes, cache block MLINE scans" +``` + +--- + +### Task 6: SVG whitespace, stale docs, CLI positional arguments, dead code, CI paths + +**Findings:** Minor 1 (`` collapses the repeated spaces the wrapper preserves), Minor 4 (`Dpi` and `FontFamilyName` XML docs are stale), Minor 5 (extra positional CLI arguments are silently ignored), Minor 6 (unused `ImageStyleResolver.Resolve` and the `ImagePage`-based SVG context overloads), Pass 3 CI verdict (path filters miss the props/solution files; no explicit build step), Pass 1 baseline note (plan 07's constraint was never amended for commit `3c3793c`). + +**Files:** +- Modify: `ACadSharp.Image/Rendering/Svg/SvgDrawingSurface.cs` (`DrawText`), `ACadSharp.Image/ImageConfiguration.cs:141-148, 212-220`, `ACadSharp.Image.Cli/Program.cs:228-232`, `ACadSharp.Image/Rendering/ImageStyleResolver.cs` (remove `Resolve`), `ACadSharp.Image/Rendering/ImageRenderContext.cs` (remove `ComputeSvgViewBox(ImagePage, …)`, `ComputeSvgFitScale(ImagePage, …)`, `CreateSvgPageContext(IDrawingSurface, ImagePage, …)`; keep `CreatePageContext(IDrawingSurface, ImagePage, …)`, which tests use), `.github/workflows/ci.yml`, `docs/superpowers/plans/2026-09-03-07-text-fidelity.md:17` +- Test: `ACadSharp.Image.Tests/SvgDrawingSurfaceTests.cs`, `ACadSharp.Image.Tests/CliTests.cs` + +- [ ] **Step 1: Write the failing tests** + +`SvgDrawingSurfaceTests`: drawing `SurfaceText("A B", ...)` yields a `` element with attribute `xml:space="preserve"` (check with `XNamespace.Xml + "space"`). `CliTests`: `Program.Run(["a.dxf", "b.dxf"], out, err)` returns 1 and `err` contains `Unexpected argument 'b.dxf'` (mirror the file's existing style for error assertions; the input need not exist because parsing fails first — verify the parse runs before the file check, otherwise create a temp file). + +- [ ] **Step 2: Run the tests to verify they fail** + +Expected: FAIL. + +- [ ] **Step 3: Implement** + +`DrawText`: add `new XAttribute(XNamespace.Xml + "space", "preserve")` to every `` element. `ImageConfiguration.Dpi` remarks → "This value affects line weight calculations only; text is sized from the drawing on both backends. Default is 96 DPI." `FontFamilyName` remarks → "If the family is not installed, the first installed family of the fallback chain `FontResolver.Fallbacks` (Liberation Sans, DejaVu Sans, Arial, Helvetica, Noto Sans, Segoe UI) is used, then the first installed family; when no font is installed, raster text is skipped with a warning and SVG text is emitted unwrapped." (Verify the no-font behaviour in `FontResolver`/`RasterDrawingSurface` and word the sentence to match.) `Program.cs`: `if (inputPath != null) { throw new ArgumentException($"Unexpected argument '{current}'."); } inputPath = current;` (use whatever exception type the parser already uses for bad values so `Run` reports it the same way). Remove the dead members and any XML `` pointing at them. CI: add `'Directory.Packages.props'`, `'Directory.Build.props'`, `'*.sln'` to both path lists, and a `Build` step `dotnet build ACadSharp.Image.sln --configuration Release --no-restore -warnaserror` before the test step (then `dotnet test ... --no-build`). Plan 07 line 17: append "**Amended 2026-09-03 (final review):** Commit B of the final fix wave regenerated `HSK80AHCP16190M_BMG.model.01.png` and `features.model.01.png` for the 5/3 line spacing, with the cause in its body." + +- [ ] **Step 4: Run the tests and the suite; regenerate the text goldens** + +The four SVG goldens with `` carries `xml:space="preserve"` so the whitespace the wrapper keeps is rendered." README fidelity paragraph: "Repeated spaces inside text are preserved in both outputs." + +- [ ] **Step 6: Commit** + +```bash +git add ACadSharp.Image/Rendering/Svg/SvgDrawingSurface.cs ACadSharp.Image/ImageConfiguration.cs ACadSharp.Image.Cli/Program.cs ACadSharp.Image/Rendering/ImageStyleResolver.cs ACadSharp.Image/Rendering/ImageRenderContext.cs .github/workflows/ci.yml docs/superpowers/plans/2026-09-03-07-text-fidelity.md ACadSharp.Image.Tests/SvgDrawingSurfaceTests.cs ACadSharp.Image.Tests/CliTests.cs ACadSharp.Image.Tests/Baselines README.md docs/superpowers/specs/2026-09-02-layers-and-svg-design.md +git commit -m "Preserve text whitespace in SVG, refresh stale docs, reject extra CLI arguments, drop dead overloads" +``` + +--- + +### Task 7: Tests the review asked for: DRAWORDER table, real renders of the new entities, tightened weak tests + +**Findings:** Pass 3 (no DRAWORDER-table test; plan 08 entities never rendered through a real surface; `ConstantAttributeDefinitionsAreStillDrawn` passes without the explode path; the non-finite MLINE/WIPEOUT export tests pass without the `HasFiniteGeometry` arms). + +**Files:** +- Modify: `ACadSharp.Image.Tests/ImagePageTests.cs`, `ACadSharp.Image.Tests/SyntheticSamples.cs` (new `EntityBlock()`), `ACadSharp.Image.Tests/EntityRenderDispatcherTests.cs` +- Create: `ACadSharp.Image.Tests/EntityGoldenTests.cs`, `ACadSharp.Image.Tests/Baselines/entities.model.01.png`, `ACadSharp.Image.Tests/Baselines/entities.model.01.svg` + +- [ ] **Step 1: DRAWORDER table test** + +```csharp + [Fact] + public void AddHonoursTheDrawOrderTable() + { + BlockRecord block = new("ORDER"); + Line low = WithHandle(new Line(new XYZ(0, 0, 0), new XYZ(1, 0, 0)), 0x10); + Line high = WithHandle(new Line(new XYZ(0, 0, 0), new XYZ(0, 1, 0)), 0x20); + block.Entities.Add(low); + block.Entities.Add(high); + block.CreateSortEntitiesTable(); + block.SortEntitiesTable!.Add(low, 0x30); // the low-handle entity is sorted last + + ImagePage page = new(); + page.Add(block, resizeLayout: false); + + Assert.Equal([0x20UL, 0x10UL], page.Entities.Select(e => e.Handle)); + } +``` + +`CreateSortEntitiesTable()` and `SortEntitiesTable.Add(Entity, ulong sorterHandle)` exist in 3.7.1 (verified). If the sorter semantics turn out to be "sorter handle replaces the entity's handle for ordering" as assumed, the expected order holds; if the probe shows otherwise, adjust the expected order to what `GetSortedEntities()` actually returns and explain in the report (the point is to pin table-driven ordering, not a particular semantic). + +- [ ] **Step 2: Synthetic entity block and goldens** + +Add `SyntheticSamples.EntityBlock()` returning a `BlockRecord("entities")` with: a `Face3D` quad at (0,0)-(20,15) with `Flags = InvisibleEdgeFlags.Third`; a `Leader` with arrow from (30,0) to (45,10) to (60,10) on a style `ArrowSize = 2`; a splined `Leader` (`PathType = LeaderPathType.Spline`, `ArrowHeadEnabled = true`, same style) through (70,0),(80,10),(90,0),(100,10); both leaders have `ArrowHeadEnabled = true`; an `MLine` with a two-element fill-on style (offsets ±1, `FillColor` colour 3, element colours 1 and 5) along (0,30)-(40,30)-(40,50) with proper miters: first vertex `Miter (0,1,0)` parameters `[1,0]/[-1,0]`, the corner vertex `Miter (-1,1,0)/√2` with parameters `[√2,0]/[-√2,0]` (offset ±1 at a right-angle corner lies √2 along the bisector), last vertex `Miter (-1,0,0)` parameters `[1,0]/[-1,0]`; a `Line` from (60,30) to (100,30) on layer "Under" followed (later in the entity list, higher handle if handles are set) by a `Wipeout` covering (70,25)-(90,35) (`InsertPoint (70,25)`, `UVector (20,0,0)`, `VVector (0,10,0)`, `Size (1,1)`, `ClippingState = true`, rectangular `(-0.5,-0.5),(0.5,0.5)`); an `Insert` of a block with an `AttributeDefinition` tag "ROOM" at (0,0) whose insert sits at (60,45) with the attribute value "A-101" placed at (60,45) height 3. Use distinct layers with colours so the SVG groups are easy to assert. + +`EntityGoldenTests` mirrors `FeatureGoldenTests`: `EntityExporter()` (800x500, padding 10, font DejaVu Sans), `EntityPngMatchesBaseline` → `GoldenAssert.Png("entities.model.01", ...)`, and `EntitySvgMatchesGoldenAndContainsEveryEntity` asserting: exactly one `` from the 3DFACE (open run, 4 points) on its layer; two `` fills with `data-type` LEADER (arrows) and one `` with `C` commands; MLINE: one `` fill in colour 3 and two ``s in colours 1 and 5; WIPEOUT: a `` filled `#ffffff` on the wipeout's layer; ATTRIB: a `` "A-101" with `data-parent`; no Warning/NotImplemented notifications. Create the baselines with `ACADSHARP_IMAGE_UPDATE_BASELINES=1 dotnet test ACadSharp.Image.Tests --filter "FullyQualifiedName~EntityGoldenTests"` and inspect the PNG (open it, describe it in the report). Add a raster occlusion assertion in `EntityPngMatchesBaseline`: a pixel on the "Under" line inside the wipeout rectangle is white, and one outside is not — compute the pixel positions from the exporter's fit (`ImageRenderContext.CreatePageContext(surface, page, configuration)` gives `ToSurfacePoint`; or sample a small window and assert on the darkest pixel). + +- [ ] **Step 3: Tighten the weak tests** + +`ConstantAttributeDefinitionsAreStillDrawn`: after constructing the insert, `insert.Attributes.Clear()` so the constant value can only come from the explode path (keep the "exactly once" assertion); the separate no-ATTRIB test then duplicates it — delete the duplicate. `FilledMLineWithANonFiniteVertexIsSkippedWithoutKillingTheExport` and the wipeout counterpart: assert the Warning message contains "geometry contains non-finite values; entity skipped" (the dispatcher's `HasFiniteGeometry` message) and that no notification contains "Raster:" (the surface-level fallback must not be what saved the export). + +- [ ] **Step 4: Run the suite** + +Full suite green; `git status --short ACadSharp.Image.Tests/Baselines` shows only the two new `entities.model.01.*` files. + +- [ ] **Step 5: Commit** + +```bash +git add ACadSharp.Image.Tests +git commit -m "Pin DRAWORDER tables, render the new entities through both backends, tighten two tests" +``` + +--- + +## Self-review + +- Coverage: I1 → T1; I2, I7 → T2; I4, I5, I6 → T3; I3 → T4; Minor 2, 3 → T5; Minor 1, 4, 5, 6 (dead code) + CI + plan-07 note → T6; Pass 3 gaps → T7. Declined/deferred items are listed in the header. +- Type consistency: `EntityBounds.TryGet` (T2) is consumed by `ImagePage.ComputeFrame` (T2); `WipeoutWorldBoundary` (T2) is consumed by `EntityBounds` and `DrawWipeout`; the `source`/`placement` parameters (T3) are the ones T4 relies on for text; `SurfaceText.WidthScale` (T4) defaults to 1 so T5–T7 callers need no change; `ImageStyleResolver.IsNamed` (T5) is the existing private helper made internal. +- Baselines: T1 (viewport sheet, measured), T6 (four text SVGs, attribute-only), T7 (new files) are the only tasks allowed to touch `Baselines/`. diff --git a/docs/superpowers/plans/2026-09-04-10-remaining-limitations.md b/docs/superpowers/plans/2026-09-04-10-remaining-limitations.md new file mode 100644 index 0000000..e0396d3 --- /dev/null +++ b/docs/superpowers/plans/2026-09-04-10-remaining-limitations.md @@ -0,0 +1,2137 @@ +# Remaining Rendering Limitations Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Draw, instead of notifying about, the five remaining rendering gaps: multi-line attributes, tilted hatches inside blocks, custom arrowhead blocks, inverted wipeout clips, and MLEDIT cut segments. + +**Architecture:** One internal placement module gathers the point/vector/OCS mapping that the dispatcher and the text renderer duplicate today. Block contents keep coming from `Insert.Explode()` with ordinal pairing; the existing `UsesOriginalGeometry` relation is extended so hatches (and, for bounds, wipeouts) are drawn from the original entity in its own OCS and mapped through the insert transform, and a cycle is caught by the scan that already walks the original block graph before `Explode()` runs. Custom arrow blocks are drawn by handing a transient `Insert` of the arrow block to the existing block-content path, so every entity type inside an arrow block gets the transform treatment it already has. + +**Tech Stack:** .NET 10, ACadSharp 3.7.1, SixLabors.ImageSharp (raster backend), `System.Xml.Linq` (SVG backend), xUnit. + +**Spec:** `docs/superpowers/specs/2026-09-04-remaining-limitations-design.md` (which follows `docs/superpowers/specs/2026-09-02-layers-and-svg-design.md`; both bind). + +> **Corrections applied during execution** — including to the test snippets quoted in the tasks below, several of which were wrong as written — are recorded in the *As implemented (2026-09-04)* section at the end of that design, which lists them per task. Read it before trusting a snippet in this plan. + +## Global Constraints + +- ACadSharp `3.7.1`; SixLabors packages as pinned; no new NuGet dependencies; target frameworks unchanged. +- Coding conventions: `this.` prefix on instance members, explicit types except LINQ lambdas, XML docs on public and internal members and a `` on private helpers, `sealed` classes, file-scoped namespaces, four-space indent, UTF-8 without BOM (never add or remove a BOM), LF line endings. +- PNG baselines and SVG goldens in `ACadSharp.Image.Tests/Baselines/` are byte-identical except where a task says otherwise. Task 8 creates `fidelity.model.01.png` and `fidelity.model.01.svg`. No other task may move a baseline; regeneration uses the scoped command the task gives, with the cause in the commit body. Never run the update variable over the whole suite. +- `dotnet build ACadSharp.Image.sln -warnaserror` warning-free; full suite green before each commit (`dotnet test ACadSharp.Image.sln --configuration Release -warnaserror`). The suite stands at 393 tests before Task 1. +- No reference to any drawing outside `Samples/` in code, tests, comments or commit messages. +- Never use bare `git stash` / `git stash pop`. Commit only the files the task names (never `git add -A`). Commit messages end with exactly these two trailer lines: + ``` + Co-Authored-By: Claude Opus 5 (1M context) + Claude-Session: https://claude.ai/code/session_016RGgSinQSUz4d89FRLxhMz + ``` +- Notifications use `ImageConfiguration.Notify(message, NotificationType[, exception])` with the message shape `[{entity.SubclassMarker}] Handle {handle:X}: ...`. +- `EntityRenderDispatcherTests.CreateContext` is `internal static` and gives a 100x100 surface at scale 1 with no offset: CAD `(x, y)` lands at `SurfacePoint(x, 100 - y)`. `RecordingDrawingSurface` records `Polygons`, `Polylines`, `Lines`, `Texts`, `Paths` and `Styles`. + +## ACadSharp 3.7.1 facts these tasks depend on (probe-verified 2026-09-04) + +- `Insert.GetTransform()` produces `world = R * S * p + (InsertPoint - BasePoint)`. AutoCAD's documented INSERT semantics are `world = InsertPoint + R * S * (p - BasePoint)`. The two agree only when the rotation and scale are identity, so a block with a **non-zero base point** inserted with rotation or scale is placed differently by ACadSharp than by AutoCAD. Measured: base point `(2,3)`, block point `(4,3)`, insert at `(10,10)` rotated 90 degrees, ACadSharp gives `(5,11)` where AutoCAD gives `(10,12)`. This is latent in practice (no sample or private drawing has a non-zero base point on a rotated or scaled insert) but Task 5 must compensate for it explicitly, because it builds an insert on purpose. +- A clone shares these list objects with its source: `MLine.Vertices`, `Leader.Vertices`, `Wipeout.ClipBoundaryVertices`. `LwPolyline.Vertices`, `Spline.ControlPoints`/`FitPoints`/`Knots`, `Polyline2D.Vertices` and `Hatch.Paths` are copied. `Explode()` overwrites the shared MLINE and LEADER lists in place; it never writes the wipeout clip list. +- `Wipeout.ApplyTransform` transforms `UVector` and `VVector` as points, so a translation contaminates them. Measured: `UVector (1,0,0)` became `(10,22,0)` under an insert at `(10,20)` with scale 2/3 and 90 degrees of rotation. +- `new Insert(BlockRecord)` creates one `AttributeEntity` per `AttributeDefinition` in the block, including constant ones. +- Both readers populate `AttributeBase.MText` for multi-line attributes: the DXF reader on the embedded-object marker (group code 101), the DWG reader in `readCommonAttData` for `AttributeType.MultiLine` and `ConstantMultiLine`. The DWG reader only reads `AttributeType` for R2018 and later files, so an older DWG always reports `SingleLine`. +- The renderer does not read `MLine.Vertex.Direction`; MLINE geometry comes from `Position`, `Miter` and `Segments[j].Parameters`. + +--- + +## File Structure + +- **Create** `ACadSharp.Image/Rendering/InsertPlacement.cs` — the point, vector and OCS-point mapping used by every task below, plus the planar similarity test Task 5 needs. Nothing else goes in this file. +- **Modify** `ACadSharp.Image/Rendering/EntityRenderDispatcher.cs` — the switch arms and per-entity helpers (`DrawHatch`, `DrawLeader`, `DrawWipeout`, `DrawMLine`, `DrawBlockContents`, `UsesOriginalGeometry`, `WipeoutWorldBoundary`). +- **Modify** `ACadSharp.Image/Rendering/TextRenderer.cs` — a `DrawAttribute` entry point for multi-line attributes. +- **Modify** `ACadSharp.Image/Rendering/EntityBounds.cs` — wipeout rings feed framing and viewport culling. +- **Tests** in `ACadSharp.Image.Tests/EntityRenderDispatcherTests.cs`, `TextRendererTests.cs`, `ImagePageTests.cs`, `SyntheticSamples.cs`, and a new `FidelityGoldenTests.cs`. +- **Docs**: `docs/superpowers/specs/2026-09-02-layers-and-svg-design.md` section 4.6, `README.md` known limitations. + +--- + +### Task 1: Insert placement helpers + +**Files:** +- Create: `ACadSharp.Image/Rendering/InsertPlacement.cs` +- Modify: `ACadSharp.Image/Rendering/TextRenderer.cs` (`Place` uses `MapPoint`), `ACadSharp.Image/Rendering/EntityRenderDispatcher.cs` (`DrawSolid`, `DrawLeader`, `DrawMLine` use `MapPoint`) +- Test: `ACadSharp.Image.Tests/InsertPlacementTests.cs` (create) + +**Interfaces:** +- Consumes: nothing from other tasks. +- Produces, all `internal static` on `InsertPlacement`: + - `XYZ MapPoint(Transform? placement, XYZ point)` + - `XYZ MapVector(Transform? placement, XYZ vector)` + - `XYZ MapOcsPoint(Transform? placement, OcsTransform? toWorld, double elevation, XYZ ocsPoint)` + - `bool TryGetPlanarSimilarity(Transform? placement, out double scale, out double rotation, out bool mirrored)` + +- [ ] **Step 1: Write the failing tests** + +Create `ACadSharp.Image.Tests/InsertPlacementTests.cs`: + +```csharp +using ACadSharp.Entities; +using ACadSharp.Image.Rendering; +using ACadSharp.Tables; +using CSMath; +using Xunit; + +namespace ACadSharp.Image.Tests; + +public sealed class InsertPlacementTests +{ + [Fact] + public void MapPointWithoutAPlacementReturnsThePointUnchanged() + { + Assert.Equal(new XYZ(3, 4, 5), InsertPlacement.MapPoint(null, new XYZ(3, 4, 5))); + } + + [Fact] + public void MapVectorDropsTheTranslation() + { + Transform placement = Transform.CreateTranslation(new XYZ(100, 200, 300)); + + Assert.Equal(new XYZ(1, 0, 0), InsertPlacement.MapVector(placement, new XYZ(1, 0, 0))); + } + + [Fact] + public void MapVectorKeepsTheLinearPart() + { + Transform placement = PlacementOf(new XYZ(100, 200, 0), 0d, 2, 3); + + XYZ mapped = InsertPlacement.MapVector(placement, new XYZ(1, 1, 0)); + + Assert.Equal(2d, mapped.X, 9); + Assert.Equal(3d, mapped.Y, 9); + } + + /// + /// A placement built the way production builds one: from a real block reference. Constructing a + /// Transform directly would depend on an argument order these tests should not be pinning. + /// + private static Transform PlacementOf(XYZ insertPoint, double rotation, double xScale, double yScale) + => new Insert(new BlockRecord("PLACEMENT")) + { + InsertPoint = insertPoint, + Rotation = rotation, + XScale = xScale, + YScale = yScale, + ZScale = Math.Abs(xScale), + }.GetTransform(); + + [Fact] + public void MapOcsPointAppliesTheOcsBeforeThePlacement() + { + // Normal (0,0,-1) mirrors X going from OCS to world: (4,1) becomes (-4,1); the placement then adds (10,0). + Transform placement = Transform.CreateTranslation(new XYZ(10, 0, 0)); + + XYZ mapped = InsertPlacement.MapOcsPoint(placement, OcsTransform.For(new XYZ(0, 0, -1)), 0d, new XYZ(4, 1, 0)); + + Assert.Equal(6d, mapped.X, 9); + Assert.Equal(1d, mapped.Y, 9); + } + + [Fact] + public void MapOcsPointUsesTheElevationForTheOutOfPlaneOffset() + { + XYZ mapped = InsertPlacement.MapOcsPoint(null, OcsTransform.For(new XYZ(0, 0, -1)), 7d, new XYZ(1, 2, 0)); + + Assert.Equal(-7d, mapped.Z, 9); + } + + [Fact] + public void MapOcsPointWithoutAnOcsIsAPlainPointMap() + { + Assert.Equal(new XYZ(1, 2, 0), InsertPlacement.MapOcsPoint(null, null, 0d, new XYZ(1, 2, 0))); + } + + [Fact] + public void ANullPlacementIsAUnitSimilarity() + { + Assert.True(InsertPlacement.TryGetPlanarSimilarity(null, out double scale, out double rotation, out bool mirrored)); + Assert.Equal(1d, scale, 9); + Assert.Equal(0d, rotation, 9); + Assert.False(mirrored); + } + + [Fact] + public void AUniformlyScaledRotationIsASimilarity() + { + Transform placement = PlacementOf(new XYZ(5, 5, 0), Math.PI / 2, 3, 3); + + Assert.True(InsertPlacement.TryGetPlanarSimilarity(placement, out double scale, out double rotation, out bool mirrored)); + Assert.Equal(3d, scale, 9); + Assert.Equal(Math.PI / 2, rotation, 9); + Assert.False(mirrored); + } + + [Fact] + public void AMirroredPlacementIsASimilarityAndSaysSo() + { + Transform placement = PlacementOf(XYZ.Zero, 0d, -2, 2); + + Assert.True(InsertPlacement.TryGetPlanarSimilarity(placement, out double scale, out double rotation, out bool mirrored)); + Assert.Equal(2d, scale, 9); + Assert.True(mirrored); + } + + [Fact] + public void ANonUniformScaleIsNotASimilarity() + { + Transform placement = PlacementOf(XYZ.Zero, 0d, 2, 5); + + Assert.False(InsertPlacement.TryGetPlanarSimilarity(placement, out _, out _, out _)); + } + + [Fact] + public void ANonUniformScaleUnderRotationIsNotASimilarityEvenWhenTheAxesMatchInLength() + { + // A 3:1 scale turned 45 degrees leaves both mapped axes the same length but no longer at right angles, so a + // check that only compared lengths would wrongly call this a similarity. + Transform placement = PlacementOf(XYZ.Zero, Math.PI / 4, 3, 1); + + Assert.False(InsertPlacement.TryGetPlanarSimilarity(placement, out _, out _, out _)); + } + + [Fact] + public void APlacementSeenEdgeOnIsNotASimilarity() + { + // Rotating a quarter turn about X flattens the Y axis onto Z, so nothing is left in the drawing plane. + Transform placement = Transform.CreateRotation(XYZ.AxisX, Math.PI / 2); + + Assert.False(InsertPlacement.TryGetPlanarSimilarity(placement, out _, out _, out _)); + } +} +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `dotnet test ACadSharp.Image.sln --configuration Release --filter "FullyQualifiedName~InsertPlacementTests"` +Expected: build failure, `InsertPlacement` does not exist. + +- [ ] **Step 3: Create the helper** + +Create `ACadSharp.Image/Rendering/InsertPlacement.cs`: + +```csharp +using CSMath; + +namespace ACadSharp.Image.Rendering; + +/// +/// Maps geometry through the transform of the block reference that placed it. A placement of null means the +/// entity is at top level and every map is the identity. +/// +/// +/// 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 +/// (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. +/// +internal static class InsertPlacement +{ + /// Maps a world point through the placement. + /// The transform of the insert that placed the entity, or null at top level. + /// The world point. + /// The placed world point. + internal static XYZ MapPoint(Transform? placement, XYZ point) => placement == null ? point : placement.ApplyTransform(point); + + /// Maps a world direction through the placement, keeping the linear part and dropping the translation. + /// The transform of the insert that placed the entity, or null at top level. + /// The world direction. + /// The placed direction, scaled and rotated but not translated. + internal static XYZ MapVector(Transform? placement, XYZ vector) + { + if (placement == null) + { + return vector; + } + + return placement.ApplyTransform(vector) - placement.ApplyTransform(XYZ.Zero); + } + + /// + /// Maps a point stored in an entity's own object coordinate system: the OCS frame first (with the entity's + /// elevation as the out-of-plane offset), then the placement. + /// + /// The transform of the insert that placed the entity, or null at top level. + /// The entity's OCS frame, or null when it lies in the world plane. + /// The entity's elevation along its own normal. + /// The point in the entity's OCS. + /// The placed world point. + internal static XYZ MapOcsPoint(Transform? placement, OcsTransform? toWorld, double elevation, XYZ ocsPoint) + { + XYZ world = toWorld != null ? toWorld.ToWorld(ocsPoint.X, ocsPoint.Y, elevation) : ocsPoint; + return MapPoint(placement, world); + } + + /// + /// Whether the placement acts on the drawing plane as a similarity: one uniform scale and a rotation, optionally + /// with a reflection. Geometry that has to be handed back to ACadSharp as an Insert can only be expressed + /// when this holds, because an Insert has no way to represent the shear a non-uniform scale composed with + /// a rotation produces. + /// + /// The transform to test, or null at top level. + /// Receives the uniform scale. + /// Receives the rotation of the mapped X axis, in radians. + /// Receives whether the mapped Y axis lies clockwise from the mapped X axis. + /// True when the placement is a planar similarity. + internal static bool TryGetPlanarSimilarity(Transform? placement, out double scale, out double rotation, out bool mirrored) + { + XYZ ex = MapVector(placement, XYZ.AxisX); + XYZ ey = MapVector(placement, XYZ.AxisY); + XY x = new(ex.X, ex.Y); + XY y = new(ey.X, ey.Y); + double lx = x.GetLength(); + double ly = y.GetLength(); + scale = lx; + rotation = 0d; + mirrored = false; + if (lx < 1e-12 || ly < 1e-12 || !double.IsFinite(lx) || !double.IsFinite(ly)) + { + return false; + } + + // A similarity keeps both axes the same length and at right angles; the tolerances are relative so a drawing + // in millimetres and one in metres are judged the same way. + if (Math.Abs(lx - ly) > 1e-9 * lx || Math.Abs((x.X * y.X) + (x.Y * y.Y)) > 1e-9 * lx * ly) + { + return false; + } + + rotation = Math.Atan2(x.Y, x.X); + mirrored = (x.X * y.Y) - (x.Y * y.X) < 0d; + return true; + } +} +``` + +- [ ] **Step 4: Route the existing duplicates through the helper** + +In `TextRenderer.cs`, delete the private `Apply` helper and replace its three uses inside `Place` so the body reads: + +```csharp + XYZ o = InsertPlacement.MapPoint(placement, origin); + XYZ dx = InsertPlacement.MapPoint(placement, origin + xAxis) - o; + XYZ dy = InsertPlacement.MapPoint(placement, origin + yAxis) - o; +``` + +In `EntityRenderDispatcher.cs`: +- in `DrawSolid`, replace `placement == null ? world : placement.ApplyTransform(world)` with `InsertPlacement.MapPoint(placement, world)`; +- in `DrawLeader`, replace the local `Map` body with `context.ToSurfacePoint(InsertPlacement.MapPoint(placement, p))`; +- in `DrawMLine`, replace `context.ToSurfacePoint(placement == null ? world : placement.ApplyTransform(world))` with `context.ToSurfacePoint(InsertPlacement.MapPoint(placement, world))`. + +Do not change any other behaviour in this task. + +- [ ] **Step 5: Run the tests and the suite** + +Run: `dotnet test ACadSharp.Image.sln --configuration Release -warnaserror` +Expected: the new tests pass, the existing 393 pass, all baselines byte-identical (`git status --short ACadSharp.Image.Tests/Baselines` empty). + +- [ ] **Step 6: Commit** + +```bash +git add ACadSharp.Image/Rendering/InsertPlacement.cs ACadSharp.Image/Rendering/TextRenderer.cs ACadSharp.Image/Rendering/EntityRenderDispatcher.cs ACadSharp.Image.Tests/InsertPlacementTests.cs +git commit -m "Add the insert placement helpers and route the existing maps through them" +``` + +--- + +### Task 2: Multi-line attributes + +**Files:** +- Modify: `ACadSharp.Image/Rendering/TextRenderer.cs` (add `DrawAttribute`), `ACadSharp.Image/Rendering/EntityRenderDispatcher.cs` (a switch arm before `case TextEntity`) +- Test: `ACadSharp.Image.Tests/TextRendererTests.cs` +- Docs: spec 4.6, `README.md` + +**Interfaces:** +- Consumes: `InsertPlacement` from Task 1 (not required directly, but the file must already exist). +- Produces: `public void DrawAttribute(ImageRenderContext context, ImageStyle style, AttributeBase attribute, Transform? placement)` on `TextRenderer`. + +An `AttributeEntity` derives from `TextEntity`, so today a multi-line attribute reaches `case TextEntity` and is drawn from its single-line `Value`. The new arm must come **before** `case MText` and `case TextEntity` in the switch, because `AttributeDefinition` and `AttributeEntity` are both `TextEntity` subclasses and neither is an `MText`. + +- [ ] **Step 1: Write the failing tests** + +Add to `ACadSharp.Image.Tests/TextRendererTests.cs`: + +```csharp + [Fact] + public void AMultiLineAttributeIsDrawnFromItsEmbeddedMTextNotItsSingleLineValue() + { + RecordingDrawingSurface surface = new(); + ImageConfiguration configuration = new(); + AttributeEntity attribute = new() + { + Tag = "ROOM", + Value = "WRONG", + AttributeType = AttributeType.MultiLine, + InsertPoint = new XYZ(0, 0, 0), + Height = 3, + MText = new MText { Value = "Line1\\PLine2", InsertPoint = new XYZ(10, 20, 0), Height = 4, RectangleWidth = 30 }, + }; + + new EntityRenderDispatcher(configuration).Draw(EntityRenderDispatcherTests.CreateContext(surface, configuration), attribute); + + SurfaceText run = Assert.Single(surface.Texts); + Assert.Contains("Line1", run.Text); + Assert.Contains("Line2", run.Text); + Assert.DoesNotContain("WRONG", run.Text); + Assert.Equal(4d, run.Height, 9); + Assert.Equal(30d, run.WrappingWidth, 9); + Assert.Equal(new SurfacePoint(10, 80), run.Origin); + } + + [Fact] + public void AMultiLineAttributeKeepsTheAttributeAsTheObservableEntity() + { + RecordingDrawingSurface surface = new(); + ImageConfiguration configuration = new(); + AttributeEntity attribute = new() + { + Tag = "ROOM", + Value = "WRONG", + AttributeType = AttributeType.MultiLine, + MText = new MText { Value = "A", InsertPoint = new XYZ(1, 1, 0), Height = 2 }, + }; + + new EntityRenderDispatcher(configuration).Draw(EntityRenderDispatcherTests.CreateContext(surface, configuration), attribute); + + Assert.Equal("ATTRIB", Assert.Single(surface.Entities).ObjectName); + } + + [Fact] + public void AMultiLineAttributeWithoutAnEmbeddedMTextFallsBackToItsValueWithAWarning() + { + RecordingDrawingSurface surface = new(); + ImageConfiguration configuration = new(); + List notifications = new(); + configuration.OnNotification += (_, e) => notifications.Add(e); + AttributeEntity attribute = new() + { + Tag = "ROOM", + Value = "FALLBACK", + AttributeType = AttributeType.MultiLine, + InsertPoint = new XYZ(5, 5, 0), + Height = 2, + }; + + new EntityRenderDispatcher(configuration).Draw(EntityRenderDispatcherTests.CreateContext(surface, configuration), attribute); + + Assert.Equal("FALLBACK", Assert.Single(surface.Texts).Text); + Assert.Contains(notifications, n => n.NotificationType == NotificationType.Warning && n.Message.Contains("multi-line layout", StringComparison.OrdinalIgnoreCase)); + } + + [Fact] + public void ASingleLineAttributeStillTakesTheTextPath() + { + RecordingDrawingSurface surface = new(); + ImageConfiguration configuration = new(); + AttributeEntity attribute = new() + { + Tag = "ROOM", + Value = "A-101", + InsertPoint = new XYZ(2, 2, 0), + Height = 2, + MText = new MText { Value = "IGNORED", InsertPoint = new XYZ(50, 50, 0), Height = 9 }, + }; + + new EntityRenderDispatcher(configuration).Draw(EntityRenderDispatcherTests.CreateContext(surface, configuration), attribute); + + SurfaceText run = Assert.Single(surface.Texts); + Assert.Equal("A-101", run.Text); + Assert.Equal(2d, run.Height, 9); + } +``` + +If `RecordingDrawingSurface` does not expose the entity records under the name `Entities`, use whatever member it records `BeginEntity` calls under and assert the `ObjectName` of the single record; check the surface's definition in `ACadSharp.Image.Tests/` before writing that assertion and adjust the test rather than the surface. + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `dotnet test ACadSharp.Image.sln --configuration Release --filter "FullyQualifiedName~TextRendererTests"` +Expected: the first three fail (the single-line value is drawn, no warning is raised); the fourth passes already. + +- [ ] **Step 3: Add `DrawAttribute` to `TextRenderer`** + +Add to `TextRenderer.cs`, after the `MText` `Draw` overload: + +```csharp + /// + /// Draws an attribute whose layout comes from an embedded . AutoCAD stores a multi-line + /// attribute's real layout there, and leaves the single-line Value as a flattened copy, so the embedded + /// object is the authority for everything geometric: the text, its rectangle width, height, rotation and + /// attachment point. The attribute itself stays the observable entity, so layer, colour, handle and parent + /// metadata are unchanged. + /// + /// The context that maps drawing units onto the surface. + /// The resolved style for the attribute. + /// 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 + /// 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. + /// + public void DrawAttribute(ImageRenderContext context, ImageStyle style, AttributeBase attribute, Transform? placement) + { + if (attribute.MText == null) + { + context.Configuration.Notify( + $"[{attribute.SubclassMarker}] Handle {attribute.Handle.ToString("X", CultureInfo.InvariantCulture)}: multi-line layout is not available; the single-line value was drawn.", + NotificationType.Warning); + this.Draw(context, style, (TextEntity)attribute, placement); + return; + } + + this.Draw(context, style, attribute.MText, placement); + } +``` + +Add `using System.Globalization;` to the file's usings if it is not already there. + +- [ ] **Step 4: Add the switch arm** + +In `EntityRenderDispatcher.Draw`, immediately **before** `case MText mtext:`, add: + +```csharp + case AttributeBase attribute when attribute.AttributeType is AttributeType.MultiLine or AttributeType.ConstantMultiLine: + this._textRenderer.DrawAttribute(context, style, source as AttributeBase ?? attribute, placement); + break; +``` + +**Scope ruling, carried from the plan's design.** An attribute that belongs to an insert nested inside another block is out of scope for this task and stays as it is. `DrawAttributes` passes no placement, and an exploded clone's embedded `MText` is never transformed by ACadSharp, so such an attribute is laid out in block-local coordinates. No sample and no available drawing contains one. Do not attempt to thread a placement through `DrawAttributes` here — that touches the attribute path for every insert and belongs in its own change. Instead record the limitation: add to the spec sentence below "An attribute belonging to an insert nested inside another block is laid out in that block's own coordinates; only top-level attributes are placed." and add the same sentence to the README known limitations. + +- [ ] **Step 5: Run the tests and the suite** + +Run: `dotnet test ACadSharp.Image.sln --configuration Release -warnaserror` +Expected: all pass; baselines byte-identical (no sample has a multi-line attribute). + +- [ ] **Step 6: Docs** + +In spec section 4.6, replace the sentence that says multi-line attributes are drawn from their single-line value with: + +``` +A multi-line attribute (`AttributeType.MultiLine` or `ConstantMultiLine`) is laid out from its embedded `MText` — text, rectangle width, height, rotation and attachment point — while the attribute stays the observable entity, so its layer, colour, handle and parent metadata are unchanged. When the embedded object is missing, which is what ACadSharp reports for a pre-2018 DWG, the single-line value is drawn with a Warning. +``` + +In `README.md`, remove "Multi-line attributes are drawn from their single-line value." from the known limitations. + +- [ ] **Step 7: Commit** + +```bash +git add ACadSharp.Image/Rendering/TextRenderer.cs ACadSharp.Image/Rendering/EntityRenderDispatcher.cs ACadSharp.Image.Tests/TextRendererTests.cs README.md docs/superpowers/specs/2026-09-02-layers-and-svg-design.md +git commit -m "Lay out multi-line attributes from their embedded MText" +``` + +--- + +### Task 3: Draw hatches from the original entity in its own OCS + +**Files:** +- Modify: `ACadSharp.Image/Rendering/EntityRenderDispatcher.cs` (`UsesOriginalGeometry`, `DrawHatch`, the `Hatch` switch arm, delete `NormalizeExplodedClone`) +- Test: `ACadSharp.Image.Tests/EntityRenderDispatcherTests.cs` +- Docs: spec 4.6, `README.md` + +**Interfaces:** +- Consumes: `InsertPlacement.MapOcsPoint` from Task 1. +- Produces: `private void DrawHatch(ImageRenderContext context, ImageStyle style, Hatch hatch, Transform? placement)`. + +A hatch clone inside a block is wrong twice over: `Hatch.ApplyTransform` transforms the raw OCS boundary as if it were world data and never folds in the elevation, and `NormalizeExplodedClone` then forces any non-world normal to `+Z` to hide it. Drawing from the original in its own OCS and mapping through the placement fixes both, and `NormalizeExplodedClone` becomes dead. + +- [ ] **Step 1: Write the failing tests** + +Add to `ACadSharp.Image.Tests/EntityRenderDispatcherTests.cs`: + +```csharp + [Fact] + public void ATiltedHatchInsideAnInsertIsMappedThroughItsOwnOcsThenTheInsertTransform() + { + RecordingDrawingSurface surface = new(); + ImageConfiguration configuration = new(); + BlockRecord block = new("TILT"); + Hatch hatch = new() { IsSolid = true, Normal = new XYZ(0, 0, -1), Elevation = 0d }; + hatch.Paths.Add(SquarePath(0, 0, 10, 10)); + block.Entities.Add(hatch); + Insert insert = new(block) { InsertPoint = new XYZ(20, 0, 0) }; + + new EntityRenderDispatcher(configuration).Draw(CreateContext(surface, configuration), insert); + + // Normal (0,0,-1) mirrors X going OCS to world, so the square spans x in [-10,0]; the insert then adds 20. + IReadOnlyList ring = Assert.Single(Assert.Single(surface.Paths)); + Assert.Equal(10d, ring.Min(p => p.X), 6); + Assert.Equal(20d, ring.Max(p => p.X), 6); + } + + [Fact] + public void ATiltedHatchAtTopLevelIsUnchanged() + { + RecordingDrawingSurface surface = new(); + ImageConfiguration configuration = new(); + Hatch hatch = new() { IsSolid = true, Normal = new XYZ(0, 0, -1), Elevation = 0d }; + hatch.Paths.Add(SquarePath(0, 0, 10, 10)); + + new EntityRenderDispatcher(configuration).Draw(CreateContext(surface, configuration), hatch); + + IReadOnlyList ring = Assert.Single(Assert.Single(surface.Paths)); + Assert.Equal(-10d, ring.Min(p => p.X), 6); + Assert.Equal(0d, ring.Max(p => p.X), 6); + } + + [Fact] + public void AHatchInsideAMirroredInsertKeepsItsExtent() + { + RecordingDrawingSurface surface = new(); + ImageConfiguration configuration = new(); + BlockRecord block = new("MIRROR"); + Hatch hatch = new() { IsSolid = true, Normal = XYZ.AxisZ, Elevation = 0d }; + hatch.Paths.Add(SquarePath(0, 0, 10, 10)); + block.Entities.Add(hatch); + Insert insert = new(block) { InsertPoint = new XYZ(50, 0, 0), XScale = -1 }; + + new EntityRenderDispatcher(configuration).Draw(CreateContext(surface, configuration), insert); + + IReadOnlyList ring = Assert.Single(Assert.Single(surface.Paths)); + Assert.Equal(40d, ring.Min(p => p.X), 6); + Assert.Equal(50d, ring.Max(p => p.X), 6); + } + + [Fact] + public void ATiltedHatchWithAnElevationIsPlacedAlongItsOwnNormal() + { + RecordingDrawingSurface surface = new(); + ImageConfiguration configuration = new(); + Hatch hatch = new() { IsSolid = true, Normal = new XYZ(0, 1, 0), Elevation = 5d }; + hatch.Paths.Add(SquarePath(0, 0, 10, 10)); + + new EntityRenderDispatcher(configuration).Draw(CreateContext(surface, configuration), hatch); + + // Normal (0,1,0): the OCS X axis is world +X and the OCS Y axis is world +Z, so the square's Y collapses and + // the elevation carries it to y = -5 in world (the arbitrary-axis frame's third axis). + IReadOnlyList ring = Assert.Single(Assert.Single(surface.Paths)); + Assert.All(ring, p => Assert.Equal(105d, p.Y, 6)); + } +``` + +`SquarePath` is a private helper in the test file; if it does not exist there, add it beside the other helpers: + +```csharp + private static Hatch.BoundaryPath SquarePath(double x0, double y0, double x1, double y1) + { + Hatch.BoundaryPath path = new(); + path.Edges.Add(new Hatch.BoundaryPath.Line { Start = new XY(x0, y0), End = new XY(x1, y0) }); + path.Edges.Add(new Hatch.BoundaryPath.Line { Start = new XY(x1, y0), End = new XY(x1, y1) }); + path.Edges.Add(new Hatch.BoundaryPath.Line { Start = new XY(x1, y1), End = new XY(x0, y1) }); + path.Edges.Add(new Hatch.BoundaryPath.Line { Start = new XY(x0, y1), End = new XY(x0, y0) }); + return path; + } +``` + +Check the exact `Hatch.BoundaryPath` edge API against ACadSharp 3.7.1 before relying on it, and adjust the helper (not the assertions) if the member names differ. The fourth test's expected value depends on the arbitrary-axis frame for normal `(0,1,0)`: run it first and, if the frame puts the elevation on the other side, flip the sign in the assertion and say so in your report — the point is that the elevation reaches the output, not which sign the frame gives it. + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `dotnet test ACadSharp.Image.sln --configuration Release --filter "FullyQualifiedName~EntityRenderDispatcherTests"` +Expected: the first test fails (the clone's normal was forced to `+Z`, so the square lands at x in [20,30]); the others pass or fail as the current code dictates. Record which. + +- [ ] **Step 3: Extend the pairing relation** + +In `UsesOriginalGeometry`, add `Hatch` to the always-original list so the body reads: + +```csharp + if (original is TextEntity or MText or Leader or Hatch) + { + return true; + } +``` + +- [ ] **Step 4: Give `DrawHatch` a placement** + +Change the signature to `private void DrawHatch(ImageRenderContext context, ImageStyle style, Hatch hatch, Transform? placement)` and replace the local `ToSurface` with: + +```csharp + // 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 + // 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); + SurfacePoint ToSurface(XYZ point) => context.ToSurfacePoint(InsertPlacement.MapOcsPoint(placement, toWorld, hatch.Elevation, point)); +``` + +Update the switch arm to `case Hatch hatch: this.DrawHatch(context, style, source as Hatch ?? hatch, placement); break;`. + +- [ ] **Step 5: Delete the dead normalisation** + +Delete the `NormalizeExplodedClone` method and its single call in `DrawBlockContents`. It existed only to hide the clone's wrong normal for hatches, which no longer reach the drawing path. + +One consequence to record rather than fix: when ordinal pairing fails — the count mismatch the existing Warning reports — a hatch falls back to its exploded clone, and that clone no longer has its normal normalised, so a mirrored hatch would be drawn from a flipped normal. Pairing failure already means the block's text may be misplaced too, and the Warning says so. Note it in your report; do not add a second normalisation path for it. + +- [ ] **Step 6: Run the tests and the suite** + +Run: `dotnet test ACadSharp.Image.sln --configuration Release -warnaserror` +Expected: all pass. Baselines must be byte-identical: the features sample's hatch is world-plane, and a world-plane hatch inside a mirrored insert is now drawn from the original through the placement instead of from a normalised clone, which produces the same points. If any baseline moves, STOP and report BLOCKED with the diff rather than regenerating. + +- [ ] **Step 7: Docs** + +In spec 4.6, replace the hatch clause that records the tilted-plane limitation with: + +``` +A hatch is drawn from the original block entity in its own OCS (normal and elevation) and mapped through the insert transform, so a hatch on a tilted plane inside a block is placed correctly; the exploded clone's boundary is never used, because ACadSharp 3.7.1 transforms it as if it were world data. +``` + +In `README.md`, remove "A hatch on a tilted plane inside a block is still wrong" from the known limitations. + +- [ ] **Step 8: Commit** + +```bash +git add ACadSharp.Image/Rendering/EntityRenderDispatcher.cs ACadSharp.Image.Tests/EntityRenderDispatcherTests.cs README.md docs/superpowers/specs/2026-09-02-layers-and-svg-design.md +git commit -m "Draw hatches from the original entity in its own OCS through the insert transform" +``` + +--- + +### Task 4: Refuse to explode a circular block graph + +**Files:** +- Modify: `ACadSharp.Image/Rendering/EntityRenderDispatcher.cs` (`DrawBlockContents`, `ScanBlockSubtree` result use) +- Test: `ACadSharp.Image.Tests/EntityRenderDispatcherTests.cs` + +**Interfaces:** +- Consumes: the existing `private (bool NeedsHeal, bool Truncated) ScanBlockSubtree(BlockRecord? block, HashSet visited)` and `private bool BlockSubtreeNeedsHeal(BlockRecord? block, HashSet visited)`. +- Produces: `internal static bool BlockGraphIsCircular(BlockRecord? block)` on `EntityRenderDispatcher`, used by `DrawBlockContents`, by `EntityBounds.TryGet`, and by Task 5 for an arrow block that leads back to itself. + +A block that contains an insert of itself makes `Insert.Explode()` deep-clone the graph until the stack overflows, inside ACadSharp, before the renderer draws anything. A guard at draw time cannot help: nested inserts hold deep-cloned block records, so identity is a different key at every level, and the overflow happens first. The scan that already walks the **original** graph before `Explode()` is the only place that can see it, and it already reports truncation on a cycle. + +- [ ] **Step 1: Write the failing test** + +Add to `ACadSharp.Image.Tests/EntityRenderDispatcherTests.cs`: + +```csharp + [Fact] + public void ABlockThatReferencesItselfIsSkippedWithAWarningInsteadOfOverflowing() + { + RecordingDrawingSurface surface = new(); + ImageConfiguration configuration = new(); + List notifications = new(); + configuration.OnNotification += (_, e) => notifications.Add(e); + CadDocument document = new(); + BlockRecord outer = new("OUTER"); + BlockRecord inner = new("INNER"); + document.BlockRecords.Add(outer); + document.BlockRecords.Add(inner); + outer.Entities.Add(new Line(new XYZ(0, 0, 0), new XYZ(1, 0, 0))); + outer.Entities.Add(new Insert(inner)); + inner.Entities.Add(new Insert(outer)); + Insert insert = new(outer); + document.Entities.Add(insert); + + new EntityRenderDispatcher(configuration).Draw(CreateContext(surface, configuration), insert); + + Assert.Contains(notifications, n => n.NotificationType == NotificationType.Warning && n.Message.Contains("references itself", StringComparison.OrdinalIgnoreCase)); + Assert.Empty(surface.Lines); + } + + [Fact] + public void ACircularBlockGraphDoesNotKillTheExporterWhileFramingThePage() + { + // Framing runs before rendering and recurses through the same graph, so this is the call that dies first if + // only the draw path is guarded. A stack overflow cannot be caught, so a regression here takes the whole + // test process down rather than failing this test: run it on its own when it is new. + CadDocument document = new(); + BlockRecord outer = new("OUTER"); + BlockRecord inner = new("INNER"); + document.BlockRecords.Add(outer); + document.BlockRecords.Add(inner); + outer.Entities.Add(new Line(new XYZ(0, 0, 0), new XYZ(1, 0, 0))); + outer.Entities.Add(new Insert(inner)); + inner.Entities.Add(new Insert(outer)); + document.Entities.Add(new Insert(outer)); + ImageExporter exporter = new(new ImageConfiguration()); + + exporter.Add(document.ModelSpace); + + Assert.NotNull(exporter.Pages); + } + + [Fact] + public void AnOrdinaryNestedBlockStillDraws() + { + RecordingDrawingSurface surface = new(); + ImageConfiguration configuration = new(); + BlockRecord outer = new("OUTER"); + BlockRecord inner = new("INNER"); + inner.Entities.Add(new Line(new XYZ(0, 0, 0), new XYZ(10, 0, 0))); + outer.Entities.Add(new Insert(inner) { InsertPoint = new XYZ(0, 5, 0) }); + Insert insert = new(outer) { InsertPoint = new XYZ(2, 3, 0) }; + + new EntityRenderDispatcher(configuration).Draw(CreateContext(surface, configuration), insert); + + Assert.Single(surface.Lines); + } +``` + +If constructing that cycle throws inside ACadSharp before the assertion runs, build the second insert with its block assigned after both blocks exist, using the same technique the existing block tests use, and record what you had to do in your report. + +- [ ] **Step 2: Run the tests to verify the first one fails** + +Run: `dotnet test ACadSharp.Image.sln --configuration Release --filter "FullyQualifiedName~ABlockThatReferencesItself"` +Expected: the test process dies with a stack overflow, or the test fails with no warning. Either counts as RED; record which you saw. Run this single test on its own so a stack overflow does not take the rest of the suite with it. + +- [ ] **Step 3: Add the guard** + +`ScanBlockSubtree` cannot answer this question. It stops as soon as it finds an MLINE or a LEADER, and it returns early on a cache hit, so a block whose entities are `[MLine, Insert(B)]` with B pointing back never walks B and never reports the cycle. It also caches, and a cached answer carries no truncation flag. Write a dedicated detector instead, with no cache and no short-circuit: + +```csharp + /// + /// Whether a block's own graph contains a cycle, so that a reference to it cannot be exploded. + /// + /// The block a reference points at. + /// True when walking the block's nested references reaches a block already on the walk. + /// + /// This walks the whole graph without caching or stopping early, unlike the heal scan: a cycle can hide behind + /// any branch, and an answer that stopped at the first interesting entity would miss it. Blocks are tracked on + /// the current path rather than globally, so a diamond — two references to the same block from different places — + /// is not mistaken for a cycle. + /// + /// It has to be answered before Insert.Explode() is called, not while drawing: exploding deep-clones the + /// whole block graph, so a cycle exhausts the stack inside ACadSharp before the renderer sees a single entity, + /// and a StackOverflowException cannot be caught in .NET — the process dies. A draw-time guard keyed on + /// the block record could not recognise a nested level anyway, because the inserts reached down there hold + /// deep-cloned records with a different identity at every level. + /// + /// + internal static bool BlockGraphIsCircular(BlockRecord? block) + { + return block != null && Walk(block, new HashSet()); + + static bool Walk(BlockRecord block, HashSet onPath) + { + if (!onPath.Add(block)) + { + return true; + } + + try + { + foreach (Entity entity in block.Entities) + { + if (entity is Insert nested && nested.Block != null && Walk(nested.Block, onPath)) + { + return true; + } + } + + return false; + } + finally + { + onPath.Remove(block); + } + } + } +``` + +In `DrawBlockContents`, immediately after the null-block guard, add: + +```csharp + if (BlockGraphIsCircular(insert.Block)) + { + this._configuration.Notify($"[{insert.SubclassMarker}] Handle {insert.Handle.ToString("X", CultureInfo.InvariantCulture)}: block '{insert.Block.Name}' references itself; skipped.", NotificationType.Warning); + return; + } +``` + +- [ ] **Step 4: Guard the bounds path too** + +Drawing is not the first thing that touches a block reference. `ImageExporter.Add` frames the page through `EntityBounds.TryGet`, which calls ACadSharp's `Insert.GetBoundingBox()`, and that recurses through the same block graph. A cycle kills the process there, before any guard in `DrawBlockContents` runs, so the public entry point must be guarded as well. + +In `EntityBounds.TryGet`, extend the existing `case Insert insert when insert.Block == null` arm into a pair: + +```csharp + case Insert insert when insert.Block == null: + bounds = default; + error = null; + return false; + case Insert insert when EntityRenderDispatcher.BlockGraphIsCircular(insert.Block): + bounds = default; + error = new InvalidOperationException($"block '{insert.Block!.Name}' references itself"); + return false; +``` + +Match the arm's real shape in the file — read it first, keep whatever the existing null-block arm does with `bounds` and `error`, and give the circular arm a non-null `error` so the viewport culler warns about it rather than dropping it silently. + +- [ ] **Step 5: Run the tests and the suite** + +Run: `dotnet test ACadSharp.Image.sln --configuration Release -warnaserror` +Expected: all pass, baselines byte-identical. + +- [ ] **Step 6: Docs** + +In spec 4.6, add to the block-contents bullet: + +``` +A block whose graph references itself is skipped with a Warning before it is exploded, and is refused by `EntityBounds` too, because `Insert.Explode()` and `Insert.GetBoundingBox()` both recurse through the graph and a stack overflow cannot be caught. +``` + +- [ ] **Step 7: Commit** + +```bash +git add ACadSharp.Image/Rendering/EntityRenderDispatcher.cs ACadSharp.Image/Rendering/EntityBounds.cs ACadSharp.Image.Tests/EntityRenderDispatcherTests.cs ACadSharp.Image.Tests/ImagePageTests.cs docs/superpowers/specs/2026-09-02-layers-and-svg-design.md +git commit -m "Refuse a circular block graph before exploding or bounding it" +``` + +--- + +### Task 5: Custom arrowhead blocks + +**Files:** +- Modify: `ACadSharp.Image/Rendering/EntityRenderDispatcher.cs` (`DrawLeader`, a new `DrawArrowBlock`) +- Test: `ACadSharp.Image.Tests/EntityRenderDispatcherTests.cs` +- Docs: spec 4.6, `README.md` + +**Interfaces:** +- Consumes: `InsertPlacement.TryGetPlanarSimilarity` (Task 1), `BlockGraphIsCircular` (Task 4), the existing `DrawBlockContents`. +- Produces: `private bool DrawArrowBlock(ImageRenderContext context, Layer? layer, ResolvedStyle parent, Leader leader, BlockRecord arrow, XY tip, XY direction, double size, double z, Transform? placement)` returning whether the block was drawn. + +Most entity types ignore the `placement` argument entirely: a `Line` inside a block is drawn from the clone's own transformed points, not from the original. So an arrow block cannot be drawn by walking its entities with a placement — it has to go through the same `Insert.Explode()` path every other block uses. The task therefore builds a transient `Insert` of the arrow block whose own transform is the composition of the arrow placement and any outer placement, and hands it to `DrawBlockContents`. + +Two ACadSharp facts constrain the construction: +- `Insert.GetTransform()` yields `world = R * S * p + (InsertPoint - BasePoint)`, not AutoCAD's `InsertPoint + R * S * (p - BasePoint)`. The insertion point must therefore be computed as `InsertPoint = wantedOrigin - L(BasePoint) + BasePoint`, where `L` is the linear part being requested. +- An `Insert` can only express a rotation and per-axis scales, so a composed transform that is not a planar similarity cannot be represented. In that case the default triangle is drawn with a Warning. + +- [ ] **Step 1: Write the failing tests** + +Add to `ACadSharp.Image.Tests/EntityRenderDispatcherTests.cs`: + +```csharp + private static BlockRecord ArrowBlock(string name = "ARROW", double baseX = 0, double baseY = 0) + { + BlockRecord block = new(name); + block.BlockEntity.BasePoint = new XYZ(baseX, baseY, 0); + // A unit arrow: the tip sits at the base point and the body runs back along local -X. + block.Entities.Add(new Line(new XYZ(baseX - 1, baseY, 0), new XYZ(baseX, baseY, 0))); + block.Entities.Add(new Solid + { + FirstCorner = new XYZ(baseX - 1, baseY - 0.25, 0), + SecondCorner = new XYZ(baseX, baseY, 0), + ThirdCorner = new XYZ(baseX - 1, baseY + 0.25, 0), + FourthCorner = new XYZ(baseX, baseY, 0), + }); + return block; + } + + [Fact] + public void ALeaderWithACustomArrowBlockDrawsTheBlockAndNotifiesNothing() + { + RecordingDrawingSurface surface = new(); + ImageConfiguration configuration = new(); + List notifications = new(); + configuration.OnNotification += (_, e) => notifications.Add(e); + CadDocument document = new(); + BlockRecord arrow = ArrowBlock(); + document.BlockRecords.Add(arrow); + Leader leader = new() + { + ArrowHeadEnabled = true, + Vertices = { new XYZ(10, 10, 0), new XYZ(30, 10, 0) }, + Style = new DimensionStyle("A") { ArrowSize = 2, ScaleFactor = 1, LeaderArrow = arrow }, + }; + document.Entities.Add(leader); + + new EntityRenderDispatcher(configuration).Draw(CreateContext(surface, configuration), leader); + + Assert.DoesNotContain(notifications, n => n.NotificationType == NotificationType.NotImplemented); + // The block's own line, scaled by 2 and pointing back along -X from the tip at (10,10). + Assert.Contains(surface.Lines, l => l.Start == new SurfacePoint(8, 90) && l.End == new SurfacePoint(10, 90)); + // The block's solid, not the built-in triangle. + Assert.Single(surface.Polygons); + } + + [Fact] + public void ACustomArrowRotatesToTheOutwardLeaderDirection() + { + RecordingDrawingSurface surface = new(); + ImageConfiguration configuration = new(); + CadDocument document = new(); + BlockRecord arrow = ArrowBlock(); + document.BlockRecords.Add(arrow); + // The leader runs downward from the tip, so the arrow's local +X must point up. + Leader leader = new() + { + ArrowHeadEnabled = true, + Vertices = { new XYZ(10, 50, 0), new XYZ(10, 20, 0) }, + Style = new DimensionStyle("A") { ArrowSize = 2, ScaleFactor = 1, LeaderArrow = arrow }, + }; + document.Entities.Add(leader); + + new EntityRenderDispatcher(configuration).Draw(CreateContext(surface, configuration), leader); + + Assert.Contains(surface.Lines, l => l.Start == new SurfacePoint(10, 52) && l.End == new SurfacePoint(10, 50)); + } + + [Fact] + public void ACustomArrowHonoursANonZeroBlockBasePoint() + { + RecordingDrawingSurface surface = new(); + ImageConfiguration configuration = new(); + CadDocument document = new(); + BlockRecord arrow = ArrowBlock("ARROWB", baseX: 5, baseY: 7); + document.BlockRecords.Add(arrow); + Leader leader = new() + { + ArrowHeadEnabled = true, + Vertices = { new XYZ(10, 10, 0), new XYZ(30, 10, 0) }, + Style = new DimensionStyle("A") { ArrowSize = 2, ScaleFactor = 1, LeaderArrow = arrow }, + }; + document.Entities.Add(leader); + + new EntityRenderDispatcher(configuration).Draw(CreateContext(surface, configuration), leader); + + // The base point is the arrow's tip, so it must land on the leader's first vertex exactly as in the + // zero-base-point case: the block's line still runs from (8,10) to (10,10) in world. + Assert.Contains(surface.Lines, l => l.Start == new SurfacePoint(8, 90) && l.End == new SurfacePoint(10, 90)); + } + + [Fact] + public void ACustomArrowInsideAScaledInsertScalesWithIt() + { + RecordingDrawingSurface surface = new(); + ImageConfiguration configuration = new(); + CadDocument document = new(); + BlockRecord arrow = ArrowBlock(); + document.BlockRecords.Add(arrow); + BlockRecord note = new("NOTE"); + document.BlockRecords.Add(note); + note.Entities.Add(new Leader + { + ArrowHeadEnabled = true, + Vertices = { new XYZ(0, 0, 0), new XYZ(10, 0, 0) }, + Style = new DimensionStyle("A") { ArrowSize = 2, ScaleFactor = 1, LeaderArrow = arrow }, + }); + Insert insert = new(note) { InsertPoint = new XYZ(10, 10, 0), XScale = 3, YScale = 3, ZScale = 3 }; + document.Entities.Add(insert); + + new EntityRenderDispatcher(configuration).Draw(CreateContext(surface, configuration), insert); + + // Arrow size 2 scaled by 3 is 6: the block's line runs from (4,10) to (10,10) in world. + Assert.Contains(surface.Lines, l => l.Start == new SurfacePoint(4, 90) && l.End == new SurfacePoint(10, 90)); + } + + [Fact] + public void ACustomArrowUnderANonUniformInsertFallsBackToTheDefaultTriangleWithAWarning() + { + RecordingDrawingSurface surface = new(); + ImageConfiguration configuration = new(); + List notifications = new(); + configuration.OnNotification += (_, e) => notifications.Add(e); + CadDocument document = new(); + BlockRecord arrow = ArrowBlock(); + document.BlockRecords.Add(arrow); + BlockRecord note = new("NOTE"); + document.BlockRecords.Add(note); + note.Entities.Add(new Leader + { + ArrowHeadEnabled = true, + Vertices = { new XYZ(0, 0, 0), new XYZ(10, 0, 0) }, + Style = new DimensionStyle("A") { ArrowSize = 2, ScaleFactor = 1, LeaderArrow = arrow }, + }); + Insert insert = new(note) { InsertPoint = new XYZ(10, 10, 0), XScale = 3, YScale = 1, ZScale = 1, Rotation = Math.PI / 4 }; + document.Entities.Add(insert); + + new EntityRenderDispatcher(configuration).Draw(CreateContext(surface, configuration), insert); + + Assert.Contains(notifications, n => n.Message.Contains("cannot be placed", StringComparison.OrdinalIgnoreCase)); + Assert.Single(surface.Polygons); + } + + [Fact] + public void ACustomArrowUnderARotatedNonUniformInsertFallsBackEvenThoughTheAxesMatchInLength() + { + // A 3:1 scale turned 45 degrees maps both unit axes to the same length, so a similarity test that compared + // only lengths would accept this and build an Insert that cannot express the shear. + RecordingDrawingSurface surface = new(); + ImageConfiguration configuration = new(); + List notifications = new(); + configuration.OnNotification += (_, e) => notifications.Add(e); + CadDocument document = new(); + BlockRecord arrow = ArrowBlock(); + document.BlockRecords.Add(arrow); + BlockRecord note = new("NOTE"); + document.BlockRecords.Add(note); + note.Entities.Add(new Leader + { + ArrowHeadEnabled = true, + Vertices = { new XYZ(0, 0, 0), new XYZ(10, 10, 0) }, + Style = new DimensionStyle("A") { ArrowSize = 2, ScaleFactor = 1, LeaderArrow = arrow }, + }); + Insert insert = new(note) { InsertPoint = new XYZ(10, 10, 0), XScale = 3, YScale = 1, ZScale = 1, Rotation = Math.PI / 4 }; + document.Entities.Add(insert); + + new EntityRenderDispatcher(configuration).Draw(CreateContext(surface, configuration), insert); + + Assert.Contains(notifications, n => n.Message.Contains("cannot be placed", StringComparison.OrdinalIgnoreCase)); + Assert.Single(surface.Polygons); + } + + [Fact] + public void ARecursiveArrowBlockFallsBackToTheDefaultTriangle() + { + RecordingDrawingSurface surface = new(); + ImageConfiguration configuration = new(); + List notifications = new(); + configuration.OnNotification += (_, e) => notifications.Add(e); + CadDocument document = new(); + BlockRecord arrow = ArrowBlock(); + document.BlockRecords.Add(arrow); + arrow.Entities.Add(new Insert(arrow)); + Leader leader = new() + { + ArrowHeadEnabled = true, + Vertices = { new XYZ(10, 10, 0), new XYZ(30, 10, 0) }, + Style = new DimensionStyle("A") { ArrowSize = 2, ScaleFactor = 1, LeaderArrow = arrow }, + }; + document.Entities.Add(leader); + + new EntityRenderDispatcher(configuration).Draw(CreateContext(surface, configuration), leader); + + Assert.Contains(notifications, n => n.Message.Contains("references itself", StringComparison.OrdinalIgnoreCase)); + Assert.Single(surface.Polygons); + } + + [Fact] + public void AnEmptyArrowBlockDrawsNothingExtraAndWarnsOnce() + { + RecordingDrawingSurface surface = new(); + ImageConfiguration configuration = new(); + List notifications = new(); + configuration.OnNotification += (_, e) => notifications.Add(e); + CadDocument document = new(); + BlockRecord arrow = new("EMPTY"); + document.BlockRecords.Add(arrow); + Leader leader = new() + { + ArrowHeadEnabled = true, + Vertices = { new XYZ(10, 10, 0), new XYZ(30, 10, 0) }, + Style = new DimensionStyle("A") { ArrowSize = 2, ScaleFactor = 1, LeaderArrow = arrow }, + }; + document.Entities.Add(leader); + + new EntityRenderDispatcher(configuration).Draw(CreateContext(surface, configuration), leader); + + Assert.Contains(notifications, n => n.Message.Contains("is empty", StringComparison.OrdinalIgnoreCase)); + Assert.Single(surface.Polygons); + } +``` + +`surface.Lines` records `DrawLine` calls; if the recording surface exposes them under a different member or shape, adjust the assertions to that shape, not the surface. + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `dotnet test ACadSharp.Image.sln --configuration Release --filter "FullyQualifiedName~CustomArrow|FullyQualifiedName~ArrowBlock"` +Expected: FAIL — today every one of these draws the default triangle and raises a NotImplemented notification. + +- [ ] **Step 3: Implement the arrow block** + +Add to `EntityRenderDispatcher.cs`: + +```csharp + /// + /// Draws a custom arrowhead block at a leader's tip: the block's base point goes to the tip, its local +X axis + /// turns to point outward along , and it is scaled by , all + /// composed with the placement of the block reference that placed the leader. + /// + /// The context that maps drawing units onto the surface. + /// The leader's effective layer, which the arrow's layer-0 entities inherit. + /// The leader's resolved style, which the arrow's ByBlock entities inherit. + /// The leader the arrow belongs to, for notifications. + /// The arrow block. + /// The leader's first vertex, in the leader's own coordinates. + /// The outward unit direction at the tip, in the leader's own coordinates. + /// The arrow size, already multiplied by the dimension style's overall scale. + /// The tip's own Z, so a leader off the world plane keeps its arrow attached to its line. + /// The transform of the insert that placed the leader, or null at top level. + /// True when the block was drawn; false when the caller should fall back to the default triangle. + /// + /// The block is drawn by handing a transient Insert of it to the ordinary block-content path, rather than + /// 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 + /// 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 + /// the insertion point below is compensated to produce AutoCAD's placement. A package upgrade that corrects this + /// will break ACustomArrowHonoursANonZeroBlockBasePoint, which is the intended tripwire. + /// + /// + private bool DrawArrowBlock(ImageRenderContext context, Layer? layer, ResolvedStyle parent, Leader leader, BlockRecord arrow, XY tip, XY direction, double size, double z, Transform? placement) + { + string handle = leader.Handle.ToString("X", CultureInfo.InvariantCulture); + if (arrow.Entities.Count == 0) + { + this._configuration.Notify($"[{leader.SubclassMarker}] Handle {handle}: arrowhead block '{arrow.Name}' is empty; the default closed arrow is drawn instead.", NotificationType.Warning); + return false; + } + + if (this.BlockGraphIsCircular(arrow)) + { + this._configuration.Notify($"[{leader.SubclassMarker}] Handle {handle}: arrowhead block '{arrow.Name}' references itself; the default closed arrow is drawn instead.", NotificationType.Warning); + return false; + } + + // The map the arrow block's own coordinates must go through: base point to the tip, local +X onto the + // outward direction, scaled by the arrow size, and then the outer placement. + XYZ basePoint = arrow.BlockEntity.BasePoint; + XY across = new(-direction.Y, direction.X); + XYZ Arrow(XYZ p) + { + XY local = new(p.X - basePoint.X, p.Y - basePoint.Y); + XY placed = tip + (direction * (local.X * size)) + (across * (local.Y * size)); + return InsertPlacement.MapPoint(placement, new XYZ(placed.X, placed.Y, z + ((p.Z - basePoint.Z) * size))); + } + + // The arrow's own map is a rotation and one uniform scale, so the composition is a similarity exactly when + // the outer placement is one. Testing the outer placement directly also catches the case a length-only check + // misses: a non-uniform scale turned 45 degrees leaves both axes the same length but not at right angles. + if (!InsertPlacement.TryGetPlanarSimilarity(placement, out double outerScale, out _, out _)) + { + this._configuration.Notify($"[{leader.SubclassMarker}] Handle {handle}: arrowhead block '{arrow.Name}' cannot be placed under a non-uniform transform; the default closed arrow is drawn instead.", NotificationType.Warning); + return false; + } + + XYZ origin = Arrow(basePoint); + XYZ ex = Arrow(basePoint + XYZ.AxisX) - origin; + XYZ ey = Arrow(basePoint + XYZ.AxisY) - origin; + double scale = size * outerScale; + bool mirrored = (ex.X * ey.Y) - (ex.Y * ey.X) < 0d; + double rotation = Math.Atan2(ex.Y, ex.X); + if (!double.IsFinite(scale) || scale < 1e-12 || !double.IsFinite(rotation)) + { + this._configuration.Notify($"[{leader.SubclassMarker}] Handle {handle}: arrowhead block '{arrow.Name}' has a degenerate size; the default closed arrow is drawn instead.", NotificationType.Warning); + return false; + } + + // A reflection is expressed as a negative X scale, which turns the mapped X axis around, so the rotation is + // taken half a turn further to bring it back. + Insert transient = new(arrow) + { + Rotation = mirrored ? rotation + Math.PI : rotation, + XScale = mirrored ? -scale : scale, + YScale = scale, + ZScale = scale, + InsertPoint = origin, + }; + transient.Attributes.Clear(); + + // Where the block's base point actually lands under the insert as built, corrected by the difference. The + // translation ACadSharp derives from the insertion point is affine in it, so one correction lands the base + // point on the tip whichever formula the package uses — which keeps this right if a later ACadSharp fixes + // its own divergence from AutoCAD's documented insert semantics. + XYZ landed = transient.GetTransform().ApplyTransform(basePoint); + transient.InsertPoint = origin + (origin - landed); + this.DrawBlockContents(context, transient, layer, parent); + return true; + } +``` + +In `DrawLeader`, replace the block that notifies about a custom arrow with a call to it. The method needs the leader's layer and resolved style to inherit ByBlock and layer 0, so change its signature to `private void DrawLeader(ImageRenderContext context, ImageStyle style, ResolvedStyle resolved, Layer? layer, Leader leader, Transform? placement)` and update the switch arm to `case Leader leader: this.DrawLeader(context, style, resolved, layer, source as Leader ?? leader, placement); break;`. Then replace: + +```csharp + if (leader.Style.LeaderArrow != null) + { + this._configuration.Notify($"[{leader.SubclassMarker}] Arrowhead block '{leader.Style.LeaderArrow.Name}' is not rendered; the default closed arrow is drawn instead.", NotificationType.NotImplemented); + } + + direction /= length; +``` + +with: + +```csharp + direction /= length; + double tipZ = leader.Vertices[0].Z; + if (leader.Style.LeaderArrow != null + && this.DrawArrowBlock(context, layer, resolved, leader, leader.Style.LeaderArrow, tip, direction, size, tipZ, placement)) + { + return; + } +``` + +and delete the now-duplicated `double z = leader.Vertices[0].Z;` line further down, using `tipZ` in the three arrow corners instead. + +The arrow block's children are drawn through the transient insert, so their `data-parent` in the SVG carries that insert's handle, which is zero. Give `DrawBlockContents` an optional trailing `ulong? parentHandleOverride = null` parameter, pass the leader's handle from `DrawArrowBlock`, and use it in place of `insert.Handle` for the `EntityRenderInfo` of the children, so an arrow's parts point at the leader they belong to rather than at a handle that exists nowhere. Add an assertion to `ALeaderWithACustomArrowBlockDrawsTheBlockAndNotifiesNothing` that the drawn entities record the leader's handle as their parent, using whatever member `RecordingDrawingSurface` records `BeginEntity` under. + +- [ ] **Step 4: Run the tests and the suite** + +Run: `dotnet test ACadSharp.Image.sln --configuration Release -warnaserror` +Expected: all pass; baselines byte-identical (no sample uses a custom arrow block). + +- [ ] **Step 5: Docs** + +In spec 4.6, replace the leader clause about custom arrowheads with: + +``` +A custom arrowhead block (`DimensionStyle.LeaderArrow`) is drawn as a placed block: its base point goes to the leader tip, its local +X axis turns to the outward direction at the tip, and it is scaled by `ArrowSize * ScaleFactor` composed with the placement of any block reference around the leader. A composed transform that is not a planar similarity has no equivalent `Insert`, and an empty or self-referencing arrow block cannot be drawn; each falls back to the default closed triangle with a Warning. +``` + +In `README.md`, remove custom arrowhead blocks from the known limitations and add a line under a "Caveats" or equivalent existing heading: "A custom arrowhead inside a non-uniformly scaled block reference falls back to the default triangle." + +- [ ] **Step 6: Commit** + +```bash +git add ACadSharp.Image/Rendering/EntityRenderDispatcher.cs ACadSharp.Image.Tests/EntityRenderDispatcherTests.cs README.md docs/superpowers/specs/2026-09-02-layers-and-svg-design.md +git commit -m "Draw custom arrowhead blocks at leader tips" +``` + +--- + +### Task 6: Inverted wipeout clips and clipping state + +**Files:** +- Modify: `ACadSharp.Image/Rendering/EntityRenderDispatcher.cs` (`WipeoutWorldBoundary` becomes `WipeoutWorldRings`, `DrawWipeout`, `UsesOriginalGeometry`), `ACadSharp.Image/Rendering/EntityBounds.cs` (the wipeout arm) +- Test: `ACadSharp.Image.Tests/EntityRenderDispatcherTests.cs`, `ACadSharp.Image.Tests/ImagePageTests.cs` +- Docs: spec 4.6, `README.md` + +**Interfaces:** +- Consumes: `InsertPlacement.MapPoint` and `MapVector` (Task 1). +- Produces: `internal static IReadOnlyList> WipeoutWorldRings(Wipeout wipeout, Transform? placement)`, replacing `internal static IReadOnlyList WipeoutWorldBoundary(Wipeout wipeout)`. It has three consumers that must all be updated: `DrawWipeout`, `EntityBounds.TryGet`, and `ImagePageRenderer.SelectViewportEntities` through `EntityBounds`. + +Three behaviours change. An inverted clip (`ClipMode.Inside`) masks the image frame minus the boundary instead of being skipped. Clipping that is switched off ignores the clip mode entirely, where today the inverted-mode check runs first and skips the entity. And a wipeout inside a block is mapped from the original, because `Wipeout.ApplyTransform` transforms the U and V vectors as points. + +- [ ] **Step 1: Write the failing tests** + +Add to `ACadSharp.Image.Tests/EntityRenderDispatcherTests.cs`: + +```csharp + [Fact] + public void AnInvertedWipeoutMasksTheFrameMinusItsBoundary() + { + RecordingDrawingSurface surface = new(); + ImageConfiguration configuration = new(); + Wipeout wipeout = UnitWipeout(); + wipeout.ClipMode = ClipMode.Inside; + + new EntityRenderDispatcher(configuration).Draw(CreateContext(surface, configuration), wipeout); + + IReadOnlyList> rings = Assert.Single(surface.Paths); + Assert.Equal(2, rings.Count); + Assert.Empty(surface.Polygons); + } + + [Fact] + public void AWipeoutWithClippingOffFillsTheWholeFrameEvenWhenItsModeIsInverted() + { + RecordingDrawingSurface surface = new(); + ImageConfiguration configuration = new(); + Wipeout wipeout = UnitWipeout(); + wipeout.ClipMode = ClipMode.Inside; + wipeout.ClippingState = false; + + new EntityRenderDispatcher(configuration).Draw(CreateContext(surface, configuration), wipeout); + + Assert.Single(surface.Polygons); + Assert.Empty(surface.Paths); + } + + [Fact] + public void AnOrdinaryWipeoutStillFillsOnePolygon() + { + RecordingDrawingSurface surface = new(); + ImageConfiguration configuration = new(); + + new EntityRenderDispatcher(configuration).Draw(CreateContext(surface, configuration), UnitWipeout()); + + Assert.Single(surface.Polygons); + Assert.Empty(surface.Paths); + } + + [Fact] + public void AWipeoutInsideAnInsertIsMappedFromTheOriginalSoItsUAndVStayDirections() + { + RecordingDrawingSurface surface = new(); + ImageConfiguration configuration = new(); + BlockRecord block = new("MASK"); + block.Entities.Add(UnitWipeout()); + Insert insert = new(block) { InsertPoint = new XYZ(50, 0, 0) }; + + new EntityRenderDispatcher(configuration).Draw(CreateContext(surface, configuration), insert); + + IReadOnlyList polygon = Assert.Single(surface.Polygons); + // UnitWipeout covers x in [0,1]; the insert moves it to [50,51]. A U vector contaminated by the translation + // would stretch it across the page instead. + Assert.Equal(50d, polygon.Min(p => p.X), 6); + Assert.Equal(51d, polygon.Max(p => p.X), 6); + } + + [Fact] + public void AHiddenWipeoutDrawsNothing() + { + RecordingDrawingSurface surface = new(); + ImageConfiguration configuration = new(); + Wipeout wipeout = UnitWipeout(); + wipeout.Flags = 0; + + new EntityRenderDispatcher(configuration).Draw(CreateContext(surface, configuration), wipeout); + + Assert.Empty(surface.Polygons); + Assert.Empty(surface.Paths); + } +``` + +And in `ACadSharp.Image.Tests/ImagePageTests.cs`: + +```csharp + [Fact] + public void TheFrameOfAnInvertedWipeoutIsItsWholeImageFootprint() + { + ImagePage page = new(); + Wipeout wipeout = new() + { + InsertPoint = new XYZ(0, 0, 0), + UVector = new XYZ(20, 0, 0), + 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)); + wipeout.ClipBoundaryVertices.Add(new XY(0.25, 0.25)); + page.AddEntity(wipeout); + + BoundingBox frame = page.ComputeFrame(null)!.Value; + + Assert.Equal(20d, frame.Max.X - frame.Min.X, 6); + Assert.Equal(10d, frame.Max.Y - frame.Min.Y, 6); + } +``` + +Match `ComputeFrame`'s real signature and return type when writing that last test; read `ImagePage.ComputeFrame` first and shape the call and the assertion to what it returns. + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `dotnet test ACadSharp.Image.sln --configuration Release --filter "FullyQualifiedName~Wipeout"` +Expected: the inverted, clipping-off and in-block tests fail; the ordinary and hidden ones pass. + +- [ ] **Step 3: Replace the boundary helper with rings** + +Replace `WipeoutWorldBoundary` with: + +```csharp + /// + /// The world rings a wipeout masks: none when the image is hidden, one when it masks a single region, and two — + /// the whole image frame and the boundary inside it — for an inverted clip, which masks everything except the + /// boundary. Clipping that is switched off masks the whole frame whatever the clip mode says. + /// + /// The wipeout entity. + /// The transform of the insert that placed it, or null at top level. + /// 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 + /// the translation and the mask is stretched across the drawing. + /// + internal static IReadOnlyList> WipeoutWorldRings(Wipeout wipeout, Transform? placement) + { + if (!wipeout.Flags.HasFlag(ImageDisplayFlags.ShowImage)) + { + return []; + } + + List frame = + [ + new XY(-0.5, -0.5), + new XY(wipeout.Size.X - 0.5, -0.5), + new XY(wipeout.Size.X - 0.5, wipeout.Size.Y - 0.5), + new XY(-0.5, wipeout.Size.Y - 0.5), + ]; + + if (!wipeout.ClippingState || wipeout.ClipBoundaryVertices.Count < 2) + { + return [Map(frame)]; + } + + List boundary; + if (wipeout.ClipType == ClipType.Rectangular || wipeout.ClipBoundaryVertices.Count == 2) + { + XY a = wipeout.ClipBoundaryVertices[0]; + XY b = wipeout.ClipBoundaryVertices[1]; + boundary = [a, new XY(b.X, a.Y), b, new XY(a.X, b.Y)]; + } + else + { + boundary = wipeout.ClipBoundaryVertices.ToList(); + } + + return wipeout.ClipMode == ClipMode.Inside + ? [Map(frame), Map(boundary)] + : [Map(boundary)]; + + IReadOnlyList Map(IEnumerable pixels) => pixels.Select(p => WipeoutPixelToWorld(wipeout, p, placement)).ToList(); + } +``` + +Change `WipeoutPixelToWorld` to map through the placement: + +```csharp + /// + /// Maps an image-space boundary vertex to world coordinates. Pixel (0,0) is the top-left pixel and Y grows + /// downwards; UVector runs along the visual bottom and VVector up the visual left side, each one + /// pixel long. The documented default boundary (-0.5,-0.5)..(Size-0.5) therefore covers exactly the image. The + /// insertion point is mapped as a point and the two vectors as directions. + /// + internal static XYZ WipeoutPixelToWorld(CadWipeoutBase image, XY pixel, Transform? placement) + { + XYZ insertPoint = InsertPlacement.MapPoint(placement, image.InsertPoint); + XYZ u = InsertPlacement.MapVector(placement, image.UVector); + XYZ v = InsertPlacement.MapVector(placement, image.VVector); + return insertPoint + (u * (pixel.X + 0.5)) + (v * (image.Size.Y - pixel.Y - 0.5)); + } +``` + +- [ ] **Step 4: Draw the rings** + +Replace `DrawWipeout`'s body after the opaque-background check with: + +```csharp + IReadOnlyList> rings = WipeoutWorldRings(wipeout, placement); + if (rings.Count == 0) + { + return; + } + + ImageStyle maskStyle = style with { StrokeColor = background, Opacity = 1f, DashPattern = null }; + if (rings.Count == 1) + { + context.Surface.FillPolygon(maskStyle, rings[0].Select(context.ToSurfacePoint).ToArray()); + return; + } + + // An inverted clip masks everything except the boundary, which is the frame with the boundary as a hole: an + // even-odd fill over both rings. + context.Surface.FillPath(maskStyle, rings.Select(ring => (IReadOnlyList)ring.Select(context.ToSurfacePoint).ToArray()).ToList()); +``` + +Delete the `ClipMode.Inside` early return and its NotImplemented notification, and delete the comment above the `ShowImage` check that pointed at the old helper. Change the signature to `private void DrawWipeout(ImageRenderContext context, ImageStyle style, Wipeout wipeout, Transform? placement)`, update the switch arm to `case Wipeout wipeout: this.DrawWipeout(context, style, source as Wipeout ?? wipeout, placement); break;`, and add `Wipeout` to the `UsesOriginalGeometry` always-original list so the body reads: + +```csharp + if (original is TextEntity or MText or Leader or Hatch or Wipeout) + { + return true; + } +``` + +- [ ] **Step 5: Update the bounds** + +In `EntityBounds.cs`, replace the wipeout arm so it bounds by every ring point: + +```csharp + case Wipeout wipeout: + return TryFromPoints(EntityRenderDispatcher.WipeoutWorldRings(wipeout, null).SelectMany(ring => ring), out bounds); +``` + +Keep whatever the surrounding method's exact shape is — read it first — and keep the `error` handling unchanged. + +- [ ] **Step 6: Run the tests and the suite** + +Run: `dotnet test ACadSharp.Image.sln --configuration Release -warnaserror` +Expected: all pass. The features sample's wipeout is an ordinary outside clip, so baselines stay byte-identical. If any moves, STOP and report BLOCKED with the diff. + +- [ ] **Step 7: Docs** + +In spec 4.6, replace the wipeout clause with: + +``` +A wipeout masks its clip boundary, or the whole image frame when clipping is off, whatever its clip mode says. An inverted clip (`ClipMode.Inside`) masks the frame minus the boundary as a single even-odd path. Its geometry is taken from the original entity, with the insertion point mapped as a point and the U and V vectors as directions. Framing and viewport culling use the same rings, so an inverted wipeout is bounded by its whole footprint. A wipeout still needs an opaque background to mask, and is skipped with a Warning otherwise. +``` + +In `README.md`, remove inverted wipeout clips from the known limitations. + +- [ ] **Step 8: Commit** + +```bash +git add ACadSharp.Image/Rendering/EntityRenderDispatcher.cs ACadSharp.Image/Rendering/EntityBounds.cs ACadSharp.Image.Tests/EntityRenderDispatcherTests.cs ACadSharp.Image.Tests/ImagePageTests.cs README.md docs/superpowers/specs/2026-09-02-layers-and-svg-design.md +git commit -m "Mask inverted wipeout clips and ignore the clip mode when clipping is off" +``` + +--- + +### Task 7: MLEDIT cut segments + +**Files:** +- Modify: `ACadSharp.Image/Rendering/EntityRenderDispatcher.cs` (`DrawMLine`, the MLINE arm of `HasFiniteGeometry`) +- Test: `ACadSharp.Image.Tests/EntityRenderDispatcherTests.cs` +- Docs: spec 4.6, `README.md` + +**Interfaces:** +- Consumes: `InsertPlacement.MapPoint` (Task 1). +- Produces: `internal static IReadOnlyList<(double Start, double End)> VisibleRuns(IReadOnlyList parameters, double length)`, the interval arithmetic, exposed for its own tests. + +An MLINE element's parameters are, per the DXF reference: `p[0]` the offset from the vertex along the miter, `p[1]` the distance from that intersection to the element's actual start, and `p[2..]` the positions where the element breaks and resumes, alternating. An odd count ends hidden. A break at or past the segment's end means no cut at all, which is what the one real-world sample contains. + +**This interpretation is not confirmed.** The DXF prose reads as absolute positions; ezdxf's comments describe relative dash and gap lengths, and neither ezdxf nor LibreDWG implements cuts. The only real sample has three parameters whose third equals the segment length, which both readings render identically. Implement the absolute reading, keep the limitation note in the README saying so, and do not present it as verified. + +- [ ] **Step 1: Write the failing tests** + +Add to `ACadSharp.Image.Tests/EntityRenderDispatcherTests.cs`: + +```csharp + [Theory] + // No cut values: one run covering the whole element. + [InlineData(new double[] { 0.5, 0 }, 10d, new double[] { 0, 10 })] + // A single break at the end is not a cut. + [InlineData(new double[] { 0.5, 0, 10 }, 10d, new double[] { 0, 10 })] + // Break at 4, resume at 6. + [InlineData(new double[] { 0.5, 0, 4, 6 }, 10d, new double[] { 0, 4, 6, 10 })] + // Odd count: the element ends hidden after the last value. + [InlineData(new double[] { 0.5, 0, 4 }, 10d, new double[] { 0, 4 })] + // Two cuts. + [InlineData(new double[] { 0.5, 0, 2, 3, 7, 8 }, 10d, new double[] { 0, 2, 3, 7, 8, 10 })] + // Values past the end are clamped. + [InlineData(new double[] { 0.5, 0, 4, 99 }, 10d, new double[] { 0, 4 })] + public void VisibleRunsFollowTheAbsoluteCutPositions(double[] parameters, double length, double[] expected) + { + IReadOnlyList<(double Start, double End)> runs = EntityRenderDispatcher.VisibleRuns(parameters, length); + + Assert.Equal(expected.Length / 2, runs.Count); + for (int i = 0; i < runs.Count; i++) + { + Assert.Equal(expected[2 * i], runs[i].Start, 9); + Assert.Equal(expected[(2 * i) + 1], runs[i].End, 9); + } + } + + [Fact] + public void VisibleRunsStopAtANonFiniteOrDecreasingValue() + { + Assert.Equal([(0d, 4d)], EntityRenderDispatcher.VisibleRuns([0.5, 0, 4, double.NaN, 8], 10d)); + Assert.Equal([(0d, 4d)], EntityRenderDispatcher.VisibleRuns([0.5, 0, 4, 3], 10d)); + } + + [Fact] + public void AnMLineWithACutDrawsTwoRunsForThatElement() + { + RecordingDrawingSurface surface = new(); + ImageConfiguration configuration = new(); + MLineStyle style = new("CUT"); + style.AddElement(new MLineStyle.Element { Offset = 0 }); + MLine mline = new() + { + Style = style, + Vertices = + { + VertexAt(0, 10, [0, 0, 4, 6]), + VertexAt(20, 10, [0, 0, 4, 6]), + }, + }; + + new EntityRenderDispatcher(configuration).Draw(CreateContext(surface, configuration), mline); + + Assert.Equal(2, surface.Polylines.Count); + Assert.Equal([new SurfacePoint(0, 90), new SurfacePoint(4, 90)], surface.Polylines[0]); + Assert.Equal([new SurfacePoint(6, 90), new SurfacePoint(20, 90)], surface.Polylines[1]); + } + + [Fact] + public void AnMLineWithoutCutsStillDrawsOnePolylinePerElement() + { + RecordingDrawingSurface surface = new(); + ImageConfiguration configuration = new(); + MLine mline = new() + { + Style = TwoElementStyle(0.5), + Vertices = { VertexAt(0, 10), VertexAt(20, 10) }, + }; + + new EntityRenderDispatcher(configuration).Draw(CreateContext(surface, configuration), mline); + + Assert.Equal(2, surface.Polylines.Count); + } + + [Fact] + public void ACutMLineInsideAScaledInsertScalesItsRuns() + { + RecordingDrawingSurface surface = new(); + ImageConfiguration configuration = new(); + MLineStyle style = new("CUT"); + style.AddElement(new MLineStyle.Element { Offset = 0 }); + BlockRecord block = new("WALL"); + block.Entities.Add(new MLine + { + Style = style, + Vertices = { VertexAt(0, 0, [0, 0, 4, 6]), VertexAt(20, 0, [0, 0, 4, 6]) }, + }); + Insert insert = new(block) { InsertPoint = new XYZ(0, 10, 0), XScale = 2, YScale = 2, ZScale = 2 }; + + new EntityRenderDispatcher(configuration).Draw(CreateContext(surface, configuration), insert); + + Assert.Equal(2, surface.Polylines.Count); + Assert.Equal([new SurfacePoint(0, 90), new SurfacePoint(8, 90)], surface.Polylines[0]); + Assert.Equal([new SurfacePoint(12, 90), new SurfacePoint(40, 90)], surface.Polylines[1]); + } + + [Fact] + public void AnMLineWithAreaFillCutsNotifiesThatFillCutsAreNotDrawn() + { + RecordingDrawingSurface surface = new(); + ImageConfiguration configuration = new(); + List notifications = new(); + configuration.OnNotification += (_, e) => notifications.Add(e); + MLine mline = new() { Style = TwoElementStyle(0.5), Vertices = { VertexAt(0, 10), VertexAt(20, 10) } }; + mline.Vertices[0].Segments[0].AreaFillParameters.Add(2); + mline.Vertices[0].Segments[0].AreaFillParameters.Add(5); + + new EntityRenderDispatcher(configuration).Draw(CreateContext(surface, configuration), mline); + + Assert.Contains(notifications, n => n.NotificationType == NotificationType.NotImplemented && n.Message.Contains("fill cuts", StringComparison.OrdinalIgnoreCase)); + } +``` + +`VertexAt(x, y, params double[][] segments)` already exists in the test file; the two-argument form gives a vertex with the default segments. Check its exact signature and pass the cut parameters the way it expects. + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `dotnet test ACadSharp.Image.sln --configuration Release --filter "FullyQualifiedName~VisibleRuns|FullyQualifiedName~Cut"` +Expected: build failure (`VisibleRuns` does not exist), then the MLINE tests fail because elements are drawn continuous with a warning. + +- [ ] **Step 3: Implement the interval arithmetic** + +Add to `EntityRenderDispatcher.cs`: + +```csharp + /// + /// The visible runs of one MLINE element, as distances from the element's own start. DXF group 41 stores, after + /// the miter offset and the element's start offset, the positions at which the element breaks and resumes, + /// alternating; an odd count leaves the element hidden to its end. Values are clamped to the element's length, + /// and the list is cut short at the first value that is not finite or not greater than the one before it. + /// + /// The element's stored parameters, starting with the miter offset. + /// The element's length between this vertex and the next. + /// The visible runs, in order; a single full-length run when there are no usable cut positions. + /// + /// Reading these as absolute positions is the literal sense of the DXF reference. ezdxf's model comments read the + /// same array as relative dash and gap lengths, and neither ezdxf nor LibreDWG draws cuts at all, so no + /// implementation settles it; the two readings agree only on a single cut. This is the interpretation the + /// renderer implements and the README records it as unconfirmed. + /// + /// p[1], the offset from the miter intersection to the element's actual start, is not applied: runs are + /// measured from the intersection, which is where the renderer already starts every element. Real values are a + /// small fraction of a unit, so applying it would move existing output for no visible gain; it is recorded here + /// so a later change is a deliberate one. + /// + /// + internal static IReadOnlyList<(double Start, double End)> VisibleRuns(IReadOnlyList parameters, double length) + { + if (!double.IsFinite(length) || length <= 0d) + { + return []; + } + + List breaks = new(); + double previous = 0d; + for (int i = 2; i < parameters.Count; i++) + { + double value = parameters[i]; + if (!double.IsFinite(value) || value <= previous) + { + break; + } + + if (value >= length) + { + break; + } + + breaks.Add(value); + previous = value; + } + + if (breaks.Count == 0) + { + return [(0d, length)]; + } + + List<(double Start, double End)> runs = new(); + double start = 0d; + for (int i = 0; i < breaks.Count; i += 2) + { + runs.Add((start, breaks[i])); + start = i + 1 < breaks.Count ? breaks[i + 1] : double.NaN; + if (double.IsNaN(start)) + { + return runs; + } + } + + runs.Add((start, length)); + return runs; + } +``` + +- [ ] **Step 4: Draw the runs** + +`DrawMLine` already builds `lines[j][i]`, the surface point of element `j` at vertex `i`, and then draws one polyline per element. Keep all of that, and additionally keep the **world** point each of those came from, because the stored cut positions are distances in drawing units and must be measured against a drawing-unit segment length, never against a surface length. + +In the loop that fills `lines`, declare `XYZ[][] world = new XYZ[elements.Length][];` beside it, allocate `world[j] = new XYZ[vertices.Count];` with `lines[j]`, and record the placed world point before projecting it: + +```csharp + XYZ placed = InsertPlacement.MapPoint(placement, vertex.Position + (vertex.Miter * along)); + world[j][i] = placed; + lines[j][i] = context.ToSurfacePoint(placed); +``` + +Then add, immediately before the existing element-drawing loop, a per-element decision: an element with no real cut keeps its single `DrawPolyline` call, and only a cut element is drawn as separate runs. + +Replace the existing element-drawing loop with: + +```csharp + for (int j = 0; j < elements.Length; j++) + { + // An element linetype named ByLayer/ByBlock is not itself a drawable pattern: it means the element + // inherits the entity's own resolved dashes, same as a null element linetype, rather than being handed + // to the resolver, which would otherwise treat the placeholder name as an unknown (solid) linetype. + LineType? elementType = elements[j].LineType; + float[]? dashes = elementType == null + || ImageStyleResolver.IsNamed(elementType, LineType.ByLayerName) + || ImageStyleResolver.IsNamed(elementType, LineType.ByBlockName) + ? style.DashPattern + : LineTypeDashResolver.Resolve(elementType, resolved.Header, resolved.LineTypeScale, context, style.StrokeWidth); + ImageStyle elementStyle = style with { StrokeColor = ElementColor(elements[j].Color), DashPattern = dashes }; + + // An uncut element stays one polyline: drawing it as a chain of separate lines would restart a dashed + // linetype's phase at every vertex and would move every existing golden. + if (!HasCut(j)) + { + context.Surface.DrawPolyline(elementStyle, lines[j], closed); + continue; + } + + int lastVertex = closed ? vertices.Count : vertices.Count - 1; + for (int i = 0; i < lastVertex; i++) + { + int next = (i + 1) % vertices.Count; + SurfacePoint from = lines[j][i]; + SurfacePoint to = lines[j][next]; + foreach ((double t0, double t1) in RunFractions(j, i, next)) + { + SurfacePoint a = new(from.X + ((to.X - from.X) * t0), from.Y + ((to.Y - from.Y) * t0)); + SurfacePoint b = new(from.X + ((to.X - from.X) * t1), from.Y + ((to.Y - from.Y) * t1)); + context.Surface.DrawLine(elementStyle, a, b); + } + } + } + + // The visible runs of one segment, as fractions of its length. The stored cut positions are distances in + // drawing units, so the segment they are measured against has to be the world one; the fractions are then + // applied to the already-projected surface points, which is exact because the projection is affine. + IReadOnlyList<(double Start, double End)> RunFractions(int element, int from, int to) + { + double segmentLength = (world[element][to] - world[element][from]).GetLength(); + if (segmentLength <= 0d || !double.IsFinite(segmentLength)) + { + // A zero-length segment (coincident vertices) has nothing to cut: report one full run so the element + // is not pushed onto the per-run path, where it would lose its linetype phase for no reason. + return [(0d, 1d)]; + } + + IReadOnlyList parameters = element < vertices[from].Segments.Count ? vertices[from].Segments[element].Parameters : []; + return VisibleRuns(parameters, segmentLength).Select(run => (run.Start / segmentLength, run.End / segmentLength)).ToList(); + } + + // Whether any segment of this element is broken, i.e. yields anything other than one run covering the whole + // segment. An unbroken element keeps its single polyline. + bool HasCut(int element) + { + int lastVertex = closed ? vertices.Count : vertices.Count - 1; + for (int i = 0; i < lastVertex; i++) + { + IReadOnlyList<(double Start, double End)> runs = RunFractions(element, i, (i + 1) % vertices.Count); + if (runs.Count != 1 || runs[0].Start > 1e-12 || runs[0].End < 1d - 1e-12) + { + return true; + } + } + + return false; + } +``` + +Delete the `cuts` local and the loop that sets it, and delete the "cut segments are not rendered" notification. + +Replace the `cuts` warning with a fill-cut notification, raised once per entity when any vertex segment has a non-empty `AreaFillParameters`: + +```csharp + if (vertices.Any(v => v.Segments.Any(s => s.AreaFillParameters.Count > 0))) + { + this._configuration.Notify($"[{mline.SubclassMarker}] Handle {handle}: fill cuts are not drawn; the filled band is continuous.", NotificationType.NotImplemented); + } +``` + +Extend the MLINE arm of `HasFiniteGeometry` so every parameter and both `Miter` and `Position` are validated, not only `Parameters[0]`. Read that method first and add the checks in its existing style. + +- [ ] **Step 5: Run the tests and the suite** + +Run: `dotnet test ACadSharp.Image.sln --configuration Release -warnaserror` +Expected: all pass; baselines byte-identical, because no sample MLINE carries cut positions and the no-cut path still emits one polyline per element. If any baseline moves, STOP and report BLOCKED with the diff — a moved baseline means the no-cut path changed shape, which this task forbids. + +- [ ] **Step 6: Docs** + +In spec 4.6, replace the MLINE cut clause with: + +``` +Cut positions (DXF group 41 beyond the first two values) are drawn: they are read as absolute distances from the element's start, alternating break and resume, with an odd count leaving the element hidden to its end and a break at or past the segment's end meaning no cut. An element with no usable cut positions is still drawn as a single polyline, so its linetype phase is unbroken. Fill cuts (group 42) are notified, not drawn. +``` + +In `README.md`, replace the MLEDIT limitation with: "MLINE cut segments are drawn from DXF group 41 read as absolute positions. The DXF reference reads that way, but ezdxf reads the same values as relative dash and gap lengths and no implementation settles it, so a drawing with more than one cut per element may differ from AutoCAD. Fill cuts are not drawn." + +- [ ] **Step 7: Commit** + +```bash +git add ACadSharp.Image/Rendering/EntityRenderDispatcher.cs ACadSharp.Image.Tests/EntityRenderDispatcherTests.cs README.md docs/superpowers/specs/2026-09-02-layers-and-svg-design.md +git commit -m "Draw MLINE cut segments from their stored positions" +``` + +--- + +### Task 8: A golden that exercises all five + +**Files:** +- Modify: `ACadSharp.Image.Tests/SyntheticSamples.cs` (add `FidelityBlock()`) +- Create: `ACadSharp.Image.Tests/FidelityGoldenTests.cs`, `ACadSharp.Image.Tests/Baselines/fidelity.model.01.png`, `ACadSharp.Image.Tests/Baselines/fidelity.model.01.svg` +- Docs: `README.md` + +**Interfaces:** +- Consumes: everything from Tasks 2 to 7. +- Produces: `internal static BlockRecord FidelityBlock()` on `SyntheticSamples`. + +- [ ] **Step 1: Add the synthetic block** + +Add to `SyntheticSamples.cs`, following the shape of the existing `EntityBlock()` (its layers, its `WithHandle` numbering and its `MLineVertex` helper): + +```csharp + /// + /// A block exercising every feature the remaining-limitations work added: a multi-line attribute, a hatch on a + /// tilted plane inside a block, a leader with a custom arrowhead block, an inverted wipeout over a line, and an + /// MLINE with a cut in both of its elements. Handles increase in draw order, so the wipeout follows the line it + /// masks. + /// + /// The block, with every entity on its own named layer. + public static BlockRecord FidelityBlock() + { + BlockRecord block = new("fidelity"); + Layer roomsLayer = new("Rooms") { Color = new Color(9) }; + Layer hatchLayer = new("Tilted") { Color = new Color(3) }; + Layer leaderLayer = new("Leader") { Color = new Color(4) }; + Layer underLayer = new("Under") { Color = new Color(1) }; + Layer coverLayer = new("Cover") { Color = new Color(8) }; + Layer wallLayer = new("Wall") { Color = new Color(6) }; + + // Multi-line attribute: the single-line value must never appear in the output. + BlockRecord label = new("LABEL"); + label.Entities.Add(new AttributeDefinition { Tag = "ROOM", Value = "FLAT", Layer = roomsLayer }); + Insert labelInsert = WithHandle(new Insert(label) { InsertPoint = new XYZ(10, 80, 0), Layer = roomsLayer }, 0x10); + labelInsert.Attributes.Clear(); + labelInsert.Attributes.Add(WithHandle(new AttributeEntity + { + Tag = "ROOM", + Value = "FLAT", + AttributeType = AttributeType.MultiLine, + InsertPoint = new XYZ(10, 80, 0), + Height = 4, + Layer = roomsLayer, + MText = new MText { Value = "Room 1\\PLevel 2", InsertPoint = new XYZ(10, 80, 0), Height = 4, RectangleWidth = 40 }, + }, 0x11)); + block.Entities.Add(labelInsert); + + // Tilted hatch inside a block: normal (0,0,-1) mirrors X on the way to world. + BlockRecord tilted = new("TILTED"); + Hatch hatch = new() { IsSolid = true, Normal = new XYZ(0, 0, -1), Elevation = 0d, Layer = hatchLayer }; + hatch.Paths.Add(SquarePath(0, 0, 20, 15)); + tilted.Entities.Add(hatch); + block.Entities.Add(WithHandle(new Insert(tilted) { InsertPoint = new XYZ(80, 70, 0), Layer = hatchLayer }, 0x12)); + + // Custom arrowhead block: tip at the base point, body back along local -X. + BlockRecord arrow = new("FIDELITY_ARROW"); + arrow.Entities.Add(new Line(new XYZ(-1, 0, 0), new XYZ(0, 0, 0))); + arrow.Entities.Add(new Solid + { + FirstCorner = new XYZ(-1, -0.25, 0), + SecondCorner = new XYZ(0, 0, 0), + ThirdCorner = new XYZ(-1, 0.25, 0), + FourthCorner = new XYZ(0, 0, 0), + }); + block.Entities.Add(WithHandle(new Leader + { + ArrowHeadEnabled = true, + Style = new DimensionStyle("FIDELITY") { ArrowSize = 4, ScaleFactor = 1, LeaderArrow = arrow }, + Layer = leaderLayer, + Vertices = { new XYZ(10, 40, 0), new XYZ(35, 55, 0), new XYZ(55, 55, 0) }, + }, 0x13)); + + // Inverted wipeout over a line: only the middle band of the line survives. + block.Entities.Add(WithHandle(new Line(new XYZ(60, 20, 0), new XYZ(110, 20, 0)) { Layer = underLayer }, 0x14)); + Wipeout wipeout = WithHandle(new Wipeout + { + InsertPoint = new XYZ(60, 10, 0), + UVector = new XYZ(50, 0, 0), + VVector = new XYZ(0, 20, 0), + Size = new XY(1, 1), + ClippingState = true, + ClipType = ClipType.Rectangular, + ClipMode = ClipMode.Inside, + Layer = coverLayer, + }, 0x15); + wipeout.ClipBoundaryVertices.Add(new XY(-0.2, -0.5)); + wipeout.ClipBoundaryVertices.Add(new XY(0.2, 0.5)); + block.Entities.Add(wipeout); + + // Cut MLINE: both elements break between 20 and 30 along their own length. + MLineStyle wallStyle = new("FIDELITY_WALL"); + wallStyle.AddElement(new MLineStyle.Element { Offset = 1 }); + wallStyle.AddElement(new MLineStyle.Element { Offset = -1 }); + block.Entities.Add(WithHandle(new MLine + { + Style = wallStyle, + Layer = wallLayer, + Vertices = + { + MLineVertex(new XYZ(10, 10, 0), new XYZ(1, 0, 0), new XYZ(0, 1, 0), [1, 0, 20, 30], [-1, 0, 20, 30]), + MLineVertex(new XYZ(60, 10, 0), new XYZ(1, 0, 0), new XYZ(0, 1, 0), [1, 0, 20, 30], [-1, 0, 20, 30]), + }, + }, 0x16)); + + return block; + } +``` + +`SquarePath` is the helper Task 3 added to the dispatcher tests; move it to `SyntheticSamples` as an `internal static` helper with a `` and have the dispatcher tests call it there, rather than writing a second copy. If `MLineVertex`'s existing signature does not take the parameter arrays as shown, match whatever it takes and keep the values. + +- [ ] **Step 2: Write the golden tests** + +Create `ACadSharp.Image.Tests/FidelityGoldenTests.cs`, modelled on `EntityGoldenTests` (read that file first and follow its structure, its `Ns` constant and its occlusion-sampling helper): + +```csharp +using System.Xml.Linq; +using ACadSharp.Image.Rendering; +using ACadSharp.Image.Rendering.Svg; +using CSMath; +using SixLabors.Fonts; +using SixLabors.ImageSharp.PixelFormats; + +namespace ACadSharp.Image.Tests; + +/// +/// Renders — a multi-line attribute, a hatch on a tilted plane inside a +/// block, a leader with a custom arrowhead block, an inverted wipeout over a line, and an MLINE with a cut in both +/// elements — through both real backends and compares the results with their baselines. Mirrors +/// . +/// +public sealed class FidelityGoldenTests +{ + private const string FontFamily = "DejaVu Sans"; + private static readonly XNamespace Ns = SvgDrawingSurface.Ns; + + private static ImageExporter FidelityExporter() + { + ImageExporter exporter = new(); + exporter.Configuration.Width = 800; + exporter.Configuration.Height = 500; + exporter.Configuration.SetPadding(10); + exporter.Configuration.FontFamilyName = FontFamily; + exporter.Add(SyntheticSamples.FidelityBlock()); + return exporter; + } + + [Fact] + public void FidelityPngMatchesBaseline() + { + Assert.True(SystemFonts.TryGet(FontFamily, out _), $"Font '{FontFamily}' must be installed for parity tests."); + ImageExporter exporter = FidelityExporter(); + + using RenderedImagePage page = Assert.IsType(Assert.Single(exporter.Render())); + + GoldenAssert.Png("fidelity.model.01", page.Canvas); + + // The inverted wipeout masks the whole image frame EXCEPT its boundary, so the "Under" line at y = 20 + // survives only inside the boundary (world x in [80,90]) and is masked outside it. This is the assertion + // the SVG cannot make: SVG groups by layer, so the line and the mask are not in draw order there. + ImageRenderContext context = ImageRenderContext.CreatePageContext(new RecordingDrawingSurface(), exporter.Pages[0], exporter.Configuration); + SurfacePoint inside = context.ToSurfacePoint(new XY(85, 20)); + SurfacePoint outside = context.ToSurfacePoint(new XY(65, 20)); + Rgba32 white = new(255, 255, 255, 255); + Assert.NotEqual(white, DarkestPixelNear(page.Canvas, inside)); + Assert.Equal(white, DarkestPixelNear(page.Canvas, outside)); + } + + [Fact] + public void FidelitySvgMatchesGoldenAndContainsEveryFeature() + { + ImageExporter exporter = FidelityExporter(); + + RenderedSvgPage page = Assert.IsType(Assert.Single(exporter.RenderSvg())); + + GoldenAssert.Svg("fidelity.model.01", page.Content); + + XDocument document = XDocument.Parse(page.Content); + XElement InLayer(string layer) => document.Descendants(Ns + "g").Single(g => (string?)g.Attribute("data-layer") == layer); + + // Multi-line attribute: two lines from the embedded MText, and the single-line value nowhere in the file. + XElement text = Assert.Single(InLayer("Rooms").Descendants(Ns + "text")); + Assert.Equal("ATTRIB", (string?)text.Attribute("data-type")); + Assert.Equal(["Room 1", "Level 2"], text.Descendants(Ns + "tspan").Select(s => s.Value).ToArray()); + Assert.DoesNotContain("FLAT", page.Content, StringComparison.Ordinal); + + // Tilted hatch inside a block: normal (0,0,-1) mirrors X, the insert then moves it to x in [60,80]. + XElement hatch = Assert.Single(InLayer("Tilted").Descendants(Ns + "path")); + double[] xs = PointsOf(hatch).Select(p => p.X).ToArray(); + Assert.Equal(60d, xs.Min(), 3); + Assert.Equal(80d, xs.Max(), 3); + + // Custom arrowhead: the block's own filled solid, not the built-in triangle. + Assert.Contains(InLayer("Leader").Descendants(Ns + "polygon"), p => (string?)p.Attribute("data-type") == "SOLID"); + + // Inverted wipeout: one even-odd path with two rings, filled with the page background. + XElement mask = Assert.Single(InLayer("Cover").Descendants(Ns + "path")); + Assert.Equal("#ffffff", (string?)mask.Attribute("fill")); + Assert.Equal("evenodd", (string?)mask.Attribute("fill-rule")); + Assert.Equal(2, RingCountOf(mask)); + + // Cut MLINE: two elements, each broken into two runs. + Assert.Equal(4, InLayer("Wall").Descendants().Count(e => e.Name == Ns + "line" || e.Name == Ns + "polyline")); + } +} +``` + +`PointsOf`, `RingCountOf` and `DarkestPixelNear` are helpers you write in this file: `PointsOf` parses an SVG path or polygon into surface points, `RingCountOf` counts the `M` commands in a path's `d`, and `DarkestPixelNear` is the sampling helper `EntityGoldenTests` already has — if it is private there, move it to a shared internal test helper and have both files call it rather than writing a second copy. Adjust `ImageRenderContext.CreatePageContext`'s argument list and `exporter.Pages`' shape to what those members actually are; read them before writing the call. + +Verify the two expected hatch x values by hand before running, from the block's own insert point and the OCS mirror, and say in your report what you computed. If the SVG's element or attribute names differ from what is written above, change the assertion to match the real output, never the output to match the assertion. + +- [ ] **Step 3: Create the baselines** + +Run: `ACADSHARP_IMAGE_UPDATE_BASELINES=1 dotnet test ACadSharp.Image.Tests --filter "FullyQualifiedName~FidelityGoldenTests"` +Then run `git status --short ACadSharp.Image.Tests/Baselines` and confirm only `fidelity.model.01.png` and `fidelity.model.01.svg` appear. Open the PNG with the Read tool and describe it in your report, feature by feature, confirming each of the five is visible and looks right — a passing byte comparison against a file you just generated proves nothing on its own. + +- [ ] **Step 4: Run the suite** + +Run: `dotnet test ACadSharp.Image.sln --configuration Release -warnaserror` +Expected: all pass; no baseline other than the two new files changed. + +- [ ] **Step 5: Docs** + +In `README.md`, make sure the known limitations section now lists only what genuinely remains: MLINE fill cuts, the unconfirmed cut interpretation, a custom arrowhead under a non-uniform block reference, wipeouts on a translucent background, and exact cross-layer painter order in SVG. + +- [ ] **Step 6: Commit** + +```bash +git add ACadSharp.Image.Tests/SyntheticSamples.cs ACadSharp.Image.Tests/FidelityGoldenTests.cs ACadSharp.Image.Tests/Baselines/fidelity.model.01.png ACadSharp.Image.Tests/Baselines/fidelity.model.01.svg README.md +git commit -m "Add a golden that exercises every newly drawn feature" +``` + +--- + +## Self-Review + +**Spec coverage.** Spec 4.1 placement helpers → Task 1. Spec 4.2 pairing → Task 3 (hatch) and Task 6 (wipeout) extend the one existing relation; spec 4.2 cycles → Task 4. Spec 4.3 arrow blocks → Task 5. Spec 4.4 wipeout rings and clipping state → Task 6. Spec 4.5 MLEDIT cuts → Task 7. Spec 4.6 tilted hatches → Task 3. Spec 4.7 multi-line attributes → Task 2. Spec 5 notification changes → each task removes or adds its own. Spec 6 tests and goldens → each task plus Task 8. Spec 7 documentation → each task plus Task 8. + +Two spec items are deliberately **not** implemented, matching the spec's own non-goals: the `Circle` to `Ellipse` pairing conversion (the relation requires identical runtime types, and no task needs a circle's original geometry), and the per-entity pairing mismatch warning (no task in this plan makes a mismatch more likely, and the existing count warning still catches a package change). If a reviewer raises either, they belong to a follow-up, not here. + +**Type consistency.** `InsertPlacement.MapPoint`, `MapVector`, `MapOcsPoint` and `TryGetPlanarSimilarity` are defined in Task 1 and used with those exact names in Tasks 3, 5, 6 and 7. `WipeoutWorldRings(Wipeout, Transform?)` replaces `WipeoutWorldBoundary(Wipeout)` in Task 6 and all three of its consumers are named there. `DrawHatch` gains its `Transform?` parameter in Task 3, `DrawWipeout` in Task 6, and `DrawLeader` gains `ResolvedStyle` and `Layer?` in Task 5; each task updates its own switch arm. `BlockGraphIsCircular` is defined in Task 4 and used in Task 5. `VisibleRuns` is defined and used in Task 7 only. + +**Ordering.** Task 1 must come first (everything uses it). Task 4 must precede Task 5 (the arrow guard uses it). Tasks 2, 3, 6 and 7 are independent of each other. Task 8 must come last. diff --git a/docs/superpowers/specs/2026-09-02-layers-and-svg-design.md b/docs/superpowers/specs/2026-09-02-layers-and-svg-design.md new file mode 100644 index 0000000..9ec6910 --- /dev/null +++ b/docs/superpowers/specs/2026-09-02-layers-and-svg-design.md @@ -0,0 +1,324 @@ +# Layer attributes and SVG output: design spec + +- Date: 2026-09-02 +- Branch: `mubeda/svg-support` +- Status: approved by the repository owner after a structured design interview (four rounds). +- Research inputs: `docs/research/layers-and-svg-support.md` (repo state), plus web research on SVG conventions for React apps and on SkiaSharp's SVG canvas (summarised in section 8). + +## 1. Goal + +Add full layer attribute support and a hand-written SVG output backend to ACadSharp.Image, sharing all entity decomposition and layer logic between the existing ImageSharp raster backend and the new SVG backend. + +## 2. Non-goals + +- Non-rectangular viewport clipping (follow-up). +- Text outlined to paths in SVG (follow-up, opt-in). +- Embedded shapes or text inside linetypes (rendered as gaps). +- Wildcard or regex layer selection. +- Any change to the DXF/DWG reading side (ACadSharp does that). + +## 3. Global constraints + +- ACadSharp pinned to **3.7.1** (verified: solution builds and all 28 tests pass against it). +- Target frameworks for the library: **net8.0;net10.0** (net6.0 dropped). CLI and tests stay net10.0. Remove `6.0.x` from both GitHub workflows. +- **No new NuGet dependencies.** SVG is built with the in-box `System.Xml.Linq` types (`XDocument`/`XElement`), which allow appending to per-layer groups in any order. +- SkiaSharp is rejected (section 8.2). +- Single branch, single PR. Plans are executed in order: foundation, SVG backend, layer attributes. +- Release versioning comes from the git tag (`release.yml` passes `-p:Version=${GITHUB_REF_NAME#v}`); the next release tag must be a **major** bump because `RenderedImagePage`, `Render()`, and `ImagePage.Entities` semantics change. Record this in the README migration notes. +- Existing raster output must stay **pixel-identical** for the default configuration after the abstraction refactor. Parity is verified against committed baseline PNGs rendered from the three files in `Samples/` before the refactor (font pinned to `DejaVu Sans`, which is installed on this machine and on `ubuntu-latest`). +- **Amended 2026-09-03 (follow-up after review):** paper-space pages are the one deliberate exception. The pre-refactor renderer rounded a raster viewport's height up to whole pixels and used the rounded value as the Y-flip origin, shifting the viewport's content down by the fraction and dropping geometry on the view's lower edge. `RasterDrawingSurface.BeginViewport` now keeps the exact height as the flip origin (the child image is still whole pixels). Both `HSK80AHCP16190M_BMG.paper.01.png` and `viewport-sheet.paper.01.png` were regenerated once for that cause, but only `viewport-sheet.paper.01.png` actually changed (2651 of 400000 pixels); `HSK80AHCP16190M_BMG.paper.01.png` came out byte-identical because that page is blank — its first paper layout contains no non-paper viewport (the exporter skips the paper-representing one) and no entities, so the PNG is 400000 white pixels and the SVG golden is only a background rect. The second-order effect of keeping the exact height is that geometry lying exactly on the view's upper edge is now only half-covered (correct: it straddles the boundary), where it was previously drawn fully inside. Model-space baselines are unchanged. +- Coding conventions already in the repo: `this.` prefix on members, explicit types except LINQ lambdas, XML docs on public members, `sealed` classes, file-scoped namespaces, `internal` for rendering internals with `InternalsVisibleTo` for the test project. + +## 4. Layer attributes + +### 4.1 Visibility mode (opt-in) + +```csharp +public enum LayerVisibilityMode +{ + /// Render everything regardless of layer state. Default; today's behaviour. + All, + /// Hide entities on layers that are off or frozen, entities flagged invisible, and layers frozen in the current viewport. + Screen, + /// Screen rules plus hide entities on non-plottable layers. + Plot, +} +``` + +`ImageConfiguration.LayerVisibility { get; set; } = LayerVisibilityMode.All`. + +Facts from ACadSharp 3.7.1 used by the rules: `Layer.IsOn` (bool, default true), `Layer.Flags` has `LayerFlags.Frozen`, `Layer.PlotFlag` (bool, default true), `Entity.IsInvisible`, `Viewport.FrozenLayers` is `List`. + +### 4.2 Selection + +- Existing exclude list stays: `HideLayer`, `HideLayers`, `ShowLayer`, `ClearHiddenLayers`, `HiddenLayers`. +- New include list with the same shape: `IncludeLayer(string)`, `IncludeLayers(IEnumerable)`, `ExcludeLayer(string)` (removes from include list, returns bool), `ClearIncludedLayers()`, `IReadOnlySet IncludedLayers`. Case-insensitive. +- Composition order, evaluated per drawn entity: include list (if non-empty, the layer must be in it), then hide list, then visibility mode. +- All checks run in the **render loop**, not at add time. `ImageExporter.Add(...)` no longer filters by layer (it still skips `Viewport` entities in the entity list). Consequences accepted: `ImagePage.Entities` contains entities that may not be drawn; changing the configuration after `Add` takes effect; viewport contents, exploded `Insert` sub-entities and dimension block entities are all filtered. +- Effective layer of a nested entity: its own layer, except entities on layer `0` (`Layer.DefaultName`) inherit the parent `Insert`'s effective layer. The effective layer is also what ByLayer attributes resolve against (section 4.3), so a layer-0 line in a block takes the placing insert's colour, weight and linetype, as in AutoCAD. +- Framing of auto-sized pages (block and model-space pages, whose extents come from their entities) is recomputed at render time from the entities that pass the filters, so hiding a far-away layer still tightens the frame exactly as it did with add-time filtering. Layout pages keep their paper size. **Amended during review (2026-09-03):** the recomputed frame is a `PageFrame` value handed to the render context; the `ImagePage` itself (`Translation`, `Layout.PaperWidth/Height`) is never modified by rendering, so clearing the filters and rendering again restores the full frame. + +### 4.3 Attributes honoured + +| Attribute | Source | Raster | SVG | +| --- | --- | --- | --- | +| Colour ByLayer/ByBlock | `ImageStyleResolver.ResolveAttributes` (see note) | stroke colour | `stroke`/`fill` | +| ACI 7 | luminance of `BackgroundColor`, or `ImageConfiguration.ForegroundColor` when set | colour | colour | +| Line weight ByLayer/ByBlock | `ImageStyleResolver.ResolveAttributes` (see note) | px via `GetLineWeightPixels` | px (non-scaling mode) or drawing units (section 5.5) | +| Linetype | `ImageStyleResolver.ResolveAttributes` (see note), `LineType.Segments` | `PatternPen` (pattern is in multiples of stroke width) | `stroke-dasharray` | +| Transparency | `Entity.Transparency` | alpha blended into colour | `opacity` attribute | +| Off / frozen / plot / viewport-frozen / invisible | section 4.1 | skipped | omitted from file | + +**Amended during review (2026-09-03), replacing "`Entity.GetActive*()` (unchanged)":** ACadSharp's `GetActiveColor`/`GetActiveLineWeightType`/`GetActiveLineType` resolve ByLayer against the entity's stored layer and ByBlock against the block record's owner, and the clones `Insert.Explode()` returns have neither owner nor document, so nested ByLayer ignored layer-0 inheritance and nested ByBlock stayed unresolved (drawn black). `ImageStyleResolver.ResolveAttributes(Entity, Layer? effectiveLayer, ResolvedStyle? parent)` resolves them instead: ByLayer reads the effective layer; ByBlock reads the placing insert's (or dimension's) resolved attributes; at top level ByBlock is colour 7, `LineWeightType.Default` and continuous, as AutoCAD draws it. The resolved `ResolvedStyle` record is the parent of the entity's block contents. + +**Deviation from the interview (recorded):** ACadSharp 3.7.1's `Layer` has no `Transparency` property, and `Entity.Transparency` defaults to ByLayer (`Value == -1`). Resolution: ByLayer resolves to opaque; ByBlock resolves to the parent `Insert`'s resolved opacity (opaque at top level); explicit values map `Value` 0..90 to `opacity = 1 - Value / 100.0`. + +### 4.4 Linetype scaling rules + +- Dash lengths in drawing units: `segment.Length * header.LineTypeScale * entity.LineTypeScale`. `header` is `entity.Document?.Header`; when the entity has no document, `LineTypeScale` is 1. **Amended during review (2026-09-03):** block contents have no document (they are clones), so the header travels down the `ResolvedStyle` chain from the placing insert, and the entity's `LineTypeScale` is multiplied by every enclosing insert's (`ResolvedStyle.LineTypeScale`). +- Segment mapping: `Length > 0` is a dash, `Length < 0` is a gap of `|Length|`, `Length == 0` is a dot rendered as a dash of one stroke width, `IsShape` or `IsText` segments are gaps of `|Length|`. +- Paper-space viewports: when the header's `$PSLTSCALE` is 1 (the DXF default), dash lengths inside a viewport are scaled by the **page** scale, not the viewport scale, so dashes look uniform on the sheet. Otherwise they scale with the viewport. Implementation note: ACadSharp 3.7.1's `SpaceLineTypeScaling` enum stores the raw DXF value (`Viewport = 0`, `Normal = 1`) but its member names are swapped relative to AutoCAD semantics, so the code branches on the raw integer value, not the enum name. +- Raster: when the full pattern length in pixels is below `ImageConfiguration.MinimumDashPixels` (default 2), draw solid. SVG in pixel-width mode applies the same threshold; in drawing-unit mode it does not. +- `LineType.Continuous` (no segments) and null linetypes are solid. + +### 4.5 Hatch (new entity, both backends) + +- Solid (`hatch.IsSolid`): fill the boundary loops with the even-odd rule. Loop points come from `path.GetPoints(ArcPrecision)`. +- Pattern: `hatch.ExplodePattern()` returns `Line` entities already clipped to the boundary and already honouring `PatternScale`, `PatternAngle`, and `DashLengths` (verified empirically against 3.7.1). Draw each as a line with the hatch's style. Cap at `ImageConfiguration.MaxHatchLines` (default 20000): beyond the cap, stop and raise a `Warning` notification. +- Deviation recorded during implementation: a pattern hatch whose `Pattern` is null raises a `Warning` and draws nothing (ACadSharp's `ExplodePattern()` would silently return an empty sequence; the warning is deliberate so a blank hatch is explained). +- **Amended during review (2026-09-03):** `ExplodePattern()` builds every line before returning, so the cap alone did not bound work or memory. Before calling it, `EntityRenderDispatcher.EstimateScanLines(hatch)` counts the scan lines the expansion would sweep across the hatch's bounding box (the library's own arithmetic); when the count exceeds `MaxHatchLines` the hatch is skipped with a `Warning`. The per-line cap still applies to what is drawn. +- **Amended during review (2026-09-03):** boundary points and exploded pattern lines are OCS coordinates. When `hatch.Normal` is not `(0,0,1)` they are transformed to world space with `OcsTransform` (`hatch.Elevation` as OCS Z) before projection. +- This is the top-level rule; a hatch inside a block reference is drawn from the original block entity in its own OCS and mapped through the insert transform instead, never from the exploded clone's boundary — see section 5.3 for the full rule and the ordinal-pairing-failure exception. + +### 4.6 Additional entities (2026-09-03) + +- Draw order: pages enumerate `BlockRecord.GetSortedEntities()` (handle order, then the DRAWORDER `SortEntitiesTable`), not file order, so later entities paint over earlier ones (in SVG, within each layer group; layer grouping comes first). It applies to page-level entities only: the contents of a block reference are drawn in the block's stored order at the first nesting level; deeper levels come back from ACadSharp's block clone in handle order (`BlockRecord.Clone()` enumerates `GetSortedEntities()`), so DRAWORDER inside nested blocks is honoured only there. Paper-space viewports are interleaved with paper entities in the same sorted order (in SVG the viewport group is a sibling of the layer groups, so a later paper entity on an already-open layer group still precedes it in document order); a viewport's contents are the sorted model-space entities whose bounds overlap or touch its view box in the XY plane (an axis-aligned interval test on X and Y, Z ignored, so an entity that encloses the view box or crosses it without either bound's own corner inside the other is still kept — unlike ACadSharp's own corner-based `Viewport.SelectEntities`), and an entity whose bounds cannot be computed is skipped with a Warning. +- 3DFACE (`Face3D`): visible edges stroked as polylines (one closed polygon when no edge is hidden; a triangle repeats its third corner and drops the degenerate edge). Never filled. Corners are WCS. +- Block attributes: `Insert.Attributes` are drawn through the TEXT pipeline with no placement (ATTRIB coordinates are absolute); non-constant ATTDEFs yielded by `Explode()` are skipped, and a constant one is skipped too when the insert already carries an ATTRIB with a matching tag (ACadSharp's `Insert(BlockRecord)` constructor emits one even for constant definitions, so a constant attribute is never drawn twice) — tag matching is case-insensitive (`StringComparison.OrdinalIgnoreCase`), since DXF attribute tags are case-insensitive identifiers. A constant ATTDEF drawn from the explode path (no matching ATTRIB) still follows ATTMODE and its own Hidden flag through `IsAttributeVisible`, the same rule an ATTRIB follows: ignored under `LayerVisibilityMode.All`, otherwise None hides all, Normal hides Hidden, All shows all. A nested insert exploded out of a block carries no `Document` of its own, so its ATTMODE is read from the outermost placing insert's document via the resolved style threaded down through `DrawBlockContents` (`insert.Document?.Header ?? parent.Header`), not defaulted to Normal. A multi-line attribute (`AttributeType.MultiLine` or `ConstantMultiLine`) is laid out from its embedded `MText` — text, rectangle width, height, rotation and attachment point — while the attribute stays the observable entity, so its layer, colour, handle and parent metadata are unchanged. When the embedded object is missing, which is what ACadSharp reports for a pre-2018 DWG, the single-line value is drawn with a Warning. An attribute belonging to an insert nested inside another block is laid out in that block's own coordinates; only top-level attributes are placed. +- Explode pairing: when `Explode()` yields a different number of entities than the block holds, a Warning is raised. +- LEADER (`Leader`): polyline through `Vertices` (WCS; the hookline is the last vertex), or a Catmull-Rom cubic Bézier chain for `PathType.Spline`; default closed filled arrowhead (length `ArrowSize x ScaleFactor`, base width one third) at the first vertex when `ArrowHeadEnabled`; the associated annotation is never drawn by the leader. A custom arrowhead block (`DimensionStyle.LeaderArrow`) is drawn as a placed block: its base point goes to the leader tip, its local +X axis turns to the outward direction at the tip, and it is scaled by `ArrowSize * ScaleFactor` composed with the placement of any block reference around the leader. A composed transform that is not a planar similarity has no equivalent `Insert`, and an empty or self-referencing arrow block cannot be drawn; each falls back to the default closed triangle with a Warning (**amended 2026-09-04**). +- MLINE (`MLine`): one polyline per style element through `Position + Parameters[0] x Miter` (stored geometry is final; justification and scale are not re-applied), style-element colour and linetype falling back to the entity's, fill between the outermost elements when the style has FillOn — closed multilines fill the full ring (a keyhole polygon bridging the outer and inner rings, not just the open band), square caps only; vertices without parameters fall back to style offsets with a Warning; Cut positions (DXF group 41 beyond the first two values) are drawn: they are read as absolute distances from the element's start, alternating break and resume, with an odd count leaving the element hidden to its end and a break at or past the segment's end meaning no cut. An element with no usable cut positions is still drawn as a single polyline, so its linetype phase is unbroken. Fill cuts (group 42) are notified, not drawn. That reading is unconfirmed: the DXF reference reads that way, but ezdxf reads the same values as relative dash and gap lengths and no implementation settles it, so a drawing with more than one cut per element may differ from AutoCAD (**amended 2026-09-04**). Multilines and leaders in nested blocks are healed in place after cloning: `MLine.Clone()` in 3.7.1 empties the vertex list an MLINE shares with its source at every depth, `Leader.Clone()` shares its vertex list the same way but has `Explode()`'s `ApplyTransform` overwrite it with world coordinates instead of emptying it, and `Insert.Clone()` deep-clones its block, so exploding an insert can corrupt an MLINE several blocks below it even though it is not that insert's direct child (its list is emptied the moment it is cloned), while a nested LEADER's list is overwritten only when the insert directly containing it is exploded, so its own snapshot is a defensive backstop rather than the load-bearing fix MLINE needs; every MLINE and LEADER reachable through a block's subtree is snapshotted before `Explode()` and restored (Clear + AddRange into its existing list, never reassigned) immediately after and again on the way out. That subtree follows nested inserts; a DIMENSION's own `Block`, the anonymous block holding the picture ACadSharp generates for it, which `Dimension.Clone()` deep-clones (probed: an MLINE inside a picture block goes from two vertices to none across one `Clone()`, and the clone's block is a different instance) and which — unlike the arrowheads — is on the ordinary render path, since `DrawDimension` draws through it; and, for a LEADER *and* a DIMENSION alike, all four block-valued properties of its dimension style (`ArrowBlock`/DIMBLK, `DimArrow1`/DIMBLK1, `DimArrow2`/DIMBLK2 and `LeaderArrow`/DIMLDRBLK), because cloning either entity clones its dimension style and `DimensionStyle.Clone()` deep-clones all four blocks, so an MLINE inside any of them is emptied by an explode that never names it — including the three arrowheads this renderer never draws (**amended 2026-09-04**, widened from `Leader.Style.LeaderArrow` alone); the scan that decides whether a subtree needs snapshotting therefore treats a DIMENSION as needing it too, since a block holding only a dimension would otherwise take no snapshot at all; and because ACadSharp 3.7.1's `Insert(BlockRecord)` constructor clones a document-owned block's entities, the snapshot around a custom arrowhead is taken before the renderer's own transient insert is constructed, not inside the block-content path which only sees the insert once it exists (**amended 2026-09-04**). `Dimension.UpdateBlock()` is the second site that reaches that constructor: generating the picture for a linear or aligned dimension builds an `Insert` of each of the style's arrow blocks, so a top-level dimension whose picture is not stored in the drawing is snapshotted and healed around that call too, in a `finally`, and is preceded by the same cycle pre-check the transient arrowhead insert uses — a dimension reached through a block reference is already covered by the block-content path's snapshot, and `EntityBounds` was probed not to reach the constructor at all (`Dimension.GetBoundingBox()` leaves `Block` null), so page framing cannot corrupt an arrow block ahead of the draw (**amended 2026-09-04**). A block LEADER's arrowhead and path are drawn from the original, snapshotted vertices mapped through the insert's transform, so a scaled or rotated insert scales and rotates the arrowhead with it (**amended 2026-09-04**); a block SOLID with a non-world normal likewise has its OCS corners brought into world space before the insert transform, not after. Justification fallback uses the signed scaled offsets; element linetypes named ByLayer/ByBlock inherit the entity's dashes; a style with non-finite offsets or scale skips the entity with a Warning, as does any non-finite vertex parameter, cut positions included (**amended 2026-09-04**); block subtrees are scanned for MLINEs, LEADERs and DIMENSIONs once per render and cached (**amended 2026-09-04**). A block whose graph references itself is skipped with a Warning before it is exploded, and is refused by `EntityBounds` too, because `Insert.Explode()` and `Insert.GetBoundingBox()` both recurse through the graph and a stack overflow cannot be caught. That graph follows the same edges as the heal walk — nested inserts, a DIMENSION's own picture block, and all four dimension-style arrowhead blocks of a LEADER or a DIMENSION — because each of them is followed by a deep clone that recurses (**amended 2026-09-04**). Refusing on the picture edge cannot cost a legitimate drawing: a picture block is geometry generated from the dimension's own definition points and never places the dimension's container in it, so a cycle there is a file that would otherwise exhaust the stack inside `Explode()`; a picture block shared by two dimensions is a diamond, which the on-path set already tells apart from a cycle. One shared enumerator supplies the edge set to the heal walk, the heal scan and the cycle walk, so the three cannot drift apart; the cycle walk also keeps a set of blocks already proven acyclic within the one call, alongside the on-path set it detects cycles with, so a heavily shared block DAG is not walked exponentially. +- WIPEOUT (`Wipeout`): A wipeout masks its clip boundary, or the whole image frame when clipping is off, whatever its clip mode says. An inverted clip (`ClipMode.Inside`) masks the frame minus the boundary as a single even-odd path. Its geometry is taken from the original entity, with the insertion point mapped as a point and the U and V vectors as directions. Framing and viewport culling use the same rings, so an inverted wipeout is bounded by its whole footprint. A wipeout still needs an opaque background to mask, and is skipped with a Warning otherwise. In SVG the mask covers only its own layer group and earlier groups, because layer grouping takes precedence over draw order. +- Framing: auto-sized pages are framed with `EntityBounds`, which applies the wipeout pixel mapping and solid OCS normals the renderer uses and skips entities whose bounds ACadSharp cannot compute; an `Insert` without a block is skipped silently by the frame and skipped with a Warning wherever it is drawn (`DrawBlockContents`, and paper-space viewport content selection). `ImagePageRenderer.SelectViewportEntities` culls model-space viewport content through the same `EntityBounds`, so the region tested against the view box is the one the renderer actually draws (the wipeout pixel mapping, the solid OCS normal), not ACadSharp's raw `GetBoundingBox`. This applies to page-level entities only: a block reference is bounded by ACadSharp's own `Insert.GetBoundingBox()`, which applies neither mapping, so a wipeout or an OCS solid *inside* a block is framed from its raw stored geometry — coarser, never wrong. A block reference nested inside a resolvable block, itself referencing an unresolved block, drops the whole outer insert from the frame (`EntityBounds.TryGet` also catches `NullReferenceException`, which ACadSharp's `Insert.GetBoundingBox()` throws for this case) — silently in `ComputeFrame`, with a "bounds could not be computed" Warning in `SelectViewportEntities` — degraded, not wrong, the same trade-off already accepted for a malformed bulge polyline. + +## 5. SVG backend + +### 5.1 Coordinate system + +- `viewBox="0 0 W H"` where `W`/`H` are the page size in drawing units (`Layout.PaperWidth/PaperHeight`, same values the raster mapping uses). Y is flipped by the render context, so SVG y grows downward like the raster canvas. +- No `width`/`height` attributes by default. When `SvgOptions.EmitSize` is true, emit `width="{Configuration.Width}"` and `height="{Configuration.Height}"` (pixels) so the SVG has an intrinsic size and the browser letterboxes with the default `preserveAspectRatio="xMidYMid meet"`. +- Padding is applied as viewBox margin: the viewBox becomes `-padL -padT (W + padL + padR) (H + padT + padB)` where paddings are converted from pixels to drawing units using the raster fit scale (`min(drawableWidth/W, drawableHeight/H)`), so the framing matches the PNG. + +### 5.2 Document structure + +```xml + + + + + + + + + + +``` + +- One `` per layer, in order of first appearance while drawing. Elements are appended to the group of their **effective** layer (section 4.2), so an Insert's sub-entities land in their own layers' groups. Accepted: this changes z-order relative to entity order. +- Layer group carries the layer's own colour and pixel/unit width as defaults; every element still writes its resolved `stroke`/`stroke-width`/`stroke-dasharray`/`opacity` when they differ from the group default. +- `id` sanitisation: lower-case, characters outside `[a-z0-9_-]` replaced by `-`, collapsed; prefixed by `SvgOptions.IdPrefix` (default empty). `data-layer` carries the raw name. +- Entity attributes (`data-handle` hex upper-case, `data-type` = entity `ObjectName`, `data-parent`, `data-block`) are emitted when `SvgOptions.EmitEntityAttributes` is true (default). `data-handle` is omitted when the handle is 0, which is the case for the transient clones `Insert.Explode()` produces for block contents in ACadSharp 3.7.1; such elements still carry `data-parent` (the insert's handle) and `data-block`. +- Hidden or filtered entities are **omitted**, never written with `display="none"`. +- Background rect only when `BackgroundColor` alpha is greater than 0. +- No XML declaration (inline SVG in HTML must not carry one); files are written as UTF-8 without a BOM. +- Layer group ids are unique per document: `{prefix}layer-{name}` at page level and `{prefix}clip-{n}-layer-{name}` inside viewport `n`. +- **Amended during review (2026-09-03):** `IdPrefix` is restricted to `[A-Za-z0-9_-]` (case kept, other runs collapsed to `-`) so `url(#id)` references stay valid. Strings taken from the drawing (text, `data-layer`, `data-block`, font family) have XML-1.0-forbidden characters removed (`SvgXmlText.Clean`, `XmlConvert.IsXmlChar`) instead of letting the serialiser throw. A translucent `BackgroundColor` keeps its alpha as `fill-opacity` on the background rect. + +### 5.3 Primitives + +- Line: ``. Polyline: `` or `` when closed. Filled polygon: ``. Even-odd multi-loop fill: ``. +- Arc: ``. Because the context flips Y, a counter-clockwise CAD arc (positive sweep) has `sweep-flag="0"` in SVG space; `large-arc-flag = |sweep| > PI ? 1 : 0`. +- Full circle: ``; full ellipse: ``; partial ellipse: one `A` command with `rx = MajorAxis / 2`, `ry = MinorAxis / 2` (ACadSharp 3.7.1's `Ellipse.MajorAxis`/`MinorAxis` are full axis lengths, verified empirically: `MajorAxisEndPoint (4,0,0)` with `RadiusRatio 0.5` gives `MajorAxis 8`, `MinorAxis 4`) and `rot` from `Rotation`. +- Polylines, hatches and solids store OCS coordinates that ACadSharp does not transform (`GetPoints`, `BoundaryPath.GetPoints` and `ExplodePattern` all return raw values). **Amended during review (2026-09-03):** when the entity's `Normal` is not `(0,0,1)`, points go through `OcsTransform` (arbitrary axis algorithm, `Elevation` as OCS Z) before projection, on both backends. Bulges are emitted natively only on the world plane; any other normal tessellates first. `Solid` corners go through `OcsTransform` the same way, but `Solid` has no `Elevation` property in ACadSharp 3.7.1 — each corner's own Z is used as its OCS Z instead (follow-up 2026-09-03). `TextEntity` points are OCS too (remaining fix, 2026-09-03): the origin goes through `OcsTransform`, and `TextRenderer.Place`/`TextRenderer.Orient` project the reading and up directions, adding half a turn and swapping Start/End anchors for planes seen from behind (readable glyphs on the mirrored extent; AutoCAD shows them mirrored, this is a readability choice). `MText` insertion point and X axis are WCS in DXF and are used as stored. **Amended during review (2026-09-03):** text inside a block reference is placed through the insert's transform from the original block entity rather than from the exploded clone, because ACadSharp 3.7.1 leaves TEXT alignment points and MTEXT directions untransformed when exploding; a hatch is drawn from the original block entity in its own OCS (normal and elevation) and mapped through the insert transform, so a hatch on a tilted plane inside a block is placed correctly; the exploded clone's boundary is never used, because ACadSharp 3.7.1 transforms it as if it were world data — except when the block's ordinal pairing between clones and originals fails (the exploded-count-mismatch Warning), in which case the hatch falls back to its own exploded clone with no OCS or placement correction, so a mirrored or tilted hatch drawn in that situation is wrong. A wipeout inside a block is drawn from the original the same way, with its insertion point mapped as a point and its U and V vectors as directions; on the same pairing failure it too falls back to its own exploded clone, whose U and V carry the translation `Wipeout.ApplyTransform` folded into them by mapping them as points, so a wipeout placed by a translated insert is drawn with the wrong footprint in that situation (**amended 2026-09-04**). An MLINE or LEADER clone has a softer fallback — it is still placed through the insert transform, from its own healed local vertices — and the count-mismatch Warning is the only signal for any of these cases. +- Polyline bulges: each bulged segment becomes an `A` command. With bulge `b`, chord length `c`, included angle `theta = 4 * atan(|b|)`, radius `r = c / (2 * sin(theta / 2))`, `large-arc = theta > PI`, `sweep-flag = b > 0 ? 0 : 1` after the Y flip. +- Splines: Bezier-form clamped cubic splines (already detected by the existing `SplineRenderer.TryGetBezierSegments`) emit `C` segments directly. Other clamped, non-rational degree-3 splines are converted to Bezier segments by knot insertion (Boehm) until every interior knot has multiplicity 3, then emitted as `C` segments. Everything else uses the existing tessellation and emits a polyline. +- Text: ``. Multi-line MText uses one `` per line. `dominant-baseline` is limited to `alphabetic`, `central`, `hanging`. `text-anchor` from CAD justification. `textLength` only for `TextEntity` with `Fit` or `Aligned` alignment (distance between insert and alignment points). Font stack: `FontFamilyName` followed by `Arial, Helvetica, sans-serif` (duplicates removed). **Amended 2026-09-03 (text fidelity):** `font-size` is 4/3 of the CAD text height (the cap height), matching the raster backend, which lays the em out at 72 dpi (`TextMetrics.EmSize`); multi-line blocks are offset so the whole block hangs from, is centred on, or stands on the anchor (Hanging/Central/Alphabetic); MTEXT with a rectangle width is wrapped by a greedy fit whose advances are measured with SixLabors.Fonts through `FontResolver`, at the raster's break opportunities (after a whitespace run, and after a hyphen or slash not followed by whitespace) so both backends break at the same opportunities and produce the same number of lines in every tested case; the greedy fit may pick a different opportunity than the raster when more than one fits. `\U+XXXX` escapes and `%%` codes are decoded for both backends. **Amended 2026-09-03 (remaining fixes):** the raster backend lays text out at a fixed 72 dpi with the em size (`TextMetrics.EmSize`, shared with the SVG backend), so text no longer scales with `ImageConfiguration.Dpi`; the single-line compensation is `em x (LineSpacing - 1) / 2`, exact for every line-spacing factor. **Amended 2026-09-04:** text under a non-uniform transform — a block reference or a tilted OCS plane — carries `SurfaceText.WidthScale` (transformed reading-axis length over up-axis length), following the reading axis of whatever placed it; the SVG emits `translate scale translate` after the rotation and the raster composes the same matrix; wrapping width and fixed length follow the reading axis. **Amended 2026-09-04 (`xml:space` scoping):** a single-line run puts `xml:space="preserve"` on `` itself, safe because its only child is then the direct text node; a multi-line run instead puts it on each `` and deliberately leaves it off ``, because a `` holding only a direct text node is not itself indented by `XDocument.Save`, while putting `xml:space` on the parent `` would also preserve the newline and indentation the serializer's pretty-printing adds between `` elements, drawing it as extra space at the end of the preceding line. +- Viewports: `` with `` in ``; contents transformed into page units by the viewport render context (no `transform` attribute needed). + +### 5.4 Numbers and precision + +- `SvgOptions.Precision` (int?, default null = adaptive). Adaptive: `decimals = clamp(4 - floor(log10(max(W, H))), 0, 8)`, giving a resolution of one ten-thousandth of the larger viewBox side. +- All numbers written with `InvariantCulture`, trailing zeros trimmed. +- The adaptive precision applies to coordinates, radii, sizes and the viewBox only. Style scalars (`stroke-width`, `stroke-dasharray` values, `opacity`) use their own precision: 3 decimals in pixel mode and for millimetre-scale drawing units, growing with the unit in drawing-unit mode as `clamp(3 - floor(log10(unitsPerMillimetre)), 3, 8)`, so a 0.25 mm line weight on a drawing in metres (0.00025 units) is not rounded to 0. **Amended during implementation (plan 02, task 7), replacing "always use a fixed 3 decimals".** Angles (`rotate(...)`) use 4 decimals. + +### 5.5 Stroke widths + +- `SvgOptions.NonScalingStroke` (default true): every stroked element gets `vector-effect="non-scaling-stroke"` and `stroke-width` in **pixels** from `GetLineWeightPixels`. +- When false: `stroke-width` in **drawing units** = millimetres from the line weight table converted by `CadHeader.InsUnits` (`Millimeters` 1, `Centimeters` 0.1, `Meters` 0.001, `Inches` 1/25.4, `Feet` 1/304.8, `Unitless` or unknown treated as millimetres), times `LineWeightScale`. Dash arrays use the same unit. +- Dash arrays follow the width's unit: with `non-scaling-stroke` the browser computes the whole stroke outline, dashes included, in pixel space, so `stroke-dasharray` values are pixels (the SVG page context's `LineTypeScale` is the raster fit scale in pixels per unit). In drawing-unit mode they are drawing units (`LineTypeScale` 1). + +### 5.6 Options object + +```csharp +public sealed class SvgOptions +{ + public bool NonScalingStroke { get; set; } = true; + public bool EmitEntityAttributes { get; set; } = true; + public bool EmitSize { get; set; } = false; + public string IdPrefix { get; set; } = string.Empty; + public int? Precision { get; set; } // null = adaptive; setter validates 0..8 +} +``` + +Exposed as `ImageConfiguration.Svg { get; }` (never null). + +## 6. Public API changes + +- `ImageExportFormat.Svg` added; extension `.svg`; `TryParse` accepts `svg`. +- `public abstract class RenderedPage : IDisposable { string Name; ImageExportFormat Format; void Save(string path); abstract void Save(Stream stream); }`. **Amended during review (2026-09-03):** `Save(string)` is concrete: it creates the target directory and opens the file, then calls the abstract `Save(Stream)`, so both page types share that behaviour. +- `RenderedImagePage : RenderedPage` keeps `Image Canvas`; `Save` encodes with the page's `Format` and the quality captured at render time. +- `public sealed class RenderedSvgPage : RenderedPage { string Content; }`. +- `ImageExporter.Render()` becomes `Render(ImageExportFormat format = ImageExportFormat.Png)` returning `IReadOnlyList`. `Save(path, format)` calls `Render(format)` and then `page.Save(path)`. +- `ImageConfiguration` additions: `LayerVisibility`, `IncludedLayers` + methods, `ForegroundColor` (`SixLabors.ImageSharp.Color?`, default null), `MinimumDashPixels` (float, default 2), `MaxHatchLines` (int, default 20000), `Svg`. +- `ImagePage.Entities` semantics: contains all added entities; filtering happens at render. + +## 7. CLI + +- `--format svg` and `.svg` output extension inference. +- `--layer-visibility `. +- `--only-layer ` (repeatable). +- `--list-layers`: prints a fixed-width table (name, on, frozen, plottable, colour, lineweight, linetype, entity count in model space) and exits 0 without rendering. +- `--svg-no-scaling-stroke`, `--svg-no-entity-attributes`, `--svg-size`, `--svg-id-prefix

`, `--svg-precision <0-8>`. + +## 8. Research conclusions that shaped the design + +### 8.1 SVG for React consumers (primary sources: MDN, SVG 2, SVGO, SVGR docs) + +- Responsive SVG is `viewBox` without `width`/`height`; the host sizes it with CSS. +- SVGR runs SVGO with `preset-default` + `prefixIds`; `cleanupIds` deletes unreferenced ids, `collapseGroups` flattens attribute-free groups, `removeHiddenElems` deletes `display="none"` elements. Only `data-*` attributes survive untouched, so `data-layer` is the durable handle and hidden content must be omitted rather than hidden. +- Three of four popular pan/zoom libraries own a `transform` on a `` inside the SVG; one discards root `` attributes. Hence the attribute-free `cad-root` group and defaults on an inner group. +- `vector-effect="non-scaling-stroke"` keeps stroke width constant under in-SVG transforms; widths must then be pixel-scale. +- Real `` is selectable and accessible; `textLength` keeps widths stable under font substitution. + +### 8.2 SkiaSharp rejected + +SkiaSharp's `SKSvgCanvas` emits no consumer-controlled groups, no `viewBox`, no `stroke-dasharray` (dashes are flattened to filled polygons), no way to add ids or data attributes, auto-generated ids that collide across inlined SVGs, and requires a 58 MB native asset package with documented Linux loading failures. + +### 8.3 ACadSharp facts verified against 3.7.1 + +- `Hatch.ExplodePattern()` returns boundary-clipped `Line` entities honouring scale, angle and dash lengths. `Hatch.Explode()` returns only the boundary polylines. +- `Viewport.SelectEntities(bool includePartial = true)`; `Viewport.FrozenLayers` is initialised (non-null) on a new viewport. +- `Hatch.BoundaryPath.GetPoints(int)` returns `IEnumerable`; `IVertex.Location` is a `CSMath.IVector` exposing only an indexer; `XYZ` has no distance helper. +- `DwgReader.Read(string filename, NotificationEventHandler notification = null)` and the `DxfReader` equivalent, so single-argument calls compile. +- `Transparency.Value` is -1 ByLayer, 100 ByBlock, 0..90 percent transparent. `Layer` has no transparency. +- `CadHeader.LineTypeScale`, `CurrentEntityLinetypeScale`, `PaperSpaceLineTypeScaling` (`SpaceLineTypeScaling.Viewport | Normal`), `InsUnits`. +- ImageSharp.Drawing `PatternPen(Color, float strokeWidth, float[] pattern)`: pattern values are multiples of the stroke width. `IImageProcessingContext.Clip(IPath, Action)` exists. `ShapeOptions.IntersectionRule` defaults to `EvenOdd`. + +## 9. Interface appendix (names every plan must use verbatim) + +```csharp +namespace ACadSharp.Image.Rendering; + +/// A point in surface coordinates (pixels for raster, drawing units for SVG). Y grows downward. +internal readonly record struct SurfacePoint(double X, double Y); + +internal readonly record struct SurfaceRect(double X, double Y, double Width, double Height); + +/// Resolved style. Widths and dash lengths are in surface units. DashPattern null means solid. Opacity 0..1. +internal readonly record struct ImageStyle( + SixLabors.ImageSharp.Color StrokeColor, + float StrokeWidth, + float[]? DashPattern, + float Opacity) +{ + public ImageStyle(SixLabors.ImageSharp.Color strokeColor, float strokeWidth) : this(strokeColor, strokeWidth, null, 1f) { } +} + +internal enum SurfaceTextAnchor { Start, Middle, End } +internal enum SurfaceTextBaseline { Alphabetic, Central, Hanging } + +/// Everything a backend needs to place text. Origin is in surface units; Height in surface units; Rotation in radians, counter-clockwise in drawing space (backends negate because Y is flipped). +internal sealed record SurfaceText( + string Text, + SurfacePoint Origin, + double Height, + double Rotation, + SurfaceTextAnchor Anchor, + SurfaceTextBaseline Baseline, + double WrappingWidth, // <= 0 means no wrapping + double LineSpacingFactor, // 1.0 = single + double FixedLength); // <= 0 means none; SVG textLength + +/// Identifies the entity being drawn so structured backends can group and tag output. +internal sealed record EntityRenderInfo( + string LayerName, + string EntityType, + ulong Handle, + ulong? ParentHandle, + string? BlockName); + +internal sealed record LayerRenderInfo(string LayerName, SixLabors.ImageSharp.Color Color, float StrokeWidth); + +/// Result of opening a viewport: the surface to draw into and where its origin sits. +internal readonly record struct ViewportSurface(IDrawingSurface Surface, double OffsetX, double BottomY); + +internal interface IDrawingSurface : IDisposable +{ + /// True when the backend draws arcs, ellipses and bulges natively; false when it wants tessellated polylines. + bool SupportsCurves { get; } + + void BeginEntity(EntityRenderInfo info, LayerRenderInfo layer); + void EndEntity(); + + void DrawLine(ImageStyle style, SurfacePoint start, SurfacePoint end); + void DrawPolyline(ImageStyle style, IReadOnlyList points, bool closed); + /// Arc of an ellipse. Angles in radians in surface space (already sign-adjusted for the Y flip). Rotation in radians in surface space. + void DrawArc(ImageStyle style, SurfacePoint center, double radiusX, double radiusY, double rotation, double startAngle, double sweepAngle); + void DrawEllipse(ImageStyle style, SurfacePoint center, double radiusX, double radiusY, double rotation); + /// Cubic Bezier chain: 3n+1 points. + void DrawCubicBezier(ImageStyle style, IReadOnlyList controlPoints, bool closed); + /// Polyline whose segments may be arcs. Bulges[i] applies to segment i (from points[i] to points[i+1]); 0 = straight. + void DrawBulgePolyline(ImageStyle style, IReadOnlyList points, IReadOnlyList bulges, bool closed); + void FillPolygon(ImageStyle style, IReadOnlyList points); + /// Even-odd fill of several rings. + void FillPath(ImageStyle style, IReadOnlyList> rings); + void FillCircle(ImageStyle style, SurfacePoint center, double radius); + void DrawText(ImageStyle style, SurfaceText text); + + /// Opens a clipped viewport region. is the viewport rectangle in this surface's units. + ViewportSurface BeginViewport(SurfaceRect bounds); + void EndViewport(ViewportSurface viewport); +} +``` + +`ImageRenderContext` (internal) becomes backend-neutral: + +```csharp +internal sealed class ImageRenderContext +{ + public IDrawingSurface Surface { get; } + public ImageConfiguration Configuration { get; } + public Layout Layout { get; } + public double SurfaceWidth { get; } + public double SurfaceHeight { get; } + public double OriginX { get; } + public double OriginY { get; } + public double Scale { get; } // surface units per drawing unit + public double OffsetX { get; } + public double OffsetY { get; } + public double LineTypeScale { get; } // surface units per linetype unit (section 4.4) + public Viewport? Viewport { get; } // non-null while drawing viewport contents + public ImageRenderContext? Parent { get; } + + public SurfacePoint ToSurfacePoint(XY point); // x = OffsetX + (p.X - OriginX) * Scale; y = SurfaceHeight - OffsetY - (p.Y - OriginY) * Scale + public SurfacePoint ToSurfacePoint(XYZ point); + public double ToSurfaceLength(double value); // value * Scale + public float ToStrokeWidth(LineWeightType lineWeight); // raster: GetLineWeightPixels; SVG: px or drawing units per section 5.5 +} +``` + +Public additions (namespace `ACadSharp.Image`): `LayerVisibilityMode`, `SvgOptions`, `RenderedPage`, `RenderedSvgPage`, `ImageExportFormat.Svg`, and the `ImageConfiguration` members listed in section 6. + +Internal additions: `RasterDrawingSurface` (ImageSharp), `SvgDrawingSurface`, `EntityVisibilityFilter`, `LineTypeDashResolver`, `SplineBezierConverter`, `SvgNumberFormatter`, `SvgIdSanitizer`. + +Added during implementation and review (2026-09-02/03): `ImageRenderContext.SinglePrecision`, `StrokeUnitsPerMillimeter`, `PixelsPerSurfaceUnit`, `ToSurfacePixels`; `ImageStyle.EffectiveColor`; `SvgDrawingSurface(ImageConfiguration, SurfaceRect viewBox, double? sizeWidth, double? sizeHeight, double? strokeUnitsPerMillimeter = null)`; `ImageStyleResolver.ResolveAttributes(Entity, Layer?, ResolvedStyle?)`, `ToImageStyle(ResolvedStyle, ImageRenderContext, SixLabors.ImageSharp.Color foreground)` and `Resolve(Entity, ImageRenderContext, SixLabors.ImageSharp.Color foreground)`; `LineTypeDashResolver.Resolve(LineType?, CadHeader?, double lineTypeScale, ImageRenderContext, float strokeWidth)`; records `ResolvedStyle` and `PageFrame`; `OcsTransform`; `SvgXmlText`; `ImagePage.ComputeFrame(Func?)`. `ImageRenderContext.SurfaceWidth` and `Parent` are kept as listed although no current code reads them. diff --git a/docs/superpowers/specs/2026-09-04-remaining-limitations-design.md b/docs/superpowers/specs/2026-09-04-remaining-limitations-design.md new file mode 100644 index 0000000..0b09565 --- /dev/null +++ b/docs/superpowers/specs/2026-09-04-remaining-limitations-design.md @@ -0,0 +1,125 @@ +# Remaining rendering limitations: design (2026-09-04) + +Revised 2026-09-04 against the branch after plan 09 (signatures, the existing pairing relation and the cycle pre-check are quoted from the code as it now stands). + +Follow-up to the layers-and-SVG design (`2026-09-02-layers-and-svg-design.md`, whose global constraints, interface appendix and notification rules apply unchanged) and to the research notes `docs/research/remaining-rendering-limitations.md` and `docs/research/remaining-limitations-design-options.md` (the Codex consultation this design argues from). Line references below describe the branch after plan 09; plan 10 executes this design. + +## 1. Goal + +Draw, instead of notifying about, the five remaining gaps listed in the README: MLEDIT cut segments, custom arrowhead blocks, inverted wipeout clips, multi-line attributes, and hatches on a tilted plane inside blocks. Harden the block-content pairing that several of these rely on so a mismatch is caught per entity rather than by a count after the fact. + +## 2. Non-goals + +- Transparent or translucent wipeouts (would need an erase primitive and break the one-group-per-layer SVG structure; the existing Warning stays). +- Exact cross-layer painter order in SVG (layer grouping keeps precedence over draw order, as documented in 5.2 of the base design). +- An `Explode()`-free block traversal. Block contents keep coming from `Insert.Explode()`; originals are used for geometry only where a clone is known to be wrong. +- MLINE fill cuts (group 42, `AreaFillParameters`): a NotImplemented notification replaces the current stroke-cut warning for them. +- Upstream ACadSharp fixes. Everything here works on 3.7.1 as shipped. + +## 3. Facts this design rests on (verified 2026-09-04) + +- Both ACadSharp 3.7.1 readers populate `AttributeBase.MText` for multi-line attributes: the DXF reader on the embedded-object marker (code 101), the DWG reader in `readCommonAttData` for `AttributeType.MultiLine`/`ConstantMultiLine` (the type byte exists only in R2018+ files, so older DWGs always read as single-line). +- `DrawWipeout` and `WipeoutWorldBoundary` return on `ClipMode.Inside` before consulting `ClippingState`, so a wipeout whose clipping is switched off but whose stored mode is inverted is skipped today. +- `Hatch.ApplyTransform` transforms the raw OCS boundary as if it were world data and never folds the original elevation in, so `NormalizeExplodedClone` (resetting any non-world normal to +Z) is only right for a world-plane hatch that a mirror flipped to -Z. +- `Insert.Explode()` is one-to-one and ordered in 3.7.1; clones carry no handle, document or owner, so ordinal position is the only original/clone identity available. `Circle` explodes to `Ellipse`; every other type keeps its type. +- Custom arrow blocks have the tip at the block base point and the body along local -X (real-world example: a square plus a line from (-1,0) to (0,0)). AutoCAD scales the block by `ArrowSize x ScaleFactor` and rotates local +X to the outward direction at the tip; ACadSharp's own `Dimension.dimensionArrow` does the same. +- MLINE `Segment.Parameters`: `p[0]` offset along `Miter` (already multiplied by `ScaleFactor` in stored data), `p[1]` distance from that intersection to the element's actual start, `p[2..]` alternating break/resume positions. The only real-world sample with three values has `p[2]` equal to the segment length (a run to the end, no visible cut), so odd counts are normal and a break at or beyond the segment end means "no cut". Whether `p[2..]` are absolute positions or relative lengths cannot be settled from the available data (see 4.5). +- `DrawBlockContents` has no draw-time recursion guard, but the per-block scan it already runs before exploding (`ScanBlockSubtree`, reached through `BlockSubtreeNeedsHeal`) walks the original block graph and reports whether a cycle cut the walk short. That truncation flag is the cycle signal this design builds on. A draw-time guard keyed on `BlockRecord` identity could not work anyway: nested inserts reached during `Explode()` hold deep-cloned block records, a different key every time, and `Insert.Explode()` deep-clones the whole block graph before any drawing happens, so a genuine cycle overflows the stack inside ACadSharp before a draw-time guard would ever run. The guard has to be a pre-check on the original graph. `BlockRecord.Name` survives cloning and is the usable key where a name-level check is needed. +- Clone list sharing (probed): `MLine.Vertices`, `Leader.Vertices` and `Wipeout.ClipBoundaryVertices` are shared between a clone and its source; `LwPolyline.Vertices`, the `Spline` lists, `Polyline2D.Vertices` and `Hatch.Paths` are copied. `Explode()` overwrites the shared MLINE and LEADER lists in place, which is why the renderer snapshots and heals both. The wipeout clip list is shared but never written by `ApplyTransform`, so drawing wipeouts from the original needs no heal; its `UVector`/`VVector` are transformed as points, which is the actual defect to work around. + +## 4. Design + +### 4.1 Shared: insert placement helpers + +One internal static class, `InsertPlacement` (`ACadSharp.Image/Rendering/InsertPlacement.cs`), gathers the maths that today lives in `TextRenderer.Place`/`Orient` and the per-type helpers of the dispatcher: + +- `MapPoint(Transform? placement, XYZ point)`: identity when null. +- `MapVector(Transform? placement, XYZ vector)`: `MapPoint(v) - MapPoint(0)`, so translations never leak into direction vectors (the wipeout U/V trap). +- `MapOcsPoint(Transform? placement, XYZ normal, double elevation, XY point)`: OCS to world through `OcsTransform.For(normal)` plus elevation, then `MapPoint`. +- `Compose(Transform? outer, Insert inner)`: the placement of `inner`'s contents seen from outside `outer`. +- `IsSimilarity(Transform, out double scale)`: true when the linear part is a rotation (possibly with a reflection) times one uniform scale; used by 4.3. + +Existing callers are moved onto these helpers where they duplicate them, without changing behaviour or goldens: `TextRenderer.Place`/`Orient` (whose `Placement` record is `(XY Origin, XY Direction, bool Mirrored, double Scale, double WidthScale)`), `DrawMLine`, and the placement paths of `DrawSolid(ImageRenderContext, ImageStyle, Solid, Transform?)` and `DrawLeader(ImageRenderContext, ImageStyle, Leader, Transform?)`. + +### 4.2 Block content pairing and recursion + +`DrawBlockContents` keeps `Explode()` and ordinal pairing. The compatibility relation it needs already exists as `UsesOriginalGeometry(Entity? original, Entity clone)`, which today requires an identical runtime type and admits TEXT, MTEXT, LEADER and non-world SOLID: extend that one relation with `Hatch` and `Wipeout` rather than adding a second. Add the `Circle` original with `Ellipse` clone case as an explicitly allowed conversion, and make a type mismatch notify once for that entity (`Warning`, "block entity {i} is a {A} but its exploded clone is a {B}; drawn from the clone") instead of returning a silent false, so a wrong original is never applied and the mismatch is visible. The existing count mismatch warning stays as the package-upgrade tripwire. + +Cycles are caught before `Explode()`, not during drawing. `ScanBlockSubtree` already walks the original block graph and returns a truncation flag when a cycle cut the walk short; `DrawBlockContents` treats a truncated scan as "this block graph is circular", notifies (`Warning`, "block {name} references itself; skipped") and returns without exploding. That is the only point at which a cycle can be stopped, because `Insert.Explode()` deep-clones the block graph and would overflow the stack first. The same pre-check covers an arrow block whose contents lead back to the same arrow block (4.3). + +`NormalizeExplodedClone` is removed: hatches are drawn from the original (4.6), so the clone's normal no longer matters. + +### 4.3 Custom arrowhead blocks + +When `Style.LeaderArrow` is set and `ArrowHeadEnabled`, `DrawLeader` draws the block instead of the default triangle and the NotImplemented notification goes away. + +- Tip = first vertex; outward direction = `tip - vertices[1]` for straight leaders, the tangent of the first Bezier segment for spline leaders (from the existing Catmull-Rom conversion). +- Arrow transform = translate(tip) x rotate(local +X onto the outward direction) x scale(`ArrowSize x ScaleFactor`) x translate(-block base point), composed with the leader's own placement when it sits inside a block. +- If the composed transform is a similarity (4.1), a transient `Insert` is built for the arrow block (insert point, rotation, uniform scale, normal from the transform; `Attributes` cleared) and handed to `DrawBlockContents`, so text, MLINE healing, hatches and nested blocks inside the arrow block get every existing rule. The transient insert is never added to a document. If it is not a similarity (a non-uniformly scaled outer insert), the default triangle is drawn and a Warning explains why. +- Layer 0 and ByBlock inside the arrow block resolve against the leader, exactly as block children resolve against their insert today. +- The leader line is drawn first, the arrow after it, so a filled arrow covers the line end. + +### 4.4 Inverted wipeout clips and clipping state + +`WipeoutWorldBoundary(Wipeout)` becomes `WipeoutWorldRings(Wipeout, Transform? placement)`, returning zero, one or two world rings. It has three consumers today, all of which must be updated together: `DrawWipeout`, `EntityBounds.TryGet` (which bounds a wipeout by the region it actually draws), and `ImagePageRenderer.SelectViewportEntities` through `EntityBounds`. The rings are: + +- image hidden (`ShowImage` off): none; +- clipping off (`ClippingState == false`), whatever `ClipMode` says: the full image frame; +- clipping on, `ClipMode.Outside`: the clip boundary (a rectangular pair expanded to four corners); +- clipping on, `ClipMode.Inside`: the full frame and the boundary. + +`DrawWipeout` fills one ring with `FillPolygon` and two rings with `FillPath` (even-odd), both with the opaque background colour as today; the NotImplemented notification for inverted clips is removed. The insert point is mapped as a point and U/V as vectors through `placement`, so wipeouts inside blocks are drawn from the original rather than from the clone whose U/V `ApplyTransform` contaminated. `EntityBounds.TryGet(Entity, out BoundingBox, out Exception?)` bounds a wipeout by all ring points, so an inverted wipeout frames and culls by its full footprint in both the page framer and the viewport culler. + +### 4.5 MLEDIT cut segments + +Per segment `i -> i+1` and element `j`, the visible runs are computed from the element's parameters: + +- `A = vertex[i].Position + p[0] x Miter`, `D = normalize(Direction)`, `S = A + p[1] x D`, `E` = the same construction at vertex `i+1` (for a closed MLINE the last segment wraps to vertex 0). +- `p[2], p[3], ...` are absolute distances from `S`: visible from `S` to `p[2]`, hidden to `p[3]`, visible to `p[4]`, and so on; an odd count ends hidden after the last value. Values are clamped to `[0, |E - S|]`, non-increasing or non-finite values end the list at that point with a Warning; a first break at or beyond the segment length means no cut. +- Endpoints are built in block space and mapped through `placement`, so mirrored and non-uniform inserts hold. +- Each visible run is one `DrawLine`/`DrawPolyline` with the element's resolved style. No-cut elements produce exactly the primitives they do today (goldens unchanged). + +The absolute interpretation is the literal reading of the DXF reference. ezdxf's comments describe relative dash/gap lengths and neither ezdxf nor LibreDWG implements cuts, so this stays flagged in README as an interpretation to confirm against an AutoCAD-authored multi-cut fixture. The `HasFiniteGeometry` arm for MLINE validates every parameter and `Direction`/`Miter`, not only `p[0]`. + +### 4.6 Tilted hatches inside blocks + +`DrawHatch(ImageRenderContext, ImageStyle, Hatch)` gains a `Transform? placement` parameter and, for block children, is called with the original hatch and the block placement instead of the exploded clone: boundary points and `ExplodePattern()` segments are produced in the hatch's own OCS, mapped with `InsertPlacement.MapOcsPoint(placement, hatch.Normal, hatch.Elevation, p)` and then projected. Pattern expansion happens before placement, so non-uniform scale and mirroring show up in the transformed endpoints instead of being squeezed back into one angle and scale. Top-level hatches take the same path with a null placement, so their output is unchanged. + +### 4.7 Multi-line attributes + +`EntityRenderDispatcher` recognises an `AttributeBase` whose `AttributeType` is `MultiLine` or `ConstantMultiLine` before the generic `TextEntity` arm and calls `TextRenderer.DrawAttribute(context, style, attribute, placement)`, which lays out `attribute.MText` (value, rectangle width, height, attachment point, direction, rotation, line spacing, style) and emits it with the attribute's own layer, colour, transparency, visibility, handle and parent metadata: the observable entity stays ATTRIB (SVG `data-type`, `data-handle`, layer group). Placement is null for a top-level insert's attributes (their coordinates already include the insert), the outer placement only for a nested insert's attributes, and the full block placement for a constant multi-line ATTDEF drawn from block space. When `MText` is null (pre-2018 DWG or malformed file) the single-line path draws `Value` and a Warning says the multi-line layout was unavailable. Single-line attributes are byte-identical to today. + +## 5. Notifications + +All messages keep the `[{SubclassMarker}] Handle {handle:X}: ...` shape. Removed: leader custom-arrow NotImplemented, wipeout inverted-clip NotImplemented, MLINE stroke-cut Warning, attribute multi-line Warning, the "hatch on a tilted plane" limitation. Added: pairing mismatch Warning, block recursion Warning, non-similarity arrow Warning, MLINE malformed-parameter Warning, MLINE fill-cut NotImplemented, attribute missing-MText Warning. + +## 6. Tests and goldens + +- Unit tests per item in `EntityRenderDispatcherTests`/`TextRendererTests` following the existing `CreateContext` conventions (100x100 surface, CAD `(x, y)` at `SurfacePoint(x, 100 - y)`), covering the cases listed in the Codex note for each item, plus: pairing mismatch drawn from the clone with one warning, self-referencing block terminates with one warning, arrow inside a non-uniform insert falls back with a warning. +- New synthetic sample `SyntheticSamples.FidelityBlock()` with a custom-arrow leader, an inverted wipeout, a cut MLINE, a tilted hatch inside a block and a multi-line attribute, exercised by `EntityGoldenTests` as `fidelity.model.01.{png,svg}` (created once, then byte-identical). +- Existing baselines stay byte-identical except where a task names the golden and the cause. +- The comparison run against the private drawings (never named in the repository) must show no remaining NotImplemented notifications for these five items and parity within the current 99.8-100% band. + +## 7. Documentation + +README "Known limitations" drops the five items and gains: MLEDIT interpretation flagged as unconfirmed; wipeouts on transparent backgrounds; fill cuts; non-similarity arrow fallback. Spec 4.6 of the base design gets a pointer to this document. + +## 8. As implemented (2026-09-04) + +Plan 10 shipped this design with four divergences, recorded here because the plan names this document as binding and none of them is visible from the sections above. + +- **4.1 — the placement helper's real API.** There is no `Compose(Transform? outer, Insert inner)`: the arrow block's own map is composed with the outer placement inline, in `DrawArrowBlock`'s local `Arrow(...)` function, and the composition is then *measured* (where the base point and the two unit axes land) rather than built up symbolically. `IsSimilarity(Transform, out double scale)` shipped as `InsertPlacement.TryGetPlanarSimilarity(Transform? placement, out double scale, out double rotation, out bool mirrored)`, which also reports the rotation and the reflection the caller needs, and which judges the XY projection rather than the 3-D map. +- **4.1 — `MapOcsPoint`'s real signature.** It is `MapOcsPoint(Transform? placement, OcsTransform? toWorld, double elevation, XYZ ocsPoint)`, not `(Transform?, XYZ normal, double elevation, XY point)`: the caller builds the OCS frame once per entity and passes it in (null on the world plane, in which case the point is used as stored, Z included), and the OCS point is an `XYZ` whose Z is replaced by `elevation` when a frame is applied. +- **4.2 — the `Circle`-to-`Ellipse` pairing conversion and the per-entity mismatch Warning were deliberately not built.** `UsesOriginalGeometry` still requires an identical runtime type, and a type mismatch still returns a silent false, so the block-level count-mismatch Warning remains the only signal that a pairing failed. The omission was ruled deliberate when the plan was written, with the cost accepted as a possible follow-up task; the consequence is that a single mispaired entity inside an otherwise correctly-counted block is drawn from its clone with no notification at all. +- **4.3 — the transient arrow insert carries no normal.** `ImageRenderContext.ToSurfacePoint(XYZ)` drops Z, so only the XY projection of the composed map is observable and a 2-D rotation plus a uniform scale reproduces it exactly; setting a `Normal` from the transform would change nothing that can be seen. This is intentional, not an omission. + +### Corrections made to the plan's own test snippets during execution + +The plan file still carries the snippets as first written. Where a task's tests disagree with it, the shipped tests are right and the reasons are these. + +- **Task 1.** The orthogonality test's premise was wrong: `Insert.GetTransform` scales and then rotates, which preserves right angles, so no placement expressible as an `Insert` reaches the orthogonality branch of `TryGetPlanarSimilarity`. The test was renamed and a real shear case, built from a hand-made `Matrix4`, was added, with premise assertions so it cannot silently degrade into re-testing the axis-length branch. +- **Task 3.** The tilted-hatch pattern fix is mirroring and rotation, not a uniform scale; `EstimateScanLines` is harmless because the estimate and the expansion now both take the original entity, so both measure the same block-local box. +- **Task 5.** The brief's arrow X values 8/8/4 were unsatisfiable against its own other test and became 12/12/16: the arrow body lies *between* the tip and the leader body, which is what the default triangle already does (`baseCenter = tip - direction * size`). The outward direction also needs no special spline case: with clamped Catmull-Rom ends the first Bezier tangent is parallel to `vertices[1] - vertices[0]`, so `tip - vertices[1]` covers both path types and 4.3's distinction above is vacuous. +- **Task 6.** The recorder property is `FillPaths`, not `Paths`; the wipeout footprint is 60/65, not 50/51; and the inverted-clip test must set explicit clip vertices, or `WipeoutWorldRings` short-circuits to a single ring and the test cannot fail. The inverted-clip fixture was also made non-degenerate: with the original boundary the two rings are the same point set, so a wrong result would have passed. +- **Task 7.** The MLINE cut measurement point moved from the placed segment to the pre-placement one: a block definition is shared by inserts at arbitrary scales, so a stored cut distance cannot be in world units. The `value >= length` clamp uses the local length for the same reason. `HasFiniteGeometry` was widened to skip the whole multiline on a non-finite cut parameter, which makes `VisibleRuns`' own non-finite guard reachable only by direct callers. +- **Task 8.** The hatch assertion in the fidelity golden is in page coordinates, not world: the page frame translates by `-minX = -10`, so the span is [50,70].