From acc75868c04e5f43bc11f0fe4900423fb2f2d1c2 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Sat, 14 Mar 2026 14:41:38 -0400 Subject: [PATCH] refactor: extract training data collection into OpenNest.Training Move brute-force data collection, TrainingDatabase, and GPU init from OpenNest.Console into a dedicated OpenNest.Training project. Replaces raw Microsoft.Data.Sqlite with EF Core. Console is now a pure nesting CLI with template support and cleaned-up usage output. Co-Authored-By: Claude Opus 4.6 (1M context) --- OpenNest.Console/OpenNest.Console.csproj | 4 - OpenNest.Console/Program.cs | 315 +++----------------- OpenNest.Console/TrainingDatabase.cs | 145 --------- OpenNest.Training/Data/TrainingDbContext.cs | 38 +++ OpenNest.Training/Data/TrainingPart.cs | 28 ++ OpenNest.Training/Data/TrainingRun.cs | 25 ++ OpenNest.Training/OpenNest.Training.csproj | 19 ++ OpenNest.Training/Program.cs | 300 +++++++++++++++++++ OpenNest.Training/TrainingDatabase.cs | 131 ++++++++ OpenNest.sln | 14 + 10 files changed, 591 insertions(+), 428 deletions(-) delete mode 100644 OpenNest.Console/TrainingDatabase.cs create mode 100644 OpenNest.Training/Data/TrainingDbContext.cs create mode 100644 OpenNest.Training/Data/TrainingPart.cs create mode 100644 OpenNest.Training/Data/TrainingRun.cs create mode 100644 OpenNest.Training/OpenNest.Training.csproj create mode 100644 OpenNest.Training/Program.cs create mode 100644 OpenNest.Training/TrainingDatabase.cs diff --git a/OpenNest.Console/OpenNest.Console.csproj b/OpenNest.Console/OpenNest.Console.csproj index f1a0806..dfb174b 100644 --- a/OpenNest.Console/OpenNest.Console.csproj +++ b/OpenNest.Console/OpenNest.Console.csproj @@ -10,9 +10,5 @@ - - - - diff --git a/OpenNest.Console/Program.cs b/OpenNest.Console/Program.cs index 8ec74e6..24451ee 100644 --- a/OpenNest.Console/Program.cs +++ b/OpenNest.Console/Program.cs @@ -6,11 +6,6 @@ using System.Linq; using OpenNest; using OpenNest.Geometry; using OpenNest.IO; -using Color = System.Drawing.Color; -using OpenNest.Console; -using OpenNest.Engine.BestFit; -using OpenNest.Engine.ML; -using OpenNest.Gpu; // Parse arguments. var nestFile = (string)null; @@ -26,27 +21,12 @@ var noSave = false; var noLog = false; var keepParts = false; var autoNest = false; -var collectDataDir = (string)null; -var dbPath = "nesting_training.db"; -var saveNestsDir = (string)null; var templateFile = (string)null; for (var i = 0; i < args.Length; i++) { switch (args[i]) { - case "--db" when i + 1 < args.Length: - dbPath = args[++i]; - break; - case "--save-nests" when i + 1 < args.Length: - saveNestsDir = args[++i]; - break; - case "--template" when i + 1 < args.Length: - templateFile = args[++i]; - break; - case "--collect" when i + 1 < args.Length: - collectDataDir = args[++i]; - break; case "--drawing" when i + 1 < args.Length: drawingName = args[++i]; break; @@ -82,6 +62,9 @@ for (var i = 0; i < args.Length; i++) case "--keep-parts": keepParts = true; break; + case "--template" when i + 1 < args.Length: + templateFile = args[++i]; + break; case "--autonest": autoNest = true; break; @@ -96,22 +79,6 @@ for (var i = 0; i < args.Length; i++) } } -// Initialize GPU if available. -if (GpuEvaluatorFactory.GpuAvailable) -{ - BestFitCache.CreateSlideComputer = () => GpuEvaluatorFactory.CreateSlideComputer(); - Console.WriteLine($"GPU: {GpuEvaluatorFactory.DeviceName}"); -} -else -{ - Console.WriteLine("GPU: not available (using CPU)"); -} - -if (collectDataDir != null) -{ - return RunDataCollection(collectDataDir, dbPath, saveNestsDir, spacing ?? 0.5, templateFile); -} - if (string.IsNullOrEmpty(nestFile) || !File.Exists(nestFile)) { PrintUsage(); @@ -149,6 +116,24 @@ if (plateIndex >= nest.Plates.Count) var plate = nest.Plates[plateIndex]; +// Apply template defaults. +if (templateFile != null) +{ + if (!File.Exists(templateFile)) + { + Console.Error.WriteLine($"Error: Template not found: {templateFile}"); + return 1; + } + 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; @@ -207,9 +192,9 @@ if (autoNest) Console.WriteLine($"AutoNest: {nestItems.Count} drawing(s), {nestItems.Sum(i => i.Quantity)} total parts"); - var parts = NestEngine.AutoNest(nestItems, plate); - plate.Parts.AddRange(parts); - success = parts.Count > 0; + var nestParts = NestEngine.AutoNest(nestItems, plate); + plate.Parts.AddRange(nestParts); + success = nestParts.Count > 0; } else { @@ -262,253 +247,25 @@ if (!noSave) return checkOverlaps && overlapCount > 0 ? 1 : 0; -int RunDataCollection(string dir, string dbPath, string saveDir, double s, string template) -{ - if (!Directory.Exists(dir)) - { - Console.Error.WriteLine($"Error: Directory not found: {dir}"); - return 1; - } - - // Load template nest for plate defaults if provided. - Nest templateNest = null; - if (template != null) - { - if (!File.Exists(template)) - { - Console.Error.WriteLine($"Error: Template not found: {template}"); - return 1; - } - templateNest = new NestReader(template).Read(); - Console.WriteLine($"Using template: {template}"); - } - - var PartColors = new[] - { - Color.FromArgb(205, 92, 92), - Color.FromArgb(148, 103, 189), - Color.FromArgb(75, 180, 175), - Color.FromArgb(210, 190, 75), - Color.FromArgb(190, 85, 175), - Color.FromArgb(185, 115, 85), - Color.FromArgb(120, 100, 190), - Color.FromArgb(200, 100, 140), - Color.FromArgb(80, 175, 155), - Color.FromArgb(195, 160, 85), - Color.FromArgb(175, 95, 160), - Color.FromArgb(215, 130, 130), - }; - - var sheetSuite = new[] - { - new Size(96, 48), new Size(120, 48), new Size(144, 48), - new Size(96, 60), new Size(120, 60), new Size(144, 60), - new Size(96, 72), new Size(120, 72), new Size(144, 72), - new Size(48, 24), new Size(120, 10) - }; - - var dxfFiles = Directory.GetFiles(dir, "*.dxf", SearchOption.AllDirectories); - Console.WriteLine($"Found {dxfFiles.Length} DXF files"); - Console.WriteLine($"Database: {Path.GetFullPath(dbPath)}"); - Console.WriteLine($"Sheet sizes: {sheetSuite.Length} configurations"); - Console.WriteLine($"Spacing: {s:F2}"); - if (saveDir != null) Console.WriteLine($"Saving nests to: {saveDir}"); - Console.WriteLine("---"); - - using var db = new TrainingDatabase(dbPath); - - var importer = new DxfImporter(); - var colorIndex = 0; - var processed = 0; - var skippedGeometry = 0; - var skippedFeatures = 0; - var skippedExisting = 0; - var totalRuns = 0; - var totalSw = Stopwatch.StartNew(); - - foreach (var file in dxfFiles) - { - var fileNum = processed + skippedGeometry + skippedFeatures + skippedExisting + 1; - var partNo = Path.GetFileNameWithoutExtension(file); - Console.Write($"[{fileNum}/{dxfFiles.Length}] {partNo}"); - - try - { - var existingRuns = db.RunCount(Path.GetFileName(file)); - if (existingRuns >= sheetSuite.Length) - { - Console.WriteLine(" - SKIP (all sizes done)"); - skippedExisting++; - continue; - } - - if (!importer.GetGeometry(file, out var entities)) - { - Console.WriteLine(" - SKIP (no geometry)"); - skippedGeometry++; - continue; - } - - var drawing = new Drawing(Path.GetFileName(file)); - drawing.Program = OpenNest.Converters.ConvertGeometry.ToProgram(entities); - drawing.UpdateArea(); - drawing.Color = PartColors[colorIndex % PartColors.Length]; - colorIndex++; - - var features = FeatureExtractor.Extract(drawing); - if (features == null) - { - Console.WriteLine(" - SKIP (feature extraction failed)"); - skippedFeatures++; - continue; - } - - Console.WriteLine($" (area={features.Area:F1}, verts={features.VertexCount})"); - - // Precompute best-fits once for all sheet sizes. - var sizes = sheetSuite.Select(sz => (sz.Width, sz.Length)).ToList(); - var bfSw = Stopwatch.StartNew(); - BestFitCache.ComputeForSizes(drawing, s, sizes); - bfSw.Stop(); - Console.WriteLine($" Best-fits computed in {bfSw.ElapsedMilliseconds}ms"); - - using var txn = db.BeginTransaction(); - - var partId = db.GetOrAddPart(Path.GetFileName(file), features, drawing.Program.ToString()); - var partSw = Stopwatch.StartNew(); - var runsThisPart = 0; - var bestUtil = 0.0; - var bestCount = 0; - - foreach (var size in sheetSuite) - { - if (db.HasRun(Path.GetFileName(file), size.Width, size.Length, s)) - { - Console.WriteLine($" {size.Length}x{size.Width} - skip (exists)"); - continue; - } - - Plate runPlate; - if (templateNest != null) - { - runPlate = templateNest.PlateDefaults.CreateNew(); - runPlate.Size = size; - runPlate.PartSpacing = s; - } - else - { - runPlate = new Plate { Size = size, PartSpacing = s }; - } - - var sizeSw = Stopwatch.StartNew(); - var result = BruteForceRunner.Run(drawing, runPlate); - sizeSw.Stop(); - - if (result == null) - { - Console.WriteLine($" {size.Length}x{size.Width} - no fit"); - continue; - } - - if (result.Utilization > bestUtil) - { - bestUtil = result.Utilization; - bestCount = result.PartCount; - } - - Console.WriteLine($" {size.Length}x{size.Width} - {result.PartCount}pcs, {result.Utilization:P1}, {sizeSw.ElapsedMilliseconds}ms"); - - string savedFilePath = null; - if (saveDir != null) - { - // Deterministic bucket (00-FF) based on filename hash - uint hash = 0; - foreach (char c in partNo) hash = (hash * 31) + c; - var bucket = (hash % 256).ToString("X2"); - - var partDir = Path.Combine(saveDir, bucket, partNo); - Directory.CreateDirectory(partDir); - - var nestName = $"{partNo}-{size.Length}x{size.Width}-{result.PartCount}pcs"; - var fileName = nestName + ".zip"; - savedFilePath = Path.Combine(partDir, fileName); - - // Create nest from template or from scratch - Nest nestObj; - if (templateNest != null) - { - nestObj = new Nest(nestName) - { - Units = templateNest.Units, - DateCreated = DateTime.Now - }; - nestObj.PlateDefaults.SetFromExisting(templateNest.PlateDefaults.CreateNew()); - } - else - { - nestObj = new Nest(nestName) { Units = Units.Inches, DateCreated = DateTime.Now }; - } - - nestObj.Drawings.Add(drawing); - var plateObj = nestObj.CreatePlate(); - plateObj.Size = size; - plateObj.PartSpacing = s; - plateObj.Parts.AddRange(result.PlacedParts); - - var writer = new NestWriter(nestObj); - writer.Write(savedFilePath); - } - - db.AddRun(partId, size.Width, size.Length, s, result, savedFilePath); - runsThisPart++; - totalRuns++; - } - - txn.Commit(); - BestFitCache.Invalidate(drawing); - partSw.Stop(); - processed++; - Console.WriteLine($" Total: {runsThisPart} runs, best={bestCount}pcs @ {bestUtil:P1}, {partSw.ElapsedMilliseconds}ms"); - } - catch (Exception ex) - { - Console.WriteLine(); - Console.Error.WriteLine($" ERROR: {ex.Message}"); - } - } - - totalSw.Stop(); - Console.WriteLine("---"); - Console.WriteLine($"Processed: {processed} parts, {totalRuns} total runs"); - Console.WriteLine($"Skipped: {skippedExisting} (existing) + {skippedGeometry} (no geometry) + {skippedFeatures} (no features)"); - Console.WriteLine($"Time: {totalSw.Elapsed:h\\:mm\\:ss}"); - Console.WriteLine($"Database: {Path.GetFullPath(dbPath)}"); - return 0; -} - void PrintUsage() { Console.Error.WriteLine("Usage: OpenNest.Console [options]"); - Console.Error.WriteLine(" OpenNest.Console --collect [options]"); Console.Error.WriteLine(); Console.Error.WriteLine("Arguments:"); Console.Error.WriteLine(" nest-file Path to a .zip nest file"); Console.Error.WriteLine(); Console.Error.WriteLine("Options:"); Console.Error.WriteLine(" --drawing Drawing name to fill with (default: first drawing)"); - Console.WriteLine(" --plate Plate index to fill (default: 0)"); - Console.WriteLine(" --quantity Max parts to place (default: 0 = unlimited)"); - Console.WriteLine(" --spacing Override part spacing"); - Console.WriteLine(" --size Override plate size (e.g. 120x60)"); - Console.WriteLine(" --output Output nest file path (default: -result.zip)"); - Console.WriteLine(" --autonest Use NFP-based mixed-part autonesting instead of linear fill"); - Console.WriteLine(" --keep-parts Don't clear existing parts before filling"); - Console.WriteLine(" --check-overlaps Run overlap detection after fill (exit code 1 if found)"); - Console.WriteLine(" --no-save Skip saving output file"); - Console.WriteLine(" --no-log Skip writing debug log file"); - Console.WriteLine(" --collect Brute-force process all DXFs in directory to SQLite"); - Console.WriteLine(" --db Path to the SQLite training database (default: nesting_training.db)"); - Console.WriteLine(" --save-nests Directory to save individual .zip nests for each winner"); - Console.WriteLine(" --template Nest template (.nstdot) for plate defaults"); - Console.WriteLine(" -h, --help Show this help"); + Console.Error.WriteLine(" --plate Plate index to fill (default: 0)"); + Console.Error.WriteLine(" --quantity Max parts to place (default: 0 = unlimited)"); + Console.Error.WriteLine(" --spacing Override part spacing"); + Console.Error.WriteLine(" --size Override plate size (e.g. 120x60)"); + Console.Error.WriteLine(" --output Output nest file path (default: -result.zip)"); + Console.Error.WriteLine(" --template Nest template for plate defaults (thickness, quadrant, material, spacing)"); + Console.Error.WriteLine(" --autonest Use NFP-based mixed-part autonesting instead of linear fill"); + Console.Error.WriteLine(" --keep-parts Don't clear existing parts before filling"); + Console.Error.WriteLine(" --check-overlaps Run overlap detection after fill (exit code 1 if found)"); + Console.Error.WriteLine(" --no-save Skip saving output file"); + Console.Error.WriteLine(" --no-log Skip writing debug log file"); + Console.Error.WriteLine(" -h, --help Show this help"); } diff --git a/OpenNest.Console/TrainingDatabase.cs b/OpenNest.Console/TrainingDatabase.cs deleted file mode 100644 index 2a40f97..0000000 --- a/OpenNest.Console/TrainingDatabase.cs +++ /dev/null @@ -1,145 +0,0 @@ -using System; -using Microsoft.Data.Sqlite; -using OpenNest.Engine.ML; - -namespace OpenNest.Console -{ - public class TrainingDatabase : IDisposable - { - private readonly SqliteConnection _connection; - - public TrainingDatabase(string dbPath) - { - var connectionString = new SqliteConnectionStringBuilder - { - DataSource = dbPath, - Mode = SqliteOpenMode.ReadWriteCreate - }.ToString(); - - _connection = new SqliteConnection(connectionString); - _connection.Open(); - - InitializeSchema(); - } - - private void InitializeSchema() - { - using var cmd = _connection.CreateCommand(); - cmd.CommandText = @" - CREATE TABLE IF NOT EXISTS Parts ( - Id INTEGER PRIMARY KEY AUTOINCREMENT, - FileName TEXT, - Area REAL, - Convexity REAL, - AspectRatio REAL, - BBFill REAL, - Circularity REAL, - VertexCount INTEGER, - Bitmask BLOB, - GeometryData TEXT - ); - - CREATE TABLE IF NOT EXISTS Runs ( - Id INTEGER PRIMARY KEY AUTOINCREMENT, - PartId INTEGER, - SheetWidth REAL, - SheetHeight REAL, - Spacing REAL, - PartCount INTEGER, - Utilization REAL, - TimeMs INTEGER, - LayoutData TEXT, - FilePath TEXT, - FOREIGN KEY(PartId) REFERENCES Parts(Id) - ); - - CREATE INDEX IF NOT EXISTS idx_parts_filename ON Parts(FileName); - CREATE INDEX IF NOT EXISTS idx_runs_partid ON Runs(PartId); - "; - cmd.ExecuteNonQuery(); - } - - public long GetOrAddPart(string fileName, PartFeatures features, string geometryData) - { - // Check if part already exists - using (var checkCmd = _connection.CreateCommand()) - { - checkCmd.CommandText = "SELECT Id FROM Parts WHERE FileName = @name"; - checkCmd.Parameters.AddWithValue("@name", fileName); - var result = checkCmd.ExecuteScalar(); - if (result != null) return (long)result; - } - - // Add new part - using (var insertCmd = _connection.CreateCommand()) - { - insertCmd.CommandText = @" - INSERT INTO Parts (FileName, Area, Convexity, AspectRatio, BBFill, Circularity, VertexCount, Bitmask, GeometryData) - VALUES (@name, @area, @conv, @asp, @fill, @circ, @vert, @mask, @geo); - SELECT last_insert_rowid();"; - - insertCmd.Parameters.AddWithValue("@name", fileName); - insertCmd.Parameters.AddWithValue("@area", features.Area); - insertCmd.Parameters.AddWithValue("@conv", features.Convexity); - insertCmd.Parameters.AddWithValue("@asp", features.AspectRatio); - insertCmd.Parameters.AddWithValue("@fill", features.BoundingBoxFill); - insertCmd.Parameters.AddWithValue("@circ", features.Circularity); - insertCmd.Parameters.AddWithValue("@vert", features.VertexCount); - insertCmd.Parameters.AddWithValue("@mask", features.Bitmask); - insertCmd.Parameters.AddWithValue("@geo", geometryData); - - return (long)insertCmd.ExecuteScalar(); - } - } - - public bool HasRun(string fileName, double sheetWidth, double sheetHeight, double spacing) - { - using var cmd = _connection.CreateCommand(); - cmd.CommandText = @"SELECT COUNT(*) FROM Runs r JOIN Parts p ON r.PartId = p.Id - WHERE p.FileName = @name AND r.SheetWidth = @w AND r.SheetHeight = @h AND r.Spacing = @s"; - cmd.Parameters.AddWithValue("@name", fileName); - cmd.Parameters.AddWithValue("@w", sheetWidth); - cmd.Parameters.AddWithValue("@h", sheetHeight); - cmd.Parameters.AddWithValue("@s", spacing); - return (long)cmd.ExecuteScalar() > 0; - } - - public int RunCount(string fileName) - { - using var cmd = _connection.CreateCommand(); - cmd.CommandText = "SELECT COUNT(*) FROM Runs r JOIN Parts p ON r.PartId = p.Id WHERE p.FileName = @name"; - cmd.Parameters.AddWithValue("@name", fileName); - return (int)(long)cmd.ExecuteScalar(); - } - - public void AddRun(long partId, double w, double h, double s, BruteForceResult result, string filePath) - { - using var cmd = _connection.CreateCommand(); - cmd.CommandText = @" - INSERT INTO Runs (PartId, SheetWidth, SheetHeight, Spacing, PartCount, Utilization, TimeMs, LayoutData, FilePath) - VALUES (@pid, @w, @h, @s, @cnt, @util, @time, @layout, @path)"; - - cmd.Parameters.AddWithValue("@pid", partId); - cmd.Parameters.AddWithValue("@w", w); - cmd.Parameters.AddWithValue("@h", h); - cmd.Parameters.AddWithValue("@s", s); - cmd.Parameters.AddWithValue("@cnt", result.PartCount); - cmd.Parameters.AddWithValue("@util", result.Utilization); - cmd.Parameters.AddWithValue("@time", result.TimeMs); - cmd.Parameters.AddWithValue("@layout", result.LayoutData ?? ""); - cmd.Parameters.AddWithValue("@path", filePath ?? ""); - - cmd.ExecuteNonQuery(); - } - - public SqliteTransaction BeginTransaction() - { - return _connection.BeginTransaction(); - } - - public void Dispose() - { - _connection?.Dispose(); - } - } -} diff --git a/OpenNest.Training/Data/TrainingDbContext.cs b/OpenNest.Training/Data/TrainingDbContext.cs new file mode 100644 index 0000000..12bc817 --- /dev/null +++ b/OpenNest.Training/Data/TrainingDbContext.cs @@ -0,0 +1,38 @@ +using Microsoft.EntityFrameworkCore; + +namespace OpenNest.Training.Data +{ + public class TrainingDbContext : DbContext + { + public DbSet Parts { get; set; } + public DbSet Runs { get; set; } + + private readonly string _dbPath; + + public TrainingDbContext(string dbPath) + { + _dbPath = dbPath; + } + + protected override void OnConfiguring(DbContextOptionsBuilder options) + { + options.UseSqlite($"Data Source={_dbPath}"); + } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(e => + { + e.HasIndex(p => p.FileName).HasDatabaseName("idx_parts_filename"); + }); + + modelBuilder.Entity(e => + { + e.HasIndex(r => r.PartId).HasDatabaseName("idx_runs_partid"); + e.HasOne(r => r.Part) + .WithMany(p => p.Runs) + .HasForeignKey(r => r.PartId); + }); + } + } +} diff --git a/OpenNest.Training/Data/TrainingPart.cs b/OpenNest.Training/Data/TrainingPart.cs new file mode 100644 index 0000000..178180f --- /dev/null +++ b/OpenNest.Training/Data/TrainingPart.cs @@ -0,0 +1,28 @@ +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace OpenNest.Training.Data +{ + [Table("Parts")] + public class TrainingPart + { + [Key] + public long Id { get; set; } + + [MaxLength(260)] + public string FileName { get; set; } + + public double Area { get; set; } + public double Convexity { get; set; } + public double AspectRatio { get; set; } + public double BBFill { get; set; } + public double Circularity { get; set; } + public double PerimeterToAreaRatio { get; set; } + public int VertexCount { get; set; } + public byte[] Bitmask { get; set; } + public string GeometryData { get; set; } + + public List Runs { get; set; } = new(); + } +} diff --git a/OpenNest.Training/Data/TrainingRun.cs b/OpenNest.Training/Data/TrainingRun.cs new file mode 100644 index 0000000..a8ae46a --- /dev/null +++ b/OpenNest.Training/Data/TrainingRun.cs @@ -0,0 +1,25 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace OpenNest.Training.Data +{ + [Table("Runs")] + public class TrainingRun + { + [Key] + public long Id { get; set; } + + public long PartId { get; set; } + public double SheetWidth { get; set; } + public double SheetHeight { get; set; } + public double Spacing { get; set; } + public int PartCount { get; set; } + public double Utilization { get; set; } + public long TimeMs { get; set; } + public string LayoutData { get; set; } + public string FilePath { get; set; } + + [ForeignKey(nameof(PartId))] + public TrainingPart Part { get; set; } + } +} diff --git a/OpenNest.Training/OpenNest.Training.csproj b/OpenNest.Training/OpenNest.Training.csproj new file mode 100644 index 0000000..0a7387d --- /dev/null +++ b/OpenNest.Training/OpenNest.Training.csproj @@ -0,0 +1,19 @@ + + + Exe + net8.0-windows + OpenNest.Training + OpenNest.Training + $(DefineConstants);DEBUG;TRACE + + + + + + + + + + + + diff --git a/OpenNest.Training/Program.cs b/OpenNest.Training/Program.cs new file mode 100644 index 0000000..6859c80 --- /dev/null +++ b/OpenNest.Training/Program.cs @@ -0,0 +1,300 @@ +using System; +using System.Diagnostics; +using System.IO; +using System.Linq; +using OpenNest; +using OpenNest.Geometry; +using OpenNest.IO; +using Color = System.Drawing.Color; +using OpenNest.Engine.BestFit; +using OpenNest.Engine.ML; +using OpenNest.Gpu; +using OpenNest.Training; + +// Parse arguments. +var dbPath = "OpenNestTraining"; +var saveNestsDir = (string)null; +var templateFile = (string)null; +var spacing = 0.5; +var collectDir = (string)null; + +for (var i = 0; i < args.Length; i++) +{ + switch (args[i]) + { + case "--db" when i + 1 < args.Length: + dbPath = args[++i]; + break; + case "--save-nests" when i + 1 < args.Length: + saveNestsDir = args[++i]; + break; + case "--template" when i + 1 < args.Length: + templateFile = args[++i]; + break; + case "--spacing" when i + 1 < args.Length: + spacing = double.Parse(args[++i]); + break; + case "--help": + case "-h": + PrintUsage(); + return 0; + default: + if (!args[i].StartsWith("--") && collectDir == null) + collectDir = args[i]; + break; + } +} + +if (string.IsNullOrEmpty(collectDir) || !Directory.Exists(collectDir)) +{ + PrintUsage(); + return 1; +} + +// Initialize GPU if available. +if (GpuEvaluatorFactory.GpuAvailable) +{ + BestFitCache.CreateSlideComputer = () => GpuEvaluatorFactory.CreateSlideComputer(); + Console.WriteLine($"GPU: {GpuEvaluatorFactory.DeviceName}"); +} +else +{ + Console.WriteLine("GPU: not available (using CPU)"); +} + +return RunDataCollection(collectDir, dbPath, saveNestsDir, spacing, templateFile); + +int RunDataCollection(string dir, string dbPath, string saveDir, double s, string template) +{ + // Load template nest for plate defaults if provided. + Nest templateNest = null; + if (template != null) + { + if (!File.Exists(template)) + { + Console.Error.WriteLine($"Error: Template not found: {template}"); + return 1; + } + templateNest = new NestReader(template).Read(); + Console.WriteLine($"Using template: {template}"); + } + + var PartColors = new[] + { + Color.FromArgb(205, 92, 92), + Color.FromArgb(148, 103, 189), + Color.FromArgb(75, 180, 175), + Color.FromArgb(210, 190, 75), + Color.FromArgb(190, 85, 175), + Color.FromArgb(185, 115, 85), + Color.FromArgb(120, 100, 190), + Color.FromArgb(200, 100, 140), + Color.FromArgb(80, 175, 155), + Color.FromArgb(195, 160, 85), + Color.FromArgb(175, 95, 160), + Color.FromArgb(215, 130, 130), + }; + + var sheetSuite = new[] + { + new Size(96, 48), new Size(120, 48), new Size(144, 48), + new Size(96, 60), new Size(120, 60), new Size(144, 60), + new Size(96, 72), new Size(120, 72), new Size(144, 72), + new Size(48, 24), new Size(120, 10) + }; + + var dxfFiles = Directory.GetFiles(dir, "*.dxf", SearchOption.AllDirectories); + Console.WriteLine($"Found {dxfFiles.Length} DXF files"); + var resolvedDb = dbPath.EndsWith(".db", StringComparison.OrdinalIgnoreCase) ? dbPath : dbPath + ".db"; + Console.WriteLine($"Database: {Path.GetFullPath(resolvedDb)}"); + Console.WriteLine($"Sheet sizes: {sheetSuite.Length} configurations"); + Console.WriteLine($"Spacing: {s:F2}"); + if (saveDir != null) Console.WriteLine($"Saving nests to: {saveDir}"); + Console.WriteLine("---"); + + using var db = new TrainingDatabase(dbPath); + + var backfilled = db.BackfillPerimeterToAreaRatio(); + if (backfilled > 0) + Console.WriteLine($"Backfilled PerimeterToAreaRatio for {backfilled} existing parts"); + + var importer = new DxfImporter(); + var colorIndex = 0; + var processed = 0; + var skippedGeometry = 0; + var skippedFeatures = 0; + var skippedExisting = 0; + var totalRuns = 0; + var totalSw = Stopwatch.StartNew(); + + foreach (var file in dxfFiles) + { + var fileNum = processed + skippedGeometry + skippedFeatures + skippedExisting + 1; + var partNo = Path.GetFileNameWithoutExtension(file); + Console.Write($"[{fileNum}/{dxfFiles.Length}] {partNo}"); + + try + { + var existingRuns = db.RunCount(Path.GetFileName(file)); + if (existingRuns >= sheetSuite.Length) + { + Console.WriteLine(" - SKIP (all sizes done)"); + skippedExisting++; + continue; + } + + if (!importer.GetGeometry(file, out var entities)) + { + Console.WriteLine(" - SKIP (no geometry)"); + skippedGeometry++; + continue; + } + + var drawing = new Drawing(Path.GetFileName(file)); + drawing.Program = OpenNest.Converters.ConvertGeometry.ToProgram(entities); + drawing.UpdateArea(); + drawing.Color = PartColors[colorIndex % PartColors.Length]; + colorIndex++; + + var features = FeatureExtractor.Extract(drawing); + if (features == null) + { + Console.WriteLine(" - SKIP (feature extraction failed)"); + skippedFeatures++; + continue; + } + + Console.WriteLine($" (area={features.Area:F1}, verts={features.VertexCount})"); + + // Precompute best-fits once for all sheet sizes. + var sizes = sheetSuite.Select(sz => (sz.Width, sz.Length)).ToList(); + var bfSw = Stopwatch.StartNew(); + BestFitCache.ComputeForSizes(drawing, s, sizes); + bfSw.Stop(); + Console.WriteLine($" Best-fits computed in {bfSw.ElapsedMilliseconds}ms"); + + var partId = db.GetOrAddPart(Path.GetFileName(file), features, drawing.Program.ToString()); + var partSw = Stopwatch.StartNew(); + var runsThisPart = 0; + var bestUtil = 0.0; + var bestCount = 0; + + foreach (var size in sheetSuite) + { + if (db.HasRun(Path.GetFileName(file), size.Width, size.Length, s)) + { + Console.WriteLine($" {size.Length}x{size.Width} - skip (exists)"); + continue; + } + + Plate runPlate; + if (templateNest != null) + { + runPlate = templateNest.PlateDefaults.CreateNew(); + runPlate.Size = size; + runPlate.PartSpacing = s; + } + else + { + runPlate = new Plate { Size = size, PartSpacing = s }; + } + + var sizeSw = Stopwatch.StartNew(); + var result = BruteForceRunner.Run(drawing, runPlate); + sizeSw.Stop(); + + if (result == null) + { + Console.WriteLine($" {size.Length}x{size.Width} - no fit"); + continue; + } + + if (result.Utilization > bestUtil) + { + bestUtil = result.Utilization; + bestCount = result.PartCount; + } + + Console.WriteLine($" {size.Length}x{size.Width} - {result.PartCount}pcs, {result.Utilization:P1}, {sizeSw.ElapsedMilliseconds}ms"); + + string savedFilePath = null; + if (saveDir != null) + { + // Deterministic bucket (00-FF) based on filename hash + uint hash = 0; + foreach (char c in partNo) hash = (hash * 31) + c; + var bucket = (hash % 256).ToString("X2"); + + var partDir = Path.Combine(saveDir, bucket, partNo); + Directory.CreateDirectory(partDir); + + var nestName = $"{partNo}-{size.Length}x{size.Width}-{result.PartCount}pcs"; + var fileName = nestName + ".zip"; + savedFilePath = Path.Combine(partDir, fileName); + + // Create nest from template or from scratch + Nest nestObj; + if (templateNest != null) + { + nestObj = new Nest(nestName) + { + Units = templateNest.Units, + DateCreated = DateTime.Now + }; + nestObj.PlateDefaults.SetFromExisting(templateNest.PlateDefaults.CreateNew()); + } + else + { + nestObj = new Nest(nestName) { Units = Units.Inches, DateCreated = DateTime.Now }; + } + + nestObj.Drawings.Add(drawing); + var plateObj = nestObj.CreatePlate(); + plateObj.Size = size; + plateObj.PartSpacing = s; + plateObj.Parts.AddRange(result.PlacedParts); + + var writer = new NestWriter(nestObj); + writer.Write(savedFilePath); + } + + db.AddRun(partId, size.Width, size.Length, s, result, savedFilePath); + runsThisPart++; + totalRuns++; + } + + BestFitCache.Invalidate(drawing); + partSw.Stop(); + processed++; + Console.WriteLine($" Total: {runsThisPart} runs, best={bestCount}pcs @ {bestUtil:P1}, {partSw.ElapsedMilliseconds}ms"); + } + catch (Exception ex) + { + Console.WriteLine(); + Console.Error.WriteLine($" ERROR: {ex.Message}"); + } + } + + totalSw.Stop(); + Console.WriteLine("---"); + Console.WriteLine($"Processed: {processed} parts, {totalRuns} total runs"); + Console.WriteLine($"Skipped: {skippedExisting} (existing) + {skippedGeometry} (no geometry) + {skippedFeatures} (no features)"); + Console.WriteLine($"Time: {totalSw.Elapsed:h\\:mm\\:ss}"); + Console.WriteLine($"Database: {Path.GetFullPath(resolvedDb)}"); + return 0; +} + +void PrintUsage() +{ + Console.Error.WriteLine("Usage: OpenNest.Training [options]"); + Console.Error.WriteLine(); + Console.Error.WriteLine("Arguments:"); + Console.Error.WriteLine(" dxf-dir Directory containing DXF files to process"); + Console.Error.WriteLine(); + Console.Error.WriteLine("Options:"); + Console.Error.WriteLine(" --spacing Part spacing (default: 0.5)"); + Console.Error.WriteLine(" --db SQLite database path (default: OpenNestTraining.db)"); + Console.Error.WriteLine(" --save-nests Directory to save individual .zip nests for each winner"); + Console.Error.WriteLine(" --template Nest template (.nstdot) for plate defaults"); + Console.Error.WriteLine(" -h, --help Show this help"); +} diff --git a/OpenNest.Training/TrainingDatabase.cs b/OpenNest.Training/TrainingDatabase.cs new file mode 100644 index 0000000..2579299 --- /dev/null +++ b/OpenNest.Training/TrainingDatabase.cs @@ -0,0 +1,131 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; +using Microsoft.EntityFrameworkCore; +using OpenNest.Engine.ML; +using OpenNest.IO; +using OpenNest.Training.Data; + +namespace OpenNest.Training +{ + public class TrainingDatabase : IDisposable + { + private readonly TrainingDbContext _db; + + public TrainingDatabase(string dbPath) + { + if (!dbPath.EndsWith(".db", StringComparison.OrdinalIgnoreCase)) + dbPath += ".db"; + + _db = new TrainingDbContext(dbPath); + _db.Database.EnsureCreated(); + } + + public long GetOrAddPart(string fileName, PartFeatures features, string geometryData) + { + var existing = _db.Parts.FirstOrDefault(p => p.FileName == fileName); + if (existing != null) return existing.Id; + + var part = new TrainingPart + { + FileName = fileName, + Area = features.Area, + Convexity = features.Convexity, + AspectRatio = features.AspectRatio, + BBFill = features.BoundingBoxFill, + Circularity = features.Circularity, + PerimeterToAreaRatio = features.PerimeterToAreaRatio, + VertexCount = features.VertexCount, + Bitmask = features.Bitmask, + GeometryData = geometryData + }; + + _db.Parts.Add(part); + _db.SaveChanges(); + return part.Id; + } + + public bool HasRun(string fileName, double sheetWidth, double sheetHeight, double spacing) + { + return _db.Runs.Any(r => + r.Part.FileName == fileName && + r.SheetWidth == sheetWidth && + r.SheetHeight == sheetHeight && + r.Spacing == spacing); + } + + public int RunCount(string fileName) + { + return _db.Runs.Count(r => r.Part.FileName == fileName); + } + + public void AddRun(long partId, double w, double h, double s, BruteForceResult result, string filePath) + { + var run = new TrainingRun + { + PartId = partId, + SheetWidth = w, + SheetHeight = h, + Spacing = s, + PartCount = result.PartCount, + Utilization = result.Utilization, + TimeMs = result.TimeMs, + LayoutData = result.LayoutData ?? "", + FilePath = filePath ?? "" + }; + + _db.Runs.Add(run); + _db.SaveChanges(); + } + + public int BackfillPerimeterToAreaRatio() + { + var partsToFix = _db.Parts + .Where(p => p.PerimeterToAreaRatio == 0) + .Select(p => new { p.Id, p.GeometryData }) + .ToList(); + + if (partsToFix.Count == 0) return 0; + + var updated = 0; + foreach (var item in partsToFix) + { + try + { + var stream = new MemoryStream(Encoding.UTF8.GetBytes(item.GeometryData)); + var programReader = new ProgramReader(stream); + var program = programReader.Read(); + + var drawing = new Drawing("backfill") { Program = program }; + drawing.UpdateArea(); + + var features = FeatureExtractor.Extract(drawing); + if (features == null) continue; + + var part = _db.Parts.Find(item.Id); + part.PerimeterToAreaRatio = features.PerimeterToAreaRatio; + _db.SaveChanges(); + updated++; + } + catch + { + // Skip parts that fail to reconstruct. + } + } + + return updated; + } + + public void SaveChanges() + { + _db.SaveChanges(); + } + + public void Dispose() + { + _db?.Dispose(); + } + } +} diff --git a/OpenNest.sln b/OpenNest.sln index 8e76f0c..69cf786 100644 --- a/OpenNest.sln +++ b/OpenNest.sln @@ -17,6 +17,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OpenNest.Mcp", "OpenNest.Mc EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OpenNest.Console", "OpenNest.Console\OpenNest.Console.csproj", "{58E00A25-86B5-42C7-87B5-DE4AD22381EA}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OpenNest.Training", "OpenNest.Training\OpenNest.Training.csproj", "{249BF728-25DD-4863-8266-207ACD26E964}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -111,6 +113,18 @@ Global {58E00A25-86B5-42C7-87B5-DE4AD22381EA}.Release|x64.Build.0 = Release|Any CPU {58E00A25-86B5-42C7-87B5-DE4AD22381EA}.Release|x86.ActiveCfg = Release|Any CPU {58E00A25-86B5-42C7-87B5-DE4AD22381EA}.Release|x86.Build.0 = Release|Any CPU + {249BF728-25DD-4863-8266-207ACD26E964}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {249BF728-25DD-4863-8266-207ACD26E964}.Debug|Any CPU.Build.0 = Debug|Any CPU + {249BF728-25DD-4863-8266-207ACD26E964}.Debug|x64.ActiveCfg = Debug|Any CPU + {249BF728-25DD-4863-8266-207ACD26E964}.Debug|x64.Build.0 = Debug|Any CPU + {249BF728-25DD-4863-8266-207ACD26E964}.Debug|x86.ActiveCfg = Debug|Any CPU + {249BF728-25DD-4863-8266-207ACD26E964}.Debug|x86.Build.0 = Debug|Any CPU + {249BF728-25DD-4863-8266-207ACD26E964}.Release|Any CPU.ActiveCfg = Release|Any CPU + {249BF728-25DD-4863-8266-207ACD26E964}.Release|Any CPU.Build.0 = Release|Any CPU + {249BF728-25DD-4863-8266-207ACD26E964}.Release|x64.ActiveCfg = Release|Any CPU + {249BF728-25DD-4863-8266-207ACD26E964}.Release|x64.Build.0 = Release|Any CPU + {249BF728-25DD-4863-8266-207ACD26E964}.Release|x86.ActiveCfg = Release|Any CPU + {249BF728-25DD-4863-8266-207ACD26E964}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE