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).
This commit is contained in:
@@ -0,0 +1,347 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user