Two bugs caused the remnant finder to miss valid empty regions: 1. RemoveDominated used an 80% overlap-area threshold that incorrectly removed L-shaped remnants. A tall strip to one side would "dominate" wide strips above/below it even though they represent different usable space. Replaced with geometric containment check — only remove a box if it's fully inside a larger one. 2. FindTieredRemnants split remnants at the obstacle envelope boundary, and both pieces could fall below minDimension even though the original remnant passed the filter (e.g., 6.6" remnant split into 5.35" + 1.25" with minDim=5.38"). Added fallback to keep the original unsplit. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
371 lines
13 KiB
C#
371 lines
13 KiB
C#
using OpenNest.Geometry;
|
|
using OpenNest.IO;
|
|
|
|
namespace OpenNest.Tests;
|
|
|
|
public class RemnantFinderTests
|
|
{
|
|
[Fact]
|
|
public void EmptyPlate_ReturnsWholeWorkArea()
|
|
{
|
|
var finder = new RemnantFinder(new Box(0, 0, 100, 100));
|
|
var remnants = finder.FindRemnants();
|
|
|
|
Assert.Single(remnants);
|
|
Assert.Equal(100 * 100, remnants[0].Area(), 0.1);
|
|
}
|
|
|
|
[Fact]
|
|
public void SingleObstacle_InCorner_FindsLShapedRemnants()
|
|
{
|
|
var finder = new RemnantFinder(new Box(0, 0, 100, 100));
|
|
finder.AddObstacle(new Box(0, 0, 40, 40));
|
|
var remnants = finder.FindRemnants();
|
|
|
|
Assert.True(remnants.Count >= 2);
|
|
var largest = remnants[0];
|
|
Assert.Equal(60 * 100, largest.Area(), 0.1);
|
|
}
|
|
|
|
[Fact]
|
|
public void SingleObstacle_InCenter_FindsFourRemnants()
|
|
{
|
|
var finder = new RemnantFinder(new Box(0, 0, 100, 100));
|
|
finder.AddObstacle(new Box(30, 30, 40, 40));
|
|
var remnants = finder.FindRemnants();
|
|
|
|
Assert.True(remnants.Count >= 4);
|
|
}
|
|
|
|
[Fact]
|
|
public void MinDimension_FiltersSmallRemnants()
|
|
{
|
|
var finder = new RemnantFinder(new Box(0, 0, 100, 100));
|
|
finder.AddObstacle(new Box(0, 0, 95, 100));
|
|
var all = finder.FindRemnants(0);
|
|
var filtered = finder.FindRemnants(10);
|
|
|
|
Assert.True(all.Count > filtered.Count);
|
|
foreach (var r in filtered)
|
|
{
|
|
Assert.True(r.Width >= 10);
|
|
Assert.True(r.Length >= 10);
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void ResultsSortedByAreaDescending()
|
|
{
|
|
var finder = new RemnantFinder(new Box(0, 0, 100, 100));
|
|
finder.AddObstacle(new Box(0, 0, 50, 50));
|
|
var remnants = finder.FindRemnants();
|
|
|
|
for (var i = 1; i < remnants.Count; i++)
|
|
Assert.True(remnants[i - 1].Area() >= remnants[i].Area());
|
|
}
|
|
|
|
[Fact]
|
|
public void AddObstacle_UpdatesResults()
|
|
{
|
|
var finder = new RemnantFinder(new Box(0, 0, 100, 100));
|
|
var before = finder.FindRemnants();
|
|
Assert.Single(before);
|
|
|
|
finder.AddObstacle(new Box(0, 0, 50, 50));
|
|
var after = finder.FindRemnants();
|
|
Assert.True(after.Count > 1);
|
|
}
|
|
|
|
[Fact]
|
|
public void ClearObstacles_ResetsToFullWorkArea()
|
|
{
|
|
var finder = new RemnantFinder(new Box(0, 0, 100, 100));
|
|
finder.AddObstacle(new Box(0, 0, 50, 50));
|
|
finder.ClearObstacles();
|
|
var remnants = finder.FindRemnants();
|
|
|
|
Assert.Single(remnants);
|
|
Assert.Equal(100 * 100, remnants[0].Area(), 0.1);
|
|
}
|
|
|
|
[Fact]
|
|
public void FullyCovered_ReturnsEmpty()
|
|
{
|
|
var finder = new RemnantFinder(new Box(0, 0, 100, 100));
|
|
finder.AddObstacle(new Box(0, 0, 100, 100));
|
|
var remnants = finder.FindRemnants();
|
|
|
|
Assert.Empty(remnants);
|
|
}
|
|
|
|
[Fact]
|
|
public void MultipleObstacles_FindsGapBetween()
|
|
{
|
|
var finder = new RemnantFinder(new Box(0, 0, 100, 100));
|
|
finder.AddObstacle(new Box(0, 0, 40, 100));
|
|
finder.AddObstacle(new Box(60, 0, 40, 100));
|
|
var remnants = finder.FindRemnants();
|
|
|
|
var gap = remnants.FirstOrDefault(r =>
|
|
r.Width >= 19.9 && r.Width <= 20.1 &&
|
|
r.Length >= 99.9);
|
|
Assert.NotNull(gap);
|
|
}
|
|
|
|
[Fact]
|
|
public void FromPlate_CreatesFinderWithPartsAsObstacles()
|
|
{
|
|
var plate = TestHelpers.MakePlate(60, 120,
|
|
TestHelpers.MakePartAt(0, 0, 20));
|
|
var finder = RemnantFinder.FromPlate(plate);
|
|
var remnants = finder.FindRemnants();
|
|
|
|
Assert.True(remnants.Count >= 1);
|
|
Assert.True(remnants[0].Area() < plate.WorkArea().Area());
|
|
}
|
|
|
|
[Fact]
|
|
public void ObstacleOutsideWorkArea_IsIgnored()
|
|
{
|
|
var finder = new RemnantFinder(new Box(0, 0, 100, 100));
|
|
finder.AddObstacle(new Box(200, 200, 50, 50));
|
|
var remnants = finder.FindRemnants();
|
|
|
|
Assert.Single(remnants);
|
|
Assert.Equal(100 * 100, remnants[0].Area(), 0.1);
|
|
}
|
|
|
|
[Fact]
|
|
public void ObstaclePartiallyOutsideWorkArea_IsClipped()
|
|
{
|
|
var finder = new RemnantFinder(new Box(0, 0, 100, 100));
|
|
// Obstacle extends 20 units past the right edge
|
|
finder.AddObstacle(new Box(80, 0, 40, 100));
|
|
var remnants = finder.FindRemnants();
|
|
|
|
// Should find the 80x100 strip on the left
|
|
var left = remnants.FirstOrDefault(r =>
|
|
r.Width >= 79.9 && r.Width <= 80.1 &&
|
|
r.Length >= 99.9);
|
|
Assert.NotNull(left);
|
|
}
|
|
|
|
[Fact]
|
|
public void OverlappingObstacles_HandledCorrectly()
|
|
{
|
|
var finder = new RemnantFinder(new Box(0, 0, 100, 100));
|
|
finder.AddObstacle(new Box(0, 0, 60, 60));
|
|
finder.AddObstacle(new Box(40, 40, 60, 60)); // overlaps first
|
|
var remnants = finder.FindRemnants();
|
|
|
|
// No remnant should overlap either obstacle
|
|
foreach (var r in remnants)
|
|
{
|
|
Assert.False(
|
|
r.Left < 60 && r.Right > 0 && r.Bottom < 60 && r.Top > 0
|
|
&& r.Left < 100 && r.Right > 40 && r.Bottom < 100 && r.Top > 40,
|
|
"Remnant should not overlap both obstacles simultaneously in their shared region");
|
|
}
|
|
|
|
// Total remnant area + obstacle coverage should not exceed work area
|
|
var totalRemnantArea = remnants.Sum(r => r.Area());
|
|
Assert.True(totalRemnantArea < 100 * 100);
|
|
Assert.True(totalRemnantArea > 0);
|
|
}
|
|
|
|
[Fact]
|
|
public void ConstructorWithObstaclesList()
|
|
{
|
|
var obstacles = new List<Box>
|
|
{
|
|
new Box(0, 0, 40, 100),
|
|
new Box(60, 0, 40, 100)
|
|
};
|
|
var finder = new RemnantFinder(new Box(0, 0, 100, 100), obstacles);
|
|
var remnants = finder.FindRemnants();
|
|
|
|
var gap = remnants.FirstOrDefault(r =>
|
|
r.Width >= 19.9 && r.Width <= 20.1);
|
|
Assert.NotNull(gap);
|
|
}
|
|
|
|
[Fact]
|
|
public void AddObstacles_Plural_AddsMultiple()
|
|
{
|
|
var finder = new RemnantFinder(new Box(0, 0, 100, 100));
|
|
finder.AddObstacles(new[]
|
|
{
|
|
new Box(0, 0, 40, 100),
|
|
new Box(60, 0, 40, 100)
|
|
});
|
|
var remnants = finder.FindRemnants();
|
|
|
|
var gap = remnants.FirstOrDefault(r =>
|
|
r.Width >= 19.9 && r.Width <= 20.1);
|
|
Assert.NotNull(gap);
|
|
}
|
|
|
|
[Fact]
|
|
public void IterativeWorkflow_AddObstacleThenRequery()
|
|
{
|
|
var finder = new RemnantFinder(new Box(0, 0, 100, 100));
|
|
|
|
// First fill: obstacle in bottom-left
|
|
finder.AddObstacle(new Box(0, 0, 50, 50));
|
|
var pass1 = finder.FindRemnants();
|
|
Assert.True(pass1.Count >= 2);
|
|
|
|
// Simulate filling the largest remnant by adding another obstacle
|
|
var largest = pass1[0];
|
|
finder.AddObstacle(new Box(largest.X, largest.Y, largest.Width / 2, largest.Length));
|
|
var pass2 = finder.FindRemnants();
|
|
|
|
// Should have more, smaller remnants now
|
|
var pass2TotalArea = pass2.Sum(r => r.Area());
|
|
var pass1TotalArea = pass1.Sum(r => r.Area());
|
|
Assert.True(pass2TotalArea < pass1TotalArea);
|
|
}
|
|
|
|
[Fact]
|
|
public void NoRemnantOverlapsObstacle()
|
|
{
|
|
var finder = new RemnantFinder(new Box(0, 0, 100, 100));
|
|
finder.AddObstacle(new Box(20, 20, 30, 30));
|
|
finder.AddObstacle(new Box(60, 10, 25, 80));
|
|
var remnants = finder.FindRemnants();
|
|
|
|
foreach (var r in remnants)
|
|
{
|
|
// Check no remnant overlaps obstacle 1
|
|
var overlaps1 = r.Left < 50 && r.Right > 20 && r.Bottom < 50 && r.Top > 20;
|
|
Assert.False(overlaps1, $"Remnant ({r.X},{r.Y} {r.Width}x{r.Length}) overlaps obstacle 1");
|
|
|
|
// Check no remnant overlaps obstacle 2
|
|
var overlaps2 = r.Left < 85 && r.Right > 60 && r.Bottom < 90 && r.Top > 10;
|
|
Assert.False(overlaps2, $"Remnant ({r.X},{r.Y} {r.Width}x{r.Length}) overlaps obstacle 2");
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void ManyObstacles_GridPattern()
|
|
{
|
|
var finder = new RemnantFinder(new Box(0, 0, 100, 100));
|
|
|
|
// Place a 5x5 grid of 10x10 obstacles with 10-unit gaps
|
|
for (var row = 0; row < 5; row++)
|
|
for (var col = 0; col < 5; col++)
|
|
finder.AddObstacle(new Box(col * 20, row * 20, 10, 10));
|
|
|
|
var remnants = finder.FindRemnants();
|
|
|
|
// All remnants should be within the work area
|
|
foreach (var r in remnants)
|
|
{
|
|
Assert.True(r.Left >= 0);
|
|
Assert.True(r.Bottom >= 0);
|
|
Assert.True(r.Right <= 100);
|
|
Assert.True(r.Top <= 100);
|
|
}
|
|
|
|
// Should find gaps between obstacles
|
|
Assert.True(remnants.Count > 0);
|
|
}
|
|
|
|
[Fact]
|
|
public void SingleObstacle_NearEdge_FindsRemnantsOnAllSides()
|
|
{
|
|
// Obstacle near top-left: should find remnants above, below, and to the right.
|
|
var finder = new RemnantFinder(new Box(0, 0, 120, 60));
|
|
finder.AddObstacle(new Box(0, 47, 21, 6));
|
|
var remnants = finder.FindRemnants();
|
|
|
|
var above = remnants.FirstOrDefault(r => r.Bottom >= 53 - 0.1 && r.Width > 50);
|
|
var below = remnants.FirstOrDefault(r => r.Top <= 47 + 0.1 && r.Width > 50);
|
|
var right = remnants.FirstOrDefault(r => r.Left >= 21 - 0.1 && r.Length > 50);
|
|
|
|
Assert.NotNull(above);
|
|
Assert.NotNull(below);
|
|
Assert.NotNull(right);
|
|
}
|
|
|
|
[Fact]
|
|
public void LoadNestFile_FindsGapAboveMainGrid()
|
|
{
|
|
var nestFile = @"C:\Users\AJ\Desktop\no_remnant_found.nest";
|
|
if (!File.Exists(nestFile))
|
|
return; // Skip if file not available.
|
|
|
|
var reader = new NestReader(nestFile);
|
|
var nest = reader.Read();
|
|
var plate = nest.Plates[0];
|
|
|
|
var finder = RemnantFinder.FromPlate(plate);
|
|
|
|
// Use smallest drawing bbox dimension as minDim (same as UI).
|
|
var minDim = nest.Drawings.Min(d =>
|
|
System.Math.Min(d.Program.BoundingBox().Width, d.Program.BoundingBox().Length));
|
|
|
|
var tiered = finder.FindTieredRemnants(minDim);
|
|
|
|
// Should find a remnant near (0.25, 53.13) — the gap above the main grid.
|
|
var topGap = tiered.FirstOrDefault(t =>
|
|
t.Box.Bottom > 50 && t.Box.Bottom < 55 &&
|
|
t.Box.Left < 1 &&
|
|
t.Box.Width > 100 &&
|
|
t.Box.Length > 5);
|
|
|
|
Assert.True(topGap.Box.Width > 0, "Expected remnant above main grid");
|
|
}
|
|
|
|
[Fact]
|
|
public void DensePack_FindsGapAtTop()
|
|
{
|
|
// Reproduce real plate: 120x60, 68 parts of SULLYS-004.
|
|
// Main grid tops out at y=53.14 (obstacle). Two rotated parts on the
|
|
// right extend to y=58.49 but only at x > 106. The gap at x < 106
|
|
// from y=53.14 to y=59.8 is ~106 x 6.66 — should be found.
|
|
var workArea = new Box(0.2, 0.8, 119.5, 59.0);
|
|
var obstacles = new List<Box>();
|
|
var spacing = 0.25;
|
|
|
|
// Main grid: 5 columns x 12 rows (6 pairs).
|
|
// Even rows: bbox bottom offsets, odd rows: different offsets.
|
|
double[] colX = { 0.25, 21.08, 41.90, 62.73, 83.56 };
|
|
double[] colXOdd = { 0.81, 21.64, 42.46, 63.29, 84.12 };
|
|
double[] evenY = { 3.67, 12.41, 21.14, 29.87, 38.60, 47.33 };
|
|
double[] oddY = { 0.75, 9.48, 18.21, 26.94, 35.67, 44.40 };
|
|
|
|
foreach (var cx in colX)
|
|
foreach (var ey in evenY)
|
|
obstacles.Add(new Box(cx - spacing, ey - spacing, 20.65 + spacing * 2, 5.56 + spacing * 2));
|
|
foreach (var cx in colXOdd)
|
|
foreach (var oy in oddY)
|
|
obstacles.Add(new Box(cx - spacing, oy - spacing, 20.65 + spacing * 2, 5.56 + spacing * 2));
|
|
|
|
// Right-side rotated parts (only 2 extend high: parts 62 and 66).
|
|
obstacles.Add(new Box(106.70 - spacing, 37.59 - spacing, 5.56 + spacing * 2, 20.65 + spacing * 2));
|
|
obstacles.Add(new Box(114.19 - spacing, 37.59 - spacing, 5.56 + spacing * 2, 20.65 + spacing * 2));
|
|
// Parts 63, 67 (lower rotated)
|
|
obstacles.Add(new Box(105.02 - spacing, 29.35 - spacing, 5.56 + spacing * 2, 20.65 + spacing * 2));
|
|
obstacles.Add(new Box(112.51 - spacing, 29.35 - spacing, 5.56 + spacing * 2, 20.65 + spacing * 2));
|
|
// Parts 60, 64 (upper-right rotated, lower)
|
|
obstacles.Add(new Box(106.70 - spacing, 8.99 - spacing, 5.56 + spacing * 2, 20.65 + spacing * 2));
|
|
obstacles.Add(new Box(114.19 - spacing, 8.99 - spacing, 5.56 + spacing * 2, 20.65 + spacing * 2));
|
|
// Parts 61, 65
|
|
obstacles.Add(new Box(105.02 - spacing, 0.75 - spacing, 5.56 + spacing * 2, 20.65 + spacing * 2));
|
|
obstacles.Add(new Box(112.51 - spacing, 0.75 - spacing, 5.56 + spacing * 2, 20.65 + spacing * 2));
|
|
|
|
var finder = new RemnantFinder(workArea, obstacles);
|
|
var remnants = finder.FindRemnants(5.375);
|
|
|
|
// The gap at x < 106 from y=53.14 to y=59.8 should be found.
|
|
Assert.True(remnants.Count > 0, "Should find gap above main grid");
|
|
var topRemnant = remnants.FirstOrDefault(r => r.Length >= 5.375 && r.Width > 50);
|
|
Assert.NotNull(topRemnant);
|
|
|
|
// Verify dimensions are close to the expected ~104 x 6.6 gap.
|
|
Assert.True(topRemnant.Width > 100, $"Expected width > 100, got {topRemnant.Width:F1}");
|
|
Assert.True(topRemnant.Length > 6, $"Expected length > 6, got {topRemnant.Length:F1}");
|
|
}
|
|
}
|