Files
OpenNest/OpenNest.Tests/Geometry/PartGeometryDirectionalPreparationTests.cs
T
ajandClaude Opus 5.5 d133f6879b fix(geometry): leave leads out of push and collision material
A part with lead-ins applied could not be pushed away once another part
touched it. PartGeometry kept LEADIN/LEADOUT entities as material, so the
leads chained onto the perimeter as open spurs and the spacing offset no
longer closed. The slide-contact classifier cannot tell the material side
of an unclosed boundary and blocks every contact, so parts resting against
it could not move up or right off it. Leads are cut into scrap, and the
spacing review, overlap analyzer and support checks already drop them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 09:44:43 -05:00

356 lines
14 KiB
C#

using OpenNest.CNC;
using OpenNest.Converters;
using OpenNest.Geometry;
namespace OpenNest.Tests.Geometry;
public class PartGeometryDirectionalPreparationTests
{
[Theory]
[InlineData(PushDirection.Left, false, -1, 0)]
[InlineData(PushDirection.Right, false, 1, 0)]
[InlineData(PushDirection.Up, false, 0, 1)]
[InlineData(PushDirection.Down, false, 0, -1)]
[InlineData(PushDirection.Left, true, -1, 0)]
[InlineData(PushDirection.Right, true, 1, 0)]
[InlineData(PushDirection.Up, true, 0, 1)]
[InlineData(PushDirection.Down, true, 0, -1)]
public void Cardinals_PreserveOrderedEndpointsAndWinding(
PushDirection direction, bool reverse, double x, double y)
{
var part = MakePart(RectangleProgram(reverse));
var expected = FrozenGetPartLines(part, direction);
Assert.Single(expected);
AssertMatches(part, direction, new Vector(x, y), 0.001);
AssertLines(expected, FrozenGetPartLines(part, new Vector(x, y)));
}
[Theory]
[InlineData(0.1)]
[InlineData(0.001)]
[InlineData(0.00001)]
public void CurvedPerimeterAndMultipleHoles_PreserveTessellationAndShapeOrder(double tolerance)
{
var program = CurvedProgram();
program.Mode = Mode.Incremental;
var part = MakePart(program);
var expected = FrozenGetPartLines(part, PushDirection.Right, tolerance);
Assert.True(expected.Count > 3);
AssertMatches(part, PushDirection.Right, new Vector(2.75, -0.625), tolerance);
// The non-directional overload is deliberately outside this extraction.
AssertLines(FrozenGetPartLines(part, (PushDirection)123, tolerance),
PartGeometry.GetPartLines(part, tolerance));
Assert.True(FrozenGetPartLines(part, PushDirection.Right, 0.00001).Count
> FrozenGetPartLines(part, PushDirection.Right, 0.1).Count);
}
[Fact]
public void MaterialIncludesDisplay_ButExcludesLeadsRapidAndScribe()
{
var material = RectangleProgram(false);
AddRectangle(material, 15, 2, 3, 2, LayerType.Display);
var clean = MakePart(material);
AddRectangle(material, 21, 3, 2, 4, LayerType.Leadin);
AddRectangle(material, 26, 1, 4, 3, LayerType.Leadout);
AddRectangle(material, 100, 120, 7, 9, LayerType.Scribe);
material.MoveTo(-200, -300);
material.MoveTo(400, 500);
var marked = MakePart(material);
foreach (var direction in new[] { PushDirection.Left, PushDirection.Right, PushDirection.Up, PushDirection.Down })
{
var expected = FrozenGetPartLines(clean, direction);
Assert.Equal(2, expected.Count);
AssertLines(expected, PartGeometry.GetPartLines(marked, direction));
AssertMatches(clean, direction, new Vector(3, -2), 0.001);
}
AssertLines(PartGeometry.GetPartLines(clean), PartGeometry.GetPartLines(marked));
}
[Theory]
[InlineData(-1)]
[InlineData(123)]
public void InvalidEnum_KeepsAllEdgesRatherThanUsingZeroVector(int value)
{
var part = MakePart(RectangleProgram(false));
var direction = (PushDirection)value;
Assert.Equal(4, FrozenGetPartLines(part, direction).Count);
Assert.Empty(FrozenGetPartLines(part, new Vector(0, 0)));
AssertMatches(part, direction, new Vector(0, 0), 0.001);
AssertLines(PartGeometry.GetPartLines(part), PartGeometry.GetPartLines(part, direction));
}
[Theory]
[InlineData(0, 0)]
[InlineData(2.75, -0.625)]
[InlineData(-19, 7)]
[InlineData(double.Epsilon, -double.Epsilon)]
[InlineData(double.MaxValue, double.MaxValue)]
[InlineData(double.PositiveInfinity, 0)]
[InlineData(0, double.NegativeInfinity)]
[InlineData(double.NaN, 1)]
public void Vectors_PreserveUnnormalizedZeroAndNonfiniteArithmetic(double x, double y)
{
var part = MakePart(RectangleProgram(true));
var direction = new Vector(x, y);
AssertMatches(part, PushDirection.Down, direction, 0.001);
if (x == 0 && y == 0 || double.IsNaN(x))
Assert.Empty(PartGeometry.GetPartLines(part, direction));
}
[Theory]
[InlineData(double.PositiveInfinity, 0)]
[InlineData(0, double.NegativeInfinity)]
[InlineData(double.NaN, 1)]
public void NonfiniteTranslation_PreservesDistinctCardinalAndVectorFilters(double x, double y)
{
var part = MakePart(RectangleProgram(false));
part.Location = new Vector(x, y);
AssertMatches(part, PushDirection.Right, new Vector(1, 0), 0.001);
AssertMatches(part, (PushDirection)123, new Vector(0, 0), 0.001);
if (double.IsPositiveInfinity(x))
{
Assert.Single(PartGeometry.GetPartLines(part, PushDirection.Right));
// Infinite X makes edx NaN; multiplying that by a zero vector component
// is not equivalent to the enum filter, which reads only dy here.
Assert.Empty(PartGeometry.GetPartLines(part, new Vector(1, 0)));
}
}
[Theory]
[InlineData(0)]
[InlineData(1)]
[InlineData(2)]
public void EmptyAndDegenerateContours_PreserveFallback(int points)
{
var program = new Program();
if (points > 0)
{
program.MoveTo(1, 2);
program.LineTo(points == 1 ? 1 : 5, points == 1 ? 2 : 3);
}
var part = MakePart(program);
AssertMatches(part, (PushDirection)123, new Vector(0, 0), 0.001);
AssertMatches(part, PushDirection.Up, new Vector(double.NaN, 0), 0.001);
AssertLines(FrozenGetPartLines(part, (PushDirection)123), PartGeometry.GetPartLines(part));
}
[Fact]
public void NullPartAndMalformedProgram_PreserveExceptions()
{
Assert.Throws<NullReferenceException>(() => FrozenGetPartLines(null!, PushDirection.Right));
Assert.Throws<NullReferenceException>(() => PartGeometry.GetPartLines(null!, PushDirection.Right));
Assert.Throws<NullReferenceException>(() => FrozenGetPartLines(null!, new Vector(1, 0)));
Assert.Throws<NullReferenceException>(() => PartGeometry.GetPartLines(null!, new Vector(1, 0)));
var part = MakePart(RectangleProgram(false));
part.Program.Codes.Add(null!);
Assert.Throws<NullReferenceException>(() => FrozenGetPartLines(part, PushDirection.Right));
Assert.Throws<NullReferenceException>(() => PartGeometry.GetPartLines(part, PushDirection.Right));
Assert.Throws<NullReferenceException>(() => FrozenGetPartLines(part, new Vector(1, 0)));
Assert.Throws<NullReferenceException>(() => PartGeometry.GetPartLines(part, new Vector(1, 0)));
}
private static void AssertMatches(Part part, PushDirection cardinal, Vector vector, double tolerance)
{
var program = part.Program;
var codes = program.Codes;
var identities = codes.ToArray();
var values = ProgramBits(program);
var drawingValues = ProgramBits(part.BaseDrawing.Program);
var location = new[] { Bits(part.Location.X), Bits(part.Location.Y) };
AssertLines(FrozenGetPartLines(part, cardinal, tolerance),
PartGeometry.GetPartLines(part, cardinal, tolerance));
Assert.Equal(values, ProgramBits(program));
AssertLines(FrozenGetPartLines(part, vector, tolerance),
PartGeometry.GetPartLines(part, vector, tolerance));
Assert.Equal(values, ProgramBits(program));
Assert.Equal(drawingValues, ProgramBits(part.BaseDrawing.Program));
Assert.Equal(location, new[] { Bits(part.Location.X), Bits(part.Location.Y) });
Assert.Same(program, part.Program);
Assert.Same(codes, program.Codes);
Assert.Equal(identities.Length, codes.Count);
for (var i = 0; i < identities.Length; i++)
Assert.Same(identities[i], codes[i]);
}
private static long[] ProgramBits(Program program)
{
var values = new List<long> { (long)program.Mode, Bits(program.Rotation) };
foreach (var code in program.Codes)
{
values.Add((long)code.Type);
var motion = Assert.IsAssignableFrom<Motion>(code);
values.Add(Bits(motion.EndPoint.X));
values.Add(Bits(motion.EndPoint.Y));
values.Add(motion.Suppressed ? 1 : 0);
if (code is LinearMove line)
values.Add((long)line.Layer);
if (code is ArcMove arc)
{
values.Add((long)arc.Layer);
values.Add((long)arc.Rotation);
values.Add(Bits(arc.CenterPoint.X));
values.Add(Bits(arc.CenterPoint.Y));
}
}
return values.ToArray();
}
private static void AssertLines(IReadOnlyList<Line> expected, IReadOnlyList<Line> actual)
{
Assert.Equal(expected.Count, actual.Count);
for (var i = 0; i < expected.Count; i++)
{
// Vector.Equals is tolerance-based and cannot establish scalar-bit preservation.
Assert.Equal(Bits(expected[i].StartPoint.X), Bits(actual[i].StartPoint.X));
Assert.Equal(Bits(expected[i].StartPoint.Y), Bits(actual[i].StartPoint.Y));
Assert.Equal(Bits(expected[i].EndPoint.X), Bits(actual[i].EndPoint.X));
Assert.Equal(Bits(expected[i].EndPoint.Y), Bits(actual[i].EndPoint.Y));
}
}
private static long Bits(double value) => BitConverter.DoubleToInt64Bits(value);
private static Part MakePart(Program program) =>
new(new Drawing("directional preparation", program), new Vector(13.125, -7.375));
private static Program RectangleProgram(bool reverse)
{
var program = new Program();
program.MoveTo(1, 2);
if (reverse)
{
program.LineTo(1, 5);
program.LineTo(5, 5);
program.LineTo(5, 2);
}
else
{
program.LineTo(5, 2);
program.LineTo(5, 5);
program.LineTo(1, 5);
}
program.LineTo(1, 2);
return program;
}
private static void AddRectangle(Program program, double x, double y, double width, double height, LayerType layer)
{
program.MoveTo(x, y);
program.Codes.Add(new LinearMove(x + width, y) { Layer = layer });
program.Codes.Add(new LinearMove(x + width, y + height) { Layer = layer });
program.Codes.Add(new LinearMove(x, y + height) { Layer = layer });
program.Codes.Add(new LinearMove(x, y) { Layer = layer });
}
private static Program CurvedProgram()
{
var program = new Program();
// A semicircular perimeter joined to three straight edges, a circular hole,
// and a rectangular hole exercise circles-first ShapeBuilder ordering.
program.MoveTo(0, 0);
program.LineTo(8, 0);
program.Codes.Add(new ArcMove(8, 6, 8, 3));
program.LineTo(0, 6);
program.LineTo(0, 0);
program.MoveTo(3, 3);
program.Codes.Add(new ArcMove(3, 3, 2, 3, RotationType.CW));
AddRectangle(program, 5, 2, 1, 2, LayerType.Cut);
return program;
}
// Frozen from PartGeometry at 8664656658d598e55d7bf85c3b392ef6888a593f.
// These independent preparation bodies and distinct filters intentionally remain
// duplicated: exact tokens alone did not prove that their overload bindings agree.
private static List<Line> FrozenGetPartLines(
Part part, PushDirection facingDirection, double chordTolerance = 0.001)
{
var entities = ConvertProgram.ToGeometry(part.Program);
var shapes = ShapeBuilder.GetShapes(
entities.Where(e => SpecialLayers.IsMaterial(e.Layer))
);
var lines = new List<Line>();
foreach (var shape in shapes)
{
var polygon = shape.ToPolygonWithTolerance(chordTolerance);
polygon.Offset(part.Location);
lines.AddRange(FrozenDirectionalLines(polygon, facingDirection));
}
return lines;
}
private static List<Line> FrozenGetPartLines(
Part part, Vector facingDirection, double chordTolerance = 0.001)
{
var entities = ConvertProgram.ToGeometry(part.Program);
var shapes = ShapeBuilder.GetShapes(
entities.Where(e => SpecialLayers.IsMaterial(e.Layer))
);
var lines = new List<Line>();
foreach (var shape in shapes)
{
var polygon = shape.ToPolygonWithTolerance(chordTolerance);
polygon.Offset(part.Location);
lines.AddRange(FrozenDirectionalLines(polygon, facingDirection));
}
return lines;
}
private static List<Line> FrozenDirectionalLines(Polygon polygon, Vector direction)
{
if (polygon.Vertices.Count < 3)
return polygon.ToLines();
var sign = polygon.RotationDirection() == RotationType.CCW ? 1.0 : -1.0;
var lines = new List<Line>();
var last = polygon.Vertices[0];
for (var i = 1; i < polygon.Vertices.Count; i++)
{
var current = polygon.Vertices[i];
var edx = current.X - last.X;
var edy = current.Y - last.Y;
var keep = sign * (edy * direction.X - edx * direction.Y) > 0;
if (keep)
lines.Add(new Line(last, current));
last = current;
}
return lines;
}
private static List<Line> FrozenDirectionalLines(Polygon polygon, PushDirection facingDirection)
{
if (polygon.Vertices.Count < 3)
return polygon.ToLines();
var sign = polygon.RotationDirection() == RotationType.CCW ? 1.0 : -1.0;
var lines = new List<Line>();
var last = polygon.Vertices[0];
for (int i = 1; i < polygon.Vertices.Count; i++)
{
var current = polygon.Vertices[i];
var dx = current.X - last.X;
var dy = current.Y - last.Y;
bool keep;
switch (facingDirection)
{
case PushDirection.Left:
keep = -sign * dy > 0;
break;
case PushDirection.Right:
keep = sign * dy > 0;
break;
case PushDirection.Up:
keep = -sign * dx > 0;
break;
case PushDirection.Down:
keep = sign * dx > 0;
break;
default:
keep = true;
break;
}
if (keep)
lines.Add(new Line(last, current));
last = current;
}
return lines;
}
}