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