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 @@
+
\ 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 @@
+
\ 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 @@
+
\ 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 @@
+
\ 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 @@
+
\ 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 @@
+
\ 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", "