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