feat(reporting): show clamps and sheet rulers on the plate layout view

Operators reading a printed nest report could not see where the machine
clamps sit or which ones must be pulled before cutting, and had no way to
measure positions off the layout. Plate diagrams now draw active clamps as
hatched rectangles (labels keep clear of them) and removed rail clamps as
dashed, crossed-out rectangles over the parts, with a legend line only on
plates that have clamps. A horizontal ruler below and a vertical ruler left
of the drawing read the sheet's plate coordinates in job units; the
diagram is fitted in two passes so the ruler bands match their numbers,
and part labels never enter them. Rulers draw as their own content stream
so the diagram stream holds only the sheet and what is on it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ajandClaude Opus 5.5 committed 2026-11-10 08:02:24 -05:00
1 parent bffcd52db1
commit f4fb6e1f3d
6 files changed
+474 -39

No files matched your search

+109 -14
View File
@@ -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<XGraphics, XRect> Draw)>();
var areas = new List<(Table Table, Action<XGraphics, XRect>[] 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<XGraphics, XRect>)> areas)
DiagramView plan, List<(Table, Action<XGraphics, XRect>[])> areas)
{
var heading = $"Plate {plate.Number} of {snapshot.Plates.Length}";
var header = new List<string> { $"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<XGraphics, XRect> Draw)> LocateAreas(
PdfDocumentRenderer renderer, int pageCount, List<(Table Table, Action<XGraphics, XRect> Draw)> areas)
private static IEnumerable<(int Page, XRect Area, Action<XGraphics, XRect>[] Layers)> LocateAreas(
PdfDocumentRenderer renderer, int pageCount, List<(Table Table, Action<XGraphics, XRect>[] 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
}
}
/// <summary>A machine clamp: white, hatched with diagonal lines, outlined.</summary>
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);
}
/// <summary>
/// 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.
/// </summary>
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();
+33
View File
@@ -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),
};
}
/// <summary>
/// The sheet's clamp zones, then the rail clamps taken off it, both moved into the parts'
/// placement frame as <see cref="ClampZoneFrame"/> does. Footprints are kept whole.
/// </summary>
private static ImmutableArray<ReportClamp> CaptureClamps(Plate plate, int number)
{
var clamps = ImmutableArray.CreateBuilder<ReportClamp>();
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)
+161 -25
View File
@@ -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<int> Parts, XRect Boundary, DiagramLabel Label);
internal sealed record DiagramView(XSize Size, Fit Fit, IReadOnlyList<DiagramLabel> Labels,
IReadOnlyList<DiagramGroup> Groups);
IReadOnlyList<DiagramGroup> Groups, DiagramRulers Rulers);
/// <summary>
/// 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. <see cref="Horizontal"/> and
/// <see cref="Vertical"/> are the canvas bands they occupy, which labels keep clear of.
/// </summary>
internal sealed record DiagramRulers(double Step, int Divisions, int Decimals, XRect Horizontal, XRect Vertical)
{
/// <summary>Major tick values: multiples of <see cref="Step"/> within [<paramref name="from"/>, <paramref name="to"/>].</summary>
public IEnumerable<double> Majors(double from, double to) => Ticks(from, to, Step);
/// <summary>Minor tick values between the majors.</summary>
public IEnumerable<double> 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<double> 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;
}
}
/// <summary>Plans best-effort labels on a single plate view; small repeated parts may share a label.</summary>
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);
}
/// <summary>Chord tolerance for placement-only flattening, relative to the part size.</summary>
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);
}
/// <summary>
/// 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.
/// </summary>
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 });
}
/// <summary>
/// The smallest 1-2-5 step whose numbered ticks sit <see cref="MinMajorSpacing"/> apart and
/// whose numbers do not touch, with the finest legible subdivision of it.
/// </summary>
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;
}
}
}
/// <summary>A closed axis-aligned rectangle contour, for clamp footprints.</summary>
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);
}
/// <summary>The canvas labels may use: inside the view and clear of the ruler bands.</summary>
private sealed record Canvas(XRect View, IReadOnlyList<XRect> Reserved)
{
public bool Holds(XRect rect) => View.Contains(rect) && !Reserved.Any(band => band.IntersectsWith(rect));
}
/// <summary>
@@ -56,7 +192,7 @@ internal static class NestReportDiagram
/// concave notch lies inside that part's bounding box but touches none of its material.
/// </summary>
private static List<DiagramGroup> PlaceGroups(ReportPlate plate, List<Shape> obstacles, List<int> failed,
List<DiagramLabel> labels, Fit fit, XRect view, XFont font)
List<DiagramLabel> 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.
/// </summary>
private static List<DiagramLabel> PlaceLabels(ReportPlate plate, List<Shape> shapes, List<Shape> obstacles,
List<int> targets, Fit fit, XRect view, XFont font, out List<int> failed)
List<int> targets, Fit fit, Canvas canvas, XFont font, out List<int> failed)
{
failed = [];
var placed = new List<DiagramLabel>();
@@ -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<Shape> shapes)
private static ReportBounds Union(ReportBounds sheet, IEnumerable<ReportBounds> 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);
}
+9
View File
@@ -33,6 +33,12 @@ public sealed record ReportDrawing(string Id, string Name, long Required, long N
public sealed record ReportPlacement(string ReportId, ReportGeometry Geometry);
/// <summary>
/// A machine clamp footprint in the parts' placement frame. <paramref name="Removed"/> marks a
/// rail clamp taken off this sheet (by Auto Nest or by hand), which the operator pulls before cutting.
/// </summary>
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<ReportPlacement> Parts, ImmutableArray<ReportGeometry> Cutoffs)
{
@@ -44,6 +50,9 @@ public sealed record ReportPlate(int Number, ReportBounds Bounds, int Copies, do
/// <see cref="NestReportSnapshot.WeightUnits"/>; null when the thickness or density is unknown.
/// </summary>
public double? Weight { get; init; }
/// <summary>Clamps on this sheet, active and removed; display only, never material.</summary>
public ImmutableArray<ReportClamp> Clamps { get; init; } = ImmutableArray<ReportClamp>.Empty;
}
/// <summary>A detached value snapshot. No domain objects or mutable geometry are retained.</summary>
@@ -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;
/// <summary>Machine clamps and sheet rulers on the plate layout view.</summary>
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);
/// <summary>The standard 48 x 24 test sheet with three 3 x 1.5 clamps on its origin-side edge.</summary>
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);
}
}
+19
View File
@@ -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: