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 <noreply@anthropic.com>
This commit is contained in:
14
OpenNest.Console/OpenNest.Console.csproj
Normal file
14
OpenNest.Console/OpenNest.Console.csproj
Normal file
@@ -0,0 +1,14 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net8.0-windows</TargetFramework>
|
||||
<RootNamespace>OpenNest.Console</RootNamespace>
|
||||
<AssemblyName>OpenNest.Console</AssemblyName>
|
||||
<DefineConstants>$(DefineConstants);DEBUG;TRACE</DefineConstants>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\OpenNest.Core\OpenNest.Core.csproj" />
|
||||
<ProjectReference Include="..\OpenNest.Engine\OpenNest.Engine.csproj" />
|
||||
<ProjectReference Include="..\OpenNest.IO\OpenNest.IO.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
216
OpenNest.Console/Program.cs
Normal file
216
OpenNest.Console/Program.cs
Normal file
@@ -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<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)
|
||||
{
|
||||
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 <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(" --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");
|
||||
}
|
||||
85
OpenNest.Mcp/Tools/TestTools.cs
Normal file
85
OpenNest.Mcp/Tools/TestTools.cs
Normal file
@@ -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: <input>-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();
|
||||
}
|
||||
}
|
||||
}
|
||||
14
OpenNest.sln
14
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
|
||||
|
||||
Reference in New Issue
Block a user