refactor(nesting): unify console MCP and API validation and commit policies
This commit is contained in:
@@ -49,7 +49,7 @@ jobs:
|
||||
if ($LASTEXITCODE -ne 0) { throw 'Solution build failed.' }
|
||||
- name: Run all test projects on Windows
|
||||
run: |
|
||||
foreach ($project in @('OpenNest.Tests', 'OpenNest.Engine.Tests', 'OpenNest.IO.Tests', 'OpenNest.WinForms.Tests')) {
|
||||
foreach ($project in @('OpenNest.Tests', 'OpenNest.Engine.Tests', 'OpenNest.IO.Tests', 'OpenNest.WinForms.Tests', 'OpenNest.FrontEnd.Tests')) {
|
||||
dotnet test "$project/$project.csproj" -c Release --no-build --logger "trx;LogFileName=$project.trx" --results-directory TestResults
|
||||
if ($LASTEXITCODE -ne 0) { throw "$project failed." }
|
||||
}
|
||||
|
||||
@@ -16,6 +16,9 @@ public class NestRequest
|
||||
|
||||
/// <summary>Built-in whole-job placement strategy. Explicit values take precedence over legacy Strategy.</summary>
|
||||
public string PlacementStrategy { get; init; } = "Default";
|
||||
|
||||
/// <summary>Registered whole-job engine. Null uses PlacementStrategy (or legacy Strategy).</summary>
|
||||
public string Engine { get; init; }
|
||||
public string Material { get; init; } = "Steel, A1011 HR";
|
||||
public double Thickness { get; init; } = 0.06;
|
||||
public double Spacing { get; init; } = 0.1;
|
||||
|
||||
@@ -5,8 +5,8 @@ using System.IO.Compression;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using System.Threading.Tasks;
|
||||
using OpenNest.IO;
|
||||
using OpenNest.Engine.Jobs;
|
||||
using OpenNest.IO;
|
||||
|
||||
namespace OpenNest.Api;
|
||||
|
||||
@@ -19,9 +19,12 @@ public sealed record NestStockUsage(string StockId, int Used, int? Remaining);
|
||||
/// <summary>Maps each materialized physical sheet to its source stock identity.</summary>
|
||||
public sealed record NestPlateStockMapping(int PlateIndex, string StockId);
|
||||
|
||||
/// <summary>Independent geometry validation; null on old archives means not recorded.</summary>
|
||||
public enum NestValidationStatus { Valid, Invalid, Unrepresentable }
|
||||
|
||||
public class NestResponse
|
||||
{
|
||||
public const int CurrentSchemaVersion = 2;
|
||||
public const int CurrentSchemaVersion = 3;
|
||||
|
||||
/// <summary>Zero identifies an archive written before response metadata was versioned.</summary>
|
||||
public int SchemaVersion { get; init; } = CurrentSchemaVersion;
|
||||
@@ -35,6 +38,9 @@ public class NestResponse
|
||||
/// <summary>Null means an older archive did not record whole-job fulfillment status.</summary>
|
||||
public NestJobStatus? Status { get; init; }
|
||||
public NestJobStopReason? StopReason { get; init; }
|
||||
/// <summary>Fulfillment status is not geometry acceptance. Review this status before using Nest.</summary>
|
||||
public NestValidationStatus? ValidationStatus { get; init; }
|
||||
public IReadOnlyList<string> Violations { get; init; } = [];
|
||||
public IReadOnlyList<NestPartFulfillment> Fulfillment { get; init; } = [];
|
||||
public IReadOnlyList<NestStockUsage> StockUsage { get; init; } = [];
|
||||
public IReadOnlyList<NestPlateStockMapping> PlateStockMappings { get; init; } = [];
|
||||
@@ -75,6 +81,8 @@ public class NestResponse
|
||||
ElapsedTicks = Elapsed.Ticks,
|
||||
Status = Status,
|
||||
StopReason = StopReason,
|
||||
ValidationStatus = ValidationStatus,
|
||||
Violations = Violations is null ? [] : new List<string>(Violations),
|
||||
Fulfillment = Fulfillment is null
|
||||
? []
|
||||
: new List<NestPartFulfillment>(Fulfillment),
|
||||
@@ -158,6 +166,8 @@ public class NestResponse
|
||||
Elapsed = TimeSpan.FromTicks(archive.ElapsedTicks),
|
||||
Status = hasStatusMetadata ? archive.Status : null,
|
||||
StopReason = hasStatusMetadata ? archive.StopReason : null,
|
||||
ValidationStatus = archive.ValidationStatus,
|
||||
Violations = archive.Violations ?? [],
|
||||
Fulfillment = hasStatusMetadata ? archive.Fulfillment ?? [] : [],
|
||||
StockUsage = hasStatusMetadata ? archive.StockUsage ?? [] : [],
|
||||
PlateStockMappings = hasStatusMetadata ? archive.PlateStockMappings ?? [] : [],
|
||||
@@ -175,6 +185,8 @@ public class NestResponse
|
||||
public long ElapsedTicks { get; init; }
|
||||
public NestJobStatus? Status { get; init; }
|
||||
public NestJobStopReason? StopReason { get; init; }
|
||||
public NestValidationStatus? ValidationStatus { get; init; }
|
||||
public List<string> Violations { get; init; } = [];
|
||||
public List<NestPartFulfillment> Fulfillment { get; init; } = [];
|
||||
public List<NestStockUsage> StockUsage { get; init; } = [];
|
||||
public List<NestPlateStockMapping> PlateStockMappings { get; init; } = [];
|
||||
|
||||
+67
-39
@@ -5,10 +5,9 @@ using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using OpenNest.IO;
|
||||
using OpenNest.Engine;
|
||||
using OpenNest.Engine.Jobs;
|
||||
using OpenNest.Engine.Jobs.Adapters;
|
||||
using OpenNest.IO;
|
||||
|
||||
namespace OpenNest.Api;
|
||||
|
||||
@@ -32,7 +31,7 @@ public static class NestRunner
|
||||
var sw = Stopwatch.StartNew();
|
||||
var parts = IdentifyParts(requestParts);
|
||||
var importedByPath = new Dictionary<string, Drawing>(StringComparer.Ordinal);
|
||||
var jobParts = new List<NestJobPart>(parts.Count);
|
||||
var items = new List<NestItem>(parts.Count);
|
||||
|
||||
foreach (var part in parts)
|
||||
{
|
||||
@@ -68,23 +67,64 @@ public static class NestRunner
|
||||
importedByPath.Add(part.Request.DxfPath, drawing);
|
||||
}
|
||||
|
||||
ConfigureDrawingForRequirement(drawing, part.Request);
|
||||
jobParts.Add(DrawingJobMapper.FromDrawing(part.Id, drawing, part.Request.Quantity));
|
||||
// Each requirement keeps its own identity/constraints even when paths are shared.
|
||||
var requirementDrawing = new Drawing(part.Id, drawing.Program);
|
||||
ConfigureDrawingForRequirement(requirementDrawing, part.Request);
|
||||
requirementDrawing.Quantity.Required = part.Request.Quantity;
|
||||
items.Add(new NestItem
|
||||
{
|
||||
Drawing = requirementDrawing,
|
||||
Quantity = part.Request.Quantity,
|
||||
Priority = part.Request.Priority,
|
||||
StepAngle = requirementDrawing.Constraints.StepAngle,
|
||||
RotationStart = requirementDrawing.Constraints.StartAngle,
|
||||
RotationEnd = requirementDrawing.Constraints.EndAngle,
|
||||
});
|
||||
}
|
||||
|
||||
var job = new NestJob(
|
||||
jobParts,
|
||||
CreateStock(request),
|
||||
new NestJobOptions(ResolvePlacementStrategy(request))
|
||||
);
|
||||
var stock = CreateStock(request);
|
||||
var engineName = request.Engine ?? ResolvePlacementStrategy(request);
|
||||
var jobProgress = progress == null ? null : new JobProgressBridge(progress);
|
||||
var result = new NestJobRunner(PlateNesterFactory.Create).Solve(job, jobProgress, token);
|
||||
var result = NestPipeline.Run(new NestPipelineRequest(
|
||||
engineName, items, stock, new NestJobOptions(engineName)), jobProgress, token);
|
||||
|
||||
// This is the sole translation from immutable result poses to mutable legacy output objects.
|
||||
var materialized = NestResultMaterializer.Materialize(job, result);
|
||||
var nest = materialized.Nest;
|
||||
nest.Thickness = request.Thickness;
|
||||
nest.Material = new Material(request.Material);
|
||||
// API returns a detached proposal, not an acceptance/commit to a caller's nest.
|
||||
// Invalid but representable proposals retain every pose and carry explicit validation status.
|
||||
var nest = new Nest { Thickness = request.Thickness, Material = new Material(request.Material) };
|
||||
foreach (var item in items)
|
||||
nest.Drawings.Add(item.Drawing);
|
||||
foreach (var proposed in result.Plates)
|
||||
{
|
||||
var plate = new Plate(proposed.Stock.Size)
|
||||
{
|
||||
PartSpacing = proposed.Stock.PartSpacing,
|
||||
EdgeSpacing = proposed.Stock.EdgeSpacing,
|
||||
Quadrant = proposed.Stock.Quadrant,
|
||||
Quantity = 1,
|
||||
};
|
||||
plate.Parts.AddRange(proposed.Parts);
|
||||
nest.Plates.Add(plate);
|
||||
}
|
||||
|
||||
// Pipeline IDs are internal part-i values. Exposed counts/IDs come from the
|
||||
// returned, bound placements and the original request, never plug-in summaries.
|
||||
var counts = result.Plates.SelectMany(p => p.Parts)
|
||||
.GroupBy(p => p.BaseDrawing).ToDictionary(g => g.Key, g => g.Count());
|
||||
var fulfillment = parts.Select((part, i) =>
|
||||
{
|
||||
var placed = counts.GetValueOrDefault(items[i].Drawing);
|
||||
return new NestPartFulfillment(part.Id, part.Request.Quantity, placed,
|
||||
System.Math.Max(0, part.Request.Quantity - placed));
|
||||
}).ToArray();
|
||||
var usage = stock.Select(s =>
|
||||
{
|
||||
var used = result.Plates.Count(p => p.Stock.Id == s.Id);
|
||||
return new NestStockUsage(s.Id, used, s.Quantity.HasValue ? System.Math.Max(0, s.Quantity.Value - used) : null);
|
||||
}).ToArray();
|
||||
var complete = fulfillment.All(f => f.Placed == f.Requested);
|
||||
var stopReason = complete ? NestJobStopReason.Completed
|
||||
: usage.All(u => u.Remaining == 0) ? NestJobStopReason.StockExhausted
|
||||
: NestJobStopReason.NoPlacementFound;
|
||||
|
||||
var timingInfo = Timing.GetTimingInfo(nest);
|
||||
var cutTime = Timing.CalculateTime(timingInfo, request.Cutting);
|
||||
@@ -97,29 +137,15 @@ public static class NestRunner
|
||||
Utilization = CalculateUtilization(nest),
|
||||
CutTime = cutTime,
|
||||
Elapsed = sw.Elapsed,
|
||||
Status = result.Status,
|
||||
StopReason = result.StopReason,
|
||||
Fulfillment = result
|
||||
.Fulfillment.Select(value => new NestPartFulfillment(
|
||||
value.PartId,
|
||||
value.Requested,
|
||||
value.Placed,
|
||||
value.Unplaced
|
||||
))
|
||||
.ToArray(),
|
||||
StockUsage = result
|
||||
.StockUsage.Select(value => new NestStockUsage(
|
||||
value.StockId,
|
||||
value.Used,
|
||||
value.Remaining
|
||||
))
|
||||
.ToArray(),
|
||||
PlateStockMappings = result
|
||||
.Plates.Select(value => new NestPlateStockMapping(
|
||||
value.PlateIndex,
|
||||
value.StockId
|
||||
))
|
||||
.ToArray(),
|
||||
Status = complete ? NestJobStatus.Complete : NestJobStatus.Incomplete,
|
||||
StopReason = stopReason,
|
||||
ValidationStatus = !result.CanKeep ? NestValidationStatus.Unrepresentable
|
||||
: result.IsValid ? NestValidationStatus.Valid : NestValidationStatus.Invalid,
|
||||
Violations = result.Violations,
|
||||
Fulfillment = fulfillment,
|
||||
StockUsage = usage,
|
||||
PlateStockMappings = result.Plates.Select((value, index) =>
|
||||
new NestPlateStockMapping(index, value.Stock.Id)).ToArray(),
|
||||
Nest = nest,
|
||||
Request = request,
|
||||
}
|
||||
@@ -140,6 +166,8 @@ public static class NestRunner
|
||||
"Request parts must not contain null entries.",
|
||||
nameof(requestParts)
|
||||
);
|
||||
if (part.Quantity < 0)
|
||||
throw new ArgumentException("Part quantities must be nonnegative.", nameof(requestParts));
|
||||
var id = part.Id ?? $"part-{index}";
|
||||
if (string.IsNullOrWhiteSpace(id))
|
||||
throw new ArgumentException("Part IDs must not be blank.", nameof(requestParts));
|
||||
|
||||
+83
-43
@@ -10,7 +10,6 @@ using OpenNest;
|
||||
using OpenNest.Diagnostics;
|
||||
using OpenNest.Engine;
|
||||
using OpenNest.Engine.Jobs;
|
||||
using OpenNest.Engine.Jobs.Adapters;
|
||||
using OpenNest.Engine.Jobs.Placement;
|
||||
using OpenNest.Geometry;
|
||||
using OpenNest.IO;
|
||||
@@ -22,6 +21,33 @@ static class NestConsole
|
||||
{
|
||||
public static int Run(string[] args)
|
||||
{
|
||||
using var cancellation = new CancellationTokenSource();
|
||||
ConsoleCancelEventHandler cancel = (_, e) => { e.Cancel = true; cancellation.Cancel(); };
|
||||
Console.CancelKeyPress += cancel;
|
||||
try
|
||||
{
|
||||
return RunCore(args, cancellation.Token);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
Console.Error.WriteLine("Nesting cancelled; nothing saved.");
|
||||
return 2;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.Error.WriteLine($"Error: {ex.Message}");
|
||||
return 1;
|
||||
}
|
||||
finally
|
||||
{
|
||||
Console.CancelKeyPress -= cancel;
|
||||
}
|
||||
}
|
||||
|
||||
static int RunCore(string[] args, CancellationToken token)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
NestingEngineRegistry.LoadPlugins(Path.Combine(AppContext.BaseDirectory, "Engines"));
|
||||
var options = ParseArgs(args);
|
||||
|
||||
if (options == null)
|
||||
@@ -101,6 +127,12 @@ static class NestConsole
|
||||
|
||||
var plate = nest.Plates[options.PlateIndex];
|
||||
|
||||
if (options.AutoNest && options.KeepParts && plate.Parts.Count > 0)
|
||||
{
|
||||
Console.Error.WriteLine("Error: --autonest --keep-parts cannot use an occupied plate. Use plain fill for existing obstacles.");
|
||||
return 2;
|
||||
}
|
||||
|
||||
ApplyTemplate(plate, options);
|
||||
ApplyOverrides(plate, options);
|
||||
|
||||
@@ -111,16 +143,19 @@ static class NestConsole
|
||||
|
||||
var existingCount = plate.Parts.Count;
|
||||
|
||||
if (!options.KeepParts)
|
||||
if (!options.AutoNest && !options.KeepParts)
|
||||
plate.Parts.Clear();
|
||||
|
||||
PrintHeader(nest, plate, drawing, existingCount, options);
|
||||
|
||||
var (success, elapsed) = Fill(nest, plate, drawing, options);
|
||||
var (success, elapsed, accepted) = Fill(nest, plate, drawing, options, token);
|
||||
if (!accepted)
|
||||
return 2; // No save or post may run after a rejected proposal.
|
||||
|
||||
var overlapCount = CheckOverlaps(plate, options);
|
||||
|
||||
PrintResults(success, plate, elapsed);
|
||||
token.ThrowIfCancellationRequested();
|
||||
if (!SaveAndPost(nest, options))
|
||||
return 1;
|
||||
|
||||
@@ -186,6 +221,9 @@ static class NestConsole
|
||||
case "--template" when i + 1 < args.Length:
|
||||
o.TemplateFile = args[++i];
|
||||
break;
|
||||
case "--allow-invalid":
|
||||
o.AllowInvalid = true;
|
||||
break;
|
||||
case "--autonest":
|
||||
o.AutoNest = true;
|
||||
break;
|
||||
@@ -398,6 +436,8 @@ static class NestConsole
|
||||
);
|
||||
var existingPartsMessage = options.KeepParts
|
||||
? $"Keeping {existingCount} existing parts"
|
||||
: options.AutoNest
|
||||
? $"Will replace {existingCount} existing parts only after acceptance"
|
||||
: $"Cleared {existingCount} existing parts";
|
||||
Console.WriteLine(
|
||||
$"""
|
||||
@@ -408,15 +448,17 @@ static class NestConsole
|
||||
);
|
||||
}
|
||||
|
||||
static (bool success, long elapsedMs) Fill(
|
||||
static (bool success, long elapsedMs, bool accepted) Fill(
|
||||
Nest nest,
|
||||
Plate plate,
|
||||
Drawing drawing,
|
||||
Options options
|
||||
Options options,
|
||||
CancellationToken token
|
||||
)
|
||||
{
|
||||
var sw = Stopwatch.StartNew();
|
||||
bool success;
|
||||
var accepted = true;
|
||||
|
||||
if (options.AutoNest)
|
||||
{
|
||||
@@ -437,7 +479,9 @@ static class NestConsole
|
||||
$"AutoNest: {nestItems.Count} drawing(s), {nestItems.Sum(i => i.Quantity)} total parts"
|
||||
);
|
||||
|
||||
success = AutoNestJob(plate, nestItems, options.Engine);
|
||||
var outcome = AutoNestJob(plate, nestItems, options.Engine, options.AllowInvalid, token);
|
||||
accepted = outcome.accepted;
|
||||
success = outcome.committed > 0;
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -451,8 +495,9 @@ static class NestConsole
|
||||
item,
|
||||
plate.WorkArea(),
|
||||
null,
|
||||
CancellationToken.None
|
||||
token
|
||||
);
|
||||
token.ThrowIfCancellationRequested();
|
||||
|
||||
if (parts.Count > 0)
|
||||
plate.Parts.AddRange(parts);
|
||||
@@ -460,52 +505,45 @@ static class NestConsole
|
||||
}
|
||||
|
||||
sw.Stop();
|
||||
return (success, sw.ElapsedMilliseconds);
|
||||
return (success, sw.ElapsedMilliseconds, accepted);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Solves the drawings as one whole job against this single plate using the named jobs
|
||||
/// engine, then commits the returned placements onto the plate. Placements are mapped back
|
||||
/// onto the caller's original drawings (same pose semantics as NestResultMaterializer), so
|
||||
/// the saved nest keeps its existing drawing identities.
|
||||
/// </summary>
|
||||
static bool AutoNestJob(Plate plate, List<NestItem> nestItems, string engineName)
|
||||
static (bool accepted, int committed) AutoNestJob(
|
||||
Plate plate, List<NestItem> nestItems, string engineName, bool allowInvalid, CancellationToken token)
|
||||
{
|
||||
var engine = NestingEngineRegistry.Create(engineName);
|
||||
var result = NestPipeline.Run(new NestPipelineRequest(
|
||||
engineName, nestItems, NestStockBuilder.SinglePlate(plate)), token: token);
|
||||
foreach (var violation in result.Violations)
|
||||
Console.Error.WriteLine($"Violation: {violation}");
|
||||
|
||||
var parts = new List<NestJobPart>(nestItems.Count);
|
||||
var drawingsByPartId = new Dictionary<string, Drawing>(StringComparer.Ordinal);
|
||||
for (var i = 0; i < nestItems.Count; i++)
|
||||
if (!result.CanKeep || result.Plates.Count > 1 || (!result.IsValid && !allowInvalid))
|
||||
{
|
||||
var partId = $"part-{i}";
|
||||
parts.Add(DrawingJobMapper.FromItem(partId, nestItems[i]));
|
||||
drawingsByPartId[partId] = nestItems[i].Drawing;
|
||||
Console.Error.WriteLine(result.Plates.Count > 1
|
||||
? "Error: multiple result sheets cannot be merged onto one target, even with --allow-invalid. Nothing saved."
|
||||
: !result.CanKeep
|
||||
? "Error: result cannot be represented faithfully, even with --allow-invalid. Nothing saved."
|
||||
: "Error: invalid result discarded. Use --allow-invalid to explicitly keep its violations. Nothing saved.");
|
||||
return (false, 0);
|
||||
}
|
||||
|
||||
// One physical sheet: this plate, this solve — the runner owns stock accounting.
|
||||
var stock = DrawingJobMapper.FromPlate("plate-0", plate, 1);
|
||||
var job = new NestJob(parts, [stock]);
|
||||
|
||||
var result = engine.Solve(job, null, CancellationToken.None);
|
||||
|
||||
var committed = 0;
|
||||
foreach (var plateResult in result.Plates)
|
||||
token.ThrowIfCancellationRequested();
|
||||
var proposed = result.Plates.SingleOrDefault();
|
||||
var committed = proposed?.Parts.Count ?? 0;
|
||||
if (committed == 0)
|
||||
{
|
||||
foreach (var pose in plateResult.Placements)
|
||||
{
|
||||
if (!drawingsByPartId.TryGetValue(pose.PartId, out var drawing))
|
||||
continue;
|
||||
var part = new Part(drawing);
|
||||
part.Rotate(pose.Rotation);
|
||||
part.Location = new Vector(pose.X, pose.Y);
|
||||
part.UpdateBounds();
|
||||
plate.Parts.Add(part);
|
||||
committed++;
|
||||
}
|
||||
Console.Error.WriteLine("Error: no placements returned. Existing layout and output left unchanged.");
|
||||
return (false, 0);
|
||||
}
|
||||
|
||||
plate.Parts.Clear();
|
||||
plate.Size = proposed.Stock.Size;
|
||||
plate.PartSpacing = proposed.Stock.PartSpacing;
|
||||
plate.EdgeSpacing = proposed.Stock.EdgeSpacing;
|
||||
plate.Quadrant = proposed.Stock.Quadrant;
|
||||
plate.Quantity = 1;
|
||||
plate.Parts.AddRange(proposed.Parts);
|
||||
Console.WriteLine($"Engine: {engineName} — committed {committed} placements");
|
||||
return committed > 0;
|
||||
return (true, committed);
|
||||
}
|
||||
|
||||
static string ResolveFillStrategy(string engineName)
|
||||
@@ -724,7 +762,8 @@ static class NestConsole
|
||||
--size <WxL> Override plate size (e.g. 60x120); required for DXF-only mode
|
||||
--output <path> Output nest file path (default: <input>-result.nest)
|
||||
--template <path> Nest template for plate defaults (thickness, quadrant, material, spacing)
|
||||
--autonest Whole-job nesting via the jobs engine (--engine) instead of single-plate fill
|
||||
--autonest Validated whole-job nesting onto one sheet; replaces only after acceptance
|
||||
--allow-invalid Explicitly keep representable invalid autonest layouts (default: reject, exit 2)
|
||||
--engine <name> With --autonest: jobs engine (default: Default; also StockLadder, Strip, ...).
|
||||
Without --autonest: fill strategy (Default, Strip, Vertical Remnant, Horizontal Remnant)
|
||||
--keep-parts Don't clear existing parts before filling
|
||||
@@ -753,6 +792,7 @@ static class NestConsole
|
||||
public bool NoSave;
|
||||
public bool KeepParts;
|
||||
public bool AutoNest;
|
||||
public bool AllowInvalid;
|
||||
public string Engine = "Default";
|
||||
public string TemplateFile;
|
||||
public string PostName;
|
||||
|
||||
@@ -0,0 +1,351 @@
|
||||
using System.Reflection;
|
||||
using OpenNest.Api;
|
||||
using OpenNest.CNC;
|
||||
using OpenNest.Engine.Jobs;
|
||||
using OpenNest.Geometry;
|
||||
using OpenNest.IO;
|
||||
using OpenNest.Mcp;
|
||||
using OpenNest.Mcp.Tools;
|
||||
|
||||
namespace OpenNest.FrontEnd.Tests;
|
||||
|
||||
[CollectionDefinition("FrontEndRegistry", DisableParallelization = true)]
|
||||
public class FrontEndRegistryCollection { }
|
||||
|
||||
[Collection("FrontEndRegistry")]
|
||||
public class NestingPipelineTests : IDisposable
|
||||
{
|
||||
private readonly string directory = Path.Combine(Path.GetTempPath(), "opennest-frontends-" + Guid.NewGuid());
|
||||
|
||||
public NestingPipelineTests() => Directory.CreateDirectory(directory);
|
||||
public void Dispose() => Directory.Delete(directory, true);
|
||||
|
||||
private static Drawing Square()
|
||||
{
|
||||
var shape = new Shape();
|
||||
shape.Entities.Add(new Line(new Vector(0, 0), new Vector(2, 0)));
|
||||
shape.Entities.Add(new Line(new Vector(2, 0), new Vector(2, 2)));
|
||||
shape.Entities.Add(new Line(new Vector(2, 2), new Vector(0, 2)));
|
||||
shape.Entities.Add(new Line(new Vector(0, 2), new Vector(0, 0)));
|
||||
return new Drawing("square", OpenNest.Converters.ConvertGeometry.ToProgram(shape));
|
||||
}
|
||||
|
||||
private static NestSession Session(bool occupied = false)
|
||||
{
|
||||
var session = new NestSession { Nest = new Nest("fixture") };
|
||||
session.Nest.Drawings.Add(Square());
|
||||
var plate = session.Nest.CreatePlate();
|
||||
plate.Size = new Size(20, 30);
|
||||
plate.PartSpacing = 0.2;
|
||||
plate.EdgeSpacing = new Spacing(0.5, 0.5, 0.5, 0.5);
|
||||
plate.Quantity = 7;
|
||||
if (occupied)
|
||||
plate.Parts.Add(new Part(session.Nest.Drawings.Single()));
|
||||
return session;
|
||||
}
|
||||
|
||||
private static string Engine(Func<NestJob, CancellationToken, NestJobResult> solve)
|
||||
{
|
||||
var name = "FrontEnd-" + Guid.NewGuid();
|
||||
NestingEngineRegistry.Register(name, "test", () => new Stub(solve));
|
||||
return name;
|
||||
}
|
||||
|
||||
private sealed class Stub(Func<NestJob, CancellationToken, NestJobResult> solve) : INestingEngine
|
||||
{
|
||||
public NestJobResult Solve(NestJob job, IProgress<NestJobProgress>? progress = null,
|
||||
CancellationToken token = default) => solve(job, token);
|
||||
}
|
||||
|
||||
private static NestJobResult Result(NestJob job, string mode)
|
||||
{
|
||||
var poses = mode switch
|
||||
{
|
||||
"overlap" => new[] { new NestJobPlacement(job.Parts[0].Id, 0, 1, 1, 0), new NestJobPlacement(job.Parts[0].Id, 1, 1, 1, 0) },
|
||||
"unknown" => new[] { new NestJobPlacement("ghost", 0, 1, 1, 0) },
|
||||
"nan" => new[] { new NestJobPlacement(job.Parts[0].Id, 0, double.NaN, 1, 0) },
|
||||
_ => new[] { new NestJobPlacement(job.Parts[0].Id, 0, 1, 1, 0) },
|
||||
};
|
||||
var sheets = mode == "empty" ? Array.Empty<NestJobPlateResult>()
|
||||
: mode == "multi" ? new[] { new NestJobPlateResult(0, job.Plates[0], poses), new NestJobPlateResult(1, job.Plates[0], poses) }
|
||||
: new[] { new NestJobPlateResult(0, job.Plates[0], poses) };
|
||||
// Deliberately lying metadata: front ends must count actual poses.
|
||||
return new NestJobResult(NestJobStatus.Complete, NestJobStopReason.Completed, sheets,
|
||||
job.Parts.Select(p => new PartFulfillment(p.Id, p.Quantity, 999, 0)),
|
||||
new[] { new StockUsage(job.Plates[0].Id, 999, 0) });
|
||||
}
|
||||
|
||||
private static int RunConsole(params string[] args)
|
||||
{
|
||||
var method = Assembly.Load("OpenNest.Console").GetType("NestConsole")!
|
||||
.GetMethod("Run", BindingFlags.Static | BindingFlags.Public)!;
|
||||
return (int)method.Invoke(null, new object[] { args })!;
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("overlap", false, 2)]
|
||||
[InlineData("overlap", true, 0)]
|
||||
[InlineData("multi", true, 2)]
|
||||
[InlineData("unknown", true, 2)]
|
||||
[InlineData("nan", true, 2)]
|
||||
public void Console_RefusesInvalidWithoutOverwritingUnlessFaithfullyKeepable(string mode, bool allow, int expected)
|
||||
{
|
||||
var input = Path.Combine(directory, "input.nest");
|
||||
var output = Path.Combine(directory, "output.nest");
|
||||
Assert.True(new NestWriter(Session(true).Nest).Write(input));
|
||||
var original = File.ReadAllBytes(input);
|
||||
File.WriteAllText(output, "must survive rejection");
|
||||
var engine = Engine((job, _) => Result(job, mode));
|
||||
var args = new List<string> { input, "--autonest", "--engine", engine, "--quantity", "2", "--output", output };
|
||||
if (allow) args.Add("--allow-invalid");
|
||||
Assert.Equal(expected, RunConsole(args.ToArray()));
|
||||
Assert.Equal(original, File.ReadAllBytes(input));
|
||||
if (expected == 2)
|
||||
Assert.Equal("must survive rejection", File.ReadAllText(output));
|
||||
else
|
||||
{
|
||||
var nest = new NestReader(output).Read();
|
||||
Assert.Equal(2, nest.Plates[0].Parts.Count);
|
||||
Assert.Equal(1, nest.Plates[0].Quantity);
|
||||
Assert.Equal(2, nest.Drawings.Single().Quantity.Nested);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Console_EmptyResultDoesNotOverwriteOutputOrOriginal()
|
||||
{
|
||||
var input = Path.Combine(directory, "input.nest");
|
||||
var output = Path.Combine(directory, "output.nest");
|
||||
Assert.True(new NestWriter(Session(true).Nest).Write(input));
|
||||
var original = File.ReadAllBytes(input);
|
||||
File.WriteAllText(output, "keep existing output");
|
||||
var engine = Engine((job, _) => Result(job, "empty"));
|
||||
Assert.Equal(2, RunConsole(input, "--autonest", "--engine", engine, "--output", output));
|
||||
Assert.Equal(original, File.ReadAllBytes(input));
|
||||
Assert.Equal("keep existing output", File.ReadAllText(output));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Console_CancelledSolveDoesNotSave()
|
||||
{
|
||||
var input = Path.Combine(directory, "input.nest");
|
||||
var output = Path.Combine(directory, "output.nest");
|
||||
Assert.True(new NestWriter(Session(true).Nest).Write(input));
|
||||
var engine = Engine((_, _) => throw new OperationCanceledException());
|
||||
Assert.Equal(2, RunConsole(input, "--autonest", "--engine", engine, "--output", output));
|
||||
Assert.False(File.Exists(output));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Console_KeepPartsOnOccupiedSheetRejectsBeforeSolve()
|
||||
{
|
||||
var input = Path.Combine(directory, "input.nest");
|
||||
Assert.True(new NestWriter(Session(true).Nest).Write(input));
|
||||
var original = File.ReadAllBytes(input);
|
||||
var engine = Engine((_, _) => throw new Xunit.Sdk.XunitException("must not solve occupied stock"));
|
||||
Assert.Equal(2, RunConsole(input, "--autonest", "--keep-parts", "--engine", engine));
|
||||
Assert.Equal(original, File.ReadAllBytes(input));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Console_ValidCommitPreservesSettingsAndUsesOnePhysicalSheet()
|
||||
{
|
||||
var input = Path.Combine(directory, "input.nest");
|
||||
var output = Path.Combine(directory, "output.nest");
|
||||
Assert.True(new NestWriter(Session(true).Nest).Write(input));
|
||||
var engine = Engine((job, _) =>
|
||||
{
|
||||
Assert.Equal(1, Assert.Single(job.Plates).Quantity);
|
||||
return Result(job, "valid");
|
||||
});
|
||||
Assert.Equal(0, RunConsole(input, "--autonest", "--engine", engine, "--output", output));
|
||||
var plate = Assert.Single(new NestReader(output).Read().Plates);
|
||||
Assert.Single(plate.Parts);
|
||||
Assert.Equal(1, plate.Quantity);
|
||||
Assert.Equal(0.2, plate.PartSpacing);
|
||||
Assert.Equal(new Spacing(0.5, 0.5, 0.5, 0.5), plate.EdgeSpacing);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("overlap", false, false)]
|
||||
[InlineData("overlap", true, true)]
|
||||
[InlineData("multi", true, false)]
|
||||
[InlineData("unknown", true, false)]
|
||||
[InlineData("nan", true, false)]
|
||||
[InlineData("valid", false, true)]
|
||||
public void Mcp_OnlyCommitsFaithfulSingleSheet(string mode, bool allow, bool committed)
|
||||
{
|
||||
var session = Session();
|
||||
var plate = session.GetPlate(0);
|
||||
var engine = Engine((job, _) =>
|
||||
{
|
||||
Assert.Equal(1, Assert.Single(job.Plates).Quantity);
|
||||
return Result(job, mode);
|
||||
});
|
||||
var tool = new NestingTools(session);
|
||||
var result = CallMcp(tool, engine, allow);
|
||||
if (committed)
|
||||
{
|
||||
Assert.NotEmpty(plate.Parts);
|
||||
Assert.Equal(1, plate.Quantity);
|
||||
Assert.All(plate.Parts, p => Assert.Same(session.Nest.Drawings.Single(), p.BaseDrawing));
|
||||
Assert.Equal(plate.Parts.Count, session.Nest.Drawings.Single().Quantity.Nested);
|
||||
}
|
||||
else
|
||||
{
|
||||
Assert.Contains("Error", result);
|
||||
Assert.Empty(plate.Parts);
|
||||
Assert.Equal(7, plate.Quantity);
|
||||
}
|
||||
if (mode != "valid") Assert.Contains("Violation", result);
|
||||
}
|
||||
|
||||
private static string CallMcp(NestingTools tool, string engine, bool allow = false, CancellationToken token = default)
|
||||
{
|
||||
// Reflection lets RED exercise the old implementation before optional parameters exist.
|
||||
var method = typeof(NestingTools).GetMethod(nameof(NestingTools.AutoNestPlate))!;
|
||||
var args = method.GetParameters().Select(p => p.Name switch
|
||||
{
|
||||
"plateIndex" => (object)0,
|
||||
"drawingNames" => "square",
|
||||
"quantities" => "2",
|
||||
"engine" => engine,
|
||||
"allow_invalid" => allow,
|
||||
"cancellationToken" => token,
|
||||
_ => p.DefaultValue,
|
||||
}).ToArray();
|
||||
try { return (string)method.Invoke(tool, args)!; }
|
||||
catch (TargetInvocationException ex) when (ex.InnerException is OperationCanceledException)
|
||||
{ throw ex.InnerException; }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Mcp_OccupiedSheetRejectsBeforeSolve()
|
||||
{
|
||||
var session = Session(true);
|
||||
var original = session.GetPlate(0).Parts[0];
|
||||
var engine = Engine((_, _) => throw new Xunit.Sdk.XunitException("must not solve occupied stock"));
|
||||
Assert.Contains("occupied", CallMcp(new NestingTools(session), engine));
|
||||
Assert.Same(original, Assert.Single(session.GetPlate(0).Parts));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Mcp_CancellationDoesNotCommit()
|
||||
{
|
||||
var session = Session();
|
||||
using var cts = new CancellationTokenSource();
|
||||
var engine = Engine((job, _) => { cts.Cancel(); return Result(job, "valid"); });
|
||||
Assert.ThrowsAny<OperationCanceledException>(() => CallMcp(new NestingTools(session), engine, token: cts.Token));
|
||||
Assert.Empty(session.GetPlate(0).Parts);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UnknownEngineDoesNotWriteOrCommit()
|
||||
{
|
||||
var session = Session();
|
||||
Assert.Contains("unknown", CallMcp(new NestingTools(session), "missing-engine"), StringComparison.OrdinalIgnoreCase);
|
||||
Assert.Empty(session.GetPlate(0).Parts);
|
||||
var input = Path.Combine(directory, "input.nest");
|
||||
var output = Path.Combine(directory, "output.nest");
|
||||
Assert.True(new NestWriter(session.Nest).Write(input));
|
||||
Assert.Equal(1, RunConsole(input, "--autonest", "--engine", "missing-engine", "--output", output));
|
||||
Assert.False(File.Exists(output));
|
||||
}
|
||||
|
||||
private NestRequest Request(string engine)
|
||||
{
|
||||
var path = Path.Combine(directory, "square.dxf");
|
||||
Dxf.ExportProgram(Square().Program, path);
|
||||
var request = new NestRequest
|
||||
{
|
||||
Parts = [new NestRequestPart { Id = "external-id", DxfPath = path, Quantity = 2, AllowRotation = false }],
|
||||
Plates = [new NestRequestPlate { Id = "stock", Size = new Size(20, 30), Quantity = 1 }],
|
||||
};
|
||||
typeof(NestRequest).GetProperty("Engine")?.SetValue(request, engine);
|
||||
return request;
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("valid", 1, "Valid")]
|
||||
[InlineData("empty", 0, "Valid")]
|
||||
[InlineData("overlap", 2, "Invalid")]
|
||||
[InlineData("unknown", 0, "Unrepresentable")]
|
||||
[InlineData("nan", 0, "Unrepresentable")]
|
||||
public async Task Api_PluginSelectionDerivesCountsPreservesIdsAndReportsValidation(string mode, int placed, string validation)
|
||||
{
|
||||
var called = false;
|
||||
var engine = Engine((job, _) => { called = true; return Result(job, mode); });
|
||||
var response = await NestRunner.RunAsync(Request(engine));
|
||||
Assert.True(called);
|
||||
var fulfillment = Assert.Single(response.Fulfillment);
|
||||
Assert.Equal("external-id", fulfillment.PartId);
|
||||
Assert.Equal(placed, fulfillment.Placed);
|
||||
Assert.Equal(2 - placed, fulfillment.Unplaced);
|
||||
Assert.Equal(placed == 2 ? NestJobStatus.Complete : NestJobStatus.Incomplete, response.Status);
|
||||
Assert.Equal(validation, typeof(NestResponse).GetProperty("ValidationStatus")?.GetValue(response)?.ToString());
|
||||
Assert.Equal(placed > 0 ? 1 : 0, Assert.Single(response.StockUsage).Used);
|
||||
Assert.Equal(placed, response.Nest.Plates.Sum(p => p.Parts.Count));
|
||||
Assert.Equal("external-id", Assert.Single(response.Nest.Drawings).Name);
|
||||
var archive = Path.Combine(directory, "result.nestquote");
|
||||
await response.SaveAsync(archive);
|
||||
var loaded = await NestResponse.LoadAsync(archive);
|
||||
Assert.Equal(validation, typeof(NestResponse).GetProperty("ValidationStatus")?.GetValue(loaded)?.ToString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Mcp_SessionGateSerializesCallsBeforeCheckingOccupiedTarget()
|
||||
{
|
||||
var session = Session();
|
||||
var tool = new NestingTools(session);
|
||||
var engine = Engine((job, _) => Result(job, "valid"));
|
||||
using var gate = new SessionToolGate();
|
||||
var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
var entered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
var first = gate.RunAsync(async token =>
|
||||
{
|
||||
entered.SetResult();
|
||||
await release.Task;
|
||||
return CallMcp(tool, engine, token: token);
|
||||
}, CancellationToken.None).AsTask();
|
||||
await entered.Task.WaitAsync(TimeSpan.FromSeconds(5));
|
||||
var second = gate.RunAsync(token => new ValueTask<string>(CallMcp(tool, engine, token: token)),
|
||||
CancellationToken.None).AsTask();
|
||||
try
|
||||
{
|
||||
Assert.False(second.IsCompleted);
|
||||
}
|
||||
finally
|
||||
{
|
||||
release.TrySetResult();
|
||||
}
|
||||
Assert.Contains("success", await first.WaitAsync(TimeSpan.FromSeconds(5)));
|
||||
Assert.Contains("occupied", await second.WaitAsync(TimeSpan.FromSeconds(5)));
|
||||
Assert.Single(session.GetPlate(0).Parts);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Console_CancelledOrdinaryFillDoesNotOverwriteOutput()
|
||||
{
|
||||
var input = Path.Combine(directory, "input.nest");
|
||||
var output = Path.Combine(directory, "output.nest");
|
||||
Assert.True(new NestWriter(Session(true).Nest).Write(input));
|
||||
File.WriteAllText(output, "existing output");
|
||||
using var cts = new CancellationTokenSource();
|
||||
cts.Cancel();
|
||||
var run = Assembly.Load("OpenNest.Console").GetType("NestConsole")!
|
||||
.GetMethod("RunCore", BindingFlags.Static | BindingFlags.NonPublic)!;
|
||||
var error = Assert.Throws<TargetInvocationException>(() => run.Invoke(null,
|
||||
new object[] { new[] { input, "--quantity", "1", "--output", output }, cts.Token }));
|
||||
Assert.IsAssignableFrom<OperationCanceledException>(error.InnerException);
|
||||
Assert.Equal("existing output", File.ReadAllText(output));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Api_UnknownEngineAndCancellationPropagate()
|
||||
{
|
||||
await Assert.ThrowsAsync<NotSupportedException>(() => NestRunner.RunAsync(Request("missing-engine")));
|
||||
using var cts = new CancellationTokenSource();
|
||||
var engine = Engine((job, _) => { cts.Cancel(); return Result(job, "valid"); });
|
||||
await Assert.ThrowsAnyAsync<OperationCanceledException>(() => NestRunner.RunAsync(Request(engine), token: cts.Token));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0-windows</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.8.0" />
|
||||
<PackageReference Include="xunit" Version="2.5.3" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.5.3" />
|
||||
<Using Include="Xunit" />
|
||||
<ProjectReference Include="../OpenNest.Console/OpenNest.Console.csproj" />
|
||||
<ProjectReference Include="../OpenNest.Mcp/OpenNest.Mcp.csproj" />
|
||||
<ProjectReference Include="../OpenNest.Api/OpenNest.Api.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -1,16 +1,23 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using OpenNest.Engine.Jobs;
|
||||
using OpenNest.Mcp;
|
||||
|
||||
NestingEngineRegistry.LoadPlugins(Path.Combine(AppContext.BaseDirectory, "Engines"));
|
||||
var builder = Host.CreateApplicationBuilder(args);
|
||||
|
||||
// stdout carries the JSON-RPC stream; console logs must go to stderr or they corrupt it.
|
||||
builder.Logging.AddConsole(o => o.LogToStandardErrorThreshold = LogLevel.Trace);
|
||||
|
||||
using var toolGate = new SessionToolGate();
|
||||
builder.Services.AddSingleton<NestSession>();
|
||||
builder
|
||||
.Services.AddMcpServer()
|
||||
.WithRequestFilters(filters => filters.AddCallToolFilter(
|
||||
next => (context, token) => toolGate.RunAsync(ct => next(context, ct), token)))
|
||||
.WithStdioServerTransport()
|
||||
.WithToolsFromAssembly(typeof(Program).Assembly);
|
||||
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace OpenNest.Mcp;
|
||||
|
||||
/// <summary>Serializes tool operations sharing a mutable nesting session.</summary>
|
||||
public sealed class SessionToolGate : IDisposable
|
||||
{
|
||||
private readonly SemaphoreSlim gate = new(1, 1);
|
||||
|
||||
public async ValueTask<T> RunAsync<T>(Func<CancellationToken, ValueTask<T>> action, CancellationToken token)
|
||||
{
|
||||
await gate.WaitAsync(token).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
return await action(token).ConfigureAwait(false);
|
||||
}
|
||||
finally
|
||||
{
|
||||
gate.Release();
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose() => gate.Dispose();
|
||||
}
|
||||
@@ -5,12 +5,11 @@ using System.Linq;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
using ModelContextProtocol.Server;
|
||||
using OpenNest.Engine;
|
||||
using OpenNest.Engine.Fill;
|
||||
using OpenNest.Engine.Jobs;
|
||||
using OpenNest.Engine.Jobs.Adapters;
|
||||
using OpenNest.Engine.Jobs.Placement;
|
||||
using OpenNest.Geometry;
|
||||
using OpenNest.Engine;
|
||||
|
||||
namespace OpenNest.Mcp.Tools
|
||||
{
|
||||
@@ -251,91 +250,75 @@ namespace OpenNest.Mcp.Tools
|
||||
}
|
||||
|
||||
[McpServerTool(Name = "autonest_plate")]
|
||||
[Description(
|
||||
"Mixed-part autonesting. Solves the drawings as one whole job against the plate using the named jobs engine and commits the resulting placements onto the plate."
|
||||
)]
|
||||
[Description("Validated whole-job nesting onto an empty single sheet. Invalid results are discarded unless allow_invalid is explicitly true; unrepresentable or multiple-sheet results are always rejected.")]
|
||||
public string AutoNestPlate(
|
||||
[Description("Index of the plate")] int plateIndex,
|
||||
[Description("Index of the empty plate")] int plateIndex,
|
||||
[Description("Comma-separated drawing names")] string drawingNames,
|
||||
[Description("Comma-separated quantities for each drawing")] string quantities,
|
||||
[Description("Jobs engine name (Default, Strip, Vertical Remnant, Horizontal Remnant, StockLadder)")]
|
||||
string engine = null
|
||||
[Description("Comma-separated positive quantities")] string quantities,
|
||||
[Description("Registered jobs engine name, including loaded plug-ins")] string engine = null,
|
||||
[Description("Explicitly keep representable layouts despite validation violations")] bool allow_invalid = false,
|
||||
CancellationToken cancellationToken = default
|
||||
)
|
||||
{
|
||||
var plate = _session.GetPlate(plateIndex);
|
||||
if (plate == null)
|
||||
return $"Error: plate {plateIndex} not found";
|
||||
|
||||
if (plate.Parts.Count > 0)
|
||||
return "Error: autonest cannot use an occupied plate. Use fill_area or fill_remnants for existing obstacles.";
|
||||
if (string.IsNullOrWhiteSpace(drawingNames))
|
||||
return "Error: drawingNames is required";
|
||||
|
||||
if (string.IsNullOrWhiteSpace(quantities))
|
||||
return "Error: quantities is required";
|
||||
|
||||
var engineName = string.IsNullOrWhiteSpace(engine)
|
||||
? _session.DefaultEngineName
|
||||
: engine.Trim();
|
||||
|
||||
var engineName = string.IsNullOrWhiteSpace(engine) ? _session.DefaultEngineName : engine.Trim();
|
||||
var parsed = ParseItems(drawingNames, quantities);
|
||||
if (parsed.error != null)
|
||||
return parsed.error;
|
||||
|
||||
if (parsed.items.Any(item => item.Quantity <= 0))
|
||||
return "Error: autonest quantities must be positive";
|
||||
|
||||
INestingEngine nestingEngine;
|
||||
NestPipelineResult result;
|
||||
try
|
||||
{
|
||||
nestingEngine = NestingEngineRegistry.Create(engineName);
|
||||
result = NestPipeline.Run(new NestPipelineRequest(
|
||||
engineName, parsed.items, NestStockBuilder.SinglePlate(plate)), token: cancellationToken);
|
||||
}
|
||||
catch (NotSupportedException)
|
||||
{
|
||||
return UnknownEngineMessage(engineName);
|
||||
}
|
||||
|
||||
var jobParts = new List<NestJobPart>(parsed.items.Count);
|
||||
var drawingsByPartId = new Dictionary<string, Drawing>(StringComparer.Ordinal);
|
||||
for (var i = 0; i < parsed.items.Count; i++)
|
||||
{
|
||||
var partId = $"part-{i}";
|
||||
jobParts.Add(DrawingJobMapper.FromItem(partId, parsed.items[i]));
|
||||
drawingsByPartId[partId] = parsed.items[i].Drawing;
|
||||
}
|
||||
|
||||
// One physical sheet: this plate, this solve — the runner owns stock accounting.
|
||||
var stock = DrawingJobMapper.FromPlate("plate-0", plate, 1);
|
||||
var job = new NestJob(jobParts, [stock]);
|
||||
|
||||
var result = nestingEngine.Solve(job, null, CancellationToken.None);
|
||||
|
||||
var totalPlaced = 0;
|
||||
foreach (var plateResult in result.Plates)
|
||||
{
|
||||
foreach (var pose in plateResult.Placements)
|
||||
{
|
||||
if (!drawingsByPartId.TryGetValue(pose.PartId, out var drawing))
|
||||
continue;
|
||||
var part = new Part(drawing);
|
||||
part.Rotate(pose.Rotation);
|
||||
part.Location = new Vector(pose.X, pose.Y);
|
||||
part.UpdateBounds();
|
||||
plate.Parts.Add(part);
|
||||
totalPlaced++;
|
||||
}
|
||||
}
|
||||
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine(
|
||||
$"AutoNest plate {plateIndex} ({engineName} engine): {(totalPlaced > 0 ? "success" : "no parts placed")}"
|
||||
);
|
||||
foreach (var violation in result.Violations)
|
||||
sb.AppendLine($"Violation: {violation}");
|
||||
if (!result.CanKeep || result.Plates.Count > 1 || (!result.IsValid && !allow_invalid))
|
||||
{
|
||||
sb.AppendLine(result.Plates.Count > 1
|
||||
? "Error: multiple result sheets cannot be merged onto one target, even with allow_invalid. Nothing committed."
|
||||
: !result.CanKeep
|
||||
? "Error: result cannot be represented faithfully, even with allow_invalid. Nothing committed."
|
||||
: "Error: invalid result discarded. Set allow_invalid to explicitly keep its violations. Nothing committed.");
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var proposed = result.Plates.SingleOrDefault();
|
||||
var totalPlaced = proposed?.Parts.Count ?? 0;
|
||||
if (totalPlaced > 0)
|
||||
{
|
||||
plate.Size = proposed.Stock.Size;
|
||||
plate.PartSpacing = proposed.Stock.PartSpacing;
|
||||
plate.EdgeSpacing = proposed.Stock.EdgeSpacing;
|
||||
plate.Quadrant = proposed.Stock.Quadrant;
|
||||
plate.Quantity = 1;
|
||||
plate.Parts.AddRange(proposed.Parts);
|
||||
}
|
||||
sb.AppendLine($"AutoNest plate {plateIndex} ({engineName} engine): {(totalPlaced > 0 ? "success" : "no parts placed")}");
|
||||
sb.AppendLine($" Parts placed: {totalPlaced}");
|
||||
sb.AppendLine($" Total parts: {plate.Parts.Count}");
|
||||
sb.AppendLine($" Utilization: {plate.Utilization():P1}");
|
||||
|
||||
var groups = plate.Parts.GroupBy(p => p.BaseDrawing.Name);
|
||||
foreach (var group in groups)
|
||||
foreach (var group in plate.Parts.GroupBy(p => p.BaseDrawing.Name))
|
||||
sb.AppendLine($" {group.Key}: {group.Count()}");
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
|
||||
@@ -44,6 +44,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OpenNest.IO.Tests", "OpenNe
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OpenNest.WinForms.Tests", "OpenNest.WinForms.Tests\OpenNest.WinForms.Tests.csproj", "{6A96D511-A633-4892-A6CE-73D0A211454E}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OpenNest.FrontEnd.Tests", "OpenNest.FrontEnd.Tests\OpenNest.FrontEnd.Tests.csproj", "{0CD494FD-F37F-420D-B2C6-DC1E0D50BCEB}"
|
||||
EndProject
|
||||
Global
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
Debug|Any CPU = Debug|Any CPU
|
||||
@@ -270,6 +272,18 @@ Global
|
||||
{6A96D511-A633-4892-A6CE-73D0A211454E}.Release|x64.Build.0 = Release|Any CPU
|
||||
{6A96D511-A633-4892-A6CE-73D0A211454E}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{6A96D511-A633-4892-A6CE-73D0A211454E}.Release|x86.Build.0 = Release|Any CPU
|
||||
{0CD494FD-F37F-420D-B2C6-DC1E0D50BCEB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{0CD494FD-F37F-420D-B2C6-DC1E0D50BCEB}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{0CD494FD-F37F-420D-B2C6-DC1E0D50BCEB}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{0CD494FD-F37F-420D-B2C6-DC1E0D50BCEB}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{0CD494FD-F37F-420D-B2C6-DC1E0D50BCEB}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{0CD494FD-F37F-420D-B2C6-DC1E0D50BCEB}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{0CD494FD-F37F-420D-B2C6-DC1E0D50BCEB}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{0CD494FD-F37F-420D-B2C6-DC1E0D50BCEB}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{0CD494FD-F37F-420D-B2C6-DC1E0D50BCEB}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{0CD494FD-F37F-420D-B2C6-DC1E0D50BCEB}.Release|x64.Build.0 = Release|Any CPU
|
||||
{0CD494FD-F37F-420D-B2C6-DC1E0D50BCEB}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{0CD494FD-F37F-420D-B2C6-DC1E0D50BCEB}.Release|x86.Build.0 = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(SolutionProperties) = preSolution
|
||||
HideSolutionNode = FALSE
|
||||
|
||||
@@ -30,6 +30,7 @@ cd OpenNest
|
||||
dotnet build OpenNest.sln # full solution (Windows)
|
||||
dotnet test OpenNest.Engine.Tests/OpenNest.Engine.Tests.csproj # cross-platform engine tests
|
||||
dotnet test OpenNest.Tests/OpenNest.Tests.csproj # core/engine/IO/API tests
|
||||
dotnet test OpenNest.FrontEnd.Tests/OpenNest.FrontEnd.Tests.csproj # console/MCP/API integration
|
||||
dotnet run --project OpenNest/OpenNest.csproj # desktop app (Windows)
|
||||
```
|
||||
|
||||
@@ -55,7 +56,7 @@ dotnet run --project OpenNest.Console -- part1.dxf part2.dxf --size 60x120 --aut
|
||||
dotnet run --project OpenNest.Console -- project.zip # re-fill a nest file
|
||||
```
|
||||
|
||||
Key options: `--size WxL`, `--autonest` (whole-job nesting), `--engine <name>` (jobs engine or fill strategy), `--quantity`, `--spacing`, `--template <nest>`, `--output <path>`, `--check-overlaps`, `--post <name>`, `--no-save`. Run without arguments for the full list.
|
||||
Key options: `--size WxL`, `--autonest` (validated single-sheet whole-job nesting), `--allow-invalid` (explicit warning override), `--engine <name>` (jobs engine or fill strategy), `--quantity`, `--spacing`, `--template <nest>`, `--output <path>`, `--check-overlaps`, `--post <name>`, `--no-save`. Run without arguments for the full list.
|
||||
|
||||
## Benchmarking Engines
|
||||
|
||||
@@ -86,7 +87,7 @@ Layouts are validated (bounds, spacing, quantity, rotation, stock match); invali
|
||||
|
||||
## Nesting Engines
|
||||
|
||||
Jobs-only API: engines implement `INestingEngine.Solve(NestJob)`; only `NestJobRunner` commits demand and stock, and every candidate passes the placement validator (bounds, spacing, rotation policy, stock match) before it consumes anything.
|
||||
Engines implement `INestingEngine.Solve(NestJob)`. Desktop Auto Nest, console autonest, MCP autonest and the API use one independent validation pipeline for every engine, including plug-ins. Invalid layouts require an explicit decision; malformed output cannot be kept. See [automatic nesting and validation](docs/automatic-nesting.md) for caller behavior and API validation status.
|
||||
|
||||
| Engine | Description |
|
||||
|--------|-------------|
|
||||
|
||||
@@ -35,4 +35,24 @@ replace the saved missing-engine preference.
|
||||
|
||||
Callers must hold drawings and target state stable from snapshot through attachment. Do not append whole-job placements to an occupied target or flatten multiple returned sheets onto one plate: that would commit a layout different from the one checked. Do not trust arbitrary engine fulfillment metadata as a substitute for counting returned placements.
|
||||
|
||||
Interactive fills are outside this contract and still use `PlateFillService`. The benchmark invokes the same independent validator. Console/MCP/API migration is a separate adoption step; the existence of the pipeline does not imply every front end already calls it.
|
||||
Interactive fills are outside this contract and still use `PlateFillService`. The benchmark invokes the same independent validator.
|
||||
|
||||
## Console and MCP
|
||||
|
||||
Console `--autonest` uses the selected jobs engine against one physical sheet. The selected plate's old parts are replaced only after acceptance; other plates are unchanged. Default demand is still one of each drawing unless `--quantity` is supplied. A partially fulfilled, valid result may be saved; a successful placement is not a claim that all demand was met.
|
||||
|
||||
Invalid output is printed and rejected with exit code 2 without saving or posting. `--allow-invalid` explicitly accepts representable layout violations. Malformed output, multiple returned sheets, and zero placements are never saved by this path. Unknown engines exit 1. `--autonest --keep-parts` rejects an occupied target: use the plain interactive fill path for existing obstacles instead.
|
||||
|
||||
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. Existing fill tools remain separate. Console and MCP load jobs plug-ins from `Engines/` beside their executable.
|
||||
|
||||
## .NET API and saved responses
|
||||
|
||||
Set `NestRequest.Engine` to a registered engine name; null retains `PlacementStrategy` / legacy `Strategy` behavior. Library hosts own plug-in discovery via `NestingEngineRegistry.LoadPlugins` before calling the API. Explicit request requirement IDs are preserved in response fulfillment even when multiple requirements use the same source DXF.
|
||||
|
||||
The API returns a detached proposal. `ValidationStatus` is `Valid`, `Invalid`, or `Unrepresentable`, and `Violations` lists the problems. Representable invalid proposals retain their parts for caller review; unrepresentable proposals contain no sheets. Consumers must inspect validation before applying, quoting or posting the proposal. `Status` describes fulfillment, not acceptance: counts, stock usage and completeness are derived from returned placements rather than trusting plug-in summary metadata.
|
||||
|
||||
Response archive schema 3 persists validation status and violations. Older archives with no validation metadata load with null status; null must not be interpreted as a successful check. Saving a proposal archive records it and does not authorize CNC posting.
|
||||
|
||||
## Verification
|
||||
|
||||
`dotnet test OpenNest.FrontEnd.Tests/OpenNest.FrontEnd.Tests.csproj` exercises Console, MCP and API rejection/override, cancellation, physical-sheet settings, response IDs and archive round-trips. Its current target matches MCP's `net8.0-windows` marker but does not use WindowsDesktop and executes on Linux as well as Windows. It is included in the solution and Windows test workflow. Desktop interaction tests remain in Windows-only `OpenNest.WinForms.Tests`.
|
||||
|
||||
Reference in New Issue
Block a user