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: