Compare commits

...

26 Commits

Author SHA1 Message Date
aj cab603c50d docs: fix spec issues from review — non-sequential stepper, font caching, sizing
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 01:54:07 -04:00
aj 40d99b402f docs: add NestProgressForm redesign spec
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 01:51:20 -04:00
aj 45509cfd3f feat(ui): add nested dimensions and area to progress window
Show width x length and total part area on the "Nested:" row
in the nesting progress dialog, using the existing GetBoundingBox
extension to compute the extents.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 01:42:34 -04:00
aj 8e0c082876 refactor(ui): optimize PushSelected with directional filtering and lazy line computation
Extract direction helpers to Helper class (EdgeDistance, DirectionalGap,
DirectionToOffset, IsHorizontalDirection) and use them to skip parts not
ahead in the push direction or further than the current best distance.
Defer line computation until parts survive bounding box checks.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 01:30:50 -04:00
aj 3c59da17c2 fix(engine): fix pair candidate filtering for narrow plates and strips
The BestFitFilter's aspect ratio cap of 5.0 was rejecting valid pair
candidates needed for narrow plates (e.g. 60x6.5, aspect 9.2) and
remainder strips on normal plates. Three fixes:

- BestFitFinder: derive MaxAspectRatio from the plate's own aspect
  ratio so narrow plates don't reject all elongated pairs
- SelectPairCandidates: search the full unfiltered candidate list
  (not just Keep=true) in strip mode, so pairs rejected by aspect
  ratio for the main plate can still be used for narrow remainder
  strips
- BestFitCache.Populate: skip caching empty result lists so stale
  pre-computed data from nest files doesn't prevent recomputation

