From 4aed2316119d96546e12a1458a2126412fac0e5d Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Thu, 9 Apr 2026 14:35:56 -0400 Subject: [PATCH] feat: emit SubProgramCalls for circle holes in ContourCuttingStrategy Co-Authored-By: Claude Opus 4.6 (1M context) --- .../CuttingStrategy/ContourCuttingStrategy.cs | 48 +++++++++-- .../CuttingStrategy/HoleSubProgramTests.cs | 82 +++++++++++++++++++ 2 files changed, 125 insertions(+), 5 deletions(-) diff --git a/OpenNest.Core/CNC/CuttingStrategy/ContourCuttingStrategy.cs b/OpenNest.Core/CNC/CuttingStrategy/ContourCuttingStrategy.cs index ad747ce..6cc402b 100644 --- a/OpenNest.Core/CNC/CuttingStrategy/ContourCuttingStrategy.cs +++ b/OpenNest.Core/CNC/CuttingStrategy/ContourCuttingStrategy.cs @@ -1,5 +1,6 @@ using OpenNest.Geometry; using OpenNest.Math; +using System; using System.Collections.Generic; namespace OpenNest.CNC.CuttingStrategy @@ -245,6 +246,13 @@ namespace OpenNest.CNC.CuttingStrategy return perimeter.ClosestPointTo(lastCutout, out entity); } + private static int ComputeSubProgramKey(double radius, double normalAngle) + { + var r = System.Math.Round(radius, 6); + var a = System.Math.Round(normalAngle, 6); + return HashCode.Combine(r, a); + } + private void EmitContour(Program program, Shape shape, Vector point, Entity entity, ContourType? forceType = null) { var contourType = forceType ?? DetectContourType(shape); @@ -262,8 +270,6 @@ namespace OpenNest.CNC.CuttingStrategy normal = System.Math.Round(normal / increment) * increment; normal = Angle.NormalizeRad(normal); - // Recompute contour start point on the circle at the rounded angle. - // For ArcCircle, normal points inward (toward center), so outward = normal - PI. var outwardAngle = normal - System.Math.PI; point = new Vector( circle.Center.X + circle.Radius * System.Math.Cos(outwardAngle), @@ -271,16 +277,48 @@ namespace OpenNest.CNC.CuttingStrategy } leadIn = ClampLeadInForCircle(leadIn, circle, point, normal); + + // Build hole sub-program relative to (0,0) + var holeCenter = circle.Center; + var relativePoint = new Vector(point.X - holeCenter.X, point.Y - holeCenter.Y); + var relativeCircle = new Circle(new Vector(0, 0), circle.Radius) { Rotation = circle.Rotation }; + var relativeShape = new Shape(); + relativeShape.Entities.Add(relativeCircle); + + var subPgm = new Program(Mode.Absolute); + subPgm.Codes.AddRange(leadIn.Generate(relativePoint, normal, winding)); + var reindexed = relativeShape.ReindexAt(relativePoint, relativeCircle); + + if (Parameters.TabsEnabled && Parameters.TabConfig != null) + reindexed = TrimShapeForTab(reindexed, relativePoint, Parameters.TabConfig.Size); + + subPgm.Codes.AddRange(ConvertShapeToMoves(reindexed, relativePoint)); + subPgm.Codes.AddRange(leadOut.Generate(relativePoint, normal, winding)); + subPgm.Mode = Mode.Incremental; + + // Deduplicate: check if an identical sub-program already exists + var key = ComputeSubProgramKey(circle.Radius, normal); + if (!program.SubPrograms.ContainsKey(key)) + program.SubPrograms[key] = subPgm; + + program.Codes.Add(new SubProgramCall + { + Id = key, + Program = program.SubPrograms[key], + Offset = holeCenter + }); + + return; } program.Codes.AddRange(leadIn.Generate(point, normal, winding)); - var reindexed = shape.ReindexAt(point, entity); + var reindexedShape = shape.ReindexAt(point, entity); if (Parameters.TabsEnabled && Parameters.TabConfig != null) - reindexed = TrimShapeForTab(reindexed, point, Parameters.TabConfig.Size); + reindexedShape = TrimShapeForTab(reindexedShape, point, Parameters.TabConfig.Size); - program.Codes.AddRange(ConvertShapeToMoves(reindexed, point)); + program.Codes.AddRange(ConvertShapeToMoves(reindexedShape, point)); program.Codes.AddRange(leadOut.Generate(point, normal, winding)); } diff --git a/OpenNest.Tests/CuttingStrategy/HoleSubProgramTests.cs b/OpenNest.Tests/CuttingStrategy/HoleSubProgramTests.cs index 9e997d3..e5f6eaa 100644 --- a/OpenNest.Tests/CuttingStrategy/HoleSubProgramTests.cs +++ b/OpenNest.Tests/CuttingStrategy/HoleSubProgramTests.cs @@ -1,5 +1,7 @@ using OpenNest.CNC; +using OpenNest.CNC.CuttingStrategy; using OpenNest.Geometry; +using System.Linq; namespace OpenNest.Tests.CuttingStrategy; @@ -76,4 +78,84 @@ public class HoleSubProgramTests Assert.NotSame(sub, clone.SubPrograms[1]); Assert.Equal(Mode.Incremental, clone.SubPrograms[1].Mode); } + + [Fact] + public void Apply_CircleHole_EmitsSubProgramCall() + { + // Create a program with a square perimeter and a circle hole at (5, 5) radius 0.5 + var pgm = new Program(Mode.Absolute); + // Square perimeter + pgm.Codes.Add(new RapidMove(0, 0)); + pgm.Codes.Add(new LinearMove(0, 10)); + pgm.Codes.Add(new LinearMove(10, 10)); + pgm.Codes.Add(new LinearMove(10, 0)); + pgm.Codes.Add(new LinearMove(0, 0)); + // Circle hole at (5, 5) radius 0.5 + pgm.Codes.Add(new RapidMove(5.5, 5)); + pgm.Codes.Add(new ArcMove(new Vector(5.5, 5), new Vector(5, 5), RotationType.CW)); + + var strategy = new ContourCuttingStrategy + { + Parameters = new CuttingParameters + { + ArcCircleLeadIn = new LineLeadIn { Length = 0.125, ApproachAngle = 90 }, + ArcCircleLeadOut = new NoLeadOut() + } + }; + + var result = strategy.Apply(pgm, new Vector(10, 10)); + + // Should contain at least one SubProgramCall + var calls = result.Program.Codes.OfType().ToList(); + Assert.Single(calls); + + // The call's offset should be approximately at the hole center (5, 5) + var call = calls[0]; + Assert.Equal(5, call.Offset.X, 1); + Assert.Equal(5, call.Offset.Y, 1); + + // The parent program should have a sub-program registered + Assert.True(result.Program.SubPrograms.ContainsKey(call.Id)); + } + + [Fact] + public void Apply_TwoIdenticalCircles_ShareSubProgram() + { + // Square perimeter with two identical circle holes at different positions + var pgm = new Program(Mode.Absolute); + // Square perimeter + pgm.Codes.Add(new RapidMove(0, 0)); + pgm.Codes.Add(new LinearMove(0, 10)); + pgm.Codes.Add(new LinearMove(10, 10)); + pgm.Codes.Add(new LinearMove(10, 0)); + pgm.Codes.Add(new LinearMove(0, 0)); + // Circle 1 at (2, 2) radius 0.5 + pgm.Codes.Add(new RapidMove(2.5, 2)); + pgm.Codes.Add(new ArcMove(new Vector(2.5, 2), new Vector(2, 2), RotationType.CW)); + // Circle 2 at (6, 6) radius 0.5 + pgm.Codes.Add(new RapidMove(6.5, 6)); + pgm.Codes.Add(new ArcMove(new Vector(6.5, 6), new Vector(6, 6), RotationType.CW)); + + var strategy = new ContourCuttingStrategy + { + Parameters = new CuttingParameters + { + RoundLeadInAngles = true, + LeadInAngleIncrement = 5.0, + ArcCircleLeadIn = new LineLeadIn { Length = 0.125, ApproachAngle = 90 }, + ArcCircleLeadOut = new NoLeadOut() + } + }; + + var result = strategy.Apply(pgm, new Vector(10, 10)); + + var calls = result.Program.Codes.OfType().ToList(); + Assert.Equal(2, calls.Count); + + // Both calls should reference the same sub-program ID (same radius, same quantized angle) + Assert.Equal(calls[0].Id, calls[1].Id); + + // But different offsets + Assert.NotEqual(calls[0].Offset.X, calls[1].Offset.X); + } }