From eddcc7602d7f1020f4f2a57038f3e1e2a9ba9253 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Sat, 14 Mar 2026 21:50:01 -0400 Subject: [PATCH] feat(console): refactor Program.cs and add DXF file import support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- OpenNest.Console/Program.cs | 642 ++++++++++++++++++++++-------------- 1 file changed, 398 insertions(+), 244 deletions(-) diff --git a/OpenNest.Console/Program.cs b/OpenNest.Console/Program.cs index 24451ee..c66deda 100644 --- a/OpenNest.Console/Program.cs +++ b/OpenNest.Console/Program.cs @@ -4,268 +4,422 @@ 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.PlateWidth = double.Parse(parts[0]); + o.PlateHeight = 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}"); + Console.WriteLine($"Plate: {options.PlateIndex} ({plate.Size.Width:F1} x {plate.Size.Length:F1}), spacing={plate.PartSpacing:F2}"); + Console.WriteLine($"Drawing: {drawing.Name}"); + Console.WriteLine(options.KeepParts + ? $"Keeping {existingCount} existing parts" + : $"Cleared {existingCount} existing parts"); + Console.WriteLine("---"); + } + + static (bool success, long elapsedMs) Fill(Nest nest, Plate plate, Drawing drawing, Options options) + { + var sw = Stopwatch.StartNew(); + bool success; + + if (options.AutoNest) + { + var nestItems = new List(); + var qty = options.Quantity > 0 ? options.Quantity : 1; + + if (options.DrawingName != null) + { + nestItems.Add(new NestItem { Drawing = drawing, Quantity = qty }); + } + else + { + foreach (var d in nest.Drawings) + nestItems.Add(new NestItem { Drawing = d, Quantity = qty }); + } + + Console.WriteLine($"AutoNest: {nestItems.Count} drawing(s), {nestItems.Sum(i => i.Quantity)} total parts"); + + var 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 = 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(); - - 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 [options]"); + Console.Error.WriteLine(); + Console.Error.WriteLine("Arguments:"); + Console.Error.WriteLine(" input-files One or more .zip nest files or .dxf drawing files"); + Console.Error.WriteLine(); + Console.Error.WriteLine("Modes:"); + Console.Error.WriteLine(" Load nest and fill (existing behavior)"); + Console.Error.WriteLine(" --size WxH Import DXF, create plate, and fill"); + Console.Error.WriteLine(" Load nest and add imported DXF drawings"); + Console.Error.WriteLine(); + Console.Error.WriteLine("Options:"); + Console.Error.WriteLine(" --drawing Drawing name to fill with (default: first drawing)"); + Console.Error.WriteLine(" --plate Plate index to fill (default: 0)"); + Console.Error.WriteLine(" --quantity Max parts to place (default: 0 = unlimited)"); + Console.Error.WriteLine(" --spacing Override part spacing"); + Console.Error.WriteLine(" --size Override plate size (e.g. 120x60); required for DXF-only mode"); + Console.Error.WriteLine(" --output Output nest file path (default: -result.zip)"); + Console.Error.WriteLine(" --template Nest template for plate defaults (thickness, quadrant, material, spacing)"); + Console.Error.WriteLine(" --autonest Use NFP-based mixed-part autonesting instead of linear fill"); + Console.Error.WriteLine(" --keep-parts Don't clear existing parts before filling"); + Console.Error.WriteLine(" --check-overlaps Run overlap detection after fill (exit code 1 if found)"); + Console.Error.WriteLine(" --no-save Skip saving output file"); + Console.Error.WriteLine(" --no-log Skip writing debug log file"); + Console.Error.WriteLine(" -h, --help Show this help"); } - 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 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 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 [options]"); - Console.Error.WriteLine(); - Console.Error.WriteLine("Arguments:"); - Console.Error.WriteLine(" nest-file Path to a .zip nest file"); - Console.Error.WriteLine(); - Console.Error.WriteLine("Options:"); - Console.Error.WriteLine(" --drawing Drawing name to fill with (default: first drawing)"); - Console.Error.WriteLine(" --plate Plate index to fill (default: 0)"); - Console.Error.WriteLine(" --quantity Max parts to place (default: 0 = unlimited)"); - Console.Error.WriteLine(" --spacing Override part spacing"); - Console.Error.WriteLine(" --size Override plate size (e.g. 120x60)"); - Console.Error.WriteLine(" --output Output nest file path (default: -result.zip)"); - Console.Error.WriteLine(" --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"); }