Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
589d341455 | ||
|
|
1a05391d94 | ||
|
|
ea4bd836cd |
@@ -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.
|
||||
|
||||
@@ -48,13 +48,13 @@ namespace OpenNest.Benchmark
|
||||
/// engine owns its own multi-plate/size strategy; this harness no
|
||||
/// longer picks plate sizes on the engine's behalf.
|
||||
/// </summary>
|
||||
public NestJob BuildNestJob(int maxPlates)
|
||||
public NestJob BuildNestJob(int maxPlates, double salvageRate = 0, double minimumSalvageDimension = 0)
|
||||
{
|
||||
var parts = Requests.Select(r =>
|
||||
DrawingJobMapper.FromDrawing(r.Drawing.Id.ToString(), r.Drawing, r.Quantity));
|
||||
var stock = CandidateSizes.Select(size =>
|
||||
new NestPlateStock(size.ToString(1), size, null, PartSpacing, EdgeSpacing, Quadrant));
|
||||
return new NestJob(parts, stock, new NestJobOptions("Default", maxPlates));
|
||||
return new NestJob(parts, stock, new NestJobOptions("Default", maxPlates, salvageRate, minimumSalvageDimension));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,7 +23,8 @@ namespace OpenNest.Benchmark
|
||||
/// <summary>Wall-clock budget for one engine solving one job.</summary>
|
||||
private static readonly TimeSpan SolveTimeout = TimeSpan.FromMinutes(5);
|
||||
|
||||
public static List<JobResult> Run(List<BenchmarkJob> jobs, IReadOnlyList<NestingEngineInfo> engines)
|
||||
public static List<JobResult> Run(List<BenchmarkJob> jobs, IReadOnlyList<NestingEngineInfo> engines,
|
||||
double salvageRate = 0, double minimumSalvageDimension = 0, string outputDirectory = null)
|
||||
{
|
||||
var results = new List<JobResult>(jobs.Count * engines.Count);
|
||||
|
||||
@@ -31,21 +32,22 @@ namespace OpenNest.Benchmark
|
||||
{
|
||||
foreach (var engineInfo in engines)
|
||||
{
|
||||
results.Add(RunOne(job, engineInfo));
|
||||
results.Add(RunOne(job, engineInfo, salvageRate, minimumSalvageDimension, outputDirectory));
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
private static JobResult RunOne(BenchmarkJob job, NestingEngineInfo engineInfo)
|
||||
private static JobResult RunOne(BenchmarkJob job, NestingEngineInfo engineInfo,
|
||||
double salvageRate, double minimumSalvageDimension, string outputDirectory)
|
||||
{
|
||||
var requested = job.TotalRequestedQuantity;
|
||||
var sw = Stopwatch.StartNew();
|
||||
|
||||
try
|
||||
{
|
||||
var nestJob = job.BuildNestJob(MaxPlates);
|
||||
var nestJob = job.BuildNestJob(MaxPlates, salvageRate, minimumSalvageDimension);
|
||||
var engine = engineInfo.Factory();
|
||||
using var cts = new CancellationTokenSource(SolveTimeout);
|
||||
var jobResult = engine.Solve(nestJob, null, cts.Token);
|
||||
@@ -70,6 +72,35 @@ namespace OpenNest.Benchmark
|
||||
.OrderByDescending(g => g.Count())
|
||||
.ToDictionary(g => g.Key, g => g.Count());
|
||||
|
||||
if (validation.Valid && outputDirectory != null)
|
||||
{
|
||||
System.IO.Directory.CreateDirectory(outputDirectory);
|
||||
// Keep names and job metadata for a useful inspectable output; never modify source.
|
||||
var source = new OpenNest.IO.NestReader(job.SourceFile).Read();
|
||||
materialized.Nest.Name = source.Name;
|
||||
materialized.Nest.Units = source.Units;
|
||||
materialized.Nest.Material = source.Material;
|
||||
materialized.Nest.Thickness = source.Thickness;
|
||||
materialized.Nest.SalvageRate = salvageRate;
|
||||
foreach (var request in job.Requests)
|
||||
materialized.DrawingsByPartId[request.Drawing.Id.ToString()].Name = request.Drawing.Name;
|
||||
var path = System.IO.Path.Combine(outputDirectory, $"{job.Name}-{engineInfo.Name}.nest");
|
||||
if (System.IO.Path.GetFullPath(path) == System.IO.Path.GetFullPath(job.SourceFile))
|
||||
throw new InvalidOperationException("Output must not overwrite the source nest.");
|
||||
new OpenNest.IO.NestWriter(materialized.Nest).Write(path);
|
||||
var report = new
|
||||
{
|
||||
Source = job.SourceFile, Engine = engineInfo.Name, jobResult.Status, jobResult.StopReason,
|
||||
Requested = requested, Placed = totalPlaced, SheetArea = plateArea, PlacedArea = placedArea,
|
||||
SalvageRate = salvageRate, MinimumSalvageDimension = minimumSalvageDimension,
|
||||
EstimatedNetArea = jobResult.Plates.Sum(p => StockLadderNestingEngine.EstimateNetArea(nestJob, p)),
|
||||
Fulfillment = jobResult.Fulfillment, StockUsage = jobResult.StockUsage,
|
||||
Plates = jobResult.Plates, validation.Violations
|
||||
};
|
||||
System.IO.File.WriteAllText(System.IO.Path.ChangeExtension(path, ".json"),
|
||||
System.Text.Json.JsonSerializer.Serialize(report,
|
||||
new System.Text.Json.JsonSerializerOptions { WriteIndented = true }));
|
||||
}
|
||||
sw.Stop();
|
||||
|
||||
return new JobResult
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -70,7 +70,7 @@ static class BenchmarkConsole
|
||||
|
||||
Console.WriteLine($"Engines: {string.Join(", ", engines.Select(e => e.Name))}");
|
||||
|
||||
var results = BenchmarkRunner.Run(jobs, engines);
|
||||
var results = BenchmarkRunner.Run(jobs, engines, options.SalvageRate, options.MinimumSalvageDimension, options.OutputDirectory);
|
||||
|
||||
Report.PrintDetailed(results);
|
||||
Report.PrintSummary(results);
|
||||
@@ -111,6 +111,16 @@ static class BenchmarkConsole
|
||||
o.CsvPath = args[++i];
|
||||
break;
|
||||
|
||||
case "--salvage-rate" when i + 1 < args.Length:
|
||||
o.SalvageRate = double.Parse(args[++i], System.Globalization.CultureInfo.InvariantCulture);
|
||||
break;
|
||||
case "--min-salvage-dimension" when i + 1 < args.Length:
|
||||
o.MinimumSalvageDimension = double.Parse(args[++i], System.Globalization.CultureInfo.InvariantCulture);
|
||||
break;
|
||||
case "--output" when i + 1 < args.Length:
|
||||
o.OutputDirectory = args[++i];
|
||||
break;
|
||||
|
||||
case "--help":
|
||||
PrintUsage();
|
||||
return null;
|
||||
@@ -162,6 +172,9 @@ static class BenchmarkConsole
|
||||
Console.Error.WriteLine(" --spacing <value> Override part spacing for every job");
|
||||
Console.Error.WriteLine(" --engines Name1,Name2,... Only benchmark these registered engines (default: all)");
|
||||
Console.Error.WriteLine(" --csv <path> Write a flat CSV of all results");
|
||||
Console.Error.WriteLine(" --salvage-rate <0..1> Fraction of eligible offcut area credited (default 0)");
|
||||
Console.Error.WriteLine(" --min-salvage-dimension <value> Both offcut dimensions must qualify; 0 disables credit");
|
||||
Console.Error.WriteLine(" --output <directory> Save valid layouts as .nest plus detailed JSON reports");
|
||||
Console.Error.WriteLine(" --help Show this message");
|
||||
}
|
||||
|
||||
@@ -172,5 +185,8 @@ static class BenchmarkConsole
|
||||
public double? PartSpacing;
|
||||
public List<string> EngineNames = new();
|
||||
public string CsvPath;
|
||||
public string OutputDirectory;
|
||||
public double SalvageRate;
|
||||
public double MinimumSalvageDimension;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -286,8 +286,10 @@ namespace OpenNest.Geometry
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Subtracts hole triangles from a region. Conservative: partial overlaps
|
||||
/// keep the full piece triangle (acceptable for visual shading).
|
||||
/// Subtracts hole triangles from a region. Exact: a piece outside a convex hole
|
||||
/// triangle equals the union of its clips against each triangle edge's outside
|
||||
/// half-space, so overlap confined to a cutout disappears while any material
|
||||
/// sliver outside the hole survives.
|
||||
/// </summary>
|
||||
private static List<Polygon> SubtractTriangles(Polygon region, List<Polygon> holeTris)
|
||||
{
|
||||
@@ -295,29 +297,25 @@ namespace OpenNest.Geometry
|
||||
|
||||
foreach (var holeTri in holeTris)
|
||||
{
|
||||
if (!BoundingBoxesOverlap(region.BoundingBox, holeTri.BoundingBox))
|
||||
continue;
|
||||
|
||||
var next = new List<Polygon>();
|
||||
|
||||
foreach (var piece in current)
|
||||
{
|
||||
var pieceTris = TriangulateWithBounds(piece);
|
||||
|
||||
foreach (var pieceTri in pieceTris)
|
||||
if (!BoundingBoxesOverlap(piece.BoundingBox, holeTri.BoundingBox))
|
||||
{
|
||||
var inside = ClipConvex(pieceTri, holeTri);
|
||||
if (inside == null)
|
||||
{
|
||||
// No overlap with hole - keep
|
||||
next.Add(pieceTri);
|
||||
}
|
||||
else if (inside.Area() < pieceTri.Area() - Tolerance.Epsilon)
|
||||
{
|
||||
// Partial overlap - keep the piece (conservative)
|
||||
next.Add(pieceTri);
|
||||
}
|
||||
// else: fully inside hole - discard
|
||||
next.Add(piece);
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (var pieceTri in TriangulateWithBounds(piece))
|
||||
{
|
||||
var holeVerts = holeTri.Vertices;
|
||||
var holeCount = holeTri.IsClosed() ? holeVerts.Count - 1 : holeVerts.Count;
|
||||
var survived = false;
|
||||
for (var i = 0; i < holeCount; i++)
|
||||
survived |= AddIfPositiveArea(next,
|
||||
ClipOutsideHalfSpace(pieceTri, holeVerts[i], holeVerts[(i + 1) % holeCount]));
|
||||
if (!survived) continue; // piece lies entirely within the hole
|
||||
}
|
||||
}
|
||||
|
||||
@@ -326,5 +324,40 @@ namespace OpenNest.Geometry
|
||||
|
||||
return current;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sutherland-Hodgman clip of a convex polygon to the strict outside of the
|
||||
/// infinite line edgeStart->edgeEnd of a CCW hole edge (Cross < -Epsilon).
|
||||
/// </summary>
|
||||
private static List<Vector> ClipOutsideHalfSpace(Polygon piece, Vector edgeStart, Vector edgeEnd)
|
||||
{
|
||||
var verts = piece.Vertices;
|
||||
var count = piece.IsClosed() ? verts.Count - 1 : verts.Count;
|
||||
var kept = new List<Vector>();
|
||||
for (var i = 0; i < count; i++)
|
||||
{
|
||||
var current = verts[i];
|
||||
var next = verts[(i + 1) % count];
|
||||
var currentInside = Cross(edgeStart, edgeEnd, current) >= -Tolerance.Epsilon;
|
||||
var nextInside = Cross(edgeStart, edgeEnd, next) >= -Tolerance.Epsilon;
|
||||
if (!currentInside) kept.Add(current);
|
||||
if (currentInside == nextInside) continue;
|
||||
var intersection = LineIntersection(edgeStart, edgeEnd, current, next);
|
||||
if (intersection.IsValid()) kept.Add(intersection);
|
||||
}
|
||||
return kept;
|
||||
}
|
||||
|
||||
private static bool AddIfPositiveArea(List<Polygon> polygons, List<Vector> vertices)
|
||||
{
|
||||
if (vertices.Count < 3) return false;
|
||||
var polygon = new Polygon();
|
||||
polygon.Vertices.AddRange(vertices);
|
||||
polygon.Close();
|
||||
polygon.UpdateBounds();
|
||||
if (polygon.Area() <= Tolerance.Epsilon) return false;
|
||||
polygons.Add(polygon);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,6 +41,33 @@ public class NestJobValidationTests
|
||||
Assert.Equal(2, result.Plates[0].Placements.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SmallCornerOverlapIsRejected()
|
||||
{
|
||||
var part = new NestJobPart("part", PartGeometrySnapshot.FromProgram(TestDrawingFactory.Rectangle(10, 10)), 2);
|
||||
var job = new NestJob(new[] { part }, new[] { new NestPlateStock("stock", new Size(20, 20), 1) });
|
||||
|
||||
Assert.Throws<InvalidOperationException>(() => Solve(job,
|
||||
new NestJobPlacement("part", 0, 0, 0, 0),
|
||||
new NestJobPlacement("part", 1, 9, 9, 0)));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(10.0, 0.0)]
|
||||
[InlineData(10.0, 10.0)]
|
||||
public void BoundaryContactWithZeroSpacingIsAccepted(double x, double y)
|
||||
{
|
||||
var part = new NestJobPart("part", PartGeometrySnapshot.FromProgram(TestDrawingFactory.Rectangle(10, 10)), 2);
|
||||
var job = new NestJob(new[] { part }, new[] { new NestPlateStock("stock", new Size(20, 20), 1) });
|
||||
|
||||
var result = Solve(job,
|
||||
new NestJobPlacement("part", 0, 0, 0, 0),
|
||||
new NestJobPlacement("part", 1, x, y, 0));
|
||||
|
||||
Assert.Equal(NestJobStatus.Complete, result.Status);
|
||||
Assert.Equal(2, Assert.Single(result.Plates).Placements.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UnknownOrOverproducingCandidateFailsBeforeCommitWithoutChangingInput()
|
||||
{
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.Engine.Tests.Jobs;
|
||||
|
||||
public class StockLadderTests
|
||||
{
|
||||
private static NestJobPart Rectangle(string id, int quantity, double x = 4, double y = 4,
|
||||
RotationPolicy? rotation = null) => new(id,
|
||||
PartGeometrySnapshot.FromProgram(TestDrawingFactory.Rectangle(x, y)), quantity,
|
||||
rotation: rotation ?? RotationPolicy.Fixed(0));
|
||||
|
||||
[Fact]
|
||||
public void MergesEquivalentDemandOntoLargerSheetAndReturnsFiniteStock()
|
||||
{
|
||||
var job = new NestJob(new[] { Rectangle("a", 5) }, new[]
|
||||
{
|
||||
new NestPlateStock("small", new Size(10, 10), 2),
|
||||
new NestPlateStock("large", new Size(10, 18), 1)
|
||||
});
|
||||
var result = new StockLadderNestingEngine().Solve(job);
|
||||
Assert.Equal(NestJobStatus.Complete, result.Status);
|
||||
Assert.Equal("large", Assert.Single(result.Plates).StockId);
|
||||
Assert.Equal(5, Assert.Single(result.Fulfillment).Placed);
|
||||
Assert.Equal(0, result.StockUsage.Single(s => s.StockId == "small").Used);
|
||||
Assert.Equal(2, result.StockUsage.Single(s => s.StockId == "small").Remaining);
|
||||
Verify(job, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FiniteStockAndPlateLimitDoNotOverproduce()
|
||||
{
|
||||
var parts = new[] { Rectangle("a", 9) };
|
||||
var stock = new[] { new NestPlateStock("only", new Size(10, 10), 1) };
|
||||
var job = new NestJob(parts, stock);
|
||||
var result = new StockLadderNestingEngine().Solve(job);
|
||||
Assert.Equal(NestJobStopReason.StockExhausted, result.StopReason);
|
||||
Assert.Equal(4, result.Fulfillment[0].Placed);
|
||||
Verify(job, result);
|
||||
job = new NestJob(parts, new[] { new NestPlateStock("only", new Size(10, 10)) }, new NestJobOptions(maxPlates: 1));
|
||||
result = new StockLadderNestingEngine().Solve(job);
|
||||
Assert.Equal(NestJobStopReason.PlateLimitReached, result.StopReason);
|
||||
Assert.Single(result.Plates);
|
||||
Verify(job, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConstrainedLargeSinglePrecedesSmallFillers()
|
||||
{
|
||||
var job = new NestJob(new[] { Rectangle("small", 12, 2, 2), Rectangle("large", 1, 12, 6) }, new[]
|
||||
{
|
||||
new NestPlateStock("small-sheet", new Size(10, 10)),
|
||||
new NestPlateStock("large-sheet", new Size(10, 18))
|
||||
});
|
||||
var result = new StockLadderNestingEngine().Solve(job);
|
||||
Assert.Equal("large", result.Plates[0].Placements[0].PartId);
|
||||
Assert.Contains(result.Plates[0].Placements, p => p.PartId == "small");
|
||||
Assert.Equal(NestJobStatus.Complete, result.Status);
|
||||
Verify(job, result);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(1)] [InlineData(2)] [InlineData(3)] [InlineData(4)]
|
||||
public void GeometrySpacingRotationsAndQuadrantsAreValidated(int quadrant)
|
||||
{
|
||||
var job = new NestJob(new[] { Rectangle("a", 6, 3, 5, RotationPolicy.Fixed(System.Math.PI / 2)) },
|
||||
new[] { new NestPlateStock("sheet", new Size(12, 18), partSpacing: 0.25,
|
||||
edgeSpacing: new Spacing { Left = 0.5, Right = 0.5, Top = 0.5, Bottom = 0.5 }, quadrant: quadrant) });
|
||||
var result = new StockLadderNestingEngine().Solve(job);
|
||||
Assert.Equal(NestJobStatus.Complete, result.Status);
|
||||
Verify(job, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ImpossibleDemandTerminatesWithoutUsingUnlimitedStock()
|
||||
{
|
||||
var job = new NestJob(new[] { Rectangle("a", 1, 100, 100) },
|
||||
new[] { new NestPlateStock("sheet", new Size(10, 10)) });
|
||||
var result = new StockLadderNestingEngine().Solve(job);
|
||||
Assert.Equal(NestJobStopReason.NoPlacementFound, result.StopReason);
|
||||
Assert.Empty(result.Plates);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CancellationBeforeAndDuringTrialNeverReturnsPartialSuccess()
|
||||
{
|
||||
var job = new NestJob(new[] { Rectangle("a", 1) }, new[] { new NestPlateStock("s", new Size(10, 10)) });
|
||||
using var cts = new CancellationTokenSource();
|
||||
var engine = new StockLadderNestingEngine(() => new CallbackNester(request =>
|
||||
{
|
||||
cts.Cancel();
|
||||
return new PlateCandidate(Array.Empty<NestJobPlacement>());
|
||||
}));
|
||||
Assert.Throws<OperationCanceledException>(() => engine.Solve(job, token: cts.Token));
|
||||
Assert.Throws<OperationCanceledException>(() => new StockLadderNestingEngine().Solve(job, token: cts.Token));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(false)] [InlineData(true)]
|
||||
public void RejectsOverlappingOrOverproducingNester(bool overproduce)
|
||||
{
|
||||
var job = new NestJob(new[] { Rectangle("a", 2) }, new[] { new NestPlateStock("s", new Size(10, 10)) });
|
||||
var engine = new StockLadderNestingEngine(() => new CallbackNester(request =>
|
||||
new PlateCandidate(overproduce
|
||||
? Enumerable.Repeat(new NestJobPlacement("a", 0, 0, 0, 0), 3)
|
||||
: new[] { new NestJobPlacement("a", 0, 50, 0, 0) })));
|
||||
Assert.Throws<InvalidOperationException>(() => engine.Solve(job));
|
||||
// Direct full-demand overlap check, not masked by the single-part feasibility probe limit.
|
||||
Assert.Throws<InvalidOperationException>(() => NestJobValidator.ValidateCandidate(
|
||||
new PlateCandidate(new[] { new NestJobPlacement("a", 0, 0, 0, 0), new NestJobPlacement("a", 1, 1, 1, 0) }),
|
||||
job.Plates[0], new Dictionary<string, int> { ["a"] = 2 }, job.Parts.ToDictionary(p => p.Id)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SalvageCreditsOnlyOneUsableEdgeRectangleAndDefaultsToZero()
|
||||
{
|
||||
var part = Rectangle("a", 1);
|
||||
var stock = new NestPlateStock("s", new Size(10, 10));
|
||||
var sheet = new NestJobPlateResult(0, stock, new[] { new NestJobPlacement("a", 0, 0, 0, 0) });
|
||||
NestJob Job(double rate, double min) => new(new[] { part }, new[] { stock },
|
||||
new NestJobOptions(salvageRate: rate, minimumSalvageDimension: min));
|
||||
Assert.Equal(100, StockLadderNestingEngine.EstimateNetArea(Job(0.5, 0), sheet));
|
||||
Assert.Equal(100, StockLadderNestingEngine.EstimateNetArea(Job(0.5, 7), sheet));
|
||||
Assert.Equal(70, StockLadderNestingEngine.EstimateNetArea(Job(0.5, 5), sheet), 6);
|
||||
Assert.Throws<ArgumentOutOfRangeException>(() => new NestJobOptions(salvageRate: double.NaN));
|
||||
Assert.Throws<ArgumentOutOfRangeException>(() => new NestJobOptions(salvageRate: 1.1));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FailedRepackRetainsAllDemandAndFiniteStockAccounting()
|
||||
{
|
||||
var job = new NestJob(new[] { Rectangle("a", 5) }, new[]
|
||||
{
|
||||
new NestPlateStock("small", new Size(10, 10), 2),
|
||||
new NestPlateStock("large", new Size(10, 18), 1)
|
||||
});
|
||||
var fullDemandLargeTrials = 0;
|
||||
var engine = new StockLadderNestingEngine(() => new CallbackNester(request =>
|
||||
{
|
||||
var quantity = Assert.Single(request.Parts).Quantity;
|
||||
if (request.Stock.Id == "large" && quantity == 5) fullDemandLargeTrials++;
|
||||
// Deliberately fail to reproduce the fifth piece on the cheaper merged sheet.
|
||||
return new PlateCandidate(Enumerable.Range(0, System.Math.Min(quantity, 4))
|
||||
.Select(i => new NestJobPlacement("a", i, i % 2 * 4, i / 2 * 4, 0)));
|
||||
}));
|
||||
var result = engine.Solve(job);
|
||||
Assert.True(fullDemandLargeTrials >= 2); // Construction AND equivalent-demand repack ran.
|
||||
Assert.Equal(NestJobStatus.Complete, result.Status);
|
||||
Assert.Equal(2, result.Plates.Count);
|
||||
Assert.All(result.Plates, sheet => Assert.Equal("small", sheet.StockId));
|
||||
Assert.Equal(5, Assert.Single(result.Fulfillment).Placed);
|
||||
Assert.Equal(0, Assert.Single(result.Fulfillment).Unplaced);
|
||||
Assert.Equal(0, result.StockUsage.Single(s => s.StockId == "large").Used);
|
||||
Assert.Equal(1, result.StockUsage.Single(s => s.StockId == "large").Remaining);
|
||||
Verify(job, result);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(3.0, false)]
|
||||
[InlineData(4.0001, true)]
|
||||
public void OpenMarkMustRemainInsideClosedMaterial(double endX, bool reject)
|
||||
{
|
||||
var program = TestDrawingFactory.Rectangle(4, 4);
|
||||
program.MoveTo(2, 2);
|
||||
program.LineTo(endX, 2);
|
||||
var part = new NestJobPart("exterior-mark", PartGeometrySnapshot.FromProgram(program), 1);
|
||||
var job = new NestJob(new[] { part }, new[] { new NestPlateStock("s", new Size(10, 10)) });
|
||||
if (reject)
|
||||
{
|
||||
var error = Assert.Throws<ArgumentException>(() => new StockLadderNestingEngine().Solve(job));
|
||||
Assert.Contains("Open geometry leaves the closed material region", error.Message);
|
||||
}
|
||||
else
|
||||
{
|
||||
var result = new StockLadderNestingEngine().Solve(job);
|
||||
Assert.Equal(NestJobStatus.Complete, result.Status);
|
||||
Verify(job, result);
|
||||
}
|
||||
}
|
||||
|
||||
private static void Verify(NestJob job, NestJobResult result)
|
||||
{
|
||||
var parts = job.Parts.ToDictionary(p => p.Id);
|
||||
var remaining = job.Parts.ToDictionary(p => p.Id, p => p.Quantity);
|
||||
foreach (var sheet in result.Plates)
|
||||
{
|
||||
NestJobValidator.ValidateCandidate(new PlateCandidate(sheet.Placements), sheet.Stock, remaining, parts);
|
||||
foreach (var pose in sheet.Placements) remaining[pose.PartId]--;
|
||||
}
|
||||
foreach (var part in job.Parts)
|
||||
{
|
||||
var poses = result.Plates.SelectMany(p => p.Placements).Where(p => p.PartId == part.Id).ToList();
|
||||
Assert.Equal(Enumerable.Range(0, poses.Count), poses.Select(p => p.InstanceIndex));
|
||||
var fulfillment = result.Fulfillment.Single(p => p.PartId == part.Id);
|
||||
Assert.Equal(part.Quantity, fulfillment.Placed + fulfillment.Unplaced);
|
||||
Assert.Equal(poses.Count, fulfillment.Placed);
|
||||
}
|
||||
foreach (var stock in job.Plates)
|
||||
{
|
||||
var count = result.Plates.Count(p => p.StockId == stock.Id);
|
||||
var usage = result.StockUsage.Single(s => s.StockId == stock.Id);
|
||||
Assert.Equal(count, usage.Used);
|
||||
Assert.Equal(stock.Quantity - count, usage.Remaining);
|
||||
Assert.True(stock.Quantity == null || count <= stock.Quantity);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class CallbackNester(Func<PlatePlacementRequest, PlateCandidate> callback) : IPlateNester
|
||||
{
|
||||
public PlateCandidate Place(PlatePlacementRequest request, IProgress<NestJobProgress>? progress = null,
|
||||
CancellationToken token = default) => callback(request);
|
||||
}
|
||||
}
|
||||
@@ -5,14 +5,27 @@ namespace OpenNest;
|
||||
/// <summary>Immutable per-job options; selection never changes the legacy global registry.</summary>
|
||||
public sealed class NestJobOptions
|
||||
{
|
||||
public NestJobOptions(string placementStrategy = "Default", int? maxPlates = null)
|
||||
public NestJobOptions(string placementStrategy = "Default", int? maxPlates = null,
|
||||
double salvageRate = 0, double minimumSalvageDimension = 0)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(placementStrategy);
|
||||
if (maxPlates <= 0) throw new ArgumentOutOfRangeException(nameof(maxPlates));
|
||||
if (!double.IsFinite(salvageRate) || salvageRate < 0 || salvageRate > 1)
|
||||
throw new ArgumentOutOfRangeException(nameof(salvageRate));
|
||||
if (!double.IsFinite(minimumSalvageDimension) || minimumSalvageDimension < 0)
|
||||
throw new ArgumentOutOfRangeException(nameof(minimumSalvageDimension));
|
||||
PlacementStrategy = placementStrategy;
|
||||
MaxPlates = maxPlates;
|
||||
SalvageRate = salvageRate;
|
||||
MinimumSalvageDimension = minimumSalvageDimension;
|
||||
}
|
||||
|
||||
/// <summary>Fraction of eligible edge-offcut area credited by StockLadder (0..1).</summary>
|
||||
public double SalvageRate { get; }
|
||||
/// <summary>Both offcut dimensions must meet this caller-supplied minimum in job units.
|
||||
/// Zero disables credit; scraps and holes are never credited.</summary>
|
||||
public double MinimumSalvageDimension { get; }
|
||||
|
||||
public string PlacementStrategy { get; }
|
||||
/// <summary>Maximum physical sheets to commit, or null for no explicit cap.</summary>
|
||||
public int? MaxPlates { get; }
|
||||
|
||||
@@ -76,14 +76,131 @@ internal static class NestJobPlacementValidator
|
||||
|
||||
var contours = ShapeBuilder.GetShapes(cutEntities);
|
||||
if (contours.Count == 0) throw new ArgumentException("Geometry must contain a closed contour.");
|
||||
var closedEntities = new List<Entity>();
|
||||
var marks = new List<Shape>();
|
||||
foreach (var contour in contours)
|
||||
ValidateContour(contour);
|
||||
{
|
||||
if (contour.IsClosed())
|
||||
{
|
||||
ValidateContour(contour);
|
||||
closedEntities.AddRange(contour.Entities);
|
||||
}
|
||||
else marks.Add(contour);
|
||||
}
|
||||
if (closedEntities.Count == 0)
|
||||
throw new ArgumentException("Geometry must contain a closed outer contour.");
|
||||
|
||||
var profile = new ShapeProfile(cutEntities);
|
||||
// ShapeProfile selects the outer profile, but does not validate containment and
|
||||
// treats open chains as cutouts. Only validated closed contours may define material.
|
||||
var profile = new ShapeProfile(closedEntities);
|
||||
foreach (var cutout in profile.Cutouts)
|
||||
ValidateInternalChain(cutout, profile.Perimeter, new List<Shape>());
|
||||
foreach (var mark in marks)
|
||||
ValidateMark(mark, profile.Perimeter, profile.Cutouts);
|
||||
profile.NormalizeWinding();
|
||||
return new ShapeTopology(profile.Perimeter, profile.Cutouts);
|
||||
}
|
||||
|
||||
private static void ValidateMark(Shape mark, Shape perimeter, List<Shape> holes)
|
||||
{
|
||||
const double chordTolerance = 0.00001;
|
||||
var boundaries = new List<Shape> { perimeter };
|
||||
boundaries.AddRange(holes);
|
||||
var polygons = boundaries.ConvertAll(s => s.ToPolygonWithTolerance(chordTolerance));
|
||||
foreach (var entity in mark.Entities)
|
||||
{
|
||||
if (entity.Length <= Epsilon || entity is not (Line or Arc))
|
||||
throw new ArgumentException("Unsupported or degenerate internal mark.");
|
||||
var parameters = new List<double> { 0, 1 };
|
||||
foreach (var boundary in boundaries)
|
||||
{
|
||||
entity.Intersects(boundary, out var intersections);
|
||||
foreach (var point in intersections)
|
||||
AddParameter(point);
|
||||
// Include endpoints of coincident edges (parallel intersections may be empty).
|
||||
foreach (var point in boundary.Entities.CollectPoints())
|
||||
if (entity.ClosestPointTo(point).DistanceTo(point) <= Epsilon)
|
||||
AddParameter(point);
|
||||
}
|
||||
parameters.Sort();
|
||||
for (var index = 0; index < parameters.Count; index++)
|
||||
{
|
||||
Check(PointAt(parameters[index]));
|
||||
if (index > 0) Check(PointAt((parameters[index - 1] + parameters[index]) / 2));
|
||||
}
|
||||
|
||||
void AddParameter(Vector point)
|
||||
{
|
||||
if (!point.IsValid()) throw new ArgumentException("Indeterminate mark intersection.");
|
||||
var value = entity is Line line
|
||||
? line.StartPoint.DistanceTo(point) / line.Length
|
||||
: Angle.NormalizeRad(((Arc)entity).IsReversed
|
||||
? ((Arc)entity).StartAngle - ((Arc)entity).Center.AngleTo(point)
|
||||
: ((Arc)entity).Center.AngleTo(point) - ((Arc)entity).StartAngle) / ((Arc)entity).SweepAngle();
|
||||
if (value >= 0 && value <= 1) parameters.Add(value);
|
||||
}
|
||||
Vector PointAt(double value)
|
||||
{
|
||||
if (entity is Line line) return line.StartPoint + (line.EndPoint - line.StartPoint) * value;
|
||||
var arc = (Arc)entity;
|
||||
var angle = arc.StartAngle + (arc.IsReversed ? -1 : 1) * arc.SweepAngle() * value;
|
||||
return arc.Center + new Vector(System.Math.Cos(angle), System.Math.Sin(angle)) * arc.Radius;
|
||||
}
|
||||
void Check(Vector point)
|
||||
{
|
||||
for (var index = 0; index < boundaries.Count; index++)
|
||||
{
|
||||
// Exact analytic boundary contact is allowed; near-boundary uncertainty is not.
|
||||
var onBoundary = false;
|
||||
foreach (var edge in boundaries[index].Entities)
|
||||
if (edge.ClosestPointTo(point).DistanceTo(point) <= Epsilon) onBoundary = true;
|
||||
if (onBoundary) continue;
|
||||
foreach (var edge in polygons[index].ToLines())
|
||||
if (edge.ClosestPointTo(point).DistanceTo(point) <= 2 * chordTolerance)
|
||||
throw new ArgumentException("Internal mark is too close to a material boundary.");
|
||||
var inside = StrictlyInside(polygons[index], point);
|
||||
if (index == 0 ? !inside : inside)
|
||||
throw new ArgumentException("Open geometry leaves the closed material region.");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void ValidateInternalChain(Shape chain, Shape perimeter, List<Shape> holes)
|
||||
{
|
||||
// A connected analytic entity cannot leave material without crossing its boundary.
|
||||
// Reject contact too: conservative, rather than guessing at tangent/collinear cuts.
|
||||
// The witness point is farther than the polygonization error from every boundary.
|
||||
const double chordTolerance = 0.00001;
|
||||
var boundaries = new List<Shape> { perimeter };
|
||||
boundaries.AddRange(holes);
|
||||
var polygons = boundaries.ConvertAll(s => s.ToPolygonWithTolerance(chordTolerance));
|
||||
foreach (var entity in chain.Entities)
|
||||
{
|
||||
if (entity.Length <= Epsilon)
|
||||
throw new ArgumentException("Geometry contains a zero-length internal edge.");
|
||||
var point = entity switch
|
||||
{
|
||||
Line line => line.StartPoint,
|
||||
Arc arc => arc.StartPoint(),
|
||||
Circle circle => circle.Center.Offset(circle.Radius, 0),
|
||||
_ => throw new ArgumentException("Unsupported internal geometry.")
|
||||
};
|
||||
if (!StrictlyInside(polygons[0], point))
|
||||
throw new ArgumentException("Open or disconnected geometry lies outside the closed perimeter.");
|
||||
for (var index = 0; index < boundaries.Count; index++)
|
||||
{
|
||||
if (index > 0 && polygons[index].ContainsPoint(point))
|
||||
throw new ArgumentException("Internal geometry lies in a cutout.");
|
||||
foreach (var edge in polygons[index].ToLines())
|
||||
if (edge.ClosestPointTo(point).DistanceTo(point) <= 2 * chordTolerance)
|
||||
throw new ArgumentException("Internal geometry is too close to a material boundary.");
|
||||
if (entity.Intersects(boundaries[index]))
|
||||
throw new ArgumentException("Internal geometry crosses or touches a material boundary.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void ValidateContour(Shape contour)
|
||||
{
|
||||
if (!contour.IsClosed())
|
||||
@@ -145,65 +262,10 @@ internal static class NestJobPlacementValidator
|
||||
return false;
|
||||
// True material overlap requires shared interior area, not boundary touching.
|
||||
// Edge/corner contact (zero clearance) is a valid placement when part spacing is zero.
|
||||
return InteriorOverlap(leftPoly, left, rightPoly, right);
|
||||
}
|
||||
|
||||
private static bool InteriorOverlap(Polygon leftPoly, ShapeTopology left, Polygon rightPoly, ShapeTopology right)
|
||||
{
|
||||
// The intersection of two polygons is either empty, a region of positive area (true overlap),
|
||||
// or a zero-area line/point (boundary contact). Test the interior of the intersection region:
|
||||
// a point strictly inside BOTH perimeters and outside both parts' holes proves shared material.
|
||||
foreach (var point in InteriorWitnessPoints(leftPoly, rightPoly))
|
||||
{
|
||||
if (StrictlyInside(leftPoly, point) && !InAnyHole(left, point) &&
|
||||
StrictlyInside(rightPoly, point) && !InAnyHole(right, point))
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Points that lie in the interior of the perimeter-perimeter intersection when one exists.
|
||||
/// For each pair of crossing edges, the two interior-side vertices (one from each polygon)
|
||||
/// have their midpoint inside both perimeters; that midpoint is a witness of positive-area
|
||||
/// overlap. For containment, an interior vertex of the inner perimeter witnesses it.
|
||||
/// </summary>
|
||||
private static IEnumerable<Vector> InteriorWitnessPoints(Polygon left, Polygon right)
|
||||
{
|
||||
foreach (var l in left.ToLines())
|
||||
foreach (var r in right.ToLines())
|
||||
if (l.Intersects(r, out var pt) && pt.IsValid())
|
||||
{
|
||||
yield return Midpoint(l, pt);
|
||||
yield return Midpoint(r, pt);
|
||||
}
|
||||
// Containment: an interior point of one polygon inside the other. Use a point pulled
|
||||
// toward the centroid of each polygon from a vertex (guaranteed interior for simple shapes).
|
||||
foreach (var poly in new[] { left, right })
|
||||
{
|
||||
foreach (var vertex in poly.Vertices)
|
||||
{
|
||||
var centroid = Centroid(poly);
|
||||
yield return (vertex + centroid) * 0.5;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static Vector Midpoint(Line line, Vector point)
|
||||
{
|
||||
var other = line.StartPoint.DistanceTo(point) <= line.EndPoint.DistanceTo(point)
|
||||
? line.EndPoint
|
||||
: line.StartPoint;
|
||||
return (other + point) * 0.5;
|
||||
}
|
||||
|
||||
private static Vector Centroid(Polygon polygon)
|
||||
{
|
||||
var n = polygon.IsClosed() ? polygon.Vertices.Count - 1 : polygon.Vertices.Count;
|
||||
var sum = Vector.Zero;
|
||||
for (var i = 0; i < n; i++)
|
||||
sum += polygon.Vertices[i];
|
||||
return sum / n;
|
||||
// Collision checks this by clipping triangulated polygons and rejecting zero-area
|
||||
// slivers, so it catches containment and small corner intersections that a witness
|
||||
// probe can miss, while contact stays legal; cutouts are subtracted from both sides.
|
||||
return Collision.HasOverlap(leftPoly, rightPoly, ToPolygons(left.Cutouts), ToPolygons(right.Cutouts));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -243,14 +305,6 @@ internal static class NestJobPlacementValidator
|
||||
private static double IsLeft(Vector p1, Vector p2, Vector p) =>
|
||||
(p2.X - p1.X) * (p.Y - p1.Y) - (p2.Y - p1.Y) * (p.X - p1.X);
|
||||
|
||||
private static bool InAnyHole(ShapeTopology topology, Vector point)
|
||||
{
|
||||
foreach (var cutout in topology.Cutouts)
|
||||
if (ToPolygon(cutout).ContainsPoint(point))
|
||||
return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
private static double Distance(ShapeTopology left, ShapeTopology right)
|
||||
{
|
||||
var result = double.PositiveInfinity;
|
||||
|
||||
@@ -31,7 +31,7 @@ public static class NestJobValidator
|
||||
}
|
||||
catch (ArgumentException exception)
|
||||
{
|
||||
throw new ArgumentException($"Geometry must contain usable closed edges: {part.Id}.", nameof(job), exception);
|
||||
throw new ArgumentException($"Geometry must contain usable closed edges: {part.Id}. {exception.Message}", nameof(job), exception);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,6 +20,9 @@ public static class NestingEngineRegistry
|
||||
|
||||
static NestingEngineRegistry()
|
||||
{
|
||||
Register("StockLadder", "Caller-stock constrained-first fill and equivalent-demand area repacking",
|
||||
() => new StockLadderNestingEngine());
|
||||
|
||||
Register("Default", "Multi-phase nesting (Linear, Pairs, RectBestFit, Remainder)",
|
||||
() => new FixedStrategyNestingEngine("Default"));
|
||||
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using OpenNest.Engine.Fill;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest;
|
||||
|
||||
/// <summary>Constrained-order linear fills in conservative rectangular free regions.
|
||||
/// Regions are only search hints; every accepted pose passes the job geometry validator.</summary>
|
||||
internal sealed class OrderedPlateNester : IPlateNester
|
||||
{
|
||||
private readonly Dictionary<string, Drawing> drawings = new(StringComparer.Ordinal);
|
||||
|
||||
public PlateCandidate Place(PlatePlacementRequest request, IProgress<NestJobProgress> progress = null,
|
||||
CancellationToken token = default)
|
||||
{
|
||||
var work = DrawingJobMapper.CreatePlate(request.Stock).WorkArea();
|
||||
var poses = new List<NestJobPlacement>();
|
||||
var obstacles = new List<Box>();
|
||||
var requirements = request.Parts.ToDictionary(p => p.Id);
|
||||
var demand = request.Parts.ToDictionary(p => p.Id, p => p.Quantity);
|
||||
foreach (var requirement in request.Parts)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
if (!drawings.TryGetValue(requirement.Id, out var drawing))
|
||||
drawings.Add(requirement.Id, drawing = DrawingJobMapper.CreateDrawing(requirement));
|
||||
var left = requirement.Quantity;
|
||||
while (left > 0)
|
||||
{
|
||||
var regions = new RemnantFinder(work, obstacles).FindRemnants();
|
||||
List<Part> best = null;
|
||||
foreach (var region in regions)
|
||||
{
|
||||
foreach (var angle in Angles(requirement.Rotation))
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
// FillLinear uses actual line/arc geometry for copy distances.
|
||||
var parts = new FillLinear(region, request.Stock.PartSpacing)
|
||||
.Fill(drawing, angle, NestDirection.Horizontal).Take(left).ToList();
|
||||
if (parts.Count == 0 || (best != null && parts.Count <= best.Count)) continue;
|
||||
var trial = poses.Concat(parts.Select(p => new NestJobPlacement(requirement.Id, 0,
|
||||
p.Location.X, p.Location.Y, p.Rotation))).ToList();
|
||||
try
|
||||
{
|
||||
NestJobValidator.ValidateCandidate(new PlateCandidate(trial), request.Stock, demand, requirements);
|
||||
best = parts;
|
||||
}
|
||||
catch (InvalidOperationException)
|
||||
{
|
||||
// Geometry kernels are proposal generators, never the acceptance gate.
|
||||
}
|
||||
if (best?.Count == left) break;
|
||||
}
|
||||
if (best?.Count == left) break;
|
||||
}
|
||||
if (best == null) break;
|
||||
foreach (var part in best)
|
||||
{
|
||||
poses.Add(new NestJobPlacement(requirement.Id, 0, part.Location.X, part.Location.Y, part.Rotation));
|
||||
obstacles.Add(part.BoundingBox.Offset(request.Stock.PartSpacing));
|
||||
}
|
||||
left -= best.Count;
|
||||
}
|
||||
}
|
||||
token.ThrowIfCancellationRequested();
|
||||
return new PlateCandidate(poses);
|
||||
}
|
||||
|
||||
private static IEnumerable<double> Angles(RotationPolicy policy)
|
||||
{
|
||||
if (policy.Kind == RotationPolicyKind.Fixed)
|
||||
{
|
||||
yield return policy.Start;
|
||||
yield break;
|
||||
}
|
||||
// A bounded deterministic search, not a proof that an unplaced part cannot fit.
|
||||
if (policy.Kind == RotationPolicyKind.Automatic)
|
||||
{
|
||||
yield return 0;
|
||||
yield return System.Math.PI / 2;
|
||||
yield return System.Math.PI;
|
||||
yield return 3 * System.Math.PI / 2;
|
||||
for (var degrees = 5; degrees < 180; degrees += 5)
|
||||
if (degrees != 90) yield return degrees * System.Math.PI / 180;
|
||||
yield break;
|
||||
}
|
||||
for (var index = 0L; ; index++)
|
||||
{
|
||||
var angle = policy.Start + index * policy.Step;
|
||||
if (angle > policy.End + 1e-9) yield break;
|
||||
yield return angle;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
|
||||
namespace OpenNest;
|
||||
|
||||
/// <summary>
|
||||
/// Caller-stock-only allocation followed by bounded adjacent-sheet repacking. All replacements
|
||||
/// must reproduce exactly the removed demand and reduce net sheet area; inventory is transactional.
|
||||
/// This is a deterministic heuristic, not an optimality or geometric impossibility proof.
|
||||
/// </summary>
|
||||
public sealed class StockLadderNestingEngine : INestingEngine
|
||||
{
|
||||
private readonly Func<IPlateNester> factory;
|
||||
public StockLadderNestingEngine() : this(() => new OrderedPlateNester()) { }
|
||||
public StockLadderNestingEngine(Func<IPlateNester> factory) =>
|
||||
this.factory = factory ?? throw new ArgumentNullException(nameof(factory));
|
||||
|
||||
public NestJobResult Solve(NestJob job, IProgress<NestJobProgress> progress = null,
|
||||
CancellationToken token = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(job);
|
||||
token.ThrowIfCancellationRequested();
|
||||
NestJobValidator.Validate(job);
|
||||
var nester = factory() ?? throw new InvalidOperationException("Null plate nester.");
|
||||
var parts = job.Parts.ToDictionary(p => p.Id, StringComparer.Ordinal);
|
||||
var remaining = job.Parts.ToDictionary(p => p.Id, p => p.Quantity, StringComparer.Ordinal);
|
||||
var used = job.Plates.ToDictionary(s => s.Id, _ => 0, StringComparer.Ordinal);
|
||||
var areas = job.Parts.ToDictionary(p => p.Id, p => DrawingJobMapper.CreateDrawing(p).Area);
|
||||
var sheets = new List<NestJobPlateResult>();
|
||||
var feasible = job.Parts.ToDictionary(p => p.Id, _ => new HashSet<string>());
|
||||
|
||||
// Probe actual validated single-part placements, not bounding-box fit assertions.
|
||||
foreach (var part in job.Parts)
|
||||
foreach (var stock in job.Plates.Where(s => s.Quantity != 0))
|
||||
{
|
||||
var probe = Trial(stock, new[] { WithQuantity(part, 1) });
|
||||
if (probe.Placements.Count != 0) feasible[part.Id].Add(stock.Id);
|
||||
}
|
||||
var ordered = job.Parts.OrderBy(p => p.Priority)
|
||||
.ThenBy(p => feasible[p.Id].Count).ThenByDescending(p => areas[p.Id]).ToList();
|
||||
var reason = NestJobStopReason.Completed;
|
||||
while (remaining.Values.Any(n => n > 0))
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
if (job.Options.MaxPlates <= sheets.Count)
|
||||
{
|
||||
if (Consolidate()) continue;
|
||||
reason = NestJobStopReason.PlateLimitReached;
|
||||
break;
|
||||
}
|
||||
var available = job.Plates.Where(s => s.Quantity == null || used[s.Id] < s.Quantity).ToList();
|
||||
if (available.Count == 0)
|
||||
{
|
||||
if (Consolidate()) continue;
|
||||
reason = NestJobStopReason.StockExhausted;
|
||||
break;
|
||||
}
|
||||
var anchor = ordered.FirstOrDefault(p => remaining[p.Id] > 0 &&
|
||||
available.Any(s => feasible[p.Id].Contains(s.Id)));
|
||||
if (anchor == null)
|
||||
{
|
||||
reason = NestJobStopReason.NoPlacementFound;
|
||||
break;
|
||||
}
|
||||
NestJobPlateResult winner = null;
|
||||
var score = double.PositiveInfinity;
|
||||
foreach (var stock in available.Where(s => feasible[anchor.Id].Contains(s.Id)))
|
||||
{
|
||||
// Pin the constrained anchor before fillers, including quantity-one requirements.
|
||||
var requests = new[] { anchor }.Concat(ordered.Where(p => p.Id != anchor.Id))
|
||||
.Where(p => remaining[p.Id] > 0).Select(p => WithQuantity(p, remaining[p.Id]));
|
||||
var candidate = Trial(stock, requests);
|
||||
if (!candidate.Placements.Any(p => p.PartId == anchor.Id)) continue;
|
||||
var sheet = new NestJobPlateResult(sheets.Count, stock, candidate.Placements);
|
||||
// Initial construction only: material area, never raw part counts. Repacking below
|
||||
// compares EXACTLY equivalent demand, and never replaces a sheet by a partial fill.
|
||||
var value = EstimateNetArea(job, sheet) / candidate.Placements.Sum(p => areas[p.PartId]);
|
||||
if (value < score - 1e-9)
|
||||
{
|
||||
winner = sheet;
|
||||
score = value;
|
||||
}
|
||||
}
|
||||
if (winner == null)
|
||||
{
|
||||
reason = NestJobStopReason.NoPlacementFound;
|
||||
break;
|
||||
}
|
||||
sheets.Add(winner);
|
||||
used[winner.StockId]++;
|
||||
foreach (var pose in winner.Placements) remaining[pose.PartId]--;
|
||||
progress?.Report(new NestJobProgress(NestJobStage.PlateCommitted, winner.StockId,
|
||||
sheets.Count - 1, sheets.Count, sheets.Sum(s => s.Placements.Count)));
|
||||
}
|
||||
Consolidate();
|
||||
token.ThrowIfCancellationRequested();
|
||||
var placed = job.Parts.ToDictionary(p => p.Id, _ => 0);
|
||||
var final = sheets.Select((sheet, index) => new NestJobPlateResult(index, sheet.Stock,
|
||||
sheet.Placements.Select(p => p with { InstanceIndex = placed[p.PartId]++ }).ToList())).ToList();
|
||||
return new NestJobResult(reason == NestJobStopReason.Completed ? NestJobStatus.Complete : NestJobStatus.Incomplete,
|
||||
reason, final, job.Parts.Select(p => new PartFulfillment(p.Id, p.Quantity, placed[p.Id], remaining[p.Id])),
|
||||
job.Plates.Select(s => new StockUsage(s.Id, used[s.Id], s.Quantity - used[s.Id])));
|
||||
|
||||
PlateCandidate Trial(NestPlateStock stock, IEnumerable<NestJobPart> requirements)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
var request = new PlatePlacementRequest(stock, requirements);
|
||||
progress?.Report(new NestJobProgress(NestJobStage.EvaluatingCandidate, stock.Id,
|
||||
sheets.Count, sheets.Count, sheets.Sum(s => s.Placements.Count)));
|
||||
var candidate = nester.Place(request, null, token);
|
||||
token.ThrowIfCancellationRequested();
|
||||
NestJobValidator.ValidateCandidate(candidate, stock, request.Parts.ToDictionary(p => p.Id, p => p.Quantity), parts);
|
||||
return candidate;
|
||||
}
|
||||
|
||||
bool Consolidate()
|
||||
{
|
||||
var changed = false;
|
||||
// Single downgrade and adjacent pair merge only: bounded local search, no combinatorial tree.
|
||||
for (var index = 0; index < sheets.Count; index++)
|
||||
for (var count = System.Math.Min(2, sheets.Count - index); count >= 1; count--)
|
||||
{
|
||||
var old = sheets.Skip(index).Take(count).ToList();
|
||||
var demand = old.SelectMany(s => s.Placements).GroupBy(p => p.PartId)
|
||||
.ToDictionary(g => g.Key, g => g.Count());
|
||||
var baseline = old.Sum(s => EstimateNetArea(job, s));
|
||||
NestJobPlateResult replacement = null;
|
||||
foreach (var stock in job.Plates)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
var returned = old.Count(s => s.StockId == stock.Id);
|
||||
if (stock.Quantity is int limit && used[stock.Id] - returned >= limit) continue;
|
||||
// Even the maximum possible salvage credit cannot beat the incumbent.
|
||||
var lowerBound = stock.Size.Width * stock.Size.Length * (1 - job.Options.SalvageRate);
|
||||
if (lowerBound >= baseline - 1e-9) continue;
|
||||
if (demand.Keys.Any(id => !feasible[id].Contains(stock.Id))) continue;
|
||||
var candidate = Trial(stock, ordered.Where(p => demand.ContainsKey(p.Id))
|
||||
.Select(p => WithQuantity(p, demand[p.Id])));
|
||||
var actual = candidate.Placements.GroupBy(p => p.PartId).ToDictionary(g => g.Key, g => g.Count());
|
||||
if (demand.Any(kv => !actual.TryGetValue(kv.Key, out var n) || n != kv.Value)) continue;
|
||||
var trial = new NestJobPlateResult(index, stock, candidate.Placements);
|
||||
var cost = EstimateNetArea(job, trial);
|
||||
if (cost >= baseline - 1e-9) continue;
|
||||
baseline = cost;
|
||||
replacement = trial;
|
||||
}
|
||||
if (replacement == null) continue;
|
||||
// No accounting changes until the entire equivalent-demand candidate is valid.
|
||||
foreach (var sheet in old) used[sheet.StockId]--;
|
||||
used[replacement.StockId]++;
|
||||
sheets.RemoveRange(index, count);
|
||||
sheets.Insert(index, replacement);
|
||||
changed = true;
|
||||
}
|
||||
return changed;
|
||||
}
|
||||
}
|
||||
|
||||
private static NestJobPart WithQuantity(NestJobPart part, int quantity) =>
|
||||
new(part.Id, part.Geometry, quantity, part.Priority, part.Rotation);
|
||||
|
||||
/// <summary>Full physical sheet area minus a conservative offcut estimate. Credits only ONE
|
||||
/// empty full-span edge rectangle outside every placed bounding box plus part clearance, within
|
||||
/// the usable work area, and meeting the caller's minimum in both dimensions. Not a certified
|
||||
/// remnant: no cut-off toolpath, kerf, handling, or future-demand valuation is modelled.</summary>
|
||||
public static double EstimateNetArea(NestJob job, NestJobPlateResult sheet)
|
||||
{
|
||||
var area = sheet.Stock.Size.Width * sheet.Stock.Size.Length;
|
||||
var minimum = job.Options.MinimumSalvageDimension;
|
||||
if (job.Options.SalvageRate == 0 || minimum <= 0 || sheet.Placements.Count == 0) return area;
|
||||
var work = DrawingJobMapper.CreatePlate(sheet.Stock).WorkArea();
|
||||
var parts = job.Parts.ToDictionary(p => p.Id);
|
||||
var boxes = sheet.Placements.Select(p =>
|
||||
{
|
||||
var part = new Part(DrawingJobMapper.CreateDrawing(parts[p.PartId]));
|
||||
part.Rotate(p.Rotation);
|
||||
part.Location = new OpenNest.Geometry.Vector(p.X, p.Y);
|
||||
part.UpdateBounds();
|
||||
return part.BoundingBox;
|
||||
}).ToList();
|
||||
var gap = sheet.Stock.PartSpacing;
|
||||
var candidates = new[]
|
||||
{
|
||||
(work.Length, boxes.Min(b => b.Bottom) - work.Bottom - gap),
|
||||
(work.Length, work.Top - boxes.Max(b => b.Top) - gap),
|
||||
(boxes.Min(b => b.Left) - work.Left - gap, work.Width),
|
||||
(work.Right - boxes.Max(b => b.Right) - gap, work.Width)
|
||||
};
|
||||
var salvage = candidates.Where(c => c.Item1 >= minimum && c.Item2 >= minimum)
|
||||
.Select(c => c.Item1 * c.Item2).DefaultIfEmpty(0).Max();
|
||||
return area - job.Options.SalvageRate * salvage;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -218,6 +261,71 @@ OpenNest.sln
|
||||
| **OpenNest.Benchmark** | Runs every registered whole-job nesting engine (`INestingEngine`) against a set of `.nest` files and scores them by material utilization, so competing engines — each owning its own multi-plate strategy — can be compared head-to-head. |
|
||||
| **OpenNest.Tests** | 89 test files covering core geometry, fill strategies, splitting, bending, BOM import, post-processing, and the API. |
|
||||
|
||||
### StockLadder whole-job baseline
|
||||
|
||||
Select `new StockLadderNestingEngine().Solve(job)` or the whole-job registry's
|
||||
`StockLadder` engine (benchmark: `--engines StockLadder`). This does not switch the
|
||||
legacy desktop single-plate engine. Supply every allowed `NestPlateStock` explicitly;
|
||||
no stock sizes are invented. Stock quantity `null` means unlimited, `0` unavailable,
|
||||
and a positive quantity is finite inventory. The benchmark's `--sheet-sizes` pool
|
||||
uses unlimited quantities; use the job API for finite stock.
|
||||
|
||||
```csharp
|
||||
var job = new NestJob(parts, callerStocks,
|
||||
new NestJobOptions(maxPlates: 100, salvageRate: 0,
|
||||
minimumSalvageDimension: 0));
|
||||
var result = new StockLadderNestingEngine().Solve(job, token: cancellationToken);
|
||||
```
|
||||
|
||||
Construction orders by priority, then validated stock-fit scarcity, then part area,
|
||||
pins an anchor before fillers, and ranks candidate sheets by estimated net sheet
|
||||
area per placed part area. Repacking tries single-sheet replacements and adjacent
|
||||
pairs into one sheet, accepting only strictly lower estimated net area with exactly
|
||||
the same demand. Failed trials leave placements and finite stock accounting intact.
|
||||
|
||||
Salvage is an **area estimate**, not price or certified recoverable material.
|
||||
`salvageRate` defaults to `0` (allowed range 0–1); `minimumSalvageDimension` defaults
|
||||
to `0`, which also disables credit. With both enabled, only the largest qualifying
|
||||
full-span edge rectangle outside placed bounding boxes plus part spacing is credited,
|
||||
within the usable work area; both dimensions must meet the minimum in job units.
|
||||
Holes/scraps are not credited. No cut-off toolpath, kerf, handling, or future-demand
|
||||
valuation is modeled. Benchmark ranking still uses gross material utilization.
|
||||
|
||||
This is a tested deterministic heuristic baseline, **not an optimal or production-
|
||||
certified solver**. Conservative rectangular free-region hints and linear fills can
|
||||
miss concave interlocks and feasible layouts. Automatic rotation tries cardinal
|
||||
angles plus 5-degree increments below 180 degrees; fixed/range policies are honored.
|
||||
Repacking is bounded local search, not a global stock/demand search or fixed-point
|
||||
optimality proof. `NoPlacementFound` is not proof of impossibility. Cancellation is
|
||||
cooperative (the benchmark requests it after five minutes), not process isolation.
|
||||
Geometry acceptance remains strict, including open marks leaving closed material.
|
||||
|
||||
Benchmark export example (use a separate output directory):
|
||||
|
||||
```bash
|
||||
dotnet run --project OpenNest.Benchmark -- input.nest \
|
||||
--engines StockLadder --sheet-sizes 48x96,48x120,48x144,60x96,60x120,60x144,72x96,72x120,72x144 \
|
||||
--salvage-rate 0 --min-salvage-dimension 0 \
|
||||
--output ./stockladder-output --csv ./stockladder.csv
|
||||
```
|
||||
|
||||
`--output` writes validated layouts as `.nest` plus JSON containing status, stop
|
||||
reason, fulfillment, stock usage, poses, and gross/estimated net area. Valid but
|
||||
incomplete layouts may be exported: inspect status and fulfillment. Thrown/invalid
|
||||
runs do not export layouts. The console can exit zero despite a reported `CRASH`;
|
||||
inspect the report, not just the process exit code. Export does not certify cutting
|
||||
readiness and must not overwrite the source.
|
||||
|
||||
**Known real-input blocker (no successful real-file result):**
|
||||
`/srv/shared/P260805-10_dxf/P260805-10.nest` requests 219 pieces from 69 drawings.
|
||||
With the nine caller-supplied sizes above, strict validation rejects drawing ID `57`,
|
||||
`4980 A01 PT75`: its open mark from `(-5.21875, -1.807287)` to
|
||||
`(-4.21875, -1.807287)` starts `0.0001` outside the perimeter's vertical edge at
|
||||
`x = -5.21865`. Error: `Geometry must contain usable closed edges: 57. Open geometry
|
||||
leaves the closed material region. (Parameter 'job')`. No snapping, clipping, or
|
||||
source geometry changes were made. Source SHA-256:
|
||||
`9e839fd51072587ec4f3173dc2f39ef1ea8ae460971889c2a3b91fa54b61091d`.
|
||||
|
||||
## Nesting Engines
|
||||
|
||||
OpenNest uses a pluggable engine architecture. The active engine can be selected at runtime.
|
||||
|
||||
Reference in New Issue
Block a user