mirror of
https://github.com/ajisaacs/OpenNest.git
synced 2026-11-01 08:01:34 -05:00
The repo mixed variants such as center/centre and color/colour, so a name could be spelled either way and searches missed half the hits. Standardize on the US spelling, matching .NET's own APIs (Color, Center). Wire values owned by other systems keep their spelling: the material ledger's "Cancelled" hold status and GitHub's "cancelled" job status. No behavior change beyond the spelling of a few status messages. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
672 lines
25 KiB
C#
672 lines
25 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_CanceledBeforeRun_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.Canceled);
|
|
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.Canceled);
|
|
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 Separate_ReportsPartBlockerRatherThanPlateEdge()
|
|
{
|
|
var plate = MakePlate(20, 20, edge: 0);
|
|
var pinned = AddSquare(plate, 8, 8);
|
|
AddSquare(plate, 3.8, 8);
|
|
AddSquare(plate, 12.2, 8);
|
|
AddSquare(plate, 8, 3.8);
|
|
AddSquare(plate, 8, 12.2);
|
|
|
|
var (_, poses, violations) = Expander.Separate(new List<Part> { pinned }, plate, 1);
|
|
Assert.NotEmpty(violations);
|
|
Assert.All(violations, v => Assert.False(v.BlockedByEdge,
|
|
$"{v.B.BaseDrawing.Name}: blocker before the work-area edge, pose={poses[0]}"));
|
|
}
|
|
|
|
[Fact]
|
|
public void Separate_ReportsActualPlateEdgeWhenItLimitsMovement()
|
|
{
|
|
var plate = MakePlate(12, 12, edge: 0);
|
|
var anchor = AddSquare(plate, 1, 4, 2);
|
|
var moving = AddSquare(plate, 7, 4, 2);
|
|
|
|
var (_, poses, violations) = Expander.Separate(new List<Part> { anchor, moving }, plate, 9);
|
|
var pair = Assert.Single(violations.Where(v => ReferenceEquals(v.A, anchor) && ReferenceEquals(v.B, moving)));
|
|
Assert.True(pair.BlockedByEdge);
|
|
Assert.True(poses[1].X + 2 <= 12 + 1e-6);
|
|
}
|
|
|
|
[Fact]
|
|
public void Separate_ReportsPartBeforeFartherEdge_OnNarrowPlate()
|
|
{
|
|
var plate = MakePlate(9, 2, edge: 0);
|
|
var anchor = AddSquare(plate, 0, 0, 2);
|
|
var moving = AddSquare(plate, 3, 0, 2);
|
|
var wall = AddRectangle(plate, 5.5, 0, 0.2, 2);
|
|
|
|
var (_, poses, violations) = Expander.Separate(new List<Part> { anchor, moving }, plate, 4);
|
|
var pair = Assert.Single(violations.Where(v => ReferenceEquals(v.A, anchor) && ReferenceEquals(v.B, moving)));
|
|
Assert.False(pair.BlockedByEdge);
|
|
Assert.True(poses[1].X + 2 <= wall.Location.X + 1e-6);
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData((int)Expander.Checkpoint.AfterScratchMove)]
|
|
[InlineData((int)Expander.Checkpoint.AfterSuccessfulProbe)]
|
|
[InlineData((int)Expander.Checkpoint.BeforeApply)]
|
|
public void Expand_CancellationAtDeterministicCheckpoint_LeavesLiveStateBitIdentical(int stage)
|
|
{
|
|
var cancelAt = (Expander.Checkpoint)stage;
|
|
var plate = MakePlate(30, 12, edge: 0);
|
|
var anchor = AddSquare(plate, 5, 4);
|
|
var moving = AddSquare(plate, 10, 4);
|
|
var wall = AddSquare(plate, 0, 4);
|
|
var before = plate.Parts.Select(p => (
|
|
Part: p, Program: p.Program,
|
|
X: BitConverter.DoubleToInt64Bits(p.Location.X),
|
|
Y: BitConverter.DoubleToInt64Bits(p.Location.Y),
|
|
Left: BitConverter.DoubleToInt64Bits(p.BoundingBox.Left))).ToArray();
|
|
var partSpacing = plate.PartSpacing;
|
|
var edgeSpacing = plate.EdgeSpacing;
|
|
using var cts = new CancellationTokenSource();
|
|
var seen = false;
|
|
|
|
var result = Expander.ExpandObserved(new List<Part> { anchor, moving }, plate,
|
|
new Expander.Options { MaxSpacing = 3 }, checkpoint =>
|
|
{
|
|
if (checkpoint != cancelAt)
|
|
return;
|
|
seen = true;
|
|
cts.Cancel();
|
|
}, cts.Token);
|
|
|
|
Assert.True(seen, $"checkpoint {cancelAt} was not reached");
|
|
Assert.True(result.Canceled);
|
|
foreach (var state in before)
|
|
{
|
|
Assert.Same(state.Program, state.Part.Program);
|
|
Assert.Equal(state.X, BitConverter.DoubleToInt64Bits(state.Part.Location.X));
|
|
Assert.Equal(state.Y, BitConverter.DoubleToInt64Bits(state.Part.Location.Y));
|
|
Assert.Equal(state.Left, BitConverter.DoubleToInt64Bits(state.Part.BoundingBox.Left));
|
|
}
|
|
Assert.Equal(partSpacing, plate.PartSpacing);
|
|
Assert.Equal(edgeSpacing, plate.EdgeSpacing);
|
|
Assert.Equal(0, wall.Location.X);
|
|
}
|
|
|
|
[Fact]
|
|
public void Separate_PinnedBetweenFixedParts_StopsAfterOneNoProgressPass()
|
|
{
|
|
var plate = MakePlate(20, 20, edge: 0);
|
|
var pinned = AddSquare(plate, 8, 8);
|
|
AddSquare(plate, 3.8, 8);
|
|
AddSquare(plate, 12.2, 8);
|
|
AddSquare(plate, 8, 3.8);
|
|
AddSquare(plate, 8, 12.2);
|
|
var passes = 0;
|
|
var initial = pinned.Location;
|
|
|
|
var (converged, poses, violations) = Expander.SeparateObserved(
|
|
new List<Part> { pinned }, plate, spacing: 1,
|
|
checkpoint =>
|
|
{
|
|
if (checkpoint == Expander.Checkpoint.AfterPairPass)
|
|
passes++;
|
|
}, maxIterations: 10000
|
|
);
|
|
|
|
Assert.False(converged);
|
|
Assert.NotEmpty(violations);
|
|
Assert.Equal(1, passes); // Not a MaxIterations spin or wall-clock assertion.
|
|
Assert.Equal(initial, poses[0]);
|
|
Assert.Equal(initial, pinned.Location);
|
|
}
|
|
|
|
[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);
|
|
}
|
|
}
|