From 589d34145538cbd8adcfb6716fac4c88f74ce0f7 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Sun, 20 Sep 2026 15:22:46 -0400 Subject: [PATCH] feat(io): conservative opt-in bend repair with tests and console CLI Add OpenNest.IO/Bending/BendRepair: opt-in repair of unambiguous paired ETCH/SCRIBE bend ticks, bounded to <=3.175 mm endpoint movement with explicit source units. Cut geometry is never modified. - CadImportOptions.BendRepair configures it; CadImportResult exposes per-bend BendRepairReports; CadImporter/Dxf wire it into import. - Console: --repair-bends-mm --cad-units inches|mm prints per-bend reports for newly imported DXFs. - New OpenNest.IO.Tests project (net8.0, synthetic DXFs, 30 tests) covering bend detection and repair, added to the solution. - Update README.md and CLAUDE.md for the new pipeline and build/test instructions. --- CLAUDE.md | 9 +- OpenNest.Benchmark/OpenNest.Benchmark.csproj | 2 +- OpenNest.Console/OpenNest.Console.csproj | 2 +- OpenNest.Console/Program.cs | 38 ++++- OpenNest.IO.Tests/BendRepairImportTests.cs | 107 +++++++++++++ OpenNest.IO.Tests/BendRepairTests.cs | 154 +++++++++++++++++++ OpenNest.IO.Tests/OpenNest.IO.Tests.csproj | 16 ++ OpenNest.IO/Bending/BendRepair.cs | 154 +++++++++++++++++++ OpenNest.IO/CadImportOptions.cs | 3 + OpenNest.IO/CadImportResult.cs | 2 + OpenNest.IO/CadImporter.cs | 31 +++- OpenNest.IO/Dxf.cs | 33 +++- OpenNest.sln | 15 ++ README.md | 45 +++++- 14 files changed, 589 insertions(+), 22 deletions(-) create mode 100644 OpenNest.IO.Tests/BendRepairImportTests.cs create mode 100644 OpenNest.IO.Tests/BendRepairTests.cs create mode 100644 OpenNest.IO.Tests/OpenNest.IO.Tests.csproj create mode 100644 OpenNest.IO/Bending/BendRepair.cs diff --git a/CLAUDE.md b/CLAUDE.md index f2c29e4..82f6974 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -8,7 +8,7 @@ OpenNest is a Windows desktop application for CNC nesting — arranging 2D parts ## Build -This is a .NET 8 solution using SDK-style `.csproj` files targeting `net8.0-windows`. Build with: +This is a .NET 8 solution using SDK-style `.csproj` files. The desktop app and Windows-dependent projects target `net8.0-windows`; the core libraries and `OpenNest.Console` target `net8.0`. Build the full solution on Windows with: ```bash dotnet build OpenNest.sln @@ -16,6 +16,8 @@ dotnet build OpenNest.sln Cross-platform whole-job engine tests (net8.0, runs on Linux/macOS/Windows without the desktop project or DXF fixtures): `dotnet test OpenNest.Engine.Tests/OpenNest.Engine.Tests.csproj`. The existing `OpenNest.Tests` suite targets `net8.0-windows` and requires a Windows runner; cross-compiling on Linux is not Windows runtime verification. +Cross-platform CAD import tests: `dotnet test OpenNest.IO.Tests/OpenNest.IO.Tests.csproj`. These synthetic-DXF and bend-repair tests target `net8.0`, require no external fixtures, and are included in the solution. Build the headless console independently with `dotnet build OpenNest.Console/OpenNest.Console.csproj`. + NuGet dependencies: `ACadSharp` 3.1.32 (DXF/DWG import/export, in OpenNest.IO), `System.Drawing.Common` 8.0.10, `ModelContextProtocol` + `Microsoft.Extensions.Hosting` (in OpenNest.Mcp), `Microsoft.ML.OnnxRuntime` (in OpenNest.Engine for ML angle prediction), `Microsoft.EntityFrameworkCore.Sqlite` (in OpenNest.Training). ## Architecture @@ -63,9 +65,10 @@ File I/O and format conversion. Uses ACadSharp for DXF/DWG support. - `Extensions` — conversion helpers between ACadSharp and OpenNest geometry types. - `CadImporter` — shared "DXF → Drawing" service used by the UI, console, MCP, API, and training projects. Two-stage API: `Import(path, options)` loads raw entities, runs bend detection, and returns a mutable `CadImportResult`; `BuildDrawing(result, visible, bends, quantity, customer, editedProgram)` produces a fully-populated `Drawing` with `Source.Offset`, `SourceEntities`, `SuppressedEntityIds`, and bends. `ImportDrawing(path, options)` composes both stages for headless callers. - `CadImportOptions`, `CadImportResult` — inputs and intermediate state for `CadImporter`. +- `Bending/BendRepair` — conservative opt-in repair configured by `CadImportOptions.BendRepair`. Requires explicit inches/mm source units and an endpoint movement limit above 0.001 and at most 3.175 physical mm. Only unambiguous paired ETCH/SCRIBE ticks may move along the existing bend axis; cut geometry and unrelated marks must remain unchanged. Opt-in imports preserve source marks without blanket etch regeneration and expose per-bend outcomes in `CadImportResult.BendRepairReports`. ### OpenNest.Console (console app, depends on Core + Engine + IO) -Command-line interface for batch nesting. Supports DXF import, plate configuration, linear fill, and NFP-based auto-nesting (`--autonest`). +Command-line interface for batch nesting (`net8.0`). Supports DXF import, plate configuration, linear fill, and NFP-based auto-nesting (`--autonest`). `--repair-bends-mm --cad-units inches|mm` opts newly imported DXFs into conservative bend repair and prints per-bend reports; it does not rescale coordinates or repair saved nests. ### OpenNest.Gpu (class library, depends on Core + Engine) GPU-accelerated pair evaluation for best-fit nesting. `GpuPairEvaluator` implements `IPairEvaluator`, `GpuSlideComputer` implements `ISlideComputer`, and `PartBitmap` handles rasterization. `GpuEvaluatorFactory` provides factory methods. @@ -133,4 +136,4 @@ Always keep `README.md` and `CLAUDE.md` up to date when making changes that affe - `FillScore` uses lexicographic comparison (count > utilization > compactness) to rank fill results consistently across all fill strategies. - **Cut-off materialization lifecycle**: `CutOff` objects live on `Plate.CutOffs`. Each generates a `Drawing` (with `IsCutOff = true`) whose `Program` contains trimmed line segments. `Plate.RegenerateCutOffs(settings)` removes old cut-off Parts, recomputes programs, and re-adds them to `Plate.Parts`. Regeneration triggers: cut-off add/remove/move, part drag complete, fill complete, plate transform. Cut-off Parts are excluded from quantity tracking, utilization, overlap detection, and nest file serialization (programs are regenerated from definitions on load). - **User-defined G-code variables**: Programs can contain named variable definitions (`name = expression [inline] [global]`) referenced in coordinates with `$name`. Variables resolve to doubles at parse time for geometry/nesting. `VariableRefs` on `Motion`/`Feedrate` track the symbolic link so post processors can emit machine variable references. Cincinnati post maps non-inline variables to numbered machine variables (`#200+`) with descriptive comments. Global variables share a number across programs; local variables get per-drawing numbers. `ProgramReader` uses a two-pass parse (collect definitions, then parse G-code with substitution). `NestWriter` serializes definitions and `$references` back to text for round-trip fidelity. -- **CAD import pipeline**: All "DXF → Drawing" conversion goes through `OpenNest.IO.CadImporter`. The UI form uses `Import` on file load (storing the mutable result in a `FileListItem`) and `BuildDrawing` on save (passing the user's current visible entities and bends). Console, MCP, API, and Training projects use `ImportDrawing` for headless conversion. This guarantees all callers produce drawings with the same shape: pierce-point `Source.Offset`, stable `SourceEntities` with GUIDs, `SuppressedEntityIds`, detected bends, and metadata. +- **CAD import pipeline**: All "DXF → Drawing" conversion goes through `OpenNest.IO.CadImporter`. The UI form uses `Import` on file load (storing the mutable result in a `FileListItem`) and `BuildDrawing` on save (passing the user's current visible entities and bends). MCP, API, and Training projects use `ImportDrawing` for headless conversion. The console uses `Import` followed by `BuildDrawing` so it can report bend-repair outcomes. This guarantees all callers produce drawings with the same shape: pierce-point `Source.Offset`, stable `SourceEntities` with GUIDs, `SuppressedEntityIds`, detected bends, and metadata. diff --git a/OpenNest.Benchmark/OpenNest.Benchmark.csproj b/OpenNest.Benchmark/OpenNest.Benchmark.csproj index ccbddae..c98f945 100644 --- a/OpenNest.Benchmark/OpenNest.Benchmark.csproj +++ b/OpenNest.Benchmark/OpenNest.Benchmark.csproj @@ -1,7 +1,7 @@ Exe - net8.0-windows + net8.0 OpenNest.Benchmark OpenNest.Benchmark disable diff --git a/OpenNest.Console/OpenNest.Console.csproj b/OpenNest.Console/OpenNest.Console.csproj index dfb174b..b4c996a 100644 --- a/OpenNest.Console/OpenNest.Console.csproj +++ b/OpenNest.Console/OpenNest.Console.csproj @@ -1,7 +1,7 @@ Exe - net8.0-windows + net8.0 OpenNest.Console OpenNest.Console $(DefineConstants);DEBUG;TRACE diff --git a/OpenNest.Console/Program.cs b/OpenNest.Console/Program.cs index d3bc9e5..33917c3 100644 --- a/OpenNest.Console/Program.cs +++ b/OpenNest.Console/Program.cs @@ -1,6 +1,8 @@ using OpenNest; using OpenNest.Geometry; using OpenNest.IO; +using OpenNest.IO.Bending; +using System.Globalization; using System; using System.Collections.Generic; using System.Diagnostics; @@ -20,6 +22,14 @@ static class NestConsole if (options == null) return 0; // --help was requested + if (options.RepairBendsMillimeters.HasValue && + (options.CadUnits == BendRepairUnits.Unspecified || !double.IsFinite(options.RepairBendsMillimeters.Value) + || options.RepairBendsMillimeters <= 0.001 || options.RepairBendsMillimeters > 3.175)) + { + Console.Error.WriteLine("Error: --repair-bends-mm requires a limit > 0.001 and <= 3.175 mm and --cad-units inches|mm."); + return 1; + } + if (options.ListPosts) { ListPostProcessors(options); @@ -82,6 +92,12 @@ static class NestConsole { switch (args[i]) { + case "--repair-bends-mm": + o.RepairBendsMillimeters = i + 1 < args.Length && double.TryParse(args[++i], NumberStyles.Float, CultureInfo.InvariantCulture, out var limit) ? limit : double.NaN; + break; + case "--cad-units" when i + 1 < args.Length: + o.CadUnits = args[++i] switch { "inches" => BendRepairUnits.Inches, "mm" => BendRepairUnits.Millimeters, _ => BendRepairUnits.Unspecified }; + break; case "--drawing" when i + 1 < args.Length: o.DrawingName = args[++i]; break; @@ -173,7 +189,7 @@ static class NestConsole foreach (var dxf in dxfFiles) { - var drawing = ImportDxf(dxf); + var drawing = ImportDxf(dxf, options); if (drawing == null) return null; @@ -204,7 +220,7 @@ static class NestConsole foreach (var dxf in dxfFiles) { - var drawing = ImportDxf(dxf); + var drawing = ImportDxf(dxf, options); if (drawing == null) return null; @@ -216,11 +232,21 @@ static class NestConsole return newNest; } - static Drawing ImportDxf(string path) + static Drawing ImportDxf(string path, Options options) { try { - return CadImporter.ImportDrawing(path); + var result = CadImporter.Import(path, new CadImportOptions + { + BendRepair = options.RepairBendsMillimeters.HasValue ? new BendRepairOptions + { + DrawingUnits = options.CadUnits, + MaxEndpointMovementMillimeters = options.RepairBendsMillimeters.Value + } : null + }); + foreach (var report in result.BendRepairReports) + Console.WriteLine($"Bend repair {Path.GetFileName(path)} #{report.BendIndex + 1}: {report.Status}: {report.Reason} ({report.OriginalStart} -> {report.Start}; {report.OriginalEnd} -> {report.End})"); + return CadImporter.BuildDrawing(result, result.Entities, result.Bends, 1, null, null); } catch (System.Exception ex) { @@ -470,6 +496,8 @@ static class NestConsole Console.Error.WriteLine(" Load nest and add imported DXF drawings"); Console.Error.WriteLine(); Console.Error.WriteLine("Options:"); + Console.Error.WriteLine(" --repair-bends-mm Opt-in endpoint/tick repair, limit >0.001 to 3.175 physical mm"); + Console.Error.WriteLine(" --cad-units inches|mm Explicit source coordinate units required for bend repair"); Console.Error.WriteLine(" --drawing Drawing name to fill with (default: first drawing)"); Console.Error.WriteLine(" --plate Plate index to fill (default: 0)"); Console.Error.WriteLine(" --quantity Max parts to place (default: 0 = unlimited)"); @@ -506,5 +534,7 @@ static class NestConsole public string PostOutput; public string PostsDir; public bool ListPosts; + public double? RepairBendsMillimeters; + public BendRepairUnits CadUnits; } } diff --git a/OpenNest.IO.Tests/BendRepairImportTests.cs b/OpenNest.IO.Tests/BendRepairImportTests.cs new file mode 100644 index 0000000..4ac260a --- /dev/null +++ b/OpenNest.IO.Tests/BendRepairImportTests.cs @@ -0,0 +1,107 @@ +using ACadSharp; +using ACadSharp.IO; +using CSMath; +using OpenNest.Geometry; +using OpenNest.IO.Bending; +using CadLine = ACadSharp.Entities.Line; +using CadLayer = ACadSharp.Tables.Layer; + +namespace OpenNest.IO.Tests; + +public class BendRepairImportTests +{ + [Theory] + [InlineData("ETCH", false, false, "Repaired")] + [InlineData("SCRIBE", false, false, "Repaired")] + [InlineData("ETCH", true, false, "Skipped")] + [InlineData("ETCH", false, true, "Skipped")] + public void ImportPreservesMarksAndHonorsAmbiguityAndHeader(string layer, bool duplicate, bool conflict, string status) + { + var doc = Fixture(layer); + if (duplicate) doc.Entities.Add(new CadLine(new XYZ(0.05, 5, 0), new XYZ(0.55, 5, 0)) { Layer = new CadLayer(layer) }); + if (conflict) doc.Header.InsUnits = ACadSharp.Types.Units.UnitsType.Millimeters; + WithFile(doc, path => + { + var raw = Dxf.Import(path, preserveRepairMarks: true); + var result = CadImporter.Import(path, new CadImportOptions { BendRepair = Options() }); + Assert.Equal(status, Assert.Single(result.BendRepairReports).Status); + Assert.Equal(raw.Entities.Count, result.Entities.Count); + Assert.Equal(Signatures(raw.Entities.Where(e => e.Layer.Name == "0")), Signatures(result.Entities.Where(e => e.Layer.Name == "0"))); + Assert.Contains(result.Entities.OfType(), l => l.StartPoint == new Vector(4, 2) && l.EndPoint == new Vector(4, 3)); + if (status == "Skipped") Assert.Equal(Signatures(raw.Entities), Signatures(result.Entities)); + else + { + Assert.Equal(new Vector(0, 5), result.Bends[0].StartPoint); + Assert.Equal(new Vector(10, 5), result.Bends[0].EndPoint); + Assert.Equal("Unchanged", Assert.Single(BendRepair.Apply(result.Entities, result.Bends, Options())).Status); + } + Assert.Empty(CadImporter.Import(path).BendRepairReports); + var disabled = CadImporter.Import(path, new CadImportOptions { DetectBends = false, BendRepair = Options() }); + Assert.Empty(disabled.Bends); + Assert.Equal(Signatures(raw.Entities), Signatures(disabled.Entities)); + }); + } + + [Fact] + public void UnitlessHeaderRequiresExplicitCallerUnits() + { + var doc = Fixture("ETCH"); + doc.Header.InsUnits = ACadSharp.Types.Units.UnitsType.Unitless; + WithFile(doc, path => + { + var configured = CadImporter.Import(path, new CadImportOptions { BendRepair = Options() }); + Assert.Equal("Repaired", Assert.Single(configured.BendRepairReports).Status); + var unspecified = CadImporter.Import(path, new CadImportOptions { BendRepair = new BendRepairOptions { MaxEndpointMovementMillimeters = 2 } }); + Assert.Equal("Skipped", Assert.Single(unspecified.BendRepairReports).Status); + Assert.Equal(Signatures(Dxf.Import(path, true).Entities), Signatures(unspecified.Entities)); + }); + } + + [Fact] + public void MarkCircleDoesNotDeduplicateCutCircle() + { + var doc = Fixture("SCRIBE"); + doc.Entities.Add(new ACadSharp.Entities.Circle { Center = new XYZ(2, 2, 0), Radius = 0.2, Layer = new CadLayer("SCRIBE") }); + doc.Entities.Add(new ACadSharp.Entities.Circle { Center = new XYZ(2, 2, 0), Radius = 0.2 }); + WithFile(doc, path => + { + var result = CadImporter.Import(path, new CadImportOptions { BendRepair = Options() }); + Assert.Equal(2, result.Entities.OfType().Count()); + Assert.Single(result.Entities.OfType().Where(e => e.Layer.Name == "0")); + Assert.Single(result.Entities.OfType().Where(e => e.Layer.Name == "SCRIBE")); + }); + } + + private static BendRepairOptions Options() => new() { DrawingUnits = BendRepairUnits.Inches, MaxEndpointMovementMillimeters = 2 }; + + private static CadDocument Fixture(string layer) + { + var doc = new CadDocument(); + doc.Header.InsUnits = ACadSharp.Types.Units.UnitsType.Inches; + foreach (var line in new[] { + new CadLine(new XYZ(0, 0, 0), new XYZ(10, 0, 0)), + new CadLine(new XYZ(10, 0, 0), new XYZ(10, 10, 0)), + new CadLine(new XYZ(10, 10, 0), new XYZ(0, 10, 0)), + new CadLine(new XYZ(0, 10, 0), new XYZ(0, 0, 0)), + new CadLine(new XYZ(0.05, 5, 0), new XYZ(0.55, 5, 0)) { Layer = new CadLayer(layer) }, + new CadLine(new XYZ(9.45, 5, 0), new XYZ(9.95, 5, 0)) { Layer = new CadLayer(layer) }, + new CadLine(new XYZ(4, 2, 0), new XYZ(4, 3, 0)) { Layer = new CadLayer(layer) }, + new CadLine(new XYZ(0.05, 5, 0), new XYZ(9.95, 5, 0)) { Layer = new CadLayer("BEND"), LineType = new ACadSharp.Tables.LineType("CENTER") } + }) doc.Entities.Add(line); + return doc; + } + + private static string[] Signatures(IEnumerable entities) => entities.OfType() + .Select(l => $"{l.Layer.Name}:{l.StartPoint}:{l.EndPoint}:{l.LineTypeName}").Order().ToArray(); + + private static void WithFile(CadDocument doc, Action action) + { + var path = Path.Combine(Path.GetTempPath(), $"opennest-repair-{Guid.NewGuid()}.dxf"); + try + { + DxfWriter.Write(path, doc, false); + action(path); + } + finally { File.Delete(path); } + } +} diff --git a/OpenNest.IO.Tests/BendRepairTests.cs b/OpenNest.IO.Tests/BendRepairTests.cs new file mode 100644 index 0000000..754c0e6 --- /dev/null +++ b/OpenNest.IO.Tests/BendRepairTests.cs @@ -0,0 +1,154 @@ +using OpenNest.Bending; +using OpenNest.Geometry; +using OpenNest.IO.Bending; + +namespace OpenNest.IO.Tests; + +public class BendRepairTests +{ + private static BendRepairOptions Options(BendRepairUnits units = BendRepairUnits.Inches, double limit = 2) => + new() { DrawingUnits = units, MaxEndpointMovementMillimeters = limit }; + + private static (List entities, List bends) Fixture(double start = 0.05, double end = 9.95) + { + var entities = new List + { + new Line(new Vector(0, 0), new Vector(10, 0)), + new Line(new Vector(10, 0), new Vector(10, 10)), + new Line(new Vector(10, 10), new Vector(0, 10)), + new Line(new Vector(0, 10), new Vector(0, 0)), + Mark(start, 5, start + 0.5, 5), Mark(end - 0.5, 5, end, 5), + Mark(4, 2, 4, 3) + }; + return (entities, new List { new() { StartPoint = new Vector(start, 5), EndPoint = new Vector(end, 5), Direction = BendDirection.Up } }); + } + + private static Line Mark(double x, double y, double x2, double y2) => + new(new Vector(x, y), new Vector(x2, y2)) { Layer = new Layer("SCRIBE") { IsVisible = true } }; + + [Theory] + [InlineData(0.05, 9.95)] + [InlineData(-0.05, 10.05)] + [InlineData(0, 9.95)] + public void RepairsAlongAxisPreservingCutAndUnrelatedMarksAndIsIdempotent(double start, double end) + { + var (entities, bends) = Fixture(start, end); + var originals = entities.ToArray(); + var cutPoints = entities.Take(4).Cast().Select(l => (l.StartPoint, l.EndPoint)).ToArray(); + var report = Assert.Single(BendRepair.Apply(entities, bends, Options())); + Assert.Equal("Repaired", report.Status); + Assert.Equal(new Vector(0, 5), bends[0].StartPoint); + Assert.Equal(new Vector(10, 5), bends[0].EndPoint); + for (var i = 0; i < 4; i++) Assert.Same(originals[i], entities[i]); + Assert.Equal(cutPoints, entities.Take(4).Cast().Select(l => (l.StartPoint, l.EndPoint)).ToArray()); + Assert.Same(originals[6], entities[6]); + Assert.Equal(new Vector(0, 5), ((Line)entities[4]).StartPoint); + Assert.Equal(new Vector(10, 5), ((Line)entities[5]).EndPoint); + Assert.Equal(0.5, entities[4].Length, 8); + var after = entities.ToArray(); + Assert.Equal("Unchanged", Assert.Single(BendRepair.Apply(entities, bends, Options())).Status); + Assert.Equal(after, entities); + } + + [Fact] + public void OneInchTicksAreAcceptedAtPhysicalLengthCap() + { + var (entities, bends) = Fixture(); + entities[4] = Mark(0.05, 5, 1.05, 5); + entities[5] = Mark(8.95, 5, 9.95, 5); + Assert.Equal("Repaired", Assert.Single(BendRepair.Apply(entities, bends, Options())).Status); + Assert.Equal("Unchanged", Assert.Single(BendRepair.Apply(entities, bends, Options())).Status); + } + + [Fact] + public void MillimeterCoordinatesUseSamePhysicalLimit() + { + var (entities, bends) = Fixture(); + foreach (var entity in entities) entity.Scale(25.4); + bends[0].StartPoint *= 25.4; + bends[0].EndPoint *= 25.4; + Assert.Equal("Repaired", Assert.Single(BendRepair.Apply(entities, bends, Options(BendRepairUnits.Millimeters))).Status); + Assert.Equal(254, bends[0].EndPoint.X, 8); + } + + [Fact] + public void RotatedAxisIsNotRotatedByRepair() + { + var (entities, bends) = Fixture(); + foreach (var entity in entities) entity.Rotate(0.7); + var axis = bends[0].ToLine(); + axis.Rotate(0.7); + bends[0].StartPoint = axis.StartPoint; + bends[0].EndPoint = axis.EndPoint; + Assert.Equal("Repaired", Assert.Single(BendRepair.Apply(entities, bends, Options())).Status); + Assert.Equal(0.7, bends[0].LineAngle, 8); + Assert.Equal(10, bends[0].Length, 8); + } + + [Theory] + [InlineData("missing")] + [InlineData("duplicate")] + [InlineData("perpendicular")] + [InlineData("offset")] + [InlineData("excessive")] + [InlineData("open")] + [InlineData("hole")] + [InlineData("shared")] + [InlineData("unknown-layer")] + [InlineData("cut-tick")] + [InlineData("nonfinite")] + public void SafetyFailuresAreAtomic(string failure) + { + var (entities, bends) = Fixture(); + switch (failure) + { + case "missing": entities.RemoveAt(5); break; + case "duplicate": entities.Add(entities[4].Clone()); break; + case "perpendicular": entities[5] = Mark(9.95, 5, 9.95, 5.5); break; + case "offset": entities[5].Offset(0, 0.01); break; + case "excessive": bends[0].EndPoint = new Vector(9, 5); entities[5] = Mark(8.5, 5, 9, 5); break; + case "open": entities.RemoveAt(0); break; + case "hole": entities.Add(new Circle(new Vector(5, 5), 1)); break; + case "shared": bends.Add(new Bend { StartPoint = bends[0].StartPoint, EndPoint = bends[0].EndPoint }); break; + case "unknown-layer": foreach (var e in entities.Take(4)) e.Layer = new Layer("UNKNOWN"); break; + case "cut-tick": entities[5].Layer = Layer.Default; break; + case "nonfinite": bends[0].StartPoint = new Vector(double.NaN, 5); break; + } + var before = entities.ToArray(); + var start = bends[0].StartPoint; + var end = bends[0].EndPoint; + var reports = BendRepair.Apply(entities, bends, Options()); + Assert.All(reports, r => Assert.Equal("Skipped", r.Status)); + Assert.Equal(before, entities); + Assert.Equal(start.X, bends[0].StartPoint.X); + Assert.Equal(start.Y, bends[0].StartPoint.Y); + Assert.Equal(end, bends[0].EndPoint); + } + + [Theory] + [InlineData(BendRepairUnits.Unspecified, 2)] + [InlineData(BendRepairUnits.Inches, 0)] + [InlineData(BendRepairUnits.Inches, -1)] + [InlineData(BendRepairUnits.Inches, 3.176)] + [InlineData(BendRepairUnits.Inches, double.NaN)] + [InlineData(BendRepairUnits.Inches, double.PositiveInfinity)] + public void InvalidConfigurationDoesNotMutate(BendRepairUnits units, double limit) + { + var (entities, bends) = Fixture(); + var before = entities.ToArray(); + Assert.Equal("Skipped", Assert.Single(BendRepair.Apply(entities, bends, Options(units, limit))).Status); + Assert.Equal(before, entities); + Assert.Equal(0.05, bends[0].StartPoint.X); + } + + [Fact] + public void DefaultsOff() + { + Assert.Null(CadImportOptions.Default.BendRepair); + var (entities, bends) = Fixture(); + var before = entities.ToArray(); + Assert.Empty(BendRepair.Apply(entities, bends, null)); + Assert.Equal(before, entities); + Assert.Equal(0.05, bends[0].StartPoint.X); + } +} diff --git a/OpenNest.IO.Tests/OpenNest.IO.Tests.csproj b/OpenNest.IO.Tests/OpenNest.IO.Tests.csproj new file mode 100644 index 0000000..4d96dc0 --- /dev/null +++ b/OpenNest.IO.Tests/OpenNest.IO.Tests.csproj @@ -0,0 +1,16 @@ + + + net8.0 + enable + enable + false + true + + + + + + + + + diff --git a/OpenNest.IO/Bending/BendRepair.cs b/OpenNest.IO/Bending/BendRepair.cs new file mode 100644 index 0000000..5c039af --- /dev/null +++ b/OpenNest.IO/Bending/BendRepair.cs @@ -0,0 +1,154 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using OpenNest.Bending; +using OpenNest.Geometry; + +namespace OpenNest.IO.Bending +{ + public enum BendRepairUnits { Unspecified, Inches, Millimeters } + + /// Explicit opt-in. Distances are physical millimeters, not drawing coordinates. + public sealed class BendRepairOptions + { + public BendRepairUnits DrawingUnits { get; set; } + public double MaxEndpointMovementMillimeters { get; set; } + } + + public sealed record BendRepairReport(int BendIndex, string Status, string Reason, + Vector OriginalStart, Vector OriginalEnd, Vector Start, Vector End); + + /// Conservative, atomic repair. Never edits cut entities or unassociated marks. + public static class BendRepair + { + public static List Apply(List entities, List bends, BendRepairOptions options) + { + var reports = new List(); + if (options == null) return reports; + var scale = options.DrawingUnits == BendRepairUnits.Inches ? 1 / 25.4 : 1.0; + var tolerance = 0.001 * scale; + var limit = options.MaxEndpointMovementMillimeters * scale; + var valid = (options.DrawingUnits == BendRepairUnits.Inches || options.DrawingUnits == BendRepairUnits.Millimeters) + && double.IsFinite(limit) && limit > tolerance && options.MaxEndpointMovementMillimeters <= 3.175; + var original = bends.Select(b => (b.StartPoint, b.EndPoint)).ToArray(); + var marks = entities.OfType().Where(IsMark).ToList(); + var cuts = entities.Where(IsCut).ToList(); + // ShapeBuilder may reverse/weld its inputs; isolate it from all source geometry. + var shapes = ShapeBuilder.GetShapes(cuts.CloneAll(), tolerance); + var boundaries = shapes.Where(s => s.IsClosed()).SelectMany(s => s.Entities).ToList(); + var polygons = shapes.Where(s => s.IsClosed()).Select(s => s.ToPolygonWithTolerance(tolerance / 10)).ToList(); + bool InMaterial(Vector point) => polygons.Count(p => p.ContainsPoint(point)) % 2 == 1; + + for (var i = 0; i < bends.Count; i++) + { + var bend = bends[i]; + var (start, end) = original[i]; + var reason = ""; + var status = "Skipped"; + if (!valid) reason = "Specify drawing units and a finite movement limit above 0.001 and at most 3.175 mm."; + else if (!Finite(start) || !Finite(end) || start.DistanceTo(end) <= 2 * limit) + reason = "Invalid or too-short bend axis."; + else + { + var axis = (end - start) / start.DistanceTo(end); + var first = marks.Where(m => Associated(m, start, end, tolerance, scale)).ToList(); + var last = marks.Where(m => Associated(m, end, start, tolerance, scale)).ToList(); + if (first.Count != 1 || last.Count != 1 || ReferenceEquals(first[0], last[0])) + reason = "Missing or ambiguous collinear ticks at both original endpoints."; + else if (original.Where((_, j) => j != i).Any(b => + Associated(first[0], b.StartPoint, b.EndPoint, tolerance, scale) || + Associated(first[0], b.EndPoint, b.StartPoint, tolerance, scale) || + Associated(last[0], b.StartPoint, b.EndPoint, tolerance, scale) || + Associated(last[0], b.EndPoint, b.StartPoint, tolerance, scale))) + reason = "Tick is shared with another bend."; + else + { + var probe = new Line(start - axis * limit, end + axis * limit); + var hits = new List(); + var uncertain = shapes.Where(s => !s.IsClosed()).Any(s => s.Intersects(probe)); + foreach (var edge in boundaries) + { + if (edge is Line line && OnAxis(line.StartPoint, start, axis, tolerance) + && OnAxis(line.EndPoint, start, axis, tolerance) + && System.Math.Max(Dot(line.StartPoint - start, axis), Dot(line.EndPoint - start, axis)) >= -limit + && System.Math.Min(Dot(line.StartPoint - start, axis), Dot(line.EndPoint - start, axis)) <= bend.Length + limit) + uncertain = true; + if (edge.Intersects(probe, out var points)) + foreach (var p in points) + if (Finite(p) && !hits.Any(h => h.DistanceTo(p) <= tolerance)) hits.Add(p); + } + var nearStart = hits.Where(p => p.DistanceTo(start) <= limit).ToList(); + var nearEnd = hits.Where(p => p.DistanceTo(end) <= limit).ToList(); + if (uncertain || hits.Count != 2 || nearStart.Count != 1 || nearEnd.Count != 1) + reason = "Missing/ambiguous closed cut boundaries, interior crossing, or movement exceeds limit."; + else + { + // Project the intersection back onto the existing axis; never rotate a bend. + var newStart = start + axis * Dot(nearStart[0] - start, axis); + var newEnd = start + axis * Dot(nearEnd[0] - start, axis); + var sample = axis * (10 * tolerance); + if (!InMaterial((newStart + newEnd) / 2) + || !InMaterial(newStart + sample) || !InMaterial(newEnd - sample) + || InMaterial(newStart - sample) || InMaterial(newEnd + sample)) + reason = "Endpoints do not bound an unambiguous material interval (possible tangent)."; + else if (Dot(newEnd - newStart, axis) <= first[0].Length + last[0].Length + tolerance) + reason = "Repaired ticks would overlap or reverse the bend."; + else if (newStart.DistanceTo(start) <= tolerance && newEnd.DistanceTo(end) <= tolerance) + { + status = "Unchanged"; + reason = "Already on cut boundaries."; + } + else + { + // Prepare both replacements before committing either endpoint or entity list. + var a = (Line)first[0].Clone(); + var b = (Line)last[0].Clone(); + a.Offset(newStart - start); + b.Offset(newEnd - end); + var ai = entities.IndexOf(first[0]); + var bi = entities.IndexOf(last[0]); + if (ai < 0 || bi < 0) reason = "Tick was already consumed; unchanged."; + else + { + entities[ai] = a; + entities[bi] = b; + bend.StartPoint = newStart; + bend.EndPoint = newEnd; + status = "Repaired"; + reason = "Both endpoints snapped along axis; only their two associated ticks replaced."; + } + } + } + } + } + reports.Add(new BendRepairReport(i, status, reason, start, end, bend.StartPoint, bend.EndPoint)); + } + return reports; + } + + private static bool Finite(Vector p) => double.IsFinite(p.X) && double.IsFinite(p.Y); + private static double Dot(Vector a, Vector b) => a.X * b.X + a.Y * b.Y; + private static bool OnAxis(Vector p, Vector origin, Vector axis, double tolerance) => + System.Math.Abs((p.X - origin.X) * axis.Y - (p.Y - origin.Y) * axis.X) <= tolerance; + private static bool Continuous(Entity e) => string.IsNullOrEmpty(e.LineTypeName) + || string.Equals(e.LineTypeName, "Continuous", StringComparison.OrdinalIgnoreCase) + || string.Equals(e.LineTypeName, "ByLayer", StringComparison.OrdinalIgnoreCase); + private static bool IsMark(Entity e) => Continuous(e) && + (string.Equals(e.Layer?.Name, "ETCH", StringComparison.OrdinalIgnoreCase) + || string.Equals(e.Layer?.Name, "SCRIBE", StringComparison.OrdinalIgnoreCase)); + private static bool IsCut(Entity e) => Continuous(e) && + (e.Layer?.Name == "0" || string.Equals(e.Layer?.Name, "CUT", StringComparison.OrdinalIgnoreCase)) + && (e is Line || e is Arc || e is Circle); + private static bool Associated(Line mark, Vector endpoint, Vector other, double tolerance, double scale) + { + var length = endpoint.DistanceTo(other); + if (!Finite(endpoint) || !Finite(other) || length <= tolerance || !Finite(mark.StartPoint) || !Finite(mark.EndPoint) + || mark.Length <= tolerance || mark.Length > 25.4 * scale + tolerance || mark.Length >= length / 3) return false; + var axis = (other - endpoint) / length; + if (!OnAxis(mark.StartPoint, endpoint, axis, tolerance) || !OnAxis(mark.EndPoint, endpoint, axis, tolerance)) return false; + var a = Dot(mark.StartPoint - endpoint, axis); + var b = Dot(mark.EndPoint - endpoint, axis); + return System.Math.Abs(System.Math.Min(a, b)) <= tolerance && System.Math.Max(a, b) > tolerance; + } + } +} diff --git a/OpenNest.IO/CadImportOptions.cs b/OpenNest.IO/CadImportOptions.cs index 48e2a58..8904712 100644 --- a/OpenNest.IO/CadImportOptions.cs +++ b/OpenNest.IO/CadImportOptions.cs @@ -16,6 +16,9 @@ namespace OpenNest.IO /// public bool DetectBends { get; set; } = true; + /// Null (default) disables repair. Explicit units and a small physical limit are required. + public Bending.BendRepairOptions BendRepair { get; set; } + /// /// Override the drawing name. Null = filename without extension. /// diff --git a/OpenNest.IO/CadImportResult.cs b/OpenNest.IO/CadImportResult.cs index f3d7555..b41013b 100644 --- a/OpenNest.IO/CadImportResult.cs +++ b/OpenNest.IO/CadImportResult.cs @@ -24,6 +24,8 @@ namespace OpenNest.IO /// public List Bends { get; set; } = new List(); + public List BendRepairReports { get; set; } = new List(); + /// /// Bounding box of at import time. May be stale /// if callers mutate ; recompute if needed. diff --git a/OpenNest.IO/CadImporter.cs b/OpenNest.IO/CadImporter.cs index 89d009f..d0a83c2 100644 --- a/OpenNest.IO/CadImporter.cs +++ b/OpenNest.IO/CadImporter.cs @@ -24,10 +24,14 @@ namespace OpenNest.IO { options ??= CadImportOptions.Default; - var dxf = Dxf.Import(path); + var dxf = Dxf.Import(path, preserveRepairMarks: options.BendRepair != null); - RemoveDuplicateArcs(dxf.Entities); - RemoveZeroSweepArcs(dxf.Entities); + var cleanup = options.BendRepair == null ? dxf.Entities : dxf.Entities + .Where(e => !IsRepairMark(e)).ToList(); + RemoveDuplicateArcs(cleanup); + RemoveZeroSweepArcs(cleanup); + if (options.BendRepair != null) + dxf.Entities.RemoveAll(e => !IsRepairMark(e) && !cleanup.Contains(e)); var bends = new List(); if (options.DetectBends && dxf.Document != null) @@ -39,12 +43,27 @@ namespace OpenNest.IO ?? new List(); } - Bend.UpdateEtchEntities(dxf.Entities, bends); + var repairReports = new List(); + if (options.BendRepair == null) + Bend.UpdateEtchEntities(dxf.Entities, bends); + else + { + // Unitless DXFs require the explicit caller declaration. Never override a conflicting header. + var headerUnits = (int)(dxf.Document?.Header.InsUnits ?? 0); + var requestedUnits = options.BendRepair.DrawingUnits == BendRepairUnits.Inches ? 1 : 4; + if (headerUnits != 0 && headerUnits != requestedUnits) + repairReports = bends.Select((b, i) => new BendRepairReport(i, "Skipped", + "DXF insertion units conflict with the declared repair units or are unsupported.", + b.StartPoint, b.EndPoint, b.StartPoint, b.EndPoint)).ToList(); + else + repairReports = BendRepair.Apply(dxf.Entities, bends, options.BendRepair); + } return new CadImportResult { Entities = dxf.Entities, Bends = bends, + BendRepairReports = repairReports, Bounds = dxf.Entities.GetBoundingBox(), SourcePath = path, Name = options.Name ?? Path.GetFileNameWithoutExtension(path), @@ -142,6 +161,10 @@ namespace OpenNest.IO return drawing; } + private static bool IsRepairMark(Entity e) => + string.Equals(e.Layer?.Name, "ETCH", System.StringComparison.OrdinalIgnoreCase) + || string.Equals(e.Layer?.Name, "SCRIBE", System.StringComparison.OrdinalIgnoreCase); + internal static void RemoveZeroSweepArcs(List entities) { entities.RemoveAll(e => diff --git a/OpenNest.IO/Dxf.cs b/OpenNest.IO/Dxf.cs index cdde347..a3d177e 100644 --- a/OpenNest.IO/Dxf.cs +++ b/OpenNest.IO/Dxf.cs @@ -25,13 +25,22 @@ namespace OpenNest.IO /// for bend detection. The CadDocument is NOT disposed — caller can use it for /// additional analysis (e.g., MText extraction for bend notes). /// - public static DxfImportResult Import(string path) + public static DxfImportResult Import(string path, bool preserveRepairMarks = false) { var doc = ReadDocument(path); + // Isolate marks before optimization, including cross-layer circle/arc deduplication. + var entities = preserveRepairMarks + ? ConvertEntities(doc, name => IsNonCutLayer(name) || IsRepairMarkLayer(name)) + : ConvertEntities(doc); + if (preserveRepairMarks) + { + // Keep source marks separate: optimization could merge two ticks or unrelated scribing. + entities.AddRange(ConvertEntities(doc, name => !IsRepairMarkLayer(name), optimize: false)); + } return new DxfImportResult { - Entities = ConvertEntities(doc), + Entities = entities, Document = doc }; } @@ -158,7 +167,7 @@ namespace OpenNest.IO } } - private static List ConvertEntities(CadDocument doc, Func layerFilter = null) + private static List ConvertEntities(CadDocument doc, Func layerFilter = null, bool optimize = true) { var entities = new List(); var lines = new List(); @@ -211,10 +220,13 @@ namespace OpenNest.IO } } - GeometryOptimizer.Optimize(lines); - GeometryOptimizer.Optimize(arcs); - GeometryOptimizer.Deduplicate(circles); - GeometryOptimizer.Deduplicate(circles, arcs); + if (optimize) + { + GeometryOptimizer.Optimize(lines); + GeometryOptimizer.Optimize(arcs); + GeometryOptimizer.Deduplicate(circles); + GeometryOptimizer.Deduplicate(circles, arcs); + } entities.AddRange(circles); entities.AddRange(lines); @@ -223,10 +235,15 @@ namespace OpenNest.IO return entities; } + private static bool IsRepairMarkLayer(string name) => + string.Equals(name, "ETCH", StringComparison.OrdinalIgnoreCase) + || string.Equals(name, "SCRIBE", StringComparison.OrdinalIgnoreCase); + private static bool IsNonCutLayer(string layerName) { + // Etch/scribe marks are never cut geometry — drop them on default import. return string.Equals(layerName, "BEND", StringComparison.OrdinalIgnoreCase) - || string.Equals(layerName, "ETCH", StringComparison.OrdinalIgnoreCase); + || IsRepairMarkLayer(layerName); } private class ExportContext diff --git a/OpenNest.sln b/OpenNest.sln index 9c3c4f4..7a28092 100644 --- a/OpenNest.sln +++ b/OpenNest.sln @@ -35,8 +35,11 @@ EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OpenNest.Data", "OpenNest.Data\OpenNest.Data.csproj", "{A0B4B48E-1DF0-4DD3-B42C-B9B7779EA8B0}" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OpenNest.Benchmark", "OpenNest.Benchmark\OpenNest.Benchmark.csproj", "{ACD8F725-829A-48A8-AA59-61DD90DE06CA}" +EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OpenNest.Engine.Tests", "OpenNest.Engine.Tests\OpenNest.Engine.Tests.csproj", "{F2EF39E0-1A95-4C32-B50B-3D71EC72F692}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OpenNest.IO.Tests", "OpenNest.IO.Tests\OpenNest.IO.Tests.csproj", "{BA93522B-8A93-4689-A850-D042483964B9}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -227,6 +230,18 @@ Global {F2EF39E0-1A95-4C32-B50B-3D71EC72F692}.Release|x64.Build.0 = Release|Any CPU {F2EF39E0-1A95-4C32-B50B-3D71EC72F692}.Release|x86.ActiveCfg = Release|Any CPU {F2EF39E0-1A95-4C32-B50B-3D71EC72F692}.Release|x86.Build.0 = Release|Any CPU + {BA93522B-8A93-4689-A850-D042483964B9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {BA93522B-8A93-4689-A850-D042483964B9}.Debug|Any CPU.Build.0 = Debug|Any CPU + {BA93522B-8A93-4689-A850-D042483964B9}.Debug|x64.ActiveCfg = Debug|Any CPU + {BA93522B-8A93-4689-A850-D042483964B9}.Debug|x64.Build.0 = Debug|Any CPU + {BA93522B-8A93-4689-A850-D042483964B9}.Debug|x86.ActiveCfg = Debug|Any CPU + {BA93522B-8A93-4689-A850-D042483964B9}.Debug|x86.Build.0 = Debug|Any CPU + {BA93522B-8A93-4689-A850-D042483964B9}.Release|Any CPU.ActiveCfg = Release|Any CPU + {BA93522B-8A93-4689-A850-D042483964B9}.Release|Any CPU.Build.0 = Release|Any CPU + {BA93522B-8A93-4689-A850-D042483964B9}.Release|x64.ActiveCfg = Release|Any CPU + {BA93522B-8A93-4689-A850-D042483964B9}.Release|x64.Build.0 = Release|Any CPU + {BA93522B-8A93-4689-A850-D042483964B9}.Release|x86.ActiveCfg = Release|Any CPU + {BA93522B-8A93-4689-A850-D042483964B9}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/README.md b/README.md index 28e3b99..2f27123 100644 --- a/README.md +++ b/README.md @@ -51,7 +51,8 @@ OpenNest takes your part drawings, lets you define your sheet (plate) sizes, and ## Prerequisites -- **Windows 10 or later** +- **Windows 10 or later** for the desktop app and Windows-dependent projects +- The headless console and engine/import test projects target `net8.0` and can be built independently on Linux, macOS, or Windows - [.NET 8 SDK](https://dotnet.microsoft.com/download/dotnet/8.0) ## Getting Started @@ -183,6 +184,47 @@ An engine's layout is rejected (scoring zero for that job) if any part falls out Custom competitor engines can be added by dropping a DLL implementing `INestingEngine` with a public parameterless constructor into the `Engines/` directory next to the benchmark executable; each one is registered under its own CLR type name. This is a separate plugin contract from the desktop app's `NestEngineRegistry`/`NestEngineBase` (which requires a `(Plate)` constructor) — a `NestEngineBase` plugin dropped into the benchmark's `Engines/` folder is silently skipped, since the benchmark only ever solves whole jobs. +### Conservative bend endpoint repair (opt-in) + +Bend endpoint repair is disabled by default in the shared CAD importer. +To opt in for newly imported DXFs in the console, add: + +```text +--repair-bends-mm 2 --cad-units inches +``` + +Use `--cad-units mm` for millimeter coordinates. The movement limit is always in +physical millimeters, must be greater than `0.001`, and cannot exceed `3.175`. +This declares the source units; it does **not** rescale the drawing. A conflicting +or unsupported DXF insertion-unit header prevents repair. A unitless header requires +the explicit caller declaration. + +Library callers set `CadImportOptions.BendRepair` to a `BendRepairOptions` with +`DrawingUnits` and `MaxEndpointMovementMillimeters`, then inspect +`CadImportResult.BendRepairReports` (`Repaired`, `Unchanged`, or `Skipped`, with +reasons and before/after endpoints). The console prints the same reports. + +Repair requires exactly one short, inward, continuous `ETCH`/`SCRIBE` line tick +collinear with **each original detected bend endpoint** (association tolerance +`0.001` physical mm, tick length at most one inch). It fits only along the existing +bend axis to an unambiguous closed material interval on continuous `0`/`CUT` +boundaries. It never rotates a bend, moves cuts, or creates missing ticks. Missing, +shared, duplicate, excessive-movement, open-boundary, hole-crossing, and ambiguous +cases stay unchanged. A successful repair replaces only the two matched ticks, +keeping their lengths and properties. Reapplying repair is idempotent. + +In opt-in mode source marks are preserved separately from geometry optimization; +the legacy blanket etch regeneration is bypassed, including for skipped bends. +Unrelated scribing remains intact. This is a narrow import repair, not general +geometry cleanup or certification of machine-ready output. No desktop toggle or +saved-nest repair is included. + +Run its cross-platform unit and synthetic-DXF integration tests with: + +```bash +dotnet test OpenNest.IO.Tests/OpenNest.IO.Tests.csproj +``` + ## Project Structure ``` @@ -192,6 +234,7 @@ OpenNest.sln ├── OpenNest.Engine/ # Nesting algorithms and whole-job contracts ├── OpenNest.Engine.Tests/ # Cross-platform whole-job contract tests (net8.0) ├── OpenNest.IO/ # File I/O — DXF import/export, nest file format +├── OpenNest.IO.Tests/ # Cross-platform CAD import and bend repair tests (net8.0) ├── OpenNest.Console/ # Command-line interface for batch nesting ├── OpenNest.Api/ # Programmatic nesting API (NestRunner pipeline) ├── OpenNest.Data/ # Machine configuration and cutting parameters