From 9f76659d5df6444804450ae1f53411ce6a3f5098 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Wed, 1 Apr 2026 14:26:47 -0400 Subject: [PATCH] refactor: two-pass lead-in placement in ContourCuttingStrategy Resolve lead-in points by walking backward through cutting order (from perimeter outward) so each lead-in faces the next cutout to be cut rather than pointing back at the previous lead-out. Extract EmitContour and EmitScribeContours to eliminate duplicated cutout/perimeter logic. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../CuttingStrategy/ContourCuttingStrategy.cs | 148 ++++++++---------- 1 file changed, 68 insertions(+), 80 deletions(-) diff --git a/OpenNest.Core/CNC/CuttingStrategy/ContourCuttingStrategy.cs b/OpenNest.Core/CNC/CuttingStrategy/ContourCuttingStrategy.cs index f404873..eef0d86 100644 --- a/OpenNest.Core/CNC/CuttingStrategy/ContourCuttingStrategy.cs +++ b/OpenNest.Core/CNC/CuttingStrategy/ContourCuttingStrategy.cs @@ -8,113 +8,101 @@ namespace OpenNest.CNC.CuttingStrategy { public CuttingParameters Parameters { get; set; } + private record ContourEntry(Shape Shape, Vector Point, Entity Entity); + public CuttingResult Apply(Program partProgram, Vector approachPoint) { - var exitPoint = approachPoint; var entities = partProgram.ToGeometry(); entities.RemoveAll(e => e.Layer == SpecialLayers.Rapid); - // Separate scribe/etch entities — they don't get lead-ins or kerf var scribeEntities = entities.FindAll(e => e.Layer == SpecialLayers.Scribe); entities.RemoveAll(e => e.Layer == SpecialLayers.Scribe); var profile = new ShapeProfile(entities); - // Find closest point on perimeter from exit point - var perimeterPoint = profile.Perimeter.ClosestPointTo(exitPoint, out var perimeterEntity); - - // Chain cutouts by nearest-neighbor from perimeter point, then reverse - // so farthest cutouts are cut first, nearest-to-perimeter cut last + // Forward pass: sequence cutouts nearest-neighbor from perimeter + var perimeterPoint = profile.Perimeter.ClosestPointTo(approachPoint, out _); var orderedCutouts = SequenceCutouts(profile.Cutouts, perimeterPoint); orderedCutouts.Reverse(); - // Build output program: scribe first, cutouts second, perimeter last + // Backward pass: walk from perimeter back through cutting order + // so each lead-in faces the next cutout to be cut, not the previous + var cutoutEntries = ResolveLeadInPoints(orderedCutouts, perimeterPoint); + var result = new Program(Mode.Absolute); - var currentPoint = exitPoint; - // Emit scribe/etch contours first (no lead-ins, no kerf) - if (scribeEntities.Count > 0) - { - var scribeShapes = ShapeBuilder.GetShapes(scribeEntities); - foreach (var scribe in scribeShapes) - { - var startPt = GetShapeStartPoint(scribe); - result.Codes.Add(new RapidMove(startPt)); - result.Codes.AddRange(ConvertShapeToMoves(scribe, startPt, LayerType.Scribe)); - currentPoint = startPt; - } - } + EmitScribeContours(result, scribeEntities); - foreach (var cutout in orderedCutouts) - { - var contourType = DetectContourType(cutout); - var closestPt = cutout.ClosestPointTo(currentPoint, out var entity); - var normal = ComputeNormal(closestPt, entity, contourType); - var winding = DetermineWinding(cutout); - - var leadIn = SelectLeadIn(contourType); - var leadOut = SelectLeadOut(contourType); - - if (contourType == ContourType.ArcCircle && entity is Circle circle) - leadIn = ClampLeadInForCircle(leadIn, circle, closestPt, normal); - - result.Codes.AddRange(leadIn.Generate(closestPt, normal, winding)); - var reindexed = cutout.ReindexAt(closestPt, entity); - - 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; - } - - var lastCutPoint = exitPoint; + foreach (var entry in cutoutEntries) + EmitContour(result, entry.Shape, entry.Point, entry.Entity); // Perimeter last - { - var perimeterPt = profile.Perimeter.ClosestPointTo(currentPoint, out perimeterEntity); - lastCutPoint = perimeterPt; - var normal = ComputeNormal(perimeterPt, perimeterEntity, ContourType.External); - var winding = DetermineWinding(profile.Perimeter); + var lastRefPoint = cutoutEntries.Count > 0 ? cutoutEntries[cutoutEntries.Count - 1].Point : approachPoint; + var perimeterPt = profile.Perimeter.ClosestPointTo(lastRefPoint, out var perimeterEntity); + EmitContour(result, profile.Perimeter, perimeterPt, perimeterEntity, ContourType.External); - var leadIn = SelectLeadIn(ContourType.External); - var leadOut = SelectLeadOut(ContourType.External); - - result.Codes.AddRange(leadIn.Generate(perimeterPt, normal, winding)); - var reindexed = profile.Perimeter.ReindexAt(perimeterPt, perimeterEntity); - - 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 - // the rest of the system (rendering, bounding box, drag, etc.). result.Mode = Mode.Incremental; return new CuttingResult { Program = result, - LastCutPoint = lastCutPoint + LastCutPoint = perimeterPt }; } + private static List ResolveLeadInPoints(List cutouts, Vector startPoint) + { + var entries = new ContourEntry[cutouts.Count]; + var currentPoint = startPoint; + + // Walk backward through cutting order (from perimeter outward) + // so each cutout's lead-in point faces the next cutout to be cut + for (var i = cutouts.Count - 1; i >= 0; i--) + { + var closestPt = cutouts[i].ClosestPointTo(currentPoint, out var entity); + entries[i] = new ContourEntry(cutouts[i], closestPt, entity); + currentPoint = closestPt; + } + + return new List(entries); + } + + private void EmitContour(Program program, Shape shape, Vector point, Entity entity, ContourType? forceType = null) + { + var contourType = forceType ?? DetectContourType(shape); + var normal = ComputeNormal(point, entity, contourType); + var winding = DetermineWinding(shape); + + var leadIn = SelectLeadIn(contourType); + var leadOut = SelectLeadOut(contourType); + + if (contourType == ContourType.ArcCircle && entity is Circle circle) + leadIn = ClampLeadInForCircle(leadIn, circle, point, normal); + + program.Codes.AddRange(leadIn.Generate(point, normal, winding)); + + var reindexed = shape.ReindexAt(point, entity); + + if (Parameters.TabsEnabled && Parameters.TabConfig != null) + reindexed = TrimShapeForTab(reindexed, point, Parameters.TabConfig.Size); + + program.Codes.AddRange(ConvertShapeToMoves(reindexed, point)); + program.Codes.AddRange(leadOut.Generate(point, normal, winding)); + } + + private void EmitScribeContours(Program program, List scribeEntities) + { + if (scribeEntities.Count == 0) return; + + var shapes = ShapeBuilder.GetShapes(scribeEntities); + foreach (var shape in shapes) + { + var startPt = GetShapeStartPoint(shape); + program.Codes.Add(new RapidMove(startPt)); + program.Codes.AddRange(ConvertShapeToMoves(shape, startPt, LayerType.Scribe)); + } + } + private List SequenceCutouts(List cutouts, Vector startPoint) { var remaining = new List(cutouts);