Files
OpenNest/OpenNest.Engine.Tests/Fill/ExpanderTests.cs
T

557 lines
21 KiB
C#

using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using OpenNest.CNC;
using OpenNest.Engine.Fill;
using OpenNest.Geometry;
using Xunit;
namespace OpenNest.Engine.Tests.Fill;
/// <summary>
/// PlateView spacing expander: grows part-to-part spacing with the work area
/// and non-selected parts as hard boundaries.
/// </summary>
public class ExpanderTests
{
private static Program Rectangle(double width = 4, double length = 4)
{
var program = new Program();
program.MoveTo(0, 0);
program.LineTo(width, 0);
program.LineTo(width, length);
program.LineTo(0, length);
program.LineTo(0, 0);
return program;
}
private static Part AddSquare(Plate plate, double x, double y, double size = 4)
{
var part = new Part(new Drawing($"sq{plate.Parts.Count}", Rectangle(size, size)), new Vector(x, y));
plate.Parts.Add(part);
return part;
}
private static Part AddRectangle(Plate plate, double x, double y, double length, double width)
{
var part = new Part(new Drawing($"rect{plate.Parts.Count}", Rectangle(length, width)), new Vector(x, y));
plate.Parts.Add(part);
return part;
}
private static Plate MakePlate(double lengthX, double widthY, double edge = 0.5)
{
var plate = new Plate(new Size(widthY, lengthX));
plate.EdgeSpacing = new Spacing(edge, edge);
return plate;
}
/// <summary>Independent clearance oracle: naive vertex/segment min distance over raw part lines.</summary>
private static double BruteClearance(Part a, Part b)
{
var linesA = PartGeometry.GetPartLines(a);
var linesB = PartGeometry.GetPartLines(b);
double min = double.MaxValue;
foreach (var la in linesA)
foreach (var lb in linesB)
{
min = System.Math.Min(min, PointSegment(a, la.StartPoint, lb));
min = System.Math.Min(min, PointSegment(a, la.EndPoint, lb));
min = System.Math.Min(min, PointSegment(b, lb.StartPoint, la));
min = System.Math.Min(min, PointSegment(b, lb.EndPoint, la));
}
return min;
}
private static double PointSegment(Part owner, Vector pt, Line seg)
{
var d = seg.EndPoint - seg.StartPoint;
var len2 = d.DotProduct(d);
var t = len2 <= 1e-12 ? 0 : System.Math.Clamp((pt - seg.StartPoint).DotProduct(d) / len2, 0, 1);
return pt.DistanceTo(seg.StartPoint + d * t);
}
private static void AssertNoOverlaps(Plate plate)
{
for (var i = 0; i < plate.Parts.Count; i++)
for (var j = i + 1; j < plate.Parts.Count; j++)
Assert.False(
plate.Parts[i].Intersects(plate.Parts[j], out _),
$"{plate.Parts[i].BaseDrawing.Name} overlaps {plate.Parts[j].BaseDrawing.Name}"
);
}
[Fact]
public void Expand_TwoSquares_GrowUntilEdgeFloor_AndAnchorStaysPut()
{
var plate = MakePlate(24, 24);
var a = AddSquare(plate, 6, 10);
var b = AddSquare(plate, 14, 10);
var result = Expander.Expand(new List<Part> { a, b }, plate);
// Max gap: B flush against the right edge floor (23.5): 23.5 - 14 - 4 + gap base...
// A stays (anchor); B slides to x=19.5 -> gap 9.5.
Assert.Equal(6, a.Location.X, 6);
Assert.Equal(10, a.Location.Y, 6);
Assert.Equal(9.5, b.Location.X - (a.Location.X + 4), 1);
Assert.True(result.AchievedSpacing >= 9.4, $"achieved {result.AchievedSpacing}");
Assert.True(result.AchievedSpacing <= 9.6, $"achieved {result.AchievedSpacing}");
AssertNoOverlaps(plate);
Assert.True(b.BoundingBox.Right <= 23.5 + 1e-6);
}
[Fact]
public void Expand_SandwichedBetweenWalls_ConvergesOnlyToInitialGaps_AndKeepsWalls()
{
var plate = MakePlate(26, 10, edge: 0.0);
var wallL = AddSquare(plate, 0, 3);
var wallR = AddSquare(plate, 18, 3);
var a = AddSquare(plate, 6, 3);
var b = AddSquare(plate, 12, 3);
var result = Expander.Expand(new List<Part> { a, b }, plate);
// Every gap starts at exactly 2.0; straight separation moves cannot open
// the row (opening one gap costs another), so the run stays at ~2.0.
Assert.Equal(0, wallL.Location.X, 6);
Assert.Equal(18, wallR.Location.X, 6);
Assert.True(
result.AchievedSpacing >= 1.9 && result.AchievedSpacing <= 2.05,
$"achieved {result.AchievedSpacing}"
);
AssertNoOverlaps(plate);
}
[Fact]
public void Expand_OverlappingPair_SeparatesAndClearsOverlap()
{
var plate = MakePlate(30, 12);
var a = AddSquare(plate, 5, 4);
var b = AddSquare(plate, 7, 4); // 2.0 overlap in X
var result = Expander.Expand(new List<Part> { a, b }, plate, new Expander.Options
{
InitialStep = 0.5,
MaxSpacing = 3,
});
Assert.False(a.Intersects(b, out _));
var gap = b.Location.X - (a.Location.X + 4);
Assert.True(gap >= 2.99, $"gap {gap}");
Assert.True(result.AchievedSpacing >= 2.9);
AssertNoOverlaps(plate);
}
[Fact]
public void Separate_PinnedPart_ReportsViolationsWithoutOverlap()
{
var plate = MakePlate(20, 20, edge: 0.0);
var pinned = AddSquare(plate, 8, 8);
// Walls 0.2 clear on all four sides.
var left = AddSquare(plate, 3.8, 8);
var right = AddSquare(plate, 12.2, 8);
var bottom = AddSquare(plate, 8, 3.8);
var top = AddSquare(plate, 8, 12.2);
var (converged, positions, violations) = Expander.Separate(
new List<Part> { pinned },
plate,
spacing: 1.0
);
Assert.False(converged);
Assert.NotEmpty(violations);
// The pinned part may slide into the walls but never through them.
AssertNoOverlaps(plate);
Assert.Equal(3.8, left.Location.X, 6);
Assert.Equal(12.2, right.Location.X, 6);
Assert.Equal(3.8, bottom.Location.Y, 6);
Assert.Equal(12.2, top.Location.Y, 6);
}
[Fact]
public void Expand_CancelledBeforeRun_LeavesEverythingInPlace()
{
var plate = MakePlate(24, 24);
var a = AddSquare(plate, 6, 10);
var b = AddSquare(plate, 14, 10);
using var cts = new CancellationTokenSource();
cts.Cancel();
var result = Expander.Expand(
new List<Part> { a, b },
plate,
token: cts.Token
);
Assert.True(result.Cancelled);
Assert.Equal(6, a.Location.X, 6);
Assert.Equal(14, b.Location.X, 6);
}
[Fact]
public void Expand_ThreeInRow_FirstSelectedNeverMoves_AndOracleConfirmsSpacing()
{
var plate = MakePlate(60, 14);
var a = AddSquare(plate, 5, 5);
var b = AddSquare(plate, 10, 5);
var c = AddSquare(plate, 15, 5);
var result = Expander.Expand(
new List<Part> { a, b, c },
plate,
new Expander.Options { MaxSpacing = 8 }
);
Assert.Equal(5, a.Location.X, 6); // anchor: never the later index of any pair
Assert.True(result.AchievedSpacing >= 7.9);
// Independent oracle: every pair clears the reported spacing.
var parts = new List<Part> { a, b, c };
for (var i = 0; i < parts.Count; i++)
for (var j = i + 1; j < parts.Count; j++)
{
var clearance = BruteClearance(parts[i], parts[j]);
Assert.True(
clearance >= result.AchievedSpacing - 0.01,
$"{parts[i].BaseDrawing.Name}/{parts[j].BaseDrawing.Name}: oracle {clearance} < reported {result.AchievedSpacing}"
);
}
AssertNoOverlaps(plate);
}
private static Program RectangleWithHole(
double width,
double length,
double hx,
double hy,
double hw,
double hh
)
{
var program = Rectangle(width, length);
program.MoveTo(hx, hy);
program.LineTo(hx + hw, hy);
program.LineTo(hx + hw, hy + hh);
program.LineTo(hx, hy + hh);
program.LineTo(hx, hy);
return program;
}
/// <summary>
/// Hole-subtracting overlap check matching NestValidator semantics (a part in
/// a cutout is legal). Part.Intersects is perimeter-only, so it cannot
/// certify part-in-cutout layouts.
/// </summary>
private static bool MateriallyOverlaps(Part a, Part b)
{
var (outerA, holesA) = Rings(a);
var (outerB, holesB) = Rings(b);
return Collision.HasOverlap(outerA, outerB, holesA, holesB);
}
private static (Polygon Outer, List<Polygon> Holes) Rings(Part part)
{
var entities = OpenNest.Converters.ConvertProgram
.ToGeometry(part.Program)
.Where(e => SpecialLayers.IsMaterial(e.Layer))
.ToList();
var profile = new ShapeProfile(entities);
var outer = profile.Perimeter.ToPolygonWithTolerance(0.001);
outer.Offset(part.Location);
var holes = new List<Polygon>();
foreach (var cutout in profile.Cutouts)
{
var hole = cutout.ToPolygonWithTolerance(0.001);
hole.Offset(part.Location);
holes.Add(hole);
}
return (outer, holes.Count == 0 ? null : holes);
}
[Fact]
public void Expand_PartInsideCutout_KeepsLegalAndClearsHoleWalls()
{
var plate = MakePlate(40, 24);
// Wall part with a 10x10 cutout; a small selected part sits inside it.
var wall = new Part(
new Drawing(
"wall",
RectangleWithHole(20, 20, 5, 5, 10, 10)
),
new Vector(0, 0)
);
plate.Parts.Add(wall);
var inside = new Part(new Drawing("inside", Rectangle(2, 2)), new Vector(9, 9));
var other = new Part(new Drawing("other", Rectangle(2, 2)), new Vector(30, 9));
plate.Parts.Add(inside);
plate.Parts.Add(other);
var result = Expander.Expand(
new List<Part> { inside, other },
plate,
new Expander.Options { MaxSpacing = 2 }
);
// Part-in-cutout is legal, never a material overlap.
Assert.False(MateriallyOverlaps(inside, wall));
Assert.False(MateriallyOverlaps(other, wall));
Assert.False(MateriallyOverlaps(inside, other));
Assert.True(result.AchievedSpacing >= 1.9);
// The part that started in the cutout must clear the hole walls too.
var holeLeft = 5;
var holeRight = 15;
var gapLeft = inside.Location.X - holeLeft;
var gapRight = holeRight - (inside.Location.X + 2);
var gapBottom = inside.Location.Y - holeLeft;
var gapTop = holeRight - (inside.Location.Y + 2);
var minGap = System.Math.Min(
System.Math.Min(gapLeft, gapRight),
System.Math.Min(gapBottom, gapTop)
);
Assert.True(minGap >= 1.9, $"closest hole-wall gap {minGap}");
}
[Fact]
public void Expand_DegenerateInputs_Throw()
{
var plate = MakePlate(10, 10);
var a = AddSquare(plate, 1, 1);
var stranger = new Part(new Drawing("stranger", Rectangle()), new Vector(50, 50));
Assert.Throws<ArgumentException>(() => Expander.Expand(new List<Part>(), plate));
Assert.Throws<ArgumentException>(() => Expander.Expand(new List<Part> { a }, plate));
Assert.Throws<ArgumentException>(() => Expander.Expand(new List<Part> { a, stranger }, plate));
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 Separate_CannotPassThroughFullHeightWall_ToClearEndpoint()
{
var plate = MakePlate(30, 12, edge: 0);
var anchor = AddSquare(plate, 1, 4, 2);
var moving = AddSquare(plate, 4, 4, 2);
var wall = AddRectangle(plate, 6.5, 0, 0.2, 12);
// Target 5 puts the otherwise clear endpoint beyond the wall AND
// preserves the entry wall gap of 0.5. An endpoint-only check passes it.
var (converged, poses, violations) = Expander.Separate(
new List<Part> { anchor, moving }, plate, spacing: 5
);
Assert.False(converged);
Assert.NotEmpty(violations);
Assert.True(poses[1].X + 2 <= wall.Location.X + 1e-6,
$"move crossed wall: right={poses[1].X + 2}, wall={wall.Location.X}");
Assert.Equal(4, moving.Location.X); // public Separate never commits.
Assert.Equal(6.5, wall.Location.X);
var result = Expander.Expand(new List<Part> { anchor, moving }, plate,
new Expander.Options { InitialStep = 5, MaxSpacing = 5 });
Assert.True(moving.Location.X + 2 <= wall.Location.X + 1e-6);
Assert.True(result.AchievedSpacing < 5);
}
[Fact]
public void Separate_OverlappingPair_CannotExitAcrossThirdWall()
{
var plate = MakePlate(30, 12, edge: 0);
var anchor = AddSquare(plate, 5, 4);
var moving = AddSquare(plate, 7, 4); // Overlaps anchor by two.
var wall = AddRectangle(plate, 12, 0, 0.2, 12);
var (converged, poses, violations) = Expander.Separate(
new List<Part> { anchor, moving }, plate, spacing: 5
);
Assert.False(converged);
Assert.NotEmpty(violations);
Assert.True(poses[1].X + 4 <= wall.Location.X + 1e-6,
$"overlap exit crossed third wall: {poses[1].X}");
Assert.Equal(7, moving.Location.X);
Assert.Equal(12, wall.Location.X);
}
[Fact]
public void Separate_CannotCrossOppositeCutoutWall()
{
var plate = MakePlate(40, 20, edge: 0);
var host = new Part(new Drawing("host", RectangleWithHole(10, 10, 0.2, 0.2, 9.6, 9.6)), new Vector(5, 1));
plate.Parts.Add(host);
var anchor = AddSquare(plate, 1, 5, 1);
var moving = AddSquare(plate, 5.2, 5, 1); // Touches inner left wall.
var (converged, poses, _) = Expander.Separate(
new List<Part> { anchor, moving }, plate, spacing: 14
);
Assert.False(converged);
Assert.True(poses[1].X + 1 <= 14.8 + 1e-6,
$"insert crossed opposite hole wall: {poses[1].X}");
Assert.Equal(5.2, moving.Location.X);
}
[Fact]
public void Separate_TinyClampZone_IsNotAnInvisibleWall()
{
var plate = MakePlate(30, 12, edge: 0);
plate.ClampZones.Add(new ClampZone("tiny", new Box(6.5, 0, 1e-5, 1e-5)));
var anchor = AddSquare(plate, 1, 0, 2);
var moving = AddSquare(plate, 4, 0, 2);
var (converged, poses, _) = Expander.Separate(
new List<Part> { anchor, moving }, plate, spacing: 6
);
Assert.False(converged);
Assert.True(poses[1].X + 2 <= 6.5 + 1e-6,
$"crossed tiny clamp at x=6.5: right={poses[1].X + 2}");
Assert.Equal(4, moving.Location.X);
}
[Fact]
public void Separate_RemoteTinyClampDoesNotPinUnrelatedGrowth()
{
var plate = MakePlate(30, 12, edge: 0);
plate.ClampZones.Add(new ClampZone("tiny", new Box(0, 0, 1e-5, 1e-5)));
var anchor = AddSquare(plate, 1, 4, 2);
var moving = AddSquare(plate, 4, 4, 2);
var (converged, poses, violations) = Expander.Separate(
new List<Part> { anchor, moving }, plate, spacing: 3
);
Assert.True(converged, $"remote zone pinned growth: {violations.Count} violations, " +
$"poses {poses[0].X:R},{poses[1].X:R}; " +
string.Join("; ", violations.Select(v => $"{v.A.BaseDrawing.Name}/{v.B.BaseDrawing.Name}={v.Achieved:R}")));
Assert.True(poses[1].X >= 6 - 1e-4);
Assert.Equal(4, moving.Location.X);
}
[Fact]
public void Separate_TouchingHoleWall_UsesOppositeDirectionToOpenGap()
{
var plate = MakePlate(40, 24, edge: 0);
var host = new Part(new Drawing("host", RectangleWithHole(20, 20, 5, 5, 10, 10)), new Vector(0, 0));
plate.Parts.Add(host);
var insert = AddSquare(plate, 13, 9, 2);
var (converged, poses, violations) = Expander.Separate(
new List<Part> { insert }, plate, spacing: 1
);
Assert.True(converged, $"contact still blocked: {violations.Count}");
Assert.True(poses[0].X <= 12 + 1e-4, $"insert at x={poses[0].X}");
Assert.True(poses[0].X >= 5 + 1 - 1e-4);
Assert.Equal(13, insert.Location.X);
Assert.Equal(0, host.Location.X);
}
[Theory]
[InlineData(13, 9)]
[InlineData(5, 9)]
[InlineData(9, 13)]
[InlineData(9, 5)]
[InlineData(12.9, 9)] // Positive gap uses its ordinary nearest-boundary direction.
public void Separate_FourHoleSides_ReachARealUnitGap(double x, double y)
{
var plate = MakePlate(40, 24, edge: 0);
var host = new Part(new Drawing("host", RectangleWithHole(20, 20, 5, 5, 10, 10)), Vector.Zero);
plate.Parts.Add(host);
var insert = AddSquare(plate, x, y, 2);
var (converged, poses, violations) = Expander.Separate(new List<Part> { insert }, plate, 1);
Assert.True(converged, $"{x},{y}: {violations.Count} violations");
Assert.InRange(poses[0].X, 6 - 1e-4, 12 + 1e-4);
Assert.InRange(poses[0].Y, 6 - 1e-4, 12 + 1e-4);
Assert.Equal(x, insert.Location.X);
Assert.Equal(y, insert.Location.Y);
}
[Fact]
public void Expand_TouchingHoleInsertWithSecondSelectedPart_StaysInsideCutout()
{
var plate = MakePlate(40, 24, edge: 0);
var host = new Part(new Drawing("host", RectangleWithHole(20, 20, 5, 5, 10, 10)), Vector.Zero);
plate.Parts.Add(host);
var insert = AddSquare(plate, 13, 9, 2);
var other = AddSquare(plate, 30, 9, 2);
var result = Expander.Expand(new List<Part> { insert, other }, plate,
new Expander.Options { MaxSpacing = 1 });
Assert.False(result.Cancelled);
Assert.True(result.AchievedSpacing >= 0.99);
Assert.InRange(insert.Location.X, 6 - 1e-4, 12 + 1e-4);
Assert.False(MateriallyOverlaps(insert, host));
Assert.Equal(0, host.Location.X);
}
[Fact]
public void Expand_WallsAndUnselectedPairs_StayExactlyAtClearance()
{
// Selection must not be pushed to open gaps between parts it excludes.
var plate = MakePlate(40, 12);
var w1 = AddSquare(plate, 2, 4);
var w2 = AddSquare(plate, 6.5, 4); // 0.5 apart from w1, both unselected
var a = AddSquare(plate, 14, 4);
var b = AddSquare(plate, 20, 4);
Expander.Expand(new List<Part> { a, b }, plate);
Assert.Equal(2, w1.Location.X, 6);
Assert.Equal(6.5, w2.Location.X, 6);
Assert.True(a.Intersects(w1, out _) == false);
Assert.True(b.Intersects(w2, out _) == false);
}
}