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 <noreply@anthropic.com>
This commit is contained in:
ajandClaude Opus 5.5 committed 2026-10-18 01:14:22 -04:00
1 parent ba39c3dc55
commit 0186ccfbff
12 files changed
+139 -21

No files matched your search

+1 -1
View File
@@ -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)
+1 -1
View File
@@ -352,7 +352,7 @@ namespace OpenNest
PerfCounters.CountOverlapPolygonPreparation();
return ConvertProgram
.ToGeometry(program)
.Where(e => SpecialLayers.IsMaterial(e.Layer))
.ForNesting()
.ToList();
}
+6 -6
View File
@@ -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<Line>();
@@ -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<Entity>();
@@ -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<Line>();
@@ -0,0 +1,29 @@
using System.Collections.Generic;
using System.Linq;
using OpenNest.Geometry;
namespace OpenNest
{
public static class NestingMaterial
{
/// <summary>
/// The material entities nesting and layout validation place parts against: those
/// <see cref="SpecialLayers.IsMaterial"/> keeps, except that a tabbed cutout's slug stays
/// attached while the sheet is cut, so a closed hole with <see cref="SpecialLayers.Tab"/>
/// spans is dropped and its slug counts as part. Area, overlap diagnostics and cutting
/// keep the hole. Entities without tab spans pass through unchanged.
/// </summary>
public static List<Entity> ForNesting(this IEnumerable<Entity> 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<Entity>();
foreach (var shape in ShapeBuilder.GetShapes(material))
if (!shape.Entities.Exists(e => e.Layer == SpecialLayers.Tab))
kept.AddRange(shape.Entities);
return kept;
}
}
}
@@ -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;
/// <summary>
/// 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.
/// </summary>
[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<Entity>
{
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");
}
}
+1 -1
View File
@@ -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)
+1 -1
View File
@@ -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)
+1 -1
View File
@@ -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)
+2 -4
View File
@@ -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<Entity>();
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)
+1 -1
View File
@@ -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)
+2 -2
View File
@@ -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<Polygon>();
@@ -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<Polygon>();
+11 -3
View File
@@ -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