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 RunAsync( NestRequest request, IProgress 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(StringComparer.Ordinal); var items = new List(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 IdentifyParts( IReadOnlyList requestParts ) { var identified = new List(requestParts.Count); var ids = new HashSet(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 CreateStock(NestRequest request) { if (request.Plates is null) { return [ new NestPlateStock( LegacyStockId, request.SheetSize, quantity: null, partSpacing: request.Spacing ), ]; } var stock = new List(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 progress) : IProgress { public void Report(NestJobProgress value) { ArgumentNullException.ThrowIfNull(value); if (value.LegacyProgress is not null) progress.Report(value.LegacyProgress); } } }