feat(reporting): add one-plate nest report PDF library slice

Add OpenNest.Reporting (net8.0, PDFsharp-MigraDoc 6.2.4) with a detached
report snapshot and a PDF writer for an empty/demand-only job or a single
plate layout: Letter portrait summary with vector part thumbnails and a
landscape plate page with a vector sheet diagram, both with Page X of Y.

- Snapshot uses reference identity for document-local R### IDs, recounts
  nested quantities from placements x copies with checked wide integers,
  excludes cutoffs from accounting, and retains no live domain objects.
- Geometry keeps Cut/Display material paths, drops rapid/scribe/lead paths,
  preserves native arcs, holes and intentional tab gaps, and rejects
  malformed programs with plate/part identification.
- Open contours and cutoffs are stroked per contour so PDFsharp cannot join
  a tab gap to the next contour.
- Bundled DejaVu Sans via a custom resolver (including MigraDoc's error
  font); unsupported text fails with the field and code point.
- Layouts beyond this slice (multiple plates, page overflow, labels that
  do not fit) fail with NotSupportedException before the destination is
  replaced; output is rendered to a temporary sibling and moved atomically.
This commit is contained in:
aj
2026-09-29 13:13:32 -04:00
parent 64d0e50df3
commit 74d2b6aa68
20 changed files with 3201 additions and 0 deletions
+27
View File
@@ -0,0 +1,27 @@
namespace OpenNest.Reporting;
internal static class AtomicReportFile
{
internal static void Write(string destination, Action<Stream> render)
{
ArgumentException.ThrowIfNullOrWhiteSpace(destination);
ArgumentNullException.ThrowIfNull(render);
var target = Path.GetFullPath(destination);
var temporary = Path.Combine(Path.GetDirectoryName(target)!,
"." + Path.GetFileName(target) + "." + Guid.NewGuid().ToString("N") + ".tmp");
try
{
using (var stream = new FileStream(temporary, FileMode.CreateNew, FileAccess.Write, FileShare.None))
{
render(stream);
stream.Flush(flushToDisk: true);
}
// The sibling stays on the same filesystem. Never truncate/delete the target first.
File.Move(temporary, target, overwrite: true);
}
finally
{
File.Delete(temporary);
}
}
}
Binary file not shown.
Binary file not shown.
+48
View File
@@ -0,0 +1,48 @@
DejaVu Sans 2.37 (regular and bold)
Source: https://dejavu-fonts.github.io/
Distributed unmodified from Ubuntu fonts-dejavu-core 2.37-8.
Copyright: Copyright (c) 2003 by Bitstream, Inc. All Rights Reserved.
Bitstream Vera is a trademark of Bitstream, Inc.
DejaVu changes are in public domain.
License: bitstream-vera
Permission is hereby granted, free of charge, to any person obtaining a copy
of the fonts accompanying this license ("Fonts") and associated
documentation files (the "Font Software"), to reproduce and distribute the
Font Software, including without limitation the rights to use, copy, merge,
publish, distribute, and/or sell copies of the Font Software, and to permit
persons to whom the Font Software is furnished to do so, subject to the
following conditions:
The above copyright and trademark notices and this permission notice shall
be included in all copies of one or more of the Font Software typefaces.
The Font Software may be modified, altered, or added to, and in particular
the designs of glyphs or characters in the Fonts may be modified and
additional glyphs or characters may be added to the Fonts, only if the fonts
are renamed to names not containing either the words "Bitstream" or the word
"Vera".
This License becomes null and void to the extent applicable to Fonts or Font
Software that has been modified and is distributed under the "Bitstream
Vera" names.
The Font Software may be sold as part of a larger software package but no
copy of one or more of the Font Software typefaces may be sold by itself.
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF COPYRIGHT, PATENT,
TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL BITSTREAM OR THE GNOME
FOUNDATION BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, INCLUDING
ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL DAMAGES,
WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF
THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM OTHER DEALINGS IN THE
FONT SOFTWARE.
Except as contained in this notice, the names of Gnome, the Gnome
Foundation, and Bitstream Inc., shall not be used in advertising or
otherwise to promote the sale, use or other dealings in this Font Software
without prior written authorization from the Gnome Foundation or Bitstream
Inc., respectively. For further information, contact: fonts at gnome dot
org.
+440
View File
@@ -0,0 +1,440 @@
using System.Globalization;
using MigraDoc.DocumentObjectModel;
using MigraDoc.DocumentObjectModel.Tables;
using MigraDoc.Rendering;
using PdfSharp.Drawing;
using PdfSharp.Pdf;
namespace OpenNest.Reporting;
/// <summary>
/// Renders a detached <see cref="NestReportSnapshot"/> to PDF: MigraDoc owns text, tables and
/// pagination; PDFsharp draws vector thumbnails and the sheet diagram into reserved layout areas.
/// </summary>
public static class NestPdfWriter
{
// Slice 1 layout limits. Anything beyond them fails before the destination is touched.
private const double Margin = 36;
private const double BottomMargin = 50;
private const double ThumbnailWidth = 54;
private const double ThumbnailHeight = 36;
private const double DiagramHeight = 320;
private const double DiagramInset = 5;
private const double LabelFontSize = 7;
private const double LabelPadding = 1;
private static readonly XColor Fill = XColor.FromArgb(222, 222, 222);
private static readonly CultureInfo Invariant = CultureInfo.InvariantCulture;
public static void Write(NestReportSnapshot snapshot, string destination) =>
Write(snapshot, destination, null);
/// <param name="wrapOutput">Test seam for injecting late write failures.</param>
internal static void Write(NestReportSnapshot snapshot, string destination, Func<Stream, Stream>? wrapOutput)
{
ArgumentNullException.ThrowIfNull(snapshot);
ArgumentException.ThrowIfNullOrWhiteSpace(destination);
ValidateText(snapshot);
if (snapshot.Plates.Length > 1)
throw new NotSupportedException($"A report with {snapshot.Plates.Length} plate layouts is not supported by this report slice; it supports at most one layout.");
ReportFonts.Initialize();
var document = new Document();
document.Info.Title = snapshot.Name;
var normal = document.Styles[StyleNames.Normal]
?? throw new InvalidOperationException("MigraDoc normal style is unavailable.");
normal.Font.Name = ReportFonts.Family;
normal.Font.Size = 9;
var diagrams = new List<Table>();
AddSummary(document, snapshot);
foreach (var plate in snapshot.Plates)
diagrams.Add(AddPlate(document, snapshot, plate));
var renderer = new PdfDocumentRenderer { Document = document };
renderer.RenderDocument();
using var pdf = renderer.PdfDocument;
// PDFsharp 6.2.4 disallows reading the page count after Save.
var pageCount = pdf.PageCount;
var areas = LocateDiagrams(renderer, pageCount, diagrams);
for (var index = 0; index < areas.Count; index++)
{
var (page, area) = areas[index];
if (page != index + 2)
throw new NotSupportedException("A summary longer than one page is not supported by this report slice.");
using var gfx = XGraphics.FromPdfPage(pdf.Pages[page - 1]);
DrawPlate(gfx, snapshot, snapshot.Plates[index], area);
}
if (pageCount != 1 + snapshot.Plates.Length)
throw new NotSupportedException(snapshot.Plates.Length == 0
? "A summary longer than one page is not supported by this report slice."
: "A plate page longer than one page is not supported by this report slice.");
pdf.Info.Title = snapshot.Name;
pdf.Info.Creator = "OpenNest";
pdf.Info.CreationDate = snapshot.GeneratedAt.UtcDateTime;
AtomicReportFile.Write(destination, stream => pdf.Save(wrapOutput?.Invoke(stream) ?? stream, false));
}
private static void ValidateText(NestReportSnapshot snapshot)
{
ReportFonts.ValidateText(snapshot.Name, "Nest name");
ReportFonts.ValidateText(snapshot.Customer, "Customer");
ReportFonts.ValidateText(snapshot.Notes, "Notes");
ReportFonts.ValidateText(snapshot.Material, "Material");
ReportFonts.ValidateText(snapshot.Grade, "Material grade");
foreach (var drawing in snapshot.Drawings)
ReportFonts.ValidateText(drawing.Name, $"Drawing {drawing.Id} name");
}
private static void AddSummary(Document document, NestReportSnapshot snapshot)
{
var section = AddSection(document, Orientation.Portrait);
var title = section.AddParagraph("Nest Report");
title.Format.Font.Size = 14;
title.Format.Font.Bold = true;
title.Format.SpaceAfter = Unit.FromPoint(6);
var info = AddTable(section, 130, 410);
info.Borders.Visible = false;
AddInfoRow(info, "Nest:", snapshot.Name);
if (snapshot.Customer.Length > 0)
AddInfoRow(info, "Customer:", snapshot.Customer);
if (snapshot.Notes.Length > 0)
AddInfoRow(info, "Notes:", snapshot.Notes);
AddInfoRow(info, "Material:", Material(snapshot));
AddInfoRow(info, "Thickness:", Thickness(snapshot));
AddInfoRow(info, "Units:", snapshot.Units == "mm" ? "millimeters (mm)" : "inches (in)");
AddInfoRow(info, "Generated:", snapshot.GeneratedAt.ToString("yyyy-MM-dd HH:mm zzz", Invariant));
AddInfoRow(info, "Distinct layouts:", snapshot.Plates.Length.ToString(Invariant));
AddInfoRow(info, "Total physical sheets:", snapshot.TotalSheets.ToString(Invariant));
AddHeading(section, "Plates");
if (snapshot.Plates.Length == 0)
section.AddParagraph("No plates in this job.");
else
{
var plates = AddTable(section, 50, 150, 70, 80, 80);
AddHeader(plates, "Plate", "Stock size", "Copies", "Parts/sheet", "Utilization");
foreach (var plate in snapshot.Plates)
AddRow(plates, plate.Number.ToString(Invariant), SheetSize(snapshot, plate),
plate.Copies.ToString(Invariant), plate.Parts.Length.ToString(Invariant), Percent(plate.Utilization));
}
AddHeading(section, "Parts");
if (snapshot.Drawings.Length == 0)
{
section.AddParagraph("No parts in this job.");
return;
}
var parts = AddTable(section, 36, 62, 170, 50, 50, 52, 40, 80);
AddHeader(parts, "ID", "Part", "Drawing", "Required", "Nested", "Shortage", "Extra", "Plates");
foreach (var drawing in snapshot.Drawings)
{
var row = AddRow(parts, drawing.Id, null, drawing.Name, drawing.Required.ToString(Invariant),
drawing.Nested.ToString(Invariant), drawing.Shortage.ToString(Invariant),
drawing.Extra.ToString(Invariant), drawing.Plates.Length == 0 ? "-" : string.Join(", ", drawing.Plates));
var image = row.Cells[1].AddParagraph().AddImage(Thumbnail(drawing.Geometry));
image.Width = Unit.FromPoint(ThumbnailWidth);
image.Height = Unit.FromPoint(ThumbnailHeight);
}
}
private static Table AddPlate(Document document, NestReportSnapshot snapshot, ReportPlate plate)
{
var section = AddSection(document, Orientation.Landscape);
var title = section.AddParagraph($"Plate {plate.Number} of {snapshot.Plates.Length}");
title.Format.Font.Size = 13;
title.Format.Font.Bold = true;
var info = AddTable(section, 80, 280, 90, 270);
info.Borders.Visible = false;
AddInfoRow(info, "Nest:", snapshot.Name, "Sheet size:", SheetSize(snapshot, plate));
AddInfoRow(info, "Material:", Material(snapshot), "Copies:", plate.Copies.ToString(Invariant));
AddInfoRow(info, "Thickness:", Thickness(snapshot), "Part spacing:",
$"{Number(plate.PartSpacing)} {snapshot.Units}");
AddInfoRow(info, "Parts/sheet:", plate.Parts.Length.ToString(Invariant), "Utilization:",
Percent(plate.Utilization) + " (net part area / full sheet)");
// An empty fixed-height row reserves the vector diagram area in MigraDoc's flow.
var diagram = AddTable(section, 720);
diagram.Borders.Visible = false;
diagram.TopPadding = diagram.BottomPadding = Unit.Zero;
var reserved = diagram.AddRow();
reserved.HeightRule = RowHeightRule.Exactly;
reserved.Height = Unit.FromPoint(DiagramHeight);
diagram.Format.SpaceBefore = Unit.FromPoint(4);
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);
var approval = section.AddParagraph("This report is not a geometry or CNC approval.");
approval.Format.Font.Size = 7.5;
approval.Format.SpaceAfter = Unit.FromPoint(4);
var names = snapshot.Drawings.ToDictionary(drawing => drawing.Id, drawing => drawing.Name, StringComparer.Ordinal);
var perSheet = plate.Parts.GroupBy(part => part.ReportId, StringComparer.Ordinal)
.ToDictionary(group => group.Key, group => (long)group.Count(), StringComparer.Ordinal);
var parts = AddTable(section, 50, 330, 100, 140);
AddHeader(parts, "ID", "Drawing", "Qty/sheet", $"Total ({plate.Copies} copies)");
foreach (var drawing in snapshot.Drawings.Where(drawing => perSheet.ContainsKey(drawing.Id)))
{
var count = perSheet[drawing.Id];
AddRow(parts, drawing.Id, names[drawing.Id], count.ToString(Invariant),
checked(count * plate.Copies).ToString(Invariant));
}
return diagram;
}
private static List<(int Page, XRect Area)> LocateDiagrams(PdfDocumentRenderer renderer, int pageCount, List<Table> diagrams)
{
var found = new (int Page, XRect Area)?[diagrams.Count];
for (var page = 1; page <= pageCount; page++)
{
var renderInfos = renderer.DocumentRenderer?.GetRenderInfoFromPage(page)
?? throw new InvalidOperationException("MigraDoc render information is unavailable.");
foreach (var info in renderInfos)
{
var index = diagrams.FindIndex(table => ReferenceEquals(table, info.DocumentObject));
if (index < 0)
continue;
if (found[index] != null)
throw new NotSupportedException($"A diagram for plate {index + 1} split across pages is not supported by this report slice.");
var area = info.LayoutInfo.ContentArea;
found[index] = (page, new XRect(area.X.Point, area.Y.Point, area.Width.Point, area.Height.Point));
}
}
return found.Select((item, index) => item
?? throw new NotSupportedException($"Plate {index + 1}: a missing diagram area is not supported by this report slice.")).ToList();
}
private static void DrawPlate(XGraphics gfx, NestReportSnapshot snapshot, ReportPlate plate, XRect area)
{
var inner = new XRect(area.X + DiagramInset, area.Y + DiagramInset,
area.Width - 2 * DiagramInset, DiagramHeight - 2 * DiagramInset);
var fit = Fit.Create(plate.Bounds, inner);
var font = new XFont(ReportFonts.Family, LabelFontSize, XFontStyleEx.Bold);
// Validate every label before drawing: dense-label callouts are a later slice.
var labels = new List<(string Id, XRect Box)>();
for (var index = 0; index < plate.Parts.Length; index++)
{
var part = plate.Parts[index];
var bounds = fit.Rect(part.Geometry.Bounds);
var size = gfx.MeasureString(part.ReportId, font);
var width = size.Width + 2 * LabelPadding;
var height = size.Height + 2 * LabelPadding;
if (width > bounds.Width || height > bounds.Height)
throw new NotSupportedException($"Plate {plate.Number}, part {index + 1} ({part.ReportId}): a label that does not fit inside its part at {LabelFontSize} pt is not supported by this report slice.");
var box = new XRect(bounds.X + (bounds.Width - width) / 2, bounds.Y + (bounds.Height - height) / 2, width, height);
foreach (var other in labels)
{
if (box.IntersectsWith(other.Box))
throw new NotSupportedException($"Plate {plate.Number}: overlapping labels {other.Id} and {part.ReportId} are not supported by this report slice.");
}
labels.Add((part.ReportId, box));
}
gfx.DrawRectangle(new XPen(XColors.Black, 1), fit.Rect(plate.Bounds));
var outline = new XPen(XColors.Black, 0.6);
foreach (var part in plate.Parts)
DrawGeometry(gfx, part.Geometry, fit, outline);
var cutoff = new XPen(XColors.Black, 0.75) { DashStyle = XDashStyle.Dash };
foreach (var geometry in plate.Cutoffs)
StrokeContours(gfx, geometry, fit, cutoff);
foreach (var (id, box) in labels)
{
gfx.DrawRectangle(XBrushes.White, box);
gfx.DrawString(id, font, XBrushes.Black, box, XStringFormats.Center);
}
}
private static string Thumbnail(ReportGeometry geometry)
{
using var document = new PdfDocument();
var page = document.AddPage();
page.Width = XUnit.FromPoint(ThumbnailWidth);
page.Height = XUnit.FromPoint(ThumbnailHeight);
using (var gfx = XGraphics.FromPdfPage(page))
{
var fit = Fit.Create(geometry.Bounds, new XRect(2, 2, ThumbnailWidth - 4, ThumbnailHeight - 4));
DrawGeometry(gfx, geometry, fit, new XPen(XColors.Black, 0.5));
}
using var stream = new MemoryStream();
document.Save(stream, false);
// MigraDoc embeds a PDF image source as a vector form XObject, not a raster image.
return "base64:" + Convert.ToBase64String(stream.ToArray());
}
private static void DrawGeometry(XGraphics gfx, ReportGeometry geometry, Fit fit, XPen pen)
{
if (geometry.StrokeOnly)
{
StrokeContours(gfx, geometry, fit, pen);
return;
}
// Every contour is closed here, so CloseFigure separates the subpaths.
var path = new XGraphicsPath();
foreach (var contour in geometry.Contours)
{
AddContour(path, contour, fit);
path.CloseFigure();
}
path.FillMode = XFillMode.Alternate; // Even-odd: holes stay unfilled.
gfx.DrawPath(pen, new XSolidBrush(Fill), path);
}
/// <summary>
/// PDFsharp's StartFigure does not emit a move-to after an open figure, which would draw a
/// false segment across a tab gap to the next contour. Stroke each contour as its own path.
/// </summary>
private static void StrokeContours(XGraphics gfx, ReportGeometry geometry, Fit fit, XPen pen)
{
foreach (var contour in geometry.Contours)
{
var path = new XGraphicsPath();
AddContour(path, contour, fit);
if (contour.Closed)
path.CloseFigure();
gfx.DrawPath(pen, path);
}
}
private static void AddContour(XGraphicsPath path, ReportContour contour, Fit fit)
{
foreach (var segment in contour.Segments)
{
if (segment.Center == null)
{
path.AddLine(fit.Point(segment.Start), fit.Point(segment.End));
continue;
}
var center = fit.Point(segment.Center);
var radius = segment.Radius * fit.Scale;
var box = new XRect(center.X - radius, center.Y - radius, 2 * radius, 2 * radius);
// Page Y points down, so model angles and sweeps change sign.
path.AddArc(box, -Degrees(segment.StartAngle), -Degrees(segment.SweepAngle));
}
}
private static double Degrees(double radians) => radians * 180 / System.Math.PI;
private static Section AddSection(Document document, Orientation orientation)
{
var section = document.AddSection();
var setup = section.PageSetup;
setup.PageFormat = PageFormat.Letter;
setup.Orientation = orientation;
setup.TopMargin = setup.LeftMargin = setup.RightMargin = Unit.FromPoint(Margin);
setup.BottomMargin = Unit.FromPoint(BottomMargin);
setup.FooterDistance = Unit.FromPoint(20);
var footer = section.Footers.Primary.AddParagraph("Page ");
footer.AddPageField();
footer.AddText(" of ");
footer.AddNumPagesField();
footer.AddText(" | Diagram fitted to page; not a dimensioned cutting drawing.");
footer.Format.Font.Size = 7.5;
return section;
}
private static void AddHeading(Section section, string text)
{
var heading = section.AddParagraph(text);
heading.Format.Font.Bold = true;
heading.Format.Font.Size = 11;
heading.Format.SpaceBefore = Unit.FromPoint(10);
heading.Format.SpaceAfter = Unit.FromPoint(4);
}
private static Table AddTable(Section section, params double[] widths)
{
var table = section.AddTable();
table.Borders.Width = 0.5;
table.Borders.Color = Colors.Gray;
table.LeftPadding = table.RightPadding = Unit.FromPoint(3);
table.TopPadding = table.BottomPadding = Unit.FromPoint(1.5);
foreach (var width in widths)
table.AddColumn(Unit.FromPoint(width));
return table;
}
private static void AddHeader(Table table, params string[] cells)
{
var row = AddRow(table, cells);
row.HeadingFormat = true;
row.Format.Font.Bold = true;
row.Shading.Color = Colors.LightGray;
}
private static void AddInfoRow(Table table, params string[] cells)
{
var row = AddRow(table, cells);
for (var index = 0; index < cells.Length; index += 2)
row.Cells[index].Format.Font.Bold = true;
}
/// <summary>Adds text cells; a null cell is left empty for non-text content.</summary>
private static Row AddRow(Table table, params string?[] cells)
{
var row = table.AddRow();
row.VerticalAlignment = VerticalAlignment.Center;
for (var index = 0; index < cells.Length; index++)
{
if (cells[index] == null)
continue;
var paragraph = row.Cells[index].AddParagraph();
var lines = cells[index]!.Replace("\r\n", "\n").Replace('\r', '\n').Replace('\t', ' ').Split('\n');
for (var line = 0; line < lines.Length; line++)
{
if (line > 0)
paragraph.AddLineBreak();
paragraph.AddText(lines[line]);
}
}
return row;
}
private static string Material(NestReportSnapshot snapshot)
{
var material = $"{snapshot.Material} {snapshot.Grade}".Trim();
return material.Length == 0 ? "Not specified" : material;
}
// Zero thickness means "unset" in a new nest; never present it as a genuine value.
private static string Thickness(NestReportSnapshot snapshot) =>
snapshot.Thickness > 0 ? $"{Number(snapshot.Thickness)} {snapshot.Units}" : "Not specified";
private static string SheetSize(NestReportSnapshot snapshot, ReportPlate plate) =>
$"{Number(plate.Bounds.Length)} x {Number(plate.Bounds.Width)} {snapshot.Units}";
private static string Number(double value) => value.ToString("0.####", Invariant);
private static string Percent(double value) => (value * 100).ToString("0.0", Invariant) + "%";
/// <summary>One aspect-preserving model-to-page transform, centered, with the Y axis flipped.</summary>
private readonly record struct Fit(ReportBounds Bounds, double Scale, double X, double Y)
{
public static Fit Create(ReportBounds bounds, XRect area)
{
var scales = new List<double>(2);
if (bounds.Length > 0)
scales.Add(area.Width / bounds.Length);
if (bounds.Width > 0)
scales.Add(area.Height / bounds.Width);
var scale = scales.Count == 0 ? 1 : scales.Min();
if (!double.IsFinite(scale) || scale <= 0)
throw new InvalidOperationException("Report geometry cannot be fitted to the page.");
return new Fit(bounds, scale,
area.X + (area.Width - bounds.Length * scale) / 2,
area.Y + (area.Height - bounds.Width * scale) / 2);
}
public XPoint Point(ReportPoint point) =>
new(X + (point.X - Bounds.Left) * Scale, Y + (Bounds.Top - point.Y) * Scale);
public XRect Rect(ReportBounds box)
{
var low = Point(new ReportPoint(box.Left, box.Top));
return new XRect(low.X, low.Y, box.Length * Scale, box.Width * Scale);
}
}
}
+131
View File
@@ -0,0 +1,131 @@
using System.Collections.Immutable;
using System.Globalization;
using OpenNest.Geometry;
namespace OpenNest.Reporting;
public static class NestReportBuilder
{
/// <summary>
/// Capture synchronously while the caller keeps the nest stable. No quantity refresh,
/// domain cloning, or event-wired temporary plates are used.
/// </summary>
public static NestReportSnapshot Capture(Nest nest, DateTimeOffset generatedAt)
{
ArgumentNullException.ThrowIfNull(nest);
var ordered = new List<DrawingCount>();
var byReference = new Dictionary<Drawing, DrawingCount>(ReferenceEqualityComparer.Instance);
var plates = ImmutableArray.CreateBuilder<ReportPlate>();
DrawingCount Register(Drawing drawing)
{
if (byReference.TryGetValue(drawing, out var existing))
return existing;
var item = new DrawingCount(drawing, "R" + (ordered.Count + 1).ToString("D3", CultureInfo.InvariantCulture));
byReference.Add(drawing, item);
ordered.Add(item);
return item;
}
for (var index = 0; index < nest.Plates.Count; index++)
{
var number = index + 1;
var plate = nest.Plates[index];
if (plate == null)
throw new InvalidOperationException($"Plate {number}: missing plate.");
if (!PositiveFinite(plate.Size.Length) || !PositiveFinite(plate.Size.Width)
|| !PositiveFinite(plate.Area()))
throw new InvalidOperationException($"Plate {number}: sheet dimensions must be finite and positive.");
if (plate.Quantity <= 0)
throw new InvalidOperationException($"Plate {number}: copies must be a positive integer.");
if (!double.IsFinite(plate.PartSpacing) || plate.PartSpacing < 0)
throw new InvalidOperationException($"Plate {number}: part spacing must be finite and nonnegative.");
var box = plate.BoundingBox(false);
var bounds = new ReportBounds(box.Left, box.Bottom, box.Right, box.Top);
if (!double.IsFinite(bounds.Left) || !double.IsFinite(bounds.Bottom)
|| !double.IsFinite(bounds.Right) || !double.IsFinite(bounds.Top))
throw new InvalidOperationException($"Plate {number}: sheet bounds are not finite.");
var parts = ImmutableArray.CreateBuilder<ReportPlacement>();
var cutoffs = ImmutableArray.CreateBuilder<ReportGeometry>();
var perSheet = new Dictionary<DrawingCount, long>();
var area = 0.0;
for (var partIndex = 0; partIndex < plate.Parts.Count; partIndex++)
{
var part = plate.Parts[partIndex];
var context = $"Plate {number}, part {partIndex + 1} ('{part?.BaseDrawing?.Name}')";
if (part?.BaseDrawing == null)
throw new InvalidOperationException($"{context}: missing part or drawing reference.");
var geometry = NestReportGeometry.Capture(part.Program, part.Location, context);
if (part.BaseDrawing.IsCutOff)
{
cutoffs.Add(geometry);
continue;
}
var item = Register(part.BaseDrawing);
parts.Add(new ReportPlacement(item.Id, geometry));
perSheet.TryGetValue(item, out var count);
perSheet[item] = checked(count + 1);
var partArea = part.BaseDrawing.Area;
if (!double.IsFinite(partArea) || partArea < 0)
throw new InvalidOperationException($"{context}: net part area is invalid.");
area += partArea;
}
foreach (var (item, count) in perSheet)
{
item.Nested = checked(item.Nested + checked(count * plate.Quantity));
item.Plates.Add(number);
}
var utilization = area / plate.Area();
if (!double.IsFinite(utilization))
throw new InvalidOperationException($"Plate {number}: utilization is not finite.");
plates.Add(new ReportPlate(number, bounds, plate.Quantity, plate.PartSpacing,
utilization, parts.ToImmutable(), cutoffs.ToImmutable()));
}
// OrderBy is stable, so the original enumeration breaks equal name/path ties.
foreach (var drawing in nest.Drawings
.Where(drawing => !drawing.IsCutOff)
.OrderBy(drawing => drawing.Name ?? "", StringComparer.Ordinal)
.ThenBy(drawing => drawing.Source?.Path ?? "", StringComparer.Ordinal))
Register(drawing);
var drawings = ImmutableArray.CreateBuilder<ReportDrawing>();
foreach (var item in ordered)
{
var drawing = item.Drawing;
var context = $"Drawing {item.Id} ('{drawing.Name}')";
var required = (long)drawing.Quantity.Required;
if (required < 0)
throw new InvalidOperationException($"{context}: required quantity cannot be negative.");
var geometry = NestReportGeometry.Capture(drawing.Program, new Vector(), context);
drawings.Add(new ReportDrawing(item.Id, drawing.Name ?? "", required, item.Nested,
System.Math.Max(checked(required - item.Nested), 0),
System.Math.Max(checked(item.Nested - required), 0), item.Plates.ToImmutableArray(), geometry));
}
var units = nest.Units switch
{
Units.Inches => "in",
Units.Millimeters => "mm",
_ => throw new InvalidOperationException("Nest: unsupported units."),
};
if (!double.IsFinite(nest.Thickness) || nest.Thickness < 0)
throw new InvalidOperationException("Nest: thickness must be finite and nonnegative.");
return new NestReportSnapshot(nest.Name ?? "", nest.Customer ?? "", nest.Notes ?? "",
nest.Material?.Name ?? "", nest.Material?.Grade ?? "", nest.Thickness, units, generatedAt,
drawings.ToImmutable(), plates.ToImmutable());
}
private static bool PositiveFinite(double value) => double.IsFinite(value) && value > 0;
private sealed class DrawingCount(Drawing drawing, string id)
{
public Drawing Drawing { get; } = drawing;
public string Id { get; } = id;
public long Nested { get; set; }
public List<int> Plates { get; } = [];
}
}
+174
View File
@@ -0,0 +1,174 @@
using System.Collections.Immutable;
using OpenNest.CNC;
using OpenNest.Converters;
using OpenNest.Geometry;
using OpenNest.Math;
namespace OpenNest.Reporting;
internal static class NestReportGeometry
{
internal static ReportGeometry Capture(Program? program, Vector location, string context)
{
ValidatePoint(location, context);
var current = new Vector();
// ConvertProgram recurses without cycle/null guards and silently skips unknown codes.
// Validate first; never clone through SubProgramCall's rotating setters.
ValidateProgram(program, ref current, new HashSet<Program>(ReferenceEqualityComparer.Instance), context);
var entities = ConvertProgram.ToGeometry(program!);
var contours = ImmutableArray.CreateBuilder<ReportContour>();
var segments = ImmutableArray.CreateBuilder<ReportSegment>();
var left = double.PositiveInfinity;
var bottom = double.PositiveInfinity;
var right = double.NegativeInfinity;
var top = double.NegativeInfinity;
void Flush()
{
if (segments.Count == 0)
return;
var closed = SamePoint(segments[0].Start, segments[^1].End);
contours.Add(new ReportContour(segments.ToImmutable(), closed));
segments.Clear();
}
foreach (var entity in entities)
{
if (!SpecialLayers.IsMaterial(entity.Layer)
|| entity.Layer == SpecialLayers.Leadin || entity.Layer == SpecialLayers.Leadout)
{
Flush();
continue;
}
ReportSegment segment;
switch (entity)
{
case Line line:
if (line.StartPoint.X == line.EndPoint.X && line.StartPoint.Y == line.EndPoint.Y)
continue;
segment = new ReportSegment(Point(line.StartPoint, location, context),
Point(line.EndPoint, location, context), null, 0, 0, 0);
break;
case Arc arc:
segment = new ReportSegment(Point(arc.StartPoint(), location, context),
Point(arc.EndPoint(), location, context), Point(arc.Center, location, context),
arc.Radius, arc.StartAngle, arc.SweepAngle() * (arc.IsReversed ? -1 : 1));
break;
case Circle circle:
Flush();
var start = Point(new Vector(circle.Center.X + circle.Radius, circle.Center.Y), location, context);
segment = new ReportSegment(start, start, Point(circle.Center, location, context),
circle.Radius, 0, circle.Rotation == RotationType.CW ? -2 * System.Math.PI : 2 * System.Math.PI);
break;
default:
throw Error(context, $"unsupported geometry '{entity.GetType().Name}'.");
}
if (segment.Center != null && (!double.IsFinite(segment.Radius) || segment.Radius <= 0
|| !double.IsFinite(segment.StartAngle) || !double.IsFinite(segment.SweepAngle)
|| segment.SweepAngle == 0 || System.Math.Abs(segment.SweepAngle) > 2 * System.Math.PI))
throw Error(context, "invalid arc/circle data.");
if (segments.Count > 0 && !SamePoint(segments[^1].End, segment.Start))
Flush();
segments.Add(segment);
if (SamePoint(segments[0].Start, segment.End))
Flush();
var box = entity.BoundingBox;
var low = Point(new Vector(box.Left, box.Bottom), location, context);
var high = Point(new Vector(box.Right, box.Top), location, context);
left = System.Math.Min(left, low.X);
bottom = System.Math.Min(bottom, low.Y);
right = System.Math.Max(right, high.X);
top = System.Math.Max(top, high.Y);
}
Flush();
if (contours.Count == 0)
throw Error(context, "missing drawable material geometry.");
if (!double.IsFinite(right - left) || !double.IsFinite(top - bottom))
throw Error(context, "geometry bounds are not finite.");
return new ReportGeometry(contours.ToImmutable(), new ReportBounds(left, bottom, right, top));
}
private static void ValidateProgram(Program? program, ref Vector current,
HashSet<Program> active, string context)
{
if (program?.Codes == null)
throw Error(context, "missing program or subprogram reference.");
if (!active.Add(program))
throw Error(context, "cyclic subprogram reference.");
if (active.Count > 256)
throw Error(context, "unsupported subprogram nesting depth (maximum 256).");
if (!double.IsFinite(program.Rotation) || !Enum.IsDefined(program.Mode))
throw Error(context, "invalid program transform or coordinate mode.");
var origin = current;
for (var index = 0; index < program.Codes.Count; index++)
{
var code = program.Codes[index];
var codeContext = $"{context}, code {index + 1}";
switch (code)
{
case SubProgramCall call when code.Type == CodeType.SubProgramCall:
ValidatePoint(call.Offset, codeContext);
if (!double.IsFinite(call.Rotation))
throw Error(codeContext, "invalid subprogram rotation.");
current = origin + call.Offset;
ValidatePoint(current, codeContext);
ValidateProgram(call.Program, ref current, active, $"{codeContext}, subprogram {call.Id}");
break;
case Motion motion when code is LinearMove && code.Type == CodeType.LinearMove
|| code is RapidMove && code.Type == CodeType.RapidMove
|| code is ArcMove && code.Type == CodeType.ArcMove:
ValidatePoint(motion.EndPoint, codeContext);
var end = program.Mode == Mode.Incremental ? current + motion.EndPoint : motion.EndPoint;
ValidatePoint(end, codeContext);
if (motion is LinearMove linear && !Enum.IsDefined(linear.Layer))
throw Error(codeContext, "unsupported material layer.");
if (motion is ArcMove arc)
{
ValidatePoint(arc.CenterPoint, codeContext);
if (!Enum.IsDefined(arc.Rotation) || !Enum.IsDefined(arc.Layer))
throw Error(codeContext, "invalid arc rotation or layer.");
var center = program.Mode == Mode.Incremental ? current + arc.CenterPoint : arc.CenterPoint;
ValidatePoint(center, codeContext);
var startRadius = current.DistanceTo(center);
var endRadius = end.DistanceTo(center);
if (!double.IsFinite(startRadius) || !double.IsFinite(endRadius)
|| startRadius <= 0 || endRadius <= 0
|| System.Math.Abs(startRadius - endRadius) > Tolerance.Epsilon)
throw Error(codeContext, "invalid arc/circle radius or inconsistent endpoints.");
}
current = end;
break;
case Comment when code.Type == CodeType.Comment:
case Feedrate when code.Type == CodeType.SetFeedrate:
case Kerf when code.Type == CodeType.SetKerf:
break;
default:
throw Error(codeContext, code == null ? "missing code." : $"unsupported code '{code.GetType().Name}'.");
}
}
active.Remove(program);
}
private static ReportPoint Point(Vector point, Vector location, string context)
{
var translated = point + location;
ValidatePoint(translated, context);
return new ReportPoint(translated.X, translated.Y);
}
private static void ValidatePoint(Vector point, string context)
{
if (!double.IsFinite(point.X) || !double.IsFinite(point.Y))
throw Error(context, "nonfinite geometry coordinate.");
}
// Only absorb floating-point trig roundoff, never ShapeBuilder's welding tolerance.
// A deliberate tab/rapid interruption is never joined by contour construction.
private static bool SamePoint(ReportPoint first, ReportPoint second) =>
System.Math.Abs(first.X - second.X) <= 1e-12 && System.Math.Abs(first.Y - second.Y) <= 1e-12;
private static InvalidOperationException Error(string context, string message) => new($"{context}: {message}");
}
+38
View File
@@ -0,0 +1,38 @@
using System.Collections.Immutable;
namespace OpenNest.Reporting;
public sealed record ReportPoint(double X, double Y);
public sealed record ReportBounds(double Left, double Bottom, double Right, double Top)
{
public double Length => Right - Left;
public double Width => Top - Bottom;
}
/// <summary>Native line or arc; angles are radians and a null center denotes a line.</summary>
public sealed record ReportSegment(ReportPoint Start, ReportPoint End, ReportPoint? Center,
double Radius, double StartAngle, double SweepAngle);
public sealed record ReportContour(ImmutableArray<ReportSegment> Segments, bool Closed);
public sealed record ReportGeometry(ImmutableArray<ReportContour> Contours, ReportBounds Bounds)
{
public bool StrokeOnly => Contours.Any(contour => !contour.Closed);
}
public sealed record ReportDrawing(string Id, string Name, long Required, long Nested,
long Shortage, long Extra, ImmutableArray<int> Plates, ReportGeometry Geometry);
public sealed record ReportPlacement(string ReportId, ReportGeometry Geometry);
public sealed record ReportPlate(int Number, ReportBounds Bounds, int Copies, double PartSpacing,
double Utilization, ImmutableArray<ReportPlacement> Parts, ImmutableArray<ReportGeometry> Cutoffs);
/// <summary>A detached value snapshot. No domain objects or mutable geometry are retained.</summary>
public sealed record NestReportSnapshot(string Name, string Customer, string Notes, string Material,
string Grade, double Thickness, string Units, DateTimeOffset GeneratedAt,
ImmutableArray<ReportDrawing> Drawings, ImmutableArray<ReportPlate> Plates)
{
public long TotalSheets => Plates.Aggregate(0L, (total, plate) => checked(total + plate.Copies));
}
@@ -0,0 +1,14 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\OpenNest.Core\OpenNest.Core.csproj" />
<PackageReference Include="PDFsharp-MigraDoc" Version="6.2.4" />
<EmbeddedResource Include="Fonts\*.ttf" />
<None Update="Fonts\LICENSE.txt;THIRD-PARTY-NOTICES.txt" CopyToOutputDirectory="PreserveNewest" CopyToPublishDirectory="PreserveNewest" TargetPath="ReportingNotices\%(Filename)%(Extension)" />
<InternalsVisibleTo Include="OpenNest.Tests" />
</ItemGroup>
</Project>
+59
View File
@@ -0,0 +1,59 @@
using PdfSharp.Fonts;
namespace OpenNest.Reporting;
/// <summary>Process-wide, embedded fonts; never consult platform font directories.</summary>
internal static class ReportFonts
{
internal const string Family = "OpenNest Report Sans";
private static readonly object Sync = new();
private static readonly BundledResolver Resolver = new();
internal static void Initialize()
{
lock (Sync)
{
if (GlobalFontSettings.FontResolver == null)
GlobalFontSettings.FontResolver = Resolver;
else if (!ReferenceEquals(GlobalFontSettings.FontResolver, Resolver))
throw new InvalidOperationException("Nest reports require their bundled font resolver before PDF fonts are created. Another resolver is already installed.");
// MigraDoc otherwise asks the resolver for a platform "Courier New" error font.
MigraDoc.PredefinedFontsAndChars.ErrorFontName = Family;
}
}
internal static void ValidateText(string text, string field)
{
// Slice 1 deliberately supports printable Latin-1, not general Unicode shaping.
// Reject unavailable text explicitly instead of substituting .notdef boxes or hiding it.
foreach (var rune in text.EnumerateRunes())
{
var value = rune.Value;
if (value is '\r' or '\n' or '\t' || value is >= 0x20 and <= 0x7e
|| value is >= 0xa0 and <= 0xff && value != 0xad)
continue;
throw new InvalidOperationException($"{field}: unsupported report character U+{value:X4}. This report slice supports printable Latin-1 text only.");
}
}
private sealed class BundledResolver : IFontResolver
{
public FontResolverInfo? ResolveTypeface(string familyName, bool isBold, bool isItalic)
{
if (!string.Equals(familyName, Family, StringComparison.OrdinalIgnoreCase) || isItalic)
return null;
return new FontResolverInfo(isBold ? "DejaVuSans-Bold" : "DejaVuSans");
}
public byte[] GetFont(string faceName)
{
if (faceName is not ("DejaVuSans" or "DejaVuSans-Bold"))
throw new InvalidOperationException($"Unknown report font face: {faceName}.");
using var source = typeof(ReportFonts).Assembly.GetManifestResourceStream($"OpenNest.Reporting.Fonts.{faceName}.ttf")
?? throw new InvalidOperationException($"Bundled report font missing: {faceName}.");
using var bytes = new MemoryStream();
source.CopyTo(bytes);
return bytes.ToArray();
}
}
}
File diff suppressed because it is too large Load Diff
+1
View File
@@ -29,6 +29,7 @@
<ProjectReference Include="..\OpenNest.Core\OpenNest.Core.csproj" />
<ProjectReference Include="..\OpenNest.Engine\OpenNest.Engine.csproj" />
<ProjectReference Include="..\OpenNest.IO\OpenNest.IO.csproj" />
<ProjectReference Include="..\OpenNest.Reporting\OpenNest.Reporting.csproj" />
<ProjectReference Include="..\Posts\OpenNest.Posts.Cincinnati\OpenNest.Posts.Cincinnati.csproj" />
<ProjectReference Include="..\Posts\OpenNest.Posts.CincinnatiCIFiber\OpenNest.Posts.CincinnatiCIFiber.csproj" />
<ProjectReference Include="..\Posts\OpenNest.Posts.GravographIS\OpenNest.Posts.GravographIS.csproj" />
@@ -0,0 +1,63 @@
using OpenNest.Reporting;
namespace OpenNest.Tests.Reporting;
public sealed class AtomicReportFileTests : IDisposable
{
private readonly string directory = Path.Combine(Path.GetTempPath(), "opennest-report-" + Guid.NewGuid().ToString("N"));
public AtomicReportFileTests() => Directory.CreateDirectory(directory);
[Fact]
public void SuccessfulRenderReplacesExistingFileOnlyAfterRendering()
{
var path = Path.Combine(directory, "existing.pdf");
File.WriteAllText(path, "original");
AtomicReportFile.Write(path, stream =>
{
Assert.Equal("original", File.ReadAllText(path));
stream.Write("replacement"u8);
});
Assert.Equal("replacement", File.ReadAllText(path));
Assert.Equal(new[] { path }, Directory.GetFiles(directory));
}
[Fact]
public void LateRenderFailurePreservesExistingFileAndRemovesTemporarySibling()
{
var path = Path.Combine(directory, "existing.pdf");
File.WriteAllText(path, "original");
Assert.Throws<IOException>(() => AtomicReportFile.Write(path, stream =>
{
stream.Write("partial PDF"u8);
Assert.Equal(2, Directory.GetFiles(directory).Length);
throw new IOException("injected late render failure");
}));
Assert.Equal("original", File.ReadAllText(path));
Assert.Equal(new[] { path }, Directory.GetFiles(directory));
}
[Fact]
public void FailedNewExportLeavesNoOutput()
{
var path = Path.Combine(directory, "new.pdf");
Assert.Throws<IOException>(() => AtomicReportFile.Write(path, stream =>
{
stream.Write("partial"u8);
throw new IOException("injected write failure");
}));
Assert.Empty(Directory.GetFiles(directory));
}
[Fact]
public void FailedReplacementPreservesDirectoryTargetAndCleansTemporaryFile()
{
var path = Path.Combine(directory, "not-a-file.pdf");
Directory.CreateDirectory(path);
Assert.ThrowsAny<IOException>(() => AtomicReportFile.Write(path, stream => stream.Write("PDF"u8)));
Assert.True(Directory.Exists(path));
Assert.Empty(Directory.GetFiles(directory));
}
public void Dispose() => Directory.Delete(directory, true);
}
@@ -0,0 +1,264 @@
using System.Diagnostics;
using System.Text;
using System.Text.RegularExpressions;
using OpenNest.Geometry;
using OpenNest.Reporting;
using PdfSharp.Pdf.IO;
namespace OpenNest.Tests.Reporting;
public sealed class NestPdfWriterTests : IDisposable
{
private readonly string directory = Path.Combine(Path.GetTempPath(), "opennest-pdf-" + Guid.NewGuid().ToString("N"));
public NestPdfWriterTests() => Directory.CreateDirectory(directory);
[Fact]
public void Write_OnePlateJobProducesPortraitSummaryAndLandscapeVectorPlatePage()
{
var path = WriteReport(NestReportTestData.CreateNest());
using (var pdf = PdfReader.Open(path, PdfDocumentOpenMode.Import))
{
Assert.Equal(2, pdf.PageCount);
Assert.Equal((612, 792), EffectiveSize(pdf.Pages[0]));
Assert.Equal((792, 612), EffectiveSize(pdf.Pages[1]));
}
var bytes = Encoding.Latin1.GetString(File.ReadAllBytes(path));
// Subset-embedded faces; PDF names escape the space as #20.
Assert.Matches(@"/BaseFont\s*/[A-Z]{6}\+DejaVu#20Sans(?![,\w])", bytes);
Assert.Matches(@"/BaseFont\s*/[A-Z]{6}\+DejaVu#20Sans,Bold\b", bytes);
Assert.Contains("/FontFile2", bytes);
// Thumbnails and diagrams are vectors, never raster screenshots.
Assert.DoesNotMatch(@"/Subtype\s*/Image", bytes);
Assert.Matches(@"/Subtype\s*/Form", bytes);
}
[SkippableFact]
public void Write_PdfTextCarriesDocumentLocalIdsQuantitiesAndPageNumbers()
{
var path = WriteReport(NestReportTestData.CreateNest());
var summary = ExtractText(path, 1);
var plate = ExtractText(path, 2);
Assert.Contains("Report test job", summary);
Assert.Contains("Test customer", summary);
Assert.Contains("Steel A36", summary);
Assert.Contains("0.125 in", summary);
Assert.Contains("2026-09-29 12:00 +00:00", summary);
Assert.Matches(@"Distinct layouts:\s+1", summary);
Assert.Matches(@"Total physical sheets:\s+2", summary);
Assert.Matches(@"1\s+48 x 24 in\s+2\s+4\s+\d+\.\d%", summary);
Assert.Matches(@"R001\s+Bracket\s+5\s+4\s+1\s+0\s+1", summary);
Assert.Matches(@"R002\s+Rotated\s+1\s+2\s+0\s+1\s+1", summary);
Assert.Matches(@"R003\s+Bracket\s+7\s+2\s+5\s+0\s+1", summary);
Assert.Matches(@"R004\s+Unplaced\s+3\s+0\s+3\s+0\s+-", summary);
Assert.Contains("Page 1 of 2", summary);
Assert.Contains("Plate 1", plate);
Assert.Contains("Report test job", plate);
Assert.Matches(@"Copies:\s+2", plate);
Assert.Matches(@"R001\s+Bracket\s+2\s+4", plate);
Assert.Matches(@"R002\s+Rotated\s+1\s+2", plate);
Assert.Matches(@"R003\s+Bracket\s+1\s+2", plate);
Assert.DoesNotContain("R004", plate);
Assert.DoesNotContain("Cutoff test", plate + summary);
Assert.Contains("not a dimensioned cutting drawing", plate);
Assert.Contains("not a geometry or CNC approval", plate);
Assert.Contains("Page 2 of 2", plate);
// Repeated export of the same snapshot is textually identical.
Assert.Equal(summary + plate, ExtractText(WriteReport(NestReportTestData.CreateNest(), "again.pdf"), 1)
+ ExtractText(Path.Combine(directory, "again.pdf"), 2));
}
[SkippableFact]
public void Write_EmptyAndDemandOnlyJobsProduceExplicitSummaryOnlyReports()
{
var nest = new Nest("Empty test") { Units = Units.Millimeters };
var empty = ExtractText(WriteReport(nest, "empty.pdf"), 1);
Assert.Contains("No plates in this job.", empty);
Assert.Contains("No parts in this job.", empty);
Assert.Contains("Page 1 of 1", empty);
nest.Drawings.Add(NestReportTestData.Rectangle("Alpha", 20, 10, 2));
var demand = ExtractText(WriteReport(nest, "demand.pdf"), 1);
Assert.Contains("No plates in this job.", demand);
Assert.Matches(@"R001\s+Alpha\s+2\s+0\s+2\s+0\s+-", demand);
Assert.Contains("Page 1 of 1", demand);
}
[Fact]
public void Write_TabbedLeadInPartStrokesEachOpenContourSeparately()
{
var nest = NestReportTestData.CreateTabbedNest();
var geometry = NestReportBuilder.Capture(nest, NestReportTestData.GeneratedAt).Plates[0].Parts[0].Geometry;
Assert.True(geometry.StrokeOnly);
var path = WriteReport(nest);
using var pdf = PdfReader.Open(path, PdfDocumentOpenMode.Import);
Assert.Equal(2, pdf.PageCount);
// The vector diagram is the last content stream on the plate page. Each stroke-only
// contour must begin its own subpath; a missing move-to draws a false connecting line.
var diagram = ContentStreams(pdf.Pages[1]).Last();
Assert.Equal(geometry.Contours.Length, Regex.Matches(diagram, @"(?m)^[-\d. ]+ m$").Count);
}
[Fact]
public void Write_LateFailureLeavesExistingReportUnchangedAndNoTemporaryFile()
{
var path = Path.Combine(directory, "existing.report.pdf");
var original = "%PDF previous report"u8.ToArray();
File.WriteAllBytes(path, original);
var snapshot = NestReportBuilder.Capture(NestReportTestData.CreateNest(), NestReportTestData.GeneratedAt);
Assert.Throws<IOException>(() => NestPdfWriter.Write(snapshot, path, stream => new FailingStream(stream, 512)));
Assert.Equal(original, File.ReadAllBytes(path));
Assert.Equal(new[] { path }, Directory.GetFiles(directory));
}
[Fact]
public void Write_SuccessfulExportReplacesExistingReport()
{
var path = Path.Combine(directory, "existing.report.pdf");
File.WriteAllText(path, "old");
WriteReport(NestReportTestData.CreateNest(), "existing.report.pdf");
Assert.StartsWith("%PDF-", Encoding.ASCII.GetString(File.ReadAllBytes(path), 0, 5));
Assert.Equal(new[] { path }, Directory.GetFiles(directory));
}
[Theory]
[InlineData("second-plate")]
[InlineData("summary-overflow")]
[InlineData("plate-table-overflow")]
[InlineData("illegible-label")]
public void Write_UnsupportedLayoutFailsBeforeReplacingDestination(string scenario)
{
var nest = NestReportTestData.CreateNest();
var plate = nest.Plates[0];
switch (scenario)
{
case "second-plate":
nest.Plates.Add(new Plate(24, 48) { Quantity = 1 });
nest.Plates[1].Parts.Add(new Part(plate.Parts[0].BaseDrawing));
break;
case "summary-overflow":
for (var i = 0; i < 40; i++)
nest.Drawings.Add(NestReportTestData.Rectangle($"Demand {i:D2}", 1, 1));
break;
case "plate-table-overflow":
for (var i = 0; i < 12; i++)
// Large enough for readable labels, so only the table can overflow.
plate.Parts.Add(new Part(NestReportTestData.Rectangle($"Small {i:D2}", 2, 2), new Vector(2 + i * 3, 16)));
break;
case "illegible-label":
plate.Parts.Add(new Part(NestReportTestData.Rectangle("Tiny", 0.05, 0.05), new Vector(44, 20)));
break;
}
var path = Path.Combine(directory, "keep.report.pdf");
File.WriteAllText(path, "keep");
var snapshot = NestReportBuilder.Capture(nest, NestReportTestData.GeneratedAt);
var error = Assert.Throws<NotSupportedException>(() => NestPdfWriter.Write(snapshot, path));
Assert.Contains("not supported", error.Message);
Assert.Equal("keep", File.ReadAllText(path));
Assert.Equal(new[] { path }, Directory.GetFiles(directory));
}
[Fact]
public void Write_UnsupportedTextIsIdentifiedBeforeReplacingDestination()
{
var nest = NestReportTestData.CreateNest();
nest.Plates[0].Parts[2].BaseDrawing.Name = "Rotated 中";
var path = Path.Combine(directory, "keep.report.pdf");
File.WriteAllText(path, "keep");
var snapshot = NestReportBuilder.Capture(nest, NestReportTestData.GeneratedAt);
var error = Assert.Throws<InvalidOperationException>(() => NestPdfWriter.Write(snapshot, path));
Assert.Contains("R002", error.Message);
Assert.Contains("U+4E2D", error.Message);
Assert.Equal("keep", File.ReadAllText(path));
Assert.Equal(new[] { path }, Directory.GetFiles(directory));
}
public void Dispose() => Directory.Delete(directory, true);
private string WriteReport(Nest nest, string name = "test.report.pdf")
{
var path = Path.Combine(directory, name);
NestPdfWriter.Write(NestReportBuilder.Capture(nest, NestReportTestData.GeneratedAt), path);
return path;
}
private static (int, int) EffectiveSize(PdfSharp.Pdf.PdfPage page)
{
var box = page.MediaBox;
var width = (int)System.Math.Round(box.Width);
var height = (int)System.Math.Round(box.Height);
return page.Rotate % 180 == 0 ? (width, height) : (height, width);
}
private static IEnumerable<string> ContentStreams(PdfSharp.Pdf.PdfPage page)
{
foreach (var item in page.Contents.Elements)
{
var dictionary = (item as PdfSharp.Pdf.Advanced.PdfReference)?.Value as PdfSharp.Pdf.PdfDictionary
?? item as PdfSharp.Pdf.PdfDictionary;
if (dictionary?.Stream != null)
yield return Encoding.Latin1.GetString(dictionary.Stream.UnfilteredValue);
}
}
/// <summary>Poppler text extraction; skipped where poppler-utils is not installed.</summary>
private static string ExtractText(string path, int page)
{
var info = new ProcessStartInfo("pdftotext")
{
RedirectStandardOutput = true,
RedirectStandardError = true,
};
foreach (var argument in new[] { "-layout", "-f", page.ToString(), "-l", page.ToString(), path, "-" })
info.ArgumentList.Add(argument);
Process process;
try
{
process = Process.Start(info)!;
}
catch (System.ComponentModel.Win32Exception)
{
Skip.If(true, "pdftotext (poppler-utils) is not installed.");
throw;
}
using (process)
{
var text = process.StandardOutput.ReadToEnd();
process.WaitForExit();
Assert.Equal(0, process.ExitCode);
return Regex.Replace(text, "[ \t]+\n", "\n");
}
}
private sealed class FailingStream(Stream inner, long limit) : Stream
{
private long written;
public override bool CanRead => false;
public override bool CanSeek => inner.CanSeek;
public override bool CanWrite => true;
public override long Length => inner.Length;
public override long Position { get => inner.Position; set => inner.Position = value; }
public override void Flush() => inner.Flush();
public override int Read(byte[] buffer, int offset, int count) => throw new NotSupportedException();
public override long Seek(long offset, SeekOrigin origin) => inner.Seek(offset, origin);
public override void SetLength(long value) => inner.SetLength(value);
public override void Write(byte[] buffer, int offset, int count)
{
if (written + count > limit)
throw new IOException("injected late report write failure");
written += count;
inner.Write(buffer, offset, count);
}
}
}
@@ -0,0 +1,167 @@
using System.Text.Json;
using OpenNest.CNC;
using OpenNest.Geometry;
using OpenNest.IO;
using OpenNest.Reporting;
namespace OpenNest.Tests.Reporting;
public class NestReportBuilderTests
{
[Fact]
public void Capture_ReconcilesOrderedReferenceUnionWithoutCountingCutoffs()
{
var nest = NestReportTestData.CreateNest();
var snapshot = NestReportBuilder.Capture(nest, NestReportTestData.GeneratedAt);
Assert.Equal("Report test job", snapshot.Name);
Assert.Equal("Test customer", snapshot.Customer);
Assert.Equal("Synthetic report test data", snapshot.Notes);
Assert.Equal("Steel", snapshot.Material);
Assert.Equal("A36", snapshot.Grade);
Assert.Equal(0.125, snapshot.Thickness);
Assert.Equal("in", snapshot.Units);
Assert.Equal(NestReportTestData.GeneratedAt, snapshot.GeneratedAt);
Assert.Equal(new[] { "R001", "R002", "R003", "R004" }, snapshot.Drawings.Select(d => d.Id));
Assert.Equal(new[] { "Bracket", "Rotated", "Bracket", "Unplaced" }, snapshot.Drawings.Select(d => d.Name));
Assert.Equal(new long[] { 5, 1, 7, 3 }, snapshot.Drawings.Select(d => d.Required));
Assert.Equal(new long[] { 4, 2, 2, 0 }, snapshot.Drawings.Select(d => d.Nested));
Assert.Equal(new long[] { 1, 0, 5, 3 }, snapshot.Drawings.Select(d => d.Shortage));
Assert.Equal(new long[] { 0, 1, 0, 0 }, snapshot.Drawings.Select(d => d.Extra));
Assert.All(snapshot.Drawings.Take(3), d => Assert.Equal(new[] { 1 }, d.Plates));
Assert.Empty(snapshot.Drawings[3].Plates);
var plate = Assert.Single(snapshot.Plates);
Assert.Equal(new[] { "R001", "R001", "R002", "R003" }, plate.Parts.Select(p => p.ReportId));
Assert.Single(plate.Cutoffs);
Assert.Equal(2, snapshot.TotalSheets);
Assert.Equal(new ReportBounds(0, 0, 48, 24), plate.Bounds);
Assert.Equal(0.125, plate.PartSpacing);
Assert.Equal(nest.Plates[0].Utilization(), plate.Utilization);
}
[Fact]
public void Capture_RecountsAcrossPlateCopiesWithWideIntegersAndUniquePlateNumbers()
{
var nest = NestReportTestData.CreateNest();
var drawing = nest.Plates[0].Parts[0].BaseDrawing;
// Change copies after adding: the legacy cached Nested count stays stale.
nest.Plates[0].Quantity = int.MaxValue;
var second = new Plate(24, 48) { Quantity = 3 };
second.Parts.Add(new Part(drawing));
second.Parts.Add(new Part(drawing));
nest.Plates.Add(second);
var cached = drawing.Quantity.Nested;
var snapshot = NestReportBuilder.Capture(nest, NestReportTestData.GeneratedAt);
Assert.Equal(checked(2L * int.MaxValue + 6), snapshot.Drawings[0].Nested);
Assert.Equal(new[] { 1, 2 }, snapshot.Drawings[0].Plates);
Assert.Equal(checked((long)int.MaxValue + 3), snapshot.TotalSheets);
Assert.Equal(cached, drawing.Quantity.Nested);
}
[Fact]
public void Capture_IsRepeatableDoesNotMutateSourcesAndRetainsNoMutableReferences()
{
var nest = NestReportTestData.CreateNest();
var part = nest.Plates[0].Parts[0];
var drawing = part.BaseDrawing;
var source = Fingerprint(nest);
var snapshot = NestReportBuilder.Capture(nest, NestReportTestData.GeneratedAt);
var serialized = JsonSerializer.Serialize(snapshot);
Assert.Equal(source, Fingerprint(nest));
Assert.Equal(serialized, JsonSerializer.Serialize(NestReportBuilder.Capture(nest, NestReportTestData.GeneratedAt)));
Assert.NotEmpty(snapshot.Plates);
nest.Name = "Edited";
nest.Material.Name = "Edited material";
nest.Plates[0].Size = new Size(100, 200);
nest.Plates[0].Quantity = 42;
part.Location = new Vector(500, 600);
((LinearMove)part.Program.Codes[1]).EndPoint = new Vector(99, 99);
((LinearMove)drawing.Program.Codes[1]).EndPoint = new Vector(88, 88);
drawing.Name = "Edited drawing";
drawing.Quantity.Required = 123;
nest.Drawings.Clear();
nest.Plates.Clear();
Assert.Equal(serialized, JsonSerializer.Serialize(snapshot));
}
[Fact]
public void Capture_EmptyAndDemandOnlyJobsKeepExplicitEmptyCollectionsAndOrdinalOrder()
{
var nest = new Nest("Empty test");
var empty = NestReportBuilder.Capture(nest, NestReportTestData.GeneratedAt);
Assert.Equal("Empty test", empty.Name);
Assert.Empty(empty.Drawings);
Assert.Empty(empty.Plates);
Assert.Equal(0, empty.TotalSheets);
nest.Drawings.Add(NestReportTestData.Rectangle("zeta", 1, 1, 5));
nest.Drawings.Add(NestReportTestData.Rectangle("Alpha", 1, 1, 2));
var demand = NestReportBuilder.Capture(nest, NestReportTestData.GeneratedAt);
Assert.Equal(new[] { "Alpha", "zeta" }, demand.Drawings.Select(d => d.Name));
Assert.All(demand.Drawings, d => { Assert.Equal(0, d.Nested); Assert.Equal(d.Required, d.Shortage); });
Assert.Empty(demand.Plates);
}
[Theory]
[InlineData(0, 48, 1)]
[InlineData(24, -1, 1)]
[InlineData(double.NaN, 48, 1)]
[InlineData(24, double.PositiveInfinity, 1)]
[InlineData(24, 48, 0)]
[InlineData(24, 48, -1)]
public void Capture_RejectsInvalidPlateBeforeProducingSnapshot(double width, double length, int copies)
{
var nest = NestReportTestData.CreateNest();
nest.Plates[0].Size = new Size(width, length);
nest.Plates[0].Quantity = copies;
var before = Fingerprint(nest);
var error = Assert.Throws<InvalidOperationException>(() => NestReportBuilder.Capture(nest, NestReportTestData.GeneratedAt));
Assert.Contains("Plate 1", error.Message);
Assert.Equal(before, Fingerprint(nest));
}
[Theory]
[InlineData(1, 0, 0, 48, 24)]
[InlineData(2, -48, 0, 0, 24)]
[InlineData(3, -48, -24, 0, 0)]
[InlineData(4, 0, -24, 48, 0)]
public void Capture_UsesQuadrantAwareStockBounds(int quadrant, double left, double bottom, double right, double top)
{
var nest = NestReportTestData.CreateNest();
nest.Plates[0].Quadrant = quadrant;
var snapshot = NestReportBuilder.Capture(nest, NestReportTestData.GeneratedAt);
Assert.Equal(new ReportBounds(left, bottom, right, top), Assert.Single(snapshot.Plates).Bounds);
}
internal static string Fingerprint(Nest nest) => JsonSerializer.Serialize(new
{
nest.Name,
nest.Customer,
nest.Notes,
nest.Material,
nest.Thickness,
nest.Units,
nest.DateCreated,
nest.DateLastModified,
Drawings = nest.Drawings.Select(d => new { d.Id, d.Name, d.Quantity, Main = NestWriter.GetProgramText(d.Program), Subs = NestWriter.GetSubProgramsText(d.Program) }),
Plates = nest.Plates.Select(p => new
{
p.Size,
p.Quantity,
p.PartSpacing,
p.Quadrant,
Parts = p.Parts.Select(part => new
{
part.BaseDrawing.Id,
part.Location,
part.Rotation,
part.HasManualLeadIns,
part.LeadInsLocked,
Main = NestWriter.GetProgramText(part.Program),
Subs = NestWriter.GetSubProgramsText(part.Program),
}),
}),
}, new JsonSerializerOptions { IncludeFields = true, NumberHandling = System.Text.Json.Serialization.JsonNumberHandling.AllowNamedFloatingPointLiterals });
}
@@ -0,0 +1,162 @@
using System.Text.Json;
using OpenNest.CNC;
using OpenNest.Converters;
using OpenNest.Geometry;
using OpenNest.IO;
using OpenNest.Reporting;
namespace OpenNest.Tests.Reporting;
public class NestReportGeometryTests
{
[Fact]
public void Capture_PreservesNativeCircleHoleAndAppliesBakedRotationAndLocationOnce()
{
var nest = NestReportTestData.CreateNest();
var snapshot = NestReportBuilder.Capture(nest, NestReportTestData.GeneratedAt);
var holed = snapshot.Plates[0].Parts[0].Geometry;
Assert.False(holed.StrokeOnly);
Assert.Equal(2, holed.Contours.Length);
Assert.All(holed.Contours, c => Assert.True(c.Closed));
var circle = Assert.Single(holed.Contours.SelectMany(c => c.Segments).Where(s => s.Center != null));
Assert.Equal(new ReportPoint(7, 7), circle.Center);
Assert.Equal(1, circle.Radius);
Assert.Equal(-2 * System.Math.PI, circle.SweepAngle);
Assert.Equal(new ReportBounds(2, 2, 12, 12), holed.Bounds);
var turned = snapshot.Plates[0].Parts[2].Geometry;
AssertBounds(new ReportBounds(28, 2, 30, 6), turned.Bounds);
AssertBounds(new ReportBounds(0, 0, 4, 2), snapshot.Drawings[1].Geometry.Bounds);
}
[Fact]
public void Capture_RealTabbedLeadInHoleRemainsOpenAndNeverMutatesPrograms()
{
var nest = NestReportTestData.CreateTabbedNest();
var part = nest.Plates[0].Parts[0];
var converted = ConvertProgram.ToGeometry(part.Program);
Assert.Contains(converted, e => e.Layer == SpecialLayers.Leadin);
Assert.Contains(converted, e => e.Layer == SpecialLayers.Leadout);
Assert.NotEmpty(part.Program.SubPrograms);
// Deliberately stale settings must not control closure detection.
part.CuttingParameters.TabsEnabled = false;
var before = NestReportBuilderTests.Fingerprint(nest);
var snapshot = NestReportBuilder.Capture(nest, NestReportTestData.GeneratedAt);
var geometry = Assert.Single(snapshot.Plates[0].Parts).Geometry;
Assert.True(geometry.StrokeOnly);
var open = Assert.Single(geometry.Contours.Where(c => !c.Closed));
Assert.NotEqual(open.Segments[0].Start, open.Segments[^1].End);
var gapX = open.Segments[^1].End.X - open.Segments[0].Start.X;
var gapY = open.Segments[^1].End.Y - open.Segments[0].Start.Y;
Assert.Equal(0.15, System.Math.Sqrt(gapX * gapX + gapY * gapY), 6);
Assert.Single(geometry.Contours.Where(c => c.Closed));
AssertBounds(new ReportBounds(10, 5, 20, 15), geometry.Bounds);
var hole = Assert.Single(geometry.Contours.SelectMany(c => c.Segments).Where(s => s.Center != null));
Assert.Equal(15, hole.Center!.X, 6);
Assert.Equal(10, hole.Center.Y, 6);
var outlineCount = converted.Count(e => SpecialLayers.IsMaterial(e.Layer) && e.Layer != SpecialLayers.Leadin && e.Layer != SpecialLayers.Leadout);
Assert.Equal(outlineCount, geometry.Contours.Sum(c => c.Segments.Length));
Assert.False(snapshot.Drawings[0].Geometry.StrokeOnly);
Assert.Equal(before, NestReportBuilderTests.Fingerprint(nest));
}
[Fact]
public void Capture_PreservesNativeArcSweepAndTrueBounds()
{
var nest = NestReportTestData.CreateNest();
var program = nest.Plates[0].Parts[0].Program;
program.Codes.Clear();
program.Codes.Add(new RapidMove(2, 0));
program.Codes.Add(new ArcMove(0, 2, 0, 0, RotationType.CCW));
var geometry = NestReportBuilder.Capture(nest, NestReportTestData.GeneratedAt).Plates[0].Parts[0].Geometry;
var arc = Assert.Single(Assert.Single(geometry.Contours).Segments);
Assert.Equal(new ReportPoint(2, 2), arc.Center);
Assert.Equal(2, arc.Radius);
Assert.Equal(0, arc.StartAngle);
Assert.Equal(System.Math.PI / 2, arc.SweepAngle);
AssertBounds(new ReportBounds(2, 2, 4, 4), geometry.Bounds);
Assert.True(geometry.StrokeOnly);
}
[Fact]
public void Capture_ExcludedLayersCannotConnectSeparateMaterialPaths()
{
var nest = NestReportTestData.CreateNest();
var codes = nest.Plates[0].Parts[0].Program.Codes;
codes.Clear();
codes.Add(new RapidMove(0, 0));
codes.Add(new LinearMove(1, 0) { Layer = LayerType.Display });
codes.Add(new LinearMove(1, 1) { Layer = LayerType.Scribe });
codes.Add(new LinearMove(2, 1));
codes.Add(new LinearMove(100, 100) { Layer = LayerType.Leadout });
var geometry = NestReportBuilder.Capture(nest, NestReportTestData.GeneratedAt).Plates[0].Parts[0].Geometry;
Assert.Equal(2, geometry.Contours.Length);
Assert.All(geometry.Contours, c => { Assert.False(c.Closed); Assert.Single(c.Segments); });
Assert.Equal(new ReportBounds(2, 2, 4, 3), geometry.Bounds);
}
[Theory]
[InlineData("missing")]
[InlineData("nonfinite")]
[InlineData("arc-center")]
[InlineData("zero-radius")]
[InlineData("unequal-radii")]
[InlineData("rotation")]
[InlineData("unsupported")]
[InlineData("null-code")]
[InlineData("null-subprogram")]
[InlineData("cyclic-subprogram")]
[InlineData("nonfinite-offset")]
public void Capture_RejectsMalformedPlacedGeometryWithIdentification(string damage)
{
var nest = NestReportTestData.CreateNest();
var part = nest.Plates[0].Parts[0];
var codes = part.Program.Codes;
switch (damage)
{
case "missing": codes.Clear(); break;
case "nonfinite": ((LinearMove)codes[1]).EndPoint = new Vector(double.NaN, 1); break;
case "arc-center": codes.Add(new ArcMove(1, 0, double.PositiveInfinity, 0)); break;
case "zero-radius": codes.Add(new ArcMove(6, 5, 6, 5)); break;
case "unequal-radii": codes.Add(new ArcMove(10, 5, 5, 5)); break;
case "rotation": codes.Add(new ArcMove(6, 5, 5, 5, (RotationType)42)); break;
case "unsupported": codes.Add(new UnsupportedCode()); break;
case "null-code": codes.Add(null!); break;
case "null-subprogram": codes.Add(new SubProgramCall { Id = 42 }); break;
case "cyclic-subprogram": codes.Add(new SubProgramCall { Id = 42, Program = part.Program }); break;
case "nonfinite-offset": part.Location = new Vector(double.NaN, 0); break;
}
var count = codes.Count;
var error = Assert.Throws<InvalidOperationException>(() => NestReportBuilder.Capture(nest, NestReportTestData.GeneratedAt));
Assert.Contains("Plate 1", error.Message);
Assert.Contains("part 1", error.Message);
Assert.Contains("Bracket", error.Message);
Assert.Equal(count, codes.Count);
}
[Fact]
public void Capture_RejectsUnrenderableDemandOnlyDrawingByName()
{
var nest = new Nest();
var drawing = new Drawing("Missing demand geometry");
drawing.Quantity.Required = 3;
nest.Drawings.Add(drawing);
var error = Assert.Throws<InvalidOperationException>(() => NestReportBuilder.Capture(nest, NestReportTestData.GeneratedAt));
Assert.Contains("Missing demand geometry", error.Message);
}
private static void AssertBounds(ReportBounds expected, ReportBounds actual)
{
Assert.Equal(expected.Left, actual.Left, 6);
Assert.Equal(expected.Bottom, actual.Bottom, 6);
Assert.Equal(expected.Right, actual.Right, 6);
Assert.Equal(expected.Top, actual.Top, 6);
}
private sealed class UnsupportedCode : ICode
{
public CodeType Type => (CodeType)999;
public ICode Clone() => new UnsupportedCode();
}
}
@@ -0,0 +1,101 @@
using OpenNest.CNC;
using OpenNest.CNC.CuttingStrategy;
using OpenNest.Geometry;
namespace OpenNest.Tests.Reporting;
/// <summary>Small, synthetic report examples; never production job data.</summary>
public static class NestReportTestData
{
public static DateTimeOffset GeneratedAt { get; } = new(2026, 9, 29, 12, 0, 0, TimeSpan.Zero);
public static Nest CreateNest()
{
var nest = new Nest("Report test job")
{
Customer = "Test customer",
Notes = "Synthetic report test data",
Material = new Material("Steel", "A36"),
Thickness = 0.125,
Units = Units.Inches,
DateCreated = new DateTime(2026, 9, 1),
DateLastModified = new DateTime(2026, 9, 28),
};
var holed = CreateHoledDrawing("Bracket", 5);
var rotated = Rectangle("Rotated", 4, 2, 1);
var sameName = Rectangle("Bracket", 3, 2, 7);
var unplaced = Rectangle("Unplaced", 2, 1, 3);
nest.Drawings.Add(unplaced);
nest.Drawings.Add(rotated);
nest.Drawings.Add(holed);
// DrawingCollection is name-keyed; this reference deliberately exists only in placements.
var plate = new Plate(24, 48) { Quantity = 2, PartSpacing = 0.125 };
plate.Parts.Add(new Part(holed, new Vector(2, 2)));
plate.Parts.Add(new Part(holed, new Vector(14, 2)));
var turned = new Part(rotated);
turned.Rotate(System.Math.PI / 2);
turned.Location = new Vector(30, 2);
plate.Parts.Add(turned);
plate.Parts.Add(new Part(sameName, new Vector(32, 2)));
var cutoffProgram = new Program();
cutoffProgram.Codes.Add(new RapidMove(40, 0));
cutoffProgram.Codes.Add(new LinearMove(40, 24));
var cutoff = new Drawing("Cutoff test", cutoffProgram) { IsCutOff = true };
cutoff.Quantity.Required = 99;
plate.Parts.Add(new Part(cutoff));
nest.Drawings.Add(cutoff);
nest.Plates.Add(plate);
return nest;
}
public static Nest CreateTabbedNest()
{
var drawing = CreateHoledDrawing("Tabbed with hole", 1);
var part = new Part(drawing);
part.Rotate(System.Math.PI / 2);
part.Offset(20, 5);
part.ApplyLeadIns(new CuttingParameters
{
ExternalLeadIn = new LineLeadIn { Length = 0.5, ApproachAngle = 90 },
ExternalLeadOut = new LineLeadOut { Length = 0.25 },
ArcCircleLeadIn = new LineLeadIn { Length = 0.3, ApproachAngle = 90 },
TabsEnabled = true,
TabConfig = new NormalTab { Size = 0.15 },
}, new Vector(-5, -5));
part.LeadInsLocked = true;
var nest = new Nest("Tabbed report test") { Units = Units.Inches };
nest.Drawings.Add(drawing);
var plate = new Plate(24, 48) { Quantity = 1 };
plate.Parts.Add(part);
nest.Plates.Add(plate);
return nest;
}
public static Drawing CreateHoledDrawing(string name, int required)
{
var program = RectangleProgram(10, 10);
program.Codes.Add(new RapidMove(6, 5));
program.Codes.Add(new ArcMove(new Vector(6, 5), new Vector(5, 5), RotationType.CW));
var drawing = new Drawing(name, program);
drawing.Quantity.Required = required;
return drawing;
}
public static Drawing Rectangle(string name, double length, double width, int required = 1)
{
var drawing = new Drawing(name, RectangleProgram(length, width));
drawing.Quantity.Required = required;
return drawing;
}
private static Program RectangleProgram(double length, double width)
{
var program = new Program();
program.Codes.Add(new RapidMove(0, 0));
program.Codes.Add(new LinearMove(0, width));
program.Codes.Add(new LinearMove(length, width));
program.Codes.Add(new LinearMove(length, 0));
program.Codes.Add(new LinearMove(0, 0));
return program;
}
}
@@ -0,0 +1,47 @@
using OpenNest.Reporting;
using PdfSharp.Drawing;
using PdfSharp.Fonts;
using PdfSharp.Pdf;
namespace OpenNest.Tests.Reporting;
public class ReportFontsTests
{
[Fact]
public void BundledFontsEmbedWithoutSystemFontResolutionAcrossRepeatedExports()
{
ReportFonts.Initialize();
var resolver = GlobalFontSettings.FontResolver;
for (var i = 0; i < 2; i++)
{
ReportFonts.Initialize();
Assert.Same(resolver, GlobalFontSettings.FontResolver);
using var doc = new PdfDocument();
var page = doc.AddPage();
using (var gfx = XGraphics.FromPdfPage(page))
{
gfx.DrawString("Résumé Ø TEST DATA", new XFont(ReportFonts.Family, 10), XBrushes.Black, 20, 20);
gfx.DrawString("Bold", new XFont(ReportFonts.Family, 10, XFontStyleEx.Bold), XBrushes.Black, 20, 40);
}
using var stream = new MemoryStream();
doc.Save(stream, false);
Assert.True(stream.Length > 1000);
}
}
[Theory]
[InlineData("ASCII, 0.25 in; R001\nRésumé Ø Æ")]
[InlineData("")]
public void SupportedTextPasses(string text) => ReportFonts.ValidateText(text, "part R001");
[Theory]
[InlineData("中文")]
[InlineData("bad\u0000text")]
[InlineData("hidden\u00ADhyphen")]
public void UnsupportedTextHasAnIdentifiedErrorInsteadOfMissingGlyphs(string text)
{
var error = Assert.Throws<InvalidOperationException>(() => ReportFonts.ValidateText(text, "part R001"));
Assert.Contains("part R001", error.Message);
Assert.Contains("U+", error.Message);
}
}
+14
View File
@@ -46,6 +46,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OpenNest.WinForms.Tests", "
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OpenNest.FrontEnd.Tests", "OpenNest.FrontEnd.Tests\OpenNest.FrontEnd.Tests.csproj", "{0CD494FD-F37F-420D-B2C6-DC1E0D50BCEB}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OpenNest.Reporting", "OpenNest.Reporting\OpenNest.Reporting.csproj", "{5C3708F5-DD69-436D-B412-7FF360DDB3DC}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -284,6 +286,18 @@ Global
{0CD494FD-F37F-420D-B2C6-DC1E0D50BCEB}.Release|x64.Build.0 = Release|Any CPU
{0CD494FD-F37F-420D-B2C6-DC1E0D50BCEB}.Release|x86.ActiveCfg = Release|Any CPU
{0CD494FD-F37F-420D-B2C6-DC1E0D50BCEB}.Release|x86.Build.0 = Release|Any CPU
{5C3708F5-DD69-436D-B412-7FF360DDB3DC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{5C3708F5-DD69-436D-B412-7FF360DDB3DC}.Debug|Any CPU.Build.0 = Debug|Any CPU
{5C3708F5-DD69-436D-B412-7FF360DDB3DC}.Debug|x64.ActiveCfg = Debug|Any CPU
{5C3708F5-DD69-436D-B412-7FF360DDB3DC}.Debug|x64.Build.0 = Debug|Any CPU
{5C3708F5-DD69-436D-B412-7FF360DDB3DC}.Debug|x86.ActiveCfg = Debug|Any CPU
{5C3708F5-DD69-436D-B412-7FF360DDB3DC}.Debug|x86.Build.0 = Debug|Any CPU
{5C3708F5-DD69-436D-B412-7FF360DDB3DC}.Release|Any CPU.ActiveCfg = Release|Any CPU
{5C3708F5-DD69-436D-B412-7FF360DDB3DC}.Release|Any CPU.Build.0 = Release|Any CPU
{5C3708F5-DD69-436D-B412-7FF360DDB3DC}.Release|x64.ActiveCfg = Release|Any CPU
{5C3708F5-DD69-436D-B412-7FF360DDB3DC}.Release|x64.Build.0 = Release|Any CPU
{5C3708F5-DD69-436D-B412-7FF360DDB3DC}.Release|x86.ActiveCfg = Release|Any CPU
{5C3708F5-DD69-436D-B412-7FF360DDB3DC}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
+116
View File
@@ -0,0 +1,116 @@
# Nest report PDF library
The first delivery is a bounded, cross-platform library slice, not yet a desktop
menu command or a general report layout engine. Capture a stable `Nest` on its
owning thread, then render only the detached snapshot:
```csharp
var snapshot = NestReportBuilder.Capture(nest, DateTimeOffset.Now);
NestPdfWriter.Write(snapshot, destinationPath);
```
Reporting does not select a post, generate CNC, refresh drawing quantities, change
the selected plate, or certify that a layout passed geometry/pre-post checks.
## Snapshot contract
- IDs (`R001`, ...) are local to the report. Visit non-cutoff placements in plate
and part order, then append unseen demanded drawings ordered by ordinal name and
source path. Identity is by reference: distinct same-named drawings stay distinct,
including placed drawings absent from the name-keyed drawing collection.
- Recount nested quantities from placements times sheet copies exactly once using
checked wide integers. Include unplaced demand; report shortage and extra
separately. Cached `Quantity.Nested` is not authoritative.
- Distinguish distinct layouts from physical sheets. Per-sheet quantities do not
include copies; total quantities do. Utilization uses net part area divided by
full sheet area, excluding cutoffs.
- Actual placed programs already contain rotations. Capture their material paths
with the location applied once. Preserve holes and intentional tab gaps; exclude
rapid, scribe, lead-in and lead-out paths. Keep Cut and Display contours.
- If any material contour of a part is open, show all its contours without fill.
Do not close the gap or substitute the drawing's nominal outline. Cutoffs are
separate strokes with no product ID or quantity.
- Snapshots retain values, not live drawings, parts, CNC programs or mutable
geometry. Invalid/missing/nonfinite geometry fails with drawing/plate context.
## Initial layout and safety limits
Slice 1 supports an empty/demand-only job or one plate layout: a Letter portrait
summary page and a Letter landscape plate page, each with "Page X of Y". The
writer rejects, with `NotSupportedException` and before touching the destination:
- more than one distinct plate layout;
- a summary (job fields, plate list, part rows with thumbnails) longer than one page;
- a plate page (header, diagram, part table) longer than one page;
- a part ID label that does not fit inside its part's fitted bounds at 7 pt, or
that overlaps another label.
It never shrinks text, truncates rows or drops labels to make a layout fit.
Multi-plate pagination, dense-label callouts and the desktop command belong to
subsequent slices. Labels are centered on the part's bounds, which can place them
inside a central hole; smarter placement is part of the dense-label work.
Summary thumbnails and the sheet diagram are vector paths, never raster images.
Each thumbnail is a small PDFsharp page embedded by MigraDoc as a form XObject.
The diagram is drawn into a fixed-height table row reserved in MigraDoc's flow,
located after layout with `DocumentRenderer.GetRenderInfoFromPage`. Closed parts
use one even-odd filled path, so holes stay unfilled. Parts with any open contour,
and cutoffs (dashed), stroke each contour as its own path: PDFsharp's
`StartFigure` does not start a new subpath after an open figure, and would
otherwise draw a false segment across a tab gap.
A report is fully rendered to a unique temporary sibling before replacement of
its destination. A failed render or write leaves an existing report untouched and
removes temporary output. Applications should obtain overwrite consent before
calling the library.
Advanced timing, cutting distances, pierce counts, weights, costs, gas use, and
machine/NC identity are intentionally omitted until their semantics are verified.
The PDF can be opened or printed through a normal PDF viewer; its fitted diagram
is not a dimensioned cutting drawing.
## Backend, fonts and redistribution
- Official `PDFsharp-MigraDoc` Core **6.2.4**, with `PDFsharp` **6.2.4**, targeting
net8.0. No GDI/WPF dependency or printer driver is used. MigraDoc owns flowing
document layout; PDFsharp draws vectors into its measured reserved areas.
- DejaVu Sans **2.37**, regular and bold, is bundled unmodified as assembly
resources. Source: https://dejavu-fonts.github.io/ ; binary provenance:
Ubuntu `fonts-dejavu-core` **2.37-8**. Both required faces are embedded in PDFs;
neither generation nor viewing needs a platform font installation.
- A custom resolver is installed once before fonts are created. Repeated exports
reuse it. An already-installed foreign resolver is an explicit integration
error, never silently replaced. A host that already uses PDFsharp needs resolver
composition before adding report support. MigraDoc's predefined error font is
pointed at the bundled family; its default (`Courier New`) is a platform font
the bundled resolver deliberately cannot supply.
- The initial text contract is printable ASCII/Latin-1 and line/tab separators.
Other codepoints, controls and soft hyphens fail with the field and codepoint;
there is no silent missing-glyph substitution. Broader Unicode/shaping support
is deferred rather than implied by the font's larger character inventory.
- PDFsharp/MigraDoc are MIT-licensed. The actual v6.2.4 license and the Microsoft
transitive dependency notices are in `OpenNest.Reporting/THIRD-PARTY-NOTICES.txt`.
Font permission/redistribution terms are in `OpenNest.Reporting/Fonts/LICENSE.txt`
(Bitstream Vera permission with DejaVu changes in the public domain). Preserve
both files in `ReportingNotices/` when publishing; embedded fonts do not replace
the obligation to ship their notices. Package notices are deduplicated, not
rewritten or shortened.
## Verification
```sh
dotnet test OpenNest.Tests/OpenNest.Tests.csproj --filter FullyQualifiedName~Reporting
```
The writer tests read page sizes and content streams with PDFsharp. Text
assertions extract with Poppler's `pdftotext -layout` and are skipped, not
passed, when poppler-utils is absent. For manual inspection also use `pdfinfo`,
`pdffonts` and `pdftoppm -png` (Linux verification baseline: poppler-utils
24.02.0). Confirm page sizes, quantity rows, page X of Y, embedded fonts, native
vector curves, unfilled holes and visible tab gaps with no connecting stroke.
Pixel inspection supplements geometry assertions; it is not a CNC-validation
result.
Windows compilation is not runtime acceptance. The later desktop adapter must be
tested on Windows for busy-operation guards, cancellation, overwrite handling,
unchanged job state and PDF viewing/printing, including the packaged application.