From 0186ccfbff69fc9d3fc87d3ffcb128b950507682 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Sun, 18 Oct 2026 01:14:22 -0400 Subject: [PATCH] feat(nest): keep parts out of tabbed cutouts A tabbed cutout's slug stays attached while the sheet is cut, but nesting saw a closed hole and could place parts inside it. Nesting and layout paths now read material through NestingMaterial.ForNesting, which drops closed contours with tab spans so the slug counts as part. That covers part outlines and collision lines, the cutout router, fill expansion, best-fit, NestLayoutCheck and the GPU bitmaps. Area, overlap diagnostics and cutting keep the hole, and programs without tabs read exactly as before. Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 2 +- OpenNest.Core/Part.cs | 2 +- OpenNest.Core/PartGeometry.cs | 12 +-- .../TabbedCutouts/NestingMaterial.cs | 29 +++++++ .../Jobs/TabbedCutoutNestingTests.cs | 83 +++++++++++++++++++ OpenNest.Engine/BestFit/BestFitResult.cs | 2 +- OpenNest.Engine/BestFit/PolygonHelper.cs | 2 +- OpenNest.Engine/Fill/Expander.cs | 2 +- OpenNest.Engine/Jobs/JobPartGeometry.cs | 6 +- OpenNest.Engine/Jobs/NestLayoutCheck.cs | 2 +- OpenNest.Gpu/PartBitmap.cs | 4 +- docs/tabbed-cutouts.md | 14 +++- 12 files changed, 139 insertions(+), 21 deletions(-) create mode 100644 OpenNest.Core/TabbedCutouts/NestingMaterial.cs create mode 100644 OpenNest.Engine.Tests/Jobs/TabbedCutoutNestingTests.cs diff --git a/AGENTS.md b/AGENTS.md index a80d1774..fa3528d6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -59,7 +59,7 @@ Read the relevant contract before changing its behavior: - [Directional slides](docs/geometry/directional-slides.md) and [pair-spacing limits](docs/geometry/pair-spacing.md) - [Lead-in placement](docs/geometry/lead-in-placement.md) - [Breakoffs](docs/breakoffs.md): tabbed scrap; `Break`-layer open cuts that are cut but are not material -- [Tabbed cutouts](docs/tabbed-cutouts.md): holes cut in pieces around `Tab`-layer spans that are material but never cut +- [Tabbed cutouts](docs/tabbed-cutouts.md): holes cut in pieces around `Tab`-layer spans that are material but never cut; nesting reads material through `NestingMaterial.ForNesting`, so nothing nests in them - [Material-overlap diagnostics](docs/geometry/visual-overlap-check.md) - [Automatic cutoffs and sequencing](docs/automatic-scrap-cutoffs.md) - [Pre-post verification](docs/post-verification.md) diff --git a/OpenNest.Core/Part.cs b/OpenNest.Core/Part.cs index 0688e194..aa342f47 100644 --- a/OpenNest.Core/Part.cs +++ b/OpenNest.Core/Part.cs @@ -352,7 +352,7 @@ namespace OpenNest PerfCounters.CountOverlapPolygonPreparation(); return ConvertProgram .ToGeometry(program) - .Where(e => SpecialLayers.IsMaterial(e.Layer)) + .ForNesting() .ToList(); } diff --git a/OpenNest.Core/PartGeometry.cs b/OpenNest.Core/PartGeometry.cs index b6d5bc4e..2289c43b 100644 --- a/OpenNest.Core/PartGeometry.cs +++ b/OpenNest.Core/PartGeometry.cs @@ -11,7 +11,7 @@ namespace OpenNest { var entities = ConvertProgram.ToGeometry(part.Program); var shapes = ShapeBuilder.GetShapes( - entities.Where(e => SpecialLayers.IsMaterial(e.Layer)) + entities.ForNesting() ); var lines = new List(); @@ -57,7 +57,7 @@ namespace OpenNest PerfCounters.CountOffsetPerimeterEntities(); var geoEntities = ConvertProgram.ToGeometry(program); var profile = new ShapeProfile( - geoEntities.Where(e => SpecialLayers.IsMaterial(e.Layer)).ToList() + geoEntities.ForNesting().ToList() ); var offsetShape = profile.Perimeter.OffsetOutward(spacing); @@ -75,7 +75,7 @@ namespace OpenNest { var geoEntities = ConvertProgram.ToGeometry(part.Program); var profile = new ShapeProfile( - geoEntities.Where(e => SpecialLayers.IsMaterial(e.Layer)).ToList() + geoEntities.ForNesting().ToList() ); var entities = new List(); @@ -108,7 +108,7 @@ namespace OpenNest { var geoEntities = ConvertProgram.ToGeometry(part.Program); var profile = new ShapeProfile( - geoEntities.Where(e => SpecialLayers.IsMaterial(e.Layer)).ToList() + geoEntities.ForNesting().ToList() ); return CopyEntitiesAtLocation(profile.Perimeter.Entities, part.Location); @@ -122,7 +122,7 @@ namespace OpenNest { var geoEntities = ConvertProgram.ToGeometry(part.Program); var profile = new ShapeProfile( - geoEntities.Where(e => SpecialLayers.IsMaterial(e.Layer)).ToList() + geoEntities.ForNesting().ToList() ); var entities = CopyEntitiesAtLocation(profile.Perimeter.Entities, part.Location); @@ -167,7 +167,7 @@ namespace OpenNest { var entities = ConvertProgram.ToGeometry(part.Program); var shapes = ShapeBuilder.GetShapes( - entities.Where(e => SpecialLayers.IsMaterial(e.Layer)) + entities.ForNesting() ); var lines = new List(); diff --git a/OpenNest.Core/TabbedCutouts/NestingMaterial.cs b/OpenNest.Core/TabbedCutouts/NestingMaterial.cs new file mode 100644 index 00000000..73418e74 --- /dev/null +++ b/OpenNest.Core/TabbedCutouts/NestingMaterial.cs @@ -0,0 +1,29 @@ +using System.Collections.Generic; +using System.Linq; +using OpenNest.Geometry; + +namespace OpenNest +{ + public static class NestingMaterial + { + /// + /// The material entities nesting and layout validation place parts against: those + /// keeps, except that a tabbed cutout's slug stays + /// attached while the sheet is cut, so a closed hole with + /// spans is dropped and its slug counts as part. Area, overlap diagnostics and cutting + /// keep the hole. Entities without tab spans pass through unchanged. + /// + public static List ForNesting(this IEnumerable entities) + { + var material = entities.Where(e => SpecialLayers.IsMaterial(e.Layer)).ToList(); + if (!material.Exists(e => e.Layer == SpecialLayers.Tab)) + return material; + + var kept = new List(); + foreach (var shape in ShapeBuilder.GetShapes(material)) + if (!shape.Entities.Exists(e => e.Layer == SpecialLayers.Tab)) + kept.AddRange(shape.Entities); + return kept; + } + } +} diff --git a/OpenNest.Engine.Tests/Jobs/TabbedCutoutNestingTests.cs b/OpenNest.Engine.Tests/Jobs/TabbedCutoutNestingTests.cs new file mode 100644 index 00000000..de6462e6 --- /dev/null +++ b/OpenNest.Engine.Tests/Jobs/TabbedCutoutNestingTests.cs @@ -0,0 +1,83 @@ +using OpenNest.CNC; +using OpenNest.Converters; +using OpenNest.Engine.Jobs; +using OpenNest.Engine.Jobs.Adapters; +using OpenNest.Geometry; +using OpenNest.TabbedCutouts; + +namespace OpenNest.Engine.Tests.Jobs; + +/// +/// A 20 x 20 frame with a 10 x 10 hole and a 6 x 6 insert on a 22 x 22 sheet: the insert only +/// fits inside the hole, which a tabbed cutout's attached slug fills. +/// +[Collection("Cutout pipeline registry")] +public class TabbedCutoutNestingTests +{ + private static Program Frame(bool tabbed) + { + var hole = new Entity[] { new Line(5, 5, 15, 5), new Line(15, 5, 15, 15), new Line(15, 15, 5, 15), new Line(5, 15, 5, 5) }; + var entities = new List + { + new Line(0, 0, 20, 0), new Line(20, 0, 20, 20), new Line(20, 20, 0, 20), new Line(0, 20, 0, 0), + }; + entities.AddRange(hole); + if (tabbed) + entities = TabbedCutoutBuilder.Apply(entities, + [new TabbedCutout { EdgeIds = hole.Select(e => e.Id).ToList() }], out _); + return ConvertGeometry.ToProgram(ShapeProfile.NormalizeEntities(entities)); + } + + private static NestPipelineRequest Request(string engine, bool tabbed) => + new(engine, + new[] { new NestItem { Drawing = new Drawing("frame", Frame(tabbed)), Quantity = 1 }, + new NestItem { Drawing = new Drawing("insert", TestDrawingFactory.Rectangle(6, 6)), Quantity = 1 } }, + new[] { new NestPlateStock("sheet", new Size(22, 22), 1, 0.25) }, + new NestJobOptions(maxPlates: 1)); + + [Fact] + public void TabbedHole_IsNoCutoutForNesting_AndItsSlugCountsAsMaterial() + { + var geometry = JobPartGeometry.Read(DrawingJobMapper.FromDrawing("frame", new Drawing("frame", Frame(true)), 1).Geometry); + + Assert.Empty(geometry.Cutouts); + Assert.Equal(400, geometry.MaterialArea, 6); + Assert.Single(JobPartGeometry.Read( + DrawingJobMapper.FromDrawing("frame", new Drawing("frame", Frame(false)), 1).Geometry).Cutouts); + } + + [Fact] + public void TabbedHole_KeepsItsHoleForAreaAndCutting() + { + var drawing = new Drawing("frame", Frame(true)); + + Assert.Equal(300, drawing.Area, 6); + // Nesting sees only the frame's outline: the hole's pieces and tabs are gone. + var nesting = ConvertProgram.ToGeometry(drawing.Program).ForNesting(); + Assert.Equal(4, nesting.Count); + Assert.Equal(400, new ShapeProfile(nesting).Perimeter.Area(), 6); + } + + [Fact] + public void UntabbedEntities_PassThroughUnchanged() + { + var material = ConvertProgram.ToGeometry(Frame(false)); + + Assert.Equal(material.Where(e => SpecialLayers.IsMaterial(e.Layer)), material.ForNesting()); + } + + [Theory] + [InlineData("Irregular")] + [InlineData("Default")] + public void NoPartIsNestedInsideATabbedHole(string engine) + { + var plain = NestPipeline.Run(Request(engine, tabbed: false)); + Assert.Equal(NestJobStatus.Complete, plain.Status); + + var result = NestPipeline.Run(Request(engine, tabbed: true)); + + Assert.True(result.IsValid, string.Join("; ", result.Violations)); + Assert.Equal(NestJobStatus.Incomplete, result.Status); + Assert.DoesNotContain(result.Plates.SelectMany(p => p.Parts), p => p.BaseDrawing.Name == "insert"); + } +} diff --git a/OpenNest.Engine/BestFit/BestFitResult.cs b/OpenNest.Engine/BestFit/BestFitResult.cs index 5c118c14..a89840d7 100644 --- a/OpenNest.Engine/BestFit/BestFitResult.cs +++ b/OpenNest.Engine/BestFit/BestFitResult.cs @@ -101,7 +101,7 @@ namespace OpenNest.Engine.BestFit { var partEntities = ConvertProgram .ToGeometry(part.Program) - .Where(e => SpecialLayers.IsMaterial(e.Layer)) + .ForNesting() .ToList(); foreach (var entity in partEntities) diff --git a/OpenNest.Engine/BestFit/PolygonHelper.cs b/OpenNest.Engine/BestFit/PolygonHelper.cs index 8ccae21a..bcdfb957 100644 --- a/OpenNest.Engine/BestFit/PolygonHelper.cs +++ b/OpenNest.Engine/BestFit/PolygonHelper.cs @@ -14,7 +14,7 @@ namespace OpenNest.Engine.BestFit { var entities = ConvertProgram .ToGeometry(drawing.Program) - .Where(e => SpecialLayers.IsMaterial(e.Layer)) + .ForNesting() .ToList(); if (entities.Count == 0) diff --git a/OpenNest.Engine/Fill/Expander.cs b/OpenNest.Engine/Fill/Expander.cs index b426b517..8afa9fb1 100644 --- a/OpenNest.Engine/Fill/Expander.cs +++ b/OpenNest.Engine/Fill/Expander.cs @@ -662,7 +662,7 @@ namespace OpenNest.Engine.Fill var prepared = new RingSet(); var entities = ConvertProgram .ToGeometry(program) - .Where(e => SpecialLayers.IsMaterial(e.Layer)) + .ForNesting() .ToList(); if (entities.Count > 0) diff --git a/OpenNest.Engine/Jobs/JobPartGeometry.cs b/OpenNest.Engine/Jobs/JobPartGeometry.cs index b3a1101a..af1e18db 100644 --- a/OpenNest.Engine/Jobs/JobPartGeometry.cs +++ b/OpenNest.Engine/Jobs/JobPartGeometry.cs @@ -66,10 +66,8 @@ public sealed class JobPartGeometry || !double.IsFinite(m.CenterX) || !double.IsFinite(m.CenterY))) throw new ArgumentException("Geometry must contain finite motions.", nameof(geometry)); var entities = ConvertProgram.ToGeometry(DrawingJobMapper.ToProgram(geometry)); - var cutEntities = new List(); - foreach (var entity in entities) - if (SpecialLayers.IsMaterial(entity.Layer)) - cutEntities.Add(entity); + // A tabbed cutout's slug stays attached, so its hole is no room for other parts. + var cutEntities = entities.ForNesting(); var contours = ShapeBuilder.GetShapes(cutEntities); if (contours.Count == 0) diff --git a/OpenNest.Engine/Jobs/NestLayoutCheck.cs b/OpenNest.Engine/Jobs/NestLayoutCheck.cs index aa08d624..fbbbedd4 100644 --- a/OpenNest.Engine/Jobs/NestLayoutCheck.cs +++ b/OpenNest.Engine/Jobs/NestLayoutCheck.cs @@ -577,7 +577,7 @@ public static class NestLayoutCheck { var entities = ConvertProgram .ToGeometry(part.Program) - .Where(e => SpecialLayers.IsMaterial(e.Layer)) + .ForNesting() .ToList(); if (entities.Count == 0) diff --git a/OpenNest.Gpu/PartBitmap.cs b/OpenNest.Gpu/PartBitmap.cs index 5ad06612..0c750f08 100644 --- a/OpenNest.Gpu/PartBitmap.cs +++ b/OpenNest.Gpu/PartBitmap.cs @@ -56,7 +56,7 @@ namespace OpenNest.Gpu { var entities = ConvertProgram .ToGeometry(part.Program) - .Where(e => SpecialLayers.IsMaterial(e.Layer)); + .ForNesting(); var shapes = ShapeBuilder.GetShapes(entities); var polygons = new List(); @@ -167,7 +167,7 @@ namespace OpenNest.Gpu { var entities = ConvertProgram .ToGeometry(drawing.Program) - .Where(e => SpecialLayers.IsMaterial(e.Layer)); + .ForNesting(); var shapes = ShapeBuilder.GetShapes(entities); var polygons = new List(); diff --git a/docs/tabbed-cutouts.md b/docs/tabbed-cutouts.md index 7a4bd217..e7f4de50 100644 --- a/docs/tabbed-cutouts.md +++ b/docs/tabbed-cutouts.md @@ -29,9 +29,17 @@ the cut pieces and tabs, and marks where each piece is pierced. ## Tabs are material, never cut Tab spans bound material like `Cut` motions: `SpecialLayers.IsMaterial` keeps them, so -area, nesting, material capture (`LeadMaterialSnapshot`) and the material-overlap check -all see a closed hole. They are never cut. The plate view fills them but does not draw -them as cuts. +area, material capture (`LeadMaterialSnapshot`) and the material-overlap check all see a +closed hole. They are never cut. The plate view fills them but does not draw them as cuts. + +Nesting is the exception: the slug stays attached while the sheet is cut, so nothing may +be placed in the hole. Nesting and layout paths read material through +`NestingMaterial.ForNesting`, which drops any closed contour with tab spans so the slug +counts as part: part outlines and collision lines (`PartGeometry`, `Part`), the cutout +router (`JobPartGeometry`, whose material area then includes the slug), fill expansion, +best-fit, `NestLayoutCheck` and the GPU bitmaps. Programs without tab spans read exactly as +before. A planned program has open pieces instead of a closed hole, so it bounds no hole +either way. Both Plan Cutting (`PreparedContours`) and Assign Lead-ins (`ContourCuttingStrategy.Apply`/`ApplySingle`) take a closed contour with tab spans out of