diff --git a/OpenNest.Engine.Tests/Fill/ExpanderTests.cs b/OpenNest.Engine.Tests/Fill/ExpanderTests.cs index b396d2e..fcfd3a9 100644 --- a/OpenNest.Engine.Tests/Fill/ExpanderTests.cs +++ b/OpenNest.Engine.Tests/Fill/ExpanderTests.cs @@ -327,6 +327,53 @@ public class ExpanderTests Assert.Throws(() => Expander.Expand(new List { a, a }, null)); } + [Fact] + public void Separate_PreservesEachEntryGap_EvenWhenTargetIsSmaller() + { + var plate = MakePlate(30, 12, edge: 0); + var anchor = AddSquare(plate, 1, 4, 2); + var mover = AddSquare(plate, 4, 4, 2); + var wall = AddSquare(plate, 12, 4, 2); + var initialWallGap = wall.Location.X - (mover.Location.X + 2); + + var (converged, positions, _) = Expander.Separate( + new List { anchor, mover }, plate, spacing: 3 + ); + + // The wall is six units away at entry. Merely clearing the requested + // three-unit spacing is not permission to consume that original gap. + Assert.True(positions[1].X + 2 <= wall.Location.X - initialWallGap + 1e-6, + $"entry gap {initialWallGap}, proposed gap {wall.Location.X - positions[1].X - 2}"); + Assert.Equal(new Vector(4, 4), mover.Location); + Assert.Equal(new Vector(12, 4), wall.Location); + Assert.False(converged); // This direct translation cannot reach 3 safely. + } + + [Fact] + public void Separate_RetriesMiddleAfterLaterBlockerMoves() + { + var plate = MakePlate(60, 14, edge: 0); + var first = AddSquare(plate, 5, 5); + var middle = AddSquare(plate, 10, 5); + var last = AddSquare(plate, 14, 5); + var selected = new List { first, middle, last }; + + var (converged, positions, violations) = Expander.Separate(selected, plate, spacing: 2); + Assert.True(converged, $"remaining: {violations.Count}"); + Assert.Empty(violations); + Assert.Equal(5, positions[0].X); + Assert.True(positions[1].X >= 11 - 1e-4); + Assert.True(positions[2].X >= positions[1].X + 6 - 1e-4); + Assert.Equal(10, middle.Location.X); // Separate is scratch-only. + Assert.Equal(14, last.Location.X); + + var result = Expander.Expand(selected, plate, new Expander.Options { MaxSpacing = 2 }); + Assert.True(result.AchievedSpacing >= 1.99, $"achieved {result.AchievedSpacing}"); + Assert.True(middle.Location.X >= 11 - 1e-4); + Assert.True(last.Location.X >= middle.Location.X + 6 - 1e-4); + Assert.Equal(5, first.Location.X); + } + [Fact] public void Expand_WallsAndUnselectedPairs_StayExactlyAtClearance() { diff --git a/OpenNest.Engine/Fill/Expander.cs b/OpenNest.Engine/Fill/Expander.cs index 7c8c199..f3a490e 100644 --- a/OpenNest.Engine/Fill/Expander.cs +++ b/OpenNest.Engine/Fill/Expander.cs @@ -198,6 +198,10 @@ namespace OpenNest.Engine.Fill } var positions = final.Positions; + // Floors belong to this public operation, not to any individual probe. + // A failed final check leaves every live part untouched. + if (!context.MeetsEntryFloors(positions)) + throw new InvalidOperationException("Expand proposal violates an entry clearance floor."); Apply(selected, entry, positions); @@ -274,8 +278,11 @@ namespace OpenNest.Engine.Fill if (token.IsCancellationRequested) return (false, start, violations); + // Parking is pass-local. A blocker later in this pass may move, + // so retry the pair on the next pass; a pass with no progress + // terminates pinned layouts without exhausting the iteration cap. + stuck.Clear(); var moved = 0.0; - var stuckChanged = false; foreach (var pair in context.Pairs) { @@ -327,7 +334,7 @@ namespace OpenNest.Engine.Fill { var trial = positions[moverIndex] + direction * room; - if (context.MaintainsValidity(moverIndex, trial, positions)) + if (context.MaintainsValidity(moverIndex, trial, positions, pair, distance)) { positions[moverIndex] = trial; applied = room; @@ -343,7 +350,9 @@ namespace OpenNest.Engine.Fill context.MaintainsValidity( moverIndex, positions[moverIndex] + direction * mid, - positions + positions, + pair, + distance ) ) lo = mid; @@ -382,16 +391,12 @@ namespace OpenNest.Engine.Fill continue; } - // Zero progress twice in a row parks the pair; the final sweep - // re-measures everything, so mid-loop bookkeeping never lies. - if (stuck.Contains(pair.Id)) - continue; - + // Zero progress parks the pair for this pass; movement of a + // different part can unblock it on the next pass. stuck.Add(pair.Id); - stuckChanged = true; } - if (moved < epsMove && !stuckChanged) + if (moved < epsMove) break; } @@ -433,6 +438,7 @@ namespace OpenNest.Engine.Fill public int IndexA; public int IndexB; public int Mover; + public double EntryFloor; } /// @@ -515,6 +521,9 @@ namespace OpenNest.Engine.Fill spacingCap ); context.BuildPairs(); + var entry = context.Positions(); + foreach (var pair in context.Pairs) + pair.EntryFloor = context.PairClearance(pair.IndexA, pair.IndexB, entry).Distance; return context; } @@ -687,64 +696,73 @@ namespace OpenNest.Engine.Fill return max < 0 ? 0 : max; } - /// - /// True when the part at stays inside the work - /// area and keeps no material overlap with any other part (Collision - /// oracle with hole subtraction, so part-in-cutout stays legal). - /// - public bool MaintainsValidity(int index, Vector trial, List positions) + // Smaller than the 0.002 tessellation margin; immutable entry floors + // prevent this comparison tolerance accumulating over iterations. + private const double FloorTolerance = 1e-6; + + public bool MeetsEntryFloors(List positions) { - var box = localBoxes[index]; - var movedBox = box.Translate(trial); - - if ( - movedBox.Left < workArea.Left - 1e-9 - || movedBox.Right > workArea.Right + 1e-9 - || movedBox.Bottom < workArea.Bottom - 1e-9 - || movedBox.Top > workArea.Top + 1e-9 - ) - return false; - - var outer = shapes[index].Outer; - if (outer == null) - return true; - - var worldOuter = CloneAt(outer, trial); - var worldHoles = shapes[index].Holes.Count == 0 - ? null - : CloneAll(shapes[index].Holes, trial); - - for (var i = 0; i < parts.Count; i++) + for (var i = 0; i < moverCount; i++) + if (!InsideWorkArea(i, positions[i])) + return false; + foreach (var pair in Pairs) { - if (i == index) - continue; - - var otherOuter = shapes[i].Outer; - if (otherOuter == null) - continue; - - if (!BoxOverlap(movedBox, localBoxes[i].Translate(positions[i]), 0.002)) - continue; - - var worldOther = CloneAt(otherOuter, positions[i]); - var worldOtherHoles = shapes[i].Holes.Count == 0 - ? null - : CloneAll(shapes[i].Holes, positions[i]); - - if ( - Collision.HasOverlap( - worldOuter, - worldOther, - worldHoles, - worldOtherHoles - ) - ) + var clearance = PairClearance(pair.IndexA, pair.IndexB, positions).Distance; + if (!double.IsFinite(clearance) || clearance < pair.EntryFloor - FloorTolerance) return false; } - return true; } + private bool InsideWorkArea(int index, Vector position) + { + var box = localBoxes[index].Translate(position); + return box.Left >= workArea.Left - 1e-9 + && box.Right <= workArea.Right + 1e-9 + && box.Bottom >= workArea.Bottom - 1e-9 + && box.Top <= workArea.Top + 1e-9; + } + + /// Check active improvement and every affected pair against + /// its immutable operation-entry floor on scratch positions. + public bool MaintainsValidity( + int index, Vector trial, List positions, Pair active, double activeDistance) + { + if (!InsideWorkArea(index, trial)) + return false; + + var current = positions[index]; + positions[index] = trial; + try + { + var improved = PairClearance(active.IndexA, active.IndexB, positions).Distance; + if (!double.IsFinite(improved) || improved <= activeDistance + 1e-7) + return false; + foreach (var pair in Pairs) + { + if (pair.IndexA != index && pair.IndexB != index) + continue; + var clearance = PairClearance(pair.IndexA, pair.IndexB, positions).Distance; + if (!double.IsFinite(clearance) || clearance < pair.EntryFloor - FloorTolerance) + return false; + // Once an initial overlap is cleared, never reintroduce it. + if (clearance < 0) + { + positions[index] = current; + var before = PairClearance(pair.IndexA, pair.IndexB, positions).Distance; + positions[index] = trial; + if (before >= 0) + return false; + } + } + return true; + } + finally + { + positions[index] = current; + } + } + private static List CloneAll(List polygons, Vector offset) { var list = new List(polygons.Count); diff --git a/docs/geometry/expand-spacing.md b/docs/geometry/expand-spacing.md new file mode 100644 index 0000000..eb091b8 --- /dev/null +++ b/docs/geometry/expand-spacing.md @@ -0,0 +1,7 @@ +# Expand Spacing + +`Plate > Expand Spacing` moves selected parts by translation only. The first selected part is the anchor for selected pairs; the later selected member moves, and nonselected parts remain fixed. The plate retains its own `EdgeSpacing` floor; the command does not change `PartSpacing` or `EdgeSpacing`. + +Every selected/selected and selected/nonselected pair has an operation-entry clearance floor, measured against material with cutouts subtracted. A later move may reuse slack opened by an earlier move, but it cannot reduce a pair below its own starting gap. Existing overlaps are accepted for attempted separation; a remaining overlap is reported as a violation, not a successful positive gap. Temporarily blocked pairs are retried after another part moves, while a pass without positive movement stops the relaxation. + +Probes run on scratch poses. Cancellation before application leaves the live layout unchanged; `Separate` never changes it. The desktop command is synchronous and has no Cancel dialog or automatic save. A bounded search reports the spacing it found, not an optimum. See `docs/geometry/directional-slides.md` for directional contact behavior.