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(); }