From ad50751250dc5439f468a7889388b0c42d745437 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Wed, 11 Mar 2026 00:43:02 -0400 Subject: [PATCH] feat: add OpenNest.Console project (renamed from TestHarness) Console app for running the nesting engine from the command line. Supports plate size/spacing overrides, quantity limits, overlap checking with exit codes, and benchmark-friendly flags (--no-save, --no-log). The MCP test_engine tool shells out to this project. Co-Authored-By: Claude Opus 4.6 --- OpenNest.Console/OpenNest.Console.csproj | 14 ++ OpenNest.Console/Program.cs | 216 +++++++++++++++++++++++ OpenNest.Mcp/Tools/TestTools.cs | 85 +++++++++ OpenNest.sln | 14 ++ 4 files changed, 329 insertions(+) create mode 100644 OpenNest.Console/OpenNest.Console.csproj create mode 100644 OpenNest.Console/Program.cs create mode 100644 OpenNest.Mcp/Tools/TestTools.cs diff --git a/OpenNest.Console/OpenNest.Console.csproj b/OpenNest.Console/OpenNest.Console.csproj new file mode 100644 index 0000000..dfb174b --- /dev/null +++ b/OpenNest.Console/OpenNest.Console.csproj @@ -0,0 +1,14 @@ + + + Exe + net8.0-windows + OpenNest.Console + OpenNest.Console + $(DefineConstants);DEBUG;TRACE + + + + + + + diff --git a/OpenNest.Console/Program.cs b/OpenNest.Console/Program.cs new file mode 100644 index 0000000..75a5258 --- /dev/null +++ b/OpenNest.Console/Program.cs @@ -0,0 +1,216 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Linq; +using OpenNest; +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; + +for (var i = 0; i < args.Length; i++) +{ + switch (args[i]) + { + case "--drawing" when i + 1 < args.Length: + drawingName = args[++i]; + break; + case "--plate" when i + 1 < args.Length: + plateIndex = int.Parse(args[++i]); + break; + case "--output" when i + 1 < args.Length: + outputFile = args[++i]; + break; + 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 "--help": + case "-h": + PrintUsage(); + return 0; + default: + if (!args[i].StartsWith("--") && nestFile == null) + nestFile = args[i]; + break; + } +} + +if (string.IsNullOrEmpty(nestFile) || !File.Exists(nestFile)) +{ + PrintUsage(); + return 1; +} + +// Set up debug log file. +StreamWriter logWriter = null; + +if (!noLog) +{ + var logDir = Path.Combine(Path.GetDirectoryName(nestFile), "test-harness-logs"); + Directory.CreateDirectory(logDir); + var logFile = Path.Combine(logDir, $"debug-{DateTime.Now:yyyyMMdd-HHmmss}.log"); + logWriter = new StreamWriter(logFile) { AutoFlush = true }; + Trace.Listeners.Add(new TextWriterTraceListener(logWriter)); + Console.WriteLine($"Debug log: {logFile}"); +} + +// Load nest. +var reader = new NestReader(nestFile); +var nest = reader.Read(); + +if (nest.Plates.Count == 0) +{ + Console.Error.WriteLine("Error: nest file contains no plates"); + return 1; +} + +if (plateIndex >= nest.Plates.Count) +{ + Console.Error.WriteLine($"Error: plate index {plateIndex} out of range (0-{nest.Plates.Count - 1})"); + return 1; +} + +var plate = nest.Plates[plateIndex]; + +// Apply overrides. +if (spacing.HasValue) + plate.PartSpacing = spacing.Value; + +if (plateWidth.HasValue && plateHeight.HasValue) + plate.Size = new Size(plateWidth.Value, plateHeight.Value); + +// Find drawing. +var drawing = drawingName != null + ? nest.Drawings.FirstOrDefault(d => d.Name == drawingName) + : nest.Drawings.FirstOrDefault(); + +if (drawing == null) +{ + Console.Error.WriteLine(drawingName != null + ? $"Error: drawing '{drawingName}' not found. Available: {string.Join(", ", nest.Drawings.Select(d => d.Name))}" + : "Error: nest file contains no drawings"); + return 1; +} + +// Clear existing parts. +var existingCount = plate.Parts.Count; + +if (!keepParts) + plate.Parts.Clear(); + +Console.WriteLine($"Nest: {nest.Name}"); +Console.WriteLine($"Plate: {plateIndex} ({plate.Size.Width:F1} x {plate.Size.Height: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. +var sw = Stopwatch.StartNew(); +var engine = new NestEngine(plate); +var item = new NestItem { Drawing = drawing, Quantity = quantity }; +var 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) + { + var dir = Path.GetDirectoryName(nestFile); + var name = Path.GetFileNameWithoutExtension(nestFile); + outputFile = Path.Combine(dir, $"{name}-result.zip"); + } + + var writer = new NestWriter(nest); + writer.Write(outputFile); + Console.WriteLine($"Saved: {outputFile}"); +} + +return 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(" --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.Mcp/Tools/TestTools.cs b/OpenNest.Mcp/Tools/TestTools.cs new file mode 100644 index 0000000..971861b --- /dev/null +++ b/OpenNest.Mcp/Tools/TestTools.cs @@ -0,0 +1,85 @@ +using System.ComponentModel; +using System.Diagnostics; +using System.IO; +using System.Text; +using ModelContextProtocol.Server; + +namespace OpenNest.Mcp.Tools +{ + [McpServerToolType] + public class TestTools + { + private const string SolutionRoot = @"C:\Users\AJ\Desktop\Projects\OpenNest"; + + private static readonly string HarnessProject = Path.Combine( + SolutionRoot, "OpenNest.Console", "OpenNest.Console.csproj"); + + [McpServerTool(Name = "test_engine")] + [Description("Build and run the nesting engine against a nest file. Returns fill results and a debug log file path for grepping. Use this to test engine changes without restarting the MCP server.")] + public string TestEngine( + [Description("Path to the nest .zip file")] string nestFile = @"C:\Users\AJ\Desktop\4980 A24 PT02 60x120 45pcs v2.zip", + [Description("Drawing name to fill with (default: first drawing)")] string drawingName = null, + [Description("Plate index to fill (default: 0)")] int plateIndex = 0, + [Description("Output nest file path (default: -result.zip)")] string outputFile = null) + { + if (!File.Exists(nestFile)) + return $"Error: nest file not found: {nestFile}"; + + var processArgs = new StringBuilder(); + processArgs.Append($"\"{nestFile}\""); + + if (!string.IsNullOrEmpty(drawingName)) + processArgs.Append($" --drawing \"{drawingName}\""); + + processArgs.Append($" --plate {plateIndex}"); + + if (!string.IsNullOrEmpty(outputFile)) + processArgs.Append($" --output \"{outputFile}\""); + + var psi = new ProcessStartInfo + { + FileName = "dotnet", + Arguments = $"run --project \"{HarnessProject}\" -- {processArgs}", + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true, + WorkingDirectory = SolutionRoot + }; + + var sb = new StringBuilder(); + + try + { + using var process = Process.Start(psi); + var stderrTask = process.StandardError.ReadToEndAsync(); + var stdout = process.StandardOutput.ReadToEnd(); + process.WaitForExit(120_000); + var stderr = stderrTask.Result; + + if (!string.IsNullOrWhiteSpace(stdout)) + sb.Append(stdout.TrimEnd()); + + if (!string.IsNullOrWhiteSpace(stderr)) + { + sb.AppendLine(); + sb.AppendLine(); + sb.AppendLine("=== Errors ==="); + sb.Append(stderr.TrimEnd()); + } + + if (process.ExitCode != 0) + { + sb.AppendLine(); + sb.AppendLine($"Process exited with code {process.ExitCode}"); + } + } + catch (System.Exception ex) + { + sb.AppendLine($"Error running test harness: {ex.Message}"); + } + + return sb.ToString(); + } + } +} diff --git a/OpenNest.sln b/OpenNest.sln index 42a267c..8e76f0c 100644 --- a/OpenNest.sln +++ b/OpenNest.sln @@ -15,6 +15,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OpenNest.IO", "OpenNest.IO\ EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OpenNest.Mcp", "OpenNest.Mcp\OpenNest.Mcp.csproj", "{61CC6F65-8B70-408A-B49A-F4E5F34FFD01}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OpenNest.Console", "OpenNest.Console\OpenNest.Console.csproj", "{58E00A25-86B5-42C7-87B5-DE4AD22381EA}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -97,6 +99,18 @@ Global {61CC6F65-8B70-408A-B49A-F4E5F34FFD01}.Release|x64.Build.0 = Release|Any CPU {61CC6F65-8B70-408A-B49A-F4E5F34FFD01}.Release|x86.ActiveCfg = Release|Any CPU {61CC6F65-8B70-408A-B49A-F4E5F34FFD01}.Release|x86.Build.0 = Release|Any CPU + {58E00A25-86B5-42C7-87B5-DE4AD22381EA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {58E00A25-86B5-42C7-87B5-DE4AD22381EA}.Debug|Any CPU.Build.0 = Debug|Any CPU + {58E00A25-86B5-42C7-87B5-DE4AD22381EA}.Debug|x64.ActiveCfg = Debug|Any CPU + {58E00A25-86B5-42C7-87B5-DE4AD22381EA}.Debug|x64.Build.0 = Debug|Any CPU + {58E00A25-86B5-42C7-87B5-DE4AD22381EA}.Debug|x86.ActiveCfg = Debug|Any CPU + {58E00A25-86B5-42C7-87B5-DE4AD22381EA}.Debug|x86.Build.0 = Debug|Any CPU + {58E00A25-86B5-42C7-87B5-DE4AD22381EA}.Release|Any CPU.ActiveCfg = Release|Any CPU + {58E00A25-86B5-42C7-87B5-DE4AD22381EA}.Release|Any CPU.Build.0 = Release|Any CPU + {58E00A25-86B5-42C7-87B5-DE4AD22381EA}.Release|x64.ActiveCfg = Release|Any CPU + {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 EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE