267 lines
10 KiB
C#
267 lines
10 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.Diagnostics;
|
|
using System.IO;
|
|
using System.Linq;
|
|
using System.Threading;
|
|
using System.Threading.Tasks;
|
|
using OpenNest.Engine;
|
|
using OpenNest.Engine.Jobs;
|
|
using OpenNest.IO;
|
|
|
|
namespace OpenNest.Api;
|
|
|
|
public static class NestRunner
|
|
{
|
|
private const string LegacyStockId = "legacy-sheet";
|
|
|
|
public static Task<NestResponse> RunAsync(
|
|
NestRequest request,
|
|
IProgress<NestProgress> progress = null,
|
|
CancellationToken token = default
|
|
)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(request);
|
|
var requestParts =
|
|
request.Parts
|
|
?? throw new ArgumentException("Request parts must not be null.", nameof(request));
|
|
if (requestParts.Count == 0)
|
|
throw new ArgumentException("Request must contain at least one part.", nameof(request));
|
|
|
|
var sw = Stopwatch.StartNew();
|
|
var parts = IdentifyParts(requestParts);
|
|
var importedByPath = new Dictionary<string, Drawing>(StringComparer.Ordinal);
|
|
var items = new List<NestItem>(parts.Count);
|
|
|
|
foreach (var part in parts)
|
|
{
|
|
token.ThrowIfCancellationRequested();
|
|
if (!File.Exists(part.Request.DxfPath))
|
|
throw new FileNotFoundException(
|
|
$"DXF file not found: {part.Request.DxfPath}",
|
|
part.Request.DxfPath
|
|
);
|
|
|
|
if (!importedByPath.TryGetValue(part.Request.DxfPath, out var drawing))
|
|
{
|
|
try
|
|
{
|
|
drawing = CadImporter.ImportDrawing(
|
|
part.Request.DxfPath,
|
|
new CadImportOptions { Quantity = part.Request.Quantity }
|
|
);
|
|
}
|
|
catch (Exception exception)
|
|
{
|
|
throw new InvalidOperationException(
|
|
$"Failed to import DXF: {part.Request.DxfPath}",
|
|
exception
|
|
);
|
|
}
|
|
|
|
if (drawing.Program == null || drawing.Program.Codes.Count == 0)
|
|
throw new InvalidOperationException(
|
|
$"Failed to import DXF: {part.Request.DxfPath}"
|
|
);
|
|
|
|
importedByPath.Add(part.Request.DxfPath, drawing);
|
|
}
|
|
|
|
// 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 stock = CreateStock(request);
|
|
var engineName = request.Engine ?? ResolvePlacementStrategy(request);
|
|
var jobProgress = progress == null ? null : new JobProgressBridge(progress);
|
|
var result = NestPipeline.Run(new NestPipelineRequest(
|
|
engineName, items, stock, new NestJobOptions(engineName)), jobProgress, token);
|
|
|
|
// 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);
|
|
sw.Stop();
|
|
|
|
return Task.FromResult(
|
|
new NestResponse
|
|
{
|
|
SheetCount = nest.Plates.Count,
|
|
Utilization = CalculateUtilization(nest),
|
|
CutTime = cutTime,
|
|
Elapsed = sw.Elapsed,
|
|
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,
|
|
}
|
|
);
|
|
}
|
|
|
|
private static IReadOnlyList<IdentifiedRequestPart> IdentifyParts(
|
|
IReadOnlyList<NestRequestPart> requestParts
|
|
)
|
|
{
|
|
var identified = new List<IdentifiedRequestPart>(requestParts.Count);
|
|
var ids = new HashSet<string>(StringComparer.Ordinal);
|
|
for (var index = 0; index < requestParts.Count; index++)
|
|
{
|
|
var part =
|
|
requestParts[index]
|
|
?? throw new ArgumentException(
|
|
"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));
|
|
if (!ids.Add(id))
|
|
throw new ArgumentException("Part IDs must be unique.", nameof(requestParts));
|
|
identified.Add(new IdentifiedRequestPart(id, part));
|
|
}
|
|
|
|
return identified;
|
|
}
|
|
|
|
private static IReadOnlyList<NestPlateStock> CreateStock(NestRequest request)
|
|
{
|
|
if (request.Plates is null)
|
|
{
|
|
return
|
|
[
|
|
new NestPlateStock(
|
|
LegacyStockId,
|
|
request.SheetSize,
|
|
quantity: null,
|
|
partSpacing: request.Spacing
|
|
),
|
|
];
|
|
}
|
|
|
|
var stock = new List<NestPlateStock>(request.Plates.Count);
|
|
foreach (var plate in request.Plates)
|
|
{
|
|
if (plate is null)
|
|
throw new ArgumentException(
|
|
"Request plates must not contain null entries.",
|
|
nameof(request)
|
|
);
|
|
stock.Add(
|
|
new NestPlateStock(
|
|
plate.Id,
|
|
plate.Size,
|
|
plate.Quantity,
|
|
plate.PartSpacing,
|
|
plate.EdgeSpacing,
|
|
plate.Quadrant
|
|
)
|
|
);
|
|
}
|
|
|
|
return stock;
|
|
}
|
|
|
|
private static void ConfigureDrawingForRequirement(Drawing drawing, NestRequestPart part)
|
|
{
|
|
drawing.Priority = part.Priority;
|
|
drawing.Constraints ??= new NestConstraints();
|
|
if (!part.AllowRotation)
|
|
{
|
|
// A zero legacy step means automatic rotation to DrawingJobMapper, so lock it explicitly.
|
|
drawing.Constraints.StepAngle = OpenNest.Math.Angle.TwoPI;
|
|
drawing.Constraints.StartAngle = 0;
|
|
drawing.Constraints.EndAngle = 0;
|
|
}
|
|
}
|
|
|
|
private static string ResolvePlacementStrategy(NestRequest request) =>
|
|
request.PlacementStrategy
|
|
?? request.Strategy switch
|
|
{
|
|
NestStrategy.Auto => "Default",
|
|
_ => throw new NotSupportedException(
|
|
$"Unknown legacy nesting strategy: {request.Strategy}."
|
|
),
|
|
};
|
|
|
|
private static double CalculateUtilization(Nest nest)
|
|
{
|
|
var sheetArea = nest.Plates.Sum(plate => plate.Area());
|
|
if (sheetArea == 0)
|
|
return 0;
|
|
var placedArea = nest.Plates.Sum(plate =>
|
|
plate.Parts.Where(part => !part.BaseDrawing.IsCutOff).Sum(part => part.BaseDrawing.Area)
|
|
);
|
|
return placedArea / sheetArea;
|
|
}
|
|
|
|
private sealed record IdentifiedRequestPart(string Id, NestRequestPart Request);
|
|
|
|
private sealed class JobProgressBridge(IProgress<NestProgress> progress)
|
|
: IProgress<NestJobProgress>
|
|
{
|
|
public void Report(NestJobProgress value)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(value);
|
|
if (value.LegacyProgress is not null)
|
|
progress.Report(value.LegacyProgress);
|
|
}
|
|
}
|
|
}
|