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