using System; using System.Collections.Generic; using System.Linq; using System.Threading; using OpenNest.Engine.Fill; using OpenNest.Engine.Jobs.Placement.Fillers; using OpenNest.Geometry; namespace OpenNest.Engine.Jobs.Placement; /// /// Public single-plate placement service over the internal fillers. Resolves one of the four /// built-in placement strategies by explicit name — no process-global registry state is read or /// modified. Operations return proposed s only; caller-owned plate mutation /// (accepting a preview, adding parts to a plate) and cancel/discard behavior remain with the /// caller, exactly as they were with the legacy single-plate engine surface. /// public static class PlateFillService { /// Strategy used when a caller selection names none. public const string DefaultStrategy = "Fill"; /// Name of the Fill strategy in earlier releases; accepted as an alias. private const string LegacyFillName = "Default"; /// The four built-in strategy names, in registry display order. public static IReadOnlyList BuiltInStrategies { get; } = [ "Fill", "Strip", "Vertical Remnant", "Horizontal Remnant", ]; public static List FillItem( string strategy, Plate plate, NestItem item, Box workArea, IProgress progress, CancellationToken token ) { var filler = RequireFiller(strategy, plate); if (!InteractiveClamps.HasZones(plate)) return filler.Fill(item, workArea, progress, token); var result = FillClearAreas(plate, workArea, item, token, (area, demand) => filler.Fill(demand, area, null, token)); ReportSafe(progress, result, workArea); return result; } public static List FillGroup( string strategy, Plate plate, List groupParts, Box workArea, IProgress progress, CancellationToken token ) { var filler = RequireFiller(strategy, plate); if (!InteractiveClamps.HasZones(plate)) return filler.Fill(groupParts, workArea, progress, token); var result = ClearAreas(plate, workArea, token, (area, _) => filler.Fill(groupParts, area, null, token), 0, groupSize: groupParts.Count); ReportSafe(progress, result, workArea); return result; } public static List PackArea( string strategy, Plate plate, Box box, List items, IProgress progress, CancellationToken token ) { var filler = RequireFiller(strategy, plate); if (!InteractiveClamps.HasZones(plate)) return filler.PackArea(box, items, progress, token); var result = new List(); foreach (var area in ClearRegionBoxes(plate, box)) { token.ThrowIfCancellationRequested(); var remaining = items.Select(item => CopyDemand(item, item.Quantity > 0 ? System.Math.Max(0, item.Quantity - result.Count(part => ReferenceEquals(part.BaseDrawing, item.Drawing))) : item.Quantity)).ToList(); var proposed = filler.PackArea(area, remaining, null, token); AddClear(result, proposed, plate, area, items); } ReportSafe(progress, result, box); return result; } public static List Nest( string strategy, Plate plate, List items, IProgress progress, CancellationToken token ) => Nest(strategy, plate, items, 0, progress, token); /// /// Whole-plate fill with the strategy's orchestration (fill-vs-pack, compaction). Returns /// proposed parts only; committing them to stays with the caller. /// /// Plate index reported with progress, as the legacy engine's /// PlateNumber was by interactive multi-plate loops. public static List Nest( string strategy, Plate plate, List items, int plateNumber, IProgress progress, CancellationToken token ) { ArgumentNullException.ThrowIfNull(strategy); ResolveStrategy(strategy, allowEmpty: false); var filler = CreateFiller(strategy, plate); filler.PlateNumber = plateNumber; if (!InteractiveClamps.HasZones(plate)) return filler.Nest(items, progress, token); // Interactive proposals are never committed here. Unlike the unconstrained // strategies, partition the sheet at static keep-outs before searching, then // certify each accepted material footprint against the full zone and occupants. var result = new List(); foreach (var item in items.OrderBy(item => item.Priority)) { token.ThrowIfCancellationRequested(); // Fill/Remnant whole-plate orchestration skips exhausted demand; the // single-item Fill API uses zero to mean unlimited. Strip has its own // historical zero-quantity behavior, preserved here. if (item.Quantity <= 0 && ResolveStrategy(strategy) != "Strip") continue; var proposed = FillClearAreas(plate, plate.WorkArea(), item, token, (area, demand) => filler.Fill(demand, area, null, token), result); result.AddRange(proposed); if (item.Quantity > 0) item.Quantity = System.Math.Max(0, item.Quantity - proposed.Count); } ReportSafe(progress, result, plate.WorkArea(), plateNumber); return result; } // Do not expose a strategy's unverified intermediate placements: the UI can // accept a progress preview before this service returns its certified proposal. private static void ReportSafe(IProgress progress, List parts, Box area, int plateNumber = 0) => progress?.Report(new NestProgress { BestParts = parts, ActiveWorkArea = area, PlateNumber = plateNumber, IsOverallBest = true, Description = $"Clamp-clear proposal: {parts.Count} parts", }); private static NestItem CopyDemand(NestItem item, int quantity) => new() { Drawing = item.Drawing, Quantity = quantity, Priority = item.Priority, RotationStart = item.RotationStart, RotationEnd = item.RotationEnd, StepAngle = item.StepAngle, }; private static List FillClearAreas(Plate plate, Box box, NestItem item, CancellationToken token, Func> fill, List previouslyPlaced = null) => ClearAreas(plate, box, token, fill, item.Quantity, item, previouslyPlaced); private static List ClearAreas(Plate plate, Box box, CancellationToken token, Func> fill, int quantity, NestItem item = null, List previouslyPlaced = null, int groupSize = 1) { var result = new List(); foreach (var area in ClearRegionBoxes(plate, box)) { token.ThrowIfCancellationRequested(); if (quantity > 0 && result.Count >= quantity) break; var demand = item == null ? null : CopyDemand(item, quantity > 0 ? quantity - result.Count : quantity); AddClear(result, fill(area, demand), plate, area, item == null ? null : new List { item }, previouslyPlaced, groupSize); } return result; } private static List ClearRegionBoxes(Plate plate, Box area) { var obstacles = InteractiveClamps.ClearanceRectangles(plate).ToList(); obstacles.AddRange(plate.Parts.Where(part => !part.BaseDrawing.IsCutOff) .Select(part => part.BoundingBox.Offset(plate.PartSpacing))); return new RemnantFinder(area, obstacles).FindRemnants(); } private static void AddClear(List accepted, List proposed, Plate plate, Box area, List demand = null, List previouslyPlaced = null, int groupSize = 1) { if (proposed == null || groupSize < 1) return; // FillLinear may append an incomplete trailing tile. Keep complete copies // ahead of it, but never expose the incomplete tail as a group. for (var start = 0; start + groupSize <= proposed.Count; start += groupSize) { // A group is one proposal: never return just its frame or just its // insert when a member collides with a clamp or occupied material. var batch = new List(); for (var index = start; index < start + groupSize; index++) { if (!TryAddClear(batch, proposed[index], accepted, plate, area, demand, previouslyPlaced)) break; } if (batch.Count == groupSize) accepted.AddRange(batch); } } private static bool TryAddClear(List batch, Part part, List accepted, Plate plate, Box area, List demand, List previouslyPlaced) { var bounds = part.BoundingBox; var full = plate.WorkArea(); // The packer permits a tiny overhang only at a real sheet edge, // never across a remnant boundary cut by a clamp or another part. double Slack(double edge, double sheet) => System.Math.Abs(edge - sheet) < 1e-9 ? NestTolerances.WorkAreaSlack : 1e-6; if (bounds.Left < area.Left - Slack(area.Left, full.Left) || bounds.Right > area.Right + Slack(area.Right, full.Right) || bounds.Bottom < area.Bottom - Slack(area.Bottom, full.Bottom) || bounds.Top > area.Top + Slack(area.Top, full.Top)) return false; if (demand != null && !demand.Any(item => ReferenceEquals(item.Drawing, part.BaseDrawing) && (item.Quantity <= 0 || accepted.Count(p => ReferenceEquals(p.BaseDrawing, item.Drawing)) + batch.Count(p => ReferenceEquals(p.BaseDrawing, item.Drawing)) < item.Quantity))) return false; if (InteractiveClamps.ValidationKeepOuts(plate).Any(zone => Overlaps(bounds, zone) && InteractiveClamps.MaterialInKeepOut(part, zone))) return false; if (plate.Parts.Any(existing => !existing.BaseDrawing.IsCutOff && Overlaps(bounds, existing.BoundingBox.Offset(plate.PartSpacing)) && InteractiveClamps.MaterialsTooClose(part, existing, plate.PartSpacing)) || accepted.Any(existing => Overlaps(bounds, existing.BoundingBox.Offset(plate.PartSpacing)) && InteractiveClamps.MaterialsTooClose(part, existing, plate.PartSpacing)) || batch.Any(existing => Overlaps(bounds, existing.BoundingBox.Offset(plate.PartSpacing)) && InteractiveClamps.MaterialsTooClose(part, existing, plate.PartSpacing)) || (previouslyPlaced?.Any(existing => Overlaps(bounds, existing.BoundingBox.Offset(plate.PartSpacing)) && InteractiveClamps.MaterialsTooClose(part, existing, plate.PartSpacing)) ?? false)) return false; batch.Add(part); return true; } private static bool Overlaps(Box a, Box b) => a.Left < b.Right && a.Right > b.Left && a.Bottom < b.Top && a.Top > b.Bottom; /// /// Resolves a caller-supplied strategy name: null or empty means "Fill"; otherwise the name /// must match a built-in strategy, matched case-insensitively like the legacy registry's /// ActiveEngineName so tolerant interactive callers keep working. "Default", the Fill /// strategy's name in earlier releases, still resolves to "Fill". Returns the canonical name; /// unknown names throw . /// public static string ResolveStrategy(string strategy) => ResolveStrategy(strategy, true); /// /// False rejects null/empty instead of defaulting (explicit service calls). internal static string ResolveStrategy(string strategy, bool allowEmpty) { if (string.IsNullOrWhiteSpace(strategy)) { if (allowEmpty) return DefaultStrategy; throw new NotSupportedException( $"Unknown placement strategy: '{strategy}'. Known strategies: {string.Join(", ", BuiltInStrategies)}." ); } if (strategy.Equals(LegacyFillName, StringComparison.OrdinalIgnoreCase)) return DefaultStrategy; foreach (var candidate in BuiltInStrategies) { if (candidate.Equals(strategy, StringComparison.OrdinalIgnoreCase)) return candidate; } throw new NotSupportedException( $"Unknown placement strategy: {strategy}. Known strategies: {string.Join(", ", BuiltInStrategies)}." ); } /// Builds the filler for an optional strategy (null/empty = Fill). private static PlateFillerBase CreateFiller(string strategy, Plate plate) { ArgumentNullException.ThrowIfNull(plate); return ResolveStrategy(strategy) switch { "Fill" => new DefaultPlateFiller(plate), "Strip" => new StripPlateFiller(plate), "Vertical Remnant" => new RemnantPlateFiller(plate, RemnantFillPolicy.Vertical), _ => new RemnantPlateFiller(plate, RemnantFillPolicy.Horizontal), }; } /// Public operations require an explicit strategy name (null is an argument error). private static PlateFillerBase RequireFiller(string strategy, Plate plate) { ArgumentNullException.ThrowIfNull(strategy); // An explicit empty string is an unknown strategy; only ResolveStrategy(string), // used for caller selections, maps null/empty to Fill. ResolveStrategy(strategy, allowEmpty: false); return CreateFiller(strategy, plate); } }