#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;
}
}