merge: integrate cutout corner lead-in bisectors
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user