From 73be301ebf5a3ad413daf1160c34ad73af1805bb Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Tue, 13 Oct 2026 21:17:11 -0400 Subject: [PATCH] test(expand): verify cancellation after scratch movement --- OpenNest.Engine.Tests/Fill/ExpanderTests.cs | 44 +++++++ OpenNest.Engine/Fill/Expander.cs | 120 +++++++++++++++++--- 2 files changed, 149 insertions(+), 15 deletions(-) diff --git a/OpenNest.Engine.Tests/Fill/ExpanderTests.cs b/OpenNest.Engine.Tests/Fill/ExpanderTests.cs index 4fb55d6..8694475 100644 --- a/OpenNest.Engine.Tests/Fill/ExpanderTests.cs +++ b/OpenNest.Engine.Tests/Fill/ExpanderTests.cs @@ -579,6 +579,50 @@ public class ExpanderTests Assert.True(poses[1].X + 2 <= wall.Location.X + 1e-6); } + [Theory] + [InlineData((int)Expander.Checkpoint.AfterScratchMove)] + [InlineData((int)Expander.Checkpoint.AfterSuccessfulProbe)] + [InlineData((int)Expander.Checkpoint.BeforeApply)] + public void Expand_CancellationAtDeterministicCheckpoint_LeavesLiveStateBitIdentical(int stage) + { + var cancelAt = (Expander.Checkpoint)stage; + var plate = MakePlate(30, 12, edge: 0); + var anchor = AddSquare(plate, 5, 4); + var moving = AddSquare(plate, 10, 4); + var wall = AddSquare(plate, 0, 4); + var before = plate.Parts.Select(p => ( + Part: p, Program: p.Program, + X: BitConverter.DoubleToInt64Bits(p.Location.X), + Y: BitConverter.DoubleToInt64Bits(p.Location.Y), + Left: BitConverter.DoubleToInt64Bits(p.BoundingBox.Left))).ToArray(); + var partSpacing = plate.PartSpacing; + var edgeSpacing = plate.EdgeSpacing; + using var cts = new CancellationTokenSource(); + var seen = false; + + var result = Expander.ExpandObserved(new List { anchor, moving }, plate, + new Expander.Options { MaxSpacing = 3 }, checkpoint => + { + if (checkpoint != cancelAt) + return; + seen = true; + cts.Cancel(); + }, cts.Token); + + Assert.True(seen, $"checkpoint {cancelAt} was not reached"); + Assert.True(result.Cancelled); + foreach (var state in before) + { + Assert.Same(state.Program, state.Part.Program); + Assert.Equal(state.X, BitConverter.DoubleToInt64Bits(state.Part.Location.X)); + Assert.Equal(state.Y, BitConverter.DoubleToInt64Bits(state.Part.Location.Y)); + Assert.Equal(state.Left, BitConverter.DoubleToInt64Bits(state.Part.BoundingBox.Left)); + } + Assert.Equal(partSpacing, plate.PartSpacing); + Assert.Equal(edgeSpacing, plate.EdgeSpacing); + Assert.Equal(0, wall.Location.X); + } + [Fact] public void Expand_WallsAndUnselectedPairs_StayExactlyAtClearance() { diff --git a/OpenNest.Engine/Fill/Expander.cs b/OpenNest.Engine/Fill/Expander.cs index e0a479f..0a831b9 100644 --- a/OpenNest.Engine/Fill/Expander.cs +++ b/OpenNest.Engine/Fill/Expander.cs @@ -71,6 +71,19 @@ namespace OpenNest.Engine.Fill public List Violations = new(); } + /// Per-call test checkpoints; no process-global mutable hook. + internal enum Checkpoint + { + AfterScratchMove, + AfterSuccessfulProbe, + BeforeApply, + } + + internal static Result ExpandObserved( + List selected, Plate plate, Options options, + Action observer, CancellationToken token = default + ) => ExpandCore(selected, plate, options, token, observer); + /// /// Raises the part-to-part spacing of the selection as far as the plate /// and its other parts allow, applying the best spacing found. Plate @@ -86,6 +99,14 @@ namespace OpenNest.Engine.Fill Plate plate, Options options = null, CancellationToken token = default + ) => ExpandCore(selected, plate, options, token, null); + + private static Result ExpandCore( + List selected, + Plate plate, + Options options, + CancellationToken token, + Action observer ) { if (plate == null) @@ -102,11 +123,29 @@ namespace OpenNest.Engine.Fill ); var opt = options ?? new Options(); - var context = SeparationContext.Prepare(selected, plate); - var entry = context.Positions(); // all parts; movers are [0, count) var result = new Result(); + SeparationContext context; + try + { + context = SeparationContext.Prepare(selected, plate, token); + } + catch (OperationCanceledException) when (token.IsCancellationRequested) + { + result.Cancelled = true; + return result; + } + var entry = context.Positions(); // all parts; movers are [0, count) - var sMin = MinimumPairClearance(context, entry); + double sMin; + try + { + sMin = MinimumPairClearance(context, entry, token); + } + catch (OperationCanceledException) when (token.IsCancellationRequested) + { + result.Cancelled = true; + return result; + } result.AchievedSpacing = System.Math.Max(0, sMin); if (token.IsCancellationRequested) @@ -121,7 +160,9 @@ namespace OpenNest.Engine.Fill (bool Converged, List Positions) Probe(double spacing) { - var attempt = Separate(context, entry, spacing, opt.MaxIterations, token); + var attempt = Separate(context, entry, spacing, opt.MaxIterations, token, observer); + if (attempt.Converged) + observer?.Invoke(Checkpoint.AfterSuccessfulProbe); return (attempt.Converged, attempt.Positions); } @@ -151,6 +192,11 @@ namespace OpenNest.Engine.Fill } var probe = Probe(s); + if (token.IsCancellationRequested) + { + result.Cancelled = true; + return result; + } if (probe.Converged) { @@ -179,7 +225,13 @@ namespace OpenNest.Engine.Fill } var mid = (lo + hi) / 2; - if (Probe(mid).Converged) + var midProbe = Probe(mid); + if (token.IsCancellationRequested) + { + result.Cancelled = true; + return result; + } + if (midProbe.Converged) lo = mid; else hi = mid; @@ -191,8 +243,9 @@ namespace OpenNest.Engine.Fill // Deterministic, so this reproduces any committed probe exactly; when // sGood was never probed (overlapping entry) it still performs the // best-effort cleanup and yields the violation report. - var final = Separate(context, entry, System.Math.Max(0, sGood), opt.MaxIterations, token); + var final = Separate(context, entry, System.Math.Max(0, sGood), opt.MaxIterations, token, observer); + observer?.Invoke(Checkpoint.BeforeApply); if (token.IsCancellationRequested) { result.Cancelled = true; @@ -205,9 +258,23 @@ namespace OpenNest.Engine.Fill if (!context.MeetsEntryFloors(positions, entry)) throw new InvalidOperationException("Expand proposal violates an entry clearance floor."); + double measured; + try + { + measured = MinimumPairClearance(context, positions, token); + } + catch (OperationCanceledException) when (token.IsCancellationRequested) + { + result.Cancelled = true; + return result; + } + if (token.IsCancellationRequested) + { + result.Cancelled = true; + return result; + } Apply(selected, entry, positions); - var measured = MinimumPairClearance(context, positions); result.AchievedSpacing = System.Math.Max(0, System.Math.Min(System.Math.Max(0, sGood), measured)); result.Violations = final.Violations; return result; @@ -226,9 +293,17 @@ namespace OpenNest.Engine.Fill CancellationToken token = default ) { - var context = SeparationContext.Prepare(selected, plate); + SeparationContext context; + try + { + context = SeparationContext.Prepare(selected, plate, token); + } + catch (OperationCanceledException) when (token.IsCancellationRequested) + { + return (false, selected.Select(p => p.Location).ToList(), new List()); + } var positions = context.Positions(); - return Separate(context, positions, spacing, maxIterations, token); + return Separate(context, positions, spacing, maxIterations, token, null); } private static void Apply(List selected, List from, List to) @@ -242,12 +317,14 @@ namespace OpenNest.Engine.Fill } } - private static double MinimumPairClearance(SeparationContext context, List positions) + private static double MinimumPairClearance( + SeparationContext context, List positions, CancellationToken token) { var min = double.MaxValue; foreach (var pair in context.Pairs) { + token.ThrowIfCancellationRequested(); var (distance, _) = context.PairClearance(pair.IndexA, pair.IndexB, positions); if (distance < min) min = distance; @@ -261,7 +338,8 @@ namespace OpenNest.Engine.Fill List start, double spacing, int maxIterations, - CancellationToken token + CancellationToken token, + Action observer ) { var positions = new List(start); @@ -285,7 +363,8 @@ namespace OpenNest.Engine.Fill foreach (var pair in context.Pairs) { - + if (token.IsCancellationRequested) + return (false, start, violations); var (distance, directionA) = context.PairClearance( pair.IndexA, pair.IndexB, @@ -330,6 +409,9 @@ namespace OpenNest.Engine.Fill { positions[moverIndex] = attempt.Proposal; moved += applied; + observer?.Invoke(Checkpoint.AfterScratchMove); + if (token.IsCancellationRequested) + return (false, start, violations); } // A pair fully separated to the user target (the internal margin @@ -364,6 +446,8 @@ namespace OpenNest.Engine.Fill violations.Clear(); foreach (var pair in context.Pairs) { + if (token.IsCancellationRequested) + return (false, start, violations); var (distance, directionA) = context.PairClearance( pair.IndexA, pair.IndexB, @@ -491,8 +575,9 @@ namespace OpenNest.Engine.Fill SpacingCap = spacingCap; } - public static SeparationContext Prepare(List selected, Plate plate) + public static SeparationContext Prepare(List selected, Plate plate, CancellationToken token) { + token.ThrowIfCancellationRequested(); var movers = new List(selected); var walls = plate.Parts.Where(p => !movers.Contains(p)).ToList(); var clampStart = movers.Count + walls.Count; @@ -514,6 +599,7 @@ namespace OpenNest.Engine.Fill for (var i = 0; i < parts.Count; i++) { + token.ThrowIfCancellationRequested(); shapes.Add(PrepareProgram(programs, parts[i].Program)); localBoxes[i] = LocalBox(parts[i]); } @@ -533,10 +619,13 @@ namespace OpenNest.Engine.Fill workArea, spacingCap ); - context.BuildPairs(); + context.BuildPairs(token); var entry = context.Positions(); foreach (var pair in context.Pairs) + { + token.ThrowIfCancellationRequested(); pair.EntryFloor = context.PairClearance(pair.IndexA, pair.IndexB, entry).Distance; + } return context; } @@ -609,10 +698,11 @@ namespace OpenNest.Engine.Fill ); } - private void BuildPairs() + private void BuildPairs(CancellationToken token) { for (var a = 0; a < parts.Count; a++) { + token.ThrowIfCancellationRequested(); for (var b = a + 1; b < parts.Count; b++) { var aMover = a < moverCount;