From cad10832870fb8ee8ffb1809b103ffeb04018662 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Tue, 13 Oct 2026 21:30:02 -0400 Subject: [PATCH] test(expand): bound pinned relaxation passes --- OpenNest.Engine.Tests/Fill/ExpanderTests.cs | 28 +++++++++++++++++++++ OpenNest.Engine/Fill/Expander.cs | 13 ++++++++++ 2 files changed, 41 insertions(+) diff --git a/OpenNest.Engine.Tests/Fill/ExpanderTests.cs b/OpenNest.Engine.Tests/Fill/ExpanderTests.cs index 8694475..77a29f0 100644 --- a/OpenNest.Engine.Tests/Fill/ExpanderTests.cs +++ b/OpenNest.Engine.Tests/Fill/ExpanderTests.cs @@ -623,6 +623,34 @@ public class ExpanderTests Assert.Equal(0, wall.Location.X); } + [Fact] + public void Separate_PinnedBetweenFixedParts_StopsAfterOneNoProgressPass() + { + var plate = MakePlate(20, 20, edge: 0); + var pinned = AddSquare(plate, 8, 8); + AddSquare(plate, 3.8, 8); + AddSquare(plate, 12.2, 8); + AddSquare(plate, 8, 3.8); + AddSquare(plate, 8, 12.2); + var passes = 0; + var initial = pinned.Location; + + var (converged, poses, violations) = Expander.SeparateObserved( + new List { pinned }, plate, spacing: 1, + checkpoint => + { + if (checkpoint == Expander.Checkpoint.AfterPairPass) + passes++; + }, maxIterations: 10000 + ); + + Assert.False(converged); + Assert.NotEmpty(violations); + Assert.Equal(1, passes); // Not a MaxIterations spin or wall-clock assertion. + Assert.Equal(initial, poses[0]); + Assert.Equal(initial, pinned.Location); + } + [Fact] public void Expand_WallsAndUnselectedPairs_StayExactlyAtClearance() { diff --git a/OpenNest.Engine/Fill/Expander.cs b/OpenNest.Engine/Fill/Expander.cs index 9009a36..8e3c505 100644 --- a/OpenNest.Engine/Fill/Expander.cs +++ b/OpenNest.Engine/Fill/Expander.cs @@ -77,6 +77,7 @@ namespace OpenNest.Engine.Fill { AfterScratchMove, AfterSuccessfulProbe, + AfterPairPass, BeforeApply, } @@ -307,6 +308,15 @@ namespace OpenNest.Engine.Fill return Separate(context, positions, spacing, maxIterations, token, null); } + internal static (bool Converged, List Positions, List Violations) SeparateObserved( + List selected, Plate plate, double spacing, + Action observer, int maxIterations = 100, + CancellationToken token = default) + { + var context = SeparationContext.Prepare(selected, plate, token); + return Separate(context, context.Positions(), spacing, maxIterations, token, observer); + } + private static void Apply(List selected, List from, List to) { // Only movers occupy [0, selected.Count); walls never move. @@ -435,6 +445,9 @@ namespace OpenNest.Engine.Fill // the next pass. The final sweep remeasures all pairs. } + observer?.Invoke(Checkpoint.AfterPairPass); + if (token.IsCancellationRequested) + return (false, start, violations); if (moved < epsMove) break; }