#nullable enable using System; using System.Collections.Generic; using System.Linq; using System.Threading; using OpenNest.Converters; using OpenNest.Engine.Jobs; using OpenNest.Engine.Jobs.Adapters; using OpenNest.Geometry; namespace OpenNest.Engine.NestingEngines.Rectangles; /// One allowed rotation of a part, reduced to its analytic material bounding box. /// Rotation in radians about the snapshot origin. /// Material X extent after rotation. /// Material Y extent after rotation. /// Rotated material bounds' left edge relative to the snapshot origin. /// Rotated material bounds' bottom edge relative to the snapshot origin. internal sealed record BoxOrientation(double Angle, double Width, double Height, double OffsetX, double OffsetY); /// A requested part type: every instance shares the same orientations. internal sealed record BoxType( int Index, NestJobPart Part, IReadOnlyList Orientations, double MaterialArea) { public string Id => Part.Id; public int Priority => Part.Priority; /// Smallest bounding-box area over the allowed orientations. public double BoxArea => Orientations.Count == 0 ? 0 : Orientations.Min(o => o.Width * o.Height); /// Shortest side over all orientations; free space narrower than this is useless. public double MinSide => Orientations.Count == 0 ? double.MaxValue : Orientations.Min(o => System.Math.Min(o.Width, o.Height)); } /// /// Reduces every requested part to the axis-aligned boxes of its useful rotations. Only material /// contours count (rapids and scribe/etch marks are excluded), exactly as the layout check's /// bounds test does. Orientations are the host rotation candidates whose box area is within a /// hair of the minimum (the minimum-area bounding rectangle plus its right-angle turn), with /// duplicate box shapes removed. Unreadable geometry yields a type with no orientations. /// internal static class BoxCatalog { private const double AreaTieRelative = 1e-6; private const double DimensionTie = 1e-7; public static IReadOnlyList Build(NestJob job) { var types = new List(job.Parts.Count); for (var i = 0; i < job.Parts.Count; i++) types.Add(Read(i, job.Parts[i])); return types; } private static BoxType Read(int index, NestJobPart part) { var geometry = JobPartGeometry.TryRead(part.Geometry); if (geometry == null) return new BoxType(index, part, Array.Empty(), 0); var candidates = new List(); foreach (var angle in RotationCandidates.ForShape(part.Rotation, geometry.Perimeter)) { var bounds = RotatedMaterialBounds(part.Geometry, angle); if (bounds is not { } b || !(b.Width > 0) || !(b.Height > 0)) continue; candidates.Add(new BoxOrientation(angle, b.Width, b.Height, b.Left, b.Bottom)); } if (candidates.Count == 0) return new BoxType(index, part, Array.Empty(), geometry.MaterialArea); var minArea = candidates.Min(c => c.Width * c.Height); var kept = new List(); foreach (var c in candidates) { if (c.Width * c.Height > minArea * (1 + AreaTieRelative)) continue; if (kept.Any(k => System.Math.Abs(k.Width - c.Width) <= DimensionTie && System.Math.Abs(k.Height - c.Height) <= DimensionTie)) continue; kept.Add(c); } return new BoxType(index, part, kept, geometry.MaterialArea); } /// /// Material bounds after rotation, as the layout check will see them. The check flattens /// perimeter arcs circumscribed and snaps to a 1e-4 Clipper grid, so a curved extreme reads /// slightly outside the true arc. Each side takes the larger of the analytic bound and the /// check's own outline (ClipperBridge.OffsetForValidation at zero inflation, flattened in the /// same local frame), and any side where the outline sticks out gets one more grid unit. /// Straight edges are unchanged, so rectangles still pack at exactly the part spacing. /// private static (double Left, double Bottom, double Width, double Height)? RotatedMaterialBounds( PartGeometrySnapshot snapshot, double angle) { var entities = ConvertProgram.ToGeometry(DrawingJobMapper.ToProgram(snapshot)) .Where(e => SpecialLayers.IsMaterial(e.Layer)) .ToList(); if (entities.Count == 0) return null; foreach (var entity in entities) entity.Rotate(angle); var left = entities.Min(e => e.Left); var bottom = entities.Min(e => e.Bottom); var right = entities.Max(e => e.Right); var top = entities.Max(e => e.Top); if (!double.IsFinite(left) || !double.IsFinite(bottom) || !double.IsFinite(right) || !double.IsFinite(top)) return null; var profile = new ShapeProfile(entities); var outline = profile.Perimeter == null ? null : ClipperBridge.OffsetForValidation(profile, 0, NestTolerances.ValidationOutline).LargestOuter(); if (outline != null && outline.Vertices.Count >= 3) { left = Widen(left, outline.Vertices.Min(v => v.X), -1); bottom = Widen(bottom, outline.Vertices.Min(v => v.Y), -1); right = Widen(right, outline.Vertices.Max(v => v.X), +1); top = Widen(top, outline.Vertices.Max(v => v.Y), +1); } return (left, bottom, right - left, top - bottom); } /// /// Pushes a side out to the check's outline plus one grid unit when the outline sticks out by /// more than a quarter of the spacing slack. Smaller differences are grid rounding (at most half /// a unit per vertex) or negligible bulge: two facing sides then lose under 0.00035 in total, /// inside NestTolerances.SpacingSlack, and ignoring them keeps rotated rectangles exact. /// private static double Widen(double analytic, double outline, int direction) { var grid = System.Math.Pow(10, -NestTolerances.ClipperPrecision); var beyond = (outline - analytic) * direction; return beyond > NestTolerances.SpacingSlack / 4 ? outline + direction * grid : analytic; } }