From bbdd51fabcae7ffe5b4bd8634c3fe5f6e2454cd9 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Sun, 1 Nov 2026 12:07:06 -0500 Subject: [PATCH] feat(cad-converter): show the source file's raw geometry as Original Show Original drew nothing until Simplify ran, and then showed entities that had already been through import cleanup. The importers now capture the entities before any optimization and the overlay draws those from load, while Simplify keeps starting from the cleaned entities. Co-Authored-By: Claude Opus 5.5 --- OpenNest.IO/CadImportResult.cs | 7 + OpenNest.IO/CadImporter.cs | 1 + OpenNest.IO/Dxf.cs | 7 +- OpenNest.IO/DxfImportResult.cs | 6 + OpenNest.IO/SvgReader.cs | 6 + .../IO/CadImporterRawEntitiesTests.cs | 126 ++++++++++++++++ .../Forms/CadConverterOriginalOverlayTests.cs | 140 ++++++++++++++++++ OpenNest/Controls/EntityView.cs | 10 +- OpenNest/Controls/FileListControl.cs | 9 ++ OpenNest/Forms/CadConverterForm.cs | 6 +- 10 files changed, 307 insertions(+), 11 deletions(-) create mode 100644 OpenNest.Tests/IO/CadImporterRawEntitiesTests.cs create mode 100644 OpenNest.WinForms.Tests/Forms/CadConverterOriginalOverlayTests.cs diff --git a/OpenNest.IO/CadImportResult.cs b/OpenNest.IO/CadImportResult.cs index 9da37b66..b4fd651c 100644 --- a/OpenNest.IO/CadImportResult.cs +++ b/OpenNest.IO/CadImportResult.cs @@ -18,6 +18,13 @@ namespace OpenNest.IO /// public List Entities { get; set; } = new List(); + /// + /// The source file's geometry on every layer, before any import cleanup, for + /// comparing against . Display only: these share no instances + /// with and their IDs key nothing. + /// + public List RawEntities { get; set; } = new List(); + /// /// Bends detected during import. Mutable — callers may add, remove, /// or replace entries before building the drawing. diff --git a/OpenNest.IO/CadImporter.cs b/OpenNest.IO/CadImporter.cs index 8f5a4e96..74f1733d 100644 --- a/OpenNest.IO/CadImporter.cs +++ b/OpenNest.IO/CadImporter.cs @@ -92,6 +92,7 @@ namespace OpenNest.IO return new CadImportResult { Entities = dxf.Entities, + RawEntities = dxf.RawEntities, Bends = bends, BendRepairReports = repairReports, Bounds = dxf.Entities.GetBoundingBox(), diff --git a/OpenNest.IO/Dxf.cs b/OpenNest.IO/Dxf.cs index 0941ba76..5658d1d7 100644 --- a/OpenNest.IO/Dxf.cs +++ b/OpenNest.IO/Dxf.cs @@ -39,7 +39,12 @@ namespace OpenNest.IO ); } - return new DxfImportResult { Entities = entities, Document = doc }; + return new DxfImportResult + { + Entities = entities, + RawEntities = ConvertEntities(doc, _ => false, optimize: false), + Document = doc, + }; } public static List GetGeometry(string path) => diff --git a/OpenNest.IO/DxfImportResult.cs b/OpenNest.IO/DxfImportResult.cs index 7d1efe96..43fb3f45 100644 --- a/OpenNest.IO/DxfImportResult.cs +++ b/OpenNest.IO/DxfImportResult.cs @@ -7,6 +7,12 @@ namespace OpenNest.IO public class DxfImportResult { public List Entities { get; set; } = new(); + + /// + /// The file's geometry on every layer, before any cleanup. These are separate + /// instances from . + /// + public List RawEntities { get; set; } = new(); public CadDocument Document { get; set; } public List Warnings { get; set; } = new(); } diff --git a/OpenNest.IO/SvgReader.cs b/OpenNest.IO/SvgReader.cs index 92fe4bbf..a4b5ea77 100644 --- a/OpenNest.IO/SvgReader.cs +++ b/OpenNest.IO/SvgReader.cs @@ -221,6 +221,11 @@ namespace OpenNest.IO { WalkChildren(root, transform, Layer.Default); + var raw = new List(); + raw.AddRange(circles.CloneAll()); + raw.AddRange(lines.CloneAll()); + raw.AddRange(arcs.CloneAll()); + GeometryOptimizer.Optimize(lines); GeometryOptimizer.Optimize(arcs); GeometryOptimizer.Deduplicate(circles); @@ -234,6 +239,7 @@ namespace OpenNest.IO return new DxfImportResult { Entities = entities, + RawEntities = raw, Warnings = skipped .OrderBy(s => s.Key, StringComparer.Ordinal) .Select(s => $"Skipped {s.Value} <{s.Key}> element(s); convert them to paths to import them.") diff --git a/OpenNest.Tests/IO/CadImporterRawEntitiesTests.cs b/OpenNest.Tests/IO/CadImporterRawEntitiesTests.cs new file mode 100644 index 00000000..3e31a3f1 --- /dev/null +++ b/OpenNest.Tests/IO/CadImporterRawEntitiesTests.cs @@ -0,0 +1,126 @@ +using System.IO; +using System.Linq; +using ACadSharp; +using ACadSharp.IO; +using CSMath; +using OpenNest.Geometry; +using OpenNest.IO; +using OpenNest.Math; +using CadArc = ACadSharp.Entities.Arc; +using CadCircle = ACadSharp.Entities.Circle; +using CadLayer = ACadSharp.Tables.Layer; +using CadLine = ACadSharp.Entities.Line; + +namespace OpenNest.Tests.IO; + +/// +/// A 4 x 6 plate whose bottom edge is two collinear lines, with one hole drawn the way CAM +/// exports do (two overlapping arcs and a seam chord) and one ETCH mark. +/// +public class CadImporterRawEntitiesTests : IDisposable +{ + private readonly string directory = Directory.CreateTempSubdirectory("opennest-raw-").FullName; + + public void Dispose() => Directory.Delete(directory, true); + + [Fact] + public void Dxf_RawEntitiesHoldTheFile_WhileEntitiesHoldTheCleanedGeometry() + { + var result = CadImporter.Import(WritePlate()); + + Assert.Empty(result.RawEntities.OfType()); + Assert.Equal(2, result.RawEntities.OfType().Count()); + Assert.Equal(7, result.RawEntities.OfType().Count()); + Assert.Contains(result.RawEntities, e => e.Layer.Name == "ETCH"); + + Assert.Single(result.Entities.OfType()); + Assert.Empty(result.Entities.OfType()); + Assert.Equal(4, result.Entities.OfType().Count()); + } + + [Fact] + public void RawEntities_ShareNoInstancesWithTheWorkingEntities() + { + var result = CadImporter.Import(WritePlate()); + + Assert.Empty(result.RawEntities.Intersect(result.Entities)); + Assert.Empty(result.RawEntities.Select(e => e.Layer).Intersect(result.Entities.Select(e => e.Layer))); + } + + [Fact] + public void RawEntities_DoNotDisturbFileIds() + { + var path = WritePlate(); + + var first = CadImporter.Import(path); + var second = CadImporter.Import(path); + + Assert.Equal(first.Entities.Select(e => e.Id), second.Entities.Select(e => e.Id)); + Assert.Empty(first.RawEntities.Select(e => e.Id).Intersect(first.Entities.Select(e => e.Id))); + } + + [Fact] + public void Svg_RawEntitiesKeepWhatTheOptimizerMerges() + { + var path = Path.Combine(directory, "plate.svg"); + File.WriteAllText( + path, + "" + + "" + + "" + ); + + var result = CadImporter.Import(path); + + Assert.Equal(2, result.RawEntities.OfType().Count()); + Assert.Equal(2, result.RawEntities.OfType().Count()); + Assert.Single(result.Entities.OfType()); + Assert.Single(result.Entities.OfType()); + Assert.Empty(result.RawEntities.Intersect(result.Entities)); + } + + private string WritePlate() + { + var layer = new CadLayer("0"); + var etch = new CadLayer("ETCH"); + var document = new CadDocument(); + document.Header.InsUnits = ACadSharp.Types.Units.UnitsType.Inches; + + void Add(double x1, double y1, double x2, double y2, CadLayer on) => + document.Entities.Add(new CadLine(new XYZ(x1, y1, 0), new XYZ(x2, y2, 0)) { Layer = on }); + + Add(0, 0, 2, 0, layer); + Add(2, 0, 4, 0, layer); + Add(4, 0, 4, 6, layer); + Add(4, 6, 0, 6, layer); + Add(0, 6, 0, 0, layer); + Add(1, 1, 1, 2, etch); + + const double radius = 0.3125; + document.Entities.Add(HoleArc(210, 30, radius, layer)); + document.Entities.Add(HoleArc(30, 215.5, radius, layer)); + var overshoot = OnHole(215.5, radius); + var seam = OnHole(210, radius); + Add(overshoot.X, overshoot.Y, seam.X, seam.Y, layer); + + var path = Path.Combine(directory, "plate.dxf"); + DxfWriter.Write(path, document, false); + return path; + } + + private static CadArc HoleArc(double start, double end, double radius, CadLayer layer) => + new() + { + Center = new XYZ(2, 3, 0), + Radius = radius, + StartAngle = Angle.ToRadians(start), + EndAngle = Angle.ToRadians(end), + Layer = layer, + }; + + private static Vector OnHole(double degrees, double radius) + { + var angle = Angle.ToRadians(degrees); + return new Vector(2 + radius * System.Math.Cos(angle), 3 + radius * System.Math.Sin(angle)); + } +} diff --git a/OpenNest.WinForms.Tests/Forms/CadConverterOriginalOverlayTests.cs b/OpenNest.WinForms.Tests/Forms/CadConverterOriginalOverlayTests.cs new file mode 100644 index 00000000..1ba06c9d --- /dev/null +++ b/OpenNest.WinForms.Tests/Forms/CadConverterOriginalOverlayTests.cs @@ -0,0 +1,140 @@ +using System.IO; +using System.Reflection; +using System.Windows.Forms; +using ACadSharp; +using ACadSharp.IO; +using CSMath; +using OpenNest.Bending; +using OpenNest.Controls; +using OpenNest.Forms; +using OpenNest.Geometry; +using OpenNest.IO; +using OpenNest.Math; +using static OpenNest.WinForms.Tests.FormTesting; +using CadArc = ACadSharp.Entities.Arc; +using CadLayer = ACadSharp.Tables.Layer; +using CadLine = ACadSharp.Entities.Line; + +namespace OpenNest.WinForms.Tests.Forms; + +/// +/// The Original overlay shows the source file, while Simplify keeps working from the +/// cleaned geometry. +/// +public class CadConverterOriginalOverlayTests +{ + [Fact] + public void Overlay_ShowsTheFileFromLoad_AndSimplifyStartsFromTheCleanedGeometry() => ArchUnitsTests.RunSta(() => + { + var directory = Directory.CreateTempSubdirectory("opennest-overlay-").FullName; + try + { + using var form = new CadConverterForm(); + Show(form); + form.AddFile(WriteHole(directory)); + var view = Field(form, "entityView1"); + var item = Field(form, "fileList").SelectedItem!; + + Assert.Null(view.OriginalEntities); + Field(form, "chkShowOriginal").Checked = true; + Assert.Same(item.RawEntities, view.OriginalEntities); + Assert.Equal(2, view.OriginalEntities!.OfType().Count()); + Assert.Empty(view.OriginalEntities.OfType()); + Assert.Single(item.Entities.OfType()); + Assert.Empty(item.Entities.OfType()); + + // Simplify must not bring the joined hole's arcs back. + Assert.Null(item.OriginalEntities); + Invoke(form, "OnSimplifyClick", null, EventArgs.Empty); + Assert.Single(item.OriginalEntities!.OfType()); + Assert.Empty(item.OriginalEntities!.OfType()); + Assert.Same(item.RawEntities, view.OriginalEntities); + form.Close(); + } + finally + { + Directory.Delete(directory, true); + } + }); + + [Fact] + public void Overlay_FallsBackToThePreSimplifyEntities_WhenNoFileWasRead() => ArchUnitsTests.RunSta(() => + { + using var form = new CadConverterForm(); + Show(form); + form.LoadDrawings([Reopened()]); + var view = Field(form, "entityView1"); + var item = Field(form, "fileList").SelectedItem!; + + Field(form, "chkShowOriginal").Checked = true; + Assert.Null(view.OriginalEntities); + + Invoke(form, "OnSimplifyClick", null, EventArgs.Empty); + Invoke(form, "OnShowOriginalChanged", null, EventArgs.Empty); + + Assert.Null(item.RawEntities); + Assert.Same(item.OriginalEntities, view.OriginalEntities); + form.Close(); + }); + + private static string WriteHole(string directory) + { + var layer = new CadLayer("0"); + var document = new CadDocument(); + document.Header.InsUnits = ACadSharp.Types.Units.UnitsType.Inches; + + void Add(double x1, double y1, double x2, double y2) => + document.Entities.Add(new CadLine(new XYZ(x1, y1, 0), new XYZ(x2, y2, 0)) { Layer = layer }); + + Add(0, 0, 4, 0); + Add(4, 0, 4, 6); + Add(4, 6, 0, 6); + Add(0, 6, 0, 0); + + const double radius = 0.3125; + document.Entities.Add(HoleArc(210, 30, radius, layer)); + document.Entities.Add(HoleArc(30, 215.5, radius, layer)); + var overshoot = OnHole(215.5, radius); + var seam = OnHole(210, radius); + Add(overshoot.X, overshoot.Y, seam.X, seam.Y); + + var path = Path.Combine(directory, "hole.dxf"); + DxfWriter.Write(path, document, false); + return path; + } + + private static CadArc HoleArc(double start, double end, double radius, CadLayer layer) => + new() + { + Center = new XYZ(2, 3, 0), + Radius = radius, + StartAngle = Angle.ToRadians(start), + EndAngle = Angle.ToRadians(end), + Layer = layer, + }; + + private static Vector OnHole(double degrees, double radius) + { + var angle = Angle.ToRadians(degrees); + return new Vector(2 + radius * System.Math.Cos(angle), 3 + radius * System.Math.Sin(angle)); + } + + private static Drawing Reopened() + { + var entities = new List + { + new Line(0, 0, 10, 0), new Line(10, 0, 10, 6), new Line(10, 6, 0, 6), new Line(0, 6, 0, 0), + }; + var layer = new Layer("0") { IsVisible = true }; + foreach (var e in entities) + e.Layer = layer; + return CadImporter.BuildDrawing(new CadImportResult { Entities = entities, Name = "reopened" }, entities, + new List(), 1, null, null); + } + + private static T Field(object owner, string name) => + (T)typeof(CadConverterForm).GetField(name, BindingFlags.Instance | BindingFlags.NonPublic)!.GetValue(owner)!; + + private static void Invoke(CadConverterForm form, string name, params object?[] args) => + typeof(CadConverterForm).GetMethod(name, BindingFlags.Instance | BindingFlags.NonPublic)!.Invoke(form, args); +} diff --git a/OpenNest/Controls/EntityView.cs b/OpenNest/Controls/EntityView.cs index aa3c7abc..b1bd6a48 100644 --- a/OpenNest/Controls/EntityView.cs +++ b/OpenNest/Controls/EntityView.cs @@ -107,15 +107,12 @@ namespace OpenNest.Controls e.Graphics.TranslateTransform(origin.X, origin.Y); - // Draw original geometry overlay (faded, behind current) + // Draw original geometry overlay (faded, behind current); filters don't apply to it if (OriginalEntities != null) { using var origPen = new Pen(Color.FromArgb(50, 255, 140, 40)); foreach (var entity in OriginalEntities) - { - if (!IsEtchLayer(entity.Layer)) - DrawEntity(e.Graphics, entity, origPen); - } + DrawEntityShape(e.Graphics, entity, origPen); } using (var excludedPen = new Pen(Color.FromArgb(90, 150, 150, 150)) { DashPattern = new float[] { 2, 4 } }) @@ -302,9 +299,6 @@ namespace OpenNest.Controls private static readonly Color EtchRoleColor = Color.Magenta; private static readonly Color DisplayRoleColor = Color.FromArgb(120, 200, 220); - private static bool IsEtchLayer(Layer layer) => - string.Equals(layer?.Name, "ETCH", System.StringComparison.OrdinalIgnoreCase); - private void DrawBendLines(Graphics g) { if (Bends == null || Bends.Count == 0) diff --git a/OpenNest/Controls/FileListControl.cs b/OpenNest/Controls/FileListControl.cs index a32e2bea..5d16a1d2 100644 --- a/OpenNest/Controls/FileListControl.cs +++ b/OpenNest/Controls/FileListControl.cs @@ -17,7 +17,16 @@ namespace OpenNest.Controls public int Quantity { get; set; } = 1; public string Path { get; set; } public List Entities { get; set; } = new(); + + /// The cleaned entities before Simplify replaced them; Simplify always restarts from these. public List OriginalEntities { get; set; } + + /// The source file's geometry before import cleanup, for the Original overlay; null when no file was read. + public List RawEntities { get; set; } + + /// What the Original overlay shows: a drawing reopened from a nest has only its pre-Simplify entities. + public List OverlayEntities => RawEntities ?? OriginalEntities; + public List Bends { get; set; } = new(); public List Breakoffs { get; set; } = new(); public List TabbedCutouts { get; set; } = new(); diff --git a/OpenNest/Forms/CadConverterForm.cs b/OpenNest/Forms/CadConverterForm.cs index 46e5225c..0e804bc8 100644 --- a/OpenNest/Forms/CadConverterForm.cs +++ b/OpenNest/Forms/CadConverterForm.cs @@ -124,6 +124,7 @@ namespace OpenNest.Forms { Name = result.Name, Entities = result.Entities, + RawEntities = result.RawEntities, Path = result.SourcePath, Quantity = 1, Customer = string.Empty, @@ -234,7 +235,7 @@ namespace OpenNest.Forms entityView1.SelectedBreakoffIndex = -1; entityView1.SelectedTabbedCutoutIndex = -1; entityView1.SelectedDimensionIndex = -1; - entityView1.OriginalEntities = chkShowOriginal.Checked ? item.OriginalEntities : null; + entityView1.OriginalEntities = chkShowOriginal.Checked ? item.OverlayEntities : null; entityView1.Entities.Clear(); entityView1.Entities.AddRange(item.Entities); entityView1.Bends = item.Bends ?? new List(); @@ -521,6 +522,7 @@ namespace OpenNest.Forms { Name = Path.GetFileNameWithoutExtension(splitPath), Entities = result.Entities, + RawEntities = result.RawEntities, Path = splitPath, Quantity = item.Quantity, Customer = item.Customer, @@ -1331,7 +1333,7 @@ namespace OpenNest.Forms private void OnShowOriginalChanged(object sender, EventArgs e) { var item = CurrentItem; - entityView1.OriginalEntities = chkShowOriginal.Checked ? item?.OriginalEntities : null; + entityView1.OriginalEntities = chkShowOriginal.Checked ? item?.OverlayEntities : null; entityView1.Invalidate(); }