using System; using System.Collections.Generic; using System.Linq; using OpenNest.Geometry; namespace OpenNest.Shapes { /// /// Catalog of standard mill sheet sizes (inches) with helpers for matching /// a bounding box to a recommended plate size. Uses the project-wide /// (Width, Length) convention where Width is the short dimension and /// Length is the long dimension. /// public static class PlateSizes { public readonly record struct Entry(string Label, double Width, double Length) { public double Area => Width * Length; /// /// Returns true if a part of the given dimensions fits within this entry /// in either orientation. /// public bool Fits(double width, double length) => (width <= Width && length <= Length) || (width <= Length && length <= Width); } /// /// Standard mill sheet sizes (inches), sorted by area ascending. /// Canonical orientation: Width <= Length. /// public static IReadOnlyList All { get; } = new[] { new Entry("48x96", 48, 96), // 4608 new Entry("48x120", 48, 120), // 5760 new Entry("48x144", 48, 144), // 6912 new Entry("60x120", 60, 120), // 7200 new Entry("60x144", 60, 144), // 8640 new Entry("72x120", 72, 120), // 8640 new Entry("72x144", 72, 144), // 10368 new Entry("96x240", 96, 240), // 23040 }; /// /// Looks up a standard size by label. Case-insensitive. /// public static bool TryGet(string label, out Entry entry) { if (!string.IsNullOrWhiteSpace(label)) { foreach (var candidate in All) { if (string.Equals(candidate.Label, label, StringComparison.OrdinalIgnoreCase)) { entry = candidate; return true; } } } entry = default; return false; } /// /// Recommends a plate size for the given bounding box. The box's /// spatial axes are normalized to (short, long) so neither the bbox /// orientation nor Box's internal Length/Width naming matters. /// public static PlateSizeResult Recommend(Box bbox, PlateSizeOptions options = null) { var a = bbox.Width; var b = bbox.Length; return Recommend(System.Math.Min(a, b), System.Math.Max(a, b), options); } /// /// Recommends a plate size for the envelope of the given boxes. /// public static PlateSizeResult Recommend(IEnumerable boxes, PlateSizeOptions options = null) { if (boxes == null) throw new ArgumentNullException(nameof(boxes)); var hasAny = false; var minX = double.PositiveInfinity; var minY = double.PositiveInfinity; var maxX = double.NegativeInfinity; var maxY = double.NegativeInfinity; foreach (var box in boxes) { hasAny = true; if (box.Left < minX) minX = box.Left; if (box.Bottom < minY) minY = box.Bottom; if (box.Right > maxX) maxX = box.Right; if (box.Top > maxY) maxY = box.Top; } if (!hasAny) throw new ArgumentException("At least one box is required.", nameof(boxes)); var b = maxX - minX; var a = maxY - minY; return Recommend(System.Math.Min(a, b), System.Math.Max(a, b), options); } /// /// Recommends a plate size for a (width, length) pair. /// Inputs are treated as orientation-independent. /// public static PlateSizeResult Recommend(double width, double length, PlateSizeOptions options = null) { options ??= new PlateSizeOptions(); var w = width + 2 * options.Margin; var l = length + 2 * options.Margin; // Canonicalize (short, long) — Fits handles rotation anyway, but // normalizing lets the below-min comparison use the narrower // MinSheet dimensions consistently. if (w > l) (w, l) = (l, w); // Below full-sheet threshold: snap each dimension up to the nearest increment. if (w <= options.MinSheetWidth && l <= options.MinSheetLength) return SnapResult(w, l, options.SnapIncrement); var catalog = BuildCatalog(options.AllowedSizes); var best = PickBest(catalog, w, l, options.Selection); if (best.HasValue) return new PlateSizeResult(best.Value.Width, best.Value.Length, best.Value.Label); // Nothing in the catalog fits - fall back to snap-up (ad-hoc oversize sheet). return SnapResult(w, l, options.SnapIncrement); } private static PlateSizeResult SnapResult(double width, double length, double increment) { if (increment <= 0) return new PlateSizeResult(width, length, null); return new PlateSizeResult(SnapUp(width, increment), SnapUp(length, increment), null); } private static double SnapUp(double value, double increment) { var steps = System.Math.Ceiling(value / increment); return steps * increment; } private static IReadOnlyList BuildCatalog(IReadOnlyList allowedSizes) { if (allowedSizes == null || allowedSizes.Count == 0) return All; var result = new List(allowedSizes.Count); foreach (var label in allowedSizes) { if (TryParseEntry(label, out var entry)) result.Add(entry); } return result; } private static bool TryParseEntry(string label, out Entry entry) { if (TryGet(label, out entry)) return true; // Accept ad-hoc "WxL" strings (e.g. "50x100", "50 x 100"). if (!string.IsNullOrWhiteSpace(label)) { var parts = label.Split(new[] { 'x', 'X' }, 2); if (parts.Length == 2 && double.TryParse(parts[0].Trim(), System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out var a) && double.TryParse(parts[1].Trim(), System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out var b) && a > 0 && b > 0) { var width = System.Math.Min(a, b); var length = System.Math.Max(a, b); entry = new Entry(label.Trim(), width, length); return true; } } entry = default; return false; } private static Entry? PickBest(IReadOnlyList catalog, double width, double length, PlateSizeSelection selection) { var fitting = catalog.Where(e => e.Fits(width, length)); fitting = selection switch { PlateSizeSelection.NarrowestFirst => fitting.OrderBy(e => e.Width).ThenBy(e => e.Area), _ => fitting.OrderBy(e => e.Area).ThenBy(e => e.Width), }; foreach (var candidate in fitting) return candidate; return null; } } public readonly record struct PlateSizeResult(double Width, double Length, string MatchedLabel) { public bool IsStandard => MatchedLabel != null; } public sealed class PlateSizeOptions { /// /// If the margin-adjusted bounding box fits within MinSheetWidth x MinSheetLength /// the result is snapped to instead of routed to a /// standard sheet. Default 48" x 48". /// public double MinSheetWidth { get; set; } = 48; public double MinSheetLength { get; set; } = 48; /// /// Increment used for below-threshold rounding and oversize fallback. Default 1". /// public double SnapIncrement { get; set; } = 1.0; /// /// Extra clearance added to each side of the bounding box before matching. /// public double Margin { get; set; } = 0; /// /// Optional whitelist. When non-empty, only these sizes are considered. /// Entries may be standard catalog labels (e.g. "48x96") or arbitrary /// "WxL" strings (e.g. "50x100"). /// public IReadOnlyList AllowedSizes { get; set; } /// /// Tiebreaker when multiple sheets can contain the bounding box. /// public PlateSizeSelection Selection { get; set; } = PlateSizeSelection.SmallestArea; } public enum PlateSizeSelection { /// Pick the cheapest sheet that contains the bbox (smallest area). SmallestArea, /// Prefer narrower-width sheets (e.g. 48-wide before 60-wide). NarrowestFirst, } }