diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json new file mode 100644 index 0000000..6e2e244 --- /dev/null +++ b/.config/dotnet-tools.json @@ -0,0 +1,13 @@ +{ + "version": 1, + "isRoot": true, + "tools": { + "csharpier": { + "version": "1.3.0", + "commands": [ + "csharpier" + ], + "rollForward": false + } + } +} \ No newline at end of file diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..8083617 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,68 @@ +# Unified code style for OpenNest. +# Canonical formatter: CSharpier (see .config/dotnet-tools.json). +# dotnet tool restore +# dotnet csharpier check . # verify before committing +# dotnet csharpier format . # fix +# These settings match CSharpier's conventions so IDE auto-formatting +# (VS / Rider / VS Code) stays consistent with the formatter. + +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true + +[*.{csproj,props,targets,xml,config,manifest}] +indent_style = space +indent_size = 2 + +[*.{json,yml,yaml}] +indent_style = space +indent_size = 2 + +[*.{cs,csx}] +indent_style = space +indent_size = 4 +# CSharpier wraps code at 100 columns; dotnet format cannot re-wrap, but +# IDEs surface a visual guide and analyzers can flag hard violations. +max_line_length = 100 + +# --- Using directives (System first, outside the namespace) --- +dotnet_sort_system_directives_first = true +csharp_using_directive_placement = outside_namespace:warning + +# --- Brace placement: Allman (opening brace on its own line) --- +csharp_new_line_before_open_brace = all +csharp_new_line_before_else = true +csharp_new_line_before_catch = true +csharp_new_line_before_finally = true +csharp_new_line_before_members_in_object_initializers = true +csharp_new_line_before_members_in_anonymous_types = true +csharp_new_line_between_query_expression_clauses = true + +# --- Spacing --- +csharp_space_after_keywords_in_control_flow_statements = true +csharp_space_between_method_call_parameter_list_parentheses = false +csharp_space_between_method_declaration_parameter_list_parentheses = false +csharp_space_between_parentheses = false +csharp_space_before_colon_in_inheritance_clause = true +csharp_space_after_colon_in_inheritance_clause = true +csharp_space_around_binary_operators = before_and_after +csharp_space_after_cast = false +csharp_space_after_comma = true +csharp_space_before_comma = false + +# --- Code style preferences --- +# Project rule: always use var for locals (see CLAUDE.md). +csharp_style_var_for_built_in_types = true:suggestion +csharp_style_var_when_type_is_apparent = true:suggestion +csharp_style_var_elsewhere = true:suggestion +csharp_prefer_braces = true:suggestion +csharp_prefer_simple_using_statement = true:suggestion +csharp_style_namespace_declarations = file_scoped:silent +dotnet_style_prefer_auto_properties = true:suggestion +dotnet_style_object_initializer = true:suggestion +dotnet_style_collection_initializer = true:suggestion +dotnet_style_prefer_is_null_check_over_reference_equality_method = true:suggestion diff --git a/CLAUDE.md b/CLAUDE.md index ad08386..975553c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -8,7 +8,7 @@ OpenNest is a Windows desktop application for CNC nesting — arranging 2D parts ## Build -This is a .NET 8 solution using SDK-style `.csproj` files targeting `net8.0-windows`. Build with: +This is a .NET 8 solution using SDK-style `.csproj` files. The desktop app and Windows-dependent projects target `net8.0-windows`; the core libraries and `OpenNest.Console` target `net8.0`. Build the full solution on Windows with: ```bash dotnet build OpenNest.sln @@ -16,6 +16,8 @@ dotnet build OpenNest.sln Cross-platform whole-job engine tests (net8.0, runs on Linux/macOS/Windows without the desktop project or DXF fixtures): `dotnet test OpenNest.Engine.Tests/OpenNest.Engine.Tests.csproj`. The existing `OpenNest.Tests` suite targets `net8.0-windows` and requires a Windows runner; cross-compiling on Linux is not Windows runtime verification. +Cross-platform CAD import tests: `dotnet test OpenNest.IO.Tests/OpenNest.IO.Tests.csproj`. These synthetic-DXF and bend-repair tests target `net8.0`, require no external fixtures, and are included in the solution. Build the headless console independently with `dotnet build OpenNest.Console/OpenNest.Console.csproj`. + NuGet dependencies: `ACadSharp` 3.1.32 (DXF/DWG import/export, in OpenNest.IO), `System.Drawing.Common` 8.0.10, `ModelContextProtocol` + `Microsoft.Extensions.Hosting` (in OpenNest.Mcp), `Microsoft.ML.OnnxRuntime` (in OpenNest.Engine for ML angle prediction), `Microsoft.EntityFrameworkCore.Sqlite` (in OpenNest.Training). ## Architecture @@ -63,9 +65,10 @@ File I/O and format conversion. Uses ACadSharp for DXF/DWG support. - `Extensions` — conversion helpers between ACadSharp and OpenNest geometry types. - `CadImporter` — shared "DXF → Drawing" service used by the UI, console, MCP, API, and training projects. Two-stage API: `Import(path, options)` loads raw entities, runs bend detection, and returns a mutable `CadImportResult`; `BuildDrawing(result, visible, bends, quantity, customer, editedProgram)` produces a fully-populated `Drawing` with `Source.Offset`, `SourceEntities`, `SuppressedEntityIds`, and bends. `ImportDrawing(path, options)` composes both stages for headless callers. - `CadImportOptions`, `CadImportResult` — inputs and intermediate state for `CadImporter`. +- `Bending/BendRepair` — conservative opt-in repair configured by `CadImportOptions.BendRepair`. Requires explicit inches/mm source units and an endpoint movement limit above 0.001 and at most 3.175 physical mm. Only unambiguous paired ETCH/SCRIBE ticks may move along the existing bend axis; cut geometry and unrelated marks must remain unchanged. Opt-in imports preserve source marks without blanket etch regeneration and expose per-bend outcomes in `CadImportResult.BendRepairReports`. ### OpenNest.Console (console app, depends on Core + Engine + IO) -Command-line interface for batch nesting. Supports DXF import, plate configuration, linear fill, and NFP-based auto-nesting (`--autonest`). +Command-line interface for batch nesting (`net8.0`). Supports DXF import, plate configuration, linear fill, and NFP-based auto-nesting (`--autonest`). `--repair-bends-mm --cad-units inches|mm` opts newly imported DXFs into conservative bend repair and prints per-bend reports; it does not rescale coordinates or repair saved nests. ### OpenNest.Gpu (class library, depends on Core + Engine) GPU-accelerated pair evaluation for best-fit nesting. `GpuPairEvaluator` implements `IPairEvaluator`, `GpuSlideComputer` implements `ISlideComputer`, and `PartBitmap` handles rasterization. `GpuEvaluatorFactory` provides factory methods. @@ -133,5 +136,5 @@ 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. - **GravographIS engrave/cut passes**: The `OpenNest.Posts.GravographIS` post splits geometry by `LayerType` into ordered tool passes — engrave (`Scribe`) then cut (`Cut`/`Leadin`/`Leadout`); `Display` is skipped. `ConvertGeometry` tags DXF layers `ENGRAVE`/`ETCH` (lines, arcs, circles) as `Scribe`; the layer round-trips through `.nest` via `NestWriter`/`ProgramReader`. `NestPolylineExtractor.ExtractLayered` carries `LayerType` per polyline (splitting a continuous chain at any layer change); `GravographISPostProcessor.BuildPasses` groups them and `GravographISWriter.Write(IReadOnlyList, …)` emits each pass at its own feed/depth, parking to origin and emitting an operator pause (motor off → aux off → `LB` console message → motor on) before any pass whose config has `PauseBefore`. Per-pass parameters live in `GravographISPostConfig` (an `IConfigurablePostProcessor` config with `Engrave`/`Cut` `LayerCutConfig` blocks), edited in the shared `PostProcessorConfigForm` PropertyGrid and persisted to JSON. The cut block pauses by default so the operator can swap/adjust the tool (the spring-floated spindle means programmed `DZ` depth is not the real cut depth). diff --git a/OpenNest.Benchmark/BenchmarkJob.cs b/OpenNest.Benchmark/BenchmarkJob.cs index c3a4140..9704114 100644 --- a/OpenNest.Benchmark/BenchmarkJob.cs +++ b/OpenNest.Benchmark/BenchmarkJob.cs @@ -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. /// - 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)); } } } diff --git a/OpenNest.Benchmark/BenchmarkRunner.cs b/OpenNest.Benchmark/BenchmarkRunner.cs index 0273782..133a6a9 100644 --- a/OpenNest.Benchmark/BenchmarkRunner.cs +++ b/OpenNest.Benchmark/BenchmarkRunner.cs @@ -23,7 +23,8 @@ namespace OpenNest.Benchmark /// Wall-clock budget for one engine solving one job. private static readonly TimeSpan SolveTimeout = TimeSpan.FromMinutes(5); - public static List Run(List jobs, IReadOnlyList engines) + public static List Run(List jobs, IReadOnlyList engines, + double salvageRate = 0, double minimumSalvageDimension = 0, string outputDirectory = null) { var results = new List(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 diff --git a/OpenNest.Benchmark/OpenNest.Benchmark.csproj b/OpenNest.Benchmark/OpenNest.Benchmark.csproj index ccbddae..c98f945 100644 --- a/OpenNest.Benchmark/OpenNest.Benchmark.csproj +++ b/OpenNest.Benchmark/OpenNest.Benchmark.csproj @@ -1,7 +1,7 @@ Exe - net8.0-windows + net8.0 OpenNest.Benchmark OpenNest.Benchmark disable diff --git a/OpenNest.Benchmark/Program.cs b/OpenNest.Benchmark/Program.cs index f8e5664..0b1ded8 100644 --- a/OpenNest.Benchmark/Program.cs +++ b/OpenNest.Benchmark/Program.cs @@ -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 Override part spacing for every job"); Console.Error.WriteLine(" --engines Name1,Name2,... Only benchmark these registered engines (default: all)"); Console.Error.WriteLine(" --csv 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 Both offcut dimensions must qualify; 0 disables credit"); + Console.Error.WriteLine(" --output 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 EngineNames = new(); public string CsvPath; + public string OutputDirectory; + public double SalvageRate; + public double MinimumSalvageDimension; } } diff --git a/OpenNest.Console/OpenNest.Console.csproj b/OpenNest.Console/OpenNest.Console.csproj index dfb174b..b4c996a 100644 --- a/OpenNest.Console/OpenNest.Console.csproj +++ b/OpenNest.Console/OpenNest.Console.csproj @@ -1,7 +1,7 @@ Exe - net8.0-windows + net8.0 OpenNest.Console OpenNest.Console $(DefineConstants);DEBUG;TRACE diff --git a/OpenNest.Console/Program.cs b/OpenNest.Console/Program.cs index d3bc9e5..33917c3 100644 --- a/OpenNest.Console/Program.cs +++ b/OpenNest.Console/Program.cs @@ -1,6 +1,8 @@ using OpenNest; using OpenNest.Geometry; using OpenNest.IO; +using OpenNest.IO.Bending; +using System.Globalization; using System; using System.Collections.Generic; using System.Diagnostics; @@ -20,6 +22,14 @@ static class NestConsole if (options == null) return 0; // --help was requested + if (options.RepairBendsMillimeters.HasValue && + (options.CadUnits == BendRepairUnits.Unspecified || !double.IsFinite(options.RepairBendsMillimeters.Value) + || options.RepairBendsMillimeters <= 0.001 || options.RepairBendsMillimeters > 3.175)) + { + Console.Error.WriteLine("Error: --repair-bends-mm requires a limit > 0.001 and <= 3.175 mm and --cad-units inches|mm."); + return 1; + } + if (options.ListPosts) { ListPostProcessors(options); @@ -82,6 +92,12 @@ static class NestConsole { switch (args[i]) { + case "--repair-bends-mm": + o.RepairBendsMillimeters = i + 1 < args.Length && double.TryParse(args[++i], NumberStyles.Float, CultureInfo.InvariantCulture, out var limit) ? limit : double.NaN; + break; + case "--cad-units" when i + 1 < args.Length: + o.CadUnits = args[++i] switch { "inches" => BendRepairUnits.Inches, "mm" => BendRepairUnits.Millimeters, _ => BendRepairUnits.Unspecified }; + break; case "--drawing" when i + 1 < args.Length: o.DrawingName = args[++i]; break; @@ -173,7 +189,7 @@ static class NestConsole foreach (var dxf in dxfFiles) { - var drawing = ImportDxf(dxf); + var drawing = ImportDxf(dxf, options); if (drawing == null) return null; @@ -204,7 +220,7 @@ static class NestConsole foreach (var dxf in dxfFiles) { - var drawing = ImportDxf(dxf); + var drawing = ImportDxf(dxf, options); if (drawing == null) return null; @@ -216,11 +232,21 @@ static class NestConsole return newNest; } - static Drawing ImportDxf(string path) + static Drawing ImportDxf(string path, Options options) { try { - return CadImporter.ImportDrawing(path); + var result = CadImporter.Import(path, new CadImportOptions + { + BendRepair = options.RepairBendsMillimeters.HasValue ? new BendRepairOptions + { + DrawingUnits = options.CadUnits, + MaxEndpointMovementMillimeters = options.RepairBendsMillimeters.Value + } : null + }); + foreach (var report in result.BendRepairReports) + Console.WriteLine($"Bend repair {Path.GetFileName(path)} #{report.BendIndex + 1}: {report.Status}: {report.Reason} ({report.OriginalStart} -> {report.Start}; {report.OriginalEnd} -> {report.End})"); + return CadImporter.BuildDrawing(result, result.Entities, result.Bends, 1, null, null); } catch (System.Exception ex) { @@ -470,6 +496,8 @@ static class NestConsole Console.Error.WriteLine(" Load nest and add imported DXF drawings"); Console.Error.WriteLine(); Console.Error.WriteLine("Options:"); + Console.Error.WriteLine(" --repair-bends-mm Opt-in endpoint/tick repair, limit >0.001 to 3.175 physical mm"); + Console.Error.WriteLine(" --cad-units inches|mm Explicit source coordinate units required for bend repair"); Console.Error.WriteLine(" --drawing Drawing name to fill with (default: first drawing)"); Console.Error.WriteLine(" --plate Plate index to fill (default: 0)"); Console.Error.WriteLine(" --quantity Max parts to place (default: 0 = unlimited)"); @@ -506,5 +534,7 @@ static class NestConsole public string PostOutput; public string PostsDir; public bool ListPosts; + public double? RepairBendsMillimeters; + public BendRepairUnits CadUnits; } } diff --git a/OpenNest.Core/Geometry/Collision.cs b/OpenNest.Core/Geometry/Collision.cs index 956f73d..d48ecf5 100644 --- a/OpenNest.Core/Geometry/Collision.cs +++ b/OpenNest.Core/Geometry/Collision.cs @@ -286,8 +286,10 @@ namespace OpenNest.Geometry } /// - /// 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. /// private static List SubtractTriangles(Polygon region, List holeTris) { @@ -295,29 +297,25 @@ namespace OpenNest.Geometry foreach (var holeTri in holeTris) { - if (!BoundingBoxesOverlap(region.BoundingBox, holeTri.BoundingBox)) - continue; - var next = new List(); 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; } + + /// + /// Sutherland-Hodgman clip of a convex polygon to the strict outside of the + /// infinite line edgeStart->edgeEnd of a CCW hole edge (Cross < -Epsilon). + /// + private static List ClipOutsideHalfSpace(Polygon piece, Vector edgeStart, Vector edgeEnd) + { + var verts = piece.Vertices; + var count = piece.IsClosed() ? verts.Count - 1 : verts.Count; + var kept = new List(); + 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 polygons, List 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; + } } } diff --git a/OpenNest.Engine.Tests/Jobs/NestJobValidationTests.cs b/OpenNest.Engine.Tests/Jobs/NestJobValidationTests.cs index 2f6a433..2d751cb 100644 --- a/OpenNest.Engine.Tests/Jobs/NestJobValidationTests.cs +++ b/OpenNest.Engine.Tests/Jobs/NestJobValidationTests.cs @@ -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(() => 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() { diff --git a/OpenNest.Engine.Tests/Jobs/StockLadderTests.cs b/OpenNest.Engine.Tests/Jobs/StockLadderTests.cs new file mode 100644 index 0000000..a44aa27 --- /dev/null +++ b/OpenNest.Engine.Tests/Jobs/StockLadderTests.cs @@ -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()); + })); + Assert.Throws(() => engine.Solve(job, token: cts.Token)); + Assert.Throws(() => 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(() => engine.Solve(job)); + // Direct full-demand overlap check, not masked by the single-part feasibility probe limit. + Assert.Throws(() => NestJobValidator.ValidateCandidate( + new PlateCandidate(new[] { new NestJobPlacement("a", 0, 0, 0, 0), new NestJobPlacement("a", 1, 1, 1, 0) }), + job.Plates[0], new Dictionary { ["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(() => new NestJobOptions(salvageRate: double.NaN)); + Assert.Throws(() => 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(() => 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 callback) : IPlateNester + { + public PlateCandidate Place(PlatePlacementRequest request, IProgress? progress = null, + CancellationToken token = default) => callback(request); + } +} diff --git a/OpenNest.Engine/Jobs/NestJobOptions.cs b/OpenNest.Engine/Jobs/NestJobOptions.cs index 9013a8e..b9449c4 100644 --- a/OpenNest.Engine/Jobs/NestJobOptions.cs +++ b/OpenNest.Engine/Jobs/NestJobOptions.cs @@ -5,14 +5,27 @@ namespace OpenNest; /// Immutable per-job options; selection never changes the legacy global registry. 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; } + /// Fraction of eligible edge-offcut area credited by StockLadder (0..1). + public double SalvageRate { get; } + /// Both offcut dimensions must meet this caller-supplied minimum in job units. + /// Zero disables credit; scraps and holes are never credited. + public double MinimumSalvageDimension { get; } + public string PlacementStrategy { get; } /// Maximum physical sheets to commit, or null for no explicit cap. public int? MaxPlates { get; } diff --git a/OpenNest.Engine/Jobs/NestJobPlacementValidator.cs b/OpenNest.Engine/Jobs/NestJobPlacementValidator.cs index e50993a..d119b3b 100644 --- a/OpenNest.Engine/Jobs/NestJobPlacementValidator.cs +++ b/OpenNest.Engine/Jobs/NestJobPlacementValidator.cs @@ -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(); + var marks = new List(); 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()); + 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 holes) + { + const double chordTolerance = 0.00001; + var boundaries = new List { 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 { 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 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 { 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; - } - - /// - /// 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. - /// - private static IEnumerable 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)); } /// @@ -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; diff --git a/OpenNest.Engine/Jobs/NestJobValidator.cs b/OpenNest.Engine/Jobs/NestJobValidator.cs index e4104bf..bf2191a 100644 --- a/OpenNest.Engine/Jobs/NestJobValidator.cs +++ b/OpenNest.Engine/Jobs/NestJobValidator.cs @@ -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); } } } diff --git a/OpenNest.Engine/Jobs/NestingEngineRegistry.cs b/OpenNest.Engine/Jobs/NestingEngineRegistry.cs index 2403c5a..c71d045 100644 --- a/OpenNest.Engine/Jobs/NestingEngineRegistry.cs +++ b/OpenNest.Engine/Jobs/NestingEngineRegistry.cs @@ -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")); diff --git a/OpenNest.Engine/Jobs/Placement/OrderedPlateNester.cs b/OpenNest.Engine/Jobs/Placement/OrderedPlateNester.cs new file mode 100644 index 0000000..c8476c7 --- /dev/null +++ b/OpenNest.Engine/Jobs/Placement/OrderedPlateNester.cs @@ -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; + +/// Constrained-order linear fills in conservative rectangular free regions. +/// Regions are only search hints; every accepted pose passes the job geometry validator. +internal sealed class OrderedPlateNester : IPlateNester +{ + private readonly Dictionary drawings = new(StringComparer.Ordinal); + + public PlateCandidate Place(PlatePlacementRequest request, IProgress progress = null, + CancellationToken token = default) + { + var work = DrawingJobMapper.CreatePlate(request.Stock).WorkArea(); + var poses = new List(); + var obstacles = new List(); + 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 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 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; + } + } +} diff --git a/OpenNest.Engine/Jobs/StockLadderNestingEngine.cs b/OpenNest.Engine/Jobs/StockLadderNestingEngine.cs new file mode 100644 index 0000000..2adcae4 --- /dev/null +++ b/OpenNest.Engine/Jobs/StockLadderNestingEngine.cs @@ -0,0 +1,195 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; + +namespace OpenNest; + +/// +/// 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. +/// +public sealed class StockLadderNestingEngine : INestingEngine +{ + private readonly Func factory; + public StockLadderNestingEngine() : this(() => new OrderedPlateNester()) { } + public StockLadderNestingEngine(Func factory) => + this.factory = factory ?? throw new ArgumentNullException(nameof(factory)); + + public NestJobResult Solve(NestJob job, IProgress 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(); + var feasible = job.Parts.ToDictionary(p => p.Id, _ => new HashSet()); + + // 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 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); + + /// 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. + 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; + } +} diff --git a/OpenNest.IO.Tests/BendRepairImportTests.cs b/OpenNest.IO.Tests/BendRepairImportTests.cs new file mode 100644 index 0000000..4ac260a --- /dev/null +++ b/OpenNest.IO.Tests/BendRepairImportTests.cs @@ -0,0 +1,107 @@ +using ACadSharp; +using ACadSharp.IO; +using CSMath; +using OpenNest.Geometry; +using OpenNest.IO.Bending; +using CadLine = ACadSharp.Entities.Line; +using CadLayer = ACadSharp.Tables.Layer; + +namespace OpenNest.IO.Tests; + +public class BendRepairImportTests +{ + [Theory] + [InlineData("ETCH", false, false, "Repaired")] + [InlineData("SCRIBE", false, false, "Repaired")] + [InlineData("ETCH", true, false, "Skipped")] + [InlineData("ETCH", false, true, "Skipped")] + public void ImportPreservesMarksAndHonorsAmbiguityAndHeader(string layer, bool duplicate, bool conflict, string status) + { + var doc = Fixture(layer); + if (duplicate) doc.Entities.Add(new CadLine(new XYZ(0.05, 5, 0), new XYZ(0.55, 5, 0)) { Layer = new CadLayer(layer) }); + if (conflict) doc.Header.InsUnits = ACadSharp.Types.Units.UnitsType.Millimeters; + WithFile(doc, path => + { + var raw = Dxf.Import(path, preserveRepairMarks: true); + var result = CadImporter.Import(path, new CadImportOptions { BendRepair = Options() }); + Assert.Equal(status, Assert.Single(result.BendRepairReports).Status); + Assert.Equal(raw.Entities.Count, result.Entities.Count); + Assert.Equal(Signatures(raw.Entities.Where(e => e.Layer.Name == "0")), Signatures(result.Entities.Where(e => e.Layer.Name == "0"))); + Assert.Contains(result.Entities.OfType(), l => l.StartPoint == new Vector(4, 2) && l.EndPoint == new Vector(4, 3)); + if (status == "Skipped") Assert.Equal(Signatures(raw.Entities), Signatures(result.Entities)); + else + { + Assert.Equal(new Vector(0, 5), result.Bends[0].StartPoint); + Assert.Equal(new Vector(10, 5), result.Bends[0].EndPoint); + Assert.Equal("Unchanged", Assert.Single(BendRepair.Apply(result.Entities, result.Bends, Options())).Status); + } + Assert.Empty(CadImporter.Import(path).BendRepairReports); + var disabled = CadImporter.Import(path, new CadImportOptions { DetectBends = false, BendRepair = Options() }); + Assert.Empty(disabled.Bends); + Assert.Equal(Signatures(raw.Entities), Signatures(disabled.Entities)); + }); + } + + [Fact] + public void UnitlessHeaderRequiresExplicitCallerUnits() + { + var doc = Fixture("ETCH"); + doc.Header.InsUnits = ACadSharp.Types.Units.UnitsType.Unitless; + WithFile(doc, path => + { + var configured = CadImporter.Import(path, new CadImportOptions { BendRepair = Options() }); + Assert.Equal("Repaired", Assert.Single(configured.BendRepairReports).Status); + var unspecified = CadImporter.Import(path, new CadImportOptions { BendRepair = new BendRepairOptions { MaxEndpointMovementMillimeters = 2 } }); + Assert.Equal("Skipped", Assert.Single(unspecified.BendRepairReports).Status); + Assert.Equal(Signatures(Dxf.Import(path, true).Entities), Signatures(unspecified.Entities)); + }); + } + + [Fact] + public void MarkCircleDoesNotDeduplicateCutCircle() + { + var doc = Fixture("SCRIBE"); + doc.Entities.Add(new ACadSharp.Entities.Circle { Center = new XYZ(2, 2, 0), Radius = 0.2, Layer = new CadLayer("SCRIBE") }); + doc.Entities.Add(new ACadSharp.Entities.Circle { Center = new XYZ(2, 2, 0), Radius = 0.2 }); + WithFile(doc, path => + { + var result = CadImporter.Import(path, new CadImportOptions { BendRepair = Options() }); + Assert.Equal(2, result.Entities.OfType().Count()); + Assert.Single(result.Entities.OfType().Where(e => e.Layer.Name == "0")); + Assert.Single(result.Entities.OfType().Where(e => e.Layer.Name == "SCRIBE")); + }); + } + + private static BendRepairOptions Options() => new() { DrawingUnits = BendRepairUnits.Inches, MaxEndpointMovementMillimeters = 2 }; + + private static CadDocument Fixture(string layer) + { + var doc = new CadDocument(); + doc.Header.InsUnits = ACadSharp.Types.Units.UnitsType.Inches; + foreach (var line in new[] { + new CadLine(new XYZ(0, 0, 0), new XYZ(10, 0, 0)), + new CadLine(new XYZ(10, 0, 0), new XYZ(10, 10, 0)), + new CadLine(new XYZ(10, 10, 0), new XYZ(0, 10, 0)), + new CadLine(new XYZ(0, 10, 0), new XYZ(0, 0, 0)), + new CadLine(new XYZ(0.05, 5, 0), new XYZ(0.55, 5, 0)) { Layer = new CadLayer(layer) }, + new CadLine(new XYZ(9.45, 5, 0), new XYZ(9.95, 5, 0)) { Layer = new CadLayer(layer) }, + new CadLine(new XYZ(4, 2, 0), new XYZ(4, 3, 0)) { Layer = new CadLayer(layer) }, + new CadLine(new XYZ(0.05, 5, 0), new XYZ(9.95, 5, 0)) { Layer = new CadLayer("BEND"), LineType = new ACadSharp.Tables.LineType("CENTER") } + }) doc.Entities.Add(line); + return doc; + } + + private static string[] Signatures(IEnumerable entities) => entities.OfType() + .Select(l => $"{l.Layer.Name}:{l.StartPoint}:{l.EndPoint}:{l.LineTypeName}").Order().ToArray(); + + private static void WithFile(CadDocument doc, Action action) + { + var path = Path.Combine(Path.GetTempPath(), $"opennest-repair-{Guid.NewGuid()}.dxf"); + try + { + DxfWriter.Write(path, doc, false); + action(path); + } + finally { File.Delete(path); } + } +} diff --git a/OpenNest.IO.Tests/BendRepairTests.cs b/OpenNest.IO.Tests/BendRepairTests.cs new file mode 100644 index 0000000..754c0e6 --- /dev/null +++ b/OpenNest.IO.Tests/BendRepairTests.cs @@ -0,0 +1,154 @@ +using OpenNest.Bending; +using OpenNest.Geometry; +using OpenNest.IO.Bending; + +namespace OpenNest.IO.Tests; + +public class BendRepairTests +{ + private static BendRepairOptions Options(BendRepairUnits units = BendRepairUnits.Inches, double limit = 2) => + new() { DrawingUnits = units, MaxEndpointMovementMillimeters = limit }; + + private static (List entities, List bends) Fixture(double start = 0.05, double end = 9.95) + { + var entities = new List + { + new Line(new Vector(0, 0), new Vector(10, 0)), + new Line(new Vector(10, 0), new Vector(10, 10)), + new Line(new Vector(10, 10), new Vector(0, 10)), + new Line(new Vector(0, 10), new Vector(0, 0)), + Mark(start, 5, start + 0.5, 5), Mark(end - 0.5, 5, end, 5), + Mark(4, 2, 4, 3) + }; + return (entities, new List { new() { StartPoint = new Vector(start, 5), EndPoint = new Vector(end, 5), Direction = BendDirection.Up } }); + } + + private static Line Mark(double x, double y, double x2, double y2) => + new(new Vector(x, y), new Vector(x2, y2)) { Layer = new Layer("SCRIBE") { IsVisible = true } }; + + [Theory] + [InlineData(0.05, 9.95)] + [InlineData(-0.05, 10.05)] + [InlineData(0, 9.95)] + public void RepairsAlongAxisPreservingCutAndUnrelatedMarksAndIsIdempotent(double start, double end) + { + var (entities, bends) = Fixture(start, end); + var originals = entities.ToArray(); + var cutPoints = entities.Take(4).Cast().Select(l => (l.StartPoint, l.EndPoint)).ToArray(); + var report = Assert.Single(BendRepair.Apply(entities, bends, Options())); + Assert.Equal("Repaired", report.Status); + Assert.Equal(new Vector(0, 5), bends[0].StartPoint); + Assert.Equal(new Vector(10, 5), bends[0].EndPoint); + for (var i = 0; i < 4; i++) Assert.Same(originals[i], entities[i]); + Assert.Equal(cutPoints, entities.Take(4).Cast().Select(l => (l.StartPoint, l.EndPoint)).ToArray()); + Assert.Same(originals[6], entities[6]); + Assert.Equal(new Vector(0, 5), ((Line)entities[4]).StartPoint); + Assert.Equal(new Vector(10, 5), ((Line)entities[5]).EndPoint); + Assert.Equal(0.5, entities[4].Length, 8); + var after = entities.ToArray(); + Assert.Equal("Unchanged", Assert.Single(BendRepair.Apply(entities, bends, Options())).Status); + Assert.Equal(after, entities); + } + + [Fact] + public void OneInchTicksAreAcceptedAtPhysicalLengthCap() + { + var (entities, bends) = Fixture(); + entities[4] = Mark(0.05, 5, 1.05, 5); + entities[5] = Mark(8.95, 5, 9.95, 5); + Assert.Equal("Repaired", Assert.Single(BendRepair.Apply(entities, bends, Options())).Status); + Assert.Equal("Unchanged", Assert.Single(BendRepair.Apply(entities, bends, Options())).Status); + } + + [Fact] + public void MillimeterCoordinatesUseSamePhysicalLimit() + { + var (entities, bends) = Fixture(); + foreach (var entity in entities) entity.Scale(25.4); + bends[0].StartPoint *= 25.4; + bends[0].EndPoint *= 25.4; + Assert.Equal("Repaired", Assert.Single(BendRepair.Apply(entities, bends, Options(BendRepairUnits.Millimeters))).Status); + Assert.Equal(254, bends[0].EndPoint.X, 8); + } + + [Fact] + public void RotatedAxisIsNotRotatedByRepair() + { + var (entities, bends) = Fixture(); + foreach (var entity in entities) entity.Rotate(0.7); + var axis = bends[0].ToLine(); + axis.Rotate(0.7); + bends[0].StartPoint = axis.StartPoint; + bends[0].EndPoint = axis.EndPoint; + Assert.Equal("Repaired", Assert.Single(BendRepair.Apply(entities, bends, Options())).Status); + Assert.Equal(0.7, bends[0].LineAngle, 8); + Assert.Equal(10, bends[0].Length, 8); + } + + [Theory] + [InlineData("missing")] + [InlineData("duplicate")] + [InlineData("perpendicular")] + [InlineData("offset")] + [InlineData("excessive")] + [InlineData("open")] + [InlineData("hole")] + [InlineData("shared")] + [InlineData("unknown-layer")] + [InlineData("cut-tick")] + [InlineData("nonfinite")] + public void SafetyFailuresAreAtomic(string failure) + { + var (entities, bends) = Fixture(); + switch (failure) + { + case "missing": entities.RemoveAt(5); break; + case "duplicate": entities.Add(entities[4].Clone()); break; + case "perpendicular": entities[5] = Mark(9.95, 5, 9.95, 5.5); break; + case "offset": entities[5].Offset(0, 0.01); break; + case "excessive": bends[0].EndPoint = new Vector(9, 5); entities[5] = Mark(8.5, 5, 9, 5); break; + case "open": entities.RemoveAt(0); break; + case "hole": entities.Add(new Circle(new Vector(5, 5), 1)); break; + case "shared": bends.Add(new Bend { StartPoint = bends[0].StartPoint, EndPoint = bends[0].EndPoint }); break; + case "unknown-layer": foreach (var e in entities.Take(4)) e.Layer = new Layer("UNKNOWN"); break; + case "cut-tick": entities[5].Layer = Layer.Default; break; + case "nonfinite": bends[0].StartPoint = new Vector(double.NaN, 5); break; + } + var before = entities.ToArray(); + var start = bends[0].StartPoint; + var end = bends[0].EndPoint; + var reports = BendRepair.Apply(entities, bends, Options()); + Assert.All(reports, r => Assert.Equal("Skipped", r.Status)); + Assert.Equal(before, entities); + Assert.Equal(start.X, bends[0].StartPoint.X); + Assert.Equal(start.Y, bends[0].StartPoint.Y); + Assert.Equal(end, bends[0].EndPoint); + } + + [Theory] + [InlineData(BendRepairUnits.Unspecified, 2)] + [InlineData(BendRepairUnits.Inches, 0)] + [InlineData(BendRepairUnits.Inches, -1)] + [InlineData(BendRepairUnits.Inches, 3.176)] + [InlineData(BendRepairUnits.Inches, double.NaN)] + [InlineData(BendRepairUnits.Inches, double.PositiveInfinity)] + public void InvalidConfigurationDoesNotMutate(BendRepairUnits units, double limit) + { + var (entities, bends) = Fixture(); + var before = entities.ToArray(); + Assert.Equal("Skipped", Assert.Single(BendRepair.Apply(entities, bends, Options(units, limit))).Status); + Assert.Equal(before, entities); + Assert.Equal(0.05, bends[0].StartPoint.X); + } + + [Fact] + public void DefaultsOff() + { + Assert.Null(CadImportOptions.Default.BendRepair); + var (entities, bends) = Fixture(); + var before = entities.ToArray(); + Assert.Empty(BendRepair.Apply(entities, bends, null)); + Assert.Equal(before, entities); + Assert.Equal(0.05, bends[0].StartPoint.X); + } +} diff --git a/OpenNest.IO.Tests/OpenNest.IO.Tests.csproj b/OpenNest.IO.Tests/OpenNest.IO.Tests.csproj new file mode 100644 index 0000000..4d96dc0 --- /dev/null +++ b/OpenNest.IO.Tests/OpenNest.IO.Tests.csproj @@ -0,0 +1,16 @@ + + + net8.0 + enable + enable + false + true + + + + + + + + + diff --git a/OpenNest.IO/Bending/BendRepair.cs b/OpenNest.IO/Bending/BendRepair.cs new file mode 100644 index 0000000..9c96ccc --- /dev/null +++ b/OpenNest.IO/Bending/BendRepair.cs @@ -0,0 +1,267 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using OpenNest.Bending; +using OpenNest.Geometry; + +namespace OpenNest.IO.Bending +{ + public enum BendRepairUnits + { + Unspecified, + Inches, + Millimeters, + } + + /// Explicit opt-in. Distances are physical millimeters, not drawing coordinates. + public sealed class BendRepairOptions + { + public BendRepairUnits DrawingUnits { get; set; } + public double MaxEndpointMovementMillimeters { get; set; } + } + + public sealed record BendRepairReport( + int BendIndex, + string Status, + string Reason, + Vector OriginalStart, + Vector OriginalEnd, + Vector Start, + Vector End + ); + + /// Conservative, atomic repair. Never edits cut entities or unassociated marks. + public static class BendRepair + { + public static List Apply( + List entities, + List bends, + BendRepairOptions options + ) + { + var reports = new List(); + if (options == null) + return reports; + var scale = options.DrawingUnits == BendRepairUnits.Inches ? 1 / 25.4 : 1.0; + var tolerance = 0.001 * scale; + var limit = options.MaxEndpointMovementMillimeters * scale; + var valid = + ( + options.DrawingUnits == BendRepairUnits.Inches + || options.DrawingUnits == BendRepairUnits.Millimeters + ) + && double.IsFinite(limit) + && limit > tolerance + && options.MaxEndpointMovementMillimeters <= 3.175; + var original = bends.Select(b => (b.StartPoint, b.EndPoint)).ToArray(); + var marks = entities.OfType().Where(IsMark).ToList(); + var cuts = entities.Where(IsCut).ToList(); + // ShapeBuilder may reverse/weld its inputs; isolate it from all source geometry. + var shapes = ShapeBuilder.GetShapes(cuts.CloneAll(), tolerance); + var boundaries = shapes.Where(s => s.IsClosed()).SelectMany(s => s.Entities).ToList(); + var polygons = shapes + .Where(s => s.IsClosed()) + .Select(s => s.ToPolygonWithTolerance(tolerance / 10)) + .ToList(); + bool InMaterial(Vector point) => polygons.Count(p => p.ContainsPoint(point)) % 2 == 1; + + for (var i = 0; i < bends.Count; i++) + { + var bend = bends[i]; + var (start, end) = original[i]; + var reason = ""; + var status = "Skipped"; + if (!valid) + reason = + "Specify drawing units and a finite movement limit above 0.001 and at most 3.175 mm."; + else if (!Finite(start) || !Finite(end) || start.DistanceTo(end) <= 2 * limit) + reason = "Invalid or too-short bend axis."; + else + { + var axis = (end - start) / start.DistanceTo(end); + var first = marks + .Where(m => Associated(m, start, end, tolerance, scale)) + .ToList(); + var last = marks + .Where(m => Associated(m, end, start, tolerance, scale)) + .ToList(); + if (first.Count != 1 || last.Count != 1 || ReferenceEquals(first[0], last[0])) + reason = "Missing or ambiguous collinear ticks at both original endpoints."; + else if ( + original + .Where((_, j) => j != i) + .Any(b => + Associated(first[0], b.StartPoint, b.EndPoint, tolerance, scale) + || Associated(first[0], b.EndPoint, b.StartPoint, tolerance, scale) + || Associated(last[0], b.StartPoint, b.EndPoint, tolerance, scale) + || Associated(last[0], b.EndPoint, b.StartPoint, tolerance, scale) + ) + ) + reason = "Tick is shared with another bend."; + else + { + var probe = new Line(start - axis * limit, end + axis * limit); + var hits = new List(); + var uncertain = shapes + .Where(s => !s.IsClosed()) + .Any(s => s.Intersects(probe)); + foreach (var edge in boundaries) + { + if ( + edge is Line line + && OnAxis(line.StartPoint, start, axis, tolerance) + && OnAxis(line.EndPoint, start, axis, tolerance) + && System.Math.Max( + Dot(line.StartPoint - start, axis), + Dot(line.EndPoint - start, axis) + ) >= -limit + && System.Math.Min( + Dot(line.StartPoint - start, axis), + Dot(line.EndPoint - start, axis) + ) + <= bend.Length + limit + ) + uncertain = true; + if (edge.Intersects(probe, out var points)) + foreach (var p in points) + if (Finite(p) && !hits.Any(h => h.DistanceTo(p) <= tolerance)) + hits.Add(p); + } + var nearStart = hits.Where(p => p.DistanceTo(start) <= limit).ToList(); + var nearEnd = hits.Where(p => p.DistanceTo(end) <= limit).ToList(); + if ( + uncertain + || hits.Count != 2 + || nearStart.Count != 1 + || nearEnd.Count != 1 + ) + reason = + "Missing/ambiguous closed cut boundaries, interior crossing, or movement exceeds limit."; + else + { + // Project the intersection back onto the existing axis; never rotate a bend. + var newStart = start + axis * Dot(nearStart[0] - start, axis); + var newEnd = start + axis * Dot(nearEnd[0] - start, axis); + var sample = axis * (10 * tolerance); + if ( + !InMaterial((newStart + newEnd) / 2) + || !InMaterial(newStart + sample) + || !InMaterial(newEnd - sample) + || InMaterial(newStart - sample) + || InMaterial(newEnd + sample) + ) + reason = + "Endpoints do not bound an unambiguous material interval (possible tangent)."; + else if ( + Dot(newEnd - newStart, axis) + <= first[0].Length + last[0].Length + tolerance + ) + reason = "Repaired ticks would overlap or reverse the bend."; + else if ( + newStart.DistanceTo(start) <= tolerance + && newEnd.DistanceTo(end) <= tolerance + ) + { + status = "Unchanged"; + reason = "Already on cut boundaries."; + } + else + { + // Prepare both replacements before committing either endpoint or entity list. + var a = (Line)first[0].Clone(); + var b = (Line)last[0].Clone(); + a.Offset(newStart - start); + b.Offset(newEnd - end); + var ai = entities.IndexOf(first[0]); + var bi = entities.IndexOf(last[0]); + if (ai < 0 || bi < 0) + reason = "Tick was already consumed; unchanged."; + else + { + entities[ai] = a; + entities[bi] = b; + bend.StartPoint = newStart; + bend.EndPoint = newEnd; + status = "Repaired"; + reason = + "Both endpoints snapped along axis; only their two associated ticks replaced."; + } + } + } + } + } + reports.Add( + new BendRepairReport( + i, + status, + reason, + start, + end, + bend.StartPoint, + bend.EndPoint + ) + ); + } + return reports; + } + + private static bool Finite(Vector p) => double.IsFinite(p.X) && double.IsFinite(p.Y); + + private static double Dot(Vector a, Vector b) => a.X * b.X + a.Y * b.Y; + + private static bool OnAxis(Vector p, Vector origin, Vector axis, double tolerance) => + System.Math.Abs((p.X - origin.X) * axis.Y - (p.Y - origin.Y) * axis.X) <= tolerance; + + private static bool Continuous(Entity e) => + string.IsNullOrEmpty(e.LineTypeName) + || string.Equals(e.LineTypeName, "Continuous", StringComparison.OrdinalIgnoreCase) + || string.Equals(e.LineTypeName, "ByLayer", StringComparison.OrdinalIgnoreCase); + + private static bool IsMark(Entity e) => + Continuous(e) + && ( + string.Equals(e.Layer?.Name, "ETCH", StringComparison.OrdinalIgnoreCase) + || string.Equals(e.Layer?.Name, "SCRIBE", StringComparison.OrdinalIgnoreCase) + ); + + private static bool IsCut(Entity e) => + Continuous(e) + && ( + e.Layer?.Name == "0" + || string.Equals(e.Layer?.Name, "CUT", StringComparison.OrdinalIgnoreCase) + ) + && (e is Line || e is Arc || e is Circle); + + private static bool Associated( + Line mark, + Vector endpoint, + Vector other, + double tolerance, + double scale + ) + { + var length = endpoint.DistanceTo(other); + if ( + !Finite(endpoint) + || !Finite(other) + || length <= tolerance + || !Finite(mark.StartPoint) + || !Finite(mark.EndPoint) + || mark.Length <= tolerance + || mark.Length > 25.4 * scale + tolerance + || mark.Length >= length / 3 + ) + return false; + var axis = (other - endpoint) / length; + if ( + !OnAxis(mark.StartPoint, endpoint, axis, tolerance) + || !OnAxis(mark.EndPoint, endpoint, axis, tolerance) + ) + return false; + var a = Dot(mark.StartPoint - endpoint, axis); + var b = Dot(mark.EndPoint - endpoint, axis); + return System.Math.Abs(System.Math.Min(a, b)) <= tolerance + && System.Math.Max(a, b) > tolerance; + } + } +} diff --git a/OpenNest.IO/CadImportOptions.cs b/OpenNest.IO/CadImportOptions.cs index 48e2a58..8904712 100644 --- a/OpenNest.IO/CadImportOptions.cs +++ b/OpenNest.IO/CadImportOptions.cs @@ -16,6 +16,9 @@ namespace OpenNest.IO /// public bool DetectBends { get; set; } = true; + /// Null (default) disables repair. Explicit units and a small physical limit are required. + public Bending.BendRepairOptions BendRepair { get; set; } + /// /// Override the drawing name. Null = filename without extension. /// diff --git a/OpenNest.IO/CadImportResult.cs b/OpenNest.IO/CadImportResult.cs index f3d7555..b41013b 100644 --- a/OpenNest.IO/CadImportResult.cs +++ b/OpenNest.IO/CadImportResult.cs @@ -24,6 +24,8 @@ namespace OpenNest.IO /// public List Bends { get; set; } = new List(); + public List BendRepairReports { get; set; } = new List(); + /// /// Bounding box of at import time. May be stale /// if callers mutate ; recompute if needed. diff --git a/OpenNest.IO/CadImporter.cs b/OpenNest.IO/CadImporter.cs index 89d009f..d0a83c2 100644 --- a/OpenNest.IO/CadImporter.cs +++ b/OpenNest.IO/CadImporter.cs @@ -24,10 +24,14 @@ namespace OpenNest.IO { options ??= CadImportOptions.Default; - var dxf = Dxf.Import(path); + var dxf = Dxf.Import(path, preserveRepairMarks: options.BendRepair != null); - RemoveDuplicateArcs(dxf.Entities); - RemoveZeroSweepArcs(dxf.Entities); + var cleanup = options.BendRepair == null ? dxf.Entities : dxf.Entities + .Where(e => !IsRepairMark(e)).ToList(); + RemoveDuplicateArcs(cleanup); + RemoveZeroSweepArcs(cleanup); + if (options.BendRepair != null) + dxf.Entities.RemoveAll(e => !IsRepairMark(e) && !cleanup.Contains(e)); var bends = new List(); if (options.DetectBends && dxf.Document != null) @@ -39,12 +43,27 @@ namespace OpenNest.IO ?? new List(); } - Bend.UpdateEtchEntities(dxf.Entities, bends); + var repairReports = new List(); + if (options.BendRepair == null) + Bend.UpdateEtchEntities(dxf.Entities, bends); + else + { + // Unitless DXFs require the explicit caller declaration. Never override a conflicting header. + var headerUnits = (int)(dxf.Document?.Header.InsUnits ?? 0); + var requestedUnits = options.BendRepair.DrawingUnits == BendRepairUnits.Inches ? 1 : 4; + if (headerUnits != 0 && headerUnits != requestedUnits) + repairReports = bends.Select((b, i) => new BendRepairReport(i, "Skipped", + "DXF insertion units conflict with the declared repair units or are unsupported.", + b.StartPoint, b.EndPoint, b.StartPoint, b.EndPoint)).ToList(); + else + repairReports = BendRepair.Apply(dxf.Entities, bends, options.BendRepair); + } return new CadImportResult { Entities = dxf.Entities, Bends = bends, + BendRepairReports = repairReports, Bounds = dxf.Entities.GetBoundingBox(), SourcePath = path, Name = options.Name ?? Path.GetFileNameWithoutExtension(path), @@ -142,6 +161,10 @@ namespace OpenNest.IO return drawing; } + private static bool IsRepairMark(Entity e) => + string.Equals(e.Layer?.Name, "ETCH", System.StringComparison.OrdinalIgnoreCase) + || string.Equals(e.Layer?.Name, "SCRIBE", System.StringComparison.OrdinalIgnoreCase); + internal static void RemoveZeroSweepArcs(List entities) { entities.RemoveAll(e => diff --git a/OpenNest.IO/Dxf.cs b/OpenNest.IO/Dxf.cs index a4fe48d..3e4387d 100644 --- a/OpenNest.IO/Dxf.cs +++ b/OpenNest.IO/Dxf.cs @@ -25,13 +25,22 @@ namespace OpenNest.IO /// for bend detection. The CadDocument is NOT disposed — caller can use it for /// additional analysis (e.g., MText extraction for bend notes). /// - public static DxfImportResult Import(string path) + public static DxfImportResult Import(string path, bool preserveRepairMarks = false) { var doc = ReadDocument(path); + // Isolate marks before optimization, including cross-layer circle/arc deduplication. + var entities = preserveRepairMarks + ? ConvertEntities(doc, name => IsNonCutLayer(name) || IsRepairMarkLayer(name)) + : ConvertEntities(doc); + if (preserveRepairMarks) + { + // Keep source marks separate: optimization could merge two ticks or unrelated scribing. + entities.AddRange(ConvertEntities(doc, name => !IsRepairMarkLayer(name), optimize: false)); + } return new DxfImportResult { - Entities = ConvertEntities(doc), + Entities = entities, Document = doc }; } @@ -158,7 +167,7 @@ namespace OpenNest.IO } } - private static List ConvertEntities(CadDocument doc, Func layerFilter = null) + private static List ConvertEntities(CadDocument doc, Func layerFilter = null, bool optimize = true) { var entities = new List(); var lines = new List(); @@ -211,10 +220,13 @@ namespace OpenNest.IO } } - GeometryOptimizer.Optimize(lines); - GeometryOptimizer.Optimize(arcs); - GeometryOptimizer.Deduplicate(circles); - GeometryOptimizer.Deduplicate(circles, arcs); + if (optimize) + { + GeometryOptimizer.Optimize(lines); + GeometryOptimizer.Optimize(arcs); + GeometryOptimizer.Deduplicate(circles); + GeometryOptimizer.Deduplicate(circles, arcs); + } entities.AddRange(circles); entities.AddRange(lines); @@ -223,10 +235,15 @@ namespace OpenNest.IO return entities; } + private static bool IsRepairMarkLayer(string name) => + string.Equals(name, "ETCH", StringComparison.OrdinalIgnoreCase) + || string.Equals(name, "SCRIBE", StringComparison.OrdinalIgnoreCase); + private static bool IsNonCutLayer(string layerName) { + // Etch/scribe marks are never cut geometry — drop them on default import. return string.Equals(layerName, "BEND", StringComparison.OrdinalIgnoreCase) - || string.Equals(layerName, "ETCH", StringComparison.OrdinalIgnoreCase); + || IsRepairMarkLayer(layerName); } private class ExportContext diff --git a/OpenNest.sln b/OpenNest.sln index 9c3c4f4..7a28092 100644 --- a/OpenNest.sln +++ b/OpenNest.sln @@ -35,8 +35,11 @@ EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OpenNest.Data", "OpenNest.Data\OpenNest.Data.csproj", "{A0B4B48E-1DF0-4DD3-B42C-B9B7779EA8B0}" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OpenNest.Benchmark", "OpenNest.Benchmark\OpenNest.Benchmark.csproj", "{ACD8F725-829A-48A8-AA59-61DD90DE06CA}" +EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OpenNest.Engine.Tests", "OpenNest.Engine.Tests\OpenNest.Engine.Tests.csproj", "{F2EF39E0-1A95-4C32-B50B-3D71EC72F692}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OpenNest.IO.Tests", "OpenNest.IO.Tests\OpenNest.IO.Tests.csproj", "{BA93522B-8A93-4689-A850-D042483964B9}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -227,6 +230,18 @@ Global {F2EF39E0-1A95-4C32-B50B-3D71EC72F692}.Release|x64.Build.0 = Release|Any CPU {F2EF39E0-1A95-4C32-B50B-3D71EC72F692}.Release|x86.ActiveCfg = Release|Any CPU {F2EF39E0-1A95-4C32-B50B-3D71EC72F692}.Release|x86.Build.0 = Release|Any CPU + {BA93522B-8A93-4689-A850-D042483964B9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {BA93522B-8A93-4689-A850-D042483964B9}.Debug|Any CPU.Build.0 = Debug|Any CPU + {BA93522B-8A93-4689-A850-D042483964B9}.Debug|x64.ActiveCfg = Debug|Any CPU + {BA93522B-8A93-4689-A850-D042483964B9}.Debug|x64.Build.0 = Debug|Any CPU + {BA93522B-8A93-4689-A850-D042483964B9}.Debug|x86.ActiveCfg = Debug|Any CPU + {BA93522B-8A93-4689-A850-D042483964B9}.Debug|x86.Build.0 = Debug|Any CPU + {BA93522B-8A93-4689-A850-D042483964B9}.Release|Any CPU.ActiveCfg = Release|Any CPU + {BA93522B-8A93-4689-A850-D042483964B9}.Release|Any CPU.Build.0 = Release|Any CPU + {BA93522B-8A93-4689-A850-D042483964B9}.Release|x64.ActiveCfg = Release|Any CPU + {BA93522B-8A93-4689-A850-D042483964B9}.Release|x64.Build.0 = Release|Any CPU + {BA93522B-8A93-4689-A850-D042483964B9}.Release|x86.ActiveCfg = Release|Any CPU + {BA93522B-8A93-4689-A850-D042483964B9}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/README.md b/README.md index 3923e0c..6ac085a 100644 --- a/README.md +++ b/README.md @@ -51,7 +51,8 @@ OpenNest takes your part drawings, lets you define your sheet (plate) sizes, and ## Prerequisites -- **Windows 10 or later** +- **Windows 10 or later** for the desktop app and Windows-dependent projects +- The headless console and engine/import test projects target `net8.0` and can be built independently on Linux, macOS, or Windows - [.NET 8 SDK](https://dotnet.microsoft.com/download/dotnet/8.0) ## Getting Started @@ -64,6 +65,15 @@ cd OpenNest dotnet build OpenNest.sln ``` +### Code formatting + +C# sources are formatted with [CSharpier](https://csharpier.com/), pinned in `.config/dotnet-tools.json`; the matching style (4-space indent, Allman braces, System-first usings, 100-column wraps) is mirrored in `.editorconfig` so IDE auto-format agrees. Before committing: + +```bash +dotnet tool restore +dotnet csharpier format . # apply; use `check` instead of `format` to verify only +``` + ### Cross-platform engine contract tests ```bash @@ -183,6 +193,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 +243,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 +270,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.