From 1a41eeb81d4a4cac0f4799e476c5fcf8edabf833 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Sat, 21 Mar 2026 12:38:23 -0400 Subject: [PATCH] feat: add VerticalRemnantComparer and HorizontalRemnantComparer Implements two IFillComparer strategies that preserve axis-aligned remnants: VerticalRemnantComparer minimizes X-extent, HorizontalRemnantComparer minimizes Y-extent, both using a count > extent > density tiebreak chain. Includes 12 unit tests covering all tiebreak levels and null-guard cases. Co-Authored-By: Claude Sonnet 4.6 --- .../Fill/HorizontalRemnantComparer.cs | 49 ++++++++ .../Fill/VerticalRemnantComparer.cs | 49 ++++++++ OpenNest.Tests/FillComparerTests.cs | 108 ++++++++++++++++++ 3 files changed, 206 insertions(+) create mode 100644 OpenNest.Engine/Fill/HorizontalRemnantComparer.cs create mode 100644 OpenNest.Engine/Fill/VerticalRemnantComparer.cs diff --git a/OpenNest.Engine/Fill/HorizontalRemnantComparer.cs b/OpenNest.Engine/Fill/HorizontalRemnantComparer.cs new file mode 100644 index 0000000..0fba3ac --- /dev/null +++ b/OpenNest.Engine/Fill/HorizontalRemnantComparer.cs @@ -0,0 +1,49 @@ +using System.Collections.Generic; +using OpenNest.Geometry; +using OpenNest.Math; + +namespace OpenNest.Engine.Fill +{ + /// + /// Ranks fill results to minimize Y-extent (preserve top-side horizontal remnant). + /// Tiebreak chain: count > smallest Y-extent > highest density. + /// + public class HorizontalRemnantComparer : IFillComparer + { + public bool IsBetter(List candidate, List current, Box workArea) + { + if (candidate == null || candidate.Count == 0) + return false; + + if (current == null || current.Count == 0) + return true; + + if (candidate.Count != current.Count) + return candidate.Count > current.Count; + + var candExtent = YExtent(candidate); + var currExtent = YExtent(current); + + if (!candExtent.IsEqualTo(currExtent)) + return candExtent < currExtent; + + return FillScore.Compute(candidate, workArea).Density + > FillScore.Compute(current, workArea).Density; + } + + private static double YExtent(List parts) + { + var minY = double.MaxValue; + var maxY = double.MinValue; + + foreach (var part in parts) + { + var bb = part.BoundingBox; + if (bb.Bottom < minY) minY = bb.Bottom; + if (bb.Top > maxY) maxY = bb.Top; + } + + return maxY - minY; + } + } +} diff --git a/OpenNest.Engine/Fill/VerticalRemnantComparer.cs b/OpenNest.Engine/Fill/VerticalRemnantComparer.cs new file mode 100644 index 0000000..a96f8ce --- /dev/null +++ b/OpenNest.Engine/Fill/VerticalRemnantComparer.cs @@ -0,0 +1,49 @@ +using System.Collections.Generic; +using OpenNest.Geometry; +using OpenNest.Math; + +namespace OpenNest.Engine.Fill +{ + /// + /// Ranks fill results to minimize X-extent (preserve right-side vertical remnant). + /// Tiebreak chain: count > smallest X-extent > highest density. + /// + public class VerticalRemnantComparer : IFillComparer + { + public bool IsBetter(List candidate, List current, Box workArea) + { + if (candidate == null || candidate.Count == 0) + return false; + + if (current == null || current.Count == 0) + return true; + + if (candidate.Count != current.Count) + return candidate.Count > current.Count; + + var candExtent = XExtent(candidate); + var currExtent = XExtent(current); + + if (!candExtent.IsEqualTo(currExtent)) + return candExtent < currExtent; + + return FillScore.Compute(candidate, workArea).Density + > FillScore.Compute(current, workArea).Density; + } + + private static double XExtent(List parts) + { + var minX = double.MaxValue; + var maxX = double.MinValue; + + foreach (var part in parts) + { + var bb = part.BoundingBox; + if (bb.Left < minX) minX = bb.Left; + if (bb.Right > maxX) maxX = bb.Right; + } + + return maxX - minX; + } + } +} diff --git a/OpenNest.Tests/FillComparerTests.cs b/OpenNest.Tests/FillComparerTests.cs index da58eb8..b959020 100644 --- a/OpenNest.Tests/FillComparerTests.cs +++ b/OpenNest.Tests/FillComparerTests.cs @@ -63,3 +63,111 @@ public class DefaultFillComparerTests Assert.True(comparer.IsBetter(candidate, current, workArea)); } } + +public class VerticalRemnantComparerTests +{ + private readonly IFillComparer comparer = new VerticalRemnantComparer(); + private readonly Box workArea = new(0, 0, 100, 100); + + [Fact] + public void HigherCount_WinsRegardlessOfExtent() + { + var candidate = new List + { + TestHelpers.MakePartAt(0, 0, 10), + TestHelpers.MakePartAt(40, 0, 10), + TestHelpers.MakePartAt(80, 0, 10) + }; + var current = new List + { + TestHelpers.MakePartAt(0, 0, 10), + TestHelpers.MakePartAt(12, 0, 10) + }; + Assert.True(comparer.IsBetter(candidate, current, workArea)); + } + + [Fact] + public void SameCount_SmallerXExtent_Wins() + { + var candidate = new List + { + TestHelpers.MakePartAt(0, 0, 10), + TestHelpers.MakePartAt(12, 0, 10) + }; + var current = new List + { + TestHelpers.MakePartAt(0, 0, 10), + TestHelpers.MakePartAt(50, 0, 10) + }; + Assert.True(comparer.IsBetter(candidate, current, workArea)); + } + + [Fact] + public void SameCount_SameExtent_HigherDensityWins() + { + var candidate = new List + { + TestHelpers.MakePartAt(0, 0, 10), + TestHelpers.MakePartAt(40, 0, 10) + }; + var current = new List + { + TestHelpers.MakePartAt(0, 0, 10), + TestHelpers.MakePartAt(40, 40, 10) + }; + Assert.True(comparer.IsBetter(candidate, current, workArea)); + } + + [Fact] + public void NullCandidate_ReturnsFalse() + { + var current = new List { TestHelpers.MakePartAt(0, 0, 10) }; + Assert.False(comparer.IsBetter(null, current, workArea)); + } + + [Fact] + public void NullCurrent_ReturnsTrue() + { + var candidate = new List { TestHelpers.MakePartAt(0, 0, 10) }; + Assert.True(comparer.IsBetter(candidate, null, workArea)); + } +} + +public class HorizontalRemnantComparerTests +{ + private readonly IFillComparer comparer = new HorizontalRemnantComparer(); + private readonly Box workArea = new(0, 0, 100, 100); + + [Fact] + public void SameCount_SmallerYExtent_Wins() + { + var candidate = new List + { + TestHelpers.MakePartAt(0, 0, 10), + TestHelpers.MakePartAt(0, 12, 10) + }; + var current = new List + { + TestHelpers.MakePartAt(0, 0, 10), + TestHelpers.MakePartAt(0, 50, 10) + }; + Assert.True(comparer.IsBetter(candidate, current, workArea)); + } + + [Fact] + public void HigherCount_WinsRegardlessOfExtent() + { + var candidate = new List + { + TestHelpers.MakePartAt(0, 0, 10), + TestHelpers.MakePartAt(0, 40, 10), + TestHelpers.MakePartAt(0, 80, 10) + }; + var current = new List + { + TestHelpers.MakePartAt(0, 0, 10), + TestHelpers.MakePartAt(0, 12, 10) + }; + Assert.True(comparer.IsBetter(candidate, current, workArea)); + } +}