#nullable enable using System; using System.Collections.Generic; using System.Linq; using System.Threading; using Clipper2Lib; using OpenNest.Engine.BestFit; using OpenNest.Engine.Jobs; using OpenNest.Engine.Jobs.Adapters; using OpenNest.Engine.Jobs.Placement; using OpenNest.Geometry; using OpenNest.Math; namespace OpenNest.Engine.NestingEngines.Irregular; /// Per-solve, per-spacing Fill proposals. Only the members occupy material. internal sealed class BlockCatalog : IDisposable { private readonly double spacing; private readonly Dictionary drawings = new(); private readonly Dictionary> orientations = new(); private readonly Dictionary attempts = new(); internal int PreparationCount => attempts.Values.Sum(); private readonly Dictionary<(int Type, int Quantity, double Length, double Width), IReadOnlyList> cache = new(); public BlockCatalog(double spacing, IReadOnlyList types, IReadOnlyDictionary> pairs) { this.spacing = spacing; foreach (var type in types) { var poses = type.Orientations.ToList(); if (pairs.TryGetValue(type.Index, out var found)) poses.AddRange(found.SelectMany(p => new[] { p.A, p.B })); orientations[type.Index] = poses.Distinct().ToList(); } } internal static IReadOnlyList Rectangles(Box work, IReadOnlyList placed, double spacing) { var free = new PathsD { new PathD { new(work.Left, work.Bottom), new(work.Right, work.Bottom), new(work.Right, work.Top), new(work.Left, work.Top), } }; foreach (var part in placed) { // Physical occupied material, not a reference-point region for any moving part. // The catalog currently prepares solid outlines; future profile preparation owns holes. var blocked = Clipper.InflatePaths(new PathsD { part.Orientation.Outline }, spacing + part.Orientation.Tolerance + 0.001, JoinType.Miter, EndType.Polygon, 2, NoFitCache.Precision); free = Clipper.Difference(free, Clipper.TranslatePaths(blocked, part.X, part.Y), FillRule.NonZero, NoFitCache.Precision); } return MaximalRectangles.InRegion(free).OrderByDescending(b => b.Area()) .ThenBy(b => b.Left).ThenBy(b => b.Bottom).ThenBy(b => b.Length).Take(2).ToArray(); } public IReadOnlyList Get(PartType type, int quantity, Box rectangle, CancellationToken token) { token.ThrowIfCancellationRequested(); if (quantity <= 2 || type.Orientations.Count == 0 || rectangle.Length <= 0 || rectangle.Width <= 0) return Array.Empty(); var key = (type.Index, quantity, rectangle.Length, rectangle.Width); if (cache.TryGetValue(key, out var cached)) return cached; if (rectangle.Area() < 3 * type.Area || attempts.GetValueOrDefault(type.Index) >= 8) return Array.Empty(); attempts[type.Index] = attempts.GetValueOrDefault(type.Index) + 1; var result = Build(type, quantity, rectangle, token); cache[key] = result; return result; } private IReadOnlyList Build(PartType type, int quantity, Box rectangle, CancellationToken token) { if (!drawings.TryGetValue(type.Index, out var drawing)) drawings[type.Index] = drawing = DrawingJobMapper.CreateDrawing(type.Part); var plate = new Plate(new Size(rectangle.Width, rectangle.Length)) { PartSpacing = spacing }; try { // The drawing is private: stabilize this cache entry before Fill's candidate pruning. var fits = BestFitCache.GetOrCompute(drawing, plate.Size.Length, plate.Size.Width, spacing); var sorted = fits.OrderBy(f => f.RotatedArea).ThenBy(f => f.Candidate.StrategyIndex) .ThenBy(f => f.Candidate.Part2Rotation).ThenBy(f => f.Candidate.Part2Offset.X) .ThenBy(f => f.Candidate.Part2Offset.Y).ThenBy(f => f.OptimalRotation).ToArray(); fits.Clear(); fits.AddRange(sorted); var members = PlateFillService.FillItem("Default", plate, new NestItem { Drawing = drawing, Quantity = quantity, RotationStart = type.Part.Rotation.Start, RotationEnd = type.Part.Rotation.End, StepAngle = DrawingJobMapper.LegacyStep(type.Part.Rotation), }, new Box(0, 0, rectangle.Length, rectangle.Width), null!, token); token.ThrowIfCancellationRequested(); return Resolve(type, members.Take(quantity).ToArray(), orientations[type.Index], spacing, token); } catch (Exception ex) when (ex is ArgumentException or InvalidOperationException or NotSupportedException or ArithmeticException) { return Array.Empty(); } } public void Dispose() { foreach (var drawing in drawings.Values) BestFitCache.Invalidate(drawing); drawings.Clear(); } internal static IReadOnlyList Resolve(PartType type, IReadOnlyList members, List orientations, double spacing, CancellationToken token = default) { token.ThrowIfCancellationRequested(); if (members.Count <= 2) return Array.Empty(); var geometry = JobPartGeometry.TryRead(type.Part.Geometry); if (geometry == null) return Array.Empty(); // Canonical rebinding is already performed by FillItem. Quantization removes sub-grid // arithmetic differences from equivalent Fill proposals; certify the resulting poses. var poses = members.Select(p => new NestJobPlacement(type.Part.Id, 0, System.Math.Round(p.Location.X, 8), System.Math.Round(p.Location.Y, 8), System.Math.Round(Angle.NormalizeRad(p.Rotation), 10))) .OrderBy(p => p.X).ThenBy(p => p.Y).ThenBy(p => p.Rotation).ToArray(); if (poses.Any(p => !double.IsFinite(p.X) || !double.IsFinite(p.Y) || !double.IsFinite(p.Rotation) || !type.Part.Rotation.Allows(p.Rotation))) return Array.Empty(); for (var i = 0; i < poses.Length; i++) for (var j = i + 1; j < poses.Length; j++) { token.ThrowIfCancellationRequested(); if (!NestLayoutCheck.Clears(geometry, poses[i], geometry, poses[j], spacing)) return Array.Empty(); } var result = new List(); foreach (var pose in poses) { token.ThrowIfCancellationRequested(); var orientation = orientations.FirstOrDefault(o => o.Rotation == pose.Rotation); if (orientation == null) { orientation = PartCatalog.CreateOrientation(type, orientations.Max(o => o.Index) + 1, pose.Rotation); if (orientation == null) return Array.Empty(); orientations.Add(orientation); } result.Add(new Placed(orientation, pose.X - poses[0].X, pose.Y - poses[0].Y)); } return result; } }