Author SHA1 Message Date
aj 589d341455 feat(io): conservative opt-in bend repair with tests and console CLI
Add OpenNest.IO/Bending/BendRepair: opt-in repair of unambiguous paired
ETCH/SCRIBE bend ticks, bounded to <=3.175 mm endpoint movement with
explicit source units. Cut geometry is never modified.

- CadImportOptions.BendRepair configures it; CadImportResult exposes
  per-bend BendRepairReports; CadImporter/Dxf wire it into import.
- Console: --repair-bends-mm <limit> --cad-units inches|mm prints
  per-bend reports for newly imported DXFs.
- New OpenNest.IO.Tests project (net8.0, synthetic DXFs, 30 tests)
  covering bend detection and repair, added to the solution.
- Update README.md and CLAUDE.md for the new pipeline and build/test
  instructions.
2026-09-20 15:22:46 -04:00
aj 1a05391d94 fix(engine): reject small corner overlaps in placement validation
The witness-probe overlap test missed small corner intersections: its
candidate points (crossing-edge midpoints and vertex-centroid midpoints)
can all land on a part boundary or outside the intersection, so two 10x10
parts at (0,0) and (9,9) with zero spacing were accepted despite sharing
a 1x1 unit of material.

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