From d133f6879b7bc45024eba3ccf64da7e06aedac17 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Tue, 3 Nov 2026 09:44:43 -0500 Subject: [PATCH] fix(geometry): leave leads out of push and collision material A part with lead-ins applied could not be pushed away once another part touched it. PartGeometry kept LEADIN/LEADOUT entities as material, so the leads chained onto the perimeter as open spurs and the spacing offset no longer closed. The slide-contact classifier cannot tell the material side of an unclosed boundary and blocks every contact, so parts resting against it could not move up or right off it. Leads are cut into scrap, and the spacing review, overlap analyzer and support checks already drop them. Co-Authored-By: Claude Opus 5.5 --- OpenNest.Core/PartGeometry.cs | 47 +++++++++---------- OpenNest.Tests/Fill/CompactorTests.cs | 29 ++++++++++++ ...PartGeometryDirectionalPreparationTests.cs | 8 ++-- 3 files changed, 56 insertions(+), 28 deletions(-) diff --git a/OpenNest.Core/PartGeometry.cs b/OpenNest.Core/PartGeometry.cs index 2289c43b..bb36d5d5 100644 --- a/OpenNest.Core/PartGeometry.cs +++ b/OpenNest.Core/PartGeometry.cs @@ -7,12 +7,21 @@ namespace OpenNest { public static class PartGeometry { + /// + /// Nesting material of a program, without lead-ins and lead-outs: a lead is a cut + /// into scrap, not part material, and chaining it into the perimeter leaves an open + /// spur, so the offset contour no longer closes and every slide contact against it + /// blocks. + /// + private static List MaterialEntities(CNC.Program program) => + ConvertProgram.ToGeometry(program) + .Where(e => e.Layer != SpecialLayers.Leadin && e.Layer != SpecialLayers.Leadout) + .ForNesting(); + public static List GetPartLines(Part part, double chordTolerance = 0.001) { - var entities = ConvertProgram.ToGeometry(part.Program); - var shapes = ShapeBuilder.GetShapes( - entities.ForNesting() - ); + var entities = MaterialEntities(part.Program); + var shapes = ShapeBuilder.GetShapes(entities); var lines = new List(); foreach (var shape in shapes) @@ -55,10 +64,8 @@ namespace OpenNest public static List GetOffsetPerimeterEntities(CNC.Program program, double spacing) { PerfCounters.CountOffsetPerimeterEntities(); - var geoEntities = ConvertProgram.ToGeometry(program); - var profile = new ShapeProfile( - geoEntities.ForNesting().ToList() - ); + var geoEntities = MaterialEntities(program); + var profile = new ShapeProfile(geoEntities); var offsetShape = profile.Perimeter.OffsetOutward(spacing); if (offsetShape == null) @@ -73,10 +80,8 @@ namespace OpenNest /// public static List GetOffsetPartEntities(Part part, double spacing) { - var geoEntities = ConvertProgram.ToGeometry(part.Program); - var profile = new ShapeProfile( - geoEntities.ForNesting().ToList() - ); + var geoEntities = MaterialEntities(part.Program); + var profile = new ShapeProfile(geoEntities); var entities = new List(); var perimeter = profile.Perimeter.OffsetOutward(spacing); @@ -106,10 +111,8 @@ namespace OpenNest /// public static List GetPerimeterEntities(Part part) { - var geoEntities = ConvertProgram.ToGeometry(part.Program); - var profile = new ShapeProfile( - geoEntities.ForNesting().ToList() - ); + var geoEntities = MaterialEntities(part.Program); + var profile = new ShapeProfile(geoEntities); return CopyEntitiesAtLocation(profile.Perimeter.Entities, part.Location); } @@ -120,10 +123,8 @@ namespace OpenNest /// public static List GetPartEntities(Part part) { - var geoEntities = ConvertProgram.ToGeometry(part.Program); - var profile = new ShapeProfile( - geoEntities.ForNesting().ToList() - ); + var geoEntities = MaterialEntities(part.Program); + var profile = new ShapeProfile(geoEntities); var entities = CopyEntitiesAtLocation(profile.Perimeter.Entities, part.Location); foreach (var cutout in profile.Cutouts) @@ -165,10 +166,8 @@ namespace OpenNest Vector vectorDirection ) { - var entities = ConvertProgram.ToGeometry(part.Program); - var shapes = ShapeBuilder.GetShapes( - entities.ForNesting() - ); + var entities = MaterialEntities(part.Program); + var shapes = ShapeBuilder.GetShapes(entities); var lines = new List(); foreach (var shape in shapes) diff --git a/OpenNest.Tests/Fill/CompactorTests.cs b/OpenNest.Tests/Fill/CompactorTests.cs index 398dafef..beb824ee 100644 --- a/OpenNest.Tests/Fill/CompactorTests.cs +++ b/OpenNest.Tests/Fill/CompactorTests.cs @@ -312,6 +312,35 @@ namespace OpenNest.Tests.Fill AssertClearance(moving, obstacle, 2); } + [Theory] + [InlineData(PushDirection.Right)] + [InlineData(PushDirection.Up)] + public void Push_WithSpacing_ContactWithLeadInPartDoesNotBlockOtherDirections( + PushDirection next + ) + { + // Leads are cut into scrap, not material: chained into the obstacle's perimeter + // they left its offset contour open, so every contact with it blocked. + var workArea = new Box(0, 0, 100, 100); + var obstacle = MakeRectPart(20, 20, 40, 10); + obstacle.ApplyLeadIns(new OpenNest.CNC.CuttingStrategy.CuttingParameters + { + ExternalLeadIn = new OpenNest.CNC.CuttingStrategy.LineLeadIn { Length = 0.25 }, + ExternalLeadOut = new OpenNest.CNC.CuttingStrategy.LineLeadOut { Length = 0.25 }, + }, new Vector(0, 0)); + var moving = MakeRectPart(30, 60, 5, 5); + var parts = new List { moving }; + var obstacles = new List { obstacle }; + + Assert.True(Compactor.Push(parts, obstacles, workArea, 2, PushDirection.Down) > 0); + Assert.Equal(32, moving.BoundingBox.Bottom, 7); + + var distance = Compactor.Push(parts, obstacles, workArea, 2, next); + + Assert.True(distance > 1, $"Push {next} after contact moved only {distance:R}"); + AssertClearance(moving, obstacle, 2); + } + [Fact] public void Push_WithSpacing_ContactStillBlocksTheSameDirection() { diff --git a/OpenNest.Tests/Geometry/PartGeometryDirectionalPreparationTests.cs b/OpenNest.Tests/Geometry/PartGeometryDirectionalPreparationTests.cs index 679a81b4..5c3e3575 100644 --- a/OpenNest.Tests/Geometry/PartGeometryDirectionalPreparationTests.cs +++ b/OpenNest.Tests/Geometry/PartGeometryDirectionalPreparationTests.cs @@ -45,13 +45,13 @@ public class PartGeometryDirectionalPreparationTests } [Fact] - public void MaterialIncludesDisplayAndLeads_ButExcludesRapidAndScribe() + public void MaterialIncludesDisplay_ButExcludesLeadsRapidAndScribe() { var material = RectangleProgram(false); AddRectangle(material, 15, 2, 3, 2, LayerType.Display); + var clean = MakePart(material); AddRectangle(material, 21, 3, 2, 4, LayerType.Leadin); AddRectangle(material, 26, 1, 4, 3, LayerType.Leadout); - var clean = MakePart(material); AddRectangle(material, 100, 120, 7, 9, LayerType.Scribe); material.MoveTo(-200, -300); material.MoveTo(400, 500); @@ -59,9 +59,9 @@ public class PartGeometryDirectionalPreparationTests foreach (var direction in new[] { PushDirection.Left, PushDirection.Right, PushDirection.Up, PushDirection.Down }) { var expected = FrozenGetPartLines(clean, direction); - Assert.Equal(4, expected.Count); + Assert.Equal(2, expected.Count); AssertLines(expected, PartGeometry.GetPartLines(marked, direction)); - AssertMatches(marked, direction, new Vector(3, -2), 0.001); + AssertMatches(clean, direction, new Vector(3, -2), 0.001); } AssertLines(PartGeometry.GetPartLines(clean), PartGeometry.GetPartLines(marked)); }