diff --git a/OpenNest.Core/CNC/CuttingPlanning/CutPinResolver.cs b/OpenNest.Core/CNC/CuttingPlanning/CutPinResolver.cs new file mode 100644 index 00000000..06dfafdc --- /dev/null +++ b/OpenNest.Core/CNC/CuttingPlanning/CutPinResolver.cs @@ -0,0 +1,136 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using OpenNest.CNC.CuttingStrategy; +using OpenNest.Geometry; + +namespace OpenNest.CNC.CuttingPlanning; + +public enum CutPinStatus +{ + /// The pin lies on exactly one contour, which no other pin claims. + Resolved, + + /// The pin no longer lies on any contour of the drawing. + NotOnContour, + + /// The pin lies on more than one contour, so which one it pins is unknown. + OnSeveralContours, + + /// Another pin lies on the same contour; neither is used. + ContourPinnedTwice, + + /// The drawing's program cannot be prepared, or is not in the frame its source offset describes. + UnsupportedGeometry, +} + +/// +/// Where one drawing pin lands on a placed contour. is in the placed +/// part's local frame (add the part's location for plate coordinates): the exact native point +/// when resolved, otherwise where the pin maps to ( when the drawing +/// frame is unknown). Ordinals match captured from the same +/// rotated clean program. +/// +public sealed record CutPinResolution(int PinIndex, ContourCutPin Pin, CutPinStatus Status, int ContourOrdinal, + int EntityOrdinal, Vector Point) +{ + public bool IsResolved => Status == CutPinStatus.Resolved; +} + +/// +/// Maps a drawing's source-frame pins onto its program contours at a placement rotation. A +/// pin never resolves by guessing: it must lie within the contour closure tolerance of exactly +/// one contour that no other pin claims, or it is reported unresolved with the reason. +/// +public static class CutPinResolver +{ + /// + /// Resolves the drawing's pins for a part rotated by radians + /// about the drawing program's origin, as does. + /// + public static IReadOnlyList Resolve(Drawing drawing, double rotation, CancellationToken token = default) + { + ArgumentNullException.ThrowIfNull(drawing); + if (!double.IsFinite(rotation)) + throw new ArgumentOutOfRangeException(nameof(rotation), rotation, "Rotation must be finite."); + var pins = drawing.CutPins ?? new List(); + if (pins.Count == 0) + return Array.Empty(); + if (drawing.Program == null || !IsSourceFrame(drawing)) + return Unsupported(pins); + + var program = (Program)drawing.Program.Clone(); + if (!Math.Tolerance.IsEqualTo(rotation, 0)) + program.Rotate(rotation); + + PreparedContours contours; + try + { + contours = PreparedContours.Capture(program, new CuttingParameters(), token); + } + catch (Exception ex) when (ex is ArgumentException || ex is NotSupportedException) + { + return Unsupported(pins); + } + + return Resolve(drawing, rotation, contours, token); + } + + /// Resolves the pins of a placed part at its current rotation. + public static IReadOnlyList Resolve(Part part, CancellationToken token = default) + { + ArgumentNullException.ThrowIfNull(part); + return Resolve(part.BaseDrawing, part.Rotation, token); + } + + /// + /// Resolves against contours the caller already captured. must + /// come from the drawing's clean program rotated by . + /// + public static IReadOnlyList Resolve(Drawing drawing, double rotation, PreparedContours contours, + CancellationToken token = default) + { + ArgumentNullException.ThrowIfNull(drawing); + ArgumentNullException.ThrowIfNull(contours); + if (!double.IsFinite(rotation)) + throw new ArgumentOutOfRangeException(nameof(rotation), rotation, "Rotation must be finite."); + var pins = drawing.CutPins ?? new List(); + if (pins.Count == 0) + return Array.Empty(); + if (!IsSourceFrame(drawing)) + return Unsupported(pins); + + var offset = drawing.Source?.Offset ?? Vector.Zero; + var results = new CutPinResolution[pins.Count]; + for (var i = 0; i < pins.Count; i++) + { + token.ThrowIfCancellationRequested(); + var pin = pins[i] ?? throw new ArgumentException($"Drawing pin {i + 1} is missing.", nameof(drawing)); + var local = (pin.Point - offset).Rotate(rotation); + var near = contours.ContoursNear(local, NominalContourClosure.Tolerance); + results[i] = near.Count switch + { + 0 => new(i, pin, CutPinStatus.NotOnContour, -1, -1, local), + 1 => new(i, pin, CutPinStatus.Resolved, near[0].ContourOrdinal, near[0].EntityOrdinal, near[0].Point), + _ => new(i, pin, CutPinStatus.OnSeveralContours, -1, -1, local), + }; + } + + // At most one pin per contour. Choosing between two would be a guess, so neither is used. + foreach (var claimed in results.Where(r => r.IsResolved).GroupBy(r => r.ContourOrdinal).Where(g => g.Count() > 1)) + foreach (var result in claimed) + results[result.PinIndex] = result with { Status = CutPinStatus.ContourPinnedTwice }; + + return results; + } + + // Pins are stored relative to the source entities; the program is that frame moved by + // -Source.Offset and never rotated in place. A rotated program (a canonical copy) is not. + private static bool IsSourceFrame(Drawing drawing) => + drawing.Program != null && Math.Tolerance.IsEqualTo(drawing.Program.Rotation, 0); + + private static CutPinResolution[] Unsupported(List pins) => + pins.Select((pin, i) => new CutPinResolution(i, pin, CutPinStatus.UnsupportedGeometry, -1, -1, Vector.Invalid)) + .ToArray(); +} diff --git a/OpenNest.Core/CNC/CuttingPlanning/OwnedCuttingParameters.cs b/OpenNest.Core/CNC/CuttingPlanning/OwnedCuttingParameters.cs index a2c83d79..f21484e2 100644 --- a/OpenNest.Core/CNC/CuttingPlanning/OwnedCuttingParameters.cs +++ b/OpenNest.Core/CNC/CuttingPlanning/OwnedCuttingParameters.cs @@ -65,7 +65,7 @@ internal static class OwnedCuttingParameters }; } - private static LeadIn CopyLeadIn(LeadIn source) => source switch + internal static LeadIn CopyLeadIn(LeadIn source) => source switch { NoLeadIn n when n.GetType() == typeof(NoLeadIn) => new NoLeadIn(), LineLeadIn n when n.GetType() == typeof(LineLeadIn) => new LineLeadIn @@ -86,7 +86,7 @@ internal static class OwnedCuttingParameters _ => throw new NotSupportedException("Unsupported lead-in runtime type.") }; - private static LeadOut CopyLeadOut(LeadOut source) => source switch + internal static LeadOut CopyLeadOut(LeadOut source) => source switch { NoLeadOut n when n.GetType() == typeof(NoLeadOut) => new NoLeadOut(), LineLeadOut n when n.GetType() == typeof(LineLeadOut) => new LineLeadOut @@ -96,7 +96,7 @@ internal static class OwnedCuttingParameters _ => throw new NotSupportedException("Unsupported lead-out runtime type.") }; - private static Tab CopyTab(Tab source) + internal static Tab CopyTab(Tab source) { Tab copy = source switch { diff --git a/OpenNest.Core/CNC/CuttingPlanning/PreparedContours.cs b/OpenNest.Core/CNC/CuttingPlanning/PreparedContours.cs index ed2810ce..83a83819 100644 --- a/OpenNest.Core/CNC/CuttingPlanning/PreparedContours.cs +++ b/OpenNest.Core/CNC/CuttingPlanning/PreparedContours.cs @@ -149,6 +149,25 @@ public sealed class PreparedContours } } + /// + /// Every contour with a native entity within of + /// , in ordinal order, each with its closest entity and the exact + /// closest native point on it. A locating aid for drawing pins, not an entry choice. + /// + internal IReadOnlyList<(int ContourOrdinal, int EntityOrdinal, Vector Point)> ContoursNear(Vector point, double tolerance) + { + PostVerificationGeometry.Validate(point); + var found = new List<(int, int, Vector)>(); + for (var ordinal = 0; ordinal < Count; ordinal++) + { + var shape = shapes[ordinal]; + var closest = shape.ClosestPointTo(point, out var entity); + if (closest.DistanceTo(point) <= tolerance) + found.Add((ordinal, shape.Entities.IndexOf(entity), closest)); + } + return found; + } + public ContourChoice Entry(int contourOrdinal, int entityOrdinal, Vector point) { var choice = new ContourChoice(contourOrdinal, entityOrdinal, point) { Owner = this }; diff --git a/OpenNest.Core/CNC/CuttingStrategy/ContourCutPin.cs b/OpenNest.Core/CNC/CuttingStrategy/ContourCutPin.cs new file mode 100644 index 00000000..2a32bbc0 --- /dev/null +++ b/OpenNest.Core/CNC/CuttingStrategy/ContourCutPin.cs @@ -0,0 +1,55 @@ +using System; +using OpenNest.CNC.CuttingPlanning; +using OpenNest.Diagnostics; +using OpenNest.Geometry; + +namespace OpenNest.CNC.CuttingStrategy +{ + /// + /// How one contour of a drawing must be cut: where it is entered and the lead-in, + /// lead-out and optional tab used there. is in the drawing's source + /// frame (the frame of ), which does not move when the + /// program is re-origined; maps it to a placed contour. + /// Immutable: only the exact built-in lead and tab types are accepted, they are copied + /// in, and every property returns a fresh copy. + /// + public sealed class ContourCutPin + { + private readonly LeadIn leadIn; + private readonly LeadOut leadOut; + private readonly Tab tab; + + /// A missing or invalid lead, a nonfinite point, or a tab with a lead-out. + /// A lead or tab that is not an exact built-in type. + public ContourCutPin(Vector point, LeadIn leadIn, LeadOut leadOut, Tab tab = null) + { + PostVerificationGeometry.Validate(point); + var ownedLeadIn = OwnedCuttingParameters.CopyLeadIn(leadIn); + var ownedLeadOut = OwnedCuttingParameters.CopyLeadOut(leadOut); + var ownedTab = tab == null ? null : OwnedCuttingParameters.CopyTab(tab); + // A tab leaves the contour open at its end; a lead-out after an open contour is + // refused by the planner, so the pin cannot ask for both. + if (ownedTab != null && ownedLeadOut.GetType() != typeof(NoLeadOut)) + throw new ArgumentException("A pin with a tab cannot have a lead-out.", nameof(leadOut)); + + Point = point; + this.leadIn = ownedLeadIn; + this.leadOut = ownedLeadOut; + this.tab = ownedTab; + } + + /// The contour entry in the drawing's source frame. + public Vector Point { get; } + + /// A copy of the pinned lead-in. + public LeadIn LeadIn => OwnedCuttingParameters.CopyLeadIn(leadIn); + + /// A copy of the pinned lead-out; always when . + public LeadOut LeadOut => OwnedCuttingParameters.CopyLeadOut(leadOut); + + /// A copy of the pinned tab, or null for a closed contour. + public Tab Tab => tab == null ? null : OwnedCuttingParameters.CopyTab(tab); + + public bool HasTab => tab != null; + } +} diff --git a/OpenNest.Core/Drawing.cs b/OpenNest.Core/Drawing.cs index 487dcb3f..471a169c 100644 --- a/OpenNest.Core/Drawing.cs +++ b/OpenNest.Core/Drawing.cs @@ -118,6 +118,13 @@ namespace OpenNest /// public DrawingLibraryLink Library { get; set; } + /// + /// Contours this drawing says must be cut a fixed way, in the source frame. At most one + /// pin per contour is used; maps them to + /// a placement. Pins do not follow geometry into new drawings (split pieces). + /// + public List CutPins { get; set; } = new List(); + /// /// Complete set of source entities with stable GUIDs. /// Null when the drawing was created from G-code or an older nest file. diff --git a/OpenNest.Tests/CuttingPlanning/ContourCutPinTests.cs b/OpenNest.Tests/CuttingPlanning/ContourCutPinTests.cs new file mode 100644 index 00000000..e6bcabc3 --- /dev/null +++ b/OpenNest.Tests/CuttingPlanning/ContourCutPinTests.cs @@ -0,0 +1,67 @@ +using OpenNest.CNC.CuttingStrategy; +using OpenNest.Geometry; + +namespace OpenNest.Tests.CuttingPlanning; + +public class ContourCutPinTests +{ + private sealed class DerivedLineLeadIn : LineLeadIn { } + + [Fact] + public void Pin_OwnsCopiesOfItsLeadsAndTab() + { + var leadIn = new LineLeadIn { Length = 0.25, ApproachAngle = 90 }; + var tab = new NormalTab { Size = 0.03 }; + var pin = new ContourCutPin(new Vector(1, 2), leadIn, new NoLeadOut(), tab); + + leadIn.Length = 9; + tab.Size = 9; + ((LineLeadIn)pin.LeadIn).Length = 7; + pin.Tab.Size = 7; + + Assert.Equal(new Vector(1, 2), pin.Point); + Assert.Equal(0.25, Assert.IsType(pin.LeadIn).Length); + Assert.Equal(0.03, Assert.IsType(pin.Tab).Size); + Assert.IsType(pin.LeadOut); + Assert.True(pin.HasTab); + } + + [Fact] + public void Pin_WithoutTab_KeepsItsLeadOut() + { + var pin = new ContourCutPin(Vector.Zero, new ArcLeadIn { Radius = 0.1 }, new LineLeadOut { Length = 0.05 }); + + Assert.False(pin.HasTab); + Assert.Null(pin.Tab); + Assert.Equal(0.05, Assert.IsType(pin.LeadOut).Length); + } + + [Fact] + public void Pin_WithTab_RefusesALeadOut() + { + var ex = Assert.Throws(() => new ContourCutPin( + Vector.Zero, new LineLeadIn { Length = 0.25 }, new LineLeadOut { Length = 0.05 }, new NormalTab())); + + Assert.Contains("tab", ex.Message); + } + + [Fact] + public void Pin_RefusesInvalidInput() + { + var lead = new LineLeadIn { Length = 0.25 }; + Assert.Throws(() => new ContourCutPin(new Vector(double.NaN, 0), lead, new NoLeadOut())); + Assert.Throws(() => new ContourCutPin(Vector.Zero, null, new NoLeadOut())); + Assert.Throws(() => new ContourCutPin(Vector.Zero, lead, null)); + Assert.Throws(() => new ContourCutPin( + Vector.Zero, new LineLeadIn { Length = -0.1 }, new NoLeadOut())); + Assert.Throws(() => new ContourCutPin( + Vector.Zero, lead, new NoLeadOut(), new NormalTab { Size = double.PositiveInfinity })); + } + + [Fact] + public void Pin_RefusesTypesThatAreNotBuiltIn() + { + Assert.Throws(() => new ContourCutPin( + Vector.Zero, new DerivedLineLeadIn { Length = 0.25 }, new NoLeadOut())); + } +} diff --git a/OpenNest.Tests/CuttingPlanning/CutPinResolverTests.cs b/OpenNest.Tests/CuttingPlanning/CutPinResolverTests.cs new file mode 100644 index 00000000..25f8bb51 --- /dev/null +++ b/OpenNest.Tests/CuttingPlanning/CutPinResolverTests.cs @@ -0,0 +1,215 @@ +using OpenNest.CNC; +using OpenNest.CNC.CuttingPlanning; +using OpenNest.CNC.CuttingStrategy; +using OpenNest.Converters; +using OpenNest.Engine; +using OpenNest.Geometry; +using OpenNest.Math; + +namespace OpenNest.Tests.CuttingPlanning; + +public class CutPinResolverTests +{ + // The program frame is the source frame moved by -Offset. + private static readonly Vector Offset = new(5, -2); + + /// A 10 x 6 plate with a radius-1 hole at (3, 3), program-local; pins go in source coordinates. + private static Drawing Bracket(params Vector[] sourcePins) + { + var entities = new List + { + new Line(0, 0, 10, 0), + new Line(10, 0, 10, 6), + new Line(10, 6, 0, 6), + new Line(0, 6, 0, 0), + new Circle(3, 3, 1), + }; + var drawing = new Drawing("Synthetic bracket", ConvertGeometry.ToProgram(entities)); + drawing.Source.Offset = Offset; + foreach (var point in sourcePins) + drawing.CutPins.Add(Pin(point)); + return drawing; + } + + private static ContourCutPin Pin(Vector sourcePoint) => + new(sourcePoint, new LineLeadIn { Length = 0.25, ApproachAngle = 90 }, new NoLeadOut(), new NormalTab { Size = 0.03 }); + + private static Vector Source(double x, double y) => new Vector(x, y) + Offset; + + [Fact] + public void NoPins_ResolveToNothing() + { + Assert.Empty(CutPinResolver.Resolve(Bracket(), 0)); + } + + [Fact] + public void Pins_ResolveToThePerimeterAndHoleInTheProgramFrame() + { + var drawing = Bracket(Source(10, 6), Source(4, 3)); + + var results = CutPinResolver.Resolve(drawing, 0); + + Assert.Equal(2, results.Count); + Assert.All(results, r => Assert.Equal(CutPinStatus.Resolved, r.Status)); + Assert.Equal((1, new Vector(10, 6)), (results[0].ContourOrdinal, results[0].Point)); + Assert.Equal((0, 0), (results[1].ContourOrdinal, results[1].EntityOrdinal)); + AssertNear(new Vector(4, 3), results[1].Point); + Assert.Same(drawing.CutPins[1], results[1].Pin); + } + + [Theory] + [InlineData(90)] + [InlineData(37)] + public void Pins_FollowTheRotationAboutTheProgramOrigin(double degrees) + { + var rotation = Angle.ToRadians(degrees); + var drawing = Bracket(Source(10, 6)); + + var result = Assert.Single(CutPinResolver.Resolve(drawing, rotation)); + + Assert.True(result.IsResolved); + AssertNear(new Vector(10 * System.Math.Cos(rotation) - 6 * System.Math.Sin(rotation), + 10 * System.Math.Sin(rotation) + 6 * System.Math.Cos(rotation)), result.Point); + } + + [Fact] + public void Resolutions_AreEntriesOfTheSameRotatedPreparation() + { + var rotation = Angle.ToRadians(37); + var drawing = Bracket(Source(10, 3), Source(3, 4)); + var program = (Program)drawing.Program.Clone(); + program.Rotate(rotation); + var contours = PreparedContours.Capture(program, ExplicitContourTests.Parameters()); + + var results = CutPinResolver.Resolve(drawing, rotation, contours); + + Assert.All(results, r => + { + Assert.True(r.IsResolved); + var choice = contours.Entry(r.ContourOrdinal, r.EntityOrdinal, r.Point); + Assert.Equal(r.Point, choice.Point); + }); + Assert.Equal(contours.PerimeterOrdinal, results[0].ContourOrdinal); + } + + [Fact] + public void PinWithinClosureTolerance_SnapsOntoTheEntity() + { + var drawing = Bracket(Source(10, 3) + new Vector(5e-7, 0)); + + var result = Assert.Single(CutPinResolver.Resolve(drawing, 0)); + + Assert.True(result.IsResolved); + Assert.Equal(new Vector(10, 3), result.Point); + } + + [Fact] + public void PinOffTheGeometry_IsNotOnAContourAndOthersStillResolve() + { + var drawing = Bracket(Source(10, 3) + new Vector(1e-5, 0), Source(3, 2)); + + var results = CutPinResolver.Resolve(drawing, 0); + + Assert.Equal(CutPinStatus.NotOnContour, results[0].Status); + Assert.Equal(-1, results[0].ContourOrdinal); + AssertNear(new Vector(10.00001, 3), results[0].Point); + Assert.True(results[1].IsResolved); + } + + [Fact] + public void TwoPinsOnOneContour_AreBothUnused() + { + var drawing = Bracket(Source(0, 0), Source(3, 4), Source(10, 6)); + + var results = CutPinResolver.Resolve(drawing, 0); + + Assert.Equal(CutPinStatus.ContourPinnedTwice, results[0].Status); + Assert.Equal(CutPinStatus.ContourPinnedTwice, results[2].Status); + Assert.Equal(1, results[0].ContourOrdinal); + Assert.True(results[1].IsResolved); + } + + [Fact] + public void PinTouchingTwoContours_IsAmbiguous() + { + var entities = new List + { + new Line(0, 0, 10, 0), new Line(10, 0, 10, 6), new Line(10, 6, 0, 6), new Line(0, 6, 0, 0), + new Circle(3, 3, 1), new Circle(5, 3, 1), + }; + var drawing = new Drawing("Touching holes", ConvertGeometry.ToProgram(entities)); + drawing.CutPins.Add(Pin(new Vector(4, 3))); + + var result = Assert.Single(CutPinResolver.Resolve(drawing, 0)); + + Assert.Equal(CutPinStatus.OnSeveralContours, result.Status); + } + + [Fact] + public void RotatedDrawingProgram_IsNotTheSourceFrame() + { + var drawing = Bracket(Source(10, 6)); + drawing.Program.Rotate(Angle.HalfPI); + + var result = Assert.Single(CutPinResolver.Resolve(drawing, 0)); + + Assert.Equal(CutPinStatus.UnsupportedGeometry, result.Status); + Assert.False(result.Point.IsValid()); + } + + [Fact] + public void OpenGeometry_IsUnsupported() + { + var program = new Program(); + program.MoveTo(0, 0); + program.LineTo(10, 0); + program.LineTo(10, 6); + var drawing = new Drawing("Open", program); + drawing.CutPins.Add(Pin(new Vector(10, 0))); + + Assert.Equal(CutPinStatus.UnsupportedGeometry, Assert.Single(CutPinResolver.Resolve(drawing, 0)).Status); + } + + [Fact] + public void Part_ResolvesAtItsRotation() + { + var drawing = Bracket(Source(10, 6)); + var part = new Part(drawing); + part.Rotate(Angle.HalfPI); + + var result = Assert.Single(CutPinResolver.Resolve(part)); + + AssertNear(new Vector(-6, 10), result.Point); + } + + [Fact] + public void CanonicalCopy_CarriesNoPins() + { + // A canonical copy's program is rotated about its centre, so source-frame pins would + // not map onto it; its parts are rebound to the original drawing, which keeps them. + var drawing = Bracket(Source(10, 6)); + + var copy = CanonicalFrame.AsCanonicalCopy(drawing); + + Assert.Empty(copy.CutPins); + Assert.Single(drawing.CutPins); + } + + [Fact] + public void SplitPieces_CarryNoPins() + { + var drawing = Bracket(Source(10, 6)); + + var pieces = DrawingSplitter.Split(drawing, + new List { new(5.0, CutOffAxis.Vertical) }, + new SplitParameters { Type = SplitType.Straight }); + + Assert.NotEmpty(pieces); + Assert.All(pieces, piece => Assert.Empty(piece.CutPins)); + } + + private static void AssertNear(Vector expected, Vector actual) + { + Assert.True(expected.DistanceTo(actual) <= 1e-9, $"Expected {expected}, got {actual}."); + } +}