diff --git a/OpenNest.Core/Plate.cs b/OpenNest.Core/Plate.cs
index 8e58ea9..e5a3714 100644
--- a/OpenNest.Core/Plate.cs
+++ b/OpenNest.Core/Plate.cs
@@ -1,6 +1,7 @@
using OpenNest.Collections;
using OpenNest.Geometry;
using OpenNest.Math;
+using OpenNest.Shapes;
using System;
using System.Collections.Generic;
using System.Linq;
@@ -548,6 +549,65 @@ namespace OpenNest
Rounding.RoundUpToNearest(xExtent, roundingFactor));
}
+ ///
+ /// Sizes the plate using the catalog: small
+ /// layouts snap to an increment, larger ones round up to the next
+ /// standard mill sheet. The plate's long-axis orientation (X vs Y)
+ /// is preserved. Does nothing if the plate has no parts.
+ ///
+ public PlateSizeResult SnapToStandardSize(PlateSizeOptions options = null)
+ {
+ if (Parts.Count == 0)
+ return default;
+
+ var bounds = Parts.GetBoundingBox();
+
+ // Quadrant-aware extents relative to the plate origin, matching AutoSize.
+ double xExtent;
+ double yExtent;
+
+ switch (Quadrant)
+ {
+ case 1:
+ xExtent = System.Math.Abs(bounds.Right) + EdgeSpacing.Right;
+ yExtent = System.Math.Abs(bounds.Top) + EdgeSpacing.Top;
+ break;
+
+ case 2:
+ xExtent = System.Math.Abs(bounds.Left) + EdgeSpacing.Left;
+ yExtent = System.Math.Abs(bounds.Top) + EdgeSpacing.Top;
+ break;
+
+ case 3:
+ xExtent = System.Math.Abs(bounds.Left) + EdgeSpacing.Left;
+ yExtent = System.Math.Abs(bounds.Bottom) + EdgeSpacing.Bottom;
+ break;
+
+ case 4:
+ xExtent = System.Math.Abs(bounds.Right) + EdgeSpacing.Right;
+ yExtent = System.Math.Abs(bounds.Bottom) + EdgeSpacing.Bottom;
+ break;
+
+ default:
+ return default;
+ }
+
+ // PlateSizes.Recommend takes (short, long); canonicalize then map
+ // the result back so the plate's long axis stays aligned with the
+ // parts' long axis.
+ var shortDim = System.Math.Min(xExtent, yExtent);
+ var longDim = System.Math.Max(xExtent, yExtent);
+ var result = PlateSizes.Recommend(shortDim, longDim, options);
+
+ // Plate convention: Length = X axis, Width = Y axis.
+ if (xExtent >= yExtent)
+ Size = new Size(result.Width, result.Length); // X is the long axis
+ else
+ Size = new Size(result.Length, result.Width); // Y is the long axis
+
+ return result;
+ }
+
///
/// Gets the area of the top surface of the plate.
///
diff --git a/OpenNest.Core/Shapes/PlateSizes.cs b/OpenNest.Core/Shapes/PlateSizes.cs
new file mode 100644
index 0000000..08af25d
--- /dev/null
+++ b/OpenNest.Core/Shapes/PlateSizes.cs
@@ -0,0 +1,255 @@
+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,
+ }
+}
diff --git a/OpenNest.Tests/PlateSnapToStandardSizeTests.cs b/OpenNest.Tests/PlateSnapToStandardSizeTests.cs
new file mode 100644
index 0000000..b4a9cd1
--- /dev/null
+++ b/OpenNest.Tests/PlateSnapToStandardSizeTests.cs
@@ -0,0 +1,118 @@
+using OpenNest.CNC;
+using OpenNest.Geometry;
+using OpenNest.Shapes;
+
+namespace OpenNest.Tests;
+
+public class PlateSnapToStandardSizeTests
+{
+ private static Part MakeRectPart(double x, double y, double length, double width)
+ {
+ var pgm = new Program();
+ pgm.Codes.Add(new RapidMove(new Vector(0, 0)));
+ pgm.Codes.Add(new LinearMove(new Vector(length, 0)));
+ pgm.Codes.Add(new LinearMove(new Vector(length, width)));
+ pgm.Codes.Add(new LinearMove(new Vector(0, width)));
+ pgm.Codes.Add(new LinearMove(new Vector(0, 0)));
+ var drawing = new Drawing("test", pgm);
+ var part = new Part(drawing);
+ part.Offset(x, y);
+ return part;
+ }
+
+ [Fact]
+ public void SnapToStandardSize_SmallParts_SnapsToIncrement()
+ {
+ var plate = new Plate(200, 200); // oversized starting size
+ plate.Parts.Add(MakeRectPart(0, 0, 10, 20));
+
+ var result = plate.SnapToStandardSize();
+
+ // 10x20 is well below 48x48 MinSheet -> snap to integer increment.
+ Assert.Null(result.MatchedLabel);
+ Assert.Equal(10, plate.Size.Length); // X axis
+ Assert.Equal(20, plate.Size.Width); // Y axis
+ }
+
+ [Fact]
+ public void SnapToStandardSize_SmallPartsWithFractionalIncrement_UsesIncrement()
+ {
+ var plate = new Plate(200, 200);
+ plate.Parts.Add(MakeRectPart(0, 0, 10.3, 20.7));
+
+ var result = plate.SnapToStandardSize(new PlateSizeOptions { SnapIncrement = 0.25 });
+
+ Assert.Null(result.MatchedLabel);
+ Assert.Equal(10.5, plate.Size.Length, 4);
+ Assert.Equal(20.75, plate.Size.Width, 4);
+ }
+
+ [Fact]
+ public void SnapToStandardSize_40x90Part_SnapsToStandard48x96_XLong()
+ {
+ // Part is 90 long (X) x 40 wide (Y) -> X is the long axis.
+ var plate = new Plate(200, 200);
+ plate.Parts.Add(MakeRectPart(0, 0, 90, 40));
+
+ var result = plate.SnapToStandardSize();
+
+ Assert.Equal("48x96", result.MatchedLabel);
+ Assert.Equal(96, plate.Size.Length); // X axis = long
+ Assert.Equal(48, plate.Size.Width); // Y axis = short
+ }
+
+ [Fact]
+ public void SnapToStandardSize_90TallPart_SnapsToStandard48x96_YLong()
+ {
+ // Part is 40 long (X) x 90 wide (Y) -> Y is the long axis.
+ var plate = new Plate(200, 200);
+ plate.Parts.Add(MakeRectPart(0, 0, 40, 90));
+
+ var result = plate.SnapToStandardSize();
+
+ Assert.Equal("48x96", result.MatchedLabel);
+ Assert.Equal(48, plate.Size.Length); // X axis = short
+ Assert.Equal(96, plate.Size.Width); // Y axis = long
+ }
+
+ [Fact]
+ public void SnapToStandardSize_JustOver48_PicksNextStandardSize()
+ {
+ var plate = new Plate(200, 200);
+ plate.Parts.Add(MakeRectPart(0, 0, 100, 50));
+
+ var result = plate.SnapToStandardSize();
+
+ Assert.Equal("60x120", result.MatchedLabel);
+ Assert.Equal(120, plate.Size.Length); // X long
+ Assert.Equal(60, plate.Size.Width);
+ }
+
+ [Fact]
+ public void SnapToStandardSize_EmptyPlate_DoesNotModifySize()
+ {
+ var plate = new Plate(60, 120);
+
+ var result = plate.SnapToStandardSize();
+
+ Assert.Null(result.MatchedLabel);
+ Assert.Equal(60, plate.Size.Width);
+ Assert.Equal(120, plate.Size.Length);
+ }
+
+ [Fact]
+ public void SnapToStandardSize_MultipleParts_UsesCombinedEnvelope()
+ {
+ var plate = new Plate(200, 200);
+ plate.Parts.Add(MakeRectPart(0, 0, 30, 40));
+ plate.Parts.Add(MakeRectPart(30, 0, 30, 40)); // combined X-extent = 60
+ plate.Parts.Add(MakeRectPart(0, 40, 60, 60)); // combined extent = 60 x 100
+
+ var result = plate.SnapToStandardSize();
+
+ // 60 x 100 fits 60x120 standard sheet, Y is the long axis.
+ Assert.Equal("60x120", result.MatchedLabel);
+ Assert.Equal(60, plate.Size.Length); // X
+ Assert.Equal(120, plate.Size.Width); // Y long
+ }
+}
diff --git a/OpenNest.Tests/Shapes/PlateSizesTests.cs b/OpenNest.Tests/Shapes/PlateSizesTests.cs
new file mode 100644
index 0000000..7b3e0a7
--- /dev/null
+++ b/OpenNest.Tests/Shapes/PlateSizesTests.cs
@@ -0,0 +1,311 @@
+using System.Collections.Generic;
+using System.Linq;
+using OpenNest.Geometry;
+using OpenNest.Shapes;
+
+namespace OpenNest.Tests.Shapes;
+
+public class PlateSizesTests
+{
+ [Fact]
+ public void All_IsNotEmpty()
+ {
+ Assert.NotEmpty(PlateSizes.All);
+ }
+
+ [Fact]
+ public void All_DoesNotContain48x48()
+ {
+ // 48x48 is not a standard sheet - it's the default MinSheet threshold only.
+ Assert.DoesNotContain(PlateSizes.All, e => e.Width == 48 && e.Length == 48);
+ }
+
+ [Fact]
+ public void All_Smallest_Is48x96()
+ {
+ var smallest = PlateSizes.All.OrderBy(e => e.Area).First();
+ Assert.Equal(48, smallest.Width);
+ Assert.Equal(96, smallest.Length);
+ }
+
+ [Fact]
+ public void All_SortedByAreaAscending()
+ {
+ for (var i = 1; i < PlateSizes.All.Count; i++)
+ Assert.True(PlateSizes.All[i].Area >= PlateSizes.All[i - 1].Area);
+ }
+
+ [Fact]
+ public void All_Entries_AreCanonical_WidthLessOrEqualLength()
+ {
+ foreach (var entry in PlateSizes.All)
+ Assert.True(entry.Width <= entry.Length, $"{entry.Label} not in canonical orientation");
+ }
+
+ [Theory]
+ [InlineData(40, 40, true)] // small - fits trivially
+ [InlineData(48, 96, true)] // exact
+ [InlineData(96, 48, true)] // rotated exact
+ [InlineData(90, 40, true)] // rotated
+ [InlineData(49, 97, false)] // just over in both dims
+ [InlineData(50, 50, false)] // too wide in both orientations
+ public void Entry_Fits_RespectsRotation(double w, double h, bool expected)
+ {
+ var entry = new PlateSizes.Entry("48x96", 48, 96);
+ Assert.Equal(expected, entry.Fits(w, h));
+ }
+
+ [Fact]
+ public void TryGet_KnownLabel_ReturnsEntry()
+ {
+ Assert.True(PlateSizes.TryGet("48x96", out var entry));
+ Assert.Equal(48, entry.Width);
+ Assert.Equal(96, entry.Length);
+ }
+
+ [Fact]
+ public void TryGet_IsCaseInsensitive()
+ {
+ Assert.True(PlateSizes.TryGet("48X96", out var entry));
+ Assert.Equal(48, entry.Width);
+ Assert.Equal(96, entry.Length);
+ }
+
+ [Fact]
+ public void TryGet_UnknownLabel_ReturnsFalse()
+ {
+ Assert.False(PlateSizes.TryGet("bogus", out _));
+ }
+
+ [Fact]
+ public void Recommend_BelowMin_SnapsToDefaultIncrementOfOne()
+ {
+ var bbox = new Box(0, 0, 10.3, 20.7);
+
+ var result = PlateSizes.Recommend(bbox);
+
+ Assert.Equal(11, result.Width);
+ Assert.Equal(21, result.Length);
+ Assert.Null(result.MatchedLabel);
+ }
+
+ [Fact]
+ public void Recommend_BelowMin_UsesCustomIncrement()
+ {
+ var bbox = new Box(0, 0, 10.3, 20.7);
+ var options = new PlateSizeOptions { SnapIncrement = 0.25 };
+
+ var result = PlateSizes.Recommend(bbox, options);
+
+ Assert.Equal(10.5, result.Width, 4);
+ Assert.Equal(20.75, result.Length, 4);
+ Assert.Null(result.MatchedLabel);
+ }
+
+ [Fact]
+ public void Recommend_ExactlyAtMin_Snaps()
+ {
+ var bbox = new Box(0, 0, 48, 48);
+
+ var result = PlateSizes.Recommend(bbox);
+
+ Assert.Equal(48, result.Width);
+ Assert.Equal(48, result.Length);
+ Assert.Null(result.MatchedLabel);
+ }
+
+ [Fact]
+ public void Recommend_AboveMin_PicksSmallestContainingStandardSheet()
+ {
+ var bbox = new Box(0, 0, 40, 90);
+
+ var result = PlateSizes.Recommend(bbox);
+
+ Assert.Equal(48, result.Width);
+ Assert.Equal(96, result.Length);
+ Assert.Equal("48x96", result.MatchedLabel);
+ }
+
+ [Fact]
+ public void Recommend_AboveMin_WithRotation_PicksSmallestSheet()
+ {
+ var bbox = new Box(0, 0, 90, 40);
+
+ var result = PlateSizes.Recommend(bbox);
+
+ Assert.Equal("48x96", result.MatchedLabel);
+ }
+
+ [Fact]
+ public void Recommend_JustOver48_PicksNextStandardSize()
+ {
+ var bbox = new Box(0, 0, 50, 100);
+
+ var result = PlateSizes.Recommend(bbox);
+
+ Assert.Equal(60, result.Width);
+ Assert.Equal(120, result.Length);
+ Assert.Equal("60x120", result.MatchedLabel);
+ }
+
+ [Fact]
+ public void Recommend_MarginIsAppliedPerSide()
+ {
+ // 46 + 2*1 = 48 (fits exactly), 94 + 2*1 = 96 (fits exactly)
+ var bbox = new Box(0, 0, 46, 94);
+ var options = new PlateSizeOptions { Margin = 1 };
+
+ var result = PlateSizes.Recommend(bbox, options);
+
+ Assert.Equal("48x96", result.MatchedLabel);
+ }
+
+ [Fact]
+ public void Recommend_MarginPushesToNextSheet()
+ {
+ // 47 + 2 = 49 > 48, so 48x96 no longer fits -> next standard
+ var bbox = new Box(0, 0, 47, 95);
+ var options = new PlateSizeOptions { Margin = 1 };
+
+ var result = PlateSizes.Recommend(bbox, options);
+
+ Assert.NotEqual("48x96", result.MatchedLabel);
+ Assert.True(result.Width >= 49);
+ Assert.True(result.Length >= 97);
+ }
+
+ [Fact]
+ public void Recommend_AllowedSizes_StandardLabelWhitelist()
+ {
+ // 60x120 is the only option; 50x50 is above min so it routes to standard
+ var bbox = new Box(0, 0, 50, 50);
+ var options = new PlateSizeOptions { AllowedSizes = new[] { "60x120" } };
+
+ var result = PlateSizes.Recommend(bbox, options);
+
+ Assert.Equal("60x120", result.MatchedLabel);
+ }
+
+ [Fact]
+ public void Recommend_AllowedSizes_ArbitraryWxHString()
+ {
+ // 50x100 isn't in the standard catalog but is valid as an ad-hoc entry.
+ // bbox 49x99 doesn't fit 48x96 or 48x120, does fit 50x100 and 60x120,
+ // but only 50x100 is allowed.
+ var bbox = new Box(0, 0, 49, 99);
+ var options = new PlateSizeOptions { AllowedSizes = new[] { "50x100" } };
+
+ var result = PlateSizes.Recommend(bbox, options);
+
+ Assert.Equal(50, result.Width);
+ Assert.Equal(100, result.Length);
+ Assert.Equal("50x100", result.MatchedLabel);
+ }
+
+ [Fact]
+ public void Recommend_NothingFits_FallsBackToSnapUp()
+ {
+ // Larger than any catalog sheet
+ var bbox = new Box(0, 0, 100, 300);
+
+ var result = PlateSizes.Recommend(bbox);
+
+ Assert.Equal(100, result.Width);
+ Assert.Equal(300, result.Length);
+ Assert.Null(result.MatchedLabel);
+ }
+
+ [Fact]
+ public void Recommend_NothingFitsInAllowedList_FallsBackToSnapUp()
+ {
+ // Only 48x96 allowed, but bbox is too big for it
+ var bbox = new Box(0, 0, 50, 100);
+ var options = new PlateSizeOptions { AllowedSizes = new[] { "48x96" } };
+
+ var result = PlateSizes.Recommend(bbox, options);
+
+ Assert.Equal(50, result.Width);
+ Assert.Equal(100, result.Length);
+ Assert.Null(result.MatchedLabel);
+ }
+
+ [Fact]
+ public void Recommend_BoxEnumerable_CombinesIntoEnvelope()
+ {
+ // Two boxes that together span 0..40 x 0..90 -> fits 48x96
+ var boxes = new[]
+ {
+ new Box(0, 0, 40, 50),
+ new Box(0, 40, 30, 50),
+ };
+
+ var result = PlateSizes.Recommend(boxes);
+
+ Assert.Equal("48x96", result.MatchedLabel);
+ }
+
+ [Fact]
+ public void Recommend_BoxEnumerable_Empty_Throws()
+ {
+ Assert.Throws(
+ () => PlateSizes.Recommend(System.Array.Empty()));
+ }
+
+ [Fact]
+ public void PlateSizeOptions_Defaults()
+ {
+ var options = new PlateSizeOptions();
+
+ Assert.Equal(48, options.MinSheetWidth);
+ Assert.Equal(48, options.MinSheetLength);
+ Assert.Equal(1.0, options.SnapIncrement);
+ Assert.Equal(0, options.Margin);
+ Assert.Null(options.AllowedSizes);
+ Assert.Equal(PlateSizeSelection.SmallestArea, options.Selection);
+ }
+
+ [Fact]
+ public void Recommend_NarrowestFirst_PicksNarrowerSheetOverSmallerArea()
+ {
+ // Hypothetical: bbox (47, 47) fits both 48x96 (area 4608) and some narrower option.
+ // With SmallestArea: picks 48x96 (it's already the smallest 48-wide).
+ // With NarrowestFirst: also picks 48x96 since that's the narrowest.
+ // Better test: AllowedSizes = ["60x120", "48x120"] with bbox that fits both.
+ // 48x120 (area 5760) is narrower; 60x120 (area 7200) has more area.
+ // SmallestArea picks 48x120; NarrowestFirst also picks 48x120. Both pick the same.
+ //
+ // Real divergence: AllowedSizes = ["60x120", "72x120"] with bbox 55x100.
+ // 60x120 has narrower width (60) AND smaller area (7200 vs 8640), so both agree.
+ //
+ // To force divergence: AllowedSizes = ["60x96", "48x144"] with bbox 47x95.
+ // 60x96 area = 5760, 48x144 area = 6912. SmallestArea -> 60x96.
+ // NarrowestFirst width 48 < 60 -> 48x144.
+ var bbox = new Box(0, 0, 47, 95);
+ var options = new PlateSizeOptions
+ {
+ AllowedSizes = new[] { "60x96", "48x144" },
+ Selection = PlateSizeSelection.NarrowestFirst,
+ };
+
+ var result = PlateSizes.Recommend(bbox, options);
+
+ Assert.Equal(48, result.Width);
+ Assert.Equal(144, result.Length);
+ }
+
+ [Fact]
+ public void Recommend_SmallestArea_PicksSmallerAreaOverNarrowerWidth()
+ {
+ var bbox = new Box(0, 0, 47, 95);
+ var options = new PlateSizeOptions
+ {
+ AllowedSizes = new[] { "60x96", "48x144" },
+ Selection = PlateSizeSelection.SmallestArea,
+ };
+
+ var result = PlateSizes.Recommend(bbox, options);
+
+ Assert.Equal(60, result.Width);
+ Assert.Equal(96, result.Length);
+ }
+}
diff --git a/OpenNest/Forms/EditNestForm.cs b/OpenNest/Forms/EditNestForm.cs
index 845ea22..f26abcc 100644
--- a/OpenNest/Forms/EditNestForm.cs
+++ b/OpenNest/Forms/EditNestForm.cs
@@ -7,6 +7,7 @@ using OpenNest.Engine.Sequencing;
using OpenNest.IO;
using OpenNest.Math;
using OpenNest.Properties;
+using OpenNest.Shapes;
using System;
using System.ComponentModel;
using System.Diagnostics;
@@ -453,7 +454,11 @@ namespace OpenNest.Forms
public void ResizePlateToFitParts()
{
- PlateView.Plate.AutoSize(Settings.Default.AutoSizePlateFactor);
+ var options = new PlateSizeOptions
+ {
+ SnapIncrement = Settings.Default.AutoSizePlateFactor,
+ };
+ PlateView.Plate.SnapToStandardSize(options);
PlateView.ZoomToPlate();
PlateView.Refresh();
UpdatePlateList();