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 <limit> --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.
This commit is contained in:
aj
2026-09-20 15:22:46 -04:00
parent 1a05391d94
commit 589d341455
14 changed files with 589 additions and 22 deletions
+6 -3
View File
@@ -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 <limit> --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.
+1 -1
View File
@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0-windows</TargetFramework>
<TargetFramework>net8.0</TargetFramework>
<RootNamespace>OpenNest.Benchmark</RootNamespace>
<AssemblyName>OpenNest.Benchmark</AssemblyName>
<Nullable>disable</Nullable>
+1 -1
View File
@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0-windows</TargetFramework>
<TargetFramework>net8.0</TargetFramework>
<RootNamespace>OpenNest.Console</RootNamespace>
<AssemblyName>OpenNest.Console</AssemblyName>
<DefineConstants>$(DefineConstants);DEBUG;TRACE</DefineConstants>
+34 -4
View File
@@ -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(" <nest.nest> <part.dxf> Load nest and add imported DXF drawings");
Console.Error.WriteLine();
Console.Error.WriteLine("Options:");
Console.Error.WriteLine(" --repair-bends-mm <n> 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 <name> Drawing name to fill with (default: first drawing)");
Console.Error.WriteLine(" --plate <index> Plate index to fill (default: 0)");
Console.Error.WriteLine(" --quantity <n> 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;
}
}
+107
View File
@@ -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<Line>(), 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<Circle>().Count());
Assert.Single(result.Entities.OfType<Circle>().Where(e => e.Layer.Name == "0"));
Assert.Single(result.Entities.OfType<Circle>().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<Entity> entities) => entities.OfType<Line>()
.Select(l => $"{l.Layer.Name}:{l.StartPoint}:{l.EndPoint}:{l.LineTypeName}").Order().ToArray();
private static void WithFile(CadDocument doc, Action<string> action)
{
var path = Path.Combine(Path.GetTempPath(), $"opennest-repair-{Guid.NewGuid()}.dxf");
try
{
DxfWriter.Write(path, doc, false);
action(path);
}
finally { File.Delete(path); }
}
}
+154
View File
@@ -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<Entity> entities, List<Bend> bends) Fixture(double start = 0.05, double end = 9.95)
{
var entities = new List<Entity>
{
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<Bend> { 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<Line>().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<Line>().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);
}
}
@@ -0,0 +1,16 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.8.0" />
<PackageReference Include="xunit" Version="2.5.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.5.3" />
<Using Include="Xunit" />
<ProjectReference Include="../OpenNest.IO/OpenNest.IO.csproj" />
</ItemGroup>
</Project>
+154
View File
@@ -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 }
/// <summary>Explicit opt-in. Distances are physical millimeters, not drawing coordinates.</summary>
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);
/// <summary>Conservative, atomic repair. Never edits cut entities or unassociated marks.</summary>
public static class BendRepair
{
public static List<BendRepairReport> Apply(List<Entity> entities, List<Bend> bends, BendRepairOptions options)
{
var reports = new List<BendRepairReport>();
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<Line>().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<Vector>();
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;
}
}
}
+3
View File
@@ -16,6 +16,9 @@ namespace OpenNest.IO
/// </summary>
public bool DetectBends { get; set; } = true;
/// <summary>Null (default) disables repair. Explicit units and a small physical limit are required.</summary>
public Bending.BendRepairOptions BendRepair { get; set; }
/// <summary>
/// Override the drawing name. Null = filename without extension.
/// </summary>
+2
View File
@@ -24,6 +24,8 @@ namespace OpenNest.IO
/// </summary>
public List<Bend> Bends { get; set; } = new List<Bend>();
public List<Bending.BendRepairReport> BendRepairReports { get; set; } = new List<Bending.BendRepairReport>();
/// <summary>
/// Bounding box of <see cref="Entities"/> at import time. May be stale
/// if callers mutate <see cref="Entities"/>; recompute if needed.
+27 -4
View File
@@ -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<Bend>();
if (options.DetectBends && dxf.Document != null)
@@ -39,12 +43,27 @@ namespace OpenNest.IO
?? new List<Bend>();
}
Bend.UpdateEtchEntities(dxf.Entities, bends);
var repairReports = new List<BendRepairReport>();
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<Entity> entities)
{
entities.RemoveAll(e =>
+25 -8
View File
@@ -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).
/// </summary>
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<Entity> ConvertEntities(CadDocument doc, Func<string, bool> layerFilter = null)
private static List<Entity> ConvertEntities(CadDocument doc, Func<string, bool> layerFilter = null, bool optimize = true)
{
var entities = new List<Entity>();
var lines = new List<Line>();
@@ -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
+15
View File
@@ -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
+44 -1
View File
@@ -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