diff --git a/OpenNest.Core/CNC/CuttingPlanning/OwnedCuttingParameters.cs b/OpenNest.Core/CNC/CuttingPlanning/OwnedCuttingParameters.cs new file mode 100644 index 0000000..a2c83d7 --- /dev/null +++ b/OpenNest.Core/CNC/CuttingPlanning/OwnedCuttingParameters.cs @@ -0,0 +1,139 @@ +using System; +using OpenNest.CNC.CuttingStrategy; + +namespace OpenNest.CNC.CuttingPlanning; + +/// Copies only supported, exact setting types; never slices custom subclasses. +internal static class OwnedCuttingParameters +{ + internal static CuttingParameters Copy(CuttingParameters source) + { + Exact(source); + Exact(source.Sequencing); + Exact(source.Assignment); + var sequence = source.Sequencing; + var assignment = source.Assignment; + if (!Enum.IsDefined(sequence.Method) || !Enum.IsDefined(assignment.Method)) + throw new ArgumentException("Unsupported sequence method."); + if (assignment.Preference == null) + throw new ArgumentException("Missing assignment preference."); + if (!double.IsFinite(source.LeadInAngleIncrement) || source.LeadInAngleIncrement <= 0 + || source.LeadInAngleIncrement > 360) + throw new ArgumentException("Angle increment must be in (0, 360] degrees."); + return new CuttingParameters + { + Id = source.Id, + MachineName = source.MachineName, + MaterialName = source.MaterialName, + Grade = source.Grade, + Thickness = Dimension(source.Thickness), + Kerf = Dimension(source.Kerf), + PartSpacing = Dimension(source.PartSpacing), + PierceClearance = Dimension(source.PierceClearance), + ExternalLeadIn = CopyLeadIn(source.ExternalLeadIn), + InternalLeadIn = CopyLeadIn(source.InternalLeadIn), + ArcCircleLeadIn = CopyLeadIn(source.ArcCircleLeadIn), + ExternalLeadOut = CopyLeadOut(source.ExternalLeadOut), + InternalLeadOut = CopyLeadOut(source.InternalLeadOut), + ArcCircleLeadOut = CopyLeadOut(source.ArcCircleLeadOut), + RoundLeadInAngles = source.RoundLeadInAngles, + LeadInAngleIncrement = source.LeadInAngleIncrement, + AutoTabMinSize = Dimension(source.AutoTabMinSize), + AutoTabMaxSize = Dimension(source.AutoTabMaxSize), + TabConfig = source.TabConfig == null + ? source.TabsEnabled ? throw new ArgumentException("Enabled tabs require a configuration.") : null + : CopyTab(source.TabConfig), + TabsEnabled = source.TabsEnabled, + Sequencing = new SequenceParameters + { + Method = sequence.Method, + SmallCutoutWidth = Dimension(sequence.SmallCutoutWidth), + SmallCutoutHeight = Dimension(sequence.SmallCutoutHeight), + MediumCutoutWidth = Dimension(sequence.MediumCutoutWidth), + MediumCutoutHeight = Dimension(sequence.MediumCutoutHeight), + DistanceMediumSmall = Dimension(sequence.DistanceMediumSmall), + AlternateRowsColumns = sequence.AlternateRowsColumns, + AlternateCutoutsWithinRowColumn = sequence.AlternateCutoutsWithinRowColumn, + MinDistanceBetweenRowsColumns = Dimension(sequence.MinDistanceBetweenRowsColumns) + }, + Assignment = new AssignmentParameters + { + Method = assignment.Method, + Preference = assignment.Preference, + MinGeometryLength = Dimension(assignment.MinGeometryLength) + } + }; + } + + private 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 + { Length = Dimension(n.Length), ApproachAngle = Angle(n.ApproachAngle) }, + ArcLeadIn n when n.GetType() == typeof(ArcLeadIn) => new ArcLeadIn { Radius = Dimension(n.Radius) }, + LineArcLeadIn n when n.GetType() == typeof(LineArcLeadIn) => new LineArcLeadIn + { LineLength = Dimension(n.LineLength), ArcRadius = Dimension(n.ArcRadius), ApproachAngle = Angle(n.ApproachAngle) }, + LineLineLeadIn n when n.GetType() == typeof(LineLineLeadIn) => new LineLineLeadIn + { + Length1 = Dimension(n.Length1), + Length2 = Dimension(n.Length2), + ApproachAngle1 = Angle(n.ApproachAngle1), + ApproachAngle2 = Angle(n.ApproachAngle2) + }, + CleanHoleLeadIn n when n.GetType() == typeof(CleanHoleLeadIn) => new CleanHoleLeadIn + { LineLength = Dimension(n.LineLength), ArcRadius = Dimension(n.ArcRadius), Kerf = Dimension(n.Kerf) }, + null => throw new ArgumentException("Missing lead-in settings."), + _ => throw new NotSupportedException("Unsupported lead-in runtime type.") + }; + + private 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 + { Length = Dimension(n.Length), ApproachAngle = Angle(n.ApproachAngle) }, + ArcLeadOut n when n.GetType() == typeof(ArcLeadOut) => new ArcLeadOut { Radius = Dimension(n.Radius) }, + null => throw new ArgumentException("Missing lead-out settings."), + _ => throw new NotSupportedException("Unsupported lead-out runtime type.") + }; + + private static Tab CopyTab(Tab source) + { + Tab copy = source switch + { + NormalTab n when n.GetType() == typeof(NormalTab) => new NormalTab + { + CutoutMinWidth = Dimension(n.CutoutMinWidth), + CutoutMinHeight = Dimension(n.CutoutMinHeight), + CutoutMaxWidth = Dimension(n.CutoutMaxWidth), + CutoutMaxHeight = Dimension(n.CutoutMaxHeight) + }, + BreakerTab n when n.GetType() == typeof(BreakerTab) => new BreakerTab + { + BreakerDepth = Dimension(n.BreakerDepth), + BreakerLeadInLength = Dimension(n.BreakerLeadInLength), + BreakerAngle = Angle(n.BreakerAngle) + }, + MachineTab n when n.GetType() == typeof(MachineTab) => new MachineTab { MachineTabId = n.MachineTabId }, + _ => throw new NotSupportedException("Unsupported tab runtime type.") + }; + copy.Size = Dimension(source.Size); + copy.TabLeadIn = source.TabLeadIn == null ? null : CopyLeadIn(source.TabLeadIn); + copy.TabLeadOut = source.TabLeadOut == null ? null : CopyLeadOut(source.TabLeadOut); + return copy; + } + + private static void Exact(T value) where T : class + { + if (value == null) + throw new ArgumentException("Missing cutting settings."); + if (value.GetType() != typeof(T)) + throw new NotSupportedException("Unsupported settings runtime type."); + } + + private static double Dimension(double value) => double.IsFinite(value) && value >= 0 + ? value : throw new ArgumentException("Dimensions must be finite and nonnegative."); + + // Approach angles are degrees, not lengths. Signed and periodic angles are valid. + private static double Angle(double value) => double.IsFinite(value) + ? value : throw new ArgumentException("Angles must be finite."); +} diff --git a/OpenNest.Core/CNC/CuttingPlanning/PreparedContours.cs b/OpenNest.Core/CNC/CuttingPlanning/PreparedContours.cs new file mode 100644 index 0000000..14e15ee --- /dev/null +++ b/OpenNest.Core/CNC/CuttingPlanning/PreparedContours.cs @@ -0,0 +1,248 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using OpenNest.CNC.CuttingStrategy; +using OpenNest.Diagnostics; +using OpenNest.Geometry; + +namespace OpenNest.CNC.CuttingPlanning; + +/// A nominal native entry owned by one preparation, not an actual emitted pierce. +public sealed record ContourChoice(int ContourOrdinal, int EntityOrdinal, Vector Point) +{ + internal PreparedContours Owner { get; init; } +} + +/// +/// Immutable owned clean contours and settings. Callers supply the clean, already-rotated +/// program and keep it stable during Capture. This is not a topology or lead-path verdict. +/// +public sealed class PreparedContours +{ + private readonly Shape[] shapes; + private readonly CuttingParameters parameters; + private readonly List scribes; + + private PreparedContours(Shape[] shapes, List scribes, CuttingParameters parameters) + { + this.shapes = shapes; + this.scribes = scribes; + this.parameters = parameters; + } + + public int Count => shapes.Length; + public int PerimeterOrdinal => Count - 1; + + public static PreparedContours Capture(Program program, CuttingParameters parameters, CancellationToken token = default) + { + token.ThrowIfCancellationRequested(); + var ownedParameters = OwnedCuttingParameters.Copy(parameters); + ExecutionMotionReader.Read(program, Vector.Zero, null, token); + ValidateProgramTypes(program, token); + // ToGeometry constructs fresh native entities. Normalize an owned motion graph + // to incremental mode so absolute subprograms retain their frame offsets too. + // Retain execution boundaries: ShapeBuilder can join duplicate closed contours. + var geometryProgram = CopyForGeometry(program, token); + var contours = new List(); + var scribes = new List(); + var current = new Shape(); + foreach (var entity in geometryProgram.ToGeometry()) + { + token.ThrowIfCancellationRequested(); + if (entity.GetType() != typeof(Line) && entity.GetType() != typeof(Arc) && entity.GetType() != typeof(Circle)) + throw new NotSupportedException("Unsupported native geometry."); + if (entity.Layer == SpecialLayers.Rapid || entity.Layer == SpecialLayers.Scribe) + { + if (current.Entities.Count != 0) + throw new ArgumentException("Preparation requires closed execution contours."); + if (entity.Layer == SpecialLayers.Scribe) + scribes.Add(entity); + continue; + } + if (entity.Layer != SpecialLayers.Cut && entity.Layer != SpecialLayers.Display) + throw new ArgumentException("Preparation requires clean cut/display contours."); + if (entity.Length <= PostVerificationGeometry.Epsilon || !double.IsFinite(entity.Length)) + throw new ArgumentException("Degenerate contour entity."); + if (entity is Circle) + { + if (current.Entities.Count != 0) + throw new ArgumentException("Circle interrupts an open contour."); + current.Entities.Add(entity); + FinishContour(); + continue; + } + if (current.Entities.Count > 0 && End(current.Entities[^1]).DistanceTo(Start(entity)) > PostVerificationGeometry.Epsilon) + throw new ArgumentException("Discontinuous native contour."); + current.Entities.Add(entity); + if (Start(current.Entities[0]).DistanceTo(End(entity)) <= PostVerificationGeometry.Epsilon) + FinishContour(); + } + if (current.Entities.Count != 0 || contours.Count == 0) + throw new ArgumentException("Preparation requires nonempty closed contours."); + var perimeter = 0; + for (var i = 1; i < contours.Count; i++) + { + var box = contours[i].BoundingBox; + var best = contours[perimeter].BoundingBox; + if (box.Width * box.Length > best.Width * best.Length) + perimeter = i; + } + var outer = contours[perimeter]; + contours.RemoveAt(perimeter); + contours.Add(outer); + return new(contours.ToArray(), scribes, ownedParameters); + + void FinishContour() + { + current.UpdateBounds(); + contours.Add(current); + current = new Shape(); + } + } + + public ContourChoice ClosestEntry(int contourOrdinal, Vector approach) + { + PostVerificationGeometry.Validate(approach); + var shape = GetShape(contourOrdinal); + var point = shape.ClosestPointTo(approach, out var entity); + return Entry(contourOrdinal, shape.Entities.IndexOf(entity), point); + } + + /// + /// Closest native point first, then source-order vertices/midpoints or eight circle + /// angles (0 through 315 degrees). Geometrical duplicates keep their first entity. + /// Emit before validating actual paths: circle rounding/clamping may move this point. + /// + public IReadOnlyList Entries(int contourOrdinal, Vector approach, int maxEntries = 16, CancellationToken token = default) + { + token.ThrowIfCancellationRequested(); + if (maxEntries <= 0) + throw new ArgumentOutOfRangeException(nameof(maxEntries)); + var shape = GetShape(contourOrdinal); + var entries = new List(); + entries.Add(ClosestEntry(contourOrdinal, approach)); + for (var i = 0; i < shape.Entities.Count && entries.Count < maxEntries; i++) + { + token.ThrowIfCancellationRequested(); + var entity = shape.Entities[i]; + if (entity is Circle circle) + { + for (var angle = 0; angle < 8 && entries.Count < maxEntries; angle++) + Add(i, circle.Center + new Vector(System.Math.Cos(angle * System.Math.PI / 4), + System.Math.Sin(angle * System.Math.PI / 4)) * circle.Radius); + } + else + { + Add(i, Start(entity)); + Add(i, entity is Line line ? line.MidPoint : ((Arc)entity).MidPoint()); + Add(i, End(entity)); + } + } + token.ThrowIfCancellationRequested(); + return entries.AsReadOnly(); + + void Add(int entityOrdinal, Vector point) + { + token.ThrowIfCancellationRequested(); + if (entries.Count < maxEntries && !entries.Any(e => e.Point.DistanceTo(point) <= PostVerificationGeometry.Epsilon)) + entries.Add(Entry(contourOrdinal, entityOrdinal, point)); + } + } + + public ContourChoice Entry(int contourOrdinal, int entityOrdinal, Vector point) + { + var choice = new ContourChoice(contourOrdinal, entityOrdinal, point) { Owner = this }; + ValidateChoice(choice); + return choice; + } + + /// Emits every contour once in caller order, holes before perimeter, with scribes once. + public Program Emit(IReadOnlyList choices) + { + Validate(choices, true); + return EmitPrefix(choices); + } + + // Each prefix is a standalone owned program, including the same scribes once. + internal Program EmitPrefix(IReadOnlyList choices) + { + Validate(choices, false); + return new ContourCuttingStrategy { Parameters = parameters }.EmitPrepared( + shapes.Select(s => (Shape)s.Clone()).ToArray(), scribes.Select(e => e.Clone()).ToList(), choices); + } + + private void Validate(IReadOnlyList choices, bool complete) + { + if (choices == null || (complete && choices.Count != Count) || choices.Count > Count) + throw new ArgumentException("Every contour must be represented exactly once."); + var visited = new HashSet(); + foreach (var choice in choices) + { + ValidateChoice(choice); + if (!visited.Add(choice.ContourOrdinal) || choice.ContourOrdinal == PerimeterOrdinal && visited.Count != Count) + throw new ArgumentException("Duplicate contour or perimeter before its internal contours."); + } + } + + private void ValidateChoice(ContourChoice choice) + { + if (choice == null || !ReferenceEquals(choice.Owner, this)) + throw new ArgumentException("Foreign contour choice."); + PostVerificationGeometry.Validate(choice.Point); + var shape = GetShape(choice.ContourOrdinal); + if (choice.EntityOrdinal < 0 || choice.EntityOrdinal >= shape.Entities.Count) + throw new ArgumentException("Foreign entity ordinal."); + var distance = shape.Entities[choice.EntityOrdinal].ClosestPointTo(choice.Point).DistanceTo(choice.Point); + if (!double.IsFinite(distance) || distance > PostVerificationGeometry.Epsilon) + throw new ArgumentException("Entry is not on the selected native entity."); + } + + private Shape GetShape(int ordinal) => ordinal < 0 || ordinal >= Count + ? throw new ArgumentException("Foreign contour ordinal.") : shapes[ordinal]; + + private static Vector Start(Entity entity) => entity is Line line ? line.StartPoint : ((Arc)entity).StartPoint(); + private static Vector End(Entity entity) => entity is Line line ? line.EndPoint : ((Arc)entity).EndPoint(); + + private static Program CopyForGeometry(Program source, CancellationToken token) + { + var copies = new Dictionary(ReferenceEqualityComparer.Instance); + return Copy(source); + + Program Copy(Program current) + { + token.ThrowIfCancellationRequested(); + if (copies.TryGetValue(current, out var existing)) + return existing; + var copy = new Program(current.Mode); + copies.Add(current, copy); + foreach (var code in current.Codes) + { + token.ThrowIfCancellationRequested(); + var ownedCode = code.Clone(); + if (ownedCode is SubProgramCall call) + call.BindProgram(Copy(((SubProgramCall)code).Program)); + copy.Codes.Add(ownedCode); + } + copy.Mode = Mode.Incremental; + return copy; + } + } + + private static void ValidateProgramTypes(Program program, CancellationToken token) + { + token.ThrowIfCancellationRequested(); + if (program.GetType() != typeof(Program) || !Enum.IsDefined(program.Mode)) + throw new NotSupportedException("Unsupported program runtime type or mode."); + foreach (var code in program.Codes) + { + token.ThrowIfCancellationRequested(); + var type = code.GetType(); + if (type != typeof(RapidMove) && type != typeof(LinearMove) && type != typeof(ArcMove) + && type != typeof(SubProgramCall) && type != typeof(Comment) && type != typeof(Feedrate) && type != typeof(Kerf)) + throw new NotSupportedException("Unsupported instruction runtime type."); + if (code is SubProgramCall call) + ValidateProgramTypes(call.Program, token); + } + } +} diff --git a/OpenNest.Core/CNC/CuttingStrategy/ContourCuttingStrategy.cs b/OpenNest.Core/CNC/CuttingStrategy/ContourCuttingStrategy.cs index ffc8c9e..a5584e5 100644 --- a/OpenNest.Core/CNC/CuttingStrategy/ContourCuttingStrategy.cs +++ b/OpenNest.Core/CNC/CuttingStrategy/ContourCuttingStrategy.cs @@ -234,6 +234,21 @@ namespace OpenNest.CNC.CuttingStrategy return null; } + internal Program EmitPrepared(Shape[] shapes, List scribes, + IReadOnlyList choices) + { + var result = new Program(Mode.Absolute); + EmitScribeContours(result, scribes); + foreach (var choice in choices) + { + var shape = shapes[choice.ContourOrdinal]; + EmitContour(result, shape, choice.Point, shape.Entities[choice.EntityOrdinal], + choice.ContourOrdinal == shapes.Length - 1 ? ContourType.External : null); + } + result.Mode = Mode.Incremental; + return result; + } + private void EmitRawContour(Program program, Shape shape) { var startPoint = GetShapeStartPoint(shape); diff --git a/OpenNest.Core/OpenNest.Core.csproj b/OpenNest.Core/OpenNest.Core.csproj index 57f00bd..45d022c 100644 --- a/OpenNest.Core/OpenNest.Core.csproj +++ b/OpenNest.Core/OpenNest.Core.csproj @@ -6,6 +6,7 @@ + diff --git a/OpenNest.Tests/CuttingPlanning/OwnedCuttingParameterTests.cs b/OpenNest.Tests/CuttingPlanning/OwnedCuttingParameterTests.cs new file mode 100644 index 0000000..5276e9b --- /dev/null +++ b/OpenNest.Tests/CuttingPlanning/OwnedCuttingParameterTests.cs @@ -0,0 +1,170 @@ +using OpenNest.CNC.CuttingPlanning; +using OpenNest.CNC.CuttingStrategy; + +namespace OpenNest.Tests.CuttingPlanning; + +public class OwnedCuttingParameterTests +{ + public static IEnumerable Styles() + { + foreach (var leadIn in new LeadIn[] { new NoLeadIn(), new LineLeadIn { Length = 0.3, ApproachAngle = 45 }, + new ArcLeadIn { Radius = 0.2 }, new LineArcLeadIn { LineLength = 0.1, ArcRadius = 0.2, ApproachAngle = 120 }, + new LineLineLeadIn { Length1 = 0.1, Length2 = 0.2, ApproachAngle1 = 40, ApproachAngle2 = -30 }, + new CleanHoleLeadIn { LineLength = 0.1, ArcRadius = 0.2, Kerf = 0.01 } }) + foreach (var leadOut in new LeadOut[] { new NoLeadOut(), new LineLeadOut { Length = 0.4, ApproachAngle = 80 }, new ArcLeadOut { Radius = 0.3 } }) + foreach (var tab in new Tab[] { new NormalTab { Size = 0.2, CutoutMinWidth = 1, CutoutMaxWidth = 5, CutoutMinHeight = 2, CutoutMaxHeight = 6 }, + new BreakerTab { Size = 0.3, BreakerDepth = 0.1, BreakerLeadInLength = 0.2, BreakerAngle = 30 }, + new MachineTab { Size = 0.4, MachineTabId = 7 } }) + yield return [leadIn, leadOut, tab]; + } + + [Theory] + [MemberData(nameof(Styles))] + public void Copy_PreservesEveryMemberAndBreaksEveryMutableAlias(LeadIn leadIn, LeadOut leadOut, Tab tab) + { + tab.TabLeadIn = leadIn; tab.TabLeadOut = leadOut; + var source = new CuttingParameters + { + Id = 17, + MachineName = "machine", + MaterialName = "material", + Grade = "grade", + Thickness = 0.4, + Kerf = 0.01, + PartSpacing = 0.1, + PierceClearance = 0.05, + ExternalLeadIn = leadIn, + InternalLeadIn = leadIn, + ArcCircleLeadIn = leadIn, + ExternalLeadOut = leadOut, + InternalLeadOut = leadOut, + ArcCircleLeadOut = leadOut, + TabConfig = tab, + TabsEnabled = true, + AutoTabMinSize = 0.1, + AutoTabMaxSize = 0.5, + RoundLeadInAngles = true, + LeadInAngleIncrement = 30, + Sequencing = new SequenceParameters + { + Method = SequenceMethod.LeftSide, + SmallCutoutWidth = 2, + SmallCutoutHeight = 3, + MediumCutoutWidth = 9, + MediumCutoutHeight = 10, + DistanceMediumSmall = 4, + AlternateRowsColumns = false, + AlternateCutoutsWithinRowColumn = false, + MinDistanceBetweenRowsColumns = 0.3 + }, + Assignment = new AssignmentParameters { Method = SequenceMethod.EdgeStart, Preference = "test", MinGeometryLength = 0.04 } + }; + var copy = OwnedCuttingParameters.Copy(source); + AssertOwnedEqual(source, copy); + var secondCopy = OwnedCuttingParameters.Copy(copy); + Mutate(source, new HashSet(ReferenceEqualityComparer.Instance)); + AssertOwnedEqual(secondCopy, copy); + } + + private static void AssertOwnedEqual(object? source, object? copy) + { + Assert.Equal(source?.GetType(), copy?.GetType()); + if (source == null) return; + Assert.NotSame(source, copy); + foreach (var property in source.GetType().GetProperties()) + { + var a = property.GetValue(source)!; var b = property.GetValue(copy); + if (property.PropertyType.IsValueType || property.PropertyType == typeof(string)) + Assert.Equal(a, b); + else + AssertOwnedEqual(a, b); + } + } + + private static void Mutate(object? source, HashSet visited) + { + if (source == null || !visited.Add(source)) return; + foreach (var property in source.GetType().GetProperties()) + { + var value = property.GetValue(source)!; + if (property.PropertyType == typeof(double)) property.SetValue(source, (double)value + 100); + else if (property.PropertyType == typeof(bool)) property.SetValue(source, !(bool)value); + else if (property.PropertyType == typeof(int)) property.SetValue(source, (int)value + 10); + else if (property.PropertyType == typeof(string)) property.SetValue(source, "mutated"); + else if (property.PropertyType.IsEnum) property.SetValue(source, Enum.ToObject(property.PropertyType, 100)); + else Mutate(value, visited); + } + } + + [Theory] + [InlineData("null-settings")] + [InlineData("null-sequence")] + [InlineData("null-assignment")] + [InlineData("null-lead")] + [InlineData("null-leadout")] + [InlineData("null-tab")] + [InlineData("dimension")] + [InlineData("angle")] + [InlineData("increment")] + [InlineData("enum")] + [InlineData("tab-lead")] + [InlineData("custom-in")] + [InlineData("custom-out")] + [InlineData("custom-tab")] + [InlineData("custom-settings")] + [InlineData("custom-sequence")] + [InlineData("custom-assignment")] + public void Copy_RejectsInvalidOrUnsupportedSettings(string fault) + { + var source = new CuttingParameters(); + switch (fault) + { + case "null-settings": source = null; break; + case "null-sequence": source.Sequencing = null; break; + case "null-assignment": source.Assignment = null; break; + case "null-lead": source.ArcCircleLeadIn = null; break; + case "null-leadout": source.InternalLeadOut = null; break; + case "null-tab": source.TabsEnabled = true; break; + case "dimension": source.PartSpacing = -1; break; + case "angle": source.ExternalLeadIn = new LineLeadIn { ApproachAngle = double.NaN }; break; + case "increment": source.LeadInAngleIncrement = 0; break; + case "enum": source.Assignment.Method = (SequenceMethod)6; break; + case "tab-lead": source.TabConfig = new NormalTab { TabLeadIn = new ArcLeadIn { Radius = double.PositiveInfinity } }; break; + case "custom-in": source.InternalLeadIn = new CustomLeadIn(); break; + case "custom-out": source.ArcCircleLeadOut = new CustomLeadOut(); break; + case "custom-tab": source.TabConfig = new CustomTab(); break; + case "custom-settings": source = new CustomParameters(); break; + case "custom-sequence": source.Sequencing = new CustomSequence(); break; + case "custom-assignment": source.Assignment = new CustomAssignment(); break; + } + Assert.ThrowsAny(() => OwnedCuttingParameters.Copy(source)); + Assert.ThrowsAny(() => PreparedContours.Capture(ExplicitContourTests.Square(false), source)); + } + + [Theory] + [InlineData(-1)] + [InlineData(double.NaN)] + [InlineData(double.PositiveInfinity)] + public void Copy_RejectsEveryInvalidDimension(double bad) + { + foreach (var sample in Styles()) + { + var source = new CuttingParameters { ExternalLeadIn = (LeadIn)sample[0], ExternalLeadOut = (LeadOut)sample[1], TabConfig = (Tab)sample[2] }; + foreach (var owner in new object[] { source, source.Sequencing, source.Assignment, source.ExternalLeadIn, source.ExternalLeadOut, source.TabConfig }) + foreach (var property in owner.GetType().GetProperties().Where(p => p.PropertyType == typeof(double) && !p.Name.Contains("Angle"))) + { + var original = property.GetValue(owner); + property.SetValue(owner, bad); + Assert.Throws(() => OwnedCuttingParameters.Copy(source)); + property.SetValue(owner, original); + } + } + } + + private sealed class CustomLeadIn : LineLeadIn { } + private sealed class CustomLeadOut : ArcLeadOut { } + private sealed class CustomTab : NormalTab { } + private sealed class CustomParameters : CuttingParameters { } + private sealed class CustomSequence : SequenceParameters { } + private sealed class CustomAssignment : AssignmentParameters { } +} diff --git a/OpenNest.Tests/CuttingPlanning/PreparedContourTests.cs b/OpenNest.Tests/CuttingPlanning/PreparedContourTests.cs new file mode 100644 index 0000000..58a138e --- /dev/null +++ b/OpenNest.Tests/CuttingPlanning/PreparedContourTests.cs @@ -0,0 +1,326 @@ +using OpenNest.CNC; +using OpenNest.CNC.CuttingPlanning; +using OpenNest.CNC.CuttingStrategy; +using OpenNest.Geometry; + +namespace OpenNest.Tests.CuttingPlanning; + +public class PreparedContourTests +{ + [Fact] + public void Emit_RespectsEveryExplicitEntryAndContourOrder() + { + var source = Holes(); + var prepared = PreparedContours.Capture(source, ExplicitContourTests.Parameters()); + var choices = new[] + { + prepared.ClosestEntry(1, new Vector(20, 3)), + prepared.ClosestEntry(0, new Vector(20, 3)), + prepared.ClosestEntry(2, new Vector(20, 5)) + }; + var emitted = prepared.Emit(choices); + var calls = emitted.Codes.OfType().ToArray(); + Assert.Equal(new[] { new Vector(7, 3), new Vector(3, 3) }, calls.Select(c => c.Offset)); + var cuts = ExecutionMotionReader.Read(emitted, Vector.Zero, null, default).Motions + .Where(m => m.Layer == LayerType.Display && !m.Rapid).ToArray(); + Assert.Equal(new Vector(8, 3), cuts[0].Start); + Assert.Equal(new Vector(4, 3), cuts[1].Start); + Assert.Equal(source.ToString(), Holes().ToString()); + } + + [Theory] + [InlineData("duplicate")] + [InlineData("omitted")] + [InlineData("foreign")] + [InlineData("perimeter-first")] + public void Emit_RejectsIncompleteOrForeignAccounting(string fault) + { + var prepared = PreparedContours.Capture(Holes(), ExplicitContourTests.Parameters()); + var choices = Enumerable.Range(0, 3).Select(i => prepared.ClosestEntry(i, new Vector(20, 5))).ToArray(); + switch (fault) + { + case "duplicate": choices[1] = choices[0]; break; + case "omitted": choices = choices[..2]; break; + case "foreign": choices[0] = PreparedContours.Capture(Holes(), ExplicitContourTests.Parameters()).ClosestEntry(0, Vector.Zero); break; + case "perimeter-first": Array.Reverse(choices); break; + } + Assert.Throws(() => prepared.Emit(choices)); + } + + [Fact] + public void Prepared_OwnsGeometryAndDeepSettings() + { + var source = Holes(); + var settings = ExplicitContourTests.Parameters(); + var prepared = PreparedContours.Capture(source, settings); + var choices = Enumerable.Range(0, 3).Select(i => prepared.ClosestEntry(i, new Vector(20, 5))).ToArray(); + var before = ExplicitContourTests.Fingerprint(prepared.Emit(choices)); + source.Codes.Clear(); + ((LineLeadIn)settings.ExternalLeadIn).Length = 100; + settings.TabsEnabled = true; + Assert.Equal(before, ExplicitContourTests.Fingerprint(prepared.Emit(choices))); + } + + [Theory] + [MemberData(nameof(ExplicitContourTests.RetainedStyles), MemberType = typeof(ExplicitContourTests))] + public void ExplicitEmission_OrdinarySingleContourMatchesLegacy(string style, bool reverse, bool internalContour) + { + var source = ExplicitContourTests.Square(reverse); + var target = source.ToGeometry().First(e => e.Layer != SpecialLayers.Rapid); + var parameters = ExplicitContourTests.Parameters(style); + if (internalContour) + { + source.MoveTo(-5, -5); source.LineTo(-5, 15); source.LineTo(15, 15); + source.LineTo(15, -5); source.LineTo(-5, -5); + parameters.ExternalLeadIn = new NoLeadIn(); + } + var prepared = PreparedContours.Capture(source, parameters); + var choice = prepared.ClosestEntry(0, Vector.Zero); + var choices = internalContour + ? new[] { choice, prepared.Entry(1, 0, new Vector(-5, -5)) } + : new[] { choice }; + var legacy = new ContourCuttingStrategy { Parameters = parameters }.ApplySingle(source, + Vector.Zero, target, internalContour ? ContourType.Internal : ContourType.External).Program; + Assert.Equal(ExplicitContourTests.Fingerprint(legacy), ExplicitContourTests.Fingerprint(prepared.Emit(choices))); + } + + [Fact] + public void Entries_AreStableBoundedNativeAndGeometricallyUnique() + { + var prepared = PreparedContours.Capture(ExplicitContourTests.Square(false), new CuttingParameters()); + var approach = new Vector(-1, 5); + var entries = prepared.Entries(0, approach); + Assert.Equal(prepared.ClosestEntry(0, approach), entries[0]); + Assert.Equal(new[] { new Vector(0, 5), new Vector(0, 0), new Vector(0, 10), + new Vector(5, 10), new Vector(10, 10), new Vector(10, 5), new Vector(10, 0), new Vector(5, 0) }, + entries.Select(e => e.Point)); + Assert.Equal(entries, prepared.Entries(0, approach)); + Assert.Equal(entries.Take(3), prepared.Entries(0, approach, 3)); + Assert.Single(prepared.Entries(0, approach, 1)); + Assert.Throws(() => ((IList)entries).Clear()); + Assert.Throws(() => prepared.Entries(0, approach, 0)); + Assert.Throws(() => prepared.Entries(0, approach, + token: new CancellationToken(true))); + Assert.Throws(() => PreparedContours.Capture( + ExplicitContourTests.Square(false), new CuttingParameters(), new CancellationToken(true))); + } + + [Fact] + public void Entries_IncludeNativeArcMidpointAndCircleAngles() + { + var source = new Program(); + source.MoveTo(1, 0); source.ArcTo(-1, 0, 0, 0, RotationType.CCW); source.LineTo(1, 0); + var prepared = PreparedContours.Capture(source, new CuttingParameters()); + var entries = prepared.Entries(0, new Vector(0, -2)); + Assert.Contains(entries, e => e.Point.DistanceTo(new Vector(0, 1)) < 1e-8); + Assert.Contains(entries, e => e.Point.DistanceTo(new Vector(-1, 0)) < 1e-8); + prepared = PreparedContours.Capture(Holes(), new CuttingParameters()); + var circles = prepared.Entries(0, new Vector(5, 5)); + Assert.InRange(circles.Count, 8, 9); + Assert.Equal(circles, prepared.Entries(0, new Vector(5, 5))); + Assert.All(circles, e => Assert.Equal(1, e.Point.DistanceTo(new Vector(3, 3)), 8)); + Assert.Equal(circles.Count, circles.Select(e => e.Point).Distinct().Count()); + } + + [Theory] + [InlineData(-1, 0, 0, 0)] + [InlineData(3, 0, 0, 0)] + [InlineData(0, -1, 0, 0)] + [InlineData(0, 1, 3, 3)] + [InlineData(0, 0, 3, 3)] + [InlineData(0, 0, double.NaN, 3)] + [InlineData(0, 0, double.PositiveInfinity, 3)] + public void Entry_RejectsInvalidNativeChoices(int contour, int entity, double x, double y) + { + var prepared = PreparedContours.Capture(Holes(), new CuttingParameters()); + Assert.Throws(() => prepared.Entry(contour, entity, new Vector(x, y))); + } + + [Fact] + public void Emit_RevalidatesRecordCopiesAndRejectsUnownedRecords() + { + var prepared = PreparedContours.Capture(ExplicitContourTests.Square(false), new CuttingParameters()); + var choice = prepared.ClosestEntry(0, Vector.Zero); + Assert.Throws(() => prepared.Emit([choice with { EntityOrdinal = 100 }])); + Assert.Throws(() => prepared.Emit([choice with { Point = new Vector(5, 5) }])); + Assert.Throws(() => prepared.Emit([new ContourChoice(0, 0, Vector.Zero)])); + Assert.Throws(() => prepared.Emit([null])); + } + + [Fact] + public void Capture_AccountsForDuplicateContoursWithoutJoiningOrDroppingThem() + { + var source = ExplicitContourTests.Square(false); + foreach (var ignored in new[] { 0, 1 }) + { + source.MoveTo(2, 2); source.LineTo(2, 4); source.LineTo(4, 4); + source.LineTo(4, 2); source.LineTo(2, 2); + source.MoveTo(7, 7); source.ArcTo(7, 7, 6, 7, RotationType.CCW); + } + var prepared = PreparedContours.Capture(source, new CuttingParameters()); + Assert.Equal(5, prepared.Count); + var choices = Enumerable.Range(0, 5).Select(i => prepared.ClosestEntry(i, Vector.Zero)).ToArray(); + var motions = ExecutionMotionReader.Read(prepared.Emit(choices), Vector.Zero, null, default).Motions; + Assert.Equal(14, motions.Count(m => !m.Rapid && m.Layer == LayerType.Display)); + } + + [Theory] + [InlineData("open")] + [InlineData("lead")] + [InlineData("layer")] + [InlineData("derived")] + [InlineData("mode")] + public void Capture_RefusesUnsupportedOrUncleanExecution(string fault) + { + var source = ExplicitContourTests.Square(false); + if (fault == "open") source.Codes.RemoveAt(source.Codes.Count - 1); + if (fault == "lead") ((LinearMove)source.Codes[1]).Layer = LayerType.Leadin; + if (fault == "layer") ((LinearMove)source.Codes[1]).Layer = (LayerType)100; + if (fault == "derived") source.Codes[1] = new CustomLinearMove(); + if (fault == "mode") typeof(Program).GetField("mode", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic)!.SetValue(source, (Mode)100); + Assert.ThrowsAny(() => PreparedContours.Capture(source, new CuttingParameters())); + } + + private sealed class CustomLinearMove : LinearMove { } + + [Fact] + public void ExplicitCircle_RoundsActualEntryClampsAndSharesOwnedSubprograms() + { + var source = Holes(); + var parameters = ExplicitContourTests.Parameters(); + parameters.ArcCircleLeadIn = new LineLeadIn { Length = 10 }; + parameters.RoundLeadInAngles = true; + parameters.LeadInAngleIncrement = 90; + var prepared = PreparedContours.Capture(source, parameters); + var choices = new[] { prepared.ClosestEntry(0, new Vector(5, 5)), + prepared.ClosestEntry(1, new Vector(9, 5)), prepared.ClosestEntry(2, Vector.Zero) }; + var result = prepared.Emit(choices); + var calls = result.Codes.OfType().ToArray(); + Assert.Equal(2, calls.Length); + Assert.Same(calls[0].Program, calls[1].Program); + var motions = ExecutionMotionReader.Read(result, Vector.Zero, null, default).Motions; + var leads = motions.Where(m => m.Layer == LayerType.Leadin).Take(2).ToArray(); + Assert.All(leads, m => Assert.True(m.Length < 2)); + Assert.True(choices[0].Point.DistanceTo(leads[0].End) > 0.1); + Assert.Equal(new Vector(4, 3), leads[0].End); + Assert.True(leads[0].Start!.Value.DistanceTo(new Vector(3, 3)) <= 0.95 + 1e-8); + var before = ExplicitContourTests.Fingerprint(prepared.Emit(choices)); + calls[0].Program.Codes.Clear(); result.Codes.Clear(); source.Codes.Clear(); + Assert.Equal(before, ExplicitContourTests.Fingerprint(prepared.Emit(choices))); + Assert.NotSame(calls[0].Program, prepared.Emit(choices).Codes.OfType().First().Program); + } + + [Theory] + [InlineData("normal")] + [InlineData("breaker")] + [InlineData("machine")] + public void ExplicitTab_RetainsLegacyGapAndOwnsTabSize(string style) + { + var parameters = ExplicitContourTests.Parameters(); + parameters.TabsEnabled = true; + parameters.TabConfig = style switch + { + "breaker" => new BreakerTab { Size = 0.2 }, + "machine" => new MachineTab { Size = 0.2 }, + _ => new NormalTab { Size = 0.2 } + }; + var source = ExplicitContourTests.Square(false); + var approach = new Vector(-1, 5); + var prepared = PreparedContours.Capture(source, parameters); + var choice = prepared.ClosestEntry(0, approach); + var result = prepared.Emit([choice]); + var legacy = new ContourCuttingStrategy { Parameters = parameters }.Apply(source, approach).Program; + Assert.Equal(ExplicitContourTests.Fingerprint(legacy), ExplicitContourTests.Fingerprint(result)); + var cuts = ExecutionMotionReader.Read(result, Vector.Zero, null, default).Motions + .Where(m => !m.Rapid && m.Layer == LayerType.Display).ToArray(); + Assert.True(cuts[0].Start!.Value.DistanceTo(cuts[^1].End) > 0.1); + parameters.TabConfig.Size = 8; parameters.TabsEnabled = false; + Assert.Equal(ExplicitContourTests.Fingerprint(result), ExplicitContourTests.Fingerprint(prepared.Emit([choice]))); + } + + [Fact] + public void PrefixAndCompletePrograms_EmitScribesExactlyOnceAndOwnThem() + { + var source = Holes(); + source.MoveTo(1, 1); source.Codes.Add(new LinearMove(new Vector(2, 1)) { Layer = LayerType.Scribe }); + var prepared = PreparedContours.Capture(source, new CuttingParameters()); + var choices = Enumerable.Range(0, 3).Select(i => prepared.ClosestEntry(i, Vector.Zero)).ToArray(); + Assert.Throws(() => prepared.EmitPrefix([choices[2]])); + foreach (var count in new[] { 0, 1, 2, 3 }) + { + var result = prepared.EmitPrefix(choices.Take(count).ToArray()); + var motions = ExecutionMotionReader.Read(result, Vector.Zero, null, default).Motions; + Assert.Single(motions.Where(m => m.Layer == LayerType.Scribe)); + result.Codes.Clear(); + } + Assert.Single(ExecutionMotionReader.Read(prepared.Emit(choices), Vector.Zero, null, default).Motions + .Where(m => m.Layer == LayerType.Scribe)); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void Capture_ExpandsSharedSubprogramFramesAndOwnsTheirMotion(bool incremental) + { + var hole = new Program(); + hole.MoveTo(1, 0); hole.ArcTo(1, 0, 0, 0, RotationType.CCW); + if (incremental) hole.Mode = Mode.Incremental; + var source = ExplicitContourTests.Square(false); + source.Codes.Add(new SubProgramCall { Id = 1, Program = hole, Offset = new Vector(3, 3) }); + source.Codes.Add(new SubProgramCall { Id = 1, Program = hole, Offset = new Vector(7, 3) }); + var prepared = PreparedContours.Capture(source, ExplicitContourTests.Parameters()); + Assert.Equal(3, prepared.Count); + var choices = Enumerable.Range(0, 3).Select(i => prepared.ClosestEntry(i, new Vector(20, 3))).ToArray(); + Assert.Equal(new Vector(4, 3), choices[0].Point); + Assert.Equal(new Vector(8, 3), choices[1].Point); + var before = ExplicitContourTests.Fingerprint(prepared.Emit(choices)); + hole.Codes.Clear(); source.Codes.Clear(); + Assert.Equal(before, ExplicitContourTests.Fingerprint(prepared.Emit(choices))); + } + + [Fact] + public void Prefix_RejectsDuplicateContoursEvenBeforeThePerimeter() + { + var prepared = PreparedContours.Capture(Holes(), new CuttingParameters()); + var choice = prepared.ClosestEntry(0, Vector.Zero); + Assert.Throws(() => prepared.EmitPrefix([choice, choice])); + } + + [Theory] + [InlineData("none", false)] + [InlineData("none", true)] + [InlineData("line", false)] + [InlineData("line", true)] + [InlineData("arc", false)] + [InlineData("arc", true)] + public void ExplicitLeadOut_RetainsLegacyGeometryAndOwnership(string style, bool reversed) + { + var source = ExplicitContourTests.Square(reversed); + var parameters = ExplicitContourTests.Parameters(); + parameters.ExternalLeadOut = style switch + { + "line" => new LineLeadOut { Length = 0.2, ApproachAngle = 45 }, + "arc" => new ArcLeadOut { Radius = 0.2 }, + _ => new NoLeadOut() + }; + var approach = new Vector(-1, 5); + var prepared = PreparedContours.Capture(source, parameters); + var choice = prepared.ClosestEntry(0, approach); + var emitted = prepared.Emit([choice]); + var legacy = new ContourCuttingStrategy { Parameters = parameters }.Apply(source, approach).Program; + Assert.Equal(ExplicitContourTests.Fingerprint(legacy), ExplicitContourTests.Fingerprint(emitted)); + if (parameters.ExternalLeadOut is LineLeadOut line) line.Length = 9; + if (parameters.ExternalLeadOut is ArcLeadOut arc) arc.Radius = 9; + Assert.Equal(ExplicitContourTests.Fingerprint(emitted), ExplicitContourTests.Fingerprint(prepared.Emit([choice]))); + } + + internal static Program Holes() + { + var p = ExplicitContourTests.Square(false); + foreach (var x in new[] { 3.0, 7.0 }) + { + p.MoveTo(x + 1, 3); p.ArcTo(x + 1, 3, x, 3, RotationType.CCW); + } + return p; + } +}