From 9f9111975d729853fe75826932c582af8993e965 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Thu, 2 Apr 2026 13:32:56 -0400 Subject: [PATCH] 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) --- .../CuttingStrategy/ContourCuttingStrategy.cs | 120 ++++++++++++++++ OpenNest.Core/Part.cs | 12 ++ .../CuttingStrategy/ApplySingleTests.cs | 130 ++++++++++++++++++ 3 files changed, 262 insertions(+) create mode 100644 OpenNest.Tests/CuttingStrategy/ApplySingleTests.cs diff --git a/OpenNest.Core/CNC/CuttingStrategy/ContourCuttingStrategy.cs b/OpenNest.Core/CNC/CuttingStrategy/ContourCuttingStrategy.cs index 813e1ad..4e0de86 100644 --- a/OpenNest.Core/CNC/CuttingStrategy/ContourCuttingStrategy.cs +++ b/OpenNest.Core/CNC/CuttingStrategy/ContourCuttingStrategy.cs @@ -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 ResolveLeadInPoints(List cutouts, Vector startPoint) { var entries = new ContourEntry[cutouts.Count]; diff --git a/OpenNest.Core/Part.cs b/OpenNest.Core/Part.cs index 5a106bf..b674e8c 100644 --- a/OpenNest.Core/Part.cs +++ b/OpenNest.Core/Part.cs @@ -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; diff --git a/OpenNest.Tests/CuttingStrategy/ApplySingleTests.cs b/OpenNest.Tests/CuttingStrategy/ApplySingleTests.cs new file mode 100644 index 0000000..7886660 --- /dev/null +++ b/OpenNest.Tests/CuttingStrategy/ApplySingleTests.cs @@ -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 GetLeadInCodes(Program program) + { + var result = new List(); + 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().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() + .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); + } +}