feat: implement tab support in ContourCuttingStrategy

When TabsEnabled is set, trims the end of each contour using a circle
centered at the lead-in point with radius equal to the tab size. The
uncut gap between the trim point and the contour start keeps the part
connected to the sheet.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-31 09:40:29 -04:00
parent 7c58cfa749
commit d7fa4bef43

View File

@@ -1,4 +1,5 @@
using OpenNest.Geometry;
using OpenNest.Math;
using System.Collections.Generic;
namespace OpenNest.CNC.CuttingStrategy
@@ -59,9 +60,18 @@ namespace OpenNest.CNC.CuttingStrategy
result.Codes.AddRange(leadIn.Generate(closestPt, normal, winding));
var reindexed = cutout.ReindexAt(closestPt, entity);
result.Codes.AddRange(ConvertShapeToMoves(reindexed, closestPt));
// TODO: MicrotabLeadOut — trim last cutting move by GapSize
result.Codes.AddRange(leadOut.Generate(closestPt, normal, winding));
if (Parameters.TabsEnabled && Parameters.TabConfig != null)
{
var trimmed = TrimShapeForTab(reindexed, closestPt, Parameters.TabConfig.Size);
result.Codes.AddRange(ConvertShapeToMoves(trimmed, closestPt));
result.Codes.AddRange(leadOut.Generate(closestPt, normal, winding));
}
else
{
result.Codes.AddRange(ConvertShapeToMoves(reindexed, closestPt));
result.Codes.AddRange(leadOut.Generate(closestPt, normal, winding));
}
currentPoint = closestPt;
}
@@ -80,9 +90,18 @@ namespace OpenNest.CNC.CuttingStrategy
result.Codes.AddRange(leadIn.Generate(perimeterPt, normal, winding));
var reindexed = profile.Perimeter.ReindexAt(perimeterPt, perimeterEntity);
result.Codes.AddRange(ConvertShapeToMoves(reindexed, perimeterPt));
// TODO: MicrotabLeadOut — trim last cutting move by GapSize
result.Codes.AddRange(leadOut.Generate(perimeterPt, normal, winding));
if (Parameters.TabsEnabled && Parameters.TabConfig != null)
{
var trimmed = TrimShapeForTab(reindexed, perimeterPt, Parameters.TabConfig.Size);
result.Codes.AddRange(ConvertShapeToMoves(trimmed, perimeterPt));
result.Codes.AddRange(leadOut.Generate(perimeterPt, normal, winding));
}
else
{
result.Codes.AddRange(ConvertShapeToMoves(reindexed, perimeterPt));
result.Codes.AddRange(leadOut.Generate(perimeterPt, normal, winding));
}
}
// Convert to incremental mode to match the convention used by
@@ -238,6 +257,70 @@ namespace OpenNest.CNC.CuttingStrategy
};
}
private static Shape TrimShapeForTab(Shape shape, Vector center, double tabSize)
{
var tabCircle = new Circle(center, tabSize);
var entities = new List<Entity>(shape.Entities);
// Trim end: walk backward removing entities inside the tab circle
while (entities.Count > 0)
{
var entity = entities[entities.Count - 1];
if (entity.Intersects(tabCircle, out var pts) && pts.Count > 0)
{
// Find intersection furthest from center (furthest along path from end)
var best = pts[0];
var bestDist = best.DistanceTo(center);
for (var j = 1; j < pts.Count; j++)
{
var dist = pts[j].DistanceTo(center);
if (dist > bestDist)
{
best = pts[j];
bestDist = dist;
}
}
if (entity is Line line)
{
var (first, _) = line.SplitAt(best);
entities.RemoveAt(entities.Count - 1);
if (first != null)
entities.Add(first);
}
else if (entity is Arc arc)
{
var (first, _) = arc.SplitAt(best);
entities.RemoveAt(entities.Count - 1);
if (first != null)
entities.Add(first);
}
break;
}
// No intersection — entity is entirely inside circle, remove it
if (EntityStartPoint(entity).DistanceTo(center) <= tabSize + Tolerance.Epsilon)
{
entities.RemoveAt(entities.Count - 1);
continue;
}
break;
}
var result = new Shape();
result.Entities.AddRange(entities);
return result;
}
private static Vector EntityStartPoint(Entity entity)
{
if (entity is Line line) return line.StartPoint;
if (entity is Arc arc) return arc.StartPoint();
return Vector.Zero;
}
private List<ICode> ConvertShapeToMoves(Shape shape, Vector startPoint, LayerType layer = LayerType.Display)
{
var moves = new List<ICode>();