feat(cutting): emit owned contours in explicit order

This commit is contained in:
aj committed 2026-10-04 20:45:15 -04:00
1 parent 6addad7b2e
commit e71fcea73d
6 files changed
+899

No files matched your search

@@ -0,0 +1,139 @@
using System;
using OpenNest.CNC.CuttingStrategy;
namespace OpenNest.CNC.CuttingPlanning;
/// <summary>Copies only supported, exact setting types; never slices custom subclasses.</summary>
internal static class OwnedCuttingParameters
{
internal static CuttingParameters Copy(CuttingParameters source)
{
Exact<CuttingParameters>(source);
Exact<SequenceParameters>(source.Sequencing);
Exact<AssignmentParameters>(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>(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.");
}
@@ -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;
/// <summary>A nominal native entry owned by one preparation, not an actual emitted pierce.</summary>
public sealed record ContourChoice(int ContourOrdinal, int EntityOrdinal, Vector Point)
{
internal PreparedContours Owner { get; init; }
}
/// <summary>
/// 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.
/// </summary>
public sealed class PreparedContours
{
private readonly Shape[] shapes;
private readonly CuttingParameters parameters;
private readonly List<Entity> scribes;
private PreparedContours(Shape[] shapes, List<Entity> 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<Shape>();
var scribes = new List<Entity>();
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);
}
/// <summary>
/// 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.
/// </summary>
public IReadOnlyList<ContourChoice> 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<ContourChoice>();
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;
}
/// <summary>Emits every contour once in caller order, holes before perimeter, with scribes once.</summary>
public Program Emit(IReadOnlyList<ContourChoice> choices)
{
Validate(choices, true);
return EmitPrefix(choices);
}
// Each prefix is a standalone owned program, including the same scribes once.
internal Program EmitPrefix(IReadOnlyList<ContourChoice> 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<ContourChoice> 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<int>();
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<Program, Program>(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);
}
}
}
@@ -234,6 +234,21 @@ namespace OpenNest.CNC.CuttingStrategy
return null;
}
internal Program EmitPrepared(Shape[] shapes, List<Entity> scribes,
IReadOnlyList<CuttingPlanning.ContourChoice> 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);
+1
View File
@@ -6,6 +6,7 @@
</PropertyGroup>
<ItemGroup>
<InternalsVisibleTo Include="OpenNest.Tests" />
<InternalsVisibleTo Include="OpenNest.Engine" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Clipper2" Version="2.0.0" />
@@ -0,0 +1,170 @@
using OpenNest.CNC.CuttingPlanning;
using OpenNest.CNC.CuttingStrategy;
namespace OpenNest.Tests.CuttingPlanning;
public class OwnedCuttingParameterTests
{
public static IEnumerable<object[]> 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<object>(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<object> 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<Exception>(() => OwnedCuttingParameters.Copy(source));
Assert.ThrowsAny<Exception>(() => 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<ArgumentException>(() => 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 { }
}
@@ -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<SubProgramCall>().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<ArgumentException>(() => 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<NotSupportedException>(() => ((IList<ContourChoice>)entries).Clear());
Assert.Throws<ArgumentOutOfRangeException>(() => prepared.Entries(0, approach, 0));
Assert.Throws<OperationCanceledException>(() => prepared.Entries(0, approach,
token: new CancellationToken(true)));
Assert.Throws<OperationCanceledException>(() => 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<ArgumentException>(() => 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<ArgumentException>(() => prepared.Emit([choice with { EntityOrdinal = 100 }]));
Assert.Throws<ArgumentException>(() => prepared.Emit([choice with { Point = new Vector(5, 5) }]));
Assert.Throws<ArgumentException>(() => prepared.Emit([new ContourChoice(0, 0, Vector.Zero)]));
Assert.Throws<ArgumentException>(() => 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<Exception>(() => 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<SubProgramCall>().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<SubProgramCall>().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<ArgumentException>(() => 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<ArgumentException>(() => 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;
}
}