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}.");
+ }
+}