From 587000f68a7fbd685caa53fe34cb802559939323 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Fri, 11 Sep 2026 22:32:37 -0400 Subject: [PATCH] Fix push broad-phase checks to preserve part spacing --- OpenNest.Engine/Fill/Compactor.cs | 22 ++++++-- OpenNest.Tests/Fill/CompactorTests.cs | 75 +++++++++++++++++++++++++++ README.md | 2 +- 3 files changed, 94 insertions(+), 5 deletions(-) diff --git a/OpenNest.Engine/Fill/Compactor.cs b/OpenNest.Engine/Fill/Compactor.cs index 7ce2489..3d70535 100644 --- a/OpenNest.Engine/Fill/Compactor.cs +++ b/OpenNest.Engine/Fill/Compactor.cs @@ -43,12 +43,16 @@ namespace OpenNest.Engine.Fill var opposite = -direction; var obstacleBoxes = new Box[obstacleParts.Count]; + var obstacleSpacingBoxes = new Box[obstacleParts.Count]; var obstacleEntities = new List[obstacleParts.Count]; + var halfSpacing = System.Math.Max(0, partSpacing) / 2; for (var i = 0; i < obstacleParts.Count; i++) + { obstacleBoxes[i] = obstacleParts[i].BoundingBox; + obstacleSpacingBoxes[i] = SpacingBounds(obstacleBoxes[i], halfSpacing); + } - var halfSpacing = partSpacing / 2; var distance = double.MaxValue; foreach (var moving in movingParts) @@ -74,17 +78,21 @@ namespace OpenNest.Engine.Fill } } + // Broad-phase bounds must enclose the spacing-offset contours. + // Raw bounds can miss near passes and overestimate the safe travel. + var movingSpacingBox = SpacingBounds(movingBox, halfSpacing); for (var i = 0; i < obstacleBoxes.Length; i++) { - var reverseGap = SpatialQuery.DirectionalGap(movingBox, obstacleBoxes[i], opposite); + var obstacleSpacingBox = obstacleSpacingBoxes[i]; + var reverseGap = SpatialQuery.DirectionalGap(movingSpacingBox, obstacleSpacingBox, opposite); if (reverseGap > 0) continue; - var gap = SpatialQuery.DirectionalGap(movingBox, obstacleBoxes[i], direction); + var gap = SpatialQuery.DirectionalGap(movingSpacingBox, obstacleSpacingBox, direction); if (gap >= distance) continue; - if (!SpatialQuery.PerpendicularOverlap(movingBox, obstacleBoxes[i], direction)) + if (!SpatialQuery.PerpendicularOverlap(movingSpacingBox, obstacleSpacingBox, direction)) continue; movingEntities ??= halfSpacing > 0 @@ -123,6 +131,12 @@ namespace OpenNest.Engine.Fill return 0; } + private static Box SpacingBounds(Box box, double spacing) + { + return new Box(box.Left - spacing, box.Bottom - spacing, + box.Length + 2 * spacing, box.Width + 2 * spacing); + } + private static bool IntersectsAny(Part candidate, List parts) { for (var i = 0; i < parts.Count; i++) diff --git a/OpenNest.Tests/Fill/CompactorTests.cs b/OpenNest.Tests/Fill/CompactorTests.cs index e85a84a..7fbef2a 100644 --- a/OpenNest.Tests/Fill/CompactorTests.cs +++ b/OpenNest.Tests/Fill/CompactorTests.cs @@ -198,6 +198,81 @@ namespace OpenNest.Tests.Fill Assert.NotEqual(distNoSpacing, distWithSpacing); } + [Theory] + [InlineData(15)] + [InlineData(30)] + [InlineData(45)] + [InlineData(60)] + [InlineData(90)] + public void Push_RotatedParts_PreservesSpacingOnRepeatedPushes(double degrees) + { + var angle = OpenNest.Math.Angle.ToRadians(degrees); + var obstacle = Part.CreateAtOrigin(MakeRectDrawing(10, 10), angle); + obstacle.Offset(20, 20); + var moving = Part.CreateAtOrigin(MakeRectDrawing(10, 10), angle); + moving.Offset(60, 20); + var parts = new List { moving }; + var obstacles = new List { obstacle }; + var workArea = new Box(0, 0, 100, 100); + var spacing = 2.0; + + var distance = Compactor.Push(parts, obstacles, workArea, spacing, PushDirection.Left); + Assert.True(distance > 0); + AssertClearance(moving, obstacle, spacing); + + for (var i = 0; i < 3; i++) + { + Compactor.Push(parts, obstacles, workArea, spacing, PushDirection.Left); + AssertClearance(moving, obstacle, spacing); + } + } + + [Theory] + [InlineData(0)] + [InlineData(15)] + [InlineData(45)] + [InlineData(75)] + public void Push_WithSpacing_StopsBeforeNearMissOutsideRawBounds(double degrees) + { + var obstacle = MakeRectPart(20, 20, 10, 10); + var moving = Part.CreateAtOrigin(MakeRectDrawing(10, 10), OpenNest.Math.Angle.ToRadians(degrees)); + moving.Offset(60, 31); + + Compactor.Push(new List { moving }, new List { obstacle }, + new Box(0, 0, 100, 100), 2, PushDirection.Left); + + // Must stop at the first clearance boundary, not pass the obstacle + // and finish in a clear position on the far side. + Assert.True(moving.BoundingBox.Left > obstacle.BoundingBox.Left); + AssertClearance(moving, obstacle, 2); + } + + [Fact] + public void Push_WithSpacing_ObstacleClearanceWinsOverCloserPlateEdge() + { + var obstacle = MakeRectPart(20, 20, 10, 10); + var moving = MakeRectPart(60, 20, 10, 10); + + Compactor.Push(new List { moving }, new List { obstacle }, + new Box(31, 0, 100, 100), 2, PushDirection.Left); + + AssertClearance(moving, obstacle, 2); + Assert.Equal(32, moving.BoundingBox.Left, 7); + } + + private static void AssertClearance(Part moving, Part obstacle, double spacing) + { + var clearance = double.MaxValue; + foreach (var a in PartGeometry.GetPartLines(moving)) + foreach (var b in PartGeometry.GetPartLines(obstacle)) + { + Assert.False(Intersect.Intersects(a, b, out _)); + clearance = System.Math.Min(clearance, a.StartPoint.DistanceTo(b.ClosestPointTo(a.StartPoint))); + clearance = System.Math.Min(clearance, b.StartPoint.DistanceTo(a.ClosestPointTo(b.StartPoint))); + } + Assert.True(clearance >= spacing - 1e-7, $"Clearance {clearance:R} is less than spacing {spacing:R}"); + } + [Fact] public void Push_Up_AllowsSharedDiagonalEdgeToSeparate() { diff --git a/README.md b/README.md index baa5f62..fbbf9cb 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,7 @@ OpenNest takes your part drawings, lets you define your sheet (plate) sizes, and | **Pluggable Engines** | Default multi-phase, Vertical Remnant, Horizontal Remnant, plus custom plugin DLLs | | **Fill Strategies** | Linear grid, interlocking pairs, rectangle best-fit, and extents-based tiling | | **Best-Fit Pair Nesting** | NFP-based pair evaluation finds tight interlocking orientations between parts | -| **Gravity Compaction** | Polygon-based directional push to close gaps after filling | +| **Gravity Compaction** | Geometry-based directional push that preserves part spacing, including rotated parts and near passes between outlines | | **Part Rotation** | Automatic angle sweep to find better fits across allowed orientations | | **Multi-Plate Support** | Manage multiple plates of different sizes and materials in one nest |