diff --git a/OpenNest.IO/CadImporter.cs b/OpenNest.IO/CadImporter.cs
index c3638382..edf90151 100644
--- a/OpenNest.IO/CadImporter.cs
+++ b/OpenNest.IO/CadImporter.cs
@@ -185,6 +185,57 @@ namespace OpenNest.IO
return drawing;
}
+ ///
+ /// The entities an editor shows for an existing drawing, in its source frame: the stored
+ /// source entities with their suppression applied to , or
+ /// for older drawings without them, the program geometry moved back by
+ /// Source.Offset without rapids.
+ ///
+ public static List EditableEntities(Drawing drawing)
+ {
+ if (drawing.SourceEntities != null)
+ {
+ // Use stored entities with stable GUIDs; apply suppression state
+ var entities = new List(drawing.SourceEntities);
+
+ foreach (var entity in entities)
+ entity.IsVisible = !drawing.SuppressedEntityIds.Contains(entity.Id);
+
+ return entities;
+ }
+
+ var derived = ConvertProgram.ToGeometry(drawing.Program);
+
+ // Re-apply source offset so entities appear in their natural position
+ if (drawing.Source?.Offset != null && drawing.Source.Offset != Vector.Zero)
+ {
+ foreach (var entity in derived)
+ entity.Offset(drawing.Source.Offset);
+ }
+
+ // Remove rapid traversals — they aren't part of the cut geometry
+ derived.RemoveAll(e => e.Layer == SpecialLayers.Rapid);
+ return derived;
+ }
+
+ ///
+ /// Applies an edited rebuild of in place, so placed parts keep
+ /// their drawing reference. Geometry, source, customer, quantity and bends are taken from
+ /// ; the library link and cut pins stay, since pins live in the
+ /// source frame and are resolved against the new program when used.
+ ///
+ public static void ApplyEdit(Drawing existing, Drawing edited)
+ {
+ existing.Program = edited.Program;
+ existing.SourceEntities = edited.SourceEntities;
+ existing.SuppressedEntityIds = edited.SuppressedEntityIds;
+ existing.Source = edited.Source;
+ existing.Customer = edited.Customer;
+ existing.Quantity.Required = edited.Quantity.Required;
+ existing.Bends.Clear();
+ existing.Bends.AddRange(edited.Bends);
+ }
+
private static bool IsRepairMark(Entity e) =>
string.Equals(e.Layer?.Name, "ETCH", System.StringComparison.OrdinalIgnoreCase)
|| string.Equals(e.Layer?.Name, "SCRIBE", System.StringComparison.OrdinalIgnoreCase);
diff --git a/OpenNest.Tests/CuttingPlanning/CutPinFrameLifecycleTests.cs b/OpenNest.Tests/CuttingPlanning/CutPinFrameLifecycleTests.cs
new file mode 100644
index 00000000..f6e87fba
--- /dev/null
+++ b/OpenNest.Tests/CuttingPlanning/CutPinFrameLifecycleTests.cs
@@ -0,0 +1,200 @@
+using OpenNest.CNC.CuttingPlanning;
+using OpenNest.CNC.CuttingStrategy;
+using OpenNest.Converters;
+using OpenNest.Geometry;
+using OpenNest.IO;
+using OpenNest.Math;
+
+namespace OpenNest.Tests.CuttingPlanning;
+
+///
+/// Pins are stored in the drawing's source frame and must land on the same physical point of a
+/// placed part however the drawing was produced, edited, saved, packaged or rotated. The oracle
+/// never uses Source.Offset or part rotation bookkeeping: it recovers the rigid source-to-plate
+/// transform from the placed geometry itself, using two holes of different sizes.
+///
+public class CutPinFrameLifecycleTests
+{
+ // Source (DXF) coordinates, deliberately far from the origin.
+ private static readonly Vector BigHole = new(23, 13);
+ private static readonly Vector SmallHole = new(31.25, 10.75);
+ private const double BigRadius = 1;
+ private const double SmallRadius = 0.25;
+
+ private static readonly Vector[] SourcePins =
+ {
+ new(32, 18), // perimeter corner
+ new(24, 13), // big hole, rightmost point
+ new(31.25, 11), // small hole, top point
+ };
+
+ private static readonly double[] Rotations = { 0, Angle.HalfPI, Angle.ToRadians(37) };
+
+ ///
+ /// A 12 x 8 plate at (20, 10) with two pinned holes and an unpinned first-cut hole: the
+ /// program origin sits on that hole, so hiding it in the converter moves the origin.
+ ///
+ private static (CadImportResult Result, Entity FirstHole) Import()
+ {
+ var firstHole = new Circle(29, 15, 0.5);
+ var entities = new List
+ {
+ new Line(20, 10, 32, 10),
+ new Line(32, 10, 32, 18),
+ new Line(32, 18, 20, 18),
+ new Line(20, 18, 20, 10),
+ new Circle(BigHole, BigRadius),
+ firstHole,
+ new Circle(SmallHole, SmallRadius),
+ };
+ return (new CadImportResult { Entities = entities, Name = "Synthetic plate", SourcePath = "synthetic.dxf" },
+ firstHole);
+ }
+
+ private static Drawing ImportPinned(out CadImportResult result, out Entity firstHole)
+ {
+ (result, firstHole) = Import();
+ var drawing = CadImporter.BuildDrawing(result, result.Entities, result.Bends, 1, null, null);
+ drawing.CutPins.Add(new ContourCutPin(SourcePins[0], new LineLeadIn { Length = 0.25, ApproachAngle = 90 },
+ new NoLeadOut(), new NormalTab { Size = 0.03 }));
+ drawing.CutPins.Add(new ContourCutPin(SourcePins[1], new ArcLeadIn { Radius = 0.1 }, new NoLeadOut()));
+ drawing.CutPins.Add(new ContourCutPin(SourcePins[2], new LineLeadIn { Length = 0.1 },
+ new LineLeadOut { Length = 0.05 }));
+ return drawing;
+ }
+
+ [Fact]
+ public void Import_ReoriginsTheProgramAwayFromTheSourceFrame()
+ {
+ var drawing = ImportPinned(out _, out _);
+
+ Assert.NotEqual(Vector.Zero, drawing.Source.Offset);
+ AssertPinsLand(drawing);
+ }
+
+ [Fact]
+ public void ConverterEditThatMovesTheProgramOrigin_KeepsPinsOnTheirContours()
+ {
+ var drawing = ImportPinned(out var result, out var firstHole);
+ var before = drawing.Source.Offset;
+
+ // Load into the editor, hide the first-cut hole, rebuild, and apply in place as the desktop does.
+ var editable = CadImporter.EditableEntities(drawing);
+ firstHole.IsVisible = false;
+ result.Entities = editable;
+ var visible = editable.Where(e => e.Layer.IsVisible && e.IsVisible).ToList();
+ var edited = CadImporter.BuildDrawing(result, visible, result.Bends, 1, null, null);
+ CadImporter.ApplyEdit(drawing, edited);
+
+ Assert.NotEqual(before, drawing.Source.Offset);
+ Assert.Equal(3, drawing.CutPins.Count);
+ AssertPinsLand(drawing);
+ }
+
+ [Fact]
+ public void ConverterEditOfADrawingWithoutSourceEntities_KeepsPinsOnTheirContours()
+ {
+ var drawing = ImportPinned(out var result, out _);
+ drawing.SourceEntities = null;
+
+ var editable = CadImporter.EditableEntities(drawing);
+ var edited = CadImporter.BuildDrawing(
+ new CadImportResult { Entities = editable, Name = result.Name }, editable, new List(), 1,
+ null, null);
+ CadImporter.ApplyEdit(drawing, edited);
+
+ AssertPinsLand(drawing);
+ }
+
+ [Fact]
+ public void SaveAndReload_KeepsPinsOnEveryPlacement()
+ {
+ var drawing = ImportPinned(out _, out _);
+ var nest = new Nest("Synthetic") { Units = Units.Inches };
+ nest.Drawings.Add(drawing);
+ var plate = new Plate(96, 48);
+ nest.Plates.Add(plate);
+ for (var i = 0; i < Rotations.Length; i++)
+ plate.Parts.Add(Place(drawing, Rotations[i], new Vector(5 + 25 * i, 20)));
+
+ using var stream = new MemoryStream();
+ Assert.True(new NestWriter(nest).Write(stream));
+ var reader = new NestReader(new MemoryStream(stream.ToArray()));
+ var restored = reader.Read();
+
+ Assert.Empty(reader.Warnings);
+ var parts = Assert.Single(restored.Plates).Parts;
+ Assert.Equal(Rotations.Length, parts.Count);
+ foreach (var part in parts)
+ AssertPinsLand(part);
+ }
+
+ [Fact]
+ public void Package_KeepsPinsOnTheirContours()
+ {
+ var drawing = ImportPinned(out _, out _);
+
+ var contents = DrawingPackage.Read(DrawingPackage.Write(drawing, Units.Inches));
+
+ Assert.Equal(drawing.Source.Offset, contents.Drawing.Source.Offset);
+ AssertPinsLand(contents.Drawing);
+ }
+
+ private static Part Place(Drawing drawing, double rotation, Vector location)
+ {
+ var part = new Part(drawing);
+ part.Rotate(rotation);
+ part.Offset(location);
+ return part;
+ }
+
+ /// Places the drawing at each rotation, directly and the way engines do, and checks every pin.
+ private static void AssertPinsLand(Drawing drawing)
+ {
+ foreach (var rotation in Rotations)
+ {
+ AssertPinsLand(Place(drawing, rotation, new Vector(50, 40)));
+ var atOrigin = Part.CreateAtOrigin(drawing, rotation);
+ atOrigin.Offset(new Vector(7, 3));
+ AssertPinsLand(atOrigin);
+ }
+ }
+
+ private static void AssertPinsLand(Part part)
+ {
+ var results = CutPinResolver.Resolve(part);
+
+ Assert.Equal(SourcePins.Length, results.Count);
+ Assert.All(results, r => Assert.True(r.IsResolved, $"Pin {r.PinIndex + 1}: {r.Status}"));
+ Assert.Equal(SourcePins.Length, results.Select(r => r.ContourOrdinal).Distinct().Count());
+ var toPlate = SourceToPlate(part);
+ for (var i = 0; i < SourcePins.Length; i++)
+ {
+ var expected = toPlate(SourcePins[i]);
+ var actual = results[i].Point + part.Location;
+ Assert.True(expected.DistanceTo(actual) <= 1e-9,
+ $"Pin {i + 1} at rotation {part.Rotation}: expected {expected}, got {actual}.");
+ }
+ }
+
+ /// The rigid source-to-plate transform recovered from the placed holes alone.
+ private static Func SourceToPlate(Part part)
+ {
+ var shapes = ShapeBuilder.GetShapes(
+ ConvertProgram.ToGeometry(part.Program).Where(e => SpecialLayers.IsMaterial(e.Layer)));
+ var big = HoleCentre(shapes, BigRadius) + part.Location;
+ var small = HoleCentre(shapes, SmallRadius) + part.Location;
+ var turn = Direction(small - big) - Direction(SmallHole - BigHole);
+ return source => (source - BigHole).Rotate(turn) + big;
+ }
+
+ // A circle's bounding box is centred on it at any rotation.
+ private static Vector HoleCentre(List shapes, double radius)
+ {
+ var box = Assert.Single(shapes.Select(s => s.Entities.GetBoundingBox()),
+ b => System.Math.Abs(b.Width - 2 * radius) < 1e-6 && System.Math.Abs(b.Length - 2 * radius) < 1e-6);
+ return box.Center;
+ }
+
+ private static double Direction(Vector v) => System.Math.Atan2(v.Y, v.X);
+}
diff --git a/OpenNest/Forms/CadConverterForm.cs b/OpenNest/Forms/CadConverterForm.cs
index a576ee99..1ba9abcf 100644
--- a/OpenNest/Forms/CadConverterForm.cs
+++ b/OpenNest/Forms/CadConverterForm.cs
@@ -804,32 +804,7 @@ namespace OpenNest.Forms
{
foreach (var drawing in drawings)
{
- List entities;
-
- if (drawing.SourceEntities != null)
- {
- // Use stored entities with stable GUIDs; apply suppression state
- entities = new List(drawing.SourceEntities);
-
- foreach (var entity in entities)
- entity.IsVisible = !drawing.SuppressedEntityIds.Contains(entity.Id);
- }
- else
- {
- // Fallback: derive entities from Program (older drawings without source entities)
- entities = ConvertProgram.ToGeometry(drawing.Program);
-
- // Re-apply source offset so entities appear in their natural position
- if (drawing.Source?.Offset != null && drawing.Source.Offset != Vector.Zero)
- {
- foreach (var entity in entities)
- entity.Offset(drawing.Source.Offset);
- }
-
- // Remove rapid traversals — they aren't part of the cut geometry
- entities.RemoveAll(e => e.Layer == SpecialLayers.Rapid);
- }
-
+ var entities = CadImporter.EditableEntities(drawing);
var bounds = entities.GetBoundingBox();
var item = new FileListItem
diff --git a/OpenNest/Forms/EditNestForm.cs b/OpenNest/Forms/EditNestForm.cs
index ff4d8218..e5298d7f 100644
--- a/OpenNest/Forms/EditNestForm.cs
+++ b/OpenNest/Forms/EditNestForm.cs
@@ -1290,14 +1290,7 @@ namespace OpenNest.Forms
{
if (newByName.TryGetValue(existing.Name, out var updated))
{
- existing.Program = updated.Program;
- existing.SourceEntities = updated.SourceEntities;
- existing.SuppressedEntityIds = updated.SuppressedEntityIds;
- existing.Source = updated.Source;
- existing.Customer = updated.Customer;
- existing.Quantity.Required = updated.Quantity.Required;
- existing.Bends.Clear();
- existing.Bends.AddRange(updated.Bends);
+ CadImporter.ApplyEdit(existing, updated);
newByName.Remove(existing.Name);
}
else