mirror of
https://github.com/ajisaacs/OpenNest.git
synced 2026-11-03 17:01:38 -05:00
The repo mixed variants such as center/centre and color/colour, so a name could be spelled either way and searches missed half the hits. Standardize on the US spelling, matching .NET's own APIs (Color, Center). Wire values owned by other systems keep their spelling: the material ledger's "Cancelled" hold status and GitHub's "cancelled" job status. No behavior change beyond the spelling of a few status messages. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
675 lines
35 KiB
C#
675 lines
35 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.ComponentModel;
|
|
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.Placement;
|
|
using OpenNest.Geometry;
|
|
using OpenNest.IO;
|
|
|
|
namespace OpenNest.Mcp.Tools
|
|
{
|
|
[McpServerToolType]
|
|
public class NestingTools
|
|
{
|
|
private readonly NestSession _session;
|
|
|
|
private const string FillStrategies =
|
|
"Placement strategy: Fill (default; lattice fill of repeated copies), Strip, "
|
|
+ "Vertical Remnant (keeps a right-side drop), Horizontal Remnant (keeps a top-side drop)";
|
|
|
|
/// <summary>Built-in engines with what each suits; a test keeps this in step with the registry.</summary>
|
|
private const string JobEngines =
|
|
"Whole-job engine. Default (used when omitted): suits any job; runs Irregular, Rectangles and Circles "
|
|
+ "and keeps the cheapest valid layout. "
|
|
+ "Irregular: irregular profiles; no-fit-polygon packing into notches and gaps, best-fit pairs. "
|
|
+ "Rectangles: plain and near-rectangular parts; packs each part as its bounding box, fastest. "
|
|
+ "Circles: round parts (blanks, washers, flanges); packs each part as its enclosing circle; places nothing when any part is not round. "
|
|
+ "FlatBar: flat bar, parts about as wide as the bar; each stock cut as one row of parts along its length, shaped ends nested together; not run by Default. "
|
|
+ "Fill: many copies of few drawings; lattice fill with pairs and rectangle best-fit. "
|
|
+ "Strip: Fill variant laying mixed drawings in strips. "
|
|
+ "Vertical Remnant, Horizontal Remnant: Fill variants keeping a right-side or top-side drop. "
|
|
+ "Plug-ins loaded from Engines/ use their class name.";
|
|
|
|
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,
|
|
[Description(FillStrategies)]
|
|
string engine = null
|
|
)
|
|
{
|
|
var plate = _session.GetPlate(plateIndex);
|
|
if (plate == null)
|
|
return $"Error: plate {plateIndex} not found";
|
|
if (_session.IsConsumedStockPlate(plate))
|
|
return "Error: this empty entry was consumed by a repeated layout; select or create an unused stock plate. Nothing committed.";
|
|
|
|
var drawing = _session.GetDrawing(drawingName);
|
|
if (drawing == null)
|
|
return $"Error: drawing '{drawingName}' not found";
|
|
|
|
var strategy = ResolveStrategy(engine);
|
|
if (strategy == null)
|
|
return EngineError(engine);
|
|
if (PlateFillService.Refusal(plate) is { } refusal)
|
|
return $"Error: {refusal} Nothing committed.";
|
|
|
|
var countBefore = plate.Parts.Count;
|
|
var item = new NestItem { Drawing = drawing, Quantity = quantity };
|
|
var parts = PlateFillService.FillItem(
|
|
strategy,
|
|
plate,
|
|
item,
|
|
plate.WorkArea(),
|
|
null,
|
|
CancellationToken.None
|
|
);
|
|
plate.Parts.AddRange(parts);
|
|
var success = parts.Count > 0;
|
|
|
|
var countAfter = plate.Parts.Count;
|
|
var added = countAfter - countBefore;
|
|
|
|
var sb = new StringBuilder();
|
|
sb.AppendLine(
|
|
$"Fill plate {plateIndex} with '{drawingName}' ({strategy}): {(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("Length of the area")] double length,
|
|
[Description("Maximum quantity to place (0 = unlimited)")] int quantity = 0,
|
|
[Description(FillStrategies)]
|
|
string engine = null
|
|
)
|
|
{
|
|
var plate = _session.GetPlate(plateIndex);
|
|
if (plate == null)
|
|
return $"Error: plate {plateIndex} not found";
|
|
if (_session.IsConsumedStockPlate(plate))
|
|
return "Error: this empty entry was consumed by a repeated layout; select or create an unused stock plate. Nothing committed.";
|
|
|
|
var drawing = _session.GetDrawing(drawingName);
|
|
if (drawing == null)
|
|
return $"Error: drawing '{drawingName}' not found";
|
|
|
|
var strategy = ResolveStrategy(engine);
|
|
if (strategy == null)
|
|
return EngineError(engine);
|
|
if (PlateFillService.Refusal(plate) is { } refusal)
|
|
return $"Error: {refusal} Nothing committed.";
|
|
|
|
var countBefore = plate.Parts.Count;
|
|
var item = new NestItem { Drawing = drawing, Quantity = quantity };
|
|
var area = new Box(x, y, width, length);
|
|
var parts = PlateFillService.FillItem(
|
|
strategy,
|
|
plate,
|
|
item,
|
|
area,
|
|
null,
|
|
CancellationToken.None
|
|
);
|
|
plate.Parts.AddRange(parts);
|
|
var success = parts.Count > 0;
|
|
|
|
var countAfter = plate.Parts.Count;
|
|
var added = countAfter - countBefore;
|
|
|
|
var sb = new StringBuilder();
|
|
sb.AppendLine(
|
|
$"Fill area ({x:F1},{y:F1} {width:F1}x{length:F1}) on plate {plateIndex} with '{drawingName}' ({strategy}): {(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,
|
|
[Description(FillStrategies)]
|
|
string engine = null
|
|
)
|
|
{
|
|
var plate = _session.GetPlate(plateIndex);
|
|
if (plate == null)
|
|
return $"Error: plate {plateIndex} not found";
|
|
if (_session.IsConsumedStockPlate(plate))
|
|
return "Error: this empty entry was consumed by a repeated layout; select or create an unused stock plate. Nothing committed.";
|
|
|
|
var drawing = _session.GetDrawing(drawingName);
|
|
if (drawing == null)
|
|
return $"Error: drawing '{drawingName}' not found";
|
|
|
|
var strategy = ResolveStrategy(engine);
|
|
if (strategy == null)
|
|
return EngineError(engine);
|
|
if (PlateFillService.Refusal(plate) is { } refusal)
|
|
return $"Error: {refusal} Nothing committed.";
|
|
|
|
var finder = RemnantFinder.FromPlate(plate);
|
|
var remnants = finder.FindRemnants();
|
|
|
|
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;
|
|
|
|
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 parts = PlateFillService.FillItem(
|
|
strategy,
|
|
plate,
|
|
item,
|
|
remnant,
|
|
null,
|
|
CancellationToken.None
|
|
);
|
|
plate.Parts.AddRange(parts);
|
|
var added = plate.Parts.Count - countBefore;
|
|
totalAdded += added;
|
|
|
|
sb.AppendLine(
|
|
$" Remnant {i}: ({remnant.X:F1},{remnant.Y:F1} {remnant.Width:F1}x{remnant.Length:F1}) -> {added} parts {(added > 0 ? "" : "(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,
|
|
[Description(FillStrategies)]
|
|
string engine = null
|
|
)
|
|
{
|
|
var plate = _session.GetPlate(plateIndex);
|
|
if (plate == null)
|
|
return $"Error: plate {plateIndex} not found";
|
|
if (_session.IsConsumedStockPlate(plate))
|
|
return "Error: this empty entry was consumed by a repeated layout; select or create an unused stock plate. Nothing committed.";
|
|
|
|
if (string.IsNullOrWhiteSpace(drawingNames))
|
|
return "Error: drawingNames is required";
|
|
|
|
if (string.IsNullOrWhiteSpace(quantities))
|
|
return "Error: quantities is required";
|
|
|
|
var strategy = ResolveStrategy(engine);
|
|
if (strategy == null)
|
|
return EngineError(engine);
|
|
if (PlateFillService.Refusal(plate) is { } refusal)
|
|
return $"Error: {refusal} Nothing committed.";
|
|
|
|
var parsed = ParseItems(drawingNames, quantities);
|
|
if (parsed.error != null)
|
|
return parsed.error;
|
|
|
|
var items = parsed.items;
|
|
|
|
var countBefore = plate.Parts.Count;
|
|
var parts = PlateFillService.PackArea(
|
|
strategy,
|
|
plate,
|
|
plate.WorkArea(),
|
|
items,
|
|
null,
|
|
CancellationToken.None
|
|
);
|
|
plate.Parts.AddRange(parts);
|
|
var success = parts.Count > 0;
|
|
var countAfter = plate.Parts.Count;
|
|
var added = countAfter - countBefore;
|
|
|
|
var sb = new StringBuilder();
|
|
sb.AppendLine($"Pack plate {plateIndex} ({strategy}): {(success ? "success" : "failed")}");
|
|
sb.AppendLine($" Parts added: {added}");
|
|
sb.AppendLine($" Total parts: {countAfter}");
|
|
sb.AppendLine($" Utilization: {plate.Utilization():P1}");
|
|
|
|
AppendMixSummary(sb, items, parts);
|
|
|
|
return sb.ToString();
|
|
}
|
|
|
|
[McpServerTool(Name = "autonest_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 empty plate")] int plateIndex,
|
|
[Description("Comma-separated drawing names")] string drawingNames,
|
|
[Description("Comma-separated positive quantities")] string quantities,
|
|
[Description(JobEngines)] 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 (_session.IsConsumedStockPlate(plate))
|
|
return "Error: this empty entry was consumed by a repeated layout; select or create an unused stock plate. Nothing committed.";
|
|
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 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";
|
|
|
|
NestPipelineResult result;
|
|
try
|
|
{
|
|
result = NestPipeline.Run(new NestPipelineRequest(
|
|
engineName, parsed.items, NestStockBuilder.SinglePlate(plate)), token: cancellationToken);
|
|
}
|
|
catch (NotSupportedException)
|
|
{
|
|
return UnknownEngineMessage(engineName);
|
|
}
|
|
|
|
var sb = new StringBuilder();
|
|
foreach (var violation in result.Violations)
|
|
sb.AppendLine($"Violation: {violation}");
|
|
foreach (var unplaced in result.Unplaced)
|
|
sb.AppendLine($"Unplaced: {unplaced.Describe()}");
|
|
if (!result.CanKeep || result.Raw.Plates.Count > 1 || (!result.IsValid && !allow_invalid))
|
|
{
|
|
sb.AppendLine(result.Raw.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}");
|
|
AppendMixSummary(sb, parsed.items, proposed == null ? Enumerable.Empty<Part>() : proposed.Parts);
|
|
return sb.ToString();
|
|
}
|
|
|
|
[McpServerTool(Name = "autonest_job")]
|
|
[Description("Complete requested drawing quantities across physical sheets. The selected empty plate supplies common settings; optional sheets offer finite sizes and quantities. An incomplete or invalid proposal changes nothing.")]
|
|
public string AutoNestJob(
|
|
[Description("Index of an empty plate to use as the stock template")] int plateIndex,
|
|
[Description("Comma-separated drawing names")] string drawingNames,
|
|
[Description("Comma-separated total requested quantities for the entire session")] string quantities,
|
|
[Description(JobEngines)] string engine = null,
|
|
[Description("Use only already-created empty plates with matching dimensions and spacing; do not add sheets")] bool no_new_plates = false,
|
|
CancellationToken cancellationToken = default,
|
|
[Description("Optional finite physical sheet stock: width, length, available quantity and optional positive per-sheet cost in common units. Specify costs for every row or omit all. Incompatible with no_new_plates.")] SheetStockInput[] sheets = null
|
|
)
|
|
{
|
|
var template = _session.GetPlate(plateIndex);
|
|
if (template == null)
|
|
return $"Error: plate {plateIndex} not found";
|
|
if (_session.IsConsumedStockPlate(template))
|
|
return "Error: this empty entry was consumed by a repeated layout; select or create an unused stock plate. Nothing committed.";
|
|
if (template.Parts.Count != 0 || template.CutOffs.Count != 0)
|
|
return "Error: stock template must be empty and have no cutoff definitions. Nothing committed.";
|
|
if (sheets != null && no_new_plates)
|
|
return "Error: sheets cannot be combined with no_new_plates. Nothing committed.";
|
|
var offeredStock = BuildSheetStock(template, sheets);
|
|
if (offeredStock.error != null)
|
|
return offeredStock.error;
|
|
if (string.IsNullOrWhiteSpace(drawingNames) || string.IsNullOrWhiteSpace(quantities))
|
|
return "Error: drawingNames and quantities are required";
|
|
var parsed = ParseItems(drawingNames, quantities);
|
|
if (parsed.error != null)
|
|
return parsed.error;
|
|
if (parsed.items.Any(item => item.Quantity <= 0))
|
|
return "Error: whole-job quantities must be positive";
|
|
|
|
var existing = _session.AllPlates();
|
|
if (existing.Any(p => p.Parts.Count > 0 && p.Quantity <= 0))
|
|
return "Error: an occupied plate has a nonpositive quantity. Nothing committed.";
|
|
var remaining = new List<NestItem>(parsed.items.Count);
|
|
var counts = new Dictionary<Drawing, long>(ReferenceEqualityComparer.Instance);
|
|
foreach (var item in parsed.items)
|
|
{
|
|
var nested = existing.Sum(p => (long)p.Parts.Count(part => ReferenceEquals(part.BaseDrawing, item.Drawing)) * p.Quantity);
|
|
if (nested > item.Quantity)
|
|
return $"Error: drawing '{item.Drawing.Name}' already exceeds requested quantity ({nested} > {item.Quantity}). Nothing committed.";
|
|
counts[item.Drawing] = nested;
|
|
if (nested < item.Quantity)
|
|
remaining.Add(new NestItem
|
|
{
|
|
Drawing = item.Drawing,
|
|
Quantity = (int)(item.Quantity - nested),
|
|
Priority = item.Priority,
|
|
StepAngle = item.StepAngle,
|
|
RotationStart = item.RotationStart,
|
|
RotationEnd = item.RotationEnd,
|
|
});
|
|
}
|
|
if (remaining.Count == 0)
|
|
return "Job already complete: all requested quantities are present. Nothing committed.";
|
|
|
|
var available = new List<Plate> { template };
|
|
available.AddRange(existing.Where(p => !ReferenceEquals(p, template)
|
|
&& !_session.IsConsumedStockPlate(p)
|
|
&& p.Parts.Count == 0 && p.CutOffs.Count == 0 && SameStock(p, template)));
|
|
var demand = remaining.Sum(item => (long)item.Quantity);
|
|
if (demand > int.MaxValue)
|
|
return "Error: total remaining demand exceeds the supported sheet limit. Nothing committed.";
|
|
var limit = no_new_plates ? System.Math.Min(available.Count, (int)demand)
|
|
: sheets != null ? (int)System.Math.Min(offeredStock.total, demand) : (int)demand;
|
|
var engineName = string.IsNullOrWhiteSpace(engine) ? _session.DefaultEngineName : engine.Trim();
|
|
if (NestingEngineRegistry.ResolveName(engineName) == null)
|
|
return $"Error: unknown whole-job engine '{engineName}'. Nothing committed. Available: {string.Join(", ", NestingEngineRegistry.AvailableEngines.Select(e => e.Name))}";
|
|
NestPipelineResult result;
|
|
try
|
|
{
|
|
result = NestPipeline.Run(new NestPipelineRequest(engineName, remaining,
|
|
sheets == null ? NestStockBuilder.FromTemplate(template, null, no_new_plates ? available.Count : null) : offeredStock.stock,
|
|
new NestJobOptions(maxPlates: limit)), token: cancellationToken);
|
|
}
|
|
catch (NotSupportedException ex)
|
|
{
|
|
return $"Error: whole-job request unsupported: {ex.Message}. Nothing committed.";
|
|
}
|
|
cancellationToken.ThrowIfCancellationRequested();
|
|
if (!result.CanKeep || !result.IsValid)
|
|
return "Error: invalid whole-job proposal. Nothing committed. " + string.Join("; ", result.Violations);
|
|
|
|
var placed = new Dictionary<Drawing, long>(ReferenceEqualityComparer.Instance);
|
|
foreach (var sheet in result.Plates)
|
|
foreach (var part in sheet.Parts)
|
|
placed[part.BaseDrawing] = placed.GetValueOrDefault(part.BaseDrawing) + sheet.Quantity;
|
|
if (remaining.Any(item => placed.GetValueOrDefault(item.Drawing) != item.Quantity))
|
|
{
|
|
var summary = string.Join(", ", remaining.Select(item =>
|
|
$"{item.Drawing.Name}: {placed.GetValueOrDefault(item.Drawing)}/{item.Quantity} newly placed"));
|
|
var reasons = string.Join("; ", result.Unplaced.Select(u => u.Describe()));
|
|
return $"Error: incomplete whole-job proposal ({result.StopReason}); {summary}. Unplaced: {reasons}. Nothing committed. A solver's no-placement result does not prove geometric impossibility.";
|
|
}
|
|
if (result.Raw.Plates.Count > limit)
|
|
return "Error: proposal exceeds the allowed sheet count. Nothing committed.";
|
|
|
|
var costSummary = NestCostSummary.FromAccepted(result);
|
|
|
|
// Build the entire target map before attaching a single part. Indexes in a mixed
|
|
// stock proposal are solver ordinals, not indexes into the session's empty plates.
|
|
var usedTargets = new HashSet<Plate>(ReferenceEqualityComparer.Instance);
|
|
var targets = new List<(Plate plate, bool created, string stockId)>();
|
|
foreach (var proposed in result.Plates)
|
|
{
|
|
var target = new[] { template }.Concat(existing.Where(p => !ReferenceEquals(p, template)))
|
|
.FirstOrDefault(p => !usedTargets.Contains(p)
|
|
&& !_session.IsConsumedStockPlate(p)
|
|
&& p.Parts.Count == 0 && p.CutOffs.Count == 0
|
|
&& SameStock(p, template, proposed.Stock.Size));
|
|
var created = target == null;
|
|
if (created && no_new_plates)
|
|
return "Error: no compatible empty target for the proposed layout. Nothing committed.";
|
|
target ??= new Plate(proposed.Stock.Size)
|
|
{
|
|
GrainAngle = template.GrainAngle,
|
|
CuttingParameters = template.CuttingParameters,
|
|
};
|
|
usedTargets.Add(target);
|
|
targets.Add((target, created, proposed.Stock.Id));
|
|
}
|
|
// Reserve physical copies separately from display-layout targets. Do this
|
|
// only after mapping every layout, so a repeat cannot steal a residual's
|
|
// target. A rejected/canceled proposal reserves nothing and deletes nothing.
|
|
var reservations = new List<List<Plate>>();
|
|
foreach (var proposed in result.Plates)
|
|
{
|
|
var copies = existing.Where(p => !usedTargets.Contains(p)
|
|
&& !_session.IsConsumedStockPlate(p)
|
|
&& p.Parts.Count == 0 && p.CutOffs.Count == 0
|
|
&& SameStock(p, template, proposed.Stock.Size))
|
|
.Take(proposed.Quantity - 1).ToList();
|
|
if (no_new_plates && copies.Count != proposed.Quantity - 1)
|
|
return "Error: insufficient unused physical stock for the repeated layout. Nothing committed.";
|
|
usedTargets.UnionWith(copies);
|
|
reservations.Add(copies);
|
|
}
|
|
cancellationToken.ThrowIfCancellationRequested();
|
|
for (var i = 0; i < result.Plates.Count; i++)
|
|
{
|
|
var proposed = result.Plates[i];
|
|
var (target, created, _) = targets[i];
|
|
target.Size = proposed.Stock.Size;
|
|
target.PartSpacing = proposed.Stock.PartSpacing;
|
|
target.EdgeSpacing = proposed.Stock.EdgeSpacing;
|
|
target.Quadrant = proposed.Stock.Quadrant;
|
|
target.Quantity = proposed.Quantity;
|
|
if (created)
|
|
_session.Plates.Add(target);
|
|
target.Parts.AddRange(proposed.Parts);
|
|
}
|
|
for (var i = 0; i < targets.Count; i++)
|
|
_session.ReserveStockCopies(targets[i].plate, reservations[i]);
|
|
foreach (var item in parsed.items)
|
|
item.Drawing.Quantity.Required = item.Quantity;
|
|
if (sheets != null)
|
|
{
|
|
_session.Nest ??= new Nest();
|
|
_session.Nest.PlateOptions = offeredStock.stock.Select(s => new PlateOption
|
|
{ Width = s.Size.Width, Length = s.Size.Length, Cost = s.Cost ?? 0 }).ToList();
|
|
}
|
|
var lines = new StringBuilder();
|
|
lines.AppendLine($"Cost basis={costSummary.Basis}, gross total={costSummary.GrossTotal}, estimated salvage credit={costSummary.SalvageCredit}, net score={costSummary.NetScore}");
|
|
foreach (var usage in costSummary.Stock)
|
|
lines.AppendLine($" {usage.StockId}: used={usage.Used}, supplied cost={usage.Cost?.ToString() ?? "unspecified"}, gross subtotal={usage.GrossSubtotal}");
|
|
lines.AppendLine($"Whole job complete: {result.Raw.Plates.Count} sheet(s) placed in {result.Plates.Count} layout(s), {targets.Count(t => t.created)} new layout(s) created.");
|
|
if (sheets != null)
|
|
{
|
|
foreach (var stock in offeredStock.stock)
|
|
{
|
|
var used = targets.Where(t => t.stockId == stock.Id).Sum(t => t.plate.Quantity);
|
|
lines.AppendLine($" {stock.Id} ({stock.Size.Width}x{stock.Size.Length}): used={used}, remaining={stock.Quantity - used}, reused={targets.Count(t => !t.created && t.stockId == stock.Id)}, created={targets.Count(t => t.created && t.stockId == stock.Id)} (layout counts)");
|
|
}
|
|
foreach (var (target, _, stockId) in targets)
|
|
lines.AppendLine($" plate {_session.AllPlates().FindIndex(p => ReferenceEquals(p, target))} -> {stockId}");
|
|
}
|
|
foreach (var item in parsed.items)
|
|
lines.AppendLine($" {item.Drawing.Name}: requested={item.Quantity}, already={counts[item.Drawing]}, newly placed={placed.GetValueOrDefault(item.Drawing)}, remaining=0");
|
|
return lines.ToString();
|
|
}
|
|
|
|
private static (List<NestPlateStock> stock, long total, string error) BuildSheetStock(Plate template, SheetStockInput[] sheets)
|
|
{
|
|
if (sheets == null)
|
|
return (null, 0, null);
|
|
if (sheets.Length == 0)
|
|
return (null, 0, "Error: sheets must contain at least one stock size. Nothing committed.");
|
|
var stock = new List<NestPlateStock>(sheets.Length);
|
|
var priced = sheets.Any(s => s?.Cost != null);
|
|
long total = 0;
|
|
for (var i = 0; i < sheets.Length; i++)
|
|
{
|
|
var row = sheets[i];
|
|
if (row == null || !double.IsFinite(row.Width) || !double.IsFinite(row.Length)
|
|
|| !double.IsFinite(row.Width * row.Length)
|
|
|| row.Width <= 0 || row.Length <= 0 || row.Quantity <= 0
|
|
|| row.Length <= template.EdgeSpacing.Left + template.EdgeSpacing.Right
|
|
|| row.Width <= template.EdgeSpacing.Top + template.EdgeSpacing.Bottom)
|
|
return (null, 0, $"Error: sheets[{i}] requires finite positive usable dimensions and a positive physical quantity. Nothing committed.");
|
|
if (row.Cost is double cost && (!double.IsFinite(cost) || cost <= 0)
|
|
|| priced && row.Cost == null)
|
|
return (null, 0, $"Error: sheets[{i}] requires a finite positive cost; specify costs on every row or omit all. Nothing committed.");
|
|
var duplicate = stock.FindIndex(s => s.Size.Width == row.Width && s.Size.Length == row.Length);
|
|
if (duplicate >= 0)
|
|
return (null, 0, $"Error: sheets[{i}] duplicates sheets[{duplicate}] size. Nothing committed.");
|
|
total += row.Quantity;
|
|
if (total > int.MaxValue)
|
|
return (null, 0, "Error: total sheet inventory exceeds the supported sheet limit. Nothing committed.");
|
|
stock.Add(new NestPlateStock($"stock-{i}", new Size(row.Width, row.Length), row.Quantity,
|
|
template.PartSpacing, template.EdgeSpacing, template.Quadrant, row.Cost));
|
|
}
|
|
return (stock, total, null);
|
|
}
|
|
|
|
private static bool SameStock(Plate a, Plate b) => SameStock(a, b, b.Size);
|
|
|
|
private static bool SameStock(Plate a, Plate b, Size size) =>
|
|
a.Size.Width == size.Width && a.Size.Length == size.Length
|
|
&& a.PartSpacing == b.PartSpacing && a.EdgeSpacing.Equals(b.EdgeSpacing)
|
|
&& a.Quadrant == b.Quadrant && a.GrainAngle == b.GrainAngle
|
|
&& (a.CuttingParameters == null && b.CuttingParameters == null
|
|
|| a.CuttingParameters != null && b.CuttingParameters != null
|
|
&& string.Equals(CuttingParametersSerializer.Serialize(a.CuttingParameters),
|
|
CuttingParametersSerializer.Serialize(b.CuttingParameters), StringComparison.Ordinal));
|
|
|
|
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 - placed[i];
|
|
sb.AppendLine($" [{i}] {item.Drawing.Name}: requested={item.Quantity}, placed={placed[i]}, remaining={remaining}");
|
|
}
|
|
|
|
if (placed.All(count => count == 0))
|
|
sb.AppendLine(" Mix: zero progress for all requested drawings.");
|
|
else if (items.Where((item, i) => item.Quantity > placed[i]).Any())
|
|
sb.AppendLine(" Warning: remaining demand in this call; place the outstanding quantities on another sheet before treating the job as complete.");
|
|
else
|
|
sb.AppendLine(" Mix: requested quantities placed in this call.");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Resolves the requested fill strategy; an omitted name means Fill. Returns null when the
|
|
/// name is not a single-plate placement strategy.
|
|
/// </summary>
|
|
private static string ResolveStrategy(string engine) => EngineNameResolver.Classify(engine).FillStrategy;
|
|
|
|
private static string EngineError(string engine)
|
|
{
|
|
var classified = EngineNameResolver.Classify(engine);
|
|
return classified.IsJobEngine
|
|
? $"Error: engine '{classified.Name}' is a whole-job engine; this tool supports: {string.Join(", ", PlateFillService.BuiltInStrategies)}. Use autonest_plate for whole-job engines."
|
|
: UnknownEngineMessage(classified.Name);
|
|
}
|
|
|
|
private static string UnknownEngineMessage(string engineName) =>
|
|
$"Error: unknown engine '{engineName}'. Fill strategies: {string.Join(", ", PlateFillService.BuiltInStrategies)}; jobs engines: {string.Join(", ", NestingEngineRegistry.AvailableEngines.Select(e => e.Name))}";
|
|
|
|
private (List<NestItem> items, string error) ParseItems(string drawingNames, string quantities)
|
|
{
|
|
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 (null, $"Error: '{qtyStrings[i]}' is not a valid quantity");
|
|
if (qtys[i] < 0)
|
|
return (null, $"Error: negative quantity '{qtys[i]}' is not supported");
|
|
}
|
|
|
|
if (names.Length != qtys.Length)
|
|
return (
|
|
null,
|
|
$"Error: drawing names count ({names.Length}) does not match quantities count ({qtys.Length})"
|
|
);
|
|
|
|
var items = new List<NestItem>();
|
|
var seen = new HashSet<Drawing>(ReferenceEqualityComparer.Instance);
|
|
|
|
for (var i = 0; i < names.Length; i++)
|
|
{
|
|
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],
|
|
Priority = drawing.Priority,
|
|
StepAngle = drawing.Constraints?.StepAngle ?? 0,
|
|
RotationStart = drawing.Constraints?.StartAngle ?? 0,
|
|
RotationEnd = drawing.Constraints?.EndAngle ?? 0,
|
|
});
|
|
}
|
|
|
|
return (items, null);
|
|
}
|
|
}
|
|
}
|