diff --git a/OpenNest.Core/Part.cs b/OpenNest.Core/Part.cs
index 27bf791..41b66f0 100644
--- a/OpenNest.Core/Part.cs
+++ b/OpenNest.Core/Part.cs
@@ -145,7 +145,7 @@ namespace OpenNest
EnsureOwnedProgram();
Program.Rotate(angle);
location = Location.Rotate(angle);
- preLeadInRotation = Program.Rotation;
+ TrackRotation(angle);
UpdateBounds();
}
@@ -159,10 +159,22 @@ namespace OpenNest
EnsureOwnedProgram();
Program.Rotate(angle);
location = Location.Rotate(angle, origin);
- preLeadInRotation = Program.Rotation;
+ TrackRotation(angle);
UpdateBounds();
}
+ ///
+ /// Records the part's rotation after it turned by . A lead-in
+ /// program is rebuilt by the cutting strategy and starts over at zero program
+ /// rotation, so for those parts the rotation is accumulated rather than read back.
+ ///
+ private void TrackRotation(double angle)
+ {
+ preLeadInRotation = HasManualLeadIns
+ ? Angle.NormalizeRad(preLeadInRotation + angle)
+ : Program.Rotation;
+ }
+
///
/// Offsets the part.
///
@@ -323,6 +335,7 @@ namespace OpenNest
new Box(BoundingBox.X, BoundingBox.Y, BoundingBox.Length, BoundingBox.Width)
);
part.ownsProgram = true;
+ part.CopyLeadInStateFrom(this);
return part;
}
@@ -347,10 +360,24 @@ namespace OpenNest
BoundingBox.Width
)
);
+ part.CopyLeadInStateFrom(this);
return part;
}
+ ///
+ /// Copies the lead-in state that goes with a copied program. Without it a copy of a
+ /// lead-in part reads its rotation from the rebuilt program (zero), and lead-in
+ /// assignment does not know to strip the copied lead-ins before adding new ones.
+ ///
+ private void CopyLeadInStateFrom(Part source)
+ {
+ HasManualLeadIns = source.HasManualLeadIns;
+ LeadInsLocked = source.LeadInsLocked;
+ CuttingParameters = source.CuttingParameters;
+ preLeadInRotation = source.preLeadInRotation;
+ }
+
private void EnsureOwnedProgram()
{
if (!ownsProgram)
diff --git a/OpenNest.Tests/CuttingStrategy/PartLeadInTransformTests.cs b/OpenNest.Tests/CuttingStrategy/PartLeadInTransformTests.cs
new file mode 100644
index 0000000..aec5be3
--- /dev/null
+++ b/OpenNest.Tests/CuttingStrategy/PartLeadInTransformTests.cs
@@ -0,0 +1,185 @@
+using OpenNest.CNC;
+using OpenNest.CNC.CuttingStrategy;
+using OpenNest.Geometry;
+
+namespace OpenNest.Tests.CuttingStrategy;
+
+///
+/// Copying and rotating a part that already carries lead-ins must keep its true
+/// rotation, lead-in state and geometry, so Remove Lead-ins (and saving) still
+/// see the part the user placed.
+///
+public class PartLeadInTransformTests
+{
+ private const double QuarterTurn = System.Math.PI / 2;
+
+ /// 10x10 square with a radius-1 hole at (5, 5), so lead-in
+ /// assignment emits both a perimeter lead-in and a hole sub-program.
+ private static Drawing MakeSquareWithHole()
+ {
+ var pgm = new Program();
+ pgm.Codes.Add(new RapidMove(new Vector(0, 0)));
+ pgm.Codes.Add(new LinearMove(new Vector(0, 10)));
+ pgm.Codes.Add(new LinearMove(new Vector(10, 10)));
+ pgm.Codes.Add(new LinearMove(new Vector(10, 0)));
+ pgm.Codes.Add(new LinearMove(new Vector(0, 0)));
+ pgm.Codes.Add(new RapidMove(new Vector(6, 5)));
+ pgm.Codes.Add(new ArcMove(new Vector(6, 5), new Vector(5, 5), RotationType.CW));
+ return new Drawing("square-with-hole", pgm);
+ }
+
+ private static CuttingParameters Parameters() =>
+ new()
+ {
+ ExternalLeadIn = new LineLeadIn { Length = 0.5, ApproachAngle = 90 },
+ ArcCircleLeadIn = new LineLeadIn { Length = 0.3, ApproachAngle = 90 },
+ };
+
+ private static Part MakeLeadInPart(Drawing drawing, double rotation)
+ {
+ var part = new Part(drawing);
+ part.Rotate(rotation);
+ part.Offset(20, 5);
+ part.ApplyLeadIns(Parameters(), new Vector(-5, -5));
+ return part;
+ }
+
+ /// Clean part of the drawing at the given rotation and location: what
+ /// Remove Lead-ins must restore.
+ private static Part MakeCleanPart(Drawing drawing, double rotation, Vector location)
+ {
+ var part = new Part(drawing);
+ part.Rotate(rotation);
+ part.Location = location;
+ return part;
+ }
+
+ private static void AssertSameBox(Box expected, Box actual)
+ {
+ Assert.Equal(expected.X, actual.X, 6);
+ Assert.Equal(expected.Y, actual.Y, 6);
+ Assert.Equal(expected.Length, actual.Length, 6);
+ Assert.Equal(expected.Width, actual.Width, 6);
+ }
+
+ private static int LeadInMoveCount(Program program) =>
+ program.Codes.OfType().Count(m => m.Layer == LayerType.Leadin)
+ + program.Codes.OfType().Count(m => m.Layer == LayerType.Leadin);
+
+ [Fact]
+ public void Clone_KeepsLeadInStateAndRotation()
+ {
+ var drawing = MakeSquareWithHole();
+ var source = MakeLeadInPart(drawing, QuarterTurn);
+ source.LeadInsLocked = true;
+
+ var clone = (Part)source.Clone();
+
+ Assert.True(clone.HasManualLeadIns);
+ Assert.True(clone.LeadInsLocked);
+ Assert.Same(source.CuttingParameters, clone.CuttingParameters);
+ Assert.Equal(source.Rotation, clone.Rotation, 6);
+ Assert.Equal(LeadInMoveCount(source.Program), LeadInMoveCount(clone.Program));
+ AssertSameBox(source.BoundingBox, clone.BoundingBox);
+ }
+
+ [Fact]
+ public void CloneAtOffset_KeepsLeadInStateAndRotation()
+ {
+ var drawing = MakeSquareWithHole();
+ var source = MakeLeadInPart(drawing, QuarterTurn);
+
+ var copy = source.CloneAtOffset(new Vector(3, 4));
+
+ Assert.True(copy.HasManualLeadIns);
+ Assert.Equal(source.Rotation, copy.Rotation, 6);
+ Assert.Equal(source.Location.X + 3, copy.Location.X, 6);
+ Assert.Equal(source.Location.Y + 4, copy.Location.Y, 6);
+ }
+
+ [Fact]
+ public void Clone_ThenRemoveLeadIns_RestoresCleanRotatedPart()
+ {
+ var drawing = MakeSquareWithHole();
+ var source = MakeLeadInPart(drawing, QuarterTurn);
+
+ var clone = (Part)source.Clone();
+ clone.RemoveLeadIns();
+
+ var expected = MakeCleanPart(drawing, QuarterTurn, source.Location);
+ Assert.False(clone.HasManualLeadIns);
+ Assert.Equal(QuarterTurn, clone.Rotation, 6);
+ AssertSameBox(expected.BoundingBox, clone.BoundingBox);
+ }
+
+ [Fact]
+ public void Clone_DoesNotShareHoleSubPrograms()
+ {
+ var drawing = MakeSquareWithHole();
+ var source = MakeLeadInPart(drawing, QuarterTurn);
+ var sourceBox = source.Program.BoundingBox();
+
+ var clone = (Part)source.Clone();
+ clone.Rotate(QuarterTurn);
+
+ // Rotating the copy must not rotate the source's hole lead-in.
+ AssertSameBox(sourceBox, source.Program.BoundingBox());
+ Assert.Equal(QuarterTurn, source.Rotation, 6);
+ }
+
+ [Fact]
+ public void Rotate_LeadInPart_ReportsCumulativeRotation()
+ {
+ var drawing = MakeSquareWithHole();
+ var part = MakeLeadInPart(drawing, QuarterTurn);
+
+ part.Rotate(QuarterTurn);
+
+ Assert.True(part.HasManualLeadIns);
+ Assert.Equal(System.Math.PI, part.Rotation, 6);
+ }
+
+ [Fact]
+ public void RotateAboutOrigin_LeadInPart_ReportsCumulativeRotation()
+ {
+ var drawing = MakeSquareWithHole();
+ var part = MakeLeadInPart(drawing, QuarterTurn);
+
+ part.Rotate(QuarterTurn, new Vector(50, 50));
+
+ Assert.Equal(System.Math.PI, part.Rotation, 6);
+ }
+
+ [Fact]
+ public void Rotate_LeadInPart_ThenRemove_RestoresCleanPartAtNewRotation()
+ {
+ var drawing = MakeSquareWithHole();
+ var part = MakeLeadInPart(drawing, QuarterTurn);
+
+ part.Rotate(QuarterTurn);
+ var location = part.Location;
+ part.RemoveLeadIns();
+
+ var expected = MakeCleanPart(drawing, System.Math.PI, location);
+ Assert.Equal(System.Math.PI, part.Rotation, 6);
+ AssertSameBox(expected.BoundingBox, part.BoundingBox);
+ }
+
+ [Fact]
+ public void Rotate_LeadInPart_RotatesHoleLeadInWithPart()
+ {
+ var drawing = MakeSquareWithHole();
+ var part = MakeLeadInPart(drawing, 0);
+ var before = part.Program.BoundingBox();
+
+ part.Rotate(QuarterTurn);
+
+ // A quarter turn about the program origin maps (x, y) to (-y, x): the lead-in
+ // program's box, holes included, must turn with it rather than stay behind.
+ var after = part.Program.BoundingBox();
+ Assert.Equal(-(before.Y + before.Width), after.X, 6);
+ Assert.Equal(before.X, after.Y, 6);
+ Assert.Equal(before.Width, after.Length, 6);
+ Assert.Equal(before.Length, after.Width, 6);
+ }
+}