Also fixes console --size parsing to use LxW format matching
Size.Parse convention, and includes prior engine refactoring
(sequential fill loops, parallel FillPattern, pre-sorted edge
arrays in RotationSlideStrategy).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 01:14:07 -04:00
aj ae010212ac refactor(engine): extract AutoNester and reorganize NestEngine
Move NFP-based AutoNest logic (polygon extraction, rotation computation,
simulated annealing) into dedicated AutoNester class. Consolidate duplicate
FillWithPairs overloads, extract BuildCandidateAngles and BuildProgressSummary,
reorganize NestEngine into logical sections. Update callers in Console,
MCP tools, and MainForm to use AutoNester.Nest.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-14 22:51:57 -04:00
aj ce6b25c12a refactor(engine): simplify FillLinear with iterative grid fill
Replace recursive FillRecursive with flat FillGrid that tiles along primary
axis, then perpendicular. Extract FindPlacedEdge, BuildRemainingStrip,
BuildRotationSet, FindBestFill helpers. Use array-based DirectionalDistance
to eliminate allocations in FindCopyDistance and FindPatternCopyDistance.
Simplify FindSinglePartPatternCopyDistance to delegate to FindCopyDistance.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-14 22:51:50 -04:00
aj 6993d169e4 perf(core): optimize geometry with edge pruning and vertex dedup
Vector implements IEquatable<Vector> with proper GetHashCode for HashSet usage.
Polygon.FindCrossing uses bounding-box pruning to skip non-overlapping edge pairs.
Helper.DirectionalDistance deduplicates vertices via HashSet, sorts edges for
early-exit pruning, and adds a new array-based overload that avoids allocations.
PartBoundary sorts directional edges and exposes GetEdges for zero-alloc access.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-14 22:51:44 -04:00
aj eddcc7602d feat(console): refactor Program.cs and add DXF file import support
Refactor flat top-level statements into NestConsole class with Options
and focused methods. Add support for passing .dxf files directly as
input — auto-imports geometry via DxfImporter and creates a fresh nest
with a plate when --size is specified. Supports three modes: nest-only,
DXF-only (requires --size), and mixed nest+DXF.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-14 21:50:01 -04:00
aj 9783d417bd feat(engine): show running ledger in progress descriptions
Linear phase shows "Linear: 12/36 angles, 45° = 48 parts" with a
running count. Pairs phase shows "Pairs: 8/50 candidates, best = 252
parts" tracking the best result seen so far. Reports on every
completion so the UI always reflects current state.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-14 20:57:01 -04:00
aj 91281c8813 fix(engine): throttle progress reports to 150ms intervals
Parallel loops were flooding the UI with per-angle/per-candidate reports
faster than WinForms could render them. Use Interlocked timestamp checks
to report at most every 150ms, keeping descriptions readable.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-14 20:51:29 -04:00
aj c2f775258d fix(ui): show live per-angle/per-candidate detail during nesting
Don't overwrite the Detail label with phase-level reports — let the
per-angle and per-candidate descriptions from the parallel loops remain
visible. Only clear the label on completion.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-14 20:48:58 -04:00
aj 930dd59213 fix(ui): set Description in phase-level progress reports
ReportProgress was not setting Description, so the Detail row always
showed the default em-dash. Now each phase report includes a meaningful
description, and UpdateProgress always updates the label (resetting to
em-dash when null).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-14 20:40:25 -04:00
aj 9cc6cfa1b1 fix(engine): add volatile to AnglePredictor lock field and Models content copy
- Mark _loadAttempted as volatile for correct double-checked locking
- Add Content item to copy Models/ directory to output for ONNX inference

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-14 20:33:07 -04:00
aj e33b5ba063 feat(engine): integrate AnglePredictor into FindBestFill angle selection
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-14 20:28:50 -04:00
aj 8cc14997af feat(engine): add AnglePredictor ONNX inference class
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-14 20:27:44 -04:00
aj eee2d0e3fe feat(training): add training notebook skeleton and requirements
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-14 20:27:41 -04:00
aj 2a58a8e123 feat(ui): add description row to nest progress form
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-14 20:26:47 -04:00
aj c466a24486 chore(engine): add Microsoft.ML.OnnxRuntime package
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-14 20:26:26 -04:00
aj 71fc1e61ef feat(training): enable forced full angle sweep and store per-angle results
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-14 20:25:33 -04:00
aj a145fd3c60 feat(training): add AngleResults table migration and batch insert
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-14 20:24:40 -04:00
aj dddd81fd90 feat(training): add TrainingAngleResult entity and DbSet
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-14 20:23:38 -04:00
aj 8e46ed1175 feat(engine): pass per-angle results through BruteForceResult
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-14 20:22:38 -04:00
aj 09ed9c228f feat(engine): add ForceFullAngleSweep flag and per-angle result collection
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-14 20:21:14 -04:00
aj eb21f76ef4 feat(engine): add AngleResult class and Description to NestProgress 2026-03-14 20:18:38 -04:00
aj 954831664a docs: add ML angle pruning design spec
Design for training an XGBoost model to predict which rotation
angles are worth trying during FillLinear, reducing the 36-angle
sweep to 4-8 predicted angles in narrow-work-area cases.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-14 20:05:03 -04:00
31 changed files with 2726 additions and 1293 deletions
+399 -244
View File
@@ -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");
}
+24
View File
@@ -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;
+24 -16
View File
@@ -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
View File
@@ -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;
+223
View File
@@ -0,0 +1,223 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Threading;
using OpenNest.Converters;
using OpenNest.Geometry;
using OpenNest.Math;
namespace OpenNest
{
/// <summary>
/// Mixed-part geometry-aware nesting using NFP-based collision avoidance
/// and simulated annealing optimization.
/// </summary>
public static class AutoNester
{
public static List<Part> Nest(List<NestItem> items, Plate plate,
CancellationToken cancellation = default)
{
var workArea = plate.WorkArea();
var halfSpacing = plate.PartSpacing / 2.0;
var nfpCache = new NfpCache();
var candidateRotations = new Dictionary<int, List<double>>();
// Extract perimeter polygons for each unique drawing.
foreach (var item in items)
{
var drawing = item.Drawing;
if (candidateRotations.ContainsKey(drawing.Id))
continue;
var perimeterPolygon = ExtractPerimeterPolygon(drawing, halfSpacing);
if (perimeterPolygon == null)
{
Debug.WriteLine($"[AutoNest] Skipping drawing '{drawing.Name}': no valid perimeter");
continue;
}
// Compute candidate rotations for this drawing.
var rotations = ComputeCandidateRotations(item, perimeterPolygon, workArea);
candidateRotations[drawing.Id] = rotations;
// Register polygons at each candidate rotation.
foreach (var rotation in rotations)
{
var rotatedPolygon = RotatePolygon(perimeterPolygon, rotation);
nfpCache.RegisterPolygon(drawing.Id, rotation, rotatedPolygon);
}
}
if (candidateRotations.Count == 0)
return new List<Part>();
// Pre-compute all NFPs.
nfpCache.PreComputeAll();
Debug.WriteLine($"[AutoNest] NFP cache: {nfpCache.Count} entries for {candidateRotations.Count} drawings");
// Run simulated annealing optimizer.
var optimizer = new SimulatedAnnealing();
var result = optimizer.Optimize(items, workArea, nfpCache, candidateRotations, cancellation);
if (result.Sequence == null || result.Sequence.Count == 0)
return new List<Part>();
// Final BLF placement with the best solution.
var blf = new BottomLeftFill(workArea, nfpCache);
var placedParts = blf.Fill(result.Sequence);
var parts = BottomLeftFill.ToNestParts(placedParts);
Debug.WriteLine($"[AutoNest] Result: {parts.Count} parts placed, {result.Iterations} SA iterations");
return parts;
}
/// <summary>
/// Extracts the perimeter polygon from a drawing, inflated by half-spacing.
/// </summary>
private static Polygon ExtractPerimeterPolygon(Drawing drawing, double halfSpacing)
{
var entities = ConvertProgram.ToGeometry(drawing.Program)
.Where(e => e.Layer != SpecialLayers.Rapid)
.ToList();
if (entities.Count == 0)
return null;
var definedShape = new ShapeProfile(entities);
var perimeter = definedShape.Perimeter;
if (perimeter == null)
return null;
// Inflate by half-spacing if spacing is non-zero.
Shape inflated;
if (halfSpacing > 0)
{
var offsetEntity = perimeter.OffsetEntity(halfSpacing, OffsetSide.Right);
inflated = offsetEntity as Shape ?? perimeter;
}
else
{
inflated = perimeter;
}
// Convert to polygon with circumscribed arcs for tight nesting.
var polygon = inflated.ToPolygonWithTolerance(0.01, circumscribe: true);
if (polygon.Vertices.Count < 3)
return null;
// Normalize: move reference point to origin.
polygon.UpdateBounds();
var bb = polygon.BoundingBox;
polygon.Offset(-bb.Left, -bb.Bottom);
return polygon;
}
/// <summary>
/// Computes candidate rotation angles for a drawing.
/// </summary>
private static List<double> ComputeCandidateRotations(NestItem item,
Polygon perimeterPolygon, Box workArea)
{
var rotations = new List<double> { 0 };
// Add hull-edge angles from the polygon itself.
var hullAngles = ComputeHullEdgeAngles(perimeterPolygon);
foreach (var angle in hullAngles)
{
if (!rotations.Any(r => r.IsEqualTo(angle)))
rotations.Add(angle);
}
// Add 90-degree rotation.
if (!rotations.Any(r => r.IsEqualTo(Angle.HalfPI)))
rotations.Add(Angle.HalfPI);
// For narrow work areas, add sweep angles.
var partBounds = perimeterPolygon.BoundingBox;
var partLongest = System.Math.Max(partBounds.Width, partBounds.Length);
var workShort = System.Math.Min(workArea.Width, workArea.Length);
if (workShort < partLongest)
{
var step = Angle.ToRadians(5);
for (var a = 0.0; a < System.Math.PI; a += step)
{
if (!rotations.Any(r => r.IsEqualTo(a)))
rotations.Add(a);
}
}
return rotations;
}
/// <summary>
/// Computes convex hull edge angles from a polygon for candidate rotations.
/// </summary>
private static List<double> ComputeHullEdgeAngles(Polygon polygon)
{
var angles = new List<double>();
if (polygon.Vertices.Count < 3)
return angles;
var hull = ConvexHull.Compute(polygon.Vertices);
var verts = hull.Vertices;
var n = hull.IsClosed() ? verts.Count - 1 : verts.Count;
for (var i = 0; i < n; i++)
{
var next = (i + 1) % n;
var dx = verts[next].X - verts[i].X;
var dy = verts[next].Y - verts[i].Y;
if (dx * dx + dy * dy < Tolerance.Epsilon)
continue;
var angle = -System.Math.Atan2(dy, dx);
if (!angles.Any(a => a.IsEqualTo(angle)))
angles.Add(angle);
}
return angles;
}
/// <summary>
/// Creates a rotated copy of a polygon around the origin.
/// </summary>
private static Polygon RotatePolygon(Polygon polygon, double angle)
{
if (angle.IsEqualTo(0))
return polygon;
var result = new Polygon();
var cos = System.Math.Cos(angle);
var sin = System.Math.Sin(angle);
foreach (var v in polygon.Vertices)
{
result.Vertices.Add(new Vector(
v.X * cos - v.Y * sin,
v.X * sin + v.Y * cos));
}
// Re-normalize to origin.
result.UpdateBounds();
var bb = result.BoundingBox;
result.Offset(-bb.Left, -bb.Bottom);
return result;
}
}
}
+3
View File
@@ -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);
}
+4 -1
View File
@@ -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
View File
@@ -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);
}
}
}
+119
View File
@@ -0,0 +1,119 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Linq;
using Microsoft.ML.OnnxRuntime;
using Microsoft.ML.OnnxRuntime.Tensors;
using OpenNest.Math;
namespace OpenNest.Engine.ML
{
public static class AnglePredictor
{
private static InferenceSession _session;
private static volatile bool _loadAttempted;
private static readonly object _lock = new();
public static List<double> PredictAngles(
PartFeatures features, double sheetWidth, double sheetHeight,
double threshold = 0.3)
{
var session = GetSession();
if (session == null)
return null;
try
{
var input = new float[11];
input[0] = (float)features.Area;
input[1] = (float)features.Convexity;
input[2] = (float)features.AspectRatio;
input[3] = (float)features.BoundingBoxFill;
input[4] = (float)features.Circularity;
input[5] = (float)features.PerimeterToAreaRatio;
input[6] = features.VertexCount;
input[7] = (float)sheetWidth;
input[8] = (float)sheetHeight;
input[9] = (float)(sheetWidth / (sheetHeight > 0 ? sheetHeight : 1.0));
input[10] = (float)(features.Area / (sheetWidth * sheetHeight));
var tensor = new DenseTensor<float>(input, new[] { 1, 11 });
var inputs = new List<NamedOnnxValue>
{
NamedOnnxValue.CreateFromTensor("features", tensor)
};
using var results = session.Run(inputs);
var probabilities = results.First().AsEnumerable<float>().ToArray();
var angles = new List<(double angleDeg, float prob)>();
for (var i = 0; i < 36 && i < probabilities.Length; i++)
{
if (probabilities[i] >= threshold)
angles.Add((i * 5.0, probabilities[i]));
}
// Minimum 3 angles — take top by probability if fewer pass threshold.
if (angles.Count < 3)
{
angles = probabilities
.Select((p, i) => (angleDeg: i * 5.0, prob: p))
.OrderByDescending(x => x.prob)
.Take(3)
.ToList();
}
// Always include 0 and 90 as safety fallback.
var result = angles.Select(a => Angle.ToRadians(a.angleDeg)).ToList();
if (!result.Any(a => a.IsEqualTo(0)))
result.Add(0);
if (!result.Any(a => a.IsEqualTo(Angle.HalfPI)))
result.Add(Angle.HalfPI);
return result;
}
catch (Exception ex)
{
Debug.WriteLine($"[AnglePredictor] Inference failed: {ex.Message}");
return null;
}
}
private static InferenceSession GetSession()
{
if (_loadAttempted)
return _session;
lock (_lock)
{
if (_loadAttempted)
return _session;
_loadAttempted = true;
try
{
var dir = Path.GetDirectoryName(typeof(AnglePredictor).Assembly.Location);
var modelPath = Path.Combine(dir, "Models", "angle_predictor.onnx");
if (!File.Exists(modelPath))
{
Debug.WriteLine($"[AnglePredictor] Model not found: {modelPath}");
return null;
}
_session = new InferenceSession(modelPath);
Debug.WriteLine("[AnglePredictor] Model loaded successfully");
}
catch (Exception ex)
{
Debug.WriteLine($"[AnglePredictor] Failed to load model: {ex.Message}");
}
return _session;
}
}
}
}
+29 -2
View File
@@ -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()
};
}
View File
File diff suppressed because it is too large Load Diff
+25
View File
@@ -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; }
}
}
+6
View File
@@ -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>
+13 -4
View File
@@ -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);
}
}
}
+1 -1
View File
@@ -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);
});
}
}
}
+11
View File
@@ -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();
}
}
+8 -3
View File
@@ -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++;
}
+72 -2
View File
@@ -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)\")"
]
}
]
}
+28 -60
View File
@@ -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();
}
+1 -1
View File
@@ -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
View File
@@ -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;
}
+5
View File
@@ -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;
+27 -27
View File
@@ -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 |