mirror of
https://github.com/ajisaacs/OpenNest.git
synced 2026-11-14 04:35:07 -05:00
On a plate packed edge to edge, an overlap left by hand-placing extra parts could not be fixed: straight push chains ran into the edge and the relaxation gave up on pairs that share material, which have no contact normal. The relaxation now pushes such a pair along its shortest straight way out and lets the rest settle, which cleared a 0.92 sq in overlap on a 38-part plate with every part kept square. When moves alone still cannot settle, the relaxation runs again as a rigid one: an off-center push also turns a part, up to 3 degrees, if its drawing is free to rotate. Moves stay first because a tilt turns every part the push reaches. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
256 lines
8.3 KiB
C#
256 lines
8.3 KiB
C#
using System.Linq;
|
|
using System.Threading;
|
|
using OpenNest.Diagnostics;
|
|
using OpenNest.Engine.Fill;
|
|
using OpenNest.Engine.Tests.Jobs;
|
|
using OpenNest.Geometry;
|
|
using Xunit;
|
|
|
|
namespace OpenNest.Engine.Tests.Fill;
|
|
|
|
/// <summary>Fix Spacing: only flagged pairs open, by the least movement, rippling into crowded neighbors.</summary>
|
|
public class SpacingFixerTests
|
|
{
|
|
private const double Spacing = 0.25;
|
|
|
|
private static Plate MakePlate(double lengthX, double widthY, double edge = 0.5)
|
|
{
|
|
var plate = new Plate(new Size(widthY, lengthX)) { PartSpacing = Spacing };
|
|
plate.EdgeSpacing = new Spacing(edge, edge);
|
|
return plate;
|
|
}
|
|
|
|
private static Part AddSquare(Plate plate, double x, double y, double size = 4)
|
|
{
|
|
var part = new Part(new Drawing($"sq{plate.Parts.Count}", TestDrawingFactory.Rectangle(size, size)),
|
|
new Vector(x, y));
|
|
plate.Parts.Add(part);
|
|
return part;
|
|
}
|
|
|
|
private static PlateOverlapReport Check(Plate plate) =>
|
|
PlateOverlapAnalyzer.Analyze(PlateOverlapAnalyzer.CapturePlate(plate));
|
|
|
|
private static void AssertClean(Plate plate)
|
|
{
|
|
var report = Check(plate);
|
|
Assert.True(report.IsComplete);
|
|
Assert.Empty(report.Pairs);
|
|
Assert.Empty(report.SpacingPairs);
|
|
Assert.Empty(report.EdgeFindings);
|
|
}
|
|
|
|
[Fact]
|
|
public void CleanPlate_MovesNothing()
|
|
{
|
|
var plate = MakePlate(30, 10);
|
|
var a = AddSquare(plate, 1, 1);
|
|
var b = AddSquare(plate, 1 + 4 + Spacing, 1);
|
|
|
|
var result = SpacingFixer.Fix(plate);
|
|
|
|
Assert.Equal(0, result.FlaggedPairs);
|
|
Assert.Empty(result.MovedParts);
|
|
Assert.Equal(new Vector(1, 1), a.Location);
|
|
Assert.Equal(1 + 4 + Spacing, b.Location.X);
|
|
}
|
|
|
|
[Fact]
|
|
public void TooClosePair_OpensToTheSpacing_AndLeavesOtherPartsAlone()
|
|
{
|
|
var plate = MakePlate(30, 20);
|
|
AddSquare(plate, 1, 1);
|
|
AddSquare(plate, 5.1, 1); // 0.1 apart
|
|
var far = AddSquare(plate, 20, 12);
|
|
var farAt = far.Location;
|
|
|
|
var result = SpacingFixer.Fix(plate);
|
|
|
|
Assert.Equal(1, result.FlaggedPairs);
|
|
Assert.Equal(0, result.RemainingPairs);
|
|
Assert.Single(result.MovedParts);
|
|
// Just the shortfall, plus the check's slack.
|
|
Assert.InRange(result.LargestMove, 0.15 - 1e-6, 0.152);
|
|
Assert.Equal(farAt, far.Location);
|
|
AssertClean(plate);
|
|
}
|
|
|
|
[Fact]
|
|
public void OverlappingPair_IsPushedApart()
|
|
{
|
|
var plate = MakePlate(30, 20);
|
|
AddSquare(plate, 1, 1);
|
|
AddSquare(plate, 4.9, 1); // overlaps by 0.1
|
|
|
|
var result = SpacingFixer.Fix(plate);
|
|
|
|
Assert.Equal(1, result.FlaggedPairs);
|
|
Assert.Equal(0, result.RemainingPairs);
|
|
Assert.InRange(result.LargestMove, 0.35 - 1e-6, 0.352);
|
|
AssertClean(plate);
|
|
}
|
|
|
|
[Fact]
|
|
public void PushRipplesThroughPartsAlreadyAtTheSpacing()
|
|
{
|
|
// The left square sits on the edge, so the fix must push the row to its right, whose
|
|
// squares are already exactly one spacing apart.
|
|
var plate = MakePlate(30, 5.5);
|
|
var anchor = AddSquare(plate, 0.5, 0.5);
|
|
var row = new[]
|
|
{
|
|
AddSquare(plate, 4.6, 0.5), // 0.1 from the anchor
|
|
AddSquare(plate, 4.6 + 4 + Spacing, 0.5),
|
|
AddSquare(plate, 4.6 + 2 * (4 + Spacing), 0.5),
|
|
};
|
|
var rowAt = row.Select(part => part.Location).ToList();
|
|
|
|
var result = SpacingFixer.Fix(plate);
|
|
|
|
Assert.Equal(0, result.RemainingPairs);
|
|
Assert.Equal(new Vector(0.5, 0.5), anchor.Location);
|
|
for (var i = 0; i < row.Length; i++)
|
|
Assert.InRange(row[i].Location.X - rowAt[i].X, 0.15 - 1e-6, 0.152);
|
|
AssertClean(plate);
|
|
}
|
|
|
|
[Fact]
|
|
public void BoxedInPair_SharesTheOpeningBetweenBothSides()
|
|
{
|
|
// Each square has 0.1 to its edge but the pair needs 0.15: neither can go alone.
|
|
var plate = MakePlate(9.3, 5.5);
|
|
var left = AddSquare(plate, 0.6, 0.5);
|
|
var right = AddSquare(plate, 4.7, 0.5);
|
|
|
|
var result = SpacingFixer.Fix(plate);
|
|
|
|
Assert.Equal(0, result.RemainingPairs);
|
|
Assert.True(left.Location.X < 0.6);
|
|
Assert.True(right.Location.X > 4.7);
|
|
AssertClean(plate);
|
|
}
|
|
|
|
[Fact]
|
|
public void BoxedInOverlap_SharesTheWayOut()
|
|
{
|
|
// Overlapping by 0.05, the pair needs 0.3 of separation; each square has 0.2 to its edge.
|
|
var plate = MakePlate(9.35, 5.5);
|
|
var left = AddSquare(plate, 0.7, 0.5);
|
|
var right = AddSquare(plate, 4.65, 0.5);
|
|
|
|
var result = SpacingFixer.Fix(plate);
|
|
|
|
Assert.Equal(0, result.RemainingPairs);
|
|
Assert.Empty(result.TurnedParts);
|
|
Assert.True(left.Location.X < 0.7);
|
|
Assert.True(right.Location.X > 4.65);
|
|
AssertClean(plate);
|
|
}
|
|
|
|
/// <summary>
|
|
/// A bar pinned at its left end by a square on the bottom edge, and a square crowding its
|
|
/// right end against the top edge: no move opens the pair, a slight tilt of the bar does.
|
|
/// </summary>
|
|
private static (Plate Plate, Part Bar, Part Square) PinnedBar()
|
|
{
|
|
var plate = MakePlate(21.5, 7.6);
|
|
plate.Parts.Add(new Part(new Drawing("pin", TestDrawingFactory.Rectangle(4, 1.25)), new Vector(0.5, 0.5)));
|
|
var bar = new Part(new Drawing("bar", TestDrawingFactory.Rectangle(20, 1)), new Vector(0.5, 2.0));
|
|
plate.Parts.Add(bar);
|
|
var square = AddSquare(plate, 16.5, 3.1); // 0.1 above the bar, its top on the top edge
|
|
return (plate, bar, square);
|
|
}
|
|
|
|
[Fact]
|
|
public void PinnedPart_TiltsSlightlyWhenNoMoveOpensThePair()
|
|
{
|
|
var (plate, bar, _) = PinnedBar();
|
|
var rotation = bar.Rotation;
|
|
|
|
var result = SpacingFixer.Fix(plate);
|
|
|
|
Assert.Equal(0, result.RemainingPairs);
|
|
Assert.Contains(bar, result.TurnedParts);
|
|
Assert.InRange(result.LargestTurn, 1e-4, SpacingFixer.DefaultMaxTilt);
|
|
Assert.NotEqual(rotation, bar.Rotation);
|
|
AssertClean(plate);
|
|
}
|
|
|
|
[Fact]
|
|
public void ZeroTilt_NeverTurns()
|
|
{
|
|
var (plate, bar, square) = PinnedBar();
|
|
var at = (bar.Location, square.Location, bar.Rotation);
|
|
|
|
var result = SpacingFixer.Fix(plate, null, default, maxTilt: 0);
|
|
|
|
Assert.Equal(1, result.RemainingPairs);
|
|
Assert.Empty(result.MovedParts);
|
|
Assert.Equal(at, (bar.Location, square.Location, bar.Rotation));
|
|
}
|
|
|
|
[Fact]
|
|
public void RestrictedRotation_NeverTurns()
|
|
{
|
|
var (plate, bar, _) = PinnedBar();
|
|
bar.BaseDrawing.Constraints = new NestConstraints { StepAngle = System.Math.PI / 2, EndAngle = System.Math.PI };
|
|
|
|
var result = SpacingFixer.Fix(plate);
|
|
|
|
Assert.DoesNotContain(bar, result.TurnedParts);
|
|
}
|
|
|
|
[Fact]
|
|
public void NoRoom_MovesNothing()
|
|
{
|
|
// Both squares sit on the edges with only 0.1 between them.
|
|
var plate = MakePlate(9.1, 5.5);
|
|
var left = AddSquare(plate, 0.5, 0.5);
|
|
var right = AddSquare(plate, 4.6, 0.5);
|
|
|
|
var result = SpacingFixer.Fix(plate);
|
|
|
|
Assert.Equal(1, result.FlaggedPairs);
|
|
Assert.Equal(1, result.RemainingPairs);
|
|
Assert.Empty(result.MovedParts);
|
|
Assert.Equal(new Vector(0.5, 0.5), left.Location);
|
|
Assert.Equal(new Vector(4.6, 0.5), right.Location);
|
|
}
|
|
|
|
[Fact]
|
|
public void Scope_LimitsTheFixToPairsTouchingIt()
|
|
{
|
|
var plate = MakePlate(30, 20);
|
|
var a = AddSquare(plate, 1, 1);
|
|
var b = AddSquare(plate, 5.1, 1);
|
|
var c = AddSquare(plate, 1, 10);
|
|
var d = AddSquare(plate, 5.1, 10);
|
|
var cAt = c.Location;
|
|
var dAt = d.Location;
|
|
|
|
var result = SpacingFixer.Fix(plate, new[] { a });
|
|
|
|
Assert.Equal(1, result.FlaggedPairs);
|
|
Assert.Equal(0, result.RemainingPairs);
|
|
Assert.Equal(cAt, c.Location);
|
|
Assert.Equal(dAt, d.Location);
|
|
Assert.Single(Check(plate).SpacingPairs);
|
|
}
|
|
|
|
[Fact]
|
|
public void CanceledRun_MovesNothing()
|
|
{
|
|
var plate = MakePlate(30, 20);
|
|
var a = AddSquare(plate, 1, 1);
|
|
var b = AddSquare(plate, 5.1, 1);
|
|
using var cts = new CancellationTokenSource();
|
|
cts.Cancel();
|
|
|
|
var result = SpacingFixer.Fix(plate, null, cts.Token);
|
|
|
|
Assert.True(result.Canceled);
|
|
Assert.Equal(new Vector(1, 1), a.Location);
|
|
Assert.Equal(new Vector(5.1, 1), b.Location);
|
|
}
|
|
}
|