diff --git a/.gitignore b/.gitignore index 6a339e1..e2d4b45 100644 --- a/.gitignore +++ b/.gitignore @@ -202,5 +202,9 @@ FakesAssemblies/ # Git worktrees .worktrees/ +# SQLite databases +*.db +*.db-journal + # Claude Code .claude/ diff --git a/CLAUDE.md b/CLAUDE.md index 86fe8c2..bf99449 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## 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 @@ -14,41 +14,57 @@ This is a .NET 8 solution using SDK-style `.csproj` files targeting `net8.0-wind 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). - -No test projects exist in this solution. +NuGet dependencies: `ACadSharp` 3.1.32 (DXF/DWG import/export, in OpenNest.IO), `System.Drawing.Common` 8.0.10, `ModelContextProtocol` + `Microsoft.Extensions.Hosting` (in OpenNest.Mcp), `Microsoft.ML.OnnxRuntime` (in OpenNest.Engine for ML angle prediction), `Microsoft.EntityFrameworkCore.Sqlite` (in OpenNest.Training). ## Architecture -Five projects form a layered architecture: +Eight projects form a layered architecture: ### OpenNest.Core (class library) 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. -- **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). -- **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`, `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. ### 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. - **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. -- `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) File I/O and format conversion. Uses ACadSharp for DXF/DWG support. - `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. - `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) 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. - **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`. -- **Actions/**: User interaction modes — `ActionSelect`, `ActionAddPart`, `ActionClone`, `ActionFillArea`, `ActionZoomWindow`, `ActionSetSequence`. +- **Controls/**: `PlateView` (2D plate renderer with zoom/pan, supports temporary preview parts), `DrawingListBox`, `DrawControl`, `QuadrantSelect`. +- **Actions/**: User interaction modes — `ActionSelect`, `ActionClone`, `ActionFillArea`, `ActionSelectArea`, `ActionZoomWindow`, `ActionSetSequence`. - **Post-processing**: `IPostProcessor` plugin interface loaded from DLLs in a `Posts/` directory at runtime. ## File Format -Nest files (`.zip`) contain: -- `info` — XML with nest metadata and plate defaults -- `drawing-info` — XML with drawing metadata (name, material, quantities, colors) -- `plate-info` — XML with plate metadata (size, material, spacing) +Nest files (`.nest`, ZIP-based) use v2 JSON format: +- `info.json` — nest metadata and plate defaults +- `drawing-info.json` — drawing metadata (name, material, quantities, colors) +- `plate-info.json` — plate metadata (size, material, spacing) - `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) @@ -89,3 +105,6 @@ Always use Roslyn Bridge MCP tools (`mcp__RoslynBridge__*`) as the primary metho - `ObservableList` 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). - `Tolerance.Epsilon` is used for floating-point comparisons across geometry operations. +- Nesting uses async progress/cancellation: `IProgress` 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. diff --git a/OpenNest.Console/Program.cs b/OpenNest.Console/Program.cs index f96e900..3bb08f0 100644 --- a/OpenNest.Console/Program.cs +++ b/OpenNest.Console/Program.cs @@ -3,246 +3,426 @@ using System.Collections.Generic; using System.Diagnostics; using System.IO; using System.Linq; +using System.Threading; using OpenNest; +using OpenNest.Converters; using OpenNest.Geometry; using OpenNest.IO; -// Parse arguments. -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; +return NestConsole.Run(args); -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: - drawingName = args[++i]; - break; - case "--plate" when i + 1 < args.Length: - plateIndex = int.Parse(args[++i]); - break; - 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": + var options = ParseArgs(args); + + if (options == null) + return 0; // --help was requested + + if (options.InputFiles.Count == 0) + { 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(); + 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; - default: - if (!args[i].StartsWith("--") && nestFile == null) - nestFile = args[i]; - break; + + var hasOverlaps = plate.HasOverlappingParts(out var overlapPts); + Console.WriteLine(hasOverlaps + ? $"OVERLAPS DETECTED: {overlapPts.Count} intersection points" + : "Overlap check: PASS"); + + return overlapPts.Count; } -} -if (string.IsNullOrEmpty(nestFile) || !File.Exists(nestFile)) -{ - 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(); - - if (drawingName != null) + static void PrintResults(bool success, Plate plate, long elapsedMs) { - 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) - nestItems.Add(new NestItem { Drawing = d, Quantity = quantity > 0 ? quantity : 1 }); + if (options.NoSave) + 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"); - - 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 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) + static void PrintUsage() { - var dir = Path.GetDirectoryName(nestFile); - var name = Path.GetFileNameWithoutExtension(nestFile); - outputFile = Path.Combine(dir, $"{name}-result.zip"); + Console.Error.WriteLine("Usage: OpenNest.Console [options]"); + Console.Error.WriteLine(); + 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(" Load nest and fill (existing behavior)"); + Console.Error.WriteLine(" --size LxW Import DXF, create plate, and fill"); + Console.Error.WriteLine(" Load nest and add imported DXF drawings"); + Console.Error.WriteLine(); + Console.Error.WriteLine("Options:"); + Console.Error.WriteLine(" --drawing Drawing name to fill with (default: first drawing)"); + Console.Error.WriteLine(" --plate Plate index to fill (default: 0)"); + Console.Error.WriteLine(" --quantity Max parts to place (default: 0 = unlimited)"); + Console.Error.WriteLine(" --spacing Override part spacing"); + Console.Error.WriteLine(" --size Override plate size (e.g. 120x60); required for DXF-only mode"); + Console.Error.WriteLine(" --output Output nest file path (default: -result.zip)"); + Console.Error.WriteLine(" --template 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); - writer.Write(outputFile); - Console.WriteLine($"Saved: {outputFile}"); -} - -return checkOverlaps && overlapCount > 0 ? 1 : 0; - -void PrintUsage() -{ - Console.Error.WriteLine("Usage: OpenNest.Console [options]"); - Console.Error.WriteLine(); - Console.Error.WriteLine("Arguments:"); - Console.Error.WriteLine(" nest-file Path to a .zip nest file"); - Console.Error.WriteLine(); - Console.Error.WriteLine("Options:"); - Console.Error.WriteLine(" --drawing Drawing name to fill with (default: first drawing)"); - Console.Error.WriteLine(" --plate Plate index to fill (default: 0)"); - Console.Error.WriteLine(" --quantity Max parts to place (default: 0 = unlimited)"); - Console.Error.WriteLine(" --spacing Override part spacing"); - Console.Error.WriteLine(" --size Override plate size (e.g. 120x60)"); - Console.Error.WriteLine(" --output Output nest file path (default: -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"); + class Options + { + public List InputFiles = new(); + public string DrawingName; + public int PlateIndex; + public string OutputFile; + public int Quantity; + public double? Spacing; + public double? PlateWidth; + public double? PlateHeight; + public bool CheckOverlaps; + public bool NoSave; + public bool NoLog; + public bool KeepParts; + public bool AutoNest; + public string TemplateFile; + } } diff --git a/OpenNest.Core/CNC/CuttingStrategy/ContourCuttingStrategy.cs b/OpenNest.Core/CNC/CuttingStrategy/ContourCuttingStrategy.cs index 7ea880b..30455be 100644 --- a/OpenNest.Core/CNC/CuttingStrategy/ContourCuttingStrategy.cs +++ b/OpenNest.Core/CNC/CuttingStrategy/ContourCuttingStrategy.cs @@ -7,9 +7,9 @@ namespace OpenNest.CNC.CuttingStrategy { 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 profile = new ShapeProfile(entities); @@ -44,9 +44,12 @@ namespace OpenNest.CNC.CuttingStrategy currentPoint = closestPt; } + var lastCutPoint = exitPoint; + // Perimeter last { var perimeterPt = profile.Perimeter.ClosestPointTo(currentPoint, out perimeterEntity); + lastCutPoint = perimeterPt; var normal = ComputeNormal(perimeterPt, perimeterEntity, ContourType.External); var winding = DetermineWinding(profile.Perimeter); @@ -60,21 +63,10 @@ namespace OpenNest.CNC.CuttingStrategy result.Codes.AddRange(leadOut.Generate(perimeterPt, normal, winding)); } - return result; - } - - private Vector GetExitPoint(Plate plate) - { - var w = plate.Size.Width; - var l = plate.Size.Length; - - return plate.Quadrant switch + return new CuttingResult { - 1 => new Vector(w, l), // Q1 origin BottomLeft -> exit TopRight - 2 => new Vector(0, l), // Q2 origin BottomRight -> exit TopLeft - 3 => new Vector(0, 0), // Q3 origin TopRight -> exit BottomLeft - 4 => new Vector(w, 0), // Q4 origin TopLeft -> exit BottomRight - _ => new Vector(w, l) + Program = result, + LastCutPoint = lastCutPoint }; } diff --git a/OpenNest.Core/CNC/CuttingStrategy/CuttingResult.cs b/OpenNest.Core/CNC/CuttingStrategy/CuttingResult.cs new file mode 100644 index 0000000..933db14 --- /dev/null +++ b/OpenNest.Core/CNC/CuttingStrategy/CuttingResult.cs @@ -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; } + } +} diff --git a/OpenNest.Core/CNC/Program.cs b/OpenNest.Core/CNC/Program.cs index 0f31650..6f61c1b 100644 --- a/OpenNest.Core/CNC/Program.cs +++ b/OpenNest.Core/CNC/Program.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using OpenNest.Converters; using OpenNest.Geometry; @@ -84,6 +84,23 @@ namespace OpenNest.CNC 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) { var mode = Mode; @@ -99,7 +116,7 @@ namespace OpenNest.CNC var subpgm = (SubProgramCall)code; if (subpgm.Program != null) - subpgm.Program.Rotate(angle); + subpgm.Program.Rotate(angle, origin); } if (code is Motion == false) diff --git a/OpenNest.Core/Converters/ConvertGeometry.cs b/OpenNest.Core/Converters/ConvertGeometry.cs index 13cc52a..6c95e56 100644 --- a/OpenNest.Core/Converters/ConvertGeometry.cs +++ b/OpenNest.Core/Converters/ConvertGeometry.cs @@ -9,7 +9,7 @@ namespace OpenNest.Converters { public static Program ToProgram(IList geometry) { - var shapes = Helper.GetShapes(geometry); + var shapes = ShapeBuilder.GetShapes(geometry); if (shapes.Count == 0) return null; diff --git a/OpenNest.Core/Drawing.cs b/OpenNest.Core/Drawing.cs index 080e72f..2adc038 100644 --- a/OpenNest.Core/Drawing.cs +++ b/OpenNest.Core/Drawing.cs @@ -65,7 +65,7 @@ namespace OpenNest public void UpdateArea() { 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) return; diff --git a/OpenNest.Core/Geometry/Arc.cs b/OpenNest.Core/Geometry/Arc.cs index d48792f..bbd2d3f 100644 --- a/OpenNest.Core/Geometry/Arc.cs +++ b/OpenNest.Core/Geometry/Arc.cs @@ -465,7 +465,7 @@ namespace OpenNest.Geometry public override bool Intersects(Arc arc) { List pts; - return Helper.Intersects(this, arc, out pts); + return Intersect.Intersects(this, arc, out pts); } /// @@ -476,7 +476,7 @@ namespace OpenNest.Geometry /// public override bool Intersects(Arc arc, out List pts) { - return Helper.Intersects(this, arc, out pts); ; + return Intersect.Intersects(this, arc, out pts); ; } /// @@ -487,7 +487,7 @@ namespace OpenNest.Geometry public override bool Intersects(Circle circle) { List pts; - return Helper.Intersects(this, circle, out pts); + return Intersect.Intersects(this, circle, out pts); } /// @@ -498,7 +498,7 @@ namespace OpenNest.Geometry /// public override bool Intersects(Circle circle, out List pts) { - return Helper.Intersects(this, circle, out pts); + return Intersect.Intersects(this, circle, out pts); } /// @@ -509,7 +509,7 @@ namespace OpenNest.Geometry public override bool Intersects(Line line) { List pts; - return Helper.Intersects(this, line, out pts); + return Intersect.Intersects(this, line, out pts); } /// @@ -520,7 +520,7 @@ namespace OpenNest.Geometry /// public override bool Intersects(Line line, out List pts) { - return Helper.Intersects(this, line, out pts); + return Intersect.Intersects(this, line, out pts); } /// @@ -531,7 +531,7 @@ namespace OpenNest.Geometry public override bool Intersects(Polygon polygon) { List pts; - return Helper.Intersects(this, polygon, out pts); + return Intersect.Intersects(this, polygon, out pts); } /// @@ -542,7 +542,7 @@ namespace OpenNest.Geometry /// public override bool Intersects(Polygon polygon, out List pts) { - return Helper.Intersects(this, polygon, out pts); + return Intersect.Intersects(this, polygon, out pts); } /// @@ -553,7 +553,7 @@ namespace OpenNest.Geometry public override bool Intersects(Shape shape) { List pts; - return Helper.Intersects(this, shape, out pts); + return Intersect.Intersects(this, shape, out pts); } /// @@ -564,7 +564,7 @@ namespace OpenNest.Geometry /// public override bool Intersects(Shape shape, out List pts) { - return Helper.Intersects(this, shape, out pts); + return Intersect.Intersects(this, shape, out pts); } /// diff --git a/OpenNest.Core/Geometry/Circle.cs b/OpenNest.Core/Geometry/Circle.cs index 859d51a..82a0b3e 100644 --- a/OpenNest.Core/Geometry/Circle.cs +++ b/OpenNest.Core/Geometry/Circle.cs @@ -320,7 +320,7 @@ namespace OpenNest.Geometry public override bool Intersects(Arc arc) { List pts; - return Helper.Intersects(arc, this, out pts); + return Intersect.Intersects(arc, this, out pts); } /// @@ -331,7 +331,7 @@ namespace OpenNest.Geometry /// public override bool Intersects(Arc arc, out List pts) { - return Helper.Intersects(arc, this, out pts); + return Intersect.Intersects(arc, this, out pts); } /// @@ -353,7 +353,7 @@ namespace OpenNest.Geometry /// public override bool Intersects(Circle circle, out List pts) { - return Helper.Intersects(this, circle, out pts); + return Intersect.Intersects(this, circle, out pts); } /// @@ -364,7 +364,7 @@ namespace OpenNest.Geometry public override bool Intersects(Line line) { List pts; - return Helper.Intersects(this, line, out pts); + return Intersect.Intersects(this, line, out pts); } /// @@ -375,7 +375,7 @@ namespace OpenNest.Geometry /// public override bool Intersects(Line line, out List pts) { - return Helper.Intersects(this, line, out pts); + return Intersect.Intersects(this, line, out pts); } /// @@ -386,7 +386,7 @@ namespace OpenNest.Geometry public override bool Intersects(Polygon polygon) { List pts; - return Helper.Intersects(this, polygon, out pts); + return Intersect.Intersects(this, polygon, out pts); } /// @@ -397,7 +397,7 @@ namespace OpenNest.Geometry /// public override bool Intersects(Polygon polygon, out List pts) { - return Helper.Intersects(this, polygon, out pts); + return Intersect.Intersects(this, polygon, out pts); } /// @@ -408,7 +408,7 @@ namespace OpenNest.Geometry public override bool Intersects(Shape shape) { List pts; - return Helper.Intersects(this, shape, out pts); + return Intersect.Intersects(this, shape, out pts); } /// @@ -419,7 +419,7 @@ namespace OpenNest.Geometry /// public override bool Intersects(Shape shape, out List pts) { - return Helper.Intersects(this, shape, out pts); + return Intersect.Intersects(this, shape, out pts); } /// diff --git a/OpenNest.Core/Geometry/GeometryOptimizer.cs b/OpenNest.Core/Geometry/GeometryOptimizer.cs new file mode 100644 index 0000000..db9eedd --- /dev/null +++ b/OpenNest.Core/Geometry/GeometryOptimizer.cs @@ -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 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 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 GetCollinearLines(this IList lines, Line line, int startIndex) + { + var collinearLines = new List(); + + 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 GetCoradialArs(this IList arcs, Arc arc, int startIndex) + { + var coradialArcs = new List(); + + 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; + } + } +} diff --git a/OpenNest.Core/Geometry/Intersect.cs b/OpenNest.Core/Geometry/Intersect.cs new file mode 100644 index 0000000..8af27e1 --- /dev/null +++ b/OpenNest.Core/Geometry/Intersect.cs @@ -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 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(); + 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 pts) + { + var c1 = new Circle(arc.Center, arc.Radius); + + if (!Intersects(c1, circle, out pts)) + { + pts = new List(); + 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 pts) + { + var c1 = new Circle(arc.Center, arc.Radius); + + if (!Intersects(c1, line, out pts)) + { + pts = new List(); + 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 pts) + { + var pts2 = new List(); + + foreach (var geo in shape.Entities) + { + List 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 pts) + { + var pts2 = new List(); + var lines = polygon.ToLines(); + + foreach (var line in lines) + { + List 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 pts) + { + var distance = circle1.Center.DistanceTo(circle2.Center); + + // check if circles are too far apart + if (distance > circle1.Radius + circle2.Radius) + { + pts = new List(); + return false; + } + + // check if one circle contains the other + if (distance < System.Math.Abs(circle1.Radius - circle2.Radius)) + { + pts = new List(); + 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 { i1, i2 } : new List { i1 }; + + return true; + } + + internal static bool Intersects(Circle circle, Line line, out List 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(); + return false; + } + + double t; + pts = new List(); + + 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 pts) + { + pts = new List(); + + foreach (var geo in shape.Entities) + { + List pts3; + geo.Intersects(circle, out pts3); + pts.AddRange(pts3); + } + + return pts.Count > 0; + } + + internal static bool Intersects(Circle circle, Polygon polygon, out List pts) + { + pts = new List(); + var lines = polygon.ToLines(); + + foreach (var line in lines) + { + List 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 pts) + { + pts = new List(); + + foreach (var geo in shape.Entities) + { + List pts3; + geo.Intersects(line, out pts3); + pts.AddRange(pts3); + } + + return pts.Count > 0; + } + + internal static bool Intersects(Line line, Polygon polygon, out List pts) + { + pts = new List(); + 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 pts) + { + pts = new List(); + + for (int i = 0; i < shape1.Entities.Count; i++) + { + var geo1 = shape1.Entities[i]; + + for (int j = 0; j < shape2.Entities.Count; j++) + { + List 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 pts) + { + pts = new List(); + + 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 pts2; + + if (geo.Intersects(line, out pts2)) + pts.AddRange(pts2); + } + } + + return pts.Count > 0; + } + + internal static bool Intersects(Polygon polygon1, Polygon polygon2, out List pts) + { + pts = new List(); + + 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; + } + } +} diff --git a/OpenNest.Core/Geometry/Line.cs b/OpenNest.Core/Geometry/Line.cs index 0145f9e..d4c5cc2 100644 --- a/OpenNest.Core/Geometry/Line.cs +++ b/OpenNest.Core/Geometry/Line.cs @@ -456,7 +456,7 @@ namespace OpenNest.Geometry public override bool Intersects(Arc arc) { List pts; - return Helper.Intersects(arc, this, out pts); + return Intersect.Intersects(arc, this, out pts); } /// @@ -467,7 +467,7 @@ namespace OpenNest.Geometry /// public override bool Intersects(Arc arc, out List pts) { - return Helper.Intersects(arc, this, out pts); + return Intersect.Intersects(arc, this, out pts); } /// @@ -478,7 +478,7 @@ namespace OpenNest.Geometry public override bool Intersects(Circle circle) { List pts; - return Helper.Intersects(circle, this, out pts); + return Intersect.Intersects(circle, this, out pts); } /// @@ -489,7 +489,7 @@ namespace OpenNest.Geometry /// public override bool Intersects(Circle circle, out List pts) { - return Helper.Intersects(circle, this, out pts); + return Intersect.Intersects(circle, this, out pts); } /// @@ -512,7 +512,7 @@ namespace OpenNest.Geometry public override bool Intersects(Line line, out List pts) { Vector pt; - var success = Helper.Intersects(this, line, out pt); + var success = Intersect.Intersects(this, line, out pt); pts = new List(new[] { pt }); return success; } @@ -525,7 +525,7 @@ namespace OpenNest.Geometry public override bool Intersects(Polygon polygon) { List pts; - return Helper.Intersects(this, polygon, out pts); + return Intersect.Intersects(this, polygon, out pts); } /// @@ -536,7 +536,7 @@ namespace OpenNest.Geometry /// public override bool Intersects(Polygon polygon, out List pts) { - return Helper.Intersects(this, polygon, out pts); + return Intersect.Intersects(this, polygon, out pts); } /// @@ -547,7 +547,7 @@ namespace OpenNest.Geometry public override bool Intersects(Shape shape) { List pts; - return Helper.Intersects(this, shape, out pts); + return Intersect.Intersects(this, shape, out pts); } /// @@ -558,7 +558,7 @@ namespace OpenNest.Geometry /// public override bool Intersects(Shape shape, out List pts) { - return Helper.Intersects(this, shape, out pts); + return Intersect.Intersects(this, shape, out pts); } /// diff --git a/OpenNest.Core/Geometry/Polygon.cs b/OpenNest.Core/Geometry/Polygon.cs index c03f209..2f09e65 100644 --- a/OpenNest.Core/Geometry/Polygon.cs +++ b/OpenNest.Core/Geometry/Polygon.cs @@ -364,7 +364,7 @@ namespace OpenNest.Geometry public override bool Intersects(Arc arc) { List pts; - return Helper.Intersects(arc, this, out pts); + return Intersect.Intersects(arc, this, out pts); } /// @@ -375,7 +375,7 @@ namespace OpenNest.Geometry /// public override bool Intersects(Arc arc, out List pts) { - return Helper.Intersects(arc, this, out pts); + return Intersect.Intersects(arc, this, out pts); } /// @@ -386,7 +386,7 @@ namespace OpenNest.Geometry public override bool Intersects(Circle circle) { List pts; - return Helper.Intersects(circle, this, out pts); + return Intersect.Intersects(circle, this, out pts); } /// @@ -397,7 +397,7 @@ namespace OpenNest.Geometry /// public override bool Intersects(Circle circle, out List pts) { - return Helper.Intersects(circle, this, out pts); + return Intersect.Intersects(circle, this, out pts); } /// @@ -408,7 +408,7 @@ namespace OpenNest.Geometry public override bool Intersects(Line line) { List pts; - return Helper.Intersects(line, this, out pts); + return Intersect.Intersects(line, this, out pts); } /// @@ -419,7 +419,7 @@ namespace OpenNest.Geometry /// public override bool Intersects(Line line, out List pts) { - return Helper.Intersects(line, this, out pts); + return Intersect.Intersects(line, this, out pts); } /// @@ -430,7 +430,7 @@ namespace OpenNest.Geometry public override bool Intersects(Polygon polygon) { List pts; - return Helper.Intersects(this, polygon, out pts); + return Intersect.Intersects(this, polygon, out pts); } /// @@ -441,7 +441,7 @@ namespace OpenNest.Geometry /// public override bool Intersects(Polygon polygon, out List pts) { - return Helper.Intersects(this, polygon, out pts); + return Intersect.Intersects(this, polygon, out pts); } /// @@ -452,7 +452,7 @@ namespace OpenNest.Geometry public override bool Intersects(Shape shape) { List pts; - return Helper.Intersects(shape, this, out pts); + return Intersect.Intersects(shape, this, out pts); } /// @@ -463,7 +463,7 @@ namespace OpenNest.Geometry /// public override bool Intersects(Shape shape, out List pts) { - return Helper.Intersects(shape, this, out pts); + return Intersect.Intersects(shape, this, out pts); } /// @@ -493,13 +493,37 @@ namespace OpenNest.Geometry { 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++) { + 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++) { if (i == 0 && j == n - 1) 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)) { edgeI = i; diff --git a/OpenNest.Core/Geometry/Shape.cs b/OpenNest.Core/Geometry/Shape.cs index d65d393..03076d2 100644 --- a/OpenNest.Core/Geometry/Shape.cs +++ b/OpenNest.Core/Geometry/Shape.cs @@ -159,8 +159,8 @@ namespace OpenNest.Geometry } } - Helper.Optimize(lines); - Helper.Optimize(arcs); + GeometryOptimizer.Optimize(lines); + GeometryOptimizer.Optimize(arcs); } /// @@ -534,7 +534,7 @@ namespace OpenNest.Geometry { Vector intersection; - if (Helper.Intersects(offsetLine, lastOffsetLine, out intersection)) + if (Intersect.Intersects(offsetLine, lastOffsetLine, out intersection)) { offsetLine.StartPoint = intersection; lastOffsetLine.EndPoint = intersection; @@ -577,7 +577,7 @@ namespace OpenNest.Geometry public override bool Intersects(Arc arc) { List pts; - return Helper.Intersects(arc, this, out pts); + return Intersect.Intersects(arc, this, out pts); } /// @@ -588,7 +588,7 @@ namespace OpenNest.Geometry /// public override bool Intersects(Arc arc, out List pts) { - return Helper.Intersects(arc, this, out pts); + return Intersect.Intersects(arc, this, out pts); } /// @@ -599,7 +599,7 @@ namespace OpenNest.Geometry public override bool Intersects(Circle circle) { List pts; - return Helper.Intersects(circle, this, out pts); + return Intersect.Intersects(circle, this, out pts); } /// @@ -610,7 +610,7 @@ namespace OpenNest.Geometry /// public override bool Intersects(Circle circle, out List pts) { - return Helper.Intersects(circle, this, out pts); + return Intersect.Intersects(circle, this, out pts); } /// @@ -621,7 +621,7 @@ namespace OpenNest.Geometry public override bool Intersects(Line line) { List pts; - return Helper.Intersects(line, this, out pts); + return Intersect.Intersects(line, this, out pts); } /// @@ -632,7 +632,7 @@ namespace OpenNest.Geometry /// public override bool Intersects(Line line, out List pts) { - return Helper.Intersects(line, this, out pts); + return Intersect.Intersects(line, this, out pts); } /// @@ -643,7 +643,7 @@ namespace OpenNest.Geometry public override bool Intersects(Polygon polygon) { List pts; - return Helper.Intersects(this, polygon, out pts); + return Intersect.Intersects(this, polygon, out pts); } /// @@ -654,7 +654,7 @@ namespace OpenNest.Geometry /// public override bool Intersects(Polygon polygon, out List pts) { - return Helper.Intersects(this, polygon, out pts); + return Intersect.Intersects(this, polygon, out pts); } /// @@ -665,7 +665,7 @@ namespace OpenNest.Geometry public override bool Intersects(Shape shape) { List pts; - return Helper.Intersects(this, shape, out pts); + return Intersect.Intersects(this, shape, out pts); } /// @@ -676,7 +676,7 @@ namespace OpenNest.Geometry /// public override bool Intersects(Shape shape, out List pts) { - return Helper.Intersects(this, shape, out pts); + return Intersect.Intersects(this, shape, out pts); } /// diff --git a/OpenNest.Core/Geometry/ShapeBuilder.cs b/OpenNest.Core/Geometry/ShapeBuilder.cs new file mode 100644 index 0000000..8ae4cae --- /dev/null +++ b/OpenNest.Core/Geometry/ShapeBuilder.cs @@ -0,0 +1,150 @@ +using System.Collections.Generic; +using System.Diagnostics; +using OpenNest.Math; + +namespace OpenNest.Geometry +{ + public static class ShapeBuilder + { + public static List GetShapes(IEnumerable entities) + { + var lines = new List(); + var arcs = new List(); + var circles = new List(); + var shapes = new List(); + + var entities2 = new Queue(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(); + + 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 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; + } + } +} diff --git a/OpenNest.Core/Geometry/ShapeProfile.cs b/OpenNest.Core/Geometry/ShapeProfile.cs index 4889df3..69f3067 100644 --- a/OpenNest.Core/Geometry/ShapeProfile.cs +++ b/OpenNest.Core/Geometry/ShapeProfile.cs @@ -16,7 +16,7 @@ namespace OpenNest.Geometry private void Update(List entities) { - var shapes = Helper.GetShapes(entities); + var shapes = ShapeBuilder.GetShapes(entities); Perimeter = shapes[0]; Cutouts = new List(); diff --git a/OpenNest.Core/Geometry/SpatialQuery.cs b/OpenNest.Core/Geometry/SpatialQuery.cs new file mode 100644 index 0000000..dca54cf --- /dev/null +++ b/OpenNest.Core/Geometry/SpatialQuery.cs @@ -0,0 +1,614 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using OpenNest.Math; + +namespace OpenNest.Geometry +{ + public static class SpatialQuery + { + /// + /// 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. + /// + 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; + } + } + + /// + /// 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. + /// + public static double DirectionalDistance(List movingLines, List stationaryLines, PushDirection direction) + { + var minDist = double.MaxValue; + + // Case 1: Each moving vertex -> each stationary edge + var movingVertices = new HashSet(); + 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(); + 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; + } + + /// + /// Computes the minimum directional distance with the moving lines translated + /// by (movingDx, movingDy) without creating new Line objects. + /// + public static double DirectionalDistance( + List movingLines, double movingDx, double movingDy, + List 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(); + 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(); + 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; + } + + /// + /// Packs line segments into a flat double array [x1,y1,x2,y2, ...] for GPU transfer. + /// + public static double[] FlattenLines(List 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; + } + + /// + /// Computes the minimum directional distance using raw edge arrays and location offsets + /// to avoid all intermediate object allocations. + /// + 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(); + 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(); + 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 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 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 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 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 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 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); + } + } +} diff --git a/OpenNest.Core/Geometry/Vector.cs b/OpenNest.Core/Geometry/Vector.cs index 64dae7f..22eccda 100644 --- a/OpenNest.Core/Geometry/Vector.cs +++ b/OpenNest.Core/Geometry/Vector.cs @@ -3,7 +3,7 @@ using OpenNest.Math; namespace OpenNest.Geometry { - public struct Vector + public struct Vector : IEquatable { public static readonly Vector Invalid = new Vector(double.NaN, double.NaN); public static readonly Vector Zero = new Vector(0, 0); @@ -17,6 +17,29 @@ namespace OpenNest.Geometry 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) { var vx = pt.X - X; @@ -186,21 +209,6 @@ namespace OpenNest.Geometry 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() { return string.Format("[Vector: X:{0}, Y:{1}]", X, Y); diff --git a/OpenNest.Core/Helper.cs b/OpenNest.Core/Helper.cs deleted file mode 100644 index 5f0da8d..0000000 --- a/OpenNest.Core/Helper.cs +++ /dev/null @@ -1,1256 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Diagnostics; -using System.Linq; -using System.Threading.Tasks; -using OpenNest.Converters; -using OpenNest.Geometry; -using OpenNest.Math; - -namespace OpenNest -{ - public static class Helper - { - /// - /// Rounds a number down to the nearest factor. - /// - /// - /// - /// - public static double RoundDownToNearest(double num, double factor) - { - return factor.IsEqualTo(0) ? num : System.Math.Floor(num / factor) * factor; - } - - /// - /// Rounds a number up to the nearest factor. - /// - /// - /// - /// - public static double RoundUpToNearest(double num, double factor) - { - return factor.IsEqualTo(0) ? num : System.Math.Ceiling(num / factor) * factor; - } - - /// - /// Rounds a number to the nearest factor using midpoint rounding convention. - /// - /// - /// - /// - public static double RoundToNearest(double num, double factor) - { - return factor.IsEqualTo(0) ? num : System.Math.Round(num / factor) * factor; - } - - public static void Optimize(IList 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 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 GetCollinearLines(this IList lines, Line line, int startIndex) - { - var collinearLines = new List(); - - 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 GetCoradialArs(this IList arcs, Arc arc, int startIndex) - { - var coradialArcs = new List(); - - 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; - } - - public static List GetShapes(IEnumerable entities) - { - var lines = new List(); - var arcs = new List(); - var circles = new List(); - var shapes = new List(); - - var entities2 = new Queue(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(); - - 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 geometry) - { - var tol = Math.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; - } - - internal static bool Intersects(Arc arc1, Arc arc2, out List 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(); - 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 pts) - { - var c1 = new Circle(arc.Center, arc.Radius); - - if (!Intersects(c1, circle, out pts)) - { - pts = new List(); - 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 pts) - { - var c1 = new Circle(arc.Center, arc.Radius); - - if (!Intersects(c1, line, out pts)) - { - pts = new List(); - 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 pts) - { - var pts2 = new List(); - - foreach (var geo in shape.Entities) - { - List 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 pts) - { - var pts2 = new List(); - var lines = polygon.ToLines(); - - foreach (var line in lines) - { - List 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 pts) - { - var distance = circle1.Center.DistanceTo(circle2.Center); - - // check if circles are too far apart - if (distance > circle1.Radius + circle2.Radius) - { - pts = new List(); - return false; - } - - // check if one circle contains the other - if (distance < System.Math.Abs(circle1.Radius - circle2.Radius)) - { - pts = new List(); - 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 { i1, i2 } : new List { i1 }; - - return true; - } - - internal static bool Intersects(Circle circle, Line line, out List 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(); - return false; - } - - double t; - pts = new List(); - - 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 pts) - { - pts = new List(); - - foreach (var geo in shape.Entities) - { - List pts3; - geo.Intersects(circle, out pts3); - pts.AddRange(pts3); - } - - return pts.Count > 0; - } - - internal static bool Intersects(Circle circle, Polygon polygon, out List pts) - { - pts = new List(); - var lines = polygon.ToLines(); - - foreach (var line in lines) - { - List 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 pts) - { - pts = new List(); - - foreach (var geo in shape.Entities) - { - List pts3; - geo.Intersects(line, out pts3); - pts.AddRange(pts3); - } - - return pts.Count > 0; - } - - internal static bool Intersects(Line line, Polygon polygon, out List pts) - { - pts = new List(); - 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 pts) - { - pts = new List(); - - for (int i = 0; i < shape1.Entities.Count; i++) - { - var geo1 = shape1.Entities[i]; - - for (int j = 0; j < shape2.Entities.Count; j++) - { - List 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 pts) - { - pts = new List(); - - 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 pts2; - - if (geo.Intersects(line, out pts2)) - pts.AddRange(pts2); - } - } - - return pts.Count > 0; - } - - internal static bool Intersects(Polygon polygon1, Polygon polygon2, out List pts) - { - pts = new List(); - - 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; - } - - public static List GetPartLines(Part part, double chordTolerance = 0.001) - { - var entities = ConvertProgram.ToGeometry(part.Program); - var shapes = GetShapes(entities.Where(e => e.Layer != SpecialLayers.Rapid)); - var lines = new List(); - - foreach (var shape in shapes) - { - var polygon = shape.ToPolygonWithTolerance(chordTolerance); - polygon.Offset(part.Location); - lines.AddRange(polygon.ToLines()); - } - - return lines; - } - - public static List GetPartLines(Part part, PushDirection facingDirection, double chordTolerance = 0.001) - { - var entities = ConvertProgram.ToGeometry(part.Program); - var shapes = GetShapes(entities.Where(e => e.Layer != SpecialLayers.Rapid)); - var lines = new List(); - - foreach (var shape in shapes) - { - var polygon = shape.ToPolygonWithTolerance(chordTolerance); - polygon.Offset(part.Location); - lines.AddRange(GetDirectionalLines(polygon, facingDirection)); - } - - return lines; - } - - public static List GetOffsetPartLines(Part part, double spacing, double chordTolerance = 0.001) - { - var entities = ConvertProgram.ToGeometry(part.Program); - var shapes = GetShapes(entities.Where(e => e.Layer != SpecialLayers.Rapid)); - var lines = new List(); - - 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 GetOffsetPartLines(Part part, double spacing, PushDirection facingDirection, double chordTolerance = 0.001) - { - var entities = ConvertProgram.ToGeometry(part.Program); - var shapes = GetShapes(entities.Where(e => e.Layer != SpecialLayers.Rapid)); - var lines = new List(); - - 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; - } - - /// - /// Returns only polygon edges whose outward normal faces the specified direction. - /// - private static List 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(); - 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; - } - - /// - /// 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. - /// - private static double RayEdgeDistance(Vector vertex, Line edge, PushDirection direction) - { - var p1x = edge.pt1.X; - var p1y = edge.pt1.Y; - var p2x = edge.pt2.X; - var p2y = edge.pt2.Y; - - switch (direction) - { - case PushDirection.Left: - { - // Ray goes in -X direction. Need non-horizontal edge. - var dy = p2y - p1y; - if (dy > -Tolerance.Epsilon && dy < Tolerance.Epsilon) - return double.MaxValue; // horizontal edge, parallel to ray - - var t = (vertex.Y - p1y) / dy; - if (t < -Tolerance.Epsilon || t > 1.0 + Tolerance.Epsilon) - return double.MaxValue; - - var ix = p1x + t * (p2x - p1x); - var dist = vertex.X - ix; // positive if edge is to the left - if (dist > Tolerance.Epsilon) return dist; - if (dist >= -Tolerance.Epsilon) return 0; // touching - return double.MaxValue; // edge is behind vertex - } - - case PushDirection.Right: - { - var dy = p2y - p1y; - if (dy > -Tolerance.Epsilon && dy < Tolerance.Epsilon) - return double.MaxValue; - - var t = (vertex.Y - p1y) / dy; - if (t < -Tolerance.Epsilon || t > 1.0 + Tolerance.Epsilon) - return double.MaxValue; - - var ix = p1x + t * (p2x - p1x); - var dist = ix - vertex.X; - if (dist > Tolerance.Epsilon) return dist; - if (dist >= -Tolerance.Epsilon) return 0; // touching - return double.MaxValue; // edge is behind vertex - } - - case PushDirection.Down: - { - // Ray goes in -Y direction. Need non-vertical edge. - var dx = p2x - p1x; - if (dx > -Tolerance.Epsilon && dx < Tolerance.Epsilon) - return double.MaxValue; // vertical edge, parallel to ray - - var t = (vertex.X - p1x) / dx; - if (t < -Tolerance.Epsilon || t > 1.0 + Tolerance.Epsilon) - return double.MaxValue; - - var iy = p1y + t * (p2y - p1y); - var dist = vertex.Y - iy; - if (dist > Tolerance.Epsilon) return dist; - if (dist >= -Tolerance.Epsilon) return 0; // touching - return double.MaxValue; // edge is behind vertex - } - - case PushDirection.Up: - { - var dx = p2x - p1x; - if (dx > -Tolerance.Epsilon && dx < Tolerance.Epsilon) - return double.MaxValue; - - var t = (vertex.X - p1x) / dx; - if (t < -Tolerance.Epsilon || t > 1.0 + Tolerance.Epsilon) - return double.MaxValue; - - var iy = p1y + t * (p2y - p1y); - var dist = iy - vertex.Y; - if (dist > Tolerance.Epsilon) return dist; - if (dist >= -Tolerance.Epsilon) return 0; // touching - return double.MaxValue; // edge is behind vertex - } - - default: - return double.MaxValue; - } - } - - /// - /// 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. - /// - public static double DirectionalDistance(List movingLines, List stationaryLines, PushDirection direction) - { - var minDist = double.MaxValue; - - // Case 1: Each moving vertex → each stationary edge - for (int i = 0; i < movingLines.Count; i++) - { - var movingStart = movingLines[i].pt1; - var movingEnd = movingLines[i].pt2; - - for (int j = 0; j < stationaryLines.Count; j++) - { - var d = RayEdgeDistance(movingStart, stationaryLines[j], direction); - if (d < minDist) minDist = d; - - d = RayEdgeDistance(movingEnd, stationaryLines[j], direction); - if (d < minDist) minDist = d; - } - } - - // Case 2: Each stationary vertex → each moving edge (opposite direction) - var opposite = OppositeDirection(direction); - - for (int i = 0; i < stationaryLines.Count; i++) - { - var stationaryStart = stationaryLines[i].pt1; - var stationaryEnd = stationaryLines[i].pt2; - - for (int j = 0; j < movingLines.Count; j++) - { - var d = RayEdgeDistance(stationaryStart, movingLines[j], opposite); - if (d < minDist) minDist = d; - - d = RayEdgeDistance(stationaryEnd, movingLines[j], opposite); - 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 double ClosestDistanceLeft(Box box, List 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 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 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 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 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 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); - } - } -} diff --git a/OpenNest.Core/Math/Rounding.cs b/OpenNest.Core/Math/Rounding.cs new file mode 100644 index 0000000..8f346ce --- /dev/null +++ b/OpenNest.Core/Math/Rounding.cs @@ -0,0 +1,38 @@ +namespace OpenNest.Math +{ + public static class Rounding + { + /// + /// Rounds a number down to the nearest factor. + /// + /// + /// + /// + public static double RoundDownToNearest(double num, double factor) + { + return factor.IsEqualTo(0) ? num : System.Math.Floor(num / factor) * factor; + } + + /// + /// Rounds a number up to the nearest factor. + /// + /// + /// + /// + public static double RoundUpToNearest(double num, double factor) + { + return factor.IsEqualTo(0) ? num : System.Math.Ceiling(num / factor) * factor; + } + + /// + /// Rounds a number to the nearest factor using midpoint rounding convention. + /// + /// + /// + /// + public static double RoundToNearest(double num, double factor) + { + return factor.IsEqualTo(0) ? num : System.Math.Round(num / factor) * factor; + } + } +} diff --git a/OpenNest.Core/Part.cs b/OpenNest.Core/Part.cs index f190ce0..37b12f0 100644 --- a/OpenNest.Core/Part.cs +++ b/OpenNest.Core/Part.cs @@ -51,6 +51,8 @@ namespace OpenNest public Program Program { get; private set; } + public bool HasManualLeadIns { get; set; } + /// /// Gets the rotation of the part in radians. /// @@ -149,31 +151,25 @@ namespace OpenNest pts = new List(); var entities1 = ConvertProgram.ToGeometry(Program) - .Where(e => e.Layer != SpecialLayers.Rapid); + .Where(e => e.Layer != SpecialLayers.Rapid) + .ToList(); var entities2 = ConvertProgram.ToGeometry(part.Program) - .Where(e => e.Layer != SpecialLayers.Rapid); + .Where(e => e.Layer != SpecialLayers.Rapid) + .ToList(); - var shapes1 = Helper.GetShapes(entities1); - var shapes2 = Helper.GetShapes(entities2); + if (entities1.Count == 0 || entities2.Count == 0) + return false; - shapes1.ForEach(shape => shape.Offset(Location)); - shapes2.ForEach(shape => shape.Offset(part.Location)); + var perimeter1 = new ShapeProfile(entities1).Perimeter; + var perimeter2 = new ShapeProfile(entities2).Perimeter; - for (int i = 0; i < shapes1.Count; i++) - { - var shape1 = shapes1[i]; + if (perimeter1 == null || perimeter2 == null) + return false; - for (int j = 0; j < shapes2.Count; j++) - { - var shape2 = shapes2[j]; - List pts2; + perimeter1.Offset(Location); + perimeter2.Offset(part.Location); - if (shape1.Intersects(shape2, out pts2)) - pts.AddRange(pts2); - } - } - - return pts.Count > 0; + return perimeter1.Intersects(perimeter2, out pts); } public double Left @@ -216,8 +212,9 @@ namespace OpenNest /// public Part CloneAtOffset(Vector offset) { - var clonedProgram = Program.Clone() as Program; - var part = new Part(BaseDrawing, clonedProgram, + // Share the Program instance — offset-only copies don't modify the program codes. + // This is a major performance win for tiling large patterns. + var part = new Part(BaseDrawing, Program, location + offset, new Box(BoundingBox.X + offset.X, BoundingBox.Y + offset.Y, BoundingBox.Width, BoundingBox.Length)); diff --git a/OpenNest.Core/PartGeometry.cs b/OpenNest.Core/PartGeometry.cs new file mode 100644 index 0000000..be5c3d7 --- /dev/null +++ b/OpenNest.Core/PartGeometry.cs @@ -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 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(); + + foreach (var shape in shapes) + { + var polygon = shape.ToPolygonWithTolerance(chordTolerance); + polygon.Offset(part.Location); + lines.AddRange(polygon.ToLines()); + } + + return lines; + } + + public static List 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(); + + foreach (var shape in shapes) + { + var polygon = shape.ToPolygonWithTolerance(chordTolerance); + polygon.Offset(part.Location); + lines.AddRange(GetDirectionalLines(polygon, facingDirection)); + } + + return lines; + } + + public static List 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(); + + 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 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(); + + 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; + } + + /// + /// Returns only polygon edges whose outward normal faces the specified direction. + /// + private static List 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(); + 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; + } + } +} diff --git a/OpenNest.Core/Plate.cs b/OpenNest.Core/Plate.cs index 4bc49b7..5005529 100644 --- a/OpenNest.Core/Plate.cs +++ b/OpenNest.Core/Plate.cs @@ -412,8 +412,8 @@ namespace OpenNest } Size = new Size( - Helper.RoundUpToNearest(width, roundingFactor), - Helper.RoundUpToNearest(length, roundingFactor)); + Rounding.RoundUpToNearest(width, roundingFactor), + Rounding.RoundUpToNearest(length, roundingFactor)); } /// diff --git a/OpenNest.Core/Timing.cs b/OpenNest.Core/Timing.cs index 11b85c8..7e1b401 100644 --- a/OpenNest.Core/Timing.cs +++ b/OpenNest.Core/Timing.cs @@ -11,7 +11,7 @@ namespace OpenNest public static TimingInfo GetTimingInfo(Program 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 last = entities[0]; diff --git a/OpenNest.Engine/AutoNester.cs b/OpenNest.Engine/AutoNester.cs new file mode 100644 index 0000000..0140544 --- /dev/null +++ b/OpenNest.Engine/AutoNester.cs @@ -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 +{ + /// + /// Mixed-part geometry-aware nesting using NFP-based collision avoidance + /// and simulated annealing optimization. + /// + public static class AutoNester + { + public static List Nest(List items, Plate plate, + CancellationToken cancellation = default) + { + var workArea = plate.WorkArea(); + var halfSpacing = plate.PartSpacing / 2.0; + var nfpCache = new NfpCache(); + var candidateRotations = new Dictionary>(); + + // 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(); + + // 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(); + + // 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; + } + + /// + /// Extracts the perimeter polygon from a drawing, inflated by half-spacing. + /// + 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; + } + + /// + /// Computes candidate rotation angles for a drawing. + /// + private static List ComputeCandidateRotations(NestItem item, + Polygon perimeterPolygon, Box workArea) + { + var rotations = new List { 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; + } + + /// + /// Computes convex hull edge angles from a polygon for candidate rotations. + /// + private static List ComputeHullEdgeAngles(Polygon polygon) + { + var angles = new List(); + + 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; + } + + /// + /// Creates a rotated copy of a polygon around the origin. + /// + 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; + } + } +} diff --git a/OpenNest.Engine/BestFit/BestFitCache.cs b/OpenNest.Engine/BestFit/BestFitCache.cs index 04b7613..8a12faa 100644 --- a/OpenNest.Engine/BestFit/BestFitCache.cs +++ b/OpenNest.Engine/BestFit/BestFitCache.cs @@ -13,6 +13,7 @@ namespace OpenNest.Engine.BestFit new ConcurrentDictionary>(); public static Func CreateEvaluator { get; set; } + public static Func CreateSlideComputer { get; set; } public static List GetOrCompute( Drawing drawing, double plateWidth, double plateHeight, @@ -24,6 +25,7 @@ namespace OpenNest.Engine.BestFit return cached; IPairEvaluator evaluator = null; + ISlideComputer slideComputer = null; try { @@ -33,13 +35,107 @@ namespace OpenNest.Engine.BestFit 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); _cache.TryAdd(key, results); return results; } 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(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(); } @@ -54,6 +150,28 @@ namespace OpenNest.Engine.BestFit } } + public static void Populate(Drawing drawing, double plateWidth, double plateHeight, + double spacing, List 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> + GetAllForDrawing(Drawing drawing) + { + var result = new Dictionary<(double, double, double), List>(); + 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() { _cache.Clear(); diff --git a/OpenNest.Engine/BestFit/BestFitFinder.cs b/OpenNest.Engine/BestFit/BestFitFinder.cs index 89d0681..49a4121 100644 --- a/OpenNest.Engine/BestFit/BestFitFinder.cs +++ b/OpenNest.Engine/BestFit/BestFitFinder.cs @@ -12,15 +12,21 @@ namespace OpenNest.Engine.BestFit public class BestFitFinder { private readonly IPairEvaluator _evaluator; + private readonly ISlideComputer _slideComputer; 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(); + _slideComputer = slideComputer; + var plateAspect = System.Math.Max(maxPlateWidth, maxPlateHeight) / + System.Math.Max(System.Math.Min(maxPlateWidth, maxPlateHeight), 0.001); _filter = new BestFitFilter { 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) { 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; @@ -102,6 +108,7 @@ namespace OpenNest.Engine.BestFit AddUniqueAngle(angles, Angle.NormalizeRad(hullAngle + System.Math.PI)); } + angles.Sort(); return angles; } @@ -109,14 +116,30 @@ namespace OpenNest.Engine.BestFit { var entities = ConvertProgram.ToGeometry(drawing.Program) .Where(e => e.Layer != SpecialLayers.Rapid); - var shapes = Helper.GetShapes(entities); + var shapes = ShapeBuilder.GetShapes(entities); var points = new List(); foreach (var shape in shapes) { - var polygon = shape.ToPolygonWithTolerance(0.01); - points.AddRange(polygon.Vertices); + // Extract key points from original geometry — line endpoints + // 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) @@ -143,13 +166,49 @@ namespace OpenNest.Engine.BestFit return hullAngles; } + /// + /// Adds the cardinal extreme points of an arc (0°, 90°, 180°, 270°) + /// if they fall within the arc's angular span. + /// + private static void AddArcExtremes(List 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)); + } + + /// + /// 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. + /// + private const double AngleTolerance = System.Math.PI / 36; // 5 degrees + private static void AddUniqueAngle(List angles, double angle) { angle = Angle.NormalizeRad(angle); foreach (var existing in angles) { - if (existing.IsEqualTo(angle)) + if (existing.IsEqualTo(angle, AngleTolerance)) return; } diff --git a/OpenNest.Engine/BestFit/BestFitResult.cs b/OpenNest.Engine/BestFit/BestFitResult.cs index 169f478..97ce07c 100644 --- a/OpenNest.Engine/BestFit/BestFitResult.cs +++ b/OpenNest.Engine/BestFit/BestFitResult.cs @@ -14,6 +14,7 @@ namespace OpenNest.Engine.BestFit public bool Keep { get; set; } public string Reason { get; set; } public double TrueArea { get; set; } + public List HullAngles { get; set; } public double Utilization { diff --git a/OpenNest.Engine/BestFit/ISlideComputer.cs b/OpenNest.Engine/BestFit/ISlideComputer.cs new file mode 100644 index 0000000..9096f9a --- /dev/null +++ b/OpenNest.Engine/BestFit/ISlideComputer.cs @@ -0,0 +1,38 @@ +using System; + +namespace OpenNest.Engine.BestFit +{ + /// + /// Batches directional-distance computations for multiple offset positions. + /// GPU implementations can process all offsets in a single kernel launch. + /// + public interface ISlideComputer : IDisposable + { + /// + /// Computes the minimum directional distance for each offset position. + /// + /// Flat array [x1,y1,x2,y2, ...] for stationary edges. + /// Number of line segments in stationarySegments. + /// Flat array [x1,y1,x2,y2, ...] for moving edges at origin. + /// Number of line segments in movingTemplateSegments. + /// Flat array [dx,dy, dx,dy, ...] of translation offsets. + /// Number of offset positions. + /// Push direction. + /// Array of minimum distances, one per offset position. + double[] ComputeBatch( + double[] stationarySegments, int stationaryCount, + double[] movingTemplateSegments, int movingCount, + double[] offsets, int offsetCount, + PushDirection direction); + + /// + /// Computes minimum directional distance for offsets with per-offset directions. + /// Uploads segment data once for all offsets, reducing GPU round-trips. + /// + double[] ComputeBatchMultiDir( + double[] stationarySegments, int stationaryCount, + double[] movingTemplateSegments, int movingCount, + double[] offsets, int offsetCount, + int[] directions); + } +} diff --git a/OpenNest.Engine/BestFit/PairEvaluator.cs b/OpenNest.Engine/BestFit/PairEvaluator.cs index 5224820..c48f1f0 100644 --- a/OpenNest.Engine/BestFit/PairEvaluator.cs +++ b/OpenNest.Engine/BestFit/PairEvaluator.cs @@ -42,6 +42,7 @@ namespace OpenNest.Engine.BestFit // Find optimal bounding rectangle via rotating calipers double bestArea, bestWidth, bestHeight, bestRotation; + List hullAngles = null; if (allPoints.Count >= 3) { @@ -51,6 +52,7 @@ namespace OpenNest.Engine.BestFit bestWidth = result.Width; bestHeight = result.Height; bestRotation = result.Angle; + hullAngles = RotationAnalysis.GetHullEdgeAngles(hull); } else { @@ -59,6 +61,7 @@ namespace OpenNest.Engine.BestFit bestWidth = combinedBox.Width; bestHeight = combinedBox.Length; bestRotation = 0; + hullAngles = new List { 0 }; } var trueArea = drawing.Area * 2; @@ -71,6 +74,7 @@ namespace OpenNest.Engine.BestFit BoundingHeight = bestHeight, OptimalRotation = bestRotation, TrueArea = trueArea, + HullAngles = hullAngles, Keep = !overlaps, Reason = overlaps ? "Overlap detected" : "Valid" }; @@ -99,7 +103,7 @@ namespace OpenNest.Engine.BestFit { var entities = ConvertProgram.ToGeometry(part.Program) .Where(e => e.Layer != SpecialLayers.Rapid); - var shapes = Helper.GetShapes(entities); + var shapes = ShapeBuilder.GetShapes(entities); shapes.ForEach(s => s.Offset(part.Location)); return shapes; } @@ -108,7 +112,7 @@ namespace OpenNest.Engine.BestFit { var entities = ConvertProgram.ToGeometry(part.Program) .Where(e => e.Layer != SpecialLayers.Rapid); - var shapes = Helper.GetShapes(entities); + var shapes = ShapeBuilder.GetShapes(entities); var points = new List(); foreach (var shape in shapes) diff --git a/OpenNest.Engine/BestFit/RotationSlideStrategy.cs b/OpenNest.Engine/BestFit/RotationSlideStrategy.cs index 59ddd21..9308ec3 100644 --- a/OpenNest.Engine/BestFit/RotationSlideStrategy.cs +++ b/OpenNest.Engine/BestFit/RotationSlideStrategy.cs @@ -1,15 +1,25 @@ using System.Collections.Generic; +using System.Linq; using OpenNest.Geometry; namespace OpenNest.Engine.BestFit { 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; Type = type; Description = description; + _slideComputer = slideComputer; } public double Part2Rotation { get; } @@ -23,43 +33,64 @@ namespace OpenNest.Engine.BestFit var part1 = Part.CreateAtOrigin(drawing); 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(); + var allDy = new List(); + var allDirs = new List(); + + 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; - // Try pushing left (horizontal slide) - GenerateCandidatesForAxis( - part1, part2Template, drawing, spacing, stepSize, - PushDirection.Left, candidates, ref testNumber); + for (var i = 0; i < allDx.Count; i++) + { + var slideDist = distances[i]; + if (slideDist >= double.MaxValue || slideDist < 0) + continue; - // Try pushing down (vertical slide) - GenerateCandidatesForAxis( - part1, part2Template, drawing, spacing, stepSize, - PushDirection.Down, candidates, ref testNumber); + var dx = allDx[i]; + var dy = allDy[i]; + var pushVector = GetPushVector(allDirs[i], slideDist); + 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) - GenerateCandidatesForAxis( - part1, part2Template, drawing, spacing, stepSize, - PushDirection.Right, candidates, ref testNumber); - - // Try pushing up (approach from below — finds concave interlocking) - GenerateCandidatesForAxis( - part1, part2Template, drawing, spacing, stepSize, - PushDirection.Up, candidates, ref testNumber); + candidates.Add(new PairCandidate + { + Drawing = drawing, + Part1Rotation = 0, + Part2Rotation = Part2Rotation, + Part2Offset = finalPosition, + StrategyType = Type, + TestNumber = testNumber++, + Spacing = spacing + }); + } return candidates; } - private void GenerateCandidatesForAxis( - Part part1, Part part2Template, Drawing drawing, - double spacing, double stepSize, PushDirection pushDir, - List candidates, ref int testNumber) + private static void BuildOffsets( + Box bbox1, Box bbox2, double spacing, double stepSize, + PushDirection pushDir, List allDx, List allDy, + List 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; double perpMin, perpMax, pushStartOffset; @@ -77,103 +108,124 @@ namespace OpenNest.Engine.BestFit 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. - 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) + for (var offset = alignedStart; offset <= perpMax; offset += 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 part1Lines, List part2TemplateLines, + List allDx, List allDy, List 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; - - for (var offset = alignedStart; offset <= regionMax; offset += currentStep) - { - var slideDist = ComputeSlideDistance( - part2Template, part1Lines, halfSpacing, - offset, pushStartOffset, isHorizontalPush, pushDir); - - if (slideDist >= double.MaxValue || slideDist < 0) - continue; - - hits.Add((offset, slideDist)); - } + offsets[i * 2] = allDx[i]; + offsets[i * 2 + 1] = allDy[i]; + directions[i] = (int)allDirs[i]; } - if (hits.Count == 0) - return; - - // Select top regions by tightest fit, deduplicating nearby hits. - hits.Sort((a, b) => a.slideDist.CompareTo(b.slideDist)); - - var selectedOffsets = new List(); - - 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; + return _slideComputer.ComputeBatchMultiDir( + stationarySegments, part1Lines.Count, + movingSegments, part2TemplateLines.Count, + offsets, count, directions); } - // Final pass: sweep refined regions at stepSize, generating candidates. - foreach (var (regionMin, regionMax) in regions) + var results = new double[count]; + + // Pre-calculate moving vertices in local space. + var movingVerticesLocal = new HashSet(); + for (var i = 0; i < part2TemplateLines.Count; i++) { - var alignedStart = System.Math.Ceiling(regionMin / stepSize) * stepSize; - - 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 - }); - } + movingVerticesLocal.Add(part2TemplateLines[i].StartPoint); + movingVerticesLocal.Add(part2TemplateLines[i].EndPoint); } + var movingVerticesArray = movingVerticesLocal.ToArray(); + + // Pre-calculate stationary vertices in local space. + var stationaryVerticesLocal = new HashSet(); + 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(); + var movingEdgesByDir = new Dictionary(); + + 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) @@ -187,48 +239,5 @@ namespace OpenNest.Engine.BestFit default: return Vector.Zero; } } - private static double ComputeSlideDistance( - Part part2Template, List 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 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); - } } } diff --git a/OpenNest.Engine/Compactor.cs b/OpenNest.Engine/Compactor.cs new file mode 100644 index 0000000..f7bc551 --- /dev/null +++ b/OpenNest.Engine/Compactor.cs @@ -0,0 +1,156 @@ +using System.Collections.Generic; +using System.Linq; +using OpenNest.Geometry; + +namespace OpenNest +{ + /// + /// 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. + /// + public static class Compactor + { + private const double ChordTolerance = 0.001; + + /// + /// Compacts movingParts toward the bottom-left of the plate work area. + /// Everything already on the plate (excluding movingParts) is treated + /// as stationary obstacles. + /// + private const double RepeatThreshold = 0.01; + private const int MaxIterations = 20; + + public static void Compact(List 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 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 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 parts, Vector[] positions) + { + for (var i = 0; i < parts.Count; i++) + parts[i].Location = positions[i]; + } + + public static double Push(List 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[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 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; + } + } +} diff --git a/OpenNest.Engine/DefaultNestEngine.cs b/OpenNest.Engine/DefaultNestEngine.cs new file mode 100644 index 0000000..827ad7d --- /dev/null +++ b/OpenNest.Engine/DefaultNestEngine.cs @@ -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 knownGoodAngles = new(); + + // --- Public Fill API --- + + public override List Fill(NestItem item, Box workArea, + IProgress 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(); + + if (item.Quantity > 0 && best.Count > item.Quantity) + best = best.Take(item.Quantity).ToList(); + + return best; + } + + /// + /// 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. + /// + 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 { 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 Fill(List groupParts, Box workArea, + IProgress progress, CancellationToken token) + { + if (groupParts == null || groupParts.Count == 0) + return new List(); + + 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(); + } + + // --- Pack API --- + + public override List PackArea(Box box, List items, + IProgress 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 FindBestFill(NestItem item, Box workArea, + IProgress progress = null, CancellationToken token = default) + { + List 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(); + } + + // --- Angle building --- + + private List BuildCandidateAngles(NestItem item, double bestRotation, Box workArea) + { + var angles = new List { 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(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 { 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 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 { item }); + } + + private List FillWithPairs(NestItem item, Box workArea, + CancellationToken token = default, IProgress 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 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(); + } + + /// + /// 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. + /// + private List SelectPairCandidates(List 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(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 groupParts, double angle) + { + var pattern = new Pattern(); + var center = ((IEnumerable)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 FillPattern(FillLinear engine, List groupParts, List angles, Box workArea) + { + var results = new System.Collections.Concurrent.ConcurrentBag<(List 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 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 TryRemainderImprovement(NestItem item, Box workArea, List currentBest) + { + if (currentBest == null || currentBest.Count < 3) + return null; + + List 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 TryStripRefill(NestItem item, Box workArea, List 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)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(mainParts.Count + stripParts.Count); + combined.AddRange(mainParts); + combined.AddRange(stripParts); + return combined; + } + + /// + /// 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. + /// + private static List> ClusterParts(List 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>(); + var current = new List { 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(); + } + + current.Add(sorted[i]); + } + + clusters.Add(current); + return clusters; + } + + } +} diff --git a/OpenNest.Engine/FillLinear.cs b/OpenNest.Engine/FillLinear.cs index 584f5fe..92e6415 100644 --- a/OpenNest.Engine/FillLinear.cs +++ b/OpenNest.Engine/FillLinear.cs @@ -1,4 +1,5 @@ using System.Collections.Generic; +using System.Threading.Tasks; using OpenNest.Geometry; using OpenNest.Math; @@ -77,17 +78,16 @@ namespace OpenNest { var bboxDim = GetDimension(partA.BoundingBox, 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); - var stationaryLines = boundary.GetLines(partA.Location, opposite); - var slideDistance = Helper.DirectionalDistance(movingLines, stationaryLines, pushDir); + // Use the most efficient array-based overload to avoid all allocations. + var slideDistance = SpatialQuery.DirectionalDistance( + boundary.GetEdges(pushDir), partA.Location + locationBOffset, + boundary.GetEdges(SpatialQuery.OppositeDirection(pushDir)), partA.Location, + pushDir); - var copyDist = 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; + return ComputeCopyDistance(bboxDim, slideDistance); } /// @@ -103,11 +103,10 @@ namespace OpenNest var bboxDim = GetDimension(patternA.BoundingBox, 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 // patternB has its offset geometry beyond patternA's offset geometry. - // max(aUpper_i - bLower_j) = max(aUpper) - min(bLower). var maxUpper = double.MinValue; var minLower = double.MaxValue; @@ -126,22 +125,28 @@ namespace OpenNest var offset = MakeOffset(direction, startOffset); - // Pre-compute stationary lines for patternA parts. - var stationaryCache = new List[patternA.Parts.Count]; + // Pre-cache edge arrays. + 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++) - 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; for (var j = 0; j < patternA.Parts.Count; j++) { var locationB = patternA.Parts[j].Location + offset; - var movingLines = boundaries[j].GetLines(locationB, pushDir); 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) continue; @@ -153,9 +158,7 @@ namespace OpenNest } } - // Fallback: if no pair interacted (shouldn't happen for real parts), - // use the simple bounding-box + spacing distance. - if (maxCopyDistance <= 0) + if (maxCopyDistance < Tolerance.Epsilon) return bboxDim + PartSpacing; return maxCopyDistance; @@ -166,19 +169,8 @@ namespace OpenNest /// private double FindSinglePartPatternCopyDistance(Pattern patternA, NestDirection direction, PartBoundary boundary) { - var bboxDim = GetDimension(patternA.BoundingBox, direction); - var pushDir = GetPushDirection(direction); - 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; + var template = patternA.Parts[0]; + return FindCopyDistance(template, direction, boundary); } /// @@ -330,54 +322,46 @@ namespace OpenNest } /// - /// Recursively fills the work area. At depth 0, tiles the pattern along the - /// primary axis, then recurses perpendicular. At depth 1, tiles and returns. + /// Fills the work area by tiling the pattern along the primary axis to form + /// 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. /// - private List FillRecursive(Pattern pattern, NestDirection direction, int depth) + private List FillGrid(Pattern pattern, NestDirection direction) { + var perpAxis = PerpendicularAxis(direction); var boundaries = CreateBoundaries(pattern); - var result = new List(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(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(); - rowPattern.Parts.AddRange(result); - 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; + row.AddRange(TilePattern(pattern, perpAxis, boundaries)); + return row; } - if (depth == 0) - { - // Single part didn't tile along primary — still try perpendicular. - return FillRecursive(pattern, PerpendicularAxis(direction), depth + 1); - } + // Step 2: Build row pattern and tile along perpendicular axis + var rowPattern = new Pattern(); + rowPattern.Parts.AddRange(row); + rowPattern.UpdateBounds(); - return result; + var rowBoundaries = CreateBoundaries(rowPattern); + var gridResult = new List(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; } /// @@ -390,37 +374,16 @@ namespace OpenNest { 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) - { - //System.Diagnostics.Debug.WriteLine($"[TryFewerRows] Skipped: too few parts for 2 rows"); return null; - } - // Remove the last row's worth of parts. var fewerParts = new List(fullResult.Count - rowPartCount); for (var i = 0; i < fullResult.Count - rowPartCount; 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); - //System.Diagnostics.Debug.WriteLine($"[TryFewerRows] Remainder fill: {remaining.Count} parts (need > {rowPartCount} to improve)"); - if (remaining.Count <= rowPartCount) return null; @@ -438,7 +401,18 @@ namespace OpenNest List placedParts, Pattern seedPattern, 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(); + + var rotations = BuildRotationSet(seedPattern); + return FindBestFill(rotations, remainingStrip); + } + + private static double FindPlacedEdge(List placedParts, NestDirection tiledAxis) + { var placedEdge = double.MinValue; foreach (var part in placedParts) @@ -451,18 +425,20 @@ namespace OpenNest placedEdge = edge; } - // Build the remaining strip with a spacing gap from the last tiled row. - Box remainingStrip; + return placedEdge; + } + private Box BuildRemainingStrip(double placedEdge, NestDirection tiledAxis) + { if (tiledAxis == NestDirection.Vertical) { var bottom = placedEdge + PartSpacing; var height = WorkArea.Top - bottom; if (height <= Tolerance.Epsilon) - return new List(); + return null; - remainingStrip = new Box(WorkArea.X, bottom, WorkArea.Width, height); + return new Box(WorkArea.X, bottom, WorkArea.Width, height); } else { @@ -470,18 +446,20 @@ namespace OpenNest var width = WorkArea.Right - left; if (width <= Tolerance.Epsilon) - return new List(); + 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°), - // plus any unique rotations from the seed pattern. - var filler = new FillLinear(remainingStrip, PartSpacing); - List best = null; + /// + /// Builds a set of (drawing, rotation) candidates: cardinal orientations + /// (0° and 90°) for each unique drawing, plus any seed pattern rotations + /// not already covered. + /// + private static List<(Drawing drawing, double rotation)> BuildRotationSet(Pattern seedPattern) + { var rotations = new List<(Drawing drawing, double rotation)>(); - - // Cardinal rotations for each unique drawing. var drawings = new List(); foreach (var seedPart in seedPattern.Parts) @@ -507,7 +485,6 @@ namespace OpenNest rotations.Add((drawing, Angle.HalfPI)); } - // Add seed pattern rotations that aren't already covered. foreach (var seedPart in seedPattern.Parts) { var skip = false; @@ -525,13 +502,22 @@ namespace OpenNest rotations.Add((seedPart.BaseDrawing, seedPart.Rotation)); } + return rotations; + } + + /// + /// Tries all rotation candidates in both directions in parallel, returns the + /// fill with the most parts. + /// + private List FindBestFill(List<(Drawing drawing, double rotation)> rotations, Box strip) + { var bag = new System.Collections.Concurrent.ConcurrentBag>(); - System.Threading.Tasks.Parallel.ForEach(rotations, entry => + Parallel.ForEach(rotations, entry => { - var localFiller = new FillLinear(remainingStrip, PartSpacing); - var h = localFiller.Fill(entry.drawing, entry.rotation, NestDirection.Horizontal); - var v = localFiller.Fill(entry.drawing, entry.rotation, NestDirection.Vertical); + var filler = new FillLinear(strip, PartSpacing); + var h = filler.Fill(entry.drawing, entry.rotation, NestDirection.Horizontal); + var v = filler.Fill(entry.drawing, entry.rotation, NestDirection.Vertical); if (h != null && h.Count > 0) bag.Add(h); @@ -540,6 +526,8 @@ namespace OpenNest bag.Add(v); }); + List best = null; + foreach (var candidate in bag) { if (best == null || candidate.Count > best.Count) @@ -604,7 +592,7 @@ namespace OpenNest basePattern.BoundingBox.Length > WorkArea.Length + Tolerance.Epsilon) return new List(); - return FillRecursive(basePattern, primaryAxis, depth: 0); + return FillGrid(basePattern, primaryAxis); } /// @@ -618,7 +606,7 @@ namespace OpenNest if (seed.Parts.Count == 0) return new List(); - return FillRecursive(seed, primaryAxis, depth: 0); + return FillGrid(seed, primaryAxis); } } } diff --git a/OpenNest.Engine/FillScore.cs b/OpenNest.Engine/FillScore.cs index 8c1dd2b..74f3954 100644 --- a/OpenNest.Engine/FillScore.cs +++ b/OpenNest.Engine/FillScore.cs @@ -41,39 +41,32 @@ namespace OpenNest return default; 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) + { totalPartArea += part.BaseDrawing.Area; + var bb = part.BoundingBox; - var bbox = ((IEnumerable)parts).GetBoundingBox(); - var bboxArea = bbox.Area(); + if (bb.Left < minX) minX = bb.Left; + 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 usableRemnantArea = ComputeUsableRemnantArea(parts, workArea); + var usableRemnantArea = ComputeUsableRemnantArea(maxX, maxY, workArea); return new FillScore(parts.Count, usableRemnantArea, density); } - /// - /// Finds the largest usable remnant (short side >= MinRemnantDimension) - /// by checking right and top edge strips between placed parts and the work area boundary. - /// - private static double ComputeUsableRemnantArea(List parts, Box workArea) + private static double ComputeUsableRemnantArea(double maxRight, double maxTop, 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; // Right strip diff --git a/OpenNest.Engine/ML/AnglePredictor.cs b/OpenNest.Engine/ML/AnglePredictor.cs new file mode 100644 index 0000000..da864a8 --- /dev/null +++ b/OpenNest.Engine/ML/AnglePredictor.cs @@ -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 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(input, new[] { 1, 11 }); + var inputs = new List + { + NamedOnnxValue.CreateFromTensor("features", tensor) + }; + + using var results = session.Run(inputs); + var probabilities = results.First().AsEnumerable().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; + } + } + } +} diff --git a/OpenNest.Engine/ML/BruteForceRunner.cs b/OpenNest.Engine/ML/BruteForceRunner.cs new file mode 100644 index 0000000..a29e272 --- /dev/null +++ b/OpenNest.Engine/ML/BruteForceRunner.cs @@ -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 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 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 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 parts, double plateArea) + { + if (plateArea <= 0) return 0; + return parts.Sum(p => p.BaseDrawing.Area) / plateArea; + } + } +} diff --git a/OpenNest.Engine/ML/FeatureExtractor.cs b/OpenNest.Engine/ML/FeatureExtractor.cs new file mode 100644 index 0000000..c940105 --- /dev/null +++ b/OpenNest.Engine/ML/FeatureExtractor.cs @@ -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; + } + } +} diff --git a/OpenNest.Engine/Models/.gitkeep b/OpenNest.Engine/Models/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/OpenNest.Engine/NestEngine.cs b/OpenNest.Engine/NestEngine.cs deleted file mode 100644 index 8364c70..0000000 --- a/OpenNest.Engine/NestEngine.cs +++ /dev/null @@ -1,1084 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Diagnostics; -using System.Linq; -using System.Threading; -using OpenNest.Converters; -using OpenNest.Engine.BestFit; -using OpenNest.Geometry; -using OpenNest.Math; -using OpenNest.RectanglePacking; - -namespace OpenNest -{ - public class NestEngine - { - public NestEngine(Plate plate) - { - Plate = plate; - } - - public Plate Plate { get; set; } - - public NestDirection NestDirection { get; set; } - - public int PlateNumber { get; set; } - - public bool Fill(NestItem item) - { - return Fill(item, Plate.WorkArea()); - } - - public bool Fill(List groupParts) - { - return Fill(groupParts, 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 List Fill(NestItem item, Box workArea, - IProgress progress, CancellationToken token) - { - var best = FindBestFill(item, workArea, progress, token); - - if (token.IsCancellationRequested) - return best ?? new List(); - - // Try improving by filling the remainder strip separately. - var improved = TryRemainderImprovement(item, workArea, best); - - if (IsBetterFill(improved, best, workArea)) - { - Debug.WriteLine($"[Fill] Remainder improvement: {improved.Count} parts (was {best?.Count ?? 0})"); - best = improved; - ReportProgress(progress, NestPhase.Remainder, PlateNumber, best, workArea); - } - - if (best == null || best.Count == 0) - return new List(); - - if (item.Quantity > 0 && best.Count > item.Quantity) - best = best.Take(item.Quantity).ToList(); - - return best; - } - - private List FindBestFill(NestItem item, Box workArea) - { - var bestRotation = RotationAnalysis.FindBestRotation(item); - - var engine = new FillLinear(workArea, Plate.PartSpacing); - - // Build candidate rotation angles — always try the best rotation and +90°. - var angles = new List { 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); - - if (workAreaShortSide < partLongestSide) - { - // Try every 5° from 0 to 175° to find rotations that fit. - 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); - } - } - - var linearBag = new System.Collections.Concurrent.ConcurrentBag<(FillScore score, List parts)>(); - - System.Threading.Tasks.Parallel.ForEach(angles, angle => - { - 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); - - if (h != null && h.Count > 0) - linearBag.Add((FillScore.Compute(h, workArea), h)); - - if (v != null && v.Count > 0) - linearBag.Add((FillScore.Compute(v, workArea), v)); - }); - - List best = null; - var bestScore = default(FillScore); - - foreach (var (score, parts) in linearBag) - { - if (best == null || score > bestScore) - { - best = parts; - bestScore = score; - } - } - - var bestLinearScore = best != null ? FillScore.Compute(best, workArea) : default; - Debug.WriteLine($"[FindBestFill] Linear: {bestLinearScore.Count} parts, density={bestLinearScore.Density:P1} | WorkArea: {workArea.Width:F1}x{workArea.Length:F1} | Angles: {angles.Count}"); - - // Try rectangle best-fit (mixes orientations to fill remnant strips). - var rectResult = FillRectangleBestFit(item, workArea); - - Debug.WriteLine($"[FindBestFill] RectBestFit: {rectResult?.Count ?? 0} parts"); - - if (IsBetterFill(rectResult, best, workArea)) - best = rectResult; - - // Try pair-based approach. - var pairResult = FillWithPairs(item, workArea); - - Debug.WriteLine($"[FindBestFill] Pair: {pairResult.Count} parts | Winner: {(IsBetterFill(pairResult, best, workArea) ? "Pair" : "Linear")}"); - - if (IsBetterFill(pairResult, best, workArea)) - best = pairResult; - - // NFP phase (non-rectangular parts only) - var nfpResult = FillNfpBestFit(item, workArea); - Debug.WriteLine($"[FindBestFill] NFP: {nfpResult?.Count ?? 0} parts"); - - if (IsBetterFill(nfpResult, best, workArea)) - best = nfpResult; - - return best; - } - - private List FindBestFill(NestItem item, Box workArea, - IProgress progress, CancellationToken token) - { - List best = null; - - try - { - var bestRotation = RotationAnalysis.FindBestRotation(item); - var engine = new FillLinear(workArea, Plate.PartSpacing); - var angles = new List { bestRotation, bestRotation + Angle.HalfPI }; - - 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); - - if (workAreaShortSide < partLongestSide) - { - 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); - } - } - - // Linear phase - var linearBag = new System.Collections.Concurrent.ConcurrentBag<(FillScore score, List parts)>(); - - System.Threading.Tasks.Parallel.ForEach(angles, - new System.Threading.Tasks.ParallelOptions { CancellationToken = token }, - angle => - { - 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); - - if (h != null && h.Count > 0) - linearBag.Add((FillScore.Compute(h, workArea), h)); - if (v != null && v.Count > 0) - linearBag.Add((FillScore.Compute(v, workArea), v)); - }); - - var bestScore = default(FillScore); - - foreach (var (score, parts) in linearBag) - { - if (best == null || score > bestScore) - { - best = parts; - bestScore = score; - } - } - - var bestLinearScore = best != null ? FillScore.Compute(best, workArea) : default; - Debug.WriteLine($"[FindBestFill] Linear: {bestLinearScore.Count} parts, density={bestLinearScore.Density:P1} | WorkArea: {workArea.Width:F1}x{workArea.Length:F1} | Angles: {angles.Count}"); - - ReportProgress(progress, NestPhase.Linear, PlateNumber, best, workArea); - token.ThrowIfCancellationRequested(); - - // RectBestFit phase - var rectResult = FillRectangleBestFit(item, workArea); - Debug.WriteLine($"[FindBestFill] RectBestFit: {rectResult?.Count ?? 0} parts"); - - if (IsBetterFill(rectResult, best, workArea)) - { - best = rectResult; - ReportProgress(progress, NestPhase.RectBestFit, PlateNumber, best, workArea); - } - - token.ThrowIfCancellationRequested(); - - // Pairs phase - var pairResult = FillWithPairs(item, workArea, token); - Debug.WriteLine($"[FindBestFill] 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); - } - - token.ThrowIfCancellationRequested(); - - // NFP phase (non-rectangular parts only) - var nfpResult = FillNfpBestFit(item, workArea); - Debug.WriteLine($"[FindBestFill] NFP: {nfpResult?.Count ?? 0} parts"); - - if (IsBetterFill(nfpResult, best, workArea)) - { - best = nfpResult; - ReportProgress(progress, NestPhase.Nfp, PlateNumber, best, workArea); - } - } - catch (OperationCanceledException) - { - Debug.WriteLine("[FindBestFill] Cancelled, returning current best"); - } - - return best ?? new List(); - } - - public bool Fill(List 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 List Fill(List groupParts, Box workArea, - IProgress progress, CancellationToken token) - { - if (groupParts == null || groupParts.Count == 0) - return new List(); - - var engine = new FillLinear(workArea, Plate.PartSpacing); - var angles = RotationAnalysis.FindHullEdgeAngles(groupParts); - var best = FillPattern(engine, groupParts, angles, workArea); - - 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); - - if (groupParts.Count == 1) - { - try - { - token.ThrowIfCancellationRequested(); - - var nestItem = new NestItem { Drawing = groupParts[0].BaseDrawing }; - var rectResult = FillRectangleBestFit(nestItem, workArea); - - Debug.WriteLine($"[Fill(groupParts,Box)] RectBestFit: {rectResult?.Count ?? 0} parts"); - - if (IsBetterFill(rectResult, best, workArea)) - { - best = rectResult; - ReportProgress(progress, NestPhase.RectBestFit, PlateNumber, best, workArea); - } - - token.ThrowIfCancellationRequested(); - - var pairResult = FillWithPairs(nestItem, workArea, token); - - 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); - } - - token.ThrowIfCancellationRequested(); - - // NFP phase (non-rectangular parts only) - var nfpResult = FillNfpBestFit(nestItem, workArea); - Debug.WriteLine($"[Fill(groupParts,Box)] NFP: {nfpResult?.Count ?? 0} parts"); - - if (IsBetterFill(nfpResult, best, workArea)) - { - best = nfpResult; - ReportProgress(progress, NestPhase.Nfp, PlateNumber, best, workArea); - } - - // 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; - ReportProgress(progress, NestPhase.Remainder, PlateNumber, best, workArea); - } - } - catch (OperationCanceledException) - { - Debug.WriteLine("[Fill(groupParts,Box)] Cancelled, returning current best"); - } - } - - return best ?? new List(); - } - - public bool Pack(List items) - { - var workArea = Plate.WorkArea(); - return PackArea(workArea, items); - } - - public bool PackArea(Box box, List items) - { - var binItems = BinConverter.ToItems(items, Plate.PartSpacing, Plate.Area()); - var bin = BinConverter.CreateBin(box, Plate.PartSpacing); - - var engine = new PackBottomLeft(bin); - engine.Pack(binItems); - - var parts = BinConverter.ToParts(bin, items); - Plate.Parts.AddRange(parts); - - return parts.Count > 0; - } - - private List 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 { item }); - } - - private List FillWithPairs(NestItem item, Box workArea) - { - var bestFits = BestFitCache.GetOrCompute( - item.Drawing, Plate.Size.Width, Plate.Size.Length, - Plate.PartSpacing); - - var candidates = SelectPairCandidates(bestFits, workArea); - Debug.WriteLine($"[FillWithPairs] Total: {bestFits.Count}, Kept: {bestFits.Count(r => r.Keep)}, Trying: {candidates.Count}"); - - var resultBag = new System.Collections.Concurrent.ConcurrentBag<(FillScore score, List parts)>(); - - System.Threading.Tasks.Parallel.For(0, candidates.Count, i => - { - var result = candidates[i]; - var pairParts = result.BuildParts(item.Drawing); - var angles = RotationAnalysis.FindHullEdgeAngles(pairParts); - var engine = new FillLinear(workArea, Plate.PartSpacing); - var filled = FillPattern(engine, pairParts, angles, workArea); - - if (filled != null && filled.Count > 0) - resultBag.Add((FillScore.Compute(filled, workArea), filled)); - }); - - List best = null; - var bestScore = default(FillScore); - - foreach (var (score, parts) in resultBag) - { - if (best == null || score > bestScore) - { - best = parts; - bestScore = score; - } - } - - Debug.WriteLine($"[FillWithPairs] Best pair result: {bestScore.Count} parts, remnant={bestScore.UsableRemnantArea:F1}, density={bestScore.Density:P1}"); - return best ?? new List(); - } - - private List FillWithPairs(NestItem item, Box workArea, CancellationToken token) - { - var bestFits = BestFitCache.GetOrCompute( - item.Drawing, Plate.Size.Width, Plate.Size.Length, - Plate.PartSpacing); - - var candidates = SelectPairCandidates(bestFits, workArea); - Debug.WriteLine($"[FillWithPairs] Total: {bestFits.Count}, Kept: {bestFits.Count(r => r.Keep)}, Trying: {candidates.Count}"); - - var resultBag = new System.Collections.Concurrent.ConcurrentBag<(FillScore score, List parts)>(); - - try - { - System.Threading.Tasks.Parallel.For(0, candidates.Count, - new System.Threading.Tasks.ParallelOptions { CancellationToken = token }, - i => - { - var result = candidates[i]; - var pairParts = result.BuildParts(item.Drawing); - var angles = RotationAnalysis.FindHullEdgeAngles(pairParts); - var engine = new FillLinear(workArea, Plate.PartSpacing); - var filled = FillPattern(engine, pairParts, angles, workArea); - - if (filled != null && filled.Count > 0) - resultBag.Add((FillScore.Compute(filled, workArea), filled)); - }); - } - catch (OperationCanceledException) - { - Debug.WriteLine("[FillWithPairs] Cancelled mid-phase, using results so far"); - } - - List best = null; - var bestScore = default(FillScore); - - foreach (var (score, parts) in resultBag) - { - if (best == null || score > bestScore) - { - best = parts; - bestScore = score; - } - } - - Debug.WriteLine($"[FillWithPairs] Best pair result: {bestScore.Count} parts, remnant={bestScore.UsableRemnantArea:F1}, density={bestScore.Density:P1}"); - return best ?? new List(); - } - - /// - /// 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 — these are candidates that can only - /// be evaluated by actually tiling them into the narrow space. - /// - private List SelectPairCandidates(List 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, - // include all pairs that fit the narrow dimension. - if (workShortSide < plateShortSide * 0.5) - { - var stripCandidates = kept - .Where(r => r.ShortestSide <= workShortSide + Tolerance.Epsilon); - - var existing = new HashSet(top); - - foreach (var r in stripCandidates) - { - if (existing.Add(r)) - top.Add(r); - } - - Debug.WriteLine($"[SelectPairCandidates] Strip mode: {top.Count} candidates (shortSide <= {workShortSide:F1})"); - } - - return top; - } - - private List FillNfpBestFit(NestItem item, Box workArea) - { - var halfSpacing = Plate.PartSpacing / 2.0; - var drawing = item.Drawing; - - // Extract offset perimeter polygon. - var polygon = ExtractPerimeterPolygon(drawing, halfSpacing); - - if (polygon == null) - return new List(); - - // Rectangularity gate: skip if bounding-box fill ratio > 0.95. - var polyArea = polygon.Area(); - var bboxArea = polygon.BoundingBox.Area(); - - if (bboxArea > 0 && polyArea / bboxArea > 0.95) - return new List(); - - // Compute candidate rotations and filter by rotation constraints. - var rotations = ComputeCandidateRotations(item, polygon, workArea); - - if (item.RotationStart != 0 || item.RotationEnd != 0) - { - rotations = rotations - .Where(a => a >= item.RotationStart && a <= item.RotationEnd) - .ToList(); - } - - if (rotations.Count == 0) - return new List(); - - // Build NFP cache with all rotation variants of this single drawing. - var nfpCache = new NfpCache(); - - foreach (var rotation in rotations) - { - var rotatedPolygon = RotatePolygon(polygon, rotation); - nfpCache.RegisterPolygon(drawing.Id, rotation, rotatedPolygon); - } - - nfpCache.PreComputeAll(); - - // Estimate max copies that could fit. - var maxN = (int)(workArea.Area() / polyArea); - maxN = System.Math.Min(maxN, 500); - - if (item.Quantity > 0) - maxN = System.Math.Min(maxN, item.Quantity); - - if (maxN <= 0) - return new List(); - - // Try each rotation and keep the best BLF result. - List bestParts = null; - var bestScore = default(FillScore); - - foreach (var rotation in rotations) - { - var sequence = new List<(int drawingId, double rotation, Drawing drawing)>(); - - for (var i = 0; i < maxN; i++) - sequence.Add((drawing.Id, rotation, drawing)); - - var blf = new BottomLeftFill(workArea, nfpCache); - var placedParts = blf.Fill(sequence); - - if (placedParts.Count == 0) - continue; - - var parts = BottomLeftFill.ToNestParts(placedParts); - var score = FillScore.Compute(parts, workArea); - - if (bestParts == null || score > bestScore) - { - bestParts = parts; - bestScore = score; - } - } - - return bestParts ?? new List(); - } - - private bool HasOverlaps(List 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; - - // Fast bounding box rejection — if boxes don't overlap, - // the parts can't intersect. Eliminates nearly all pairs - // in grid layouts. - if (box1.Right < box2.Left || box2.Right < box1.Left || - box1.Top < box2.Bottom || box2.Top < box1.Bottom) - continue; - - List 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; - } - - private bool IsBetterFill(List candidate, List 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); - } - - private bool IsBetterValidFill(List candidate, List 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); - } - - /// - /// 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. - /// - private static List> ClusterParts(List 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>(); - var current = new List { 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(); - } - - current.Add(sorted[i]); - } - - clusters.Add(current); - return clusters; - } - - private List TryStripRefill(NestItem item, Box workArea, List 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)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(mainParts.Count + stripParts.Count); - combined.AddRange(mainParts); - combined.AddRange(stripParts); - return combined; - } - - private List TryRemainderImprovement(NestItem item, Box workArea, List currentBest) - { - if (currentBest == null || currentBest.Count < 3) - return null; - - List 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 Pattern BuildRotatedPattern(List groupParts, double angle) - { - var pattern = new Pattern(); - var center = ((IEnumerable)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 FillPattern(FillLinear engine, List groupParts, List angles, Box workArea) - { - var bag = new System.Collections.Concurrent.ConcurrentBag<(FillScore score, List parts)>(); - - System.Threading.Tasks.Parallel.ForEach(angles, angle => - { - var pattern = BuildRotatedPattern(groupParts, angle); - - if (pattern.Parts.Count == 0) - return; - - var localEngine = new FillLinear(workArea, engine.PartSpacing); - var h = localEngine.Fill(pattern, NestDirection.Horizontal); - var v = localEngine.Fill(pattern, NestDirection.Vertical); - - if (h != null && h.Count > 0 && !HasOverlaps(h, engine.PartSpacing)) - bag.Add((FillScore.Compute(h, workArea), h)); - - if (v != null && v.Count > 0 && !HasOverlaps(v, engine.PartSpacing)) - bag.Add((FillScore.Compute(v, workArea), v)); - }); - - List best = null; - var bestScore = default(FillScore); - - foreach (var (score, parts) in bag) - { - if (best == null || score > bestScore) - { - best = parts; - bestScore = score; - } - } - - return best; - } - - private static void ReportProgress( - IProgress progress, - NestPhase phase, - int plateNumber, - List best, - Box workArea) - { - if (progress == null || best == null || best.Count == 0) - return; - - var score = FillScore.Compute(best, workArea); - var clonedParts = new List(best.Count); - - foreach (var part in best) - clonedParts.Add((Part)part.Clone()); - - progress.Report(new NestProgress - { - Phase = phase, - PlateNumber = plateNumber, - BestPartCount = score.Count, - BestDensity = score.Density, - UsableRemnantArea = score.UsableRemnantArea, - BestParts = clonedParts - }); - } - - /// - /// Mixed-part geometry-aware nesting using NFP-based collision avoidance - /// and simulated annealing optimization. - /// - public List AutoNest(List items, CancellationToken cancellation = default) - { - return AutoNest(items, Plate, cancellation); - } - - /// - /// Mixed-part geometry-aware nesting using NFP-based collision avoidance - /// and simulated annealing optimization. - /// - public static List AutoNest(List items, Plate plate, - CancellationToken cancellation = default) - { - var workArea = plate.WorkArea(); - var halfSpacing = plate.PartSpacing / 2.0; - var nfpCache = new NfpCache(); - var candidateRotations = new Dictionary>(); - - // 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(); - - // 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(); - - // 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; - } - - /// - /// Extracts the perimeter polygon from a drawing, inflated by half-spacing. - /// - 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; - } - - /// - /// Computes candidate rotation angles for a drawing. - /// - private static List ComputeCandidateRotations(NestItem item, - Polygon perimeterPolygon, Box workArea) - { - var rotations = new List { 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; - } - - /// - /// Computes convex hull edge angles from a polygon for candidate rotations. - /// - private static List ComputeHullEdgeAngles(Polygon polygon) - { - var angles = new List(); - - 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; - } - - /// - /// Creates a rotated copy of a polygon around the origin. - /// - 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; - } - - } -} diff --git a/OpenNest.Engine/NestEngineBase.cs b/OpenNest.Engine/NestEngineBase.cs new file mode 100644 index 0000000..2bbeb88 --- /dev/null +++ b/OpenNest.Engine/NestEngineBase.cs @@ -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 PhaseResults { get; } = new(); + + public List AngleResults { get; } = new(); + + public abstract string Name { get; } + + public abstract string Description { get; } + + // --- Virtual methods (side-effect-free, return parts) --- + + public virtual List Fill(NestItem item, Box workArea, + IProgress progress, CancellationToken token) + { + return new List(); + } + + public virtual List Fill(List groupParts, Box workArea, + IProgress progress, CancellationToken token) + { + return new List(); + } + + public virtual List PackArea(Box box, List items, + IProgress progress, CancellationToken token) + { + return new List(); + } + + // --- Nest: multi-item strategy (virtual, side-effect-free) --- + + public virtual List Nest(List items, + IProgress progress, CancellationToken token) + { + if (items == null || items.Count == 0) + return new List(); + + var workArea = Plate.WorkArea(); + var allParts = new List(); + + 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().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 FillExact(NestItem item, Box workArea, + IProgress 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 groupParts) + { + return Fill(groupParts, Plate.WorkArea()); + } + + public bool Fill(List 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 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 progress, + NestPhase phase, + int plateNumber, + List best, + Box workArea, + string description) + { + if (progress == null || best == null || best.Count == 0) + return; + + var score = FillScore.Compute(best, workArea); + var clonedParts = new List(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(PhaseResults.Count); + + foreach (var r in PhaseResults) + parts.Add($"{FormatPhaseName(r.Phase)}: {r.PartCount}"); + + return string.Join(" | ", parts); + } + + protected bool IsBetterFill(List candidate, List 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 candidate, List 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 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 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(); + } + } + } +} diff --git a/OpenNest.Engine/NestEngineInfo.cs b/OpenNest.Engine/NestEngineInfo.cs new file mode 100644 index 0000000..a18a93d --- /dev/null +++ b/OpenNest.Engine/NestEngineInfo.cs @@ -0,0 +1,18 @@ +using System; + +namespace OpenNest +{ + public class NestEngineInfo + { + public NestEngineInfo(string name, string description, Func factory) + { + Name = name; + Description = description; + Factory = factory; + } + + public string Name { get; } + public string Description { get; } + public Func Factory { get; } + } +} diff --git a/OpenNest.Engine/NestEngineRegistry.cs b/OpenNest.Engine/NestEngineRegistry.cs new file mode 100644 index 0000000..82973ea --- /dev/null +++ b/OpenNest.Engine/NestEngineRegistry.cs @@ -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 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 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 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}"); + } + } + } + } +} diff --git a/OpenNest.Engine/NestProgress.cs b/OpenNest.Engine/NestProgress.cs index 97b8bc5..c7c4ead 100644 --- a/OpenNest.Engine/NestProgress.cs +++ b/OpenNest.Engine/NestProgress.cs @@ -11,13 +11,38 @@ namespace OpenNest 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 NestPhase Phase { get; set; } public int PlateNumber { get; set; } public int BestPartCount { 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 List BestParts { get; set; } + public string Description { get; set; } } } diff --git a/OpenNest.Engine/OpenNest.Engine.csproj b/OpenNest.Engine/OpenNest.Engine.csproj index 5379833..bcf6d9e 100644 --- a/OpenNest.Engine/OpenNest.Engine.csproj +++ b/OpenNest.Engine/OpenNest.Engine.csproj @@ -7,4 +7,10 @@ + + + + + + diff --git a/OpenNest.Engine/PartBoundary.cs b/OpenNest.Engine/PartBoundary.cs index 4ab96fc..44c14bc 100644 --- a/OpenNest.Engine/PartBoundary.cs +++ b/OpenNest.Engine/PartBoundary.cs @@ -23,22 +23,26 @@ namespace OpenNest public PartBoundary(Part part, double spacing) { - var entities = ConvertProgram.ToGeometry(part.Program); - var shapes = Helper.GetShapes(entities.Where(e => e.Layer != SpecialLayers.Rapid)); + var entities = ConvertProgram.ToGeometry(part.Program) + .Where(e => e.Layer != SpecialLayers.Rapid) + .ToList(); + + var definedShape = new ShapeProfile(entities); + var perimeter = definedShape.Perimeter; _polygons = new List(); - 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) - continue; - - // Circumscribe arcs so polygon vertices are always outside - // the true arc — guarantees the boundary never under-estimates. - var polygon = offsetEntity.ToPolygonWithTolerance(PolygonTolerance, circumscribe: true); - polygon.RemoveSelfIntersections(); - _polygons.Add(polygon); + if (offsetEntity != null) + { + // Circumscribe arcs so polygon vertices are always outside + // the true arc — guarantees the boundary never under-estimates. + var polygon = offsetEntity.ToPolygonWithTolerance(PolygonTolerance, circumscribe: true); + polygon.RemoveSelfIntersections(); + _polygons.Add(polygon); + } } PrecomputeDirectionalEdges( @@ -89,10 +93,10 @@ namespace OpenNest } } - leftEdges = left.ToArray(); - rightEdges = right.ToArray(); - upEdges = up.ToArray(); - downEdges = down.ToArray(); + leftEdges = left.OrderBy(e => System.Math.Min(e.Item1.Y, e.Item2.Y)).ToArray(); + rightEdges = right.OrderBy(e => System.Math.Min(e.Item1.Y, e.Item2.Y)).ToArray(); + upEdges = up.OrderBy(e => System.Math.Min(e.Item1.X, e.Item2.X)).ToArray(); + downEdges = down.OrderBy(e => System.Math.Min(e.Item1.X, e.Item2.X)).ToArray(); } /// @@ -148,5 +152,14 @@ namespace OpenNest default: return _leftEdges; } } + + /// + /// Returns the pre-computed edge arrays for the given direction. + /// These are in part-local coordinates (no translation applied). + /// + public (Vector start, Vector end)[] GetEdges(PushDirection direction) + { + return GetDirectionalEdges(direction); + } } } diff --git a/OpenNest.Engine/PlateProcessor.cs b/OpenNest.Engine/PlateProcessor.cs new file mode 100644 index 0000000..9601933 --- /dev/null +++ b/OpenNest.Engine/PlateProcessor.cs @@ -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(sequenced.Count); + var cutAreas = new List(); + 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; + } + } +} diff --git a/OpenNest.Engine/PlateResult.cs b/OpenNest.Engine/PlateResult.cs new file mode 100644 index 0000000..7209be7 --- /dev/null +++ b/OpenNest.Engine/PlateResult.cs @@ -0,0 +1,18 @@ +using System.Collections.Generic; +using OpenNest.CNC; +using OpenNest.Engine.RapidPlanning; + +namespace OpenNest.Engine +{ + public class PlateResult + { + public List Parts { get; init; } + } + + public readonly struct ProcessedPart + { + public Part Part { get; init; } + public Program ProcessedProgram { get; init; } + public RapidPath RapidPath { get; init; } + } +} diff --git a/OpenNest.Engine/RapidPlanning/DirectRapidPlanner.cs b/OpenNest.Engine/RapidPlanning/DirectRapidPlanner.cs new file mode 100644 index 0000000..154e525 --- /dev/null +++ b/OpenNest.Engine/RapidPlanning/DirectRapidPlanner.cs @@ -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 cutAreas) + { + var travelLine = new Line(from, to); + + foreach (var cutArea in cutAreas) + { + if (TravelLineIntersectsShape(travelLine, cutArea)) + { + return new RapidPath + { + HeadUp = true, + Waypoints = new List() + }; + } + } + + return new RapidPath + { + HeadUp = false, + Waypoints = new List() + }; + } + + 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; + } + } +} diff --git a/OpenNest.Engine/RapidPlanning/IRapidPlanner.cs b/OpenNest.Engine/RapidPlanning/IRapidPlanner.cs new file mode 100644 index 0000000..edae37c --- /dev/null +++ b/OpenNest.Engine/RapidPlanning/IRapidPlanner.cs @@ -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 cutAreas); + } +} diff --git a/OpenNest.Engine/RapidPlanning/RapidPath.cs b/OpenNest.Engine/RapidPlanning/RapidPath.cs new file mode 100644 index 0000000..8ff6eb4 --- /dev/null +++ b/OpenNest.Engine/RapidPlanning/RapidPath.cs @@ -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 Waypoints { get; init; } + } +} diff --git a/OpenNest.Engine/RapidPlanning/SafeHeightRapidPlanner.cs b/OpenNest.Engine/RapidPlanning/SafeHeightRapidPlanner.cs new file mode 100644 index 0000000..6de4db6 --- /dev/null +++ b/OpenNest.Engine/RapidPlanning/SafeHeightRapidPlanner.cs @@ -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 cutAreas) + { + return new RapidPath + { + HeadUp = true, + Waypoints = new List() + }; + } + } +} diff --git a/OpenNest.Engine/RotationAnalysis.cs b/OpenNest.Engine/RotationAnalysis.cs index 65dac9d..0d7e20f 100644 --- a/OpenNest.Engine/RotationAnalysis.cs +++ b/OpenNest.Engine/RotationAnalysis.cs @@ -17,7 +17,7 @@ namespace OpenNest var entities = ConvertProgram.ToGeometry(item.Drawing.Program) .Where(e => e.Layer != SpecialLayers.Rapid); - var shapes = Helper.GetShapes(entities); + var shapes = ShapeBuilder.GetShapes(entities); if (shapes.Count == 0) return 0; @@ -65,7 +65,7 @@ namespace OpenNest var entities = ConvertProgram.ToGeometry(part.Program) .Where(e => e.Layer != SpecialLayers.Rapid); - var shapes = Helper.GetShapes(entities); + var shapes = ShapeBuilder.GetShapes(entities); foreach (var shape in shapes) { @@ -80,6 +80,11 @@ namespace OpenNest return new List { 0 }; var hull = ConvexHull.Compute(points); + return GetHullEdgeAngles(hull); + } + + public static List GetHullEdgeAngles(Polygon hull) + { var vertices = hull.Vertices; var n = hull.IsClosed() ? vertices.Count - 1 : vertices.Count; diff --git a/OpenNest.Engine/Sequencing/AdvancedSequencer.cs b/OpenNest.Engine/Sequencing/AdvancedSequencer.cs new file mode 100644 index 0000000..fa285aa --- /dev/null +++ b/OpenNest.Engine/Sequencing/AdvancedSequencer.cs @@ -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 Sequence(IReadOnlyList parts, Plate plate) + { + if (parts.Count == 0) + return new List(); + + 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(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 GroupIntoRows(IReadOnlyList parts, double minDistance) + { + // Sort parts by Y center + var sorted = parts + .OrderBy(p => p.BoundingBox.Center.Y) + .ToList(); + + var rows = new List(); + + 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 Parts { get; } = new List(); + + public PartRow(double rowY) + { + RowY = rowY; + } + } + } +} diff --git a/OpenNest.Engine/Sequencing/BottomSideSequencer.cs b/OpenNest.Engine/Sequencing/BottomSideSequencer.cs new file mode 100644 index 0000000..de06053 --- /dev/null +++ b/OpenNest.Engine/Sequencing/BottomSideSequencer.cs @@ -0,0 +1,17 @@ +using System.Collections.Generic; +using System.Linq; + +namespace OpenNest.Engine.Sequencing +{ + public class BottomSideSequencer : IPartSequencer + { + public List Sequence(IReadOnlyList parts, Plate plate) + { + return parts + .OrderBy(p => p.Location.Y) + .ThenBy(p => p.Location.X) + .Select(p => new SequencedPart { Part = p }) + .ToList(); + } + } +} diff --git a/OpenNest.Engine/Sequencing/EdgeStartSequencer.cs b/OpenNest.Engine/Sequencing/EdgeStartSequencer.cs new file mode 100644 index 0000000..187a599 --- /dev/null +++ b/OpenNest.Engine/Sequencing/EdgeStartSequencer.cs @@ -0,0 +1,36 @@ +using System.Collections.Generic; +using System.Linq; + +namespace OpenNest.Engine.Sequencing +{ + public class EdgeStartSequencer : IPartSequencer + { + public List Sequence(IReadOnlyList 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)); + } + } +} diff --git a/OpenNest.Engine/Sequencing/IPartSequencer.cs b/OpenNest.Engine/Sequencing/IPartSequencer.cs new file mode 100644 index 0000000..79b0c3e --- /dev/null +++ b/OpenNest.Engine/Sequencing/IPartSequencer.cs @@ -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 Sequence(IReadOnlyList parts, Plate plate); + } +} diff --git a/OpenNest.Engine/Sequencing/LeastCodeSequencer.cs b/OpenNest.Engine/Sequencing/LeastCodeSequencer.cs new file mode 100644 index 0000000..63b0e2a --- /dev/null +++ b/OpenNest.Engine/Sequencing/LeastCodeSequencer.cs @@ -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 Sequence(IReadOnlyList parts, Plate plate) + { + if (parts.Count == 0) + return new List(); + + var exit = PlateHelper.GetExitPoint(plate); + var ordered = NearestNeighbor(parts, exit); + TwoOpt(ordered, exit); + + var result = new List(ordered.Count); + foreach (var p in ordered) + result.Add(new SequencedPart { Part = p }); + return result; + } + + private static List NearestNeighbor(IReadOnlyList parts, OpenNest.Geometry.Vector exit) + { + var remaining = new List(parts); + var ordered = new List(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 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; + } + } + + /// + /// 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. + /// + private static double RouteDistance(List 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 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); + } + } +} diff --git a/OpenNest.Engine/Sequencing/LeftSideSequencer.cs b/OpenNest.Engine/Sequencing/LeftSideSequencer.cs new file mode 100644 index 0000000..ca0f20a --- /dev/null +++ b/OpenNest.Engine/Sequencing/LeftSideSequencer.cs @@ -0,0 +1,17 @@ +using System.Collections.Generic; +using System.Linq; + +namespace OpenNest.Engine.Sequencing +{ + public class LeftSideSequencer : IPartSequencer + { + public List Sequence(IReadOnlyList parts, Plate plate) + { + return parts + .OrderBy(p => p.Location.X) + .ThenBy(p => p.Location.Y) + .Select(p => new SequencedPart { Part = p }) + .ToList(); + } + } +} diff --git a/OpenNest.Engine/Sequencing/PartSequencerFactory.cs b/OpenNest.Engine/Sequencing/PartSequencerFactory.cs new file mode 100644 index 0000000..0e29d1e --- /dev/null +++ b/OpenNest.Engine/Sequencing/PartSequencerFactory.cs @@ -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.") + }; + } + } +} diff --git a/OpenNest.Engine/Sequencing/PlateHelper.cs b/OpenNest.Engine/Sequencing/PlateHelper.cs new file mode 100644 index 0000000..1a46327 --- /dev/null +++ b/OpenNest.Engine/Sequencing/PlateHelper.cs @@ -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) + }; + } + } +} diff --git a/OpenNest.Engine/Sequencing/RightSideSequencer.cs b/OpenNest.Engine/Sequencing/RightSideSequencer.cs new file mode 100644 index 0000000..f804a38 --- /dev/null +++ b/OpenNest.Engine/Sequencing/RightSideSequencer.cs @@ -0,0 +1,17 @@ +using System.Collections.Generic; +using System.Linq; + +namespace OpenNest.Engine.Sequencing +{ + public class RightSideSequencer : IPartSequencer + { + public List Sequence(IReadOnlyList parts, Plate plate) + { + return parts + .OrderByDescending(p => p.Location.X) + .ThenBy(p => p.Location.Y) + .Select(p => new SequencedPart { Part = p }) + .ToList(); + } + } +} diff --git a/OpenNest.Engine/StripDirection.cs b/OpenNest.Engine/StripDirection.cs new file mode 100644 index 0000000..10005c8 --- /dev/null +++ b/OpenNest.Engine/StripDirection.cs @@ -0,0 +1,8 @@ +namespace OpenNest +{ + public enum StripDirection + { + Bottom, + Left + } +} diff --git a/OpenNest.Engine/StripNestEngine.cs b/OpenNest.Engine/StripNestEngine.cs new file mode 100644 index 0000000..87a6b4a --- /dev/null +++ b/OpenNest.Engine/StripNestEngine.cs @@ -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"; + + /// + /// Single-item fill delegates to DefaultNestEngine. + /// The strip strategy adds value for multi-drawing nesting, not single-item fills. + /// + public override List Fill(NestItem item, Box workArea, + IProgress progress, CancellationToken token) + { + var inner = new DefaultNestEngine(Plate); + return inner.Fill(item, workArea, progress, token); + } + + /// + /// Group-parts fill delegates to DefaultNestEngine. + /// + public override List Fill(List groupParts, Box workArea, + IProgress progress, CancellationToken token) + { + var inner = new DefaultNestEngine(Plate); + return inner.Fill(groupParts, workArea, progress, token); + } + + /// + /// Pack delegates to DefaultNestEngine. + /// + public override List PackArea(Box box, List items, + IProgress progress, CancellationToken token) + { + var inner = new DefaultNestEngine(Plate); + return inner.PackArea(box, items, progress, token); + } + + /// + /// Selects the item that consumes the most plate area (bounding box area x quantity). + /// Returns the index into the items list. + /// + private static int SelectStripItemIndex(List 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; + } + + /// + /// 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. + /// + 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); + } + + /// + /// 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. + /// + public override List Nest(List items, + IProgress progress, CancellationToken token) + { + if (items == null || items.Count == 0) + return new List(); + + 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 remainderItems, Box workArea, IProgress 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().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().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(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(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 { 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().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 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)); + } + } + + /// + /// Wraps an IProgress to prepend previously placed parts to each report, + /// so the UI shows the full picture (strip + remnant) during remnant fills. + /// + private class AccumulatingProgress : IProgress + { + private readonly IProgress inner; + private readonly List previousParts; + + public AccumulatingProgress(IProgress inner, List previousParts) + { + this.inner = inner; + this.previousParts = previousParts; + } + + public void Report(NestProgress value) + { + if (value.BestParts != null && previousParts.Count > 0) + { + var combined = new List(previousParts.Count + value.BestParts.Count); + combined.AddRange(previousParts); + combined.AddRange(value.BestParts); + value.BestParts = combined; + value.BestPartCount = combined.Count; + } + + inner.Report(value); + } + } + } +} diff --git a/OpenNest.Engine/StripNestResult.cs b/OpenNest.Engine/StripNestResult.cs new file mode 100644 index 0000000..f849d8e --- /dev/null +++ b/OpenNest.Engine/StripNestResult.cs @@ -0,0 +1,14 @@ +using System.Collections.Generic; +using OpenNest.Geometry; + +namespace OpenNest +{ + internal class StripNestResult + { + public List Parts { get; set; } = new(); + public Box StripBox { get; set; } + public Box RemnantBox { get; set; } + public FillScore Score { get; set; } + public StripDirection Direction { get; set; } + } +} diff --git a/OpenNest.Gpu/GpuEvaluatorFactory.cs b/OpenNest.Gpu/GpuEvaluatorFactory.cs index e69edb2..9ee04f4 100644 --- a/OpenNest.Gpu/GpuEvaluatorFactory.cs +++ b/OpenNest.Gpu/GpuEvaluatorFactory.cs @@ -11,6 +11,8 @@ namespace OpenNest.Gpu private static bool _probed; private static bool _gpuAvailable; private static string _deviceName; + private static GpuSlideComputer _slideComputer; + private static readonly object _slideLock = new object(); 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() { _probed = true; diff --git a/OpenNest.Gpu/GpuPairEvaluator.cs b/OpenNest.Gpu/GpuPairEvaluator.cs index 17c2871..4a6e502 100644 --- a/OpenNest.Gpu/GpuPairEvaluator.cs +++ b/OpenNest.Gpu/GpuPairEvaluator.cs @@ -258,7 +258,7 @@ namespace OpenNest.Gpu { var entities = ConvertProgram.ToGeometry(part.Program) .Where(e => e.Layer != SpecialLayers.Rapid); - var shapes = Helper.GetShapes(entities); + var shapes = ShapeBuilder.GetShapes(entities); var points = new List(); foreach (var shape in shapes) diff --git a/OpenNest.Gpu/GpuSlideComputer.cs b/OpenNest.Gpu/GpuSlideComputer.cs new file mode 100644 index 0000000..b6c2ea0 --- /dev/null +++ b/OpenNest.Gpu/GpuSlideComputer.cs @@ -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, // stationaryPrep + ArrayView1D, // movingPrep + ArrayView1D, // offsets + ArrayView1D, // results + int, int, int> _kernel; + + private readonly Action, // stationaryPrep + ArrayView1D, // movingPrep + ArrayView1D, // offsets + ArrayView1D, // results + ArrayView1D, // directions + int, int> _kernelMultiDir; + + private readonly Action, // raw + ArrayView1D, // prepared + int> _prepareKernel; + + // ── Buffers ────────────────────────────────────────────────── + + private MemoryBuffer1D? _gpuStationaryRaw; + private MemoryBuffer1D? _gpuStationaryPrep; + private double[]? _lastStationaryData; // Keep CPU copy/ref for content check + + private MemoryBuffer1D? _gpuMovingRaw; + private MemoryBuffer1D? _gpuMovingPrep; + private double[]? _lastMovingData; // Keep CPU copy/ref for content check + + private MemoryBuffer1D? _gpuOffsets; + private MemoryBuffer1D? _gpuResults; + private MemoryBuffer1D? _gpuDirs; + private int _offsetCapacity; + + public GpuSlideComputer() + { + _context = Context.CreateDefault(); + _accelerator = _context.GetPreferredDevice(preferCPU: false) + .CreateAccelerator(_context); + + _kernel = _accelerator.LoadAutoGroupedStreamKernel< + Index1D, + ArrayView1D, + ArrayView1D, + ArrayView1D, + ArrayView1D, + int, int, int>(SlideKernel); + + _kernelMultiDir = _accelerator.LoadAutoGroupedStreamKernel< + Index1D, + ArrayView1D, + ArrayView1D, + ArrayView1D, + ArrayView1D, + ArrayView1D, + int, int>(SlideKernelMultiDir); + + _prepareKernel = _accelerator.LoadAutoGroupedStreamKernel< + Index1D, + ArrayView1D, + ArrayView1D, + 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(_lastStationaryData).SequenceEqual(new ReadOnlySpan(data))) + { + return; + } + } + + _gpuStationaryRaw?.Dispose(); + _gpuStationaryPrep?.Dispose(); + + _gpuStationaryRaw = _accelerator.Allocate1D(data); + _gpuStationaryPrep = _accelerator.Allocate1D(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(_lastMovingData).SequenceEqual(new ReadOnlySpan(data))) + { + return; + } + } + + _gpuMovingRaw?.Dispose(); + _gpuMovingPrep?.Dispose(); + + _gpuMovingRaw = _accelerator.Allocate1D(data); + _gpuMovingPrep = _accelerator.Allocate1D(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(newCapacity * 2); + _gpuResults = _accelerator.Allocate1D(newCapacity); + _gpuDirs = _accelerator.Allocate1D(newCapacity); + + _offsetCapacity = newCapacity; + } + + // ── Preparation Kernel ─────────────────────────────────────── + + private static void PrepareKernel( + Index1D index, + ArrayView1D raw, + ArrayView1D 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 stationaryPrep, + ArrayView1D movingPrep, + ArrayView1D offsets, + ArrayView1D 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 stationaryPrep, + ArrayView1D movingPrep, + ArrayView1D offsets, + ArrayView1D results, + ArrayView1D 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 sPrep, + ArrayView1D 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 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 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(); + } + } +} diff --git a/OpenNest.Gpu/PartBitmap.cs b/OpenNest.Gpu/PartBitmap.cs index 97b3af3..9b729ef 100644 --- a/OpenNest.Gpu/PartBitmap.cs +++ b/OpenNest.Gpu/PartBitmap.cs @@ -47,7 +47,7 @@ namespace OpenNest.Gpu { var entities = ConvertProgram.ToGeometry(part.Program) .Where(e => e.Layer != SpecialLayers.Rapid); - var shapes = Helper.GetShapes(entities); + var shapes = ShapeBuilder.GetShapes(entities); var polygons = new List(); @@ -137,7 +137,7 @@ namespace OpenNest.Gpu { var entities = ConvertProgram.ToGeometry(drawing.Program) .Where(e => e.Layer != SpecialLayers.Rapid); - var shapes = Helper.GetShapes(entities); + var shapes = ShapeBuilder.GetShapes(entities); var polygons = new List(); diff --git a/OpenNest.IO/DxfImporter.cs b/OpenNest.IO/DxfImporter.cs index 50fcb85..c604a69 100644 --- a/OpenNest.IO/DxfImporter.cs +++ b/OpenNest.IO/DxfImporter.cs @@ -56,8 +56,8 @@ namespace OpenNest.IO } } - Helper.Optimize(lines); - Helper.Optimize(arcs); + GeometryOptimizer.Optimize(lines); + GeometryOptimizer.Optimize(arcs); entities.AddRange(lines); entities.AddRange(arcs); diff --git a/OpenNest.IO/NestFormat.cs b/OpenNest.IO/NestFormat.cs index 6801a31..60a0097 100644 --- a/OpenNest.IO/NestFormat.cs +++ b/OpenNest.IO/NestFormat.cs @@ -122,5 +122,32 @@ namespace OpenNest.IO public double X { 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 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 HullAngles { get; init; } = new(); + } } } diff --git a/OpenNest.IO/NestReader.cs b/OpenNest.IO/NestReader.cs index 5ce5715..e88889e 100644 --- a/OpenNest.IO/NestReader.cs +++ b/OpenNest.IO/NestReader.cs @@ -6,6 +6,7 @@ using System.IO.Compression; using System.Linq; using System.Text.Json; using OpenNest.CNC; +using OpenNest.Engine.BestFit; using OpenNest.Geometry; using static OpenNest.IO.NestFormat; @@ -35,6 +36,7 @@ namespace OpenNest.IO var programs = ReadPrograms(dto.Drawings.Count); var drawingMap = BuildDrawings(dto, programs); + ReadBestFits(drawingMap); var nest = BuildNest(dto, drawingMap); zipArchive.Dispose(); @@ -97,6 +99,54 @@ namespace OpenNest.IO return map; } + private void ReadBestFits(Dictionary 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>(json, JsonOptions); + if (sets == null) continue; + + PopulateBestFitSets(kvp.Value, sets); + } + } + + private void PopulateBestFitSets(Drawing drawing, List 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 drawingMap) { var nest = new Nest(); diff --git a/OpenNest.IO/NestWriter.cs b/OpenNest.IO/NestWriter.cs index 4202a3d..d9583c5 100644 --- a/OpenNest.IO/NestWriter.cs +++ b/OpenNest.IO/NestWriter.cs @@ -6,6 +6,8 @@ using System.Linq; using System.Text; using System.Text.Json; using OpenNest.CNC; +using OpenNest.Engine.BestFit; +using OpenNest.Geometry; using OpenNest.Math; using static OpenNest.IO.NestFormat; @@ -35,6 +37,7 @@ namespace OpenNest.IO WriteNestJson(zipArchive); WritePrograms(zipArchive); + WriteBestFits(zipArchive); return true; } @@ -185,6 +188,70 @@ namespace OpenNest.IO return list; } + private List BuildBestFitDtos(Drawing drawing) + { + var allBestFits = BestFitCache.GetAllForDrawing(drawing); + var sets = new List(); + + // 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() + }).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) { foreach (var kvp in drawingDict.OrderBy(k => k.Key)) diff --git a/OpenNest.IO/OpenNest.IO.csproj b/OpenNest.IO/OpenNest.IO.csproj index cf96aaf..3314127 100644 --- a/OpenNest.IO/OpenNest.IO.csproj +++ b/OpenNest.IO/OpenNest.IO.csproj @@ -6,6 +6,7 @@ + diff --git a/OpenNest.Mcp/Tools/NestingTools.cs b/OpenNest.Mcp/Tools/NestingTools.cs index cdd96ce..b1c7b28 100644 --- a/OpenNest.Mcp/Tools/NestingTools.cs +++ b/OpenNest.Mcp/Tools/NestingTools.cs @@ -34,7 +34,7 @@ namespace OpenNest.Mcp.Tools return $"Error: drawing '{drawingName}' not found"; var countBefore = plate.Parts.Count; - var engine = new NestEngine(plate); + var engine = NestEngineRegistry.Create(plate); var item = new NestItem { Drawing = drawing, Quantity = quantity }; var success = engine.Fill(item); @@ -70,7 +70,7 @@ namespace OpenNest.Mcp.Tools return $"Error: drawing '{drawingName}' not found"; var countBefore = plate.Parts.Count; - var engine = new NestEngine(plate); + var engine = NestEngineRegistry.Create(plate); var item = new NestItem { Drawing = drawing, Quantity = quantity }; var area = new Box(x, y, width, height); 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}"); var totalAdded = 0; - var engine = new NestEngine(plate); + var engine = NestEngineRegistry.Create(plate); for (var i = 0; i < remnants.Count; i++) { @@ -173,7 +173,7 @@ namespace OpenNest.Mcp.Tools } var countBefore = plate.Parts.Count; - var engine = new NestEngine(plate); + var engine = NestEngineRegistry.Create(plate); var success = engine.Pack(items); var countAfter = plate.Parts.Count; var added = countAfter - countBefore; @@ -193,7 +193,7 @@ namespace OpenNest.Mcp.Tools } [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( [Description("Index of the plate")] int plateIndex, [Description("Comma-separated drawing names")] string drawingNames, @@ -233,16 +233,18 @@ namespace OpenNest.Mcp.Tools items.Add(new NestItem { Drawing = drawing, Quantity = qtys[i] }); } - var parts = NestEngine.AutoNest(items, plate); - plate.Parts.AddRange(parts); + var engine = NestEngineRegistry.Create(plate); + var nestParts = engine.Nest(items, null, CancellationToken.None); + plate.Parts.AddRange(nestParts); + var totalPlaced = nestParts.Count; var sb = new StringBuilder(); - sb.AppendLine($"AutoNest plate {plateIndex}: {(parts.Count > 0 ? "success" : "no parts placed")}"); - sb.AppendLine($" Parts placed: {parts.Count}"); + sb.AppendLine($"AutoNest plate {plateIndex} ({engine.Name} engine): {(totalPlaced > 0 ? "success" : "no parts placed")}"); + sb.AppendLine($" Parts placed: {totalPlaced}"); sb.AppendLine($" Total parts: {plate.Parts.Count}"); 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) sb.AppendLine($" {group.Key}: {group.Count()}"); diff --git a/OpenNest.Tests/CuttingResultTests.cs b/OpenNest.Tests/CuttingResultTests.cs new file mode 100644 index 0000000..58cbefc --- /dev/null +++ b/OpenNest.Tests/CuttingResultTests.cs @@ -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); + } +} diff --git a/OpenNest.Tests/OpenNest.Tests.csproj b/OpenNest.Tests/OpenNest.Tests.csproj new file mode 100644 index 0000000..ec1b229 --- /dev/null +++ b/OpenNest.Tests/OpenNest.Tests.csproj @@ -0,0 +1,29 @@ + + + + net8.0-windows + enable + enable + + false + true + + + + + + + + + + + + + + + + + + + + diff --git a/OpenNest.Tests/PartFlagTests.cs b/OpenNest.Tests/PartFlagTests.cs new file mode 100644 index 0000000..f140dd2 --- /dev/null +++ b/OpenNest.Tests/PartFlagTests.cs @@ -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); + } +} diff --git a/OpenNest.Tests/PlateProcessorTests.cs b/OpenNest.Tests/PlateProcessorTests.cs new file mode 100644 index 0000000..73d5a98 --- /dev/null +++ b/OpenNest.Tests/PlateProcessorTests.cs @@ -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); + } +} diff --git a/OpenNest.Tests/RapidPlanning/DirectRapidPlannerTests.cs b/OpenNest.Tests/RapidPlanning/DirectRapidPlannerTests.cs new file mode 100644 index 0000000..ca6a00b --- /dev/null +++ b/OpenNest.Tests/RapidPlanning/DirectRapidPlannerTests.cs @@ -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()); + + 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 { 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 { cutArea }); + + Assert.True(result.HeadUp); + Assert.Empty(result.Waypoints); + } +} diff --git a/OpenNest.Tests/RapidPlanning/SafeHeightRapidPlannerTests.cs b/OpenNest.Tests/RapidPlanning/SafeHeightRapidPlannerTests.cs new file mode 100644 index 0000000..3db408f --- /dev/null +++ b/OpenNest.Tests/RapidPlanning/SafeHeightRapidPlannerTests.cs @@ -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(); + + 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 }; + + var result = planner.Plan(from, to, cutAreas); + + Assert.True(result.HeadUp); + } +} diff --git a/OpenNest.Tests/Sequencing/AdvancedSequencerTests.cs b/OpenNest.Tests/Sequencing/AdvancedSequencerTests.cs new file mode 100644 index 0000000..69f540f --- /dev/null +++ b/OpenNest.Tests/Sequencing/AdvancedSequencerTests.cs @@ -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); + } +} diff --git a/OpenNest.Tests/Sequencing/DirectionalSequencerTests.cs b/OpenNest.Tests/Sequencing/DirectionalSequencerTests.cs new file mode 100644 index 0000000..0a64a98 --- /dev/null +++ b/OpenNest.Tests/Sequencing/DirectionalSequencerTests.cs @@ -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); + } +} diff --git a/OpenNest.Tests/Sequencing/EdgeStartSequencerTests.cs b/OpenNest.Tests/Sequencing/EdgeStartSequencerTests.cs new file mode 100644 index 0000000..3f002de --- /dev/null +++ b/OpenNest.Tests/Sequencing/EdgeStartSequencerTests.cs @@ -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); + } +} diff --git a/OpenNest.Tests/Sequencing/LeastCodeSequencerTests.cs b/OpenNest.Tests/Sequencing/LeastCodeSequencerTests.cs new file mode 100644 index 0000000..a5929c5 --- /dev/null +++ b/OpenNest.Tests/Sequencing/LeastCodeSequencerTests.cs @@ -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); + } +} diff --git a/OpenNest.Tests/Sequencing/PartSequencerFactoryTests.cs b/OpenNest.Tests/Sequencing/PartSequencerFactoryTests.cs new file mode 100644 index 0000000..1c6a22e --- /dev/null +++ b/OpenNest.Tests/Sequencing/PartSequencerFactoryTests.cs @@ -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(() => PartSequencerFactory.Create(parameters)); + } +} diff --git a/OpenNest.Tests/TestHelpers.cs b/OpenNest.Tests/TestHelpers.cs new file mode 100644 index 0000000..8d68f0b --- /dev/null +++ b/OpenNest.Tests/TestHelpers.cs @@ -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; + } +} diff --git a/OpenNest.Training/Data/TrainingAngleResult.cs b/OpenNest.Training/Data/TrainingAngleResult.cs new file mode 100644 index 0000000..b01a0ce --- /dev/null +++ b/OpenNest.Training/Data/TrainingAngleResult.cs @@ -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; } + } +} diff --git a/OpenNest.Training/Data/TrainingDbContext.cs b/OpenNest.Training/Data/TrainingDbContext.cs new file mode 100644 index 0000000..5ffcaaa --- /dev/null +++ b/OpenNest.Training/Data/TrainingDbContext.cs @@ -0,0 +1,47 @@ +using Microsoft.EntityFrameworkCore; + +namespace OpenNest.Training.Data +{ + public class TrainingDbContext : DbContext + { + public DbSet Parts { get; set; } + public DbSet Runs { get; set; } + public DbSet 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(e => + { + e.HasIndex(p => p.FileName).HasDatabaseName("idx_parts_filename"); + }); + + modelBuilder.Entity(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(e => + { + e.HasIndex(a => a.RunId).HasDatabaseName("idx_angleresults_runid"); + e.HasOne(a => a.Run) + .WithMany(r => r.AngleResults) + .HasForeignKey(a => a.RunId); + }); + } + } +} diff --git a/OpenNest.Training/Data/TrainingPart.cs b/OpenNest.Training/Data/TrainingPart.cs new file mode 100644 index 0000000..178180f --- /dev/null +++ b/OpenNest.Training/Data/TrainingPart.cs @@ -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 Runs { get; set; } = new(); + } +} diff --git a/OpenNest.Training/Data/TrainingRun.cs b/OpenNest.Training/Data/TrainingRun.cs new file mode 100644 index 0000000..c968ff4 --- /dev/null +++ b/OpenNest.Training/Data/TrainingRun.cs @@ -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 AngleResults { get; set; } = new(); + } +} diff --git a/OpenNest.Training/OpenNest.Training.csproj b/OpenNest.Training/OpenNest.Training.csproj new file mode 100644 index 0000000..0a7387d --- /dev/null +++ b/OpenNest.Training/OpenNest.Training.csproj @@ -0,0 +1,19 @@ + + + Exe + net8.0-windows + OpenNest.Training + OpenNest.Training + $(DefineConstants);DEBUG;TRACE + + + + + + + + + + + + diff --git a/OpenNest.Training/Program.cs b/OpenNest.Training/Program.cs new file mode 100644 index 0000000..421e00c --- /dev/null +++ b/OpenNest.Training/Program.cs @@ -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 [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 Part spacing (default: 0.5)"); + Console.Error.WriteLine(" --db SQLite database path (default: OpenNestTraining.db)"); + Console.Error.WriteLine(" --save-nests Directory to save individual .zip nests for each winner"); + Console.Error.WriteLine(" --template Nest template (.nstdot) for plate defaults"); + Console.Error.WriteLine(" -h, --help Show this help"); +} diff --git a/OpenNest.Training/TrainingDatabase.cs b/OpenNest.Training/TrainingDatabase.cs new file mode 100644 index 0000000..97eb46e --- /dev/null +++ b/OpenNest.Training/TrainingDatabase.cs @@ -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 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(); + } + } +} diff --git a/OpenNest.Training/notebooks/requirements.txt b/OpenNest.Training/notebooks/requirements.txt new file mode 100644 index 0000000..840594e --- /dev/null +++ b/OpenNest.Training/notebooks/requirements.txt @@ -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 diff --git a/OpenNest.Training/notebooks/train_angle_model.ipynb b/OpenNest.Training/notebooks/train_angle_model.ipynb new file mode 100644 index 0000000..75c1b7c --- /dev/null +++ b/OpenNest.Training/notebooks/train_angle_model.ipynb @@ -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)\")" + ] + } + ] +} diff --git a/OpenNest.sln b/OpenNest.sln index 8e76f0c..d0a8d89 100644 --- a/OpenNest.sln +++ b/OpenNest.sln @@ -17,6 +17,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OpenNest.Mcp", "OpenNest.Mc EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OpenNest.Console", "OpenNest.Console\OpenNest.Console.csproj", "{58E00A25-86B5-42C7-87B5-DE4AD22381EA}" 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 GlobalSection(SolutionConfigurationPlatforms) = preSolution 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|x86.ActiveCfg = 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 GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/OpenNest/Actions/ActionClone.cs b/OpenNest/Actions/ActionClone.cs index 9abb583..793c6eb 100644 --- a/OpenNest/Actions/ActionClone.cs +++ b/OpenNest/Actions/ActionClone.cs @@ -186,8 +186,8 @@ namespace OpenNest.Actions boxes.Add(part.BoundingBox.Offset(plate.PartSpacing)); var pt = plateView.CurrentPoint; - var vertical = Helper.GetLargestBoxVertically(pt, bounds, boxes); - var horizontal = Helper.GetLargestBoxHorizontally(pt, bounds, boxes); + var vertical = SpatialQuery.GetLargestBoxVertically(pt, bounds, boxes); + var horizontal = SpatialQuery.GetLargestBoxHorizontally(pt, bounds, boxes); var bestArea = vertical; if (horizontal.Area() > vertical.Area()) diff --git a/OpenNest/Actions/ActionFillArea.cs b/OpenNest/Actions/ActionFillArea.cs index 00d2da3..4101232 100644 --- a/OpenNest/Actions/ActionFillArea.cs +++ b/OpenNest/Actions/ActionFillArea.cs @@ -47,7 +47,7 @@ namespace OpenNest.Actions { try { - var engine = new NestEngine(plateView.Plate); + var engine = NestEngineRegistry.Create(plateView.Plate); var parts = await Task.Run(() => engine.Fill(new NestItem { Drawing = drawing }, SelectedArea, progress, cts.Token)); @@ -61,7 +61,7 @@ namespace OpenNest.Actions } else { - var engine = new NestEngine(plateView.Plate); + var engine = NestEngineRegistry.Create(plateView.Plate); engine.Fill(new NestItem { Drawing = drawing }, SelectedArea); plateView.Invalidate(); } diff --git a/OpenNest/Actions/ActionSelectArea.cs b/OpenNest/Actions/ActionSelectArea.cs index d65c46b..77b3ff3 100644 --- a/OpenNest/Actions/ActionSelectArea.cs +++ b/OpenNest/Actions/ActionSelectArea.cs @@ -150,8 +150,8 @@ namespace OpenNest.Actions private void UpdateSelectedArea() { SelectedArea = altSelect - ? Helper.GetLargestBoxHorizontally(plateView.CurrentPoint, Bounds, boxes) - : Helper.GetLargestBoxVertically(plateView.CurrentPoint, Bounds, boxes); + ? SpatialQuery.GetLargestBoxHorizontally(plateView.CurrentPoint, Bounds, boxes) + : SpatialQuery.GetLargestBoxVertically(plateView.CurrentPoint, Bounds, boxes); plateView.Invalidate(); } diff --git a/OpenNest/Actions/ActionSetSequence.cs b/OpenNest/Actions/ActionSetSequence.cs index e76e4e2..fb2ed33 100644 --- a/OpenNest/Actions/ActionSetSequence.cs +++ b/OpenNest/Actions/ActionSetSequence.cs @@ -52,7 +52,7 @@ namespace OpenNest.Actions { var entities = ConvertProgram.ToGeometry(part.Program).Where(e => e.Layer == SpecialLayers.Cut).ToList(); entities.ForEach(entity => entity.Offset(part.Location)); - var shapes = Helper.GetShapes(entities); + var shapes = ShapeBuilder.GetShapes(entities); var shape = new Shape(); shape.Entities.AddRange(shapes); ShapePartPairs.Add(new Pair() { Part = part, Shape = shape }); diff --git a/OpenNest/Controls/PlateView.cs b/OpenNest/Controls/PlateView.cs index 5ef9bba..5d884ac 100644 --- a/OpenNest/Controls/PlateView.cs +++ b/OpenNest/Controls/PlateView.cs @@ -833,11 +833,11 @@ namespace OpenNest.Controls try { - var engine = new NestEngine(Plate); + var engine = NestEngineRegistry.Create(Plate); var parts = await Task.Run(() => engine.Fill(groupParts, workArea, progress, cts.Token)); - if (parts.Count > 0) + if (parts.Count > 0 && !cts.IsCancellationRequested) { AcceptTemporaryParts(); sw.Stop(); @@ -937,97 +937,10 @@ namespace OpenNest.Controls public void PushSelected(PushDirection direction) { - // Build line segments for all stationary parts. - var stationaryParts = parts.Where(p => !p.IsSelected && !SelectedParts.Contains(p)).ToList(); - var stationaryLines = new List>(stationaryParts.Count); - var stationaryBoxes = new List(stationaryParts.Count); - - var opposite = Helper.OppositeDirection(direction); - var halfSpacing = Plate.PartSpacing / 2; - - foreach (var part in stationaryParts) - { - stationaryLines.Add(halfSpacing > 0 - ? Helper.GetOffsetPartLines(part.BasePart, halfSpacing, opposite, OffsetTolerance) - : Helper.GetPartLines(part.BasePart, opposite, OffsetTolerance)); - stationaryBoxes.Add(part.BoundingBox); - } - - var workArea = Plate.WorkArea(); - var distance = double.MaxValue; - - foreach (var selected in SelectedParts) - { - // Get offset lines for the moving part (half-spacing, symmetric with stationary). - var movingLines = halfSpacing > 0 - ? Helper.GetOffsetPartLines(selected.BasePart, halfSpacing, direction, OffsetTolerance) - : Helper.GetPartLines(selected.BasePart, direction, OffsetTolerance); - - var movingBox = selected.BoundingBox; - - // Check geometry distance against each stationary part. - for (int i = 0; i < stationaryLines.Count; i++) - { - // Early-out: skip if bounding boxes don't overlap on the perpendicular axis. - var stBox = stationaryBoxes[i]; - bool perpOverlap; - - switch (direction) - { - case PushDirection.Left: - case PushDirection.Right: - perpOverlap = !(movingBox.Bottom >= stBox.Top || movingBox.Top <= stBox.Bottom); - break; - default: // Up, Down - perpOverlap = !(movingBox.Left >= stBox.Right || movingBox.Right <= stBox.Left); - break; - } - - if (!perpOverlap) - continue; - - var d = Helper.DirectionalDistance(movingLines, stationaryLines[i], direction); - if (d < distance) - distance = d; - } - - // Check distance to plate edge (actual geometry bbox, not offset). - double edgeDist; - switch (direction) - { - case PushDirection.Left: - edgeDist = selected.Left - workArea.Left; - break; - case PushDirection.Right: - edgeDist = workArea.Right - selected.Right; - break; - case PushDirection.Up: - edgeDist = workArea.Top - selected.Top; - break; - default: // Down - edgeDist = selected.Bottom - workArea.Bottom; - break; - } - - if (edgeDist > 0 && edgeDist < distance) - distance = edgeDist; - } - - if (distance < double.MaxValue && distance > 0) - { - var offset = new Vector(); - - switch (direction) - { - case PushDirection.Left: offset.X = -distance; break; - case PushDirection.Right: offset.X = distance; break; - case PushDirection.Up: offset.Y = distance; break; - case PushDirection.Down: offset.Y = -distance; break; - } - - SelectedParts.ForEach(p => p.Offset(offset)); - Invalidate(); - } + var movingParts = SelectedParts.Select(p => p.BasePart).ToList(); + Compactor.Push(movingParts, Plate, direction); + SelectedParts.ForEach(p => p.IsDirty = true); + Invalidate(); } private string GetDisplayName(Type type) diff --git a/OpenNest/Forms/BestFitViewerForm.Designer.cs b/OpenNest/Forms/BestFitViewerForm.Designer.cs index a53a48a..e977148 100644 --- a/OpenNest/Forms/BestFitViewerForm.Designer.cs +++ b/OpenNest/Forms/BestFitViewerForm.Designer.cs @@ -14,11 +14,15 @@ namespace OpenNest.Forms private void InitializeComponent() { this.gridPanel = new System.Windows.Forms.TableLayoutPanel(); + this.navPanel = new System.Windows.Forms.Panel(); + this.btnPrev = new System.Windows.Forms.Button(); + this.btnNext = new System.Windows.Forms.Button(); + this.lblPage = new System.Windows.Forms.Label(); + this.navPanel.SuspendLayout(); this.SuspendLayout(); // // gridPanel // - this.gridPanel.AutoScroll = true; this.gridPanel.ColumnCount = 5; this.gridPanel.ColumnStyles.Add(new System.Windows.Forms.ColumnStyle(System.Windows.Forms.SizeType.Percent, 20F)); this.gridPanel.ColumnStyles.Add(new System.Windows.Forms.ColumnStyle(System.Windows.Forms.SizeType.Percent, 20F)); @@ -28,24 +32,72 @@ namespace OpenNest.Forms this.gridPanel.Dock = System.Windows.Forms.DockStyle.Fill; this.gridPanel.Location = new System.Drawing.Point(0, 0); this.gridPanel.Name = "gridPanel"; - this.gridPanel.RowCount = 1; - this.gridPanel.RowStyles.Add(new System.Windows.Forms.RowStyle()); - this.gridPanel.Size = new System.Drawing.Size(1200, 800); + this.gridPanel.RowCount = 2; + this.gridPanel.RowStyles.Add(new System.Windows.Forms.RowStyle(System.Windows.Forms.SizeType.Percent, 50F)); + this.gridPanel.RowStyles.Add(new System.Windows.Forms.RowStyle(System.Windows.Forms.SizeType.Percent, 50F)); + this.gridPanel.Size = new System.Drawing.Size(1200, 764); this.gridPanel.TabIndex = 0; // + // navPanel + // + this.navPanel.Controls.Add(this.btnPrev); + this.navPanel.Controls.Add(this.lblPage); + this.navPanel.Controls.Add(this.btnNext); + this.navPanel.Dock = System.Windows.Forms.DockStyle.Bottom; + this.navPanel.Location = new System.Drawing.Point(0, 764); + this.navPanel.Name = "navPanel"; + this.navPanel.Size = new System.Drawing.Size(1200, 36); + this.navPanel.TabIndex = 1; + // + // btnPrev + // + this.btnPrev.FlatStyle = System.Windows.Forms.FlatStyle.Flat; + this.btnPrev.Location = new System.Drawing.Point(4, 4); + this.btnPrev.Name = "btnPrev"; + this.btnPrev.Size = new System.Drawing.Size(80, 28); + this.btnPrev.TabIndex = 0; + this.btnPrev.Text = "< Prev"; + this.btnPrev.Click += new System.EventHandler(this.btnPrev_Click); + // + // lblPage + // + this.lblPage.Dock = System.Windows.Forms.DockStyle.Fill; + this.lblPage.Name = "lblPage"; + this.lblPage.Size = new System.Drawing.Size(1200, 36); + this.lblPage.TabIndex = 1; + this.lblPage.Text = "Page 1 / 1"; + this.lblPage.TextAlign = System.Drawing.ContentAlignment.MiddleCenter; + // + // btnNext + // + this.btnNext.Anchor = System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Right; + this.btnNext.FlatStyle = System.Windows.Forms.FlatStyle.Flat; + this.btnNext.Location = new System.Drawing.Point(1116, 4); + this.btnNext.Name = "btnNext"; + this.btnNext.Size = new System.Drawing.Size(80, 28); + this.btnNext.TabIndex = 2; + this.btnNext.Text = "Next >"; + this.btnNext.Click += new System.EventHandler(this.btnNext_Click); + // // BestFitViewerForm // this.AutoScaleDimensions = new System.Drawing.SizeF(6F, 13F); this.AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font; this.ClientSize = new System.Drawing.Size(1200, 800); this.Controls.Add(this.gridPanel); + this.Controls.Add(this.navPanel); this.KeyPreview = true; this.Name = "BestFitViewerForm"; this.StartPosition = System.Windows.Forms.FormStartPosition.CenterParent; this.Text = "Best-Fit Viewer"; + this.navPanel.ResumeLayout(false); this.ResumeLayout(false); } private System.Windows.Forms.TableLayoutPanel gridPanel; + private System.Windows.Forms.Panel navPanel; + private System.Windows.Forms.Button btnPrev; + private System.Windows.Forms.Button btnNext; + private System.Windows.Forms.Label lblPage; } } diff --git a/OpenNest/Forms/BestFitViewerForm.cs b/OpenNest/Forms/BestFitViewerForm.cs index 2b52a4d..5d590a7 100644 --- a/OpenNest/Forms/BestFitViewerForm.cs +++ b/OpenNest/Forms/BestFitViewerForm.cs @@ -1,3 +1,4 @@ +using System.Collections.Generic; using System.Diagnostics; using System.Drawing; using System.Windows.Forms; @@ -9,7 +10,8 @@ namespace OpenNest.Forms public partial class BestFitViewerForm : Form { private const int Columns = 5; - private const int RowHeight = 300; + private const int Rows = 2; + private const int ItemsPerPage = Columns * Rows; private const int MaxResults = 50; private static readonly Color KeptColor = Color.FromArgb(0, 0, 100); @@ -18,6 +20,14 @@ namespace OpenNest.Forms private readonly Drawing drawing; private readonly Plate plate; + private List results; + private int totalResults; + private int keptCount; + private double computeSeconds; + private double totalSeconds; + private int currentPage; + private int pageCount; + public BestFitResult SelectedResult { get; private set; } public BestFitViewerForm(Drawing drawing, Plate plate) @@ -33,7 +43,8 @@ namespace OpenNest.Forms Cursor = Cursors.WaitCursor; try { - PopulateGrid(drawing, plate); + ComputeResults(); + ShowPage(0); } finally { @@ -48,51 +59,84 @@ namespace OpenNest.Forms Close(); return true; } + if (keyData == Keys.Left || keyData == Keys.PageUp) + { + NavigatePage(-1); + return true; + } + if (keyData == Keys.Right || keyData == Keys.PageDown) + { + NavigatePage(1); + return true; + } return base.ProcessCmdKey(ref msg, keyData); } - private void PopulateGrid(Drawing drawing, Plate plate) + private void ComputeResults() { var sw = Stopwatch.StartNew(); - var results = BestFitCache.GetOrCompute( + var all = BestFitCache.GetOrCompute( drawing, plate.Size.Width, plate.Size.Length, plate.PartSpacing); - var findMs = sw.ElapsedMilliseconds; - var total = results.Count; - var kept = 0; + computeSeconds = sw.ElapsedMilliseconds / 1000.0; + totalResults = all.Count; + keptCount = 0; - foreach (var r in results) + foreach (var r in all) { - if (r.Keep) kept++; + if (r.Keep) keptCount++; } - var count = System.Math.Min(total, MaxResults); - var rows = (int)System.Math.Ceiling(count / (double)Columns); - gridPanel.RowCount = rows; - gridPanel.RowStyles.Clear(); - - for (var i = 0; i < rows; i++) - gridPanel.RowStyles.Add(new RowStyle(SizeType.Absolute, RowHeight)); - - gridPanel.SuspendLayout(); - try - { - for (var i = 0; i < count; i++) - { - var result = results[i]; - var cell = CreateCell(result, drawing, i + 1); - gridPanel.Controls.Add(cell, i % Columns, i / Columns); - } - } - finally - { - gridPanel.ResumeLayout(true); - } + var count = System.Math.Min(totalResults, MaxResults); + results = all.GetRange(0, count); + pageCount = System.Math.Max(1, (int)System.Math.Ceiling(count / (double)ItemsPerPage)); sw.Stop(); - Text = string.Format("Best-Fit Viewer — {0} candidates ({1} kept) | Compute: {2:F1}s | Total: {3:F1}s | Showing {4}", - total, kept, findMs / 1000.0, sw.Elapsed.TotalSeconds, count); + totalSeconds = sw.Elapsed.TotalSeconds; + } + + private void ShowPage(int page) + { + currentPage = page; + var start = page * ItemsPerPage; + var count = System.Math.Min(ItemsPerPage, results.Count - start); + + gridPanel.SuspendLayout(); + gridPanel.Controls.Clear(); + + gridPanel.RowCount = Rows; + gridPanel.RowStyles.Clear(); + for (var i = 0; i < Rows; i++) + gridPanel.RowStyles.Add(new RowStyle(SizeType.Percent, 100f / Rows)); + + for (var i = 0; i < count; i++) + { + var result = results[start + i]; + var cell = CreateCell(result, drawing, start + i + 1); + gridPanel.Controls.Add(cell, i % Columns, i / Columns); + } + + gridPanel.ResumeLayout(true); + + btnPrev.Enabled = currentPage > 0; + btnNext.Enabled = currentPage < pageCount - 1; + lblPage.Text = string.Format("Page {0} / {1}", currentPage + 1, pageCount); + + Text = string.Format("Best-Fit Viewer — {0} candidates ({1} kept) | Compute: {2:F1}s | Total: {3:F1}s | Showing {4}-{5} of {6}", + totalResults, keptCount, computeSeconds, totalSeconds, + start + 1, start + count, results.Count); + } + + private void btnPrev_Click(object sender, System.EventArgs e) => NavigatePage(-1); + + private void btnNext_Click(object sender, System.EventArgs e) => NavigatePage(1); + + private void NavigatePage(int delta) + { + var newPage = currentPage + delta; + if (newPage >= 0 && newPage < pageCount) + ShowPage(newPage); } private BestFitCell CreateCell(BestFitResult result, Drawing drawing, int rank) diff --git a/OpenNest/Forms/EditNestForm.cs b/OpenNest/Forms/EditNestForm.cs index e838871..1bcc52e 100644 --- a/OpenNest/Forms/EditNestForm.cs +++ b/OpenNest/Forms/EditNestForm.cs @@ -6,8 +6,12 @@ using System.IO; using System.Linq; using System.Windows.Forms; using OpenNest.Actions; +using OpenNest.CNC.CuttingStrategy; using OpenNest.Collections; using OpenNest.Controls; +using OpenNest.Engine; +using OpenNest.Engine.RapidPlanning; +using OpenNest.Engine.Sequencing; using OpenNest.IO; using OpenNest.Math; using OpenNest.Properties; @@ -438,27 +442,29 @@ namespace OpenNest.Forms public void AutoSequenceCurrentPlate() { - var seq = new SequenceByNearest(); - var parts = seq.SequenceParts(PlateView.Plate.Parts); - - PlateView.Plate.Parts.Clear(); - PlateView.Plate.Parts.AddRange(parts); + SequencePlate(PlateView.Plate); + PlateView.Invalidate(); } public void AutoSequenceAllPlates() { - var seq = new SequenceByNearest(); - foreach (var plate in Nest.Plates) - { - var parts = seq.SequenceParts(plate.Parts); - plate.Parts.Clear(); - plate.Parts.AddRange(parts); - } + SequencePlate(plate); PlateView.Invalidate(); } + private static void SequencePlate(Plate plate) + { + var parameters = new SequenceParameters { Method = SequenceMethod.LeastCode }; + var sequencer = PartSequencerFactory.Create(parameters); + var ordered = sequencer.Sequence(plate.Parts.ToList(), plate); + + plate.Parts.Clear(); + for (var i = ordered.Count - 1; i >= 0; i--) + plate.Parts.Add(ordered[i].Part); + } + public void CalculateCurrentPlateCutTime() { var cutParamsForm = new CutParametersForm(); diff --git a/OpenNest/Forms/MainForm.Designer.cs b/OpenNest/Forms/MainForm.Designer.cs index e32d58d..6aad83f 100644 --- a/OpenNest/Forms/MainForm.Designer.cs +++ b/OpenNest/Forms/MainForm.Designer.cs @@ -28,1163 +28,1082 @@ /// private void InitializeComponent() { - this.menuStrip1 = new System.Windows.Forms.MenuStrip(); - this.mnuFile = new System.Windows.Forms.ToolStripMenuItem(); - this.mnuFileNew = new System.Windows.Forms.ToolStripMenuItem(); - this.mnuFileOpen = new System.Windows.Forms.ToolStripMenuItem(); - this.toolStripMenuItem1 = new System.Windows.Forms.ToolStripSeparator(); - this.mnuFileSave = new System.Windows.Forms.ToolStripMenuItem(); - this.mnuFileSaveAs = new System.Windows.Forms.ToolStripMenuItem(); - this.toolStripMenuItem2 = new System.Windows.Forms.ToolStripSeparator(); - this.mnuFileExport = new System.Windows.Forms.ToolStripMenuItem(); - this.mnuFileExportAll = new System.Windows.Forms.ToolStripMenuItem(); - this.toolStripMenuItem3 = new System.Windows.Forms.ToolStripSeparator(); - this.mnuFileExit = new System.Windows.Forms.ToolStripMenuItem(); - this.mnuEdit = new System.Windows.Forms.ToolStripMenuItem(); - this.mnuEditCut = new System.Windows.Forms.ToolStripMenuItem(); - this.mnuEditCopy = new System.Windows.Forms.ToolStripMenuItem(); - this.mnuEditPaste = new System.Windows.Forms.ToolStripMenuItem(); - this.toolStripMenuItem4 = new System.Windows.Forms.ToolStripSeparator(); - this.mnuEditSelectAll = new System.Windows.Forms.ToolStripMenuItem(); - this.mnuView = new System.Windows.Forms.ToolStripMenuItem(); - this.mnuViewDrawRapids = new System.Windows.Forms.ToolStripMenuItem(); - this.mnuViewDrawBounds = new System.Windows.Forms.ToolStripMenuItem(); - this.mnuViewDrawOffset = new System.Windows.Forms.ToolStripMenuItem(); - this.toolStripMenuItem5 = new System.Windows.Forms.ToolStripSeparator(); - this.mnuViewZoomTo = new System.Windows.Forms.ToolStripMenuItem(); - this.mnuViewZoomToArea = new System.Windows.Forms.ToolStripMenuItem(); - this.mnuViewZoomToFit = new System.Windows.Forms.ToolStripMenuItem(); - this.mnuViewZoomToPlate = new System.Windows.Forms.ToolStripMenuItem(); - this.mnuViewZoomToSelected = new System.Windows.Forms.ToolStripMenuItem(); - this.mnuViewZoomIn = new System.Windows.Forms.ToolStripMenuItem(); - this.mnuViewZoomOut = new System.Windows.Forms.ToolStripMenuItem(); - this.mnuTools = new System.Windows.Forms.ToolStripMenuItem(); - this.mnuToolsBestFitViewer = new System.Windows.Forms.ToolStripMenuItem(); - this.mnuToolsMeasureArea = new System.Windows.Forms.ToolStripMenuItem(); - this.mnuToolsAlign = new System.Windows.Forms.ToolStripMenuItem(); - this.mnuToolsAlignLeft = new System.Windows.Forms.ToolStripMenuItem(); - this.mnuToolsAlignRight = new System.Windows.Forms.ToolStripMenuItem(); - this.mnuToolsAlignTop = new System.Windows.Forms.ToolStripMenuItem(); - this.mnuToolsAlignBottom = new System.Windows.Forms.ToolStripMenuItem(); - this.toolStripMenuItem11 = new System.Windows.Forms.ToolStripSeparator(); - this.mnuToolsAlignHorizontal = new System.Windows.Forms.ToolStripMenuItem(); - this.mnuToolsAlignVertically = new System.Windows.Forms.ToolStripMenuItem(); - this.toolStripMenuItem8 = new System.Windows.Forms.ToolStripSeparator(); - this.mnuToolsEvenlySpaceHorizontal = new System.Windows.Forms.ToolStripMenuItem(); - this.mnuToolsEvenlySpaceVertical = new System.Windows.Forms.ToolStripMenuItem(); - this.toolStripMenuItem14 = new System.Windows.Forms.ToolStripSeparator(); - this.mnuSetOffsetIncrement = new System.Windows.Forms.ToolStripMenuItem(); - this.mnuSetRotationIncrement = new System.Windows.Forms.ToolStripMenuItem(); - this.toolStripMenuItem15 = new System.Windows.Forms.ToolStripSeparator(); - this.mnuToolsOptions = new System.Windows.Forms.ToolStripMenuItem(); - this.mnuNest = new System.Windows.Forms.ToolStripMenuItem(); - this.mnuNestEdit = new System.Windows.Forms.ToolStripMenuItem(); - this.mnuNestImportDrawing = new System.Windows.Forms.ToolStripMenuItem(); - this.toolStripMenuItem7 = new System.Windows.Forms.ToolStripSeparator(); - this.mnuNestFirstPlate = new System.Windows.Forms.ToolStripMenuItem(); - this.mnuNestLastPlate = new System.Windows.Forms.ToolStripMenuItem(); - this.toolStripMenuItem6 = new System.Windows.Forms.ToolStripSeparator(); - this.mnuNestNextPlate = new System.Windows.Forms.ToolStripMenuItem(); - this.mnuNestPreviousPlate = new System.Windows.Forms.ToolStripMenuItem(); - this.toolStripMenuItem12 = new System.Windows.Forms.ToolStripSeparator(); - this.runAutoNestToolStripMenuItem = new System.Windows.Forms.ToolStripMenuItem(); - this.autoSequenceAllPlatesToolStripMenuItem = new System.Windows.Forms.ToolStripMenuItem(); - this.mnuNestRemoveEmptyPlates = new System.Windows.Forms.ToolStripMenuItem(); - this.mnuNestPost = new System.Windows.Forms.ToolStripMenuItem(); - this.toolStripMenuItem19 = new System.Windows.Forms.ToolStripSeparator(); - this.calculateCutTimeToolStripMenuItem = new System.Windows.Forms.ToolStripMenuItem(); - this.mnuPlate = new System.Windows.Forms.ToolStripMenuItem(); - this.mnuPlateEdit = new System.Windows.Forms.ToolStripMenuItem(); - this.mnuPlateSetAsDefault = new System.Windows.Forms.ToolStripMenuItem(); - this.toolStripMenuItem18 = new System.Windows.Forms.ToolStripSeparator(); - this.mnuPlateAdd = new System.Windows.Forms.ToolStripMenuItem(); - this.mnuPlateRemove = new System.Windows.Forms.ToolStripMenuItem(); - this.toolStripMenuItem16 = new System.Windows.Forms.ToolStripSeparator(); - this.mnuPlateFill = new System.Windows.Forms.ToolStripMenuItem(); - this.toolStripMenuItem9 = new System.Windows.Forms.ToolStripMenuItem(); - this.mnuPlateRotate = new System.Windows.Forms.ToolStripMenuItem(); - this.mnuPlateRotateCw = new System.Windows.Forms.ToolStripMenuItem(); - this.mnuPlateRotateCcw = new System.Windows.Forms.ToolStripMenuItem(); - this.toolStripSeparator2 = new System.Windows.Forms.ToolStripSeparator(); - this.mnuPlateRotate180 = new System.Windows.Forms.ToolStripMenuItem(); - this.mnuResizeToFitParts = new System.Windows.Forms.ToolStripMenuItem(); - this.toolStripMenuItem13 = new System.Windows.Forms.ToolStripSeparator(); - this.mnuPlateViewInCad = new System.Windows.Forms.ToolStripMenuItem(); - this.toolStripMenuItem20 = new System.Windows.Forms.ToolStripSeparator(); - this.mnuSequenceParts = new System.Windows.Forms.ToolStripMenuItem(); - this.autoSequenceCurrentPlateToolStripMenuItem = new System.Windows.Forms.ToolStripMenuItem(); - this.manualSequenceToolStripMenuItem = new System.Windows.Forms.ToolStripMenuItem(); - this.calculateCutTimeToolStripMenuItem1 = new System.Windows.Forms.ToolStripMenuItem(); - this.centerPartsToolStripMenuItem = new System.Windows.Forms.ToolStripMenuItem(); - this.mnuWindow = new System.Windows.Forms.ToolStripMenuItem(); - this.mnuWindowCascade = new System.Windows.Forms.ToolStripMenuItem(); - this.mnuWindowTileVertical = new System.Windows.Forms.ToolStripMenuItem(); - this.mnuWindowTileHorizontal = new System.Windows.Forms.ToolStripMenuItem(); - this.toolStripMenuItem10 = new System.Windows.Forms.ToolStripSeparator(); - this.closeToolStripMenuItem = new System.Windows.Forms.ToolStripMenuItem(); - this.mnuCloseAll = new System.Windows.Forms.ToolStripMenuItem(); - this.statusStrip1 = new System.Windows.Forms.StatusStrip(); - this.statusLabel1 = new System.Windows.Forms.ToolStripStatusLabel(); - this.locationStatusLabel = new System.Windows.Forms.ToolStripStatusLabel(); - this.spacerLabel = new System.Windows.Forms.ToolStripStatusLabel(); - this.plateIndexStatusLabel = new System.Windows.Forms.ToolStripStatusLabel(); - this.plateSizeStatusLabel = new System.Windows.Forms.ToolStripStatusLabel(); - this.plateQtyStatusLabel = new System.Windows.Forms.ToolStripStatusLabel(); - this.gpuStatusLabel = new System.Windows.Forms.ToolStripStatusLabel(); - this.toolStrip1 = new System.Windows.Forms.ToolStrip(); - this.btnNew = new System.Windows.Forms.ToolStripButton(); - this.btnOpen = new System.Windows.Forms.ToolStripButton(); - this.btnSave = new System.Windows.Forms.ToolStripButton(); - this.btnSaveAs = new System.Windows.Forms.ToolStripButton(); - this.toolStripSeparator1 = new System.Windows.Forms.ToolStripSeparator(); - this.btnFirstPlate = new System.Windows.Forms.ToolStripButton(); - this.btnPreviousPlate = new System.Windows.Forms.ToolStripButton(); - this.btnNextPlate = new System.Windows.Forms.ToolStripButton(); - this.btnLastPlate = new System.Windows.Forms.ToolStripButton(); - this.toolStripSeparator3 = new System.Windows.Forms.ToolStripSeparator(); - this.btnZoomOut = new System.Windows.Forms.ToolStripButton(); - this.btnZoomIn = new System.Windows.Forms.ToolStripButton(); - this.btnZoomToFit = new System.Windows.Forms.ToolStripButton(); - this.pEPToolStripMenuItem = new System.Windows.Forms.ToolStripMenuItem(); - this.openNestToolStripMenuItem = new System.Windows.Forms.ToolStripMenuItem(); - this.menuStrip1.SuspendLayout(); - this.statusStrip1.SuspendLayout(); - this.toolStrip1.SuspendLayout(); - this.SuspendLayout(); + menuStrip1 = new System.Windows.Forms.MenuStrip(); + mnuFile = new System.Windows.Forms.ToolStripMenuItem(); + mnuFileNew = new System.Windows.Forms.ToolStripMenuItem(); + mnuFileOpen = new System.Windows.Forms.ToolStripMenuItem(); + toolStripMenuItem1 = new System.Windows.Forms.ToolStripSeparator(); + mnuFileSave = new System.Windows.Forms.ToolStripMenuItem(); + mnuFileSaveAs = new System.Windows.Forms.ToolStripMenuItem(); + toolStripMenuItem2 = new System.Windows.Forms.ToolStripSeparator(); + mnuFileExport = new System.Windows.Forms.ToolStripMenuItem(); + mnuFileExportAll = new System.Windows.Forms.ToolStripMenuItem(); + toolStripMenuItem3 = new System.Windows.Forms.ToolStripSeparator(); + mnuFileExit = new System.Windows.Forms.ToolStripMenuItem(); + mnuEdit = new System.Windows.Forms.ToolStripMenuItem(); + mnuEditCut = new System.Windows.Forms.ToolStripMenuItem(); + mnuEditCopy = new System.Windows.Forms.ToolStripMenuItem(); + mnuEditPaste = new System.Windows.Forms.ToolStripMenuItem(); + toolStripMenuItem4 = new System.Windows.Forms.ToolStripSeparator(); + mnuEditSelectAll = new System.Windows.Forms.ToolStripMenuItem(); + mnuView = new System.Windows.Forms.ToolStripMenuItem(); + mnuViewDrawRapids = new System.Windows.Forms.ToolStripMenuItem(); + mnuViewDrawBounds = new System.Windows.Forms.ToolStripMenuItem(); + mnuViewDrawOffset = new System.Windows.Forms.ToolStripMenuItem(); + toolStripMenuItem5 = new System.Windows.Forms.ToolStripSeparator(); + mnuViewZoomTo = new System.Windows.Forms.ToolStripMenuItem(); + mnuViewZoomToArea = new System.Windows.Forms.ToolStripMenuItem(); + mnuViewZoomToFit = new System.Windows.Forms.ToolStripMenuItem(); + mnuViewZoomToPlate = new System.Windows.Forms.ToolStripMenuItem(); + mnuViewZoomToSelected = new System.Windows.Forms.ToolStripMenuItem(); + mnuViewZoomIn = new System.Windows.Forms.ToolStripMenuItem(); + mnuViewZoomOut = new System.Windows.Forms.ToolStripMenuItem(); + mnuTools = new System.Windows.Forms.ToolStripMenuItem(); + mnuToolsMeasureArea = new System.Windows.Forms.ToolStripMenuItem(); + mnuToolsBestFitViewer = new System.Windows.Forms.ToolStripMenuItem(); + mnuToolsAlign = new System.Windows.Forms.ToolStripMenuItem(); + mnuToolsAlignLeft = new System.Windows.Forms.ToolStripMenuItem(); + mnuToolsAlignRight = new System.Windows.Forms.ToolStripMenuItem(); + mnuToolsAlignTop = new System.Windows.Forms.ToolStripMenuItem(); + mnuToolsAlignBottom = new System.Windows.Forms.ToolStripMenuItem(); + toolStripMenuItem11 = new System.Windows.Forms.ToolStripSeparator(); + mnuToolsAlignHorizontal = new System.Windows.Forms.ToolStripMenuItem(); + mnuToolsAlignVertically = new System.Windows.Forms.ToolStripMenuItem(); + toolStripMenuItem8 = new System.Windows.Forms.ToolStripSeparator(); + mnuToolsEvenlySpaceHorizontal = new System.Windows.Forms.ToolStripMenuItem(); + mnuToolsEvenlySpaceVertical = new System.Windows.Forms.ToolStripMenuItem(); + toolStripMenuItem14 = new System.Windows.Forms.ToolStripSeparator(); + mnuSetOffsetIncrement = new System.Windows.Forms.ToolStripMenuItem(); + mnuSetRotationIncrement = new System.Windows.Forms.ToolStripMenuItem(); + toolStripMenuItem15 = new System.Windows.Forms.ToolStripSeparator(); + mnuToolsOptions = new System.Windows.Forms.ToolStripMenuItem(); + mnuNest = new System.Windows.Forms.ToolStripMenuItem(); + mnuNestEdit = new System.Windows.Forms.ToolStripMenuItem(); + mnuNestImportDrawing = new System.Windows.Forms.ToolStripMenuItem(); + toolStripMenuItem7 = new System.Windows.Forms.ToolStripSeparator(); + mnuNestFirstPlate = new System.Windows.Forms.ToolStripMenuItem(); + mnuNestLastPlate = new System.Windows.Forms.ToolStripMenuItem(); + toolStripMenuItem6 = new System.Windows.Forms.ToolStripSeparator(); + mnuNestNextPlate = new System.Windows.Forms.ToolStripMenuItem(); + mnuNestPreviousPlate = new System.Windows.Forms.ToolStripMenuItem(); + toolStripMenuItem12 = new System.Windows.Forms.ToolStripSeparator(); + runAutoNestToolStripMenuItem = new System.Windows.Forms.ToolStripMenuItem(); + autoSequenceAllPlatesToolStripMenuItem = new System.Windows.Forms.ToolStripMenuItem(); + mnuNestRemoveEmptyPlates = new System.Windows.Forms.ToolStripMenuItem(); + mnuNestPost = new System.Windows.Forms.ToolStripMenuItem(); + toolStripMenuItem19 = new System.Windows.Forms.ToolStripSeparator(); + calculateCutTimeToolStripMenuItem = new System.Windows.Forms.ToolStripMenuItem(); + mnuPlate = new System.Windows.Forms.ToolStripMenuItem(); + mnuPlateEdit = new System.Windows.Forms.ToolStripMenuItem(); + mnuPlateSetAsDefault = new System.Windows.Forms.ToolStripMenuItem(); + toolStripMenuItem18 = new System.Windows.Forms.ToolStripSeparator(); + mnuPlateAdd = new System.Windows.Forms.ToolStripMenuItem(); + mnuPlateRemove = new System.Windows.Forms.ToolStripMenuItem(); + toolStripMenuItem16 = new System.Windows.Forms.ToolStripSeparator(); + mnuPlateFill = new System.Windows.Forms.ToolStripMenuItem(); + toolStripMenuItem9 = new System.Windows.Forms.ToolStripMenuItem(); + mnuPlateRotate = new System.Windows.Forms.ToolStripMenuItem(); + mnuPlateRotateCw = new System.Windows.Forms.ToolStripMenuItem(); + mnuPlateRotateCcw = new System.Windows.Forms.ToolStripMenuItem(); + toolStripSeparator2 = new System.Windows.Forms.ToolStripSeparator(); + mnuPlateRotate180 = new System.Windows.Forms.ToolStripMenuItem(); + mnuResizeToFitParts = new System.Windows.Forms.ToolStripMenuItem(); + toolStripMenuItem13 = new System.Windows.Forms.ToolStripSeparator(); + mnuPlateViewInCad = new System.Windows.Forms.ToolStripMenuItem(); + toolStripMenuItem20 = new System.Windows.Forms.ToolStripSeparator(); + mnuSequenceParts = new System.Windows.Forms.ToolStripMenuItem(); + autoSequenceCurrentPlateToolStripMenuItem = new System.Windows.Forms.ToolStripMenuItem(); + manualSequenceToolStripMenuItem = new System.Windows.Forms.ToolStripMenuItem(); + calculateCutTimeToolStripMenuItem1 = new System.Windows.Forms.ToolStripMenuItem(); + centerPartsToolStripMenuItem = new System.Windows.Forms.ToolStripMenuItem(); + mnuWindow = new System.Windows.Forms.ToolStripMenuItem(); + mnuWindowCascade = new System.Windows.Forms.ToolStripMenuItem(); + mnuWindowTileVertical = new System.Windows.Forms.ToolStripMenuItem(); + mnuWindowTileHorizontal = new System.Windows.Forms.ToolStripMenuItem(); + toolStripMenuItem10 = new System.Windows.Forms.ToolStripSeparator(); + closeToolStripMenuItem = new System.Windows.Forms.ToolStripMenuItem(); + mnuCloseAll = new System.Windows.Forms.ToolStripMenuItem(); + statusStrip1 = new System.Windows.Forms.StatusStrip(); + statusLabel1 = new System.Windows.Forms.ToolStripStatusLabel(); + locationStatusLabel = new System.Windows.Forms.ToolStripStatusLabel(); + spacerLabel = new System.Windows.Forms.ToolStripStatusLabel(); + plateIndexStatusLabel = new System.Windows.Forms.ToolStripStatusLabel(); + plateSizeStatusLabel = new System.Windows.Forms.ToolStripStatusLabel(); + plateQtyStatusLabel = new System.Windows.Forms.ToolStripStatusLabel(); + gpuStatusLabel = new System.Windows.Forms.ToolStripStatusLabel(); + toolStrip1 = new System.Windows.Forms.ToolStrip(); + btnNew = new System.Windows.Forms.ToolStripButton(); + btnOpen = new System.Windows.Forms.ToolStripButton(); + btnSave = new System.Windows.Forms.ToolStripButton(); + btnSaveAs = new System.Windows.Forms.ToolStripButton(); + toolStripSeparator1 = new System.Windows.Forms.ToolStripSeparator(); + btnFirstPlate = new System.Windows.Forms.ToolStripButton(); + btnPreviousPlate = new System.Windows.Forms.ToolStripButton(); + btnNextPlate = new System.Windows.Forms.ToolStripButton(); + btnLastPlate = new System.Windows.Forms.ToolStripButton(); + toolStripSeparator3 = new System.Windows.Forms.ToolStripSeparator(); + btnZoomOut = new System.Windows.Forms.ToolStripButton(); + btnZoomIn = new System.Windows.Forms.ToolStripButton(); + btnZoomToFit = new System.Windows.Forms.ToolStripButton(); + toolStripSeparator4 = new System.Windows.Forms.ToolStripSeparator(); + engineLabel = new System.Windows.Forms.ToolStripLabel(); + engineComboBox = new System.Windows.Forms.ToolStripComboBox(); + btnAutoNest = new System.Windows.Forms.ToolStripButton(); + pEPToolStripMenuItem = new System.Windows.Forms.ToolStripMenuItem(); + openNestToolStripMenuItem = new System.Windows.Forms.ToolStripMenuItem(); + menuStrip1.SuspendLayout(); + statusStrip1.SuspendLayout(); + toolStrip1.SuspendLayout(); + SuspendLayout(); // // menuStrip1 // - this.menuStrip1.Items.AddRange(new System.Windows.Forms.ToolStripItem[] { - this.mnuFile, - this.mnuEdit, - this.mnuView, - this.mnuTools, - this.mnuNest, - this.mnuPlate, - this.mnuWindow}); - this.menuStrip1.Location = new System.Drawing.Point(0, 0); - this.menuStrip1.Name = "menuStrip1"; - this.menuStrip1.Size = new System.Drawing.Size(1098, 24); - this.menuStrip1.TabIndex = 7; - this.menuStrip1.Text = "menuStrip1"; + menuStrip1.Items.AddRange(new System.Windows.Forms.ToolStripItem[] { mnuFile, mnuEdit, mnuView, mnuTools, mnuNest, mnuPlate, mnuWindow }); + menuStrip1.Location = new System.Drawing.Point(0, 0); + menuStrip1.Name = "menuStrip1"; + menuStrip1.Padding = new System.Windows.Forms.Padding(7, 2, 0, 2); + menuStrip1.Size = new System.Drawing.Size(1281, 24); + menuStrip1.TabIndex = 7; + menuStrip1.Text = "menuStrip1"; // // mnuFile // - this.mnuFile.DropDownItems.AddRange(new System.Windows.Forms.ToolStripItem[] { - this.mnuFileNew, - this.mnuFileOpen, - this.toolStripMenuItem1, - this.mnuFileSave, - this.mnuFileSaveAs, - this.toolStripMenuItem2, - this.mnuFileExport, - this.mnuFileExportAll, - this.toolStripMenuItem3, - this.mnuFileExit}); - this.mnuFile.Name = "mnuFile"; - this.mnuFile.Size = new System.Drawing.Size(37, 20); - this.mnuFile.Text = "&File"; + mnuFile.DropDownItems.AddRange(new System.Windows.Forms.ToolStripItem[] { mnuFileNew, mnuFileOpen, toolStripMenuItem1, mnuFileSave, mnuFileSaveAs, toolStripMenuItem2, mnuFileExport, mnuFileExportAll, toolStripMenuItem3, mnuFileExit }); + mnuFile.Name = "mnuFile"; + mnuFile.Size = new System.Drawing.Size(37, 20); + mnuFile.Text = "&File"; // // mnuFileNew // - this.mnuFileNew.Image = global::OpenNest.Properties.Resources.doc_new; - this.mnuFileNew.Name = "mnuFileNew"; - this.mnuFileNew.ShortcutKeys = ((System.Windows.Forms.Keys)((System.Windows.Forms.Keys.Control | System.Windows.Forms.Keys.N))); - this.mnuFileNew.Size = new System.Drawing.Size(146, 22); - this.mnuFileNew.Text = "New"; - this.mnuFileNew.Click += new System.EventHandler(this.New_Click); + mnuFileNew.Image = Properties.Resources.doc_new; + mnuFileNew.Name = "mnuFileNew"; + mnuFileNew.ShortcutKeys = System.Windows.Forms.Keys.Control | System.Windows.Forms.Keys.N; + mnuFileNew.Size = new System.Drawing.Size(146, 22); + mnuFileNew.Text = "New"; + mnuFileNew.Click += New_Click; // // mnuFileOpen // - this.mnuFileOpen.Image = global::OpenNest.Properties.Resources.doc_open; - this.mnuFileOpen.Name = "mnuFileOpen"; - this.mnuFileOpen.ShortcutKeys = ((System.Windows.Forms.Keys)((System.Windows.Forms.Keys.Control | System.Windows.Forms.Keys.O))); - this.mnuFileOpen.Size = new System.Drawing.Size(146, 22); - this.mnuFileOpen.Text = "Open"; - this.mnuFileOpen.Click += new System.EventHandler(this.Open_Click); + mnuFileOpen.Image = Properties.Resources.doc_open; + mnuFileOpen.Name = "mnuFileOpen"; + mnuFileOpen.ShortcutKeys = System.Windows.Forms.Keys.Control | System.Windows.Forms.Keys.O; + mnuFileOpen.Size = new System.Drawing.Size(146, 22); + mnuFileOpen.Text = "Open"; + mnuFileOpen.Click += Open_Click; // // toolStripMenuItem1 // - this.toolStripMenuItem1.Name = "toolStripMenuItem1"; - this.toolStripMenuItem1.Size = new System.Drawing.Size(143, 6); + toolStripMenuItem1.Name = "toolStripMenuItem1"; + toolStripMenuItem1.Size = new System.Drawing.Size(143, 6); // // mnuFileSave // - this.mnuFileSave.Enabled = false; - this.mnuFileSave.Image = global::OpenNest.Properties.Resources.save; - this.mnuFileSave.Name = "mnuFileSave"; - this.mnuFileSave.ShortcutKeys = ((System.Windows.Forms.Keys)((System.Windows.Forms.Keys.Control | System.Windows.Forms.Keys.S))); - this.mnuFileSave.Size = new System.Drawing.Size(146, 22); - this.mnuFileSave.Text = "Save"; - this.mnuFileSave.Click += new System.EventHandler(this.Save_Click); + mnuFileSave.Enabled = false; + mnuFileSave.Image = Properties.Resources.save; + mnuFileSave.Name = "mnuFileSave"; + mnuFileSave.ShortcutKeys = System.Windows.Forms.Keys.Control | System.Windows.Forms.Keys.S; + mnuFileSave.Size = new System.Drawing.Size(146, 22); + mnuFileSave.Text = "Save"; + mnuFileSave.Click += Save_Click; // // mnuFileSaveAs // - this.mnuFileSaveAs.Enabled = false; - this.mnuFileSaveAs.Image = global::OpenNest.Properties.Resources.save_as; - this.mnuFileSaveAs.Name = "mnuFileSaveAs"; - this.mnuFileSaveAs.Size = new System.Drawing.Size(146, 22); - this.mnuFileSaveAs.Text = "Save As"; - this.mnuFileSaveAs.Click += new System.EventHandler(this.SaveAs_Click); + mnuFileSaveAs.Enabled = false; + mnuFileSaveAs.Image = Properties.Resources.save_as; + mnuFileSaveAs.Name = "mnuFileSaveAs"; + mnuFileSaveAs.Size = new System.Drawing.Size(146, 22); + mnuFileSaveAs.Text = "Save As"; + mnuFileSaveAs.Click += SaveAs_Click; // // toolStripMenuItem2 // - this.toolStripMenuItem2.Name = "toolStripMenuItem2"; - this.toolStripMenuItem2.Size = new System.Drawing.Size(143, 6); + toolStripMenuItem2.Name = "toolStripMenuItem2"; + toolStripMenuItem2.Size = new System.Drawing.Size(143, 6); // // mnuFileExport // - this.mnuFileExport.Name = "mnuFileExport"; - this.mnuFileExport.Size = new System.Drawing.Size(146, 22); - this.mnuFileExport.Text = "Export"; - this.mnuFileExport.Click += new System.EventHandler(this.Export_Click); + mnuFileExport.Name = "mnuFileExport"; + mnuFileExport.Size = new System.Drawing.Size(146, 22); + mnuFileExport.Text = "Export"; + mnuFileExport.Click += Export_Click; // // mnuFileExportAll // - this.mnuFileExportAll.Name = "mnuFileExportAll"; - this.mnuFileExportAll.Size = new System.Drawing.Size(146, 22); - this.mnuFileExportAll.Text = "Export All"; - this.mnuFileExportAll.Click += new System.EventHandler(this.ExportAll_Click); + mnuFileExportAll.Name = "mnuFileExportAll"; + mnuFileExportAll.Size = new System.Drawing.Size(146, 22); + mnuFileExportAll.Text = "Export All"; + mnuFileExportAll.Click += ExportAll_Click; // // toolStripMenuItem3 // - this.toolStripMenuItem3.Name = "toolStripMenuItem3"; - this.toolStripMenuItem3.Size = new System.Drawing.Size(143, 6); + toolStripMenuItem3.Name = "toolStripMenuItem3"; + toolStripMenuItem3.Size = new System.Drawing.Size(143, 6); // // mnuFileExit // - this.mnuFileExit.Name = "mnuFileExit"; - this.mnuFileExit.ShortcutKeys = ((System.Windows.Forms.Keys)((System.Windows.Forms.Keys.Control | System.Windows.Forms.Keys.Q))); - this.mnuFileExit.Size = new System.Drawing.Size(146, 22); - this.mnuFileExit.Text = "Exit"; - this.mnuFileExit.Click += new System.EventHandler(this.Exit_Click); + mnuFileExit.Name = "mnuFileExit"; + mnuFileExit.ShortcutKeys = System.Windows.Forms.Keys.Control | System.Windows.Forms.Keys.Q; + mnuFileExit.Size = new System.Drawing.Size(146, 22); + mnuFileExit.Text = "Exit"; + mnuFileExit.Click += Exit_Click; // // mnuEdit // - this.mnuEdit.DropDownItems.AddRange(new System.Windows.Forms.ToolStripItem[] { - this.mnuEditCut, - this.mnuEditCopy, - this.mnuEditPaste, - this.toolStripMenuItem4, - this.mnuEditSelectAll}); - this.mnuEdit.Name = "mnuEdit"; - this.mnuEdit.Size = new System.Drawing.Size(39, 20); - this.mnuEdit.Text = "&Edit"; + mnuEdit.DropDownItems.AddRange(new System.Windows.Forms.ToolStripItem[] { mnuEditCut, mnuEditCopy, mnuEditPaste, toolStripMenuItem4, mnuEditSelectAll }); + mnuEdit.Name = "mnuEdit"; + mnuEdit.Size = new System.Drawing.Size(39, 20); + mnuEdit.Text = "&Edit"; // // mnuEditCut // - this.mnuEditCut.Name = "mnuEditCut"; - this.mnuEditCut.ShortcutKeys = ((System.Windows.Forms.Keys)((System.Windows.Forms.Keys.Control | System.Windows.Forms.Keys.X))); - this.mnuEditCut.Size = new System.Drawing.Size(164, 22); - this.mnuEditCut.Text = "Cut"; - this.mnuEditCut.Click += new System.EventHandler(this.EditCut_Click); + mnuEditCut.Name = "mnuEditCut"; + mnuEditCut.ShortcutKeys = System.Windows.Forms.Keys.Control | System.Windows.Forms.Keys.X; + mnuEditCut.Size = new System.Drawing.Size(164, 22); + mnuEditCut.Text = "Cut"; + mnuEditCut.Click += EditCut_Click; // // mnuEditCopy // - this.mnuEditCopy.Name = "mnuEditCopy"; - this.mnuEditCopy.ShortcutKeys = ((System.Windows.Forms.Keys)((System.Windows.Forms.Keys.Control | System.Windows.Forms.Keys.C))); - this.mnuEditCopy.Size = new System.Drawing.Size(164, 22); - this.mnuEditCopy.Text = "Copy"; - this.mnuEditCopy.Click += new System.EventHandler(this.EditCopy_Click); + mnuEditCopy.Name = "mnuEditCopy"; + mnuEditCopy.ShortcutKeys = System.Windows.Forms.Keys.Control | System.Windows.Forms.Keys.C; + mnuEditCopy.Size = new System.Drawing.Size(164, 22); + mnuEditCopy.Text = "Copy"; + mnuEditCopy.Click += EditCopy_Click; // // mnuEditPaste // - this.mnuEditPaste.Enabled = false; - this.mnuEditPaste.Name = "mnuEditPaste"; - this.mnuEditPaste.ShortcutKeys = ((System.Windows.Forms.Keys)((System.Windows.Forms.Keys.Control | System.Windows.Forms.Keys.V))); - this.mnuEditPaste.Size = new System.Drawing.Size(164, 22); - this.mnuEditPaste.Text = "Paste"; - this.mnuEditPaste.Visible = false; - this.mnuEditPaste.Click += new System.EventHandler(this.EditPaste_Click); + mnuEditPaste.Enabled = false; + mnuEditPaste.Name = "mnuEditPaste"; + mnuEditPaste.ShortcutKeys = System.Windows.Forms.Keys.Control | System.Windows.Forms.Keys.V; + mnuEditPaste.Size = new System.Drawing.Size(164, 22); + mnuEditPaste.Text = "Paste"; + mnuEditPaste.Visible = false; + mnuEditPaste.Click += EditPaste_Click; // // toolStripMenuItem4 // - this.toolStripMenuItem4.Name = "toolStripMenuItem4"; - this.toolStripMenuItem4.Size = new System.Drawing.Size(161, 6); + toolStripMenuItem4.Name = "toolStripMenuItem4"; + toolStripMenuItem4.Size = new System.Drawing.Size(161, 6); // // mnuEditSelectAll // - this.mnuEditSelectAll.Name = "mnuEditSelectAll"; - this.mnuEditSelectAll.ShortcutKeys = ((System.Windows.Forms.Keys)((System.Windows.Forms.Keys.Control | System.Windows.Forms.Keys.A))); - this.mnuEditSelectAll.Size = new System.Drawing.Size(164, 22); - this.mnuEditSelectAll.Text = "Select All"; - this.mnuEditSelectAll.Click += new System.EventHandler(this.EditSelectAll_Click); + mnuEditSelectAll.Name = "mnuEditSelectAll"; + mnuEditSelectAll.ShortcutKeys = System.Windows.Forms.Keys.Control | System.Windows.Forms.Keys.A; + mnuEditSelectAll.Size = new System.Drawing.Size(164, 22); + mnuEditSelectAll.Text = "Select All"; + mnuEditSelectAll.Click += EditSelectAll_Click; // // mnuView // - this.mnuView.DropDownItems.AddRange(new System.Windows.Forms.ToolStripItem[] { - this.mnuViewDrawRapids, - this.mnuViewDrawBounds, - this.mnuViewDrawOffset, - this.toolStripMenuItem5, - this.mnuViewZoomTo, - this.mnuViewZoomIn, - this.mnuViewZoomOut}); - this.mnuView.Name = "mnuView"; - this.mnuView.Size = new System.Drawing.Size(44, 20); - this.mnuView.Text = "&View"; + mnuView.DropDownItems.AddRange(new System.Windows.Forms.ToolStripItem[] { mnuViewDrawRapids, mnuViewDrawBounds, mnuViewDrawOffset, toolStripMenuItem5, mnuViewZoomTo, mnuViewZoomIn, mnuViewZoomOut }); + mnuView.Name = "mnuView"; + mnuView.Size = new System.Drawing.Size(44, 20); + mnuView.Text = "&View"; // // mnuViewDrawRapids // - this.mnuViewDrawRapids.Name = "mnuViewDrawRapids"; - this.mnuViewDrawRapids.Size = new System.Drawing.Size(222, 22); - this.mnuViewDrawRapids.Text = "Draw Rapids"; - this.mnuViewDrawRapids.Click += new System.EventHandler(this.ToggleDrawRapids_Click); + mnuViewDrawRapids.Name = "mnuViewDrawRapids"; + mnuViewDrawRapids.Size = new System.Drawing.Size(222, 22); + mnuViewDrawRapids.Text = "Draw Rapids"; + mnuViewDrawRapids.Click += ToggleDrawRapids_Click; // // mnuViewDrawBounds // - this.mnuViewDrawBounds.CheckOnClick = true; - this.mnuViewDrawBounds.Name = "mnuViewDrawBounds"; - this.mnuViewDrawBounds.Size = new System.Drawing.Size(222, 22); - this.mnuViewDrawBounds.Text = "Draw Bounds"; - this.mnuViewDrawBounds.Click += new System.EventHandler(this.ToggleDrawBounds_Click); - // + mnuViewDrawBounds.CheckOnClick = true; + mnuViewDrawBounds.Name = "mnuViewDrawBounds"; + mnuViewDrawBounds.Size = new System.Drawing.Size(222, 22); + mnuViewDrawBounds.Text = "Draw Bounds"; + mnuViewDrawBounds.Click += ToggleDrawBounds_Click; + // // mnuViewDrawOffset - // - this.mnuViewDrawOffset.CheckOnClick = true; - this.mnuViewDrawOffset.Name = "mnuViewDrawOffset"; - this.mnuViewDrawOffset.Size = new System.Drawing.Size(222, 22); - this.mnuViewDrawOffset.Text = "Draw Offset"; - this.mnuViewDrawOffset.Click += new System.EventHandler(this.ToggleDrawOffset_Click); - // + // + mnuViewDrawOffset.CheckOnClick = true; + mnuViewDrawOffset.Name = "mnuViewDrawOffset"; + mnuViewDrawOffset.Size = new System.Drawing.Size(222, 22); + mnuViewDrawOffset.Text = "Draw Offset"; + mnuViewDrawOffset.Click += ToggleDrawOffset_Click; + // // toolStripMenuItem5 // - this.toolStripMenuItem5.Name = "toolStripMenuItem5"; - this.toolStripMenuItem5.Size = new System.Drawing.Size(219, 6); + toolStripMenuItem5.Name = "toolStripMenuItem5"; + toolStripMenuItem5.Size = new System.Drawing.Size(219, 6); // // mnuViewZoomTo // - this.mnuViewZoomTo.DropDownItems.AddRange(new System.Windows.Forms.ToolStripItem[] { - this.mnuViewZoomToArea, - this.mnuViewZoomToFit, - this.mnuViewZoomToPlate, - this.mnuViewZoomToSelected}); - this.mnuViewZoomTo.Name = "mnuViewZoomTo"; - this.mnuViewZoomTo.Size = new System.Drawing.Size(222, 22); - this.mnuViewZoomTo.Text = "Zoom To"; + mnuViewZoomTo.DropDownItems.AddRange(new System.Windows.Forms.ToolStripItem[] { mnuViewZoomToArea, mnuViewZoomToFit, mnuViewZoomToPlate, mnuViewZoomToSelected }); + mnuViewZoomTo.Name = "mnuViewZoomTo"; + mnuViewZoomTo.Size = new System.Drawing.Size(222, 22); + mnuViewZoomTo.Text = "Zoom To"; // // mnuViewZoomToArea // - this.mnuViewZoomToArea.Name = "mnuViewZoomToArea"; - this.mnuViewZoomToArea.Size = new System.Drawing.Size(118, 22); - this.mnuViewZoomToArea.Text = "Area"; - this.mnuViewZoomToArea.Click += new System.EventHandler(this.ZoomToArea_Click); + mnuViewZoomToArea.Name = "mnuViewZoomToArea"; + mnuViewZoomToArea.Size = new System.Drawing.Size(118, 22); + mnuViewZoomToArea.Text = "Area"; + mnuViewZoomToArea.Click += ZoomToArea_Click; // // mnuViewZoomToFit // - this.mnuViewZoomToFit.Image = global::OpenNest.Properties.Resources.zoom_all; - this.mnuViewZoomToFit.Name = "mnuViewZoomToFit"; - this.mnuViewZoomToFit.Size = new System.Drawing.Size(118, 22); - this.mnuViewZoomToFit.Text = "Fit"; - this.mnuViewZoomToFit.Click += new System.EventHandler(this.ZoomToFit_Click); + mnuViewZoomToFit.Image = Properties.Resources.zoom_all; + mnuViewZoomToFit.Name = "mnuViewZoomToFit"; + mnuViewZoomToFit.Size = new System.Drawing.Size(118, 22); + mnuViewZoomToFit.Text = "Fit"; + mnuViewZoomToFit.Click += ZoomToFit_Click; // // mnuViewZoomToPlate // - this.mnuViewZoomToPlate.Name = "mnuViewZoomToPlate"; - this.mnuViewZoomToPlate.Size = new System.Drawing.Size(118, 22); - this.mnuViewZoomToPlate.Text = "Plate"; - this.mnuViewZoomToPlate.Click += new System.EventHandler(this.ZoomToPlate_Click); + mnuViewZoomToPlate.Name = "mnuViewZoomToPlate"; + mnuViewZoomToPlate.Size = new System.Drawing.Size(118, 22); + mnuViewZoomToPlate.Text = "Plate"; + mnuViewZoomToPlate.Click += ZoomToPlate_Click; // // mnuViewZoomToSelected // - this.mnuViewZoomToSelected.Name = "mnuViewZoomToSelected"; - this.mnuViewZoomToSelected.Size = new System.Drawing.Size(118, 22); - this.mnuViewZoomToSelected.Text = "Selected"; - this.mnuViewZoomToSelected.Click += new System.EventHandler(this.ZoomToSelected_Click); + mnuViewZoomToSelected.Name = "mnuViewZoomToSelected"; + mnuViewZoomToSelected.Size = new System.Drawing.Size(118, 22); + mnuViewZoomToSelected.Text = "Selected"; + mnuViewZoomToSelected.Click += ZoomToSelected_Click; // // mnuViewZoomIn // - this.mnuViewZoomIn.Image = global::OpenNest.Properties.Resources.zoom_in; - this.mnuViewZoomIn.Name = "mnuViewZoomIn"; - this.mnuViewZoomIn.ShortcutKeys = ((System.Windows.Forms.Keys)((System.Windows.Forms.Keys.Control | System.Windows.Forms.Keys.Oemplus))); - this.mnuViewZoomIn.Size = new System.Drawing.Size(222, 22); - this.mnuViewZoomIn.Text = "Zoom In"; - this.mnuViewZoomIn.Click += new System.EventHandler(this.ZoomIn_Click); + mnuViewZoomIn.Image = Properties.Resources.zoom_in; + mnuViewZoomIn.Name = "mnuViewZoomIn"; + mnuViewZoomIn.ShortcutKeys = System.Windows.Forms.Keys.Control | System.Windows.Forms.Keys.Oemplus; + mnuViewZoomIn.Size = new System.Drawing.Size(222, 22); + mnuViewZoomIn.Text = "Zoom In"; + mnuViewZoomIn.Click += ZoomIn_Click; // // mnuViewZoomOut // - this.mnuViewZoomOut.Image = global::OpenNest.Properties.Resources.zoom_out; - this.mnuViewZoomOut.Name = "mnuViewZoomOut"; - this.mnuViewZoomOut.ShortcutKeys = ((System.Windows.Forms.Keys)((System.Windows.Forms.Keys.Control | System.Windows.Forms.Keys.OemMinus))); - this.mnuViewZoomOut.Size = new System.Drawing.Size(222, 22); - this.mnuViewZoomOut.Text = "Zoom Out"; - this.mnuViewZoomOut.Click += new System.EventHandler(this.ZoomOut_Click); + mnuViewZoomOut.Image = Properties.Resources.zoom_out; + mnuViewZoomOut.Name = "mnuViewZoomOut"; + mnuViewZoomOut.ShortcutKeys = System.Windows.Forms.Keys.Control | System.Windows.Forms.Keys.OemMinus; + mnuViewZoomOut.Size = new System.Drawing.Size(222, 22); + mnuViewZoomOut.Text = "Zoom Out"; + mnuViewZoomOut.Click += ZoomOut_Click; // // mnuTools // - this.mnuTools.DropDownItems.AddRange(new System.Windows.Forms.ToolStripItem[] { - this.mnuToolsMeasureArea, - this.mnuToolsBestFitViewer, - this.mnuToolsAlign, - this.toolStripMenuItem14, - this.mnuSetOffsetIncrement, - this.mnuSetRotationIncrement, - this.toolStripMenuItem15, - this.mnuToolsOptions}); - this.mnuTools.Name = "mnuTools"; - this.mnuTools.Size = new System.Drawing.Size(47, 20); - this.mnuTools.Text = "&Tools"; + mnuTools.DropDownItems.AddRange(new System.Windows.Forms.ToolStripItem[] { mnuToolsMeasureArea, mnuToolsBestFitViewer, mnuToolsAlign, toolStripMenuItem14, mnuSetOffsetIncrement, mnuSetRotationIncrement, toolStripMenuItem15, mnuToolsOptions }); + mnuTools.Name = "mnuTools"; + mnuTools.Size = new System.Drawing.Size(47, 20); + mnuTools.Text = "&Tools"; // // mnuToolsMeasureArea // - this.mnuToolsMeasureArea.Name = "mnuToolsMeasureArea"; - this.mnuToolsMeasureArea.Size = new System.Drawing.Size(214, 22); - this.mnuToolsMeasureArea.Text = "Measure Area"; - this.mnuToolsMeasureArea.Click += new System.EventHandler(this.MeasureArea_Click); - // + mnuToolsMeasureArea.Name = "mnuToolsMeasureArea"; + mnuToolsMeasureArea.Size = new System.Drawing.Size(214, 22); + mnuToolsMeasureArea.Text = "Measure Area"; + mnuToolsMeasureArea.Click += MeasureArea_Click; + // // mnuToolsBestFitViewer - // - this.mnuToolsBestFitViewer.Name = "mnuToolsBestFitViewer"; - this.mnuToolsBestFitViewer.Size = new System.Drawing.Size(214, 22); - this.mnuToolsBestFitViewer.Text = "Best-Fit Viewer"; - this.mnuToolsBestFitViewer.Click += new System.EventHandler(this.BestFitViewer_Click); - // + // + mnuToolsBestFitViewer.Name = "mnuToolsBestFitViewer"; + mnuToolsBestFitViewer.Size = new System.Drawing.Size(214, 22); + mnuToolsBestFitViewer.Text = "Best-Fit Viewer"; + mnuToolsBestFitViewer.Click += BestFitViewer_Click; + // // mnuToolsAlign // - this.mnuToolsAlign.DropDownItems.AddRange(new System.Windows.Forms.ToolStripItem[] { - this.mnuToolsAlignLeft, - this.mnuToolsAlignRight, - this.mnuToolsAlignTop, - this.mnuToolsAlignBottom, - this.toolStripMenuItem11, - this.mnuToolsAlignHorizontal, - this.mnuToolsAlignVertically, - this.toolStripMenuItem8, - this.mnuToolsEvenlySpaceHorizontal, - this.mnuToolsEvenlySpaceVertical}); - this.mnuToolsAlign.Name = "mnuToolsAlign"; - this.mnuToolsAlign.Size = new System.Drawing.Size(214, 22); - this.mnuToolsAlign.Text = "Align Selected"; + mnuToolsAlign.DropDownItems.AddRange(new System.Windows.Forms.ToolStripItem[] { mnuToolsAlignLeft, mnuToolsAlignRight, mnuToolsAlignTop, mnuToolsAlignBottom, toolStripMenuItem11, mnuToolsAlignHorizontal, mnuToolsAlignVertically, toolStripMenuItem8, mnuToolsEvenlySpaceHorizontal, mnuToolsEvenlySpaceVertical }); + mnuToolsAlign.Name = "mnuToolsAlign"; + mnuToolsAlign.Size = new System.Drawing.Size(214, 22); + mnuToolsAlign.Text = "Align Selected"; // // mnuToolsAlignLeft // - this.mnuToolsAlignLeft.Name = "mnuToolsAlignLeft"; - this.mnuToolsAlignLeft.Size = new System.Drawing.Size(209, 22); - this.mnuToolsAlignLeft.Text = "Left"; - this.mnuToolsAlignLeft.Click += new System.EventHandler(this.AlignLeft_Click); + mnuToolsAlignLeft.Name = "mnuToolsAlignLeft"; + mnuToolsAlignLeft.Size = new System.Drawing.Size(209, 22); + mnuToolsAlignLeft.Text = "Left"; + mnuToolsAlignLeft.Click += AlignLeft_Click; // // mnuToolsAlignRight // - this.mnuToolsAlignRight.Name = "mnuToolsAlignRight"; - this.mnuToolsAlignRight.Size = new System.Drawing.Size(209, 22); - this.mnuToolsAlignRight.Text = "Right"; - this.mnuToolsAlignRight.Click += new System.EventHandler(this.AlignRight_Click); + mnuToolsAlignRight.Name = "mnuToolsAlignRight"; + mnuToolsAlignRight.Size = new System.Drawing.Size(209, 22); + mnuToolsAlignRight.Text = "Right"; + mnuToolsAlignRight.Click += AlignRight_Click; // // mnuToolsAlignTop // - this.mnuToolsAlignTop.Name = "mnuToolsAlignTop"; - this.mnuToolsAlignTop.Size = new System.Drawing.Size(209, 22); - this.mnuToolsAlignTop.Text = "Top"; - this.mnuToolsAlignTop.Click += new System.EventHandler(this.AlignTop_Click); + mnuToolsAlignTop.Name = "mnuToolsAlignTop"; + mnuToolsAlignTop.Size = new System.Drawing.Size(209, 22); + mnuToolsAlignTop.Text = "Top"; + mnuToolsAlignTop.Click += AlignTop_Click; // // mnuToolsAlignBottom // - this.mnuToolsAlignBottom.Name = "mnuToolsAlignBottom"; - this.mnuToolsAlignBottom.Size = new System.Drawing.Size(209, 22); - this.mnuToolsAlignBottom.Text = "Bottom"; - this.mnuToolsAlignBottom.Click += new System.EventHandler(this.AlignBottom_Click); + mnuToolsAlignBottom.Name = "mnuToolsAlignBottom"; + mnuToolsAlignBottom.Size = new System.Drawing.Size(209, 22); + mnuToolsAlignBottom.Text = "Bottom"; + mnuToolsAlignBottom.Click += AlignBottom_Click; // // toolStripMenuItem11 // - this.toolStripMenuItem11.Name = "toolStripMenuItem11"; - this.toolStripMenuItem11.Size = new System.Drawing.Size(206, 6); + toolStripMenuItem11.Name = "toolStripMenuItem11"; + toolStripMenuItem11.Size = new System.Drawing.Size(206, 6); // // mnuToolsAlignHorizontal // - this.mnuToolsAlignHorizontal.Name = "mnuToolsAlignHorizontal"; - this.mnuToolsAlignHorizontal.Size = new System.Drawing.Size(209, 22); - this.mnuToolsAlignHorizontal.Text = "Horizontally"; - this.mnuToolsAlignHorizontal.Click += new System.EventHandler(this.AlignHorizontal_Click); + mnuToolsAlignHorizontal.Name = "mnuToolsAlignHorizontal"; + mnuToolsAlignHorizontal.Size = new System.Drawing.Size(209, 22); + mnuToolsAlignHorizontal.Text = "Horizontally"; + mnuToolsAlignHorizontal.Click += AlignHorizontal_Click; // // mnuToolsAlignVertically // - this.mnuToolsAlignVertically.Name = "mnuToolsAlignVertically"; - this.mnuToolsAlignVertically.Size = new System.Drawing.Size(209, 22); - this.mnuToolsAlignVertically.Text = "Vertically"; - this.mnuToolsAlignVertically.Click += new System.EventHandler(this.AlignVertical_Click); + mnuToolsAlignVertically.Name = "mnuToolsAlignVertically"; + mnuToolsAlignVertically.Size = new System.Drawing.Size(209, 22); + mnuToolsAlignVertically.Text = "Vertically"; + mnuToolsAlignVertically.Click += AlignVertical_Click; // // toolStripMenuItem8 // - this.toolStripMenuItem8.Name = "toolStripMenuItem8"; - this.toolStripMenuItem8.Size = new System.Drawing.Size(206, 6); + toolStripMenuItem8.Name = "toolStripMenuItem8"; + toolStripMenuItem8.Size = new System.Drawing.Size(206, 6); // // mnuToolsEvenlySpaceHorizontal // - this.mnuToolsEvenlySpaceHorizontal.Name = "mnuToolsEvenlySpaceHorizontal"; - this.mnuToolsEvenlySpaceHorizontal.Size = new System.Drawing.Size(209, 22); - this.mnuToolsEvenlySpaceHorizontal.Text = "Evenly Space Horizontally"; - this.mnuToolsEvenlySpaceHorizontal.Click += new System.EventHandler(this.EvenlySpaceHorizontally_Click); + mnuToolsEvenlySpaceHorizontal.Name = "mnuToolsEvenlySpaceHorizontal"; + mnuToolsEvenlySpaceHorizontal.Size = new System.Drawing.Size(209, 22); + mnuToolsEvenlySpaceHorizontal.Text = "Evenly Space Horizontally"; + mnuToolsEvenlySpaceHorizontal.Click += EvenlySpaceHorizontally_Click; // // mnuToolsEvenlySpaceVertical // - this.mnuToolsEvenlySpaceVertical.Name = "mnuToolsEvenlySpaceVertical"; - this.mnuToolsEvenlySpaceVertical.Size = new System.Drawing.Size(209, 22); - this.mnuToolsEvenlySpaceVertical.Text = "Evenly Space Vertically"; - this.mnuToolsEvenlySpaceVertical.Click += new System.EventHandler(this.EvenlySpaceVertically_Click); + mnuToolsEvenlySpaceVertical.Name = "mnuToolsEvenlySpaceVertical"; + mnuToolsEvenlySpaceVertical.Size = new System.Drawing.Size(209, 22); + mnuToolsEvenlySpaceVertical.Text = "Evenly Space Vertically"; + mnuToolsEvenlySpaceVertical.Click += EvenlySpaceVertically_Click; // // toolStripMenuItem14 // - this.toolStripMenuItem14.Name = "toolStripMenuItem14"; - this.toolStripMenuItem14.Size = new System.Drawing.Size(211, 6); + toolStripMenuItem14.Name = "toolStripMenuItem14"; + toolStripMenuItem14.Size = new System.Drawing.Size(211, 6); // // mnuSetOffsetIncrement // - this.mnuSetOffsetIncrement.Name = "mnuSetOffsetIncrement"; - this.mnuSetOffsetIncrement.ShortcutKeys = System.Windows.Forms.Keys.F5; - this.mnuSetOffsetIncrement.Size = new System.Drawing.Size(214, 22); - this.mnuSetOffsetIncrement.Text = "Set Offset Increment"; - this.mnuSetOffsetIncrement.Click += new System.EventHandler(this.SetOffsetIncrement_Click); + mnuSetOffsetIncrement.Name = "mnuSetOffsetIncrement"; + mnuSetOffsetIncrement.ShortcutKeys = System.Windows.Forms.Keys.F5; + mnuSetOffsetIncrement.Size = new System.Drawing.Size(214, 22); + mnuSetOffsetIncrement.Text = "Set Offset Increment"; + mnuSetOffsetIncrement.Click += SetOffsetIncrement_Click; // // mnuSetRotationIncrement // - this.mnuSetRotationIncrement.Name = "mnuSetRotationIncrement"; - this.mnuSetRotationIncrement.ShortcutKeys = System.Windows.Forms.Keys.F6; - this.mnuSetRotationIncrement.Size = new System.Drawing.Size(214, 22); - this.mnuSetRotationIncrement.Text = "Set Rotation Increment"; - this.mnuSetRotationIncrement.Click += new System.EventHandler(this.SetRotationIncrement_Click); + mnuSetRotationIncrement.Name = "mnuSetRotationIncrement"; + mnuSetRotationIncrement.ShortcutKeys = System.Windows.Forms.Keys.F6; + mnuSetRotationIncrement.Size = new System.Drawing.Size(214, 22); + mnuSetRotationIncrement.Text = "Set Rotation Increment"; + mnuSetRotationIncrement.Click += SetRotationIncrement_Click; // // toolStripMenuItem15 // - this.toolStripMenuItem15.Name = "toolStripMenuItem15"; - this.toolStripMenuItem15.Size = new System.Drawing.Size(211, 6); + toolStripMenuItem15.Name = "toolStripMenuItem15"; + toolStripMenuItem15.Size = new System.Drawing.Size(211, 6); // // mnuToolsOptions // - this.mnuToolsOptions.Name = "mnuToolsOptions"; - this.mnuToolsOptions.Size = new System.Drawing.Size(214, 22); - this.mnuToolsOptions.Text = "Options"; - this.mnuToolsOptions.Click += new System.EventHandler(this.Options_Click); + mnuToolsOptions.Name = "mnuToolsOptions"; + mnuToolsOptions.Size = new System.Drawing.Size(214, 22); + mnuToolsOptions.Text = "Options"; + mnuToolsOptions.Click += Options_Click; // // mnuNest // - this.mnuNest.DropDownItems.AddRange(new System.Windows.Forms.ToolStripItem[] { - this.mnuNestEdit, - this.mnuNestImportDrawing, - this.toolStripMenuItem7, - this.mnuNestFirstPlate, - this.mnuNestLastPlate, - this.toolStripMenuItem6, - this.mnuNestNextPlate, - this.mnuNestPreviousPlate, - this.toolStripMenuItem12, - this.runAutoNestToolStripMenuItem, - this.autoSequenceAllPlatesToolStripMenuItem, - this.mnuNestRemoveEmptyPlates, - this.mnuNestPost, - this.toolStripMenuItem19, - this.calculateCutTimeToolStripMenuItem}); - this.mnuNest.Name = "mnuNest"; - this.mnuNest.Size = new System.Drawing.Size(43, 20); - this.mnuNest.Text = "&Nest"; + mnuNest.DropDownItems.AddRange(new System.Windows.Forms.ToolStripItem[] { mnuNestEdit, mnuNestImportDrawing, toolStripMenuItem7, mnuNestFirstPlate, mnuNestLastPlate, toolStripMenuItem6, mnuNestNextPlate, mnuNestPreviousPlate, toolStripMenuItem12, runAutoNestToolStripMenuItem, autoSequenceAllPlatesToolStripMenuItem, mnuNestRemoveEmptyPlates, mnuNestPost, toolStripMenuItem19, calculateCutTimeToolStripMenuItem }); + mnuNest.Name = "mnuNest"; + mnuNest.Size = new System.Drawing.Size(43, 20); + mnuNest.Text = "&Nest"; // // mnuNestEdit // - this.mnuNestEdit.Name = "mnuNestEdit"; - this.mnuNestEdit.Size = new System.Drawing.Size(205, 22); - this.mnuNestEdit.Text = "Edit"; - this.mnuNestEdit.Click += new System.EventHandler(this.EditNest_Click); + mnuNestEdit.Name = "mnuNestEdit"; + mnuNestEdit.Size = new System.Drawing.Size(205, 22); + mnuNestEdit.Text = "Edit"; + mnuNestEdit.Click += EditNest_Click; // // mnuNestImportDrawing // - this.mnuNestImportDrawing.Image = global::OpenNest.Properties.Resources.import; - this.mnuNestImportDrawing.Name = "mnuNestImportDrawing"; - this.mnuNestImportDrawing.Size = new System.Drawing.Size(205, 22); - this.mnuNestImportDrawing.Text = "Import Drawing"; - this.mnuNestImportDrawing.Click += new System.EventHandler(this.Import_Click); + mnuNestImportDrawing.Image = Properties.Resources.import; + mnuNestImportDrawing.Name = "mnuNestImportDrawing"; + mnuNestImportDrawing.Size = new System.Drawing.Size(205, 22); + mnuNestImportDrawing.Text = "Import Drawing"; + mnuNestImportDrawing.Click += Import_Click; // // toolStripMenuItem7 // - this.toolStripMenuItem7.Name = "toolStripMenuItem7"; - this.toolStripMenuItem7.Size = new System.Drawing.Size(202, 6); + toolStripMenuItem7.Name = "toolStripMenuItem7"; + toolStripMenuItem7.Size = new System.Drawing.Size(202, 6); // // mnuNestFirstPlate // - this.mnuNestFirstPlate.Image = global::OpenNest.Properties.Resources.move_first; - this.mnuNestFirstPlate.Name = "mnuNestFirstPlate"; - this.mnuNestFirstPlate.Size = new System.Drawing.Size(205, 22); - this.mnuNestFirstPlate.Text = "First Plate"; - this.mnuNestFirstPlate.Click += new System.EventHandler(this.LoadFirstPlate_Click); + mnuNestFirstPlate.Image = Properties.Resources.move_first; + mnuNestFirstPlate.Name = "mnuNestFirstPlate"; + mnuNestFirstPlate.Size = new System.Drawing.Size(205, 22); + mnuNestFirstPlate.Text = "First Plate"; + mnuNestFirstPlate.Click += LoadFirstPlate_Click; // // mnuNestLastPlate // - this.mnuNestLastPlate.Image = global::OpenNest.Properties.Resources.move_last; - this.mnuNestLastPlate.Name = "mnuNestLastPlate"; - this.mnuNestLastPlate.Size = new System.Drawing.Size(205, 22); - this.mnuNestLastPlate.Text = "Last Plate"; - this.mnuNestLastPlate.Click += new System.EventHandler(this.LoadLastPlate_Click); + mnuNestLastPlate.Image = Properties.Resources.move_last; + mnuNestLastPlate.Name = "mnuNestLastPlate"; + mnuNestLastPlate.Size = new System.Drawing.Size(205, 22); + mnuNestLastPlate.Text = "Last Plate"; + mnuNestLastPlate.Click += LoadLastPlate_Click; // // toolStripMenuItem6 // - this.toolStripMenuItem6.Name = "toolStripMenuItem6"; - this.toolStripMenuItem6.Size = new System.Drawing.Size(202, 6); + toolStripMenuItem6.Name = "toolStripMenuItem6"; + toolStripMenuItem6.Size = new System.Drawing.Size(202, 6); // // mnuNestNextPlate // - this.mnuNestNextPlate.Image = global::OpenNest.Properties.Resources.move_next; - this.mnuNestNextPlate.Name = "mnuNestNextPlate"; - this.mnuNestNextPlate.ShortcutKeys = ((System.Windows.Forms.Keys)((System.Windows.Forms.Keys.Control | System.Windows.Forms.Keys.Right))); - this.mnuNestNextPlate.Size = new System.Drawing.Size(205, 22); - this.mnuNestNextPlate.Text = "Next Plate"; - this.mnuNestNextPlate.Click += new System.EventHandler(this.LoadNextPlate_Click); + mnuNestNextPlate.Image = Properties.Resources.move_next; + mnuNestNextPlate.Name = "mnuNestNextPlate"; + mnuNestNextPlate.ShortcutKeys = System.Windows.Forms.Keys.Control | System.Windows.Forms.Keys.Right; + mnuNestNextPlate.Size = new System.Drawing.Size(205, 22); + mnuNestNextPlate.Text = "Next Plate"; + mnuNestNextPlate.Click += LoadNextPlate_Click; // // mnuNestPreviousPlate // - this.mnuNestPreviousPlate.Image = global::OpenNest.Properties.Resources.move_previous; - this.mnuNestPreviousPlate.Name = "mnuNestPreviousPlate"; - this.mnuNestPreviousPlate.ShortcutKeys = ((System.Windows.Forms.Keys)((System.Windows.Forms.Keys.Control | System.Windows.Forms.Keys.Left))); - this.mnuNestPreviousPlate.Size = new System.Drawing.Size(205, 22); - this.mnuNestPreviousPlate.Text = "Previous Plate"; - this.mnuNestPreviousPlate.Click += new System.EventHandler(this.LoadPreviousPlate_Click); + mnuNestPreviousPlate.Image = Properties.Resources.move_previous; + mnuNestPreviousPlate.Name = "mnuNestPreviousPlate"; + mnuNestPreviousPlate.ShortcutKeys = System.Windows.Forms.Keys.Control | System.Windows.Forms.Keys.Left; + mnuNestPreviousPlate.Size = new System.Drawing.Size(205, 22); + mnuNestPreviousPlate.Text = "Previous Plate"; + mnuNestPreviousPlate.Click += LoadPreviousPlate_Click; // // toolStripMenuItem12 // - this.toolStripMenuItem12.Name = "toolStripMenuItem12"; - this.toolStripMenuItem12.Size = new System.Drawing.Size(202, 6); + toolStripMenuItem12.Name = "toolStripMenuItem12"; + toolStripMenuItem12.Size = new System.Drawing.Size(202, 6); // // runAutoNestToolStripMenuItem // - this.runAutoNestToolStripMenuItem.Name = "runAutoNestToolStripMenuItem"; - this.runAutoNestToolStripMenuItem.Size = new System.Drawing.Size(205, 22); - this.runAutoNestToolStripMenuItem.Text = "Auto Nest"; - this.runAutoNestToolStripMenuItem.Click += new System.EventHandler(this.RunAutoNest_Click); + runAutoNestToolStripMenuItem.Name = "runAutoNestToolStripMenuItem"; + runAutoNestToolStripMenuItem.Size = new System.Drawing.Size(205, 22); + runAutoNestToolStripMenuItem.Text = "Auto Nest"; + runAutoNestToolStripMenuItem.Click += RunAutoNest_Click; // // autoSequenceAllPlatesToolStripMenuItem // - this.autoSequenceAllPlatesToolStripMenuItem.Name = "autoSequenceAllPlatesToolStripMenuItem"; - this.autoSequenceAllPlatesToolStripMenuItem.Size = new System.Drawing.Size(205, 22); - this.autoSequenceAllPlatesToolStripMenuItem.Text = "Auto Sequence All Plates"; - this.autoSequenceAllPlatesToolStripMenuItem.Click += new System.EventHandler(this.SequenceAllPlates_Click); + autoSequenceAllPlatesToolStripMenuItem.Name = "autoSequenceAllPlatesToolStripMenuItem"; + autoSequenceAllPlatesToolStripMenuItem.Size = new System.Drawing.Size(205, 22); + autoSequenceAllPlatesToolStripMenuItem.Text = "Auto Sequence All Plates"; + autoSequenceAllPlatesToolStripMenuItem.Click += SequenceAllPlates_Click; // // mnuNestRemoveEmptyPlates // - this.mnuNestRemoveEmptyPlates.Name = "mnuNestRemoveEmptyPlates"; - this.mnuNestRemoveEmptyPlates.Size = new System.Drawing.Size(205, 22); - this.mnuNestRemoveEmptyPlates.Text = "Remove Empty Plates"; - this.mnuNestRemoveEmptyPlates.Click += new System.EventHandler(this.RemoveEmptyPlates_Click); + mnuNestRemoveEmptyPlates.Name = "mnuNestRemoveEmptyPlates"; + mnuNestRemoveEmptyPlates.Size = new System.Drawing.Size(205, 22); + mnuNestRemoveEmptyPlates.Text = "Remove Empty Plates"; + mnuNestRemoveEmptyPlates.Click += RemoveEmptyPlates_Click; // // mnuNestPost // - this.mnuNestPost.Name = "mnuNestPost"; - this.mnuNestPost.Size = new System.Drawing.Size(205, 22); - this.mnuNestPost.Text = "Post"; + mnuNestPost.Name = "mnuNestPost"; + mnuNestPost.Size = new System.Drawing.Size(205, 22); + mnuNestPost.Text = "Post"; // // toolStripMenuItem19 // - this.toolStripMenuItem19.Name = "toolStripMenuItem19"; - this.toolStripMenuItem19.Size = new System.Drawing.Size(202, 6); + toolStripMenuItem19.Name = "toolStripMenuItem19"; + toolStripMenuItem19.Size = new System.Drawing.Size(202, 6); // // calculateCutTimeToolStripMenuItem // - this.calculateCutTimeToolStripMenuItem.Name = "calculateCutTimeToolStripMenuItem"; - this.calculateCutTimeToolStripMenuItem.Size = new System.Drawing.Size(205, 22); - this.calculateCutTimeToolStripMenuItem.Text = "Calculate Cut Time"; - this.calculateCutTimeToolStripMenuItem.Click += new System.EventHandler(this.CalculateNestCutTime_Click); + calculateCutTimeToolStripMenuItem.Name = "calculateCutTimeToolStripMenuItem"; + calculateCutTimeToolStripMenuItem.Size = new System.Drawing.Size(205, 22); + calculateCutTimeToolStripMenuItem.Text = "Calculate Cut Time"; + calculateCutTimeToolStripMenuItem.Click += CalculateNestCutTime_Click; // // mnuPlate // - this.mnuPlate.DropDownItems.AddRange(new System.Windows.Forms.ToolStripItem[] { - this.mnuPlateEdit, - this.mnuPlateSetAsDefault, - this.toolStripMenuItem18, - this.mnuPlateAdd, - this.mnuPlateRemove, - this.toolStripMenuItem16, - this.mnuPlateFill, - this.toolStripMenuItem9, - this.mnuPlateRotate, - this.mnuResizeToFitParts, - this.toolStripMenuItem13, - this.mnuPlateViewInCad, - this.toolStripMenuItem20, - this.mnuSequenceParts, - this.calculateCutTimeToolStripMenuItem1, - this.centerPartsToolStripMenuItem}); - this.mnuPlate.Name = "mnuPlate"; - this.mnuPlate.Size = new System.Drawing.Size(45, 20); - this.mnuPlate.Text = "&Plate"; + mnuPlate.DropDownItems.AddRange(new System.Windows.Forms.ToolStripItem[] { mnuPlateEdit, mnuPlateSetAsDefault, toolStripMenuItem18, mnuPlateAdd, mnuPlateRemove, toolStripMenuItem16, mnuPlateFill, toolStripMenuItem9, mnuPlateRotate, mnuResizeToFitParts, toolStripMenuItem13, mnuPlateViewInCad, toolStripMenuItem20, mnuSequenceParts, calculateCutTimeToolStripMenuItem1, centerPartsToolStripMenuItem }); + mnuPlate.Name = "mnuPlate"; + mnuPlate.Size = new System.Drawing.Size(45, 20); + mnuPlate.Text = "&Plate"; // // mnuPlateEdit // - this.mnuPlateEdit.Name = "mnuPlateEdit"; - this.mnuPlateEdit.Size = new System.Drawing.Size(177, 22); - this.mnuPlateEdit.Text = "Edit"; - this.mnuPlateEdit.Click += new System.EventHandler(this.EditPlate_Click); + mnuPlateEdit.Name = "mnuPlateEdit"; + mnuPlateEdit.Size = new System.Drawing.Size(177, 22); + mnuPlateEdit.Text = "Edit"; + mnuPlateEdit.Click += EditPlate_Click; // // mnuPlateSetAsDefault // - this.mnuPlateSetAsDefault.Name = "mnuPlateSetAsDefault"; - this.mnuPlateSetAsDefault.Size = new System.Drawing.Size(177, 22); - this.mnuPlateSetAsDefault.Text = "Set as Default"; - this.mnuPlateSetAsDefault.Click += new System.EventHandler(this.SetAsNestDefault_Click); + mnuPlateSetAsDefault.Name = "mnuPlateSetAsDefault"; + mnuPlateSetAsDefault.Size = new System.Drawing.Size(177, 22); + mnuPlateSetAsDefault.Text = "Set as Default"; + mnuPlateSetAsDefault.Click += SetAsNestDefault_Click; // // toolStripMenuItem18 // - this.toolStripMenuItem18.Name = "toolStripMenuItem18"; - this.toolStripMenuItem18.Size = new System.Drawing.Size(174, 6); + toolStripMenuItem18.Name = "toolStripMenuItem18"; + toolStripMenuItem18.Size = new System.Drawing.Size(174, 6); // // mnuPlateAdd // - this.mnuPlateAdd.Image = global::OpenNest.Properties.Resources.add; - this.mnuPlateAdd.Name = "mnuPlateAdd"; - this.mnuPlateAdd.Size = new System.Drawing.Size(177, 22); - this.mnuPlateAdd.Text = "Add"; - this.mnuPlateAdd.Click += new System.EventHandler(this.AddPlate_Click); + mnuPlateAdd.Image = Properties.Resources.add; + mnuPlateAdd.Name = "mnuPlateAdd"; + mnuPlateAdd.Size = new System.Drawing.Size(177, 22); + mnuPlateAdd.Text = "Add"; + mnuPlateAdd.Click += AddPlate_Click; // // mnuPlateRemove // - this.mnuPlateRemove.Image = global::OpenNest.Properties.Resources.remove; - this.mnuPlateRemove.Name = "mnuPlateRemove"; - this.mnuPlateRemove.Size = new System.Drawing.Size(177, 22); - this.mnuPlateRemove.Text = "Remove"; - this.mnuPlateRemove.Click += new System.EventHandler(this.RemovePlate_Click); + mnuPlateRemove.Image = Properties.Resources.remove; + mnuPlateRemove.Name = "mnuPlateRemove"; + mnuPlateRemove.Size = new System.Drawing.Size(177, 22); + mnuPlateRemove.Text = "Remove"; + mnuPlateRemove.Click += RemovePlate_Click; // // toolStripMenuItem16 // - this.toolStripMenuItem16.Name = "toolStripMenuItem16"; - this.toolStripMenuItem16.Size = new System.Drawing.Size(174, 6); + toolStripMenuItem16.Name = "toolStripMenuItem16"; + toolStripMenuItem16.Size = new System.Drawing.Size(174, 6); // // mnuPlateFill // - this.mnuPlateFill.Name = "mnuPlateFill"; - this.mnuPlateFill.Size = new System.Drawing.Size(177, 22); - this.mnuPlateFill.Text = "Fill"; - this.mnuPlateFill.Click += new System.EventHandler(this.FillPlate_Click); + mnuPlateFill.Name = "mnuPlateFill"; + mnuPlateFill.Size = new System.Drawing.Size(177, 22); + mnuPlateFill.Text = "Fill"; + mnuPlateFill.Click += FillPlate_Click; // // toolStripMenuItem9 // - this.toolStripMenuItem9.Name = "toolStripMenuItem9"; - this.toolStripMenuItem9.Size = new System.Drawing.Size(177, 22); - this.toolStripMenuItem9.Text = "Fill Area"; - this.toolStripMenuItem9.Click += new System.EventHandler(this.FillArea_Click); + toolStripMenuItem9.Name = "toolStripMenuItem9"; + toolStripMenuItem9.Size = new System.Drawing.Size(177, 22); + toolStripMenuItem9.Text = "Fill Area"; + toolStripMenuItem9.Click += FillArea_Click; // // mnuPlateRotate // - this.mnuPlateRotate.DropDownItems.AddRange(new System.Windows.Forms.ToolStripItem[] { - this.mnuPlateRotateCw, - this.mnuPlateRotateCcw, - this.toolStripSeparator2, - this.mnuPlateRotate180}); - this.mnuPlateRotate.Name = "mnuPlateRotate"; - this.mnuPlateRotate.Size = new System.Drawing.Size(177, 22); - this.mnuPlateRotate.Text = "Rotate"; + mnuPlateRotate.DropDownItems.AddRange(new System.Windows.Forms.ToolStripItem[] { mnuPlateRotateCw, mnuPlateRotateCcw, toolStripSeparator2, mnuPlateRotate180 }); + mnuPlateRotate.Name = "mnuPlateRotate"; + mnuPlateRotate.Size = new System.Drawing.Size(177, 22); + mnuPlateRotate.Text = "Rotate"; // // mnuPlateRotateCw // - this.mnuPlateRotateCw.Image = global::OpenNest.Properties.Resources.rotate_cw; - this.mnuPlateRotateCw.Name = "mnuPlateRotateCw"; - this.mnuPlateRotateCw.Size = new System.Drawing.Size(121, 22); - this.mnuPlateRotateCw.Text = "90° CW"; - this.mnuPlateRotateCw.Click += new System.EventHandler(this.RotateCw_Click); + mnuPlateRotateCw.Image = Properties.Resources.rotate_cw; + mnuPlateRotateCw.Name = "mnuPlateRotateCw"; + mnuPlateRotateCw.Size = new System.Drawing.Size(121, 22); + mnuPlateRotateCw.Text = "90° CW"; + mnuPlateRotateCw.Click += RotateCw_Click; // // mnuPlateRotateCcw // - this.mnuPlateRotateCcw.Image = global::OpenNest.Properties.Resources.rotate_ccw; - this.mnuPlateRotateCcw.Name = "mnuPlateRotateCcw"; - this.mnuPlateRotateCcw.Size = new System.Drawing.Size(121, 22); - this.mnuPlateRotateCcw.Text = "90° CCW"; - this.mnuPlateRotateCcw.Click += new System.EventHandler(this.RotateCcw_Click); + mnuPlateRotateCcw.Image = Properties.Resources.rotate_ccw; + mnuPlateRotateCcw.Name = "mnuPlateRotateCcw"; + mnuPlateRotateCcw.Size = new System.Drawing.Size(121, 22); + mnuPlateRotateCcw.Text = "90° CCW"; + mnuPlateRotateCcw.Click += RotateCcw_Click; // // toolStripSeparator2 // - this.toolStripSeparator2.Name = "toolStripSeparator2"; - this.toolStripSeparator2.Size = new System.Drawing.Size(118, 6); + toolStripSeparator2.Name = "toolStripSeparator2"; + toolStripSeparator2.Size = new System.Drawing.Size(118, 6); // // mnuPlateRotate180 // - this.mnuPlateRotate180.Name = "mnuPlateRotate180"; - this.mnuPlateRotate180.Size = new System.Drawing.Size(121, 22); - this.mnuPlateRotate180.Text = "180°"; - this.mnuPlateRotate180.Click += new System.EventHandler(this.Rotate180_Click); + mnuPlateRotate180.Name = "mnuPlateRotate180"; + mnuPlateRotate180.Size = new System.Drawing.Size(121, 22); + mnuPlateRotate180.Text = "180°"; + mnuPlateRotate180.Click += Rotate180_Click; // // mnuResizeToFitParts // - this.mnuResizeToFitParts.Name = "mnuResizeToFitParts"; - this.mnuResizeToFitParts.ShortcutKeys = ((System.Windows.Forms.Keys)((System.Windows.Forms.Keys.Control | System.Windows.Forms.Keys.P))); - this.mnuResizeToFitParts.Size = new System.Drawing.Size(177, 22); - this.mnuResizeToFitParts.Text = "Resize to Fit"; - this.mnuResizeToFitParts.Click += new System.EventHandler(this.ResizeToFitParts_Click); + mnuResizeToFitParts.Name = "mnuResizeToFitParts"; + mnuResizeToFitParts.ShortcutKeys = System.Windows.Forms.Keys.Control | System.Windows.Forms.Keys.P; + mnuResizeToFitParts.Size = new System.Drawing.Size(177, 22); + mnuResizeToFitParts.Text = "Resize to Fit"; + mnuResizeToFitParts.Click += ResizeToFitParts_Click; // // toolStripMenuItem13 // - this.toolStripMenuItem13.Name = "toolStripMenuItem13"; - this.toolStripMenuItem13.Size = new System.Drawing.Size(174, 6); + toolStripMenuItem13.Name = "toolStripMenuItem13"; + toolStripMenuItem13.Size = new System.Drawing.Size(174, 6); // // mnuPlateViewInCad // - this.mnuPlateViewInCad.Name = "mnuPlateViewInCad"; - this.mnuPlateViewInCad.Size = new System.Drawing.Size(177, 22); - this.mnuPlateViewInCad.Text = "View in CAD"; - this.mnuPlateViewInCad.Click += new System.EventHandler(this.OpenInExternalCad_Click); + mnuPlateViewInCad.Name = "mnuPlateViewInCad"; + mnuPlateViewInCad.Size = new System.Drawing.Size(177, 22); + mnuPlateViewInCad.Text = "View in CAD"; + mnuPlateViewInCad.Click += OpenInExternalCad_Click; // // toolStripMenuItem20 // - this.toolStripMenuItem20.Name = "toolStripMenuItem20"; - this.toolStripMenuItem20.Size = new System.Drawing.Size(174, 6); + toolStripMenuItem20.Name = "toolStripMenuItem20"; + toolStripMenuItem20.Size = new System.Drawing.Size(174, 6); // // mnuSequenceParts // - this.mnuSequenceParts.DropDownItems.AddRange(new System.Windows.Forms.ToolStripItem[] { - this.autoSequenceCurrentPlateToolStripMenuItem, - this.manualSequenceToolStripMenuItem}); - this.mnuSequenceParts.Name = "mnuSequenceParts"; - this.mnuSequenceParts.Size = new System.Drawing.Size(177, 22); - this.mnuSequenceParts.Text = "Sequence Parts"; + mnuSequenceParts.DropDownItems.AddRange(new System.Windows.Forms.ToolStripItem[] { autoSequenceCurrentPlateToolStripMenuItem, manualSequenceToolStripMenuItem }); + mnuSequenceParts.Name = "mnuSequenceParts"; + mnuSequenceParts.Size = new System.Drawing.Size(177, 22); + mnuSequenceParts.Text = "Sequence Parts"; // // autoSequenceCurrentPlateToolStripMenuItem // - this.autoSequenceCurrentPlateToolStripMenuItem.Name = "autoSequenceCurrentPlateToolStripMenuItem"; - this.autoSequenceCurrentPlateToolStripMenuItem.Size = new System.Drawing.Size(168, 22); - this.autoSequenceCurrentPlateToolStripMenuItem.Text = "Auto Sequence"; - this.autoSequenceCurrentPlateToolStripMenuItem.Click += new System.EventHandler(this.AutoSequenceCurrentPlate_Click); + autoSequenceCurrentPlateToolStripMenuItem.Name = "autoSequenceCurrentPlateToolStripMenuItem"; + autoSequenceCurrentPlateToolStripMenuItem.Size = new System.Drawing.Size(168, 22); + autoSequenceCurrentPlateToolStripMenuItem.Text = "Auto Sequence"; + autoSequenceCurrentPlateToolStripMenuItem.Click += AutoSequenceCurrentPlate_Click; // // manualSequenceToolStripMenuItem // - this.manualSequenceToolStripMenuItem.Name = "manualSequenceToolStripMenuItem"; - this.manualSequenceToolStripMenuItem.Size = new System.Drawing.Size(168, 22); - this.manualSequenceToolStripMenuItem.Text = "Manual Sequence"; - this.manualSequenceToolStripMenuItem.Click += new System.EventHandler(this.ManualSequenceParts_Click); + manualSequenceToolStripMenuItem.Name = "manualSequenceToolStripMenuItem"; + manualSequenceToolStripMenuItem.Size = new System.Drawing.Size(168, 22); + manualSequenceToolStripMenuItem.Text = "Manual Sequence"; + manualSequenceToolStripMenuItem.Click += ManualSequenceParts_Click; // // calculateCutTimeToolStripMenuItem1 // - this.calculateCutTimeToolStripMenuItem1.Name = "calculateCutTimeToolStripMenuItem1"; - this.calculateCutTimeToolStripMenuItem1.Size = new System.Drawing.Size(177, 22); - this.calculateCutTimeToolStripMenuItem1.Text = "Calculate Cut Time"; - this.calculateCutTimeToolStripMenuItem1.Click += new System.EventHandler(this.CalculatePlateCutTime_Click); + calculateCutTimeToolStripMenuItem1.Name = "calculateCutTimeToolStripMenuItem1"; + calculateCutTimeToolStripMenuItem1.Size = new System.Drawing.Size(177, 22); + calculateCutTimeToolStripMenuItem1.Text = "Calculate Cut Time"; + calculateCutTimeToolStripMenuItem1.Click += CalculatePlateCutTime_Click; // // centerPartsToolStripMenuItem // - this.centerPartsToolStripMenuItem.Name = "centerPartsToolStripMenuItem"; - this.centerPartsToolStripMenuItem.Size = new System.Drawing.Size(177, 22); - this.centerPartsToolStripMenuItem.Text = "Center Parts"; - this.centerPartsToolStripMenuItem.Click += new System.EventHandler(this.centerPartsToolStripMenuItem_Click); + centerPartsToolStripMenuItem.Name = "centerPartsToolStripMenuItem"; + centerPartsToolStripMenuItem.Size = new System.Drawing.Size(177, 22); + centerPartsToolStripMenuItem.Text = "Center Parts"; + centerPartsToolStripMenuItem.Click += centerPartsToolStripMenuItem_Click; // // mnuWindow // - this.mnuWindow.DropDownItems.AddRange(new System.Windows.Forms.ToolStripItem[] { - this.mnuWindowCascade, - this.mnuWindowTileVertical, - this.mnuWindowTileHorizontal, - this.toolStripMenuItem10, - this.closeToolStripMenuItem, - this.mnuCloseAll}); - this.mnuWindow.Name = "mnuWindow"; - this.mnuWindow.Size = new System.Drawing.Size(63, 20); - this.mnuWindow.Text = "&Window"; + mnuWindow.DropDownItems.AddRange(new System.Windows.Forms.ToolStripItem[] { mnuWindowCascade, mnuWindowTileVertical, mnuWindowTileHorizontal, toolStripMenuItem10, closeToolStripMenuItem, mnuCloseAll }); + mnuWindow.Name = "mnuWindow"; + mnuWindow.Size = new System.Drawing.Size(63, 20); + mnuWindow.Text = "&Window"; // // mnuWindowCascade // - this.mnuWindowCascade.Name = "mnuWindowCascade"; - this.mnuWindowCascade.Size = new System.Drawing.Size(151, 22); - this.mnuWindowCascade.Text = "Cascade"; - this.mnuWindowCascade.Click += new System.EventHandler(this.CascadeWindows_Click); + mnuWindowCascade.Name = "mnuWindowCascade"; + mnuWindowCascade.Size = new System.Drawing.Size(151, 22); + mnuWindowCascade.Text = "Cascade"; + mnuWindowCascade.Click += CascadeWindows_Click; // // mnuWindowTileVertical // - this.mnuWindowTileVertical.Name = "mnuWindowTileVertical"; - this.mnuWindowTileVertical.Size = new System.Drawing.Size(151, 22); - this.mnuWindowTileVertical.Text = "Tile Vertical"; - this.mnuWindowTileVertical.Click += new System.EventHandler(this.TileVertical_Click); + mnuWindowTileVertical.Name = "mnuWindowTileVertical"; + mnuWindowTileVertical.Size = new System.Drawing.Size(151, 22); + mnuWindowTileVertical.Text = "Tile Vertical"; + mnuWindowTileVertical.Click += TileVertical_Click; // // mnuWindowTileHorizontal // - this.mnuWindowTileHorizontal.Name = "mnuWindowTileHorizontal"; - this.mnuWindowTileHorizontal.Size = new System.Drawing.Size(151, 22); - this.mnuWindowTileHorizontal.Text = "Tile Horizontal"; - this.mnuWindowTileHorizontal.Click += new System.EventHandler(this.TileHorizontal_Click); + mnuWindowTileHorizontal.Name = "mnuWindowTileHorizontal"; + mnuWindowTileHorizontal.Size = new System.Drawing.Size(151, 22); + mnuWindowTileHorizontal.Text = "Tile Horizontal"; + mnuWindowTileHorizontal.Click += TileHorizontal_Click; // // toolStripMenuItem10 // - this.toolStripMenuItem10.Name = "toolStripMenuItem10"; - this.toolStripMenuItem10.Size = new System.Drawing.Size(148, 6); + toolStripMenuItem10.Name = "toolStripMenuItem10"; + toolStripMenuItem10.Size = new System.Drawing.Size(148, 6); // // closeToolStripMenuItem // - this.closeToolStripMenuItem.Name = "closeToolStripMenuItem"; - this.closeToolStripMenuItem.Size = new System.Drawing.Size(151, 22); - this.closeToolStripMenuItem.Text = "Close"; - this.closeToolStripMenuItem.Click += new System.EventHandler(this.Close_Click); + closeToolStripMenuItem.Name = "closeToolStripMenuItem"; + closeToolStripMenuItem.Size = new System.Drawing.Size(151, 22); + closeToolStripMenuItem.Text = "Close"; + closeToolStripMenuItem.Click += Close_Click; // // mnuCloseAll // - this.mnuCloseAll.Name = "mnuCloseAll"; - this.mnuCloseAll.Size = new System.Drawing.Size(151, 22); - this.mnuCloseAll.Text = "Close All"; - this.mnuCloseAll.Click += new System.EventHandler(this.CloseAll_Click); + mnuCloseAll.Name = "mnuCloseAll"; + mnuCloseAll.Size = new System.Drawing.Size(151, 22); + mnuCloseAll.Text = "Close All"; + mnuCloseAll.Click += CloseAll_Click; // // statusStrip1 // - this.statusStrip1.Items.AddRange(new System.Windows.Forms.ToolStripItem[] { - this.statusLabel1, - this.locationStatusLabel, - this.spacerLabel, - this.plateIndexStatusLabel, - this.plateSizeStatusLabel, - this.plateQtyStatusLabel, - this.gpuStatusLabel}); - this.statusStrip1.Location = new System.Drawing.Point(0, 543); - this.statusStrip1.Name = "statusStrip1"; - this.statusStrip1.Size = new System.Drawing.Size(1098, 24); - this.statusStrip1.TabIndex = 9; - this.statusStrip1.Text = "statusStrip1"; + statusStrip1.Items.AddRange(new System.Windows.Forms.ToolStripItem[] { statusLabel1, locationStatusLabel, spacerLabel, plateIndexStatusLabel, plateSizeStatusLabel, plateQtyStatusLabel, gpuStatusLabel }); + statusStrip1.Location = new System.Drawing.Point(0, 630); + statusStrip1.Name = "statusStrip1"; + statusStrip1.Padding = new System.Windows.Forms.Padding(1, 0, 16, 0); + statusStrip1.Size = new System.Drawing.Size(1281, 24); + statusStrip1.TabIndex = 9; + statusStrip1.Text = "statusStrip1"; // // statusLabel1 // - this.statusLabel1.Name = "statusLabel1"; - this.statusLabel1.Padding = new System.Windows.Forms.Padding(5, 0, 5, 0); - this.statusLabel1.Size = new System.Drawing.Size(10, 19); + statusLabel1.Name = "statusLabel1"; + statusLabel1.Padding = new System.Windows.Forms.Padding(5, 0, 5, 0); + statusLabel1.Size = new System.Drawing.Size(10, 19); // // locationStatusLabel // - this.locationStatusLabel.BorderSides = System.Windows.Forms.ToolStripStatusLabelBorderSides.Left; - this.locationStatusLabel.Name = "locationStatusLabel"; - this.locationStatusLabel.Padding = new System.Windows.Forms.Padding(5, 0, 5, 0); - this.locationStatusLabel.Size = new System.Drawing.Size(102, 19); - this.locationStatusLabel.Text = "Location : [0, 0]"; - this.locationStatusLabel.Click += new System.EventHandler(this.LocationStatusLabel_Click); + locationStatusLabel.BorderSides = System.Windows.Forms.ToolStripStatusLabelBorderSides.Left; + locationStatusLabel.Name = "locationStatusLabel"; + locationStatusLabel.Padding = new System.Windows.Forms.Padding(5, 0, 5, 0); + locationStatusLabel.Size = new System.Drawing.Size(102, 19); + locationStatusLabel.Text = "Location : [0, 0]"; + locationStatusLabel.Click += LocationStatusLabel_Click; // // spacerLabel // - this.spacerLabel.Name = "spacerLabel"; - this.spacerLabel.Padding = new System.Windows.Forms.Padding(5, 0, 5, 0); - this.spacerLabel.Size = new System.Drawing.Size(764, 19); - this.spacerLabel.Spring = true; + spacerLabel.Name = "spacerLabel"; + spacerLabel.Padding = new System.Windows.Forms.Padding(5, 0, 5, 0); + spacerLabel.Size = new System.Drawing.Size(931, 19); + spacerLabel.Spring = true; // // plateIndexStatusLabel // - this.plateIndexStatusLabel.BorderSides = System.Windows.Forms.ToolStripStatusLabelBorderSides.Left; - this.plateIndexStatusLabel.Name = "plateIndexStatusLabel"; - this.plateIndexStatusLabel.Padding = new System.Windows.Forms.Padding(5, 0, 5, 0); - this.plateIndexStatusLabel.Size = new System.Drawing.Size(85, 19); - this.plateIndexStatusLabel.Text = "Plate : 0 of 0"; + plateIndexStatusLabel.BorderSides = System.Windows.Forms.ToolStripStatusLabelBorderSides.Left; + plateIndexStatusLabel.Name = "plateIndexStatusLabel"; + plateIndexStatusLabel.Padding = new System.Windows.Forms.Padding(5, 0, 5, 0); + plateIndexStatusLabel.Size = new System.Drawing.Size(85, 19); + plateIndexStatusLabel.Text = "Plate : 0 of 0"; // // plateSizeStatusLabel // - this.plateSizeStatusLabel.BorderSides = System.Windows.Forms.ToolStripStatusLabelBorderSides.Left; - this.plateSizeStatusLabel.Name = "plateSizeStatusLabel"; - this.plateSizeStatusLabel.Padding = new System.Windows.Forms.Padding(5, 0, 5, 0); - this.plateSizeStatusLabel.Size = new System.Drawing.Size(67, 19); - this.plateSizeStatusLabel.Text = "Size : 0x0"; + plateSizeStatusLabel.BorderSides = System.Windows.Forms.ToolStripStatusLabelBorderSides.Left; + plateSizeStatusLabel.Name = "plateSizeStatusLabel"; + plateSizeStatusLabel.Padding = new System.Windows.Forms.Padding(5, 0, 5, 0); + plateSizeStatusLabel.Size = new System.Drawing.Size(67, 19); + plateSizeStatusLabel.Text = "Size : 0x0"; // // plateQtyStatusLabel // - this.plateQtyStatusLabel.BorderSides = System.Windows.Forms.ToolStripStatusLabelBorderSides.Left; - this.plateQtyStatusLabel.Name = "plateQtyStatusLabel"; - this.plateQtyStatusLabel.Padding = new System.Windows.Forms.Padding(5, 0, 5, 0); - this.plateQtyStatusLabel.Size = new System.Drawing.Size(55, 19); - this.plateQtyStatusLabel.Text = "Qty : 0"; - // + plateQtyStatusLabel.BorderSides = System.Windows.Forms.ToolStripStatusLabelBorderSides.Left; + plateQtyStatusLabel.Name = "plateQtyStatusLabel"; + plateQtyStatusLabel.Padding = new System.Windows.Forms.Padding(5, 0, 5, 0); + plateQtyStatusLabel.Size = new System.Drawing.Size(55, 19); + plateQtyStatusLabel.Text = "Qty : 0"; + // // gpuStatusLabel - // - this.gpuStatusLabel.BorderSides = System.Windows.Forms.ToolStripStatusLabelBorderSides.Left; - this.gpuStatusLabel.Name = "gpuStatusLabel"; - this.gpuStatusLabel.Padding = new System.Windows.Forms.Padding(5, 0, 5, 0); - this.gpuStatusLabel.Size = new System.Drawing.Size(55, 19); - // + // + gpuStatusLabel.BorderSides = System.Windows.Forms.ToolStripStatusLabelBorderSides.Left; + gpuStatusLabel.Name = "gpuStatusLabel"; + gpuStatusLabel.Padding = new System.Windows.Forms.Padding(5, 0, 5, 0); + gpuStatusLabel.Size = new System.Drawing.Size(14, 19); + // // toolStrip1 // - this.toolStrip1.AutoSize = false; - this.toolStrip1.Items.AddRange(new System.Windows.Forms.ToolStripItem[] { - this.btnNew, - this.btnOpen, - this.btnSave, - this.btnSaveAs, - this.toolStripSeparator1, - this.btnFirstPlate, - this.btnPreviousPlate, - this.btnNextPlate, - this.btnLastPlate, - this.toolStripSeparator3, - this.btnZoomOut, - this.btnZoomIn, - this.btnZoomToFit}); - this.toolStrip1.Location = new System.Drawing.Point(0, 24); - this.toolStrip1.Name = "toolStrip1"; - this.toolStrip1.Size = new System.Drawing.Size(1098, 35); - this.toolStrip1.TabIndex = 10; - this.toolStrip1.Text = "toolStrip1"; + toolStrip1.AutoSize = false; + toolStrip1.Items.AddRange(new System.Windows.Forms.ToolStripItem[] { btnNew, btnOpen, btnSave, btnSaveAs, toolStripSeparator1, btnFirstPlate, btnPreviousPlate, btnNextPlate, btnLastPlate, toolStripSeparator3, btnZoomOut, btnZoomIn, btnZoomToFit, toolStripSeparator4, engineLabel, engineComboBox, btnAutoNest }); + toolStrip1.Location = new System.Drawing.Point(0, 24); + toolStrip1.Name = "toolStrip1"; + toolStrip1.Size = new System.Drawing.Size(1281, 40); + toolStrip1.TabIndex = 10; + toolStrip1.Text = "toolStrip1"; // // btnNew // - this.btnNew.DisplayStyle = System.Windows.Forms.ToolStripItemDisplayStyle.Image; - this.btnNew.Image = global::OpenNest.Properties.Resources.doc_new; - this.btnNew.ImageScaling = System.Windows.Forms.ToolStripItemImageScaling.None; - this.btnNew.Name = "btnNew"; - this.btnNew.Padding = new System.Windows.Forms.Padding(5, 0, 5, 0); - this.btnNew.RightToLeft = System.Windows.Forms.RightToLeft.No; - this.btnNew.Size = new System.Drawing.Size(38, 32); - this.btnNew.Text = "New"; - this.btnNew.Click += new System.EventHandler(this.New_Click); + btnNew.DisplayStyle = System.Windows.Forms.ToolStripItemDisplayStyle.Image; + btnNew.Image = Properties.Resources.doc_new; + btnNew.ImageScaling = System.Windows.Forms.ToolStripItemImageScaling.None; + btnNew.Name = "btnNew"; + btnNew.Padding = new System.Windows.Forms.Padding(5, 0, 5, 0); + btnNew.RightToLeft = System.Windows.Forms.RightToLeft.No; + btnNew.Size = new System.Drawing.Size(38, 37); + btnNew.Text = "New"; + btnNew.Click += New_Click; // // btnOpen // - this.btnOpen.DisplayStyle = System.Windows.Forms.ToolStripItemDisplayStyle.Image; - this.btnOpen.Image = global::OpenNest.Properties.Resources.doc_open; - this.btnOpen.ImageScaling = System.Windows.Forms.ToolStripItemImageScaling.None; - this.btnOpen.Name = "btnOpen"; - this.btnOpen.Padding = new System.Windows.Forms.Padding(5, 0, 5, 0); - this.btnOpen.RightToLeft = System.Windows.Forms.RightToLeft.No; - this.btnOpen.Size = new System.Drawing.Size(38, 32); - this.btnOpen.Text = "Open"; - this.btnOpen.Click += new System.EventHandler(this.Open_Click); + btnOpen.DisplayStyle = System.Windows.Forms.ToolStripItemDisplayStyle.Image; + btnOpen.Image = Properties.Resources.doc_open; + btnOpen.ImageScaling = System.Windows.Forms.ToolStripItemImageScaling.None; + btnOpen.Name = "btnOpen"; + btnOpen.Padding = new System.Windows.Forms.Padding(5, 0, 5, 0); + btnOpen.RightToLeft = System.Windows.Forms.RightToLeft.No; + btnOpen.Size = new System.Drawing.Size(38, 37); + btnOpen.Text = "Open"; + btnOpen.Click += Open_Click; // // btnSave // - this.btnSave.DisplayStyle = System.Windows.Forms.ToolStripItemDisplayStyle.Image; - this.btnSave.Image = global::OpenNest.Properties.Resources.save; - this.btnSave.ImageScaling = System.Windows.Forms.ToolStripItemImageScaling.None; - this.btnSave.Name = "btnSave"; - this.btnSave.Padding = new System.Windows.Forms.Padding(5, 0, 5, 0); - this.btnSave.RightToLeft = System.Windows.Forms.RightToLeft.No; - this.btnSave.Size = new System.Drawing.Size(38, 32); - this.btnSave.Text = "Save"; - this.btnSave.Click += new System.EventHandler(this.Save_Click); + btnSave.DisplayStyle = System.Windows.Forms.ToolStripItemDisplayStyle.Image; + btnSave.Image = Properties.Resources.save; + btnSave.ImageScaling = System.Windows.Forms.ToolStripItemImageScaling.None; + btnSave.Name = "btnSave"; + btnSave.Padding = new System.Windows.Forms.Padding(5, 0, 5, 0); + btnSave.RightToLeft = System.Windows.Forms.RightToLeft.No; + btnSave.Size = new System.Drawing.Size(38, 37); + btnSave.Text = "Save"; + btnSave.Click += Save_Click; // // btnSaveAs // - this.btnSaveAs.DisplayStyle = System.Windows.Forms.ToolStripItemDisplayStyle.Image; - this.btnSaveAs.Image = global::OpenNest.Properties.Resources.save_as; - this.btnSaveAs.ImageScaling = System.Windows.Forms.ToolStripItemImageScaling.None; - this.btnSaveAs.Name = "btnSaveAs"; - this.btnSaveAs.Padding = new System.Windows.Forms.Padding(5, 0, 5, 0); - this.btnSaveAs.RightToLeft = System.Windows.Forms.RightToLeft.No; - this.btnSaveAs.Size = new System.Drawing.Size(38, 32); - this.btnSaveAs.Text = "Save As"; - this.btnSaveAs.Click += new System.EventHandler(this.SaveAs_Click); + btnSaveAs.DisplayStyle = System.Windows.Forms.ToolStripItemDisplayStyle.Image; + btnSaveAs.Image = Properties.Resources.save_as; + btnSaveAs.ImageScaling = System.Windows.Forms.ToolStripItemImageScaling.None; + btnSaveAs.Name = "btnSaveAs"; + btnSaveAs.Padding = new System.Windows.Forms.Padding(5, 0, 5, 0); + btnSaveAs.RightToLeft = System.Windows.Forms.RightToLeft.No; + btnSaveAs.Size = new System.Drawing.Size(38, 37); + btnSaveAs.Text = "Save As"; + btnSaveAs.Click += SaveAs_Click; // // toolStripSeparator1 // - this.toolStripSeparator1.Name = "toolStripSeparator1"; - this.toolStripSeparator1.Size = new System.Drawing.Size(6, 35); + toolStripSeparator1.Name = "toolStripSeparator1"; + toolStripSeparator1.Size = new System.Drawing.Size(6, 40); // // btnFirstPlate // - this.btnFirstPlate.DisplayStyle = System.Windows.Forms.ToolStripItemDisplayStyle.Image; - this.btnFirstPlate.Image = global::OpenNest.Properties.Resources.move_first; - this.btnFirstPlate.ImageScaling = System.Windows.Forms.ToolStripItemImageScaling.None; - this.btnFirstPlate.ImageTransparentColor = System.Drawing.Color.Magenta; - this.btnFirstPlate.Name = "btnFirstPlate"; - this.btnFirstPlate.Padding = new System.Windows.Forms.Padding(5, 0, 5, 0); - this.btnFirstPlate.Size = new System.Drawing.Size(38, 32); - this.btnFirstPlate.Text = "Go to First Plate"; - this.btnFirstPlate.Click += new System.EventHandler(this.LoadFirstPlate_Click); + btnFirstPlate.DisplayStyle = System.Windows.Forms.ToolStripItemDisplayStyle.Image; + btnFirstPlate.Image = Properties.Resources.move_first; + btnFirstPlate.ImageScaling = System.Windows.Forms.ToolStripItemImageScaling.None; + btnFirstPlate.ImageTransparentColor = System.Drawing.Color.Magenta; + btnFirstPlate.Name = "btnFirstPlate"; + btnFirstPlate.Padding = new System.Windows.Forms.Padding(5, 0, 5, 0); + btnFirstPlate.Size = new System.Drawing.Size(38, 37); + btnFirstPlate.Text = "Go to First Plate"; + btnFirstPlate.Click += LoadFirstPlate_Click; // // btnPreviousPlate // - this.btnPreviousPlate.DisplayStyle = System.Windows.Forms.ToolStripItemDisplayStyle.Image; - this.btnPreviousPlate.Image = global::OpenNest.Properties.Resources.move_previous; - this.btnPreviousPlate.ImageScaling = System.Windows.Forms.ToolStripItemImageScaling.None; - this.btnPreviousPlate.ImageTransparentColor = System.Drawing.Color.Magenta; - this.btnPreviousPlate.Name = "btnPreviousPlate"; - this.btnPreviousPlate.Padding = new System.Windows.Forms.Padding(5, 0, 5, 0); - this.btnPreviousPlate.Size = new System.Drawing.Size(38, 32); - this.btnPreviousPlate.Text = "Go to Previous Plate"; - this.btnPreviousPlate.Click += new System.EventHandler(this.LoadPreviousPlate_Click); + btnPreviousPlate.DisplayStyle = System.Windows.Forms.ToolStripItemDisplayStyle.Image; + btnPreviousPlate.Image = Properties.Resources.move_previous; + btnPreviousPlate.ImageScaling = System.Windows.Forms.ToolStripItemImageScaling.None; + btnPreviousPlate.ImageTransparentColor = System.Drawing.Color.Magenta; + btnPreviousPlate.Name = "btnPreviousPlate"; + btnPreviousPlate.Padding = new System.Windows.Forms.Padding(5, 0, 5, 0); + btnPreviousPlate.Size = new System.Drawing.Size(38, 37); + btnPreviousPlate.Text = "Go to Previous Plate"; + btnPreviousPlate.Click += LoadPreviousPlate_Click; // // btnNextPlate // - this.btnNextPlate.DisplayStyle = System.Windows.Forms.ToolStripItemDisplayStyle.Image; - this.btnNextPlate.Image = global::OpenNest.Properties.Resources.move_next; - this.btnNextPlate.ImageScaling = System.Windows.Forms.ToolStripItemImageScaling.None; - this.btnNextPlate.ImageTransparentColor = System.Drawing.Color.Magenta; - this.btnNextPlate.Name = "btnNextPlate"; - this.btnNextPlate.Padding = new System.Windows.Forms.Padding(5, 0, 5, 0); - this.btnNextPlate.RightToLeft = System.Windows.Forms.RightToLeft.No; - this.btnNextPlate.Size = new System.Drawing.Size(38, 32); - this.btnNextPlate.Text = "Go to Next Plate"; - this.btnNextPlate.Click += new System.EventHandler(this.LoadNextPlate_Click); + btnNextPlate.DisplayStyle = System.Windows.Forms.ToolStripItemDisplayStyle.Image; + btnNextPlate.Image = Properties.Resources.move_next; + btnNextPlate.ImageScaling = System.Windows.Forms.ToolStripItemImageScaling.None; + btnNextPlate.ImageTransparentColor = System.Drawing.Color.Magenta; + btnNextPlate.Name = "btnNextPlate"; + btnNextPlate.Padding = new System.Windows.Forms.Padding(5, 0, 5, 0); + btnNextPlate.RightToLeft = System.Windows.Forms.RightToLeft.No; + btnNextPlate.Size = new System.Drawing.Size(38, 37); + btnNextPlate.Text = "Go to Next Plate"; + btnNextPlate.Click += LoadNextPlate_Click; // // btnLastPlate // - this.btnLastPlate.DisplayStyle = System.Windows.Forms.ToolStripItemDisplayStyle.Image; - this.btnLastPlate.Image = global::OpenNest.Properties.Resources.move_last; - this.btnLastPlate.ImageScaling = System.Windows.Forms.ToolStripItemImageScaling.None; - this.btnLastPlate.ImageTransparentColor = System.Drawing.Color.Magenta; - this.btnLastPlate.Name = "btnLastPlate"; - this.btnLastPlate.Padding = new System.Windows.Forms.Padding(5, 0, 5, 0); - this.btnLastPlate.RightToLeft = System.Windows.Forms.RightToLeft.No; - this.btnLastPlate.Size = new System.Drawing.Size(38, 32); - this.btnLastPlate.Text = "Go to Last Plate"; - this.btnLastPlate.Click += new System.EventHandler(this.LoadLastPlate_Click); + btnLastPlate.DisplayStyle = System.Windows.Forms.ToolStripItemDisplayStyle.Image; + btnLastPlate.Image = Properties.Resources.move_last; + btnLastPlate.ImageScaling = System.Windows.Forms.ToolStripItemImageScaling.None; + btnLastPlate.ImageTransparentColor = System.Drawing.Color.Magenta; + btnLastPlate.Name = "btnLastPlate"; + btnLastPlate.Padding = new System.Windows.Forms.Padding(5, 0, 5, 0); + btnLastPlate.RightToLeft = System.Windows.Forms.RightToLeft.No; + btnLastPlate.Size = new System.Drawing.Size(38, 37); + btnLastPlate.Text = "Go to Last Plate"; + btnLastPlate.Click += LoadLastPlate_Click; // // toolStripSeparator3 // - this.toolStripSeparator3.Name = "toolStripSeparator3"; - this.toolStripSeparator3.Size = new System.Drawing.Size(6, 35); + toolStripSeparator3.Name = "toolStripSeparator3"; + toolStripSeparator3.Size = new System.Drawing.Size(6, 40); // // btnZoomOut // - this.btnZoomOut.DisplayStyle = System.Windows.Forms.ToolStripItemDisplayStyle.Image; - this.btnZoomOut.Image = global::OpenNest.Properties.Resources.zoom_out; - this.btnZoomOut.ImageScaling = System.Windows.Forms.ToolStripItemImageScaling.None; - this.btnZoomOut.ImageTransparentColor = System.Drawing.Color.Magenta; - this.btnZoomOut.Name = "btnZoomOut"; - this.btnZoomOut.Padding = new System.Windows.Forms.Padding(5, 0, 5, 0); - this.btnZoomOut.RightToLeft = System.Windows.Forms.RightToLeft.No; - this.btnZoomOut.Size = new System.Drawing.Size(38, 32); - this.btnZoomOut.Text = "Zoom Out"; - this.btnZoomOut.Click += new System.EventHandler(this.ZoomOut_Click); + btnZoomOut.DisplayStyle = System.Windows.Forms.ToolStripItemDisplayStyle.Image; + btnZoomOut.Image = Properties.Resources.zoom_out; + btnZoomOut.ImageScaling = System.Windows.Forms.ToolStripItemImageScaling.None; + btnZoomOut.ImageTransparentColor = System.Drawing.Color.Magenta; + btnZoomOut.Name = "btnZoomOut"; + btnZoomOut.Padding = new System.Windows.Forms.Padding(5, 0, 5, 0); + btnZoomOut.RightToLeft = System.Windows.Forms.RightToLeft.No; + btnZoomOut.Size = new System.Drawing.Size(38, 37); + btnZoomOut.Text = "Zoom Out"; + btnZoomOut.Click += ZoomOut_Click; // // btnZoomIn // - this.btnZoomIn.DisplayStyle = System.Windows.Forms.ToolStripItemDisplayStyle.Image; - this.btnZoomIn.Image = global::OpenNest.Properties.Resources.zoom_in; - this.btnZoomIn.ImageScaling = System.Windows.Forms.ToolStripItemImageScaling.None; - this.btnZoomIn.ImageTransparentColor = System.Drawing.Color.Magenta; - this.btnZoomIn.Name = "btnZoomIn"; - this.btnZoomIn.Padding = new System.Windows.Forms.Padding(5, 0, 5, 0); - this.btnZoomIn.RightToLeft = System.Windows.Forms.RightToLeft.No; - this.btnZoomIn.Size = new System.Drawing.Size(38, 32); - this.btnZoomIn.Text = "Zoom In"; - this.btnZoomIn.Click += new System.EventHandler(this.ZoomIn_Click); + btnZoomIn.DisplayStyle = System.Windows.Forms.ToolStripItemDisplayStyle.Image; + btnZoomIn.Image = Properties.Resources.zoom_in; + btnZoomIn.ImageScaling = System.Windows.Forms.ToolStripItemImageScaling.None; + btnZoomIn.ImageTransparentColor = System.Drawing.Color.Magenta; + btnZoomIn.Name = "btnZoomIn"; + btnZoomIn.Padding = new System.Windows.Forms.Padding(5, 0, 5, 0); + btnZoomIn.RightToLeft = System.Windows.Forms.RightToLeft.No; + btnZoomIn.Size = new System.Drawing.Size(38, 37); + btnZoomIn.Text = "Zoom In"; + btnZoomIn.Click += ZoomIn_Click; // // btnZoomToFit // - this.btnZoomToFit.DisplayStyle = System.Windows.Forms.ToolStripItemDisplayStyle.Image; - this.btnZoomToFit.Image = global::OpenNest.Properties.Resources.zoom_all; - this.btnZoomToFit.ImageScaling = System.Windows.Forms.ToolStripItemImageScaling.None; - this.btnZoomToFit.ImageTransparentColor = System.Drawing.Color.Magenta; - this.btnZoomToFit.Name = "btnZoomToFit"; - this.btnZoomToFit.Padding = new System.Windows.Forms.Padding(5, 0, 5, 0); - this.btnZoomToFit.RightToLeft = System.Windows.Forms.RightToLeft.No; - this.btnZoomToFit.Size = new System.Drawing.Size(38, 32); - this.btnZoomToFit.Text = "Zoom To Fit"; - this.btnZoomToFit.Click += new System.EventHandler(this.ZoomToFit_Click); + btnZoomToFit.DisplayStyle = System.Windows.Forms.ToolStripItemDisplayStyle.Image; + btnZoomToFit.Image = Properties.Resources.zoom_all; + btnZoomToFit.ImageScaling = System.Windows.Forms.ToolStripItemImageScaling.None; + btnZoomToFit.ImageTransparentColor = System.Drawing.Color.Magenta; + btnZoomToFit.Name = "btnZoomToFit"; + btnZoomToFit.Padding = new System.Windows.Forms.Padding(5, 0, 5, 0); + btnZoomToFit.RightToLeft = System.Windows.Forms.RightToLeft.No; + btnZoomToFit.Size = new System.Drawing.Size(38, 37); + btnZoomToFit.Text = "Zoom To Fit"; + btnZoomToFit.Click += ZoomToFit_Click; + // + // toolStripSeparator4 + // + toolStripSeparator4.Name = "toolStripSeparator4"; + toolStripSeparator4.Size = new System.Drawing.Size(6, 40); + // + // engineLabel + // + engineLabel.Font = new System.Drawing.Font("Segoe UI", 9F, System.Drawing.FontStyle.Bold, System.Drawing.GraphicsUnit.Point, 0); + engineLabel.Name = "engineLabel"; + engineLabel.Size = new System.Drawing.Size(47, 37); + engineLabel.Text = "Engine:"; + // + // engineComboBox + // + engineComboBox.DropDownStyle = System.Windows.Forms.ComboBoxStyle.DropDownList; + engineComboBox.Name = "engineComboBox"; + engineComboBox.Size = new System.Drawing.Size(116, 40); + // + // btnAutoNest + // + btnAutoNest.DisplayStyle = System.Windows.Forms.ToolStripItemDisplayStyle.Text; + btnAutoNest.Name = "btnAutoNest"; + btnAutoNest.Size = new System.Drawing.Size(64, 37); + btnAutoNest.Text = "Auto Nest"; + btnAutoNest.Click += RunAutoNest_Click; // // pEPToolStripMenuItem // - this.pEPToolStripMenuItem.Name = "pEPToolStripMenuItem"; - this.pEPToolStripMenuItem.Size = new System.Drawing.Size(32, 19); + pEPToolStripMenuItem.Name = "pEPToolStripMenuItem"; + pEPToolStripMenuItem.Size = new System.Drawing.Size(32, 19); // // openNestToolStripMenuItem // - this.openNestToolStripMenuItem.Name = "openNestToolStripMenuItem"; - this.openNestToolStripMenuItem.Size = new System.Drawing.Size(32, 19); + openNestToolStripMenuItem.Name = "openNestToolStripMenuItem"; + openNestToolStripMenuItem.Size = new System.Drawing.Size(32, 19); // // MainForm // - this.AutoScaleDimensions = new System.Drawing.SizeF(6F, 13F); - this.AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font; - this.BackgroundImage = global::OpenNest.Properties.Resources.watermark; - this.BackgroundImageLayout = System.Windows.Forms.ImageLayout.Center; - this.ClientSize = new System.Drawing.Size(1098, 567); - this.Controls.Add(this.toolStrip1); - this.Controls.Add(this.statusStrip1); - this.Controls.Add(this.menuStrip1); - this.DoubleBuffered = true; - this.IsMdiContainer = true; - this.MainMenuStrip = this.menuStrip1; - this.MinimumSize = new System.Drawing.Size(506, 335); - this.Name = "MainForm"; - this.Text = "OpenNest"; - this.WindowState = System.Windows.Forms.FormWindowState.Maximized; - this.menuStrip1.ResumeLayout(false); - this.menuStrip1.PerformLayout(); - this.statusStrip1.ResumeLayout(false); - this.statusStrip1.PerformLayout(); - this.toolStrip1.ResumeLayout(false); - this.toolStrip1.PerformLayout(); - this.ResumeLayout(false); - this.PerformLayout(); + AutoScaleDimensions = new System.Drawing.SizeF(7F, 15F); + AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font; + BackgroundImage = Properties.Resources.watermark; + BackgroundImageLayout = System.Windows.Forms.ImageLayout.Center; + ClientSize = new System.Drawing.Size(1281, 654); + Controls.Add(toolStrip1); + Controls.Add(statusStrip1); + Controls.Add(menuStrip1); + DoubleBuffered = true; + IsMdiContainer = true; + MainMenuStrip = menuStrip1; + Margin = new System.Windows.Forms.Padding(4, 3, 4, 3); + MinimumSize = new System.Drawing.Size(588, 381); + Name = "MainForm"; + Text = "OpenNest"; + WindowState = System.Windows.Forms.FormWindowState.Maximized; + menuStrip1.ResumeLayout(false); + menuStrip1.PerformLayout(); + statusStrip1.ResumeLayout(false); + statusStrip1.PerformLayout(); + toolStrip1.ResumeLayout(false); + toolStrip1.PerformLayout(); + ResumeLayout(false); + PerformLayout(); } @@ -1309,5 +1228,9 @@ private System.Windows.Forms.ToolStripButton btnSaveAs; private System.Windows.Forms.ToolStripMenuItem centerPartsToolStripMenuItem; private System.Windows.Forms.ToolStripStatusLabel gpuStatusLabel; + private System.Windows.Forms.ToolStripSeparator toolStripSeparator4; + private System.Windows.Forms.ToolStripLabel engineLabel; + private System.Windows.Forms.ToolStripComboBox engineComboBox; + private System.Windows.Forms.ToolStripButton btnAutoNest; } } \ No newline at end of file diff --git a/OpenNest/Forms/MainForm.cs b/OpenNest/Forms/MainForm.cs index d98568b..26c6d50 100644 --- a/OpenNest/Forms/MainForm.cs +++ b/OpenNest/Forms/MainForm.cs @@ -50,6 +50,18 @@ namespace OpenNest.Forms //if (GpuEvaluatorFactory.GpuAvailable) // BestFitCache.CreateEvaluator = (drawing, spacing) => GpuEvaluatorFactory.Create(drawing, spacing); + + if (GpuEvaluatorFactory.GpuAvailable) + BestFitCache.CreateSlideComputer = () => GpuEvaluatorFactory.CreateSlideComputer(); + + var enginesDir = Path.Combine(Application.StartupPath, "Engines"); + NestEngineRegistry.LoadPlugins(enginesDir); + + foreach (var engine in NestEngineRegistry.AvailableEngines) + engineComboBox.Items.Add(engine.Name); + + engineComboBox.SelectedItem = NestEngineRegistry.ActiveEngineName; + engineComboBox.SelectedIndexChanged += EngineComboBox_SelectedIndexChanged; } private Nest CreateDefaultNest() @@ -246,6 +258,12 @@ namespace OpenNest.Forms } } + private void EngineComboBox_SelectedIndexChanged(object sender, EventArgs e) + { + if (engineComboBox.SelectedItem is string name) + NestEngineRegistry.ActiveEngineName = name; + } + private void UpdateLocationMode() { if (activeForm == null) @@ -735,6 +753,15 @@ namespace OpenNest.Forms nestingCts = new CancellationTokenSource(); var token = nestingCts.Token; + var progressForm = new NestProgressForm(nestingCts, showPlateRow: true); + + var progress = new Progress(p => + { + progressForm.UpdateProgress(p); + activeForm.PlateView.SetTemporaryParts(p.BestParts); + }); + + progressForm.Show(this); SetNestingLockout(true); try @@ -758,32 +785,39 @@ namespace OpenNest.Forms if (plate != activeForm.PlateView.Plate) activeForm.LoadLastPlate(); - var parts = await Task.Run(() => - NestEngine.AutoNest(remaining, plate, token)); + var anyPlaced = false; - if (parts.Count == 0) - break; + var engine = NestEngineRegistry.Create(plate); + engine.PlateNumber = plateCount; - plate.Parts.AddRange(parts); - activeForm.PlateView.Invalidate(); + var nestParts = await Task.Run(() => + engine.Nest(remaining, progress, token)); - // Deduct placed quantities using Drawing.Name to avoid reference issues. - foreach (var item in remaining) + activeForm.PlateView.ClearTemporaryParts(); + + if (nestParts.Count > 0 && !token.IsCancellationRequested) { - var placed = parts.Count(p => p.BaseDrawing.Name == item.Drawing.Name); - item.Quantity = System.Math.Max(0, item.Quantity - placed); + plate.Parts.AddRange(nestParts); + activeForm.PlateView.Invalidate(); + anyPlaced = true; } + + if (!anyPlaced) + break; } activeForm.Nest.UpdateDrawingQuantities(); + progressForm.ShowCompleted(); } catch (Exception ex) { + activeForm.PlateView.ClearTemporaryParts(); MessageBox.Show($"Nesting error: {ex.Message}", "Error", MessageBoxButtons.OK, MessageBoxIcon.Error); } finally { + progressForm.Close(); SetNestingLockout(false); nestingCts.Dispose(); nestingCts = null; @@ -868,7 +902,7 @@ namespace OpenNest.Forms try { var plate = activeForm.PlateView.Plate; - var engine = new NestEngine(plate); + var engine = NestEngineRegistry.Create(plate); var parts = await Task.Run(() => engine.Fill(new NestItem { Drawing = drawing }, diff --git a/OpenNest/Forms/MainForm.resx b/OpenNest/Forms/MainForm.resx index 24c4237..9178246 100644 --- a/OpenNest/Forms/MainForm.resx +++ b/OpenNest/Forms/MainForm.resx @@ -1,17 +1,17 @@  - diff --git a/OpenNest/Forms/NestProgressForm.Designer.cs b/OpenNest/Forms/NestProgressForm.Designer.cs index 573e08f..9bed575 100644 --- a/OpenNest/Forms/NestProgressForm.Designer.cs +++ b/OpenNest/Forms/NestProgressForm.Designer.cs @@ -28,169 +28,281 @@ namespace OpenNest.Forms /// private void InitializeComponent() { - this.table = new System.Windows.Forms.TableLayoutPanel(); - this.phaseLabel = new System.Windows.Forms.Label(); - this.phaseValue = new System.Windows.Forms.Label(); - this.plateLabel = new System.Windows.Forms.Label(); - this.plateValue = new System.Windows.Forms.Label(); - this.partsLabel = new System.Windows.Forms.Label(); - this.partsValue = new System.Windows.Forms.Label(); - this.densityLabel = new System.Windows.Forms.Label(); - this.densityValue = new System.Windows.Forms.Label(); - this.remnantLabel = new System.Windows.Forms.Label(); - this.remnantValue = new System.Windows.Forms.Label(); - this.stopButton = new System.Windows.Forms.Button(); - this.buttonPanel = new System.Windows.Forms.FlowLayoutPanel(); - this.table.SuspendLayout(); - this.buttonPanel.SuspendLayout(); - this.SuspendLayout(); - // + table = new System.Windows.Forms.TableLayoutPanel(); + phaseLabel = new System.Windows.Forms.Label(); + phaseValue = new System.Windows.Forms.Label(); + plateLabel = new System.Windows.Forms.Label(); + plateValue = new System.Windows.Forms.Label(); + partsLabel = new System.Windows.Forms.Label(); + partsValue = new System.Windows.Forms.Label(); + densityLabel = new System.Windows.Forms.Label(); + densityValue = new System.Windows.Forms.Label(); + nestedAreaLabel = new System.Windows.Forms.Label(); + nestedAreaValue = new System.Windows.Forms.Label(); + remnantLabel = new System.Windows.Forms.Label(); + remnantValue = new System.Windows.Forms.Label(); + elapsedLabel = new System.Windows.Forms.Label(); + elapsedValue = new System.Windows.Forms.Label(); + descriptionLabel = new System.Windows.Forms.Label(); + descriptionValue = new System.Windows.Forms.Label(); + stopButton = new System.Windows.Forms.Button(); + buttonPanel = new System.Windows.Forms.FlowLayoutPanel(); + table.SuspendLayout(); + buttonPanel.SuspendLayout(); + SuspendLayout(); + // // table - // - this.table.ColumnCount = 2; - this.table.ColumnStyles.Add(new System.Windows.Forms.ColumnStyle(System.Windows.Forms.SizeType.Absolute, 80F)); - this.table.ColumnStyles.Add(new System.Windows.Forms.ColumnStyle(System.Windows.Forms.SizeType.AutoSize)); - this.table.Controls.Add(this.phaseLabel, 0, 0); - this.table.Controls.Add(this.phaseValue, 1, 0); - this.table.Controls.Add(this.plateLabel, 0, 1); - this.table.Controls.Add(this.plateValue, 1, 1); - this.table.Controls.Add(this.partsLabel, 0, 2); - this.table.Controls.Add(this.partsValue, 1, 2); - this.table.Controls.Add(this.densityLabel, 0, 3); - this.table.Controls.Add(this.densityValue, 1, 3); - this.table.Controls.Add(this.remnantLabel, 0, 4); - this.table.Controls.Add(this.remnantValue, 1, 4); - this.table.Dock = System.Windows.Forms.DockStyle.Top; - this.table.Location = new System.Drawing.Point(0, 0); - this.table.Name = "table"; - this.table.Padding = new System.Windows.Forms.Padding(8); - this.table.RowCount = 5; - this.table.RowStyles.Add(new System.Windows.Forms.RowStyle(System.Windows.Forms.SizeType.AutoSize)); - this.table.RowStyles.Add(new System.Windows.Forms.RowStyle(System.Windows.Forms.SizeType.AutoSize)); - this.table.RowStyles.Add(new System.Windows.Forms.RowStyle(System.Windows.Forms.SizeType.AutoSize)); - this.table.RowStyles.Add(new System.Windows.Forms.RowStyle(System.Windows.Forms.SizeType.AutoSize)); - this.table.RowStyles.Add(new System.Windows.Forms.RowStyle(System.Windows.Forms.SizeType.AutoSize)); - this.table.AutoSize = true; - this.table.Size = new System.Drawing.Size(264, 130); - this.table.TabIndex = 0; - // + // + table.AutoSize = true; + table.ColumnCount = 2; + table.ColumnStyles.Add(new System.Windows.Forms.ColumnStyle(System.Windows.Forms.SizeType.Absolute, 93F)); + table.ColumnStyles.Add(new System.Windows.Forms.ColumnStyle()); + table.Controls.Add(phaseLabel, 0, 0); + table.Controls.Add(phaseValue, 1, 0); + table.Controls.Add(plateLabel, 0, 1); + table.Controls.Add(plateValue, 1, 1); + table.Controls.Add(partsLabel, 0, 2); + table.Controls.Add(partsValue, 1, 2); + table.Controls.Add(densityLabel, 0, 3); + table.Controls.Add(densityValue, 1, 3); + table.Controls.Add(nestedAreaLabel, 0, 4); + table.Controls.Add(nestedAreaValue, 1, 4); + table.Controls.Add(remnantLabel, 0, 5); + table.Controls.Add(remnantValue, 1, 5); + table.Controls.Add(elapsedLabel, 0, 6); + table.Controls.Add(elapsedValue, 1, 6); + table.Controls.Add(descriptionLabel, 0, 7); + table.Controls.Add(descriptionValue, 1, 7); + table.Dock = System.Windows.Forms.DockStyle.Top; + table.Location = new System.Drawing.Point(0, 45); + table.Margin = new System.Windows.Forms.Padding(4, 3, 4, 3); + table.Name = "table"; + table.Padding = new System.Windows.Forms.Padding(9, 9, 9, 9); + table.RowCount = 8; + table.RowStyles.Add(new System.Windows.Forms.RowStyle()); + table.RowStyles.Add(new System.Windows.Forms.RowStyle()); + table.RowStyles.Add(new System.Windows.Forms.RowStyle()); + table.RowStyles.Add(new System.Windows.Forms.RowStyle()); + table.RowStyles.Add(new System.Windows.Forms.RowStyle()); + table.RowStyles.Add(new System.Windows.Forms.RowStyle()); + table.RowStyles.Add(new System.Windows.Forms.RowStyle()); + table.RowStyles.Add(new System.Windows.Forms.RowStyle()); + table.Size = new System.Drawing.Size(425, 218); + table.TabIndex = 0; + // // phaseLabel - // - this.phaseLabel.AutoSize = true; - this.phaseLabel.Font = new System.Drawing.Font(System.Drawing.SystemFonts.DefaultFont, System.Drawing.FontStyle.Bold); - this.phaseLabel.Margin = new System.Windows.Forms.Padding(4); - this.phaseLabel.Name = "phaseLabel"; - this.phaseLabel.Text = "Phase:"; - // + // + phaseLabel.AutoSize = true; + phaseLabel.Font = new System.Drawing.Font("Microsoft Sans Serif", 8.25F, System.Drawing.FontStyle.Bold); + phaseLabel.Location = new System.Drawing.Point(14, 14); + phaseLabel.Margin = new System.Windows.Forms.Padding(5, 5, 5, 5); + phaseLabel.Name = "phaseLabel"; + phaseLabel.Size = new System.Drawing.Size(46, 13); + phaseLabel.TabIndex = 0; + phaseLabel.Text = "Phase:"; + // // phaseValue - // - this.phaseValue.AutoSize = true; - this.phaseValue.Margin = new System.Windows.Forms.Padding(4); - this.phaseValue.Name = "phaseValue"; - this.phaseValue.Text = "\u2014"; - // + // + phaseValue.AutoSize = true; + phaseValue.Location = new System.Drawing.Point(107, 14); + phaseValue.Margin = new System.Windows.Forms.Padding(5, 5, 5, 5); + phaseValue.Name = "phaseValue"; + phaseValue.Size = new System.Drawing.Size(19, 15); + phaseValue.TabIndex = 1; + phaseValue.Text = "—"; + // // plateLabel - // - this.plateLabel.AutoSize = true; - this.plateLabel.Font = new System.Drawing.Font(System.Drawing.SystemFonts.DefaultFont, System.Drawing.FontStyle.Bold); - this.plateLabel.Margin = new System.Windows.Forms.Padding(4); - this.plateLabel.Name = "plateLabel"; - this.plateLabel.Text = "Plate:"; - // + // + plateLabel.AutoSize = true; + plateLabel.Font = new System.Drawing.Font("Microsoft Sans Serif", 8.25F, System.Drawing.FontStyle.Bold); + plateLabel.Location = new System.Drawing.Point(14, 39); + plateLabel.Margin = new System.Windows.Forms.Padding(5, 5, 5, 5); + plateLabel.Name = "plateLabel"; + plateLabel.Size = new System.Drawing.Size(40, 13); + plateLabel.TabIndex = 2; + plateLabel.Text = "Plate:"; + // // plateValue - // - this.plateValue.AutoSize = true; - this.plateValue.Margin = new System.Windows.Forms.Padding(4); - this.plateValue.Name = "plateValue"; - this.plateValue.Text = "\u2014"; - // + // + plateValue.AutoSize = true; + plateValue.Location = new System.Drawing.Point(107, 39); + plateValue.Margin = new System.Windows.Forms.Padding(5, 5, 5, 5); + plateValue.Name = "plateValue"; + plateValue.Size = new System.Drawing.Size(19, 15); + plateValue.TabIndex = 3; + plateValue.Text = "—"; + // // partsLabel - // - this.partsLabel.AutoSize = true; - this.partsLabel.Font = new System.Drawing.Font(System.Drawing.SystemFonts.DefaultFont, System.Drawing.FontStyle.Bold); - this.partsLabel.Margin = new System.Windows.Forms.Padding(4); - this.partsLabel.Name = "partsLabel"; - this.partsLabel.Text = "Parts:"; - // + // + partsLabel.AutoSize = true; + partsLabel.Font = new System.Drawing.Font("Microsoft Sans Serif", 8.25F, System.Drawing.FontStyle.Bold); + partsLabel.Location = new System.Drawing.Point(14, 64); + partsLabel.Margin = new System.Windows.Forms.Padding(5, 5, 5, 5); + partsLabel.Name = "partsLabel"; + partsLabel.Size = new System.Drawing.Size(40, 13); + partsLabel.TabIndex = 4; + partsLabel.Text = "Parts:"; + // // partsValue - // - this.partsValue.AutoSize = true; - this.partsValue.Margin = new System.Windows.Forms.Padding(4); - this.partsValue.Name = "partsValue"; - this.partsValue.Text = "\u2014"; - // + // + partsValue.AutoSize = true; + partsValue.Location = new System.Drawing.Point(107, 64); + partsValue.Margin = new System.Windows.Forms.Padding(5, 5, 5, 5); + partsValue.Name = "partsValue"; + partsValue.Size = new System.Drawing.Size(19, 15); + partsValue.TabIndex = 5; + partsValue.Text = "—"; + // // densityLabel - // - this.densityLabel.AutoSize = true; - this.densityLabel.Font = new System.Drawing.Font(System.Drawing.SystemFonts.DefaultFont, System.Drawing.FontStyle.Bold); - this.densityLabel.Margin = new System.Windows.Forms.Padding(4); - this.densityLabel.Name = "densityLabel"; - this.densityLabel.Text = "Density:"; - // + // + densityLabel.AutoSize = true; + densityLabel.Font = new System.Drawing.Font("Microsoft Sans Serif", 8.25F, System.Drawing.FontStyle.Bold); + densityLabel.Location = new System.Drawing.Point(14, 89); + densityLabel.Margin = new System.Windows.Forms.Padding(5, 5, 5, 5); + densityLabel.Name = "densityLabel"; + densityLabel.Size = new System.Drawing.Size(53, 13); + densityLabel.TabIndex = 6; + densityLabel.Text = "Density:"; + // // densityValue - // - this.densityValue.AutoSize = true; - this.densityValue.Margin = new System.Windows.Forms.Padding(4); - this.densityValue.Name = "densityValue"; - this.densityValue.Text = "\u2014"; - // + // + densityValue.AutoSize = true; + densityValue.Location = new System.Drawing.Point(107, 89); + densityValue.Margin = new System.Windows.Forms.Padding(5, 5, 5, 5); + densityValue.Name = "densityValue"; + densityValue.Size = new System.Drawing.Size(19, 15); + densityValue.TabIndex = 7; + densityValue.Text = "—"; + // + // nestedAreaLabel + // + nestedAreaLabel.AutoSize = true; + nestedAreaLabel.Font = new System.Drawing.Font("Microsoft Sans Serif", 8.25F, System.Drawing.FontStyle.Bold); + nestedAreaLabel.Location = new System.Drawing.Point(14, 114); + nestedAreaLabel.Margin = new System.Windows.Forms.Padding(5, 5, 5, 5); + nestedAreaLabel.Name = "nestedAreaLabel"; + nestedAreaLabel.Size = new System.Drawing.Size(51, 13); + nestedAreaLabel.TabIndex = 8; + nestedAreaLabel.Text = "Nested:"; + // + // nestedAreaValue + // + nestedAreaValue.AutoSize = true; + nestedAreaValue.Location = new System.Drawing.Point(107, 114); + nestedAreaValue.Margin = new System.Windows.Forms.Padding(5, 5, 5, 5); + nestedAreaValue.Name = "nestedAreaValue"; + nestedAreaValue.Size = new System.Drawing.Size(19, 15); + nestedAreaValue.TabIndex = 9; + nestedAreaValue.Text = "—"; + // // remnantLabel - // - this.remnantLabel.AutoSize = true; - this.remnantLabel.Font = new System.Drawing.Font(System.Drawing.SystemFonts.DefaultFont, System.Drawing.FontStyle.Bold); - this.remnantLabel.Margin = new System.Windows.Forms.Padding(4); - this.remnantLabel.Name = "remnantLabel"; - this.remnantLabel.Text = "Remnant:"; - // + // + remnantLabel.AutoSize = true; + remnantLabel.Font = new System.Drawing.Font("Microsoft Sans Serif", 8.25F, System.Drawing.FontStyle.Bold); + remnantLabel.Location = new System.Drawing.Point(14, 139); + remnantLabel.Margin = new System.Windows.Forms.Padding(5, 5, 5, 5); + remnantLabel.Name = "remnantLabel"; + remnantLabel.Size = new System.Drawing.Size(54, 13); + remnantLabel.TabIndex = 10; + remnantLabel.Text = "Unused:"; + // // remnantValue - // - this.remnantValue.AutoSize = true; - this.remnantValue.Margin = new System.Windows.Forms.Padding(4); - this.remnantValue.Name = "remnantValue"; - this.remnantValue.Text = "\u2014"; - // + // + remnantValue.AutoSize = true; + remnantValue.Location = new System.Drawing.Point(107, 139); + remnantValue.Margin = new System.Windows.Forms.Padding(5, 5, 5, 5); + remnantValue.Name = "remnantValue"; + remnantValue.Size = new System.Drawing.Size(19, 15); + remnantValue.TabIndex = 11; + remnantValue.Text = "—"; + // + // elapsedLabel + // + elapsedLabel.AutoSize = true; + elapsedLabel.Font = new System.Drawing.Font("Microsoft Sans Serif", 8.25F, System.Drawing.FontStyle.Bold); + elapsedLabel.Location = new System.Drawing.Point(14, 164); + elapsedLabel.Margin = new System.Windows.Forms.Padding(5, 5, 5, 5); + elapsedLabel.Name = "elapsedLabel"; + elapsedLabel.Size = new System.Drawing.Size(56, 13); + elapsedLabel.TabIndex = 12; + elapsedLabel.Text = "Elapsed:"; + // + // elapsedValue + // + elapsedValue.AutoSize = true; + elapsedValue.Location = new System.Drawing.Point(107, 164); + elapsedValue.Margin = new System.Windows.Forms.Padding(5, 5, 5, 5); + elapsedValue.Name = "elapsedValue"; + elapsedValue.Size = new System.Drawing.Size(28, 15); + elapsedValue.TabIndex = 13; + elapsedValue.Text = "0:00"; + // + // descriptionLabel + // + descriptionLabel.AutoSize = true; + descriptionLabel.Font = new System.Drawing.Font("Microsoft Sans Serif", 8.25F, System.Drawing.FontStyle.Bold); + descriptionLabel.Location = new System.Drawing.Point(14, 189); + descriptionLabel.Margin = new System.Windows.Forms.Padding(5, 5, 5, 5); + descriptionLabel.Name = "descriptionLabel"; + descriptionLabel.Size = new System.Drawing.Size(44, 13); + descriptionLabel.TabIndex = 14; + descriptionLabel.Text = "Detail:"; + // + // descriptionValue + // + descriptionValue.AutoSize = true; + descriptionValue.Location = new System.Drawing.Point(107, 189); + descriptionValue.Margin = new System.Windows.Forms.Padding(5, 5, 5, 5); + descriptionValue.Name = "descriptionValue"; + descriptionValue.Size = new System.Drawing.Size(19, 15); + descriptionValue.TabIndex = 15; + descriptionValue.Text = "—"; + // // stopButton - // - this.stopButton.Anchor = System.Windows.Forms.AnchorStyles.None; - this.stopButton.Margin = new System.Windows.Forms.Padding(0, 8, 0, 8); - this.stopButton.Name = "stopButton"; - this.stopButton.Size = new System.Drawing.Size(80, 23); - this.stopButton.TabIndex = 0; - this.stopButton.Text = "Stop"; - this.stopButton.UseVisualStyleBackColor = true; - this.stopButton.Click += new System.EventHandler(this.StopButton_Click); - // + // + stopButton.Anchor = System.Windows.Forms.AnchorStyles.None; + stopButton.Location = new System.Drawing.Point(314, 9); + stopButton.Margin = new System.Windows.Forms.Padding(0, 9, 0, 9); + stopButton.Name = "stopButton"; + stopButton.Size = new System.Drawing.Size(93, 27); + stopButton.TabIndex = 0; + stopButton.Text = "Stop"; + stopButton.UseVisualStyleBackColor = true; + stopButton.Click += StopButton_Click; + // // buttonPanel - // - this.buttonPanel.AutoSize = true; - this.buttonPanel.Controls.Add(this.stopButton); - this.buttonPanel.Dock = System.Windows.Forms.DockStyle.Top; - this.buttonPanel.FlowDirection = System.Windows.Forms.FlowDirection.RightToLeft; - this.buttonPanel.Name = "buttonPanel"; - this.buttonPanel.Padding = new System.Windows.Forms.Padding(8, 0, 8, 0); - this.buttonPanel.TabIndex = 1; - // + // + buttonPanel.AutoSize = true; + buttonPanel.Controls.Add(stopButton); + buttonPanel.Dock = System.Windows.Forms.DockStyle.Top; + buttonPanel.FlowDirection = System.Windows.Forms.FlowDirection.RightToLeft; + buttonPanel.Location = new System.Drawing.Point(0, 0); + buttonPanel.Margin = new System.Windows.Forms.Padding(4, 3, 4, 3); + buttonPanel.Name = "buttonPanel"; + buttonPanel.Padding = new System.Windows.Forms.Padding(9, 0, 9, 0); + buttonPanel.Size = new System.Drawing.Size(425, 45); + buttonPanel.TabIndex = 1; + // // NestProgressForm - // - this.AutoScaleDimensions = new System.Drawing.SizeF(6F, 13F); - this.AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font; - this.ClientSize = new System.Drawing.Size(264, 181); - this.Controls.Add(this.buttonPanel); - this.Controls.Add(this.table); - this.Controls.SetChildIndex(this.table, 0); - this.Controls.SetChildIndex(this.buttonPanel, 1); - this.FormBorderStyle = System.Windows.Forms.FormBorderStyle.FixedToolWindow; - this.MaximizeBox = false; - this.MinimizeBox = false; - this.Name = "NestProgressForm"; - this.ShowInTaskbar = false; - this.StartPosition = System.Windows.Forms.FormStartPosition.CenterParent; - this.Text = "Nesting Progress"; - this.table.ResumeLayout(false); - this.table.PerformLayout(); - this.buttonPanel.ResumeLayout(false); - this.ResumeLayout(false); - this.PerformLayout(); + // + AutoScaleDimensions = new System.Drawing.SizeF(7F, 15F); + AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font; + ClientSize = new System.Drawing.Size(425, 266); + Controls.Add(table); + Controls.Add(buttonPanel); + FormBorderStyle = System.Windows.Forms.FormBorderStyle.FixedToolWindow; + Margin = new System.Windows.Forms.Padding(4, 3, 4, 3); + MaximizeBox = false; + MinimizeBox = false; + Name = "NestProgressForm"; + ShowInTaskbar = false; + StartPosition = System.Windows.Forms.FormStartPosition.CenterParent; + Text = "Nesting Progress"; + table.ResumeLayout(false); + table.PerformLayout(); + buttonPanel.ResumeLayout(false); + ResumeLayout(false); + PerformLayout(); } #endregion @@ -204,8 +316,14 @@ namespace OpenNest.Forms private System.Windows.Forms.Label partsValue; private System.Windows.Forms.Label densityLabel; private System.Windows.Forms.Label densityValue; + private System.Windows.Forms.Label nestedAreaLabel; + private System.Windows.Forms.Label nestedAreaValue; private System.Windows.Forms.Label remnantLabel; private System.Windows.Forms.Label remnantValue; + private System.Windows.Forms.Label elapsedLabel; + private System.Windows.Forms.Label elapsedValue; + private System.Windows.Forms.Label descriptionLabel; + private System.Windows.Forms.Label descriptionValue; private System.Windows.Forms.Button stopButton; private System.Windows.Forms.FlowLayoutPanel buttonPanel; } diff --git a/OpenNest/Forms/NestProgressForm.cs b/OpenNest/Forms/NestProgressForm.cs index 6e51a24..023e8fc 100644 --- a/OpenNest/Forms/NestProgressForm.cs +++ b/OpenNest/Forms/NestProgressForm.cs @@ -1,4 +1,5 @@ using System; +using System.Diagnostics; using System.Threading; using System.Windows.Forms; @@ -7,6 +8,8 @@ namespace OpenNest.Forms public partial class NestProgressForm : Form { private readonly CancellationTokenSource cts; + private readonly Stopwatch stopwatch = Stopwatch.StartNew(); + private readonly System.Windows.Forms.Timer elapsedTimer; public NestProgressForm(CancellationTokenSource cts, bool showPlateRow = true) { @@ -18,6 +21,10 @@ namespace OpenNest.Forms plateLabel.Visible = false; plateValue.Visible = false; } + + elapsedTimer = new System.Windows.Forms.Timer { Interval = 1000 }; + elapsedTimer.Tick += (s, e) => UpdateElapsed(); + elapsedTimer.Start(); } public void UpdateProgress(NestProgress progress) @@ -29,7 +36,11 @@ namespace OpenNest.Forms plateValue.Text = progress.PlateNumber.ToString(); partsValue.Text = progress.BestPartCount.ToString(); densityValue.Text = progress.BestDensity.ToString("P1"); + nestedAreaValue.Text = $"{progress.NestedWidth:F1} x {progress.NestedLength:F1} ({progress.NestedArea:F1} sq in)"; remnantValue.Text = $"{progress.UsableRemnantArea:F1} sq in"; + + if (!string.IsNullOrEmpty(progress.Description)) + descriptionValue.Text = progress.Description; } public void ShowCompleted() @@ -37,13 +48,29 @@ namespace OpenNest.Forms if (IsDisposed || !IsHandleCreated) return; + stopwatch.Stop(); + elapsedTimer.Stop(); + UpdateElapsed(); + phaseValue.Text = "Done"; + descriptionValue.Text = "\u2014"; stopButton.Text = "Close"; stopButton.Enabled = true; stopButton.Click -= StopButton_Click; stopButton.Click += (s, e) => Close(); } + private void UpdateElapsed() + { + if (IsDisposed || !IsHandleCreated) + return; + + var elapsed = stopwatch.Elapsed; + elapsedValue.Text = elapsed.TotalHours >= 1 + ? elapsed.ToString(@"h\:mm\:ss") + : elapsed.ToString(@"m\:ss"); + } + private void StopButton_Click(object sender, EventArgs e) { cts.Cancel(); @@ -53,6 +80,10 @@ namespace OpenNest.Forms protected override void OnFormClosing(FormClosingEventArgs e) { + elapsedTimer.Stop(); + elapsedTimer.Dispose(); + stopwatch.Stop(); + if (!cts.IsCancellationRequested) cts.Cancel(); diff --git a/OpenNest/Forms/NestProgressForm.resx b/OpenNest/Forms/NestProgressForm.resx index 1af7de1..8b2ff64 100644 --- a/OpenNest/Forms/NestProgressForm.resx +++ b/OpenNest/Forms/NestProgressForm.resx @@ -1,17 +1,17 @@  - diff --git a/OpenNest/LayoutPart.cs b/OpenNest/LayoutPart.cs index 94685d6..b66a64f 100644 --- a/OpenNest/LayoutPart.cs +++ b/OpenNest/LayoutPart.cs @@ -134,7 +134,7 @@ namespace OpenNest { var result = new List(); var entities = ConvertProgram.ToGeometry(BasePart.Program); - var shapes = Helper.GetShapes(entities.Where(e => e.Layer != SpecialLayers.Rapid)); + var shapes = ShapeBuilder.GetShapes(entities.Where(e => e.Layer != SpecialLayers.Rapid)); foreach (var shape in shapes) { diff --git a/collect-training-data.ps1 b/collect-training-data.ps1 new file mode 100644 index 0000000..ef4b9a4 --- /dev/null +++ b/collect-training-data.ps1 @@ -0,0 +1,10 @@ +param( + [Parameter(Mandatory, Position = 0)] + [string]$DxfDir +) + +$DbPath = Join-Path $PSScriptRoot 'test-training.db' +$SaveDir = 'X:\' +$Template = 'X:\Template.nstdot' + +dotnet run --project (Join-Path $PSScriptRoot 'OpenNest.Console') -- --collect $DxfDir --db $DbPath --save-nests $SaveDir --template $Template diff --git a/docs/plans/2026-03-10-gpu-overlap-debug.md b/docs/plans/2026-03-10-gpu-overlap-debug.md new file mode 100644 index 0000000..268bffd --- /dev/null +++ b/docs/plans/2026-03-10-gpu-overlap-debug.md @@ -0,0 +1,110 @@ +# GPU Pair Evaluator — Overlap Detection Bug + +**Date**: 2026-03-10 +**Status**: RESOLVED — commit b55aa7a + +## Problem + +The `GpuPairEvaluator` reports "Overlap detected" for ALL best-fit candidates, even though the parts are clearly not overlapping. The CPU `PairEvaluator` works correctly (screenshot comparison: GPU = all red/overlap, CPU = blue with valid results like 93.9% utilization). + +## Root Cause (identified but not yet fully fixed) + +The bitmap coordinate system doesn't match the `Part2Offset` coordinate system. + +### How Part2Offset is computed +`RotationSlideStrategy` creates parts using `Part.CreateAtOrigin(drawing, rotation)` which: +1. Clones the drawing's program +2. Rotates it +3. Calls `Program.BoundingBox()` to get the bbox +4. Offsets by `-bbox.Location` to normalize to origin + +`Part2Offset` is the final position of Part2 in this **normalized** coordinate space. + +### How bitmaps are rasterized +`PartBitmap.FromDrawing` / `FromDrawingRotated`: +1. Extracts closed polygons from the drawing (filters out rapids, open shapes) +2. Rotates them (for B) +3. Rasterizes with `OriginX/Y = polygon min` + +### The mismatch +`Program.BoundingBox()` initializes `minX=0, minY=0, maxX=0, maxY=0` (line 289-292 in Program.cs), so (0,0) is **always** included in the bbox. This means: +- For geometry at (5,3)-(10,8): bbox.Location = (0,0), CreateAtOrigin shifts by (0,0) = no change +- But polygon min = (5,3), so bitmap OriginX=5, OriginY=3 +- Part2Offset is in the (0,0)-based normalized space, bitmap is in the (5,3)-based polygon space + +For rotated geometry, the discrepancy is even worse because rotation changes the polygon min dramatically while the bbox may or may not include (0,0). + +## What we tried + +### Attempt 1: BlitPair approach (correct but too slow) +- Added `PartBitmap.BlitPair()` that places both bitmaps into a shared world-space grid +- Eliminated all offset math from the kernel (trivial element-wise AND) +- **Problem**: Per-candidate grid allocation. 21K candidates × large grids = massive memory + GPU transfer. Took minutes instead of seconds. + +### Attempt 2: Integer offsets with gap correction +- Kept shared-bitmap approach (one A + one B per rotation group) +- Changed offsets from `float` to `int` with `Math.Round()` on CPU +- Added gap correction: `offset = (Part2Offset - gapA + gapB) / cellSize` where `gapA = bitmapOriginA - bboxA.Location`, `gapB = bitmapOriginB - bboxB.Location` +- **Problem**: Still false positives. The formula is mathematically correct in derivation but something is wrong in practice. + +### Attempt 3: Normalize bitmaps to match CreateAtOrigin (current state) +- Added `PartBitmap.FromDrawingAtOrigin()` and `FromDrawingAtOriginRotated()` +- These shift polygons by `-bbox.Location` before rasterizing, exactly like `CreateAtOrigin` +- Offset formula: `(Part2Offset.X - bitmapA.OriginX + bitmapB.OriginX) / cellSize` +- **Problem**: STILL showing false overlaps for all candidates (see gpu.png). 33.8s compute, 3942 kept but all marked overlap. + +## Current state of code + +### Files modified + +**`OpenNest.Gpu/PartBitmap.cs`**: +- Added `BlitPair()` static method (from attempt 1, still present but unused) +- Added `FromDrawingAtOrigin()` — normalizes polygons by `-bbox.Location` before rasterize +- Added `FromDrawingAtOriginRotated()` — rotates polygons, clones+rotates program for bbox, normalizes, rasterizes + +**`OpenNest.Gpu/GpuPairEvaluator.cs`**: +- Uses `FromDrawingAtOrigin` / `FromDrawingAtOriginRotated` instead of raw `FromDrawing` / `FromDrawingRotated` +- Offsets are `int[]` (not `float[]`) computed with `Math.Round()` on CPU +- Kernel is `OverlapKernel` — uses integer offsets, early-exit on `cellA != 1` +- `PadBitmap` helper restored +- Removed the old `NestingKernel` with float offsets + +**`OpenNest/Forms/MainForm.cs`**: +- Added `using OpenNest.Engine.BestFit;` +- Wired up GPU evaluator: `BestFitCache.CreateEvaluator = (drawing, spacing) => GpuEvaluatorFactory.Create(drawing, spacing);` + +## Next steps to debug + +1. **Add diagnostic logging** to compare GPU vs CPU for a single candidate: + - Print bitmapA: OriginX, OriginY, Width, Height + - Print bitmapB: OriginX, OriginY, Width, Height + - Print the computed integer offset + - Print the overlap count from the kernel + - Compare with CPU `PairEvaluator.CheckOverlap()` result for the same candidate + +2. **Verify Program.Clone() + Rotate() produces same geometry as Polygon.Rotate()**: + - `FromDrawingAtOriginRotated` rotates polygons with `poly.Rotate(rotation)` then normalizes using `prog.Clone().Rotate(rotation).BoundingBox()` + - If `Program.Rotate` and `Polygon.Rotate` use different rotation centers or conventions, the normalization would be wrong + - Check: does `Program.Rotate` rotate around (0,0)? Does `Polygon.Rotate` rotate around (0,0)? + +3. **Try rasterizing from the Part directly**: Instead of extracting polygons from the raw drawing and manually rotating/normalizing, create `Part.CreateAtOrigin(drawing, rotation)` and extract polygons from the Part's already-normalized program. This guarantees exact coordinate system match. + +4. **Consider that the kernel grid might be too small**: `gridWidth = max(A.Width, B.Width)` only works if offset is small. If Part2Offset places B far from A, the B cells at `bx = x - offset` could all be out of bounds (negative), leading the kernel to find zero overlaps (false negative). But we're seeing false POSITIVES, so this isn't the issue unless the offset sign is wrong. + +5. **Check offset sign**: Verify that when offset is positive, `bx = x - offset` correctly maps A cells to B cells. A positive offset should mean B is shifted right relative to A. + +## Performance notes +- CPU evaluator: 25.0s compute, 5954 kept, correct results +- GPU evaluator (current): 33.8s compute, 3942 kept, all false overlaps +- GPU is actually SLOWER because `FromDrawingAtOriginRotated` clones+rotates the full program per rotation group +- Once overlap detection is fixed, performance optimization should focus on avoiding the Program.Clone().Rotate() per rotation group + +## Key files to reference +- `OpenNest.Gpu/GpuPairEvaluator.cs` — the GPU evaluator +- `OpenNest.Gpu/PartBitmap.cs` — bitmap rasterization +- `OpenNest.Engine/BestFit/PairEvaluator.cs` — CPU evaluator (working reference) +- `OpenNest.Engine/BestFit/RotationSlideStrategy.cs` — generates Part2Offset values +- `OpenNest.Core/Part.cs:109` — `Part.CreateAtOrigin()` +- `OpenNest.Core/CNC/Program.cs:281-342` — `Program.BoundingBox()` (note min init at 0,0) +- `OpenNest.Engine/BestFit/BestFitCache.cs` — where evaluator is plugged in +- `OpenNest/Forms/MainForm.cs` — where GPU evaluator is wired up diff --git a/docs/superpowers/plans/2026-03-11-test-harness.md b/docs/superpowers/plans/2026-03-11-test-harness.md new file mode 100644 index 0000000..6830462 --- /dev/null +++ b/docs/superpowers/plans/2026-03-11-test-harness.md @@ -0,0 +1,367 @@ +# OpenNest Test Harness Implementation Plan + +> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Create a console app + MCP tool that builds and runs OpenNest.Engine against a nest file, writing debug output to a file for grepping and saving the resulting nest. + +**Architecture:** A new `OpenNest.TestHarness` console app references Core, Engine, and IO. It loads a nest file, clears a plate, runs `NestEngine.Fill()`, writes `Debug.WriteLine` output to a timestamped log file via `TextWriterTraceListener`, prints a summary to stdout, and saves the nest. An MCP tool `test_engine` in OpenNest.Mcp shells out to `dotnet run --project OpenNest.TestHarness` and returns the summary + log file path. + +**Tech Stack:** .NET 8, System.Diagnostics tracing, OpenNest.Core/Engine/IO + +--- + +## File Structure + +| Action | File | Responsibility | +|--------|------|----------------| +| Create | `OpenNest.TestHarness/OpenNest.TestHarness.csproj` | Console app project, references Core + Engine + IO. Forces `DEBUG` constant. | +| Create | `OpenNest.TestHarness/Program.cs` | Entry point: parse args, load nest, run fill, write debug to file, save nest | +| Modify | `OpenNest.sln` | Add new project to solution | +| Create | `OpenNest.Mcp/Tools/TestTools.cs` | MCP `test_engine` tool that shells out to the harness | + +--- + +## Chunk 1: Console App + MCP Tool + +### Task 1: Create the OpenNest.TestHarness project + +**Files:** +- Create: `OpenNest.TestHarness/OpenNest.TestHarness.csproj` + +- [ ] **Step 1: Create the project file** + +Note: `DEBUG` is defined for all configurations so `Debug.WriteLine` output is always captured — that's the whole point of this tool. + +```xml + + + Exe + net8.0-windows + OpenNest.TestHarness + OpenNest.TestHarness + $(DefineConstants);DEBUG;TRACE + + + + + + + +``` + +- [ ] **Step 2: Add project to solution** + +```bash +dotnet sln OpenNest.sln add OpenNest.TestHarness/OpenNest.TestHarness.csproj +``` + +- [ ] **Step 3: Verify it builds** + +```bash +dotnet build OpenNest.TestHarness/OpenNest.TestHarness.csproj +``` + +Expected: Build succeeded (with warning about empty Program.cs — that's fine, we create it next). + +--- + +### Task 2: Write the TestHarness Program.cs + +**Files:** +- Create: `OpenNest.TestHarness/Program.cs` + +The console app does: +1. Parse command-line args for nest file path, optional drawing name, plate index, output path +2. Create a timestamped log file and attach a `TextWriterTraceListener` so `Debug.WriteLine` goes to the file +3. Load the nest file via `NestReader` +4. Find the drawing and plate +5. Clear existing parts from the plate +6. Run `NestEngine.Fill()` +7. Print summary (part count, utilization, log file path) to stdout +8. Save the nest via `NestWriter` + +- [ ] **Step 1: Write Program.cs** + +```csharp +using System; +using System.Diagnostics; +using System.IO; +using System.Linq; +using OpenNest; +using OpenNest.IO; + +// Parse arguments. +var nestFile = args.Length > 0 ? args[0] : null; +var drawingName = (string)null; +var plateIndex = 0; +var outputFile = (string)null; + +for (var i = 1; i < args.Length; i++) +{ + switch (args[i]) + { + case "--drawing" when i + 1 < args.Length: + drawingName = args[++i]; + break; + case "--plate" when i + 1 < args.Length: + plateIndex = int.Parse(args[++i]); + break; + case "--output" when i + 1 < args.Length: + outputFile = args[++i]; + break; + } +} + +if (string.IsNullOrEmpty(nestFile) || !File.Exists(nestFile)) +{ + Console.Error.WriteLine("Usage: OpenNest.TestHarness [--drawing ] [--plate ] [--output ]"); + Console.Error.WriteLine(" nest-file Path to a .zip nest file"); + Console.Error.WriteLine(" --drawing Drawing name to fill with (default: first drawing)"); + Console.Error.WriteLine(" --plate Plate index to fill (default: 0)"); + Console.Error.WriteLine(" --output Output nest file path (default: -result.zip)"); + return 1; +} + +// Set up debug log file. +var logDir = Path.Combine(Path.GetDirectoryName(nestFile), "test-harness-logs"); +Directory.CreateDirectory(logDir); +var logFile = Path.Combine(logDir, $"debug-{DateTime.Now:yyyyMMdd-HHmmss}.log"); +var logWriter = new StreamWriter(logFile) { AutoFlush = true }; +Trace.Listeners.Add(new TextWriterTraceListener(logWriter)); + +// 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]; + +// 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" + : "Error: nest file contains no drawings"); + return 1; +} + +// Clear existing parts. +var existingCount = plate.Parts.Count; +plate.Parts.Clear(); + +Console.WriteLine($"Nest: {nest.Name}"); +Console.WriteLine($"Plate: {plateIndex} ({plate.Size.Width:F1} x {plate.Size.Height:F1}), spacing={plate.PartSpacing:F2}"); +Console.WriteLine($"Drawing: {drawing.Name}"); +Console.WriteLine($"Cleared {existingCount} existing parts"); +Console.WriteLine("---"); + +// Run fill. +var sw = Stopwatch.StartNew(); +var engine = new NestEngine(plate); +var item = new NestItem { Drawing = drawing, Quantity = 0 }; +var success = engine.Fill(item); +sw.Stop(); + +// 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"); +Console.WriteLine($"Debug log: {logFile}"); + +// Save output. +if (outputFile == null) +{ + var dir = Path.GetDirectoryName(nestFile); + var name = Path.GetFileNameWithoutExtension(nestFile); + outputFile = Path.Combine(dir, $"{name}-result.zip"); +} + +var writer = new NestWriter(nest); +writer.Write(outputFile); +Console.WriteLine($"Saved: {outputFile}"); + +return 0; +``` + +- [ ] **Step 2: Build the project** + +```bash +dotnet build OpenNest.TestHarness/OpenNest.TestHarness.csproj +``` + +Expected: Build succeeded with 0 errors. + +- [ ] **Step 3: Run a smoke test with the real nest file** + +```bash +dotnet run --project OpenNest.TestHarness -- "C:\Users\AJ\Desktop\4980 A24 PT02 60x120 45pcs v2.zip" +``` + +Expected: Prints nest info and results to stdout, writes debug log file, saves a `-result.zip` file. + +- [ ] **Step 4: Commit** + +```bash +git add OpenNest.TestHarness/ OpenNest.sln +git commit -m "feat: add OpenNest.TestHarness console app for engine testing" +``` + +--- + +### Task 3: Add the MCP test_engine tool + +**Files:** +- Create: `OpenNest.Mcp/Tools/TestTools.cs` + +The MCP tool: +1. Accepts optional `nestFile`, `drawingName`, `plateIndex` parameters +2. Runs `dotnet run --project -- ` capturing stdout (results) and stderr (errors only) +3. Returns the summary + debug log file path (Claude can then Grep the log file) + +Note: The solution root is hard-coded because the MCP server is published to `~/.claude/mcp/OpenNest.Mcp/`, far from the source tree. + +- [ ] **Step 1: Create TestTools.cs** + +```csharp +using System.ComponentModel; +using System.Diagnostics; +using System.IO; +using System.Text; +using ModelContextProtocol.Server; + +namespace OpenNest.Mcp.Tools +{ + [McpServerToolType] + public class TestTools + { + private const string SolutionRoot = @"C:\Users\AJ\Desktop\Projects\OpenNest"; + + private static readonly string HarnessProject = Path.Combine( + SolutionRoot, "OpenNest.TestHarness", "OpenNest.TestHarness.csproj"); + + [McpServerTool(Name = "test_engine")] + [Description("Build and run the nesting engine against a nest file. Returns fill results and a debug log file path for grepping. Use this to test engine changes without restarting the MCP server.")] + public string TestEngine( + [Description("Path to the nest .zip file")] string nestFile = @"C:\Users\AJ\Desktop\4980 A24 PT02 60x120 45pcs v2.zip", + [Description("Drawing name to fill with (default: first drawing)")] string drawingName = null, + [Description("Plate index to fill (default: 0)")] int plateIndex = 0, + [Description("Output nest file path (default: -result.zip)")] string outputFile = null) + { + if (!File.Exists(nestFile)) + return $"Error: nest file not found: {nestFile}"; + + var processArgs = new StringBuilder(); + processArgs.Append($"\"{nestFile}\""); + + if (!string.IsNullOrEmpty(drawingName)) + processArgs.Append($" --drawing \"{drawingName}\""); + + processArgs.Append($" --plate {plateIndex}"); + + if (!string.IsNullOrEmpty(outputFile)) + processArgs.Append($" --output \"{outputFile}\""); + + var psi = new ProcessStartInfo + { + FileName = "dotnet", + Arguments = $"run --project \"{HarnessProject}\" -- {processArgs}", + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true, + WorkingDirectory = SolutionRoot + }; + + var sb = new StringBuilder(); + + try + { + using var process = Process.Start(psi); + var stderrTask = process.StandardError.ReadToEndAsync(); + var stdout = process.StandardOutput.ReadToEnd(); + process.WaitForExit(120_000); + var stderr = stderrTask.Result; + + if (!string.IsNullOrWhiteSpace(stdout)) + sb.Append(stdout.TrimEnd()); + + if (!string.IsNullOrWhiteSpace(stderr)) + { + sb.AppendLine(); + sb.AppendLine(); + sb.AppendLine("=== Errors ==="); + sb.Append(stderr.TrimEnd()); + } + + if (process.ExitCode != 0) + { + sb.AppendLine(); + sb.AppendLine($"Process exited with code {process.ExitCode}"); + } + } + catch (System.Exception ex) + { + sb.AppendLine($"Error running test harness: {ex.Message}"); + } + + return sb.ToString(); + } + } +} +``` + +- [ ] **Step 2: Build the MCP project** + +```bash +dotnet build OpenNest.Mcp/OpenNest.Mcp.csproj +``` + +Expected: Build succeeded. + +- [ ] **Step 3: Republish the MCP server** + +```bash +dotnet publish OpenNest.Mcp/OpenNest.Mcp.csproj -c Release -o "$USERPROFILE/.claude/mcp/OpenNest.Mcp" +``` + +Expected: Publish succeeded. The MCP server now has the `test_engine` tool. + +- [ ] **Step 4: Commit** + +```bash +git add OpenNest.Mcp/Tools/TestTools.cs +git commit -m "feat: add test_engine MCP tool for iterative engine testing" +``` + +--- + +## Usage + +After implementation, the workflow for iterating on FillLinear becomes: + +1. **Other session** makes changes to `FillLinear.cs` or `NestEngine.cs` +2. **This session** calls `test_engine` (no args needed — defaults to the test nest file) +3. The tool builds the latest code and runs it in a fresh process +4. Returns: part count, utilization, timing, and **debug log file path** +5. Grep the log file for specific patterns (e.g., `[FillLinear]`, `[FindBestFill]`) +6. Repeat diff --git a/docs/superpowers/plans/2026-03-12-contour-reindexing.md b/docs/superpowers/plans/2026-03-12-contour-reindexing.md new file mode 100644 index 0000000..660d707 --- /dev/null +++ b/docs/superpowers/plans/2026-03-12-contour-reindexing.md @@ -0,0 +1,281 @@ +# Contour Re-Indexing Implementation Plan + +> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add entity-splitting primitives (`Line.SplitAt`, `Arc.SplitAt`), a `Shape.ReindexAt` method, and wire them into `ContourCuttingStrategy.Apply()` to replace the `NotImplementedException` stubs. + +**Architecture:** Bottom-up — build splitting primitives first, then the reindexing algorithm on top, then wire into the strategy. Each layer depends only on the one below it. + +**Tech Stack:** C# / .NET 8, OpenNest.Core (Geometry + CNC namespaces) + +**Spec:** `docs/superpowers/specs/2026-03-12-contour-reindexing-design.md` + +--- + +## File Structure + +| File | Change | Responsibility | +|------|--------|----------------| +| `OpenNest.Core/Geometry/Line.cs` | Add method | `SplitAt(Vector)` — split a line at a point into two halves | +| `OpenNest.Core/Geometry/Arc.cs` | Add method | `SplitAt(Vector)` — split an arc at a point into two halves | +| `OpenNest.Core/Geometry/Shape.cs` | Add method | `ReindexAt(Vector, Entity)` — reorder a closed contour to start at a given point | +| `OpenNest.Core/CNC/CuttingStrategy/ContourCuttingStrategy.cs` | Add method + modify | `ConvertShapeToMoves` + replace two `NotImplementedException` blocks | + +--- + +## Chunk 1: Splitting Primitives + +### Task 1: Add `Line.SplitAt(Vector)` + +**Files:** +- Modify: `OpenNest.Core/Geometry/Line.cs` + +- [ ] **Step 1: Add `SplitAt` method to `Line`** + +Add the following method to the `Line` class (after the existing `ClosestPointTo` method): + +```csharp +public (Line first, Line second) SplitAt(Vector point) +{ + var first = point.DistanceTo(StartPoint) < Tolerance.Epsilon + ? null + : new Line(StartPoint, point); + + var second = point.DistanceTo(EndPoint) < Tolerance.Epsilon + ? null + : new Line(point, EndPoint); + + return (first, second); +} +``` + +- [ ] **Step 2: Build to verify** + +Run: `dotnet build OpenNest.Core/OpenNest.Core.csproj` +Expected: Build succeeded, 0 errors + +- [ ] **Step 3: Commit** + +```bash +git add OpenNest.Core/Geometry/Line.cs +git commit -m "feat: add Line.SplitAt(Vector) splitting primitive" +``` + +### Task 2: Add `Arc.SplitAt(Vector)` + +**Files:** +- Modify: `OpenNest.Core/Geometry/Arc.cs` + +- [ ] **Step 1: Add `SplitAt` method to `Arc`** + +Add the following method to the `Arc` class (after the existing `EndPoint` method): + +```csharp +public (Arc first, Arc second) SplitAt(Vector point) +{ + if (point.DistanceTo(StartPoint()) < Tolerance.Epsilon) + return (null, new Arc(Center, Radius, StartAngle, EndAngle, IsReversed)); + + if (point.DistanceTo(EndPoint()) < Tolerance.Epsilon) + return (new Arc(Center, Radius, StartAngle, EndAngle, IsReversed), null); + + var splitAngle = Angle.NormalizeRad(Center.AngleTo(point)); + + var firstArc = new Arc(Center, Radius, StartAngle, splitAngle, IsReversed); + var secondArc = new Arc(Center, Radius, splitAngle, EndAngle, IsReversed); + + return (firstArc, secondArc); +} +``` + +Key details from spec: +- Compare distances to `StartPoint()`/`EndPoint()` rather than comparing angles (avoids 0/2π wrap-around issues). +- `splitAngle` is computed from `Center.AngleTo(point)`, normalized. +- Both halves preserve center, radius, and `IsReversed` direction. + +- [ ] **Step 2: Build to verify** + +Run: `dotnet build OpenNest.Core/OpenNest.Core.csproj` +Expected: Build succeeded, 0 errors + +- [ ] **Step 3: Commit** + +```bash +git add OpenNest.Core/Geometry/Arc.cs +git commit -m "feat: add Arc.SplitAt(Vector) splitting primitive" +``` + +--- + +## Chunk 2: Shape.ReindexAt + +### Task 3: Add `Shape.ReindexAt(Vector, Entity)` + +**Files:** +- Modify: `OpenNest.Core/Geometry/Shape.cs` + +- [ ] **Step 1: Add `ReindexAt` method to `Shape`** + +Add the following method to the `Shape` class (after the existing `ClosestPointTo(Vector, out Entity)` method around line 201): + +```csharp +public Shape ReindexAt(Vector point, Entity entity) +{ + // Circle case: return a new shape with just the circle + if (entity is Circle) + { + var result = new Shape(); + result.Entities.Add(entity); + return result; + } + + var i = Entities.IndexOf(entity); + if (i < 0) + throw new ArgumentException("Entity not found in shape", nameof(entity)); + + // Split the entity at the point + Entity firstHalf = null; + Entity secondHalf = null; + + if (entity is Line line) + { + var (f, s) = line.SplitAt(point); + firstHalf = f; + secondHalf = s; + } + else if (entity is Arc arc) + { + var (f, s) = arc.SplitAt(point); + firstHalf = f; + secondHalf = s; + } + + // Build reindexed entity list + var entities = new List(); + + // secondHalf of split entity (if not null) + if (secondHalf != null) + entities.Add(secondHalf); + + // Entities after the split index (wrapping) + for (var j = i + 1; j < Entities.Count; j++) + entities.Add(Entities[j]); + + // Entities before the split index (wrapping) + for (var j = 0; j < i; j++) + entities.Add(Entities[j]); + + // firstHalf of split entity (if not null) + if (firstHalf != null) + entities.Add(firstHalf); + + var reindexed = new Shape(); + reindexed.Entities.AddRange(entities); + return reindexed; +} +``` + +The `Shape` class already imports `System` and `System.Collections.Generic`, so no new usings needed. + +- [ ] **Step 2: Build to verify** + +Run: `dotnet build OpenNest.Core/OpenNest.Core.csproj` +Expected: Build succeeded, 0 errors + +- [ ] **Step 3: Commit** + +```bash +git add OpenNest.Core/Geometry/Shape.cs +git commit -m "feat: add Shape.ReindexAt(Vector, Entity) for contour reordering" +``` + +--- + +## Chunk 3: Wire into ContourCuttingStrategy + +### Task 4: Add `ConvertShapeToMoves` and replace stubs + +**Files:** +- Modify: `OpenNest.Core/CNC/CuttingStrategy/ContourCuttingStrategy.cs` + +- [ ] **Step 1: Add `ConvertShapeToMoves` private method** + +Add the following private method to `ContourCuttingStrategy` (after the existing `SelectLeadOut` method, before the closing brace of the class): + +```csharp +private List ConvertShapeToMoves(Shape shape, Vector startPoint) +{ + var moves = new List(); + + foreach (var entity in shape.Entities) + { + if (entity is Line line) + { + moves.Add(new LinearMove(line.EndPoint)); + } + else if (entity is Arc arc) + { + moves.Add(new ArcMove(arc.EndPoint(), arc.Center, arc.IsReversed ? RotationType.CW : RotationType.CCW)); + } + else if (entity is Circle circle) + { + moves.Add(new ArcMove(startPoint, circle.Center, circle.Rotation)); + } + else + { + throw new System.InvalidOperationException($"Unsupported entity type: {entity.Type}"); + } + } + + return moves; +} +``` + +This matches the `ConvertGeometry.AddArc`/`AddCircle`/`AddLine` patterns but without `RapidMove` between entities (they are contiguous in a reindexed shape). + +- [ ] **Step 2: Replace cutout `NotImplementedException` (line 41)** + +In the `Apply` method, replace: +```csharp + // Contour re-indexing: split shape entities at closestPt so cutting + // starts there, convert to ICode, and add to result.Codes + throw new System.NotImplementedException("Contour re-indexing not yet implemented"); +``` + +With: +```csharp + var reindexed = cutout.ReindexAt(closestPt, entity); + result.Codes.AddRange(ConvertShapeToMoves(reindexed, closestPt)); + // TODO: MicrotabLeadOut — trim last cutting move by GapSize +``` + +- [ ] **Step 3: Replace perimeter `NotImplementedException` (line 57)** + +In the `Apply` method, replace: +```csharp + throw new System.NotImplementedException("Contour re-indexing not yet implemented"); +``` + +With: +```csharp + var reindexed = profile.Perimeter.ReindexAt(perimeterPt, perimeterEntity); + result.Codes.AddRange(ConvertShapeToMoves(reindexed, perimeterPt)); + // TODO: MicrotabLeadOut — trim last cutting move by GapSize +``` + +- [ ] **Step 4: Build to verify** + +Run: `dotnet build OpenNest.Core/OpenNest.Core.csproj` +Expected: Build succeeded, 0 errors + +- [ ] **Step 5: Build full solution** + +Run: `dotnet build OpenNest.sln` +Expected: Build succeeded, 0 errors + +- [ ] **Step 6: Commit** + +```bash +git add OpenNest.Core/CNC/CuttingStrategy/ContourCuttingStrategy.cs +git commit -m "feat: wire contour re-indexing into ContourCuttingStrategy.Apply()" +``` diff --git a/docs/superpowers/plans/2026-03-14-ml-angle-pruning.md b/docs/superpowers/plans/2026-03-14-ml-angle-pruning.md new file mode 100644 index 0000000..b80e3f2 --- /dev/null +++ b/docs/superpowers/plans/2026-03-14-ml-angle-pruning.md @@ -0,0 +1,1003 @@ +# ML Angle Pruning Implementation Plan + +> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Instrument the nesting engine to collect per-angle results during training runs, store them in the database, and add a `Description` field to the progress window for real-time visibility. + +**Architecture:** The engine gains a `ForceFullAngleSweep` flag and an `AngleResults` collection populated during `FindBestFill`. `BruteForceResult` passes these through. The training project stores them in a new `AngleResults` SQLite table. The WinForms progress form gains a description row. ONNX inference (`AnglePredictor`) is a separate future task — it requires trained model data that doesn't exist yet. + +**Tech Stack:** C# / .NET 8, EF Core SQLite, WinForms + +**Spec:** `docs/superpowers/specs/2026-03-14-ml-angle-pruning-design.md` + +--- + +## Chunk 1: Engine Instrumentation + +### Task 1: Add `AngleResult` class and `Description` to `NestProgress` + +**Files:** +- Modify: `OpenNest.Engine/NestProgress.cs` + +- [ ] **Step 1: Add `AngleResult` class and `Description` property** + +Add to `OpenNest.Engine/NestProgress.cs`, after the `PhaseResult` class: + +```csharp +public class AngleResult +{ + public double AngleDeg { get; set; } + public NestDirection Direction { get; set; } + public int PartCount { get; set; } +} +``` + +Add to `NestProgress`: + +```csharp +public string Description { get; set; } +``` + +- [ ] **Step 2: Build to verify** + +Run: `dotnet build OpenNest.Engine` +Expected: Build succeeded + +- [ ] **Step 3: Commit** + +```bash +git add OpenNest.Engine/NestProgress.cs +git commit -m "feat(engine): add AngleResult class and Description to NestProgress" +``` + +--- + +### Task 2: Add `ForceFullAngleSweep` and `AngleResults` to `NestEngine` + +**Files:** +- Modify: `OpenNest.Engine/NestEngine.cs` + +- [ ] **Step 1: Add properties to `NestEngine`** + +Add after the existing `PhaseResults` property (line 29): + +```csharp +public bool ForceFullAngleSweep { get; set; } +public List AngleResults { get; } = new(); +``` + +- [ ] **Step 2: Clear `AngleResults` at the start of `Fill`** + +In `Fill(NestItem item, Box workArea, IProgress progress, CancellationToken token)` (line 52), add `AngleResults.Clear();` right after `PhaseResults.Clear();` (line 55). + +- [ ] **Step 3: Force full angle sweep when flag is set** + +In `FindBestFill(NestItem item, Box workArea, IProgress progress, CancellationToken token)` (line 163), after the existing narrow-work-area angle expansion block (lines 182-190), add a second block: + +```csharp +if (ForceFullAngleSweep) +{ + 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); + } +} +``` + +Also apply the same pattern to the non-progress overload `FindBestFill(NestItem item, Box workArea)` (line 84) — add the same `ForceFullAngleSweep` block after line 115. + +- [ ] **Step 4: Collect per-angle results in the progress overload** + +In `FindBestFill` (progress overload, line 163), inside the `Parallel.ForEach` over angles (lines 209-221), replace the parallel body to also collect angle results. Use a `ConcurrentBag` alongside the existing `linearBag`: + +Before the `Parallel.ForEach` (after line 207), add: +```csharp +var angleBag = new System.Collections.Concurrent.ConcurrentBag(); +``` + +Inside the parallel body, after computing `h` and `v`, add: +```csharp +var angleDeg = Angle.ToDegrees(angle); +if (h != null && h.Count > 0) + angleBag.Add(new AngleResult { AngleDeg = angleDeg, Direction = NestDirection.Horizontal, PartCount = h.Count }); +if (v != null && v.Count > 0) + angleBag.Add(new AngleResult { AngleDeg = angleDeg, Direction = NestDirection.Vertical, PartCount = v.Count }); +``` + +After the `Parallel.ForEach` completes and `linearSw.Stop()` (line 222), add: +```csharp +AngleResults.AddRange(angleBag); +``` + +- [ ] **Step 5: Add `ForceFullAngleSweep` to non-progress overload (angle sweep only, no result collection)** + +The non-progress `FindBestFill` at line 84 is only called from `TryStripRefill` for sub-strip fills. Do NOT collect angle results there — sub-strip data would contaminate the main plate's results. Only add the `ForceFullAngleSweep` block (same as Step 3) after line 115. + +Do NOT add `AngleResults.Clear()` to `Fill(NestItem, Box)` at line 41 — it delegates to the progress overload at line 52 which already clears `AngleResults`. + +- [ ] **Step 6: Report per-angle progress descriptions** + +In the progress overload's `Parallel.ForEach` body, after computing h and v for an angle, report progress with a description. Since we're inside a parallel loop, use a simple approach — report after computing each angle: + +```csharp +var bestDir = (h?.Count ?? 0) >= (v?.Count ?? 0) ? "H" : "V"; +var bestCount = System.Math.Max(h?.Count ?? 0, v?.Count ?? 0); +progress?.Report(new NestProgress +{ + Phase = NestPhase.Linear, + PlateNumber = PlateNumber, + Description = $"Linear: {angleDeg:F0}\u00b0 {bestDir} - {bestCount} parts" +}); +``` + +- [ ] **Step 7: Report per-candidate progress in Pairs phase** + +In `FillWithPairs(NestItem item, Box workArea, CancellationToken token)` (line 409), inside the `Parallel.For` body (line 424), add progress reporting. Since `FillWithPairs` doesn't have access to the `progress` parameter, this requires adding a progress parameter. + +Change the signature of the cancellation-token overload at line 409 to: + +```csharp +private List FillWithPairs(NestItem item, Box workArea, CancellationToken token, IProgress progress = null) +``` + +Update the call site at line 194 (`FindBestFill` progress overload) to pass `progress`: + +```csharp +var pairResult = FillWithPairs(item, workArea, token, progress); +``` + +Inside the `Parallel.For` body (line 424), after computing `filled`, add: + +```csharp +progress?.Report(new NestProgress +{ + Phase = NestPhase.Pairs, + PlateNumber = PlateNumber, + Description = $"Pairs: candidate {i + 1}/{candidates.Count} - {filled?.Count ?? 0} parts" +}); +``` + +- [ ] **Step 8: Build to verify** + +Run: `dotnet build OpenNest.Engine` +Expected: Build succeeded + +- [ ] **Step 9: Commit** + +```bash +git add OpenNest.Engine/NestEngine.cs +git commit -m "feat(engine): add ForceFullAngleSweep flag and per-angle result collection" +``` + +--- + +### Task 3: Add `AngleResults` to `BruteForceResult` and `BruteForceRunner` + +**Files:** +- Modify: `OpenNest.Engine/ML/BruteForceRunner.cs` + +- [ ] **Step 1: Add `AngleResults` property to `BruteForceResult`** + +Add to `BruteForceResult` class (after `ThirdPlaceTimeMs`): + +```csharp +public List AngleResults { get; set; } = new(); +``` + +- [ ] **Step 2: Populate `AngleResults` in `BruteForceRunner.Run`** + +In the `return new BruteForceResult` block (line 47), add: + +```csharp +AngleResults = engine.AngleResults.ToList(), +``` + +- [ ] **Step 3: Build to verify** + +Run: `dotnet build OpenNest.Engine` +Expected: Build succeeded + +- [ ] **Step 4: Commit** + +```bash +git add OpenNest.Engine/ML/BruteForceRunner.cs +git commit -m "feat(engine): pass per-angle results through BruteForceResult" +``` + +--- + +## Chunk 2: Training Database & Runner + +### Task 4: Add `TrainingAngleResult` EF Core entity + +**Files:** +- Create: `OpenNest.Training/Data/TrainingAngleResult.cs` +- Modify: `OpenNest.Training/Data/TrainingDbContext.cs` + +- [ ] **Step 1: Create the entity class** + +Create `OpenNest.Training/Data/TrainingAngleResult.cs`: + +```csharp +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; } + } +} +``` + +- [ ] **Step 2: Add navigation property to `TrainingRun`** + +In `OpenNest.Training/Data/TrainingRun.cs`, add at the end of the class (after the `Part` navigation property): + +```csharp +public List AngleResults { get; set; } = new(); +``` + +Add `using System.Collections.Generic;` to the top if not already present. + +- [ ] **Step 3: Register `DbSet` and configure in `TrainingDbContext`** + +In `OpenNest.Training/Data/TrainingDbContext.cs`: + +Add DbSet: +```csharp +public DbSet AngleResults { get; set; } +``` + +Add configuration in `OnModelCreating`: +```csharp +modelBuilder.Entity(e => +{ + e.HasIndex(a => a.RunId).HasDatabaseName("idx_angleresults_runid"); + e.HasOne(a => a.Run) + .WithMany(r => r.AngleResults) + .HasForeignKey(a => a.RunId); +}); +``` + +- [ ] **Step 4: Build to verify** + +Run: `dotnet build OpenNest.Training` +Expected: Build succeeded + +- [ ] **Step 5: Commit** + +```bash +git add OpenNest.Training/Data/TrainingAngleResult.cs OpenNest.Training/Data/TrainingRun.cs OpenNest.Training/Data/TrainingDbContext.cs +git commit -m "feat(training): add TrainingAngleResult entity and DbSet" +``` + +--- + +### Task 5: Extend `TrainingDatabase` for angle result storage and migration + +**Files:** +- Modify: `OpenNest.Training/TrainingDatabase.cs` + +- [ ] **Step 1: Create `AngleResults` table in `MigrateSchema`** + +Add to the end of the `MigrateSchema` method, after the existing column migration loop: + +```csharp +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. +} +``` + +- [ ] **Step 2: Extend `AddRun` to accept and batch-insert angle results** + +Change the `AddRun` signature to accept angle results: + +```csharp +public void AddRun(long partId, double w, double h, double s, BruteForceResult result, string filePath, List angleResults = null) +``` + +Add `using OpenNest;` at the top if not already present (for `AngleResult` type). + +After `_db.Runs.Add(run);` and before `_db.SaveChanges();`, add: + +```csharp +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 + }); + } +} +``` + +The single `SaveChanges()` call will batch-insert both the run and all angle results in one transaction. + +- [ ] **Step 3: Build to verify** + +Run: `dotnet build OpenNest.Training` +Expected: Build succeeded + +- [ ] **Step 4: Commit** + +```bash +git add OpenNest.Training/TrainingDatabase.cs +git commit -m "feat(training): add AngleResults table migration and batch insert" +``` + +--- + +### Task 6: Wire up training runner to collect angle data + +**Files:** +- Modify: `OpenNest.Training/Program.cs` + +- [ ] **Step 1: Set `ForceFullAngleSweep` on the engine** + +In `Program.cs`, inside the `foreach (var size in sheetSuite)` loop, after creating the `BruteForceRunner.Run` call (line 203), we need to change the approach. Currently `BruteForceRunner.Run` creates the engine internally. We need to modify `BruteForceRunner.Run` to accept the `ForceFullAngleSweep` flag. + +Actually, looking at the code, `BruteForceRunner.Run` creates a `NestEngine` internally (line 29 of BruteForceRunner.cs). The cleanest approach: add an overload or optional parameter. + +In `OpenNest.Engine/ML/BruteForceRunner.cs`, change the `Run` method signature to: + +```csharp +public static BruteForceResult Run(Drawing drawing, Plate plate, bool forceFullAngleSweep = false) +``` + +And set it on the engine after creation: + +```csharp +var engine = new NestEngine(plate); +engine.ForceFullAngleSweep = forceFullAngleSweep; +``` + +- [ ] **Step 2: Pass `forceFullAngleSweep = true` from the training runner** + +In `OpenNest.Training/Program.cs`, change the `BruteForceRunner.Run` call (line 203) to: + +```csharp +var result = BruteForceRunner.Run(drawing, runPlate, forceFullAngleSweep: true); +``` + +- [ ] **Step 3: Pass angle results to `AddRun`** + +Change the `db.AddRun` call (line 266) to: + +```csharp +db.AddRun(partId, size.Width, size.Length, s, result, savedFilePath, result.AngleResults); +``` + +- [ ] **Step 4: Add angle result count to console output** + +In the console output line (line 223), append angle result count. Change: + +```csharp +Console.WriteLine($" {size.Length}x{size.Width} - {result.PartCount}pcs, {result.Utilization:P1}, {sizeSw.ElapsedMilliseconds}ms [{engineInfo}]"); +``` + +To: + +```csharp +Console.WriteLine($" {size.Length}x{size.Width} - {result.PartCount}pcs, {result.Utilization:P1}, {sizeSw.ElapsedMilliseconds}ms [{engineInfo}] angles={result.AngleResults.Count}"); +``` + +- [ ] **Step 5: Build to verify** + +Run: `dotnet build OpenNest.Training` +Expected: Build succeeded + +- [ ] **Step 6: Commit** + +```bash +git add OpenNest.Engine/ML/BruteForceRunner.cs OpenNest.Training/Program.cs +git commit -m "feat(training): enable forced full angle sweep and store per-angle results" +``` + +--- + +## Chunk 3: Progress Window Enhancement + +### Task 7: Add `Description` row to the progress form + +**Files:** +- Modify: `OpenNest/Forms/NestProgressForm.Designer.cs` +- Modify: `OpenNest/Forms/NestProgressForm.cs` + +- [ ] **Step 1: Add description label and value controls in Designer** + +In `NestProgressForm.Designer.cs`, add field declarations alongside the existing ones (after `elapsedValue` on line 231): + +```csharp +private System.Windows.Forms.Label descriptionLabel; +private System.Windows.Forms.Label descriptionValue; +``` + +In `InitializeComponent()`, add control creation (after the `elapsedValue` creation, around line 43): + +```csharp +this.descriptionLabel = new System.Windows.Forms.Label(); +this.descriptionValue = new System.Windows.Forms.Label(); +``` + +Add the description row to the table. Exact changes: +- Line 71: Change `this.table.RowCount = 6;` to `this.table.RowCount = 7;` +- After line 77 (last `RowStyles.Add`), add: `this.table.RowStyles.Add(new System.Windows.Forms.RowStyle(System.Windows.Forms.SizeType.AutoSize));` +- After line 66 (elapsedValue table.Controls.Add), add the description controls to the table +- Line 197: Change `this.ClientSize = new System.Drawing.Size(264, 207);` to `this.ClientSize = new System.Drawing.Size(264, 230);` (taller to fit the new row) + +Add table controls (after the elapsed row controls): +```csharp +this.table.Controls.Add(this.descriptionLabel, 0, 6); +this.table.Controls.Add(this.descriptionValue, 1, 6); +``` + +Configure the labels (in the label configuration section, after elapsedValue config): +```csharp +// descriptionLabel +this.descriptionLabel.AutoSize = true; +this.descriptionLabel.Font = new System.Drawing.Font(System.Drawing.SystemFonts.DefaultFont, System.Drawing.FontStyle.Bold); +this.descriptionLabel.Margin = new System.Windows.Forms.Padding(4); +this.descriptionLabel.Name = "descriptionLabel"; +this.descriptionLabel.Text = "Detail:"; +// descriptionValue +this.descriptionValue.AutoSize = true; +this.descriptionValue.Margin = new System.Windows.Forms.Padding(4); +this.descriptionValue.Name = "descriptionValue"; +this.descriptionValue.Text = "\u2014"; +``` + +Add field declarations after `elapsedValue` (line 230), before `stopButton` (line 231): + +- [ ] **Step 2: Display `Description` in `UpdateProgress`** + +In `NestProgressForm.cs`, in the `UpdateProgress` method (line 30), add after the existing updates: + +```csharp +if (!string.IsNullOrEmpty(progress.Description)) + descriptionValue.Text = progress.Description; +``` + +- [ ] **Step 3: Build to verify** + +Run: `dotnet build OpenNest.sln` +Expected: Build succeeded + +- [ ] **Step 4: Commit** + +```bash +git add OpenNest/Forms/NestProgressForm.cs OpenNest/Forms/NestProgressForm.Designer.cs +git commit -m "feat(ui): add description row to nest progress form" +``` + +--- + +## Chunk 4: ONNX Inference Scaffolding + +### Task 8: Add `Microsoft.ML.OnnxRuntime` NuGet package + +**Files:** +- Modify: `OpenNest.Engine/OpenNest.Engine.csproj` + +- [ ] **Step 1: Add the package reference** + +Add to `OpenNest.Engine/OpenNest.Engine.csproj` inside the existing `` (or create a new one for packages): + +```xml + + + +``` + +- [ ] **Step 2: Restore and build** + +Run: `dotnet restore OpenNest.Engine && dotnet build OpenNest.Engine` +Expected: Build succeeded + +- [ ] **Step 3: Commit** + +```bash +git add OpenNest.Engine/OpenNest.Engine.csproj +git commit -m "chore(engine): add Microsoft.ML.OnnxRuntime package" +``` + +--- + +### Task 9: Create `AnglePredictor` with ONNX inference + +**Files:** +- Create: `OpenNest.Engine/ML/AnglePredictor.cs` + +- [ ] **Step 1: Create the predictor class** + +Create `OpenNest.Engine/ML/AnglePredictor.cs`: + +```csharp +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 bool _loadAttempted; + private static readonly object _lock = new(); + + public static List 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(input, new[] { 1, 11 }); + var inputs = new List + { + NamedOnnxValue.CreateFromTensor("features", tensor) + }; + + using var results = session.Run(inputs); + var probabilities = results.First().AsEnumerable().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; + } + } + } +} +``` + +- [ ] **Step 2: Build to verify** + +Run: `dotnet build OpenNest.Engine` +Expected: Build succeeded (model file doesn't exist yet — that's expected, `GetSession` returns null gracefully) + +- [ ] **Step 3: Commit** + +```bash +git add OpenNest.Engine/ML/AnglePredictor.cs +git commit -m "feat(engine): add AnglePredictor ONNX inference class" +``` + +--- + +### Task 10: Wire `AnglePredictor` into `NestEngine.FindBestFill` + +**Files:** +- Modify: `OpenNest.Engine/NestEngine.cs` + +- [ ] **Step 1: Add ML angle prediction to the progress overload** + +In `FindBestFill` (progress overload), after the narrow-work-area angle expansion block and after the `ForceFullAngleSweep` block, add ML prediction logic. This replaces the full sweep when the model is available: + +```csharp +// 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) + { + // Use predicted angles, but always keep bestRotation and bestRotation + 90. + var mlAngles = new List(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($"[FindBestFill] ML: {angles.Count} angles -> {mlAngles.Count} predicted"); + angles = mlAngles; + } + } +} +``` + +Add `using OpenNest.Engine.ML;` at the top of the file if not already present. + +- [ ] **Step 2: Apply the same pattern to the non-progress overload** + +Add the identical ML prediction block to `FindBestFill(NestItem item, Box workArea)` after its `ForceFullAngleSweep` block. + +- [ ] **Step 3: Build the full solution** + +Run: `dotnet build OpenNest.sln` +Expected: Build succeeded + +- [ ] **Step 4: Commit** + +```bash +git add OpenNest.Engine/NestEngine.cs +git commit -m "feat(engine): integrate AnglePredictor into FindBestFill angle selection" +``` + +--- + +## Chunk 5: Training Notebook Scaffolding + +### Task 11: Create Python training notebook and requirements + +**Files:** +- Create: `OpenNest.Training/notebooks/requirements.txt` +- Create: `OpenNest.Training/notebooks/train_angle_model.ipynb` + +- [ ] **Step 1: Create requirements.txt** + +Create `OpenNest.Training/notebooks/requirements.txt`: + +``` +pandas>=2.0 +scikit-learn>=1.3 +xgboost>=2.0 +onnxmltools>=1.12 +skl2onnx>=1.16 +matplotlib>=3.7 +jupyter>=1.0 +``` + +- [ ] **Step 2: Create training notebook skeleton** + +Create `OpenNest.Training/notebooks/train_angle_model.ipynb` as a Jupyter notebook with the following cells: + +Cell 1 (markdown): +``` +# Angle Prediction Model Training +Trains an XGBoost multi-label classifier to predict which rotation angles are competitive for a given part geometry and sheet size. + +**Input:** SQLite database from OpenNest.Training data collection runs +**Output:** `angle_predictor.onnx` model file for `OpenNest.Engine/Models/` +``` + +Cell 2 (code): imports and setup +```python +import sqlite3 +import pandas as pd +import numpy as np +from pathlib import Path + +DB_PATH = "../OpenNestTraining.db" # Adjust to your database location +OUTPUT_PATH = "../../OpenNest.Engine/Models/angle_predictor.onnx" +COMPETITIVE_THRESHOLD = 0.95 # Angle is "competitive" if >= 95% of best +``` + +Cell 3 (code): data extraction +```python +# Extract training data from SQLite +conn = sqlite3.connect(DB_PATH) + +query = """ +SELECT + p.Area, p.Convexity, p.AspectRatio, p.BBFill, p.Circularity, + p.PerimeterToAreaRatio, p.VertexCount, + r.SheetWidth, r.SheetHeight, r.Id as RunId, + a.AngleDeg, a.Direction, a.PartCount +FROM AngleResults a +JOIN Runs r ON a.RunId = r.Id +JOIN Parts p ON r.PartId = p.Id +WHERE a.PartCount > 0 +""" + +df = pd.read_sql_query(query, conn) +conn.close() + +print(f"Loaded {len(df)} angle result rows") +print(f"Unique runs: {df['RunId'].nunique()}") +print(f"Angle range: {df['AngleDeg'].min()}-{df['AngleDeg'].max()}") +``` + +Cell 4 (code): label generation +```python +# For each run, find best PartCount (max of H and V per angle), +# then label angles within 95% of best as positive. + +# Best count per angle per run (max of H and V) +angle_best = df.groupby(['RunId', 'AngleDeg'])['PartCount'].max().reset_index() +angle_best.columns = ['RunId', 'AngleDeg', 'BestCount'] + +# Best count per run (overall best angle) +run_best = angle_best.groupby('RunId')['BestCount'].max().reset_index() +run_best.columns = ['RunId', 'RunBest'] + +# Merge and compute labels +labels = angle_best.merge(run_best, on='RunId') +labels['IsCompetitive'] = (labels['BestCount'] >= labels['RunBest'] * COMPETITIVE_THRESHOLD).astype(int) + +# Pivot to 36-column binary label matrix +label_matrix = labels.pivot_table( + index='RunId', columns='AngleDeg', values='IsCompetitive', fill_value=0 +) + +# Ensure all 36 angle columns exist (0, 5, 10, ..., 175) +all_angles = [i * 5 for i in range(36)] +for a in all_angles: + if a not in label_matrix.columns: + label_matrix[a] = 0 +label_matrix = label_matrix[all_angles] + +print(f"Label matrix: {label_matrix.shape}") +print(f"Average competitive angles per run: {label_matrix.sum(axis=1).mean():.1f}") +``` + +Cell 5 (code): feature engineering +```python +# Build feature matrix — one row per run +features_query = """ +SELECT DISTINCT + r.Id as RunId, p.FileName, + p.Area, p.Convexity, p.AspectRatio, p.BBFill, p.Circularity, + p.PerimeterToAreaRatio, p.VertexCount, + r.SheetWidth, r.SheetHeight +FROM Runs r +JOIN Parts p ON r.PartId = p.Id +WHERE r.Id IN ({}) +""".format(','.join(str(x) for x in label_matrix.index)) + +conn = sqlite3.connect(DB_PATH) +features_df = pd.read_sql_query(features_query, conn) +conn.close() + +features_df = features_df.set_index('RunId') + +# Derived features +features_df['SheetAspectRatio'] = features_df['SheetWidth'] / features_df['SheetHeight'] +features_df['PartToSheetAreaRatio'] = features_df['Area'] / (features_df['SheetWidth'] * features_df['SheetHeight']) + +# Filter outliers (title blocks, etc.) +mask = (features_df['BBFill'] >= 0.01) & (features_df['Area'] > 0.1) +print(f"Filtering: {(~mask).sum()} outlier runs removed") +features_df = features_df[mask] +label_matrix = label_matrix.loc[features_df.index] + +feature_cols = ['Area', 'Convexity', 'AspectRatio', 'BBFill', 'Circularity', + 'PerimeterToAreaRatio', 'VertexCount', + 'SheetWidth', 'SheetHeight', 'SheetAspectRatio', 'PartToSheetAreaRatio'] + +X = features_df[feature_cols].values +y = label_matrix.values + +print(f"Features: {X.shape}, Labels: {y.shape}") +``` + +Cell 6 (code): train/test split and training +```python +from sklearn.model_selection import GroupShuffleSplit +from sklearn.multioutput import MultiOutputClassifier +import xgboost as xgb + +# Split by part (all sheet sizes for a part stay in the same split) +groups = features_df['FileName'] +splitter = GroupShuffleSplit(n_splits=1, test_size=0.2, random_state=42) +train_idx, test_idx = next(splitter.split(X, y, groups)) + +X_train, X_test = X[train_idx], X[test_idx] +y_train, y_test = y[train_idx], y[test_idx] + +print(f"Train: {len(train_idx)}, Test: {len(test_idx)}") + +# Train XGBoost multi-label classifier +base_clf = xgb.XGBClassifier( + n_estimators=200, + max_depth=6, + learning_rate=0.1, + use_label_encoder=False, + eval_metric='logloss', + random_state=42 +) + +clf = MultiOutputClassifier(base_clf, n_jobs=-1) +clf.fit(X_train, y_train) +print("Training complete") +``` + +Cell 7 (code): evaluation +```python +from sklearn.metrics import recall_score, precision_score +import matplotlib.pyplot as plt + +y_pred = clf.predict(X_test) +y_prob = np.array([est.predict_proba(X_test)[:, 1] for est in clf.estimators_]).T + +# Per-angle metrics +recalls = [] +precisions = [] +for i in range(36): + if y_test[:, i].sum() > 0: + recalls.append(recall_score(y_test[:, i], y_pred[:, i], zero_division=0)) + precisions.append(precision_score(y_test[:, i], y_pred[:, i], zero_division=0)) + +print(f"Mean recall: {np.mean(recalls):.3f}") +print(f"Mean precision: {np.mean(precisions):.3f}") + +# Average angles predicted per run +avg_predicted = y_pred.sum(axis=1).mean() +print(f"Avg angles predicted per run: {avg_predicted:.1f}") + +# Plot +fig, axes = plt.subplots(1, 2, figsize=(12, 4)) +axes[0].bar(range(len(recalls)), recalls) +axes[0].set_title('Recall per Angle Bin') +axes[0].set_xlabel('Angle (5-deg bins)') +axes[0].axhline(y=0.95, color='r', linestyle='--', label='Target 95%') +axes[0].legend() + +axes[1].bar(range(len(precisions)), precisions) +axes[1].set_title('Precision per Angle Bin') +axes[1].set_xlabel('Angle (5-deg bins)') +axes[1].axhline(y=0.60, color='r', linestyle='--', label='Target 60%') +axes[1].legend() + +plt.tight_layout() +plt.show() +``` + +Cell 8 (code): export to ONNX +```python +from skl2onnx import convert_sklearn +from skl2onnx.common.data_types import FloatTensorType +from pathlib import Path + +initial_type = [('features', FloatTensorType([None, 11]))] +onnx_model = convert_sklearn(clf, initial_types=initial_type) + +output_path = Path(OUTPUT_PATH) +output_path.parent.mkdir(parents=True, exist_ok=True) + +with open(output_path, 'wb') as f: + f.write(onnx_model.SerializeToString()) + +print(f"Model saved to {output_path} ({output_path.stat().st_size / 1024:.0f} KB)") +``` + +- [ ] **Step 3: Create the `Models` directory placeholder** + +Run: `mkdir -p OpenNest.Engine/Models` + +Create `OpenNest.Engine/Models/.gitkeep` (empty file to track the directory). + +- [ ] **Step 4: Commit** + +```bash +git add OpenNest.Training/notebooks/ OpenNest.Engine/Models/.gitkeep +git commit -m "feat(training): add training notebook skeleton and requirements" +``` + +--- + +## Summary + +| Chunk | Tasks | Purpose | +|-------|-------|---------| +| 1 | 1-3 | Engine instrumentation: `AngleResult`, `ForceFullAngleSweep`, per-angle collection | +| 2 | 4-6 | Training DB: `AngleResults` table, migration, runner wiring | +| 3 | 7 | Progress window: `Description` display | +| 4 | 8-10 | ONNX inference: `AnglePredictor` class, NuGet package, `FindBestFill` integration | +| 5 | 11 | Python notebook: training pipeline skeleton | + +**Dependency order:** Chunks 1-2 must be sequential (2 depends on 1). Chunks 3, 4, 5 are independent of each other and can be done in parallel after Chunk 1. + +**After this plan:** Run training data collection with `--force-sweep` (the existing training runner + new angle collection). Once data exists, run the notebook to train and export the ONNX model. The engine will automatically use it once `angle_predictor.onnx` is placed in the `Models/` directory. diff --git a/docs/superpowers/plans/2026-03-15-abstract-nest-engine.md b/docs/superpowers/plans/2026-03-15-abstract-nest-engine.md new file mode 100644 index 0000000..e7bc230 --- /dev/null +++ b/docs/superpowers/plans/2026-03-15-abstract-nest-engine.md @@ -0,0 +1,867 @@ +# Abstract Nest Engine Implementation Plan + +> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Refactor the concrete `NestEngine` into an abstract `NestEngineBase` with pluggable implementations, a registry for engine discovery/selection, and plugin loading from DLLs. + +**Architecture:** Extract shared state and utilities into `NestEngineBase` (abstract). Current logic becomes `DefaultNestEngine`. `NestEngineRegistry` provides factory creation, built-in registration, and DLL plugin discovery. All callsites migrate from `new NestEngine(plate)` to `NestEngineRegistry.Create(plate)`. + +**Tech Stack:** C# / .NET 8, OpenNest.Engine, OpenNest (WinForms), OpenNest.Mcp, OpenNest.Console + +**Spec:** `docs/superpowers/specs/2026-03-15-abstract-nest-engine-design.md` + +**Deferred:** `StripNester.cs` → `StripNestEngine.cs` conversion is deferred to the strip nester implementation plan (`docs/superpowers/plans/2026-03-15-strip-nester.md`). That plan should be updated to create `StripNestEngine` as a `NestEngineBase` subclass and register it in `NestEngineRegistry`. The UI engine selector combobox is also deferred — it can be added once there are multiple engines to choose from. + +--- + +## Chunk 1: NestEngineBase and DefaultNestEngine + +### Task 1: Create NestEngineBase abstract class + +**Files:** +- Create: `OpenNest.Engine/NestEngineBase.cs` + +This is the abstract base class. It holds shared properties, abstract `Name`/`Description`, virtual methods that return empty lists by default, convenience overloads that mutate the plate, `FillExact` (non-virtual), and protected utility methods extracted from the current `NestEngine`. + +- [ ] **Step 1: Create NestEngineBase.cs** + +```csharp +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 PhaseResults { get; } = new(); + + public List AngleResults { get; } = new(); + + public abstract string Name { get; } + + public abstract string Description { get; } + + // --- Virtual methods (side-effect-free, return parts) --- + + public virtual List Fill(NestItem item, Box workArea, + IProgress progress, CancellationToken token) + { + return new List(); + } + + public virtual List Fill(List groupParts, Box workArea, + IProgress progress, CancellationToken token) + { + return new List(); + } + + public virtual List PackArea(Box box, List items, + IProgress progress, CancellationToken token) + { + return new List(); + } + + // --- FillExact (non-virtual, delegates to virtual Fill) --- + + public List FillExact(NestItem item, Box workArea, + IProgress 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 groupParts) + { + return Fill(groupParts, Plate.WorkArea()); + } + + public bool Fill(List 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 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 progress, + NestPhase phase, + int plateNumber, + List best, + Box workArea, + string description) + { + if (progress == null || best == null || best.Count == 0) + return; + + var score = FillScore.Compute(best, workArea); + var clonedParts = new List(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(PhaseResults.Count); + + foreach (var r in PhaseResults) + parts.Add($"{FormatPhaseName(r.Phase)}: {r.PartCount}"); + + return string.Join(" | ", parts); + } + + protected bool IsBetterFill(List candidate, List 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 candidate, List 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 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 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(); + } + } + } +} +``` + +- [ ] **Step 2: Build to verify** + +Run: `dotnet build OpenNest.Engine/OpenNest.Engine.csproj` +Expected: Build succeeded + +- [ ] **Step 3: Commit** + +```bash +git add OpenNest.Engine/NestEngineBase.cs +git commit -m "feat: add NestEngineBase abstract class" +``` + +--- + +### Task 2: Convert NestEngine to DefaultNestEngine + +**Files:** +- Rename: `OpenNest.Engine/NestEngine.cs` → `OpenNest.Engine/DefaultNestEngine.cs` + +Rename the class, make it inherit `NestEngineBase`, add `Name`/`Description`, change the virtual methods to `override`, and remove methods that now live in the base class (convenience overloads, `ReportProgress`, `BuildProgressSummary`, `IsBetterFill`, `IsBetterValidFill`, `HasOverlaps`, `FormatPhaseName`, `FillExact`). + +- [ ] **Step 1: Rename the file** + +```bash +git mv OpenNest.Engine/NestEngine.cs OpenNest.Engine/DefaultNestEngine.cs +``` + +- [ ] **Step 2: Update class declaration and add inheritance** + +In `DefaultNestEngine.cs`, change the class declaration from: + +```csharp + public class NestEngine + { + public NestEngine(Plate plate) + { + Plate = plate; + } + + public Plate Plate { get; set; } + + public NestDirection NestDirection { get; set; } + + public int PlateNumber { get; set; } + + public NestPhase WinnerPhase { get; private set; } + + public List PhaseResults { get; } = new(); + + public bool ForceFullAngleSweep { get; set; } + + public List AngleResults { get; } = new(); +``` + +To: + +```csharp + 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; } +``` + +This removes properties that now come from the base class (`Plate`, `PlateNumber`, `NestDirection`, `WinnerPhase`, `PhaseResults`, `AngleResults`). + +- [ ] **Step 3: Convert the convenience Fill overloads to override the virtual methods** + +Remove the non-progress `Fill` convenience overloads (they are now in the base class). The two remaining `Fill` methods that take `IProgress` and `CancellationToken` become overrides. + +Change: +```csharp + public List Fill(NestItem item, Box workArea, + IProgress progress, CancellationToken token) +``` +To: +```csharp + public override List Fill(NestItem item, Box workArea, + IProgress progress, CancellationToken token) +``` + +Change: +```csharp + public List Fill(List groupParts, Box workArea, + IProgress progress, CancellationToken token) +``` +To: +```csharp + public override List Fill(List groupParts, Box workArea, + IProgress progress, CancellationToken token) +``` + +Remove these methods entirely (now in base class): +- `bool Fill(NestItem item)` (2-arg convenience) +- `bool Fill(NestItem item, Box workArea)` (convenience that calls the 4-arg) +- `bool Fill(List groupParts)` (convenience) +- `bool Fill(List groupParts, Box workArea)` (convenience that calls the 4-arg) +- `FillExact` (now in base class) +- `ReportProgress` (now in base class) +- `BuildProgressSummary` (now in base class) +- `IsBetterFill` (now in base class) +- `IsBetterValidFill` (now in base class) +- `HasOverlaps` (now in base class) +- `FormatPhaseName` (now in base class) + +- [ ] **Step 4: Convert Pack/PackArea to override** + +Remove `Pack(List)` (now in base class). + +Convert `PackArea` to override with the new signature. Replace: + +```csharp + public bool Pack(List items) + { + var workArea = Plate.WorkArea(); + return PackArea(workArea, items); + } + + public bool PackArea(Box box, List items) + { + var binItems = BinConverter.ToItems(items, Plate.PartSpacing, Plate.Area()); + var bin = BinConverter.CreateBin(box, Plate.PartSpacing); + + var engine = new PackBottomLeft(bin); + engine.Pack(binItems); + + var parts = BinConverter.ToParts(bin, items); + Plate.Parts.AddRange(parts); + + return parts.Count > 0; + } +``` + +With: + +```csharp + public override List PackArea(Box box, List items, + IProgress 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); + } +``` + +Note: the `progress` and `token` parameters are not used yet in the default rectangle packing — the contract is there for engines that need them. + +- [ ] **Step 5: Update BruteForceRunner to use DefaultNestEngine** + +`BruteForceRunner.cs` is in the same project and still references `NestEngine`. It must be updated before the Engine project can compile. This is the one callsite that stays as a direct `DefaultNestEngine` reference (not via registry) because training data must come from the known algorithm. + +In `OpenNest.Engine/ML/BruteForceRunner.cs`, change line 30: + +```csharp + var engine = new NestEngine(plate); +``` + +To: + +```csharp + var engine = new DefaultNestEngine(plate); +``` + +- [ ] **Step 6: Build to verify** + +Run: `dotnet build OpenNest.Engine/OpenNest.Engine.csproj` +Expected: Build succeeded (other projects will have errors since their callsites still reference `NestEngine` — fixed in Chunk 3) + +- [ ] **Step 7: Commit** + +```bash +git add OpenNest.Engine/DefaultNestEngine.cs OpenNest.Engine/ML/BruteForceRunner.cs +git commit -m "refactor: rename NestEngine to DefaultNestEngine, inherit NestEngineBase" +``` + +--- + +## Chunk 2: NestEngineRegistry and NestEngineInfo + +### Task 3: Create NestEngineInfo + +**Files:** +- Create: `OpenNest.Engine/NestEngineInfo.cs` + +- [ ] **Step 1: Create NestEngineInfo.cs** + +```csharp +using System; + +namespace OpenNest +{ + public class NestEngineInfo + { + public NestEngineInfo(string name, string description, Func factory) + { + Name = name; + Description = description; + Factory = factory; + } + + public string Name { get; } + public string Description { get; } + public Func Factory { get; } + } +} +``` + +- [ ] **Step 2: Build to verify** + +Run: `dotnet build OpenNest.Engine/OpenNest.Engine.csproj` +Expected: Build succeeded + +- [ ] **Step 3: Commit** + +```bash +git add OpenNest.Engine/NestEngineInfo.cs +git commit -m "feat: add NestEngineInfo metadata class" +``` + +--- + +### Task 4: Create NestEngineRegistry + +**Files:** +- Create: `OpenNest.Engine/NestEngineRegistry.cs` + +Static class with built-in registration, plugin loading, active engine selection, and factory creation. + +- [ ] **Step 1: Create NestEngineRegistry.cs** + +```csharp +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 engines = new(); + + static NestEngineRegistry() + { + Register("Default", + "Multi-phase nesting (Linear, Pairs, RectBestFit, Remainder)", + plate => new DefaultNestEngine(plate)); + } + + public static IReadOnlyList 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 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}"); + } + } + } + } +} +``` + +- [ ] **Step 2: Build to verify** + +Run: `dotnet build OpenNest.Engine/OpenNest.Engine.csproj` +Expected: Build succeeded + +- [ ] **Step 3: Commit** + +```bash +git add OpenNest.Engine/NestEngineRegistry.cs +git commit -m "feat: add NestEngineRegistry with built-in registration and plugin loading" +``` + +--- + +## Chunk 3: Callsite Migration + +### Task 5: Migrate OpenNest.Mcp callsites + +**Files:** +- Modify: `OpenNest.Mcp/Tools/NestingTools.cs` + +Six `new NestEngine(plate)` calls become `NestEngineRegistry.Create(plate)`. The `PackArea` call on line 276 changes signature since `PackArea` now returns `List` instead of mutating the plate. + +- [ ] **Step 1: Replace all NestEngine instantiations** + +In `NestingTools.cs`, replace all six occurrences of `new NestEngine(plate)` with `NestEngineRegistry.Create(plate)`. + +Lines to change: +- Line 37: `var engine = new NestEngine(plate);` → `var engine = NestEngineRegistry.Create(plate);` +- Line 73: `var engine = new NestEngine(plate);` → `var engine = NestEngineRegistry.Create(plate);` +- Line 114: `var engine = new NestEngine(plate);` → `var engine = NestEngineRegistry.Create(plate);` +- Line 176: `var engine = new NestEngine(plate);` → `var engine = NestEngineRegistry.Create(plate);` +- Line 255: `var engine = new NestEngine(plate);` → `var engine = NestEngineRegistry.Create(plate);` +- Line 275: `var engine = new NestEngine(plate);` → `var engine = NestEngineRegistry.Create(plate);` + +- [ ] **Step 2: Fix PackArea call in AutoNestPlate** + +The old code on line 276 was: +```csharp + engine.PackArea(workArea, packItems); +``` + +This used the old `bool PackArea(Box, List)` which mutated the plate. The new virtual method returns `List`. Use the convenience `Pack`-like pattern instead. Replace lines 274-277: + +```csharp + var before = plate.Parts.Count; + var engine = new NestEngine(plate); + engine.PackArea(workArea, packItems); + totalPlaced += plate.Parts.Count - before; +``` + +With: + +```csharp + var engine = NestEngineRegistry.Create(plate); + var packParts = engine.PackArea(workArea, packItems, null, CancellationToken.None); + if (packParts.Count > 0) + { + plate.Parts.AddRange(packParts); + totalPlaced += packParts.Count; + } +``` + +- [ ] **Step 3: Build OpenNest.Mcp** + +Run: `dotnet build OpenNest.Mcp/OpenNest.Mcp.csproj` +Expected: Build succeeded + +- [ ] **Step 4: Commit** + +```bash +git add OpenNest.Mcp/Tools/NestingTools.cs +git commit -m "refactor: migrate NestingTools to NestEngineRegistry" +``` + +--- + +### Task 6: Migrate OpenNest.Console callsites + +**Files:** +- Modify: `OpenNest.Console/Program.cs` + +Three `new NestEngine(plate)` calls. The `PackArea` call also needs the same signature update. + +- [ ] **Step 1: Replace NestEngine instantiations** + +In `Program.cs`, replace: +- Line 351: `var engine = new NestEngine(plate);` → `var engine = NestEngineRegistry.Create(plate);` +- Line 380: `var engine = new NestEngine(plate);` → `var engine = NestEngineRegistry.Create(plate);` + +- [ ] **Step 2: Fix PackArea call** + +Replace lines 370-372: + +```csharp + var engine = new NestEngine(plate); + var before = plate.Parts.Count; + engine.PackArea(workArea, packItems); +``` + +With: + +```csharp + var engine = NestEngineRegistry.Create(plate); + var packParts = engine.PackArea(workArea, packItems, null, CancellationToken.None); + plate.Parts.AddRange(packParts); +``` + +And update line 374-375 from: +```csharp + if (plate.Parts.Count > before) + success = true; +``` +To: +```csharp + if (packParts.Count > 0) + success = true; +``` + +- [ ] **Step 3: Build OpenNest.Console** + +Run: `dotnet build OpenNest.Console/OpenNest.Console.csproj` +Expected: Build succeeded + +- [ ] **Step 4: Commit** + +```bash +git add OpenNest.Console/Program.cs +git commit -m "refactor: migrate Console Program to NestEngineRegistry" +``` + +--- + +### Task 7: Migrate OpenNest WinForms callsites + +**Files:** +- Modify: `OpenNest/Actions/ActionFillArea.cs` +- Modify: `OpenNest/Controls/PlateView.cs` +- Modify: `OpenNest/Forms/MainForm.cs` + +- [ ] **Step 1: Migrate ActionFillArea.cs** + +In `ActionFillArea.cs`, replace both `new NestEngine(plateView.Plate)` calls: +- Line 50: `var engine = new NestEngine(plateView.Plate);` → `var engine = NestEngineRegistry.Create(plateView.Plate);` +- Line 64: `var engine = new NestEngine(plateView.Plate);` → `var engine = NestEngineRegistry.Create(plateView.Plate);` + +- [ ] **Step 2: Migrate PlateView.cs** + +In `PlateView.cs`, replace: +- Line 836: `var engine = new NestEngine(Plate);` → `var engine = NestEngineRegistry.Create(Plate);` + +- [ ] **Step 3: Migrate MainForm.cs** + +In `MainForm.cs`, replace all three `new NestEngine(plate)` calls: +- Line 797: `var engine = new NestEngine(plate) { PlateNumber = plateCount };` → `var engine = NestEngineRegistry.Create(plate); engine.PlateNumber = plateCount;` +- Line 829: `var engine = new NestEngine(plate);` → `var engine = NestEngineRegistry.Create(plate);` +- Line 965: `var engine = new NestEngine(plate);` → `var engine = NestEngineRegistry.Create(plate);` + +- [ ] **Step 4: Fix MainForm PackArea call** + +In `MainForm.cs`, the auto-nest pack phase (around line 829-832) uses the old `PackArea` signature. Replace: + +```csharp + var engine = new NestEngine(plate); + var partsBefore = plate.Parts.Count; + engine.PackArea(workArea, packItems); + var packed = plate.Parts.Count - partsBefore; +``` + +With: + +```csharp + var engine = NestEngineRegistry.Create(plate); + var packParts = engine.PackArea(workArea, packItems, null, CancellationToken.None); + plate.Parts.AddRange(packParts); + var packed = packParts.Count; +``` + +- [ ] **Step 5: Add plugin loading at startup** + +In `MainForm.cs`, find where post-processors are loaded at startup (look for `Posts` directory loading) and add engine plugin loading nearby. Add after the existing plugin loading: + +```csharp + var enginesDir = Path.Combine(Application.StartupPath, "Engines"); + NestEngineRegistry.LoadPlugins(enginesDir); +``` + +If there is no explicit post-processor loading call visible, add this to the `MainForm` constructor or `Load` event. + +- [ ] **Step 6: Build the full solution** + +Run: `dotnet build OpenNest.sln` +Expected: Build succeeded with no errors + +- [ ] **Step 7: Commit** + +```bash +git add OpenNest/Actions/ActionFillArea.cs OpenNest/Controls/PlateView.cs OpenNest/Forms/MainForm.cs +git commit -m "refactor: migrate WinForms callsites to NestEngineRegistry" +``` + +--- + +## Chunk 4: Verification and Cleanup + +### Task 8: Verify no remaining NestEngine references + +**Files:** +- No changes expected — verification only + +- [ ] **Step 1: Search for stale references** + +Run: `grep -rn "new NestEngine(" --include="*.cs" .` +Expected: Only `BruteForceRunner.cs` should have `new DefaultNestEngine(`. No `new NestEngine(` references should remain. + +Also run: `grep -rn "class NestEngine[^B]" --include="*.cs" .` +Expected: No matches (the old `class NestEngine` no longer exists). + +- [ ] **Step 2: Build and run smoke test** + +Run: `dotnet build OpenNest.sln` +Expected: Build succeeded, 0 errors, 0 warnings related to NestEngine + +- [ ] **Step 3: Publish MCP server** + +Run: `dotnet publish OpenNest.Mcp/OpenNest.Mcp.csproj -c Release -o "$USERPROFILE/.claude/mcp/OpenNest.Mcp"` +Expected: Publish succeeded + +- [ ] **Step 4: Commit if any fixes were needed** + +If any issues were found and fixed in previous steps, commit them now. + +--- + +### Task 9: Update CLAUDE.md architecture documentation + +**Files:** +- Modify: `CLAUDE.md` + +- [ ] **Step 1: Update architecture section** + +Update the `### OpenNest.Engine` section in `CLAUDE.md` to document the new engine hierarchy: +- `NestEngineBase` is the abstract base class +- `DefaultNestEngine` is the current multi-phase engine (formerly `NestEngine`) +- `NestEngineRegistry` manages available engines and the active selection +- `NestEngineInfo` holds engine metadata +- Plugin engines loaded from `Engines/` directory + +Also update any references to `NestEngine` that should now say `DefaultNestEngine` or `NestEngineBase`. + +- [ ] **Step 2: Build to verify no docs broke anything** + +Run: `dotnet build OpenNest.sln` +Expected: Build succeeded + +- [ ] **Step 3: Commit** + +```bash +git add CLAUDE.md +git commit -m "docs: update CLAUDE.md for abstract nest engine architecture" +``` diff --git a/docs/superpowers/plans/2026-03-15-fill-exact.md b/docs/superpowers/plans/2026-03-15-fill-exact.md new file mode 100644 index 0000000..addf0b3 --- /dev/null +++ b/docs/superpowers/plans/2026-03-15-fill-exact.md @@ -0,0 +1,462 @@ +# FillExact Implementation Plan + +> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add a `FillExact` method to `NestEngine` that binary-searches for the smallest work area sub-region that fits an exact quantity of parts, then integrate it into AutoNest. + +**Architecture:** `FillExact` wraps the existing `Fill(NestItem, Box, IProgress, CancellationToken)` method. It calls Fill repeatedly with progressively smaller test boxes (binary search on one dimension, both orientations), picks the tightest fit, then re-runs the winner with progress reporting. Callers swap `Fill` for `FillExact` — no other engine changes needed. + +**Tech Stack:** C# / .NET 8, OpenNest.Engine, OpenNest (WinForms), OpenNest.Console, OpenNest.Mcp + +**Spec:** `docs/superpowers/specs/2026-03-15-fill-exact-design.md` + +--- + +## Chunk 1: Core Implementation + +### Task 1: Add `BinarySearchFill` helper to NestEngine + +**Files:** +- Modify: `OpenNest.Engine/NestEngine.cs` (add private method after the existing `Fill` overloads, around line 85) + +- [ ] **Step 1: Add the BinarySearchFill method** + +Add after the `Fill(NestItem, Box, IProgress, CancellationToken)` method (line 85): + +```csharp +/// +/// Binary-searches for the smallest sub-area (one dimension fixed) that fits +/// exactly item.Quantity parts. Returns the best parts list and the dimension +/// value that achieved it. +/// +private (List parts, double usedDim) BinarySearchFill( + NestItem item, Box workArea, bool shrinkWidth, + CancellationToken token) +{ + var quantity = item.Quantity; + var partBox = item.Drawing.Program.BoundingBox(); + var partArea = item.Drawing.Area; + + // Fixed and variable dimensions. + var fixedDim = shrinkWidth ? workArea.Length : workArea.Width; + var highDim = shrinkWidth ? workArea.Width : workArea.Length; + + // Estimate starting point: target area at 50% utilization. + var targetArea = partArea * quantity / 0.5; + var minPartDim = shrinkWidth + ? partBox.Width + Plate.PartSpacing + : partBox.Length + Plate.PartSpacing; + var estimatedDim = System.Math.Max(minPartDim, targetArea / fixedDim); + + var low = estimatedDim; + var high = highDim; + + List bestParts = null; + var bestDim = high; + + for (var iter = 0; iter < 8; iter++) + { + if (token.IsCancellationRequested) + break; + + if (high - low < Plate.PartSpacing) + break; + + var mid = (low + high) / 2.0; + + var testBox = shrinkWidth + ? new Box(workArea.X, workArea.Y, mid, workArea.Length) + : new Box(workArea.X, workArea.Y, workArea.Width, mid); + + var result = Fill(item, testBox, null, token); + + if (result.Count >= quantity) + { + bestParts = result.Count > quantity + ? result.Take(quantity).ToList() + : result; + bestDim = mid; + high = mid; + } + else + { + low = mid; + } + } + + return (bestParts, bestDim); +} +``` + +- [ ] **Step 2: Build to verify compilation** + +Run: `dotnet build OpenNest.Engine/OpenNest.Engine.csproj --nologo -v q` +Expected: `Build succeeded. 0 Error(s)` + +- [ ] **Step 3: Commit** + +```bash +git add OpenNest.Engine/NestEngine.cs +git commit -m "feat(engine): add BinarySearchFill helper for exact-quantity search" +``` + +--- + +### Task 2: Add `FillExact` public method to NestEngine + +**Files:** +- Modify: `OpenNest.Engine/NestEngine.cs` (add public method after the existing `Fill` overloads, before `BinarySearchFill`) + +- [ ] **Step 1: Add the FillExact method** + +Add between the `Fill(NestItem, Box, IProgress, CancellationToken)` method and `BinarySearchFill`: + +```csharp +/// +/// Finds the smallest sub-area of workArea that fits exactly item.Quantity parts. +/// Uses binary search on both orientations and picks the tightest fit. +/// Falls through to standard Fill for unlimited (0) or single (1) quantities. +/// +public List FillExact(NestItem item, Box workArea, + IProgress progress, CancellationToken token) +{ + // Early exits: unlimited or single quantity — no benefit from area search. + if (item.Quantity <= 1) + return Fill(item, workArea, progress, token); + + // Full fill to establish upper bound. + var fullResult = Fill(item, workArea, progress, token); + + if (fullResult.Count <= item.Quantity) + return fullResult; + + // Binary search: try shrinking each dimension. + var (lengthParts, lengthDim) = BinarySearchFill(item, workArea, shrinkWidth: false, token); + var (widthParts, widthDim) = BinarySearchFill(item, workArea, shrinkWidth: true, token); + + // Pick winner by smallest test box area. Tie-break: prefer shrink-length. + List winner; + Box winnerBox; + + var lengthArea = lengthParts != null ? workArea.Width * lengthDim : double.MaxValue; + var widthArea = widthParts != null ? widthDim * workArea.Length : double.MaxValue; + + if (lengthParts != null && lengthArea <= widthArea) + { + winner = lengthParts; + winnerBox = new Box(workArea.X, workArea.Y, workArea.Width, lengthDim); + } + else if (widthParts != null) + { + winner = widthParts; + winnerBox = new Box(workArea.X, workArea.Y, widthDim, workArea.Length); + } + else + { + // Neither search found the exact quantity — return full fill truncated. + return fullResult.Take(item.Quantity).ToList(); + } + + // Re-run the winner with progress so PhaseResults/WinnerPhase are correct + // and the progress form shows the final result. + var finalResult = Fill(item, winnerBox, progress, token); + + if (finalResult.Count >= item.Quantity) + return finalResult.Count > item.Quantity + ? finalResult.Take(item.Quantity).ToList() + : finalResult; + + // Fallback: return the binary search result if the re-run produced fewer. + return winner; +} +``` + +- [ ] **Step 2: Build to verify compilation** + +Run: `dotnet build OpenNest.Engine/OpenNest.Engine.csproj --nologo -v q` +Expected: `Build succeeded. 0 Error(s)` + +- [ ] **Step 3: Commit** + +```bash +git add OpenNest.Engine/NestEngine.cs +git commit -m "feat(engine): add FillExact method for exact-quantity nesting" +``` + +--- + +### Task 3: Add Compactor class to Engine + +**Files:** +- Create: `OpenNest.Engine/Compactor.cs` + +- [ ] **Step 1: Create the Compactor class** + +Create `OpenNest.Engine/Compactor.cs`: + +```csharp +using System.Collections.Generic; +using System.Linq; +using OpenNest.Geometry; + +namespace OpenNest +{ + /// + /// 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. + /// + public static class Compactor + { + private const double ChordTolerance = 0.001; + + /// + /// Compacts movingParts toward the bottom-left of the plate work area. + /// Everything already on the plate (excluding movingParts) is treated + /// as stationary obstacles. + /// + public static void Compact(List movingParts, Plate plate) + { + if (movingParts == null || movingParts.Count == 0) + return; + + Push(movingParts, plate, PushDirection.Left); + Push(movingParts, plate, PushDirection.Down); + } + + private static void Push(List movingParts, Plate plate, PushDirection direction) + { + var stationaryParts = plate.Parts + .Where(p => !movingParts.Contains(p)) + .ToList(); + + var stationaryBoxes = new Box[stationaryParts.Count]; + + for (var i = 0; i < stationaryParts.Count; i++) + stationaryBoxes[i] = stationaryParts[i].BoundingBox; + + var stationaryLines = new List[stationaryParts.Count]; + var opposite = Helper.OppositeDirection(direction); + var halfSpacing = plate.PartSpacing / 2; + var isHorizontal = Helper.IsHorizontalDirection(direction); + var workArea = plate.WorkArea(); + + foreach (var moving in movingParts) + { + var distance = double.MaxValue; + var movingBox = moving.BoundingBox; + + // Plate edge distance. + var edgeDist = Helper.EdgeDistance(movingBox, workArea, direction); + if (edgeDist > 0 && edgeDist < distance) + distance = edgeDist; + + List movingLines = null; + + for (var i = 0; i < stationaryBoxes.Length; i++) + { + var gap = Helper.DirectionalGap(movingBox, stationaryBoxes[i], direction); + if (gap < 0 || gap >= distance) + continue; + + var perpOverlap = isHorizontal + ? movingBox.IsHorizontalTo(stationaryBoxes[i], out _) + : movingBox.IsVerticalTo(stationaryBoxes[i], out _); + + if (!perpOverlap) + continue; + + movingLines ??= halfSpacing > 0 + ? Helper.GetOffsetPartLines(moving, halfSpacing, direction, ChordTolerance) + : Helper.GetPartLines(moving, direction, ChordTolerance); + + stationaryLines[i] ??= halfSpacing > 0 + ? Helper.GetOffsetPartLines(stationaryParts[i], halfSpacing, opposite, ChordTolerance) + : Helper.GetPartLines(stationaryParts[i], opposite, ChordTolerance); + + var d = Helper.DirectionalDistance(movingLines, stationaryLines[i], direction); + if (d < distance) + distance = d; + } + + if (distance < double.MaxValue && distance > 0) + { + var offset = Helper.DirectionToOffset(direction, distance); + moving.Offset(offset); + + // Update this part's bounding box in the stationary set for + // subsequent moving parts to collide against correctly. + // (Parts already pushed become obstacles for the next part.) + } + } + } + } +} +``` + +- [ ] **Step 2: Build to verify compilation** + +Run: `dotnet build OpenNest.Engine/OpenNest.Engine.csproj --nologo -v q` +Expected: `Build succeeded. 0 Error(s)` + +- [ ] **Step 3: Commit** + +```bash +git add OpenNest.Engine/Compactor.cs +git commit -m "feat(engine): add Compactor for post-fill gravity compaction" +``` + +--- + +## Chunk 2: Integration + +### Task 4: Integrate FillExact and Compactor into AutoNest (MainForm) + +**Files:** +- Modify: `OpenNest/Forms/MainForm.cs` (RunAutoNest_Click, around lines 797-815) + +- [ ] **Step 1: Replace Fill with FillExact and add Compactor call** + +In `RunAutoNest_Click`, change the Fill call and the block after it (around lines 799-815). Replace: + +```csharp + var parts = await Task.Run(() => + engine.Fill(item, workArea, progress, token)); +``` + +with: + +```csharp + var parts = await Task.Run(() => + engine.FillExact(item, workArea, progress, token)); +``` + +Then after `plate.Parts.AddRange(parts);` and before `ComputeRemainderStrip`, add the compaction call: + +```csharp + plate.Parts.AddRange(parts); + Compactor.Compact(parts, plate); + activeForm.PlateView.Invalidate(); +``` + +- [ ] **Step 2: Build to verify compilation** + +Run: `dotnet build OpenNest.sln --nologo -v q` +Expected: `Build succeeded. 0 Error(s)` + +- [ ] **Step 3: Commit** + +```bash +git add OpenNest/Forms/MainForm.cs +git commit -m "feat(ui): use FillExact + Compactor in AutoNest" +``` + +--- + +### Task 5: Integrate FillExact and Compactor into Console app + +**Files:** +- Modify: `OpenNest.Console/Program.cs` (around lines 346-360) + +- [ ] **Step 1: Replace Fill with FillExact and add Compactor call** + +Change the Fill call (around line 352) from: + +```csharp + var parts = engine.Fill(item, workArea, null, CancellationToken.None); +``` + +to: + +```csharp + var parts = engine.FillExact(item, workArea, null, CancellationToken.None); +``` + +Then after `plate.Parts.AddRange(parts);` add the compaction call: + +```csharp + plate.Parts.AddRange(parts); + Compactor.Compact(parts, plate); + item.Quantity = System.Math.Max(0, item.Quantity - parts.Count); +``` + +- [ ] **Step 2: Build to verify compilation** + +Run: `dotnet build OpenNest.Console/OpenNest.Console.csproj --nologo -v q` +Expected: `Build succeeded. 0 Error(s)` + +- [ ] **Step 3: Commit** + +```bash +git add OpenNest.Console/Program.cs +git commit -m "feat(console): use FillExact + Compactor in --autonest" +``` + +--- + +### Task 6: Integrate FillExact and Compactor into MCP server + +**Files:** +- Modify: `OpenNest.Mcp/Tools/NestingTools.cs` (around lines 255-264) + +- [ ] **Step 1: Replace Fill with FillExact and add Compactor call** + +Change the Fill call (around line 256) from: + +```csharp + var parts = engine.Fill(item, workArea, null, CancellationToken.None); +``` + +to: + +```csharp + var parts = engine.FillExact(item, workArea, null, CancellationToken.None); +``` + +Then after `plate.Parts.AddRange(parts);` add the compaction call: + +```csharp + plate.Parts.AddRange(parts); + Compactor.Compact(parts, plate); + item.Quantity = System.Math.Max(0, item.Quantity - parts.Count); +``` + +- [ ] **Step 2: Build to verify compilation** + +Run: `dotnet build OpenNest.Mcp/OpenNest.Mcp.csproj --nologo -v q` +Expected: `Build succeeded. 0 Error(s)` + +- [ ] **Step 3: Commit** + +```bash +git add OpenNest.Mcp/Tools/NestingTools.cs +git commit -m "feat(mcp): use FillExact in autonest_plate for tighter packing" +``` + +--- + +## Chunk 3: Verification + +### Task 7: End-to-end test via Console + +- [ ] **Step 1: Run AutoNest with qty > 1 and verify tighter packing** + +Run: `dotnet run --project OpenNest.Console/OpenNest.Console.csproj -- --autonest --quantity 10 --no-save "C:\Users\AJ\Desktop\N0312-002.zip"` + +Verify: +- Completes without error +- Parts placed count is reasonable (not 0, not wildly over-placed) +- Utilization is reported + +- [ ] **Step 2: Run with qty=1 to verify fallback path** + +Run: `dotnet run --project OpenNest.Console/OpenNest.Console.csproj -- --autonest --no-save "C:\Users\AJ\Desktop\N0312-002.zip"` + +Verify: +- Completes quickly (qty=1 goes through Pack, no binary search) +- Parts placed > 0 + +- [ ] **Step 3: Build full solution one final time** + +Run: `dotnet build OpenNest.sln --nologo -v q` +Expected: `Build succeeded. 0 Error(s)` diff --git a/docs/superpowers/plans/2026-03-15-helper-decomposition.md b/docs/superpowers/plans/2026-03-15-helper-decomposition.md new file mode 100644 index 0000000..42d4acd --- /dev/null +++ b/docs/superpowers/plans/2026-03-15-helper-decomposition.md @@ -0,0 +1,350 @@ +# Helper Class Decomposition + +> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Break the 1,464-line `Helper` catch-all class into focused, single-responsibility static classes. + +**Architecture:** Extract six logical groups from `Helper` into dedicated classes. Each extraction creates a new file, moves methods, updates all call sites, and verifies with `dotnet build`. The original `Helper.cs` is deleted once empty. No behavioral changes — pure mechanical refactoring. + +**Tech Stack:** .NET 8, C# 12 + +--- + +## File Structure + +| New File | Namespace | Responsibility | Methods Moved | +|----------|-----------|----------------|---------------| +| `OpenNest.Core/Math/Rounding.cs` | `OpenNest.Math` | Factor-based rounding | `RoundDownToNearest`, `RoundUpToNearest`, `RoundToNearest` | +| `OpenNest.Core/Geometry/GeometryOptimizer.cs` | `OpenNest.Geometry` | Merge collinear lines / coradial arcs | `Optimize(arcs)`, `Optimize(lines)`, `TryJoinLines`, `TryJoinArcs`, `GetCollinearLines`, `GetCoradialArs` | +| `OpenNest.Core/Geometry/ShapeBuilder.cs` | `OpenNest.Geometry` | Chain entities into shapes | `GetShapes`, `GetConnected` | +| `OpenNest.Core/Geometry/Intersect.cs` | `OpenNest.Geometry` | All intersection algorithms | 16 `Intersects` overloads | +| `OpenNest.Core/PartGeometry.cs` | `OpenNest` | Convert Parts to line geometry | `GetPartLines` (×2), `GetOffsetPartLines` (×2), `GetDirectionalLines` | +| `OpenNest.Core/Geometry/SpatialQuery.cs` | `OpenNest.Geometry` | Directional distance, ray casting, box queries | `RayEdgeDistance` (×2), `DirectionalDistance` (×3), `FlattenLines`, `OneWayDistance`, `OppositeDirection`, `IsHorizontalDirection`, `EdgeDistance`, `DirectionToOffset`, `DirectionalGap`, `ClosestDistance*` (×4), `GetLargestBox*` (×2) | + +**Files modified (call-site updates):** + +| File | Methods Referenced | +|------|--------------------| +| `OpenNest.Core/Plate.cs` | `RoundUpToNearest` → `Rounding.RoundUpToNearest` | +| `OpenNest.IO/DxfImporter.cs` | `Optimize` → `GeometryOptimizer.Optimize` | +| `OpenNest.Core/Geometry/Shape.cs` | `Optimize` → `GeometryOptimizer.Optimize`, `Intersects` → `Intersect.Intersects` | +| `OpenNest.Core/Drawing.cs` | `GetShapes` → `ShapeBuilder.GetShapes` | +| `OpenNest.Core/Timing.cs` | `GetShapes` → `ShapeBuilder.GetShapes` | +| `OpenNest.Core/Converters/ConvertGeometry.cs` | `GetShapes` → `ShapeBuilder.GetShapes` | +| `OpenNest.Core/Geometry/ShapeProfile.cs` | `GetShapes` → `ShapeBuilder.GetShapes` | +| `OpenNest.Core/Geometry/Arc.cs` | `Intersects` → `Intersect.Intersects` | +| `OpenNest.Core/Geometry/Circle.cs` | `Intersects` → `Intersect.Intersects` | +| `OpenNest.Core/Geometry/Line.cs` | `Intersects` → `Intersect.Intersects` | +| `OpenNest.Core/Geometry/Polygon.cs` | `Intersects` → `Intersect.Intersects` | +| `OpenNest/LayoutPart.cs` | `GetShapes` → `ShapeBuilder.GetShapes` | +| `OpenNest/Actions/ActionSetSequence.cs` | `GetShapes` → `ShapeBuilder.GetShapes` | +| `OpenNest/Actions/ActionSelectArea.cs` | `GetLargestBox*` → `SpatialQuery.GetLargestBox*` | +| `OpenNest/Actions/ActionClone.cs` | `GetLargestBox*` → `SpatialQuery.GetLargestBox*` | +| `OpenNest.Gpu/PartBitmap.cs` | `GetShapes` → `ShapeBuilder.GetShapes` | +| `OpenNest.Gpu/GpuPairEvaluator.cs` | `GetShapes` → `ShapeBuilder.GetShapes` | +| `OpenNest.Engine/RotationAnalysis.cs` | `GetShapes` → `ShapeBuilder.GetShapes` | +| `OpenNest.Engine/BestFit/BestFitFinder.cs` | `GetShapes` → `ShapeBuilder.GetShapes` | +| `OpenNest.Engine/BestFit/PairEvaluator.cs` | `GetShapes` → `ShapeBuilder.GetShapes` | +| `OpenNest.Engine/FillLinear.cs` | `DirectionalDistance`, `OppositeDirection` → `SpatialQuery.*` | +| `OpenNest.Engine/Compactor.cs` | Multiple `Helper.*` → `SpatialQuery.*` + `PartGeometry.*` | +| `OpenNest.Engine/BestFit/RotationSlideStrategy.cs` | Multiple `Helper.*` → `SpatialQuery.*` + `PartGeometry.*` | + +--- + +## Chunk 1: Rounding + GeometryOptimizer + ShapeBuilder + +### Task 1: Extract Rounding to OpenNest.Math + +**Files:** +- Create: `OpenNest.Core/Math/Rounding.cs` +- Modify: `OpenNest.Core/Plate.cs:415-416` +- Delete from: `OpenNest.Core/Helper.cs` (lines 14–45) + +- [ ] **Step 1: Create `Rounding.cs`** + +```csharp +using OpenNest.Math; + +namespace OpenNest.Math +{ + public static class Rounding + { + public static double RoundDownToNearest(double num, double factor) + { + return factor.IsEqualTo(0) ? num : System.Math.Floor(num / factor) * factor; + } + + public static double RoundUpToNearest(double num, double factor) + { + return factor.IsEqualTo(0) ? num : System.Math.Ceiling(num / factor) * factor; + } + + public static double RoundToNearest(double num, double factor) + { + return factor.IsEqualTo(0) ? num : System.Math.Round(num / factor) * factor; + } + } +} +``` + +- [ ] **Step 2: Update call site in `Plate.cs`** + +Replace `Helper.RoundUpToNearest` with `Rounding.RoundUpToNearest`. Add `using OpenNest.Math;` if not present. + +- [ ] **Step 3: Remove three rounding methods from `Helper.cs`** + +Delete lines 14–45 (the three methods and their XML doc comments). + +- [ ] **Step 4: Build and verify** + +Run: `dotnet build OpenNest.sln` +Expected: Build succeeded + +- [ ] **Step 5: Commit** + +``` +refactor: extract Rounding from Helper to OpenNest.Math +``` + +--- + +### Task 2: Extract GeometryOptimizer + +**Files:** +- Create: `OpenNest.Core/Geometry/GeometryOptimizer.cs` +- Modify: `OpenNest.IO/DxfImporter.cs:59-60`, `OpenNest.Core/Geometry/Shape.cs:162-163` +- Delete from: `OpenNest.Core/Helper.cs` (lines 47–237) + +- [ ] **Step 1: Create `GeometryOptimizer.cs`** + +Move these 6 methods (preserving exact code): +- `Optimize(IList)` +- `Optimize(IList)` +- `TryJoinLines` +- `TryJoinArcs` +- `GetCollinearLines` (private extension method) +- `GetCoradialArs` (private extension method) + +Namespace: `OpenNest.Geometry`. Class: `public static class GeometryOptimizer`. + +Required usings: `System`, `System.Collections.Generic`, `System.Threading.Tasks`, `OpenNest.Math`. + +- [ ] **Step 2: Update call sites** + +- `DxfImporter.cs`: `Helper.Optimize(...)` → `GeometryOptimizer.Optimize(...)`. Add `using OpenNest.Geometry;`. +- `Shape.cs`: `Helper.Optimize(...)` → `GeometryOptimizer.Optimize(...)`. Already in `OpenNest.Geometry` namespace — no using needed. + +- [ ] **Step 3: Remove methods from `Helper.cs`** + +- [ ] **Step 4: Build and verify** + +Run: `dotnet build OpenNest.sln` + +- [ ] **Step 5: Commit** + +``` +refactor: extract GeometryOptimizer from Helper +``` + +--- + +### Task 3: Extract ShapeBuilder + +**Files:** +- Create: `OpenNest.Core/Geometry/ShapeBuilder.cs` +- Modify: 11 files (see call-site table above for `GetShapes` callers) +- Delete from: `OpenNest.Core/Helper.cs` (lines 239–378) + +- [ ] **Step 1: Create `ShapeBuilder.cs`** + +Move these 2 methods: +- `GetShapes(IEnumerable)` — public +- `GetConnected(Vector, IEnumerable)` — internal + +Namespace: `OpenNest.Geometry`. Class: `public static class ShapeBuilder`. + +Required usings: `System.Collections.Generic`, `System.Diagnostics`, `OpenNest.Math`. + +- [ ] **Step 2: Update all call sites** + +Replace `Helper.GetShapes` → `ShapeBuilder.GetShapes` in every file. Add `using OpenNest.Geometry;` where the file isn't already in that namespace. + +Files to update: +- `OpenNest.Core/Drawing.cs` +- `OpenNest.Core/Timing.cs` +- `OpenNest.Core/Converters/ConvertGeometry.cs` +- `OpenNest.Core/Geometry/ShapeProfile.cs` (already in namespace) +- `OpenNest/LayoutPart.cs` +- `OpenNest/Actions/ActionSetSequence.cs` +- `OpenNest.Gpu/PartBitmap.cs` +- `OpenNest.Gpu/GpuPairEvaluator.cs` +- `OpenNest.Engine/RotationAnalysis.cs` +- `OpenNest.Engine/BestFit/BestFitFinder.cs` +- `OpenNest.Engine/BestFit/PairEvaluator.cs` + +- [ ] **Step 3: Remove methods from `Helper.cs`** + +- [ ] **Step 4: Build and verify** + +Run: `dotnet build OpenNest.sln` + +- [ ] **Step 5: Commit** + +``` +refactor: extract ShapeBuilder from Helper +``` + +--- + +## Chunk 2: Intersect + PartGeometry + +### Task 4: Extract Intersect + +**Files:** +- Create: `OpenNest.Core/Geometry/Intersect.cs` +- Modify: `Arc.cs`, `Circle.cs`, `Line.cs`, `Shape.cs`, `Polygon.cs` (all in `OpenNest.Core/Geometry/`) +- Delete from: `OpenNest.Core/Helper.cs` (lines 380–742) + +- [ ] **Step 1: Create `Intersect.cs`** + +Move all 16 `Intersects` overloads. Namespace: `OpenNest.Geometry`. Class: `public static class Intersect`. + +All methods keep their existing access modifiers (`internal` for most, none are `public`). + +Required usings: `System.Collections.Generic`, `System.Linq`, `OpenNest.Math`. + +- [ ] **Step 2: Update call sites in geometry types** + +All callers are in the same namespace (`OpenNest.Geometry`) so no using changes needed. Replace `Helper.Intersects` → `Intersect.Intersects` in: +- `Arc.cs` (10 calls) +- `Circle.cs` (10 calls) +- `Line.cs` (8 calls) +- `Shape.cs` (12 calls, including the internal offset usage at line 537) +- `Polygon.cs` (10 calls) + +- [ ] **Step 3: Remove methods from `Helper.cs`** + +- [ ] **Step 4: Build and verify** + +Run: `dotnet build OpenNest.sln` + +- [ ] **Step 5: Commit** + +``` +refactor: extract Intersect from Helper +``` + +--- + +### Task 5: Extract PartGeometry + +**Files:** +- Create: `OpenNest.Core/PartGeometry.cs` +- Modify: `OpenNest.Engine/Compactor.cs`, `OpenNest.Engine/BestFit/RotationSlideStrategy.cs` +- Delete from: `OpenNest.Core/Helper.cs` (lines 744–858) + +- [ ] **Step 1: Create `PartGeometry.cs`** + +Move these 5 methods: +- `GetPartLines(Part, double)` — public +- `GetPartLines(Part, PushDirection, double)` — public +- `GetOffsetPartLines(Part, double, double)` — public +- `GetOffsetPartLines(Part, double, PushDirection, double)` — public +- `GetDirectionalLines(Polygon, PushDirection)` — private + +Namespace: `OpenNest`. Class: `public static class PartGeometry`. + +Required usings: `System.Collections.Generic`, `System.Linq`, `OpenNest.Converters`, `OpenNest.Geometry`. + +- [ ] **Step 2: Update call sites** + +- `Compactor.cs`: `Helper.GetOffsetPartLines` / `Helper.GetPartLines` → `PartGeometry.*` +- `RotationSlideStrategy.cs`: `Helper.GetOffsetPartLines` → `PartGeometry.GetOffsetPartLines` + +- [ ] **Step 3: Remove methods from `Helper.cs`** + +- [ ] **Step 4: Build and verify** + +Run: `dotnet build OpenNest.sln` + +- [ ] **Step 5: Commit** + +``` +refactor: extract PartGeometry from Helper +``` + +--- + +## Chunk 3: SpatialQuery + Cleanup + +### Task 6: Extract SpatialQuery + +**Files:** +- Create: `OpenNest.Core/Geometry/SpatialQuery.cs` +- Modify: `Compactor.cs`, `FillLinear.cs`, `RotationSlideStrategy.cs`, `ActionClone.cs`, `ActionSelectArea.cs` +- Delete from: `OpenNest.Core/Helper.cs` (lines 860–1462, all remaining methods) + +- [ ] **Step 1: Create `SpatialQuery.cs`** + +Move all remaining methods (14 total): +- `RayEdgeDistance(Vector, Line, PushDirection)` — private +- `RayEdgeDistance(double, double, double, double, double, double, PushDirection)` — private, `[AggressiveInlining]` +- `DirectionalDistance(List, List, PushDirection)` — public +- `DirectionalDistance(List, double, double, List, PushDirection)` — public +- `DirectionalDistance((Vector,Vector)[], Vector, (Vector,Vector)[], Vector, PushDirection)` — public +- `FlattenLines(List)` — public +- `OneWayDistance(Vector, (Vector,Vector)[], Vector, PushDirection)` — public +- `OppositeDirection(PushDirection)` — public +- `IsHorizontalDirection(PushDirection)` — public +- `EdgeDistance(Box, Box, PushDirection)` — public +- `DirectionToOffset(PushDirection, double)` — public +- `DirectionalGap(Box, Box, PushDirection)` — public +- `ClosestDistanceLeft/Right/Up/Down` — public (4 methods) +- `GetLargestBoxVertically/Horizontally` — public (2 methods) + +Namespace: `OpenNest.Geometry`. Class: `public static class SpatialQuery`. + +Required usings: `System`, `System.Collections.Generic`, `System.Linq`, `OpenNest.Math`. + +- [ ] **Step 2: Update call sites** + +Replace `Helper.*` → `SpatialQuery.*` and add `using OpenNest.Geometry;` where needed: +- `OpenNest.Engine/Compactor.cs` — `OppositeDirection`, `IsHorizontalDirection`, `EdgeDistance`, `DirectionalGap`, `DirectionalDistance`, `DirectionToOffset` +- `OpenNest.Engine/FillLinear.cs` — `DirectionalDistance`, `OppositeDirection` +- `OpenNest.Engine/BestFit/RotationSlideStrategy.cs` — `FlattenLines`, `OppositeDirection`, `OneWayDistance` +- `OpenNest/Actions/ActionClone.cs` — `GetLargestBoxVertically`, `GetLargestBoxHorizontally` +- `OpenNest/Actions/ActionSelectArea.cs` — `GetLargestBoxHorizontally`, `GetLargestBoxVertically` + +- [ ] **Step 3: Remove methods from `Helper.cs`** + +At this point `Helper.cs` should be empty (just the class wrapper and usings). + +- [ ] **Step 4: Build and verify** + +Run: `dotnet build OpenNest.sln` + +- [ ] **Step 5: Commit** + +``` +refactor: extract SpatialQuery from Helper +``` + +--- + +### Task 7: Delete Helper.cs + +**Files:** +- Delete: `OpenNest.Core/Helper.cs` + +- [ ] **Step 1: Delete the empty `Helper.cs` file** + +- [ ] **Step 2: Build and verify** + +Run: `dotnet build OpenNest.sln` +Expected: Build succeeded with zero errors + +- [ ] **Step 3: Commit** + +``` +refactor: remove empty Helper class +``` diff --git a/docs/superpowers/plans/2026-03-15-strip-nester.md b/docs/superpowers/plans/2026-03-15-strip-nester.md new file mode 100644 index 0000000..e095b07 --- /dev/null +++ b/docs/superpowers/plans/2026-03-15-strip-nester.md @@ -0,0 +1,588 @@ +# Strip Nester Implementation Plan + +> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Implement a strip-based multi-drawing nesting strategy as a `NestEngineBase` subclass that dedicates a tight strip to the largest-area drawing and fills the remnant with remaining drawings. + +**Architecture:** `StripNestEngine` extends `NestEngineBase`, uses `DefaultNestEngine` internally (composition) for individual fills. Registered in `NestEngineRegistry`. For single-item fills, delegates to `DefaultNestEngine`. For multi-drawing nesting, orchestrates the strip+remnant strategy. The MCP `autonest_plate` tool always runs `StripNestEngine` as a competitor alongside the current sequential approach, picking the denser result. + +**Tech Stack:** C# / .NET 8, OpenNest.Engine, OpenNest.Mcp + +**Spec:** `docs/superpowers/specs/2026-03-15-strip-nester-design.md` + +**Depends on:** `docs/superpowers/plans/2026-03-15-abstract-nest-engine.md` (must be implemented first — provides `NestEngineBase`, `DefaultNestEngine`, `NestEngineRegistry`) + +--- + +## Chunk 1: Core StripNestEngine + +### Task 1: Create StripDirection enum + +**Files:** +- Create: `OpenNest.Engine/StripDirection.cs` + +- [ ] **Step 1: Create the enum file** + +```csharp +namespace OpenNest +{ + public enum StripDirection + { + Bottom, + Left + } +} +``` + +- [ ] **Step 2: Build to verify** + +Run: `dotnet build OpenNest.Engine/OpenNest.Engine.csproj` +Expected: Build succeeded + +- [ ] **Step 3: Commit** + +```bash +git add OpenNest.Engine/StripDirection.cs +git commit -m "feat: add StripDirection enum" +``` + +--- + +### Task 2: Create StripNestResult internal class + +**Files:** +- Create: `OpenNest.Engine/StripNestResult.cs` + +- [ ] **Step 1: Create the result class** + +```csharp +using System.Collections.Generic; +using OpenNest.Geometry; + +namespace OpenNest +{ + internal class StripNestResult + { + public List Parts { get; set; } = new(); + public Box StripBox { get; set; } + public Box RemnantBox { get; set; } + public FillScore Score { get; set; } + public StripDirection Direction { get; set; } + } +} +``` + +- [ ] **Step 2: Build to verify** + +Run: `dotnet build OpenNest.Engine/OpenNest.Engine.csproj` +Expected: Build succeeded + +- [ ] **Step 3: Commit** + +```bash +git add OpenNest.Engine/StripNestResult.cs +git commit -m "feat: add StripNestResult internal class" +``` + +--- + +### Task 3: Create StripNestEngine — class skeleton with selection and estimation helpers + +**Files:** +- Create: `OpenNest.Engine/StripNestEngine.cs` + +This task creates the class extending `NestEngineBase`, with `Name`/`Description` overrides, the single-item `Fill` override that delegates to `DefaultNestEngine`, and the helper methods for strip item selection and dimension estimation. The main `Nest` method is added in the next task. + +- [ ] **Step 1: Create StripNestEngine with skeleton and helpers** + +```csharp +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"; + + /// + /// Single-item fill delegates to DefaultNestEngine. + /// The strip strategy adds value for multi-drawing nesting, not single-item fills. + /// + public override List Fill(NestItem item, Box workArea, + IProgress progress, CancellationToken token) + { + var inner = new DefaultNestEngine(Plate); + return inner.Fill(item, workArea, progress, token); + } + + /// + /// Selects the item that consumes the most plate area (bounding box area x quantity). + /// Returns the index into the items list. + /// + private static int SelectStripItemIndex(List 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; + } + + /// + /// 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. + /// + 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); + } + } +} +``` + +- [ ] **Step 2: Build to verify** + +Run: `dotnet build OpenNest.Engine/OpenNest.Engine.csproj` +Expected: Build succeeded + +- [ ] **Step 3: Commit** + +```bash +git add OpenNest.Engine/StripNestEngine.cs +git commit -m "feat: add StripNestEngine skeleton with Fill delegate and estimation helpers" +``` + +--- + +### Task 4: Add the Nest method and TryOrientation + +**Files:** +- Modify: `OpenNest.Engine/StripNestEngine.cs` + +This is the main multi-drawing algorithm: tries both orientations, fills strip + remnant, compares results. Uses `DefaultNestEngine` internally for all fill operations (composition pattern per the abstract engine spec). + +Key detail: The remnant fill shrinks the remnant box after each item fill using `ComputeRemainderWithin` to prevent overlapping placements. + +- [ ] **Step 1: Add Nest, TryOrientation, and ComputeRemainderWithin methods** + +Add these methods to the `StripNestEngine` class, after the `EstimateStripDimension` method: + +```csharp + /// + /// 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. + /// + public List Nest(List items, + IProgress progress, CancellationToken token) + { + if (items == null || items.Count == 0) + return new List(); + + 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, token); + var leftResult = TryOrientation(StripDirection.Left, stripItem, remainderItems, workArea, token); + + // Pick the better result. + if (bottomResult.Score >= leftResult.Score) + return bottomResult.Parts; + + return leftResult.Parts; + } + + private StripNestResult TryOrientation(StripDirection direction, NestItem stripItem, + List remainderItems, Box workArea, 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, null, token); + + if (stripParts == null || stripParts.Count == 0) + return result; + + // Measure actual strip dimension from placed parts. + var placedBox = stripParts.Cast().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, null, token); + + if (trialParts == null || trialParts.Count < targetCount) + break; + + // Same count in a tighter strip — keep going. + bestParts = trialParts; + var trialPlacedBox = trialParts.Cast().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(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(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, shrinking the available area after each. + if (remnantBox.Width > 0 && remnantBox.Length > 0) + { + var currentRemnant = remnantBox; + + foreach (var item in effectiveRemainder) + { + if (token.IsCancellationRequested) + break; + + if (currentRemnant.Width <= 0 || currentRemnant.Length <= 0) + break; + + var remnantInner = new DefaultNestEngine(Plate); + var remnantParts = remnantInner.Fill( + new NestItem { Drawing = item.Drawing, Quantity = item.Quantity }, + currentRemnant, null, token); + + if (remnantParts != null && remnantParts.Count > 0) + { + allParts.AddRange(remnantParts); + + // Shrink remnant to avoid overlap with next item. + var usedBox = remnantParts.Cast().GetBoundingBox(); + currentRemnant = ComputeRemainderWithin(currentRemnant, usedBox, spacing); + } + } + } + + 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; + } + + /// + /// Computes the largest usable remainder within a work area after a portion has been used. + /// Picks whichever is larger: the horizontal strip to the right, or the vertical strip above. + /// + private 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; + } +``` + +- [ ] **Step 2: Build to verify** + +Run: `dotnet build OpenNest.Engine/OpenNest.Engine.csproj` +Expected: Build succeeded + +- [ ] **Step 3: Commit** + +```bash +git add OpenNest.Engine/StripNestEngine.cs +git commit -m "feat: add StripNestEngine.Nest with strip fill, shrink loop, and remnant fill" +``` + +--- + +### Task 5: Register StripNestEngine in NestEngineRegistry + +**Files:** +- Modify: `OpenNest.Engine/NestEngineRegistry.cs` + +- [ ] **Step 1: Add Strip registration** + +In `NestEngineRegistry.cs`, add the strip engine registration in the static constructor, after the Default registration: + +```csharp + Register("Strip", + "Strip-based nesting for mixed-drawing layouts", + plate => new StripNestEngine(plate)); +``` + +- [ ] **Step 2: Build to verify** + +Run: `dotnet build OpenNest.Engine/OpenNest.Engine.csproj` +Expected: Build succeeded + +- [ ] **Step 3: Commit** + +```bash +git add OpenNest.Engine/NestEngineRegistry.cs +git commit -m "feat: register StripNestEngine in NestEngineRegistry" +``` + +--- + +## Chunk 2: MCP Integration + +### Task 6: Integrate StripNestEngine into autonest_plate MCP tool + +**Files:** +- Modify: `OpenNest.Mcp/Tools/NestingTools.cs` + +Run the strip nester alongside the existing sequential approach. Both use side-effect-free fills (4-arg `Fill` returning `List`), then the winner's parts are added to the plate. + +Note: After the abstract engine migration, callsites already use `NestEngineRegistry.Create(plate)`. The `autonest_plate` tool creates a `StripNestEngine` directly for the strip strategy competition (it's always tried, regardless of active engine selection). + +- [ ] **Step 1: Refactor AutoNestPlate to run both strategies** + +In `NestingTools.cs`, replace the fill/pack logic in `AutoNestPlate` (the section after the items list is built) with a strategy competition. + +Replace the fill/pack logic with: + +```csharp + // Strategy 1: Strip nesting + var stripEngine = new StripNestEngine(plate); + var stripResult = stripEngine.Nest(items, null, CancellationToken.None); + var stripScore = FillScore.Compute(stripResult, plate.WorkArea()); + + // Strategy 2: Current sequential fill + var seqResult = SequentialFill(plate, items); + var seqScore = FillScore.Compute(seqResult, plate.WorkArea()); + + // Pick winner and apply to plate. + var winner = stripScore >= seqScore ? stripResult : seqResult; + var winnerName = stripScore >= seqScore ? "strip" : "sequential"; + plate.Parts.AddRange(winner); + var totalPlaced = winner.Count; +``` + +Update the output section: + +```csharp + var sb = new StringBuilder(); + sb.AppendLine($"AutoNest plate {plateIndex} ({winnerName} strategy): {(totalPlaced > 0 ? "success" : "no parts placed")}"); + sb.AppendLine($" Parts placed: {totalPlaced}"); + sb.AppendLine($" Total parts: {plate.Parts.Count}"); + sb.AppendLine($" Utilization: {plate.Utilization():P1}"); + sb.AppendLine($" Strip score: {stripScore.Count} parts, density {stripScore.Density:P1}"); + sb.AppendLine($" Sequential score: {seqScore.Count} parts, density {seqScore.Density:P1}"); + + var groups = plate.Parts.GroupBy(p => p.BaseDrawing.Name); + foreach (var group in groups) + sb.AppendLine($" {group.Key}: {group.Count()}"); + + return sb.ToString(); +``` + +- [ ] **Step 2: Add the SequentialFill helper method** + +Add this private method to `NestingTools`. It mirrors the existing sequential fill phase using side-effect-free fills. + +```csharp + private static List SequentialFill(Plate plate, List items) + { + var fillItems = items + .Where(i => i.Quantity != 1) + .OrderBy(i => i.Priority) + .ThenByDescending(i => i.Drawing.Area) + .ToList(); + + var workArea = plate.WorkArea(); + var allParts = new List(); + + foreach (var item in fillItems) + { + if (item.Quantity == 0 || workArea.Width <= 0 || workArea.Length <= 0) + continue; + + var engine = new DefaultNestEngine(plate); + var parts = engine.Fill( + new NestItem { Drawing = item.Drawing, Quantity = item.Quantity }, + workArea, null, CancellationToken.None); + + if (parts.Count > 0) + { + allParts.AddRange(parts); + var placedBox = parts.Cast().GetBoundingBox(); + workArea = ComputeRemainderWithin(workArea, placedBox, plate.PartSpacing); + } + } + + return allParts; + } +``` + +- [ ] **Step 3: Add required using statement** + +Add `using System.Threading;` to the top of `NestingTools.cs` if not already present. + +- [ ] **Step 4: Build the full solution** + +Run: `dotnet build OpenNest.sln` +Expected: Build succeeded + +- [ ] **Step 5: Commit** + +```bash +git add OpenNest.Mcp/Tools/NestingTools.cs +git commit -m "feat: integrate StripNestEngine into autonest_plate MCP tool" +``` + +--- + +## Chunk 3: Publish and Test + +### Task 7: Publish MCP server and test with real parts + +**Files:** +- No code changes — publish and manual testing + +- [ ] **Step 1: Publish OpenNest.Mcp** + +Run: `dotnet publish OpenNest.Mcp/OpenNest.Mcp.csproj -c Release -o "$USERPROFILE/.claude/mcp/OpenNest.Mcp"` +Expected: Build and publish succeeded + +- [ ] **Step 2: Test with SULLYS parts** + +Using the MCP tools, test the strip nester with the SULLYS-001 and SULLYS-002 parts: + +1. Load the test nest file or import the DXF files +2. Create a 60x120 plate +3. Run `autonest_plate` with both drawings at qty 10 +4. Verify the output reports which strategy won (strip vs sequential) +5. Verify the output shows scores for both strategies +6. Check plate info for part placement and utilization + +- [ ] **Step 3: Compare with current results** + +Verify the strip nester produces a result matching or improving on the target layout from screenshot 190519 (all 20 parts on one 60x120 plate with organized strip arrangement). + +- [ ] **Step 4: Commit any fixes** + +If issues are found during testing, fix and commit with descriptive messages. diff --git a/docs/superpowers/plans/2026-03-16-plate-processor.md b/docs/superpowers/plans/2026-03-16-plate-processor.md new file mode 100644 index 0000000..13c4c38 --- /dev/null +++ b/docs/superpowers/plans/2026-03-16-plate-processor.md @@ -0,0 +1,1760 @@ +# Plate Processor Implementation Plan + +> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build a plate-level orchestrator that sequences parts, assigns per-part lead-ins based on approach direction, and plans safe rapid paths between parts. + +**Architecture:** Three-stage pipeline in OpenNest.Engine — IPartSequencer (cut order) → ContourCuttingStrategy (lead-ins) → IRapidPlanner (safe rapids) — wired by PlateProcessor. Non-destructive: results stored in PlateResult, original Part.Program untouched. + +**Tech Stack:** .NET 8, xUnit (new test project), OpenNest.Core, OpenNest.Engine + +**Spec:** `docs/superpowers/specs/2026-03-15-plate-processor-design.md` + +--- + +## File Structure + +| Action | File | Responsibility | +|--------|------|----------------| +| Create | `OpenNest.Engine.Tests/OpenNest.Engine.Tests.csproj` | xUnit test project | +| Create | `OpenNest.Engine.Tests/TestHelpers.cs` | Shared test helpers (MakePartAt, MakePlate) | +| Modify | `OpenNest.sln` | Add test project to solution | +| Modify | `OpenNest.Core/Part.cs` | Add `HasManualLeadIns` property | +| Create | `OpenNest.Core/CNC/CuttingStrategy/CuttingResult.cs` | Return type for ContourCuttingStrategy.Apply | +| Modify | `OpenNest.Core/CNC/CuttingStrategy/ContourCuttingStrategy.cs` | Change Apply signature to accept Vector approachPoint | +| Create | `OpenNest.Engine/Sequencing/IPartSequencer.cs` | Interface + SequencedPart struct | +| Create | `OpenNest.Engine/Sequencing/PartSequencerFactory.cs` | Maps SequenceMethod to IPartSequencer | +| Create | `OpenNest.Engine/Sequencing/RightSideSequencer.cs` | Sort by X descending | +| Create | `OpenNest.Engine/Sequencing/LeftSideSequencer.cs` | Sort by X ascending | +| Create | `OpenNest.Engine/Sequencing/BottomSideSequencer.cs` | Sort by Y ascending | +| Create | `OpenNest.Engine/Sequencing/EdgeStartSequencer.cs` | Sort by distance from nearest plate edge | +| Create | `OpenNest.Engine/Sequencing/PlateHelper.cs` | Shared exit point calculation | +| Create | `OpenNest.Engine/Sequencing/LeastCodeSequencer.cs` | Nearest-neighbor + 2-opt | +| Create | `OpenNest.Engine/Sequencing/AdvancedSequencer.cs` | Row/column grouping with serpentine ordering | +| Create | `OpenNest.Engine/RapidPlanning/IRapidPlanner.cs` | Interface | +| Create | `OpenNest.Engine/RapidPlanning/RapidPath.cs` | Result struct | +| Create | `OpenNest.Engine/RapidPlanning/SafeHeightRapidPlanner.cs` | Always head-up | +| Create | `OpenNest.Engine/RapidPlanning/DirectRapidPlanner.cs` | Head-down if clear, head-up if blocked | +| Create | `OpenNest.Engine/PlateProcessor.cs` | Orchestrator | +| Create | `OpenNest.Engine/PlateResult.cs` | Result types (PlateResult, ProcessedPart) | + +--- + +## Chunk 1: Foundation + +### Task 1: Create xUnit test project + +**Files:** +- Create: `OpenNest.Engine.Tests/OpenNest.Engine.Tests.csproj` +- Modify: `OpenNest.sln` + +- [ ] **Step 1: Create the test project** + +```bash +cd C:/Users/AJ/Desktop/Projects/OpenNest +dotnet new xunit -n OpenNest.Engine.Tests --framework net8.0-windows +dotnet sln add OpenNest.Engine.Tests/OpenNest.Engine.Tests.csproj +dotnet add OpenNest.Engine.Tests/OpenNest.Engine.Tests.csproj reference OpenNest.Core/OpenNest.Core.csproj +dotnet add OpenNest.Engine.Tests/OpenNest.Engine.Tests.csproj reference OpenNest.Engine/OpenNest.Engine.csproj +``` + +- [ ] **Step 2: Verify the project builds** + +Run: `dotnet build OpenNest.Engine.Tests/OpenNest.Engine.Tests.csproj` +Expected: Build succeeded + +- [ ] **Step 3: Delete the generated UnitTest1.cs** + +Delete `OpenNest.Engine.Tests/UnitTest1.cs` — we'll create our own test files. + +- [ ] **Step 4: Commit** + +```bash +git add OpenNest.Engine.Tests/ OpenNest.sln +git commit -m "chore: add OpenNest.Engine.Tests xUnit project" +``` + +--- + +### Task 2: Shared test helper + +**Files:** +- Create: `OpenNest.Engine.Tests/TestHelpers.cs` + +- [ ] **Step 1: Create TestHelpers.cs** + +This helper is used by nearly every test in the plan. Creates simple 1x1 or 2x2 square parts at known positions. + +```csharp +using OpenNest.CNC; +using OpenNest.Geometry; + +namespace OpenNest.Engine.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; + } +} +``` + +- [ ] **Step 2: Build to verify** + +Run: `dotnet build OpenNest.Engine.Tests/OpenNest.Engine.Tests.csproj` +Expected: Build succeeded + +- [ ] **Step 3: Commit** + +```bash +git add OpenNest.Engine.Tests/TestHelpers.cs +git commit -m "chore: add shared test helpers for Engine tests" +``` + +--- + +### Task 3: CuttingResult struct + +**Files:** +- Create: `OpenNest.Core/CNC/CuttingStrategy/CuttingResult.cs` +- Test: `OpenNest.Engine.Tests/CuttingResultTests.cs` + +- [ ] **Step 1: Write the test** + +```csharp +using OpenNest.CNC; +using OpenNest.CNC.CuttingStrategy; +using OpenNest.Geometry; +using Xunit; + +namespace OpenNest.Engine.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); + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `dotnet test OpenNest.Engine.Tests --filter CuttingResult_StoresValues -v n` +Expected: FAIL — `CuttingResult` type does not exist + +- [ ] **Step 3: Create CuttingResult.cs** + +```csharp +using OpenNest.CNC; +using OpenNest.Geometry; + +namespace OpenNest.CNC.CuttingStrategy +{ + public readonly struct CuttingResult + { + public Program Program { get; init; } + public Vector LastCutPoint { get; init; } + } +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `dotnet test OpenNest.Engine.Tests --filter CuttingResult_StoresValues -v n` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add OpenNest.Core/CNC/CuttingStrategy/CuttingResult.cs OpenNest.Engine.Tests/CuttingResultTests.cs +git commit -m "feat: add CuttingResult struct" +``` + +--- + +### Task 4: Part.HasManualLeadIns flag + +**Files:** +- Modify: `OpenNest.Core/Part.cs` +- Test: `OpenNest.Engine.Tests/PartFlagTests.cs` + +- [ ] **Step 1: Write the test** + +```csharp +using OpenNest.CNC; +using OpenNest.Geometry; +using Xunit; + +namespace OpenNest.Engine.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); + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `dotnet test OpenNest.Engine.Tests --filter HasManualLeadIns -v n` +Expected: FAIL — `HasManualLeadIns` property does not exist + +- [ ] **Step 3: Add the property to Part.cs** + +In `OpenNest.Core/Part.cs`, after the `Program` property (line 52), add: + +```csharp +public bool HasManualLeadIns { get; set; } +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `dotnet test OpenNest.Engine.Tests --filter HasManualLeadIns -v n` +Expected: 2 tests PASS + +- [ ] **Step 5: Commit** + +```bash +git add OpenNest.Core/Part.cs OpenNest.Engine.Tests/PartFlagTests.cs +git commit -m "feat: add Part.HasManualLeadIns flag" +``` + +--- + +### Task 5: ContourCuttingStrategy signature change + +**Files:** +- Modify: `OpenNest.Core/CNC/CuttingStrategy/ContourCuttingStrategy.cs` + +This changes the existing `Apply` method signature and return type. No new tests needed — this modifies an existing class that currently has no callers (it was recently built). + +- [ ] **Step 1: Change the Apply signature** + +In `ContourCuttingStrategy.cs`, change line 10: + +```csharp +// Before: +public Program Apply(Program partProgram, Plate plate) +// After: +public CuttingResult Apply(Program partProgram, Vector approachPoint) +``` + +- [ ] **Step 2: Replace GetExitPoint usage with approachPoint** + +Replace line 12: +```csharp +// Before: +var exitPoint = GetExitPoint(plate); +// After: +var exitPoint = approachPoint; +``` + +- [ ] **Step 3: Delete the GetExitPoint method** + +Delete lines 66-79 (the `GetExitPoint(Plate plate)` method). This logic moves to `PlateProcessor` later. + +- [ ] **Step 4: Track the last cut point and return CuttingResult** + +`perimeterPt` is declared inside a bare block (lines 48-61) and goes out of scope at the closing brace. Declare a `lastCutPoint` variable before the block and assign it inside. + +Before the `// Perimeter last` block (before line 48), add: +```csharp +var lastCutPoint = exitPoint; +``` + +Inside the `// Perimeter last` block, after the perimeter point is computed (after line 49), add: +```csharp +lastCutPoint = perimeterPt; +``` + +Replace line 63 (`return result;`): +```csharp +return new CuttingResult +{ + Program = result, + LastCutPoint = lastCutPoint +}; +``` + +- [ ] **Step 5: Remove the unused Plate using directive if needed** + +Check if `Plate` is still referenced. The `using OpenNest.Geometry` is still needed for `Vector`. + +- [ ] **Step 6: Build to verify** + +Run: `dotnet build OpenNest.Core/OpenNest.Core.csproj` +Expected: Build succeeded + +- [ ] **Step 7: Commit** + +```bash +git add OpenNest.Core/CNC/CuttingStrategy/ContourCuttingStrategy.cs +git commit -m "refactor: change ContourCuttingStrategy.Apply to accept approachPoint" +``` + +--- + +## Chunk 2: Part Sequencing + +### Task 6: IPartSequencer interface and SequencedPart + +**Files:** +- Create: `OpenNest.Engine/Sequencing/IPartSequencer.cs` + +- [ ] **Step 1: Create the interface file** + +```csharp +using System.Collections.Generic; + +namespace OpenNest.Engine.Sequencing +{ + public readonly struct SequencedPart + { + public Part Part { get; init; } + } + + public interface IPartSequencer + { + List Sequence(IReadOnlyList parts, Plate plate); + } +} +``` + +- [ ] **Step 2: Build to verify** + +Run: `dotnet build OpenNest.Engine/OpenNest.Engine.csproj` +Expected: Build succeeded + +- [ ] **Step 3: Commit** + +```bash +git add OpenNest.Engine/Sequencing/ +git commit -m "feat: add IPartSequencer interface and SequencedPart" +``` + +--- + +### Task 7: Directional sequencers (RightSide, LeftSide, BottomSide) + +**Files:** +- Create: `OpenNest.Engine/Sequencing/RightSideSequencer.cs` +- Create: `OpenNest.Engine/Sequencing/LeftSideSequencer.cs` +- Create: `OpenNest.Engine/Sequencing/BottomSideSequencer.cs` +- Test: `OpenNest.Engine.Tests/Sequencing/DirectionalSequencerTests.cs` + +- [ ] **Step 1: Write the tests** + +Create a helper method to build simple parts at known positions, then test all three directional sequencers. + +```csharp +using System.Collections.Generic; +using OpenNest.CNC; +using OpenNest.Engine.Sequencing; +using OpenNest.Geometry; +using Xunit; + +namespace OpenNest.Engine.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); + + // Same X, tie broken by Y — lower Y first for RightSide + Assert.Same(b, result[0].Part); + Assert.Same(a, result[1].Part); + } +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `dotnet test OpenNest.Engine.Tests --filter DirectionalSequencerTests -v n` +Expected: FAIL — sequencer classes don't exist + +- [ ] **Step 3: Implement RightSideSequencer** + +```csharp +using System.Collections.Generic; +using System.Linq; + +namespace OpenNest.Engine.Sequencing +{ + public class RightSideSequencer : IPartSequencer + { + public List Sequence(IReadOnlyList parts, Plate plate) + { + return parts + .OrderByDescending(p => p.BoundingBox.Center.X) + .ThenBy(p => p.BoundingBox.Center.Y) + .Select(p => new SequencedPart { Part = p }) + .ToList(); + } + } +} +``` + +- [ ] **Step 4: Implement LeftSideSequencer** + +```csharp +using System.Collections.Generic; +using System.Linq; + +namespace OpenNest.Engine.Sequencing +{ + public class LeftSideSequencer : IPartSequencer + { + public List Sequence(IReadOnlyList parts, Plate plate) + { + return parts + .OrderBy(p => p.BoundingBox.Center.X) + .ThenBy(p => p.BoundingBox.Center.Y) + .Select(p => new SequencedPart { Part = p }) + .ToList(); + } + } +} +``` + +- [ ] **Step 5: Implement BottomSideSequencer** + +```csharp +using System.Collections.Generic; +using System.Linq; + +namespace OpenNest.Engine.Sequencing +{ + public class BottomSideSequencer : IPartSequencer + { + public List Sequence(IReadOnlyList parts, Plate plate) + { + return parts + .OrderBy(p => p.BoundingBox.Center.Y) + .ThenBy(p => p.BoundingBox.Center.X) + .Select(p => new SequencedPart { Part = p }) + .ToList(); + } + } +} +``` + +- [ ] **Step 6: Run tests to verify they pass** + +Run: `dotnet test OpenNest.Engine.Tests --filter DirectionalSequencerTests -v n` +Expected: 4 tests PASS + +- [ ] **Step 7: Commit** + +```bash +git add OpenNest.Engine/Sequencing/ OpenNest.Engine.Tests/Sequencing/ +git commit -m "feat: add directional part sequencers (RightSide, LeftSide, BottomSide)" +``` + +--- + +### Task 8: EdgeStartSequencer + +**Files:** +- Create: `OpenNest.Engine/Sequencing/EdgeStartSequencer.cs` +- Test: `OpenNest.Engine.Tests/Sequencing/EdgeStartSequencerTests.cs` + +- [ ] **Step 1: Write the test** + +```csharp +using System.Collections.Generic; +using OpenNest.CNC; +using OpenNest.Engine.Sequencing; +using OpenNest.Geometry; +using Xunit; + +namespace OpenNest.Engine.Tests.Sequencing; + +public class EdgeStartSequencerTests +{ + private static Part MakePartAt(double x, double y) => TestHelpers.MakePartAt(x, y); + + [Fact] + public void SortsByDistanceFromNearestEdge() + { + // Plate is 60x120, Q1 (origin at 0,0) + var plate = new Plate(60, 120); + var edgePart = MakePartAt(1, 1); // 1 unit from left and bottom edges + var centerPart = MakePartAt(25, 55); // ~25 from nearest edge + var midPart = MakePartAt(10, 10); // 10 from left and bottom edges + 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); + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `dotnet test OpenNest.Engine.Tests --filter EdgeStartSequencerTests -v n` +Expected: FAIL + +- [ ] **Step 3: Implement EdgeStartSequencer** + +```csharp +using System.Collections.Generic; +using System.Linq; +using OpenNest.Geometry; + +namespace OpenNest.Engine.Sequencing +{ + public class EdgeStartSequencer : IPartSequencer + { + public List Sequence(IReadOnlyList parts, Plate plate) + { + var plateBox = plate.BoundingBox(false); + + return parts + .OrderBy(p => MinEdgeDistance(p.BoundingBox, plateBox)) + .ThenBy(p => p.BoundingBox.Center.X) + .Select(p => new SequencedPart { Part = p }) + .ToList(); + } + + private static double MinEdgeDistance(Box partBox, Box plateBox) + { + var center = partBox.Center; + var distLeft = System.Math.Abs(center.X - plateBox.Left); + var distRight = System.Math.Abs(center.X - plateBox.Right); + var distBottom = System.Math.Abs(center.Y - plateBox.Bottom); + var distTop = System.Math.Abs(center.Y - plateBox.Top); + return System.Math.Min(System.Math.Min(distLeft, distRight), + System.Math.Min(distBottom, distTop)); + } + } +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `dotnet test OpenNest.Engine.Tests --filter EdgeStartSequencerTests -v n` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add OpenNest.Engine/Sequencing/EdgeStartSequencer.cs OpenNest.Engine.Tests/Sequencing/EdgeStartSequencerTests.cs +git commit -m "feat: add EdgeStartSequencer" +``` + +--- + +### Task 9: LeastCodeSequencer (nearest-neighbor + 2-opt) + +**Files:** +- Create: `OpenNest.Engine/Sequencing/LeastCodeSequencer.cs` +- Test: `OpenNest.Engine.Tests/Sequencing/LeastCodeSequencerTests.cs` + +- [ ] **Step 1: Write the tests** + +```csharp +using System.Collections.Generic; +using OpenNest.CNC; +using OpenNest.Engine.Sequencing; +using OpenNest.Geometry; +using Xunit; + +namespace OpenNest.Engine.Tests.Sequencing; + +public class LeastCodeSequencerTests +{ + private static Part MakePartAt(double x, double y) => TestHelpers.MakePartAt(x, y); + + [Fact] + public void NearestNeighbor_FromExitPoint() + { + // Q1 plate: exit point is (60, 120) (top-right corner) + 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 (60,120), 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() + { + // Create a scenario where nearest-neighbor produces a crossing + // that 2-opt should fix + var plate = new Plate(100, 100); + // Exit point at (100, 100) for Q1 + // Parts arranged so NN would cross but 2-opt should uncross + var a = MakePartAt(90, 90); // nearest to exit + var b = MakePartAt(10, 80); // NN picks this 2nd (closest to a) + var c = MakePartAt(80, 10); // NN picks this 3rd — crosses! + var d = MakePartAt(5, 5); // last + 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); + + // Just verify all parts present and result is deterministic + Assert.Equal(4, result.Count); + } +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `dotnet test OpenNest.Engine.Tests --filter LeastCodeSequencerTests -v n` +Expected: FAIL + +- [ ] **Step 3: Implement LeastCodeSequencer** + +The exit point logic (opposite corner from quadrant origin) is needed here and will also be used by `PlateProcessor`. Put it as a static helper. + +```csharp +using System.Collections.Generic; +using System.Linq; +using OpenNest.Geometry; + +namespace OpenNest.Engine.Sequencing +{ + public class LeastCodeSequencer : IPartSequencer + { + private readonly int _maxIterations; + + public LeastCodeSequencer(int maxIterations = 100) + { + _maxIterations = maxIterations; + } + + public List Sequence(IReadOnlyList parts, Plate plate) + { + if (parts.Count == 0) + return new List(); + + var exitPoint = PlateHelper.GetExitPoint(plate); + var order = NearestNeighbor(parts, exitPoint); + TwoOpt(order, exitPoint); + + return order.Select(p => new SequencedPart { Part = p }).ToList(); + } + + private static List NearestNeighbor(IReadOnlyList parts, Vector startPoint) + { + var remaining = new List(parts); + var ordered = new List(); + var currentPoint = startPoint; + + while (remaining.Count > 0) + { + var nearest = remaining[0]; + var nearestDist = nearest.BoundingBox.Center.DistanceTo(currentPoint); + + for (var i = 1; i < remaining.Count; i++) + { + var dist = remaining[i].BoundingBox.Center.DistanceTo(currentPoint); + if (dist < nearestDist) + { + nearest = remaining[i]; + nearestDist = dist; + } + } + + ordered.Add(nearest); + remaining.Remove(nearest); + currentPoint = nearest.BoundingBox.Center; + } + + return ordered; + } + + private void TwoOpt(List order, Vector startPoint) + { + var improved = true; + var iterations = 0; + + while (improved && iterations < _maxIterations) + { + improved = false; + iterations++; + + for (var i = 0; i < order.Count - 1; i++) + { + for (var j = i + 1; j < order.Count; j++) + { + var delta = TwoOptDelta(order, startPoint, i, j); + if (delta < -OpenNest.Math.Tolerance.Epsilon) + { + Reverse(order, i, j); + improved = true; + } + } + } + } + } + + private static double TwoOptDelta(List order, Vector startPoint, int i, int j) + { + var prevI = i == 0 ? startPoint : order[i - 1].BoundingBox.Center; + var ci = order[i].BoundingBox.Center; + var cj = order[j].BoundingBox.Center; + var nextJ = j + 1 < order.Count ? order[j + 1].BoundingBox.Center : (Vector?)null; + + var oldDist = prevI.DistanceTo(ci); + var newDist = prevI.DistanceTo(cj); + + if (nextJ.HasValue) + { + oldDist += cj.DistanceTo(nextJ.Value); + newDist += ci.DistanceTo(nextJ.Value); + } + + return newDist - oldDist; + } + + private static void Reverse(List list, int start, int end) + { + while (start < end) + { + (list[start], list[end]) = (list[end], list[start]); + start++; + end--; + } + } + } +} +``` + +- [ ] **Step 4: Create PlateHelper for shared exit point logic** + +```csharp +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) + }; + } + } +} +``` + +- [ ] **Step 5: Run tests to verify they pass** + +Run: `dotnet test OpenNest.Engine.Tests --filter LeastCodeSequencerTests -v n` +Expected: 3 tests PASS + +- [ ] **Step 6: Commit** + +```bash +git add OpenNest.Engine/Sequencing/ OpenNest.Engine.Tests/Sequencing/ +git commit -m "feat: add LeastCodeSequencer with nearest-neighbor and 2-opt" +``` + +--- + +### Task 10: AdvancedSequencer + +**Files:** +- Create: `OpenNest.Engine/Sequencing/AdvancedSequencer.cs` +- Test: `OpenNest.Engine.Tests/Sequencing/AdvancedSequencerTests.cs` + +- [ ] **Step 1: Write the tests** + +```csharp +using System.Collections.Generic; +using OpenNest.CNC; +using OpenNest.CNC.CuttingStrategy; +using OpenNest.Engine.Sequencing; +using OpenNest.Geometry; +using Xunit; + +namespace OpenNest.Engine.Tests.Sequencing; + +public class AdvancedSequencerTests +{ + private static Part MakePartAt(double x, double y) => TestHelpers.MakePartAt(x, y); + + [Fact] + public void GroupsIntoRows_NoAlternate() + { + // Two rows of parts with clear Y separation + // Q1 plate 100x100: exit point (100, 100), so leftToRight starts true + 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); + + // No alternation: both rows left-to-right (X ascending) + 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() + { + // Q1 plate 100x100: exit point (100, 100), so leftToRight starts true + 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); + + // Row 1: left-to-right (X ascending) + Assert.Same(r1Left, result[0].Part); + Assert.Same(r1Right, result[1].Part); + // Row 2: right-to-left (X descending, alternated) + Assert.Same(r2Right, result[2].Part); + Assert.Same(r2Left, result[3].Part); + } +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `dotnet test OpenNest.Engine.Tests --filter AdvancedSequencerTests -v n` +Expected: FAIL + +- [ ] **Step 3: Implement AdvancedSequencer** + +```csharp +using System.Collections.Generic; +using System.Linq; +using OpenNest.CNC.CuttingStrategy; +using OpenNest.Geometry; + +namespace OpenNest.Engine.Sequencing +{ + public class AdvancedSequencer : IPartSequencer + { + private readonly SequenceParameters _parameters; + + public AdvancedSequencer(SequenceParameters parameters) + { + _parameters = parameters; + } + + public List Sequence(IReadOnlyList parts, Plate plate) + { + if (parts.Count == 0) + return new List(); + + var rows = GroupIntoRows(parts); + var exitPoint = PlateHelper.GetExitPoint(plate); + + // Sort rows by Y (bottom to top for Q1) + rows.Sort((a, b) => a[0].BoundingBox.Center.Y.CompareTo(b[0].BoundingBox.Center.Y)); + + var result = new List(); + var leftToRight = exitPoint.X > plate.Size.Width * 0.5; + + for (var r = 0; r < rows.Count; r++) + { + var row = rows[r]; + + if (leftToRight) + row.Sort((a, b) => a.BoundingBox.Center.X.CompareTo(b.BoundingBox.Center.X)); + else + row.Sort((a, b) => b.BoundingBox.Center.X.CompareTo(a.BoundingBox.Center.X)); + + foreach (var part in row) + result.Add(new SequencedPart { Part = part }); + + if (_parameters.AlternateRowsColumns) + leftToRight = !leftToRight; + } + + return result; + } + + private List> GroupIntoRows(IReadOnlyList parts) + { + var sorted = parts.OrderBy(p => p.BoundingBox.Center.Y).ToList(); + var rows = new List>(); + var currentRow = new List { sorted[0] }; + + for (var i = 1; i < sorted.Count; i++) + { + var prevY = sorted[i - 1].BoundingBox.Center.Y; + var currY = sorted[i].BoundingBox.Center.Y; + + if (currY - prevY > _parameters.MinDistanceBetweenRowsColumns) + { + rows.Add(currentRow); + currentRow = new List(); + } + + currentRow.Add(sorted[i]); + } + + rows.Add(currentRow); + return rows; + } + } +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `dotnet test OpenNest.Engine.Tests --filter AdvancedSequencerTests -v n` +Expected: 2 tests PASS + +- [ ] **Step 5: Commit** + +```bash +git add OpenNest.Engine/Sequencing/AdvancedSequencer.cs OpenNest.Engine.Tests/Sequencing/AdvancedSequencerTests.cs +git commit -m "feat: add AdvancedSequencer with row grouping and serpentine" +``` + +--- + +### Task 11: PartSequencerFactory + +**Files:** +- Create: `OpenNest.Engine/Sequencing/PartSequencerFactory.cs` +- Test: `OpenNest.Engine.Tests/Sequencing/PartSequencerFactoryTests.cs` + +- [ ] **Step 1: Write the tests** + +```csharp +using System; +using OpenNest.CNC.CuttingStrategy; +using OpenNest.Engine.Sequencing; +using Xunit; + +namespace OpenNest.Engine.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(() => PartSequencerFactory.Create(parameters)); + } +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `dotnet test OpenNest.Engine.Tests --filter PartSequencerFactoryTests -v n` +Expected: FAIL + +- [ ] **Step 3: Implement PartSequencerFactory** + +```csharp +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($"SequenceMethod {parameters.Method} is not supported") + }; + } + } +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `dotnet test OpenNest.Engine.Tests --filter PartSequencerFactoryTests -v n` +Expected: 7 tests PASS + +- [ ] **Step 5: Commit** + +```bash +git add OpenNest.Engine/Sequencing/PartSequencerFactory.cs OpenNest.Engine.Tests/Sequencing/PartSequencerFactoryTests.cs +git commit -m "feat: add PartSequencerFactory" +``` + +--- + +## Chunk 3: Rapid Planning and Orchestrator + +### Task 12: IRapidPlanner, RapidPath, and SafeHeightRapidPlanner + +**Files:** +- Create: `OpenNest.Engine/RapidPlanning/IRapidPlanner.cs` +- Create: `OpenNest.Engine/RapidPlanning/RapidPath.cs` +- Create: `OpenNest.Engine/RapidPlanning/SafeHeightRapidPlanner.cs` +- Test: `OpenNest.Engine.Tests/RapidPlanning/SafeHeightRapidPlannerTests.cs` + +- [ ] **Step 1: Write the test** + +```csharp +using System.Collections.Generic; +using OpenNest.Engine.RapidPlanning; +using OpenNest.Geometry; +using Xunit; + +namespace OpenNest.Engine.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(); + + 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); + + // Add a cut area (doesn't matter — always head up) + var shape = new Shape(); + shape.Entities.Add(new Line(new Vector(5, 0), new Vector(5, 20))); + var cutAreas = new List { shape }; + + var result = planner.Plan(from, to, cutAreas); + + Assert.True(result.HeadUp); + } +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `dotnet test OpenNest.Engine.Tests --filter SafeHeightRapidPlannerTests -v n` +Expected: FAIL + +- [ ] **Step 3: Create IRapidPlanner.cs** + +```csharp +using System.Collections.Generic; +using OpenNest.Geometry; + +namespace OpenNest.Engine.RapidPlanning +{ + public interface IRapidPlanner + { + RapidPath Plan(Vector from, Vector to, IReadOnlyList cutAreas); + } +} +``` + +- [ ] **Step 4: Create RapidPath.cs** + +```csharp +using System.Collections.Generic; +using OpenNest.Geometry; + +namespace OpenNest.Engine.RapidPlanning +{ + public readonly struct RapidPath + { + public bool HeadUp { get; init; } + public List Waypoints { get; init; } + } +} +``` + +- [ ] **Step 5: Create SafeHeightRapidPlanner.cs** + +```csharp +using System.Collections.Generic; +using OpenNest.Geometry; + +namespace OpenNest.Engine.RapidPlanning +{ + public class SafeHeightRapidPlanner : IRapidPlanner + { + public RapidPath Plan(Vector from, Vector to, IReadOnlyList cutAreas) + { + return new RapidPath + { + HeadUp = true, + Waypoints = new List() + }; + } + } +} +``` + +- [ ] **Step 6: Run tests to verify they pass** + +Run: `dotnet test OpenNest.Engine.Tests --filter SafeHeightRapidPlannerTests -v n` +Expected: 2 tests PASS + +- [ ] **Step 7: Commit** + +```bash +git add OpenNest.Engine/RapidPlanning/ OpenNest.Engine.Tests/RapidPlanning/ +git commit -m "feat: add IRapidPlanner, RapidPath, and SafeHeightRapidPlanner" +``` + +--- + +### Task 13: DirectRapidPlanner + +**Files:** +- Create: `OpenNest.Engine/RapidPlanning/DirectRapidPlanner.cs` +- Test: `OpenNest.Engine.Tests/RapidPlanning/DirectRapidPlannerTests.cs` + +- [ ] **Step 1: Write the tests** + +```csharp +using System.Collections.Generic; +using OpenNest.Engine.RapidPlanning; +using OpenNest.Geometry; +using Xunit; + +namespace OpenNest.Engine.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()); + + Assert.False(result.HeadUp); + Assert.Empty(result.Waypoints); + } + + [Fact] + public void ClearPath_ReturnsHeadDown() + { + var planner = new DirectRapidPlanner(); + + // Cut area is off to the side — path doesn't cross it + 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 { cutArea }); + + Assert.False(result.HeadUp); + } + + [Fact] + public void BlockedPath_ReturnsHeadUp() + { + var planner = new DirectRapidPlanner(); + + // Cut area directly between from and to + 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 { cutArea }); + + Assert.True(result.HeadUp); + Assert.Empty(result.Waypoints); + } +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `dotnet test OpenNest.Engine.Tests --filter DirectRapidPlannerTests -v n` +Expected: FAIL + +- [ ] **Step 3: Implement DirectRapidPlanner** + +Uses `Shape.Intersects(Line)` — a public method on `Shape` that delegates to `Intersect.Intersects(Line, Shape, out pts)`. + +```csharp +using System.Collections.Generic; +using OpenNest.Geometry; + +namespace OpenNest.Engine.RapidPlanning +{ + public class DirectRapidPlanner : IRapidPlanner + { + public RapidPath Plan(Vector from, Vector to, IReadOnlyList cutAreas) + { + var travelLine = new Line(from, to); + + foreach (var cutArea in cutAreas) + { + if (cutArea.Intersects(travelLine)) + { + return new RapidPath + { + HeadUp = true, + Waypoints = new List() + }; + } + } + + return new RapidPath + { + HeadUp = false, + Waypoints = new List() + }; + } + } +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `dotnet test OpenNest.Engine.Tests --filter DirectRapidPlannerTests -v n` +Expected: 3 tests PASS + +- [ ] **Step 5: Commit** + +```bash +git add OpenNest.Engine/RapidPlanning/DirectRapidPlanner.cs OpenNest.Engine.Tests/RapidPlanning/DirectRapidPlannerTests.cs +git commit -m "feat: add DirectRapidPlanner with line-shape intersection check" +``` + +--- + +### Task 14: PlateResult and ProcessedPart + +**Files:** +- Create: `OpenNest.Engine/PlateResult.cs` + +- [ ] **Step 1: Create PlateResult.cs** + +```csharp +using System.Collections.Generic; +using OpenNest.CNC; +using OpenNest.Engine.RapidPlanning; + +namespace OpenNest.Engine +{ + public class PlateResult + { + public List Parts { get; init; } + } + + public readonly struct ProcessedPart + { + public Part Part { get; init; } + public Program ProcessedProgram { get; init; } + public RapidPath RapidPath { get; init; } + } +} +``` + +- [ ] **Step 2: Build to verify** + +Run: `dotnet build OpenNest.Engine/OpenNest.Engine.csproj` +Expected: Build succeeded + +- [ ] **Step 3: Commit** + +```bash +git add OpenNest.Engine/PlateResult.cs +git commit -m "feat: add PlateResult and ProcessedPart" +``` + +--- + +### Task 15: PlateProcessor orchestrator + +**Files:** +- Create: `OpenNest.Engine/PlateProcessor.cs` +- Test: `OpenNest.Engine.Tests/PlateProcessorTests.cs` + +- [ ] **Step 1: Write the tests** + +```csharp +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; +using Xunit; + +namespace OpenNest.Engine.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); + + // RightSide sorts X descending — right part first + 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); + + // Part program should be passed through unchanged + 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); + + // Part.Program should be untouched + 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(), + // No CuttingStrategy set + 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); + } +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `dotnet test OpenNest.Engine.Tests --filter PlateProcessorTests -v n` +Expected: FAIL — `PlateProcessor` class doesn't exist + +- [ ] **Step 3: Implement PlateProcessor** + +```csharp +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 ordered = Sequencer.Sequence(plate.Parts.ToList(), plate); + + var results = new List(); + var cutAreas = new List(); + var currentPoint = PlateHelper.GetExitPoint(plate); + + foreach (var sequenced in ordered) + { + var part = sequenced.Part; + var localApproach = ToPartLocal(currentPoint, part); + + CuttingResult cutResult; + if (!part.HasManualLeadIns && CuttingStrategy != null) + { + cutResult = CuttingStrategy.Apply(part.Program, localApproach); + } + else + { + cutResult = new CuttingResult + { + Program = part.Program, + LastCutPoint = GetProgramEndPoint(part.Program) + }; + } + + var piercePoint = ToPlateSpace(GetProgramStartPoint(cutResult.Program), part); + var rapid = RapidPlanner.Plan(currentPoint, piercePoint, cutAreas); + + results.Add(new ProcessedPart + { + Part = part, + ProcessedProgram = cutResult.Program, + RapidPath = rapid + }); + + var perimeter = GetPartPerimeter(part); + if (perimeter != null) + cutAreas.Add(perimeter); + + currentPoint = ToPlateSpace(cutResult.LastCutPoint, 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) + { + var firstMove = program.Codes.OfType().FirstOrDefault(); + return firstMove?.EndPoint ?? new Vector(); + } + + private static Vector GetProgramEndPoint(Program program) + { + var lastMove = program.Codes.OfType().LastOrDefault(); + return lastMove?.EndPoint ?? new Vector(); + } + + private static Shape GetPartPerimeter(Part part) + { + var entities = part.Program.ToGeometry(); + if (entities == null || entities.Count == 0) + return null; + + var profile = new ShapeProfile(entities); + if (profile.Perimeter == null) + return null; + + var perimeter = profile.Perimeter; + perimeter.Offset(part.Location); + return perimeter; + } + } +} +``` + +Note: `PlateHelper.GetExitPoint` is already defined in Task 8. If `PlateHelper` was created in the `Sequencing` namespace, make it `internal` and accessible via `using OpenNest.Engine.Sequencing`. Alternatively, move it to the `OpenNest.Engine` namespace root. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `dotnet test OpenNest.Engine.Tests --filter PlateProcessorTests -v n` +Expected: 6 tests PASS + +- [ ] **Step 5: Run all tests** + +Run: `dotnet test OpenNest.Engine.Tests -v n` +Expected: All tests PASS + +- [ ] **Step 6: Commit** + +```bash +git add OpenNest.Engine/PlateProcessor.cs OpenNest.Engine.Tests/PlateProcessorTests.cs +git commit -m "feat: add PlateProcessor orchestrator" +``` + +--- + +## Chunk 4: Final verification + +### Task 16: Full build and test run + +- [ ] **Step 1: Build entire solution** + +Run: `dotnet build OpenNest.sln` +Expected: Build succeeded with no errors + +- [ ] **Step 2: Run all tests** + +Run: `dotnet test OpenNest.Engine.Tests -v n` +Expected: All tests pass + +- [ ] **Step 3: Commit any remaining changes** + +If any files were missed, stage and commit them. diff --git a/docs/superpowers/specs/2026-03-14-ml-angle-pruning-design.md b/docs/superpowers/specs/2026-03-14-ml-angle-pruning-design.md new file mode 100644 index 0000000..dd16376 --- /dev/null +++ b/docs/superpowers/specs/2026-03-14-ml-angle-pruning-design.md @@ -0,0 +1,218 @@ +# ML Angle Pruning Design + +**Date:** 2026-03-14 +**Status:** Draft + +## Problem + +The nesting engine's biggest performance bottleneck is `FillLinear.FillRecursive`, which consumes ~66% of total CPU time. The linear phase builds a list of rotation angles to try — normally just 2 (`bestRotation` and `bestRotation + 90`), but expanding to a full 36-angle sweep (0-175 in 5-degree increments) when the work area's short side is smaller than the part's longest side. This narrow-work-area condition triggers frequently during remainder-strip fills and for large/elongated parts. Each angle x 2 directions requires expensive ray/edge distance calculations for every tile placement. + +## Goal + +Train an ML model that predicts which rotation angles are competitive for a given part geometry and sheet size. At runtime, replace the full angle sweep with only the predicted angles, reducing linear phase compute time in the narrow-work-area case. The model only applies when the engine would otherwise sweep all 36 angles — for the normal 2-angle case, no change is needed. + +## Design + +### Training Data Collection + +#### Forced Full Sweep for Training + +In production, `FindBestFill` only sweeps all 36 angles when `workAreaShortSide < partLongestSide`. For training, the sweep must be forced for every part x sheet combination regardless of this condition — otherwise the model has no data to learn from for the majority of runs that only evaluate 2 angles. + +`NestEngine` gains a `ForceFullAngleSweep` property (default `false`). When `true`, `FindBestFill` always builds the full 0-175 angle list. The training runner sets this to `true`; production code leaves it `false`. + +#### Per-Angle Results from NestEngine + +Instrument `NestEngine.FindBestFill` to collect per-angle results from the linear phase. Each call to `FillLinear.Fill(drawing, angle, direction)` produces a result that is currently only compared against the running best. With this change, each result is also accumulated into a collection on the engine instance. + +New types in `NestProgress.cs`: + +```csharp +public class AngleResult +{ + public double AngleDeg { get; set; } + public NestDirection Direction { get; set; } + public int PartCount { get; set; } +} +``` + +New properties on `NestEngine`: + +```csharp +public bool ForceFullAngleSweep { get; set; } +public List AngleResults { get; } = new(); +``` + +`AngleResults` is cleared at the start of `Fill` (alongside `PhaseResults.Clear()`). Populated inside the `Parallel.ForEach` over angles in `FindBestFill` — uses a `ConcurrentBag` during the parallel loop, then transferred to `AngleResults` via `AddRange` after the loop completes (same pattern as the existing `linearBag`). + +#### Progress Window Enhancement + +`NestProgress` gains a `Description` field — a freeform status string that the progress window displays directly: + +```csharp +public class NestProgress +{ + // ... existing fields ... + public string Description { get; set; } +} +``` + +Progress is reported per-angle during the linear phase (e.g. `"Linear: 35 V - 48 parts"`) and per-candidate during the pairs phase (e.g. `"Pairs: candidate 12/50"`). This gives real-time visibility into what the engine is doing, beyond the current phase-level updates. + +#### BruteForceRunner Changes + +`BruteForceRunner.Run` reads `engine.AngleResults` after `Fill` completes and passes them through `BruteForceResult`: + +```csharp +public class BruteForceResult +{ + // ... existing fields ... + public List AngleResults { get; set; } +} +``` + +The training runner sets `engine.ForceFullAngleSweep = true` before calling `Fill`. + +#### Database Schema + +New `AngleResults` table: + +| Column | Type | Description | +|-----------|---------|--------------------------------------| +| Id | long | PK, auto-increment | +| RunId | long | FK to Runs table | +| AngleDeg | double | Rotation angle in degrees (0-175) | +| Direction | string | "Horizontal" or "Vertical" | +| PartCount | int | Parts placed at this angle/direction | + +Each run produces up to ~72 rows (36 angles x 2 directions, minus angles where zero parts fit). With forced full sweep during training: 41k parts x 11 sheet sizes x ~72 angle results = ~32 million rows. SQLite handles this for batch writes; SQL Express on barge.lan is available as a fallback if needed. + +New EF Core entity `TrainingAngleResult` in `OpenNest.Training/Data/`. `TrainingDatabase.AddRun` is extended to accept and batch-insert angle results alongside the run. + +Migration: `MigrateSchema` creates the `AngleResults` table if it doesn't exist. Existing databases without the table continue to work — the table is created on first use. + +### Model Architecture + +**Type:** XGBoost multi-label classifier exported to ONNX. + +**Input features (11 scalars):** +- Part geometry (7): Area, Convexity, AspectRatio, BBFill, Circularity, PerimeterToAreaRatio, VertexCount +- Sheet dimensions (2): Width, Height +- Derived (2): SheetAspectRatio (Width/Height), PartToSheetAreaRatio (PartArea / SheetArea) + +The 32x32 bitmask is excluded from the initial model. The 7 scalar geometry features capture sufficient shape information for angle prediction. Bitmask can be added later if accuracy needs improvement. + +**Output:** 36 probabilities, one per 5-degree angle bin (0, 5, 10, ..., 175). Each probability represents "this angle is competitive for this part/sheet combination." + +**Label generation:** For each part x sheet run, an angle is labeled positive (1) if its best PartCount (max of H and V directions) is >= 95% of the overall best angle's PartCount for that run. This creates a multi-label target where typically 2-8 angles are labeled positive. + +**Direction handling:** The model predicts angles only. Both H and V directions are always tried for each selected angle — direction computation is cheap relative to the angle setup. + +### Training Pipeline + +Python notebook at `OpenNest.Training/notebooks/train_angle_model.ipynb`: + +1. **Extract** — Read SQLite database, join Parts + Runs + AngleResults into a flat dataframe. +2. **Filter** — Remove title block outliers using feature thresholds (e.g. BBFill < 0.01, abnormally large bounding boxes relative to actual geometry area). Log filtered parts for manual review. +3. **Label** — For each run, compute the best angle's PartCount. Mark angles within 95% as positive. Build a 36-column binary label matrix. +4. **Feature engineering** — Compute derived features (SheetAspectRatio, PartToSheetAreaRatio). Normalize if needed. +5. **Train** — XGBoost multi-label classifier. Use `sklearn.multioutput.MultiOutputClassifier` wrapping `xgboost.XGBClassifier`. Train/test split stratified by part (all sheet sizes for a part stay in the same split). +6. **Evaluate** — Primary metric: per-angle recall > 95% (must almost never skip the winning angle). Secondary: precision > 60% (acceptable to try a few extra angles). Report average angles predicted per part. +7. **Export** — Convert to ONNX via `skl2onnx` or `onnxmltools`. Save to `OpenNest.Engine/Models/angle_predictor.onnx`. + +Python dependencies: `pandas`, `scikit-learn`, `xgboost`, `onnxmltools` (or `skl2onnx`), `matplotlib` (for evaluation plots). + +### C# Inference Integration + +New file `OpenNest.Engine/ML/AnglePredictor.cs`: + +```csharp +public static class AnglePredictor +{ + public static List PredictAngles( + PartFeatures features, double sheetWidth, double sheetHeight); +} +``` + +- Loads `angle_predictor.onnx` from the `Models/` directory adjacent to the Engine DLL on first call. Caches the ONNX session for reuse. +- Runs inference with the 11 input features. +- Applies threshold (default 0.3) to the 36 output probabilities. +- Returns angles above threshold, converted to radians. +- Always includes 0 and 90 degrees as safety fallback. +- Minimum 3 angles returned (if fewer pass threshold, take top 3 by probability). +- If the model file is missing or inference fails, returns `null` — caller falls back to trying all angles (current behavior unchanged). + +**NuGet dependency:** `Microsoft.ML.OnnxRuntime` added to `OpenNest.Engine.csproj`. + +### NestEngine Integration + +In `FindBestFill` (the progress/token overload), the angle list construction changes: + +``` +Current: + angles = [bestRotation, bestRotation + 90] + + sweep 0-175 if narrow work area + +With model (only when narrow work area condition is met): + predicted = AnglePredictor.PredictAngles(features, sheetW, sheetH) + if predicted != null: + angles = predicted + + bestRotation and bestRotation + 90 (if not already included) + else: + angles = current behavior (full sweep) + +ForceFullAngleSweep = true (training only): + angles = full 0-175 sweep regardless of work area condition +``` + +`FeatureExtractor.Extract(drawing)` is called once per drawing before the fill loop. This is cheap (~0ms) and already exists. + +**Note:** The Pairs phase (`FillWithPairs`) uses hull-edge angles from each pair candidate's geometry, not the linear angle list. The ML model does not affect the Pairs phase angle selection. Pairs phase optimization (e.g. pruning pair candidates) is a separate future concern. + +### Fallback and Safety + +- **No model file:** Full angle sweep (current behavior). Zero regression risk. +- **Model loads but prediction fails:** Full angle sweep. Logged to Debug output. +- **Model predicts too few angles:** Minimum 3 angles enforced. 0, 90, bestRotation, and bestRotation + 90 always included. +- **Normal 2-angle case (no narrow work area):** Model is not consulted — the engine only tries bestRotation and bestRotation + 90 as it does today. +- **Model misses the optimal angle:** Recall target of 95% means ~5% of runs may not find the absolute best. The result will still be good (within 95% of optimal by definition of the training labels). Users can disable the model via a setting if needed. + +## Files Changed + +### OpenNest.Engine +- `NestProgress.cs` — Add `AngleResult` class, add `Description` to `NestProgress` +- `NestEngine.cs` — Add `ForceFullAngleSweep` and `AngleResults` properties, clear `AngleResults` alongside `PhaseResults`, populate per-angle results in `FindBestFill` via `ConcurrentBag` + `AddRange`, report per-angle progress with descriptions, use `AnglePredictor` for angle selection when narrow work area +- `ML/BruteForceRunner.cs` — Pass through `AngleResults` from engine +- `ML/AnglePredictor.cs` — New: ONNX model loading and inference +- `ML/FeatureExtractor.cs` — No changes (already exists) +- `Models/angle_predictor.onnx` — New: trained model file (added after training) +- `OpenNest.Engine.csproj` — Add `Microsoft.ML.OnnxRuntime` NuGet package + +### OpenNest.Training +- `Data/TrainingAngleResult.cs` — New: EF Core entity for AngleResults table +- `Data/TrainingDbContext.cs` — Add `DbSet` +- `Data/TrainingRun.cs` — No changes +- `TrainingDatabase.cs` — Add angle result storage, extend `MigrateSchema` +- `Program.cs` — Set `ForceFullAngleSweep = true` on engine, collect and store per-angle results from `BruteForceRunner` + +### OpenNest.Training/notebooks (new directory) +- `train_angle_model.ipynb` — Training notebook +- `requirements.txt` — Python dependencies + +### OpenNest (WinForms) +- Progress window UI — Display `NestProgress.Description` string (minimal change) + +## Data Volume Estimates + +- 41k parts x 11 sheet sizes = ~450k runs +- With forced full sweep: ~72 angle results per run = ~32 million angle result rows +- SQLite can handle this for batch writes. SQL Express on barge.lan available as fallback. +- Trained model file: ~1-5 MB ONNX + +## Success Criteria + +- Per-angle recall > 95% (almost never skips the winning angle) +- Average angles predicted: 4-8 per part (down from 36) +- Linear phase speedup in narrow-work-area case: 70-80% reduction +- Zero regression when model is absent — current behavior preserved exactly +- Progress window shows live angle/candidate details during nesting diff --git a/docs/superpowers/specs/2026-03-15-abstract-nest-engine-design.md b/docs/superpowers/specs/2026-03-15-abstract-nest-engine-design.md new file mode 100644 index 0000000..4d8904f --- /dev/null +++ b/docs/superpowers/specs/2026-03-15-abstract-nest-engine-design.md @@ -0,0 +1,195 @@ +# Abstract Nest Engine Design Spec + +**Date:** 2026-03-15 +**Goal:** Create a pluggable nest engine architecture so users can create custom nesting algorithms, switch between engines globally, and load third-party engines as plugins. + +--- + +## Motivation + +The current `NestEngine` is a concrete class with a sophisticated multi-phase fill strategy (Linear, Pairs, RectBestFit, Remainder). Different part geometries benefit from different algorithms — circles need circle-packing, strip-based layouts work better for mixed-drawing nests, and users may want to experiment with their own approaches. The engine needs to be swappable without changing the UI or other consumers. + +## Architecture Overview + +``` +NestEngineBase (abstract, OpenNest.Engine) +├── DefaultNestEngine (current multi-phase logic) +├── StripNestEngine (strip-based multi-drawing nesting) +├── CircleNestEngine (future, circle-packing) +└── [Plugin engines loaded from DLLs] + +NestEngineRegistry (static, OpenNest.Engine) +├── Tracks available engines (built-in + plugins) +├── Manages active engine selection (global) +└── Factory method: Create(Plate) → NestEngineBase +``` + +**Note on AutoNester:** The existing `AutoNester` static class (NFP + simulated annealing for mixed parts) is a natural future candidate for the registry but is currently unused by any caller. It is out of scope for this refactor — it can be wrapped as an engine later when it's ready for use. + +## NestEngineBase + +Abstract base class in `OpenNest.Engine`. Provides the contract, shared state, and utility methods. + +**Instance lifetime:** Engine instances are short-lived and plate-specific — created per operation via the registry factory. Some engines (like `DefaultNestEngine`) maintain internal state across multiple `Fill` calls on the same instance (e.g., `knownGoodAngles` for angle pruning). Plugin authors should be aware that a single engine instance may receive multiple `Fill` calls within one nesting session. + +### Properties + +| Property | Type | Notes | +|----------|------|-------| +| `Plate` | `Plate` | The plate being nested | +| `PlateNumber` | `int` | For progress reporting | +| `NestDirection` | `NestDirection` | Fill direction preference, set by callers after creation | +| `WinnerPhase` | `NestPhase` | Which phase produced the best result (protected set) | +| `PhaseResults` | `List` | Per-phase results for diagnostics | +| `AngleResults` | `List` | Per-angle results for diagnostics | + +### Abstract Members + +| Member | Type | Purpose | +|--------|------|---------| +| `Name` | `string` (get) | Display name for UI/registry | +| `Description` | `string` (get) | Human-readable description | + +### Virtual Methods (return parts, no side effects) + +These are the core methods subclasses override. Base class default implementations return empty lists — subclasses override the ones they support. + +```csharp +virtual List Fill(NestItem item, Box workArea, + IProgress progress, CancellationToken token) + +virtual List Fill(List groupParts, Box workArea, + IProgress progress, CancellationToken token) + +virtual List PackArea(Box box, List items, + IProgress progress, CancellationToken token) +``` + +**`FillExact` is non-virtual.** It is orchestration logic (binary search wrapper around `Fill`) that works regardless of the underlying fill algorithm. It lives in the base class and calls the virtual `Fill` method. Any engine that implements `Fill` gets `FillExact` for free. + +**`PackArea` signature change:** The current `PackArea(Box, List)` mutates the plate directly and returns `bool`. The new virtual method adds `IProgress` and `CancellationToken` parameters and returns `List` (side-effect-free). This is a deliberate refactor — the old mutating behavior moves to the convenience overload `Pack(List)`. + +### Convenience Overloads (non-virtual, add parts to plate) + +These call the virtual methods and handle plate mutation: + +```csharp +bool Fill(NestItem item) +bool Fill(NestItem item, Box workArea) +bool Fill(List groupParts) +bool Fill(List groupParts, Box workArea) +bool Pack(List items) +``` + +Pattern: call the virtual method → if parts returned → add to `Plate.Parts` → return `true`. + +### Protected Utilities + +Available to all subclasses: + +- `ReportProgress(IProgress, NestPhase, int plateNumber, List, Box, string)` — clone parts and report +- `BuildProgressSummary()` — format PhaseResults into a status string +- `IsBetterFill(List candidate, List current, Box workArea)` — FillScore comparison +- `IsBetterValidFill(List candidate, List current, Box workArea)` — with overlap check + +## DefaultNestEngine + +Rename of the current `NestEngine`. Inherits `NestEngineBase` and overrides all virtual methods with the existing multi-phase logic. + +- `Name` → `"Default"` +- `Description` → `"Multi-phase nesting (Linear, Pairs, RectBestFit, Remainder)"` +- All current private methods (`FindBestFill`, `FillWithPairs`, `FillRectangleBestFit`, `FillPattern`, `TryRemainderImprovement`, `BuildCandidateAngles`, `QuickFillCount`, etc.) remain as private methods in this class +- `ForceFullAngleSweep` property stays on `DefaultNestEngine` (not the base class) — only used by `BruteForceRunner` which references `DefaultNestEngine` directly +- `knownGoodAngles` HashSet stays as a private field — accumulates across multiple `Fill` calls for angle pruning +- No behavioral change — purely structural refactor + +## StripNestEngine + +The planned `StripNester` (from the strip nester spec) becomes a `NestEngineBase` subclass instead of a standalone class. + +- `Name` → `"Strip"` +- `Description` → `"Strip-based nesting for mixed-drawing layouts"` +- Overrides `Fill` for multi-item scenarios with its strip+remnant strategy +- Uses `DefaultNestEngine` internally as a building block for individual strip/remnant fills (composition, not inheritance from Default) + +## NestEngineRegistry + +Static class in `OpenNest.Engine` managing engine discovery and selection. Accessed only from the UI thread — not thread-safe. Engines are created per-operation and used on background threads, but the registry itself is only mutated/queried from the UI thread at startup and when the user changes the active engine. + +### NestEngineInfo + +```csharp +class NestEngineInfo +{ + string Name { get; } + string Description { get; } + Func Factory { get; } +} +``` + +### API + +| Member | Purpose | +|--------|---------| +| `List AvailableEngines` | All registered engines | +| `string ActiveEngineName` | Currently selected engine (defaults to `"Default"`) | +| `NestEngineBase Create(Plate plate)` | Creates instance of active engine | +| `void Register(string name, string description, Func factory)` | Register a built-in engine | +| `void LoadPlugins(string directory)` | Scan DLLs for NestEngineBase subclasses | + +### Built-in Registration + +```csharp +Register("Default", "Multi-phase nesting...", plate => new DefaultNestEngine(plate)); +Register("Strip", "Strip-based nesting...", plate => new StripNestEngine(plate)); +``` + +### Plugin Discovery + +Follows the existing `IPostProcessor` pattern from `Posts/`: +- Scan `Engines/` directory next to the executable for DLLs +- Reflect over types, find concrete subclasses of `NestEngineBase` +- Require a constructor taking `Plate` +- Register each with its `Name` and `Description` properties +- Called at application startup alongside post-processor loading (WinForms app only — Console and MCP use built-in engines only) + +**Error handling:** +- DLLs that fail to load (bad assembly, missing dependencies) are logged and skipped +- Types without a `Plate` constructor are skipped +- Duplicate engine names: first registration wins, duplicates are logged and skipped +- Exceptions from plugin constructors during `Create()` are caught and surfaced to the caller + +## Callsite Migration + +All `new NestEngine(plate)` calls become `NestEngineRegistry.Create(plate)`: + +| Location | Count | Notes | +|----------|-------|-------| +| `MainForm.cs` | 3 | Auto-nest fill, auto-nest pack, single-drawing fill plate | +| `ActionFillArea.cs` | 2 | | +| `PlateView.cs` | 1 | | +| `NestingTools.cs` (MCP) | 6 | | +| `Program.cs` (Console) | 3 | | +| `BruteForceRunner.cs` | 1 | **Keep as `new DefaultNestEngine(plate)`** — training data must come from the known algorithm | + +## UI Integration + +- Global engine selector: combobox or menu item bound to `NestEngineRegistry.AvailableEngines` +- Changing selection sets `NestEngineRegistry.ActiveEngineName` +- No per-plate engine state — global setting applies to all subsequent operations +- Plugin directory: `Engines/` next to executable, loaded at startup + +## File Summary + +| Action | File | Project | +|--------|------|---------| +| Create | `NestEngineBase.cs` | OpenNest.Engine | +| Rename/Modify | `NestEngine.cs` → `DefaultNestEngine.cs` | OpenNest.Engine | +| Create | `NestEngineRegistry.cs` | OpenNest.Engine | +| Create | `NestEngineInfo.cs` | OpenNest.Engine | +| Modify | `StripNester.cs` → `StripNestEngine.cs` | OpenNest.Engine | +| Modify | `MainForm.cs` | OpenNest | +| Modify | `ActionFillArea.cs` | OpenNest | +| Modify | `PlateView.cs` | OpenNest | +| Modify | `NestingTools.cs` | OpenNest.Mcp | +| Modify | `Program.cs` | OpenNest.Console | diff --git a/docs/superpowers/specs/2026-03-15-fill-exact-design.md b/docs/superpowers/specs/2026-03-15-fill-exact-design.md new file mode 100644 index 0000000..d911f8e --- /dev/null +++ b/docs/superpowers/specs/2026-03-15-fill-exact-design.md @@ -0,0 +1,96 @@ +# FillExact — Exact-Quantity Fill with Binary Search + +## Problem + +The current `NestEngine.Fill` fills an entire work area and truncates to `item.Quantity` with `.Take(n)`. This wastes plate space — parts are spread across the full area, leaving no usable remainder strip for subsequent drawings in AutoNest. + +## Solution + +Add a `FillExact` method that binary-searches for the smallest sub-area of the work area that fits exactly the requested quantity. This packs parts tightly against one edge, maximizing the remainder strip available for the next drawing. + +## Coordinate Conventions + +`Box.Width` is the X-axis extent. `Box.Length` is the Y-axis extent. The box is anchored at `(Box.X, Box.Y)` (bottom-left corner). + +- **Shrink width** means reducing `Box.Width` (X-axis), producing a narrower box anchored at the left edge. The remainder strip extends to the right. +- **Shrink length** means reducing `Box.Length` (Y-axis), producing a shorter box anchored at the bottom edge. The remainder strip extends upward. + +## Algorithm + +1. **Early exits:** + - Quantity is 0 (unlimited): delegate to `Fill` directly. + - Quantity is 1: delegate to `Fill` directly (a single part placement doesn't benefit from area search). +2. **Full fill** — Call `Fill(item, workArea, progress, token)` to establish the upper bound (max parts that fit). This call gets progress reporting so the user sees the phases running. +3. **Already exact or under** — If `fullCount <= quantity`, return the full fill result. The plate can't fit more than requested anyway. +4. **Estimate starting point** — Calculate an initial dimension estimate assuming 50% utilization: `estimatedDim = (partArea * quantity) / (0.5 * fixedDim)`, clamped to at least the part's bounding box dimension in that axis. +5. **Binary search** (max 8 iterations, or until `high - low < partSpacing`) — Keep one dimension of the work area fixed and binary-search on the other: + - `low = estimatedDim`, `high = workArea dimension` + - Each iteration: create a test box, call `Fill(item, testBox, null, token)` (no progress — search iterations are silent), check count. + - `count >= quantity` → record result, shrink: `high = mid` + - `count < quantity` → expand: `low = mid` + - Check cancellation token between iterations; if cancelled, return best found so far. +6. **Try both orientations** — Run the binary search twice: once shrinking length (fixed width) and once shrinking width (fixed length). +7. **Pick winner** — Compare by test box area (`testBox.Width * testBox.Length`). Return whichever orientation's result has a smaller test box area, leaving more remainder for subsequent drawings. Tie-break: prefer shrink-length (leaves horizontal remainder strip, generally more useful on wide plates). + +## Method Signature + +```csharp +// NestEngine.cs +public List FillExact(NestItem item, Box workArea, + IProgress progress, CancellationToken token) +``` + +Returns exactly `item.Quantity` parts packed into the smallest sub-area of `workArea`, or fewer if they don't all fit. + +## Internal Helper + +```csharp +private (List parts, double usedDim) BinarySearchFill( + NestItem item, Box workArea, bool shrinkWidth, + CancellationToken token) +``` + +Performs the binary search for one orientation. Returns the parts and the dimension value at which the exact quantity was achieved. Progress is not passed to inner Fill calls — the search iterations run silently. + +## Engine State + +Each inner `Fill` call clears `PhaseResults`, `AngleResults`, and overwrites `WinnerPhase`. After the winning Fill call is identified, `FillExact` runs the winner one final time with `progress` so: +- `PhaseResults` / `AngleResults` / `WinnerPhase` reflect the winning fill. +- The progress form shows the final result. + +## Integration + +### AutoNest (MainForm.RunAutoNest_Click) + +Replace `engine.Fill(item, workArea, progress, token)` with `engine.FillExact(item, workArea, progress, token)` for multi-quantity items. The tighter packing means `ComputeRemainderStrip` returns a larger box for subsequent drawings. + +### Single-drawing Fill + +`FillExact` works for single-drawing fills too. When `item.Quantity` is set, the caller gets a tight layout instead of parts scattered across the full plate. + +### Fallback + +When `item.Quantity` is 0 (unlimited), `FillExact` falls through to the standard `Fill` behavior — fill the entire work area. + +## Performance Notes + +The binary search converges in at most 8 iterations per orientation. Each iteration calls `Fill` internally, which runs the pairs/linear/best-fit phases. For a typical auto-nest scenario: + +- Full fill: 1 call (with progress) +- Shrink-length search: ~6-8 calls (silent) +- Shrink-width search: ~6-8 calls (silent) +- Final re-fill of winner: 1 call (with progress) +- Total: ~15-19 Fill calls per drawing + +The inner `Fill` calls for reduced work areas are faster than full-plate fills since the search space is smaller. The `BestFitCache` (used by the pairs phase) is keyed on the full plate size, so it stays warm across iterations — only the linear/rect phases re-run. + +Early termination (`high - low < partSpacing`) typically cuts 1-3 iterations, bringing the total closer to 12-15 calls. + +## Edge Cases + +- **Quantity 0 (unlimited):** Skip binary search, delegate to `Fill` directly. +- **Quantity 1:** Skip binary search, delegate to `Fill` directly. +- **Full fill already exact:** Return immediately without searching. +- **Part doesn't fit at all:** Return empty list. +- **Binary search can't hit exact count** (e.g., jumps from N-1 to N+2): Take the smallest test box where `count >= quantity` and truncate with `.Take(quantity)`. +- **Cancellation:** Check token between iterations. Return best result found so far. diff --git a/docs/superpowers/specs/2026-03-15-nest-progress-redesign.md b/docs/superpowers/specs/2026-03-15-nest-progress-redesign.md new file mode 100644 index 0000000..a913514 --- /dev/null +++ b/docs/superpowers/specs/2026-03-15-nest-progress-redesign.md @@ -0,0 +1,135 @@ +# NestProgressForm Redesign + +## Problem + +The current `NestProgressForm` is a flat list of label/value pairs with no visual hierarchy, no progress indicator, and default WinForms styling. It's functional but looks basic and gives no sense of where the engine is in its process. + +## Solution + +Redesign the form with three changes: +1. A custom-drawn **phase stepper** control showing which nesting phases have been visited +2. **Grouped sections** separating results from status information +3. **Modern styling** — Segoe UI fonts, subtle background contrast, better spacing + +## Phase Stepper Control + +**New file: `OpenNest/Controls/PhaseStepperControl.cs`** + +A custom `UserControl` that draws 4 circles with labels beneath, connected by lines: + +``` + ●━━━━━━━●━━━━━━━○━━━━━━━○ +Linear BestFit Pairs Remainder +``` + +### Non-sequential design + +The engine does **not** execute phases in a fixed order. `FindBestFill` runs Pairs → Linear → BestFit → Remainder, while the group fill path runs Linear → BestFit → Pairs → Remainder. Some phases may not execute at all (e.g., multi-part fills only run Linear). + +The stepper therefore tracks **which phases have been visited**, not a left-to-right progression. Each circle independently lights up when its phase reports progress, regardless of position. The connecting lines between circles are purely decorative (always light gray) — they do not indicate sequential flow. + +### Visual States + +- **Completed/visited:** Filled circle with accent color, bold label — the phase has reported at least one progress update +- **Active:** Filled circle with accent color and slightly larger radius, bold label — the phase currently executing +- **Pending:** Hollow circle with border only, dimmed label text — the phase has not yet reported progress +- **Skipped:** Same as Pending — phases that never execute simply remain hollow. No special "skipped" visual needed. +- **All complete:** All 4 circles filled (used when `ShowCompleted()` is called) +- **Initial state (before first `UpdateProgress`):** All 4 circles in Pending (hollow) state + +### Implementation + +- Single `OnPaint` override. Circles evenly spaced across control width. Connecting lines drawn between circle centers in light gray. +- Colors and fonts defined as `static readonly` fields at the top of the class. Fonts are cached (not created per paint call) to avoid GDI handle leaks during frequent progress updates. +- Tracks state via a `HashSet VisitedPhases` and a `NestPhase? ActivePhase` property. When `ActivePhase` is set, it is added to `VisitedPhases` and `Invalidate()` is called. A `bool IsComplete` property marks all phases as done. +- `DoubleBuffered = true` to prevent flicker on repaint. +- Fixed height (~60px), docks to fill width. +- Namespace: `OpenNest.Controls` (follows existing convention, e.g., `QuadrantSelect`). + +## Form Layout + +Three vertical zones using `DockStyle.Top` stacking: + +``` +┌─────────────────────────────────────┐ +│ ●━━━━━━━●━━━━━━━○━━━━━━━○ │ Phase stepper +│ Linear BestFit Pairs Remainder │ +├─────────────────────────────────────┤ +│ Results │ Results group +│ Parts: 156 │ +│ Density: 68.3% │ +│ Nested: 24.1 x 36.0 (867.6 sq in)│ +│ Unused: 43.2 sq in │ +├─────────────────────────────────────┤ +│ Status │ Status group +│ Plate: 2 │ +│ Elapsed: 1:24 │ +│ Detail: Trying 45° rotation... │ +├─────────────────────────────────────┤ +│ [ Stop ] │ Button bar +└─────────────────────────────────────┘ +``` + +### Group Panels + +Each group is a `Panel` containing: +- A header label (Segoe UI 9pt bold) at the top +- A `TableLayoutPanel` with label/value rows beneath + +Group panels use `Color.White` (or very light gray) `BackColor` against the form's `SystemColors.Control` background to create visual separation without borders. Small padding/margins between groups. + +### Typography + +- All fonts: Segoe UI (replaces MS Sans Serif) +- Group headers: 9pt bold +- Row labels: 8.25pt bold +- Row values: 8.25pt regular +- Value labels use `ForeColor = SystemColors.ControlText` + +### Sizing + +- Width: ~450px (slightly wider than current 425px for breathing room) +- Height: fixed `ClientSize` calculated to fit stepper (~60px) + results group (~110px) + status group (~90px) + button bar (~45px) + padding. The form uses `FixedToolWindow` which does not auto-resize, so the height is set explicitly in the designer. +- `FormBorderStyle.FixedToolWindow`, `StartPosition.CenterParent`, `ShowInTaskbar = false` + +### Plate Row Visibility + +The Plate row in the Status group is hidden when `showPlateRow: false` is passed to the constructor (same as current behavior). + +### Phase description text + +The current form's `FormatPhase()` method produces friendly text like "Trying rotations..." which was displayed in the Phase row. Since the phase stepper replaces the Phase row visually, this descriptive text moves to the **Detail** row. `UpdateProgress` writes `FormatPhase(progress.Phase)` to the Detail value when `progress.Description` is empty, and writes `progress.Description` when it's set (the engine's per-iteration descriptions like "Linear: 3/12 angles" take precedence). + +## Public API + +No signature changes. The form remains a drop-in replacement. + +### Constructor + +`NestProgressForm(CancellationTokenSource cts, bool showPlateRow = true)` — unchanged. + +### UpdateProgress(NestProgress progress) + +Same as today, plus: +- Sets `phaseStepperControl.ActivePhase = progress.Phase` to update the stepper +- Writes `FormatPhase(progress.Phase)` to the Detail row as a fallback when `progress.Description` is empty + +### ShowCompleted() + +Same as today (stops timer, changes button to "Close"), plus sets `phaseStepperControl.IsComplete = true` to fill all circles. + +Note: `MainForm.FillArea_Click` currently calls `progressForm.Close()` without calling `ShowCompleted()` first. This is existing behavior and is fine — the form closes immediately so the "all complete" visual is not needed in that path. + +## No External Changes + +- `NestProgress` and `NestPhase` are unchanged. +- All callers (`MainForm`, `PlateView.FillWithProgress`) continue calling `UpdateProgress` and `ShowCompleted` with no code changes. +- The form file paths remain the same — this is a modification, not a new form. + +## Files Touched + +| File | Change | +|------|--------| +| `OpenNest/Controls/PhaseStepperControl.cs` | New — custom-drawn phase stepper control | +| `OpenNest/Forms/NestProgressForm.cs` | Rewritten — grouped layout, stepper integration | +| `OpenNest/Forms/NestProgressForm.Designer.cs` | Rewritten — new control layout | diff --git a/docs/superpowers/specs/2026-03-15-plate-processor-design.md b/docs/superpowers/specs/2026-03-15-plate-processor-design.md new file mode 100644 index 0000000..2987bf4 --- /dev/null +++ b/docs/superpowers/specs/2026-03-15-plate-processor-design.md @@ -0,0 +1,329 @@ +# Plate Processor Design — Per-Part Lead-In Assignment & Cut Sequencing + +## Overview + +Add a plate-level orchestrator (`PlateProcessor`) to `OpenNest.Engine` that sequences parts across a plate, assigns lead-ins per-part based on approach direction, and plans safe rapid paths between parts. This replaces the current `ContourCuttingStrategy` usage model where the exit point is derived from the plate corner alone — instead, each part's lead-in pierce point is computed from the actual approach direction (the previous part's last cut point). + +The motivation is laser head safety: on a CL-980 fiber laser, head-down rapids are significantly faster than raising the head, but traversing over already-cut areas risks collision with tipped-up slugs. The orchestrator must track cut areas and choose safe rapid paths. + +## Architecture + +Three pipeline stages, wired by a thin orchestrator: + +``` +IPartSequencer → ContourCuttingStrategy → IRapidPlanner + ↓ ↓ ↓ + ordered parts lead-ins applied safe rapid paths + └──────────── PlateProcessor ─────────────┘ +``` + +All new code lives in `OpenNest.Engine/` except the `ContourCuttingStrategy` signature change and `Part.HasManualLeadIns` flag which are in `OpenNest.Core`. + +## Model Changes + +### Part (OpenNest.Core) + +Add a flag to indicate the user has manually assigned lead-ins to this part: + +```csharp +public bool HasManualLeadIns { get; set; } +``` + +When `true`, the orchestrator skips `ContourCuttingStrategy.Apply()` for this part and uses the program as-is. + +### ContourCuttingStrategy (OpenNest.Core) + +Change the `Apply` signature to accept an approach point instead of a plate: + +```csharp +// Before +public Program Apply(Program partProgram, Plate plate) + +// After +public CuttingResult Apply(Program partProgram, Vector approachPoint) +``` + +Remove `GetExitPoint(Plate)` — the caller provides the approach point in part-local coordinates. + +### CuttingResult (OpenNest.Core, namespace OpenNest.CNC.CuttingStrategy) + +New readonly struct returned by `ContourCuttingStrategy.Apply()`. Lives in `CNC/CuttingStrategy/CuttingResult.cs`: + +```csharp +public readonly struct CuttingResult +{ + public Program Program { get; init; } + public Vector LastCutPoint { get; init; } +} +``` + +- `Program`: the program with lead-ins/lead-outs applied. +- `LastCutPoint`: where the last contour cut ends, in part-local coordinates. The orchestrator transforms this to plate coordinates to compute the approach point for the next part. + +## Stage 1: IPartSequencer + +### Interface + +```csharp +namespace OpenNest.Engine +{ + public interface IPartSequencer + { + List Sequence(IReadOnlyList parts, Plate plate); + } +} +``` + +### SequencedPart + +```csharp +public readonly struct SequencedPart +{ + public Part Part { get; init; } +} +``` + +The sequencer only determines cut order. Approach points are computed by the orchestrator as it loops, since each part's approach point depends on the previous part's `CuttingResult.LastCutPoint`. + +### Implementations + +One class per `SequenceMethod`. All live in `OpenNest.Engine/Sequencing/`. + +| Class | SequenceMethod | Algorithm | +|-------|---------------|-----------| +| `RightSideSequencer` | RightSide | Sort parts by X descending (rightmost first) | +| `LeftSideSequencer` | LeftSide | Sort parts by X ascending (leftmost first) | +| `BottomSideSequencer` | BottomSide | Sort parts by Y ascending (bottom first) | +| `LeastCodeSequencer` | LeastCode | Nearest-neighbor from exit point, then 2-opt improvement | +| `AdvancedSequencer` | Advanced | Nearest-neighbor with row/column grouping from `SequenceParameters` | +| `EdgeStartSequencer` | EdgeStart | Sort by distance from nearest plate edge, closest first | + +#### Directional sequencers (RightSide, LeftSide, BottomSide) + +Sort parts by their bounding box center along the relevant axis. Ties broken by the perpendicular axis. These are simple positional sorts — no TSP involved. + +#### LeastCodeSequencer + +1. Start from the plate exit point. +2. Nearest-neighbor greedy: pick the unvisited part whose bounding box center is closest to the current position. +3. 2-opt improvement: iterate over the sequence, try swapping pairs. If total travel distance decreases, keep the swap. Repeat until no improvement found (or max iterations). + +#### AdvancedSequencer + +Uses `SequenceParameters` to group parts into rows/columns based on `MinDistanceBetweenRowsColumns`, then sequences within each group. `AlternateRowsColumns` and `AlternateCutoutsWithinRowColumn` control serpentine vs. unidirectional ordering within rows. + +#### EdgeStartSequencer + +Sort parts by distance from the nearest plate edge (minimum of distances to all four edges). Parts closest to an edge cut first. Ties broken by nearest-neighbor. + +### Parameter Flow + +Sequencers that need configuration accept it through their constructor: +- `LeastCodeSequencer(int maxIterations = 100)` — max 2-opt iterations +- `AdvancedSequencer(SequenceParameters parameters)` — row/column grouping config +- Directional sequencers and `EdgeStartSequencer` need no configuration + +### Factory + +A static `PartSequencerFactory.Create(SequenceParameters parameters)` method in `OpenNest.Engine/Sequencing/` maps `parameters.Method` to the correct `IPartSequencer` implementation, passing constructor args as needed. Throws `NotSupportedException` for `RightSideAlt`. + +## Stage 2: ContourCuttingStrategy + +Already exists in `OpenNest.Core/CNC/CuttingStrategy/`. Only the signature and return type change: + +1. `Apply(Program partProgram, Plate plate)` → `Apply(Program partProgram, Vector approachPoint)` +2. Return `CuttingResult` instead of `Program` +3. Remove `GetExitPoint(Plate)` — replaced by the `approachPoint` parameter +4. Set `CuttingResult.LastCutPoint` to the end point of the last contour (perimeter), which is the same as the perimeter's reindexed start point for closed contours + +The internal logic (cutout sequencing, contour type detection, normal computation, lead-in/out selection) remains unchanged — only the source of the approach direction changes. + +## Stage 3: IRapidPlanner + +### Interface + +```csharp +namespace OpenNest.Engine +{ + public interface IRapidPlanner + { + RapidPath Plan(Vector from, Vector to, IReadOnlyList cutAreas); + } +} +``` + +All coordinates are in plate space. + +### RapidPath + +```csharp +public readonly struct RapidPath +{ + public bool HeadUp { get; init; } + public List Waypoints { get; init; } +} +``` + +- `HeadUp = true`: the post-processor should raise Z before traversing. `Waypoints` is empty (direct move). +- `HeadUp = false`: head-down rapid. `Waypoints` contains the path (may be empty for a direct move, or contain intermediate points for obstacle avoidance in future implementations). + +### Implementations + +Both live in `OpenNest.Engine/RapidPlanning/`. + +#### SafeHeightRapidPlanner + +Always returns `HeadUp = true` with empty waypoints. Guaranteed safe, simplest possible implementation. + +#### DirectRapidPlanner + +Checks if the straight line from `from` to `to` intersects any shape in `cutAreas`: +- If clear: returns `HeadUp = false`, empty waypoints (direct head-down rapid). +- If blocked: returns `HeadUp = true`, empty waypoints (fall back to safe height). + +Uses existing `Intersect` class from `OpenNest.Geometry` for line-shape intersection checks. + +Future enhancement: obstacle-avoidance pathfinding that routes around cut areas with head down. This is a 2D pathfinding problem (visibility graph or similar) and is out of scope for the initial implementation. + +## PlateProcessor (Orchestrator) + +Lives in `OpenNest.Engine/PlateProcessor.cs`. + +```csharp +public class PlateProcessor +{ + public IPartSequencer Sequencer { get; set; } + public ContourCuttingStrategy CuttingStrategy { get; set; } + public IRapidPlanner RapidPlanner { get; set; } + + public PlateResult Process(Plate plate) + { + // 1. Sequence parts + var ordered = Sequencer.Sequence(plate.Parts, plate); + + var results = new List(); + var cutAreas = new List(); + var currentPoint = GetExitPoint(plate); // plate-space starting point + + foreach (var sequenced in ordered) + { + var part = sequenced.Part; + + // 2. Transform approach point from plate space to part-local space + var localApproach = ToPartLocal(currentPoint, part); + + // 3. Apply lead-ins (or skip if manual) + CuttingResult cutResult; + if (!part.HasManualLeadIns && CuttingStrategy != null) + { + cutResult = CuttingStrategy.Apply(part.Program, localApproach); + } + else + { + cutResult = new CuttingResult + { + Program = part.Program, + LastCutPoint = GetProgramEndPoint(part.Program) + }; + } + + // 4. Get pierce point in plate space for rapid planning + var piercePoint = ToPlateSpace(GetProgramStartPoint(cutResult.Program), part); + + // 5. Plan rapid from current position to this part's pierce point + var rapid = RapidPlanner.Plan(currentPoint, piercePoint, cutAreas); + + results.Add(new ProcessedPart + { + Part = part, + ProcessedProgram = cutResult.Program, + RapidPath = rapid + }); + + // 6. Track cut area (part perimeter in plate space) for future rapid planning + cutAreas.Add(GetPartPerimeter(part)); + + // 7. Update current position to this part's last cut point (plate space) + currentPoint = ToPlateSpace(cutResult.LastCutPoint, part); + } + + return new PlateResult { Parts = results }; + } +} +``` + +### Coordinate Transforms + +Part programs already have rotation baked in (the `Part` constructor calls `Program.Rotate()`). `Part.Location` is a pure translation offset. Therefore, coordinate transforms are simple vector addition/subtraction — no rotation involved: + +- `ToPartLocal(Vector platePoint, Part part)`: `platePoint - part.Location` +- `ToPlateSpace(Vector localPoint, Part part)`: `localPoint + part.Location` + +This matches how `Part.Intersects` converts to plate space (offset by `Location` only). + +### Helper Methods + +- `GetExitPoint(Plate)`: moved from `ContourCuttingStrategy` — returns the plate corner opposite the quadrant origin. +- `GetProgramStartPoint(Program)`: first `RapidMove` position in the program (the pierce point). +- `GetProgramEndPoint(Program)`: last move's end position in the program. +- `GetPartPerimeter(Part)`: converts the part's program to geometry, builds `ShapeProfile`, returns the perimeter `Shape` offset by `part.Location` (translation only — rotation is already baked in). + +### PlateResult + +```csharp +public class PlateResult +{ + public List Parts { get; init; } +} + +public readonly struct ProcessedPart +{ + public Part Part { get; init; } + public Program ProcessedProgram { get; init; } // with lead-ins applied (original Part.Program unchanged) + public RapidPath RapidPath { get; init; } +} +``` + +The orchestrator is non-destructive — it does not mutate `Part.Program` (which has a `private set`). Instead, the processed program with lead-ins is stored in `ProcessedPart.ProcessedProgram`. The post-processor consumes `PlateResult` to generate machine-specific G-code, using `ProcessedProgram` for cut paths and `RapidPath.HeadUp` for Z-axis commands. + +Note: the caller is responsible for configuring `CuttingStrategy.Parameters` (the `CuttingParameters` instance with lead-in/lead-out settings) before calling `Process()`. Parameters typically vary by material/thickness. + +## File Structure + +``` +OpenNest.Core/ +├── Part.cs # add HasManualLeadIns property +└── CNC/CuttingStrategy/ + ├── ContourCuttingStrategy.cs # signature change + CuttingResult return + └── CuttingResult.cs # new struct + +OpenNest.Engine/ +├── PlateProcessor.cs # orchestrator +├── Sequencing/ +│ ├── IPartSequencer.cs +│ ├── SequencedPart.cs # removed ApproachPoint (orchestrator tracks it) +│ ├── RightSideSequencer.cs +│ ├── LeftSideSequencer.cs +│ ├── BottomSideSequencer.cs +│ ├── LeastCodeSequencer.cs +│ ├── AdvancedSequencer.cs +│ └── EdgeStartSequencer.cs +└── RapidPlanning/ + ├── IRapidPlanner.cs + ├── RapidPath.cs + ├── SafeHeightRapidPlanner.cs + └── DirectRapidPlanner.cs +``` + +## Known Limitations + +- `DirectRapidPlanner` checks edge intersection only — a rapid that passes entirely through the interior of a concave cut part without crossing a perimeter edge would not be detected. Unlikely in practice (parts have material around them) but worth noting. +- `LeastCodeSequencer` uses bounding box centers for nearest-neighbor distance. For highly irregular parts, closest-point-on-perimeter could yield better results, but the simpler approach is sufficient for the initial implementation. + +## Out of Scope + +- Obstacle-avoidance pathfinding for head-down rapids (future enhancement to `DirectRapidPlanner`) +- UI integration (selecting sequencing method, configuring rapid planner) +- Post-processor changes to consume `PlateResult` — interim state: `PlateResult` is returned from `Process()` and the caller bridges it to the existing `IPostProcessor` interface +- `RightSideAlt` sequencer (unclear how it differs from `RightSide` — defer until behavior is defined; `PlateProcessor` should throw `NotSupportedException` if selected) +- Serialization of `PlateResult` diff --git a/docs/superpowers/specs/2026-03-15-strip-nester-design.md b/docs/superpowers/specs/2026-03-15-strip-nester-design.md new file mode 100644 index 0000000..70523ae --- /dev/null +++ b/docs/superpowers/specs/2026-03-15-strip-nester-design.md @@ -0,0 +1,133 @@ +# Strip Nester Design Spec + +## Problem + +The current multi-drawing nesting strategies (AutoNester with NFP/simulated annealing, sequential FillExact) produce scattered, unstructured layouts. For jobs with multiple part types, a structured strip-based approach can pack more densely by dedicating a tight strip to the highest-area drawing and filling the remnant with the rest. + +## Strategy Overview + +1. Pick the drawing that consumes the most plate area (bounding box area x quantity) as the "strip item." All others are "remainder items." +2. Try two orientations — bottom strip and left strip. +3. For each orientation, find the tightest strip that fits the strip item's full quantity. +4. Fill the remnant area with remainder items using existing fill strategies. +5. Compare both orientations. The denser overall result wins. + +## Algorithm Detail + +### Step 1: Select Strip Item + +Sort `NestItem`s by `Drawing.Program.BoundingBox().Area() * quantity` descending — bounding box area, not `Drawing.Area`, because the bounding box represents the actual plate space consumed by each part. The first item becomes the strip item. If quantity is 0 (unlimited), estimate max capacity from `workArea.Area() / bboxArea` as a stand-in for sorting. + +### Step 2: Estimate Initial Strip Height + +For the strip item, calculate at both 0 deg and 90 deg rotation. These two angles are sufficient since this is only an estimate for the shrink loop starting point — the actual fill in Step 3 uses `NestEngine.Fill` which tries many rotation angles internally. + +- Parts per row: `floor(stripLength / bboxWidth)` +- Rows needed: `ceil(quantity / partsPerRow)` +- Strip height: `rows * bboxHeight` + +Pick the rotation with the shorter strip height. The strip length is the work area dimension along the strip's long axis (work area width for bottom strip, work area length for left strip). + +### Step 3: Initial Fill + +Create a `Box` for the strip area: + +- **Bottom strip**: `(workArea.X, workArea.Y, workArea.Width, estimatedStripHeight)` +- **Left strip**: `(workArea.X, workArea.Y, estimatedStripWidth, workArea.Length)` + +Fill using `NestEngine.Fill(stripItem, stripBox)`. Measure the actual strip dimension from placed parts: for a bottom strip, `actualStripHeight = placedParts.GetBoundingBox().Top - workArea.Y`; for a left strip, `actualStripWidth = placedParts.GetBoundingBox().Right - workArea.X`. This may be shorter than the estimate since FillLinear packs more efficiently than pure bounding-box grid. + +### Step 4: Shrink Loop + +Starting from the actual placed dimension (not the estimate), capped at 20 iterations: + +1. Reduce strip height by `plate.PartSpacing` (typically 0.25"). +2. Create new strip box with reduced dimension. +3. Fill with `NestEngine.Fill(stripItem, newStripBox)`. +4. If part count equals the initial fill count, record this as the new best and repeat. +5. If part count drops, stop. Use the previous iteration's result (tightest strip that still fits). + +For unlimited quantity (qty = 0), the initial fill count becomes the target. + +### Step 5: Remnant Fill + +Calculate the remnant box from the tightest strip's actual placed dimension, adding `plate.PartSpacing` between the strip and remnant to prevent spacing violations: + +- **Bottom strip remnant**: `(workArea.X, workArea.Y + actualStripHeight + partSpacing, workArea.Width, workArea.Length - actualStripHeight - partSpacing)` +- **Left strip remnant**: `(workArea.X + actualStripWidth + partSpacing, workArea.Y, workArea.Width - actualStripWidth - partSpacing, workArea.Length)` + +Fill remainder items in descending order by `bboxArea * quantity` (largest first, same as strip selection). If the strip item was only partially placed (fewer than target quantity), add the leftover quantity as a remainder item so it participates in the remnant fill. + +For each remainder item, fill using `NestEngine.Fill(remainderItem, remnantBox)`. + +### Step 6: Compare Orientations + +Score each orientation using `FillScore.Compute` over all placed parts (strip + remnant) against `plate.WorkArea()`. The orientation with the better `FillScore` wins. Apply the winning parts to the plate. + +## Classes + +### `StripNester` (new, `OpenNest.Engine`) + +```csharp +public class StripNester +{ + public StripNester(Plate plate) { } + + public List Nest(List items, + IProgress progress, + CancellationToken token); +} +``` + +**Constructor**: Takes the target plate (for work area, part spacing, quadrant). + +**`Nest` method**: Runs the full strategy. Returns the combined list of placed parts. The caller adds them to `plate.Parts`. Same instance-based pattern as `NestEngine`. + +### `StripNestResult` (new, internal, `OpenNest.Engine`) + +```csharp +internal class StripNestResult +{ + public List Parts { get; set; } = new(); + public Box StripBox { get; set; } + public Box RemnantBox { get; set; } + public FillScore Score { get; set; } + public StripDirection Direction { get; set; } +} +``` + +Holds intermediate results for comparing bottom vs left orientations. + +### `StripDirection` (new enum, `OpenNest.Engine`) + +```csharp +public enum StripDirection { Bottom, Left } +``` + +## Integration + +### MCP (`NestingTools`) + +`StripNester` becomes an additional strategy in the autonest flow. When multiple items are provided, both `StripNester` and the current approach run, and the better result wins. + +### UI (`AutoNestForm`) + +Can be offered as a strategy option alongside existing NFP-based auto-nesting. + +### No changes to `NestEngine` + +`StripNester` is a consumer of `NestEngine.Fill`, not a modification of it. + +## Edge Cases + +- **Single item**: Strategy reduces to strip optimization only (shrink loop with no remnant fill). Still valuable for finding the tightest area. +- **Strip item can't fill target quantity**: Use the partial result. Leftover quantity is added to remainder items for the remnant fill. +- **Remnant too small**: `NestEngine.Fill` returns empty naturally. No special handling needed. +- **Quantity = 0 (unlimited)**: Initial fill count becomes the shrink loop target. +- **Strip already one part tall**: Skip the shrink loop. + +## Dependencies + +- `NestEngine.Fill(NestItem, Box)` — existing API, no changes needed. +- `FillScore.Compute` — existing scoring, no changes needed. +- `Part.GetBoundingBox()` / list extensions — existing geometry utilities. diff --git a/docs/superpowers/specs/2026-03-16-leadin-ui-design.md b/docs/superpowers/specs/2026-03-16-leadin-ui-design.md new file mode 100644 index 0000000..9155de7 --- /dev/null +++ b/docs/superpowers/specs/2026-03-16-leadin-ui-design.md @@ -0,0 +1,260 @@ +# Lead-In Assignment UI Design (Revised) + +## Overview + +Add a dialog and menu item for assigning lead-ins to parts on a plate. The dialog provides separate parameter sets for external (perimeter) and internal (cutout/hole) contours. Lead-in/lead-out moves are tagged with the existing `LayerType.Leadin`/`LayerType.Leadout` enum on each code, making them distinguishable from normal cut code and easy to strip and re-apply. + +## Design Principles + +- **LayerType tagging.** Every lead-in move gets `Layer = LayerType.Leadin`, every lead-out move gets `Layer = LayerType.Leadout`. Normal contour cuts keep `Layer = LayerType.Cut` (the default). This uses the existing `LayerType` enum and `LinearMove.Layer`/`ArcMove.Layer` properties — no new enums or flags. +- **Always rebuild from base.** `ContourCuttingStrategy.Apply` converts the input program to geometry via `Program.ToGeometry()` and `ShapeProfile`. These do NOT filter by layer — all entities (including lead-in/out codes if present) would be processed. Therefore, the strategy must always receive a clean program (cut codes only). The flow always clones from `Part.BaseDrawing.Program` and re-rotates before applying. +- **Non-destructive.** `Part.BaseDrawing.Program` is never modified. The strategy builds a fresh `Program` with lead-ins baked in. `Part.HasManualLeadIns` (existing property) is set to `true` when lead-ins are assigned, so the automated `PlateProcessor` pipeline skips these parts. + +## Lead-In Dialog (`LeadInForm`) + +A WinForms dialog in `OpenNest/Forms/LeadInForm.cs` with two parameter groups, one checkbox, and OK/Cancel buttons. + +### External Group (Perimeter) +- Lead-in angle (degrees) — default 90 +- Lead-in length (inches) — default 0.125 +- Overtravel (inches) — default 0.03 + +### Internal Group (Cutouts & Holes) +- Lead-in angle (degrees) — default 90 +- Lead-in length (inches) — default 0.125 +- Overtravel (inches) — default 0.03 + +### Update Existing Checkbox +- **"Update existing lead-ins"** — checked by default +- When checked: strip all existing lead-in/lead-out codes from every part before re-applying +- When unchecked: only process parts that have no `LayerType.Leadin` codes in their program + +### Dialog Result + +```csharp +public class LeadInSettings +{ + // External (perimeter) parameters + public double ExternalLeadInAngle { get; set; } = 90; + public double ExternalLeadInLength { get; set; } = 0.125; + public double ExternalOvertravel { get; set; } = 0.03; + + // Internal (cutout/hole) parameters + public double InternalLeadInAngle { get; set; } = 90; + public double InternalLeadInLength { get; set; } = 0.125; + public double InternalOvertravel { get; set; } = 0.03; + + // Behavior + public bool UpdateExisting { get; set; } = true; +} +``` + +Note: `LineLeadIn.ApproachAngle` and `LineLeadOut.ApproachAngle` store degrees (not radians), converting internally via `Angle.ToRadians()`. The `LeadInSettings` values are degrees and can be passed directly. + +## LeadInSettings to CuttingParameters Mapping + +The caller builds one `CuttingParameters` instance with separate external and internal settings. ArcCircle shares the internal settings: + +``` +ExternalLeadIn = new LineLeadIn { ApproachAngle = settings.ExternalLeadInAngle, Length = settings.ExternalLeadInLength } +ExternalLeadOut = new LineLeadOut { Length = settings.ExternalOvertravel } +InternalLeadIn = new LineLeadIn { ApproachAngle = settings.InternalLeadInAngle, Length = settings.InternalLeadInLength } +InternalLeadOut = new LineLeadOut { Length = settings.InternalOvertravel } +ArcCircleLeadIn = (same as Internal) +ArcCircleLeadOut = (same as Internal) +``` + +## Detecting Existing Lead-Ins + +Check whether a part's program contains lead-in codes by inspecting `LayerType`: + +```csharp +bool HasLeadIns(Program program) +{ + foreach (var code in program.Codes) + { + if (code is LinearMove lm && lm.Layer == LayerType.Leadin) + return true; + if (code is ArcMove am && am.Layer == LayerType.Leadin) + return true; + } + return false; +} +``` + +## Preparing a Clean Program + +**Important:** `Program.ToGeometry()` and `ShapeProfile` process ALL entities regardless of layer. They do NOT filter out lead-in/lead-out codes. If the strategy receives a program that already has lead-in codes baked in, those codes would be converted to geometry entities and corrupt the perimeter/cutout detection. + +Therefore, the flow always starts from a clean base: + +```csharp +var cleanProgram = part.BaseDrawing.Program.Clone() as Program; +cleanProgram.Rotate(part.Rotation); +``` + +This produces a program with only the original cut geometry at the part's current rotation angle, safe to feed into `ContourCuttingStrategy.Apply`. + +## Menu Integration + +Add "Assign Lead-Ins" to the Plate menu in `MainForm`, after "Sequence Parts" and before "Calculate Cut Time". + +Click handler in `MainForm` delegates to `EditNestForm.AssignLeadIns()`. + +## AssignLeadIns Flow (EditNestForm) + +``` +1. Open LeadInForm dialog +2. If user clicks OK: + a. Get LeadInSettings from dialog (includes UpdateExisting flag) + b. Build one ContourCuttingStrategy with CuttingParameters from settings + c. Get exit point: PlateHelper.GetExitPoint(plate) [now public] + d. Set currentPoint = exitPoint + e. For each part on the current plate (in sequence order): + - If !updateExisting and part already has lead-in codes → skip + - Build clean program: clone BaseDrawing.Program, rotate to part.Rotation + - Compute localApproach = currentPoint - part.Location + - Call strategy.Apply(cleanProgram, localApproach) → CuttingResult + - Call part.ApplyLeadIns(cutResult.Program) + (this sets Program, HasManualLeadIns = true, and recalculates bounds) + - Update currentPoint = cutResult.LastCutPoint + part.Location + f. Invalidate PlateView to show updated geometry +``` + +Note: The clean program is always rebuilt from `BaseDrawing.Program` — never from the current `Part.Program` — because `Program.ToGeometry()` and `ShapeProfile` do not filter by layer and would be corrupted by existing lead-in codes. + +Note: Setting `Part.Program` requires a public method since the setter is `private`. See Model Changes below. + +## Model Changes + +### Part (OpenNest.Core) + +Add a method to apply lead-ins and mark the part: + +```csharp +public void ApplyLeadIns(Program processedProgram) +{ + Program = processedProgram; + HasManualLeadIns = true; + UpdateBounds(); +} +``` + +This atomically sets the processed program, marks `HasManualLeadIns = true` (so `PlateProcessor` skips this part), and recalculates bounds. The private setter on `Program` stays private — `ApplyLeadIns` is the public API. + +### PlateHelper (OpenNest.Engine) + +Change `PlateHelper` from `internal static` to `public static` so the UI project can access `GetExitPoint`. + +## ContourCuttingStrategy Changes + +### LayerType Tagging + +When emitting lead-in moves, stamp each code with `Layer = LayerType.Leadin`. When emitting lead-out moves, stamp with `Layer = LayerType.Leadout`. This applies to all move types (`LinearMove`, `ArcMove`) generated by `LeadIn.Generate()` and `LeadOut.Generate()`. + +The `LeadIn.Generate()` and `LeadOut.Generate()` methods return `List`. After calling them, the strategy sets the `Layer` property on each returned code: + +```csharp +var leadInCodes = leadIn.Generate(piercePoint, normal, winding); +foreach (var code in leadInCodes) +{ + if (code is LinearMove lm) lm.Layer = LayerType.Leadin; + else if (code is ArcMove am) am.Layer = LayerType.Leadin; +} +result.Codes.AddRange(leadInCodes); +``` + +Same pattern for lead-out codes with `LayerType.Leadout`. + +### Corner vs Mid-Entity Auto-Detection + +When generating the lead-out, the strategy detects whether the pierce point landed on a corner or mid-entity. Detection uses the `out Entity` from `ClosestPointTo` with type-specific endpoint checks: + +```csharp +private static bool IsCornerPierce(Vector closestPt, Entity entity) +{ + if (entity is Line line) + return closestPt.DistanceTo(line.StartPoint) < Tolerance.Epsilon + || closestPt.DistanceTo(line.EndPoint) < Tolerance.Epsilon; + if (entity is Arc arc) + return closestPt.DistanceTo(arc.StartPoint()) < Tolerance.Epsilon + || closestPt.DistanceTo(arc.EndPoint()) < Tolerance.Epsilon; + return false; +} +``` + +Note: `Entity` has no polymorphic `StartPoint`/`EndPoint` — `Line` has properties, `Arc` has methods, `Circle` has neither. + +### Corner Lead-Out + +Delegates to `LeadOut.Generate()` as normal — `LineLeadOut` extends past the corner along the contour normal. Moves are tagged `LayerType.Leadout`. + +### Mid-Entity Lead-Out (Contour-Follow Overtravel) + +Handled at the `ContourCuttingStrategy` level, NOT via `LeadOut.Generate()` (which lacks access to the contour shape). The overtravel distance is read from the selected `LeadOut` for the current contour type — `SelectLeadOut(contourType)`. Since external and internal have separate `LineLeadOut` instances in `CuttingParameters`, the overtravel distance automatically varies by contour type. + +```csharp +var leadOut = SelectLeadOut(contourType); +if (IsCornerPierce(closestPt, entity)) +{ + // Corner: delegate to LeadOut.Generate() as normal + var codes = leadOut.Generate(closestPt, normal, winding); + // tag as LayerType.Leadout +} +else if (leadOut is LineLeadOut lineLeadOut && lineLeadOut.Length > 0) +{ + // Mid-entity: retrace the start of the contour for overtravel distance + var codes = GenerateOvertravelMoves(reindexed, lineLeadOut.Length); + // tag as LayerType.Leadout +} +``` + +The contour-follow retraces the beginning of the reindexed shape: + +1. Walking the reindexed shape's entities from the start +2. Accumulating distance until overtravel is reached +3. Emitting `LinearMove`/`ArcMove` codes for those segments (splitting the last segment if needed) +4. Tagging all emitted moves as `LayerType.Leadout` + +This produces a clean overcut that ensures the contour fully closes. + +### Lead-out behavior summary + +| Contour Type | Pierce Location | Lead-Out Behavior | +|---|---|---| +| External | Corner | `LineLeadOut.Generate()` — extends past corner | +| External | Mid-entity | Contour-follow overtravel moves | +| Internal | Corner | `LineLeadOut.Generate()` — extends past corner | +| Internal | Mid-entity | Contour-follow overtravel moves | +| ArcCircle | N/A (always mid-entity) | Contour-follow overtravel moves | + +## File Structure + +``` +OpenNest.Core/ +├── Part.cs # add ApplyLeadIns method +└── CNC/CuttingStrategy/ + └── ContourCuttingStrategy.cs # LayerType tagging, Overtravel, corner detection + +OpenNest.Engine/ +└── Sequencing/ + └── PlateHelper.cs # change internal → public + +OpenNest/ +├── Forms/ +│ ├── LeadInForm.cs # new dialog +│ ├── LeadInForm.Designer.cs # new dialog designer +│ ├── MainForm.Designer.cs # add menu item +│ ├── MainForm.cs # add click handler +│ └── EditNestForm.cs # add AssignLeadIns method +└── LeadInSettings.cs # settings DTO +``` + +## Out of Scope + +- Tabbed (V lead-in/out) parameters and `Part.IsTabbed` — deferred until tab assignment UI +- Slug destruct for internal cutouts +- Lead-in visualization colors in PlateView (separate enhancement) +- Database storage of lead-in presets by material/thickness +- MicrotabLeadOut integration +- Nest file serialization changes