docs: revise lead-in UI spec with external/internal split and LayerType tagging

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-16 11:04:42 -04:00
130 changed files with 16147 additions and 4197 deletions

4
.gitignore vendored
View File

@@ -202,5 +202,9 @@ FakesAssemblies/
# Git worktrees # Git worktrees
.worktrees/ .worktrees/
# SQLite databases
*.db
*.db-journal
# Claude Code # Claude Code
.claude/ .claude/

View File

@@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
## Project Overview ## Project Overview
OpenNest is a Windows desktop application for CNC nesting — arranging 2D parts on material plates to minimize waste. It imports DXF drawings, places parts onto plates using rectangle-packing algorithms, and can export nest layouts as DXF or post-process them to G-code for CNC cutting machines. OpenNest is a Windows desktop application for CNC nesting — arranging 2D parts on material plates to minimize waste. It imports DXF drawings, places parts onto plates using NFP-based (No Fit Polygon) and rectangle-packing algorithms, and can export nest layouts as DXF or post-process them to G-code for CNC cutting machines.
## Build ## Build
@@ -14,41 +14,57 @@ This is a .NET 8 solution using SDK-style `.csproj` files targeting `net8.0-wind
dotnet build OpenNest.sln dotnet build OpenNest.sln
``` ```
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). 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).
No test projects exist in this solution.
## Architecture ## Architecture
Five projects form a layered architecture: Eight projects form a layered architecture:
### OpenNest.Core (class library) ### OpenNest.Core (class library)
Domain model, geometry, and CNC primitives organized into namespaces: Domain model, geometry, and CNC primitives organized into namespaces:
- **Root** (`namespace OpenNest`): Domain model — `Nest``Plate[]``Part[]``Drawing``Program`. A `Nest` is the top-level container. Each `Plate` has a size, material, quadrant, spacing, and contains placed `Part` instances. Each `Part` references a `Drawing` (the template) and has its own location/rotation. A `Drawing` wraps a CNC `Program`. Also contains utilities: `Helper`, `Align`, `Sequence`, `Timing`. - **Root** (`namespace OpenNest`): Domain model — `Nest``Plate[]``Part[]``Drawing``Program`. A `Nest` is the top-level container. Each `Plate` has a size, material, quadrant, spacing, and contains placed `Part` instances. Each `Part` references a `Drawing` (the template) and has its own location/rotation. A `Drawing` wraps a CNC `Program`. Also contains utilities: `PartGeometry`, `Align`, `Sequence`, `Timing`.
- **CNC** (`CNC/`, `namespace OpenNest.CNC`): `Program` holds a list of `ICode` instructions (G-code-like: `RapidMove`, `LinearMove`, `ArcMove`, `SubProgramCall`). Programs support absolute/incremental mode conversion, rotation, offset, bounding box calculation, and cloning. - **CNC** (`CNC/`, `namespace OpenNest.CNC`): `Program` holds a list of `ICode` instructions (G-code-like: `RapidMove`, `LinearMove`, `ArcMove`, `SubProgramCall`). Programs support absolute/incremental mode conversion, rotation, offset, bounding box calculation, and cloning.
- **Geometry** (`Geometry/`, `namespace OpenNest.Geometry`): Spatial primitives (`Vector`, `Box`, `Size`, `Spacing`, `BoundingBox`, `IBoundable`) and higher-level shapes (`Line`, `Arc`, `Circle`, `Polygon`, `Shape`) used for intersection detection, area calculation, and DXF conversion. - **Geometry** (`Geometry/`, `namespace OpenNest.Geometry`): Spatial primitives (`Vector`, `Box`, `Size`, `Spacing`, `BoundingBox`, `IBoundable`) and higher-level shapes (`Line`, `Arc`, `Circle`, `Polygon`, `Shape`) used for intersection detection, area calculation, and DXF conversion. Also contains `Intersect` (intersection algorithms), `ShapeBuilder` (entity chaining), `GeometryOptimizer` (line/arc merging), `SpatialQuery` (directional distance, ray casting, box queries), `ShapeProfile` (perimeter/area analysis), `NoFitPolygon`, `InnerFitPolygon`, `ConvexHull`, `ConvexDecomposition`, and `RotatingCalipers`.
- **Converters** (`Converters/`, `namespace OpenNest.Converters`): Bridges between CNC and Geometry — `ConvertProgram` (CNC→Geometry), `ConvertGeometry` (Geometry→CNC), `ConvertMode` (absolute↔incremental). - **Converters** (`Converters/`, `namespace OpenNest.Converters`): Bridges between CNC and Geometry — `ConvertProgram` (CNC→Geometry), `ConvertGeometry` (Geometry→CNC), `ConvertMode` (absolute↔incremental).
- **Math** (`Math/`, `namespace OpenNest.Math`): `Angle` (radian/degree conversion), `Tolerance` (floating-point comparison), `Trigonometry`, `Generic` (swap utility), `EvenOdd`. Note: `OpenNest.Math` shadows `System.Math` — use `System.Math` fully qualified where both are needed. - **Math** (`Math/`, `namespace OpenNest.Math`): `Angle` (radian/degree conversion), `Tolerance` (floating-point comparison), `Trigonometry`, `Generic` (swap utility), `EvenOdd`, `Rounding` (factor-based rounding). Note: `OpenNest.Math` shadows `System.Math` — use `System.Math` fully qualified where both are needed.
- **CNC/CuttingStrategy** (`CNC/CuttingStrategy/`, `namespace OpenNest.CNC`): `ContourCuttingStrategy` orchestrates cut ordering, lead-ins/lead-outs, and tabs. Includes `LeadIn`/`LeadOut` hierarchies (line, arc, clean-hole variants), `Tab` hierarchy (normal, machine, breaker), and `CuttingParameters`/`AssignmentParameters`/`SequenceParameters` configuration.
- **Collections** (`Collections/`, `namespace OpenNest.Collections`): `ObservableList<T>`, `DrawingCollection`. - **Collections** (`Collections/`, `namespace OpenNest.Collections`): `ObservableList<T>`, `DrawingCollection`.
- **Quadrant system**: Plates use quadrants 1-4 (like Cartesian quadrants) to determine coordinate origin placement. This affects bounding box calculation, rotation, and part positioning. - **Quadrant system**: Plates use quadrants 1-4 (like Cartesian quadrants) to determine coordinate origin placement. This affects bounding box calculation, rotation, and part positioning.
### OpenNest.Engine (class library, depends on Core) ### OpenNest.Engine (class library, depends on Core)
Nesting algorithms. `NestEngine` orchestrates filling plates with parts. Nesting algorithms with a pluggable engine architecture. `NestEngineBase` is the abstract base class; `DefaultNestEngine` (formerly `NestEngine`) provides the multi-phase fill strategy. `NestEngineRegistry` manages available engines (built-in + plugins from `Engines/` directory) and the globally active engine. `AutoNester` handles mixed-part NFP-based nesting with simulated annealing (not yet integrated into the registry).
- **Engine hierarchy**: `NestEngineBase` (abstract) → `DefaultNestEngine` (Linear, Pairs, RectBestFit, Remainder phases). Custom engines subclass `NestEngineBase` and register via `NestEngineRegistry.Register()` or as plugin DLLs in `Engines/`.
- **NestEngineRegistry**: Static registry — `Create(Plate)` factory, `ActiveEngineName` global selection, `LoadPlugins(directory)` for DLL discovery. All callsites use `NestEngineRegistry.Create(plate)` except `BruteForceRunner` which uses `new DefaultNestEngine(plate)` directly for training consistency.
- **BestFit/**: NFP-based pair evaluation pipeline — `BestFitFinder` orchestrates angle sweeps, `PairEvaluator`/`IPairEvaluator` scores part pairs, `RotationSlideStrategy`/`ISlideComputer` computes slide distances. `BestFitCache` and `BestFitFilter` optimize repeated lookups.
- **RectanglePacking/**: `FillBestFit` (single-item fill, tries horizontal and vertical orientations), `PackBottomLeft` (multi-item bin packing, sorts by area descending). Both operate on `Bin`/`Item` abstractions. - **RectanglePacking/**: `FillBestFit` (single-item fill, tries horizontal and vertical orientations), `PackBottomLeft` (multi-item bin packing, sorts by area descending). Both operate on `Bin`/`Item` abstractions.
- **CirclePacking/**: Alternative packing for circular parts. - **CirclePacking/**: Alternative packing for circular parts.
- **ML/**: `AnglePredictor` (ONNX model for predicting good rotation angles), `FeatureExtractor` (part geometry features), `BruteForceRunner` (full angle sweep for training data).
- `FillLinear`: Grid-based fill with directional sliding.
- `Compactor`: Post-fill gravity compaction — pushes parts toward a plate edge to close gaps.
- `FillScore`: Lexicographic comparison struct for fill results (count > utilization > compactness).
- `NestItem`: Input to the engine — wraps a `Drawing` with quantity, priority, and rotation constraints. - `NestItem`: Input to the engine — wraps a `Drawing` with quantity, priority, and rotation constraints.
- `BestCombination`: Finds optimal mix of normal/rotated columns for grid fills. - `NestProgress`: Progress reporting model with `NestPhase` enum for UI feedback.
- `RotationAnalysis`: Analyzes part geometry to determine valid rotation angles.
### OpenNest.IO (class library, depends on Core) ### OpenNest.IO (class library, depends on Core)
File I/O and format conversion. Uses ACadSharp for DXF/DWG support. File I/O and format conversion. Uses ACadSharp for DXF/DWG support.
- `DxfImporter`/`DxfExporter` — DXF file import/export via ACadSharp. - `DxfImporter`/`DxfExporter` — DXF file import/export via ACadSharp.
- `NestReader`/`NestWriter` — custom ZIP-based nest format (XML metadata + G-code programs). - `NestReader`/`NestWriter` — custom ZIP-based nest format (JSON metadata + G-code programs, v2 format).
- `ProgramReader` — G-code text parser. - `ProgramReader` — G-code text parser.
- `Extensions` — conversion helpers between ACadSharp and OpenNest geometry types. - `Extensions` — conversion helpers between ACadSharp and OpenNest geometry types.
### 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`).
### 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.
### OpenNest.Training (console app, depends on Core + Engine)
Training data collection for ML angle prediction. `TrainingDatabase` stores per-angle nesting results in SQLite via EF Core for offline model training.
### OpenNest.Mcp (console app, depends on Core + Engine + IO) ### OpenNest.Mcp (console app, depends on Core + Engine + IO)
MCP server for Claude Code integration. Exposes nesting operations as MCP tools over stdio transport. Published to `~/.claude/mcp/OpenNest.Mcp/`. MCP server for Claude Code integration. Exposes nesting operations as MCP tools over stdio transport. Published to `~/.claude/mcp/OpenNest.Mcp/`.
@@ -62,16 +78,16 @@ MCP server for Claude Code integration. Exposes nesting operations as MCP tools
The UI application with MDI interface. The UI application with MDI interface.
- **Forms/**: `MainForm` (MDI parent), `EditNestForm` (MDI child per nest), plus dialogs for plate editing, auto-nesting, DXF conversion, cut parameters, etc. - **Forms/**: `MainForm` (MDI parent), `EditNestForm` (MDI child per nest), plus dialogs for plate editing, auto-nesting, DXF conversion, cut parameters, etc.
- **Controls/**: `PlateView` (2D plate renderer with zoom/pan), `DrawingListBox`, `DrawControl`, `QuadrantSelect`. - **Controls/**: `PlateView` (2D plate renderer with zoom/pan, supports temporary preview parts), `DrawingListBox`, `DrawControl`, `QuadrantSelect`.
- **Actions/**: User interaction modes — `ActionSelect`, `ActionAddPart`, `ActionClone`, `ActionFillArea`, `ActionZoomWindow`, `ActionSetSequence`. - **Actions/**: User interaction modes — `ActionSelect`, `ActionClone`, `ActionFillArea`, `ActionSelectArea`, `ActionZoomWindow`, `ActionSetSequence`.
- **Post-processing**: `IPostProcessor` plugin interface loaded from DLLs in a `Posts/` directory at runtime. - **Post-processing**: `IPostProcessor` plugin interface loaded from DLLs in a `Posts/` directory at runtime.
## File Format ## File Format
Nest files (`.zip`) contain: Nest files (`.nest`, ZIP-based) use v2 JSON format:
- `info` XML with nest metadata and plate defaults - `info.json` — nest metadata and plate defaults
- `drawing-info` XML with drawing metadata (name, material, quantities, colors) - `drawing-info.json` — drawing metadata (name, material, quantities, colors)
- `plate-info` XML with plate metadata (size, material, spacing) - `plate-info.json` — plate metadata (size, material, spacing)
- `program-NNN` — G-code text for each drawing's cut program - `program-NNN` — G-code text for each drawing's cut program
- `plate-NNN` — G-code text encoding part placements (G00 for position, G65 for sub-program call with rotation) - `plate-NNN` — G-code text encoding part placements (G00 for position, G65 for sub-program call with rotation)
@@ -89,3 +105,6 @@ Always use Roslyn Bridge MCP tools (`mcp__RoslynBridge__*`) as the primary metho
- `ObservableList<T>` provides ItemAdded/ItemRemoved/ItemChanged events used for automatic quantity tracking between plates and drawings. - `ObservableList<T>` provides ItemAdded/ItemRemoved/ItemChanged events used for automatic quantity tracking between plates and drawings.
- Angles throughout the codebase are in **radians** (use `Angle.ToRadians()`/`Angle.ToDegrees()` for conversion). - Angles throughout the codebase are in **radians** (use `Angle.ToRadians()`/`Angle.ToDegrees()` for conversion).
- `Tolerance.Epsilon` is used for floating-point comparisons across geometry operations. - `Tolerance.Epsilon` is used for floating-point comparisons across geometry operations.
- Nesting uses async progress/cancellation: `IProgress<NestProgress>` and `CancellationToken` flow through the engine to the UI's `NestProgressForm`.
- `Compactor` performs post-fill gravity compaction — after filling, parts are pushed toward a plate edge using directional distance calculations to close gaps between irregular shapes.
- `FillScore` uses lexicographic comparison (count > utilization > compactness) to rank fill results consistently across all fill strategies.

View File

@@ -3,246 +3,426 @@ using System.Collections.Generic;
using System.Diagnostics; using System.Diagnostics;
using System.IO; using System.IO;
using System.Linq; using System.Linq;
using System.Threading;
using OpenNest; using OpenNest;
using OpenNest.Converters;
using OpenNest.Geometry; using OpenNest.Geometry;
using OpenNest.IO; using OpenNest.IO;
// Parse arguments. return NestConsole.Run(args);
var nestFile = (string)null;
var drawingName = (string)null;
var plateIndex = 0;
var outputFile = (string)null;
var quantity = 0;
var spacing = (double?)null;
var plateWidth = (double?)null;
var plateHeight = (double?)null;
var checkOverlaps = false;
var noSave = false;
var noLog = false;
var keepParts = false;
var autoNest = false;
for (var i = 0; i < args.Length; i++) static class NestConsole
{ {
switch (args[i]) public static int Run(string[] args)
{ {
case "--drawing" when i + 1 < args.Length: var options = ParseArgs(args);
drawingName = args[++i];
break; if (options == null)
case "--plate" when i + 1 < args.Length: return 0; // --help was requested
plateIndex = int.Parse(args[++i]);
break; if (options.InputFiles.Count == 0)
case "--output" when i + 1 < args.Length: {
outputFile = args[++i];
break;
case "--quantity" when i + 1 < args.Length:
quantity = int.Parse(args[++i]);
break;
case "--spacing" when i + 1 < args.Length:
spacing = double.Parse(args[++i]);
break;
case "--size" when i + 1 < args.Length:
var parts = args[++i].Split('x');
if (parts.Length == 2)
{
plateWidth = double.Parse(parts[0]);
plateHeight = double.Parse(parts[1]);
}
break;
case "--check-overlaps":
checkOverlaps = true;
break;
case "--no-save":
noSave = true;
break;
case "--no-log":
noLog = true;
break;
case "--keep-parts":
keepParts = true;
break;
case "--autonest":
autoNest = true;
break;
case "--help":
case "-h":
PrintUsage(); PrintUsage();
return 1;
}
foreach (var f in options.InputFiles)
{
if (!File.Exists(f))
{
Console.Error.WriteLine($"Error: file not found: {f}");
return 1;
}
}
using var log = SetUpLog(options);
var nest = LoadOrCreateNest(options);
if (nest == null)
return 1;
var plate = nest.Plates[options.PlateIndex];
ApplyTemplate(plate, options);
ApplyOverrides(plate, options);
var drawing = ResolveDrawing(nest, options);
if (drawing == null)
return 1;
var existingCount = plate.Parts.Count;
if (!options.KeepParts)
plate.Parts.Clear();
PrintHeader(nest, plate, drawing, existingCount, options);
var (success, elapsed) = Fill(nest, plate, drawing, options);
var overlapCount = CheckOverlaps(plate, options);
// Flush and close the log before printing results.
Trace.Flush();
log?.Dispose();
PrintResults(success, plate, elapsed);
Save(nest, options);
return options.CheckOverlaps && overlapCount > 0 ? 1 : 0;
}
static Options ParseArgs(string[] args)
{
var o = new Options();
for (var i = 0; i < args.Length; i++)
{
switch (args[i])
{
case "--drawing" when i + 1 < args.Length:
o.DrawingName = args[++i];
break;
case "--plate" when i + 1 < args.Length:
o.PlateIndex = int.Parse(args[++i]);
break;
case "--output" when i + 1 < args.Length:
o.OutputFile = args[++i];
break;
case "--quantity" when i + 1 < args.Length:
o.Quantity = int.Parse(args[++i]);
break;
case "--spacing" when i + 1 < args.Length:
o.Spacing = double.Parse(args[++i]);
break;
case "--size" when i + 1 < args.Length:
var parts = args[++i].Split('x');
if (parts.Length == 2)
{
o.PlateHeight = double.Parse(parts[0]);
o.PlateWidth = double.Parse(parts[1]);
}
break;
case "--check-overlaps":
o.CheckOverlaps = true;
break;
case "--no-save":
o.NoSave = true;
break;
case "--no-log":
o.NoLog = true;
break;
case "--keep-parts":
o.KeepParts = true;
break;
case "--template" when i + 1 < args.Length:
o.TemplateFile = args[++i];
break;
case "--autonest":
o.AutoNest = true;
break;
case "--help":
case "-h":
PrintUsage();
return null;
default:
if (!args[i].StartsWith("--"))
o.InputFiles.Add(args[i]);
break;
}
}
return o;
}
static StreamWriter SetUpLog(Options options)
{
if (options.NoLog)
return null;
var baseDir = Path.GetDirectoryName(options.InputFiles[0]);
var logDir = Path.Combine(baseDir, "test-harness-logs");
Directory.CreateDirectory(logDir);
var logFile = Path.Combine(logDir, $"debug-{DateTime.Now:yyyyMMdd-HHmmss}.log");
var writer = new StreamWriter(logFile) { AutoFlush = true };
Trace.Listeners.Add(new TextWriterTraceListener(writer));
Console.WriteLine($"Debug log: {logFile}");
return writer;
}
static Nest LoadOrCreateNest(Options options)
{
var nestFile = options.InputFiles.FirstOrDefault(f =>
f.EndsWith(".zip", StringComparison.OrdinalIgnoreCase));
var dxfFiles = options.InputFiles.Where(f =>
f.EndsWith(".dxf", StringComparison.OrdinalIgnoreCase)).ToList();
// If we have a nest file, load it and optionally add DXFs.
if (nestFile != null)
{
var nest = new NestReader(nestFile).Read();
if (nest.Plates.Count == 0)
{
Console.Error.WriteLine("Error: nest file contains no plates");
return null;
}
if (options.PlateIndex >= nest.Plates.Count)
{
Console.Error.WriteLine($"Error: plate index {options.PlateIndex} out of range (0-{nest.Plates.Count - 1})");
return null;
}
foreach (var dxf in dxfFiles)
{
var drawing = ImportDxf(dxf);
if (drawing == null)
return null;
nest.Drawings.Add(drawing);
Console.WriteLine($"Imported: {drawing.Name}");
}
return nest;
}
// DXF-only mode: create a fresh nest.
if (dxfFiles.Count == 0)
{
Console.Error.WriteLine("Error: no nest (.zip) or DXF (.dxf) files specified");
return null;
}
if (!options.PlateWidth.HasValue || !options.PlateHeight.HasValue)
{
Console.Error.WriteLine("Error: --size WxH is required when importing DXF files without a nest");
return null;
}
var newNest = new Nest { Name = "DXF Import" };
var plate = new Plate { Size = new Size(options.PlateWidth.Value, options.PlateHeight.Value) };
newNest.Plates.Add(plate);
foreach (var dxf in dxfFiles)
{
var drawing = ImportDxf(dxf);
if (drawing == null)
return null;
newNest.Drawings.Add(drawing);
Console.WriteLine($"Imported: {drawing.Name}");
}
return newNest;
}
static Drawing ImportDxf(string path)
{
var importer = new DxfImporter();
if (!importer.GetGeometry(path, out var geometry))
{
Console.Error.WriteLine($"Error: failed to read DXF file: {path}");
return null;
}
if (geometry.Count == 0)
{
Console.Error.WriteLine($"Error: no geometry found in DXF file: {path}");
return null;
}
var pgm = ConvertGeometry.ToProgram(geometry);
if (pgm == null)
{
Console.Error.WriteLine($"Error: failed to convert geometry: {path}");
return null;
}
var name = Path.GetFileNameWithoutExtension(path);
return new Drawing(name, pgm);
}
static void ApplyTemplate(Plate plate, Options options)
{
if (options.TemplateFile == null)
return;
if (!File.Exists(options.TemplateFile))
{
Console.Error.WriteLine($"Error: Template not found: {options.TemplateFile}");
return;
}
var templatePlate = new NestReader(options.TemplateFile).Read().PlateDefaults.CreateNew();
plate.Thickness = templatePlate.Thickness;
plate.Quadrant = templatePlate.Quadrant;
plate.Material = templatePlate.Material;
plate.EdgeSpacing = templatePlate.EdgeSpacing;
plate.PartSpacing = templatePlate.PartSpacing;
Console.WriteLine($"Template: {options.TemplateFile}");
}
static void ApplyOverrides(Plate plate, Options options)
{
if (options.Spacing.HasValue)
plate.PartSpacing = options.Spacing.Value;
// Only apply size override when it wasn't already used to create the plate.
var hasDxfOnly = !options.InputFiles.Any(f => f.EndsWith(".zip", StringComparison.OrdinalIgnoreCase));
if (options.PlateWidth.HasValue && options.PlateHeight.HasValue && !hasDxfOnly)
plate.Size = new Size(options.PlateWidth.Value, options.PlateHeight.Value);
}
static Drawing ResolveDrawing(Nest nest, Options options)
{
var drawing = options.DrawingName != null
? nest.Drawings.FirstOrDefault(d => d.Name == options.DrawingName)
: nest.Drawings.FirstOrDefault();
if (drawing != null)
return drawing;
Console.Error.WriteLine(options.DrawingName != null
? $"Error: drawing '{options.DrawingName}' not found. Available: {string.Join(", ", nest.Drawings.Select(d => d.Name))}"
: "Error: nest file contains no drawings");
return null;
}
static void PrintHeader(Nest nest, Plate plate, Drawing drawing, int existingCount, Options options)
{
Console.WriteLine($"Nest: {nest.Name}");
var wa = plate.WorkArea();
Console.WriteLine($"Plate: {options.PlateIndex} ({plate.Size.Length:F1} x {plate.Size.Width:F1}), spacing={plate.PartSpacing:F2}, edge=({plate.EdgeSpacing.Left},{plate.EdgeSpacing.Bottom},{plate.EdgeSpacing.Right},{plate.EdgeSpacing.Top}), workArea={wa.Length:F1}x{wa.Width:F1}");
Console.WriteLine($"Drawing: {drawing.Name}");
Console.WriteLine(options.KeepParts
? $"Keeping {existingCount} existing parts"
: $"Cleared {existingCount} existing parts");
Console.WriteLine("---");
}
static (bool success, long elapsedMs) Fill(Nest nest, Plate plate, Drawing drawing, Options options)
{
var sw = Stopwatch.StartNew();
bool success;
if (options.AutoNest)
{
var nestItems = new List<NestItem>();
var qty = options.Quantity > 0 ? options.Quantity : 1;
if (options.DrawingName != null)
{
nestItems.Add(new NestItem { Drawing = drawing, Quantity = qty });
}
else
{
foreach (var d in nest.Drawings)
nestItems.Add(new NestItem { Drawing = d, Quantity = qty });
}
Console.WriteLine($"AutoNest: {nestItems.Count} drawing(s), {nestItems.Sum(i => i.Quantity)} total parts");
var engine = NestEngineRegistry.Create(plate);
var nestParts = engine.Nest(nestItems, null, CancellationToken.None);
plate.Parts.AddRange(nestParts);
success = nestParts.Count > 0;
}
else
{
var engine = NestEngineRegistry.Create(plate);
var item = new NestItem { Drawing = drawing, Quantity = options.Quantity };
success = engine.Fill(item);
}
sw.Stop();
return (success, sw.ElapsedMilliseconds);
}
static int CheckOverlaps(Plate plate, Options options)
{
if (!options.CheckOverlaps || plate.Parts.Count == 0)
return 0; return 0;
default:
if (!args[i].StartsWith("--") && nestFile == null) var hasOverlaps = plate.HasOverlappingParts(out var overlapPts);
nestFile = args[i]; Console.WriteLine(hasOverlaps
break; ? $"OVERLAPS DETECTED: {overlapPts.Count} intersection points"
: "Overlap check: PASS");
return overlapPts.Count;
} }
}
if (string.IsNullOrEmpty(nestFile) || !File.Exists(nestFile)) static void PrintResults(bool success, Plate plate, long elapsedMs)
{
PrintUsage();
return 1;
}
// Set up debug log file.
StreamWriter logWriter = null;
if (!noLog)
{
var logDir = Path.Combine(Path.GetDirectoryName(nestFile), "test-harness-logs");
Directory.CreateDirectory(logDir);
var logFile = Path.Combine(logDir, $"debug-{DateTime.Now:yyyyMMdd-HHmmss}.log");
logWriter = new StreamWriter(logFile) { AutoFlush = true };
Trace.Listeners.Add(new TextWriterTraceListener(logWriter));
Console.WriteLine($"Debug log: {logFile}");
}
// Load nest.
var reader = new NestReader(nestFile);
var nest = reader.Read();
if (nest.Plates.Count == 0)
{
Console.Error.WriteLine("Error: nest file contains no plates");
return 1;
}
if (plateIndex >= nest.Plates.Count)
{
Console.Error.WriteLine($"Error: plate index {plateIndex} out of range (0-{nest.Plates.Count - 1})");
return 1;
}
var plate = nest.Plates[plateIndex];
// Apply overrides.
if (spacing.HasValue)
plate.PartSpacing = spacing.Value;
if (plateWidth.HasValue && plateHeight.HasValue)
plate.Size = new Size(plateWidth.Value, plateHeight.Value);
// Find drawing.
var drawing = drawingName != null
? nest.Drawings.FirstOrDefault(d => d.Name == drawingName)
: nest.Drawings.FirstOrDefault();
if (drawing == null)
{
Console.Error.WriteLine(drawingName != null
? $"Error: drawing '{drawingName}' not found. Available: {string.Join(", ", nest.Drawings.Select(d => d.Name))}"
: "Error: nest file contains no drawings");
return 1;
}
// Clear existing parts.
var existingCount = plate.Parts.Count;
if (!keepParts)
plate.Parts.Clear();
Console.WriteLine($"Nest: {nest.Name}");
Console.WriteLine($"Plate: {plateIndex} ({plate.Size.Width:F1} x {plate.Size.Length:F1}), spacing={plate.PartSpacing:F2}");
Console.WriteLine($"Drawing: {drawing.Name}");
if (!keepParts)
Console.WriteLine($"Cleared {existingCount} existing parts");
else
Console.WriteLine($"Keeping {existingCount} existing parts");
Console.WriteLine("---");
// Run fill or autonest.
var sw = Stopwatch.StartNew();
bool success;
if (autoNest)
{
// AutoNest: use all drawings (or specific drawing if --drawing given).
var nestItems = new List<NestItem>();
if (drawingName != null)
{ {
nestItems.Add(new NestItem { Drawing = drawing, Quantity = quantity > 0 ? quantity : 1 }); Console.WriteLine($"Result: {(success ? "success" : "failed")}");
Console.WriteLine($"Parts placed: {plate.Parts.Count}");
Console.WriteLine($"Utilization: {plate.Utilization():P1}");
Console.WriteLine($"Time: {elapsedMs}ms");
} }
else
static void Save(Nest nest, Options options)
{ {
foreach (var d in nest.Drawings) if (options.NoSave)
nestItems.Add(new NestItem { Drawing = d, Quantity = quantity > 0 ? quantity : 1 }); return;
var firstInput = options.InputFiles[0];
var outputFile = options.OutputFile ?? Path.Combine(
Path.GetDirectoryName(firstInput),
$"{Path.GetFileNameWithoutExtension(firstInput)}-result.zip");
new NestWriter(nest).Write(outputFile);
Console.WriteLine($"Saved: {outputFile}");
} }
Console.WriteLine($"AutoNest: {nestItems.Count} drawing(s), {nestItems.Sum(i => i.Quantity)} total parts"); static void PrintUsage()
var parts = NestEngine.AutoNest(nestItems, plate);
plate.Parts.AddRange(parts);
success = parts.Count > 0;
}
else
{
var engine = new NestEngine(plate);
var item = new NestItem { Drawing = drawing, Quantity = quantity };
success = engine.Fill(item);
}
sw.Stop();
// Check overlaps.
var overlapCount = 0;
if (checkOverlaps && plate.Parts.Count > 0)
{
List<Vector> overlapPts;
var hasOverlaps = plate.HasOverlappingParts(out overlapPts);
overlapCount = overlapPts.Count;
if (hasOverlaps)
Console.WriteLine($"OVERLAPS DETECTED: {overlapCount} intersection points");
else
Console.WriteLine("Overlap check: PASS");
}
// Flush and close the log.
Trace.Flush();
logWriter?.Dispose();
// Print results.
Console.WriteLine($"Result: {(success ? "success" : "failed")}");
Console.WriteLine($"Parts placed: {plate.Parts.Count}");
Console.WriteLine($"Utilization: {plate.Utilization():P1}");
Console.WriteLine($"Time: {sw.ElapsedMilliseconds}ms");
// Save output.
if (!noSave)
{
if (outputFile == null)
{ {
var dir = Path.GetDirectoryName(nestFile); Console.Error.WriteLine("Usage: OpenNest.Console <input-files...> [options]");
var name = Path.GetFileNameWithoutExtension(nestFile); Console.Error.WriteLine();
outputFile = Path.Combine(dir, $"{name}-result.zip"); Console.Error.WriteLine("Arguments:");
Console.Error.WriteLine(" input-files One or more .zip nest files or .dxf drawing files");
Console.Error.WriteLine();
Console.Error.WriteLine("Modes:");
Console.Error.WriteLine(" <nest.zip> Load nest and fill (existing behavior)");
Console.Error.WriteLine(" <part.dxf> --size LxW Import DXF, create plate, and fill");
Console.Error.WriteLine(" <nest.zip> <part.dxf> Load nest and add imported DXF drawings");
Console.Error.WriteLine();
Console.Error.WriteLine("Options:");
Console.Error.WriteLine(" --drawing <name> Drawing name to fill with (default: first drawing)");
Console.Error.WriteLine(" --plate <index> Plate index to fill (default: 0)");
Console.Error.WriteLine(" --quantity <n> Max parts to place (default: 0 = unlimited)");
Console.Error.WriteLine(" --spacing <value> Override part spacing");
Console.Error.WriteLine(" --size <LxW> Override plate size (e.g. 120x60); required for DXF-only mode");
Console.Error.WriteLine(" --output <path> Output nest file path (default: <input>-result.zip)");
Console.Error.WriteLine(" --template <path> Nest template for plate defaults (thickness, quadrant, material, spacing)");
Console.Error.WriteLine(" --autonest Use NFP-based mixed-part autonesting instead of linear fill");
Console.Error.WriteLine(" --keep-parts Don't clear existing parts before filling");
Console.Error.WriteLine(" --check-overlaps Run overlap detection after fill (exit code 1 if found)");
Console.Error.WriteLine(" --no-save Skip saving output file");
Console.Error.WriteLine(" --no-log Skip writing debug log file");
Console.Error.WriteLine(" -h, --help Show this help");
} }
var writer = new NestWriter(nest); class Options
writer.Write(outputFile); {
Console.WriteLine($"Saved: {outputFile}"); public List<string> InputFiles = new();
} public string DrawingName;
public int PlateIndex;
return checkOverlaps && overlapCount > 0 ? 1 : 0; public string OutputFile;
public int Quantity;
void PrintUsage() public double? Spacing;
{ public double? PlateWidth;
Console.Error.WriteLine("Usage: OpenNest.Console <nest-file> [options]"); public double? PlateHeight;
Console.Error.WriteLine(); public bool CheckOverlaps;
Console.Error.WriteLine("Arguments:"); public bool NoSave;
Console.Error.WriteLine(" nest-file Path to a .zip nest file"); public bool NoLog;
Console.Error.WriteLine(); public bool KeepParts;
Console.Error.WriteLine("Options:"); public bool AutoNest;
Console.Error.WriteLine(" --drawing <name> Drawing name to fill with (default: first drawing)"); public string TemplateFile;
Console.Error.WriteLine(" --plate <index> Plate index to fill (default: 0)"); }
Console.Error.WriteLine(" --quantity <n> Max parts to place (default: 0 = unlimited)");
Console.Error.WriteLine(" --spacing <value> Override part spacing");
Console.Error.WriteLine(" --size <WxH> Override plate size (e.g. 120x60)");
Console.Error.WriteLine(" --output <path> Output nest file path (default: <input>-result.zip)");
Console.Error.WriteLine(" --autonest Use NFP-based mixed-part autonesting instead of linear fill");
Console.Error.WriteLine(" --keep-parts Don't clear existing parts before filling");
Console.Error.WriteLine(" --check-overlaps Run overlap detection after fill (exit code 1 if found)");
Console.Error.WriteLine(" --no-save Skip saving output file");
Console.Error.WriteLine(" --no-log Skip writing debug log file");
Console.Error.WriteLine(" -h, --help Show this help");
} }

View File

@@ -7,9 +7,9 @@ namespace OpenNest.CNC.CuttingStrategy
{ {
public CuttingParameters Parameters { get; set; } public CuttingParameters Parameters { get; set; }
public Program Apply(Program partProgram, Plate plate) public CuttingResult Apply(Program partProgram, Vector approachPoint)
{ {
var exitPoint = GetExitPoint(plate); var exitPoint = approachPoint;
var entities = partProgram.ToGeometry(); var entities = partProgram.ToGeometry();
var profile = new ShapeProfile(entities); var profile = new ShapeProfile(entities);
@@ -44,9 +44,12 @@ namespace OpenNest.CNC.CuttingStrategy
currentPoint = closestPt; currentPoint = closestPt;
} }
var lastCutPoint = exitPoint;
// Perimeter last // Perimeter last
{ {
var perimeterPt = profile.Perimeter.ClosestPointTo(currentPoint, out perimeterEntity); var perimeterPt = profile.Perimeter.ClosestPointTo(currentPoint, out perimeterEntity);
lastCutPoint = perimeterPt;
var normal = ComputeNormal(perimeterPt, perimeterEntity, ContourType.External); var normal = ComputeNormal(perimeterPt, perimeterEntity, ContourType.External);
var winding = DetermineWinding(profile.Perimeter); var winding = DetermineWinding(profile.Perimeter);
@@ -60,21 +63,10 @@ namespace OpenNest.CNC.CuttingStrategy
result.Codes.AddRange(leadOut.Generate(perimeterPt, normal, winding)); result.Codes.AddRange(leadOut.Generate(perimeterPt, normal, winding));
} }
return result; return new CuttingResult
}
private Vector GetExitPoint(Plate plate)
{
var w = plate.Size.Width;
var l = plate.Size.Length;
return plate.Quadrant switch
{ {
1 => new Vector(w, l), // Q1 origin BottomLeft -> exit TopRight Program = result,
2 => new Vector(0, l), // Q2 origin BottomRight -> exit TopLeft LastCutPoint = lastCutPoint
3 => new Vector(0, 0), // Q3 origin TopRight -> exit BottomLeft
4 => new Vector(w, 0), // Q4 origin TopLeft -> exit BottomRight
_ => new Vector(w, l)
}; };
} }

View File

@@ -0,0 +1,11 @@
using OpenNest.CNC;
using OpenNest.Geometry;
namespace OpenNest.CNC.CuttingStrategy
{
public readonly struct CuttingResult
{
public Program Program { get; init; }
public Vector LastCutPoint { get; init; }
}
}

View File

@@ -1,4 +1,4 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using OpenNest.Converters; using OpenNest.Converters;
using OpenNest.Geometry; using OpenNest.Geometry;
@@ -84,6 +84,23 @@ namespace OpenNest.CNC
Rotation = Angle.NormalizeRad(Rotation + angle); Rotation = Angle.NormalizeRad(Rotation + angle);
} }
public override string ToString()
{
var sb = new System.Text.StringBuilder();
sb.AppendLine(mode == Mode.Absolute ? "G90" : "G91");
foreach (var code in Codes)
{
if (code is Motion m)
{
var cmd = m is RapidMove ? "G00" : (m is ArcMove am ? (am.Rotation == RotationType.CW ? "G02" : "G03") : "G01");
sb.Append($"{cmd}X{m.EndPoint.X:F4}Y{m.EndPoint.Y:F4}");
if (m is ArcMove arc) sb.Append($"I{arc.CenterPoint.X:F4}J{arc.CenterPoint.Y:F4}");
sb.AppendLine();
}
}
return sb.ToString();
}
public virtual void Rotate(double angle, Vector origin) public virtual void Rotate(double angle, Vector origin)
{ {
var mode = Mode; var mode = Mode;
@@ -99,7 +116,7 @@ namespace OpenNest.CNC
var subpgm = (SubProgramCall)code; var subpgm = (SubProgramCall)code;
if (subpgm.Program != null) if (subpgm.Program != null)
subpgm.Program.Rotate(angle); subpgm.Program.Rotate(angle, origin);
} }
if (code is Motion == false) if (code is Motion == false)

View File

@@ -9,7 +9,7 @@ namespace OpenNest.Converters
{ {
public static Program ToProgram(IList<Entity> geometry) public static Program ToProgram(IList<Entity> geometry)
{ {
var shapes = Helper.GetShapes(geometry); var shapes = ShapeBuilder.GetShapes(geometry);
if (shapes.Count == 0) if (shapes.Count == 0)
return null; return null;

View File

@@ -65,7 +65,7 @@ namespace OpenNest
public void UpdateArea() public void UpdateArea()
{ {
var geometry = ConvertProgram.ToGeometry(Program).Where(entity => entity.Layer != SpecialLayers.Rapid); var geometry = ConvertProgram.ToGeometry(Program).Where(entity => entity.Layer != SpecialLayers.Rapid);
var shapes = Helper.GetShapes(geometry); var shapes = ShapeBuilder.GetShapes(geometry);
if (shapes.Count == 0) if (shapes.Count == 0)
return; return;

View File

@@ -465,7 +465,7 @@ namespace OpenNest.Geometry
public override bool Intersects(Arc arc) public override bool Intersects(Arc arc)
{ {
List<Vector> pts; List<Vector> pts;
return Helper.Intersects(this, arc, out pts); return Intersect.Intersects(this, arc, out pts);
} }
/// <summary> /// <summary>
@@ -476,7 +476,7 @@ namespace OpenNest.Geometry
/// <returns></returns> /// <returns></returns>
public override bool Intersects(Arc arc, out List<Vector> pts) public override bool Intersects(Arc arc, out List<Vector> pts)
{ {
return Helper.Intersects(this, arc, out pts); ; return Intersect.Intersects(this, arc, out pts); ;
} }
/// <summary> /// <summary>
@@ -487,7 +487,7 @@ namespace OpenNest.Geometry
public override bool Intersects(Circle circle) public override bool Intersects(Circle circle)
{ {
List<Vector> pts; List<Vector> pts;
return Helper.Intersects(this, circle, out pts); return Intersect.Intersects(this, circle, out pts);
} }
/// <summary> /// <summary>
@@ -498,7 +498,7 @@ namespace OpenNest.Geometry
/// <returns></returns> /// <returns></returns>
public override bool Intersects(Circle circle, out List<Vector> pts) public override bool Intersects(Circle circle, out List<Vector> pts)
{ {
return Helper.Intersects(this, circle, out pts); return Intersect.Intersects(this, circle, out pts);
} }
/// <summary> /// <summary>
@@ -509,7 +509,7 @@ namespace OpenNest.Geometry
public override bool Intersects(Line line) public override bool Intersects(Line line)
{ {
List<Vector> pts; List<Vector> pts;
return Helper.Intersects(this, line, out pts); return Intersect.Intersects(this, line, out pts);
} }
/// <summary> /// <summary>
@@ -520,7 +520,7 @@ namespace OpenNest.Geometry
/// <returns></returns> /// <returns></returns>
public override bool Intersects(Line line, out List<Vector> pts) public override bool Intersects(Line line, out List<Vector> pts)
{ {
return Helper.Intersects(this, line, out pts); return Intersect.Intersects(this, line, out pts);
} }
/// <summary> /// <summary>
@@ -531,7 +531,7 @@ namespace OpenNest.Geometry
public override bool Intersects(Polygon polygon) public override bool Intersects(Polygon polygon)
{ {
List<Vector> pts; List<Vector> pts;
return Helper.Intersects(this, polygon, out pts); return Intersect.Intersects(this, polygon, out pts);
} }
/// <summary> /// <summary>
@@ -542,7 +542,7 @@ namespace OpenNest.Geometry
/// <returns></returns> /// <returns></returns>
public override bool Intersects(Polygon polygon, out List<Vector> pts) public override bool Intersects(Polygon polygon, out List<Vector> pts)
{ {
return Helper.Intersects(this, polygon, out pts); return Intersect.Intersects(this, polygon, out pts);
} }
/// <summary> /// <summary>
@@ -553,7 +553,7 @@ namespace OpenNest.Geometry
public override bool Intersects(Shape shape) public override bool Intersects(Shape shape)
{ {
List<Vector> pts; List<Vector> pts;
return Helper.Intersects(this, shape, out pts); return Intersect.Intersects(this, shape, out pts);
} }
/// <summary> /// <summary>
@@ -564,7 +564,7 @@ namespace OpenNest.Geometry
/// <returns></returns> /// <returns></returns>
public override bool Intersects(Shape shape, out List<Vector> pts) public override bool Intersects(Shape shape, out List<Vector> pts)
{ {
return Helper.Intersects(this, shape, out pts); return Intersect.Intersects(this, shape, out pts);
} }
/// <summary> /// <summary>

View File

@@ -320,7 +320,7 @@ namespace OpenNest.Geometry
public override bool Intersects(Arc arc) public override bool Intersects(Arc arc)
{ {
List<Vector> pts; List<Vector> pts;
return Helper.Intersects(arc, this, out pts); return Intersect.Intersects(arc, this, out pts);
} }
/// <summary> /// <summary>
@@ -331,7 +331,7 @@ namespace OpenNest.Geometry
/// <returns></returns> /// <returns></returns>
public override bool Intersects(Arc arc, out List<Vector> pts) public override bool Intersects(Arc arc, out List<Vector> pts)
{ {
return Helper.Intersects(arc, this, out pts); return Intersect.Intersects(arc, this, out pts);
} }
/// <summary> /// <summary>
@@ -353,7 +353,7 @@ namespace OpenNest.Geometry
/// <returns></returns> /// <returns></returns>
public override bool Intersects(Circle circle, out List<Vector> pts) public override bool Intersects(Circle circle, out List<Vector> pts)
{ {
return Helper.Intersects(this, circle, out pts); return Intersect.Intersects(this, circle, out pts);
} }
/// <summary> /// <summary>
@@ -364,7 +364,7 @@ namespace OpenNest.Geometry
public override bool Intersects(Line line) public override bool Intersects(Line line)
{ {
List<Vector> pts; List<Vector> pts;
return Helper.Intersects(this, line, out pts); return Intersect.Intersects(this, line, out pts);
} }
/// <summary> /// <summary>
@@ -375,7 +375,7 @@ namespace OpenNest.Geometry
/// <returns></returns> /// <returns></returns>
public override bool Intersects(Line line, out List<Vector> pts) public override bool Intersects(Line line, out List<Vector> pts)
{ {
return Helper.Intersects(this, line, out pts); return Intersect.Intersects(this, line, out pts);
} }
/// <summary> /// <summary>
@@ -386,7 +386,7 @@ namespace OpenNest.Geometry
public override bool Intersects(Polygon polygon) public override bool Intersects(Polygon polygon)
{ {
List<Vector> pts; List<Vector> pts;
return Helper.Intersects(this, polygon, out pts); return Intersect.Intersects(this, polygon, out pts);
} }
/// <summary> /// <summary>
@@ -397,7 +397,7 @@ namespace OpenNest.Geometry
/// <returns></returns> /// <returns></returns>
public override bool Intersects(Polygon polygon, out List<Vector> pts) public override bool Intersects(Polygon polygon, out List<Vector> pts)
{ {
return Helper.Intersects(this, polygon, out pts); return Intersect.Intersects(this, polygon, out pts);
} }
/// <summary> /// <summary>
@@ -408,7 +408,7 @@ namespace OpenNest.Geometry
public override bool Intersects(Shape shape) public override bool Intersects(Shape shape)
{ {
List<Vector> pts; List<Vector> pts;
return Helper.Intersects(this, shape, out pts); return Intersect.Intersects(this, shape, out pts);
} }
/// <summary> /// <summary>
@@ -419,7 +419,7 @@ namespace OpenNest.Geometry
/// <returns></returns> /// <returns></returns>
public override bool Intersects(Shape shape, out List<Vector> pts) public override bool Intersects(Shape shape, out List<Vector> pts)
{ {
return Helper.Intersects(this, shape, out pts); return Intersect.Intersects(this, shape, out pts);
} }
/// <summary> /// <summary>

View File

@@ -0,0 +1,202 @@
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using OpenNest.Math;
namespace OpenNest.Geometry
{
public static class GeometryOptimizer
{
public static void Optimize(IList<Arc> arcs)
{
for (int i = 0; i < arcs.Count; ++i)
{
var arc = arcs[i];
var coradialArcs = arcs.GetCoradialArs(arc, i);
int index = 0;
while (index < coradialArcs.Count)
{
Arc arc2 = coradialArcs[index];
Arc joinArc;
if (!TryJoinArcs(arc, arc2, out joinArc))
{
index++;
continue;
}
coradialArcs.Remove(arc2);
arcs.Remove(arc2);
arc = joinArc;
index = 0;
}
arcs[i] = arc;
}
}
public static void Optimize(IList<Line> lines)
{
for (int i = 0; i < lines.Count; ++i)
{
var line = lines[i];
var collinearLines = lines.GetCollinearLines(line, i);
var index = 0;
while (index < collinearLines.Count)
{
Line line2 = collinearLines[index];
Line joinLine;
if (!TryJoinLines(line, line2, out joinLine))
{
index++;
continue;
}
collinearLines.Remove(line2);
lines.Remove(line2);
line = joinLine;
index = 0;
}
lines[i] = line;
}
}
public static bool TryJoinLines(Line line1, Line line2, out Line lineOut)
{
lineOut = null;
if (line1 == line2)
return false;
if (!line1.IsCollinearTo(line2))
return false;
bool onPoint = false;
if (line1.StartPoint == line2.StartPoint)
onPoint = true;
else if (line1.StartPoint == line2.EndPoint)
onPoint = true;
else if (line1.EndPoint == line2.StartPoint)
onPoint = true;
else if (line1.EndPoint == line2.EndPoint)
onPoint = true;
var t1 = line1.StartPoint.Y > line1.EndPoint.Y ? line1.StartPoint.Y : line1.EndPoint.Y;
var t2 = line2.StartPoint.Y > line2.EndPoint.Y ? line2.StartPoint.Y : line2.EndPoint.Y;
var b1 = line1.StartPoint.Y < line1.EndPoint.Y ? line1.StartPoint.Y : line1.EndPoint.Y;
var b2 = line2.StartPoint.Y < line2.EndPoint.Y ? line2.StartPoint.Y : line2.EndPoint.Y;
var l1 = line1.StartPoint.X < line1.EndPoint.X ? line1.StartPoint.X : line1.EndPoint.X;
var l2 = line2.StartPoint.X < line2.EndPoint.X ? line2.StartPoint.X : line2.EndPoint.X;
var r1 = line1.StartPoint.X > line1.EndPoint.X ? line1.StartPoint.X : line1.EndPoint.X;
var r2 = line2.StartPoint.X > line2.EndPoint.X ? line2.StartPoint.X : line2.EndPoint.X;
if (!onPoint)
{
if (t1 < b2 - Tolerance.Epsilon) return false;
if (b1 > t2 + Tolerance.Epsilon) return false;
if (l1 > r2 + Tolerance.Epsilon) return false;
if (r1 < l2 - Tolerance.Epsilon) return false;
}
var l = l1 < l2 ? l1 : l2;
var r = r1 > r2 ? r1 : r2;
var t = t1 > t2 ? t1 : t2;
var b = b1 < b2 ? b1 : b2;
if (!line1.IsVertical() && line1.Slope() < 0)
lineOut = new Line(new Vector(l, t), new Vector(r, b));
else
lineOut = new Line(new Vector(l, b), new Vector(r, t));
return true;
}
public static bool TryJoinArcs(Arc arc1, Arc arc2, out Arc arcOut)
{
arcOut = null;
if (arc1 == arc2)
return false;
if (arc1.Center != arc2.Center)
return false;
if (!arc1.Radius.IsEqualTo(arc2.Radius))
return false;
if (arc1.StartAngle > arc1.EndAngle)
arc1.StartAngle -= Angle.TwoPI;
if (arc2.StartAngle > arc2.EndAngle)
arc2.StartAngle -= Angle.TwoPI;
if (arc1.EndAngle < arc2.StartAngle || arc1.StartAngle > arc2.EndAngle)
return false;
var startAngle = arc1.StartAngle < arc2.StartAngle ? arc1.StartAngle : arc2.StartAngle;
var endAngle = arc1.EndAngle > arc2.EndAngle ? arc1.EndAngle : arc2.EndAngle;
if (startAngle < 0) startAngle += Angle.TwoPI;
if (endAngle < 0) endAngle += Angle.TwoPI;
arcOut = new Arc(arc1.Center, arc1.Radius, startAngle, endAngle);
return true;
}
private static List<Line> GetCollinearLines(this IList<Line> lines, Line line, int startIndex)
{
var collinearLines = new List<Line>();
Parallel.For(startIndex, lines.Count, index =>
{
var compareLine = lines[index];
if (Object.ReferenceEquals(line, compareLine))
return;
if (!line.IsCollinearTo(compareLine))
return;
lock (collinearLines)
{
collinearLines.Add(compareLine);
}
});
return collinearLines;
}
private static List<Arc> GetCoradialArs(this IList<Arc> arcs, Arc arc, int startIndex)
{
var coradialArcs = new List<Arc>();
Parallel.For(startIndex, arcs.Count, index =>
{
var compareArc = arcs[index];
if (Object.ReferenceEquals(arc, compareArc))
return;
if (!arc.IsCoradialTo(compareArc))
return;
lock (coradialArcs)
{
coradialArcs.Add(compareArc);
}
});
return coradialArcs;
}
}
}

View File

@@ -0,0 +1,373 @@
using System.Collections.Generic;
using System.Linq;
using OpenNest.Math;
namespace OpenNest.Geometry
{
public static class Intersect
{
internal static bool Intersects(Arc arc1, Arc arc2, out List<Vector> pts)
{
var c1 = new Circle(arc1.Center, arc1.Radius);
var c2 = new Circle(arc2.Center, arc2.Radius);
if (!Intersects(c1, c2, out pts))
{
pts = new List<Vector>();
return false;
}
pts = pts.Where(pt =>
Angle.IsBetweenRad(arc1.Center.AngleTo(pt), arc1.StartAngle, arc1.EndAngle, arc1.IsReversed) &&
Angle.IsBetweenRad(arc2.Center.AngleTo(pt), arc2.StartAngle, arc2.EndAngle, arc2.IsReversed))
.ToList();
return pts.Count > 0;
}
internal static bool Intersects(Arc arc, Circle circle, out List<Vector> pts)
{
var c1 = new Circle(arc.Center, arc.Radius);
if (!Intersects(c1, circle, out pts))
{
pts = new List<Vector>();
return false;
}
pts = pts.Where(pt => Angle.IsBetweenRad(
arc.Center.AngleTo(pt),
arc.StartAngle,
arc.EndAngle,
arc.IsReversed)).ToList();
return pts.Count > 0;
}
internal static bool Intersects(Arc arc, Line line, out List<Vector> pts)
{
var c1 = new Circle(arc.Center, arc.Radius);
if (!Intersects(c1, line, out pts))
{
pts = new List<Vector>();
return false;
}
pts = pts.Where(pt => Angle.IsBetweenRad(
arc.Center.AngleTo(pt),
arc.StartAngle,
arc.EndAngle,
arc.IsReversed)).ToList();
return pts.Count > 0;
}
internal static bool Intersects(Arc arc, Shape shape, out List<Vector> pts)
{
var pts2 = new List<Vector>();
foreach (var geo in shape.Entities)
{
List<Vector> pts3;
geo.Intersects(arc, out pts3);
pts2.AddRange(pts3);
}
pts = pts2.Where(pt => Angle.IsBetweenRad(
arc.Center.AngleTo(pt),
arc.StartAngle,
arc.EndAngle,
arc.IsReversed)).ToList();
return pts.Count > 0;
}
internal static bool Intersects(Arc arc, Polygon polygon, out List<Vector> pts)
{
var pts2 = new List<Vector>();
var lines = polygon.ToLines();
foreach (var line in lines)
{
List<Vector> pts3;
Intersects(arc, line, out pts3);
pts2.AddRange(pts3);
}
pts = pts2.Where(pt => Angle.IsBetweenRad(
arc.Center.AngleTo(pt),
arc.StartAngle,
arc.EndAngle,
arc.IsReversed)).ToList();
return pts.Count > 0;
}
internal static bool Intersects(Circle circle1, Circle circle2, out List<Vector> pts)
{
var distance = circle1.Center.DistanceTo(circle2.Center);
// check if circles are too far apart
if (distance > circle1.Radius + circle2.Radius)
{
pts = new List<Vector>();
return false;
}
// check if one circle contains the other
if (distance < System.Math.Abs(circle1.Radius - circle2.Radius))
{
pts = new List<Vector>();
return false;
}
var d = circle2.Center - circle1.Center;
var a = (circle1.Radius * circle1.Radius - circle2.Radius * circle2.Radius + distance * distance) / (2.0 * distance);
var h = System.Math.Sqrt(circle1.Radius * circle1.Radius - a * a);
var pt = new Vector(
circle1.Center.X + (a * d.X) / distance,
circle1.Center.Y + (a * d.Y) / distance);
var i1 = new Vector(
pt.X + (h * d.Y) / distance,
pt.Y - (h * d.X) / distance);
var i2 = new Vector(
pt.X - (h * d.Y) / distance,
pt.Y + (h * d.X) / distance);
pts = i1 != i2 ? new List<Vector> { i1, i2 } : new List<Vector> { i1 };
return true;
}
internal static bool Intersects(Circle circle, Line line, out List<Vector> pts)
{
var d1 = line.EndPoint - line.StartPoint;
var d2 = line.StartPoint - circle.Center;
var a = d1.X * d1.X + d1.Y * d1.Y;
var b = (d1.X * d2.X + d1.Y * d2.Y) * 2;
var c = (d2.X * d2.X + d2.Y * d2.Y) - circle.Radius * circle.Radius;
var det = b * b - 4 * a * c;
if ((a <= Tolerance.Epsilon) || (det < 0))
{
pts = new List<Vector>();
return false;
}
double t;
pts = new List<Vector>();
if (det.IsEqualTo(0))
{
t = -b / (2 * a);
var pt1 = new Vector(line.StartPoint.X + t * d1.X, line.StartPoint.Y + t * d1.Y);
if (line.BoundingBox.Contains(pt1))
pts.Add(pt1);
return true;
}
t = (-b + System.Math.Sqrt(det)) / (2 * a);
var pt2 = new Vector(line.StartPoint.X + t * d1.X, line.StartPoint.Y + t * d1.Y);
if (line.BoundingBox.Contains(pt2))
pts.Add(pt2);
t = (-b - System.Math.Sqrt(det)) / (2 * a);
var pt3 = new Vector(line.StartPoint.X + t * d1.X, line.StartPoint.Y + t * d1.Y);
if (line.BoundingBox.Contains(pt3))
pts.Add(pt3);
return true;
}
internal static bool Intersects(Circle circle, Shape shape, out List<Vector> pts)
{
pts = new List<Vector>();
foreach (var geo in shape.Entities)
{
List<Vector> pts3;
geo.Intersects(circle, out pts3);
pts.AddRange(pts3);
}
return pts.Count > 0;
}
internal static bool Intersects(Circle circle, Polygon polygon, out List<Vector> pts)
{
pts = new List<Vector>();
var lines = polygon.ToLines();
foreach (var line in lines)
{
List<Vector> pts3;
Intersects(circle, line, out pts3);
pts.AddRange(pts3);
}
return pts.Count > 0;
}
internal static bool Intersects(Line line1, Line line2, out Vector pt)
{
var a1 = line1.EndPoint.Y - line1.StartPoint.Y;
var b1 = line1.StartPoint.X - line1.EndPoint.X;
var c1 = a1 * line1.StartPoint.X + b1 * line1.StartPoint.Y;
var a2 = line2.EndPoint.Y - line2.StartPoint.Y;
var b2 = line2.StartPoint.X - line2.EndPoint.X;
var c2 = a2 * line2.StartPoint.X + b2 * line2.StartPoint.Y;
var d = a1 * b2 - a2 * b1;
if (d.IsEqualTo(0.0))
{
pt = Vector.Zero;
return false;
}
var x = (b2 * c1 - b1 * c2) / d;
var y = (a1 * c2 - a2 * c1) / d;
pt = new Vector(x, y);
return line1.BoundingBox.Contains(pt) && line2.BoundingBox.Contains(pt);
}
internal static bool Intersects(Line line, Shape shape, out List<Vector> pts)
{
pts = new List<Vector>();
foreach (var geo in shape.Entities)
{
List<Vector> pts3;
geo.Intersects(line, out pts3);
pts.AddRange(pts3);
}
return pts.Count > 0;
}
internal static bool Intersects(Line line, Polygon polygon, out List<Vector> pts)
{
pts = new List<Vector>();
var lines = polygon.ToLines();
foreach (var line2 in lines)
{
Vector pt;
if (Intersects(line, line2, out pt))
pts.Add(pt);
}
return pts.Count > 0;
}
internal static bool Intersects(Shape shape1, Shape shape2, out List<Vector> pts)
{
pts = new List<Vector>();
for (int i = 0; i < shape1.Entities.Count; i++)
{
var geo1 = shape1.Entities[i];
for (int j = 0; j < shape2.Entities.Count; j++)
{
List<Vector> pts2;
bool success = false;
var geo2 = shape2.Entities[j];
switch (geo2.Type)
{
case EntityType.Arc:
success = geo1.Intersects((Arc)geo2, out pts2);
break;
case EntityType.Circle:
success = geo1.Intersects((Circle)geo2, out pts2);
break;
case EntityType.Line:
success = geo1.Intersects((Line)geo2, out pts2);
break;
case EntityType.Shape:
success = geo1.Intersects((Shape)geo2, out pts2);
break;
case EntityType.Polygon:
success = geo1.Intersects((Polygon)geo2, out pts2);
break;
default:
continue;
}
if (success)
pts.AddRange(pts2);
}
}
return pts.Count > 0;
}
internal static bool Intersects(Shape shape, Polygon polygon, out List<Vector> pts)
{
pts = new List<Vector>();
var lines = polygon.ToLines();
for (int i = 0; i < shape.Entities.Count; i++)
{
var geo = shape.Entities[i];
for (int j = 0; j < lines.Count; j++)
{
var line = lines[j];
List<Vector> pts2;
if (geo.Intersects(line, out pts2))
pts.AddRange(pts2);
}
}
return pts.Count > 0;
}
internal static bool Intersects(Polygon polygon1, Polygon polygon2, out List<Vector> pts)
{
pts = new List<Vector>();
var lines1 = polygon1.ToLines();
var lines2 = polygon2.ToLines();
for (int i = 0; i < lines1.Count; i++)
{
var line1 = lines1[i];
for (int j = 0; j < lines2.Count; j++)
{
var line2 = lines2[j];
Vector pt;
if (Intersects(line1, line2, out pt))
pts.Add(pt);
}
}
return pts.Count > 0;
}
}
}

View File

@@ -456,7 +456,7 @@ namespace OpenNest.Geometry
public override bool Intersects(Arc arc) public override bool Intersects(Arc arc)
{ {
List<Vector> pts; List<Vector> pts;
return Helper.Intersects(arc, this, out pts); return Intersect.Intersects(arc, this, out pts);
} }
/// <summary> /// <summary>
@@ -467,7 +467,7 @@ namespace OpenNest.Geometry
/// <returns></returns> /// <returns></returns>
public override bool Intersects(Arc arc, out List<Vector> pts) public override bool Intersects(Arc arc, out List<Vector> pts)
{ {
return Helper.Intersects(arc, this, out pts); return Intersect.Intersects(arc, this, out pts);
} }
/// <summary> /// <summary>
@@ -478,7 +478,7 @@ namespace OpenNest.Geometry
public override bool Intersects(Circle circle) public override bool Intersects(Circle circle)
{ {
List<Vector> pts; List<Vector> pts;
return Helper.Intersects(circle, this, out pts); return Intersect.Intersects(circle, this, out pts);
} }
/// <summary> /// <summary>
@@ -489,7 +489,7 @@ namespace OpenNest.Geometry
/// <returns></returns> /// <returns></returns>
public override bool Intersects(Circle circle, out List<Vector> pts) public override bool Intersects(Circle circle, out List<Vector> pts)
{ {
return Helper.Intersects(circle, this, out pts); return Intersect.Intersects(circle, this, out pts);
} }
/// <summary> /// <summary>
@@ -512,7 +512,7 @@ namespace OpenNest.Geometry
public override bool Intersects(Line line, out List<Vector> pts) public override bool Intersects(Line line, out List<Vector> pts)
{ {
Vector pt; Vector pt;
var success = Helper.Intersects(this, line, out pt); var success = Intersect.Intersects(this, line, out pt);
pts = new List<Vector>(new[] { pt }); pts = new List<Vector>(new[] { pt });
return success; return success;
} }
@@ -525,7 +525,7 @@ namespace OpenNest.Geometry
public override bool Intersects(Polygon polygon) public override bool Intersects(Polygon polygon)
{ {
List<Vector> pts; List<Vector> pts;
return Helper.Intersects(this, polygon, out pts); return Intersect.Intersects(this, polygon, out pts);
} }
/// <summary> /// <summary>
@@ -536,7 +536,7 @@ namespace OpenNest.Geometry
/// <returns></returns> /// <returns></returns>
public override bool Intersects(Polygon polygon, out List<Vector> pts) public override bool Intersects(Polygon polygon, out List<Vector> pts)
{ {
return Helper.Intersects(this, polygon, out pts); return Intersect.Intersects(this, polygon, out pts);
} }
/// <summary> /// <summary>
@@ -547,7 +547,7 @@ namespace OpenNest.Geometry
public override bool Intersects(Shape shape) public override bool Intersects(Shape shape)
{ {
List<Vector> pts; List<Vector> pts;
return Helper.Intersects(this, shape, out pts); return Intersect.Intersects(this, shape, out pts);
} }
/// <summary> /// <summary>
@@ -558,7 +558,7 @@ namespace OpenNest.Geometry
/// <returns></returns> /// <returns></returns>
public override bool Intersects(Shape shape, out List<Vector> pts) public override bool Intersects(Shape shape, out List<Vector> pts)
{ {
return Helper.Intersects(this, shape, out pts); return Intersect.Intersects(this, shape, out pts);
} }
/// <summary> /// <summary>

View File

@@ -364,7 +364,7 @@ namespace OpenNest.Geometry
public override bool Intersects(Arc arc) public override bool Intersects(Arc arc)
{ {
List<Vector> pts; List<Vector> pts;
return Helper.Intersects(arc, this, out pts); return Intersect.Intersects(arc, this, out pts);
} }
/// <summary> /// <summary>
@@ -375,7 +375,7 @@ namespace OpenNest.Geometry
/// <returns></returns> /// <returns></returns>
public override bool Intersects(Arc arc, out List<Vector> pts) public override bool Intersects(Arc arc, out List<Vector> pts)
{ {
return Helper.Intersects(arc, this, out pts); return Intersect.Intersects(arc, this, out pts);
} }
/// <summary> /// <summary>
@@ -386,7 +386,7 @@ namespace OpenNest.Geometry
public override bool Intersects(Circle circle) public override bool Intersects(Circle circle)
{ {
List<Vector> pts; List<Vector> pts;
return Helper.Intersects(circle, this, out pts); return Intersect.Intersects(circle, this, out pts);
} }
/// <summary> /// <summary>
@@ -397,7 +397,7 @@ namespace OpenNest.Geometry
/// <returns></returns> /// <returns></returns>
public override bool Intersects(Circle circle, out List<Vector> pts) public override bool Intersects(Circle circle, out List<Vector> pts)
{ {
return Helper.Intersects(circle, this, out pts); return Intersect.Intersects(circle, this, out pts);
} }
/// <summary> /// <summary>
@@ -408,7 +408,7 @@ namespace OpenNest.Geometry
public override bool Intersects(Line line) public override bool Intersects(Line line)
{ {
List<Vector> pts; List<Vector> pts;
return Helper.Intersects(line, this, out pts); return Intersect.Intersects(line, this, out pts);
} }
/// <summary> /// <summary>
@@ -419,7 +419,7 @@ namespace OpenNest.Geometry
/// <returns></returns> /// <returns></returns>
public override bool Intersects(Line line, out List<Vector> pts) public override bool Intersects(Line line, out List<Vector> pts)
{ {
return Helper.Intersects(line, this, out pts); return Intersect.Intersects(line, this, out pts);
} }
/// <summary> /// <summary>
@@ -430,7 +430,7 @@ namespace OpenNest.Geometry
public override bool Intersects(Polygon polygon) public override bool Intersects(Polygon polygon)
{ {
List<Vector> pts; List<Vector> pts;
return Helper.Intersects(this, polygon, out pts); return Intersect.Intersects(this, polygon, out pts);
} }
/// <summary> /// <summary>
@@ -441,7 +441,7 @@ namespace OpenNest.Geometry
/// <returns></returns> /// <returns></returns>
public override bool Intersects(Polygon polygon, out List<Vector> pts) public override bool Intersects(Polygon polygon, out List<Vector> pts)
{ {
return Helper.Intersects(this, polygon, out pts); return Intersect.Intersects(this, polygon, out pts);
} }
/// <summary> /// <summary>
@@ -452,7 +452,7 @@ namespace OpenNest.Geometry
public override bool Intersects(Shape shape) public override bool Intersects(Shape shape)
{ {
List<Vector> pts; List<Vector> pts;
return Helper.Intersects(shape, this, out pts); return Intersect.Intersects(shape, this, out pts);
} }
/// <summary> /// <summary>
@@ -463,7 +463,7 @@ namespace OpenNest.Geometry
/// <returns></returns> /// <returns></returns>
public override bool Intersects(Shape shape, out List<Vector> pts) public override bool Intersects(Shape shape, out List<Vector> pts)
{ {
return Helper.Intersects(shape, this, out pts); return Intersect.Intersects(shape, this, out pts);
} }
/// <summary> /// <summary>
@@ -493,13 +493,37 @@ namespace OpenNest.Geometry
{ {
var n = Vertices.Count - 1; var n = Vertices.Count - 1;
// Pre-calculate edge bounding boxes to speed up intersection checks.
var edgeBounds = new (double minX, double maxX, double minY, double maxY)[n];
for (var i = 0; i < n; i++) for (var i = 0; i < n; i++)
{ {
var v1 = Vertices[i];
var v2 = Vertices[i + 1];
edgeBounds[i] = (
System.Math.Min(v1.X, v2.X) - Tolerance.Epsilon,
System.Math.Max(v1.X, v2.X) + Tolerance.Epsilon,
System.Math.Min(v1.Y, v2.Y) - Tolerance.Epsilon,
System.Math.Max(v1.Y, v2.Y) + Tolerance.Epsilon
);
}
for (var i = 0; i < n; i++)
{
var bi = edgeBounds[i];
for (var j = i + 2; j < n; j++) for (var j = i + 2; j < n; j++)
{ {
if (i == 0 && j == n - 1) if (i == 0 && j == n - 1)
continue; continue;
var bj = edgeBounds[j];
// Prune with bounding box check.
if (bi.maxX < bj.minX || bj.maxX < bi.minX ||
bi.maxY < bj.minY || bj.maxY < bi.minY)
{
continue;
}
if (SegmentsIntersect(Vertices[i], Vertices[i + 1], Vertices[j], Vertices[j + 1], out pt)) if (SegmentsIntersect(Vertices[i], Vertices[i + 1], Vertices[j], Vertices[j + 1], out pt))
{ {
edgeI = i; edgeI = i;

View File

@@ -159,8 +159,8 @@ namespace OpenNest.Geometry
} }
} }
Helper.Optimize(lines); GeometryOptimizer.Optimize(lines);
Helper.Optimize(arcs); GeometryOptimizer.Optimize(arcs);
} }
/// <summary> /// <summary>
@@ -534,7 +534,7 @@ namespace OpenNest.Geometry
{ {
Vector intersection; Vector intersection;
if (Helper.Intersects(offsetLine, lastOffsetLine, out intersection)) if (Intersect.Intersects(offsetLine, lastOffsetLine, out intersection))
{ {
offsetLine.StartPoint = intersection; offsetLine.StartPoint = intersection;
lastOffsetLine.EndPoint = intersection; lastOffsetLine.EndPoint = intersection;
@@ -577,7 +577,7 @@ namespace OpenNest.Geometry
public override bool Intersects(Arc arc) public override bool Intersects(Arc arc)
{ {
List<Vector> pts; List<Vector> pts;
return Helper.Intersects(arc, this, out pts); return Intersect.Intersects(arc, this, out pts);
} }
/// <summary> /// <summary>
@@ -588,7 +588,7 @@ namespace OpenNest.Geometry
/// <returns></returns> /// <returns></returns>
public override bool Intersects(Arc arc, out List<Vector> pts) public override bool Intersects(Arc arc, out List<Vector> pts)
{ {
return Helper.Intersects(arc, this, out pts); return Intersect.Intersects(arc, this, out pts);
} }
/// <summary> /// <summary>
@@ -599,7 +599,7 @@ namespace OpenNest.Geometry
public override bool Intersects(Circle circle) public override bool Intersects(Circle circle)
{ {
List<Vector> pts; List<Vector> pts;
return Helper.Intersects(circle, this, out pts); return Intersect.Intersects(circle, this, out pts);
} }
/// <summary> /// <summary>
@@ -610,7 +610,7 @@ namespace OpenNest.Geometry
/// <returns></returns> /// <returns></returns>
public override bool Intersects(Circle circle, out List<Vector> pts) public override bool Intersects(Circle circle, out List<Vector> pts)
{ {
return Helper.Intersects(circle, this, out pts); return Intersect.Intersects(circle, this, out pts);
} }
/// <summary> /// <summary>
@@ -621,7 +621,7 @@ namespace OpenNest.Geometry
public override bool Intersects(Line line) public override bool Intersects(Line line)
{ {
List<Vector> pts; List<Vector> pts;
return Helper.Intersects(line, this, out pts); return Intersect.Intersects(line, this, out pts);
} }
/// <summary> /// <summary>
@@ -632,7 +632,7 @@ namespace OpenNest.Geometry
/// <returns></returns> /// <returns></returns>
public override bool Intersects(Line line, out List<Vector> pts) public override bool Intersects(Line line, out List<Vector> pts)
{ {
return Helper.Intersects(line, this, out pts); return Intersect.Intersects(line, this, out pts);
} }
/// <summary> /// <summary>
@@ -643,7 +643,7 @@ namespace OpenNest.Geometry
public override bool Intersects(Polygon polygon) public override bool Intersects(Polygon polygon)
{ {
List<Vector> pts; List<Vector> pts;
return Helper.Intersects(this, polygon, out pts); return Intersect.Intersects(this, polygon, out pts);
} }
/// <summary> /// <summary>
@@ -654,7 +654,7 @@ namespace OpenNest.Geometry
/// <returns></returns> /// <returns></returns>
public override bool Intersects(Polygon polygon, out List<Vector> pts) public override bool Intersects(Polygon polygon, out List<Vector> pts)
{ {
return Helper.Intersects(this, polygon, out pts); return Intersect.Intersects(this, polygon, out pts);
} }
/// <summary> /// <summary>
@@ -665,7 +665,7 @@ namespace OpenNest.Geometry
public override bool Intersects(Shape shape) public override bool Intersects(Shape shape)
{ {
List<Vector> pts; List<Vector> pts;
return Helper.Intersects(this, shape, out pts); return Intersect.Intersects(this, shape, out pts);
} }
/// <summary> /// <summary>
@@ -676,7 +676,7 @@ namespace OpenNest.Geometry
/// <returns></returns> /// <returns></returns>
public override bool Intersects(Shape shape, out List<Vector> pts) public override bool Intersects(Shape shape, out List<Vector> pts)
{ {
return Helper.Intersects(this, shape, out pts); return Intersect.Intersects(this, shape, out pts);
} }
/// <summary> /// <summary>

View File

@@ -0,0 +1,150 @@
using System.Collections.Generic;
using System.Diagnostics;
using OpenNest.Math;
namespace OpenNest.Geometry
{
public static class ShapeBuilder
{
public static List<Shape> GetShapes(IEnumerable<Entity> entities)
{
var lines = new List<Line>();
var arcs = new List<Arc>();
var circles = new List<Circle>();
var shapes = new List<Shape>();
var entities2 = new Queue<Entity>(entities);
while (entities2.Count > 0)
{
var entity = entities2.Dequeue();
switch (entity.Type)
{
case EntityType.Arc:
arcs.Add((Arc)entity);
break;
case EntityType.Circle:
circles.Add((Circle)entity);
break;
case EntityType.Line:
lines.Add((Line)entity);
break;
case EntityType.Shape:
var shape = (Shape)entity;
shape.Entities.ForEach(e => entities2.Enqueue(e));
break;
default:
Debug.Fail("Unhandled geometry type");
break;
}
}
foreach (var circle in circles)
{
var shape = new Shape();
shape.Entities.Add(circle);
shape.UpdateBounds();
shapes.Add(shape);
}
var entityList = new List<Entity>();
entityList.AddRange(lines);
entityList.AddRange(arcs);
while (entityList.Count > 0)
{
var next = entityList[0];
var shape = new Shape();
shape.Entities.Add(next);
entityList.RemoveAt(0);
Vector startPoint = new Vector();
Entity connected;
switch (next.Type)
{
case EntityType.Arc:
var arc = (Arc)next;
startPoint = arc.EndPoint();
break;
case EntityType.Line:
var line = (Line)next;
startPoint = line.EndPoint;
break;
}
while ((connected = GetConnected(startPoint, entityList)) != null)
{
shape.Entities.Add(connected);
entityList.Remove(connected);
switch (connected.Type)
{
case EntityType.Arc:
var arc = (Arc)connected;
startPoint = arc.EndPoint();
break;
case EntityType.Line:
var line = (Line)connected;
startPoint = line.EndPoint;
break;
}
}
shape.UpdateBounds();
shapes.Add(shape);
}
return shapes;
}
internal static Entity GetConnected(Vector pt, IEnumerable<Entity> geometry)
{
var tol = Tolerance.ChainTolerance;
foreach (var geo in geometry)
{
switch (geo.Type)
{
case EntityType.Arc:
var arc = (Arc)geo;
if (arc.StartPoint().DistanceTo(pt) <= tol)
return arc;
if (arc.EndPoint().DistanceTo(pt) <= tol)
{
arc.Reverse();
return arc;
}
break;
case EntityType.Line:
var line = (Line)geo;
if (line.StartPoint.DistanceTo(pt) <= tol)
return line;
if (line.EndPoint.DistanceTo(pt) <= tol)
{
line.Reverse();
return line;
}
break;
}
}
return null;
}
}
}

View File

@@ -16,7 +16,7 @@ namespace OpenNest.Geometry
private void Update(List<Entity> entities) private void Update(List<Entity> entities)
{ {
var shapes = Helper.GetShapes(entities); var shapes = ShapeBuilder.GetShapes(entities);
Perimeter = shapes[0]; Perimeter = shapes[0];
Cutouts = new List<Shape>(); Cutouts = new List<Shape>();

View File

@@ -0,0 +1,614 @@
using System;
using System.Collections.Generic;
using System.Linq;
using OpenNest.Math;
namespace OpenNest.Geometry
{
public static class SpatialQuery
{
/// <summary>
/// Finds the distance from a vertex to a line segment along a push axis.
/// Returns double.MaxValue if the ray does not hit the segment.
/// </summary>
private static double RayEdgeDistance(Vector vertex, Line edge, PushDirection direction)
{
return RayEdgeDistance(
vertex.X, vertex.Y,
edge.pt1.X, edge.pt1.Y, edge.pt2.X, edge.pt2.Y,
direction);
}
[System.Runtime.CompilerServices.MethodImpl(
System.Runtime.CompilerServices.MethodImplOptions.AggressiveInlining)]
private static double RayEdgeDistance(
double vx, double vy,
double p1x, double p1y, double p2x, double p2y,
PushDirection direction)
{
switch (direction)
{
case PushDirection.Left:
case PushDirection.Right:
{
var dy = p2y - p1y;
if (System.Math.Abs(dy) < Tolerance.Epsilon)
return double.MaxValue;
var t = (vy - p1y) / dy;
if (t < -Tolerance.Epsilon || t > 1.0 + Tolerance.Epsilon)
return double.MaxValue;
var ix = p1x + t * (p2x - p1x);
var dist = direction == PushDirection.Left ? vx - ix : ix - vx;
if (dist > Tolerance.Epsilon) return dist;
if (dist >= -Tolerance.Epsilon) return 0;
return double.MaxValue;
}
case PushDirection.Down:
case PushDirection.Up:
{
var dx = p2x - p1x;
if (System.Math.Abs(dx) < Tolerance.Epsilon)
return double.MaxValue;
var t = (vx - p1x) / dx;
if (t < -Tolerance.Epsilon || t > 1.0 + Tolerance.Epsilon)
return double.MaxValue;
var iy = p1y + t * (p2y - p1y);
var dist = direction == PushDirection.Down ? vy - iy : iy - vy;
if (dist > Tolerance.Epsilon) return dist;
if (dist >= -Tolerance.Epsilon) return 0;
return double.MaxValue;
}
default:
return double.MaxValue;
}
}
/// <summary>
/// Computes the minimum translation distance along a push direction before
/// any edge of movingLines contacts any edge of stationaryLines.
/// Returns double.MaxValue if no collision path exists.
/// </summary>
public static double DirectionalDistance(List<Line> movingLines, List<Line> stationaryLines, PushDirection direction)
{
var minDist = double.MaxValue;
// Case 1: Each moving vertex -> each stationary edge
var movingVertices = new HashSet<Vector>();
for (int i = 0; i < movingLines.Count; i++)
{
movingVertices.Add(movingLines[i].pt1);
movingVertices.Add(movingLines[i].pt2);
}
var stationaryEdges = new (Vector start, Vector end)[stationaryLines.Count];
for (int i = 0; i < stationaryLines.Count; i++)
stationaryEdges[i] = (stationaryLines[i].pt1, stationaryLines[i].pt2);
// Sort edges for pruning if not already sorted (usually they aren't here)
if (direction == PushDirection.Left || direction == PushDirection.Right)
stationaryEdges = stationaryEdges.OrderBy(e => System.Math.Min(e.start.Y, e.end.Y)).ToArray();
else
stationaryEdges = stationaryEdges.OrderBy(e => System.Math.Min(e.start.X, e.end.X)).ToArray();
foreach (var mv in movingVertices)
{
var d = OneWayDistance(mv, stationaryEdges, Vector.Zero, direction);
if (d < minDist) minDist = d;
}
// Case 2: Each stationary vertex -> each moving edge (opposite direction)
var opposite = OppositeDirection(direction);
var stationaryVertices = new HashSet<Vector>();
for (int i = 0; i < stationaryLines.Count; i++)
{
stationaryVertices.Add(stationaryLines[i].pt1);
stationaryVertices.Add(stationaryLines[i].pt2);
}
var movingEdges = new (Vector start, Vector end)[movingLines.Count];
for (int i = 0; i < movingLines.Count; i++)
movingEdges[i] = (movingLines[i].pt1, movingLines[i].pt2);
if (opposite == PushDirection.Left || opposite == PushDirection.Right)
movingEdges = movingEdges.OrderBy(e => System.Math.Min(e.start.Y, e.end.Y)).ToArray();
else
movingEdges = movingEdges.OrderBy(e => System.Math.Min(e.start.X, e.end.X)).ToArray();
foreach (var sv in stationaryVertices)
{
var d = OneWayDistance(sv, movingEdges, Vector.Zero, opposite);
if (d < minDist) minDist = d;
}
return minDist;
}
/// <summary>
/// Computes the minimum directional distance with the moving lines translated
/// by (movingDx, movingDy) without creating new Line objects.
/// </summary>
public static double DirectionalDistance(
List<Line> movingLines, double movingDx, double movingDy,
List<Line> stationaryLines, PushDirection direction)
{
var minDist = double.MaxValue;
var movingOffset = new Vector(movingDx, movingDy);
// Case 1: Each moving vertex -> each stationary edge
var movingVertices = new HashSet<Vector>();
for (int i = 0; i < movingLines.Count; i++)
{
movingVertices.Add(movingLines[i].pt1 + movingOffset);
movingVertices.Add(movingLines[i].pt2 + movingOffset);
}
var stationaryEdges = new (Vector start, Vector end)[stationaryLines.Count];
for (int i = 0; i < stationaryLines.Count; i++)
stationaryEdges[i] = (stationaryLines[i].pt1, stationaryLines[i].pt2);
if (direction == PushDirection.Left || direction == PushDirection.Right)
stationaryEdges = stationaryEdges.OrderBy(e => System.Math.Min(e.start.Y, e.end.Y)).ToArray();
else
stationaryEdges = stationaryEdges.OrderBy(e => System.Math.Min(e.start.X, e.end.X)).ToArray();
foreach (var mv in movingVertices)
{
var d = OneWayDistance(mv, stationaryEdges, Vector.Zero, direction);
if (d < minDist) minDist = d;
}
// Case 2: Each stationary vertex -> each moving edge (opposite direction)
var opposite = OppositeDirection(direction);
var stationaryVertices = new HashSet<Vector>();
for (int i = 0; i < stationaryLines.Count; i++)
{
stationaryVertices.Add(stationaryLines[i].pt1);
stationaryVertices.Add(stationaryLines[i].pt2);
}
var movingEdges = new (Vector start, Vector end)[movingLines.Count];
for (int i = 0; i < movingLines.Count; i++)
movingEdges[i] = (movingLines[i].pt1, movingLines[i].pt2);
if (opposite == PushDirection.Left || opposite == PushDirection.Right)
movingEdges = movingEdges.OrderBy(e => System.Math.Min(e.start.Y, e.end.Y)).ToArray();
else
movingEdges = movingEdges.OrderBy(e => System.Math.Min(e.start.X, e.end.X)).ToArray();
foreach (var sv in stationaryVertices)
{
var d = OneWayDistance(sv, movingEdges, movingOffset, opposite);
if (d < minDist) minDist = d;
}
return minDist;
}
/// <summary>
/// Packs line segments into a flat double array [x1,y1,x2,y2, ...] for GPU transfer.
/// </summary>
public static double[] FlattenLines(List<Line> lines)
{
var result = new double[lines.Count * 4];
for (int i = 0; i < lines.Count; i++)
{
var line = lines[i];
result[i * 4] = line.pt1.X;
result[i * 4 + 1] = line.pt1.Y;
result[i * 4 + 2] = line.pt2.X;
result[i * 4 + 3] = line.pt2.Y;
}
return result;
}
/// <summary>
/// Computes the minimum directional distance using raw edge arrays and location offsets
/// to avoid all intermediate object allocations.
/// </summary>
public static double DirectionalDistance(
(Vector start, Vector end)[] movingEdges, Vector movingOffset,
(Vector start, Vector end)[] stationaryEdges, Vector stationaryOffset,
PushDirection direction)
{
var minDist = double.MaxValue;
// Extract unique vertices from moving edges.
var movingVertices = new HashSet<Vector>();
for (var i = 0; i < movingEdges.Length; i++)
{
movingVertices.Add(movingEdges[i].start + movingOffset);
movingVertices.Add(movingEdges[i].end + movingOffset);
}
// Case 1: Each moving vertex -> each stationary edge
foreach (var mv in movingVertices)
{
var d = OneWayDistance(mv, stationaryEdges, stationaryOffset, direction);
if (d < minDist) minDist = d;
}
// Case 2: Each stationary vertex -> each moving edge (opposite direction)
var opposite = OppositeDirection(direction);
var stationaryVertices = new HashSet<Vector>();
for (var i = 0; i < stationaryEdges.Length; i++)
{
stationaryVertices.Add(stationaryEdges[i].start + stationaryOffset);
stationaryVertices.Add(stationaryEdges[i].end + stationaryOffset);
}
foreach (var sv in stationaryVertices)
{
var d = OneWayDistance(sv, movingEdges, movingOffset, opposite);
if (d < minDist) minDist = d;
}
return minDist;
}
public static double OneWayDistance(
Vector vertex, (Vector start, Vector end)[] edges, Vector edgeOffset,
PushDirection direction)
{
var minDist = double.MaxValue;
var vx = vertex.X;
var vy = vertex.Y;
// Pruning: edges are sorted by their perpendicular min-coordinate in PartBoundary.
if (direction == PushDirection.Left || direction == PushDirection.Right)
{
for (var i = 0; i < edges.Length; i++)
{
var e1 = edges[i].start + edgeOffset;
var e2 = edges[i].end + edgeOffset;
var minY = e1.Y < e2.Y ? e1.Y : e2.Y;
var maxY = e1.Y > e2.Y ? e1.Y : e2.Y;
// Since edges are sorted by minY, if vy < minY, then vy < all subsequent minY.
if (vy < minY - Tolerance.Epsilon)
break;
if (vy > maxY + Tolerance.Epsilon)
continue;
var d = RayEdgeDistance(vx, vy, e1.X, e1.Y, e2.X, e2.Y, direction);
if (d < minDist) minDist = d;
}
}
else // Up/Down
{
for (var i = 0; i < edges.Length; i++)
{
var e1 = edges[i].start + edgeOffset;
var e2 = edges[i].end + edgeOffset;
var minX = e1.X < e2.X ? e1.X : e2.X;
var maxX = e1.X > e2.X ? e1.X : e2.X;
// Since edges are sorted by minX, if vx < minX, then vx < all subsequent minX.
if (vx < minX - Tolerance.Epsilon)
break;
if (vx > maxX + Tolerance.Epsilon)
continue;
var d = RayEdgeDistance(vx, vy, e1.X, e1.Y, e2.X, e2.Y, direction);
if (d < minDist) minDist = d;
}
}
return minDist;
}
public static PushDirection OppositeDirection(PushDirection direction)
{
switch (direction)
{
case PushDirection.Left: return PushDirection.Right;
case PushDirection.Right: return PushDirection.Left;
case PushDirection.Up: return PushDirection.Down;
case PushDirection.Down: return PushDirection.Up;
default: return direction;
}
}
public static bool IsHorizontalDirection(PushDirection direction)
{
return direction is PushDirection.Left or PushDirection.Right;
}
public static double EdgeDistance(Box box, Box boundary, PushDirection direction)
{
switch (direction)
{
case PushDirection.Left: return box.Left - boundary.Left;
case PushDirection.Right: return boundary.Right - box.Right;
case PushDirection.Up: return boundary.Top - box.Top;
case PushDirection.Down: return box.Bottom - boundary.Bottom;
default: return double.MaxValue;
}
}
public static Vector DirectionToOffset(PushDirection direction, double distance)
{
switch (direction)
{
case PushDirection.Left: return new Vector(-distance, 0);
case PushDirection.Right: return new Vector(distance, 0);
case PushDirection.Up: return new Vector(0, distance);
case PushDirection.Down: return new Vector(0, -distance);
default: return new Vector();
}
}
public static double DirectionalGap(Box from, Box to, PushDirection direction)
{
switch (direction)
{
case PushDirection.Left: return from.Left - to.Right;
case PushDirection.Right: return to.Left - from.Right;
case PushDirection.Up: return to.Bottom - from.Top;
case PushDirection.Down: return from.Bottom - to.Top;
default: return double.MaxValue;
}
}
public static double ClosestDistanceLeft(Box box, List<Box> boxes)
{
var closestDistance = double.MaxValue;
for (int i = 0; i < boxes.Count; i++)
{
var compareBox = boxes[i];
RelativePosition pos;
if (!box.IsHorizontalTo(compareBox, out pos))
continue;
if (pos != RelativePosition.Right)
continue;
var distance = box.Left - compareBox.Right;
if (distance < closestDistance)
closestDistance = distance;
}
return closestDistance == double.MaxValue ? double.NaN : closestDistance;
}
public static double ClosestDistanceRight(Box box, List<Box> boxes)
{
var closestDistance = double.MaxValue;
for (int i = 0; i < boxes.Count; i++)
{
var compareBox = boxes[i];
RelativePosition pos;
if (!box.IsHorizontalTo(compareBox, out pos))
continue;
if (pos != RelativePosition.Left)
continue;
var distance = compareBox.Left - box.Right;
if (distance < closestDistance)
closestDistance = distance;
}
return closestDistance == double.MaxValue ? double.NaN : closestDistance;
}
public static double ClosestDistanceUp(Box box, List<Box> boxes)
{
var closestDistance = double.MaxValue;
for (int i = 0; i < boxes.Count; i++)
{
var compareBox = boxes[i];
RelativePosition pos;
if (!box.IsVerticalTo(compareBox, out pos))
continue;
if (pos != RelativePosition.Bottom)
continue;
var distance = compareBox.Bottom - box.Top;
if (distance < closestDistance)
closestDistance = distance;
}
return closestDistance == double.MaxValue ? double.NaN : closestDistance;
}
public static double ClosestDistanceDown(Box box, List<Box> boxes)
{
var closestDistance = double.MaxValue;
for (int i = 0; i < boxes.Count; i++)
{
var compareBox = boxes[i];
RelativePosition pos;
if (!box.IsVerticalTo(compareBox, out pos))
continue;
if (pos != RelativePosition.Top)
continue;
var distance = box.Bottom - compareBox.Top;
if (distance < closestDistance)
closestDistance = distance;
}
return closestDistance == double.MaxValue ? double.NaN : closestDistance;
}
public static Box GetLargestBoxVertically(Vector pt, Box bounds, IEnumerable<Box> boxes)
{
var verticalBoxes = boxes.Where(b => !(b.Left > pt.X || b.Right < pt.X)).ToList();
#region Find Top/Bottom Limits
var top = double.MaxValue;
var btm = double.MinValue;
foreach (var box in verticalBoxes)
{
var boxBtm = box.Bottom;
var boxTop = box.Top;
if (boxBtm > pt.Y && boxBtm < top)
top = boxBtm;
else if (box.Top < pt.Y && boxTop > btm)
btm = boxTop;
}
if (top == double.MaxValue)
{
if (bounds.Top > pt.Y)
top = bounds.Top;
else return Box.Empty;
}
if (btm == double.MinValue)
{
if (bounds.Bottom < pt.Y)
btm = bounds.Bottom;
else return Box.Empty;
}
#endregion
var horizontalBoxes = boxes.Where(b => !(b.Bottom >= top || b.Top <= btm)).ToList();
#region Find Left/Right Limits
var lft = double.MinValue;
var rgt = double.MaxValue;
foreach (var box in horizontalBoxes)
{
var boxLft = box.Left;
var boxRgt = box.Right;
if (boxLft > pt.X && boxLft < rgt)
rgt = boxLft;
else if (boxRgt < pt.X && boxRgt > lft)
lft = boxRgt;
}
if (rgt == double.MaxValue)
{
if (bounds.Right > pt.X)
rgt = bounds.Right;
else return Box.Empty;
}
if (lft == double.MinValue)
{
if (bounds.Left < pt.X)
lft = bounds.Left;
else return Box.Empty;
}
#endregion
return new Box(lft, btm, rgt - lft, top - btm);
}
public static Box GetLargestBoxHorizontally(Vector pt, Box bounds, IEnumerable<Box> boxes)
{
var horizontalBoxes = boxes.Where(b => !(b.Bottom > pt.Y || b.Top < pt.Y)).ToList();
#region Find Left/Right Limits
var lft = double.MinValue;
var rgt = double.MaxValue;
foreach (var box in horizontalBoxes)
{
var boxLft = box.Left;
var boxRgt = box.Right;
if (boxLft > pt.X && boxLft < rgt)
rgt = boxLft;
else if (boxRgt < pt.X && boxRgt > lft)
lft = boxRgt;
}
if (rgt == double.MaxValue)
{
if (bounds.Right > pt.X)
rgt = bounds.Right;
else return Box.Empty;
}
if (lft == double.MinValue)
{
if (bounds.Left < pt.X)
lft = bounds.Left;
else return Box.Empty;
}
#endregion
var verticalBoxes = boxes.Where(b => !(b.Left >= rgt || b.Right <= lft)).ToList();
#region Find Top/Bottom Limits
var top = double.MaxValue;
var btm = double.MinValue;
foreach (var box in verticalBoxes)
{
var boxBtm = box.Bottom;
var boxTop = box.Top;
if (boxBtm > pt.Y && boxBtm < top)
top = boxBtm;
else if (box.Top < pt.Y && boxTop > btm)
btm = boxTop;
}
if (top == double.MaxValue)
{
if (bounds.Top > pt.Y)
top = bounds.Top;
else return Box.Empty;
}
if (btm == double.MinValue)
{
if (bounds.Bottom < pt.Y)
btm = bounds.Bottom;
else return Box.Empty;
}
#endregion
return new Box(lft, btm, rgt - lft, top - btm);
}
}
}

View File

@@ -3,7 +3,7 @@ using OpenNest.Math;
namespace OpenNest.Geometry namespace OpenNest.Geometry
{ {
public struct Vector public struct Vector : IEquatable<Vector>
{ {
public static readonly Vector Invalid = new Vector(double.NaN, double.NaN); public static readonly Vector Invalid = new Vector(double.NaN, double.NaN);
public static readonly Vector Zero = new Vector(0, 0); public static readonly Vector Zero = new Vector(0, 0);
@@ -17,6 +17,29 @@ namespace OpenNest.Geometry
Y = y; Y = y;
} }
public bool Equals(Vector other)
{
return X.IsEqualTo(other.X) && Y.IsEqualTo(other.Y);
}
public override bool Equals(object obj)
{
return obj is Vector other && Equals(other);
}
public override int GetHashCode()
{
unchecked
{
// Use a simple but effective hash combine.
// We use a small epsilon-safe rounding if needed, but for uniqueness in HashSet
// during a single operation, raw bits or slightly rounded is usually fine.
// However, IsEqualTo uses Tolerance.Epsilon, so we should probably round to some precision.
// But typically for these geometric algorithms, exact matches (or very close) are what we want to prune.
return (X.GetHashCode() * 397) ^ Y.GetHashCode();
}
}
public double DistanceTo(Vector pt) public double DistanceTo(Vector pt)
{ {
var vx = pt.X - X; var vx = pt.X - X;
@@ -186,21 +209,6 @@ namespace OpenNest.Geometry
return new Vector(X, Y); return new Vector(X, Y);
} }
public override bool Equals(object obj)
{
if (!(obj is Vector))
return false;
var pt = (Vector)obj;
return (X.IsEqualTo(pt.X)) && (Y.IsEqualTo(pt.Y));
}
public override int GetHashCode()
{
return base.GetHashCode();
}
public override string ToString() public override string ToString()
{ {
return string.Format("[Vector: X:{0}, Y:{1}]", X, Y); return string.Format("[Vector: X:{0}, Y:{1}]", X, Y);

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,38 @@
namespace OpenNest.Math
{
public static class Rounding
{
/// <summary>
/// Rounds a number down to the nearest factor.
/// </summary>
/// <param name="num"></param>
/// <param name="factor"></param>
/// <returns></returns>
public static double RoundDownToNearest(double num, double factor)
{
return factor.IsEqualTo(0) ? num : System.Math.Floor(num / factor) * factor;
}
/// <summary>
/// Rounds a number up to the nearest factor.
/// </summary>
/// <param name="num"></param>
/// <param name="factor"></param>
/// <returns></returns>
public static double RoundUpToNearest(double num, double factor)
{
return factor.IsEqualTo(0) ? num : System.Math.Ceiling(num / factor) * factor;
}
/// <summary>
/// Rounds a number to the nearest factor using midpoint rounding convention.
/// </summary>
/// <param name="num"></param>
/// <param name="factor"></param>
/// <returns></returns>
public static double RoundToNearest(double num, double factor)
{
return factor.IsEqualTo(0) ? num : System.Math.Round(num / factor) * factor;
}
}
}

View File

@@ -51,6 +51,8 @@ namespace OpenNest
public Program Program { get; private set; } public Program Program { get; private set; }
public bool HasManualLeadIns { get; set; }
/// <summary> /// <summary>
/// Gets the rotation of the part in radians. /// Gets the rotation of the part in radians.
/// </summary> /// </summary>
@@ -149,31 +151,25 @@ namespace OpenNest
pts = new List<Vector>(); pts = new List<Vector>();
var entities1 = ConvertProgram.ToGeometry(Program) var entities1 = ConvertProgram.ToGeometry(Program)
.Where(e => e.Layer != SpecialLayers.Rapid); .Where(e => e.Layer != SpecialLayers.Rapid)
.ToList();
var entities2 = ConvertProgram.ToGeometry(part.Program) var entities2 = ConvertProgram.ToGeometry(part.Program)
.Where(e => e.Layer != SpecialLayers.Rapid); .Where(e => e.Layer != SpecialLayers.Rapid)
.ToList();
var shapes1 = Helper.GetShapes(entities1); if (entities1.Count == 0 || entities2.Count == 0)
var shapes2 = Helper.GetShapes(entities2); return false;
shapes1.ForEach(shape => shape.Offset(Location)); var perimeter1 = new ShapeProfile(entities1).Perimeter;
shapes2.ForEach(shape => shape.Offset(part.Location)); var perimeter2 = new ShapeProfile(entities2).Perimeter;
for (int i = 0; i < shapes1.Count; i++) if (perimeter1 == null || perimeter2 == null)
{ return false;
var shape1 = shapes1[i];
for (int j = 0; j < shapes2.Count; j++) perimeter1.Offset(Location);
{ perimeter2.Offset(part.Location);
var shape2 = shapes2[j];
List<Vector> pts2;
if (shape1.Intersects(shape2, out pts2)) return perimeter1.Intersects(perimeter2, out pts);
pts.AddRange(pts2);
}
}
return pts.Count > 0;
} }
public double Left public double Left
@@ -216,8 +212,9 @@ namespace OpenNest
/// </summary> /// </summary>
public Part CloneAtOffset(Vector offset) public Part CloneAtOffset(Vector offset)
{ {
var clonedProgram = Program.Clone() as Program; // Share the Program instance — offset-only copies don't modify the program codes.
var part = new Part(BaseDrawing, clonedProgram, // This is a major performance win for tiling large patterns.
var part = new Part(BaseDrawing, Program,
location + offset, location + offset,
new Box(BoundingBox.X + offset.X, BoundingBox.Y + offset.Y, new Box(BoundingBox.X + offset.X, BoundingBox.Y + offset.Y,
BoundingBox.Width, BoundingBox.Length)); BoundingBox.Width, BoundingBox.Length));

View File

@@ -0,0 +1,126 @@
using System.Collections.Generic;
using System.Linq;
using OpenNest.Converters;
using OpenNest.Geometry;
namespace OpenNest
{
public static class PartGeometry
{
public static List<Line> GetPartLines(Part part, double chordTolerance = 0.001)
{
var entities = ConvertProgram.ToGeometry(part.Program);
var shapes = ShapeBuilder.GetShapes(entities.Where(e => e.Layer != SpecialLayers.Rapid));
var lines = new List<Line>();
foreach (var shape in shapes)
{
var polygon = shape.ToPolygonWithTolerance(chordTolerance);
polygon.Offset(part.Location);
lines.AddRange(polygon.ToLines());
}
return lines;
}
public static List<Line> GetPartLines(Part part, PushDirection facingDirection, double chordTolerance = 0.001)
{
var entities = ConvertProgram.ToGeometry(part.Program);
var shapes = ShapeBuilder.GetShapes(entities.Where(e => e.Layer != SpecialLayers.Rapid));
var lines = new List<Line>();
foreach (var shape in shapes)
{
var polygon = shape.ToPolygonWithTolerance(chordTolerance);
polygon.Offset(part.Location);
lines.AddRange(GetDirectionalLines(polygon, facingDirection));
}
return lines;
}
public static List<Line> GetOffsetPartLines(Part part, double spacing, double chordTolerance = 0.001)
{
var entities = ConvertProgram.ToGeometry(part.Program);
var shapes = ShapeBuilder.GetShapes(entities.Where(e => e.Layer != SpecialLayers.Rapid));
var lines = new List<Line>();
foreach (var shape in shapes)
{
// Add chord tolerance to compensate for inscribed polygon chords
// being inside the actual offset arcs.
var offsetEntity = shape.OffsetEntity(spacing + chordTolerance, OffsetSide.Left) as Shape;
if (offsetEntity == null)
continue;
var polygon = offsetEntity.ToPolygonWithTolerance(chordTolerance);
polygon.RemoveSelfIntersections();
polygon.Offset(part.Location);
lines.AddRange(polygon.ToLines());
}
return lines;
}
public static List<Line> GetOffsetPartLines(Part part, double spacing, PushDirection facingDirection, double chordTolerance = 0.001)
{
var entities = ConvertProgram.ToGeometry(part.Program);
var shapes = ShapeBuilder.GetShapes(entities.Where(e => e.Layer != SpecialLayers.Rapid));
var lines = new List<Line>();
foreach (var shape in shapes)
{
var offsetEntity = shape.OffsetEntity(spacing + chordTolerance, OffsetSide.Left) as Shape;
if (offsetEntity == null)
continue;
var polygon = offsetEntity.ToPolygonWithTolerance(chordTolerance);
polygon.RemoveSelfIntersections();
polygon.Offset(part.Location);
lines.AddRange(GetDirectionalLines(polygon, facingDirection));
}
return lines;
}
/// <summary>
/// Returns only polygon edges whose outward normal faces the specified direction.
/// </summary>
private static List<Line> GetDirectionalLines(Polygon polygon, PushDirection facingDirection)
{
if (polygon.Vertices.Count < 3)
return polygon.ToLines();
var sign = polygon.RotationDirection() == RotationType.CCW ? 1.0 : -1.0;
var lines = new List<Line>();
var last = polygon.Vertices[0];
for (int i = 1; i < polygon.Vertices.Count; i++)
{
var current = polygon.Vertices[i];
var dx = current.X - last.X;
var dy = current.Y - last.Y;
bool keep;
switch (facingDirection)
{
case PushDirection.Left: keep = -sign * dy > 0; break;
case PushDirection.Right: keep = sign * dy > 0; break;
case PushDirection.Up: keep = -sign * dx > 0; break;
case PushDirection.Down: keep = sign * dx > 0; break;
default: keep = true; break;
}
if (keep)
lines.Add(new Line(last, current));
last = current;
}
return lines;
}
}
}

View File

@@ -412,8 +412,8 @@ namespace OpenNest
} }
Size = new Size( Size = new Size(
Helper.RoundUpToNearest(width, roundingFactor), Rounding.RoundUpToNearest(width, roundingFactor),
Helper.RoundUpToNearest(length, roundingFactor)); Rounding.RoundUpToNearest(length, roundingFactor));
} }
/// <summary> /// <summary>

View File

@@ -11,7 +11,7 @@ namespace OpenNest
public static TimingInfo GetTimingInfo(Program pgm) public static TimingInfo GetTimingInfo(Program pgm)
{ {
var entities = ConvertProgram.ToGeometry(pgm); var entities = ConvertProgram.ToGeometry(pgm);
var shapes = Helper.GetShapes(entities.Where(entity => entity.Layer != SpecialLayers.Rapid)); var shapes = ShapeBuilder.GetShapes(entities.Where(entity => entity.Layer != SpecialLayers.Rapid));
var info = new TimingInfo { PierceCount = shapes.Count }; var info = new TimingInfo { PierceCount = shapes.Count };
var last = entities[0]; var last = entities[0];

View File

@@ -0,0 +1,223 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Threading;
using OpenNest.Converters;
using OpenNest.Geometry;
using OpenNest.Math;
namespace OpenNest
{
/// <summary>
/// Mixed-part geometry-aware nesting using NFP-based collision avoidance
/// and simulated annealing optimization.
/// </summary>
public static class AutoNester
{
public static List<Part> Nest(List<NestItem> items, Plate plate,
CancellationToken cancellation = default)
{
var workArea = plate.WorkArea();
var halfSpacing = plate.PartSpacing / 2.0;
var nfpCache = new NfpCache();
var candidateRotations = new Dictionary<int, List<double>>();
// Extract perimeter polygons for each unique drawing.
foreach (var item in items)
{
var drawing = item.Drawing;
if (candidateRotations.ContainsKey(drawing.Id))
continue;
var perimeterPolygon = ExtractPerimeterPolygon(drawing, halfSpacing);
if (perimeterPolygon == null)
{
Debug.WriteLine($"[AutoNest] Skipping drawing '{drawing.Name}': no valid perimeter");
continue;
}
// Compute candidate rotations for this drawing.
var rotations = ComputeCandidateRotations(item, perimeterPolygon, workArea);
candidateRotations[drawing.Id] = rotations;
// Register polygons at each candidate rotation.
foreach (var rotation in rotations)
{
var rotatedPolygon = RotatePolygon(perimeterPolygon, rotation);
nfpCache.RegisterPolygon(drawing.Id, rotation, rotatedPolygon);
}
}
if (candidateRotations.Count == 0)
return new List<Part>();
// Pre-compute all NFPs.
nfpCache.PreComputeAll();
Debug.WriteLine($"[AutoNest] NFP cache: {nfpCache.Count} entries for {candidateRotations.Count} drawings");
// Run simulated annealing optimizer.
var optimizer = new SimulatedAnnealing();
var result = optimizer.Optimize(items, workArea, nfpCache, candidateRotations, cancellation);
if (result.Sequence == null || result.Sequence.Count == 0)
return new List<Part>();
// Final BLF placement with the best solution.
var blf = new BottomLeftFill(workArea, nfpCache);
var placedParts = blf.Fill(result.Sequence);
var parts = BottomLeftFill.ToNestParts(placedParts);
Debug.WriteLine($"[AutoNest] Result: {parts.Count} parts placed, {result.Iterations} SA iterations");
return parts;
}
/// <summary>
/// Extracts the perimeter polygon from a drawing, inflated by half-spacing.
/// </summary>
private static Polygon ExtractPerimeterPolygon(Drawing drawing, double halfSpacing)
{
var entities = ConvertProgram.ToGeometry(drawing.Program)
.Where(e => e.Layer != SpecialLayers.Rapid)
.ToList();
if (entities.Count == 0)
return null;
var definedShape = new ShapeProfile(entities);
var perimeter = definedShape.Perimeter;
if (perimeter == null)
return null;
// Inflate by half-spacing if spacing is non-zero.
Shape inflated;
if (halfSpacing > 0)
{
var offsetEntity = perimeter.OffsetEntity(halfSpacing, OffsetSide.Right);
inflated = offsetEntity as Shape ?? perimeter;
}
else
{
inflated = perimeter;
}
// Convert to polygon with circumscribed arcs for tight nesting.
var polygon = inflated.ToPolygonWithTolerance(0.01, circumscribe: true);
if (polygon.Vertices.Count < 3)
return null;
// Normalize: move reference point to origin.
polygon.UpdateBounds();
var bb = polygon.BoundingBox;
polygon.Offset(-bb.Left, -bb.Bottom);
return polygon;
}
/// <summary>
/// Computes candidate rotation angles for a drawing.
/// </summary>
private static List<double> ComputeCandidateRotations(NestItem item,
Polygon perimeterPolygon, Box workArea)
{
var rotations = new List<double> { 0 };
// Add hull-edge angles from the polygon itself.
var hullAngles = ComputeHullEdgeAngles(perimeterPolygon);
foreach (var angle in hullAngles)
{
if (!rotations.Any(r => r.IsEqualTo(angle)))
rotations.Add(angle);
}
// Add 90-degree rotation.
if (!rotations.Any(r => r.IsEqualTo(Angle.HalfPI)))
rotations.Add(Angle.HalfPI);
// For narrow work areas, add sweep angles.
var partBounds = perimeterPolygon.BoundingBox;
var partLongest = System.Math.Max(partBounds.Width, partBounds.Length);
var workShort = System.Math.Min(workArea.Width, workArea.Length);
if (workShort < partLongest)
{
var step = Angle.ToRadians(5);
for (var a = 0.0; a < System.Math.PI; a += step)
{
if (!rotations.Any(r => r.IsEqualTo(a)))
rotations.Add(a);
}
}
return rotations;
}
/// <summary>
/// Computes convex hull edge angles from a polygon for candidate rotations.
/// </summary>
private static List<double> ComputeHullEdgeAngles(Polygon polygon)
{
var angles = new List<double>();
if (polygon.Vertices.Count < 3)
return angles;
var hull = ConvexHull.Compute(polygon.Vertices);
var verts = hull.Vertices;
var n = hull.IsClosed() ? verts.Count - 1 : verts.Count;
for (var i = 0; i < n; i++)
{
var next = (i + 1) % n;
var dx = verts[next].X - verts[i].X;
var dy = verts[next].Y - verts[i].Y;
if (dx * dx + dy * dy < Tolerance.Epsilon)
continue;
var angle = -System.Math.Atan2(dy, dx);
if (!angles.Any(a => a.IsEqualTo(angle)))
angles.Add(angle);
}
return angles;
}
/// <summary>
/// Creates a rotated copy of a polygon around the origin.
/// </summary>
private static Polygon RotatePolygon(Polygon polygon, double angle)
{
if (angle.IsEqualTo(0))
return polygon;
var result = new Polygon();
var cos = System.Math.Cos(angle);
var sin = System.Math.Sin(angle);
foreach (var v in polygon.Vertices)
{
result.Vertices.Add(new Vector(
v.X * cos - v.Y * sin,
v.X * sin + v.Y * cos));
}
// Re-normalize to origin.
result.UpdateBounds();
var bb = result.BoundingBox;
result.Offset(-bb.Left, -bb.Bottom);
return result;
}
}
}

View File

@@ -13,6 +13,7 @@ namespace OpenNest.Engine.BestFit
new ConcurrentDictionary<CacheKey, List<BestFitResult>>(); new ConcurrentDictionary<CacheKey, List<BestFitResult>>();
public static Func<Drawing, double, IPairEvaluator> CreateEvaluator { get; set; } public static Func<Drawing, double, IPairEvaluator> CreateEvaluator { get; set; }
public static Func<ISlideComputer> CreateSlideComputer { get; set; }
public static List<BestFitResult> GetOrCompute( public static List<BestFitResult> GetOrCompute(
Drawing drawing, double plateWidth, double plateHeight, Drawing drawing, double plateWidth, double plateHeight,
@@ -24,6 +25,7 @@ namespace OpenNest.Engine.BestFit
return cached; return cached;
IPairEvaluator evaluator = null; IPairEvaluator evaluator = null;
ISlideComputer slideComputer = null;
try try
{ {
@@ -33,13 +35,107 @@ namespace OpenNest.Engine.BestFit
catch { /* fall back to default evaluator */ } catch { /* fall back to default evaluator */ }
} }
var finder = new BestFitFinder(plateWidth, plateHeight, evaluator); if (CreateSlideComputer != null)
{
try { slideComputer = CreateSlideComputer(); }
catch { /* fall back to CPU slide computation */ }
}
var finder = new BestFitFinder(plateWidth, plateHeight, evaluator, slideComputer);
var results = finder.FindBestFits(drawing, spacing, StepSize); var results = finder.FindBestFits(drawing, spacing, StepSize);
_cache.TryAdd(key, results); _cache.TryAdd(key, results);
return results; return results;
} }
finally finally
{
(evaluator as IDisposable)?.Dispose();
// Slide computer is managed by the factory as a singleton — don't dispose here
}
}
public static void ComputeForSizes(
Drawing drawing, double spacing,
IEnumerable<(double Width, double Height)> plateSizes)
{
// Skip sizes that are already cached.
var needed = new List<(double Width, double Height)>();
foreach (var size in plateSizes)
{
var key = new CacheKey(drawing, size.Width, size.Height, spacing);
if (!_cache.ContainsKey(key))
needed.Add(size);
}
if (needed.Count == 0)
return;
// Find the largest plate to use for the initial computation — this
// keeps the filter maximally permissive so we don't discard results
// that a smaller plate might still use after re-filtering.
var maxWidth = 0.0;
var maxHeight = 0.0;
foreach (var size in needed)
{
if (size.Width > maxWidth) maxWidth = size.Width;
if (size.Height > maxHeight) maxHeight = size.Height;
}
IPairEvaluator evaluator = null;
ISlideComputer slideComputer = null;
try
{
if (CreateEvaluator != null)
{
try { evaluator = CreateEvaluator(drawing, spacing); }
catch { /* fall back to default evaluator */ }
}
if (CreateSlideComputer != null)
{
try { slideComputer = CreateSlideComputer(); }
catch { /* fall back to CPU slide computation */ }
}
// Compute candidates and evaluate once with the largest plate.
var finder = new BestFitFinder(maxWidth, maxHeight, evaluator, slideComputer);
var baseResults = finder.FindBestFits(drawing, spacing, StepSize);
// Cache a filtered copy for each plate size.
foreach (var size in needed)
{
var filter = new BestFitFilter
{
MaxPlateWidth = size.Width,
MaxPlateHeight = size.Height
};
var copy = new List<BestFitResult>(baseResults.Count);
for (var i = 0; i < baseResults.Count; i++)
{
var r = baseResults[i];
copy.Add(new BestFitResult
{
Candidate = r.Candidate,
RotatedArea = r.RotatedArea,
BoundingWidth = r.BoundingWidth,
BoundingHeight = r.BoundingHeight,
OptimalRotation = r.OptimalRotation,
TrueArea = r.TrueArea,
HullAngles = r.HullAngles,
Keep = r.Keep,
Reason = r.Reason
});
}
filter.Apply(copy);
var key = new CacheKey(drawing, size.Width, size.Height, spacing);
_cache.TryAdd(key, copy);
}
}
finally
{ {
(evaluator as IDisposable)?.Dispose(); (evaluator as IDisposable)?.Dispose();
} }
@@ -54,6 +150,28 @@ namespace OpenNest.Engine.BestFit
} }
} }
public static void Populate(Drawing drawing, double plateWidth, double plateHeight,
double spacing, List<BestFitResult> results)
{
if (results == null || results.Count == 0)
return;
var key = new CacheKey(drawing, plateWidth, plateHeight, spacing);
_cache.TryAdd(key, results);
}
public static Dictionary<(double PlateWidth, double PlateHeight, double Spacing), List<BestFitResult>>
GetAllForDrawing(Drawing drawing)
{
var result = new Dictionary<(double, double, double), List<BestFitResult>>();
foreach (var kvp in _cache)
{
if (ReferenceEquals(kvp.Key.Drawing, drawing))
result[(kvp.Key.PlateWidth, kvp.Key.PlateHeight, kvp.Key.Spacing)] = kvp.Value;
}
return result;
}
public static void Clear() public static void Clear()
{ {
_cache.Clear(); _cache.Clear();

View File

@@ -12,15 +12,21 @@ namespace OpenNest.Engine.BestFit
public class BestFitFinder public class BestFitFinder
{ {
private readonly IPairEvaluator _evaluator; private readonly IPairEvaluator _evaluator;
private readonly ISlideComputer _slideComputer;
private readonly BestFitFilter _filter; private readonly BestFitFilter _filter;
public BestFitFinder(double maxPlateWidth, double maxPlateHeight, IPairEvaluator evaluator = null) public BestFitFinder(double maxPlateWidth, double maxPlateHeight,
IPairEvaluator evaluator = null, ISlideComputer slideComputer = null)
{ {
_evaluator = evaluator ?? new PairEvaluator(); _evaluator = evaluator ?? new PairEvaluator();
_slideComputer = slideComputer;
var plateAspect = System.Math.Max(maxPlateWidth, maxPlateHeight) /
System.Math.Max(System.Math.Min(maxPlateWidth, maxPlateHeight), 0.001);
_filter = new BestFitFilter _filter = new BestFitFilter
{ {
MaxPlateWidth = maxPlateWidth, MaxPlateWidth = maxPlateWidth,
MaxPlateHeight = maxPlateHeight MaxPlateHeight = maxPlateHeight,
MaxAspectRatio = System.Math.Max(5.0, plateAspect)
}; };
} }
@@ -78,7 +84,7 @@ namespace OpenNest.Engine.BestFit
foreach (var angle in angles) foreach (var angle in angles)
{ {
var desc = string.Format("{0:F1} deg rotated, offset slide", Angle.ToDegrees(angle)); var desc = string.Format("{0:F1} deg rotated, offset slide", Angle.ToDegrees(angle));
strategies.Add(new RotationSlideStrategy(angle, type++, desc)); strategies.Add(new RotationSlideStrategy(angle, type++, desc, _slideComputer));
} }
return strategies; return strategies;
@@ -102,6 +108,7 @@ namespace OpenNest.Engine.BestFit
AddUniqueAngle(angles, Angle.NormalizeRad(hullAngle + System.Math.PI)); AddUniqueAngle(angles, Angle.NormalizeRad(hullAngle + System.Math.PI));
} }
angles.Sort();
return angles; return angles;
} }
@@ -109,14 +116,30 @@ namespace OpenNest.Engine.BestFit
{ {
var entities = ConvertProgram.ToGeometry(drawing.Program) var entities = ConvertProgram.ToGeometry(drawing.Program)
.Where(e => e.Layer != SpecialLayers.Rapid); .Where(e => e.Layer != SpecialLayers.Rapid);
var shapes = Helper.GetShapes(entities); var shapes = ShapeBuilder.GetShapes(entities);
var points = new List<Vector>(); var points = new List<Vector>();
foreach (var shape in shapes) foreach (var shape in shapes)
{ {
var polygon = shape.ToPolygonWithTolerance(0.01); // Extract key points from original geometry — line endpoints
points.AddRange(polygon.Vertices); // plus arc endpoints and cardinal extreme points. This avoids
// tessellating arcs into many chords that flood the hull with
// near-duplicate edge angles.
foreach (var entity in shape.Entities)
{
if (entity is Line line)
{
points.Add(line.StartPoint);
points.Add(line.EndPoint);
}
else if (entity is Arc arc)
{
points.Add(arc.StartPoint());
points.Add(arc.EndPoint());
AddArcExtremes(points, arc);
}
}
} }
if (points.Count < 3) if (points.Count < 3)
@@ -143,13 +166,49 @@ namespace OpenNest.Engine.BestFit
return hullAngles; return hullAngles;
} }
/// <summary>
/// Adds the cardinal extreme points of an arc (0°, 90°, 180°, 270°)
/// if they fall within the arc's angular span.
/// </summary>
private static void AddArcExtremes(List<Vector> points, Arc arc)
{
var a1 = arc.StartAngle;
var a2 = arc.EndAngle;
if (arc.IsReversed)
Generic.Swap(ref a1, ref a2);
// Right (0°)
if (Angle.IsBetweenRad(Angle.TwoPI, a1, a2))
points.Add(new Vector(arc.Center.X + arc.Radius, arc.Center.Y));
// Top (90°)
if (Angle.IsBetweenRad(Angle.HalfPI, a1, a2))
points.Add(new Vector(arc.Center.X, arc.Center.Y + arc.Radius));
// Left (180°)
if (Angle.IsBetweenRad(System.Math.PI, a1, a2))
points.Add(new Vector(arc.Center.X - arc.Radius, arc.Center.Y));
// Bottom (270°)
if (Angle.IsBetweenRad(System.Math.PI * 1.5, a1, a2))
points.Add(new Vector(arc.Center.X, arc.Center.Y - arc.Radius));
}
/// <summary>
/// Minimum angular separation (radians) between hull-derived rotation candidates.
/// Tessellated arcs produce many hull edges with nearly identical angles;
/// a 1° threshold collapses those into a single representative.
/// </summary>
private const double AngleTolerance = System.Math.PI / 36; // 5 degrees
private static void AddUniqueAngle(List<double> angles, double angle) private static void AddUniqueAngle(List<double> angles, double angle)
{ {
angle = Angle.NormalizeRad(angle); angle = Angle.NormalizeRad(angle);
foreach (var existing in angles) foreach (var existing in angles)
{ {
if (existing.IsEqualTo(angle)) if (existing.IsEqualTo(angle, AngleTolerance))
return; return;
} }

View File

@@ -14,6 +14,7 @@ namespace OpenNest.Engine.BestFit
public bool Keep { get; set; } public bool Keep { get; set; }
public string Reason { get; set; } public string Reason { get; set; }
public double TrueArea { get; set; } public double TrueArea { get; set; }
public List<double> HullAngles { get; set; }
public double Utilization public double Utilization
{ {

View File

@@ -0,0 +1,38 @@
using System;
namespace OpenNest.Engine.BestFit
{
/// <summary>
/// Batches directional-distance computations for multiple offset positions.
/// GPU implementations can process all offsets in a single kernel launch.
/// </summary>
public interface ISlideComputer : IDisposable
{
/// <summary>
/// Computes the minimum directional distance for each offset position.
/// </summary>
/// <param name="stationarySegments">Flat array [x1,y1,x2,y2, ...] for stationary edges.</param>
/// <param name="stationaryCount">Number of line segments in stationarySegments.</param>
/// <param name="movingTemplateSegments">Flat array [x1,y1,x2,y2, ...] for moving edges at origin.</param>
/// <param name="movingCount">Number of line segments in movingTemplateSegments.</param>
/// <param name="offsets">Flat array [dx,dy, dx,dy, ...] of translation offsets.</param>
/// <param name="offsetCount">Number of offset positions.</param>
/// <param name="direction">Push direction.</param>
/// <returns>Array of minimum distances, one per offset position.</returns>
double[] ComputeBatch(
double[] stationarySegments, int stationaryCount,
double[] movingTemplateSegments, int movingCount,
double[] offsets, int offsetCount,
PushDirection direction);
/// <summary>
/// Computes minimum directional distance for offsets with per-offset directions.
/// Uploads segment data once for all offsets, reducing GPU round-trips.
/// </summary>
double[] ComputeBatchMultiDir(
double[] stationarySegments, int stationaryCount,
double[] movingTemplateSegments, int movingCount,
double[] offsets, int offsetCount,
int[] directions);
}
}

View File

@@ -42,6 +42,7 @@ namespace OpenNest.Engine.BestFit
// Find optimal bounding rectangle via rotating calipers // Find optimal bounding rectangle via rotating calipers
double bestArea, bestWidth, bestHeight, bestRotation; double bestArea, bestWidth, bestHeight, bestRotation;
List<double> hullAngles = null;
if (allPoints.Count >= 3) if (allPoints.Count >= 3)
{ {
@@ -51,6 +52,7 @@ namespace OpenNest.Engine.BestFit
bestWidth = result.Width; bestWidth = result.Width;
bestHeight = result.Height; bestHeight = result.Height;
bestRotation = result.Angle; bestRotation = result.Angle;
hullAngles = RotationAnalysis.GetHullEdgeAngles(hull);
} }
else else
{ {
@@ -59,6 +61,7 @@ namespace OpenNest.Engine.BestFit
bestWidth = combinedBox.Width; bestWidth = combinedBox.Width;
bestHeight = combinedBox.Length; bestHeight = combinedBox.Length;
bestRotation = 0; bestRotation = 0;
hullAngles = new List<double> { 0 };
} }
var trueArea = drawing.Area * 2; var trueArea = drawing.Area * 2;
@@ -71,6 +74,7 @@ namespace OpenNest.Engine.BestFit
BoundingHeight = bestHeight, BoundingHeight = bestHeight,
OptimalRotation = bestRotation, OptimalRotation = bestRotation,
TrueArea = trueArea, TrueArea = trueArea,
HullAngles = hullAngles,
Keep = !overlaps, Keep = !overlaps,
Reason = overlaps ? "Overlap detected" : "Valid" Reason = overlaps ? "Overlap detected" : "Valid"
}; };
@@ -99,7 +103,7 @@ namespace OpenNest.Engine.BestFit
{ {
var entities = ConvertProgram.ToGeometry(part.Program) var entities = ConvertProgram.ToGeometry(part.Program)
.Where(e => e.Layer != SpecialLayers.Rapid); .Where(e => e.Layer != SpecialLayers.Rapid);
var shapes = Helper.GetShapes(entities); var shapes = ShapeBuilder.GetShapes(entities);
shapes.ForEach(s => s.Offset(part.Location)); shapes.ForEach(s => s.Offset(part.Location));
return shapes; return shapes;
} }
@@ -108,7 +112,7 @@ namespace OpenNest.Engine.BestFit
{ {
var entities = ConvertProgram.ToGeometry(part.Program) var entities = ConvertProgram.ToGeometry(part.Program)
.Where(e => e.Layer != SpecialLayers.Rapid); .Where(e => e.Layer != SpecialLayers.Rapid);
var shapes = Helper.GetShapes(entities); var shapes = ShapeBuilder.GetShapes(entities);
var points = new List<Vector>(); var points = new List<Vector>();
foreach (var shape in shapes) foreach (var shape in shapes)

View File

@@ -1,15 +1,25 @@
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq;
using OpenNest.Geometry; using OpenNest.Geometry;
namespace OpenNest.Engine.BestFit namespace OpenNest.Engine.BestFit
{ {
public class RotationSlideStrategy : IBestFitStrategy public class RotationSlideStrategy : IBestFitStrategy
{ {
public RotationSlideStrategy(double part2Rotation, int type, string description) private readonly ISlideComputer _slideComputer;
private static readonly PushDirection[] AllDirections =
{
PushDirection.Left, PushDirection.Down, PushDirection.Right, PushDirection.Up
};
public RotationSlideStrategy(double part2Rotation, int type, string description,
ISlideComputer slideComputer = null)
{ {
Part2Rotation = part2Rotation; Part2Rotation = part2Rotation;
Type = type; Type = type;
Description = description; Description = description;
_slideComputer = slideComputer;
} }
public double Part2Rotation { get; } public double Part2Rotation { get; }
@@ -23,43 +33,64 @@ namespace OpenNest.Engine.BestFit
var part1 = Part.CreateAtOrigin(drawing); var part1 = Part.CreateAtOrigin(drawing);
var part2Template = Part.CreateAtOrigin(drawing, Part2Rotation); var part2Template = Part.CreateAtOrigin(drawing, Part2Rotation);
var halfSpacing = spacing / 2;
var part1Lines = PartGeometry.GetOffsetPartLines(part1, halfSpacing);
var part2TemplateLines = PartGeometry.GetOffsetPartLines(part2Template, halfSpacing);
var bbox1 = part1.BoundingBox;
var bbox2 = part2Template.BoundingBox;
// Collect offsets and directions across all 4 axes
var allDx = new List<double>();
var allDy = new List<double>();
var allDirs = new List<PushDirection>();
foreach (var pushDir in AllDirections)
BuildOffsets(bbox1, bbox2, spacing, stepSize, pushDir, allDx, allDy, allDirs);
if (allDx.Count == 0)
return candidates;
// Compute all distances — single GPU dispatch or CPU loop
var distances = ComputeAllDistances(
part1Lines, part2TemplateLines, allDx, allDy, allDirs);
// Create candidates from valid results
var testNumber = 0; var testNumber = 0;
// Try pushing left (horizontal slide) for (var i = 0; i < allDx.Count; i++)
GenerateCandidatesForAxis( {
part1, part2Template, drawing, spacing, stepSize, var slideDist = distances[i];
PushDirection.Left, candidates, ref testNumber); if (slideDist >= double.MaxValue || slideDist < 0)
continue;
// Try pushing down (vertical slide) var dx = allDx[i];
GenerateCandidatesForAxis( var dy = allDy[i];
part1, part2Template, drawing, spacing, stepSize, var pushVector = GetPushVector(allDirs[i], slideDist);
PushDirection.Down, candidates, ref testNumber); var finalPosition = new Vector(
part2Template.Location.X + dx + pushVector.X,
part2Template.Location.Y + dy + pushVector.Y);
// Try pushing right (approach from left — finds concave interlocking) candidates.Add(new PairCandidate
GenerateCandidatesForAxis( {
part1, part2Template, drawing, spacing, stepSize, Drawing = drawing,
PushDirection.Right, candidates, ref testNumber); Part1Rotation = 0,
Part2Rotation = Part2Rotation,
// Try pushing up (approach from below — finds concave interlocking) Part2Offset = finalPosition,
GenerateCandidatesForAxis( StrategyType = Type,
part1, part2Template, drawing, spacing, stepSize, TestNumber = testNumber++,
PushDirection.Up, candidates, ref testNumber); Spacing = spacing
});
}
return candidates; return candidates;
} }
private void GenerateCandidatesForAxis( private static void BuildOffsets(
Part part1, Part part2Template, Drawing drawing, Box bbox1, Box bbox2, double spacing, double stepSize,
double spacing, double stepSize, PushDirection pushDir, PushDirection pushDir, List<double> allDx, List<double> allDy,
List<PairCandidate> candidates, ref int testNumber) List<PushDirection> allDirs)
{ {
const int CoarseMultiplier = 16;
const int MaxRegions = 5;
var bbox1 = part1.BoundingBox;
var bbox2 = part2Template.BoundingBox;
var halfSpacing = spacing / 2;
var isHorizontalPush = pushDir == PushDirection.Left || pushDir == PushDirection.Right; var isHorizontalPush = pushDir == PushDirection.Left || pushDir == PushDirection.Right;
double perpMin, perpMax, pushStartOffset; double perpMin, perpMax, pushStartOffset;
@@ -77,103 +108,124 @@ namespace OpenNest.Engine.BestFit
pushStartOffset = bbox1.Length + bbox2.Length + spacing * 2; pushStartOffset = bbox1.Length + bbox2.Length + spacing * 2;
} }
var part1Lines = Helper.GetOffsetPartLines(part1, halfSpacing); var alignedStart = System.Math.Ceiling(perpMin / stepSize) * stepSize;
var isPositiveStart = pushDir == PushDirection.Left || pushDir == PushDirection.Down;
var startPos = isPositiveStart ? pushStartOffset : -pushStartOffset;
// Start with the full range as a single region. for (var offset = alignedStart; offset <= perpMax; offset += stepSize)
var regions = new List<(double min, double max)> { (perpMin, perpMax) };
var currentStep = stepSize * CoarseMultiplier;
// Iterative halving: coarse sweep, select top regions, narrow, repeat.
while (currentStep > stepSize)
{ {
var hits = new List<(double offset, double slideDist)>(); allDx.Add(isHorizontalPush ? startPos : offset);
allDy.Add(isHorizontalPush ? offset : startPos);
allDirs.Add(pushDir);
}
}
foreach (var (regionMin, regionMax) in regions) private double[] ComputeAllDistances(
List<Line> part1Lines, List<Line> part2TemplateLines,
List<double> allDx, List<double> allDy, List<PushDirection> allDirs)
{
var count = allDx.Count;
if (_slideComputer != null)
{
var stationarySegments = SpatialQuery.FlattenLines(part1Lines);
var movingSegments = SpatialQuery.FlattenLines(part2TemplateLines);
var offsets = new double[count * 2];
var directions = new int[count];
for (var i = 0; i < count; i++)
{ {
var alignedStart = System.Math.Ceiling(regionMin / currentStep) * currentStep; offsets[i * 2] = allDx[i];
offsets[i * 2 + 1] = allDy[i];
for (var offset = alignedStart; offset <= regionMax; offset += currentStep) directions[i] = (int)allDirs[i];
{
var slideDist = ComputeSlideDistance(
part2Template, part1Lines, halfSpacing,
offset, pushStartOffset, isHorizontalPush, pushDir);
if (slideDist >= double.MaxValue || slideDist < 0)
continue;
hits.Add((offset, slideDist));
}
} }
if (hits.Count == 0) return _slideComputer.ComputeBatchMultiDir(
return; stationarySegments, part1Lines.Count,
movingSegments, part2TemplateLines.Count,
// Select top regions by tightest fit, deduplicating nearby hits. offsets, count, directions);
hits.Sort((a, b) => a.slideDist.CompareTo(b.slideDist));
var selectedOffsets = new List<double>();
foreach (var (offset, _) in hits)
{
var tooClose = false;
foreach (var selected in selectedOffsets)
{
if (System.Math.Abs(offset - selected) < currentStep)
{
tooClose = true;
break;
}
}
if (!tooClose)
{
selectedOffsets.Add(offset);
if (selectedOffsets.Count >= MaxRegions)
break;
}
}
// Build narrowed regions around selected offsets.
regions = new List<(double min, double max)>();
foreach (var offset in selectedOffsets)
{
var regionMin = System.Math.Max(perpMin, offset - currentStep);
var regionMax = System.Math.Min(perpMax, offset + currentStep);
regions.Add((regionMin, regionMax));
}
currentStep /= 2;
} }
// Final pass: sweep refined regions at stepSize, generating candidates. var results = new double[count];
foreach (var (regionMin, regionMax) in regions)
// Pre-calculate moving vertices in local space.
var movingVerticesLocal = new HashSet<Vector>();
for (var i = 0; i < part2TemplateLines.Count; i++)
{ {
var alignedStart = System.Math.Ceiling(regionMin / stepSize) * stepSize; movingVerticesLocal.Add(part2TemplateLines[i].StartPoint);
movingVerticesLocal.Add(part2TemplateLines[i].EndPoint);
for (var offset = alignedStart; offset <= regionMax; offset += stepSize)
{
var (slideDist, finalPosition) = ComputeSlideResult(
part2Template, part1Lines, halfSpacing,
offset, pushStartOffset, isHorizontalPush, pushDir);
if (slideDist >= double.MaxValue || slideDist < 0)
continue;
candidates.Add(new PairCandidate
{
Drawing = drawing,
Part1Rotation = 0,
Part2Rotation = Part2Rotation,
Part2Offset = finalPosition,
StrategyType = Type,
TestNumber = testNumber++,
Spacing = spacing
});
}
} }
var movingVerticesArray = movingVerticesLocal.ToArray();
// Pre-calculate stationary vertices in local space.
var stationaryVerticesLocal = new HashSet<Vector>();
for (var i = 0; i < part1Lines.Count; i++)
{
stationaryVerticesLocal.Add(part1Lines[i].StartPoint);
stationaryVerticesLocal.Add(part1Lines[i].EndPoint);
}
var stationaryVerticesArray = stationaryVerticesLocal.ToArray();
// Pre-sort stationary and moving edges for all 4 directions.
var stationaryEdgesByDir = new Dictionary<PushDirection, (Vector start, Vector end)[]>();
var movingEdgesByDir = new Dictionary<PushDirection, (Vector start, Vector end)[]>();
foreach (var dir in AllDirections)
{
var sEdges = new (Vector start, Vector end)[part1Lines.Count];
for (var i = 0; i < part1Lines.Count; i++)
sEdges[i] = (part1Lines[i].StartPoint, part1Lines[i].EndPoint);
if (dir == PushDirection.Left || dir == PushDirection.Right)
sEdges = sEdges.OrderBy(e => System.Math.Min(e.start.Y, e.end.Y)).ToArray();
else
sEdges = sEdges.OrderBy(e => System.Math.Min(e.start.X, e.end.X)).ToArray();
stationaryEdgesByDir[dir] = sEdges;
var opposite = SpatialQuery.OppositeDirection(dir);
var mEdges = new (Vector start, Vector end)[part2TemplateLines.Count];
for (var i = 0; i < part2TemplateLines.Count; i++)
mEdges[i] = (part2TemplateLines[i].StartPoint, part2TemplateLines[i].EndPoint);
if (opposite == PushDirection.Left || opposite == PushDirection.Right)
mEdges = mEdges.OrderBy(e => System.Math.Min(e.start.Y, e.end.Y)).ToArray();
else
mEdges = mEdges.OrderBy(e => System.Math.Min(e.start.X, e.end.X)).ToArray();
movingEdgesByDir[dir] = mEdges;
}
// Use Parallel.For for the heavy lifting.
System.Threading.Tasks.Parallel.For(0, count, i =>
{
var dx = allDx[i];
var dy = allDy[i];
var dir = allDirs[i];
var movingOffset = new Vector(dx, dy);
var sEdges = stationaryEdgesByDir[dir];
var mEdges = movingEdgesByDir[dir];
var opposite = SpatialQuery.OppositeDirection(dir);
var minDist = double.MaxValue;
// Case 1: Moving vertices -> Stationary edges
foreach (var mv in movingVerticesArray)
{
var d = SpatialQuery.OneWayDistance(mv + movingOffset, sEdges, Vector.Zero, dir);
if (d < minDist) minDist = d;
}
// Case 2: Stationary vertices -> Moving edges (translated)
foreach (var sv in stationaryVerticesArray)
{
var d = SpatialQuery.OneWayDistance(sv, mEdges, movingOffset, opposite);
if (d < minDist) minDist = d;
}
results[i] = minDist;
});
return results;
} }
private static Vector GetPushVector(PushDirection direction, double distance) private static Vector GetPushVector(PushDirection direction, double distance)
@@ -187,48 +239,5 @@ namespace OpenNest.Engine.BestFit
default: return Vector.Zero; default: return Vector.Zero;
} }
} }
private static double ComputeSlideDistance(
Part part2Template, List<Line> part1Lines, double halfSpacing,
double offset, double pushStartOffset,
bool isHorizontalPush, PushDirection pushDir)
{
var part2 = (Part)part2Template.Clone();
var isPositiveStart = pushDir == PushDirection.Left || pushDir == PushDirection.Down;
var startPos = isPositiveStart ? pushStartOffset : -pushStartOffset;
if (isHorizontalPush)
part2.Offset(startPos, offset);
else
part2.Offset(offset, startPos);
var part2Lines = Helper.GetOffsetPartLines(part2, halfSpacing);
return Helper.DirectionalDistance(part2Lines, part1Lines, pushDir);
}
private static (double slideDist, Vector finalPosition) ComputeSlideResult(
Part part2Template, List<Line> part1Lines, double halfSpacing,
double offset, double pushStartOffset,
bool isHorizontalPush, PushDirection pushDir)
{
var part2 = (Part)part2Template.Clone();
var isPositiveStart = pushDir == PushDirection.Left || pushDir == PushDirection.Down;
var startPos = isPositiveStart ? pushStartOffset : -pushStartOffset;
if (isHorizontalPush)
part2.Offset(startPos, offset);
else
part2.Offset(offset, startPos);
var part2Lines = Helper.GetOffsetPartLines(part2, halfSpacing);
var slideDist = Helper.DirectionalDistance(part2Lines, part1Lines, pushDir);
var pushVector = GetPushVector(pushDir, slideDist);
var finalPosition = part2.Location + pushVector;
return (slideDist, finalPosition);
}
} }
} }

View File

@@ -0,0 +1,156 @@
using System.Collections.Generic;
using System.Linq;
using OpenNest.Geometry;
namespace OpenNest
{
/// <summary>
/// Pushes a group of parts left and down to close gaps after placement.
/// Uses the same directional-distance logic as PlateView.PushSelected
/// but operates on Part objects directly.
/// </summary>
public static class Compactor
{
private const double ChordTolerance = 0.001;
/// <summary>
/// Compacts movingParts toward the bottom-left of the plate work area.
/// Everything already on the plate (excluding movingParts) is treated
/// as stationary obstacles.
/// </summary>
private const double RepeatThreshold = 0.01;
private const int MaxIterations = 20;
public static void Compact(List<Part> movingParts, Plate plate)
{
if (movingParts == null || movingParts.Count == 0)
return;
var savedPositions = SavePositions(movingParts);
// Try left-first.
var leftFirst = CompactLoop(movingParts, plate, PushDirection.Left, PushDirection.Down);
// Restore and try down-first.
RestorePositions(movingParts, savedPositions);
var downFirst = CompactLoop(movingParts, plate, PushDirection.Down, PushDirection.Left);
// Keep left-first if it traveled further.
if (leftFirst > downFirst)
{
RestorePositions(movingParts, savedPositions);
CompactLoop(movingParts, plate, PushDirection.Left, PushDirection.Down);
}
}
private static double CompactLoop(List<Part> parts, Plate plate,
PushDirection first, PushDirection second)
{
var total = 0.0;
for (var i = 0; i < MaxIterations; i++)
{
var a = Push(parts, plate, first);
var b = Push(parts, plate, second);
total += a + b;
if (a <= RepeatThreshold && b <= RepeatThreshold)
break;
}
return total;
}
private static Vector[] SavePositions(List<Part> parts)
{
var positions = new Vector[parts.Count];
for (var i = 0; i < parts.Count; i++)
positions[i] = parts[i].Location;
return positions;
}
private static void RestorePositions(List<Part> parts, Vector[] positions)
{
for (var i = 0; i < parts.Count; i++)
parts[i].Location = positions[i];
}
public static double Push(List<Part> movingParts, Plate plate, PushDirection direction)
{
var obstacleParts = plate.Parts
.Where(p => !movingParts.Contains(p))
.ToList();
var obstacleBoxes = new Box[obstacleParts.Count];
var obstacleLines = new List<Line>[obstacleParts.Count];
for (var i = 0; i < obstacleParts.Count; i++)
obstacleBoxes[i] = obstacleParts[i].BoundingBox;
var opposite = SpatialQuery.OppositeDirection(direction);
var halfSpacing = plate.PartSpacing / 2;
var isHorizontal = SpatialQuery.IsHorizontalDirection(direction);
var workArea = plate.WorkArea();
var distance = double.MaxValue;
// BB gap at which offset geometries are expected to be touching.
var contactGap = (halfSpacing + ChordTolerance) * 2;
foreach (var moving in movingParts)
{
var edgeDist = SpatialQuery.EdgeDistance(moving.BoundingBox, workArea, direction);
if (edgeDist <= 0)
distance = 0;
else if (edgeDist < distance)
distance = edgeDist;
var movingBox = moving.BoundingBox;
List<Line> movingLines = null;
for (var i = 0; i < obstacleBoxes.Length; i++)
{
// Use the reverse-direction gap to check if the obstacle is entirely
// behind the moving part. The forward gap (gap < 0) is unreliable for
// irregular shapes whose bounding boxes overlap even when the actual
// geometry still has a valid contact in the push direction.
var reverseGap = SpatialQuery.DirectionalGap(movingBox, obstacleBoxes[i], opposite);
if (reverseGap > 0)
continue;
var gap = SpatialQuery.DirectionalGap(movingBox, obstacleBoxes[i], direction);
if (gap >= distance)
continue;
var perpOverlap = isHorizontal
? movingBox.IsHorizontalTo(obstacleBoxes[i], out _)
: movingBox.IsVerticalTo(obstacleBoxes[i], out _);
if (!perpOverlap)
continue;
movingLines ??= halfSpacing > 0
? PartGeometry.GetOffsetPartLines(moving, halfSpacing, direction, ChordTolerance)
: PartGeometry.GetPartLines(moving, direction, ChordTolerance);
obstacleLines[i] ??= halfSpacing > 0
? PartGeometry.GetOffsetPartLines(obstacleParts[i], halfSpacing, opposite, ChordTolerance)
: PartGeometry.GetPartLines(obstacleParts[i], opposite, ChordTolerance);
var d = SpatialQuery.DirectionalDistance(movingLines, obstacleLines[i], direction);
if (d < distance)
distance = d;
}
}
if (distance < double.MaxValue && distance > 0)
{
var offset = SpatialQuery.DirectionToOffset(direction, distance);
foreach (var moving in movingParts)
moving.Offset(offset);
return distance;
}
return 0;
}
}
}

View File

@@ -0,0 +1,697 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using OpenNest.Engine.BestFit;
using OpenNest.Engine.ML;
using OpenNest.Geometry;
using OpenNest.Math;
using OpenNest.RectanglePacking;
namespace OpenNest
{
public class DefaultNestEngine : NestEngineBase
{
public DefaultNestEngine(Plate plate) : base(plate) { }
public override string Name => "Default";
public override string Description => "Multi-phase nesting (Linear, Pairs, RectBestFit, Remainder)";
public bool ForceFullAngleSweep { get; set; }
// Angles that have produced results across multiple Fill calls.
// Populated after each Fill; used to prune subsequent fills.
private readonly HashSet<double> knownGoodAngles = new();
// --- Public Fill API ---
public override List<Part> Fill(NestItem item, Box workArea,
IProgress<NestProgress> progress, CancellationToken token)
{
PhaseResults.Clear();
AngleResults.Clear();
var best = FindBestFill(item, workArea, progress, token);
if (!token.IsCancellationRequested)
{
// Try improving by filling the remainder strip separately.
var remainderSw = Stopwatch.StartNew();
var improved = TryRemainderImprovement(item, workArea, best);
remainderSw.Stop();
if (IsBetterFill(improved, best, workArea))
{
Debug.WriteLine($"[Fill] Remainder improvement: {improved.Count} parts (was {best?.Count ?? 0})");
best = improved;
WinnerPhase = NestPhase.Remainder;
PhaseResults.Add(new PhaseResult(NestPhase.Remainder, improved.Count, remainderSw.ElapsedMilliseconds));
ReportProgress(progress, NestPhase.Remainder, PlateNumber, best, workArea, BuildProgressSummary());
}
}
if (best == null || best.Count == 0)
return new List<Part>();
if (item.Quantity > 0 && best.Count > item.Quantity)
best = best.Take(item.Quantity).ToList();
return best;
}
/// <summary>
/// Fast fill count using linear fill with two angles plus the top cached
/// pair candidates. Used by binary search to estimate capacity at a given
/// box size without running the full Fill pipeline.
/// </summary>
private int QuickFillCount(Drawing drawing, Box testBox, double bestRotation)
{
var engine = new FillLinear(testBox, Plate.PartSpacing);
var bestCount = 0;
// Single-part linear fills.
var angles = new[] { bestRotation, bestRotation + Angle.HalfPI };
foreach (var angle in angles)
{
var h = engine.Fill(drawing, angle, NestDirection.Horizontal);
if (h != null && h.Count > bestCount)
bestCount = h.Count;
var v = engine.Fill(drawing, angle, NestDirection.Vertical);
if (v != null && v.Count > bestCount)
bestCount = v.Count;
}
// Top pair candidates — check if pairs tile better in this box.
var bestFits = BestFitCache.GetOrCompute(
drawing, Plate.Size.Width, Plate.Size.Length, Plate.PartSpacing);
var topPairs = bestFits.Where(r => r.Keep).Take(3);
foreach (var pair in topPairs)
{
var pairParts = pair.BuildParts(drawing);
var pairAngles = pair.HullAngles ?? new List<double> { 0 };
var pairEngine = new FillLinear(testBox, Plate.PartSpacing);
foreach (var angle in pairAngles)
{
var pattern = BuildRotatedPattern(pairParts, angle);
if (pattern.Parts.Count == 0)
continue;
var h = pairEngine.Fill(pattern, NestDirection.Horizontal);
if (h != null && h.Count > bestCount)
bestCount = h.Count;
var v = pairEngine.Fill(pattern, NestDirection.Vertical);
if (v != null && v.Count > bestCount)
bestCount = v.Count;
}
}
return bestCount;
}
public override List<Part> Fill(List<Part> groupParts, Box workArea,
IProgress<NestProgress> progress, CancellationToken token)
{
if (groupParts == null || groupParts.Count == 0)
return new List<Part>();
PhaseResults.Clear();
var engine = new FillLinear(workArea, Plate.PartSpacing);
var angles = RotationAnalysis.FindHullEdgeAngles(groupParts);
var best = FillPattern(engine, groupParts, angles, workArea);
PhaseResults.Add(new PhaseResult(NestPhase.Linear, best?.Count ?? 0, 0));
Debug.WriteLine($"[Fill(groupParts,Box)] Linear: {best?.Count ?? 0} parts | WorkArea: {workArea.Width:F1}x{workArea.Length:F1}");
ReportProgress(progress, NestPhase.Linear, PlateNumber, best, workArea, BuildProgressSummary());
if (groupParts.Count == 1)
{
try
{
token.ThrowIfCancellationRequested();
var nestItem = new NestItem { Drawing = groupParts[0].BaseDrawing };
var rectResult = FillRectangleBestFit(nestItem, workArea);
PhaseResults.Add(new PhaseResult(NestPhase.RectBestFit, rectResult?.Count ?? 0, 0));
Debug.WriteLine($"[Fill(groupParts,Box)] RectBestFit: {rectResult?.Count ?? 0} parts");
if (IsBetterFill(rectResult, best, workArea))
{
best = rectResult;
ReportProgress(progress, NestPhase.RectBestFit, PlateNumber, best, workArea, BuildProgressSummary());
}
token.ThrowIfCancellationRequested();
var pairResult = FillWithPairs(nestItem, workArea, token, progress);
PhaseResults.Add(new PhaseResult(NestPhase.Pairs, pairResult.Count, 0));
Debug.WriteLine($"[Fill(groupParts,Box)] Pair: {pairResult.Count} parts | Winner: {(IsBetterFill(pairResult, best, workArea) ? "Pair" : "Linear")}");
if (IsBetterFill(pairResult, best, workArea))
{
best = pairResult;
ReportProgress(progress, NestPhase.Pairs, PlateNumber, best, workArea, BuildProgressSummary());
}
// Try improving by filling the remainder strip separately.
var improved = TryRemainderImprovement(nestItem, workArea, best);
if (IsBetterFill(improved, best, workArea))
{
Debug.WriteLine($"[Fill(groupParts,Box)] Remainder improvement: {improved.Count} parts (was {best?.Count ?? 0})");
best = improved;
PhaseResults.Add(new PhaseResult(NestPhase.Remainder, improved.Count, 0));
ReportProgress(progress, NestPhase.Remainder, PlateNumber, best, workArea, BuildProgressSummary());
}
}
catch (OperationCanceledException)
{
Debug.WriteLine("[Fill(groupParts,Box)] Cancelled, returning current best");
}
}
return best ?? new List<Part>();
}
// --- Pack API ---
public override List<Part> PackArea(Box box, List<NestItem> items,
IProgress<NestProgress> progress, CancellationToken token)
{
var binItems = BinConverter.ToItems(items, Plate.PartSpacing, Plate.Area());
var bin = BinConverter.CreateBin(box, Plate.PartSpacing);
var engine = new PackBottomLeft(bin);
engine.Pack(binItems);
return BinConverter.ToParts(bin, items);
}
// --- FindBestFill: core orchestration ---
private List<Part> FindBestFill(NestItem item, Box workArea,
IProgress<NestProgress> progress = null, CancellationToken token = default)
{
List<Part> best = null;
try
{
var bestRotation = RotationAnalysis.FindBestRotation(item);
var angles = BuildCandidateAngles(item, bestRotation, workArea);
// Pairs phase
var pairSw = Stopwatch.StartNew();
var pairResult = FillWithPairs(item, workArea, token, progress);
pairSw.Stop();
best = pairResult;
var bestScore = FillScore.Compute(best, workArea);
WinnerPhase = NestPhase.Pairs;
PhaseResults.Add(new PhaseResult(NestPhase.Pairs, pairResult.Count, pairSw.ElapsedMilliseconds));
Debug.WriteLine($"[FindBestFill] Pair: {bestScore.Count} parts");
ReportProgress(progress, NestPhase.Pairs, PlateNumber, best, workArea, BuildProgressSummary());
token.ThrowIfCancellationRequested();
// Linear phase
var linearSw = Stopwatch.StartNew();
var bestLinearCount = 0;
for (var ai = 0; ai < angles.Count; ai++)
{
token.ThrowIfCancellationRequested();
var angle = angles[ai];
var localEngine = new FillLinear(workArea, Plate.PartSpacing);
var h = localEngine.Fill(item.Drawing, angle, NestDirection.Horizontal);
var v = localEngine.Fill(item.Drawing, angle, NestDirection.Vertical);
var angleDeg = Angle.ToDegrees(angle);
if (h != null && h.Count > 0)
{
var scoreH = FillScore.Compute(h, workArea);
AngleResults.Add(new AngleResult { AngleDeg = angleDeg, Direction = NestDirection.Horizontal, PartCount = h.Count });
if (h.Count > bestLinearCount) bestLinearCount = h.Count;
if (scoreH > bestScore)
{
best = h;
bestScore = scoreH;
WinnerPhase = NestPhase.Linear;
}
}
if (v != null && v.Count > 0)
{
var scoreV = FillScore.Compute(v, workArea);
AngleResults.Add(new AngleResult { AngleDeg = angleDeg, Direction = NestDirection.Vertical, PartCount = v.Count });
if (v.Count > bestLinearCount) bestLinearCount = v.Count;
if (scoreV > bestScore)
{
best = v;
bestScore = scoreV;
WinnerPhase = NestPhase.Linear;
}
}
ReportProgress(progress, NestPhase.Linear, PlateNumber, best, workArea,
$"Linear: {ai + 1}/{angles.Count} angles, {angleDeg:F0}° best = {bestScore.Count} parts");
}
linearSw.Stop();
PhaseResults.Add(new PhaseResult(NestPhase.Linear, bestLinearCount, linearSw.ElapsedMilliseconds));
// Record productive angles for future fills.
foreach (var ar in AngleResults)
{
if (ar.PartCount > 0)
knownGoodAngles.Add(Angle.ToRadians(ar.AngleDeg));
}
Debug.WriteLine($"[FindBestFill] Linear: {bestScore.Count} parts, density={bestScore.Density:P1} | WorkArea: {workArea.Width:F1}x{workArea.Length:F1} | Angles: {angles.Count}");
ReportProgress(progress, NestPhase.Linear, PlateNumber, best, workArea, BuildProgressSummary());
token.ThrowIfCancellationRequested();
// RectBestFit phase
var rectSw = Stopwatch.StartNew();
var rectResult = FillRectangleBestFit(item, workArea);
rectSw.Stop();
var rectScore = rectResult != null ? FillScore.Compute(rectResult, workArea) : default;
Debug.WriteLine($"[FindBestFill] RectBestFit: {rectScore.Count} parts");
PhaseResults.Add(new PhaseResult(NestPhase.RectBestFit, rectResult?.Count ?? 0, rectSw.ElapsedMilliseconds));
if (rectScore > bestScore)
{
best = rectResult;
WinnerPhase = NestPhase.RectBestFit;
ReportProgress(progress, NestPhase.RectBestFit, PlateNumber, best, workArea, BuildProgressSummary());
}
}
catch (OperationCanceledException)
{
Debug.WriteLine("[FindBestFill] Cancelled, returning current best");
}
return best ?? new List<Part>();
}
// --- Angle building ---
private List<double> BuildCandidateAngles(NestItem item, double bestRotation, Box workArea)
{
var angles = new List<double> { bestRotation, bestRotation + Angle.HalfPI };
// When the work area is narrow relative to the part, sweep rotation
// angles so we can find one that fits the part into the tight strip.
var testPart = new Part(item.Drawing);
if (!bestRotation.IsEqualTo(0))
testPart.Rotate(bestRotation);
testPart.UpdateBounds();
var partLongestSide = System.Math.Max(testPart.BoundingBox.Width, testPart.BoundingBox.Length);
var workAreaShortSide = System.Math.Min(workArea.Width, workArea.Length);
var needsSweep = workAreaShortSide < partLongestSide || ForceFullAngleSweep;
if (needsSweep)
{
var step = Angle.ToRadians(5);
for (var a = 0.0; a < System.Math.PI; a += step)
{
if (!angles.Any(existing => existing.IsEqualTo(a)))
angles.Add(a);
}
}
// When the work area triggers a full sweep (and we're not forcing it for training),
// try ML angle prediction to reduce the sweep.
if (!ForceFullAngleSweep && angles.Count > 2)
{
var features = FeatureExtractor.Extract(item.Drawing);
if (features != null)
{
var predicted = AnglePredictor.PredictAngles(
features, workArea.Width, workArea.Length);
if (predicted != null)
{
var mlAngles = new List<double>(predicted);
if (!mlAngles.Any(a => a.IsEqualTo(bestRotation)))
mlAngles.Add(bestRotation);
if (!mlAngles.Any(a => a.IsEqualTo(bestRotation + Angle.HalfPI)))
mlAngles.Add(bestRotation + Angle.HalfPI);
Debug.WriteLine($"[BuildCandidateAngles] ML: {angles.Count} angles -> {mlAngles.Count} predicted");
angles = mlAngles;
}
}
}
// If we have known-good angles from previous fills, use only those
// plus the defaults (bestRotation + 90°). This prunes the expensive
// angle sweep after the first fill.
if (knownGoodAngles.Count > 0 && !ForceFullAngleSweep)
{
var pruned = new List<double> { bestRotation, bestRotation + Angle.HalfPI };
foreach (var a in knownGoodAngles)
{
if (!pruned.Any(existing => existing.IsEqualTo(a)))
pruned.Add(a);
}
Debug.WriteLine($"[BuildCandidateAngles] Pruned: {angles.Count} -> {pruned.Count} angles (known-good)");
return pruned;
}
return angles;
}
// --- Fill strategies ---
private List<Part> FillRectangleBestFit(NestItem item, Box workArea)
{
var binItem = BinConverter.ToItem(item, Plate.PartSpacing);
var bin = BinConverter.CreateBin(workArea, Plate.PartSpacing);
var engine = new FillBestFit(bin);
engine.Fill(binItem);
return BinConverter.ToParts(bin, new List<NestItem> { item });
}
private List<Part> FillWithPairs(NestItem item, Box workArea,
CancellationToken token = default, IProgress<NestProgress> progress = null)
{
var bestFits = BestFitCache.GetOrCompute(
item.Drawing, Plate.Size.Width, Plate.Size.Length,
Plate.PartSpacing);
var candidates = SelectPairCandidates(bestFits, workArea);
var diagMsg = $"[FillWithPairs] Total: {bestFits.Count}, Kept: {bestFits.Count(r => r.Keep)}, Trying: {candidates.Count}\n" +
$"[FillWithPairs] Plate: {Plate.Size.Width:F2}x{Plate.Size.Length:F2}, WorkArea: {workArea.Width:F2}x{workArea.Length:F2}";
Debug.WriteLine(diagMsg);
try { System.IO.File.AppendAllText(
System.IO.Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.Desktop), "nest-debug.log"),
$"{DateTime.Now:HH:mm:ss} {diagMsg}\n"); } catch { }
List<Part> best = null;
var bestScore = default(FillScore);
var sinceImproved = 0;
try
{
for (var i = 0; i < candidates.Count; i++)
{
token.ThrowIfCancellationRequested();
var result = candidates[i];
var pairParts = result.BuildParts(item.Drawing);
var angles = result.HullAngles;
var engine = new FillLinear(workArea, Plate.PartSpacing);
var filled = FillPattern(engine, pairParts, angles, workArea);
if (filled != null && filled.Count > 0)
{
var score = FillScore.Compute(filled, workArea);
if (best == null || score > bestScore)
{
best = filled;
bestScore = score;
sinceImproved = 0;
}
else
{
sinceImproved++;
}
}
else
{
sinceImproved++;
}
ReportProgress(progress, NestPhase.Pairs, PlateNumber, best, workArea,
$"Pairs: {i + 1}/{candidates.Count} candidates, best = {bestScore.Count} parts");
// Early exit: stop if we've tried enough candidates without improvement.
if (i >= 9 && sinceImproved >= 10)
{
Debug.WriteLine($"[FillWithPairs] Early exit at {i + 1}/{candidates.Count} — no improvement in last {sinceImproved} candidates");
break;
}
}
}
catch (OperationCanceledException)
{
Debug.WriteLine("[FillWithPairs] Cancelled mid-phase, using results so far");
}
Debug.WriteLine($"[FillWithPairs] Best pair result: {bestScore.Count} parts, remnant={bestScore.UsableRemnantArea:F1}, density={bestScore.Density:P1}");
try { System.IO.File.AppendAllText(
System.IO.Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.Desktop), "nest-debug.log"),
$"{DateTime.Now:HH:mm:ss} [FillWithPairs] Best: {bestScore.Count} parts, density={bestScore.Density:P1}\n"); } catch { }
return best ?? new List<Part>();
}
/// <summary>
/// Selects pair candidates to try for the given work area. Always includes
/// the top 50 by area. For narrow work areas, also includes all pairs whose
/// shortest side fits the strip width.
/// </summary>
private List<BestFitResult> SelectPairCandidates(List<BestFitResult> bestFits, Box workArea)
{
var kept = bestFits.Where(r => r.Keep).ToList();
var top = kept.Take(50).ToList();
var workShortSide = System.Math.Min(workArea.Width, workArea.Length);
var plateShortSide = System.Math.Min(Plate.Size.Width, Plate.Size.Length);
// When the work area is significantly narrower than the plate,
// search ALL candidates (not just kept) for pairs that fit the
// narrow dimension. Pairs rejected by aspect ratio for the full
// plate may be exactly what's needed for a narrow remainder strip.
if (workShortSide < plateShortSide * 0.5)
{
var stripCandidates = bestFits
.Where(r => r.ShortestSide <= workShortSide + Tolerance.Epsilon
&& r.Utilization >= 0.3)
.OrderByDescending(r => r.Utilization);
var existing = new HashSet<BestFitResult>(top);
foreach (var r in stripCandidates)
{
if (top.Count >= 100)
break;
if (existing.Add(r))
top.Add(r);
}
Debug.WriteLine($"[SelectPairCandidates] Strip mode: {top.Count} candidates (shortSide <= {workShortSide:F1})");
}
return top;
}
// --- Pattern helpers ---
private Pattern BuildRotatedPattern(List<Part> groupParts, double angle)
{
var pattern = new Pattern();
var center = ((IEnumerable<IBoundable>)groupParts).GetBoundingBox().Center;
foreach (var part in groupParts)
{
var clone = (Part)part.Clone();
clone.UpdateBounds();
if (!angle.IsEqualTo(0))
clone.Rotate(angle, center);
pattern.Parts.Add(clone);
}
pattern.UpdateBounds();
return pattern;
}
private List<Part> FillPattern(FillLinear engine, List<Part> groupParts, List<double> angles, Box workArea)
{
var results = new System.Collections.Concurrent.ConcurrentBag<(List<Part> Parts, FillScore Score)>();
Parallel.ForEach(angles, angle =>
{
var pattern = BuildRotatedPattern(groupParts, angle);
if (pattern.Parts.Count == 0)
return;
var h = engine.Fill(pattern, NestDirection.Horizontal);
if (h != null && h.Count > 0)
results.Add((h, FillScore.Compute(h, workArea)));
var v = engine.Fill(pattern, NestDirection.Vertical);
if (v != null && v.Count > 0)
results.Add((v, FillScore.Compute(v, workArea)));
});
List<Part> best = null;
var bestScore = default(FillScore);
foreach (var res in results)
{
if (best == null || res.Score > bestScore)
{
best = res.Parts;
bestScore = res.Score;
}
}
return best;
}
// --- Remainder improvement ---
private List<Part> TryRemainderImprovement(NestItem item, Box workArea, List<Part> currentBest)
{
if (currentBest == null || currentBest.Count < 3)
return null;
List<Part> best = null;
var hResult = TryStripRefill(item, workArea, currentBest, horizontal: true);
if (IsBetterFill(hResult, best, workArea))
best = hResult;
var vResult = TryStripRefill(item, workArea, currentBest, horizontal: false);
if (IsBetterFill(vResult, best, workArea))
best = vResult;
return best;
}
private List<Part> TryStripRefill(NestItem item, Box workArea, List<Part> parts, bool horizontal)
{
if (parts == null || parts.Count < 3)
return null;
var clusters = ClusterParts(parts, horizontal);
if (clusters.Count < 2)
return null;
// Determine the mode (most common) cluster count, excluding the last cluster.
var mainClusters = clusters.Take(clusters.Count - 1).ToList();
var modeCount = mainClusters
.GroupBy(c => c.Count)
.OrderByDescending(g => g.Count())
.First()
.Key;
var lastCluster = clusters[clusters.Count - 1];
// Only attempt refill if the last cluster is smaller than the mode.
if (lastCluster.Count >= modeCount)
return null;
Debug.WriteLine($"[TryStripRefill] {(horizontal ? "H" : "V")} clusters: {clusters.Count}, mode: {modeCount}, last: {lastCluster.Count}");
// Build the main parts list (everything except the last cluster).
var mainParts = clusters.Take(clusters.Count - 1).SelectMany(c => c).ToList();
var mainBox = ((IEnumerable<IBoundable>)mainParts).GetBoundingBox();
// Compute the strip box from the main grid edge to the work area edge.
Box stripBox;
if (horizontal)
{
var stripLeft = mainBox.Right + Plate.PartSpacing;
var stripWidth = workArea.Right - stripLeft;
if (stripWidth <= 0)
return null;
stripBox = new Box(stripLeft, workArea.Y, stripWidth, workArea.Length);
}
else
{
var stripBottom = mainBox.Top + Plate.PartSpacing;
var stripHeight = workArea.Top - stripBottom;
if (stripHeight <= 0)
return null;
stripBox = new Box(workArea.X, stripBottom, workArea.Width, stripHeight);
}
Debug.WriteLine($"[TryStripRefill] Strip: {stripBox.Width:F1}x{stripBox.Length:F1} at ({stripBox.X:F1},{stripBox.Y:F1})");
var stripParts = FindBestFill(item, stripBox);
if (stripParts == null || stripParts.Count <= lastCluster.Count)
{
Debug.WriteLine($"[TryStripRefill] No improvement: strip={stripParts?.Count ?? 0} vs oddball={lastCluster.Count}");
return null;
}
Debug.WriteLine($"[TryStripRefill] Improvement: strip={stripParts.Count} vs oddball={lastCluster.Count}");
var combined = new List<Part>(mainParts.Count + stripParts.Count);
combined.AddRange(mainParts);
combined.AddRange(stripParts);
return combined;
}
/// <summary>
/// Groups parts into positional clusters along the given axis.
/// Parts whose center positions are separated by more than half
/// the part dimension start a new cluster.
/// </summary>
private static List<List<Part>> ClusterParts(List<Part> parts, bool horizontal)
{
var sorted = horizontal
? parts.OrderBy(p => p.BoundingBox.Center.X).ToList()
: parts.OrderBy(p => p.BoundingBox.Center.Y).ToList();
var refDim = horizontal
? sorted.Max(p => p.BoundingBox.Width)
: sorted.Max(p => p.BoundingBox.Length);
var gapThreshold = refDim * 0.5;
var clusters = new List<List<Part>>();
var current = new List<Part> { sorted[0] };
for (var i = 1; i < sorted.Count; i++)
{
var prevCenter = horizontal
? sorted[i - 1].BoundingBox.Center.X
: sorted[i - 1].BoundingBox.Center.Y;
var currCenter = horizontal
? sorted[i].BoundingBox.Center.X
: sorted[i].BoundingBox.Center.Y;
if (currCenter - prevCenter > gapThreshold)
{
clusters.Add(current);
current = new List<Part>();
}
current.Add(sorted[i]);
}
clusters.Add(current);
return clusters;
}
}
}

View File

@@ -1,4 +1,5 @@
using System.Collections.Generic; using System.Collections.Generic;
using System.Threading.Tasks;
using OpenNest.Geometry; using OpenNest.Geometry;
using OpenNest.Math; using OpenNest.Math;
@@ -77,17 +78,16 @@ namespace OpenNest
{ {
var bboxDim = GetDimension(partA.BoundingBox, direction); var bboxDim = GetDimension(partA.BoundingBox, direction);
var pushDir = GetPushDirection(direction); var pushDir = GetPushDirection(direction);
var opposite = Helper.OppositeDirection(pushDir);
var locationB = partA.Location + MakeOffset(direction, bboxDim); var locationBOffset = MakeOffset(direction, bboxDim);
var movingLines = boundary.GetLines(locationB, pushDir); // Use the most efficient array-based overload to avoid all allocations.
var stationaryLines = boundary.GetLines(partA.Location, opposite); var slideDistance = SpatialQuery.DirectionalDistance(
var slideDistance = Helper.DirectionalDistance(movingLines, stationaryLines, pushDir); boundary.GetEdges(pushDir), partA.Location + locationBOffset,
boundary.GetEdges(SpatialQuery.OppositeDirection(pushDir)), partA.Location,
pushDir);
var copyDist = ComputeCopyDistance(bboxDim, slideDistance); return ComputeCopyDistance(bboxDim, slideDistance);
//System.Diagnostics.Debug.WriteLine($"[FindCopyDistance] dir={direction} bboxDim={bboxDim:F4} slide={slideDistance:F4} copyDist={copyDist:F4} spacing={PartSpacing:F4} locA={partA.Location} locB={locationB} movingEdges={movingLines.Count} stationaryEdges={stationaryLines.Count}");
return copyDist;
} }
/// <summary> /// <summary>
@@ -103,11 +103,10 @@ namespace OpenNest
var bboxDim = GetDimension(patternA.BoundingBox, direction); var bboxDim = GetDimension(patternA.BoundingBox, direction);
var pushDir = GetPushDirection(direction); var pushDir = GetPushDirection(direction);
var opposite = Helper.OppositeDirection(pushDir); var opposite = SpatialQuery.OppositeDirection(pushDir);
// Compute a starting offset large enough that every part-pair in // Compute a starting offset large enough that every part-pair in
// patternB has its offset geometry beyond patternA's offset geometry. // patternB has its offset geometry beyond patternA's offset geometry.
// max(aUpper_i - bLower_j) = max(aUpper) - min(bLower).
var maxUpper = double.MinValue; var maxUpper = double.MinValue;
var minLower = double.MaxValue; var minLower = double.MaxValue;
@@ -126,22 +125,28 @@ namespace OpenNest
var offset = MakeOffset(direction, startOffset); var offset = MakeOffset(direction, startOffset);
// Pre-compute stationary lines for patternA parts. // Pre-cache edge arrays.
var stationaryCache = new List<Line>[patternA.Parts.Count]; var movingEdges = new (Vector start, Vector end)[patternA.Parts.Count][];
var stationaryEdges = new (Vector start, Vector end)[patternA.Parts.Count][];
for (var i = 0; i < patternA.Parts.Count; i++) for (var i = 0; i < patternA.Parts.Count; i++)
stationaryCache[i] = boundaries[i].GetLines(patternA.Parts[i].Location, opposite); {
movingEdges[i] = boundaries[i].GetEdges(pushDir);
stationaryEdges[i] = boundaries[i].GetEdges(opposite);
}
var maxCopyDistance = 0.0; var maxCopyDistance = 0.0;
for (var j = 0; j < patternA.Parts.Count; j++) for (var j = 0; j < patternA.Parts.Count; j++)
{ {
var locationB = patternA.Parts[j].Location + offset; var locationB = patternA.Parts[j].Location + offset;
var movingLines = boundaries[j].GetLines(locationB, pushDir);
for (var i = 0; i < patternA.Parts.Count; i++) for (var i = 0; i < patternA.Parts.Count; i++)
{ {
var slideDistance = Helper.DirectionalDistance(movingLines, stationaryCache[i], pushDir); var slideDistance = SpatialQuery.DirectionalDistance(
movingEdges[j], locationB,
stationaryEdges[i], patternA.Parts[i].Location,
pushDir);
if (slideDistance >= double.MaxValue || slideDistance < 0) if (slideDistance >= double.MaxValue || slideDistance < 0)
continue; continue;
@@ -153,9 +158,7 @@ namespace OpenNest
} }
} }
// Fallback: if no pair interacted (shouldn't happen for real parts), if (maxCopyDistance < Tolerance.Epsilon)
// use the simple bounding-box + spacing distance.
if (maxCopyDistance <= 0)
return bboxDim + PartSpacing; return bboxDim + PartSpacing;
return maxCopyDistance; return maxCopyDistance;
@@ -166,19 +169,8 @@ namespace OpenNest
/// </summary> /// </summary>
private double FindSinglePartPatternCopyDistance(Pattern patternA, NestDirection direction, PartBoundary boundary) private double FindSinglePartPatternCopyDistance(Pattern patternA, NestDirection direction, PartBoundary boundary)
{ {
var bboxDim = GetDimension(patternA.BoundingBox, direction); var template = patternA.Parts[0];
var pushDir = GetPushDirection(direction); return FindCopyDistance(template, direction, boundary);
var opposite = Helper.OppositeDirection(pushDir);
var offset = MakeOffset(direction, bboxDim);
var movingLines = GetOffsetPatternLines(patternA, offset, boundary, pushDir);
var stationaryLines = GetPatternLines(patternA, boundary, opposite);
var slideDistance = Helper.DirectionalDistance(movingLines, stationaryLines, pushDir);
var copyDist = ComputeCopyDistance(bboxDim, slideDistance);
//System.Diagnostics.Debug.WriteLine($"[FindSinglePartPatternCopyDist] dir={direction} bboxDim={bboxDim:F4} slide={slideDistance:F4} copyDist={copyDist:F4} spacing={PartSpacing:F4} patternParts={patternA.Parts.Count} movingEdges={movingLines.Count} stationaryEdges={stationaryLines.Count}");
return copyDist;
} }
/// <summary> /// <summary>
@@ -330,54 +322,46 @@ namespace OpenNest
} }
/// <summary> /// <summary>
/// Recursively fills the work area. At depth 0, tiles the pattern along the /// Fills the work area by tiling the pattern along the primary axis to form
/// primary axis, then recurses perpendicular. At depth 1, tiles and returns. /// a row, then tiling that row along the perpendicular axis to form a grid.
/// After the grid is formed, fills the remaining strip with individual parts. /// After the grid is formed, fills the remaining strip with individual parts.
/// </summary> /// </summary>
private List<Part> FillRecursive(Pattern pattern, NestDirection direction, int depth) private List<Part> FillGrid(Pattern pattern, NestDirection direction)
{ {
var perpAxis = PerpendicularAxis(direction);
var boundaries = CreateBoundaries(pattern); var boundaries = CreateBoundaries(pattern);
var result = new List<Part>(pattern.Parts);
result.AddRange(TilePattern(pattern, direction, boundaries));
if (depth == 0 && result.Count > pattern.Parts.Count) // Step 1: Tile along primary axis
var row = new List<Part>(pattern.Parts);
row.AddRange(TilePattern(pattern, direction, boundaries));
// If primary tiling didn't produce copies, just tile along perpendicular
if (row.Count <= pattern.Parts.Count)
{ {
var rowPattern = new Pattern(); row.AddRange(TilePattern(pattern, perpAxis, boundaries));
rowPattern.Parts.AddRange(result); return row;
rowPattern.UpdateBounds();
var perpAxis = PerpendicularAxis(direction);
var gridResult = FillRecursive(rowPattern, perpAxis, depth + 1);
//System.Diagnostics.Debug.WriteLine($"[FillRecursive] Grid: {gridResult.Count} parts, rowSize={rowPattern.Parts.Count}, dir={direction}");
// Fill the remaining strip (after the last full row/column)
// with individual parts from the seed pattern.
var remaining = FillRemainingStrip(gridResult, pattern, perpAxis, direction);
//System.Diagnostics.Debug.WriteLine($"[FillRecursive] Remainder: {remaining.Count} parts");
if (remaining.Count > 0)
gridResult.AddRange(remaining);
// Try one fewer row/column — the larger remainder strip may
// fit more parts than the extra row contained.
var fewerResult = TryFewerRows(gridResult, rowPattern, pattern, perpAxis, direction);
//System.Diagnostics.Debug.WriteLine($"[FillRecursive] TryFewerRows: {fewerResult?.Count ?? -1} vs grid+remainder={gridResult.Count}");
if (fewerResult != null && fewerResult.Count > gridResult.Count)
return fewerResult;
return gridResult;
} }
if (depth == 0) // Step 2: Build row pattern and tile along perpendicular axis
{ var rowPattern = new Pattern();
// Single part didn't tile along primary — still try perpendicular. rowPattern.Parts.AddRange(row);
return FillRecursive(pattern, PerpendicularAxis(direction), depth + 1); rowPattern.UpdateBounds();
}
return result; var rowBoundaries = CreateBoundaries(rowPattern);
var gridResult = new List<Part>(rowPattern.Parts);
gridResult.AddRange(TilePattern(rowPattern, perpAxis, rowBoundaries));
// Step 3: Fill remaining strip
var remaining = FillRemainingStrip(gridResult, pattern, perpAxis, direction);
if (remaining.Count > 0)
gridResult.AddRange(remaining);
// Step 4: Try fewer rows optimization
var fewerResult = TryFewerRows(gridResult, rowPattern, pattern, perpAxis, direction);
if (fewerResult != null && fewerResult.Count > gridResult.Count)
return fewerResult;
return gridResult;
} }
/// <summary> /// <summary>
@@ -390,37 +374,16 @@ namespace OpenNest
{ {
var rowPartCount = rowPattern.Parts.Count; var rowPartCount = rowPattern.Parts.Count;
//System.Diagnostics.Debug.WriteLine($"[TryFewerRows] fullResult={fullResult.Count}, rowPartCount={rowPartCount}, tiledAxis={tiledAxis}");
// Need at least 2 rows for this to make sense (remove 1, keep 1+).
if (fullResult.Count < rowPartCount * 2) if (fullResult.Count < rowPartCount * 2)
{
//System.Diagnostics.Debug.WriteLine($"[TryFewerRows] Skipped: too few parts for 2 rows");
return null; return null;
}
// Remove the last row's worth of parts.
var fewerParts = new List<Part>(fullResult.Count - rowPartCount); var fewerParts = new List<Part>(fullResult.Count - rowPartCount);
for (var i = 0; i < fullResult.Count - rowPartCount; i++) for (var i = 0; i < fullResult.Count - rowPartCount; i++)
fewerParts.Add(fullResult[i]); fewerParts.Add(fullResult[i]);
// Find the top/right edge of the kept parts for logging.
var edge = double.MinValue;
foreach (var part in fewerParts)
{
var e = tiledAxis == NestDirection.Vertical
? part.BoundingBox.Top
: part.BoundingBox.Right;
if (e > edge) edge = e;
}
//System.Diagnostics.Debug.WriteLine($"[TryFewerRows] Kept {fewerParts.Count} parts, edge={edge:F2}, workArea={WorkArea}");
var remaining = FillRemainingStrip(fewerParts, seedPattern, tiledAxis, primaryAxis); var remaining = FillRemainingStrip(fewerParts, seedPattern, tiledAxis, primaryAxis);
//System.Diagnostics.Debug.WriteLine($"[TryFewerRows] Remainder fill: {remaining.Count} parts (need > {rowPartCount} to improve)");
if (remaining.Count <= rowPartCount) if (remaining.Count <= rowPartCount)
return null; return null;
@@ -438,7 +401,18 @@ namespace OpenNest
List<Part> placedParts, Pattern seedPattern, List<Part> placedParts, Pattern seedPattern,
NestDirection tiledAxis, NestDirection primaryAxis) NestDirection tiledAxis, NestDirection primaryAxis)
{ {
// Find the furthest edge of placed parts along the tiled axis. var placedEdge = FindPlacedEdge(placedParts, tiledAxis);
var remainingStrip = BuildRemainingStrip(placedEdge, tiledAxis);
if (remainingStrip == null)
return new List<Part>();
var rotations = BuildRotationSet(seedPattern);
return FindBestFill(rotations, remainingStrip);
}
private static double FindPlacedEdge(List<Part> placedParts, NestDirection tiledAxis)
{
var placedEdge = double.MinValue; var placedEdge = double.MinValue;
foreach (var part in placedParts) foreach (var part in placedParts)
@@ -451,18 +425,20 @@ namespace OpenNest
placedEdge = edge; placedEdge = edge;
} }
// Build the remaining strip with a spacing gap from the last tiled row. return placedEdge;
Box remainingStrip; }
private Box BuildRemainingStrip(double placedEdge, NestDirection tiledAxis)
{
if (tiledAxis == NestDirection.Vertical) if (tiledAxis == NestDirection.Vertical)
{ {
var bottom = placedEdge + PartSpacing; var bottom = placedEdge + PartSpacing;
var height = WorkArea.Top - bottom; var height = WorkArea.Top - bottom;
if (height <= Tolerance.Epsilon) if (height <= Tolerance.Epsilon)
return new List<Part>(); return null;
remainingStrip = new Box(WorkArea.X, bottom, WorkArea.Width, height); return new Box(WorkArea.X, bottom, WorkArea.Width, height);
} }
else else
{ {
@@ -470,18 +446,20 @@ namespace OpenNest
var width = WorkArea.Right - left; var width = WorkArea.Right - left;
if (width <= Tolerance.Epsilon) if (width <= Tolerance.Epsilon)
return new List<Part>(); return null;
remainingStrip = new Box(left, WorkArea.Y, width, WorkArea.Length); return new Box(left, WorkArea.Y, width, WorkArea.Length);
} }
}
// Build rotation set: always try cardinal orientations (0° and 90°), /// <summary>
// plus any unique rotations from the seed pattern. /// Builds a set of (drawing, rotation) candidates: cardinal orientations
var filler = new FillLinear(remainingStrip, PartSpacing); /// (0° and 90°) for each unique drawing, plus any seed pattern rotations
List<Part> best = null; /// not already covered.
/// </summary>
private static List<(Drawing drawing, double rotation)> BuildRotationSet(Pattern seedPattern)
{
var rotations = new List<(Drawing drawing, double rotation)>(); var rotations = new List<(Drawing drawing, double rotation)>();
// Cardinal rotations for each unique drawing.
var drawings = new List<Drawing>(); var drawings = new List<Drawing>();
foreach (var seedPart in seedPattern.Parts) foreach (var seedPart in seedPattern.Parts)
@@ -507,7 +485,6 @@ namespace OpenNest
rotations.Add((drawing, Angle.HalfPI)); rotations.Add((drawing, Angle.HalfPI));
} }
// Add seed pattern rotations that aren't already covered.
foreach (var seedPart in seedPattern.Parts) foreach (var seedPart in seedPattern.Parts)
{ {
var skip = false; var skip = false;
@@ -525,13 +502,22 @@ namespace OpenNest
rotations.Add((seedPart.BaseDrawing, seedPart.Rotation)); rotations.Add((seedPart.BaseDrawing, seedPart.Rotation));
} }
return rotations;
}
/// <summary>
/// Tries all rotation candidates in both directions in parallel, returns the
/// fill with the most parts.
/// </summary>
private List<Part> FindBestFill(List<(Drawing drawing, double rotation)> rotations, Box strip)
{
var bag = new System.Collections.Concurrent.ConcurrentBag<List<Part>>(); var bag = new System.Collections.Concurrent.ConcurrentBag<List<Part>>();
System.Threading.Tasks.Parallel.ForEach(rotations, entry => Parallel.ForEach(rotations, entry =>
{ {
var localFiller = new FillLinear(remainingStrip, PartSpacing); var filler = new FillLinear(strip, PartSpacing);
var h = localFiller.Fill(entry.drawing, entry.rotation, NestDirection.Horizontal); var h = filler.Fill(entry.drawing, entry.rotation, NestDirection.Horizontal);
var v = localFiller.Fill(entry.drawing, entry.rotation, NestDirection.Vertical); var v = filler.Fill(entry.drawing, entry.rotation, NestDirection.Vertical);
if (h != null && h.Count > 0) if (h != null && h.Count > 0)
bag.Add(h); bag.Add(h);
@@ -540,6 +526,8 @@ namespace OpenNest
bag.Add(v); bag.Add(v);
}); });
List<Part> best = null;
foreach (var candidate in bag) foreach (var candidate in bag)
{ {
if (best == null || candidate.Count > best.Count) if (best == null || candidate.Count > best.Count)
@@ -604,7 +592,7 @@ namespace OpenNest
basePattern.BoundingBox.Length > WorkArea.Length + Tolerance.Epsilon) basePattern.BoundingBox.Length > WorkArea.Length + Tolerance.Epsilon)
return new List<Part>(); return new List<Part>();
return FillRecursive(basePattern, primaryAxis, depth: 0); return FillGrid(basePattern, primaryAxis);
} }
/// <summary> /// <summary>
@@ -618,7 +606,7 @@ namespace OpenNest
if (seed.Parts.Count == 0) if (seed.Parts.Count == 0)
return new List<Part>(); return new List<Part>();
return FillRecursive(seed, primaryAxis, depth: 0); return FillGrid(seed, primaryAxis);
} }
} }
} }

View File

@@ -41,39 +41,32 @@ namespace OpenNest
return default; return default;
var totalPartArea = 0.0; var totalPartArea = 0.0;
var minX = double.MaxValue;
var minY = double.MaxValue;
var maxX = double.MinValue;
var maxY = double.MinValue;
foreach (var part in parts) foreach (var part in parts)
{
totalPartArea += part.BaseDrawing.Area; totalPartArea += part.BaseDrawing.Area;
var bb = part.BoundingBox;
var bbox = ((IEnumerable<IBoundable>)parts).GetBoundingBox(); if (bb.Left < minX) minX = bb.Left;
var bboxArea = bbox.Area(); if (bb.Bottom < minY) minY = bb.Bottom;
if (bb.Right > maxX) maxX = bb.Right;
if (bb.Top > maxY) maxY = bb.Top;
}
var bboxArea = (maxX - minX) * (maxY - minY);
var density = bboxArea > 0 ? totalPartArea / bboxArea : 0; var density = bboxArea > 0 ? totalPartArea / bboxArea : 0;
var usableRemnantArea = ComputeUsableRemnantArea(parts, workArea); var usableRemnantArea = ComputeUsableRemnantArea(maxX, maxY, workArea);
return new FillScore(parts.Count, usableRemnantArea, density); return new FillScore(parts.Count, usableRemnantArea, density);
} }
/// <summary> private static double ComputeUsableRemnantArea(double maxRight, double maxTop, Box workArea)
/// Finds the largest usable remnant (short side >= MinRemnantDimension)
/// by checking right and top edge strips between placed parts and the work area boundary.
/// </summary>
private static double ComputeUsableRemnantArea(List<Part> parts, Box workArea)
{ {
var maxRight = double.MinValue;
var maxTop = double.MinValue;
foreach (var part in parts)
{
var bb = part.BoundingBox;
if (bb.Right > maxRight)
maxRight = bb.Right;
if (bb.Top > maxTop)
maxTop = bb.Top;
}
var largest = 0.0; var largest = 0.0;
// Right strip // Right strip

View File

@@ -0,0 +1,119 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Linq;
using Microsoft.ML.OnnxRuntime;
using Microsoft.ML.OnnxRuntime.Tensors;
using OpenNest.Math;
namespace OpenNest.Engine.ML
{
public static class AnglePredictor
{
private static InferenceSession _session;
private static volatile bool _loadAttempted;
private static readonly object _lock = new();
public static List<double> PredictAngles(
PartFeatures features, double sheetWidth, double sheetHeight,
double threshold = 0.3)
{
var session = GetSession();
if (session == null)
return null;
try
{
var input = new float[11];
input[0] = (float)features.Area;
input[1] = (float)features.Convexity;
input[2] = (float)features.AspectRatio;
input[3] = (float)features.BoundingBoxFill;
input[4] = (float)features.Circularity;
input[5] = (float)features.PerimeterToAreaRatio;
input[6] = features.VertexCount;
input[7] = (float)sheetWidth;
input[8] = (float)sheetHeight;
input[9] = (float)(sheetWidth / (sheetHeight > 0 ? sheetHeight : 1.0));
input[10] = (float)(features.Area / (sheetWidth * sheetHeight));
var tensor = new DenseTensor<float>(input, new[] { 1, 11 });
var inputs = new List<NamedOnnxValue>
{
NamedOnnxValue.CreateFromTensor("features", tensor)
};
using var results = session.Run(inputs);
var probabilities = results.First().AsEnumerable<float>().ToArray();
var angles = new List<(double angleDeg, float prob)>();
for (var i = 0; i < 36 && i < probabilities.Length; i++)
{
if (probabilities[i] >= threshold)
angles.Add((i * 5.0, probabilities[i]));
}
// Minimum 3 angles — take top by probability if fewer pass threshold.
if (angles.Count < 3)
{
angles = probabilities
.Select((p, i) => (angleDeg: i * 5.0, prob: p))
.OrderByDescending(x => x.prob)
.Take(3)
.ToList();
}
// Always include 0 and 90 as safety fallback.
var result = angles.Select(a => Angle.ToRadians(a.angleDeg)).ToList();
if (!result.Any(a => a.IsEqualTo(0)))
result.Add(0);
if (!result.Any(a => a.IsEqualTo(Angle.HalfPI)))
result.Add(Angle.HalfPI);
return result;
}
catch (Exception ex)
{
Debug.WriteLine($"[AnglePredictor] Inference failed: {ex.Message}");
return null;
}
}
private static InferenceSession GetSession()
{
if (_loadAttempted)
return _session;
lock (_lock)
{
if (_loadAttempted)
return _session;
_loadAttempted = true;
try
{
var dir = Path.GetDirectoryName(typeof(AnglePredictor).Assembly.Location);
var modelPath = Path.Combine(dir, "Models", "angle_predictor.onnx");
if (!File.Exists(modelPath))
{
Debug.WriteLine($"[AnglePredictor] Model not found: {modelPath}");
return null;
}
_session = new InferenceSession(modelPath);
Debug.WriteLine("[AnglePredictor] Model loaded successfully");
}
catch (Exception ex)
{
Debug.WriteLine($"[AnglePredictor] Failed to load model: {ex.Message}");
}
return _session;
}
}
}
}

View File

@@ -0,0 +1,80 @@
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using OpenNest.Geometry;
namespace OpenNest.Engine.ML
{
public class BruteForceResult
{
public int PartCount { get; set; }
public double Utilization { get; set; }
public long TimeMs { get; set; }
public string LayoutData { get; set; }
public List<Part> PlacedParts { get; set; }
public string WinnerEngine { get; set; } = "";
public long WinnerTimeMs { get; set; }
public string RunnerUpEngine { get; set; } = "";
public int RunnerUpPartCount { get; set; }
public long RunnerUpTimeMs { get; set; }
public string ThirdPlaceEngine { get; set; } = "";
public int ThirdPlacePartCount { get; set; }
public long ThirdPlaceTimeMs { get; set; }
public List<AngleResult> AngleResults { get; set; } = new();
}
public static class BruteForceRunner
{
public static BruteForceResult Run(Drawing drawing, Plate plate, bool forceFullAngleSweep = false)
{
var engine = new DefaultNestEngine(plate);
engine.ForceFullAngleSweep = forceFullAngleSweep;
var item = new NestItem { Drawing = drawing };
var sw = Stopwatch.StartNew();
var parts = engine.Fill(item, plate.WorkArea(), null, System.Threading.CancellationToken.None);
sw.Stop();
if (parts == null || parts.Count == 0)
return null;
// Rank phase results — winner is explicit, runners-up sorted by count.
var winner = engine.PhaseResults
.FirstOrDefault(r => r.Phase == engine.WinnerPhase);
var runnerUps = engine.PhaseResults
.Where(r => r.PartCount > 0 && r.Phase != engine.WinnerPhase)
.OrderByDescending(r => r.PartCount)
.ToList();
return new BruteForceResult
{
PartCount = parts.Count,
Utilization = CalculateUtilization(parts, plate.Area()),
TimeMs = sw.ElapsedMilliseconds,
LayoutData = SerializeLayout(parts),
PlacedParts = parts,
WinnerEngine = engine.WinnerPhase.ToString(),
WinnerTimeMs = winner?.TimeMs ?? 0,
RunnerUpEngine = runnerUps.Count > 0 ? runnerUps[0].Phase.ToString() : "",
RunnerUpPartCount = runnerUps.Count > 0 ? runnerUps[0].PartCount : 0,
RunnerUpTimeMs = runnerUps.Count > 0 ? runnerUps[0].TimeMs : 0,
ThirdPlaceEngine = runnerUps.Count > 1 ? runnerUps[1].Phase.ToString() : "",
ThirdPlacePartCount = runnerUps.Count > 1 ? runnerUps[1].PartCount : 0,
ThirdPlaceTimeMs = runnerUps.Count > 1 ? runnerUps[1].TimeMs : 0,
AngleResults = engine.AngleResults.ToList()
};
}
private static string SerializeLayout(List<Part> parts)
{
var data = parts.Select(p => new { X = p.Location.X, Y = p.Location.Y, R = p.Rotation }).ToList();
return System.Text.Json.JsonSerializer.Serialize(data);
}
private static double CalculateUtilization(List<Part> parts, double plateArea)
{
if (plateArea <= 0) return 0;
return parts.Sum(p => p.BaseDrawing.Area) / plateArea;
}
}
}

View File

@@ -0,0 +1,90 @@
using System;
using System.Collections.Generic;
using System.Linq;
using OpenNest.Geometry;
namespace OpenNest.Engine.ML
{
public class PartFeatures
{
// --- Geometric Features ---
public double Area { get; set; }
public double Convexity { get; set; } // Area / Convex Hull Area
public double AspectRatio { get; set; } // Width / Length
public double BoundingBoxFill { get; set; } // Area / (Width * Length)
public double Circularity { get; set; } // 4 * PI * Area / Perimeter^2
public double PerimeterToAreaRatio { get; set; } // Perimeter / Area — spacing sensitivity
public int VertexCount { get; set; }
// --- Normalized Bitmask (32x32 = 1024 features) ---
public byte[] Bitmask { get; set; }
public override string ToString()
{
return $"{Area:F2},{Convexity:F4},{AspectRatio:F4},{BoundingBoxFill:F4},{Circularity:F4},{PerimeterToAreaRatio:F4},{VertexCount}";
}
}
public static class FeatureExtractor
{
public static PartFeatures Extract(Drawing drawing)
{
var entities = OpenNest.Converters.ConvertProgram.ToGeometry(drawing.Program)
.Where(e => e.Layer != SpecialLayers.Rapid)
.ToList();
var profile = new ShapeProfile(entities);
var perimeter = profile.Perimeter;
if (perimeter == null) return null;
var polygon = perimeter.ToPolygonWithTolerance(0.01);
polygon.UpdateBounds();
var bb = polygon.BoundingBox;
var hull = ConvexHull.Compute(polygon.Vertices);
var hullArea = hull.Area();
var features = new PartFeatures
{
Area = drawing.Area,
Convexity = drawing.Area / (hullArea > 0 ? hullArea : 1.0),
AspectRatio = bb.Width / (bb.Length > 0 ? bb.Length : 1.0),
BoundingBoxFill = drawing.Area / (bb.Area() > 0 ? bb.Area() : 1.0),
VertexCount = polygon.Vertices.Count,
Bitmask = GenerateBitmask(polygon, 32)
};
// Circularity = 4 * PI * Area / Perimeter^2
var perimeterLen = polygon.Perimeter();
features.Circularity = (4 * System.Math.PI * drawing.Area) / (perimeterLen * perimeterLen);
features.PerimeterToAreaRatio = drawing.Area > 0 ? perimeterLen / drawing.Area : 0;
return features;
}
private static byte[] GenerateBitmask(Polygon polygon, int size)
{
var mask = new byte[size * size];
polygon.UpdateBounds();
var bb = polygon.BoundingBox;
for (int y = 0; y < size; y++)
{
for (int x = 0; x < size; x++)
{
// Map grid coordinate (0..size) to bounding box coordinate
var px = bb.Left + (x + 0.5) * (bb.Width / size);
var py = bb.Bottom + (y + 0.5) * (bb.Length / size);
if (polygon.ContainsPoint(new Vector(px, py)))
{
mask[y * size + x] = 1;
}
}
}
return mask;
}
}
}

View File

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,319 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Threading;
using OpenNest.Geometry;
namespace OpenNest
{
public abstract class NestEngineBase
{
protected NestEngineBase(Plate plate)
{
Plate = plate;
}
public Plate Plate { get; set; }
public int PlateNumber { get; set; }
public NestDirection NestDirection { get; set; }
public NestPhase WinnerPhase { get; protected set; }
public List<PhaseResult> PhaseResults { get; } = new();
public List<AngleResult> AngleResults { get; } = new();
public abstract string Name { get; }
public abstract string Description { get; }
// --- Virtual methods (side-effect-free, return parts) ---
public virtual List<Part> Fill(NestItem item, Box workArea,
IProgress<NestProgress> progress, CancellationToken token)
{
return new List<Part>();
}
public virtual List<Part> Fill(List<Part> groupParts, Box workArea,
IProgress<NestProgress> progress, CancellationToken token)
{
return new List<Part>();
}
public virtual List<Part> PackArea(Box box, List<NestItem> items,
IProgress<NestProgress> progress, CancellationToken token)
{
return new List<Part>();
}
// --- Nest: multi-item strategy (virtual, side-effect-free) ---
public virtual List<Part> Nest(List<NestItem> items,
IProgress<NestProgress> progress, CancellationToken token)
{
if (items == null || items.Count == 0)
return new List<Part>();
var workArea = Plate.WorkArea();
var allParts = new List<Part>();
var fillItems = items
.Where(i => i.Quantity != 1)
.OrderBy(i => i.Priority)
.ThenByDescending(i => i.Drawing.Area)
.ToList();
var packItems = items
.Where(i => i.Quantity == 1)
.ToList();
// Phase 1: Fill multi-quantity drawings sequentially.
foreach (var item in fillItems)
{
if (token.IsCancellationRequested)
break;
if (item.Quantity <= 0 || workArea.Width <= 0 || workArea.Length <= 0)
continue;
var parts = FillExact(
new NestItem { Drawing = item.Drawing, Quantity = item.Quantity },
workArea, progress, token);
if (parts.Count > 0)
{
allParts.AddRange(parts);
item.Quantity = System.Math.Max(0, item.Quantity - parts.Count);
var placedBox = parts.Cast<IBoundable>().GetBoundingBox();
workArea = ComputeRemainderWithin(workArea, placedBox, Plate.PartSpacing);
}
}
// Phase 2: Pack single-quantity items into remaining space.
packItems = packItems.Where(i => i.Quantity > 0).ToList();
if (packItems.Count > 0 && workArea.Width > 0 && workArea.Length > 0
&& !token.IsCancellationRequested)
{
var packParts = PackArea(workArea, packItems, progress, token);
if (packParts.Count > 0)
{
allParts.AddRange(packParts);
foreach (var item in packItems)
{
var placed = packParts.Count(p =>
p.BaseDrawing.Name == item.Drawing.Name);
item.Quantity = System.Math.Max(0, item.Quantity - placed);
}
}
}
return allParts;
}
protected static Box ComputeRemainderWithin(Box workArea, Box usedBox, double spacing)
{
var hWidth = workArea.Right - usedBox.Right - spacing;
var hStrip = hWidth > 0
? new Box(usedBox.Right + spacing, workArea.Y, hWidth, workArea.Length)
: Box.Empty;
var vHeight = workArea.Top - usedBox.Top - spacing;
var vStrip = vHeight > 0
? new Box(workArea.X, usedBox.Top + spacing, workArea.Width, vHeight)
: Box.Empty;
return hStrip.Area() >= vStrip.Area() ? hStrip : vStrip;
}
// --- FillExact (non-virtual, delegates to virtual Fill) ---
public List<Part> FillExact(NestItem item, Box workArea,
IProgress<NestProgress> progress, CancellationToken token)
{
return Fill(item, workArea, progress, token);
}
// --- Convenience overloads (mutate plate, return bool) ---
public bool Fill(NestItem item)
{
return Fill(item, Plate.WorkArea());
}
public bool Fill(NestItem item, Box workArea)
{
var parts = Fill(item, workArea, null, CancellationToken.None);
if (parts == null || parts.Count == 0)
return false;
Plate.Parts.AddRange(parts);
return true;
}
public bool Fill(List<Part> groupParts)
{
return Fill(groupParts, Plate.WorkArea());
}
public bool Fill(List<Part> groupParts, Box workArea)
{
var parts = Fill(groupParts, workArea, null, CancellationToken.None);
if (parts == null || parts.Count == 0)
return false;
Plate.Parts.AddRange(parts);
return true;
}
public bool Pack(List<NestItem> items)
{
var workArea = Plate.WorkArea();
var parts = PackArea(workArea, items, null, CancellationToken.None);
if (parts == null || parts.Count == 0)
return false;
Plate.Parts.AddRange(parts);
return true;
}
// --- Protected utilities ---
protected static void ReportProgress(
IProgress<NestProgress> progress,
NestPhase phase,
int plateNumber,
List<Part> best,
Box workArea,
string description)
{
if (progress == null || best == null || best.Count == 0)
return;
var score = FillScore.Compute(best, workArea);
var clonedParts = new List<Part>(best.Count);
var totalPartArea = 0.0;
foreach (var part in best)
{
clonedParts.Add((Part)part.Clone());
totalPartArea += part.BaseDrawing.Area;
}
var bounds = best.GetBoundingBox();
var msg = $"[Progress] Phase={phase}, Plate={plateNumber}, Parts={score.Count}, " +
$"Density={score.Density:P1}, Nested={bounds.Width:F1}x{bounds.Length:F1}, " +
$"PartArea={totalPartArea:F0}, Remnant={workArea.Area() - totalPartArea:F0}, " +
$"WorkArea={workArea.Width:F1}x{workArea.Length:F1} | {description}";
Debug.WriteLine(msg);
try { System.IO.File.AppendAllText(
System.IO.Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.Desktop), "nest-debug.log"),
$"{DateTime.Now:HH:mm:ss.fff} {msg}\n"); } catch { }
progress.Report(new NestProgress
{
Phase = phase,
PlateNumber = plateNumber,
BestPartCount = score.Count,
BestDensity = score.Density,
NestedWidth = bounds.Width,
NestedLength = bounds.Length,
NestedArea = totalPartArea,
UsableRemnantArea = workArea.Area() - totalPartArea,
BestParts = clonedParts,
Description = description
});
}
protected string BuildProgressSummary()
{
if (PhaseResults.Count == 0)
return null;
var parts = new List<string>(PhaseResults.Count);
foreach (var r in PhaseResults)
parts.Add($"{FormatPhaseName(r.Phase)}: {r.PartCount}");
return string.Join(" | ", parts);
}
protected bool IsBetterFill(List<Part> candidate, List<Part> current, Box workArea)
{
if (candidate == null || candidate.Count == 0)
return false;
if (current == null || current.Count == 0)
return true;
return FillScore.Compute(candidate, workArea) > FillScore.Compute(current, workArea);
}
protected bool IsBetterValidFill(List<Part> candidate, List<Part> current, Box workArea)
{
if (candidate != null && candidate.Count > 0 && HasOverlaps(candidate, Plate.PartSpacing))
{
Debug.WriteLine($"[IsBetterValidFill] REJECTED {candidate.Count} parts due to overlaps (current best: {current?.Count ?? 0})");
return false;
}
return IsBetterFill(candidate, current, workArea);
}
protected static bool HasOverlaps(List<Part> parts, double spacing)
{
if (parts == null || parts.Count <= 1)
return false;
for (var i = 0; i < parts.Count; i++)
{
var box1 = parts[i].BoundingBox;
for (var j = i + 1; j < parts.Count; j++)
{
var box2 = parts[j].BoundingBox;
if (box1.Right < box2.Left || box2.Right < box1.Left ||
box1.Top < box2.Bottom || box2.Top < box1.Bottom)
continue;
List<Vector> pts;
if (parts[i].Intersects(parts[j], out pts))
{
var b1 = parts[i].BoundingBox;
var b2 = parts[j].BoundingBox;
Debug.WriteLine($"[HasOverlaps] Overlap: part[{i}] ({parts[i].BaseDrawing?.Name}) @ ({b1.Left:F2},{b1.Bottom:F2})-({b1.Right:F2},{b1.Top:F2}) rot={parts[i].Rotation:F2}" +
$" vs part[{j}] ({parts[j].BaseDrawing?.Name}) @ ({b2.Left:F2},{b2.Bottom:F2})-({b2.Right:F2},{b2.Top:F2}) rot={parts[j].Rotation:F2}" +
$" intersections={pts?.Count ?? 0}");
return true;
}
}
}
return false;
}
protected static string FormatPhaseName(NestPhase phase)
{
switch (phase)
{
case NestPhase.Pairs: return "Pairs";
case NestPhase.Linear: return "Linear";
case NestPhase.RectBestFit: return "BestFit";
case NestPhase.Remainder: return "Remainder";
default: return phase.ToString();
}
}
}
}

View File

@@ -0,0 +1,18 @@
using System;
namespace OpenNest
{
public class NestEngineInfo
{
public NestEngineInfo(string name, string description, Func<Plate, NestEngineBase> factory)
{
Name = name;
Description = description;
Factory = factory;
}
public string Name { get; }
public string Description { get; }
public Func<Plate, NestEngineBase> Factory { get; }
}
}

View File

@@ -0,0 +1,100 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Reflection;
namespace OpenNest
{
public static class NestEngineRegistry
{
private static readonly List<NestEngineInfo> engines = new();
static NestEngineRegistry()
{
Register("Default",
"Multi-phase nesting (Linear, Pairs, RectBestFit, Remainder)",
plate => new DefaultNestEngine(plate));
Register("Strip",
"Strip-based nesting for mixed-drawing layouts",
plate => new StripNestEngine(plate));
}
public static IReadOnlyList<NestEngineInfo> AvailableEngines => engines;
public static string ActiveEngineName { get; set; } = "Default";
public static NestEngineBase Create(Plate plate)
{
var info = engines.FirstOrDefault(e =>
e.Name.Equals(ActiveEngineName, StringComparison.OrdinalIgnoreCase));
if (info == null)
{
Debug.WriteLine($"[NestEngineRegistry] Engine '{ActiveEngineName}' not found, falling back to Default");
info = engines[0];
}
return info.Factory(plate);
}
public static void Register(string name, string description, Func<Plate, NestEngineBase> factory)
{
if (engines.Any(e => e.Name.Equals(name, StringComparison.OrdinalIgnoreCase)))
{
Debug.WriteLine($"[NestEngineRegistry] Duplicate engine '{name}' skipped");
return;
}
engines.Add(new NestEngineInfo(name, description, factory));
}
public static void LoadPlugins(string directory)
{
if (!Directory.Exists(directory))
return;
foreach (var dll in Directory.GetFiles(directory, "*.dll"))
{
try
{
var assembly = Assembly.LoadFrom(dll);
foreach (var type in assembly.GetTypes())
{
if (type.IsAbstract || !typeof(NestEngineBase).IsAssignableFrom(type))
continue;
var ctor = type.GetConstructor(new[] { typeof(Plate) });
if (ctor == null)
{
Debug.WriteLine($"[NestEngineRegistry] Skipping {type.Name}: no Plate constructor");
continue;
}
// Create a temporary instance to read Name and Description.
try
{
var tempPlate = new Plate();
var instance = (NestEngineBase)ctor.Invoke(new object[] { tempPlate });
Register(instance.Name, instance.Description,
plate => (NestEngineBase)ctor.Invoke(new object[] { plate }));
Debug.WriteLine($"[NestEngineRegistry] Loaded plugin engine: {instance.Name}");
}
catch (Exception ex)
{
Debug.WriteLine($"[NestEngineRegistry] Failed to instantiate {type.Name}: {ex.Message}");
}
}
}
catch (Exception ex)
{
Debug.WriteLine($"[NestEngineRegistry] Failed to load assembly {Path.GetFileName(dll)}: {ex.Message}");
}
}
}
}
}

View File

@@ -11,13 +11,38 @@ namespace OpenNest
Remainder Remainder
} }
public class PhaseResult
{
public NestPhase Phase { get; set; }
public int PartCount { get; set; }
public long TimeMs { get; set; }
public PhaseResult(NestPhase phase, int partCount, long timeMs)
{
Phase = phase;
PartCount = partCount;
TimeMs = timeMs;
}
}
public class AngleResult
{
public double AngleDeg { get; set; }
public NestDirection Direction { get; set; }
public int PartCount { get; set; }
}
public class NestProgress public class NestProgress
{ {
public NestPhase Phase { get; set; } public NestPhase Phase { get; set; }
public int PlateNumber { get; set; } public int PlateNumber { get; set; }
public int BestPartCount { get; set; } public int BestPartCount { get; set; }
public double BestDensity { get; set; } public double BestDensity { get; set; }
public double NestedWidth { get; set; }
public double NestedLength { get; set; }
public double NestedArea { get; set; }
public double UsableRemnantArea { get; set; } public double UsableRemnantArea { get; set; }
public List<Part> BestParts { get; set; } public List<Part> BestParts { get; set; }
public string Description { get; set; }
} }
} }

View File

@@ -7,4 +7,10 @@
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\OpenNest.Core\OpenNest.Core.csproj" /> <ProjectReference Include="..\OpenNest.Core\OpenNest.Core.csproj" />
</ItemGroup> </ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.ML.OnnxRuntime" Version="1.17.3" />
</ItemGroup>
<ItemGroup>
<Content Include="Models\**" CopyToOutputDirectory="PreserveNewest" Condition="Exists('Models')" />
</ItemGroup>
</Project> </Project>

View File

@@ -23,22 +23,26 @@ namespace OpenNest
public PartBoundary(Part part, double spacing) public PartBoundary(Part part, double spacing)
{ {
var entities = ConvertProgram.ToGeometry(part.Program); var entities = ConvertProgram.ToGeometry(part.Program)
var shapes = Helper.GetShapes(entities.Where(e => e.Layer != SpecialLayers.Rapid)); .Where(e => e.Layer != SpecialLayers.Rapid)
.ToList();
var definedShape = new ShapeProfile(entities);
var perimeter = definedShape.Perimeter;
_polygons = new List<Polygon>(); _polygons = new List<Polygon>();
foreach (var shape in shapes) if (perimeter != null)
{ {
var offsetEntity = shape.OffsetEntity(spacing, OffsetSide.Left) as Shape; var offsetEntity = perimeter.OffsetEntity(spacing, OffsetSide.Left) as Shape;
if (offsetEntity == null) if (offsetEntity != null)
continue; {
// Circumscribe arcs so polygon vertices are always outside
// Circumscribe arcs so polygon vertices are always outside // the true arc — guarantees the boundary never under-estimates.
// the true arc — guarantees the boundary never under-estimates. var polygon = offsetEntity.ToPolygonWithTolerance(PolygonTolerance, circumscribe: true);
var polygon = offsetEntity.ToPolygonWithTolerance(PolygonTolerance, circumscribe: true); polygon.RemoveSelfIntersections();
polygon.RemoveSelfIntersections(); _polygons.Add(polygon);
_polygons.Add(polygon); }
} }
PrecomputeDirectionalEdges( PrecomputeDirectionalEdges(
@@ -89,10 +93,10 @@ namespace OpenNest
} }
} }
leftEdges = left.ToArray(); leftEdges = left.OrderBy(e => System.Math.Min(e.Item1.Y, e.Item2.Y)).ToArray();
rightEdges = right.ToArray(); rightEdges = right.OrderBy(e => System.Math.Min(e.Item1.Y, e.Item2.Y)).ToArray();
upEdges = up.ToArray(); upEdges = up.OrderBy(e => System.Math.Min(e.Item1.X, e.Item2.X)).ToArray();
downEdges = down.ToArray(); downEdges = down.OrderBy(e => System.Math.Min(e.Item1.X, e.Item2.X)).ToArray();
} }
/// <summary> /// <summary>
@@ -148,5 +152,14 @@ namespace OpenNest
default: return _leftEdges; default: return _leftEdges;
} }
} }
/// <summary>
/// Returns the pre-computed edge arrays for the given direction.
/// These are in part-local coordinates (no translation applied).
/// </summary>
public (Vector start, Vector end)[] GetEdges(PushDirection direction)
{
return GetDirectionalEdges(direction);
}
} }
} }

View File

@@ -0,0 +1,120 @@
using System.Collections.Generic;
using System.Linq;
using OpenNest.CNC;
using OpenNest.CNC.CuttingStrategy;
using OpenNest.Engine.RapidPlanning;
using OpenNest.Engine.Sequencing;
using OpenNest.Geometry;
namespace OpenNest.Engine
{
public class PlateProcessor
{
public IPartSequencer Sequencer { get; set; }
public ContourCuttingStrategy CuttingStrategy { get; set; }
public IRapidPlanner RapidPlanner { get; set; }
public PlateResult Process(Plate plate)
{
var sequenced = Sequencer.Sequence(plate.Parts.ToList(), plate);
var results = new List<ProcessedPart>(sequenced.Count);
var cutAreas = new List<Shape>();
var currentPoint = PlateHelper.GetExitPoint(plate);
foreach (var sp in sequenced)
{
var part = sp.Part;
// Compute approach point in part-local space
var localApproach = ToPartLocal(currentPoint, part);
Program processedProgram;
Vector lastCutLocal;
if (!part.HasManualLeadIns && CuttingStrategy != null)
{
var cuttingResult = CuttingStrategy.Apply(part.Program, localApproach);
processedProgram = cuttingResult.Program;
lastCutLocal = cuttingResult.LastCutPoint;
}
else
{
processedProgram = part.Program;
lastCutLocal = GetProgramEndPoint(part.Program);
}
// Pierce point: program start point in plate space
var pierceLocal = GetProgramStartPoint(part.Program);
var piercePoint = ToPlateSpace(pierceLocal, part);
// Plan rapid from currentPoint to pierce point
var rapidPath = RapidPlanner.Plan(currentPoint, piercePoint, cutAreas);
results.Add(new ProcessedPart
{
Part = part,
ProcessedProgram = processedProgram,
RapidPath = rapidPath
});
// Update cut areas with part perimeter
var perimeter = GetPartPerimeter(part);
if (perimeter != null)
cutAreas.Add(perimeter);
// Update current point to last cut point in plate space
currentPoint = ToPlateSpace(lastCutLocal, part);
}
return new PlateResult { Parts = results };
}
private static Vector ToPartLocal(Vector platePoint, Part part)
{
return platePoint - part.Location;
}
private static Vector ToPlateSpace(Vector localPoint, Part part)
{
return localPoint + part.Location;
}
private static Vector GetProgramStartPoint(Program program)
{
if (program.Codes.Count == 0)
return Vector.Zero;
var first = program.Codes[0];
if (first is Motion motion)
return motion.EndPoint;
return Vector.Zero;
}
private static Vector GetProgramEndPoint(Program program)
{
for (var i = program.Codes.Count - 1; i >= 0; i--)
{
if (program.Codes[i] is Motion motion)
return motion.EndPoint;
}
return Vector.Zero;
}
private static Shape GetPartPerimeter(Part part)
{
var entities = part.Program.ToGeometry();
if (entities == null || entities.Count == 0)
return null;
var profile = new ShapeProfile(entities);
var perimeter = profile.Perimeter;
if (perimeter == null || perimeter.Entities.Count == 0)
return null;
perimeter.Offset(part.Location);
return perimeter;
}
}
}

View File

@@ -0,0 +1,18 @@
using System.Collections.Generic;
using OpenNest.CNC;
using OpenNest.Engine.RapidPlanning;
namespace OpenNest.Engine
{
public class PlateResult
{
public List<ProcessedPart> Parts { get; init; }
}
public readonly struct ProcessedPart
{
public Part Part { get; init; }
public Program ProcessedProgram { get; init; }
public RapidPath RapidPath { get; init; }
}
}

View File

@@ -0,0 +1,44 @@
using System.Collections.Generic;
using OpenNest.Geometry;
namespace OpenNest.Engine.RapidPlanning
{
public class DirectRapidPlanner : IRapidPlanner
{
public RapidPath Plan(Vector from, Vector to, IReadOnlyList<Shape> cutAreas)
{
var travelLine = new Line(from, to);
foreach (var cutArea in cutAreas)
{
if (TravelLineIntersectsShape(travelLine, cutArea))
{
return new RapidPath
{
HeadUp = true,
Waypoints = new List<Vector>()
};
}
}
return new RapidPath
{
HeadUp = false,
Waypoints = new List<Vector>()
};
}
private static bool TravelLineIntersectsShape(Line travelLine, Shape shape)
{
foreach (var entity in shape.Entities)
{
if (entity is Line edge)
{
if (travelLine.Intersects(edge, out _))
return true;
}
}
return false;
}
}
}

View File

@@ -0,0 +1,10 @@
using System.Collections.Generic;
using OpenNest.Geometry;
namespace OpenNest.Engine.RapidPlanning
{
public interface IRapidPlanner
{
RapidPath Plan(Vector from, Vector to, IReadOnlyList<Shape> cutAreas);
}
}

View File

@@ -0,0 +1,11 @@
using System.Collections.Generic;
using OpenNest.Geometry;
namespace OpenNest.Engine.RapidPlanning
{
public readonly struct RapidPath
{
public bool HeadUp { get; init; }
public List<Vector> Waypoints { get; init; }
}
}

View File

@@ -0,0 +1,17 @@
using System.Collections.Generic;
using OpenNest.Geometry;
namespace OpenNest.Engine.RapidPlanning
{
public class SafeHeightRapidPlanner : IRapidPlanner
{
public RapidPath Plan(Vector from, Vector to, IReadOnlyList<Shape> cutAreas)
{
return new RapidPath
{
HeadUp = true,
Waypoints = new List<Vector>()
};
}
}
}

View File

@@ -17,7 +17,7 @@ namespace OpenNest
var entities = ConvertProgram.ToGeometry(item.Drawing.Program) var entities = ConvertProgram.ToGeometry(item.Drawing.Program)
.Where(e => e.Layer != SpecialLayers.Rapid); .Where(e => e.Layer != SpecialLayers.Rapid);
var shapes = Helper.GetShapes(entities); var shapes = ShapeBuilder.GetShapes(entities);
if (shapes.Count == 0) if (shapes.Count == 0)
return 0; return 0;
@@ -65,7 +65,7 @@ namespace OpenNest
var entities = ConvertProgram.ToGeometry(part.Program) var entities = ConvertProgram.ToGeometry(part.Program)
.Where(e => e.Layer != SpecialLayers.Rapid); .Where(e => e.Layer != SpecialLayers.Rapid);
var shapes = Helper.GetShapes(entities); var shapes = ShapeBuilder.GetShapes(entities);
foreach (var shape in shapes) foreach (var shape in shapes)
{ {
@@ -80,6 +80,11 @@ namespace OpenNest
return new List<double> { 0 }; return new List<double> { 0 };
var hull = ConvexHull.Compute(points); var hull = ConvexHull.Compute(points);
return GetHullEdgeAngles(hull);
}
public static List<double> GetHullEdgeAngles(Polygon hull)
{
var vertices = hull.Vertices; var vertices = hull.Vertices;
var n = hull.IsClosed() ? vertices.Count - 1 : vertices.Count; var n = hull.IsClosed() ? vertices.Count - 1 : vertices.Count;

View File

@@ -0,0 +1,96 @@
using System.Collections.Generic;
using System.Linq;
using OpenNest.CNC.CuttingStrategy;
using OpenNest.Math;
namespace OpenNest.Engine.Sequencing
{
public class AdvancedSequencer : IPartSequencer
{
private readonly SequenceParameters _parameters;
public AdvancedSequencer(SequenceParameters parameters)
{
_parameters = parameters;
}
public List<SequencedPart> Sequence(IReadOnlyList<Part> parts, Plate plate)
{
if (parts.Count == 0)
return new List<SequencedPart>();
var exit = PlateHelper.GetExitPoint(plate);
// Group parts into rows by Y proximity
var rows = GroupIntoRows(parts, _parameters.MinDistanceBetweenRowsColumns);
// Sort rows bottom-to-top (ascending Y)
rows.Sort((a, b) => a.RowY.CompareTo(b.RowY));
// Determine initial direction based on exit point
var leftToRight = exit.X > plate.Size.Width * 0.5;
var result = new List<SequencedPart>(parts.Count);
foreach (var row in rows)
{
var sorted = leftToRight
? row.Parts.OrderBy(p => p.BoundingBox.Center.X).ToList()
: row.Parts.OrderByDescending(p => p.BoundingBox.Center.X).ToList();
foreach (var p in sorted)
result.Add(new SequencedPart { Part = p });
if (_parameters.AlternateRowsColumns)
leftToRight = !leftToRight;
}
return result;
}
private static List<PartRow> GroupIntoRows(IReadOnlyList<Part> parts, double minDistance)
{
// Sort parts by Y center
var sorted = parts
.OrderBy(p => p.BoundingBox.Center.Y)
.ToList();
var rows = new List<PartRow>();
foreach (var part in sorted)
{
var y = part.BoundingBox.Center.Y;
var placed = false;
foreach (var row in rows)
{
if (System.Math.Abs(y - row.RowY) <= minDistance + Tolerance.Epsilon)
{
row.Parts.Add(part);
placed = true;
break;
}
}
if (!placed)
{
var row = new PartRow(y);
row.Parts.Add(part);
rows.Add(row);
}
}
return rows;
}
private class PartRow
{
public double RowY { get; }
public List<Part> Parts { get; } = new List<Part>();
public PartRow(double rowY)
{
RowY = rowY;
}
}
}
}

View File

@@ -0,0 +1,17 @@
using System.Collections.Generic;
using System.Linq;
namespace OpenNest.Engine.Sequencing
{
public class BottomSideSequencer : IPartSequencer
{
public List<SequencedPart> Sequence(IReadOnlyList<Part> parts, Plate plate)
{
return parts
.OrderBy(p => p.Location.Y)
.ThenBy(p => p.Location.X)
.Select(p => new SequencedPart { Part = p })
.ToList();
}
}
}

View File

@@ -0,0 +1,36 @@
using System.Collections.Generic;
using System.Linq;
namespace OpenNest.Engine.Sequencing
{
public class EdgeStartSequencer : IPartSequencer
{
public List<SequencedPart> Sequence(IReadOnlyList<Part> parts, Plate plate)
{
// Plate(width, length) stores Size with Width/Length swapped internally.
// Reconstruct the logical plate box using the BoundingBox origin and the
// corrected extents: Size.Length = X-extent, Size.Width = Y-extent.
var origin = plate.BoundingBox(false);
var plateBox = new OpenNest.Geometry.Box(
origin.X, origin.Y,
plate.Size.Length,
plate.Size.Width);
return parts
.OrderBy(p => MinEdgeDistance(p.BoundingBox.Center, plateBox))
.ThenBy(p => p.Location.X)
.Select(p => new SequencedPart { Part = p })
.ToList();
}
private static double MinEdgeDistance(OpenNest.Geometry.Vector center, OpenNest.Geometry.Box plateBox)
{
var distLeft = center.X - plateBox.Left;
var distRight = plateBox.Right - center.X;
var distBottom = center.Y - plateBox.Bottom;
var distTop = plateBox.Top - center.Y;
return System.Math.Min(System.Math.Min(distLeft, distRight), System.Math.Min(distBottom, distTop));
}
}
}

View File

@@ -0,0 +1,14 @@
using System.Collections.Generic;
namespace OpenNest.Engine.Sequencing
{
public readonly struct SequencedPart
{
public Part Part { get; init; }
}
public interface IPartSequencer
{
List<SequencedPart> Sequence(IReadOnlyList<Part> parts, Plate plate);
}
}

View File

@@ -0,0 +1,139 @@
using System;
using System.Collections.Generic;
using OpenNest.Math;
namespace OpenNest.Engine.Sequencing
{
public class LeastCodeSequencer : IPartSequencer
{
private readonly int _maxIterations;
public LeastCodeSequencer(int maxIterations = 100)
{
_maxIterations = maxIterations;
}
public List<SequencedPart> Sequence(IReadOnlyList<Part> parts, Plate plate)
{
if (parts.Count == 0)
return new List<SequencedPart>();
var exit = PlateHelper.GetExitPoint(plate);
var ordered = NearestNeighbor(parts, exit);
TwoOpt(ordered, exit);
var result = new List<SequencedPart>(ordered.Count);
foreach (var p in ordered)
result.Add(new SequencedPart { Part = p });
return result;
}
private static List<Part> NearestNeighbor(IReadOnlyList<Part> parts, OpenNest.Geometry.Vector exit)
{
var remaining = new List<Part>(parts);
var ordered = new List<Part>(parts.Count);
var current = exit;
while (remaining.Count > 0)
{
var bestIdx = 0;
var bestDist = Distance(current, Center(remaining[0]));
for (var i = 1; i < remaining.Count; i++)
{
var d = Distance(current, Center(remaining[i]));
if (d < bestDist - Tolerance.Epsilon)
{
bestDist = d;
bestIdx = i;
}
}
var next = remaining[bestIdx];
ordered.Add(next);
remaining.RemoveAt(bestIdx);
current = Center(next);
}
return ordered;
}
private void TwoOpt(List<Part> ordered, OpenNest.Geometry.Vector exit)
{
var n = ordered.Count;
if (n < 3)
return;
for (var iter = 0; iter < _maxIterations; iter++)
{
var improved = false;
for (var i = 0; i < n - 1; i++)
{
for (var j = i + 1; j < n; j++)
{
var before = RouteDistance(ordered, exit, i, j);
Reverse(ordered, i, j);
var after = RouteDistance(ordered, exit, i, j);
if (after < before - Tolerance.Epsilon)
{
improved = true;
}
else
{
// Revert
Reverse(ordered, i, j);
}
}
}
if (!improved)
break;
}
}
/// <summary>
/// Computes the total distance of the route starting from exit through all parts.
/// Only the segment around the reversed segment [i..j] needs to be checked,
/// but here we compute the full route cost for correctness.
/// </summary>
private static double RouteDistance(List<Part> ordered, OpenNest.Geometry.Vector exit, int i, int j)
{
// Full route distance: exit -> ordered[0] -> ... -> ordered[n-1]
var total = 0.0;
var prev = exit;
foreach (var p in ordered)
{
var c = Center(p);
total += Distance(prev, c);
prev = c;
}
return total;
}
private static void Reverse(List<Part> list, int i, int j)
{
while (i < j)
{
var tmp = list[i];
list[i] = list[j];
list[j] = tmp;
i++;
j--;
}
}
private static OpenNest.Geometry.Vector Center(Part part)
{
return part.BoundingBox.Center;
}
private static double Distance(OpenNest.Geometry.Vector a, OpenNest.Geometry.Vector b)
{
var dx = b.X - a.X;
var dy = b.Y - a.Y;
return System.Math.Sqrt(dx * dx + dy * dy);
}
}
}

View File

@@ -0,0 +1,17 @@
using System.Collections.Generic;
using System.Linq;
namespace OpenNest.Engine.Sequencing
{
public class LeftSideSequencer : IPartSequencer
{
public List<SequencedPart> Sequence(IReadOnlyList<Part> parts, Plate plate)
{
return parts
.OrderBy(p => p.Location.X)
.ThenBy(p => p.Location.Y)
.Select(p => new SequencedPart { Part = p })
.ToList();
}
}
}

View File

@@ -0,0 +1,23 @@
using System;
using OpenNest.CNC.CuttingStrategy;
namespace OpenNest.Engine.Sequencing
{
public static class PartSequencerFactory
{
public static IPartSequencer Create(SequenceParameters parameters)
{
return parameters.Method switch
{
SequenceMethod.RightSide => new RightSideSequencer(),
SequenceMethod.LeftSide => new LeftSideSequencer(),
SequenceMethod.BottomSide => new BottomSideSequencer(),
SequenceMethod.EdgeStart => new EdgeStartSequencer(),
SequenceMethod.LeastCode => new LeastCodeSequencer(),
SequenceMethod.Advanced => new AdvancedSequencer(parameters),
_ => throw new NotSupportedException(
$"Sequence method '{parameters.Method}' is not supported.")
};
}
}
}

View File

@@ -0,0 +1,22 @@
using OpenNest.Geometry;
namespace OpenNest.Engine.Sequencing
{
internal static class PlateHelper
{
public static Vector GetExitPoint(Plate plate)
{
var w = plate.Size.Width;
var l = plate.Size.Length;
return plate.Quadrant switch
{
1 => new Vector(w, l),
2 => new Vector(0, l),
3 => new Vector(0, 0),
4 => new Vector(w, 0),
_ => new Vector(w, l)
};
}
}
}

View File

@@ -0,0 +1,17 @@
using System.Collections.Generic;
using System.Linq;
namespace OpenNest.Engine.Sequencing
{
public class RightSideSequencer : IPartSequencer
{
public List<SequencedPart> Sequence(IReadOnlyList<Part> parts, Plate plate)
{
return parts
.OrderByDescending(p => p.Location.X)
.ThenBy(p => p.Location.Y)
.Select(p => new SequencedPart { Part = p })
.ToList();
}
}
}

View File

@@ -0,0 +1,8 @@
namespace OpenNest
{
public enum StripDirection
{
Bottom,
Left
}
}

View File

@@ -0,0 +1,375 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using OpenNest.Geometry;
using OpenNest.Math;
namespace OpenNest
{
public class StripNestEngine : NestEngineBase
{
private const int MaxShrinkIterations = 20;
public StripNestEngine(Plate plate) : base(plate)
{
}
public override string Name => "Strip";
public override string Description => "Strip-based nesting for mixed-drawing layouts";
/// <summary>
/// Single-item fill delegates to DefaultNestEngine.
/// The strip strategy adds value for multi-drawing nesting, not single-item fills.
/// </summary>
public override List<Part> Fill(NestItem item, Box workArea,
IProgress<NestProgress> progress, CancellationToken token)
{
var inner = new DefaultNestEngine(Plate);
return inner.Fill(item, workArea, progress, token);
}
/// <summary>
/// Group-parts fill delegates to DefaultNestEngine.
/// </summary>
public override List<Part> Fill(List<Part> groupParts, Box workArea,
IProgress<NestProgress> progress, CancellationToken token)
{
var inner = new DefaultNestEngine(Plate);
return inner.Fill(groupParts, workArea, progress, token);
}
/// <summary>
/// Pack delegates to DefaultNestEngine.
/// </summary>
public override List<Part> PackArea(Box box, List<NestItem> items,
IProgress<NestProgress> progress, CancellationToken token)
{
var inner = new DefaultNestEngine(Plate);
return inner.PackArea(box, items, progress, token);
}
/// <summary>
/// Selects the item that consumes the most plate area (bounding box area x quantity).
/// Returns the index into the items list.
/// </summary>
private static int SelectStripItemIndex(List<NestItem> items, Box workArea)
{
var bestIndex = 0;
var bestArea = 0.0;
for (var i = 0; i < items.Count; i++)
{
var bbox = items[i].Drawing.Program.BoundingBox();
var qty = items[i].Quantity > 0
? items[i].Quantity
: (int)(workArea.Area() / bbox.Area());
var totalArea = bbox.Area() * qty;
if (totalArea > bestArea)
{
bestArea = totalArea;
bestIndex = i;
}
}
return bestIndex;
}
/// <summary>
/// Estimates the strip dimension (height for bottom, width for left) needed
/// to fit the target quantity. Tries 0 deg and 90 deg rotations and picks the shorter.
/// This is only an estimate for the shrink loop starting point — the actual fill
/// uses DefaultNestEngine.Fill which tries many rotation angles internally.
/// </summary>
private static double EstimateStripDimension(NestItem item, double stripLength, double maxDimension)
{
var bbox = item.Drawing.Program.BoundingBox();
var qty = item.Quantity > 0
? item.Quantity
: System.Math.Max(1, (int)(stripLength * maxDimension / bbox.Area()));
// At 0 deg: parts per row along strip length, strip dimension is bbox.Length
var perRow0 = (int)(stripLength / bbox.Width);
var rows0 = perRow0 > 0 ? (int)System.Math.Ceiling((double)qty / perRow0) : int.MaxValue;
var dim0 = rows0 * bbox.Length;
// At 90 deg: rotated bounding box (Width and Length swap)
var perRow90 = (int)(stripLength / bbox.Length);
var rows90 = perRow90 > 0 ? (int)System.Math.Ceiling((double)qty / perRow90) : int.MaxValue;
var dim90 = rows90 * bbox.Width;
var estimate = System.Math.Min(dim0, dim90);
// Clamp to available dimension
return System.Math.Min(estimate, maxDimension);
}
/// <summary>
/// Multi-drawing strip nesting strategy.
/// Picks the largest-area drawing for strip treatment, finds the tightest strip
/// in both bottom and left orientations, fills remnants with remaining drawings,
/// and returns the denser result.
/// </summary>
public override List<Part> Nest(List<NestItem> items,
IProgress<NestProgress> progress, CancellationToken token)
{
if (items == null || items.Count == 0)
return new List<Part>();
var workArea = Plate.WorkArea();
// Select which item gets the strip treatment.
var stripIndex = SelectStripItemIndex(items, workArea);
var stripItem = items[stripIndex];
var remainderItems = items.Where((_, i) => i != stripIndex).ToList();
// Try both orientations.
var bottomResult = TryOrientation(StripDirection.Bottom, stripItem, remainderItems, workArea, progress, token);
var leftResult = TryOrientation(StripDirection.Left, stripItem, remainderItems, workArea, progress, token);
// Pick the better result.
var winner = bottomResult.Score >= leftResult.Score
? bottomResult.Parts
: leftResult.Parts;
// Deduct placed quantities from the original items.
foreach (var item in items)
{
if (item.Quantity <= 0)
continue;
var placed = winner.Count(p => p.BaseDrawing.Name == item.Drawing.Name);
item.Quantity = System.Math.Max(0, item.Quantity - placed);
}
return winner;
}
private StripNestResult TryOrientation(StripDirection direction, NestItem stripItem,
List<NestItem> remainderItems, Box workArea, IProgress<NestProgress> progress, CancellationToken token)
{
var result = new StripNestResult { Direction = direction };
if (token.IsCancellationRequested)
return result;
// Estimate initial strip dimension.
var stripLength = direction == StripDirection.Bottom ? workArea.Width : workArea.Length;
var maxDimension = direction == StripDirection.Bottom ? workArea.Length : workArea.Width;
var estimatedDim = EstimateStripDimension(stripItem, stripLength, maxDimension);
// Create the initial strip box.
var stripBox = direction == StripDirection.Bottom
? new Box(workArea.X, workArea.Y, workArea.Width, estimatedDim)
: new Box(workArea.X, workArea.Y, estimatedDim, workArea.Length);
// Initial fill using DefaultNestEngine (composition, not inheritance).
var inner = new DefaultNestEngine(Plate);
var stripParts = inner.Fill(
new NestItem { Drawing = stripItem.Drawing, Quantity = stripItem.Quantity },
stripBox, progress, token);
if (stripParts == null || stripParts.Count == 0)
return result;
// Measure actual strip dimension from placed parts.
var placedBox = stripParts.Cast<IBoundable>().GetBoundingBox();
var actualDim = direction == StripDirection.Bottom
? placedBox.Top - workArea.Y
: placedBox.Right - workArea.X;
var bestParts = stripParts;
var bestDim = actualDim;
var targetCount = stripParts.Count;
// Shrink loop: reduce strip dimension by PartSpacing until count drops.
for (var i = 0; i < MaxShrinkIterations; i++)
{
if (token.IsCancellationRequested)
break;
var trialDim = bestDim - Plate.PartSpacing;
if (trialDim <= 0)
break;
var trialBox = direction == StripDirection.Bottom
? new Box(workArea.X, workArea.Y, workArea.Width, trialDim)
: new Box(workArea.X, workArea.Y, trialDim, workArea.Length);
var trialInner = new DefaultNestEngine(Plate);
var trialParts = trialInner.Fill(
new NestItem { Drawing = stripItem.Drawing, Quantity = stripItem.Quantity },
trialBox, progress, token);
if (trialParts == null || trialParts.Count < targetCount)
break;
// Same count in a tighter strip — keep going.
bestParts = trialParts;
var trialPlacedBox = trialParts.Cast<IBoundable>().GetBoundingBox();
bestDim = direction == StripDirection.Bottom
? trialPlacedBox.Top - workArea.Y
: trialPlacedBox.Right - workArea.X;
}
// Build remnant box with spacing gap.
var spacing = Plate.PartSpacing;
var remnantBox = direction == StripDirection.Bottom
? new Box(workArea.X, workArea.Y + bestDim + spacing,
workArea.Width, workArea.Length - bestDim - spacing)
: new Box(workArea.X + bestDim + spacing, workArea.Y,
workArea.Width - bestDim - spacing, workArea.Length);
// Collect all parts.
var allParts = new List<Part>(bestParts);
// If strip item was only partially placed, add leftovers to remainder.
var placed = bestParts.Count;
var leftover = stripItem.Quantity > 0 ? stripItem.Quantity - placed : 0;
var effectiveRemainder = new List<NestItem>(remainderItems);
if (leftover > 0)
{
effectiveRemainder.Add(new NestItem
{
Drawing = stripItem.Drawing,
Quantity = leftover
});
}
// Sort remainder by descending bounding box area x quantity.
effectiveRemainder = effectiveRemainder
.OrderByDescending(i =>
{
var bb = i.Drawing.Program.BoundingBox();
return bb.Area() * (i.Quantity > 0 ? i.Quantity : 1);
})
.ToList();
// Fill remnant with remainder items using free-rectangle tracking.
// After each fill, the consumed box is split into two non-overlapping
// sub-rectangles (guillotine cut) so no usable area is lost.
if (remnantBox.Width > 0 && remnantBox.Length > 0)
{
var freeBoxes = new List<Box> { remnantBox };
var remnantProgress = progress != null
? new AccumulatingProgress(progress, allParts)
: null;
foreach (var item in effectiveRemainder)
{
if (token.IsCancellationRequested || freeBoxes.Count == 0)
break;
var itemBbox = item.Drawing.Program.BoundingBox();
var minItemDim = System.Math.Min(itemBbox.Width, itemBbox.Length);
// Try free boxes from largest to smallest.
freeBoxes.Sort((a, b) => b.Area().CompareTo(a.Area()));
for (var i = 0; i < freeBoxes.Count; i++)
{
var box = freeBoxes[i];
if (System.Math.Min(box.Width, box.Length) < minItemDim)
continue;
var remnantInner = new DefaultNestEngine(Plate);
var remnantParts = remnantInner.Fill(
new NestItem { Drawing = item.Drawing, Quantity = item.Quantity },
box, remnantProgress, token);
if (remnantParts != null && remnantParts.Count > 0)
{
allParts.AddRange(remnantParts);
freeBoxes.RemoveAt(i);
var usedBox = remnantParts.Cast<IBoundable>().GetBoundingBox();
SplitFreeBox(box, usedBox, spacing, freeBoxes);
break;
}
}
}
}
result.Parts = allParts;
result.StripBox = direction == StripDirection.Bottom
? new Box(workArea.X, workArea.Y, workArea.Width, bestDim)
: new Box(workArea.X, workArea.Y, bestDim, workArea.Length);
result.RemnantBox = remnantBox;
result.Score = FillScore.Compute(allParts, workArea);
return result;
}
private static void SplitFreeBox(Box parent, Box used, double spacing, List<Box> freeBoxes)
{
var hWidth = parent.Right - used.Right - spacing;
var vHeight = parent.Top - used.Top - spacing;
if (hWidth > spacing && vHeight > spacing)
{
// Guillotine split: give the overlapping corner to the larger strip.
var hFullArea = hWidth * parent.Length;
var vFullArea = parent.Width * vHeight;
if (hFullArea >= vFullArea)
{
// hStrip gets full height; vStrip truncated to left of split line.
freeBoxes.Add(new Box(used.Right + spacing, parent.Y, hWidth, parent.Length));
var vWidth = used.Right + spacing - parent.X;
if (vWidth > spacing)
freeBoxes.Add(new Box(parent.X, used.Top + spacing, vWidth, vHeight));
}
else
{
// vStrip gets full width; hStrip truncated below split line.
freeBoxes.Add(new Box(parent.X, used.Top + spacing, parent.Width, vHeight));
var hHeight = used.Top + spacing - parent.Y;
if (hHeight > spacing)
freeBoxes.Add(new Box(used.Right + spacing, parent.Y, hWidth, hHeight));
}
}
else if (hWidth > spacing)
{
freeBoxes.Add(new Box(used.Right + spacing, parent.Y, hWidth, parent.Length));
}
else if (vHeight > spacing)
{
freeBoxes.Add(new Box(parent.X, used.Top + spacing, parent.Width, vHeight));
}
}
/// <summary>
/// Wraps an IProgress to prepend previously placed parts to each report,
/// so the UI shows the full picture (strip + remnant) during remnant fills.
/// </summary>
private class AccumulatingProgress : IProgress<NestProgress>
{
private readonly IProgress<NestProgress> inner;
private readonly List<Part> previousParts;
public AccumulatingProgress(IProgress<NestProgress> inner, List<Part> previousParts)
{
this.inner = inner;
this.previousParts = previousParts;
}
public void Report(NestProgress value)
{
if (value.BestParts != null && previousParts.Count > 0)
{
var combined = new List<Part>(previousParts.Count + value.BestParts.Count);
combined.AddRange(previousParts);
combined.AddRange(value.BestParts);
value.BestParts = combined;
value.BestPartCount = combined.Count;
}
inner.Report(value);
}
}
}
}

View File

@@ -0,0 +1,14 @@
using System.Collections.Generic;
using OpenNest.Geometry;
namespace OpenNest
{
internal class StripNestResult
{
public List<Part> Parts { get; set; } = new();
public Box StripBox { get; set; }
public Box RemnantBox { get; set; }
public FillScore Score { get; set; }
public StripDirection Direction { get; set; }
}
}

View File

@@ -11,6 +11,8 @@ namespace OpenNest.Gpu
private static bool _probed; private static bool _probed;
private static bool _gpuAvailable; private static bool _gpuAvailable;
private static string _deviceName; private static string _deviceName;
private static GpuSlideComputer _slideComputer;
private static readonly object _slideLock = new object();
public static bool GpuAvailable public static bool GpuAvailable
{ {
@@ -46,6 +48,29 @@ namespace OpenNest.Gpu
} }
} }
public static ISlideComputer CreateSlideComputer()
{
if (!GpuAvailable)
return null;
lock (_slideLock)
{
if (_slideComputer != null)
return _slideComputer;
try
{
_slideComputer = new GpuSlideComputer();
return _slideComputer;
}
catch (Exception ex)
{
Debug.WriteLine($"[GpuEvaluatorFactory] GPU slide computer failed: {ex.Message}");
return null;
}
}
}
private static void Probe() private static void Probe()
{ {
_probed = true; _probed = true;

View File

@@ -258,7 +258,7 @@ namespace OpenNest.Gpu
{ {
var entities = ConvertProgram.ToGeometry(part.Program) var entities = ConvertProgram.ToGeometry(part.Program)
.Where(e => e.Layer != SpecialLayers.Rapid); .Where(e => e.Layer != SpecialLayers.Rapid);
var shapes = Helper.GetShapes(entities); var shapes = ShapeBuilder.GetShapes(entities);
var points = new List<Vector>(); var points = new List<Vector>();
foreach (var shape in shapes) foreach (var shape in shapes)

View File

@@ -0,0 +1,460 @@
using System;
using ILGPU;
using ILGPU.Runtime;
using ILGPU.Algorithms;
using OpenNest.Engine.BestFit;
namespace OpenNest.Gpu
{
public class GpuSlideComputer : ISlideComputer
{
private readonly Context _context;
private readonly Accelerator _accelerator;
private readonly object _lock = new object();
// ── Kernels ──────────────────────────────────────────────────
private readonly Action<Index1D,
ArrayView1D<double, Stride1D.Dense>, // stationaryPrep
ArrayView1D<double, Stride1D.Dense>, // movingPrep
ArrayView1D<double, Stride1D.Dense>, // offsets
ArrayView1D<double, Stride1D.Dense>, // results
int, int, int> _kernel;
private readonly Action<Index1D,
ArrayView1D<double, Stride1D.Dense>, // stationaryPrep
ArrayView1D<double, Stride1D.Dense>, // movingPrep
ArrayView1D<double, Stride1D.Dense>, // offsets
ArrayView1D<double, Stride1D.Dense>, // results
ArrayView1D<int, Stride1D.Dense>, // directions
int, int> _kernelMultiDir;
private readonly Action<Index1D,
ArrayView1D<double, Stride1D.Dense>, // raw
ArrayView1D<double, Stride1D.Dense>, // prepared
int> _prepareKernel;
// ── Buffers ──────────────────────────────────────────────────
private MemoryBuffer1D<double, Stride1D.Dense>? _gpuStationaryRaw;
private MemoryBuffer1D<double, Stride1D.Dense>? _gpuStationaryPrep;
private double[]? _lastStationaryData; // Keep CPU copy/ref for content check
private MemoryBuffer1D<double, Stride1D.Dense>? _gpuMovingRaw;
private MemoryBuffer1D<double, Stride1D.Dense>? _gpuMovingPrep;
private double[]? _lastMovingData; // Keep CPU copy/ref for content check
private MemoryBuffer1D<double, Stride1D.Dense>? _gpuOffsets;
private MemoryBuffer1D<double, Stride1D.Dense>? _gpuResults;
private MemoryBuffer1D<int, Stride1D.Dense>? _gpuDirs;
private int _offsetCapacity;
public GpuSlideComputer()
{
_context = Context.CreateDefault();
_accelerator = _context.GetPreferredDevice(preferCPU: false)
.CreateAccelerator(_context);
_kernel = _accelerator.LoadAutoGroupedStreamKernel<
Index1D,
ArrayView1D<double, Stride1D.Dense>,
ArrayView1D<double, Stride1D.Dense>,
ArrayView1D<double, Stride1D.Dense>,
ArrayView1D<double, Stride1D.Dense>,
int, int, int>(SlideKernel);
_kernelMultiDir = _accelerator.LoadAutoGroupedStreamKernel<
Index1D,
ArrayView1D<double, Stride1D.Dense>,
ArrayView1D<double, Stride1D.Dense>,
ArrayView1D<double, Stride1D.Dense>,
ArrayView1D<double, Stride1D.Dense>,
ArrayView1D<int, Stride1D.Dense>,
int, int>(SlideKernelMultiDir);
_prepareKernel = _accelerator.LoadAutoGroupedStreamKernel<
Index1D,
ArrayView1D<double, Stride1D.Dense>,
ArrayView1D<double, Stride1D.Dense>,
int>(PrepareKernel);
}
public double[] ComputeBatch(
double[] stationarySegments, int stationaryCount,
double[] movingTemplateSegments, int movingCount,
double[] offsets, int offsetCount,
PushDirection direction)
{
var results = new double[offsetCount];
if (offsetCount == 0 || stationaryCount == 0 || movingCount == 0)
{
Array.Fill(results, double.MaxValue);
return results;
}
lock (_lock)
{
EnsureStationary(stationarySegments, stationaryCount);
EnsureMoving(movingTemplateSegments, movingCount);
EnsureOffsetBuffers(offsetCount);
_gpuOffsets!.View.SubView(0, offsetCount * 2).CopyFromCPU(offsets);
_kernel(offsetCount,
_gpuStationaryPrep!.View, _gpuMovingPrep!.View,
_gpuOffsets.View, _gpuResults!.View,
stationaryCount, movingCount, (int)direction);
_accelerator.Synchronize();
_gpuResults.View.SubView(0, offsetCount).CopyToCPU(results);
}
return results;
}
public double[] ComputeBatchMultiDir(
double[] stationarySegments, int stationaryCount,
double[] movingTemplateSegments, int movingCount,
double[] offsets, int offsetCount,
int[] directions)
{
var results = new double[offsetCount];
if (offsetCount == 0 || stationaryCount == 0 || movingCount == 0)
{
Array.Fill(results, double.MaxValue);
return results;
}
lock (_lock)
{
EnsureStationary(stationarySegments, stationaryCount);
EnsureMoving(movingTemplateSegments, movingCount);
EnsureOffsetBuffers(offsetCount);
_gpuOffsets!.View.SubView(0, offsetCount * 2).CopyFromCPU(offsets);
_gpuDirs!.View.SubView(0, offsetCount).CopyFromCPU(directions);
_kernelMultiDir(offsetCount,
_gpuStationaryPrep!.View, _gpuMovingPrep!.View,
_gpuOffsets.View, _gpuResults!.View, _gpuDirs.View,
stationaryCount, movingCount);
_accelerator.Synchronize();
_gpuResults.View.SubView(0, offsetCount).CopyToCPU(results);
}
return results;
}
public void InvalidateStationary() => _lastStationaryData = null;
public void InvalidateMoving() => _lastMovingData = null;
private void EnsureStationary(double[] data, int count)
{
// Fast check: if same object or content is identical, skip upload
if (_gpuStationaryPrep != null &&
_lastStationaryData != null &&
_lastStationaryData.Length == data.Length)
{
// Reference equality or content equality
if (_lastStationaryData == data ||
new ReadOnlySpan<double>(_lastStationaryData).SequenceEqual(new ReadOnlySpan<double>(data)))
{
return;
}
}
_gpuStationaryRaw?.Dispose();
_gpuStationaryPrep?.Dispose();
_gpuStationaryRaw = _accelerator.Allocate1D(data);
_gpuStationaryPrep = _accelerator.Allocate1D<double>(count * 10);
_prepareKernel(count, _gpuStationaryRaw.View, _gpuStationaryPrep.View, count);
_accelerator.Synchronize();
_lastStationaryData = data; // store reference for next comparison
}
private void EnsureMoving(double[] data, int count)
{
if (_gpuMovingPrep != null &&
_lastMovingData != null &&
_lastMovingData.Length == data.Length)
{
if (_lastMovingData == data ||
new ReadOnlySpan<double>(_lastMovingData).SequenceEqual(new ReadOnlySpan<double>(data)))
{
return;
}
}
_gpuMovingRaw?.Dispose();
_gpuMovingPrep?.Dispose();
_gpuMovingRaw = _accelerator.Allocate1D(data);
_gpuMovingPrep = _accelerator.Allocate1D<double>(count * 10);
_prepareKernel(count, _gpuMovingRaw.View, _gpuMovingPrep.View, count);
_accelerator.Synchronize();
_lastMovingData = data;
}
private void EnsureOffsetBuffers(int offsetCount)
{
if (_offsetCapacity >= offsetCount)
return;
var newCapacity = System.Math.Max(offsetCount, _offsetCapacity * 3 / 2);
_gpuOffsets?.Dispose();
_gpuResults?.Dispose();
_gpuDirs?.Dispose();
_gpuOffsets = _accelerator.Allocate1D<double>(newCapacity * 2);
_gpuResults = _accelerator.Allocate1D<double>(newCapacity);
_gpuDirs = _accelerator.Allocate1D<int>(newCapacity);
_offsetCapacity = newCapacity;
}
// ── Preparation Kernel ───────────────────────────────────────
private static void PrepareKernel(
Index1D index,
ArrayView1D<double, Stride1D.Dense> raw,
ArrayView1D<double, Stride1D.Dense> prepared,
int count)
{
if (index >= count) return;
var x1 = raw[index * 4 + 0];
var y1 = raw[index * 4 + 1];
var x2 = raw[index * 4 + 2];
var y2 = raw[index * 4 + 3];
prepared[index * 10 + 0] = x1;
prepared[index * 10 + 1] = y1;
prepared[index * 10 + 2] = x2;
prepared[index * 10 + 3] = y2;
var dx = x2 - x1;
var dy = y2 - y1;
// invD is used for parameter 't'. We use a small epsilon for stability.
prepared[index * 10 + 4] = (XMath.Abs(dx) < 1e-9) ? 0 : 1.0 / dx;
prepared[index * 10 + 5] = (XMath.Abs(dy) < 1e-9) ? 0 : 1.0 / dy;
prepared[index * 10 + 6] = XMath.Min(x1, x2);
prepared[index * 10 + 7] = XMath.Max(x1, x2);
prepared[index * 10 + 8] = XMath.Min(y1, y2);
prepared[index * 10 + 9] = XMath.Max(y1, y2);
}
// ── Main Slide Kernels ───────────────────────────────────────
private static void SlideKernel(
Index1D index,
ArrayView1D<double, Stride1D.Dense> stationaryPrep,
ArrayView1D<double, Stride1D.Dense> movingPrep,
ArrayView1D<double, Stride1D.Dense> offsets,
ArrayView1D<double, Stride1D.Dense> results,
int sCount, int mCount, int direction)
{
if (index >= results.Length) return;
var dx = offsets[index * 2];
var dy = offsets[index * 2 + 1];
results[index] = ComputeSlideLean(
stationaryPrep, movingPrep, dx, dy, sCount, mCount, direction);
}
private static void SlideKernelMultiDir(
Index1D index,
ArrayView1D<double, Stride1D.Dense> stationaryPrep,
ArrayView1D<double, Stride1D.Dense> movingPrep,
ArrayView1D<double, Stride1D.Dense> offsets,
ArrayView1D<double, Stride1D.Dense> results,
ArrayView1D<int, Stride1D.Dense> directions,
int sCount, int mCount)
{
if (index >= results.Length) return;
var dx = offsets[index * 2];
var dy = offsets[index * 2 + 1];
var dir = directions[index];
results[index] = ComputeSlideLean(
stationaryPrep, movingPrep, dx, dy, sCount, mCount, dir);
}
private static double ComputeSlideLean(
ArrayView1D<double, Stride1D.Dense> sPrep,
ArrayView1D<double, Stride1D.Dense> mPrep,
double dx, double dy, int sCount, int mCount, int direction)
{
const double eps = 0.00001;
var minDist = double.MaxValue;
var horizontal = direction >= 2;
var oppDir = direction ^ 1;
// ── Forward Pass: moving vertices vs stationary edges ─────
for (int i = 0; i < mCount; i++)
{
var m1x = mPrep[i * 10 + 0] + dx;
var m1y = mPrep[i * 10 + 1] + dy;
var m2x = mPrep[i * 10 + 2] + dx;
var m2y = mPrep[i * 10 + 3] + dy;
for (int j = 0; j < sCount; j++)
{
var sMin = horizontal ? sPrep[j * 10 + 8] : sPrep[j * 10 + 6];
var sMax = horizontal ? sPrep[j * 10 + 9] : sPrep[j * 10 + 7];
// Test moving vertex 1 against stationary edge j
var mv1 = horizontal ? m1y : m1x;
if (mv1 >= sMin - eps && mv1 <= sMax + eps)
{
var d = RayEdgeLean(m1x, m1y, sPrep, j, direction, eps);
if (d < minDist) minDist = d;
}
// Test moving vertex 2 against stationary edge j
var mv2 = horizontal ? m2y : m2x;
if (mv2 >= sMin - eps && mv2 <= sMax + eps)
{
var d = RayEdgeLean(m2x, m2y, sPrep, j, direction, eps);
if (d < minDist) minDist = d;
}
}
}
// ── Reverse Pass: stationary vertices vs moving edges ─────
for (int i = 0; i < sCount; i++)
{
var s1x = sPrep[i * 10 + 0];
var s1y = sPrep[i * 10 + 1];
var s2x = sPrep[i * 10 + 2];
var s2y = sPrep[i * 10 + 3];
for (int j = 0; j < mCount; j++)
{
var mMin = horizontal ? (mPrep[j * 10 + 8] + dy) : (mPrep[j * 10 + 6] + dx);
var mMax = horizontal ? (mPrep[j * 10 + 9] + dy) : (mPrep[j * 10 + 7] + dx);
// Test stationary vertex 1 against moving edge j
var sv1 = horizontal ? s1y : s1x;
if (sv1 >= mMin - eps && sv1 <= mMax + eps)
{
var d = RayEdgeLeanMoving(s1x, s1y, mPrep, j, dx, dy, oppDir, eps);
if (d < minDist) minDist = d;
}
// Test stationary vertex 2 against moving edge j
var sv2 = horizontal ? s2y : s2x;
if (sv2 >= mMin - eps && sv2 <= mMax + eps)
{
var d = RayEdgeLeanMoving(s2x, s2y, mPrep, j, dx, dy, oppDir, eps);
if (d < minDist) minDist = d;
}
}
}
return minDist;
}
private static double RayEdgeLean(
double vx, double vy,
ArrayView1D<double, Stride1D.Dense> sPrep, int j,
int direction, double eps)
{
var p1x = sPrep[j * 10 + 0];
var p1y = sPrep[j * 10 + 1];
var p2x = sPrep[j * 10 + 2];
var p2y = sPrep[j * 10 + 3];
if (direction >= 2) // Horizontal (Left=2, Right=3)
{
var invDy = sPrep[j * 10 + 5];
if (invDy == 0) return double.MaxValue;
var t = (vy - p1y) * invDy;
if (t < -eps || t > 1.0 + eps) return double.MaxValue;
var ix = p1x + t * (p2x - p1x);
var dist = (direction == 2) ? (vx - ix) : (ix - vx);
if (dist > eps) return dist;
return (dist >= -eps) ? 0.0 : double.MaxValue;
}
else // Vertical (Up=0, Down=1)
{
var invDx = sPrep[j * 10 + 4];
if (invDx == 0) return double.MaxValue;
var t = (vx - p1x) * invDx;
if (t < -eps || t > 1.0 + eps) return double.MaxValue;
var iy = p1y + t * (p2y - p1y);
var dist = (direction == 1) ? (vy - iy) : (iy - vy);
if (dist > eps) return dist;
return (dist >= -eps) ? 0.0 : double.MaxValue;
}
}
private static double RayEdgeLeanMoving(
double vx, double vy,
ArrayView1D<double, Stride1D.Dense> mPrep, int j,
double dx, double dy, int direction, double eps)
{
var p1x = mPrep[j * 10 + 0] + dx;
var p1y = mPrep[j * 10 + 1] + dy;
var p2x = mPrep[j * 10 + 2] + dx;
var p2y = mPrep[j * 10 + 3] + dy;
if (direction >= 2) // Horizontal
{
var invDy = mPrep[j * 10 + 5];
if (invDy == 0) return double.MaxValue;
var t = (vy - p1y) * invDy;
if (t < -eps || t > 1.0 + eps) return double.MaxValue;
var ix = p1x + t * (p2x - p1x);
var dist = (direction == 2) ? (vx - ix) : (ix - vx);
if (dist > eps) return dist;
return (dist >= -eps) ? 0.0 : double.MaxValue;
}
else // Vertical
{
var invDx = mPrep[j * 10 + 4];
if (invDx == 0) return double.MaxValue;
var t = (vx - p1x) * invDx;
if (t < -eps || t > 1.0 + eps) return double.MaxValue;
var iy = p1y + t * (p2y - p1y);
var dist = (direction == 1) ? (vy - iy) : (iy - vy);
if (dist > eps) return dist;
return (dist >= -eps) ? 0.0 : double.MaxValue;
}
}
public void Dispose()
{
_gpuStationaryRaw?.Dispose();
_gpuStationaryPrep?.Dispose();
_gpuMovingRaw?.Dispose();
_gpuMovingPrep?.Dispose();
_gpuOffsets?.Dispose();
_gpuResults?.Dispose();
_gpuDirs?.Dispose();
_accelerator?.Dispose();
_context?.Dispose();
}
}
}

View File

@@ -47,7 +47,7 @@ namespace OpenNest.Gpu
{ {
var entities = ConvertProgram.ToGeometry(part.Program) var entities = ConvertProgram.ToGeometry(part.Program)
.Where(e => e.Layer != SpecialLayers.Rapid); .Where(e => e.Layer != SpecialLayers.Rapid);
var shapes = Helper.GetShapes(entities); var shapes = ShapeBuilder.GetShapes(entities);
var polygons = new List<Polygon>(); var polygons = new List<Polygon>();
@@ -137,7 +137,7 @@ namespace OpenNest.Gpu
{ {
var entities = ConvertProgram.ToGeometry(drawing.Program) var entities = ConvertProgram.ToGeometry(drawing.Program)
.Where(e => e.Layer != SpecialLayers.Rapid); .Where(e => e.Layer != SpecialLayers.Rapid);
var shapes = Helper.GetShapes(entities); var shapes = ShapeBuilder.GetShapes(entities);
var polygons = new List<Polygon>(); var polygons = new List<Polygon>();

View File

@@ -56,8 +56,8 @@ namespace OpenNest.IO
} }
} }
Helper.Optimize(lines); GeometryOptimizer.Optimize(lines);
Helper.Optimize(arcs); GeometryOptimizer.Optimize(arcs);
entities.AddRange(lines); entities.AddRange(lines);
entities.AddRange(arcs); entities.AddRange(arcs);

View File

@@ -122,5 +122,32 @@ namespace OpenNest.IO
public double X { get; init; } public double X { get; init; }
public double Y { get; init; } public double Y { get; init; }
} }
public record BestFitSetDto
{
public double PlateWidth { get; init; }
public double PlateHeight { get; init; }
public double Spacing { get; init; }
public List<BestFitResultDto> Results { get; init; } = new();
}
public record BestFitResultDto
{
public double Part1Rotation { get; init; }
public double Part2Rotation { get; init; }
public double Part2OffsetX { get; init; }
public double Part2OffsetY { get; init; }
public int StrategyType { get; init; }
public int TestNumber { get; init; }
public double CandidateSpacing { get; init; }
public double RotatedArea { get; init; }
public double BoundingWidth { get; init; }
public double BoundingHeight { get; init; }
public double OptimalRotation { get; init; }
public bool Keep { get; init; }
public string Reason { get; init; } = "";
public double TrueArea { get; init; }
public List<double> HullAngles { get; init; } = new();
}
} }
} }

View File

@@ -6,6 +6,7 @@ using System.IO.Compression;
using System.Linq; using System.Linq;
using System.Text.Json; using System.Text.Json;
using OpenNest.CNC; using OpenNest.CNC;
using OpenNest.Engine.BestFit;
using OpenNest.Geometry; using OpenNest.Geometry;
using static OpenNest.IO.NestFormat; using static OpenNest.IO.NestFormat;
@@ -35,6 +36,7 @@ namespace OpenNest.IO
var programs = ReadPrograms(dto.Drawings.Count); var programs = ReadPrograms(dto.Drawings.Count);
var drawingMap = BuildDrawings(dto, programs); var drawingMap = BuildDrawings(dto, programs);
ReadBestFits(drawingMap);
var nest = BuildNest(dto, drawingMap); var nest = BuildNest(dto, drawingMap);
zipArchive.Dispose(); zipArchive.Dispose();
@@ -97,6 +99,54 @@ namespace OpenNest.IO
return map; return map;
} }
private void ReadBestFits(Dictionary<int, Drawing> drawingMap)
{
foreach (var kvp in drawingMap)
{
var entry = zipArchive.GetEntry($"bestfits/bestfit-{kvp.Key}");
if (entry == null) continue;
using var entryStream = entry.Open();
using var reader = new StreamReader(entryStream);
var json = reader.ReadToEnd();
var sets = JsonSerializer.Deserialize<List<BestFitSetDto>>(json, JsonOptions);
if (sets == null) continue;
PopulateBestFitSets(kvp.Value, sets);
}
}
private void PopulateBestFitSets(Drawing drawing, List<BestFitSetDto> sets)
{
foreach (var set in sets)
{
var results = set.Results.Select(r => new BestFitResult
{
Candidate = new PairCandidate
{
Drawing = drawing,
Part1Rotation = r.Part1Rotation,
Part2Rotation = r.Part2Rotation,
Part2Offset = new Vector(r.Part2OffsetX, r.Part2OffsetY),
StrategyType = r.StrategyType,
TestNumber = r.TestNumber,
Spacing = r.CandidateSpacing
},
RotatedArea = r.RotatedArea,
BoundingWidth = r.BoundingWidth,
BoundingHeight = r.BoundingHeight,
OptimalRotation = r.OptimalRotation,
Keep = r.Keep,
Reason = r.Reason,
TrueArea = r.TrueArea,
HullAngles = r.HullAngles
}).ToList();
BestFitCache.Populate(drawing, set.PlateWidth, set.PlateHeight, set.Spacing, results);
}
}
private Nest BuildNest(NestDto dto, Dictionary<int, Drawing> drawingMap) private Nest BuildNest(NestDto dto, Dictionary<int, Drawing> drawingMap)
{ {
var nest = new Nest(); var nest = new Nest();

View File

@@ -6,6 +6,8 @@ using System.Linq;
using System.Text; using System.Text;
using System.Text.Json; using System.Text.Json;
using OpenNest.CNC; using OpenNest.CNC;
using OpenNest.Engine.BestFit;
using OpenNest.Geometry;
using OpenNest.Math; using OpenNest.Math;
using static OpenNest.IO.NestFormat; using static OpenNest.IO.NestFormat;
@@ -35,6 +37,7 @@ namespace OpenNest.IO
WriteNestJson(zipArchive); WriteNestJson(zipArchive);
WritePrograms(zipArchive); WritePrograms(zipArchive);
WriteBestFits(zipArchive);
return true; return true;
} }
@@ -185,6 +188,70 @@ namespace OpenNest.IO
return list; return list;
} }
private List<BestFitSetDto> BuildBestFitDtos(Drawing drawing)
{
var allBestFits = BestFitCache.GetAllForDrawing(drawing);
var sets = new List<BestFitSetDto>();
// Only save best-fit sets for plate sizes actually used in this nest.
var plateSizes = new HashSet<(double, double, double)>();
foreach (var plate in nest.Plates)
plateSizes.Add((plate.Size.Width, plate.Size.Length, plate.PartSpacing));
foreach (var kvp in allBestFits)
{
if (!plateSizes.Contains((kvp.Key.PlateWidth, kvp.Key.PlateHeight, kvp.Key.Spacing)))
continue;
var results = kvp.Value
.Where(r => r.Keep)
.Select(r => new BestFitResultDto
{
Part1Rotation = r.Candidate.Part1Rotation,
Part2Rotation = r.Candidate.Part2Rotation,
Part2OffsetX = r.Candidate.Part2Offset.X,
Part2OffsetY = r.Candidate.Part2Offset.Y,
StrategyType = r.Candidate.StrategyType,
TestNumber = r.Candidate.TestNumber,
CandidateSpacing = r.Candidate.Spacing,
RotatedArea = r.RotatedArea,
BoundingWidth = r.BoundingWidth,
BoundingHeight = r.BoundingHeight,
OptimalRotation = r.OptimalRotation,
Keep = r.Keep,
Reason = r.Reason ?? "",
TrueArea = r.TrueArea,
HullAngles = r.HullAngles ?? new List<double>()
}).ToList();
sets.Add(new BestFitSetDto
{
PlateWidth = kvp.Key.PlateWidth,
PlateHeight = kvp.Key.PlateHeight,
Spacing = kvp.Key.Spacing,
Results = results
});
}
return sets;
}
private void WriteBestFits(ZipArchive zipArchive)
{
foreach (var kvp in drawingDict.OrderBy(k => k.Key))
{
var sets = BuildBestFitDtos(kvp.Value);
if (sets.Count == 0)
continue;
var json = JsonSerializer.Serialize(sets, JsonOptions);
var entry = zipArchive.CreateEntry($"bestfits/bestfit-{kvp.Key}");
using var stream = entry.Open();
using var writer = new StreamWriter(stream, Encoding.UTF8);
writer.Write(json);
}
}
private void WritePrograms(ZipArchive zipArchive) private void WritePrograms(ZipArchive zipArchive)
{ {
foreach (var kvp in drawingDict.OrderBy(k => k.Key)) foreach (var kvp in drawingDict.OrderBy(k => k.Key))

View File

@@ -6,6 +6,7 @@
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\OpenNest.Core\OpenNest.Core.csproj" /> <ProjectReference Include="..\OpenNest.Core\OpenNest.Core.csproj" />
<ProjectReference Include="..\OpenNest.Engine\OpenNest.Engine.csproj" />
<PackageReference Include="ACadSharp" Version="3.1.32" /> <PackageReference Include="ACadSharp" Version="3.1.32" />
</ItemGroup> </ItemGroup>
</Project> </Project>

View File

@@ -34,7 +34,7 @@ namespace OpenNest.Mcp.Tools
return $"Error: drawing '{drawingName}' not found"; return $"Error: drawing '{drawingName}' not found";
var countBefore = plate.Parts.Count; var countBefore = plate.Parts.Count;
var engine = new NestEngine(plate); var engine = NestEngineRegistry.Create(plate);
var item = new NestItem { Drawing = drawing, Quantity = quantity }; var item = new NestItem { Drawing = drawing, Quantity = quantity };
var success = engine.Fill(item); var success = engine.Fill(item);
@@ -70,7 +70,7 @@ namespace OpenNest.Mcp.Tools
return $"Error: drawing '{drawingName}' not found"; return $"Error: drawing '{drawingName}' not found";
var countBefore = plate.Parts.Count; var countBefore = plate.Parts.Count;
var engine = new NestEngine(plate); var engine = NestEngineRegistry.Create(plate);
var item = new NestItem { Drawing = drawing, Quantity = quantity }; var item = new NestItem { Drawing = drawing, Quantity = quantity };
var area = new Box(x, y, width, height); var area = new Box(x, y, width, height);
var success = engine.Fill(item, area); var success = engine.Fill(item, area);
@@ -111,7 +111,7 @@ namespace OpenNest.Mcp.Tools
sb.AppendLine($"Found {remnants.Count} remnant area(s) on plate {plateIndex}"); sb.AppendLine($"Found {remnants.Count} remnant area(s) on plate {plateIndex}");
var totalAdded = 0; var totalAdded = 0;
var engine = new NestEngine(plate); var engine = NestEngineRegistry.Create(plate);
for (var i = 0; i < remnants.Count; i++) for (var i = 0; i < remnants.Count; i++)
{ {
@@ -173,7 +173,7 @@ namespace OpenNest.Mcp.Tools
} }
var countBefore = plate.Parts.Count; var countBefore = plate.Parts.Count;
var engine = new NestEngine(plate); var engine = NestEngineRegistry.Create(plate);
var success = engine.Pack(items); var success = engine.Pack(items);
var countAfter = plate.Parts.Count; var countAfter = plate.Parts.Count;
var added = countAfter - countBefore; var added = countAfter - countBefore;
@@ -193,7 +193,7 @@ namespace OpenNest.Mcp.Tools
} }
[McpServerTool(Name = "autonest_plate")] [McpServerTool(Name = "autonest_plate")]
[Description("NFP-based mixed-part autonesting. Places multiple different drawings on a plate with geometry-aware collision avoidance and simulated annealing optimization. Produces tighter layouts than pack_plate by allowing parts to interlock.")] [Description("Mixed-part autonesting. Fills the plate with multiple different drawings using iterative per-drawing fills with remainder-strip packing.")]
public string AutoNestPlate( public string AutoNestPlate(
[Description("Index of the plate")] int plateIndex, [Description("Index of the plate")] int plateIndex,
[Description("Comma-separated drawing names")] string drawingNames, [Description("Comma-separated drawing names")] string drawingNames,
@@ -233,16 +233,18 @@ namespace OpenNest.Mcp.Tools
items.Add(new NestItem { Drawing = drawing, Quantity = qtys[i] }); items.Add(new NestItem { Drawing = drawing, Quantity = qtys[i] });
} }
var parts = NestEngine.AutoNest(items, plate); var engine = NestEngineRegistry.Create(plate);
plate.Parts.AddRange(parts); var nestParts = engine.Nest(items, null, CancellationToken.None);
plate.Parts.AddRange(nestParts);
var totalPlaced = nestParts.Count;
var sb = new StringBuilder(); var sb = new StringBuilder();
sb.AppendLine($"AutoNest plate {plateIndex}: {(parts.Count > 0 ? "success" : "no parts placed")}"); sb.AppendLine($"AutoNest plate {plateIndex} ({engine.Name} engine): {(totalPlaced > 0 ? "success" : "no parts placed")}");
sb.AppendLine($" Parts placed: {parts.Count}"); sb.AppendLine($" Parts placed: {totalPlaced}");
sb.AppendLine($" Total parts: {plate.Parts.Count}"); sb.AppendLine($" Total parts: {plate.Parts.Count}");
sb.AppendLine($" Utilization: {plate.Utilization():P1}"); sb.AppendLine($" Utilization: {plate.Utilization():P1}");
var groups = parts.GroupBy(p => p.BaseDrawing.Name); var groups = plate.Parts.GroupBy(p => p.BaseDrawing.Name);
foreach (var group in groups) foreach (var group in groups)
sb.AppendLine($" {group.Key}: {group.Count()}"); sb.AppendLine($" {group.Key}: {group.Count()}");

View File

@@ -0,0 +1,23 @@
using OpenNest.CNC;
using OpenNest.CNC.CuttingStrategy;
using OpenNest.Geometry;
using Xunit;
namespace OpenNest.Tests;
public class CuttingResultTests
{
[Fact]
public void CuttingResult_StoresValues()
{
var pgm = new Program();
pgm.Codes.Add(new RapidMove(new Vector(1, 2)));
var point = new Vector(3, 4);
var result = new CuttingResult { Program = pgm, LastCutPoint = point };
Assert.Same(pgm, result.Program);
Assert.Equal(3, result.LastCutPoint.X);
Assert.Equal(4, result.LastCutPoint.Y);
}
}

View File

@@ -0,0 +1,29 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0-windows</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.8.0" />
<PackageReference Include="xunit" Version="2.5.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.5.3" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\OpenNest.Core\OpenNest.Core.csproj" />
<ProjectReference Include="..\OpenNest.Engine\OpenNest.Engine.csproj" />
<ProjectReference Include="..\OpenNest.IO\OpenNest.IO.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,32 @@
using OpenNest.CNC;
using OpenNest.Geometry;
using Xunit;
namespace OpenNest.Tests;
public class PartFlagTests
{
[Fact]
public void HasManualLeadIns_DefaultsFalse()
{
var pgm = new Program();
pgm.Codes.Add(new RapidMove(new Vector(0, 0)));
var drawing = new Drawing("test", pgm);
var part = new Part(drawing);
Assert.False(part.HasManualLeadIns);
}
[Fact]
public void HasManualLeadIns_CanBeSet()
{
var pgm = new Program();
pgm.Codes.Add(new RapidMove(new Vector(0, 0)));
var drawing = new Drawing("test", pgm);
var part = new Part(drawing);
part.HasManualLeadIns = true;
Assert.True(part.HasManualLeadIns);
}
}

View File

@@ -0,0 +1,132 @@
using System.Collections.Generic;
using System.Linq;
using OpenNest.CNC;
using OpenNest.CNC.CuttingStrategy;
using OpenNest.Engine;
using OpenNest.Engine.RapidPlanning;
using OpenNest.Engine.Sequencing;
using OpenNest.Geometry;
using Xunit;
namespace OpenNest.Tests;
public class PlateProcessorTests
{
private static Part MakePartAt(double x, double y) => TestHelpers.MakePartAt(x, y, size: 2);
[Fact]
public void Process_ReturnsAllParts()
{
var plate = new Plate(60, 120);
plate.Parts.Add(MakePartAt(10, 10));
plate.Parts.Add(MakePartAt(30, 30));
plate.Parts.Add(MakePartAt(50, 50));
var processor = new PlateProcessor
{
Sequencer = new RightSideSequencer(),
RapidPlanner = new SafeHeightRapidPlanner()
};
var result = processor.Process(plate);
Assert.Equal(3, result.Parts.Count);
}
[Fact]
public void Process_PreservesSequenceOrder()
{
var plate = new Plate(60, 120);
var left = MakePartAt(5, 10);
var right = MakePartAt(50, 10);
plate.Parts.Add(left);
plate.Parts.Add(right);
var processor = new PlateProcessor
{
Sequencer = new RightSideSequencer(),
RapidPlanner = new SafeHeightRapidPlanner()
};
var result = processor.Process(plate);
Assert.Same(right, result.Parts[0].Part);
Assert.Same(left, result.Parts[1].Part);
}
[Fact]
public void Process_SkipsCuttingStrategy_WhenManualLeadIns()
{
var plate = new Plate(60, 120);
var part = MakePartAt(10, 10);
part.HasManualLeadIns = true;
plate.Parts.Add(part);
var processor = new PlateProcessor
{
Sequencer = new LeftSideSequencer(),
CuttingStrategy = new ContourCuttingStrategy
{
Parameters = new CuttingParameters()
},
RapidPlanner = new SafeHeightRapidPlanner()
};
var result = processor.Process(plate);
Assert.Same(part.Program, result.Parts[0].ProcessedProgram);
}
[Fact]
public void Process_DoesNotMutatePart()
{
var plate = new Plate(60, 120);
var part = MakePartAt(10, 10);
var originalProgram = part.Program;
plate.Parts.Add(part);
var processor = new PlateProcessor
{
Sequencer = new LeftSideSequencer(),
RapidPlanner = new SafeHeightRapidPlanner()
};
var result = processor.Process(plate);
Assert.Same(originalProgram, part.Program);
}
[Fact]
public void Process_NoCuttingStrategy_PassesProgramThrough()
{
var plate = new Plate(60, 120);
var part = MakePartAt(10, 10);
plate.Parts.Add(part);
var processor = new PlateProcessor
{
Sequencer = new LeftSideSequencer(),
RapidPlanner = new SafeHeightRapidPlanner()
};
var result = processor.Process(plate);
Assert.Same(part.Program, result.Parts[0].ProcessedProgram);
}
[Fact]
public void Process_EmptyPlate_ReturnsEmptyResult()
{
var plate = new Plate(60, 120);
var processor = new PlateProcessor
{
Sequencer = new LeftSideSequencer(),
RapidPlanner = new SafeHeightRapidPlanner()
};
var result = processor.Process(plate);
Assert.Empty(result.Parts);
}
}

View File

@@ -0,0 +1,56 @@
using System.Collections.Generic;
using OpenNest.Engine.RapidPlanning;
using OpenNest.Geometry;
using Xunit;
namespace OpenNest.Tests.RapidPlanning;
public class DirectRapidPlannerTests
{
[Fact]
public void NoCutAreas_ReturnsHeadDown()
{
var planner = new DirectRapidPlanner();
var result = planner.Plan(new Vector(0, 0), new Vector(10, 10), new List<Shape>());
Assert.False(result.HeadUp);
Assert.Empty(result.Waypoints);
}
[Fact]
public void ClearPath_ReturnsHeadDown()
{
var planner = new DirectRapidPlanner();
var cutArea = new Shape();
cutArea.Entities.Add(new Line(new Vector(50, 0), new Vector(50, 10)));
cutArea.Entities.Add(new Line(new Vector(50, 10), new Vector(60, 10)));
cutArea.Entities.Add(new Line(new Vector(60, 10), new Vector(60, 0)));
cutArea.Entities.Add(new Line(new Vector(60, 0), new Vector(50, 0)));
var result = planner.Plan(
new Vector(0, 0), new Vector(10, 10),
new List<Shape> { cutArea });
Assert.False(result.HeadUp);
}
[Fact]
public void BlockedPath_ReturnsHeadUp()
{
var planner = new DirectRapidPlanner();
var cutArea = new Shape();
cutArea.Entities.Add(new Line(new Vector(5, 0), new Vector(5, 20)));
cutArea.Entities.Add(new Line(new Vector(5, 20), new Vector(6, 20)));
cutArea.Entities.Add(new Line(new Vector(6, 20), new Vector(6, 0)));
cutArea.Entities.Add(new Line(new Vector(6, 0), new Vector(5, 0)));
var result = planner.Plan(
new Vector(0, 10), new Vector(10, 10),
new List<Shape> { cutArea });
Assert.True(result.HeadUp);
Assert.Empty(result.Waypoints);
}
}

View File

@@ -0,0 +1,39 @@
using System.Collections.Generic;
using OpenNest.Engine.RapidPlanning;
using OpenNest.Geometry;
using Xunit;
namespace OpenNest.Tests.RapidPlanning;
public class SafeHeightRapidPlannerTests
{
[Fact]
public void AlwaysReturnsHeadUp()
{
var planner = new SafeHeightRapidPlanner();
var from = new Vector(10, 10);
var to = new Vector(50, 50);
var cutAreas = new List<Shape>();
var result = planner.Plan(from, to, cutAreas);
Assert.True(result.HeadUp);
Assert.Empty(result.Waypoints);
}
[Fact]
public void ReturnsHeadUp_EvenWithCutAreas()
{
var planner = new SafeHeightRapidPlanner();
var from = new Vector(0, 0);
var to = new Vector(10, 10);
var shape = new Shape();
shape.Entities.Add(new Line(new Vector(5, 0), new Vector(5, 20)));
var cutAreas = new List<Shape> { shape };
var result = planner.Plan(from, to, cutAreas);
Assert.True(result.HeadUp);
}
}

View File

@@ -0,0 +1,69 @@
using System.Collections.Generic;
using OpenNest.CNC;
using OpenNest.CNC.CuttingStrategy;
using OpenNest.Engine.Sequencing;
using OpenNest.Geometry;
using Xunit;
namespace OpenNest.Tests.Sequencing;
public class AdvancedSequencerTests
{
private static Part MakePartAt(double x, double y) => TestHelpers.MakePartAt(x, y);
[Fact]
public void GroupsIntoRows_NoAlternate()
{
var plate = new Plate(100, 100);
var row1a = MakePartAt(10, 10);
var row1b = MakePartAt(30, 10);
var row2a = MakePartAt(10, 50);
var row2b = MakePartAt(30, 50);
plate.Parts.Add(row1a);
plate.Parts.Add(row1b);
plate.Parts.Add(row2a);
plate.Parts.Add(row2b);
var parameters = new SequenceParameters
{
Method = SequenceMethod.Advanced,
MinDistanceBetweenRowsColumns = 5.0,
AlternateRowsColumns = false
};
var sequencer = new AdvancedSequencer(parameters);
var result = sequencer.Sequence(plate.Parts.ToList(), plate);
Assert.Same(row1a, result[0].Part);
Assert.Same(row1b, result[1].Part);
Assert.Same(row2a, result[2].Part);
Assert.Same(row2b, result[3].Part);
}
[Fact]
public void SerpentineAlternatesDirection()
{
var plate = new Plate(100, 100);
var r1Left = MakePartAt(10, 10);
var r1Right = MakePartAt(30, 10);
var r2Left = MakePartAt(10, 50);
var r2Right = MakePartAt(30, 50);
plate.Parts.Add(r1Left);
plate.Parts.Add(r1Right);
plate.Parts.Add(r2Left);
plate.Parts.Add(r2Right);
var parameters = new SequenceParameters
{
Method = SequenceMethod.Advanced,
MinDistanceBetweenRowsColumns = 5.0,
AlternateRowsColumns = true
};
var sequencer = new AdvancedSequencer(parameters);
var result = sequencer.Sequence(plate.Parts.ToList(), plate);
Assert.Same(r1Left, result[0].Part);
Assert.Same(r1Right, result[1].Part);
Assert.Same(r2Right, result[2].Part);
Assert.Same(r2Left, result[3].Part);
}
}

View File

@@ -0,0 +1,75 @@
using System.Collections.Generic;
using OpenNest.CNC;
using OpenNest.Engine.Sequencing;
using OpenNest.Geometry;
using Xunit;
namespace OpenNest.Tests.Sequencing;
public class DirectionalSequencerTests
{
private static Part MakePartAt(double x, double y) => TestHelpers.MakePartAt(x, y);
private static Plate MakePlate(params Part[] parts) => TestHelpers.MakePlate(60, 120, parts);
[Fact]
public void RightSide_SortsXDescending()
{
var a = MakePartAt(10, 5);
var b = MakePartAt(30, 5);
var c = MakePartAt(20, 5);
var plate = MakePlate(a, b, c);
var sequencer = new RightSideSequencer();
var result = sequencer.Sequence(plate.Parts.ToList(), plate);
Assert.Same(b, result[0].Part);
Assert.Same(c, result[1].Part);
Assert.Same(a, result[2].Part);
}
[Fact]
public void LeftSide_SortsXAscending()
{
var a = MakePartAt(10, 5);
var b = MakePartAt(30, 5);
var c = MakePartAt(20, 5);
var plate = MakePlate(a, b, c);
var sequencer = new LeftSideSequencer();
var result = sequencer.Sequence(plate.Parts.ToList(), plate);
Assert.Same(a, result[0].Part);
Assert.Same(c, result[1].Part);
Assert.Same(b, result[2].Part);
}
[Fact]
public void BottomSide_SortsYAscending()
{
var a = MakePartAt(5, 20);
var b = MakePartAt(5, 5);
var c = MakePartAt(5, 10);
var plate = MakePlate(a, b, c);
var sequencer = new BottomSideSequencer();
var result = sequencer.Sequence(plate.Parts.ToList(), plate);
Assert.Same(b, result[0].Part);
Assert.Same(c, result[1].Part);
Assert.Same(a, result[2].Part);
}
[Fact]
public void RightSide_TiesBrokenByPerpendicularAxis()
{
var a = MakePartAt(10, 20);
var b = MakePartAt(10, 5);
var plate = MakePlate(a, b);
var sequencer = new RightSideSequencer();
var result = sequencer.Sequence(plate.Parts.ToList(), plate);
Assert.Same(b, result[0].Part);
Assert.Same(a, result[1].Part);
}
}

View File

@@ -0,0 +1,31 @@
using System.Collections.Generic;
using OpenNest.CNC;
using OpenNest.Engine.Sequencing;
using OpenNest.Geometry;
using Xunit;
namespace OpenNest.Tests.Sequencing;
public class EdgeStartSequencerTests
{
private static Part MakePartAt(double x, double y) => TestHelpers.MakePartAt(x, y);
[Fact]
public void SortsByDistanceFromNearestEdge()
{
var plate = new Plate(60, 120);
var edgePart = MakePartAt(1, 1);
var centerPart = MakePartAt(25, 55);
var midPart = MakePartAt(10, 10);
plate.Parts.Add(edgePart);
plate.Parts.Add(centerPart);
plate.Parts.Add(midPart);
var sequencer = new EdgeStartSequencer();
var result = sequencer.Sequence(plate.Parts.ToList(), plate);
Assert.Same(edgePart, result[0].Part);
Assert.Same(midPart, result[1].Part);
Assert.Same(centerPart, result[2].Part);
}
}

View File

@@ -0,0 +1,61 @@
using System.Collections.Generic;
using OpenNest.CNC;
using OpenNest.Engine.Sequencing;
using OpenNest.Geometry;
using Xunit;
namespace OpenNest.Tests.Sequencing;
public class LeastCodeSequencerTests
{
private static Part MakePartAt(double x, double y) => TestHelpers.MakePartAt(x, y);
[Fact]
public void NearestNeighbor_FromExitPoint()
{
var plate = new Plate(60, 120);
var farPart = MakePartAt(5, 5);
var nearPart = MakePartAt(55, 115);
plate.Parts.Add(farPart);
plate.Parts.Add(nearPart);
var sequencer = new LeastCodeSequencer();
var result = sequencer.Sequence(plate.Parts.ToList(), plate);
// nearPart is closer to exit point, should come first
Assert.Same(nearPart, result[0].Part);
Assert.Same(farPart, result[1].Part);
}
[Fact]
public void PreservesAllParts()
{
var plate = new Plate(60, 120);
for (var i = 0; i < 10; i++)
plate.Parts.Add(MakePartAt(i * 5, i * 10));
var sequencer = new LeastCodeSequencer();
var result = sequencer.Sequence(plate.Parts.ToList(), plate);
Assert.Equal(10, result.Count);
}
[Fact]
public void TwoOpt_ImprovesSolution()
{
var plate = new Plate(100, 100);
var a = MakePartAt(90, 90);
var b = MakePartAt(10, 80);
var c = MakePartAt(80, 10);
var d = MakePartAt(5, 5);
plate.Parts.Add(a);
plate.Parts.Add(b);
plate.Parts.Add(c);
plate.Parts.Add(d);
var sequencer = new LeastCodeSequencer();
var result = sequencer.Sequence(plate.Parts.ToList(), plate);
Assert.Equal(4, result.Count);
}
}

View File

@@ -0,0 +1,30 @@
using System;
using OpenNest.CNC.CuttingStrategy;
using OpenNest.Engine.Sequencing;
using Xunit;
namespace OpenNest.Tests.Sequencing;
public class PartSequencerFactoryTests
{
[Theory]
[InlineData(SequenceMethod.RightSide, typeof(RightSideSequencer))]
[InlineData(SequenceMethod.LeftSide, typeof(LeftSideSequencer))]
[InlineData(SequenceMethod.BottomSide, typeof(BottomSideSequencer))]
[InlineData(SequenceMethod.EdgeStart, typeof(EdgeStartSequencer))]
[InlineData(SequenceMethod.LeastCode, typeof(LeastCodeSequencer))]
[InlineData(SequenceMethod.Advanced, typeof(AdvancedSequencer))]
public void Create_ReturnsCorrectType(SequenceMethod method, Type expectedType)
{
var parameters = new SequenceParameters { Method = method };
var sequencer = PartSequencerFactory.Create(parameters);
Assert.IsType(expectedType, sequencer);
}
[Fact]
public void Create_RightSideAlt_Throws()
{
var parameters = new SequenceParameters { Method = SequenceMethod.RightSideAlt };
Assert.Throws<NotSupportedException>(() => PartSequencerFactory.Create(parameters));
}
}

View File

@@ -0,0 +1,27 @@
using OpenNest.CNC;
using OpenNest.Geometry;
namespace OpenNest.Tests;
internal static class TestHelpers
{
public static Part MakePartAt(double x, double y, double size = 1)
{
var pgm = new Program();
pgm.Codes.Add(new RapidMove(new Vector(0, 0)));
pgm.Codes.Add(new LinearMove(new Vector(size, 0)));
pgm.Codes.Add(new LinearMove(new Vector(size, size)));
pgm.Codes.Add(new LinearMove(new Vector(0, size)));
pgm.Codes.Add(new LinearMove(new Vector(0, 0)));
var drawing = new Drawing("test", pgm);
return new Part(drawing, new Vector(x, y));
}
public static Plate MakePlate(double width = 60, double length = 120, params Part[] parts)
{
var plate = new Plate(width, length);
foreach (var p in parts)
plate.Parts.Add(p);
return plate;
}
}

View File

@@ -0,0 +1,20 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace OpenNest.Training.Data
{
[Table("AngleResults")]
public class TrainingAngleResult
{
[Key]
public long Id { get; set; }
public long RunId { get; set; }
public double AngleDeg { get; set; }
public string Direction { get; set; }
public int PartCount { get; set; }
[ForeignKey(nameof(RunId))]
public TrainingRun Run { get; set; }
}
}

View File

@@ -0,0 +1,47 @@
using Microsoft.EntityFrameworkCore;
namespace OpenNest.Training.Data
{
public class TrainingDbContext : DbContext
{
public DbSet<TrainingPart> Parts { get; set; }
public DbSet<TrainingRun> Runs { get; set; }
public DbSet<TrainingAngleResult> AngleResults { get; set; }
private readonly string _dbPath;
public TrainingDbContext(string dbPath)
{
_dbPath = dbPath;
}
protected override void OnConfiguring(DbContextOptionsBuilder options)
{
options.UseSqlite($"Data Source={_dbPath}");
}
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<TrainingPart>(e =>
{
e.HasIndex(p => p.FileName).HasDatabaseName("idx_parts_filename");
});
modelBuilder.Entity<TrainingRun>(e =>
{
e.HasIndex(r => r.PartId).HasDatabaseName("idx_runs_partid");
e.HasOne(r => r.Part)
.WithMany(p => p.Runs)
.HasForeignKey(r => r.PartId);
});
modelBuilder.Entity<TrainingAngleResult>(e =>
{
e.HasIndex(a => a.RunId).HasDatabaseName("idx_angleresults_runid");
e.HasOne(a => a.Run)
.WithMany(r => r.AngleResults)
.HasForeignKey(a => a.RunId);
});
}
}
}

View File

@@ -0,0 +1,28 @@
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace OpenNest.Training.Data
{
[Table("Parts")]
public class TrainingPart
{
[Key]
public long Id { get; set; }
[MaxLength(260)]
public string FileName { get; set; }
public double Area { get; set; }
public double Convexity { get; set; }
public double AspectRatio { get; set; }
public double BBFill { get; set; }
public double Circularity { get; set; }
public double PerimeterToAreaRatio { get; set; }
public int VertexCount { get; set; }
public byte[] Bitmask { get; set; }
public string GeometryData { get; set; }
public List<TrainingRun> Runs { get; set; } = new();
}
}

View File

@@ -0,0 +1,36 @@
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace OpenNest.Training.Data
{
[Table("Runs")]
public class TrainingRun
{
[Key]
public long Id { get; set; }
public long PartId { get; set; }
public double SheetWidth { get; set; }
public double SheetHeight { get; set; }
public double Spacing { get; set; }
public int PartCount { get; set; }
public double Utilization { get; set; }
public long TimeMs { get; set; }
public string LayoutData { get; set; }
public string FilePath { get; set; }
public string WinnerEngine { get; set; } = "";
public long WinnerTimeMs { get; set; }
public string RunnerUpEngine { get; set; } = "";
public int RunnerUpPartCount { get; set; }
public long RunnerUpTimeMs { get; set; }
public string ThirdPlaceEngine { get; set; } = "";
public int ThirdPlacePartCount { get; set; }
public long ThirdPlaceTimeMs { get; set; }
[ForeignKey(nameof(PartId))]
public TrainingPart Part { get; set; }
public List<TrainingAngleResult> AngleResults { get; set; } = new();
}
}

View File

@@ -0,0 +1,19 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0-windows</TargetFramework>
<RootNamespace>OpenNest.Training</RootNamespace>
<AssemblyName>OpenNest.Training</AssemblyName>
<DefineConstants>$(DefineConstants);DEBUG;TRACE</DefineConstants>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\OpenNest.Core\OpenNest.Core.csproj" />
<ProjectReference Include="..\OpenNest.Engine\OpenNest.Engine.csproj" />
<ProjectReference Include="..\OpenNest.IO\OpenNest.IO.csproj" />
<ProjectReference Include="..\OpenNest.Gpu\OpenNest.Gpu.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="8.0.10" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.10" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,305 @@
using System;
using System.Diagnostics;
using System.IO;
using System.Linq;
using OpenNest;
using OpenNest.Geometry;
using OpenNest.IO;
using Color = System.Drawing.Color;
using OpenNest.Engine.BestFit;
using OpenNest.Engine.ML;
using OpenNest.Gpu;
using OpenNest.Training;
// Parse arguments.
var dbPath = "OpenNestTraining";
var saveNestsDir = (string)null;
var templateFile = (string)null;
var spacing = 0.5;
var collectDir = (string)null;
for (var i = 0; i < args.Length; i++)
{
switch (args[i])
{
case "--db" when i + 1 < args.Length:
dbPath = args[++i];
break;
case "--save-nests" when i + 1 < args.Length:
saveNestsDir = args[++i];
break;
case "--template" when i + 1 < args.Length:
templateFile = args[++i];
break;
case "--spacing" when i + 1 < args.Length:
spacing = double.Parse(args[++i]);
break;
case "--help":
case "-h":
PrintUsage();
return 0;
default:
if (!args[i].StartsWith("--") && collectDir == null)
collectDir = args[i];
break;
}
}
if (string.IsNullOrEmpty(collectDir) || !Directory.Exists(collectDir))
{
PrintUsage();
return 1;
}
// Initialize GPU if available.
if (GpuEvaluatorFactory.GpuAvailable)
{
BestFitCache.CreateSlideComputer = () => GpuEvaluatorFactory.CreateSlideComputer();
Console.WriteLine($"GPU: {GpuEvaluatorFactory.DeviceName}");
}
else
{
Console.WriteLine("GPU: not available (using CPU)");
}
return RunDataCollection(collectDir, dbPath, saveNestsDir, spacing, templateFile);
int RunDataCollection(string dir, string dbPath, string saveDir, double s, string template)
{
// Load template nest for plate defaults if provided.
Nest templateNest = null;
if (template != null)
{
if (!File.Exists(template))
{
Console.Error.WriteLine($"Error: Template not found: {template}");
return 1;
}
templateNest = new NestReader(template).Read();
Console.WriteLine($"Using template: {template}");
}
var PartColors = new[]
{
Color.FromArgb(205, 92, 92),
Color.FromArgb(148, 103, 189),
Color.FromArgb(75, 180, 175),
Color.FromArgb(210, 190, 75),
Color.FromArgb(190, 85, 175),
Color.FromArgb(185, 115, 85),
Color.FromArgb(120, 100, 190),
Color.FromArgb(200, 100, 140),
Color.FromArgb(80, 175, 155),
Color.FromArgb(195, 160, 85),
Color.FromArgb(175, 95, 160),
Color.FromArgb(215, 130, 130),
};
var sheetSuite = new[]
{
new Size(96, 48), new Size(120, 48), new Size(144, 48),
new Size(96, 60), new Size(120, 60), new Size(144, 60),
new Size(96, 72), new Size(120, 72), new Size(144, 72),
new Size(48, 24), new Size(120, 10)
};
var dxfFiles = Directory.GetFiles(dir, "*.dxf", SearchOption.AllDirectories);
Console.WriteLine($"Found {dxfFiles.Length} DXF files");
var resolvedDb = dbPath.EndsWith(".db", StringComparison.OrdinalIgnoreCase) ? dbPath : dbPath + ".db";
Console.WriteLine($"Database: {Path.GetFullPath(resolvedDb)}");
Console.WriteLine($"Sheet sizes: {sheetSuite.Length} configurations");
Console.WriteLine($"Spacing: {s:F2}");
if (saveDir != null) Console.WriteLine($"Saving nests to: {saveDir}");
Console.WriteLine("---");
using var db = new TrainingDatabase(dbPath);
var backfilled = db.BackfillPerimeterToAreaRatio();
if (backfilled > 0)
Console.WriteLine($"Backfilled PerimeterToAreaRatio for {backfilled} existing parts");
var importer = new DxfImporter();
var colorIndex = 0;
var processed = 0;
var skippedGeometry = 0;
var skippedFeatures = 0;
var skippedExisting = 0;
var totalRuns = 0;
var totalSw = Stopwatch.StartNew();
foreach (var file in dxfFiles)
{
var fileNum = processed + skippedGeometry + skippedFeatures + skippedExisting + 1;
var partNo = Path.GetFileNameWithoutExtension(file);
Console.Write($"[{fileNum}/{dxfFiles.Length}] {partNo}");
try
{
var existingRuns = db.RunCount(Path.GetFileName(file));
if (existingRuns >= sheetSuite.Length)
{
Console.WriteLine(" - SKIP (all sizes done)");
skippedExisting++;
continue;
}
if (!importer.GetGeometry(file, out var entities))
{
Console.WriteLine(" - SKIP (no geometry)");
skippedGeometry++;
continue;
}
var drawing = new Drawing(Path.GetFileName(file));
drawing.Program = OpenNest.Converters.ConvertGeometry.ToProgram(entities);
drawing.UpdateArea();
drawing.Color = PartColors[colorIndex % PartColors.Length];
colorIndex++;
var features = FeatureExtractor.Extract(drawing);
if (features == null)
{
Console.WriteLine(" - SKIP (feature extraction failed)");
skippedFeatures++;
continue;
}
Console.WriteLine($" (area={features.Area:F1}, verts={features.VertexCount})");
// Precompute best-fits once for all sheet sizes.
var sizes = sheetSuite.Select(sz => (sz.Width, sz.Length)).ToList();
var bfSw = Stopwatch.StartNew();
BestFitCache.ComputeForSizes(drawing, s, sizes);
bfSw.Stop();
Console.WriteLine($" Best-fits computed in {bfSw.ElapsedMilliseconds}ms");
var partId = db.GetOrAddPart(Path.GetFileName(file), features, drawing.Program.ToString());
var partSw = Stopwatch.StartNew();
var runsThisPart = 0;
var bestUtil = 0.0;
var bestCount = 0;
foreach (var size in sheetSuite)
{
if (db.HasRun(Path.GetFileName(file), size.Width, size.Length, s))
{
Console.WriteLine($" {size.Length}x{size.Width} - skip (exists)");
continue;
}
Plate runPlate;
if (templateNest != null)
{
runPlate = templateNest.PlateDefaults.CreateNew();
runPlate.Size = size;
runPlate.PartSpacing = s;
}
else
{
runPlate = new Plate { Size = size, PartSpacing = s };
}
var sizeSw = Stopwatch.StartNew();
var result = BruteForceRunner.Run(drawing, runPlate, forceFullAngleSweep: true);
sizeSw.Stop();
if (result == null)
{
Console.WriteLine($" {size.Length}x{size.Width} - no fit");
continue;
}
if (result.Utilization > bestUtil)
{
bestUtil = result.Utilization;
bestCount = result.PartCount;
}
var engineInfo = $"{result.WinnerEngine}({result.WinnerTimeMs}ms)";
if (!string.IsNullOrEmpty(result.RunnerUpEngine))
engineInfo += $", 2nd={result.RunnerUpEngine}({result.RunnerUpPartCount}pcs/{result.RunnerUpTimeMs}ms)";
if (!string.IsNullOrEmpty(result.ThirdPlaceEngine))
engineInfo += $", 3rd={result.ThirdPlaceEngine}({result.ThirdPlacePartCount}pcs/{result.ThirdPlaceTimeMs}ms)";
Console.WriteLine($" {size.Length}x{size.Width} - {result.PartCount}pcs, {result.Utilization:P1}, {sizeSw.ElapsedMilliseconds}ms [{engineInfo}] angles={result.AngleResults.Count}");
string savedFilePath = null;
if (saveDir != null)
{
// Deterministic bucket (00-FF) based on filename hash
uint hash = 0;
foreach (char c in partNo) hash = (hash * 31) + c;
var bucket = (hash % 256).ToString("X2");
var partDir = Path.Combine(saveDir, bucket, partNo);
Directory.CreateDirectory(partDir);
var nestName = $"{partNo}-{size.Length}x{size.Width}-{result.PartCount}pcs";
var fileName = nestName + ".zip";
savedFilePath = Path.Combine(partDir, fileName);
// Create nest from template or from scratch
Nest nestObj;
if (templateNest != null)
{
nestObj = new Nest(nestName)
{
Units = templateNest.Units,
DateCreated = DateTime.Now
};
nestObj.PlateDefaults.SetFromExisting(templateNest.PlateDefaults.CreateNew());
}
else
{
nestObj = new Nest(nestName) { Units = Units.Inches, DateCreated = DateTime.Now };
}
nestObj.Drawings.Add(drawing);
var plateObj = nestObj.CreatePlate();
plateObj.Size = size;
plateObj.PartSpacing = s;
plateObj.Parts.AddRange(result.PlacedParts);
var writer = new NestWriter(nestObj);
writer.Write(savedFilePath);
}
db.AddRun(partId, size.Width, size.Length, s, result, savedFilePath, result.AngleResults);
runsThisPart++;
totalRuns++;
}
BestFitCache.Invalidate(drawing);
partSw.Stop();
processed++;
Console.WriteLine($" Total: {runsThisPart} runs, best={bestCount}pcs @ {bestUtil:P1}, {partSw.ElapsedMilliseconds}ms");
}
catch (Exception ex)
{
Console.WriteLine();
Console.Error.WriteLine($" ERROR: {ex.Message}");
}
}
totalSw.Stop();
Console.WriteLine("---");
Console.WriteLine($"Processed: {processed} parts, {totalRuns} total runs");
Console.WriteLine($"Skipped: {skippedExisting} (existing) + {skippedGeometry} (no geometry) + {skippedFeatures} (no features)");
Console.WriteLine($"Time: {totalSw.Elapsed:h\\:mm\\:ss}");
Console.WriteLine($"Database: {Path.GetFullPath(resolvedDb)}");
return 0;
}
void PrintUsage()
{
Console.Error.WriteLine("Usage: OpenNest.Training <dxf-dir> [options]");
Console.Error.WriteLine();
Console.Error.WriteLine("Arguments:");
Console.Error.WriteLine(" dxf-dir Directory containing DXF files to process");
Console.Error.WriteLine();
Console.Error.WriteLine("Options:");
Console.Error.WriteLine(" --spacing <value> Part spacing (default: 0.5)");
Console.Error.WriteLine(" --db <path> SQLite database path (default: OpenNestTraining.db)");
Console.Error.WriteLine(" --save-nests <dir> Directory to save individual .zip nests for each winner");
Console.Error.WriteLine(" --template <path> Nest template (.nstdot) for plate defaults");
Console.Error.WriteLine(" -h, --help Show this help");
}

View File

@@ -0,0 +1,201 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text;
using Microsoft.EntityFrameworkCore;
using OpenNest.Engine.ML;
using OpenNest.IO;
using OpenNest.Training.Data;
namespace OpenNest.Training
{
public class TrainingDatabase : IDisposable
{
private readonly TrainingDbContext _db;
public TrainingDatabase(string dbPath)
{
if (!dbPath.EndsWith(".db", StringComparison.OrdinalIgnoreCase))
dbPath += ".db";
_db = new TrainingDbContext(dbPath);
_db.Database.EnsureCreated();
MigrateSchema();
}
public long GetOrAddPart(string fileName, PartFeatures features, string geometryData)
{
var existing = _db.Parts.FirstOrDefault(p => p.FileName == fileName);
if (existing != null) return existing.Id;
var part = new TrainingPart
{
FileName = fileName,
Area = features.Area,
Convexity = features.Convexity,
AspectRatio = features.AspectRatio,
BBFill = features.BoundingBoxFill,
Circularity = features.Circularity,
PerimeterToAreaRatio = features.PerimeterToAreaRatio,
VertexCount = features.VertexCount,
Bitmask = features.Bitmask,
GeometryData = geometryData
};
_db.Parts.Add(part);
_db.SaveChanges();
return part.Id;
}
public bool HasRun(string fileName, double sheetWidth, double sheetHeight, double spacing)
{
return _db.Runs.Any(r =>
r.Part.FileName == fileName &&
r.SheetWidth == sheetWidth &&
r.SheetHeight == sheetHeight &&
r.Spacing == spacing);
}
public int RunCount(string fileName)
{
return _db.Runs.Count(r => r.Part.FileName == fileName);
}
public void AddRun(long partId, double w, double h, double s, BruteForceResult result, string filePath, List<AngleResult> angleResults = null)
{
var run = new TrainingRun
{
PartId = partId,
SheetWidth = w,
SheetHeight = h,
Spacing = s,
PartCount = result.PartCount,
Utilization = result.Utilization,
TimeMs = result.TimeMs,
LayoutData = result.LayoutData ?? "",
FilePath = filePath ?? "",
WinnerEngine = result.WinnerEngine ?? "",
WinnerTimeMs = result.WinnerTimeMs,
RunnerUpEngine = result.RunnerUpEngine ?? "",
RunnerUpPartCount = result.RunnerUpPartCount,
RunnerUpTimeMs = result.RunnerUpTimeMs,
ThirdPlaceEngine = result.ThirdPlaceEngine ?? "",
ThirdPlacePartCount = result.ThirdPlacePartCount,
ThirdPlaceTimeMs = result.ThirdPlaceTimeMs
};
_db.Runs.Add(run);
if (angleResults != null && angleResults.Count > 0)
{
foreach (var ar in angleResults)
{
_db.AngleResults.Add(new Data.TrainingAngleResult
{
Run = run,
AngleDeg = ar.AngleDeg,
Direction = ar.Direction.ToString(),
PartCount = ar.PartCount
});
}
}
_db.SaveChanges();
}
public int BackfillPerimeterToAreaRatio()
{
var partsToFix = _db.Parts
.Where(p => p.PerimeterToAreaRatio == 0)
.Select(p => new { p.Id, p.GeometryData })
.ToList();
if (partsToFix.Count == 0) return 0;
var updated = 0;
foreach (var item in partsToFix)
{
try
{
var stream = new MemoryStream(Encoding.UTF8.GetBytes(item.GeometryData));
var programReader = new ProgramReader(stream);
var program = programReader.Read();
var drawing = new Drawing("backfill") { Program = program };
drawing.UpdateArea();
var features = FeatureExtractor.Extract(drawing);
if (features == null) continue;
var part = _db.Parts.Find(item.Id);
part.PerimeterToAreaRatio = features.PerimeterToAreaRatio;
_db.SaveChanges();
updated++;
}
catch
{
// Skip parts that fail to reconstruct.
}
}
return updated;
}
private void MigrateSchema()
{
var columns = new[]
{
("WinnerEngine", "TEXT NOT NULL DEFAULT ''"),
("WinnerTimeMs", "INTEGER NOT NULL DEFAULT 0"),
("RunnerUpEngine", "TEXT NOT NULL DEFAULT ''"),
("RunnerUpPartCount", "INTEGER NOT NULL DEFAULT 0"),
("RunnerUpTimeMs", "INTEGER NOT NULL DEFAULT 0"),
("ThirdPlaceEngine", "TEXT NOT NULL DEFAULT ''"),
("ThirdPlacePartCount", "INTEGER NOT NULL DEFAULT 0"),
("ThirdPlaceTimeMs", "INTEGER NOT NULL DEFAULT 0"),
};
foreach (var (name, type) in columns)
{
try
{
_db.Database.ExecuteSqlRaw($"ALTER TABLE Runs ADD COLUMN {name} {type}");
}
catch
{
// Column already exists.
}
}
try
{
_db.Database.ExecuteSqlRaw(@"
CREATE TABLE IF NOT EXISTS AngleResults (
Id INTEGER PRIMARY KEY AUTOINCREMENT,
RunId INTEGER NOT NULL,
AngleDeg REAL NOT NULL,
Direction TEXT NOT NULL,
PartCount INTEGER NOT NULL,
FOREIGN KEY (RunId) REFERENCES Runs(Id)
)");
_db.Database.ExecuteSqlRaw(
"CREATE INDEX IF NOT EXISTS idx_angleresults_runid ON AngleResults (RunId)");
}
catch
{
// Table already exists or other non-fatal issue.
}
}
public void SaveChanges()
{
_db.SaveChanges();
}
public void Dispose()
{
_db?.Dispose();
}
}
}

View File

@@ -0,0 +1,7 @@
pandas>=2.0
scikit-learn>=1.3
xgboost>=2.0
onnxmltools>=1.12
skl2onnx>=1.16
matplotlib>=3.7
jupyter>=1.0

View File

@@ -0,0 +1,264 @@
{
"nbformat": 4,
"nbformat_minor": 5,
"metadata": {
"kernelspec": {
"display_name": "Python 3",
"language": "python",
"name": "python3"
},
"language_info": {
"name": "python",
"version": "3.11.0"
}
},
"cells": [
{
"cell_type": "markdown",
"id": "a1b2c3d4-0001-0000-0000-000000000001",
"metadata": {},
"source": [
"# Angle Prediction Model Training\n",
"Trains an XGBoost multi-label classifier to predict which rotation angles are competitive for a given part geometry and sheet size.\n",
"\n",
"**Input:** SQLite database from OpenNest.Training data collection runs\n",
"**Output:** `angle_predictor.onnx` model file for `OpenNest.Engine/Models/`"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "a1b2c3d4-0002-0000-0000-000000000002",
"metadata": {},
"outputs": [],
"source": [
"import sqlite3\n",
"import pandas as pd\n",
"import numpy as np\n",
"from pathlib import Path\n",
"\n",
"DB_PATH = \"../OpenNestTraining.db\" # Adjust to your database location\n",
"OUTPUT_PATH = \"../../OpenNest.Engine/Models/angle_predictor.onnx\"\n",
"COMPETITIVE_THRESHOLD = 0.95 # Angle is \"competitive\" if >= 95% of best"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "a1b2c3d4-0003-0000-0000-000000000003",
"metadata": {},
"outputs": [],
"source": [
"# Extract training data from SQLite\n",
"conn = sqlite3.connect(DB_PATH)\n",
"\n",
"query = \"\"\"\n",
"SELECT\n",
" p.Area, p.Convexity, p.AspectRatio, p.BBFill, p.Circularity,\n",
" p.PerimeterToAreaRatio, p.VertexCount,\n",
" r.SheetWidth, r.SheetHeight, r.Id as RunId,\n",
" a.AngleDeg, a.Direction, a.PartCount\n",
"FROM AngleResults a\n",
"JOIN Runs r ON a.RunId = r.Id\n",
"JOIN Parts p ON r.PartId = p.Id\n",
"WHERE a.PartCount > 0\n",
"\"\"\"\n",
"\n",
"df = pd.read_sql_query(query, conn)\n",
"conn.close()\n",
"\n",
"print(f\"Loaded {len(df)} angle result rows\")\n",
"print(f\"Unique runs: {df['RunId'].nunique()}\")\n",
"print(f\"Angle range: {df['AngleDeg'].min()}-{df['AngleDeg'].max()}\")"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "a1b2c3d4-0004-0000-0000-000000000004",
"metadata": {},
"outputs": [],
"source": [
"# For each run, find best PartCount (max of H and V per angle),\n",
"# then label angles within 95% of best as positive.\n",
"\n",
"# Best count per angle per run (max of H and V)\n",
"angle_best = df.groupby(['RunId', 'AngleDeg'])['PartCount'].max().reset_index()\n",
"angle_best.columns = ['RunId', 'AngleDeg', 'BestCount']\n",
"\n",
"# Best count per run (overall best angle)\n",
"run_best = angle_best.groupby('RunId')['BestCount'].max().reset_index()\n",
"run_best.columns = ['RunId', 'RunBest']\n",
"\n",
"# Merge and compute labels\n",
"labels = angle_best.merge(run_best, on='RunId')\n",
"labels['IsCompetitive'] = (labels['BestCount'] >= labels['RunBest'] * COMPETITIVE_THRESHOLD).astype(int)\n",
"\n",
"# Pivot to 36-column binary label matrix\n",
"label_matrix = labels.pivot_table(\n",
" index='RunId', columns='AngleDeg', values='IsCompetitive', fill_value=0\n",
")\n",
"\n",
"# Ensure all 36 angle columns exist (0, 5, 10, ..., 175)\n",
"all_angles = [i * 5 for i in range(36)]\n",
"for a in all_angles:\n",
" if a not in label_matrix.columns:\n",
" label_matrix[a] = 0\n",
"label_matrix = label_matrix[all_angles]\n",
"\n",
"print(f\"Label matrix: {label_matrix.shape}\")\n",
"print(f\"Average competitive angles per run: {label_matrix.sum(axis=1).mean():.1f}\")"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "a1b2c3d4-0005-0000-0000-000000000005",
"metadata": {},
"outputs": [],
"source": [
"# Build feature matrix - one row per run\n",
"features_query = \"\"\"\n",
"SELECT DISTINCT\n",
" r.Id as RunId, p.FileName,\n",
" p.Area, p.Convexity, p.AspectRatio, p.BBFill, p.Circularity,\n",
" p.PerimeterToAreaRatio, p.VertexCount,\n",
" r.SheetWidth, r.SheetHeight\n",
"FROM Runs r\n",
"JOIN Parts p ON r.PartId = p.Id\n",
"WHERE r.Id IN ({})\n",
"\"\"\".format(','.join(str(x) for x in label_matrix.index))\n",
"\n",
"conn = sqlite3.connect(DB_PATH)\n",
"features_df = pd.read_sql_query(features_query, conn)\n",
"conn.close()\n",
"\n",
"features_df = features_df.set_index('RunId')\n",
"\n",
"# Derived features\n",
"features_df['SheetAspectRatio'] = features_df['SheetWidth'] / features_df['SheetHeight']\n",
"features_df['PartToSheetAreaRatio'] = features_df['Area'] / (features_df['SheetWidth'] * features_df['SheetHeight'])\n",
"\n",
"# Filter outliers (title blocks, etc.)\n",
"mask = (features_df['BBFill'] >= 0.01) & (features_df['Area'] > 0.1)\n",
"print(f\"Filtering: {(~mask).sum()} outlier runs removed\")\n",
"features_df = features_df[mask]\n",
"label_matrix = label_matrix.loc[features_df.index]\n",
"\n",
"feature_cols = ['Area', 'Convexity', 'AspectRatio', 'BBFill', 'Circularity',\n",
" 'PerimeterToAreaRatio', 'VertexCount',\n",
" 'SheetWidth', 'SheetHeight', 'SheetAspectRatio', 'PartToSheetAreaRatio']\n",
"\n",
"X = features_df[feature_cols].values\n",
"y = label_matrix.values\n",
"\n",
"print(f\"Features: {X.shape}, Labels: {y.shape}\")"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "a1b2c3d4-0006-0000-0000-000000000006",
"metadata": {},
"outputs": [],
"source": [
"from sklearn.model_selection import GroupShuffleSplit\n",
"from sklearn.multioutput import MultiOutputClassifier\n",
"import xgboost as xgb\n",
"\n",
"# Split by part (all sheet sizes for a part stay in the same split)\n",
"groups = features_df['FileName']\n",
"splitter = GroupShuffleSplit(n_splits=1, test_size=0.2, random_state=42)\n",
"train_idx, test_idx = next(splitter.split(X, y, groups))\n",
"\n",
"X_train, X_test = X[train_idx], X[test_idx]\n",
"y_train, y_test = y[train_idx], y[test_idx]\n",
"\n",
"print(f\"Train: {len(train_idx)}, Test: {len(test_idx)}\")\n",
"\n",
"# Train XGBoost multi-label classifier\n",
"base_clf = xgb.XGBClassifier(\n",
" n_estimators=200,\n",
" max_depth=6,\n",
" learning_rate=0.1,\n",
" use_label_encoder=False,\n",
" eval_metric='logloss',\n",
" random_state=42\n",
")\n",
"\n",
"clf = MultiOutputClassifier(base_clf, n_jobs=-1)\n",
"clf.fit(X_train, y_train)\n",
"print(\"Training complete\")"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "a1b2c3d4-0007-0000-0000-000000000007",
"metadata": {},
"outputs": [],
"source": [
"from sklearn.metrics import recall_score, precision_score\n",
"import matplotlib.pyplot as plt\n",
"\n",
"y_pred = clf.predict(X_test)\n",
"y_prob = np.array([est.predict_proba(X_test)[:, 1] for est in clf.estimators_]).T\n",
"\n",
"# Per-angle metrics\n",
"recalls = []\n",
"precisions = []\n",
"for i in range(36):\n",
" if y_test[:, i].sum() > 0:\n",
" recalls.append(recall_score(y_test[:, i], y_pred[:, i], zero_division=0))\n",
" precisions.append(precision_score(y_test[:, i], y_pred[:, i], zero_division=0))\n",
"\n",
"print(f\"Mean recall: {np.mean(recalls):.3f}\")\n",
"print(f\"Mean precision: {np.mean(precisions):.3f}\")\n",
"\n",
"# Average angles predicted per run\n",
"avg_predicted = y_pred.sum(axis=1).mean()\n",
"print(f\"Avg angles predicted per run: {avg_predicted:.1f}\")\n",
"\n",
"# Plot\n",
"fig, axes = plt.subplots(1, 2, figsize=(12, 4))\n",
"axes[0].bar(range(len(recalls)), recalls)\n",
"axes[0].set_title('Recall per Angle Bin')\n",
"axes[0].set_xlabel('Angle (5-deg bins)')\n",
"axes[0].axhline(y=0.95, color='r', linestyle='--', label='Target 95%')\n",
"axes[0].legend()\n",
"\n",
"axes[1].bar(range(len(precisions)), precisions)\n",
"axes[1].set_title('Precision per Angle Bin')\n",
"axes[1].set_xlabel('Angle (5-deg bins)')\n",
"axes[1].axhline(y=0.60, color='r', linestyle='--', label='Target 60%')\n",
"axes[1].legend()\n",
"\n",
"plt.tight_layout()\n",
"plt.show()"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "a1b2c3d4-0008-0000-0000-000000000008",
"metadata": {},
"outputs": [],
"source": [
"from skl2onnx import convert_sklearn\n",
"from skl2onnx.common.data_types import FloatTensorType\n",
"from pathlib import Path\n",
"\n",
"initial_type = [('features', FloatTensorType([None, 11]))]\n",
"onnx_model = convert_sklearn(clf, initial_types=initial_type)\n",
"\n",
"output_path = Path(OUTPUT_PATH)\n",
"output_path.parent.mkdir(parents=True, exist_ok=True)\n",
"\n",
"with open(output_path, 'wb') as f:\n",
" f.write(onnx_model.SerializeToString())\n",
"\n",
"print(f\"Model saved to {output_path} ({output_path.stat().st_size / 1024:.0f} KB)\")"
]
}
]
}

View File

@@ -17,6 +17,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OpenNest.Mcp", "OpenNest.Mc
EndProject EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OpenNest.Console", "OpenNest.Console\OpenNest.Console.csproj", "{58E00A25-86B5-42C7-87B5-DE4AD22381EA}" Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OpenNest.Console", "OpenNest.Console\OpenNest.Console.csproj", "{58E00A25-86B5-42C7-87B5-DE4AD22381EA}"
EndProject EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OpenNest.Training", "OpenNest.Training\OpenNest.Training.csproj", "{249BF728-25DD-4863-8266-207ACD26E964}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OpenNest.Tests", "OpenNest.Tests\OpenNest.Tests.csproj", "{03539EB7-9DB2-4634-A6FD-F094B9603596}"
EndProject
Global Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU Debug|Any CPU = Debug|Any CPU
@@ -111,6 +115,30 @@ Global
{58E00A25-86B5-42C7-87B5-DE4AD22381EA}.Release|x64.Build.0 = Release|Any CPU {58E00A25-86B5-42C7-87B5-DE4AD22381EA}.Release|x64.Build.0 = Release|Any CPU
{58E00A25-86B5-42C7-87B5-DE4AD22381EA}.Release|x86.ActiveCfg = Release|Any CPU {58E00A25-86B5-42C7-87B5-DE4AD22381EA}.Release|x86.ActiveCfg = Release|Any CPU
{58E00A25-86B5-42C7-87B5-DE4AD22381EA}.Release|x86.Build.0 = Release|Any CPU {58E00A25-86B5-42C7-87B5-DE4AD22381EA}.Release|x86.Build.0 = Release|Any CPU
{249BF728-25DD-4863-8266-207ACD26E964}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{249BF728-25DD-4863-8266-207ACD26E964}.Debug|Any CPU.Build.0 = Debug|Any CPU
{249BF728-25DD-4863-8266-207ACD26E964}.Debug|x64.ActiveCfg = Debug|Any CPU
{249BF728-25DD-4863-8266-207ACD26E964}.Debug|x64.Build.0 = Debug|Any CPU
{249BF728-25DD-4863-8266-207ACD26E964}.Debug|x86.ActiveCfg = Debug|Any CPU
{249BF728-25DD-4863-8266-207ACD26E964}.Debug|x86.Build.0 = Debug|Any CPU
{249BF728-25DD-4863-8266-207ACD26E964}.Release|Any CPU.ActiveCfg = Release|Any CPU
{249BF728-25DD-4863-8266-207ACD26E964}.Release|Any CPU.Build.0 = Release|Any CPU
{249BF728-25DD-4863-8266-207ACD26E964}.Release|x64.ActiveCfg = Release|Any CPU
{249BF728-25DD-4863-8266-207ACD26E964}.Release|x64.Build.0 = Release|Any CPU
{249BF728-25DD-4863-8266-207ACD26E964}.Release|x86.ActiveCfg = Release|Any CPU
{249BF728-25DD-4863-8266-207ACD26E964}.Release|x86.Build.0 = Release|Any CPU
{03539EB7-9DB2-4634-A6FD-F094B9603596}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{03539EB7-9DB2-4634-A6FD-F094B9603596}.Debug|Any CPU.Build.0 = Debug|Any CPU
{03539EB7-9DB2-4634-A6FD-F094B9603596}.Debug|x64.ActiveCfg = Debug|Any CPU
{03539EB7-9DB2-4634-A6FD-F094B9603596}.Debug|x64.Build.0 = Debug|Any CPU
{03539EB7-9DB2-4634-A6FD-F094B9603596}.Debug|x86.ActiveCfg = Debug|Any CPU
{03539EB7-9DB2-4634-A6FD-F094B9603596}.Debug|x86.Build.0 = Debug|Any CPU
{03539EB7-9DB2-4634-A6FD-F094B9603596}.Release|Any CPU.ActiveCfg = Release|Any CPU
{03539EB7-9DB2-4634-A6FD-F094B9603596}.Release|Any CPU.Build.0 = Release|Any CPU
{03539EB7-9DB2-4634-A6FD-F094B9603596}.Release|x64.ActiveCfg = Release|Any CPU
{03539EB7-9DB2-4634-A6FD-F094B9603596}.Release|x64.Build.0 = Release|Any CPU
{03539EB7-9DB2-4634-A6FD-F094B9603596}.Release|x86.ActiveCfg = Release|Any CPU
{03539EB7-9DB2-4634-A6FD-F094B9603596}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection EndGlobalSection
GlobalSection(SolutionProperties) = preSolution GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE HideSolutionNode = FALSE

View File

@@ -186,8 +186,8 @@ namespace OpenNest.Actions
boxes.Add(part.BoundingBox.Offset(plate.PartSpacing)); boxes.Add(part.BoundingBox.Offset(plate.PartSpacing));
var pt = plateView.CurrentPoint; var pt = plateView.CurrentPoint;
var vertical = Helper.GetLargestBoxVertically(pt, bounds, boxes); var vertical = SpatialQuery.GetLargestBoxVertically(pt, bounds, boxes);
var horizontal = Helper.GetLargestBoxHorizontally(pt, bounds, boxes); var horizontal = SpatialQuery.GetLargestBoxHorizontally(pt, bounds, boxes);
var bestArea = vertical; var bestArea = vertical;
if (horizontal.Area() > vertical.Area()) if (horizontal.Area() > vertical.Area())

View File

@@ -47,7 +47,7 @@ namespace OpenNest.Actions
{ {
try try
{ {
var engine = new NestEngine(plateView.Plate); var engine = NestEngineRegistry.Create(plateView.Plate);
var parts = await Task.Run(() => var parts = await Task.Run(() =>
engine.Fill(new NestItem { Drawing = drawing }, engine.Fill(new NestItem { Drawing = drawing },
SelectedArea, progress, cts.Token)); SelectedArea, progress, cts.Token));
@@ -61,7 +61,7 @@ namespace OpenNest.Actions
} }
else else
{ {
var engine = new NestEngine(plateView.Plate); var engine = NestEngineRegistry.Create(plateView.Plate);
engine.Fill(new NestItem { Drawing = drawing }, SelectedArea); engine.Fill(new NestItem { Drawing = drawing }, SelectedArea);
plateView.Invalidate(); plateView.Invalidate();
} }

Some files were not shown because too many files have changed in this diff Show More