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 = HoleCenter(shapes, BigRadius) + part.Location;
var small = HoleCenter(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 centerd on it at any rotation.
private static Vector HoleCenter(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);
}