diff --git a/OpenNest.Core/Converters/DrawingMarkRecovery.cs b/OpenNest.Core/Converters/DrawingMarkRecovery.cs
new file mode 100644
index 00000000..df92fc1a
--- /dev/null
+++ b/OpenNest.Core/Converters/DrawingMarkRecovery.cs
@@ -0,0 +1,170 @@
+using System.Collections.Generic;
+using System.Linq;
+using OpenNest.CNC;
+using OpenNest.Geometry;
+using OpenNest.Math;
+
+namespace OpenNest.Converters;
+
+///
+/// Puts etch marks back on drawings that lost them. Nests converted from PEP kept etch marks
+/// only in each placed part's saved cutting program; the drawing itself had none, so removing
+/// lead-ins (which rebuilds the part from its drawing) silently removed the marks too.
+///
+public static class DrawingMarkRecovery
+{
+ private const double MatchTolerance = 0.001;
+
+ ///
+ /// For each drawing without etch marks whose saved placed programs all carry the same marks,
+ /// adds those marks to the drawing in its own frame and refreshes the drawing's clean placed
+ /// parts. Saved programs are never changed. Drawings whose saved programs disagree, or that
+ /// already have marks, are left alone. Returns the number of drawings that were given marks.
+ ///
+ public static int Apply(Nest nest)
+ {
+ if (nest == null)
+ return 0;
+
+ var parts = nest.Plates.SelectMany(plate => plate.Parts).ToList();
+ var recovered = 0;
+ foreach (var group in parts.GroupBy(part => part.BaseDrawing, ReferenceEqualityComparer.Instance))
+ {
+ var drawing = (Drawing)group.Key;
+ if (Apply(drawing, group.ToList()))
+ recovered++;
+ }
+ return recovered;
+ }
+
+ /// Recovers marks for one drawing from its placed parts; true when marks were added.
+ public static bool Apply(Drawing drawing, IReadOnlyList placedParts)
+ {
+ if (drawing?.Program == null || drawing.IsCutOff || placedParts == null)
+ return false;
+ // An incremental call's end position is not tracked by Program.EndPoint, so only
+ // flat drawings (which is what conversion produces) can be extended safely.
+ if (HasMarks(drawing.Program) || drawing.Program.Codes.Any(code => code is SubProgramCall))
+ return false;
+
+ var saved = placedParts.Where(part => ReferenceEquals(part.BaseDrawing, drawing) && part.HasManualLeadIns)
+ .ToList();
+ if (saved.Count == 0)
+ return false;
+
+ var marks = MarksInDrawingFrame(saved[0]);
+ if (marks.Count == 0 || saved.Skip(1).Any(part => !SameMarks(marks, MarksInDrawingFrame(part))))
+ return false;
+
+ AppendMarks(drawing.Program, marks);
+ drawing.UpdateArea();
+ drawing.RecomputeCanonicalAngle();
+
+ foreach (var part in placedParts.Where(part => ReferenceEquals(part.BaseDrawing, drawing)
+ && !part.HasManualLeadIns))
+ part.Update();
+ return true;
+ }
+
+ private static bool HasMarks(Program program) =>
+ program.Codes.Any(code => code is LinearMove { Layer: LayerType.Scribe } or ArcMove { Layer: LayerType.Scribe }
+ || code is SubProgramCall { Program: not null } call && HasMarks(call.Program));
+
+ ///
+ /// The part's marks as they would sit in its drawing:
+ /// rebuilds the part as the drawing turned by , so undo that turn.
+ ///
+ private static List MarksInDrawingFrame(Part part)
+ {
+ var marks = ConvertProgram.ToGeometry(part.Program)
+ .Where(entity => entity.Layer == SpecialLayers.Scribe)
+ .ToList();
+ if (!Tolerance.IsEqualTo(part.Rotation, 0))
+ foreach (var mark in marks)
+ mark.Rotate(-part.Rotation);
+ return marks;
+ }
+
+ private static bool SameMarks(List expected, List actual)
+ {
+ if (expected.Count != actual.Count)
+ return false;
+ var unmatched = actual.ToList();
+ foreach (var mark in expected)
+ {
+ var index = unmatched.FindIndex(other => SameMark(mark, other));
+ if (index < 0)
+ return false;
+ unmatched.RemoveAt(index);
+ }
+ return true;
+ }
+
+ // Direction does not matter for an etch: both marks must lie on each other.
+ private static bool SameMark(Entity a, Entity b) =>
+ a.Type == b.Type && LiesOn(Samples(a), b) && LiesOn(Samples(b), a);
+
+ private static bool LiesOn(List points, Entity entity) =>
+ points.Count > 0 && points.All(point => entity.ClosestPointTo(point).DistanceTo(point) <= MatchTolerance);
+
+ private static List Samples(Entity entity) => entity switch
+ {
+ Line line => new List
+ {
+ line.StartPoint,
+ line.EndPoint,
+ new((line.StartPoint.X + line.EndPoint.X) / 2, (line.StartPoint.Y + line.EndPoint.Y) / 2),
+ },
+ Arc arc => new List { arc.StartPoint(), arc.EndPoint(), arc.MidPoint() },
+ Circle circle => new List
+ {
+ new(circle.Center.X + circle.Radius, circle.Center.Y),
+ new(circle.Center.X - circle.Radius, circle.Center.Y),
+ new(circle.Center.X, circle.Center.Y + circle.Radius),
+ new(circle.Center.X, circle.Center.Y - circle.Radius),
+ },
+ _ => new List(),
+ };
+
+ ///
+ /// Appends the marks after the drawing's existing motion, so the existing codes and the
+ /// drawing's first-rapid origin stay exactly as they were.
+ ///
+ private static void AppendMarks(Program program, List marks)
+ {
+ var shape = new Shape();
+ shape.Entities.AddRange(marks);
+ var markProgram = ConvertGeometry.ToProgram(shape);
+
+ // Start from the drawing's end point, with an explicit rapid onto the first mark.
+ var start = EndOf(program);
+ var first = markProgram.Codes.OfType().First();
+ var positioned = new Program(Mode.Absolute);
+ positioned.Codes.Add(new RapidMove(start));
+ if (first is not RapidMove)
+ positioned.Codes.Add(new RapidMove(StartOf(marks[0])));
+ positioned.Codes.AddRange(markProgram.Codes);
+
+ if (program.Mode == Mode.Incremental)
+ positioned.Mode = Mode.Incremental;
+ // Drop the move onto the drawing's end point; it is where the drawing already stops.
+ program.Codes.AddRange(positioned.Codes.Skip(1));
+ }
+
+ // Where a flat program leaves the head (callers exclude sub-program calls).
+ private static Vector EndOf(Program program)
+ {
+ var position = Vector.Zero;
+ foreach (var motion in program.Codes.OfType())
+ position = program.Mode == Mode.Incremental ? position + motion.EndPoint : motion.EndPoint;
+ return position;
+ }
+
+ private static Vector StartOf(Entity entity) => entity switch
+ {
+ Line line => line.StartPoint,
+ Arc arc => arc.StartPoint(),
+ Circle circle => new Vector(circle.Center.X + circle.Radius, circle.Center.Y),
+ _ => Vector.Zero,
+ };
+}
diff --git a/OpenNest.IO/NestReader.cs b/OpenNest.IO/NestReader.cs
index a54ba1e8..e14c7f1d 100644
--- a/OpenNest.IO/NestReader.cs
+++ b/OpenNest.IO/NestReader.cs
@@ -46,6 +46,9 @@ namespace OpenNest.IO
var drawingMap = BuildDrawings(dto, programs, entitySets);
ReadBestFits(drawingMap);
var nest = BuildNest(dto, drawingMap);
+ // Nests converted from PEP kept etch marks only in the placed programs; give them
+ // back to the drawing so removing lead-ins does not remove the marks too.
+ DrawingMarkRecovery.Apply(nest);
zipArchive.Dispose();
stream.Close();
diff --git a/OpenNest.Tests/IO/ConvertedEtchMarkTests.cs b/OpenNest.Tests/IO/ConvertedEtchMarkTests.cs
new file mode 100644
index 00000000..0db56751
--- /dev/null
+++ b/OpenNest.Tests/IO/ConvertedEtchMarkTests.cs
@@ -0,0 +1,232 @@
+using OpenNest.CNC;
+using OpenNest.Converters;
+using OpenNest.Geometry;
+using OpenNest.IO;
+
+namespace OpenNest.Tests.IO;
+
+///
+/// Nests converted from PEP carry etch marks only in each placed part's locked cutting program;
+/// the drawing has none. Removing lead-ins rebuilds a part from its drawing, so loading such a
+/// nest must give the marks back to the drawing or clearing lead-ins erases them.
+///
+public class ConvertedEtchMarkTests
+{
+ private const double HalfTurn = System.Math.PI;
+ private const double QuarterTurn = System.Math.PI / 2;
+
+ [Theory]
+ [InlineData(Mode.Incremental)]
+ [InlineData(Mode.Absolute)]
+ public void ClearingLeadInsKeepsEtchMarksOfConvertedDrawing(Mode mode)
+ {
+ var loaded = Read(Save(ConvertedNest(mode)));
+ var parts = loaded.Plates[0].Parts;
+
+ AppliedCuttingWork.Clear(loaded.Plates.ToList());
+
+ Assert.All(parts, part => Assert.False(part.HasManualLeadIns));
+ AssertMarks(Expected(0, new Vector(0, 0)), parts[0]);
+ AssertMarks(Expected(QuarterTurn, new Vector(30, 2)), parts[1]);
+ AssertMarks(Expected(HalfTurn, new Vector(50, 10)), parts[2]);
+ }
+
+ ///
+ /// Turning a mark is not symmetric, so a slanted placement proves the marks are put back in
+ /// the drawing's own frame rather than the frame of the placement they were read from.
+ ///
+ [Theory]
+ [InlineData(Mode.Incremental)]
+ [InlineData(Mode.Absolute)]
+ public void MarksOfASlantedPlacementAreTurnedBackIntoTheDrawingFrame(Mode mode)
+ {
+ const double slant = System.Math.PI / 6;
+ var drawing = new Drawing("converted", Build(marks: false, mode));
+ var clean = new Part(drawing);
+ clean.Rotate(slant);
+ clean.Offset(12, 7);
+ var nest = NestOf(drawing, SavedPart(drawing, Build(marks: true, mode), slant, new Vector(12, 7)), clean);
+
+ var loaded = Read(Save(nest));
+ var parts = loaded.Plates[0].Parts;
+
+ Assert.Equal(2, MarkMoves(loaded.Drawings.Single().Program));
+ var inDrawingFrame = MarkSamples(loaded.Drawings.Single().Program, Vector.Zero);
+ Assert.Equal(MarkSamples(Build(marks: true, Mode.Incremental), Vector.Zero),
+ inDrawingFrame, VectorSame);
+ AssertMarks(Expected(slant, new Vector(12, 7)), parts[1]);
+ AppliedCuttingWork.Clear(loaded.Plates.ToList());
+ AssertMarks(Expected(slant, new Vector(12, 7)), parts[0]);
+ AssertMarks(Expected(slant, new Vector(12, 7)), parts[1]);
+ }
+
+ [Fact]
+ public void LoadingGivesTheMarksToCleanPartsAndLeavesSavedProgramsAlone()
+ {
+ var nest = ConvertedNest(Mode.Incremental);
+ var saved = nest.Plates[0].Parts.Take(2).Select(p => NestWriter.GetProgramText(p.Program)).ToArray();
+
+ var loaded = Read(Save(nest));
+ var parts = loaded.Plates[0].Parts;
+
+ Assert.Equal(2, MarkMoves(loaded.Drawings.Single().Program));
+ Assert.True(parts[0].LeadInsLocked);
+ Assert.Equal(saved[0], NestWriter.GetProgramText(parts[0].Program));
+ Assert.Equal(saved[1], NestWriter.GetProgramText(parts[1].Program));
+ Assert.False(parts[2].HasManualLeadIns);
+ AssertMarks(Expected(HalfTurn, new Vector(50, 10)), parts[2]);
+ }
+
+ [Fact]
+ public void RecoveredMarksAreSavedOnceAndKeepTheSavedPrograms()
+ {
+ var once = Read(Save(ConvertedNest(Mode.Incremental)));
+ var text = NestWriter.GetProgramText(once.Drawings.Single().Program);
+
+ var twice = Read(Save(once));
+
+ Assert.Equal(text, NestWriter.GetProgramText(twice.Drawings.Single().Program));
+ Assert.Equal(2, MarkMoves(twice.Drawings.Single().Program));
+ Assert.True(twice.Plates[0].Parts[0].HasManualLeadIns);
+ Assert.True(twice.Plates[0].Parts[1].HasManualLeadIns);
+ }
+
+ [Fact]
+ public void DrawingThatAlreadyHasMarksIsUnchanged()
+ {
+ var drawing = new Drawing("imported", Build(marks: true, Mode.Incremental));
+ var text = NestWriter.GetProgramText(drawing.Program);
+ var part = new Part(drawing);
+ part.Rotate(QuarterTurn);
+ part.Offset(30, 2);
+ part.ApplyLeadIns(new OpenNest.CNC.CuttingStrategy.CuttingParameters(), new Vector(-5, -5));
+ var nest = NestOf(drawing, part);
+
+ var loaded = Read(Save(nest));
+ AppliedCuttingWork.Clear(loaded.Plates.ToList());
+
+ Assert.Equal(text, NestWriter.GetProgramText(loaded.Drawings.Single().Program));
+ AssertMarks(Expected(QuarterTurn, new Vector(30, 2)), loaded.Plates[0].Parts[0]);
+ }
+
+ [Fact]
+ public void PartsWithDifferentMarksLeaveTheDrawingWithout()
+ {
+ var drawing = new Drawing("converted", Build(marks: false, Mode.Incremental));
+ var withMarks = SavedPart(drawing, Build(marks: true, Mode.Incremental), 0, new Vector(0, 0));
+ var other = Build(marks: true, Mode.Incremental);
+ other.Codes.RemoveAt(other.Codes.FindLastIndex(code => code is ArcMove));
+ var withLineOnly = SavedPart(drawing, other, 0, new Vector(20, 0));
+
+ var reader = new NestReader(new MemoryStream(Save(NestOf(drawing, withMarks, withLineOnly))));
+ var loaded = reader.Read();
+
+ Assert.Empty(reader.Warnings);
+ Assert.Equal(0, MarkMoves(loaded.Drawings.Single().Program));
+ Assert.True(loaded.Plates[0].Parts.All(p => p.HasManualLeadIns));
+ }
+
+ ///
+ /// A converted nest: the drawing is material only, two placements keep the full PEP cutting
+ /// program (etch line and etch circle) at different turns, and one placement is clean.
+ ///
+ private static Nest ConvertedNest(Mode mode)
+ {
+ var drawing = new Drawing("converted", Build(marks: false, mode));
+ var clean = new Part(drawing);
+ clean.Rotate(HalfTurn);
+ clean.Offset(50, 10);
+ return NestOf(drawing,
+ SavedPart(drawing, Build(marks: true, mode), 0, new Vector(0, 0)),
+ SavedPart(drawing, Build(marks: true, mode), QuarterTurn, new Vector(30, 2)),
+ clean);
+ }
+
+ private static Part SavedPart(Drawing drawing, Program cutting, double rotation, Vector location)
+ {
+ var part = new Part(drawing);
+ part.Rotate(rotation);
+ part.Offset(location);
+ var program = (Program)cutting.Clone();
+ program.Rotate(rotation);
+ Assert.True(part.RestoreLeadInProgram(program, locked: true));
+ return part;
+ }
+
+ /// A 10 x 6 plate with an etch line and an etch circle; without marks they are rapids.
+ private static Program Build(bool marks, Mode mode)
+ {
+ var program = new Program(Mode.Absolute);
+ program.Codes.Add(new RapidMove(2, 3));
+ program.Codes.Add(marks ? new LinearMove(4, 3) { Layer = LayerType.Scribe } : new RapidMove(4, 3));
+ program.Codes.Add(new RapidMove(7.5, 3));
+ program.Codes.Add(marks
+ ? new ArcMove(new Vector(7.5, 3), new Vector(7, 3), RotationType.CCW) { Layer = LayerType.Scribe }
+ : new RapidMove(7.5, 3));
+ program.Codes.Add(new RapidMove(0, 0));
+ program.Codes.Add(new LinearMove(10, 0));
+ program.Codes.Add(new LinearMove(10, 6));
+ program.Codes.Add(new LinearMove(0, 6));
+ program.Codes.Add(new LinearMove(0, 0));
+ program.Mode = mode;
+ return program;
+ }
+
+ private static Nest NestOf(Drawing drawing, params Part[] parts)
+ {
+ var nest = new Nest { Name = "converted-etch" };
+ nest.Drawings.Add(drawing);
+ var plate = new Plate { Size = new Size(48, 96) };
+ foreach (var part in parts)
+ plate.Parts.Add(part);
+ nest.Plates.Add(plate);
+ return nest;
+ }
+
+ /// World-space mark samples of the full program turned and placed like a part.
+ private static List Expected(double rotation, Vector location)
+ {
+ var program = Build(marks: true, Mode.Incremental);
+ program.Rotate(rotation);
+ return MarkSamples(program, location);
+ }
+
+ private static void AssertMarks(List expected, Part part)
+ {
+ var actual = MarkSamples(part.Program, part.Location);
+ Assert.Equal(expected.Count, actual.Count);
+ foreach (var point in expected)
+ {
+ var index = actual.FindIndex(p => p.DistanceTo(point) <= 1e-4);
+ Assert.True(index >= 0, $"No etch mark at {point} on the part at {part.Location}.");
+ actual.RemoveAt(index);
+ }
+ }
+
+ // Turn-independent samples: line ends, and each circle's centre and radius.
+ private static List MarkSamples(Program program, Vector location) =>
+ ConvertProgram.ToGeometry(program)
+ .Where(entity => entity.Layer == SpecialLayers.Scribe)
+ .SelectMany(entity => entity switch
+ {
+ Line line => new[] { line.StartPoint + location, line.EndPoint + location },
+ Circle circle => new[] { circle.Center + location, new Vector(circle.Radius, 0) },
+ Arc arc => new[] { arc.StartPoint() + location, arc.EndPoint() + location },
+ _ => Array.Empty(),
+ })
+ .ToList();
+
+ private static bool VectorSame(Vector left, Vector right) => left.DistanceTo(right) <= 1e-6;
+
+ private static int MarkMoves(Program program) =>
+ program.Codes.Count(code => code is LinearMove { Layer: LayerType.Scribe } or ArcMove { Layer: LayerType.Scribe });
+
+ private static byte[] Save(Nest nest)
+ {
+ using var stream = new MemoryStream();
+ Assert.True(new NestWriter(nest).Write(stream));
+ return stream.ToArray();
+ }
+
+ private static Nest Read(byte[] bytes) => new NestReader(new MemoryStream(bytes)).Read();
+}
diff --git a/docs/nest-file-format.md b/docs/nest-file-format.md
index 0c7b96c2..73a8cb27 100644
--- a/docs/nest-file-format.md
+++ b/docs/nest-file-format.md
@@ -50,6 +50,17 @@ the plate, zero-based part index, and entry; File > Open displays these warnings
The program reader is not a general G-code or geometric safety validator; the
saved cutting geometry is not validated against the drawing.
+Nests converted from PEP before their drawings carried etch marks keep those
+marks only in the saved placed programs. When a drawing has no etch (scribe)
+moves and every placement with a saved program carries the same marks, the
+reader adds those marks to the drawing in its own frame (after its existing
+moves) and refreshes that drawing's clean placements, so Remove Lead-ins and
+Clear Lead-ins & Cut-offs keep the marks. Saved programs are never changed. If
+the saved programs disagree, the drawing is left without marks and no warning
+is raised. The drawing hash is checked against the file text before this
+repair, so saved programs still load; the next save writes the marks into
+`programs/program-N`.
+
## Plate cutting parameters
Each plate's optional `cuttingParameters` stores the settings used by Assign