diff --git a/OpenNest.Core/PartGeometry.cs b/OpenNest.Core/PartGeometry.cs index 020d1ac..b6d5bc4 100644 --- a/OpenNest.Core/PartGeometry.cs +++ b/OpenNest.Core/PartGeometry.cs @@ -31,20 +31,9 @@ namespace OpenNest double chordTolerance = 0.001 ) { - var entities = ConvertProgram.ToGeometry(part.Program); - var shapes = ShapeBuilder.GetShapes( - entities.Where(e => SpecialLayers.IsMaterial(e.Layer)) + return GetDirectionalPartLines( + part, chordTolerance, useVector: false, facingDirection, default ); - var lines = new List(); - - foreach (var shape in shapes) - { - var polygon = shape.ToPolygonWithTolerance(chordTolerance); - polygon.Offset(part.Location); - lines.AddRange(GetDirectionalLines(polygon, facingDirection)); - } - - return lines; } /// @@ -162,6 +151,19 @@ namespace OpenNest Vector facingDirection, double chordTolerance = 0.001 ) + { + return GetDirectionalPartLines( + part, chordTolerance, useVector: true, default, facingDirection + ); + } + + private static List GetDirectionalPartLines( + Part part, + double chordTolerance, + bool useVector, + PushDirection cardinalDirection, + Vector vectorDirection + ) { var entities = ConvertProgram.ToGeometry(part.Program); var shapes = ShapeBuilder.GetShapes( @@ -173,7 +175,10 @@ namespace OpenNest { var polygon = shape.ToPolygonWithTolerance(chordTolerance); polygon.Offset(part.Location); - lines.AddRange(GetDirectionalLines(polygon, facingDirection)); + // Keep the overload-specific arithmetic and invalid-direction behavior. + lines.AddRange(useVector + ? GetDirectionalLines(polygon, vectorDirection) + : GetDirectionalLines(polygon, cardinalDirection)); } return lines; diff --git a/OpenNest.Tests/Geometry/PartGeometryDirectionalPreparationTests.cs b/OpenNest.Tests/Geometry/PartGeometryDirectionalPreparationTests.cs new file mode 100644 index 0000000..679a81b --- /dev/null +++ b/OpenNest.Tests/Geometry/PartGeometryDirectionalPreparationTests.cs @@ -0,0 +1,355 @@ +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 MaterialIncludesDisplayAndLeads_ButExcludesRapidAndScribe() + { + var material = RectangleProgram(false); + AddRectangle(material, 15, 2, 3, 2, LayerType.Display); + AddRectangle(material, 21, 3, 2, 4, LayerType.Leadin); + AddRectangle(material, 26, 1, 4, 3, LayerType.Leadout); + var clean = MakePart(material); + 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(4, expected.Count); + AssertLines(expected, PartGeometry.GetPartLines(marked, direction)); + AssertMatches(marked, 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(() => FrozenGetPartLines(null!, PushDirection.Right)); + Assert.Throws(() => PartGeometry.GetPartLines(null!, PushDirection.Right)); + Assert.Throws(() => FrozenGetPartLines(null!, new Vector(1, 0))); + Assert.Throws(() => PartGeometry.GetPartLines(null!, new Vector(1, 0))); + var part = MakePart(RectangleProgram(false)); + part.Program.Codes.Add(null!); + Assert.Throws(() => FrozenGetPartLines(part, PushDirection.Right)); + Assert.Throws(() => PartGeometry.GetPartLines(part, PushDirection.Right)); + Assert.Throws(() => FrozenGetPartLines(part, new Vector(1, 0))); + Assert.Throws(() => 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)program.Mode, Bits(program.Rotation) }; + foreach (var code in program.Codes) + { + values.Add((long)code.Type); + var motion = Assert.IsAssignableFrom(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 expected, IReadOnlyList 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 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(); + foreach (var shape in shapes) + { + var polygon = shape.ToPolygonWithTolerance(chordTolerance); + polygon.Offset(part.Location); + lines.AddRange(FrozenDirectionalLines(polygon, facingDirection)); + } + return lines; + } + + private static List 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(); + foreach (var shape in shapes) + { + var polygon = shape.ToPolygonWithTolerance(chordTolerance); + polygon.Offset(part.Location); + lines.AddRange(FrozenDirectionalLines(polygon, facingDirection)); + } + return lines; + } + + private static List 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(); + 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 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(); + 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; + } +}