diff --git a/.mcp.json b/.mcp.json new file mode 100644 index 0000000..be4d04b --- /dev/null +++ b/.mcp.json @@ -0,0 +1,8 @@ +{ + "mcpServers": { + "opennest": { + "command": "C:/Users/AJ/.claude/mcp/OpenNest.Mcp/OpenNest.Mcp.exe", + "args": [] + } + } +} diff --git a/CLAUDE.md b/CLAUDE.md index 3ab710f..86fe8c2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -14,13 +14,13 @@ This is a .NET 8 solution using SDK-style `.csproj` files targeting `net8.0-wind dotnet build OpenNest.sln ``` -NuGet dependency: `ACadSharp` 3.1.32 (DXF/DWG import/export), `System.Drawing.Common` 8.0.10. +NuGet dependencies: `ACadSharp` 3.1.32 (DXF/DWG import/export, in OpenNest.IO), `System.Drawing.Common` 8.0.10, `ModelContextProtocol` + `Microsoft.Extensions.Hosting` (in OpenNest.Mcp). No test projects exist in this solution. ## Architecture -Three projects form a layered architecture: +Five projects form a layered architecture: ### OpenNest.Core (class library) Domain model, geometry, and CNC primitives organized into namespaces: @@ -41,13 +41,29 @@ Nesting algorithms. `NestEngine` orchestrates filling plates with parts. - `NestItem`: Input to the engine — wraps a `Drawing` with quantity, priority, and rotation constraints. - `BestCombination`: Finds optimal mix of normal/rotated columns for grid fills. -### OpenNest (WinForms WinExe, depends on Core + Engine) +### OpenNest.IO (class library, depends on Core) +File I/O and format conversion. Uses ACadSharp for DXF/DWG support. + +- `DxfImporter`/`DxfExporter` — DXF file import/export via ACadSharp. +- `NestReader`/`NestWriter` — custom ZIP-based nest format (XML metadata + G-code programs). +- `ProgramReader` — G-code text parser. +- `Extensions` — conversion helpers between ACadSharp and OpenNest geometry types. + +### OpenNest.Mcp (console app, depends on Core + Engine + IO) +MCP server for Claude Code integration. Exposes nesting operations as MCP tools over stdio transport. Published to `~/.claude/mcp/OpenNest.Mcp/`. + +- **Tools/InputTools**: `load_nest`, `import_dxf`, `create_drawing` (built-in shapes or G-code). +- **Tools/SetupTools**: `create_plate`, `clear_plate`. +- **Tools/NestingTools**: `fill_plate`, `fill_area`, `fill_remnants`, `pack_plate`. +- **Tools/InspectionTools**: `get_plate_info`, `get_parts`, `check_overlaps`. +- `NestSession` — in-memory state across tool calls (current Nest, standalone plates/drawings). + +### OpenNest (WinForms WinExe, depends on Core + Engine + IO) The UI application with MDI interface. - **Forms/**: `MainForm` (MDI parent), `EditNestForm` (MDI child per nest), plus dialogs for plate editing, auto-nesting, DXF conversion, cut parameters, etc. - **Controls/**: `PlateView` (2D plate renderer with zoom/pan), `DrawingListBox`, `DrawControl`, `QuadrantSelect`. - **Actions/**: User interaction modes — `ActionSelect`, `ActionAddPart`, `ActionClone`, `ActionFillArea`, `ActionZoomWindow`, `ActionSetSequence`. -- **IO/**: `DxfImporter`/`DxfExporter` (via ACadSharp library), `NestReader`/`NestWriter` (custom ZIP-based format with XML metadata + G-code programs), `ProgramReader`. - **Post-processing**: `IPostProcessor` plugin interface loaded from DLLs in a `Posts/` directory at runtime. ## File Format diff --git a/OpenNest.Core/Plate.cs b/OpenNest.Core/Plate.cs index 5e01d5b..2614922 100644 --- a/OpenNest.Core/Plate.cs +++ b/OpenNest.Core/Plate.cs @@ -473,5 +473,87 @@ namespace OpenNest return pts.Count > 0; } + + /// + /// Finds rectangular remnant (empty) regions on the plate. + /// Returns strips along edges that are clear of parts. + /// + public List GetRemnants() + { + var work = WorkArea(); + var results = new List(); + + if (Parts.Count == 0) + { + results.Add(work); + return results; + } + + var obstacles = new List(); + foreach (var part in Parts) + obstacles.Add(part.BoundingBox.Offset(PartSpacing)); + + // Right strip: from the rightmost part edge to the work area right edge + var maxRight = double.MinValue; + foreach (var box in obstacles) + { + if (box.Right > maxRight) + maxRight = box.Right; + } + + if (maxRight < work.Right) + { + var strip = new Box(maxRight, work.Bottom, work.Right - maxRight, work.Height); + if (strip.Area() > 1.0) + results.Add(strip); + } + + // Top strip: from the topmost part edge to the work area top edge + var maxTop = double.MinValue; + foreach (var box in obstacles) + { + if (box.Top > maxTop) + maxTop = box.Top; + } + + if (maxTop < work.Top) + { + var strip = new Box(work.Left, maxTop, work.Width, work.Top - maxTop); + if (strip.Area() > 1.0) + results.Add(strip); + } + + // Bottom strip: from work area bottom to the lowest part edge + var minBottom = double.MaxValue; + foreach (var box in obstacles) + { + if (box.Bottom < minBottom) + minBottom = box.Bottom; + } + + if (minBottom > work.Bottom) + { + var strip = new Box(work.Left, work.Bottom, work.Width, minBottom - work.Bottom); + if (strip.Area() > 1.0) + results.Add(strip); + } + + // Left strip: from work area left to the leftmost part edge + var minLeft = double.MaxValue; + foreach (var box in obstacles) + { + if (box.Left < minLeft) + minLeft = box.Left; + } + + if (minLeft > work.Left) + { + var strip = new Box(work.Left, work.Bottom, minLeft - work.Left, work.Height); + if (strip.Area() > 1.0) + results.Add(strip); + } + + return results; + } } } diff --git a/OpenNest/IO/DxfExporter.cs b/OpenNest.IO/DxfExporter.cs similarity index 100% rename from OpenNest/IO/DxfExporter.cs rename to OpenNest.IO/DxfExporter.cs diff --git a/OpenNest/IO/DxfImporter.cs b/OpenNest.IO/DxfImporter.cs similarity index 100% rename from OpenNest/IO/DxfImporter.cs rename to OpenNest.IO/DxfImporter.cs diff --git a/OpenNest/IO/Extensions.cs b/OpenNest.IO/Extensions.cs similarity index 100% rename from OpenNest/IO/Extensions.cs rename to OpenNest.IO/Extensions.cs diff --git a/OpenNest/IO/NestReader.cs b/OpenNest.IO/NestReader.cs similarity index 100% rename from OpenNest/IO/NestReader.cs rename to OpenNest.IO/NestReader.cs diff --git a/OpenNest/IO/NestWriter.cs b/OpenNest.IO/NestWriter.cs similarity index 100% rename from OpenNest/IO/NestWriter.cs rename to OpenNest.IO/NestWriter.cs diff --git a/OpenNest.IO/OpenNest.IO.csproj b/OpenNest.IO/OpenNest.IO.csproj new file mode 100644 index 0000000..cf96aaf --- /dev/null +++ b/OpenNest.IO/OpenNest.IO.csproj @@ -0,0 +1,11 @@ + + + net8.0-windows + OpenNest.IO + OpenNest.IO + + + + + + diff --git a/OpenNest/IO/ProgramReader.cs b/OpenNest.IO/ProgramReader.cs similarity index 99% rename from OpenNest/IO/ProgramReader.cs rename to OpenNest.IO/ProgramReader.cs index 1ffb031..6776d22 100644 --- a/OpenNest/IO/ProgramReader.cs +++ b/OpenNest.IO/ProgramReader.cs @@ -6,7 +6,7 @@ using OpenNest.Geometry; namespace OpenNest.IO { - internal sealed class ProgramReader + public sealed class ProgramReader { private const int BufferSize = 200; diff --git a/OpenNest.Mcp/NestSession.cs b/OpenNest.Mcp/NestSession.cs new file mode 100644 index 0000000..2dbd993 --- /dev/null +++ b/OpenNest.Mcp/NestSession.cs @@ -0,0 +1,61 @@ +using System.Collections.Generic; + +namespace OpenNest.Mcp +{ + public class NestSession + { + public Nest Nest { get; set; } + public List Plates { get; } = new(); + public List Drawings { get; } = new(); + + public Plate GetPlate(int index) + { + if (Nest != null && index < Nest.Plates.Count) + return Nest.Plates[index]; + + var adjustedIndex = index - (Nest?.Plates.Count ?? 0); + if (adjustedIndex >= 0 && adjustedIndex < Plates.Count) + return Plates[adjustedIndex]; + + return null; + } + + public Drawing GetDrawing(string name) + { + if (Nest != null) + { + foreach (var d in Nest.Drawings) + { + if (d.Name == name) + return d; + } + } + + foreach (var d in Drawings) + { + if (d.Name == name) + return d; + } + + return null; + } + + public List AllPlates() + { + var all = new List(); + if (Nest != null) + all.AddRange(Nest.Plates); + all.AddRange(Plates); + return all; + } + + public List AllDrawings() + { + var all = new List(); + if (Nest != null) + all.AddRange(Nest.Drawings); + all.AddRange(Drawings); + return all; + } + } +} diff --git a/OpenNest.Mcp/OpenNest.Mcp.csproj b/OpenNest.Mcp/OpenNest.Mcp.csproj new file mode 100644 index 0000000..c99afd8 --- /dev/null +++ b/OpenNest.Mcp/OpenNest.Mcp.csproj @@ -0,0 +1,18 @@ + + + + Exe + net8.0-windows + OpenNest.Mcp + OpenNest.Mcp + + + + + + + + + + + diff --git a/OpenNest.Mcp/Program.cs b/OpenNest.Mcp/Program.cs new file mode 100644 index 0000000..c924f9f --- /dev/null +++ b/OpenNest.Mcp/Program.cs @@ -0,0 +1,15 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using ModelContextProtocol.Server; +using OpenNest.Mcp; + +var builder = Host.CreateApplicationBuilder(args); + +builder.Services.AddSingleton(); +builder.Services + .AddMcpServer() + .WithStdioServerTransport() + .WithToolsFromAssembly(typeof(Program).Assembly); + +var app = builder.Build(); +await app.RunAsync(); diff --git a/OpenNest.Mcp/Tools/InputTools.cs b/OpenNest.Mcp/Tools/InputTools.cs new file mode 100644 index 0000000..e3f1a28 --- /dev/null +++ b/OpenNest.Mcp/Tools/InputTools.cs @@ -0,0 +1,214 @@ +using System.Collections.Generic; +using System.ComponentModel; +using System.IO; +using System.Text; +using ModelContextProtocol.Server; +using OpenNest.Converters; +using OpenNest.Geometry; +using OpenNest.IO; +using CncProgram = OpenNest.CNC.Program; + +namespace OpenNest.Mcp.Tools +{ + [McpServerToolType] + public class InputTools + { + private readonly NestSession _session; + + public InputTools(NestSession session) + { + _session = session; + } + + [McpServerTool(Name = "load_nest")] + [Description("Load a .nest zip file into the session. Returns a summary of plates, parts, and drawings.")] + public string LoadNest([Description("Absolute path to the .nest file")] string path) + { + if (!File.Exists(path)) + return $"Error: file not found: {path}"; + + var reader = new NestReader(path); + var nest = reader.Read(); + _session.Nest = nest; + + var sb = new StringBuilder(); + sb.AppendLine($"Loaded nest: {nest.Name}"); + sb.AppendLine($"Units: {nest.Units}"); + sb.AppendLine($"Plates: {nest.Plates.Count}"); + + for (var i = 0; i < nest.Plates.Count; i++) + { + var plate = nest.Plates[i]; + var work = plate.WorkArea(); + sb.AppendLine($" Plate {i}: {plate.Size.Width:F1} x {plate.Size.Height:F1}, " + + $"parts={plate.Parts.Count}, " + + $"utilization={plate.Utilization():P1}, " + + $"work area={work.Width:F1} x {work.Height:F1}"); + } + + sb.AppendLine($"Drawings: {nest.Drawings.Count}"); + + foreach (var dwg in nest.Drawings) + { + var bbox = dwg.Program.BoundingBox(); + sb.AppendLine($" {dwg.Name}: bbox={bbox.Width:F2} x {bbox.Height:F2}, " + + $"required={dwg.Quantity.Required}, nested={dwg.Quantity.Nested}"); + } + + return sb.ToString(); + } + + [McpServerTool(Name = "import_dxf")] + [Description("Import a DXF file as a new drawing. Returns drawing name and bounding box.")] + public string ImportDxf( + [Description("Absolute path to the DXF file")] string path, + [Description("Name for the drawing (defaults to filename without extension)")] string name = null) + { + if (!File.Exists(path)) + return $"Error: file not found: {path}"; + + var importer = new DxfImporter(); + + if (!importer.GetGeometry(path, out var geometry)) + return "Error: failed to read DXF file"; + + if (geometry.Count == 0) + return "Error: no geometry found in DXF file"; + + var pgm = ConvertGeometry.ToProgram(geometry); + + if (pgm == null) + return "Error: failed to convert geometry to program"; + + var drawingName = name ?? Path.GetFileNameWithoutExtension(path); + var drawing = new Drawing(drawingName, pgm); + _session.Drawings.Add(drawing); + + var bbox = pgm.BoundingBox(); + return $"Imported drawing '{drawingName}': bbox={bbox.Width:F2} x {bbox.Height:F2}"; + } + + [McpServerTool(Name = "create_drawing")] + [Description("Create a drawing from a built-in shape or G-code string. Shape can be: rectangle, circle, l_shape, t_shape, gcode.")] + public string CreateDrawing( + [Description("Name for the drawing")] string name, + [Description("Shape type: rectangle, circle, l_shape, t_shape, gcode")] string shape, + [Description("Width of the shape (not used for circle or gcode)")] double width = 10, + [Description("Height of the shape (not used for circle or gcode)")] double height = 10, + [Description("Radius for circle shape")] double radius = 5, + [Description("G-code string (only used when shape is 'gcode')")] string gcode = null) + { + CncProgram pgm; + + switch (shape.ToLower()) + { + case "rectangle": + pgm = CreateRectangle(width, height); + break; + + case "circle": + pgm = CreateCircle(radius); + break; + + case "l_shape": + pgm = CreateLShape(width, height); + break; + + case "t_shape": + pgm = CreateTShape(width, height); + break; + + case "gcode": + if (string.IsNullOrWhiteSpace(gcode)) + return "Error: gcode parameter is required when shape is 'gcode'"; + pgm = ParseGcode(gcode); + if (pgm == null) + return "Error: failed to parse G-code"; + break; + + default: + return $"Error: unknown shape '{shape}'. Use: rectangle, circle, l_shape, t_shape, gcode"; + } + + var drawing = new Drawing(name, pgm); + _session.Drawings.Add(drawing); + + var bbox = pgm.BoundingBox(); + return $"Created drawing '{name}': bbox={bbox.Width:F2} x {bbox.Height:F2}"; + } + + private static CncProgram CreateRectangle(double width, double height) + { + var entities = new List + { + new Line(0, 0, width, 0), + new Line(width, 0, width, height), + new Line(width, height, 0, height), + new Line(0, height, 0, 0) + }; + + return ConvertGeometry.ToProgram(entities); + } + + private static CncProgram CreateCircle(double radius) + { + var entities = new List + { + new Circle(0, 0, radius) + }; + + return ConvertGeometry.ToProgram(entities); + } + + private static CncProgram CreateLShape(double width, double height) + { + var hw = width / 2; + var hh = height / 2; + + var entities = new List + { + new Line(0, 0, width, 0), + new Line(width, 0, width, hh), + new Line(width, hh, hw, hh), + new Line(hw, hh, hw, height), + new Line(hw, height, 0, height), + new Line(0, height, 0, 0) + }; + + return ConvertGeometry.ToProgram(entities); + } + + private static CncProgram CreateTShape(double width, double height) + { + var stemWidth = width / 3; + var topHeight = height / 3; + var stemLeft = (width - stemWidth) / 2; + var stemRight = stemLeft + stemWidth; + var stemBottom = 0.0; + var stemTop = height - topHeight; + + var entities = new List + { + new Line(stemLeft, stemBottom, stemRight, stemBottom), + new Line(stemRight, stemBottom, stemRight, stemTop), + new Line(stemRight, stemTop, width, stemTop), + new Line(width, stemTop, width, height), + new Line(width, height, 0, height), + new Line(0, height, 0, stemTop), + new Line(0, stemTop, stemLeft, stemTop), + new Line(stemLeft, stemTop, stemLeft, stemBottom) + }; + + return ConvertGeometry.ToProgram(entities); + } + + private static CncProgram ParseGcode(string gcode) + { + using var stream = new MemoryStream(Encoding.UTF8.GetBytes(gcode)); + var reader = new ProgramReader(stream); + var pgm = reader.Read(); + reader.Close(); + return pgm; + } + } +} diff --git a/OpenNest.Mcp/Tools/InspectionTools.cs b/OpenNest.Mcp/Tools/InspectionTools.cs new file mode 100644 index 0000000..50c95eb --- /dev/null +++ b/OpenNest.Mcp/Tools/InspectionTools.cs @@ -0,0 +1,136 @@ +using System.Collections.Generic; +using System.ComponentModel; +using System.Linq; +using System.Text; +using ModelContextProtocol.Server; +using OpenNest.Geometry; +using OpenNest.Math; + +namespace OpenNest.Mcp.Tools +{ + [McpServerToolType] + public class InspectionTools + { + private readonly NestSession _session; + + public InspectionTools(NestSession session) + { + _session = session; + } + + [McpServerTool(Name = "get_plate_info")] + [Description("Get detailed information about a plate including dimensions, part count, utilization, remnants, and drawing breakdown.")] + public string GetPlateInfo( + [Description("Index of the plate")] int plateIndex) + { + var plate = _session.GetPlate(plateIndex); + if (plate == null) + return $"Error: plate {plateIndex} not found"; + + var work = plate.WorkArea(); + var remnants = plate.GetRemnants(); + + var sb = new StringBuilder(); + sb.AppendLine($"Plate {plateIndex}:"); + sb.AppendLine($" Size: {plate.Size.Width:F1} x {plate.Size.Height:F1}"); + sb.AppendLine($" Quadrant: {plate.Quadrant}"); + sb.AppendLine($" Thickness: {plate.Thickness:F2}"); + sb.AppendLine($" Material: {plate.Material.Name}"); + sb.AppendLine($" Part spacing: {plate.PartSpacing:F2}"); + sb.AppendLine($" Edge spacing: L={plate.EdgeSpacing.Left:F2} B={plate.EdgeSpacing.Bottom:F2} R={plate.EdgeSpacing.Right:F2} T={plate.EdgeSpacing.Top:F2}"); + sb.AppendLine($" Work area: {work.X:F1},{work.Y:F1} {work.Width:F1}x{work.Height:F1}"); + sb.AppendLine($" Parts: {plate.Parts.Count}"); + sb.AppendLine($" Utilization: {plate.Utilization():P1}"); + sb.AppendLine($" Quantity: {plate.Quantity}"); + + // Drawing breakdown + if (plate.Parts.Count > 0) + { + sb.AppendLine(" Drawings:"); + var groups = plate.Parts.GroupBy(p => p.BaseDrawing.Name); + foreach (var group in groups) + sb.AppendLine($" {group.Key}: {group.Count()}"); + } + + // Remnants + sb.AppendLine($" Remnants: {remnants.Count}"); + for (var i = 0; i < remnants.Count; i++) + { + var r = remnants[i]; + sb.AppendLine($" Remnant {i}: ({r.X:F1},{r.Y:F1}) {r.Width:F1}x{r.Height:F1}, area={r.Area():F1}"); + } + + return sb.ToString(); + } + + [McpServerTool(Name = "get_parts")] + [Description("List placed parts on a plate with index, drawing name, location, rotation, and bounding box.")] + public string GetParts( + [Description("Index of the plate")] int plateIndex, + [Description("Maximum number of parts to list (default 50)")] int limit = 50) + { + var plate = _session.GetPlate(plateIndex); + if (plate == null) + return $"Error: plate {plateIndex} not found"; + + if (plate.Parts.Count == 0) + return $"Plate {plateIndex} has no parts"; + + var sb = new StringBuilder(); + sb.AppendLine($"Plate {plateIndex}: {plate.Parts.Count} parts (showing up to {limit})"); + + var count = System.Math.Min(plate.Parts.Count, limit); + + for (var i = 0; i < count; i++) + { + var part = plate.Parts[i]; + var bbox = part.BoundingBox; + var rotDeg = Angle.ToDegrees(part.Rotation); + + sb.AppendLine($" [{i}] {part.BaseDrawing.Name}: " + + $"loc=({part.Location.X:F2},{part.Location.Y:F2}), " + + $"rot={rotDeg:F1} deg, " + + $"bbox=({bbox.X:F2},{bbox.Y:F2} {bbox.Width:F2}x{bbox.Height:F2})"); + } + + if (plate.Parts.Count > limit) + sb.AppendLine($" ... and {plate.Parts.Count - limit} more"); + + return sb.ToString(); + } + + [McpServerTool(Name = "check_overlaps")] + [Description("Check a plate for overlapping parts. Reports collision points if any.")] + public string CheckOverlaps( + [Description("Index of the plate")] int plateIndex) + { + var plate = _session.GetPlate(plateIndex); + if (plate == null) + return $"Error: plate {plateIndex} not found"; + + if (plate.Parts.Count < 2) + return $"Plate {plateIndex}: no overlaps possible (fewer than 2 parts)"; + + var hasOverlaps = plate.HasOverlappingParts(out var pts); + + if (!hasOverlaps) + return $"Plate {plateIndex}: no overlapping parts detected"; + + var sb = new StringBuilder(); + sb.AppendLine($"Plate {plateIndex}: {pts.Count} collision point(s) detected!"); + + var limit = System.Math.Min(pts.Count, 20); + + for (var i = 0; i < limit; i++) + { + var pt = pts[i]; + sb.AppendLine($" Collision at ({pt.X:F2}, {pt.Y:F2})"); + } + + if (pts.Count > limit) + sb.AppendLine($" ... and {pts.Count - limit} more collision points"); + + return sb.ToString(); + } + } +} diff --git a/OpenNest.Mcp/Tools/NestingTools.cs b/OpenNest.Mcp/Tools/NestingTools.cs new file mode 100644 index 0000000..46b865d --- /dev/null +++ b/OpenNest.Mcp/Tools/NestingTools.cs @@ -0,0 +1,194 @@ +using System.Collections.Generic; +using System.ComponentModel; +using System.Linq; +using System.Text; +using ModelContextProtocol.Server; +using OpenNest.Geometry; + +namespace OpenNest.Mcp.Tools +{ + [McpServerToolType] + public class NestingTools + { + private readonly NestSession _session; + + public NestingTools(NestSession session) + { + _session = session; + } + + [McpServerTool(Name = "fill_plate")] + [Description("Fill an entire plate with a single drawing. Returns parts added and utilization.")] + public string FillPlate( + [Description("Index of the plate to fill")] int plateIndex, + [Description("Name of the drawing to fill with")] string drawingName, + [Description("Maximum quantity to place (0 = unlimited)")] int quantity = 0) + { + var plate = _session.GetPlate(plateIndex); + if (plate == null) + return $"Error: plate {plateIndex} not found"; + + var drawing = _session.GetDrawing(drawingName); + if (drawing == null) + return $"Error: drawing '{drawingName}' not found"; + + var countBefore = plate.Parts.Count; + var engine = new NestEngine(plate); + var item = new NestItem { Drawing = drawing, Quantity = quantity }; + var success = engine.Fill(item); + + var countAfter = plate.Parts.Count; + var added = countAfter - countBefore; + + var sb = new StringBuilder(); + sb.AppendLine($"Fill plate {plateIndex} with '{drawingName}': {(success ? "success" : "failed")}"); + sb.AppendLine($" Parts added: {added}"); + sb.AppendLine($" Total parts: {countAfter}"); + sb.AppendLine($" Utilization: {plate.Utilization():P1}"); + + return sb.ToString(); + } + + [McpServerTool(Name = "fill_area")] + [Description("Fill a specific rectangular area on a plate with a single drawing.")] + public string FillArea( + [Description("Index of the plate")] int plateIndex, + [Description("Name of the drawing to fill with")] string drawingName, + [Description("X origin of the area")] double x, + [Description("Y origin of the area")] double y, + [Description("Width of the area")] double width, + [Description("Height of the area")] double height, + [Description("Maximum quantity to place (0 = unlimited)")] int quantity = 0) + { + var plate = _session.GetPlate(plateIndex); + if (plate == null) + return $"Error: plate {plateIndex} not found"; + + var drawing = _session.GetDrawing(drawingName); + if (drawing == null) + return $"Error: drawing '{drawingName}' not found"; + + var countBefore = plate.Parts.Count; + var engine = new NestEngine(plate); + var item = new NestItem { Drawing = drawing, Quantity = quantity }; + var area = new Box(x, y, width, height); + var success = engine.Fill(item, area); + + var countAfter = plate.Parts.Count; + var added = countAfter - countBefore; + + var sb = new StringBuilder(); + sb.AppendLine($"Fill area ({x:F1},{y:F1} {width:F1}x{height:F1}) on plate {plateIndex} with '{drawingName}': {(success ? "success" : "failed")}"); + sb.AppendLine($" Parts added: {added}"); + sb.AppendLine($" Total parts: {countAfter}"); + sb.AppendLine($" Utilization: {plate.Utilization():P1}"); + + return sb.ToString(); + } + + [McpServerTool(Name = "fill_remnants")] + [Description("Find empty remnant regions on a plate and fill each with a drawing.")] + public string FillRemnants( + [Description("Index of the plate")] int plateIndex, + [Description("Name of the drawing to fill with")] string drawingName, + [Description("Maximum quantity per remnant (0 = unlimited)")] int quantity = 0) + { + var plate = _session.GetPlate(plateIndex); + if (plate == null) + return $"Error: plate {plateIndex} not found"; + + var drawing = _session.GetDrawing(drawingName); + if (drawing == null) + return $"Error: drawing '{drawingName}' not found"; + + var remnants = plate.GetRemnants(); + + if (remnants.Count == 0) + return $"No remnant areas found on plate {plateIndex}"; + + var sb = new StringBuilder(); + sb.AppendLine($"Found {remnants.Count} remnant area(s) on plate {plateIndex}"); + + var totalAdded = 0; + var engine = new NestEngine(plate); + + for (var i = 0; i < remnants.Count; i++) + { + var remnant = remnants[i]; + var countBefore = plate.Parts.Count; + var item = new NestItem { Drawing = drawing, Quantity = quantity }; + var success = engine.Fill(item, remnant); + var added = plate.Parts.Count - countBefore; + totalAdded += added; + + sb.AppendLine($" Remnant {i}: ({remnant.X:F1},{remnant.Y:F1} {remnant.Width:F1}x{remnant.Height:F1}) -> {added} parts {(success ? "" : "(no fit)")}"); + } + + sb.AppendLine($"Total parts added: {totalAdded}"); + sb.AppendLine($"Utilization: {plate.Utilization():P1}"); + + return sb.ToString(); + } + + [McpServerTool(Name = "pack_plate")] + [Description("Pack multiple drawings onto a plate using bin-packing. Specify drawings and quantities as comma-separated lists.")] + public string PackPlate( + [Description("Index of the plate")] int plateIndex, + [Description("Comma-separated drawing names")] string drawingNames, + [Description("Comma-separated quantities for each drawing")] string quantities) + { + var plate = _session.GetPlate(plateIndex); + if (plate == null) + return $"Error: plate {plateIndex} not found"; + + if (string.IsNullOrWhiteSpace(drawingNames)) + return "Error: drawingNames is required"; + + if (string.IsNullOrWhiteSpace(quantities)) + return "Error: quantities is required"; + + var names = drawingNames.Split(',').Select(n => n.Trim()).ToArray(); + var qtyStrings = quantities.Split(',').Select(q => q.Trim()).ToArray(); + var qtys = new int[qtyStrings.Length]; + + for (var i = 0; i < qtyStrings.Length; i++) + { + if (!int.TryParse(qtyStrings[i], out qtys[i])) + return $"Error: '{qtyStrings[i]}' is not a valid quantity"; + } + + if (names.Length != qtys.Length) + return $"Error: drawing names count ({names.Length}) does not match quantities count ({qtys.Length})"; + + var items = new List(); + + for (var i = 0; i < names.Length; i++) + { + var drawing = _session.GetDrawing(names[i]); + if (drawing == null) + return $"Error: drawing '{names[i]}' not found"; + + items.Add(new NestItem { Drawing = drawing, Quantity = qtys[i] }); + } + + var countBefore = plate.Parts.Count; + var engine = new NestEngine(plate); + var success = engine.Pack(items); + var countAfter = plate.Parts.Count; + var added = countAfter - countBefore; + + var sb = new StringBuilder(); + sb.AppendLine($"Pack plate {plateIndex}: {(success ? "success" : "failed")}"); + sb.AppendLine($" Parts added: {added}"); + sb.AppendLine($" Total parts: {countAfter}"); + sb.AppendLine($" Utilization: {plate.Utilization():P1}"); + + // Breakdown by drawing + var groups = plate.Parts.GroupBy(p => p.BaseDrawing.Name); + foreach (var group in groups) + sb.AppendLine($" {group.Key}: {group.Count()}"); + + return sb.ToString(); + } + } +} diff --git a/OpenNest.Mcp/Tools/SetupTools.cs b/OpenNest.Mcp/Tools/SetupTools.cs new file mode 100644 index 0000000..d093738 --- /dev/null +++ b/OpenNest.Mcp/Tools/SetupTools.cs @@ -0,0 +1,69 @@ +using System.ComponentModel; +using System.Text; +using ModelContextProtocol.Server; +using OpenNest.Geometry; + +namespace OpenNest.Mcp.Tools +{ + [McpServerToolType] + public class SetupTools + { + private readonly NestSession _session; + + public SetupTools(NestSession session) + { + _session = session; + } + + [McpServerTool(Name = "create_plate")] + [Description("Create a new plate with the given dimensions and spacing. Returns plate index and work area.")] + public string CreatePlate( + [Description("Plate width")] double width, + [Description("Plate height")] double height, + [Description("Spacing between parts (default 0)")] double partSpacing = 0, + [Description("Edge spacing on all sides (default 0)")] double edgeSpacing = 0, + [Description("Quadrant 1-4 (default 1). 1=TopRight, 2=TopLeft, 3=BottomLeft, 4=BottomRight")] int quadrant = 1, + [Description("Material name (optional)")] string material = null) + { + var plate = new Plate(width, height); + plate.PartSpacing = partSpacing; + plate.EdgeSpacing = new Spacing(edgeSpacing, edgeSpacing); + plate.Quadrant = quadrant; + plate.Quantity = 1; + + if (!string.IsNullOrEmpty(material)) + plate.Material.Name = material; + + _session.Plates.Add(plate); + + var allPlates = _session.AllPlates(); + var index = allPlates.Count - 1; + var work = plate.WorkArea(); + + var sb = new StringBuilder(); + sb.AppendLine($"Created plate {index}: {plate.Size.Width:F1} x {plate.Size.Height:F1}"); + sb.AppendLine($" Quadrant: {plate.Quadrant}"); + sb.AppendLine($" Part spacing: {plate.PartSpacing:F2}"); + sb.AppendLine($" Edge spacing: L={plate.EdgeSpacing.Left:F2} B={plate.EdgeSpacing.Bottom:F2} R={plate.EdgeSpacing.Right:F2} T={plate.EdgeSpacing.Top:F2}"); + sb.AppendLine($" Work area: {work.Width:F1} x {work.Height:F1}"); + + return sb.ToString(); + } + + [McpServerTool(Name = "clear_plate")] + [Description("Remove all parts from a plate. Returns how many parts were removed.")] + public string ClearPlate( + [Description("Index of the plate to clear")] int plateIndex) + { + var plate = _session.GetPlate(plateIndex); + + if (plate == null) + return $"Error: plate {plateIndex} not found"; + + var count = plate.Parts.Count; + plate.Parts.Clear(); + + return $"Cleared plate {plateIndex}: removed {count} parts"; + } + } +} diff --git a/OpenNest.sln b/OpenNest.sln index 251ebd6..42a267c 100644 --- a/OpenNest.sln +++ b/OpenNest.sln @@ -11,6 +11,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OpenNest.Engine", "OpenNest EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OpenNest.Gpu", "OpenNest.Gpu\OpenNest.Gpu.csproj", "{1F0DD58E-9E83-4F78-A9D9-0557C0B2D96F}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OpenNest.IO", "OpenNest.IO\OpenNest.IO.csproj", "{1EFCF5FB-7ADE-4044-B55D-60F6F75C3A8B}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OpenNest.Mcp", "OpenNest.Mcp\OpenNest.Mcp.csproj", "{61CC6F65-8B70-408A-B49A-F4E5F34FFD01}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -69,6 +73,30 @@ Global {1F0DD58E-9E83-4F78-A9D9-0557C0B2D96F}.Release|x64.Build.0 = Release|Any CPU {1F0DD58E-9E83-4F78-A9D9-0557C0B2D96F}.Release|x86.ActiveCfg = Release|Any CPU {1F0DD58E-9E83-4F78-A9D9-0557C0B2D96F}.Release|x86.Build.0 = Release|Any CPU + {1EFCF5FB-7ADE-4044-B55D-60F6F75C3A8B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {1EFCF5FB-7ADE-4044-B55D-60F6F75C3A8B}.Debug|Any CPU.Build.0 = Debug|Any CPU + {1EFCF5FB-7ADE-4044-B55D-60F6F75C3A8B}.Debug|x64.ActiveCfg = Debug|Any CPU + {1EFCF5FB-7ADE-4044-B55D-60F6F75C3A8B}.Debug|x64.Build.0 = Debug|Any CPU + {1EFCF5FB-7ADE-4044-B55D-60F6F75C3A8B}.Debug|x86.ActiveCfg = Debug|Any CPU + {1EFCF5FB-7ADE-4044-B55D-60F6F75C3A8B}.Debug|x86.Build.0 = Debug|Any CPU + {1EFCF5FB-7ADE-4044-B55D-60F6F75C3A8B}.Release|Any CPU.ActiveCfg = Release|Any CPU + {1EFCF5FB-7ADE-4044-B55D-60F6F75C3A8B}.Release|Any CPU.Build.0 = Release|Any CPU + {1EFCF5FB-7ADE-4044-B55D-60F6F75C3A8B}.Release|x64.ActiveCfg = Release|Any CPU + {1EFCF5FB-7ADE-4044-B55D-60F6F75C3A8B}.Release|x64.Build.0 = Release|Any CPU + {1EFCF5FB-7ADE-4044-B55D-60F6F75C3A8B}.Release|x86.ActiveCfg = Release|Any CPU + {1EFCF5FB-7ADE-4044-B55D-60F6F75C3A8B}.Release|x86.Build.0 = Release|Any CPU + {61CC6F65-8B70-408A-B49A-F4E5F34FFD01}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {61CC6F65-8B70-408A-B49A-F4E5F34FFD01}.Debug|Any CPU.Build.0 = Debug|Any CPU + {61CC6F65-8B70-408A-B49A-F4E5F34FFD01}.Debug|x64.ActiveCfg = Debug|Any CPU + {61CC6F65-8B70-408A-B49A-F4E5F34FFD01}.Debug|x64.Build.0 = Debug|Any CPU + {61CC6F65-8B70-408A-B49A-F4E5F34FFD01}.Debug|x86.ActiveCfg = Debug|Any CPU + {61CC6F65-8B70-408A-B49A-F4E5F34FFD01}.Debug|x86.Build.0 = Debug|Any CPU + {61CC6F65-8B70-408A-B49A-F4E5F34FFD01}.Release|Any CPU.ActiveCfg = Release|Any CPU + {61CC6F65-8B70-408A-B49A-F4E5F34FFD01}.Release|Any CPU.Build.0 = Release|Any CPU + {61CC6F65-8B70-408A-B49A-F4E5F34FFD01}.Release|x64.ActiveCfg = Release|Any CPU + {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 EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/OpenNest/OpenNest.csproj b/OpenNest/OpenNest.csproj index 4eafc18..b2c2b77 100644 --- a/OpenNest/OpenNest.csproj +++ b/OpenNest/OpenNest.csproj @@ -14,7 +14,7 @@ - +