diff --git a/OpenNest.FrontEnd.Tests/McpMixReportingTests.cs b/OpenNest.FrontEnd.Tests/McpMixReportingTests.cs new file mode 100644 index 0000000..9a0c51d --- /dev/null +++ b/OpenNest.FrontEnd.Tests/McpMixReportingTests.cs @@ -0,0 +1,91 @@ +using OpenNest.CNC; +using OpenNest.Engine.Jobs; +using OpenNest.Geometry; +using OpenNest.Mcp; +using OpenNest.Mcp.Tools; + +namespace OpenNest.FrontEnd.Tests; + +[Collection("FrontEndRegistry")] +public class McpMixReportingTests +{ + private static Drawing Rectangle(string name, double width, double length) + { + var shape = new Shape(); + shape.Entities.Add(new Line(new Vector(0, 0), new Vector(width, 0))); + shape.Entities.Add(new Line(new Vector(width, 0), new Vector(width, length))); + shape.Entities.Add(new Line(new Vector(width, length), new Vector(0, length))); + shape.Entities.Add(new Line(new Vector(0, length), new Vector(0, 0))); + return new Drawing(name, OpenNest.Converters.ConvertGeometry.ToProgram(shape)); + } + + private static NestSession Session() + { + var session = new NestSession(); + session.Drawings.Add(Rectangle("A", 2, 2)); + session.Drawings.Add(Rectangle("B", 20, 20)); + session.Plates.Add(new Plate(10, 10)); + return session; + } + + [Fact] + public void PackReportsZeroForUnfitDrawingAndCountsOnlyNewParts() + { + var session = Session(); + var plate = session.GetPlate(0); + plate.Parts.Add(new Part(session.Drawings[0])); + var text = new NestingTools(session).PackPlate(0, "A,B", "10,10"); + Assert.Contains("A: requested=10", text); + Assert.Contains("B: requested=10, placed=0, remaining=10", text); + Assert.Contains("ratio not met", text); + Assert.Contains($"A: requested=10, placed={plate.Parts.Count - 1}, remaining={11 - plate.Parts.Count}", text); + Assert.True(plate.Parts.Count > 1); + } + + [Fact] + public void AutoNestReportsSkewedAndZeroProgressByRequirement() + { + var session = Session(); + var engine = "MixStub-" + Guid.NewGuid(); + NestingEngineRegistry.Register(engine, "test", () => new Stub()); + var tool = new NestingTools(session); + var text = tool.AutoNestPlate(0, "A,B", "2,2", engine); + Assert.Contains("A: requested=2, placed=1, remaining=1", text); + Assert.Contains("B: requested=2, placed=0, remaining=2", text); + Assert.Contains("ratio not met", text); + + // Duplicate tokens must be refused before the engine can mutate a fresh target. + var fresh = Session(); + Assert.Contains("duplicate", new NestingTools(fresh).AutoNestPlate(0, "A,A", "2,2", engine), StringComparison.OrdinalIgnoreCase); + Assert.Empty(fresh.GetPlate(0).Parts); + Assert.Single(session.GetPlate(0).Parts); + } + + [Fact] + public void PackZeroProgressAndAmbiguousNamesAreExplicit() + { + var session = Session(); + session.GetPlate(0).Size = new Size(1, 1); + var text = new NestingTools(session).PackPlate(0, "A,B", "2,2"); + Assert.Contains("A: requested=2, placed=0, remaining=2", text); + Assert.Contains("B: requested=2, placed=0, remaining=2", text); + Assert.Contains("zero progress", text); + Assert.Empty(session.GetPlate(0).Parts); + + session.Drawings.Add(Rectangle("A", 1, 1)); + Assert.Contains("ambiguous drawing name", new NestingTools(session).PackPlate(0, "A,B", "2,2")); + Assert.Empty(session.GetPlate(0).Parts); + } + + private sealed class Stub : INestingEngine + { + public NestJobResult Solve(NestJob job, IProgress? progress = null, + CancellationToken token = default) + { + var placed = new[] { new NestJobPlacement(job.Parts[0].Id, 0, 1, 1, 0) }; + return new NestJobResult(NestJobStatus.Incomplete, NestJobStopReason.Completed, + [new NestJobPlateResult(0, job.Plates[0], placed)], + [], []); + } + } +} diff --git a/OpenNest.Mcp/Tools/NestingTools.cs b/OpenNest.Mcp/Tools/NestingTools.cs index 8f89d25..558f299 100644 --- a/OpenNest.Mcp/Tools/NestingTools.cs +++ b/OpenNest.Mcp/Tools/NestingTools.cs @@ -257,10 +257,7 @@ namespace OpenNest.Mcp.Tools 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()}"); + AppendMixSummary(sb, items, parts); return sb.ToString(); } @@ -333,11 +330,43 @@ namespace OpenNest.Mcp.Tools sb.AppendLine($" Parts placed: {totalPlaced}"); sb.AppendLine($" Total parts: {plate.Parts.Count}"); sb.AppendLine($" Utilization: {plate.Utilization():P1}"); - foreach (var group in plate.Parts.GroupBy(p => p.BaseDrawing.Name)) - sb.AppendLine($" {group.Key}: {group.Count()}"); + AppendMixSummary(sb, parsed.items, proposed == null ? Enumerable.Empty() : proposed.Parts); return sb.ToString(); } + private static void AppendMixSummary(StringBuilder sb, IReadOnlyList items, IEnumerable newlyPlaced) + { + var counts = new Dictionary(ReferenceEqualityComparer.Instance); + foreach (var part in newlyPlaced) + counts[part.BaseDrawing] = counts.TryGetValue(part.BaseDrawing, out var n) ? n + 1 : 1; + + var placed = new int[items.Count]; + sb.AppendLine(" Requested mix (new placements only):"); + for (var i = 0; i < items.Count; i++) + { + var item = items[i]; + placed[i] = counts.GetValueOrDefault(item.Drawing); + var remaining = item.Quantity > 0 ? (item.Quantity - placed[i]).ToString() : "unlimited"; + sb.AppendLine($" [{i}] {item.Drawing.Name}: requested={item.Quantity}, placed={placed[i]}, remaining={remaining}"); + } + + var total = placed.Sum(count => (long)count); + if (total == 0) + sb.AppendLine(" Mix: zero progress for all requested drawings."); + else if (items.Count > 1 && items.All(item => item.Quantity > 0)) + { + var balanced = true; + for (var i = 1; i < items.Count; i++) + if ((long)placed[i] * items[0].Quantity != (long)placed[0] * items[i].Quantity) + balanced = false; + sb.AppendLine(balanced + ? " Mix: ratio satisfied for new placements; remaining demand may still exist." + : " Warning: ratio not met by new placements; quantities are ceilings, not a balance constraint."); + } + else if (items.Count > 1) + sb.AppendLine(" Mix: ratio not evaluated for unlimited or nonpositive requested quantities."); + } + /// /// Resolves the requested fill strategy; an omitted name means Fill. Returns null when the /// name is not a single-plate placement strategy. @@ -385,12 +414,18 @@ namespace OpenNest.Mcp.Tools ); var items = new List(); + var seen = new HashSet(ReferenceEqualityComparer.Instance); for (var i = 0; i < names.Length; i++) { - var drawing = _session.GetDrawing(names[i]); - if (drawing == null) + var matches = _session.AllDrawings().Where(d => d.Name == names[i]).ToArray(); + if (matches.Length == 0) return (null, $"Error: drawing '{names[i]}' not found"); + if (matches.Length > 1) + return (null, $"Error: ambiguous drawing name '{names[i]}' matches multiple drawings"); + var drawing = matches[0]; + if (!seen.Add(drawing)) + return (null, $"Error: duplicate drawing '{names[i]}' in request"); items.Add(new NestItem { Drawing = drawing, Quantity = qtys[i] }); } diff --git a/docs/automatic-nesting.md b/docs/automatic-nesting.md index b3df09d..9b975d1 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 `pack_plate` and `autonest_plate` report each requested drawing's newly placed count and remaining ceiling, indexed by request; a skewed positive-demand mix gets an explicit `ratio not met` warning. This is reporting, not proportional packing: both tools still choose the same density-first layout. Duplicate request tokens and ambiguous drawing names are refused before placement. A zero-quantity pack request remains unlimited and has no meaningful ratio. A partially placed balanced mix can still leave demand unfilled. Strict per-sheet ratio enforcement is not available yet. + 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