Compare commits
26 Commits
acc75868c0
...
cab603c50d
| Author | SHA1 | Date | |
|---|---|---|---|
| cab603c50d | |||
| 40d99b402f | |||
| 45509cfd3f | |||
| 8e0c082876 | |||
| 3c59da17c2 | |||
| ae010212ac | |||
| ce6b25c12a | |||
| 6993d169e4 | |||
| eddcc7602d | |||
| 9783d417bd | |||
| 91281c8813 | |||
| c2f775258d | |||
| 930dd59213 | |||
| 9cc6cfa1b1 | |||
| e33b5ba063 | |||
| 8cc14997af | |||
| eee2d0e3fe | |||
| 2a58a8e123 | |||
| c466a24486 | |||
| 71fc1e61ef | |||
| a145fd3c60 | |||
| dddd81fd90 | |||
| 8e46ed1175 | |||
| 09ed9c228f | |||
| eb21f76ef4 | |||
| 954831664a |
+399
-244
@@ -4,268 +4,423 @@ using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
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;
|
||||
var templateFile = (string)null;
|
||||
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 "--template" when i + 1 < args.Length:
|
||||
templateFile = args[++i];
|
||||
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<NestItem>();
|
||||
var qty = options.Quantity > 0 ? options.Quantity : 1;
|
||||
|
||||
if (options.DrawingName != null)
|
||||
{
|
||||
nestItems.Add(new NestItem { Drawing = drawing, Quantity = qty });
|
||||
}
|
||||
else
|
||||
{
|
||||
foreach (var d in nest.Drawings)
|
||||
nestItems.Add(new NestItem { Drawing = d, Quantity = qty });
|
||||
}
|
||||
|
||||
Console.WriteLine($"AutoNest: {nestItems.Count} drawing(s), {nestItems.Sum(i => i.Quantity)} total parts");
|
||||
|
||||
var nestParts = AutoNester.Nest(nestItems, plate);
|
||||
plate.Parts.AddRange(nestParts);
|
||||
success = nestParts.Count > 0;
|
||||
}
|
||||
else
|
||||
{
|
||||
var engine = new NestEngine(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 template defaults.
|
||||
if (templateFile != null)
|
||||
{
|
||||
if (!File.Exists(templateFile))
|
||||
static void PrintResults(bool success, Plate plate, long elapsedMs)
|
||||
{
|
||||
Console.Error.WriteLine($"Error: Template not found: {templateFile}");
|
||||
return 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");
|
||||
}
|
||||
var templateNest = new NestReader(templateFile).Read();
|
||||
var templatePlate = templateNest.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: {templateFile}");
|
||||
}
|
||||
|
||||
// Apply overrides.
|
||||
if (spacing.HasValue)
|
||||
plate.PartSpacing = spacing.Value;
|
||||
|
||||
if (plateWidth.HasValue && plateHeight.HasValue)
|
||||
plate.Size = new Size(plateWidth.Value, plateHeight.Value);
|
||||
|
||||
// Find drawing.
|
||||
var drawing = drawingName != null
|
||||
? nest.Drawings.FirstOrDefault(d => d.Name == drawingName)
|
||||
: nest.Drawings.FirstOrDefault();
|
||||
|
||||
if (drawing == null)
|
||||
{
|
||||
Console.Error.WriteLine(drawingName != null
|
||||
? $"Error: drawing '{drawingName}' not found. Available: {string.Join(", ", nest.Drawings.Select(d => d.Name))}"
|
||||
: "Error: nest file contains no drawings");
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Clear existing parts.
|
||||
var existingCount = plate.Parts.Count;
|
||||
|
||||
if (!keepParts)
|
||||
plate.Parts.Clear();
|
||||
|
||||
Console.WriteLine($"Nest: {nest.Name}");
|
||||
Console.WriteLine($"Plate: {plateIndex} ({plate.Size.Width:F1} x {plate.Size.Length:F1}), spacing={plate.PartSpacing:F2}");
|
||||
Console.WriteLine($"Drawing: {drawing.Name}");
|
||||
|
||||
if (!keepParts)
|
||||
Console.WriteLine($"Cleared {existingCount} existing parts");
|
||||
else
|
||||
Console.WriteLine($"Keeping {existingCount} existing parts");
|
||||
|
||||
Console.WriteLine("---");
|
||||
|
||||
// Run fill or autonest.
|
||||
var sw = Stopwatch.StartNew();
|
||||
bool success;
|
||||
|
||||
if (autoNest)
|
||||
{
|
||||
// AutoNest: use all drawings (or specific drawing if --drawing given).
|
||||
var nestItems = new List<NestItem>();
|
||||
|
||||
if (drawingName != null)
|
||||
static void Save(Nest nest, Options options)
|
||||
{
|
||||
nestItems.Add(new NestItem { Drawing = drawing, 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}");
|
||||
}
|
||||
else
|
||||
|
||||
static void PrintUsage()
|
||||
{
|
||||
foreach (var d in nest.Drawings)
|
||||
nestItems.Add(new NestItem { Drawing = d, Quantity = quantity > 0 ? quantity : 1 });
|
||||
Console.Error.WriteLine("Usage: OpenNest.Console <input-files...> [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(" <nest.zip> Load nest and fill (existing behavior)");
|
||||
Console.Error.WriteLine(" <part.dxf> --size LxW Import DXF, create plate, and fill");
|
||||
Console.Error.WriteLine(" <nest.zip> <part.dxf> Load nest and add imported DXF drawings");
|
||||
Console.Error.WriteLine();
|
||||
Console.Error.WriteLine("Options:");
|
||||
Console.Error.WriteLine(" --drawing <name> Drawing name to fill with (default: first drawing)");
|
||||
Console.Error.WriteLine(" --plate <index> Plate index to fill (default: 0)");
|
||||
Console.Error.WriteLine(" --quantity <n> Max parts to place (default: 0 = unlimited)");
|
||||
Console.Error.WriteLine(" --spacing <value> Override part spacing");
|
||||
Console.Error.WriteLine(" --size <LxW> Override plate size (e.g. 120x60); required for DXF-only mode");
|
||||
Console.Error.WriteLine(" --output <path> Output nest file path (default: <input>-result.zip)");
|
||||
Console.Error.WriteLine(" --template <path> Nest template for plate defaults (thickness, quadrant, material, spacing)");
|
||||
Console.Error.WriteLine(" --autonest Use NFP-based mixed-part autonesting instead of linear fill");
|
||||
Console.Error.WriteLine(" --keep-parts Don't clear existing parts before filling");
|
||||
Console.Error.WriteLine(" --check-overlaps Run overlap detection after fill (exit code 1 if found)");
|
||||
Console.Error.WriteLine(" --no-save Skip saving output file");
|
||||
Console.Error.WriteLine(" --no-log Skip writing debug log file");
|
||||
Console.Error.WriteLine(" -h, --help Show this help");
|
||||
}
|
||||
|
||||
Console.WriteLine($"AutoNest: {nestItems.Count} drawing(s), {nestItems.Sum(i => i.Quantity)} total parts");
|
||||
|
||||
var nestParts = NestEngine.AutoNest(nestItems, plate);
|
||||
plate.Parts.AddRange(nestParts);
|
||||
success = nestParts.Count > 0;
|
||||
}
|
||||
else
|
||||
{
|
||||
var engine = new NestEngine(plate);
|
||||
var item = new NestItem { Drawing = drawing, Quantity = quantity };
|
||||
success = engine.Fill(item);
|
||||
}
|
||||
|
||||
sw.Stop();
|
||||
|
||||
// Check overlaps.
|
||||
var overlapCount = 0;
|
||||
|
||||
if (checkOverlaps && plate.Parts.Count > 0)
|
||||
{
|
||||
List<Vector> overlapPts;
|
||||
var hasOverlaps = plate.HasOverlappingParts(out overlapPts);
|
||||
overlapCount = overlapPts.Count;
|
||||
|
||||
if (hasOverlaps)
|
||||
Console.WriteLine($"OVERLAPS DETECTED: {overlapCount} intersection points");
|
||||
else
|
||||
Console.WriteLine("Overlap check: PASS");
|
||||
}
|
||||
|
||||
// Flush and close the log.
|
||||
Trace.Flush();
|
||||
logWriter?.Dispose();
|
||||
|
||||
// Print results.
|
||||
Console.WriteLine($"Result: {(success ? "success" : "failed")}");
|
||||
Console.WriteLine($"Parts placed: {plate.Parts.Count}");
|
||||
Console.WriteLine($"Utilization: {plate.Utilization():P1}");
|
||||
Console.WriteLine($"Time: {sw.ElapsedMilliseconds}ms");
|
||||
|
||||
// Save output.
|
||||
if (!noSave)
|
||||
{
|
||||
if (outputFile == null)
|
||||
class Options
|
||||
{
|
||||
var dir = Path.GetDirectoryName(nestFile);
|
||||
var name = Path.GetFileNameWithoutExtension(nestFile);
|
||||
outputFile = Path.Combine(dir, $"{name}-result.zip");
|
||||
public List<string> 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;
|
||||
}
|
||||
|
||||
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 <nest-file> [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 <name> Drawing name to fill with (default: first drawing)");
|
||||
Console.Error.WriteLine(" --plate <index> Plate index to fill (default: 0)");
|
||||
Console.Error.WriteLine(" --quantity <n> Max parts to place (default: 0 = unlimited)");
|
||||
Console.Error.WriteLine(" --spacing <value> Override part spacing");
|
||||
Console.Error.WriteLine(" --size <WxH> Override plate size (e.g. 120x60)");
|
||||
Console.Error.WriteLine(" --output <path> Output nest file path (default: <input>-result.zip)");
|
||||
Console.Error.WriteLine(" --template <path> Nest template for plate defaults (thickness, quadrant, material, spacing)");
|
||||
Console.Error.WriteLine(" --autonest Use NFP-based mixed-part autonesting instead of linear fill");
|
||||
Console.Error.WriteLine(" --keep-parts Don't clear existing parts before filling");
|
||||
Console.Error.WriteLine(" --check-overlaps Run overlap detection after fill (exit code 1 if found)");
|
||||
Console.Error.WriteLine(" --no-save Skip saving output file");
|
||||
Console.Error.WriteLine(" --no-log Skip writing debug log file");
|
||||
Console.Error.WriteLine(" -h, --help Show this help");
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -3,7 +3,7 @@ using OpenNest.Math;
|
||||
|
||||
namespace OpenNest.Geometry
|
||||
{
|
||||
public struct Vector
|
||||
public struct Vector : IEquatable<Vector>
|
||||
{
|
||||
public static readonly Vector Invalid = new Vector(double.NaN, double.NaN);
|
||||
public static readonly Vector 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);
|
||||
|
||||
+218
-60
@@ -882,7 +882,7 @@ namespace OpenNest
|
||||
case PushDirection.Right:
|
||||
{
|
||||
var dy = p2y - p1y;
|
||||
if (dy > -Tolerance.Epsilon && dy < Tolerance.Epsilon)
|
||||
if (System.Math.Abs(dy) < Tolerance.Epsilon)
|
||||
return double.MaxValue;
|
||||
|
||||
var t = (vy - p1y) / dy;
|
||||
@@ -891,6 +891,7 @@ namespace OpenNest
|
||||
|
||||
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;
|
||||
@@ -900,7 +901,7 @@ namespace OpenNest
|
||||
case PushDirection.Up:
|
||||
{
|
||||
var dx = p2x - p1x;
|
||||
if (dx > -Tolerance.Epsilon && dx < Tolerance.Epsilon)
|
||||
if (System.Math.Abs(dx) < Tolerance.Epsilon)
|
||||
return double.MaxValue;
|
||||
|
||||
var t = (vx - p1x) / dx;
|
||||
@@ -909,6 +910,7 @@ namespace OpenNest
|
||||
|
||||
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;
|
||||
@@ -928,38 +930,52 @@ namespace OpenNest
|
||||
{
|
||||
var minDist = double.MaxValue;
|
||||
|
||||
// Case 1: Each moving vertex → each stationary edge
|
||||
// Case 1: Each moving vertex -> each stationary edge
|
||||
var movingVertices = new HashSet<Vector>();
|
||||
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;
|
||||
}
|
||||
movingVertices.Add(movingLines[i].pt1);
|
||||
movingVertices.Add(movingLines[i].pt2);
|
||||
}
|
||||
|
||||
// Case 2: Each stationary vertex → each moving edge (opposite direction)
|
||||
var opposite = OppositeDirection(direction);
|
||||
var stationaryEdges = new (Vector start, Vector end)[stationaryLines.Count];
|
||||
for (int i = 0; i < stationaryLines.Count; i++)
|
||||
stationaryEdges[i] = (stationaryLines[i].pt1, stationaryLines[i].pt2);
|
||||
|
||||
// Sort edges for pruning if not already sorted (usually they aren't here)
|
||||
if (direction == PushDirection.Left || direction == PushDirection.Right)
|
||||
stationaryEdges = stationaryEdges.OrderBy(e => System.Math.Min(e.start.Y, e.end.Y)).ToArray();
|
||||
else
|
||||
stationaryEdges = stationaryEdges.OrderBy(e => System.Math.Min(e.start.X, e.end.X)).ToArray();
|
||||
|
||||
foreach (var mv in movingVertices)
|
||||
{
|
||||
var d = OneWayDistance(mv, stationaryEdges, Vector.Zero, direction);
|
||||
if (d < minDist) minDist = d;
|
||||
}
|
||||
|
||||
// Case 2: Each stationary vertex -> each moving edge (opposite direction)
|
||||
var opposite = OppositeDirection(direction);
|
||||
var stationaryVertices = new HashSet<Vector>();
|
||||
for (int i = 0; i < stationaryLines.Count; i++)
|
||||
{
|
||||
var stationaryStart = stationaryLines[i].pt1;
|
||||
var stationaryEnd = stationaryLines[i].pt2;
|
||||
stationaryVertices.Add(stationaryLines[i].pt1);
|
||||
stationaryVertices.Add(stationaryLines[i].pt2);
|
||||
}
|
||||
|
||||
for (int j = 0; j < movingLines.Count; j++)
|
||||
{
|
||||
var d = RayEdgeDistance(stationaryStart, movingLines[j], opposite);
|
||||
if (d < minDist) minDist = d;
|
||||
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);
|
||||
|
||||
d = RayEdgeDistance(stationaryEnd, movingLines[j], opposite);
|
||||
if (d < minDist) minDist = d;
|
||||
}
|
||||
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;
|
||||
@@ -974,51 +990,53 @@ namespace OpenNest
|
||||
List<Line> stationaryLines, PushDirection direction)
|
||||
{
|
||||
var minDist = double.MaxValue;
|
||||
var movingOffset = new Vector(movingDx, movingDy);
|
||||
|
||||
// Case 1: Each moving vertex → each stationary edge
|
||||
// Case 1: Each moving vertex -> each stationary edge
|
||||
var movingVertices = new HashSet<Vector>();
|
||||
for (int i = 0; i < movingLines.Count; i++)
|
||||
{
|
||||
var ml = movingLines[i];
|
||||
var mx1 = ml.pt1.X + movingDx;
|
||||
var my1 = ml.pt1.Y + movingDy;
|
||||
var mx2 = ml.pt2.X + movingDx;
|
||||
var my2 = ml.pt2.Y + movingDy;
|
||||
|
||||
for (int j = 0; j < stationaryLines.Count; j++)
|
||||
{
|
||||
var se = stationaryLines[j];
|
||||
var d = RayEdgeDistance(mx1, my1, se.pt1.X, se.pt1.Y, se.pt2.X, se.pt2.Y, direction);
|
||||
if (d < minDist) minDist = d;
|
||||
|
||||
d = RayEdgeDistance(mx2, my2, se.pt1.X, se.pt1.Y, se.pt2.X, se.pt2.Y, direction);
|
||||
if (d < minDist) minDist = d;
|
||||
}
|
||||
movingVertices.Add(movingLines[i].pt1 + movingOffset);
|
||||
movingVertices.Add(movingLines[i].pt2 + movingOffset);
|
||||
}
|
||||
|
||||
// Case 2: Each stationary vertex → each moving edge (opposite direction)
|
||||
var opposite = OppositeDirection(direction);
|
||||
var stationaryEdges = new (Vector start, Vector end)[stationaryLines.Count];
|
||||
for (int i = 0; i < stationaryLines.Count; i++)
|
||||
stationaryEdges[i] = (stationaryLines[i].pt1, stationaryLines[i].pt2);
|
||||
|
||||
if (direction == PushDirection.Left || direction == PushDirection.Right)
|
||||
stationaryEdges = stationaryEdges.OrderBy(e => System.Math.Min(e.start.Y, e.end.Y)).ToArray();
|
||||
else
|
||||
stationaryEdges = stationaryEdges.OrderBy(e => System.Math.Min(e.start.X, e.end.X)).ToArray();
|
||||
|
||||
foreach (var mv in movingVertices)
|
||||
{
|
||||
var d = OneWayDistance(mv, stationaryEdges, Vector.Zero, direction);
|
||||
if (d < minDist) minDist = d;
|
||||
}
|
||||
|
||||
// Case 2: Each stationary vertex -> each moving edge (opposite direction)
|
||||
var opposite = OppositeDirection(direction);
|
||||
var stationaryVertices = new HashSet<Vector>();
|
||||
for (int i = 0; i < stationaryLines.Count; i++)
|
||||
{
|
||||
var sl = stationaryLines[i];
|
||||
stationaryVertices.Add(stationaryLines[i].pt1);
|
||||
stationaryVertices.Add(stationaryLines[i].pt2);
|
||||
}
|
||||
|
||||
for (int j = 0; j < movingLines.Count; j++)
|
||||
{
|
||||
var me = movingLines[j];
|
||||
var d = RayEdgeDistance(
|
||||
sl.pt1.X, sl.pt1.Y,
|
||||
me.pt1.X + movingDx, me.pt1.Y + movingDy,
|
||||
me.pt2.X + movingDx, me.pt2.Y + movingDy,
|
||||
opposite);
|
||||
if (d < minDist) minDist = d;
|
||||
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);
|
||||
|
||||
d = RayEdgeDistance(
|
||||
sl.pt2.X, sl.pt2.Y,
|
||||
me.pt1.X + movingDx, me.pt1.Y + movingDy,
|
||||
me.pt2.X + movingDx, me.pt2.Y + movingDy,
|
||||
opposite);
|
||||
if (d < minDist) minDist = d;
|
||||
}
|
||||
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;
|
||||
@@ -1041,6 +1059,105 @@ namespace OpenNest
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Computes the minimum directional distance using raw edge arrays and location offsets
|
||||
/// to avoid all intermediate object allocations.
|
||||
/// </summary>
|
||||
public static double DirectionalDistance(
|
||||
(Vector start, Vector end)[] movingEdges, Vector movingOffset,
|
||||
(Vector start, Vector end)[] stationaryEdges, Vector stationaryOffset,
|
||||
PushDirection direction)
|
||||
{
|
||||
var minDist = double.MaxValue;
|
||||
|
||||
// Extract unique vertices from moving edges.
|
||||
var movingVertices = new HashSet<Vector>();
|
||||
for (var i = 0; i < movingEdges.Length; i++)
|
||||
{
|
||||
movingVertices.Add(movingEdges[i].start + movingOffset);
|
||||
movingVertices.Add(movingEdges[i].end + movingOffset);
|
||||
}
|
||||
|
||||
// Case 1: Each moving vertex -> each stationary edge
|
||||
foreach (var mv in movingVertices)
|
||||
{
|
||||
var d = OneWayDistance(mv, stationaryEdges, stationaryOffset, direction);
|
||||
if (d < minDist) minDist = d;
|
||||
}
|
||||
|
||||
// Case 2: Each stationary vertex -> each moving edge (opposite direction)
|
||||
var opposite = OppositeDirection(direction);
|
||||
var stationaryVertices = new HashSet<Vector>();
|
||||
for (var i = 0; i < stationaryEdges.Length; i++)
|
||||
{
|
||||
stationaryVertices.Add(stationaryEdges[i].start + stationaryOffset);
|
||||
stationaryVertices.Add(stationaryEdges[i].end + stationaryOffset);
|
||||
}
|
||||
|
||||
foreach (var sv in stationaryVertices)
|
||||
{
|
||||
var d = OneWayDistance(sv, movingEdges, movingOffset, opposite);
|
||||
if (d < minDist) minDist = d;
|
||||
}
|
||||
|
||||
return minDist;
|
||||
}
|
||||
|
||||
public static double OneWayDistance(
|
||||
Vector vertex, (Vector start, Vector end)[] edges, Vector edgeOffset,
|
||||
PushDirection direction)
|
||||
{
|
||||
var minDist = double.MaxValue;
|
||||
var vx = vertex.X;
|
||||
var vy = vertex.Y;
|
||||
|
||||
// Pruning: edges are sorted by their perpendicular min-coordinate in PartBoundary.
|
||||
if (direction == PushDirection.Left || direction == PushDirection.Right)
|
||||
{
|
||||
for (var i = 0; i < edges.Length; i++)
|
||||
{
|
||||
var e1 = edges[i].start + edgeOffset;
|
||||
var e2 = edges[i].end + edgeOffset;
|
||||
|
||||
var minY = e1.Y < e2.Y ? e1.Y : e2.Y;
|
||||
var maxY = e1.Y > e2.Y ? e1.Y : e2.Y;
|
||||
|
||||
// Since edges are sorted by minY, if vy < minY, then vy < all subsequent minY.
|
||||
if (vy < minY - Tolerance.Epsilon)
|
||||
break;
|
||||
|
||||
if (vy > maxY + Tolerance.Epsilon)
|
||||
continue;
|
||||
|
||||
var d = RayEdgeDistance(vx, vy, e1.X, e1.Y, e2.X, e2.Y, direction);
|
||||
if (d < minDist) minDist = d;
|
||||
}
|
||||
}
|
||||
else // Up/Down
|
||||
{
|
||||
for (var i = 0; i < edges.Length; i++)
|
||||
{
|
||||
var e1 = edges[i].start + edgeOffset;
|
||||
var e2 = edges[i].end + edgeOffset;
|
||||
|
||||
var minX = e1.X < e2.X ? e1.X : e2.X;
|
||||
var maxX = e1.X > e2.X ? e1.X : e2.X;
|
||||
|
||||
// Since edges are sorted by minX, if vx < minX, then vx < all subsequent minX.
|
||||
if (vx < minX - Tolerance.Epsilon)
|
||||
break;
|
||||
|
||||
if (vx > maxX + Tolerance.Epsilon)
|
||||
continue;
|
||||
|
||||
var d = RayEdgeDistance(vx, vy, e1.X, e1.Y, e2.X, e2.Y, direction);
|
||||
if (d < minDist) minDist = d;
|
||||
}
|
||||
}
|
||||
|
||||
return minDist;
|
||||
}
|
||||
|
||||
public static PushDirection OppositeDirection(PushDirection direction)
|
||||
{
|
||||
switch (direction)
|
||||
@@ -1053,6 +1170,47 @@ namespace OpenNest
|
||||
}
|
||||
}
|
||||
|
||||
public static bool IsHorizontalDirection(PushDirection direction)
|
||||
{
|
||||
return direction is PushDirection.Left or PushDirection.Right;
|
||||
}
|
||||
|
||||
public static double EdgeDistance(Box box, Box boundary, PushDirection direction)
|
||||
{
|
||||
switch (direction)
|
||||
{
|
||||
case PushDirection.Left: return box.Left - boundary.Left;
|
||||
case PushDirection.Right: return boundary.Right - box.Right;
|
||||
case PushDirection.Up: return boundary.Top - box.Top;
|
||||
case PushDirection.Down: return box.Bottom - boundary.Bottom;
|
||||
default: return double.MaxValue;
|
||||
}
|
||||
}
|
||||
|
||||
public static Vector DirectionToOffset(PushDirection direction, double distance)
|
||||
{
|
||||
switch (direction)
|
||||
{
|
||||
case PushDirection.Left: return new Vector(-distance, 0);
|
||||
case PushDirection.Right: return new Vector(distance, 0);
|
||||
case PushDirection.Up: return new Vector(0, distance);
|
||||
case PushDirection.Down: return new Vector(0, -distance);
|
||||
default: return new Vector();
|
||||
}
|
||||
}
|
||||
|
||||
public static double DirectionalGap(Box from, Box to, PushDirection direction)
|
||||
{
|
||||
switch (direction)
|
||||
{
|
||||
case PushDirection.Left: return from.Left - to.Right;
|
||||
case PushDirection.Right: return to.Left - from.Right;
|
||||
case PushDirection.Up: return to.Bottom - from.Top;
|
||||
case PushDirection.Down: return from.Bottom - to.Top;
|
||||
default: return double.MaxValue;
|
||||
}
|
||||
}
|
||||
|
||||
public static double ClosestDistanceLeft(Box box, List<Box> boxes)
|
||||
{
|
||||
var closestDistance = double.MaxValue;
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using OpenNest.Converters;
|
||||
using OpenNest.Geometry;
|
||||
using OpenNest.Math;
|
||||
|
||||
namespace OpenNest
|
||||
{
|
||||
/// <summary>
|
||||
/// Mixed-part geometry-aware nesting using NFP-based collision avoidance
|
||||
/// and simulated annealing optimization.
|
||||
/// </summary>
|
||||
public static class AutoNester
|
||||
{
|
||||
public static List<Part> Nest(List<NestItem> items, Plate plate,
|
||||
CancellationToken cancellation = default)
|
||||
{
|
||||
var workArea = plate.WorkArea();
|
||||
var halfSpacing = plate.PartSpacing / 2.0;
|
||||
var nfpCache = new NfpCache();
|
||||
var candidateRotations = new Dictionary<int, List<double>>();
|
||||
|
||||
// Extract perimeter polygons for each unique drawing.
|
||||
foreach (var item in items)
|
||||
{
|
||||
var drawing = item.Drawing;
|
||||
|
||||
if (candidateRotations.ContainsKey(drawing.Id))
|
||||
continue;
|
||||
|
||||
var perimeterPolygon = ExtractPerimeterPolygon(drawing, halfSpacing);
|
||||
|
||||
if (perimeterPolygon == null)
|
||||
{
|
||||
Debug.WriteLine($"[AutoNest] Skipping drawing '{drawing.Name}': no valid perimeter");
|
||||
continue;
|
||||
}
|
||||
|
||||
// Compute candidate rotations for this drawing.
|
||||
var rotations = ComputeCandidateRotations(item, perimeterPolygon, workArea);
|
||||
candidateRotations[drawing.Id] = rotations;
|
||||
|
||||
// Register polygons at each candidate rotation.
|
||||
foreach (var rotation in rotations)
|
||||
{
|
||||
var rotatedPolygon = RotatePolygon(perimeterPolygon, rotation);
|
||||
nfpCache.RegisterPolygon(drawing.Id, rotation, rotatedPolygon);
|
||||
}
|
||||
}
|
||||
|
||||
if (candidateRotations.Count == 0)
|
||||
return new List<Part>();
|
||||
|
||||
// Pre-compute all NFPs.
|
||||
nfpCache.PreComputeAll();
|
||||
|
||||
Debug.WriteLine($"[AutoNest] NFP cache: {nfpCache.Count} entries for {candidateRotations.Count} drawings");
|
||||
|
||||
// Run simulated annealing optimizer.
|
||||
var optimizer = new SimulatedAnnealing();
|
||||
var result = optimizer.Optimize(items, workArea, nfpCache, candidateRotations, cancellation);
|
||||
|
||||
if (result.Sequence == null || result.Sequence.Count == 0)
|
||||
return new List<Part>();
|
||||
|
||||
// Final BLF placement with the best solution.
|
||||
var blf = new BottomLeftFill(workArea, nfpCache);
|
||||
var placedParts = blf.Fill(result.Sequence);
|
||||
var parts = BottomLeftFill.ToNestParts(placedParts);
|
||||
|
||||
Debug.WriteLine($"[AutoNest] Result: {parts.Count} parts placed, {result.Iterations} SA iterations");
|
||||
|
||||
return parts;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extracts the perimeter polygon from a drawing, inflated by half-spacing.
|
||||
/// </summary>
|
||||
private static Polygon ExtractPerimeterPolygon(Drawing drawing, double halfSpacing)
|
||||
{
|
||||
var entities = ConvertProgram.ToGeometry(drawing.Program)
|
||||
.Where(e => e.Layer != SpecialLayers.Rapid)
|
||||
.ToList();
|
||||
|
||||
if (entities.Count == 0)
|
||||
return null;
|
||||
|
||||
var definedShape = new ShapeProfile(entities);
|
||||
var perimeter = definedShape.Perimeter;
|
||||
|
||||
if (perimeter == null)
|
||||
return null;
|
||||
|
||||
// Inflate by half-spacing if spacing is non-zero.
|
||||
Shape inflated;
|
||||
|
||||
if (halfSpacing > 0)
|
||||
{
|
||||
var offsetEntity = perimeter.OffsetEntity(halfSpacing, OffsetSide.Right);
|
||||
inflated = offsetEntity as Shape ?? perimeter;
|
||||
}
|
||||
else
|
||||
{
|
||||
inflated = perimeter;
|
||||
}
|
||||
|
||||
// Convert to polygon with circumscribed arcs for tight nesting.
|
||||
var polygon = inflated.ToPolygonWithTolerance(0.01, circumscribe: true);
|
||||
|
||||
if (polygon.Vertices.Count < 3)
|
||||
return null;
|
||||
|
||||
// Normalize: move reference point to origin.
|
||||
polygon.UpdateBounds();
|
||||
var bb = polygon.BoundingBox;
|
||||
polygon.Offset(-bb.Left, -bb.Bottom);
|
||||
|
||||
return polygon;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Computes candidate rotation angles for a drawing.
|
||||
/// </summary>
|
||||
private static List<double> ComputeCandidateRotations(NestItem item,
|
||||
Polygon perimeterPolygon, Box workArea)
|
||||
{
|
||||
var rotations = new List<double> { 0 };
|
||||
|
||||
// Add hull-edge angles from the polygon itself.
|
||||
var hullAngles = ComputeHullEdgeAngles(perimeterPolygon);
|
||||
|
||||
foreach (var angle in hullAngles)
|
||||
{
|
||||
if (!rotations.Any(r => r.IsEqualTo(angle)))
|
||||
rotations.Add(angle);
|
||||
}
|
||||
|
||||
// Add 90-degree rotation.
|
||||
if (!rotations.Any(r => r.IsEqualTo(Angle.HalfPI)))
|
||||
rotations.Add(Angle.HalfPI);
|
||||
|
||||
// For narrow work areas, add sweep angles.
|
||||
var partBounds = perimeterPolygon.BoundingBox;
|
||||
var partLongest = System.Math.Max(partBounds.Width, partBounds.Length);
|
||||
var workShort = System.Math.Min(workArea.Width, workArea.Length);
|
||||
|
||||
if (workShort < partLongest)
|
||||
{
|
||||
var step = Angle.ToRadians(5);
|
||||
|
||||
for (var a = 0.0; a < System.Math.PI; a += step)
|
||||
{
|
||||
if (!rotations.Any(r => r.IsEqualTo(a)))
|
||||
rotations.Add(a);
|
||||
}
|
||||
}
|
||||
|
||||
return rotations;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Computes convex hull edge angles from a polygon for candidate rotations.
|
||||
/// </summary>
|
||||
private static List<double> ComputeHullEdgeAngles(Polygon polygon)
|
||||
{
|
||||
var angles = new List<double>();
|
||||
|
||||
if (polygon.Vertices.Count < 3)
|
||||
return angles;
|
||||
|
||||
var hull = ConvexHull.Compute(polygon.Vertices);
|
||||
var verts = hull.Vertices;
|
||||
var n = hull.IsClosed() ? verts.Count - 1 : verts.Count;
|
||||
|
||||
for (var i = 0; i < n; i++)
|
||||
{
|
||||
var next = (i + 1) % n;
|
||||
var dx = verts[next].X - verts[i].X;
|
||||
var dy = verts[next].Y - verts[i].Y;
|
||||
|
||||
if (dx * dx + dy * dy < Tolerance.Epsilon)
|
||||
continue;
|
||||
|
||||
var angle = -System.Math.Atan2(dy, dx);
|
||||
|
||||
if (!angles.Any(a => a.IsEqualTo(angle)))
|
||||
angles.Add(angle);
|
||||
}
|
||||
|
||||
return angles;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a rotated copy of a polygon around the origin.
|
||||
/// </summary>
|
||||
private static Polygon RotatePolygon(Polygon polygon, double angle)
|
||||
{
|
||||
if (angle.IsEqualTo(0))
|
||||
return polygon;
|
||||
|
||||
var result = new Polygon();
|
||||
var cos = System.Math.Cos(angle);
|
||||
var sin = System.Math.Sin(angle);
|
||||
|
||||
foreach (var v in polygon.Vertices)
|
||||
{
|
||||
result.Vertices.Add(new Vector(
|
||||
v.X * cos - v.Y * sin,
|
||||
v.X * sin + v.Y * cos));
|
||||
}
|
||||
|
||||
// Re-normalize to origin.
|
||||
result.UpdateBounds();
|
||||
var bb = result.BoundingBox;
|
||||
result.Offset(-bb.Left, -bb.Bottom);
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -153,6 +153,9 @@ namespace OpenNest.Engine.BestFit
|
||||
public static void Populate(Drawing drawing, double plateWidth, double plateHeight,
|
||||
double spacing, List<BestFitResult> results)
|
||||
{
|
||||
if (results == null || results.Count == 0)
|
||||
return;
|
||||
|
||||
var key = new CacheKey(drawing, plateWidth, plateHeight, spacing);
|
||||
_cache.TryAdd(key, results);
|
||||
}
|
||||
|
||||
@@ -20,10 +20,13 @@ namespace OpenNest.Engine.BestFit
|
||||
{
|
||||
_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)
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.Engine.BestFit
|
||||
@@ -147,11 +148,82 @@ namespace OpenNest.Engine.BestFit
|
||||
|
||||
var results = new double[count];
|
||||
|
||||
for (var i = 0; i < count; i++)
|
||||
// Pre-calculate moving vertices in local space.
|
||||
var movingVerticesLocal = new HashSet<Vector>();
|
||||
for (var i = 0; i < part2TemplateLines.Count; i++)
|
||||
{
|
||||
results[i] = Helper.DirectionalDistance(
|
||||
part2TemplateLines, allDx[i], allDy[i], part1Lines, allDirs[i]);
|
||||
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<Vector>();
|
||||
for (var i = 0; i < part1Lines.Count; i++)
|
||||
{
|
||||
stationaryVerticesLocal.Add(part1Lines[i].StartPoint);
|
||||
stationaryVerticesLocal.Add(part1Lines[i].EndPoint);
|
||||
}
|
||||
var stationaryVerticesArray = stationaryVerticesLocal.ToArray();
|
||||
|
||||
// Pre-sort stationary and moving edges for all 4 directions.
|
||||
var stationaryEdgesByDir = new Dictionary<PushDirection, (Vector start, Vector end)[]>();
|
||||
var movingEdgesByDir = new Dictionary<PushDirection, (Vector start, Vector end)[]>();
|
||||
|
||||
foreach (var dir in AllDirections)
|
||||
{
|
||||
var sEdges = new (Vector start, Vector end)[part1Lines.Count];
|
||||
for (var i = 0; i < part1Lines.Count; i++)
|
||||
sEdges[i] = (part1Lines[i].StartPoint, part1Lines[i].EndPoint);
|
||||
|
||||
if (dir == PushDirection.Left || dir == PushDirection.Right)
|
||||
sEdges = sEdges.OrderBy(e => System.Math.Min(e.start.Y, e.end.Y)).ToArray();
|
||||
else
|
||||
sEdges = sEdges.OrderBy(e => System.Math.Min(e.start.X, e.end.X)).ToArray();
|
||||
stationaryEdgesByDir[dir] = sEdges;
|
||||
|
||||
var opposite = Helper.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 = Helper.OppositeDirection(dir);
|
||||
|
||||
var minDist = double.MaxValue;
|
||||
|
||||
// Case 1: Moving vertices -> Stationary edges
|
||||
foreach (var mv in movingVerticesArray)
|
||||
{
|
||||
var d = Helper.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 = Helper.OneWayDistance(sv, mEdges, movingOffset, opposite);
|
||||
if (d < minDist) minDist = d;
|
||||
}
|
||||
|
||||
results[i] = minDist;
|
||||
});
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
+98
-110
@@ -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 = Helper.DirectionalDistance(
|
||||
boundary.GetEdges(pushDir), partA.Location + locationBOffset,
|
||||
boundary.GetEdges(Helper.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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -107,7 +107,6 @@ namespace OpenNest
|
||||
|
||||
// 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<Line>[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 = Helper.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
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -330,54 +322,46 @@ namespace OpenNest
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
private List<Part> FillRecursive(Pattern pattern, NestDirection direction, int depth)
|
||||
private List<Part> FillGrid(Pattern pattern, NestDirection direction)
|
||||
{
|
||||
var perpAxis = PerpendicularAxis(direction);
|
||||
var boundaries = CreateBoundaries(pattern);
|
||||
var result = new List<Part>(pattern.Parts);
|
||||
result.AddRange(TilePattern(pattern, direction, boundaries));
|
||||
|
||||
if (depth == 0 && result.Count > pattern.Parts.Count)
|
||||
// Step 1: Tile along primary axis
|
||||
var row = new List<Part>(pattern.Parts);
|
||||
row.AddRange(TilePattern(pattern, direction, boundaries));
|
||||
|
||||
// If primary tiling didn't produce copies, just tile along perpendicular
|
||||
if (row.Count <= pattern.Parts.Count)
|
||||
{
|
||||
var rowPattern = new Pattern();
|
||||
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<Part>(rowPattern.Parts);
|
||||
gridResult.AddRange(TilePattern(rowPattern, perpAxis, rowBoundaries));
|
||||
|
||||
// Step 3: Fill remaining strip
|
||||
var remaining = FillRemainingStrip(gridResult, pattern, perpAxis, direction);
|
||||
if (remaining.Count > 0)
|
||||
gridResult.AddRange(remaining);
|
||||
|
||||
// Step 4: Try fewer rows optimization
|
||||
var fewerResult = TryFewerRows(gridResult, rowPattern, pattern, perpAxis, direction);
|
||||
if (fewerResult != null && fewerResult.Count > gridResult.Count)
|
||||
return fewerResult;
|
||||
|
||||
return gridResult;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -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<Part>(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<Part> 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<Part>();
|
||||
|
||||
var rotations = BuildRotationSet(seedPattern);
|
||||
return FindBestFill(rotations, remainingStrip);
|
||||
}
|
||||
|
||||
private static double FindPlacedEdge(List<Part> 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<Part>();
|
||||
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<Part>();
|
||||
return null;
|
||||
|
||||
remainingStrip = new Box(left, WorkArea.Y, width, WorkArea.Length);
|
||||
return new Box(left, WorkArea.Y, width, WorkArea.Length);
|
||||
}
|
||||
}
|
||||
|
||||
// Build rotation set: always try cardinal orientations (0° and 90°),
|
||||
// plus any unique rotations from the seed pattern.
|
||||
var filler = new FillLinear(remainingStrip, PartSpacing);
|
||||
List<Part> best = null;
|
||||
/// <summary>
|
||||
/// Builds a set of (drawing, rotation) candidates: cardinal orientations
|
||||
/// (0° and 90°) for each unique drawing, plus any seed pattern rotations
|
||||
/// not already covered.
|
||||
/// </summary>
|
||||
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<Drawing>();
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tries all rotation candidates in both directions in parallel, returns the
|
||||
/// fill with the most parts.
|
||||
/// </summary>
|
||||
private List<Part> FindBestFill(List<(Drawing drawing, double rotation)> rotations, Box strip)
|
||||
{
|
||||
var bag = new System.Collections.Concurrent.ConcurrentBag<List<Part>>();
|
||||
|
||||
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<Part> 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<Part>();
|
||||
|
||||
return FillRecursive(basePattern, primaryAxis, depth: 0);
|
||||
return FillGrid(basePattern, primaryAxis);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -618,7 +606,7 @@ namespace OpenNest
|
||||
if (seed.Parts.Count == 0)
|
||||
return new List<Part>();
|
||||
|
||||
return FillRecursive(seed, primaryAxis, depth: 0);
|
||||
return FillGrid(seed, primaryAxis);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using Microsoft.ML.OnnxRuntime;
|
||||
using Microsoft.ML.OnnxRuntime.Tensors;
|
||||
using OpenNest.Math;
|
||||
|
||||
namespace OpenNest.Engine.ML
|
||||
{
|
||||
public static class AnglePredictor
|
||||
{
|
||||
private static InferenceSession _session;
|
||||
private static volatile bool _loadAttempted;
|
||||
private static readonly object _lock = new();
|
||||
|
||||
public static List<double> PredictAngles(
|
||||
PartFeatures features, double sheetWidth, double sheetHeight,
|
||||
double threshold = 0.3)
|
||||
{
|
||||
var session = GetSession();
|
||||
if (session == null)
|
||||
return null;
|
||||
|
||||
try
|
||||
{
|
||||
var input = new float[11];
|
||||
input[0] = (float)features.Area;
|
||||
input[1] = (float)features.Convexity;
|
||||
input[2] = (float)features.AspectRatio;
|
||||
input[3] = (float)features.BoundingBoxFill;
|
||||
input[4] = (float)features.Circularity;
|
||||
input[5] = (float)features.PerimeterToAreaRatio;
|
||||
input[6] = features.VertexCount;
|
||||
input[7] = (float)sheetWidth;
|
||||
input[8] = (float)sheetHeight;
|
||||
input[9] = (float)(sheetWidth / (sheetHeight > 0 ? sheetHeight : 1.0));
|
||||
input[10] = (float)(features.Area / (sheetWidth * sheetHeight));
|
||||
|
||||
var tensor = new DenseTensor<float>(input, new[] { 1, 11 });
|
||||
var inputs = new List<NamedOnnxValue>
|
||||
{
|
||||
NamedOnnxValue.CreateFromTensor("features", tensor)
|
||||
};
|
||||
|
||||
using var results = session.Run(inputs);
|
||||
var probabilities = results.First().AsEnumerable<float>().ToArray();
|
||||
|
||||
var angles = new List<(double angleDeg, float prob)>();
|
||||
for (var i = 0; i < 36 && i < probabilities.Length; i++)
|
||||
{
|
||||
if (probabilities[i] >= threshold)
|
||||
angles.Add((i * 5.0, probabilities[i]));
|
||||
}
|
||||
|
||||
// Minimum 3 angles — take top by probability if fewer pass threshold.
|
||||
if (angles.Count < 3)
|
||||
{
|
||||
angles = probabilities
|
||||
.Select((p, i) => (angleDeg: i * 5.0, prob: p))
|
||||
.OrderByDescending(x => x.prob)
|
||||
.Take(3)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
// Always include 0 and 90 as safety fallback.
|
||||
var result = angles.Select(a => Angle.ToRadians(a.angleDeg)).ToList();
|
||||
|
||||
if (!result.Any(a => a.IsEqualTo(0)))
|
||||
result.Add(0);
|
||||
if (!result.Any(a => a.IsEqualTo(Angle.HalfPI)))
|
||||
result.Add(Angle.HalfPI);
|
||||
|
||||
return result;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Debug.WriteLine($"[AnglePredictor] Inference failed: {ex.Message}");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static InferenceSession GetSession()
|
||||
{
|
||||
if (_loadAttempted)
|
||||
return _session;
|
||||
|
||||
lock (_lock)
|
||||
{
|
||||
if (_loadAttempted)
|
||||
return _session;
|
||||
|
||||
_loadAttempted = true;
|
||||
|
||||
try
|
||||
{
|
||||
var dir = Path.GetDirectoryName(typeof(AnglePredictor).Assembly.Location);
|
||||
var modelPath = Path.Combine(dir, "Models", "angle_predictor.onnx");
|
||||
|
||||
if (!File.Exists(modelPath))
|
||||
{
|
||||
Debug.WriteLine($"[AnglePredictor] Model not found: {modelPath}");
|
||||
return null;
|
||||
}
|
||||
|
||||
_session = new InferenceSession(modelPath);
|
||||
Debug.WriteLine("[AnglePredictor] Model loaded successfully");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Debug.WriteLine($"[AnglePredictor] Failed to load model: {ex.Message}");
|
||||
}
|
||||
|
||||
return _session;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -12,13 +12,23 @@ namespace OpenNest.Engine.ML
|
||||
public long TimeMs { get; set; }
|
||||
public string LayoutData { get; set; }
|
||||
public List<Part> PlacedParts { get; set; }
|
||||
public string WinnerEngine { get; set; } = "";
|
||||
public long WinnerTimeMs { get; set; }
|
||||
public string RunnerUpEngine { get; set; } = "";
|
||||
public int RunnerUpPartCount { get; set; }
|
||||
public long RunnerUpTimeMs { get; set; }
|
||||
public string ThirdPlaceEngine { get; set; } = "";
|
||||
public int ThirdPlacePartCount { get; set; }
|
||||
public long ThirdPlaceTimeMs { get; set; }
|
||||
public List<AngleResult> AngleResults { get; set; } = new();
|
||||
}
|
||||
|
||||
public static class BruteForceRunner
|
||||
{
|
||||
public static BruteForceResult Run(Drawing drawing, Plate plate)
|
||||
public static BruteForceResult Run(Drawing drawing, Plate plate, bool forceFullAngleSweep = false)
|
||||
{
|
||||
var engine = new NestEngine(plate);
|
||||
engine.ForceFullAngleSweep = forceFullAngleSweep;
|
||||
var item = new NestItem { Drawing = drawing };
|
||||
|
||||
var sw = Stopwatch.StartNew();
|
||||
@@ -28,13 +38,30 @@ namespace OpenNest.Engine.ML
|
||||
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
|
||||
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()
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+397
-592
File diff suppressed because it is too large
Load Diff
@@ -10,13 +10,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<Part> BestParts { get; set; }
|
||||
public string Description { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,4 +7,10 @@
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\OpenNest.Core\OpenNest.Core.csproj" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.ML.OnnxRuntime" Version="1.17.3" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<Content Include="Models\**" CopyToOutputDirectory="PreserveNewest" Condition="Exists('Models')" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
@@ -93,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();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -152,5 +152,14 @@ namespace OpenNest
|
||||
default: return _leftEdges;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the pre-computed edge arrays for the given direction.
|
||||
/// These are in part-local coordinates (no translation applied).
|
||||
/// </summary>
|
||||
public (Vector start, Vector end)[] GetEdges(PushDirection direction)
|
||||
{
|
||||
return GetDirectionalEdges(direction);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -233,7 +233,7 @@ namespace OpenNest.Mcp.Tools
|
||||
items.Add(new NestItem { Drawing = drawing, Quantity = qtys[i] });
|
||||
}
|
||||
|
||||
var parts = NestEngine.AutoNest(items, plate);
|
||||
var parts = AutoNester.Nest(items, plate);
|
||||
plate.Parts.AddRange(parts);
|
||||
|
||||
var sb = new StringBuilder();
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ namespace OpenNest.Training.Data
|
||||
{
|
||||
public DbSet<TrainingPart> Parts { get; set; }
|
||||
public DbSet<TrainingRun> Runs { get; set; }
|
||||
public DbSet<TrainingAngleResult> AngleResults { get; set; }
|
||||
|
||||
private readonly string _dbPath;
|
||||
|
||||
@@ -33,6 +34,14 @@ namespace OpenNest.Training.Data
|
||||
.WithMany(p => p.Runs)
|
||||
.HasForeignKey(r => r.PartId);
|
||||
});
|
||||
|
||||
modelBuilder.Entity<TrainingAngleResult>(e =>
|
||||
{
|
||||
e.HasIndex(a => a.RunId).HasDatabaseName("idx_angleresults_runid");
|
||||
e.HasOne(a => a.Run)
|
||||
.WithMany(r => r.AngleResults)
|
||||
.HasForeignKey(a => a.RunId);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using System.Collections.Generic;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
|
||||
@@ -18,8 +19,18 @@ namespace OpenNest.Training.Data
|
||||
public long TimeMs { get; set; }
|
||||
public string LayoutData { get; set; }
|
||||
public string FilePath { get; set; }
|
||||
public string WinnerEngine { get; set; } = "";
|
||||
public long WinnerTimeMs { get; set; }
|
||||
public string RunnerUpEngine { get; set; } = "";
|
||||
public int RunnerUpPartCount { get; set; }
|
||||
public long RunnerUpTimeMs { get; set; }
|
||||
public string ThirdPlaceEngine { get; set; } = "";
|
||||
public int ThirdPlacePartCount { get; set; }
|
||||
public long ThirdPlaceTimeMs { get; set; }
|
||||
|
||||
[ForeignKey(nameof(PartId))]
|
||||
public TrainingPart Part { get; set; }
|
||||
|
||||
public List<TrainingAngleResult> AngleResults { get; set; } = new();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -200,7 +200,7 @@ int RunDataCollection(string dir, string dbPath, string saveDir, double s, strin
|
||||
}
|
||||
|
||||
var sizeSw = Stopwatch.StartNew();
|
||||
var result = BruteForceRunner.Run(drawing, runPlate);
|
||||
var result = BruteForceRunner.Run(drawing, runPlate, forceFullAngleSweep: true);
|
||||
sizeSw.Stop();
|
||||
|
||||
if (result == null)
|
||||
@@ -215,7 +215,12 @@ int RunDataCollection(string dir, string dbPath, string saveDir, double s, strin
|
||||
bestCount = result.PartCount;
|
||||
}
|
||||
|
||||
Console.WriteLine($" {size.Length}x{size.Width} - {result.PartCount}pcs, {result.Utilization:P1}, {sizeSw.ElapsedMilliseconds}ms");
|
||||
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)
|
||||
@@ -258,7 +263,7 @@ int RunDataCollection(string dir, string dbPath, string saveDir, double s, strin
|
||||
writer.Write(savedFilePath);
|
||||
}
|
||||
|
||||
db.AddRun(partId, size.Width, size.Length, s, result, savedFilePath);
|
||||
db.AddRun(partId, size.Width, size.Length, s, result, savedFilePath, result.AngleResults);
|
||||
runsThisPart++;
|
||||
totalRuns++;
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ namespace OpenNest.Training
|
||||
|
||||
_db = new TrainingDbContext(dbPath);
|
||||
_db.Database.EnsureCreated();
|
||||
MigrateSchema();
|
||||
}
|
||||
|
||||
public long GetOrAddPart(string fileName, PartFeatures features, string geometryData)
|
||||
@@ -61,7 +62,7 @@ namespace OpenNest.Training
|
||||
return _db.Runs.Count(r => r.Part.FileName == fileName);
|
||||
}
|
||||
|
||||
public void AddRun(long partId, double w, double h, double s, BruteForceResult result, string filePath)
|
||||
public void AddRun(long partId, double w, double h, double s, BruteForceResult result, string filePath, List<AngleResult> angleResults = null)
|
||||
{
|
||||
var run = new TrainingRun
|
||||
{
|
||||
@@ -73,10 +74,33 @@ namespace OpenNest.Training
|
||||
Utilization = result.Utilization,
|
||||
TimeMs = result.TimeMs,
|
||||
LayoutData = result.LayoutData ?? "",
|
||||
FilePath = filePath ?? ""
|
||||
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();
|
||||
}
|
||||
|
||||
@@ -118,6 +142,52 @@ namespace OpenNest.Training
|
||||
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();
|
||||
|
||||
@@ -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
|
||||
@@ -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)\")"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -937,94 +937,62 @@ 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<List<Line>>(stationaryParts.Count);
|
||||
var stationaryBoxes = new List<Box>(stationaryParts.Count);
|
||||
var stationaryBoxes = new Box[stationaryParts.Count];
|
||||
var stationaryLines = new List<Line>[stationaryParts.Count];
|
||||
|
||||
var opposite = Helper.OppositeDirection(direction);
|
||||
var halfSpacing = Plate.PartSpacing / 2;
|
||||
var isHorizontal = Helper.IsHorizontalDirection(direction);
|
||||
|
||||
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);
|
||||
}
|
||||
for (var i = 0; i < stationaryParts.Count; i++)
|
||||
stationaryBoxes[i] = stationaryParts[i].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);
|
||||
// Check plate edge first to tighten the upper bound.
|
||||
var edgeDist = Helper.EdgeDistance(selected.BoundingBox, workArea, direction);
|
||||
if (edgeDist > 0 && edgeDist < distance)
|
||||
distance = edgeDist;
|
||||
|
||||
var movingBox = selected.BoundingBox;
|
||||
List<Line> movingLines = null;
|
||||
|
||||
// Check geometry distance against each stationary part.
|
||||
for (int i = 0; i < stationaryLines.Count; i++)
|
||||
for (var i = 0; i < stationaryBoxes.Length; i++)
|
||||
{
|
||||
// Early-out: skip if bounding boxes don't overlap on the perpendicular axis.
|
||||
var stBox = stationaryBoxes[i];
|
||||
bool perpOverlap;
|
||||
// Skip parts not ahead in the push direction or further than current best.
|
||||
var gap = Helper.DirectionalGap(movingBox, stationaryBoxes[i], direction);
|
||||
if (gap < 0 || gap >= distance)
|
||||
continue;
|
||||
|
||||
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;
|
||||
}
|
||||
var perpOverlap = isHorizontal
|
||||
? movingBox.IsHorizontalTo(stationaryBoxes[i], out _)
|
||||
: movingBox.IsVerticalTo(stationaryBoxes[i], out _);
|
||||
|
||||
if (!perpOverlap)
|
||||
continue;
|
||||
|
||||
// Compute lines lazily — only for parts that survive bounding box checks.
|
||||
movingLines ??= halfSpacing > 0
|
||||
? Helper.GetOffsetPartLines(selected.BasePart, halfSpacing, direction, OffsetTolerance)
|
||||
: Helper.GetPartLines(selected.BasePart, direction, OffsetTolerance);
|
||||
|
||||
stationaryLines[i] ??= halfSpacing > 0
|
||||
? Helper.GetOffsetPartLines(stationaryParts[i].BasePart, halfSpacing, opposite, OffsetTolerance)
|
||||
: Helper.GetPartLines(stationaryParts[i].BasePart, opposite, OffsetTolerance);
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
var offset = Helper.DirectionToOffset(direction, distance);
|
||||
SelectedParts.ForEach(p => p.Offset(offset));
|
||||
Invalidate();
|
||||
}
|
||||
|
||||
@@ -762,7 +762,7 @@ namespace OpenNest.Forms
|
||||
activeForm.LoadLastPlate();
|
||||
|
||||
var parts = await Task.Run(() =>
|
||||
NestEngine.AutoNest(remaining, plate, token));
|
||||
AutoNester.Nest(remaining, plate, token));
|
||||
|
||||
if (parts.Count == 0)
|
||||
break;
|
||||
|
||||
+263
-167
@@ -28,189 +28,281 @@ namespace OpenNest.Forms
|
||||
/// </summary>
|
||||
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.elapsedLabel = new System.Windows.Forms.Label();
|
||||
this.elapsedValue = 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.Controls.Add(this.elapsedLabel, 0, 5);
|
||||
this.table.Controls.Add(this.elapsedValue, 1, 5);
|
||||
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 = 6;
|
||||
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.RowStyles.Add(new System.Windows.Forms.RowStyle(System.Windows.Forms.SizeType.AutoSize));
|
||||
this.table.AutoSize = true;
|
||||
this.table.Size = new System.Drawing.Size(264, 156);
|
||||
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 = "Unused:";
|
||||
//
|
||||
//
|
||||
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
|
||||
//
|
||||
this.elapsedLabel.AutoSize = true;
|
||||
this.elapsedLabel.Font = new System.Drawing.Font(System.Drawing.SystemFonts.DefaultFont, System.Drawing.FontStyle.Bold);
|
||||
this.elapsedLabel.Margin = new System.Windows.Forms.Padding(4);
|
||||
this.elapsedLabel.Name = "elapsedLabel";
|
||||
this.elapsedLabel.Text = "Elapsed:";
|
||||
//
|
||||
//
|
||||
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
|
||||
//
|
||||
this.elapsedValue.AutoSize = true;
|
||||
this.elapsedValue.Margin = new System.Windows.Forms.Padding(4);
|
||||
this.elapsedValue.Name = "elapsedValue";
|
||||
this.elapsedValue.Text = "0:00";
|
||||
//
|
||||
//
|
||||
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, 207);
|
||||
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
|
||||
@@ -224,10 +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;
|
||||
}
|
||||
|
||||
@@ -36,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()
|
||||
@@ -49,6 +53,7 @@ namespace OpenNest.Forms
|
||||
UpdateElapsed();
|
||||
|
||||
phaseValue.Text = "Done";
|
||||
descriptionValue.Text = "\u2014";
|
||||
stopButton.Text = "Close";
|
||||
stopButton.Enabled = true;
|
||||
stopButton.Click -= StopButton_Click;
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<root>
|
||||
<!--
|
||||
Microsoft ResX Schema
|
||||
|
||||
<!--
|
||||
Microsoft ResX Schema
|
||||
|
||||
Version 2.0
|
||||
|
||||
The primary goals of this format is to allow a simple XML format
|
||||
that is mostly human readable. The generation and parsing of the
|
||||
various data types are done through the TypeConverter classes
|
||||
|
||||
The primary goals of this format is to allow a simple XML format
|
||||
that is mostly human readable. The generation and parsing of the
|
||||
various data types are done through the TypeConverter classes
|
||||
associated with the data types.
|
||||
|
||||
|
||||
Example:
|
||||
|
||||
|
||||
... ado.net/XML headers & schema ...
|
||||
<resheader name="resmimetype">text/microsoft-resx</resheader>
|
||||
<resheader name="version">2.0</resheader>
|
||||
@@ -26,36 +26,36 @@
|
||||
<value>[base64 mime encoded string representing a byte array form of the .NET Framework object]</value>
|
||||
<comment>This is a comment</comment>
|
||||
</data>
|
||||
|
||||
There are any number of "resheader" rows that contain simple
|
||||
|
||||
There are any number of "resheader" rows that contain simple
|
||||
name/value pairs.
|
||||
|
||||
Each data row contains a name, and value. The row also contains a
|
||||
type or mimetype. Type corresponds to a .NET class that support
|
||||
text/value conversion through the TypeConverter architecture.
|
||||
Classes that don't support this are serialized and stored with the
|
||||
|
||||
Each data row contains a name, and value. The row also contains a
|
||||
type or mimetype. Type corresponds to a .NET class that support
|
||||
text/value conversion through the TypeConverter architecture.
|
||||
Classes that don't support this are serialized and stored with the
|
||||
mimetype set.
|
||||
|
||||
The mimetype is used for serialized objects, and tells the
|
||||
ResXResourceReader how to depersist the object. This is currently not
|
||||
|
||||
The mimetype is used for serialized objects, and tells the
|
||||
ResXResourceReader how to depersist the object. This is currently not
|
||||
extensible. For a given mimetype the value must be set accordingly:
|
||||
|
||||
Note - application/x-microsoft.net.object.binary.base64 is the format
|
||||
that the ResXResourceWriter will generate, however the reader can
|
||||
|
||||
Note - application/x-microsoft.net.object.binary.base64 is the format
|
||||
that the ResXResourceWriter will generate, however the reader can
|
||||
read any of the formats listed below.
|
||||
|
||||
|
||||
mimetype: application/x-microsoft.net.object.binary.base64
|
||||
value : The object must be serialized with
|
||||
value : The object must be serialized with
|
||||
: System.Runtime.Serialization.Formatters.Binary.BinaryFormatter
|
||||
: and then encoded with base64 encoding.
|
||||
|
||||
|
||||
mimetype: application/x-microsoft.net.object.soap.base64
|
||||
value : The object must be serialized with
|
||||
value : The object must be serialized with
|
||||
: System.Runtime.Serialization.Formatters.Soap.SoapFormatter
|
||||
: and then encoded with base64 encoding.
|
||||
|
||||
mimetype: application/x-microsoft.net.object.bytearray.base64
|
||||
value : The object must be serialized into a byte array
|
||||
value : The object must be serialized into a byte array
|
||||
: using a System.ComponentModel.TypeConverter
|
||||
: and then encoded with base64 encoding.
|
||||
-->
|
||||
|
||||
@@ -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<AngleResult> 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<AngleResult>` 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<AngleResult> 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<double> 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<TrainingAngleResult>`
|
||||
- `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
|
||||
@@ -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<NestPhase> 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 |
|
||||
Reference in New Issue
Block a user