feat: add ApplySingle for exact-click single-contour lead-in placement

Adds ApplySingle to ContourCuttingStrategy that applies lead-in/out to
only the contour containing the clicked entity, emitting other contours
as raw geometry. Also adds ApplySingleLeadIn wrapper to Part.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-02 13:32:56 -04:00
parent 25ee193ae6
commit 9f9111975d
3 changed files with 262 additions and 0 deletions

View File

@@ -50,6 +50,126 @@ namespace OpenNest.CNC.CuttingStrategy
};
}
public CuttingResult ApplySingle(Program partProgram, Vector point, Entity entity, ContourType contourType)
{
var entities = partProgram.ToGeometry();
entities.RemoveAll(e => e.Layer == SpecialLayers.Rapid);
var scribeEntities = entities.FindAll(e => e.Layer == SpecialLayers.Scribe);
entities.RemoveAll(e => e.Layer == SpecialLayers.Scribe);
var profile = new ShapeProfile(entities);
var result = new Program(Mode.Absolute);
EmitScribeContours(result, scribeEntities);
// Find the target shape that contains the clicked entity
var (targetShape, matchedEntity) = FindTargetShape(profile, point, entity);
// Emit cutouts — only the target gets lead-in/out
foreach (var cutout in profile.Cutouts)
{
if (cutout == targetShape)
{
var ct = DetectContourType(cutout);
EmitContour(result, cutout, point, matchedEntity, ct);
}
else
{
EmitRawContour(result, cutout);
}
}
// Emit perimeter
if (profile.Perimeter == targetShape)
{
EmitContour(result, profile.Perimeter, point, matchedEntity, ContourType.External);
}
else
{
EmitRawContour(result, profile.Perimeter);
}
result.Mode = Mode.Incremental;
return new CuttingResult
{
Program = result,
LastCutPoint = point
};
}
private static (Shape Shape, Entity Entity) FindTargetShape(ShapeProfile profile, Vector point, Entity clickedEntity)
{
var matched = FindMatchingEntity(profile.Perimeter, clickedEntity);
if (matched != null)
return (profile.Perimeter, matched);
foreach (var cutout in profile.Cutouts)
{
matched = FindMatchingEntity(cutout, clickedEntity);
if (matched != null)
return (cutout, matched);
}
// Fallback: closest shape, use closest point to find entity
var best = profile.Perimeter;
var bestPt = profile.Perimeter.ClosestPointTo(point, out var bestEntity);
var bestDist = bestPt.DistanceTo(point);
foreach (var cutout in profile.Cutouts)
{
var pt = cutout.ClosestPointTo(point, out var cutoutEntity);
var dist = pt.DistanceTo(point);
if (dist < bestDist)
{
best = cutout;
bestEntity = cutoutEntity;
bestDist = dist;
}
}
return (best, bestEntity);
}
private static Entity FindMatchingEntity(Shape shape, Entity clickedEntity)
{
foreach (var shapeEntity in shape.Entities)
{
if (shapeEntity.GetType() != clickedEntity.GetType())
continue;
if (shapeEntity is Line sLine && clickedEntity is Line cLine)
{
if (sLine.StartPoint.DistanceTo(cLine.StartPoint) < Math.Tolerance.Epsilon
&& sLine.EndPoint.DistanceTo(cLine.EndPoint) < Math.Tolerance.Epsilon)
return shapeEntity;
}
else if (shapeEntity is Arc sArc && clickedEntity is Arc cArc)
{
if (System.Math.Abs(sArc.Radius - cArc.Radius) < Math.Tolerance.Epsilon
&& sArc.Center.DistanceTo(cArc.Center) < Math.Tolerance.Epsilon)
return shapeEntity;
}
else if (shapeEntity is Circle sCircle && clickedEntity is Circle cCircle)
{
if (System.Math.Abs(sCircle.Radius - cCircle.Radius) < Math.Tolerance.Epsilon
&& sCircle.Center.DistanceTo(cCircle.Center) < Math.Tolerance.Epsilon)
return shapeEntity;
}
}
return null;
}
private void EmitRawContour(Program program, Shape shape)
{
var startPoint = GetShapeStartPoint(shape);
program.Codes.Add(new RapidMove(startPoint));
program.Codes.AddRange(ConvertShapeToMoves(shape, startPoint));
}
private static List<ContourEntry> ResolveLeadInPoints(List<Shape> cutouts, Vector startPoint)
{
var entries = new ContourEntry[cutouts.Count];

View File

@@ -72,6 +72,18 @@ namespace OpenNest
UpdateBounds();
}
public void ApplySingleLeadIn(CNC.CuttingStrategy.CuttingParameters parameters,
Geometry.Vector point, Geometry.Entity entity, CNC.CuttingStrategy.ContourType contourType)
{
preLeadInRotation = Rotation;
var strategy = new CNC.CuttingStrategy.ContourCuttingStrategy { Parameters = parameters };
var result = strategy.ApplySingle(Program, point, entity, contourType);
Program = result.Program;
CuttingParameters = parameters;
HasManualLeadIns = true;
UpdateBounds();
}
public void RemoveLeadIns()
{
var rotation = preLeadInRotation;

View File

@@ -0,0 +1,130 @@
using OpenNest.CNC;
using OpenNest.CNC.CuttingStrategy;
using OpenNest.Geometry;
namespace OpenNest.Tests.CuttingStrategy;
public class ApplySingleTests
{
private static Program MakeSquareProgram()
{
var pgm = new Program();
pgm.Codes.Add(new RapidMove(new Vector(0, 0)));
pgm.Codes.Add(new LinearMove(new Vector(0, 10)));
pgm.Codes.Add(new LinearMove(new Vector(10, 10)));
pgm.Codes.Add(new LinearMove(new Vector(10, 0)));
pgm.Codes.Add(new LinearMove(new Vector(0, 0)));
return pgm;
}
private static Program MakeSquareWithHoleProgram()
{
var pgm = new Program();
pgm.Codes.Add(new RapidMove(new Vector(0, 0)));
pgm.Codes.Add(new LinearMove(new Vector(0, 20)));
pgm.Codes.Add(new LinearMove(new Vector(20, 20)));
pgm.Codes.Add(new LinearMove(new Vector(20, 0)));
pgm.Codes.Add(new LinearMove(new Vector(0, 0)));
pgm.Codes.Add(new RapidMove(new Vector(12, 10)));
pgm.Codes.Add(new ArcMove(new Vector(12, 10), new Vector(10, 10), RotationType.CW));
return pgm;
}
private static List<ICode> GetLeadInCodes(Program program)
{
var result = new List<ICode>();
foreach (var code in program.Codes)
{
if (code is LinearMove lm && lm.Layer == LayerType.Leadin)
result.Add(lm);
else if (code is ArcMove am && am.Layer == LayerType.Leadin)
result.Add(am);
}
return result;
}
[Fact]
public void ApplySingle_ExternalContour_PlacesLeadInAtExactPoint()
{
var pgm = MakeSquareProgram();
var strategy = new ContourCuttingStrategy
{
Parameters = new CuttingParameters
{
ExternalLeadIn = new LineLeadIn { Length = 0.5, ApproachAngle = 90 }
}
};
var clickPoint = new Vector(5, 0);
var entity = new Line(new Vector(10, 0), new Vector(0, 0));
var result = strategy.ApplySingle(pgm, clickPoint, entity, ContourType.External);
var hasLeadin = result.Program.Codes.OfType<LinearMove>().Any(m => m.Layer == LayerType.Leadin);
Assert.True(hasLeadin);
}
[Fact]
public void ApplySingle_ContourStartsAtClickPoint()
{
var pgm = MakeSquareProgram();
var strategy = new ContourCuttingStrategy
{
Parameters = new CuttingParameters
{
ExternalLeadIn = new LineLeadIn { Length = 0.5, ApproachAngle = 90 }
}
};
var clickPoint = new Vector(5, 0);
var entity = new Line(new Vector(10, 0), new Vector(0, 0));
var result = strategy.ApplySingle(pgm, clickPoint, entity, ContourType.External);
// Convert back to absolute to check positions
result.Program.Mode = Mode.Absolute;
var firstLinear = result.Program.Codes.OfType<LinearMove>()
.First(m => m.Layer == LayerType.Leadin);
Assert.Equal(clickPoint.X, firstLinear.EndPoint.X, 4);
Assert.Equal(clickPoint.Y, firstLinear.EndPoint.Y, 4);
}
[Fact]
public void ApplySingle_ProgramModeIsIncremental()
{
var pgm = MakeSquareProgram();
var strategy = new ContourCuttingStrategy
{
Parameters = new CuttingParameters
{
ExternalLeadIn = new LineLeadIn { Length = 0.5, ApproachAngle = 90 }
}
};
var clickPoint = new Vector(5, 0);
var entity = new Line(new Vector(10, 0), new Vector(0, 0));
var result = strategy.ApplySingle(pgm, clickPoint, entity, ContourType.External);
Assert.Equal(Mode.Incremental, result.Program.Mode);
}
[Fact]
public void ApplySingle_OnlyTargetContourGetsLeadIn()
{
var pgm = MakeSquareWithHoleProgram();
var strategy = new ContourCuttingStrategy
{
Parameters = new CuttingParameters
{
ExternalLeadIn = new LineLeadIn { Length = 0.5, ApproachAngle = 90 },
InternalLeadIn = new LineLeadIn { Length = 0.25, ApproachAngle = 90 }
}
};
var clickPoint = new Vector(10, 0);
var entity = new Line(new Vector(20, 0), new Vector(0, 0));
var result = strategy.ApplySingle(pgm, clickPoint, entity, ContourType.External);
var leadinMoves = GetLeadInCodes(result.Program);
Assert.Single(leadinMoves);
}
}