diff --git a/OpenNest.Reporting/NestPdfWriter.cs b/OpenNest.Reporting/NestPdfWriter.cs index 80ac6892..9776b265 100644 --- a/OpenNest.Reporting/NestPdfWriter.cs +++ b/OpenNest.Reporting/NestPdfWriter.cs @@ -64,7 +64,7 @@ public static class NestPdfWriter ?? throw new InvalidOperationException("MigraDoc normal style is unavailable."); normal.Font.Name = ReportFonts.Family; normal.Font.Size = BodyFontSize; - var areas = new List<(Table Table, Action Draw)>(); + var areas = new List<(Table Table, Action[] Layers)>(); AddSummary(document, snapshot); for (var index = 0; index < snapshot.Plates.Length; index++) AddPlate(document, snapshot, snapshot.Plates[index], plans[index], areas); @@ -74,14 +74,19 @@ public static class NestPdfWriter using var pdf = renderer.PdfDocument; // PDFsharp 6.2.4 disallows reading the page count after Save. var pageCount = pdf.PageCount; - foreach (var (page, area, draw) in LocateAreas(renderer, pageCount, areas)) + foreach (var (page, area, layers) in LocateAreas(renderer, pageCount, areas)) { - using var gfx = XGraphics.FromPdfPage(pdf.Pages[page - 1]); - var state = gfx.Save(); - gfx.TranslateTransform(area.X, area.Y); - gfx.IntersectClip(new XRect(0, 0, area.Width, area.Height)); - draw(gfx, area); - gfx.Restore(state); + // Each layer is its own content stream, so the diagram's stream holds only the sheet + // and what is on it, never the rulers around it. + foreach (var draw in layers) + { + using var gfx = XGraphics.FromPdfPage(pdf.Pages[page - 1]); + var state = gfx.Save(); + gfx.TranslateTransform(area.X, area.Y); + gfx.IntersectClip(new XRect(0, 0, area.Width, area.Height)); + draw(gfx, area); + gfx.Restore(state); + } } pdf.Info.Title = snapshot.Name; @@ -116,7 +121,7 @@ public static class NestPdfWriter left.Add(("Customer:", snapshot.Customer)); left.Add(("Material:", Material(snapshot))); left.Add(("Thickness:", Thickness(snapshot))); - left.Add(("Units:", snapshot.Units == "mm" ? "millimeters (mm)" : "inches (in)")); + left.Add(("Units:", UnitsName(snapshot))); var right = new List<(string Key, string Value)> { ("Generated:", snapshot.GeneratedAt.ToString("yyyy-MM-dd HH:mm zzz", Invariant)), @@ -322,7 +327,7 @@ public static class NestPdfWriter }; private static void AddPlate(Document document, NestReportSnapshot snapshot, ReportPlate plate, - DiagramView plan, List<(Table, Action)> areas) + DiagramView plan, List<(Table, Action[])> areas) { var heading = $"Plate {plate.Number} of {snapshot.Plates.Length}"; var header = new List { $"Nest report: {snapshot.Name}", heading, SheetSize(snapshot, plate) }; @@ -349,11 +354,20 @@ public static class NestPdfWriter Duration(cutTime * plate.Copies) + (plate.Copies == 1 ? " (1 plate)" : $" ({plate.Copies} plates)")); var overview = AddReservedArea(section, plan.Size.Height); - areas.Add((overview, (gfx, _) => DrawView(gfx, plate, plan))); + areas.Add((overview, [(gfx, _) => DrawRulers(gfx, plate, plan, snapshot.Units), (gfx, _) => DrawView(gfx, plate, plan)])); var legend = section.AddParagraph("Filled outlines are closed parts; open material paths such as tab gaps are shown without fill. Dashed lines are scrap cutoffs."); legend.Format.Font.Size = 7.5; legend.Format.SpaceBefore = Unit.FromPoint(4); + if (plate.Clamps.Length > 0) + { + var clamps = section.AddParagraph(plate.Clamps.Any(clamp => clamp.Removed) + ? "Hatched rectangles are machine clamps. A dashed rectangle crossed out is a clamp to remove before cutting." + : "Hatched rectangles are machine clamps."); + clamps.Format.Font.Size = 7.5; + } + var rulers = section.AddParagraph($"Rulers show sheet coordinates in {UnitsName(snapshot)}."); + rulers.Format.Font.Size = 7.5; var labelsNote = section.AddParagraph("Part IDs are shown where space permits. A dotted outline groups like parts under one ID."); labelsNote.Format.Font.Size = 7.5; var approval = section.AddParagraph("Verify geometry and CNC output separately before cutting."); @@ -387,8 +401,8 @@ public static class NestPdfWriter return table; } - private static IEnumerable<(int Page, XRect Area, Action Draw)> LocateAreas( - PdfDocumentRenderer renderer, int pageCount, List<(Table Table, Action Draw)> areas) + private static IEnumerable<(int Page, XRect Area, Action[] Layers)> LocateAreas( + PdfDocumentRenderer renderer, int pageCount, List<(Table Table, Action[] Layers)> areas) { var found = new (int Page, XRect Area)?[areas.Count]; for (var page = 1; page <= pageCount; page++) @@ -416,7 +430,7 @@ public static class NestPdfWriter { var (page, area) = found[index] ?? throw new InvalidOperationException($"Report drawing area {index + 1} was not laid out."); - yield return (page, area, areas[index].Draw); + yield return (page, area, areas[index].Layers); } } @@ -424,9 +438,22 @@ public static class NestPdfWriter { var fit = view.Fit; gfx.DrawRectangle(new XPen(XColors.Black, 1), fit.Rect(plate.Bounds)); + // Active clamps lie under the parts; a removed clamp's area may hold parts, so its + // cross goes on top where the operator sees it. + foreach (var clamp in plate.Clamps.Where(clamp => !clamp.Removed)) + DrawClamp(gfx, fit.Rect(clamp.Bounds)); var outline = new XPen(XColors.Black, 0.6); foreach (var part in plate.Parts) DrawGeometry(gfx, part.Geometry, fit, outline); + var removed = new XPen(XColors.Black, 0.6) { DashPattern = [5, 3] }; + var cross = new XPen(XColors.Black, 0.4); + foreach (var clamp in plate.Clamps.Where(clamp => clamp.Removed)) + { + var box = fit.Rect(clamp.Bounds); + gfx.DrawRectangle(removed, box); + gfx.DrawLine(cross, box.TopLeft, box.BottomRight); + gfx.DrawLine(cross, box.TopRight, box.BottomLeft); + } // PDFsharp multiplies dash patterns by the pen width; the preset styles are so short at // these thin widths that they print as solid lines. var cutoff = new XPen(XColors.Black, 0.75) { DashPattern = [9, 3] }; @@ -443,6 +470,71 @@ public static class NestPdfWriter } } + /// A machine clamp: white, hatched with diagonal lines, outlined. + private static void DrawClamp(XGraphics gfx, XRect box) + { + const double spacing = 2.5; + gfx.DrawRectangle(XBrushes.White, box); + var state = gfx.Save(); + gfx.IntersectClip(box); + var hatch = new XPen(XColors.Black, 0.35); + for (var offset = -box.Height; offset < box.Width; offset += spacing) + gfx.DrawLine(hatch, box.X + offset, box.Bottom, box.X + offset + box.Height, box.Y); + gfx.Restore(state); + gfx.DrawRectangle(new XPen(XColors.Black, 0.75), box); + } + + /// + /// A horizontal ruler under the drawing and a vertical one left of it, each spanning the + /// sheet and reading its plate coordinates, with the units in the corner between them. + /// + private static void DrawRulers(XGraphics gfx, ReportPlate plate, DiagramView view, string units) + { + var fit = view.Fit; + var rulers = view.Rulers; + var sheet = plate.Bounds; + var pen = new XPen(XColors.Black, 0.5); + var font = NestReportDiagram.RulerFont(); + var textHeight = font.GetHeight(); + var low = fit.Point(sheet.Left, sheet.Top); + var high = fit.Point(sheet.Right, sheet.Bottom); + const double textBox = 100; + + var baseY = rulers.Horizontal.Top; + var textY = baseY + NestReportDiagram.MajorTick + NestReportDiagram.RulerTextGap; + gfx.DrawLine(pen, low.X, baseY, high.X, baseY); + foreach (var value in rulers.Minors(sheet.Left, sheet.Right)) + { + var x = fit.Point(value, 0).X; + gfx.DrawLine(pen, x, baseY, x, baseY + NestReportDiagram.MinorTick); + } + foreach (var value in rulers.Majors(sheet.Left, sheet.Right)) + { + var x = fit.Point(value, 0).X; + gfx.DrawLine(pen, x, baseY, x, baseY + NestReportDiagram.MajorTick); + gfx.DrawString(rulers.Format(value), font, XBrushes.Black, + new XRect(x - textBox / 2, textY, textBox, textHeight), XStringFormats.TopCenter); + } + + var baseX = rulers.Vertical.Right; + var textRight = baseX - NestReportDiagram.MajorTick - NestReportDiagram.RulerTextGap; + gfx.DrawLine(pen, baseX, low.Y, baseX, high.Y); + foreach (var value in rulers.Minors(sheet.Bottom, sheet.Top)) + { + var y = fit.Point(0, value).Y; + gfx.DrawLine(pen, baseX, y, baseX - NestReportDiagram.MinorTick, y); + } + foreach (var value in rulers.Majors(sheet.Bottom, sheet.Top)) + { + var y = fit.Point(0, value).Y; + gfx.DrawLine(pen, baseX, y, baseX - NestReportDiagram.MajorTick, y); + gfx.DrawString(rulers.Format(value), font, XBrushes.Black, + new XRect(textRight - textBox, y - textHeight / 2, textBox, textHeight), XStringFormats.CenterRight); + } + gfx.DrawString(units, font, XBrushes.Black, new XRect(textRight - textBox, textY, textBox, textHeight), + XStringFormats.TopRight); + } + private static string Thumbnail(ReportGeometry geometry, double width, double height) { using var document = new PdfDocument(); @@ -623,6 +715,9 @@ public static class NestPdfWriter } } + private static string UnitsName(NestReportSnapshot snapshot) => + snapshot.Units == "mm" ? "millimeters (mm)" : "inches (in)"; + private static string Material(NestReportSnapshot snapshot) { var material = $"{snapshot.Material} {snapshot.Grade}".Trim(); diff --git a/OpenNest.Reporting/NestReportBuilder.cs b/OpenNest.Reporting/NestReportBuilder.cs index 1bbe362c..e9f34a1a 100644 --- a/OpenNest.Reporting/NestReportBuilder.cs +++ b/OpenNest.Reporting/NestReportBuilder.cs @@ -105,9 +105,42 @@ public static class NestReportBuilder { CutTime = cutParameters == null ? null : EstimateCutTime(plate, cutParameters, number), Weight = Weight(plate.Area(), nest, SheetDensity(nest, catalogDensity)), + Clamps = CaptureClamps(plate, number), }; } + /// + /// The sheet's clamp zones, then the rail clamps taken off it, both moved into the parts' + /// placement frame as does. Footprints are kept whole. + /// + private static ImmutableArray CaptureClamps(Plate plate, int number) + { + var clamps = ImmutableArray.CreateBuilder(); + try + { + foreach (var zone in ClampZoneFrame.ToPlacement(plate)) + clamps.Add(new ReportClamp(zone.Id, ToBounds(zone.Bounds), false)); + } + catch (ArgumentException exception) + { + throw new InvalidOperationException($"Plate {number}: {exception.Message}", exception); + } + + if (plate.ClampRail != null && plate.RemovedClamps.Count > 0) + { + var left = plate.Quadrant is 2 or 3 ? -plate.Size.Length : 0; + var bottom = plate.Quadrant is 3 or 4 ? -plate.Size.Width : 0; + foreach (var zone in plate.ClampRail.ResolveFor(plate.Size, plate.Quadrant)) + { + if (plate.RemovedClamps.Contains(zone.Id)) + clamps.Add(new ReportClamp(zone.Id, ToBounds(zone.Bounds.Translate(left, bottom)), true)); + } + } + return clamps.ToImmutable(); + } + + private static ReportBounds ToBounds(Box box) => new(box.Left, box.Bottom, box.Right, box.Top); + private static void ValidatePlate(Plate plate, int number) { if (plate == null) diff --git a/OpenNest.Reporting/NestReportDiagram.cs b/OpenNest.Reporting/NestReportDiagram.cs index 0a07fdc3..b0843d93 100644 --- a/OpenNest.Reporting/NestReportDiagram.cs +++ b/OpenNest.Reporting/NestReportDiagram.cs @@ -1,3 +1,4 @@ +using System.Collections.Immutable; using OpenNest.Geometry; using PdfSharp.Drawing; @@ -10,7 +11,46 @@ internal sealed record DiagramLabel(int Part, string Id, XRect Box); internal sealed record DiagramGroup(IReadOnlyList Parts, XRect Boundary, DiagramLabel Label); internal sealed record DiagramView(XSize Size, Fit Fit, IReadOnlyList Labels, - IReadOnlyList Groups); + IReadOnlyList Groups, DiagramRulers Rulers); + +/// +/// Sheet rulers in model units: a horizontal ruler under the drawing and a vertical one to its +/// left, each reading plate coordinates across the sheet. and +/// are the canvas bands they occupy, which labels keep clear of. +/// +internal sealed record DiagramRulers(double Step, int Divisions, int Decimals, XRect Horizontal, XRect Vertical) +{ + /// Major tick values: multiples of within [, ]. + public IEnumerable Majors(double from, double to) => Ticks(from, to, Step); + + /// Minor tick values between the majors. + public IEnumerable Minors(double from, double to) => + Ticks(from, to, Step / Divisions).Where(value => !IsMajor(value)); + + public string Format(double value) + { + var text = value.ToString("F" + Decimals, System.Globalization.CultureInfo.InvariantCulture); + // Roundoff can leave a negative zero ("-0" or "-0.0"); print it as zero. + return text[0] == '-' && text.AsSpan(1).Trim("0.").IsEmpty ? text[1..] : text; + } + + private bool IsMajor(double value) + { + var ratio = value / Step; + return System.Math.Abs(ratio - System.Math.Round(ratio)) < 1e-6; + } + + private static IEnumerable Ticks(double from, double to, double step) + { + // Integer multiples avoid accumulated roundoff; a sliver of tolerance keeps the sheet's + // own edges (0 and its size) when they are multiples. + var slack = step * 1e-6; + var first = System.Math.Ceiling((from - slack) / step); + var last = System.Math.Floor((to + slack) / step); + for (var index = first; index <= last; index++) + yield return index * step; + } +} /// Plans best-effort labels on a single plate view; small repeated parts may share a label. internal static class NestReportDiagram @@ -21,12 +61,29 @@ internal static class NestReportDiagram private const double Clearance = 1.5; private const double Inset = 5; + internal const double RulerFontSize = 6; + // Canvas points between the drawing and a ruler's baseline, tick lengths, and the space + // between a major tick and its number. + internal const double RulerGap = 3; + internal const double MajorTick = 5; + internal const double MinorTick = 2.5; + internal const double RulerTextGap = 1.5; + // Numbered ticks at least this far apart, minor ticks no closer than the second. + private const double MinMajorSpacing = 40; + private const double MinMinorSpacing = 4; + internal static XFont LabelFont() { ReportFonts.Initialize(); return new(ReportFonts.Family, LabelFontSize, XFontStyleEx.Bold); } + internal static XFont RulerFont() + { + ReportFonts.Initialize(); + return new(ReportFonts.Family, RulerFontSize, XFontStyleEx.Regular); + } + /// Chord tolerance for placement-only flattening, relative to the part size. internal static double ChordTolerance(ReportBounds bounds) => System.Math.Max(System.Math.Max(bounds.Length, bounds.Width) * 0.0005, 1e-9); @@ -38,16 +95,95 @@ internal static class NestReportDiagram internal static DiagramView Plan(ReportPlate plate, double width, double height) { var shapes = plate.Parts.Select(part => new Shape(part.Geometry)).ToList(); - var obstacles = shapes.Concat(plate.Cutoffs.Select(cutoff => new Shape(cutoff))).ToList(); - var union = Union(plate, obstacles); + // Active clamps are fixtures labels must not cover; a removed clamp's area may hold parts. + var obstacles = shapes.Concat(plate.Cutoffs.Select(cutoff => new Shape(cutoff))) + .Concat(plate.Clamps.Where(clamp => !clamp.Removed).Select(clamp => new Shape(Rectangle(clamp.Bounds)))) + .ToList(); + // Whole clamp footprints can hang past the sheet; keep them on the page. + var union = Union(plate.Bounds, obstacles.Select(shape => shape.Bounds) + .Concat(plate.Clamps.Select(clamp => clamp.Bounds))); + var (fit, rulers) = FitWithRulers(plate.Bounds, union, width, height); var font = LabelFont(); var all = Enumerable.Range(0, plate.Parts.Length).ToList(); - var canvas = new XSize(width, height); - 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, obstacles, failed, labels, fit, view, font); - return new DiagramView(canvas, fit, labels, groups); + var canvas = new Canvas(new XRect(0, 0, width, height), [rulers.Horizontal, rulers.Vertical]); + var labels = PlaceLabels(plate, shapes, obstacles, all, fit, canvas, font, out var failed); + var groups = PlaceGroups(plate, obstacles, failed, labels, fit, canvas, font); + return new DiagramView(new XSize(width, height), fit, labels, groups, rulers); + } + + /// + /// Fits the drawing beside a vertical ruler on its left and a horizontal one below it. A + /// first fit with provisional bands picks the tick step; the second sizes the bands to that + /// step's widest numbers. Each ruler hugs the drawing and spans the sheet. + /// + private static (Fit Fit, DiagramRulers Rulers) FitWithRulers(ReportBounds sheet, ReportBounds union, + double width, double height) + { + var font = RulerFont(); + var textHeight = font.GetHeight(); + var below = RulerGap + MajorTick + RulerTextGap + textHeight; + XRect Area(double left, double right) => + new(Inset + left, Inset, width - 2 * Inset - left - right, height - 2 * Inset - below); + + var draft = Ruler(sheet, Fit.Create(union, Area(30, 10)).Scale, font); + double Widest(double from, double to) => draft.Majors(from, to) + .Select(value => ReportText.Size(draft.Format(value), font).Width).DefaultIfEmpty(0).Max(); + var left = RulerGap + MajorTick + RulerTextGap + Widest(sheet.Bottom, sheet.Top); + var overhang = Widest(sheet.Left, sheet.Right) / 2; + var fit = Fit.Create(union, Area(left, overhang)); + + var drawing = fit.Rect(union); + var low = fit.Point(sheet.Left, sheet.Top); + var high = fit.Point(sheet.Right, sheet.Bottom); + var horizontal = new XRect(low.X - overhang, drawing.Bottom + RulerGap, high.X - low.X + 2 * overhang, + below - RulerGap); + var vertical = new XRect(drawing.Left - left, low.Y - textHeight / 2, left - RulerGap, + high.Y - low.Y + textHeight); + return (fit, draft with { Horizontal = horizontal, Vertical = vertical }); + } + + /// + /// The smallest 1-2-5 step whose numbered ticks sit apart and + /// whose numbers do not touch, with the finest legible subdivision of it. + /// + private static DiagramRulers Ruler(ReportBounds sheet, double scale, XFont font) + { + var exponent = (int)System.Math.Floor(System.Math.Log10(MinMajorSpacing / scale)); + for (; ; exponent++) + { + foreach (var mantissa in new[] { 1, 2, 5 }) + { + var step = mantissa * System.Math.Pow(10, exponent); + var spacing = step * scale; + if (spacing < MinMajorSpacing) + continue; + var decimals = exponent >= 0 ? 0 : -exponent; + int[] divisions = mantissa switch { 1 => [10, 5, 2], 2 => [4, 2], _ => [5] }; + var ruler = new DiagramRulers(step, + divisions.FirstOrDefault(count => spacing / count >= MinMinorSpacing, 1), decimals, default, default); + var widest = ruler.Majors(sheet.Left, sheet.Right).Concat(ruler.Majors(sheet.Bottom, sheet.Top)) + .Select(value => ReportText.Size(ruler.Format(value), font).Width).DefaultIfEmpty(0).Max(); + if (widest + 2 * RulerTextGap <= spacing) + return ruler; + } + } + } + + /// A closed axis-aligned rectangle contour, for clamp footprints. + private static ReportGeometry Rectangle(ReportBounds bounds) + { + ReportPoint[] corners = [new(bounds.Left, bounds.Bottom), new(bounds.Right, bounds.Bottom), + new(bounds.Right, bounds.Top), new(bounds.Left, bounds.Top)]; + var segments = Enumerable.Range(0, 4) + .Select(index => new ReportSegment(corners[index], corners[(index + 1) % 4], null, 0, 0, 0)) + .ToImmutableArray(); + return new ReportGeometry([new ReportContour(segments, true)], bounds); + } + + /// The canvas labels may use: inside the view and clear of the ruler bands. + private sealed record Canvas(XRect View, IReadOnlyList Reserved) + { + public bool Holds(XRect rect) => View.Contains(rect) && !Reserved.Any(band => band.IntersectsWith(rect)); } /// @@ -56,7 +192,7 @@ internal static class NestReportDiagram /// 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) + List labels, Fit fit, Canvas canvas, 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(); @@ -97,7 +233,7 @@ internal static class NestReportDiagram boundary.Inflate(margin, margin); var memberSet = members.ToHashSet(); // A rectangular group must not imply that unrelated or ungrouped parts belong to it. - if (!view.Contains(boundary) + if (!canvas.Holds(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))) @@ -119,7 +255,7 @@ internal static class NestReportDiagram new XRect(boundary.Right + Clearance, centerY, labelWidth, labelHeight), new XRect(boundary.X - labelWidth - Clearance, centerY, labelWidth, labelHeight), ]); - bool Blocked(XRect padded) => !view.Contains(padded) || OnSheetEdge(sheet, padded) + bool Blocked(XRect padded) => !canvas.Holds(padded) || OnSheetEdge(sheet, padded) || labels.Any(label => label.Box.IntersectsWith(padded)) || groups.Any(group => group.Boundary.IntersectsWith(padded) || group.Label.Box.IntersectsWith(padded)); var others = obstacles.Where((_, index) => !memberSet.Contains(index)).ToList(); @@ -210,7 +346,7 @@ internal static class NestReportDiagram /// remain unlabeled. /// private static List PlaceLabels(ReportPlate plate, List shapes, List obstacles, - List targets, Fit fit, XRect view, XFont font, out List failed) + List targets, Fit fit, Canvas canvas, XFont font, out List failed) { failed = []; var placed = new List(); @@ -225,7 +361,7 @@ internal static class NestReportDiagram var padded = box; padded.Inflate(Clearance, Clearance); var test = ModelBox(fit, padded); - if (view.Contains(box) && !placed.Any(label => label.Box.IntersectsWith(box)) + if (canvas.Holds(box) && !placed.Any(label => label.Box.IntersectsWith(box)) && !obstacles.Any(shape => shape.Crosses(test))) placed.Add(new DiagramLabel(index, id, box)); else @@ -242,7 +378,7 @@ internal static class NestReportDiagram var others = obstacles.Where((_, other) => other != index).ToList(); var spot = BestSpot(Grid(area, size.Width + 2 * LabelPadding, size.Height + 2 * LabelPadding) .Where(area.Contains), obstacles, others, fit, - padded => !view.Contains(padded) || OnSheetEdge(fit.Rect(plate.Bounds), padded) || placed.Any(label => label.Box.IntersectsWith(padded)), + padded => !canvas.Holds(padded) || OnSheetEdge(fit.Rect(plate.Bounds), padded) || placed.Any(label => label.Box.IntersectsWith(padded)), new XPoint(area.X + area.Width / 2, area.Y + area.Height / 2)); if (spot is not { } found) continue; @@ -260,18 +396,18 @@ internal static class NestReportDiagram return new Box(topLeft.X, bottomRight.Y, bottomRight.X - topLeft.X, topLeft.Y - bottomRight.Y); } - private static ReportBounds Union(ReportPlate plate, IEnumerable shapes) + private static ReportBounds Union(ReportBounds sheet, IEnumerable boxes) { - var left = plate.Bounds.Left; - var bottom = plate.Bounds.Bottom; - var right = plate.Bounds.Right; - var top = plate.Bounds.Top; - foreach (var shape in shapes) + var left = sheet.Left; + var bottom = sheet.Bottom; + var right = sheet.Right; + var top = sheet.Top; + foreach (var box in boxes) { - left = System.Math.Min(left, shape.Bounds.Left); - bottom = System.Math.Min(bottom, shape.Bounds.Bottom); - right = System.Math.Max(right, shape.Bounds.Right); - top = System.Math.Max(top, shape.Bounds.Top); + left = System.Math.Min(left, box.Left); + bottom = System.Math.Min(bottom, box.Bottom); + right = System.Math.Max(right, box.Right); + top = System.Math.Max(top, box.Top); } return new ReportBounds(left, bottom, right, top); } diff --git a/OpenNest.Reporting/NestReportSnapshot.cs b/OpenNest.Reporting/NestReportSnapshot.cs index bd432159..8ccb110d 100644 --- a/OpenNest.Reporting/NestReportSnapshot.cs +++ b/OpenNest.Reporting/NestReportSnapshot.cs @@ -33,6 +33,12 @@ public sealed record ReportDrawing(string Id, string Name, long Required, long N public sealed record ReportPlacement(string ReportId, ReportGeometry Geometry); +/// +/// A machine clamp footprint in the parts' placement frame. marks a +/// rail clamp taken off this sheet (by Auto Nest or by hand), which the operator pulls before cutting. +/// +public sealed record ReportClamp(string Id, ReportBounds Bounds, bool Removed); + public sealed record ReportPlate(int Number, ReportBounds Bounds, int Copies, double PartSpacing, double Utilization, ImmutableArray Parts, ImmutableArray Cutoffs) { @@ -44,6 +50,9 @@ public sealed record ReportPlate(int Number, ReportBounds Bounds, int Copies, do /// ; null when the thickness or density is unknown. /// public double? Weight { get; init; } + + /// Clamps on this sheet, active and removed; display only, never material. + public ImmutableArray Clamps { get; init; } = ImmutableArray.Empty; } /// A detached value snapshot. No domain objects or mutable geometry are retained. diff --git a/OpenNest.Tests/Reporting/NestReportClampsAndRulersTests.cs b/OpenNest.Tests/Reporting/NestReportClampsAndRulersTests.cs new file mode 100644 index 00000000..5a5944df --- /dev/null +++ b/OpenNest.Tests/Reporting/NestReportClampsAndRulersTests.cs @@ -0,0 +1,143 @@ +using System.Text.RegularExpressions; +using OpenNest.Geometry; +using OpenNest.Reporting; +using PdfSharp.Drawing; +using PdfSharp.Pdf.IO; + +namespace OpenNest.Tests.Reporting; + +/// Machine clamps and sheet rulers on the plate layout view. +public sealed class NestReportClampsAndRulersTests : IDisposable +{ + private readonly string directory = Path.Combine(Path.GetTempPath(), "opennest-report-clamps-" + Guid.NewGuid().ToString("N")); + + public NestReportClampsAndRulersTests() => Directory.CreateDirectory(directory); + + public void Dispose() => Directory.Delete(directory, true); + + /// The standard 48 x 24 test sheet with three 3 x 1.5 clamps on its origin-side edge. + private static Nest ClampedNest(bool removeMiddle) + { + var nest = NestReportTestData.CreateNest(); + var plate = nest.Plates[0]; + plate.SetClampRail(new ClampRail(3, 1.5, [6, 24, 44])); + if (removeMiddle) + Assert.True(plate.SetClampRemoved("clamp-2", true)); + return nest; + } + + [Fact] + public void Capture_ListsActiveClampsThenRemovedOnesInThePlacementFrame() + { + var snapshot = NestReportBuilder.Capture(ClampedNest(removeMiddle: true), NestReportTestData.GeneratedAt); + + Assert.Equal(new[] + { + new ReportClamp("clamp-1", new ReportBounds(4.5, 0, 7.5, 1.5), false), + new ReportClamp("clamp-3", new ReportBounds(42.5, 0, 45.5, 1.5), false), + new ReportClamp("clamp-2", new ReportBounds(22.5, 0, 25.5, 1.5), true), + }, snapshot.Plates[0].Clamps); + } + + [Fact] + public void Capture_MovesClampsWithTheSheetIntoItsQuadrant() + { + var nest = new Nest("Quadrant") { Units = Units.Inches }; + var drawing = NestReportTestData.Rectangle("Part", 2, 2); + nest.Drawings.Add(drawing); + var plate = new Plate(24, 48) { Quantity = 1, Quadrant = 3 }; + plate.Parts.Add(new Part(drawing, new Vector(-20, -20))); + plate.SetClampRail(new ClampRail(3, 1.5, [6])); + nest.Plates.Add(plate); + + var report = NestReportBuilder.Capture(nest, NestReportTestData.GeneratedAt).Plates[0]; + + // Quadrant 3 puts the origin at the sheet's upper-right corner: the clamp sits 6 in from + // it along the top edge, inside the sheet's (-48, -24)-(0, 0) bounds. + var clamp = Assert.Single(report.Clamps); + Assert.Equal(new ReportBounds(-7.5, -1.5, -4.5, 0), clamp.Bounds); + Assert.Equal(new ReportBounds(-48, -24, 0, 0), report.Bounds); + } + + [Fact] + public void Capture_PlateWithoutClampsHasNone() + { + var snapshot = NestReportBuilder.Capture(NestReportTestData.CreateNest(), NestReportTestData.GeneratedAt); + + Assert.Empty(snapshot.Plates[0].Clamps); + } + + [Fact] + public void Plan_RulersHugTheSheetWithLegibleTicksAndLabelsKeepClearOfThemAndTheClamps() + { + var plate = NestReportBuilder.Capture(ClampedNest(removeMiddle: false), NestReportTestData.GeneratedAt).Plates[0]; + var plan = NestReportDiagram.Plan(plate, 720, 320); + var rulers = plan.Rulers; + var sheet = plan.Fit.Rect(plate.Bounds); + var canvas = new XRect(0, 0, 720, 320); + + Assert.True(canvas.Contains(rulers.Horizontal), $"{rulers.Horizontal}"); + Assert.True(canvas.Contains(rulers.Vertical), $"{rulers.Vertical}"); + Assert.True(rulers.Horizontal.Top >= sheet.Bottom); + Assert.True(rulers.Vertical.Right <= sheet.Left); + Assert.True(rulers.Step * plan.Fit.Scale >= 40); + Assert.Equal(0, rulers.Majors(plate.Bounds.Left, plate.Bounds.Right).First()); + Assert.Equal(0, rulers.Majors(plate.Bounds.Bottom, plate.Bounds.Top).First()); + Assert.True(rulers.Step / rulers.Divisions * plan.Fit.Scale >= 4); + + var labels = plan.Labels.Concat(plan.Groups.Select(group => group.Label)).ToList(); + Assert.NotEmpty(labels); + Assert.DoesNotContain(labels, label => label.Box.IntersectsWith(rulers.Horizontal) + || label.Box.IntersectsWith(rulers.Vertical)); + foreach (var clamp in plate.Clamps) + Assert.DoesNotContain(labels, label => label.Box.IntersectsWith(plan.Fit.Rect(clamp.Bounds))); + } + + [Fact] + public void Ruler_TicksAreStepMultiplesAndNeverPrintNegativeZero() + { + var ruler = new DiagramRulers(0.5, 5, 1, default, default); + + Assert.Equal(new[] { -1, -0.5, 0, 0.5, 1 }, ruler.Majors(-1.2, 1.2)); + Assert.Equal(new[] { 0.1, 0.2, 0.3, 0.4 }, ruler.Minors(0, 0.5).Select(value => System.Math.Round(value, 9))); + Assert.Equal("0.0", ruler.Format(-0.01)); + Assert.Equal("-1.5", ruler.Format(-1.5)); + Assert.Equal("0", new DiagramRulers(10, 5, 0, default, default).Format(-0.2)); + } + + [SkippableFact] + public void Write_PlatePageShowsClampsRulersAndTheirLegend() + { + var nest = ClampedNest(removeMiddle: true); + var snapshot = NestReportBuilder.Capture(nest, NestReportTestData.GeneratedAt); + var plan = NestReportDiagram.Plan(snapshot.Plates[0], 720, 320); + var path = Path.Combine(directory, "clamps.report.pdf"); + NestPdfWriter.Write(snapshot, path); + + var page = ReportPdf.Pages(path)[1]; + Assert.Matches(@"Hatched rectangles are machine clamps\.\s+A dashed rectangle crossed out is a clamp to remove before cutting\.", page); + Assert.Contains("Rulers show sheet coordinates in inches (in).", page); + // The horizontal ruler's numbers share one row, the units in the corner before them. + var bounds = snapshot.Plates[0].Bounds; + var numbers = plan.Rulers.Majors(bounds.Left, bounds.Right).Select(plan.Rulers.Format); + Assert.Matches($"(?m)^in {string.Join(" ", numbers)}$", page); + foreach (var value in plan.Rulers.Majors(bounds.Bottom, bounds.Top).Skip(1)) + Assert.Matches($@"(?m)^{Regex.Escape(plan.Rulers.Format(value))}\b", page); + + using var pdf = PdfReader.Open(path, PdfDocumentOpenMode.Import); + var diagram = ReportPdf.ContentStreams(pdf.Pages[1]).Last(); + // The removed clamp's dash (5, 3 times its 0.6 pen) differs from the cutoff dash. + Assert.Matches(@"\[3 1\.8\]\s*0\s+d", diagram); + } + + [SkippableFact] + public void Write_PlateWithoutClampsOmitsTheClampLegend() + { + var path = Path.Combine(directory, "plain.report.pdf"); + NestPdfWriter.Write(NestReportBuilder.Capture(NestReportTestData.CreateNest(), NestReportTestData.GeneratedAt), path); + + var page = ReportPdf.Pages(path)[1]; + Assert.DoesNotContain("clamp", page, StringComparison.OrdinalIgnoreCase); + Assert.Contains("Rulers show sheet coordinates in inches (in).", page); + } +} diff --git a/docs/nest-reports.md b/docs/nest-reports.md index 3ab580f5..185cec18 100644 --- a/docs/nest-reports.md +++ b/docs/nest-reports.md @@ -64,6 +64,10 @@ the selected plate, or certify that a layout passed geometry/pre-post checks. - A plate's sheet weight is one full sheet (its outline area for an irregular sheet) times the nest thickness and density, from the nest material or the catalog only: a drawing's own material does not say what the stock is. +- A plate's clamps are its clamp zones moved into the parts' frame with + `ClampZoneFrame`, then any rail clamps taken off that sheet (`RemovedClamps`, + resolved by the rail for the sheet), flagged as removed. Footprints stay whole, + even where they hang past the sheet. ## Layout, pagination and dense-label fallback @@ -114,6 +118,21 @@ other part or cutoff, or if even that fallback finds no place 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. +Machine clamps appear on the plate diagram as white, diagonally hatched +rectangles drawn under the parts, and IDs keep clear of them like any other +obstacle. A clamp taken off the sheet is a dashed rectangle crossed out, drawn +over the parts (they may sit where it was) so the operator sees it must come off +before cutting. A legend line explains them only on plates that have clamps. + +Rulers sit beside the diagram: a horizontal one below it and a vertical one to its +left, each spanning the sheet and reading its plate coordinates (negative in +quadrants 2-4) in job units, with the unit in the corner between them. Numbered +ticks use the smallest 1-2-5 step at least 40 pt apart whose numbers do not touch, +subdivided as finely as stays legible. The diagram is fitted in two passes so the +ruler bands are sized to their numbers, and labels never enter those bands. The +rulers are drawn as their own content stream before the diagram's, so the +diagram's stream holds only the sheet and what is on it. + The writer still rejects, with `NotSupportedException` and before touching the destination: