merge: integrate cutout corner lead-in bisectors

This commit is contained in:
aj
2026-09-28 19:40:02 -04:00
4 changed files with 424 additions and 13 deletions
@@ -367,7 +367,8 @@ namespace OpenNest.CNC.CuttingStrategy
return;
}
program.Codes.AddRange(leadIn.Generate(point, normal, winding));
var leadInNormal = ComputeLeadInNormal(shape, point, entity, contourType, leadIn, winding);
program.Codes.AddRange(leadIn.Generate(point, leadInNormal, winding));
var reindexedShape = shape.ReindexAt(point, entity);
@@ -436,6 +437,65 @@ namespace OpenNest.CNC.CuttingStrategy
return ContourType.Internal;
}
/// <summary>
/// Uses the inward angle bisector for straight lead-ins at cutout corners.
/// Edge interiors and other lead-in styles keep the entity normal. Shared
/// by program generation and the manual placement preview.
/// </summary>
public static double ComputeLeadInNormal(
Shape shape,
Vector point,
Entity entity,
ContourType contourType,
LeadIn leadIn,
RotationType winding = RotationType.CW
)
{
var normal = ComputeNormal(point, entity, contourType, winding);
if (contourType != ContourType.Internal || leadIn is not LineLeadIn
|| entity is not (Line or Arc) || entity.Length <= Tolerance.Epsilon
|| shape.Entities.Count < 2 || !shape.IsClosed())
return normal;
var index = shape.Entities.IndexOf(entity);
if (index < 0)
return normal;
var atStart = point.DistanceTo(EntityStartPoint(entity)) <= Tolerance.Epsilon;
if (!atStart && point.DistanceTo(EntityEndPoint(entity)) > Tolerance.Epsilon)
return normal;
var adjacentIndex = atStart
? (index + shape.Entities.Count - 1) % shape.Entities.Count
: (index + 1) % shape.Entities.Count;
var adjacent = shape.Entities[adjacentIndex];
var adjacentPoint = atStart ? EntityEndPoint(adjacent) : EntityStartPoint(adjacent);
if (adjacent is not (Line or Arc) || adjacent.Length <= Tolerance.Epsilon
|| point.DistanceTo(adjacentPoint) > Tolerance.Epsilon)
return normal;
var adjacentNormal = ComputeNormal(point, adjacent, contourType, winding);
// Sum unit normals rather than averaging angles (which fails at 0/2π).
// Winding makes this point into the scrap even at reflex corners.
var x = System.Math.Cos(normal) + System.Math.Cos(adjacentNormal);
var y = System.Math.Sin(normal) + System.Math.Sin(adjacentNormal);
if (!double.IsFinite(x) || !double.IsFinite(y)
|| x * x + y * y <= Tolerance.Epsilon * Tolerance.Epsilon)
return normal; // Opposing normals at a cusp have no unique bisector.
return Angle.NormalizeRad(System.Math.Atan2(y, x));
}
private static Vector EntityEndPoint(Entity entity)
{
if (entity is Line line)
return line.EndPoint;
if (entity is Arc arc)
return arc.EndPoint();
return Vector.Invalid;
}
public static double ComputeNormal(
Vector point,
Entity entity,
@@ -0,0 +1,306 @@
using OpenNest.CNC;
using OpenNest.CNC.CuttingStrategy;
using OpenNest.Geometry;
namespace OpenNest.Tests.CuttingStrategy;
public class CutoutCornerLeadInTests
{
private const double LeadLength = 0.125;
[Theory]
[InlineData(false)]
[InlineData(true)]
public void ApplyLeadIns_SmallRectangularCutout_PiercesInsideOnBisector(bool reverse)
{
// Dimensions of the small slots in 4980 A01 PT07.dxf.
var vertices = Rectangle();
var part = MakePart(vertices, reverse);
part.ApplyLeadIns(Parameters(), Vector.Zero);
var lead = SingleLeadIn(part);
var corner = new Vector(2.282, 2.532);
AssertPoint(corner, lead.EndPoint);
AssertBisector(lead, corner, new Vector(-1, -1));
Assert.True(lead.StartPoint.X > 2 && lead.StartPoint.X < 2.282);
Assert.True(lead.StartPoint.Y > 2 && lead.StartPoint.Y < 2.532);
}
public static IEnumerable<object[]> Corners()
{
foreach (var reverse in new[] { false, true })
foreach (var rotation in new[] { 0.0, 0.63 })
for (var corner = 0; corner < 4; corner++)
foreach (var incoming in new[] { false, true })
yield return new object[] { reverse, rotation, corner, incoming };
}
[Theory]
[MemberData(nameof(Corners))]
public void ApplySingleLeadIn_EitherCornerEdge_UsesSameInwardBisector(
bool reverse, double rotation, int cornerIndex, bool incoming)
{
var part = MakePart(Rectangle(), reverse);
part.Rotate(rotation);
var cutout = Cutout(part);
var outgoing = Assert.IsType<Line>(cutout.Entities[cornerIndex]);
var point = outgoing.StartPoint;
var entity = incoming
? cutout.Entities[(cornerIndex + cutout.Entities.Count - 1) % cutout.Entities.Count]
: outgoing;
// Normalize each rectangular axis, not the unequal diagonal lengths.
var next = outgoing.EndPoint - point;
var previous = Assert.IsType<Line>(cutout.Entities[(cornerIndex + 3) % 4]).StartPoint - point;
var direction = next / next.DistanceTo(Vector.Zero) + previous / previous.DistanceTo(Vector.Zero);
part.ApplySingleLeadIn(Parameters(), point, entity, ContourType.Internal);
AssertBisector(SingleLeadIn(part), point, direction);
}
[Theory]
[InlineData(30, false)]
[InlineData(30, true)]
[InlineData(90, false)]
[InlineData(90, true)]
[InlineData(140, false)]
[InlineData(140, true)]
public void ApplySingleLeadIn_UnequalEdgeLengths_BisectsAngle(double degrees, bool reverse)
{
var angle = degrees * System.Math.PI / 180;
var point = new Vector(3, 3);
var vertices = new[]
{
point,
point + new Vector(4, 0),
point + new Vector(2 * System.Math.Cos(angle), 2 * System.Math.Sin(angle)),
};
var part = MakePart(vertices, reverse);
var cutout = Cutout(part);
var entity = cutout.Entities.OfType<Line>().First(e => e.StartPoint == point);
part.ApplySingleLeadIn(Parameters(), point, entity, ContourType.Internal);
AssertBisector(SingleLeadIn(part), point,
new Vector(System.Math.Cos(angle / 2), System.Math.Sin(angle / 2)));
}
[Theory]
[InlineData(false)]
[InlineData(true)]
public void ApplySingleLeadIn_ReflexCorner_BisectorPointsIntoScrap(bool reverse)
{
var vertices = new[]
{
new Vector(2, 2), new Vector(6, 2), new Vector(6, 4),
new Vector(4, 4), new Vector(4, 6), new Vector(2, 6),
};
var part = MakePart(vertices, reverse);
var point = new Vector(4, 4);
var entity = Cutout(part).Entities.OfType<Line>().First(e => e.StartPoint == point);
part.ApplySingleLeadIn(Parameters(), point, entity, ContourType.Internal);
AssertBisector(SingleLeadIn(part), point, new Vector(-1, -1));
}
[Fact]
public void ApplySingleLeadIn_MidEdge_KeepsPerpendicularApproach()
{
var part = MakePart(Rectangle());
var entity = Assert.IsType<Line>(Cutout(part).Entities[0]);
var point = entity.MidPoint;
var normal = ContourCuttingStrategy.ComputeNormal(point, entity, ContourType.Internal, RotationType.CCW);
var expected = Parameters().InternalLeadIn.GetPiercePoint(point, normal);
part.ApplySingleLeadIn(Parameters(), point, entity, ContourType.Internal);
AssertPoint(expected, SingleLeadIn(part).StartPoint);
}
[Fact]
public void ApplySingleLeadIn_ExternalCorner_KeepsEntityNormal()
{
var part = MakePart(Rectangle());
var profile = Profile(part);
var entity = Assert.IsType<Line>(profile.Perimeter.Entities[0]);
var point = entity.StartPoint;
var parameters = Parameters();
parameters.ExternalLeadIn = parameters.InternalLeadIn;
var normal = ContourCuttingStrategy.ComputeNormal(point, entity, ContourType.External,
ContourCuttingStrategy.DetermineWinding(profile.Perimeter));
part.ApplySingleLeadIn(parameters, point, entity, ContourType.External);
AssertPoint(parameters.ExternalLeadIn.GetPiercePoint(point, normal), SingleLeadIn(part).StartPoint);
}
[Fact]
public void ApplySingleLeadIn_Corner_DoesNotChangeLeadOutDirection()
{
var part = MakePart(Rectangle());
var cutout = Cutout(part);
var entity = Assert.IsType<Line>(cutout.Entities[0]);
var point = entity.StartPoint;
var parameters = Parameters();
parameters.InternalLeadOut = new LineLeadOut { Length = 0.05 };
var normal = ContourCuttingStrategy.ComputeNormal(point, entity, ContourType.Internal,
ContourCuttingStrategy.DetermineWinding(cutout));
var expected = Assert.IsType<LinearMove>(Assert.Single(parameters.InternalLeadOut.Generate(point, normal)));
part.ApplySingleLeadIn(parameters, point, entity, ContourType.Internal);
var leadOut = Assert.IsType<Line>(Assert.Single(part.Program.ToGeometry().Where(e => e.Layer == SpecialLayers.Leadout)));
AssertPoint(expected.EndPoint, leadOut.EndPoint);
}
[Theory]
[InlineData(false, false)]
[InlineData(false, true)]
[InlineData(true, false)]
[InlineData(true, true)]
public void ApplySingleLeadIn_LineArcCorner_UsesTangentBisector(bool reverse, bool selectArc)
{
var part = MakePart(Rectangle());
var program = part.BaseDrawing.Program.Clone() as Program;
// Replace the rectangular hole with a right half-circle, closed by a line.
program!.Codes.RemoveRange(5, program.Codes.Count - 5);
var point = new Vector(3, 3);
var top = new Vector(3, 7);
program.Codes.Add(new RapidMove(point));
if (reverse)
{
program.Codes.Add(new LinearMove(top));
program.Codes.Add(new ArcMove(point, new Vector(3, 5), RotationType.CW));
}
else
{
program.Codes.Add(new ArcMove(top, new Vector(3, 5), RotationType.CCW));
program.Codes.Add(new LinearMove(point));
}
part = new Part(new Drawing("line-arc-corner", program));
var shape = Cutout(part);
Assert.True(shape.IsClosed());
var entity = shape.Entities.Single(e => selectArc ? e is Arc : e is Line);
var parameters = Parameters();
var previewNormal = ContourCuttingStrategy.ComputeLeadInNormal(shape, point, entity,
ContourType.Internal, parameters.InternalLeadIn, ContourCuttingStrategy.DetermineWinding(shape));
part.ApplySingleLeadIn(parameters, point, entity, ContourType.Internal);
var lead = SingleLeadIn(part);
AssertBisector(lead, point, new Vector(1, 1));
AssertPoint(parameters.InternalLeadIn.GetPiercePoint(point, previewNormal), lead.StartPoint);
}
[Theory]
[InlineData(0)]
[InlineData(1)]
[InlineData(2)]
[InlineData(3)]
public void ComputeLeadInNormal_NonStraightStyles_KeepEntityNormal(int style)
{
var shape = Cutout(MakePart(Rectangle()));
var entity = Assert.IsType<Line>(shape.Entities[0]);
var leadIn = style switch
{
0 => (LeadIn)new ArcLeadIn { Radius = 0.05 },
1 => new LineArcLeadIn { ArcRadius = 0.05, LineLength = 0.1 },
2 => new LineLineLeadIn { Length1 = 0.05, Length2 = 0.1 },
_ => new NoLeadIn(),
};
var winding = ContourCuttingStrategy.DetermineWinding(shape);
var expected = ContourCuttingStrategy.ComputeNormal(entity.StartPoint, entity, ContourType.Internal, winding);
var actual = ContourCuttingStrategy.ComputeLeadInNormal(shape, entity.StartPoint, entity,
ContourType.Internal, leadIn, winding);
Assert.Equal(expected, actual);
}
[Theory]
[InlineData("open")]
[InlineData("zero-length")]
[InlineData("cusp")]
[InlineData("endpoint-gap")]
public void ComputeLeadInNormal_AmbiguousCorner_FallsBackToFiniteEntityNormal(string kind)
{
var shape = Cutout(MakePart(Rectangle()));
var entity = Assert.IsType<Line>(shape.Entities[0]);
var point = entity.StartPoint;
switch (kind)
{
case "open":
shape.Entities.RemoveAt(3);
break;
case "zero-length":
shape.Entities.Insert(0, new Line(point, point));
break;
case "cusp":
shape.Entities.Clear();
shape.Entities.Add(entity);
shape.Entities.Add(new Line(entity.EndPoint, entity.StartPoint));
break;
case "endpoint-gap":
// A chained contour is not necessarily an exact shared vertex.
var last = Assert.IsType<Line>(shape.Entities[3]);
last.EndPoint = point + new Vector(0, OpenNest.Math.Tolerance.ChainTolerance / 2);
break;
}
var expected = ContourCuttingStrategy.ComputeNormal(point, entity, ContourType.Internal, RotationType.CCW);
var actual = ContourCuttingStrategy.ComputeLeadInNormal(shape, point, entity,
ContourType.Internal, Parameters().InternalLeadIn, RotationType.CCW);
Assert.True(double.IsFinite(actual));
Assert.Equal(expected, actual);
}
private static Vector[] Rectangle() => new[]
{
new Vector(2, 2), new Vector(2.282, 2),
new Vector(2.282, 2.532), new Vector(2, 2.532),
};
private static CuttingParameters Parameters() => new()
{
InternalLeadIn = new LineLeadIn { Length = LeadLength, ApproachAngle = 90 },
};
private static Part MakePart(Vector[] hole, bool reverse = false)
{
var program = new Program(Mode.Absolute);
AddContour(program, new[] { new Vector(0, 0), new Vector(10, 0), new Vector(10, 10), new Vector(0, 10) });
AddContour(program, reverse ? hole.Reverse().ToArray() : hole);
return new Part(new Drawing("corner-test", program));
}
private static void AddContour(Program program, Vector[] vertices)
{
program.Codes.Add(new RapidMove(vertices[0]));
foreach (var point in vertices.Skip(1).Append(vertices[0]))
program.Codes.Add(new LinearMove(point));
}
private static ShapeProfile Profile(Part part) => new(part.Program.ToGeometry()
.Where(e => SpecialLayers.IsMaterial(e.Layer)).ToList());
private static Shape Cutout(Part part) => Assert.Single(Profile(part).Cutouts);
private static Line SingleLeadIn(Part part) => Assert.IsType<Line>(Assert.Single(
part.Program.ToGeometry().Where(e => e.Layer == SpecialLayers.Leadin)));
private static void AssertBisector(Line lead, Vector corner, Vector direction)
{
AssertPoint(corner, lead.EndPoint);
AssertPoint(corner + direction / direction.DistanceTo(Vector.Zero) * LeadLength, lead.StartPoint);
Assert.Equal(LeadLength, lead.Length, 8);
}
private static void AssertPoint(Vector expected, Vector actual)
{
Assert.Equal(expected.X, actual.X, 8);
Assert.Equal(expected.Y, actual.Y, 8);
}
}
+8 -12
View File
@@ -200,12 +200,6 @@ namespace OpenNest.Actions
snapPoint = closest;
snapEntity = entity;
snapContourType = info.ContourType;
snapNormal = ContourCuttingStrategy.ComputeNormal(
closest,
entity,
info.ContourType,
info.Winding
);
hasSnap = true;
hoveredContour = info;
}
@@ -345,6 +339,14 @@ namespace OpenNest.Actions
if (leadIn == null)
return;
snapNormal = ContourCuttingStrategy.ComputeLeadInNormal(
hoveredContour.Shape,
snapPoint,
snapEntity,
snapContourType,
leadIn,
hoveredContour.Winding
);
leadIn = ClampLeadInForCircle(leadIn, parameters);
var piercePoint = leadIn.GetPiercePoint(snapPoint, snapNormal);
@@ -431,12 +433,6 @@ namespace OpenNest.Actions
{
snapPoint = bestPoint;
snapEntity = bestEntity;
snapNormal = ContourCuttingStrategy.ComputeNormal(
bestPoint,
bestEntity,
snapContourType,
hoveredContour.Winding
);
activeSnapType = bestType;
}
+49
View File
@@ -0,0 +1,49 @@
# Lead-in placement at cutout corners
Straight (`LineLeadIn`) lead-ins at closed internal contour corners use the
inward angle bisector instead of the normal of whichever edge was selected.
With the default 90-degree approach angle, a rectangular cutout therefore gets
a diagonal lead-in into the scrap, rather than one lying along the other edge.
This applies to automatic assignment and manual placement. The manual preview
uses the same Core calculation, including when the lead-in style changes while
the cursor is stationary.
`ContourCuttingStrategy.ComputeLeadInNormal` combines the two adjacent inward
unit normals. It handles either winding, either selected edge, rotated parts,
and line/arc junctions using their local normals. Summing vectors avoids angle
wraparound and edge-length weighting. Open contours, disconnected endpoints,
zero-length edges, and cusps without a unique bisector retain the entity normal.
The configured approach-angle offset is still applied relative to the computed
normal; 90 degrees follows the bisector. Lead-in length is unchanged. Mid-edge
points, external contours, circles, curved/composite lead-in styles, and
lead-outs keep their existing placement rules.
This is a local direction correction, not a whole-path clearance guarantee.
An excessively long lead-in or an approach angle rotated away from the bisector
can still leave a small cutout. General non-circular containment/length clamping
and sharp-corner handling for curved/composite lead-ins remain separate work.
## Regression checks
Run `dotnet test OpenNest.Tests/OpenNest.Tests.csproj --filter FullyQualifiedName~CutoutCornerLeadInTests`.
The tests exercise generated part programs, default automatic placement, every
rectangular corner with both adjoining edges and windings, part rotation, acute
and obtuse angles, reflex corners, line/arc corners, preview agreement, and
unchanged/fallback behavior.
A headless before/after import of `4980 A01 PT07.dxf` (SHA-256
`1535D77BC1EEEDD21A27E7CE91EA4C51055118D019C5A09C144F1F41740895B6`)
reproduced the issue on all five 0.282 × 0.532 rectangular cutouts. At the default
0.125 lead-in length, all five corrected pierce points are approximately 0.08838835
inside both adjacent edges. The straight segments stay inside the rectangles
except for their contour endpoints; the other two generated lead-ins remain
unchanged. Coordinates were compared with a 1e-8 tolerance to allow incremental
program round-trip floating-point noise. The source drawing is not bundled.
Windows manual acceptance: import the part and use Plate > Assign Lead-ins.
Confirm diagonal lead-ins at all five small rectangular cutouts. Under Plate >
Place Lead-in, select the part, lock a cutout, and hover a corner: the preview
should point into the cutout and the committed lead-in should match. Hover a
mid-edge point and confirm perpendicular placement is unchanged. Windows visual
interaction is not verified by the Linux cross-build.