mirror of
https://github.com/ajisaacs/OpenNest.git
synced 2026-10-15 11:11:54 -04:00
fix(expand): preserve entry clearances and retry blocked pairs
This commit is contained in:
1 parent
ddd1e422be
commit
43e08965b9
3 files changed
+133
-61
No files matched your search
@@ -327,6 +327,53 @@ public class ExpanderTests
|
||||
Assert.Throws<ArgumentNullException>(() => Expander.Expand(new List<Part> { 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<Part> { 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<Part> { 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()
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// True when the part at <paramref name="trial"/> 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).
|
||||
/// </summary>
|
||||
public bool MaintainsValidity(int index, Vector trial, List<Vector> 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<Vector> 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;
|
||||
}
|
||||
|
||||
/// <summary>Check active improvement and every affected pair against
|
||||
/// its immutable operation-entry floor on scratch positions.</summary>
|
||||
public bool MaintainsValidity(
|
||||
int index, Vector trial, List<Vector> 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<Polygon> CloneAll(List<Polygon> polygons, Vector offset)
|
||||
{
|
||||
var list = new List<Polygon>(polygons.Count);
|
||||
|
||||
@@ -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.
|
||||
Reference in new issue
Block a user