Files
OpenNest/OpenNest.Tests/CuttingStrategy/TabbedLeadOutTests.cs
T
ajandClaude Opus 5.5 52e421dcbe fix(cutting): center straight leads on a corner a tab wraps
A tab entered at a fillet joint trims back around the corner, so the cut
stops on one edge and starts on the other. Each straight lead took its own
edge's normal and the pair sat 150 degrees apart, reading as one bent line
through the corner. Both now take the bisector of the two ends' normals and
form the same V they form on a straight edge, falling back to their own
normals when either lead would not clear the part.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 22:08:18 -05:00

156 lines
6.9 KiB
C#

using OpenNest.CNC;
using OpenNest.CNC.CuttingPlanning;
using OpenNest.CNC.CuttingStrategy;
using OpenNest.Geometry;
using OpenNest.Tests.CuttingPlanning;
namespace OpenNest.Tests.CuttingStrategy;
/// <summary>
/// A tab trims the perimeter short of its entry, so the lead-out leaves from where the
/// trimmed cut actually ends. Generating it from the nominal entry emitted an arc whose
/// start was off its own radius.
/// </summary>
public class TabbedLeadOutTests
{
private const double TabSize = 0.2;
private const double LeadRadius = 0.2;
[Theory]
[InlineData(false)]
[InlineData(true)]
public void ArcLeadOut_LeavesTangentlyFromTheTrimmedCutEnd(bool reversed)
{
var execution = Emit(ExplicitContourTests.Square(reversed), new Vector(-2, 5),
new ArcLeadOut { Radius = LeadRadius }, tabs: true);
var (cuts, leadout) = Split(execution);
var end = cuts[^1].End;
Assert.Equal(0, end.X, 9);
Assert.Equal(TabSize, System.Math.Abs(end.Y - 5), 9);
Assert.Equal(end, leadout.Start!.Value);
var arc = Assert.IsType<Arc>(leadout.Curve.ToEntity());
Assert.Equal(LeadRadius, arc.Radius, 9);
// Centerd on the actual end's outward normal, so it touches the edge only where it starts.
Assert.Equal(-LeadRadius, arc.Center.X, 9);
Assert.Equal(end.Y, arc.Center.Y, 9);
}
[Fact]
public void ArcLeadOut_OnCurvedPerimeter_IsTangentAtTheTrimmedEnd()
{
// Half disc: the entry is on the arc, so the trimmed end's radial differs from the entry's.
var clean = new Program();
clean.MoveTo(0, 5); clean.LineTo(10, 5); clean.ArcTo(0, 5, 5, 5, RotationType.CW);
var execution = Emit(clean, new Vector(5, -2), new ArcLeadOut { Radius = LeadRadius }, tabs: true);
var (cuts, leadout) = Split(execution);
var center = new Vector(5, 5);
var end = cuts[^1].End;
Assert.Equal(5, end.DistanceTo(center), 9);
Assert.Equal(TabSize, end.DistanceTo(new Vector(5, 0)), 9);
Assert.Equal(end, leadout.Start!.Value);
var arc = Assert.IsType<Arc>(leadout.Curve.ToEntity());
Assert.Equal(LeadRadius, arc.Radius, 9);
// Externally tangent to the perimeter at the actual end: center on its radial line.
Assert.Equal(5 + LeadRadius, arc.Center.DistanceTo(center), 9);
var radial = end - center;
var offset = arc.Center - end;
Assert.Equal(0, radial.X * offset.Y - radial.Y * offset.X, 9);
}
[Fact]
public void LineLeadOut_RunsAlongTheEndNormalFromTheTrimmedCutEnd()
{
var execution = Emit(ExplicitContourTests.Square(false), new Vector(-2, 5),
new LineLeadOut { Length = 0.3 }, tabs: true);
var (cuts, leadout) = Split(execution);
Assert.Equal(cuts[^1].End, leadout.Start!.Value);
Assert.Equal(-0.3, leadout.End.X, 9);
Assert.Equal(cuts[^1].End.Y, leadout.End.Y, 9);
}
[Fact]
public void UntabbedArcLeadOut_StillLeavesFromTheEntry()
{
var execution = Emit(ExplicitContourTests.Square(false), new Vector(-2, 5),
new ArcLeadOut { Radius = LeadRadius }, tabs: false);
var (cuts, leadout) = Split(execution);
Assert.Equal(new Vector(0, 5), cuts[^1].End);
var arc = Assert.IsType<Arc>(leadout.Curve.ToEntity());
Assert.Equal(-LeadRadius, arc.Center.X, 9);
Assert.Equal(5, arc.Center.Y, 9);
}
[Theory]
[InlineData(false)]
[InlineData(true)]
public void LineLeads_TabWrappingAFilletedCorner_ShareTheCornerBisector(bool reversed)
{
// Entered at the fillet joint the tab trims back around: on its own edge's normal the
// lead-in and lead-out would sit 150 degrees apart, not the 60 degree V of a straight edge.
const double fillet = 0.015;
const double tab = 0.03;
var clean = new Program(Mode.Absolute);
if (reversed)
{
clean.MoveTo(fillet, 10); clean.ArcTo(0, 10 - fillet, fillet, 10 - fillet, RotationType.CCW);
clean.LineTo(0, 0); clean.LineTo(10, 0); clean.LineTo(10, 10); clean.LineTo(fillet, 10);
}
else
{
clean.MoveTo(0, 0); clean.LineTo(0, 10 - fillet);
clean.ArcTo(fillet, 10, fillet, 10 - fillet, RotationType.CW);
clean.LineTo(10, 10); clean.LineTo(10, 0); clean.LineTo(0, 0);
}
var part = new Part(new Drawing("tabbed-fillet", clean));
var perimeter = LeadInTestHelpers.Profile(part).Perimeter;
var entry = reversed ? new Vector(0, 10 - fillet) : new Vector(fillet, 10);
var entity = perimeter.Entities.OfType<Line>().First(l => l.StartPoint.DistanceTo(entry) < 1e-9);
var parameters = new CuttingParameters
{
ExternalLeadIn = new LineLeadIn { Length = 0.15, ApproachAngle = 60 },
ExternalLeadOut = new LineLeadOut { Length = 0.1, ApproachAngle = 60 },
TabsEnabled = true,
TabConfig = new NormalTab { Size = tab },
};
part.ApplySingleLeadIn(parameters, entry, entity, ContourType.External);
var geometry = part.Program.ToGeometry();
var leadIn = Assert.IsType<Line>(Assert.Single(geometry.Where(e => e.Layer == SpecialLayers.Leadin)));
var leadOut = Assert.IsType<Line>(Assert.Single(geometry.Where(e => e.Layer == SpecialLayers.Leadout)));
LeadInTestHelpers.AssertPoint(entry, leadIn.EndPoint);
Assert.Equal(tab, leadOut.StartPoint.DistanceTo(entry), 9);
// The cut stops on the other edge, past the corner.
Assert.Equal(0, reversed ? 10 - leadOut.StartPoint.Y : leadOut.StartPoint.X, 9);
const double bisector = 3 * System.Math.PI / 4;
var spread = OpenNest.Math.Angle.ToRadians(30);
LeadInTestHelpers.AssertPoint(entry + Polar(0.15, bisector - spread), leadIn.StartPoint);
LeadInTestHelpers.AssertPoint(leadOut.StartPoint + Polar(0.1, bisector + spread), leadOut.EndPoint);
}
private static Vector Polar(double length, double angle) =>
new(length * System.Math.Cos(angle), length * System.Math.Sin(angle));
private static OwnedExecution Emit(Program clean, Vector approach, LeadOut leadOut, bool tabs)
{
var parameters = ExplicitContourTests.Parameters();
parameters.TabsEnabled = tabs;
parameters.TabConfig = new NormalTab { Size = TabSize };
parameters.ExternalLeadOut = leadOut;
var emitted = new ContourCuttingStrategy { Parameters = parameters }.Apply(clean, approach);
return ExecutionMotionReader.Read(emitted.Program, Vector.Zero, null, default);
}
private static (ExecutionMotion[] Cuts, ExecutionMotion Leadout) Split(OwnedExecution execution)
{
var cuts = execution.Motions.Where(m => m.Layer == LayerType.Display && !m.Rapid).ToArray();
Assert.NotEmpty(cuts);
return (cuts, Assert.Single(execution.Motions.Where(m => m.Layer == LayerType.Leadout)));
}
}