From 20395eed9bbcc035347d156b53c05f9c83b9d170 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Mon, 28 Sep 2026 18:15:53 -0400 Subject: [PATCH] fix(core): keep lead-in state and rotation through part copy and rotate Part.Clone and CloneAtOffset copied the lead-in program but not HasManualLeadIns, LeadInsLocked, CuttingParameters or the tracked pre-lead-in rotation. A copy of a rotated lead-in part reported rotation 0 (the rebuilt program's), so saving it wrote the wrong rotation and Remove Lead-ins restored an unrotated part. Part.Rotate on a lead-in part set the tracked rotation from the lead-in program's own Rotation, which starts at zero when the cutting strategy rebuilds it, so a further quarter turn left Rotation unchanged. Lead-in parts now accumulate the applied angle instead. --- OpenNest.Core/Part.cs | 31 ++- .../PartLeadInTransformTests.cs | 185 ++++++++++++++++++ 2 files changed, 214 insertions(+), 2 deletions(-) create mode 100644 OpenNest.Tests/CuttingStrategy/PartLeadInTransformTests.cs 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); + } +}