diff --git a/OpenNest.FrontEnd.Tests/McpPlateToolsTests.cs b/OpenNest.FrontEnd.Tests/McpPlateToolsTests.cs index f5b238e..c31b732 100644 --- a/OpenNest.FrontEnd.Tests/McpPlateToolsTests.cs +++ b/OpenNest.FrontEnd.Tests/McpPlateToolsTests.cs @@ -1,3 +1,4 @@ +using OpenNest.CNC; using OpenNest.Geometry; using OpenNest.IO; using OpenNest.Mcp; @@ -35,4 +36,77 @@ public class McpPlateToolsTests : IDisposable Assert.Same(appended, sessionOnly.GetPlate(0)); Assert.Null(sessionOnly.GetPlate(1)); } + + private static string Delete(SetupTools tools, int index) => tools.DeletePlate(index); + + [Fact] + public void DeleteEmptyLoadedMiddlePlateRenumbersAppendedAndPersists() + { + var session = new NestSession { Nest = new Nest("fixture") }; + var drawing = new Drawing("square", new Program()); + session.Nest.Drawings.Add(drawing); + var occupied = new Plate(20, 30) { Quantity = 2 }; + occupied.Parts.Add(new Part(drawing)); + var unused = new Plate(40, 50); + var appended = new Plate(60, 70) { PartSpacing = 1.25, Quantity = 3 }; + session.Nest.Plates.Add(occupied); + session.Nest.Plates.Add(unused); + session.Plates.Add(appended); + var tools = new SetupTools(session); + + var result = Delete(tools, 1); + Assert.Contains("Deleted plate 1", result); + Assert.Contains("2 -> 1", result); + Assert.Single(session.Nest.Plates); + Assert.Same(appended, session.GetPlate(1)); + Assert.Null(session.GetPlate(2)); + Assert.Contains("60.0 x 70.0", new InspectionTools(session).GetPlateInfo(1)); + Assert.Equal(2, drawing.Quantity.Nested); + + var path = Path.Combine(directory, "cleaned.nest"); + Assert.Contains("Saved nest", new InputTools(session).SaveNest(path)); + var reloaded = new NestReader(path).Read(); + // NestWriter already omits empty plates; the deletion is session cleanup, + // and the saved file must still retain the occupied plate and its counts. + var saved = Assert.Single(reloaded.Plates); + Assert.Single(saved.Parts); + Assert.Equal(20, saved.Size.Width); + Assert.Equal(2, saved.Quantity); + Assert.Equal(2, reloaded.Drawings.Single().Quantity.Nested); + } + + [Fact] + public void DeleteRefusesOccupiedCutoffAndInvalidIndicesWithoutMutation() + { + var session = new NestSession { Nest = new Nest("fixture") }; + var drawing = new Drawing("square", new Program()); + session.Nest.Drawings.Add(drawing); + var occupied = new Plate(20, 30) { Quantity = 4 }; + var part = new Part(drawing); + occupied.Parts.Add(part); + var cutoff = new Plate(40, 50); + var definition = new CutOff(new Vector(10, 10), CutOffAxis.Vertical); + cutoff.CutOffs.Add(definition); + session.Nest.Plates.Add(occupied); + session.Plates.Add(cutoff); + var tools = new SetupTools(session); + + Assert.Contains("occupied", Delete(tools, 0)); + Assert.Contains("cutoff", Delete(tools, 1), StringComparison.OrdinalIgnoreCase); + Assert.Contains("not found", Delete(tools, -1)); + Assert.Contains("not found", Delete(tools, 2)); + Assert.Same(occupied, session.GetPlate(0)); + Assert.Same(cutoff, session.GetPlate(1)); + Assert.Same(part, Assert.Single(occupied.Parts)); + Assert.Same(definition, Assert.Single(cutoff.CutOffs)); + Assert.Equal(4, drawing.Quantity.Nested); + Assert.Single(session.Nest.Plates); + Assert.Single(session.Plates); + + // The session-only collection must also delete without requiring a loaded nest. + var standalone = new NestSession(); + standalone.Plates.Add(new Plate(12, 13)); + Assert.Contains("Deleted plate 0", Delete(new SetupTools(standalone), 0)); + Assert.Empty(standalone.Plates); + } } diff --git a/OpenNest.Mcp/NestSession.cs b/OpenNest.Mcp/NestSession.cs index 579543e..25a491d 100644 --- a/OpenNest.Mcp/NestSession.cs +++ b/OpenNest.Mcp/NestSession.cs @@ -30,6 +30,38 @@ namespace OpenNest.Mcp return null; } + /// Remove only a genuinely unused sheet; loaded sheets precede appended sheets. + public bool TryDeleteEmptyPlate(int index, out string reason) + { + var plate = GetPlate(index); + if (plate == null) + { + reason = $"plate {index} not found"; + return false; + } + + if (plate.Parts.Count != 0) + { + reason = $"plate {index} is occupied"; + return false; + } + + if (plate.CutOffs.Count != 0) + { + reason = $"plate {index} has cutoff definitions"; + return false; + } + + var loadedCount = Nest?.Plates.Count ?? 0; + if (index < loadedCount) + Nest.Plates.RemoveAt(index); + else + Plates.RemoveAt(index - loadedCount); + + reason = null; + return true; + } + public Drawing GetDrawing(string name) { if (Nest != null) diff --git a/OpenNest.Mcp/Tools/SetupTools.cs b/OpenNest.Mcp/Tools/SetupTools.cs index 40a8f9f..450cde0 100644 --- a/OpenNest.Mcp/Tools/SetupTools.cs +++ b/OpenNest.Mcp/Tools/SetupTools.cs @@ -58,6 +58,22 @@ namespace OpenNest.Mcp.Tools return sb.ToString(); } + [McpServerTool(Name = "delete_plate")] + [Description("Delete an empty plate without parts or cutoff definitions. Later plate indices shift down by one.")] + public string DeletePlate([Description("Index of the empty plate to delete")] int plateIndex) + { + var oldCount = _session.AllPlates().Count; + if (!_session.TryDeleteEmptyPlate(plateIndex, out var reason)) + return $"Error: {reason}; no plates changed"; + + var sb = new StringBuilder(); + sb.AppendLine($"Deleted plate {plateIndex}. Plates: {oldCount} -> {oldCount - 1}."); + for (var oldIndex = plateIndex + 1; oldIndex < oldCount; oldIndex++) + sb.AppendLine($" {oldIndex} -> {oldIndex - 1}"); + sb.AppendLine("Use get_plate_info with the new indices before further operations."); + 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) diff --git a/docs/automatic-nesting.md b/docs/automatic-nesting.md index d40d77c..b3df09d 100644 --- a/docs/automatic-nesting.md +++ b/docs/automatic-nesting.md @@ -48,6 +48,8 @@ Invalid output is printed and rejected with exit code 2 without saving or postin MCP `autonest_plate` requires an empty target. The stdio server serializes all tool calls sharing its mutable session, so another request cannot change drawings or occupy a target during a solve. `allow_invalid` defaults to false. It reports violations and makes no changes on rejection, including with an override when the output is unrepresentable or contains multiple sheets. `engine` defaults to Default, and its description lists the built-in engines with what each suits. Fill tools (`fill_plate`, `fill_area`, `fill_remnants`, `pack_plate`) remain separate and default to the Fill strategy. Console and MCP load jobs plug-ins from `Engines/` beside their executable. +MCP `delete_plate(plateIndex)` removes only a sheet with no parts and no cutoff definitions, from either loaded or newly created session plates. Occupied, cutoff-bearing and invalid indices are refused without changing the session. After deletion every later index shifts down by one; inspect `get_plate_info` before further edits. This is session cleanup, not a data purge or a save-time option. `save_nest` lists all session plates in its response, but the existing `.nest` writer omits empty cutoff-free plates from the serialized file; reloading may therefore have fewer plates than the response says. There is no opt-in switch to preserve empty plates in the file. + ### MCP engine development harness `test_engine` builds and runs `OpenNest.Console` in a configured, trusted checkout.