Files
OpenNest/OpenNest.Engine.Tests/Fill/ExpanderTests.cs
T
aj 3cab90747a feat(expand): plateview spacing expander
Grows part-to-part spacing of a selected group with the work area and
non-selected parts as hard boundaries. Doubling + bisection search over
the target spacing; Gauss-Seidel straight-line relaxation with anchor
mover policy (later-index selected part moves, first selection never).
Overlapping input is separated along penetration MTVs instead of being
rejected. Cancel/failure never mutates part positions. Clearance gains
BoundaryDistance for ring-pair gaps (part-in-cutout legality).
2026-09-28 03:05:45 -04:00

348 lines
12 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 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 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);
}
}