mirror of
https://github.com/ajisaacs/OpenNest.git
synced 2026-10-10 07:34:14 -04:00
fix(mcp): report requested and placed drawing mix
This commit is contained in:
1 parent
8b94e49711
commit
3d5c73a353
3 files changed
+136
-8
No files matched your search
@@ -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<NestJobProgress>? 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)],
|
||||
[], []);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<Part>() : proposed.Parts);
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
private static void AppendMixSummary(StringBuilder sb, IReadOnlyList<NestItem> items, IEnumerable<Part> newlyPlaced)
|
||||
{
|
||||
var counts = new Dictionary<Drawing, int>(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.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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<NestItem>();
|
||||
var seen = new HashSet<Drawing>(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] });
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user