From 6ce501da11add30d1920e63fd46e2917b74fb7c7 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Sun, 29 Mar 2026 23:25:40 -0400 Subject: [PATCH] feat: smart strategy skipping, pack rotation, and dual-sort packing - Skip ExtentsFillStrategy for rectangle/circle parts - Skip PairsFillStrategy for circle parts - PackBottomLeft now tries rotated orientation when items don't fit - PackBottomLeft tries both area-descending and length-descending sort orders, keeping whichever places more parts (tighter bbox on tie) - Add user constraint override tests for AngleCandidateBuilder Co-Authored-By: Claude Opus 4.6 (1M context) --- .../RectanglePacking/PackBottomLeft.cs | 61 ++++++++++++++----- .../Strategies/ExtentsFillStrategy.cs | 2 + .../Strategies/PairsFillStrategy.cs | 3 + OpenNest.Tests/AngleCandidateBuilderTests.cs | 42 +++++++++++++ 4 files changed, 93 insertions(+), 15 deletions(-) diff --git a/OpenNest.Engine/RectanglePacking/PackBottomLeft.cs b/OpenNest.Engine/RectanglePacking/PackBottomLeft.cs index 6548432..6017b59 100644 --- a/OpenNest.Engine/RectanglePacking/PackBottomLeft.cs +++ b/OpenNest.Engine/RectanglePacking/PackBottomLeft.cs @@ -1,4 +1,4 @@ -using OpenNest.Geometry; +using OpenNest.Geometry; using OpenNest.Math; using System.Collections.Generic; using System.Linq; @@ -7,33 +7,50 @@ namespace OpenNest.RectanglePacking { internal class PackBottomLeft : PackEngine { - private List points; - public PackBottomLeft(Bin bin) : base(bin) { - points = new List(); } public override void Pack(List items) { - items = items.OrderBy(i => -i.Area()).ToList(); + var byArea = items.Select(i => i.Clone() as Item).OrderByDescending(i => i.Area()).ToList(); + var byLength = items.Select(i => i.Clone() as Item).OrderByDescending(i => System.Math.Max(i.Width, i.Length)).ToList(); - points.Add(Bin.Location); + var resultA = PackWithOrder(byArea); + var resultB = PackWithOrder(byLength); + var winner = PickWinner(resultA, resultB); + + Bin.Items.AddRange(winner); + } + + private List PackWithOrder(List items) + { + var points = new List { Bin.Location }; + var placed = new List(); var skip = new List(); - for (int i = 0; i < items.Count; i++) + for (var i = 0; i < items.Count; i++) { var item = items[i]; if (skip.Contains(item.Id)) continue; - var pt = FindPointVertical(item); + var pt = FindPointVertical(item, points, placed); + + // If it doesn't fit, try rotated. + if (pt == null) + { + item.Rotate(); + pt = FindPointVertical(item, points, placed); + } if (pt == null) { + if (item.IsRotated) + item.Rotate(); skip.Add(item.Id); continue; } @@ -44,23 +61,37 @@ namespace OpenNest.RectanglePacking points.Add(new Vector(item.Left, item.Top)); points.Add(new Vector(item.Right, item.Bottom)); - Bin.Items.Add(item); + placed.Add(item); } - points.Clear(); + return placed; } - private Vector? FindPointVertical(Item item) + private static List PickWinner(List a, List b) + { + if (a.Count != b.Count) + return a.Count > b.Count ? a : b; + + if (a.Count == 0) + return a; + + var areaA = a.GetBoundingBox().Area(); + var areaB = b.GetBoundingBox().Area(); + + return areaB < areaA ? b : a; + } + + private Vector? FindPointVertical(Item item, List points, List placed) { var pt = new Vector(double.MaxValue, double.MaxValue); - for (int i = 0; i < points.Count; i++) + for (var i = 0; i < points.Count; i++) { var point = points[i]; item.Location = point; - if (!IsValid(item)) + if (!IsValid(item, placed)) continue; if (point.X < pt.X) @@ -75,12 +106,12 @@ namespace OpenNest.RectanglePacking return null; } - private bool IsValid(Item item) + private bool IsValid(Item item, List placed) { if (!Bin.Contains(item)) return false; - foreach (var it in Bin.Items) + foreach (var it in placed) { if (item.Intersects(it)) return false; diff --git a/OpenNest.Engine/Strategies/ExtentsFillStrategy.cs b/OpenNest.Engine/Strategies/ExtentsFillStrategy.cs index 3c4f487..4530df3 100644 --- a/OpenNest.Engine/Strategies/ExtentsFillStrategy.cs +++ b/OpenNest.Engine/Strategies/ExtentsFillStrategy.cs @@ -12,6 +12,8 @@ namespace OpenNest.Engine.Strategies public List Fill(FillContext context) { + if (context.PartType == PartType.Rectangle || context.PartType == PartType.Circle) + return null; var filler = new FillExtents(context.WorkArea, context.Plate.PartSpacing); var bestRotation = context.SharedState.TryGetValue("BestRotation", out var rot) diff --git a/OpenNest.Engine/Strategies/PairsFillStrategy.cs b/OpenNest.Engine/Strategies/PairsFillStrategy.cs index 4fda406..e9b7f0a 100644 --- a/OpenNest.Engine/Strategies/PairsFillStrategy.cs +++ b/OpenNest.Engine/Strategies/PairsFillStrategy.cs @@ -20,6 +20,9 @@ namespace OpenNest.Engine.Strategies if (active.Value) return null; + if (context.PartType == PartType.Circle) + return null; + active.Value = true; try { diff --git a/OpenNest.Tests/AngleCandidateBuilderTests.cs b/OpenNest.Tests/AngleCandidateBuilderTests.cs index ba9acc1..a3af60c 100644 --- a/OpenNest.Tests/AngleCandidateBuilderTests.cs +++ b/OpenNest.Tests/AngleCandidateBuilderTests.cs @@ -1,6 +1,7 @@ using OpenNest.Engine; using OpenNest.Engine.Fill; using OpenNest.Geometry; +using OpenNest.Math; namespace OpenNest.Tests; @@ -110,4 +111,45 @@ public class AngleCandidateBuilderTests Assert.Single(angles); Assert.Equal(0, angles[0]); } + + [Fact] + public void Build_UserConstraints_OverrideRectangleClassification() + { + var builder = new AngleCandidateBuilder(); + var item = new NestItem + { + Drawing = MakeRectDrawing(100, 50), + RotationStart = Angle.ToRadians(10), + RotationEnd = Angle.ToRadians(90), + StepAngle = Angle.ToRadians(10), + }; + var classification = MakeClassification(0, PartType.Rectangle); + var workArea = new Box(0, 0, 1000, 500); + + var angles = builder.Build(item, classification, workArea); + + Assert.True(angles.Count > 2, + $"User constraints should override rect classification, got {angles.Count} angles"); + } + + [Fact] + public void Build_UserConstraints_StartingAtZero_AreRespected() + { + var builder = new AngleCandidateBuilder(); + var item = new NestItem + { + Drawing = MakeRectDrawing(100, 50), + RotationStart = 0, + RotationEnd = System.Math.PI, + StepAngle = Angle.ToRadians(45), + }; + var classification = MakeClassification(0, PartType.Rectangle); + var workArea = new Box(0, 0, 1000, 500); + + var angles = builder.Build(item, classification, workArea); + + // Start=0, End=PI is NOT "no constraints" — it's a real 0-180 range + Assert.True(angles.Count > 2, + $"0-to-PI constraint should produce multiple angles, got {angles.Count}"); + } }