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