From c60694b947ff02d2a38bd9cf17c36f4652427a55 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Wed, 4 Nov 2026 09:38:30 -0500 Subject: [PATCH] fix(reporting): label groups of small parts nested in a larger part's notch Group outlines and their shared ID were rejected whenever they touched another part's bounding box. A large irregular part's box can cover most of the sheet, so small repeated parts sitting in its notch or cutout (e.g. test squares) were left with no ID at all. Test clearance against real contours instead, as individual labels already do. Co-Authored-By: Claude Opus 5.5 --- OpenNest.Reporting/NestReportDiagram.cs | 31 +++++++++++++------ .../Reporting/NestPdfLayoutTests.cs | 28 +++++++++++++++++ docs/nest-reports.md | 3 +- 3 files changed, 51 insertions(+), 11 deletions(-) diff --git a/OpenNest.Reporting/NestReportDiagram.cs b/OpenNest.Reporting/NestReportDiagram.cs index 997935b4..40a9143d 100644 --- a/OpenNest.Reporting/NestReportDiagram.cs +++ b/OpenNest.Reporting/NestReportDiagram.cs @@ -46,15 +46,20 @@ internal static class NestReportDiagram var view = new XRect(0, 0, width, height); var fit = Fit.Create(union, new XRect(Inset, Inset, width - 2 * Inset, height - 2 * Inset)); var labels = PlaceLabels(plate, shapes, obstacles, all, fit, view, font, out var failed); - var groups = PlaceGroups(plate, failed, labels, fit, view, font); + var groups = PlaceGroups(plate, obstacles, failed, labels, fit, view, font); return new DiagramView(canvas, fit, labels, groups); } - private static List PlaceGroups(ReportPlate plate, List failed, + /// + /// Groups unlabeled same-ID neighbors. Clearance against other parts and cutoffs is tested on + /// their real contours, not their bounding boxes: a cluster nested in a large part's hole or + /// concave notch lies inside that part's bounding box but touches none of its material. + /// + private static List PlaceGroups(ReportPlate plate, List obstacles, List failed, List labels, Fit fit, XRect view, XFont font) { + // Obstacles are the parts, in plate order, followed by the cutoffs. var boxes = plate.Parts.Select(part => fit.Rect(part.Geometry.Bounds)).ToList(); - var cutoffs = plate.Cutoffs.Select(cutoff => fit.Rect(cutoff.Bounds)).ToList(); var groups = new List(); // A small screen-space gap also accommodates tightly packed parts with unset spacing. var gap = System.Math.Max(4, plate.PartSpacing * fit.Scale * 2); @@ -89,8 +94,7 @@ internal static class NestReportDiagram var memberSet = members.ToHashSet(); // A rectangular group must not imply that unrelated or ungrouped parts belong to it. if (!view.Contains(boundary) - || boxes.Where((_, index) => !memberSet.Contains(index)).Any(box => box.IntersectsWith(boundary)) - || cutoffs.Any(box => box.IntersectsWith(boundary)) + || obstacles.Where((_, index) => !memberSet.Contains(index)).Any(shape => shape.Crosses(ModelBox(fit, boundary))) || labels.Any(label => label.Box.IntersectsWith(boundary)) || groups.Any(group => group.Boundary.IntersectsWith(boundary) || group.Label.Box.IntersectsWith(boundary))) continue; @@ -112,8 +116,7 @@ internal static class NestReportDiagram { var padded = box; padded.Inflate(Clearance, Clearance); - if (!view.Contains(padded) || boxes.Any(other => other.IntersectsWith(padded)) - || cutoffs.Any(other => other.IntersectsWith(padded)) + if (!view.Contains(padded) || obstacles.Any(shape => shape.Crosses(ModelBox(fit, padded))) || labels.Any(label => label.Box.IntersectsWith(padded)) || groups.Any(group => group.Boundary.IntersectsWith(padded) || group.Label.Box.IntersectsWith(padded))) continue; @@ -143,9 +146,9 @@ internal static class NestReportDiagram var height = size.Height + 2 * LabelPadding; var pole = fit.Point(shapes[index].Pole.X, shapes[index].Pole.Y); var box = new XRect(pole.X - width / 2, pole.Y - height / 2, width, height); - var topLeft = fit.Model(new XPoint(box.X - Clearance, box.Y - Clearance)); - var bottomRight = fit.Model(new XPoint(box.Right + Clearance, box.Bottom + Clearance)); - var test = new Box(topLeft.X, bottomRight.Y, bottomRight.X - topLeft.X, topLeft.Y - bottomRight.Y); + var padded = box; + padded.Inflate(Clearance, Clearance); + var test = ModelBox(fit, padded); if (view.Contains(box) && !placed.Any(label => label.Box.IntersectsWith(box)) && !obstacles.Any(shape => shape.Crosses(test))) placed.Add(new DiagramLabel(index, id, box)); @@ -155,6 +158,14 @@ internal static class NestReportDiagram return placed; } + /// The model-space rectangle under a canvas rectangle. + private static Box ModelBox(Fit fit, XRect rect) + { + var topLeft = fit.Model(new XPoint(rect.Left, rect.Top)); + var bottomRight = fit.Model(new XPoint(rect.Right, rect.Bottom)); + return new Box(topLeft.X, bottomRight.Y, bottomRight.X - topLeft.X, topLeft.Y - bottomRight.Y); + } + private static ReportBounds Union(ReportPlate plate, IEnumerable shapes) { var left = plate.Bounds.Left; diff --git a/OpenNest.Tests/Reporting/NestPdfLayoutTests.cs b/OpenNest.Tests/Reporting/NestPdfLayoutTests.cs index 2631f987..bc35aa19 100644 --- a/OpenNest.Tests/Reporting/NestPdfLayoutTests.cs +++ b/OpenNest.Tests/Reporting/NestPdfLayoutTests.cs @@ -209,6 +209,34 @@ public sealed class NestPdfLayoutTests : IDisposable Assert.Equal(2, plan.Labels.Count); } + [Fact] + public void Plan_GroupNestedInsideALargerPartsCutoutIsStillLabeled() + { + // A frame whose cutout holds the washer cluster: its bounding box covers the group, but + // its material does not, so the group outline and ID are still clear. + var nest = NestReportTestData.CreateDenseNest(); + var frame = new Program(); + frame.Codes.Add(new RapidMove(0, 0)); + frame.Codes.Add(new LinearMove(0, 30)); + frame.Codes.Add(new LinearMove(40, 30)); + frame.Codes.Add(new LinearMove(40, 0)); + frame.Codes.Add(new LinearMove(0, 0)); + frame.Codes.Add(new RapidMove(5, 5)); + frame.Codes.Add(new LinearMove(35, 5)); + frame.Codes.Add(new LinearMove(35, 25)); + frame.Codes.Add(new LinearMove(5, 25)); + frame.Codes.Add(new LinearMove(5, 5)); + nest.Plates[0].Parts.Add(new Part(new Drawing("Frame", frame), new Vector(50, 20))); + var snapshot = NestReportBuilder.Capture(nest, NestReportTestData.GeneratedAt); + var plan = NestReportDiagram.Plan(snapshot.Plates[0], 720, 320); + var group = Assert.Single(plan.Groups); + Assert.Equal("2", group.Label.Id); + Assert.Equal(24, group.Parts.Count); + var frameBox = plan.Fit.Rect(snapshot.Plates[0].Parts[^1].Geometry.Bounds); + Assert.True(frameBox.Contains(group.Boundary)); + Assert.True(frameBox.Contains(group.Label.Box)); + } + [Fact] public void Plan_SeparatedClustersOfTheSamePartGetSeparateGroups() { diff --git a/docs/nest-reports.md b/docs/nest-reports.md index 8ba9c561..f0942490 100644 --- a/docs/nest-reports.md +++ b/docs/nest-reports.md @@ -70,7 +70,8 @@ quantity tables even when its diagram label cannot fit. Nearby small parts with the same report ID may share a dotted rectangular outline and one ID beside it. Separate clusters get separate outlines. A group is omitted if its rectangle includes any other part or cutoff, or if there is no clear space -for its label. Dotted group outlines are distinct from dashed scrap cutoffs. +for its label. Clearance is tested against real contours, not bounding boxes, so a +cluster nested in a larger part's cutout or notch is still grouped and labeled. Dotted group outlines are distinct from dashed scrap cutoffs. The writer still rejects, with `NotSupportedException` and before touching the destination: