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:
@@ -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,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,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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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); }
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user